Detailed changes
@@ -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
@@ -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
@@ -1,28 +1,13 @@
-## Context
+Self-Review Checklist:
-<!-- What does this PR do, and why? How is it expected to impact users?
- Not just what changed, but what motivated it and why this approach.
-
- Link to Linear issue (e.g., ENG-123) or GitHub issue (e.g., Closes #456)
- if one exists β helps with traceability. -->
-
-## How to Review
-
-<!-- Help reviewers focus their attention:
- - For small PRs: note what to focus on (e.g., "error handling in foo.rs")
- - For large PRs (>400 LOC): provide a guided tour β numbered list of
- files/commits to read in order. (The `large-pr` label is applied automatically.)
- - See the review process guidelines for comment conventions -->
-
-## Self-Review Checklist
-
-<!-- Check before requesting review: -->
- [ ] 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 ...
@@ -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 \
@@ -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
@@ -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"
@@ -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
@@ -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:
@@ -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:
@@ -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
@@ -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: |
@@ -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
@@ -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
@@ -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(/<!--[\s\S]*?-->/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}
@@ -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 = '<!-- pr-size-check -->';
- 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}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
@@ -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"
@@ -1077,6 +1077,7 @@
"alt-up": "collab_panel::MoveChannelUp",
"alt-down": "collab_panel::MoveChannelDown",
"alt-enter": "collab_panel::OpenSelectedChannelNotes",
+ "shift-enter": "collab_panel::ToggleSelectedChannelFavorite",
},
},
{
@@ -1138,6 +1138,7 @@
"alt-up": "collab_panel::MoveChannelUp",
"alt-down": "collab_panel::MoveChannelDown",
"alt-enter": "collab_panel::OpenSelectedChannelNotes",
+ "shift-enter": "collab_panel::ToggleSelectedChannelFavorite",
},
},
{
@@ -1082,6 +1082,7 @@
"alt-up": "collab_panel::MoveChannelUp",
"alt-down": "collab_panel::MoveChannelDown",
"alt-enter": "collab_panel::OpenSelectedChannelNotes",
+ "shift-enter": "collab_panel::ToggleSelectedChannelFavorite",
},
},
{
@@ -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",
@@ -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.
@@ -160,6 +160,7 @@ pub enum AgentThreadEntry {
UserMessage(UserMessage),
AssistantMessage(AssistantMessage),
ToolCall(ToolCall),
+ CompletedPlan(Vec<PlanEntry>),
}
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<Self>) {
+ 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>) {
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;
@@ -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
@@ -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::{
@@ -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)
})
}
@@ -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),
)
@@ -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),
)
@@ -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<String> {
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ let trimmed = stderr.trim();
@@ -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 || {
@@ -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
@@ -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
@@ -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);
@@ -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::*,
@@ -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::<Editor>(cx);
});
@@ -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<Self>) {
- 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<Self>) {
@@ -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::*,
@@ -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
});
@@ -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);
@@ -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<usize>,
pub show_external_source_prompt_warning: bool,
pub show_codex_windows_warning: bool,
+ pub generating_indicator_in_list: bool,
pub history: Option<Entity<ThreadHistory>>,
pub _history_subscription: Option<Subscription>,
}
@@ -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>) {
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<Self>) {
@@ -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<Self>) -> Option<AnyElement> {
+ 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<Self>,
+ ) -> 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<Buffer>, Entity<BufferDiff>>,
@@ -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<Self>) -> Option<impl IntoElement> {
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::<Vec<_>>();
+ 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<uuid::Uuid>,
+ project_rules_count: usize,
+ project_entry_ids: Vec<ProjectEntryId>,
+ workspace: WeakEntity<Workspace>,
+}
+
+impl Render for TokenUsageTooltip {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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::<Vec<_>>();
+ 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<Self>) -> 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<Self>) {
- 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>) {
+ self.list_state.scroll_to_end();
cx.notify();
}
@@ -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::<Editor>() {
@@ -320,6 +327,7 @@ pub enum Entry {
UserMessage(Entity<MessageEditor>),
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<MessageEditor>> {
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<ScrollHandle> {
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);
});
}
@@ -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;
@@ -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);
});
@@ -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};
@@ -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);
@@ -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]
@@ -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);
});
}
@@ -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::<Vec<_>>()
+ });
+ 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::<Vec<_>>()
+ });
+ 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::<Vec<_>>()
+ });
+ 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::<Vec<_>>()
+ });
+ assert_eq!(metadata_ids, vec![session_id]);
+ }
+
#[gpui::test]
async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) {
cx.update(|cx| {
@@ -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<Self>) -> 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;
@@ -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};
@@ -1651,6 +1651,7 @@ impl BufferDiff {
language: Option<Arc<Language>>,
cx: &App,
) -> Task<BufferDiffUpdate> {
+ 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);
+ }
}
@@ -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
@@ -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
@@ -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
@@ -191,7 +191,7 @@ pub async fn run_randomized_test<T: RandomizedTest>(
let settings = cx.remove_global::<SettingsStore>();
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();
@@ -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);
@@ -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
@@ -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<Subscription>,
collapsed_sections: Vec<Section>,
collapsed_channels: Vec<ChannelId>,
+ favorite_channels: Vec<ChannelId>,
filter_active_channels: bool,
workspace: WeakEntity<Workspace>,
}
@@ -263,11 +267,14 @@ pub struct CollabPanel {
#[derive(Serialize, Deserialize)]
struct SerializedCollabPanel {
collapsed_channels: Option<Vec<u64>>,
+ #[serde(default)]
+ favorite_channels: Option<Vec<u64>>,
}
#[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<Self>) {
- 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<usize, &StringMatch> =
+ 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<Self>) {
+ 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<Self>,
+ ) {
+ 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<Self>) -> 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()
@@ -111,7 +111,7 @@ impl IncomingCallNotification {
impl Render for IncomingCallNotification {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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(
@@ -120,7 +120,7 @@ impl ProjectSharedNotification {
impl Render for ProjectSharedNotification {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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 { ":" };
@@ -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
@@ -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;
@@ -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<K, V> {
+ keys: Vec<K>,
+ values: Vec<V>,
+}
+
+impl<K, V> VecMap<K, V> {
+ 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<K: Eq, V> VecMap<K, V> {
+ 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, K>, 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::Item> {
+ 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<F>(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<F>(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<K, V>,
+ 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<F>(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<F>(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<K, V>,
+ key: &'key K,
+}
@@ -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<_>>(), 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<_>>(), 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<_>>(), 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<_>>(), 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<_>>(),
+ 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<_>>(), 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<_>>(), 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<_>>(),
+ vec![(&"b", &2), (&"a", &1), (&"c", &3)]
+ );
+}
+
+#[test]
+fn test_multiple_entries_independent() {
+ let mut map: VecMap<i32, i32> = 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<Cell<usize>>,
+}
+
+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<String, i32> = 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<String, i32> = 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<CountedKey, i32> = 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<CountedKey, i32> = 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<String, String> = 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<_>>(),
+ vec![(&"hello".to_string(), &"HELLO".to_string())]
+ );
+}
+
+#[test]
+fn test_entry_ref_or_insert_with_not_called_when_occupied() {
+ let mut map: VecMap<String, i32> = 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<_>>(), vec![(&key, &1)]);
+}
+
+#[test]
+fn test_entry_ref_or_insert_default() {
+ let mut map: VecMap<String, i32> = VecMap::new();
+ map.entry_ref(&"a".to_string()).or_insert_default();
+ assert_eq!(map.iter().collect::<Vec<_>>(), vec![(&"a".to_string(), &0)]);
+}
+
+#[test]
+fn test_entry_ref_key() {
+ let mut map: VecMap<String, i32> = 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<String, i32> = VecMap::new();
+ let key = "a".to_string();
+ let value = map.entry_ref(&key).or_insert(0);
+ *value = 5;
+ assert_eq!(map.iter().collect::<Vec<_>>(), vec![(&key, &5)]);
+}
@@ -49,3 +49,4 @@ menu.workspace = true
project = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }
+theme_settings.workspace = true
@@ -931,7 +931,7 @@ mod tests {
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
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);
@@ -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
@@ -39,7 +39,7 @@ fn main() {
<dyn fs::Fs>::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(),
@@ -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
@@ -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));
});
@@ -481,7 +481,6 @@ impl ConfigurationView {
cx: &mut Context<Self>,
) -> 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);
}
})
@@ -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
@@ -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;
@@ -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::*,
@@ -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);
@@ -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
@@ -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;
@@ -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);
});
@@ -22,8 +22,45 @@ static KEYMAP_WINDOWS: LazyLock<KeymapFile> = LazyLock::new(|| {
load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap")
});
+static KEYMAP_JETBRAINS_MACOS: LazyLock<KeymapFile> = LazyLock::new(|| {
+ load_keymap("keymaps/macos/jetbrains.json").expect("Failed to load JetBrains macOS keymap")
+});
+
+static KEYMAP_JETBRAINS_LINUX: LazyLock<KeymapFile> = LazyLock::new(|| {
+ load_keymap("keymaps/linux/jetbrains.json").expect("Failed to load JetBrains Linux keymap")
+});
+
static ALL_ACTIONS: LazyLock<ActionManifest> = 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<Self> {
+ 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 = "<!-- ZED_META {} -->";
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<PreprocessorError>) {
- 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 "<div>No default binding</div>".to_string();
@@ -227,7 +293,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Prepr
let formatted_macos_binding = format_binding(macos_binding);
let formatted_linux_binding = format_binding(linux_binding);
- format!("<kbd class=\"keybinding\">{formatted_macos_binding}|{formatted_linux_binding}</kbd>")
+ format!("<kbd class=\"keybinding\">{formatted_macos_binding}|{formatted_linux_binding}</kbd>")
})
.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<String> {
- 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<String> {
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<String> {
})
}
+fn find_binding(os: Os, action: &str) -> Option<String> {
+ 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<KeymapOverlay>,
+) -> Option<String> {
+ 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<PreprocessorError>) {
let settings_schema = SettingsStore::json_schema(&Default::default());
let settings_validator = jsonschema::validator_for(&settings_schema)
@@ -1992,7 +1992,7 @@ impl EditPredictionStore {
}
fn currently_following(project: &Entity<Project>, 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;
};
@@ -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;
@@ -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
@@ -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
@@ -1418,9 +1418,9 @@ pub fn get_available_providers(cx: &mut App) -> Vec<EditPredictionProvider> {
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);
};
@@ -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::*,
@@ -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 }
@@ -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);
});
@@ -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};
@@ -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::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, f);
});
@@ -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);
}
@@ -57,7 +57,8 @@ impl FoldPlaceholder {
pub fn fold_element(fold_id: FoldId, cx: &App) -> Stateful<gpui::Div> {
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)
@@ -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
@@ -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);
});
}
@@ -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::<MultiBufferOffset>(&display_map)
- .reversed;
+ let initial_selection = self.selections.oldest::<MultiBufferOffset>(&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<usize>, 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<usize>, 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<Item = &str> + '_ {
@@ -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);
});
@@ -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(
@@ -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);
});
@@ -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;
@@ -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);
});
@@ -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<HighlightedText>, Option<Font>)> {
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
@@ -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);
}
}
@@ -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, |_| {});
@@ -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,
@@ -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;
@@ -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
@@ -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 {
@@ -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
@@ -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);
});
}
@@ -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
})
@@ -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
@@ -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,
@@ -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"] }
@@ -3789,7 +3789,7 @@ async fn open_queried_buffer(
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
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
@@ -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<Oid>,
+ ) -> BoxFuture<'_, Result<()>> {
+ async { bail!("search_commits not supported for FakeGitRepository") }.boxed()
+ }
+
fn commit_data_reader(&self) -> Result<CommitDataReader> {
anyhow::bail!("commit_data_reader not supported for FakeGitRepository")
}
@@ -161,6 +161,14 @@ impl Oid {
}
}
+impl TryFrom<&str> for Oid {
+ type Error = anyhow::Error;
+
+ fn try_from(value: &str) -> std::prelude::v1::Result<Self, Self::Error> {
+ Oid::from_str(value)
+ }
+}
+
impl FromStr for Oid {
type Err = anyhow::Error;
@@ -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<Vec<Arc<InitialGraphCommitData>>>,
) -> BoxFuture<'_, Result<()>>;
+ fn search_commits(
+ &self,
+ log_source: LogSource,
+ search_args: SearchCommitArgs,
+ request_tx: Sender<Oid>,
+ ) -> BoxFuture<'_, Result<()>>;
+
fn commit_data_reader(&self) -> Result<CommitDataReader>;
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<OsString> = vec![
- "--no-optional-locks".into(),
- "worktree".into(),
- "remove".into(),
- ];
+ let mut args: Vec<OsString> = 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<OsString> = 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<Oid>,
+ ) -> 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<CommitDataReader> {
let git_binary = self.git_binary()?;
@@ -2741,7 +2799,7 @@ async fn run_commit_data_reader(
request_rx: smol::channel::Receiver<CommitDataRequest>,
) -> 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<OsString> {
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 {
@@ -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
@@ -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<Editor>,
+ state: QueryState,
+ pub matches: IndexSet<Oid>,
+ pub selected_index: Option<usize>,
+}
+
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::<GitGraph>(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<Project>,
workspace: WeakEntity<Workspace>,
@@ -859,6 +892,14 @@ pub struct GitGraph {
}
impl GitGraph {
+ fn invalidate_state(&mut self, cx: &mut Context<Self>) {
+ 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::<Vec<_>>()
+ } 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::<Vec<_>>()
+ };
+
+ (!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>) {
- self.select_entry(0, cx);
+ self.select_entry(0, ScrollStrategy::Nearest, cx);
}
fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
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>) {
- 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>) {
self.open_selected_commit_view(window, cx);
}
- fn select_entry(&mut self, idx: usize, cx: &mut Context<Self>) {
+ fn search(&mut self, query: SharedString, cx: &mut Context<Self>) {
+ 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::<Oid>();
+
+ 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<Self>) {
+ 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<Self>,
+ ) {
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<Self>) {
- let Ok(oid) = sha.parse::<Oid>() else {
+ fn select_previous_match(&mut self, cx: &mut Context<Self>) {
+ 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<Self>) {
+ 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<Oid>, cx: &mut Context<Self>) {
+ fn inner(this: &mut GitGraph, oid: Oid, cx: &mut Context<GitGraph>) {
+ 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<Self>) {
@@ -1318,6 +1532,129 @@ impl GitGraph {
})
}
+ fn render_search_bar(&self, cx: &mut Context<Self>) -> 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<Self>,
) {
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<Self>) -> 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);
});
}
@@ -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
@@ -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;
@@ -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);
});
}
@@ -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;
@@ -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);
});
}
@@ -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);
});
@@ -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);
});
@@ -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);
})
}
@@ -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);
});
}
@@ -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<SharedString>,
+ forbidden_deletion_path: Option<PathBuf>,
}
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::<Vec<_>>()
+ });
+
+ 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(
@@ -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"] }
@@ -72,6 +72,7 @@ struct StateInner {
scrollbar_drag_start_height: Option<Pixels>,
measuring_behavior: ListMeasuringBehavior,
pending_scroll: Option<PendingScrollFraction>,
+ 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<Cell<usize>>,
+ }
+ impl Render for TestView {
+ fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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();
@@ -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::<TaskTiming>();
+// 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::<TaskTiming>();
#[doc(hidden)]
-pub type TaskTimings = circular_buffer::CircularBuffer<MAX_TASK_TIMINGS, TaskTiming>;
+pub(crate) type TaskTimings = VecDeque<TaskTiming>;
+
#[doc(hidden)]
pub type GuardedTaskTimings = spin::Mutex<ThreadTimings>;
@@ -287,7 +288,7 @@ thread_local! {
pub struct ThreadTimings {
pub thread_name: Option<String>,
pub thread_id: ThreadId,
- pub timings: Box<TaskTimings>,
+ 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())
+}
@@ -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<regex::Regex> =
+ 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<usvg::fontdb::ID> {
+ 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<usvg::fontdb::Database>| {
+ 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, 'β'));
+ }
+}
@@ -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 {
@@ -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);
}
@@ -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 {
@@ -17,4 +17,3 @@ brackets = [
]
debuggers = ["CodeLLDB", "GDB"]
documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }
-import_path_strip_regex = "^<|>$"
@@ -1,7 +0,0 @@
-(preproc_include
- path: [
- ((system_lib_string) @source @wildcard
- (#strip! @source "[<>]"))
- (string_literal
- (string_content) @source @wildcard)
- ]) @import
@@ -19,4 +19,3 @@ brackets = [
]
debuggers = ["CodeLLDB", "GDB"]
documentation_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }
-import_path_strip_regex = "^<|>$"
@@ -1,6 +0,0 @@
-(preproc_include
- path: [
- (system_lib_string) @source @wildcard
- (string_literal
- (string_content) @source @wildcard)
- ]) @import
@@ -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
@@ -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"
@@ -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
@@ -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$"
@@ -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
@@ -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$"
@@ -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)
@@ -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
@@ -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 = ["-", "."]
@@ -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
@@ -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
@@ -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::{
@@ -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
@@ -57,7 +57,7 @@ fn render_inspector(
window: &mut Window,
cx: &mut Context<Inspector>,
) -> 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);
@@ -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
@@ -3431,7 +3431,7 @@ impl ActionArgumentsEditor {
impl Render for ActionArgumentsEditor {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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 {
@@ -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());
@@ -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"
@@ -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 {
@@ -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> {
- self.with_grammar_query_and_name(|grammar, name| grammar.with_imports_query(source, name))
- }
-
pub fn with_brackets_query(self, source: &str) -> Result<Self> {
self.with_grammar_query_and_name(|grammar, name| grammar.with_brackets_query(source, name))
}
@@ -1579,9 +1574,6 @@ pub fn rust_lang() -> Arc<Language> {
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)
@@ -41,7 +41,6 @@ pub struct Grammar {
pub injection_config: Option<InjectionConfig>,
pub override_config: Option<OverrideConfig>,
pub debug_variables_config: Option<DebugVariablesConfig>,
- pub imports_config: Option<ImportsConfig>,
pub highlight_map: Mutex<HighlightMap>,
}
@@ -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<u32>,
- pub namespace_ix: Option<u32>,
- pub source_ix: Option<u32>,
- pub list_ix: Option<u32>,
- pub wildcard_ix: Option<u32>,
- pub alias_ix: Option<u32>,
-}
-
enum Capture<'a> {
Required(&'static str, &'a mut u32),
Optional(&'static str, &'a mut Option<u32>),
@@ -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<Self> {
- 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,
@@ -148,15 +148,6 @@ pub struct LanguageConfig {
/// A list of preferred debuggers for this language.
#[serde(default)]
pub debuggers: IndexSet<SharedString>,
- /// 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<Arc<str>>,
- /// 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<Regex>,
}
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,
}
}
}
@@ -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::{
@@ -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<Cow<'static, str>>,
pub text_objects: Option<Cow<'static, str>>,
pub debugger: Option<Cow<'static, str>>,
- pub imports: Option<Cow<'static, str>>,
}
@@ -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);
}
}
@@ -17,7 +17,12 @@ pub enum LanguageModelToolSchemaFormat {
pub fn root_schema_for<T: JsonSchema>(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;
@@ -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")?;
@@ -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
+zlog.workspace = true
+theme_settings.workspace = true
@@ -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);
});
}
@@ -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)
-}
@@ -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
@@ -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());
@@ -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);
@@ -505,7 +505,7 @@ mod tests {
settings::init(cx);
}
if !cx.has_global::<theme::GlobalTheme>() {
- theme::init(theme::LoadThemes::JustBase, cx);
+ theme_settings::init(theme::LoadThemes::JustBase, cx);
}
});
}
@@ -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::GlobalTheme>() {
- theme::init(theme::LoadThemes::JustBase, cx);
+ theme_settings::init(theme::LoadThemes::JustBase, cx);
}
});
}
@@ -278,7 +278,7 @@ mod tests {
settings::init(cx);
}
if !cx.has_global::<theme::GlobalTheme>() {
- theme::init(theme::LoadThemes::JustBase, cx);
+ theme_settings::init(theme::LoadThemes::JustBase, cx);
}
});
}
@@ -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
@@ -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};
@@ -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
@@ -456,7 +456,7 @@ impl Render for ProfilerWindow {
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> 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();
@@ -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
@@ -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 = <dyn 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,
@@ -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]
@@ -410,7 +410,7 @@ async fn test_open_path_prompt_with_show_hidden(cx: &mut TestAppContext) {
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
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
@@ -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
@@ -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};
@@ -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
@@ -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);
@@ -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
@@ -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);
});
}
@@ -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
@@ -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,
@@ -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<Oid>,
+ cx: &mut Context<Self>,
+ ) {
+ 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,
@@ -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,
@@ -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
@@ -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,
@@ -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::<SettingsStore, _>(|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);
@@ -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
@@ -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);
});
@@ -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",
];
@@ -1852,7 +1852,6 @@ impl RemoteServerProjects {
cx: &mut Context<Self>,
) {
let replace_window = window.window_handle().downcast::<MultiWorkspace>();
-
let app_state = Arc::downgrade(&app_state);
cx.spawn_in(window, async move |entity, cx| {
let (connection, starting_dir) =
@@ -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(),
)
@@ -235,9 +235,6 @@ impl WslOpenModal {
cx: &mut Context<Self>,
) {
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(),
@@ -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
@@ -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::*,
@@ -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"] }
@@ -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);
});
@@ -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
@@ -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;
@@ -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;
@@ -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()
}
@@ -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;
@@ -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
@@ -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<Self>) -> 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(
@@ -17,3 +17,4 @@ serde.workspace = true
serde_json.workspace = true
settings.workspace = true
theme.workspace = true
+theme_settings.workspace = true
@@ -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 {
@@ -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
@@ -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);
});
}
@@ -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);
@@ -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,
@@ -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::*};
@@ -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
@@ -769,6 +769,7 @@ impl VsCodeSettings {
fn status_bar_settings_content(&self) -> Option<StatusBarSettingsContent> {
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,
@@ -1133,15 +1133,15 @@ pub struct WhichKeySettingsContent {
pub delay_ms: Option<u64>,
}
+// 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<String, bool> 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<String, bool> 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<T>(pub Vec<T>);
impl<T> Into<Vec<T>> for ExtendingVec<T> {
@@ -1161,10 +1161,10 @@ impl<T: Clone> merge_from::MergeFrom for ExtendingVec<T> {
}
}
-/// 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);
@@ -434,6 +434,10 @@ pub struct StatusBarSettingsContent {
/// Default: true
#[serde(rename = "experimental.show")]
pub show: Option<bool>,
+ /// Whether to show the name of the active file in the status bar.
+ ///
+ /// Default: false
+ pub show_active_file: Option<bool>,
/// Whether to display the active language button in the status bar.
///
/// Default: true
@@ -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"] }
@@ -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
@@ -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
@@ -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)]
@@ -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,
+ }),
]
}
@@ -710,12 +710,9 @@ fn render_github_copilot_provider(window: &mut Window, cx: &mut App) -> Option<i
let configuration_view = window.use_state(cx, |_, cx| {
copilot_ui::ConfigurationView::new(
move |cx| {
- if let Some(app_state) = AppState::global(cx).upgrade() {
- copilot::GlobalCopilotAuth::try_get_or_init(app_state, cx)
- .is_some_and(|copilot| copilot.0.read(cx).is_authenticated())
- } else {
- false
- }
+ let app_state = AppState::global(cx);
+ copilot::GlobalCopilotAuth::try_get_or_init(app_state, cx)
+ .is_some_and(|copilot| copilot.0.read(cx).is_authenticated())
},
copilot_ui::ConfigurationMode::EditPrediction,
cx,
@@ -7,7 +7,7 @@ use gpui::{
use settings::{Settings as _, SettingsStore, ToolPermissionMode};
use shell_command_parser::extract_commands;
use std::sync::Arc;
-use theme::ThemeSettings;
+use theme_settings::ThemeSettings;
use ui::{Banner, ContextMenu, Divider, PopoverMenu, Severity, Tooltip, prelude::*};
use util::ResultExt as _;
use util::shell::ShellKind;
@@ -33,7 +33,7 @@ use std::{
sync::{Arc, LazyLock, RwLock},
time::Duration,
};
-use theme::ThemeSettings;
+use theme_settings::ThemeSettings;
use ui::{
Banner, ContextMenu, Divider, DropdownMenu, DropdownStyle, IconButtonShape, KeyBinding,
KeybindingHint, PopoverMenu, Scrollbars, Switch, Tooltip, TreeViewItem, WithScrollbar,
@@ -639,7 +639,9 @@ pub fn open_settings_editor(
// We have to defer this to get the workspace off the stack.
let path = path.map(ToOwned::to_owned);
cx.defer(move |cx| {
- let current_rem_size: f32 = theme::ThemeSettings::get_global(cx).ui_font_size(cx).into();
+ let current_rem_size: f32 = theme_settings::ThemeSettings::get_global(cx)
+ .ui_font_size(cx)
+ .into();
let default_bounds = DEFAULT_ADDITIONAL_WINDOW_SIZE;
let default_rem_size = 16.0;
@@ -1392,17 +1394,14 @@ impl PartialEq for ActionLink {
}
fn all_language_names(cx: &App) -> Vec<SharedString> {
- 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<Entity<Workspace>> = app_state
- .workspace_store
- .read(cx)
- .workspaces()
- .filter_map(|weak| weak.upgrade())
- .collect();
+ let app_state = AppState::global(cx);
+ let workspaces: Vec<Entity<Workspace>> = 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<Self>) -> 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<Item = Entity<Project>> {
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>| 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::<Vec<_>>()
- }),
- )
- .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>| 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::<Vec<_>>()
+ }),
+ )
+ .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);
@@ -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
@@ -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<Entity<Workspace>>,
+ /// 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<Arc<Path>>,
+}
+
+impl ProjectGroup {
+ fn add_workspace(&mut self, workspace: &Entity<Workspace>, 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<Workspace> {
+ self.workspaces
+ .first()
+ .expect("groups always have at least one workspace")
+ }
+
+ pub fn main_workspace(&self, cx: &App) -> &Entity<Workspace> {
+ 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<PathBuf, PathBuf>,
+ project_groups: VecMap<ProjectGroupName, ProjectGroup>,
+}
+
+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<Workspace>,
+ 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<Item = (&ProjectGroupName, &ProjectGroup)> {
+ 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<FakeFs> {
+ 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| <dyn fs::Fs>::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| <dyn fs::Fs>::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"),
+ );
+ });
+ }
+}
@@ -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<usize>,
+}
+
#[derive(Clone)]
struct ThreadEntry {
agent: Agent,
@@ -105,12 +125,28 @@ struct ThreadEntry {
is_background: bool,
is_title_generating: bool,
highlight_positions: Vec<usize>,
- worktree_name: Option<SharedString>,
- worktree_full_path: Option<SharedString>,
- worktree_highlight_positions: Vec<usize>,
+ worktrees: Vec<WorktreeInfo>,
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<Entity<Workspace>> {
+ 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<ThreadEntry> for ListEntry {
fn from(thread: ThreadEntry) -> Self {
ListEntry::Thread(thread)
@@ -202,19 +260,31 @@ fn workspace_path_list(workspace: &Entity<Workspace>, 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<WorktreeInfo> {
+ 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<acp::SessionId> = HashSet::new();
let mut project_header_indices: Vec<usize> = 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<Arc<Path>, usize> = HashMap::new();
- let mut absorbed: HashMap<usize, (usize, SharedString)> = HashMap::new();
- let mut pending: HashMap<Arc<Path>, Vec<(usize, SharedString, Arc<Path>)>> = HashMap::new();
- let mut absorbed_workspace_by_path: HashMap<Arc<Path>, usize> = HashMap::new();
- let workspace_indices_by_path: HashMap<Arc<Path>, Vec<usize>> = 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<Path> = 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<SharedString>) {
+ 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<ThreadEntry> = Vec::new();
let mut has_running_threads = false;
@@ -719,138 +742,90 @@ impl Sidebar {
if should_load_threads {
let mut seen_session_ids: HashSet<acp::SessionId> = 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<Path>)> =
- 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::<Vec<_>>()
+ });
- 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<Self>) {
- 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<std::path::PathBuf> = 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<Entity<Workspace>> = 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<Workspace>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- 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<Self>) {
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<Self>) -> 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<Workspace>,
cx: &App,
) -> impl Iterator<Item = ActiveThreadInfo> {
- enum ThreadInfoIterator<T: Iterator<Item = ActiveThreadInfo>> {
- Empty,
- Threads(T),
- }
-
- impl<T: Iterator<Item = ActiveThreadInfo>> Iterator for ThreadInfoIterator<T> {
- type Item = ActiveThreadInfo;
-
- fn next(&mut self) -> Option<Self::Item> {
- match self {
- ThreadInfoIterator::Empty => None,
- ThreadInfoIterator::Threads(threads) => threads.next(),
- }
- }
- }
-
let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(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<project::Project> {
- init_test(cx);
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
- .await;
- cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
- project::Project::test(fs, [worktree_path.as_ref()], cx).await
- }
-
- fn setup_sidebar(
- multi_workspace: &Entity<MultiWorkspace>,
- cx: &mut gpui::VisualTestContext,
- ) -> Entity<Sidebar> {
- 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<Utc>,
- 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<Sidebar>, 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<Sidebar>,
- cx: &mut gpui::VisualTestContext,
- ) -> Vec<String> {
- 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<project::Project> {
- 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| <dyn fs::Fs>::set_global(fs.clone(), cx));
- project::Project::test(fs, [worktree_path.as_ref()], cx).await
- }
-
- fn add_agent_panel(
- workspace: &Entity<Workspace>,
- project: &Entity<project::Project>,
- cx: &mut gpui::VisualTestContext,
- ) -> Entity<AgentPanel> {
- 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<MultiWorkspace>,
- project: &Entity<project::Project>,
- cx: &mut gpui::VisualTestContext,
- ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
- 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| <dyn fs::Fs>::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<Sidebar>, 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::<String>::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::<std::path::PathBuf>(&[]);
-
- 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::<String>::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::<std::path::PathBuf>(&[]);
-
- 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<usize> 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| <dyn fs::Fs>::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::<AgentPanel>(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| <dyn fs::Fs>::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::<AgentPanel>(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| <dyn fs::Fs>::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::<AgentPanel>(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<project::Project>, Arc<dyn fs::Fs>) {
- init_test(cx);
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- worktree_path,
- serde_json::json!({
- ".git": {},
- "src": {},
- }),
- )
- .await;
- cx.update(|cx| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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<Sidebar>| {
- 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("<none>");
- 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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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()
}
@@ -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<project::Project> {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
+ .await;
+ cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+ project::Project::test(fs, [worktree_path.as_ref()], cx).await
+}
+
+fn setup_sidebar(
+ multi_workspace: &Entity<MultiWorkspace>,
+ cx: &mut gpui::VisualTestContext,
+) -> Entity<Sidebar> {
+ 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<Utc>,
+ 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<Sidebar>, 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<Sidebar>,
+ cx: &mut gpui::VisualTestContext,
+) -> Vec<String> {
+ 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<project::Project> {
+ 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| <dyn fs::Fs>::set_global(fs.clone(), cx));
+ project::Project::test(fs, [worktree_path.as_ref()], cx).await
+}
+
+fn add_agent_panel(
+ workspace: &Entity<Workspace>,
+ project: &Entity<project::Project>,
+ cx: &mut gpui::VisualTestContext,
+) -> Entity<AgentPanel> {
+ 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<MultiWorkspace>,
+ project: &Entity<project::Project>,
+ cx: &mut gpui::VisualTestContext,
+) -> (Entity<Sidebar>, Entity<AgentPanel>) {
+ 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| <dyn fs::Fs>::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<Sidebar>, 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::<String>::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::<std::path::PathBuf>(&[]);
+
+ 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::<String>::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::<std::path::PathBuf>(&[]);
+
+ 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<usize> 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| <dyn fs::Fs>::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::<AgentPanel>(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| <dyn fs::Fs>::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::<AgentPanel>(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| <dyn fs::Fs>::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::<AgentPanel>(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<project::Project>, Arc<dyn fs::Fs>) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ worktree_path,
+ serde_json::json!({
+ ".git": {},
+ "src": {},
+ }),
+ )
+ .await;
+ cx.update(|cx| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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<Sidebar>| {
+ 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("<none>");
+ 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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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| <dyn fs::Fs>::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<FakeFs>,
+ thread_counter: u32,
+ workspace_counter: u32,
+ worktree_counter: u32,
+ saved_thread_ids: Vec<acp::SessionId>,
+ workspace_paths: Vec<String>,
+ main_repo_indices: Vec<usize>,
+ unopened_worktrees: Vec<UnopenedWorktree>,
+ }
+
+ impl TestState {
+ fn new(fs: Arc<FakeFs>, 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<MultiWorkspace>,
+ sidebar: &Entity<Sidebar>,
+ 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::<AgentPanel>(window, cx);
+ } else {
+ workspace.open_panel::<AgentPanel>(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<dyn fs::Fs>,
+ [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<dyn fs::Fs>,
+ [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<Sidebar>, cx: &mut gpui::VisualTestContext) {
+ sidebar.update_in(cx, |sidebar, _window, cx| {
+ sidebar.collapsed_groups.clear();
+ let path_lists: Vec<PathList> = 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<Workspace>| -> HashSet<PathBuf> {
+ root_repository_snapshots(ws, cx)
+ .map(|snapshot| snapshot.original_repo_abs_path.to_path_buf())
+ .collect::<HashSet<_>>()
+ };
+
+ // Build a map from canonical repo path β set of workspace
+ // EntityIds that share that repo.
+ let mut repo_to_workspaces: HashMap<PathBuf, HashSet<EntityId>> = 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<Workspace>| -> 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<EntityId> = workspaces
+ .iter()
+ .filter(|ws| !workspace_path_list(ws, cx).paths().is_empty())
+ .map(|ws| ws.entity_id())
+ .collect();
+
+ let sidebar_workspaces: HashSet<EntityId> = 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<acp::SessionId> = sidebar
+ .contents
+ .entries
+ .iter()
+ .filter_map(|entry| entry.session_id().cloned())
+ .collect();
+
+ let mut metadata_thread_ids: HashSet<acp::SessionId> = 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::<AgentPanel>(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<u32>,
+ 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| <dyn fs::Fs>::set_global(fs.clone(), cx));
+ let project =
+ project::Project::test(fs.clone() as Arc<dyn fs::Fs>, ["/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<String> = 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(),
+ );
+ }
+ }
+ }
+}
@@ -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"] }
@@ -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)))
},
@@ -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
@@ -258,7 +258,7 @@ async fn test_close_selected_item(cx: &mut gpui::TestAppContext) {
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
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
@@ -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
@@ -36,11 +36,19 @@ impl ProcessIdGetter {
}
fn pid(&self) -> Option<Pid> {
+ // 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
}
}
@@ -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<String>,
child_exited: Option<ExitStatus>,
+ keyboard_input_sent: bool,
event_loop_task: Task<Result<(), anyhow::Error>>,
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::<Event>();
+ 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();
@@ -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 {
@@ -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
@@ -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();
@@ -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);
});
@@ -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);
});
@@ -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;
@@ -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"] }
@@ -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,
) {
@@ -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<Arc<IconTheme>> = LazyLock::new(|| {
Arc::new(IconTheme {
@@ -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<dyn AssetSource>) -> Self {
let registry = Self {
@@ -118,28 +119,21 @@ impl ThemeRegistry {
self.state.write().extensions_loaded = true;
}
- fn insert_theme_families(&self, families: impl IntoIterator<Item = ThemeFamily>) {
+ /// Inserts the given theme families into the registry.
+ pub fn insert_theme_families(&self, families: impl IntoIterator<Item = ThemeFamily>) {
for family in families.into_iter() {
self.insert_themes(family.themes);
}
}
- fn insert_themes(&self, themes: impl IntoIterator<Item = Theme>) {
+ /// Inserts the given themes into the registry.
+ pub fn insert_themes(&self, themes: impl IntoIterator<Item = Theme>) {
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<Item = ThemeFamilyContent>) {
- 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<dyn Fs>) -> 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<dyn Fs>) -> 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<Arc<IconTheme>, 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<dyn Fs>,
) -> 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())
@@ -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<ThemeContent>,
-}
-
-/// 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<Hsla> {
+/// Parses a color string into an [`Hsla`] value.
+pub fn try_parse_color(color: &str) -> anyhow::Result<Hsla> {
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);
@@ -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::<Vec<_>>();
-
- if !colors.is_empty() {
- self.0 = Arc::from(colors);
- }
- }
}
@@ -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(),
- });
- }
- }
- }
}
@@ -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<WindowAppearance> for Appearance {
}
}
-impl From<Appearance> 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<dyn AssetSource>),
}
-/// 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<dyn AssetSource>, false),
- LoadThemes::All(assets) => (assets, true),
+ let assets = match themes_to_load {
+ LoadThemes::JustBase => Box::new(()) as Box<dyn AssetSource>,
+ 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::<SettingsStore>(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>() =
+ GlobalSystemAppearance(SystemAppearance(cx.window_appearance().into()));
+ }
+
+ /// Returns the global [`SystemAppearance`].
+ pub fn global(cx: &App) -> Self {
+ cx.global::<GlobalSystemAppearance>().0
+ }
+
+ /// Returns a mutable reference to the global [`SystemAppearance`].
+ pub fn global_mut(cx: &mut App) -> &mut Self {
+ cx.global_mut::<GlobalSystemAppearance>()
+ }
+}
+
/// 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<dyn Fs>) -> Result<ThemeFamilyContent> {
- 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<dyn Fs>,
-) -> Result<IconThemeFamilyContent> {
- 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<IconThemeFamilyContent> {
+ 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<Theme>,
icon_theme: Arc<IconTheme>,
@@ -446,72 +276,27 @@ pub struct GlobalTheme {
impl Global for GlobalTheme {}
impl GlobalTheme {
- fn configured_theme(cx: &mut App) -> Arc<Theme> {
- 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<Theme>, icon_theme: Arc<IconTheme>) -> 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<Theme>) {
cx.update_global::<Self, _>(|this, _| this.theme = theme);
- cx.refresh_windows();
- }
-
- fn configured_icon_theme(cx: &mut App) -> Arc<IconTheme> {
- 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<IconTheme>) {
cx.update_global::<Self, _>(|this, _| this.icon_theme = icon_theme);
- cx.refresh_windows();
}
- /// the active theme
+ /// Returns the active theme.
pub fn theme(cx: &App) -> &Arc<Theme> {
&cx.global::<Self>().theme
}
- /// the active icon theme
+ /// Returns the active icon theme.
pub fn icon_theme(cx: &App) -> &Arc<IconTheme> {
&cx.global::<Self>().icon_theme
}
@@ -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<dyn ThemeSettingsProvider>);
+
+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<dyn ThemeSettingsProvider>, 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::<GlobalThemeSettingsProvider>().0
+}
@@ -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<String> for UiDensity {
+ fn from(s: String) -> Self {
+ match s.as_str() {
+ "compact" => Self::Compact,
+ "default" => Self::Default,
+ "comfortable" => Self::Comfortable,
+ _ => Self::default(),
+ }
+ }
+}
+
+impl From<UiDensity> for String {
+ fn from(val: UiDensity) -> Self {
+ match val {
+ UiDensity::Compact => "compact".to_string(),
+ UiDensity::Default => "default".to_string(),
+ UiDensity::Comfortable => "comfortable".to_string(),
+ }
+ }
+}
@@ -17,3 +17,4 @@ extension.workspace = true
fs.workspace = true
gpui.workspace = true
theme.workspace = true
+theme_settings.workspace = true
@@ -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<ExtensionHostProxy>,
@@ -30,7 +31,8 @@ impl ExtensionThemeProxy for ThemeRegistryProxy {
fn list_theme_names(&self, theme_path: PathBuf, fs: Arc<dyn Fs>) -> Task<Result<Vec<String>>> {
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<dyn Fs>) -> Task<Result<()>> {
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<dyn Fs>,
) -> Task<Result<Vec<String>>> {
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<Result<()>> {
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)
}
}
@@ -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"
@@ -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,
};
@@ -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
@@ -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
@@ -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
@@ -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"] }
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -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<ThemeContent>,
+}
+
+/// 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<Hsla> {
+ 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)
+}
@@ -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<String> for UiDensity {
- fn from(s: String) -> Self {
- match s.as_str() {
- "compact" => Self::Compact,
- "default" => Self::Default,
- "comfortable" => Self::Comfortable,
- _ => Self::default(),
- }
- }
-}
-
-impl From<UiDensity> 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<settings::UiDensity> 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>() =
- GlobalSystemAppearance(SystemAppearance(cx.window_appearance().into()));
- }
-
- /// Returns the global [`SystemAppearance`].
- pub fn global(cx: &App) -> Self {
- cx.global::<GlobalSystemAppearance>().0
- }
-
- /// Returns a mutable reference to the global [`SystemAppearance`].
- pub fn global_mut(cx: &mut App) -> &mut Self {
- cx.global_mut::<GlobalSystemAppearance>()
- }
-}
-
#[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<Theme>) -> Arc<Theme> {
- // 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),
}
}
@@ -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::<SettingsStore>(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<Theme> {
+ 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<theme::IconTheme> {
+ 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<ThemeFamilyContent> {
+ 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> = 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::<Vec<_>>();
+
+ if !colors.is_empty() {
+ accent_colors.0 = Arc::from(colors);
+ }
+}
@@ -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
@@ -18,6 +18,13 @@ pub enum AgentThreadStatus {
Error,
}
+#[derive(Clone)]
+pub struct ThreadItemWorktreeInfo {
+ pub name: SharedString,
+ pub full_path: SharedString,
+ pub highlight_positions: Vec<usize>,
+}
+
#[derive(IntoElement, RegisterComponent)]
pub struct ThreadItem {
id: ElementId,
@@ -37,9 +44,7 @@ pub struct ThreadItem {
hovered: bool,
added: Option<usize>,
removed: Option<usize>,
- worktree: Option<SharedString>,
- worktree_full_path: Option<SharedString>,
- worktree_highlight_positions: Vec<usize>,
+ worktrees: Vec<ThreadItemWorktreeInfo>,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
action_slot: Option<AnyElement>,
@@ -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<SharedString>) -> Self {
- self.worktree = Some(worktree.into());
- self
- }
-
- pub fn worktree_full_path(mut self, worktree_full_path: impl Into<SharedString>) -> Self {
- self.worktree_full_path = Some(worktree_full_path.into());
- self
- }
-
- pub fn worktree_highlight_positions(mut self, positions: Vec<usize>) -> Self {
- self.worktree_highlight_positions = positions;
+ pub fn worktrees(mut self, worktrees: Vec<ThreadItemWorktreeInfo>) -> 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::<Vec<_>>()
+ .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<SharedString> = Vec::new();
+ let mut worktree_chips: Vec<AnyElement> = 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(),
),
@@ -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<Self>) -> 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;
@@ -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);
}
@@ -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<SharedString>,
+ highlight_ranges: Vec<Range<usize>>,
+ ) -> 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()
}
@@ -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)
}
@@ -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())
@@ -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;
@@ -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<App>,
{
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(
@@ -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)
}
@@ -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)
@@ -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::<String>() + chars.as_str(),
+ }
+}
@@ -65,7 +65,7 @@ pub fn derive_spacing(input: TokenStream) -> TokenStream {
DynamicSpacingValue::Single(n) => {
let n = n.base10_parse::<f32>().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::<f32>().unwrap();
let c = c.base10_parse::<f32>().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))
}
}
@@ -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
@@ -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;
@@ -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::<String>() + chars.as_str(),
- }
-}
-
fn emoji_regex() -> &'static Regex {
static EMOJI_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new("(\\p{Emoji}|\u{200D})").unwrap());
@@ -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
@@ -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;
@@ -731,10 +731,10 @@ impl Vim {
.collect::<Vec<_>>();
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::<Point>(&editor.display_snapshot(cx));
let snapshot = editor.buffer().read(cx).snapshot(cx);
let selection_end_rows: BTreeSet<u32> = 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::<Vec<_>>();
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);
@@ -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,
@@ -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();
@@ -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());
@@ -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
@@ -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,
@@ -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
@@ -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<SharedString>,
+ full_path: Option<SharedString>,
+}
+
+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<Self>) -> 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<crate::ToolbarItemEvent> 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<Self>,
+ ) {
+ 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();
+ }
+}
@@ -776,17 +776,9 @@ impl Dock {
}
}
- pub fn panel_size(&self, panel: &dyn PanelHandle, window: &Window, cx: &App) -> Option<Pixels> {
- 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<Pixels> {
+ pub fn active_panel_size(&self) -> Option<PanelSizeState> {
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<Self>) -> impl IntoElement {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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
@@ -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()]);
});
@@ -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};
@@ -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);
});
}
@@ -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::<TestItem>(cx);
});
let fs = FakeFs::new(cx.executor());
@@ -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<AppState>, 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<AppSession>,
}
-struct GlobalAppState(Weak<AppState>);
+struct GlobalAppState(Arc<AppState>);
impl Global for GlobalAppState {}
@@ -1108,14 +1102,14 @@ struct Follower {
impl AppState {
#[track_caller]
- pub fn global(cx: &App) -> Weak<Self> {
+ pub fn global(cx: &App) -> Arc<Self> {
cx.global::<GlobalAppState>().0.clone()
}
- pub fn try_global(cx: &App) -> Option<Weak<Self>> {
+ pub fn try_global(cx: &App) -> Option<Arc<Self>> {
cx.try_global::<GlobalAppState>()
.map(|state| state.0.clone())
}
- pub fn set_global(state: Weak<AppState>, cx: &mut App) {
+ pub fn set_global(state: Arc<AppState>, 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<Pixels> {
- if position.axis() != Axis::Horizontal {
- return None;
- }
+ fn dock_size(&self, dock: &Dock, window: &Window, cx: &App) -> Option<Pixels> {
+ 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<Entity<Workspace>> {
@@ -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<Self>,
) {
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<Self>) {
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::<Vec<_>>();
- 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);
});
}
@@ -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(),
@@ -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"
);
@@ -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
@@ -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<dyn fs::Fs>, 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<dyn fs::Fs>, 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<dyn fs::Fs>, languages: Arc<LanguageRegistry>, 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<dyn fs::Fs>, languages: Arc<LanguageRegistry>, cx: &m
.detach();
}
-#[cfg(not(debug_assertions))]
-fn watch_languages(_fs: Arc<dyn fs::Fs>, _languages: Arc<LanguageRegistry>, _cx: &mut App) {}
-
fn dump_all_gpui_actions() {
#[derive(Debug, serde::Serialize)]
struct ActionDef {
@@ -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<AppState> {
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<AppState> {
build_window_options: |_, _| Default::default(),
session,
});
- AppState::set_global(Arc::downgrade(&app_state), cx);
+ AppState::set_global(app_state.clone(), cx);
app_state
}
@@ -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::<MultiWorkspace>() 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<dyn Fs>, 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<dyn Fs>, 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);
@@ -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};
@@ -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::*,
};
@@ -51,7 +51,7 @@ pub fn init_visual_test(cx: &mut VisualTestAppContext) -> Arc<AppState> {
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);
@@ -53,6 +53,14 @@ This will output a code element like: `<code>Cmd + , | Ctrl + ,</code>`. 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}`.
@@ -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.
-<img width="1921" height="1080" alt="Screenshot for the Pane and Dock features" src="https://github.com/user-attachments/assets/2cb1170e-2850-450d-89bb-73622b5d07b2" />
+<img width="1921" height="auto" alt="Screenshot for the Pane and Dock features" src="https://github.com/user-attachments/assets/2cb1170e-2850-450d-89bb-73622b5d07b2" />
- `Project`: One or more `Worktree`s
- `Worktree`: Represents either local or remote files.
-<img width="552" height="1118" alt="Screenshot for the Worktree feature" src="https://github.com/user-attachments/assets/da5c58e4-b02e-4038-9736-27e3509fdbfa" />
+<img width="552" height="auto" alt="Screenshot for the Worktree feature" src="https://github.com/user-attachments/assets/da5c58e4-b02e-4038-9736-27e3509fdbfa" />
- [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.
-<img width="800" height="886" alt="Screenshot for the MultiBuffer feature" src="https://github.com/user-attachments/assets/d59dcecd-8ab6-4172-8fb6-b1fc3c3eaf9d" />
+<img width="800" height="auto" alt="Screenshot for the MultiBuffer feature" src="https://github.com/user-attachments/assets/d59dcecd-8ab6-4172-8fb6-b1fc3c3eaf9d" />
## Editor
@@ -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**
@@ -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.
-<img width="851" height="613" alt="image" src="https://github.com/user-attachments/assets/cbef2b51-0442-4ee9-bc5c-95f6ccf9be2c" />
+<img width="851" height="auto" alt="image" src="https://github.com/user-attachments/assets/cbef2b51-0442-4ee9-bc5c-95f6ccf9be2c" />
# 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.
-<img width="392" height="287" alt="image" src="https://github.com/user-attachments/assets/b6f06fc3-6b25-41c7-ade9-558cc93d6033" />
+
+<img width="392" height="auto" alt="image" src="https://github.com/user-attachments/assets/b6f06fc3-6b25-41c7-ade9-558cc93d6033" />
To find functions that take a long time follow this image:
-<img width="888" height="1159" alt="image" src="https://github.com/user-attachments/assets/77087617-f53a-4331-863d-e59f8a5b6f0b" />
+
+<img width="888" height="auto" alt="image" src="https://github.com/user-attachments/assets/77087617-f53a-4331-863d-e59f8a5b6f0b" />
# Task/Async profiling
@@ -42,7 +42,11 @@ pub fn run_perf(
}
fn install_hyperfine() -> Step<Use> {
- named::uses("taiki-e", "install-action", "hyperfine")
+ named::uses(
+ "taiki-e",
+ "install-action",
+ "b4f2d5cb8597b15997c8ede873eb6185efc5f0ad", // hyperfine
+ )
}
fn compare_runs(head: &WorkflowInput, base: &WorkflowInput) -> Step<Run> {
@@ -145,7 +145,12 @@ fn create_version_label(
}
fn create_version_tag(tag: &StepOutput, generated_token: StepOutput) -> Step<Use> {
- 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<Use> {
- named::uses("actions", "github-script", "v7")
+ named::uses(
+ "actions",
+ "github-script",
+ "f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7
+ )
.add_with(("github-token", generated_token.to_string()))
.add_with((
"script",
@@ -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<Use>, StepOutput) {
- let step = named::uses("actions", "github-script", "v7")
+ let step = named::uses("actions", "github-script", "f28e40c7f34bde8b3046d885e986cb6290c5673b")
.id("list-repos")
.add_with((
"script",
@@ -177,7 +177,11 @@ pub fn cargo_fmt() -> Step<Run> {
}
pub fn cargo_install_nextest() -> Step<Use> {
- named::uses("taiki-e", "install-action", "nextest")
+ named::uses(
+ "taiki-e",
+ "install-action",
+ "921e2c9f7148d7ba14cd819f417db338f63e733c", // nextest
+ )
}
pub fn setup_cargo_config(platform: Platform) -> Step<Run> {
@@ -230,9 +234,13 @@ pub fn install_rustup_target(target: &str) -> Step<Run> {
}
pub fn cache_rust_dependencies_namespace() -> Step<Use> {
- 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<Run> {
@@ -259,14 +267,24 @@ pub fn show_sccache_stats(platform: Platform) -> Step<Run> {
}
pub fn cache_nix_dependencies_namespace() -> Step<Use> {
- 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<Use> {
// 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<Run> {