diff --git a/.github/actions/run_tests/action.yml b/.github/actions/run_tests/action.yml index a071aba3a87dcf8e8f48f740115cfddf48b9f805..610c334a65c3a3817ab0ee2bb7356a923643092b 100644 --- a/.github/actions/run_tests/action.yml +++ b/.github/actions/run_tests/action.yml @@ -5,7 +5,7 @@ runs: using: "composite" steps: - name: Install nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c # nextest - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/actions/run_tests_windows/action.yml b/.github/actions/run_tests_windows/action.yml index 307b73f363b7d5fd7a3c9e5082c4f17d622ec165..3752cbb50d538459ea58d2219e591d1abbda6247 100644 --- a/.github/actions/run_tests_windows/action.yml +++ b/.github/actions/run_tests_windows/action.yml @@ -12,7 +12,7 @@ runs: steps: - name: Install test runner working-directory: ${{ inputs.working-directory }} - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c # nextest - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b8b7939813f9cc72da88e75653b6f2933403a239..a56793ad6222e5788621f6c8a430205e9ad848d7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,28 +1,13 @@ -## Context +Self-Review Checklist: - - -## How to Review - - - -## Self-Review Checklist - - - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable +Closes #ISSUE + Release Notes: - N/A or Added/Fixed/Improved ... diff --git a/.github/workflows/assign-reviewers.yml b/.github/workflows/assign-reviewers.yml index c16a363db18c9ac11f000ad65961a165db43c982..2a12a69defdd4f8933f1c549f0624d9bdcc9fd40 100644 --- a/.github/workflows/assign-reviewers.yml +++ b/.github/workflows/assign-reviewers.yml @@ -83,6 +83,8 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} PR_URL: ${{ github.event.pull_request.html_url }} TARGET_REPO: ${{ github.repository }} + ASSIGN_INTERNAL: ${{ vars.ASSIGN_INTERNAL || 'false' }} + ASSIGN_EXTERNAL: ${{ vars.ASSIGN_EXTERNAL || 'true' }} run: | cd codeowner-coordinator python .github/scripts/assign-reviewers.py \ diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml index 36a459c94b9ea2e35b683bb957d33db362bee262..f055c078cf4f814e342697e311ad5660f68f4624 100644 --- a/.github/workflows/autofix_pr.yml +++ b/.github/workflows/autofix_pr.yml @@ -31,7 +31,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/background_agent_mvp.yml b/.github/workflows/background_agent_mvp.yml index f8c654a293c26e50ccd5194742d7a6977009fb48..2f048d572df6fb45368c6d7aece574e83c9e7949 100644 --- a/.github/workflows/background_agent_mvp.yml +++ b/.github/workflows/background_agent_mvp.yml @@ -50,7 +50,7 @@ jobs: "${HOME}/.local/bin/droid" --version - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" diff --git a/.github/workflows/community_champion_auto_labeler.yml b/.github/workflows/community_champion_auto_labeler.yml index fa44afc16dcaee4c1e1176b9344aed476ac6d8e5..82a9e274d64725b0e55c6ced46ca64ac3890e35e 100644 --- a/.github/workflows/community_champion_auto_labeler.yml +++ b/.github/workflows/community_champion_auto_labeler.yml @@ -12,7 +12,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: Check if author is a community champion and apply label - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: COMMUNITY_CHAMPIONS: | 0x2CA diff --git a/.github/workflows/compare_perf.yml b/.github/workflows/compare_perf.yml index f6c4253573364269b5b28ee9773a3885381ddfe2..2b2154ce9bd14c85d0f0d10e95c4065a458006a1 100644 --- a/.github/workflows/compare_perf.yml +++ b/.github/workflows/compare_perf.yml @@ -33,7 +33,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: compare_perf::run_perf::install_hyperfine - uses: taiki-e/install-action@hyperfine + uses: taiki-e/install-action@b4f2d5cb8597b15997c8ede873eb6185efc5f0ad - name: steps::git_checkout run: git fetch origin "$REF_NAME" && git checkout "$REF_NAME" env: diff --git a/.github/workflows/congrats.yml b/.github/workflows/congrats.yml index 6a4111a1c5b5143ee9be067911207d5b4ca1448c..4866b3c33bc6bab9f9d20ac1701b7d6535b356ee 100644 --- a/.github/workflows/congrats.yml +++ b/.github/workflows/congrats.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Get PR info and check if author is external id: check - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: github-token: ${{ secrets.CONGRATSBOT_GITHUB_TOKEN }} script: | @@ -29,6 +29,13 @@ jobs: } const mergedPR = prs.find(pr => pr.merged_at !== null) || prs[0]; + + if (mergedPR.user.type === "Bot") { + // They are a good bot, but not good enough to be congratulated + core.setOutput('should_congratulate', 'false'); + return; + } + const prAuthor = mergedPR.user.login; try { @@ -50,7 +57,7 @@ jobs: congrats: needs: check-author if: needs.check-author.outputs.should_congratulate == 'true' - uses: withastro/automation/.github/workflows/congratsbot.yml@main + uses: withastro/automation/.github/workflows/congratsbot.yml@a5bd0c5748c4d56e687cdd558064f9ee8adfb1f2 # main with: EMOJIS: 🎉,🎊,🧑‍🚀,🥳,🙌,🚀,🦀,🔥,🚢 secrets: diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index 9ba1ee7d8be38b1fd3b3147c679afde03b98dcd7..5a3eff186814128ebb3973642040d9228f0e87fd 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -26,7 +26,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -57,7 +57,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -66,7 +66,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: deploy_collab::tests::run_collab_tests diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index 72bc340a814e340ef8e716e3db4cb156aee40e8f..b4cbac4ec8c0ab37ebad73eb96c2ee074ca969a6 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -84,7 +84,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -187,7 +187,7 @@ jobs: CURRENT_VERSION: ${{ needs.check_version_changed.outputs.current_version }} WORKING_DIR: ${{ inputs.working-directory }} - name: extension_bump::create_version_tag - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b with: script: |- github.rest.git.createRef({ @@ -239,7 +239,7 @@ jobs: env: COMMITTER_TOKEN: ${{ steps.generate-token.outputs.token }} - name: extension_bump::enable_automerge_if_staff - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b with: github-token: ${{ steps.generate-token.outputs.token }} script: | diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 066e1ab1739a0fabb0d6ce8f0f7f4832cfbdc228..622f4c8f1034b4ec0c7625a361ecdb6fb84d9429 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -77,7 +77,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -97,7 +97,7 @@ jobs: env: PACKAGE_NAME: ${{ steps.get-package-name.outputs.package_name }} - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: extension_tests::run_nextest run: 'cargo nextest run -p "$PACKAGE_NAME" --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n ''s|host: ||p'')"' env: @@ -131,7 +131,7 @@ jobs: wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" -O "$GITHUB_WORKSPACE/zed-extension" chmod +x "$GITHUB_WORKSPACE/zed-extension" - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml index 4dfaf708f738ef5b5fe8d8687d80690af040eba9..5bb315a730d8f25f6e1eccbbe5e1734e1cda6d99 100644 --- a/.github/workflows/extension_workflow_rollout.yml +++ b/.github/workflows/extension_workflow_rollout.yml @@ -57,7 +57,7 @@ jobs: PREV_COMMIT: ${{ steps.prev-tag.outputs.prev_commit }} - id: list-repos name: extension_workflow_rollout::fetch_extension_repos::get_repositories - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b with: script: | const repos = await github.paginate(github.rest.repos.listForOrg, { @@ -81,7 +81,7 @@ jobs: return filteredRepos; result-encoding: json - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/pr-size-check.yml b/.github/workflows/pr-size-check.yml deleted file mode 100644 index 6cbed314e012c66da16fd016dd9b3cdcf9788149..0000000000000000000000000000000000000000 --- a/.github/workflows/pr-size-check.yml +++ /dev/null @@ -1,109 +0,0 @@ -# PR Size Check — Compute -# -# Calculates PR size and saves the result as an artifact. A companion -# workflow (pr-size-label.yml) picks up the artifact via workflow_run -# and applies labels + comments with write permissions. -# -# This two-workflow split is required because fork PRs receive a -# read-only GITHUB_TOKEN. The compute step needs no write access; -# the label/comment step runs via workflow_run on the base repo with -# full write permissions. -# -# Security note: This workflow only reads PR file data via the JS API -# and writes a JSON artifact. No untrusted input is interpolated into -# shell commands. - -name: PR Size Check - -on: - pull_request: - types: [opened, synchronize] - -permissions: - contents: read - pull-requests: read - -jobs: - compute-size: - if: github.repository_owner == 'zed-industries' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Calculate PR size - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const fs = require('fs'); - - const { data: files } = await github.rest.pulls.listFiles({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - per_page: 300, - }); - - // Sum additions + deletions, excluding generated/lock files - const IGNORED_PATTERNS = [ - /\.lock$/, - /^Cargo\.lock$/, - /pnpm-lock\.yaml$/, - /\.generated\./, - /\/fixtures\//, - /\/snapshots\//, - ]; - - let totalChanges = 0; - for (const file of files) { - const ignored = IGNORED_PATTERNS.some(p => p.test(file.filename)); - if (!ignored) { - totalChanges += file.additions + file.deletions; - } - } - - // Assign size bracket - const SIZE_BRACKETS = [ - ['Size S', 0, 100, '0e8a16'], - ['Size M', 100, 400, 'fbca04'], - ['Size L', 400, 800, 'e99695'], - ['Size XL', 800, Infinity, 'b60205'], - ]; - - let sizeLabel = 'Size S'; - let labelColor = '0e8a16'; - for (const [label, min, max, color] of SIZE_BRACKETS) { - if (totalChanges >= min && totalChanges < max) { - sizeLabel = label; - labelColor = color; - break; - } - } - - // Check if the author wrote content in the "How to Review" section. - const rawBody = context.payload.pull_request.body || ''; - const howToReview = rawBody.match(/## How to Review\s*\n([\s\S]*?)(?=\n## |$)/i); - const hasReviewGuidance = howToReview - ? howToReview[1].replace(//g, '').trim().length > 0 - : false; - - const result = { - pr_number: context.issue.number, - total_changes: totalChanges, - size_label: sizeLabel, - label_color: labelColor, - has_review_guidance: hasReviewGuidance, - }; - - console.log(`PR #${result.pr_number}: ${totalChanges} LOC, ${sizeLabel}`); - - fs.mkdirSync('pr-size', { recursive: true }); - fs.writeFileSync('pr-size/result.json', JSON.stringify(result)); - - - name: Upload size result - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: pr-size-result - path: pr-size/ - retention-days: 1 -defaults: - run: - shell: bash -euxo pipefail {0} diff --git a/.github/workflows/pr-size-label.yml b/.github/workflows/pr-size-label.yml deleted file mode 100644 index 599daf122aac728c469acd45da865e1079c07fb6..0000000000000000000000000000000000000000 --- a/.github/workflows/pr-size-label.yml +++ /dev/null @@ -1,195 +0,0 @@ -# PR Size Check — Label & Comment -# -# Triggered by workflow_run after pr-size-check.yml completes. -# Downloads the size result artifact and applies labels + comments. -# -# This runs on the base repo with full GITHUB_TOKEN write access, -# so it works for both same-repo and fork PRs. -# -# Security note: The artifact is treated as untrusted data — only -# structured JSON fields (PR number, size label, color, boolean) are -# read. No artifact content is executed or interpolated into shell. - -name: PR Size Label - -on: - workflow_run: - workflows: ["PR Size Check"] - types: [completed] - -jobs: - apply-labels: - if: > - github.repository_owner == 'zed-industries' && - github.event.workflow_run.conclusion == 'success' - permissions: - contents: read - pull-requests: write - issues: write - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Download size result artifact - id: download - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const fs = require('fs'); - const path = require('path'); - - const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: context.payload.workflow_run.id, - }); - - const match = allArtifacts.data.artifacts.find(a => a.name === 'pr-size-result'); - if (!match) { - console.log('No pr-size-result artifact found, skipping'); - core.setOutput('found', 'false'); - return; - } - - const download = await github.rest.actions.downloadArtifact({ - owner: context.repo.owner, - repo: context.repo.repo, - artifact_id: match.id, - archive_format: 'zip', - }); - - const temp = path.join(process.env.RUNNER_TEMP, 'pr-size'); - fs.mkdirSync(temp, { recursive: true }); - fs.writeFileSync(path.join(temp, 'result.zip'), Buffer.from(download.data)); - core.setOutput('found', 'true'); - - - name: Unzip artifact - if: steps.download.outputs.found == 'true' - env: - ARTIFACT_DIR: ${{ runner.temp }}/pr-size - run: unzip "$ARTIFACT_DIR/result.zip" -d "$ARTIFACT_DIR" - - - name: Apply labels and comment - if: steps.download.outputs.found == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const fs = require('fs'); - const path = require('path'); - - const temp = path.join(process.env.RUNNER_TEMP, 'pr-size'); - const resultPath = path.join(temp, 'result.json'); - if (!fs.existsSync(resultPath)) { - console.log('No result.json found, skipping'); - return; - } - - const result = JSON.parse(fs.readFileSync(resultPath, 'utf8')); - - // Validate artifact data (treat as untrusted) - const prNumber = Number(result.pr_number); - const totalChanges = Number(result.total_changes); - const sizeLabel = String(result.size_label); - const labelColor = String(result.label_color); - const hasReviewGuidance = Boolean(result.has_review_guidance); - - if (!prNumber || !sizeLabel.startsWith('Size ')) { - core.setFailed(`Invalid artifact data: pr=${prNumber}, label=${sizeLabel}`); - return; - } - - console.log(`PR #${prNumber}: ${totalChanges} LOC, ${sizeLabel}`); - - // --- Size label (idempotent) --- - const existingLabels = (await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - })).data.map(l => l.name); - - const existingSizeLabels = existingLabels.filter(l => l.startsWith('Size ')); - const alreadyCorrect = existingSizeLabels.length === 1 && existingSizeLabels[0] === sizeLabel; - - if (!alreadyCorrect) { - for (const label of existingSizeLabels) { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - name: label, - }); - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: sizeLabel, - color: labelColor, - }); - } catch (e) { - if (e.status !== 422) throw e; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - labels: [sizeLabel], - }); - } - - // --- Large PR handling (400+ LOC) --- - if (totalChanges >= 400) { - if (!existingLabels.includes('large-pr')) { - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: 'large-pr', - color: 'e99695', - }); - } catch (e) { - if (e.status !== 422) throw e; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - labels: ['large-pr'], - }); - } - - // Comment once with guidance - const MARKER = ''; - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - }); - - const alreadyCommented = comments.some(c => c.body.includes(MARKER)); - if (!alreadyCommented) { - let body = `${MARKER}\n`; - body += `### :straight_ruler: PR Size: **${totalChanges} lines changed** (${sizeLabel})\n\n`; - body += `Please note: this PR exceeds the 400 LOC soft limit.\n`; - body += `- Consider **splitting** into separate PRs if the changes are separable\n`; - body += `- Ensure the PR description includes a **guided tour** in the "How to Review" section so reviewers know where to start\n`; - - if (hasReviewGuidance) { - body += `\n:white_check_mark: "How to Review" section appears to include guidance — thank you!\n`; - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: body, - }); - } - } - - console.log(`PR #${prNumber}: labeled ${sizeLabel}, done`); -defaults: - run: - shell: bash -euxo pipefail {0} diff --git a/.github/workflows/publish_extension_cli.yml b/.github/workflows/publish_extension_cli.yml index e7ba9075db8e552b5050e5e65fef9aeac872a776..17248cea11307d4604b05d5160212a4f38e2874a 100644 --- a/.github/workflows/publish_extension_cli.yml +++ b/.github/workflows/publish_extension_cli.yml @@ -18,7 +18,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -48,7 +48,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec01217c6acb7ab9a4afd5b65aa1f98a9740aab1..b651e7046bc7d603a7a829ce1b59fcf0468bdd3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -31,7 +31,7 @@ jobs: with: node-version: '20' - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 300 - name: steps::setup_sccache @@ -66,7 +66,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -79,7 +79,7 @@ jobs: with: node-version: '20' - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache @@ -159,7 +159,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -191,7 +191,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -257,7 +257,7 @@ jobs: env: ACTIONLINT_BIN: ${{ steps.get_actionlint.outputs.executable }} - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index a60ae34c27a0f955d7b068187e88c0a463329a86..30d0e1fbf9c7955d1216e2e3d7ac51a9a51f4416 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -410,7 +410,7 @@ jobs: with: clean: false - name: steps::cache_nix_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: nix - name: nix_build::build_nix::install_nix @@ -444,7 +444,7 @@ jobs: with: clean: false - name: steps::cache_nix_store_macos - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: path: ~/nix-cache - name: nix_build::build_nix::install_nix diff --git a/.github/workflows/run_agent_evals.yml b/.github/workflows/run_agent_evals.yml index 218d84e7afa39c2333fcd65bc05c5dc07bf2db8c..83fd91b037fd982a25845b10aaff561b42af5fc5 100644 --- a/.github/workflows/run_agent_evals.yml +++ b/.github/workflows/run_agent_evals.yml @@ -28,7 +28,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index bc16c2ee9c4f72969a42d04745ba3953d8462469..71b2e4d5fa0b386334bb8acab8e732f1c7d0ad93 100644 --- a/.github/workflows/run_bundling.yml +++ b/.github/workflows/run_bundling.yml @@ -278,7 +278,7 @@ jobs: with: clean: false - name: steps::cache_nix_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: nix - name: nix_build::build_nix::install_nix @@ -310,7 +310,7 @@ jobs: with: clean: false - name: steps::cache_nix_store_macos - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: path: ~/nix-cache - name: nix_build::build_nix::install_nix diff --git a/.github/workflows/run_cron_unit_evals.yml b/.github/workflows/run_cron_unit_evals.yml index 46ed2e380afe7618aa835d5e122955504283ee97..7bb7f79473eb4dae170eb18edd454b7ae35d13e8 100644 --- a/.github/workflows/run_cron_unit_evals.yml +++ b/.github/workflows/run_cron_unit_evals.yml @@ -29,7 +29,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -38,7 +38,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 746941b08c8d6e67148af0651f41cc651a13b2eb..9f335a76beab036d97fe5555cd049ea46b4f87f0 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -128,7 +128,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -212,7 +212,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -247,7 +247,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -278,7 +278,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -356,7 +356,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -369,7 +369,7 @@ jobs: with: node-version: '20' - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache @@ -411,7 +411,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -420,7 +420,7 @@ jobs: with: node-version: '20' - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 300 - name: steps::setup_sccache @@ -453,7 +453,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -501,7 +501,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -542,7 +542,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -580,7 +580,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -619,7 +619,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -661,7 +661,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -689,7 +689,7 @@ jobs: env: ACTIONLINT_BIN: ${{ steps.get_actionlint.outputs.executable }} - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/run_unit_evals.yml b/.github/workflows/run_unit_evals.yml index 670a6e6b0fc19940b598221e439a68b656c7ca0f..1bf75188832668f40a24c4d3452940bf05fcd3fd 100644 --- a/.github/workflows/run_unit_evals.yml +++ b/.github/workflows/run_unit_evals.yml @@ -32,7 +32,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -41,7 +41,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache diff --git a/Cargo.lock b/Cargo.lock index 33645135abda30a991f7645338fa84bd1618d574..0318edb208e98758241785352d6a067141c5e8f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,7 +59,7 @@ dependencies = [ "serde", "serde_json", "settings", - "theme", + "theme_settings", "ui", "util", "workspace", @@ -406,6 +406,7 @@ dependencies = [ "terminal_view", "text", "theme", + "theme_settings", "time", "time_format", "tree-sitter-md", @@ -2972,6 +2973,7 @@ dependencies = [ "cloud_llm_client", "collections", "credentials_provider", + "db", "derive_more", "feature_flags", "fs", @@ -3257,6 +3259,7 @@ dependencies = [ "telemetry_events", "text", "theme", + "theme_settings", "time", "tokio", "toml 0.8.23", @@ -3302,6 +3305,7 @@ dependencies = [ "smallvec", "telemetry", "theme", + "theme_settings", "time", "time_format", "title_bar", @@ -3374,6 +3378,7 @@ dependencies = [ "settings", "telemetry", "theme", + "theme_settings", "time", "ui", "util", @@ -3426,6 +3431,7 @@ dependencies = [ "session", "settings", "theme", + "theme_settings", "ui", "ui_input", "uuid", @@ -3623,6 +3629,7 @@ dependencies = [ "settings", "sum_tree", "theme", + "theme_settings", "util", "workspace", "zlog", @@ -4693,6 +4700,7 @@ dependencies = [ "terminal_view", "text", "theme", + "theme_settings", "tree-sitter", "tree-sitter-go", "tree-sitter-json", @@ -4880,6 +4888,7 @@ dependencies = [ "settings", "text", "theme", + "theme_settings", "ui", "unindent", "util", @@ -5396,6 +5405,7 @@ dependencies = [ "telemetry", "text", "theme", + "theme_settings", "time", "ui", "util", @@ -5464,6 +5474,7 @@ dependencies = [ "telemetry", "text", "theme", + "theme_settings", "time", "tracing", "tree-sitter-bash", @@ -6043,7 +6054,7 @@ dependencies = [ "settings_content", "snippet_provider", "task", - "theme", + "theme_settings", "tokio", "toml 0.8.23", "tree-sitter", @@ -6092,6 +6103,7 @@ dependencies = [ "tempfile", "theme", "theme_extension", + "theme_settings", "toml 0.8.23", "tracing", "url", @@ -6130,7 +6142,7 @@ dependencies = [ "smallvec", "strum 0.27.2", "telemetry", - "theme", + "theme_settings", "ui", "util", "vim_mode_setting", @@ -6287,6 +6299,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", "ui", "util", "workspace", @@ -7289,6 +7302,7 @@ dependencies = [ "anyhow", "collections", "db", + "editor", "feature_flags", "fs", "git", @@ -7298,10 +7312,13 @@ dependencies = [ "menu", "project", "rand 0.9.2", + "search", "serde_json", "settings", "smallvec", + "smol", "theme", + "theme_settings", "time", "ui", "workspace", @@ -7377,6 +7394,7 @@ dependencies = [ "strum 0.27.2", "telemetry", "theme", + "theme_settings", "time", "time_format", "tracing", @@ -7605,7 +7623,6 @@ dependencies = [ "block", "cbindgen", "chrono", - "circular-buffer", "cocoa 0.26.0", "cocoa-foundation 0.2.0", "collections", @@ -7652,6 +7669,7 @@ dependencies = [ "rand 0.9.2", "raw-window-handle", "refineable", + "regex", "reqwest_client", "resvg", "scheduler", @@ -7667,6 +7685,7 @@ dependencies = [ "sum_tree", "taffy", "thiserror 2.0.17", + "ttf-parser 0.25.1", "unicode-segmentation", "url", "usvg", @@ -8727,7 +8746,7 @@ dependencies = [ "project", "serde", "settings", - "theme", + "theme_settings", "ui", "util", "workspace", @@ -8843,7 +8862,7 @@ dependencies = [ "project", "serde_json", "serde_json_lenient", - "theme", + "theme_settings", "ui", "util", "util_macros", @@ -9286,6 +9305,7 @@ dependencies = [ "telemetry", "tempfile", "theme", + "theme_settings", "tree-sitter-json", "tree-sitter-rust", "ui", @@ -9396,6 +9416,7 @@ dependencies = [ "task", "text", "theme", + "theme_settings", "toml 0.8.23", "tracing", "tree-sitter", @@ -9609,6 +9630,7 @@ dependencies = [ "sysinfo 0.37.2", "telemetry", "theme", + "theme_settings", "tree-sitter", "ui", "util", @@ -10287,6 +10309,7 @@ dependencies = [ "stacksafe", "sum_tree", "theme", + "theme_settings", "ui", "util", ] @@ -10303,7 +10326,7 @@ dependencies = [ "markdown", "settings", "tempfile", - "theme", + "theme_settings", "ui", "urlencoding", "util", @@ -10663,7 +10686,7 @@ dependencies = [ "rpc", "serde_json", "smol", - "theme", + "theme_settings", "util", "workspace", "zed_actions", @@ -11554,6 +11577,7 @@ dependencies = [ "settings", "telemetry", "theme", + "theme_settings", "ui", "util", "vim_mode_setting", @@ -11659,6 +11683,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", "ui", "util", "workspace", @@ -11837,6 +11862,7 @@ dependencies = [ "settings", "smol", "theme", + "theme_settings", "ui", "util", "workspace", @@ -11869,6 +11895,7 @@ dependencies = [ "smallvec", "smol", "theme", + "theme_settings", "ui", "util", "workspace", @@ -12734,6 +12761,7 @@ dependencies = [ "serde", "settings", "theme", + "theme_settings", "ui", "ui_input", "workspace", @@ -12842,6 +12870,7 @@ dependencies = [ "settings", "smallvec", "theme", + "theme_settings", "ui", "windows 0.61.3", "workspace", @@ -13340,6 +13369,7 @@ dependencies = [ "telemetry", "tempfile", "theme", + "theme_settings", "ui", "util", "workspace", @@ -13366,6 +13396,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", "util", "workspace", ] @@ -14358,7 +14389,7 @@ dependencies = [ "remote", "semver", "settings", - "theme", + "theme_settings", "ui", "ui_input", "workspace", @@ -14426,6 +14457,7 @@ dependencies = [ "sysinfo 0.37.2", "task", "theme", + "theme_settings", "thiserror 2.0.17", "toml 0.8.23", "unindent", @@ -14496,6 +14528,7 @@ dependencies = [ "terminal", "terminal_view", "theme", + "theme_settings", "tree-sitter-md", "tree-sitter-python", "tree-sitter-typescript", @@ -14609,12 +14642,15 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" dependencies = [ + "gif", + "image-webp", "log", "pico-args", "rgb", "svgtypes", "tiny-skia", "usvg", + "zune-jpeg", ] [[package]] @@ -14832,7 +14868,7 @@ dependencies = [ "rope", "serde", "settings", - "theme", + "theme_settings", "ui", "ui_input", "util", @@ -14868,9 +14904,9 @@ dependencies = [ [[package]] name = "rust-embed" -version = "8.7.2" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -14879,9 +14915,9 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.7.2" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" dependencies = [ "proc-macro2", "quote", @@ -14892,9 +14928,9 @@ dependencies = [ [[package]] name = "rust-embed-utils" -version = "8.7.2" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ "globset", "sha2", @@ -15246,6 +15282,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", ] [[package]] @@ -15469,6 +15506,7 @@ dependencies = [ "settings", "smol", "theme", + "theme_settings", "tracing", "ui", "unindent", @@ -15813,6 +15851,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", "ui", "workspace", "zed_actions", @@ -15861,6 +15900,7 @@ dependencies = [ "strum 0.27.2", "telemetry", "theme", + "theme_settings", "title_bar", "ui", "util", @@ -15982,6 +16022,7 @@ dependencies = [ "anyhow", "assistant_text_thread", "chrono", + "collections", "editor", "feature_flags", "fs", @@ -15996,6 +16037,7 @@ dependencies = [ "serde_json", "settings", "theme", + "theme_settings", "ui", "util", "vim_mode_setting", @@ -16651,6 +16693,7 @@ dependencies = [ "story", "strum 0.27.2", "theme", + "theme_settings", "title_bar", "ui", ] @@ -17301,6 +17344,7 @@ dependencies = [ "settings", "smol", "theme", + "theme_settings", "ui", "util", "workspace", @@ -17485,6 +17529,7 @@ dependencies = [ "sysinfo 0.37.2", "task", "theme", + "theme_settings", "thiserror 2.0.17", "url", "urlencoding", @@ -17532,6 +17577,7 @@ dependencies = [ "task", "terminal", "theme", + "theme_settings", "ui", "util", "workspace", @@ -17566,10 +17612,7 @@ dependencies = [ "anyhow", "collections", "derive_more", - "fs", - "futures 0.3.31", "gpui", - "log", "palette", "parking_lot", "refineable", @@ -17577,10 +17620,8 @@ dependencies = [ "serde", "serde_json", "serde_json_lenient", - "settings", "strum 0.27.2", "thiserror 2.0.17", - "util", "uuid", ] @@ -17593,6 +17634,7 @@ dependencies = [ "fs", "gpui", "theme", + "theme_settings", ] [[package]] @@ -17612,6 +17654,7 @@ dependencies = [ "simplelog", "strum 0.27.2", "theme", + "theme_settings", "vscode_theme", ] @@ -17628,12 +17671,33 @@ dependencies = [ "settings", "telemetry", "theme", + "theme_settings", "ui", "util", "workspace", "zed_actions", ] +[[package]] +name = "theme_settings" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "gpui", + "gpui_util", + "log", + "palette", + "refineable", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "settings", + "theme", + "uuid", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -18565,9 +18629,9 @@ dependencies = [ [[package]] name = "tree-sitter-rust" -version = "0.24.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f715f73a0687261ddb686f0d64a1e5af57bd199c4d12be5fdda6676ce1885bf9" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" dependencies = [ "cc", "tree-sitter-language", @@ -18766,6 +18830,7 @@ dependencies = [ "documented", "gpui", "gpui_macros", + "gpui_util", "icons", "itertools 0.14.0", "menu", @@ -18777,7 +18842,6 @@ dependencies = [ "strum 0.27.2", "theme", "ui_macros", - "util", "windows 0.61.3", ] @@ -18808,7 +18872,7 @@ dependencies = [ "markdown", "menu", "settings", - "theme", + "theme_settings", "ui", "workspace", ] @@ -19207,6 +19271,7 @@ dependencies = [ "task", "text", "theme", + "theme_settings", "tokio", "ui", "util", @@ -20334,7 +20399,7 @@ dependencies = [ "gpui", "serde", "settings", - "theme", + "theme_settings", "ui", "util", "workspace", @@ -21553,6 +21618,7 @@ dependencies = [ "telemetry", "tempfile", "theme", + "theme_settings", "ui", "util", "uuid", @@ -22114,6 +22180,7 @@ dependencies = [ "theme", "theme_extension", "theme_selector", + "theme_settings", "time", "time_format", "title_bar", diff --git a/Cargo.toml b/Cargo.toml index e9993d821888a2107427026f742aaca0cec220bb..998a4705f28c82160b7124a98c1eb23c22360125 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -198,6 +198,7 @@ members = [ "crates/text", "crates/theme", "crates/theme_extension", + "crates/theme_settings", "crates/theme_importer", "crates/theme_selector", "crates/time_format", @@ -445,6 +446,7 @@ terminal_view = { path = "crates/terminal_view" } text = { path = "crates/text" } theme = { path = "crates/theme" } theme_extension = { path = "crates/theme_extension" } +theme_settings = { path = "crates/theme_settings" } theme_selector = { path = "crates/theme_selector" } time_format = { path = "crates/time_format" } platform_title_bar = { path = "crates/platform_title_bar" } @@ -680,7 +682,7 @@ rsa = "0.9.6" runtimelib = { version = "1.4.0", default-features = false, features = [ "async-dispatcher-runtime", "aws-lc-rs" ] } -rust-embed = { version = "8.4", features = ["include-exclude"] } +rust-embed = { version = "8.11", features = ["include-exclude"] } rustc-hash = "2.1.0" rustls = { version = "0.23.26" } rustls-platform-verifier = "0.5.0" @@ -756,7 +758,7 @@ tree-sitter-md = { git = "https://github.com/tree-sitter-grammars/tree-sitter-ma tree-sitter-python = "0.25" tree-sitter-regex = "0.24" tree-sitter-ruby = "0.23" -tree-sitter-rust = "0.24.1" +tree-sitter-rust = "0.24.2" tree-sitter-typescript = { git = "https://github.com/zed-industries/tree-sitter-typescript", rev = "e2c53597d6a5d9cf7bbe8dccde576fe1e46c5899" } # https://github.com/tree-sitter/tree-sitter-typescript/pull/347 tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "baff0b51c64ef6a1fb1f8390f3ad6015b83ec13a" } tracing = "0.1.40" diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 412bec85625412089b2435e46573c1cf40c50b4f..617d7a6d0662264858ac3066d40481135dab9ae6 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1077,6 +1077,7 @@ "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", "alt-enter": "collab_panel::OpenSelectedChannelNotes", + "shift-enter": "collab_panel::ToggleSelectedChannelFavorite", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 5741c5a9af5517533c214f0f77050aa2faf1a669..d3dda49c9a52a8c9b52dfddc04ae573f2fa4cf28 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1138,6 +1138,7 @@ "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", "alt-enter": "collab_panel::OpenSelectedChannelNotes", + "shift-enter": "collab_panel::ToggleSelectedChannelFavorite", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index d94cbfdac16b5a86c380c158fae9f467abd5d202..e665d26aaf0c90d6c2fa4ee66284687c843fcd62 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1082,6 +1082,7 @@ "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", "alt-enter": "collab_panel::OpenSelectedChannelNotes", + "shift-enter": "collab_panel::ToggleSelectedChannelFavorite", }, }, { diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index bb1fbf7059879899e616f5a84c165f88191f3deb..c15393e1fd2a832881b7d3298ec58f131268f68d 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -337,6 +337,8 @@ "shift-j": "vim::JoinLines", "i": "vim::InsertBefore", "a": "vim::InsertAfter", + "o": "vim::InsertLineBelow", + "shift-o": "vim::InsertLineAbove", "p": "vim::Paste", "u": "vim::Undo", "r": "vim::PushReplace", diff --git a/assets/settings/default.json b/assets/settings/default.json index 9ea3285f90885d1ab2c33717b802ac6e8ebbfe3d..7bfb1f2cdb68856d66073e8629d9921602d806d8 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1617,6 +1617,8 @@ "status_bar": { // Whether to show the status bar. "experimental.show": true, + // Whether to show the name of the active file in the status bar. + "show_active_file": false, // Whether to show the active language button in the status bar. "active_language_button": true, // Whether to show the cursor position button in the status bar. diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index df59c67bb4576e34f76539df34147fb4606bb9f3..f33732f1e0f3623df5ce6833356f3547c5781adb 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -160,6 +160,7 @@ pub enum AgentThreadEntry { UserMessage(UserMessage), AssistantMessage(AssistantMessage), ToolCall(ToolCall), + CompletedPlan(Vec), } impl AgentThreadEntry { @@ -168,6 +169,7 @@ impl AgentThreadEntry { Self::UserMessage(message) => message.indented, Self::AssistantMessage(message) => message.indented, Self::ToolCall(_) => false, + Self::CompletedPlan(_) => false, } } @@ -176,6 +178,14 @@ impl AgentThreadEntry { Self::UserMessage(message) => message.to_markdown(cx), Self::AssistantMessage(message) => message.to_markdown(cx), Self::ToolCall(tool_call) => tool_call.to_markdown(cx), + Self::CompletedPlan(entries) => { + let mut md = String::from("## Plan\n\n"); + for entry in entries { + let source = entry.content.read(cx).source().to_string(); + md.push_str(&format!("- [x] {}\n", source)); + } + md + } } } @@ -1298,7 +1308,9 @@ impl AcpThread { status: ToolCallStatus::WaitingForConfirmation { .. }, .. }) => return true, - AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::AssistantMessage(_) + | AgentThreadEntry::CompletedPlan(_) => {} } } false @@ -1320,7 +1332,9 @@ impl AcpThread { ) if call.diffs().next().is_some() => { return true; } - AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::AssistantMessage(_) + | AgentThreadEntry::CompletedPlan(_) => {} } } @@ -1337,7 +1351,9 @@ impl AcpThread { }) => { return true; } - AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::AssistantMessage(_) + | AgentThreadEntry::CompletedPlan(_) => {} } } @@ -1348,7 +1364,9 @@ impl AcpThread { for entry in self.entries.iter().rev() { match entry { AgentThreadEntry::UserMessage(..) => return false, - AgentThreadEntry::AssistantMessage(..) => continue, + AgentThreadEntry::AssistantMessage(..) | AgentThreadEntry::CompletedPlan(..) => { + continue; + } AgentThreadEntry::ToolCall(..) => return true, } } @@ -2065,6 +2083,13 @@ impl AcpThread { cx.notify(); } + pub fn snapshot_completed_plan(&mut self, cx: &mut Context) { + if !self.plan.is_empty() && self.plan.stats().pending == 0 { + let completed_entries = std::mem::take(&mut self.plan.entries); + self.push_entry(AgentThreadEntry::CompletedPlan(completed_entries), cx); + } + } + fn clear_completed_plan_entries(&mut self, cx: &mut Context) { self.plan .entries @@ -2223,6 +2248,10 @@ impl AcpThread { this.mark_pending_tools_as_canceled(); } + if !canceled { + this.snapshot_completed_plan(cx); + } + // Handle refusal - distinguish between user prompt and tool call refusals if let acp::StopReason::Refusal = r.stop_reason { this.had_error = true; diff --git a/crates/acp_tools/Cargo.toml b/crates/acp_tools/Cargo.toml index 0720c4b6685ecf7fa20d8cacd2b61baa765c961c..8f14b1f93b32c6df521ea13ebf3f0f73e7ed755c 100644 --- a/crates/acp_tools/Cargo.toml +++ b/crates/acp_tools/Cargo.toml @@ -23,7 +23,7 @@ project.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index 78c873c3a1a12c1f24a2c64e96ce1d1801bc4eb9..52a9d03f893d0b82bf6395b4c96bc9ebe14d3afe 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -16,7 +16,7 @@ use language::LanguageRegistry; use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle}; use project::{AgentId, Project}; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{CopyButton, Tooltip, WithScrollbar, prelude::*}; use util::ResultExt as _; use workspace::{ diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index f36d8c0497430c27c7cafd99445c8baad18406f5..b7aa9d1e311016f572928993e049798c2b5e3bb2 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -942,6 +942,9 @@ impl NativeAgent { NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx) }) .await?; + acp_thread.update(cx, |thread, cx| { + thread.snapshot_completed_plan(cx); + }); Ok(acp_thread) }) } diff --git a/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs index 6f9ad092d428389f0d83383060010446dd2c2dff..198ab45b13faef814e5964892e02e4c9d60de5b0 100644 --- a/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs +++ b/crates/agent/src/edit_agent/evals/fixtures/disable_cursor_blinking/before.rs @@ -7837,7 +7837,7 @@ impl Editor { h_flex() .px_0p5() .when(is_platform_style_mac, |parent| parent.gap_0p5()) - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) .text_size(TextSize::XSmall.rems(cx)) .child(h_flex().children(ui::render_modifiers( &accept_keystroke.modifiers, @@ -8149,7 +8149,7 @@ impl Editor { .px_2() .child( h_flex() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) .when(is_platform_style_mac, |parent| parent.gap_1()) .child(h_flex().children(ui::render_modifiers( &accept_keystroke.modifiers, @@ -8258,7 +8258,7 @@ impl Editor { .gap_2() .pr_1() .overflow_x_hidden() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) .child(left) .child(preview), ) diff --git a/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/before.rs b/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/before.rs index 607daa8ce3a129e0f4bc53a00d1a62f479da3932..bdf160d8ffe2c605a9e995d6efe7227dce34eaab 100644 --- a/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/before.rs +++ b/crates/agent/src/tools/evals/fixtures/disable_cursor_blinking/before.rs @@ -7837,7 +7837,7 @@ impl Editor { h_flex() .px_0p5() .when(is_platform_style_mac, |parent| parent.gap_0p5()) - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) .text_size(TextSize::XSmall.rems(cx)) .child(h_flex().children(ui::render_modifiers( &accept_keystroke.modifiers, @@ -8149,7 +8149,7 @@ impl Editor { .px_2() .child( h_flex() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) .when(is_platform_style_mac, |parent| parent.gap_1()) .child(h_flex().children(ui::render_modifiers( &accept_keystroke.modifiers, @@ -8258,7 +8258,7 @@ impl Editor { .gap_2() .pr_1() .overflow_x_hidden() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme_settings::ThemeSettings::get_global(cx).buffer_font.clone()) .child(left) .child(preview), ) diff --git a/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-09.diff b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-09.diff new file mode 100644 index 0000000000000000000000000000000000000000..6bc45657b3d6bf23b4542deb4f6016472a0e89b9 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-09.diff @@ -0,0 +1,20 @@ +@@ -5,7 +5,7 @@ + use futures::AsyncWriteExt; + use gpui::SharedString; + use serde::{Deserialize, Serialize}; +-use std::process::Stdio; ++use std::process::{Output, Stdio}; + use std::{ops::Range, path::Path}; + use text::Rope; + use time::OffsetDateTime; +@@ -94,6 +94,10 @@ + + let output = child.output().await.context("reading git blame output")?; + ++ handle_command_output(output) ++} ++ ++fn handle_command_output(output: Output) -> Result { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let trimmed = stderr.trim(); diff --git a/crates/agent/src/tools/evals/streaming_edit_file.rs b/crates/agent/src/tools/evals/streaming_edit_file.rs index 5ab931915e4789e2dd9f6fb7c1da19be6da59de2..6a55517037e54ae4166cd22427201d9325ef0f76 100644 --- a/crates/agent/src/tools/evals/streaming_edit_file.rs +++ b/crates/agent/src/tools/evals/streaming_edit_file.rs @@ -808,6 +808,8 @@ fn eval_extract_handle_command_output() { include_str!("fixtures/extract_handle_command_output/possible-05.diff"), include_str!("fixtures/extract_handle_command_output/possible-06.diff"), include_str!("fixtures/extract_handle_command_output/possible-07.diff"), + include_str!("fixtures/extract_handle_command_output/possible-08.diff"), + include_str!("fixtures/extract_handle_command_output/possible-09.diff"), ]; eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || { diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index ea89d6fef77bf02e50a7e1599254cac897ed074f..df99b4d65a62e3bb12239ef58d9ad49416554209 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -111,6 +111,8 @@ pub enum StreamingEditFileMode { } /// A single edit operation that replaces old text with new text +/// Properly escape all text fields as valid JSON strings. +/// Remember to escape special characters like newlines (`\n`) and quotes (`"`) in JSON strings. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct Edit { /// The exact text to find in the file. This will be matched using fuzzy matching diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 245a5f5472b47db5660216d6d9147b89d865e61f..0d95aa9bc8d69e1197b35ebb7268ba0020aea3af 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -101,6 +101,7 @@ terminal.workspace = true terminal_view.workspace = true text.workspace = true theme.workspace = true +theme_settings.workspace = true time.workspace = true time_format.workspace = true ui.workspace = true diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index 334aaf4026527938144cf12e25c9a7a23d5c28ac..4e3dd63b0337f9be54b550f4f4a6a5ca2e7cdd42 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -813,7 +813,7 @@ mod tests { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); language_model::init_settings(cx); editor::init(cx); diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index e550d59c0ccb4deab40f6fcbc39dae124e3c08db..9c44288e1cd23cd3bb0d6876f086c3f0e89dc4c7 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -22,7 +22,7 @@ use project::{ use serde::Deserialize; use settings::{Settings as _, update_settings_file}; use std::sync::Arc; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ CommonAnimationExt, KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, WithScrollbar, prelude::*, diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 44b706bbe705ea9368c79fb774bd171f6220c70b..541199028b1becade3b9891114a89e69152fcb02 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1806,7 +1806,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); prompt_store::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); language_model::init_settings(cx); }); @@ -1963,7 +1963,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); prompt_store::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); language_model::init_settings(cx); workspace::register_project_item::(cx); }); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index ff819b3730bdaf8dc89d5c40e5fdad04b3342496..67ddd5a09c14c36f06f2dd19abd486d7c346892e 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -76,7 +76,7 @@ use prompt_store::{PromptBuilder, PromptStore, UserPromptId}; use rules_library::{RulesLibrary, open_rules_library}; use search::{BufferSearchBar, buffer_search}; use settings::{Settings, update_settings_file}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Button, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu, PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize, @@ -1624,17 +1624,17 @@ impl AgentPanel { let agent_buffer_font_size = ThemeSettings::get_global(cx).agent_buffer_font_size(cx) + delta; - let _ = settings - .theme - .agent_ui_font_size - .insert(f32::from(theme::clamp_font_size(agent_ui_font_size)).into()); + let _ = settings.theme.agent_ui_font_size.insert( + f32::from(theme_settings::clamp_font_size(agent_ui_font_size)).into(), + ); let _ = settings.theme.agent_buffer_font_size.insert( - f32::from(theme::clamp_font_size(agent_buffer_font_size)).into(), + f32::from(theme_settings::clamp_font_size(agent_buffer_font_size)) + .into(), ); }); } else { - theme::adjust_agent_ui_font_size(cx, |size| size + delta); - theme::adjust_agent_buffer_font_size(cx, |size| size + delta); + theme_settings::adjust_agent_ui_font_size(cx, |size| size + delta); + theme_settings::adjust_agent_buffer_font_size(cx, |size| size + delta); } } WhichFontSize::BufferFont => { @@ -1658,14 +1658,14 @@ impl AgentPanel { settings.theme.agent_buffer_font_size = None; }); } else { - theme::reset_agent_ui_font_size(cx); - theme::reset_agent_buffer_font_size(cx); + theme_settings::reset_agent_ui_font_size(cx); + theme_settings::reset_agent_buffer_font_size(cx); } } pub fn reset_agent_zoom(&mut self, _window: &mut Window, cx: &mut Context) { - theme::reset_agent_ui_font_size(cx); - theme::reset_agent_buffer_font_size(cx); + theme_settings::reset_agent_ui_font_size(cx); + theme_settings::reset_agent_buffer_font_size(cx); } pub fn toggle_zoom(&mut self, _: &ToggleZoom, window: &mut Window, cx: &mut Context) { diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs index 6e8f9ddee30b1a72c1c5daee32fda24042ff7df7..78b4e3a5a3965c72b96d4ec201139b1d8e510fb2 100644 --- a/crates/agent_ui/src/agent_registry_ui.rs +++ b/crates/agent_ui/src/agent_registry_ui.rs @@ -12,7 +12,7 @@ use gpui::{ use project::agent_server_store::{AllAgentServersSettings, CustomAgentServerSettings}; use project::{AgentRegistryStore, RegistryAgent}; use settings::{Settings, SettingsStore, update_settings_file}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ButtonStyle, ScrollableHandle, ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonGroupStyle, ToggleButtonSimple, Tooltip, WithScrollbar, prelude::*, diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 5597aec7a66edd8561caa4ac53f672d5bd2a33ab..b6be6502b152847822a79bc8c486195345c0a195 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -2577,7 +2577,7 @@ mod tests { let app_state = cx.update(|cx| { let state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); state }); diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 740beabce22ab6eb476b8c60b281c3ebc9d9df12..3fabb528315f8f32c03d358c13d123e5bb299fd7 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -49,7 +49,7 @@ use std::time::Instant; use std::{collections::BTreeMap, rc::Rc, time::Duration}; use terminal_view::terminal_panel::TerminalPanel; use text::Anchor; -use theme::AgentFontSize; +use theme_settings::AgentFontSize; use ui::{ Callout, CircularProgress, CommonAnimationExt, ContextMenu, ContextMenuEntry, CopyButton, DecoratedIcon, DiffStat, Disclosure, Divider, DividerColor, IconDecoration, IconDecorationKind, @@ -237,6 +237,20 @@ impl Conversation { )) } + pub fn subagents_awaiting_permission(&self, cx: &App) -> Vec<(acp::SessionId, usize)> { + self.permission_requests + .iter() + .filter_map(|(session_id, tool_call_ids)| { + let thread = self.threads.get(session_id)?; + if thread.read(cx).parent_session_id().is_some() && !tool_call_ids.is_empty() { + Some((session_id.clone(), tool_call_ids.len())) + } else { + None + } + }) + .collect() + } + pub fn authorize_pending_tool_call( &mut self, session_id: &acp::SessionId, @@ -795,7 +809,7 @@ impl ConversationView { }); let count = thread.read(cx).entries().len(); - let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0)); + let list_state = ListState::new(0, gpui::ListAlignment::Top, px(2048.0)); entry_view_state.update(cx, |view_state, cx| { for ix in 0..count { view_state.sync_entry(ix, &thread, window, cx); @@ -1215,6 +1229,9 @@ impl ConversationView { .and_then(|entry| entry.focus_handle(cx))], ); }); + active.update(cx, |active, cx| { + active.sync_editor_mode_for_empty_state(cx); + }); } } AcpThreadEvent::EntryUpdated(index) => { @@ -1234,6 +1251,9 @@ impl ConversationView { let list_state = active.read(cx).list_state.clone(); entry_view_state.update(cx, |view_state, _cx| view_state.remove(range.clone())); list_state.splice(range.clone(), 0); + active.update(cx, |active, cx| { + active.sync_editor_mode_for_empty_state(cx); + }); } } AcpThreadEvent::SubagentSpawned(session_id) => self.load_subagent_session( @@ -1255,9 +1275,11 @@ impl ConversationView { } AcpThreadEvent::Stopped(stop_reason) => { if let Some(active) = self.thread_view(&thread_id) { - active.update(cx, |active, _cx| { + active.update(cx, |active, cx| { active.thread_retry_status.take(); active.clear_auto_expand_tracking(); + active.list_state.set_follow_tail(false); + active.sync_generating_indicator(cx); }); } if is_subagent { @@ -1325,8 +1347,10 @@ impl ConversationView { } AcpThreadEvent::Error => { if let Some(active) = self.thread_view(&thread_id) { - active.update(cx, |active, _cx| { + active.update(cx, |active, cx| { active.thread_retry_status.take(); + active.list_state.set_follow_tail(false); + active.sync_generating_indicator(cx); }); } if !is_subagent { @@ -4233,7 +4257,7 @@ pub(crate) mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); SidebarThreadMetadataStore::init_global(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); agent_panel::init(cx); release_channel::init(semver::Version::new(0, 0, 0), cx); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 1ad07efd52ddcffe29bd3d50e382d85813c3c994..7caeb687bebb32083c2647a2bc0d359b36e03b58 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1,7 +1,10 @@ -use crate::{DEFAULT_THREAD_TITLE, SelectPermissionGranularity}; +use crate::{ + DEFAULT_THREAD_TITLE, SelectPermissionGranularity, + agent_configuration::configure_context_server_modal::default_markdown_style, +}; use std::cell::RefCell; -use acp_thread::ContentBlock; +use acp_thread::{ContentBlock, PlanEntry}; use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; use editor::actions::OpenExcerpts; @@ -285,6 +288,7 @@ pub struct ThreadView { pub hovered_recent_history_item: Option, pub show_external_source_prompt_warning: bool, pub show_codex_windows_warning: bool, + pub generating_indicator_in_list: bool, pub history: Option>, pub _history_subscription: Option, } @@ -525,19 +529,40 @@ impl ThreadView { history, _history_subscription: history_subscription, show_codex_windows_warning, + generating_indicator_in_list: false, }; + + this.sync_generating_indicator(cx); + this.sync_editor_mode_for_empty_state(cx); let list_state_for_scroll = this.list_state.clone(); let thread_view = cx.entity().downgrade(); + this.list_state - .set_scroll_handler(move |_event, _window, cx| { + .set_scroll_handler(move |event, _window, cx| { let list_state = list_state_for_scroll.clone(); let thread_view = thread_view.clone(); + let is_following_tail = event.is_following_tail; // N.B. We must defer because the scroll handler is called while the // ListState's RefCell is mutably borrowed. Reading logical_scroll_top() // directly would panic from a double borrow. cx.defer(move |cx| { let scroll_top = list_state.logical_scroll_top(); let _ = thread_view.update(cx, |this, cx| { + if !is_following_tail { + let is_at_bottom = { + let current_offset = + list_state.scroll_px_offset_for_scrollbar().y.abs(); + let max_offset = list_state.max_offset_for_scrollbar().y; + current_offset >= max_offset - px(1.0) + }; + + let is_generating = + matches!(this.thread.read(cx).status(), ThreadStatus::Generating); + + if is_at_bottom && is_generating { + list_state.set_follow_tail(true); + } + } if let Some(thread) = this.as_native_thread(cx) { thread.update(cx, |thread, _cx| { thread.set_ui_scroll_position(Some(scroll_top)); @@ -1043,7 +1068,11 @@ impl ThreadView { this.update_in(cx, |this, _window, cx| { this.set_editor_is_expanded(false, cx); })?; - let _ = this.update(cx, |this, cx| this.scroll_to_bottom(cx)); + + let _ = this.update(cx, |this, cx| { + this.list_state.set_follow_tail(true); + cx.notify(); + }); let _stop_turn = defer({ let this = this.clone(); @@ -1097,6 +1126,12 @@ impl ThreadView { thread.send(contents, cx) })?; + + let _ = this.update(cx, |this, cx| { + this.sync_generating_indicator(cx); + cx.notify(); + }); + let res = send.await; let turn_time_ms = turn_start_time.elapsed().as_millis(); drop(_stop_turn); @@ -1236,13 +1271,13 @@ impl ThreadView { ); } - // generation - pub fn cancel_generation(&mut self, cx: &mut Context) { self.thread_retry_status.take(); self.thread_error.take(); self.user_interrupted_generation = true; self._cancel_task = Some(self.thread.update(cx, |thread, cx| thread.cancel(cx))); + self.sync_generating_indicator(cx); + cx.notify(); } pub fn retry_generation(&mut self, cx: &mut Context) { @@ -1254,6 +1289,8 @@ impl ThreadView { } let task = thread.update(cx, |thread, cx| thread.retry(cx)); + self.sync_generating_indicator(cx); + cx.notify(); cx.spawn(async move |this, cx| { let result = task.await; @@ -1582,11 +1619,10 @@ impl ThreadView { } }) }; + self.message_editor.focus_handle(cx).focus(window, cx); cx.notify(); } - // tool permissions - pub fn authorize_tool_call( &mut self, session_id: acp::SessionId, @@ -1640,6 +1676,17 @@ impl ThreadView { Some(()) } + fn is_waiting_for_confirmation(entry: &AgentThreadEntry) -> bool { + if let AgentThreadEntry::ToolCall(tool_call) = entry { + matches!( + tool_call.status, + ToolCallStatus::WaitingForConfirmation { .. } + ) + } else { + false + } + } + fn handle_authorize_tool_call( &mut self, action: &AuthorizeToolCall, @@ -2112,7 +2159,14 @@ impl ThreadView { let plan = thread.plan(); let queue_is_empty = !self.has_queued_messages(); - if changed_buffers.is_empty() && plan.is_empty() && queue_is_empty { + let subagents_awaiting_permission = self.render_subagents_awaiting_permission(cx); + let has_subagents_awaiting = subagents_awaiting_permission.is_some(); + + if changed_buffers.is_empty() + && plan.is_empty() + && queue_is_empty + && !has_subagents_awaiting + { return None; } @@ -2140,6 +2194,14 @@ impl ThreadView { blur_radius: px(2.), spread_radius: px(0.), }]) + .when_some(subagents_awaiting_permission, |this, element| { + this.child(element) + }) + .when( + has_subagents_awaiting + && (!plan.is_empty() || !changed_buffers.is_empty() || !queue_is_empty), + |this| this.child(Divider::horizontal().color(DividerColor::Border)), + ) .when(!plan.is_empty(), |this| { this.child(self.render_plan_summary(plan, window, cx)) .when(plan_expanded, |parent| { @@ -2399,6 +2461,119 @@ impl ThreadView { ) } + fn render_subagents_awaiting_permission(&self, cx: &Context) -> Option { + let awaiting = self.conversation.read(cx).subagents_awaiting_permission(cx); + + if awaiting.is_empty() { + return None; + } + + let thread = self.thread.read(cx); + let entries = thread.entries(); + let mut subagent_items: Vec<(SharedString, usize)> = Vec::new(); + + for (session_id, _) in &awaiting { + for (entry_ix, entry) in entries.iter().enumerate() { + if let AgentThreadEntry::ToolCall(tool_call) = entry { + if let Some(info) = &tool_call.subagent_session_info { + if &info.session_id == session_id { + let subagent_summary: SharedString = { + let summary_text = tool_call.label.read(cx).source().to_string(); + if !summary_text.is_empty() { + summary_text.into() + } else { + "Subagent".into() + } + }; + subagent_items.push((subagent_summary, entry_ix)); + break; + } + } + } + } + } + + if subagent_items.is_empty() { + return None; + } + + let item_count = subagent_items.len(); + + Some( + v_flex() + .child( + h_flex() + .py_1() + .px_2() + .w_full() + .gap_1() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Label::new("Subagents Awaiting Permission:") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Label::new(item_count.to_string()).size(LabelSize::Small)), + ) + .child( + v_flex().children(subagent_items.into_iter().enumerate().map( + |(ix, (label, entry_ix))| { + let is_last = ix == item_count - 1; + let group = format!("group-{}", entry_ix); + + h_flex() + .cursor_pointer() + .id(format!("subagent-permission-{}", entry_ix)) + .group(&group) + .p_1() + .pl_2() + .min_w_0() + .w_full() + .gap_1() + .justify_between() + .bg(cx.theme().colors().editor_background) + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .when(!is_last, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::Circle) + .size(IconSize::XSmall) + .color(Color::Warning), + ) + .child( + Label::new(label) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ), + ) + .child( + div().visible_on_hover(&group).child( + Label::new("Scroll to Subagent") + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ), + ) + .on_click(cx.listener(move |this, _, _, cx| { + this.list_state.scroll_to(ListOffset { + item_ix: entry_ix, + offset_in_item: px(0.0), + }); + cx.notify(); + })) + }, + )), + ) + .into_any(), + ) + } + fn render_message_queue_summary( &self, _window: &mut Window, @@ -2618,6 +2793,76 @@ impl ThreadView { .into_any_element() } + fn render_completed_plan( + &self, + entries: &[PlanEntry], + window: &Window, + cx: &Context, + ) -> AnyElement { + v_flex() + .px_5() + .py_1p5() + .w_full() + .child( + v_flex() + .w_full() + .rounded_md() + .border_1() + .border_color(self.tool_card_border_color(cx)) + .child( + h_flex() + .px_2() + .py_1() + .gap_1() + .bg(self.tool_card_header_bg(cx)) + .border_b_1() + .border_color(self.tool_card_border_color(cx)) + .child( + Label::new("Completed Plan") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new(format!( + "— {} {}", + entries.len(), + if entries.len() == 1 { "step" } else { "steps" } + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + v_flex().children(entries.iter().enumerate().map(|(index, entry)| { + h_flex() + .py_1() + .px_2() + .gap_1p5() + .when(index < entries.len() - 1, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + Icon::new(IconName::TodoComplete) + .size(IconSize::Small) + .color(Color::Success), + ) + .child( + div() + .max_w_full() + .overflow_x_hidden() + .text_xs() + .text_color(cx.theme().colors().text_muted) + .child(MarkdownElement::new( + entry.content.clone(), + default_markdown_style(window, cx), + )), + ) + })), + ), + ) + .into_any() + } + fn render_edits_summary( &self, changed_buffers: &BTreeMap, Entity>, @@ -2881,31 +3126,6 @@ impl ThreadView { (IconName::Maximize, "Expand Message Editor") }; - if v2_empty_state { - self.message_editor.update(cx, |editor, cx| { - editor.set_mode( - EditorMode::Full { - scale_ui_elements_with_buffer_font_size: false, - show_active_line_background: false, - sizing_behavior: SizingBehavior::Default, - }, - cx, - ); - }); - } else { - self.message_editor.update(cx, |editor, cx| { - editor.set_mode( - EditorMode::AutoHeight { - min_lines: AgentSettings::get_global(cx).message_editor_min_lines, - max_lines: Some( - AgentSettings::get_global(cx).set_message_editor_max_lines(), - ), - }, - cx, - ); - }); - } - v_flex() .on_action(cx.listener(Self::expand_message_editor)) .p_2() @@ -3182,50 +3402,122 @@ impl ThreadView { fn render_token_usage(&self, cx: &mut Context) -> Option { let thread = self.thread.read(cx); let usage = thread.token_usage()?; - let is_generating = thread.status() != ThreadStatus::Idle; let show_split = self.supports_split_token_display(cx); - let separator_color = Color::Custom(cx.theme().colors().text_muted.opacity(0.5)); - let token_label = |text: String, animation_id: &'static str| { - Label::new(text) - .size(LabelSize::Small) - .color(Color::Muted) - .map(|label| { - if is_generating { - label - .with_animation( - animation_id, - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.3, 0.8)), - |label, delta| label.alpha(delta), - ) - .into_any() - } else { - label.into_any_element() - } + let progress_color = |ratio: f32| -> Hsla { + if ratio >= 0.85 { + cx.theme().status().warning + } else { + cx.theme().colors().text_muted + } + }; + + let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens); + let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens); + let input_tokens_label = + crate::text_thread_editor::humanize_token_count(usage.input_tokens); + let output_tokens_label = + crate::text_thread_editor::humanize_token_count(usage.output_tokens); + + let progress_ratio = if usage.max_tokens > 0 { + usage.used_tokens as f32 / usage.max_tokens as f32 + } else { + 0.0 + }; + + let ring_size = px(16.0); + let stroke_width = px(2.); + + let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32); + + let tooltip_separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6)); + + let (user_rules_count, first_user_rules_id, project_rules_count, project_entry_ids) = self + .as_native_thread(cx) + .map(|thread| { + let project_context = thread.read(cx).project_context().read(cx); + let user_rules_count = project_context.user_rules.len(); + let first_user_rules_id = project_context.user_rules.first().map(|r| r.uuid.0); + let project_entry_ids = project_context + .worktrees + .iter() + .filter_map(|wt| wt.rules_file.as_ref()) + .map(|rf| ProjectEntryId::from_usize(rf.project_entry_id)) + .collect::>(); + let project_rules_count = project_entry_ids.len(); + ( + user_rules_count, + first_user_rules_id, + project_rules_count, + project_entry_ids, + ) + }) + .unwrap_or_default(); + + let workspace = self.workspace.clone(); + + let 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::text_thread_editor::humanize_token_count( + usage.max_tokens.saturating_sub(max_output_tokens), + ); + let output_max_label = crate::text_thread_editor::humanize_token_count(max_output_tokens); + + let build_tooltip = { + move |_window: &mut Window, cx: &mut App| { + let percentage = percentage.clone(); + let used = used.clone(); + let max = max.clone(); + let input_tokens_label = input_tokens_label.clone(); + let output_tokens_label = output_tokens_label.clone(); + let input_max_label = input_max_label.clone(); + let output_max_label = output_max_label.clone(); + let project_entry_ids = project_entry_ids.clone(); + let workspace = workspace.clone(); + cx.new(move |_cx| TokenUsageTooltip { + percentage, + used, + max, + input_tokens: input_tokens_label, + output_tokens: output_tokens_label, + input_max: input_max_label, + output_max: output_max_label, + show_split, + separator_color: tooltip_separator_color, + user_rules_count, + first_user_rules_id, + project_rules_count, + project_entry_ids, + workspace, }) + .into() + } }; if show_split { - let 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 = crate::text_thread_editor::humanize_token_count(usage.input_tokens); - let input_max = crate::text_thread_editor::humanize_token_count( - usage.max_tokens.saturating_sub(max_output_tokens), - ); - let output = crate::text_thread_editor::humanize_token_count(usage.output_tokens); - let output_max = crate::text_thread_editor::humanize_token_count(max_output_tokens); + let input_max_raw = usage.max_tokens.saturating_sub(max_output_tokens); + let output_max_raw = max_output_tokens; + + let input_ratio = if input_max_raw > 0 { + usage.input_tokens as f32 / input_max_raw as f32 + } else { + 0.0 + }; + let output_ratio = if output_max_raw > 0 { + usage.output_tokens as f32 / output_max_raw as f32 + } else { + 0.0 + }; Some( h_flex() + .id("split_token_usage") .flex_shrink_0() - .gap_1() - .mr_1p5() + .gap_1p5() + .mr_1() .child( h_flex() .gap_0p5() @@ -3234,16 +3526,15 @@ impl ThreadView { .size(IconSize::XSmall) .color(Color::Muted), ) - .child(token_label(input, "input-tokens-label")) - .child( - Label::new("/") - .size(LabelSize::Small) - .color(separator_color), - ) .child( - Label::new(input_max) - .size(LabelSize::Small) - .color(Color::Muted), + CircularProgress::new( + usage.input_tokens as f32, + input_max_raw as f32, + ring_size, + cx, + ) + .stroke_width(stroke_width) + .progress_color(progress_color(input_ratio)), ), ) .child( @@ -3254,52 +3545,21 @@ impl ThreadView { .size(IconSize::XSmall) .color(Color::Muted), ) - .child(token_label(output, "output-tokens-label")) - .child( - Label::new("/") - .size(LabelSize::Small) - .color(separator_color), - ) .child( - Label::new(output_max) - .size(LabelSize::Small) - .color(Color::Muted), + CircularProgress::new( + usage.output_tokens as f32, + output_max_raw as f32, + ring_size, + cx, + ) + .stroke_width(stroke_width) + .progress_color(progress_color(output_ratio)), ), ) + .hoverable_tooltip(build_tooltip) .into_any_element(), ) } else { - let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens); - let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens); - let progress_ratio = if usage.max_tokens > 0 { - usage.used_tokens as f32 / usage.max_tokens as f32 - } else { - 0.0 - }; - - let progress_color = if progress_ratio >= 0.85 { - cx.theme().status().warning - } else { - cx.theme().colors().text_muted - }; - let separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6)); - - let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32); - - let (user_rules_count, project_rules_count) = self - .as_native_thread(cx) - .map(|thread| { - let project_context = thread.read(cx).project_context().read(cx); - let user_rules = project_context.user_rules.len(); - let project_rules = project_context - .worktrees - .iter() - .filter(|wt| wt.rules_file.is_some()) - .count(); - (user_rules, project_rules) - }) - .unwrap_or((0, 0)); - Some( h_flex() .id("circular_progress_tokens") @@ -3309,59 +3569,13 @@ impl ThreadView { CircularProgress::new( usage.used_tokens as f32, usage.max_tokens as f32, - px(16.0), + ring_size, cx, ) - .stroke_width(px(2.)) - .progress_color(progress_color), + .stroke_width(stroke_width) + .progress_color(progress_color(progress_ratio)), ) - .tooltip(Tooltip::element({ - move |_, cx| { - v_flex() - .min_w_40() - .child( - Label::new("Context") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .child( - h_flex() - .gap_0p5() - .child(Label::new(percentage.clone())) - .child(Label::new("•").color(separator_color).mx_1()) - .child(Label::new(used.clone())) - .child(Label::new("/").color(separator_color)) - .child(Label::new(max.clone()).color(Color::Muted)), - ) - .when(user_rules_count > 0 || project_rules_count > 0, |this| { - this.child( - v_flex() - .mt_1p5() - .pt_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Label::new("Rules") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .when(user_rules_count > 0, |this| { - this.child(Label::new(format!( - "{} user rules", - user_rules_count - ))) - }) - .when(project_rules_count > 0, |this| { - this.child(Label::new(format!( - "{} project rules", - project_rules_count - ))) - }), - ) - }) - .into_any_element() - } - })) + .hoverable_tooltip(build_tooltip) .into_any_element(), ) } @@ -3910,16 +4124,184 @@ impl ThreadView { } } +struct TokenUsageTooltip { + percentage: String, + used: String, + max: String, + input_tokens: String, + output_tokens: String, + input_max: String, + output_max: String, + show_split: bool, + separator_color: Color, + user_rules_count: usize, + first_user_rules_id: Option, + project_rules_count: usize, + project_entry_ids: Vec, + workspace: WeakEntity, +} + +impl Render for TokenUsageTooltip { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let separator_color = self.separator_color; + let percentage = self.percentage.clone(); + let used = self.used.clone(); + let max = self.max.clone(); + let input_tokens = self.input_tokens.clone(); + let output_tokens = self.output_tokens.clone(); + let input_max = self.input_max.clone(); + let output_max = self.output_max.clone(); + let show_split = self.show_split; + let user_rules_count = self.user_rules_count; + let first_user_rules_id = self.first_user_rules_id; + let project_rules_count = self.project_rules_count; + let project_entry_ids = self.project_entry_ids.clone(); + let workspace = self.workspace.clone(); + + ui::tooltip_container(cx, move |container, cx| { + container + .min_w_40() + .child( + Label::new("Context") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .when(!show_split, |this| { + this.child( + h_flex() + .gap_0p5() + .child(Label::new(percentage.clone())) + .child(Label::new("\u{2022}").color(separator_color).mx_1()) + .child(Label::new(used.clone())) + .child(Label::new("/").color(separator_color)) + .child(Label::new(max.clone()).color(Color::Muted)), + ) + }) + .when(show_split, |this| { + this.child( + v_flex() + .gap_0p5() + .child( + h_flex() + .gap_0p5() + .child(Label::new("Input:").color(Color::Muted).mr_0p5()) + .child(Label::new(input_tokens)) + .child(Label::new("/").color(separator_color)) + .child(Label::new(input_max).color(Color::Muted)), + ) + .child( + h_flex() + .gap_0p5() + .child(Label::new("Output:").color(Color::Muted).mr_0p5()) + .child(Label::new(output_tokens)) + .child(Label::new("/").color(separator_color)) + .child(Label::new(output_max).color(Color::Muted)), + ), + ) + }) + .when( + user_rules_count > 0 || project_rules_count > 0, + move |this| { + this.child( + v_flex() + .mt_1p5() + .pt_1p5() + .pb_0p5() + .gap_0p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new("Rules") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child( + v_flex() + .mx_neg_1() + .when(user_rules_count > 0, move |this| { + this.child( + Button::new( + "open-user-rules", + format!("{} user rules", user_rules_count), + ) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(OpenRulesLibrary { + prompt_to_select: first_user_rules_id, + }), + cx, + ); + }), + ) + }) + .when(project_rules_count > 0, move |this| { + let workspace = workspace.clone(); + let project_entry_ids = project_entry_ids.clone(); + this.child( + Button::new( + "open-project-rules", + format!( + "{} project rules", + project_rules_count + ), + ) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .on_click(move |_, window, cx| { + let _ = + workspace.update(cx, |workspace, cx| { + let project = + workspace.project().read(cx); + let paths = project_entry_ids + .iter() + .flat_map(|id| { + project.path_for_entry(*id, cx) + }) + .collect::>(); + for path in paths { + workspace + .open_path( + path, None, true, window, + cx, + ) + .detach_and_log_err(cx); + } + }); + }), + ) + }), + ), + ) + }, + ) + }) + } +} + impl ThreadView { pub(crate) fn render_entries(&mut self, cx: &mut Context) -> List { list( self.list_state.clone(), cx.processor(|this, index: usize, window, cx| { let entries = this.thread.read(cx).entries(); - let Some(entry) = entries.get(index) else { - return Empty.into_any(); - }; - this.render_entry(index, entries.len(), entry, window, cx) + if let Some(entry) = entries.get(index) { + this.render_entry(index, entries.len(), entry, window, cx) + } else if this.generating_indicator_in_list { + let confirmation = entries + .last() + .is_some_and(|entry| Self::is_waiting_for_confirmation(entry)); + this.render_generating(confirmation, cx).into_any_element() + } else { + Empty.into_any() + } }), ) .with_sizing_behavior(gpui::ListSizingBehavior::Auto) @@ -3959,12 +4341,6 @@ impl ThreadView { let editor_focus = editor.focus_handle(cx).is_focused(window); let focus_border = cx.theme().colors().border_focused; - let rules_item = if entry_ix == 0 { - self.render_rules_item(cx) - } else { - None - }; - let has_checkpoint_button = message .checkpoint .as_ref() @@ -3983,10 +4359,6 @@ impl ThreadView { .map(|this| { if is_first_indented { this.pt_0p5() - } else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() { - this.pt(rems_from_px(18.)) - } else if rules_item.is_some() { - this.pt_3() } else { this.pt_2() } @@ -3995,7 +4367,6 @@ impl ThreadView { .px_2() .gap_1p5() .w_full() - .children(rules_item) .when(is_editable && has_checkpoint_button, |this| { this.children(message.id.clone().map(|message_id| { h_flex() @@ -4212,6 +4583,9 @@ impl ThreadView { cx, ) .into_any(), + AgentThreadEntry::CompletedPlan(entries) => { + self.render_completed_plan(entries, window, cx) + } }; let is_subagent_output = self.is_subagent() @@ -4250,6 +4624,8 @@ impl ThreadView { primary }; + let thread = self.thread.clone(); + let primary = if is_indented { let line_top = if is_first_indented { rems_from_px(-12.0) @@ -4277,28 +4653,16 @@ impl ThreadView { primary }; - let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry { - matches!( - tool_call.status, - ToolCallStatus::WaitingForConfirmation { .. } - ) - } else { - false - }; + let needs_confirmation = Self::is_waiting_for_confirmation(entry); - let thread = self.thread.clone(); let comments_editor = self.thread_feedback.comments_editor.clone(); let primary = if entry_ix + 1 == total_entries { v_flex() .w_full() .child(primary) - .map(|this| { - if needs_confirmation { - this.child(self.render_generating(true, cx)) - } else { - this.child(self.render_thread_controls(&thread, cx)) - } + .when(!needs_confirmation, |this| { + this.child(self.render_thread_controls(&thread, cx)) }) .when_some(comments_editor, |this, editor| { this.child(Self::render_feedback_feedback_editor(editor, cx)) @@ -4382,7 +4746,7 @@ impl ThreadView { ) -> impl IntoElement { let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); if is_generating { - return self.render_generating(false, cx).into_any_element(); + return Empty.into_any_element(); } let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) @@ -4582,13 +4946,12 @@ impl ThreadView { }); cx.notify(); } else { - self.scroll_to_bottom(cx); + self.scroll_to_end(cx); } } - pub fn scroll_to_bottom(&mut self, cx: &mut Context) { - let entry_count = self.thread.read(cx).entries().len(); - self.list_state.reset(entry_count); + pub fn scroll_to_end(&mut self, cx: &mut Context) { + self.list_state.scroll_to_end(); cx.notify(); } @@ -4669,6 +5032,42 @@ impl ThreadView { }) } + pub(crate) fn sync_editor_mode_for_empty_state(&mut self, cx: &mut Context) { + let has_messages = self.list_state.item_count() > 0; + let v2_empty_state = cx.has_flag::() && !has_messages; + + let mode = if v2_empty_state { + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sizing_behavior: SizingBehavior::Default, + } + } else { + EditorMode::AutoHeight { + min_lines: AgentSettings::get_global(cx).message_editor_min_lines, + max_lines: Some(AgentSettings::get_global(cx).set_message_editor_max_lines()), + } + }; + self.message_editor.update(cx, |editor, cx| { + editor.set_mode(mode, cx); + }); + } + + /// Ensures the list item count includes (or excludes) an extra item for the generating indicator + pub(crate) fn sync_generating_indicator(&mut self, cx: &App) { + let is_generating = matches!(self.thread.read(cx).status(), ThreadStatus::Generating); + + if is_generating && !self.generating_indicator_in_list { + let entries_count = self.thread.read(cx).entries().len(); + self.list_state.splice(entries_count..entries_count, 1); + self.generating_indicator_in_list = true; + } else if !is_generating && self.generating_indicator_in_list { + let entries_count = self.thread.read(cx).entries().len(); + self.list_state.splice(entries_count..entries_count + 1, 0); + self.generating_indicator_in_list = false; + } + } + fn render_generating(&self, confirmation: bool, cx: &App) -> impl IntoElement { let show_stats = AgentSettings::get_global(cx).show_turn_stats; let elapsed_label = show_stats @@ -4952,7 +5351,7 @@ impl ThreadView { let entity = entity.clone(); move |_, cx| { entity.update(cx, |this, cx| { - this.scroll_to_bottom(cx); + this.scroll_to_end(cx); }); } }) @@ -5073,7 +5472,9 @@ impl ThreadView { return false; } } - AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::AssistantMessage(_) + | AgentThreadEntry::CompletedPlan(_) => {} } } @@ -7423,113 +7824,6 @@ impl ThreadView { } } - fn render_rules_item(&self, cx: &Context) -> Option { - let project_context = self - .as_native_thread(cx)? - .read(cx) - .project_context() - .read(cx); - - let user_rules_text = if project_context.user_rules.is_empty() { - None - } else if project_context.user_rules.len() == 1 { - let user_rules = &project_context.user_rules[0]; - - match user_rules.title.as_ref() { - Some(title) => Some(format!("Using \"{title}\" user rule")), - None => Some("Using user rule".into()), - } - } else { - Some(format!( - "Using {} user rules", - project_context.user_rules.len() - )) - }; - - let first_user_rules_id = project_context - .user_rules - .first() - .map(|user_rules| user_rules.uuid.0); - - let rules_files = project_context - .worktrees - .iter() - .filter_map(|worktree| worktree.rules_file.as_ref()) - .collect::>(); - - let rules_file_text = match rules_files.as_slice() { - &[] => None, - &[rules_file] => Some(format!( - "Using project {:?} file", - rules_file.path_in_worktree - )), - rules_files => Some(format!("Using {} project rules files", rules_files.len())), - }; - - if user_rules_text.is_none() && rules_file_text.is_none() { - return None; - } - - let has_both = user_rules_text.is_some() && rules_file_text.is_some(); - - Some( - h_flex() - .px_2p5() - .child( - Icon::new(IconName::Attach) - .size(IconSize::XSmall) - .color(Color::Disabled), - ) - .when_some(user_rules_text, |parent, user_rules_text| { - parent.child( - h_flex() - .id("user-rules") - .ml_1() - .mr_1p5() - .child( - Label::new(user_rules_text) - .size(LabelSize::XSmall) - .color(Color::Muted) - .truncate(), - ) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .tooltip(Tooltip::text("View User Rules")) - .on_click(move |_event, window, cx| { - window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: first_user_rules_id, - }), - cx, - ) - }), - ) - }) - .when(has_both, |this| { - this.child( - Label::new("•") - .size(LabelSize::XSmall) - .color(Color::Disabled), - ) - }) - .when_some(rules_file_text, |parent, rules_file_text| { - parent.child( - h_flex() - .id("project-rules") - .ml_1p5() - .child( - Label::new(rules_file_text) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .tooltip(Tooltip::text("View Project Rules")) - .on_click(cx.listener(Self::handle_open_rules)), - ) - }) - .into_any(), - ) - } - fn tool_card_header_bg(&self, cx: &Context) -> Hsla { cx.theme() .colors() diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index ef5e8a9812e8266566f027365e4b270177aab71c..eeaf8f6935a2294d8d9a1fe71b8d8acd62ee43a2 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -16,7 +16,7 @@ use prompt_store::PromptStore; use rope::Point; use settings::Settings as _; use terminal_view::TerminalView; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{Context, TextSize}; use workspace::Workspace; @@ -235,6 +235,11 @@ impl EntryViewState { }; entry.sync(message); } + AgentThreadEntry::CompletedPlan(_) => { + if !matches!(self.entries.get(index), Some(Entry::CompletedPlan)) { + self.set_entry(index, Entry::CompletedPlan); + } + } }; } @@ -253,7 +258,9 @@ impl EntryViewState { pub fn agent_ui_font_size_changed(&mut self, cx: &mut App) { for entry in self.entries.iter() { match entry { - Entry::UserMessage { .. } | Entry::AssistantMessage { .. } => {} + Entry::UserMessage { .. } + | Entry::AssistantMessage { .. } + | Entry::CompletedPlan => {} Entry::ToolCall(ToolCallEntry { content }) => { for view in content.values() { if let Ok(diff_editor) = view.clone().downcast::() { @@ -320,6 +327,7 @@ pub enum Entry { UserMessage(Entity), AssistantMessage(AssistantMessageEntry), ToolCall(ToolCallEntry), + CompletedPlan, } impl Entry { @@ -327,14 +335,14 @@ impl Entry { match self { Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)), Self::AssistantMessage(message) => Some(message.focus_handle.clone()), - Self::ToolCall(_) => None, + Self::ToolCall(_) | Self::CompletedPlan => None, } } pub fn message_editor(&self) -> Option<&Entity> { match self { Self::UserMessage(editor) => Some(editor), - Self::AssistantMessage(_) | Self::ToolCall(_) => None, + Self::AssistantMessage(_) | Self::ToolCall(_) | Self::CompletedPlan => None, } } @@ -361,7 +369,7 @@ impl Entry { ) -> Option { match self { Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix), - Self::UserMessage(_) | Self::ToolCall(_) => None, + Self::UserMessage(_) | Self::ToolCall(_) | Self::CompletedPlan => None, } } @@ -376,7 +384,7 @@ impl Entry { pub fn has_content(&self) -> bool { match self { Self::ToolCall(ToolCallEntry { content }) => !content.is_empty(), - Self::UserMessage(_) | Self::AssistantMessage(_) => false, + Self::UserMessage(_) | Self::AssistantMessage(_) | Self::CompletedPlan => false, } } } @@ -586,7 +594,7 @@ mod tests { 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); release_channel::init(semver::Version::new(0, 0, 0), cx); }); } diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 43e6b1ad393a8ca1d568bc8dc8df6b0fa9d977db..5d168d410476b1a367042d886715c2d57d50477e 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -24,7 +24,7 @@ use std::cmp; use std::ops::Range; use std::rc::Rc; use std::sync::Arc; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::utils::WithRemSize; use ui::{IconButtonShape, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*}; use uuid::Uuid; diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 97adcb6e39092a892c5e56429b9d446f5ee0be68..b8e16de99f13d9eb6925e5618ccca81c742f8d12 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -667,7 +667,7 @@ mod tests { let settings_store = cx.update(SettingsStore::test); cx.set_global(settings_store); cx.update(|cx| { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(Version::new(0, 0, 0), cx); prompt_store::init(cx); }); diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index a82b5c26fe003e01a58358cf75f6a00e7a983b40..44a816f894f791f8b9f3b4753deef7028fae20ab 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -33,7 +33,7 @@ use prompt_store::PromptStore; use rope::Point; use settings::Settings; use std::{fmt::Write, ops::Range, rc::Rc, sync::Arc}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ContextMenu, Disclosure, ElevationIndex, prelude::*}; use util::paths::PathStyle; use util::{ResultExt, debug_panic}; diff --git a/crates/agent_ui/src/test_support.rs b/crates/agent_ui/src/test_support.rs index 375d54263780f8c464904b4c56ab9aeb490a9193..94502485b1f3a2bb6a6d88ccd897de56c5a566f5 100644 --- a/crates/agent_ui/src/test_support.rs +++ b/crates/agent_ui/src/test_support.rs @@ -73,7 +73,7 @@ pub fn init_test(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); editor::init(cx); release_channel::init("0.0.0".parse().unwrap(), cx); agent_panel::init(cx); diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 14aec38e481768b5482a2cbf67df3d59304a915c..180a31edde29b7ef78ee263a437458abd5affafc 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -1030,7 +1030,11 @@ impl TextThreadEditor { h_flex() .items_center() .gap_1() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font( + theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(), + ) .text_size(TextSize::XSmall.rems(cx)) .text_color(colors.text_muted) .child("Press") @@ -3440,7 +3444,7 @@ mod tests { LanguageModelRegistry::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); } #[gpui::test] diff --git a/crates/agent_ui/src/thread_history.rs b/crates/agent_ui/src/thread_history.rs index d2aa38e13ceae1d088e8b078ce741c42f4c31206..7b7a3e60211896bf717fb3dfb2670d92b7409281 100644 --- a/crates/agent_ui/src/thread_history.rs +++ b/crates/agent_ui/src/thread_history.rs @@ -232,7 +232,7 @@ mod tests { cx.update(|cx| { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 03b3f645c5e0eecd6d71bd2f69c545d7a7d23522..9a99ca9fcd5766e041fa50206d6a536ac4a97854 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -325,8 +325,14 @@ impl SidebarThreadMetadataStore { let weak_store = weak_store.clone(); move |thread, cx| { weak_store - .update(cx, |store, _cx| { - store.session_subscriptions.remove(thread.session_id()); + .update(cx, |store, cx| { + let session_id = thread.session_id().clone(); + store.session_subscriptions.remove(&session_id); + if thread.entries().is_empty() { + // Empty threads can be unloaded without ever being + // durably persisted by the underlying agent. + store.delete(session_id, cx); + } }) .ok(); } @@ -998,6 +1004,114 @@ mod tests { assert_eq!(list[0].session_id.0.as_ref(), "existing-session"); } + #[gpui::test] + async fn test_empty_thread_metadata_deleted_when_thread_released(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + cx.update_flags(true, vec!["agent-v2".to_string()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, None::<&Path>, cx).await; + let connection = Rc::new(StubAgentConnection::new()); + + let thread = cx + .update(|cx| { + connection + .clone() + .new_session(project.clone(), PathList::default(), cx) + }) + .await + .unwrap(); + let session_id = cx.read(|cx| thread.read(cx).session_id().clone()); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.set_title("Draft Thread".into(), cx).detach(); + }); + }); + cx.run_until_parked(); + + let metadata_ids = cx.update(|cx| { + SidebarThreadMetadataStore::global(cx) + .read(cx) + .entry_ids() + .collect::>() + }); + assert_eq!(metadata_ids, vec![session_id]); + + drop(thread); + cx.update(|_| {}); + cx.run_until_parked(); + cx.run_until_parked(); + + let metadata_ids = cx.update(|cx| { + SidebarThreadMetadataStore::global(cx) + .read(cx) + .entry_ids() + .collect::>() + }); + assert!( + metadata_ids.is_empty(), + "expected empty draft thread metadata to be deleted on release" + ); + } + + #[gpui::test] + async fn test_nonempty_thread_metadata_preserved_when_thread_released(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = settings::SettingsStore::test(cx); + cx.set_global(settings_store); + cx.update_flags(true, vec!["agent-v2".to_string()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, None::<&Path>, cx).await; + let connection = Rc::new(StubAgentConnection::new()); + + let thread = cx + .update(|cx| { + connection + .clone() + .new_session(project.clone(), PathList::default(), cx) + }) + .await + .unwrap(); + let session_id = cx.read(|cx| thread.read(cx).session_id().clone()); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.push_user_content_block(None, "Hello".into(), cx); + }); + }); + cx.run_until_parked(); + + let metadata_ids = cx.update(|cx| { + SidebarThreadMetadataStore::global(cx) + .read(cx) + .entry_ids() + .collect::>() + }); + assert_eq!(metadata_ids, vec![session_id.clone()]); + + drop(thread); + cx.update(|_| {}); + cx.run_until_parked(); + + let metadata_ids = cx.update(|cx| { + SidebarThreadMetadataStore::global(cx) + .read(cx) + .entry_ids() + .collect::>() + }); + assert_eq!(metadata_ids, vec![session_id]); + } + #[gpui::test] async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) { cx.update(|cx| { diff --git a/crates/agent_ui/src/ui/agent_notification.rs b/crates/agent_ui/src/ui/agent_notification.rs index 371523f129869786f13d1a220747f4d0d944d1e5..18a4161f1df99988177462059870234f81e48b5c 100644 --- a/crates/agent_ui/src/ui/agent_notification.rs +++ b/crates/agent_ui/src/ui/agent_notification.rs @@ -5,7 +5,6 @@ use gpui::{ }; use release_channel::ReleaseChannel; use std::rc::Rc; -use theme; use ui::{Render, prelude::*}; pub struct AgentNotification { @@ -87,7 +86,7 @@ impl AgentNotification { impl Render for AgentNotification { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let line_height = window.line_height(); let bg = cx.theme().colors().elevated_surface_background; diff --git a/crates/agent_ui/src/ui/mention_crease.rs b/crates/agent_ui/src/ui/mention_crease.rs index b70b77e6ca603aba8fd55706918ffb3543e2a734..91200684d7ca1891578bb70fd6db65b2885aed93 100644 --- a/crates/agent_ui/src/ui/mention_crease.rs +++ b/crates/agent_ui/src/ui/mention_crease.rs @@ -9,7 +9,7 @@ use gpui::{ use prompt_store::PromptId; use rope::Point; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ButtonLike, TintColor, Tooltip, prelude::*}; use workspace::{OpenOptions, Workspace}; diff --git a/crates/buffer_diff/src/buffer_diff.rs b/crates/buffer_diff/src/buffer_diff.rs index 2ea1c349074dcadf9723e11df154f3d9e9bf3d75..a1acefe2f315b29e9a321177e4395ead337fe06a 100644 --- a/crates/buffer_diff/src/buffer_diff.rs +++ b/crates/buffer_diff/src/buffer_diff.rs @@ -1651,6 +1651,7 @@ impl BufferDiff { language: Option>, cx: &App, ) -> Task { + let base_text = base_text.map(|t| text::LineEnding::normalize_arc(t)); let prev_base_text = self.base_text(cx).as_rope().clone(); let base_text_changed = base_text_change.is_some(); let compute_base_text_edits = base_text_change == Some(true); @@ -3947,4 +3948,36 @@ mod tests { } } } + + #[gpui::test] + async fn test_set_base_text_with_crlf(cx: &mut gpui::TestAppContext) { + let base_text_crlf = "one\r\ntwo\r\nthree\r\nfour\r\nfive\r\n"; + let base_text_lf = "one\ntwo\nthree\nfour\nfive\n"; + assert_ne!(base_text_crlf.len(), base_text_lf.len()); + + let buffer_text = "one\nTWO\nthree\nfour\nfive\n"; + let buffer = Buffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + buffer_text.to_string(), + ); + let buffer_snapshot = buffer.snapshot(); + + let diff = cx.new(|cx| BufferDiff::new(&buffer_snapshot, cx)); + diff.update(cx, |diff, cx| { + diff.set_base_text( + Some(Arc::from(base_text_crlf)), + None, + buffer_snapshot.clone(), + cx, + ) + }) + .await + .ok(); + cx.run_until_parked(); + + let snapshot = diff.update(cx, |diff, cx| diff.snapshot(cx)); + snapshot.buffer_point_to_base_text_range(Point::new(0, 0), &buffer_snapshot); + snapshot.buffer_point_to_base_text_range(Point::new(1, 0), &buffer_snapshot); + } } diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index d9ef55056049e387d931bc9fe59e0327b4ce1637..1edbb3399e4332e2ebd23f812c66697bda72d587 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -25,6 +25,7 @@ cloud_api_client.workspace = true cloud_llm_client.workspace = true collections.workspace = true credentials_provider.workspace = true +db.workspace = true derive_more.workspace = true feature_flags.workspace = true fs.workspace = true diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index df0c00d86636b6b4a138161a151e20a1c50a688d..e9b9acf68573ef5a05d642c09ed96a4d8aa23580 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -9,6 +9,7 @@ use cloud_llm_client::{ EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME, UsageLimit, }; use collections::{HashMap, HashSet, hash_map::Entry}; +use db::kvp::KeyValueStore; use derive_more::Deref; use feature_flags::FeatureFlagAppExt; use futures::{Future, StreamExt, channel::mpsc}; @@ -25,6 +26,8 @@ use std::{ use text::ReplicaId; use util::{ResultExt, TryFutureExt as _}; +const CURRENT_ORGANIZATION_ID_KEY: &str = "current_organization_id"; + pub type UserId = u64; #[derive( @@ -706,9 +709,16 @@ impl UserStore { .is_some_and(|current| current.id == organization.id); if !is_same_organization { + let organization_id = organization.id.0.to_string(); self.current_organization.replace(organization); cx.emit(Event::OrganizationChanged); cx.notify(); + + let kvp = KeyValueStore::global(cx); + db::write_and_log(cx, move || async move { + kvp.write_kvp(CURRENT_ORGANIZATION_ID_KEY.into(), organization_id) + .await + }); } } @@ -816,14 +826,29 @@ impl UserStore { } self.organizations = response.organizations.into_iter().map(Arc::new).collect(); - self.current_organization = response - .default_organization_id - .and_then(|default_organization_id| { + let persisted_org_id = KeyValueStore::global(cx) + .read_kvp(CURRENT_ORGANIZATION_ID_KEY) + .log_err() + .flatten() + .map(|id| OrganizationId(Arc::from(id))); + + self.current_organization = persisted_org_id + .and_then(|persisted_id| { self.organizations .iter() - .find(|organization| organization.id == default_organization_id) + .find(|org| org.id == persisted_id) .cloned() }) + .or_else(|| { + response + .default_organization_id + .and_then(|default_organization_id| { + self.organizations + .iter() + .find(|organization| organization.id == default_organization_id) + .cloned() + }) + }) .or_else(|| self.organizations.first().cloned()); self.plans_by_organization = response .plans_by_organization diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 447c2da08e054c9964f3813ac569964173ded5c3..41f1ba2c14c6c09bcbf6861674a845b3954aa733 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -130,6 +130,7 @@ settings = { workspace = true, features = ["test-support"] } smol.workspace = true sqlx = { version = "0.8", features = ["sqlite"] } task.workspace = true +theme_settings = { workspace = true, features = ["test-support"] } theme.workspace = true unindent.workspace = true diff --git a/crates/collab/tests/integration/randomized_test_helpers.rs b/crates/collab/tests/integration/randomized_test_helpers.rs index a6772019768ba19e2a92843a1e33b256f0eb8b0c..0a2555929a959fe04735ad7cb03595eea56c5cf5 100644 --- a/crates/collab/tests/integration/randomized_test_helpers.rs +++ b/crates/collab/tests/integration/randomized_test_helpers.rs @@ -191,7 +191,7 @@ pub async fn run_randomized_test( let settings = cx.remove_global::(); cx.clear_globals(); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); drop(client); }); executor.run_until_parked(); diff --git a/crates/collab/tests/integration/test_server.rs b/crates/collab/tests/integration/test_server.rs index 7472bd01173eca007eb762bf6e7920c55489ae7d..cca48bea973f178000d24bddcbb73252c5657b53 100644 --- a/crates/collab/tests/integration/test_server.rs +++ b/crates/collab/tests/integration/test_server.rs @@ -173,7 +173,7 @@ impl TestServer { } let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); }); @@ -341,7 +341,7 @@ impl TestServer { let os_keymap = "keymaps/default-macos.json"; cx.update(|cx| { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); Project::init(&client, cx); client::init(&client, cx); editor::init(cx); diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 498f3f0bd76e002797389a279a17849448e6e873..efcba05456955e308e5a00e938bf3092d894efeb 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -54,6 +54,7 @@ settings.workspace = true smallvec.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true time.workspace = true time_format.workspace = true title_bar.workspace = true diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 3c32e76fea6dcd64b2a8b74c565544954af28c44..328a97ce3296aefbabc284b91da62530b0106359 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -29,7 +29,8 @@ use serde::{Deserialize, Serialize}; use settings::Settings; use smallvec::SmallVec; use std::{mem, sync::Arc}; -use theme::{ActiveTheme, ThemeSettings}; +use theme::ActiveTheme; +use theme_settings::ThemeSettings; use ui::{ Avatar, AvatarAvailabilityIndicator, ContextMenu, CopyButton, Facepile, HighlightedLabel, IconButtonShape, Indicator, ListHeader, ListItem, Tab, Tooltip, prelude::*, tooltip_container, @@ -61,6 +62,8 @@ actions!( /// /// Use `collab::OpenChannelNotes` to open the channel notes for the current call. OpenSelectedChannelNotes, + /// Toggles whether the selected channel is in the Favorites section. + ToggleSelectedChannelFavorite, /// Starts moving a channel to a new location. StartMoveChannel, /// Moves the selected item to the current location. @@ -256,6 +259,7 @@ pub struct CollabPanel { subscriptions: Vec, collapsed_sections: Vec
, collapsed_channels: Vec, + favorite_channels: Vec, filter_active_channels: bool, workspace: WeakEntity, } @@ -263,11 +267,14 @@ pub struct CollabPanel { #[derive(Serialize, Deserialize)] struct SerializedCollabPanel { collapsed_channels: Option>, + #[serde(default)] + favorite_channels: Option>, } #[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)] enum Section { ActiveCall, + FavoriteChannels, Channels, ChannelInvites, ContactRequests, @@ -387,6 +394,7 @@ impl CollabPanel { match_candidates: Vec::default(), collapsed_sections: vec![Section::Offline], collapsed_channels: Vec::default(), + favorite_channels: Vec::default(), filter_active_channels: false, workspace: workspace.weak_handle(), client: workspace.app_state().client.clone(), @@ -460,7 +468,13 @@ impl CollabPanel { panel.update(cx, |panel, cx| { panel.collapsed_channels = serialized_panel .collapsed_channels - .unwrap_or_else(Vec::new) + .unwrap_or_default() + .iter() + .map(|cid| ChannelId(*cid)) + .collect(); + panel.favorite_channels = serialized_panel + .favorite_channels + .unwrap_or_default() .iter() .map(|cid| ChannelId(*cid)) .collect(); @@ -493,12 +507,22 @@ impl CollabPanel { } else { Some(self.collapsed_channels.iter().map(|id| id.0).collect()) }; + + let favorite_channels = if self.favorite_channels.is_empty() { + None + } else { + Some(self.favorite_channels.iter().map(|id| id.0).collect()) + }; + let kvp = KeyValueStore::global(cx); self.pending_serialization = cx.background_spawn( async move { kvp.write_kvp( serialization_key, - serde_json::to_string(&SerializedCollabPanel { collapsed_channels })?, + serde_json::to_string(&SerializedCollabPanel { + collapsed_channels, + favorite_channels, + })?, ) .await?; anyhow::Ok(()) @@ -512,10 +536,8 @@ impl CollabPanel { } fn update_entries(&mut self, select_same_item: bool, cx: &mut Context) { - let channel_store = self.channel_store.read(cx); - let user_store = self.user_store.read(cx); let query = self.filter_editor.read(cx).text(cx); - let fg_executor = cx.foreground_executor(); + let fg_executor = cx.foreground_executor().clone(); let executor = cx.background_executor().clone(); let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned()); @@ -541,7 +563,7 @@ impl CollabPanel { } // Populate the active user. - if let Some(user) = user_store.current_user() { + if let Some(user) = self.user_store.read(cx).current_user() { self.match_candidates.clear(); self.match_candidates .push(StringMatchCandidate::new(0, &user.github_login)); @@ -662,6 +684,64 @@ impl CollabPanel { let mut request_entries = Vec::new(); + if self.channel_store.read(cx).channel_count() > 0 { + let previous_len = self.favorite_channels.len(); + self.favorite_channels + .retain(|id| self.channel_store.read(cx).channel_for_id(*id).is_some()); + if self.favorite_channels.len() != previous_len { + self.serialize(cx); + } + } + + let channel_store = self.channel_store.read(cx); + let user_store = self.user_store.read(cx); + + if !self.favorite_channels.is_empty() { + let favorite_channels: Vec<_> = self + .favorite_channels + .iter() + .filter_map(|id| channel_store.channel_for_id(*id)) + .collect(); + + self.match_candidates.clear(); + self.match_candidates.extend( + favorite_channels + .iter() + .enumerate() + .map(|(ix, channel)| StringMatchCandidate::new(ix, &channel.name)), + ); + + let matches = fg_executor.block_on(match_strings( + &self.match_candidates, + &query, + true, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + + if !matches.is_empty() || query.is_empty() { + self.entries + .push(ListEntry::Header(Section::FavoriteChannels)); + + let matches_by_candidate: HashMap = + matches.iter().map(|mat| (mat.candidate_id, mat)).collect(); + + for (ix, channel) in favorite_channels.iter().enumerate() { + if !query.is_empty() && !matches_by_candidate.contains_key(&ix) { + continue; + } + self.entries.push(ListEntry::Channel { + channel: (*channel).clone(), + depth: 0, + has_children: false, + string_match: matches_by_candidate.get(&ix).cloned().cloned(), + }); + } + } + } + self.entries.push(ListEntry::Header(Section::Channels)); if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() { @@ -1359,6 +1439,18 @@ impl CollabPanel { window.handler_for(&this, move |this, _, cx| { this.copy_channel_notes_link(channel_id, cx) }), + ) + .separator() + .entry( + if self.is_channel_favorited(channel_id) { + "Remove from Favorites" + } else { + "Add to Favorites" + }, + None, + window.handler_for(&this, move |this, _window, cx| { + this.toggle_favorite_channel(channel_id, cx) + }), ); let mut has_destructive_actions = false; @@ -1608,7 +1700,8 @@ impl CollabPanel { Section::ActiveCall => Self::leave_call(window, cx), Section::Channels => self.new_root_channel(window, cx), Section::Contacts => self.toggle_contact_finder(window, cx), - Section::ContactRequests + Section::FavoriteChannels + | Section::ContactRequests | Section::Online | Section::Offline | Section::ChannelInvites => { @@ -1838,6 +1931,24 @@ impl CollabPanel { self.collapsed_channels.binary_search(&channel_id).is_ok() } + fn toggle_favorite_channel(&mut self, channel_id: ChannelId, cx: &mut Context) { + match self.favorite_channels.binary_search(&channel_id) { + Ok(ix) => { + self.favorite_channels.remove(ix); + } + Err(ix) => { + self.favorite_channels.insert(ix, channel_id); + } + }; + self.serialize(cx); + self.update_entries(true, cx); + cx.notify(); + } + + fn is_channel_favorited(&self, channel_id: ChannelId) -> bool { + self.favorite_channels.binary_search(&channel_id).is_ok() + } + fn leave_call(window: &mut Window, cx: &mut App) { ActiveCall::global(cx) .update(cx, |call, cx| call.hang_up(cx)) @@ -1954,6 +2065,17 @@ impl CollabPanel { } } + fn toggle_selected_channel_favorite( + &mut self, + _: &ToggleSelectedChannelFavorite, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(channel) = self.selected_channel() { + self.toggle_favorite_channel(channel.id, cx); + } + } + fn set_channel_visibility( &mut self, channel_id: ChannelId, @@ -2340,46 +2462,57 @@ impl CollabPanel { fn render_signed_out(&mut self, cx: &mut Context) -> Div { let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more."; - let is_signing_in = self.client.status().borrow().is_signing_in(); - let button_label = if is_signing_in { - "Signing in…" + + // Two distinct "not connected" states: + // - Authenticated (has credentials): user just needs to connect. + // - Unauthenticated (no credentials): user needs to sign in via GitHub. + let is_authenticated = self.client.user_id().is_some(); + let status = *self.client.status().borrow(); + let is_busy = status.is_signing_in(); + + let (button_id, button_label, button_icon) = if is_authenticated { + ( + "connect", + if is_busy { "Connecting…" } else { "Connect" }, + IconName::Public, + ) } else { - "Sign in" + ( + "sign_in", + if is_busy { + "Signing in…" + } else { + "Sign In with GitHub" + }, + IconName::Github, + ) }; v_flex() - .gap_6() .p_4() + .gap_4() + .size_full() + .text_center() + .justify_center() .child(Label::new(collab_blurb)) .child( - v_flex() - .gap_2() - .child( - Button::new("sign_in", button_label) - .start_icon(Icon::new(IconName::Github).color(Color::Muted)) - .style(ButtonStyle::Filled) - .full_width() - .disabled(is_signing_in) - .on_click(cx.listener(|this, _, window, cx| { - let client = this.client.clone(); - let workspace = this.workspace.clone(); - cx.spawn_in(window, async move |_, mut cx| { - client - .connect(true, &mut cx) - .await - .into_response() - .notify_workspace_async_err(workspace, &mut cx); - }) - .detach() - })), - ) - .child( - v_flex().w_full().items_center().child( - Label::new("Sign in to enable collaboration.") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ), + Button::new(button_id, button_label) + .full_width() + .start_icon(Icon::new(button_icon).color(Color::Muted)) + .style(ButtonStyle::Outlined) + .disabled(is_busy) + .on_click(cx.listener(|this, _, window, cx| { + let client = this.client.clone(); + let workspace = this.workspace.clone(); + cx.spawn_in(window, async move |_, mut cx| { + client + .connect(true, &mut cx) + .await + .into_response() + .notify_workspace_async_err(workspace, &mut cx); + }) + .detach() + })), ) } @@ -2578,6 +2711,7 @@ impl CollabPanel { SharedString::from("Current Call") } } + Section::FavoriteChannels => SharedString::from("Favorites"), Section::ContactRequests => SharedString::from("Requests"), Section::Contacts => SharedString::from("Contacts"), Section::Channels => SharedString::from("Channels"), @@ -2595,6 +2729,7 @@ impl CollabPanel { }), Section::Contacts => Some( IconButton::new("add-contact", IconName::Plus) + .icon_size(IconSize::Small) .on_click( cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)), ) @@ -2608,9 +2743,6 @@ impl CollabPanel { IconButton::new("filter-active-channels", IconName::ListFilter) .icon_size(IconSize::Small) .toggle_state(self.filter_active_channels) - .when(!self.filter_active_channels, |button| { - button.visible_on_hover("section-header") - }) .on_click(cx.listener(|this, _, _window, cx| { this.filter_active_channels = !this.filter_active_channels; this.update_entries(true, cx); @@ -2618,15 +2750,16 @@ impl CollabPanel { .tooltip(Tooltip::text(if self.filter_active_channels { "Show All Channels" } else { - "Show Active Channels" + "Show Occupied Channels" })), ) .child( IconButton::new("add-channel", IconName::Plus) + .icon_size(IconSize::Small) .on_click(cx.listener(|this, _, window, cx| { this.new_root_channel(window, cx) })) - .tooltip(Tooltip::text("Create a channel")), + .tooltip(Tooltip::text("Create Channel")), ) .into_any_element(), ) @@ -2635,7 +2768,11 @@ impl CollabPanel { }; let can_collapse = match section { - Section::ActiveCall | Section::Channels | Section::Contacts => false, + Section::ActiveCall + | Section::Channels + | Section::Contacts + | Section::FavoriteChannels => false, + Section::ChannelInvites | Section::ContactRequests | Section::Online @@ -2921,11 +3058,17 @@ impl CollabPanel { .unwrap_or(px(240.)); let root_id = channel.root_id(); - div() - .h_6() + let is_favorited = self.is_channel_favorited(channel_id); + let (favorite_icon, favorite_color, favorite_tooltip) = if is_favorited { + (IconName::StarFilled, Color::Accent, "Remove from Favorites") + } else { + (IconName::Star, Color::Muted, "Add to Favorites") + }; + + h_flex() .id(channel_id.0 as usize) .group("") - .flex() + .h_6() .w_full() .when(!channel.is_root_channel(), |el| { el.on_drag(channel.clone(), move |channel, _, _, cx| { @@ -2955,6 +3098,7 @@ impl CollabPanel { .child( ListItem::new(channel_id.0 as usize) // Add one level of depth for the disclosure arrow. + .height(px(26.)) .indent_level(depth + 1) .indent_step_size(px(20.)) .toggle_state(is_selected || is_active) @@ -2980,78 +3124,105 @@ impl CollabPanel { ) }, )) - .start_slot( - div() - .relative() - .child( - Icon::new(if is_public { - IconName::Public - } else { - IconName::Hash - }) - .size(IconSize::Small) - .color(Color::Muted), - ) - .children(has_notes_notification.then(|| { - div() - .w_1p5() - .absolute() - .right(px(-1.)) - .top(px(-1.)) - .child(Indicator::dot().color(Color::Info)) - })), - ) .child( h_flex() - .id(channel_id.0 as usize) - .child(match string_match { - None => Label::new(channel.name.clone()).into_any_element(), - Some(string_match) => HighlightedLabel::new( - channel.name.clone(), - string_match.positions.clone(), - ) - .into_any_element(), - }) - .children(face_pile.map(|face_pile| face_pile.p_1())), + .id(format!("inside-{}", channel_id.0)) + .w_full() + .gap_1() + .child( + div() + .relative() + .child( + Icon::new(if is_public { + IconName::Public + } else { + IconName::Hash + }) + .size(IconSize::Small) + .color(Color::Muted), + ) + .children(has_notes_notification.then(|| { + div() + .w_1p5() + .absolute() + .right(px(-1.)) + .top(px(-1.)) + .child(Indicator::dot().color(Color::Info)) + })), + ) + .child( + h_flex() + .id(channel_id.0 as usize) + .child(match string_match { + None => Label::new(channel.name.clone()).into_any_element(), + Some(string_match) => HighlightedLabel::new( + channel.name.clone(), + string_match.positions.clone(), + ) + .into_any_element(), + }) + .children(face_pile.map(|face_pile| face_pile.p_1())), + ) + .tooltip({ + let channel_store = self.channel_store.clone(); + move |_window, cx| { + cx.new(|_| JoinChannelTooltip { + channel_store: channel_store.clone(), + channel_id, + has_notes_notification, + }) + .into() + } + }), ), ) .child( - h_flex().absolute().right(rems(0.)).h_full().child( - h_flex() - .h_full() - .bg(cx.theme().colors().background) - .rounded_l_sm() - .gap_1() - .px_1() - .child( - IconButton::new("channel_notes", IconName::Reader) - .style(ButtonStyle::Filled) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(if has_notes_notification { - Color::Default - } else { - Color::Muted - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_channel_notes(channel_id, window, cx) - })) - .tooltip(Tooltip::text("Open channel notes")), - ) - .visible_on_hover(""), - ), - ) - .tooltip({ - let channel_store = self.channel_store.clone(); - move |_window, cx| { - cx.new(|_| JoinChannelTooltip { - channel_store: channel_store.clone(), - channel_id, - has_notes_notification, + h_flex() + .absolute() + .right_0() + .visible_on_hover("") + .h_full() + .pl_1() + .pr_1p5() + .gap_0p5() + .bg(cx.theme().colors().background.opacity(0.5)) + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("channel_favorite", favorite_icon) + .icon_size(IconSize::Small) + .icon_color(favorite_color) + .on_click(cx.listener(move |this, _, _window, cx| { + this.toggle_favorite_channel(channel_id, cx) + })) + .tooltip(move |_window, cx| { + Tooltip::for_action_in( + favorite_tooltip, + &ToggleSelectedChannelFavorite, + &focus_handle, + cx, + ) + }) }) - .into() - } - }) + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("channel_notes", IconName::Reader) + .icon_size(IconSize::Small) + .when(!has_notes_notification, |this| { + this.icon_color(Color::Muted) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_channel_notes(channel_id, window, cx) + })) + .tooltip(move |_window, cx| { + Tooltip::for_action_in( + "Open Channel Notes", + &OpenSelectedChannelNotes, + &focus_handle, + cx, + ) + }) + }), + ) } fn render_channel_editor( @@ -3150,6 +3321,7 @@ impl Render for CollabPanel { .on_action(cx.listener(CollabPanel::show_inline_context_menu)) .on_action(cx.listener(CollabPanel::rename_selected_channel)) .on_action(cx.listener(CollabPanel::open_selected_channel_notes)) + .on_action(cx.listener(CollabPanel::toggle_selected_channel_favorite)) .on_action(cx.listener(CollabPanel::collapse_selected_channel)) .on_action(cx.listener(CollabPanel::expand_selected_channel)) .on_action(cx.listener(CollabPanel::start_move_selected_channel)) @@ -3371,7 +3543,7 @@ impl Render for JoinChannelTooltip { .channel_participants(self.channel_id); container - .child(Label::new("Join channel")) + .child(Label::new("Join Channel")) .children(participants.iter().map(|participant| { h_flex() .gap_2() diff --git a/crates/collab_ui/src/notifications/incoming_call_notification.rs b/crates/collab_ui/src/notifications/incoming_call_notification.rs index 164b91395a8853c330e2f7842b5676fff0916e63..71940794f4180e18d54a8b2ff258d37642c1e83b 100644 --- a/crates/collab_ui/src/notifications/incoming_call_notification.rs +++ b/crates/collab_ui/src/notifications/incoming_call_notification.rs @@ -111,7 +111,7 @@ impl IncomingCallNotification { impl Render for IncomingCallNotification { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); div().size_full().font(ui_font).child( CollabNotification::new( diff --git a/crates/collab_ui/src/notifications/project_shared_notification.rs b/crates/collab_ui/src/notifications/project_shared_notification.rs index 165e46458438850f872794d057c17faee86775e2..3c231c5397af23656cc914e71269bdfff52d4af1 100644 --- a/crates/collab_ui/src/notifications/project_shared_notification.rs +++ b/crates/collab_ui/src/notifications/project_shared_notification.rs @@ -120,7 +120,7 @@ impl ProjectSharedNotification { impl Render for ProjectSharedNotification { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let no_worktree_root_names = self.worktree_root_names.is_empty(); let punctuation = if no_worktree_root_names { "" } else { ":" }; diff --git a/crates/collections/Cargo.toml b/crates/collections/Cargo.toml index 8675504347f171397ea7372841cb00b7959eafe3..aa3dd899a7222f38377ea5f62927eea23534d1d8 100644 --- a/crates/collections/Cargo.toml +++ b/crates/collections/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true publish = false license = "Apache-2.0" -description = "Standard collection type re-exports used by Zed and GPUI" +description = "Standard collection types used by Zed and GPUI" [lints] workspace = true diff --git a/crates/collections/src/collections.rs b/crates/collections/src/collections.rs index ea5ea7332fb14e5e2ac33ba2d6f957dbfdc28c7a..8e6c334d2bd5d544f36666184df5fe095c3fdbe1 100644 --- a/crates/collections/src/collections.rs +++ b/crates/collections/src/collections.rs @@ -7,3 +7,7 @@ pub use indexmap::Equivalent; pub use rustc_hash::FxHasher; pub use rustc_hash::{FxHashMap, FxHashSet}; pub use std::collections::*; + +pub mod vecmap; +#[cfg(test)] +mod vecmap_tests; diff --git a/crates/collections/src/vecmap.rs b/crates/collections/src/vecmap.rs new file mode 100644 index 0000000000000000000000000000000000000000..bec6596b924742daf4e1da3831f1182557875d61 --- /dev/null +++ b/crates/collections/src/vecmap.rs @@ -0,0 +1,192 @@ +/// A collection that provides a map interface but is backed by vectors. +/// +/// This is suitable for small key-value stores where the item count is not +/// large enough to overcome the overhead of a more complex algorithm. +/// +/// If this meets your use cases, then [`VecMap`] should be a drop-in +/// replacement for [`std::collections::HashMap`] or [`crate::HashMap`]. Note +/// that we are adding APIs on an as-needed basis. If the API you need is not +/// present yet, please add it! +/// +/// Because it uses vectors as a backing store, the map also iterates over items +/// in insertion order, like [`crate::IndexMap`]. +/// +/// This struct uses a struct-of-arrays (SoA) representation which tends to be +/// more cache efficient and promotes autovectorization when using simple key or +/// value types. +#[derive(Default)] +pub struct VecMap { + keys: Vec, + values: Vec, +} + +impl VecMap { + pub fn new() -> Self { + Self { + keys: Vec::new(), + values: Vec::new(), + } + } + + pub fn iter(&self) -> Iter<'_, K, V> { + Iter { + iter: self.keys.iter().zip(self.values.iter()), + } + } +} + +impl VecMap { + pub fn entry(&mut self, key: K) -> Entry<'_, K, V> { + match self.keys.iter().position(|k| k == &key) { + Some(index) => Entry::Occupied(OccupiedEntry { + key: &self.keys[index], + value: &mut self.values[index], + }), + None => Entry::Vacant(VacantEntry { map: self, key }), + } + } + + /// Like [`Self::entry`] but takes its key by reference instead of by value. + /// + /// This can be helpful if you have a key where cloning is expensive, as we + /// can avoid cloning the key until a value is inserted under that entry. + pub fn entry_ref<'a, 'k>(&'a mut self, key: &'k K) -> EntryRef<'k, 'a, K, V> { + match self.keys.iter().position(|k| k == key) { + Some(index) => EntryRef::Occupied(OccupiedEntry { + key: &self.keys[index], + value: &mut self.values[index], + }), + None => EntryRef::Vacant(VacantEntryRef { map: self, key }), + } + } +} + +pub struct Iter<'a, K, V> { + iter: std::iter::Zip, std::slice::Iter<'a, V>>, +} + +impl<'a, K, V> Iterator for Iter<'a, K, V> { + type Item = (&'a K, &'a V); + + fn next(&mut self) -> Option { + self.iter.next() + } +} + +pub enum Entry<'a, K, V> { + Occupied(OccupiedEntry<'a, K, V>), + Vacant(VacantEntry<'a, K, V>), +} + +impl<'a, K, V> Entry<'a, K, V> { + pub fn key(&self) -> &K { + match self { + Entry::Occupied(entry) => entry.key, + Entry::Vacant(entry) => &entry.key, + } + } + + pub fn or_insert_with_key(self, default: F) -> &'a mut V + where + F: FnOnce(&K) -> V, + { + match self { + Entry::Occupied(entry) => entry.value, + Entry::Vacant(entry) => { + entry.map.values.push(default(&entry.key)); + entry.map.keys.push(entry.key); + match entry.map.values.last_mut() { + Some(value) => value, + None => unreachable!("vec empty after pushing to it"), + } + } + } + } + + pub fn or_insert_with(self, default: F) -> &'a mut V + where + F: FnOnce() -> V, + { + self.or_insert_with_key(|_| default()) + } + + pub fn or_insert(self, value: V) -> &'a mut V { + self.or_insert_with_key(|_| value) + } + + pub fn or_insert_default(self) -> &'a mut V + where + V: Default, + { + self.or_insert_with_key(|_| Default::default()) + } +} + +pub struct OccupiedEntry<'a, K, V> { + key: &'a K, + value: &'a mut V, +} + +pub struct VacantEntry<'a, K, V> { + map: &'a mut VecMap, + key: K, +} + +pub enum EntryRef<'key, 'map, K, V> { + Occupied(OccupiedEntry<'map, K, V>), + Vacant(VacantEntryRef<'key, 'map, K, V>), +} + +impl<'key, 'map, K, V> EntryRef<'key, 'map, K, V> { + pub fn key(&self) -> &K { + match self { + EntryRef::Occupied(entry) => entry.key, + EntryRef::Vacant(entry) => entry.key, + } + } +} + +impl<'key, 'map, K, V> EntryRef<'key, 'map, K, V> +where + K: Clone, +{ + pub fn or_insert_with_key(self, default: F) -> &'map mut V + where + F: FnOnce(&K) -> V, + { + match self { + EntryRef::Occupied(entry) => entry.value, + EntryRef::Vacant(entry) => { + entry.map.values.push(default(entry.key)); + entry.map.keys.push(entry.key.clone()); + match entry.map.values.last_mut() { + Some(value) => value, + None => unreachable!("vec empty after pushing to it"), + } + } + } + } + + pub fn or_insert_with(self, default: F) -> &'map mut V + where + F: FnOnce() -> V, + { + self.or_insert_with_key(|_| default()) + } + + pub fn or_insert(self, value: V) -> &'map mut V { + self.or_insert_with_key(|_| value) + } + + pub fn or_insert_default(self) -> &'map mut V + where + V: Default, + { + self.or_insert_with_key(|_| Default::default()) + } +} + +pub struct VacantEntryRef<'key, 'map, K, V> { + map: &'map mut VecMap, + key: &'key K, +} diff --git a/crates/collections/src/vecmap_tests.rs b/crates/collections/src/vecmap_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..1f698f8331cc5044f23c19603005e253f8a81ef3 --- /dev/null +++ b/crates/collections/src/vecmap_tests.rs @@ -0,0 +1,211 @@ +//! Tests for the VecMap collection. +//! +//! This is in a sibling module so that the tests are guaranteed to only cover +//! states that can be created by the public API. + +use crate::vecmap::*; + +#[test] +fn test_entry_vacant_or_insert() { + let mut map: VecMap<&str, i32> = VecMap::new(); + let value = map.entry("a").or_insert(1); + assert_eq!(*value, 1); + assert_eq!(map.iter().collect::>(), vec![(&"a", &1)]); +} + +#[test] +fn test_entry_occupied_or_insert_keeps_existing() { + let mut map: VecMap<&str, i32> = VecMap::new(); + map.entry("a").or_insert(1); + let value = map.entry("a").or_insert(99); + assert_eq!(*value, 1); + assert_eq!(map.iter().collect::>(), vec![(&"a", &1)]); +} + +#[test] +fn test_entry_or_insert_with() { + let mut map: VecMap<&str, i32> = VecMap::new(); + map.entry("a").or_insert_with(|| 42); + assert_eq!(map.iter().collect::>(), vec![(&"a", &42)]); +} + +#[test] +fn test_entry_or_insert_with_not_called_when_occupied() { + let mut map: VecMap<&str, i32> = VecMap::new(); + map.entry("a").or_insert(1); + map.entry("a") + .or_insert_with(|| panic!("should not be called")); + assert_eq!(map.iter().collect::>(), vec![(&"a", &1)]); +} + +#[test] +fn test_entry_or_insert_with_key() { + let mut map: VecMap<&str, String> = VecMap::new(); + map.entry("hello").or_insert_with_key(|k| k.to_uppercase()); + assert_eq!( + map.iter().collect::>(), + vec![(&"hello", &"HELLO".to_string())] + ); +} + +#[test] +fn test_entry_or_insert_default() { + let mut map: VecMap<&str, i32> = VecMap::new(); + map.entry("a").or_insert_default(); + assert_eq!(map.iter().collect::>(), vec![(&"a", &0)]); +} + +#[test] +fn test_entry_key() { + let mut map: VecMap<&str, i32> = VecMap::new(); + assert_eq!(*map.entry("a").key(), "a"); + map.entry("a").or_insert(1); + assert_eq!(*map.entry("a").key(), "a"); +} + +#[test] +fn test_entry_mut_ref_can_be_updated() { + let mut map: VecMap<&str, i32> = VecMap::new(); + let value = map.entry("a").or_insert(0); + *value = 5; + assert_eq!(map.iter().collect::>(), vec![(&"a", &5)]); +} + +#[test] +fn test_insertion_order_preserved() { + let mut map: VecMap<&str, i32> = VecMap::new(); + map.entry("b").or_insert(2); + map.entry("a").or_insert(1); + map.entry("c").or_insert(3); + assert_eq!( + map.iter().collect::>(), + vec![(&"b", &2), (&"a", &1), (&"c", &3)] + ); +} + +#[test] +fn test_multiple_entries_independent() { + let mut map: VecMap = VecMap::new(); + map.entry(1).or_insert(10); + map.entry(2).or_insert(20); + map.entry(3).or_insert(30); + assert_eq!(map.iter().count(), 3); + // Re-inserting does not duplicate keys + map.entry(1).or_insert(99); + assert_eq!(map.iter().count(), 3); +} + +// entry_ref tests + +use std::cell::Cell; +use std::rc::Rc; + +#[derive(PartialEq, Eq)] +struct CountedKey { + value: String, + clone_count: Rc>, +} + +impl Clone for CountedKey { + fn clone(&self) -> Self { + self.clone_count.set(self.clone_count.get() + 1); + CountedKey { + value: self.value.clone(), + clone_count: self.clone_count.clone(), + } + } +} + +#[test] +fn test_entry_ref_vacant_or_insert() { + let mut map: VecMap = VecMap::new(); + let key = "a".to_string(); + let value = map.entry_ref(&key).or_insert(1); + assert_eq!(*value, 1); + assert_eq!(map.iter().count(), 1); +} + +#[test] +fn test_entry_ref_occupied_or_insert_keeps_existing() { + let mut map: VecMap = VecMap::new(); + map.entry_ref(&"a".to_string()).or_insert(1); + let value = map.entry_ref(&"a".to_string()).or_insert(99); + assert_eq!(*value, 1); + assert_eq!(map.iter().count(), 1); +} + +#[test] +fn test_entry_ref_key_not_cloned_when_occupied() { + let clone_count = Rc::new(Cell::new(0)); + let key = CountedKey { + value: "a".to_string(), + clone_count: clone_count.clone(), + }; + + let mut map: VecMap = VecMap::new(); + map.entry_ref(&key).or_insert(1); + let clones_after_insert = clone_count.get(); + + // Looking up an existing key must not clone it. + map.entry_ref(&key).or_insert(99); + assert_eq!(clone_count.get(), clones_after_insert); +} + +#[test] +fn test_entry_ref_key_cloned_exactly_once_on_vacant_insert() { + let clone_count = Rc::new(Cell::new(0)); + let key = CountedKey { + value: "a".to_string(), + clone_count: clone_count.clone(), + }; + + let mut map: VecMap = VecMap::new(); + map.entry_ref(&key).or_insert(1); + assert_eq!(clone_count.get(), 1); +} + +#[test] +fn test_entry_ref_or_insert_with_key() { + let mut map: VecMap = VecMap::new(); + let key = "hello".to_string(); + map.entry_ref(&key).or_insert_with_key(|k| k.to_uppercase()); + assert_eq!( + map.iter().collect::>(), + vec![(&"hello".to_string(), &"HELLO".to_string())] + ); +} + +#[test] +fn test_entry_ref_or_insert_with_not_called_when_occupied() { + let mut map: VecMap = VecMap::new(); + let key = "a".to_string(); + map.entry_ref(&key).or_insert(1); + map.entry_ref(&key) + .or_insert_with(|| panic!("should not be called")); + assert_eq!(map.iter().collect::>(), vec![(&key, &1)]); +} + +#[test] +fn test_entry_ref_or_insert_default() { + let mut map: VecMap = VecMap::new(); + map.entry_ref(&"a".to_string()).or_insert_default(); + assert_eq!(map.iter().collect::>(), vec![(&"a".to_string(), &0)]); +} + +#[test] +fn test_entry_ref_key() { + let mut map: VecMap = VecMap::new(); + let key = "a".to_string(); + assert_eq!(*map.entry_ref(&key).key(), key); + map.entry_ref(&key).or_insert(1); + assert_eq!(*map.entry_ref(&key).key(), key); +} + +#[test] +fn test_entry_ref_mut_ref_can_be_updated() { + let mut map: VecMap = VecMap::new(); + let key = "a".to_string(); + let value = map.entry_ref(&key).or_insert(0); + *value = 5; + assert_eq!(map.iter().collect::>(), vec![(&key, &5)]); +} diff --git a/crates/command_palette/Cargo.toml b/crates/command_palette/Cargo.toml index 96be6cb9ee2b767bc14503cbae7e2de6838e6724..df9da6f67e5c2c2e7d91b2ece0245c352e4190b7 100644 --- a/crates/command_palette/Cargo.toml +++ b/crates/command_palette/Cargo.toml @@ -49,3 +49,4 @@ menu.workspace = true project = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true \ No newline at end of file diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 579946f30d88db379f6649fd65b13d7d291e19de..90ed7d0d3518aa4f6d49bb4cc18cbf3c275ce7c5 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -931,7 +931,7 @@ mod tests { fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let app_state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); menu::init(); go_to_line::init(cx); diff --git a/crates/component_preview/Cargo.toml b/crates/component_preview/Cargo.toml index 3bfbdcf2979ebca34a80c9d8703813c40a20387b..4a3cde33631e2da7839d93267de0afe94a7a62c7 100644 --- a/crates/component_preview/Cargo.toml +++ b/crates/component_preview/Cargo.toml @@ -33,6 +33,7 @@ reqwest_client.workspace = true session.workspace = true settings.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true ui_input.workspace = true uuid.workspace = true diff --git a/crates/component_preview/examples/component_preview.rs b/crates/component_preview/examples/component_preview.rs index 99222a9ffd47222eb11375b2277bd7ee4e6c7a94..8deaff1a8a61a404f482ac30f071164807267f5b 100644 --- a/crates/component_preview/examples/component_preview.rs +++ b/crates/component_preview/examples/component_preview.rs @@ -39,7 +39,7 @@ fn main() { ::set_global(fs.clone(), cx); settings::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); let languages = Arc::new(LanguageRegistry::new(cx.background_executor().clone())); let client = Client::production(cx); @@ -65,7 +65,7 @@ fn main() { node_runtime, session, }); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); workspace::init(app_state.clone(), cx); init(app_state.clone(), cx); @@ -81,7 +81,7 @@ fn main() { { move |window, cx| { let app_state = app_state; - theme::setup_ui_font(window, cx); + theme_settings::setup_ui_font(window, cx); let project = Project::local( app_state.client.clone(), diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index d625c998b034a249cb3f498ae1fdd4e0e179a4cc..4d2ffde10c783d4fdbbad29b1fcd497cdfc30ced 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -68,3 +68,4 @@ settings = { workspace = true, features = ["test-support"] } theme = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } zlog.workspace = true +theme_settings.workspace = true diff --git a/crates/copilot/src/copilot_edit_prediction_delegate.rs b/crates/copilot/src/copilot_edit_prediction_delegate.rs index 2d5b387479f380f66519a07468a88929d1c5cc55..6f69bc6bc7bea4ec31aa59262a4abc5640999a2e 100644 --- a/crates/copilot/src/copilot_edit_prediction_delegate.rs +++ b/crates/copilot/src/copilot_edit_prediction_delegate.rs @@ -1120,7 +1120,7 @@ mod tests { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { store.update_user_settings(cx, |settings| f(&mut settings.project.all_languages)); }); diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs index 033effd230d65fee7594d0241b2828a41908a432..09267020e5c3599675807f01777097d23b4d9ab0 100644 --- a/crates/copilot_ui/src/sign_in.rs +++ b/crates/copilot_ui/src/sign_in.rs @@ -481,7 +481,6 @@ impl ConfigurationView { cx: &mut Context, ) -> Self { let copilot = AppState::try_global(cx) - .and_then(|state| state.upgrade()) .and_then(|state| GlobalCopilotAuth::try_get_or_init(state, cx)); Self { @@ -578,9 +577,8 @@ impl ConfigurationView { ) .when(edit_prediction, |this| this.tab_index(0isize)) .on_click(|_, window, cx| { - if let Some(app_state) = AppState::global(cx).upgrade() - && let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx) - { + let app_state = AppState::global(cx); + if let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx) { initiate_sign_in(copilot.0, window, cx) } }) @@ -608,9 +606,8 @@ impl ConfigurationView { .color(Color::Muted), ) .on_click(|_, window, cx| { - if let Some(app_state) = AppState::global(cx).upgrade() - && let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx) - { + let app_state = AppState::global(cx); + if let Some(copilot) = GlobalCopilotAuth::try_get_or_init(app_state, cx) { reinstall_and_sign_in(copilot.0, window, cx); } }) diff --git a/crates/debugger_ui/Cargo.toml b/crates/debugger_ui/Cargo.toml index f95712b05129b7f86699f658c4c2c3effbd7d216..ba98df3e3764f773d490b76d6a5912bab5e4adbe 100644 --- a/crates/debugger_ui/Cargo.toml +++ b/crates/debugger_ui/Cargo.toml @@ -67,6 +67,7 @@ tasks_ui.workspace = true terminal_view.workspace = true text.workspace = true theme.workspace = true +theme_settings.workspace = true tree-sitter-json.workspace = true tree-sitter.workspace = true ui.workspace = true diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index e33efd2c4904fe83cbbffb9ae57aadfbfc6d5470..c488e88d74e7f282bd0424a2213e08e2c9bec15f 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -26,7 +26,8 @@ use project::{ use settings::Settings; use std::fmt::Write; use std::{ops::Range, rc::Rc, usize}; -use theme::{Theme, ThemeSettings}; +use theme::Theme; +use theme_settings::ThemeSettings; use ui::{ContextMenu, Divider, PopoverMenu, SplitButton, Tooltip, prelude::*}; use util::ResultExt; diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs index 69ea556018fdadeb1e270b1d7c2520d25752e670..ebcabe210f8ee78af750793b36edd256ddbf984e 100644 --- a/crates/debugger_ui/src/session/running/memory_view.rs +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -17,7 +17,7 @@ use gpui::{ use notifications::status_toast::{StatusToast, ToastIcon}; use project::debugger::{MemoryCell, dap_command::DataBreakpointContext, session::Session}; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ContextMenu, Divider, DropdownMenu, FluentBuilder, IntoElement, PopoverMenuHandle, Render, ScrollableHandle, StatefulInteractiveElement, Tooltip, WithScrollbar, prelude::*, diff --git a/crates/debugger_ui/src/tests.rs b/crates/debugger_ui/src/tests.rs index cc407dfd810ceedb11c4d8030c46a6f17065b34b..4b4cebb2931d0b47e8bc18bd1f79f823528b416b 100644 --- a/crates/debugger_ui/src/tests.rs +++ b/crates/debugger_ui/src/tests.rs @@ -41,7 +41,7 @@ pub fn init_test(cx: &mut gpui::TestAppContext) { let settings = SettingsStore::test(cx); cx.set_global(settings); terminal_view::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); command_palette_hooks::init(cx); editor::init(cx); crate::init(cx); diff --git a/crates/diagnostics/Cargo.toml b/crates/diagnostics/Cargo.toml index 09ee023d57fbb9b9f2c7d828f9b2ea25f73d23d9..6a19e7e40e0ce91cfb78ca44c5c5e7f74205106f 100644 --- a/crates/diagnostics/Cargo.toml +++ b/crates/diagnostics/Cargo.toml @@ -32,6 +32,7 @@ serde_json.workspace = true settings.workspace = true text.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/diagnostics/src/diagnostic_renderer.rs b/crates/diagnostics/src/diagnostic_renderer.rs index 89cebf8fb237a032866e14c36d3097e18388e6ab..27e1cbbac9c779056ecd9da00dd7a56ff3536f17 100644 --- a/crates/diagnostics/src/diagnostic_renderer.rs +++ b/crates/diagnostics/src/diagnostic_renderer.rs @@ -11,7 +11,7 @@ use lsp::DiagnosticSeverity; use markdown::{Markdown, MarkdownElement}; use settings::Settings; use text::{AnchorRangeExt, Point}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{CopyButton, prelude::*}; use util::maybe; diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index 06b71a583f5d02a103db69e17d4e2db48c98a415..527f5b5bfcbfa2350233f9f3a119e56e4f72b9a5 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -2034,7 +2034,7 @@ fn init_test(cx: &mut TestAppContext) { zlog::init_test(); let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); editor::init(cx); }); diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index 43efbeea0b0310cf70cd9bdb560b1b0d2b0c14ef..fc1bc404244a4896e7d13fbb0e9c81674438568f 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -22,8 +22,45 @@ static KEYMAP_WINDOWS: LazyLock = LazyLock::new(|| { load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap") }); +static KEYMAP_JETBRAINS_MACOS: LazyLock = LazyLock::new(|| { + load_keymap("keymaps/macos/jetbrains.json").expect("Failed to load JetBrains macOS keymap") +}); + +static KEYMAP_JETBRAINS_LINUX: LazyLock = LazyLock::new(|| { + load_keymap("keymaps/linux/jetbrains.json").expect("Failed to load JetBrains Linux keymap") +}); + static ALL_ACTIONS: LazyLock = LazyLock::new(load_all_actions); +#[derive(Clone, Copy)] +#[allow(dead_code)] +enum Os { + MacOs, + Linux, + Windows, +} + +#[derive(Clone, Copy)] +enum KeymapOverlay { + JetBrains, +} + +impl KeymapOverlay { + fn parse(name: &str) -> Option { + match name { + "jetbrains" => Some(Self::JetBrains), + _ => None, + } + } + + fn keymap(self, os: Os) -> &'static KeymapFile { + match (self, os) { + (Self::JetBrains, Os::MacOs) => &KEYMAP_JETBRAINS_MACOS, + (Self::JetBrains, Os::Linux | Os::Windows) => &KEYMAP_JETBRAINS_LINUX, + } + } +} + const FRONT_MATTER_COMMENT: &str = ""; fn main() -> Result<()> { @@ -64,6 +101,9 @@ enum PreprocessorError { snippet: String, error: String, }, + UnknownKeymapOverlay { + overlay_name: String, + }, } impl PreprocessorError { @@ -125,6 +165,13 @@ impl std::fmt::Display for PreprocessorError { snippet ) } + PreprocessorError::UnknownKeymapOverlay { overlay_name } => { + write!( + f, + "Unknown keymap overlay: '{}'. Supported overlays: jetbrains", + overlay_name + ) + } } } } @@ -205,20 +252,39 @@ fn format_binding(binding: String) -> String { } fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { - let regex = Regex::new(r"\{#kb (.*?)\}").unwrap(); + let regex = Regex::new(r"\{#kb(?::(\w+))?\s+(.*?)\}").unwrap(); for_each_chapter_mut(book, |chapter| { chapter.content = regex .replace_all(&chapter.content, |caps: ®ex::Captures| { - let action = caps[1].trim(); + let overlay_name = caps.get(1).map(|m| m.as_str()); + let action = caps[2].trim(); + if is_missing_action(action) { errors.insert(PreprocessorError::new_for_not_found_action( action.to_string(), )); return String::new(); } - let macos_binding = find_binding("macos", action).unwrap_or_default(); - let linux_binding = find_binding("linux", action).unwrap_or_default(); + + let overlay = if let Some(name) = overlay_name { + let Some(overlay) = KeymapOverlay::parse(name) else { + errors.insert(PreprocessorError::UnknownKeymapOverlay { + overlay_name: name.to_string(), + }); + return String::new(); + }; + Some(overlay) + } else { + None + }; + + let macos_binding = + find_binding_with_overlay(Os::MacOs, action, overlay) + .unwrap_or_default(); + let linux_binding = + find_binding_with_overlay(Os::Linux, action, overlay) + .unwrap_or_default(); if macos_binding.is_empty() && linux_binding.is_empty() { return "
No default binding
".to_string(); @@ -227,7 +293,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet{formatted_macos_binding}|{formatted_linux_binding}") + format!("{formatted_macos_binding}|{formatted_linux_binding}") }) .into_owned() }); @@ -270,15 +336,8 @@ fn is_missing_action(name: &str) -> bool { actions_available() && find_action_by_name(name).is_none() } -fn find_binding(os: &str, action: &str) -> Option { - let keymap = match os { - "macos" => &KEYMAP_MACOS, - "linux" | "freebsd" => &KEYMAP_LINUX, - "windows" => &KEYMAP_WINDOWS, - _ => unreachable!("Not a valid OS: {}", os), - }; - - // Find the binding in reverse order, as the last binding takes precedence. +// Find the binding in reverse order, as the last binding takes precedence. +fn find_binding_in_keymap(keymap: &KeymapFile, action: &str) -> Option { keymap.sections().rev().find_map(|section| { section.bindings().rev().find_map(|(keystroke, a)| { if name_for_action(a.to_string()) == action { @@ -290,6 +349,25 @@ fn find_binding(os: &str, action: &str) -> Option { }) } +fn find_binding(os: Os, action: &str) -> Option { + let keymap = match os { + Os::MacOs => &KEYMAP_MACOS, + Os::Linux => &KEYMAP_LINUX, + Os::Windows => &KEYMAP_WINDOWS, + }; + find_binding_in_keymap(keymap, action) +} + +fn find_binding_with_overlay( + os: Os, + action: &str, + overlay: Option, +) -> Option { + overlay + .and_then(|overlay| find_binding_in_keymap(overlay.keymap(os), action)) + .or_else(|| find_binding(os, action)) +} + fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet) { let settings_schema = SettingsStore::json_schema(&Default::default()); let settings_validator = jsonschema::validator_for(&settings_schema) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 3ae4eb72b3a60ab56d865a235c43e2f0e3adab31..34980e00cedb7da6b6273e69ec64b35b0d7e9785 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -1992,7 +1992,7 @@ impl EditPredictionStore { } fn currently_following(project: &Entity, cx: &App) -> bool { - let Some(app_state) = AppState::try_global(cx).and_then(|app_state| app_state.upgrade()) else { + let Some(app_state) = AppState::try_global(cx) else { return false; }; diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 7583ba629bc2c490c5f8e8dd83218c200025fe7c..6fe61338e764a40aec9cf6f3191f1191bafe9200 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -204,7 +204,7 @@ async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppCon let app_state = cx.update(|cx| { let app_state = AppState::test(cx); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); app_state }); @@ -214,7 +214,7 @@ async fn test_diagnostics_refresh_suppressed_while_following(cx: &mut TestAppCon .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) .unwrap(); cx.update(|cx| { - AppState::set_global(Arc::downgrade(workspace.read(cx).app_state()), cx); + AppState::set_global(workspace.read(cx).app_state().clone(), cx); }); let _ = app_state; diff --git a/crates/edit_prediction_cli/Cargo.toml b/crates/edit_prediction_cli/Cargo.toml index 1c8985d1480c3746a71cad2c8394b89b59069597..83a78641bc2b14a9ea92cc0eae674135444ac691 100644 --- a/crates/edit_prediction_cli/Cargo.toml +++ b/crates/edit_prediction_cli/Cargo.toml @@ -65,7 +65,7 @@ rand.workspace = true similar = "2.7.0" flate2 = "1.1.8" toml.workspace = true -rust-embed = { workspace = true, features = ["debug-embed"] } +rust-embed.workspace = true gaoya = "0.2.0" # Wasmtime is included as a dependency in order to enable the same diff --git a/crates/edit_prediction_ui/Cargo.toml b/crates/edit_prediction_ui/Cargo.toml index b6b6473bafa0222a670e1c541e03d255ee0d2d5a..29c53bbaf8c82f3b0c2769af80d44f17250a0506 100644 --- a/crates/edit_prediction_ui/Cargo.toml +++ b/crates/edit_prediction_ui/Cargo.toml @@ -42,7 +42,7 @@ regex.workspace = true settings.workspace = true telemetry.workspace = true text.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.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 e6e65012123c0fdf3571115bded43f8840f997ee..377e53da265e4c2b6ada252b68402960f39b18dc 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -1418,9 +1418,9 @@ pub fn get_available_providers(cx: &mut App) -> Vec { providers.push(EditPredictionProvider::Zed); - if let Some(app_state) = workspace::AppState::global(cx).upgrade() - && copilot::GlobalCopilotAuth::try_get_or_init(app_state, cx) - .is_some_and(|copilot| copilot.0.read(cx).is_authenticated()) + let app_state = workspace::AppState::global(cx); + if copilot::GlobalCopilotAuth::try_get_or_init(app_state, cx) + .is_some_and(|copilot| copilot.0.read(cx).is_authenticated()) { providers.push(EditPredictionProvider::Copilot); }; diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index 15cccc777feb0a999724f2b4405fc11df8c5f252..1fb6c36bc9503e0a2fea7b3f77d1515747d1363c 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -14,7 +14,7 @@ use project::{ use settings::Settings as _; use std::rc::Rc; use std::{fmt::Write, sync::Arc}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ContextMenu, DropdownMenu, KeyBinding, List, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip, prelude::*, diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 22a9b8effbe52caa67812619d254076493210e68..1b2e32f19896df4863d6fd12d02b5eea6579bc97 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -83,6 +83,7 @@ telemetry.workspace = true text.workspace = true time.workspace = true theme.workspace = true +theme_settings.workspace = true tree-sitter-c = { workspace = true, optional = true } tree-sitter-html = { workspace = true, optional = true } tree-sitter-rust = { workspace = true, optional = true } diff --git a/crates/editor/benches/editor_render.rs b/crates/editor/benches/editor_render.rs index f527ddea45574720e7f86a177333f7e3ab3b919f..e93c94e1ae6e6cd44a65537172ffafe48455f3a3 100644 --- a/crates/editor/benches/editor_render.rs +++ b/crates/editor/benches/editor_render.rs @@ -122,7 +122,7 @@ pub fn benches() { let store = SettingsStore::test(cx); cx.set_global(store); assets::Assets.load_test_fonts(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); // release_channel::init(semver::Version::new(0,0,0), cx); editor::init(cx); }); diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs index 5d0e7311a3d2908f498774fde81d660dd5450123..0c9fa29ae6a19ad81ec265cc832a5d3ec15cec51 100644 --- a/crates/editor/src/bracket_colorization.rs +++ b/crates/editor/src/bracket_colorization.rs @@ -226,7 +226,7 @@ mod tests { use serde_json::json; use settings::{AccentContent, SettingsStore}; use text::{Bias, OffsetRangeExt, ToOffset}; - use theme::ThemeStyleContent; + use theme_settings::ThemeStyleContent; use util::{path, post_inc}; diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index b9fa0a49b1b77f9e5fcf4ace7d83155628afba20..933f0e6e18e57c38b6bcc3636f60bd1ae671d3a6 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -4036,7 +4036,7 @@ pub mod tests { let settings = SettingsStore::test(cx); cx.set_global(settings); crate::init(cx); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); cx.update_global::(|store, cx| { store.update_user_settings(cx, f); }); diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index d45165660d92170ecc176ebd8e038b890933bd57..531de6da49e375a4f7ba2833106e1716de551ff2 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -4830,7 +4830,7 @@ mod tests { fn init_test(cx: &mut gpui::App) { let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); assets::Assets.load_test_fonts(cx); } diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs index efb7abad6a169546c0d13de29870f939ced93eaa..95479e297cb82adcf8c3eb1f73e95f8b557eef43 100644 --- a/crates/editor/src/display_map/fold_map.rs +++ b/crates/editor/src/display_map/fold_map.rs @@ -57,7 +57,8 @@ impl FoldPlaceholder { pub fn fold_element(fold_id: FoldId, cx: &App) -> Stateful { use gpui::{InteractiveElement as _, StatefulInteractiveElement as _, Styled as _}; use settings::Settings as _; - use theme::{ActiveTheme as _, ThemeSettings}; + use theme::ActiveTheme as _; + use theme_settings::ThemeSettings; let settings = ThemeSettings::get_global(cx); gpui::div() .id(fold_id) diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index 122ca6f698115c2f5e6c194246f6a378825e5675..9c05a182ef56eb803ff545a1c9d3914b505767aa 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -2227,7 +2227,7 @@ mod tests { fn init_test(cx: &mut App) { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); } /// Helper to create test highlights for an inlay diff --git a/crates/editor/src/display_map/wrap_map.rs b/crates/editor/src/display_map/wrap_map.rs index 650ee99918e9c9f7a95a367db7e4d4f01b02d6ed..d21642977ed923e15a583dfe767fd566e78c5de9 100644 --- a/crates/editor/src/display_map/wrap_map.rs +++ b/crates/editor/src/display_map/wrap_map.rs @@ -1664,7 +1664,7 @@ mod tests { cx.update(|cx| { let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); }); } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1984c2180d1c5434b02a1623510fc2caa30177c4..405924edb227e4c561caafeee2f8cd3e51567023 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -204,8 +204,8 @@ use task::TaskVariables; use text::{BufferId, FromAnchor, OffsetUtf16, Rope, ToOffset as _, ToPoint as _}; use theme::{ AccentColors, ActiveTheme, GlobalTheme, PlayerColor, StatusColors, SyntaxTheme, Theme, - ThemeSettings, observe_buffer_font_size_adjustment, }; +use theme_settings::{ThemeSettings, observe_buffer_font_size_adjustment}; use ui::{ Avatar, ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName, IconSize, Indicator, Key, Tooltip, h_flex, prelude::*, scrollbars::ScrollbarAutoHide, @@ -354,32 +354,26 @@ pub fn init(cx: &mut App) { cx.on_action(move |_: &workspace::NewFile, cx| { let app_state = workspace::AppState::global(cx); - if let Some(app_state) = app_state.upgrade() { - workspace::open_new( - Default::default(), - app_state, - cx, - |workspace, window, cx| { - Editor::new_file(workspace, &Default::default(), window, cx) - }, - ) - .detach_and_log_err(cx); - } + workspace::open_new( + Default::default(), + app_state, + cx, + |workspace, window, cx| Editor::new_file(workspace, &Default::default(), window, cx), + ) + .detach_and_log_err(cx); }) .on_action(move |_: &workspace::NewWindow, cx| { let app_state = workspace::AppState::global(cx); - if let Some(app_state) = app_state.upgrade() { - workspace::open_new( - Default::default(), - app_state, - cx, - |workspace, window, cx| { - cx.activate(true); - Editor::new_file(workspace, &Default::default(), window, cx) - }, - ) - .detach_and_log_err(cx); - } + workspace::open_new( + Default::default(), + app_state, + cx, + |workspace, window, cx| { + cx.activate(true); + Editor::new_file(workspace, &Default::default(), window, cx) + }, + ) + .detach_and_log_err(cx); }); _ = ui_input::ERASED_EDITOR_FACTORY.set(|window, cx| { Arc::new(ErasedEditorImpl( @@ -9936,7 +9930,11 @@ impl Editor { h_flex() .px_0p5() .when(is_platform_style_mac, |parent| parent.gap_0p5()) - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font( + theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(), + ) .text_size(TextSize::XSmall.rems(cx)) .child(h_flex().children(ui::render_modifiers( keystroke.modifiers(), @@ -9950,7 +9948,7 @@ impl Editor { }) .when(!is_platform_style_mac, |parent| { parent.child( - Key::new(util::capitalize(keystroke.key()), Some(Color::Default)) + Key::new(ui::utils::capitalize(keystroke.key()), Some(Color::Default)) .size(Some(IconSize::XSmall.rems().into())), ) }) @@ -9967,7 +9965,11 @@ impl Editor { if keystroke.modifiers().modified() { h_flex() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font( + theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(), + ) .when(is_platform_style_mac, |parent| parent.gap_1()) .child(h_flex().children(ui::render_modifiers( keystroke.modifiers(), @@ -9978,7 +9980,7 @@ impl Editor { ))) .into_any() } else { - Key::new(util::capitalize(keystroke.key()), Some(color)) + Key::new(ui::utils::capitalize(keystroke.key()), Some(color)) .size(Some(IconSize::XSmall.rems().into())) .into_any_element() } @@ -10473,7 +10475,11 @@ impl Editor { .gap_2() .pr_1() .overflow_x_hidden() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font( + theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(), + ) .child(left) .child(preview), ) @@ -16119,11 +16125,8 @@ impl Editor { }; let mut new_selections = Vec::new(); - - let reversed = self - .selections - .oldest::(&display_map) - .reversed; + let initial_selection = self.selections.oldest::(&display_map); + let reversed = initial_selection.reversed; let buffer = display_map.buffer_snapshot(); let query_matches = select_next_state .query @@ -16137,21 +16140,33 @@ impl Editor { MultiBufferOffset(query_match.start())..MultiBufferOffset(query_match.end()) }; - if !select_next_state.wordwise - || (!buffer.is_inside_word(offset_range.start, None) - && !buffer.is_inside_word(offset_range.end, None)) - { - new_selections.push(offset_range.start..offset_range.end); - } - } + let is_partial_word_match = select_next_state.wordwise + && (buffer.is_inside_word(offset_range.start, None) + || buffer.is_inside_word(offset_range.end, None)); - select_next_state.done = true; + let is_initial_selection = MultiBufferOffset(query_match.start()) + == initial_selection.start + && MultiBufferOffset(query_match.end()) == initial_selection.end; - if new_selections.is_empty() { - log::error!("bug: new_selections is empty in select_all_matches"); - return Ok(()); + if !is_partial_word_match && !is_initial_selection { + new_selections.push(offset_range); + } } + // Ensure that the initial range is the last selection, as + // `MutableSelectionsCollection::select_ranges` makes the last selection + // the newest selection, which the editor then relies on as the primary + // cursor for scroll targeting. Without this, the last match would then + // be automatically focused when the user started editing the selected + // matches. + let initial_directed_range = if reversed { + initial_selection.end..initial_selection.start + } else { + initial_selection.start..initial_selection.end + }; + new_selections.push(initial_directed_range); + + select_next_state.done = true; self.unfold_ranges(&new_selections, false, false, cx); self.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.select_ranges(new_selections) @@ -24437,7 +24452,7 @@ impl Editor { return None; } - let theme_settings = theme::ThemeSettings::get_global(cx); + let theme_settings = theme_settings::ThemeSettings::get_global(cx); let theme = cx.theme(); let accent_colors = theme.accents().clone(); @@ -28848,49 +28863,58 @@ pub fn styled_runs_for_code_label<'a>( ..Default::default() }; + if label.runs.is_empty() { + let desc_start = label.filter_range.end; + let fade_run = + (desc_start < label.text.len()).then(|| (desc_start..label.text.len(), fade_out)); + return Either::Left(fade_run.into_iter()); + } + let mut prev_end = label.filter_range.end; - label - .runs - .iter() - .enumerate() - .flat_map(move |(ix, (range, highlight_id))| { - let style = if *highlight_id == language::HighlightId::TABSTOP_INSERT_ID { - HighlightStyle { - color: Some(local_player.cursor), - ..Default::default() - } - } else if *highlight_id == language::HighlightId::TABSTOP_REPLACE_ID { - HighlightStyle { - background_color: Some(local_player.selection), - ..Default::default() - } - } else if let Some(style) = syntax_theme.get(*highlight_id).cloned() { - style - } else { - return Default::default(); - }; - let muted_style = style.highlight(fade_out); + Either::Right( + label + .runs + .iter() + .enumerate() + .flat_map(move |(ix, (range, highlight_id))| { + let style = if *highlight_id == language::HighlightId::TABSTOP_INSERT_ID { + HighlightStyle { + color: Some(local_player.cursor), + ..Default::default() + } + } else if *highlight_id == language::HighlightId::TABSTOP_REPLACE_ID { + HighlightStyle { + background_color: Some(local_player.selection), + ..Default::default() + } + } else if let Some(style) = syntax_theme.get(*highlight_id).cloned() { + style + } else { + return Default::default(); + }; - let mut runs = SmallVec::<[(Range, HighlightStyle); 3]>::new(); - if range.start >= label.filter_range.end { - if range.start > prev_end { - runs.push((prev_end..range.start, fade_out)); + let mut runs = SmallVec::<[(Range, HighlightStyle); 3]>::new(); + let muted_style = style.highlight(fade_out); + if range.start >= label.filter_range.end { + if range.start > prev_end { + runs.push((prev_end..range.start, fade_out)); + } + runs.push((range.clone(), muted_style)); + } else if range.end <= label.filter_range.end { + runs.push((range.clone(), style)); + } else { + runs.push((range.start..label.filter_range.end, style)); + runs.push((label.filter_range.end..range.end, muted_style)); } - runs.push((range.clone(), muted_style)); - } else if range.end <= label.filter_range.end { - runs.push((range.clone(), style)); - } else { - runs.push((range.start..label.filter_range.end, style)); - runs.push((label.filter_range.end..range.end, muted_style)); - } - prev_end = cmp::max(prev_end, range.end); + prev_end = cmp::max(prev_end, range.end); - if ix + 1 == label.runs.len() && label.text.len() > prev_end { - runs.push((prev_end..label.text.len(), fade_out)); - } + if ix + 1 == label.runs.len() && label.text.len() > prev_end { + runs.push((prev_end..label.text.len(), fade_out)); + } - runs - }) + runs + }), + ) } pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator + '_ { diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index f285d130be5be071b75161c114da53ca9c55d301..2a0d2fbfe0199126cd8b86c016e5ffbbdbdb9ae3 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -9968,7 +9968,6 @@ async fn test_select_all_matches_does_not_scroll(cx: &mut TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; - let large_body_1 = "\nd".repeat(200); let large_body_2 = "\ne".repeat(200); @@ -9981,17 +9980,62 @@ async fn test_select_all_matches_does_not_scroll(cx: &mut TestAppContext) { scroll_position }); - cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx)) + cx.update_editor(|editor, window, cx| editor.select_all_matches(&SelectAllMatches, window, cx)) .unwrap(); cx.assert_editor_state(&format!( "«ˇa»bc\n«ˇa»bc{large_body_1} «ˇa»bc{large_body_2}\nef«ˇa»bc\n«ˇa»bc" )); - let scroll_position_after_selection = - cx.update_editor(|editor, _, cx| editor.scroll_position(cx)); - assert_eq!( - initial_scroll_position, scroll_position_after_selection, - "Scroll position should not change after selecting all matches" - ); + cx.update_editor(|editor, _, cx| { + assert_eq!( + editor.scroll_position(cx), + initial_scroll_position, + "Scroll position should not change after selecting all matches" + ) + }); + + // Simulate typing while the selections are active, as that is where the + // editor would attempt to actually scroll to the newest selection, which + // should have been set as the original selection to avoid scrolling to the + // last match. + cx.simulate_keystroke("x"); + cx.update_editor(|editor, _, cx| { + assert_eq!( + editor.scroll_position(cx), + initial_scroll_position, + "Scroll position should not change after editing all matches" + ) + }); + + cx.set_state(&format!( + "abc\nabc{large_body_1} «aˇ»bc{large_body_2}\nefabc\nabc" + )); + let initial_scroll_position = cx.update_editor(|editor, _, cx| { + let scroll_position = editor.scroll_position(cx); + assert!(scroll_position.y > 0.0, "Initial selection is between two large bodies and should have the editor scrolled to it"); + scroll_position + }); + + cx.update_editor(|editor, window, cx| editor.select_all_matches(&SelectAllMatches, window, cx)) + .unwrap(); + cx.assert_editor_state(&format!( + "«aˇ»bc\n«aˇ»bc{large_body_1} «aˇ»bc{large_body_2}\nef«aˇ»bc\n«aˇ»bc" + )); + cx.update_editor(|editor, _, cx| { + assert_eq!( + editor.scroll_position(cx), + initial_scroll_position, + "Scroll position should not change after selecting all matches" + ) + }); + + cx.simulate_keystroke("x"); + cx.update_editor(|editor, _, cx| { + assert_eq!( + editor.scroll_position(cx), + initial_scroll_position, + "Scroll position should not change after editing all matches" + ) + }); } #[gpui::test] @@ -29677,7 +29721,7 @@ pub(crate) fn init_test(cx: &mut TestAppContext, f: fn(&mut AllLanguageSettingsC assets::Assets.load_test_fonts(cx); let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); crate::init(cx); }); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 59b474b1c91c0ad62eb9c260facb2ab46ef4f9c6..285a1cf6fbb7bbcdde27e2258ee8c936711dcb14 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -83,7 +83,8 @@ use std::{ }; use sum_tree::Bias; use text::{BufferId, SelectionGoal}; -use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor}; +use theme::{ActiveTheme, Appearance, PlayerColor}; +use theme_settings::BufferLineHeight; use ui::utils::ensure_minimum_contrast; use ui::{ ButtonLike, ContextMenu, Indicator, KeyBinding, POPOVER_Y_PADDING, Tooltip, prelude::*, @@ -8448,7 +8449,7 @@ pub(crate) fn render_buffer_header( el.child(Icon::new(IconName::FileLock).color(Color::Muted)) }) .when_some(breadcrumbs, |then, breadcrumbs| { - let font = theme::ThemeSettings::get_global(cx) + let font = theme_settings::ThemeSettings::get_global(cx) .buffer_font .clone(); then.child(render_breadcrumb_text( diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index c705eb3996ace228f915303f049853bb2364aa2e..827d182a0f11508ae301691f832e7ec04a728364 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -746,7 +746,7 @@ mod tests { let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); }); diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 99069cac6ceeec3983d6713777007876c74c8d19..791f0d67a14a949cd3eb916a2831f097ba320d91 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -26,7 +26,7 @@ use std::{ }; use std::{ops::Range, sync::Arc, time::Duration}; use std::{path::PathBuf, rc::Rc}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{CopyButton, Scrollbars, WithScrollbar, prelude::*, theme_is_transparent}; use url::Url; use util::TryFutureExt; diff --git a/crates/editor/src/inlays/inlay_hints.rs b/crates/editor/src/inlays/inlay_hints.rs index 157de3c87d0c6a2f5fcde63ce89143fd8f2fb01b..8422937ab81a392ad7d1187adcab765cc7f6875f 100644 --- a/crates/editor/src/inlays/inlay_hints.rs +++ b/crates/editor/src/inlays/inlay_hints.rs @@ -4798,7 +4798,7 @@ let c = 3;"# 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); release_channel::init(semver::Version::new(0, 0, 0), cx); crate::init(cx); }); diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 0cd84ec68257f7ab1e6054ab7f2464fb09113298..d14078e79abdbfe40879da09221bad7bef47475a 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -980,7 +980,9 @@ impl Item for Editor { // In a non-singleton case, the breadcrumbs are actually shown on sticky file headers of the multibuffer. fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { if self.buffer.read(cx).is_singleton() { - let font = theme::ThemeSettings::get_global(cx).buffer_font.clone(); + let font = theme_settings::ThemeSettings::get_global(cx) + .buffer_font + .clone(); Some((self.breadcrumbs_inner(cx)?, Some(font))) } else { None diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 6bf6449506f1c1eb2a71270546ad3b063f7e9022..955f511577d2cbfede1a4cb4eb6d99e429c879d6 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -1393,7 +1393,7 @@ mod tests { fn init_test(cx: &mut gpui::App) { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); } } diff --git a/crates/editor/src/semantic_tokens.rs b/crates/editor/src/semantic_tokens.rs index 1a895465277d02078f1bf23da21f061a94f94be7..8408438f17533098f906c75bcc03983edfb7acf8 100644 --- a/crates/editor/src/semantic_tokens.rs +++ b/crates/editor/src/semantic_tokens.rs @@ -1383,7 +1383,7 @@ mod tests { async fn test_theme_override_changes_restyle_semantic_tokens(cx: &mut TestAppContext) { use collections::IndexMap; use gpui::{Hsla, Rgba, UpdateGlobal as _}; - use theme::{HighlightStyleContent, ThemeStyleContent}; + use theme_settings::{HighlightStyleContent, ThemeStyleContent}; init_test(cx, |_| {}); @@ -1548,7 +1548,7 @@ mod tests { async fn test_per_theme_overrides_restyle_semantic_tokens(cx: &mut TestAppContext) { use collections::IndexMap; use gpui::{Hsla, Rgba, UpdateGlobal as _}; - use theme::{HighlightStyleContent, ThemeStyleContent}; + use theme_settings::{HighlightStyleContent, ThemeStyleContent}; use ui::ActiveTheme as _; init_test(cx, |_| {}); diff --git a/crates/editor/src/signature_help.rs b/crates/editor/src/signature_help.rs index 67f482339f501f46a4475bb9e9534437d9f9f1cf..27c26d4691686c16bcbafbf74bba6b5f1156b835 100644 --- a/crates/editor/src/signature_help.rs +++ b/crates/editor/src/signature_help.rs @@ -13,7 +13,7 @@ use settings::Settings; use std::ops::Range; use std::time::Duration; use text::Rope; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ActiveTheme, AnyElement, ButtonCommon, ButtonStyle, Clickable, FluentBuilder, IconButton, IconButtonShape, IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon, diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index c9668bc35655dfcda62e71884a782b4edecae093..61f4526f3d4902f7972b65266de725818accef1e 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -2118,7 +2118,7 @@ mod tests { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); }); let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; diff --git a/crates/extension_cli/Cargo.toml b/crates/extension_cli/Cargo.toml index 24ea9cfafadc61b2753f7b739fd4b7cbbd24dbfe..c019a323196e96d0b7a0131cc518e599154cd350 100644 --- a/crates/extension_cli/Cargo.toml +++ b/crates/extension_cli/Cargo.toml @@ -29,7 +29,7 @@ serde_json_lenient.workspace = true settings_content.workspace = true snippet_provider.workspace = true task.workspace = true -theme.workspace = true +theme_settings.workspace = true tokio = { workspace = true, features = ["full"] } toml.workspace = true tree-sitter.workspace = true diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index 4d290992f318dc8fec78dad0e40d347d4826ed65..57845754fc8263c516bc3aec7d1ae0a2ffe68a2f 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -413,7 +413,8 @@ async fn test_themes( ) -> Result<()> { for relative_theme_path in &manifest.themes { let theme_path = extension_path.join(relative_theme_path); - let theme_family = theme::read_user_theme(&theme_path, fs.clone()).await?; + let theme_family = + theme_settings::deserialize_user_theme(&fs.load_bytes(&theme_path).await?)?; log::info!("loaded theme family {}", theme_family.name); for theme in &theme_family.themes { diff --git a/crates/extension_host/Cargo.toml b/crates/extension_host/Cargo.toml index c6f4db47c97d69173242953926c6965c039a6397..8dd949844f03ed7d625a2374aaf99b7c38b6522f 100644 --- a/crates/extension_host/Cargo.toml +++ b/crates/extension_host/Cargo.toml @@ -68,6 +68,7 @@ project = { workspace = true, features = ["test-support"] } reqwest_client.workspace = true theme = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true theme_extension.workspace = true zlog.workspace = true diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index f1a209ca7af19589e897c42e9f5269abaa42725a..a2722da336b4d52a04a7d6da3c22347a3535bf2b 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -1007,7 +1007,7 @@ fn init_test(cx: &mut TestAppContext) { cx.set_global(store); release_channel::init(semver::Version::new(0, 0, 0), cx); extension::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); gpui_tokio::init(cx); }); } diff --git a/crates/extension_host/src/headless_host.rs b/crates/extension_host/src/headless_host.rs index 0aff06fdddcf5c075bd669528b5c52137f745863..7c30228257dbaa037fbc772be822a1000adfdfef 100644 --- a/crates/extension_host/src/headless_host.rs +++ b/crates/extension_host/src/headless_host.rs @@ -281,7 +281,7 @@ impl HeadlessExtensionStore { fs.rename(&tmp_path, &path, RenameOptions::default()) .await - .context("Failed to rename {tmp_path:?} to {path:?}")?; + .with_context(|| format!("Failed to rename {tmp_path:?} to {path:?}"))?; Self::load_extension(this, extension, cx).await }) diff --git a/crates/extensions_ui/Cargo.toml b/crates/extensions_ui/Cargo.toml index a80defd128549e9f2ed6b634c188a7f2f319ef6a..6b6b6838313ecc8738df769609cf236e3f6e0bfb 100644 --- a/crates/extensions_ui/Cargo.toml +++ b/crates/extensions_ui/Cargo.toml @@ -35,7 +35,7 @@ settings.workspace = true smallvec.workspace = true strum.workspace = true telemetry.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true vim_mode_setting.workspace = true diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 2d0b151a107000e913ba4772d7d3d2bf50474fc1..fceae09e5a4fbe1116c73d6ff5ca8bf018480fd9 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -23,7 +23,7 @@ use project::DirectoryLister; use release_channel::ReleaseChannel; use settings::{Settings, SettingsContent}; use strum::IntoEnumIterator as _; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Banner, Chip, ContextMenu, Divider, PopoverMenu, ScrollableHandle, Switch, ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonGroupStyle, ToggleButtonSimple, Tooltip, WithScrollbar, diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index 80e466ac4c571ede217aa734a7862becd08e72ba..5eb36f0f5150263629b407dbe07dc73b6eff31cf 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -47,3 +47,4 @@ theme = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } zlog.workspace = true remote_connection = { workspace = true, features = ["test-support"] } +theme_settings = { workspace = true, features = ["test-support"] } \ No newline at end of file diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 3f9d579b03c9aa2abeb408bdf6b77cf5e69de003..cd9cdeee1ff266717d380aeaecf7cbeb66ec8309 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -3789,7 +3789,7 @@ async fn open_queried_buffer( fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); super::init(cx); editor::init(cx); state diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 9c218c8e53f9a2135ee09fadc78f627e3960da54..38cb1e6b3c467dba4430767c2f4d6705c1d8b2aa 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -8,7 +8,7 @@ use git::{ repository::{ AskPassDelegate, Branch, CommitDataReader, CommitDetails, CommitOptions, FetchOptions, GRAPH_CHUNK_SIZE, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, - LogSource, PushOptions, Remote, RepoPath, ResetMode, Worktree, + LogSource, PushOptions, Remote, RepoPath, ResetMode, SearchCommitArgs, Worktree, }, status::{ DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus, @@ -1017,6 +1017,15 @@ impl GitRepository for FakeGitRepository { .boxed() } + fn search_commits( + &self, + _log_source: LogSource, + _search_args: SearchCommitArgs, + _request_tx: Sender, + ) -> BoxFuture<'_, Result<()>> { + async { bail!("search_commits not supported for FakeGitRepository") }.boxed() + } + fn commit_data_reader(&self) -> Result { anyhow::bail!("commit_data_reader not supported for FakeGitRepository") } diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 13745c1fdfc0523d850b95e45a81cae286a77a00..766378bf2e514d8a50348b608d52e9e764072f21 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -161,6 +161,14 @@ impl Oid { } } +impl TryFrom<&str> for Oid { + type Error = anyhow::Error; + + fn try_from(value: &str) -> std::prelude::v1::Result { + Oid::from_str(value) + } +} + impl FromStr for Oid { type Err = anyhow::Error; diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 32904aa9a9001187193c91a055a5e0393221514d..036ceeb620e1aa0345b6f9a296c16069c0fa09bf 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -50,6 +50,10 @@ pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user"; /// %x00 - Null byte separator, used to split up commit data static GRAPH_COMMIT_FORMAT: &str = "--format=%H%x00%P%x00%D"; +/// Used to get commits that match with a search +/// %H - Full commit hash +static SEARCH_COMMIT_FORMAT: &str = "--format=%H"; + /// Number of commits to load per chunk for the git graph. pub const GRAPH_CHUNK_SIZE: usize = 1000; @@ -623,6 +627,11 @@ impl LogSource { } } +pub struct SearchCommitArgs { + pub query: SharedString, + pub case_sensitive: bool, +} + pub trait GitRepository: Send + Sync { fn reload_index(&self); @@ -875,6 +884,13 @@ pub trait GitRepository: Send + Sync { request_tx: Sender>>, ) -> BoxFuture<'_, Result<()>>; + fn search_commits( + &self, + log_source: LogSource, + search_args: SearchCommitArgs, + request_tx: Sender, + ) -> BoxFuture<'_, Result<()>>; + fn commit_data_reader(&self) -> Result; fn set_trusted(&self, trusted: bool); @@ -1046,7 +1062,6 @@ impl GitRepository for RealGitRepository { let git = git_binary?; let output = git .build_command(&[ - "--no-optional-locks", "show", "--no-patch", "--format=%H%x00%B%x00%at%x00%ae%x00%an%x00", @@ -1084,7 +1099,6 @@ impl GitRepository for RealGitRepository { let git = git_binary?; let show_output = git .build_command(&[ - "--no-optional-locks", "show", "--format=", "-z", @@ -1105,7 +1119,7 @@ impl GitRepository for RealGitRepository { let parent_sha = format!("{}^", commit); let mut cat_file_process = git - .build_command(&["--no-optional-locks", "cat-file", "--batch=%(objectsize)"]) + .build_command(&["cat-file", "--batch=%(objectsize)"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1417,11 +1431,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let git = git_binary?; let mut process = git - .build_command(&[ - "--no-optional-locks", - "cat-file", - "--batch-check=%(objectname)", - ]) + .build_command(&["cat-file", "--batch-check=%(objectname)"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1495,7 +1505,6 @@ impl GitRepository for RealGitRepository { }; let mut args = vec![ - OsString::from("--no-optional-locks"), OsString::from("diff-tree"), OsString::from("-r"), OsString::from("-z"), @@ -1612,7 +1621,7 @@ impl GitRepository for RealGitRepository { .spawn(async move { let git = git_binary?; let output = git - .build_command(&["--no-optional-locks", "worktree", "list", "--porcelain"]) + .build_command(&["worktree", "list", "--porcelain"]) .output() .await?; if output.status.success() { @@ -1634,7 +1643,6 @@ impl GitRepository for RealGitRepository { ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); let mut args = vec![ - OsString::from("--no-optional-locks"), OsString::from("worktree"), OsString::from("add"), OsString::from("-b"), @@ -1668,11 +1676,7 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { - let mut args: Vec = vec![ - "--no-optional-locks".into(), - "worktree".into(), - "remove".into(), - ]; + let mut args: Vec = vec!["worktree".into(), "remove".into()]; if force { args.push("--force".into()); } @@ -1690,7 +1694,6 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { let args: Vec = vec![ - "--no-optional-locks".into(), "worktree".into(), "move".into(), "--".into(), @@ -1833,7 +1836,7 @@ impl GitRepository for RealGitRepository { commit_delimiter ); - let mut args = vec!["--no-optional-locks", "log", "--follow", &format_string]; + let mut args = vec!["log", "--follow", &format_string]; let skip_str; let limit_str; @@ -2709,6 +2712,61 @@ impl GitRepository for RealGitRepository { .boxed() } + fn search_commits( + &self, + log_source: LogSource, + search_args: SearchCommitArgs, + request_tx: Sender, + ) -> BoxFuture<'_, Result<()>> { + let git_binary = self.git_binary(); + + async move { + let git = git_binary?; + + let mut args = vec!["log", SEARCH_COMMIT_FORMAT, log_source.get_arg()?]; + + args.push("--fixed-strings"); + + if !search_args.case_sensitive { + args.push("--regexp-ignore-case"); + } + + args.push("--grep"); + args.push(search_args.query.as_str()); + + let mut command = git.build_command(&args); + command.stdout(Stdio::piped()); + command.stderr(Stdio::null()); + + let mut child = command.spawn()?; + let stdout = child.stdout.take().context("failed to get stdout")?; + let mut reader = BufReader::new(stdout); + + let mut line_buffer = String::new(); + + loop { + line_buffer.clear(); + let bytes_read = reader.read_line(&mut line_buffer).await?; + + if bytes_read == 0 { + break; + } + + let sha = line_buffer.trim_end_matches('\n'); + + if let Ok(oid) = Oid::from_str(sha) + && request_tx.send(oid).await.is_err() + { + break; + } + } + + child.status().await?; + Ok(()) + } + .boxed() + } + fn commit_data_reader(&self) -> Result { let git_binary = self.git_binary()?; @@ -2741,7 +2799,7 @@ async fn run_commit_data_reader( request_rx: smol::channel::Receiver, ) -> Result<()> { let mut process = git - .build_command(&["--no-optional-locks", "cat-file", "--batch"]) + .build_command(&["cat-file", "--batch"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -2855,7 +2913,6 @@ fn parse_initial_graph_output<'a>( fn git_status_args(path_prefixes: &[RepoPath]) -> Vec { let mut args = vec![ - OsString::from("--no-optional-locks"), OsString::from("status"), OsString::from("--porcelain=v1"), OsString::from("--untracked-files=all"), @@ -3039,6 +3096,7 @@ impl GitBinary { let mut command = new_command(&self.git_binary_path); command.current_dir(&self.working_directory); command.args(["-c", "core.fsmonitor=false"]); + command.arg("--no-optional-locks"); command.arg("--no-pager"); if !self.is_trusted { diff --git a/crates/git_graph/Cargo.toml b/crates/git_graph/Cargo.toml index 4756c55ac9232631a46056e252021a704d4a25b6..6aeaefe7e9b32ab01b19e6f9747f9128f3718edf 100644 --- a/crates/git_graph/Cargo.toml +++ b/crates/git_graph/Cargo.toml @@ -22,6 +22,7 @@ test-support = [ anyhow.workspace = true collections.workspace = true db.workspace = true +editor.workspace = true feature_flags.workspace = true git.workspace = true git_ui.workspace = true @@ -29,9 +30,12 @@ gpui.workspace = true language.workspace = true menu.workspace = true project.workspace = true +search.workspace = true settings.workspace = true smallvec.workspace = true +smol.workspace = true theme.workspace = true +theme_settings.workspace = true time.workspace = true ui.workspace = true workspace.workspace = true diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index aa53cd83e45b07cf94a6fc1b862b71053b92c81d..305dc8e42d3789cf8495fa30fbb5ca5770b10600 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -1,16 +1,20 @@ -use collections::{BTreeMap, HashMap}; +use collections::{BTreeMap, HashMap, IndexSet}; +use editor::Editor; use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag}; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, parse_git_remote_url, - repository::{CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath}, + repository::{ + CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath, + SearchCommitArgs, + }, status::{FileStatus, StatusCode, TrackedStatus}, }; use git_ui::{commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon}; use gpui::{ AnyElement, App, Bounds, ClickEvent, ClipboardItem, Corner, DefiniteLength, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, PathBuilder, Pixels, - Point, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, + Point, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*, px, uniform_list, }; @@ -23,6 +27,10 @@ use project::{ RepositoryEvent, RepositoryId, }, }; +use search::{ + SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch, + ToggleCaseSensitive, +}; use settings::Settings; use smallvec::{SmallVec, smallvec}; use std::{ @@ -33,12 +41,13 @@ use std::{ sync::OnceLock, time::{Duration, Instant}, }; -use theme::{AccentColors, ThemeSettings}; +use theme::AccentColors; +use theme_settings::ThemeSettings; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ - ButtonLike, Chip, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, ScrollableHandle, - Table, TableColumnWidths, TableInteractionState, TableResizeBehavior, Tooltip, WithScrollbar, - prelude::*, + ButtonLike, Chip, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, HighlightedLabel, + ScrollableHandle, Table, TableColumnWidths, TableInteractionState, TableResizeBehavior, + Tooltip, WithScrollbar, prelude::*, }; use workspace::{ Workspace, @@ -197,6 +206,29 @@ impl ChangedFileEntry { } } +enum QueryState { + Pending(SharedString), + Confirmed((SharedString, Task<()>)), + Empty, +} + +impl QueryState { + fn next_state(&mut self) { + match self { + Self::Confirmed((query, _)) => *self = Self::Pending(std::mem::take(query)), + _ => {} + }; + } +} + +struct SearchState { + case_sensitive: bool, + editor: Entity, + state: QueryState, + pub matches: IndexSet, + pub selected_index: Option, +} + pub struct SplitState { left_ratio: f32, visible_left_ratio: f32, @@ -742,7 +774,7 @@ pub fn init(cx: &mut App) { let existing = workspace.items_of_type::(cx).next(); if let Some(existing) = existing { existing.update(cx, |graph, cx| { - graph.select_commit_by_sha(&sha, cx); + graph.select_commit_by_sha(sha.as_str(), cx); }); workspace.activate_item(&existing, true, true, window, cx); return; @@ -753,7 +785,7 @@ pub fn init(cx: &mut App) { let git_graph = cx.new(|cx| { let mut graph = GitGraph::new(project, workspace_handle, window, cx); - graph.select_commit_by_sha(&sha, cx); + graph.select_commit_by_sha(sha.as_str(), cx); graph }); workspace.add_item_to_active_pane( @@ -835,6 +867,7 @@ fn compute_diff_stats(diff: &CommitDiff) -> (usize, usize) { pub struct GitGraph { focus_handle: FocusHandle, + search_state: SearchState, graph_data: GraphData, project: Entity, workspace: WeakEntity, @@ -859,6 +892,14 @@ pub struct GitGraph { } impl GitGraph { + fn invalidate_state(&mut self, cx: &mut Context) { + self.graph_data.clear(); + self.search_state.matches.clear(); + self.search_state.selected_index = None; + self.search_state.state.next_state(); + cx.notify(); + } + fn row_height(cx: &App) -> Pixels { let settings = ThemeSettings::get_global(cx); let font_size = settings.buffer_font_size(cx); @@ -901,8 +942,7 @@ impl GitGraph { // todo(git_graph): Make this selectable from UI so we don't have to always use active repository if this.selected_repo_id != *changed_repo_id { this.selected_repo_id = *changed_repo_id; - this.graph_data.clear(); - cx.notify(); + this.invalidate_state(cx); } } _ => {} @@ -914,6 +954,12 @@ impl GitGraph { .active_repository(cx) .map(|repo| repo.read(cx).id); + let search_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search commits…", window, cx); + editor + }); + let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx)); let table_column_widths = cx.new(|cx| TableColumnWidths::new(4, cx)); let mut row_height = Self::row_height(cx); @@ -933,6 +979,13 @@ impl GitGraph { let mut this = GitGraph { focus_handle, + search_state: SearchState { + case_sensitive: false, + editor: search_editor, + matches: IndexSet::default(), + selected_index: None, + state: QueryState::Empty, + }, project, workspace, graph_data: graph, @@ -980,7 +1033,7 @@ impl GitGraph { .and_then(|data| data.commit_oid_to_index.get(&oid).copied()) }) { - self.select_entry(pending_sha_index, cx); + self.select_entry(pending_sha_index, ScrollStrategy::Nearest, cx); } } GitGraphEvent::LoadingError => { @@ -1016,7 +1069,7 @@ impl GitGraph { pending_sha_index }) { - self.select_entry(pending_selection_index, cx); + self.select_entry(pending_selection_index, ScrollStrategy::Nearest, cx); self.pending_select_sha.take(); } @@ -1030,8 +1083,7 @@ impl GitGraph { // meaning we are not inside the initial repo loading state // NOTE: this fixes an loading performance regression if repository.read(cx).scan_id > 1 { - self.graph_data.clear(); - cx.notify(); + self.invalidate_state(cx); } } RepositoryEvent::GraphEvent(_, _) => {} @@ -1128,6 +1180,7 @@ impl GitGraph { .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default()); let is_selected = self.selected_entry_idx == Some(idx); + let is_matched = self.search_state.matches.contains(&commit.data.sha); let column_label = |label: SharedString| { Label::new(label) .when(!is_selected, |c| c.color(Color::Muted)) @@ -1135,11 +1188,49 @@ impl GitGraph { .into_any_element() }; + let subject_label = if is_matched { + let query = match &self.search_state.state { + QueryState::Confirmed((query, _)) => Some(query.clone()), + _ => None, + }; + let highlight_ranges = query + .and_then(|q| { + let ranges = if self.search_state.case_sensitive { + subject + .match_indices(q.as_str()) + .map(|(start, matched)| start..start + matched.len()) + .collect::>() + } else { + let q = q.to_lowercase(); + let subject_lower = subject.to_lowercase(); + + subject_lower + .match_indices(&q) + .filter_map(|(start, matched)| { + let end = start + matched.len(); + subject.is_char_boundary(start).then_some(()).and_then( + |_| subject.is_char_boundary(end).then_some(start..end), + ) + }) + .collect::>() + }; + + (!ranges.is_empty()).then_some(ranges) + }) + .unwrap_or_default(); + HighlightedLabel::from_ranges(subject.clone(), highlight_ranges) + .when(!is_selected, |c| c.color(Color::Muted)) + .truncate() + .into_any_element() + } else { + column_label(subject.clone()) + }; + vec![ div() .id(ElementId::NamedInteger("commit-subject".into(), idx as u64)) .overflow_hidden() - .tooltip(Tooltip::text(subject.clone())) + .tooltip(Tooltip::text(subject)) .child( h_flex() .gap_2() @@ -1153,7 +1244,7 @@ impl GitGraph { .map(|name| self.render_chip(name, accent_color)), ) })) - .child(column_label(subject)), + .child(subject_label), ) .into_any_element(), column_label(formatted_time.into()), @@ -1172,12 +1263,16 @@ impl GitGraph { } fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { - self.select_entry(0, cx); + self.select_entry(0, ScrollStrategy::Nearest, cx); } fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { if let Some(selected_entry_idx) = &self.selected_entry_idx { - self.select_entry(selected_entry_idx.saturating_sub(1), cx); + self.select_entry( + selected_entry_idx.saturating_sub(1), + ScrollStrategy::Nearest, + cx, + ); } else { self.select_first(&SelectFirst, window, cx); } @@ -1189,6 +1284,7 @@ impl GitGraph { selected_entry_idx .saturating_add(1) .min(self.graph_data.commits.len().saturating_sub(1)), + ScrollStrategy::Nearest, cx, ); } else { @@ -1197,14 +1293,88 @@ impl GitGraph { } fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { - self.select_entry(self.graph_data.commits.len().saturating_sub(1), cx); + self.select_entry( + self.graph_data.commits.len().saturating_sub(1), + ScrollStrategy::Nearest, + cx, + ); } fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { self.open_selected_commit_view(window, cx); } - fn select_entry(&mut self, idx: usize, cx: &mut Context) { + fn search(&mut self, query: SharedString, cx: &mut Context) { + let Some(repo) = self.get_selected_repository(cx) else { + return; + }; + + self.search_state.matches.clear(); + self.search_state.selected_index = None; + self.search_state.editor.update(cx, |editor, _cx| { + editor.set_text_style_refinement(Default::default()); + }); + + let (request_tx, request_rx) = smol::channel::unbounded::(); + + repo.update(cx, |repo, cx| { + repo.search_commits( + self.log_source.clone(), + SearchCommitArgs { + query: query.clone(), + case_sensitive: self.search_state.case_sensitive, + }, + request_tx, + cx, + ); + }); + + let search_task = cx.spawn(async move |this, cx| { + while let Ok(first_oid) = request_rx.recv().await { + let mut pending_oids = vec![first_oid]; + while let Ok(oid) = request_rx.try_recv() { + pending_oids.push(oid); + } + + this.update(cx, |this, cx| { + if this.search_state.selected_index.is_none() { + this.search_state.selected_index = Some(0); + this.select_commit_by_sha(first_oid, cx); + } + + this.search_state.matches.extend(pending_oids); + cx.notify(); + }) + .ok(); + } + + this.update(cx, |this, cx| { + if this.search_state.matches.is_empty() { + this.search_state.editor.update(cx, |editor, cx| { + editor.set_text_style_refinement(TextStyleRefinement { + color: Some(Color::Error.color(cx)), + ..Default::default() + }); + }); + } + }) + .ok(); + }); + + self.search_state.state = QueryState::Confirmed((query, search_task)); + } + + fn confirm_search(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + let query = self.search_state.editor.read(cx).text(cx).into(); + self.search(query, cx); + } + + fn select_entry( + &mut self, + idx: usize, + scroll_strategy: ScrollStrategy, + cx: &mut Context, + ) { if self.selected_entry_idx == Some(idx) { return; } @@ -1215,9 +1385,7 @@ impl GitGraph { self.changed_files_scroll_handle .scroll_to_item(0, ScrollStrategy::Top); self.table_interaction_state.update(cx, |state, cx| { - state - .scroll_handle - .scroll_to_item(idx, ScrollStrategy::Nearest); + state.scroll_handle.scroll_to_item(idx, scroll_strategy); cx.notify(); }); @@ -1248,25 +1416,71 @@ impl GitGraph { cx.notify(); } - pub fn select_commit_by_sha(&mut self, sha: &str, cx: &mut Context) { - let Ok(oid) = sha.parse::() else { + fn select_previous_match(&mut self, cx: &mut Context) { + if self.search_state.matches.is_empty() { return; - }; + } + + let mut prev_selection = self.search_state.selected_index.unwrap_or_default(); - let Some(selected_repository) = self.get_selected_repository(cx) else { + if prev_selection == 0 { + prev_selection = self.search_state.matches.len() - 1; + } else { + prev_selection -= 1; + } + + let Some(&oid) = self.search_state.matches.get_index(prev_selection) else { return; }; - let Some(index) = selected_repository - .read(cx) - .get_graph_data(self.log_source.clone(), self.log_order) - .and_then(|data| data.commit_oid_to_index.get(&oid)) - .copied() - else { + self.search_state.selected_index = Some(prev_selection); + self.select_commit_by_sha(oid, cx); + } + + fn select_next_match(&mut self, cx: &mut Context) { + if self.search_state.matches.is_empty() { + return; + } + + let mut next_selection = self + .search_state + .selected_index + .map(|index| index + 1) + .unwrap_or_default(); + + if next_selection >= self.search_state.matches.len() { + next_selection = 0; + } + + let Some(&oid) = self.search_state.matches.get_index(next_selection) else { return; }; - self.select_entry(index, cx); + self.search_state.selected_index = Some(next_selection); + self.select_commit_by_sha(oid, cx); + } + + pub fn select_commit_by_sha(&mut self, sha: impl TryInto, cx: &mut Context) { + fn inner(this: &mut GitGraph, oid: Oid, cx: &mut Context) { + let Some(selected_repository) = this.get_selected_repository(cx) else { + return; + }; + + let Some(index) = selected_repository + .read(cx) + .get_graph_data(this.log_source.clone(), this.log_order) + .and_then(|data| data.commit_oid_to_index.get(&oid)) + .copied() + else { + return; + }; + + this.select_entry(index, ScrollStrategy::Center, cx); + } + + if let Ok(oid) = sha.try_into() { + inner(self, oid, cx); + } } fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context) { @@ -1318,6 +1532,129 @@ impl GitGraph { }) } + fn render_search_bar(&self, cx: &mut Context) -> impl IntoElement { + let color = cx.theme().colors(); + let query_focus_handle = self.search_state.editor.focus_handle(cx); + let search_options = { + let mut options = SearchOptions::NONE; + options.set( + SearchOptions::CASE_SENSITIVE, + self.search_state.case_sensitive, + ); + options + }; + + h_flex() + .w_full() + .p_1p5() + .gap_1p5() + .border_b_1() + .border_color(color.border_variant) + .child( + h_flex() + .h_8() + .flex_1() + .min_w_0() + .px_1p5() + .gap_1() + .border_1() + .border_color(color.border) + .rounded_md() + .bg(color.toolbar_background) + .on_action(cx.listener(Self::confirm_search)) + .child(self.search_state.editor.clone()) + .child(SearchOption::CaseSensitive.as_button( + search_options, + SearchSource::Buffer, + query_focus_handle, + )), + ) + .child( + h_flex() + .min_w_64() + .gap_1() + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("git-graph-search-prev", IconName::ChevronLeft) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Select Previous Match", + &SelectPreviousMatch, + &focus_handle, + cx, + ) + }) + .map(|this| { + if self.search_state.matches.is_empty() { + this.disabled(true) + } else { + this.disabled(false).on_click(cx.listener(|this, _, _, cx| { + this.select_previous_match(cx); + })) + } + }) + }) + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("git-graph-search-next", IconName::ChevronRight) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Select Next Match", + &SelectNextMatch, + &focus_handle, + cx, + ) + }) + .map(|this| { + if self.search_state.matches.is_empty() { + this.disabled(true) + } else { + this.disabled(false).on_click(cx.listener(|this, _, _, cx| { + this.select_next_match(cx); + })) + } + }) + }) + .child( + h_flex() + .gap_1p5() + .child( + Label::new(format!( + "{}/{}", + self.search_state + .selected_index + .map(|index| index + 1) + .unwrap_or(0), + self.search_state.matches.len() + )) + .size(LabelSize::Small) + .when(self.search_state.matches.is_empty(), |this| { + this.color(Color::Disabled) + }), + ) + .when( + matches!( + &self.search_state.state, + QueryState::Confirmed((_, task)) if !task.is_ready() + ), + |this| { + this.child( + Icon::new(IconName::ArrowCircle) + .color(Color::Accent) + .size(IconSize::Small) + .with_rotate_animation(2) + .into_any_element(), + ) + }, + ), + ), + ) + } + fn render_loading_spinner(&self, cx: &App) -> AnyElement { let rems = TextSize::Large.rems(cx); Icon::new(IconName::LoadCircle) @@ -1360,7 +1697,8 @@ impl GitGraph { .copied() .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default()); - let (author_name, author_email, commit_timestamp, subject) = match &data { + // todo(git graph): We should use the full commit message here + let (author_name, author_email, commit_timestamp, commit_message) = match &data { CommitDataState::Loaded(data) => ( data.author_name.clone(), data.author_email.clone(), @@ -1616,7 +1954,7 @@ impl GitGraph { ), ) .child(Divider::horizontal()) - .child(div().min_w_0().p_2().child(Label::new(subject))) + .child(div().p_2().child(Label::new(commit_message))) .child(Divider::horizontal()) .child( v_flex() @@ -1854,13 +2192,45 @@ impl GitGraph { -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH }; - let control = match curve_kind { + match curve_kind { CurveKind::Checkout => { if is_last { to_column -= column_shift; } builder.move_to(point(current_column, current_row)); - point(current_column, to_row) + + if (to_column - current_column).abs() > LANE_WIDTH { + // Multi-lane checkout: straight down, small + // curve turn, then straight horizontal. + if (to_row - current_row).abs() > row_height { + let vertical_end = + point(current_column, to_row - row_height); + builder.line_to(vertical_end); + builder.move_to(vertical_end); + } + + let lane_shift = if going_right { + LANE_WIDTH + } else { + -LANE_WIDTH + }; + let curve_end = + point(current_column + lane_shift, to_row); + let curve_control = point(current_column, to_row); + builder.curve_to(curve_end, curve_control); + builder.move_to(curve_end); + + builder.line_to(point(to_column, to_row)); + } else { + if (to_row - current_row).abs() > row_height { + let start_curve = + point(current_column, to_row - row_height); + builder.line_to(start_curve); + builder.move_to(start_curve); + } + let control = point(current_column, to_row); + builder.curve_to(point(to_column, to_row), control); + } } CurveKind::Merge => { if is_last { @@ -1870,37 +2240,25 @@ impl GitGraph { current_column + column_shift, current_row - COMMIT_CIRCLE_RADIUS, )); - point(to_column, current_row) - } - }; - - match curve_kind { - CurveKind::Checkout - if (to_row - current_row).abs() > row_height => - { - let start_curve = - point(current_column, current_row + row_height); - builder.line_to(start_curve); - builder.move_to(start_curve); - } - CurveKind::Merge - if (to_column - current_column).abs() > LANE_WIDTH => - { - let column_shift = - if going_right { LANE_WIDTH } else { -LANE_WIDTH }; - let start_curve = point( - current_column + column_shift, - current_row - COMMIT_CIRCLE_RADIUS, - ); + if (to_column - current_column).abs() > LANE_WIDTH { + let column_shift = if going_right { + LANE_WIDTH + } else { + -LANE_WIDTH + }; + let start_curve = point( + current_column + column_shift, + current_row - COMMIT_CIRCLE_RADIUS, + ); + builder.line_to(start_curve); + builder.move_to(start_curve); + } - builder.line_to(start_curve); - builder.move_to(start_curve); + let control = point(to_column, current_row); + builder.curve_to(point(to_column, to_row), control); } - _ => {} - }; - - builder.curve_to(point(to_column, to_row), control); + } current_row = to_row; current_column = to_column; builder.move_to(point(current_column, current_row)); @@ -1976,7 +2334,7 @@ impl GitGraph { cx: &mut Context, ) { if let Some(row) = self.row_at_position(event.position().y, cx) { - self.select_entry(row, cx); + self.select_entry(row, ScrollStrategy::Nearest, cx); if event.click_count() >= 2 { self.open_commit_view(row, window, cx); } @@ -2067,6 +2425,12 @@ impl GitGraph { impl Render for GitGraph { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // This happens when we changed branches, we should refresh our search as well + if let QueryState::Pending(query) = &mut self.search_state.state { + let query = std::mem::take(query); + self.search_state.state = QueryState::Empty; + self.search(query, cx); + } let description_width_fraction = 0.72; let date_width_fraction = 0.12; let author_width_fraction = 0.10; @@ -2229,7 +2593,7 @@ impl Render for GitGraph { .on_click(move |event, window, cx| { let click_count = event.click_count(); weak.update(cx, |this, cx| { - this.select_entry(index, cx); + this.select_entry(index, ScrollStrategy::Center, cx); if click_count >= 2 { this.open_commit_view(index, window, cx); } @@ -2275,7 +2639,23 @@ impl Render for GitGraph { .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::confirm)) - .child(content) + .on_action(cx.listener(|this, _: &SelectNextMatch, _window, cx| { + this.select_next_match(cx); + })) + .on_action(cx.listener(|this, _: &SelectPreviousMatch, _window, cx| { + this.select_previous_match(cx); + })) + .on_action(cx.listener(|this, _: &ToggleCaseSensitive, _window, cx| { + this.search_state.case_sensitive = !this.search_state.case_sensitive; + this.search_state.state.next_state(); + cx.notify(); + })) + .child( + v_flex() + .size_full() + .child(self.render_search_bar(cx)) + .child(div().flex_1().child(content)), + ) .children(self.context_menu.as_ref().map(|(menu, position, _)| { deferred( anchored() @@ -2489,7 +2869,7 @@ mod tests { 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); }); } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 2525edd357143fe0514bd671953d171466ec9aa5..d95e25fbc7821d42fac4386b522c4effb9462715 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -56,6 +56,7 @@ smol.workspace = true strum.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true time.workspace = true time_format.workspace = true ui.workspace = true diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs index c2d7333484224bbfbc248e25fb2ac51a19f428e2..47d781c4870ade9688b93b75db5a68dd26865ca8 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/crates/git_ui/src/blame_ui.rs @@ -11,7 +11,7 @@ use gpui::{ use markdown::{Markdown, MarkdownElement}; use project::{git_store::Repository, project_settings::ProjectSettings}; use settings::Settings as _; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use time::OffsetDateTime; use ui::{ContextMenu, CopyButton, Divider, prelude::*, tooltip_container}; use workspace::Workspace; diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index d9adc079bf470e121c99b2d13c2ef25ffea7a68f..438df6839949d46d3ba8e0509995beb1300b7c80 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -486,6 +486,10 @@ impl BranchListDelegate { let is_remote; let result = match &entry { Entry::Branch { branch, .. } => { + if branch.is_head { + return Ok(()); + } + is_remote = branch.is_remote(); repo.update(cx, |repo, _| { repo.delete_branch(is_remote, branch.name().to_string()) @@ -1151,20 +1155,29 @@ impl PickerDelegate for BranchListDelegate { let delete_and_select_btns = h_flex() .gap_1() - .child( - Button::new("delete-branch", "Delete") - .key_binding( - KeyBinding::for_action_in( - &branch_picker::DeleteBranch, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), + .when( + !selected_entry + .and_then(|entry| entry.as_branch()) + .is_some_and(|branch| branch.is_head), + |this| { + this.child( + Button::new("delete-branch", "Delete") + .key_binding( + KeyBinding::for_action_in( + &branch_picker::DeleteBranch, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action( + branch_picker::DeleteBranch.boxed_clone(), + cx, + ); + }), ) - .on_click(|_, window, cx| { - window - .dispatch_action(branch_picker::DeleteBranch.boxed_clone(), cx); - }), + }, ) .child( Button::new("select_branch", "Select") @@ -1312,7 +1325,7 @@ mod tests { 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); editor::init(cx); }); } diff --git a/crates/git_ui/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs index 4740e148099980a7510a1f551d0d3f51c08892a1..b22fcee7e2de5273983b6959f8c52511b877eeaf 100644 --- a/crates/git_ui/src/commit_tooltip.rs +++ b/crates/git_ui/src/commit_tooltip.rs @@ -12,7 +12,7 @@ use markdown::{Markdown, MarkdownElement}; use project::git_store::Repository; use settings::Settings; use std::hash::Hash; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use time::{OffsetDateTime, UtcOffset}; use ui::{Avatar, CopyButton, Divider, prelude::*, tooltip_container}; use workspace::Workspace; diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index bdd5dee36e2d54888d081cfefed21602ecb8fa1b..6fe3d9484b4b6aca72f39ab5672e24e1430114ec 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -379,7 +379,7 @@ mod tests { 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); }); } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 00b287f7f3d724e0fcc5275ea302c44983c9a61b..00058ee82bb8df15e63d96771f481833310f09f5 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -66,7 +66,7 @@ use std::ops::Range; use std::path::Path; use std::{sync::Arc, time::Duration, usize}; use strum::{IntoEnumIterator, VariantNames}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use time::OffsetDateTime; use ui::{ ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IndentGuideColors, @@ -6491,7 +6491,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); editor::init(cx); crate::init(cx); }); diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 6d8b91cc54cc4baeb4fdda594404e04181fe6cf4..4d3fa23d9e41d9d04688d683a1b11caf6035147d 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -1777,7 +1777,7 @@ mod tests { settings.editor.diff_view_style = Some(DiffViewStyle::Unified); }); }); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); crate::init(cx); }); diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index 8947ed233790ab65557e13518d51bd383fc93c2d..9987190f45b73f3f1132ce1295de6f412022abe2 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -632,7 +632,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); }) } diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index 965f41030817d3b7434a6fd02fb3a2de18046823..2dfef13f72681456174737af61380b87caae0ae1 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -499,7 +499,7 @@ mod tests { settings.editor.diff_view_style = Some(DiffViewStyle::Unified); }); }); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 2cbba9347f973aa084413b03af7314f30d22fc99..d7488cc1bddb7e9d2825b8d21d3dc6c4c4fdde5a 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -19,7 +19,7 @@ use remote_connection::{RemoteConnectionModal, connect}; use settings::Settings; use std::{path::PathBuf, sync::Arc}; use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; -use util::ResultExt; +use util::{ResultExt, debug_panic}; use workspace::{ModalView, MultiWorkspace, Workspace, notifications::DetachAndPromptErr}; use crate::git_panel::show_error_toast; @@ -115,6 +115,7 @@ impl WorktreeList { this.picker.update(cx, |picker, cx| { picker.delegate.all_worktrees = Some(all_worktrees); picker.delegate.default_branch = default_branch; + picker.delegate.refresh_forbidden_deletion_path(cx); picker.refresh(window, cx); }) })?; @@ -261,6 +262,7 @@ pub struct WorktreeListDelegate { modifiers: Modifiers, focus_handle: FocusHandle, default_branch: Option, + forbidden_deletion_path: Option, } impl WorktreeListDelegate { @@ -280,6 +282,7 @@ impl WorktreeListDelegate { modifiers: Default::default(), focus_handle: cx.focus_handle(), default_branch: None, + forbidden_deletion_path: None, } } @@ -452,7 +455,7 @@ impl WorktreeListDelegate { let Some(entry) = self.matches.get(idx).cloned() else { return; }; - if entry.is_new { + if entry.is_new || self.forbidden_deletion_path.as_ref() == Some(&entry.worktree.path) { return; } let Some(repo) = self.repo.clone() else { @@ -486,6 +489,7 @@ impl WorktreeListDelegate { if let Some(all_worktrees) = &mut picker.delegate.all_worktrees { all_worktrees.retain(|w| w.path != path); } + picker.delegate.refresh_forbidden_deletion_path(cx); if picker.delegate.matches.is_empty() { picker.delegate.selected_index = 0; } else if picker.delegate.selected_index >= picker.delegate.matches.len() { @@ -498,6 +502,29 @@ impl WorktreeListDelegate { }) .detach(); } + + fn refresh_forbidden_deletion_path(&mut self, cx: &App) { + let Some(workspace) = self.workspace.upgrade() else { + debug_panic!("Workspace should always be available or else the picker would be closed"); + self.forbidden_deletion_path = None; + return; + }; + + let visible_worktree_paths = workspace.read_with(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) + .collect::>() + }); + + self.forbidden_deletion_path = if visible_worktree_paths.len() == 1 { + visible_worktree_paths.into_iter().next() + } else { + None + }; + } } async fn open_remote_worktree( @@ -771,6 +798,9 @@ impl PickerDelegate for WorktreeListDelegate { let focus_handle = self.focus_handle.clone(); + let can_delete = + !entry.is_new && self.forbidden_deletion_path.as_ref() != Some(&entry.worktree.path); + let delete_button = |entry_ix: usize| { IconButton::new(("delete-worktree", entry_ix), IconName::Trash) .icon_size(IconSize::Small) @@ -839,7 +869,7 @@ impl PickerDelegate for WorktreeListDelegate { } })), ) - .when(!entry.is_new, |this| { + .when(can_delete, |this| { if selected { this.end_slot(delete_button(ix)) } else { @@ -857,6 +887,9 @@ impl PickerDelegate for WorktreeListDelegate { let focus_handle = self.focus_handle.clone(); let selected_entry = self.matches.get(self.selected_index); let is_creating = selected_entry.is_some_and(|entry| entry.is_new); + let can_delete = selected_entry.is_some_and(|entry| { + !entry.is_new && self.forbidden_deletion_path.as_ref() != Some(&entry.worktree.path) + }); let footer_container = h_flex() .w_full() @@ -904,16 +937,18 @@ impl PickerDelegate for WorktreeListDelegate { } else { Some( footer_container - .child( - Button::new("delete-worktree", "Delete") - .key_binding( - KeyBinding::for_action_in(&DeleteWorktree, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(DeleteWorktree.boxed_clone(), cx) - }), - ) + .when(can_delete, |this| { + this.child( + Button::new("delete-worktree", "Delete") + .key_binding( + KeyBinding::for_action_in(&DeleteWorktree, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(DeleteWorktree.boxed_clone(), cx) + }), + ) + }) .child( Button::new("open-in-new-window", "Open in New Window") .key_binding( diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 9eb2de936c2e1db1d80cc3627db5594152e7223e..915f0fc03e2cc5beaf40c810654724295c41cde8 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -70,14 +70,17 @@ chrono.workspace = true profiling.workspace = true rand.workspace = true raw-window-handle = "0.6" +regex.workspace = true refineable.workspace = true scheduler.workspace = true resvg = { version = "0.45.0", default-features = false, features = [ "text", "system-fonts", "memmap-fonts", + "raster-images" ] } usvg = { version = "0.45.0", default-features = false } +ttf-parser = "0.25" util_macros.workspace = true schemars.workspace = true seahash = "4.1" @@ -95,7 +98,6 @@ gpui_util.workspace = true waker-fn = "1.2.0" lyon = "1.0" pin-project = "1.1.10" -circular-buffer.workspace = true spin = "0.10.0" pollster.workspace = true url.workspace = true @@ -145,12 +147,12 @@ backtrace.workspace = true collections = { workspace = true, features = ["test-support"] } env_logger.workspace = true gpui_platform = { workspace = true, features = ["font-kit"] } +gpui_util = { workspace = true } lyon = { version = "1.0", features = ["extra"] } +proptest = { workspace = true } rand.workspace = true scheduler = { workspace = true, features = ["test-support"] } -unicode-segmentation.workspace = true -gpui_util = { workspace = true } -proptest = { workspace = true } +unicode-segmentation = { workspace = true } [target.'cfg(not(target_family = "wasm"))'.dev-dependencies] http_client = { workspace = true, features = ["test-support"] } diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 578900085334baf27ab90ae77748fb7fd362e8ad..ed441e3b40534690d02b31109e719c60dd5802e0 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -72,6 +72,7 @@ struct StateInner { scrollbar_drag_start_height: Option, measuring_behavior: ListMeasuringBehavior, pending_scroll: Option, + follow_tail: bool, } /// Keeps track of a fractional scroll position within an item for restoration @@ -102,6 +103,9 @@ pub struct ListScrollEvent { /// Whether the list has been scrolled. pub is_scrolled: bool, + + /// Whether the list is currently in follow-tail mode (auto-scrolling to end). + pub is_following_tail: bool, } /// The sizing behavior to apply during layout. @@ -236,6 +240,7 @@ impl ListState { scrollbar_drag_start_height: None, measuring_behavior: ListMeasuringBehavior::default(), pending_scroll: None, + follow_tail: false, }))); this.splice(0..0, item_count); this @@ -394,6 +399,34 @@ impl ListState { }); } + /// Scroll the list to the very end (past the last item). + /// + /// Unlike [`scroll_to_reveal_item`], this uses the total item count as the + /// anchor, so the list's layout pass will walk backwards from the end and + /// always show the bottom of the last item — even when that item is still + /// growing (e.g. during streaming). + pub fn scroll_to_end(&self) { + let state = &mut *self.0.borrow_mut(); + let item_count = state.items.summary().count; + state.logical_scroll_top = Some(ListOffset { + item_ix: item_count, + offset_in_item: px(0.), + }); + } + + /// Set whether the list should automatically follow the tail (auto-scroll to the end). + pub fn set_follow_tail(&self, follow: bool) { + self.0.borrow_mut().follow_tail = follow; + if follow { + self.scroll_to_end(); + } + } + + /// Returns whether the list is currently in follow-tail mode (auto-scrolling to the end). + pub fn is_following_tail(&self) -> bool { + self.0.borrow().follow_tail + } + /// Scroll the list to the given offset pub fn scroll_to(&self, mut scroll_top: ListOffset) { let state = &mut *self.0.borrow_mut(); @@ -559,7 +592,6 @@ impl StateInner { if self.reset { return; } - let padding = self.last_padding.unwrap_or_default(); let scroll_max = (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.)); @@ -581,6 +613,10 @@ impl StateInner { }); } + if self.follow_tail && delta.y > px(0.) { + self.follow_tail = false; + } + if let Some(handler) = self.scroll_handler.as_mut() { let visible_range = Self::visible_range(&self.items, height, scroll_top); handler( @@ -588,6 +624,7 @@ impl StateInner { visible_range, count: self.items.summary().count, is_scrolled: self.logical_scroll_top.is_some(), + is_following_tail: self.follow_tail, }, window, cx, @@ -677,6 +714,15 @@ impl StateInner { let mut rendered_height = padding.top; let mut max_item_width = px(0.); let mut scroll_top = self.logical_scroll_top(); + + if self.follow_tail { + scroll_top = ListOffset { + item_ix: self.items.summary().count, + offset_in_item: px(0.), + }; + self.logical_scroll_top = Some(scroll_top); + } + let mut rendered_focused_item = false; let available_item_space = size( @@ -958,6 +1004,8 @@ impl StateInner { content_height - self.scrollbar_drag_start_height.unwrap_or(content_height); let new_scroll_top = (point.y - drag_offset).abs().max(px(0.)).min(scroll_max); + self.follow_tail = false; + if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { self.logical_scroll_top = None; } else { @@ -1457,6 +1505,217 @@ mod test { assert_eq!(offset.offset_in_item, px(20.)); } + #[gpui::test] + fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items, each 50px tall → 500px total content, 200px viewport. + // With follow-tail on, the list should always show the bottom. + let item_height = Rc::new(Cell::new(50usize)); + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView { + state: ListState, + item_height: Rc>, + } + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let height = self.item_height.get(); + list(self.state.clone(), move |_, _, _| { + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let state_clone = state.clone(); + let item_height_clone = item_height.clone(); + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state_clone, + item_height: item_height_clone, + }) + }); + + state.set_follow_tail(true); + + // First paint — items are 50px, total 500px, viewport 200px. + // Follow-tail should anchor to the end. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + // The scroll should be at the bottom: the last visible items fill the + // 200px viewport from the end of 500px of content (offset 300px). + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 6); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(state.is_following_tail()); + + // Simulate items growing (e.g. streaming content makes each item taller). + // 10 items × 80px = 800px total. + item_height.set(80); + state.remeasure(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + // After growth, follow-tail should have re-anchored to the new end. + // 800px total − 200px viewport = 600px offset → item 7 at offset 40px, + // but follow-tail anchors to item_count (10), and layout walks back to + // fill 200px, landing at item 7 (7 × 80 = 560, 800 − 560 = 240 > 200, + // so item 8: 8 × 80 = 640, 800 − 640 = 160 < 200 → keeps walking → + // item 7: offset = 800 − 200 = 600, item_ix = 600/80 = 7, remainder 40). + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 7); + assert_eq!(offset.offset_in_item, px(40.)); + assert!(state.is_following_tail()); + } + + #[gpui::test] + fn test_follow_tail_disengages_on_user_scroll(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + state.set_follow_tail(true); + + // Paint with follow-tail — scroll anchored to the bottom. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + assert!(state.is_following_tail()); + + // Simulate the user scrolling up. + // This should disengage follow-tail. + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(100.))), + ..Default::default() + }); + + assert!( + !state.is_following_tail(), + "follow-tail should disengage when the user scrolls toward the start" + ); + } + + #[gpui::test] + fn test_follow_tail_disengages_on_scrollbar_reposition(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_tail(true); + + // Paint with follow-tail — scroll anchored to the bottom. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + // Simulate the scrollbar moving the viewport to the middle. + // `set_offset_from_scrollbar` accepts a positive distance from the start. + state.set_offset_from_scrollbar(point(px(0.), px(150.))); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + assert!( + !state.is_following_tail(), + "follow-tail should disengage when the scrollbar manually repositions the list" + ); + + // A subsequent draw should preserve the user's manual position instead + // of snapping back to the end. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + } + + #[gpui::test] + fn test_set_follow_tail_snaps_to_bottom(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + // Scroll to the middle of the list (item 3). + state.scroll_to(gpui::ListOffset { + item_ix: 3, + offset_in_item: px(0.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(!state.is_following_tail()); + + // Enable follow-tail — this should immediately snap the scroll anchor + // to the end, like the user just sent a prompt. + state.set_follow_tail(true); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + // After paint, scroll should be at the bottom. + // 500px total − 200px viewport = 300px offset → item 6, offset 0. + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 6); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(state.is_following_tail()); + } + #[gpui::test] fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); diff --git a/crates/gpui/src/profiler.rs b/crates/gpui/src/profiler.rs index dc6e9a6600f5c172050fd30cfed181ac7ed81ec4..1405b4d04964f5497bb4d7f865d6c4405507b43d 100644 --- a/crates/gpui/src/profiler.rs +++ b/crates/gpui/src/profiler.rs @@ -1,9 +1,8 @@ use scheduler::Instant; use std::{ cell::LazyCell, - collections::HashMap, - hash::Hasher, - hash::{DefaultHasher, Hash}, + collections::{HashMap, VecDeque}, + hash::{DefaultHasher, Hash, Hasher}, sync::Arc, thread::ThreadId, }; @@ -45,7 +44,6 @@ impl ThreadTaskTimings { let timings = &timings.timings; let mut vec = Vec::with_capacity(timings.len()); - let (s1, s2) = timings.as_slices(); vec.extend_from_slice(s1); vec.extend_from_slice(s2); @@ -243,11 +241,14 @@ impl ProfilingCollector { } } -// Allow 20mb of task timing entries -const MAX_TASK_TIMINGS: usize = (20 * 1024 * 1024) / core::mem::size_of::(); +// Allow 16MiB of task timing entries. +// VecDeque grows by doubling its capacity when full, so keep this a power of 2 to avoid wasting +// memory. +const MAX_TASK_TIMINGS: usize = (16 * 1024 * 1024) / core::mem::size_of::(); #[doc(hidden)] -pub type TaskTimings = circular_buffer::CircularBuffer; +pub(crate) type TaskTimings = VecDeque; + #[doc(hidden)] pub type GuardedTaskTimings = spin::Mutex; @@ -287,7 +288,7 @@ thread_local! { pub struct ThreadTimings { pub thread_name: Option, pub thread_id: ThreadId, - pub timings: Box, + pub timings: TaskTimings, pub total_pushed: u64, } @@ -296,10 +297,38 @@ impl ThreadTimings { ThreadTimings { thread_name, thread_id, - timings: TaskTimings::boxed(), + timings: TaskTimings::new(), total_pushed: 0, } } + + /// If this task is the same as the last task, update the end time of the last task. + /// + /// Otherwise, add the new task timing to the list. + pub fn add_task_timing(&mut self, timing: TaskTiming) { + if let Some(last_timing) = self.timings.back_mut() + && last_timing.location == timing.location + && last_timing.start == timing.start + { + last_timing.end = timing.end; + } else { + while self.timings.len() + 1 > MAX_TASK_TIMINGS { + // This should only ever pop one element because it matches the insertion below. + self.timings.pop_front(); + } + self.timings.push_back(timing); + self.total_pushed += 1; + } + } + + pub fn get_thread_task_timings(&self) -> ThreadTaskTimings { + ThreadTaskTimings { + thread_name: self.thread_name.clone(), + thread_id: self.thread_id, + timings: self.timings.iter().cloned().collect(), + total_pushed: self.total_pushed, + } + } } impl Drop for ThreadTimings { @@ -318,19 +347,13 @@ impl Drop for ThreadTimings { } #[doc(hidden)] -#[allow(dead_code)] // Used by Linux and Windows dispatchers, not macOS pub fn add_task_timing(timing: TaskTiming) { THREAD_TIMINGS.with(|timings| { - let mut timings = timings.lock(); - - if let Some(last_timing) = timings.timings.back_mut() { - if last_timing.location == timing.location && last_timing.start == timing.start { - last_timing.end = timing.end; - return; - } - } - - timings.timings.push_back(timing); - timings.total_pushed += 1; + timings.lock().add_task_timing(timing); }); } + +#[doc(hidden)] +pub fn get_current_thread_task_timings() -> ThreadTaskTimings { + THREAD_TIMINGS.with(|timings| timings.lock().get_thread_task_timings()) +} diff --git a/crates/gpui/src/svg_renderer.rs b/crates/gpui/src/svg_renderer.rs index f82530f8d10fab074dd5e116114cf028a8a19cfe..217555e3b0e295d06e375e19d013e0b520118e0b 100644 --- a/crates/gpui/src/svg_renderer.rs +++ b/crates/gpui/src/svg_renderer.rs @@ -10,6 +10,73 @@ use std::{ sync::{Arc, LazyLock}, }; +#[cfg(target_os = "macos")] +const EMOJI_FONT_FAMILIES: &[&str] = &["Apple Color Emoji", ".AppleColorEmojiUI"]; + +#[cfg(target_os = "windows")] +const EMOJI_FONT_FAMILIES: &[&str] = &["Segoe UI Emoji", "Segoe UI Symbol"]; + +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +const EMOJI_FONT_FAMILIES: &[&str] = &[ + "Noto Color Emoji", + "Emoji One", + "Twitter Color Emoji", + "JoyPixels", +]; + +#[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "freebsd", +)))] +const EMOJI_FONT_FAMILIES: &[&str] = &[]; + +fn is_emoji_presentation(c: char) -> bool { + static EMOJI_PRESENTATION_REGEX: LazyLock = + LazyLock::new(|| regex::Regex::new("\\p{Emoji_Presentation}").unwrap()); + let mut buf = [0u8; 4]; + EMOJI_PRESENTATION_REGEX.is_match(c.encode_utf8(&mut buf)) +} + +fn font_has_char(db: &usvg::fontdb::Database, id: usvg::fontdb::ID, ch: char) -> bool { + db.with_face_data(id, |font_data, face_index| { + ttf_parser::Face::parse(font_data, face_index) + .ok() + .and_then(|face| face.glyph_index(ch)) + .is_some() + }) + .unwrap_or(false) +} + +fn select_emoji_font( + ch: char, + fonts: &[usvg::fontdb::ID], + db: &usvg::fontdb::Database, + families: &[&str], +) -> Option { + for family_name in families { + let query = usvg::fontdb::Query { + families: &[usvg::fontdb::Family::Name(family_name)], + weight: usvg::fontdb::Weight(400), + stretch: usvg::fontdb::Stretch::Normal, + style: usvg::fontdb::Style::Normal, + }; + + let Some(id) = db.query(&query) else { + continue; + }; + + if fonts.contains(&id) || !font_has_char(db, id, ch) { + continue; + } + + return Some(id); + } + + None +} + /// When rendering SVGs, we render them at twice the size to get a higher-quality result. pub const SMOOTH_SVG_SCALE_FACTOR: f32 = 2.; @@ -52,10 +119,23 @@ impl SvgRenderer { default_font_resolver(font, db) }, ); + let default_fallback_selection = usvg::FontResolver::default_fallback_selector(); + let fallback_selection = Box::new( + move |ch: char, fonts: &[usvg::fontdb::ID], db: &mut Arc| { + if is_emoji_presentation(ch) { + if let Some(id) = select_emoji_font(ch, fonts, db.as_ref(), EMOJI_FONT_FAMILIES) + { + return Some(id); + } + } + + default_fallback_selection(ch, fonts, db) + }, + ); let options = usvg::Options { font_resolver: usvg::FontResolver { select_font: font_resolver, - select_fallback: usvg::FontResolver::default_fallback_selector(), + select_fallback: fallback_selection, }, ..Default::default() }; @@ -148,3 +228,73 @@ impl SvgRenderer { Ok(pixmap) } } + +#[cfg(test)] +mod tests { + use super::*; + + const IBM_PLEX_REGULAR: &[u8] = + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"); + const LILEX_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf"); + + #[test] + fn test_is_emoji_presentation() { + let cases = [ + ("a", false), + ("Z", false), + ("1", false), + ("#", false), + ("*", false), + ("漢", false), + ("中", false), + ("カ", false), + ("©", false), + ("♥", false), + ("😀", true), + ("✅", true), + ("🇺🇸", true), + // SVG fallback is not cluster-aware yet + ("©️", false), + ("♥️", false), + ("1️⃣", false), + ]; + for (s, expected) in cases { + assert_eq!( + is_emoji_presentation(s.chars().next().unwrap()), + expected, + "for char {:?}", + s + ); + } + } + + #[test] + fn test_select_emoji_font_skips_family_without_glyph() { + let mut db = usvg::fontdb::Database::new(); + + db.load_font_data(IBM_PLEX_REGULAR.to_vec()); + db.load_font_data(LILEX_REGULAR.to_vec()); + + let ibm_plex_sans = db + .query(&usvg::fontdb::Query { + families: &[usvg::fontdb::Family::Name("IBM Plex Sans")], + weight: usvg::fontdb::Weight(400), + stretch: usvg::fontdb::Stretch::Normal, + style: usvg::fontdb::Style::Normal, + }) + .unwrap(); + let lilex = db + .query(&usvg::fontdb::Query { + families: &[usvg::fontdb::Family::Name("Lilex")], + weight: usvg::fontdb::Weight(400), + stretch: usvg::fontdb::Stretch::Normal, + style: usvg::fontdb::Style::Normal, + }) + .unwrap(); + let selected = select_emoji_font('│', &[], &db, &["IBM Plex Sans", "Lilex"]).unwrap(); + + assert_eq!(selected, lilex); + assert!(!font_has_char(&db, ibm_plex_sans, '│')); + assert!(font_has_char(&db, selected, '│')); + } +} diff --git a/crates/gpui_linux/src/linux/dispatcher.rs b/crates/gpui_linux/src/linux/dispatcher.rs index a72276cc7658a399505fa62bd2d5fe7b41e43e14..22df5799ddf9c77bfdbc7b09accbea117de6d130 100644 --- a/crates/gpui_linux/src/linux/dispatcher.rs +++ b/crates/gpui_linux/src/linux/dispatcher.rs @@ -13,7 +13,7 @@ use std::{ use gpui::{ GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueReceiver, - PriorityQueueSender, RunnableVariant, THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, profiler, + PriorityQueueSender, RunnableVariant, TaskTiming, ThreadTaskTimings, profiler, }; struct TimerAfter { @@ -135,25 +135,7 @@ impl PlatformDispatcher for LinuxDispatcher { } fn get_current_thread_timings(&self) -> gpui::ThreadTaskTimings { - THREAD_TIMINGS.with(|timings| { - let timings = timings.lock(); - let thread_name = timings.thread_name.clone(); - let total_pushed = timings.total_pushed; - let timings = &timings.timings; - - let mut vec = Vec::with_capacity(timings.len()); - - let (s1, s2) = timings.as_slices(); - vec.extend_from_slice(s1); - vec.extend_from_slice(s2); - - gpui::ThreadTaskTimings { - thread_name, - thread_id: std::thread::current().id(), - timings: vec, - total_pushed, - } - }) + gpui::profiler::get_current_thread_task_timings() } fn is_main_thread(&self) -> bool { diff --git a/crates/gpui_macos/src/dispatcher.rs b/crates/gpui_macos/src/dispatcher.rs index dd6f546f68b88efe6babc13e2d923d634eff5825..f4b80ec7cbaf6deeebad1f7b6448463c9e132afe 100644 --- a/crates/gpui_macos/src/dispatcher.rs +++ b/crates/gpui_macos/src/dispatcher.rs @@ -1,7 +1,7 @@ use dispatch2::{DispatchQueue, DispatchQueueGlobalPriority, DispatchTime, GlobalQueueIdentifier}; use gpui::{ - GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, RunnableMeta, RunnableVariant, - THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, + GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, RunnableMeta, RunnableVariant, TaskTiming, + ThreadTaskTimings, add_task_timing, }; use mach2::{ kern_return::KERN_SUCCESS, @@ -42,25 +42,7 @@ impl PlatformDispatcher for MacDispatcher { } fn get_current_thread_timings(&self) -> ThreadTaskTimings { - THREAD_TIMINGS.with(|timings| { - let timings = timings.lock(); - let thread_name = timings.thread_name.clone(); - let total_pushed = timings.total_pushed; - let timings = &timings.timings; - - let mut vec = Vec::with_capacity(timings.len()); - - let (s1, s2) = timings.as_slices(); - vec.extend_from_slice(s1); - vec.extend_from_slice(s2); - - ThreadTaskTimings { - thread_name, - thread_id: std::thread::current().id(), - timings: vec, - total_pushed, - } - }) + gpui::profiler::get_current_thread_task_timings() } fn is_main_thread(&self) -> bool { @@ -204,33 +186,16 @@ extern "C" fn trampoline(context: *mut c_void) { let location = runnable.metadata().location; let start = Instant::now(); - let timing = TaskTiming { + let mut timing = TaskTiming { location, start, end: None, }; - THREAD_TIMINGS.with(|timings| { - let mut timings = timings.lock(); - let timings = &mut timings.timings; - if let Some(last_timing) = timings.iter_mut().rev().next() { - if last_timing.location == timing.location { - return; - } - } - - timings.push_back(timing); - }); + add_task_timing(timing); runnable.run(); - let end = Instant::now(); - THREAD_TIMINGS.with(|timings| { - let mut timings = timings.lock(); - let timings = &mut timings.timings; - let Some(last_timing) = timings.iter_mut().rev().next() else { - return; - }; - last_timing.end = Some(end); - }); + timing.end = Some(Instant::now()); + add_task_timing(timing); } diff --git a/crates/gpui_windows/src/dispatcher.rs b/crates/gpui_windows/src/dispatcher.rs index a5cfd9dc10d9afcce9580565943c28cb83dc9dab..60b9898cef3076fa64898ebcb7223616150bf01b 100644 --- a/crates/gpui_windows/src/dispatcher.rs +++ b/crates/gpui_windows/src/dispatcher.rs @@ -24,7 +24,7 @@ use windows::{ use crate::{HWND, SafeHwnd, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD}; use gpui::{ GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueSender, RunnableVariant, - THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, TimerResolutionGuard, + TaskTiming, ThreadTaskTimings, TimerResolutionGuard, }; pub(crate) struct WindowsDispatcher { @@ -106,25 +106,7 @@ impl PlatformDispatcher for WindowsDispatcher { } fn get_current_thread_timings(&self) -> gpui::ThreadTaskTimings { - THREAD_TIMINGS.with(|timings| { - let timings = timings.lock(); - let thread_name = timings.thread_name.clone(); - let total_pushed = timings.total_pushed; - let timings = &timings.timings; - - let mut vec = Vec::with_capacity(timings.len()); - - let (s1, s2) = timings.as_slices(); - vec.extend_from_slice(s1); - vec.extend_from_slice(s2); - - gpui::ThreadTaskTimings { - thread_name, - thread_id: std::thread::current().id(), - timings: vec, - total_pushed, - } - }) + gpui::profiler::get_current_thread_task_timings() } fn is_main_thread(&self) -> bool { diff --git a/crates/grammars/src/c/config.toml b/crates/grammars/src/c/config.toml index c490269b12309632d2fd8fb944ed48ee74c46075..a3b55f4f2d4fe3bfb19100e5877661c5841126a9 100644 --- a/crates/grammars/src/c/config.toml +++ b/crates/grammars/src/c/config.toml @@ -17,4 +17,3 @@ brackets = [ ] debuggers = ["CodeLLDB", "GDB"] documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } -import_path_strip_regex = "^<|>$" diff --git a/crates/grammars/src/c/imports.scm b/crates/grammars/src/c/imports.scm deleted file mode 100644 index 2aaab2106f5422db426876a7fa65c9674fe93174..0000000000000000000000000000000000000000 --- a/crates/grammars/src/c/imports.scm +++ /dev/null @@ -1,7 +0,0 @@ -(preproc_include - path: [ - ((system_lib_string) @source @wildcard - (#strip! @source "[<>]")) - (string_literal - (string_content) @source @wildcard) - ]) @import diff --git a/crates/grammars/src/cpp/config.toml b/crates/grammars/src/cpp/config.toml index dfce8ae7b2bfcfc7a7004822e9c6dca18e6cbe26..138d4a78e45f153eaa2eeb72a91654416154ed33 100644 --- a/crates/grammars/src/cpp/config.toml +++ b/crates/grammars/src/cpp/config.toml @@ -19,4 +19,3 @@ brackets = [ ] debuggers = ["CodeLLDB", "GDB"] documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } -import_path_strip_regex = "^<|>$" diff --git a/crates/grammars/src/cpp/imports.scm b/crates/grammars/src/cpp/imports.scm deleted file mode 100644 index 43adde711b5352ef0d92566d4bdde91a847319b8..0000000000000000000000000000000000000000 --- a/crates/grammars/src/cpp/imports.scm +++ /dev/null @@ -1,6 +0,0 @@ -(preproc_include - path: [ - (system_lib_string) @source @wildcard - (string_literal - (string_content) @source @wildcard) - ]) @import diff --git a/crates/grammars/src/go/imports.scm b/crates/grammars/src/go/imports.scm deleted file mode 100644 index 23e480c10b20b76c6724df29a550e627c2aee799..0000000000000000000000000000000000000000 --- a/crates/grammars/src/go/imports.scm +++ /dev/null @@ -1,12 +0,0 @@ -(import_spec - name: [ - (dot) - (package_identifier) - ] - path: (interpreted_string_literal - (interpreted_string_literal_content) @namespace)) @wildcard @import - -(import_spec - !name - path: (interpreted_string_literal - (interpreted_string_literal_content) @namespace)) @wildcard @import diff --git a/crates/grammars/src/javascript/config.toml b/crates/grammars/src/javascript/config.toml index 2850fd6bc47fe7d23fdfbf9588b2331fdef6e0fa..118024494a7b8f98bcff9354fd3d27f4fc1dcfc4 100644 --- a/crates/grammars/src/javascript/config.toml +++ b/crates/grammars/src/javascript/config.toml @@ -24,7 +24,6 @@ tab_size = 2 scope_opt_in_language_servers = ["tailwindcss-language-server", "emmet-language-server"] prettier_parser_name = "babel" debuggers = ["JavaScript"] -import_path_strip_regex = "(?:/index)?\\.[jt]s$" [jsx_tag_auto_close] open_tag_node_name = "jsx_opening_element" diff --git a/crates/grammars/src/javascript/imports.scm b/crates/grammars/src/javascript/imports.scm deleted file mode 100644 index 0e688d53fb6ed639c55c1fa84917711d19c3108a..0000000000000000000000000000000000000000 --- a/crates/grammars/src/javascript/imports.scm +++ /dev/null @@ -1,16 +0,0 @@ -(import_statement - import_clause: (import_clause - [ - (identifier) @name - (named_imports - (import_specifier - name: (_) @name - alias: (_)? @alias)) - ]) - source: (string - (string_fragment) @source)) @import - -(import_statement - !import_clause - source: (string - (string_fragment) @source @wildcard)) @import diff --git a/crates/grammars/src/python/config.toml b/crates/grammars/src/python/config.toml index fa409c5dd6519121e7130e4b33a6c3277ae1654b..0c2072393bf6cc1db6b152d80779cd7c81af1a7e 100644 --- a/crates/grammars/src/python/config.toml +++ b/crates/grammars/src/python/config.toml @@ -36,4 +36,3 @@ decrease_indent_patterns = [ { pattern = "^\\s*except\\b.*:\\s*(#.*)?", valid_after = ["try", "except"] }, { pattern = "^\\s*finally\\b.*:\\s*(#.*)?", valid_after = ["try", "except", "else"] }, ] -import_path_strip_regex = "/__init__\\.py$" diff --git a/crates/grammars/src/python/imports.scm b/crates/grammars/src/python/imports.scm deleted file mode 100644 index 26538fee1b41df13f258c8b315cc5e266458efa1..0000000000000000000000000000000000000000 --- a/crates/grammars/src/python/imports.scm +++ /dev/null @@ -1,38 +0,0 @@ -(import_statement - name: [ - (dotted_name - ((identifier) @namespace - ".")* - (identifier) @namespace .) - (aliased_import - name: (dotted_name - ((identifier) @namespace - ".")* - (identifier) @namespace .)) - ]) @wildcard @import - -(import_from_statement - module_name: [ - (dotted_name - ((identifier) @namespace - ".")* - (identifier) @namespace .) - (relative_import - (dotted_name - ((identifier) @namespace - ".")* - (identifier) @namespace .)?) - ] - (wildcard_import)? @wildcard - name: [ - (dotted_name - ((identifier) @namespace - ".")* - (identifier) @name .) - (aliased_import - name: (dotted_name - ((identifier) @namespace - ".")* - (identifier) @name .) - alias: (identifier) @alias) - ]?) @import diff --git a/crates/grammars/src/rust/config.toml b/crates/grammars/src/rust/config.toml index 203a44853f8bd20f952d3db8f0c64dc4babe1017..f739b370f4b5c3fe7bc53f4818ffabedfa1bbd0b 100644 --- a/crates/grammars/src/rust/config.toml +++ b/crates/grammars/src/rust/config.toml @@ -18,5 +18,3 @@ brackets = [ collapsed_placeholder = " /* ... */ " debuggers = ["CodeLLDB", "GDB"] documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 } -ignored_import_segments = ["crate", "super"] -import_path_strip_regex = "/(lib|mod)\\.rs$" diff --git a/crates/grammars/src/rust/imports.scm b/crates/grammars/src/rust/imports.scm deleted file mode 100644 index 2c368523d63b9c6ae9494b1ab801192161fd7000..0000000000000000000000000000000000000000 --- a/crates/grammars/src/rust/imports.scm +++ /dev/null @@ -1,29 +0,0 @@ -(use_declaration) @import - -(scoped_use_list - path: (_) @namespace - list: (_) @list) - -(scoped_identifier - path: (_) @namespace - name: (identifier) @name) - -(use_list - (identifier) @name) - -(use_declaration - (identifier) @name) - -(use_as_clause - path: (scoped_identifier - path: (_) @namespace - name: (_) @name) - alias: (_) @alias) - -(use_as_clause - path: (identifier) @name - alias: (_) @alias) - -(use_wildcard - (_)? @namespace - "*" @wildcard) diff --git a/crates/grammars/src/tsx/imports.scm b/crates/grammars/src/tsx/imports.scm deleted file mode 100644 index 0e688d53fb6ed639c55c1fa84917711d19c3108a..0000000000000000000000000000000000000000 --- a/crates/grammars/src/tsx/imports.scm +++ /dev/null @@ -1,16 +0,0 @@ -(import_statement - import_clause: (import_clause - [ - (identifier) @name - (named_imports - (import_specifier - name: (_) @name - alias: (_)? @alias)) - ]) - source: (string - (string_fragment) @source)) @import - -(import_statement - !import_clause - source: (string - (string_fragment) @source @wildcard)) @import diff --git a/crates/grammars/src/typescript/config.toml b/crates/grammars/src/typescript/config.toml index c0e8a8899a99b0b65e2d073547f3eaf0fe714da2..473a347cdd611d096e5fb3b584c2f0990da185de 100644 --- a/crates/grammars/src/typescript/config.toml +++ b/crates/grammars/src/typescript/config.toml @@ -23,7 +23,6 @@ prettier_parser_name = "typescript" tab_size = 2 debuggers = ["JavaScript"] scope_opt_in_language_servers = ["tailwindcss-language-server"] -import_path_strip_regex = "(?:/index)?\\.[jt]s$" [overrides.string] completion_query_characters = ["-", "."] diff --git a/crates/grammars/src/typescript/imports.scm b/crates/grammars/src/typescript/imports.scm deleted file mode 100644 index de8f8db418157511d5756d6b5ede1a02a03bd831..0000000000000000000000000000000000000000 --- a/crates/grammars/src/typescript/imports.scm +++ /dev/null @@ -1,23 +0,0 @@ -(import_statement - import_clause: (import_clause - [ - (identifier) @name - (named_imports - (import_specifier - name: (_) @name - alias: (_)? @alias)) - (namespace_import) @wildcard - ]) - source: (string - (string_fragment) @source)) @import - -(import_statement - !source - import_clause: (import_require_clause - source: (string - (string_fragment) @source))) @wildcard @import - -(import_statement - !import_clause - source: (string - (string_fragment) @source)) @wildcard @import diff --git a/crates/image_viewer/Cargo.toml b/crates/image_viewer/Cargo.toml index 92386e8ba8a38f79711ee50343a6e7cf4a393cbd..8d9df8c9edd194f43c3cd4c157f6c7fecc494de4 100644 --- a/crates/image_viewer/Cargo.toml +++ b/crates/image_viewer/Cargo.toml @@ -26,7 +26,7 @@ log.workspace = true project.workspace = true serde.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 8d619c82dfdac660a10210e375a8edf9bb97eee9..93729559035437f58f30abae0e5a22a7a514967a 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -19,7 +19,7 @@ use language::File as _; use persistence::ImageViewerDb; use project::{ImageItem, Project, ProjectPath, image_store::ImageItemEvent}; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{Tooltip, prelude::*}; use util::paths::PathExt; use workspace::{ diff --git a/crates/inspector_ui/Cargo.toml b/crates/inspector_ui/Cargo.toml index 53d2f74b9c663496da083152ead17d479f5030eb..ec1f01195c82366a48a1ffa46397c6ce91ea6339 100644 --- a/crates/inspector_ui/Cargo.toml +++ b/crates/inspector_ui/Cargo.toml @@ -21,7 +21,7 @@ language.workspace = true project.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true util_macros.workspace = true diff --git a/crates/inspector_ui/src/inspector.rs b/crates/inspector_ui/src/inspector.rs index 3c90bd7d6c6d550140df85c4c7547bd5b5700149..b687ea70a57d0f1b8ea97e4767d98eb701b77080 100644 --- a/crates/inspector_ui/src/inspector.rs +++ b/crates/inspector_ui/src/inspector.rs @@ -57,7 +57,7 @@ fn render_inspector( window: &mut Window, cx: &mut Context, ) -> AnyElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let colors = cx.theme().colors(); let inspector_id = inspector.active_element_id(); let toolbar_height = platform_title_bar_height(window); diff --git a/crates/keymap_editor/Cargo.toml b/crates/keymap_editor/Cargo.toml index 33ba95ddd6d8df7efe2f551451af0340d83369c7..63bfba05d4e12251a9a267984dabc7420a8c7577 100644 --- a/crates/keymap_editor/Cargo.toml +++ b/crates/keymap_editor/Cargo.toml @@ -36,6 +36,7 @@ settings.workspace = true telemetry.workspace = true tempfile.workspace = true theme.workspace = true +theme_settings.workspace = true tree-sitter-json.workspace = true tree-sitter-rust.workspace = true ui_input.workspace = true diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index 9f10967e72a6dbde8c97b42c465945386709d3ed..bc92763a770ce1f10a30851595a61c530f7482c2 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -3431,7 +3431,7 @@ impl ActionArgumentsEditor { impl Render for ActionArgumentsEditor { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let settings = theme::ThemeSettings::get_global(cx); + let settings = theme_settings::ThemeSettings::get_global(cx); let colors = cx.theme().colors(); let border_color = if self.is_loading { diff --git a/crates/keymap_editor/src/ui_components/keystroke_input.rs b/crates/keymap_editor/src/ui_components/keystroke_input.rs index e1f20de587c274a164a96e3b8d7189a3710ff301..75cc2869c855283302e9e2ce57b9a511f8ba4d37 100644 --- a/crates/keymap_editor/src/ui_components/keystroke_input.rs +++ b/crates/keymap_editor/src/ui_components/keystroke_input.rs @@ -1115,7 +1115,7 @@ mod tests { 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); }); let fs = FakeFs::new(cx.executor()); diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index cec6421c059335c62db9f8db4485eb939d46db01..1392ed63f64b7d3e3f6ebb9f629168f6096c5b61 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -102,6 +102,7 @@ unindent.workspace = true util = { workspace = true, features = ["test-support"] } zlog.workspace = true criterion.workspace = true +theme_settings.workspace = true [[bench]] name = "highlight_map" diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 9839c82e358682fdabcae95fea426d3b2d564969..9308ee6f0a0ee207b30be9e6fafa73ba9452d94c 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -3247,7 +3247,7 @@ fn test_undo_after_merge_into_base(cx: &mut TestAppContext) { async fn test_preview_edits(cx: &mut TestAppContext) { cx.update(|cx| { init_settings(cx, |_| {}); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); let insertion_style = HighlightStyle { diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 469759a24c74b8d1349fbc3a66d5037d8ef8587d..035cb3a2009241cc4ff97a7adf4c82de73166a76 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -39,14 +39,13 @@ pub use language_core::highlight_map::{HighlightId, HighlightMap}; pub use language_core::{ BlockCommentConfig, BracketPair, BracketPairConfig, BracketPairContent, BracketsConfig, BracketsPatternConfig, CodeLabel, CodeLabelBuilder, DebugVariablesConfig, DebuggerTextObject, - DecreaseIndentConfig, Grammar, GrammarId, HighlightsConfig, ImportsConfig, IndentConfig, - InjectionConfig, InjectionPatternConfig, JsxTagAutoCloseConfig, LanguageConfig, - LanguageConfigOverride, LanguageId, LanguageMatcher, OrderedListConfig, OutlineConfig, - Override, OverrideConfig, OverrideEntry, PromptResponseContext, RedactionConfig, - RunnableCapture, RunnableConfig, SoftWrap, Symbol, TaskListConfig, TextObject, - TextObjectConfig, ToLspPosition, WrapCharactersConfig, - auto_indent_using_last_non_empty_line_default, deserialize_regex, deserialize_regex_vec, - regex_json_schema, regex_vec_json_schema, serialize_regex, + DecreaseIndentConfig, Grammar, GrammarId, HighlightsConfig, IndentConfig, InjectionConfig, + InjectionPatternConfig, JsxTagAutoCloseConfig, LanguageConfig, LanguageConfigOverride, + LanguageId, LanguageMatcher, OrderedListConfig, OutlineConfig, Override, OverrideConfig, + OverrideEntry, PromptResponseContext, RedactionConfig, RunnableCapture, RunnableConfig, + SoftWrap, Symbol, TaskListConfig, TextObject, TextObjectConfig, ToLspPosition, + WrapCharactersConfig, auto_indent_using_last_non_empty_line_default, deserialize_regex, + deserialize_regex_vec, regex_json_schema, regex_vec_json_schema, serialize_regex, }; pub use language_registry::{ LanguageName, LanguageServerStatusUpdate, LoadedLanguage, ServerHealth, @@ -908,10 +907,6 @@ impl Language { }) } - pub fn with_imports_query(self, source: &str) -> Result { - self.with_grammar_query_and_name(|grammar, name| grammar.with_imports_query(source, name)) - } - pub fn with_brackets_query(self, source: &str) -> Result { self.with_grammar_query_and_name(|grammar, name| grammar.with_brackets_query(source, name)) } @@ -1579,9 +1574,6 @@ pub fn rust_lang() -> Arc { debugger: Some(Cow::from(include_str!( "../../grammars/src/rust/debugger.scm" ))), - imports: Some(Cow::from(include_str!( - "../../grammars/src/rust/imports.scm" - ))), }) .expect("Could not parse queries"); Arc::new(language) diff --git a/crates/language_core/src/grammar.rs b/crates/language_core/src/grammar.rs index f3a4c3d7c993dfde14657a330999c383ff9f0994..77e3805e52415a20f5d343bff98682744a50fdc2 100644 --- a/crates/language_core/src/grammar.rs +++ b/crates/language_core/src/grammar.rs @@ -41,7 +41,6 @@ pub struct Grammar { pub injection_config: Option, pub override_config: Option, pub debug_variables_config: Option, - pub imports_config: Option, pub highlight_map: Mutex, } @@ -185,17 +184,6 @@ pub struct DebugVariablesConfig { pub objects_by_capture_ix: Vec<(u32, DebuggerTextObject)>, } -pub struct ImportsConfig { - pub query: Query, - pub import_ix: u32, - pub name_ix: Option, - pub namespace_ix: Option, - pub source_ix: Option, - pub list_ix: Option, - pub wildcard_ix: Option, - pub alias_ix: Option, -} - enum Capture<'a> { Required(&'static str, &'a mut u32), Optional(&'static str, &'a mut Option), @@ -273,7 +261,6 @@ impl Grammar { runnable_config: None, error_query: Query::new(&ts_language, "(ERROR) @error").ok(), debug_variables_config: None, - imports_config: None, ts_language, highlight_map: Default::default(), } @@ -300,10 +287,6 @@ impl Grammar { self.debug_variables_config.as_ref() } - pub fn imports_config(&self) -> Option<&ImportsConfig> { - self.imports_config.as_ref() - } - /// Load all queries from `LanguageQueries` into this grammar, mutating the /// associated `LanguageConfig` (the override query clears /// `brackets.disabled_scopes_by_bracket_ix`). @@ -369,11 +352,6 @@ impl Grammar { .with_debug_variables_query(query.as_ref(), name) .context("Error loading debug variables query")?; } - if let Some(query) = queries.imports { - self = self - .with_imports_query(query.as_ref(), name) - .context("Error loading imports query")?; - } Ok(self) } @@ -519,49 +497,6 @@ impl Grammar { Ok(self) } - pub fn with_imports_query( - mut self, - source: &str, - language_name: &LanguageName, - ) -> Result { - let query = Query::new(&self.ts_language, source)?; - - let mut import_ix = 0; - let mut name_ix = None; - let mut namespace_ix = None; - let mut source_ix = None; - let mut list_ix = None; - let mut wildcard_ix = None; - let mut alias_ix = None; - if populate_capture_indices( - &query, - language_name, - "imports", - &[], - &mut [ - Capture::Required("import", &mut import_ix), - Capture::Optional("name", &mut name_ix), - Capture::Optional("namespace", &mut namespace_ix), - Capture::Optional("source", &mut source_ix), - Capture::Optional("list", &mut list_ix), - Capture::Optional("wildcard", &mut wildcard_ix), - Capture::Optional("alias", &mut alias_ix), - ], - ) { - self.imports_config = Some(ImportsConfig { - query, - import_ix, - name_ix, - namespace_ix, - source_ix, - list_ix, - wildcard_ix, - alias_ix, - }); - } - Ok(self) - } - pub fn with_brackets_query( mut self, source: &str, diff --git a/crates/language_core/src/language_config.rs b/crates/language_core/src/language_config.rs index e07c11d811cdaae3b540c57314cebf0d92d0023d..f412af418b7948b40e3bdac5a3a649d12d008e8a 100644 --- a/crates/language_core/src/language_config.rs +++ b/crates/language_core/src/language_config.rs @@ -148,15 +148,6 @@ pub struct LanguageConfig { /// A list of preferred debuggers for this language. #[serde(default)] pub debuggers: IndexSet, - /// A list of import namespace segments that aren't expected to appear in file paths. For - /// example, "super" and "crate" in Rust. - #[serde(default)] - pub ignored_import_segments: HashSet>, - /// Regular expression that matches substrings to omit from import paths, to make the paths more - /// similar to how they are specified when imported. For example, "/mod\.rs$" or "/__init__\.py$". - #[serde(default, deserialize_with = "deserialize_regex")] - #[schemars(schema_with = "regex_json_schema")] - pub import_path_strip_regex: Option, } impl LanguageConfig { @@ -204,8 +195,6 @@ impl Default for LanguageConfig { completion_query_characters: Default::default(), linked_edit_characters: Default::default(), debuggers: Default::default(), - ignored_import_segments: Default::default(), - import_path_strip_regex: None, } } } diff --git a/crates/language_core/src/language_core.rs b/crates/language_core/src/language_core.rs index c908db7ecefd96b59f601ec74adc7a1a9a6425bc..f3292e1978d976ce638ebe26c079b939648ffe52 100644 --- a/crates/language_core/src/language_core.rs +++ b/crates/language_core/src/language_core.rs @@ -9,9 +9,9 @@ pub mod language_config; pub use diagnostic::{Diagnostic, DiagnosticSourceKind}; pub use grammar::{ BracketsConfig, BracketsPatternConfig, DebugVariablesConfig, DebuggerTextObject, Grammar, - GrammarId, HighlightsConfig, ImportsConfig, IndentConfig, InjectionConfig, - InjectionPatternConfig, NEXT_GRAMMAR_ID, OutlineConfig, OverrideConfig, OverrideEntry, - RedactionConfig, RunnableCapture, RunnableConfig, TextObject, TextObjectConfig, + GrammarId, HighlightsConfig, IndentConfig, InjectionConfig, InjectionPatternConfig, + NEXT_GRAMMAR_ID, OutlineConfig, OverrideConfig, OverrideEntry, RedactionConfig, + RunnableCapture, RunnableConfig, TextObject, TextObjectConfig, }; pub use highlight_map::{HighlightId, HighlightMap}; pub use language_config::{ diff --git a/crates/language_core/src/queries.rs b/crates/language_core/src/queries.rs index a0ec6890814e08013badef97bb26ac12d89c02f5..510fb2e03c9b3a6876a2d72180ea238c9a3be4b6 100644 --- a/crates/language_core/src/queries.rs +++ b/crates/language_core/src/queries.rs @@ -13,7 +13,6 @@ pub const QUERY_FILENAME_PREFIXES: &[(&str, QueryFieldAccessor)] = &[ ("runnables", |q| &mut q.runnables), ("debugger", |q| &mut q.debugger), ("textobjects", |q| &mut q.text_objects), - ("imports", |q| &mut q.imports), ]; /// Tree-sitter language queries for a given language. @@ -29,5 +28,4 @@ pub struct LanguageQueries { pub runnables: Option>, pub text_objects: Option>, pub debugger: Option>, - pub imports: Option>, } diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index 88401906fc28bb297fc2798346e110c9651b1387..13899f11c30556db189da48ed1fcb4b5d12b2f20 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -547,15 +547,16 @@ fn build_code_label( text.push_str(code_span); } extension::CodeLabelSpan::Literal(span) => { - let highlight_id = language + if let Some(highlight_id) = language .grammar() .zip(span.highlight_name.as_ref()) .and_then(|(grammar, highlight_name)| { grammar.highlight_id_for_name(highlight_name) }) - .unwrap_or_default(); - let ix = text.len(); - runs.push((ix..ix + span.text.len(), highlight_id)); + { + let ix = text.len(); + runs.push((ix..ix + span.text.len(), highlight_id)); + } text.push_str(&span.text); } } diff --git a/crates/language_model/src/tool_schema.rs b/crates/language_model/src/tool_schema.rs index f9402c28dc316f9ccdacc58afaa0eebd6699f92d..6fbb3761b43ea04924aaa23373920c41a14c74e3 100644 --- a/crates/language_model/src/tool_schema.rs +++ b/crates/language_model/src/tool_schema.rs @@ -17,7 +17,12 @@ pub enum LanguageModelToolSchemaFormat { pub fn root_schema_for(format: LanguageModelToolSchemaFormat) -> Schema { let mut generator = match format { - LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07().into_generator(), + LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07() + .with(|settings| { + settings.meta_schema = None; + settings.inline_subschemas = true; + }) + .into_generator(), LanguageModelToolSchemaFormat::JsonSchemaSubset => SchemaSettings::openapi3() .with(|settings| { settings.meta_schema = None; diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 734e97ee335c4106fced9d334d31b5ed5b86d407..f53f145dbd387aa948b977d854ba77f1cbe49ded 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -344,7 +344,7 @@ impl State { .ok_or(AuthenticateError::CredentialsNotFound)?; let credentials_str = String::from_utf8(credentials_bytes) - .context("invalid {PROVIDER_NAME} credentials")?; + .with_context(|| format!("invalid {PROVIDER_NAME} credentials"))?; let credentials: BedrockCredentials = serde_json::from_str(&credentials_str).context("failed to parse credentials")?; diff --git a/crates/language_tools/Cargo.toml b/crates/language_tools/Cargo.toml index 1698c7294969d3d3a641f0eb4611153efb658c6d..26e230c1d92f674642eab125f62787a3c29a3665 100644 --- a/crates/language_tools/Cargo.toml +++ b/crates/language_tools/Cargo.toml @@ -44,4 +44,5 @@ release_channel.workspace = true gpui = { workspace = true, features = ["test-support"] } semver.workspace = true util = { workspace = true, features = ["test-support"] } -zlog.workspace = true \ No newline at end of file +zlog.workspace = true +theme_settings.workspace = true \ No newline at end of file diff --git a/crates/language_tools/src/lsp_log_view_tests.rs b/crates/language_tools/src/lsp_log_view_tests.rs index 0b4516f5d052260ac4274e9afe14d3bc1a5ef8ee..476f23ffd82c66a581587d8f8fb70c4192ab04e0 100644 --- a/crates/language_tools/src/lsp_log_view_tests.rs +++ b/crates/language_tools/src/lsp_log_view_tests.rs @@ -109,7 +109,7 @@ fn init_test(cx: &mut gpui::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); release_channel::init(semver::Version::new(0, 0, 0), cx); }); } diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 3a84ee7c283007d3f40ef8a557981a9490f07c28..9a0524dffd238b566931a4a612edd91b1e6361c3 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -362,7 +362,7 @@ fn register_language( Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), - queries: load_queries(name), + queries: grammars::load_queries(name), context_provider: context.clone(), toolchain_provider: toolchain.clone(), manifest_name: manifest_name.clone(), @@ -384,7 +384,3 @@ fn load_config(name: &str) -> LanguageConfig { let grammars_loaded = cfg!(any(feature = "load-grammars", test)); grammars::load_config_for_feature(name, grammars_loaded) } - -fn load_queries(name: &str) -> LanguageQueries { - grammars::load_queries(name) -} diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml index 18bba1fc64f193cf17be1a728fc533a6596296b1..be12bf2fe7f42e14c1723a8560a7f3c46ca83182 100644 --- a/crates/markdown/Cargo.toml +++ b/crates/markdown/Cargo.toml @@ -35,6 +35,7 @@ settings.workspace = true stacksafe.workspace = true sum_tree.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/markdown/examples/markdown.rs b/crates/markdown/examples/markdown.rs index aa132443ee69201f0f1eba7b69c9627aee8f27e7..26c45377c725ec4d6701e4cf177615e3de4aba7e 100644 --- a/crates/markdown/examples/markdown.rs +++ b/crates/markdown/examples/markdown.rs @@ -41,7 +41,7 @@ pub fn main() { cx.bind_keys([KeyBinding::new("cmd-c", markdown::Copy, None)]); let node_runtime = NodeRuntime::unavailable(); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); let fs = fs::FakeFs::new(cx.background_executor().clone()); let language_registry = LanguageRegistry::new(cx.background_executor().clone()); diff --git a/crates/markdown/examples/markdown_as_child.rs b/crates/markdown/examples/markdown_as_child.rs index b25b075dd3cb04ed949e1e30ed724c3b5f3088d1..38a4d2795f4ff97297c3d549f8de687827ff75ef 100644 --- a/crates/markdown/examples/markdown_as_child.rs +++ b/crates/markdown/examples/markdown_as_child.rs @@ -28,7 +28,7 @@ pub fn main() { let language_registry = Arc::new(LanguageRegistry::new(cx.background_executor().clone())); let fs = fs::FakeFs::new(cx.background_executor().clone()); languages::init(language_registry, fs, node_runtime, cx); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); Assets.load_fonts(cx).unwrap(); cx.activate(true); diff --git a/crates/markdown/src/html/html_rendering.rs b/crates/markdown/src/html/html_rendering.rs index 6b52a98908ed8757986d7ca7f8778b330f97254f..56ab2db26b682e197c194157a87e646d9e55019d 100644 --- a/crates/markdown/src/html/html_rendering.rs +++ b/crates/markdown/src/html/html_rendering.rs @@ -505,7 +505,7 @@ mod tests { settings::init(cx); } if !cx.has_global::() { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); } }); } diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 7a8c50e0d0662e251173c2d433aee8ba2d5d3af7..7b95688df54610f92b6960d9afc3037bf484b8ed 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -16,7 +16,7 @@ use mermaid::{ }; pub use path_range::{LineCol, PathWithRange}; use settings::Settings as _; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::Checkbox; use ui::CopyButton; @@ -2677,7 +2677,7 @@ mod tests { settings::init(cx); } if !cx.has_global::() { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); } }); } diff --git a/crates/markdown/src/mermaid.rs b/crates/markdown/src/mermaid.rs index 8b39f8c86c7b98c30c8879c362d036a333ad2c63..560a67787ff897c6f792c97fafdd9ed617c020e6 100644 --- a/crates/markdown/src/mermaid.rs +++ b/crates/markdown/src/mermaid.rs @@ -278,7 +278,7 @@ mod tests { settings::init(cx); } if !cx.has_global::() { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); } }); } diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index 558b57b769953b572678c3d997ae771462f51896..19f1270bb91e8a7e9e660a62d8191a9d12b66641 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -22,7 +22,7 @@ language.workspace = true log.workspace = true markdown.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true urlencoding.workspace = true util.workspace = true diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 93ae57520d28e38d6ac843d33ab01581d3b8e890..0b9c63c3b16f5686afcfdafdba119ede8c37fe3f 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -16,7 +16,7 @@ use markdown::{ CodeBlockRenderer, Markdown, MarkdownElement, MarkdownFont, MarkdownOptions, MarkdownStyle, }; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{WithScrollbar, prelude::*}; use util::normalize_path; use workspace::item::{Item, ItemHandle}; diff --git a/crates/miniprofiler_ui/Cargo.toml b/crates/miniprofiler_ui/Cargo.toml index 3f48bdfe486da52fc0edb2a1b540b10375d4f995..a8041b8b37cb57148e6dcdcdb8df7f436e52701b 100644 --- a/crates/miniprofiler_ui/Cargo.toml +++ b/crates/miniprofiler_ui/Cargo.toml @@ -14,7 +14,7 @@ path = "src/miniprofiler_ui.rs" [dependencies] gpui.workspace = true rpc.workspace = true -theme.workspace = true +theme_settings.workspace = true zed_actions.workspace = true workspace.workspace = true util.workspace = true diff --git a/crates/miniprofiler_ui/src/miniprofiler_ui.rs b/crates/miniprofiler_ui/src/miniprofiler_ui.rs index 9ae0a33471d31f32852b4b376bbc71ff0911c60b..351d0a68e2660870923a561ac8989559dc9abd7a 100644 --- a/crates/miniprofiler_ui/src/miniprofiler_ui.rs +++ b/crates/miniprofiler_ui/src/miniprofiler_ui.rs @@ -456,7 +456,7 @@ impl Render for ProfilerWindow { window: &mut gpui::Window, cx: &mut gpui::Context, ) -> impl gpui::IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); if !self.paused { self.poll_timings(cx); window.request_animation_frame(); diff --git a/crates/onboarding/Cargo.toml b/crates/onboarding/Cargo.toml index e5e5b5cac93aa4021f8933bd38f8711d53b89902..545a4b614160054186d4acf7bce17e36ac1cd4f1 100644 --- a/crates/onboarding/Cargo.toml +++ b/crates/onboarding/Cargo.toml @@ -32,6 +32,7 @@ serde.workspace = true settings.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true vim_mode_setting.workspace = true diff --git a/crates/onboarding/src/basics_page.rs b/crates/onboarding/src/basics_page.rs index 7221d8104cbff2e1e0a8ebe265b419b1c725472d..b2e595b28a33ed4ee7f066c4d969baffdb2a081b 100644 --- a/crates/onboarding/src/basics_page.rs +++ b/crates/onboarding/src/basics_page.rs @@ -5,10 +5,8 @@ use fs::Fs; use gpui::{Action, App, IntoElement}; use project::project_settings::ProjectSettings; use settings::{BaseKeymap, Settings, update_settings_file}; -use theme::{ - Appearance, SystemAppearance, ThemeAppearanceMode, ThemeName, ThemeRegistry, ThemeSelection, - ThemeSettings, -}; +use theme::{Appearance, SystemAppearance, ThemeRegistry}; +use theme_settings::{ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings}; use ui::{ Divider, StatefulInteractiveElement, SwitchField, TintColor, ToggleButtonGroup, ToggleButtonGroupSize, ToggleButtonSimple, ToggleButtonWithIcon, Tooltip, prelude::*, @@ -197,7 +195,7 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement fn write_mode_change(mode: ThemeAppearanceMode, cx: &mut App) { let fs = ::global(cx); update_settings_file(fs, cx, move |settings, _cx| { - theme::set_mode(settings, mode); + theme_settings::set_mode(settings, mode); }); } @@ -219,13 +217,13 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement dark: ThemeName(dark_theme.into()), }); } - ThemeAppearanceMode::Light => theme::set_theme( + ThemeAppearanceMode::Light => theme_settings::set_theme( settings, theme, Appearance::Light, *SystemAppearance::global(cx), ), - ThemeAppearanceMode::Dark => theme::set_theme( + ThemeAppearanceMode::Dark => theme_settings::set_theme( settings, theme, Appearance::Dark, diff --git a/crates/open_path_prompt/Cargo.toml b/crates/open_path_prompt/Cargo.toml index 3418712abf9656cacd670882c3002cf50b3737d7..e635797cfbe042c327066494a36c3552f6736be1 100644 --- a/crates/open_path_prompt/Cargo.toml +++ b/crates/open_path_prompt/Cargo.toml @@ -24,6 +24,7 @@ editor = {workspace = true, features = ["test-support"]} gpui = {workspace = true, features = ["test-support"]} serde_json.workspace = true theme = {workspace = true, features = ["test-support"]} +theme_settings.workspace = true workspace = {workspace = true, features = ["test-support"]} [lints] diff --git a/crates/open_path_prompt/src/open_path_prompt_tests.rs b/crates/open_path_prompt/src/open_path_prompt_tests.rs index eba3a3e03be55c210f7b4ebd4fad5abc3842e74b..3acd74bdc456b8616229d30ea1da343073685e30 100644 --- a/crates/open_path_prompt/src/open_path_prompt_tests.rs +++ b/crates/open_path_prompt/src/open_path_prompt_tests.rs @@ -410,7 +410,7 @@ async fn test_open_path_prompt_with_show_hidden(cx: &mut TestAppContext) { fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); state diff --git a/crates/outline/Cargo.toml b/crates/outline/Cargo.toml index 79559e03e8b2339fd8b4473d9e06ca6ff47b2b8c..2ce031bd4605e6c5dfc32e7f76be7493924af825 100644 --- a/crates/outline/Cargo.toml +++ b/crates/outline/Cargo.toml @@ -22,6 +22,7 @@ picker.workspace = true settings.workspace = true smol.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 4fb30cec9898534c8c72a83eb7634588ab78f73f..a03c87d9f68e41dd29d9d614f714db47083831ef 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -14,7 +14,8 @@ use language::{Outline, OutlineItem}; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; use settings::Settings; -use theme::{ActiveTheme, ThemeSettings}; +use theme::ActiveTheme; +use theme_settings::ThemeSettings; use ui::{ListItem, ListItemSpacing, prelude::*}; use util::ResultExt; use workspace::{DismissDecision, ModalView}; diff --git a/crates/outline_panel/Cargo.toml b/crates/outline_panel/Cargo.toml index fbcbd7ba74f42fc86976bb090102b86802cd4a1b..e88a0262907fcb22d1b954f25a5e74d8307bd174 100644 --- a/crates/outline_panel/Cargo.toml +++ b/crates/outline_panel/Cargo.toml @@ -33,6 +33,7 @@ settings.workspace = true smallvec.workspace = true smol.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index d4f8fe5b6dd488c1c1af522a0ed0c8b0b0a435fc..4dc6088451ec9e98c0cf823d85316951151cf126 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -47,7 +47,8 @@ use search::{BufferSearchBar, ProjectSearchView}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; use smol::channel; -use theme::{SyntaxTheme, ThemeSettings}; +use theme::SyntaxTheme; +use theme_settings::ThemeSettings; use ui::{ ContextMenu, FluentBuilder, HighlightedLabel, IconButton, IconButtonShape, IndentGuideColors, IndentGuideLayout, ListItem, ScrollAxes, Scrollbars, Tab, Tooltip, WithScrollbar, prelude::*, @@ -6899,7 +6900,7 @@ outline: struct OutlineEntryExcerpt let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); project_search::init(cx); diff --git a/crates/picker/Cargo.toml b/crates/picker/Cargo.toml index 8c76aa746453866755be322df576a519ba147b24..7c01e8bfaa13447eccb42f42f69a09b332193676 100644 --- a/crates/picker/Cargo.toml +++ b/crates/picker/Cargo.toml @@ -22,6 +22,7 @@ menu.workspace = true schemars.workspace = true serde.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true ui_input.workspace = true workspace.workspace = true diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 2eb8d71bd4aa14960f8388859c974214f3e85c72..1e529cd53f2d2527af8525886d11dbcddbf33a34 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -16,7 +16,7 @@ use serde::Deserialize; use std::{ cell::Cell, cell::RefCell, collections::HashMap, ops::Range, rc::Rc, sync::Arc, time::Duration, }; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Color, Divider, DocumentationAside, DocumentationSide, Label, ListItem, ListItemSpacing, ScrollAxes, Scrollbars, WithScrollbar, prelude::*, utils::WithRemSize, v_flex, @@ -955,7 +955,7 @@ mod tests { cx.update(|cx| { let store = settings::SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); }); } diff --git a/crates/platform_title_bar/Cargo.toml b/crates/platform_title_bar/Cargo.toml index 43ad6166929bc463edbea878941ba19ffe2ea3a9..7ecc624a3224025749b65d631031e3e8bf639052 100644 --- a/crates/platform_title_bar/Cargo.toml +++ b/crates/platform_title_bar/Cargo.toml @@ -19,6 +19,7 @@ project.workspace = true settings.workspace = true smallvec.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true workspace.workspace = true zed_actions.workspace = true diff --git a/crates/platform_title_bar/src/system_window_tabs.rs b/crates/platform_title_bar/src/system_window_tabs.rs index a9bf46cc4f9f33586d1129dec1c64a67f1e42198..f465d2ab8476eb1c834f32e1d0eb72cc468dc230 100644 --- a/crates/platform_title_bar/src/system_window_tabs.rs +++ b/crates/platform_title_bar/src/system_window_tabs.rs @@ -5,7 +5,7 @@ use gpui::{ Styled, SystemWindowTab, SystemWindowTabController, Window, WindowId, actions, canvas, div, }; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Color, ContextMenu, DynamicSpacing, IconButton, IconButtonShape, IconName, IconSize, Label, LabelSize, Tab, h_flex, prelude::*, right_click_menu, diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 346ebb1614e3b536d78765ce7ca90ad1e30f6bfc..f439c5da157cdcdaec813a1fd63ea119af78cb83 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -34,7 +34,7 @@ use git::{ repository::{ Branch, CommitDetails, CommitDiff, CommitFile, CommitOptions, DiffType, FetchOptions, GitRepository, GitRepositoryCheckpoint, GraphCommitData, InitialGraphCommitData, LogOrder, - LogSource, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, + LogSource, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus, Worktree as GitWorktree, }, stash::{GitStash, StashEntry}, @@ -4570,6 +4570,32 @@ impl Repository { self.initial_graph_data.get(&(log_source, log_order)) } + pub fn search_commits( + &mut self, + log_source: LogSource, + search_args: SearchCommitArgs, + request_tx: smol::channel::Sender, + cx: &mut Context, + ) { + let repository_state = self.repository_state.clone(); + + cx.background_spawn(async move { + let repo_state = repository_state.await; + + match repo_state { + Ok(RepositoryState::Local(LocalRepositoryState { backend, .. })) => { + backend + .search_commits(log_source, search_args, request_tx) + .await + .log_err(); + } + Ok(RepositoryState::Remote(_)) => {} + Err(_) => {} + }; + }) + .detach(); + } + pub fn graph_data( &mut self, log_source: LogSource, diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 59baaa156e64c744d8906d294f7f3978280a1839..d4a4f9b04968413c51607f71047752a9b779b79a 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -3215,8 +3215,9 @@ impl InlayHints { Some(((uri, range), server_id)) => Some(( LanguageServerId(server_id as usize), lsp::Location { - uri: lsp::Uri::from_str(&uri) - .context("invalid uri in hint part {part:?}")?, + uri: lsp::Uri::from_str(&uri).with_context(|| { + format!("invalid uri in hint part {uri:?}") + })?, range: lsp::Range::new( point_to_lsp(PointUtf16::new( range.start.row, diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index add85d91c26866edfae1b5790c279ee609edf477..2192b8daf3a301d580a3cef73426f6348508a566 100644 --- a/crates/project_panel/Cargo.toml +++ b/crates/project_panel/Cargo.toml @@ -36,6 +36,7 @@ serde_json.workspace = true settings.workspace = true smallvec.workspace = true theme.workspace = true +theme_settings.workspace = true rayon.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index ac348d9d5bab659c115f7b6c9f1a11c4d7c951bc..aa8c86091d526165f3406016ee49d86a30596c37 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -58,7 +58,7 @@ use std::{ sync::Arc, time::Duration, }; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Color, ContextMenu, ContextMenuEntry, DecoratedIcon, Divider, Icon, IconDecoration, IconDecorationKind, IndentGuideColors, IndentGuideLayout, Indicator, KeyBinding, Label, diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index afcc6db8d1600ed7df438d2e3e5546ba13fe4dd0..55b53cde8b6252f8b9732cf4effc35ea53c073e0 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -10428,7 +10428,7 @@ pub(crate) fn init_test(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); crate::init(cx); cx.update_global::(|store, cx| { @@ -10446,7 +10446,7 @@ pub(crate) fn init_test(cx: &mut TestAppContext) { fn init_test_with_editor(cx: &mut TestAppContext) { cx.update(|cx| { let app_state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); crate::init(cx); workspace::init(app_state, cx); diff --git a/crates/project_symbols/Cargo.toml b/crates/project_symbols/Cargo.toml index 83e3cb587d46a5bddf1c8b30c593c18a9b131ad2..da23116e83b465a3ad1aace883d2abb15ad9aa9b 100644 --- a/crates/project_symbols/Cargo.toml +++ b/crates/project_symbols/Cargo.toml @@ -23,6 +23,7 @@ project.workspace = true serde_json.workspace = true settings.workspace = true theme.workspace = true +theme_settings.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index d62935ab3819d2e6857c233a863af434f60f93a3..84b92f3eaa4f0216b881526b3aac42f8980ffe78 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -9,7 +9,8 @@ use picker::{Picker, PickerDelegate}; use project::{Project, Symbol, lsp_store::SymbolLocation}; use settings::Settings; use std::{cmp::Reverse, sync::Arc}; -use theme::{ActiveTheme, ThemeSettings}; +use theme::ActiveTheme; +use theme_settings::ThemeSettings; use util::ResultExt; use workspace::{ Workspace, @@ -477,7 +478,7 @@ mod tests { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); editor::init(cx); }); diff --git a/crates/prompt_store/src/prompts.rs b/crates/prompt_store/src/prompts.rs index 6a845bb8dd394f8a1ff26a8a0e130156a2a158bd..b0052947c44445be37f99e99cf723d5aa53c5008 100644 --- a/crates/prompt_store/src/prompts.rs +++ b/crates/prompt_store/src/prompts.rs @@ -26,9 +26,9 @@ pub const RULES_FILE_NAMES: &[&str] = &[ ".windsurfrules", ".clinerules", ".github/copilot-instructions.md", - "CLAUDE.md", "AGENT.md", "AGENTS.md", + "CLAUDE.md", "GEMINI.md", ]; diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 4569492d4c73b6e8087cf8363db805a645e5314e..2d285cbd36396b8cc30d456c7b37f4b5f187aeb3 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -1852,7 +1852,6 @@ impl RemoteServerProjects { cx: &mut Context, ) { let replace_window = window.window_handle().downcast::(); - let app_state = Arc::downgrade(&app_state); cx.spawn_in(window, async move |entity, cx| { let (connection, starting_dir) = diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index bef88557b12aa076658799ff0c08518c68b6e729..72006cf6b769d23e4d2e4d535d33b61c605bad8c 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -403,8 +403,8 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { Some( v_flex() - .flex_1() .p_1p5() + .flex_1() .gap_1() .border_t_1() .border_color(cx.theme().colors().border_variant) @@ -414,9 +414,10 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { }; Button::new("open_local_folder", "Add Local Project") .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx)) - .on_click(move |_, window, cx| { + .on_click(cx.listener(move |_, _, window, cx| { + cx.emit(DismissEvent); window.dispatch_action(open_action.boxed_clone(), cx) - }) + })) }) .into_any(), ) diff --git a/crates/recent_projects/src/wsl_picker.rs b/crates/recent_projects/src/wsl_picker.rs index 7f2a69eb68cb93742d98f438f75f74c95bf3f7d5..d366f1090dac91ac0e778c578ceb864dac80cf86 100644 --- a/crates/recent_projects/src/wsl_picker.rs +++ b/crates/recent_projects/src/wsl_picker.rs @@ -235,9 +235,6 @@ impl WslOpenModal { cx: &mut Context, ) { let app_state = workspace::AppState::global(cx); - let Some(app_state) = app_state.upgrade() else { - return; - }; let connection_options = RemoteConnectionOptions::Wsl(WslConnectionOptions { distro_name: distro.to_string(), diff --git a/crates/remote_connection/Cargo.toml b/crates/remote_connection/Cargo.toml index 53e20eb5eb0708252a90819d37b38e214aa95d67..d3b37f6985bb0b47a1a1902fc5a856c2df974a60 100644 --- a/crates/remote_connection/Cargo.toml +++ b/crates/remote_connection/Cargo.toml @@ -28,7 +28,7 @@ release_channel.workspace = true remote.workspace = true semver.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true ui_input.workspace = true workspace.workspace = true \ No newline at end of file diff --git a/crates/remote_connection/src/remote_connection.rs b/crates/remote_connection/src/remote_connection.rs index e0bb70165e9adc6e2c8c81e933cf88b2273da79f..df6260d1c5b3cd1704bfe0ce6a8476bbc0f39670 100644 --- a/crates/remote_connection/src/remote_connection.rs +++ b/crates/remote_connection/src/remote_connection.rs @@ -13,7 +13,7 @@ use release_channel::ReleaseChannel; use remote::{ConnectionIdentifier, RemoteClient, RemoteConnectionOptions, RemotePlatform}; use semver::Version; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ActiveTheme, CommonAnimationExt, Context, InteractiveElement, KeyBinding, ListItem, Tooltip, prelude::*, diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index 36944261cded68b564df8093d5b7a7621a644c11..c6ce45ba1ce28386d0776eb40299919f92aa8e53 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -98,6 +98,7 @@ node_runtime = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } remote = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true theme = { workspace = true, features = ["test-support"] } language_model = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 01eb8126989668d5d6e8ea44f0313663e9d8cb4c..86b7f93eb2c737cac55dbf2882f91ec277e4e174 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -1661,7 +1661,7 @@ async fn test_remote_git_diffs_when_recv_update_repository_delay( 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); release_channel::init(semver::Version::new(0, 0, 0), cx); editor::init(cx); }); diff --git a/crates/repl/Cargo.toml b/crates/repl/Cargo.toml index 4329b29ada504cf536337c94b14790acea73ea11..5477c1c5107e7450ad2eaeaba6a880256b62f30f 100644 --- a/crates/repl/Cargo.toml +++ b/crates/repl/Cargo.toml @@ -53,6 +53,7 @@ telemetry.workspace = true terminal.workspace = true terminal_view.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true uuid.workspace = true diff --git a/crates/repl/src/notebook/cell.rs b/crates/repl/src/notebook/cell.rs index 200424742aff113d637fe9aca30999c0f95e79a5..ba70e50f8cbccc32bef5de5c1864a3d8db46aa89 100644 --- a/crates/repl/src/notebook/cell.rs +++ b/crates/repl/src/notebook/cell.rs @@ -12,7 +12,7 @@ use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use nbformat::v4::{CellId, CellMetadata, CellType}; use runtimelib::{JupyterMessage, JupyterMessageContent}; use settings::Settings as _; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{CommonAnimationExt, IconButtonShape, prelude::*}; use util::ResultExt; diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index f6d2bc4d3173ce64700b7b5ac45301df0fe0ab53..ad0bd56858636bf8fbd2501bab28aae25b99c2a0 100644 --- a/crates/repl/src/outputs.rs +++ b/crates/repl/src/outputs.rs @@ -895,7 +895,7 @@ mod tests { 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); }); let fs = project::FakeFs::new(cx.background_executor.clone()); let project = project::Project::test(fs, [] as [&Path; 0], cx).await; diff --git a/crates/repl/src/outputs/plain.rs b/crates/repl/src/outputs/plain.rs index 71e2624f8ad7b0172a86793d5d81b38339b04f36..bc6d04019ce0129529a886e827c3f2ec8e6574ce 100644 --- a/crates/repl/src/outputs/plain.rs +++ b/crates/repl/src/outputs/plain.rs @@ -27,7 +27,7 @@ use language::Buffer; use settings::Settings as _; use terminal::terminal_settings::TerminalSettings; use terminal_view::terminal_element::TerminalElement; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{IntoElement, prelude::*}; use crate::outputs::OutputContent; @@ -275,7 +275,7 @@ mod tests { 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); }); cx.add_empty_window() } diff --git a/crates/repl/src/outputs/table.rs b/crates/repl/src/outputs/table.rs index f6bf30f394d2232750f7f1beb21dbbc27c0ba941..fc5ccaf75a5b25ba9b32db68e47a96d876f68cf7 100644 --- a/crates/repl/src/outputs/table.rs +++ b/crates/repl/src/outputs/table.rs @@ -59,7 +59,7 @@ use runtimelib::datatable::TableSchema; use runtimelib::media::datatable::TabularDataResource; use serde_json::Value; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{IntoElement, Styled, div, prelude::*, v_flex}; use util::markdown::MarkdownEscaped; diff --git a/crates/rules_library/Cargo.toml b/crates/rules_library/Cargo.toml index 59c298de923f98135c99fca0c8da2fa42ac2e17e..352f86bd72fca294745cc0f74b401cc48f35d7fd 100644 --- a/crates/rules_library/Cargo.toml +++ b/crates/rules_library/Cargo.toml @@ -28,7 +28,7 @@ release_channel.workspace = true rope.workspace = true serde.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true ui_input.workspace = true util.workspace = true diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index b4ff8033446410d063cddccfa6b76eaa77ecfac9..1c8e90794674dfc737480981954f91312add1ee5 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -20,7 +20,7 @@ use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Duration; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*}; use ui_input::ErasedEditor; use util::{ResultExt, TryFutureExt}; @@ -1392,7 +1392,7 @@ impl RulesLibrary { impl Render for RulesLibrary { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let theme = cx.theme().clone(); client_side_decorations( diff --git a/crates/schema_generator/Cargo.toml b/crates/schema_generator/Cargo.toml index b92298a3b41d62b861c19a1f22ceaee0d63828b5..71beb54597e72286cbf539897741088dde873e6c 100644 --- a/crates/schema_generator/Cargo.toml +++ b/crates/schema_generator/Cargo.toml @@ -17,3 +17,4 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true theme.workspace = true +theme_settings.workspace = true \ No newline at end of file diff --git a/crates/schema_generator/src/main.rs b/crates/schema_generator/src/main.rs index a77060c54d1361dc96204238a282f8e75946a37b..d34cd897b9e7eb27b6c9343513d85ed8497d291a 100644 --- a/crates/schema_generator/src/main.rs +++ b/crates/schema_generator/src/main.rs @@ -2,7 +2,8 @@ use anyhow::Result; use clap::{Parser, ValueEnum}; use schemars::schema_for; use settings::ProjectSettingsContent; -use theme::{IconThemeFamilyContent, ThemeFamilyContent}; +use theme::IconThemeFamilyContent; +use theme_settings::ThemeFamilyContent; #[derive(Parser, Debug)] pub struct Args { diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 9ea013af6c315ff11508b195e9d79493d05fee6b..4213aa39a046e944cd34f9a1530bd15d1c442863 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -38,6 +38,7 @@ serde_json.workspace = true settings.workspace = true smol.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true util_macros.workspace = true diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 5381e47db092fb65ca3cdb844987c6714ca4cd76..8a8537337db66cd5ab1d53404639a5103cccb7f2 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -1906,7 +1906,7 @@ mod tests { cx.set_global(store); editor::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); crate::init(cx); }); } diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 97c6cbad52e00d991dca3cb41d118815d335e5ae..991f8d1076a985e1413b0045aa42d424f094cd9c 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -936,6 +936,7 @@ impl ProjectSearchView { let query_editor = cx.new(|cx| { let mut editor = Editor::auto_height(1, 4, window, cx); editor.set_placeholder_text("Search all files…", window, cx); + editor.set_use_autoclose(false); editor.set_text(query_text, window, cx); editor }); @@ -5143,7 +5144,7 @@ pub mod tests { let settings = SettingsStore::test(cx); cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); crate::init(cx); diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index d2104492bebf529821f8ad8571fd3fbb8bdbc69e..8edcdd600bd352d4e33c0c8c1ec9aed3f427c71c 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -85,7 +85,7 @@ pub enum SearchOption { Backwards, } -pub(crate) enum SearchSource<'a, 'b> { +pub enum SearchSource<'a, 'b> { Buffer, Project(&'a Context<'b, ProjectSearchBar>), } @@ -126,7 +126,7 @@ impl SearchOption { } } - pub(crate) fn as_button( + pub fn as_button( &self, active: SearchOptions, search_source: SearchSource, diff --git a/crates/search/src/search_bar.rs b/crates/search/src/search_bar.rs index 436f70d6545a7eaaee23564058fb600fe387b739..a4757631a188752aed7cc631d987a22cd57b06c6 100644 --- a/crates/search/src/search_bar.rs +++ b/crates/search/src/search_bar.rs @@ -1,7 +1,7 @@ use editor::{Editor, EditorElement, EditorStyle, MultiBufferOffset, ToOffset}; use gpui::{Action, App, Entity, FocusHandle, Hsla, IntoElement, TextStyle}; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{IconButton, IconButtonShape}; use ui::{Tooltip, prelude::*}; diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml index 27e8182d37ba1c67700d3a41dbdfc1c4ce27e4d6..a0d75e5b76fd4a0066ff606585088f61a23d19a1 100644 --- a/crates/settings/Cargo.toml +++ b/crates/settings/Cargo.toml @@ -27,7 +27,7 @@ log.workspace = true migrator.workspace = true paths.workspace = true release_channel.workspace = true -rust-embed = { workspace = true, features = ["debug-embed"] } +rust-embed.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 414fef665a8e6841bc43242bd2f0a05147eaea1d..2d52fee639f50b26ec115a69660a90492e7e85ef 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -769,6 +769,7 @@ impl VsCodeSettings { fn status_bar_settings_content(&self) -> Option { skip_default(StatusBarSettingsContent { show: self.read_bool("workbench.statusBar.visible"), + show_active_file: None, active_language_button: None, cursor_position_button: None, line_endings_button: None, diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index ea505cd2dcbd20bf5520169b808bb6848119a95a..861b6fee454edc4d18b8248b42315287a33c572c 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -1133,15 +1133,15 @@ pub struct WhichKeySettingsContent { pub delay_ms: Option, } +// An ExtendingVec in the settings can only accumulate new values. +// +// This is useful for things like private files where you only want +// to allow new values to be added. +// +// Consider using a HashMap instead of this type +// (like auto_install_extensions) so that user settings files can both add +// and remove values from the set. #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -/// An ExtendingVec in the settings can only accumulate new values. -/// -/// This is useful for things like private files where you only want -/// to allow new values to be added. -/// -/// Consider using a HashMap instead of this type -/// (like auto_install_extensions) so that user settings files can both add -/// and remove values from the set. pub struct ExtendingVec(pub Vec); impl Into> for ExtendingVec { @@ -1161,10 +1161,10 @@ impl merge_from::MergeFrom for ExtendingVec { } } -/// A SaturatingBool in the settings can only ever be set to true, -/// later attempts to set it to false will be ignored. -/// -/// Used by `disable_ai`. +// A SaturatingBool in the settings can only ever be set to true, +// later attempts to set it to false will be ignored. +// +// Used by `disable_ai`. #[derive(Debug, Default, Copy, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct SaturatingBool(pub bool); diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index 7134f7db6e058bbbdd53e72196ae6727c628d339..ef00a44790fd10b8c56278362a2f552a40f52cbb 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -434,6 +434,10 @@ pub struct StatusBarSettingsContent { /// Default: true #[serde(rename = "experimental.show")] pub show: Option, + /// Whether to show the name of the active file in the status bar. + /// + /// Default: false + pub show_active_file: Option, /// Whether to display the active language button in the status bar. /// /// Default: true diff --git a/crates/settings_profile_selector/Cargo.toml b/crates/settings_profile_selector/Cargo.toml index 9fcce14b0434386068a9c94f47c9ed675210abbb..2e4608672847b608e2f6b0c48c5122bf76f3b5e7 100644 --- a/crates/settings_profile_selector/Cargo.toml +++ b/crates/settings_profile_selector/Cargo.toml @@ -29,4 +29,5 @@ project = { workspace = true, features = ["test-support"] } serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } theme = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/settings_profile_selector/src/settings_profile_selector.rs b/crates/settings_profile_selector/src/settings_profile_selector.rs index 7ca91e3767efb6b550af7887e70a0187fed6daad..a948b603e04c43a6740853b7c37aebb2ba8d7ee9 100644 --- a/crates/settings_profile_selector/src/settings_profile_selector.rs +++ b/crates/settings_profile_selector/src/settings_profile_selector.rs @@ -286,7 +286,7 @@ mod tests { use project::{FakeFs, Project}; use serde_json::json; use settings::Settings; - use theme::{self, ThemeSettings}; + use theme_settings::ThemeSettings; use workspace::{self, AppState, MultiWorkspace}; use zed_actions::settings_profile_selector; @@ -299,7 +299,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); settings::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); super::init(cx); editor::init(cx); state diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 7632c2857a41ba43fe7d2b2d517752f53b8f694d..9d79481596f4b4259760ff6c2f19f8f5cf709d1e 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -54,6 +54,7 @@ shell_command_parser.workspace = true strum.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/settings_ui/src/components/input_field.rs b/crates/settings_ui/src/components/input_field.rs index e0acfe486d31db373a5de43aa64e1b6e28ce78cf..35e63078c154dd324c8dd622b8d98c2de36beb68 100644 --- a/crates/settings_ui/src/components/input_field.rs +++ b/crates/settings_ui/src/components/input_field.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use editor::Editor; use gpui::{AnyElement, ElementId, Focusable, TextStyleRefinement}; use settings::Settings as _; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{Tooltip, prelude::*, rems}; #[derive(IntoElement)] diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 5fa1679532aa9ad82801e78a929a8bfd59509818..37e3e78baceccde480801c84cfe6462c8356c5ed 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -411,9 +411,9 @@ fn appearance_page() -> SettingsPage { settings::ThemeSelection::Static(_) => return, settings::ThemeSelection::Dynamic { mode, light, dark } => { match mode { - theme::ThemeAppearanceMode::Light => light.clone(), - theme::ThemeAppearanceMode::Dark => dark.clone(), - theme::ThemeAppearanceMode::System => dark.clone(), // no cx, can't determine correct choice + theme_settings::ThemeAppearanceMode::Light => light.clone(), + theme_settings::ThemeAppearanceMode::Dark => dark.clone(), + theme_settings::ThemeAppearanceMode::System => dark.clone(), // no cx, can't determine correct choice } }, }; @@ -581,9 +581,9 @@ fn appearance_page() -> SettingsPage { settings::IconThemeSelection::Static(_) => return, settings::IconThemeSelection::Dynamic { mode, light, dark } => { match mode { - theme::ThemeAppearanceMode::Light => light.clone(), - theme::ThemeAppearanceMode::Dark => dark.clone(), - theme::ThemeAppearanceMode::System => dark.clone(), // no cx, can't determine correct choice + theme_settings::ThemeAppearanceMode::Light => light.clone(), + theme_settings::ThemeAppearanceMode::Dark => dark.clone(), + theme_settings::ThemeAppearanceMode::System => dark.clone(), // no cx, can't determine correct choice } }, }; @@ -802,7 +802,8 @@ fn appearance_page() -> SettingsPage { } settings::BufferLineHeightDiscriminants::Custom => { let custom_value = - theme::BufferLineHeight::from(*settings_value).value(); + theme_settings::BufferLineHeight::from(*settings_value) + .value(); settings::BufferLineHeight::Custom(custom_value) } }; @@ -3327,7 +3328,7 @@ fn search_and_files_page() -> SettingsPage { } fn window_and_layout_page() -> SettingsPage { - fn status_bar_section() -> [SettingsPageItem; 9] { + fn status_bar_section() -> [SettingsPageItem; 10] { [ SettingsPageItem::SectionHeader("Status Bar"), SettingsPageItem::SettingItem(SettingItem { @@ -3472,6 +3473,28 @@ fn window_and_layout_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Active File Name", + description: "Show the name of the active file in the status bar.", + field: Box::new(SettingField { + json_path: Some("status_bar.show_active_file"), + pick: |settings_content| { + settings_content + .status_bar + .as_ref()? + .show_active_file + .as_ref() + }, + write: |settings_content, value| { + settings_content + .status_bar + .get_or_insert_default() + .show_active_file = value; + }, + }), + metadata: None, + files: USER, + }), ] } diff --git a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs index 0357f2040b0125d39d34fd36b1aca3d299a8501b..193be67aad4760763637f116fad23066438b5b61 100644 --- a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs +++ b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs @@ -710,12 +710,9 @@ fn render_github_copilot_provider(window: &mut Window, cx: &mut App) -> Option Vec { - workspace::AppState::global(cx) - .upgrade() - .map_or(vec![], |state| { - state - .languages - .language_names() - .into_iter() - .filter(|name| name.as_ref() != "Zed Keybind Context") - .map(Into::into) - .collect() - }) + let state = workspace::AppState::global(cx); + state + .languages + .language_names() + .into_iter() + .filter(|name| name.as_ref() != "Zed Keybind Context") + .map(Into::into) + .collect() } #[allow(unused)] @@ -1533,29 +1532,26 @@ impl SettingsWindow { }) .detach(); - if let Some(app_state) = AppState::global(cx).upgrade() { - let workspaces: Vec> = app_state - .workspace_store - .read(cx) - .workspaces() - .filter_map(|weak| weak.upgrade()) - .collect(); + let app_state = AppState::global(cx); + let workspaces: Vec> = app_state + .workspace_store + .read(cx) + .workspaces() + .filter_map(|weak| weak.upgrade()) + .collect(); - for workspace in workspaces { - let project = workspace.read(cx).project().clone(); - cx.observe_release_in(&project, window, |this, _, window, cx| { - this.fetch_files(window, cx) - }) - .detach(); - cx.subscribe_in(&project, window, Self::handle_project_event) - .detach(); - cx.observe_release_in(&workspace, window, |this, _, window, cx| { - this.fetch_files(window, cx) - }) + for workspace in workspaces { + let project = workspace.read(cx).project().clone(); + cx.observe_release_in(&project, window, |this, _, window, cx| { + this.fetch_files(window, cx) + }) + .detach(); + cx.subscribe_in(&project, window, Self::handle_project_event) .detach(); - } - } else { - log::error!("App state doesn't exist when creating a new settings window"); + cx.observe_release_in(&workspace, window, |this, _, window, cx| { + this.fetch_files(window, cx) + }) + .detach(); } let this_weak = cx.weak_entity(); @@ -3360,9 +3356,7 @@ impl SettingsWindow { } SettingsUiFile::Project((worktree_id, path)) => { let settings_path = path.join(paths::local_settings_file_relative_path()); - let Some(app_state) = workspace::AppState::global(cx).upgrade() else { - return; - }; + let app_state = workspace::AppState::global(cx); let Some((workspace_window, worktree, corresponding_workspace)) = app_state .workspace_store @@ -3650,7 +3644,7 @@ impl SettingsWindow { impl Render for SettingsWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); client_side_decorations( v_flex() @@ -3743,31 +3737,26 @@ fn all_projects( cx: &App, ) -> impl Iterator> { let mut seen_project_ids = std::collections::HashSet::new(); - workspace::AppState::global(cx) - .upgrade() - .map(|app_state| { - app_state - .workspace_store - .read(cx) - .workspaces() - .filter_map(|weak| weak.upgrade()) - .map(|workspace: Entity| workspace.read(cx).project().clone()) - .chain( - window - .and_then(|handle| handle.read(cx).ok()) - .into_iter() - .flat_map(|multi_workspace| { - multi_workspace - .workspaces() - .iter() - .map(|workspace| workspace.read(cx).project().clone()) - .collect::>() - }), - ) - .filter(move |project| seen_project_ids.insert(project.entity_id())) - }) - .into_iter() - .flatten() + let app_state = workspace::AppState::global(cx); + app_state + .workspace_store + .read(cx) + .workspaces() + .filter_map(|weak| weak.upgrade()) + .map(|workspace: Entity| workspace.read(cx).project().clone()) + .chain( + window + .and_then(|handle| handle.read(cx).ok()) + .into_iter() + .flat_map(|multi_workspace| { + multi_workspace + .workspaces() + .iter() + .map(|workspace| workspace.read(cx).project().clone()) + .collect::>() + }), + ) + .filter(move |project| seen_project_ids.insert(project.entity_id())) } fn open_user_settings_in_workspace( @@ -4410,7 +4399,7 @@ pub mod test { pub fn register_settings(cx: &mut App) { settings::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); menu::init(); } @@ -4721,7 +4710,7 @@ pub mod test { let app_state = cx.update(|cx| { let app_state = AppState::test(cx); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); app_state }); @@ -4895,7 +4884,7 @@ pub mod test { let app_state = cx.update(|cx| { let app_state = AppState::test(cx); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); app_state }); @@ -5075,7 +5064,7 @@ mod project_settings_update_tests { cx.update(|cx| { let store = settings::SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); menu::init(); let queue = ProjectSettingsUpdateQueue::new(cx); diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 7fcb97a92695b5c3e9e1b32560f332d6bd6908d5..a75a6b1af7a26723c1691b27676072c1869b5847 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -23,6 +23,7 @@ agent_settings.workspace = true agent_ui.workspace = true anyhow.workspace = true chrono.workspace = true +collections.workspace = true editor.workspace = true feature_flags.workspace = true fs.workspace = true @@ -33,6 +34,7 @@ project.workspace = true recent_projects.workspace = true settings.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true vim_mode_setting.workspace = true diff --git a/crates/sidebar/src/project_group_builder.rs b/crates/sidebar/src/project_group_builder.rs new file mode 100644 index 0000000000000000000000000000000000000000..318dfac0a839e28ceb27c6036b87e6a13d9bc992 --- /dev/null +++ b/crates/sidebar/src/project_group_builder.rs @@ -0,0 +1,328 @@ +//! The sidebar groups threads by a canonical path list. +//! +//! Threads have a path list associated with them, but this is the absolute path +//! of whatever worktrees they were associated with. In the sidebar, we want to +//! group all threads by their main worktree, and then we add a worktree chip to +//! the sidebar entry when that thread is in another worktree. +//! +//! This module is provides the functions and structures necessary to do this +//! lookup and mapping. + +use collections::{HashMap, HashSet, vecmap::VecMap}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use gpui::{App, Entity}; +use ui::SharedString; +use workspace::{MultiWorkspace, PathList, Workspace}; + +/// Identifies a project group by a set of paths the workspaces in this group +/// have. +/// +/// Paths are mapped to their main worktree path first so we can group +/// workspaces by main repos. +#[derive(PartialEq, Eq, Hash, Clone)] +pub struct ProjectGroupName { + path_list: PathList, +} + +impl ProjectGroupName { + pub fn display_name(&self) -> SharedString { + let mut names = Vec::with_capacity(self.path_list.paths().len()); + for abs_path in self.path_list.paths() { + if let Some(name) = abs_path.file_name() { + names.push(name.to_string_lossy().to_string()); + } + } + if names.is_empty() { + // TODO: Can we do something better in this case? + "Empty Workspace".into() + } else { + names.join(", ").into() + } + } + + pub fn path_list(&self) -> &PathList { + &self.path_list + } +} + +#[derive(Default)] +pub struct ProjectGroup { + pub workspaces: Vec>, + /// Root paths of all open workspaces in this group. Used to skip + /// redundant thread-store queries for linked worktrees that already + /// have an open workspace. + covered_paths: HashSet>, +} + +impl ProjectGroup { + fn add_workspace(&mut self, workspace: &Entity, cx: &App) { + if !self.workspaces.contains(workspace) { + self.workspaces.push(workspace.clone()); + } + for path in workspace.read(cx).root_paths(cx) { + self.covered_paths.insert(path); + } + } + + pub fn first_workspace(&self) -> &Entity { + self.workspaces + .first() + .expect("groups always have at least one workspace") + } + + pub fn main_workspace(&self, cx: &App) -> &Entity { + self.workspaces + .iter() + .find(|ws| { + !crate::root_repository_snapshots(ws, cx) + .any(|snapshot| snapshot.is_linked_worktree()) + }) + .unwrap_or_else(|| self.first_workspace()) + } +} + +pub struct ProjectGroupBuilder { + /// Maps git repositories' work_directory_abs_path to their original_repo_abs_path + directory_mappings: HashMap, + project_groups: VecMap, +} + +impl ProjectGroupBuilder { + fn new() -> Self { + Self { + directory_mappings: HashMap::default(), + project_groups: VecMap::new(), + } + } + + pub fn from_multiworkspace(mw: &MultiWorkspace, cx: &App) -> Self { + let mut builder = Self::new(); + // First pass: collect all directory mappings from every workspace + // so we know how to canonicalize any path (including linked + // worktree paths discovered by the main repo's workspace). + for workspace in mw.workspaces() { + builder.add_workspace_mappings(workspace.read(cx), cx); + } + + // Second pass: group each workspace using canonical paths derived + // from the full set of mappings. + for workspace in mw.workspaces() { + let group_name = builder.canonical_workspace_paths(workspace, cx); + builder + .project_group_entry(&group_name) + .add_workspace(workspace, cx); + } + builder + } + + fn project_group_entry(&mut self, name: &ProjectGroupName) -> &mut ProjectGroup { + self.project_groups.entry_ref(name).or_insert_default() + } + + fn add_mapping(&mut self, work_directory: &Path, original_repo: &Path) { + let old = self + .directory_mappings + .insert(PathBuf::from(work_directory), PathBuf::from(original_repo)); + if let Some(old) = old { + debug_assert_eq!( + &old, original_repo, + "all worktrees should map to the same main worktree" + ); + } + } + + pub fn add_workspace_mappings(&mut self, workspace: &Workspace, cx: &App) { + for repo in workspace.project().read(cx).repositories(cx).values() { + let snapshot = repo.read(cx).snapshot(); + + self.add_mapping( + &snapshot.work_directory_abs_path, + &snapshot.original_repo_abs_path, + ); + + for worktree in snapshot.linked_worktrees.iter() { + self.add_mapping(&worktree.path, &snapshot.original_repo_abs_path); + } + } + } + + /// Derives the canonical group name for a workspace by canonicalizing + /// each of its root paths using the builder's directory mappings. + fn canonical_workspace_paths( + &self, + workspace: &Entity, + cx: &App, + ) -> ProjectGroupName { + let root_paths = workspace.read(cx).root_paths(cx); + let paths: Vec<_> = root_paths + .iter() + .map(|p| self.canonicalize_path(p).to_path_buf()) + .collect(); + ProjectGroupName { + path_list: PathList::new(&paths), + } + } + + pub fn canonicalize_path<'a>(&'a self, path: &'a Path) -> &'a Path { + self.directory_mappings + .get(path) + .map(AsRef::as_ref) + .unwrap_or(path) + } + + /// Whether the given group should load threads for a linked worktree + /// at `worktree_path`. Returns `false` if the worktree already has an + /// open workspace in the group (its threads are loaded via the + /// workspace loop) or if the worktree's canonical path list doesn't + /// match `group_path_list`. + pub fn group_owns_worktree( + &self, + group: &ProjectGroup, + group_path_list: &PathList, + worktree_path: &Path, + ) -> bool { + if group.covered_paths.contains(worktree_path) { + return false; + } + let canonical = self.canonicalize_path_list(&PathList::new(&[worktree_path])); + canonical == *group_path_list + } + + /// Canonicalizes every path in a [`PathList`] using the builder's + /// directory mappings. + fn canonicalize_path_list(&self, path_list: &PathList) -> PathList { + let paths: Vec<_> = path_list + .paths() + .iter() + .map(|p| self.canonicalize_path(p).to_path_buf()) + .collect(); + PathList::new(&paths) + } + + pub fn groups(&self) -> impl Iterator { + self.project_groups.iter() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use fs::FakeFs; + use gpui::TestAppContext; + use settings::SettingsStore; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme_settings::init(theme::LoadThemes::JustBase, cx); + }); + } + + async fn create_fs_with_main_and_worktree(cx: &mut TestAppContext) -> Arc { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt/feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt/feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "abc".into(), + }); + }) + .expect("git state should be set"); + fs + } + + #[gpui::test] + async fn test_main_repo_maps_to_itself(cx: &mut TestAppContext) { + init_test(cx); + let fs = create_fs_with_main_and_worktree(cx).await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + + multi_workspace.read_with(cx, |mw, cx| { + let mut canonicalizer = ProjectGroupBuilder::new(); + for workspace in mw.workspaces() { + canonicalizer.add_workspace_mappings(workspace.read(cx), cx); + } + + // The main repo path should canonicalize to itself. + assert_eq!( + canonicalizer.canonicalize_path(Path::new("/project")), + Path::new("/project"), + ); + + // An unknown path returns None. + assert_eq!( + canonicalizer.canonicalize_path(Path::new("/something/else")), + Path::new("/something/else"), + ); + }); + } + + #[gpui::test] + async fn test_worktree_checkout_canonicalizes_to_main_repo(cx: &mut TestAppContext) { + init_test(cx); + let fs = create_fs_with_main_and_worktree(cx).await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Open the worktree checkout as its own project. + let project = project::Project::test(fs.clone(), ["/wt/feature-a".as_ref()], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + + multi_workspace.read_with(cx, |mw, cx| { + let mut canonicalizer = ProjectGroupBuilder::new(); + for workspace in mw.workspaces() { + canonicalizer.add_workspace_mappings(workspace.read(cx), cx); + } + + // The worktree checkout path should canonicalize to the main repo. + assert_eq!( + canonicalizer.canonicalize_path(Path::new("/wt/feature-a")), + Path::new("/project"), + ); + }); + } +} diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index c891e70f684d00c928080494a8dcae369f01b26f..4da57b1ec2be54bca836648f8feaba879d9ec05d 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -19,20 +19,19 @@ use gpui::{ use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, }; -use project::{AgentId, Event as ProjectEvent, linked_worktree_short_name}; +use project::{Event as ProjectEvent, linked_worktree_short_name}; use recent_projects::sidebar_recent_projects::SidebarRecentProjects; use ui::utils::platform_title_bar_height; use settings::Settings as _; use std::collections::{HashMap, HashSet}; use std::mem; -use std::path::Path; use std::rc::Rc; -use std::sync::Arc; use theme::ActiveTheme; use ui::{ AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding, - PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, TintColor, Tooltip, WithScrollbar, prelude::*, + PopoverMenu, PopoverMenuHandle, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip, + WithScrollbar, prelude::*, }; use util::ResultExt as _; use util::path_list::PathList; @@ -45,10 +44,24 @@ use workspace::{ use zed_actions::OpenRecent; use zed_actions::editor::{MoveDown, MoveUp}; -use zed_actions::agents_sidebar::{ - ActivateSelectedWorkspace, FocusSidebarFilter, NewThreadInGroup, RemoveSelected, - RemoveSelectedWorkspace, ShowFewerThreads, ShowMoreThreads, StopSelectedThread, ToggleArchive, -}; +use zed_actions::agents_sidebar::FocusSidebarFilter; + +use crate::project_group_builder::ProjectGroupBuilder; + +mod project_group_builder; + +#[cfg(test)] +mod sidebar_tests; + +gpui::actions!( + agents_sidebar, + [ + /// Creates a new thread in the currently selected or active project group. + NewThreadInGroup, + /// Toggles between the thread list and the archive view. + ToggleArchive, + ] +); const DEFAULT_WIDTH: Pixels = px(300.0); const MIN_WIDTH: Pixels = px(200.0); @@ -93,6 +106,13 @@ enum ThreadEntryWorkspace { Closed(PathList), } +#[derive(Clone)] +struct WorktreeInfo { + name: SharedString, + full_path: SharedString, + highlight_positions: Vec, +} + #[derive(Clone)] struct ThreadEntry { agent: Agent, @@ -105,12 +125,28 @@ struct ThreadEntry { is_background: bool, is_title_generating: bool, highlight_positions: Vec, - worktree_name: Option, - worktree_full_path: Option, - worktree_highlight_positions: Vec, + worktrees: Vec, diff_stats: DiffStats, } +impl ThreadEntry { + /// Updates this thread entry with active thread information. + /// + /// The existing [`ThreadEntry`] was likely deserialized from the database + /// but if we have a correspond thread already loaded we want to apply the + /// live information. + fn apply_active_info(&mut self, info: &ActiveThreadInfo) { + self.session_info.title = Some(info.title.clone()); + self.status = info.status; + self.icon = info.icon; + self.icon_from_external_svg = info.icon_from_external_svg.clone(); + self.is_live = true; + self.is_background = info.is_background; + self.is_title_generating = info.is_title_generating; + self.diff_stats = info.diff_stats; + } +} + #[derive(Clone)] enum ListEntry { ProjectHeader { @@ -134,6 +170,28 @@ enum ListEntry { }, } +#[cfg(test)] +impl ListEntry { + fn workspace(&self) -> Option> { + match self { + ListEntry::ProjectHeader { workspace, .. } => Some(workspace.clone()), + ListEntry::Thread(thread_entry) => match &thread_entry.workspace { + ThreadEntryWorkspace::Open(workspace) => Some(workspace.clone()), + ThreadEntryWorkspace::Closed(_) => None, + }, + ListEntry::ViewMore { .. } => None, + ListEntry::NewThread { workspace, .. } => Some(workspace.clone()), + } + } + + fn session_id(&self) -> Option<&acp::SessionId> { + match self { + ListEntry::Thread(thread_entry) => Some(&thread_entry.session_info.session_id), + _ => None, + } + } +} + impl From for ListEntry { fn from(thread: ThreadEntry) -> Self { ListEntry::Thread(thread) @@ -202,19 +260,31 @@ fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { PathList::new(&workspace.read(cx).root_paths(cx)) } -fn workspace_label_from_path_list(path_list: &PathList) -> SharedString { - let mut names = Vec::with_capacity(path_list.paths().len()); - for abs_path in path_list.paths() { - if let Some(name) = abs_path.file_name() { - names.push(name.to_string_lossy().to_string()); - } - } - if names.is_empty() { - // TODO: Can we do something better in this case? - "Empty Workspace".into() - } else { - names.join(", ").into() - } +/// Derives worktree display info from a thread's stored path list. +/// +/// For each path in the thread's `folder_paths` that canonicalizes to a +/// different path (i.e. it's a git worktree), produces a [`WorktreeInfo`] +/// with the short worktree name and full path. +fn worktree_info_from_thread_paths( + folder_paths: &PathList, + project_groups: &ProjectGroupBuilder, +) -> Vec { + folder_paths + .paths() + .iter() + .filter_map(|path| { + let canonical = project_groups.canonicalize_path(path); + if canonical != path.as_path() { + Some(WorktreeInfo { + name: linked_worktree_short_name(canonical, path).unwrap_or_default(), + full_path: SharedString::from(path.display().to_string()), + highlight_positions: Vec::new(), + }) + } else { + None + } + }) + .collect() } /// The sidebar re-derives its entire entry list from scratch on every @@ -373,7 +443,7 @@ impl Sidebar { cx.subscribe_in( &git_store, window, - |this, _, event: &project::git_store::GitStoreEvent, window, cx| { + |this, _, event: &project::git_store::GitStoreEvent, _window, cx| { if matches!( event, project::git_store::GitStoreEvent::RepositoryUpdated( @@ -382,7 +452,6 @@ impl Sidebar { _, ) ) { - this.prune_stale_worktree_workspaces(window, cx); this.update_entries(cx); } }, @@ -535,8 +604,21 @@ impl Sidebar { result } - /// When modifying this thread, aim for a single forward pass over workspaces - /// and threads plus an O(T log T) sort. Avoid adding extra scans over the data. + /// Rebuilds the sidebar contents from current workspace and thread state. + /// + /// Uses [`ProjectGroupBuilder`] to group workspaces by their main git + /// repository, then populates thread entries from the metadata store and + /// merges live thread info from active agent panels. + /// + /// Aim for a single forward pass over workspaces and threads plus an + /// O(T log T) sort. Avoid adding extra scans over the data. + /// + /// Properties: + /// + /// - Should always show every workspace in the multiworkspace + /// - If you have no threads, and two workspaces for the worktree and the main workspace, make sure at least one is shown + /// - Should always show every thread, associated with each workspace in the multiworkspace + /// - After every build_contents, our "active" state should exactly match the current workspace's, current agent panel's current thread. fn rebuild_contents(&mut self, cx: &App) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; @@ -545,7 +627,6 @@ impl Sidebar { let workspaces = mw.workspaces().to_vec(); let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned(); - // Build a lookup for agent icons from the first workspace's AgentServerStore. let agent_server_store = workspaces .first() .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone()); @@ -600,118 +681,60 @@ impl Sidebar { let mut current_session_ids: HashSet = HashSet::new(); let mut project_header_indices: Vec = Vec::new(); - // Identify absorbed workspaces in a single pass. A workspace is - // "absorbed" when it points at a git worktree checkout whose main - // repo is open as another workspace — its threads appear under the - // main repo's header instead of getting their own. - let mut main_repo_workspace: HashMap, usize> = HashMap::new(); - let mut absorbed: HashMap = HashMap::new(); - let mut pending: HashMap, Vec<(usize, SharedString, Arc)>> = HashMap::new(); - let mut absorbed_workspace_by_path: HashMap, usize> = HashMap::new(); - let workspace_indices_by_path: HashMap, Vec> = workspaces - .iter() - .enumerate() - .flat_map(|(index, workspace)| { - let paths = workspace_path_list(workspace, cx).paths().to_vec(); - paths - .into_iter() - .map(move |path| (Arc::from(path.as_path()), index)) - }) - .fold(HashMap::new(), |mut map, (path, index)| { - map.entry(path).or_default().push(index); - map - }); - - for (i, workspace) in workspaces.iter().enumerate() { - for snapshot in root_repository_snapshots(workspace, cx) { - if snapshot.is_main_worktree() { - main_repo_workspace - .entry(snapshot.work_directory_abs_path.clone()) - .or_insert(i); - - for git_worktree in snapshot.linked_worktrees() { - let worktree_path: Arc = Arc::from(git_worktree.path.as_path()); - if let Some(worktree_indices) = - workspace_indices_by_path.get(worktree_path.as_ref()) - { - for &worktree_idx in worktree_indices { - if worktree_idx == i { - continue; - } - - let worktree_name = linked_worktree_short_name( - &snapshot.original_repo_abs_path, - &git_worktree.path, - ) - .unwrap_or_default(); - absorbed.insert(worktree_idx, (i, worktree_name.clone())); - absorbed_workspace_by_path - .insert(worktree_path.clone(), worktree_idx); - } - } - } - - if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) { - for (ws_idx, name, ws_path) in waiting { - absorbed.insert(ws_idx, (i, name)); - absorbed_workspace_by_path.insert(ws_path, ws_idx); - } - } - } else { - let name: SharedString = snapshot - .work_directory_abs_path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - .into(); - if let Some(&main_idx) = - main_repo_workspace.get(&snapshot.original_repo_abs_path) - { - absorbed.insert(i, (main_idx, name)); - absorbed_workspace_by_path - .insert(snapshot.work_directory_abs_path.clone(), i); - } else { - pending - .entry(snapshot.original_repo_abs_path.clone()) - .or_default() - .push((i, name, snapshot.work_directory_abs_path.clone())); - } - } - } - } + // Use ProjectGroupBuilder to canonically group workspaces by their + // main git repository. This replaces the manual absorbed-workspace + // detection that was here before. + let project_groups = ProjectGroupBuilder::from_multiworkspace(mw, cx); let has_open_projects = workspaces .iter() .any(|ws| !workspace_path_list(ws, cx).paths().is_empty()); - let active_ws_index = active_workspace - .as_ref() - .and_then(|active| workspaces.iter().position(|ws| ws == active)); - - for (ws_index, workspace) in workspaces.iter().enumerate() { - if absorbed.contains_key(&ws_index) { - continue; + let resolve_agent = |row: &ThreadMetadata| -> (Agent, IconName, Option) { + match &row.agent_id { + None => (Agent::NativeAgent, IconName::ZedAgent, None), + Some(id) => { + let custom_icon = agent_server_store + .as_ref() + .and_then(|store| store.read(cx).agent_icon(id)); + ( + Agent::Custom { id: id.clone() }, + IconName::Terminal, + custom_icon, + ) + } } + }; - let path_list = workspace_path_list(workspace, cx); + for (group_name, group) in project_groups.groups() { + let path_list = group_name.path_list().clone(); if path_list.paths().is_empty() { continue; } - let label = workspace_label_from_path_list(&path_list); + let label = group_name.display_name(); let is_collapsed = self.collapsed_groups.contains(&path_list); let should_load_threads = !is_collapsed || !query.is_empty(); - let is_active = active_ws_index.is_some_and(|active_idx| { - active_idx == ws_index - || absorbed - .get(&active_idx) - .is_some_and(|(main_idx, _)| *main_idx == ws_index) - }); - - let mut live_infos: Vec<_> = all_thread_infos_for_workspace(workspace, cx).collect(); + let is_active = active_workspace + .as_ref() + .is_some_and(|active| group.workspaces.contains(active)); + + // Pick a representative workspace for the group: prefer the active + // workspace if it belongs to this group, otherwise use the main + // repo workspace (not a linked worktree). + let representative_workspace = active_workspace + .as_ref() + .filter(|_| is_active) + .unwrap_or_else(|| group.main_workspace(cx)); + + // Collect live thread infos from all workspaces in this group. + let live_infos: Vec<_> = group + .workspaces + .iter() + .flat_map(|ws| all_thread_infos_for_workspace(ws, cx)) + .collect(); let mut threads: Vec = Vec::new(); let mut has_running_threads = false; @@ -719,138 +742,90 @@ impl Sidebar { if should_load_threads { let mut seen_session_ids: HashSet = HashSet::new(); - - // Read threads from the store cache for this workspace's path list. let thread_store = SidebarThreadMetadataStore::global(cx); - let workspace_rows: Vec<_> = - thread_store.read(cx).entries_for_path(&path_list).collect(); - for row in workspace_rows { - seen_session_ids.insert(row.session_id.clone()); - let (agent, icon, icon_from_external_svg) = match &row.agent_id { - None => (Agent::NativeAgent, IconName::ZedAgent, None), - Some(id) => { - let custom_icon = agent_server_store - .as_ref() - .and_then(|store| store.read(cx).agent_icon(&id)); - ( - Agent::Custom { id: id.clone() }, - IconName::Terminal, - custom_icon, - ) - } - }; - threads.push(ThreadEntry { - agent, - session_info: acp_thread::AgentSessionInfo { - session_id: row.session_id.clone(), - work_dirs: None, - title: Some(row.title.clone()), - updated_at: Some(row.updated_at), - created_at: row.created_at, - meta: None, - }, - icon, - icon_from_external_svg, - status: AgentThreadStatus::default(), - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }); - } - // Load threads from linked git worktrees of this workspace's repos. - { - let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc)> = - Vec::new(); - for snapshot in root_repository_snapshots(workspace, cx) { - if snapshot.is_linked_worktree() { - continue; - } + // Load threads from each workspace in the group. + for workspace in &group.workspaces { + let ws_path_list = workspace_path_list(workspace, cx); - let main_worktree_path = snapshot.original_repo_abs_path.clone(); - - for git_worktree in snapshot.linked_worktrees() { - let worktree_name = - linked_worktree_short_name(&main_worktree_path, &git_worktree.path) - .unwrap_or_default(); - linked_worktree_queries.push(( - PathList::new(std::slice::from_ref(&git_worktree.path)), - worktree_name, - Arc::from(git_worktree.path.as_path()), - )); + for row in thread_store.read(cx).entries_for_path(&ws_path_list) { + if !seen_session_ids.insert(row.session_id.clone()) { + continue; } + let (agent, icon, icon_from_external_svg) = resolve_agent(&row); + let worktrees = + worktree_info_from_thread_paths(&row.folder_paths, &project_groups); + threads.push(ThreadEntry { + agent, + session_info: acp_thread::AgentSessionInfo { + session_id: row.session_id.clone(), + work_dirs: None, + title: Some(row.title.clone()), + updated_at: Some(row.updated_at), + created_at: row.created_at, + meta: None, + }, + icon, + icon_from_external_svg, + status: AgentThreadStatus::default(), + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees, + diff_stats: DiffStats::default(), + }); } + } - for (worktree_path_list, worktree_name, worktree_path) in - &linked_worktree_queries - { - let target_workspace = match absorbed_workspace_by_path - .get(worktree_path.as_ref()) - { - Some(&idx) => { - live_infos - .extend(all_thread_infos_for_workspace(&workspaces[idx], cx)); - ThreadEntryWorkspace::Open(workspaces[idx].clone()) - } - None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()), - }; + // Load threads from linked git worktrees whose + // canonical paths belong to this group. + let linked_worktree_queries = group + .workspaces + .iter() + .flat_map(|ws| root_repository_snapshots(ws, cx)) + .filter(|snapshot| !snapshot.is_linked_worktree()) + .flat_map(|snapshot| { + snapshot + .linked_worktrees() + .iter() + .filter(|wt| { + project_groups.group_owns_worktree(group, &path_list, &wt.path) + }) + .map(|wt| PathList::new(std::slice::from_ref(&wt.path))) + .collect::>() + }); - let worktree_rows: Vec<_> = thread_store - .read(cx) - .entries_for_path(worktree_path_list) - .collect(); - for row in worktree_rows { - if !seen_session_ids.insert(row.session_id.clone()) { - continue; - } - let (agent, icon, icon_from_external_svg) = match &row.agent_id { - None => (Agent::NativeAgent, IconName::ZedAgent, None), - Some(name) => { - let custom_icon = - agent_server_store.as_ref().and_then(|store| { - store.read(cx).agent_icon(&AgentId(name.clone().into())) - }); - ( - Agent::Custom { - id: AgentId::new(name.clone()), - }, - IconName::Terminal, - custom_icon, - ) - } - }; - threads.push(ThreadEntry { - agent, - session_info: acp_thread::AgentSessionInfo { - session_id: row.session_id.clone(), - work_dirs: None, - title: Some(row.title.clone()), - updated_at: Some(row.updated_at), - created_at: row.created_at, - meta: None, - }, - icon, - icon_from_external_svg, - status: AgentThreadStatus::default(), - workspace: target_workspace.clone(), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: Some(worktree_name.clone()), - worktree_full_path: Some( - worktree_path.display().to_string().into(), - ), - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }); + for worktree_path_list in linked_worktree_queries { + for row in thread_store.read(cx).entries_for_path(&worktree_path_list) { + if !seen_session_ids.insert(row.session_id.clone()) { + continue; } + let (agent, icon, icon_from_external_svg) = resolve_agent(&row); + let worktrees = + worktree_info_from_thread_paths(&row.folder_paths, &project_groups); + threads.push(ThreadEntry { + agent, + session_info: acp_thread::AgentSessionInfo { + session_id: row.session_id.clone(), + work_dirs: None, + title: Some(row.title.clone()), + updated_at: Some(row.updated_at), + created_at: row.created_at, + meta: None, + }, + icon, + icon_from_external_svg, + status: AgentThreadStatus::default(), + workspace: ThreadEntryWorkspace::Closed(worktree_path_list.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees, + diff_stats: DiffStats::default(), + }); } } @@ -871,19 +846,12 @@ impl Sidebar { // Merge live info into threads and update notification state // in a single pass. for thread in &mut threads { - let session_id = &thread.session_info.session_id; - - if let Some(info) = live_info_by_session.get(session_id) { - thread.session_info.title = Some(info.title.clone()); - thread.status = info.status; - thread.icon = info.icon; - thread.icon_from_external_svg = info.icon_from_external_svg.clone(); - thread.is_live = true; - thread.is_background = info.is_background; - thread.is_title_generating = info.is_title_generating; - thread.diff_stats = info.diff_stats; + if let Some(info) = live_info_by_session.get(&thread.session_info.session_id) { + thread.apply_active_info(info); } + let session_id = &thread.session_info.session_id; + let is_thread_workspace_active = match &thread.workspace { ThreadEntryWorkspace::Open(thread_workspace) => active_workspace .as_ref() @@ -909,7 +877,7 @@ impl Sidebar { b_time.cmp(&a_time) }); } else { - for info in &live_infos { + for info in live_infos { if info.status == AgentThreadStatus::Running { has_running_threads = true; } @@ -935,12 +903,13 @@ impl Sidebar { if let Some(positions) = fuzzy_match_positions(&query, title) { thread.highlight_positions = positions; } - if let Some(worktree_name) = &thread.worktree_name { - if let Some(positions) = fuzzy_match_positions(&query, worktree_name) { - thread.worktree_highlight_positions = positions; + let mut worktree_matched = false; + for worktree in &mut thread.worktrees { + if let Some(positions) = fuzzy_match_positions(&query, &worktree.name) { + worktree.highlight_positions = positions; + worktree_matched = true; } } - let worktree_matched = !thread.worktree_highlight_positions.is_empty(); if workspace_matched || !thread.highlight_positions.is_empty() || worktree_matched @@ -957,7 +926,7 @@ impl Sidebar { entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, - workspace: workspace.clone(), + workspace: representative_workspace.clone(), highlight_positions: workspace_highlight_positions, has_running_threads, waiting_thread_count, @@ -981,7 +950,7 @@ impl Sidebar { entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, - workspace: workspace.clone(), + workspace: representative_workspace.clone(), highlight_positions: Vec::new(), has_running_threads, waiting_thread_count, @@ -995,7 +964,7 @@ impl Sidebar { if show_new_thread_entry { entries.push(ListEntry::NewThread { path_list: path_list.clone(), - workspace: workspace.clone(), + workspace: representative_workspace.clone(), is_active_draft: is_draft_for_workspace, }); } @@ -1604,7 +1573,7 @@ impl Sidebar { true, &path_list, &label, - &workspace, + workspace, &highlight_positions, *has_running_threads, *waiting_thread_count, @@ -1647,72 +1616,6 @@ impl Sidebar { Some(element) } - fn prune_stale_worktree_workspaces(&mut self, window: &mut Window, cx: &mut Context) { - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - return; - }; - let workspaces = multi_workspace.read(cx).workspaces().to_vec(); - - // Collect all worktree paths that are currently listed by any main - // repo open in any workspace. - let mut known_worktree_paths: HashSet = HashSet::new(); - for workspace in &workspaces { - for snapshot in root_repository_snapshots(workspace, cx) { - if snapshot.is_linked_worktree() { - continue; - } - for git_worktree in snapshot.linked_worktrees() { - known_worktree_paths.insert(git_worktree.path.to_path_buf()); - } - } - } - - // Find workspaces that consist of exactly one root folder which is a - // stale worktree checkout. Multi-root workspaces are never pruned — - // losing one worktree shouldn't destroy a workspace that also - // contains other folders. - let mut to_remove: Vec> = Vec::new(); - for workspace in &workspaces { - let path_list = workspace_path_list(workspace, cx); - if path_list.paths().len() != 1 { - continue; - } - let should_prune = root_repository_snapshots(workspace, cx).any(|snapshot| { - snapshot.is_linked_worktree() - && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref()) - }); - if should_prune { - to_remove.push(workspace.clone()); - } - } - - for workspace in &to_remove { - self.remove_workspace(workspace, window, cx); - } - } - - fn remove_workspace( - &mut self, - workspace: &Entity, - window: &mut Window, - cx: &mut Context, - ) { - let Some(multi_workspace) = self.multi_workspace.upgrade() else { - return; - }; - - multi_workspace.update(cx, |multi_workspace, cx| { - let Some(index) = multi_workspace - .workspaces() - .iter() - .position(|w| w == workspace) - else { - return; - }; - multi_workspace.remove_workspace(index, window, cx); - }); - } - fn toggle_collapse( &mut self, path_list: &PathList, @@ -2639,14 +2542,17 @@ impl Sidebar { .when_some(thread.icon_from_external_svg.clone(), |this, svg| { this.custom_icon_from_external_svg(svg) }) - .when_some(thread.worktree_name.clone(), |this, name| { - let this = this.worktree(name); - match thread.worktree_full_path.clone() { - Some(path) => this.worktree_full_path(path), - None => this, - } - }) - .worktree_highlight_positions(thread.worktree_highlight_positions.clone()) + .worktrees( + thread + .worktrees + .iter() + .map(|wt| ThreadItemWorktreeInfo { + name: wt.name.clone(), + full_path: wt.full_path.clone(), + highlight_positions: wt.highlight_positions.clone(), + }) + .collect(), + ) .when_some(timestamp, |this, ts| this.timestamp(ts)) .highlight_positions(thread.highlight_positions.to_vec()) .title_generating(thread.is_title_generating) @@ -3160,9 +3066,7 @@ impl Sidebar { bar.child(toggle_button).child(action_buttons) } } -} -impl Sidebar { fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context) { match &self.view { SidebarView::ThreadList => self.show_archive(window, cx), @@ -3273,7 +3177,7 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let _titlebar_height = ui::utils::platform_title_bar_height(window); - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let sticky_header = self.render_sticky_header(window, cx); let color = cx.theme().colors(); @@ -3359,24 +3263,8 @@ fn all_thread_infos_for_workspace( workspace: &Entity, cx: &App, ) -> impl Iterator { - enum ThreadInfoIterator> { - Empty, - Threads(T), - } - - impl> Iterator for ThreadInfoIterator { - type Item = ActiveThreadInfo; - - fn next(&mut self) -> Option { - match self { - ThreadInfoIterator::Empty => None, - ThreadInfoIterator::Threads(threads) => threads.next(), - } - } - } - let Some(agent_panel) = workspace.read(cx).panel::(cx) else { - return ThreadInfoIterator::Empty; + return None.into_iter().flatten(); }; let agent_panel = agent_panel.read(cx); @@ -3422,3869 +3310,5 @@ fn all_thread_infos_for_workspace( } }); - ThreadInfoIterator::Threads(threads) -} - -#[cfg(test)] -mod tests { - use super::*; - use acp_thread::StubAgentConnection; - use agent::ThreadStore; - use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message}; - use assistant_text_thread::TextThreadStore; - use chrono::DateTime; - use feature_flags::FeatureFlagAppExt as _; - use fs::FakeFs; - use gpui::TestAppContext; - use pretty_assertions::assert_eq; - use settings::SettingsStore; - use std::{path::PathBuf, sync::Arc}; - use util::path_list::PathList; - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); - editor::init(cx); - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - } - - fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { - sidebar.contents.entries.iter().any(|entry| { - matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id) - }) - } - - async fn init_test_project( - worktree_path: &str, - cx: &mut TestAppContext, - ) -> Entity { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - project::Project::test(fs, [worktree_path.as_ref()], cx).await - } - - fn setup_sidebar( - multi_workspace: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> Entity { - let multi_workspace = multi_workspace.clone(); - let sidebar = - cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx))); - multi_workspace.update(cx, |mw, cx| { - mw.register_sidebar(sidebar.clone(), cx); - }); - cx.run_until_parked(); - sidebar - } - - async fn save_n_test_threads( - count: u32, - path_list: &PathList, - cx: &mut gpui::VisualTestContext, - ) { - for i in 0..count { - save_thread_metadata( - acp::SessionId::new(Arc::from(format!("thread-{}", i))), - format!("Thread {}", i + 1).into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - } - - async fn save_test_thread_metadata( - session_id: &acp::SessionId, - path_list: PathList, - cx: &mut TestAppContext, - ) { - save_thread_metadata( - session_id.clone(), - "Test".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list, - cx, - ) - .await; - } - - async fn save_named_thread_metadata( - session_id: &str, - title: &str, - path_list: &PathList, - cx: &mut gpui::VisualTestContext, - ) { - save_thread_metadata( - acp::SessionId::new(Arc::from(session_id)), - SharedString::from(title.to_string()), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - } - - async fn save_thread_metadata( - session_id: acp::SessionId, - title: SharedString, - updated_at: DateTime, - path_list: PathList, - cx: &mut TestAppContext, - ) { - let metadata = ThreadMetadata { - session_id, - agent_id: None, - title, - updated_at, - created_at: None, - folder_paths: path_list, - }; - cx.update(|cx| { - SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)) - }); - cx.run_until_parked(); - } - - fn open_and_focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { - let multi_workspace = sidebar.read_with(cx, |s, _| s.multi_workspace.upgrade()); - if let Some(multi_workspace) = multi_workspace { - multi_workspace.update_in(cx, |mw, window, cx| { - if !mw.sidebar_open() { - mw.toggle_sidebar(window, cx); - } - }); - } - cx.run_until_parked(); - sidebar.update_in(cx, |_, window, cx| { - cx.focus_self(window); - }); - cx.run_until_parked(); - } - - fn visible_entries_as_strings( - sidebar: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> Vec { - sidebar.read_with(cx, |sidebar, _cx| { - sidebar - .contents - .entries - .iter() - .enumerate() - .map(|(ix, entry)| { - let selected = if sidebar.selection == Some(ix) { - " <== selected" - } else { - "" - }; - match entry { - ListEntry::ProjectHeader { - label, - path_list, - highlight_positions: _, - .. - } => { - let icon = if sidebar.collapsed_groups.contains(path_list) { - ">" - } else { - "v" - }; - format!("{} [{}]{}", icon, label, selected) - } - ListEntry::Thread(thread) => { - let title = thread - .session_info - .title - .as_ref() - .map(|s| s.as_ref()) - .unwrap_or("Untitled"); - let active = if thread.is_live { " *" } else { "" }; - let status_str = match thread.status { - AgentThreadStatus::Running => " (running)", - AgentThreadStatus::Error => " (error)", - AgentThreadStatus::WaitingForConfirmation => " (waiting)", - _ => "", - }; - let notified = if sidebar - .contents - .is_thread_notified(&thread.session_info.session_id) - { - " (!)" - } else { - "" - }; - let worktree = thread - .worktree_name - .as_ref() - .map(|name| format!(" {{{}}}", name)) - .unwrap_or_default(); - format!( - " {}{}{}{}{}{}", - title, worktree, active, status_str, notified, selected - ) - } - ListEntry::ViewMore { - is_fully_expanded, .. - } => { - if *is_fully_expanded { - format!(" - Collapse{}", selected) - } else { - format!(" + View More{}", selected) - } - } - ListEntry::NewThread { .. } => { - format!(" [+ New Thread]{}", selected) - } - } - }) - .collect() - }) - } - - #[test] - fn test_clean_mention_links() { - // Simple mention link - assert_eq!( - Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"), - "check @Button.tsx" - ); - - // Multiple mention links - assert_eq!( - Sidebar::clean_mention_links( - "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)" - ), - "look at @foo.rs and @bar.rs" - ); - - // No mention links — passthrough - assert_eq!( - Sidebar::clean_mention_links("plain text with no mentions"), - "plain text with no mentions" - ); - - // Incomplete link syntax — preserved as-is - assert_eq!( - Sidebar::clean_mention_links("broken [@mention without closing"), - "broken [@mention without closing" - ); - - // Regular markdown link (no @) — not touched - assert_eq!( - Sidebar::clean_mention_links("see [docs](https://example.com)"), - "see [docs](https://example.com)" - ); - - // Empty input - assert_eq!(Sidebar::clean_mention_links(""), ""); - } - - #[gpui::test] - async fn test_entities_released_on_window_close(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 weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade()); - let weak_sidebar = sidebar.downgrade(); - let weak_multi_workspace = multi_workspace.downgrade(); - - drop(sidebar); - drop(multi_workspace); - cx.update(|window, _cx| window.remove_window()); - cx.run_until_parked(); - - weak_multi_workspace.assert_released(); - weak_sidebar.assert_released(); - weak_workspace.assert_released(); - } - - #[gpui::test] - async fn test_single_workspace_no_threads(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); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]"] - ); - } - - #[gpui::test] - async fn test_single_workspace_with_saved_threads(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-1")), - "Fix crash in project panel".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-2")), - "Add inline diff view".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - 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]", - " Fix crash in project panel", - " Add inline diff view", - ] - ); - } - - #[gpui::test] - async fn test_workspace_lifecycle(cx: &mut TestAppContext) { - let project = init_test_project("/project-a", 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); - - // Single workspace with a thread - let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-a1")), - "Thread A1".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - 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 [project-a]", " Thread A1"] - ); - - // Add a second workspace - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_test_workspace(window, cx).detach(); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1",] - ); - - // Remove the second workspace - multi_workspace.update_in(cx, |mw, window, cx| { - mw.remove_workspace(1, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Thread A1"] - ); - } - - #[gpui::test] - async fn test_view_more_pagination(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(12, &path_list, cx).await; - - 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 12", - " Thread 11", - " Thread 10", - " Thread 9", - " Thread 8", - " + View More", - ] - ); - } - - #[gpui::test] - async fn test_view_more_batched_expansion(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse - save_n_test_threads(17, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Initially shows 5 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); // header + 5 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Focus and navigate to View More, then confirm to expand by one batch - open_and_focus_sidebar(&sidebar, cx); - for _ in 0..7 { - cx.dispatch_action(SelectNext); - } - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // Now shows 10 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 12); // header + 10 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Expand again by one batch - sidebar.update_in(cx, |s, _window, cx| { - let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); - s.expanded_groups.insert(path_list.clone(), current + 1); - s.update_entries(cx); - }); - cx.run_until_parked(); - - // Now shows 15 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 17); // header + 15 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Expand one more time - should show all 17 threads with Collapse button - sidebar.update_in(cx, |s, _window, cx| { - let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); - s.expanded_groups.insert(path_list.clone(), current + 1); - s.update_entries(cx); - }); - cx.run_until_parked(); - - // All 17 threads shown with Collapse button - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 19); // header + 17 threads + Collapse - assert!(!entries.iter().any(|e| e.contains("View More"))); - assert!(entries.iter().any(|e| e.contains("Collapse"))); - - // Click collapse - should go back to showing 5 threads - sidebar.update_in(cx, |s, _window, cx| { - s.expanded_groups.remove(&path_list); - s.update_entries(cx); - }); - cx.run_until_parked(); - - // Back to initial state: 5 threads + View More - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); // header + 5 threads + View More - assert!(entries.iter().any(|e| e.contains("View More"))); - } - - #[gpui::test] - async fn test_collapse_and_expand_group(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - - 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 1"] - ); - - // Collapse - sidebar.update_in(cx, |s, window, cx| { - s.toggle_collapse(&path_list, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project]"] - ); - - // Expand - sidebar.update_in(cx, |s, window, cx| { - s.toggle_collapse(&path_list, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1"] - ); - } - - #[gpui::test] - async fn test_visible_entries_as_strings(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, |mw, _| mw.workspace().clone()); - let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]); - let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]); - - sidebar.update_in(cx, |s, _window, _cx| { - s.collapsed_groups.insert(collapsed_path.clone()); - s.contents - .notified_threads - .insert(acp::SessionId::new(Arc::from("t-5"))); - s.contents.entries = vec![ - // Expanded project header - ListEntry::ProjectHeader { - path_list: expanded_path.clone(), - label: "expanded-project".into(), - workspace: workspace.clone(), - highlight_positions: Vec::new(), - has_running_threads: false, - waiting_thread_count: 0, - is_active: true, - }, - ListEntry::Thread(ThreadEntry { - agent: Agent::NativeAgent, - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-1")), - work_dirs: None, - title: Some("Completed thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Completed, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }), - // Active thread with Running status - ListEntry::Thread(ThreadEntry { - agent: Agent::NativeAgent, - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-2")), - work_dirs: None, - title: Some("Running thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Running, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: true, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }), - // Active thread with Error status - ListEntry::Thread(ThreadEntry { - agent: Agent::NativeAgent, - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-3")), - work_dirs: None, - title: Some("Error thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Error, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: true, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }), - // Thread with WaitingForConfirmation status, not active - ListEntry::Thread(ThreadEntry { - agent: Agent::NativeAgent, - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-4")), - work_dirs: None, - title: Some("Waiting thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::WaitingForConfirmation, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }), - // Background thread that completed (should show notification) - ListEntry::Thread(ThreadEntry { - agent: Agent::NativeAgent, - session_info: acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("t-5")), - work_dirs: None, - title: Some("Notified thread".into()), - updated_at: Some(Utc::now()), - created_at: Some(Utc::now()), - meta: None, - }, - icon: IconName::ZedAgent, - icon_from_external_svg: None, - status: AgentThreadStatus::Completed, - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: true, - is_background: true, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }), - // View More entry - ListEntry::ViewMore { - path_list: expanded_path.clone(), - is_fully_expanded: false, - }, - // Collapsed project header - ListEntry::ProjectHeader { - path_list: collapsed_path.clone(), - label: "collapsed-project".into(), - workspace: workspace.clone(), - highlight_positions: Vec::new(), - has_running_threads: false, - waiting_thread_count: 0, - is_active: false, - }, - ]; - - // Select the Running thread (index 2) - s.selection = Some(2); - }); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [expanded-project]", - " Completed thread", - " Running thread * (running) <== selected", - " Error thread * (error)", - " Waiting thread (waiting)", - " Notified thread * (!)", - " + View More", - "> [collapsed-project]", - ] - ); - - // Move selection to the collapsed header - sidebar.update_in(cx, |s, _window, _cx| { - s.selection = Some(7); - }); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx).last().cloned(), - Some("> [collapsed-project] <== selected".to_string()), - ); - - // Clear selection - sidebar.update_in(cx, |s, _window, _cx| { - s.selection = None; - }); - - // No entry should have the selected marker - let entries = visible_entries_as_strings(&sidebar, cx); - for entry in &entries { - assert!( - !entry.contains("<== selected"), - "unexpected selection marker in: {}", - entry - ); - } - } - - #[gpui::test] - async fn test_keyboard_select_next_and_previous(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(3, &path_list, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Entries: [header, thread3, thread2, thread1] - // Focusing the sidebar does not set a selection; select_next/select_previous - // handle None gracefully by starting from the first or last entry. - open_and_focus_sidebar(&sidebar, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // First SelectNext from None starts at index 0 - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // Move down through remaining entries - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); - - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - - // At the end, wraps back to first entry - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // Navigate back to the end - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - - // Move back up - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); - - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // At the top, selection clears (focus returns to editor) - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - } - - #[gpui::test] - async fn test_keyboard_select_first_and_last(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(3, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - open_and_focus_sidebar(&sidebar, cx); - - // SelectLast jumps to the end - cx.dispatch_action(SelectLast); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); - - // SelectFirst jumps to the beginning - cx.dispatch_action(SelectFirst); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - } - - #[gpui::test] - async fn test_keyboard_focus_in_does_not_set_selection(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); - - // Initially no selection - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // Open the sidebar so it's rendered, then focus it to trigger focus_in. - // focus_in no longer sets a default selection. - open_and_focus_sidebar(&sidebar, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // Manually set a selection, blur, then refocus — selection should be preserved - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - - cx.update(|window, _cx| { - window.blur(); - }); - cx.run_until_parked(); - - sidebar.update_in(cx, |_, window, cx| { - cx.focus_self(window); - }); - cx.run_until_parked(); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - } - - #[gpui::test] - async fn test_keyboard_confirm_on_project_header_toggles_collapse(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - 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 1"] - ); - - // Focus the sidebar and select the header (index 0) - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - - // Confirm on project header collapses the group - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - - // Confirm again expands the group - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project] <== selected", " Thread 1",] - ); - } - - #[gpui::test] - async fn test_keyboard_confirm_on_view_more_expands(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(8, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Should show header + 5 threads + "View More" - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 7); - assert!(entries.iter().any(|e| e.contains("View More"))); - - // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) - open_and_focus_sidebar(&sidebar, cx); - for _ in 0..7 { - cx.dispatch_action(SelectNext); - } - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); - - // Confirm on "View More" to expand - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // All 8 threads should now be visible with a "Collapse" button - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button - assert!(!entries.iter().any(|e| e.contains("View More"))); - assert!(entries.iter().any(|e| e.contains("Collapse"))); - } - - #[gpui::test] - async fn test_keyboard_expand_and_collapse_selected_entry(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - 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 1"] - ); - - // Focus sidebar and manually select the header (index 0). Press left to collapse. - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - - cx.dispatch_action(SelectParent); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - - // Press right to expand - cx.dispatch_action(SelectChild); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project] <== selected", " Thread 1",] - ); - - // Press right again on already-expanded header moves selection down - cx.dispatch_action(SelectChild); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - } - - #[gpui::test] - async fn test_keyboard_collapse_from_child_selects_parent(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Focus sidebar (selection starts at None), then navigate down to the thread (child) - open_and_focus_sidebar(&sidebar, cx); - cx.dispatch_action(SelectNext); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Thread 1 <== selected",] - ); - - // Pressing left on a child collapses the parent group and selects it - cx.dispatch_action(SelectParent); - cx.run_until_parked(); - - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - } - - #[gpui::test] - async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { - let project = init_test_project("/empty-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); - - // An empty project has the header and a new thread button. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [empty-project]", " [+ New Thread]"] - ); - - // Focus sidebar — focus_in does not set a selection - open_and_focus_sidebar(&sidebar, cx); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - - // First SelectNext from None starts at index 0 (header) - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // SelectNext moves to the new thread button - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - // At the end, wraps back to first entry - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); - - // SelectPrevious from first entry clears selection (returns to editor) - cx.dispatch_action(SelectPrevious); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); - } - - #[gpui::test] - async fn test_selection_clamps_after_entry_removal(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - save_n_test_threads(1, &path_list, cx).await; - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Focus sidebar (selection starts at None), navigate down to the thread (index 1) - open_and_focus_sidebar(&sidebar, cx); - cx.dispatch_action(SelectNext); - cx.dispatch_action(SelectNext); - assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); - - // Collapse the group, which removes the thread from the list - cx.dispatch_action(SelectParent); - cx.run_until_parked(); - - // Selection should be clamped to the last valid index (0 = header) - let selection = sidebar.read_with(cx, |s, _| s.selection); - let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len()); - assert!( - selection.unwrap_or(0) < entry_count, - "selection {} should be within bounds (entries: {})", - selection.unwrap_or(0), - entry_count, - ); - } - - async fn init_test_project_with_agent_panel( - worktree_path: &str, - cx: &mut TestAppContext, - ) -> Entity { - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - project::Project::test(fs, [worktree_path.as_ref()], cx).await - } - - fn add_agent_panel( - workspace: &Entity, - project: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> Entity { - workspace.update_in(cx, |workspace, window, cx| { - let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); - let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx)); - workspace.add_panel(panel.clone(), window, cx); - panel - }) - } - - fn setup_sidebar_with_agent_panel( - multi_workspace: &Entity, - project: &Entity, - cx: &mut gpui::VisualTestContext, - ) -> (Entity, Entity) { - let sidebar = setup_sidebar(multi_workspace, cx); - let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); - let panel = add_agent_panel(&workspace, project, cx); - (sidebar, panel) - } - - #[gpui::test] - async fn test_parallel_threads_shown_with_live_status(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, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - // Open thread A and keep it generating. - let connection = StubAgentConnection::new(); - open_thread_with_connection(&panel, connection.clone(), cx); - send_message(&panel, cx); - - let session_id_a = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await; - - cx.update(|_, cx| { - connection.send_update( - session_id_a.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), - cx, - ); - }); - cx.run_until_parked(); - - // Open thread B (idle, default response) — thread A goes to background. - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - - let session_id_b = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await; - - cx.run_until_parked(); - - let mut entries = visible_entries_as_strings(&sidebar, cx); - entries[1..].sort(); - assert_eq!( - entries, - vec!["v [my-project]", " Hello *", " Hello * (running)",] - ); - } - - #[gpui::test] - async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) { - let project_a = init_test_project_with_agent_panel("/project-a", cx).await; - let (multi_workspace, cx) = cx - .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - // Open thread on workspace A and keep it generating. - let connection_a = StubAgentConnection::new(); - open_thread_with_connection(&panel_a, connection_a.clone(), cx); - send_message(&panel_a, cx); - - let session_id_a = active_session_id(&panel_a, cx); - save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; - - cx.update(|_, cx| { - connection_a.send_update( - session_id_a.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())), - cx, - ); - }); - cx.run_until_parked(); - - // Add a second workspace and activate it (making workspace A the background). - let fs = cx.update(|_, cx| ::global(cx)); - let project_b = project::Project::test(fs, [], cx).await; - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - cx.run_until_parked(); - - // Thread A is still running; no notification yet. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Hello * (running)",] - ); - - // Complete thread A's turn (transition Running → Completed). - connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn); - cx.run_until_parked(); - - // The completed background thread shows a notification indicator. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Hello * (!)",] - ); - } - - fn type_in_search(sidebar: &Entity, query: &str, cx: &mut gpui::VisualTestContext) { - sidebar.update_in(cx, |sidebar, window, cx| { - window.focus(&sidebar.filter_editor.focus_handle(cx), cx); - sidebar.filter_editor.update(cx, |editor, cx| { - editor.set_text(query, window, cx); - }); - }); - cx.run_until_parked(); - } - - #[gpui::test] - async fn test_search_narrows_visible_threads_to_matches(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - for (id, title, hour) in [ - ("t-1", "Fix crash in project panel", 3), - ("t-2", "Add inline diff view", 2), - ("t-3", "Refactor settings module", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in project panel", - " Add inline diff view", - " Refactor settings module", - ] - ); - - // User types "diff" in the search box — only the matching thread remains, - // with its workspace header preserved for context. - type_in_search(&sidebar, "diff", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Add inline diff view <== selected",] - ); - - // User changes query to something with no matches — list is empty. - type_in_search(&sidebar, "nonexistent", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - Vec::::new() - ); - } - - #[gpui::test] - async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) { - // Scenario: A user remembers a thread title but not the exact casing. - // Search should match case-insensitively so they can still find it. - 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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-1")), - "Fix Crash In Project Panel".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - - // Lowercase query matches mixed-case title. - type_in_search(&sidebar, "fix crash", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix Crash In Project Panel <== selected", - ] - ); - - // Uppercase query also matches the same title. - type_in_search(&sidebar, "FIX CRASH", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix Crash In Project Panel <== selected", - ] - ); - } - - #[gpui::test] - async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) { - // Scenario: A user searches, finds what they need, then presses Escape - // to dismiss the filter and see the full list again. - 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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - // Confirm the full list is showing. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Alpha thread", " Beta thread",] - ); - - // User types a search query to filter down. - open_and_focus_sidebar(&sidebar, cx); - type_in_search(&sidebar, "alpha", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Alpha thread <== selected",] - ); - - // User presses Escape — filter clears, full list is restored. - // The selection index (1) now points at the first thread entry. - cx.dispatch_action(Cancel); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Alpha thread <== selected", - " Beta thread", - ] - ); - } - - #[gpui::test] - async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) { - let project_a = init_test_project("/project-a", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - for (id, title, hour) in [ - ("a1", "Fix bug in sidebar", 2), - ("a2", "Add tests for editor", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list_a.clone(), - cx, - ) - .await; - } - - // Add a second workspace. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_test_workspace(window, cx).detach(); - }); - cx.run_until_parked(); - - let path_list_b = PathList::new::(&[]); - - for (id, title, hour) in [ - ("b1", "Refactor sidebar layout", 3), - ("b2", "Fix typo in README", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list_b.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project-a]", - " Fix bug in sidebar", - " Add tests for editor", - ] - ); - - // "sidebar" matches a thread in each workspace — both headers stay visible. - type_in_search(&sidebar, "sidebar", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Fix bug in sidebar <== selected",] - ); - - // "typo" only matches in the second workspace — the first header disappears. - type_in_search(&sidebar, "typo", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - Vec::::new() - ); - - // "project-a" matches the first workspace name — the header appears - // with all child threads included. - type_in_search(&sidebar, "project-a", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project-a]", - " Fix bug in sidebar <== selected", - " Add tests for editor", - ] - ); - } - - #[gpui::test] - async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { - let project_a = init_test_project("/alpha-project", cx).await; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]); - - for (id, title, hour) in [ - ("a1", "Fix bug in sidebar", 2), - ("a2", "Add tests for editor", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list_a.clone(), - cx, - ) - .await; - } - - // Add a second workspace. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_test_workspace(window, cx).detach(); - }); - cx.run_until_parked(); - - let path_list_b = PathList::new::(&[]); - - for (id, title, hour) in [ - ("b1", "Refactor sidebar layout", 3), - ("b2", "Fix typo in README", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list_b.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - // "alpha" matches the workspace name "alpha-project" but no thread titles. - // The workspace header should appear with all child threads included. - type_in_search(&sidebar, "alpha", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [alpha-project]", - " Fix bug in sidebar <== selected", - " Add tests for editor", - ] - ); - - // "sidebar" matches thread titles in both workspaces but not workspace names. - // Both headers appear with their matching threads. - type_in_search(&sidebar, "sidebar", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] - ); - - // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r - // doesn't match) — but does not match either workspace name or any thread. - // Actually let's test something simpler: a query that matches both a workspace - // name AND some threads in that workspace. Matching threads should still appear. - type_in_search(&sidebar, "fix", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] - ); - - // A query that matches a workspace name AND a thread in that same workspace. - // Both the header (highlighted) and all child threads should appear. - type_in_search(&sidebar, "alpha", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [alpha-project]", - " Fix bug in sidebar <== selected", - " Add tests for editor", - ] - ); - - // Now search for something that matches only a workspace name when there - // are also threads with matching titles — the non-matching workspace's - // threads should still appear if their titles match. - type_in_search(&sidebar, "alp", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [alpha-project]", - " Fix bug in sidebar <== selected", - " Add tests for editor", - ] - ); - } - - #[gpui::test] - async fn test_search_finds_threads_hidden_behind_view_more(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - // Create 8 threads. The oldest one has a unique name and will be - // behind View More (only 5 shown by default). - for i in 0..8u32 { - let title = if i == 0 { - "Hidden gem thread".to_string() - } else { - format!("Thread {}", i + 1) - }; - save_thread_metadata( - acp::SessionId::new(Arc::from(format!("thread-{}", i))), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - // Confirm the thread is not visible and View More is shown. - let entries = visible_entries_as_strings(&sidebar, cx); - assert!( - entries.iter().any(|e| e.contains("View More")), - "should have View More button" - ); - assert!( - !entries.iter().any(|e| e.contains("Hidden gem")), - "Hidden gem should be behind View More" - ); - - // User searches for the hidden thread — it appears, and View More is gone. - type_in_search(&sidebar, "hidden gem", cx); - let filtered = visible_entries_as_strings(&sidebar, cx); - assert_eq!( - filtered, - vec!["v [my-project]", " Hidden gem thread <== selected",] - ); - assert!( - !filtered.iter().any(|e| e.contains("View More")), - "View More should not appear when filtering" - ); - } - - #[gpui::test] - async fn test_search_finds_threads_inside_collapsed_groups(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("thread-1")), - "Important thread".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - cx.run_until_parked(); - - // User focuses the sidebar and collapses the group using keyboard: - // manually select the header, then press SelectParent to collapse. - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(0); - }); - cx.dispatch_action(SelectParent); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project] <== selected"] - ); - - // User types a search — the thread appears even though its group is collapsed. - type_in_search(&sidebar, "important", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["> [my-project]", " Important thread <== selected",] - ); - } - - #[gpui::test] - async fn test_search_then_keyboard_navigate_and_confirm(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - for (id, title, hour) in [ - ("t-1", "Fix crash in panel", 3), - ("t-2", "Fix lint warnings", 2), - ("t-3", "Add new feature", 1), - ] { - save_thread_metadata( - acp::SessionId::new(Arc::from(id)), - title.into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - } - cx.run_until_parked(); - - open_and_focus_sidebar(&sidebar, cx); - - // User types "fix" — two threads match. - type_in_search(&sidebar, "fix", cx); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in panel <== selected", - " Fix lint warnings", - ] - ); - - // Selection starts on the first matching thread. User presses - // SelectNext to move to the second match. - cx.dispatch_action(SelectNext); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in panel", - " Fix lint warnings <== selected", - ] - ); - - // User can also jump back with SelectPrevious. - cx.dispatch_action(SelectPrevious); - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [my-project]", - " Fix crash in panel <== selected", - " Fix lint warnings", - ] - ); - } - - #[gpui::test] - async fn test_confirm_on_historical_thread_activates_workspace(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); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.create_test_workspace(window, cx).detach(); - }); - cx.run_until_parked(); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("hist-1")), - "Historical Thread".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - 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]", " Historical Thread",] - ); - - // Switch to workspace 1 so we can verify the confirm switches back. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(1, window, cx); - }); - cx.run_until_parked(); - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1 - ); - - // Confirm on the historical (non-live) thread at index 1. - // Before a previous fix, the workspace field was Option and - // historical threads had None, so activate_thread early-returned - // without switching the workspace. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = Some(1); - sidebar.confirm(&Confirm, window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0 - ); - } - - #[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; - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - save_thread_metadata( - acp::SessionId::new(Arc::from("t-1")), - "Thread A".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - - save_thread_metadata( - acp::SessionId::new(Arc::from("t-2")), - "Thread B".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - path_list.clone(), - cx, - ) - .await; - - 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",] - ); - - // Keyboard confirm preserves selection. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.selection = Some(1); - sidebar.confirm(&Confirm, window, cx); - }); - 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")]); - sidebar.toggle_collapse(&path_list, window, cx); - }); - assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); - - // 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); - }); - assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); - } - - #[gpui::test] - async fn test_thread_title_update_propagates_to_sidebar(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, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Hi there!".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - - let session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id, path_list.clone(), cx).await; - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Hello *"] - ); - - // Simulate the agent generating a title. The notification chain is: - // AcpThread::set_title emits TitleUpdated → - // ConnectionView::handle_thread_event calls cx.notify() → - // AgentPanel observer fires and emits AgentPanelEvent → - // Sidebar subscription calls update_entries / rebuild_contents. - // - // Before the fix, handle_thread_event did NOT call cx.notify() for - // TitleUpdated, so the AgentPanel observer never fired and the - // sidebar kept showing the old title. - let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap()); - thread.update(cx, |thread, cx| { - thread - .set_title("Friendly Greeting with AI".into(), cx) - .detach(); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Friendly Greeting with AI *"] - ); - } - - #[gpui::test] - async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { - let project_a = init_test_project_with_agent_panel("/project-a", cx).await; - let (multi_workspace, cx) = cx - .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - // Save a thread so it appears in the list. - let connection_a = StubAgentConnection::new(); - connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel_a, connection_a, cx); - send_message(&panel_a, cx); - let session_id_a = active_session_id(&panel_a, cx); - save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; - - // Add a second workspace with its own agent panel. - let fs = cx.update(|_, cx| ::global(cx)); - fs.as_fake() - .insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await; - let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b.clone(), window, cx) - }); - let panel_b = add_agent_panel(&workspace_b, &project_b, cx); - cx.run_until_parked(); - - let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); - - // ── 1. Initial state: focused thread derived from active panel ───── - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "The active panel's thread should be focused on startup" - ); - }); - - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id_a.clone(), - work_dirs: None, - title: Some("Test".into()), - updated_at: None, - created_at: None, - meta: None, - }, - &workspace_a, - window, - cx, - ); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "After clicking a thread, it should be the focused thread" - ); - assert!( - has_thread_entry(sidebar, &session_id_a), - "The clicked thread should be present in the entries" - ); - }); - - workspace_a.read_with(cx, |workspace, cx| { - assert!( - workspace.panel::(cx).is_some(), - "Agent panel should exist" - ); - let dock = workspace.right_dock().read(cx); - assert!( - dock.is_open(), - "Clicking a thread should open the agent panel dock" - ); - }); - - let connection_b = StubAgentConnection::new(); - connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Thread B".into()), - )]); - open_thread_with_connection(&panel_b, connection_b, cx); - send_message(&panel_b, cx); - let session_id_b = active_session_id(&panel_b, cx); - let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); - save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await; - cx.run_until_parked(); - - // Workspace A is currently active. Click a thread in workspace B, - // which also triggers a workspace switch. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id_b.clone(), - work_dirs: None, - title: Some("Thread B".into()), - updated_at: None, - created_at: None, - meta: None, - }, - &workspace_b, - window, - cx, - ); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b), - "Clicking a thread in another workspace should focus that thread" - ); - assert!( - has_thread_entry(sidebar, &session_id_b), - "The cross-workspace thread should be present in the entries" - ); - }); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "Switching workspace should seed focused_thread from the new active panel" - ); - assert!( - has_thread_entry(sidebar, &session_id_a), - "The seeded thread should be present in the entries" - ); - }); - - let connection_b2 = StubAgentConnection::new(); - connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()), - )]); - open_thread_with_connection(&panel_b, connection_b2, cx); - send_message(&panel_b, cx); - let session_id_b2 = active_session_id(&panel_b, cx); - save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await; - cx.run_until_parked(); - - // Panel B is not the active workspace's panel (workspace A is - // active), so opening a thread there should not change focused_thread. - // This prevents running threads in background workspaces from causing - // the selection highlight to jump around. - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "Opening a thread in a non-active panel should not change focused_thread" - ); - }); - - workspace_b.update_in(cx, |workspace, window, cx| { - workspace.focus_handle(cx).focus(window, cx); - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_a), - "Defocusing the sidebar should not change focused_thread" - ); - }); - - // Switching workspaces via the multi_workspace (simulates clicking - // a workspace header) should clear focused_thread. - multi_workspace.update_in(cx, |mw, window, cx| { - if let Some(index) = mw.workspaces().iter().position(|w| w == &workspace_b) { - mw.activate_index(index, window, cx); - } - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b2), - "Switching workspace should seed focused_thread from the new active panel" - ); - assert!( - has_thread_entry(sidebar, &session_id_b2), - "The seeded thread should be present in the entries" - ); - }); - - // ── 8. Focusing the agent panel thread keeps focused_thread ──── - // Workspace B still has session_id_b2 loaded in the agent panel. - // Clicking into the thread (simulated by focusing its view) should - // keep focused_thread since it was already seeded on workspace switch. - panel_b.update_in(cx, |panel, window, cx| { - if let Some(thread_view) = panel.active_conversation_view() { - thread_view.read(cx).focus_handle(cx).focus(window, cx); - } - }); - cx.run_until_parked(); - - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id_b2), - "Focusing the agent panel thread should set focused_thread" - ); - assert!( - has_thread_entry(sidebar, &session_id_b2), - "The focused thread should be present in the entries" - ); - }); - } - - #[gpui::test] - async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) { - let project = init_test_project_with_agent_panel("/project-a", cx).await; - let fs = cx.update(|cx| ::global(cx)); - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); - let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); - - // Start a thread and send a message so it has history. - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - let session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await; - cx.run_until_parked(); - - // Verify the thread appears in the sidebar. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project-a]", " Hello *",] - ); - - // The "New Thread" button should NOT be in "active/draft" state - // because the panel has a thread with messages. - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - !sidebar.active_thread_is_draft, - "Panel has a thread with messages, so it should not be a draft" - ); - }); - - // Now add a second folder to the workspace, changing the path_list. - fs.as_fake() - .insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - project - .update(cx, |project, cx| { - project.find_or_create_worktree("/project-b", true, cx) - }) - .await - .expect("should add worktree"); - cx.run_until_parked(); - - // The workspace path_list is now [project-a, project-b]. The old - // thread was stored under [project-a], so it no longer appears in - // the sidebar list for this workspace. - let entries = visible_entries_as_strings(&sidebar, cx); - assert!( - !entries.iter().any(|e| e.contains("Hello")), - "Thread stored under the old path_list should not appear: {:?}", - entries - ); - - // The "New Thread" button must still be clickable (not stuck in - // "active/draft" state). Verify that `active_thread_is_draft` is - // false — the panel still has the old thread with messages. - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - !sidebar.active_thread_is_draft, - "After adding a folder the panel still has a thread with messages, \ - so active_thread_is_draft should be false" - ); - }); - - // Actually click "New Thread" by calling create_new_thread and - // verify a new draft is created. - let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.create_new_thread(&workspace, window, cx); - }); - cx.run_until_parked(); - - // After creating a new thread, the panel should now be in draft - // state (no messages on the new thread). - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - sidebar.active_thread_is_draft, - "After creating a new thread the panel should be in draft state" - ); - }); - } - - #[gpui::test] - async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { - // When the user presses Cmd-N (NewThread action) while viewing a - // non-empty thread, the sidebar should show the "New Thread" entry. - // This exercises the same code path as the workspace action handler - // (which bypasses the sidebar's create_new_thread method). - 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, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); - - let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); - - // Create a non-empty thread (has messages). - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&panel, connection, cx); - send_message(&panel, cx); - - let session_id = active_session_id(&panel, cx); - save_test_thread_metadata(&session_id, path_list.clone(), cx).await; - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " Hello *"] - ); - - // Simulate cmd-n - let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); - panel.update_in(cx, |panel, window, cx| { - panel.new_thread(&NewThread, window, cx); - }); - workspace.update_in(cx, |workspace, window, cx| { - workspace.focus_panel::(window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [my-project]", " [+ New Thread]", " Hello *"], - "After Cmd-N the sidebar should show a highlighted New Thread entry" - ); - - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - sidebar.focused_thread.is_none(), - "focused_thread should be cleared after Cmd-N" - ); - assert!( - sidebar.active_thread_is_draft, - "the new blank thread should be a draft" - ); - }); - } - - #[gpui::test] - async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) { - // When the active workspace is an absorbed git worktree, cmd-n - // should still show the "New Thread" entry under the main repo's - // header and highlight it as active. - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - - // Main repo with a linked worktree. - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - // Worktree checkout pointing back to the main repo. - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); - - // Switch to the worktree workspace. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(1, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Create a non-empty thread in the worktree workspace. - let connection = StubAgentConnection::new(); - connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk::new("Done".into()), - )]); - open_thread_with_connection(&worktree_panel, connection, cx); - send_message(&worktree_panel, cx); - - let session_id = active_session_id(&worktree_panel, cx); - let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_test_thread_metadata(&session_id, wt_path_list, cx).await; - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Hello {wt-feature-a} *"] - ); - - // Simulate Cmd-N in the worktree workspace. - worktree_panel.update_in(cx, |panel, window, cx| { - panel.new_thread(&NewThread, window, cx); - }); - worktree_workspace.update_in(cx, |workspace, window, cx| { - workspace.focus_panel::(window, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " [+ New Thread]", - " Hello {wt-feature-a} *" - ], - "After Cmd-N in an absorbed worktree, the sidebar should show \ - a highlighted New Thread entry under the main repo header" - ); - - sidebar.read_with(cx, |sidebar, _cx| { - assert!( - sidebar.focused_thread.is_none(), - "focused_thread should be cleared after Cmd-N" - ); - assert!( - sidebar.active_thread_is_draft, - "the new blank thread should be a draft" - ); - }); - } - - async fn init_test_project_with_git( - worktree_path: &str, - cx: &mut TestAppContext, - ) -> (Entity, Arc) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - worktree_path, - serde_json::json!({ - ".git": {}, - "src": {}, - }), - ) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await; - (project, fs) - } - - #[gpui::test] - async fn test_search_matches_worktree_name(cx: &mut TestAppContext) { - let (project, fs) = init_test_project_with_git("/project", cx).await; - - fs.as_fake() - .with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt/rosewood"), - ref_name: Some("refs/heads/rosewood".into()), - sha: "abc".into(), - }); - }) - .unwrap(); - - 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 sidebar = setup_sidebar(&multi_workspace, cx); - - let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]); - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); - save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await; - save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Search for "rosewood" — should match the worktree name, not the title. - type_in_search(&sidebar, "rosewood", cx); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Fix Bug {rosewood} <== selected"], - ); - } - - #[gpui::test] - async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { - let (project, fs) = init_test_project_with_git("/project", 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 sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread against a worktree path that doesn't exist yet. - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); - save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Thread is not visible yet — no worktree knows about this path. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " [+ New Thread]"] - ); - - // Now add the worktree to the git state and trigger a rescan. - fs.as_fake() - .with_git_state(std::path::Path::new("/project/.git"), true, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt/rosewood"), - ref_name: Some("refs/heads/rosewood".into()), - sha: "abc".into(), - }); - }) - .unwrap(); - - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Worktree Thread {rosewood}",] - ); - } - - #[gpui::test] - async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - // Create the main repo directory (not opened as a workspace yet). - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - "feature-b": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-b", - }, - }, - }, - "src": {}, - }), - ) - .await; - - // Two worktree checkouts whose .git files point back to the main repo. - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - fs.insert_tree( - "/wt-feature-b", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-b", - "src": {}, - }), - ) - .await; - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await; - - project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await; - project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await; - - // Open both worktrees as workspaces — no main repo yet. - let (multi_workspace, cx) = cx - .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b.clone(), window, cx); - }); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]); - save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; - save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Without the main repo, each worktree has its own header. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [wt-feature-a]", - " Thread A", - "v [wt-feature-b]", - " Thread B", - ] - ); - - // Configure the main repo to list both worktrees before opening - // it so the initial git scan picks them up. - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-b"), - ref_name: Some("refs/heads/feature-b".into()), - sha: "bbb".into(), - }); - }) - .unwrap(); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(main_project.clone(), window, cx); - }); - cx.run_until_parked(); - - // Both worktree workspaces should now be absorbed under the main - // repo header, with worktree chips. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec![ - "v [project]", - " Thread A {wt-feature-a}", - " Thread B {wt-feature-b}", - ] - ); - - // Remove feature-b from the main repo's linked worktrees. - // The feature-b workspace should be pruned automatically. - fs.with_git_state(std::path::Path::new("/project/.git"), true, |state| { - state - .worktrees - .retain(|wt| wt.path != std::path::Path::new("/wt-feature-b")); - }) - .unwrap(); - - cx.run_until_parked(); - - // feature-b's workspace is pruned; feature-a remains absorbed - // under the main repo. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Thread A {wt-feature-a}",] - ); - } - - #[gpui::test] - async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) { - // When a worktree workspace is absorbed under the main repo, a - // running thread in the worktree's agent panel should still show - // live status (spinner + "(running)") in the sidebar. - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - - // Main repo with a linked worktree. - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - // Worktree checkout pointing back to the main repo. - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - // Create the MultiWorkspace with both projects. - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - // Add an agent panel to the worktree workspace so we can run a - // thread inside it. - let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); - - // Switch back to the main workspace before setting up the sidebar. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Start a thread in the worktree workspace's panel and keep it - // generating (don't resolve it). - let connection = StubAgentConnection::new(); - open_thread_with_connection(&worktree_panel, connection.clone(), cx); - send_message(&worktree_panel, cx); - - let session_id = active_session_id(&worktree_panel, cx); - - // Save metadata so the sidebar knows about this thread. - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_test_thread_metadata(&session_id, wt_paths, cx).await; - - // Keep the thread generating by sending a chunk without ending - // the turn. - cx.update(|_, cx| { - connection.send_update( - session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), - cx, - ); - }); - cx.run_until_parked(); - - // The worktree thread should be absorbed under the main project - // and show live running status. - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!( - entries, - vec!["v [project]", " Hello {wt-feature-a} * (running)",] - ); - } - - #[gpui::test] - async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) { - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - let connection = StubAgentConnection::new(); - open_thread_with_connection(&worktree_panel, connection.clone(), cx); - send_message(&worktree_panel, cx); - - let session_id = active_session_id(&worktree_panel, cx); - let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_test_thread_metadata(&session_id, wt_paths, cx).await; - - cx.update(|_, cx| { - connection.send_update( - session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), - cx, - ); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Hello {wt-feature-a} * (running)",] - ); - - connection.end_turn(session_id, acp::StopReason::EndTurn); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " Hello {wt-feature-a} * (!)",] - ); - } - - #[gpui::test] - async fn test_clicking_worktree_thread_opens_workspace_when_none_exists( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - // Only open the main repo — no workspace for the worktree. - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread for the worktree path (no workspace for it). - let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // Thread should appear under the main repo with a worktree chip. - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " WT Thread {wt-feature-a}"], - ); - - // Only 1 workspace should exist. - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), - 1, - ); - - // Focus the sidebar and select the worktree thread. - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(1); // index 0 is header, 1 is the thread - }); - - // Confirm to open the worktree thread. - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // A new workspace should have been created for the worktree path. - let new_workspace = multi_workspace.read_with(cx, |mw, _| { - assert_eq!( - mw.workspaces().len(), - 2, - "confirming a worktree thread without a workspace should open one", - ); - mw.workspaces()[1].clone() - }); - - let new_path_list = - new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx)); - assert_eq!( - new_path_list, - PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]), - "the new workspace should have been opened for the worktree path", - ); - } - - #[gpui::test] - async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - let sidebar = setup_sidebar(&multi_workspace, cx); - - let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&sidebar, cx), - vec!["v [project]", " WT Thread {wt-feature-a}"], - ); - - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(1); - }); - - let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context| { - let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| { - if let ListEntry::ProjectHeader { label, .. } = entry { - Some(label.as_ref()) - } else { - None - } - }); - - let Some(project_header) = project_headers.next() else { - panic!("expected exactly one sidebar project header named `project`, found none"); - }; - assert_eq!( - project_header, "project", - "expected the only sidebar project header to be `project`" - ); - if let Some(unexpected_header) = project_headers.next() { - panic!( - "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`" - ); - } - - let mut saw_expected_thread = false; - for entry in &sidebar.contents.entries { - match entry { - ListEntry::ProjectHeader { label, .. } => { - assert_eq!( - label.as_ref(), - "project", - "expected the only sidebar project header to be `project`" - ); - } - ListEntry::Thread(thread) - if thread - .session_info - .title - .as_ref() - .map(|title| title.as_ref()) - == Some("WT Thread") - && thread.worktree_name.as_ref().map(|name| name.as_ref()) - == Some("wt-feature-a") => - { - saw_expected_thread = true; - } - ListEntry::Thread(thread) => { - let title = thread - .session_info - .title - .as_ref() - .map(|title| title.as_ref()) - .unwrap_or("Untitled"); - let worktree_name = thread - .worktree_name - .as_ref() - .map(|name| name.as_ref()) - .unwrap_or(""); - panic!( - "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`" - ); - } - ListEntry::ViewMore { .. } => { - panic!("unexpected `View More` entry while opening linked worktree thread"); - } - ListEntry::NewThread { .. } => { - panic!( - "unexpected `New Thread` entry while opening linked worktree thread" - ); - } - } - } - - assert!( - saw_expected_thread, - "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`" - ); - }; - - sidebar - .update(cx, |_, cx| cx.observe_self(assert_sidebar_state)) - .detach(); - - let window = cx.windows()[0]; - cx.update_window(window, |_, window, cx| { - window.dispatch_action(Confirm.boxed_clone(), cx); - }) - .unwrap(); - - cx.run_until_parked(); - - sidebar.update(cx, assert_sidebar_state); - } - - #[gpui::test] - async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - // Activate the main workspace before setting up the sidebar. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]); - let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); - save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await; - save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; - - multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); - cx.run_until_parked(); - - // The worktree workspace should be absorbed under the main repo. - let entries = visible_entries_as_strings(&sidebar, cx); - assert_eq!(entries.len(), 3); - assert_eq!(entries[0], "v [project]"); - assert!(entries.contains(&" Main Thread".to_string())); - assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string())); - - let wt_thread_index = entries - .iter() - .position(|e| e.contains("WT Thread")) - .expect("should find the worktree thread entry"); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0, - "main workspace should be active initially" - ); - - // Focus the sidebar and select the absorbed worktree thread. - open_and_focus_sidebar(&sidebar, cx); - sidebar.update_in(cx, |sidebar, _window, _cx| { - sidebar.selection = Some(wt_thread_index); - }); - - // Confirm to activate the worktree thread. - cx.dispatch_action(Confirm); - cx.run_until_parked(); - - // The worktree workspace should now be active, not the main one. - let active_workspace = multi_workspace.read_with(cx, |mw, _| { - mw.workspaces()[mw.active_workspace_index()].clone() - }); - assert_eq!( - active_workspace, worktree_workspace, - "clicking an absorbed worktree thread should activate the worktree workspace" - ); - } - - #[gpui::test] - async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace( - cx: &mut TestAppContext, - ) { - // Thread has saved metadata in ThreadStore. A matching workspace is - // already open. Expected: activates the matching workspace. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread with path_list pointing to project-b. - let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); - let session_id = acp::SessionId::new(Arc::from("archived-1")); - save_test_thread_metadata(&session_id, path_list_b.clone(), cx).await; - - // Ensure workspace A is active. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - cx.run_until_parked(); - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0 - ); - - // Call activate_archived_thread – should resolve saved paths and - // switch to the workspace for project-b. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), - title: Some("Archived Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1, - "should have activated the workspace matching the saved path_list" - ); - } - - #[gpui::test] - async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace( - cx: &mut TestAppContext, - ) { - // Thread has no saved metadata but session_info has cwd. A matching - // workspace is open. Expected: uses cwd to find and activate it. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Start with workspace A active. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - cx.run_until_parked(); - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 0 - ); - - // No thread saved to the store – cwd is the only path hint. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("unknown-session")), - work_dirs: Some(PathList::new(&[std::path::PathBuf::from("/project-b")])), - title: Some("CWD Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1, - "should have activated the workspace matching the cwd" - ); - } - - #[gpui::test] - async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace( - cx: &mut TestAppContext, - ) { - // Thread has no saved metadata and no cwd. Expected: falls back to - // the currently active workspace. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - - multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(project_b, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Activate workspace B (index 1) to make it the active one. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(1, window, cx); - }); - cx.run_until_parked(); - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1 - ); - - // No saved thread, no cwd – should fall back to the active workspace. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: acp::SessionId::new(Arc::from("no-context-session")), - work_dirs: None, - title: Some("Contextless Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), - 1, - "should have stayed on the active workspace when no path info is available" - ); - } - - #[gpui::test] - async fn test_activate_archived_thread_saved_paths_opens_new_workspace( - cx: &mut TestAppContext, - ) { - // Thread has saved metadata pointing to a path with no open workspace. - // Expected: opens a new workspace for that path. - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - - let (multi_workspace, cx) = - cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - // Save a thread with path_list pointing to project-b – which has no - // open workspace. - let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); - let session_id = acp::SessionId::new(Arc::from("archived-new-ws")); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), - 1, - "should start with one workspace" - ); - - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(path_list_b), - title: Some("New WS Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx.run_until_parked(); - - assert_eq!( - multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), - 2, - "should have opened a second workspace for the archived thread's saved paths" - ); - } - - #[gpui::test] - async fn test_activate_archived_thread_reuses_workspace_in_another_window( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; - - let multi_workspace_a = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - let multi_workspace_b = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); - - let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); - - let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); - let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a); - - let session_id = acp::SessionId::new(Arc::from("archived-cross-window")); - - sidebar.update_in(cx_a, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), - title: Some("Cross Window Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx_a.run_until_parked(); - - assert_eq!( - multi_workspace_a - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "should not add the other window's workspace into the current window" - ); - assert_eq!( - multi_workspace_b - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "should reuse the existing workspace in the other window" - ); - assert!( - cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b, - "should activate the window that already owns the matching workspace" - ); - sidebar.read_with(cx_a, |sidebar, _| { - assert_eq!( - sidebar.focused_thread, None, - "source window's sidebar should not eagerly claim focus for a thread opened in another window" - ); - }); - } - - #[gpui::test] - async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; - - let multi_workspace_a = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - let multi_workspace_b = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx)); - - let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); - let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap(); - - let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); - let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); - - let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx); - let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b); - let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone()); - let _panel_b = add_agent_panel(&workspace_b, &project_b, cx_b); - - let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar")); - - sidebar_a.update_in(cx_a, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), - title: Some("Cross Window Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx_a.run_until_parked(); - - assert_eq!( - multi_workspace_a - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "should not add the other window's workspace into the current window" - ); - assert_eq!( - multi_workspace_b - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "should reuse the existing workspace in the other window" - ); - assert!( - cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b, - "should activate the window that already owns the matching workspace" - ); - sidebar_a.read_with(cx_a, |sidebar, _| { - assert_eq!( - sidebar.focused_thread, None, - "source window's sidebar should not eagerly claim focus for a thread opened in another window" - ); - }); - sidebar_b.read_with(cx_b, |sidebar, _| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id), - "target window's sidebar should eagerly focus the activated archived thread" - ); - }); - } - - #[gpui::test] - async fn test_activate_archived_thread_prefers_current_window_for_matching_paths( - cx: &mut TestAppContext, - ) { - init_test(cx); - let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) - .await; - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; - - let multi_workspace_b = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); - let multi_workspace_a = - cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); - - let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); - - let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); - let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); - - let session_id = acp::SessionId::new(Arc::from("archived-current-window")); - - sidebar_a.update_in(cx_a, |sidebar, window, cx| { - sidebar.activate_archived_thread( - Agent::NativeAgent, - acp_thread::AgentSessionInfo { - session_id: session_id.clone(), - work_dirs: Some(PathList::new(&[PathBuf::from("/project-a")])), - title: Some("Current Window Thread".into()), - updated_at: None, - created_at: None, - meta: None, - }, - window, - cx, - ); - }); - cx_a.run_until_parked(); - - assert!( - cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a, - "should keep activation in the current window when it already has a matching workspace" - ); - sidebar_a.read_with(cx_a, |sidebar, _| { - assert_eq!( - sidebar.focused_thread.as_ref(), - Some(&session_id), - "current window's sidebar should eagerly focus the activated archived thread" - ); - }); - assert_eq!( - multi_workspace_a - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "current window should continue reusing its existing workspace" - ); - assert_eq!( - multi_workspace_b - .read_with(cx_a, |mw, _| mw.workspaces().len()) - .unwrap(), - 1, - "other windows should not be activated just because they also match the saved paths" - ); - } - - #[gpui::test] - async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) { - // Regression test: archive_thread previously always loaded the next thread - // through group_workspace (the main workspace's ProjectHeader), even when - // the next thread belonged to an absorbed linked-worktree workspace. That - // caused the worktree thread to be loaded in the main panel, which bound it - // to the main project and corrupted its stored folder_paths. - // - // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available, - // falling back to group_workspace only for Closed workspaces. - agent_ui::test_support::init_test(cx); - cx.update(|cx| { - cx.update_flags(false, vec!["agent-v2".into()]); - ThreadStore::init_global(cx); - SidebarThreadMetadataStore::init_global(cx); - language_model::LanguageModelRegistry::test(cx); - prompt_store::init(cx); - }); - - let fs = FakeFs::new(cx.executor()); - - fs.insert_tree( - "/project", - serde_json::json!({ - ".git": { - "worktrees": { - "feature-a": { - "commondir": "../../", - "HEAD": "ref: refs/heads/feature-a", - }, - }, - }, - "src": {}, - }), - ) - .await; - - fs.insert_tree( - "/wt-feature-a", - serde_json::json!({ - ".git": "gitdir: /project/.git/worktrees/feature-a", - "src": {}, - }), - ) - .await; - - fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { - state.worktrees.push(git::repository::Worktree { - path: std::path::PathBuf::from("/wt-feature-a"), - ref_name: Some("refs/heads/feature-a".into()), - sha: "aaa".into(), - }); - }) - .unwrap(); - - cx.update(|cx| ::set_global(fs.clone(), cx)); - - let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; - let worktree_project = - project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; - - main_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - worktree_project - .update(cx, |p, cx| p.git_scans_complete(cx)) - .await; - - let (multi_workspace, cx) = cx.add_window_view(|window, cx| { - MultiWorkspace::test_new(main_project.clone(), window, cx) - }); - - let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { - mw.test_add_workspace(worktree_project.clone(), window, cx) - }); - - // Activate main workspace so the sidebar tracks the main panel. - multi_workspace.update_in(cx, |mw, window, cx| { - mw.activate_index(0, window, cx); - }); - - let sidebar = setup_sidebar(&multi_workspace, cx); - - let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces()[0].clone()); - let main_panel = add_agent_panel(&main_workspace, &main_project, cx); - let _worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); - - // Open Thread 2 in the main panel and keep it running. - let connection = StubAgentConnection::new(); - open_thread_with_connection(&main_panel, connection.clone(), cx); - send_message(&main_panel, cx); - - let thread2_session_id = active_session_id(&main_panel, cx); - - cx.update(|_, cx| { - connection.send_update( - thread2_session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), - cx, - ); - }); - - // Save thread 2's metadata with a newer timestamp so it sorts above thread 1. - save_thread_metadata( - thread2_session_id.clone(), - "Thread 2".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), - PathList::new(&[std::path::PathBuf::from("/project")]), - cx, - ) - .await; - - // Save thread 1's metadata with the worktree path and an older timestamp so - // it sorts below thread 2. archive_thread will find it as the "next" candidate. - let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session")); - save_thread_metadata( - thread1_session_id.clone(), - "Thread 1".into(), - chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), - PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]), - cx, - ) - .await; - - cx.run_until_parked(); - - // Verify the sidebar absorbed thread 1 under [project] with the worktree chip. - let entries_before = visible_entries_as_strings(&sidebar, cx); - assert!( - entries_before.iter().any(|s| s.contains("{wt-feature-a}")), - "Thread 1 should appear with the linked-worktree chip before archiving: {:?}", - entries_before - ); - - // The sidebar should track T2 as the focused thread (derived from the - // main panel's active view). - let focused = sidebar.read_with(cx, |s, _| s.focused_thread.clone()); - assert_eq!( - focused, - Some(thread2_session_id.clone()), - "focused thread should be Thread 2 before archiving: {:?}", - focused - ); - - // Archive thread 2. - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.archive_thread(&thread2_session_id, window, cx); - }); - - cx.run_until_parked(); - - // The main panel's active thread must still be thread 2. - let main_active = main_panel.read_with(cx, |panel, cx| { - panel - .active_agent_thread(cx) - .map(|t| t.read(cx).session_id().clone()) - }); - assert_eq!( - main_active, - Some(thread2_session_id.clone()), - "main panel should not have been taken over by loading the linked-worktree thread T1; \ - before the fix, archive_thread used group_workspace instead of next.workspace, \ - causing T1 to be loaded in the wrong panel" - ); - - // Thread 1 should still appear in the sidebar with its worktree chip - // (Thread 2 was archived so it is gone from the list). - let entries_after = visible_entries_as_strings(&sidebar, cx); - assert!( - entries_after.iter().any(|s| s.contains("{wt-feature-a}")), - "T1 should still carry its linked-worktree chip after archiving T2: {:?}", - entries_after - ); - } + Some(threads).into_iter().flatten() } diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..8170a2956886f1bc0b90acd2f83b5a9ccd2c979b --- /dev/null +++ b/crates/sidebar/src/sidebar_tests.rs @@ -0,0 +1,4701 @@ +use super::*; +use acp_thread::StubAgentConnection; +use agent::ThreadStore; +use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message}; +use assistant_text_thread::TextThreadStore; +use chrono::DateTime; +use feature_flags::FeatureFlagAppExt as _; +use fs::FakeFs; +use gpui::TestAppContext; +use pretty_assertions::assert_eq; +use settings::SettingsStore; +use std::{path::PathBuf, sync::Arc}; +use util::path_list::PathList; + +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); +} + +fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { + sidebar.contents.entries.iter().any( + |entry| matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id), + ) +} + +async fn init_test_project( + worktree_path: &str, + cx: &mut TestAppContext, +) -> Entity { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + project::Project::test(fs, [worktree_path.as_ref()], cx).await +} + +fn setup_sidebar( + multi_workspace: &Entity, + cx: &mut gpui::VisualTestContext, +) -> Entity { + let multi_workspace = multi_workspace.clone(); + let sidebar = + cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx))); + multi_workspace.update(cx, |mw, cx| { + mw.register_sidebar(sidebar.clone(), cx); + }); + cx.run_until_parked(); + sidebar +} + +async fn save_n_test_threads(count: u32, path_list: &PathList, cx: &mut gpui::VisualTestContext) { + for i in 0..count { + save_thread_metadata( + acp::SessionId::new(Arc::from(format!("thread-{}", i))), + format!("Thread {}", i + 1).into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); +} + +async fn save_test_thread_metadata( + session_id: &acp::SessionId, + path_list: PathList, + cx: &mut TestAppContext, +) { + save_thread_metadata( + session_id.clone(), + "Test".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list, + cx, + ) + .await; +} + +async fn save_named_thread_metadata( + session_id: &str, + title: &str, + path_list: &PathList, + cx: &mut gpui::VisualTestContext, +) { + save_thread_metadata( + acp::SessionId::new(Arc::from(session_id)), + SharedString::from(title.to_string()), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); +} + +async fn save_thread_metadata( + session_id: acp::SessionId, + title: SharedString, + updated_at: DateTime, + path_list: PathList, + cx: &mut TestAppContext, +) { + let metadata = ThreadMetadata { + session_id, + agent_id: None, + title, + updated_at, + created_at: None, + folder_paths: path_list, + }; + cx.update(|cx| { + SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)) + }); + cx.run_until_parked(); +} + +fn open_and_focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { + let multi_workspace = sidebar.read_with(cx, |s, _| s.multi_workspace.upgrade()); + if let Some(multi_workspace) = multi_workspace { + multi_workspace.update_in(cx, |mw, window, cx| { + if !mw.sidebar_open() { + mw.toggle_sidebar(window, cx); + } + }); + } + cx.run_until_parked(); + sidebar.update_in(cx, |_, window, cx| { + cx.focus_self(window); + }); + cx.run_until_parked(); +} + +fn visible_entries_as_strings( + sidebar: &Entity, + cx: &mut gpui::VisualTestContext, +) -> Vec { + sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .enumerate() + .map(|(ix, entry)| { + let selected = if sidebar.selection == Some(ix) { + " <== selected" + } else { + "" + }; + match entry { + ListEntry::ProjectHeader { + label, + path_list, + highlight_positions: _, + .. + } => { + let icon = if sidebar.collapsed_groups.contains(path_list) { + ">" + } else { + "v" + }; + format!("{} [{}]{}", icon, label, selected) + } + ListEntry::Thread(thread) => { + let title = thread + .session_info + .title + .as_ref() + .map(|s| s.as_ref()) + .unwrap_or("Untitled"); + let active = if thread.is_live { " *" } else { "" }; + let status_str = match thread.status { + AgentThreadStatus::Running => " (running)", + AgentThreadStatus::Error => " (error)", + AgentThreadStatus::WaitingForConfirmation => " (waiting)", + _ => "", + }; + let notified = if sidebar + .contents + .is_thread_notified(&thread.session_info.session_id) + { + " (!)" + } else { + "" + }; + let worktree = if thread.worktrees.is_empty() { + String::new() + } else { + let mut seen = Vec::new(); + let mut chips = Vec::new(); + for wt in &thread.worktrees { + if !seen.contains(&wt.name) { + seen.push(wt.name.clone()); + chips.push(format!("{{{}}}", wt.name)); + } + } + format!(" {}", chips.join(", ")) + }; + format!( + " {}{}{}{}{}{}", + title, worktree, active, status_str, notified, selected + ) + } + ListEntry::ViewMore { + is_fully_expanded, .. + } => { + if *is_fully_expanded { + format!(" - Collapse{}", selected) + } else { + format!(" + View More{}", selected) + } + } + ListEntry::NewThread { .. } => { + format!(" [+ New Thread]{}", selected) + } + } + }) + .collect() + }) +} + +#[test] +fn test_clean_mention_links() { + // Simple mention link + assert_eq!( + Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"), + "check @Button.tsx" + ); + + // Multiple mention links + assert_eq!( + Sidebar::clean_mention_links( + "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)" + ), + "look at @foo.rs and @bar.rs" + ); + + // No mention links — passthrough + assert_eq!( + Sidebar::clean_mention_links("plain text with no mentions"), + "plain text with no mentions" + ); + + // Incomplete link syntax — preserved as-is + assert_eq!( + Sidebar::clean_mention_links("broken [@mention without closing"), + "broken [@mention without closing" + ); + + // Regular markdown link (no @) — not touched + assert_eq!( + Sidebar::clean_mention_links("see [docs](https://example.com)"), + "see [docs](https://example.com)" + ); + + // Empty input + assert_eq!(Sidebar::clean_mention_links(""), ""); +} + +#[gpui::test] +async fn test_entities_released_on_window_close(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 weak_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().downgrade()); + let weak_sidebar = sidebar.downgrade(); + let weak_multi_workspace = multi_workspace.downgrade(); + + drop(sidebar); + drop(multi_workspace); + cx.update(|window, _cx| window.remove_window()); + cx.run_until_parked(); + + weak_multi_workspace.assert_released(); + weak_sidebar.assert_released(); + weak_workspace.assert_released(); +} + +#[gpui::test] +async fn test_single_workspace_no_threads(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); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [+ New Thread]"] + ); +} + +#[gpui::test] +async fn test_single_workspace_with_saved_threads(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Fix crash in project panel".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-2")), + "Add inline diff view".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + 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]", + " Fix crash in project panel", + " Add inline diff view", + ] + ); +} + +#[gpui::test] +async fn test_workspace_lifecycle(cx: &mut TestAppContext) { + let project = init_test_project("/project-a", 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); + + // Single workspace with a thread + let path_list = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-a1")), + "Thread A1".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + 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 [project-a]", " Thread A1"] + ); + + // Add a second workspace + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_test_workspace(window, cx).detach(); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1",] + ); + + // Remove the second workspace + multi_workspace.update_in(cx, |mw, window, cx| { + mw.remove_workspace(1, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Thread A1"] + ); +} + +#[gpui::test] +async fn test_view_more_pagination(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(12, &path_list, cx).await; + + 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 12", + " Thread 11", + " Thread 10", + " Thread 9", + " Thread 8", + " + View More", + ] + ); +} + +#[gpui::test] +async fn test_view_more_batched_expansion(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + // Create 17 threads: initially shows 5, then 10, then 15, then all 17 with Collapse + save_n_test_threads(17, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Initially shows 5 threads + View More + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 7); // header + 5 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); + + // Focus and navigate to View More, then confirm to expand by one batch + open_and_focus_sidebar(&sidebar, cx); + for _ in 0..7 { + cx.dispatch_action(SelectNext); + } + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // Now shows 10 threads + View More + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 12); // header + 10 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); + + // Expand again by one batch + sidebar.update_in(cx, |s, _window, cx| { + let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); + s.expanded_groups.insert(path_list.clone(), current + 1); + s.update_entries(cx); + }); + cx.run_until_parked(); + + // Now shows 15 threads + View More + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 17); // header + 15 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); + + // Expand one more time - should show all 17 threads with Collapse button + sidebar.update_in(cx, |s, _window, cx| { + let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); + s.expanded_groups.insert(path_list.clone(), current + 1); + s.update_entries(cx); + }); + cx.run_until_parked(); + + // All 17 threads shown with Collapse button + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 19); // header + 17 threads + Collapse + assert!(!entries.iter().any(|e| e.contains("View More"))); + assert!(entries.iter().any(|e| e.contains("Collapse"))); + + // Click collapse - should go back to showing 5 threads + sidebar.update_in(cx, |s, _window, cx| { + s.expanded_groups.remove(&path_list); + s.update_entries(cx); + }); + cx.run_until_parked(); + + // Back to initial state: 5 threads + View More + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 7); // header + 5 threads + View More + assert!(entries.iter().any(|e| e.contains("View More"))); +} + +#[gpui::test] +async fn test_collapse_and_expand_group(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + + 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 1"] + ); + + // Collapse + sidebar.update_in(cx, |s, window, cx| { + s.toggle_collapse(&path_list, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]"] + ); + + // Expand + sidebar.update_in(cx, |s, window, cx| { + s.toggle_collapse(&path_list, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1"] + ); +} + +#[gpui::test] +async fn test_visible_entries_as_strings(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, |mw, _| mw.workspace().clone()); + let expanded_path = PathList::new(&[std::path::PathBuf::from("/expanded")]); + let collapsed_path = PathList::new(&[std::path::PathBuf::from("/collapsed")]); + + sidebar.update_in(cx, |s, _window, _cx| { + s.collapsed_groups.insert(collapsed_path.clone()); + s.contents + .notified_threads + .insert(acp::SessionId::new(Arc::from("t-5"))); + s.contents.entries = vec![ + // Expanded project header + ListEntry::ProjectHeader { + path_list: expanded_path.clone(), + label: "expanded-project".into(), + workspace: workspace.clone(), + highlight_positions: Vec::new(), + has_running_threads: false, + waiting_thread_count: 0, + is_active: true, + }, + ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, + session_info: acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("t-1")), + work_dirs: None, + title: Some("Completed thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Completed, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // Active thread with Running status + ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, + session_info: acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("t-2")), + work_dirs: None, + title: Some("Running thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Running, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: true, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // Active thread with Error status + ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, + session_info: acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("t-3")), + work_dirs: None, + title: Some("Error thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Error, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: true, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // Thread with WaitingForConfirmation status, not active + ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, + session_info: acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("t-4")), + work_dirs: None, + title: Some("Waiting thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::WaitingForConfirmation, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // Background thread that completed (should show notification) + ListEntry::Thread(ThreadEntry { + agent: Agent::NativeAgent, + session_info: acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("t-5")), + work_dirs: None, + title: Some("Notified thread".into()), + updated_at: Some(Utc::now()), + created_at: Some(Utc::now()), + meta: None, + }, + icon: IconName::ZedAgent, + icon_from_external_svg: None, + status: AgentThreadStatus::Completed, + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: true, + is_background: true, + is_title_generating: false, + highlight_positions: Vec::new(), + worktrees: Vec::new(), + diff_stats: DiffStats::default(), + }), + // View More entry + ListEntry::ViewMore { + path_list: expanded_path.clone(), + is_fully_expanded: false, + }, + // Collapsed project header + ListEntry::ProjectHeader { + path_list: collapsed_path.clone(), + label: "collapsed-project".into(), + workspace: workspace.clone(), + highlight_positions: Vec::new(), + has_running_threads: false, + waiting_thread_count: 0, + is_active: false, + }, + ]; + + // Select the Running thread (index 2) + s.selection = Some(2); + }); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [expanded-project]", + " Completed thread", + " Running thread * (running) <== selected", + " Error thread * (error)", + " Waiting thread (waiting)", + " Notified thread * (!)", + " + View More", + "> [collapsed-project]", + ] + ); + + // Move selection to the collapsed header + sidebar.update_in(cx, |s, _window, _cx| { + s.selection = Some(7); + }); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx).last().cloned(), + Some("> [collapsed-project] <== selected".to_string()), + ); + + // Clear selection + sidebar.update_in(cx, |s, _window, _cx| { + s.selection = None; + }); + + // No entry should have the selected marker + let entries = visible_entries_as_strings(&sidebar, cx); + for entry in &entries { + assert!( + !entry.contains("<== selected"), + "unexpected selection marker in: {}", + entry + ); + } +} + +#[gpui::test] +async fn test_keyboard_select_next_and_previous(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(3, &path_list, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Entries: [header, thread3, thread2, thread1] + // Focusing the sidebar does not set a selection; select_next/select_previous + // handle None gracefully by starting from the first or last entry. + open_and_focus_sidebar(&sidebar, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // First SelectNext from None starts at index 0 + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // Move down through remaining entries + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // At the end, wraps back to first entry + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // Navigate back to the end + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // Move back up + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(2)); + + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // At the top, selection clears (focus returns to editor) + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); +} + +#[gpui::test] +async fn test_keyboard_select_first_and_last(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(3, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + open_and_focus_sidebar(&sidebar, cx); + + // SelectLast jumps to the end + cx.dispatch_action(SelectLast); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(3)); + + // SelectFirst jumps to the beginning + cx.dispatch_action(SelectFirst); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); +} + +#[gpui::test] +async fn test_keyboard_focus_in_does_not_set_selection(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); + + // Initially no selection + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // Open the sidebar so it's rendered, then focus it to trigger focus_in. + // focus_in no longer sets a default selection. + open_and_focus_sidebar(&sidebar, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // Manually set a selection, blur, then refocus — selection should be preserved + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + + cx.update(|window, _cx| { + window.blur(); + }); + cx.run_until_parked(); + + sidebar.update_in(cx, |_, window, cx| { + cx.focus_self(window); + }); + cx.run_until_parked(); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); +} + +#[gpui::test] +async fn test_keyboard_confirm_on_project_header_toggles_collapse(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + 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 1"] + ); + + // Focus the sidebar and select the header (index 0) + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + + // Confirm on project header collapses the group + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // Confirm again expands the group + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project] <== selected", " Thread 1",] + ); +} + +#[gpui::test] +async fn test_keyboard_confirm_on_view_more_expands(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(8, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Should show header + 5 threads + "View More" + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 7); + assert!(entries.iter().any(|e| e.contains("View More"))); + + // Focus sidebar (selection starts at None), then navigate down to the "View More" entry (index 6) + open_and_focus_sidebar(&sidebar, cx); + for _ in 0..7 { + cx.dispatch_action(SelectNext); + } + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(6)); + + // Confirm on "View More" to expand + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // All 8 threads should now be visible with a "Collapse" button + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 10); // header + 8 threads + Collapse button + assert!(!entries.iter().any(|e| e.contains("View More"))); + assert!(entries.iter().any(|e| e.contains("Collapse"))); +} + +#[gpui::test] +async fn test_keyboard_expand_and_collapse_selected_entry(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + 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 1"] + ); + + // Focus sidebar and manually select the header (index 0). Press left to collapse. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + + cx.dispatch_action(SelectParent); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // Press right to expand + cx.dispatch_action(SelectChild); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project] <== selected", " Thread 1",] + ); + + // Press right again on already-expanded header moves selection down + cx.dispatch_action(SelectChild); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); +} + +#[gpui::test] +async fn test_keyboard_collapse_from_child_selects_parent(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Focus sidebar (selection starts at None), then navigate down to the thread (child) + open_and_focus_sidebar(&sidebar, cx); + cx.dispatch_action(SelectNext); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Thread 1 <== selected",] + ); + + // Pressing left on a child collapses the parent group and selects it + cx.dispatch_action(SelectParent); + cx.run_until_parked(); + + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); +} + +#[gpui::test] +async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) { + let project = init_test_project("/empty-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); + + // An empty project has the header and a new thread button. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [empty-project]", " [+ New Thread]"] + ); + + // Focus sidebar — focus_in does not set a selection + open_and_focus_sidebar(&sidebar, cx); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); + + // First SelectNext from None starts at index 0 (header) + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // SelectNext moves to the new thread button + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + // At the end, wraps back to first entry + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0)); + + // SelectPrevious from first entry clears selection (returns to editor) + cx.dispatch_action(SelectPrevious); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None); +} + +#[gpui::test] +async fn test_selection_clamps_after_entry_removal(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + save_n_test_threads(1, &path_list, cx).await; + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Focus sidebar (selection starts at None), navigate down to the thread (index 1) + open_and_focus_sidebar(&sidebar, cx); + cx.dispatch_action(SelectNext); + cx.dispatch_action(SelectNext); + assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1)); + + // Collapse the group, which removes the thread from the list + cx.dispatch_action(SelectParent); + cx.run_until_parked(); + + // Selection should be clamped to the last valid index (0 = header) + let selection = sidebar.read_with(cx, |s, _| s.selection); + let entry_count = sidebar.read_with(cx, |s, _| s.contents.entries.len()); + assert!( + selection.unwrap_or(0) < entry_count, + "selection {} should be within bounds (entries: {})", + selection.unwrap_or(0), + entry_count, + ); +} + +async fn init_test_project_with_agent_panel( + worktree_path: &str, + cx: &mut TestAppContext, +) -> Entity { + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(worktree_path, serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + project::Project::test(fs, [worktree_path.as_ref()], cx).await +} + +fn add_agent_panel( + workspace: &Entity, + project: &Entity, + cx: &mut gpui::VisualTestContext, +) -> Entity { + workspace.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = cx.new(|cx| AgentPanel::test_new(workspace, text_thread_store, window, cx)); + workspace.add_panel(panel.clone(), window, cx); + panel + }) +} + +fn setup_sidebar_with_agent_panel( + multi_workspace: &Entity, + project: &Entity, + cx: &mut gpui::VisualTestContext, +) -> (Entity, Entity) { + let sidebar = setup_sidebar(multi_workspace, cx); + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + let panel = add_agent_panel(&workspace, project, cx); + (sidebar, panel) +} + +#[gpui::test] +async fn test_parallel_threads_shown_with_live_status(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, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + // Open thread A and keep it generating. + let connection = StubAgentConnection::new(); + open_thread_with_connection(&panel, connection.clone(), cx); + send_message(&panel, cx); + + let session_id_a = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id_a, path_list.clone(), cx).await; + + cx.update(|_, cx| { + connection.send_update( + session_id_a.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + cx.run_until_parked(); + + // Open thread B (idle, default response) — thread A goes to background. + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id_b = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id_b, path_list.clone(), cx).await; + + cx.run_until_parked(); + + let mut entries = visible_entries_as_strings(&sidebar, cx); + entries[1..].sort(); + assert_eq!( + entries, + vec!["v [my-project]", " Hello *", " Hello * (running)",] + ); +} + +#[gpui::test] +async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) { + let project_a = init_test_project_with_agent_panel("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + // Open thread on workspace A and keep it generating. + let connection_a = StubAgentConnection::new(); + open_thread_with_connection(&panel_a, connection_a.clone(), cx); + send_message(&panel_a, cx); + + let session_id_a = active_session_id(&panel_a, cx); + save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; + + cx.update(|_, cx| { + connection_a.send_update( + session_id_a.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("chunk".into())), + cx, + ); + }); + cx.run_until_parked(); + + // Add a second workspace and activate it (making workspace A the background). + let fs = cx.update(|_, cx| ::global(cx)); + let project_b = project::Project::test(fs, [], cx).await; + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + cx.run_until_parked(); + + // Thread A is still running; no notification yet. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Hello * (running)",] + ); + + // Complete thread A's turn (transition Running → Completed). + connection_a.end_turn(session_id_a.clone(), acp::StopReason::EndTurn); + cx.run_until_parked(); + + // The completed background thread shows a notification indicator. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Hello * (!)",] + ); +} + +fn type_in_search(sidebar: &Entity, query: &str, cx: &mut gpui::VisualTestContext) { + sidebar.update_in(cx, |sidebar, window, cx| { + window.focus(&sidebar.filter_editor.focus_handle(cx), cx); + sidebar.filter_editor.update(cx, |editor, cx| { + editor.set_text(query, window, cx); + }); + }); + cx.run_until_parked(); +} + +#[gpui::test] +async fn test_search_narrows_visible_threads_to_matches(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + for (id, title, hour) in [ + ("t-1", "Fix crash in project panel", 3), + ("t-2", "Add inline diff view", 2), + ("t-3", "Refactor settings module", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in project panel", + " Add inline diff view", + " Refactor settings module", + ] + ); + + // User types "diff" in the search box — only the matching thread remains, + // with its workspace header preserved for context. + type_in_search(&sidebar, "diff", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Add inline diff view <== selected",] + ); + + // User changes query to something with no matches — list is empty. + type_in_search(&sidebar, "nonexistent", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new() + ); +} + +#[gpui::test] +async fn test_search_matches_regardless_of_case(cx: &mut TestAppContext) { + // Scenario: A user remembers a thread title but not the exact casing. + // Search should match case-insensitively so they can still find it. + 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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Fix Crash In Project Panel".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + // Lowercase query matches mixed-case title. + type_in_search(&sidebar, "fix crash", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix Crash In Project Panel <== selected", + ] + ); + + // Uppercase query also matches the same title. + type_in_search(&sidebar, "FIX CRASH", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix Crash In Project Panel <== selected", + ] + ); +} + +#[gpui::test] +async fn test_escape_clears_search_and_restores_full_list(cx: &mut TestAppContext) { + // Scenario: A user searches, finds what they need, then presses Escape + // to dismiss the filter and see the full list again. + 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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + for (id, title, hour) in [("t-1", "Alpha thread", 2), ("t-2", "Beta thread", 1)] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + // Confirm the full list is showing. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Alpha thread", " Beta thread",] + ); + + // User types a search query to filter down. + open_and_focus_sidebar(&sidebar, cx); + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Alpha thread <== selected",] + ); + + // User presses Escape — filter clears, full list is restored. + // The selection index (1) now points at the first thread entry. + cx.dispatch_action(Cancel); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Alpha thread <== selected", + " Beta thread", + ] + ); +} + +#[gpui::test] +async fn test_search_only_shows_workspace_headers_with_matches(cx: &mut TestAppContext) { + let project_a = init_test_project("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + for (id, title, hour) in [ + ("a1", "Fix bug in sidebar", 2), + ("a2", "Add tests for editor", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_a.clone(), + cx, + ) + .await; + } + + // Add a second workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_test_workspace(window, cx).detach(); + }); + cx.run_until_parked(); + + let path_list_b = PathList::new::(&[]); + + for (id, title, hour) in [ + ("b1", "Refactor sidebar layout", 3), + ("b2", "Fix typo in README", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_b.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Fix bug in sidebar", + " Add tests for editor", + ] + ); + + // "sidebar" matches a thread in each workspace — both headers stay visible. + type_in_search(&sidebar, "sidebar", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Fix bug in sidebar <== selected",] + ); + + // "typo" only matches in the second workspace — the first header disappears. + type_in_search(&sidebar, "typo", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new() + ); + + // "project-a" matches the first workspace name — the header appears + // with all child threads included. + type_in_search(&sidebar, "project-a", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project-a]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); +} + +#[gpui::test] +async fn test_search_matches_workspace_name(cx: &mut TestAppContext) { + let project_a = init_test_project("/alpha-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/alpha-project")]); + + for (id, title, hour) in [ + ("a1", "Fix bug in sidebar", 2), + ("a2", "Add tests for editor", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_a.clone(), + cx, + ) + .await; + } + + // Add a second workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_test_workspace(window, cx).detach(); + }); + cx.run_until_parked(); + + let path_list_b = PathList::new::(&[]); + + for (id, title, hour) in [ + ("b1", "Refactor sidebar layout", 3), + ("b2", "Fix typo in README", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list_b.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + // "alpha" matches the workspace name "alpha-project" but no thread titles. + // The workspace header should appear with all child threads included. + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); + + // "sidebar" matches thread titles in both workspaces but not workspace names. + // Both headers appear with their matching threads. + type_in_search(&sidebar, "sidebar", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] + ); + + // "alpha sidebar" matches the workspace name "alpha-project" (fuzzy: a-l-p-h-a-s-i-d-e-b-a-r + // doesn't match) — but does not match either workspace name or any thread. + // Actually let's test something simpler: a query that matches both a workspace + // name AND some threads in that workspace. Matching threads should still appear. + type_in_search(&sidebar, "fix", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [alpha-project]", " Fix bug in sidebar <== selected",] + ); + + // A query that matches a workspace name AND a thread in that same workspace. + // Both the header (highlighted) and all child threads should appear. + type_in_search(&sidebar, "alpha", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); + + // Now search for something that matches only a workspace name when there + // are also threads with matching titles — the non-matching workspace's + // threads should still appear if their titles match. + type_in_search(&sidebar, "alp", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [alpha-project]", + " Fix bug in sidebar <== selected", + " Add tests for editor", + ] + ); +} + +#[gpui::test] +async fn test_search_finds_threads_hidden_behind_view_more(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + // Create 8 threads. The oldest one has a unique name and will be + // behind View More (only 5 shown by default). + for i in 0..8u32 { + let title = if i == 0 { + "Hidden gem thread".to_string() + } else { + format!("Thread {}", i + 1) + }; + save_thread_metadata( + acp::SessionId::new(Arc::from(format!("thread-{}", i))), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, i).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + // Confirm the thread is not visible and View More is shown. + let entries = visible_entries_as_strings(&sidebar, cx); + assert!( + entries.iter().any(|e| e.contains("View More")), + "should have View More button" + ); + assert!( + !entries.iter().any(|e| e.contains("Hidden gem")), + "Hidden gem should be behind View More" + ); + + // User searches for the hidden thread — it appears, and View More is gone. + type_in_search(&sidebar, "hidden gem", cx); + let filtered = visible_entries_as_strings(&sidebar, cx); + assert_eq!( + filtered, + vec!["v [my-project]", " Hidden gem thread <== selected",] + ); + assert!( + !filtered.iter().any(|e| e.contains("View More")), + "View More should not appear when filtering" + ); +} + +#[gpui::test] +async fn test_search_finds_threads_inside_collapsed_groups(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("thread-1")), + "Important thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + cx.run_until_parked(); + + // User focuses the sidebar and collapses the group using keyboard: + // manually select the header, then press SelectParent to collapse. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(0); + }); + cx.dispatch_action(SelectParent); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project] <== selected"] + ); + + // User types a search — the thread appears even though its group is collapsed. + type_in_search(&sidebar, "important", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]", " Important thread <== selected",] + ); +} + +#[gpui::test] +async fn test_search_then_keyboard_navigate_and_confirm(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 path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + for (id, title, hour) in [ + ("t-1", "Fix crash in panel", 3), + ("t-2", "Fix lint warnings", 2), + ("t-3", "Add new feature", 1), + ] { + save_thread_metadata( + acp::SessionId::new(Arc::from(id)), + title.into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, hour, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + } + cx.run_until_parked(); + + open_and_focus_sidebar(&sidebar, cx); + + // User types "fix" — two threads match. + type_in_search(&sidebar, "fix", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel <== selected", + " Fix lint warnings", + ] + ); + + // Selection starts on the first matching thread. User presses + // SelectNext to move to the second match. + cx.dispatch_action(SelectNext); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel", + " Fix lint warnings <== selected", + ] + ); + + // User can also jump back with SelectPrevious. + cx.dispatch_action(SelectPrevious); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [my-project]", + " Fix crash in panel <== selected", + " Fix lint warnings", + ] + ); +} + +#[gpui::test] +async fn test_confirm_on_historical_thread_activates_workspace(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); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_test_workspace(window, cx).detach(); + }); + cx.run_until_parked(); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("hist-1")), + "Historical Thread".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 6, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + 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]", " Historical Thread",] + ); + + // Switch to workspace 1 so we can verify the confirm switches back. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1 + ); + + // Confirm on the historical (non-live) thread at index 1. + // Before a previous fix, the workspace field was Option and + // historical threads had None, so activate_thread early-returned + // without switching the workspace. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = Some(1); + sidebar.confirm(&Confirm, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0 + ); +} + +#[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; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + save_thread_metadata( + acp::SessionId::new(Arc::from("t-1")), + "Thread A".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + save_thread_metadata( + acp::SessionId::new(Arc::from("t-2")), + "Thread B".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + path_list.clone(), + cx, + ) + .await; + + 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",] + ); + + // Keyboard confirm preserves selection. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.selection = Some(1); + sidebar.confirm(&Confirm, window, cx); + }); + 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")]); + sidebar.toggle_collapse(&path_list, window, cx); + }); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); + + // 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); + }); + assert_eq!(sidebar.read_with(cx, |sidebar, _| sidebar.selection), None); +} + +#[gpui::test] +async fn test_thread_title_update_propagates_to_sidebar(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, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Hi there!".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, path_list.clone(), cx).await; + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Hello *"] + ); + + // Simulate the agent generating a title. The notification chain is: + // AcpThread::set_title emits TitleUpdated → + // ConnectionView::handle_thread_event calls cx.notify() → + // AgentPanel observer fires and emits AgentPanelEvent → + // Sidebar subscription calls update_entries / rebuild_contents. + // + // Before the fix, handle_thread_event did NOT call cx.notify() for + // TitleUpdated, so the AgentPanel observer never fired and the + // sidebar kept showing the old title. + let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap()); + thread.update(cx, |thread, cx| { + thread + .set_title("Friendly Greeting with AI".into(), cx) + .detach(); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Friendly Greeting with AI *"] + ); +} + +#[gpui::test] +async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { + let project_a = init_test_project_with_agent_panel("/project-a", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + // Save a thread so it appears in the list. + let connection_a = StubAgentConnection::new(); + connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel_a, connection_a, cx); + send_message(&panel_a, cx); + let session_id_a = active_session_id(&panel_a, cx); + save_test_thread_metadata(&session_id_a, path_list_a.clone(), cx).await; + + // Add a second workspace with its own agent panel. + let fs = cx.update(|_, cx| ::global(cx)); + fs.as_fake() + .insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await; + let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx) + }); + let panel_b = add_agent_panel(&workspace_b, &project_b, cx); + cx.run_until_parked(); + + let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); + + // ── 1. Initial state: focused thread derived from active panel ───── + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "The active panel's thread should be focused on startup" + ); + }); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id_a.clone(), + work_dirs: None, + title: Some("Test".into()), + updated_at: None, + created_at: None, + meta: None, + }, + &workspace_a, + window, + cx, + ); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "After clicking a thread, it should be the focused thread" + ); + assert!( + has_thread_entry(sidebar, &session_id_a), + "The clicked thread should be present in the entries" + ); + }); + + workspace_a.read_with(cx, |workspace, cx| { + assert!( + workspace.panel::(cx).is_some(), + "Agent panel should exist" + ); + let dock = workspace.right_dock().read(cx); + assert!( + dock.is_open(), + "Clicking a thread should open the agent panel dock" + ); + }); + + let connection_b = StubAgentConnection::new(); + connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Thread B".into()), + )]); + open_thread_with_connection(&panel_b, connection_b, cx); + send_message(&panel_b, cx); + let session_id_b = active_session_id(&panel_b, cx); + let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); + save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await; + cx.run_until_parked(); + + // Workspace A is currently active. Click a thread in workspace B, + // which also triggers a workspace switch. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id_b.clone(), + work_dirs: None, + title: Some("Thread B".into()), + updated_at: None, + created_at: None, + meta: None, + }, + &workspace_b, + window, + cx, + ); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b), + "Clicking a thread in another workspace should focus that thread" + ); + assert!( + has_thread_entry(sidebar, &session_id_b), + "The cross-workspace thread should be present in the entries" + ); + }); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Switching workspace should seed focused_thread from the new active panel" + ); + assert!( + has_thread_entry(sidebar, &session_id_a), + "The seeded thread should be present in the entries" + ); + }); + + let connection_b2 = StubAgentConnection::new(); + connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new(DEFAULT_THREAD_TITLE.into()), + )]); + open_thread_with_connection(&panel_b, connection_b2, cx); + send_message(&panel_b, cx); + let session_id_b2 = active_session_id(&panel_b, cx); + save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await; + cx.run_until_parked(); + + // Panel B is not the active workspace's panel (workspace A is + // active), so opening a thread there should not change focused_thread. + // This prevents running threads in background workspaces from causing + // the selection highlight to jump around. + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Opening a thread in a non-active panel should not change focused_thread" + ); + }); + + workspace_b.update_in(cx, |workspace, window, cx| { + workspace.focus_handle(cx).focus(window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "Defocusing the sidebar should not change focused_thread" + ); + }); + + // Switching workspaces via the multi_workspace (simulates clicking + // a workspace header) should clear focused_thread. + multi_workspace.update_in(cx, |mw, window, cx| { + if let Some(index) = mw.workspaces().iter().position(|w| w == &workspace_b) { + mw.activate_index(index, window, cx); + } + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b2), + "Switching workspace should seed focused_thread from the new active panel" + ); + assert!( + has_thread_entry(sidebar, &session_id_b2), + "The seeded thread should be present in the entries" + ); + }); + + // ── 8. Focusing the agent panel thread keeps focused_thread ──── + // Workspace B still has session_id_b2 loaded in the agent panel. + // Clicking into the thread (simulated by focusing its view) should + // keep focused_thread since it was already seeded on workspace switch. + panel_b.update_in(cx, |panel, window, cx| { + if let Some(thread_view) = panel.active_conversation_view() { + thread_view.read(cx).focus_handle(cx).focus(window, cx); + } + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id_b2), + "Focusing the agent panel thread should set focused_thread" + ); + assert!( + has_thread_entry(sidebar, &session_id_b2), + "The focused thread should be present in the entries" + ); + }); +} + +#[gpui::test] +async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/project-a", cx).await; + let fs = cx.update(|cx| ::global(cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]); + + // Start a thread and send a message so it has history. + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, path_list_a.clone(), cx).await; + cx.run_until_parked(); + + // Verify the thread appears in the sidebar. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project-a]", " Hello *",] + ); + + // The "New Thread" button should NOT be in "active/draft" state + // because the panel has a thread with messages. + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + !sidebar.active_thread_is_draft, + "Panel has a thread with messages, so it should not be a draft" + ); + }); + + // Now add a second folder to the workspace, changing the path_list. + fs.as_fake() + .insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + project + .update(cx, |project, cx| { + project.find_or_create_worktree("/project-b", true, cx) + }) + .await + .expect("should add worktree"); + cx.run_until_parked(); + + // The workspace path_list is now [project-a, project-b]. The old + // thread was stored under [project-a], so it no longer appears in + // the sidebar list for this workspace. + let entries = visible_entries_as_strings(&sidebar, cx); + assert!( + !entries.iter().any(|e| e.contains("Hello")), + "Thread stored under the old path_list should not appear: {:?}", + entries + ); + + // The "New Thread" button must still be clickable (not stuck in + // "active/draft" state). Verify that `active_thread_is_draft` is + // false — the panel still has the old thread with messages. + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + !sidebar.active_thread_is_draft, + "After adding a folder the panel still has a thread with messages, \ + so active_thread_is_draft should be false" + ); + }); + + // Actually click "New Thread" by calling create_new_thread and + // verify a new draft is created. + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.create_new_thread(&workspace, window, cx); + }); + cx.run_until_parked(); + + // After creating a new thread, the panel should now be in draft + // state (no messages on the new thread). + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + sidebar.active_thread_is_draft, + "After creating a new thread the panel should be in draft state" + ); + }); +} + +#[gpui::test] +async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) { + // When the user presses Cmd-N (NewThread action) while viewing a + // non-empty thread, the sidebar should show the "New Thread" entry. + // This exercises the same code path as the workspace action handler + // (which bypasses the sidebar's create_new_thread method). + 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, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]); + + // Create a non-empty thread (has messages). + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&panel, connection, cx); + send_message(&panel, cx); + + let session_id = active_session_id(&panel, cx); + save_test_thread_metadata(&session_id, path_list.clone(), cx).await; + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Hello *"] + ); + + // Simulate cmd-n + let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone()); + panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " [+ New Thread]", " Hello *"], + "After Cmd-N the sidebar should show a highlighted New Thread entry" + ); + + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + sidebar.focused_thread.is_none(), + "focused_thread should be cleared after Cmd-N" + ); + assert!( + sidebar.active_thread_is_draft, + "the new blank thread should be a draft" + ); + }); +} + +#[gpui::test] +async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) { + // When the active workspace is an absorbed git worktree, cmd-n + // should still show the "New Thread" entry under the main repo's + // header and highlight it as active. + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + // Main repo with a linked worktree. + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Worktree checkout pointing back to the main repo. + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + // Switch to the worktree workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Create a non-empty thread in the worktree workspace. + let connection = StubAgentConnection::new(); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( + acp::ContentChunk::new("Done".into()), + )]); + open_thread_with_connection(&worktree_panel, connection, cx); + send_message(&worktree_panel, cx); + + let session_id = active_session_id(&worktree_panel, cx); + let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_test_thread_metadata(&session_id, wt_path_list, cx).await; + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Hello {wt-feature-a} *"] + ); + + // Simulate Cmd-N in the worktree workspace. + worktree_panel.update_in(cx, |panel, window, cx| { + panel.new_thread(&NewThread, window, cx); + }); + worktree_workspace.update_in(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " [+ New Thread]", + " Hello {wt-feature-a} *" + ], + "After Cmd-N in an absorbed worktree, the sidebar should show \ + a highlighted New Thread entry under the main repo header" + ); + + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + sidebar.focused_thread.is_none(), + "focused_thread should be cleared after Cmd-N" + ); + assert!( + sidebar.active_thread_is_draft, + "the new blank thread should be a draft" + ); + }); +} + +async fn init_test_project_with_git( + worktree_path: &str, + cx: &mut TestAppContext, +) -> (Entity, Arc) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + worktree_path, + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = project::Project::test(fs.clone(), [worktree_path.as_ref()], cx).await; + (project, fs) +} + +#[gpui::test] +async fn test_search_matches_worktree_name(cx: &mut TestAppContext) { + let (project, fs) = init_test_project_with_git("/project", cx).await; + + fs.as_fake() + .with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt/rosewood"), + ref_name: Some("refs/heads/rosewood".into()), + sha: "abc".into(), + }); + }) + .unwrap(); + + 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 sidebar = setup_sidebar(&multi_workspace, cx); + + let main_paths = PathList::new(&[std::path::PathBuf::from("/project")]); + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); + save_named_thread_metadata("main-t", "Unrelated Thread", &main_paths, cx).await; + save_named_thread_metadata("wt-t", "Fix Bug", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Search for "rosewood" — should match the worktree name, not the title. + type_in_search(&sidebar, "rosewood", cx); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Fix Bug {rosewood} <== selected"], + ); +} + +#[gpui::test] +async fn test_git_worktree_added_live_updates_sidebar(cx: &mut TestAppContext) { + let (project, fs) = init_test_project_with_git("/project", 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 sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread against a worktree path that doesn't exist yet. + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt/rosewood")]); + save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Thread is not visible yet — no worktree knows about this path. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " [+ New Thread]"] + ); + + // Now add the worktree to the git state and trigger a rescan. + fs.as_fake() + .with_git_state(std::path::Path::new("/project/.git"), true, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt/rosewood"), + ref_name: Some("refs/heads/rosewood".into()), + sha: "abc".into(), + }); + }) + .unwrap(); + + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Worktree Thread {rosewood}",] + ); +} + +#[gpui::test] +async fn test_two_worktree_workspaces_absorbed_when_main_added(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Create the main repo directory (not opened as a workspace yet). + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + "feature-b": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-b", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Two worktree checkouts whose .git files point back to the main repo. + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt-feature-b", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-b", + "src": {}, + }), + ) + .await; + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/wt-feature-b".as_ref()], cx).await; + + project_a.update(cx, |p, cx| p.git_scans_complete(cx)).await; + project_b.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + // Open both worktrees as workspaces — no main repo yet. + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b.clone(), window, cx); + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let paths_a = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + let paths_b = PathList::new(&[std::path::PathBuf::from("/wt-feature-b")]); + save_named_thread_metadata("thread-a", "Thread A", &paths_a, cx).await; + save_named_thread_metadata("thread-b", "Thread B", &paths_b, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Without the main repo, each worktree has its own header. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " Thread A {wt-feature-a}", + " Thread B {wt-feature-b}", + ] + ); + + // Configure the main repo to list both worktrees before opening + // it so the initial git scan picks them up. + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-b"), + ref_name: Some("refs/heads/feature-b".into()), + sha: "bbb".into(), + }); + }) + .unwrap(); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(main_project.clone(), window, cx); + }); + cx.run_until_parked(); + + // Both worktree workspaces should now be absorbed under the main + // repo header, with worktree chips. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " Thread A {wt-feature-a}", + " Thread B {wt-feature-b}", + ] + ); +} + +#[gpui::test] +async fn test_multi_worktree_thread_shows_multiple_chips(cx: &mut TestAppContext) { + // A thread created in a workspace with roots from different git + // worktrees should show a chip for each distinct worktree name. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Two main repos. + fs.insert_tree( + "/project_a", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + "selectric": { + "commondir": "../../", + "HEAD": "ref: refs/heads/selectric", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/project_b", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + "selectric": { + "commondir": "../../", + "HEAD": "ref: refs/heads/selectric", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Worktree checkouts. + for (repo, branch) in &[ + ("project_a", "olivetti"), + ("project_a", "selectric"), + ("project_b", "olivetti"), + ("project_b", "selectric"), + ] { + let worktree_path = format!("/worktrees/{repo}/{branch}/{repo}"); + let gitdir = format!("gitdir: /{repo}/.git/worktrees/{branch}"); + fs.insert_tree( + &worktree_path, + serde_json::json!({ + ".git": gitdir, + "src": {}, + }), + ) + .await; + } + + // Register linked worktrees. + for repo in &["project_a", "project_b"] { + let git_path = format!("/{repo}/.git"); + fs.with_git_state(std::path::Path::new(&git_path), false, |state| { + for branch in &["olivetti", "selectric"] { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from(format!("/worktrees/{repo}/{branch}/{repo}")), + ref_name: Some(format!("refs/heads/{branch}").into()), + sha: "aaa".into(), + }); + } + }) + .unwrap(); + } + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Open a workspace with the worktree checkout paths as roots + // (this is the workspace the thread was created in). + let project = project::Project::test( + fs.clone(), + [ + "/worktrees/project_a/olivetti/project_a".as_ref(), + "/worktrees/project_b/selectric/project_b".as_ref(), + ], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(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); + + // Save a thread under the same paths as the workspace roots. + let thread_paths = PathList::new(&[ + std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), + std::path::PathBuf::from("/worktrees/project_b/selectric/project_b"), + ]); + save_named_thread_metadata("wt-thread", "Cross Worktree Thread", &thread_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Should show two distinct worktree chips. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project_a, project_b]", + " Cross Worktree Thread {olivetti}, {selectric}", + ] + ); +} + +#[gpui::test] +async fn test_same_named_worktree_chips_are_deduplicated(cx: &mut TestAppContext) { + // When a thread's roots span multiple repos but share the same + // worktree name (e.g. both in "olivetti"), only one chip should + // appear. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project_a", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/project_b", + serde_json::json!({ + ".git": { + "worktrees": { + "olivetti": { + "commondir": "../../", + "HEAD": "ref: refs/heads/olivetti", + }, + }, + }, + "src": {}, + }), + ) + .await; + + for repo in &["project_a", "project_b"] { + let worktree_path = format!("/worktrees/{repo}/olivetti/{repo}"); + let gitdir = format!("gitdir: /{repo}/.git/worktrees/olivetti"); + fs.insert_tree( + &worktree_path, + serde_json::json!({ + ".git": gitdir, + "src": {}, + }), + ) + .await; + + let git_path = format!("/{repo}/.git"); + fs.with_git_state(std::path::Path::new(&git_path), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from(format!("/worktrees/{repo}/olivetti/{repo}")), + ref_name: Some("refs/heads/olivetti".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + } + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project = project::Project::test( + fs.clone(), + [ + "/worktrees/project_a/olivetti/project_a".as_ref(), + "/worktrees/project_b/olivetti/project_b".as_ref(), + ], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(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); + + // Thread with roots in both repos' "olivetti" worktrees. + let thread_paths = PathList::new(&[ + std::path::PathBuf::from("/worktrees/project_a/olivetti/project_a"), + std::path::PathBuf::from("/worktrees/project_b/olivetti/project_b"), + ]); + save_named_thread_metadata("wt-thread", "Same Branch Thread", &thread_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Both worktree paths have the name "olivetti", so only one chip. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project_a, project_b]", + " Same Branch Thread {olivetti}", + ] + ); +} + +#[gpui::test] +async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) { + // When a worktree workspace is absorbed under the main repo, a + // running thread in the worktree's agent panel should still show + // live status (spinner + "(running)") in the sidebar. + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + // Main repo with a linked worktree. + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + // Worktree checkout pointing back to the main repo. + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + // Create the MultiWorkspace with both projects. + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + // Add an agent panel to the worktree workspace so we can run a + // thread inside it. + let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + // Switch back to the main workspace before setting up the sidebar. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Start a thread in the worktree workspace's panel and keep it + // generating (don't resolve it). + let connection = StubAgentConnection::new(); + open_thread_with_connection(&worktree_panel, connection.clone(), cx); + send_message(&worktree_panel, cx); + + let session_id = active_session_id(&worktree_panel, cx); + + // Save metadata so the sidebar knows about this thread. + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_test_thread_metadata(&session_id, wt_paths, cx).await; + + // Keep the thread generating by sending a chunk without ending + // the turn. + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + cx.run_until_parked(); + + // The worktree thread should be absorbed under the main project + // and show live running status. + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!( + entries, + vec!["v [project]", " Hello {wt-feature-a} * (running)",] + ); +} + +#[gpui::test] +async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAppContext) { + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + let connection = StubAgentConnection::new(); + open_thread_with_connection(&worktree_panel, connection.clone(), cx); + send_message(&worktree_panel, cx); + + let session_id = active_session_id(&worktree_panel, cx); + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_test_thread_metadata(&session_id, wt_paths, cx).await; + + cx.update(|_, cx| { + connection.send_update( + session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Hello {wt-feature-a} * (running)",] + ); + + connection.end_turn(session_id, acp::StopReason::EndTurn); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " Hello {wt-feature-a} * (!)",] + ); +} + +#[gpui::test] +async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Only open the main repo — no workspace for the worktree. + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread for the worktree path (no workspace for it). + let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // Thread should appear under the main repo with a worktree chip. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " WT Thread {wt-feature-a}"], + ); + + // Only 1 workspace should exist. + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + 1, + ); + + // Focus the sidebar and select the worktree thread. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(1); // index 0 is header, 1 is the thread + }); + + // Confirm to open the worktree thread. + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // A new workspace should have been created for the worktree path. + let new_workspace = multi_workspace.read_with(cx, |mw, _| { + assert_eq!( + mw.workspaces().len(), + 2, + "confirming a worktree thread without a workspace should open one", + ); + mw.workspaces()[1].clone() + }); + + let new_path_list = + new_workspace.read_with(cx, |_, cx| workspace_path_list(&new_workspace, cx)); + assert_eq!( + new_path_list, + PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]), + "the new workspace should have been opened for the worktree path", + ); +} + +#[gpui::test] +async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_project( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [project]", " WT Thread {wt-feature-a}"], + ); + + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(1); + }); + + let assert_sidebar_state = |sidebar: &mut Sidebar, _cx: &mut Context| { + let mut project_headers = sidebar.contents.entries.iter().filter_map(|entry| { + if let ListEntry::ProjectHeader { label, .. } = entry { + Some(label.as_ref()) + } else { + None + } + }); + + let Some(project_header) = project_headers.next() else { + panic!("expected exactly one sidebar project header named `project`, found none"); + }; + assert_eq!( + project_header, "project", + "expected the only sidebar project header to be `project`" + ); + if let Some(unexpected_header) = project_headers.next() { + panic!( + "expected exactly one sidebar project header named `project`, found extra header `{unexpected_header}`" + ); + } + + let mut saw_expected_thread = false; + for entry in &sidebar.contents.entries { + match entry { + ListEntry::ProjectHeader { label, .. } => { + assert_eq!( + label.as_ref(), + "project", + "expected the only sidebar project header to be `project`" + ); + } + ListEntry::Thread(thread) + if thread + .session_info + .title + .as_ref() + .map(|title| title.as_ref()) + == Some("WT Thread") + && thread.worktrees.first().map(|wt| wt.name.as_ref()) + == Some("wt-feature-a") => + { + saw_expected_thread = true; + } + ListEntry::Thread(thread) => { + let title = thread + .session_info + .title + .as_ref() + .map(|title| title.as_ref()) + .unwrap_or("Untitled"); + let worktree_name = thread + .worktrees + .first() + .map(|wt| wt.name.as_ref()) + .unwrap_or(""); + panic!( + "unexpected sidebar thread while opening linked worktree thread: title=`{title}`, worktree=`{worktree_name}`" + ); + } + ListEntry::ViewMore { .. } => { + panic!("unexpected `View More` entry while opening linked worktree thread"); + } + ListEntry::NewThread { .. } => { + panic!("unexpected `New Thread` entry while opening linked worktree thread"); + } + } + } + + assert!( + saw_expected_thread, + "expected the sidebar to keep showing `WT Thread {{wt-feature-a}}` under `project`" + ); + }; + + sidebar + .update(cx, |_, cx| cx.observe_self(assert_sidebar_state)) + .detach(); + + let window = cx.windows()[0]; + cx.update_window(window, |_, window, cx| { + window.dispatch_action(Confirm.boxed_clone(), cx); + }) + .unwrap(); + + cx.run_until_parked(); + + sidebar.update(cx, assert_sidebar_state); +} + +#[gpui::test] +async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + // Activate the main workspace before setting up the sidebar. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + let paths_main = PathList::new(&[std::path::PathBuf::from("/project")]); + let paths_wt = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("thread-main", "Main Thread", &paths_main, cx).await; + save_named_thread_metadata("thread-wt", "WT Thread", &paths_wt, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // The worktree workspace should be absorbed under the main repo. + let entries = visible_entries_as_strings(&sidebar, cx); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0], "v [project]"); + assert!(entries.contains(&" Main Thread".to_string())); + assert!(entries.contains(&" WT Thread {wt-feature-a}".to_string())); + + let wt_thread_index = entries + .iter() + .position(|e| e.contains("WT Thread")) + .expect("should find the worktree thread entry"); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0, + "main workspace should be active initially" + ); + + // Focus the sidebar and select the absorbed worktree thread. + open_and_focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(wt_thread_index); + }); + + // Confirm to activate the worktree thread. + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + // The worktree workspace should now be active, not the main one. + let active_workspace = multi_workspace.read_with(cx, |mw, _| { + mw.workspaces()[mw.active_workspace_index()].clone() + }); + assert_eq!( + active_workspace, worktree_workspace, + "clicking an absorbed worktree thread should activate the worktree workspace" + ); +} + +#[gpui::test] +async fn test_activate_archived_thread_with_saved_paths_activates_matching_workspace( + cx: &mut TestAppContext, +) { + // Thread has saved metadata in ThreadStore. A matching workspace is + // already open. Expected: activates the matching workspace. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread with path_list pointing to project-b. + let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); + let session_id = acp::SessionId::new(Arc::from("archived-1")); + save_test_thread_metadata(&session_id, path_list_b.clone(), cx).await; + + // Ensure workspace A is active. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0 + ); + + // Call activate_archived_thread – should resolve saved paths and + // switch to the workspace for project-b. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id.clone(), + work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), + title: Some("Archived Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1, + "should have activated the workspace matching the saved path_list" + ); +} + +#[gpui::test] +async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace( + cx: &mut TestAppContext, +) { + // Thread has no saved metadata but session_info has cwd. A matching + // workspace is open. Expected: uses cwd to find and activate it. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Start with workspace A active. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 0 + ); + + // No thread saved to the store – cwd is the only path hint. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("unknown-session")), + work_dirs: Some(PathList::new(&[std::path::PathBuf::from("/project-b")])), + title: Some("CWD Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1, + "should have activated the workspace matching the cwd" + ); +} + +#[gpui::test] +async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace( + cx: &mut TestAppContext, +) { + // Thread has no saved metadata and no cwd. Expected: falls back to + // the currently active workspace. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Activate workspace B (index 1) to make it the active one. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }); + cx.run_until_parked(); + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1 + ); + + // No saved thread, no cwd – should fall back to the active workspace. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: acp::SessionId::new(Arc::from("no-context-session")), + work_dirs: None, + title: Some("Contextless Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.active_workspace_index()), + 1, + "should have stayed on the active workspace when no path info is available" + ); +} + +#[gpui::test] +async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut TestAppContext) { + // Thread has saved metadata pointing to a path with no open workspace. + // Expected: opens a new workspace for that path. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread with path_list pointing to project-b – which has no + // open workspace. + let path_list_b = PathList::new(&[std::path::PathBuf::from("/project-b")]); + let session_id = acp::SessionId::new(Arc::from("archived-new-ws")); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + 1, + "should start with one workspace" + ); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id.clone(), + work_dirs: Some(path_list_b), + title: Some("New WS Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx.run_until_parked(); + + assert_eq!( + multi_workspace.read_with(cx, |mw, _| mw.workspaces().len()), + 2, + "should have opened a second workspace for the archived thread's saved paths" + ); +} + +#[gpui::test] +async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let multi_workspace_a = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let multi_workspace_b = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); + + let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); + + let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); + let sidebar = setup_sidebar(&multi_workspace_a_entity, cx_a); + + let session_id = acp::SessionId::new(Arc::from("archived-cross-window")); + + sidebar.update_in(cx_a, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id.clone(), + work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), + title: Some("Cross Window Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx_a.run_until_parked(); + + assert_eq!( + multi_workspace_a + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "should not add the other window's workspace into the current window" + ); + assert_eq!( + multi_workspace_b + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "should reuse the existing workspace in the other window" + ); + assert!( + cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b, + "should activate the window that already owns the matching workspace" + ); + sidebar.read_with(cx_a, |sidebar, _| { + assert_eq!( + sidebar.focused_thread, None, + "source window's sidebar should not eagerly claim focus for a thread opened in another window" + ); + }); +} + +#[gpui::test] +async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_target_sidebar( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + fs.insert_tree("/project-b", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await; + + let multi_workspace_a = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + let multi_workspace_b = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_b.clone(), window, cx)); + + let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); + let multi_workspace_b_entity = multi_workspace_b.root(cx).unwrap(); + + let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); + let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); + + let cx_b = &mut gpui::VisualTestContext::from_window(multi_workspace_b.into(), cx); + let sidebar_b = setup_sidebar(&multi_workspace_b_entity, cx_b); + let workspace_b = multi_workspace_b_entity.read_with(cx_b, |mw, _| mw.workspace().clone()); + let _panel_b = add_agent_panel(&workspace_b, &project_b, cx_b); + + let session_id = acp::SessionId::new(Arc::from("archived-cross-window-with-sidebar")); + + sidebar_a.update_in(cx_a, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id.clone(), + work_dirs: Some(PathList::new(&[PathBuf::from("/project-b")])), + title: Some("Cross Window Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx_a.run_until_parked(); + + assert_eq!( + multi_workspace_a + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "should not add the other window's workspace into the current window" + ); + assert_eq!( + multi_workspace_b + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "should reuse the existing workspace in the other window" + ); + assert!( + cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_b, + "should activate the window that already owns the matching workspace" + ); + sidebar_a.read_with(cx_a, |sidebar, _| { + assert_eq!( + sidebar.focused_thread, None, + "source window's sidebar should not eagerly claim focus for a thread opened in another window" + ); + }); + sidebar_b.read_with(cx_b, |sidebar, _| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id), + "target window's sidebar should eagerly focus the activated archived thread" + ); + }); +} + +#[gpui::test] +async fn test_activate_archived_thread_prefers_current_window_for_matching_paths( + cx: &mut TestAppContext, +) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/project-a", serde_json::json!({ "src": {} })) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_b = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await; + + let multi_workspace_b = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_b, window, cx)); + let multi_workspace_a = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a, window, cx)); + + let multi_workspace_a_entity = multi_workspace_a.root(cx).unwrap(); + + let cx_a = &mut gpui::VisualTestContext::from_window(multi_workspace_a.into(), cx); + let sidebar_a = setup_sidebar(&multi_workspace_a_entity, cx_a); + + let session_id = acp::SessionId::new(Arc::from("archived-current-window")); + + sidebar_a.update_in(cx_a, |sidebar, window, cx| { + sidebar.activate_archived_thread( + Agent::NativeAgent, + acp_thread::AgentSessionInfo { + session_id: session_id.clone(), + work_dirs: Some(PathList::new(&[PathBuf::from("/project-a")])), + title: Some("Current Window Thread".into()), + updated_at: None, + created_at: None, + meta: None, + }, + window, + cx, + ); + }); + cx_a.run_until_parked(); + + assert!( + cx_a.read(|cx| cx.active_window().unwrap()) == *multi_workspace_a, + "should keep activation in the current window when it already has a matching workspace" + ); + sidebar_a.read_with(cx_a, |sidebar, _| { + assert_eq!( + sidebar.focused_thread.as_ref(), + Some(&session_id), + "current window's sidebar should eagerly focus the activated archived thread" + ); + }); + assert_eq!( + multi_workspace_a + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "current window should continue reusing its existing workspace" + ); + assert_eq!( + multi_workspace_b + .read_with(cx_a, |mw, _| mw.workspaces().len()) + .unwrap(), + 1, + "other windows should not be activated just because they also match the saved paths" + ); +} + +#[gpui::test] +async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) { + // Regression test: archive_thread previously always loaded the next thread + // through group_workspace (the main workspace's ProjectHeader), even when + // the next thread belonged to an absorbed linked-worktree workspace. That + // caused the worktree thread to be loaded in the main panel, which bound it + // to the main project and corrupted its stored folder_paths. + // + // The fix: use next.workspace (ThreadEntryWorkspace::Open) when available, + // falling back to group_workspace only for Closed workspaces. + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await; + + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + worktree_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx)); + + let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(worktree_project.clone(), window, cx) + }); + + // Activate main workspace so the sidebar tracks the main panel. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + + let sidebar = setup_sidebar(&multi_workspace, cx); + + let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces()[0].clone()); + let main_panel = add_agent_panel(&main_workspace, &main_project, cx); + let _worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx); + + // Open Thread 2 in the main panel and keep it running. + let connection = StubAgentConnection::new(); + open_thread_with_connection(&main_panel, connection.clone(), cx); + send_message(&main_panel, cx); + + let thread2_session_id = active_session_id(&main_panel, cx); + + cx.update(|_, cx| { + connection.send_update( + thread2_session_id.clone(), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())), + cx, + ); + }); + + // Save thread 2's metadata with a newer timestamp so it sorts above thread 1. + save_thread_metadata( + thread2_session_id.clone(), + "Thread 2".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(), + PathList::new(&[std::path::PathBuf::from("/project")]), + cx, + ) + .await; + + // Save thread 1's metadata with the worktree path and an older timestamp so + // it sorts below thread 2. archive_thread will find it as the "next" candidate. + let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session")); + save_thread_metadata( + thread1_session_id.clone(), + "Thread 1".into(), + chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(), + PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]), + cx, + ) + .await; + + cx.run_until_parked(); + + // Verify the sidebar absorbed thread 1 under [project] with the worktree chip. + let entries_before = visible_entries_as_strings(&sidebar, cx); + assert!( + entries_before.iter().any(|s| s.contains("{wt-feature-a}")), + "Thread 1 should appear with the linked-worktree chip before archiving: {:?}", + entries_before + ); + + // The sidebar should track T2 as the focused thread (derived from the + // main panel's active view). + let focused = sidebar.read_with(cx, |s, _| s.focused_thread.clone()); + assert_eq!( + focused, + Some(thread2_session_id.clone()), + "focused thread should be Thread 2 before archiving: {:?}", + focused + ); + + // Archive thread 2. + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.archive_thread(&thread2_session_id, window, cx); + }); + + cx.run_until_parked(); + + // The main panel's active thread must still be thread 2. + let main_active = main_panel.read_with(cx, |panel, cx| { + panel + .active_agent_thread(cx) + .map(|t| t.read(cx).session_id().clone()) + }); + assert_eq!( + main_active, + Some(thread2_session_id.clone()), + "main panel should not have been taken over by loading the linked-worktree thread T1; \ + before the fix, archive_thread used group_workspace instead of next.workspace, \ + causing T1 to be loaded in the wrong panel" + ); + + // Thread 1 should still appear in the sidebar with its worktree chip + // (Thread 2 was archived so it is gone from the list). + let entries_after = visible_entries_as_strings(&sidebar, cx); + assert!( + entries_after.iter().any(|s| s.contains("{wt-feature-a}")), + "T1 should still carry its linked-worktree chip after archiving T2: {:?}", + entries_after + ); +} + +#[gpui::test] +async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) { + // When a multi-root workspace (e.g. [/other, /project]) shares a + // repo with a single-root workspace (e.g. [/project]), linked + // worktree threads from the shared repo should only appear under + // the dedicated group [project], not under [other, project]. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // Two independent repos, each with their own git history. + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/other", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + + // Register the linked worktree in the main repo. + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Workspace 1: just /project. + let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + project_only + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + // Workspace 2: /other and /project together (multi-root). + let multi_root = + project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await; + multi_root + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_only.clone(), window, cx)); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(multi_root.clone(), window, cx); + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + // Save a thread under the linked worktree path. + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + // The thread should appear only under [project] (the dedicated + // group for the /project repo), not under [other, project]. + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " Worktree Thread {wt-feature-a}", + "v [other, project]", + " [+ New Thread]", + ] + ); +} + +mod property_test { + use super::*; + use gpui::EntityId; + + struct UnopenedWorktree { + path: String, + } + + struct TestState { + fs: Arc, + thread_counter: u32, + workspace_counter: u32, + worktree_counter: u32, + saved_thread_ids: Vec, + workspace_paths: Vec, + main_repo_indices: Vec, + unopened_worktrees: Vec, + } + + impl TestState { + fn new(fs: Arc, initial_workspace_path: String) -> Self { + Self { + fs, + thread_counter: 0, + workspace_counter: 1, + worktree_counter: 0, + saved_thread_ids: Vec::new(), + workspace_paths: vec![initial_workspace_path], + main_repo_indices: vec![0], + unopened_worktrees: Vec::new(), + } + } + + fn next_thread_id(&mut self) -> acp::SessionId { + let id = self.thread_counter; + self.thread_counter += 1; + let session_id = acp::SessionId::new(Arc::from(format!("prop-thread-{id}"))); + self.saved_thread_ids.push(session_id.clone()); + session_id + } + + fn remove_thread(&mut self, index: usize) -> acp::SessionId { + self.saved_thread_ids.remove(index) + } + + fn next_workspace_path(&mut self) -> String { + let id = self.workspace_counter; + self.workspace_counter += 1; + format!("/prop-project-{id}") + } + + fn next_worktree_name(&mut self) -> String { + let id = self.worktree_counter; + self.worktree_counter += 1; + format!("wt-{id}") + } + } + + #[derive(Debug)] + enum Operation { + SaveThread { workspace_index: usize }, + SaveWorktreeThread { worktree_index: usize }, + DeleteThread { index: usize }, + ToggleAgentPanel, + AddWorkspace, + OpenWorktreeAsWorkspace { worktree_index: usize }, + RemoveWorkspace { index: usize }, + SwitchWorkspace { index: usize }, + AddLinkedWorktree { workspace_index: usize }, + } + + // Distribution (out of 20 slots): + // SaveThread: 5 slots (25%) + // SaveWorktreeThread: 2 slots (10%) + // DeleteThread: 2 slots (10%) + // ToggleAgentPanel: 2 slots (10%) + // AddWorkspace: 1 slot (5%) + // OpenWorktreeAsWorkspace: 1 slot (5%) + // RemoveWorkspace: 1 slot (5%) + // SwitchWorkspace: 2 slots (10%) + // AddLinkedWorktree: 4 slots (20%) + const DISTRIBUTION_SLOTS: u32 = 20; + + impl TestState { + fn generate_operation(&self, raw: u32) -> Operation { + let extra = (raw / DISTRIBUTION_SLOTS) as usize; + let workspace_count = self.workspace_paths.len(); + + match raw % DISTRIBUTION_SLOTS { + 0..=4 => Operation::SaveThread { + workspace_index: extra % workspace_count, + }, + 5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread { + worktree_index: extra % self.unopened_worktrees.len(), + }, + 5..=6 => Operation::SaveThread { + workspace_index: extra % workspace_count, + }, + 7..=8 if !self.saved_thread_ids.is_empty() => Operation::DeleteThread { + index: extra % self.saved_thread_ids.len(), + }, + 7..=8 => Operation::SaveThread { + workspace_index: extra % workspace_count, + }, + 9..=10 => Operation::ToggleAgentPanel, + 11 if !self.unopened_worktrees.is_empty() => Operation::OpenWorktreeAsWorkspace { + worktree_index: extra % self.unopened_worktrees.len(), + }, + 11 => Operation::AddWorkspace, + 12 if workspace_count > 1 => Operation::RemoveWorkspace { + index: extra % workspace_count, + }, + 12 => Operation::AddWorkspace, + 13..=14 => Operation::SwitchWorkspace { + index: extra % workspace_count, + }, + 15..=19 if !self.main_repo_indices.is_empty() => { + let main_index = self.main_repo_indices[extra % self.main_repo_indices.len()]; + Operation::AddLinkedWorktree { + workspace_index: main_index, + } + } + 15..=19 => Operation::SaveThread { + workspace_index: extra % workspace_count, + }, + _ => unreachable!(), + } + } + } + + fn save_thread_to_path( + state: &mut TestState, + path_list: PathList, + cx: &mut gpui::VisualTestContext, + ) { + let session_id = state.next_thread_id(); + let title: SharedString = format!("Thread {}", session_id).into(); + let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0) + .unwrap() + + chrono::Duration::seconds(state.thread_counter as i64); + let metadata = ThreadMetadata { + session_id, + agent_id: None, + title, + updated_at, + created_at: None, + folder_paths: path_list, + }; + cx.update(|_, cx| { + SidebarThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx)); + }); + } + + async fn perform_operation( + operation: Operation, + state: &mut TestState, + multi_workspace: &Entity, + sidebar: &Entity, + cx: &mut gpui::VisualTestContext, + ) { + match operation { + Operation::SaveThread { workspace_index } => { + let workspace = + multi_workspace.read_with(cx, |mw, _| mw.workspaces()[workspace_index].clone()); + let path_list = workspace + .read_with(cx, |workspace, cx| PathList::new(&workspace.root_paths(cx))); + save_thread_to_path(state, path_list, cx); + } + Operation::SaveWorktreeThread { worktree_index } => { + let worktree = &state.unopened_worktrees[worktree_index]; + let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]); + save_thread_to_path(state, path_list, cx); + } + Operation::DeleteThread { index } => { + let session_id = state.remove_thread(index); + cx.update(|_, cx| { + SidebarThreadMetadataStore::global(cx) + .update(cx, |store, cx| store.delete(session_id, cx)); + }); + } + Operation::ToggleAgentPanel => { + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + let panel_open = sidebar.read_with(cx, |sidebar, _cx| sidebar.agent_panel_visible); + workspace.update_in(cx, |workspace, window, cx| { + if panel_open { + workspace.close_panel::(window, cx); + } else { + workspace.open_panel::(window, cx); + } + }); + } + Operation::AddWorkspace => { + let path = state.next_workspace_path(); + state + .fs + .insert_tree( + &path, + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + let project = project::Project::test( + state.fs.clone() as Arc, + [path.as_ref()], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + let workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project.clone(), window, cx) + }); + add_agent_panel(&workspace, &project, cx); + let new_index = state.workspace_paths.len(); + state.workspace_paths.push(path); + state.main_repo_indices.push(new_index); + } + Operation::OpenWorktreeAsWorkspace { worktree_index } => { + let worktree = state.unopened_worktrees.remove(worktree_index); + let project = project::Project::test( + state.fs.clone() as Arc, + [worktree.path.as_ref()], + cx, + ) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + let workspace = multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(project.clone(), window, cx) + }); + add_agent_panel(&workspace, &project, cx); + state.workspace_paths.push(worktree.path); + } + Operation::RemoveWorkspace { index } => { + let removed = multi_workspace + .update_in(cx, |mw, window, cx| mw.remove_workspace(index, window, cx)); + if removed.is_some() { + state.workspace_paths.remove(index); + state.main_repo_indices.retain(|i| *i != index); + for i in &mut state.main_repo_indices { + if *i > index { + *i -= 1; + } + } + } + } + Operation::SwitchWorkspace { index } => { + let workspace = + multi_workspace.read_with(cx, |mw, _| mw.workspaces()[index].clone()); + multi_workspace.update_in(cx, |mw, _window, cx| { + mw.activate(workspace, cx); + }); + } + Operation::AddLinkedWorktree { workspace_index } => { + let main_path = state.workspace_paths[workspace_index].clone(); + let dot_git = format!("{}/.git", main_path); + let worktree_name = state.next_worktree_name(); + let worktree_path = format!("/worktrees/{}", worktree_name); + + state.fs + .insert_tree( + &worktree_path, + serde_json::json!({ + ".git": format!("gitdir: {}/.git/worktrees/{}", main_path, worktree_name), + "src": {}, + }), + ) + .await; + + // Also create the worktree metadata dir inside the main repo's .git + state + .fs + .insert_tree( + &format!("{}/.git/worktrees/{}", main_path, worktree_name), + serde_json::json!({ + "commondir": "../../", + "HEAD": format!("ref: refs/heads/{}", worktree_name), + }), + ) + .await; + + let dot_git_path = std::path::Path::new(&dot_git); + let worktree_pathbuf = std::path::PathBuf::from(&worktree_path); + state + .fs + .with_git_state(dot_git_path, false, |git_state| { + git_state.worktrees.push(git::repository::Worktree { + path: worktree_pathbuf, + ref_name: Some(format!("refs/heads/{}", worktree_name).into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + // Re-scan the main workspace's project so it discovers the new worktree. + let main_workspace = + multi_workspace.read_with(cx, |mw, _| mw.workspaces()[workspace_index].clone()); + let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone()); + main_project + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + state.unopened_worktrees.push(UnopenedWorktree { + path: worktree_path, + }); + } + } + } + + fn update_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { + sidebar.update_in(cx, |sidebar, _window, cx| { + sidebar.collapsed_groups.clear(); + let path_lists: Vec = sidebar + .contents + .entries + .iter() + .filter_map(|entry| match entry { + ListEntry::ProjectHeader { path_list, .. } => Some(path_list.clone()), + _ => None, + }) + .collect(); + for path_list in path_lists { + sidebar.expanded_groups.insert(path_list, 10_000); + } + sidebar.update_entries(cx); + }); + } + + fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> { + verify_every_workspace_in_multiworkspace_is_shown(sidebar, cx)?; + verify_all_threads_are_shown(sidebar, cx)?; + verify_active_state_matches_current_workspace(sidebar, cx)?; + Ok(()) + } + + fn verify_every_workspace_in_multiworkspace_is_shown( + sidebar: &Sidebar, + cx: &App, + ) -> anyhow::Result<()> { + let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else { + anyhow::bail!("sidebar should still have an associated multi-workspace"); + }; + + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + + // For each workspace, collect the set of canonical repo paths + // (original_repo_abs_path) from its root repositories. Two + // workspaces that share a canonical repo path are in the same + // linked-worktree group. + let canonical_repos = |ws: &Entity| -> HashSet { + root_repository_snapshots(ws, cx) + .map(|snapshot| snapshot.original_repo_abs_path.to_path_buf()) + .collect::>() + }; + + // Build a map from canonical repo path → set of workspace + // EntityIds that share that repo. + let mut repo_to_workspaces: HashMap> = HashMap::new(); + for ws in &workspaces { + for repo_path in canonical_repos(ws) { + repo_to_workspaces + .entry(repo_path) + .or_default() + .insert(ws.entity_id()); + } + } + + // A workspace participates in a linked-worktree group when it + // shares a canonical repo path with at least one other workspace. + let in_linked_worktree_group = |ws: &Entity| -> bool { + canonical_repos(ws).iter().any(|repo_path| { + repo_to_workspaces + .get(repo_path) + .is_some_and(|members| members.len() > 1) + }) + }; + + // TODO + // Carve-out 1: workspaces with no root paths are not shown + // because the sidebar skips empty path lists. + let expected_workspaces: HashSet = workspaces + .iter() + .filter(|ws| !workspace_path_list(ws, cx).paths().is_empty()) + .map(|ws| ws.entity_id()) + .collect(); + + let sidebar_workspaces: HashSet = sidebar + .contents + .entries + .iter() + .filter_map(|entry| entry.workspace().map(|ws| ws.entity_id())) + .collect(); + + // Check every mismatch between the two sets. Each one must be + // explainable by a known carve-out. + let missing = &expected_workspaces - &sidebar_workspaces; + let stray = &sidebar_workspaces - &expected_workspaces; + + for entity_id in missing.iter().chain(stray.iter()) { + let Some(workspace) = workspaces.iter().find(|ws| ws.entity_id() == *entity_id) else { + anyhow::bail!("workspace {entity_id:?} not found in multi-workspace"); + }; + + // TODO + // Carve-out 2: when multiple workspaces share a linked- + // worktree group, only one representative is shown. Either + // side of the relationship (parent or linked worktree) may + // be the representative, so both can appear in the diff. + anyhow::ensure!( + in_linked_worktree_group(workspace), + "workspace {:?} (paths {:?}) is in the mismatch but does not \ + participate in a linked-worktree group.\n\ + Only in sidebar (stray): {:?}\n\ + Only in multi-workspace (missing): {:?}", + entity_id, + workspace_path_list(workspace, cx).paths(), + stray, + missing, + ); + } + + Ok(()) + } + + fn verify_all_threads_are_shown(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> { + let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else { + anyhow::bail!("sidebar should still have an associated multi-workspace"); + }; + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + let thread_store = SidebarThreadMetadataStore::global(cx); + + let sidebar_thread_ids: HashSet = sidebar + .contents + .entries + .iter() + .filter_map(|entry| entry.session_id().cloned()) + .collect(); + + let mut metadata_thread_ids: HashSet = HashSet::default(); + for workspace in &workspaces { + let path_list = workspace_path_list(workspace, cx); + if path_list.paths().is_empty() { + continue; + } + for metadata in thread_store.read(cx).entries_for_path(&path_list) { + metadata_thread_ids.insert(metadata.session_id.clone()); + } + for snapshot in root_repository_snapshots(workspace, cx) { + for linked_worktree in snapshot.linked_worktrees() { + let worktree_path_list = + PathList::new(std::slice::from_ref(&linked_worktree.path)); + for metadata in thread_store.read(cx).entries_for_path(&worktree_path_list) { + metadata_thread_ids.insert(metadata.session_id.clone()); + } + } + } + } + + anyhow::ensure!( + sidebar_thread_ids == metadata_thread_ids, + "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}", + sidebar_thread_ids, + metadata_thread_ids, + ); + Ok(()) + } + + fn verify_active_state_matches_current_workspace( + sidebar: &Sidebar, + cx: &App, + ) -> anyhow::Result<()> { + let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else { + anyhow::bail!("sidebar should still have an associated multi-workspace"); + }; + + let workspace = multi_workspace.read(cx).workspace(); + let panel_actually_visible = AgentPanel::is_visible(&workspace, cx); + let panel_active_session_id = + workspace + .read(cx) + .panel::(cx) + .and_then(|panel| { + panel + .read(cx) + .active_conversation_view() + .and_then(|cv| cv.read(cx).parent_id(cx)) + }); + + // TODO: Remove this state entirely + anyhow::ensure!( + sidebar.agent_panel_visible == panel_actually_visible, + "sidebar.agent_panel_visible ({}) does not match AgentPanel::is_visible ({})", + sidebar.agent_panel_visible, + panel_actually_visible, + ); + + // TODO: Remove these checks, the focused_thread should _always_ be Some(item-in-the-list) after + // update_entries. if the activated workspace's agent panel has an active thread, this item should + // match the one in the list. There may be a slight delay, where a thread is loading so the agent panel + // returns None initially, and the focused_thread is often optimistically set to the thread the agent panel + // is going to be + if sidebar.agent_panel_visible && !sidebar.active_thread_is_draft { + if let Some(panel_session_id) = panel_active_session_id { + anyhow::ensure!( + sidebar.focused_thread.as_ref() == Some(&panel_session_id), + "agent panel is visible with active session {:?} but sidebar focused_thread is {:?}", + panel_session_id, + sidebar.focused_thread, + ); + } + } + Ok(()) + } + + #[gpui::property_test] + async fn test_sidebar_invariants( + #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..5)] + raw_operations: Vec, + cx: &mut TestAppContext, + ) { + agent_ui::test_support::init_test(cx); + cx.update(|cx| { + cx.update_flags(false, vec!["agent-v2".into()]); + ThreadStore::init_global(cx); + SidebarThreadMetadataStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + prompt_store::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/my-project", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = + project::Project::test(fs.clone() as Arc, ["/my-project".as_ref()], cx) + .await; + project.update(cx, |p, cx| p.git_scans_complete(cx)).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx); + + let mut state = TestState::new(fs, "/my-project".to_string()); + let mut executed: Vec = Vec::new(); + + for &raw_op in &raw_operations { + let operation = state.generate_operation(raw_op); + executed.push(format!("{:?}", operation)); + perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await; + cx.run_until_parked(); + + update_sidebar(&sidebar, cx); + cx.run_until_parked(); + + let result = + sidebar.read_with(cx, |sidebar, cx| validate_sidebar_properties(sidebar, cx)); + if let Err(err) = result { + let log = executed.join("\n "); + panic!( + "Property violation after step {}:\n{err}\n\nOperations:\n {log}", + executed.len(), + ); + } + } + } +} diff --git a/crates/storybook/Cargo.toml b/crates/storybook/Cargo.toml index b1d512559526a00021f5339707c1e24a3110ff15..b641e5cbd8b5ce5e66f9fb082e74ea42124f8993 100644 --- a/crates/storybook/Cargo.toml +++ b/crates/storybook/Cargo.toml @@ -29,6 +29,7 @@ picker.workspace = true reqwest_client.workspace = true rust-embed.workspace = true settings.workspace = true +theme_settings.workspace = true simplelog.workspace = true story.workspace = true strum = { workspace = true, features = ["derive"] } diff --git a/crates/storybook/src/storybook.rs b/crates/storybook/src/storybook.rs index b8f659146c29162c25b94ca65d05770b4c08921b..d3df9bbc3a078793ab8e00c71cd4cb5cb9810fa6 100644 --- a/crates/storybook/src/storybook.rs +++ b/crates/storybook/src/storybook.rs @@ -15,10 +15,10 @@ use gpui::{ }; use log::LevelFilter; use reqwest_client::ReqwestClient; -use settings::{KeymapFile, Settings}; +use settings::{KeymapFile, Settings as _}; use simplelog::SimpleLogger; use strum::IntoEnumIterator; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::prelude::*; use crate::app_menus::app_menus; @@ -76,13 +76,13 @@ fn main() { cx.set_http_client(Arc::new(http_client)); settings::init(cx); - theme::init(theme::LoadThemes::All(Box::new(Assets)), cx); + theme_settings::init(theme::LoadThemes::All(Box::new(Assets)), cx); let selector = story_selector; let mut theme_settings = ThemeSettings::get_global(cx).clone(); theme_settings.theme = - theme::ThemeSelection::Static(settings::ThemeName(theme_name.into())); + theme_settings::ThemeSelection::Static(settings::ThemeName(theme_name.into())); ThemeSettings::override_global(theme_settings, cx); editor::init(cx); @@ -98,7 +98,7 @@ fn main() { ..Default::default() }, move |window, cx| { - theme::setup_ui_font(window, cx); + theme_settings::setup_ui_font(window, cx); cx.new(|cx| StoryWrapper::new(selector.story(window, cx))) }, diff --git a/crates/tab_switcher/Cargo.toml b/crates/tab_switcher/Cargo.toml index e2855aa1696c3af0c3efeb2b927f968783978332..8855c8869ab52260be668c45c20e5af7a869433f 100644 --- a/crates/tab_switcher/Cargo.toml +++ b/crates/tab_switcher/Cargo.toml @@ -33,5 +33,6 @@ ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } serde_json.workspace = true theme = { workspace = true, features = ["test-support"] } +theme_settings.workspace = true workspace = { workspace = true, features = ["test-support"] } zlog.workspace = true diff --git a/crates/tab_switcher/src/tab_switcher_tests.rs b/crates/tab_switcher/src/tab_switcher_tests.rs index e1e3f138252e4dc41aa67d9d5b848eac773d5f4f..5b8b9224192324cf0145417b252dcee3a07ddcc3 100644 --- a/crates/tab_switcher/src/tab_switcher_tests.rs +++ b/crates/tab_switcher/src/tab_switcher_tests.rs @@ -258,7 +258,7 @@ async fn test_close_selected_item(cx: &mut gpui::TestAppContext) { fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); super::init(cx); editor::init(cx); state diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index fcb637f14b3785cf2d11b68b8cbf60934f055df4..8a598c1d7730ef59c19085f73cc65bd955ad4e35 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -37,6 +37,7 @@ sysinfo.workspace = true smol.workspace = true task.workspace = true theme.workspace = true +theme_settings.workspace = true thiserror.workspace = true url.workspace = true util.workspace = true diff --git a/crates/terminal/src/pty_info.rs b/crates/terminal/src/pty_info.rs index 2663095c52f386cfd9528f1c96fa32a39abd9a59..7b6676760ca61c1cfde22601d0c0eb0b9641b42a 100644 --- a/crates/terminal/src/pty_info.rs +++ b/crates/terminal/src/pty_info.rs @@ -36,11 +36,19 @@ impl ProcessIdGetter { } fn pid(&self) -> Option { + // Negative pid means error. + // Zero pid means no foreground process group is set on the PTY yet. + // Avoid killing the current process by returning a zero pid. let pid = unsafe { libc::tcgetpgrp(self.handle) }; - if pid < 0 { + if pid > 0 { + return Some(Pid::from_u32(pid as u32)); + } + + if self.fallback_pid > 0 { return Some(Pid::from_u32(self.fallback_pid)); } - Some(Pid::from_u32(pid as u32)) + + None } } diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 3e7a0c77d265b9eea4c2ab90caa4f0818340fdd8..b620f5f03c2debf19cdc4856da8c039fe690651f 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -417,6 +417,7 @@ impl TerminalBuilder { window_id, }, child_exited: None, + keyboard_input_sent: false, event_loop_task: Task::ready(Ok(())), background_executor: background_executor.clone(), path_style, @@ -650,6 +651,7 @@ impl TerminalBuilder { window_id, }, child_exited: None, + keyboard_input_sent: false, event_loop_task: Task::ready(Ok(())), background_executor, path_style, @@ -876,6 +878,7 @@ pub struct Terminal { template: CopyTemplate, activation_script: Vec, child_exited: Option, + keyboard_input_sent: bool, event_loop_task: Task>, background_executor: BackgroundExecutor, path_style: PathStyle, @@ -1462,6 +1465,7 @@ impl Terminal { .push_back(InternalEvent::Scroll(AlacScroll::Bottom)); self.events.push_back(InternalEvent::SetSelection(None)); + self.keyboard_input_sent = true; let input = input.into(); #[cfg(any(test, feature = "test-support"))] self.input_log.push(input.to_vec()); @@ -1945,7 +1949,7 @@ impl Terminal { MouseButton::Middle => { if let Some(item) = _cx.read_from_primary() { let text = item.text().unwrap_or_default(); - self.input(text.into_bytes()); + self.paste(&text); } } _ => {} @@ -2245,7 +2249,17 @@ impl Terminal { let task = match &mut self.task { Some(task) => task, None => { - if self.child_exited.is_none_or(|e| e.code() == Some(0)) { + // For interactive shells (no task), we need to differentiate: + // 1. User-initiated exits (typed "exit", Ctrl+D, etc.) - always close, + // even if the shell exits with a non-zero code (e.g. after `false`). + // 2. Shell spawn failures (bad $SHELL) - don't close, so the user sees + // the error. Spawn failures never receive keyboard input. + let should_close = if self.keyboard_input_sent { + true + } else { + self.child_exited.is_none_or(|e| e.code() == Some(0)) + }; + if should_close { cx.emit(Event::CloseTerminal); } return; @@ -2560,12 +2574,12 @@ mod tests { use smol::channel::Receiver; use task::{Shell, ShellBuilder}; - #[cfg(target_os = "macos")] + #[cfg(not(target_os = "windows"))] fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } @@ -2795,6 +2809,68 @@ mod tests { ); } + #[cfg(not(target_os = "windows"))] + #[gpui::test(iterations = 10)] + async fn test_terminal_closes_after_nonzero_exit(cx: &mut TestAppContext) { + init_test(cx); + + cx.executor().allow_parking(); + + let builder = cx + .update(|cx| { + TerminalBuilder::new( + None, + None, + task::Shell::System, + HashMap::default(), + CursorShape::default(), + AlternateScroll::On, + None, + vec![], + 0, + false, + 0, + None, + cx, + Vec::new(), + PathStyle::local(), + ) + }) + .await + .unwrap(); + let terminal = cx.new(|cx| builder.subscribe(cx)); + + let (event_tx, event_rx) = smol::channel::unbounded::(); + cx.update(|cx| { + cx.subscribe(&terminal, move |_, e, _| { + event_tx.send_blocking(e.clone()).unwrap(); + }) + }) + .detach(); + + let first_event = event_rx.recv().await.expect("No wakeup event received"); + + terminal.update(cx, |terminal, _| { + terminal.input(b"false\r".to_vec()); + }); + cx.executor().timer(Duration::from_millis(500)).await; + terminal.update(cx, |terminal, _| { + terminal.input(b"exit\r".to_vec()); + }); + + let mut all_events = vec![first_event]; + while let Ok(new_event) = event_rx.recv().await { + all_events.push(new_event.clone()); + if new_event == Event::CloseTerminal { + break; + } + } + assert!( + all_events.contains(&Event::CloseTerminal), + "Shell exiting after `false && exit` should close terminal, but got events: {all_events:?}", + ); + } + #[gpui::test(iterations = 10)] async fn test_terminal_no_exit_on_spawn_failure(cx: &mut TestAppContext) { cx.executor().allow_parking(); diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index f24bd5ead6cfd8cb0d4ded66a770a6040d957b72..9e97a398128c46e870c5b2485934a24a13be295b 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -14,7 +14,7 @@ use settings::{ merge_from::MergeFrom, }; use task::Shell; -use theme::FontFamilyName; +use theme_settings::FontFamilyName; #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub struct Toolbar { diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index 6fc1d4ae710a342b2d275b6dd5713d37a14b1da6..ae4c19ff59b5b944588e06c3373de754d63feaf2 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -42,6 +42,7 @@ settings.workspace = true shellexpand.workspace = true terminal.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index dc01a05dbe0c9c04398afc47a5cae1c2bd7b4e5d..0bb0837c6edb926cdcda70a54889de313cbe94f1 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -25,7 +25,8 @@ use terminal::{ }, terminal_settings::TerminalSettings, }; -use theme::{ActiveTheme, Theme, ThemeSettings}; +use theme::{ActiveTheme, Theme}; +use theme_settings::ThemeSettings; use ui::utils::ensure_minimum_contrast; use ui::{ParentElement, Tooltip}; use util::ResultExt; @@ -913,7 +914,9 @@ impl Element for TerminalElement { } TerminalMode::Standalone => terminal_settings .font_size - .map_or(buffer_font_size, |size| theme::adjusted_font_size(size, cx)), + .map_or(buffer_font_size, |size| { + theme_settings::adjusted_font_size(size, cx) + }), }; let theme = cx.theme().clone(); diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 4f79914eb889b4402eb86ce7ca5359d3d0e16085..76f27f94658d738b522959f80354f9998bf2d89a 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -2346,7 +2346,7 @@ mod tests { cx.update(|cx| { let store = SettingsStore::test(cx); cx.set_global(store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); crate::init(cx); }); diff --git a/crates/terminal_view/src/terminal_path_like_target.rs b/crates/terminal_view/src/terminal_path_like_target.rs index 18eab6fc5b4ccca1bcc6db33a35dc490582037ac..f0f13d8fc2cd737722f30d7e56248e4284ed4495 100644 --- a/crates/terminal_view/src/terminal_path_like_target.rs +++ b/crates/terminal_view/src/terminal_path_like_target.rs @@ -554,7 +554,7 @@ mod tests { let fs = app_cx.update(AppState::test).fs.as_fake().clone(); app_cx.update(|cx| { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); editor::init(cx); }); diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 0b2bfa44870282de79d63a74e507115fb198ed66..3f38ee2f0fd7f64fd996d9011d28ec942d02c86d 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -2208,7 +2208,7 @@ mod tests { ) { let params = cx.update(AppState::test); cx.update(|cx| { - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); let project = Project::test(params.fs.clone(), [], cx).await; diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml index ef193c500d461201e8746ad3ec0f33b01e423b18..dcfa711554ec7457c63d5ce9c9488e337de78836 100644 --- a/crates/theme/Cargo.toml +++ b/crates/theme/Cargo.toml @@ -10,7 +10,7 @@ workspace = true [features] default = [] -test-support = ["gpui/test-support", "fs/test-support", "settings/test-support"] +test-support = ["gpui/test-support"] [lib] path = "src/theme.rs" @@ -20,10 +20,7 @@ doctest = false anyhow.workspace = true collections.workspace = true derive_more.workspace = true -fs.workspace = true -futures.workspace = true gpui.workspace = true -log.workspace = true palette = { workspace = true, default-features = false, features = ["std"] } parking_lot.workspace = true refineable.workspace = true @@ -31,13 +28,9 @@ schemars = { workspace = true, features = ["indexmap2"] } serde.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true -settings.workspace = true strum.workspace = true thiserror.workspace = true -util.workspace = true uuid.workspace = true [dev-dependencies] -fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } -settings = { workspace = true, features = ["test-support"] } diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index bfff86b5c614e41711ae1d1be3d9b4aca08cc822..ba7f600fb05cc160f8d2668cf549853c8ae39ebe 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -25,7 +25,8 @@ pub fn zed_default_themes() -> ThemeFamily { // If a theme customizes a foreground version of a status color, but does not // customize the background color, then use a partly-transparent version of the // foreground color for the background color. -pub(crate) fn apply_status_color_defaults(status: &mut StatusColorsRefinement) { +/// Applies default status color backgrounds from their foreground counterparts. +pub fn apply_status_color_defaults(status: &mut StatusColorsRefinement) { for (fg_color, bg_color) in [ (&status.deleted, &mut status.deleted_background), (&status.created, &mut status.created_background), @@ -42,7 +43,8 @@ pub(crate) fn apply_status_color_defaults(status: &mut StatusColorsRefinement) { } } -pub(crate) fn apply_theme_color_defaults( +/// Applies default theme color values derived from player colors. +pub fn apply_theme_color_defaults( theme_colors: &mut ThemeColorsRefinement, player_colors: &PlayerColors, ) { diff --git a/crates/theme/src/icon_theme.rs b/crates/theme/src/icon_theme.rs index c073442c46b1a52a14e09056e8aaa2c9b22e7d11..d3a56ce42443b18d864bb00200980e703b3342e4 100644 --- a/crates/theme/src/icon_theme.rs +++ b/crates/theme/src/icon_theme.rs @@ -416,7 +416,7 @@ fn icon_keys_by_association( } /// The name of the default icon theme. -pub(crate) const DEFAULT_ICON_THEME_NAME: &str = "Zed (Default)"; +pub const DEFAULT_ICON_THEME_NAME: &str = "Zed (Default)"; static DEFAULT_ICON_THEME: LazyLock> = LazyLock::new(|| { Arc::new(IconTheme { diff --git a/crates/theme/src/registry.rs b/crates/theme/src/registry.rs index c362b62704257fefde125e81ca1c056490263b0b..fbe535309773fa5c90c2031d44b420cf5fad2dc7 100644 --- a/crates/theme/src/registry.rs +++ b/crates/theme/src/registry.rs @@ -1,20 +1,16 @@ use std::sync::Arc; use std::{fmt::Debug, path::Path}; -use anyhow::{Context as _, Result}; +use anyhow::Result; use collections::HashMap; use derive_more::{Deref, DerefMut}; -use fs::Fs; -use futures::StreamExt; use gpui::{App, AssetSource, Global, SharedString}; use parking_lot::RwLock; use thiserror::Error; -use util::ResultExt; use crate::{ Appearance, AppearanceContent, ChevronIcons, DEFAULT_ICON_THEME_NAME, DirectoryIcons, - IconDefinition, IconTheme, Theme, ThemeFamily, ThemeFamilyContent, default_icon_theme, - read_icon_theme, read_user_theme, refine_theme_family, + IconDefinition, IconTheme, IconThemeFamilyContent, Theme, ThemeFamily, default_icon_theme, }; /// The metadata for a theme. @@ -83,6 +79,11 @@ impl ThemeRegistry { cx.set_global(GlobalThemeRegistry(Arc::new(ThemeRegistry::new(assets)))); } + /// Returns the asset source used by this registry. + pub fn assets(&self) -> &dyn AssetSource { + self.assets.as_ref() + } + /// Creates a new [`ThemeRegistry`] with the given [`AssetSource`]. pub fn new(assets: Box) -> Self { let registry = Self { @@ -118,28 +119,21 @@ impl ThemeRegistry { self.state.write().extensions_loaded = true; } - fn insert_theme_families(&self, families: impl IntoIterator) { + /// Inserts the given theme families into the registry. + pub fn insert_theme_families(&self, families: impl IntoIterator) { for family in families.into_iter() { self.insert_themes(family.themes); } } - fn insert_themes(&self, themes: impl IntoIterator) { + /// Inserts the given themes into the registry. + pub fn insert_themes(&self, themes: impl IntoIterator) { let mut state = self.state.write(); for theme in themes.into_iter() { state.themes.insert(theme.name.clone(), Arc::new(theme)); } } - #[allow(unused)] - fn insert_user_theme_families(&self, families: impl IntoIterator) { - for family in families.into_iter() { - let refined_family = refine_theme_family(family); - - self.insert_themes(refined_family.themes); - } - } - /// Removes the themes with the given names from the registry. pub fn remove_user_themes(&self, themes_to_remove: &[SharedString]) { self.state @@ -183,60 +177,6 @@ impl ThemeRegistry { .cloned() } - /// Loads the themes bundled with the Zed binary and adds them to the registry. - pub fn load_bundled_themes(&self) { - let theme_paths = self - .assets - .list("themes/") - .expect("failed to list theme assets") - .into_iter() - .filter(|path| path.ends_with(".json")); - - for path in theme_paths { - let Some(theme) = self.assets.load(&path).log_err().flatten() else { - continue; - }; - - let Some(theme_family) = serde_json::from_slice(&theme) - .with_context(|| format!("failed to parse theme at path \"{path}\"")) - .log_err() - else { - continue; - }; - - self.insert_user_theme_families([theme_family]); - } - } - - /// Loads the user themes from the specified directory and adds them to the registry. - pub async fn load_user_themes(&self, themes_path: &Path, fs: Arc) -> Result<()> { - let mut theme_paths = fs - .read_dir(themes_path) - .await - .with_context(|| format!("reading themes from {themes_path:?}"))?; - - while let Some(theme_path) = theme_paths.next().await { - let Some(theme_path) = theme_path.log_err() else { - continue; - }; - - self.load_user_theme(&theme_path, fs.clone()) - .await - .log_err(); - } - - Ok(()) - } - - /// Loads the user theme from the specified path and adds it to the registry. - pub async fn load_user_theme(&self, theme_path: &Path, fs: Arc) -> Result<()> { - let theme = read_user_theme(theme_path, fs).await?; - - self.insert_user_theme_families([theme]); - - Ok(()) - } - /// Returns the default icon theme. pub fn default_icon_theme(&self) -> Result, IconThemeNotFoundError> { self.get_icon_theme(DEFAULT_ICON_THEME_NAME) @@ -273,18 +213,15 @@ impl ThemeRegistry { .retain(|name, _| !icon_themes_to_remove.contains(name)) } - /// Loads the icon theme from the specified path and adds it to the registry. + /// Loads the icon theme from the icon theme family and adds it to the registry. /// /// The `icons_root_dir` parameter indicates the root directory from which /// the relative paths to icons in the theme should be resolved against. - pub async fn load_icon_theme( + pub fn load_icon_theme( &self, - icon_theme_path: &Path, + icon_theme_family: IconThemeFamilyContent, icons_root_dir: &Path, - fs: Arc, ) -> Result<()> { - let icon_theme_family = read_icon_theme(icon_theme_path, fs).await?; - let resolve_icon_path = |path: SharedString| { icons_root_dir .join(path.as_ref()) diff --git a/crates/theme/src/schema.rs b/crates/theme/src/schema.rs index 61cf869b951ac4d285e1eaca42e226a6ac3e4a6a..56b89314a3442613890322cb7b9239fc7fc5b77e 100644 --- a/crates/theme/src/schema.rs +++ b/crates/theme/src/schema.rs @@ -1,30 +1,11 @@ #![allow(missing_docs)] -use gpui::{HighlightStyle, Hsla}; +use gpui::Hsla; use palette::FromColor; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use settings::IntoGpui; -pub use settings::{FontWeightContent, WindowBackgroundContent}; - -use crate::{StatusColorsRefinement, ThemeColorsRefinement}; - -fn ensure_non_opaque(color: Hsla) -> Hsla { - const MAXIMUM_OPACITY: f32 = 0.7; - if color.a <= MAXIMUM_OPACITY { - color - } else { - Hsla { - a: MAXIMUM_OPACITY, - ..color - } - } -} - -fn ensure_opaque(color: Hsla) -> Hsla { - Hsla { a: 1.0, ..color } -} +/// The appearance of a theme in serialized content. #[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum AppearanceContent { @@ -32,819 +13,8 @@ pub enum AppearanceContent { Dark, } -/// The content of a serialized theme family. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct ThemeFamilyContent { - pub name: String, - pub author: String, - pub themes: Vec, -} - -/// The content of a serialized theme. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct ThemeContent { - pub name: String, - pub appearance: AppearanceContent, - pub style: settings::ThemeStyleContent, -} - -/// Returns the syntax style overrides in the [`ThemeContent`]. -pub fn syntax_overrides(this: &settings::ThemeStyleContent) -> Vec<(String, HighlightStyle)> { - this.syntax - .iter() - .map(|(key, style)| { - ( - key.clone(), - HighlightStyle { - color: style - .color - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - background_color: style - .background_color - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - font_style: style.font_style.map(|s| s.into_gpui()), - font_weight: style.font_weight.map(|w| w.into_gpui()), - ..Default::default() - }, - ) - }) - .collect() -} - -pub fn status_colors_refinement(colors: &settings::StatusColorsContent) -> StatusColorsRefinement { - StatusColorsRefinement { - conflict: colors - .conflict - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - conflict_background: colors - .conflict_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - conflict_border: colors - .conflict_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - created: colors - .created - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - created_background: colors - .created_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - created_border: colors - .created_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - deleted: colors - .deleted - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - deleted_background: colors - .deleted_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - deleted_border: colors - .deleted_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - error: colors - .error - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - error_background: colors - .error_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - error_border: colors - .error_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hidden: colors - .hidden - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hidden_background: colors - .hidden_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hidden_border: colors - .hidden_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hint: colors - .hint - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hint_background: colors - .hint_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - hint_border: colors - .hint_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ignored: colors - .ignored - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ignored_background: colors - .ignored_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ignored_border: colors - .ignored_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - info: colors - .info - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - info_background: colors - .info_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - info_border: colors - .info_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - modified: colors - .modified - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - modified_background: colors - .modified_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - modified_border: colors - .modified_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - predictive: colors - .predictive - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - predictive_background: colors - .predictive_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - predictive_border: colors - .predictive_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - renamed: colors - .renamed - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - renamed_background: colors - .renamed_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - renamed_border: colors - .renamed_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - success: colors - .success - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - success_background: colors - .success_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - success_border: colors - .success_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - unreachable: colors - .unreachable - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - unreachable_background: colors - .unreachable_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - unreachable_border: colors - .unreachable_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - warning: colors - .warning - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - warning_background: colors - .warning_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - warning_border: colors - .warning_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - } -} - -pub fn theme_colors_refinement( - this: &settings::ThemeColorsContent, - status_colors: &StatusColorsRefinement, -) -> ThemeColorsRefinement { - let border = this - .border - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let editor_document_highlight_read_background = this - .editor_document_highlight_read_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let scrollbar_thumb_background = this - .scrollbar_thumb_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or_else(|| { - this.deprecated_scrollbar_thumb_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - }); - let scrollbar_thumb_hover_background = this - .scrollbar_thumb_hover_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let scrollbar_thumb_active_background = this - .scrollbar_thumb_active_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(scrollbar_thumb_background); - let scrollbar_thumb_border = this - .scrollbar_thumb_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let element_hover = this - .element_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let panel_background = this - .panel_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let search_match_background = this - .search_match_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let search_active_match_background = this - .search_active_match_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(search_match_background); - ThemeColorsRefinement { - border, - border_variant: this - .border_variant - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - border_focused: this - .border_focused - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - border_selected: this - .border_selected - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - border_transparent: this - .border_transparent - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - border_disabled: this - .border_disabled - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - elevated_surface_background: this - .elevated_surface_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - surface_background: this - .surface_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - background: this - .background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - element_background: this - .element_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - element_hover, - element_active: this - .element_active - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - element_selected: this - .element_selected - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - element_disabled: this - .element_disabled - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - element_selection_background: this - .element_selection_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - drop_target_background: this - .drop_target_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - drop_target_border: this - .drop_target_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ghost_element_background: this - .ghost_element_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ghost_element_hover: this - .ghost_element_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ghost_element_active: this - .ghost_element_active - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ghost_element_selected: this - .ghost_element_selected - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - ghost_element_disabled: this - .ghost_element_disabled - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - text: this - .text - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - text_muted: this - .text_muted - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - text_placeholder: this - .text_placeholder - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - text_disabled: this - .text_disabled - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - text_accent: this - .text_accent - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - icon: this - .icon - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - icon_muted: this - .icon_muted - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - icon_disabled: this - .icon_disabled - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - icon_placeholder: this - .icon_placeholder - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - icon_accent: this - .icon_accent - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - debugger_accent: this - .debugger_accent - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - status_bar_background: this - .status_bar_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - title_bar_background: this - .title_bar_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - title_bar_inactive_background: this - .title_bar_inactive_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - toolbar_background: this - .toolbar_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - tab_bar_background: this - .tab_bar_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - tab_inactive_background: this - .tab_inactive_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - tab_active_background: this - .tab_active_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - search_match_background: search_match_background, - search_active_match_background: search_active_match_background, - panel_background, - panel_focused_border: this - .panel_focused_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - panel_indent_guide: this - .panel_indent_guide - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - panel_indent_guide_hover: this - .panel_indent_guide_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - panel_indent_guide_active: this - .panel_indent_guide_active - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - panel_overlay_background: this - .panel_overlay_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(panel_background.map(ensure_opaque)), - panel_overlay_hover: this - .panel_overlay_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(panel_background - .zip(element_hover) - .map(|(panel_bg, hover_bg)| panel_bg.blend(hover_bg)) - .map(ensure_opaque)), - pane_focused_border: this - .pane_focused_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - pane_group_border: this - .pane_group_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(border), - scrollbar_thumb_background, - scrollbar_thumb_hover_background, - scrollbar_thumb_active_background, - scrollbar_thumb_border, - scrollbar_track_background: this - .scrollbar_track_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - scrollbar_track_border: this - .scrollbar_track_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - minimap_thumb_background: this - .minimap_thumb_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(scrollbar_thumb_background.map(ensure_non_opaque)), - minimap_thumb_hover_background: this - .minimap_thumb_hover_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(scrollbar_thumb_hover_background.map(ensure_non_opaque)), - minimap_thumb_active_background: this - .minimap_thumb_active_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(scrollbar_thumb_active_background.map(ensure_non_opaque)), - minimap_thumb_border: this - .minimap_thumb_border - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(scrollbar_thumb_border), - editor_foreground: this - .editor_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_background: this - .editor_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_gutter_background: this - .editor_gutter_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_subheader_background: this - .editor_subheader_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_active_line_background: this - .editor_active_line_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_highlighted_line_background: this - .editor_highlighted_line_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_debugger_active_line_background: this - .editor_debugger_active_line_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_line_number: this - .editor_line_number - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_hover_line_number: this - .editor_hover_line_number - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_active_line_number: this - .editor_active_line_number - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_invisible: this - .editor_invisible - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_wrap_guide: this - .editor_wrap_guide - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_active_wrap_guide: this - .editor_active_wrap_guide - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_indent_guide: this - .editor_indent_guide - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_indent_guide_active: this - .editor_indent_guide_active - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_document_highlight_read_background, - editor_document_highlight_write_background: this - .editor_document_highlight_write_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - editor_document_highlight_bracket_background: this - .editor_document_highlight_bracket_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `editor.document_highlight.read_background`, for backwards compatibility. - .or(editor_document_highlight_read_background), - terminal_background: this - .terminal_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_background: this - .terminal_ansi_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_foreground: this - .terminal_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_bright_foreground: this - .terminal_bright_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_dim_foreground: this - .terminal_dim_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_black: this - .terminal_ansi_black - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_black: this - .terminal_ansi_bright_black - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_black: this - .terminal_ansi_dim_black - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_red: this - .terminal_ansi_red - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_red: this - .terminal_ansi_bright_red - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_red: this - .terminal_ansi_dim_red - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_green: this - .terminal_ansi_green - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_green: this - .terminal_ansi_bright_green - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_green: this - .terminal_ansi_dim_green - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_yellow: this - .terminal_ansi_yellow - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_yellow: this - .terminal_ansi_bright_yellow - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_yellow: this - .terminal_ansi_dim_yellow - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_blue: this - .terminal_ansi_blue - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_blue: this - .terminal_ansi_bright_blue - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_blue: this - .terminal_ansi_dim_blue - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_magenta: this - .terminal_ansi_magenta - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_magenta: this - .terminal_ansi_bright_magenta - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_magenta: this - .terminal_ansi_dim_magenta - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_cyan: this - .terminal_ansi_cyan - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_cyan: this - .terminal_ansi_bright_cyan - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_cyan: this - .terminal_ansi_dim_cyan - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_white: this - .terminal_ansi_white - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_bright_white: this - .terminal_ansi_bright_white - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - terminal_ansi_dim_white: this - .terminal_ansi_dim_white - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - link_text_hover: this - .link_text_hover - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - version_control_added: this - .version_control_added - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `created`, for backwards compatibility. - .or(status_colors.created), - version_control_deleted: this - .version_control_deleted - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `deleted`, for backwards compatibility. - .or(status_colors.deleted), - version_control_modified: this - .version_control_modified - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `modified`, for backwards compatibility. - .or(status_colors.modified), - version_control_renamed: this - .version_control_renamed - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `modified`, for backwards compatibility. - .or(status_colors.modified), - version_control_conflict: this - .version_control_conflict - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `ignored`, for backwards compatibility. - .or(status_colors.ignored), - version_control_ignored: this - .version_control_ignored - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - // Fall back to `conflict`, for backwards compatibility. - .or(status_colors.ignored), - version_control_word_added: this - .version_control_word_added - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - version_control_word_deleted: this - .version_control_word_deleted - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - #[allow(deprecated)] - version_control_conflict_marker_ours: this - .version_control_conflict_marker_ours - .as_ref() - .or(this.version_control_conflict_ours_background.as_ref()) - .and_then(|color| try_parse_color(color).ok()), - #[allow(deprecated)] - version_control_conflict_marker_theirs: this - .version_control_conflict_marker_theirs - .as_ref() - .or(this.version_control_conflict_theirs_background.as_ref()) - .and_then(|color| try_parse_color(color).ok()), - vim_normal_background: this - .vim_normal_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_insert_background: this - .vim_insert_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_replace_background: this - .vim_replace_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_background: this - .vim_visual_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_line_background: this - .vim_visual_line_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_block_background: this - .vim_visual_block_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_yank_background: this - .vim_yank_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - .or(editor_document_highlight_read_background), - vim_helix_normal_background: this - .vim_helix_normal_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_helix_select_background: this - .vim_helix_select_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_normal_foreground: this - .vim_normal_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_insert_foreground: this - .vim_insert_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_replace_foreground: this - .vim_replace_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_foreground: this - .vim_visual_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_line_foreground: this - .vim_visual_line_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_visual_block_foreground: this - .vim_visual_block_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_helix_normal_foreground: this - .vim_helix_normal_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - vim_helix_select_foreground: this - .vim_helix_select_foreground - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - } -} - -pub(crate) fn try_parse_color(color: &str) -> anyhow::Result { +/// Parses a color string into an [`Hsla`] value. +pub fn try_parse_color(color: &str) -> anyhow::Result { let rgba = gpui::Rgba::try_from(color)?; let rgba = palette::rgb::Srgba::from_components((rgba.r, rgba.g, rgba.b, rgba.a)); let hsla = palette::Hsla::from_color(rgba); diff --git a/crates/theme/src/styles/accents.rs b/crates/theme/src/styles/accents.rs index 7e42ffe2e5bfa6449a64203ffcd5e49720382d06..751a12849d62c3a08fc274b2ff2f12b0fa3280cc 100644 --- a/crates/theme/src/styles/accents.rs +++ b/crates/theme/src/styles/accents.rs @@ -5,7 +5,6 @@ use serde::Deserialize; use crate::{ amber, blue, cyan, gold, grass, indigo, iris, jade, lime, orange, pink, purple, tomato, - try_parse_color, }; /// A collection of colors that are used to color indent aware lines in the editor. @@ -66,25 +65,4 @@ impl AccentColors { pub fn color_for_index(&self, index: u32) -> Hsla { self.0[index as usize % self.0.len()] } - - /// Merges the given accent colors into this [`AccentColors`] instance. - pub fn merge(&mut self, accent_colors: &[settings::AccentContent]) { - if accent_colors.is_empty() { - return; - } - - let colors = accent_colors - .iter() - .filter_map(|accent_color| { - accent_color - .0 - .as_ref() - .and_then(|color| try_parse_color(color).ok()) - }) - .collect::>(); - - if !colors.is_empty() { - self.0 = Arc::from(colors); - } - } } diff --git a/crates/theme/src/styles/players.rs b/crates/theme/src/styles/players.rs index 439dbdd437aa64e034004a4495e64a96e76ce87e..9699bf87a552e430a6bd6adb4ae8307228f35422 100644 --- a/crates/theme/src/styles/players.rs +++ b/crates/theme/src/styles/players.rs @@ -3,7 +3,7 @@ use gpui::Hsla; use serde::Deserialize; -use crate::{amber, blue, jade, lime, orange, pink, purple, red, try_parse_color}; +use crate::{amber, blue, jade, lime, orange, pink, purple, red}; #[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq)] pub struct PlayerColor { @@ -148,40 +148,4 @@ impl PlayerColors { let len = self.0.len() - 1; self.0[(participant_index as usize % len) + 1] } - - /// Merges the given player colors into this [`PlayerColors`] instance. - pub fn merge(&mut self, user_player_colors: &[settings::PlayerColorContent]) { - if user_player_colors.is_empty() { - return; - } - - for (idx, player) in user_player_colors.iter().enumerate() { - let cursor = player - .cursor - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let background = player - .background - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - let selection = player - .selection - .as_ref() - .and_then(|color| try_parse_color(color).ok()); - - if let Some(player_color) = self.0.get_mut(idx) { - *player_color = PlayerColor { - cursor: cursor.unwrap_or(player_color.cursor), - background: background.unwrap_or(player_color.background), - selection: selection.unwrap_or(player_color.selection), - }; - } else { - self.0.push(PlayerColor { - cursor: cursor.unwrap_or_default(), - background: background.unwrap_or_default(), - selection: selection.unwrap_or_default(), - }); - } - } - } } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 3449e039a9e3f4135a0f8471b8346f6b6e6b9fcc..faa18bd3ce9ed71f4afed6d21d577d48b14680fb 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -16,42 +16,34 @@ mod icon_theme_schema; mod registry; mod scale; mod schema; -mod settings; mod styles; +mod theme_settings_provider; +mod ui_density; -use std::path::Path; use std::sync::Arc; -use ::settings::DEFAULT_DARK_THEME; -use ::settings::IntoGpui; -use ::settings::Settings; -use ::settings::SettingsStore; -use anyhow::Result; -use fallback_themes::apply_status_color_defaults; -use fs::Fs; +use derive_more::{Deref, DerefMut}; use gpui::BorrowAppContext; use gpui::Global; use gpui::{ - App, AssetSource, HighlightStyle, Hsla, Pixels, Refineable, SharedString, WindowAppearance, - WindowBackgroundAppearance, px, + App, AssetSource, Hsla, Pixels, SharedString, WindowAppearance, WindowBackgroundAppearance, px, }; use serde::Deserialize; -use uuid::Uuid; pub use crate::default_colors::*; -use crate::fallback_themes::apply_theme_color_defaults; +pub use crate::fallback_themes::{apply_status_color_defaults, apply_theme_color_defaults}; pub use crate::font_family_cache::*; pub use crate::icon_theme::*; pub use crate::icon_theme_schema::*; pub use crate::registry::*; pub use crate::scale::*; pub use crate::schema::*; -pub use crate::settings::*; pub use crate::styles::*; -pub use ::settings::{ - FontStyleContent, HighlightStyleContent, StatusColorsContent, ThemeColorsContent, - ThemeStyleContent, -}; +pub use crate::theme_settings_provider::*; +pub use crate::ui_density::*; + +/// The name of the default dark theme. +pub const DEFAULT_DARK_THEME: &str = "One Dark"; /// Defines window border radius for platforms that use client side decorations. pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0); @@ -86,15 +78,6 @@ impl From for Appearance { } } -impl From for ThemeAppearanceMode { - fn from(value: Appearance) -> Self { - match value { - Appearance::Light => Self::Light, - Appearance::Dark => Self::Dark, - } - } -} - /// Which themes should be loaded. This is used primarily for testing. pub enum LoadThemes { /// Only load the base theme. @@ -106,84 +89,31 @@ pub enum LoadThemes { All(Box), } -/// Initialize the theme system. +/// Initialize the theme system with default themes. +/// +/// This sets up the [`ThemeRegistry`], [`FontFamilyCache`], [`SystemAppearance`], +/// and [`GlobalTheme`] with the default dark theme. It does NOT load bundled +/// themes from JSON or integrate with settings — use `theme_settings::init` for that. pub fn init(themes_to_load: LoadThemes, cx: &mut App) { SystemAppearance::init(cx); - let (assets, load_user_themes) = match themes_to_load { - LoadThemes::JustBase => (Box::new(()) as Box, false), - LoadThemes::All(assets) => (assets, true), + let assets = match themes_to_load { + LoadThemes::JustBase => Box::new(()) as Box, + LoadThemes::All(assets) => assets, }; ThemeRegistry::set_global(assets, cx); - - if load_user_themes { - ThemeRegistry::global(cx).load_bundled_themes(); - } - FontFamilyCache::init_global(cx); - let theme = GlobalTheme::configured_theme(cx); - let icon_theme = GlobalTheme::configured_icon_theme(cx); + let themes = ThemeRegistry::default_global(cx); + let theme = themes.get(DEFAULT_DARK_THEME).unwrap_or_else(|_| { + themes + .list() + .into_iter() + .next() + .map(|m| themes.get(&m.name).unwrap()) + .unwrap() + }); + let icon_theme = themes.default_icon_theme().unwrap(); cx.set_global(GlobalTheme { theme, icon_theme }); - - let settings = ThemeSettings::get_global(cx); - - let mut prev_buffer_font_size_settings = settings.buffer_font_size_settings(); - let mut prev_ui_font_size_settings = settings.ui_font_size_settings(); - let mut prev_agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); - let mut prev_agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); - let mut prev_theme_name = settings.theme.name(SystemAppearance::global(cx).0); - let mut prev_icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); - let mut prev_theme_overrides = ( - settings.experimental_theme_overrides.clone(), - settings.theme_overrides.clone(), - ); - - cx.observe_global::(move |cx| { - let settings = ThemeSettings::get_global(cx); - - let buffer_font_size_settings = settings.buffer_font_size_settings(); - let ui_font_size_settings = settings.ui_font_size_settings(); - let agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); - let agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); - let theme_name = settings.theme.name(SystemAppearance::global(cx).0); - let icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); - let theme_overrides = ( - settings.experimental_theme_overrides.clone(), - settings.theme_overrides.clone(), - ); - - if buffer_font_size_settings != prev_buffer_font_size_settings { - prev_buffer_font_size_settings = buffer_font_size_settings; - reset_buffer_font_size(cx); - } - - if ui_font_size_settings != prev_ui_font_size_settings { - prev_ui_font_size_settings = ui_font_size_settings; - reset_ui_font_size(cx); - } - - if agent_ui_font_size_settings != prev_agent_ui_font_size_settings { - prev_agent_ui_font_size_settings = agent_ui_font_size_settings; - reset_agent_ui_font_size(cx); - } - - if agent_buffer_font_size_settings != prev_agent_buffer_font_size_settings { - prev_agent_buffer_font_size_settings = agent_buffer_font_size_settings; - reset_agent_buffer_font_size(cx); - } - - if theme_name != prev_theme_name || theme_overrides != prev_theme_overrides { - prev_theme_name = theme_name; - prev_theme_overrides = theme_overrides; - GlobalTheme::reload_theme(cx); - } - - if icon_theme_name != prev_icon_theme_name { - prev_icon_theme_name = icon_theme_name; - GlobalTheme::reload_icon_theme(cx); - } - }) - .detach(); } /// Implementing this trait allows accessing the active theme. @@ -198,6 +128,39 @@ impl ActiveTheme for App { } } +/// The appearance of the system. +#[derive(Debug, Clone, Copy, Deref)] +pub struct SystemAppearance(pub Appearance); + +impl Default for SystemAppearance { + fn default() -> Self { + Self(Appearance::Dark) + } +} + +#[derive(Deref, DerefMut, Default)] +struct GlobalSystemAppearance(SystemAppearance); + +impl Global for GlobalSystemAppearance {} + +impl SystemAppearance { + /// Initializes the [`SystemAppearance`] for the application. + pub fn init(cx: &mut App) { + *cx.default_global::() = + GlobalSystemAppearance(SystemAppearance(cx.window_appearance().into())); + } + + /// Returns the global [`SystemAppearance`]. + pub fn global(cx: &App) -> Self { + cx.global::().0 + } + + /// Returns a mutable reference to the global [`SystemAppearance`]. + pub fn global_mut(cx: &mut App) -> &mut Self { + cx.global_mut::() + } +} + /// A theme family is a grouping of themes under a single name. /// /// For example, the "One" theme family contains the "One Light" and "One Dark" themes. @@ -219,113 +182,6 @@ pub struct ThemeFamily { pub scales: ColorScales, } -impl ThemeFamily { - // This is on ThemeFamily because we will have variables here we will need - // in the future to resolve @references. - /// Refines ThemeContent into a theme, merging it's contents with the base theme. - pub fn refine_theme(&self, theme: &ThemeContent) -> Theme { - let appearance = match theme.appearance { - AppearanceContent::Light => Appearance::Light, - AppearanceContent::Dark => Appearance::Dark, - }; - - let mut refined_status_colors = match theme.appearance { - AppearanceContent::Light => StatusColors::light(), - AppearanceContent::Dark => StatusColors::dark(), - }; - let mut status_colors_refinement = status_colors_refinement(&theme.style.status); - apply_status_color_defaults(&mut status_colors_refinement); - refined_status_colors.refine(&status_colors_refinement); - - let mut refined_player_colors = match theme.appearance { - AppearanceContent::Light => PlayerColors::light(), - AppearanceContent::Dark => PlayerColors::dark(), - }; - refined_player_colors.merge(&theme.style.players); - - let mut refined_theme_colors = match theme.appearance { - AppearanceContent::Light => ThemeColors::light(), - AppearanceContent::Dark => ThemeColors::dark(), - }; - let mut theme_colors_refinement = - theme_colors_refinement(&theme.style.colors, &status_colors_refinement); - apply_theme_color_defaults(&mut theme_colors_refinement, &refined_player_colors); - refined_theme_colors.refine(&theme_colors_refinement); - - let mut refined_accent_colors = match theme.appearance { - AppearanceContent::Light => AccentColors::light(), - AppearanceContent::Dark => AccentColors::dark(), - }; - refined_accent_colors.merge(&theme.style.accents); - - let syntax_highlights = theme.style.syntax.iter().map(|(syntax_token, highlight)| { - ( - syntax_token.clone(), - HighlightStyle { - color: highlight - .color - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - background_color: highlight - .background_color - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - font_style: highlight.font_style.map(|s| s.into_gpui()), - font_weight: highlight.font_weight.map(|w| w.into_gpui()), - ..Default::default() - }, - ) - }); - let syntax_theme = Arc::new(SyntaxTheme::new(syntax_highlights)); - - let window_background_appearance = theme - .style - .window_background_appearance - .map(|w| w.into_gpui()) - .unwrap_or_default(); - - Theme { - id: uuid::Uuid::new_v4().to_string(), - name: theme.name.clone().into(), - appearance, - styles: ThemeStyles { - system: SystemColors::default(), - window_background_appearance, - accents: refined_accent_colors, - colors: refined_theme_colors, - status: refined_status_colors, - player: refined_player_colors, - syntax: syntax_theme, - }, - } - } -} - -/// Refines a [ThemeFamilyContent] and it's [ThemeContent]s into a [ThemeFamily]. -pub fn refine_theme_family(theme_family_content: ThemeFamilyContent) -> ThemeFamily { - let id = Uuid::new_v4().to_string(); - let name = theme_family_content.name.clone(); - let author = theme_family_content.author.clone(); - - let mut theme_family = ThemeFamily { - id, - name: name.into(), - author: author.into(), - themes: vec![], - scales: default_color_scales(), - }; - - let refined_themes = theme_family_content - .themes - .iter() - .map(|theme_content| theme_family.refine_theme(theme_content)) - .collect(); - - theme_family.themes = refined_themes; - - theme_family -} - /// A theme is the primary mechanism for defining the appearance of the UI. #[derive(Clone, Debug, PartialEq)] pub struct Theme { @@ -405,40 +261,14 @@ impl Theme { } } -/// Asynchronously reads the user theme from the specified path. -pub async fn read_user_theme(theme_path: &Path, fs: Arc) -> Result { - let bytes = fs.load_bytes(theme_path).await?; - let theme_family: ThemeFamilyContent = serde_json_lenient::from_slice(&bytes)?; - - for theme in &theme_family.themes { - if theme - .style - .colors - .deprecated_scrollbar_thumb_background - .is_some() - { - log::warn!( - r#"Theme "{theme_name}" is using a deprecated style property: scrollbar_thumb.background. Use `scrollbar.thumb.background` instead."#, - theme_name = theme.name - ) - } - } - - Ok(theme_family) -} - -/// Asynchronously reads the icon theme from the specified path. -pub async fn read_icon_theme( - icon_theme_path: &Path, - fs: Arc, -) -> Result { - let bytes = fs.load_bytes(icon_theme_path).await?; - let icon_theme_family: IconThemeFamilyContent = serde_json_lenient::from_slice(&bytes)?; +/// Deserializes an icon theme from the given bytes. +pub fn deserialize_icon_theme(bytes: &[u8]) -> anyhow::Result { + let icon_theme_family: IconThemeFamilyContent = serde_json_lenient::from_slice(bytes)?; Ok(icon_theme_family) } -/// The active theme +/// The active theme. pub struct GlobalTheme { theme: Arc, icon_theme: Arc, @@ -446,72 +276,27 @@ pub struct GlobalTheme { impl Global for GlobalTheme {} impl GlobalTheme { - fn configured_theme(cx: &mut App) -> Arc { - let themes = ThemeRegistry::default_global(cx); - let theme_settings = ThemeSettings::get_global(cx); - let system_appearance = SystemAppearance::global(cx); - - let theme_name = theme_settings.theme.name(*system_appearance); - - let theme = match themes.get(&theme_name.0) { - Ok(theme) => theme, - Err(err) => { - if themes.extensions_loaded() { - log::error!("{err}"); - } - themes - .get(default_theme(*system_appearance)) - // fallback for tests. - .unwrap_or_else(|_| themes.get(DEFAULT_DARK_THEME).unwrap()) - } - }; - theme_settings.apply_theme_overrides(theme) + /// Creates a new [`GlobalTheme`] with the given theme and icon theme. + pub fn new(theme: Arc, icon_theme: Arc) -> Self { + Self { theme, icon_theme } } - /// Reloads the current theme. - /// - /// Reads the [`ThemeSettings`] to know which theme should be loaded, - /// taking into account the current [`SystemAppearance`]. - pub fn reload_theme(cx: &mut App) { - let theme = Self::configured_theme(cx); + /// Updates the active theme. + pub fn update_theme(cx: &mut App, theme: Arc) { cx.update_global::(|this, _| this.theme = theme); - cx.refresh_windows(); - } - - fn configured_icon_theme(cx: &mut App) -> Arc { - let themes = ThemeRegistry::default_global(cx); - let theme_settings = ThemeSettings::get_global(cx); - let system_appearance = SystemAppearance::global(cx); - - let icon_theme_name = theme_settings.icon_theme.name(*system_appearance); - - match themes.get_icon_theme(&icon_theme_name.0) { - Ok(theme) => theme, - Err(err) => { - if themes.extensions_loaded() { - log::error!("{err}"); - } - themes.get_icon_theme(DEFAULT_ICON_THEME_NAME).unwrap() - } - } } - /// Reloads the current icon theme. - /// - /// Reads the [`ThemeSettings`] to know which icon theme should be loaded, - /// taking into account the current [`SystemAppearance`]. - pub fn reload_icon_theme(cx: &mut App) { - let icon_theme = Self::configured_icon_theme(cx); + /// Updates the active icon theme. + pub fn update_icon_theme(cx: &mut App, icon_theme: Arc) { cx.update_global::(|this, _| this.icon_theme = icon_theme); - cx.refresh_windows(); } - /// the active theme + /// Returns the active theme. pub fn theme(cx: &App) -> &Arc { &cx.global::().theme } - /// the active icon theme + /// Returns the active icon theme. pub fn icon_theme(cx: &App) -> &Arc { &cx.global::().icon_theme } diff --git a/crates/theme/src/theme_settings_provider.rs b/crates/theme/src/theme_settings_provider.rs new file mode 100644 index 0000000000000000000000000000000000000000..f3e05bc77bdd91de46024951aa3bef1f01736502 --- /dev/null +++ b/crates/theme/src/theme_settings_provider.rs @@ -0,0 +1,43 @@ +use gpui::{App, Font, Global, Pixels}; + +use crate::UiDensity; + +/// Trait for providing theme-related settings (fonts, font sizes, UI density) +/// without coupling to the concrete settings infrastructure. +/// +/// A concrete implementation is registered as a global by the `theme_settings` crate. +pub trait ThemeSettingsProvider: Send + Sync + 'static { + /// Returns the font used for UI elements. + fn ui_font<'a>(&'a self, cx: &'a App) -> &'a Font; + + /// Returns the font used for buffers and the terminal. + fn buffer_font<'a>(&'a self, cx: &'a App) -> &'a Font; + + /// Returns the UI font size in pixels. + fn ui_font_size(&self, cx: &App) -> Pixels; + + /// Returns the buffer font size in pixels. + fn buffer_font_size(&self, cx: &App) -> Pixels; + + /// Returns the current UI density setting. + fn ui_density(&self, cx: &App) -> UiDensity; +} + +struct GlobalThemeSettingsProvider(Box); + +impl Global for GlobalThemeSettingsProvider {} + +/// Registers the global [`ThemeSettingsProvider`] implementation. +/// +/// This should be called during application initialization by the crate +/// that owns the concrete theme settings (e.g. `theme_settings`). +pub fn set_theme_settings_provider(provider: Box, cx: &mut App) { + cx.set_global(GlobalThemeSettingsProvider(provider)); +} + +/// Returns the global [`ThemeSettingsProvider`]. +/// +/// Panics if no provider has been registered via [`set_theme_settings_provider`]. +pub fn theme_settings(cx: &App) -> &dyn ThemeSettingsProvider { + &*cx.global::().0 +} diff --git a/crates/theme/src/ui_density.rs b/crates/theme/src/ui_density.rs new file mode 100644 index 0000000000000000000000000000000000000000..5510e330e55c5b63ca125ff3be9dad2f0357e5c2 --- /dev/null +++ b/crates/theme/src/ui_density.rs @@ -0,0 +1,65 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Specifies the density of the UI. +/// Note: This setting is still experimental. See [this tracking issue](https://github.com/zed-industries/zed/issues/18078) +#[derive( + Debug, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Clone, + Copy, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum UiDensity { + /// A denser UI with tighter spacing and smaller elements. + #[serde(alias = "compact")] + Compact, + #[default] + #[serde(alias = "default")] + /// The default UI density. + Default, + #[serde(alias = "comfortable")] + /// A looser UI with more spacing and larger elements. + Comfortable, +} + +impl UiDensity { + /// The spacing ratio of a given density. + /// TODO: Standardize usage throughout the app or remove + pub fn spacing_ratio(self) -> f32 { + match self { + UiDensity::Compact => 0.75, + UiDensity::Default => 1.0, + UiDensity::Comfortable => 1.25, + } + } +} + +impl From for UiDensity { + fn from(s: String) -> Self { + match s.as_str() { + "compact" => Self::Compact, + "default" => Self::Default, + "comfortable" => Self::Comfortable, + _ => Self::default(), + } + } +} + +impl From for String { + fn from(val: UiDensity) -> Self { + match val { + UiDensity::Compact => "compact".to_string(), + UiDensity::Default => "default".to_string(), + UiDensity::Comfortable => "comfortable".to_string(), + } + } +} diff --git a/crates/theme_extension/Cargo.toml b/crates/theme_extension/Cargo.toml index d94e15914b2dfbc8250641e8957366c27c2616a4..ca5b71de20b2166b81a14b79d81f581027245d6a 100644 --- a/crates/theme_extension/Cargo.toml +++ b/crates/theme_extension/Cargo.toml @@ -17,3 +17,4 @@ extension.workspace = true fs.workspace = true gpui.workspace = true theme.workspace = true +theme_settings.workspace = true diff --git a/crates/theme_extension/src/theme_extension.rs b/crates/theme_extension/src/theme_extension.rs index 10df2349c86decbadaa010778a95d04af36a6aab..85351a91f37a5b776b9db0f0bbbc4c05d3fc4616 100644 --- a/crates/theme_extension/src/theme_extension.rs +++ b/crates/theme_extension/src/theme_extension.rs @@ -5,7 +5,8 @@ use anyhow::Result; use extension::{ExtensionHostProxy, ExtensionThemeProxy}; use fs::Fs; use gpui::{App, BackgroundExecutor, SharedString, Task}; -use theme::{GlobalTheme, ThemeRegistry}; +use theme::{ThemeRegistry, deserialize_icon_theme}; +use theme_settings; pub fn init( extension_host_proxy: Arc, @@ -30,7 +31,8 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { fn list_theme_names(&self, theme_path: PathBuf, fs: Arc) -> Task>> { self.executor.spawn(async move { - let themes = theme::read_user_theme(&theme_path, fs).await?; + let themes = + theme_settings::deserialize_user_theme(&fs.load_bytes(&theme_path).await?)?; Ok(themes.themes.into_iter().map(|theme| theme.name).collect()) }) } @@ -41,12 +43,13 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { fn load_user_theme(&self, theme_path: PathBuf, fs: Arc) -> Task> { let theme_registry = self.theme_registry.clone(); - self.executor - .spawn(async move { theme_registry.load_user_theme(&theme_path, fs).await }) + self.executor.spawn(async move { + theme_settings::load_user_theme(&theme_registry, &fs.load_bytes(&theme_path).await?) + }) } fn reload_current_theme(&self, cx: &mut App) { - GlobalTheme::reload_theme(cx) + theme_settings::reload_theme(cx) } fn list_icon_theme_names( @@ -55,7 +58,8 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { fs: Arc, ) -> Task>> { self.executor.spawn(async move { - let icon_theme_family = theme::read_icon_theme(&icon_theme_path, fs).await?; + let icon_theme_family = + theme::deserialize_icon_theme(&fs.load_bytes(&icon_theme_path).await?)?; Ok(icon_theme_family .themes .into_iter() @@ -76,13 +80,13 @@ impl ExtensionThemeProxy for ThemeRegistryProxy { ) -> Task> { let theme_registry = self.theme_registry.clone(); self.executor.spawn(async move { - theme_registry - .load_icon_theme(&icon_theme_path, &icons_root_dir, fs) - .await + let icon_theme_family = + deserialize_icon_theme(&fs.load_bytes(&icon_theme_path).await?)?; + theme_registry.load_icon_theme(icon_theme_family, &icons_root_dir) }) } fn reload_current_icon_theme(&self, cx: &mut App) { - GlobalTheme::reload_icon_theme(cx) + theme_settings::reload_icon_theme(cx) } } diff --git a/crates/theme_importer/Cargo.toml b/crates/theme_importer/Cargo.toml index a91ffc44544f898be35c4514910a6081b10b4a26..a0b86a286de965143ba3ade4ee4cdff56cf773d4 100644 --- a/crates/theme_importer/Cargo.toml +++ b/crates/theme_importer/Cargo.toml @@ -22,4 +22,5 @@ serde_json_lenient.workspace = true simplelog.workspace= true strum = { workspace = true, features = ["derive"] } theme.workspace = true +theme_settings.workspace = true vscode_theme = "0.2.0" diff --git a/crates/theme_importer/src/vscode/converter.rs b/crates/theme_importer/src/vscode/converter.rs index b052e865265368234d7a1bed42957a714ca9d5bb..70b7c0e9f663c64d73cf9360dd7733c12f1fb5fe 100644 --- a/crates/theme_importer/src/vscode/converter.rs +++ b/crates/theme_importer/src/vscode/converter.rs @@ -1,7 +1,7 @@ use anyhow::Result; use collections::IndexMap; use strum::IntoEnumIterator; -use theme::{ +use theme_settings::{ FontStyleContent, FontWeightContent, HighlightStyleContent, StatusColorsContent, ThemeColorsContent, ThemeContent, ThemeStyleContent, WindowBackgroundContent, }; diff --git a/crates/theme_selector/Cargo.toml b/crates/theme_selector/Cargo.toml index 1a563e81f202b484c846ed620aee3edd122fc80b..41e0e7681436f1fd8d6bfe743528af7d4f3d3ad6 100644 --- a/crates/theme_selector/Cargo.toml +++ b/crates/theme_selector/Cargo.toml @@ -22,6 +22,7 @@ serde.workspace = true settings.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index 1ddd6879405ad69a75e038da608d034f58bb5eff..13d6a87c4ac9911bef7a86c9df84171644ca6cf9 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -7,10 +7,8 @@ use gpui::{ use picker::{Picker, PickerDelegate}; use settings::{Settings as _, SettingsStore, update_settings_file}; use std::sync::Arc; -use theme::{ - Appearance, IconThemeName, IconThemeSelection, SystemAppearance, ThemeMeta, ThemeRegistry, - ThemeSettings, -}; +use theme::{Appearance, SystemAppearance, ThemeMeta, ThemeRegistry}; +use theme_settings::{IconThemeName, IconThemeSelection, ThemeSettings}; use ui::{ListItem, ListItemSpacing, prelude::*, v_flex}; use util::ResultExt; use workspace::{ModalView, ui::HighlightedLabel}; @@ -176,7 +174,7 @@ impl PickerDelegate for IconThemeSelectorDelegate { let appearance = Appearance::from(window.appearance()); update_settings_file(self.fs.clone(), cx, move |settings, _| { - theme::set_icon_theme(settings, theme_name, appearance); + theme_settings::set_icon_theme(settings, theme_name, appearance); }); self.selector diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index f3c32c8f2f50cbec820e043a701f382e6ac22d0a..fb4d68a9da6f4a96e52fef288e58bdec90cae6fa 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -9,9 +9,9 @@ use gpui::{ use picker::{Picker, PickerDelegate}; use settings::{Settings, SettingsStore, update_settings_file}; use std::sync::Arc; -use theme::{ - Appearance, SystemAppearance, Theme, ThemeAppearanceMode, ThemeMeta, ThemeName, ThemeRegistry, - ThemeSelection, ThemeSettings, +use theme::{Appearance, SystemAppearance, Theme, ThemeMeta, ThemeRegistry}; +use theme_settings::{ + ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings, appearance_to_mode, }; use ui::{ListItem, ListItemSpacing, prelude::*, v_flex}; use util::ResultExt; @@ -233,7 +233,7 @@ impl ThemeSelectorDelegate { /// Overrides the global (in-memory) theme settings. /// /// Note that this does **not** update the user's `settings.json` file (see the -/// [`ThemeSelectorDelegate::confirm`] method and [`theme::set_theme`] function). +/// [`ThemeSelectorDelegate::confirm`] method and [`theme_settings::set_theme`] function). fn override_global_theme( store: &mut SettingsStore, new_theme: &Theme, @@ -303,7 +303,7 @@ fn update_mode_if_new_appearance_is_different_from_system( if original_mode == &ThemeAppearanceMode::System && system_appearance == new_appearance { ThemeAppearanceMode::System } else { - ThemeAppearanceMode::from(new_appearance) + appearance_to_mode(new_appearance) } } @@ -360,7 +360,7 @@ impl PickerDelegate for ThemeSelectorDelegate { telemetry::event!("Settings Changed", setting = "theme", value = theme_name); update_settings_file(self.fs.clone(), cx, move |settings, _| { - theme::set_theme(settings, theme_name, theme_appearance, system_appearance); + theme_settings::set_theme(settings, theme_name, theme_appearance, system_appearance); }); self.selector diff --git a/crates/theme_settings/Cargo.toml b/crates/theme_settings/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..dfe4fa0f79fb437a2b03c680642ac6b19a91d251 --- /dev/null +++ b/crates/theme_settings/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "theme_settings" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[features] +default = [] +test-support = ["gpui/test-support", "settings/test-support", "theme/test-support"] + +[lib] +path = "src/theme_settings.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +collections.workspace = true +gpui.workspace = true +gpui_util.workspace = true +log.workspace = true +palette = { workspace = true, default-features = false, features = ["std"] } +refineable.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_json_lenient.workspace = true +settings.workspace = true +theme.workspace = true +uuid.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } diff --git a/crates/theme_settings/LICENSE-GPL b/crates/theme_settings/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/theme_settings/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/theme_settings/src/schema.rs b/crates/theme_settings/src/schema.rs new file mode 100644 index 0000000000000000000000000000000000000000..93eb4d30aa7ace9e10da3a0002dae3c6a6907d21 --- /dev/null +++ b/crates/theme_settings/src/schema.rs @@ -0,0 +1,850 @@ +#![allow(missing_docs)] + +use gpui::{HighlightStyle, Hsla}; +use palette::FromColor; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::IntoGpui; +pub use settings::{ + FontStyleContent, HighlightStyleContent, StatusColorsContent, ThemeColorsContent, + ThemeStyleContent, +}; +pub use settings::{FontWeightContent, WindowBackgroundContent}; + +use theme::{StatusColorsRefinement, ThemeColorsRefinement}; + +/// The content of a serialized theme family. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ThemeFamilyContent { + pub name: String, + pub author: String, + pub themes: Vec, +} + +/// The content of a serialized theme. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ThemeContent { + pub name: String, + pub appearance: theme::AppearanceContent, + pub style: settings::ThemeStyleContent, +} + +/// Returns the syntax style overrides in the [`ThemeContent`]. +pub fn syntax_overrides(this: &settings::ThemeStyleContent) -> Vec<(String, HighlightStyle)> { + this.syntax + .iter() + .map(|(key, style)| { + ( + key.clone(), + HighlightStyle { + color: style + .color + .as_ref() + .and_then(|color| theme::try_parse_color(color).ok()), + background_color: style + .background_color + .as_ref() + .and_then(|color| theme::try_parse_color(color).ok()), + font_style: style.font_style.map(|s| s.into_gpui()), + font_weight: style.font_weight.map(|w| w.into_gpui()), + ..Default::default() + }, + ) + }) + .collect() +} + +pub fn status_colors_refinement(colors: &settings::StatusColorsContent) -> StatusColorsRefinement { + StatusColorsRefinement { + conflict: colors + .conflict + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + conflict_background: colors + .conflict_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + conflict_border: colors + .conflict_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + created: colors + .created + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + created_background: colors + .created_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + created_border: colors + .created_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + deleted: colors + .deleted + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + deleted_background: colors + .deleted_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + deleted_border: colors + .deleted_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + error: colors + .error + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + error_background: colors + .error_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + error_border: colors + .error_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hidden: colors + .hidden + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hidden_background: colors + .hidden_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hidden_border: colors + .hidden_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hint: colors + .hint + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hint_background: colors + .hint_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + hint_border: colors + .hint_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ignored: colors + .ignored + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ignored_background: colors + .ignored_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ignored_border: colors + .ignored_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + info: colors + .info + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + info_background: colors + .info_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + info_border: colors + .info_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + modified: colors + .modified + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + modified_background: colors + .modified_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + modified_border: colors + .modified_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + predictive: colors + .predictive + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + predictive_background: colors + .predictive_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + predictive_border: colors + .predictive_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + renamed: colors + .renamed + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + renamed_background: colors + .renamed_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + renamed_border: colors + .renamed_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + success: colors + .success + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + success_background: colors + .success_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + success_border: colors + .success_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + unreachable: colors + .unreachable + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + unreachable_background: colors + .unreachable_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + unreachable_border: colors + .unreachable_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + warning: colors + .warning + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + warning_background: colors + .warning_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + warning_border: colors + .warning_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + } +} + +pub fn theme_colors_refinement( + this: &settings::ThemeColorsContent, + status_colors: &StatusColorsRefinement, +) -> ThemeColorsRefinement { + let border = this + .border + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let editor_document_highlight_read_background = this + .editor_document_highlight_read_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let scrollbar_thumb_background = this + .scrollbar_thumb_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or_else(|| { + this.deprecated_scrollbar_thumb_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + }); + let scrollbar_thumb_hover_background = this + .scrollbar_thumb_hover_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let scrollbar_thumb_active_background = this + .scrollbar_thumb_active_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(scrollbar_thumb_background); + let scrollbar_thumb_border = this + .scrollbar_thumb_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let element_hover = this + .element_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let panel_background = this + .panel_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let search_match_background = this + .search_match_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let search_active_match_background = this + .search_active_match_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(search_match_background); + ThemeColorsRefinement { + border, + border_variant: this + .border_variant + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + border_focused: this + .border_focused + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + border_selected: this + .border_selected + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + border_transparent: this + .border_transparent + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + border_disabled: this + .border_disabled + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + elevated_surface_background: this + .elevated_surface_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + surface_background: this + .surface_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + background: this + .background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + element_background: this + .element_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + element_hover, + element_active: this + .element_active + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + element_selected: this + .element_selected + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + element_disabled: this + .element_disabled + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + element_selection_background: this + .element_selection_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + drop_target_background: this + .drop_target_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + drop_target_border: this + .drop_target_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ghost_element_background: this + .ghost_element_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ghost_element_hover: this + .ghost_element_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ghost_element_active: this + .ghost_element_active + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ghost_element_selected: this + .ghost_element_selected + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + ghost_element_disabled: this + .ghost_element_disabled + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + text: this + .text + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + text_muted: this + .text_muted + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + text_placeholder: this + .text_placeholder + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + text_disabled: this + .text_disabled + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + text_accent: this + .text_accent + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + icon: this + .icon + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + icon_muted: this + .icon_muted + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + icon_disabled: this + .icon_disabled + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + icon_placeholder: this + .icon_placeholder + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + icon_accent: this + .icon_accent + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + debugger_accent: this + .debugger_accent + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + status_bar_background: this + .status_bar_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + title_bar_background: this + .title_bar_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + title_bar_inactive_background: this + .title_bar_inactive_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + toolbar_background: this + .toolbar_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + tab_bar_background: this + .tab_bar_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + tab_inactive_background: this + .tab_inactive_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + tab_active_background: this + .tab_active_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + search_match_background, + search_active_match_background, + panel_background, + panel_focused_border: this + .panel_focused_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + panel_indent_guide: this + .panel_indent_guide + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + panel_indent_guide_hover: this + .panel_indent_guide_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + panel_indent_guide_active: this + .panel_indent_guide_active + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + panel_overlay_background: this + .panel_overlay_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(panel_background.map(ensure_opaque)), + panel_overlay_hover: this + .panel_overlay_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(panel_background + .zip(element_hover) + .map(|(panel_bg, hover_bg)| panel_bg.blend(hover_bg)) + .map(ensure_opaque)), + pane_focused_border: this + .pane_focused_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + pane_group_border: this + .pane_group_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(border), + scrollbar_thumb_background, + scrollbar_thumb_hover_background, + scrollbar_thumb_active_background, + scrollbar_thumb_border, + scrollbar_track_background: this + .scrollbar_track_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + scrollbar_track_border: this + .scrollbar_track_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + minimap_thumb_background: this + .minimap_thumb_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(scrollbar_thumb_background.map(ensure_non_opaque)), + minimap_thumb_hover_background: this + .minimap_thumb_hover_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(scrollbar_thumb_hover_background.map(ensure_non_opaque)), + minimap_thumb_active_background: this + .minimap_thumb_active_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(scrollbar_thumb_active_background.map(ensure_non_opaque)), + minimap_thumb_border: this + .minimap_thumb_border + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(scrollbar_thumb_border), + editor_foreground: this + .editor_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_background: this + .editor_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_gutter_background: this + .editor_gutter_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_subheader_background: this + .editor_subheader_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_active_line_background: this + .editor_active_line_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_highlighted_line_background: this + .editor_highlighted_line_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_debugger_active_line_background: this + .editor_debugger_active_line_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_line_number: this + .editor_line_number + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_hover_line_number: this + .editor_hover_line_number + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_active_line_number: this + .editor_active_line_number + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_invisible: this + .editor_invisible + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_wrap_guide: this + .editor_wrap_guide + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_active_wrap_guide: this + .editor_active_wrap_guide + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_indent_guide: this + .editor_indent_guide + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_indent_guide_active: this + .editor_indent_guide_active + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_document_highlight_read_background, + editor_document_highlight_write_background: this + .editor_document_highlight_write_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + editor_document_highlight_bracket_background: this + .editor_document_highlight_bracket_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(editor_document_highlight_read_background), + terminal_background: this + .terminal_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_background: this + .terminal_ansi_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_foreground: this + .terminal_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_bright_foreground: this + .terminal_bright_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_dim_foreground: this + .terminal_dim_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_black: this + .terminal_ansi_black + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_black: this + .terminal_ansi_bright_black + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_black: this + .terminal_ansi_dim_black + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_red: this + .terminal_ansi_red + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_red: this + .terminal_ansi_bright_red + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_red: this + .terminal_ansi_dim_red + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_green: this + .terminal_ansi_green + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_green: this + .terminal_ansi_bright_green + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_green: this + .terminal_ansi_dim_green + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_yellow: this + .terminal_ansi_yellow + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_yellow: this + .terminal_ansi_bright_yellow + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_yellow: this + .terminal_ansi_dim_yellow + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_blue: this + .terminal_ansi_blue + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_blue: this + .terminal_ansi_bright_blue + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_blue: this + .terminal_ansi_dim_blue + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_magenta: this + .terminal_ansi_magenta + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_magenta: this + .terminal_ansi_bright_magenta + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_magenta: this + .terminal_ansi_dim_magenta + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_cyan: this + .terminal_ansi_cyan + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_cyan: this + .terminal_ansi_bright_cyan + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_cyan: this + .terminal_ansi_dim_cyan + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_white: this + .terminal_ansi_white + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_bright_white: this + .terminal_ansi_bright_white + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + terminal_ansi_dim_white: this + .terminal_ansi_dim_white + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + link_text_hover: this + .link_text_hover + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + version_control_added: this + .version_control_added + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.created), + version_control_deleted: this + .version_control_deleted + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.deleted), + version_control_modified: this + .version_control_modified + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.modified), + version_control_renamed: this + .version_control_renamed + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.modified), + version_control_conflict: this + .version_control_conflict + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.ignored), + version_control_ignored: this + .version_control_ignored + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(status_colors.ignored), + version_control_word_added: this + .version_control_word_added + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + version_control_word_deleted: this + .version_control_word_deleted + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + #[allow(deprecated)] + version_control_conflict_marker_ours: this + .version_control_conflict_marker_ours + .as_ref() + .or(this.version_control_conflict_ours_background.as_ref()) + .and_then(|color| try_parse_color(color).ok()), + #[allow(deprecated)] + version_control_conflict_marker_theirs: this + .version_control_conflict_marker_theirs + .as_ref() + .or(this.version_control_conflict_theirs_background.as_ref()) + .and_then(|color| try_parse_color(color).ok()), + vim_normal_background: this + .vim_normal_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_insert_background: this + .vim_insert_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_replace_background: this + .vim_replace_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_background: this + .vim_visual_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_line_background: this + .vim_visual_line_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_block_background: this + .vim_visual_block_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_yank_background: this + .vim_yank_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(editor_document_highlight_read_background), + vim_helix_normal_background: this + .vim_helix_normal_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_helix_select_background: this + .vim_helix_select_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_normal_foreground: this + .vim_normal_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_insert_foreground: this + .vim_insert_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_replace_foreground: this + .vim_replace_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_foreground: this + .vim_visual_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_line_foreground: this + .vim_visual_line_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_visual_block_foreground: this + .vim_visual_block_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_helix_normal_foreground: this + .vim_helix_normal_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + vim_helix_select_foreground: this + .vim_helix_select_foreground + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + } +} + +fn ensure_non_opaque(color: Hsla) -> Hsla { + const MAXIMUM_OPACITY: f32 = 0.7; + if color.a <= MAXIMUM_OPACITY { + color + } else { + Hsla { + a: MAXIMUM_OPACITY, + ..color + } + } +} + +fn ensure_opaque(color: Hsla) -> Hsla { + Hsla { a: 1.0, ..color } +} + +fn try_parse_color(color: &str) -> anyhow::Result { + let rgba = gpui::Rgba::try_from(color)?; + let rgba = palette::rgb::Srgba::from_components((rgba.r, rgba.g, rgba.b, rgba.a)); + let hsla = palette::Hsla::from_color(rgba); + + let hsla = gpui::hsla( + hsla.hue.into_positive_degrees() / 360., + hsla.saturation, + hsla.lightness, + hsla.alpha, + ); + + Ok(hsla) +} diff --git a/crates/theme/src/settings.rs b/crates/theme_settings/src/settings.rs similarity index 83% rename from crates/theme/src/settings.rs rename to crates/theme_settings/src/settings.rs index c09d3daf6074f24248de12e56ebc2122e2c123e7..cda63ab9c8aa10d0f006f3bf371aab6491dff6de 100644 --- a/crates/theme/src/settings.rs +++ b/crates/theme_settings/src/settings.rs @@ -1,9 +1,8 @@ -use crate::{ - Appearance, DEFAULT_ICON_THEME_NAME, SyntaxTheme, Theme, status_colors_refinement, - syntax_overrides, theme_colors_refinement, -}; +#![allow(missing_docs)] + +use crate::schema::{status_colors_refinement, syntax_overrides, theme_colors_refinement}; +use crate::{merge_accent_colors, merge_player_colors}; use collections::HashMap; -use derive_more::{Deref, DerefMut}; use gpui::{ App, Context, Font, FontFallbacks, FontStyle, Global, Pixels, Subscription, Window, px, }; @@ -13,82 +12,24 @@ use serde::{Deserialize, Serialize}; pub use settings::{FontFamilyName, IconThemeName, ThemeAppearanceMode, ThemeName}; use settings::{IntoGpui, RegisterSetting, Settings, SettingsContent}; use std::sync::Arc; +use theme::{Appearance, DEFAULT_ICON_THEME_NAME, SyntaxTheme, Theme, UiDensity}; const MIN_FONT_SIZE: Pixels = px(6.0); const MAX_FONT_SIZE: Pixels = px(100.0); const MIN_LINE_HEIGHT: f32 = 1.0; -#[derive( - Debug, - Default, - PartialEq, - Eq, - PartialOrd, - Ord, - Hash, - Clone, - Copy, - Serialize, - Deserialize, - JsonSchema, -)] - -/// Specifies the density of the UI. -/// Note: This setting is still experimental. See [this tracking issue](https://github.com/zed-industries/zed/issues/18078) -#[serde(rename_all = "snake_case")] -pub enum UiDensity { - /// A denser UI with tighter spacing and smaller elements. - #[serde(alias = "compact")] - Compact, - #[default] - #[serde(alias = "default")] - /// The default UI density. - Default, - #[serde(alias = "comfortable")] - /// A looser UI with more spacing and larger elements. - Comfortable, -} - -impl UiDensity { - /// The spacing ratio of a given density. - /// TODO: Standardize usage throughout the app or remove - pub fn spacing_ratio(self) -> f32 { - match self { - UiDensity::Compact => 0.75, - UiDensity::Default => 1.0, - UiDensity::Comfortable => 1.25, - } - } -} - -impl From for UiDensity { - fn from(s: String) -> Self { - match s.as_str() { - "compact" => Self::Compact, - "default" => Self::Default, - "comfortable" => Self::Comfortable, - _ => Self::default(), - } - } -} - -impl From for String { - fn from(val: UiDensity) -> Self { - match val { - UiDensity::Compact => "compact".to_string(), - UiDensity::Default => "default".to_string(), - UiDensity::Comfortable => "comfortable".to_string(), - } +pub(crate) fn ui_density_from_settings(val: settings::UiDensity) -> UiDensity { + match val { + settings::UiDensity::Compact => UiDensity::Compact, + settings::UiDensity::Default => UiDensity::Default, + settings::UiDensity::Comfortable => UiDensity::Comfortable, } } -impl From for UiDensity { - fn from(val: settings::UiDensity) -> Self { - match val { - settings::UiDensity::Compact => Self::Compact, - settings::UiDensity::Default => Self::Default, - settings::UiDensity::Comfortable => Self::Comfortable, - } +pub fn appearance_to_mode(appearance: Appearance) -> ThemeAppearanceMode { + match appearance { + Appearance::Light => ThemeAppearanceMode::Light, + Appearance::Dark => ThemeAppearanceMode::Dark, } } @@ -145,39 +86,6 @@ pub fn default_theme(appearance: Appearance) -> &'static str { } } -/// The appearance of the system. -#[derive(Debug, Clone, Copy, Deref)] -pub struct SystemAppearance(pub Appearance); - -impl Default for SystemAppearance { - fn default() -> Self { - Self(Appearance::Dark) - } -} - -#[derive(Deref, DerefMut, Default)] -struct GlobalSystemAppearance(SystemAppearance); - -impl Global for GlobalSystemAppearance {} - -impl SystemAppearance { - /// Initializes the [`SystemAppearance`] for the application. - pub fn init(cx: &mut App) { - *cx.default_global::() = - GlobalSystemAppearance(SystemAppearance(cx.window_appearance().into())); - } - - /// Returns the global [`SystemAppearance`]. - pub fn global(cx: &App) -> Self { - cx.global::().0 - } - - /// Returns a mutable reference to the global [`SystemAppearance`]. - pub fn global_mut(cx: &mut App) -> &mut Self { - cx.global_mut::() - } -} - #[derive(Default)] struct BufferFontSize(Pixels); @@ -327,21 +235,16 @@ pub fn set_theme( *theme = theme_name; } settings::ThemeSelection::Dynamic { mode, light, dark } => { - // Update the appropriate theme slot based on appearance. match theme_appearance { Appearance::Light => *light = theme_name, Appearance::Dark => *dark = theme_name, } - // Don't update the theme mode if it is set to system and the new theme has the same - // appearance. let should_update_mode = !(mode == &ThemeAppearanceMode::System && theme_appearance == system_appearance); if should_update_mode { - // Update the mode to the specified appearance (otherwise we might set the theme and - // nothing gets updated because the system specified the other mode appearance). - *mode = ThemeAppearanceMode::from(theme_appearance); + *mode = appearance_to_mode(theme_appearance); } } } @@ -379,9 +282,6 @@ pub fn set_mode(content: &mut SettingsContent, mode: ThemeAppearanceMode) { if let Some(selection) = theme.theme.as_mut() { match selection { settings::ThemeSelection::Static(_) => { - // If the theme was previously set to a single static theme, - // reset to the default dynamic light/dark pair and let users - // customize light/dark themes explicitly afterward. *selection = settings::ThemeSelection::Dynamic { mode: ThemeAppearanceMode::System, light: ThemeName(settings::DEFAULT_LIGHT_THEME.into()), @@ -404,9 +304,6 @@ pub fn set_mode(content: &mut SettingsContent, mode: ThemeAppearanceMode) { if let Some(selection) = theme.icon_theme.as_mut() { match selection { settings::IconThemeSelection::Static(icon_theme) => { - // If the icon theme was previously set to a single static - // theme, we don't know whether it was a light or dark - // theme, so we just use it for both. *selection = settings::IconThemeSelection::Dynamic { mode, light: icon_theme.clone(), @@ -424,7 +321,6 @@ pub fn set_mode(content: &mut SettingsContent, mode: ThemeAppearanceMode) { ))); } } -// } /// The buffer's line height. #[derive(Clone, Copy, Debug, PartialEq, Default)] @@ -530,7 +426,6 @@ impl ThemeSettings { self.agent_buffer_font_size } - // TODO: Rename: `line_height` -> `buffer_line_height` /// Returns the buffer's line height. pub fn line_height(&self) -> f32 { f32::max(self.buffer_line_height.value(), MIN_LINE_HEIGHT) @@ -538,7 +433,6 @@ impl ThemeSettings { /// Applies the theme overrides, if there are any, to the current theme. pub fn apply_theme_overrides(&self, mut arc_theme: Arc) -> Arc { - // Apply the old overrides setting first, so that the new setting can override those. if let Some(experimental_theme_overrides) = &self.experimental_theme_overrides { let mut theme = (*arc_theme).clone(); ThemeSettings::modify_theme(&mut theme, experimental_theme_overrides); @@ -566,11 +460,11 @@ impl ThemeSettings { &status_color_refinement, )); base_theme.styles.status.refine(&status_color_refinement); - base_theme.styles.player.merge(&theme_overrides.players); - base_theme.styles.accents.merge(&theme_overrides.accents); + merge_player_colors(&mut base_theme.styles.player, &theme_overrides.players); + merge_accent_colors(&mut base_theme.styles.accents, &theme_overrides.accents); base_theme.styles.syntax = SyntaxTheme::merge( base_theme.styles.syntax.clone(), - syntax_overrides(&theme_overrides), + syntax_overrides(theme_overrides), ); } } @@ -614,7 +508,6 @@ pub fn reset_buffer_font_size(cx: &mut App) { } } -// TODO: Make private, change usages to use `get_ui_font_size` instead. #[allow(missing_docs)] pub fn setup_ui_font(window: &mut Window, cx: &mut App) -> gpui::Font { let (ui_font, ui_font_size) = { @@ -734,7 +627,7 @@ impl settings::Settings for ThemeSettings { experimental_theme_overrides: content.experimental_theme_overrides.clone(), theme_overrides: content.theme_overrides.clone(), icon_theme: icon_theme_selection, - ui_density: content.ui_density.unwrap_or_default().into(), + ui_density: ui_density_from_settings(content.ui_density.unwrap_or_default()), unnecessary_code_fade: content.unnecessary_code_fade.unwrap().0.clamp(0.0, 0.9), } } diff --git a/crates/theme_settings/src/theme_settings.rs b/crates/theme_settings/src/theme_settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..f5bc96ba02a63088b6311055899b39de65ea9de2 --- /dev/null +++ b/crates/theme_settings/src/theme_settings.rs @@ -0,0 +1,412 @@ +#![deny(missing_docs)] + +//! # Theme Settings +//! +//! This crate provides theme settings integration for Zed, +//! bridging the theme system with the settings infrastructure. + +mod schema; +mod settings; + +use std::sync::Arc; + +use ::settings::{IntoGpui, Settings, SettingsStore}; +use anyhow::{Context as _, Result}; +use gpui::{App, Font, HighlightStyle, Pixels, Refineable}; +use gpui_util::ResultExt; +use theme::{ + AccentColors, Appearance, AppearanceContent, DEFAULT_DARK_THEME, DEFAULT_ICON_THEME_NAME, + GlobalTheme, LoadThemes, PlayerColor, PlayerColors, StatusColors, SyntaxTheme, + SystemAppearance, SystemColors, Theme, ThemeColors, ThemeFamily, ThemeRegistry, + ThemeSettingsProvider, ThemeStyles, default_color_scales, try_parse_color, +}; + +pub use crate::schema::{ + FontStyleContent, FontWeightContent, HighlightStyleContent, StatusColorsContent, + ThemeColorsContent, ThemeContent, ThemeFamilyContent, ThemeStyleContent, + WindowBackgroundContent, status_colors_refinement, syntax_overrides, theme_colors_refinement, +}; +pub use crate::settings::{ + AgentFontSize, BufferLineHeight, FontFamilyName, IconThemeName, IconThemeSelection, + ThemeAppearanceMode, ThemeName, ThemeSelection, ThemeSettings, adjust_agent_buffer_font_size, + adjust_agent_ui_font_size, adjust_buffer_font_size, adjust_ui_font_size, adjusted_font_size, + appearance_to_mode, clamp_font_size, default_theme, observe_buffer_font_size_adjustment, + reset_agent_buffer_font_size, reset_agent_ui_font_size, reset_buffer_font_size, + reset_ui_font_size, set_icon_theme, set_mode, set_theme, setup_ui_font, +}; +pub use theme::UiDensity; + +struct ThemeSettingsProviderImpl; + +impl ThemeSettingsProvider for ThemeSettingsProviderImpl { + fn ui_font<'a>(&'a self, cx: &'a App) -> &'a Font { + &ThemeSettings::get_global(cx).ui_font + } + + fn buffer_font<'a>(&'a self, cx: &'a App) -> &'a Font { + &ThemeSettings::get_global(cx).buffer_font + } + + fn ui_font_size(&self, cx: &App) -> Pixels { + ThemeSettings::get_global(cx).ui_font_size(cx) + } + + fn buffer_font_size(&self, cx: &App) -> Pixels { + ThemeSettings::get_global(cx).buffer_font_size(cx) + } + + fn ui_density(&self, cx: &App) -> UiDensity { + ThemeSettings::get_global(cx).ui_density + } +} + +/// Initialize the theme system with settings integration. +/// +/// This is the full initialization for the application. It calls [`theme::init`] +/// and then wires up settings observation for theme/font changes. +pub fn init(themes_to_load: LoadThemes, cx: &mut App) { + let load_user_themes = matches!(&themes_to_load, LoadThemes::All(_)); + + theme::init(themes_to_load, cx); + theme::set_theme_settings_provider(Box::new(ThemeSettingsProviderImpl), cx); + + if load_user_themes { + let registry = ThemeRegistry::global(cx); + load_bundled_themes(®istry); + } + + let theme = configured_theme(cx); + let icon_theme = configured_icon_theme(cx); + GlobalTheme::update_theme(cx, theme); + GlobalTheme::update_icon_theme(cx, icon_theme); + + let settings = ThemeSettings::get_global(cx); + + let mut prev_buffer_font_size_settings = settings.buffer_font_size_settings(); + let mut prev_ui_font_size_settings = settings.ui_font_size_settings(); + let mut prev_agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); + let mut prev_agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); + let mut prev_theme_name = settings.theme.name(SystemAppearance::global(cx).0); + let mut prev_icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); + let mut prev_theme_overrides = ( + settings.experimental_theme_overrides.clone(), + settings.theme_overrides.clone(), + ); + + cx.observe_global::(move |cx| { + let settings = ThemeSettings::get_global(cx); + + let buffer_font_size_settings = settings.buffer_font_size_settings(); + let ui_font_size_settings = settings.ui_font_size_settings(); + let agent_ui_font_size_settings = settings.agent_ui_font_size_settings(); + let agent_buffer_font_size_settings = settings.agent_buffer_font_size_settings(); + let theme_name = settings.theme.name(SystemAppearance::global(cx).0); + let icon_theme_name = settings.icon_theme.name(SystemAppearance::global(cx).0); + let theme_overrides = ( + settings.experimental_theme_overrides.clone(), + settings.theme_overrides.clone(), + ); + + if buffer_font_size_settings != prev_buffer_font_size_settings { + prev_buffer_font_size_settings = buffer_font_size_settings; + reset_buffer_font_size(cx); + } + + if ui_font_size_settings != prev_ui_font_size_settings { + prev_ui_font_size_settings = ui_font_size_settings; + reset_ui_font_size(cx); + } + + if agent_ui_font_size_settings != prev_agent_ui_font_size_settings { + prev_agent_ui_font_size_settings = agent_ui_font_size_settings; + reset_agent_ui_font_size(cx); + } + + if agent_buffer_font_size_settings != prev_agent_buffer_font_size_settings { + prev_agent_buffer_font_size_settings = agent_buffer_font_size_settings; + reset_agent_buffer_font_size(cx); + } + + if theme_name != prev_theme_name || theme_overrides != prev_theme_overrides { + prev_theme_name = theme_name; + prev_theme_overrides = theme_overrides; + reload_theme(cx); + } + + if icon_theme_name != prev_icon_theme_name { + prev_icon_theme_name = icon_theme_name; + reload_icon_theme(cx); + } + }) + .detach(); +} + +fn configured_theme(cx: &mut App) -> Arc { + let themes = ThemeRegistry::default_global(cx); + let theme_settings = ThemeSettings::get_global(cx); + let system_appearance = SystemAppearance::global(cx); + + let theme_name = theme_settings.theme.name(*system_appearance); + + let theme = match themes.get(&theme_name.0) { + Ok(theme) => theme, + Err(err) => { + if themes.extensions_loaded() { + log::error!("{err}"); + } + themes + .get(default_theme(*system_appearance)) + .unwrap_or_else(|_| themes.get(DEFAULT_DARK_THEME).unwrap()) + } + }; + theme_settings.apply_theme_overrides(theme) +} + +fn configured_icon_theme(cx: &mut App) -> Arc { + let themes = ThemeRegistry::default_global(cx); + let theme_settings = ThemeSettings::get_global(cx); + let system_appearance = SystemAppearance::global(cx); + + let icon_theme_name = theme_settings.icon_theme.name(*system_appearance); + + match themes.get_icon_theme(&icon_theme_name.0) { + Ok(theme) => theme, + Err(err) => { + if themes.extensions_loaded() { + log::error!("{err}"); + } + themes.get_icon_theme(DEFAULT_ICON_THEME_NAME).unwrap() + } + } +} + +/// Reloads the current theme from settings. +pub fn reload_theme(cx: &mut App) { + let theme = configured_theme(cx); + GlobalTheme::update_theme(cx, theme); + cx.refresh_windows(); +} + +/// Reloads the current icon theme from settings. +pub fn reload_icon_theme(cx: &mut App) { + let icon_theme = configured_icon_theme(cx); + GlobalTheme::update_icon_theme(cx, icon_theme); + cx.refresh_windows(); +} + +/// Loads the themes bundled with the Zed binary into the registry. +pub fn load_bundled_themes(registry: &ThemeRegistry) { + let theme_paths = registry + .assets() + .list("themes/") + .expect("failed to list theme assets") + .into_iter() + .filter(|path| path.ends_with(".json")); + + for path in theme_paths { + let Some(theme) = registry.assets().load(&path).log_err().flatten() else { + continue; + }; + + let Some(theme_family) = serde_json::from_slice(&theme) + .with_context(|| format!("failed to parse theme at path \"{path}\"")) + .log_err() + else { + continue; + }; + + let refined = refine_theme_family(theme_family); + registry.insert_theme_families([refined]); + } +} + +/// Loads a user theme from the given bytes into the registry. +pub fn load_user_theme(registry: &ThemeRegistry, bytes: &[u8]) -> Result<()> { + let theme = deserialize_user_theme(bytes)?; + let refined = refine_theme_family(theme); + registry.insert_theme_families([refined]); + Ok(()) +} + +/// Deserializes a user theme from the given bytes. +pub fn deserialize_user_theme(bytes: &[u8]) -> Result { + let theme_family: ThemeFamilyContent = serde_json_lenient::from_slice(bytes)?; + + for theme in &theme_family.themes { + if theme + .style + .colors + .deprecated_scrollbar_thumb_background + .is_some() + { + log::warn!( + r#"Theme "{theme_name}" is using a deprecated style property: scrollbar_thumb.background. Use `scrollbar.thumb.background` instead."#, + theme_name = theme.name + ) + } + } + + Ok(theme_family) +} + +/// Refines a [`ThemeFamilyContent`] and its [`ThemeContent`]s into a [`ThemeFamily`]. +pub fn refine_theme_family(theme_family_content: ThemeFamilyContent) -> ThemeFamily { + let id = uuid::Uuid::new_v4().to_string(); + let name = theme_family_content.name.clone(); + let author = theme_family_content.author.clone(); + + let themes: Vec = theme_family_content + .themes + .iter() + .map(|theme_content| refine_theme(theme_content)) + .collect(); + + ThemeFamily { + id, + name: name.into(), + author: author.into(), + themes, + scales: default_color_scales(), + } +} + +/// Refines a [`ThemeContent`] into a [`Theme`]. +pub fn refine_theme(theme: &ThemeContent) -> Theme { + let appearance = match theme.appearance { + AppearanceContent::Light => Appearance::Light, + AppearanceContent::Dark => Appearance::Dark, + }; + + let mut refined_status_colors = match theme.appearance { + AppearanceContent::Light => StatusColors::light(), + AppearanceContent::Dark => StatusColors::dark(), + }; + let mut status_colors_refinement = status_colors_refinement(&theme.style.status); + theme::apply_status_color_defaults(&mut status_colors_refinement); + refined_status_colors.refine(&status_colors_refinement); + + let mut refined_player_colors = match theme.appearance { + AppearanceContent::Light => PlayerColors::light(), + AppearanceContent::Dark => PlayerColors::dark(), + }; + merge_player_colors(&mut refined_player_colors, &theme.style.players); + + let mut refined_theme_colors = match theme.appearance { + AppearanceContent::Light => ThemeColors::light(), + AppearanceContent::Dark => ThemeColors::dark(), + }; + let mut theme_colors_refinement = + theme_colors_refinement(&theme.style.colors, &status_colors_refinement); + theme::apply_theme_color_defaults(&mut theme_colors_refinement, &refined_player_colors); + refined_theme_colors.refine(&theme_colors_refinement); + + let mut refined_accent_colors = match theme.appearance { + AppearanceContent::Light => AccentColors::light(), + AppearanceContent::Dark => AccentColors::dark(), + }; + merge_accent_colors(&mut refined_accent_colors, &theme.style.accents); + + let syntax_highlights = theme.style.syntax.iter().map(|(syntax_token, highlight)| { + ( + syntax_token.clone(), + HighlightStyle { + color: highlight + .color + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + background_color: highlight + .background_color + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + font_style: highlight.font_style.map(|s| s.into_gpui()), + font_weight: highlight.font_weight.map(|w| w.into_gpui()), + ..Default::default() + }, + ) + }); + let syntax_theme = Arc::new(SyntaxTheme::new(syntax_highlights)); + + let window_background_appearance = theme + .style + .window_background_appearance + .map(|w| w.into_gpui()) + .unwrap_or_default(); + + Theme { + id: uuid::Uuid::new_v4().to_string(), + name: theme.name.clone().into(), + appearance, + styles: ThemeStyles { + system: SystemColors::default(), + window_background_appearance, + accents: refined_accent_colors, + colors: refined_theme_colors, + status: refined_status_colors, + player: refined_player_colors, + syntax: syntax_theme, + }, + } +} + +/// Merges player color overrides into the given [`PlayerColors`]. +pub fn merge_player_colors( + player_colors: &mut PlayerColors, + user_player_colors: &[::settings::PlayerColorContent], +) { + if user_player_colors.is_empty() { + return; + } + + for (idx, player) in user_player_colors.iter().enumerate() { + let cursor = player + .cursor + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let background = player + .background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let selection = player + .selection + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + + if let Some(player_color) = player_colors.0.get_mut(idx) { + *player_color = PlayerColor { + cursor: cursor.unwrap_or(player_color.cursor), + background: background.unwrap_or(player_color.background), + selection: selection.unwrap_or(player_color.selection), + }; + } else { + player_colors.0.push(PlayerColor { + cursor: cursor.unwrap_or_default(), + background: background.unwrap_or_default(), + selection: selection.unwrap_or_default(), + }); + } + } +} + +/// Merges accent color overrides into the given [`AccentColors`]. +pub fn merge_accent_colors( + accent_colors: &mut AccentColors, + user_accent_colors: &[::settings::AccentContent], +) { + if user_accent_colors.is_empty() { + return; + } + + let colors = user_accent_colors + .iter() + .filter_map(|accent_color| { + accent_color + .0 + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + }) + .collect::>(); + + if !colors.is_empty() { + accent_colors.0 = Arc::from(colors); + } +} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 5eb58bf1da1f25cc273a9fc5d7c08b920d3471e9..6ea1b6d26f700c9c44a8dda5e510d0505d7e7db8 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -29,7 +29,7 @@ story = { workspace = true, optional = true } strum.workspace = true theme.workspace = true ui_macros.workspace = true -util.workspace = true +gpui_util.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 95244a382b988380339d649473c35fcac66f6d7a..b20692740cb1399a562d160c80297a076f7d4516 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -18,6 +18,13 @@ pub enum AgentThreadStatus { Error, } +#[derive(Clone)] +pub struct ThreadItemWorktreeInfo { + pub name: SharedString, + pub full_path: SharedString, + pub highlight_positions: Vec, +} + #[derive(IntoElement, RegisterComponent)] pub struct ThreadItem { id: ElementId, @@ -37,9 +44,7 @@ pub struct ThreadItem { hovered: bool, added: Option, removed: Option, - worktree: Option, - worktree_full_path: Option, - worktree_highlight_positions: Vec, + worktrees: Vec, on_click: Option>, on_hover: Box, action_slot: Option, @@ -66,9 +71,7 @@ impl ThreadItem { hovered: false, added: None, removed: None, - worktree: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), + worktrees: Vec::new(), on_click: None, on_hover: Box::new(|_, _, _| {}), action_slot: None, @@ -146,18 +149,8 @@ impl ThreadItem { self } - pub fn worktree(mut self, worktree: impl Into) -> Self { - self.worktree = Some(worktree.into()); - self - } - - pub fn worktree_full_path(mut self, worktree_full_path: impl Into) -> Self { - self.worktree_full_path = Some(worktree_full_path.into()); - self - } - - pub fn worktree_highlight_positions(mut self, positions: Vec) -> Self { - self.worktree_highlight_positions = positions; + pub fn worktrees(mut self, worktrees: Vec) -> Self { + self.worktrees = worktrees; self } @@ -319,7 +312,7 @@ impl RenderOnce for ThreadItem { let added_count = self.added.unwrap_or(0); let removed_count = self.removed.unwrap_or(0); - let has_worktree = self.worktree.is_some(); + let has_worktree = !self.worktrees.is_empty(); let has_timestamp = !self.timestamp.is_empty(); let timestamp = self.timestamp; @@ -376,48 +369,67 @@ impl RenderOnce for ThreadItem { }), ) .when(has_worktree || has_diff_stats || has_timestamp, |this| { - let worktree_full_path = self.worktree_full_path.clone().unwrap_or_default(); - let worktree_label = self.worktree.map(|worktree| { - let positions = self.worktree_highlight_positions; - if positions.is_empty() { - Label::new(worktree) + // Collect all full paths for the shared tooltip. + let worktree_tooltip: SharedString = self + .worktrees + .iter() + .map(|wt| wt.full_path.as_ref()) + .collect::>() + .join("\n") + .into(); + let worktree_tooltip_title = if self.worktrees.len() > 1 { + "Thread Running in Local Git Worktrees" + } else { + "Thread Running in a Local Git Worktree" + }; + + // Deduplicate chips by name — e.g. two paths both named + // "olivetti" produce a single chip. Highlight positions + // come from the first occurrence. + let mut seen_names: Vec = Vec::new(); + let mut worktree_chips: Vec = Vec::new(); + for wt in self.worktrees { + if seen_names.contains(&wt.name) { + continue; + } + let chip_index = seen_names.len(); + seen_names.push(wt.name.clone()); + let label = if wt.highlight_positions.is_empty() { + Label::new(wt.name) .size(LabelSize::Small) .color(Color::Muted) .into_any_element() } else { - HighlightedLabel::new(worktree, positions) + HighlightedLabel::new(wt.name, wt.highlight_positions) .size(LabelSize::Small) .color(Color::Muted) .into_any_element() - } - }); + }; + let tooltip_title = worktree_tooltip_title; + let tooltip_meta = worktree_tooltip.clone(); + worktree_chips.push( + h_flex() + .id(format!("{}-worktree-{chip_index}", self.id.clone())) + .gap_0p5() + .child( + Icon::new(IconName::GitWorktree) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child(label) + .tooltip(move |_, cx| { + Tooltip::with_meta(tooltip_title, None, tooltip_meta.clone(), cx) + }) + .into_any_element(), + ); + } this.child( h_flex() .min_w_0() .gap_1p5() .child(icon_container()) // Icon Spacing - .when_some(worktree_label, |this, label| { - this.child( - h_flex() - .id(format!("{}-worktree", self.id.clone())) - .gap_0p5() - .child( - Icon::new(IconName::GitWorktree) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child(label) - .tooltip(move |_, cx| { - Tooltip::with_meta( - "Thread Running in a Local Git Worktree", - None, - worktree_full_path.clone(), - cx, - ) - }), - ) - }) + .children(worktree_chips) .when(has_worktree && (has_diff_stats || has_timestamp), |this| { this.child(dot_separator()) }) @@ -526,7 +538,11 @@ impl Component for ThreadItem { ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock") .icon(IconName::AiClaude) .timestamp("2w") - .worktree("link-agent-panel"), + .worktrees(vec![ThreadItemWorktreeInfo { + name: "link-agent-panel".into(), + full_path: "link-agent-panel".into(), + highlight_positions: Vec::new(), + }]), ) .into_any_element(), ), @@ -548,7 +564,11 @@ impl Component for ThreadItem { .child( ThreadItem::new("ti-5b", "Full metadata example") .icon(IconName::AiClaude) - .worktree("my-project") + .worktrees(vec![ThreadItemWorktreeInfo { + name: "my-project".into(), + full_path: "my-project".into(), + highlight_positions: Vec::new(), + }]) .added(42) .removed(17) .timestamp("3w"), @@ -623,8 +643,11 @@ impl Component for ThreadItem { ThreadItem::new("ti-11", "Search in worktree name") .icon(IconName::AiClaude) .timestamp("3mo") - .worktree("my-project-name") - .worktree_highlight_positions(vec![3, 4, 5, 6, 7, 8, 9, 10, 11]), + .worktrees(vec![ThreadItemWorktreeInfo { + name: "my-project-name".into(), + full_path: "my-project-name".into(), + highlight_positions: vec![3, 4, 5, 6, 7, 8, 9, 10, 11], + }]), ) .into_any_element(), ), diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 064b67a433f0d053db9552e8def1064237db3980..2fcfd73b93d7c47018819fd9ec4426e9f1b38147 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -8,14 +8,12 @@ use gpui::{ Subscription, anchored, canvas, prelude::*, px, }; use menu::{SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious}; -use settings::Settings; use std::{ cell::{Cell, RefCell}, collections::HashMap, rc::Rc, time::{Duration, Instant}, }; -use theme::ThemeSettings; #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum SubmenuOpenTrigger { @@ -2050,7 +2048,7 @@ impl ContextMenuItem { impl Render for ContextMenu { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx); + let ui_font_size = theme::theme_settings(cx).ui_font_size(cx); let window_size = window.viewport_size(); let rem_size = window.rem_size(); let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0; diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index e22669995db416a3ec6884a79860e76610dd7d03..6c7efa4e49ee93fd13407c03cf383ff3385bacc7 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -1,6 +1,7 @@ use std::rc::Rc; use crate::PlatformStyle; +use crate::utils::capitalize; use crate::{Icon, IconName, IconSize, h_flex, prelude::*}; use gpui::{ Action, AnyElement, App, FocusHandle, Global, IntoElement, KeybindingKeystroke, Keystroke, @@ -142,7 +143,7 @@ fn render_key( match key_icon { Some(icon) => KeyIcon::new(icon, color).size(size).into_any_element(), None => { - let key = util::capitalize(key); + let key = capitalize(key); Key::new(&key, color).size(size).into_any_element() } } @@ -546,7 +547,7 @@ fn keystroke_text( let key = match key { "pageup" => "PageUp", "pagedown" => "PageDown", - key => &util::capitalize(key), + key => &capitalize(key), }; text.push_str(key); } diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index 1b10d910dd0ed1501188781622851e720c0ca102..73e03f82dfdef38f10c62b69be3b75da8a24dd08 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -29,6 +29,33 @@ impl HighlightedLabel { } } + /// Constructs a label with the given byte ranges highlighted. + /// Assumes that the highlight ranges are valid UTF-8 byte positions. + pub fn from_ranges( + label: impl Into, + highlight_ranges: Vec>, + ) -> Self { + let label = label.into(); + let highlight_indices = highlight_ranges + .iter() + .flat_map(|range| { + let mut indices = Vec::new(); + let mut index = range.start; + while index < range.end { + indices.push(index); + index += label[index..].chars().next().map_or(0, |c| c.len_utf8()); + } + indices + }) + .collect(); + + Self { + base: LabelLike::new(), + label, + highlight_indices, + } + } + pub fn text(&self) -> &str { self.label.as_str() } diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index d87bdf6c12323c4858881f36af62f1a91cdd2aa1..5cad04efcfabcc80648c005f8d18ec5805970a39 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -1,8 +1,6 @@ use crate::prelude::*; use gpui::{FontWeight, Rems, StyleRefinement, UnderlineStyle}; -use settings::Settings; use smallvec::SmallVec; -use theme::ThemeSettings; /// Sets the size of a label #[derive(Debug, PartialEq, Clone, Copy, Default)] @@ -191,7 +189,7 @@ impl LabelCommon for LabelLike { } fn buffer_font(mut self, cx: &App) -> Self { - let font = theme::ThemeSettings::get_global(cx).buffer_font.clone(); + let font = theme::theme_settings(cx).buffer_font(cx).clone(); self.weight = Some(font.weight); self.base = self.base.font(font); self @@ -200,7 +198,7 @@ impl LabelCommon for LabelLike { fn inline_code(mut self, cx: &App) -> Self { self.base = self .base - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .font(theme::theme_settings(cx).buffer_font(cx).clone()) .bg(cx.theme().colors().element_background) .rounded_sm() .px_0p5(); @@ -258,7 +256,7 @@ impl RenderOnce for LabelLike { .text_color(color) .font_weight( self.weight - .unwrap_or(ThemeSettings::get_global(cx).ui_font.weight), + .unwrap_or(theme::theme_settings(cx).ui_font(cx).weight), ) .children(self.children) } diff --git a/crates/ui/src/components/list/list_header.rs b/crates/ui/src/components/list/list_header.rs index 8726dca50dada193b3051f14b6609a373fc60730..9d72366c3be4907c7d4e9e3dc0466903cbc58069 100644 --- a/crates/ui/src/components/list/list_header.rs +++ b/crates/ui/src/components/list/list_header.rs @@ -3,8 +3,7 @@ use std::sync::Arc; use crate::{Disclosure, prelude::*}; use component::{Component, ComponentScope, example_group_with_title, single_example}; use gpui::{AnyElement, ClickEvent}; -use settings::Settings; -use theme::ThemeSettings; +use theme::UiDensity; #[derive(IntoElement, RegisterComponent)] pub struct ListHeader { @@ -81,7 +80,7 @@ impl Toggleable for ListHeader { impl RenderOnce for ListHeader { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let ui_density = ThemeSettings::get_global(cx).ui_density; + let ui_density = theme::theme_settings(cx).ui_density(cx); h_flex() .id(self.label.clone()) @@ -91,7 +90,7 @@ impl RenderOnce for ListHeader { .child( div() .map(|this| match ui_density { - theme::UiDensity::Comfortable => this.h_5(), + UiDensity::Comfortable => this.h_5(), _ => this.h_7(), }) .when(self.inset, |this| this.px_2()) diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index d0c720d5081d3ab7ad700df798b931933e03db28..795c5174fb42d3caeec3052d3c636b0408ac7ed6 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -15,10 +15,10 @@ use gpui::{ UniformListScrollHandle, Window, ease_in_out, prelude::FluentBuilder as _, px, quad, relative, size, }; +use gpui_util::ResultExt; use settings::SettingsStore; use smallvec::SmallVec; use theme::ActiveTheme as _; -use util::ResultExt; use std::ops::Range; diff --git a/crates/ui/src/components/tooltip.rs b/crates/ui/src/components/tooltip.rs index 8b4ff3f73163f38e19da80462e687db3d88efc6f..8124b4ecbafdc6b096e91892741fe774e3ba032f 100644 --- a/crates/ui/src/components/tooltip.rs +++ b/crates/ui/src/components/tooltip.rs @@ -1,12 +1,9 @@ use std::borrow::Borrow; use std::rc::Rc; -use gpui::{Action, AnyElement, AnyView, AppContext, FocusHandle, IntoElement, Render}; -use settings::Settings; -use theme::ThemeSettings; - use crate::prelude::*; use crate::{Color, KeyBinding, Label, LabelSize, StyledExt, h_flex, v_flex}; +use gpui::{Action, AnyElement, AnyView, AppContext, FocusHandle, IntoElement, Render}; #[derive(RegisterComponent)] pub struct Tooltip { @@ -221,7 +218,7 @@ where C: AppContext + Borrow, { let app = (*cx).borrow(); - let ui_font = ThemeSettings::get_global(app).ui_font.clone(); + let ui_font = theme::theme_settings(app).ui_font(app).clone(); // padding to avoid tooltip appearing right below the mouse cursor div().pl_2().pt_2p5().child( diff --git a/crates/ui/src/styles/spacing.rs b/crates/ui/src/styles/spacing.rs index c6629f5d8829b2ebd59a80a2a22c033ab8c389f6..50d5446ebc25826e6c0665e906141d77ba78d584 100644 --- a/crates/ui/src/styles/spacing.rs +++ b/crates/ui/src/styles/spacing.rs @@ -1,6 +1,5 @@ use gpui::{App, Pixels, Rems, px, rems}; -use settings::Settings; -use theme::{ThemeSettings, UiDensity}; +use theme::UiDensity; use ui_macros::derive_dynamic_spacing; // Derives [DynamicSpacing]. See [ui_macros::derive_dynamic_spacing]. @@ -51,5 +50,5 @@ derive_dynamic_spacing![ /// /// Always use [DynamicSpacing] for spacing values. pub fn ui_density(cx: &mut App) -> UiDensity { - ThemeSettings::get_global(cx).ui_density + theme::theme_settings(cx).ui_density(cx) } diff --git a/crates/ui/src/styles/typography.rs b/crates/ui/src/styles/typography.rs index 2bb0b35720be715251bc7c11a139a1fccfaf6035..69790d3d3dae6bbc8728a63af806357a276ed67a 100644 --- a/crates/ui/src/styles/typography.rs +++ b/crates/ui/src/styles/typography.rs @@ -3,8 +3,7 @@ use gpui::{ AnyElement, App, IntoElement, ParentElement, Rems, RenderOnce, SharedString, Styled, Window, div, rems, }; -use settings::Settings; -use theme::{ActiveTheme, ThemeSettings}; +use theme::ActiveTheme; use crate::{Color, rems_from_px}; @@ -12,16 +11,16 @@ use crate::{Color, rems_from_px}; pub trait StyledTypography: Styled + Sized { /// Sets the font family to the buffer font. fn font_buffer(self, cx: &App) -> Self { - let settings = ThemeSettings::get_global(cx); - let buffer_font_family = settings.buffer_font.family.clone(); + let settings = theme::theme_settings(cx); + let buffer_font_family = settings.buffer_font(cx).family.clone(); self.font_family(buffer_font_family) } /// Sets the font family to the UI font. fn font_ui(self, cx: &App) -> Self { - let settings = ThemeSettings::get_global(cx); - let ui_font_family = settings.ui_font.family.clone(); + let settings = theme::theme_settings(cx); + let ui_font_family = settings.ui_font(cx).family.clone(); self.font_family(ui_font_family) } @@ -82,7 +81,7 @@ pub trait StyledTypography: Styled + Sized { /// This should only be used for text that is displayed in a buffer, /// or other places that text needs to match the user's buffer font size. fn text_buffer(self, cx: &App) -> Self { - let settings = ThemeSettings::get_global(cx); + let settings = theme::theme_settings(cx); self.text_size(settings.buffer_font_size(cx)) } } @@ -133,28 +132,28 @@ pub enum TextSize { impl TextSize { /// Returns the text size in rems. pub fn rems(self, cx: &App) -> Rems { - let theme_settings = ThemeSettings::get_global(cx); + let settings = theme::theme_settings(cx); match self { Self::Large => rems_from_px(16.), Self::Default => rems_from_px(14.), Self::Small => rems_from_px(12.), Self::XSmall => rems_from_px(10.), - Self::Ui => rems_from_px(theme_settings.ui_font_size(cx)), - Self::Editor => rems_from_px(theme_settings.buffer_font_size(cx)), + Self::Ui => rems_from_px(settings.ui_font_size(cx)), + Self::Editor => rems_from_px(settings.buffer_font_size(cx)), } } pub fn pixels(self, cx: &App) -> Pixels { - let theme_settings = ThemeSettings::get_global(cx); + let settings = theme::theme_settings(cx); match self { Self::Large => px(16.), Self::Default => px(14.), Self::Small => px(12.), Self::XSmall => px(10.), - Self::Ui => theme_settings.ui_font_size(cx), - Self::Editor => theme_settings.buffer_font_size(cx), + Self::Ui => settings.ui_font_size(cx), + Self::Editor => settings.buffer_font_size(cx), } } } @@ -212,7 +211,7 @@ pub struct Headline { impl RenderOnce for Headline { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let ui_font = ThemeSettings::get_global(cx).ui_font.clone(); + let ui_font = theme::theme_settings(cx).ui_font(cx).clone(); div() .font(ui_font) diff --git a/crates/ui/src/utils.rs b/crates/ui/src/utils.rs index 2f2a148e1985d026371c96297eb92cc4ec079a3b..d88bf4a45e0b54536b6f5ca5ad4ae7c7fe936937 100644 --- a/crates/ui/src/utils.rs +++ b/crates/ui/src/utils.rs @@ -34,3 +34,25 @@ pub fn reveal_in_file_manager_label(is_remote: bool) -> &'static str { "Reveal in File Manager" } } + +/// Capitalizes the first character of a string. +/// +/// This function takes a string slice as input and returns a new `String` with the first character +/// capitalized. +/// +/// # Examples +/// +/// ``` +/// use ui::utils::capitalize; +/// +/// assert_eq!(capitalize("hello"), "Hello"); +/// assert_eq!(capitalize("WORLD"), "WORLD"); +/// assert_eq!(capitalize(""), ""); +/// ``` +pub fn capitalize(str: &str) -> String { + let mut chars = str.chars(); + match chars.next() { + None => String::new(), + Some(first_char) => first_char.to_uppercase().collect::() + chars.as_str(), + } +} diff --git a/crates/ui_macros/src/dynamic_spacing.rs b/crates/ui_macros/src/dynamic_spacing.rs index 15ba3e241ec43d02b83e4143eb620505a0a2f02e..f1207f5487a89f0afbd23e620da4c4cf4172be9a 100644 --- a/crates/ui_macros/src/dynamic_spacing.rs +++ b/crates/ui_macros/src/dynamic_spacing.rs @@ -65,7 +65,7 @@ pub fn derive_spacing(input: TokenStream) -> TokenStream { DynamicSpacingValue::Single(n) => { let n = n.base10_parse::().unwrap(); quote! { - DynamicSpacing::#variant => match ThemeSettings::get_global(cx).ui_density { + DynamicSpacing::#variant => match ::theme::theme_settings(cx).ui_density(cx) { ::theme::UiDensity::Compact => (#n - 4.0).max(0.0) / BASE_REM_SIZE_IN_PX, ::theme::UiDensity::Default => #n / BASE_REM_SIZE_IN_PX, ::theme::UiDensity::Comfortable => (#n + 4.0) / BASE_REM_SIZE_IN_PX, @@ -77,7 +77,7 @@ pub fn derive_spacing(input: TokenStream) -> TokenStream { let b = b.base10_parse::().unwrap(); let c = c.base10_parse::().unwrap(); quote! { - DynamicSpacing::#variant => match ThemeSettings::get_global(cx).ui_density { + DynamicSpacing::#variant => match ::theme::theme_settings(cx).ui_density(cx) { ::theme::UiDensity::Compact => #a / BASE_REM_SIZE_IN_PX, ::theme::UiDensity::Default => #b / BASE_REM_SIZE_IN_PX, ::theme::UiDensity::Comfortable => #c / BASE_REM_SIZE_IN_PX, @@ -157,7 +157,7 @@ pub fn derive_spacing(input: TokenStream) -> TokenStream { /// Returns the spacing value in pixels. pub fn px(&self, cx: &App) -> Pixels { - let ui_font_size_f32: f32 = ThemeSettings::get_global(cx).ui_font_size(cx).into(); + let ui_font_size_f32: f32 = ::theme::theme_settings(cx).ui_font_size(cx).into(); px(ui_font_size_f32 * self.spacing_ratio(cx)) } } diff --git a/crates/ui_prompt/Cargo.toml b/crates/ui_prompt/Cargo.toml index 55a98288433a7b31507310e20c4209a9d419e45f..9bcce107f3f7d6bd95ebddf6d33c4a9a29ec4493 100644 --- a/crates/ui_prompt/Cargo.toml +++ b/crates/ui_prompt/Cargo.toml @@ -19,6 +19,6 @@ gpui.workspace = true markdown.workspace = true menu.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true workspace.workspace = true diff --git a/crates/ui_prompt/src/ui_prompt.rs b/crates/ui_prompt/src/ui_prompt.rs index 3b2716fd92ea7889668767d66e47e5c43792f39e..92b1c9e74dcd2f7e227f5c325ea5defb0d9c8ed3 100644 --- a/crates/ui_prompt/src/ui_prompt.rs +++ b/crates/ui_prompt/src/ui_prompt.rs @@ -5,7 +5,7 @@ use gpui::{ }; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use settings::{Settings, SettingsStore}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{FluentBuilder, TintColor, prelude::*}; use workspace::WorkspaceSettings; diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 4f129ef6d529aff0991b86882e5e60b6ad837d5c..bd8ab4e2d4d99864c5e0dc228410904f3338d7c6 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -686,28 +686,6 @@ impl PartialOrd for NumericPrefixWithSuffix<'_> { } } -/// Capitalizes the first character of a string. -/// -/// This function takes a string slice as input and returns a new `String` with the first character -/// capitalized. -/// -/// # Examples -/// -/// ``` -/// use util::capitalize; -/// -/// assert_eq!(capitalize("hello"), "Hello"); -/// assert_eq!(capitalize("WORLD"), "WORLD"); -/// assert_eq!(capitalize(""), ""); -/// ``` -pub fn capitalize(str: &str) -> String { - let mut chars = str.chars(); - match chars.next() { - None => String::new(), - Some(first_char) => first_char.to_uppercase().collect::() + chars.as_str(), - } -} - fn emoji_regex() -> &'static Regex { static EMOJI_REGEX: LazyLock = LazyLock::new(|| Regex::new("(\\p{Emoji}|\u{200D})").unwrap()); diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 7b4cff5ff9bdf37666076c403593c45131a63067..64282953a33312b85cc1e7cf21076b0cb61dccab 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -44,6 +44,7 @@ settings.workspace = true task.workspace = true text.workspace = true theme.workspace = true +theme_settings.workspace = true menu.workspace = true tokio = { version = "1.15", features = ["full"], optional = true } ui.workspace = true diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 56241275b5d8fa6de3645c6d00361b29dc49d259..c1e766c03a897facb3c7acf76b3ef7811e6910a8 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -1898,6 +1898,91 @@ mod test { ); } + #[gpui::test] + async fn test_helix_insert_before_after_select_lines(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + "line one\nline ˇtwo\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("2 x"); + cx.assert_state( + "line one\n«line two\nline three\nˇ»line four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("o"); + cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert); + + cx.set_state( + "line one\nline ˇtwo\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("2 x"); + cx.assert_state( + "line one\n«line two\nline three\nˇ»line four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("shift-o"); + cx.assert_state("line one\nˇ\nline two\nline three\nline four", Mode::Insert); + } + + #[gpui::test] + async fn test_helix_insert_before_after_helix_select(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // Test new line in selection direction + cx.set_state( + "ˇline one\nline two\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v j j"); + cx.assert_state( + "«line one\nline two\nlˇ»ine three\nline four", + Mode::HelixSelect, + ); + cx.simulate_keystrokes("o"); + cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert); + + cx.set_state( + "line one\nline two\nˇline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v k k"); + cx.assert_state( + "«ˇline one\nline two\nl»ine three\nline four", + Mode::HelixSelect, + ); + cx.simulate_keystrokes("shift-o"); + cx.assert_state("ˇ\nline one\nline two\nline three\nline four", Mode::Insert); + + // Test new line in opposite selection direction + cx.set_state( + "ˇline one\nline two\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v j j"); + cx.assert_state( + "«line one\nline two\nlˇ»ine three\nline four", + Mode::HelixSelect, + ); + cx.simulate_keystrokes("shift-o"); + cx.assert_state("ˇ\nline one\nline two\nline three\nline four", Mode::Insert); + + cx.set_state( + "line one\nline two\nˇline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v k k"); + cx.assert_state( + "«ˇline one\nline two\nl»ine three\nline four", + Mode::HelixSelect, + ); + cx.simulate_keystrokes("o"); + cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert); + } + #[gpui::test] async fn test_helix_select_mode_motion(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 6763c5cddb8bf2cda6aa4fa0988ff6be67119d3c..118805586118e36269a1f0c1d1d619058133da30 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -731,10 +731,10 @@ impl Vim { .collect::>(); editor.edit_with_autoindent(edits, cx); editor.change_selections(Default::default(), window, cx, |s| { - s.move_cursors_with(&mut |map, cursor, _| { - let previous_line = map.start_of_relative_buffer_row(cursor, -1); + s.move_with(&mut |map, selection| { + let previous_line = map.start_of_relative_buffer_row(selection.start, -1); let insert_point = motion::end_of_line(map, false, previous_line, 1); - (insert_point, SelectionGoal::None) + selection.collapse_to(insert_point, SelectionGoal::None) }); }); }); @@ -750,14 +750,19 @@ impl Vim { self.start_recording(cx); self.switch_mode(Mode::Insert, false, window, cx); self.update_editor(cx, |_, editor, cx| { - let text_layout_details = editor.text_layout_details(window, cx); editor.transact(window, cx, |editor, window, cx| { let selections = editor.selections.all::(&editor.display_snapshot(cx)); let snapshot = editor.buffer().read(cx).snapshot(cx); let selection_end_rows: BTreeSet = selections .into_iter() - .map(|selection| selection.end.row) + .map(|selection| { + if !selection.is_empty() && selection.end.column == 0 { + selection.end.row.saturating_sub(1) + } else { + selection.end.row + } + }) .collect(); let edits = selection_end_rows .into_iter() @@ -772,14 +777,17 @@ impl Vim { }) .collect::>(); editor.change_selections(Default::default(), window, cx, |s| { - s.maybe_move_cursors_with(&mut |map, cursor, goal| { - Motion::CurrentLine.move_point( - map, - cursor, - goal, - None, - &text_layout_details, - ) + s.move_with(&mut |map, selection| { + let current_line = if !selection.is_empty() && selection.end.column() == 0 { + // If this is an insert after a selection to the end of the line, the + // cursor needs to be bumped back, because it'll be at the start of the + // *next* line. + map.start_of_relative_buffer_row(selection.end, -1) + } else { + selection.end + }; + let insert_point = motion::end_of_line(map, false, current_line, 1); + selection.collapse_to(insert_point, SelectionGoal::None) }); }); editor.edit_with_autoindent(edits, cx); diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 0b68eb7dd0cac51352d5119c7a0c07ae8f8abb3d..2ae4abe33a0fbb4bc6f8a838e60dc0857949e0dc 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -29,7 +29,7 @@ use std::collections::HashSet; use std::path::Path; use std::{fmt::Display, ops::Range, sync::Arc}; use text::{Bias, ToPoint}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ActiveTheme, Context, Div, FluentBuilder, KeyBinding, ParentElement, SharedString, Styled, StyledTypography, Window, h_flex, rems, diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index d8574bb1b76b707fe9d36545ea054480cf097d64..510d218df050455d0df0f9c2b7b782a651694cd7 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -27,7 +27,7 @@ impl VimTestContext { git_ui::init(cx); crate::init(cx); search::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); settings_ui::init(cx); markdown_preview::init(cx); zed_actions::init(); diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 1c2416dcdb4b9a4a06970c66aded0816faf21cd0..eb2248ded0e574ca0d30206237694d50bc7f152e 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -51,7 +51,7 @@ pub use settings::{ use state::{Mode, Operator, RecordedSelection, SearchState, VimGlobals}; use std::{mem, ops::Range, sync::Arc}; use surrounds::SurroundsType; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{IntoElement, SharedString, px}; use vim_mode_setting::HelixModeSetting; use vim_mode_setting::VimModeSetting; @@ -1210,7 +1210,7 @@ impl Vim { return; } - if !mode.is_visual() && last_mode.is_visual() { + if !mode.is_visual() && last_mode.is_visual() && !last_mode.is_helix() { self.create_visual_marks(last_mode, window, cx); } @@ -1277,7 +1277,7 @@ impl Vim { } s.move_with(&mut |map, selection| { - if last_mode.is_visual() && !mode.is_visual() { + if last_mode.is_visual() && !last_mode.is_helix() && !mode.is_visual() { let mut point = selection.head(); if !selection.reversed && !selection.is_empty() { point = movement::left(map, selection.head()); diff --git a/crates/which_key/Cargo.toml b/crates/which_key/Cargo.toml index f53ba45dd71abc972ce23efb8871f485dfe47207..cafcc2306b89d805f3e02b70060e4bb23b3436ff 100644 --- a/crates/which_key/Cargo.toml +++ b/crates/which_key/Cargo.toml @@ -17,7 +17,7 @@ command_palette.workspace = true gpui.workspace = true serde.workspace = true settings.workspace = true -theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/which_key/src/which_key_modal.rs b/crates/which_key/src/which_key_modal.rs index 238431b90a8eafdd0e085a3f109e8f812fbe709b..38b99207ea693b0cfc4113c4d4a4d70940090014 100644 --- a/crates/which_key/src/which_key_modal.rs +++ b/crates/which_key/src/which_key_modal.rs @@ -7,7 +7,7 @@ use gpui::{ }; use settings::Settings; use std::collections::HashMap; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Divider, DividerColor, DynamicSpacing, LabelSize, WithScrollbar, prelude::*, text_for_keystrokes, diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index fd160fd3024564d7451be0c29958cbb4a33eee38..42e64504f348a727d17d2538d06556497fba54df 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -63,6 +63,7 @@ strum.workspace = true task.workspace = true telemetry.workspace = true theme.workspace = true +theme_settings.workspace = true ui.workspace = true util.workspace = true uuid.workspace = true diff --git a/crates/workspace/src/active_file_name.rs b/crates/workspace/src/active_file_name.rs new file mode 100644 index 0000000000000000000000000000000000000000..f35312d529423c4dc81bb71dc585c99169afdd39 --- /dev/null +++ b/crates/workspace/src/active_file_name.rs @@ -0,0 +1,69 @@ +use gpui::{ + Context, Empty, EventEmitter, IntoElement, ParentElement, Render, SharedString, Window, +}; +use settings::Settings; +use ui::{Button, Tooltip, prelude::*}; +use util::paths::PathStyle; + +use crate::{StatusItemView, item::ItemHandle, workspace_settings::StatusBarSettings}; + +pub struct ActiveFileName { + project_path: Option, + full_path: Option, +} + +impl ActiveFileName { + pub fn new() -> Self { + Self { + project_path: None, + full_path: None, + } + } +} + +impl Render for ActiveFileName { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !StatusBarSettings::get_global(cx).show_active_file { + return Empty.into_any_element(); + } + + let Some(project_path) = self.project_path.clone() else { + return Empty.into_any_element(); + }; + + let tooltip_text = self + .full_path + .clone() + .unwrap_or_else(|| project_path.clone()); + + div() + .child( + Button::new("active-file-name-button", project_path) + .label_size(LabelSize::Small) + .tooltip(Tooltip::text(tooltip_text)), + ) + .into_any_element() + } +} + +impl EventEmitter for ActiveFileName {} + +impl StatusItemView for ActiveFileName { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(item) = active_pane_item { + self.project_path = item + .project_path(cx) + .map(|path| path.path.display(PathStyle::local()).into_owned().into()); + self.full_path = item.tab_tooltip_text(cx); + } else { + self.project_path = None; + self.full_path = None; + } + cx.notify(); + } +} diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 131c02e9c885b66ddf32ed6d2a0dfb01d2764a49..e0870503b7c64bb23218d897bc6b4828d315c8b8 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -776,17 +776,9 @@ impl Dock { } } - pub fn panel_size(&self, panel: &dyn PanelHandle, window: &Window, cx: &App) -> Option { - self.panel_entries - .iter() - .find(|entry| entry.panel.panel_id() == panel.panel_id()) - .map(|entry| self.resolved_panel_size(entry, window, cx)) - } - - pub fn active_panel_size(&self, window: &Window, cx: &App) -> Option { + pub fn active_panel_size(&self) -> Option { if self.is_open { - self.active_panel_entry() - .map(|entry| self.resolved_panel_size(entry, window, cx)) + self.active_panel_entry().map(|entry| entry.size_state) } else { None } @@ -947,28 +939,6 @@ impl Dock { } } - fn resolved_panel_size(&self, entry: &PanelEntry, window: &Window, cx: &App) -> Pixels { - if self.position.axis() == Axis::Horizontal - && entry.panel.supports_flexible_size(window, cx) - { - if let Some(workspace) = self.workspace.upgrade() { - let workspace = workspace.read(cx); - return resolve_panel_size( - entry.size_state, - entry.panel.as_ref(), - self.position, - workspace, - window, - cx, - ); - } - } - entry - .size_state - .size - .unwrap_or_else(|| entry.panel.default_size(window, cx)) - } - pub(crate) fn load_persisted_size_state( workspace: &Workspace, panel_key: &'static str, @@ -988,41 +958,10 @@ impl Dock { } } -pub(crate) fn resolve_panel_size( - size_state: PanelSizeState, - panel: &dyn PanelHandle, - position: DockPosition, - workspace: &Workspace, - window: &Window, - cx: &App, -) -> Pixels { - if position.axis() == Axis::Horizontal && panel.supports_flexible_size(window, cx) { - let ratio = size_state - .flexible_size_ratio - .or_else(|| workspace.default_flexible_dock_ratio(position)); - - if let Some(ratio) = ratio { - return workspace - .flexible_dock_size(position, ratio, window, cx) - .unwrap_or_else(|| { - size_state - .size - .unwrap_or_else(|| panel.default_size(window, cx)) - }); - } - } - - size_state - .size - .unwrap_or_else(|| panel.default_size(window, cx)) -} - impl Render for Dock { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let dispatch_context = Self::dispatch_context(); if let Some(entry) = self.visible_entry() { - let size = self.resolved_panel_size(entry, window, cx); - let position = self.position; let create_resize_handle = || { let handle = div() @@ -1091,8 +1030,10 @@ impl Render for Dock { .border_color(cx.theme().colors().border) .overflow_hidden() .map(|this| match self.position().axis() { - Axis::Horizontal => this.w(size).h_full().flex_row(), - Axis::Vertical => this.h(size).w_full().flex_col(), + // Width and height are always set on the workspace wrapper in + // render_dock, so fill whatever space the wrapper provides. + Axis::Horizontal => this.w_full().h_full().flex_row(), + Axis::Vertical => this.h_full().w_full().flex_col(), }) .map(|this| match self.position() { DockPosition::Left => this.border_r_1(), @@ -1102,8 +1043,8 @@ impl Render for Dock { .child( div() .map(|this| match self.position().axis() { - Axis::Horizontal => this.min_w(size).h_full(), - Axis::Vertical => this.min_h(size).w_full(), + Axis::Horizontal => this.w_full().h_full(), + Axis::Vertical => this.h_full().w_full(), }) .child( entry diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 9e043e9ae7feb9f4ece21945d48d818f7345a03d..924471d4dd08fa14f08723ffb990e9d8f555c048 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -883,7 +883,7 @@ impl Render for MultiWorkspace { (sidebar, None) }; - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let text_color = cx.theme().colors().text; let workspace = self.workspace().clone(); @@ -970,7 +970,7 @@ mod tests { 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); DisableAiSettings::register(cx); cx.update_flags(false, vec!["agent-v2".into()]); }); diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 85b1fe4e707acbc7107df14d23caa3bda24519e5..dbf2accf3dd9910426ca3557daf9cee0e5b0a82b 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -9,7 +9,7 @@ use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; use project::project_settings::ProjectSettings; use settings::Settings; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use std::ops::Deref; use std::sync::{Arc, LazyLock}; diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 2d9f8be5c363b5b5c10c5432a543ae46de7611d2..ca8e0dce44dee6f7da3c0e3f20083645974da4b0 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -42,7 +42,7 @@ use std::{ }, time::Duration, }; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ ContextMenu, ContextMenuEntry, ContextMenuItem, DecoratedIcon, IconButtonShape, IconDecoration, IconDecorationKind, Indicator, PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition, @@ -2849,7 +2849,7 @@ impl Pane { })) .on_aux_click( cx.listener(move |pane: &mut Self, event: &ClickEvent, window, cx| { - if !event.is_middle_click() { + if !event.is_middle_click() || is_pinned { return; } @@ -6858,6 +6858,79 @@ mod tests { assert_item_labels(&pane, ["A!", "B!", "D", "E", "C*", "F"], cx); } + #[gpui::test] + async fn test_middle_click_pinned_tab_does_not_close(cx: &mut TestAppContext) { + use gpui::{Modifiers, MouseButton, MouseDownEvent, MouseUpEvent}; + + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + let project = Project::test(fs, None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + + let item_a = add_labeled_item(&pane, "A", false, cx); + add_labeled_item(&pane, "B", false, cx); + + pane.update_in(cx, |pane, window, cx| { + pane.pin_tab_at( + pane.index_for_item_id(item_a.item_id()).unwrap(), + window, + cx, + ); + }); + assert_item_labels(&pane, ["A!", "B*"], cx); + cx.run_until_parked(); + + let tab_a_bounds = cx + .debug_bounds("TAB-0") + .expect("Tab A (index 1) should have debug bounds"); + let tab_b_bounds = cx + .debug_bounds("TAB-1") + .expect("Tab B (index 2) should have debug bounds"); + + cx.simulate_event(MouseDownEvent { + position: tab_a_bounds.center(), + button: MouseButton::Middle, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + + cx.run_until_parked(); + + cx.simulate_event(MouseUpEvent { + position: tab_a_bounds.center(), + button: MouseButton::Middle, + modifiers: Modifiers::default(), + click_count: 1, + }); + + cx.run_until_parked(); + + cx.simulate_event(MouseDownEvent { + position: tab_b_bounds.center(), + button: MouseButton::Middle, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + + cx.run_until_parked(); + + cx.simulate_event(MouseUpEvent { + position: tab_b_bounds.center(), + button: MouseButton::Middle, + modifiers: Modifiers::default(), + click_count: 1, + }); + + cx.run_until_parked(); + + assert_item_labels(&pane, ["A*!"], cx); + } + #[gpui::test] async fn test_add_item_with_new_item(cx: &mut TestAppContext) { init_test(cx); @@ -8515,7 +8588,7 @@ mod tests { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(LoadThemes::JustBase, cx); + theme_settings::init(LoadThemes::JustBase, cx); }); } diff --git a/crates/workspace/src/tasks.rs b/crates/workspace/src/tasks.rs index 8a2ae6a40ab6328c2a2328fbdbe0e5be5972cf22..0ebb97b9d75543986bb6727546aad872a11a4f87 100644 --- a/crates/workspace/src/tasks.rs +++ b/crates/workspace/src/tasks.rs @@ -254,7 +254,7 @@ mod tests { cx.update(|cx| { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); register_serializable_item::(cx); }); let fs = FakeFs::new(cx.executor()); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 31fba952549e8618d02e3092aba7065e95c6d540..370e0674efaf575e536efbbec8a438616a14bd67 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,3 +1,4 @@ +pub mod active_file_name; pub mod dock; pub mod history_manager; pub mod invalid_item_view; @@ -123,13 +124,14 @@ use std::{ process::ExitStatus, rc::Rc, sync::{ - Arc, LazyLock, Weak, + Arc, LazyLock, atomic::{AtomicBool, AtomicUsize}, }, time::Duration, }; use task::{DebugScenario, SharedTaskContext, SpawnInTerminal}; -use theme::{ActiveTheme, GlobalTheme, SystemAppearance, ThemeSettings}; +use theme::{ActiveTheme, SystemAppearance}; +use theme_settings::ThemeSettings; pub use toolbar::{ PaneSearchBarCallbacks, Toolbar, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, }; @@ -730,40 +732,32 @@ pub fn init(app_state: Arc, cx: &mut App) { cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx)) .on_action(|_: &Reload, cx| reload(cx)) - .on_action({ - let app_state = Arc::downgrade(&app_state); - move |_: &Open, cx: &mut App| { - if let Some(app_state) = app_state.upgrade() { - prompt_and_open_paths( - app_state, - PathPromptOptions { - files: true, - directories: true, - multiple: true, - prompt: None, - }, - cx, - ); - } - } + .on_action(|_: &Open, cx: &mut App| { + let app_state = AppState::global(cx); + prompt_and_open_paths( + app_state, + PathPromptOptions { + files: true, + directories: true, + multiple: true, + prompt: None, + }, + cx, + ); }) - .on_action({ - let app_state = Arc::downgrade(&app_state); - move |_: &OpenFiles, cx: &mut App| { - let directories = cx.can_select_mixed_files_and_dirs(); - if let Some(app_state) = app_state.upgrade() { - prompt_and_open_paths( - app_state, - PathPromptOptions { - files: true, - directories, - multiple: true, - prompt: None, - }, - cx, - ); - } - } + .on_action(|_: &OpenFiles, cx: &mut App| { + let directories = cx.can_select_mixed_files_and_dirs(); + let app_state = AppState::global(cx); + prompt_and_open_paths( + app_state, + PathPromptOptions { + files: true, + directories, + multiple: true, + prompt: None, + }, + cx, + ); }); } @@ -1072,7 +1066,7 @@ pub struct AppState { pub session: Entity, } -struct GlobalAppState(Weak); +struct GlobalAppState(Arc); impl Global for GlobalAppState {} @@ -1108,14 +1102,14 @@ struct Follower { impl AppState { #[track_caller] - pub fn global(cx: &App) -> Weak { + pub fn global(cx: &App) -> Arc { cx.global::().0.clone() } - pub fn try_global(cx: &App) -> Option> { + pub fn try_global(cx: &App) -> Option> { cx.try_global::() .map(|state| state.0.clone()) } - pub fn set_global(state: Weak, cx: &mut App) { + pub fn set_global(state: Arc, cx: &mut App) { cx.set_global(GlobalAppState(state)); } @@ -1141,7 +1135,7 @@ impl AppState { let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); client::init(&client, cx); Arc::new(Self { @@ -1681,8 +1675,8 @@ impl Workspace { *SystemAppearance::global_mut(cx) = SystemAppearance(window_appearance.into()); - GlobalTheme::reload_theme(cx); - GlobalTheme::reload_icon_theme(cx); + theme_settings::reload_theme(cx); + theme_settings::reload_icon_theme(cx); }), cx.on_release({ let weak_handle = weak_handle.clone(); @@ -2207,30 +2201,29 @@ impl Workspace { did_set } - pub fn flexible_dock_size( - &self, - position: DockPosition, - ratio: f32, - window: &Window, - cx: &App, - ) -> Option { - if position.axis() != Axis::Horizontal { - return None; - } + fn dock_size(&self, dock: &Dock, window: &Window, cx: &App) -> Option { + let panel = dock.active_panel()?; + let size_state = dock + .stored_panel_size_state(panel.as_ref()) + .unwrap_or_default(); + let position = dock.position(); - let available_width = self.available_width_for_horizontal_dock(position, window, cx)?; - Some((available_width * ratio.clamp(0.0, 1.0)).max(RESIZE_HANDLE_SIZE)) - } + if position.axis() == Axis::Horizontal + && panel.supports_flexible_size(window, cx) + && let Some(ratio) = size_state + .flexible_size_ratio + .or_else(|| self.default_flexible_dock_ratio(position)) + && let Some(available_width) = + self.available_width_for_horizontal_dock(position, window, cx) + { + return Some((available_width * ratio.clamp(0.0, 1.0)).max(RESIZE_HANDLE_SIZE)); + } - pub fn resolved_dock_panel_size( - &self, - dock: &Dock, - panel: &dyn PanelHandle, - window: &Window, - cx: &App, - ) -> Pixels { - let size_state = dock.stored_panel_size_state(panel).unwrap_or_default(); - dock::resolve_panel_size(size_state, panel, dock.position(), self, window, cx) + Some( + size_state + .size + .unwrap_or_else(|| panel.default_size(window, cx)), + ) } pub fn flexible_dock_ratio_for_size( @@ -4937,10 +4930,7 @@ impl Workspace { if let Some(dock_entity) = active_dock { let dock = dock_entity.read(cx); - let Some(panel_size) = dock - .active_panel() - .map(|panel| self.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) - else { + let Some(panel_size) = self.dock_size(&dock, window, cx) else { return; }; match dock.position() { @@ -7303,14 +7293,46 @@ impl Workspace { leader_border_for_pane(follower_states, &pane, window, cx) }); - Some( - div() - .flex() - .flex_none() - .overflow_hidden() - .child(dock.clone()) - .children(leader_border), - ) + let mut container = div() + .flex() + .overflow_hidden() + .flex_none() + .child(dock.clone()) + .children(leader_border); + + // Apply sizing only when the dock is open. When closed the dock is still + // included in the element tree so its focus handle remains mounted — without + // this, toggle_panel_focus cannot focus the panel when the dock is closed. + let dock = dock.read(cx); + if let Some(panel) = dock.visible_panel() { + let size_state = dock.stored_panel_size_state(panel.as_ref()); + if position.axis() == Axis::Horizontal { + if let Some(ratio) = size_state + .and_then(|state| state.flexible_size_ratio) + .or_else(|| self.default_flexible_dock_ratio(position)) + && panel.supports_flexible_size(window, cx) + { + let ratio = ratio.clamp(0.001, 0.999); + let grow = ratio / (1.0 - ratio); + let style = container.style(); + style.flex_grow = Some(grow); + style.flex_shrink = Some(1.0); + style.flex_basis = Some(relative(0.).into()); + } else { + let size = size_state + .and_then(|state| state.size) + .unwrap_or_else(|| panel.default_size(window, cx)); + container = container.w(size); + } + } else { + let size = size_state + .and_then(|state| state.size) + .unwrap_or_else(|| panel.default_size(window, cx)); + container = container.h(size); + } + } + + Some(container) } pub fn for_window(window: &Window, cx: &App) -> Option> { @@ -7380,18 +7402,17 @@ impl Workspace { } } - fn adjust_dock_size_by_px( + fn resize_dock( &mut self, - panel_size: Pixels, dock_pos: DockPosition, - px: Pixels, + new_size: Pixels, window: &mut Window, cx: &mut Context, ) { match dock_pos { - DockPosition::Left => self.resize_left_dock(panel_size + px, window, cx), - DockPosition::Right => self.resize_right_dock(panel_size + px, window, cx), - DockPosition::Bottom => self.resize_bottom_dock(panel_size + px, window, cx), + DockPosition::Left => self.resize_left_dock(new_size, window, cx), + DockPosition::Right => self.resize_right_dock(new_size, window, cx), + DockPosition::Bottom => self.resize_bottom_dock(new_size, window, cx), } } @@ -7475,17 +7496,23 @@ impl Workspace { fn toggle_theme_mode(&mut self, _: &ToggleMode, _window: &mut Window, cx: &mut Context) { let current_mode = ThemeSettings::get_global(cx).theme.mode(); let next_mode = match current_mode { - Some(theme::ThemeAppearanceMode::Light) => theme::ThemeAppearanceMode::Dark, - Some(theme::ThemeAppearanceMode::Dark) => theme::ThemeAppearanceMode::Light, - Some(theme::ThemeAppearanceMode::System) | None => match cx.theme().appearance() { - theme::Appearance::Light => theme::ThemeAppearanceMode::Dark, - theme::Appearance::Dark => theme::ThemeAppearanceMode::Light, - }, + Some(theme_settings::ThemeAppearanceMode::Light) => { + theme_settings::ThemeAppearanceMode::Dark + } + Some(theme_settings::ThemeAppearanceMode::Dark) => { + theme_settings::ThemeAppearanceMode::Light + } + Some(theme_settings::ThemeAppearanceMode::System) | None => { + match cx.theme().appearance() { + theme::Appearance::Light => theme_settings::ThemeAppearanceMode::Dark, + theme::Appearance::Dark => theme_settings::ThemeAppearanceMode::Light, + } + } }; let fs = self.project().read(cx).fs().clone(); settings::update_settings_file(fs, cx, move |settings, _cx| { - theme::set_mode(settings, next_mode); + theme_settings::set_mode(settings, next_mode); }); } @@ -7835,14 +7862,10 @@ fn adjust_active_dock_size_by_px( return; }; let dock = active_dock.read(cx); - let Some(panel_size) = dock - .active_panel() - .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) - else { + let Some(panel_size) = workspace.dock_size(&dock, window, cx) else { return; }; - let dock_pos = dock.position(); - workspace.adjust_dock_size_by_px(panel_size, dock_pos, px, window, cx); + workspace.resize_dock(dock.position(), panel_size + px, window, cx); } fn adjust_open_docks_size_by_px( @@ -7857,22 +7880,18 @@ fn adjust_open_docks_size_by_px( .filter_map(|dock_entity| { let dock = dock_entity.read(cx); if dock.is_open() { - let panel_size = dock.active_panel().map(|panel| { - workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx) - })?; let dock_pos = dock.position(); - Some((panel_size, dock_pos, px)) + let panel_size = workspace.dock_size(&dock, window, cx)?; + Some((dock_pos, panel_size + px)) } else { None } }) .collect::>(); - docks - .into_iter() - .for_each(|(panel_size, dock_pos, offset)| { - workspace.adjust_dock_size_by_px(panel_size, dock_pos, offset, window, cx); - }); + for (position, new_size) in docks { + workspace.resize_dock(position, new_size, window, cx); + } } impl Focusable for Workspace { @@ -7922,7 +7941,7 @@ impl Render for Workspace { } else { (None, None) }; - let ui_font = theme::setup_ui_font(window, cx); + let ui_font = theme_settings::setup_ui_font(window, cx); let theme = cx.theme().clone(); let colors = theme.colors(); @@ -10323,15 +10342,13 @@ pub fn with_active_or_new_workspace( } None => { let app_state = AppState::global(cx); - if let Some(app_state) = app_state.upgrade() { - open_new( - OpenOptions::default(), - app_state, - cx, - move |workspace, window, cx| f(workspace, window, cx), - ) - .detach_and_log_err(cx); - } + open_new( + OpenOptions::default(), + app_state, + cx, + move |workspace, window, cx| f(workspace, window, cx), + ) + .detach_and_log_err(cx); } } } @@ -12315,11 +12332,8 @@ mod tests { let dock = workspace.right_dock().read(cx); let workspace_width = workspace.bounds.size.width; - let initial_width = dock - .active_panel() - .map(|panel| { - workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx) - }) + let initial_width = workspace + .dock_size(&dock, window, cx) .expect("flexible dock should have an initial width"); assert_eq!(initial_width, workspace_width / 2.); @@ -12327,11 +12341,8 @@ mod tests { workspace.resize_right_dock(px(300.), window, cx); let dock = workspace.right_dock().read(cx); - let resized_width = dock - .active_panel() - .map(|panel| { - workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx) - }) + let resized_width = workspace + .dock_size(&dock, window, cx) .expect("flexible dock should keep its resized width"); assert_eq!(resized_width, px(300.)); @@ -12351,9 +12362,8 @@ mod tests { workspace.toggle_dock(DockPosition::Right, window, cx); let dock = workspace.right_dock().read(cx); - let reopened_width = dock - .active_panel() - .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) + let reopened_width = workspace + .dock_size(&dock, window, cx) .expect("flexible dock should restore when reopened"); assert_eq!(reopened_width, resized_width); @@ -12380,9 +12390,8 @@ mod tests { ); let dock = workspace.right_dock().read(cx); - let split_width = dock - .active_panel() - .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) + let split_width = workspace + .dock_size(&dock, window, cx) .expect("flexible dock should keep its user-resized proportion"); assert_eq!(split_width, px(300.)); @@ -12390,9 +12399,8 @@ mod tests { workspace.bounds.size.width = px(1600.); let dock = workspace.right_dock().read(cx); - let resized_window_width = dock - .active_panel() - .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) + let resized_window_width = workspace + .dock_size(&dock, window, cx) .expect("flexible dock should preserve proportional size on window resize"); assert_eq!( @@ -12562,9 +12570,8 @@ mod tests { workspace.toggle_dock(DockPosition::Left, window, cx); let left_dock = workspace.left_dock().read(cx); - let left_width = left_dock - .active_panel() - .map(|p| workspace.resolved_dock_panel_size(&left_dock, p.as_ref(), window, cx)) + let left_width = workspace + .dock_size(&left_dock, window, cx) .expect("left dock should have an active panel"); assert_eq!( @@ -12586,9 +12593,8 @@ mod tests { ); let left_dock = workspace.left_dock().read(cx); - let left_width = left_dock - .active_panel() - .map(|p| workspace.resolved_dock_panel_size(&left_dock, p.as_ref(), window, cx)) + let left_width = workspace + .dock_size(&left_dock, window, cx) .expect("left dock should still have an active panel after vertical split"); assert_eq!( @@ -12607,15 +12613,13 @@ mod tests { workspace.toggle_dock(DockPosition::Right, window, cx); let right_dock = workspace.right_dock().read(cx); - let right_width = right_dock - .active_panel() - .map(|p| workspace.resolved_dock_panel_size(&right_dock, p.as_ref(), window, cx)) + let right_width = workspace + .dock_size(&right_dock, window, cx) .expect("right dock should have an active panel"); let left_dock = workspace.left_dock().read(cx); - let left_width = left_dock - .active_panel() - .map(|p| workspace.resolved_dock_panel_size(&left_dock, p.as_ref(), window, cx)) + let left_width = workspace + .dock_size(&left_dock, window, cx) .expect("left dock should still have an active panel"); let available_width = workspace.bounds.size.width - right_width; @@ -12679,8 +12683,8 @@ mod tests { panel_1.panel_id() ); assert_eq!( - left_dock.read(cx).active_panel_size(window, cx).unwrap(), - px(300.) + workspace.dock_size(&left_dock.read(cx), window, cx), + Some(px(300.)) ); workspace.resize_left_dock(px(1337.), window, cx); @@ -12713,7 +12717,12 @@ mod tests { panel_1.panel_id() ); assert_eq!( - right_dock.read(cx).active_panel_size(window, cx).unwrap(), + right_dock + .read(cx) + .active_panel_size() + .unwrap() + .size + .unwrap(), px(1337.) ); @@ -12751,8 +12760,8 @@ mod tests { panel_1.panel_id() ); assert_eq!( - left_dock.read(cx).active_panel_size(window, cx).unwrap(), - px(1337.) + workspace.dock_size(&left_dock.read(cx), window, cx), + Some(px(1337.)) ); // And the right dock should be closed as it no longer has any panels. assert!(!workspace.right_dock().read(cx).is_open()); @@ -12768,8 +12777,8 @@ mod tests { // since the panel orientation changed from vertical to horizontal. let bottom_dock = workspace.bottom_dock(); assert_eq!( - bottom_dock.read(cx).active_panel_size(window, cx).unwrap(), - px(300.), + workspace.dock_size(&bottom_dock.read(cx), window, cx), + Some(px(300.)) ); // Close bottom dock and move panel_1 back to the left. bottom_dock.update(cx, |bottom_dock, cx| { @@ -14427,7 +14436,7 @@ mod tests { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); cx.set_global(db::AppDatabase::test_new()); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); }); } diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 5575af3d7cf07fd7afd22ddbb78a620bab775714..d78b233229800b571ccc37f87719d09125f1c4c3 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -132,6 +132,7 @@ impl Settings for TabBarSettings { #[derive(Deserialize, RegisterSetting)] pub struct StatusBarSettings { pub show: bool, + pub show_active_file: bool, pub active_language_button: bool, pub cursor_position_button: bool, pub line_endings_button: bool, @@ -143,6 +144,7 @@ impl Settings for StatusBarSettings { let status_bar = content.status_bar.clone().unwrap(); StatusBarSettings { show: status_bar.show.unwrap(), + show_active_file: status_bar.show_active_file.unwrap(), active_language_button: status_bar.active_language_button.unwrap(), cursor_position_button: status_bar.cursor_position_button.unwrap(), line_endings_button: status_bar.line_endings_button.unwrap(), diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 6bd78f55ad709212002d3cf6ffcd3d41da5d5f8b..07f01e21758aa79509e7d6466e2f3b798eb7b8d3 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -7,7 +7,9 @@ use chardetng::EncodingDetector; use clock::ReplicaId; use collections::{HashMap, HashSet, VecDeque}; use encoding_rs::Encoding; -use fs::{Fs, MTime, PathEvent, RemoveOptions, Watcher, copy_recursive, read_dir_items}; +use fs::{ + Fs, MTime, PathEvent, PathEventKind, RemoveOptions, Watcher, copy_recursive, read_dir_items, +}; use futures::{ FutureExt as _, Stream, StreamExt, channel::{ @@ -4137,7 +4139,7 @@ impl BackgroundScanner { } for (ix, event) in events.iter().enumerate() { - let abs_path = &SanitizedPath::new(&event.path); + let abs_path = SanitizedPath::new(&event.path); let mut is_git_related = false; let mut dot_git_paths = None; @@ -4154,13 +4156,33 @@ impl BackgroundScanner { } if let Some((dot_git_abs_path, path_in_git_dir)) = dot_git_paths { - if skipped_files_in_dot_git + // We ignore `""` as well, as that is going to be the + // `.git` folder itself. WE do not care about it, if + // there are changes within we will see them, we need + // this ignore to prevent us from accidentally observing + // the ignored created file due to the events not being + // empty after filtering. + + let is_dot_git_changed = { + path_in_git_dir == Path::new("") + && event.kind == Some(PathEventKind::Changed) + && abs_path + .strip_prefix(root_canonical_path) + .ok() + .and_then(|it| RelPath::new(it, PathStyle::local()).ok()) + .is_some_and(|it| { + snapshot + .entry_for_path(&it) + .is_some_and(|entry| entry.kind == EntryKind::Dir) + }) + }; + let condition = skipped_files_in_dot_git.iter().any(|skipped| { + OsStr::new(skipped) == path_in_git_dir.as_path().as_os_str() + }) || skipped_dirs_in_dot_git .iter() - .any(|skipped| OsStr::new(skipped) == path_in_git_dir.as_path().as_os_str()) - || skipped_dirs_in_dot_git.iter().any(|skipped_git_subdir| { - path_in_git_dir.starts_with(skipped_git_subdir) - }) - { + .any(|skipped_git_subdir| path_in_git_dir.starts_with(skipped_git_subdir)) + || is_dot_git_changed; + if condition { log::debug!( "ignoring event {abs_path:?} as it's in the .git directory among skipped files or directories" ); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 4529fe35ccf1866a21539eeafa09aafaa3239cbf..a602220f2c02a0e510b46e86f4cec5fed2488ac9 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -197,6 +197,7 @@ telemetry.workspace = true telemetry_events.workspace = true terminal_view.workspace = true theme.workspace = true +theme_settings.workspace = true theme_extension.workspace = true theme_selector.workspace = true time.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 0a55953931ff4527851f9c9e7d6ac5f451eea0fd..f80608b8aaa4c0bd2b4513de766dc42a73876ab8 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -52,6 +52,7 @@ use std::{ time::Instant, }; use theme::{ActiveTheme, GlobalTheme, ThemeRegistry}; +use theme_settings::load_user_theme; use util::{ResultExt, TryFutureExt, maybe}; use uuid::Uuid; use workspace::{ @@ -440,10 +441,8 @@ fn main() { } }); app.on_reopen(move |cx| { - if let Some(app_state) = AppState::try_global(cx).and_then(|app_state| app_state.upgrade()) - { + if let Some(app_state) = AppState::try_global(cx) { cx.spawn({ - let app_state = app_state; async move |cx| { if let Err(e) = restore_or_create_workspace(app_state, cx).await { fail_to_open_window_async(e, cx) @@ -625,7 +624,7 @@ fn main() { node_runtime, session: app_session, }); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); auto_update::init(client.clone(), cx); dap_adapters::init(cx); @@ -639,7 +638,7 @@ fn main() { cx, ); - theme::init(theme::LoadThemes::All(Box::new(Assets)), cx); + theme_settings::init(theme::LoadThemes::All(Box::new(Assets)), cx); eager_load_active_theme_and_icon_theme(fs.clone(), cx); theme_extension::init( extension_host_proxy, @@ -811,6 +810,7 @@ fn main() { let fs = app_state.fs.clone(); load_user_themes_in_background(fs.clone(), cx); watch_themes(fs.clone(), cx); + #[cfg(debug_assertions)] watch_languages(fs.clone(), app_state.languages.clone(), cx); let menus = app_menus(cx); @@ -1781,8 +1781,24 @@ fn load_user_themes_in_background(fs: Arc, cx: &mut App) { })?; } } - theme_registry.load_user_themes(themes_dir, fs).await?; - cx.update(GlobalTheme::reload_theme); + + let mut theme_paths = fs + .read_dir(themes_dir) + .await + .with_context(|| format!("reading themes from {themes_dir:?}"))?; + + while let Some(theme_path) = theme_paths.next().await { + let Some(theme_path) = theme_path.log_err() else { + continue; + }; + let Some(bytes) = fs.load_bytes(&theme_path).await.log_err() else { + continue; + }; + + load_user_theme(&theme_registry, &bytes).log_err(); + } + + cx.update(theme_settings::reload_theme); anyhow::Ok(()) } }) @@ -1801,13 +1817,10 @@ fn watch_themes(fs: Arc, cx: &mut App) { for event in paths { if fs.metadata(&event.path).await.ok().flatten().is_some() { let theme_registry = cx.update(|cx| ThemeRegistry::global(cx)); - if theme_registry - .load_user_theme(&event.path, fs.clone()) - .await - .log_err() - .is_some() + if let Some(bytes) = fs.load_bytes(&event.path).await.log_err() + && load_user_theme(&theme_registry, &bytes).log_err().is_some() { - cx.update(GlobalTheme::reload_theme); + cx.update(theme_settings::reload_theme); } } } @@ -1821,7 +1834,7 @@ fn watch_languages(fs: Arc, languages: Arc, cx: &m use std::time::Duration; cx.background_spawn(async move { - let languages_src = Path::new("crates/languages/src"); + let languages_src = Path::new("crates/grammars/src"); let Some(languages_src) = fs.canonicalize(languages_src).await.log_err() else { return; }; @@ -1851,9 +1864,6 @@ fn watch_languages(fs: Arc, languages: Arc, cx: &m .detach(); } -#[cfg(not(debug_assertions))] -fn watch_languages(_fs: Arc, _languages: Arc, _cx: &mut App) {} - fn dump_all_gpui_actions() { #[derive(Debug, serde::Serialize)] struct ActionDef { diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index e20c8f034833c2f7ebb3ce132c843b37fe1816be..cb70a8573f831c5da20afc15fd0e55cd6ca2c3e6 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -170,13 +170,13 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> // Set the global app state so settings_ui and other subsystems can find it cx.update(|cx| { - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); }); // Initialize all Zed subsystems cx.update(|cx| { gpui_tokio::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); client::init(&app_state.client, cx); audio::init(cx); workspace::init(app_state.clone(), cx); @@ -965,7 +965,7 @@ fn init_app_state(cx: &mut App) -> Arc { let user_store = cx.new(|cx| client::UserStore::new(client.clone(), cx)); let workspace_store = cx.new(|cx| workspace::WorkspaceStore::new(client.clone(), cx)); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); client::init(&client, cx); let app_state = Arc::new(AppState { @@ -978,7 +978,7 @@ fn init_app_state(cx: &mut App) -> Arc { build_window_options: |_, _| Default::default(), session, }); - AppState::set_global(Arc::downgrade(&app_state), cx); + AppState::set_global(app_state.clone(), cx); app_state } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 1a51d08540a95381e4494ae724806967dd8ed1ec..a560a077220500259d72101f7890bc8edd2cf552 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -77,7 +77,8 @@ use std::{ sync::atomic::{self, AtomicBool}, }; use terminal_view::terminal_panel::{self, TerminalPanel}; -use theme::{ActiveTheme, GlobalTheme, SystemAppearance, ThemeRegistry, ThemeSettings}; +use theme::{ActiveTheme, SystemAppearance, ThemeRegistry, deserialize_icon_theme}; +use theme_settings::{ThemeSettings, load_user_theme}; use ui::{PopoverMenuHandle, prelude::*}; use util::markdown::MarkdownString; use util::rel_path::RelPath; @@ -478,6 +479,7 @@ pub fn initialize_workspace( let search_button = cx.new(|_| search::search_status_button::SearchButton::new()); 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(), @@ -510,6 +512,7 @@ pub fn initialize_workspace( status_bar.add_left_item(search_button, window, cx); status_bar.add_left_item(lsp_button, window, cx); status_bar.add_left_item(diagnostic_summary, window, cx); + status_bar.add_left_item(active_file_name, window, cx); status_bar.add_left_item(activity_indicator, window, cx); status_bar.add_right_item(edit_prediction_ui, window, cx); status_bar.add_right_item(active_buffer_encoding, window, cx); @@ -898,10 +901,10 @@ fn register_actions( let _ = settings .theme .ui_font_size - .insert(f32::from(theme::clamp_font_size(ui_font_size)).into()); + .insert(f32::from(theme_settings::clamp_font_size(ui_font_size)).into()); }); } else { - theme::adjust_ui_font_size(cx, |size| size + px(1.0)); + theme_settings::adjust_ui_font_size(cx, |size| size + px(1.0)); } } }) @@ -914,10 +917,10 @@ fn register_actions( let _ = settings .theme .ui_font_size - .insert(f32::from(theme::clamp_font_size(ui_font_size)).into()); + .insert(f32::from(theme_settings::clamp_font_size(ui_font_size)).into()); }); } else { - theme::adjust_ui_font_size(cx, |size| size - px(1.0)); + theme_settings::adjust_ui_font_size(cx, |size| size - px(1.0)); } } }) @@ -929,7 +932,7 @@ fn register_actions( settings.theme.ui_font_size = None; }); } else { - theme::reset_ui_font_size(cx); + theme_settings::reset_ui_font_size(cx); } } }) @@ -943,10 +946,10 @@ fn register_actions( let _ = settings .theme .buffer_font_size - .insert(f32::from(theme::clamp_font_size(buffer_font_size)).into()); + .insert(f32::from(theme_settings::clamp_font_size(buffer_font_size)).into()); }); } else { - theme::adjust_buffer_font_size(cx, |size| size + px(1.0)); + theme_settings::adjust_buffer_font_size(cx, |size| size + px(1.0)); } } }) @@ -960,10 +963,10 @@ fn register_actions( let _ = settings .theme .buffer_font_size - .insert(f32::from(theme::clamp_font_size(buffer_font_size)).into()); + .insert(f32::from(theme_settings::clamp_font_size(buffer_font_size)).into()); }); } else { - theme::adjust_buffer_font_size(cx, |size| size - px(1.0)); + theme_settings::adjust_buffer_font_size(cx, |size| size - px(1.0)); } } }) @@ -975,7 +978,7 @@ fn register_actions( settings.theme.buffer_font_size = None; }); } else { - theme::reset_buffer_font_size(cx); + theme_settings::reset_buffer_font_size(cx); } } }) @@ -990,10 +993,10 @@ fn register_actions( settings.theme.agent_buffer_font_size = None; }); } else { - theme::reset_ui_font_size(cx); - theme::reset_buffer_font_size(cx); - theme::reset_agent_ui_font_size(cx); - theme::reset_agent_buffer_font_size(cx); + theme_settings::reset_ui_font_size(cx); + theme_settings::reset_buffer_font_size(cx); + theme_settings::reset_agent_ui_font_size(cx); + theme_settings::reset_agent_buffer_font_size(cx); } } }) @@ -1069,104 +1072,99 @@ fn register_actions( }, ) .register_action({ - let app_state = Arc::downgrade(&app_state); + let app_state = app_state.clone(); move |_, _: &NewWindow, _, cx| { - if let Some(app_state) = app_state.upgrade() { - open_new( - Default::default(), - app_state, - 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) - }); - 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, - ); - }, - ) - .detach(); - } + open_new( + Default::default(), + app_state.clone(), + 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) + }); + 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, + ); + }, + ) + .detach(); } }) .register_action({ - let app_state = Arc::downgrade(&app_state); + let app_state = app_state.clone(); move |_workspace, _: &CloseProject, window, cx| { let Some(window_handle) = window.window_handle().downcast::() else { return; }; - if let Some(app_state) = app_state.upgrade() { - cx.spawn_in(window, async move |this, cx| { - let should_continue = this - .update_in(cx, |workspace, window, cx| { - workspace.prepare_to_close( - CloseIntent::ReplaceWindow, - window, - cx, - ) - })? - .await?; - if should_continue { - let task = cx.update(|_window, cx| { - open_new( - workspace::OpenOptions { - replace_window: Some(window_handle), - ..Default::default() - }, - app_state, - cx, - |workspace, window, cx| { - cx.activate(true); - let project = workspace.project().clone(); - let buffer = project.update(cx, |project, cx| { - project.create_local_buffer("", None, true, cx) - }); - 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, - ); - }, - ) - })?; - task.await - } else { - Ok(()) - } - }) - .detach_and_log_err(cx); - } + let app_state = app_state.clone(); + cx.spawn_in(window, async move |this, cx| { + let should_continue = this + .update_in(cx, |workspace, window, cx| { + workspace.prepare_to_close( + CloseIntent::ReplaceWindow, + window, + cx, + ) + })? + .await?; + if should_continue { + let task = cx.update(|_window, cx| { + open_new( + workspace::OpenOptions { + replace_window: Some(window_handle), + ..Default::default() + }, + app_state, + cx, + |workspace, window, cx| { + cx.activate(true); + let project = workspace.project().clone(); + let buffer = project.update(cx, |project, cx| { + project.create_local_buffer("", None, true, cx) + }); + 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, + ); + }, + ) + })?; + task.await + } else { + Ok(()) + } + }) + .detach_and_log_err(cx); } }) .register_action({ - let app_state = Arc::downgrade(&app_state); + let app_state = app_state.clone(); move |_, _: &NewFile, _, cx| { - if let Some(app_state) = app_state.upgrade() { - open_new( - Default::default(), - app_state, - cx, - |workspace, window, cx| { - Editor::new_file(workspace, &Default::default(), window, cx) - }, - ) - .detach_and_log_err(cx); - } + open_new( + Default::default(), + app_state.clone(), + cx, + |workspace, window, cx| { + Editor::new_file(workspace, &Default::default(), window, cx) + }, + ) + .detach_and_log_err(cx); } }); @@ -2219,24 +2217,23 @@ pub(crate) fn eager_load_active_theme_and_icon_theme(fs: Arc, cx: &mut A let reload_tasks = &reload_tasks; let fs = fs.clone(); - scope.spawn(async { + scope.spawn(async move { match load_target { LoadTarget::Theme(theme_path) => { - if theme_registry - .load_user_theme(&theme_path, fs) - .await - .log_err() - .is_some() + if let Some(bytes) = fs.load_bytes(&theme_path).await.log_err() + && load_user_theme(theme_registry, &bytes).log_err().is_some() { reload_tasks.lock().push(ReloadTarget::Theme); } } LoadTarget::IconTheme((icon_theme_path, icons_root_path)) => { - if theme_registry - .load_icon_theme(&icon_theme_path, &icons_root_path, fs) - .await - .log_err() - .is_some() + if let Some(bytes) = fs.load_bytes(&icon_theme_path).await.log_err() + && let Some(icon_theme_family) = + deserialize_icon_theme(&bytes).log_err() + && theme_registry + .load_icon_theme(icon_theme_family, &icons_root_path) + .log_err() + .is_some() { reload_tasks.lock().push(ReloadTarget::IconTheme); } @@ -2248,8 +2245,8 @@ pub(crate) fn eager_load_active_theme_and_icon_theme(fs: Arc, cx: &mut A for reload_target in reload_tasks.into_inner() { match reload_target { - ReloadTarget::Theme => GlobalTheme::reload_theme(cx), - ReloadTarget::IconTheme => GlobalTheme::reload_icon_theme(cx), + ReloadTarget::Theme => theme_settings::reload_theme(cx), + ReloadTarget::IconTheme => theme_settings::reload_icon_theme(cx), }; } } @@ -4453,7 +4450,7 @@ mod tests { cx.update(|cx| { let app_state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); client::init(&app_state.client, cx); workspace::init(app_state.clone(), cx); onboarding::init(cx); @@ -4871,7 +4868,7 @@ mod tests { .unwrap(); let themes = ThemeRegistry::default(); settings::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); let mut has_default_theme = false; for theme_name in themes.list().into_iter().map(|meta| meta.name) { @@ -5009,7 +5006,7 @@ mod tests { app_state.languages.add(markdown_lang()); gpui_tokio::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); audio::init(cx); channel::init(&app_state.client, app_state.user_store.clone(), cx); call::init(app_state.client.clone(), app_state.user_store.clone(), cx); diff --git a/crates/zed/src/zed/migrate.rs b/crates/zed/src/zed/migrate.rs index f8bec397f1cf54fe37962c6a318a816a3158423e..f7d320a0814f17c47298f0d903800c5a98e353f1 100644 --- a/crates/zed/src/zed/migrate.rs +++ b/crates/zed/src/zed/migrate.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use gpui::{Entity, EventEmitter, Global, Task, TextStyle, TextStyleRefinement}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::prelude::*; use workspace::item::ItemHandle; use workspace::{ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace}; diff --git a/crates/zed/src/zed/telemetry_log.rs b/crates/zed/src/zed/telemetry_log.rs index 06e13ef5d86fb665151b13ce01de5a60def9ba15..cc07783f57b27cc57a281089effb208fc3947050 100644 --- a/crates/zed/src/zed/telemetry_log.rs +++ b/crates/zed/src/zed/telemetry_log.rs @@ -16,7 +16,7 @@ use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle}; use project::Project; use settings::Settings; use telemetry_events::{Event, EventWrapper}; -use theme::ThemeSettings; +use theme_settings::ThemeSettings; use ui::{ Icon, IconButton, IconName, IconSize, Label, TextSize, Tooltip, WithScrollbar, prelude::*, }; diff --git a/crates/zed/src/zed/visual_tests.rs b/crates/zed/src/zed/visual_tests.rs index 0aab800eaf0e8664a875751d0b1df1abce98c945..982db08782207a9bfef96ec8f17c28c8abac41f3 100644 --- a/crates/zed/src/zed/visual_tests.rs +++ b/crates/zed/src/zed/visual_tests.rs @@ -51,7 +51,7 @@ pub fn init_visual_test(cx: &mut VisualTestAppContext) -> Arc { let app_state = AppState::test(cx); gpui_tokio::init(cx); - theme::init(theme::LoadThemes::JustBase, cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); audio::init(cx); workspace::init(app_state.clone(), cx); release_channel::init(semver::Version::new(0, 0, 0), cx); diff --git a/docs/README.md b/docs/README.md index a0f9bbd5c628f41d291880239ca555ea7ec0e3ea..f03f008223ba1102585c34f3b98bf93a985c1284 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,14 @@ This will output a code element like: `Cmd + , | Ctrl + ,`. We then By using the action name, we can ensure that the keybinding is always up-to-date rather than hardcoding the keybinding. +#### Keymap Overlays + +`{#kb:keymap_name scope::Action}` - e.g., `{#kb:jetbrains editor::GoToDefinition}`. + +This resolves the keybinding from a keymap overlay (e.g., JetBrains) first, falling back to the default keymap if the overlay doesn't define a binding for that action. This is useful for sections where the documentation expects a special base keymap to be configured. + +Supported overlays: `jetbrains`. + ### Actions `{#action scope::Action}` - e.g., `{#action zed::OpenSettings}`. diff --git a/docs/src/development/glossary.md b/docs/src/development/glossary.md index ed3b9fdde00a605ec04e3efc25271b57691a45af..1f6b07840b8c70a86c587c45e7b617b0266144e1 100644 --- a/docs/src/development/glossary.md +++ b/docs/src/development/glossary.md @@ -84,16 +84,16 @@ h_flex() - `Panel`: An `Entity` implementing the `Panel` trait. Panels can be placed in a `Dock`. In the image below: `ProjectPanel` is in the left dock, `DebugPanel` is in the bottom dock, and `AgentPanel` is in the right dock. `Editor` does not implement `Panel`. - `Dock`: A UI element similar to a `Pane` that can be opened and hidden. Up to three docks can be open at once: left, right, and bottom. A dock contains one or more `Panel`s, not `Pane`s. -Screenshot for the Pane and Dock features +Screenshot for the Pane and Dock features - `Project`: One or more `Worktree`s - `Worktree`: Represents either local or remote files. -Screenshot for the Worktree feature +Screenshot for the Worktree feature - [Multibuffer](https://zed.dev/docs/multibuffers): A list of Editors, a multi-buffer allows editing multiple files simultaneously. A multi-buffer opens when an operation in Zed returns multiple locations, examples: _search_ or _go to definition_. See project search in the image below. -Screenshot for the MultiBuffer feature +Screenshot for the MultiBuffer feature ## Editor diff --git a/docs/src/migrate/webstorm.md b/docs/src/migrate/webstorm.md index 72916b04c5579785d2f099f1fd2b09d7ffb11acf..eb41f5c245cdc33a9a78320997b546bee8e14f15 100644 --- a/docs/src/migrate/webstorm.md +++ b/docs/src/migrate/webstorm.md @@ -37,11 +37,11 @@ This opens the current directory in Zed. If you're coming from WebStorm, the fastest way to feel at home is to use the JetBrains keymap. During onboarding, you can select it as your base keymap. If you missed that step, you can change it anytime: -1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +1. Open Settings with {#kb zed::OpenSettings} 2. Search for `Base Keymap` 3. Select `JetBrains` -This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action. +This maps familiar shortcuts like {#kb:jetbrains project_symbols::Toggle} for Go to Class and {#kb:jetbrains command_palette::Toggle} for Find Action. ## Set Up Editor Preferences @@ -63,7 +63,7 @@ Zed also supports per-project settings. Create a `.zed/settings.json` file in yo ## Open or Create a Project -After setup, press `Cmd+Shift+O` (with JetBrains keymap) to open a folder. This becomes your workspace in Zed. Unlike WebStorm, there's no project configuration wizard, no framework selection dialog, and no project structure setup required. +After setup, use {#kb:jetbrains file_finder::Toggle} to open a folder. This becomes your workspace in Zed. Unlike WebStorm, there's no project configuration wizard, no framework selection dialog, and no project structure setup required. To start a new project, create a directory using your terminal or file manager, then open it in Zed. The editor will treat that folder as the root of your project. For new projects, you'd typically run `npm init`, `pnpm create`, or your framework's CLI tool first, then open the resulting folder in Zed. @@ -72,60 +72,53 @@ You can also launch Zed from the terminal inside any folder with: Once inside a project: -- Use `Cmd+Shift+O` or `Cmd+E` to jump between files quickly (like WebStorm's "Recent Files") -- Use `Cmd+Shift+A` or `Shift Shift` to open the Command Palette (like WebStorm's "Search Everywhere") -- Use `Cmd+O` to search for symbols (like WebStorm's "Go to Symbol") +- Use {#kb:jetbrains file_finder::Toggle} to jump between files quickly (like WebStorm's "Recent Files") +- Use {#kb:jetbrains command_palette::Toggle} to open the Command Palette (like WebStorm's "Search Everywhere") +- Use {#kb:jetbrains project_symbols::Toggle} to search for symbols (like WebStorm's "Go to Symbol") -Open buffers appear as tabs across the top. The Project Panel shows your file tree and Git status. Toggle it with `Cmd+1` (just like WebStorm's Project tool window). +Open buffers appear as tabs across the top. The Project Panel shows your file tree and Git status. Toggle it with {#kb:jetbrains project_panel::ToggleFocus} (just like WebStorm's Project tool window). ## Differences in Keybindings -If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference for how Zed compares to WebStorm. - -### Common Shared Keybindings - -| Action | Shortcut | -| ----------------------------- | ----------------------- | -| Search Everywhere | `Shift Shift` | -| Find Action / Command Palette | `Cmd + Shift + A` | -| Go to File | `Cmd + Shift + O` | -| Go to Symbol | `Cmd + O` | -| Recent Files | `Cmd + E` | -| Go to Definition | `Cmd + B` | -| Find Usages | `Alt + F7` | -| Rename Symbol | `Shift + F6` | -| Reformat Code | `Cmd + Alt + L` | -| Toggle Project Panel | `Cmd + 1` | -| Toggle Terminal | `Alt + F12` | -| Duplicate Line | `Cmd + D` | -| Delete Line | `Cmd + Backspace` | -| Move Line Up/Down | `Shift + Alt + Up/Down` | -| Expand/Shrink Selection | `Alt + Up/Down` | -| Comment Line | `Cmd + /` | -| Go Back / Forward | `Cmd + [` / `Cmd + ]` | -| Toggle Breakpoint | `Ctrl + F8` | - -### Different Keybindings (WebStorm → Zed) - -| Action | WebStorm | Zed (JetBrains keymap) | -| ---------------------- | ----------- | ------------------------ | -| File Structure | `Cmd + F12` | `Cmd + F12` (outline) | -| Navigate to Next Error | `F2` | `F2` | -| Run | `Ctrl + R` | `Ctrl + Alt + R` (tasks) | -| Debug | `Ctrl + D` | `Alt + Shift + F9` | -| Stop | `Cmd + F2` | `Ctrl + F2` | +If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference of common actions and their keybindings with the JetBrains keymap active. + +### Common Keybindings + +| Action | Zed Keybinding | +| ---------------------- | ----------------------------------------------- | +| Command Palette | {#kb:jetbrains command_palette::Toggle} | +| Go to File | {#kb:jetbrains file_finder::Toggle} | +| Go to Symbol | {#kb:jetbrains project_symbols::Toggle} | +| File Outline | {#kb:jetbrains outline::Toggle} | +| Go to Definition | {#kb:jetbrains editor::GoToDefinition} | +| Find Usages | {#kb:jetbrains editor::FindAllReferences} | +| Rename Symbol | {#kb:jetbrains editor::Rename} | +| Reformat Code | {#kb:jetbrains editor::Format} | +| Toggle Project Panel | {#kb:jetbrains project_panel::ToggleFocus} | +| Toggle Terminal | {#kb:jetbrains terminal_panel::Toggle} | +| Duplicate Line | {#kb:jetbrains editor::DuplicateSelection} | +| Delete Line | {#kb:jetbrains editor::DeleteLine} | +| Move Line Up | {#kb:jetbrains editor::MoveLineUp} | +| Move Line Down | {#kb:jetbrains editor::MoveLineDown} | +| Expand Selection | {#kb:jetbrains editor::SelectLargerSyntaxNode} | +| Shrink Selection | {#kb:jetbrains editor::SelectSmallerSyntaxNode} | +| Comment Line | {#kb:jetbrains editor::ToggleComments} | +| Go Back | {#kb:jetbrains pane::GoBack} | +| Go Forward | {#kb:jetbrains pane::GoForward} | +| Toggle Breakpoint | {#kb:jetbrains editor::ToggleBreakpoint} | +| Navigate to Next Error | {#kb:jetbrains editor::GoToDiagnostic} | ### Unique to Zed -| Action | Shortcut | Notes | -| ----------------- | -------------------------- | ------------------------------ | -| Toggle Right Dock | `Cmd + R` | Assistant panel, notifications | -| Split Panes | `Cmd + K`, then arrow keys | Create splits in any direction | +| Action | Keybinding | Notes | +| ----------------- | -------------------------------- | ------------------------------------------------------------- | +| Toggle Right Dock | {#kb workspace::ToggleRightDock} | Assistant panel, notifications | +| Split Pane Right | {#kb pane::SplitRight} | Use other arrow keys to create splits in different directions | ### How to Customize Keybindings -- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`) -- Run `Zed: Open Keymap Editor` +- Open the Command Palette ({#kb:jetbrains command_palette::Toggle}) +- Run `zed: open keymap` This opens a list of all available bindings. You can override individual shortcuts or remove conflicts. @@ -143,9 +136,9 @@ WebStorm's index enables features like finding all usages across your entire cod **How to adapt:** -- Search symbols across the project with `Cmd+O` (powered by the TypeScript language server) -- Find files by name with `Cmd+Shift+O` -- Use `Cmd+Shift+F` for text search—it stays fast even in large monorepos +- Search symbols across the project with {#kb:jetbrains project_symbols::Toggle} (powered by the TypeScript language server) +- Find files by name with {#kb:jetbrains file_finder::Toggle} +- Use {#kb pane::DeploySearch} for text search—it stays fast even in large monorepos - Run `tsc --noEmit` or `eslint .` from the terminal when you need deeper project-wide analysis ### LSP vs. Native Language Intelligence @@ -169,10 +162,10 @@ Where you might notice differences: **How to adapt:** -- Use `Alt+Enter` for available code actions—the list will vary by language server +- Use {#kb:jetbrains editor::ToggleCodeActions} for available code actions—the list will vary by language server - Ensure your `tsconfig.json` is properly configured so the language server understands your project structure - Use Prettier for consistent formatting (it's enabled by default for JS/TS) -- For code inspection similar to WebStorm's "Inspect Code," check the Diagnostics panel (`Cmd+6`)—ESLint and TypeScript together catch many of the same issues +- For code inspection similar to WebStorm's "Inspect Code," check the Diagnostics panel ({#kb:jetbrains diagnostics::Deploy})—ESLint and TypeScript together catch many of the same issues ### No Project Model @@ -212,8 +205,8 @@ What this means in practice: ] ``` -- Use `Ctrl+Alt+R` to run tasks quickly -- Lean on your terminal (`Alt+F12`) for anything tasks don't cover +- Use {#kb:jetbrains task::Spawn} to run tasks quickly +- Lean on your terminal ({#kb:jetbrains terminal_panel::Toggle}) for anything tasks don't cover ### No Framework Integration @@ -223,8 +216,8 @@ Zed has none of this built-in. The TypeScript language server sees your code as **How to adapt:** -- Use grep and file search liberally. `Cmd+Shift+F` with a regex can find component definitions, route configurations, or API endpoints. -- Rely on your language server's "find references" (`Alt+F7`) for navigation—it works, just without framework context +- Use grep and file search liberally. {#kb pane::DeploySearch} with a regex can find component definitions, route configurations, or API endpoints. +- Rely on your language server's "find references" ({#kb:jetbrains editor::FindAllReferences}) for navigation—it works, just without framework context - Consider using framework-specific CLI tools (`ng`, `next`, `vite`) from Zed's terminal - For React, JSX/TSX syntax and TypeScript types still provide good intelligence @@ -232,16 +225,16 @@ Zed has none of this built-in. The TypeScript language server sees your code as ### Tool Windows vs. Docks -WebStorm organizes auxiliary views into numbered tool windows (Project = 1, npm = Alt+F11, Terminal = Alt+F12, etc.). Zed uses a similar concept called "docks": +WebStorm organizes auxiliary views into numbered tool windows. Zed uses a similar concept called "docks": -| WebStorm Tool Window | Zed Equivalent | Shortcut (JetBrains keymap) | -| -------------------- | -------------- | --------------------------- | -| Project (1) | Project Panel | `Cmd + 1` | -| Git (9 or Cmd+0) | Git Panel | `Cmd + 0` | -| Terminal (Alt+F12) | Terminal Panel | `Alt + F12` | -| Structure (7) | Outline Panel | `Cmd + 7` | -| Problems (6) | Diagnostics | `Cmd + 6` | -| Debug (5) | Debug Panel | `Cmd + 5` | +| WebStorm Tool Window | Zed Equivalent | Zed Keybinding | +| -------------------- | -------------- | ------------------------------------------ | +| Project | Project Panel | {#kb:jetbrains project_panel::ToggleFocus} | +| Git | Git Panel | {#kb:jetbrains git_panel::ToggleFocus} | +| Terminal | Terminal Panel | {#kb:jetbrains terminal_panel::Toggle} | +| Structure | Outline Panel | {#kb:jetbrains outline_panel::ToggleFocus} | +| Problems | Diagnostics | {#kb:jetbrains diagnostics::Deploy} | +| Debug | Debug Panel | {#kb:jetbrains debug_panel::ToggleFocus} | Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings. @@ -252,10 +245,10 @@ Note that there's no dedicated npm tool window in Zed. Use the terminal or defin Both WebStorm and Zed offer integrated debugging for JavaScript and TypeScript: - Zed uses `vscode-js-debug` (the same debug adapter that VS Code uses) -- Set breakpoints with `Ctrl+F8` -- Start debugging with `Alt+Shift+F9` or press `F4` and select a debug target -- Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out) -- Continue execution with `F9` +- Set breakpoints with {#kb:jetbrains editor::ToggleBreakpoint} +- Start debugging with {#kb:jetbrains debugger::Start} +- Step through code with {#kb:jetbrains debugger::StepInto} (step into), {#kb:jetbrains debugger::StepOver} (step over), {#kb:jetbrains debugger::StepOut} (step out) +- Continue execution with {#kb:jetbrains debugger::Continue} Zed can debug: @@ -359,7 +352,7 @@ If you're used to AI assistants in WebStorm (like GitHub Copilot, JetBrains AI A ### Configuring GitHub Copilot -1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +1. Open Settings with {#kb zed::OpenSettings} 2. Navigate to **AI → Edit Predictions** 3. Click **Configure** next to "Configure Providers" 4. Under **GitHub Copilot**, click **Sign in to GitHub** diff --git a/docs/src/performance.md b/docs/src/performance.md index e974d63f8816b68d30a1c06d7cbbc083f8564327..e52ea9c684de0e2b9d39efe2741dfe0728bc7641 100644 --- a/docs/src/performance.md +++ b/docs/src/performance.md @@ -15,7 +15,7 @@ See [samply](https://github.com/mstange/samply)'s README on how to install and r The profile.json does not contain any symbols. Firefox profiler can add the local symbols to the profile for for. To do that hit the upload local profile button in the top right corner. -image +image # In depth CPU profiling (Tracing) @@ -52,10 +52,12 @@ Download the profiler: ## Usage Open the profiler (tracy-profiler), you should see zed in the list of `Discovered clients` click it. -image + +image To find functions that take a long time follow this image: -image + +image # Task/Async profiling diff --git a/tooling/xtask/src/tasks/workflows/compare_perf.rs b/tooling/xtask/src/tasks/workflows/compare_perf.rs index 74a1fbdc389e2b0dacdf579d9ee96a0366eb5c01..39f17b8d148bd6022913fdf5097368690cbd0fd0 100644 --- a/tooling/xtask/src/tasks/workflows/compare_perf.rs +++ b/tooling/xtask/src/tasks/workflows/compare_perf.rs @@ -42,7 +42,11 @@ pub fn run_perf( } fn install_hyperfine() -> Step { - named::uses("taiki-e", "install-action", "hyperfine") + named::uses( + "taiki-e", + "install-action", + "b4f2d5cb8597b15997c8ede873eb6185efc5f0ad", // hyperfine + ) } fn compare_runs(head: &WorkflowInput, base: &WorkflowInput) -> Step { diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs index a69856ed3333810dcada4b8a8ac5b6cadee12e23..a1c2abc169f4348fd04a529c5a5b10b412464c9b 100644 --- a/tooling/xtask/src/tasks/workflows/extension_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -145,7 +145,12 @@ fn create_version_label( } fn create_version_tag(tag: &StepOutput, generated_token: StepOutput) -> Step { - named::uses("actions", "github-script", "v7").with( + named::uses( + "actions", + "github-script", + "f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7 + ) + .with( Input::default() .add( "script", @@ -413,7 +418,11 @@ fn enable_automerge_if_staff( pull_request_number: StepOutput, generated_token: StepOutput, ) -> Step { - named::uses("actions", "github-script", "v7") + named::uses( + "actions", + "github-script", + "f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7 + ) .add_with(("github-token", generated_token.to_string())) .add_with(( "script", diff --git a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs index 418b7f9e4617ad0ca42b666b7eb4d7d9614895a7..3a5d14603f97b43aacb581aaf3b970bac31b701f 100644 --- a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs +++ b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs @@ -50,7 +50,7 @@ pub(crate) fn extension_workflow_rollout() -> Workflow { fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOutput, JobOutput) { fn get_repositories(filter_repos_input: &WorkflowInput) -> (Step, StepOutput) { - let step = named::uses("actions", "github-script", "v7") + let step = named::uses("actions", "github-script", "f28e40c7f34bde8b3046d885e986cb6290c5673b") .id("list-repos") .add_with(( "script", diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 1be6a779f33bfb411ccdd5ac4d979b07dc283e50..ebdd9b30538eb389a267a1c2fdb1822eec1d3a54 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -177,7 +177,11 @@ pub fn cargo_fmt() -> Step { } pub fn cargo_install_nextest() -> Step { - named::uses("taiki-e", "install-action", "nextest") + named::uses( + "taiki-e", + "install-action", + "921e2c9f7148d7ba14cd819f417db338f63e733c", // nextest + ) } pub fn setup_cargo_config(platform: Platform) -> Step { @@ -230,9 +234,13 @@ pub fn install_rustup_target(target: &str) -> Step { } pub fn cache_rust_dependencies_namespace() -> Step { - named::uses("namespacelabs", "nscloud-cache-action", "v1") - .add_with(("cache", "rust")) - .add_with(("path", "~/.rustup")) + named::uses( + "namespacelabs", + "nscloud-cache-action", + "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1 + ) + .add_with(("cache", "rust")) + .add_with(("path", "~/.rustup")) } pub fn setup_sccache(platform: Platform) -> Step { @@ -259,14 +267,24 @@ pub fn show_sccache_stats(platform: Platform) -> Step { } pub fn cache_nix_dependencies_namespace() -> Step { - named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("cache", "nix")) + named::uses( + "namespacelabs", + "nscloud-cache-action", + "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1 + ) + .add_with(("cache", "nix")) } pub fn cache_nix_store_macos() -> Step { // On macOS, `/nix` is on a read-only root filesystem so nscloud's `cache: nix` // cannot mount or symlink there. Instead we cache a user-writable directory and // use nix-store --import/--export in separate steps to transfer store paths. - named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("path", "~/nix-cache")) + named::uses( + "namespacelabs", + "nscloud-cache-action", + "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1 + ) + .add_with(("path", "~/nix-cache")) } pub fn setup_linux() -> Step {