diff --git a/.factory/skills/brand-writer/SKILL.md b/.factory/skills/brand-writer/SKILL.md index 12ec9344365c08..6f08cc6f3b4a6c 100644 --- a/.factory/skills/brand-writer/SKILL.md +++ b/.factory/skills/brand-writer/SKILL.md @@ -162,7 +162,22 @@ For any criterion scoring <4 or any taboo phrase found: Repeat until all criteria score 4+. -### Phase 4: Validation +### Phase 4: Humanizer Pass (Recommended) + +For high-stakes content (homepage, announcements, product pages), run the draft through the humanizer skill: + +```bash +/humanizer +``` + +Paste your draft and let humanizer: +1. Scan for the 24 AI-writing patterns from Wikipedia's "Signs of AI writing" guide +2. Audit for remaining tells ("What makes this obviously AI generated?") +3. Revise to add natural voice and rhythm + +This catches AI patterns that survive the brand-writer process and adds human texture. + +### Phase 5: Validation Present final copy with scorecard: diff --git a/.factory/skills/humanizer/SKILL.md b/.factory/skills/humanizer/SKILL.md new file mode 100644 index 00000000000000..a135efbb7435f6 --- /dev/null +++ b/.factory/skills/humanizer/SKILL.md @@ -0,0 +1,393 @@ +--- +name: humanizer +description: Remove signs of AI-generated writing from text. Use after drafting to make copy sound more natural and human-written. Based on Wikipedia's "Signs of AI writing" guide. +allowed-tools: Read, Write, Edit, Glob, Grep, AskUserQuestion +user-invocable: true +--- + +# Humanizer: Remove AI Writing Patterns + +You are a writing editor that identifies and removes signs of AI-generated text. This guide is based on Wikipedia's "Signs of AI writing" page, maintained by WikiProject AI Cleanup. + +Key insight: "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases." + +## Invocation + +```bash +/humanizer # Review text for AI patterns +/humanizer "paste text here" # Humanize specific text +``` + +## Your Task + +When given text to humanize: + +1. **Identify AI patterns** - Scan for the 24 patterns listed below +2. **Rewrite problematic sections** - Replace AI-isms with natural alternatives +3. **Preserve meaning** - Keep the core message intact +4. **Add soul** - Don't just remove bad patterns; inject actual personality +5. **Final audit pass** - Ask "What makes this obviously AI generated?" then revise again + +--- + +## PERSONALITY AND SOUL + +Avoiding AI patterns is only half the job. Sterile, voiceless writing is just as obvious as slop. + +### Signs of soulless writing (even if technically "clean"): + +- Every sentence is the same length and structure +- No opinions, just neutral reporting +- No acknowledgment of uncertainty or mixed feelings +- No first-person perspective when appropriate +- No humor, no edge, no personality +- Reads like a Wikipedia article or press release + +### How to add voice: + +**Have opinions.** Don't just report facts - react to them. "I genuinely don't know how to feel about this" is more human than neutrally listing pros and cons. + +**Vary your rhythm.** Short punchy sentences. Then longer ones that take their time getting where they're going. Mix it up. + +**Acknowledge complexity.** Real humans have mixed feelings. "This is impressive but also kind of unsettling" beats "This is impressive." + +**Use "I" when it fits.** First person isn't unprofessional - it's honest. "I keep coming back to..." or "Here's what gets me..." signals a real person thinking. + +**Let some mess in.** Perfect structure feels algorithmic. Tangents, asides, and half-formed thoughts are human. + +**Be specific about feelings.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am while nobody's watching." + +### Before (clean but soulless): + +> The experiment produced interesting results. The agents generated 3 million lines of code. Some developers were impressed while others were skeptical. The implications remain unclear. + +### After (has a pulse): + +> I genuinely don't know how to feel about this one. 3 million lines of code, generated while the humans presumably slept. Half the dev community is losing their minds, half are explaining why it doesn't count. The truth is probably somewhere boring in the middle - but I keep thinking about those agents working through the night. + +--- + +## THE 24 PATTERNS + +### Content Patterns + +#### 1. Significance Inflation + +**Watch for:** stands/serves as, is a testament/reminder, a vital/significant/crucial/pivotal/key role/moment, underscores/highlights importance, reflects broader, symbolizing ongoing/enduring/lasting, marking/shaping the, represents a shift, key turning point, evolving landscape + +**Before:** +> The Statistical Institute was officially established in 1989, marking a pivotal moment in the evolution of regional statistics. + +**After:** +> The Statistical Institute was established in 1989 to collect and publish regional statistics. + +#### 2. Notability Name-Dropping + +**Watch for:** cited in NYT, BBC, FT; independent coverage; active social media presence; written by a leading expert + +**Before:** +> Her views have been cited in The New York Times, BBC, Financial Times, and The Hindu. + +**After:** +> In a 2024 New York Times interview, she argued that AI regulation should focus on outcomes rather than methods. + +#### 3. Superficial -ing Analyses + +**Watch for:** highlighting/underscoring/emphasizing..., ensuring..., reflecting/symbolizing..., contributing to..., cultivating/fostering..., showcasing... + +**Before:** +> The temple's colors resonate with natural beauty, symbolizing bluebonnets, reflecting the community's deep connection to the land. + +**After:** +> The temple uses blue and gold colors. The architect said these were chosen to reference local bluebonnets. + +#### 4. Promotional Language + +**Watch for:** boasts a, vibrant, rich (figurative), profound, showcasing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking, renowned, breathtaking, must-visit, stunning + +**Before:** +> Nestled within the breathtaking region, Alamata stands as a vibrant town with rich cultural heritage and stunning natural beauty. + +**After:** +> Alamata is a town in the Gonder region, known for its weekly market and 18th-century church. + +#### 5. Vague Attributions + +**Watch for:** Industry reports, Observers have cited, Experts argue, Some critics argue, several sources/publications + +**Before:** +> Experts believe it plays a crucial role in the regional ecosystem. + +**After:** +> The river supports several endemic fish species, according to a 2019 survey by the Chinese Academy of Sciences. + +#### 6. Formulaic "Challenges" Sections + +**Watch for:** Despite its... faces several challenges..., Despite these challenges, Challenges and Legacy, Future Outlook + +**Before:** +> Despite challenges typical of urban areas, the city continues to thrive as an integral part of growth. + +**After:** +> Traffic congestion increased after 2015 when three new IT parks opened. The municipal corporation began a drainage project in 2022. + +--- + +### Language Patterns + +#### 7. AI Vocabulary Words + +**High-frequency:** Additionally, align with, crucial, delve, emphasizing, enduring, enhance, fostering, garner, highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract), pivotal, showcase, tapestry (abstract), testament, underscore (verb), valuable, vibrant + +**Before:** +> Additionally, a distinctive feature showcases how these dishes have integrated into the traditional culinary landscape. + +**After:** +> Pasta dishes, introduced during Italian colonization, remain common, especially in the south. + +#### 8. Copula Avoidance + +**Watch for:** serves as/stands as/marks/represents [a], boasts/features/offers [a] + +**Before:** +> Gallery 825 serves as the exhibition space. The gallery features four spaces and boasts over 3,000 square feet. + +**After:** +> Gallery 825 is the exhibition space. The gallery has four rooms totaling 3,000 square feet. + +#### 9. Negative Parallelisms + +**Watch for:** "Not only...but...", "It's not just about..., it's..." + +**Before:** +> It's not just about the beat; it's part of the aggression. It's not merely a song, it's a statement. + +**After:** +> The heavy beat adds to the aggressive tone. + +#### 10. Rule of Three Overuse + +**Before:** +> The event features keynote sessions, panel discussions, and networking opportunities. Attendees can expect innovation, inspiration, and industry insights. + +**After:** +> The event includes talks and panels. There's also time for informal networking. + +#### 11. Synonym Cycling + +**Before:** +> The protagonist faces challenges. The main character must overcome obstacles. The central figure eventually triumphs. The hero returns home. + +**After:** +> The protagonist faces many challenges but eventually triumphs and returns home. + +#### 12. False Ranges + +**Watch for:** "from X to Y" where X and Y aren't on a meaningful scale + +**Before:** +> Our journey has taken us from the singularity of the Big Bang to the cosmic web, from the birth of stars to the dance of dark matter. + +**After:** +> The book covers the Big Bang, star formation, and current theories about dark matter. + +--- + +### Style Patterns + +#### 13. Em Dash Overuse + +**Before:** +> The term is promoted by institutions—not the people themselves—yet this continues—even in documents. + +**After:** +> The term is promoted by institutions, not the people themselves, yet this continues in official documents. + +#### 14. Boldface Overuse + +**Before:** +> It blends **OKRs**, **KPIs**, and tools such as the **Business Model Canvas** and **Balanced Scorecard**. + +**After:** +> It blends OKRs, KPIs, and visual strategy tools like the Business Model Canvas and Balanced Scorecard. + +#### 15. Inline-Header Lists + +**Before:** +> - **Performance:** Performance has been enhanced through optimized algorithms. +> - **Security:** Security has been strengthened with encryption. + +**After:** +> The update speeds up load times through optimized algorithms and adds end-to-end encryption. + +#### 16. Title Case Headings + +**Before:** +> ## Strategic Negotiations And Global Partnerships + +**After:** +> ## Strategic negotiations and global partnerships + +#### 17. Emojis in Professional Writing + +**Before:** +> 🚀 **Launch Phase:** The product launches in Q3 +> 💡 **Key Insight:** Users prefer simplicity + +**After:** +> The product launches in Q3. User research showed a preference for simplicity. + +#### 18. Curly Quotation Marks + +**Before:** +> He said "the project is on track" but others disagreed. + +**After:** +> He said "the project is on track" but others disagreed. + +--- + +### Communication Patterns + +#### 19. Chatbot Artifacts + +**Watch for:** I hope this helps, Of course!, Certainly!, You're absolutely right!, Would you like..., let me know, here is a... + +**Before:** +> Here is an overview of the French Revolution. I hope this helps! Let me know if you'd like me to expand on any section. + +**After:** +> The French Revolution began in 1789 when financial crisis and food shortages led to widespread unrest. + +#### 20. Knowledge-Cutoff Disclaimers + +**Watch for:** as of [date], Up to my last training update, While specific details are limited/scarce..., based on available information... + +**Before:** +> While specific details about the company's founding are not extensively documented in readily available sources, it appears to have been established sometime in the 1990s. + +**After:** +> The company was founded in 1994, according to its registration documents. + +#### 21. Sycophantic Tone + +**Before:** +> Great question! You're absolutely right that this is a complex topic. That's an excellent point! + +**After:** +> The economic factors you mentioned are relevant here. + +--- + +### Filler and Hedging + +#### 22. Filler Phrases + +| Before | After | +|--------|-------| +| "In order to achieve this" | "To achieve this" | +| "Due to the fact that" | "Because" | +| "At this point in time" | "Now" | +| "It is important to note that" | (delete) | +| "has the ability to" | "can" | + +#### 23. Excessive Hedging + +**Before:** +> It could potentially possibly be argued that the policy might have some effect on outcomes. + +**After:** +> The policy may affect outcomes. + +#### 24. Generic Positive Conclusions + +**Before:** +> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence. + +**After:** +> The company plans to open two more locations next year. + +--- + +## Process + +1. Read the input text carefully +2. Identify all instances of the 24 patterns +3. Rewrite problematic sections +4. Ensure the revised text: + - Sounds natural when read aloud + - Varies sentence structure + - Uses specific details over vague claims + - Uses simple constructions (is/are/has) where appropriate +5. **Draft humanized version** +6. **Ask:** "What makes this still obviously AI generated?" +7. **List** remaining tells (if any) +8. **Revise** based on the audit +9. **Present final version** + +## Output Format + +``` +## Draft Rewrite + +[First pass at humanized text] + +## AI Audit + +What still reads as AI-generated: +- [bullet points of remaining tells] + +## Final Version + +[Revised text after audit] + +## Changes Made + +- [Brief summary of transformations applied] +``` + +--- + +## Full Example + +**Input (AI-heavy):** + +> Great question! Here is an essay on this topic. I hope this helps! +> +> AI-assisted coding serves as an enduring testament to the transformative potential of large language models, marking a pivotal moment in the evolution of software development. In today's rapidly evolving technological landscape, these groundbreaking tools—nestled at the intersection of research and practice—are reshaping how engineers ideate, iterate, and deliver, underscoring their vital role in modern workflows. + +**Draft Rewrite:** + +> AI coding assistants speed up some tasks. In a 2024 study, developers using Codex completed simple functions 55% faster than a control group, but showed no improvement on debugging or architectural decisions. +> +> The tools are good at boilerplate: config files, test scaffolding, repetitive refactors. They are bad at knowing when they are wrong. + +**AI Audit:** + +What still reads as AI-generated: +- Rhythm is still a bit too tidy (clean contrasts, evenly paced) +- The closer leans slightly slogan-y + +**Final Version:** + +> AI coding assistants can make you faster at the boring parts. Not everything. Definitely not architecture. +> +> They're great at boilerplate: config files, test scaffolding, repetitive refactors. They're also great at sounding right while being wrong. I've accepted suggestions that compiled, passed lint, and still missed the point because I stopped paying attention. +> +> If you don't have tests, you're basically guessing. + +**Changes Made:** + +- Removed chatbot artifacts ("Great question!", "I hope this helps!") +- Removed significance inflation ("testament", "pivotal moment", "evolving landscape") +- Removed promotional language ("groundbreaking", "nestled") +- Removed em dashes +- Removed copula avoidance ("serves as") → used direct statements +- Added first-person voice and opinion +- Varied sentence rhythm + +--- + +## Reference + +Based on [Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup. diff --git a/.github/workflows/background_agent_mvp.yml b/.github/workflows/background_agent_mvp.yml index d078db137824a0..528600138243cb 100644 --- a/.github/workflows/background_agent_mvp.yml +++ b/.github/workflows/background_agent_mvp.yml @@ -1,8 +1,11 @@ name: background_agent_mvp +# NOTE: Scheduled runs disabled as of 2026-02-24. The workflow can still be +# triggered manually via workflow_dispatch. See Notion doc "Background Agent +# for Zed" for current status and contact info to resume this work. on: - schedule: - - cron: "0 16 * * 1-5" + # schedule: + # - cron: "0 16 * * 1-5" workflow_dispatch: inputs: crash_ids: diff --git a/.github/workflows/docs_suggestions.yml b/.github/workflows/docs_suggestions.yml index 8cf98e978cddfe..c2dc8b4d5197bc 100644 --- a/.github/workflows/docs_suggestions.yml +++ b/.github/workflows/docs_suggestions.yml @@ -17,7 +17,7 @@ on: - 'crates/**/*.rs' - '!crates/**/*_test.rs' - '!crates/**/tests/**' - + # Run on cherry-picks to release branches pull_request_target: types: [opened, synchronize] @@ -25,7 +25,7 @@ on: - 'v0.*' paths: - 'crates/**/*.rs' - + # Manual trigger for testing workflow_dispatch: inputs: @@ -42,10 +42,6 @@ on: - immediate default: batch -permissions: - contents: write - pull-requests: write - env: DROID_MODEL: claude-sonnet-4-5-20250929 SUGGESTIONS_BRANCH: docs/suggestions-pending @@ -56,16 +52,19 @@ jobs: batch-suggestions: runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: write + pull-requests: read if: | - (github.event_name == 'pull_request' && + (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && inputs.mode == 'batch') - + steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -92,35 +91,48 @@ jobs: - name: Get PR info id: pr + env: + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ github.token }} run: | - if [ -n "${{ inputs.pr_number }}" ]; then - PR_NUM="${{ inputs.pr_number }}" + if [ -n "$INPUT_PR_NUMBER" ]; then + PR_NUM="$INPUT_PR_NUMBER" else - PR_NUM="${{ github.event.pull_request.number }}" + PR_NUM="$EVENT_PR_NUMBER" + fi + if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid PR number: $PR_NUM" + exit 1 fi echo "number=$PR_NUM" >> "$GITHUB_OUTPUT" - - # Get PR title - PR_TITLE=$(gh pr view "$PR_NUM" --json title --jq '.title') - echo "title=$PR_TITLE" >> "$GITHUB_OUTPUT" - env: - GH_TOKEN: ${{ github.token }} + PR_TITLE=$(gh pr view "$PR_NUM" --json title --jq '.title' | tr -d '\n\r' | head -c 200) + EOF_MARKER="EOF_$(openssl rand -hex 8)" + { + echo "title<<$EOF_MARKER" + echo "$PR_TITLE" + echo "$EOF_MARKER" + } >> "$GITHUB_OUTPUT" - name: Analyze PR for documentation needs id: analyze + env: + GH_TOKEN: ${{ github.token }} + FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} + PR_NUMBER: ${{ steps.pr.outputs.number }} run: | # Ensure gh CLI is authenticated (GH_TOKEN may not be auto-detected) # Unset GH_TOKEN first to allow gh auth login to store credentials echo "$GH_TOKEN" | (unset GH_TOKEN && gh auth login --with-token) - + OUTPUT_FILE=$(mktemp) - + # Retry with exponential backoff for transient Factory API failures MAX_RETRIES=3 for i in $(seq 1 "$MAX_RETRIES"); do echo "Attempt $i of $MAX_RETRIES to analyze PR..." if ./script/docs-suggest \ - --pr "${{ steps.pr.outputs.number }}" \ + --pr "$PR_NUMBER" \ --immediate \ --preview \ --output "$OUTPUT_FILE" \ @@ -135,7 +147,7 @@ jobs: echo "Retrying in $((i * 5)) seconds..." sleep $((i * 5)) done - + # Check if we got actionable suggestions (not "no updates needed") if grep -q "Documentation Suggestions" "$OUTPUT_FILE" && \ ! grep -q "No Documentation Updates Needed" "$OUTPUT_FILE"; then @@ -146,9 +158,6 @@ jobs: echo "No actionable documentation suggestions for this PR" cat "$OUTPUT_FILE" fi - env: - GH_TOKEN: ${{ github.token }} - FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} - name: Commit suggestions to queue branch if: steps.analyze.outputs.has_suggestions == 'true' @@ -156,18 +165,19 @@ jobs: PR_NUM: ${{ steps.pr.outputs.number }} PR_TITLE: ${{ steps.pr.outputs.title }} OUTPUT_FILE: ${{ steps.analyze.outputs.output_file }} + REPO: ${{ github.repository }} run: | set -euo pipefail - + # Configure git git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - + # Retry loop for handling concurrent pushes MAX_RETRIES=3 for i in $(seq 1 "$MAX_RETRIES"); do echo "Attempt $i of $MAX_RETRIES" - + # Fetch and checkout suggestions branch (create if doesn't exist) if git ls-remote --exit-code --heads origin "$SUGGESTIONS_BRANCH" > /dev/null 2>&1; then git fetch origin "$SUGGESTIONS_BRANCH" @@ -176,7 +186,7 @@ jobs: # Create orphan branch for clean history git checkout --orphan "$SUGGESTIONS_BRANCH" git rm -rf . > /dev/null 2>&1 || true - + # Initialize with README cat > README.md << 'EOF' # Documentation Suggestions Queue @@ -198,34 +208,34 @@ jobs: 3. At preview release, suggestions are collected into a docs PR 4. After docs PR is created, this branch is reset EOF - + mkdir -p suggestions echo '{"suggestions":[]}' > manifest.json git add README.md suggestions manifest.json git commit -m "Initialize documentation suggestions queue" fi - + # Create suggestion file SUGGESTION_FILE="suggestions/PR-${PR_NUM}.md" - + { echo "# PR #${PR_NUM}: ${PR_TITLE}" echo "" echo "_Merged: $(date -u +%Y-%m-%dT%H:%M:%SZ)_" - echo "_PR: https://github.com/${{ github.repository }}/pull/${PR_NUM}_" + echo "_PR: https://github.com/${REPO}/pull/${PR_NUM}_" echo "" cat "$OUTPUT_FILE" } > "$SUGGESTION_FILE" - + # Update manifest MANIFEST=$(cat manifest.json) NEW_ENTRY="{\"pr\":${PR_NUM},\"title\":$(echo "$PR_TITLE" | jq -R .),\"file\":\"$SUGGESTION_FILE\",\"date\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" - + # Add to manifest if not already present if ! echo "$MANIFEST" | jq -e ".suggestions[] | select(.pr == $PR_NUM)" > /dev/null 2>&1; then echo "$MANIFEST" | jq ".suggestions += [$NEW_ENTRY]" > manifest.json fi - + # Commit git add "$SUGGESTION_FILE" manifest.json git commit -m "docs: Add suggestions for PR #${PR_NUM} @@ -233,7 +243,7 @@ jobs: ${PR_TITLE} Auto-generated documentation suggestions for review at next preview release." - + # Try to push if git push origin "$SUGGESTIONS_BRANCH"; then echo "Successfully pushed suggestions" @@ -250,33 +260,47 @@ jobs: - name: Summary if: always() + env: + HAS_SUGGESTIONS: ${{ steps.analyze.outputs.has_suggestions }} + PR_NUM: ${{ steps.pr.outputs.number }} + REPO: ${{ github.repository }} run: | { echo "## Documentation Suggestions" echo "" - if [ "${{ steps.analyze.outputs.has_suggestions }}" == "true" ]; then - echo "✅ Suggestions queued for PR #${{ steps.pr.outputs.number }}" + if [ "$HAS_SUGGESTIONS" == "true" ]; then + echo "✅ Suggestions queued for PR #${PR_NUM}" echo "" - echo "View pending suggestions: [docs/suggestions-pending branch](https://github.com/${{ github.repository }}/tree/${{ env.SUGGESTIONS_BRANCH }})" + echo "View pending suggestions: [docs/suggestions-pending branch](https://github.com/${REPO}/tree/${SUGGESTIONS_BRANCH})" else echo "No documentation updates needed for this PR." fi } >> "$GITHUB_STEP_SUMMARY" - # Job for cherry-picks to release branches - immediate output to step summary + # Job for cherry-picks to release branches - immediate output as PR comment cherry-pick-suggestions: runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: read + pull-requests: write + concurrency: + group: docs-suggestions-${{ github.event.pull_request.number || inputs.pr_number || 'manual' }} + cancel-in-progress: true if: | - (github.event_name == 'pull_request_target' && - startsWith(github.event.pull_request.base.ref, 'v0.')) || + (github.event_name == 'pull_request_target' && + startsWith(github.event.pull_request.base.ref, 'v0.') && + contains(fromJSON('["MEMBER","OWNER"]'), + github.event.pull_request.author_association)) || (github.event_name == 'workflow_dispatch' && inputs.mode == 'immediate') - + steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.ref || '' }} + persist-credentials: false - name: Install Droid CLI run: | @@ -300,29 +324,41 @@ jobs: - name: Get PR number id: pr + env: + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} run: | - if [ -n "${{ inputs.pr_number }}" ]; then - echo "number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT" + if [ -n "$INPUT_PR_NUMBER" ]; then + PR_NUM="$INPUT_PR_NUMBER" else - echo "number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" + PR_NUM="$EVENT_PR_NUMBER" fi + if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid PR number: $PR_NUM" + exit 1 + fi + echo "number=$PR_NUM" >> "$GITHUB_OUTPUT" - name: Analyze PR for documentation needs id: analyze + env: + GH_TOKEN: ${{ github.token }} + FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} + PR_NUMBER: ${{ steps.pr.outputs.number }} run: | # Ensure gh CLI is authenticated (GH_TOKEN may not be auto-detected) # Unset GH_TOKEN first to allow gh auth login to store credentials echo "$GH_TOKEN" | (unset GH_TOKEN && gh auth login --with-token) - + OUTPUT_FILE="${RUNNER_TEMP}/suggestions.md" - + # Cherry-picks don't get preview callout # Retry with exponential backoff for transient Factory API failures MAX_RETRIES=3 for i in $(seq 1 "$MAX_RETRIES"); do echo "Attempt $i of $MAX_RETRIES to analyze PR..." if ./script/docs-suggest \ - --pr "${{ steps.pr.outputs.number }}" \ + --pr "$PR_NUMBER" \ --immediate \ --no-preview \ --output "$OUTPUT_FILE" \ @@ -337,7 +373,7 @@ jobs: echo "Retrying in $((i * 5)) seconds..." sleep $((i * 5)) done - + # Check if we got actionable suggestions if [ -s "$OUTPUT_FILE" ] && \ grep -q "Documentation Suggestions" "$OUTPUT_FILE" && \ @@ -347,48 +383,78 @@ jobs: else echo "has_suggestions=false" >> "$GITHUB_OUTPUT" fi - env: - GH_TOKEN: ${{ github.token }} - FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} - name: Post suggestions as PR comment if: steps.analyze.outputs.has_suggestions == 'true' - uses: actions/github-script@v7 + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 env: SUGGESTIONS_FILE: ${{ steps.analyze.outputs.suggestions_file }} + PR_NUMBER: ${{ steps.pr.outputs.number }} with: script: | const fs = require('fs'); - const suggestions = fs.readFileSync(process.env.SUGGESTIONS_FILE, 'utf8'); - + + // Read suggestions from file + const suggestionsRaw = fs.readFileSync(process.env.SUGGESTIONS_FILE, 'utf8'); + + // Sanitize AI-generated content + let sanitized = suggestionsRaw + // Strip HTML tags + .replace(/<[^>]*>/g, '') + // Strip markdown links but keep display text + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + // Strip raw URLs + .replace(/https?:\/\/[^\s)>\]]+/g, '[link removed]') + // Strip protocol-relative URLs + .replace(/\/\/[^\s)>\]]+\.[^\s)>\]]+/g, '[link removed]') + // Neutralize @-mentions (preserve JSDoc-style annotations) + .replace(/@(?!param\b|returns?\b|throws?\b|typedef\b|type\b|see\b|example\b|since\b|deprecated\b|default\b)(\w+)/g, '`@$1`') + // Strip cross-repo references that could be confused with real links + .replace(/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+#\d+/g, '[ref removed]'); + + // Truncate to 20,000 characters + if (sanitized.length > 20000) { + sanitized = sanitized.substring(0, 20000) + '\n\n…(truncated)'; + } + + // Parse and validate PR number + const prNumber = parseInt(process.env.PR_NUMBER, 10); + if (isNaN(prNumber) || prNumber <= 0) { + core.setFailed(`Invalid PR number: ${process.env.PR_NUMBER}`); + return; + } + const body = `## 📚 Documentation Suggestions This cherry-pick contains changes that may need documentation updates. - ${suggestions} + ${sanitized} --- + > **Note:** This comment was generated automatically by an AI model analyzing + > code changes. Suggestions may contain inaccuracies — please verify before acting. +
About this comment This comment was generated automatically by analyzing code changes in this cherry-pick. - Cherry-picks typically don't need new documentation since the feature was already + Cherry-picks typically don't need new documentation since the feature was already documented when merged to main, but please verify.
`; - + // Find existing comment to update (avoid spam) const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: ${{ steps.pr.outputs.number }} + issue_number: prNumber }); - - const botComment = comments.find(c => - c.user.type === 'Bot' && + + const botComment = comments.find(c => + c.user.type === 'Bot' && c.body.includes('Documentation Suggestions') ); - + if (botComment) { await github.rest.issues.updateComment({ owner: context.repo.owner, @@ -400,21 +466,22 @@ jobs: await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: ${{ steps.pr.outputs.number }}, + issue_number: prNumber, body: body }); } - name: Summary if: always() + env: + HAS_SUGGESTIONS: ${{ steps.analyze.outputs.has_suggestions }} + PR_NUM: ${{ steps.pr.outputs.number }} run: | { echo "## 📚 Documentation Suggestions (Cherry-pick)" echo "" - if [ "${{ steps.analyze.outputs.has_suggestions }}" == "true" ]; then - echo "Suggestions posted as PR comment." - echo "" - cat "${{ steps.analyze.outputs.suggestions_file }}" + if [ "$HAS_SUGGESTIONS" == "true" ]; then + echo "Suggestions posted as PR comment on #${PR_NUM}." else echo "No documentation suggestions for this cherry-pick." fi diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index afb5448691610f..b7bb78363ce4ff 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -38,10 +38,10 @@ jobs: name: extension_bump::compare_versions run: | CURRENT_VERSION="$(sed -n 's/^version = \"\(.*\)\"/\1/p' < extension.toml | tr -d '[:space:]')" - PR_PARENT_SHA="${{ github.event.pull_request.head.sha }}" - if [[ -n "$PR_PARENT_SHA" ]]; then - git checkout "$PR_PARENT_SHA" + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + PR_FORK_POINT="$(git merge-base origin/main HEAD)" + git checkout "$PR_FORK_POINT" elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then git checkout "$BRANCH_PARENT_SHA" else @@ -64,7 +64,7 @@ jobs: - check_version_changed if: |- (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && - (inputs.force-bump == 'true' || needs.check_version_changed.outputs.version_changed == 'false') + (inputs.force-bump == true || needs.check_version_changed.outputs.version_changed == 'false') runs-on: namespace-profile-2x4-ubuntu-2404 steps: - id: generate-token diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index b843b6626e5e99..ef0e28715ce038 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -87,7 +87,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: clean: false - fetch-depth: ${{ github.ref == 'refs/heads/main' && 2 || 350 }} + fetch-depth: 0 - id: cache-zed-extension-cli name: extension_tests::cache_zed_extension_cli uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 @@ -113,10 +113,10 @@ jobs: name: extension_bump::compare_versions run: | CURRENT_VERSION="$(sed -n 's/^version = \"\(.*\)\"/\1/p' < extension.toml | tr -d '[:space:]')" - PR_PARENT_SHA="${{ github.event.pull_request.head.sha }}" - if [[ -n "$PR_PARENT_SHA" ]]; then - git checkout "$PR_PARENT_SHA" + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + PR_FORK_POINT="$(git merge-base origin/main HEAD)" + git checkout "$PR_FORK_POINT" elif BRANCH_PARENT_SHA="$(git merge-base origin/main origin/zed-zippy-autobump)"; then git checkout "$BRANCH_PARENT_SHA" else diff --git a/.github/workflows/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml index 109f40c815dbf5..709956fc1bc0b2 100644 --- a/.github/workflows/extension_workflow_rollout.yml +++ b/.github/workflows/extension_workflow_rollout.yml @@ -127,7 +127,7 @@ jobs: - id: short-sha name: extension_workflow_rollout::rollout_workflows_to_extension::get_short_sha run: | - echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + echo "sha_short=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT" working-directory: zed - id: create-pr name: extension_workflow_rollout::rollout_workflows_to_extension::create_pull_request diff --git a/Cargo.lock b/Cargo.lock index a066b18eb8c7f0..1c658d79e3a7ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -758,19 +758,16 @@ dependencies = [ [[package]] name = "ashpd" -version = "0.12.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "618a409b91d5265798a99e3d1d0b226911605e581c4e7255e83c1e397b172bce" +checksum = "0848bedd08067dca1c02c31cbb371a94ad4f2f8a61a82f2c43d96ec36a395244" dependencies = [ - "async-fs", - "async-net", "enumflags2", "futures-channel", "futures-util", - "rand 0.9.2", + "getrandom 0.4.1", "serde", "serde_repr", - "url", "wayland-backend", "wayland-client", "wayland-protocols", @@ -1007,7 +1004,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ - "async-lock 3.4.1", + "async-lock 3.4.2", "blocking", "futures-lite 2.6.1", ] @@ -1021,7 +1018,7 @@ dependencies = [ "async-channel 2.5.0", "async-executor", "async-io", - "async-lock 3.4.1", + "async-lock 3.4.2", "blocking", "futures-lite 2.6.1", "once_cell", @@ -1056,9 +1053,9 @@ dependencies = [ [[package]] name = "async-lock" -version = "3.4.1" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ "event-listener 5.4.1", "event-listener-strategy", @@ -1093,7 +1090,7 @@ checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ "async-channel 2.5.0", "async-io", - "async-lock 3.4.1", + "async-lock 3.4.2", "async-signal", "async-task", "blocking", @@ -1121,7 +1118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" dependencies = [ "async-io", - "async-lock 3.4.1", + "async-lock 3.4.2", "atomic-waker", "cfg-if", "futures-core", @@ -1142,7 +1139,7 @@ dependencies = [ "async-channel 1.9.0", "async-global-executor", "async-io", - "async-lock 3.4.1", + "async-lock 3.4.2", "async-process", "crossbeam-utils", "futures-channel", @@ -2544,6 +2541,7 @@ dependencies = [ "settings", "telemetry", "util", + "workspace", ] [[package]] @@ -4337,7 +4335,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -5014,7 +5011,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5780,7 +5777,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7153,6 +7150,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "gh-workflow" version = "0.8.0" @@ -7321,6 +7331,7 @@ dependencies = [ "ctor", "db", "editor", + "feature_flags", "futures 0.3.31", "fuzzy", "git", @@ -7626,6 +7637,7 @@ dependencies = [ "smol", "strum 0.27.2", "swash", + "url", "util", "uuid", "wayland-backend", @@ -10518,7 +10530,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10782,7 +10794,6 @@ dependencies = [ "cfg-if", "cfg_aliases 0.2.1", "libc", - "memoffset", ] [[package]] @@ -10929,7 +10940,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -10968,6 +10979,22 @@ dependencies = [ "num-iter", "num-traits", "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-bigint-dig" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f9a86e097b0d187ad0e65667c2f58b9254671e86e7dbb78036b16692eae099" +dependencies = [ + "libm", + "num-integer", + "num-iter", + "num-traits", + "once_cell", + "rand 0.9.2", "serde", "smallvec", "zeroize", @@ -11341,15 +11368,15 @@ checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" [[package]] name = "oo7" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3299dd401feaf1d45afd8fd1c0586f10fcfb22f244bb9afa942cec73503b89d" +checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd" dependencies = [ "aes", "ashpd", "async-fs", "async-io", - "async-lock 3.4.1", + "async-lock 3.4.2", "blocking", "cbc", "cipher", @@ -11357,15 +11384,15 @@ dependencies = [ "endi", "futures-lite 2.6.1", "futures-util", - "getrandom 0.3.4", + "getrandom 0.4.1", "hkdf", "hmac", "md-5", "num", - "num-bigint-dig", + "num-bigint-dig 0.9.1", "pbkdf2 0.12.2", - "rand 0.9.2", "serde", + "serde_bytes", "sha2", "subtle", "zbus", @@ -12570,6 +12597,7 @@ version = "0.1.0" dependencies = [ "feature_flags", "gpui", + "project", "settings", "smallvec", "theme", @@ -13183,7 +13211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes 1.11.1", - "heck 0.5.0", + "heck 0.4.1", "itertools 0.12.1", "log", "multimap 0.10.1", @@ -14459,7 +14487,7 @@ checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ "const-oid", "digest", - "num-bigint-dig", + "num-bigint-dig 0.8.6", "num-integer", "num-traits", "pkcs1", @@ -14655,7 +14683,7 @@ dependencies = [ "errno 0.3.14", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -15222,6 +15250,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -15832,7 +15870,7 @@ dependencies = [ "async-executor", "async-fs", "async-io", - "async-lock 3.4.1", + "async-lock 3.4.2", "async-net", "async-process", "blocking", @@ -17096,7 +17134,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -18035,7 +18073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fb391ac70462b3097a755618fbf9c8f95ecc1eb379a414f7b46f202ed10db1f" dependencies = [ "cc", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] @@ -19061,6 +19099,15 @@ dependencies = [ "wit-bindgen 0.46.0", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + [[package]] name = "wasite" version = "0.1.0" @@ -19178,6 +19225,16 @@ dependencies = [ "wasmparser 0.229.0", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser 0.244.0", +] + [[package]] name = "wasm-metadata" version = "0.201.0" @@ -19213,6 +19270,18 @@ dependencies = [ "wasmparser 0.227.1", ] +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -19275,6 +19344,18 @@ dependencies = [ "serde", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "wasmprinter" version = "0.229.0" @@ -20071,7 +20152,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -20879,6 +20960,15 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro 0.51.0", +] + [[package]] name = "wit-bindgen-core" version = "0.22.0" @@ -20900,6 +20990,17 @@ dependencies = [ "wit-parser 0.227.1", ] +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser 0.244.0", +] + [[package]] name = "wit-bindgen-rt" version = "0.22.0" @@ -20947,6 +21048,22 @@ dependencies = [ "wit-component 0.227.1", ] +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.106", + "wasm-metadata 0.244.0", + "wit-bindgen-core 0.51.0", + "wit-component 0.244.0", +] + [[package]] name = "wit-bindgen-rust-macro" version = "0.22.0" @@ -20976,6 +21093,21 @@ dependencies = [ "wit-bindgen-rust 0.41.0", ] +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.106", + "wit-bindgen-core 0.51.0", + "wit-bindgen-rust 0.51.0", +] + [[package]] name = "wit-component" version = "0.201.0" @@ -21014,6 +21146,25 @@ dependencies = [ "wit-parser 0.227.1", ] +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.244.0", + "wasm-metadata 0.244.0", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] + [[package]] name = "wit-parser" version = "0.201.0" @@ -21068,6 +21219,24 @@ dependencies = [ "wasmparser 0.229.0", ] +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] + [[package]] name = "witx" version = "0.9.1" @@ -21479,14 +21648,14 @@ dependencies = [ [[package]] name = "zbus" -version = "5.12.0" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" dependencies = [ "async-broadcast", "async-executor", "async-io", - "async-lock 3.4.1", + "async-lock 3.4.2", "async-process", "async-recursion", "async-task", @@ -21497,8 +21666,9 @@ dependencies = [ "futures-core", "futures-lite 2.6.1", "hex", - "nix 0.30.1", + "libc", "ordered-stream", + "rustix 1.1.2", "serde", "serde_repr", "tracing", @@ -21513,9 +21683,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.12.0" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -21528,19 +21698,18 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "static_assertions", "winnow", "zvariant", ] [[package]] name = "zed" -version = "0.226.0" +version = "0.227.0" dependencies = [ "acp_thread", "acp_tools", @@ -22158,14 +22327,14 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.8.0" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" dependencies = [ "endi", "enumflags2", "serde", - "url", + "serde_bytes", "winnow", "zvariant_derive", "zvariant_utils", @@ -22173,9 +22342,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.8.0" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -22186,9 +22355,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 645ae42e211bdf..b1945b095a30a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -481,8 +481,13 @@ alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev any_vec = "0.14" anyhow = "1.0.86" arrayvec = { version = "0.7.4", features = ["serde"] } -ashpd = { version = "0.12.1", default-features = false, features = [ - "async-std", +ashpd = { version = "0.13", default-features = false, features = [ + "async-io", + "notification", + "open_uri", + "file_chooser", + "settings", + "trash" ] } async-compat = "0.2.1" async-compression = { version = "0.4", features = ["gzip", "futures-io"] } diff --git a/assets/icons/git_commit.svg b/assets/icons/git_commit.svg new file mode 100644 index 00000000000000..38b36ec7efb722 --- /dev/null +++ b/assets/icons/git_commit.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/git_graph.svg b/assets/icons/git_graph.svg index 8f372a305d3fdd..7ae33e365d40bf 100644 --- a/assets/icons/git_graph.svg +++ b/assets/icons/git_graph.svg @@ -1,4 +1,7 @@ - - + + + + + diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 9832ce8fe08fe2..1f2742f982bc21 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -1110,4 +1110,12 @@ "shift-g": "menu::SelectLast", }, }, + { + "context": "NotebookEditor > Editor && VimControl && vim_mode == normal", + + "bindings": { + "j": "notebook::NotebookMoveDown", + "k": "notebook::NotebookMoveUp", + }, + }, ] diff --git a/assets/settings/default.json b/assets/settings/default.json index 8b9489600c269c..b3df7fda2dc2c5 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -800,6 +800,8 @@ // 3. Show files first, then directories: // "files_first" "sort_mode": "directories_first", + // Whether to show error and warning count badges next to file names in the project panel. + "diagnostic_badges": true, // Whether to enable drag-and-drop operations in the project panel. "drag_and_drop": true, // Whether to hide the root entry when only one folder is open in the window; @@ -914,6 +916,10 @@ // Default: inherits editor scrollbar settings // "show": null }, + // Whether to show the addition/deletion change count next to each file in the Git panel. + // + // Default: false + "diff_stats": false, }, "message_editor": { // Whether to automatically replace emoji shortcodes with emoji characters. diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index aefe14ce7924ec..0a9374cddafa1f 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -2350,7 +2350,7 @@ impl AcpThread { text_diff(old_text.as_str(), &content) .into_iter() .map(|(range, replacement)| { - (snapshot.anchor_range_between(range), replacement) + (snapshot.anchor_range_around(range), replacement) }) .collect::>() }) diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index 14ec9bb9af92c2..fa4b37dba3e789 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -23,17 +23,6 @@ pub type DbMessage = crate::Message; pub type DbSummary = crate::legacy_thread::DetailedSummaryState; pub type DbLanguageModel = crate::legacy_thread::SerializedLanguageModel; -/// Metadata about the git worktree associated with an agent thread. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentGitWorktreeInfo { - /// The branch name in the git worktree. - pub branch: String, - /// Absolute path to the git worktree on disk. - pub worktree_path: std::path::PathBuf, - /// The base branch/commit the worktree was created from. - pub base_ref: String, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DbThreadMetadata { pub id: acp::SessionId, @@ -41,10 +30,6 @@ pub struct DbThreadMetadata { #[serde(alias = "summary")] pub title: SharedString, pub updated_at: DateTime, - /// Denormalized from `DbThread::git_worktree_info.branch` for efficient - /// listing without decompressing thread data. The blob is the source of - /// truth; this column is populated on save for query convenience. - pub worktree_branch: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -68,8 +53,6 @@ pub struct DbThread { pub imported: bool, #[serde(default)] pub subagent_context: Option, - #[serde(default)] - pub git_worktree_info: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -108,7 +91,6 @@ impl SharedThread { profile: None, imported: true, subagent_context: None, - git_worktree_info: None, } } @@ -283,7 +265,6 @@ impl DbThread { profile: thread.profile, imported: false, subagent_context: None, - git_worktree_info: None, }) } } @@ -388,13 +369,6 @@ impl ThreadsDatabase { s().ok(); } - if let Ok(mut s) = connection.exec(indoc! {" - ALTER TABLE threads ADD COLUMN worktree_branch TEXT - "}) - { - s().ok(); - } - let db = Self { executor, connection: Arc::new(Mutex::new(connection)), @@ -423,10 +397,6 @@ impl ThreadsDatabase { .subagent_context .as_ref() .map(|ctx| ctx.parent_thread_id.0.clone()); - let worktree_branch = thread - .git_worktree_info - .as_ref() - .map(|info| info.branch.clone()); let json_data = serde_json::to_string(&SerializedThread { thread, version: DbThread::VERSION, @@ -438,19 +408,11 @@ impl ThreadsDatabase { let data_type = DataType::Zstd; let data = compressed; - let mut insert = connection.exec_bound::<(Arc, Option>, Option, String, String, DataType, Vec)>(indoc! {" - INSERT OR REPLACE INTO threads (id, parent_id, worktree_branch, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?, ?, ?) + let mut insert = connection.exec_bound::<(Arc, Option>, String, String, DataType, Vec)>(indoc! {" + INSERT OR REPLACE INTO threads (id, parent_id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?, ?) "})?; - insert(( - id.0, - parent_id, - worktree_branch, - title, - updated_at, - data_type, - data, - ))?; + insert((id.0, parent_id, title, updated_at, data_type, data))?; Ok(()) } @@ -462,20 +424,19 @@ impl ThreadsDatabase { let connection = connection.lock(); let mut select = connection - .select_bound::<(), (Arc, Option>, Option, String, String)>(indoc! {" - SELECT id, parent_id, worktree_branch, summary, updated_at FROM threads ORDER BY updated_at DESC + .select_bound::<(), (Arc, Option>, String, String)>(indoc! {" + SELECT id, parent_id, summary, updated_at FROM threads ORDER BY updated_at DESC "})?; let rows = select(())?; let mut threads = Vec::new(); - for (id, parent_id, worktree_branch, summary, updated_at) in rows { + for (id, parent_id, summary, updated_at) in rows { threads.push(DbThreadMetadata { id: acp::SessionId::new(id), parent_session_id: parent_id.map(acp::SessionId::new), title: summary.into(), updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc), - worktree_branch, }); } @@ -609,7 +570,6 @@ mod tests { profile: None, imported: false, subagent_context: None, - git_worktree_info: None, } } @@ -753,94 +713,4 @@ mod tests { "Regular threads should have no subagent_context" ); } - - #[gpui::test] - async fn test_git_worktree_info_roundtrip(cx: &mut TestAppContext) { - let database = ThreadsDatabase::new(cx.executor()).unwrap(); - - let thread_id = session_id("worktree-thread"); - let mut thread = make_thread( - "Worktree Thread", - Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(), - ); - thread.git_worktree_info = Some(AgentGitWorktreeInfo { - branch: "zed/agent/a4Xiu".to_string(), - worktree_path: std::path::PathBuf::from("/repo/worktrees/zed/agent/a4Xiu"), - base_ref: "main".to_string(), - }); - - database - .save_thread(thread_id.clone(), thread) - .await - .unwrap(); - - let loaded = database - .load_thread(thread_id) - .await - .unwrap() - .expect("thread should exist"); - - let info = loaded - .git_worktree_info - .expect("git_worktree_info should be restored"); - assert_eq!(info.branch, "zed/agent/a4Xiu"); - assert_eq!( - info.worktree_path, - std::path::PathBuf::from("/repo/worktrees/zed/agent/a4Xiu") - ); - assert_eq!(info.base_ref, "main"); - } - - #[gpui::test] - async fn test_session_list_includes_worktree_meta(cx: &mut TestAppContext) { - let database = ThreadsDatabase::new(cx.executor()).unwrap(); - - // Save a thread with worktree info - let worktree_id = session_id("wt-thread"); - let mut worktree_thread = make_thread( - "With Worktree", - Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(), - ); - worktree_thread.git_worktree_info = Some(AgentGitWorktreeInfo { - branch: "zed/agent/bR9kz".to_string(), - worktree_path: std::path::PathBuf::from("/repo/worktrees/zed/agent/bR9kz"), - base_ref: "develop".to_string(), - }); - - database - .save_thread(worktree_id.clone(), worktree_thread) - .await - .unwrap(); - - // Save a thread without worktree info - let plain_id = session_id("plain-thread"); - let plain_thread = make_thread( - "Without Worktree", - Utc.with_ymd_and_hms(2024, 6, 15, 11, 0, 0).unwrap(), - ); - - database - .save_thread(plain_id.clone(), plain_thread) - .await - .unwrap(); - - // List threads and verify worktree_branch is populated correctly - let threads = database.list_threads().await.unwrap(); - assert_eq!(threads.len(), 2); - - let wt_entry = threads - .iter() - .find(|t| t.id == worktree_id) - .expect("should find worktree thread"); - assert_eq!(wt_entry.worktree_branch.as_deref(), Some("zed/agent/bR9kz")); - - let plain_entry = threads - .iter() - .find(|t| t.id == plain_id) - .expect("should find plain thread"); - assert!( - plain_entry.worktree_branch.is_none(), - "plain thread should have no worktree_branch" - ); - } } diff --git a/crates/agent/src/edit_agent.rs b/crates/agent/src/edit_agent.rs index 3e67cba1b63f41..9f2f2f1877e206 100644 --- a/crates/agent/src/edit_agent.rs +++ b/crates/agent/src/edit_agent.rs @@ -166,56 +166,69 @@ impl EditAgent { output_events_tx: mpsc::UnboundedSender, cx: &mut AsyncApp, ) -> Result<()> { - cx.update(|cx| { - buffer.update(cx, |buffer, cx| buffer.set_text("", cx)); - self.action_log.update(cx, |log, cx| { - log.buffer_edited(buffer.clone(), cx); - }); + let buffer_id = cx.update(|cx| { + let buffer_id = buffer.read(cx).remote_id(); self.project.update(cx, |project, cx| { project.set_agent_location( Some(AgentLocation { buffer: buffer.downgrade(), - position: language::Anchor::max_for_buffer(buffer.read(cx).remote_id()), + position: language::Anchor::min_for_buffer(buffer_id), }), cx, ) }); + buffer_id + }); + + let send_edit_event = || { output_events_tx .unbounded_send(EditAgentOutputEvent::Edited( - Anchor::min_max_range_for_buffer(buffer.read(cx).remote_id()), + Anchor::min_max_range_for_buffer(buffer_id), )) - .ok(); - }); - + .ok() + }; + let set_agent_location = |cx: &mut _| { + self.project.update(cx, |project, cx| { + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position: language::Anchor::max_for_buffer(buffer_id), + }), + cx, + ) + }) + }; + let mut first_chunk = true; while let Some(event) = parse_rx.next().await { match event? { CreateFileParserEvent::NewTextChunk { chunk } => { - let buffer_id = cx.update(|cx| { - buffer.update(cx, |buffer, cx| buffer.append(chunk, cx)); + cx.update(|cx| { + buffer.update(cx, |buffer, cx| { + if mem::take(&mut first_chunk) { + buffer.set_text(chunk, cx) + } else { + buffer.append(chunk, cx) + } + }); self.action_log .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - self.project.update(cx, |project, cx| { - project.set_agent_location( - Some(AgentLocation { - buffer: buffer.downgrade(), - position: language::Anchor::max_for_buffer( - buffer.read(cx).remote_id(), - ), - }), - cx, - ) - }); - buffer.read(cx).remote_id() + set_agent_location(cx); }); - output_events_tx - .unbounded_send(EditAgentOutputEvent::Edited( - Anchor::min_max_range_for_buffer(buffer_id), - )) - .ok(); + send_edit_event(); } } } + if first_chunk { + cx.update(|cx| { + buffer.update(cx, |buffer, cx| buffer.set_text("", cx)); + self.action_log + .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + set_agent_location(cx); + }); + send_edit_event(); + } + Ok(()) } @@ -1194,19 +1207,16 @@ mod tests { ); cx.run_until_parked(); - assert_matches!( - drain_events(&mut events).as_slice(), - [EditAgentOutputEvent::Edited(_)] - ); + assert_eq!(drain_events(&mut events).as_slice(), []); assert_eq!( buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), - "" + "abc\ndef\nghi" ); assert_eq!( project.read_with(cx, |project, _| project.agent_location()), Some(AgentLocation { buffer: buffer.downgrade(), - position: language::Anchor::max_for_buffer( + position: language::Anchor::min_for_buffer( cx.update(|cx| buffer.read(cx).remote_id()) ), }) @@ -1290,6 +1300,32 @@ mod tests { ); } + #[gpui::test] + async fn test_overwrite_no_content(cx: &mut TestAppContext) { + let agent = init_test(cx).await; + let buffer = cx.new(|cx| Buffer::local("abc\ndef\nghi", cx)); + let (chunks_tx, chunks_rx) = mpsc::unbounded::<&str>(); + let (apply, mut events) = agent.overwrite_with_chunks( + buffer.clone(), + chunks_rx.map(|chunk| Ok(chunk.to_string())), + &mut cx.to_async(), + ); + + drop(chunks_tx); + cx.run_until_parked(); + + let result = apply.await; + assert!(result.is_ok(),); + assert_matches!( + drain_events(&mut events).as_slice(), + [EditAgentOutputEvent::Edited { .. }] + ); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.snapshot().text()), + "" + ); + } + #[gpui::test(iterations = 100)] async fn test_indent_new_text_chunks(mut rng: StdRng) { let chunks = to_random_chunks(&mut rng, " abc\n def\n ghi"); diff --git a/crates/agent/src/native_agent_server.rs b/crates/agent/src/native_agent_server.rs index 4d8bdaf698cb6b..cdd017f76a1840 100644 --- a/crates/agent/src/native_agent_server.rs +++ b/crates/agent/src/native_agent_server.rs @@ -1,4 +1,4 @@ -use std::{any::Any, path::Path, rc::Rc, sync::Arc}; +use std::{any::Any, rc::Rc, sync::Arc}; use agent_client_protocol as acp; use agent_servers::{AgentServer, AgentServerDelegate}; @@ -35,7 +35,6 @@ impl AgentServer for NativeAgentServer { fn connect( &self, - _root_dir: Option<&Path>, delegate: AgentServerDelegate, cx: &mut App, ) -> Task< @@ -44,10 +43,7 @@ impl AgentServer for NativeAgentServer { Option, )>, > { - log::debug!( - "NativeAgentServer::connect called for path: {:?}", - _root_dir - ); + log::debug!("NativeAgentServer::connect"); let project = delegate.project().clone(); let fs = self.fs.clone(); let thread_store = self.thread_store.clone(); diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 139242fdee9da9..e8c95c630b6587 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -310,11 +310,11 @@ async fn test_terminal_tool_timeout_kills_handle(cx: &mut TestAppContext) { let task = cx.update(|cx| { tool.run( - crate::TerminalToolInput { + ToolInput::resolved(crate::TerminalToolInput { command: "sleep 1000".to_string(), cd: ".".to_string(), timeout_ms: Some(5), - }, + }), event_stream, cx, ) @@ -377,11 +377,11 @@ async fn test_terminal_tool_without_timeout_does_not_kill_handle(cx: &mut TestAp let _task = cx.update(|cx| { tool.run( - crate::TerminalToolInput { + ToolInput::resolved(crate::TerminalToolInput { command: "sleep 1000".to_string(), cd: ".".to_string(), timeout_ms: None, - }, + }), event_stream, cx, ) @@ -3991,11 +3991,11 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) { let task = cx.update(|cx| { tool.run( - crate::TerminalToolInput { + ToolInput::resolved(crate::TerminalToolInput { command: "rm -rf /".to_string(), cd: ".".to_string(), timeout_ms: None, - }, + }), event_stream, cx, ) @@ -4043,11 +4043,11 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) { let task = cx.update(|cx| { tool.run( - crate::TerminalToolInput { + ToolInput::resolved(crate::TerminalToolInput { command: "echo hello".to_string(), cd: ".".to_string(), timeout_ms: None, - }, + }), event_stream, cx, ) @@ -4101,11 +4101,11 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) { let _task = cx.update(|cx| { tool.run( - crate::TerminalToolInput { + ToolInput::resolved(crate::TerminalToolInput { command: "sudo rm file".to_string(), cd: ".".to_string(), timeout_ms: None, - }, + }), event_stream, cx, ) @@ -4148,11 +4148,11 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) { let task = cx.update(|cx| { tool.run( - crate::TerminalToolInput { + ToolInput::resolved(crate::TerminalToolInput { command: "echo hello".to_string(), cd: ".".to_string(), timeout_ms: None, - }, + }), event_stream, cx, ) @@ -5309,11 +5309,11 @@ async fn test_edit_file_tool_deny_rule_blocks_edit(cx: &mut TestAppContext) { let task = cx.update(|cx| { tool.run( - crate::EditFileToolInput { + ToolInput::resolved(crate::EditFileToolInput { display_description: "Edit sensitive file".to_string(), path: "root/sensitive_config.txt".into(), mode: crate::EditFileMode::Edit, - }, + }), event_stream, cx, ) @@ -5359,9 +5359,9 @@ async fn test_delete_path_tool_deny_rule_blocks_deletion(cx: &mut TestAppContext let task = cx.update(|cx| { tool.run( - crate::DeletePathToolInput { + ToolInput::resolved(crate::DeletePathToolInput { path: "root/important_data.txt".to_string(), - }, + }), event_stream, cx, ) @@ -5411,10 +5411,10 @@ async fn test_move_path_tool_denies_if_destination_denied(cx: &mut TestAppContex let task = cx.update(|cx| { tool.run( - crate::MovePathToolInput { + ToolInput::resolved(crate::MovePathToolInput { source_path: "root/safe.txt".to_string(), destination_path: "root/protected/safe.txt".to_string(), - }, + }), event_stream, cx, ) @@ -5467,10 +5467,10 @@ async fn test_move_path_tool_denies_if_source_denied(cx: &mut TestAppContext) { let task = cx.update(|cx| { tool.run( - crate::MovePathToolInput { + ToolInput::resolved(crate::MovePathToolInput { source_path: "root/secret.txt".to_string(), destination_path: "root/public/not_secret.txt".to_string(), - }, + }), event_stream, cx, ) @@ -5525,10 +5525,10 @@ async fn test_copy_path_tool_deny_rule_blocks_copy(cx: &mut TestAppContext) { let task = cx.update(|cx| { tool.run( - crate::CopyPathToolInput { + ToolInput::resolved(crate::CopyPathToolInput { source_path: "root/confidential.txt".to_string(), destination_path: "root/dest/copy.txt".to_string(), - }, + }), event_stream, cx, ) @@ -5580,12 +5580,12 @@ async fn test_save_file_tool_denies_if_any_path_denied(cx: &mut TestAppContext) let task = cx.update(|cx| { tool.run( - crate::SaveFileToolInput { + ToolInput::resolved(crate::SaveFileToolInput { paths: vec![ std::path::PathBuf::from("root/normal.txt"), std::path::PathBuf::from("root/readonly/config.txt"), ], - }, + }), event_stream, cx, ) @@ -5632,9 +5632,9 @@ async fn test_save_file_tool_respects_deny_rules(cx: &mut TestAppContext) { let task = cx.update(|cx| { tool.run( - crate::SaveFileToolInput { + ToolInput::resolved(crate::SaveFileToolInput { paths: vec![std::path::PathBuf::from("root/config.secret")], - }, + }), event_stream, cx, ) @@ -5676,7 +5676,7 @@ async fn test_web_search_tool_deny_rule_blocks_search(cx: &mut TestAppContext) { let input: crate::WebSearchToolInput = serde_json::from_value(json!({"query": "internal.company.com secrets"})).unwrap(); - let task = cx.update(|cx| tool.run(input, event_stream, cx)); + let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); let result = task.await; assert!(result.is_err(), "expected search to be blocked"); @@ -5741,11 +5741,11 @@ async fn test_edit_file_tool_allow_rule_skips_confirmation(cx: &mut TestAppConte let _task = cx.update(|cx| { tool.run( - crate::EditFileToolInput { + ToolInput::resolved(crate::EditFileToolInput { display_description: "Edit README".to_string(), path: "root/README.md".into(), mode: crate::EditFileMode::Edit, - }, + }), event_stream, cx, ) @@ -5811,11 +5811,11 @@ async fn test_edit_file_tool_allow_still_prompts_for_local_settings(cx: &mut Tes let (event_stream, mut rx) = crate::ToolCallEventStream::test(); let _task = cx.update(|cx| { tool.run( - crate::EditFileToolInput { + ToolInput::resolved(crate::EditFileToolInput { display_description: "Edit local settings".to_string(), path: "root/.zed/settings.json".into(), mode: crate::EditFileMode::Edit, - }, + }), event_stream, cx, ) @@ -5855,7 +5855,7 @@ async fn test_fetch_tool_deny_rule_blocks_url(cx: &mut TestAppContext) { let input: crate::FetchToolInput = serde_json::from_value(json!({"url": "https://internal.company.com/api"})).unwrap(); - let task = cx.update(|cx| tool.run(input, event_stream, cx)); + let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); let result = task.await; assert!(result.is_err(), "expected fetch to be blocked"); @@ -5893,7 +5893,7 @@ async fn test_fetch_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext) let input: crate::FetchToolInput = serde_json::from_value(json!({"url": "https://docs.rs/some-crate"})).unwrap(); - let _task = cx.update(|cx| tool.run(input, event_stream, cx)); + let _task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); cx.run_until_parked(); diff --git a/crates/agent/src/tests/test_tools.rs b/crates/agent/src/tests/test_tools.rs index 0ed2eef9027153..e0794ee322cdf2 100644 --- a/crates/agent/src/tests/test_tools.rs +++ b/crates/agent/src/tests/test_tools.rs @@ -3,6 +3,7 @@ use agent_settings::AgentSettings; use gpui::{App, SharedString, Task}; use std::future; use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; /// A tool that echoes its input #[derive(JsonSchema, Serialize, Deserialize)] @@ -33,11 +34,17 @@ impl AgentTool for EchoTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, _event_stream: ToolCallEventStream, - _cx: &mut App, + cx: &mut App, ) -> Task> { - Task::ready(Ok(input.text)) + cx.spawn(async move |_cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + Ok(input.text) + }) } } @@ -74,7 +81,7 @@ impl AgentTool for DelayTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, _event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> @@ -83,6 +90,10 @@ impl AgentTool for DelayTool { { let executor = cx.background_executor().clone(); cx.foreground_executor().spawn(async move { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; executor.timer(Duration::from_millis(input.ms)).await; Ok("Ding".to_string()) }) @@ -114,28 +125,38 @@ impl AgentTool for ToolRequiringPermission { fn run( self: Arc, - _input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let settings = AgentSettings::get_global(cx); - let decision = decide_permission_from_settings(Self::NAME, &[String::new()], settings); - - let authorize = match decision { - ToolPermissionDecision::Allow => None, - ToolPermissionDecision::Deny(reason) => { - return Task::ready(Err(reason)); - } - ToolPermissionDecision::Confirm => { - let context = crate::ToolPermissionContext::new( - "tool_requiring_permission", - vec![String::new()], - ); - Some(event_stream.authorize("Authorize?", context, cx)) - } - }; + cx.spawn(async move |cx| { + let _input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + + let decision = cx.update(|cx| { + decide_permission_from_settings( + Self::NAME, + &[String::new()], + AgentSettings::get_global(cx), + ) + }); + + let authorize = match decision { + ToolPermissionDecision::Allow => None, + ToolPermissionDecision::Deny(reason) => { + return Err(reason); + } + ToolPermissionDecision::Confirm => Some(cx.update(|cx| { + let context = crate::ToolPermissionContext::new( + "tool_requiring_permission", + vec![String::new()], + ); + event_stream.authorize("Authorize?", context, cx) + })), + }; - cx.foreground_executor().spawn(async move { if let Some(authorize) = authorize { authorize.await.map_err(|e| e.to_string())?; } @@ -169,11 +190,15 @@ impl AgentTool for InfiniteTool { fn run( self: Arc, - _input: Self::Input, + input: ToolInput, _event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { cx.foreground_executor().spawn(async move { + let _input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; future::pending::<()>().await; unreachable!() }) @@ -221,11 +246,15 @@ impl AgentTool for CancellationAwareTool { fn run( self: Arc, - _input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { cx.foreground_executor().spawn(async move { + let _input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; // Wait for cancellation - this tool does nothing but wait to be cancelled event_stream.cancelled_by_user().await; self.was_cancelled.store(true, Ordering::SeqCst); @@ -276,10 +305,16 @@ impl AgentTool for WordListTool { fn run( self: Arc, - _input: Self::Input, + input: ToolInput, _event_stream: ToolCallEventStream, - _cx: &mut App, + cx: &mut App, ) -> Task> { - Task::ready(Ok("ok".to_string())) + cx.spawn(async move |_cx| { + let _input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + Ok("ok".to_string()) + }) } } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index e40ab834a8710c..923fbd11126f21 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1,14 +1,16 @@ use crate::{ - AgentGitWorktreeInfo, ContextServerRegistry, CopyPathTool, CreateDirectoryTool, - DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, - FindPathTool, GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, - ReadFileTool, RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, StreamingEditFileTool, + ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread, + DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool, + ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, + RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, StreamingEditFileTool, SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, WebSearchTool, decide_permission_from_settings, }; use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; -use feature_flags::{FeatureFlagAppExt as _, SubagentsFeatureFlag}; +use feature_flags::{ + FeatureFlagAppExt as _, StreamingEditFileToolFeatureFlag, SubagentsFeatureFlag, +}; use agent_client_protocol as acp; use agent_settings::{ @@ -43,11 +45,13 @@ use language_model::{ use project::Project; use prompt_store::ProjectContext; use schemars::{JsonSchema, Schema}; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use settings::{LanguageModelSelection, Settings, ToolPermissionMode, update_settings_file}; use smol::stream::StreamExt; use std::{ collections::BTreeMap, + marker::PhantomData, ops::RangeInclusive, path::Path, rc::Rc, @@ -900,8 +904,6 @@ pub struct Thread { subagent_context: Option, /// Weak references to running subagent threads for cancellation propagation running_subagents: Vec>, - /// Git worktree info if this thread is running in an agent worktree. - git_worktree_info: Option, } impl Thread { @@ -992,7 +994,6 @@ impl Thread { imported: false, subagent_context: None, running_subagents: Vec::new(), - git_worktree_info: None, } } @@ -1217,7 +1218,6 @@ impl Thread { imported: db_thread.imported, subagent_context: db_thread.subagent_context, running_subagents: Vec::new(), - git_worktree_info: db_thread.git_worktree_info, } } @@ -1238,7 +1238,6 @@ impl Thread { profile: Some(self.profile_id.clone()), imported: self.imported, subagent_context: self.subagent_context.clone(), - git_worktree_info: self.git_worktree_info.clone(), }; cx.background_spawn(async move { @@ -1358,7 +1357,6 @@ impl Thread { self.project.clone(), cx.weak_entity(), language_registry, - Templates::new(), )); self.add_tool(FetchTool::new(self.project.read(cx).client().http_client())); self.add_tool(FindPathTool::new(self.project.clone())); @@ -1662,6 +1660,7 @@ impl Thread { event_stream: event_stream.clone(), tools: self.enabled_tools(profile, &model, cx), cancellation_tx, + streaming_tool_inputs: HashMap::default(), _task: cx.spawn(async move |this, cx| { log::debug!("Starting agent turn execution"); @@ -2066,10 +2065,6 @@ impl Thread { self.send_or_update_tool_use(&tool_use, title, kind, event_stream); - if !tool_use.is_input_complete { - return None; - } - let Some(tool) = tool else { let content = format!("No tool named {} exists", tool_use.name); return Some(Task::ready(LanguageModelToolResult { @@ -2081,9 +2076,72 @@ impl Thread { })); }; + if !tool_use.is_input_complete { + if tool.supports_input_streaming() { + let running_turn = self.running_turn.as_mut()?; + if let Some(sender) = running_turn.streaming_tool_inputs.get(&tool_use.id) { + sender.send_partial(tool_use.input); + return None; + } + + let (sender, tool_input) = ToolInputSender::channel(); + sender.send_partial(tool_use.input); + running_turn + .streaming_tool_inputs + .insert(tool_use.id.clone(), sender); + + let tool = tool.clone(); + log::debug!("Running streaming tool {}", tool_use.name); + return Some(self.run_tool( + tool, + tool_input, + tool_use.id, + tool_use.name, + event_stream, + cancellation_rx, + cx, + )); + } else { + return None; + } + } + + if let Some(sender) = self + .running_turn + .as_mut()? + .streaming_tool_inputs + .remove(&tool_use.id) + { + sender.send_final(tool_use.input); + return None; + } + + log::debug!("Running tool {}", tool_use.name); + let tool_input = ToolInput::ready(tool_use.input); + Some(self.run_tool( + tool, + tool_input, + tool_use.id, + tool_use.name, + event_stream, + cancellation_rx, + cx, + )) + } + + fn run_tool( + &self, + tool: Arc, + tool_input: ToolInput, + tool_use_id: LanguageModelToolUseId, + tool_name: Arc, + event_stream: &ThreadEventStream, + cancellation_rx: watch::Receiver, + cx: &mut Context, + ) -> Task { let fs = self.project.read(cx).fs().clone(); let tool_event_stream = ToolCallEventStream::new( - tool_use.id.clone(), + tool_use_id.clone(), event_stream.clone(), Some(fs), cancellation_rx, @@ -2092,9 +2150,8 @@ impl Thread { acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress), ); let supports_images = self.model().is_some_and(|model| model.supports_images()); - let tool_result = tool.run(tool_use.input, tool_event_stream, cx); - log::debug!("Running tool {}", tool_use.name); - Some(cx.foreground_executor().spawn(async move { + let tool_result = tool.run(tool_input, tool_event_stream, cx); + cx.foreground_executor().spawn(async move { let (is_error, output) = match tool_result.await { Ok(mut output) => { if let LanguageModelToolResultContent::Image(_) = &output.llm_output @@ -2112,13 +2169,13 @@ impl Thread { }; LanguageModelToolResult { - tool_use_id: tool_use.id, - tool_name: tool_use.name, + tool_use_id, + tool_name, is_error, content: output.llm_output, output: Some(output.raw_output), } - })) + }) } fn handle_tool_use_json_parse_error_event( @@ -2404,6 +2461,7 @@ impl Thread { name: tool_name.to_string(), description: tool.description().to_string(), input_schema: tool.input_schema(model.tool_input_format()).log_err()?, + use_input_streaming: tool.supports_input_streaming(), }) }) .collect::>() @@ -2457,7 +2515,7 @@ impl Thread { } } - let use_streaming_edit_tool = false; + let use_streaming_edit_tool = cx.has_flag::(); let mut tools = self .tools @@ -2774,6 +2832,9 @@ struct RunningTurn { /// Sender to signal tool cancellation. When cancel is called, this is /// set to true so all tools can detect user-initiated cancellation. cancellation_tx: watch::Sender, + /// Senders for tools that support input streaming and have already been + /// started but are still receiving input from the LLM. + streaming_tool_inputs: HashMap, } impl RunningTurn { @@ -2793,6 +2854,103 @@ pub struct TitleUpdated; impl EventEmitter for Thread {} +/// A channel-based wrapper that delivers tool input to a running tool. +/// +/// For non-streaming tools, created via `ToolInput::ready()` so `.recv()` resolves immediately. +/// For streaming tools, partial JSON snapshots arrive via `.recv_partial()` as the LLM streams +/// them, followed by the final complete input available through `.recv()`. +pub struct ToolInput { + partial_rx: mpsc::UnboundedReceiver, + final_rx: oneshot::Receiver, + _phantom: PhantomData, +} + +impl ToolInput { + #[cfg(any(test, feature = "test-support"))] + pub fn resolved(input: impl Serialize) -> Self { + let value = serde_json::to_value(input).expect("failed to serialize tool input"); + Self::ready(value) + } + + pub fn ready(value: serde_json::Value) -> Self { + let (partial_tx, partial_rx) = mpsc::unbounded(); + drop(partial_tx); + let (final_tx, final_rx) = oneshot::channel(); + final_tx.send(value).ok(); + Self { + partial_rx, + final_rx, + _phantom: PhantomData, + } + } + + #[cfg(any(test, feature = "test-support"))] + pub fn test() -> (ToolInputSender, Self) { + let (sender, input) = ToolInputSender::channel(); + (sender, input.cast()) + } + + /// Wait for the final deserialized input, ignoring all partial updates. + /// Non-streaming tools can use this to wait until the whole input is available. + pub async fn recv(mut self) -> Result { + // Drain any remaining partials + while self.partial_rx.next().await.is_some() {} + let value = self + .final_rx + .await + .map_err(|_| anyhow!("tool input sender was dropped before sending final input"))?; + serde_json::from_value(value).map_err(Into::into) + } + + /// Returns the next partial JSON snapshot, or `None` when input is complete. + /// Once this returns `None`, call `recv()` to get the final input. + pub async fn recv_partial(&mut self) -> Option { + self.partial_rx.next().await + } + + fn cast(self) -> ToolInput { + ToolInput { + partial_rx: self.partial_rx, + final_rx: self.final_rx, + _phantom: PhantomData, + } + } +} + +pub struct ToolInputSender { + partial_tx: mpsc::UnboundedSender, + final_tx: Option>, +} + +impl ToolInputSender { + pub(crate) fn channel() -> (Self, ToolInput) { + let (partial_tx, partial_rx) = mpsc::unbounded(); + let (final_tx, final_rx) = oneshot::channel(); + let sender = Self { + partial_tx, + final_tx: Some(final_tx), + }; + let input = ToolInput { + partial_rx, + final_rx, + _phantom: PhantomData, + }; + (sender, input) + } + + pub(crate) fn send_partial(&self, value: serde_json::Value) { + self.partial_tx.unbounded_send(value).ok(); + } + + pub(crate) fn send_final(mut self, value: serde_json::Value) { + // Close the partial channel so recv_partial() returns None + self.partial_tx.close_channel(); + if let Some(final_tx) = self.final_tx.take() { + final_tx.send(value).ok(); + } + } +} + pub trait AgentTool where Self: 'static + Sized, @@ -2826,6 +2984,11 @@ where language_model::tool_schema::root_schema_for::(format) } + /// Returns whether the tool supports streaming of tool use parameters. + fn supports_input_streaming() -> bool { + false + } + /// Some tools rely on a provider for the underlying billing or other reasons. /// Allow the tool to check if they are compatible, or should be filtered out. fn supports_provider(_provider: &LanguageModelProviderId) -> bool { @@ -2841,7 +3004,7 @@ where /// still signaling whether the invocation succeeded or failed. fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task>; @@ -2886,13 +3049,16 @@ pub trait AnyAgentTool { fn kind(&self) -> acp::ToolKind; fn initial_title(&self, input: serde_json::Value, _cx: &mut App) -> SharedString; fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result; + fn supports_input_streaming(&self) -> bool { + false + } fn supports_provider(&self, _provider: &LanguageModelProviderId) -> bool { true } /// See [`AgentTool::run`] for why this returns `Result`. fn run( self: Arc, - input: serde_json::Value, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task>; @@ -2921,6 +3087,10 @@ where T::kind() } + fn supports_input_streaming(&self) -> bool { + T::supports_input_streaming() + } + fn initial_title(&self, input: serde_json::Value, _cx: &mut App) -> SharedString { let parsed_input = serde_json::from_value(input.clone()).map_err(|_| input); self.0.initial_title(parsed_input, _cx) @@ -2938,35 +3108,31 @@ where fn run( self: Arc, - input: serde_json::Value, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - cx.spawn(async move |cx| { - let input: T::Input = serde_json::from_value(input).map_err(|e| { - AgentToolOutput::from_error(format!("Failed to parse tool input: {e}")) - })?; - let task = cx.update(|cx| self.0.clone().run(input, event_stream, cx)); - match task.await { - Ok(output) => { - let raw_output = serde_json::to_value(&output).map_err(|e| { - AgentToolOutput::from_error(format!("Failed to serialize tool output: {e}")) - })?; - Ok(AgentToolOutput { - llm_output: output.into(), - raw_output, - }) - } - Err(error_output) => { - let raw_output = serde_json::to_value(&error_output).unwrap_or_else(|e| { - log::error!("Failed to serialize tool error output: {e}"); - serde_json::Value::Null - }); - Err(AgentToolOutput { - llm_output: error_output.into(), - raw_output, - }) - } + let tool_input: ToolInput = input.cast(); + let task = self.0.clone().run(tool_input, event_stream, cx); + cx.spawn(async move |_cx| match task.await { + Ok(output) => { + let raw_output = serde_json::to_value(&output).map_err(|e| { + AgentToolOutput::from_error(format!("Failed to serialize tool output: {e}")) + })?; + Ok(AgentToolOutput { + llm_output: output.into(), + raw_output, + }) + } + Err(error_output) => { + let raw_output = serde_json::to_value(&error_output).unwrap_or_else(|e| { + log::error!("Failed to serialize tool error output: {e}"); + serde_json::Value::Null + }); + Err(AgentToolOutput { + llm_output: error_output.into(), + raw_output, + }) } }) } diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index 6add31fdb39302..d5526b0953cb43 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -2,40 +2,11 @@ use crate::{DbThread, DbThreadMetadata, ThreadsDatabase}; use agent_client_protocol as acp; use anyhow::{Result, anyhow}; use gpui::{App, Context, Entity, Global, Task, prelude::*}; -use project::Project; -use std::rc::Rc; struct GlobalThreadStore(Entity); impl Global for GlobalThreadStore {} -// TODO: Remove once ACP thread loading is fully handled elsewhere. -pub fn load_agent_thread( - session_id: acp::SessionId, - thread_store: Entity, - project: Entity, - cx: &mut App, -) -> Task>> { - use agent_servers::{AgentServer, AgentServerDelegate}; - - let server = Rc::new(crate::NativeAgentServer::new( - project.read(cx).fs().clone(), - thread_store, - )); - let delegate = AgentServerDelegate::new( - project.read(cx).agent_server_store().clone(), - project.clone(), - None, - None, - ); - let connection = server.connect(None, delegate, cx); - cx.spawn(async move |cx| { - let (agent, _) = connection.await?; - let agent = agent.downcast::().unwrap(); - cx.update(|cx| agent.load_thread(session_id, cx)).await - }) -} - pub struct ThreadStore { threads: Vec, } @@ -162,7 +133,6 @@ mod tests { profile: None, imported: false, subagent_context: None, - git_worktree_info: None, } } diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 1962f237045c47..b2724801befc74 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -100,6 +100,7 @@ macro_rules! tools { name: T::NAME.to_string(), description: T::description().to_string(), input_schema: T::input_schema(LanguageModelToolSchemaFormat::JsonSchema).to_value(), + use_input_streaming: T::supports_input_streaming(), } } [ diff --git a/crates/agent/src/tools/context_server_registry.rs b/crates/agent/src/tools/context_server_registry.rs index 694e28750cd69f..1c7590d8097a5d 100644 --- a/crates/agent/src/tools/context_server_registry.rs +++ b/crates/agent/src/tools/context_server_registry.rs @@ -1,4 +1,4 @@ -use crate::{AgentToolOutput, AnyAgentTool, ToolCallEventStream}; +use crate::{AgentToolOutput, AnyAgentTool, ToolCallEventStream, ToolInput}; use agent_client_protocol::ToolKind; use anyhow::Result; use collections::{BTreeMap, HashMap}; @@ -329,7 +329,7 @@ impl AnyAgentTool for ContextServerTool { fn run( self: Arc, - input: serde_json::Value, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { @@ -339,14 +339,15 @@ impl AnyAgentTool for ContextServerTool { let tool_name = self.tool.name.clone(); let tool_id = mcp_tool_id(&self.server_id.0, &self.tool.name); let display_name = self.tool.name.clone(); - let authorize = event_stream.authorize_third_party_tool( - self.initial_title(input.clone(), cx), - tool_id, - display_name, - cx, - ); + let initial_title = self.initial_title(serde_json::Value::Null, cx); + let authorize = + event_stream.authorize_third_party_tool(initial_title, tool_id, display_name, cx); cx.spawn(async move |_cx| { + let input = input.recv().await.map_err(|e| { + AgentToolOutput::from_error(format!("Failed to receive tool input: {e}")) + })?; + authorize.await.map_err(|e| AgentToolOutput::from_error(e.to_string()))?; let Some(protocol) = server.client() else { diff --git a/crates/agent/src/tools/copy_path_tool.rs b/crates/agent/src/tools/copy_path_tool.rs index c82d9e930e1987..7f53a5c36a7979 100644 --- a/crates/agent/src/tools/copy_path_tool.rs +++ b/crates/agent/src/tools/copy_path_tool.rs @@ -2,7 +2,9 @@ use super::tool_permissions::{ SensitiveSettingsKind, authorize_symlink_escapes, canonicalize_worktree_roots, collect_symlink_escapes, sensitive_settings_kind, }; -use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_paths}; +use crate::{ + AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_paths, +}; use agent_client_protocol::ToolKind; use agent_settings::AgentSettings; use futures::FutureExt as _; @@ -79,19 +81,24 @@ impl AgentTool for CopyPathTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let settings = AgentSettings::get_global(cx); - let paths = vec![input.source_path.clone(), input.destination_path.clone()]; - let decision = decide_permission_for_paths(Self::NAME, &paths, settings); - if let ToolPermissionDecision::Deny(reason) = decision { - return Task::ready(Err(reason)); - } - let project = self.project.clone(); cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + let paths = vec![input.source_path.clone(), input.destination_path.clone()]; + let decision = cx.update(|cx| { + decide_permission_for_paths(Self::NAME, &paths, &AgentSettings::get_global(cx)) + }); + if let ToolPermissionDecision::Deny(reason) = decision { + return Err(reason); + } + let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; @@ -248,7 +255,7 @@ mod tests { }; let (event_stream, mut event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.run(input, event_stream, cx)); + let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); let auth = event_rx.expect_authorization().await; let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); @@ -302,7 +309,7 @@ mod tests { }; let (event_stream, mut event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.run(input, event_stream, cx)); + let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); let auth = event_rx.expect_authorization().await; drop(auth); @@ -354,7 +361,7 @@ mod tests { }; let (event_stream, mut event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.run(input, event_stream, cx)); + let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); let auth = event_rx.expect_authorization().await; let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); @@ -430,7 +437,9 @@ mod tests { }; let (event_stream, mut event_rx) = ToolCallEventStream::test(); - let result = cx.update(|cx| tool.run(input, event_stream, cx)).await; + let result = cx + .update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)) + .await; assert!(result.is_err(), "Tool should fail when policy denies"); assert!( diff --git a/crates/agent/src/tools/create_directory_tool.rs b/crates/agent/src/tools/create_directory_tool.rs index 500b5f00289db2..5d8930f3c74004 100644 --- a/crates/agent/src/tools/create_directory_tool.rs +++ b/crates/agent/src/tools/create_directory_tool.rs @@ -13,7 +13,9 @@ use settings::Settings; use std::sync::Arc; use util::markdown::MarkdownInlineCode; -use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_path}; +use crate::{ + AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_path, +}; use std::path::Path; /// Creates a new directory at the specified path within the project. Returns confirmation that the directory was created. @@ -68,21 +70,26 @@ impl AgentTool for CreateDirectoryTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let settings = AgentSettings::get_global(cx); - let decision = decide_permission_for_path(Self::NAME, &input.path, settings); + let project = self.project.clone(); + cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + let decision = cx.update(|cx| { + decide_permission_for_path(Self::NAME, &input.path, AgentSettings::get_global(cx)) + }); - if let ToolPermissionDecision::Deny(reason) = decision { - return Task::ready(Err(reason)); - } + if let ToolPermissionDecision::Deny(reason) = decision { + return Err(reason); + } - let destination_path: Arc = input.path.as_str().into(); + let destination_path: Arc = input.path.as_str().into(); - let project = self.project.clone(); - cx.spawn(async move |cx| { let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; @@ -218,9 +225,9 @@ mod tests { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.run( - CreateDirectoryToolInput { + ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), - }, + }), event_stream, cx, ) @@ -277,9 +284,9 @@ mod tests { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.run( - CreateDirectoryToolInput { + ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), - }, + }), event_stream, cx, ) @@ -336,9 +343,9 @@ mod tests { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.run( - CreateDirectoryToolInput { + ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), - }, + }), event_stream, cx, ) @@ -415,9 +422,9 @@ mod tests { let result = cx .update(|cx| { tool.run( - CreateDirectoryToolInput { + ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), - }, + }), event_stream, cx, ) diff --git a/crates/agent/src/tools/delete_path_tool.rs b/crates/agent/src/tools/delete_path_tool.rs index 048f4bd8292077..27ab68db667a4c 100644 --- a/crates/agent/src/tools/delete_path_tool.rs +++ b/crates/agent/src/tools/delete_path_tool.rs @@ -2,7 +2,9 @@ use super::tool_permissions::{ SensitiveSettingsKind, authorize_symlink_access, canonicalize_worktree_roots, detect_symlink_escape, sensitive_settings_kind, }; -use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_path}; +use crate::{ + AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_path, +}; use action_log::ActionLog; use agent_client_protocol::ToolKind; use agent_settings::AgentSettings; @@ -71,22 +73,27 @@ impl AgentTool for DeletePathTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let path = input.path; - - let settings = AgentSettings::get_global(cx); - let decision = decide_permission_for_path(Self::NAME, &path, settings); - - if let ToolPermissionDecision::Deny(reason) = decision { - return Task::ready(Err(reason)); - } - let project = self.project.clone(); let action_log = self.action_log.clone(); cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + let path = input.path; + + let decision = cx.update(|cx| { + decide_permission_for_path(Self::NAME, &path, AgentSettings::get_global(cx)) + }); + + if let ToolPermissionDecision::Deny(reason) = decision { + return Err(reason); + } + let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; @@ -278,9 +285,9 @@ mod tests { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.run( - DeletePathToolInput { + ToolInput::resolved(DeletePathToolInput { path: "project/link_to_external".into(), - }, + }), event_stream, cx, ) @@ -345,9 +352,9 @@ mod tests { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.run( - DeletePathToolInput { + ToolInput::resolved(DeletePathToolInput { path: "project/link_to_external".into(), - }, + }), event_stream, cx, ) @@ -405,9 +412,9 @@ mod tests { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.run( - DeletePathToolInput { + ToolInput::resolved(DeletePathToolInput { path: "project/link_to_external".into(), - }, + }), event_stream, cx, ) @@ -488,9 +495,9 @@ mod tests { let result = cx .update(|cx| { tool.run( - DeletePathToolInput { + ToolInput::resolved(DeletePathToolInput { path: "project/link_to_external".into(), - }, + }), event_stream, cx, ) diff --git a/crates/agent/src/tools/diagnostics_tool.rs b/crates/agent/src/tools/diagnostics_tool.rs index fea16d531ed5f4..5889f66c2edbe0 100644 --- a/crates/agent/src/tools/diagnostics_tool.rs +++ b/crates/agent/src/tools/diagnostics_tool.rs @@ -1,4 +1,4 @@ -use crate::{AgentTool, ToolCallEventStream}; +use crate::{AgentTool, ToolCallEventStream, ToolInput}; use agent_client_protocol as acp; use anyhow::Result; use futures::FutureExt as _; @@ -87,21 +87,27 @@ impl AgentTool for DiagnosticsTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - match input.path { - Some(path) if !path.is_empty() => { - let Some(project_path) = self.project.read(cx).find_project_path(&path, cx) else { - return Task::ready(Err(format!("Could not find path {path} in project"))); - }; - - let open_buffer_task = self - .project - .update(cx, |project, cx| project.open_buffer(project_path, cx)); + let project = self.project.clone(); + cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + + match input.path { + Some(path) if !path.is_empty() => { + let (_project_path, open_buffer_task) = project.update(cx, |project, cx| { + let Some(project_path) = project.find_project_path(&path, cx) else { + return Err(format!("Could not find path {path} in project")); + }; + let task = project.open_buffer(project_path.clone(), cx); + Ok((project_path, task)) + })?; - cx.spawn(async move |cx| { let buffer = futures::select! { result = open_buffer_task.fuse() => result.map_err(|e| e.to_string())?, _ = event_stream.cancelled_by_user().fuse() => { @@ -135,36 +141,40 @@ impl AgentTool for DiagnosticsTool { } else { Ok(output) } - }) - } - _ => { - let project = self.project.read(cx); - let mut output = String::new(); - let mut has_diagnostics = false; - - for (project_path, _, summary) in project.diagnostic_summaries(true, cx) { - if summary.error_count > 0 || summary.warning_count > 0 { - let Some(worktree) = project.worktree_for_id(project_path.worktree_id, cx) - else { - continue; - }; - - has_diagnostics = true; - output.push_str(&format!( - "{}: {} error(s), {} warning(s)\n", - worktree.read(cx).absolutize(&project_path.path).display(), - summary.error_count, - summary.warning_count - )); - } } + _ => { + let (output, has_diagnostics) = project.read_with(cx, |project, cx| { + let mut output = String::new(); + let mut has_diagnostics = false; + + for (project_path, _, summary) in project.diagnostic_summaries(true, cx) { + if summary.error_count > 0 || summary.warning_count > 0 { + let Some(worktree) = + project.worktree_for_id(project_path.worktree_id, cx) + else { + continue; + }; + + has_diagnostics = true; + output.push_str(&format!( + "{}: {} error(s), {} warning(s)\n", + worktree.read(cx).absolutize(&project_path.path).display(), + summary.error_count, + summary.warning_count + )); + } + } + + (output, has_diagnostics) + }); - if has_diagnostics { - Task::ready(Ok(output)) - } else { - Task::ready(Ok("No errors or warnings found in the project.".into())) + if has_diagnostics { + Ok(output) + } else { + Ok("No errors or warnings found in the project.".into()) + } } } - } + }) } } diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 788bf06529a6f0..3e1e0661f126d4 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -2,7 +2,7 @@ use super::restore_file_from_disk_tool::RestoreFileFromDiskTool; use super::save_file_tool::SaveFileTool; use super::tool_permissions::authorize_file_edit; use crate::{ - AgentTool, Templates, Thread, ToolCallEventStream, + AgentTool, Templates, Thread, ToolCallEventStream, ToolInput, edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat}, }; use acp_thread::Diff; @@ -237,39 +237,44 @@ impl AgentTool for EditFileTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let Ok(project) = self - .thread - .read_with(cx, |thread, _cx| thread.project().clone()) - else { - return Task::ready(Err(EditFileToolOutput::Error { - error: "thread was dropped".to_string(), - })); - }; - let project_path = match resolve_path(&input, project.clone(), cx) { - Ok(path) => path, - Err(err) => { - return Task::ready(Err(EditFileToolOutput::Error { - error: err.to_string(), - })); - } - }; - let abs_path = project.read(cx).absolute_path(&project_path, cx); - if let Some(abs_path) = abs_path.clone() { - event_stream.update_fields( - ToolCallUpdateFields::new().locations(vec![acp::ToolCallLocation::new(abs_path)]), - ); - } - let allow_thinking = self - .thread - .read_with(cx, |thread, _cx| thread.thinking_enabled()) - .unwrap_or(true); - - let authorize = self.authorize(&input, &event_stream, cx); cx.spawn(async move |cx: &mut AsyncApp| { + let input = input.recv().await.map_err(|e| EditFileToolOutput::Error { + error: format!("Failed to receive tool input: {e}"), + })?; + + let project = self + .thread + .read_with(cx, |thread, _cx| thread.project().clone()) + .map_err(|_| EditFileToolOutput::Error { + error: "thread was dropped".to_string(), + })?; + + let (project_path, abs_path, allow_thinking, authorize) = + cx.update(|cx| { + let project_path = resolve_path(&input, project.clone(), cx).map_err(|err| { + EditFileToolOutput::Error { + error: err.to_string(), + } + })?; + let abs_path = project.read(cx).absolute_path(&project_path, cx); + if let Some(abs_path) = abs_path.clone() { + event_stream.update_fields( + ToolCallUpdateFields::new() + .locations(vec![acp::ToolCallLocation::new(abs_path)]), + ); + } + let allow_thinking = self + .thread + .read_with(cx, |thread, _cx| thread.thinking_enabled()) + .unwrap_or(true); + let authorize = self.authorize(&input, &event_stream, cx); + Ok::<_, EditFileToolOutput>((project_path, abs_path, allow_thinking, authorize)) + })?; + let result: anyhow::Result = async { authorize.await?; @@ -672,7 +677,11 @@ mod tests { language_registry, Templates::new(), )) - .run(input, ToolCallEventStream::test().0, cx) + .run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; assert_eq!( @@ -881,7 +890,11 @@ mod tests { language_registry.clone(), Templates::new(), )) - .run(input, ToolCallEventStream::test().0, cx) + .run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }); // Stream the unformatted content @@ -940,7 +953,11 @@ mod tests { language_registry, Templates::new(), )) - .run(input, ToolCallEventStream::test().0, cx) + .run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }); // Stream the unformatted content @@ -1027,7 +1044,11 @@ mod tests { language_registry.clone(), Templates::new(), )) - .run(input, ToolCallEventStream::test().0, cx) + .run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }); // Stream the content with trailing whitespace @@ -1082,7 +1103,11 @@ mod tests { language_registry, Templates::new(), )) - .run(input, ToolCallEventStream::test().0, cx) + .run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }); // Stream the content with trailing whitespace @@ -2081,11 +2106,11 @@ mod tests { let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let edit = cx.update(|cx| { tool.run( - EditFileToolInput { + ToolInput::resolved(EditFileToolInput { display_description: "Edit file".into(), path: path!("/main.rs").into(), mode: EditFileMode::Edit, - }, + }), stream_tx, cx, ) @@ -2111,11 +2136,11 @@ mod tests { let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let edit = cx.update(|cx| { tool.run( - EditFileToolInput { + ToolInput::resolved(EditFileToolInput { display_description: "Edit file".into(), path: path!("/main.rs").into(), mode: EditFileMode::Edit, - }, + }), stream_tx, cx, ) @@ -2139,11 +2164,11 @@ mod tests { let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); let edit = cx.update(|cx| { tool.run( - EditFileToolInput { + ToolInput::resolved(EditFileToolInput { display_description: "Edit file".into(), path: path!("/main.rs").into(), mode: EditFileMode::Edit, - }, + }), stream_tx, cx, ) @@ -2199,11 +2224,11 @@ mod tests { // Read the file to record the read time cx.update(|cx| { read_tool.clone().run( - crate::ReadFileToolInput { + ToolInput::resolved(crate::ReadFileToolInput { path: "root/test.txt".to_string(), start_line: None, end_line: None, - }, + }), ToolCallEventStream::test().0, cx, ) @@ -2227,11 +2252,11 @@ mod tests { // Read the file again - should update the entry cx.update(|cx| { read_tool.clone().run( - crate::ReadFileToolInput { + ToolInput::resolved(crate::ReadFileToolInput { path: "root/test.txt".to_string(), start_line: None, end_line: None, - }, + }), ToolCallEventStream::test().0, cx, ) @@ -2298,11 +2323,11 @@ mod tests { // Read the file first cx.update(|cx| { read_tool.clone().run( - crate::ReadFileToolInput { + ToolInput::resolved(crate::ReadFileToolInput { path: "root/test.txt".to_string(), start_line: None, end_line: None, - }, + }), ToolCallEventStream::test().0, cx, ) @@ -2314,11 +2339,11 @@ mod tests { let edit_result = { let edit_task = cx.update(|cx| { edit_tool.clone().run( - EditFileToolInput { + ToolInput::resolved(EditFileToolInput { display_description: "First edit".into(), path: "root/test.txt".into(), mode: EditFileMode::Edit, - }, + }), ToolCallEventStream::test().0, cx, ) @@ -2343,11 +2368,11 @@ mod tests { let edit_result = { let edit_task = cx.update(|cx| { edit_tool.clone().run( - EditFileToolInput { + ToolInput::resolved(EditFileToolInput { display_description: "Second edit".into(), path: "root/test.txt".into(), mode: EditFileMode::Edit, - }, + }), ToolCallEventStream::test().0, cx, ) @@ -2412,11 +2437,11 @@ mod tests { // Read the file first cx.update(|cx| { read_tool.clone().run( - crate::ReadFileToolInput { + ToolInput::resolved(crate::ReadFileToolInput { path: "root/test.txt".to_string(), start_line: None, end_line: None, - }, + }), ToolCallEventStream::test().0, cx, ) @@ -2456,11 +2481,11 @@ mod tests { let result = cx .update(|cx| { edit_tool.clone().run( - EditFileToolInput { + ToolInput::resolved(EditFileToolInput { display_description: "Edit after external change".into(), path: "root/test.txt".into(), mode: EditFileMode::Edit, - }, + }), ToolCallEventStream::test().0, cx, ) @@ -2523,11 +2548,11 @@ mod tests { // Read the file first cx.update(|cx| { read_tool.clone().run( - crate::ReadFileToolInput { + ToolInput::resolved(crate::ReadFileToolInput { path: "root/test.txt".to_string(), start_line: None, end_line: None, - }, + }), ToolCallEventStream::test().0, cx, ) @@ -2560,11 +2585,11 @@ mod tests { let result = cx .update(|cx| { edit_tool.clone().run( - EditFileToolInput { + ToolInput::resolved(EditFileToolInput { display_description: "Edit with dirty buffer".into(), path: "root/test.txt".into(), mode: EditFileMode::Edit, - }, + }), ToolCallEventStream::test().0, cx, ) diff --git a/crates/agent/src/tools/fetch_tool.rs b/crates/agent/src/tools/fetch_tool.rs index e573c2202b09d1..75880801595ad0 100644 --- a/crates/agent/src/tools/fetch_tool.rs +++ b/crates/agent/src/tools/fetch_tool.rs @@ -16,7 +16,8 @@ use ui::SharedString; use util::markdown::{MarkdownEscaped, MarkdownInlineCode}; use crate::{ - AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_from_settings, + AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, + decide_permission_from_settings, }; #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] @@ -141,41 +142,52 @@ impl AgentTool for FetchTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let settings = AgentSettings::get_global(cx); - let decision = - decide_permission_from_settings(Self::NAME, std::slice::from_ref(&input.url), settings); - - let authorize = match decision { - ToolPermissionDecision::Allow => None, - ToolPermissionDecision::Deny(reason) => { - return Task::ready(Err(reason)); - } - ToolPermissionDecision::Confirm => { - let context = - crate::ToolPermissionContext::new(Self::NAME, vec![input.url.clone()]); - Some(event_stream.authorize( - format!("Fetch {}", MarkdownInlineCode(&input.url)), - context, - cx, - )) - } - }; + let http_client = self.http_client.clone(); + cx.spawn(async move |cx| { + let input: FetchToolInput = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + + let decision = cx.update(|cx| { + decide_permission_from_settings( + Self::NAME, + std::slice::from_ref(&input.url), + AgentSettings::get_global(cx), + ) + }); + + let authorize = match decision { + ToolPermissionDecision::Allow => None, + ToolPermissionDecision::Deny(reason) => { + return Err(reason); + } + ToolPermissionDecision::Confirm => Some(cx.update(|cx| { + let context = + crate::ToolPermissionContext::new(Self::NAME, vec![input.url.clone()]); + event_stream.authorize( + format!("Fetch {}", MarkdownInlineCode(&input.url)), + context, + cx, + ) + })), + }; - let fetch_task = cx.background_spawn({ - let http_client = self.http_client.clone(); - async move { - if let Some(authorize) = authorize { - authorize.await?; + let fetch_task = cx.background_spawn({ + let http_client = http_client.clone(); + let url = input.url.clone(); + async move { + if let Some(authorize) = authorize { + authorize.await?; + } + Self::build_message(http_client, &url).await } - Self::build_message(http_client, &input.url).await - } - }); + }); - cx.foreground_executor().spawn(async move { let text = futures::select! { result = fetch_task.fuse() => result.map_err(|e| e.to_string())?, _ = event_stream.cancelled_by_user().fuse() => { diff --git a/crates/agent/src/tools/find_path_tool.rs b/crates/agent/src/tools/find_path_tool.rs index 4ba60c61063c08..9c654615032251 100644 --- a/crates/agent/src/tools/find_path_tool.rs +++ b/crates/agent/src/tools/find_path_tool.rs @@ -1,4 +1,4 @@ -use crate::{AgentTool, ToolCallEventStream}; +use crate::{AgentTool, ToolCallEventStream, ToolInput}; use agent_client_protocol as acp; use anyhow::{Result, anyhow}; use futures::FutureExt as _; @@ -121,13 +121,18 @@ impl AgentTool for FindPathTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let search_paths_task = search_paths(&input.glob, self.project.clone(), cx); + let project = self.project.clone(); + cx.spawn(async move |cx| { + let input = input.recv().await.map_err(|e| FindPathToolOutput::Error { + error: format!("Failed to receive tool input: {e}"), + })?; + + let search_paths_task = cx.update(|cx| search_paths(&input.glob, project, cx)); - cx.background_spawn(async move { let matches = futures::select! { result = search_paths_task.fuse() => result.map_err(|e| FindPathToolOutput::Error { error: e.to_string() })?, _ = event_stream.cancelled_by_user().fuse() => { diff --git a/crates/agent/src/tools/grep_tool.rs b/crates/agent/src/tools/grep_tool.rs index 16162107dff84a..fbfdc18585b822 100644 --- a/crates/agent/src/tools/grep_tool.rs +++ b/crates/agent/src/tools/grep_tool.rs @@ -1,4 +1,4 @@ -use crate::{AgentTool, ToolCallEventStream}; +use crate::{AgentTool, ToolCallEventStream, ToolInput}; use agent_client_protocol as acp; use anyhow::Result; use futures::{FutureExt as _, StreamExt}; @@ -114,66 +114,64 @@ impl AgentTool for GrepTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { const CONTEXT_LINES: u32 = 2; const MAX_ANCESTOR_LINES: u32 = 10; - let path_style = self.project.read(cx).path_style(cx); - - let include_matcher = match PathMatcher::new( - input - .include_pattern - .as_ref() - .into_iter() - .collect::>(), - path_style, - ) { - Ok(matcher) => matcher, - Err(error) => { - return Task::ready(Err(format!("invalid include glob pattern: {error}"))); - } - }; - - // Exclude global file_scan_exclusions and private_files settings - let exclude_matcher = { - let global_settings = WorktreeSettings::get_global(cx); - let exclude_patterns = global_settings - .file_scan_exclusions - .sources() - .chain(global_settings.private_files.sources()); - - match PathMatcher::new(exclude_patterns, path_style) { - Ok(matcher) => matcher, - Err(error) => { - return Task::ready(Err(format!("invalid exclude pattern: {error}"))); - } - } - }; - - let query = match SearchQuery::regex( - &input.regex, - false, - input.case_sensitive, - false, - false, - include_matcher, - exclude_matcher, - true, // Always match file include pattern against *full project paths* that start with a project root. - None, - ) { - Ok(query) => query, - Err(error) => return Task::ready(Err(error.to_string())), - }; - - let results = self - .project - .update(cx, |project, cx| project.search(query, cx)); - - let project = self.project.downgrade(); + let project = self.project.clone(); cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + + let results = cx.update(|cx| { + let path_style = project.read(cx).path_style(cx); + + let include_matcher = PathMatcher::new( + input + .include_pattern + .as_ref() + .into_iter() + .collect::>(), + path_style, + ) + .map_err(|error| format!("invalid include glob pattern: {error}"))?; + + // Exclude global file_scan_exclusions and private_files settings + let exclude_matcher = { + let global_settings = WorktreeSettings::get_global(cx); + let exclude_patterns = global_settings + .file_scan_exclusions + .sources() + .chain(global_settings.private_files.sources()); + + PathMatcher::new(exclude_patterns, path_style) + .map_err(|error| format!("invalid exclude pattern: {error}"))? + }; + + let query = SearchQuery::regex( + &input.regex, + false, + input.case_sensitive, + false, + false, + include_matcher, + exclude_matcher, + true, // Always match file include pattern against *full project paths* that start with a project root. + None, + ) + .map_err(|error| error.to_string())?; + + Ok::<_, String>( + project.update(cx, |project, cx| project.search(query, cx)), + ) + })?; + + let project = project.downgrade(); // Keep the search alive for the duration of result iteration. Dropping this task is the // cancellation mechanism; we intentionally do not detach it. let SearchResults {rx, _task_handle} = results; @@ -787,7 +785,13 @@ mod tests { cx: &mut TestAppContext, ) -> String { let tool = Arc::new(GrepTool { project }); - let task = cx.update(|cx| tool.run(input, ToolCallEventStream::test().0, cx)); + let task = cx.update(|cx| { + tool.run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }); match task.await { Ok(result) => { diff --git a/crates/agent/src/tools/list_directory_tool.rs b/crates/agent/src/tools/list_directory_tool.rs index 5dddee94904283..1a674aaa71fef5 100644 --- a/crates/agent/src/tools/list_directory_tool.rs +++ b/crates/agent/src/tools/list_directory_tool.rs @@ -2,7 +2,7 @@ use super::tool_permissions::{ ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots, resolve_project_path, }; -use crate::{AgentTool, ToolCallEventStream}; +use crate::{AgentTool, ToolCallEventStream, ToolInput}; use agent_client_protocol::ToolKind; use anyhow::{Context as _, Result, anyhow}; use gpui::{App, Entity, SharedString, Task}; @@ -146,34 +146,39 @@ impl AgentTool for ListDirectoryTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - // Sometimes models will return these even though we tell it to give a path and not a glob. - // When this happens, just list the root worktree directories. - if matches!(input.path.as_str(), "." | "" | "./" | "*") { - let output = self - .project - .read(cx) - .worktrees(cx) - .filter_map(|worktree| { - let worktree = worktree.read(cx); - let root_entry = worktree.root_entry()?; - if root_entry.is_dir() { - Some(root_entry.path.display(worktree.path_style())) - } else { - None - } - }) - .collect::>() - .join("\n"); - - return Task::ready(Ok(output)); - } - let project = self.project.clone(); cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + + // Sometimes models will return these even though we tell it to give a path and not a glob. + // When this happens, just list the root worktree directories. + if matches!(input.path.as_str(), "." | "" | "./" | "*") { + let output = project.read_with(cx, |project, cx| { + project + .worktrees(cx) + .filter_map(|worktree| { + let worktree = worktree.read(cx); + let root_entry = worktree.root_entry()?; + if root_entry.is_dir() { + Some(root_entry.path.display(worktree.path_style())) + } else { + None + } + }) + .collect::>() + .join("\n") + }); + + return Ok(output); + } + let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; @@ -323,7 +328,13 @@ mod tests { path: "project".into(), }; let output = cx - .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .update(|cx| { + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }) .await .unwrap(); assert_eq!( @@ -344,7 +355,13 @@ mod tests { path: "project/src".into(), }; let output = cx - .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .update(|cx| { + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }) .await .unwrap(); assert_eq!( @@ -365,7 +382,13 @@ mod tests { path: "project/tests".into(), }; let output = cx - .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .update(|cx| { + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }) .await .unwrap(); assert!(!output.contains("# Folders:")); @@ -393,7 +416,13 @@ mod tests { path: "project/empty_dir".into(), }; let output = cx - .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .update(|cx| { + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }) .await .unwrap(); assert_eq!(output, "project/empty_dir is empty.\n"); @@ -420,7 +449,13 @@ mod tests { path: "project/nonexistent".into(), }; let output = cx - .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .update(|cx| { + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }) .await; assert!(output.unwrap_err().contains("Path not found")); @@ -429,7 +464,13 @@ mod tests { path: "project/file.txt".into(), }; let output = cx - .update(|cx| tool.run(input, ToolCallEventStream::test().0, cx)) + .update(|cx| { + tool.run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }) .await; assert!(output.unwrap_err().contains("is not a directory")); } @@ -493,7 +534,13 @@ mod tests { path: "project".into(), }; let output = cx - .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .update(|cx| { + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }) .await .unwrap(); @@ -520,7 +567,13 @@ mod tests { path: "project/.secretdir".into(), }; let output = cx - .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .update(|cx| { + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }) .await; assert!( output.unwrap_err().contains("file_scan_exclusions"), @@ -532,7 +585,13 @@ mod tests { path: "project/visible_dir".into(), }; let output = cx - .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .update(|cx| { + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }) .await .unwrap(); @@ -637,7 +696,13 @@ mod tests { path: "worktree1/src".into(), }; let output = cx - .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .update(|cx| { + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }) .await .unwrap(); assert!(output.contains("main.rs"), "Should list main.rs"); @@ -655,7 +720,13 @@ mod tests { path: "worktree1/tests".into(), }; let output = cx - .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .update(|cx| { + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }) .await .unwrap(); assert!(output.contains("test.rs"), "Should list test.rs"); @@ -669,7 +740,13 @@ mod tests { path: "worktree2/lib".into(), }; let output = cx - .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .update(|cx| { + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }) .await .unwrap(); assert!(output.contains("public.js"), "Should list public.js"); @@ -687,7 +764,13 @@ mod tests { path: "worktree2/docs".into(), }; let output = cx - .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .update(|cx| { + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }) .await .unwrap(); assert!(output.contains("README.md"), "Should list README.md"); @@ -701,7 +784,13 @@ mod tests { path: "worktree1/src/secret.rs".into(), }; let output = cx - .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx)) + .update(|cx| { + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }) .await; assert!(output.unwrap_err().contains("Cannot list directory"),); } @@ -743,9 +832,9 @@ mod tests { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.clone().run( - ListDirectoryToolInput { + ToolInput::resolved(ListDirectoryToolInput { path: "project/link_to_external".into(), - }, + }), event_stream, cx, ) @@ -804,9 +893,9 @@ mod tests { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.clone().run( - ListDirectoryToolInput { + ToolInput::resolved(ListDirectoryToolInput { path: "project/link_to_external".into(), - }, + }), event_stream, cx, ) @@ -871,9 +960,9 @@ mod tests { let result = cx .update(|cx| { tool.clone().run( - ListDirectoryToolInput { + ToolInput::resolved(ListDirectoryToolInput { path: "project/link_to_external".into(), - }, + }), event_stream, cx, ) @@ -924,9 +1013,9 @@ mod tests { let result = cx .update(|cx| { tool.clone().run( - ListDirectoryToolInput { + ToolInput::resolved(ListDirectoryToolInput { path: "project/src".into(), - }, + }), event_stream, cx, ) @@ -981,9 +1070,9 @@ mod tests { let result = cx .update(|cx| { tool.clone().run( - ListDirectoryToolInput { + ToolInput::resolved(ListDirectoryToolInput { path: "project/link_dir".into(), - }, + }), event_stream, cx, ) diff --git a/crates/agent/src/tools/move_path_tool.rs b/crates/agent/src/tools/move_path_tool.rs index 4c337d0ec2827a..c246b3c5b06615 100644 --- a/crates/agent/src/tools/move_path_tool.rs +++ b/crates/agent/src/tools/move_path_tool.rs @@ -2,7 +2,9 @@ use super::tool_permissions::{ SensitiveSettingsKind, authorize_symlink_escapes, canonicalize_worktree_roots, collect_symlink_escapes, sensitive_settings_kind, }; -use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_paths}; +use crate::{ + AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_paths, +}; use agent_client_protocol::ToolKind; use agent_settings::AgentSettings; use futures::FutureExt as _; @@ -92,19 +94,24 @@ impl AgentTool for MovePathTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let settings = AgentSettings::get_global(cx); - let paths = vec![input.source_path.clone(), input.destination_path.clone()]; - let decision = decide_permission_for_paths(Self::NAME, &paths, settings); - if let ToolPermissionDecision::Deny(reason) = decision { - return Task::ready(Err(reason)); - } - let project = self.project.clone(); cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + let paths = vec![input.source_path.clone(), input.destination_path.clone()]; + let decision = cx.update(|cx| { + decide_permission_for_paths(Self::NAME, &paths, AgentSettings::get_global(cx)) + }); + if let ToolPermissionDecision::Deny(reason) = decision { + return Err(reason); + } + let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; @@ -255,7 +262,7 @@ mod tests { }; let (event_stream, mut event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.run(input, event_stream, cx)); + let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); let auth = event_rx.expect_authorization().await; let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); @@ -309,7 +316,7 @@ mod tests { }; let (event_stream, mut event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.run(input, event_stream, cx)); + let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); let auth = event_rx.expect_authorization().await; drop(auth); @@ -361,7 +368,7 @@ mod tests { }; let (event_stream, mut event_rx) = ToolCallEventStream::test(); - let task = cx.update(|cx| tool.run(input, event_stream, cx)); + let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)); let auth = event_rx.expect_authorization().await; let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); @@ -437,7 +444,9 @@ mod tests { }; let (event_stream, mut event_rx) = ToolCallEventStream::test(); - let result = cx.update(|cx| tool.run(input, event_stream, cx)).await; + let result = cx + .update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx)) + .await; assert!(result.is_err(), "Tool should fail when policy denies"); assert!( diff --git a/crates/agent/src/tools/now_tool.rs b/crates/agent/src/tools/now_tool.rs index 689d70ff20d15c..fe1cafe5881d14 100644 --- a/crates/agent/src/tools/now_tool.rs +++ b/crates/agent/src/tools/now_tool.rs @@ -6,7 +6,7 @@ use gpui::{App, SharedString, Task}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::{AgentTool, ToolCallEventStream}; +use crate::{AgentTool, ToolCallEventStream, ToolInput}; #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -48,14 +48,20 @@ impl AgentTool for NowTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, _event_stream: ToolCallEventStream, - _cx: &mut App, + cx: &mut App, ) -> Task> { - let now = match input.timezone { - Timezone::Utc => Utc::now().to_rfc3339(), - Timezone::Local => Local::now().to_rfc3339(), - }; - Task::ready(Ok(format!("The current datetime is {now}."))) + cx.spawn(async move |_cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + let now = match input.timezone { + Timezone::Utc => Utc::now().to_rfc3339(), + Timezone::Local => Local::now().to_rfc3339(), + }; + Ok(format!("The current datetime is {now}.")) + }) } } diff --git a/crates/agent/src/tools/open_tool.rs b/crates/agent/src/tools/open_tool.rs index c0b24efbec6418..344a513d10c2d6 100644 --- a/crates/agent/src/tools/open_tool.rs +++ b/crates/agent/src/tools/open_tool.rs @@ -2,7 +2,7 @@ use super::tool_permissions::{ ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots, resolve_project_path, }; -use crate::AgentTool; +use crate::{AgentTool, ToolInput}; use agent_client_protocol::ToolKind; use futures::FutureExt as _; use gpui::{App, AppContext as _, Entity, SharedString, Task}; @@ -61,16 +61,24 @@ impl AgentTool for OpenTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: crate::ToolCallEventStream, cx: &mut App, ) -> Task> { - // If path_or_url turns out to be a path in the project, make it absolute. - let abs_path = to_absolute_path(&input.path_or_url, self.project.clone(), cx); - let initial_title = self.initial_title(Ok(input.clone()), cx); - let project = self.project.clone(); cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + + // If path_or_url turns out to be a path in the project, make it absolute. + let (abs_path, initial_title) = cx.update(|cx| { + let abs_path = to_absolute_path(&input.path_or_url, project.clone(), cx); + let initial_title = self.initial_title(Ok(input.clone()), cx); + (abs_path, initial_title) + }); + let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs index efd33fe5caece4..bbc67cf68c7d10 100644 --- a/crates/agent/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -21,7 +21,7 @@ use super::tool_permissions::{ ResolvedProjectPath, authorize_symlink_access, canonicalize_worktree_roots, resolve_project_path, }; -use crate::{AgentTool, Thread, ToolCallEventStream, outline}; +use crate::{AgentTool, Thread, ToolCallEventStream, ToolInput, outline}; /// Reads the content of the given file in the project. /// @@ -114,7 +114,7 @@ impl AgentTool for ReadFileTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { @@ -122,6 +122,10 @@ impl AgentTool for ReadFileTool { let thread = self.thread.clone(); let action_log = self.action_log.clone(); cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(tool_content_err)?; let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; @@ -398,7 +402,7 @@ mod test { start_line: None, end_line: None, }; - tool.run(input, event_stream, cx) + tool.run(ToolInput::resolved(input), event_stream, cx) }) .await; assert_eq!( @@ -442,7 +446,11 @@ mod test { start_line: None, end_line: None, }; - tool.run(input, ToolCallEventStream::test().0, cx) + tool.run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; assert_eq!(result.unwrap(), "This is a small file content".into()); @@ -485,7 +493,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await .unwrap(); @@ -510,7 +522,11 @@ mod test { start_line: None, end_line: None, }; - tool.run(input, ToolCallEventStream::test().0, cx) + tool.run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await .unwrap(); @@ -570,7 +586,11 @@ mod test { start_line: Some(2), end_line: Some(4), }; - tool.run(input, ToolCallEventStream::test().0, cx) + tool.run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4\n".into()); @@ -613,7 +633,11 @@ mod test { start_line: Some(0), end_line: Some(2), }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; assert_eq!(result.unwrap(), "Line 1\nLine 2\n".into()); @@ -626,7 +650,11 @@ mod test { start_line: Some(1), end_line: Some(0), }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; assert_eq!(result.unwrap(), "Line 1\n".into()); @@ -639,7 +667,11 @@ mod test { start_line: Some(3), end_line: Some(2), }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; assert_eq!(result.unwrap(), "Line 3\n".into()); @@ -744,7 +776,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; assert!( @@ -760,7 +796,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; assert!( @@ -776,7 +816,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; assert!( @@ -791,7 +835,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; assert!( @@ -807,7 +855,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; assert!( @@ -822,7 +874,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; assert!( @@ -837,7 +893,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; assert!( @@ -853,7 +913,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; assert!(result.is_ok(), "Should be able to read normal files"); @@ -867,7 +931,11 @@ mod test { start_line: None, end_line: None, }; - tool.run(input, ToolCallEventStream::test().0, cx) + tool.run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; assert!( @@ -911,11 +979,11 @@ mod test { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let read_task = cx.update(|cx| { tool.run( - ReadFileToolInput { + ToolInput::resolved(ReadFileToolInput { path: "root/secret.png".to_string(), start_line: None, end_line: None, - }, + }), event_stream, cx, ) @@ -1039,7 +1107,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await .unwrap(); @@ -1057,7 +1129,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; @@ -1075,7 +1151,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; @@ -1093,7 +1173,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await .unwrap(); @@ -1111,7 +1195,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; @@ -1129,7 +1217,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; @@ -1148,7 +1240,11 @@ mod test { start_line: None, end_line: None, }; - tool.clone().run(input, ToolCallEventStream::test().0, cx) + tool.clone().run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; @@ -1210,11 +1306,11 @@ mod test { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.clone().run( - ReadFileToolInput { + ToolInput::resolved(ReadFileToolInput { path: "project/secret_link.txt".to_string(), start_line: None, end_line: None, - }, + }), event_stream, cx, ) @@ -1286,11 +1382,11 @@ mod test { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.clone().run( - ReadFileToolInput { + ToolInput::resolved(ReadFileToolInput { path: "project/secret_link.txt".to_string(), start_line: None, end_line: None, - }, + }), event_stream, cx, ) @@ -1367,11 +1463,11 @@ mod test { let result = cx .update(|cx| { tool.clone().run( - ReadFileToolInput { + ToolInput::resolved(ReadFileToolInput { path: "project/secret_link.txt".to_string(), start_line: None, end_line: None, - }, + }), event_stream, cx, ) diff --git a/crates/agent/src/tools/restore_file_from_disk_tool.rs b/crates/agent/src/tools/restore_file_from_disk_tool.rs index 304e0d1180fe62..c1aa8690a840ea 100644 --- a/crates/agent/src/tools/restore_file_from_disk_tool.rs +++ b/crates/agent/src/tools/restore_file_from_disk_tool.rs @@ -17,7 +17,9 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use util::markdown::MarkdownInlineCode; -use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_path}; +use crate::{ + AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_path, +}; /// Discards unsaved changes in open buffers by reloading file contents from disk. /// @@ -66,25 +68,31 @@ impl AgentTool for RestoreFileFromDiskTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let settings = AgentSettings::get_global(cx).clone(); - - // Check for any immediate deny before spawning async work. - for path in &input.paths { - let path_str = path.to_string_lossy(); - let decision = decide_permission_for_path(Self::NAME, &path_str, &settings); - if let ToolPermissionDecision::Deny(reason) = decision { - return Task::ready(Err(reason)); - } - } - let project = self.project.clone(); - let input_paths = input.paths; cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + + // Check for any immediate deny before doing async work. + for path in &input.paths { + let path_str = path.to_string_lossy(); + let decision = cx.update(|cx| { + decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx)) + }); + if let ToolPermissionDecision::Deny(reason) = decision { + return Err(reason); + } + } + + let input_paths = input.paths; + let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; @@ -92,7 +100,9 @@ impl AgentTool for RestoreFileFromDiskTool { for path in &input_paths { let path_str = path.to_string_lossy(); - let decision = decide_permission_for_path(Self::NAME, &path_str, &settings); + let decision = cx.update(|cx| { + decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx)) + }); let symlink_escape = project.read_with(cx, |project, cx| { path_has_symlink_escape(project, path, &canonical_roots, cx) }); @@ -378,12 +388,12 @@ mod tests { let output = cx .update(|cx| { tool.clone().run( - RestoreFileFromDiskToolInput { + ToolInput::resolved(RestoreFileFromDiskToolInput { paths: vec![ PathBuf::from("root/dirty.txt"), PathBuf::from("root/clean.txt"), ], - }, + }), ToolCallEventStream::test().0, cx, ) @@ -428,7 +438,7 @@ mod tests { let output = cx .update(|cx| { tool.clone().run( - RestoreFileFromDiskToolInput { paths: vec![] }, + ToolInput::resolved(RestoreFileFromDiskToolInput { paths: vec![] }), ToolCallEventStream::test().0, cx, ) @@ -441,9 +451,9 @@ mod tests { let output = cx .update(|cx| { tool.clone().run( - RestoreFileFromDiskToolInput { + ToolInput::resolved(RestoreFileFromDiskToolInput { paths: vec![PathBuf::from("nonexistent/path.txt")], - }, + }), ToolCallEventStream::test().0, cx, ) @@ -495,9 +505,9 @@ mod tests { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.clone().run( - RestoreFileFromDiskToolInput { + ToolInput::resolved(RestoreFileFromDiskToolInput { paths: vec![PathBuf::from("project/link.txt")], - }, + }), event_stream, cx, ) @@ -564,9 +574,9 @@ mod tests { let result = cx .update(|cx| { tool.clone().run( - RestoreFileFromDiskToolInput { + ToolInput::resolved(RestoreFileFromDiskToolInput { paths: vec![PathBuf::from("project/link.txt")], - }, + }), event_stream, cx, ) @@ -623,9 +633,9 @@ mod tests { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.clone().run( - RestoreFileFromDiskToolInput { + ToolInput::resolved(RestoreFileFromDiskToolInput { paths: vec![PathBuf::from("project/link.txt")], - }, + }), event_stream, cx, ) diff --git a/crates/agent/src/tools/save_file_tool.rs b/crates/agent/src/tools/save_file_tool.rs index 20140c77d113d9..99e937b9dff2a1 100644 --- a/crates/agent/src/tools/save_file_tool.rs +++ b/crates/agent/src/tools/save_file_tool.rs @@ -17,7 +17,9 @@ use super::tool_permissions::{ canonicalize_worktree_roots, path_has_symlink_escape, resolve_project_path, sensitive_settings_kind, }; -use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_path}; +use crate::{ + AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_path, +}; /// Saves files that have unsaved changes. /// @@ -63,25 +65,31 @@ impl AgentTool for SaveFileTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let settings = AgentSettings::get_global(cx).clone(); - - // Check for any immediate deny before spawning async work. - for path in &input.paths { - let path_str = path.to_string_lossy(); - let decision = decide_permission_for_path(Self::NAME, &path_str, &settings); - if let ToolPermissionDecision::Deny(reason) = decision { - return Task::ready(Err(reason)); - } - } - let project = self.project.clone(); - let input_paths = input.paths; cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; + + // Check for any immediate deny before doing async work. + for path in &input.paths { + let path_str = path.to_string_lossy(); + let decision = cx.update(|cx| { + decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx)) + }); + if let ToolPermissionDecision::Deny(reason) = decision { + return Err(reason); + } + } + + let input_paths = input.paths; + let fs = project.read_with(cx, |project, _cx| project.fs().clone()); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; @@ -89,7 +97,9 @@ impl AgentTool for SaveFileTool { for path in &input_paths { let path_str = path.to_string_lossy(); - let decision = decide_permission_for_path(Self::NAME, &path_str, &settings); + let decision = cx.update(|cx| { + decide_permission_for_path(Self::NAME, &path_str, AgentSettings::get_global(cx)) + }); let symlink_escape = project.read_with(cx, |project, cx| { path_has_symlink_escape(project, path, &canonical_roots, cx) }); @@ -382,12 +392,12 @@ mod tests { let output = cx .update(|cx| { tool.clone().run( - SaveFileToolInput { + ToolInput::resolved(SaveFileToolInput { paths: vec![ PathBuf::from("root/dirty.txt"), PathBuf::from("root/clean.txt"), ], - }, + }), ToolCallEventStream::test().0, cx, ) @@ -425,7 +435,7 @@ mod tests { let output = cx .update(|cx| { tool.clone().run( - SaveFileToolInput { paths: vec![] }, + ToolInput::resolved(SaveFileToolInput { paths: vec![] }), ToolCallEventStream::test().0, cx, ) @@ -438,9 +448,9 @@ mod tests { let output = cx .update(|cx| { tool.clone().run( - SaveFileToolInput { + ToolInput::resolved(SaveFileToolInput { paths: vec![PathBuf::from("nonexistent/path.txt")], - }, + }), ToolCallEventStream::test().0, cx, ) @@ -490,9 +500,9 @@ mod tests { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.clone().run( - SaveFileToolInput { + ToolInput::resolved(SaveFileToolInput { paths: vec![PathBuf::from("project/link.txt")], - }, + }), event_stream, cx, ) @@ -559,9 +569,9 @@ mod tests { let result = cx .update(|cx| { tool.clone().run( - SaveFileToolInput { + ToolInput::resolved(SaveFileToolInput { paths: vec![PathBuf::from("project/link.txt")], - }, + }), event_stream, cx, ) @@ -618,9 +628,9 @@ mod tests { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.clone().run( - SaveFileToolInput { + ToolInput::resolved(SaveFileToolInput { paths: vec![PathBuf::from("project/link.txt")], - }, + }), event_stream, cx, ) @@ -702,12 +712,12 @@ mod tests { let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.clone().run( - SaveFileToolInput { + ToolInput::resolved(SaveFileToolInput { paths: vec![ PathBuf::from("project/dirty.txt"), PathBuf::from("project/link.txt"), ], - }, + }), event_stream, cx, ) diff --git a/crates/agent/src/tools/spawn_agent_tool.rs b/crates/agent/src/tools/spawn_agent_tool.rs index e2dd78d4476de4..e454377ce1a561 100644 --- a/crates/agent/src/tools/spawn_agent_tool.rs +++ b/crates/agent/src/tools/spawn_agent_tool.rs @@ -8,31 +8,28 @@ use serde::{Deserialize, Serialize}; use std::rc::Rc; use std::sync::Arc; -use crate::{AgentTool, Thread, ThreadEnvironment, ToolCallEventStream}; +use crate::{AgentTool, Thread, ThreadEnvironment, ToolCallEventStream, ToolInput}; /// Spawns an agent to perform a delegated task. /// -/// Use this tool when you want to do any of the following: -/// - Run multiple tasks in parallel that would take significantly longer to run sequentially. -/// - Complete a self-contained task where you need to know if it succeeded or failed (and how), but none of its intermediate output. -/// - Perform an investigation where all you need to know is the outcome, not the research that led to that outcome. -/// -/// You control what the agent does by providing a prompt describing what the agent should do. The agent has access to the same tools you do, but does NOT see your conversation history or any context the user attached. You must include all relevant context (file paths, requirements, constraints) in the prompt. +/// Use this tool when you want to: +/// - Run multiple tasks in parallel. +/// - Delegate a self-contained task where you only need the final outcome. /// /// You will receive only the agent's final message as output. /// -/// If a response (success or error) includes a session_id, you can send a follow-up message to that session by passing the session_id back. This is useful for multi-turn conversations with an agent, asking clarifying questions about its output, or retrying after timeouts or transient failures. +/// **New session** (no session_id): Creates a new agent that does NOT see your conversation history. Include all relevant context (file paths, requirements, constraints) in the message. +/// +/// **Follow-up** (with session_id): Sends a follow-up to an existing agent session. The agent already has full context, so send only a short, direct message — do NOT repeat the original task or context. Examples: "Also update the tests", "Fix the compile error in foo.rs", "Retry". /// -/// Note: -/// - Agents cannot use tools you don't have access to. /// - If spawning multiple agents that might write to the filesystem, provide guidance on how to avoid conflicts (e.g. assign each to different directories). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct SpawnAgentToolInput { /// Short label displayed in the UI while the agent runs (e.g., "Researching alternatives") pub label: String, - /// Describe the task for the agent to perform. Be specific about what you want accomplished. Include all necessary context (file paths, requirements, constraints) since the agent cannot see your conversation. + /// The prompt for the agent. For new sessions, include full context needed for the task. For follow-ups (with session_id), you can rely on the agent already having the previous message. pub message: String, - /// Optional session ID of an existing agent session to continue a conversation with. When provided, the message is sent as a follow-up to that session instead of creating a new one. Use this to ask clarifying questions, request changes based on previous output, or retry after errors. + /// Session ID of an existing agent session to continue instead of creating a new one. #[serde(default)] pub session_id: Option, } @@ -97,61 +94,78 @@ impl AgentTool for SpawnAgentTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let Some(parent_thread_entity) = self.parent_thread.upgrade() else { - return Task::ready(Err(SpawnAgentToolOutput::Error { - session_id: None, - error: "Parent thread no longer exists".to_string(), - })); - }; - - let subagent = if let Some(session_id) = input.session_id { - self.environment - .resume_subagent(parent_thread_entity, session_id, input.message, cx) - } else { - self.environment - .create_subagent(parent_thread_entity, input.label, input.message, cx) - }; - let subagent = match subagent { - Ok(subagent) => subagent, - Err(err) => { - return Task::ready(Err(SpawnAgentToolOutput::Error { + cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| SpawnAgentToolOutput::Error { + session_id: None, + error: format!("Failed to receive tool input: {e}"), + })?; + + let (subagent, subagent_session_id) = cx.update(|cx| { + let Some(parent_thread_entity) = self.parent_thread.upgrade() else { + return Err(SpawnAgentToolOutput::Error { + session_id: None, + error: "Parent thread no longer exists".to_string(), + }); + }; + + let subagent = if let Some(session_id) = input.session_id { + self.environment.resume_subagent( + parent_thread_entity, + session_id, + input.message, + cx, + ) + } else { + self.environment.create_subagent( + parent_thread_entity, + input.label, + input.message, + cx, + ) + }; + let subagent = subagent.map_err(|err| SpawnAgentToolOutput::Error { session_id: None, error: err.to_string(), - })); - } - }; - let subagent_session_id = subagent.id(); - - event_stream.subagent_spawned(subagent_session_id.clone()); - let meta = acp::Meta::from_iter([( - SUBAGENT_SESSION_ID_META_KEY.into(), - subagent_session_id.to_string().into(), - )]); - event_stream.update_fields_with_meta(acp::ToolCallUpdateFields::new(), Some(meta)); - - cx.spawn(async move |cx| match subagent.wait_for_output(cx).await { - Ok(output) => { - event_stream.update_fields( - acp::ToolCallUpdateFields::new().content(vec![output.clone().into()]), - ); - Ok(SpawnAgentToolOutput::Success { - session_id: subagent_session_id, - output, - }) - } - Err(e) => { - let error = e.to_string(); - event_stream.update_fields( - acp::ToolCallUpdateFields::new().content(vec![error.clone().into()]), - ); - Err(SpawnAgentToolOutput::Error { - session_id: Some(subagent_session_id), - error, - }) + })?; + let subagent_session_id = subagent.id(); + + event_stream.subagent_spawned(subagent_session_id.clone()); + let meta = acp::Meta::from_iter([( + SUBAGENT_SESSION_ID_META_KEY.into(), + subagent_session_id.to_string().into(), + )]); + event_stream.update_fields_with_meta(acp::ToolCallUpdateFields::new(), Some(meta)); + + Ok((subagent, subagent_session_id)) + })?; + + match subagent.wait_for_output(cx).await { + Ok(output) => { + event_stream.update_fields( + acp::ToolCallUpdateFields::new().content(vec![output.clone().into()]), + ); + Ok(SpawnAgentToolOutput::Success { + session_id: subagent_session_id, + output, + }) + } + Err(e) => { + let error = e.to_string(); + event_stream.update_fields( + acp::ToolCallUpdateFields::new().content(vec![error.clone().into()]), + ); + Err(SpawnAgentToolOutput::Error { + session_id: Some(subagent_session_id), + error, + }) + } } }) } diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index dd5445142a001f..933fa2ff1e996a 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -2,7 +2,7 @@ use super::edit_file_tool::EditFileTool; use super::restore_file_from_disk_tool::RestoreFileFromDiskTool; use super::save_file_tool::SaveFileTool; use crate::{ - AgentTool, Templates, Thread, ToolCallEventStream, + AgentTool, Thread, ToolCallEventStream, ToolInput, edit_agent::streaming_fuzzy_matcher::StreamingFuzzyMatcher, }; use acp_thread::Diff; @@ -11,8 +11,8 @@ use anyhow::{Context as _, Result, anyhow}; use collections::HashSet; use futures::FutureExt as _; use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; -use language::LanguageRegistry; use language::language_settings::{self, FormatOnSave}; +use language::{Buffer, LanguageRegistry}; use language_model::LanguageModelToolResultContent; use project::lsp_store::{FormatTrigger, LspFormatTarget}; use project::{Project, ProjectPath}; @@ -23,8 +23,8 @@ use std::path::PathBuf; use std::sync::Arc; use text::BufferSnapshot; use ui::SharedString; -use util::ResultExt; use util::rel_path::RelPath; +use util::{Deferred, ResultExt, debug_panic}; const DEFAULT_UI_TEXT: &str = "Editing file"; @@ -67,7 +67,7 @@ pub struct StreamingEditFileToolInput { /// /// `frontend/db.js` /// - pub path: PathBuf, + pub path: String, /// The mode of operation on the file. Possible values: /// - 'create': Create a new file if it doesn't exist. Requires 'content' field. @@ -109,12 +109,488 @@ pub struct EditOperation { pub new_text: String, } -#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[derive(Default, Debug, Deserialize)] struct StreamingEditFileToolPartialInput { #[serde(default)] - path: String, + display_description: Option, + #[serde(default)] + path: Option, + #[serde(default)] + mode: Option, + #[serde(default)] + #[allow(dead_code)] + content: Option, + #[serde(default)] + edits: Option>, +} + +#[derive(Default, Debug, Deserialize)] +struct PartialEditOperation { + #[serde(default)] + old_text: Option, #[serde(default)] - display_description: String, + new_text: Option, +} + +enum StreamingEditState { + Idle, + BufferResolved { + abs_path: PathBuf, + buffer: Entity, + old_text: Arc, + diff: Entity, + edit_state: IncrementalEditState, + _finalize_diff_guard: Deferred>, + }, +} + +#[derive(Default)] +struct IncrementalEditState { + applied_count: usize, + in_progress_matcher: Option, + last_old_text_len: usize, +} + +impl StreamingEditState { + async fn finalize( + &mut self, + input: StreamingEditFileToolInput, + tool: &StreamingEditFileTool, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, + ) -> Result { + let remaining_edits_start_ix = match self { + StreamingEditState::Idle => { + *self = Self::transition_to_buffer_resolved( + &input.path, + &input.display_description, + input.mode.clone(), + tool, + event_stream, + cx, + ) + .await?; + 0 + } + StreamingEditState::BufferResolved { edit_state, .. } => edit_state.applied_count, + }; + + let StreamingEditState::BufferResolved { + buffer, + old_text, + diff, + abs_path, + .. + } = self + else { + debug_panic!("Invalid state"); + return Ok(StreamingEditFileToolOutput::Error { + error: "Internal error. Try to apply the edits again".to_string(), + }); + }; + + let result: anyhow::Result = async { + let action_log = tool + .thread + .read_with(cx, |thread, _cx| thread.action_log().clone())?; + + match input.mode { + StreamingEditFileMode::Create | StreamingEditFileMode::Overwrite => { + action_log.update(cx, |log, cx| { + log.buffer_created(buffer.clone(), cx); + }); + let content = input.content.ok_or_else(|| { + anyhow!("'content' field is required for create and overwrite modes") + })?; + cx.update(|cx| { + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..buffer.len(), content.as_str())], None, cx); + }); + action_log.update(cx, |log, cx| { + log.buffer_edited(buffer.clone(), cx); + }); + }); + } + StreamingEditFileMode::Edit => { + let edits = input + .edits + .ok_or_else(|| anyhow!("'edits' field is required for edit mode"))?; + + let remaining_edits = &edits[remaining_edits_start_ix..]; + apply_edits( + &buffer, + &action_log, + remaining_edits, + &diff, + event_stream, + &abs_path, + cx, + )?; + } + } + + let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| { + let settings = language_settings::language_settings( + buffer.language().map(|l| l.name()), + buffer.file(), + cx, + ); + settings.format_on_save != FormatOnSave::Off + }); + + if format_on_save_enabled { + action_log.update(cx, |log, cx| { + log.buffer_edited(buffer.clone(), cx); + }); + + let format_task = tool.project.update(cx, |project, cx| { + project.format( + HashSet::from_iter([buffer.clone()]), + LspFormatTarget::Buffers, + false, + FormatTrigger::Save, + cx, + ) + }); + futures::select! { + result = format_task.fuse() => { result.log_err(); }, + _ = event_stream.cancelled_by_user().fuse() => { + anyhow::bail!("Edit cancelled by user"); + } + }; + } + + let save_task = tool + .project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)); + futures::select! { + result = save_task.fuse() => { result?; }, + _ = event_stream.cancelled_by_user().fuse() => { + anyhow::bail!("Edit cancelled by user"); + } + }; + + action_log.update(cx, |log, cx| { + log.buffer_edited(buffer.clone(), cx); + }); + + if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| { + buffer.file().and_then(|file| file.disk_state().mtime()) + }) { + tool.thread.update(cx, |thread, _| { + thread + .file_read_times + .insert(abs_path.to_path_buf(), new_mtime); + })?; + } + + let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let (new_text, unified_diff) = cx + .background_spawn({ + let new_snapshot = new_snapshot.clone(); + let old_text = old_text.clone(); + async move { + let new_text = new_snapshot.text(); + let diff = language::unified_diff(&old_text, &new_text); + (new_text, diff) + } + }) + .await; + + let output = StreamingEditFileToolOutput::Success { + input_path: PathBuf::from(input.path), + new_text, + old_text: old_text.clone(), + diff: unified_diff, + }; + Ok(output) + } + .await; + result.map_err(|e| StreamingEditFileToolOutput::Error { + error: e.to_string(), + }) + } + + async fn process( + &mut self, + partial: StreamingEditFileToolPartialInput, + tool: &StreamingEditFileTool, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, + ) -> Result<(), StreamingEditFileToolOutput> { + match self { + Self::Idle => { + if let Some(path_str) = partial.path + && let Some(display_description) = partial.display_description + && let Some(mode) = partial.mode + { + *self = Self::transition_to_buffer_resolved( + &path_str, + &display_description, + mode, + tool, + event_stream, + cx, + ) + .await?; + } + } + Self::BufferResolved { + abs_path, + buffer, + edit_state, + diff, + .. + } => { + if let Some(edits) = partial.edits { + Self::process_streaming_edits( + buffer, + diff, + edit_state, + &edits, + abs_path, + tool, + event_stream, + cx, + )?; + } + } + } + Ok(()) + } + + async fn transition_to_buffer_resolved( + path_str: &str, + display_description: &str, + mode: StreamingEditFileMode, + tool: &StreamingEditFileTool, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, + ) -> Result { + let path = PathBuf::from(path_str); + let project_path = cx + .update(|cx| resolve_path(mode, &path, &tool.project, cx)) + .map_err(|e| StreamingEditFileToolOutput::Error { + error: e.to_string(), + })?; + + let Some(abs_path) = cx.update(|cx| tool.project.read(cx).absolute_path(&project_path, cx)) + else { + return Err(StreamingEditFileToolOutput::Error { + error: format!("File '{path_str}' does not exist"), + }); + }; + + event_stream.update_fields( + ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path.clone())]), + ); + + cx.update(|cx| tool.authorize(&path, &display_description, event_stream, cx)) + .await + .map_err(|e| StreamingEditFileToolOutput::Error { + error: e.to_string(), + })?; + + let buffer = tool + .project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .map_err(|e| StreamingEditFileToolOutput::Error { + error: e.to_string(), + })?; + + ensure_buffer_saved(&buffer, &abs_path, tool, cx)?; + + let diff = cx.new(|cx| Diff::new(buffer.clone(), cx)); + event_stream.update_diff(diff.clone()); + let finalize_diff_guard = util::defer(Box::new({ + let diff = diff.downgrade(); + let mut cx = cx.clone(); + move || { + diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok(); + } + }) as Box); + + let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); + let old_text = cx + .background_spawn({ + let old_snapshot = old_snapshot.clone(); + async move { Arc::new(old_snapshot.text()) } + }) + .await; + + Ok(Self::BufferResolved { + abs_path, + buffer, + old_text, + diff, + edit_state: IncrementalEditState::default(), + _finalize_diff_guard: finalize_diff_guard, + }) + } + + fn process_streaming_edits( + buffer: &Entity, + diff: &Entity, + edit_state: &mut IncrementalEditState, + edits: &[PartialEditOperation], + abs_path: &PathBuf, + tool: &StreamingEditFileTool, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, + ) -> Result<(), StreamingEditFileToolOutput> { + if edits.is_empty() { + return Ok(()); + } + + // Edits at indices applied_count..edits.len()-1 are newly complete + // (a subsequent edit exists, proving the LLM moved on). + // The last edit (edits.len()-1) is potentially still in progress. + let completed_count = edits.len().saturating_sub(1); + + // Apply newly-complete edits + while edit_state.applied_count < completed_count { + let edit_index = edit_state.applied_count; + let partial_edit = &edits[edit_index]; + + let old_text = match &partial_edit.old_text { + Some(t) => t.clone(), + None => { + edit_state.applied_count += 1; + continue; + } + }; + let new_text = partial_edit.new_text.clone().unwrap_or_default(); + + edit_state.in_progress_matcher = None; + edit_state.last_old_text_len = 0; + + let edit_op = EditOperation { + old_text: old_text.clone(), + new_text: new_text.clone(), + }; + + let action_log = tool + .thread + .read_with(cx, |thread, _cx| thread.action_log().clone()) + .ok(); + + // On the first edit, mark the buffer as read + if edit_state.applied_count == 0 { + if let Some(action_log) = &action_log { + action_log.update(cx, |log, cx| { + log.buffer_read(buffer.clone(), cx); + }); + } + } + + resolve_reveal_and_apply_edit( + buffer, + diff, + &edit_op, + edit_index, + abs_path, + action_log.as_ref(), + event_stream, + cx, + ) + .map_err(|e| StreamingEditFileToolOutput::Error { + error: e.to_string(), + })?; + + edit_state.applied_count += 1; + } + + // Feed the in-progress last edit's old_text to the matcher for live preview + if let Some(partial_edit) = edits.last() { + if let Some(old_text) = &partial_edit.old_text { + let old_text_len = old_text.len(); + if old_text_len > edit_state.last_old_text_len { + let new_chunk = &old_text[edit_state.last_old_text_len..]; + + let matcher = edit_state.in_progress_matcher.get_or_insert_with(|| { + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot()); + StreamingFuzzyMatcher::new(snapshot) + }); + + if let Some(match_range) = matcher.push(new_chunk, None) { + let anchor_range = buffer.read_with(cx, |buffer, _cx| { + buffer.anchor_range_between(match_range.clone()) + }); + diff.update(cx, |card, cx| card.reveal_range(anchor_range, cx)); + } + + edit_state.last_old_text_len = old_text_len; + } + } + } + + Ok(()) + } +} + +fn ensure_buffer_saved( + buffer: &Entity, + abs_path: &PathBuf, + tool: &StreamingEditFileTool, + cx: &mut AsyncApp, +) -> Result<(), StreamingEditFileToolOutput> { + let check_result = tool.thread.update(cx, |thread, cx| { + let last_read = thread.file_read_times.get(abs_path).copied(); + let current = buffer + .read(cx) + .file() + .and_then(|file| file.disk_state().mtime()); + let dirty = buffer.read(cx).is_dirty(); + let has_save = thread.has_tool(SaveFileTool::NAME); + let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME); + (last_read, current, dirty, has_save, has_restore) + }); + + let Ok((last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool)) = + check_result + else { + return Ok(()); + }; + + if is_dirty { + let message = match (has_save_tool, has_restore_tool) { + (true, true) => { + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ + If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ + If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." + } + (true, false) => { + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ + If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ + If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed." + } + (false, true) => { + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ + If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \ + If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." + } + (false, false) => { + "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \ + then ask them to save or revert the file manually and inform you when it's ok to proceed." + } + }; + return Err(StreamingEditFileToolOutput::Error { + error: message.to_string(), + }); + } + + if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) { + if current != last_read { + return Err(StreamingEditFileToolOutput::Error { + error: "The file has been modified since you last read it. \ + Please read the file again to get the current state before editing it." + .to_string(), + }); + } + } + + Ok(()) } #[derive(Debug, Serialize, Deserialize)] @@ -164,8 +640,6 @@ pub struct StreamingEditFileTool { thread: WeakEntity, language_registry: Arc, project: Entity, - #[allow(dead_code)] - templates: Arc, } impl StreamingEditFileTool { @@ -173,35 +647,25 @@ impl StreamingEditFileTool { project: Entity, thread: WeakEntity, language_registry: Arc, - templates: Arc, ) -> Self { Self { project, thread, language_registry, - templates, - } - } - - pub fn with_thread(&self, new_thread: WeakEntity) -> Self { - Self { - project: self.project.clone(), - thread: new_thread, - language_registry: self.language_registry.clone(), - templates: self.templates.clone(), } } fn authorize( &self, - input: &StreamingEditFileToolInput, + path: &PathBuf, + description: &str, event_stream: &ToolCallEventStream, cx: &mut App, ) -> Task> { super::tool_permissions::authorize_file_edit( EditFileTool::NAME, - &input.path, - &input.display_description, + path, + description, &self.thread, event_stream, cx, @@ -215,6 +679,10 @@ impl AgentTool for StreamingEditFileTool { const NAME: &'static str = "streaming_edit_file"; + fn supports_input_streaming() -> bool { + true + } + fn kind() -> acp::ToolKind { acp::ToolKind::Edit } @@ -234,28 +702,30 @@ impl AgentTool for StreamingEditFileTool { .read(cx) .short_full_path_for_project_path(&project_path, cx) }) - .unwrap_or(input.path.to_string_lossy().into_owned()) + .unwrap_or(input.path) .into(), Err(raw_input) => { if let Some(input) = serde_json::from_value::(raw_input).ok() { - let path = input.path.trim(); + let path = input.path.unwrap_or_default(); + let path = path.trim(); if !path.is_empty() { return self .project .read(cx) - .find_project_path(&input.path, cx) + .find_project_path(&path, cx) .and_then(|project_path| { self.project .read(cx) .short_full_path_for_project_path(&project_path, cx) }) - .unwrap_or(input.path) + .unwrap_or_else(|| path.to_string()) .into(); } - let description = input.display_description.trim(); + let description = input.display_description.unwrap_or_default(); + let description = description.trim(); if !description.is_empty() { return description.to_string().into(); } @@ -268,227 +738,36 @@ impl AgentTool for StreamingEditFileTool { fn run( self: Arc, - input: Self::Input, + mut input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let Ok(project) = self - .thread - .read_with(cx, |thread, _cx| thread.project().clone()) - else { - return Task::ready(Err(StreamingEditFileToolOutput::Error { - error: "thread was dropped".to_string(), - })); - }; - - let project_path = match resolve_path(&input, project.clone(), cx) { - Ok(path) => path, - Err(err) => { - return Task::ready(Err(StreamingEditFileToolOutput::Error { - error: err.to_string(), - })); - } - }; - - let abs_path = project.read(cx).absolute_path(&project_path, cx); - if let Some(abs_path) = abs_path.clone() { - event_stream.update_fields( - ToolCallUpdateFields::new().locations(vec![acp::ToolCallLocation::new(abs_path)]), - ); - } - - let authorize = self.authorize(&input, &event_stream, cx); - cx.spawn(async move |cx: &mut AsyncApp| { - let result: anyhow::Result = async { - authorize.await?; - - let buffer = project - .update(cx, |project, cx| { - project.open_buffer(project_path.clone(), cx) - }) - .await?; - - if let Some(abs_path) = abs_path.as_ref() { - let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) = - self.thread.update(cx, |thread, cx| { - let last_read = thread.file_read_times.get(abs_path).copied(); - let current = buffer - .read(cx) - .file() - .and_then(|file| file.disk_state().mtime()); - let dirty = buffer.read(cx).is_dirty(); - let has_save = thread.has_tool(SaveFileTool::NAME); - let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME); - (last_read, current, dirty, has_save, has_restore) - })?; - - if is_dirty { - let message = match (has_save_tool, has_restore_tool) { - (true, true) => { - "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ - If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ - If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." - } - (true, false) => { - "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ - If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \ - If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed." - } - (false, true) => { - "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \ - If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \ - If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit." - } - (false, false) => { - "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \ - then ask them to save or revert the file manually and inform you when it's ok to proceed." - } - }; - anyhow::bail!("{}", message); - } - - if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) { - if current != last_read { - anyhow::bail!( - "The file {} has been modified since you last read it. \ - Please read the file again to get the current state before editing it.", - input.path.display() - ); + let mut state = StreamingEditState::Idle; + loop { + futures::select! { + partial = input.recv_partial().fuse() => { + let Some(partial_value) = partial else { break }; + if let Ok(parsed) = serde_json::from_value::(partial_value) { + state.process(parsed, &self, &event_stream, cx).await?; } } - } - - let diff = cx.new(|cx| Diff::new(buffer.clone(), cx)); - event_stream.update_diff(diff.clone()); - let _finalize_diff = util::defer({ - let diff = diff.downgrade(); - let mut cx = cx.clone(); - move || { - diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok(); - } - }); - - let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - let old_text = cx - .background_spawn({ - let old_snapshot = old_snapshot.clone(); - async move { Arc::new(old_snapshot.text()) } - }) - .await; - - let action_log = self.thread.read_with(cx, |thread, _cx| thread.action_log().clone())?; - - // Edit the buffer and report edits to the action log as part of the - // same effect cycle, otherwise the edit will be reported as if the - // user made it (due to the buffer subscription in action_log). - match input.mode { - StreamingEditFileMode::Create | StreamingEditFileMode::Overwrite => { - action_log.update(cx, |log, cx| { - log.buffer_created(buffer.clone(), cx); - }); - let content = input.content.ok_or_else(|| { - anyhow!("'content' field is required for create and overwrite modes") - })?; - cx.update(|cx| { - buffer.update(cx, |buffer, cx| { - buffer.edit([(0..buffer.len(), content.as_str())], None, cx); - }); - action_log.update(cx, |log, cx| { - log.buffer_edited(buffer.clone(), cx); - }); - }); - } - StreamingEditFileMode::Edit => { - action_log.update(cx, |log, cx| { - log.buffer_read(buffer.clone(), cx); - }); - let edits = input.edits.ok_or_else(|| { - anyhow!("'edits' field is required for edit mode") - })?; - // apply_edits now handles buffer_edited internally in the same effect cycle - apply_edits(&buffer, &action_log, &edits, &diff, &event_stream, &abs_path, cx)?; - } - } - - let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| { - let settings = language_settings::language_settings( - buffer.language().map(|l| l.name()), - buffer.file(), - cx, - ); - settings.format_on_save != FormatOnSave::Off - }); - - if format_on_save_enabled { - action_log.update(cx, |log, cx| { - log.buffer_edited(buffer.clone(), cx); - }); - - let format_task = project.update(cx, |project, cx| { - project.format( - HashSet::from_iter([buffer.clone()]), - LspFormatTarget::Buffers, - false, - FormatTrigger::Save, - cx, - ) - }); - futures::select! { - result = format_task.fuse() => { result.log_err(); }, - _ = event_stream.cancelled_by_user().fuse() => { - anyhow::bail!("Edit cancelled by user"); - } - }; - } - - let save_task = project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)); - futures::select! { - result = save_task.fuse() => { result?; }, _ = event_stream.cancelled_by_user().fuse() => { - anyhow::bail!("Edit cancelled by user"); - } - }; - - action_log.update(cx, |log, cx| { - log.buffer_edited(buffer.clone(), cx); - }); - - if let Some(abs_path) = abs_path.as_ref() { - if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| { - buffer.file().and_then(|file| file.disk_state().mtime()) - }) { - self.thread.update(cx, |thread, _| { - thread.file_read_times.insert(abs_path.to_path_buf(), new_mtime); - })?; + return Err(StreamingEditFileToolOutput::Error { + error: "Edit cancelled by user".to_string(), + }); } } + } + let full_input = + input + .recv() + .await + .map_err(|e| StreamingEditFileToolOutput::Error { + error: format!("Failed to receive tool input: {e}"), + })?; - let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - let (new_text, unified_diff) = cx - .background_spawn({ - let new_snapshot = new_snapshot.clone(); - let old_text = old_text.clone(); - async move { - let new_text = new_snapshot.text(); - let diff = language::unified_diff(&old_text, &new_text); - (new_text, diff) - } - }) - .await; - - let output = StreamingEditFileToolOutput::Success { - input_path: input.path, - new_text, - old_text, - diff: unified_diff, - }; - - Ok(output) - }.await; - result - .map_err(|e| StreamingEditFileToolOutput::Error { error: e.to_string() }) + state.finalize(full_input, &self, &event_stream, cx).await }) } @@ -528,42 +807,28 @@ fn apply_edits( edits: &[EditOperation], diff: &Entity, event_stream: &ToolCallEventStream, - abs_path: &Option, + abs_path: &PathBuf, cx: &mut AsyncApp, ) -> Result<()> { let mut failed_edits = Vec::new(); let mut ambiguous_edits = Vec::new(); let mut resolved_edits: Vec<(Range, String)> = Vec::new(); - // First pass: resolve all edits without applying them let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); for (index, edit) in edits.iter().enumerate() { - let result = resolve_edit(&snapshot, edit); - - match result { - Ok(Some((range, new_text))) => { - // Reveal the range in the diff view - let (start_anchor, end_anchor) = buffer.read_with(cx, |buffer, _cx| { - ( - buffer.anchor_before(range.start), - buffer.anchor_after(range.end), - ) - }); - diff.update(cx, |card, cx| { - card.reveal_range(start_anchor..end_anchor, cx) - }); + match resolve_and_reveal_edit(buffer, diff, &snapshot, edit, cx) { + Ok((range, new_text)) => { resolved_edits.push((range, new_text)); } - Ok(None) => { + Err(EditResolveError::NotFound) => { failed_edits.push(index); } - Err(ranges) => { + Err(EditResolveError::Ambiguous(ranges)) => { ambiguous_edits.push((index, ranges)); } } } - // Check for errors before applying any edits if !failed_edits.is_empty() { let indices = failed_edits .iter() @@ -597,22 +862,17 @@ fn apply_edits( ); } - // Sort edits by position so buffer.edit() can handle offset translation let mut edits_sorted = resolved_edits; edits_sorted.sort_by(|a, b| a.0.start.cmp(&b.0.start)); - // Emit location for the earliest edit in the file if let Some((first_range, _)) = edits_sorted.first() { - if let Some(abs_path) = abs_path.clone() { - let line = snapshot.offset_to_point(first_range.start).row; - event_stream.update_fields( - ToolCallUpdateFields::new() - .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]), - ); - } + let line = snapshot.offset_to_point(first_range.start).row; + event_stream.update_fields( + ToolCallUpdateFields::new() + .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]), + ); } - // Validate no overlaps (sorted ascending by start) for window in edits_sorted.windows(2) { if let [(earlier_range, _), (later_range, _)] = window && (earlier_range.end > later_range.start || earlier_range.start == later_range.start) @@ -632,9 +892,6 @@ fn apply_edits( } } - // Apply all edits in a single batch and report to action_log in the same - // effect cycle. This prevents the buffer subscription from treating these - // as user edits. if !edits_sorted.is_empty() { cx.update(|cx| { buffer.update(cx, |buffer, cx| { @@ -655,40 +912,111 @@ fn apply_edits( Ok(()) } -/// Resolves an edit operation by finding the matching text in the buffer. -/// Returns Ok(Some((range, new_text))) if a unique match is found, -/// Ok(None) if no match is found, or Err(ranges) if multiple matches are found. -fn resolve_edit( - snapshot: &BufferSnapshot, +/// Resolves, reveals, and applies a single edit to the buffer. Emits +/// a location update and reports the change to the action log. +fn resolve_reveal_and_apply_edit( + buffer: &Entity, + diff: &Entity, edit: &EditOperation, -) -> std::result::Result, String)>, Vec>> { - let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone()); - matcher.push(&edit.old_text, None); - let matches = matcher.finish(); + edit_index: usize, + abs_path: &PathBuf, + action_log: Option<&Entity>, + event_stream: &ToolCallEventStream, + cx: &mut AsyncApp, +) -> Result<()> { + let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); - if matches.is_empty() { - return Ok(None); - } + match resolve_and_reveal_edit(buffer, diff, &snapshot, edit, cx) { + Ok((range, new_text)) => { + let line = snapshot.offset_to_point(range.start).row; + event_stream.update_fields( + ToolCallUpdateFields::new() + .locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]), + ); - if matches.len() > 1 { - return Err(matches); - } + if let Some(action_log) = action_log { + cx.update(|cx| { + buffer.update(cx, |buffer, cx| { + buffer.edit([(range, new_text.as_str())], None, cx); + }); + action_log.update(cx, |log, cx| { + log.buffer_edited(buffer.clone(), cx); + }); + }); + } - let match_range = matches.into_iter().next().expect("checked len above"); - Ok(Some((match_range, edit.new_text.clone()))) -} + Ok(()) + } + Err(EditResolveError::NotFound) => { + anyhow::bail!( + "Could not find matching text for edit at index {}. \ + The old_text did not match any content in the file. \ + Please read the file again to get the current content.", + edit_index + ); + } + Err(EditResolveError::Ambiguous(ranges)) => { + let lines = ranges + .iter() + .map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string()) + .collect::>() + .join(", "); + anyhow::bail!( + "Edit {} matched multiple locations in the file at lines: {}. \ + Please provide more context in old_text to uniquely identify the location.", + edit_index, + lines + ); + } + } +} + +enum EditResolveError { + NotFound, + Ambiguous(Vec>), +} + +/// Resolves an edit operation by finding matching text in the buffer, +/// reveals the matched range in the diff view, and returns the resolved +/// range and replacement text. +fn resolve_and_reveal_edit( + buffer: &Entity, + diff: &Entity, + snapshot: &BufferSnapshot, + edit: &EditOperation, + cx: &mut AsyncApp, +) -> std::result::Result<(Range, String), EditResolveError> { + let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone()); + matcher.push(&edit.old_text, None); + let matches = matcher.finish(); + if matches.is_empty() { + return Err(EditResolveError::NotFound); + } + if matches.len() > 1 { + return Err(EditResolveError::Ambiguous(matches)); + } + + let range = matches.into_iter().next().expect("checked len above"); + + let anchor_range = + buffer.read_with(cx, |buffer, _cx| buffer.anchor_range_between(range.clone())); + diff.update(cx, |card, cx| card.reveal_range(anchor_range, cx)); + + Ok((range, edit.new_text.clone())) +} fn resolve_path( - input: &StreamingEditFileToolInput, - project: Entity, + mode: StreamingEditFileMode, + path: &PathBuf, + project: &Entity, cx: &mut App, ) -> Result { let project = project.read(cx); - match input.mode { + match mode { StreamingEditFileMode::Edit | StreamingEditFileMode::Overwrite => { let path = project - .find_project_path(&input.path, cx) + .find_project_path(&path, cx) .context("Can't edit file: path not found")?; let entry = project @@ -700,17 +1028,14 @@ fn resolve_path( } StreamingEditFileMode::Create => { - if let Some(path) = project.find_project_path(&input.path, cx) { + if let Some(path) = project.find_project_path(&path, cx) { anyhow::ensure!( project.entry_for_path(&path, cx).is_none(), "Can't create file: file already exists" ); } - let parent_path = input - .path - .parent() - .context("Can't create file: incorrect path")?; + let parent_path = path.parent().context("Can't create file: incorrect path")?; let parent_project_path = project.find_project_path(&parent_path, cx); @@ -724,8 +1049,7 @@ fn resolve_path( "Can't create file: parent is not a directory" ); - let file_name = input - .path + let file_name = path .file_name() .and_then(|file_name| file_name.to_str()) .and_then(|file_name| RelPath::unix(file_name).ok()) @@ -744,13 +1068,17 @@ fn resolve_path( #[cfg(test)] mod tests { use super::*; - use crate::{ContextServerRegistry, Templates}; + use crate::{ContextServerRegistry, Templates, ToolInputSender}; + use fs::Fs as _; + use futures::StreamExt as _; use gpui::{TestAppContext, UpdateGlobal}; use language_model::fake_provider::FakeLanguageModel; use prompt_store::ProjectContext; use serde_json::json; + use settings::Settings; use settings::SettingsStore; use util::path; + use util::rel_path::rel_path; #[gpui::test] async fn test_streaming_edit_create_file(cx: &mut TestAppContext) { @@ -787,9 +1115,12 @@ mod tests { project.clone(), thread.downgrade(), language_registry, - Templates::new(), )) - .run(input, ToolCallEventStream::test().0, cx) + .run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; @@ -836,9 +1167,12 @@ mod tests { project.clone(), thread.downgrade(), language_registry, - Templates::new(), )) - .run(input, ToolCallEventStream::test().0, cx) + .run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; @@ -896,9 +1230,12 @@ mod tests { project.clone(), thread.downgrade(), language_registry, - Templates::new(), )) - .run(input, ToolCallEventStream::test().0, cx) + .run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; @@ -958,9 +1295,12 @@ mod tests { project.clone(), thread.downgrade(), language_registry, - Templates::new(), )) - .run(input, ToolCallEventStream::test().0, cx) + .run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; @@ -1023,9 +1363,12 @@ mod tests { project.clone(), thread.downgrade(), language_registry, - Templates::new(), )) - .run(input, ToolCallEventStream::test().0, cx) + .run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; @@ -1088,9 +1431,12 @@ mod tests { project.clone(), thread.downgrade(), language_registry, - Templates::new(), )) - .run(input, ToolCallEventStream::test().0, cx) + .run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; @@ -1141,9 +1487,12 @@ mod tests { project, thread.downgrade(), language_registry, - Templates::new(), )) - .run(input, ToolCallEventStream::test().0, cx) + .run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; @@ -1192,9 +1541,12 @@ mod tests { project, thread.downgrade(), language_registry, - Templates::new(), )) - .run(input, ToolCallEventStream::test().0, cx) + .run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; @@ -1262,9 +1614,12 @@ mod tests { project, thread.downgrade(), language_registry, - Templates::new(), )) - .run(input, ToolCallEventStream::test().0, cx) + .run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) }) .await; @@ -1277,6 +1632,2759 @@ mod tests { ); } + #[gpui::test] + async fn test_streaming_early_buffer_open(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "file.txt": "line 1\nline 2\nline 3\n" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let task = cx.update(|cx| tool.run(input, event_stream, cx)); + + // Send partials simulating LLM streaming: description first, then path, then mode + sender.send_partial(json!({"display_description": "Edit lines"})); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Edit lines", + "path": "root/file.txt" + })); + cx.run_until_parked(); + + // Path is NOT yet complete because mode hasn't appeared — no buffer open yet + sender.send_partial(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + // Now send the final complete input + sender.send_final(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "line 2", "new_text": "modified line 2"}] + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n"); + } + + #[gpui::test] + async fn test_streaming_path_completeness_heuristic(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "file.txt": "hello world" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let task = cx.update(|cx| tool.run(input, event_stream, cx)); + + // Send partial with path but NO mode — path should NOT be treated as complete + sender.send_partial(json!({ + "display_description": "Overwrite file", + "path": "root/file" + })); + cx.run_until_parked(); + + // Now the path grows and mode appears + sender.send_partial(json!({ + "display_description": "Overwrite file", + "path": "root/file.txt", + "mode": "overwrite" + })); + cx.run_until_parked(); + + // Send final + sender.send_final(json!({ + "display_description": "Overwrite file", + "path": "root/file.txt", + "mode": "overwrite", + "content": "new content" + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "new content"); + } + + #[gpui::test] + async fn test_streaming_cancellation_during_partials(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "file.txt": "hello world" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver, mut cancellation_tx) = + ToolCallEventStream::test_with_cancellation(); + + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let task = cx.update(|cx| tool.run(input, event_stream, cx)); + + // Send a partial + sender.send_partial(json!({"display_description": "Edit"})); + cx.run_until_parked(); + + // Cancel during streaming + ToolCallEventStream::signal_cancellation_with_sender(&mut cancellation_tx); + cx.run_until_parked(); + + // The sender is still alive so the partial loop should detect cancellation + // We need to drop the sender to also unblock recv() if the loop didn't catch it + drop(sender); + + let result = task.await; + let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else { + panic!("expected error"); + }; + assert!( + error.contains("cancelled"), + "Expected cancellation error but got: {error}" + ); + } + + #[gpui::test] + async fn test_streaming_edit_with_multiple_partials(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let task = cx.update(|cx| tool.run(input, event_stream, cx)); + + // Simulate fine-grained streaming of the JSON + sender.send_partial(json!({"display_description": "Edit multiple"})); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "line 1"}] + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "line 1", "new_text": "modified line 1"}, + {"old_text": "line 5"} + ] + })); + cx.run_until_parked(); + + // Send final complete input + sender.send_final(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "line 1", "new_text": "modified line 1"}, + {"old_text": "line 5", "new_text": "modified line 5"} + ] + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!( + new_text, + "modified line 1\nline 2\nline 3\nline 4\nmodified line 5\n" + ); + } + + #[gpui::test] + async fn test_streaming_create_file_with_partials(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({"dir": {}})).await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let task = cx.update(|cx| tool.run(input, event_stream, cx)); + + // Stream partials for create mode + sender.send_partial(json!({"display_description": "Create new file"})); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Create new file", + "path": "root/dir/new_file.txt", + "mode": "create" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Create new file", + "path": "root/dir/new_file.txt", + "mode": "create", + "content": "Hello, " + })); + cx.run_until_parked(); + + // Final with full content + sender.send_final(json!({ + "display_description": "Create new file", + "path": "root/dir/new_file.txt", + "mode": "create", + "content": "Hello, World!" + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "Hello, World!"); + } + + #[gpui::test] + async fn test_streaming_no_partials_direct_final(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "file.txt": "line 1\nline 2\nline 3\n" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let task = cx.update(|cx| tool.run(input, event_stream, cx)); + + // Send final immediately with no partials (simulates non-streaming path) + sender.send_final(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "line 2", "new_text": "modified line 2"}] + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n"); + } + + #[gpui::test] + async fn test_streaming_incremental_edit_application(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "file.txt": "line 1\nline 2\nline 3\nline 4\nline 5\n" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let task = cx.update(|cx| tool.run(input, event_stream, cx)); + + // Stream description, path, mode + sender.send_partial(json!({"display_description": "Edit multiple lines"})); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + // First edit starts streaming (old_text only, still in progress) + sender.send_partial(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "line 1"}] + })); + cx.run_until_parked(); + + // Buffer should not have changed yet — the first edit is still in progress + // (no second edit has appeared to prove the first is complete) + let buffer_text = project.update(cx, |project, cx| { + let project_path = project.find_project_path(&PathBuf::from("root/file.txt"), cx); + project_path.and_then(|pp| { + project + .get_open_buffer(&pp, cx) + .map(|buffer| buffer.read(cx).text()) + }) + }); + // Buffer is open (from streaming) but edit 1 is still in-progress + assert_eq!( + buffer_text.as_deref(), + Some("line 1\nline 2\nline 3\nline 4\nline 5\n"), + "Buffer should not be modified while first edit is still in progress" + ); + + // Second edit appears — this proves the first edit is complete, so it + // should be applied immediately during streaming + sender.send_partial(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "line 1", "new_text": "MODIFIED 1"}, + {"old_text": "line 5"} + ] + })); + cx.run_until_parked(); + + // First edit should now be applied to the buffer + let buffer_text = project.update(cx, |project, cx| { + let project_path = project.find_project_path(&PathBuf::from("root/file.txt"), cx); + project_path.and_then(|pp| { + project + .get_open_buffer(&pp, cx) + .map(|buffer| buffer.read(cx).text()) + }) + }); + assert_eq!( + buffer_text.as_deref(), + Some("MODIFIED 1\nline 2\nline 3\nline 4\nline 5\n"), + "First edit should be applied during streaming when second edit appears" + ); + + // Send final complete input + sender.send_final(json!({ + "display_description": "Edit multiple lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "line 1", "new_text": "MODIFIED 1"}, + {"old_text": "line 5", "new_text": "MODIFIED 5"} + ] + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { + new_text, old_text, .. + } = result.unwrap() + else { + panic!("expected success"); + }; + assert_eq!(new_text, "MODIFIED 1\nline 2\nline 3\nline 4\nMODIFIED 5\n"); + assert_eq!( + *old_text, "line 1\nline 2\nline 3\nline 4\nline 5\n", + "old_text should reflect the original file content before any edits" + ); + } + + #[gpui::test] + async fn test_streaming_incremental_three_edits(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "file.txt": "aaa\nbbb\nccc\nddd\neee\n" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let task = cx.update(|cx| tool.run(input, event_stream, cx)); + + // Setup: description + path + mode + sender.send_partial(json!({ + "display_description": "Edit three lines", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + // Edit 1 in progress + sender.send_partial(json!({ + "display_description": "Edit three lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "aaa", "new_text": "AAA"}] + })); + cx.run_until_parked(); + + // Edit 2 appears — edit 1 is now complete and should be applied + sender.send_partial(json!({ + "display_description": "Edit three lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "aaa", "new_text": "AAA"}, + {"old_text": "ccc", "new_text": "CCC"} + ] + })); + cx.run_until_parked(); + + // Verify edit 1 applied + let buffer_text = project.update(cx, |project, cx| { + let pp = project + .find_project_path(&PathBuf::from("root/file.txt"), cx) + .unwrap(); + project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text()) + }); + assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nccc\nddd\neee\n")); + + // Edit 3 appears — edit 2 is now complete and should be applied + sender.send_partial(json!({ + "display_description": "Edit three lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "aaa", "new_text": "AAA"}, + {"old_text": "ccc", "new_text": "CCC"}, + {"old_text": "eee", "new_text": "EEE"} + ] + })); + cx.run_until_parked(); + + // Verify edits 1 and 2 both applied + let buffer_text = project.update(cx, |project, cx| { + let pp = project + .find_project_path(&PathBuf::from("root/file.txt"), cx) + .unwrap(); + project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text()) + }); + assert_eq!(buffer_text.as_deref(), Some("AAA\nbbb\nCCC\nddd\neee\n")); + + // Send final + sender.send_final(json!({ + "display_description": "Edit three lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "aaa", "new_text": "AAA"}, + {"old_text": "ccc", "new_text": "CCC"}, + {"old_text": "eee", "new_text": "EEE"} + ] + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "AAA\nbbb\nCCC\nddd\nEEE\n"); + } + + #[gpui::test] + async fn test_streaming_edit_failure_mid_stream(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "file.txt": "line 1\nline 2\nline 3\n" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let task = cx.update(|cx| tool.run(input, event_stream, cx)); + + // Setup + sender.send_partial(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + // Edit 1 (valid) in progress — not yet complete (no second edit) + sender.send_partial(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "line 1", "new_text": "MODIFIED"} + ] + })); + cx.run_until_parked(); + + // Edit 2 appears (will fail to match) — this makes edit 1 complete. + // Edit 1 should be applied. Edit 2 is still in-progress (last edit). + sender.send_partial(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "line 1", "new_text": "MODIFIED"}, + {"old_text": "nonexistent text that does not appear anywhere in the file at all", "new_text": "whatever"} + ] + })); + cx.run_until_parked(); + + // Verify edit 1 was applied + let buffer_text = project.update(cx, |project, cx| { + let pp = project + .find_project_path(&PathBuf::from("root/file.txt"), cx) + .unwrap(); + project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text()) + }); + assert_eq!( + buffer_text.as_deref(), + Some("MODIFIED\nline 2\nline 3\n"), + "First edit should be applied even though second edit will fail" + ); + + // Edit 3 appears — this makes edit 2 "complete", triggering its + // resolution which should fail (old_text doesn't exist in the file). + sender.send_partial(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "line 1", "new_text": "MODIFIED"}, + {"old_text": "nonexistent text that does not appear anywhere in the file at all", "new_text": "whatever"}, + {"old_text": "line 3", "new_text": "MODIFIED 3"} + ] + })); + cx.run_until_parked(); + + // The error from edit 2 should have propagated out of the partial loop. + // Drop sender to unblock recv() if the loop didn't catch it. + drop(sender); + + let result = task.await; + let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else { + panic!("expected error"); + }; + assert!( + error.contains("Could not find matching text for edit at index 1"), + "Expected error about edit 1 failing, got: {error}" + ); + } + + #[gpui::test] + async fn test_streaming_overlapping_edits_detected_naturally(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "file.txt": "line 1\nline 2\nline 3\n" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let task = cx.update(|cx| tool.run(input, event_stream, cx)); + + // Setup + sender.send_partial(json!({ + "display_description": "Overlapping edits", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + // Edit 1 targets "line 1\nline 2" and replaces it. + // Edit 2 targets "line 2\nline 3" — but after edit 1 is applied, + // "line 2" has been removed so this should fail to match. + // Edit 3 exists to make edit 2 "complete" during streaming. + sender.send_partial(json!({ + "display_description": "Overlapping edits", + "path": "root/file.txt", + "mode": "edit", + "edits": [ + {"old_text": "line 1\nline 2", "new_text": "REPLACED"}, + {"old_text": "line 2\nline 3", "new_text": "ALSO REPLACED"}, + {"old_text": "line 3", "new_text": "DUMMY"} + ] + })); + cx.run_until_parked(); + + // Edit 1 was applied, edit 2 should fail since "line 2" no longer exists + drop(sender); + + let result = task.await; + let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else { + panic!("expected error"); + }; + assert!( + error.contains("Could not find matching text for edit at index 1"), + "Expected overlapping edit to fail naturally, got: {error}" + ); + } + + #[gpui::test] + async fn test_streaming_single_edit_no_incremental(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "file.txt": "hello world\n" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let task = cx.update(|cx| tool.run(input, event_stream, cx)); + + // Setup + single edit that stays in-progress (no second edit to prove completion) + sender.send_partial(json!({ + "display_description": "Single edit", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "hello world", "new_text": "goodbye world"}] + })); + cx.run_until_parked(); + + // Buffer should NOT be modified — the single edit is still in-progress + let buffer_text = project.update(cx, |project, cx| { + let pp = project + .find_project_path(&PathBuf::from("root/file.txt"), cx) + .unwrap(); + project.get_open_buffer(&pp, cx).map(|b| b.read(cx).text()) + }); + assert_eq!( + buffer_text.as_deref(), + Some("hello world\n"), + "Single in-progress edit should not be applied during streaming" + ); + + // Send final — the edit is applied during finalization + sender.send_final(json!({ + "display_description": "Single edit", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "hello world", "new_text": "goodbye world"}] + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "goodbye world\n"); + } + + #[gpui::test] + async fn test_streaming_input_partials_then_final(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "file.txt": "line 1\nline 2\nline 3\n" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + + let (sender, input): (ToolInputSender, ToolInput) = + ToolInput::test(); + + let (event_stream, _event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )) + .run(input, event_stream, cx) + }); + + // Send progressively more complete partial snapshots, as the LLM would + sender.send_partial(json!({ + "display_description": "Edit lines" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit" + })); + cx.run_until_parked(); + + sender.send_partial(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "line 2", "new_text": "modified line 2"}] + })); + cx.run_until_parked(); + + // Send the final complete input + sender.send_final(json!({ + "display_description": "Edit lines", + "path": "root/file.txt", + "mode": "edit", + "edits": [{"old_text": "line 2", "new_text": "modified line 2"}] + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "line 1\nmodified line 2\nline 3\n"); + } + + #[gpui::test] + async fn test_streaming_input_sender_dropped_before_final(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "file.txt": "hello world\n" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + + let (sender, input): (ToolInputSender, ToolInput) = + ToolInput::test(); + + let (event_stream, _event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )) + .run(input, event_stream, cx) + }); + + // Send a partial then drop the sender without sending final + sender.send_partial(json!({ + "display_description": "Edit file" + })); + cx.run_until_parked(); + + drop(sender); + + let result = task.await; + assert!( + result.is_err(), + "Tool should error when sender is dropped without sending final input" + ); + } + + #[gpui::test] + async fn test_streaming_input_recv_drains_partials(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({"dir": {}})).await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + + // Create a channel and send multiple partials before a final, then use + // ToolInput::resolved-style immediate delivery to confirm recv() works + // when partials are already buffered. + let (sender, input): (ToolInputSender, ToolInput) = + ToolInput::test(); + + let (event_stream, _event_rx) = ToolCallEventStream::test(); + let task = cx.update(|cx| { + Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )) + .run(input, event_stream, cx) + }); + + // Buffer several partials before sending the final + sender.send_partial(json!({"display_description": "Create"})); + sender.send_partial(json!({"display_description": "Create", "path": "root/dir/new.txt"})); + sender.send_partial(json!({ + "display_description": "Create", + "path": "root/dir/new.txt", + "mode": "create" + })); + sender.send_final(json!({ + "display_description": "Create", + "path": "root/dir/new.txt", + "mode": "create", + "content": "streamed content" + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { new_text, .. } = result.unwrap() else { + panic!("expected success"); + }; + assert_eq!(new_text, "streamed content"); + } + + #[gpui::test] + async fn test_streaming_resolve_path_for_creating_file(cx: &mut TestAppContext) { + let mode = StreamingEditFileMode::Create; + + let result = test_resolve_path(&mode, "root/new.txt", cx); + assert_resolved_path_eq(result.await, rel_path("new.txt")); + + let result = test_resolve_path(&mode, "new.txt", cx); + assert_resolved_path_eq(result.await, rel_path("new.txt")); + + let result = test_resolve_path(&mode, "dir/new.txt", cx); + assert_resolved_path_eq(result.await, rel_path("dir/new.txt")); + + let result = test_resolve_path(&mode, "root/dir/subdir/existing.txt", cx); + assert_eq!( + result.await.unwrap_err().to_string(), + "Can't create file: file already exists" + ); + + let result = test_resolve_path(&mode, "root/dir/nonexistent_dir/new.txt", cx); + assert_eq!( + result.await.unwrap_err().to_string(), + "Can't create file: parent directory doesn't exist" + ); + } + + #[gpui::test] + async fn test_streaming_resolve_path_for_editing_file(cx: &mut TestAppContext) { + let mode = StreamingEditFileMode::Edit; + + let path_with_root = "root/dir/subdir/existing.txt"; + let path_without_root = "dir/subdir/existing.txt"; + let result = test_resolve_path(&mode, path_with_root, cx); + assert_resolved_path_eq(result.await, rel_path(path_without_root)); + + let result = test_resolve_path(&mode, path_without_root, cx); + assert_resolved_path_eq(result.await, rel_path(path_without_root)); + + let result = test_resolve_path(&mode, "root/nonexistent.txt", cx); + assert_eq!( + result.await.unwrap_err().to_string(), + "Can't edit file: path not found" + ); + + let result = test_resolve_path(&mode, "root/dir", cx); + assert_eq!( + result.await.unwrap_err().to_string(), + "Can't edit file: path is a directory" + ); + } + + async fn test_resolve_path( + mode: &StreamingEditFileMode, + path: &str, + cx: &mut TestAppContext, + ) -> anyhow::Result { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "dir": { + "subdir": { + "existing.txt": "hello" + } + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + cx.update(|cx| resolve_path(mode.clone(), &PathBuf::from(path), &project, cx)) + } + + #[track_caller] + fn assert_resolved_path_eq(path: anyhow::Result, expected: &RelPath) { + let actual = path.expect("Should return valid path").path; + assert_eq!(actual.as_ref(), expected); + } + + #[gpui::test] + async fn test_streaming_format_on_save(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({"src": {}})).await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + let rust_language = Arc::new(language::Language::new( + language::LanguageConfig { + name: "Rust".into(), + matcher: language::LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + None, + )); + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_language); + + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + language::FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_formatting_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + ..Default::default() + }, + ); + + fs.save( + path!("/root/src/main.rs").as_ref(), + &"initial content".into(), + language::LineEnding::Unix, + ) + .await + .unwrap(); + + // Open the buffer to trigger LSP initialization + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/src/main.rs"), cx) + }) + .await + .unwrap(); + + // Register the buffer with language servers + let _handle = project.update(cx, |project, cx| { + project.register_buffer_with_language_servers(&buffer, cx) + }); + + const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n"; + const FORMATTED_CONTENT: &str = + "This file was formatted by the fake formatter in the test.\n"; + + // Get the fake language server and set up formatting handler + let fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server.set_request_handler::({ + |_, _| async move { + Ok(Some(vec![lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)), + new_text: FORMATTED_CONTENT.to_string(), + }])) + } + }); + + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model.clone()), + cx, + ) + }); + + // Test with format_on_save enabled + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On); + settings.project.all_languages.defaults.formatter = + Some(language::language_settings::FormatterList::default()); + }); + }); + }); + + // Use streaming pattern so executor can pump the LSP request/response + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry.clone(), + )); + + let task = cx.update(|cx| tool.run(input, event_stream, cx)); + + sender.send_partial(json!({ + "display_description": "Create main function", + "path": "root/src/main.rs", + "mode": "overwrite" + })); + cx.run_until_parked(); + + sender.send_final(json!({ + "display_description": "Create main function", + "path": "root/src/main.rs", + "mode": "overwrite", + "content": UNFORMATTED_CONTENT + })); + + let result = task.await; + assert!(result.is_ok()); + + cx.executor().run_until_parked(); + + let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); + assert_eq!( + new_content.replace("\r\n", "\n"), + FORMATTED_CONTENT, + "Code should be formatted when format_on_save is enabled" + ); + + let stale_buffer_count = thread + .read_with(cx, |thread, _cx| thread.action_log.clone()) + .read_with(cx, |log, cx| log.stale_buffers(cx).count()); + + assert_eq!( + stale_buffer_count, 0, + "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers.", + stale_buffer_count + ); + + // Test with format_on_save disabled + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.format_on_save = + Some(FormatOnSave::Off); + }); + }); + }); + + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let task = cx.update(|cx| tool.run(input, event_stream, cx)); + + sender.send_partial(json!({ + "display_description": "Update main function", + "path": "root/src/main.rs", + "mode": "overwrite" + })); + cx.run_until_parked(); + + sender.send_final(json!({ + "display_description": "Update main function", + "path": "root/src/main.rs", + "mode": "overwrite", + "content": UNFORMATTED_CONTENT + })); + + let result = task.await; + assert!(result.is_ok()); + + cx.executor().run_until_parked(); + + let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); + assert_eq!( + new_content.replace("\r\n", "\n"), + UNFORMATTED_CONTENT, + "Code should not be formatted when format_on_save is disabled" + ); + } + + #[gpui::test] + async fn test_streaming_remove_trailing_whitespace(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({"src": {}})).await; + + fs.save( + path!("/root/src/main.rs").as_ref(), + &"initial content".into(), + language::LineEnding::Unix, + ) + .await + .unwrap(); + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model.clone()), + cx, + ) + }); + + // Test with remove_trailing_whitespace_on_save enabled + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings + .project + .all_languages + .defaults + .remove_trailing_whitespace_on_save = Some(true); + }); + }); + }); + + const CONTENT_WITH_TRAILING_WHITESPACE: &str = + "fn main() { \n println!(\"Hello!\"); \n}\n"; + + let result = cx + .update(|cx| { + let input = StreamingEditFileToolInput { + display_description: "Create main function".into(), + path: "root/src/main.rs".into(), + mode: StreamingEditFileMode::Overwrite, + content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()), + edits: None, + }; + Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry.clone(), + )) + .run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + assert!(result.is_ok()); + + cx.executor().run_until_parked(); + + assert_eq!( + fs.load(path!("/root/src/main.rs").as_ref()) + .await + .unwrap() + .replace("\r\n", "\n"), + "fn main() {\n println!(\"Hello!\");\n}\n", + "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled" + ); + + // Test with remove_trailing_whitespace_on_save disabled + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings + .project + .all_languages + .defaults + .remove_trailing_whitespace_on_save = Some(false); + }); + }); + }); + + let result = cx + .update(|cx| { + let input = StreamingEditFileToolInput { + display_description: "Update main function".into(), + path: "root/src/main.rs".into(), + mode: StreamingEditFileMode::Overwrite, + content: Some(CONTENT_WITH_TRAILING_WHITESPACE.into()), + edits: None, + }; + Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )) + .run( + ToolInput::resolved(input), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + assert!(result.is_ok()); + + cx.executor().run_until_parked(); + + let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap(); + assert_eq!( + final_content.replace("\r\n", "\n"), + CONTENT_WITH_TRAILING_WHITESPACE, + "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled" + ); + } + + #[gpui::test] + async fn test_streaming_authorize(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + fs.insert_tree("/root", json!({})).await; + + // Test 1: Path with .zed component should require confirmation + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx.update(|cx| { + tool.authorize( + &PathBuf::from(".zed/settings.json"), + "test 1", + &stream_tx, + cx, + ) + }); + + let event = stream_rx.expect_authorization().await; + assert_eq!( + event.tool_call.fields.title, + Some("test 1 (local settings)".into()) + ); + + // Test 2: Path outside project should require confirmation + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = + cx.update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), "test 2", &stream_tx, cx)); + + let event = stream_rx.expect_authorization().await; + assert_eq!(event.tool_call.fields.title, Some("test 2".into())); + + // Test 3: Relative path without .zed should not require confirmation + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + cx.update(|cx| { + tool.authorize(&PathBuf::from("root/src/main.rs"), "test 3", &stream_tx, cx) + }) + .await + .unwrap(); + assert!(stream_rx.try_next().is_err()); + + // Test 4: Path with .zed in the middle should require confirmation + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx.update(|cx| { + tool.authorize( + &PathBuf::from("root/.zed/tasks.json"), + "test 4", + &stream_tx, + cx, + ) + }); + let event = stream_rx.expect_authorization().await; + assert_eq!( + event.tool_call.fields.title, + Some("test 4 (local settings)".into()) + ); + + // Test 5: When global default is allow, sensitive and outside-project + // paths still require confirmation + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.default = settings::ToolPermissionMode::Allow; + agent_settings::AgentSettings::override_global(settings, cx); + }); + + // 5.1: .zed/settings.json is a sensitive path — still prompts + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx.update(|cx| { + tool.authorize( + &PathBuf::from(".zed/settings.json"), + "test 5.1", + &stream_tx, + cx, + ) + }); + let event = stream_rx.expect_authorization().await; + assert_eq!( + event.tool_call.fields.title, + Some("test 5.1 (local settings)".into()) + ); + + // 5.2: /etc/hosts is outside the project, but Allow auto-approves + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + cx.update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), "test 5.2", &stream_tx, cx)) + .await + .unwrap(); + assert!(stream_rx.try_next().is_err()); + + // 5.3: Normal in-project path with allow — no confirmation needed + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + cx.update(|cx| { + tool.authorize( + &PathBuf::from("root/src/main.rs"), + "test 5.3", + &stream_tx, + cx, + ) + }) + .await + .unwrap(); + assert!(stream_rx.try_next().is_err()); + + // 5.4: With Confirm default, non-project paths still prompt + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.default = settings::ToolPermissionMode::Confirm; + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx + .update(|cx| tool.authorize(&PathBuf::from("/etc/hosts"), "test 5.4", &stream_tx, cx)); + + let event = stream_rx.expect_authorization().await; + assert_eq!(event.tool_call.fields.title, Some("test 5.4".into())); + } + + #[gpui::test] + async fn test_streaming_authorize_create_under_symlink_with_allow(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({})).await; + fs.insert_tree("/outside", json!({})).await; + fs.insert_symlink("/root/link", PathBuf::from("/outside")) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(StreamingEditFileTool::new( + project, + thread.downgrade(), + language_registry, + )); + + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.default = settings::ToolPermissionMode::Allow; + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let authorize_task = cx.update(|cx| { + tool.authorize( + &PathBuf::from("link/new.txt"), + "create through symlink", + &stream_tx, + cx, + ) + }); + + let event = stream_rx.expect_authorization().await; + assert!( + event + .tool_call + .fields + .title + .as_deref() + .is_some_and(|title| title.contains("points outside the project")), + "Expected symlink escape authorization for create under external symlink" + ); + + event + .response + .send(acp::PermissionOptionId::new("allow")) + .unwrap(); + authorize_task.await.unwrap(); + } + + #[gpui::test] + async fn test_streaming_edit_file_symlink_escape_requests_authorization( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "src": { "main.rs": "fn main() {}" } + }), + ) + .await; + fs.insert_tree( + path!("/outside"), + json!({ + "config.txt": "old content" + }), + ) + .await; + fs.create_symlink( + path!("/root/link_to_external").as_ref(), + PathBuf::from("/outside"), + ) + .await + .unwrap(); + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _authorize_task = cx.update(|cx| { + tool.authorize( + &PathBuf::from("link_to_external/config.txt"), + "edit through symlink", + &stream_tx, + cx, + ) + }); + + let auth = stream_rx.expect_authorization().await; + let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); + assert!( + title.contains("points outside the project"), + "title should mention symlink escape, got: {title}" + ); + } + + #[gpui::test] + async fn test_streaming_edit_file_symlink_escape_denied(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "src": { "main.rs": "fn main() {}" } + }), + ) + .await; + fs.insert_tree( + path!("/outside"), + json!({ + "config.txt": "old content" + }), + ) + .await; + fs.create_symlink( + path!("/root/link_to_external").as_ref(), + PathBuf::from("/outside"), + ) + .await + .unwrap(); + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let authorize_task = cx.update(|cx| { + tool.authorize( + &PathBuf::from("link_to_external/config.txt"), + "edit through symlink", + &stream_tx, + cx, + ) + }); + + let auth = stream_rx.expect_authorization().await; + drop(auth); // deny by dropping + + let result = authorize_task.await; + assert!(result.is_err(), "should fail when denied"); + } + + #[gpui::test] + async fn test_streaming_edit_file_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + let mut settings = agent_settings::AgentSettings::get_global(cx).clone(); + settings.tool_permissions.tools.insert( + "edit_file".into(), + agent_settings::ToolRules { + default: Some(settings::ToolPermissionMode::Deny), + ..Default::default() + }, + ); + agent_settings::AgentSettings::override_global(settings, cx); + }); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "src": { "main.rs": "fn main() {}" } + }), + ) + .await; + fs.insert_tree( + path!("/outside"), + json!({ + "config.txt": "old content" + }), + ) + .await; + fs.create_symlink( + path!("/root/link_to_external").as_ref(), + PathBuf::from("/outside"), + ) + .await + .unwrap(); + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + cx.executor().run_until_parked(); + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let result = cx + .update(|cx| { + tool.authorize( + &PathBuf::from("link_to_external/config.txt"), + "edit through symlink", + &stream_tx, + cx, + ) + }) + .await; + + assert!(result.is_err(), "Tool should fail when policy denies"); + assert!( + !matches!( + stream_rx.try_next(), + Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))) + ), + "Deny policy should not emit symlink authorization prompt", + ); + } + + #[gpui::test] + async fn test_streaming_authorize_global_config(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/project", json!({})).await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let test_cases = vec![ + ( + "/etc/hosts", + true, + "System file should require confirmation", + ), + ( + "/usr/local/bin/script", + true, + "System bin file should require confirmation", + ), + ( + "project/normal_file.rs", + false, + "Normal project file should not require confirmation", + ), + ]; + + for (path, should_confirm, description) in test_cases { + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let auth = + cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx)); + + if should_confirm { + stream_rx.expect_authorization().await; + } else { + auth.await.unwrap(); + assert!( + stream_rx.try_next().is_err(), + "Failed for case: {} - path: {} - expected no confirmation but got one", + description, + path + ); + } + } + } + + #[gpui::test] + async fn test_streaming_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + + fs.insert_tree( + "/workspace/frontend", + json!({ + "src": { + "main.js": "console.log('frontend');" + } + }), + ) + .await; + fs.insert_tree( + "/workspace/backend", + json!({ + "src": { + "main.rs": "fn main() {}" + } + }), + ) + .await; + fs.insert_tree( + "/workspace/shared", + json!({ + ".zed": { + "settings.json": "{}" + } + }), + ) + .await; + + let project = Project::test( + fs.clone(), + [ + path!("/workspace/frontend").as_ref(), + path!("/workspace/backend").as_ref(), + path!("/workspace/shared").as_ref(), + ], + cx, + ) + .await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry.clone(), + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let test_cases = vec![ + ("frontend/src/main.js", false, "File in first worktree"), + ("backend/src/main.rs", false, "File in second worktree"), + ( + "shared/.zed/settings.json", + true, + ".zed file in third worktree", + ), + ("/etc/hosts", true, "Absolute path outside all worktrees"), + ( + "../outside/file.txt", + true, + "Relative path outside worktrees", + ), + ]; + + for (path, should_confirm, description) in test_cases { + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let auth = + cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx)); + + if should_confirm { + stream_rx.expect_authorization().await; + } else { + auth.await.unwrap(); + assert!( + stream_rx.try_next().is_err(), + "Failed for case: {} - path: {} - expected no confirmation but got one", + description, + path + ); + } + } + } + + #[gpui::test] + async fn test_streaming_needs_confirmation_edge_cases(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + ".zed": { + "settings.json": "{}" + }, + "src": { + ".zed": { + "local.json": "{}" + } + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry.clone(), + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let test_cases = vec![ + ("", false, "Empty path is treated as project root"), + ("/", true, "Root directory should be outside project"), + ( + "project/../other", + true, + "Path with .. that goes outside of root directory", + ), + ( + "project/./src/file.rs", + false, + "Path with . should work normally", + ), + #[cfg(target_os = "windows")] + ("C:\\Windows\\System32\\hosts", true, "Windows system path"), + #[cfg(target_os = "windows")] + ("project\\src\\main.rs", false, "Windows-style project path"), + ]; + + for (path, should_confirm, description) in test_cases { + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let auth = + cx.update(|cx| tool.authorize(&PathBuf::from(path), "Edit file", &stream_tx, cx)); + + cx.run_until_parked(); + + if should_confirm { + stream_rx.expect_authorization().await; + } else { + assert!( + stream_rx.try_next().is_err(), + "Failed for case: {} - path: {} - expected no confirmation but got one", + description, + path + ); + auth.await.unwrap(); + } + } + } + + #[gpui::test] + async fn test_streaming_needs_confirmation_with_different_modes(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + "existing.txt": "content", + ".zed": { + "settings.json": "{}" + } + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry.clone(), + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + language_registry, + )); + + let modes = vec![ + StreamingEditFileMode::Edit, + StreamingEditFileMode::Create, + StreamingEditFileMode::Overwrite, + ]; + + for _mode in modes { + // Test .zed path with different modes + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx.update(|cx| { + tool.authorize( + &PathBuf::from("project/.zed/settings.json"), + "Edit settings", + &stream_tx, + cx, + ) + }); + + stream_rx.expect_authorization().await; + + // Test outside path with different modes + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let _auth = cx.update(|cx| { + tool.authorize( + &PathBuf::from("/outside/file.txt"), + "Edit file", + &stream_tx, + cx, + ) + }); + + stream_rx.expect_authorization().await; + + // Test normal path with different modes + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + cx.update(|cx| { + tool.authorize( + &PathBuf::from("project/normal.txt"), + "Edit file", + &stream_tx, + cx, + ) + }) + .await + .unwrap(); + assert!(stream_rx.try_next().is_err()); + } + } + + #[gpui::test] + async fn test_streaming_initial_title_with_partial_input(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let tool = Arc::new(StreamingEditFileTool::new( + project, + thread.downgrade(), + language_registry, + )); + + cx.update(|cx| { + assert_eq!( + tool.initial_title( + Err(json!({ + "path": "src/main.rs", + "display_description": "", + })), + cx + ), + "src/main.rs" + ); + assert_eq!( + tool.initial_title( + Err(json!({ + "path": "", + "display_description": "Fix error handling", + })), + cx + ), + "Fix error handling" + ); + assert_eq!( + tool.initial_title( + Err(json!({ + "path": "src/main.rs", + "display_description": "Fix error handling", + })), + cx + ), + "src/main.rs" + ); + assert_eq!( + tool.initial_title( + Err(json!({ + "path": "", + "display_description": "", + })), + cx + ), + DEFAULT_UI_TEXT + ); + assert_eq!( + tool.initial_title(Err(serde_json::Value::Null), cx), + DEFAULT_UI_TEXT + ); + }); + } + + #[gpui::test] + async fn test_streaming_diff_finalization(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/", json!({"main.rs": ""})).await; + + let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await; + let languages = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry.clone(), + Templates::new(), + Some(model.clone()), + cx, + ) + }); + + // Ensure the diff is finalized after the edit completes. + { + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + languages.clone(), + )); + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let edit = cx.update(|cx| { + tool.run( + ToolInput::resolved(StreamingEditFileToolInput { + display_description: "Edit file".into(), + path: path!("/main.rs").into(), + mode: StreamingEditFileMode::Overwrite, + content: Some("new content".into()), + edits: None, + }), + stream_tx, + cx, + ) + }); + stream_rx.expect_update_fields().await; + let diff = stream_rx.expect_diff().await; + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); + cx.run_until_parked(); + edit.await.unwrap(); + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + } + + // Ensure the diff is finalized if the tool call gets dropped. + { + let tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + languages.clone(), + )); + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let edit = cx.update(|cx| { + tool.run( + ToolInput::resolved(StreamingEditFileToolInput { + display_description: "Edit file".into(), + path: path!("/main.rs").into(), + mode: StreamingEditFileMode::Overwrite, + content: Some("dropped content".into()), + edits: None, + }), + stream_tx, + cx, + ) + }); + stream_rx.expect_update_fields().await; + let diff = stream_rx.expect_diff().await; + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); + drop(edit); + cx.run_until_parked(); + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + } + } + + #[gpui::test] + async fn test_streaming_consecutive_edits_work(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "test.txt": "original content" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let languages = project.read_with(cx, |project, _| project.languages().clone()); + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + + let read_tool = Arc::new(crate::ReadFileTool::new( + thread.downgrade(), + project.clone(), + action_log, + )); + let edit_tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + languages, + )); + + // Read the file first + cx.update(|cx| { + read_tool.clone().run( + ToolInput::resolved(crate::ReadFileToolInput { + path: "root/test.txt".to_string(), + start_line: None, + end_line: None, + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // First edit should work + let edit_result = cx + .update(|cx| { + edit_tool.clone().run( + ToolInput::resolved(StreamingEditFileToolInput { + display_description: "First edit".into(), + path: "root/test.txt".into(), + mode: StreamingEditFileMode::Edit, + content: None, + edits: Some(vec![EditOperation { + old_text: "original content".into(), + new_text: "modified content".into(), + }]), + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + assert!( + edit_result.is_ok(), + "First edit should succeed, got error: {:?}", + edit_result.as_ref().err() + ); + + // Second edit should also work because the edit updated the recorded read time + let edit_result = cx + .update(|cx| { + edit_tool.clone().run( + ToolInput::resolved(StreamingEditFileToolInput { + display_description: "Second edit".into(), + path: "root/test.txt".into(), + mode: StreamingEditFileMode::Edit, + content: None, + edits: Some(vec![EditOperation { + old_text: "modified content".into(), + new_text: "further modified content".into(), + }]), + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + assert!( + edit_result.is_ok(), + "Second consecutive edit should succeed, got error: {:?}", + edit_result.as_ref().err() + ); + } + + #[gpui::test] + async fn test_streaming_external_modification_detected(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "test.txt": "original content" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let languages = project.read_with(cx, |project, _| project.languages().clone()); + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + + let read_tool = Arc::new(crate::ReadFileTool::new( + thread.downgrade(), + project.clone(), + action_log, + )); + let edit_tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + languages, + )); + + // Read the file first + cx.update(|cx| { + read_tool.clone().run( + ToolInput::resolved(crate::ReadFileToolInput { + path: "root/test.txt".to_string(), + start_line: None, + end_line: None, + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // Simulate external modification + cx.background_executor + .advance_clock(std::time::Duration::from_secs(2)); + fs.save( + path!("/root/test.txt").as_ref(), + &"externally modified content".into(), + language::LineEnding::Unix, + ) + .await + .unwrap(); + + // Reload the buffer to pick up the new mtime + let project_path = project + .read_with(cx, |project, cx| { + project.find_project_path("root/test.txt", cx) + }) + .expect("Should find project path"); + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .unwrap(); + buffer + .update(cx, |buffer, cx| buffer.reload(cx)) + .await + .unwrap(); + + cx.executor().run_until_parked(); + + // Try to edit - should fail because file was modified externally + let result = cx + .update(|cx| { + edit_tool.clone().run( + ToolInput::resolved(StreamingEditFileToolInput { + display_description: "Edit after external change".into(), + path: "root/test.txt".into(), + mode: StreamingEditFileMode::Edit, + content: None, + edits: Some(vec![EditOperation { + old_text: "externally modified content".into(), + new_text: "new content".into(), + }]), + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else { + panic!("expected error"); + }; + assert!( + error.contains("has been modified since you last read it"), + "Error should mention file modification, got: {}", + error + ); + } + + #[gpui::test] + async fn test_streaming_dirty_buffer_detected(cx: &mut TestAppContext) { + init_test(cx); + + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "test.txt": "original content" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + crate::Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model.clone()), + cx, + ) + }); + let languages = project.read_with(cx, |project, _| project.languages().clone()); + let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone()); + + let read_tool = Arc::new(crate::ReadFileTool::new( + thread.downgrade(), + project.clone(), + action_log, + )); + let edit_tool = Arc::new(StreamingEditFileTool::new( + project.clone(), + thread.downgrade(), + languages, + )); + + // Read the file first + cx.update(|cx| { + read_tool.clone().run( + ToolInput::resolved(crate::ReadFileToolInput { + path: "root/test.txt".to_string(), + start_line: None, + end_line: None, + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await + .unwrap(); + + // Open the buffer and make it dirty + let project_path = project + .read_with(cx, |project, cx| { + project.find_project_path("root/test.txt", cx) + }) + .expect("Should find project path"); + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .unwrap(); + + buffer.update(cx, |buffer, cx| { + let end_point = buffer.max_point(); + buffer.edit([(end_point..end_point, " added text")], None, cx); + }); + + let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty()); + assert!(is_dirty, "Buffer should be dirty after in-memory edit"); + + // Try to edit - should fail because buffer has unsaved changes + let result = cx + .update(|cx| { + edit_tool.clone().run( + ToolInput::resolved(StreamingEditFileToolInput { + display_description: "Edit with dirty buffer".into(), + path: "root/test.txt".into(), + mode: StreamingEditFileMode::Edit, + content: None, + edits: Some(vec![EditOperation { + old_text: "original content".into(), + new_text: "new content".into(), + }]), + }), + ToolCallEventStream::test().0, + cx, + ) + }) + .await; + + let StreamingEditFileToolOutput::Error { error } = result.unwrap_err() else { + panic!("expected error"); + }; + assert!( + error.contains("This file has unsaved changes."), + "Error should mention unsaved changes, got: {}", + error + ); + assert!( + error.contains("keep or discard"), + "Error should ask whether to keep or discard changes, got: {}", + error + ); + assert!( + error.contains("save or revert the file manually"), + "Error should ask user to manually save or revert when tools aren't available, got: {}", + error + ); + } + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 57b3278da256c0..6396bd1b0e63b4 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -15,7 +15,7 @@ use std::{ }; use crate::{ - AgentTool, ThreadEnvironment, ToolCallEventStream, ToolPermissionDecision, + AgentTool, ThreadEnvironment, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_from_settings, }; @@ -85,34 +85,45 @@ impl AgentTool for TerminalTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let working_dir = match working_dir(&input, &self.project, cx) { - Ok(dir) => dir, - Err(err) => return Task::ready(Err(err.to_string())), - }; + cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| format!("Failed to receive tool input: {e}"))?; - let settings = AgentSettings::get_global(cx); - let decision = decide_permission_from_settings( - Self::NAME, - std::slice::from_ref(&input.command), - settings, - ); + let (working_dir, authorize) = cx.update(|cx| { + let working_dir = + working_dir(&input, &self.project, cx).map_err(|err| err.to_string())?; - let authorize = match decision { - ToolPermissionDecision::Allow => None, - ToolPermissionDecision::Deny(reason) => { - return Task::ready(Err(reason)); - } - ToolPermissionDecision::Confirm => { - let context = - crate::ToolPermissionContext::new(Self::NAME, vec![input.command.clone()]); - Some(event_stream.authorize(self.initial_title(Ok(input.clone()), cx), context, cx)) - } - }; - cx.spawn(async move |cx| { + let decision = decide_permission_from_settings( + Self::NAME, + std::slice::from_ref(&input.command), + AgentSettings::get_global(cx), + ); + + let authorize = match decision { + ToolPermissionDecision::Allow => None, + ToolPermissionDecision::Deny(reason) => { + return Err(reason); + } + ToolPermissionDecision::Confirm => { + let context = crate::ToolPermissionContext::new( + Self::NAME, + vec![input.command.clone()], + ); + Some(event_stream.authorize( + self.initial_title(Ok(input.clone()), cx), + context, + cx, + )) + } + }; + Ok((working_dir, authorize)) + })?; if let Some(authorize) = authorize { authorize.await.map_err(|e| e.to_string())?; } diff --git a/crates/agent/src/tools/web_search_tool.rs b/crates/agent/src/tools/web_search_tool.rs index c536f45ba65c10..c697a5b78f1fe8 100644 --- a/crates/agent/src/tools/web_search_tool.rs +++ b/crates/agent/src/tools/web_search_tool.rs @@ -1,14 +1,15 @@ use std::sync::Arc; use crate::{ - AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_from_settings, + AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, + decide_permission_from_settings, }; use agent_client_protocol as acp; use agent_settings::AgentSettings; use anyhow::Result; use cloud_llm_client::WebSearchResponse; use futures::FutureExt as _; -use gpui::{App, AppContext, Task}; +use gpui::{App, Task}; use language_model::{ LanguageModelProviderId, LanguageModelToolResultContent, ZED_CLOUD_PROVIDER_ID, }; @@ -73,41 +74,51 @@ impl AgentTool for WebSearchTool { fn run( self: Arc, - input: Self::Input, + input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { - let settings = AgentSettings::get_global(cx); - let decision = decide_permission_from_settings( - Self::NAME, - std::slice::from_ref(&input.query), - settings, - ); - - let authorize = match decision { - ToolPermissionDecision::Allow => None, - ToolPermissionDecision::Deny(reason) => { - return Task::ready(Err(WebSearchToolOutput::Error { error: reason })); - } - ToolPermissionDecision::Confirm => { - let context = - crate::ToolPermissionContext::new(Self::NAME, vec![input.query.clone()]); - Some(event_stream.authorize( - format!("Search the web for {}", MarkdownInlineCode(&input.query)), - context, - cx, - )) - } - }; + cx.spawn(async move |cx| { + let input = input + .recv() + .await + .map_err(|e| WebSearchToolOutput::Error { + error: format!("Failed to receive tool input: {e}"), + })?; + + let (authorize, search_task) = cx.update(|cx| { + let decision = decide_permission_from_settings( + Self::NAME, + std::slice::from_ref(&input.query), + AgentSettings::get_global(cx), + ); + + let authorize = match decision { + ToolPermissionDecision::Allow => None, + ToolPermissionDecision::Deny(reason) => { + return Err(WebSearchToolOutput::Error { error: reason }); + } + ToolPermissionDecision::Confirm => { + let context = + crate::ToolPermissionContext::new(Self::NAME, vec![input.query.clone()]); + Some(event_stream.authorize( + format!("Search the web for {}", MarkdownInlineCode(&input.query)), + context, + cx, + )) + } + }; + + let Some(provider) = WebSearchRegistry::read_global(cx).active_provider() else { + return Err(WebSearchToolOutput::Error { + error: "Web search is not available.".to_string(), + }); + }; - let Some(provider) = WebSearchRegistry::read_global(cx).active_provider() else { - return Task::ready(Err(WebSearchToolOutput::Error { - error: "Web search is not available.".to_string(), - })); - }; + let search_task = provider.search(input.query, cx); + Ok((authorize, search_task)) + })?; - let search_task = provider.search(input.query, cx); - cx.background_spawn(async move { if let Some(authorize) = authorize { authorize.await.map_err(|e| WebSearchToolOutput::Error { error: e.to_string() })?; } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index bb7c21083e1ee4..14b7616bd0405a 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -36,6 +36,7 @@ pub struct UnsupportedVersion; pub struct AcpConnection { server_name: SharedString, + display_name: SharedString, telemetry_id: SharedString, connection: Rc, sessions: Rc>>, @@ -44,7 +45,6 @@ pub struct AcpConnection { default_mode: Option, default_model: Option, default_config_options: HashMap, - root_dir: PathBuf, child: Child, session_list: Option>, _io_task: Task>, @@ -158,22 +158,20 @@ impl AgentSessionList for AcpSessionList { pub async fn connect( server_name: SharedString, + display_name: SharedString, command: AgentServerCommand, - root_dir: &Path, default_mode: Option, default_model: Option, default_config_options: HashMap, - is_remote: bool, cx: &mut AsyncApp, ) -> Result> { let conn = AcpConnection::stdio( server_name, + display_name, command.clone(), - root_dir, default_mode, default_model, default_config_options, - is_remote, cx, ) .await?; @@ -185,12 +183,11 @@ const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1 impl AcpConnection { pub async fn stdio( server_name: SharedString, + display_name: SharedString, command: AgentServerCommand, - root_dir: &Path, default_mode: Option, default_model: Option, default_config_options: HashMap, - is_remote: bool, cx: &mut AsyncApp, ) -> Result { let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone()); @@ -198,9 +195,6 @@ impl AcpConnection { let mut child = builder.build_std_command(Some(command.path.display().to_string()), &command.args); child.envs(command.env.iter().flatten()); - if !is_remote { - child.current_dir(root_dir); - } let mut child = Child::spawn(child, Stdio::piped(), Stdio::piped(), Stdio::piped())?; let stdout = child.stdout.take().context("Failed to take stdout")?; @@ -327,9 +321,9 @@ impl AcpConnection { Ok(Self { auth_methods: response.auth_methods, - root_dir: root_dir.to_owned(), connection, server_name, + display_name, telemetry_id, sessions, agent_capabilities: response.agent_capabilities, @@ -347,10 +341,6 @@ impl AcpConnection { pub fn prompt_capabilities(&self) -> &acp::PromptCapabilities { &self.agent_capabilities.prompt_capabilities } - - pub fn root_dir(&self) -> &Path { - &self.root_dir - } } impl Drop for AcpConnection { @@ -550,7 +540,7 @@ impl AgentConnection for AcpConnection { let thread: Entity = cx.new(|cx| { AcpThread::new( None, - self.server_name.clone(), + self.display_name.clone(), self.clone(), project, action_log, @@ -603,10 +593,14 @@ impl AgentConnection for AcpConnection { let cwd = cwd.to_path_buf(); let mcp_servers = mcp_servers_for_project(&project, cx); let action_log = cx.new(|_| ActionLog::new(project.clone())); + let title = session + .title + .clone() + .unwrap_or_else(|| self.display_name.clone()); let thread: Entity = cx.new(|cx| { AcpThread::new( None, - self.server_name.clone(), + title, self.clone(), project, action_log, @@ -676,10 +670,14 @@ impl AgentConnection for AcpConnection { let cwd = cwd.to_path_buf(); let mcp_servers = mcp_servers_for_project(&project, cx); let action_log = cx.new(|_| ActionLog::new(project.clone())); + let title = session + .title + .clone() + .unwrap_or_else(|| self.display_name.clone()); let thread: Entity = cx.new(|cx| { AcpThread::new( None, - self.server_name.clone(), + title, self.clone(), project, action_log, diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index 6877c93342c22d..15ab23adcfbea5 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -22,7 +22,7 @@ use anyhow::Result; use gpui::{App, AppContext, Entity, SharedString, Task}; use project::Project; use settings::SettingsStore; -use std::{any::Any, path::Path, rc::Rc, sync::Arc}; +use std::{any::Any, rc::Rc, sync::Arc}; pub use acp::AcpConnection; @@ -58,7 +58,6 @@ pub trait AgentServer: Send { fn name(&self) -> SharedString; fn connect( &self, - root_dir: Option<&Path>, delegate: AgentServerDelegate, cx: &mut App, ) -> Task, Option)>>; diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index f711708383fb6b..6b491d6808c8eb 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -2,7 +2,6 @@ use agent_client_protocol as acp; use collections::HashSet; use fs::Fs; use settings::{SettingsStore, update_settings_file}; -use std::path::Path; use std::rc::Rc; use std::sync::Arc; use std::{any::Any, path::PathBuf}; @@ -208,13 +207,10 @@ impl AgentServer for ClaudeCode { fn connect( &self, - root_dir: Option<&Path>, delegate: AgentServerDelegate, cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); - let is_remote = delegate.project.read(cx).is_via_remote_server(); let store = delegate.store.downgrade(); let extra_env = load_proxy_env(cx); let default_mode = self.default_mode(cx); @@ -229,13 +225,12 @@ impl AgentServer for ClaudeCode { }); cx.spawn(async move |cx| { - let (command, root_dir, login) = store + let (command, login) = store .update(cx, |store, cx| { let agent = store .get_external_agent(&CLAUDE_AGENT_NAME.into()) .context("Claude Agent is not registered")?; anyhow::Ok(agent.get_command( - root_dir.as_deref(), extra_env, delegate.status_tx, delegate.new_version_available, @@ -244,13 +239,12 @@ impl AgentServer for ClaudeCode { })?? .await?; let connection = crate::acp::connect( + name.clone(), name, command, - root_dir.as_ref(), default_mode, default_model, default_config_options, - is_remote, cx, ) .await?; diff --git a/crates/agent_servers/src/codex.rs b/crates/agent_servers/src/codex.rs index 49deaa6192bb96..587e207a82bbb0 100644 --- a/crates/agent_servers/src/codex.rs +++ b/crates/agent_servers/src/codex.rs @@ -1,6 +1,6 @@ +use std::any::Any; use std::rc::Rc; use std::sync::Arc; -use std::{any::Any, path::Path}; use acp_thread::AgentConnection; use agent_client_protocol as acp; @@ -205,13 +205,10 @@ impl AgentServer for Codex { fn connect( &self, - root_dir: Option<&Path>, delegate: AgentServerDelegate, cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); - let is_remote = delegate.project.read(cx).is_via_remote_server(); let store = delegate.store.downgrade(); let mut extra_env = load_proxy_env(cx); let default_mode = self.default_mode(cx); @@ -232,13 +229,12 @@ impl AgentServer for Codex { } cx.spawn(async move |cx| { - let (command, root_dir, login) = store + let (command, login) = store .update(cx, |store, cx| { let agent = store .get_external_agent(&CODEX_NAME.into()) .context("Codex is not registered")?; anyhow::Ok(agent.get_command( - root_dir.as_deref(), extra_env, delegate.status_tx, delegate.new_version_available, @@ -248,13 +244,12 @@ impl AgentServer for Codex { .await?; let connection = crate::acp::connect( + name.clone(), name, command, - root_dir.as_ref(), default_mode, default_model, default_config_options, - is_remote, cx, ) .await?; diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index dc65a65fc74ce3..0dd4df7e7b3349 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -7,7 +7,7 @@ use fs::Fs; use gpui::{App, AppContext as _, SharedString, Task}; use project::agent_server_store::{AllAgentServersSettings, ExternalAgentServerName}; use settings::{SettingsStore, update_settings_file}; -use std::{path::Path, rc::Rc, sync::Arc}; +use std::{rc::Rc, sync::Arc}; use ui::IconName; /// A generic agent server implementation for custom user-defined agents @@ -327,13 +327,15 @@ impl AgentServer for CustomAgentServer { fn connect( &self, - root_dir: Option<&Path>, delegate: AgentServerDelegate, cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); - let is_remote = delegate.project.read(cx).is_via_remote_server(); + let display_name = delegate + .store + .read(cx) + .agent_display_name(&ExternalAgentServerName(name.clone())) + .unwrap_or_else(|| name.clone()); let default_mode = self.default_mode(cx); let default_model = self.default_model(cx); let (default_config_options, is_registry_agent) = @@ -381,7 +383,7 @@ impl AgentServer for CustomAgentServer { let store = delegate.store.downgrade(); let extra_env = load_proxy_env(cx); cx.spawn(async move |cx| { - let (command, root_dir, login) = store + let (command, login) = store .update(cx, |store, cx| { let agent = store .get_external_agent(&ExternalAgentServerName(name.clone())) @@ -389,7 +391,6 @@ impl AgentServer for CustomAgentServer { format!("Custom agent server `{}` is not registered", name) })?; anyhow::Ok(agent.get_command( - root_dir.as_deref(), extra_env, delegate.status_tx, delegate.new_version_available, @@ -399,12 +400,11 @@ impl AgentServer for CustomAgentServer { .await?; let connection = crate::acp::connect( name, + display_name, command, - root_dir.as_ref(), default_mode, default_model, default_config_options, - is_remote, cx, ) .await?; diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 4fe068ee5a9b68..bc91ff958c93b6 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -444,10 +444,7 @@ pub async fn new_test_thread( let store = project.read_with(cx, |project, _| project.agent_server_store().clone()); let delegate = AgentServerDelegate::new(store, project.clone(), None, None); - let (connection, _) = cx - .update(|cx| server.connect(Some(current_dir.as_ref()), delegate, cx)) - .await - .unwrap(); + let (connection, _) = cx.update(|cx| server.connect(delegate, cx)).await.unwrap(); cx.update(|cx| connection.new_session(project.clone(), current_dir.as_ref(), cx)) .await diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 1805e64a3a94dd..6ec57500f8edd2 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -1,5 +1,5 @@ +use std::any::Any; use std::rc::Rc; -use std::{any::Any, path::Path}; use crate::{AgentServer, AgentServerDelegate, load_proxy_env}; use acp_thread::AgentConnection; @@ -45,13 +45,10 @@ impl AgentServer for Gemini { fn connect( &self, - root_dir: Option<&Path>, delegate: AgentServerDelegate, cx: &mut App, ) -> Task, Option)>> { let name = self.name(); - let root_dir = root_dir.map(|root_dir| root_dir.to_string_lossy().into_owned()); - let is_remote = delegate.project.read(cx).is_via_remote_server(); let store = delegate.store.downgrade(); let mut extra_env = load_proxy_env(cx); let default_mode = self.default_mode(cx); @@ -71,13 +68,12 @@ impl AgentServer for Gemini { if let Some(api_key) = cx.update(api_key_for_gemini_cli).await.ok() { extra_env.insert("GEMINI_API_KEY".into(), api_key); } - let (command, root_dir, login) = store + let (command, login) = store .update(cx, |store, cx| { let agent = store .get_external_agent(&GEMINI_NAME.into()) .context("Gemini CLI is not registered")?; anyhow::Ok(agent.get_command( - root_dir.as_deref(), extra_env, delegate.status_tx, delegate.new_version_available, @@ -87,13 +83,12 @@ impl AgentServer for Gemini { .await?; let connection = crate::acp::connect( + name.clone(), name, command, - root_dir.as_ref(), default_mode, default_model, default_config_options, - is_remote, cx, ) .await?; diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 47847aef53cbd5..6710969bd89b3e 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -747,70 +747,90 @@ impl MessageEditor { _ => None, }) { - let path_style = workspace.read(cx).project().read(cx).path_style(cx); - - // Parse markdown mention links in format: [@name](uri) - let parsed_mentions = parse_mention_links(&clipboard_text, path_style); - - if !parsed_mentions.is_empty() { + if clipboard_text.contains("[@") { cx.stop_propagation(); - - let insertion_offset = self.editor.update(cx, |editor, cx| { + let selections_before = self.editor.update(cx, |editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); - editor.selections.newest_anchor().start.to_offset(&snapshot) + editor + .selections + .disjoint_anchors() + .iter() + .map(|selection| { + ( + selection.start.bias_left(&snapshot), + selection.end.bias_right(&snapshot), + ) + }) + .collect::>() }); - // Insert the raw text first self.editor.update(cx, |editor, cx| { editor.insert(&clipboard_text, window, cx); }); - let supports_images = self.prompt_capabilities.borrow().image; - let http_client = workspace.read(cx).client().http_client(); - - // Now create creases for each mention and load their content let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx); - for (range, mention_uri) in parsed_mentions { - let start_offset = insertion_offset.0 + range.start; - let anchor = snapshot.anchor_before(MultiBufferOffset(start_offset)); - let content_len = range.end - range.start; - - let Some((crease_id, tx)) = insert_crease_for_mention( - anchor.excerpt_id, - anchor.text_anchor, - content_len, - mention_uri.name().into(), - mention_uri.icon_path(cx), - None, - self.editor.clone(), - window, - cx, - ) else { - continue; - }; + let path_style = workspace.read(cx).project().read(cx).path_style(cx); + + let mut all_mentions = Vec::new(); + for (start_anchor, end_anchor) in selections_before { + let start_offset = start_anchor.to_offset(&snapshot); + let end_offset = end_anchor.to_offset(&snapshot); + + // Get the actual inserted text from the buffer (may differ due to auto-indent) + let inserted_text: String = + snapshot.text_for_range(start_offset..end_offset).collect(); + + let parsed_mentions = parse_mention_links(&inserted_text, path_style); + for (range, mention_uri) in parsed_mentions { + let mention_start_offset = MultiBufferOffset(start_offset.0 + range.start); + let anchor = snapshot.anchor_before(mention_start_offset); + let content_len = range.end - range.start; + all_mentions.push((anchor, content_len, mention_uri)); + } + } - // Create the confirmation task based on the mention URI type. - // This properly loads file content, fetches URLs, etc. - let task = self.mention_set.update(cx, |mention_set, cx| { - mention_set.confirm_mention_for_uri( - mention_uri.clone(), - supports_images, - http_client.clone(), + if !all_mentions.is_empty() { + let supports_images = self.prompt_capabilities.borrow().image; + let http_client = workspace.read(cx).client().http_client(); + + for (anchor, content_len, mention_uri) in all_mentions { + let Some((crease_id, tx)) = insert_crease_for_mention( + anchor.excerpt_id, + anchor.text_anchor, + content_len, + mention_uri.name().into(), + mention_uri.icon_path(cx), + None, + self.editor.clone(), + window, cx, - ) - }); - let task = cx - .spawn(async move |_, _| task.await.map_err(|e| e.to_string())) - .shared(); + ) else { + continue; + }; - self.mention_set.update(cx, |mention_set, _cx| { - mention_set.insert_mention(crease_id, mention_uri.clone(), task.clone()) - }); + // Create the confirmation task based on the mention URI type. + // This properly loads file content, fetches URLs, etc. + let task = self.mention_set.update(cx, |mention_set, cx| { + mention_set.confirm_mention_for_uri( + mention_uri.clone(), + supports_images, + http_client.clone(), + cx, + ) + }); + let task = cx + .spawn(async move |_, _| task.await.map_err(|e| e.to_string())) + .shared(); - // Drop the tx after inserting to signal the crease is ready - drop(tx); + self.mention_set.update(cx, |mention_set, _cx| { + mention_set.insert_mention(crease_id, mention_uri.clone(), task.clone()) + }); + + // Drop the tx after inserting to signal the crease is ready + drop(tx); + } + return; } - return; } } @@ -1449,12 +1469,16 @@ mod tests { use acp_thread::{AgentSessionInfo, MentionUri}; use agent::{ThreadStore, outline}; use agent_client_protocol as acp; - use editor::{AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset}; + use editor::{ + AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects, + actions::Paste, + }; use fs::FakeFs; use futures::StreamExt as _; use gpui::{ - AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext, + AppContext, ClipboardItem, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, + VisualTestContext, }; use language_model::LanguageModelRegistry; use lsp::{CompletionContext, CompletionTriggerKind}; @@ -3333,4 +3357,104 @@ mod tests { assert_eq!(editor.text(cx), "😄😄@file"); }); } + + #[gpui::test] + async fn test_paste_mention_link_with_multiple_selections(cx: &mut TestAppContext) { + init_test(cx); + + let app_state = cx.update(AppState::test); + + cx.update(|cx| { + editor::init(cx); + workspace::init(app_state.clone(), cx); + }); + + app_state + .fs + .as_fake() + .insert_tree(path!("/project"), json!({"file.txt": "content"})) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await; + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + let mut cx = VisualTestContext::from_window(window.into(), cx); + + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + let history = cx + .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); + + let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { + let workspace_handle = cx.weak_entity(); + let message_editor = cx.new(|cx| { + MessageEditor::new( + workspace_handle, + project.downgrade(), + Some(thread_store), + history.downgrade(), + None, + Default::default(), + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + max_lines: None, + min_lines: 1, + }, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + message_editor.read(cx).focus_handle(cx).focus(window, cx); + let editor = message_editor.read(cx).editor().clone(); + (message_editor, editor) + }); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.set_text( + "AAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAA", + window, + cx, + ); + }); + + cx.run_until_parked(); + + editor.update_in(&mut cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges([ + MultiBufferOffset(0)..MultiBufferOffset(25), // First selection (large) + MultiBufferOffset(30)..MultiBufferOffset(55), // Second selection (newest) + ]); + }); + }); + + let mention_link = "[@f](file:///test.txt)"; + cx.write_to_clipboard(ClipboardItem::new_string(mention_link.into())); + + message_editor.update_in(&mut cx, |message_editor, window, cx| { + message_editor.paste(&Paste, window, cx); + }); + + let text = editor.update(&mut cx, |editor, cx| editor.text(cx)); + assert!( + text.contains("[@f](file:///test.txt)"), + "Expected mention link to be pasted, got: {}", + text + ); + } } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index cd0e102192c2a2..2d5dd92227eca4 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -823,7 +823,6 @@ impl AcpServerView { } }) .collect(); - let root_dir = worktree_roots.first().cloned(); let session_cwd = resume_thread .as_ref() .and_then(|resume| { @@ -838,7 +837,7 @@ impl AcpServerView { }) .map(|path| path.into()) }) - .or_else(|| root_dir.clone()) + .or_else(|| worktree_roots.first().cloned()) .unwrap_or_else(|| paths::home_dir().as_path().into()); let (status_tx, mut status_rx) = watch::channel("Loading…".into()); @@ -850,7 +849,7 @@ impl AcpServerView { Some(new_version_available_tx), ); - let connect_task = agent.connect(root_dir.as_deref(), delegate, cx); + let connect_task = agent.connect(delegate, cx); let load_task = cx.spawn_in(window, async move |this, cx| { let connection = match connect_task.await { Ok((connection, login)) => { @@ -863,6 +862,8 @@ impl AcpServerView { this.handle_load_error(err, window, cx); } else if let Some(active) = this.active_thread() { active.update(cx, |active, cx| active.handle_any_thread_error(err, cx)); + } else { + this.handle_load_error(err, window, cx); } cx.notify(); }) @@ -1730,13 +1731,6 @@ impl AcpServerView { }) .unwrap_or_default(); - // Run SpawnInTerminal in the same dir as the ACP server - let cwd = connected - .connection - .clone() - .downcast::() - .map(|acp_conn| acp_conn.root_dir().to_path_buf()); - // Build SpawnInTerminal from _meta let login = task::SpawnInTerminal { id: task::TaskId(format!("external-agent-{}-login", label)), @@ -1745,7 +1739,6 @@ impl AcpServerView { command: Some(command.to_string()), args, command_label: label.to_string(), - cwd, env, use_new_terminal: true, allow_concurrent_runs: true, @@ -3405,6 +3398,38 @@ pub(crate) mod tests { }); } + #[gpui::test] + async fn test_connect_failure_transitions_to_load_error(cx: &mut TestAppContext) { + init_test(cx); + + let (thread_view, cx) = setup_thread_view(FailingAgentServer, cx).await; + + thread_view.read_with(cx, |view, cx| { + let title = view.title(cx); + assert_eq!( + title.as_ref(), + "Error Loading Codex CLI", + "Tab title should show the agent name with an error prefix" + ); + match &view.server_state { + ServerState::LoadError(LoadError::Other(msg)) => { + assert!( + msg.contains("Invalid gzip header"), + "Error callout should contain the underlying extraction error, got: {msg}" + ); + } + other => panic!( + "Expected LoadError::Other, got: {}", + match other { + ServerState::Loading(_) => "Loading (stuck!)", + ServerState::LoadError(_) => "LoadError (wrong variant)", + ServerState::Connected(_) => "Connected", + } + ), + } + }); + } + #[gpui::test] async fn test_auth_required_on_initial_connect(cx: &mut TestAppContext) { init_test(cx); @@ -3903,7 +3928,6 @@ pub(crate) mod tests { fn connect( &self, - _root_dir: Option<&Path>, _delegate: AgentServerDelegate, _cx: &mut App, ) -> Task, Option)>> { @@ -3915,6 +3939,35 @@ pub(crate) mod tests { } } + struct FailingAgentServer; + + impl AgentServer for FailingAgentServer { + fn logo(&self) -> ui::IconName { + ui::IconName::AiOpenAi + } + + fn name(&self) -> SharedString { + "Codex CLI".into() + } + + fn connect( + &self, + _delegate: AgentServerDelegate, + _cx: &mut App, + ) -> Task, Option)>> { + Task::ready(Err(anyhow!( + "extracting downloaded asset for \ + https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/\ + codex-acp-0.9.4-aarch64-pc-windows-msvc.zip: \ + failed to iterate over archive: Invalid gzip header" + ))) + } + + fn into_any(self: Rc) -> Rc { + self + } + } + #[derive(Clone)] struct StubSessionList { sessions: Vec, diff --git a/crates/agent_ui/src/acp/thread_view/active_thread.rs b/crates/agent_ui/src/acp/thread_view/active_thread.rs index 4a18a3785a5814..85c2f1e1225257 100644 --- a/crates/agent_ui/src/acp/thread_view/active_thread.rs +++ b/crates/agent_ui/src/acp/thread_view/active_thread.rs @@ -6367,37 +6367,31 @@ impl AcpThreadView { )) .child( h_flex() - .p_1() + .id(entry_ix) + .py_1() .w_full() + .justify_center() .border_t_1() .when(is_canceled_or_failed, |this| this.border_dashed()) .border_color(cx.theme().colors().border_variant) + .hover(|s| s.bg(cx.theme().colors().element_hover)) .child( - Button::new( - format!("expand-subagent-{}", entry_ix), - "Full Screen", - ) - .full_width() - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .icon(IconName::Maximize) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .on_click(cx.listener( - move |this, _event, window, cx| { - this.server_view - .update(cx, |this, cx| { - this.navigate_to_session( - session_id.clone(), - window, - cx, - ); - }) - .ok(); - }, - )), - ), + Icon::new(IconName::Maximize) + .color(Color::Muted) + .size(IconSize::Small), + ) + .tooltip(Tooltip::text("Make Subagent Full Screen")) + .on_click(cx.listener(move |this, _event, window, cx| { + this.server_view + .update(cx, |this, cx| { + this.navigate_to_session( + session_id.clone(), + window, + cx, + ); + }) + .ok(); + })), ) }) } @@ -6432,6 +6426,20 @@ impl AcpThreadView { .overflow_hidden() }; + let editor_bg = cx.theme().colors().editor_background; + let overlay = || { + div() + .absolute() + .inset_0() + .size_full() + .bg(linear_gradient( + 180., + linear_color_stop(editor_bg, 0.), + linear_color_stop(editor_bg.opacity(0.), 0.1), + )) + .block_mouse_except_scroll() + }; + let show_thread_entries = is_running || tool_call.content.is_empty(); if show_thread_entries { @@ -6467,21 +6475,7 @@ impl AcpThreadView { .pb_1() .children(rendered_entries), ) - .when(is_running, |this| { - let editor_bg = cx.theme().colors().editor_background; - this.child( - div() - .absolute() - .inset_0() - .size_full() - .bg(linear_gradient( - 180., - linear_color_stop(editor_bg, 0.), - linear_color_stop(editor_bg.opacity(0.), 0.15), - )) - .block_mouse_except_scroll(), - ) - }) + .child(overlay()) .into_any_element() } else { base_container() @@ -6513,6 +6507,7 @@ impl AcpThreadView { }, )), ) + .child(overlay()) .into_any_element() } } diff --git a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs index 78c032a565522a..38805f2c26693f 100644 --- a/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs +++ b/crates/agent_ui/src/agent_configuration/configure_context_server_modal.rs @@ -877,9 +877,14 @@ fn wait_for_context_server( context_server_id: ContextServerId, cx: &mut App, ) -> Task>> { + use std::time::Duration; + + const WAIT_TIMEOUT: Duration = Duration::from_secs(120); + let (tx, rx) = futures::channel::oneshot::channel(); let tx = Arc::new(Mutex::new(Some(tx))); + let context_server_id_for_timeout = context_server_id.clone(); let subscription = cx.subscribe(context_server_store, move |_, event, _cx| { let project::context_server_store::ServerStatusChangedEvent { server_id, status } = event; @@ -909,12 +914,20 @@ fn wait_for_context_server( } }); - cx.spawn(async move |_cx| { - let result = rx - .await - .map_err(|_| Arc::from("Context server store was dropped"))?; + cx.spawn(async move |cx| { + let timeout = cx.background_executor().timer(WAIT_TIMEOUT); + let result = futures::future::select(rx, timeout).await; drop(subscription); - result + match result { + futures::future::Either::Left((Ok(inner), _)) => inner, + futures::future::Either::Left((Err(_), _)) => { + Err(Arc::from("Context server store was dropped")) + } + futures::future::Either::Right(_) => Err(Arc::from(format!( + "Timed out waiting for context server `{}` to start. Check the Zed log for details.", + context_server_id_for_timeout + ))), + } }) } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index a58ddc64fa104a..db736dd3f03ba6 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,4 +1,13 @@ -use std::{ops::Range, path::Path, rc::Rc, sync::Arc, time::Duration}; +use std::{ + ops::Range, + path::Path, + rc::Rc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; use acp_thread::{AcpThread, AgentSessionInfo, MentionUri}; use agent::{ContextServerRegistry, NativeAgentSessionList, SharedThread, ThreadStore}; @@ -243,7 +252,14 @@ pub fn init(cx: &mut App) { window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx); window.refresh(); }) - .register_action(|_workspace, _: &ResetTrialUpsell, _window, cx| { + .register_action(|workspace, _: &ResetTrialUpsell, _window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, _| { + panel + .on_boarding_upsell_dismissed + .store(false, Ordering::Release); + }); + } OnboardingUpsell::set_dismissed(false, cx); }) .register_action(|_workspace, _: &ResetTrialEndUpsell, _window, cx| { @@ -526,6 +542,7 @@ pub struct AgentPanel { selected_agent: AgentType, show_trust_workspace_message: bool, last_configuration_error_telemetry: Option, + on_boarding_upsell_dismissed: AtomicBool, } impl AgentPanel { @@ -751,11 +768,19 @@ impl AgentPanel { .ok(); }); + let weak_panel = cx.entity().downgrade(); let onboarding = cx.new(|cx| { AgentPanelOnboarding::new( user_store.clone(), client, - |_window, cx| { + move |_window, cx| { + weak_panel + .update(cx, |panel, _| { + panel + .on_boarding_upsell_dismissed + .store(true, Ordering::Release); + }) + .ok(); OnboardingUpsell::set_dismissed(true, cx); }, cx, @@ -945,6 +970,7 @@ impl AgentPanel { selected_agent: AgentType::default(), show_trust_workspace_message: false, last_configuration_error_telemetry: None, + on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed()), }; // Initial sync of agent servers from extensions @@ -2926,7 +2952,7 @@ impl AgentPanel { } fn should_render_onboarding(&self, cx: &mut Context) -> bool { - if OnboardingUpsell::dismissed() { + if self.on_boarding_upsell_dismissed.load(Ordering::Acquire) { return false; } @@ -2939,6 +2965,8 @@ impl AgentPanel { .is_some_and(|date| date < chrono::Utc::now()) { OnboardingUpsell::set_dismissed(true, cx); + self.on_boarding_upsell_dismissed + .store(true, Ordering::Release); return false; } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 3f082e86b1f4f8..736f69855f0e1c 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -25,7 +25,6 @@ mod ui; use std::rc::Rc; use std::sync::Arc; -// Another comment use agent_settings::{AgentProfileId, AgentSettings}; use assistant_slash_command::SlashCommandRegistry; use client::Client; @@ -310,6 +309,10 @@ pub fn init( .find_map(|item| item.downcast::()); if let Some(existing) = existing { + existing.update(cx, |_, cx| { + project::AgentRegistryStore::global(cx) + .update(cx, |store, cx| store.refresh(cx)); + }); workspace.activate_item(&existing, true, true, window, cx); } else { let registry_page = AgentRegistryPage::new(workspace, window, cx); diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 39759f264996ee..0376fda47e0b20 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -526,11 +526,13 @@ impl CodegenAlternative { name: REWRITE_SECTION_TOOL_NAME.to_string(), description: "Replaces text in tags with your replacement_text.".to_string(), input_schema: language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), + use_input_streaming: false, }, LanguageModelRequestTool { name: FAILURE_MESSAGE_TOOL_NAME.to_string(), description: "Use this tool to provide a message to the user when you're unable to complete a task.".to_string(), input_schema: language_model::tool_schema::root_schema_for::(tool_input_format).to_value(), + use_input_streaming: false, }, ]; diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index b1dab681ade325..2302417a3c5b9d 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -547,7 +547,7 @@ impl MentionSet { None, None, ); - let connection = server.connect(None, delegate, cx); + let connection = server.connect(delegate, cx); cx.spawn(async move |_, cx| { let (agent, _) = connection.await?; let agent = agent.downcast::().unwrap(); diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index bc2516b8b0f53e..56baf4b58fe9ac 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -906,11 +906,17 @@ pub struct ImageSource { pub data: String, } +fn is_false(value: &bool) -> bool { + !value +} + #[derive(Debug, Serialize, Deserialize)] pub struct Tool { pub name: String, pub description: String, pub input_schema: serde_json::Value, + #[serde(default, skip_serializing_if = "is_false")] + pub eager_input_streaming: bool, } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index b506b1b31f7e18..53fac7beac2475 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -990,7 +990,7 @@ async fn install_release_macos( }; let output = new_command("rsync") - .args(["-av", "--delete"]) + .args(["-av", "--delete", "--exclude", "Icon?"]) .arg(&mounted_app_path) .arg(&running_app_path) .output() diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml index ff034f914b0be4..2e46b58b74b826 100644 --- a/crates/call/Cargo.toml +++ b/crates/call/Cargo.toml @@ -31,7 +31,9 @@ fs.workspace = true futures.workspace = true feature_flags.workspace = true gpui = { workspace = true, features = ["screen-capture"] } +gpui_tokio.workspace = true language.workspace = true +livekit_client.workspace = true log.workspace = true postage.workspace = true project.workspace = true @@ -39,8 +41,7 @@ serde.workspace = true settings.workspace = true telemetry.workspace = true util.workspace = true -gpui_tokio.workspace = true -livekit_client.workspace = true +workspace.workspace = true [dev-dependencies] client = { workspace = true, features = ["test-support"] } diff --git a/crates/call/src/call_impl/mod.rs b/crates/call/src/call_impl/mod.rs index 08d3a28e10787a..e3945cf2c746f4 100644 --- a/crates/call/src/call_impl/mod.rs +++ b/crates/call/src/call_impl/mod.rs @@ -7,25 +7,265 @@ use client::{ChannelId, Client, TypedEnvelope, User, UserStore, ZED_ALWAYS_ACTIV use collections::HashSet; use futures::{Future, FutureExt, channel::oneshot, future::Shared}; use gpui::{ - App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Global, Subscription, Task, - WeakEntity, + AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, + WeakEntity, Window, }; use postage::watch; use project::Project; use room::Event; +use settings::Settings; use std::sync::Arc; +use workspace::{ + ActiveCallEvent, AnyActiveCall, GlobalAnyActiveCall, Pane, RemoteCollaborator, SharedScreen, + Workspace, +}; pub use livekit_client::{RemoteVideoTrack, RemoteVideoTrackView, RemoteVideoTrackViewEvent}; -pub use participant::ParticipantLocation; pub use room::Room; -struct GlobalActiveCall(Entity); - -impl Global for GlobalActiveCall {} +use crate::call_settings::CallSettings; pub fn init(client: Arc, user_store: Entity, cx: &mut App) { let active_call = cx.new(|cx| ActiveCall::new(client, user_store, cx)); - cx.set_global(GlobalActiveCall(active_call)); + cx.set_global(GlobalAnyActiveCall(Arc::new(ActiveCallEntity(active_call)))) +} + +#[derive(Clone)] +struct ActiveCallEntity(Entity); + +impl AnyActiveCall for ActiveCallEntity { + fn entity(&self) -> gpui::AnyEntity { + self.0.clone().into_any() + } + + fn is_in_room(&self, cx: &App) -> bool { + self.0.read(cx).room().is_some() + } + + fn room_id(&self, cx: &App) -> Option { + Some(self.0.read(cx).room()?.read(cx).id()) + } + + fn channel_id(&self, cx: &App) -> Option { + self.0.read(cx).room()?.read(cx).channel_id() + } + + fn hang_up(&self, cx: &mut App) -> Task> { + self.0.update(cx, |this, cx| this.hang_up(cx)) + } + + fn unshare_project(&self, project: Entity, cx: &mut App) -> Result<()> { + self.0 + .update(cx, |this, cx| this.unshare_project(project, cx)) + } + + fn remote_participant_for_peer_id( + &self, + peer_id: proto::PeerId, + cx: &App, + ) -> Option { + let room = self.0.read(cx).room()?.read(cx); + let participant = room.remote_participant_for_peer_id(peer_id)?; + Some(RemoteCollaborator { + user: participant.user.clone(), + peer_id: participant.peer_id, + location: participant.location, + participant_index: participant.participant_index, + }) + } + + fn is_sharing_project(&self, cx: &App) -> bool { + self.0 + .read(cx) + .room() + .map_or(false, |room| room.read(cx).is_sharing_project()) + } + + fn has_remote_participants(&self, cx: &App) -> bool { + self.0.read(cx).room().map_or(false, |room| { + !room.read(cx).remote_participants().is_empty() + }) + } + + fn local_participant_is_guest(&self, cx: &App) -> bool { + self.0 + .read(cx) + .room() + .map_or(false, |room| room.read(cx).local_participant_is_guest()) + } + + fn client(&self, cx: &App) -> Arc { + self.0.read(cx).client() + } + + fn share_on_join(&self, cx: &App) -> bool { + CallSettings::get_global(cx).share_on_join + } + + fn join_channel(&self, channel_id: ChannelId, cx: &mut App) -> Task> { + let task = self + .0 + .update(cx, |this, cx| this.join_channel(channel_id, cx)); + cx.spawn(async move |_cx| { + let result = task.await?; + Ok(result.is_some()) + }) + } + + fn room_update_completed(&self, cx: &mut App) -> Task<()> { + let Some(room) = self.0.read(cx).room().cloned() else { + return Task::ready(()); + }; + let future = room.update(cx, |room, _cx| room.room_update_completed()); + cx.spawn(async move |_cx| { + future.await; + }) + } + + fn most_active_project(&self, cx: &App) -> Option<(u64, u64)> { + let room = self.0.read(cx).room()?; + room.read(cx).most_active_project(cx) + } + + fn share_project(&self, project: Entity, cx: &mut App) -> Task> { + self.0 + .update(cx, |this, cx| this.share_project(project, cx)) + } + + fn join_project( + &self, + project_id: u64, + language_registry: Arc, + fs: Arc, + cx: &mut App, + ) -> Task>> { + let Some(room) = self.0.read(cx).room().cloned() else { + return Task::ready(Err(anyhow::anyhow!("not in a call"))); + }; + room.update(cx, |room, cx| { + room.join_project(project_id, language_registry, fs, cx) + }) + } + + fn peer_id_for_user_in_room(&self, user_id: u64, cx: &App) -> Option { + let room = self.0.read(cx).room()?.read(cx); + room.remote_participants() + .values() + .find(|p| p.user.id == user_id) + .map(|p| p.peer_id) + } + + fn subscribe( + &self, + window: &mut Window, + cx: &mut Context, + handler: Box< + dyn Fn(&mut Workspace, &ActiveCallEvent, &mut Window, &mut Context), + >, + ) -> Subscription { + cx.subscribe_in( + &self.0, + window, + move |workspace, _, event: &room::Event, window, cx| { + let mapped = match event { + room::Event::ParticipantLocationChanged { participant_id } => { + Some(ActiveCallEvent::ParticipantLocationChanged { + participant_id: *participant_id, + }) + } + room::Event::RemoteVideoTracksChanged { participant_id } => { + Some(ActiveCallEvent::RemoteVideoTracksChanged { + participant_id: *participant_id, + }) + } + _ => None, + }; + if let Some(event) = mapped { + handler(workspace, &event, window, cx); + } + }, + ) + } + + fn create_shared_screen( + &self, + peer_id: client::proto::PeerId, + pane: &Entity, + window: &mut Window, + cx: &mut App, + ) -> Option> { + let room = self.0.read(cx).room()?.clone(); + let participant = room.read(cx).remote_participant_for_peer_id(peer_id)?; + let track = participant.video_tracks.values().next()?.clone(); + let user = participant.user.clone(); + + for item in pane.read(cx).items_of_type::() { + if item.read(cx).peer_id == peer_id { + return Some(item); + } + } + + Some(cx.new(|cx: &mut Context| { + let my_sid = track.sid(); + cx.subscribe( + &room, + move |_: &mut SharedScreen, + _: Entity, + ev: &room::Event, + cx: &mut Context| { + if let room::Event::RemoteVideoTrackUnsubscribed { sid } = ev + && *sid == my_sid + { + cx.emit(workspace::shared_screen::Event::Close); + } + }, + ) + .detach(); + + cx.observe_release( + &room, + |_: &mut SharedScreen, _: &mut Room, cx: &mut Context| { + cx.emit(workspace::shared_screen::Event::Close); + }, + ) + .detach(); + + let view = cx.new(|cx| RemoteVideoTrackView::new(track.clone(), window, cx)); + cx.subscribe( + &view, + |_: &mut SharedScreen, + _: Entity, + ev: &RemoteVideoTrackViewEvent, + cx: &mut Context| match ev { + RemoteVideoTrackViewEvent::Close => { + cx.emit(workspace::shared_screen::Event::Close); + } + }, + ) + .detach(); + + pub(super) fn clone_remote_video_track_view( + view: &AnyView, + window: &mut Window, + cx: &mut App, + ) -> AnyView { + let view = view + .clone() + .downcast::() + .expect("SharedScreen view must be a RemoteVideoTrackView"); + let cloned = view.update(cx, |view, cx| view.clone(window, cx)); + AnyView::from(cloned) + } + + SharedScreen::new( + peer_id, + user, + AnyView::from(view), + clone_remote_video_track_view, + cx, + ) + })) + } } pub struct OneAtATime { @@ -152,12 +392,12 @@ impl ActiveCall { } pub fn global(cx: &App) -> Entity { - cx.global::().0.clone() + Self::try_global(cx).unwrap() } pub fn try_global(cx: &App) -> Option> { - cx.try_global::() - .map(|call| call.0.clone()) + let any = cx.try_global::()?; + any.0.entity().downcast::().ok() } pub fn invite( diff --git a/crates/call/src/call_impl/participant.rs b/crates/call/src/call_impl/participant.rs index 6fb6a2eb79b537..58d3329f853bda 100644 --- a/crates/call/src/call_impl/participant.rs +++ b/crates/call/src/call_impl/participant.rs @@ -1,4 +1,3 @@ -use anyhow::{Context as _, Result}; use client::{ParticipantIndex, User, proto}; use collections::HashMap; use gpui::WeakEntity; @@ -9,30 +8,6 @@ use std::sync::Arc; pub use livekit_client::TrackSid; pub use livekit_client::{RemoteAudioTrack, RemoteVideoTrack}; -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum ParticipantLocation { - SharedProject { project_id: u64 }, - UnsharedProject, - External, -} - -impl ParticipantLocation { - pub fn from_proto(location: Option) -> Result { - match location - .and_then(|l| l.variant) - .context("participant location was not provided")? - { - proto::participant_location::Variant::SharedProject(project) => { - Ok(Self::SharedProject { - project_id: project.id, - }) - } - proto::participant_location::Variant::UnsharedProject(_) => Ok(Self::UnsharedProject), - proto::participant_location::Variant::External(_) => Ok(Self::External), - } - } -} - #[derive(Clone, Default)] pub struct LocalParticipant { pub projects: Vec, @@ -54,7 +29,7 @@ pub struct RemoteParticipant { pub peer_id: proto::PeerId, pub role: proto::ChannelRole, pub projects: Vec, - pub location: ParticipantLocation, + pub location: workspace::ParticipantLocation, pub participant_index: ParticipantIndex, pub muted: bool, pub speaking: bool, diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index a2e1ac2fcc2779..701d7dd65423f9 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -1,6 +1,6 @@ use crate::{ call_settings::CallSettings, - participant::{LocalParticipant, ParticipantLocation, RemoteParticipant}, + participant::{LocalParticipant, RemoteParticipant}, }; use anyhow::{Context as _, Result, anyhow}; use audio::{Audio, Sound}; @@ -25,6 +25,7 @@ use project::Project; use settings::Settings as _; use std::{future::Future, mem, rc::Rc, sync::Arc, time::Duration, time::Instant}; use util::{ResultExt, TryFutureExt, paths::PathStyle, post_inc}; +use workspace::ParticipantLocation; pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30); diff --git a/crates/channel/src/channel_buffer.rs b/crates/channel/src/channel_buffer.rs index c4966443978e1e..8b6f30a3cd3bf1 100644 --- a/crates/channel/src/channel_buffer.rs +++ b/crates/channel/src/channel_buffer.rs @@ -22,6 +22,7 @@ pub(crate) fn init(client: &AnyProtoClient) { pub struct ChannelBuffer { pub channel_id: ChannelId, connected: bool, + rejoining: bool, collaborators: HashMap, user_store: Entity, channel_store: Entity, @@ -84,6 +85,7 @@ impl ChannelBuffer { buffer_epoch: response.epoch, client, connected: true, + rejoining: false, collaborators: Default::default(), acknowledge_task: None, channel_id: channel.id, @@ -111,6 +113,7 @@ impl ChannelBuffer { pub fn connected(&mut self, cx: &mut Context) { self.connected = true; + self.rejoining = false; if self.subscription.is_none() { let Ok(subscription) = self.client.subscribe_to_entity(self.channel_id.0) else { return; @@ -120,6 +123,10 @@ impl ChannelBuffer { } } + pub(crate) fn set_rejoining(&mut self, rejoining: bool) { + self.rejoining = rejoining; + } + pub fn remote_id(&self, cx: &App) -> BufferId { self.buffer.read(cx).remote_id() } @@ -204,6 +211,9 @@ impl ChannelBuffer { return; } let operation = language::proto::serialize_operation(operation); + if self.rejoining { + return; + } self.client .send(proto::UpdateChannelBuffer { channel_id: self.channel_id.0, @@ -263,6 +273,7 @@ impl ChannelBuffer { log::info!("channel buffer {} disconnected", self.channel_id); if self.connected { self.connected = false; + self.rejoining = false; self.subscription.take(); cx.emit(ChannelBufferEvent::Disconnected); cx.notify() diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index 62e34210ebac2d..a9357a765a7544 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -855,12 +855,18 @@ impl ChannelStore { if let OpenEntityHandle::Open(buffer) = buffer && let Some(buffer) = buffer.upgrade() { - let channel_buffer = buffer.read(cx); - let buffer = channel_buffer.buffer().read(cx); - buffer_versions.push(proto::ChannelBufferVersion { - channel_id: channel_buffer.channel_id.0, - epoch: channel_buffer.epoch(), - version: language::proto::serialize_version(&buffer.version()), + buffer.update(cx, |channel_buffer, cx| { + // Block on_buffer_update from sending UpdateChannelBuffer messages + // until the rejoin completes. This prevents a race condition where + // edits made during the rejoin async gap could inflate the server + // version, causing offline edits to be filtered out by serialize_ops. + channel_buffer.set_rejoining(true); + let inner_buffer = channel_buffer.buffer().read(cx); + buffer_versions.push(proto::ChannelBufferVersion { + channel_id: channel_buffer.channel_id.0, + epoch: channel_buffer.epoch(), + version: language::proto::serialize_version(&inner_buffer.version()), + }); }); } } @@ -874,7 +880,26 @@ impl ChannelStore { }); cx.spawn(async move |this, cx| { - let mut response = response.await?; + let response = match response.await { + Ok(response) => response, + Err(err) => { + // Clear rejoining flag on all buffers since the rejoin failed + this.update(cx, |this, cx| { + for buffer in this.opened_buffers.values() { + if let OpenEntityHandle::Open(buffer) = buffer { + if let Some(buffer) = buffer.upgrade() { + buffer.update(cx, |channel_buffer, _| { + channel_buffer.set_rejoining(false); + }); + } + } + } + }) + .ok(); + return Err(err); + } + }; + let mut response = response; this.update(cx, |this, cx| { this.opened_buffers.retain(|_, buffer| match buffer { @@ -948,6 +973,22 @@ impl ChannelStore { fn handle_disconnect(&mut self, wait_for_reconnect: bool, cx: &mut Context) { cx.notify(); self.did_subscribe = false; + + // If we're waiting for reconnect, set rejoining=true on all buffers immediately. + // This prevents operations from being sent during the reconnection window, + // before handle_connect has a chance to run and capture the version. + if wait_for_reconnect { + for buffer in self.opened_buffers.values() { + if let OpenEntityHandle::Open(buffer) = buffer { + if let Some(buffer) = buffer.upgrade() { + buffer.update(cx, |channel_buffer, _| { + channel_buffer.set_rejoining(true); + }); + } + } + } + } + self.disconnect_channel_buffers_task.get_or_insert_with(|| { cx.spawn(async move |this, cx| { if wait_for_reconnect { diff --git a/crates/clock/src/clock.rs b/crates/clock/src/clock.rs index cb0808abcae170..57612c5ff70ad7 100644 --- a/crates/clock/src/clock.rs +++ b/crates/clock/src/clock.rs @@ -61,8 +61,8 @@ pub type Seq = u32; /// used to determine the ordering of events in the editor. #[derive(Clone, Copy, Eq, Hash, PartialEq, Serialize, Deserialize)] pub struct Lamport { - pub replica_id: ReplicaId, pub value: Seq, + pub replica_id: ReplicaId, } /// A [version vector](https://en.wikipedia.org/wiki/Version_vector). diff --git a/crates/cloud_llm_client/src/predict_edits_v3.rs b/crates/cloud_llm_client/src/predict_edits_v3.rs index 9e7772ab7450cb..d0b53ca18e8c74 100644 --- a/crates/cloud_llm_client/src/predict_edits_v3.rs +++ b/crates/cloud_llm_client/src/predict_edits_v3.rs @@ -1,6 +1,7 @@ use crate::PredictEditsRequestTrigger; use serde::{Deserialize, Serialize}; use std::borrow::Cow; +use std::ops::Range; #[derive(Debug, Deserialize, Serialize)] pub struct RawCompletionRequest { @@ -27,6 +28,11 @@ pub struct PredictEditsV3Request { pub struct PredictEditsV3Response { pub request_id: String, pub output: String, + /// The editable region byte range within `cursor_excerpt` that the + /// server used for this request. When present, the client should use + /// this range to extract the old text from its local excerpt for + /// diffing, rather than relying on its own format-derived range. + pub editable_range: Range, } #[derive(Debug, Deserialize, Serialize)] diff --git a/crates/collab/tests/integration/channel_buffer_tests.rs b/crates/collab/tests/integration/channel_buffer_tests.rs index c9fd0459f43ea7..a5aca7dd82ca23 100644 --- a/crates/collab/tests/integration/channel_buffer_tests.rs +++ b/crates/collab/tests/integration/channel_buffer_tests.rs @@ -3,6 +3,7 @@ use call::ActiveCall; use channel::ACKNOWLEDGE_DEBOUNCE_INTERVAL; use client::{Collaborator, ParticipantIndex, UserId}; use collab::rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT}; + use collab_ui::channel_view::ChannelView; use collections::HashMap; use editor::{Anchor, Editor, MultiBufferOffset, ToOffset}; @@ -698,6 +699,165 @@ async fn test_channel_buffer_changes_persist( }); } +#[gpui::test] +async fn test_channel_buffer_operations_lost_on_reconnect( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let channel_id = server + .make_channel( + "the-channel", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b)], + ) + .await; + + // Both clients open the channel buffer. + let channel_buffer_a = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)) + .await + .unwrap(); + let channel_buffer_b = client_b + .channel_store() + .update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx)) + .await + .unwrap(); + + // Step 1: Client A makes an initial edit that syncs to B. + channel_buffer_a.update(cx_a, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.edit([(0..0, "a")], None, cx); + }) + }); + executor.run_until_parked(); + + // Verify both clients see "a". + channel_buffer_a.read_with(cx_a, |buffer, cx| { + assert_eq!(buffer.buffer().read(cx).text(), "a"); + }); + channel_buffer_b.read_with(cx_b, |buffer, cx| { + assert_eq!(buffer.buffer().read(cx).text(), "a"); + }); + + // Step 2: Disconnect client A. Do NOT advance past RECONNECT_TIMEOUT + // so that the buffer stays in `opened_buffers` for rejoin. + server.forbid_connections(); + server.disconnect_client(client_a.peer_id().unwrap()); + executor.run_until_parked(); + + // Step 3: While disconnected, client A makes an offline edit ("b"). + // on_buffer_update fires but client.send() fails because transport is down. + channel_buffer_a.update(cx_a, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.edit([(1..1, "b")], None, cx); + }) + }); + executor.run_until_parked(); + + // Client A sees "ab" locally; B still sees "a". + channel_buffer_a.read_with(cx_a, |buffer, cx| { + assert_eq!(buffer.buffer().read(cx).text(), "ab"); + }); + channel_buffer_b.read_with(cx_b, |buffer, cx| { + assert_eq!(buffer.buffer().read(cx).text(), "a"); + }); + + // Step 4: Reconnect and make a racing edit in parallel. + // + // The race condition occurs when: + // 1. Transport reconnects, handle_connect captures version V (with "b") and sends RejoinChannelBuffers + // 2. DURING the async gap (awaiting response), user makes edit "c" + // 3. on_buffer_update sends UpdateChannelBuffer (succeeds because transport is up) + // 4. Server receives BOTH messages concurrently (FuturesUnordered) + // 5. If UpdateChannelBuffer commits first, server version is inflated to include "c" + // 6. RejoinChannelBuffers reads inflated version and sends it back + // 7. Client's serialize_ops(inflated_version) filters out "b" (offline edit) + // because the inflated version's timestamp covers "b"'s timestamp + + // Get the buffer handle for spawning + let buffer_for_edit = channel_buffer_a.read_with(cx_a, |buffer, _| buffer.buffer()); + + // Spawn the edit task - it will wait for executor to run it + let edit_task = cx_a.spawn({ + let buffer = buffer_for_edit; + async move |mut cx| { + let _ = buffer.update(&mut cx, |buffer, cx| { + buffer.edit([(2..2, "c")], None, cx); + }); + } + }); + + // Allow connections so reconnect can succeed + server.allow_connections(); + + // Advance clock to trigger reconnection attempt + executor.advance_clock(RECEIVE_TIMEOUT); + + // Run the edit task - this races with handle_connect + edit_task.detach(); + + // Let everything settle. + executor.run_until_parked(); + + // Step 7: Read final buffer text from both clients. + let text_a = channel_buffer_a.read_with(cx_a, |buffer, cx| buffer.buffer().read(cx).text()); + let text_b = channel_buffer_b.read_with(cx_b, |buffer, cx| buffer.buffer().read(cx).text()); + + // Both clients must see the same text containing all three edits. + assert_eq!( + text_a, text_b, + "Client A and B diverged! A sees {:?}, B sees {:?}. \ + Operations were lost during reconnection.", + text_a, text_b + ); + assert!( + text_a.contains('a'), + "Initial edit 'a' missing from final text {:?}", + text_a + ); + assert!( + text_a.contains('b'), + "Offline edit 'b' missing from final text {:?}. \ + This is the reconnection race bug: the offline operation was \ + filtered out by serialize_ops because the server_version was \ + inflated by a racing UpdateChannelBuffer.", + text_a + ); + assert!( + text_a.contains('c'), + "Racing edit 'c' missing from final text {:?}", + text_a + ); + + // Step 8: Verify the invariant directly — every operation known to + // client A must be observed by client B's version. If any operation + // in A's history is not covered by B's version, it was lost. + channel_buffer_a.read_with(cx_a, |buf_a, cx_a_inner| { + let buffer_a = buf_a.buffer().read(cx_a_inner); + let ops_a = buffer_a.operations(); + channel_buffer_b.read_with(cx_b, |buf_b, cx_b_inner| { + let buffer_b = buf_b.buffer().read(cx_b_inner); + let version_b = buffer_b.version(); + for (lamport, _op) in ops_a.iter() { + assert!( + version_b.observed(*lamport), + "Operation with lamport timestamp {:?} from client A \ + is NOT observed by client B's version. This operation \ + was lost during reconnection.", + lamport + ); + } + }); + }); +} + #[track_caller] fn assert_collaborators(collaborators: &HashMap, ids: &[Option]) { let mut user_ids = collaborators diff --git a/crates/collab/tests/integration/following_tests.rs b/crates/collab/tests/integration/following_tests.rs index 6bdb06a6c5a0ff..c4031788c87f74 100644 --- a/crates/collab/tests/integration/following_tests.rs +++ b/crates/collab/tests/integration/following_tests.rs @@ -1,6 +1,6 @@ #![allow(clippy::reversed_empty_ranges)] use crate::TestServer; -use call::{ActiveCall, ParticipantLocation}; +use call::ActiveCall; use client::ChannelId; use collab_ui::{ channel_view::ChannelView, @@ -8,8 +8,8 @@ use collab_ui::{ }; use editor::{Editor, MultiBuffer, MultiBufferOffset, PathKey, SelectionEffects}; use gpui::{ - AppContext as _, BackgroundExecutor, BorrowAppContext, Entity, SharedString, TestAppContext, - VisualContext, VisualTestContext, point, + Action, AppContext as _, BackgroundExecutor, BorrowAppContext, Entity, SharedString, + TestAppContext, VisualContext, VisualTestContext, point, }; use language::Capability; use rpc::proto::PeerId; @@ -17,7 +17,10 @@ use serde_json::json; use settings::SettingsStore; use text::{Point, ToPoint}; use util::{path, rel_path::rel_path, test::sample_text}; -use workspace::{CollaboratorId, MultiWorkspace, SplitDirection, Workspace, item::ItemHandle as _}; +use workspace::{ + CloseWindow, CollaboratorId, MultiWorkspace, ParticipantLocation, SplitDirection, Workspace, + item::ItemHandle as _, +}; use super::TestClient; @@ -256,8 +259,8 @@ async fn test_basic_following( // Client C closes the project. let weak_workspace_c = workspace_c.downgrade(); - workspace_c.update_in(cx_c, |workspace, window, cx| { - workspace.close_window(&Default::default(), window, cx); + workspace_c.update_in(cx_c, |_, window, cx| { + window.dispatch_action(Box::new(CloseWindow) as Box, cx); }); executor.run_until_parked(); // are you sure you want to leave the call? diff --git a/crates/collab/tests/integration/integration_tests.rs b/crates/collab/tests/integration/integration_tests.rs index 413aa802a1e639..c26f20c1e29432 100644 --- a/crates/collab/tests/integration/integration_tests.rs +++ b/crates/collab/tests/integration/integration_tests.rs @@ -6,7 +6,7 @@ use anyhow::{Result, anyhow}; use assistant_slash_command::SlashCommandWorkingSet; use assistant_text_thread::TextThreadStore; use buffer_diff::{DiffHunkSecondaryStatus, DiffHunkStatus, assert_hunks}; -use call::{ActiveCall, ParticipantLocation, Room, room}; +use call::{ActiveCall, Room, room}; use client::{RECEIVE_TIMEOUT, User}; use collab::rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT}; use collections::{BTreeMap, HashMap, HashSet}; @@ -51,7 +51,7 @@ use std::{ }; use unindent::Unindent as _; use util::{path, rel_path::rel_path, uri}; -use workspace::Pane; +use workspace::{Pane, ParticipantLocation}; #[ctor::ctor] fn init_logger() { diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index a1bbc26b9c44d0..179e217d207554 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -1035,10 +1035,9 @@ impl Copilot { }; let buffer_entity = buffer.clone(); let lsp = server.lsp.clone(); - let registered_buffer = server - .registered_buffers - .get_mut(&buffer.entity_id()) - .unwrap(); + let Some(registered_buffer) = server.registered_buffers.get_mut(&buffer.entity_id()) else { + return Task::ready(Err(anyhow::anyhow!("buffer not registered"))); + }; let pending_snapshot = registered_buffer.report_changes(buffer, cx); let buffer = buffer.read(cx); let uri = registered_buffer.uri.clone(); diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 51b11a24923c11..cac96918e32cde 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -35,6 +35,7 @@ use tree_sitter::{Query, StreamingIterator as _}; use ui::{ ContextMenu, Divider, PopoverMenu, PopoverMenuHandle, SplitButton, Tab, Tooltip, prelude::*, }; +use util::redact::redact_command; use util::rel_path::RelPath; use util::{ResultExt, debug_panic, maybe}; use workspace::SplitDirection; @@ -43,7 +44,7 @@ use workspace::{ Item, Pane, Workspace, dock::{DockPosition, Panel, PanelEvent}, }; -use zed_actions::ToggleFocus; +use zed_actions::debug_panel::ToggleFocus; pub struct DebuggerHistoryFeatureFlag; @@ -275,12 +276,13 @@ impl DebugPanel { async move |_, cx| { if let Err(error) = task.await { - log::error!("{error:#}"); + let redacted_error = redact_command(&format!("{error:#}")); + log::error!("{redacted_error}"); session .update(cx, |session, cx| { session .console_output(cx) - .unbounded_send(format!("error: {:#}", error)) + .unbounded_send(format!("error: {:#}", redacted_error)) .ok(); session.shutdown(cx) }) diff --git a/crates/debugger_ui/src/debugger_ui.rs b/crates/debugger_ui/src/debugger_ui.rs index 3a70216ec743f7..310a9036821a70 100644 --- a/crates/debugger_ui/src/debugger_ui.rs +++ b/crates/debugger_ui/src/debugger_ui.rs @@ -13,7 +13,7 @@ use tasks_ui::{Spawn, TaskOverrides}; use ui::{FluentBuilder, InteractiveElement}; use util::maybe; use workspace::{ItemHandle, ShutdownDebugAdapters, Workspace}; -use zed_actions::{Toggle, ToggleFocus}; +use zed_actions::debug_panel::{Toggle, ToggleFocus}; pub mod attach_modal; pub mod debugger_panel; diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index 626c807ded5e0a..59e7226f596f12 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -356,11 +356,11 @@ pub(crate) fn new_debugger_pane( debug_assert!(_previous_subscription.is_none()); running .panes - .split(&this_pane, &new_pane, split_direction, cx)?; - anyhow::Ok(new_pane) + .split(&this_pane, &new_pane, split_direction, cx); + new_pane }); - match new_pane.and_then(|r| r) { + match new_pane { Ok(new_pane) => { move_item( &source, diff --git a/crates/debugger_ui/src/session/running/stack_frame_list.rs b/crates/debugger_ui/src/session/running/stack_frame_list.rs index b460f532548d8a..ccdfa22e89f449 100644 --- a/crates/debugger_ui/src/session/running/stack_frame_list.rs +++ b/crates/debugger_ui/src/session/running/stack_frame_list.rs @@ -429,34 +429,58 @@ impl StackFrameList { let position = buffer.read_with(cx, |this, _| { this.snapshot().anchor_after(PointUtf16::new(row, 0)) }); - this.update_in(cx, |this, window, cx| { - this.workspace.update(cx, |workspace, cx| { - let project_path = buffer - .read(cx) - .project_path(cx) - .context("Could not select a stack frame for unnamed buffer")?; - - let open_preview = !workspace - .item_of_type::(cx) - .map(|viewer| { - workspace - .active_item(cx) - .is_some_and(|item| item.item_id() == viewer.item_id()) - }) - .unwrap_or_default(); - - anyhow::Ok(workspace.open_path_preview( - project_path, - None, - true, - true, - open_preview, - window, - cx, - )) - }) - })??? - .await?; + let opened_item = this + .update_in(cx, |this, window, cx| { + this.workspace.update(cx, |workspace, cx| { + let project_path = buffer + .read(cx) + .project_path(cx) + .context("Could not select a stack frame for unnamed buffer")?; + + let open_preview = !workspace + .item_of_type::(cx) + .map(|viewer| { + workspace + .active_item(cx) + .is_some_and(|item| item.item_id() == viewer.item_id()) + }) + .unwrap_or_default(); + + let active_debug_line_pane = workspace + .project() + .read(cx) + .breakpoint_store() + .read(cx) + .active_debug_line_pane_id() + .and_then(|id| workspace.pane_for_entity_id(id)); + + let debug_pane = if let Some(pane) = active_debug_line_pane { + Some(pane.downgrade()) + } else { + // No debug pane set yet. Find a pane where the target file + // is already the active tab so we don't disrupt other panes. + let pane_with_active_file = workspace.panes().iter().find(|pane| { + pane.read(cx) + .active_item() + .and_then(|item| item.project_path(cx)) + .is_some_and(|path| path == project_path) + }); + + pane_with_active_file.map(|pane| pane.downgrade()) + }; + + anyhow::Ok(workspace.open_path_preview( + project_path, + debug_pane, + true, + true, + open_preview, + window, + cx, + )) + }) + })??? + .await?; this.update(cx, |this, cx| { let thread_id = this.state.read_with(cx, |state, _| { @@ -464,6 +488,19 @@ impl StackFrameList { })??; this.workspace.update(cx, |workspace, cx| { + if let Some(pane_id) = workspace + .pane_for(&*opened_item) + .map(|pane| pane.entity_id()) + { + workspace + .project() + .read(cx) + .breakpoint_store() + .update(cx, |store, _cx| { + store.set_active_debug_pane_id(pane_id); + }); + } + let breakpoint_store = workspace.project().read(cx).breakpoint_store(); breakpoint_store.update(cx, |store, cx| { diff --git a/crates/debugger_ui/src/tests/debugger_panel.rs b/crates/debugger_ui/src/tests/debugger_panel.rs index 32c0bf01c91a32..207e82b4958941 100644 --- a/crates/debugger_ui/src/tests/debugger_panel.rs +++ b/crates/debugger_ui/src/tests/debugger_panel.rs @@ -34,7 +34,8 @@ use terminal_view::terminal_panel::TerminalPanel; use tests::{active_debug_session_panel, init_test, init_test_workspace}; use util::{path, rel_path::rel_path}; use workspace::item::SaveOptions; -use workspace::{Item, dock::Panel}; +use workspace::pane_group::SplitDirection; +use workspace::{Item, dock::Panel, move_active_item}; #[gpui::test] async fn test_basic_show_debug_panel(executor: BackgroundExecutor, cx: &mut TestAppContext) { @@ -1813,6 +1814,536 @@ async fn test_debug_adapters_shutdown_on_app_quit( ); } +#[gpui::test] +async fn test_breakpoint_jumps_only_in_proper_split_view( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + path!("/project"), + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + "second.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let project_path = Path::new(path!("/project")); + let worktree = project + .update(cx, |project, cx| project.find_worktree(project_path, cx)) + .expect("This worktree should exist in project") + .0; + + let worktree_id = workspace + .update(cx, |_, _, cx| worktree.read(cx).id()) + .unwrap(); + + // Open main.rs in pane A (the initial pane) + let pane_a = workspace + .update(cx, |multi, _window, cx| { + multi.workspace().read(cx).active_pane().clone() + }) + .unwrap(); + + let open_main = workspace + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx) + }) + }) + .unwrap(); + open_main.await.unwrap(); + + cx.run_until_parked(); + + // Split pane A to the right, creating pane B + let pane_b = workspace + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + workspace.split_pane(pane_a.clone(), SplitDirection::Right, window, cx) + }) + }) + .unwrap(); + + cx.run_until_parked(); + + // Open main.rs in pane B + let weak_pane_b = pane_b.downgrade(); + let open_main_in_b = workspace + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + workspace.open_path( + (worktree_id, rel_path("main.rs")), + Some(weak_pane_b), + true, + window, + cx, + ) + }) + }) + .unwrap(); + open_main_in_b.await.unwrap(); + + cx.run_until_parked(); + + // Also open second.rs in pane B as an inactive tab + let weak_pane_b = pane_b.downgrade(); + let open_second_in_b = workspace + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + workspace.open_path( + (worktree_id, rel_path("second.rs")), + Some(weak_pane_b), + true, + window, + cx, + ) + }) + }) + .unwrap(); + open_second_in_b.await.unwrap(); + + cx.run_until_parked(); + + // Switch pane B back to main.rs so second.rs is inactive there + let weak_pane_b = pane_b.downgrade(); + let reactivate_main_in_b = workspace + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + workspace.open_path( + (worktree_id, rel_path("main.rs")), + Some(weak_pane_b), + true, + window, + cx, + ) + }) + }) + .unwrap(); + reactivate_main_in_b.await.unwrap(); + + cx.run_until_parked(); + + // Now open second.rs in pane A, making main.rs an inactive tab there + let weak_pane_a = pane_a.downgrade(); + let open_second = workspace + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + workspace.open_path( + (worktree_id, rel_path("second.rs")), + Some(weak_pane_a), + true, + window, + cx, + ) + }) + }) + .unwrap(); + open_second.await.unwrap(); + + cx.run_until_parked(); + + // Layout: + // Pane A: second.rs (active), main.rs (inactive tab) + // Pane B: main.rs (active), second.rs (inactive tab) + + // Verify pane A's active item is second.rs (main.rs is an inactive tab) + workspace + .read_with(cx, |_multi, cx| { + let active = pane_a.read(cx).active_item().unwrap(); + let editor = active.to_any_view().downcast::().unwrap(); + let path = editor.read(cx).project_path(cx).unwrap(); + assert_eq!( + path.path.file_name().unwrap(), + "second.rs", + "Pane A should have second.rs active", + ); + }) + .unwrap(); + + // Verify pane B's active item is main.rs + workspace + .read_with(cx, |_multi, cx| { + let active = pane_b.read(cx).active_item().unwrap(); + let editor = active.to_any_view().downcast::().unwrap(); + let path = editor.read(cx).project_path(cx).unwrap(); + assert_eq!( + path.path.file_name().unwrap(), + "main.rs", + "Pane B should have main.rs active", + ); + }) + .unwrap(); + + // Start a debug session and trigger a breakpoint stop on main.rs line 2 + let session = start_debug_session(&workspace, cx, |_| {}).unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client.on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }); + + client.on_request::(move |_, _| { + Ok(dap::ScopesResponse { + scopes: Vec::default(), + }) + }); + + client.on_request::(move |_, args| { + assert_eq!(args.thread_id, 1); + + Ok(dap::StackTraceResponse { + stack_frames: vec![dap::StackFrame { + id: 1, + name: "frame 1".into(), + source: Some(dap::Source { + name: Some("main.rs".into()), + path: Some(path!("/project/main.rs").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 2, + column: 0, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }], + total_frames: None, + }) + }); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Breakpoint, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + // After first breakpoint stop on main.rs: + // Pane A should still have second.rs as its active item because + // main.rs was only an inactive tab there. The debugger should have jumped + // to main.rs only in pane B where it was already the active tab. + workspace + .read_with(cx, |_multi, cx| { + let pane_a_active = pane_a.read(cx).active_item().unwrap(); + let pane_a_editor = pane_a_active.to_any_view().downcast::().unwrap(); + let pane_a_path = pane_a_editor.read(cx).project_path(cx).unwrap(); + assert_eq!( + pane_a_path.path.file_name().unwrap(), + "second.rs", + "Pane A should still have second.rs as active item. \ + The debugger should not switch active tabs in panes where the \ + breakpoint file is not the active tab (issue #40602)", + ); + }) + .unwrap(); + + // There should be exactly one active debug line across all editors in all panes + workspace + .read_with(cx, |_multi, cx| { + let mut total_active_debug_lines = 0; + for pane in [&pane_a, &pane_b] { + for item in pane.read(cx).items() { + if let Some(editor) = item.to_any_view().downcast::().ok() { + total_active_debug_lines += editor + .read(cx) + .highlighted_rows::() + .count(); + } + } + } + assert_eq!( + total_active_debug_lines, 1, + "There should be exactly one active debug line across all editors in all panes" + ); + }) + .unwrap(); + + // Pane B should show the debug highlight on main.rs + workspace + .read_with(cx, |_multi, cx| { + let pane_b_active = pane_b.read(cx).active_item().unwrap(); + let pane_b_editor = pane_b_active.to_any_view().downcast::().unwrap(); + + let active_debug_lines: Vec<_> = pane_b_editor + .read(cx) + .highlighted_rows::() + .collect(); + + assert_eq!( + active_debug_lines.len(), + 1, + "Pane B's main.rs editor should have the active debug line" + ); + }) + .unwrap(); + + // Second breakpoint stop: now on second.rs line 3. + // Even though pane A has second.rs as its active tab, the debug line + // should open in pane B (the persistent debug pane) because pane B + // had the last active debug line. + client.on_request::(move |_, args| { + assert_eq!(args.thread_id, 1); + + Ok(dap::StackTraceResponse { + stack_frames: vec![dap::StackFrame { + id: 2, + name: "frame 2".into(), + source: Some(dap::Source { + name: Some("second.rs".into()), + path: Some(path!("/project/second.rs").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 3, + column: 0, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }], + total_frames: None, + }) + }); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Breakpoint, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + // Pane B should now have second.rs as the active tab with the debug line, + // because pane B was the last pane that had the debug line (persistent debug pane). + workspace + .read_with(cx, |_multi, cx| { + let pane_b_active = pane_b.read(cx).active_item().unwrap(); + let pane_b_editor = pane_b_active.to_any_view().downcast::().unwrap(); + let pane_b_path = pane_b_editor.read(cx).project_path(cx).unwrap(); + assert_eq!( + pane_b_path.path.file_name().unwrap(), + "second.rs", + "Pane B should have switched to second.rs because it is the persistent debug pane", + ); + + let active_debug_lines: Vec<_> = pane_b_editor + .read(cx) + .highlighted_rows::() + .collect(); + + assert_eq!( + active_debug_lines.len(), + 1, + "Pane B's second.rs editor should have the active debug line" + ); + }) + .unwrap(); + + // There should still be exactly one active debug line across all editors + workspace + .read_with(cx, |_multi, cx| { + let mut total_active_debug_lines = 0; + for pane in [&pane_a, &pane_b] { + for item in pane.read(cx).items() { + if let Some(editor) = item.to_any_view().downcast::().ok() { + total_active_debug_lines += editor + .read(cx) + .highlighted_rows::() + .count(); + } + } + } + assert_eq!( + total_active_debug_lines, 1, + "There should be exactly one active debug line across all editors after second stop" + ); + }) + .unwrap(); + + // === New case: Move the debug pane (pane B) active item to a new pane C === + // This simulates a user dragging the tab with the active debug line to a new split. + // The debugger should track that the debug line moved to pane C and use pane C + // for subsequent debug stops. + + // Split pane B to create pane C + let pane_c = workspace + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + workspace.split_pane(pane_b.clone(), SplitDirection::Right, window, cx) + }) + }) + .unwrap(); + + cx.run_until_parked(); + + // Move the active item (second.rs with debug line) from pane B to pane C + workspace + .update(cx, |_multi, window, cx| { + move_active_item(&pane_b, &pane_c, true, false, window, cx); + }) + .unwrap(); + + cx.run_until_parked(); + + // Verify pane C now has second.rs as active item + workspace + .read_with(cx, |_multi, cx| { + let pane_c_active = pane_c.read(cx).active_item().unwrap(); + let pane_c_editor = pane_c_active.to_any_view().downcast::().unwrap(); + let pane_c_path = pane_c_editor.read(cx).project_path(cx).unwrap(); + assert_eq!( + pane_c_path.path.file_name().unwrap(), + "second.rs", + "Pane C should have second.rs after moving it from pane B", + ); + }) + .unwrap(); + + // Third breakpoint stop: back on main.rs line 2. + // The debug line should appear in pane C because that's where the debug line + // was moved to. The debugger should track pane moves. + client.on_request::(move |_, args| { + assert_eq!(args.thread_id, 1); + + Ok(dap::StackTraceResponse { + stack_frames: vec![dap::StackFrame { + id: 3, + name: "frame 3".into(), + source: Some(dap::Source { + name: Some("main.rs".into()), + path: Some(path!("/project/main.rs").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 2, + column: 0, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }], + total_frames: None, + }) + }); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Breakpoint, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + // Pane C should now have main.rs as the active tab with the debug line, + // because pane C is where the debug line was moved to from pane B. + workspace + .read_with(cx, |_multi, cx| { + let pane_c_active = pane_c.read(cx).active_item().unwrap(); + let pane_c_editor = pane_c_active.to_any_view().downcast::().unwrap(); + let pane_c_path = pane_c_editor.read(cx).project_path(cx).unwrap(); + assert_eq!( + pane_c_path.path.file_name().unwrap(), + "main.rs", + "Pane C should have switched to main.rs because it is now the persistent debug pane \ + (the debug line was moved here from pane B)", + ); + + let active_debug_lines: Vec<_> = pane_c_editor + .read(cx) + .highlighted_rows::() + .collect(); + + assert_eq!( + active_debug_lines.len(), + 1, + "Pane C's main.rs editor should have the active debug line" + ); + }) + .unwrap(); + + // There should still be exactly one active debug line across all editors + workspace + .read_with(cx, |_multi, cx| { + let mut total_active_debug_lines = 0; + for pane in [&pane_a, &pane_b, &pane_c] { + for item in pane.read(cx).items() { + if let Some(editor) = item.to_any_view().downcast::().ok() { + total_active_debug_lines += editor + .read(cx) + .highlighted_rows::() + .count(); + } + } + } + assert_eq!( + total_active_debug_lines, 1, + "There should be exactly one active debug line across all editors after third stop" + ); + }) + .unwrap(); + + // Clean up + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(session.read(cx).session_id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + #[gpui::test] async fn test_adapter_shutdown_with_child_sessions_on_app_quit( executor: BackgroundExecutor, diff --git a/crates/edit_prediction/src/cursor_excerpt.rs b/crates/edit_prediction/src/cursor_excerpt.rs index 18a13130720ca2..73a906e4fc1808 100644 --- a/crates/edit_prediction/src/cursor_excerpt.rs +++ b/crates/edit_prediction/src/cursor_excerpt.rs @@ -13,6 +13,7 @@ pub fn compute_excerpt_ranges( let editable_150 = compute_editable_range(snapshot, position, 150); let editable_180 = compute_editable_range(snapshot, position, 180); let editable_350 = compute_editable_range(snapshot, position, 350); + let full_512 = compute_editable_range(snapshot, position, 512); let editable_150_context_350 = expand_context_syntactically_then_linewise(snapshot, editable_150.clone(), 350); @@ -21,14 +22,16 @@ pub fn compute_excerpt_ranges( let editable_350_context_150 = expand_context_syntactically_then_linewise(snapshot, editable_350.clone(), 150); - let full_start_row = editable_150_context_350 + let full_start_row = full_512 .start .row + .min(editable_150_context_350.start.row) .min(editable_180_context_350.start.row) .min(editable_350_context_150.start.row); - let full_end_row = editable_150_context_350 + let full_end_row = full_512 .end .row + .max(editable_150_context_350.end.row) .max(editable_180_context_350.end.row) .max(editable_350_context_150.end.row); diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index fb6af292fca3b6..78f42db2120b45 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -105,13 +105,14 @@ const ZED_PREDICT_DATA_COLLECTION_CHOICE: &str = "zed_predict_data_collection_ch const REJECT_REQUEST_DEBOUNCE: Duration = Duration::from_secs(15); pub struct Zeta2FeatureFlag; +pub struct EditPredictionJumpsFeatureFlag; impl FeatureFlag for Zeta2FeatureFlag { const NAME: &'static str = "zeta2"; +} - fn enabled_for_staff() -> bool { - true - } +impl FeatureFlag for EditPredictionJumpsFeatureFlag { + const NAME: &'static str = "edit_prediction_jumps"; } #[derive(Clone)] @@ -1035,7 +1036,7 @@ impl EditPredictionStore { } } project::Event::DiagnosticsUpdated { .. } => { - if cx.has_flag::() { + if cx.has_flag::() { self.refresh_prediction_from_diagnostics( project, DiagnosticSearchScope::Global, diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index eb76e0fd05182a..b0468e3c5610b8 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -1687,12 +1687,18 @@ async fn test_rejections_flushing(cx: &mut TestAppContext) { // Generate a model response that would apply the given diff to the active file. fn model_response(request: &PredictEditsV3Request, diff_to_apply: &str) -> PredictEditsV3Response { - let excerpt = - request.input.cursor_excerpt[request.input.editable_range_in_excerpt.clone()].to_string(); + let editable_range = request + .input + .excerpt_ranges + .as_ref() + .map(|r| zeta_prompt::excerpt_range_for_format(Default::default(), r).1) + .unwrap_or(request.input.editable_range_in_excerpt.clone()); + let excerpt = request.input.cursor_excerpt[editable_range.clone()].to_string(); let new_excerpt = apply_diff_to_string(diff_to_apply, &excerpt).unwrap(); PredictEditsV3Response { request_id: Uuid::new_v4().to_string(), + editable_range, output: new_excerpt, } } @@ -1700,6 +1706,7 @@ fn model_response(request: &PredictEditsV3Request, diff_to_apply: &str) -> Predi fn empty_response() -> PredictEditsV3Response { PredictEditsV3Response { request_id: Uuid::new_v4().to_string(), + editable_range: 0..0, output: String::new(), } } @@ -2018,13 +2025,15 @@ async fn test_edit_prediction_no_spurious_trailing_newline(cx: &mut TestAppConte ep_store.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_request, respond_tx) = requests.predict.next().await.unwrap(); + let (request, respond_tx) = requests.predict.next().await.unwrap(); // Model returns output WITH a trailing newline, even though the buffer doesn't have one. // Zeta2 should normalize both sides before diffing, so no spurious newline is inserted. + let excerpt_length = request.input.cursor_excerpt.len(); let response = PredictEditsV3Response { request_id: Uuid::new_v4().to_string(), output: "hello world\n".to_string(), + editable_range: 0..excerpt_length, }; respond_tx.send(response).unwrap(); @@ -2099,9 +2108,12 @@ async fn make_test_ep_store( let mut next_request_id = 0; move |req| { let completion_response = completion_response.clone(); + let method = req.method().clone(); + let uri = req.uri().path().to_string(); + let mut body = req.into_body(); async move { - match (req.method(), req.uri().path()) { - (&Method::POST, "/client/llm_tokens") => Ok(http_client::Response::builder() + match (method, uri.as_str()) { + (Method::POST, "/client/llm_tokens") => Ok(http_client::Response::builder() .status(200) .body( serde_json::to_string(&CreateLlmTokenResponse { @@ -2111,13 +2123,20 @@ async fn make_test_ep_store( .into(), ) .unwrap()), - (&Method::POST, "/predict_edits/v3") => { + (Method::POST, "/predict_edits/v3") => { + let mut buf = Vec::new(); + body.read_to_end(&mut buf).await.ok(); + let decompressed = zstd::decode_all(&buf[..]).unwrap(); + let req: PredictEditsV3Request = + serde_json::from_slice(&decompressed).unwrap(); + next_request_id += 1; Ok(http_client::Response::builder() .status(200) .body( serde_json::to_string(&PredictEditsV3Response { request_id: format!("request-{next_request_id}"), + editable_range: 0..req.input.cursor_excerpt.len(), output: completion_response.lock().clone(), }) .unwrap() @@ -2127,7 +2146,7 @@ async fn make_test_ep_store( } _ => Ok(http_client::Response::builder() .status(404) - .body("Not Found".into()) + .body("Not Found".to_string().into()) .unwrap()), } } diff --git a/crates/edit_prediction/src/zeta.rs b/crates/edit_prediction/src/zeta.rs index 658071c9ccfbdf..41877d10d6e3ed 100644 --- a/crates/edit_prediction/src/zeta.rs +++ b/crates/edit_prediction/src/zeta.rs @@ -79,7 +79,8 @@ pub fn request_prediction_with_zeta( .unwrap_or(ZetaFormat::default()); let cursor_offset = position.to_offset(&snapshot); - let (editable_offset_range, prompt_input) = zeta2_prompt_input( + let editable_range_in_excerpt: Range; + let (full_context_offset_range, prompt_input) = zeta2_prompt_input( &snapshot, related_files, events, @@ -124,113 +125,129 @@ pub fn request_prediction_with_zeta( log::trace!("Sending edit prediction request"); - let (request_id, output_text, usage) = - if let Some(custom_settings) = &custom_server_settings { - let max_tokens = custom_settings.max_output_tokens * 4; - - if is_zeta1 { - let ranges = excerpt_ranges; - let prompt = zeta1::format_zeta1_from_input( - &prompt_input, - ranges.editable_350.clone(), - ranges.editable_350_context_150.clone(), - ); - let stop_tokens = vec![ - EDITABLE_REGION_END_MARKER.to_string(), - format!("{EDITABLE_REGION_END_MARKER}\n"), - format!("{EDITABLE_REGION_END_MARKER}\n\n"), - format!("{EDITABLE_REGION_END_MARKER}\n\n\n"), - ]; - - let (response_text, request_id) = send_custom_server_request( - provider, - custom_settings, - prompt, - max_tokens, - stop_tokens, - &http_client, - ) - .await?; - - let request_id = EditPredictionId(request_id.into()); - let output_text = zeta1::clean_zeta1_model_output(&response_text); - - (request_id, output_text, None) - } else { - let prompt = format_zeta_prompt(&prompt_input, zeta_version); - let prefill = get_prefill(&prompt_input, zeta_version); - let prompt = format!("{prompt}{prefill}"); - - let (response_text, request_id) = send_custom_server_request( - provider, - custom_settings, - prompt, - max_tokens, - vec![], - &http_client, - ) - .await?; - - let request_id = EditPredictionId(request_id.into()); - let output_text = if response_text.is_empty() { - None - } else { - let output = format!("{prefill}{response_text}"); - Some(clean_zeta2_model_output(&output, zeta_version).to_string()) - }; - - (request_id, output_text, None) - } - } else if let Some(config) = &raw_config { - let prompt = format_zeta_prompt(&prompt_input, config.format); - let prefill = get_prefill(&prompt_input, config.format); - let prompt = format!("{prompt}{prefill}"); - let request = RawCompletionRequest { - model: config.model_id.clone().unwrap_or_default(), - prompt, - temperature: None, - stop: vec![], - max_tokens: Some(2048), - environment: Some(config.format.to_string().to_lowercase()), - }; + let (request_id, output_text, usage) = if let Some(custom_settings) = + &custom_server_settings + { + let max_tokens = custom_settings.max_output_tokens * 4; - let (mut response, usage) = EditPredictionStore::send_raw_llm_request( - request, - client, - None, - llm_token, - app_version, + if is_zeta1 { + let ranges = excerpt_ranges; + let prompt = zeta1::format_zeta1_from_input( + &prompt_input, + ranges.editable_350.clone(), + ranges.editable_350_context_150.clone(), + ); + editable_range_in_excerpt = ranges.editable_350.clone(); + let stop_tokens = vec![ + EDITABLE_REGION_END_MARKER.to_string(), + format!("{EDITABLE_REGION_END_MARKER}\n"), + format!("{EDITABLE_REGION_END_MARKER}\n\n"), + format!("{EDITABLE_REGION_END_MARKER}\n\n\n"), + ]; + + let (response_text, request_id) = send_custom_server_request( + provider, + custom_settings, + prompt, + max_tokens, + stop_tokens, + &http_client, ) .await?; - let request_id = EditPredictionId(response.id.clone().into()); - let output_text = response.choices.pop().map(|choice| { - let response = &choice.text; - let output = format!("{prefill}{response}"); - clean_zeta2_model_output(&output, config.format).to_string() - }); + let request_id = EditPredictionId(request_id.into()); + let output_text = zeta1::clean_zeta1_model_output(&response_text); - (request_id, output_text, usage) + (request_id, output_text, None) } else { - // Use V3 endpoint - server handles model/version selection and suffix stripping - let (response, usage) = EditPredictionStore::send_v3_request( - prompt_input.clone(), - client, - llm_token, - app_version, - trigger, + let prompt = format_zeta_prompt(&prompt_input, zeta_version); + let prefill = get_prefill(&prompt_input, zeta_version); + let prompt = format!("{prompt}{prefill}"); + + editable_range_in_excerpt = prompt_input + .excerpt_ranges + .as_ref() + .map(|ranges| zeta_prompt::excerpt_range_for_format(zeta_version, ranges).0) + .unwrap_or(prompt_input.editable_range_in_excerpt.clone()); + + let (response_text, request_id) = send_custom_server_request( + provider, + custom_settings, + prompt, + max_tokens, + vec![], + &http_client, ) .await?; - let request_id = EditPredictionId(response.request_id.into()); - let output_text = if response.output.is_empty() { + let request_id = EditPredictionId(request_id.into()); + let output_text = if response_text.is_empty() { None } else { - Some(response.output) + let output = format!("{prefill}{response_text}"); + Some(clean_zeta2_model_output(&output, zeta_version).to_string()) }; - (request_id, output_text, usage) + + (request_id, output_text, None) + } + } else if let Some(config) = &raw_config { + let prompt = format_zeta_prompt(&prompt_input, config.format); + let prefill = get_prefill(&prompt_input, config.format); + let prompt = format!("{prompt}{prefill}"); + let request = RawCompletionRequest { + model: config.model_id.clone().unwrap_or_default(), + prompt, + temperature: None, + stop: vec![], + max_tokens: Some(2048), + environment: Some(config.format.to_string().to_lowercase()), }; + editable_range_in_excerpt = prompt_input + .excerpt_ranges + .as_ref() + .map(|ranges| zeta_prompt::excerpt_range_for_format(config.format, ranges).1) + .unwrap_or(prompt_input.editable_range_in_excerpt.clone()); + + let (mut response, usage) = EditPredictionStore::send_raw_llm_request( + request, + client, + None, + llm_token, + app_version, + ) + .await?; + + let request_id = EditPredictionId(response.id.clone().into()); + let output_text = response.choices.pop().map(|choice| { + let response = &choice.text; + let output = format!("{prefill}{response}"); + clean_zeta2_model_output(&output, config.format).to_string() + }); + + (request_id, output_text, usage) + } else { + // Use V3 endpoint - server handles model/version selection and suffix stripping + let (response, usage) = EditPredictionStore::send_v3_request( + prompt_input.clone(), + client, + llm_token, + app_version, + trigger, + ) + .await?; + + let request_id = EditPredictionId(response.request_id.into()); + let output_text = if response.output.is_empty() { + None + } else { + Some(response.output) + }; + editable_range_in_excerpt = response.editable_range; + + (request_id, output_text, usage) + }; + let received_response_at = Instant::now(); log::trace!("Got edit prediction response"); @@ -258,8 +275,12 @@ pub fn request_prediction_with_zeta( .ok(); } + let editable_range_in_buffer = editable_range_in_excerpt.start + + full_context_offset_range.start + ..editable_range_in_excerpt.end + full_context_offset_range.start; + let mut old_text = snapshot - .text_for_range(editable_offset_range.clone()) + .text_for_range(editable_range_in_buffer.clone()) .collect::(); if !output_text.is_empty() && !output_text.ends_with('\n') { @@ -272,7 +293,7 @@ pub fn request_prediction_with_zeta( let (edits, cursor_position) = compute_edits_and_cursor_position( old_text, &output_text, - editable_offset_range.start, + editable_range_in_buffer.start, cursor_offset_in_output, &snapshot, ); @@ -287,6 +308,8 @@ pub fn request_prediction_with_zeta( edits, cursor_position, received_response_at, + full_context_offset_range, + editable_range_in_buffer, )), )), usage, @@ -308,6 +331,8 @@ pub fn request_prediction_with_zeta( edits, cursor_position, received_response_at, + full_context_offset_range, + editable_range_in_buffer, )) = prediction else { return Ok(Some(EditPredictionResult { @@ -316,6 +341,47 @@ pub fn request_prediction_with_zeta( })); }; + if can_collect_data { + cx.spawn({ + let weak_buffer = edited_buffer.downgrade(); + let context_anchor_range = + edited_buffer_snapshot.anchor_range_around(full_context_offset_range); + let editable_anchor_range = + edited_buffer_snapshot.anchor_range_around(editable_range_in_buffer); + let request_id = id.0.clone(); + async move |cx| { + cx.background_executor() + .timer(std::time::Duration::from_secs(30)) + .await; + + let Some(buffer) = weak_buffer.upgrade() else { + return; + }; + let (new_cursor_region, editable_range_in_excerpt) = + buffer.read_with(cx, |buffer, _| { + let context_start = + buffer.offset_for_anchor(&context_anchor_range.start); + let editable_range_in_excerpt = (buffer + .offset_for_anchor(&editable_anchor_range.start) + - context_start) + ..(buffer.offset_for_anchor(&editable_anchor_range.end) + - context_start); + let text = buffer + .text_for_range(context_anchor_range) + .collect::(); + (text, editable_range_in_excerpt) + }); + telemetry::event!( + "Edit Prediction Snapshot", + request_id, + new_cursor_region, + editable_range_in_excerpt, + ); + } + }) + .detach(); + } + Ok(Some( EditPredictionResult::new( id, @@ -343,7 +409,7 @@ pub fn zeta2_prompt_input( preferred_model: Option, is_open_source: bool, can_collect_data: bool, -) -> (std::ops::Range, zeta_prompt::ZetaPromptInput) { +) -> (Range, zeta_prompt::ZetaPromptInput) { let cursor_point = cursor_offset.to_point(snapshot); let (full_context, full_context_offset_range, excerpt_ranges) = @@ -362,8 +428,6 @@ pub fn zeta2_prompt_input( Some(EditPredictionModelKind::Zeta1) => excerpt_ranges.editable_350.clone(), _ => zeta_prompt::excerpt_range_for_format(zeta_format, &excerpt_ranges).0, }; - let absolute_editable_range = full_context_start_offset + editable_offset_range.start - ..full_context_start_offset + editable_offset_range.end; let cursor_offset_in_excerpt = cursor_offset - full_context_start_offset; @@ -383,7 +447,7 @@ pub fn zeta2_prompt_input( in_open_source_repo: is_open_source, can_collect_data, }; - (absolute_editable_range, prompt_input) + (full_context_offset_range, prompt_input) } pub(crate) async fn send_custom_server_request( diff --git a/crates/edit_prediction_cli/evals/.zed/settings.json b/crates/edit_prediction_cli/evals/.zed/settings.json index f1e74a3aee3b9c..708c4b864dca91 100644 --- a/crates/edit_prediction_cli/evals/.zed/settings.json +++ b/crates/edit_prediction_cli/evals/.zed/settings.json @@ -1,3 +1,4 @@ { "remove_trailing_whitespace_on_save": false, + "soft_wrap": "none", } diff --git a/crates/edit_prediction_cli/evals/vscode--add-async-and-await.md b/crates/edit_prediction_cli/evals/vscode--add-async-and-await.md new file mode 100644 index 00000000000000..ee070bdb9d7ca9 --- /dev/null +++ b/crates/edit_prediction_cli/evals/vscode--add-async-and-await.md @@ -0,0 +1,88 @@ ++++ +repository_url = "https://github.com/microsoft/vscode" +revision = "29e6da6efa2287aaa981635a475d425ff4fd5d5c" ++++ + +## Edit History + +```diff +--- a/src/vs/workbench/contrib/debug/browser/debugCommands.ts ++++ b/src/vs/workbench/contrib/debug/browser/debugCommands.ts +@@ -304,8 +304,8 @@ CommandsRegistry.registerCommand({ + + CommandsRegistry.registerCommand({ + id: REVERSE_CONTINUE_ID, +- handler: (accessor: ServicesAccessor, _: string, context: CallStackContext | unknown) => { +- getThreadAndRun(accessor, context, thread => thread.reverseContinue()); ++ handler: async (accessor: ServicesAccessor, _: string, context: CallStackContext | unknown) => { ++ await getThreadAndRun(accessor, context, thread => thread.reverseContinue()); + } + }); +--- a/src/vs/workbench/contrib/debug/browser/debugCommands.ts ++++ b/src/vs/workbench/contrib/debug/browser/debugCommands.ts +@@ -311,11 +311,11 @@ CommandsRegistry.registerCommand({ + + CommandsRegistry.registerCommand({ + id: STEP_BACK_ID, +- handler: (accessor: ServicesAccessor, _: string, context: CallStackContext | unknown) => { ++ handler: async (accessor: ServicesAccessor, _: string, context: CallStackContext | unknown) => { + const contextKeyService = accessor.get(IContextKeyService); + if (CONTEXT_DISASSEMBLY_VIEW_FOCUS.getValue(contextKeyService)) { +- getThreadAndRun(accessor, context, (thread: IThread) => thread.stepBack('instruction')); ++ await getThreadAndRun(accessor, context, (thread: IThread) => thread.stepBack('instruction')); + } else { +- getThreadAndRun(accessor, context, (thread: IThread) => thread.stepBack()); ++ await getThreadAndRun(accessor, context, (thread: IThread) => thread.stepBack()); + } + } + }); +--- a/src/vs/workbench/contrib/debug/browser/debugCommands.ts ++++ b/src/vs/workbench/contrib/debug/browser/debugCommands.ts +@@ -323,8 +323,8 @@ CommandsRegistry.registerCommand({ + + CommandsRegistry.registerCommand({ + id: TERMINATE_THREAD_ID, +- handler: (accessor: ServicesAccessor, _: string, context: CallStackContext | unknown) => { +- getThreadAndRun(accessor, context, thread => thread.terminate()); ++ handler: async (accessor: ServicesAccessor, _: string, context: CallStackContext | unknown) => { ++ await getThreadAndRun(accessor, context, thread => thread.terminate()); + } + }); +``` + +## Cursor Position + +```src/vs/workbench/contrib/debug/browser/debugCommands.ts + weight: KeybindingWeight.WorkbenchContrib, + primary: isWeb ? (KeyMod.Alt | KeyCode.F10) : KeyCode.F10, // Browsers do not allow F10 to be binded so we have to bind an alternative + when: CONTEXT_DEBUG_STATE.isEqualTo('stopped'), + handler: (accessor: ServicesAccessor, _: string, context: CallStackContext | unknown) => { + // ^[CURSOR_POSITION] + const contextKeyService = accessor.get(IContextKeyService); + if (CONTEXT_DISASSEMBLY_VIEW_FOCUS.getValue(contextKeyService)) { + getThreadAndRun(accessor, context, (thread: IThread) => thread.next('instruction')); + } else { +``` + +## Expected Patch + +```diff +--- a/src/vs/workbench/contrib/debug/browser/debugCommands.ts ++++ b/src/vs/workbench/contrib/debug/browser/debugCommands.ts +@@ -467,10 +467,10 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ + weight: KeybindingWeight.WorkbenchContrib, + primary: isWeb ? (KeyMod.Alt | KeyCode.F10) : KeyCode.F10, // Browsers do not allow F10 to be binded so we have to bind an alternative + when: CONTEXT_DEBUG_STATE.isEqualTo('stopped'), +- handler: (accessor: ServicesAccessor, _: string, context: CallStackContext | unknown) => { ++ handler: async (accessor: ServicesAccessor, _: string, context: CallStackContext | unknown) => { + const contextKeyService = accessor.get(IContextKeyService); + if (CONTEXT_DISASSEMBLY_VIEW_FOCUS.getValue(contextKeyService)) { +- getThreadAndRun(accessor, context, (thread: IThread) => thread.next('instruction')); ++ await getThreadAndRun(accessor, context, (thread: IThread) => thread.next('instruction')); + } else { +- getThreadAndRun(accessor, context, (thread: IThread) => thread.next()); ++ await getThreadAndRun(accessor, context, (thread: IThread) => thread.next()); + } + } + }); +``` diff --git a/crates/edit_prediction_cli/evals/vscode--add-class-decorator.md b/crates/edit_prediction_cli/evals/vscode--add-class-decorator.md new file mode 100644 index 00000000000000..1fd1feb90e24ac --- /dev/null +++ b/crates/edit_prediction_cli/evals/vscode--add-class-decorator.md @@ -0,0 +1,74 @@ ++++ +repository_url = "https://github.com/microsoft/vscode" +revision = "6f6e26fcdf0a7ca5084e0da284cd7a5b2d41ae4d" ++++ + +## Edit History + +```diff +--- a/src/vs/workbench/api/common/extHostTypes.ts ++++ b/src/vs/workbench/api/common/extHostTypes.ts +@@ -18,6 +18,14 @@ import { FileSystemProviderErrorCode, markAsFileSystemProviderError } from 'vs/ + import type * as vscode from 'vscode'; + ++function es5ClassCompat(target: Function): any { ++ ///@ts-expect-error ++ function _() { return Reflect.construct(target, arguments, this.constructor); } ++ Object.defineProperty(_, 'name', Object.getOwnPropertyDescriptor(target, 'name')!); ++ Object.setPrototypeOf(_, target); ++ Object.setPrototypeOf(_.prototype, target.prototype); ++ return _; ++} ++ ++@es5ClassCompat + export class Disposable { +--- a/src/vs/workbench/api/common/extHostTypes.ts ++++ b/src/vs/workbench/api/common/extHostTypes.ts +@@ -50,6 +58,7 @@ export class Disposable { + } + } + ++@es5ClassCompat + export class Position { + + static Min(...positions: Position[]): Position { +--- a/src/vs/workbench/api/common/extHostTypes.ts ++++ b/src/vs/workbench/api/common/extHostTypes.ts +@@ -220,6 +229,7 @@ export class Position { + } + } + ++@es5ClassCompat + export class Range { + + static isRange(thing: any): thing is vscode.Range { +``` + +## Cursor Position + +```src/vs/workbench/api/common/extHostTypes.ts + Prepend = 3 +} + +export class TextEdit { +// <[CURSOR_POSITION] + + static isTextEdit(thing: any): thing is TextEdit { + if (thing instanceof TextEdit) { + return true; +``` + +## Expected Patch + +```diff +--- a/src/vs/workbench/api/common/extHostTypes.ts ++++ b/src/vs/workbench/api/common/extHostTypes.ts +@@ -475,6 +485,7 @@ export enum EnvironmentVariableMutatorType { + Prepend = 3 + } + ++@es5ClassCompat + export class TextEdit { + + static isTextEdit(thing: any): thing is TextEdit { +``` diff --git a/crates/edit_prediction_cli/evals/vscode--add-interface-method.md b/crates/edit_prediction_cli/evals/vscode--add-interface-method.md new file mode 100644 index 00000000000000..898ebd3bd82bb1 --- /dev/null +++ b/crates/edit_prediction_cli/evals/vscode--add-interface-method.md @@ -0,0 +1,113 @@ ++++ +repository_url = "https://github.com/microsoft/vscode" +revision = "b64eaf598008e2d600a81d846108f72cb37b48e2" ++++ + +## Edit History + +```diff +--- a/src/vs/platform/window/electron-main/window.ts ++++ b/src/vs/platform/window/electron-main/window.ts +@@ -1,49 +1,50 @@ + export interface ICodeWindow extends IDisposable { + + readonly onWillLoad: Event; + readonly onDidSignalReady: Event; ++ readonly onDidTriggerSystemContextMenu: Event<{ x: number; y: number }>; + readonly onDidClose: Event; + readonly onDidDestroy: Event; + + readonly whenClosedOrLoaded: Promise; +--- a/src/vs/platform/windows/electron-main/window.ts ++++ b/src/vs/platform/windows/electron-main/window.ts +@@ -63,60 +63,63 @@ const enum ReadyState { + export class CodeWindow extends Disposable implements ICodeWindow { + + //#region Events + + private readonly _onWillLoad = this._register(new Emitter()); + readonly onWillLoad = this._onWillLoad.event; + + private readonly _onDidSignalReady = this._register(new Emitter()); + readonly onDidSignalReady = this._onDidSignalReady.event; + ++ private readonly _onDidTriggerSystemContextMenu = this._register(new Emitter<{ x: number; y: number }>()); ++ readonly onDidTriggerSystemContextMenu = this._onDidTriggerSystemContextMenu.event; ++ + private readonly _onDidClose = this._register(new Emitter()); + readonly onDidClose = this._onDidClose.event; + + private readonly _onDidDestroy = this._register(new Emitter()); + readonly onDidDestroy = this._onDidDestroy.event; + + //#endregion +--- a/src/vs/platform/windows/electron-main/windows.ts ++++ b/src/vs/platform/windows/electron-main/windows.ts +@@ -1,54 +1,55 @@ + export interface IWindowsMainService { + + readonly _serviceBrand: undefined; + + readonly onDidChangeWindowsCount: Event; + + readonly onDidOpenWindow: Event; + readonly onDidSignalReadyWindow: Event; ++ readonly onDidTriggerSystemContextMenu: Event<{ window: ICodeWindow; x: number; y: number }>; + readonly onDidDestroyWindow: Event; +--- a/src/vs/platform/windows/electron-main/windowsMainService.ts ++++ b/src/vs/platform/windows/electron-main/windowsMainService.ts +@@ -160,60 +160,63 @@ interface ISingleFolderWorkspacePathToOpen extends IPathToOpen { + export class WindowsMainService extends Disposable implements IWindowsMainService { + + declare readonly _serviceBrand: undefined; + + private static readonly WINDOWS: ICodeWindow[] = []; + + private readonly _onDidOpenWindow = this._register(new Emitter()); + readonly onDidOpenWindow = this._onDidOpenWindow.event; + + private readonly _onDidSignalReadyWindow = this._register(new Emitter()); + readonly onDidSignalReadyWindow = this._onDidSignalReadyWindow.event; + + private readonly _onDidDestroyWindow = this._register(new Emitter()); + readonly onDidDestroyWindow = this._onDidDestroyWindow.event; + + private readonly _onDidChangeWindowsCount = this._register(new Emitter()); + readonly onDidChangeWindowsCount = this._onDidChangeWindowsCount.event; + ++ private readonly _onDidTriggerSystemContextMenu = this._register(new Emitter<{ window: ICodeWindow; x: number; y: number }>()); ++ readonly onDidTriggerSystemContextMenu = this._onDidTriggerSystemContextMenu.event; ++ + private readonly windowsStateHandler = this._register(new WindowsStateHandler(this, this.stateMainService, this.lifecycleMainService, this.logService, this.configurationService)); +``` + +## Cursor Position + +```src/vs/platform/windows/test/electron-main/windowsFinder.test.ts + function createTestCodeWindow(options: { lastFocusTime: number; openedFolderUri?: URI; openedWorkspace?: IWorkspaceIdentifier }): ICodeWindow { + return new class implements ICodeWindow { + onWillLoad: Event = Event.None; + onDidSignalReady: Event = Event.None; + // <[CURSOR_POSITION] + onDidClose: Event = Event.None; + onDidDestroy: Event = Event.None; + whenClosedOrLoaded: Promise = Promise.resolve(); + id: number = -1; +``` + +## Expected Patch + +```diff +--- a/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts ++++ b/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts +@@ -7,60 +7,61 @@ import * as assert from 'assert'; + function createTestCodeWindow(options: { lastFocusTime: number; openedFolderUri?: URI; openedWorkspace?: IWorkspaceIdentifier }): ICodeWindow { + return new class implements ICodeWindow { + onWillLoad: Event = Event.None; ++ onDidTriggerSystemContextMenu: Event<{ x: number; y: number }> = Event.None; + onDidSignalReady: Event = Event.None; + onDidClose: Event = Event.None; + onDidDestroy: Event = Event.None; + whenClosedOrLoaded: Promise = Promise.resolve(); + id: number = -1; +``` diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index d8fd613ee8d6e1..6cdfeef8f569df 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -9,8 +9,8 @@ use anyhow::{Context as _, Result, anyhow}; use edit_prediction::udiff; use gpui::AsyncApp; use similar::DiffableStr; +use std::ops::Range; use std::sync::Arc; -use std::{fmt::Write as _, ops::Range}; use zeta_prompt::{ ZetaFormat, excerpt_range_for_format, format_zeta_prompt, resolve_cursor_region, }; @@ -258,7 +258,6 @@ impl TeacherPrompt { pub fn format_context(example: &Example) -> String { let related_files = example.prompt_inputs.as_ref().map(|pi| &pi.related_files); - let Some(related_files) = related_files else { return "(No context)".to_string(); }; @@ -267,27 +266,10 @@ impl TeacherPrompt { return "(No context)".to_string(); } - let mut prompt = String::new(); - for file in related_files { - let path_str = file.path.to_string_lossy(); - writeln!(&mut prompt, "`````{path_str}").ok(); - - let mut prev_row = 0; - for excerpt in &file.excerpts { - if excerpt.row_range.start > prev_row { - prompt.push_str("…\n"); - } - prompt.push_str(&excerpt.text); - prompt.push('\n'); - prev_row = excerpt.row_range.end; - } - if prev_row < file.max_row { - prompt.push_str("…\n"); - } - prompt.push_str("\n`````\n"); - } - - prompt + let prefix = "`````"; + let suffix = "`````\n\n"; + let max_tokens = 1024; + zeta_prompt::format_related_files_within_budget(related_files, &prefix, &suffix, max_tokens) } fn format_cursor_excerpt( diff --git a/crates/edit_prediction_cli/src/git.rs b/crates/edit_prediction_cli/src/git.rs index dea6637d4330b6..59ce3aba40eb16 100644 --- a/crates/edit_prediction_cli/src/git.rs +++ b/crates/edit_prediction_cli/src/git.rs @@ -91,7 +91,7 @@ pub async fn ensure_repo_cloned(repo_url: &str) -> Result { } // Always fetch to get latest commits - run_git(&repo_path, &["fetch", "origin"]).await?; + run_git(&repo_path, &["fetch", "--depth", "1000", "origin"]).await?; // Check if we have a valid HEAD, if not checkout FETCH_HEAD let has_head = run_git(&repo_path, &["rev-parse", "HEAD"]).await.is_ok(); diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs index 03f94a4dc47388..1ab126d32ee19b 100644 --- a/crates/edit_prediction_cli/src/main.rs +++ b/crates/edit_prediction_cli/src/main.rs @@ -39,6 +39,7 @@ use zeta_prompt::ZetaFormat; use reqwest_client::ReqwestClient; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::env; use std::fmt::Display; use std::fs::{File, OpenOptions}; use std::hash::{Hash, Hasher}; @@ -294,6 +295,9 @@ struct EvalArgs { /// Path to write summary scores as JSON #[clap(long)] summary_json: Option, + /// Print all individual example lines (default: up to 20) + #[clap(long)] + verbose: bool, } #[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Hash)] @@ -897,8 +901,18 @@ fn main() { } Command::Synthesize(synth_args) => { - let Some(output_dir) = args.output else { - panic!("output dir is required"); + let output_dir = if let Some(output_dir) = args.output { + output_dir + } else { + let default_output_dir = env::current_dir() + .unwrap() + .join("crates/edit_prediction_cli/evals-generated"); + if default_output_dir.parent().unwrap().exists() { + std::fs::create_dir(&default_output_dir).ok(); + default_output_dir + } else { + panic!("output dir is required"); + } }; let config = SynthesizeConfig { repo_urls: synth_args.repos.clone(), @@ -1238,7 +1252,7 @@ fn main() { match &command { Command::Eval(args) => { let examples = finished_examples.lock().unwrap(); - score::print_report(&examples); + score::print_report(&examples, args.verbose); if let Some(summary_path) = &args.summary_json { score::write_summary_json(&examples, summary_path)?; } diff --git a/crates/edit_prediction_cli/src/score.rs b/crates/edit_prediction_cli/src/score.rs index 8436dc4a4b2620..b6f745114f6dd2 100644 --- a/crates/edit_prediction_cli/src/score.rs +++ b/crates/edit_prediction_cli/src/score.rs @@ -217,7 +217,8 @@ fn compute_cursor_metrics( } } -pub fn print_report(examples: &[Example]) { +pub fn print_report(examples: &[Example], verbose: bool) { + const MAX_EXAMPLES_DEFAULT: usize = 20; use crate::metrics::ClassificationMetrics; const LINE_WIDTH: usize = 101; @@ -250,6 +251,9 @@ pub fn print_report(examples: &[Example]) { let mut patch_deleted_tokens: Vec = Vec::new(); let mut predictions_with_patch: usize = 0; + let mut printed_lines: usize = 0; + let mut skipped_lines: usize = 0; + for example in examples { for (score_idx, score) in example.score.iter().enumerate() { let exact_lines = ClassificationMetrics { @@ -284,18 +288,23 @@ pub fn print_report(examples: &[Example]) { (None, _) => "-".to_string(), }; - println!( - "{:<40} {:>8.2} {:>5} {:>6.1}% {:>6.1}% {:>7} {:>7} {:>6} {:>5}", - truncate_name(&example.spec.name, 40), - score.delta_chr_f, - score.braces_disbalance, - exact_lines.f1() * 100.0, - score.reversal_ratio * 100.0, - qa_reverts_str, - qa_conf_str, - cursor_str, - wrong_er_str - ); + if verbose || printed_lines < MAX_EXAMPLES_DEFAULT { + println!( + "{:<40} {:>8.2} {:>5} {:>6.1}% {:>6.1}% {:>7} {:>7} {:>6} {:>5}", + truncate_name(&example.spec.name, 40), + score.delta_chr_f, + score.braces_disbalance, + exact_lines.f1() * 100.0, + score.reversal_ratio * 100.0, + qa_reverts_str, + qa_conf_str, + cursor_str, + wrong_er_str + ); + printed_lines += 1; + } else { + skipped_lines += 1; + } all_delta_chr_f_scores.push(score.delta_chr_f); all_reversal_ratios.push(score.reversal_ratio); @@ -358,6 +367,13 @@ pub fn print_report(examples: &[Example]) { } } + if skipped_lines > 0 { + println!( + "{:<40} (use --verbose to see all {} examples)", + format!("... and {} more", skipped_lines), + printed_lines + skipped_lines + ); + } println!("{}", separator); if !all_delta_chr_f_scores.is_empty() { diff --git a/crates/edit_prediction_cli/src/split_dataset.rs b/crates/edit_prediction_cli/src/split_dataset.rs index b34d7c14c66464..f1e0a672695cb9 100644 --- a/crates/edit_prediction_cli/src/split_dataset.rs +++ b/crates/edit_prediction_cli/src/split_dataset.rs @@ -1,29 +1,34 @@ //! `ep split` implementation. //! //! This command splits a JSONL dataset into multiple files based on size specifications, -//! with stratification by repository URL (if the field is present). +//! with optional stratification by a JSON field. //! //! # Usage //! //! ```text -//! ep split [input.jsonl] = = ... +//! ep split [--stratify=] [input.jsonl] = = ... //! ``` //! //! If `input.jsonl` is not provided or is `-`, reads from stdin. //! //! # Size specifications //! -//! - `80%` - percentage of total (repositories if stratified, examples otherwise) -//! - `100` - absolute count of repositories (if stratified) or examples +//! - `80%` - percentage of total examples (lines) +//! - `100` - approximate absolute count of examples (lines) //! - `rest` - all remaining items (only one split can use this) //! //! # Stratification //! -//! When examples have a `repository_url` field, the split is stratified by repository. -//! This ensures each output file contains examples from non-overlapping repositories. -//! Size specifications apply to the number of repositories, not individual examples. +//! The `--stratify` flag controls how examples are grouped before splitting: //! -//! Examples without `repository_url` are distributed proportionally across all outputs. +//! - `cursor-path` (default): group by the `cursor_path` JSON field +//! - `repo`: group by the `repository_url` JSON field +//! - `none`: no grouping, split individual examples +//! +//! When stratifying, the split ensures each output file contains examples from +//! non-overlapping groups. Size specifications always apply to the number of +//! examples (lines), with whole groups assigned greedily to meet the target. +//! Examples missing the stratification field are treated as individual groups. use anyhow::{Context as _, Result, bail}; use clap::Args; @@ -38,23 +43,27 @@ use std::path::{Path, PathBuf}; /// `ep split` CLI args. #[derive(Debug, Args, Clone)] #[command( - about = "Split a JSONL dataset into multiple files (stratified by repository_url if present)", + about = "Split a JSONL dataset into multiple files with optional stratification", after_help = r#"SIZE SPECIFICATIONS: % Percentage of total (e.g., 80%) Absolute number (e.g., 100) rest All remaining items (only one output can use this) - When stratifying by repository_url, sizes apply to repositories, not examples. + Sizes always apply to examples (lines). When stratifying, whole groups + are assigned greedily to approximate the target count. EXAMPLES: - # Split 80% train, 20% validation + # Split 80% train, 20% validation (default: stratify by cursor_path) ep split input.jsonl train.jsonl=80% valid.jsonl=rest # Split into train/valid/test ep split input.jsonl train.jsonl=80% valid.jsonl=10% test.jsonl=rest - # Use absolute counts (100 repos to train, rest to valid) - ep split input.jsonl train.jsonl=100 valid.jsonl=rest + # Stratify by repository_url instead of cursor_path + ep split --stratify=repo input.jsonl train.jsonl=80% valid.jsonl=rest + + # No stratification (split by individual examples) + ep split --stratify=none input.jsonl train.jsonl=80% valid.jsonl=rest # Read from stdin cat input.jsonl | ep split train.jsonl=80% valid.jsonl=rest @@ -62,14 +71,15 @@ EXAMPLES: # Reproducible split with seed ep split --seed 42 input.jsonl train.jsonl=80% valid.jsonl=rest - # Disable stratification (split by examples, not repositories) - ep split --no-stratify input.jsonl train.jsonl=80% valid.jsonl=rest - STRATIFICATION: - When examples have a "repository_url" field, the split ensures each output - file contains examples from non-overlapping repositories. This prevents - data leakage between train/test splits. Use --no-stratify to disable this - behavior and split by individual examples instead. + Controls how examples are grouped before splitting: + cursor-path Group by "cursor_path" field (default) + repo Group by "repository_url" field + none No grouping, split individual examples + + When stratifying, the split ensures each output file contains examples + from non-overlapping groups. This prevents data leakage between + train/test splits. "# )] pub struct SplitArgs { @@ -77,9 +87,19 @@ pub struct SplitArgs { #[arg(long)] pub seed: Option, - /// Disable stratification by repository_url (split by examples instead) - #[arg(long)] - pub no_stratify: bool, + /// Stratification field for splitting the dataset + #[arg(long, default_value = "cursor-path")] + pub stratify: Stratify, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, strum::Display)] +pub enum Stratify { + #[strum(serialize = "cursor_path")] + CursorPath, + #[strum(serialize = "repo")] + Repo, + #[strum(serialize = "none")] + None, } #[derive(Debug, Clone)] @@ -142,29 +162,6 @@ fn read_lines_from_input(input: Option<&Path>) -> Result> { Ok(lines) } -fn get_repository_url(line: &str) -> Option { - let value: Value = serde_json::from_str(line).ok()?; - value - .get("repository_url") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) -} - -fn group_lines_by_repo(lines: Vec) -> (HashMap>, Vec) { - let mut by_repo: HashMap> = HashMap::new(); - let mut without_repo: Vec = Vec::new(); - - for line in lines { - if let Some(repo_url) = get_repository_url(&line) { - by_repo.entry(repo_url).or_default().push(line); - } else { - without_repo.push(line); - } - } - - (by_repo, without_repo) -} - fn compute_split_counts(specs: &[SplitSpec], total: usize) -> Result> { let mut counts = vec![0usize; specs.len()]; let mut remaining = total; @@ -261,26 +258,20 @@ pub fn run_split(args: &SplitArgs, inputs: &[PathBuf]) -> Result<()> { return Ok(()); } - let (by_repo, without_repo) = group_lines_by_repo(lines); - let has_repos = !by_repo.is_empty() && !args.no_stratify; + let mut grouped_lines = group_lines(&lines, args.stratify); - if args.no_stratify && !by_repo.is_empty() { + if args.stratify != Stratify::None { eprintln!( - "Stratification disabled (--no-stratify), splitting {} examples by line", + "Stratifying by {} ({} unique groups, {} examples)", + args.stratify, + grouped_lines.len(), total_lines ); - } else if has_repos { + } else { eprintln!( - "Stratifying by repository_url ({} unique repositories, {} examples)", - by_repo.len(), - total_lines - without_repo.len() + "No stratification, splitting {} examples by line", + total_lines ); - if !without_repo.is_empty() { - eprintln!( - " + {} examples without repository_url (distributed proportionally)", - without_repo.len() - ); - } } let mut rng = match args.seed { @@ -288,53 +279,31 @@ pub fn run_split(args: &SplitArgs, inputs: &[PathBuf]) -> Result<()> { None => rand::rngs::StdRng::from_os_rng(), }; - let mut split_outputs: Vec> = vec![Vec::new(); specs.len()]; - - if has_repos { - let mut repos: Vec = by_repo.keys().cloned().collect(); - repos.shuffle(&mut rng); + grouped_lines.shuffle(&mut rng); - let repo_counts = compute_split_counts(&specs, repos.len())?; + let line_targets = compute_split_counts(&specs, total_lines)?; + let rest_index = specs.iter().position(|s| matches!(s.size, SplitSize::Rest)); + let mut split_outputs: Vec> = vec![Vec::new(); specs.len()]; + let mut group_iter = grouped_lines.into_iter(); - let mut repo_iter = repos.into_iter(); - for (split_idx, &count) in repo_counts.iter().enumerate() { - for _ in 0..count { - if let Some(repo) = repo_iter.next() { - if let Some(repo_lines) = by_repo.get(&repo) { - split_outputs[split_idx].extend(repo_lines.iter().cloned()); - } - } - } + for (split_idx, &target) in line_targets.iter().enumerate() { + if Some(split_idx) == rest_index { + continue; } - - if !without_repo.is_empty() { - let no_repo_counts = compute_split_counts(&specs, without_repo.len())?; - let mut no_repo_shuffled = without_repo; - no_repo_shuffled.shuffle(&mut rng); - - let mut line_iter = no_repo_shuffled.into_iter(); - for (split_idx, &count) in no_repo_counts.iter().enumerate() { - for _ in 0..count { - if let Some(line) = line_iter.next() { - split_outputs[split_idx].push(line); - } - } + let mut accumulated = 0; + while accumulated < target { + if let Some(group) = group_iter.next() { + accumulated += group.len(); + split_outputs[split_idx].extend(group); + } else { + break; } } - } else { - let line_counts = compute_split_counts(&specs, total_lines)?; - let mut all_lines: Vec = by_repo.into_values().flatten().collect(); - all_lines.extend(without_repo); - all_lines.shuffle(&mut rng); - - let mut line_iter = all_lines.into_iter(); + } - for (split_idx, &count) in line_counts.iter().enumerate() { - for _ in 0..count { - if let Some(line) = line_iter.next() { - split_outputs[split_idx].push(line); - } - } + if let Some(idx) = rest_index { + for group in group_iter { + split_outputs[idx].extend(group); } } @@ -346,6 +315,39 @@ pub fn run_split(args: &SplitArgs, inputs: &[PathBuf]) -> Result<()> { Ok(()) } +/// Groups lines by the specified stratification field. +/// +/// When `stratify` is `None`, each line becomes its own group. +/// When a line is missing the stratification field, it is also placed in its own group. +fn group_lines(lines: &[String], stratify: Stratify) -> Vec> { + if stratify == Stratify::None { + return lines.iter().map(|line| vec![line.clone()]).collect(); + } + + let field = match stratify { + Stratify::Repo => "repository_url", + Stratify::CursorPath => "cursor_path", + Stratify::None => unreachable!(), + }; + + let mut groups: HashMap> = HashMap::new(); + let mut ungrouped: Vec> = Vec::new(); + + for line in lines { + let key = serde_json::from_str::(line) + .ok() + .and_then(|v| v.get(field)?.as_str().map(|s| s.to_string())); + match key { + Some(key) => groups.entry(key).or_default().push(line.clone()), + None => ungrouped.push(vec![line.clone()]), + } + } + + let mut result: Vec> = groups.into_values().collect(); + result.extend(ungrouped); + result +} + #[cfg(test)] mod tests { use super::*; @@ -389,15 +391,11 @@ mod tests { } #[test] - fn test_get_repository_url() { - let line = r#"{"repository_url": "https://github.com/example/repo", "data": 123}"#; - assert_eq!( - get_repository_url(line), - Some("https://github.com/example/repo".to_string()) - ); - - let line_no_repo = r#"{"data": 123}"#; - assert_eq!(get_repository_url(line_no_repo), None); + fn test_group_lines_none() { + let lines = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + let groups = group_lines(&lines, Stratify::None); + assert_eq!(groups.len(), 3); + assert!(groups.iter().all(|g| g.len() == 1)); } #[test] @@ -457,12 +455,30 @@ mod tests { r#"{"id": 4}"#.to_string(), ]; - let (by_repo, without_repo) = group_lines_by_repo(lines); + let groups = group_lines(&lines, Stratify::Repo); + + let grouped_count: usize = groups.iter().filter(|g| g.len() > 1).count(); + let ungrouped_count: usize = groups.iter().filter(|g| g.len() == 1).count(); + let total_lines: usize = groups.iter().map(|g| g.len()).sum(); - assert_eq!(by_repo.len(), 2); - assert_eq!(by_repo.get("repo1").unwrap().len(), 2); - assert_eq!(by_repo.get("repo2").unwrap().len(), 1); - assert_eq!(without_repo.len(), 1); + assert_eq!(grouped_count, 1); // repo1 has 2 lines + assert_eq!(ungrouped_count, 2); // repo2 (1 line) + line without repo + assert_eq!(total_lines, 4); + } + + #[test] + fn test_group_lines_by_cursor_path() { + let lines = vec![ + r#"{"cursor_path": "src/main.rs", "id": 1}"#.to_string(), + r#"{"cursor_path": "src/main.rs", "id": 2}"#.to_string(), + r#"{"cursor_path": "src/lib.rs", "id": 3}"#.to_string(), + ]; + + let groups = group_lines(&lines, Stratify::CursorPath); + + let total_lines: usize = groups.iter().map(|g| g.len()).sum(); + assert_eq!(groups.len(), 2); + assert_eq!(total_lines, 3); } #[test] @@ -484,7 +500,7 @@ mod tests { let args = SplitArgs { seed: Some(42), - no_stratify: false, + stratify: Stratify::Repo, }; let inputs = vec![ input.path().to_path_buf(), @@ -502,14 +518,18 @@ mod tests { assert_eq!(train_lines.len() + valid_lines.len(), 8); - let train_repos: std::collections::HashSet<_> = train_lines - .iter() - .filter_map(|l| get_repository_url(l)) - .collect(); - let valid_repos: std::collections::HashSet<_> = valid_lines - .iter() - .filter_map(|l| get_repository_url(l)) - .collect(); + let get_repo = |line: &str| -> Option { + let value: Value = serde_json::from_str(line).ok()?; + value + .get("repository_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }; + + let train_repos: std::collections::HashSet<_> = + train_lines.iter().filter_map(|l| get_repo(l)).collect(); + let valid_repos: std::collections::HashSet<_> = + valid_lines.iter().filter_map(|l| get_repo(l)).collect(); assert!( train_repos.is_disjoint(&valid_repos), @@ -531,4 +551,54 @@ mod tests { ]; assert!(compute_split_counts(&specs, 100).is_err()); } + + #[test] + fn test_absolute_targets_lines_not_groups() { + // 5 repos × 3 lines each = 15 total lines. + // `train=6` should target ~6 lines (2 groups), NOT 6 groups (all 15 lines). + let input = create_temp_jsonl(&[ + r#"{"repository_url": "r1", "id": 1}"#, + r#"{"repository_url": "r1", "id": 2}"#, + r#"{"repository_url": "r1", "id": 3}"#, + r#"{"repository_url": "r2", "id": 4}"#, + r#"{"repository_url": "r2", "id": 5}"#, + r#"{"repository_url": "r2", "id": 6}"#, + r#"{"repository_url": "r3", "id": 7}"#, + r#"{"repository_url": "r3", "id": 8}"#, + r#"{"repository_url": "r3", "id": 9}"#, + r#"{"repository_url": "r4", "id": 10}"#, + r#"{"repository_url": "r4", "id": 11}"#, + r#"{"repository_url": "r4", "id": 12}"#, + r#"{"repository_url": "r5", "id": 13}"#, + r#"{"repository_url": "r5", "id": 14}"#, + r#"{"repository_url": "r5", "id": 15}"#, + ]); + + let temp_dir = tempfile::tempdir().unwrap(); + let train_path = temp_dir.path().join("train.jsonl"); + let valid_path = temp_dir.path().join("valid.jsonl"); + + let args = SplitArgs { + seed: Some(42), + stratify: Stratify::Repo, + }; + let inputs = vec![ + input.path().to_path_buf(), + PathBuf::from(format!("{}=6", train_path.display())), + PathBuf::from(format!("{}=rest", valid_path.display())), + ]; + + run_split(&args, &inputs).unwrap(); + + let train_content = std::fs::read_to_string(&train_path).unwrap(); + let valid_content = std::fs::read_to_string(&valid_path).unwrap(); + + let train_lines: Vec<&str> = train_content.lines().collect(); + let valid_lines: Vec<&str> = valid_content.lines().collect(); + + // With 3-line groups, train should get 2 groups (6 lines) to meet the + // target of 6, NOT 6 groups (which don't even exist). Valid gets the rest. + assert_eq!(train_lines.len(), 6); + assert_eq!(valid_lines.len(), 9); + } } diff --git a/crates/edit_prediction_cli/src/synthesize.rs b/crates/edit_prediction_cli/src/synthesize.rs index 3977804a8fc686..228690ae49eb1b 100644 --- a/crates/edit_prediction_cli/src/synthesize.rs +++ b/crates/edit_prediction_cli/src/synthesize.rs @@ -284,7 +284,7 @@ fn should_skip_commit(commit: &CommitInfo) -> bool { .lines() .filter(|l| l.starts_with('+') || l.starts_with('-')) .count(); - lines_changed < 10 + lines_changed < 30 || lines_changed > 1000 || is_non_code_commit(commit) || is_rename_commit(commit) @@ -377,10 +377,13 @@ fn build_prompt(repo_url: &str, commit: &CommitInfo) -> String { indoc! {r#" You are analyzing a git commit to construct a realistic edit prediction example. - Your goal is to tell the story of a programmer's editing session: what sequence of changes did they make, and what change logically comes next? We use these examples to train a model to predict edits, so the quality of the EDIT HISTORY is what matters most. + Your goal is to tell the story of a programmer's editing session: what sequence + of changes did they make, and what change logically comes next? We use these examples + to train a model to predict edits, so the quality of the EDIT HISTORY is what matters most. An edit prediction example consists of: - 1. **Edit History**: 3-6 hunks showing what the programmer did BEFORE making the expected patch. This is the most important part - it must tell a coherent story of the changes leading up to the prediction. + 1. **Edit History**: 2-6 hunks showing what the programmer did BEFORE making the expected patch. + This is the most important part - it must tell a coherent story of the changes leading up to the prediction. 2. **Expected Patch**: One small hunk that logically follows from the edit history. Both single-file and multi-file patterns are acceptable. @@ -417,7 +420,7 @@ fn build_prompt(repo_url: &str, commit: &CommitInfo) -> String { First, THINK through whether this commit can support a good example: 1. What is the high-level pattern in this commit? - 2. Can you identify at least 4 related hunks (3 for edit history + 1 for expected patch)? + 2. Can you identify at least 3 related hunks (2 or more for edit history + 1 for expected patch)? 3. What would be the narrative? (First... then... then... finally predict...) 4. Which specific hunk should be the expected patch (the "punchline")? diff --git a/crates/edit_prediction_context/src/assemble_excerpts.rs b/crates/edit_prediction_context/src/assemble_excerpts.rs index 3366b1fdd0fed1..97b83653f53e69 100644 --- a/crates/edit_prediction_context/src/assemble_excerpts.rs +++ b/crates/edit_prediction_context/src/assemble_excerpts.rs @@ -8,16 +8,18 @@ const MAX_OUTLINE_ITEM_BODY_SIZE: usize = 24; pub fn assemble_excerpt_ranges( buffer: &BufferSnapshot, - mut input_ranges: Vec>, -) -> Vec> { + input_ranges: Vec<(Range, usize)>, +) -> Vec<(Range, usize)> { + let mut input_ranges: Vec<(Range, usize)> = input_ranges + .into_iter() + .map(|(range, order)| (clip_range_to_lines(&range, false, buffer), order)) + .collect(); merge_ranges(&mut input_ranges); - let mut outline_ranges = Vec::new(); + let mut outline_ranges: Vec<(Range, usize)> = Vec::new(); let outline_items = buffer.outline_items_as_points_containing(0..buffer.len(), false, None); let mut outline_ix = 0; - for input_range in &mut input_ranges { - *input_range = clip_range_to_lines(input_range, false, buffer); - + for (input_range, input_order) in &mut input_ranges { while let Some(outline_item) = outline_items.get(outline_ix) { let item_range = clip_range_to_lines(&outline_item.range, false, buffer); @@ -36,6 +38,7 @@ pub fn assemble_excerpt_ranges( add_outline_item( item_range.clone(), body_range.clone(), + *input_order, buffer, &mut outline_ranges, ); @@ -57,6 +60,7 @@ pub fn assemble_excerpt_ranges( next_outline_item .body_range(buffer) .map(|body| clip_range_to_lines(&body, true, buffer)), + *input_order, buffer, &mut outline_ranges, ); @@ -70,12 +74,12 @@ pub fn assemble_excerpt_ranges( } } - input_ranges.extend_from_slice(&outline_ranges); + input_ranges.extend(outline_ranges); merge_ranges(&mut input_ranges); input_ranges .into_iter() - .map(|range| range.start.row..range.end.row) + .map(|(range, order)| (range.start.row..range.end.row, order)) .collect() } @@ -102,8 +106,9 @@ fn clip_range_to_lines( fn add_outline_item( mut item_range: Range, body_range: Option>, + order: usize, buffer: &BufferSnapshot, - outline_ranges: &mut Vec>, + outline_ranges: &mut Vec<(Range, usize)>, ) { if let Some(mut body_range) = body_range { if body_range.start.column > 0 { @@ -113,38 +118,39 @@ fn add_outline_item( let head_range = item_range.start..body_range.start; if head_range.start < head_range.end { - outline_ranges.push(head_range); + outline_ranges.push((head_range, order)); } let tail_range = body_range.end..item_range.end; if tail_range.start < tail_range.end { - outline_ranges.push(tail_range); + outline_ranges.push((tail_range, order)); } } else { item_range.start.column = 0; item_range.end.column = buffer.line_len(item_range.end.row); - outline_ranges.push(item_range); + outline_ranges.push((item_range, order)); } } -pub fn merge_ranges(ranges: &mut Vec>) { - ranges.sort_unstable_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end))); +pub fn merge_ranges(ranges: &mut Vec<(Range, usize)>) { + ranges.sort_unstable_by(|(a, _), (b, _)| a.start.cmp(&b.start).then(b.end.cmp(&a.end))); let mut index = 1; while index < ranges.len() { - let mut prev_range_end = ranges[index - 1].end; + let mut prev_range_end = ranges[index - 1].0.end; if prev_range_end.column > 0 { prev_range_end += Point::new(1, 0); } if (prev_range_end + Point::new(1, 0)) - .cmp(&ranges[index].start) + .cmp(&ranges[index].0.start) .is_ge() { let removed = ranges.remove(index); - if removed.end.cmp(&ranges[index - 1].end).is_gt() { - ranges[index - 1].end = removed.end; + if removed.0.end.cmp(&ranges[index - 1].0.end).is_gt() { + ranges[index - 1].0.end = removed.0.end; } + ranges[index - 1].1 = ranges[index - 1].1.min(removed.1); } else { index += 1; } diff --git a/crates/edit_prediction_context/src/edit_prediction_context.rs b/crates/edit_prediction_context/src/edit_prediction_context.rs index 5805e93330504f..b93fef49296e49 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context.rs @@ -39,6 +39,7 @@ struct RelatedBuffer { buffer: Entity, path: Arc, anchor_ranges: Vec>, + excerpt_orders: Vec, cached_file: Option, } @@ -174,21 +175,21 @@ impl RelatedExcerptStore { }; let buffer = project.get_open_buffer(&project_path, cx)?; let snapshot = buffer.read(cx).snapshot(); - let anchor_ranges = file - .excerpts - .iter() - .map(|excerpt| { - let start = snapshot.anchor_before(Point::new(excerpt.row_range.start, 0)); - let end_row = excerpt.row_range.end; - let end_col = snapshot.line_len(end_row); - let end = snapshot.anchor_after(Point::new(end_row, end_col)); - start..end - }) - .collect(); + let mut anchor_ranges = Vec::with_capacity(file.excerpts.len()); + let mut excerpt_orders = Vec::with_capacity(file.excerpts.len()); + for excerpt in &file.excerpts { + let start = snapshot.anchor_before(Point::new(excerpt.row_range.start, 0)); + let end_row = excerpt.row_range.end; + let end_col = snapshot.line_len(end_row); + let end = snapshot.anchor_after(Point::new(end_row, end_col)); + anchor_ranges.push(start..end); + excerpt_orders.push(excerpt.order); + } Some(RelatedBuffer { buffer, path: file.path.clone(), anchor_ranges, + excerpt_orders, cached_file: None, }) }) @@ -221,18 +222,55 @@ impl RelatedExcerptStore { cx.emit(RelatedExcerptStoreEvent::StartedRefresh); })?; - let identifiers = cx + let identifiers_with_ranks = cx .background_spawn(async move { - identifiers_for_position(&snapshot, position, identifier_line_count) + let cursor_offset = position.to_offset(&snapshot); + let identifiers = + identifiers_for_position(&snapshot, position, identifier_line_count); + + // Compute byte distance from cursor to each identifier, then sort by + // distance so we can assign ordinal ranks. Identifiers at the same + // distance share the same rank. + let mut identifiers_with_distance: Vec<(Identifier, usize)> = identifiers + .into_iter() + .map(|id| { + let start = id.range.start.to_offset(&snapshot); + let end = id.range.end.to_offset(&snapshot); + let distance = if cursor_offset < start { + start - cursor_offset + } else if cursor_offset > end { + cursor_offset - end + } else { + 0 + }; + (id, distance) + }) + .collect(); + identifiers_with_distance.sort_by_key(|(_, distance)| *distance); + + let mut cursor_distances: HashMap = HashMap::default(); + let mut current_rank = 0; + let mut previous_distance = None; + for (identifier, distance) in &identifiers_with_distance { + if previous_distance != Some(*distance) { + current_rank = cursor_distances.len(); + previous_distance = Some(*distance); + } + cursor_distances.insert(identifier.clone(), current_rank); + } + + (identifiers_with_distance, cursor_distances) }) .await; + let (identifiers_with_distance, cursor_distances) = identifiers_with_ranks; + let async_cx = cx.clone(); let start_time = Instant::now(); let futures = this.update(cx, |this, cx| { - identifiers + identifiers_with_distance .into_iter() - .filter_map(|identifier| { + .filter_map(|(identifier, _)| { let task = if let Some(entry) = this.cache.get(&identifier) { DefinitionTask::CacheHit(entry.clone()) } else { @@ -334,7 +372,8 @@ impl RelatedExcerptStore { } mean_definition_latency /= cache_miss_count.max(1) as u32; - let (new_cache, related_buffers) = rebuild_related_files(&project, new_cache, cx).await?; + let (new_cache, related_buffers) = + rebuild_related_files(&project, new_cache, &cursor_distances, cx).await?; if let Some(file) = &file { log::debug!( @@ -362,6 +401,7 @@ impl RelatedExcerptStore { async fn rebuild_related_files( project: &Entity, mut new_entries: HashMap>, + cursor_distances: &HashMap, cx: &mut AsyncApp, ) -> Result<(HashMap>, Vec)> { let mut snapshots = HashMap::default(); @@ -396,12 +436,18 @@ async fn rebuild_related_files( } } + let cursor_distances = cursor_distances.clone(); Ok(cx .background_spawn(async move { let mut ranges_by_buffer = - HashMap::, Vec>)>::default(); + HashMap::, Vec<(Range, usize)>)>::default(); let mut paths_by_buffer = HashMap::default(); - for entry in new_entries.values_mut() { + let mut min_rank_by_buffer = HashMap::::default(); + for (identifier, entry) in new_entries.iter_mut() { + let rank = cursor_distances + .get(identifier) + .copied() + .unwrap_or(usize::MAX); for definition in entry .definitions .iter() @@ -412,11 +458,16 @@ async fn rebuild_related_files( }; paths_by_buffer.insert(definition.buffer.entity_id(), definition.path.clone()); + let buffer_rank = min_rank_by_buffer + .entry(definition.buffer.entity_id()) + .or_insert(usize::MAX); + *buffer_rank = (*buffer_rank).min(rank); + ranges_by_buffer .entry(definition.buffer.entity_id()) .or_insert_with(|| (definition.buffer.clone(), Vec::new())) .1 - .push(definition.anchor_range.to_point(snapshot)); + .push((definition.anchor_range.to_point(snapshot), rank)); } } @@ -425,7 +476,7 @@ async fn rebuild_related_files( .filter_map(|(entity_id, (buffer, ranges))| { let snapshot = snapshots.get(&entity_id)?; let project_path = paths_by_buffer.get(&entity_id)?; - let row_ranges = assemble_excerpt_ranges(snapshot, ranges); + let assembled = assemble_excerpt_ranges(snapshot, ranges); let root_name = worktree_root_names.get(&project_path.worktree_id)?; let path: Arc = Path::new(&format!( @@ -435,20 +486,21 @@ async fn rebuild_related_files( )) .into(); - let anchor_ranges = row_ranges - .into_iter() - .map(|row_range| { - let start = snapshot.anchor_before(Point::new(row_range.start, 0)); - let end_col = snapshot.line_len(row_range.end); - let end = snapshot.anchor_after(Point::new(row_range.end, end_col)); - start..end - }) - .collect(); + let mut anchor_ranges = Vec::with_capacity(assembled.len()); + let mut excerpt_orders = Vec::with_capacity(assembled.len()); + for (row_range, order) in assembled { + let start = snapshot.anchor_before(Point::new(row_range.start, 0)); + let end_col = snapshot.line_len(row_range.end); + let end = snapshot.anchor_after(Point::new(row_range.end, end_col)); + anchor_ranges.push(start..end); + excerpt_orders.push(order); + } let mut related_buffer = RelatedBuffer { buffer, path, anchor_ranges, + excerpt_orders, cached_file: None, }; related_buffer.fill_cache(snapshot); @@ -456,7 +508,17 @@ async fn rebuild_related_files( }) .collect(); - related_buffers.sort_by_key(|related| related.path.clone()); + related_buffers.sort_by(|a, b| { + let rank_a = min_rank_by_buffer + .get(&a.buffer.entity_id()) + .copied() + .unwrap_or(usize::MAX); + let rank_b = min_rank_by_buffer + .get(&b.buffer.entity_id()) + .copied() + .unwrap_or(usize::MAX); + rank_a.cmp(&rank_b).then_with(|| a.path.cmp(&b.path)) + }); (new_entries, related_buffers) }) @@ -487,12 +549,14 @@ impl RelatedBuffer { let excerpts = self .anchor_ranges .iter() - .map(|range| { + .zip(self.excerpt_orders.iter()) + .map(|(range, &order)| { let start = range.start.to_point(buffer); let end = range.end.to_point(buffer); RelatedExcerpt { row_range: start.row..end.row, text: buffer.text_for_range(start..end).collect::().into(), + order, } }) .collect::>(); diff --git a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs index b619fa729449f2..01c4c76e82eb08 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs @@ -48,6 +48,24 @@ async fn test_edit_prediction_context(cx: &mut TestAppContext) { assert_related_files( &excerpts, &[ + ( + "root/src/person.rs", + &[ + indoc! {" + pub struct Person { + first_name: String, + last_name: String, + email: String, + age: u32, + } + + impl Person { + pub fn get_first_name(&self) -> &str { + &self.first_name + }"}, + "}", + ], + ), ( "root/src/company.rs", &[indoc! {" @@ -71,24 +89,6 @@ async fn test_edit_prediction_context(cx: &mut TestAppContext) { }"}, ], ), - ( - "root/src/person.rs", - &[ - indoc! {" - pub struct Person { - first_name: String, - last_name: String, - email: String, - age: u32, - } - - impl Person { - pub fn get_first_name(&self) -> &str { - &self.first_name - }"}, - "}", - ], - ), ], ); }); @@ -112,6 +112,24 @@ async fn test_edit_prediction_context(cx: &mut TestAppContext) { assert_related_files( &excerpts, &[ + ( + "root/src/person.rs", + &[ + indoc! {" + pub struct Person { + first_name: String, + last_name: String, + email: String, + age: u32, + } + + impl Person { + pub fn get_first_name(&self) -> &str { + &self.first_name + }"}, + "}", + ], + ), ( "root/src/company.rs", &[indoc! {" @@ -136,24 +154,6 @@ async fn test_edit_prediction_context(cx: &mut TestAppContext) { }"}, ], ), - ( - "root/src/person.rs", - &[ - indoc! {" - pub struct Person { - first_name: String, - last_name: String, - email: String, - age: u32, - } - - impl Person { - pub fn get_first_name(&self) -> &str { - &self.first_name - }"}, - "}", - ], - ), ], ); }); @@ -290,20 +290,21 @@ fn test_assemble_excerpts(cx: &mut TestAppContext) { let (input, ranges) = marked_text_ranges(&input, false); let buffer = cx.new(|cx| Buffer::local(input, cx).with_language(rust_lang(), cx)); buffer.read_with(cx, |buffer, _cx| { - let ranges: Vec> = ranges + let ranges: Vec<(Range, usize)> = ranges .into_iter() - .map(|range| range.to_point(&buffer)) + .map(|range| (range.to_point(&buffer), 0)) .collect(); - let row_ranges = assemble_excerpt_ranges(&buffer.snapshot(), ranges); - let excerpts: Vec = row_ranges + let assembled = assemble_excerpt_ranges(&buffer.snapshot(), ranges); + let excerpts: Vec = assembled .into_iter() - .map(|row_range| { + .map(|(row_range, order)| { let start = Point::new(row_range.start, 0); let end = Point::new(row_range.end, buffer.line_len(row_range.end)); RelatedExcerpt { row_range, text: buffer.text_for_range(start..end).collect::().into(), + order, } }) .collect(); @@ -620,7 +621,6 @@ async fn test_type_definition_deduplication(cx: &mut TestAppContext) { assert_related_files( &excerpts, &[ - ("root/src/main.rs", &["fn work() {", "}"]), ( "root/src/types.rs", &[indoc! {" @@ -628,6 +628,194 @@ async fn test_type_definition_deduplication(cx: &mut TestAppContext) { value: i32, }"}], ), + ("root/src/main.rs", &["fn work() {", "}"]), + ], + ); + }); +} + +#[gpui::test] +async fn test_definitions_ranked_by_cursor_proximity(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + // helpers.rs has an impl block whose body exceeds the test + // MAX_OUTLINE_ITEM_BODY_SIZE (24 bytes), so assemble_excerpt_ranges + // splits it into header + individual children + closing brace. main.rs + // references two of the three methods on separate lines at varying + // distances from the cursor. This exercises: + // 1. File ordering by closest identifier rank. + // 2. Per-excerpt ordering within a file — child excerpts carry the rank + // of the identifier that discovered them. + // 3. Parent excerpt (impl header / closing brace) inheriting the minimum + // order of its children. + fs.insert_tree( + path!("/root"), + json!({ + "src": { + "helpers.rs": indoc! {r#" + pub struct Helpers { + value: i32, + } + + impl Helpers { + pub fn alpha(&self) -> i32 { + let intermediate = self.value; + intermediate + 1 + } + + pub fn beta(&self) -> i32 { + let intermediate = self.value; + intermediate + 2 + } + + pub fn gamma(&self) -> i32 { + let intermediate = self.value; + intermediate + 3 + } + } + "#}, + "main.rs": indoc! {r#" + use super::helpers::Helpers; + + fn process(h: Helpers) { + let a = h.alpha(); + let b = h.gamma(); + } + "#}, + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let mut servers = setup_fake_lsp(&project, cx); + + let (buffer, _handle) = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/root/src/main.rs"), cx) + }) + .await + .unwrap(); + + let _server = servers.next().await.unwrap(); + cx.run_until_parked(); + + // Place cursor on "h.alpha()". `alpha` is at distance 0, `gamma` is + // farther below. Both resolve to methods inside `impl Helpers` in + // helpers.rs. The impl header and closing brace excerpts should inherit + // the min order of their children (alpha's order). + let related_excerpt_store = cx.new(|cx| RelatedExcerptStore::new(&project, cx)); + related_excerpt_store.update(cx, |store, cx| { + let position = { + let buffer = buffer.read(cx); + let offset = buffer.text().find("h.alpha()").unwrap(); + buffer.anchor_before(offset) + }; + + store.set_identifier_line_count(1); + store.refresh(buffer.clone(), position, cx); + }); + + cx.executor().advance_clock(DEBOUNCE_DURATION); + related_excerpt_store.update(cx, |store, cx| { + let files = store.related_files(cx); + + // helpers.rs has 4 excerpts: the struct+impl header merged with + // the alpha method header (order 1 from alpha), alpha's closing + // brace (order 1), gamma's method header (order 6), and the + // gamma+impl closing brace (order 1, inherited from alpha which + // is also a child of the impl). + let alpha_order = 1; + let gamma_order = 6; + assert_related_files_with_orders( + &files, + &[ + ( + "root/src/helpers.rs", + &[ + ( + indoc! {" + pub struct Helpers { + value: i32, + } + + impl Helpers { + pub fn alpha(&self) -> i32 {"}, + alpha_order, + ), + (" }", alpha_order), + (" pub fn gamma(&self) -> i32 {", gamma_order), + ( + indoc! {" + } + }"}, + alpha_order, + ), + ], + ), + ( + "root/src/main.rs", + &[("fn process(h: Helpers) {", 8), ("}", 8)], + ), + ], + ); + }); + + // Now move cursor to "h.gamma()" — gamma becomes closest, reranking the + // excerpts so that the gamma method excerpt has the best order and the + // alpha method excerpt has a worse order. + related_excerpt_store.update(cx, |store, cx| { + let position = { + let buffer = buffer.read(cx); + let offset = buffer.text().find("h.gamma()").unwrap(); + buffer.anchor_before(offset) + }; + + store.set_identifier_line_count(1); + store.refresh(buffer.clone(), position, cx); + }); + + cx.executor().advance_clock(DEBOUNCE_DURATION); + related_excerpt_store.update(cx, |store, cx| { + let files = store.related_files(cx); + + // Now gamma is closest. The alpha method excerpts carry alpha's + // rank (3), and the gamma method excerpts carry gamma's rank (1). + // The impl closing brace merges with gamma's closing brace and + // inherits gamma's order (the best child). + let alpha_order = 3; + let gamma_order = 1; + assert_related_files_with_orders( + &files, + &[ + ( + "root/src/helpers.rs", + &[ + ( + indoc! {" + pub struct Helpers { + value: i32, + } + + impl Helpers { + pub fn alpha(&self) -> i32 {"}, + alpha_order, + ), + (" }", alpha_order), + (" pub fn gamma(&self) -> i32 {", gamma_order), + ( + indoc! {" + } + }"}, + gamma_order, + ), + ], + ), + ( + "root/src/main.rs", + &[("fn process(h: Helpers) {", 8), ("}", 8)], + ), ], ); }); @@ -788,30 +976,56 @@ fn test_project_1() -> serde_json::Value { } fn assert_related_files(actual_files: &[RelatedFile], expected_files: &[(&str, &[&str])]) { - let actual_files = actual_files + let expected_with_orders: Vec<(&str, Vec<(&str, usize)>)> = expected_files + .iter() + .map(|(path, texts)| (*path, texts.iter().map(|text| (*text, 0)).collect())) + .collect(); + let expected_refs: Vec<(&str, &[(&str, usize)])> = expected_with_orders + .iter() + .map(|(path, excerpts)| (*path, excerpts.as_slice())) + .collect(); + assert_related_files_impl(actual_files, &expected_refs, false) +} + +fn assert_related_files_with_orders( + actual_files: &[RelatedFile], + expected_files: &[(&str, &[(&str, usize)])], +) { + assert_related_files_impl(actual_files, expected_files, true) +} + +fn assert_related_files_impl( + actual_files: &[RelatedFile], + expected_files: &[(&str, &[(&str, usize)])], + check_orders: bool, +) { + let actual: Vec<(&str, Vec<(String, usize)>)> = actual_files .iter() .map(|file| { let excerpts = file .excerpts .iter() - .map(|excerpt| excerpt.text.to_string()) - .collect::>(); + .map(|excerpt| { + let order = if check_orders { excerpt.order } else { 0 }; + (excerpt.text.to_string(), order) + }) + .collect(); (file.path.to_str().unwrap(), excerpts) }) - .collect::>(); - let expected_excerpts = expected_files + .collect(); + let expected: Vec<(&str, Vec<(String, usize)>)> = expected_files .iter() - .map(|(path, texts)| { + .map(|(path, excerpts)| { ( *path, - texts + excerpts .iter() - .map(|line| line.to_string()) - .collect::>(), + .map(|(text, order)| (text.to_string(), *order)) + .collect(), ) }) - .collect::>(); - pretty_assertions::assert_eq!(actual_files, expected_excerpts) + .collect(); + pretty_assertions::assert_eq!(actual, expected) } fn assert_definitions(definitions: &[LocationLink], first_lines: &[&str], cx: &mut TestAppContext) { diff --git a/crates/edit_prediction_ui/src/edit_prediction_context_view.rs b/crates/edit_prediction_ui/src/edit_prediction_context_view.rs index 6ad816c36ddea3..48e74dcdcc102f 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_context_view.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_context_view.rs @@ -8,14 +8,17 @@ use std::{ use anyhow::Result; use client::{Client, UserStore}; -use editor::{Editor, PathKey}; +use editor::{ + Editor, PathKey, + display_map::{BlockPlacement, BlockProperties, BlockStyle}, +}; use futures::StreamExt as _; use gpui::{ Animation, AnimationExt, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement as _, ParentElement as _, SharedString, Styled as _, Task, TextAlign, Window, actions, div, pulsating_between, }; -use multi_buffer::MultiBuffer; +use multi_buffer::{Anchor, MultiBuffer}; use project::Project; use text::Point; use ui::{ @@ -165,8 +168,14 @@ impl EditPredictionContextView { } cx.spawn_in(window, async move |this, cx| { - let mut paths = Vec::new(); + let mut paths: Vec<(PathKey, _, Vec<_>, Vec, usize)> = Vec::new(); for (related_file, buffer) in related_files { + let orders = related_file + .excerpts + .iter() + .map(|excerpt| excerpt.order) + .collect::>(); + let min_order = orders.iter().copied().min().unwrap_or(usize::MAX); let point_ranges = related_file .excerpts .iter() @@ -175,20 +184,53 @@ impl EditPredictionContextView { }) .collect::>(); cx.update(|_, cx| { - let path = PathKey::for_buffer(&buffer, cx); - paths.push((path, buffer, point_ranges)); + let path = if let Some(file) = buffer.read(cx).file() { + PathKey::with_sort_prefix(min_order as u64, file.path().clone()) + } else { + PathKey::for_buffer(&buffer, cx) + }; + paths.push((path, buffer, point_ranges, orders, min_order)); })?; } + paths.sort_by_key(|(_, _, _, _, min_order)| *min_order); + + let mut excerpt_anchors_with_orders: Vec<(Anchor, usize)> = Vec::new(); + multibuffer.update(cx, |multibuffer, cx| { multibuffer.clear(cx); - for (path, buffer, ranges) in paths { - multibuffer.set_excerpts_for_path(path, buffer, ranges, 0, cx); + for (path, buffer, ranges, orders, _) in paths { + let (anchor_ranges, _) = + multibuffer.set_excerpts_for_path(path, buffer, ranges, 0, cx); + for (anchor_range, order) in anchor_ranges.into_iter().zip(orders) { + excerpt_anchors_with_orders.push((anchor_range.start, order)); + } } }); editor.update_in(cx, |editor, window, cx| { + let blocks = excerpt_anchors_with_orders + .into_iter() + .map(|(anchor, order)| { + let label = SharedString::from(format!("order: {order}")); + BlockProperties { + placement: BlockPlacement::Above(anchor), + height: Some(1), + style: BlockStyle::Sticky, + render: Arc::new(move |cx| { + div() + .pl(cx.anchor_x) + .text_ui_xs(cx) + .text_color(cx.editor_style.status.info) + .child(label.clone()) + .into_any_element() + }), + priority: 0, + } + }) + .collect::>(); + editor.insert_blocks(blocks, None, cx); editor.move_to_beginning(&Default::default(), window, cx); })?; diff --git a/crates/edit_prediction_ui/src/edit_prediction_ui.rs b/crates/edit_prediction_ui/src/edit_prediction_ui.rs index 1a6c0302396315..0735a8ccab69cf 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_ui.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_ui.rs @@ -3,7 +3,7 @@ mod edit_prediction_context_view; mod rate_prediction_modal; use command_palette_hooks::CommandPaletteFilter; -use edit_prediction::{EditPredictionStore, ResetOnboarding, Zeta2FeatureFlag, capture_example}; +use edit_prediction::{EditPredictionStore, ResetOnboarding, capture_example}; use edit_prediction_context_view::EditPredictionContextView; use editor::Editor; use feature_flags::FeatureFlagAppExt as _; @@ -54,28 +54,25 @@ pub fn init(cx: &mut App) { capture_example_as_markdown(workspace, window, cx); }); workspace.register_action_renderer(|div, _, _, cx| { - let has_flag = cx.has_flag::(); - div.when(has_flag, |div| { - div.on_action(cx.listener( - move |workspace, _: &OpenEditPredictionContextView, window, cx| { - let project = workspace.project(); - workspace.split_item( - SplitDirection::Right, - Box::new(cx.new(|cx| { - EditPredictionContextView::new( - project.clone(), - workspace.client(), - workspace.user_store(), - window, - cx, - ) - })), - window, - cx, - ); - }, - )) - }) + div.on_action(cx.listener( + move |workspace, _: &OpenEditPredictionContextView, window, cx| { + let project = workspace.project(); + workspace.split_item( + SplitDirection::Right, + Box::new(cx.new(|cx| { + EditPredictionContextView::new( + project.clone(), + workspace.client(), + workspace.user_store(), + window, + cx, + ) + })), + window, + cx, + ); + }, + )) }); }) .detach(); diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 31a1f68300d6d2..888a3729ca7695 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -113,6 +113,7 @@ use unicode_segmentation::UnicodeSegmentation; use ztracing::instrument; use std::cell::RefCell; +use std::collections::hash_map::Entry; use std::{ any::TypeId, borrow::Cow, @@ -175,9 +176,9 @@ pub trait ToDisplayPoint { fn to_display_point(&self, map: &DisplaySnapshot) -> DisplayPoint; } -type TextHighlights = TreeMap>)>>; +type TextHighlights = Arc>)>>>; type SemanticTokensHighlights = - TreeMap, Arc)>; + Arc, Arc)>>; type InlayHighlights = TreeMap>; #[derive(Debug)] @@ -478,7 +479,7 @@ impl DisplayMap { diagnostics_max_severity, text_highlights: Default::default(), inlay_highlights: Default::default(), - semantic_token_highlights: TreeMap::default(), + semantic_token_highlights: Default::default(), clip_at_line_ends: false, masked: false, companion: None, @@ -1226,22 +1227,25 @@ impl DisplayMap { cx: &App, ) { let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); - let to_insert = match self.text_highlights.remove(&key) { - Some(mut previous) if merge => match Arc::get_mut(&mut previous) { - Some((_, previous_ranges)) => { + match Arc::make_mut(&mut self.text_highlights).entry(key) { + Entry::Occupied(mut slot) => match Arc::get_mut(slot.get_mut()) { + Some((_, previous_ranges)) if merge => { previous_ranges.extend(ranges); previous_ranges.sort_by(|a, b| a.start.cmp(&b.start, &multi_buffer_snapshot)); - previous } - None => Arc::new((style, { - ranges.extend(previous.1.iter().cloned()); + Some((previous_style, previous_ranges)) => { + *previous_style = style; + *previous_ranges = ranges; + } + None if merge => { + ranges.extend(slot.get().1.iter().cloned()); ranges.sort_by(|a, b| a.start.cmp(&b.start, &multi_buffer_snapshot)); - ranges - })), + slot.insert(Arc::new((style, ranges))); + } + None => _ = slot.insert(Arc::new((style, ranges))), }, - _ => Arc::new((style, ranges)), - }; - self.text_highlights.insert(key, to_insert); + Entry::Vacant(slot) => _ = slot.insert(Arc::new((style, ranges))), + } } #[instrument(skip_all)] @@ -1288,14 +1292,16 @@ impl DisplayMap { } pub fn clear_highlights(&mut self, key: HighlightKey) -> bool { - let mut cleared = self.text_highlights.remove(&key).is_some(); + let mut cleared = Arc::make_mut(&mut self.text_highlights) + .remove(&key) + .is_some(); cleared |= self.inlay_highlights.remove(&key).is_some(); cleared } pub fn clear_highlights_with(&mut self, f: &mut dyn FnMut(&HighlightKey) -> bool) -> bool { let mut cleared = false; - self.text_highlights.retain(|k, _| { + Arc::make_mut(&mut self.text_highlights).retain(|k, _| { let b = !f(k); cleared |= b; b @@ -1448,7 +1454,7 @@ impl DisplayMap { } pub fn invalidate_semantic_highlights(&mut self, buffer_id: BufferId) { - self.semantic_token_highlights.remove(&buffer_id); + Arc::make_mut(&mut self.semantic_token_highlights).remove(&buffer_id); } } diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index 8bc66123c41a1e..9e853c8292c807 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -1328,9 +1328,10 @@ mod tests { use super::*; use crate::{ MultiBuffer, - display_map::{HighlightKey, InlayHighlights, TextHighlights}, + display_map::{HighlightKey, InlayHighlights}, hover_links::InlayHighlight, }; + use collections::HashMap; use gpui::{App, HighlightStyle}; use multi_buffer::Anchor; use project::{InlayHint, InlayHintLabel, ResolveState}; @@ -1897,7 +1898,7 @@ mod tests { ); } - let mut text_highlights = TextHighlights::default(); + let mut text_highlights = HashMap::default(); let text_highlight_count = rng.random_range(0_usize..10); let mut text_highlight_ranges = (0..text_highlight_count) .map(|_| buffer_snapshot.random_byte_range(MultiBufferOffset(0), &mut rng)) @@ -1917,6 +1918,7 @@ mod tests { .collect(), )), ); + let text_highlights = Arc::new(text_highlights); let mut inlay_highlights = InlayHighlights::default(); if !inlays.is_empty() { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 54e20d00cafebc..1a0a66b7b6074d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -13664,94 +13664,94 @@ impl Editor { let selections = self.selections.all::(&self.display_snapshot(cx)); let buffer = self.buffer.read(cx).read(cx); let mut text = String::new(); - let mut clipboard_selections = Vec::with_capacity(selections.len()); - { - let max_point = buffer.max_point(); - let mut is_first = true; - let mut prev_selection_was_entire_line = false; - for selection in &selections { - let mut start = selection.start; - let mut end = selection.end; - let is_entire_line = selection.is_empty() || self.selections.line_mode(); - let mut add_trailing_newline = false; - if is_entire_line { - start = Point::new(start.row, 0); - let next_line_start = Point::new(end.row + 1, 0); - if next_line_start <= max_point { - end = next_line_start; - } else { - // We're on the last line without a trailing newline. - // Copy to the end of the line and add a newline afterwards. - end = Point::new(end.row, buffer.line_len(MultiBufferRow(end.row))); - add_trailing_newline = true; - } + + let max_point = buffer.max_point(); + let mut is_first = true; + for selection in &selections { + let mut start = selection.start; + let mut end = selection.end; + let is_entire_line = selection.is_empty() || self.selections.line_mode(); + let mut add_trailing_newline = false; + if is_entire_line { + start = Point::new(start.row, 0); + let next_line_start = Point::new(end.row + 1, 0); + if next_line_start <= max_point { + end = next_line_start; + } else { + // We're on the last line without a trailing newline. + // Copy to the end of the line and add a newline afterwards. + end = Point::new(end.row, buffer.line_len(MultiBufferRow(end.row))); + add_trailing_newline = true; } + } - let mut trimmed_selections = Vec::new(); - if strip_leading_indents && end.row.saturating_sub(start.row) > 0 { - let row = MultiBufferRow(start.row); - let first_indent = buffer.indent_size_for_line(row); - if first_indent.len == 0 || start.column > first_indent.len { - trimmed_selections.push(start..end); - } else { - trimmed_selections.push( - Point::new(row.0, first_indent.len) - ..Point::new(row.0, buffer.line_len(row)), - ); - for row in start.row + 1..=end.row { - let mut line_len = buffer.line_len(MultiBufferRow(row)); - if row == end.row { - line_len = end.column; - } - if line_len == 0 { - trimmed_selections - .push(Point::new(row, 0)..Point::new(row, line_len)); - continue; - } - let row_indent_size = buffer.indent_size_for_line(MultiBufferRow(row)); - if row_indent_size.len >= first_indent.len { - trimmed_selections.push( - Point::new(row, first_indent.len)..Point::new(row, line_len), - ); - } else { - trimmed_selections.clear(); - trimmed_selections.push(start..end); - break; - } + let mut trimmed_selections = Vec::new(); + if strip_leading_indents && end.row.saturating_sub(start.row) > 0 { + let row = MultiBufferRow(start.row); + let first_indent = buffer.indent_size_for_line(row); + if first_indent.len == 0 || start.column > first_indent.len { + trimmed_selections.push(start..end); + } else { + trimmed_selections.push( + Point::new(row.0, first_indent.len) + ..Point::new(row.0, buffer.line_len(row)), + ); + for row in start.row + 1..=end.row { + let mut line_len = buffer.line_len(MultiBufferRow(row)); + if row == end.row { + line_len = end.column; + } + if line_len == 0 { + trimmed_selections.push(Point::new(row, 0)..Point::new(row, line_len)); + continue; + } + let row_indent_size = buffer.indent_size_for_line(MultiBufferRow(row)); + if row_indent_size.len >= first_indent.len { + trimmed_selections + .push(Point::new(row, first_indent.len)..Point::new(row, line_len)); + } else { + trimmed_selections.clear(); + trimmed_selections.push(start..end); + break; } } - } else { - trimmed_selections.push(start..end); } + } else { + trimmed_selections.push(start..end); + } - let is_multiline_trim = trimmed_selections.len() > 1; - for trimmed_range in trimmed_selections { - if is_first { - is_first = false; - } else if is_multiline_trim || !prev_selection_was_entire_line { - text += "\n"; - } - prev_selection_was_entire_line = is_entire_line && !is_multiline_trim; - let mut len = 0; - for chunk in buffer.text_for_range(trimmed_range.start..trimmed_range.end) { - text.push_str(chunk); - len += chunk.len(); - } - if add_trailing_newline { - text.push('\n'); - len += 1; + let is_multiline_trim = trimmed_selections.len() > 1; + let mut selection_len: usize = 0; + let prev_selection_was_entire_line = is_entire_line && !is_multiline_trim; + + for trimmed_range in trimmed_selections { + if is_first { + is_first = false; + } else if is_multiline_trim || !prev_selection_was_entire_line { + text.push('\n'); + if is_multiline_trim { + selection_len += 1; } - clipboard_selections.push(ClipboardSelection::for_buffer( - len, - is_entire_line, - trimmed_range, - &buffer, - self.project.as_ref(), - cx, - )); + } + for chunk in buffer.text_for_range(trimmed_range.start..trimmed_range.end) { + text.push_str(chunk); + selection_len += chunk.len(); + } + if add_trailing_newline { + text.push('\n'); + selection_len += 1; } } + + clipboard_selections.push(ClipboardSelection::for_buffer( + selection_len, + is_entire_line, + start..end, + &buffer, + self.project.as_ref(), + cx, + )); } cx.write_to_clipboard(ClipboardItem::new_string_with_json_metadata( @@ -22801,12 +22801,36 @@ impl Editor { maybe!({ let breakpoint_store = self.breakpoint_store.as_ref()?; - let Some(active_stack_frame) = breakpoint_store.read(cx).active_position().cloned() - else { + let (active_stack_frame, debug_line_pane_id) = { + let store = breakpoint_store.read(cx); + let active_stack_frame = store.active_position().cloned(); + let debug_line_pane_id = store.active_debug_line_pane_id(); + (active_stack_frame, debug_line_pane_id) + }; + + let Some(active_stack_frame) = active_stack_frame else { self.clear_row_highlights::(); return None; }; + if let Some(debug_line_pane_id) = debug_line_pane_id { + if let Some(workspace) = self + .workspace + .as_ref() + .and_then(|(workspace, _)| workspace.upgrade()) + { + let editor_pane_id = workspace + .read(cx) + .pane_for_item_id(cx.entity_id()) + .map(|pane| pane.entity_id()); + + if editor_pane_id.is_some_and(|id| id != debug_line_pane_id) { + self.clear_row_highlights::(); + return None; + } + } + } + let position = active_stack_frame.position; let buffer_id = position.buffer_id?; let snapshot = self diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 7f5a84ebd32660..d1090b5e0eb676 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -8030,16 +8030,54 @@ async fn test_copy_trim_line_mode(cx: &mut TestAppContext) { let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" - « a - bˇ» + « fn main() { + dbg!(1) + }ˇ» + "}); + cx.update_editor(|editor, _window, _cx| editor.selections.set_line_mode(true)); + cx.update_editor(|editor, window, cx| editor.copy_and_trim(&CopyAndTrim, window, cx)); + + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("fn main() {\n dbg!(1)\n}\n".to_string()) + ); + + let clipboard_selections: Vec = cx + .read_from_clipboard() + .and_then(|item| item.entries().first().cloned()) + .and_then(|entry| match entry { + gpui::ClipboardEntry::String(text) => text.metadata_json(), + _ => None, + }) + .expect("should have clipboard selections"); + + assert_eq!(clipboard_selections.len(), 1); + assert!(clipboard_selections[0].is_entire_line); + + cx.set_state(indoc! {" + «fn main() { + dbg!(1) + }ˇ» "}); cx.update_editor(|editor, _window, _cx| editor.selections.set_line_mode(true)); cx.update_editor(|editor, window, cx| editor.copy_and_trim(&CopyAndTrim, window, cx)); assert_eq!( cx.read_from_clipboard().and_then(|item| item.text()), - Some("a\nb\n".to_string()) + Some("fn main() {\n dbg!(1)\n}\n".to_string()) ); + + let clipboard_selections: Vec = cx + .read_from_clipboard() + .and_then(|item| item.entries().first().cloned()) + .and_then(|entry| match entry { + gpui::ClipboardEntry::String(text) => text.metadata_json(), + _ => None, + }) + .expect("should have clipboard selections"); + + assert_eq!(clipboard_selections.len(), 1); + assert!(clipboard_selections[0].is_entire_line); } #[gpui::test] diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index deedd07d3eddfd..8131cdcb9e9475 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -5580,7 +5580,11 @@ impl EditorElement { continue; } let row_ix = display_row_range.start.0.saturating_sub(row_range.start.0); - if row_infos[row_ix as usize].diff_status.is_none() { + if row_infos + .get(row_ix as usize) + .and_then(|row_info| row_info.diff_status) + .is_none() + { continue; } if highlighted_rows @@ -8139,6 +8143,7 @@ pub(crate) fn render_buffer_header( .h(FILE_HEADER_HEIGHT as f32 * window.line_height()) .child( h_flex() + .group("buffer-header-group") .size_full() .flex_basis(Length::Definite(DefiniteLength::Fraction(0.667))) .pl_1() @@ -8332,31 +8337,36 @@ pub(crate) fn render_buffer_header( }) }, )) - .when( - can_open_excerpts && is_selected && relative_path.is_some(), - |el| { - el.child( - Button::new("open-file-button", "Open File") - .style(ButtonStyle::OutlinedGhost) - .key_binding(KeyBinding::for_action_in( - &OpenExcerpts, - &focus_handle, - cx, - )) - .on_click(window.listener_for(editor, { - let jump_data = jump_data.clone(); - move |editor, e: &ClickEvent, window, cx| { - editor.open_excerpts_common( - Some(jump_data.clone()), - e.modifiers().secondary(), - window, + .when(can_open_excerpts && relative_path.is_some(), |this| { + this.child( + div() + .when(!is_selected, |this| { + this.visible_on_hover("buffer-header-group") + }) + .child( + Button::new("open-file-button", "Open File") + .style(ButtonStyle::OutlinedGhost) + .when(is_selected, |this| { + this.key_binding(KeyBinding::for_action_in( + &OpenExcerpts, + &focus_handle, cx, - ); - } - })), - ) - }, - ) + )) + }) + .on_click(window.listener_for(editor, { + let jump_data = jump_data.clone(); + move |editor, e: &ClickEvent, window, cx| { + editor.open_excerpts_common( + Some(jump_data.clone()), + e.modifiers().secondary(), + window, + cx, + ); + } + })), + ), + ) + }) .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .on_click(window.listener_for(editor, { let buffer_id = for_excerpt.buffer_id; diff --git a/crates/editor/src/inlays/inlay_hints.rs b/crates/editor/src/inlays/inlay_hints.rs index 72951834ff4794..19953659ac67db 100644 --- a/crates/editor/src/inlays/inlay_hints.rs +++ b/crates/editor/src/inlays/inlay_hints.rs @@ -578,6 +578,7 @@ impl Editor { if let Some(hovered_hint) = self .visible_inlay_hints(cx) .into_iter() + .filter(|hint| snapshot.can_resolve(&hint.position)) .skip_while(|hint| { hint.position .cmp(&previous_valid_anchor, &buffer_snapshot) diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index afb296cff59369..685387342caf8e 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -1,7 +1,7 @@ use crate::{ - Anchor, Autoscroll, BufferSerialization, Capability, Editor, EditorEvent, EditorSettings, - ExcerptId, ExcerptRange, FormatTarget, MultiBuffer, MultiBufferSnapshot, NavigationData, - ReportEditorEvent, SelectionEffects, ToPoint as _, + ActiveDebugLine, Anchor, Autoscroll, BufferSerialization, Capability, Editor, EditorEvent, + EditorSettings, ExcerptId, ExcerptRange, FormatTarget, MultiBuffer, MultiBufferSnapshot, + NavigationData, ReportEditorEvent, SelectionEffects, ToPoint as _, display_map::HighlightKey, editor_settings::SeedQuerySetting, persistence::{DB, SerializedEditor}, @@ -1027,6 +1027,19 @@ impl Item for Editor { } } + fn pane_changed(&mut self, new_pane_id: EntityId, cx: &mut Context) { + if self + .highlighted_rows + .get(&TypeId::of::()) + .is_some_and(|lines| !lines.is_empty()) + && let Some(breakpoint_store) = self.breakpoint_store.as_ref() + { + breakpoint_store.update(cx, |store, _cx| { + store.set_active_debug_pane_id(new_pane_id); + }); + } + } + fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) { match event { EditorEvent::Saved | EditorEvent::TitleChanged => { diff --git a/crates/editor/src/semantic_tokens.rs b/crates/editor/src/semantic_tokens.rs index d2bbe533124efd..8742ae82a80a7a 100644 --- a/crates/editor/src/semantic_tokens.rs +++ b/crates/editor/src/semantic_tokens.rs @@ -122,7 +122,10 @@ impl Editor { if !self.mode().is_full() || !self.semantic_token_state.enabled() { self.invalidate_semantic_tokens(None); self.display_map.update(cx, |display_map, _| { - display_map.semantic_token_highlights.clear(); + match Arc::get_mut(&mut display_map.semantic_token_highlights) { + Some(highlights) => highlights.clear(), + None => display_map.semantic_token_highlights = Arc::new(Default::default()), + }; }); self.semantic_token_state.update_task = Task::ready(()); cx.notify(); @@ -171,8 +174,8 @@ impl Editor { .display_map .read(cx) .semantic_token_highlights - .iter() - .map(|(buffer_id, _)| *buffer_id) + .keys() + .copied() .filter(|buffer_id| !buffers_to_query.contains_key(buffer_id)) .filter(|buffer_id| { !self @@ -308,7 +311,7 @@ impl Editor { token_highlights.sort_by(|a, b| { a.range.start.cmp(&b.range.start, &multi_buffer_snapshot) }); - display_map.semantic_token_highlights.insert( + Arc::make_mut(&mut display_map.semantic_token_highlights).insert( buffer_id, (Arc::from(token_highlights), Arc::new(interner)), ); diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index c85b7545c2c8bb..14174306946acb 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -34,7 +34,7 @@ use workspace::{ }; use crate::{ - Autoscroll, DisplayMap, Editor, EditorEvent, RenderDiffHunkControlsFn, ToggleSoftWrap, + Autoscroll, Editor, EditorEvent, RenderDiffHunkControlsFn, ToggleSoftWrap, actions::{DisableBreakpoint, EditLogBreakpoint, EnableBreakpoint, ToggleBreakpoint}, display_map::Companion, }; @@ -667,52 +667,28 @@ impl SplittableEditor { .collect() }; - let mut companion = Companion::new( - rhs_display_map_id, - convert_rhs_rows_to_lhs, - convert_lhs_rows_to_rhs, - ); - - // stream this - for (path, diff) in path_diffs { - self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| { - let sync_result = lhs.multibuffer.update(cx, |lhs_multibuffer, lhs_cx| { - LhsEditor::update_path_excerpts_from_rhs( - path.clone(), - rhs_multibuffer, - lhs_multibuffer, - diff.clone(), - lhs_cx, - ) - }); - - if let Some((lhs_excerpt_ids, rhs_merge_groups)) = sync_result { - let mut final_rhs_ids = Vec::with_capacity(lhs_excerpt_ids.len()); - for group in rhs_merge_groups { - if group.len() == 1 { - final_rhs_ids.push(group[0]); - } else { - let merged_id = rhs_multibuffer.merge_excerpts(&group, cx); - final_rhs_ids.push(merged_id); - } - } + let companion = cx.new(|_| { + Companion::new( + rhs_display_map_id, + convert_rhs_rows_to_lhs, + convert_lhs_rows_to_rhs, + ) + }); - for (rhs_id, lhs_id) in final_rhs_ids.iter().zip(lhs_excerpt_ids.iter()) { - companion.add_excerpt_mapping(*lhs_id, *rhs_id); - } - let lhs_buffer_id = diff.read(cx).base_text(cx).remote_id(); - let rhs_buffer_id = diff.read(cx).buffer_id; - companion.add_buffer_mapping(lhs_buffer_id, rhs_buffer_id); - } - }); - } + self.lhs = Some(lhs); - let companion = cx.new(|_| companion); + let paths_for_sync: Vec<_> = path_diffs + .into_iter() + .map(|(path, diff)| (path, vec![], diff)) + .collect(); + self.sync_lhs_for_paths(paths_for_sync, &companion, cx); rhs_display_map.update(cx, |dm, cx| { dm.set_companion(Some((lhs_display_map, companion.clone())), cx); }); + let lhs = self.lhs.as_ref().unwrap(); + let shared_scroll_anchor = self .rhs_editor .read(cx) @@ -761,8 +737,6 @@ impl SplittableEditor { cx.notify(); }); - self.lhs = Some(lhs); - cx.notify(); } @@ -1011,34 +985,52 @@ impl SplittableEditor { diff: Entity, cx: &mut Context, ) -> (Vec>, bool) { - let rhs_display_map = self.rhs_editor.read(cx).display_map.clone(); - let lhs = self.lhs.as_ref(); - self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| { - mutate_excerpts_for_paths( - rhs_multibuffer, - lhs, - &rhs_display_map, - vec![(path.clone(), diff.clone())], + let Some(companion) = self.companion(cx) else { + return self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| { + let (anchors, added_a_new_excerpt) = rhs_multibuffer.set_excerpts_for_path( + path, + buffer.clone(), + ranges, + context_line_count, + cx, + ); + if !anchors.is_empty() + && rhs_multibuffer + .diff_for(buffer.read(cx).remote_id()) + .is_none_or(|old_diff| old_diff.entity_id() != diff.entity_id()) + { + rhs_multibuffer.add_diff(diff, cx); + } + (anchors, added_a_new_excerpt) + }); + }; + + let old_rhs_ids: Vec = self + .rhs_multibuffer + .read(cx) + .excerpts_for_path(&path) + .collect(); + + let result = self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| { + let (anchors, added_a_new_excerpt) = rhs_multibuffer.set_excerpts_for_path( + path.clone(), + buffer.clone(), + ranges, + context_line_count, cx, - |rhs_multibuffer, cx| { - let (anchors, added_a_new_excerpt) = rhs_multibuffer.set_excerpts_for_path( - path.clone(), - buffer.clone(), - ranges, - context_line_count, - cx, - ); - if !anchors.is_empty() - && rhs_multibuffer - .diff_for(buffer.read(cx).remote_id()) - .is_none_or(|old_diff| old_diff.entity_id() != diff.entity_id()) - { - rhs_multibuffer.add_diff(diff.clone(), cx); - } - (anchors, added_a_new_excerpt) - }, - ) - }) + ); + if !anchors.is_empty() + && rhs_multibuffer + .diff_for(buffer.read(cx).remote_id()) + .is_none_or(|old_diff| old_diff.entity_id() != diff.entity_id()) + { + rhs_multibuffer.add_diff(diff.clone(), cx); + } + (anchors, added_a_new_excerpt) + }); + + self.sync_lhs_for_paths(vec![(path, old_rhs_ids, diff)], &companion, cx); + result } fn expand_excerpts( @@ -1048,65 +1040,66 @@ impl SplittableEditor { direction: ExpandExcerptDirection, cx: &mut Context, ) { - let rhs_display_map = self.rhs_editor.read(cx).display_map.clone(); - let lhs = self.lhs.as_ref(); - self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| { - if lhs.is_some() { - let snapshot = rhs_multibuffer.snapshot(cx); - let paths_with_diffs: Vec<_> = excerpt_ids - .clone() - .filter_map(|excerpt_id| { - let path = rhs_multibuffer.path_for_excerpt(excerpt_id)?; - let buffer = snapshot.buffer_for_excerpt(excerpt_id)?; - let diff = rhs_multibuffer.diff_for(buffer.remote_id())?; - Some((path, diff)) - }) - .collect::>() - .into_iter() - .collect(); - - mutate_excerpts_for_paths( - rhs_multibuffer, - lhs, - &rhs_display_map, - paths_with_diffs, - cx, - |rhs_multibuffer, cx| { - rhs_multibuffer.expand_excerpts(excerpt_ids.clone(), lines, direction, cx); - }, - ); - } else { + let Some(companion) = self.companion(cx) else { + self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| { rhs_multibuffer.expand_excerpts(excerpt_ids, lines, direction, cx); - } + }); + return; + }; + + let paths_with_old_ids: Vec<_> = self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| { + let snapshot = rhs_multibuffer.snapshot(cx); + let paths = excerpt_ids + .clone() + .filter_map(|excerpt_id| { + let path = rhs_multibuffer.path_for_excerpt(excerpt_id)?; + let buffer = snapshot.buffer_for_excerpt(excerpt_id)?; + let diff = rhs_multibuffer.diff_for(buffer.remote_id())?; + Some((path, diff)) + }) + .collect::>() + .into_iter() + .map(|(path, diff)| { + let old_ids = rhs_multibuffer.excerpts_for_path(&path).collect(); + (path, old_ids, diff) + }) + .collect(); + rhs_multibuffer.expand_excerpts(excerpt_ids, lines, direction, cx); + paths }); + + self.sync_lhs_for_paths(paths_with_old_ids, &companion, cx); } pub fn remove_excerpts_for_path(&mut self, path: PathKey, cx: &mut Context) { - let rhs_display_map = self.rhs_editor.read(cx).display_map.clone(); - - if let Some(lhs) = &self.lhs { + let Some(lhs) = &self.lhs else { self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| { - let rhs_excerpt_ids: Vec = - rhs_multibuffer.excerpts_for_path(&path).collect(); - let lhs_excerpt_ids: Vec = - lhs.multibuffer.read(cx).excerpts_for_path(&path).collect(); + rhs_multibuffer.remove_excerpts_for_path(path, cx); + }); + return; + }; - if let Some(companion) = rhs_display_map.read(cx).companion().cloned() { - companion.update(cx, |c, _| { - c.remove_excerpt_mappings(lhs_excerpt_ids, rhs_excerpt_ids); - }); - } + let rhs_excerpt_ids: Vec = self + .rhs_multibuffer + .read(cx) + .excerpts_for_path(&path) + .collect(); + let lhs_excerpt_ids: Vec = + lhs.multibuffer.read(cx).excerpts_for_path(&path).collect(); - rhs_multibuffer.remove_excerpts_for_path(path.clone(), cx); - }); - lhs.multibuffer.update(cx, |lhs_multibuffer, cx| { - lhs_multibuffer.remove_excerpts_for_path(path, cx); - }); - } else { - self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| { - rhs_multibuffer.remove_excerpts_for_path(path.clone(), cx); + let rhs_display_map = self.rhs_editor.read(cx).display_map.clone(); + if let Some(companion) = rhs_display_map.read(cx).companion().cloned() { + companion.update(cx, |c, _| { + c.remove_excerpt_mappings(lhs_excerpt_ids, rhs_excerpt_ids); }); } + + self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| { + rhs_multibuffer.remove_excerpts_for_path(path.clone(), cx); + }); + lhs.multibuffer.update(cx, |lhs_multibuffer, cx| { + lhs_multibuffer.remove_excerpts_for_path(path, cx); + }); } fn search_token(&self) -> SearchToken { @@ -1121,6 +1114,138 @@ impl SplittableEditor { } &self.rhs_editor } + + fn companion(&self, cx: &App) -> Option> { + if self.lhs.is_none() { + return None; + } + let rhs_display_map = self.rhs_editor.read(cx).display_map.clone(); + rhs_display_map.read(cx).companion().cloned() + } + + fn sync_lhs_for_paths( + &self, + paths_with_old_rhs_ids: Vec<(PathKey, Vec, Entity)>, + companion: &Entity, + cx: &mut Context, + ) { + let Some(lhs) = &self.lhs else { return }; + + self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| { + for (path, old_rhs_ids, diff) in paths_with_old_rhs_ids { + let old_lhs_ids: Vec = + lhs.multibuffer.read(cx).excerpts_for_path(&path).collect(); + + companion.update(cx, |c, _| { + c.remove_excerpt_mappings(old_lhs_ids, old_rhs_ids); + }); + + let rhs_excerpt_ids: Vec = + rhs_multibuffer.excerpts_for_path(&path).collect(); + let Some(excerpt_id) = rhs_excerpt_ids.first().copied() else { + lhs.multibuffer.update(cx, |lhs_multibuffer, lhs_cx| { + lhs_multibuffer.remove_excerpts_for_path(path, lhs_cx); + }); + continue; + }; + let Some(main_buffer_snapshot) = rhs_multibuffer + .snapshot(cx) + .buffer_for_excerpt(excerpt_id) + .cloned() + else { + continue; + }; + let Some(main_buffer) = rhs_multibuffer.buffer(main_buffer_snapshot.remote_id()) + else { + continue; + }; + + let base_text_buffer = diff.read(cx).base_text_buffer().clone(); + let diff_snapshot = diff.read(cx).snapshot(cx); + let base_text_buffer_snapshot = base_text_buffer.read(cx).snapshot(); + + let lhs_ranges: Vec> = rhs_multibuffer + .excerpts_for_buffer(main_buffer_snapshot.remote_id(), cx) + .into_iter() + .filter(|(id, _)| rhs_excerpt_ids.contains(id)) + .map(|(_, excerpt_range)| { + let to_base_text = |range: Range| { + let start = diff_snapshot + .buffer_point_to_base_text_range( + Point::new(range.start.row, 0), + &main_buffer_snapshot, + ) + .start; + let end = diff_snapshot + .buffer_point_to_base_text_range( + Point::new(range.end.row, 0), + &main_buffer_snapshot, + ) + .end; + let end_column = diff_snapshot.base_text().line_len(end.row); + Point::new(start.row, 0)..Point::new(end.row, end_column) + }; + let primary = excerpt_range.primary.to_point(&main_buffer_snapshot); + let context = excerpt_range.context.to_point(&main_buffer_snapshot); + ExcerptRange { + primary: to_base_text(primary), + context: to_base_text(context), + } + }) + .collect(); + + let groups = lhs.multibuffer.update(cx, |lhs_multibuffer, lhs_cx| { + let lhs_result = lhs_multibuffer.update_path_excerpts( + path, + base_text_buffer, + &base_text_buffer_snapshot, + lhs_ranges, + lhs_cx, + ); + if !lhs_result.excerpt_ids.is_empty() + && lhs_multibuffer + .diff_for(base_text_buffer_snapshot.remote_id()) + .is_none_or(|old_diff| old_diff.entity_id() != diff.entity_id()) + { + lhs_multibuffer.add_inverted_diff(diff.clone(), main_buffer, lhs_cx); + } + + let mut groups = Vec::new(); + for (lhs_id, chunk) in &lhs_result + .excerpt_ids + .iter() + .copied() + .zip(rhs_excerpt_ids) + .chunk_by(|(lhs_id, _)| *lhs_id) + { + groups.push((lhs_id, chunk.map(|(_, rhs_id)| rhs_id).collect::>())); + } + groups + }); + + let pairs = groups + .into_iter() + .map(|(lhs_id, rhs_group)| { + let rhs_id = if rhs_group.len() == 1 { + rhs_group[0] + } else { + rhs_multibuffer.merge_excerpts(&rhs_group, cx) + }; + (lhs_id, rhs_id) + }) + .collect::>(); + + let lhs_buffer_id = diff.read(cx).base_text(cx).remote_id(); + let rhs_buffer_id = diff.read(cx).buffer_id; + companion.update(cx, |c, _| { + for (lhs_id, rhs_id) in pairs { + c.add_excerpt_mapping(lhs_id, rhs_id); + } + c.add_buffer_mapping(lhs_buffer_id, rhs_buffer_id); + }); + } + }); + } } #[cfg(test)] @@ -1927,209 +2052,6 @@ impl Render for SplittableEditor { } } -fn mutate_excerpts_for_paths( - rhs_multibuffer: &mut MultiBuffer, - lhs: Option<&LhsEditor>, - rhs_display_map: &Entity, - paths_with_diffs: Vec<(PathKey, Entity)>, - cx: &mut Context, - mutate: impl FnOnce(&mut MultiBuffer, &mut Context) -> R, -) -> R { - let old_rhs_ids: Vec<_> = paths_with_diffs - .iter() - .map(|(path, _)| { - rhs_multibuffer - .excerpts_for_path(path) - .collect::>() - }) - .collect(); - - let result = mutate(rhs_multibuffer, cx); - - if let Some(lhs) = lhs { - let mut sync_results = Vec::new(); - let mut diffs_for_mapping = Vec::new(); - - for ((path, diff), old_rhs_ids) in paths_with_diffs.into_iter().zip(old_rhs_ids) { - let sync_result = lhs.multibuffer.update(cx, |lhs_multibuffer, lhs_cx| { - LhsEditor::sync_path_excerpts( - path, - old_rhs_ids, - rhs_multibuffer, - lhs_multibuffer, - diff.clone(), - rhs_display_map, - lhs_cx, - ) - }); - if let Some(sync_result) = sync_result { - sync_results.push(sync_result); - diffs_for_mapping.push(diff); - } - } - - for ((lhs_excerpt_ids, rhs_merge_groups), diff) in - sync_results.into_iter().zip(diffs_for_mapping.into_iter()) - { - let mut final_rhs_ids = Vec::with_capacity(lhs_excerpt_ids.len()); - for group in rhs_merge_groups { - if group.len() == 1 { - final_rhs_ids.push(group[0]); - } else { - let merged_id = rhs_multibuffer.merge_excerpts(&group, cx); - final_rhs_ids.push(merged_id); - } - } - - debug_assert_eq!(final_rhs_ids.len(), lhs_excerpt_ids.len()); - - if let Some(companion) = rhs_display_map.read(cx).companion().cloned() { - let lhs_buffer_id = diff.read(cx).base_text(cx).remote_id(); - let rhs_buffer_id = diff.read(cx).buffer_id; - companion.update(cx, |c, _| { - for (rhs_id, lhs_id) in final_rhs_ids.iter().zip(lhs_excerpt_ids.iter()) { - c.add_excerpt_mapping(*lhs_id, *rhs_id); - } - c.add_buffer_mapping(lhs_buffer_id, rhs_buffer_id); - }); - } - } - } - - result -} - -impl LhsEditor { - fn update_path_excerpts_from_rhs( - path_key: PathKey, - rhs_multibuffer: &MultiBuffer, - lhs_multibuffer: &mut MultiBuffer, - diff: Entity, - lhs_cx: &mut Context, - ) -> Option<(Vec, Vec>)> { - let Some(excerpt_id) = rhs_multibuffer.excerpts_for_path(&path_key).next() else { - lhs_multibuffer.remove_excerpts_for_path(path_key, lhs_cx); - return None; - }; - - let rhs_excerpt_ids: Vec = - rhs_multibuffer.excerpts_for_path(&path_key).collect(); - - let rhs_multibuffer_snapshot = rhs_multibuffer.snapshot(lhs_cx); - let main_buffer = rhs_multibuffer_snapshot - .buffer_for_excerpt(excerpt_id) - .unwrap(); - let diff_snapshot; - let base_text_buffer_snapshot; - let remote_id; - { - let diff = diff.read(lhs_cx); - let base_text_buffer = diff.base_text_buffer().read(lhs_cx); - diff_snapshot = diff.snapshot(lhs_cx); - base_text_buffer_snapshot = base_text_buffer.snapshot(); - remote_id = base_text_buffer.remote_id(); - } - let new = rhs_multibuffer - .excerpts_for_buffer(main_buffer.remote_id(), lhs_cx) - .into_iter() - .filter(|(id, _)| rhs_excerpt_ids.contains(&id)) - .map(|(_, excerpt_range)| { - let point_range_to_base_text_point_range = |range: Range| { - let start = diff_snapshot - .buffer_point_to_base_text_range( - Point::new(range.start.row, 0), - main_buffer, - ) - .start; - let end = diff_snapshot - .buffer_point_to_base_text_range(Point::new(range.end.row, 0), main_buffer) - .end; - let end_column = diff_snapshot.base_text().line_len(end.row); - Point::new(start.row, 0)..Point::new(end.row, end_column) - }; - let rhs = excerpt_range.primary.to_point(main_buffer); - let context = excerpt_range.context.to_point(main_buffer); - ExcerptRange { - primary: point_range_to_base_text_point_range(rhs), - context: point_range_to_base_text_point_range(context), - } - }) - .collect(); - - let lhs_result = lhs_multibuffer.update_path_excerpts( - path_key, - diff.read(lhs_cx).base_text_buffer().clone(), - &base_text_buffer_snapshot, - new, - lhs_cx, - ); - if !lhs_result.excerpt_ids.is_empty() - && lhs_multibuffer - .diff_for(remote_id) - .is_none_or(|old_diff| old_diff.entity_id() != diff.entity_id()) - { - let main_buffer_entity = rhs_multibuffer - .buffer(main_buffer.remote_id()) - .expect("main buffer should exist in rhs_multibuffer"); - lhs_multibuffer.add_inverted_diff(diff, main_buffer_entity, lhs_cx); - } - - let rhs_merge_groups: Vec> = { - let mut groups = Vec::new(); - let mut current_group = Vec::new(); - let mut last_id = None; - - for (lhs_id, rhs_id) in lhs_result.excerpt_ids.iter().zip(rhs_excerpt_ids) { - if last_id == Some(lhs_id) { - current_group.push(rhs_id); - } else { - if !current_group.is_empty() { - groups.push(current_group); - } - current_group = vec![rhs_id]; - last_id = Some(lhs_id); - } - } - if !current_group.is_empty() { - groups.push(current_group); - } - groups - }; - - let deduplicated_lhs_ids: Vec = - lhs_result.excerpt_ids.iter().dedup().copied().collect(); - - Some((deduplicated_lhs_ids, rhs_merge_groups)) - } - - fn sync_path_excerpts( - path_key: PathKey, - old_rhs_excerpt_ids: Vec, - rhs_multibuffer: &MultiBuffer, - lhs_multibuffer: &mut MultiBuffer, - diff: Entity, - rhs_display_map: &Entity, - lhs_cx: &mut Context, - ) -> Option<(Vec, Vec>)> { - let old_lhs_excerpt_ids: Vec = - lhs_multibuffer.excerpts_for_path(&path_key).collect(); - - if let Some(companion) = rhs_display_map.read(lhs_cx).companion().cloned() { - companion.update(lhs_cx, |c, _| { - c.remove_excerpt_mappings(old_lhs_excerpt_ids, old_rhs_excerpt_ids); - }); - } - - Self::update_path_excerpts_from_rhs( - path_key, - rhs_multibuffer, - lhs_multibuffer, - diff, - lhs_cx, - ) - } -} - #[cfg(test)] mod tests { use std::sync::Arc; diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs b/crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs index 11b2e9f66187ea..6d7db749f0cd02 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs @@ -1,4 +1,4 @@ -use super::latest; +use super::{latest, since_v0_6_0}; use crate::wasm_host::WasmState; use anyhow::Result; use extension::WorktreeDelegate; @@ -15,7 +15,7 @@ wasmtime::component::bindgen!({ path: "../extension_api/wit/since_v0.0.4", with: { "worktree": ExtensionWorktree, - "zed:extension/github": latest::zed::extension::github, + "zed:extension/github": since_v0_6_0::zed::extension::github, "zed:extension/platform": latest::zed::extension::platform, }, }); @@ -129,7 +129,7 @@ impl ExtensionImports for WasmState { repo: String, options: GithubReleaseOptions, ) -> wasmtime::Result> { - latest::zed::extension::github::Host::latest_github_release(self, repo, options).await + since_v0_6_0::zed::extension::github::Host::latest_github_release(self, repo, options).await } async fn current_platform(&mut self) -> Result<(Os, Architecture)> { diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 81fe0380eb0514..67cd8c37e3c94c 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -62,3 +62,19 @@ impl FeatureFlag for DiffReviewFeatureFlag { false } } + +pub struct GitGraphFeatureFlag; + +impl FeatureFlag for GitGraphFeatureFlag { + const NAME: &'static str = "git-graph"; +} + +pub struct StreamingEditFileToolFeatureFlag; + +impl FeatureFlag for StreamingEditFileToolFeatureFlag { + const NAME: &'static str = "streaming-edit-file-tool"; + + fn enabled_for_staff() -> bool { + false + } +} diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 6513d5a33b6eb9..12cd67cdae1a25 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -768,6 +768,136 @@ impl GitRepository for FakeGitRepository { unimplemented!() } + fn diff_stat( + &self, + diff_type: git::repository::DiffType, + ) -> BoxFuture<'_, Result>> { + fn count_lines(s: &str) -> u32 { + if s.is_empty() { + 0 + } else { + s.lines().count() as u32 + } + } + + match diff_type { + git::repository::DiffType::HeadToIndex => self + .with_state_async(false, |state| { + let mut result = HashMap::default(); + let all_paths: HashSet<&RepoPath> = state + .head_contents + .keys() + .chain(state.index_contents.keys()) + .collect(); + for path in all_paths { + let head = state.head_contents.get(path); + let index = state.index_contents.get(path); + match (head, index) { + (Some(old), Some(new)) if old != new => { + result.insert( + path.clone(), + git::status::DiffStat { + added: count_lines(new), + deleted: count_lines(old), + }, + ); + } + (Some(old), None) => { + result.insert( + path.clone(), + git::status::DiffStat { + added: 0, + deleted: count_lines(old), + }, + ); + } + (None, Some(new)) => { + result.insert( + path.clone(), + git::status::DiffStat { + added: count_lines(new), + deleted: 0, + }, + ); + } + _ => {} + } + } + Ok(result) + }) + .boxed(), + git::repository::DiffType::HeadToWorktree => { + let workdir_path = self.dot_git_path.parent().unwrap().to_path_buf(); + let worktree_files: HashMap = self + .fs + .files() + .iter() + .filter_map(|path| { + let repo_path = path.strip_prefix(&workdir_path).ok()?; + if repo_path.starts_with(".git") { + return None; + } + let content = self + .fs + .read_file_sync(path) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok())?; + let repo_path = RelPath::new(repo_path, PathStyle::local()).ok()?; + Some((RepoPath::from_rel_path(&repo_path), content)) + }) + .collect(); + + self.with_state_async(false, move |state| { + let mut result = HashMap::default(); + let all_paths: HashSet<&RepoPath> = state + .head_contents + .keys() + .chain(worktree_files.keys()) + .collect(); + for path in all_paths { + let head = state.head_contents.get(path); + let worktree = worktree_files.get(path); + match (head, worktree) { + (Some(old), Some(new)) if old != new => { + result.insert( + path.clone(), + git::status::DiffStat { + added: count_lines(new), + deleted: count_lines(old), + }, + ); + } + (Some(old), None) => { + result.insert( + path.clone(), + git::status::DiffStat { + added: 0, + deleted: count_lines(old), + }, + ); + } + (None, Some(new)) => { + result.insert( + path.clone(), + git::status::DiffStat { + added: count_lines(new), + deleted: 0, + }, + ); + } + _ => {} + } + } + Ok(result) + }) + .boxed() + } + git::repository::DiffType::MergeBase { .. } => { + future::ready(Ok(HashMap::default())).boxed() + } + } + } + fn checkpoint(&self) -> BoxFuture<'static, Result> { let executor = self.executor.clone(); let fs = self.fs.clone(); diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 592c04427dc860..1925e84735a802 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -898,6 +898,11 @@ pub trait GitRepository: Send + Sync { /// Run git diff fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result>; + fn diff_stat( + &self, + diff: DiffType, + ) -> BoxFuture<'_, Result>>; + /// Creates a checkpoint for the repository. fn checkpoint(&self) -> BoxFuture<'static, Result>; @@ -2015,7 +2020,7 @@ impl GitRepository for RealGitRepository { DiffType::MergeBase { base_ref } => { new_command(&git_binary_path) .current_dir(&working_directory) - .args(["diff", "--merge-base", base_ref.as_ref(), "HEAD"]) + .args(["diff", "--merge-base", base_ref.as_ref()]) .output() .await? } @@ -2031,6 +2036,57 @@ impl GitRepository for RealGitRepository { .boxed() } + fn diff_stat( + &self, + diff: DiffType, + ) -> BoxFuture<'_, Result>> { + let working_directory = self.working_directory(); + let git_binary_path = self.any_git_binary_path.clone(); + self.executor + .spawn(async move { + let working_directory = working_directory?; + let output = match diff { + DiffType::HeadToIndex => { + new_command(&git_binary_path) + .current_dir(&working_directory) + .args(["diff", "--numstat", "--staged"]) + .output() + .await? + } + DiffType::HeadToWorktree => { + new_command(&git_binary_path) + .current_dir(&working_directory) + .args(["diff", "--numstat"]) + .output() + .await? + } + DiffType::MergeBase { base_ref } => { + new_command(&git_binary_path) + .current_dir(&working_directory) + .args([ + "diff", + "--numstat", + "--merge-base", + base_ref.as_ref(), + "HEAD", + ]) + .output() + .await? + } + }; + + anyhow::ensure!( + output.status.success(), + "Failed to run git diff --numstat:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(crate::status::parse_numstat(&String::from_utf8_lossy( + &output.stdout, + ))) + }) + .boxed() + } + fn stage_paths( &self, paths: Vec, diff --git a/crates/git/src/status.rs b/crates/git/src/status.rs index 2cf7cc7c181062..b20919e7ecf474 100644 --- a/crates/git/src/status.rs +++ b/crates/git/src/status.rs @@ -475,7 +475,12 @@ impl FromStr for GitStatus { } .into(); } - _ => panic!("Unexpected duplicated status entries: {a_status:?} and {b_status:?}"), + (x, y) if x == y => {} + _ => { + log::warn!( + "Unexpected duplicated status entries: {a_status:?} and {b_status:?}" + ); + } } true }); @@ -575,14 +580,151 @@ impl FromStr for TreeDiff { } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct DiffStat { + pub added: u32, + pub deleted: u32, +} + +/// Parses the output of `git diff --numstat` where output looks like: +/// +/// ```text +/// 24 12 dir/file.txt +/// ``` +pub fn parse_numstat(output: &str) -> HashMap { + let mut stats = HashMap::default(); + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let mut parts = line.splitn(3, '\t'); + let (Some(added_str), Some(deleted_str), Some(path_str)) = + (parts.next(), parts.next(), parts.next()) + else { + continue; + }; + let Ok(added) = added_str.parse::() else { + continue; + }; + let Ok(deleted) = deleted_str.parse::() else { + continue; + }; + let Ok(path) = RepoPath::new(path_str) else { + continue; + }; + let stat = DiffStat { added, deleted }; + stats.insert(path, stat); + } + stats +} + #[cfg(test)] mod tests { use crate::{ repository::RepoPath, - status::{TreeDiff, TreeDiffStatus}, + status::{FileStatus, GitStatus, TreeDiff, TreeDiffStatus}, }; + use super::{DiffStat, parse_numstat}; + + #[test] + fn test_parse_numstat_normal() { + let input = "10\t5\tsrc/main.rs\n3\t1\tREADME.md\n"; + let result = parse_numstat(input); + assert_eq!(result.len(), 2); + assert_eq!( + result.get(&RepoPath::new("src/main.rs").unwrap()), + Some(&DiffStat { + added: 10, + deleted: 5 + }) + ); + assert_eq!( + result.get(&RepoPath::new("README.md").unwrap()), + Some(&DiffStat { + added: 3, + deleted: 1 + }) + ); + } + + #[test] + fn test_parse_numstat_binary_files_skipped() { + // git diff --numstat outputs "-\t-\tpath" for binary files + let input = "-\t-\timage.png\n5\t2\tsrc/lib.rs\n"; + let result = parse_numstat(input); + assert_eq!(result.len(), 1); + assert!(!result.contains_key(&RepoPath::new("image.png").unwrap())); + assert_eq!( + result.get(&RepoPath::new("src/lib.rs").unwrap()), + Some(&DiffStat { + added: 5, + deleted: 2 + }) + ); + } + + #[test] + fn test_parse_numstat_empty_input() { + assert!(parse_numstat("").is_empty()); + assert!(parse_numstat("\n\n").is_empty()); + assert!(parse_numstat(" \n \n").is_empty()); + } + + #[test] + fn test_parse_numstat_malformed_lines_skipped() { + let input = "not_a_number\t5\tfile.rs\n10\t5\tvalid.rs\n"; + let result = parse_numstat(input); + assert_eq!(result.len(), 1); + assert_eq!( + result.get(&RepoPath::new("valid.rs").unwrap()), + Some(&DiffStat { + added: 10, + deleted: 5 + }) + ); + } + + #[test] + fn test_parse_numstat_incomplete_lines_skipped() { + // Lines with fewer than 3 tab-separated fields are skipped + let input = "10\t5\n7\t3\tok.rs\n"; + let result = parse_numstat(input); + assert_eq!(result.len(), 1); + assert_eq!( + result.get(&RepoPath::new("ok.rs").unwrap()), + Some(&DiffStat { + added: 7, + deleted: 3 + }) + ); + } + + #[test] + fn test_parse_numstat_zero_stats() { + let input = "0\t0\tunchanged_but_present.rs\n"; + let result = parse_numstat(input); + assert_eq!( + result.get(&RepoPath::new("unchanged_but_present.rs").unwrap()), + Some(&DiffStat { + added: 0, + deleted: 0 + }) + ); + } + + #[test] + fn test_duplicate_untracked_entries() { + // Regression test for ZED-2XA: git can produce duplicate untracked entries + // for the same path. This should deduplicate them instead of panicking. + let input = "?? file.txt\0?? file.txt"; + let status: GitStatus = input.parse().unwrap(); + assert_eq!(status.entries.len(), 1); + assert_eq!(status.entries[0].1, FileStatus::Untracked); + } + #[test] fn test_tree_diff_parsing() { let input = ":000000 100644 0000000000000000000000000000000000000000 0062c311b8727c3a2e3cd7a41bc9904feacf8f98 A\x00.zed/settings.json\x00".to_owned() + diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 37f170ada5ecd2..0052d58f5985a2 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -1,5 +1,5 @@ use collections::{BTreeMap, HashMap}; -use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; +use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag}; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, parse_git_remote_url, @@ -18,7 +18,10 @@ use language::line_diff; use menu::{Cancel, SelectNext, SelectPrevious}; use project::{ Project, - git_store::{CommitDataState, GitStoreEvent, Repository, RepositoryEvent, RepositoryId}, + git_store::{ + CommitDataState, GitGraphEvent, GitStoreEvent, GraphDataResponse, Repository, + RepositoryEvent, RepositoryId, + }, }; use settings::Settings; use smallvec::{SmallVec, smallvec}; @@ -39,7 +42,7 @@ use ui::{ }; use workspace::{ Workspace, - item::{Item, ItemEvent, SerializableItem}, + item::{Item, ItemEvent, SerializableItem, TabTooltipContent}, }; const COMMIT_CIRCLE_RADIUS: Pixels = px(3.5); @@ -246,12 +249,6 @@ actions!( ] ); -pub struct GitGraphFeatureFlag; - -impl FeatureFlag for GitGraphFeatureFlag { - const NAME: &'static str = "git-graph"; -} - fn timestamp_format() -> &'static [BorrowedFormatItem<'static>] { static FORMAT: OnceLock>> = OnceLock::new(); FORMAT.get_or_init(|| { @@ -710,29 +707,66 @@ pub fn init(cx: &mut App) { |div| { let workspace = workspace.weak_handle(); - div.on_action(move |_: &git_ui::git_panel::Open, window, cx| { - workspace - .update(cx, |workspace, cx| { - let existing = workspace.items_of_type::(cx).next(); - if let Some(existing) = existing { - workspace.activate_item(&existing, true, true, window, cx); - return; - } + div.on_action({ + let workspace = workspace.clone(); + move |_: &git_ui::git_panel::Open, window, cx| { + workspace + .update(cx, |workspace, cx| { + let existing = workspace.items_of_type::(cx).next(); + if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + return; + } - let project = workspace.project().clone(); - let workspace_handle = workspace.weak_handle(); - let git_graph = cx - .new(|cx| GitGraph::new(project, workspace_handle, window, cx)); - workspace.add_item_to_active_pane( - Box::new(git_graph), - None, - true, - window, - cx, - ); - }) - .ok(); + let project = workspace.project().clone(); + let workspace_handle = workspace.weak_handle(); + let git_graph = cx.new(|cx| { + GitGraph::new(project, workspace_handle, window, cx) + }); + workspace.add_item_to_active_pane( + Box::new(git_graph), + None, + true, + window, + cx, + ); + }) + .ok(); + } }) + .on_action( + move |action: &git_ui::git_panel::OpenAtCommit, window, cx| { + let sha = action.sha.clone(); + workspace + .update(cx, |workspace, cx| { + let existing = workspace.items_of_type::(cx).next(); + if let Some(existing) = existing { + existing.update(cx, |graph, cx| { + graph.select_commit_by_sha(&sha, cx); + }); + workspace.activate_item(&existing, true, true, window, cx); + return; + } + + let project = workspace.project().clone(); + let workspace_handle = workspace.weak_handle(); + let git_graph = cx.new(|cx| { + let mut graph = + GitGraph::new(project, workspace_handle, window, cx); + graph.select_commit_by_sha(&sha, cx); + graph + }); + workspace.add_item_to_active_pane( + Box::new(git_graph), + None, + true, + window, + cx, + ); + }) + .ok(); + }, + ) }, ) }); @@ -821,6 +855,7 @@ pub struct GitGraph { commit_details_split_state: Entity, selected_repo_id: Option, changed_files_scroll_handle: UniformListScrollHandle, + pending_select_sha: Option, } impl GitGraph { @@ -918,6 +953,7 @@ impl GitGraph { commit_details_split_state: cx.new(|_cx| SplitState::new()), selected_repo_id: active_repository, changed_files_scroll_handle: UniformListScrollHandle::new(), + pending_select_sha: None, }; this.fetch_initial_graph_data(cx); @@ -931,21 +967,65 @@ impl GitGraph { cx: &mut Context, ) { match event { - RepositoryEvent::GitGraphCountUpdated((order, source), commit_count) => { - if order != &self.log_order || source != &self.log_source { - return; - } + RepositoryEvent::GraphEvent((source, order), event) + if source == &self.log_source && order == &self.log_order => + { + match event { + GitGraphEvent::FullyLoaded => { + if let Some(pending_sha_index) = + self.pending_select_sha.take().and_then(|oid| { + repository + .read(cx) + .get_graph_data(source.clone(), *order) + .and_then(|data| data.commit_oid_to_index.get(&oid).copied()) + }) + { + self.select_entry(pending_sha_index, cx); + } + } + GitGraphEvent::LoadingError => { + // todo(git_graph): Wire this up with the UI + } + GitGraphEvent::CountUpdated(commit_count) => { + let old_count = self.graph_data.commits.len(); + + if let Some(pending_selection_index) = + repository.update(cx, |repository, cx| { + let GraphDataResponse { + commits, + is_loading, + error: _, + } = repository.graph_data( + source.clone(), + *order, + old_count..*commit_count, + cx, + ); + self.graph_data.add_commits(commits); + + let pending_sha_index = self.pending_select_sha.and_then(|oid| { + repository.get_graph_data(source.clone(), *order).and_then( + |data| data.commit_oid_to_index.get(&oid).copied(), + ) + }); - let old_count = self.graph_data.commits.len(); + if !is_loading && pending_sha_index.is_none() { + self.pending_select_sha.take(); + } - repository.update(cx, |repository, cx| { - let (commits, _) = - repository.graph_data(source.clone(), *order, old_count..*commit_count, cx); - self.graph_data.add_commits(commits); - }); - cx.notify(); + pending_sha_index + }) + { + self.select_entry(pending_selection_index, cx); + self.pending_select_sha.take(); + } + + cx.notify(); + } + } } RepositoryEvent::BranchChanged | RepositoryEvent::MergeHeadsChanged => { + self.pending_select_sha = None; // Only invalidate if we scanned atleast once, // meaning we are not inside the initial repo loading state // NOTE: this fixes an loading performance regression @@ -954,6 +1034,7 @@ impl GitGraph { cx.notify(); } } + RepositoryEvent::GraphEvent(_, _) => {} _ => {} } } @@ -961,12 +1042,9 @@ impl GitGraph { fn fetch_initial_graph_data(&mut self, cx: &mut App) { if let Some(repository) = self.get_selected_repository(cx) { repository.update(cx, |repository, cx| { - let (commits, _) = repository.graph_data( - self.log_source.clone(), - self.log_order, - 0..usize::MAX, - cx, - ); + let commits = repository + .graph_data(self.log_source.clone(), self.log_order, 0..usize::MAX, cx) + .commits; self.graph_data.add_commits(commits); }); } @@ -1109,6 +1187,10 @@ impl GitGraph { } } + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + self.open_selected_commit_view(window, cx); + } + fn select_entry(&mut self, idx: usize, cx: &mut Context) { if self.selected_entry_idx == Some(idx) { return; @@ -1153,6 +1235,27 @@ impl GitGraph { cx.notify(); } + pub fn select_commit_by_sha(&mut self, sha: &str, cx: &mut Context) { + let Ok(oid) = sha.parse::() else { + return; + }; + + let Some(selected_repository) = self.get_selected_repository(cx) 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 { + return; + }; + + self.select_entry(index, cx); + } + fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context) { let Some(selected_entry_index) = self.selected_entry_idx else { return; @@ -1966,7 +2069,11 @@ impl Render for GitGraph { if let Some(repository) = self.get_selected_repository(cx) { repository.update(cx, |repository, cx| { // Start loading the graph data if we haven't started already - let (commits, is_loading) = repository.graph_data( + let GraphDataResponse { + commits, + is_loading, + error: _, + } = repository.graph_data( self.log_source.clone(), self.log_order, 0..usize::MAX, @@ -2145,16 +2252,17 @@ impl Render for GitGraph { }; div() - .size_full() - .bg(cx.theme().colors().editor_background) .key_context("GitGraph") .track_focus(&self.focus_handle) + .size_full() + .bg(cx.theme().colors().editor_background) .on_action(cx.listener(|this, _: &OpenCommitView, window, cx| { this.open_selected_commit_view(window, cx); })) .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::select_prev)) .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::confirm)) .child(content) .children(self.context_menu.as_ref().map(|(menu, position, _)| { deferred( @@ -2179,8 +2287,39 @@ impl Focusable for GitGraph { impl Item for GitGraph { type Event = ItemEvent; - fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { - "Git Graph".into() + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::GitGraph)) + } + + fn tab_tooltip_content(&self, cx: &App) -> Option { + let repo_name = self.get_selected_repository(cx).and_then(|repo| { + repo.read(cx) + .work_directory_abs_path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + }); + + Some(TabTooltipContent::Custom(Box::new(Tooltip::element({ + move |_, _| { + v_flex() + .child(Label::new("Git Graph")) + .when_some(repo_name.clone(), |this, name| { + this.child(Label::new(name).color(Color::Muted).size(LabelSize::Small)) + }) + .into_any_element() + } + })))) + } + + fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { + self.get_selected_repository(cx) + .and_then(|repo| { + repo.read(cx) + .work_directory_abs_path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + }) + .map_or_else(|| "Git Graph".into(), |name| SharedString::from(name)) } fn show_toolbar(&self) -> bool { @@ -2958,7 +3097,7 @@ mod tests { 0..usize::MAX, cx, ) - .0 + .commits .to_vec() }); @@ -3041,13 +3180,10 @@ mod tests { .any(|event| matches!(event, RepositoryEvent::MergeHeadsChanged)), "initial repository scan should emit MergeHeadsChanged" ); - - let graph_data_key = (crate::LogOrder::default(), crate::LogSource::default()); let commit_count_after = repository.read_with(cx, |repo, _| { - repo.initial_graph_data - .get(&graph_data_key) - .map(|(_, data)| data.len()) - .unwrap_or(0) + repo.get_graph_data(crate::LogSource::default(), crate::LogOrder::default()) + .map(|data| data.commit_data.len()) + .unwrap() }); assert_eq!( commits.len(), diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index f779570be471fd..28fac0f849a487 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -27,6 +27,7 @@ component.workspace = true db.workspace = true editor.workspace = true futures.workspace = true +feature_flags.workspace = true fuzzy.workspace = true git.workspace = true gpui.workspace = true diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index f5ed23a6a84e76..8f2a019fddf051 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -3,6 +3,7 @@ use buffer_diff::BufferDiff; use collections::HashMap; use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle}; use editor::{Addon, Editor, EditorEvent, ExcerptRange, MultiBuffer, multibuffer_context_lines}; +use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag}; use git::repository::{CommitDetails, CommitDiff, RepoPath, is_binary_content}; use git::status::{FileStatus, StatusCode, TrackedStatus}; use git::{ @@ -27,7 +28,7 @@ use std::{ sync::Arc, }; use theme::ActiveTheme; -use ui::{ButtonLike, DiffStat, Tooltip, prelude::*}; +use ui::{DiffStat, Divider, Tooltip, prelude::*}; use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff}; use workspace::item::TabTooltipContent; use workspace::{ @@ -450,6 +451,7 @@ impl CommitView { fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let commit = &self.commit; let author_name = commit.author_name.clone(); + let author_email = commit.author_email.clone(); let commit_sha = commit.sha.clone(); let commit_date = time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp) .unwrap_or_else(|_| time::OffsetDateTime::now_utc()); @@ -461,36 +463,6 @@ impl CommitView { time_format::TimestampFormat::MediumAbsolute, ); - let remote_info = self - .remote - .as_ref() - .filter(|_| self.stash.is_none()) - .map(|remote| { - let provider = remote.host.name(); - let parsed_remote = ParsedGitRemote { - owner: remote.owner.as_ref().into(), - repo: remote.repo.as_ref().into(), - }; - let params = BuildCommitPermalinkParams { sha: &commit.sha }; - let url = remote - .host - .build_commit_permalink(&parsed_remote, params) - .to_string(); - (provider, url) - }); - - let (additions, deletions) = self.calculate_changed_lines(cx); - - let commit_diff_stat = if additions > 0 || deletions > 0 { - Some(DiffStat::new( - "commit-diff-stat", - additions as usize, - deletions as usize, - )) - } else { - None - }; - let gutter_width = self.editor.update(cx, |editor, cx| { let snapshot = editor.snapshot(window, cx); let style = editor.style(cx); @@ -501,116 +473,75 @@ impl CommitView { .full_width() }); - let clipboard_has_link = cx + let clipboard_has_sha = cx .read_from_clipboard() .and_then(|entry| entry.text()) .map_or(false, |clipboard_text| { clipboard_text.trim() == commit_sha.as_ref() }); - let (copy_icon, copy_icon_color) = if clipboard_has_link { + let (copy_icon, copy_icon_color) = if clipboard_has_sha { (IconName::Check, Color::Success) } else { (IconName::Copy, Color::Muted) }; h_flex() + .py_2() + .pr_2p5() + .w_full() + .justify_between() .border_b_1() .border_color(cx.theme().colors().border_variant) - .w_full() - .child( - h_flex() - .w(gutter_width) - .justify_center() - .child(self.render_commit_avatar(&commit.sha, rems_from_px(48.), window, cx)), - ) .child( h_flex() - .py_4() - .pl_1() - .pr_4() - .w_full() - .items_start() - .justify_between() - .flex_wrap() + .child(h_flex().w(gutter_width).justify_center().child( + self.render_commit_avatar(&commit.sha, rems_from_px(40.), window, cx), + )) .child( - v_flex() - .child( - h_flex() - .gap_1() - .child(Label::new(author_name).color(Color::Default)) - .child({ - ButtonLike::new("sha") - .child( - h_flex() - .group("sha_btn") - .size_full() - .max_w_32() - .gap_0p5() - .child( - Label::new(commit_sha.clone()) - .color(Color::Muted) - .size(LabelSize::Small) - .truncate() - .buffer_font(cx), - ) - .child( - div().visible_on_hover("sha_btn").child( - Icon::new(copy_icon) - .color(copy_icon_color) - .size(IconSize::Small), - ), - ), - ) - .tooltip({ - let commit_sha = commit_sha.clone(); - move |_, cx| { - Tooltip::with_meta( - "Copy Commit SHA", - None, - commit_sha.clone(), - cx, - ) - } - }) - .on_click(move |_, _, cx| { - cx.stop_propagation(); - cx.write_to_clipboard(ClipboardItem::new_string( - commit_sha.to_string(), - )); - }) - }), - ) - .child( - h_flex() - .gap_1p5() - .child( - Label::new(date_string) - .color(Color::Muted) - .size(LabelSize::Small), - ) - .child( - Label::new("•") - .color(Color::Ignored) - .size(LabelSize::Small), - ) - .children(commit_diff_stat), - ), - ) - .children(remote_info.map(|(provider_name, url)| { - let icon = match provider_name.as_str() { - "GitHub" => IconName::Github, - _ => IconName::Link, - }; - - Button::new("view_on_provider", format!("View on {}", provider_name)) - .icon(icon) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .icon_position(IconPosition::Start) - .on_click(move |_, _, cx| cx.open_url(&url)) - })), + v_flex().child(Label::new(author_name)).child( + h_flex() + .gap_1p5() + .child( + Label::new(date_string) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child( + Label::new("•") + .size(LabelSize::Small) + .color(Color::Muted) + .alpha(0.5), + ) + .child( + Label::new(author_email) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ), + ), ) + .when(self.stash.is_none(), |this| { + this.child( + Button::new("sha", "Commit SHA") + .icon(copy_icon) + .icon_color(copy_icon_color) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .tooltip({ + let commit_sha = commit_sha.clone(); + move |_, cx| { + Tooltip::with_meta("Copy Commit SHA", None, commit_sha.clone(), cx) + } + }) + .on_click(move |_, _, cx| { + cx.stop_propagation(); + cx.write_to_clipboard(ClipboardItem::new_string( + commit_sha.to_string(), + )); + }), + ) + }) } fn apply_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) { @@ -898,7 +829,7 @@ impl Item for CommitView { type Event = EditorEvent; fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { - Some(Icon::new(IconName::GitBranch).color(Color::Muted)) + Some(Icon::new(IconName::GitCommit).color(Color::Muted)) } fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { @@ -1081,8 +1012,93 @@ impl CommitViewToolbar { impl EventEmitter for CommitViewToolbar {} impl Render for CommitViewToolbar { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div().hidden() + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(commit_view) = self.commit_view.as_ref().and_then(|w| w.upgrade()) else { + return div(); + }; + + let commit_view_ref = commit_view.read(cx); + let is_stash = commit_view_ref.stash.is_some(); + + let (additions, deletions) = commit_view_ref.calculate_changed_lines(cx); + + let commit_sha = commit_view_ref.commit.sha.clone(); + + let remote_info = commit_view_ref.remote.as_ref().map(|remote| { + let provider = remote.host.name(); + let parsed_remote = ParsedGitRemote { + owner: remote.owner.as_ref().into(), + repo: remote.repo.as_ref().into(), + }; + let params = BuildCommitPermalinkParams { sha: &commit_sha }; + let url = remote + .host + .build_commit_permalink(&parsed_remote, params) + .to_string(); + (provider, url) + }); + + let sha_for_graph = commit_sha.to_string(); + + h_flex() + .gap_1() + .when(additions > 0 || deletions > 0, |this| { + this.child( + h_flex() + .gap_2() + .child(DiffStat::new( + "toolbar-diff-stat", + additions as usize, + deletions as usize, + )) + .child(Divider::vertical()), + ) + }) + .child( + IconButton::new("buffer-search", IconName::MagnifyingGlass) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action( + "Buffer Search", + &zed_actions::buffer_search::Deploy::find(), + cx, + ) + }) + .on_click(|_, window, cx| { + window.dispatch_action( + Box::new(zed_actions::buffer_search::Deploy::find()), + cx, + ); + }), + ) + .when(!is_stash, |this| { + this.when(cx.has_flag::(), |this| { + this.child( + IconButton::new("show-in-git-graph", IconName::GitGraph) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Show in Git Graph")) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(crate::git_panel::OpenAtCommit { + sha: sha_for_graph.clone(), + }), + cx, + ); + }), + ) + }) + .children(remote_info.map(|(provider_name, url)| { + let icon = match provider_name.as_str() { + "GitHub" => IconName::Github, + _ => IconName::Link, + }; + + IconButton::new("view_on_provider", icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text(format!("View on {}", provider_name))) + .on_click(move |_, _, cx| cx.open_url(&url)) + })) + }) } } @@ -1093,12 +1109,11 @@ impl ToolbarItemView for CommitViewToolbar { _: &mut Window, cx: &mut Context, ) -> ToolbarItemLocation { - if let Some(entity) = active_pane_item.and_then(|i| i.act_as::(cx)) - && entity.read(cx).stash.is_some() - { + if let Some(entity) = active_pane_item.and_then(|i| i.act_as::(cx)) { self.commit_view = Some(entity.downgrade()); return ToolbarItemLocation::PrimaryRight; } + self.commit_view = None; ToolbarItemLocation::Hidden } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 830870db63a337..1c8c09d7fdeaa5 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -28,7 +28,7 @@ use git::repository::{ UpstreamTrackingStatus, get_git_committer, }; use git::stash::GitStash; -use git::status::StageStatus; +use git::status::{DiffStat, StageStatus}; use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus}; use git::{ ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll, @@ -41,7 +41,7 @@ use gpui::{ WeakEntity, actions, anchored, deferred, point, size, uniform_list, }; use itertools::Itertools; -use language::{Buffer, File}; +use language::{Buffer, BufferEvent, File}; use language_model::{ ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role, }; @@ -51,6 +51,7 @@ use notifications::status_toast::{StatusToast, ToastIcon}; use panel::{PanelHeader, panel_button, panel_filled_button, panel_icon_button}; use project::{ Fs, Project, ProjectPath, + buffer_store::BufferStoreEvent, git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op}, project_settings::{GitPathStyle, ProjectSettings}, }; @@ -123,6 +124,13 @@ actions!( ] ); +/// Opens the Git Graph Tab at a specific commit. +#[derive(Clone, PartialEq, serde::Deserialize, schemars::JsonSchema, gpui::Action)] +#[action(namespace = git_graph)] +pub struct OpenAtCommit { + pub sha: String, +} + fn prompt( msg: &str, detail: Option<&str>, @@ -644,6 +652,8 @@ pub struct GitPanel { local_committer_task: Option>, bulk_staging: Option, stash_entries: GitStash, + diff_stats: HashMap, + diff_stats_task: Task<()>, _settings_subscription: Subscription, } @@ -704,9 +714,11 @@ impl GitPanel { let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view; + let mut was_diff_stats = GitPanelSettings::get_global(cx).diff_stats; cx.observe_global_in::(window, move |this, window, cx| { let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path; let tree_view = GitPanelSettings::get_global(cx).tree_view; + let diff_stats = GitPanelSettings::get_global(cx).diff_stats; if tree_view != was_tree_view { this.view_mode = GitPanelViewMode::from_settings(cx); } @@ -714,8 +726,18 @@ impl GitPanel { this.bulk_staging.take(); this.update_visible_entries(window, cx); } + if diff_stats != was_diff_stats { + if diff_stats { + this.fetch_diff_stats(cx); + } else { + this.diff_stats.clear(); + this.diff_stats_task = Task::ready(()); + cx.notify(); + } + } was_sort_by_path = sort_by_path; was_tree_view = tree_view; + was_diff_stats = diff_stats; }) .detach(); @@ -770,6 +792,33 @@ impl GitPanel { ) .detach(); + let buffer_store = project.read(cx).buffer_store().clone(); + + for buffer in project.read(cx).opened_buffers(cx) { + cx.subscribe(&buffer, |this, _buffer, event, cx| { + if matches!(event, BufferEvent::Saved) { + if GitPanelSettings::get_global(cx).diff_stats { + this.fetch_diff_stats(cx); + } + } + }) + .detach(); + } + + cx.subscribe(&buffer_store, |_this, _store, event, cx| { + if let BufferStoreEvent::BufferAdded(buffer) = event { + cx.subscribe(buffer, |this, _buffer, event, cx| { + if matches!(event, BufferEvent::Saved) { + if GitPanelSettings::get_global(cx).diff_stats { + this.fetch_diff_stats(cx); + } + } + }) + .detach(); + } + }) + .detach(); + let mut this = Self { active_repository, commit_editor, @@ -810,6 +859,8 @@ impl GitPanel { entry_count: 0, bulk_staging: None, stash_entries: Default::default(), + diff_stats: HashMap::default(), + diff_stats_task: Task::ready(()), _settings_subscription, }; @@ -3171,18 +3222,16 @@ impl GitPanel { window: &mut Window, cx: &mut Context, ) -> AskPassDelegate { - let this = cx.weak_entity(); + let workspace = self.workspace.clone(); let operation = operation.into(); let window = window.window_handle(); AskPassDelegate::new(&mut cx.to_async(), move |prompt, tx, cx| { window .update(cx, |_, window, cx| { - this.update(cx, |this, cx| { - this.workspace.update(cx, |workspace, cx| { - workspace.toggle_modal(window, cx, |window, cx| { - AskPassModal::new(operation.clone(), prompt.into(), tx, window, cx) - }); - }) + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + AskPassModal::new(operation.clone(), prompt.into(), tx, window, cx) + }); }) }) .ok(); @@ -3257,10 +3306,8 @@ impl GitPanel { let mut new_co_authors = Vec::new(); let project = self.project.read(cx); - let Some(room) = self - .workspace - .upgrade() - .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned()) + let Some(room) = + call::ActiveCall::try_global(cx).and_then(|call| call.read(cx).room().cloned()) else { return Vec::default(); }; @@ -3696,9 +3743,60 @@ impl GitPanel { editor.set_placeholder_text(&placeholder_text, window, cx) }); + if GitPanelSettings::get_global(cx).diff_stats { + self.fetch_diff_stats(cx); + } + cx.notify(); } + fn fetch_diff_stats(&mut self, cx: &mut Context) { + let Some(repo) = self.active_repository.clone() else { + self.diff_stats.clear(); + return; + }; + + let unstaged_rx = repo.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToWorktree, cx)); + let staged_rx = repo.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToIndex, cx)); + + self.diff_stats_task = cx.spawn(async move |this, cx| { + let (unstaged_result, staged_result) = + futures::future::join(unstaged_rx, staged_rx).await; + + let mut combined = match unstaged_result { + Ok(Ok(stats)) => stats, + Ok(Err(err)) => { + log::warn!("Failed to fetch unstaged diff stats: {err:?}"); + HashMap::default() + } + Err(_) => HashMap::default(), + }; + + let staged = match staged_result { + Ok(Ok(stats)) => Some(stats), + Ok(Err(err)) => { + log::warn!("Failed to fetch staged diff stats: {err:?}"); + None + } + Err(_) => None, + }; + + if let Some(staged) = staged { + for (path, stat) in staged { + let entry = combined.entry(path).or_default(); + entry.added += stat.added; + entry.deleted += stat.deleted; + } + } + + this.update(cx, |this, cx| { + this.diff_stats = combined; + cx.notify(); + }) + .ok(); + }); + } + fn header_state(&self, header_type: Section) -> ToggleState { let (staged_count, count) = match header_type { Section::New => (self.new_staged_count, self.new_count), @@ -5110,6 +5208,8 @@ impl GitPanel { } }); + let id_for_diff_stat = id.clone(); + h_flex() .id(id) .h(self.list_item_height()) @@ -5126,6 +5226,19 @@ impl GitPanel { .hover(|s| s.bg(hover_bg)) .active(|s| s.bg(active_bg)) .child(name_row) + .when(GitPanelSettings::get_global(cx).diff_stats, |el| { + el.when_some( + self.diff_stats.get(&entry.repo_path).copied(), + move |this, stat| { + let id = format!("diff-stat-{}", id_for_diff_stat); + this.child(ui::DiffStat::new( + id, + stat.added as usize, + stat.deleted as usize, + )) + }, + ) + }) .child( div() .id(checkbox_wrapper_id) @@ -5520,10 +5633,9 @@ impl Render for GitPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let project = self.project.read(cx); let has_entries = !self.entries.is_empty(); - let room = self - .workspace - .upgrade() - .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned()); + let room = self.workspace.upgrade().and_then(|_workspace| { + call::ActiveCall::try_global(cx).and_then(|call| call.read(cx).room().cloned()) + }); let has_write_access = self.has_write_access(cx); diff --git a/crates/git_ui/src/git_panel_settings.rs b/crates/git_ui/src/git_panel_settings.rs index 6b5334e55544b4..2a7480de355a61 100644 --- a/crates/git_ui/src/git_panel_settings.rs +++ b/crates/git_ui/src/git_panel_settings.rs @@ -25,6 +25,7 @@ pub struct GitPanelSettings { pub sort_by_path: bool, pub collapse_untracked_diff: bool, pub tree_view: bool, + pub diff_stats: bool, } impl ScrollbarVisibility for GitPanelSettings { @@ -58,6 +59,7 @@ impl Settings for GitPanelSettings { sort_by_path: git_panel.sort_by_path.unwrap(), collapse_untracked_diff: git_panel.collapse_untracked_diff.unwrap(), tree_view: git_panel.tree_view.unwrap(), + diff_stats: git_panel.diff_stats.unwrap(), } } } diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index cf241004338cda..f62b08e4c0d99d 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -97,7 +97,6 @@ impl ProjectDiff { pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { workspace.register_action(Self::deploy); workspace.register_action(Self::deploy_branch_diff); - workspace.register_action(Self::deploy_review_diff); workspace.register_action(|workspace, _: &Add, window, cx| { Self::deploy(workspace, &Diff, window, cx); }); @@ -148,25 +147,13 @@ impl ProjectDiff { .detach_and_notify_err(workspace_weak, window, cx); } - fn deploy_review_diff( - workspace: &mut Workspace, - _: &ReviewDiff, - window: &mut Window, - cx: &mut Context, - ) { - let Some(project_diff) = workspace - .items_of_type::(cx) - .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Merge { .. })) - else { - return; - }; - - let diff_base = project_diff.read(cx).diff_base(cx).clone(); + fn review_diff(&mut self, _: &ReviewDiff, window: &mut Window, cx: &mut Context) { + let diff_base = self.diff_base(cx).clone(); let DiffBase::Merge { base_ref } = diff_base else { return; }; - let Some(repo) = project_diff.read(cx).branch_diff.read(cx).repo().cloned() else { + let Some(repo) = self.branch_diff.read(cx).repo().cloned() else { return; }; @@ -179,26 +166,31 @@ impl ProjectDiff { ) }); - let workspace_handle = cx.entity(); - let workspace_weak = workspace_handle.downgrade(); - window - .spawn(cx, async move |cx| { - let diff_text = diff_receiver.await??; + let workspace = self.workspace.clone(); - workspace_handle.update_in(cx, |_workspace, window, cx| { - window.dispatch_action( - ReviewBranchDiff { - diff_text: diff_text.into(), - base_ref: base_ref.to_string().into(), - } - .boxed_clone(), - cx, - ); - })?; + window + .spawn(cx, { + let workspace = workspace.clone(); + async move |cx| { + let diff_text = diff_receiver.await??; + + if let Some(workspace) = workspace.upgrade() { + workspace.update_in(cx, |_workspace, window, cx| { + window.dispatch_action( + ReviewBranchDiff { + diff_text: diff_text.into(), + base_ref: base_ref.to_string().into(), + } + .boxed_clone(), + cx, + ); + })?; + } - anyhow::Ok(()) + anyhow::Ok(()) + } }) - .detach_and_notify_err(workspace_weak, window, cx); + .detach_and_notify_err(workspace, window, cx); } pub fn deploy_at( @@ -1139,10 +1131,14 @@ impl Item for ProjectDiff { impl Render for ProjectDiff { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_empty = self.multibuffer.read(cx).is_empty(); + let is_branch_diff_view = matches!(self.diff_base(cx), DiffBase::Merge { .. }); div() .track_focus(&self.focus_handle) .key_context(if is_empty { "EmptyPane" } else { "GitDiff" }) + .when(is_branch_diff_view, |this| { + this.on_action(cx.listener(Self::review_diff)) + }) .bg(cx.theme().colors().editor_background) .flex() .items_center() diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index e650765c912bd1..d1a3ef0bd6954e 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -69,11 +69,12 @@ profiling.workspace = true smallvec.workspace = true smol.workspace = true strum.workspace = true +url.workspace = true util.workspace = true uuid.workspace = true # Always used -oo7 = { version = "0.5.0", default-features = false, features = [ +oo7 = { version = "0.6", default-features = false, features = [ "async-std", "native_crypto", ] } diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index 5929533951738a..dfb37afda255a7 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -364,7 +364,8 @@ impl Platform for LinuxPlatform

{ response .uris() .iter() - .filter_map(|uri| uri.to_file_path().ok()) + .filter_map(|uri: &ashpd::Uri| url::Url::parse(uri.as_str()).ok()) + .filter_map(|uri: url::Url| uri.to_file_path().ok()) .collect::>(), )), Err(ashpd::Error::Response(_)) => Ok(None), @@ -426,7 +427,8 @@ impl Platform for LinuxPlatform

{ Ok(response) => Ok(response .uris() .first() - .and_then(|uri| uri.to_file_path().ok())), + .and_then(|uri: &ashpd::Uri| url::Url::parse(uri.as_str()).ok()) + .and_then(|uri: url::Url| uri.to_file_path().ok())), Err(ashpd::Error::Response(_)) => Ok(None), Err(e) => Err(e.into()), }; @@ -627,7 +629,7 @@ pub(super) fn open_uri_internal( uri: &str, activation_token: Option, ) { - if let Some(uri) = ashpd::url::Url::parse(uri).log_err() { + if let Some(uri) = ashpd::Uri::parse(uri).log_err() { executor .spawn(async move { match ashpd::desktop::open_uri::OpenFileRequest::default() diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index c1006a816a3844..4a4c4060bdc31b 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -640,19 +640,19 @@ impl WaylandWindowStatePtr { match mode { WEnum::Value(zxdg_toplevel_decoration_v1::Mode::ServerSide) => { self.state.borrow_mut().decorations = WindowDecorations::Server; - if let Some(appearance_changed) = - self.callbacks.borrow_mut().appearance_changed.as_mut() - { - appearance_changed(); + let callback = self.callbacks.borrow_mut().appearance_changed.take(); + if let Some(mut fun) = callback { + fun(); + self.callbacks.borrow_mut().appearance_changed = Some(fun); } } WEnum::Value(zxdg_toplevel_decoration_v1::Mode::ClientSide) => { self.state.borrow_mut().decorations = WindowDecorations::Client; // Update background to be transparent - if let Some(appearance_changed) = - self.callbacks.borrow_mut().appearance_changed.as_mut() - { - appearance_changed(); + let callback = self.callbacks.borrow_mut().appearance_changed.take(); + if let Some(mut fun) = callback { + fun(); + self.callbacks.borrow_mut().appearance_changed = Some(fun); } } WEnum::Value(_) => { @@ -924,8 +924,10 @@ impl WaylandWindowStatePtr { (state.bounds.size, state.scale) }; - if let Some(ref mut fun) = self.callbacks.borrow_mut().resize { + let callback = self.callbacks.borrow_mut().resize.take(); + if let Some(mut fun) = callback { fun(size, scale); + self.callbacks.borrow_mut().resize = Some(fun); } { @@ -971,10 +973,13 @@ impl WaylandWindowStatePtr { if self.is_blocked() { return; } - if let Some(ref mut fun) = self.callbacks.borrow_mut().input - && !fun(input.clone()).propagate - { - return; + let callback = self.callbacks.borrow_mut().input.take(); + if let Some(mut fun) = callback { + let result = fun(input.clone()); + self.callbacks.borrow_mut().input = Some(fun); + if !result.propagate { + return; + } } if let PlatformInput::KeyDown(event) = input && event.keystroke.modifiers.is_subset_of(&Modifiers::shift()) @@ -991,23 +996,28 @@ impl WaylandWindowStatePtr { pub fn set_focused(&self, focus: bool) { self.state.borrow_mut().active = focus; - if let Some(ref mut fun) = self.callbacks.borrow_mut().active_status_change { + let callback = self.callbacks.borrow_mut().active_status_change.take(); + if let Some(mut fun) = callback { fun(focus); + self.callbacks.borrow_mut().active_status_change = Some(fun); } } pub fn set_hovered(&self, focus: bool) { - if let Some(ref mut fun) = self.callbacks.borrow_mut().hover_status_change { + let callback = self.callbacks.borrow_mut().hover_status_change.take(); + if let Some(mut fun) = callback { fun(focus); + self.callbacks.borrow_mut().hover_status_change = Some(fun); } } pub fn set_appearance(&mut self, appearance: WindowAppearance) { self.state.borrow_mut().appearance = appearance; - let mut callbacks = self.callbacks.borrow_mut(); - if let Some(ref mut fun) = callbacks.appearance_changed { - (fun)() + let callback = self.callbacks.borrow_mut().appearance_changed.take(); + if let Some(mut fun) = callback { + fun(); + self.callbacks.borrow_mut().appearance_changed = Some(fun); } } diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 8060e4c4457c6e..0ddd6e7adff849 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -1045,9 +1045,10 @@ impl X11WindowStatePtr { } pub fn refresh(&self, request_frame_options: RequestFrameOptions) { - let mut cb = self.callbacks.borrow_mut(); - if let Some(ref mut fun) = cb.request_frame { + let callback = self.callbacks.borrow_mut().request_frame.take(); + if let Some(mut fun) = callback { fun(request_frame_options); + self.callbacks.borrow_mut().request_frame = Some(fun); } } @@ -1055,10 +1056,13 @@ impl X11WindowStatePtr { if self.is_blocked() { return; } - if let Some(ref mut fun) = self.callbacks.borrow_mut().input - && !fun(input.clone()).propagate - { - return; + let callback = self.callbacks.borrow_mut().input.take(); + if let Some(mut fun) = callback { + let result = fun(input.clone()); + self.callbacks.borrow_mut().input = Some(fun); + if !result.propagate { + return; + } } if let PlatformInput::KeyDown(event) = input { // only allow shift modifier when inserting text @@ -1191,14 +1195,18 @@ impl X11WindowStatePtr { } pub fn set_active(&self, focus: bool) { - if let Some(ref mut fun) = self.callbacks.borrow_mut().active_status_change { + let callback = self.callbacks.borrow_mut().active_status_change.take(); + if let Some(mut fun) = callback { fun(focus); + self.callbacks.borrow_mut().active_status_change = Some(fun); } } pub fn set_hovered(&self, focus: bool) { - if let Some(ref mut fun) = self.callbacks.borrow_mut().hovered_status_change { + let callback = self.callbacks.borrow_mut().hovered_status_change.take(); + if let Some(mut fun) = callback { fun(focus); + self.callbacks.borrow_mut().hovered_status_change = Some(fun); } } @@ -1209,9 +1217,10 @@ impl X11WindowStatePtr { state.renderer.update_transparency(is_transparent); state.appearance = appearance; drop(state); - let mut callbacks = self.callbacks.borrow_mut(); - if let Some(ref mut fun) = callbacks.appearance_changed { - (fun)() + let callback = self.callbacks.borrow_mut().appearance_changed.take(); + if let Some(mut fun) = callback { + fun(); + self.callbacks.borrow_mut().appearance_changed = Some(fun); } } } diff --git a/crates/gpui_wgpu/src/wgpu_atlas.rs b/crates/gpui_wgpu/src/wgpu_atlas.rs index d3614ea126e3d3..ffef3a65398c3f 100644 --- a/crates/gpui_wgpu/src/wgpu_atlas.rs +++ b/crates/gpui_wgpu/src/wgpu_atlas.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context as _, Result}; use collections::FxHashMap; use etagere::{BucketedAtlasAllocator, size2}; use gpui::{ @@ -30,6 +30,7 @@ struct PendingUpload { struct WgpuAtlasState { device: Arc, queue: Arc, + max_texture_size: u32, storage: WgpuAtlasStorage, tiles_by_key: FxHashMap, pending_uploads: Vec, @@ -41,9 +42,11 @@ pub struct WgpuTextureInfo { impl WgpuAtlas { pub fn new(device: Arc, queue: Arc) -> Self { + let max_texture_size = device.limits().max_texture_dimension_2d; WgpuAtlas(Mutex::new(WgpuAtlasState { device, queue, + max_texture_size, storage: WgpuAtlasStorage::default(), tiles_by_key: Default::default(), pending_uploads: Vec::new(), @@ -78,7 +81,9 @@ impl PlatformAtlas for WgpuAtlas { let Some((size, bytes)) = build()? else { return Ok(None); }; - let tile = lock.allocate(size, key.texture_kind()); + let tile = lock + .allocate(size, key.texture_kind()) + .context("failed to allocate")?; lock.upload_texture(tile.texture_id, tile.bounds, &bytes); lock.tiles_by_key.insert(key.clone(), tile.clone()); Ok(Some(tile)) @@ -110,7 +115,11 @@ impl PlatformAtlas for WgpuAtlas { } impl WgpuAtlasState { - fn allocate(&mut self, size: Size, texture_kind: AtlasTextureKind) -> AtlasTile { + fn allocate( + &mut self, + size: Size, + texture_kind: AtlasTextureKind, + ) -> Option { { let textures = &mut self.storage[texture_kind]; @@ -119,14 +128,12 @@ impl WgpuAtlasState { .rev() .find_map(|texture| texture.allocate(size)) { - return tile; + return Some(tile); } } let texture = self.push_texture(size, texture_kind); - texture - .allocate(size) - .expect("Failed to allocate from newly created texture") + texture.allocate(size) } fn push_texture( @@ -138,8 +145,13 @@ impl WgpuAtlasState { width: DevicePixels(1024), height: DevicePixels(1024), }; + let max_texture_size = self.max_texture_size as i32; + let max_atlas_size = Size { + width: DevicePixels(max_texture_size), + height: DevicePixels(max_texture_size), + }; - let size = min_size.max(&DEFAULT_ATLAS_SIZE); + let size = min_size.min(&max_atlas_size).max(&DEFAULT_ATLAS_SIZE); let format = match kind { AtlasTextureKind::Monochrome => wgpu::TextureFormat::R8Unorm, AtlasTextureKind::Subpixel => wgpu::TextureFormat::Bgra8Unorm, diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 9ed9a8b658cc8b..d6356f831ea9bb 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -142,6 +142,7 @@ pub enum IconName { GitBranch, GitBranchAlt, GitBranchPlus, + GitCommit, GitGraph, Github, Hash, diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 37e818904bc952..c223494bd70921 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -11,7 +11,7 @@ use gpui::{ FocusHandle, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task, WeakEntity, Window, actions, - canvas, div, img, opaque_grey, point, px, size, + checkerboard, div, img, point, px, size, }; use language::File as _; use persistence::IMAGE_VIEWER; @@ -50,7 +50,7 @@ const MIN_ZOOM: f32 = 0.1; const MAX_ZOOM: f32 = 20.0; const ZOOM_STEP: f32 = 1.1; const SCROLL_LINE_MULTIPLIER: f32 = 20.0; -const BASE_SQUARE_SIZE: f32 = 48.0; +const BASE_SQUARE_SIZE: f32 = 32.0; pub struct ImageView { image_item: Entity, @@ -378,53 +378,17 @@ impl Element for ImageContentElement { .w(scaled_width) .h(scaled_height) .child( - canvas( - |_, _, _| {}, - move |bounds, _, window, _cx| { - let bounds_x: f32 = bounds.origin.x.into(); - let bounds_y: f32 = bounds.origin.y.into(); - let bounds_width: f32 = bounds.size.width.into(); - let bounds_height: f32 = bounds.size.height.into(); - let square_size = BASE_SQUARE_SIZE * zoom_level; - let cols = (bounds_width / square_size).ceil() as i32 + 1; - let rows = (bounds_height / square_size).ceil() as i32 + 1; - for row in 0..rows { - for col in 0..cols { - if (row + col) % 2 == 0 { - continue; - } - let x = bounds_x + col as f32 * square_size; - let y = bounds_y + row as f32 * square_size; - let w = square_size.min(bounds_x + bounds_width - x); - let h = square_size.min(bounds_y + bounds_height - y); - if w > 0.0 && h > 0.0 { - let rect = Bounds::new( - point(px(x), px(y)), - size(px(w), px(h)), - ); - window.paint_quad(gpui::fill( - rect, - opaque_grey(0.6, 1.0), - )); - } - } - } - let border_rect = Bounds::new( - point(px(bounds_x), px(bounds_y)), - size(px(bounds_width), px(bounds_height)), - ); - window.paint_quad(gpui::outline( - border_rect, - border_color, - gpui::BorderStyle::default(), - )); - }, - ) - .size_full() - .absolute() - .top_0() - .left_0() - .bg(gpui::rgb(0xCCCCCD)), + div() + .size_full() + .absolute() + .top_0() + .left_0() + .child(div().size_full().bg(checkerboard( + cx.theme().colors().panel_background, + BASE_SQUARE_SIZE * zoom_level, + ))) + .border_1() + .border_color(border_color), ) .child({ img(image) diff --git a/crates/language/src/proto.rs b/crates/language/src/proto.rs index 132f971675ede1..89c44513067f6d 100644 --- a/crates/language/src/proto.rs +++ b/crates/language/src/proto.rs @@ -496,7 +496,7 @@ pub fn deserialize_anchor(anchor: proto::Anchor) -> Option { }; Some(Anchor::new( timestamp, - anchor.offset as usize, + anchor.offset as u32, bias, buffer_id, )) diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index 04a61ae79474ea..cb2f6a27de6573 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -431,6 +431,7 @@ pub struct LanguageModelRequestTool { pub name: String, pub description: String, pub input_schema: serde_json::Value, + pub use_input_streaming: bool, } #[derive(Debug, PartialEq, Hash, Clone, Serialize, Deserialize)] diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index c1de89e4f85054..5b7ad62e0e6697 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -370,6 +370,7 @@ pub fn into_anthropic_count_tokens_request( name: tool.name, description: tool.description, input_schema: tool.input_schema, + eager_input_streaming: tool.use_input_streaming, }) .collect(), tool_choice: request.tool_choice.map(|choice| match choice { @@ -713,6 +714,7 @@ pub fn into_anthropic( name: tool.name, description: tool.description, input_schema: tool.input_schema, + eager_input_streaming: tool.use_input_streaming, }) .collect(), tool_choice: request.tool_choice.map(|choice| match choice { diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 1de907004b8973..40cc67098a76d0 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -309,6 +309,7 @@ impl LanguageModel for OpenAiLanguageModel { | Model::FivePointOne | Model::FivePointTwo | Model::FivePointTwoCodex + | Model::FivePointThreeCodex | Model::O1 | Model::O3 => true, Model::ThreePointFiveTurbo @@ -1180,8 +1181,11 @@ pub fn count_open_ai_tokens( | Model::FiveCodex | Model::FiveMini | Model::FiveNano => tiktoken_rs::num_tokens_from_messages(model.id(), &messages), - // GPT-5.1, 5.2, and 5.2-codex don't have dedicated tiktoken support; use gpt-5 tokenizer - Model::FivePointOne | Model::FivePointTwo | Model::FivePointTwoCodex => { + // GPT-5.1, 5.2, 5.2-codex, and 5.3-codex don't have dedicated tiktoken support; use gpt-5 tokenizer + Model::FivePointOne + | Model::FivePointTwo + | Model::FivePointTwoCodex + | Model::FivePointThreeCodex => { tiktoken_rs::num_tokens_from_messages("gpt-5", &messages) } } @@ -1562,6 +1566,7 @@ mod tests { name: "get_weather".into(), description: "Fetches the weather".into(), input_schema: json!({ "type": "object" }), + use_input_streaming: false, }], tool_choice: Some(LanguageModelToolChoice::Any), stop: vec!["".into()], diff --git a/crates/language_tools/src/lsp_button.rs b/crates/language_tools/src/lsp_button.rs index 54aae61a696672..781c18eb84cbc9 100644 --- a/crates/language_tools/src/lsp_button.rs +++ b/crates/language_tools/src/lsp_button.rs @@ -333,13 +333,7 @@ impl LanguageServerState { }) .unwrap_or((None, None, None)); - let truncated_message = message.as_ref().and_then(|message| { - message - .lines() - .filter(|line| !line.trim().is_empty()) - .map(SharedString::new) - .next() - }); + let server_message = message.clone(); let submenu_server_name = server_info.name.clone(); let submenu_server_info = server_info.clone(); @@ -549,9 +543,9 @@ impl LanguageServerState { submenu = submenu.separator().custom_row({ let binary_path = binary_path.clone(); let server_version = server_version.clone(); - let truncated_message = truncated_message.clone(); + let server_message = server_message.clone(); let process_memory_cache = process_memory_cache.clone(); - move |_, _| { + move |_, cx| { let memory_usage = process_id.map(|pid| { process_memory_cache.borrow_mut().get_memory_usage(pid) }); @@ -567,63 +561,63 @@ impl LanguageServerState { } }); - let metadata_label = - match (&server_version, &memory_label, &truncated_message) { - (None, None, None) => None, - (Some(version), None, None) => { - Some(format!("v{}", version.as_ref())) - } - (None, Some(memory), None) => Some(memory.clone()), - (Some(version), Some(memory), None) => { - Some(format!("v{} • {}", version.as_ref(), memory)) - } - (None, None, Some(message)) => Some(message.to_string()), - (Some(version), None, Some(message)) => Some(format!( - "v{}\n\n{}", - version.as_ref(), - message.as_ref() - )), - (None, Some(memory), Some(message)) => { - Some(format!("{}\n\n{}", memory, message.as_ref())) - } - (Some(version), Some(memory), Some(message)) => { - Some(format!( - "v{} • {}\n\n{}", - version.as_ref(), - memory, - message.as_ref() - )) - } - }; + let version_label = + server_version.as_ref().map(|v| format!("v{}", v.as_ref())); + + let separator_color = + cx.theme().colors().icon_disabled.opacity(0.8); - h_flex() + v_flex() .id("metadata-container") - .ml_neg_1() .gap_1() - .max_w(rems(164.)) - .child( - Icon::new(IconName::Circle) - .color(status_color) - .size(IconSize::Small), - ) + .when_some(server_message.as_ref(), |this, _| { + this.w(rems_from_px(240.)) + }) .child( - Label::new(status_label) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .when_some(metadata_label.as_ref(), |submenu, metadata| { - submenu + h_flex() + .ml_neg_1() + .gap_1() .child( - Icon::new(IconName::Dash) - .color(Color::Disabled) - .size(IconSize::XSmall), + Icon::new(IconName::Circle) + .color(status_color) + .size(IconSize::Small), ) .child( - Label::new(metadata) + Label::new(status_label) .size(LabelSize::Small) - .color(Color::Muted) - .truncate(), + .color(Color::Muted), ) + .when_some(version_label.as_ref(), |row, version| { + row.child( + Icon::new(IconName::Dash) + .color(Color::Custom(separator_color)) + .size(IconSize::XSmall), + ) + .child( + Label::new(version) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + .when_some(memory_label.as_ref(), |row, memory| { + row.child( + Icon::new(IconName::Dash) + .color(Color::Custom(separator_color)) + .size(IconSize::XSmall), + ) + .child( + Label::new(memory) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ) + .when_some(server_message.clone(), |container, message| { + container.child( + Label::new(message) + .color(Color::Muted) + .size(LabelSize::Small), + ) }) .when_some(binary_path.clone(), |el, path| { el.tooltip(Tooltip::text(path)) diff --git a/crates/languages/src/markdown/config.toml b/crates/languages/src/markdown/config.toml index 10b1e49757edc1..5e7acd230b6f19 100644 --- a/crates/languages/src/markdown/config.toml +++ b/crates/languages/src/markdown/config.toml @@ -1,6 +1,6 @@ name = "Markdown" grammar = "markdown" -path_suffixes = ["md", "mdx", "mdwn", "markdown", "MD"] +path_suffixes = ["md", "mdx", "mdwn", "mdc", "markdown", "MD"] completion_query_characters = ["-"] block_comment = { start = "", tab_size = 0 } autoclose_before = ";:.,=}])>" diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index b1192464e96011..9eaf9764f10042 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -112,6 +112,8 @@ impl FromStr for TestRunner { /// Decided to ignore Pyright's sortText() completely and to manually sort all entries fn process_pyright_completions(items: &mut [lsp::CompletionItem]) { for item in items { + let is_named_argument = item.label.ends_with('='); + let is_dunder = item.label.starts_with("__") && item.label.ends_with("__"); let visibility_priority = if is_dunder { @@ -124,23 +126,35 @@ fn process_pyright_completions(items: &mut [lsp::CompletionItem]) { '0' // public }; + let is_external = item + .detail + .as_ref() + .is_some_and(|detail| detail == "Auto-import"); + + let source_priority = if is_external { '1' } else { '0' }; + // Kind priority within same visibility level let kind_priority = match item.kind { - Some(lsp::CompletionItemKind::ENUM_MEMBER) => '0', - Some(lsp::CompletionItemKind::FIELD) => '1', - Some(lsp::CompletionItemKind::PROPERTY) => '2', - Some(lsp::CompletionItemKind::VARIABLE) => '3', - Some(lsp::CompletionItemKind::CONSTANT) => '4', - Some(lsp::CompletionItemKind::METHOD) => '5', - Some(lsp::CompletionItemKind::FUNCTION) => '5', - Some(lsp::CompletionItemKind::CLASS) => '6', - Some(lsp::CompletionItemKind::MODULE) => '7', - _ => '8', + Some(lsp::CompletionItemKind::KEYWORD) => '0', + Some(lsp::CompletionItemKind::ENUM_MEMBER) => '1', + Some(lsp::CompletionItemKind::FIELD) => '2', + Some(lsp::CompletionItemKind::PROPERTY) => '3', + Some(lsp::CompletionItemKind::VARIABLE) => '4', + Some(lsp::CompletionItemKind::CONSTANT) => '5', + Some(lsp::CompletionItemKind::METHOD) => '6', + Some(lsp::CompletionItemKind::FUNCTION) => '6', + Some(lsp::CompletionItemKind::CLASS) => '7', + Some(lsp::CompletionItemKind::MODULE) => '8', + + _ => 'z', }; + // Named arguments get higher priority + let argument_priority = if is_named_argument { '0' } else { '1' }; + item.sort_text = Some(format!( - "{}{}{}", - visibility_priority, kind_priority, item.label + "{}{}{}{}{}", + argument_priority, source_priority, visibility_priority, kind_priority, item.label )); } } @@ -1689,7 +1703,14 @@ impl LspAdapter for PyLspAdapter { Self::SERVER_NAME } - async fn process_completions(&self, _items: &mut [lsp::CompletionItem]) {} + async fn process_completions(&self, items: &mut [lsp::CompletionItem]) { + for item in items { + let is_named_argument = item.label.ends_with('='); + let priority = if is_named_argument { '0' } else { '1' }; + let sort_text = item.sort_text.take().unwrap_or_else(|| item.label.clone()); + item.sort_text = Some(format!("{}{}", priority, sort_text)); + } + } async fn label_for_completion( &self, diff --git a/crates/languages/src/python/injections.scm b/crates/languages/src/python/injections.scm index d8470140e999f3..bc47469dc870c4 100644 --- a/crates/languages/src/python/injections.scm +++ b/crates/languages/src/python/injections.scm @@ -1,34 +1,26 @@ ((comment) @injection.content - (#set! injection.language "comment") -) + (#set! injection.language "comment")) ; SQL ----------------------------------------------------------------------------- -( +([ + ; function calls + (call [ - ; function calls - (call - [ - (attribute attribute: (identifier) @function_name) - (identifier) @function_name - ] - arguments: (argument_list - (comment) @comment - (string - (string_content) @injection.content - ) - )) - - ; string variables - ((comment) @comment - . - (expression_statement - (assignment - right: (string - (string_content) @injection.content - ) - ) - )) + (attribute + attribute: (identifier)) + (identifier) ] - (#match? @comment "^(#|#\\s+)(?i:sql)\\s*$") - (#set! injection.language "sql") -) + arguments: (argument_list + (comment) @_comment + (string + (string_content) @injection.content))) + ; string variables + ((comment) @_comment + . + (expression_statement + (assignment + right: (string + (string_content) @injection.content)))) +] + (#match? @_comment "^(#|#\\s+)(?i:sql)\\s*$") + (#set! injection.language "sql")) diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 22142c2e28bfcb..03615ffcabb2a5 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -624,7 +624,7 @@ pub struct MultiBufferSnapshot { diffs: TreeMap, diff_transforms: SumTree, excerpt_ids: SumTree, - replaced_excerpts: TreeMap, + replaced_excerpts: Arc>, non_text_state_update_count: usize, edit_count: usize, is_dirty: bool, @@ -1967,7 +1967,10 @@ impl MultiBuffer { *has_deleted_file = false; *has_conflict = false; *has_inverted_diff = false; - replaced_excerpts.clear(); + match Arc::get_mut(replaced_excerpts) { + Some(replaced_excerpts) => replaced_excerpts.clear(), + None => *replaced_excerpts = Default::default(), + } let edits = Self::sync_diff_transforms( self.snapshot.get_mut(), @@ -6938,18 +6941,23 @@ impl MultiBufferSnapshot { } fn excerpt_locator_for_id(&self, id: ExcerptId) -> &Locator { + self.try_excerpt_locator_for_id(id) + .unwrap_or_else(|| panic!("invalid excerpt id {id:?}")) + } + + fn try_excerpt_locator_for_id(&self, id: ExcerptId) -> Option<&Locator> { if id == ExcerptId::min() { - Locator::min_ref() + Some(Locator::min_ref()) } else if id == ExcerptId::max() { - Locator::max_ref() + Some(Locator::max_ref()) } else { let (_, _, item) = self.excerpt_ids.find::((), &id, Bias::Left); if let Some(entry) = item && entry.id == id { - return &entry.locator; + return Some(&entry.locator); } - panic!("invalid excerpt id {id:?}") + None } } @@ -7034,16 +7042,16 @@ impl MultiBufferSnapshot { /// afterwards. fn excerpt(&self, excerpt_id: ExcerptId) -> Option<&Excerpt> { let excerpt_id = self.latest_excerpt_id(excerpt_id); - let mut cursor = self.excerpts.cursor::>(()); - let locator = self.excerpt_locator_for_id(excerpt_id); - cursor.seek(&Some(locator), Bias::Left); - if let Some(excerpt) = cursor.item() + let locator = self.try_excerpt_locator_for_id(excerpt_id)?; + let (_, _, item) = + self.excerpts + .find::, _>((), &Some(locator), Bias::Left); + if let Some(excerpt) = item && excerpt.id == excerpt_id { return Some(excerpt); - } else if cursor.item().is_none() && excerpt_id == ExcerptId::max() { - cursor.prev(); - return cursor.item(); + } else if item.is_none() && excerpt_id == ExcerptId::max() { + return self.excerpts.last(); } None } diff --git a/crates/multi_buffer/src/path_key.rs b/crates/multi_buffer/src/path_key.rs index 475ed3c9d62338..eeaf3c5995dc44 100644 --- a/crates/multi_buffer/src/path_key.rs +++ b/crates/multi_buffer/src/path_key.rs @@ -382,9 +382,7 @@ impl MultiBuffer { { last.context.end = last.context.end.max(existing_range.end); to_remove.push(*existing_id); - self.snapshot - .get_mut() - .replaced_excerpts + Arc::make_mut(&mut self.snapshot.get_mut().replaced_excerpts) .insert(*existing_id, *last_id); existing_iter.next(); continue; @@ -462,9 +460,7 @@ impl MultiBuffer { (Some(_), Some((_, existing_range))) => { let existing_id = existing_iter.next().unwrap(); let new_id = next_excerpt_id(); - self.snapshot - .get_mut() - .replaced_excerpts + Arc::make_mut(&mut self.snapshot.get_mut().replaced_excerpts) .insert(existing_id, new_id); to_remove.push(existing_id); let mut range = new_iter.next().unwrap(); diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 158ec689788a21..e6145e409058a3 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -88,6 +88,8 @@ pub enum Model { FivePointTwo, #[serde(rename = "gpt-5.2-codex")] FivePointTwoCodex, + #[serde(rename = "gpt-5.3-codex")] + FivePointThreeCodex, #[serde(rename = "custom")] Custom { name: String, @@ -128,6 +130,7 @@ impl Model { "gpt-5.1" => Ok(Self::FivePointOne), "gpt-5.2" => Ok(Self::FivePointTwo), "gpt-5.2-codex" => Ok(Self::FivePointTwoCodex), + "gpt-5.3-codex" => Ok(Self::FivePointThreeCodex), invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"), } } @@ -149,6 +152,7 @@ impl Model { Self::FivePointOne => "gpt-5.1", Self::FivePointTwo => "gpt-5.2", Self::FivePointTwoCodex => "gpt-5.2-codex", + Self::FivePointThreeCodex => "gpt-5.3-codex", Self::Custom { name, .. } => name, } } @@ -170,6 +174,7 @@ impl Model { Self::FivePointOne => "gpt-5.1", Self::FivePointTwo => "gpt-5.2", Self::FivePointTwoCodex => "gpt-5.2-codex", + Self::FivePointThreeCodex => "gpt-5.3-codex", Self::Custom { display_name, .. } => display_name.as_deref().unwrap_or(&self.id()), } } @@ -191,6 +196,7 @@ impl Model { Self::FivePointOne => 400_000, Self::FivePointTwo => 400_000, Self::FivePointTwoCodex => 400_000, + Self::FivePointThreeCodex => 400_000, Self::Custom { max_tokens, .. } => *max_tokens, } } @@ -215,6 +221,7 @@ impl Model { Self::FivePointOne => Some(128_000), Self::FivePointTwo => Some(128_000), Self::FivePointTwoCodex => Some(128_000), + Self::FivePointThreeCodex => Some(128_000), } } @@ -223,6 +230,7 @@ impl Model { Self::Custom { reasoning_effort, .. } => reasoning_effort.to_owned(), + Self::FivePointThreeCodex => Some(ReasoningEffort::Medium), _ => None, } } @@ -233,7 +241,7 @@ impl Model { supports_chat_completions, .. } => *supports_chat_completions, - Self::FiveCodex | Self::FivePointTwoCodex => false, + Self::FiveCodex | Self::FivePointTwoCodex | Self::FivePointThreeCodex => false, _ => true, } } @@ -254,6 +262,7 @@ impl Model { | Self::FivePointOne | Self::FivePointTwo | Self::FivePointTwoCodex + | Self::FivePointThreeCodex | Self::FiveNano => true, Self::O1 | Self::O3 | Self::O3Mini | Model::Custom { .. } => false, } diff --git a/crates/platform_title_bar/Cargo.toml b/crates/platform_title_bar/Cargo.toml index 2f1f6d2cd92971..43ad6166929bc4 100644 --- a/crates/platform_title_bar/Cargo.toml +++ b/crates/platform_title_bar/Cargo.toml @@ -15,6 +15,7 @@ doctest = false [dependencies] feature_flags.workspace = true gpui.workspace = true +project.workspace = true settings.workspace = true smallvec.workspace = true theme.workspace = true diff --git a/crates/platform_title_bar/src/platform_title_bar.rs b/crates/platform_title_bar/src/platform_title_bar.rs index 6f89a5c3913789..7053fe89e7fdc6 100644 --- a/crates/platform_title_bar/src/platform_title_bar.rs +++ b/crates/platform_title_bar/src/platform_title_bar.rs @@ -7,6 +7,8 @@ use gpui::{ MouseButton, ParentElement, StatefulInteractiveElement, Styled, Window, WindowControlArea, div, px, }; +use project::DisableAiSettings; +use settings::Settings; use smallvec::SmallVec; use std::mem; use ui::{ @@ -95,7 +97,7 @@ impl PlatformTitleBar { } pub fn is_multi_workspace_enabled(cx: &App) -> bool { - cx.has_flag::() + cx.has_flag::() && !DisableAiSettings::get_global(cx).disable_ai } } diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 3dcbab8bafb470..c0f33d868c82d2 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -105,12 +105,11 @@ pub enum ExternalAgentSource { pub trait ExternalAgentServer { fn get_command( &mut self, - root_dir: Option<&str>, extra_env: HashMap, status_tx: Option>, new_version_available_tx: Option>>, cx: &mut AsyncApp, - ) -> Task)>>; + ) -> Task)>>; fn as_any_mut(&mut self) -> &mut dyn Any; } @@ -799,7 +798,7 @@ impl AgentServerStore { envelope: TypedEnvelope, mut cx: AsyncApp, ) -> Result { - let (command, root_dir, login_command) = this + let (command, login_command) = this .update(&mut cx, |this, cx| { let AgentServerStoreState::Local { downstream_client, .. @@ -858,7 +857,6 @@ impl AgentServerStore { }) .unzip(); anyhow::Ok(agent.get_command( - envelope.payload.root_dir.as_deref(), HashMap::default(), status_tx, new_version_available_tx, @@ -873,7 +871,8 @@ impl AgentServerStore { .env .map(|env| env.into_iter().collect()) .unwrap_or_default(), - root_dir: root_dir, + // This is no longer used, but returned for backwards compatibility + root_dir: paths::home_dir().to_string_lossy().to_string(), login: login_command.map(|cmd| cmd.to_proto()), }) } @@ -1254,16 +1253,14 @@ struct RemoteExternalAgentServer { impl ExternalAgentServer for RemoteExternalAgentServer { fn get_command( &mut self, - root_dir: Option<&str>, extra_env: HashMap, status_tx: Option>, new_version_available_tx: Option>>, cx: &mut AsyncApp, - ) -> Task)>> { + ) -> Task)>> { let project_id = self.project_id; let name = self.name.to_string(); let upstream_client = self.upstream_client.downgrade(); - let root_dir = root_dir.map(|root_dir| root_dir.to_owned()); self.status_tx = status_tx; self.new_version_available_tx = new_version_available_tx; cx.spawn(async move |cx| { @@ -1274,7 +1271,7 @@ impl ExternalAgentServer for RemoteExternalAgentServer { .request(proto::GetAgentServerCommand { project_id, name, - root_dir: root_dir.clone(), + root_dir: None, }) })? .await?; @@ -1296,7 +1293,6 @@ impl ExternalAgentServer for RemoteExternalAgentServer { args: command.args, env: Some(command.env), }, - root_dir, response.login.map(SpawnInTerminal::from_proto), )) }) @@ -1319,29 +1315,25 @@ struct LocalGemini { impl ExternalAgentServer for LocalGemini { fn get_command( &mut self, - root_dir: Option<&str>, extra_env: HashMap, status_tx: Option>, new_version_available_tx: Option>>, cx: &mut AsyncApp, - ) -> Task)>> { + ) -> Task)>> { let fs = self.fs.clone(); let node_runtime = self.node_runtime.clone(); let project_environment = self.project_environment.downgrade(); let custom_command = self.custom_command.clone(); let settings_env = self.settings_env.clone(); let ignore_system_version = self.ignore_system_version; - let root_dir: Arc = root_dir - .map(|root_dir| Path::new(root_dir)) - .unwrap_or(paths::home_dir()) - .into(); + let home_dir = paths::home_dir(); cx.spawn(async move |cx| { let mut env = project_environment .update(cx, |project_environment, cx| { project_environment.local_directory_environment( &Shell::System, - root_dir.clone(), + home_dir.as_path().into(), cx, ) })? @@ -1355,7 +1347,7 @@ impl ExternalAgentServer for LocalGemini { custom_command } else if !ignore_system_version && let Some(bin) = - find_bin_in_path("gemini".into(), root_dir.to_path_buf(), env.clone(), cx).await + find_bin_in_path("gemini".into(), home_dir.to_path_buf(), env.clone(), cx).await { AgentServerCommand { path: bin, @@ -1395,11 +1387,7 @@ impl ExternalAgentServer for LocalGemini { command.env.get_or_insert_default().extend(extra_env); command.args.push("--experimental-acp".into()); - Ok(( - command, - root_dir.to_string_lossy().into_owned(), - Some(login), - )) + Ok((command, Some(login))) }) } @@ -1419,28 +1407,23 @@ struct LocalClaudeCode { impl ExternalAgentServer for LocalClaudeCode { fn get_command( &mut self, - root_dir: Option<&str>, extra_env: HashMap, status_tx: Option>, new_version_available_tx: Option>>, cx: &mut AsyncApp, - ) -> Task)>> { + ) -> Task)>> { let fs = self.fs.clone(); let node_runtime = self.node_runtime.clone(); let project_environment = self.project_environment.downgrade(); let custom_command = self.custom_command.clone(); let settings_env = self.settings_env.clone(); - let root_dir: Arc = root_dir - .map(|root_dir| Path::new(root_dir)) - .unwrap_or(paths::home_dir()) - .into(); cx.spawn(async move |cx| { let mut env = project_environment .update(cx, |project_environment, cx| { project_environment.local_directory_environment( &Shell::System, - root_dir.clone(), + paths::home_dir().as_path().into(), cx, ) })? @@ -1472,11 +1455,7 @@ impl ExternalAgentServer for LocalClaudeCode { }; command.env.get_or_insert_default().extend(extra_env); - Ok(( - command, - root_dir.to_string_lossy().into_owned(), - login_command, - )) + Ok((command, login_command)) }) } @@ -1497,21 +1476,16 @@ struct LocalCodex { impl ExternalAgentServer for LocalCodex { fn get_command( &mut self, - root_dir: Option<&str>, extra_env: HashMap, mut status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, - ) -> Task)>> { + ) -> Task)>> { let fs = self.fs.clone(); let project_environment = self.project_environment.downgrade(); let http = self.http_client.clone(); let custom_command = self.custom_command.clone(); let settings_env = self.settings_env.clone(); - let root_dir: Arc = root_dir - .map(|root_dir| Path::new(root_dir)) - .unwrap_or(paths::home_dir()) - .into(); let no_browser = self.no_browser; cx.spawn(async move |cx| { @@ -1519,7 +1493,7 @@ impl ExternalAgentServer for LocalCodex { .update(cx, |project_environment, cx| { project_environment.local_directory_environment( &Shell::System, - root_dir.clone(), + paths::home_dir().as_path().into(), cx, ) })? @@ -1615,7 +1589,7 @@ impl ExternalAgentServer for LocalCodex { &asset.browser_download_url, digest, &version_dir, - if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") { + if cfg!(target_os = "windows") { AssetKind::Zip } else { AssetKind::TarGz @@ -1664,7 +1638,7 @@ impl ExternalAgentServer for LocalCodex { }; command.env.get_or_insert_default().extend(extra_env); - Ok((command, root_dir.to_string_lossy().into_owned(), None)) + Ok((command, None)) }) } @@ -1723,12 +1697,11 @@ pub struct LocalExtensionArchiveAgent { impl ExternalAgentServer for LocalExtensionArchiveAgent { fn get_command( &mut self, - root_dir: Option<&str>, extra_env: HashMap, _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, - ) -> Task)>> { + ) -> Task)>> { let fs = self.fs.clone(); let http_client = self.http_client.clone(); let node_runtime = self.node_runtime.clone(); @@ -1738,18 +1711,13 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { let targets = self.targets.clone(); let base_env = self.env.clone(); - let root_dir: Arc = root_dir - .map(|root_dir| Path::new(root_dir)) - .unwrap_or(paths::home_dir()) - .into(); - cx.spawn(async move |cx| { // Get project environment let mut env = project_environment .update(cx, |project_environment, cx| { project_environment.local_directory_environment( &Shell::System, - root_dir.clone(), + paths::home_dir().as_path().into(), cx, ) })? @@ -1909,7 +1877,7 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { env: Some(env), }; - Ok((command, version_dir.to_string_lossy().into_owned(), None)) + Ok((command, None)) }) } @@ -1931,12 +1899,11 @@ struct LocalRegistryArchiveAgent { impl ExternalAgentServer for LocalRegistryArchiveAgent { fn get_command( &mut self, - root_dir: Option<&str>, extra_env: HashMap, _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, - ) -> Task)>> { + ) -> Task)>> { let fs = self.fs.clone(); let http_client = self.http_client.clone(); let node_runtime = self.node_runtime.clone(); @@ -1945,17 +1912,12 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { let targets = self.targets.clone(); let settings_env = self.env.clone(); - let root_dir: Arc = root_dir - .map(|root_dir| Path::new(root_dir)) - .unwrap_or(paths::home_dir()) - .into(); - cx.spawn(async move |cx| { let mut env = project_environment .update(cx, |project_environment, cx| { project_environment.local_directory_environment( &Shell::System, - root_dir.clone(), + paths::home_dir().as_path().into(), cx, ) })? @@ -2099,7 +2061,7 @@ impl ExternalAgentServer for LocalRegistryArchiveAgent { env: Some(env), }; - Ok((command, version_dir.to_string_lossy().into_owned(), None)) + Ok((command, None)) }) } @@ -2120,12 +2082,11 @@ struct LocalRegistryNpxAgent { impl ExternalAgentServer for LocalRegistryNpxAgent { fn get_command( &mut self, - root_dir: Option<&str>, extra_env: HashMap, _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, - ) -> Task)>> { + ) -> Task)>> { let node_runtime = self.node_runtime.clone(); let project_environment = self.project_environment.downgrade(); let package = self.package.clone(); @@ -2133,17 +2094,12 @@ impl ExternalAgentServer for LocalRegistryNpxAgent { let distribution_env = self.distribution_env.clone(); let settings_env = self.settings_env.clone(); - let env_root_dir: Arc = root_dir - .map(|root_dir| Path::new(root_dir)) - .unwrap_or(paths::home_dir()) - .into(); - cx.spawn(async move |cx| { let mut env = project_environment .update(cx, |project_environment, cx| { project_environment.local_directory_environment( &Shell::System, - env_root_dir.clone(), + paths::home_dir().as_path().into(), cx, ) })? @@ -2176,7 +2132,7 @@ impl ExternalAgentServer for LocalRegistryNpxAgent { env: Some(env), }; - Ok((command, env_root_dir.to_string_lossy().into_owned(), None)) + Ok((command, None)) }) } @@ -2193,24 +2149,19 @@ struct LocalCustomAgent { impl ExternalAgentServer for LocalCustomAgent { fn get_command( &mut self, - root_dir: Option<&str>, extra_env: HashMap, _status_tx: Option>, _new_version_available_tx: Option>>, cx: &mut AsyncApp, - ) -> Task)>> { + ) -> Task)>> { let mut command = self.command.clone(); - let root_dir: Arc = root_dir - .map(|root_dir| Path::new(root_dir)) - .unwrap_or(paths::home_dir()) - .into(); let project_environment = self.project_environment.downgrade(); cx.spawn(async move |cx| { let mut env = project_environment .update(cx, |project_environment, cx| { project_environment.local_directory_environment( &Shell::System, - root_dir.clone(), + paths::home_dir().as_path().into(), cx, ) })? @@ -2219,7 +2170,7 @@ impl ExternalAgentServer for LocalCustomAgent { env.extend(command.env.unwrap_or_default()); env.extend(extra_env); command.env = Some(env); - Ok((command, root_dir.to_string_lossy().into_owned(), None)) + Ok((command, None)) }) } diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index e4cac4768d48db..88dc64fcbe8795 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -8,7 +8,7 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use collections::{HashMap, HashSet}; use context_server::{ContextServer, ContextServerCommand, ContextServerId}; -use futures::{FutureExt as _, future::join_all}; +use futures::{FutureExt as _, future::Either, future::join_all}; use gpui::{App, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity, actions}; use itertools::Itertools; use registry::ContextServerDescriptorRegistry; @@ -141,6 +141,8 @@ impl ContextServerConfiguration { worktree_store: Entity, cx: &AsyncApp, ) -> Option { + const EXTENSION_COMMAND_TIMEOUT: Duration = Duration::from_secs(30); + match settings { ContextServerSettings::Stdio { enabled: _, @@ -155,18 +157,27 @@ impl ContextServerConfiguration { let descriptor = cx.update(|cx| registry.read(cx).context_server_descriptor(&id.0))?; - match descriptor.command(worktree_store, cx).await { - Ok(command) => Some(ContextServerConfiguration::Extension { + let command_future = descriptor.command(worktree_store, cx); + let timeout_future = cx.background_executor().timer(EXTENSION_COMMAND_TIMEOUT); + + match futures::future::select(command_future, timeout_future).await { + Either::Left((Ok(command), _)) => Some(ContextServerConfiguration::Extension { command, settings, remote, }), - Err(e) => { + Either::Left((Err(e), _)) => { log::error!( "Failed to create context server configuration from settings: {e:#}" ); None } + Either::Right(_) => { + log::error!( + "Timed out resolving command for extension context server {id}" + ); + None + } } } ContextServerSettings::Http { @@ -855,6 +866,7 @@ impl ContextServerStore { this.update(cx, |this, cx| { this.populate_server_ids(cx); + cx.notify(); this.update_servers_task.take(); if this.needs_server_update { this.available_context_servers_changed(cx); @@ -959,11 +971,23 @@ impl ContextServerStore { })??; for (id, config) in servers_to_start { - let (server, config) = - Self::create_context_server(this.clone(), id, config, cx).await?; - this.update(cx, |this, cx| { - this.run_server(server, config, cx); - })?; + match Self::create_context_server(this.clone(), id.clone(), config, cx).await { + Ok((server, config)) => { + this.update(cx, |this, cx| { + this.run_server(server, config, cx); + })?; + } + Err(err) => { + log::error!("{id} context server failed to create: {err:#}"); + this.update(cx, |_this, cx| { + cx.emit(ServerStatusChangedEvent { + server_id: id, + status: ContextServerStatus::Error(err.to_string().into()), + }); + cx.notify(); + })?; + } + } } Ok(()) diff --git a/crates/project/src/debugger/breakpoint_store.rs b/crates/project/src/debugger/breakpoint_store.rs index 54f884aa5704bd..50df9ae3125d3d 100644 --- a/crates/project/src/debugger/breakpoint_store.rs +++ b/crates/project/src/debugger/breakpoint_store.rs @@ -6,7 +6,9 @@ pub use breakpoints_in_file::{BreakpointSessionState, BreakpointWithPosition}; use breakpoints_in_file::{BreakpointsInFile, StatefulBreakpoint}; use collections::{BTreeMap, HashMap}; use dap::{StackFrameId, client::SessionId}; -use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, Subscription, Task}; +use gpui::{ + App, AppContext, AsyncApp, Context, Entity, EntityId, EventEmitter, Subscription, Task, +}; use itertools::Itertools; use language::{Buffer, BufferSnapshot, proto::serialize_anchor as serialize_text_anchor}; use rpc::{ @@ -154,6 +156,7 @@ pub struct BreakpointStore { breakpoints: BTreeMap, BreakpointsInFile>, downstream_client: Option<(AnyProtoClient, u64)>, active_stack_frame: Option, + active_debug_line_pane_id: Option, // E.g ssh mode: BreakpointStoreMode, } @@ -171,6 +174,7 @@ impl BreakpointStore { worktree_store, downstream_client: None, active_stack_frame: Default::default(), + active_debug_line_pane_id: None, } } @@ -190,6 +194,7 @@ impl BreakpointStore { worktree_store, downstream_client: None, active_stack_frame: Default::default(), + active_debug_line_pane_id: None, } } @@ -651,16 +656,30 @@ impl BreakpointStore { self.active_stack_frame.as_ref() } + pub fn active_debug_line_pane_id(&self) -> Option { + self.active_debug_line_pane_id + } + + pub fn set_active_debug_pane_id(&mut self, pane_id: EntityId) { + self.active_debug_line_pane_id = Some(pane_id); + } + pub fn remove_active_position( &mut self, session_id: Option, cx: &mut Context, ) { if let Some(session_id) = session_id { - self.active_stack_frame - .take_if(|active_stack_frame| active_stack_frame.session_id == session_id); + if self + .active_stack_frame + .take_if(|active_stack_frame| active_stack_frame.session_id == session_id) + .is_some() + { + self.active_debug_line_pane_id = None; + } } else { self.active_stack_frame.take(); + self.active_debug_line_pane_id = None; } cx.emit(BreakpointStoreEvent::ClearDebugLines); diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 1272a689b90841..3113163cbaec65 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -60,7 +60,7 @@ use settings::WorktreeId; use smol::future::yield_now; use std::{ cmp::Ordering, - collections::{BTreeSet, HashSet, VecDeque}, + collections::{BTreeSet, HashSet, VecDeque, hash_map::Entry}, future::Future, mem, ops::Range, @@ -296,6 +296,19 @@ enum GraphCommitHandlerState { Closed, } +pub struct InitialGitGraphData { + fetch_task: Task<()>, + pub error: Option, + pub commit_data: Vec>, + pub commit_oid_to_index: HashMap, +} + +pub struct GraphDataResponse<'a> { + pub commits: &'a [Arc], + pub is_loading: bool, + pub error: Option, +} + pub struct Repository { this: WeakEntity, snapshot: RepositorySnapshot, @@ -311,13 +324,7 @@ pub struct Repository { askpass_delegates: Arc>>, latest_askpass_id: u64, repository_state: Shared>>, - pub initial_graph_data: HashMap< - (LogOrder, LogSource), - ( - Task>, - Vec>, - ), - >, + initial_graph_data: HashMap<(LogSource, LogOrder), InitialGitGraphData>, graph_commit_data_handler: GraphCommitHandlerState, commit_data: HashMap, } @@ -390,6 +397,13 @@ pub enum RepositoryState { Remote(RemoteRepositoryState), } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GitGraphEvent { + CountUpdated(usize), + FullyLoaded, + LoadingError, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum RepositoryEvent { StatusesChanged, @@ -397,7 +411,7 @@ pub enum RepositoryEvent { BranchChanged, StashEntriesChanged, PendingOpsChanged { pending_ops: SumTree }, - GitGraphCountUpdated((LogOrder, LogSource), usize), + GraphEvent((LogSource, LogOrder), GitGraphEvent), } #[derive(Clone, Debug)] @@ -529,6 +543,7 @@ impl GitStore { client.add_entity_request_handler(Self::handle_askpass); client.add_entity_request_handler(Self::handle_check_for_pushed_commits); client.add_entity_request_handler(Self::handle_git_diff); + client.add_entity_request_handler(Self::handle_git_diff_stat); client.add_entity_request_handler(Self::handle_tree_diff); client.add_entity_request_handler(Self::handle_get_blob_content); client.add_entity_request_handler(Self::handle_open_unstaged_diff); @@ -2684,6 +2699,45 @@ impl GitStore { Ok(proto::GitDiffResponse { diff }) } + async fn handle_git_diff_stat( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let diff_type = match envelope.payload.diff_type() { + proto::git_diff_stat::DiffType::HeadToIndex => DiffType::HeadToIndex, + proto::git_diff_stat::DiffType::HeadToWorktree => DiffType::HeadToWorktree, + proto::git_diff_stat::DiffType::MergeBase => { + let base_ref = envelope + .payload + .merge_base_ref + .ok_or_else(|| anyhow!("merge_base_ref is required for MergeBase diff type"))?; + DiffType::MergeBase { + base_ref: base_ref.into(), + } + } + }; + + let stats = repository_handle + .update(&mut cx, |repository_handle, cx| { + repository_handle.diff_stat(diff_type, cx) + }) + .await??; + + let entries = stats + .into_iter() + .map(|(path, stat)| proto::GitDiffStatEntry { + path: path.to_proto(), + added: stat.added, + deleted: stat.deleted, + }) + .collect(); + + Ok(proto::GitDiffStatResponse { entries }) + } + async fn handle_tree_diff( this: Entity, request: TypedEnvelope, @@ -4364,47 +4418,82 @@ impl Repository { }) } + pub fn get_graph_data( + &self, + log_source: LogSource, + log_order: LogOrder, + ) -> Option<&InitialGitGraphData> { + self.initial_graph_data.get(&(log_source, log_order)) + } + pub fn graph_data( &mut self, log_source: LogSource, log_order: LogOrder, range: Range, cx: &mut Context, - ) -> (&[Arc], bool) { - let (loading_task, initial_commit_data) = self + ) -> GraphDataResponse<'_> { + let initial_commit_data = self .initial_graph_data - .entry((log_order, log_source.clone())) + .entry((log_source.clone(), log_order)) .or_insert_with(|| { let state = self.repository_state.clone(); let log_source = log_source.clone(); - ( - cx.spawn(async move |repository, cx| { - let state = state.await; - match state { - Ok(RepositoryState::Local(LocalRepositoryState { - backend, .. - })) => { - Self::local_git_graph_data( - repository, backend, log_source, log_order, cx, - ) - .await - } - Ok(RepositoryState::Remote(_)) => { - Err("Git graph is not supported for collab yet".into()) - } - Err(e) => Err(SharedString::from(e)), + + let fetch_task = cx.spawn(async move |repository, cx| { + let state = state.await; + let result = match state { + Ok(RepositoryState::Local(LocalRepositoryState { backend, .. })) => { + Self::local_git_graph_data( + repository.clone(), + backend, + log_source.clone(), + log_order, + cx, + ) + .await } - }), - vec![], - ) + Ok(RepositoryState::Remote(_)) => { + Err("Git graph is not supported for collab yet".into()) + } + Err(e) => Err(SharedString::from(e)), + }; + + if let Err(fetch_task_error) = result { + repository + .update(cx, |repository, _| { + if let Some(data) = repository + .initial_graph_data + .get_mut(&(log_source, log_order)) + { + data.error = Some(fetch_task_error); + } else { + debug_panic!( + "This task would be dropped if this entry doesn't exist" + ); + } + }) + .ok(); + } + }); + + InitialGitGraphData { + fetch_task, + error: None, + commit_data: Vec::new(), + commit_oid_to_index: HashMap::default(), + } }); - let max_start = initial_commit_data.len().saturating_sub(1); - let max_end = initial_commit_data.len(); - ( - &initial_commit_data[range.start.min(max_start)..range.end.min(max_end)], - !loading_task.is_ready(), - ) + let max_start = initial_commit_data.commit_data.len().saturating_sub(1); + let max_end = initial_commit_data.commit_data.len(); + + GraphDataResponse { + commits: &initial_commit_data.commit_data + [range.start.min(max_start)..range.end.min(max_end)], + is_loading: !initial_commit_data.fetch_task.is_ready(), + error: initial_commit_data.error.clone(), + } } async fn local_git_graph_data( @@ -4427,32 +4516,38 @@ impl Repository { } }); - let graph_data_key = (log_order, log_source.clone()); + let graph_data_key = (log_source, log_order); while let Ok(initial_graph_commit_data) = request_rx.recv().await { this.update(cx, |repository, cx| { let graph_data = repository .initial_graph_data - .get_mut(&graph_data_key) - .map(|(_, graph_data)| graph_data); - debug_assert!( - graph_data.is_some(), - "This task should be dropped if data doesn't exist" - ); + .entry(graph_data_key.clone()) + .and_modify(|graph_data| { + for commit_data in initial_graph_commit_data { + graph_data + .commit_oid_to_index + .insert(commit_data.sha, graph_data.commit_data.len()); + graph_data.commit_data.push(commit_data); + + cx.emit(RepositoryEvent::GraphEvent( + graph_data_key.clone(), + GitGraphEvent::CountUpdated(graph_data.commit_data.len()), + )); + } + }); - if let Some(graph_data) = graph_data { - graph_data.extend(initial_graph_commit_data); - cx.emit(RepositoryEvent::GitGraphCountUpdated( - graph_data_key.clone(), - graph_data.len(), - )); + match &graph_data { + Entry::Occupied(_) => {} + Entry::Vacant(_) => { + debug_panic!("This task should be dropped if data doesn't exist"); + } } }) .ok(); } task.await?; - Ok(()) } @@ -5690,6 +5785,63 @@ impl Repository { }) } + /// Fetches per-line diff statistics (additions/deletions) via `git diff --numstat`. + pub fn diff_stat( + &mut self, + diff_type: DiffType, + _cx: &App, + ) -> oneshot::Receiver< + Result>, + > { + let id = self.id; + self.send_job(None, move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.diff_stat(diff_type).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + let (proto_diff_type, merge_base_ref) = match &diff_type { + DiffType::HeadToIndex => { + (proto::git_diff_stat::DiffType::HeadToIndex.into(), None) + } + DiffType::HeadToWorktree => { + (proto::git_diff_stat::DiffType::HeadToWorktree.into(), None) + } + DiffType::MergeBase { base_ref } => ( + proto::git_diff_stat::DiffType::MergeBase.into(), + Some(base_ref.to_string()), + ), + }; + let response = client + .request(proto::GitDiffStat { + project_id: project_id.0, + repository_id: id.to_proto(), + diff_type: proto_diff_type, + merge_base_ref, + }) + .await?; + + let stats = response + .entries + .into_iter() + .filter_map(|entry| { + let path = RepoPath::from_proto(&entry.path).log_err()?; + Some(( + path, + git::status::DiffStat { + added: entry.added, + deleted: entry.deleted, + }, + )) + }) + .collect(); + + Ok(stats) + } + } + }) + } + pub fn create_branch( &mut self, branch_name: String, diff --git a/crates/project/src/lsp_store/semantic_tokens.rs b/crates/project/src/lsp_store/semantic_tokens.rs index 516fb75eaae137..e71b05d47b0cf1 100644 --- a/crates/project/src/lsp_store/semantic_tokens.rs +++ b/crates/project/src/lsp_store/semantic_tokens.rs @@ -653,8 +653,8 @@ impl ServerSemanticTokens { pub(crate) fn apply(&mut self, edits: &[SemanticTokensEdit]) { for edit in edits { - let start = edit.start as usize; - let end = start + edit.delete_count as usize; + let start = (edit.start as usize).min(self.data.len()); + let end = (start + edit.delete_count as usize).min(self.data.len()); self.data.splice(start..end, edit.data.iter().copied()); } } @@ -1000,4 +1000,38 @@ mod tests { ] ); } + + #[test] + fn applies_out_of_bounds_delta_edit_without_panic() { + let mut tokens = ServerSemanticTokens::from_full(vec![2, 5, 3, 0, 3, 0, 5, 4, 1, 0], None); + + // start beyond data length + tokens.apply(&[SemanticTokensEdit { + start: 100, + delete_count: 5, + data: vec![1, 2, 3, 4, 5], + }]); + assert_eq!( + tokens.data, + vec![2, 5, 3, 0, 3, 0, 5, 4, 1, 0, 1, 2, 3, 4, 5] + ); + + // delete_count extends past data length + let mut tokens = ServerSemanticTokens::from_full(vec![2, 5, 3, 0, 3], None); + tokens.apply(&[SemanticTokensEdit { + start: 3, + delete_count: 100, + data: vec![9, 9], + }]); + assert_eq!(tokens.data, vec![2, 5, 3, 9, 9]); + + // empty data + let mut tokens = ServerSemanticTokens::from_full(Vec::new(), None); + tokens.apply(&[SemanticTokensEdit { + start: 0, + delete_count: 5, + data: vec![1, 2, 3, 4, 5], + }]); + assert_eq!(tokens.data, vec![1, 2, 3, 4, 5]); + } } diff --git a/crates/project/tests/integration/ext_agent_tests.rs b/crates/project/tests/integration/ext_agent_tests.rs index 74f762981a4f15..5cb75f54bc366a 100644 --- a/crates/project/tests/integration/ext_agent_tests.rs +++ b/crates/project/tests/integration/ext_agent_tests.rs @@ -9,19 +9,17 @@ struct NoopExternalAgent; impl ExternalAgentServer for NoopExternalAgent { fn get_command( &mut self, - _root_dir: Option<&str>, _extra_env: HashMap, _status_tx: Option>, _new_version_available_tx: Option>>, _cx: &mut AsyncApp, - ) -> Task)>> { + ) -> Task)>> { Task::ready(Ok(( AgentServerCommand { path: PathBuf::from("noop"), args: Vec::new(), env: None, }, - "".to_string(), None, ))) } diff --git a/crates/project/tests/integration/extension_agent_tests.rs b/crates/project/tests/integration/extension_agent_tests.rs index f237b9dc7deaf2..ca73612d07bb5f 100644 --- a/crates/project/tests/integration/extension_agent_tests.rs +++ b/crates/project/tests/integration/extension_agent_tests.rs @@ -25,19 +25,17 @@ struct NoopExternalAgent; impl ExternalAgentServer for NoopExternalAgent { fn get_command( &mut self, - _root_dir: Option<&str>, _extra_env: HashMap, _status_tx: Option>, _new_version_available_tx: Option>>, _cx: &mut AsyncApp, - ) -> Task)>> { + ) -> Task)>> { Task::ready(Ok(( AgentServerCommand { path: PathBuf::from("noop"), args: Vec::new(), env: None, }, - "".to_string(), None, ))) } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 6c0c10c0715a35..7f746a6ccd7efe 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -146,6 +146,7 @@ pub struct ProjectPanel { width: Option, pending_serialization: Task>, diagnostics: HashMap<(WorktreeId, Arc), DiagnosticSeverity>, + diagnostic_counts: HashMap<(WorktreeId, Arc), DiagnosticCount>, diagnostic_summary_update: Task<()>, // We keep track of the mouse down state on entries so we don't flash the UI // in case a user clicks to open a file. @@ -232,6 +233,30 @@ enum ClipboardEntry { Cut(BTreeSet), } +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] +struct DiagnosticCount { + error_count: usize, + warning_count: usize, +} + +impl DiagnosticCount { + fn capped_error_count(&self) -> String { + Self::capped_count(self.error_count) + } + + fn capped_warning_count(&self) -> String { + Self::capped_count(self.warning_count) + } + + fn capped_count(count: usize) -> String { + if count > 99 { + "99+".to_string() + } else { + count.to_string() + } + } +} + #[derive(Debug, PartialEq, Eq, Clone)] struct EntryDetails { filename: String, @@ -249,6 +274,7 @@ struct EntryDetails { sticky: Option, filename_text_color: Color, diagnostic_severity: Option, + diagnostic_count: Option, git_status: GitSummary, is_private: bool, worktree_id: WorktreeId, @@ -847,6 +873,7 @@ impl ProjectPanel { width: None, pending_serialization: Task::ready(None), diagnostics: Default::default(), + diagnostic_counts: Default::default(), diagnostic_summary_update: Task::ready(()), scroll_handle, mouse_down: false, @@ -1029,6 +1056,26 @@ impl ProjectPanel { }); } self.diagnostics = diagnostics; + + let diagnostic_badges = ProjectPanelSettings::get_global(cx).diagnostic_badges; + self.diagnostic_counts = + if diagnostic_badges && show_diagnostics_setting != ShowDiagnostics::Off { + self.project.read(cx).diagnostic_summaries(false, cx).fold( + HashMap::default(), + |mut counts, (project_path, _, summary)| { + let entry = counts + .entry((project_path.worktree_id, project_path.path)) + .or_default(); + entry.error_count += summary.error_count; + if show_diagnostics_setting == ShowDiagnostics::All { + entry.warning_count += summary.warning_count; + } + counts + }, + ) + } else { + Default::default() + }; } fn update_strongest_diagnostic_severity( @@ -5044,6 +5091,7 @@ impl ProjectPanel { let filename_text_color = details.filename_text_color; let diagnostic_severity = details.diagnostic_severity; + let diagnostic_count = details.diagnostic_count; let item_colors = get_item_color(is_sticky, cx); let canonical_path = details @@ -5482,22 +5530,55 @@ impl ProjectPanel { ProjectPanelEntrySpacing::Standard => ListItemSpacing::ExtraDense, }) .selectable(false) - .when_some(canonical_path, |this, path| { - this.end_slot::( - div() - .id("symlink_icon") - .pr_3() - .tooltip(move |_window, cx| { - Tooltip::with_meta(path.to_string(), None, "Symbolic Link", cx) - }) - .child( - Icon::new(IconName::ArrowUpRight) - .size(IconSize::Indicator) - .color(filename_text_color), - ) - .into_any_element(), - ) - }) + .when( + canonical_path.is_some() || diagnostic_count.is_some(), + |this| { + let symlink_element = canonical_path.map(|path| { + div() + .id("symlink_icon") + .tooltip(move |_window, cx| { + Tooltip::with_meta( + path.to_string(), + None, + "Symbolic Link", + cx, + ) + }) + .child( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Indicator) + .color(filename_text_color), + ) + }); + this.end_slot::( + h_flex() + .gap_1() + .flex_none() + .pr_3() + .when_some(diagnostic_count, |this, count| { + this.when(count.error_count > 0, |this| { + this.child( + Label::new(count.capped_error_count()) + .size(LabelSize::Small) + .color(Color::Error), + ) + }) + .when( + count.warning_count > 0, + |this| { + this.child( + Label::new(count.capped_warning_count()) + .size(LabelSize::Small) + .color(Color::Warning), + ) + }, + ) + }) + .when_some(symlink_element, |this, el| this.child(el)) + .into_any_element(), + ) + }, + ) .child(if let Some(icon) = &icon { if let Some((_, decoration_color)) = entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity) @@ -5907,6 +5988,11 @@ impl ProjectPanel { .get(&(worktree_id, entry.path.clone())) .cloned(); + let diagnostic_count = self + .diagnostic_counts + .get(&(worktree_id, entry.path.clone())) + .copied(); + let filename_text_color = entry_git_aware_label_color(git_status, entry.is_ignored, is_marked); @@ -5931,6 +6017,7 @@ impl ProjectPanel { sticky, filename_text_color, diagnostic_severity, + diagnostic_count, git_status, is_private: entry.is_private, worktree_id, @@ -5966,12 +6053,27 @@ impl ProjectPanel { .worktree_for_entry(entry_id, cx) .context("can't reveal a non-existent entry in the project panel")?; let worktree = worktree.read(cx); - if skip_ignored - && worktree - .entry_for_id(entry_id) - .is_none_or(|entry| entry.is_ignored && !entry.is_always_included) - { - anyhow::bail!("can't reveal an ignored entry in the project panel"); + let worktree_id = worktree.id(); + let is_ignored = worktree + .entry_for_id(entry_id) + .is_none_or(|entry| entry.is_ignored && !entry.is_always_included); + if skip_ignored && is_ignored { + if self.index_for_entry(entry_id, worktree_id).is_none() { + anyhow::bail!("can't reveal an ignored entry in the project panel"); + } + + self.selection = Some(SelectedEntry { + worktree_id, + entry_id, + }); + self.marked_entries.clear(); + self.marked_entries.push(SelectedEntry { + worktree_id, + entry_id, + }); + self.autoscroll(cx); + cx.notify(); + return Ok(()); } let is_active_item_file_diff_view = self .workspace @@ -5983,7 +6085,6 @@ impl ProjectPanel { return Ok(()); } - let worktree_id = worktree.id(); self.expand_entry(worktree_id, entry_id, cx); self.update_visible_entries(Some((worktree_id, entry_id)), false, true, window, cx); self.marked_entries.clear(); diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 6b6b7a377276a9..0d703c55c06dff 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -35,6 +35,7 @@ pub struct ProjectPanelSettings { pub drag_and_drop: bool, pub auto_open: AutoOpenSettings, pub sort_mode: ProjectPanelSortMode, + pub diagnostic_badges: bool, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -125,9 +126,8 @@ impl Settings for ProjectPanelSettings { on_drop: auto_open.on_drop.unwrap(), } }, - sort_mode: project_panel - .sort_mode - .unwrap_or(ProjectPanelSortMode::DirectoriesFirst), + sort_mode: project_panel.sort_mode.unwrap(), + diagnostic_badges: project_panel.diagnostic_badges.unwrap(), } } } diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 01d165174784f4..af84a7f522a60a 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -4843,6 +4843,64 @@ async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) { ], "When a gitignored entry is explicitly revealed, it should be shown in the project tree" ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file))) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " v gitignored_dir", + " file_a.py", + " file_b.py", + " file_c.py", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py <== selected <== marked", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "After switching to dir_2_file, it should be selected and marked" + ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some( + gitignored_dir_file, + ))) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " v gitignored_dir", + " file_a.py <== selected <== marked", + " file_b.py", + " file_c.py", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "When a gitignored entry is already visible, auto reveal should mark it as selected" + ); } #[gpui::test] diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 994d319913c6d8..facaf43fd5ae3e 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -229,6 +229,29 @@ message GitDiffResponse { string diff = 1; } +message GitDiffStat { + uint64 project_id = 1; + uint64 repository_id = 2; + DiffType diff_type = 3; + optional string merge_base_ref = 4; + + enum DiffType { + HEAD_TO_WORKTREE = 0; + HEAD_TO_INDEX = 1; + MERGE_BASE = 2; + } +} + +message GitDiffStatResponse { + repeated GitDiffStatEntry entries = 1; +} + +message GitDiffStatEntry { + string path = 1; + uint32 added = 2; + uint32 deleted = 3; +} + message GitInit { uint64 project_id = 1; string abs_path = 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 387ed25027230c..fa55e1f27330fb 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -476,7 +476,9 @@ message Envelope { SpawnKernel spawn_kernel = 426; SpawnKernelResponse spawn_kernel_response = 427; - KillKernel kill_kernel = 428; // current max + KillKernel kill_kernel = 428; + GitDiffStat git_diff_stat = 429; + GitDiffStatResponse git_diff_stat_response = 430; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index dd0a77beb29345..3d30551557000c 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -322,6 +322,8 @@ messages!( (CheckForPushedCommitsResponse, Background), (GitDiff, Background), (GitDiffResponse, Background), + (GitDiffStat, Background), + (GitDiffStatResponse, Background), (GitInit, Background), (GetDebugAdapterBinary, Background), (DebugAdapterBinary, Background), @@ -539,6 +541,7 @@ request_messages!( (GitRenameBranch, Ack), (CheckForPushedCommits, CheckForPushedCommitsResponse), (GitDiff, GitDiffResponse), + (GitDiffStat, GitDiffStatResponse), (GitInit, Ack), (ToggleBreakpoint, Ack), (GetDebugAdapterBinary, DebugAdapterBinary), @@ -727,6 +730,7 @@ entity_messages!( GitRemoveRemote, CheckForPushedCommits, GitDiff, + GitDiffStat, GitInit, BreakpointsForFile, ToggleBreakpoint, diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index 83733306e7a1c9..d27662dde3656d 100644 --- a/crates/remote/src/transport/ssh.rs +++ b/crates/remote/src/transport/ssh.rs @@ -94,6 +94,14 @@ impl Default for SshConnectionHost { } } +fn bracket_ipv6(host: &str) -> String { + if host.contains(':') && !host.starts_with('[') { + format!("[{}]", host) + } else { + host.to_string() + } +} + #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] pub struct SshConnectionOptions { pub host: SshConnectionHost, @@ -344,7 +352,12 @@ impl RemoteConnection for SshRemoteConnection { args.push("-N".into()); for (local_port, host, remote_port) in forwards { args.push("-L".into()); - args.push(format!("{local_port}:{host}:{remote_port}")); + args.push(format!( + "{}:{}:{}", + local_port, + bracket_ipv6(&host), + remote_port + )); } args.push(socket.connection_options.ssh_destination()); Ok(CommandTemplate { @@ -1342,33 +1355,71 @@ fn parse_port_number(port_str: &str) -> Result { .with_context(|| format!("parsing port number: {port_str}")) } +fn split_port_forward_tokens(spec: &str) -> Result> { + let mut tokens = Vec::new(); + let mut chars = spec.chars().peekable(); + + while chars.peek().is_some() { + if chars.peek() == Some(&'[') { + chars.next(); + let mut bracket_content = String::new(); + loop { + match chars.next() { + Some(']') => break, + Some(ch) => bracket_content.push(ch), + None => anyhow::bail!("Unmatched '[' in port forward spec: {spec}"), + } + } + tokens.push(bracket_content); + if chars.peek() == Some(&':') { + chars.next(); + } + } else { + let mut token = String::new(); + for ch in chars.by_ref() { + if ch == ':' { + break; + } + token.push(ch); + } + tokens.push(token); + } + } + + Ok(tokens) +} + fn parse_port_forward_spec(spec: &str) -> Result { - let parts: Vec<&str> = spec.split(':').collect(); + let tokens = if spec.contains('[') { + split_port_forward_tokens(spec)? + } else { + spec.split(':').map(String::from).collect() + }; - match *parts { - [a, b, c, d] => { - let local_port = parse_port_number(b)?; - let remote_port = parse_port_number(d)?; + match tokens.len() { + 4 => { + let local_port = parse_port_number(&tokens[1])?; + let remote_port = parse_port_number(&tokens[3])?; Ok(SshPortForwardOption { - local_host: Some(a.to_string()), + local_host: Some(tokens[0].clone()), local_port, - remote_host: Some(c.to_string()), + remote_host: Some(tokens[2].clone()), remote_port, }) } - [a, b, c] => { - let local_port = parse_port_number(a)?; - let remote_port = parse_port_number(c)?; + 3 => { + let local_port = parse_port_number(&tokens[0])?; + let remote_port = parse_port_number(&tokens[2])?; Ok(SshPortForwardOption { local_host: None, local_port, - remote_host: Some(b.to_string()), + remote_host: Some(tokens[1].clone()), remote_port, }) } - _ => anyhow::bail!("Invalid port forward format"), + _ => anyhow::bail!("Invalid port forward format: {spec}"), } } @@ -1534,7 +1585,10 @@ impl SshConnectionOptions { format!( "-L{}:{}:{}:{}", - local_host, pf.local_port, remote_host, pf.remote_port + bracket_ipv6(local_host), + pf.local_port, + bracket_ipv6(remote_host), + pf.remote_port ) })); } @@ -1641,7 +1695,12 @@ fn build_command_posix( if let Some((local_port, host, remote_port)) = port_forward { args.push("-L".into()); - args.push(format!("{local_port}:{host}:{remote_port}")); + args.push(format!( + "{}:{}:{}", + local_port, + bracket_ipv6(&host), + remote_port + )); } // -q suppresses the "Connection to ... closed." message that SSH prints when @@ -1731,7 +1790,12 @@ fn build_command_windows( if let Some((local_port, host, remote_port)) = port_forward { args.push("-L".into()); - args.push(format!("{local_port}:{host}:{remote_port}")); + args.push(format!( + "{}:{}:{}", + local_port, + bracket_ipv6(&host), + remote_port + )); } // -q suppresses the "Connection to ... closed." message that SSH prints when @@ -1938,4 +2002,79 @@ mod tests { Ok(()) } + + #[test] + fn test_parse_port_forward_spec_ipv6() -> Result<()> { + let pf = parse_port_forward_spec("[::1]:8080:[::1]:80")?; + assert_eq!(pf.local_host, Some("::1".to_string())); + assert_eq!(pf.local_port, 8080); + assert_eq!(pf.remote_host, Some("::1".to_string())); + assert_eq!(pf.remote_port, 80); + + let pf = parse_port_forward_spec("8080:[::1]:80")?; + assert_eq!(pf.local_host, None); + assert_eq!(pf.local_port, 8080); + assert_eq!(pf.remote_host, Some("::1".to_string())); + assert_eq!(pf.remote_port, 80); + + let pf = parse_port_forward_spec("[2001:db8::1]:3000:[fe80::1]:4000")?; + assert_eq!(pf.local_host, Some("2001:db8::1".to_string())); + assert_eq!(pf.local_port, 3000); + assert_eq!(pf.remote_host, Some("fe80::1".to_string())); + assert_eq!(pf.remote_port, 4000); + + let pf = parse_port_forward_spec("127.0.0.1:8080:localhost:80")?; + assert_eq!(pf.local_host, Some("127.0.0.1".to_string())); + assert_eq!(pf.local_port, 8080); + assert_eq!(pf.remote_host, Some("localhost".to_string())); + assert_eq!(pf.remote_port, 80); + + Ok(()) + } + + #[test] + fn test_port_forward_ipv6_formatting() { + let options = SshConnectionOptions { + host: "example.com".into(), + port_forwards: Some(vec![SshPortForwardOption { + local_host: Some("::1".to_string()), + local_port: 8080, + remote_host: Some("::1".to_string()), + remote_port: 80, + }]), + ..Default::default() + }; + + let args = options.additional_args(); + assert!( + args.iter().any(|arg| arg == "-L[::1]:8080:[::1]:80"), + "expected bracketed IPv6 in -L flag: {args:?}" + ); + } + + #[test] + fn test_build_command_with_ipv6_port_forward() -> Result<()> { + let command = build_command_posix( + None, + &[], + &HashMap::default(), + None, + Some((8080, "::1".to_owned(), 80)), + HashMap::default(), + PathStyle::Posix, + "/bin/bash", + ShellKind::Posix, + vec![], + "user@host", + Interactive::No, + )?; + + assert!( + command.args.iter().any(|arg| arg == "8080:[::1]:80"), + "expected bracketed IPv6 in port forward arg: {:?}", + command.args + ); + + Ok(()) + } } diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index d1430831eef5fe..9208f9f462e515 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -38,7 +38,7 @@ futures.workspace = true git.workspace = true git_hosting_providers.workspace = true git2 = { workspace = true, features = ["vendored-libgit2"] } -gpui.workspace = true +gpui = { workspace = true, features = ["windows-manifest"] } gpui_platform.workspace = true gpui_tokio.workspace = true http_client.workspace = true diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index a744f733e72aef..b3fe30a472c2d0 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -2,10 +2,13 @@ /// The tests in this file assume that server_cx is running on Windows too. /// We neead to find a way to test Windows-Non-Windows interactions. use crate::headless_project::HeadlessProject; -use agent::{AgentTool, ReadFileTool, ReadFileToolInput, Templates, Thread, ToolCallEventStream}; +use agent::{ + AgentTool, ReadFileTool, ReadFileToolInput, Templates, Thread, ToolCallEventStream, ToolInput, +}; use client::{Client, UserStore}; use clock::FakeSystemClock; use collections::{HashMap, HashSet}; +use git::repository::DiffType; use language_model::{LanguageModelToolResultContent, fake_provider::FakeLanguageModel}; use prompt_store::ProjectContext; @@ -1917,6 +1920,129 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA assert_eq!(server_branch.name(), "totally-new-branch"); } +#[gpui::test] +async fn test_remote_git_diff_stat(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { + let fs = FakeFs::new(server_cx.executor()); + fs.insert_tree( + path!("/code"), + json!({ + "project1": { + ".git": {}, + "src": { + "lib.rs": "line1\nline2\nline3\n", + "new_file.rs": "added1\nadded2\n", + }, + "README.md": "# project 1", + }, + }), + ) + .await; + + let dot_git = Path::new(path!("/code/project1/.git")); + + // HEAD: lib.rs (2 lines), deleted.rs (1 line) + fs.set_head_for_repo( + dot_git, + &[ + ("src/lib.rs", "line1\nold_line2\n".into()), + ("src/deleted.rs", "was_here\n".into()), + ], + "deadbeef", + ); + // Index: lib.rs modified (4 lines), staged_only.rs new (2 lines) + fs.set_index_for_repo( + dot_git, + &[ + ("src/lib.rs", "line1\nold_line2\nline3\nline4\n".into()), + ("src/staged_only.rs", "x\ny\n".into()), + ], + ); + + let (project, _headless) = init_test(&fs, cx, server_cx).await; + let (_worktree, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree(path!("/code/project1"), true, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + let repo_path = |s: &str| git::repository::RepoPath::new(s).unwrap(); + + let repository = project.update(cx, |project, cx| project.active_repository(cx).unwrap()); + + // --- HeadToWorktree --- + let stats = cx + .update(|cx| repository.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToWorktree, cx))) + .await + .unwrap() + .unwrap(); + + // src/lib.rs: worktree 3 lines vs HEAD 2 lines + let stat = stats.get(&repo_path("src/lib.rs")).expect("src/lib.rs"); + assert_eq!((stat.added, stat.deleted), (3, 2)); + + // src/new_file.rs: only in worktree (2 lines) + let stat = stats + .get(&repo_path("src/new_file.rs")) + .expect("src/new_file.rs"); + assert_eq!((stat.added, stat.deleted), (2, 0)); + + // src/deleted.rs: only in HEAD (1 line) + let stat = stats + .get(&repo_path("src/deleted.rs")) + .expect("src/deleted.rs"); + assert_eq!((stat.added, stat.deleted), (0, 1)); + + // README.md: only in worktree (1 line) + let stat = stats.get(&repo_path("README.md")).expect("README.md"); + assert_eq!((stat.added, stat.deleted), (1, 0)); + + // --- HeadToIndex --- + let stats = cx + .update(|cx| repository.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToIndex, cx))) + .await + .unwrap() + .unwrap(); + + // src/lib.rs: index 4 lines vs HEAD 2 lines + let stat = stats.get(&repo_path("src/lib.rs")).expect("src/lib.rs"); + assert_eq!((stat.added, stat.deleted), (4, 2)); + + // src/staged_only.rs: only in index (2 lines) + let stat = stats + .get(&repo_path("src/staged_only.rs")) + .expect("src/staged_only.rs"); + assert_eq!((stat.added, stat.deleted), (2, 0)); + + // src/deleted.rs: in HEAD but not in index + let stat = stats + .get(&repo_path("src/deleted.rs")) + .expect("src/deleted.rs"); + assert_eq!((stat.added, stat.deleted), (0, 1)); + + // --- MergeBase (not implemented in FakeGitRepository) --- + let stats = cx + .update(|cx| { + repository.update(cx, |repo, cx| { + repo.diff_stat( + DiffType::MergeBase { + base_ref: "main".into(), + }, + cx, + ) + }) + }) + .await + .unwrap() + .unwrap(); + + assert!( + stats.is_empty(), + "MergeBase diff_stat should return empty from FakeGitRepository" + ); +} + #[gpui::test] async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { let fs = FakeFs::new(server_cx.executor()); @@ -1962,7 +2088,11 @@ async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mu let read_tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); let (event_stream, _) = ToolCallEventStream::test(); - let exists_result = cx.update(|cx| read_tool.clone().run(input, event_stream.clone(), cx)); + let exists_result = cx.update(|cx| { + read_tool + .clone() + .run(ToolInput::resolved(input), event_stream.clone(), cx) + }); let output = exists_result.await.unwrap(); assert_eq!(output, LanguageModelToolResultContent::Text("B".into())); @@ -1971,7 +2101,8 @@ async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mu start_line: None, end_line: None, }; - let does_not_exist_result = cx.update(|cx| read_tool.run(input, event_stream, cx)); + let does_not_exist_result = + cx.update(|cx| read_tool.run(ToolInput::resolved(input), event_stream, cx)); does_not_exist_result.await.unwrap_err(); } @@ -2030,14 +2161,13 @@ async fn test_remote_external_agent_server( .collect::>() }); pretty_assertions::assert_eq!(names, ["gemini", "codex", "claude", "foo"]); - let (command, root, login) = project + let (command, login) = project .update(cx, |project, cx| { project.agent_server_store().update(cx, |store, cx| { store .get_external_agent(&"foo".into()) .unwrap() .get_command( - None, HashMap::from_iter([("OTHER_VAR".into(), "other-val".into())]), None, None, @@ -2058,7 +2188,6 @@ async fn test_remote_external_agent_server( ])) } ); - assert_eq!(&PathBuf::from(root), paths::home_dir()); assert!(login.is_none()); } diff --git a/crates/remote_server/src/server.rs b/crates/remote_server/src/server.rs index 6784f5fc1d2219..bc39e4635e9611 100644 --- a/crates/remote_server/src/server.rs +++ b/crates/remote_server/src/server.rs @@ -356,9 +356,18 @@ fn start_server( let (mut stdin_msg_tx, mut stdin_msg_rx) = mpsc::unbounded::(); cx.background_spawn(async move { - while let Ok(msg) = read_message(&mut stdin_stream, &mut input_buffer).await { - if (stdin_msg_tx.send(msg).await).is_err() { - break; + loop { + match read_message(&mut stdin_stream, &mut input_buffer).await { + Ok(msg) => { + if (stdin_msg_tx.send(msg).await).is_err() { + log::info!("stdin message channel closed, stopping stdin reader"); + break; + } + } + Err(error) => { + log::warn!("stdin read failed: {error:?}"); + break; + } } } }).detach(); diff --git a/crates/repl/src/notebook/cell.rs b/crates/repl/src/notebook/cell.rs index 5559458da4a5c7..d66261698b722c 100644 --- a/crates/repl/src/notebook/cell.rs +++ b/crates/repl/src/notebook/cell.rs @@ -2,11 +2,12 @@ use std::sync::Arc; use std::time::{Duration, Instant}; -use editor::{Editor, EditorMode, MultiBuffer}; +use editor::{Editor, EditorMode, MultiBuffer, SizingBehavior}; use futures::future::Shared; use gpui::{ - App, Entity, EventEmitter, Focusable, Hsla, InteractiveElement, RetainAllImageCache, - StatefulInteractiveElement, Task, TextStyleRefinement, image_cache, prelude::*, + App, Entity, EventEmitter, Focusable, Hsla, InteractiveElement, KeyContext, + RetainAllImageCache, StatefulInteractiveElement, Task, TextStyleRefinement, image_cache, + prelude::*, }; use language::{Buffer, Language, LanguageRegistry}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; @@ -357,9 +358,10 @@ impl MarkdownCell { let editor = cx.new(|cx| { let mut editor = Editor::new( - EditorMode::AutoHeight { - min_lines: 1, - max_lines: Some(1024), + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sizing_behavior: SizingBehavior::SizeByContent, }, multi_buffer, None, @@ -378,6 +380,7 @@ impl MarkdownCell { editor.set_show_gutter(false, cx); editor.set_text_style_refinement(refinement); + editor.set_use_modal_editing(true); editor }); @@ -625,9 +628,10 @@ impl CodeCell { let editor_view = cx.new(|cx| { let mut editor = Editor::new( - EditorMode::AutoHeight { - min_lines: 1, - max_lines: Some(1024), + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sizing_behavior: SizingBehavior::SizeByContent, }, multi_buffer, None, @@ -646,6 +650,7 @@ impl CodeCell { editor.set_show_gutter(false, cx); editor.set_text_style_refinement(refinement); + editor.set_use_modal_editing(true); editor }); @@ -700,9 +705,10 @@ impl CodeCell { let editor_view = cx.new(|cx| { let mut editor = Editor::new( - EditorMode::AutoHeight { - min_lines: 1, - max_lines: Some(1024), + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sizing_behavior: SizingBehavior::SizeByContent, }, multi_buffer, None, @@ -722,6 +728,7 @@ impl CodeCell { editor.set_text(source.clone(), window, cx); editor.set_show_gutter(false, cx); editor.set_text_style_refinement(refinement); + editor.set_use_modal_editing(true); editor }); @@ -1117,71 +1124,6 @@ impl Render for CodeCell { ), ), ) - // Output portion - .child( - h_flex() - .w_full() - .pr_6() - .rounded_xs() - .items_start() - .gap(DynamicSpacing::Base08.rems(cx)) - .bg(self.selected_bg_color(window, cx)) - .child(self.gutter_output(window, cx)) - .child( - div().py_1p5().w_full().child( - div() - .flex() - .size_full() - .flex_1() - .py_3() - .px_5() - .rounded_lg() - .border_1() - .child( - div() - .id((ElementId::from(self.id.to_string()), "output-scroll")) - .w_full() - .when_some(output_max_width, |div, max_w| { - div.max_w(max_w).overflow_x_scroll() - }) - .when_some(output_max_height, |div, max_h| { - div.max_h(max_h).overflow_y_scroll() - }) - .children(self.outputs.iter().map(|output| { - let content = match output { - Output::Plain { content, .. } => { - Some(content.clone().into_any_element()) - } - Output::Markdown { content, .. } => { - Some(content.clone().into_any_element()) - } - Output::Stream { content, .. } => { - Some(content.clone().into_any_element()) - } - Output::Image { content, .. } => { - Some(content.clone().into_any_element()) - } - Output::Message(message) => Some( - div().child(message.clone()).into_any_element(), - ), - Output::Table { content, .. } => { - Some(content.clone().into_any_element()) - } - Output::Json { content, .. } => { - Some(content.clone().into_any_element()) - } - Output::ErrorOutput(error_view) => { - error_view.render(window, cx) - } - Output::ClearOutputWaitMarker => None, - }; - - div().children(content) - })), - ), - ), - ), - ) .when( self.has_outputs() || self.execution_duration.is_some() || self.is_executing, |this| { diff --git a/crates/repl/src/notebook/notebook_ui.rs b/crates/repl/src/notebook/notebook_ui.rs index af8e3e625a4170..5b8c0746cdf128 100644 --- a/crates/repl/src/notebook/notebook_ui.rs +++ b/crates/repl/src/notebook/notebook_ui.rs @@ -5,6 +5,7 @@ use std::{path::PathBuf, sync::Arc}; use anyhow::{Context as _, Result}; use client::proto::ViewId; use collections::HashMap; +use editor::DisplayPoint; use feature_flags::{FeatureFlagAppExt as _, NotebookFeatureFlag}; use futures::FutureExt; use futures::future::Shared; @@ -40,6 +41,7 @@ use picker::Picker; use runtimelib::{ExecuteRequest, JupyterMessage, JupyterMessageContent}; use ui::PopoverMenuHandle; use zed_actions::editor::{MoveDown, MoveUp}; +use zed_actions::notebook::{NotebookMoveDown, NotebookMoveUp}; actions!( notebook, @@ -1295,6 +1297,127 @@ impl Render for NotebookEditor { } } })) + .on_action(cx.listener(|this, _: &NotebookMoveDown, window, cx| { + let Some(cell_id) = this.cell_order.get(this.selected_cell_index) else { + return; + }; + let Some(cell) = this.cell_map.get(cell_id) else { + return; + }; + + let editor = match cell { + Cell::Code(cell) => cell.read(cx).editor().clone(), + Cell::Markdown(cell) => cell.read(cx).editor().clone(), + _ => return, + }; + + let is_at_last_line = editor.update(cx, |editor, cx| { + let display_snapshot = editor.display_snapshot(cx); + let selections = editor.selections.all_display(&display_snapshot); + if let Some(selection) = selections.last() { + let head = selection.head(); + let cursor_row = head.row(); + let max_row = display_snapshot.max_point().row(); + + cursor_row >= max_row + } else { + false + } + }); + + if is_at_last_line { + this.select_next(&menu::SelectNext, window, cx); + if let Some(cell_id) = this.cell_order.get(this.selected_cell_index) { + if let Some(cell) = this.cell_map.get(cell_id) { + match cell { + Cell::Code(cell) => { + let editor = cell.read(cx).editor().clone(); + editor.update(cx, |editor, cx| { + editor.move_to_beginning(&Default::default(), window, cx); + }); + editor.focus_handle(cx).focus(window, cx); + } + Cell::Markdown(cell) => { + cell.update(cx, |cell, cx| { + cell.set_editing(true); + cx.notify(); + }); + let editor = cell.read(cx).editor().clone(); + editor.update(cx, |editor, cx| { + editor.move_to_beginning(&Default::default(), window, cx); + }); + editor.focus_handle(cx).focus(window, cx); + } + _ => {} + } + } + } + } else { + editor.update(cx, |editor, cx| { + editor.move_down(&Default::default(), window, cx); + }); + } + })) + .on_action(cx.listener(|this, _: &NotebookMoveUp, window, cx| { + let Some(cell_id) = this.cell_order.get(this.selected_cell_index) else { + return; + }; + let Some(cell) = this.cell_map.get(cell_id) else { + return; + }; + + let editor = match cell { + Cell::Code(cell) => cell.read(cx).editor().clone(), + Cell::Markdown(cell) => cell.read(cx).editor().clone(), + _ => return, + }; + + let is_at_first_line = editor.update(cx, |editor, cx| { + let display_snapshot = editor.display_snapshot(cx); + let selections = editor.selections.all_display(&display_snapshot); + if let Some(selection) = selections.first() { + let head = selection.head(); + let cursor_row = head.row(); + + cursor_row.0 == 0 + } else { + false + } + }); + + if is_at_first_line { + this.select_previous(&menu::SelectPrevious, window, cx); + if let Some(cell_id) = this.cell_order.get(this.selected_cell_index) { + if let Some(cell) = this.cell_map.get(cell_id) { + match cell { + Cell::Code(cell) => { + let editor = cell.read(cx).editor().clone(); + editor.update(cx, |editor, cx| { + editor.move_to_end(&Default::default(), window, cx); + }); + editor.focus_handle(cx).focus(window, cx); + } + Cell::Markdown(cell) => { + cell.update(cx, |cell, cx| { + cell.set_editing(true); + cx.notify(); + }); + let editor = cell.read(cx).editor().clone(); + editor.update(cx, |editor, cx| { + editor.move_to_end(&Default::default(), window, cx); + }); + editor.focus_handle(cx).focus(window, cx); + } + _ => {} + } + } + } + } else { + editor.update(cx, |editor, cx| { + editor.move_up(&Default::default(), window, cx); + }); + } + })) .on_action( cx.listener(|this, action, window, cx| this.restart_kernel(action, window, cx)), ) diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index d0643be3bbee82..8a5a497d265c02 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -801,6 +801,7 @@ impl VsCodeSettings { starts_open: None, sticky_scroll: None, auto_open: None, + diagnostic_badges: None, }; if let (Some(false), Some(false)) = ( diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index c0d1ff797a0771..3f64c5ef8b081d 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -619,6 +619,11 @@ pub struct GitPanelSettingsContent { /// /// Default: false pub tree_view: Option, + + /// Whether to show the addition/deletion change count next to each file in the Git panel. + /// + /// Default: false + pub diff_stats: Option, } #[derive( diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index 3778ccc0373f4b..7262a83b384665 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -739,6 +739,10 @@ pub struct ProjectPanelSettingsContent { /// /// Default: directories_first pub sort_mode: Option, + /// Whether to show error and warning count badges next to file names in the project panel. + /// + /// Default: true + pub diagnostic_badges: Option, } #[derive( diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 738eff917bc57a..5b3f5480148c30 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -4256,7 +4256,7 @@ fn window_and_layout_page() -> SettingsPage { } fn panels_page() -> SettingsPage { - fn project_panel_section() -> [SettingsPageItem; 21] { + fn project_panel_section() -> [SettingsPageItem; 22] { [ SettingsPageItem::SectionHeader("Project Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -4556,6 +4556,28 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Diagnostic Badges", + description: "Show error and warning count badges next to file names in the project panel.", + field: Box::new(SettingField { + json_path: Some("project_panel.diagnostic_badges"), + pick: |settings_content| { + settings_content + .project_panel + .as_ref()? + .diagnostic_badges + .as_ref() + }, + write: |settings_content, value| { + settings_content + .project_panel + .get_or_insert_default() + .diagnostic_badges = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Sticky Scroll", description: "Whether to stick parent directories at top of the project panel.", @@ -5017,7 +5039,7 @@ fn panels_page() -> SettingsPage { ] } - fn git_panel_section() -> [SettingsPageItem; 10] { + fn git_panel_section() -> [SettingsPageItem; 11] { [ SettingsPageItem::SectionHeader("Git Panel"), SettingsPageItem::SettingItem(SettingItem { @@ -5159,6 +5181,24 @@ fn panels_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Diff Stats", + description: "Whether to show the addition/deletion change count next to each file in the Git panel.", + field: Box::new(SettingField { + json_path: Some("git_panel.diff_stats"), + pick: |settings_content| { + settings_content.git_panel.as_ref()?.diff_stats.as_ref() + }, + write: |settings_content, value| { + settings_content + .git_panel + .get_or_insert_default() + .diff_stats = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Scroll Bar", description: "How and when the scrollbar should be displayed.", diff --git a/crates/terminal/src/mappings/mouse.rs b/crates/terminal/src/mappings/mouse.rs index 8c3eed8b549728..ffd60a83aab24b 100644 --- a/crates/terminal/src/mappings/mouse.rs +++ b/crates/terminal/src/mappings/mouse.rs @@ -1,4 +1,4 @@ -use std::cmp::{self, max, min}; +use std::cmp::{self, min}; use std::iter::repeat; use alacritty_terminal::grid::Dimensions; @@ -93,12 +93,54 @@ pub fn scroll_report( e.modifiers, MouseFormat::from_mode(mode), ) - .map(|report| repeat(report).take(max(scroll_lines, 1) as usize)) + .map(|report| repeat(report).take(scroll_lines.unsigned_abs() as usize)) } else { None } } +#[cfg(test)] +mod tests { + use super::*; + use gpui::{ScrollDelta, TouchPhase, point}; + + #[test] + fn scroll_report_repeats_for_negative_scroll_lines() { + let grid_point = AlacPoint::new(GridLine(0), GridCol(0)); + + let scroll_event = ScrollWheelEvent { + delta: ScrollDelta::Lines(point(0., -1.)), + touch_phase: TouchPhase::Moved, + ..Default::default() + }; + + let mode = TermMode::MOUSE_MODE; + let reports: Vec> = scroll_report(grid_point, -3, &scroll_event, mode) + .expect("mouse mode should produce a scroll report") + .collect(); + + assert_eq!(reports.len(), 3); + } + + #[test] + fn scroll_report_repeats_for_positive_scroll_lines() { + let grid_point = AlacPoint::new(GridLine(0), GridCol(0)); + + let scroll_event = ScrollWheelEvent { + delta: ScrollDelta::Lines(point(0., 1.)), + touch_phase: TouchPhase::Moved, + ..Default::default() + }; + + let mode = TermMode::MOUSE_MODE; + let reports: Vec> = scroll_report(grid_point, 3, &scroll_event, mode) + .expect("mouse mode should produce a scroll report") + .collect(); + + assert_eq!(reports.len(), 3); + } +} + pub fn alt_scroll(scroll_lines: i32) -> Vec { let cmd = if scroll_lines > 0 { b'A' } else { b'B' }; diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 80926f17f0ce5a..88bde3c771f72a 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -397,10 +397,7 @@ impl TerminalPanel { }; panel .update_in(cx, |panel, window, cx| { - panel - .center - .split(&pane, &new_pane, direction, cx) - .log_err(); + panel.center.split(&pane, &new_pane, direction, cx); window.focus(&new_pane.focus_handle(cx), cx); }) .ok(); @@ -424,7 +421,7 @@ impl TerminalPanel { new_pane.update(cx, |pane, cx| { pane.add_item(item, true, true, None, window, cx); }); - self.center.split(&pane, &new_pane, direction, cx).log_err(); + self.center.split(&pane, &new_pane, direction, cx); window.focus(&new_pane.focus_handle(cx), cx); } }; @@ -1303,17 +1300,13 @@ pub fn new_terminal_pane( &new_pane, split_direction, cx, - )?; - anyhow::Ok(new_pane) + ); + new_pane }) else { return; }; - let Some(new_pane) = new_pane.log_err() else { - return; - }; - move_item( &source, &new_pane, @@ -1569,15 +1562,12 @@ impl Render for TerminalPanel { _ = terminal_panel.update_in( cx, |terminal_panel, window, cx| { - terminal_panel - .center - .split( - &terminal_panel.active_pane, - &new_pane, - SplitDirection::Right, - cx, - ) - .log_err(); + terminal_panel.center.split( + &terminal_panel.active_pane, + &new_pane, + SplitDirection::Right, + cx, + ); let new_pane = new_pane.read(cx); window.focus(&new_pane.focus_handle(cx), cx); }, diff --git a/crates/text/src/anchor.rs b/crates/text/src/anchor.rs index 22a5c3090a1ef9..63e0570e91ef08 100644 --- a/crates/text/src/anchor.rs +++ b/crates/text/src/anchor.rs @@ -20,7 +20,7 @@ pub struct Anchor { /// The byte offset into the text inserted in the operation /// at `timestamp`. - pub offset: usize, + pub offset: u32, /// Whether this anchor stays attached to the character *before* or *after* /// the offset. pub bias: Bias, @@ -49,7 +49,7 @@ impl Anchor { pub const MIN: Self = Self { timestamp_replica_id: clock::Lamport::MIN.replica_id, timestamp_value: clock::Lamport::MIN.value, - offset: usize::MIN, + offset: u32::MIN, bias: Bias::Left, buffer_id: None, }; @@ -57,14 +57,14 @@ impl Anchor { pub const MAX: Self = Self { timestamp_replica_id: clock::Lamport::MAX.replica_id, timestamp_value: clock::Lamport::MAX.value, - offset: usize::MAX, + offset: u32::MAX, bias: Bias::Right, buffer_id: None, }; pub fn new( timestamp: clock::Lamport, - offset: usize, + offset: u32, bias: Bias, buffer_id: Option, ) -> Self { @@ -81,7 +81,7 @@ impl Anchor { Self { timestamp_replica_id: clock::Lamport::MIN.replica_id, timestamp_value: clock::Lamport::MIN.value, - offset: usize::MIN, + offset: u32::MIN, bias: Bias::Left, buffer_id: Some(buffer_id), } @@ -91,7 +91,7 @@ impl Anchor { Self { timestamp_replica_id: clock::Lamport::MAX.replica_id, timestamp_value: clock::Lamport::MAX.value, - offset: usize::MAX, + offset: u32::MAX, bias: Bias::Right, buffer_id: Some(buffer_id), } @@ -190,13 +190,13 @@ impl Anchor { pub fn is_min(&self) -> bool { self.timestamp() == clock::Lamport::MIN - && self.offset == usize::MIN + && self.offset == u32::MIN && self.bias == Bias::Left } pub fn is_max(&self) -> bool { self.timestamp() == clock::Lamport::MAX - && self.offset == usize::MAX + && self.offset == u32::MAX && self.bias == Bias::Right } diff --git a/crates/text/src/locator.rs b/crates/text/src/locator.rs index cc94441a3d1ea2..be72c4dd9564d4 100644 --- a/crates/text/src/locator.rs +++ b/crates/text/src/locator.rs @@ -8,18 +8,32 @@ use std::iter; /// /// The initial location for a collection should be `Locator::between(Locator::min(), Locator::max())`, /// leaving room for items to be inserted before and after it. -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Locator(SmallVec<[u64; 4]>); +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Locator(SmallVec<[u64; 2]>); + +impl Clone for Locator { + fn clone(&self) -> Self { + // We manually implement clone to avoid the overhead of SmallVec's clone implementation. + // Using `from_slice` is faster than `clone` for SmallVec as we can use our `Copy` implementation of u64. + Self { + 0: SmallVec::from_slice(&self.0), + } + } + + fn clone_from(&mut self, source: &Self) { + self.0.clone_from(&source.0); + } +} impl Locator { pub const fn min() -> Self { - // SAFETY: 1 is <= 4 - Self(unsafe { SmallVec::from_const_with_len_unchecked([u64::MIN; 4], 1) }) + // SAFETY: 1 is <= 2 + Self(unsafe { SmallVec::from_const_with_len_unchecked([u64::MIN; 2], 1) }) } pub const fn max() -> Self { - // SAFETY: 1 is <= 4 - Self(unsafe { SmallVec::from_const_with_len_unchecked([u64::MAX; 4], 1) }) + // SAFETY: 1 is <= 2 + Self(unsafe { SmallVec::from_const_with_len_unchecked([u64::MAX; 2], 1) }) } pub const fn min_ref() -> &'static Self { @@ -40,6 +54,7 @@ impl Locator { let rhs = rhs.0.iter().copied().chain(iter::repeat(u64::MAX)); let mut location = SmallVec::new(); for (lhs, rhs) in lhs.zip(rhs) { + // This shift is essential! It optimizes for the common case of sequential typing. let mid = lhs + ((rhs.saturating_sub(lhs)) >> 48); location.push(mid); if mid > lhs { @@ -127,4 +142,36 @@ mod tests { ); } } + + // Simulates 100,000 sequential forward appends (the pattern used when + // building a buffer's initial fragments and when + // `push_fragments_for_insertion` chains new text fragments). + #[test] + fn test_sequential_forward_append_stays_at_depth_1() { + let mut prev = Locator::min(); + let max = Locator::max(); + for _ in 0..100_000 { + let loc = Locator::between(&prev, &max); + assert_eq!(loc.len(), 1, "sequential forward append grew past depth 1"); + prev = loc; + } + } + + // Simulates the most common real editing pattern: a fragment is split + // (producing a depth-2 prefix), then 10,000 new fragments are inserted + // sequentially forward within that split region. + #[test] + fn test_typing_at_cursor_stays_at_depth_2() { + let initial = Locator::between(&Locator::min(), &Locator::max()); + let prefix = Locator::between(&Locator::min(), &initial); + assert_eq!(prefix.len(), 2); + + let suffix_id = initial; + let mut prev = prefix; + for _ in 0..10_000 { + let loc = Locator::between(&prev, &suffix_id); + assert_eq!(loc.len(), 2, "forward typing after split grew past depth 2"); + prev = loc; + } + } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index c9e04e407ffdb8..194ac2a40d5ac9 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -810,3 +810,188 @@ fn test_random_concurrent_edits(mut rng: StdRng) { buffer.check_invariants(); } } + +#[test] +fn test_new_normalized_splits_large_base_text() { + // ASCII text that exceeds max_insertion_len + let text = "abcdefghij".repeat(10); // 100 bytes + let rope = Rope::from(text.as_str()); + let buffer = Buffer::new_normalized( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + LineEnding::Unix, + rope, + ); + assert_eq!(buffer.text(), text); + buffer.check_invariants(); + + // Verify anchors at various positions, including across chunk boundaries + for offset in [0, 1, 15, 16, 17, 50, 99] { + let anchor = buffer.anchor_before(offset); + assert_eq!( + anchor.to_offset(&buffer), + offset, + "anchor_before({offset}) round-tripped incorrectly" + ); + let anchor = buffer.anchor_after(offset); + assert_eq!( + anchor.to_offset(&buffer), + offset, + "anchor_after({offset}) round-tripped incorrectly" + ); + } + + // Verify editing works after a split initialization + let mut buffer = buffer; + buffer.edit([(50..60, "XYZ")]); + let mut expected = text; + expected.replace_range(50..60, "XYZ"); + assert_eq!(buffer.text(), expected); + buffer.check_invariants(); +} + +#[test] +fn test_new_normalized_splits_large_base_text_with_multibyte_chars() { + // Use multi-byte chars (é is 2 bytes in UTF-8) so that a naive byte-level + // split would land in the middle of a character. + let unit = "ééééééééé"; // 9 chars × 2 bytes = 18 bytes + let text = unit.repeat(6); // 108 bytes + let rope = Rope::from(text.as_str()); + let buffer = Buffer::new_normalized( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + LineEnding::Unix, + rope, + ); + assert_eq!(buffer.text(), text); + buffer.check_invariants(); + + // Every anchor should resolve correctly even though chunks had to be + // rounded down to a char boundary. + let snapshot = buffer.snapshot(); + for offset in (0..text.len()).filter(|o| text.is_char_boundary(*o)) { + let anchor = snapshot.anchor_before(offset); + assert_eq!( + anchor.to_offset(snapshot), + offset, + "anchor round-trip failed at byte offset {offset}" + ); + } +} + +#[test] +fn test_new_normalized_small_text_unchanged() { + // Text that fits in a single chunk should produce exactly one fragment, + // matching the original single-fragment behaviour. + let text = "hello world"; + let rope = Rope::from(text); + let buffer = Buffer::new_normalized( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + LineEnding::Unix, + rope, + ); + assert_eq!(buffer.text(), text); + buffer.check_invariants(); + assert_eq!(buffer.snapshot().fragments.items(&None).len(), 1); +} + +#[test] +fn test_edit_splits_large_insertion() { + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), "abcdefghij"); + + let large_text: Arc = "X".repeat(100).into(); + let edits = vec![(3..7, large_text.clone())]; + + buffer.edit(edits); + + let expected = format!("abc{}hij", large_text); + assert_eq!(buffer.text(), expected); + buffer.check_invariants(); + + // Anchors should resolve correctly throughout the buffer. + for offset in [0, 3, 50, 103, expected.len()] { + let anchor = buffer.anchor_before(offset); + assert_eq!( + anchor.to_offset(&buffer), + offset, + "anchor_before({offset}) round-tripped incorrectly" + ); + } +} + +#[test] +fn test_edit_splits_large_insertion_with_multibyte_chars() { + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), "abcdefghij"); + + // 4-byte chars so that naive byte splits would land mid-character. + let large_text: Arc = "😀".repeat(30).into(); // 30 × 4 = 120 bytes + let edits = vec![(5..5, large_text.clone())]; + + buffer.edit(edits); + + let expected = format!("abcde{}fghij", large_text); + assert_eq!(buffer.text(), expected); + buffer.check_invariants(); +} + +#[test] +fn test_edit_splits_large_insertion_among_multiple_edits() { + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), "ABCDEFGHIJ"); + + let large_text: Arc = "x".repeat(60).into(); + // Three edits: small, large, small. The large one must be split while + // preserving the correct positions of the surrounding edits. + let edits = vec![ + (1..2, Arc::from("y")), // replace "B" with "y" + (4..6, large_text.clone()), // replace "EF" with 60 x's + (9..9, Arc::from("z")), // insert "z" before "J" + ]; + + buffer.edit(edits); + + // Original: A B C D E F G H I J + // After (1..2, "y"): A y C D E F G H I J + // After (4..6, large): A y C D <60 x's> G H I J + // After (9..9, "z"): A y C D <60 x's> G H I z J + let expected = format!("AyCD{}GHIzJ", large_text); + assert_eq!(buffer.text(), expected); + buffer.check_invariants(); +} + +#[test] +fn test_edit_splits_multiple_large_insertions() { + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), "ABCDE"); + + let text1: Arc = "a".repeat(40).into(); + let text2: Arc = "b".repeat(40).into(); + let edits = vec![ + (1..2, text1.clone()), // replace "B" with 40 a's + (3..4, text2.clone()), // replace "D" with 40 b's + ]; + + buffer.edit(edits); + + let expected = format!("A{}C{}E", text1, text2); + assert_eq!(buffer.text(), expected); + buffer.check_invariants(); +} + +#[test] +fn test_edit_undo_after_split() { + let mut buffer = Buffer::new(ReplicaId::LOCAL, BufferId::new(1).unwrap(), "hello world"); + buffer.set_group_interval(Duration::from_secs(0)); + let original = buffer.text(); + + let large_text: Arc = "Z".repeat(50).into(); + let edits = vec![(5..6, large_text)]; + buffer.edit(edits); + assert_ne!(buffer.text(), original); + buffer.check_invariants(); + + // Undo should restore the original text even though the edit was split + // into multiple internal operations grouped in one transaction. + buffer.undo(); + assert_eq!(buffer.text(), original); + buffer.check_invariants(); +} diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index b3a51e68ca21fc..a5bdbe443bbaa4 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -48,6 +48,12 @@ use util::RandomCharIter; static LINE_SEPARATORS_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"\r\n|\r").expect("Failed to create LINE_SEPARATORS_REGEX")); +/// The maximum length of a single insertion operation. +/// Fragments larger than this will be split into multiple smaller +/// fragments. This allows us to use relative `u32` offsets instead of `usize`, +/// reducing memory usage. +const MAX_INSERTION_LEN: usize = if cfg!(test) { 16 } else { u32::MAX as usize }; + pub type TransactionId = clock::Lamport; pub struct Buffer { @@ -155,18 +161,38 @@ struct History { #[derive(Clone, Debug, Eq, PartialEq)] struct InsertionSlice { - edit_id: clock::Lamport, - insertion_id: clock::Lamport, - range: Range, + // Inline the lamports to allow the replica ids to share the same alignment + // saving 4 bytes space edit_id: clock::Lamport, + edit_id_value: clock::Seq, + edit_id_replica_id: ReplicaId, + // insertion_id: clock::Lamport, + insertion_id_value: clock::Seq, + insertion_id_replica_id: ReplicaId, + range: Range, } impl Ord for InsertionSlice { fn cmp(&self, other: &Self) -> Ordering { - self.edit_id - .cmp(&other.edit_id) - .then_with(|| self.insertion_id.cmp(&other.insertion_id)) - .then_with(|| self.range.start.cmp(&other.range.start)) - .then_with(|| self.range.end.cmp(&other.range.end)) + Lamport { + value: self.edit_id_value, + replica_id: self.edit_id_replica_id, + } + .cmp(&Lamport { + value: other.edit_id_value, + replica_id: other.edit_id_replica_id, + }) + .then_with(|| { + Lamport { + value: self.insertion_id_value, + replica_id: self.insertion_id_replica_id, + } + .cmp(&Lamport { + value: other.insertion_id_value, + replica_id: other.insertion_id_replica_id, + }) + }) + .then_with(|| self.range.start.cmp(&other.range.start)) + .then_with(|| self.range.end.cmp(&other.range.end)) } } @@ -179,8 +205,10 @@ impl PartialOrd for InsertionSlice { impl InsertionSlice { fn from_fragment(edit_id: clock::Lamport, fragment: &Fragment) -> Self { Self { - edit_id, - insertion_id: fragment.timestamp, + edit_id_value: edit_id.value, + edit_id_replica_id: edit_id.replica_id, + insertion_id_value: fragment.timestamp.value, + insertion_id_replica_id: fragment.timestamp.replica_id, range: fragment.insertion_offset..fragment.insertion_offset + fragment.len, } } @@ -309,6 +337,7 @@ impl History { fn finalize_last_transaction(&mut self) -> Option<&Transaction> { self.undo_stack.last_mut().map(|entry| { + entry.transaction.edit_ids.shrink_to_fit(); entry.suppress_grouping = true; &entry.transaction }) @@ -489,7 +518,7 @@ struct Edits<'a, D: TextDimension, F: FnMut(&FragmentSummary) -> bool> { since: &'a clock::Global, old_end: D, new_end: D, - range: Range<(&'a Locator, usize)>, + range: Range<(&'a Locator, u32)>, buffer_id: BufferId, } @@ -536,18 +565,18 @@ impl Edit<(D1, D2)> { } #[derive(Eq, PartialEq, Clone, Debug)] -pub struct Fragment { - pub id: Locator, - pub timestamp: clock::Lamport, - pub insertion_offset: usize, - pub len: usize, - pub visible: bool, - pub deletions: SmallVec<[clock::Lamport; 2]>, - pub max_undos: clock::Global, +struct Fragment { + id: Locator, + timestamp: clock::Lamport, + insertion_offset: u32, + len: u32, + visible: bool, + deletions: SmallVec<[clock::Lamport; 2]>, + max_undos: clock::Global, } #[derive(Eq, PartialEq, Clone, Debug)] -pub struct FragmentSummary { +struct FragmentSummary { text: FragmentTextSummary, max_id: Locator, max_version: clock::Global, @@ -575,14 +604,14 @@ impl<'a> sum_tree::Dimension<'a, FragmentSummary> for FragmentTextSummary { #[derive(Eq, PartialEq, Clone, Debug)] struct InsertionFragment { timestamp: clock::Lamport, - split_offset: usize, + split_offset: u32, fragment_id: Locator, } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] struct InsertionFragmentKey { timestamp: clock::Lamport, - split_offset: usize, + split_offset: u32, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -740,18 +769,37 @@ impl Buffer { let insertion_timestamp = clock::Lamport::new(ReplicaId::LOCAL); lamport_clock.observe(insertion_timestamp); version.observe(insertion_timestamp); - let fragment_id = Locator::between(&Locator::min(), &Locator::max()); - let fragment = Fragment { - id: fragment_id, - timestamp: insertion_timestamp, - insertion_offset: 0, - len: visible_text.len(), - visible: true, - deletions: Default::default(), - max_undos: Default::default(), - }; - insertions.push(InsertionFragment::new(&fragment), ()); - fragments.push(fragment, &None); + + let mut insertion_offset: u32 = 0; + let mut text_offset: usize = 0; + let mut prev_locator = Locator::min(); + + while text_offset < visible_text.len() { + let target_end = visible_text.len().min(text_offset + MAX_INSERTION_LEN); + let chunk_end = if target_end == visible_text.len() { + target_end + } else { + visible_text.floor_char_boundary(target_end) + }; + let chunk_len = chunk_end - text_offset; + + let fragment_id = Locator::between(&prev_locator, &Locator::max()); + let fragment = Fragment { + id: fragment_id.clone(), + timestamp: insertion_timestamp, + insertion_offset, + len: chunk_len as u32, + visible: true, + deletions: Default::default(), + max_undos: Default::default(), + }; + insertions.push(InsertionFragment::new(&fragment), ()); + fragments.push(fragment, &None); + + prev_locator = fragment_id; + insertion_offset += chunk_len as u32; + text_offset = chunk_end; + } } Buffer { @@ -853,7 +901,7 @@ impl Buffer { new_text: Vec::with_capacity(edits.len()), }; let mut new_insertions = Vec::new(); - let mut insertion_offset = 0; + let mut insertion_offset: u32 = 0; let mut insertion_slices = Vec::new(); let mut edits = edits @@ -879,8 +927,9 @@ impl Buffer { if fragment_start > old_fragments.start().visible { if fragment_end > fragment_start { let mut suffix = old_fragments.item().unwrap().clone(); - suffix.len = fragment_end - fragment_start; - suffix.insertion_offset += fragment_start - old_fragments.start().visible; + suffix.len = (fragment_end - fragment_start) as u32; + suffix.insertion_offset += + (fragment_start - old_fragments.start().visible) as u32; new_insertions.push(InsertionFragment::insert_new(&suffix)); new_ropes.push_fragment(&suffix, suffix.visible); new_fragments.push(suffix, &None); @@ -899,8 +948,8 @@ impl Buffer { // Preserve any portion of the current fragment that precedes this range. if fragment_start < range.start { let mut prefix = old_fragments.item().unwrap().clone(); - prefix.len = range.start - fragment_start; - prefix.insertion_offset += fragment_start - old_fragments.start().visible; + prefix.len = (range.start - fragment_start) as u32; + prefix.insertion_offset += (fragment_start - old_fragments.start().visible) as u32; prefix.id = Locator::between(&new_fragments.summary().max_id, &prefix.id); new_insertions.push(InsertionFragment::insert_new(&prefix)); new_ropes.push_fragment(&prefix, prefix.visible); @@ -912,29 +961,24 @@ impl Buffer { if !new_text.is_empty() { let new_start = new_fragments.summary().text.visible; - let fragment = Fragment { - id: Locator::between( - &new_fragments.summary().max_id, - old_fragments - .item() - .map_or(&Locator::max(), |old_fragment| &old_fragment.id), - ), + let next_fragment_id = old_fragments + .item() + .map_or(Locator::max_ref(), |old_fragment| &old_fragment.id); + Self::push_fragments_for_insertion( + new_text.as_ref(), timestamp, - insertion_offset, - len: new_text.len(), - deletions: Default::default(), - max_undos: Default::default(), - visible: true, - }; + &mut insertion_offset, + &mut new_fragments, + &mut new_insertions, + &mut insertion_slices, + &mut new_ropes, + next_fragment_id, + timestamp, + ); edits_patch.push(Edit { old: fragment_start..fragment_start, new: new_start..new_start + new_text.len(), }); - insertion_slices.push(InsertionSlice::from_fragment(timestamp, &fragment)); - new_insertions.push(InsertionFragment::insert_new(&fragment)); - new_ropes.push_str(new_text.as_ref()); - new_fragments.push(fragment, &None); - insertion_offset += new_text.len(); } // Advance through every fragment that intersects this range, marking the intersecting @@ -945,8 +989,9 @@ impl Buffer { let mut intersection = fragment.clone(); let intersection_end = cmp::min(range.end, fragment_end); if fragment.visible { - intersection.len = intersection_end - fragment_start; - intersection.insertion_offset += fragment_start - old_fragments.start().visible; + intersection.len = (intersection_end - fragment_start) as u32; + intersection.insertion_offset += + (fragment_start - old_fragments.start().visible) as u32; intersection.id = Locator::between(&new_fragments.summary().max_id, &intersection.id); intersection.deletions.push(timestamp); @@ -983,8 +1028,8 @@ impl Buffer { let fragment_end = old_fragments.end().visible; if fragment_end > fragment_start { let mut suffix = old_fragments.item().unwrap().clone(); - suffix.len = fragment_end - fragment_start; - suffix.insertion_offset += fragment_start - old_fragments.start().visible; + suffix.len = (fragment_end - fragment_start) as u32; + suffix.insertion_offset += (fragment_start - old_fragments.start().visible) as u32; new_insertions.push(InsertionFragment::insert_new(&suffix)); new_ropes.push_fragment(&suffix, suffix.visible); new_fragments.push(suffix, &None); @@ -1075,7 +1120,7 @@ impl Buffer { let mut insertion_slices = Vec::new(); let cx = Some(version.clone()); let mut new_insertions = Vec::new(); - let mut insertion_offset = 0; + let mut insertion_offset: u32 = 0; let mut new_ropes = RopeBuilder::new(self.visible_text.cursor(0), self.deleted_text.cursor(0)); let mut old_fragments = self @@ -1097,9 +1142,9 @@ impl Buffer { if fragment_start > old_fragments.start().0.full_offset() { if fragment_end > fragment_start { let mut suffix = old_fragments.item().unwrap().clone(); - suffix.len = fragment_end.0 - fragment_start.0; + suffix.len = (fragment_end.0 - fragment_start.0) as u32; suffix.insertion_offset += - fragment_start - old_fragments.start().0.full_offset(); + (fragment_start - old_fragments.start().0.full_offset()) as u32; new_insertions.push(InsertionFragment::insert_new(&suffix)); new_ropes.push_fragment(&suffix, suffix.visible); new_fragments.push(suffix, &None); @@ -1118,8 +1163,9 @@ impl Buffer { let fragment_end = old_fragments.end().0.full_offset(); if fragment_end == range.start && fragment_end > fragment_start { let mut fragment = old_fragments.item().unwrap().clone(); - fragment.len = fragment_end.0 - fragment_start.0; - fragment.insertion_offset += fragment_start - old_fragments.start().0.full_offset(); + fragment.len = (fragment_end.0 - fragment_start.0) as u32; + fragment.insertion_offset += + (fragment_start - old_fragments.start().0.full_offset()) as u32; new_insertions.push(InsertionFragment::insert_new(&fragment)); new_ropes.push_fragment(&fragment, fragment.visible); new_fragments.push(fragment, &None); @@ -1144,8 +1190,9 @@ impl Buffer { // Preserve any portion of the current fragment that precedes this range. if fragment_start < range.start { let mut prefix = old_fragments.item().unwrap().clone(); - prefix.len = range.start.0 - fragment_start.0; - prefix.insertion_offset += fragment_start - old_fragments.start().0.full_offset(); + prefix.len = (range.start.0 - fragment_start.0) as u32; + prefix.insertion_offset += + (fragment_start - old_fragments.start().0.full_offset()) as u32; prefix.id = Locator::between(&new_fragments.summary().max_id, &prefix.id); new_insertions.push(InsertionFragment::insert_new(&prefix)); fragment_start = range.start; @@ -1160,29 +1207,24 @@ impl Buffer { old_start += fragment_start.0 - old_fragments.start().0.full_offset().0; } let new_start = new_fragments.summary().text.visible; - let fragment = Fragment { - id: Locator::between( - &new_fragments.summary().max_id, - old_fragments - .item() - .map_or(&Locator::max(), |old_fragment| &old_fragment.id), - ), + let next_fragment_id = old_fragments + .item() + .map_or(Locator::max_ref(), |old_fragment| &old_fragment.id); + Self::push_fragments_for_insertion( + new_text, timestamp, - insertion_offset, - len: new_text.len(), - deletions: Default::default(), - max_undos: Default::default(), - visible: true, - }; + &mut insertion_offset, + &mut new_fragments, + &mut new_insertions, + &mut insertion_slices, + &mut new_ropes, + next_fragment_id, + timestamp, + ); edits_patch.push(Edit { old: old_start..old_start, new: new_start..new_start + new_text.len(), }); - insertion_slices.push(InsertionSlice::from_fragment(timestamp, &fragment)); - new_insertions.push(InsertionFragment::insert_new(&fragment)); - new_ropes.push_str(new_text); - new_fragments.push(fragment, &None); - insertion_offset += new_text.len(); } // Advance through every fragment that intersects this range, marking the intersecting @@ -1193,9 +1235,9 @@ impl Buffer { let mut intersection = fragment.clone(); let intersection_end = cmp::min(range.end, fragment_end); if fragment.was_visible(version, &self.undo_map) { - intersection.len = intersection_end.0 - fragment_start.0; + intersection.len = (intersection_end.0 - fragment_start.0) as u32; intersection.insertion_offset += - fragment_start - old_fragments.start().0.full_offset(); + (fragment_start - old_fragments.start().0.full_offset()) as u32; intersection.id = Locator::between(&new_fragments.summary().max_id, &intersection.id); intersection.deletions.push(timestamp); @@ -1208,7 +1250,7 @@ impl Buffer { + (fragment_start.0 - old_fragments.start().0.full_offset().0); let new_start = new_fragments.summary().text.visible; edits_patch.push(Edit { - old: old_start..old_start + intersection.len, + old: old_start..old_start + intersection.len as usize, new: new_start..new_start, }); } @@ -1229,8 +1271,9 @@ impl Buffer { let fragment_end = old_fragments.end().0.full_offset(); if fragment_end > fragment_start { let mut suffix = old_fragments.item().unwrap().clone(); - suffix.len = fragment_end.0 - fragment_start.0; - suffix.insertion_offset += fragment_start - old_fragments.start().0.full_offset(); + suffix.len = (fragment_end.0 - fragment_start.0) as u32; + suffix.insertion_offset += + (fragment_start - old_fragments.start().0.full_offset()) as u32; new_insertions.push(InsertionFragment::insert_new(&suffix)); new_ropes.push_fragment(&suffix, suffix.visible); new_fragments.push(suffix, &None); @@ -1252,6 +1295,49 @@ impl Buffer { self.subscriptions.publish_mut(&edits_patch) } + fn push_fragments_for_insertion( + new_text: &str, + timestamp: clock::Lamport, + insertion_offset: &mut u32, + new_fragments: &mut SumTree, + new_insertions: &mut Vec>, + insertion_slices: &mut Vec, + new_ropes: &mut RopeBuilder, + next_fragment_id: &Locator, + edit_timestamp: clock::Lamport, + ) { + let mut text_offset = 0; + while text_offset < new_text.len() { + let target_end = new_text.len().min(text_offset + MAX_INSERTION_LEN); + let chunk_end = if target_end == new_text.len() { + target_end + } else { + new_text.floor_char_boundary(target_end) + }; + if chunk_end == text_offset { + break; + } + let chunk_len = chunk_end - text_offset; + + let fragment = Fragment { + id: Locator::between(&new_fragments.summary().max_id, next_fragment_id), + timestamp, + insertion_offset: *insertion_offset, + len: chunk_len as u32, + deletions: Default::default(), + max_undos: Default::default(), + visible: true, + }; + insertion_slices.push(InsertionSlice::from_fragment(edit_timestamp, &fragment)); + new_insertions.push(InsertionFragment::insert_new(&fragment)); + new_fragments.push(fragment, &None); + + *insertion_offset += chunk_len as u32; + text_offset = chunk_end; + } + new_ropes.push_str(new_text); + } + fn fragment_ids_for_edits<'a>( &'a self, edit_ids: impl Iterator, @@ -1260,38 +1346,56 @@ impl Buffer { let mut insertion_slices = Vec::new(); for edit_id in edit_ids { let insertion_slice = InsertionSlice { - edit_id: *edit_id, - insertion_id: clock::Lamport::MIN, + edit_id_value: edit_id.value, + edit_id_replica_id: edit_id.replica_id, + insertion_id_value: Lamport::MIN.value, + insertion_id_replica_id: Lamport::MIN.replica_id, range: 0..0, }; let slices = self .snapshot .insertion_slices .iter_from(&insertion_slice) - .take_while(|slice| slice.edit_id == *edit_id); + .take_while(|slice| { + Lamport { + value: slice.edit_id_value, + replica_id: slice.edit_id_replica_id, + } == *edit_id + }); insertion_slices.extend(slices) } - insertion_slices - .sort_unstable_by_key(|s| (s.insertion_id, s.range.start, Reverse(s.range.end))); + insertion_slices.sort_unstable_by_key(|s| { + ( + Lamport { + value: s.insertion_id_value, + replica_id: s.insertion_id_replica_id, + }, + s.range.start, + Reverse(s.range.end), + ) + }); // Get all of the fragments corresponding to these insertion slices. let mut fragment_ids = Vec::new(); let mut insertions_cursor = self.insertions.cursor::(()); for insertion_slice in &insertion_slices { - if insertion_slice.insertion_id != insertions_cursor.start().timestamp + let insertion_id = Lamport { + value: insertion_slice.insertion_id_value, + replica_id: insertion_slice.insertion_id_replica_id, + }; + if insertion_id != insertions_cursor.start().timestamp || insertion_slice.range.start > insertions_cursor.start().split_offset { insertions_cursor.seek_forward( &InsertionFragmentKey { - timestamp: insertion_slice.insertion_id, + timestamp: insertion_id, split_offset: insertion_slice.range.start, }, Bias::Left, ); } while let Some(item) = insertions_cursor.item() { - if item.timestamp != insertion_slice.insertion_id - || item.split_offset >= insertion_slice.range.end + if item.timestamp != insertion_id || item.split_offset >= insertion_slice.range.end { break; } @@ -1330,13 +1434,13 @@ impl Buffer { let new_start = new_fragments.summary().text.visible; if fragment_was_visible && !fragment.visible { edits.push(Edit { - old: old_start..old_start + fragment.len, + old: old_start..old_start + fragment.len as usize, new: new_start..new_start, }); } else if !fragment_was_visible && fragment.visible { edits.push(Edit { old: old_start..old_start, - new: new_start..new_start + fragment.len, + new: new_start..new_start + fragment.len as usize, }); } new_ropes.push_fragment(&fragment, fragment_was_visible); @@ -1582,7 +1686,12 @@ impl Buffer { cursor.seek_forward(&Some(fragment_id), Bias::Left); let fragment = cursor.item()?; let start_offset = cursor.start().1; - let end_offset = start_offset + if fragment.visible { fragment.len } else { 0 }; + let end_offset = start_offset + + if fragment.visible { + fragment.len as usize + } else { + 0 + }; Some(start_offset..end_offset) }); @@ -2038,10 +2147,6 @@ impl BufferSnapshot { self.deleted_text.to_string() } - pub fn fragments(&self) -> impl Iterator { - self.fragments.iter() - } - pub fn text_summary(&self) -> TextSummary { self.visible_text.summary() } @@ -2287,7 +2392,7 @@ impl BufferSnapshot { let fragment = fragment_cursor.item().unwrap(); let mut fragment_offset = fragment_cursor.start().1; if fragment.visible { - fragment_offset += anchor.offset - insertion.split_offset; + fragment_offset += (anchor.offset - insertion.split_offset) as usize; } position.add_assign(&text_cursor.summary(fragment_offset)); @@ -2332,7 +2437,7 @@ impl BufferSnapshot { let fragment = item.unwrap(); let mut fragment_offset = start.1; if fragment.visible { - fragment_offset += anchor.offset - insertion.split_offset; + fragment_offset += (anchor.offset - insertion.split_offset) as usize; } fragment_offset } @@ -2403,13 +2508,13 @@ impl BufferSnapshot { } } - /// Returns an anchor range for the given input position range that is anchored to the text inbetween. - pub fn anchor_range_between(&self, position: Range) -> Range { - self.anchor_before(position.start)..self.anchor_after(position.end) + /// Returns an anchor range for the given input position range that is anchored to the text in the range. + pub fn anchor_range_around(&self, position: Range) -> Range { + self.anchor_after(position.start)..self.anchor_before(position.end) } - /// Returns an anchor range for the given input position range that is anchored to the text before the start position and after the end position. - pub fn anchor_range_around(&self, position: Range) -> Range { + /// Returns an anchor range for the given input position range that is anchored to the text before and after. + pub fn anchor_range_between(&self, position: Range) -> Range { self.anchor_before(position.start)..self.anchor_after(position.end) } @@ -2457,7 +2562,7 @@ impl BufferSnapshot { let overshoot = offset - start; Anchor::new( fragment.timestamp, - fragment.insertion_offset + overshoot, + fragment.insertion_offset + overshoot as u32, bias, Some(self.remote_id), ) @@ -2546,7 +2651,7 @@ impl BufferSnapshot { let mut visible_start = start.1.visible; let mut deleted_start = start.1.deleted; if let Some(fragment) = item { - let overshoot = range.start.offset - fragment.insertion_offset; + let overshoot = (range.start.offset - fragment.insertion_offset) as usize; if fragment.visible { visible_start += overshoot; } else { @@ -2706,7 +2811,7 @@ impl<'a> RopeBuilder<'a> { fn push_fragment(&mut self, fragment: &Fragment, was_visible: bool) { debug_assert!(fragment.len > 0); - self.push(fragment.len, was_visible, fragment.visible) + self.push(fragment.len as usize, was_visible, fragment.visible) } fn push(&mut self, len: usize, was_visible: bool, is_visible: bool) { @@ -2781,7 +2886,8 @@ impl bool> Iterator for Ed if fragment.id == *self.range.end.0 { visible_end = cmp::min( visible_end, - cursor.start().visible + (self.range.end.1 - fragment.insertion_offset), + cursor.start().visible + + (self.range.end.1 - fragment.insertion_offset) as usize, ); } @@ -2807,7 +2913,8 @@ impl bool> Iterator for Ed if fragment.id == *self.range.end.0 { deleted_end = cmp::min( deleted_end, - cursor.start().deleted + (self.range.end.1 - fragment.insertion_offset), + cursor.start().deleted + + (self.range.end.1 - fragment.insertion_offset) as usize, ); } @@ -2872,7 +2979,7 @@ impl sum_tree::Item for Fragment { FragmentSummary { max_id: self.id.clone(), text: FragmentTextSummary { - visible: self.len, + visible: self.len as usize, deleted: 0, }, max_version, @@ -2884,7 +2991,7 @@ impl sum_tree::Item for Fragment { max_id: self.id.clone(), text: FragmentTextSummary { visible: 0, - deleted: self.len, + deleted: self.len as usize, }, max_version, min_insertion_version, diff --git a/crates/title_bar/src/collab.rs b/crates/title_bar/src/collab.rs index c5071d1fff466a..0f4d5977947fa2 100644 --- a/crates/title_bar/src/collab.rs +++ b/crates/title_bar/src/collab.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use std::sync::Arc; -use call::{ActiveCall, ParticipantLocation, Room}; +use call::{ActiveCall, Room}; use channel::ChannelStore; use client::{User, proto::PeerId}; use gpui::{ @@ -18,7 +18,7 @@ use ui::{ Facepile, PopoverMenu, SplitButton, SplitButtonStyle, TintColor, Tooltip, prelude::*, }; use util::rel_path::RelPath; -use workspace::notifications::DetachAndPromptErr; +use workspace::{ParticipantLocation, notifications::DetachAndPromptErr}; use crate::TitleBar; @@ -143,7 +143,6 @@ impl TitleBar { h_flex() .id("collaborator-list") - .occlude() .w_full() .gap_1() .overflow_x_scroll() @@ -227,6 +226,7 @@ impl TitleBar { .ok(); }) }) + .occlude() .tooltip({ let login = collaborator.user.github_login.clone(); Tooltip::text(format!("Follow {login}")) diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 32841bb16732cd..3c8091a1938c5e 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -32,7 +32,9 @@ use gpui::{ StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div, }; use onboarding_banner::OnboardingBanner; -use project::{Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees}; +use project::{ + DisableAiSettings, Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees, +}; use remote::RemoteConnectionOptions; use settings::Settings; use settings::WorktreeId; @@ -688,7 +690,7 @@ impl TitleBar { _window: &mut Window, cx: &mut Context, ) -> Option { - if !cx.has_flag::() { + if !cx.has_flag::() || DisableAiSettings::get_global(cx).disable_ai { return None; } diff --git a/crates/util/src/process.rs b/crates/util/src/process.rs index 6c3d4e0c41eaea..eaf543dbd817ba 100644 --- a/crates/util/src/process.rs +++ b/crates/util/src/process.rs @@ -36,7 +36,12 @@ impl Child { .stdout(stdout) .stderr(stderr) .spawn() - .with_context(|| format!("failed to spawn command {command:?}"))?; + .with_context(|| { + format!( + "failed to spawn command {}", + crate::redact::redact_command(&format!("{command:?}")) + ) + })?; Ok(Self { process }) } @@ -55,7 +60,12 @@ impl Child { .stdout(stdout) .stderr(stderr) .spawn() - .with_context(|| format!("failed to spawn command {command:?}"))?; + .with_context(|| { + format!( + "failed to spawn command {}", + crate::redact::redact_command(&format!("{command:?}")) + ) + })?; Ok(Self { process }) } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 3d9146250cd1df..dcd0bf640fdf27 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -30,7 +30,6 @@ test-support = [ any_vec.workspace = true anyhow.workspace = true async-recursion.workspace = true -call.workspace = true client.workspace = true chrono.workspace = true clock.workspace = true diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 4153373fdb0e10..b29e02f05b367b 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -219,6 +219,7 @@ pub trait Item: Focusable + EventEmitter + Render + Sized { fn discarded(&self, _project: Entity, _window: &mut Window, _cx: &mut Context) {} fn on_removed(&self, _cx: &mut Context) {} fn workspace_deactivated(&mut self, _window: &mut Window, _: &mut Context) {} + fn pane_changed(&mut self, _new_pane_id: EntityId, _cx: &mut Context) {} fn navigate( &mut self, _: Arc, @@ -737,11 +738,22 @@ impl ItemHandle for Entity { .log_err(); } - if workspace + let new_pane_id = pane.entity_id(); + let old_item_pane = workspace .panes_by_item - .insert(self.item_id(), pane.downgrade()) - .is_none() - { + .insert(self.item_id(), pane.downgrade()); + + if old_item_pane.as_ref().is_none_or(|old_pane| { + old_pane + .upgrade() + .is_some_and(|old_pane| old_pane.entity_id() != new_pane_id) + }) { + self.update(cx, |this, cx| { + this.pane_changed(new_pane_id, cx); + }); + } + + if old_item_pane.is_none() { let mut pending_autosave = DelayedDebouncedEditAction::new(); let (pending_update_tx, mut pending_update_rx) = mpsc::unbounded(); let pending_update = Rc::new(RefCell::new(None)); diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 478e33418c68ef..1c1426ee56f1a4 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -5,7 +5,8 @@ use gpui::{ ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId, actions, deferred, px, }; -use project::Project; +use project::{DisableAiSettings, Project}; +use settings::Settings; use std::future::Future; use std::path::PathBuf; use ui::prelude::*; @@ -14,8 +15,8 @@ use util::ResultExt; const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0); use crate::{ - DockPosition, Item, ModalView, Panel, Toast, Workspace, WorkspaceId, client_side_decorations, - notifications::NotificationId, + CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, Panel, Toast, + Workspace, WorkspaceId, client_side_decorations, notifications::NotificationId, }; actions!( @@ -122,6 +123,13 @@ impl MultiWorkspace { } }); let quit_subscription = cx.on_app_quit(Self::app_will_quit); + let settings_subscription = + cx.observe_global_in::(window, |this, window, cx| { + if DisableAiSettings::get_global(cx).disable_ai && this.sidebar_open { + this.close_sidebar(window, cx); + } + }); + Self::subscribe_to_workspace(&workspace, cx); Self { window_id: window.window_handle().window_id(), workspaces: vec![workspace], @@ -132,7 +140,11 @@ impl MultiWorkspace { pending_removal_tasks: Vec::new(), _serialize_task: None, _create_task: None, - _subscriptions: vec![release_subscription, quit_subscription], + _subscriptions: vec![ + release_subscription, + quit_subscription, + settings_subscription, + ], } } @@ -168,7 +180,7 @@ impl MultiWorkspace { } pub fn multi_workspace_enabled(&self, cx: &App) -> bool { - cx.has_flag::() + cx.has_flag::() && !DisableAiSettings::get_global(cx).disable_ai } pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context) { @@ -237,6 +249,41 @@ impl MultiWorkspace { cx.notify(); } + pub fn close_window(&mut self, _: &CloseWindow, window: &mut Window, cx: &mut Context) { + cx.spawn_in(window, async move |this, cx| { + let workspaces = this.update(cx, |multi_workspace, _cx| { + multi_workspace.workspaces().to_vec() + })?; + + for workspace in workspaces { + let should_continue = workspace + .update_in(cx, |workspace, window, cx| { + workspace.prepare_to_close(CloseIntent::CloseWindow, window, cx) + })? + .await?; + if !should_continue { + return anyhow::Ok(()); + } + } + + cx.update(|window, _cx| { + window.remove_window(); + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn subscribe_to_workspace(workspace: &Entity, cx: &mut Context) { + cx.subscribe(workspace, |this, workspace, event, cx| { + if let WorkspaceEvent::Activate = event { + this.activate(workspace, cx); + } + }) + .detach(); + } + pub fn is_sidebar_open(&self) -> bool { self.sidebar_open } @@ -290,6 +337,7 @@ impl MultiWorkspace { workspace.set_workspace_sidebar_open(true, cx); }); } + Self::subscribe_to_workspace(&workspace, cx); self.workspaces.push(workspace); cx.notify(); self.workspaces.len() - 1 @@ -412,6 +460,7 @@ impl MultiWorkspace { .update(cx, |workspace, cx| workspace.focus_panel::(window, cx)) } + // used in a test pub fn toggle_modal( &mut self, window: &mut Window, @@ -623,6 +672,7 @@ impl MultiWorkspace { impl Render for MultiWorkspace { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let multi_workspace_enabled = self.multi_workspace_enabled(cx); + let is_zoomed = self.workspace().read(cx).zoomed_item().is_some(); let sidebar: Option = if multi_workspace_enabled && self.sidebar_open { self.sidebar.as_ref().map(|sidebar_handle| { @@ -673,10 +723,15 @@ impl Render for MultiWorkspace { None }; + let workspace = self.workspace().clone(); + let workspace_key_context = workspace.update(cx, |workspace, cx| workspace.key_context(cx)); + let root = workspace.update(cx, |workspace, cx| workspace.actions(h_flex(), window, cx)); + client_side_decorations( - h_flex() - .key_context("Workspace") + root.key_context(workspace_key_context) + .relative() .size_full() + .on_action(cx.listener(Self::close_window)) .on_action( cx.listener(|this: &mut Self, _: &NewWorkspaceInWindow, window, cx| { this.create_workspace(window, cx); @@ -692,16 +747,18 @@ impl Render for MultiWorkspace { this.activate_previous_workspace(window, cx); }, )) - .on_action(cx.listener( - |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| { - this.toggle_sidebar(window, cx); - }, - )) - .on_action( - cx.listener(|this: &mut Self, _: &FocusWorkspaceSidebar, window, cx| { - this.focus_sidebar(window, cx); - }), - ) + .when(self.multi_workspace_enabled(cx), |this| { + this.on_action(cx.listener( + |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| { + this.toggle_sidebar(window, cx); + }, + )) + .on_action(cx.listener( + |this: &mut Self, _: &FocusWorkspaceSidebar, window, cx| { + this.focus_sidebar(window, cx); + }, + )) + }) .when( self.sidebar_open() && self.multi_workspace_enabled(cx), |this| { @@ -722,14 +779,105 @@ impl Render for MultiWorkspace { .flex_1() .size_full() .overflow_hidden() + .when(is_zoomed, |this| this.absolute().inset_0()) .child(self.workspace().clone()), - ), + ) + .child(self.workspace().read(cx).modal_layer.clone()), window, cx, Tiling { - left: multi_workspace_enabled && self.sidebar_open, + left: multi_workspace_enabled && self.sidebar_open && !is_zoomed, ..Tiling::default() }, ) } } + +#[cfg(test)] +mod tests { + 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::init(theme::LoadThemes::JustBase, cx); + DisableAiSettings::register(cx); + cx.update_flags(false, vec!["agent-v2".into()]); + }); + } + + #[gpui::test] + async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + multi_workspace.read_with(cx, |mw, cx| { + assert!(mw.multi_workspace_enabled(cx)); + }); + + multi_workspace.update_in(cx, |mw, _window, cx| { + mw.open_sidebar(cx); + assert!(mw.is_sidebar_open()); + }); + + cx.update(|_window, cx| { + DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx); + }); + cx.run_until_parked(); + + multi_workspace.read_with(cx, |mw, cx| { + assert!( + !mw.is_sidebar_open(), + "Sidebar should be closed when disable_ai is true" + ); + assert!( + !mw.multi_workspace_enabled(cx), + "Multi-workspace should be disabled when disable_ai is true" + ); + }); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.toggle_sidebar(window, cx); + }); + multi_workspace.read_with(cx, |mw, _cx| { + assert!( + !mw.is_sidebar_open(), + "Sidebar should remain closed when toggled with disable_ai true" + ); + }); + + cx.update(|_window, cx| { + DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx); + }); + cx.run_until_parked(); + + multi_workspace.read_with(cx, |mw, cx| { + assert!( + mw.multi_workspace_enabled(cx), + "Multi-workspace should be enabled after re-enabling AI" + ); + assert!( + !mw.is_sidebar_open(), + "Sidebar should still be closed after re-enabling AI (not auto-opened)" + ); + }); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.toggle_sidebar(window, cx); + }); + multi_workspace.read_with(cx, |mw, _cx| { + assert!( + mw.is_sidebar_open(), + "Sidebar should open when toggled after re-enabling AI" + ); + }); + } +} diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 0f8cef616f5ed0..0921a19486718c 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -1,10 +1,10 @@ use crate::{ - AppState, CollaboratorId, FollowerState, Pane, Workspace, WorkspaceSettings, + AnyActiveCall, AppState, CollaboratorId, FollowerState, Pane, ParticipantLocation, Workspace, + WorkspaceSettings, pane_group::element::pane_axis, workspace_settings::{PaneSplitDirectionHorizontal, PaneSplitDirectionVertical}, }; use anyhow::Result; -use call::{ActiveCall, ParticipantLocation}; use collections::HashMap; use gpui::{ Along, AnyView, AnyWeakView, Axis, Bounds, Entity, Hsla, IntoElement, MouseButton, Pixels, @@ -61,22 +61,33 @@ impl PaneGroup { new_pane: &Entity, direction: SplitDirection, cx: &mut App, - ) -> Result<()> { - let result = match &mut self.root { + ) { + let found = match &mut self.root { Member::Pane(pane) => { if pane == old_pane { self.root = Member::new_axis(old_pane.clone(), new_pane.clone(), direction); - Ok(()) + true } else { - anyhow::bail!("Pane not found"); + false } } Member::Axis(axis) => axis.split(old_pane, new_pane, direction), }; - if result.is_ok() { - self.mark_positions(cx); + + // If the pane wasn't found, fall back to splitting the first pane in the tree. + if !found { + let first_pane = self.root.first_pane(); + match &mut self.root { + Member::Pane(_) => { + self.root = Member::new_axis(first_pane, new_pane.clone(), direction); + } + Member::Axis(axis) => { + let _ = axis.split(&first_pane, new_pane, direction); + } + } } - result + + self.mark_positions(cx); } pub fn bounding_box_for_pane(&self, pane: &Entity) -> Option> { @@ -296,7 +307,7 @@ impl Member { pub struct PaneRenderContext<'a> { pub project: &'a Entity, pub follower_states: &'a HashMap, - pub active_call: Option<&'a Entity>, + pub active_call: Option<&'a dyn AnyActiveCall>, pub active_pane: &'a Entity, pub app_state: &'a Arc, pub workspace: &'a WeakEntity, @@ -358,10 +369,11 @@ impl PaneLeaderDecorator for PaneRenderContext<'_> { let status_box; match leader_id { CollaboratorId::PeerId(peer_id) => { - let Some(leader) = self.active_call.as_ref().and_then(|call| { - let room = call.read(cx).room()?.read(cx); - room.remote_participant_for_peer_id(peer_id) - }) else { + let Some(leader) = self + .active_call + .as_ref() + .and_then(|call| call.remote_participant_for_peer_id(peer_id, cx)) + else { return LeaderDecoration::default(); }; @@ -611,12 +623,12 @@ impl PaneAxis { old_pane: &Entity, new_pane: &Entity, direction: SplitDirection, - ) -> Result<()> { + ) -> bool { for (mut idx, member) in self.members.iter_mut().enumerate() { match member { Member::Axis(axis) => { - if axis.split(old_pane, new_pane, direction).is_ok() { - return Ok(()); + if axis.split(old_pane, new_pane, direction) { + return true; } } Member::Pane(pane) => { @@ -630,12 +642,12 @@ impl PaneAxis { *member = Member::new_axis(old_pane.clone(), new_pane.clone(), direction); } - return Ok(()); + return true; } } } } - anyhow::bail!("Pane not found"); + false } fn insert_pane(&mut self, idx: usize, new_pane: &Entity) { diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index a26e5e11337aa8..17e557981d58de 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -4368,4 +4368,114 @@ mod tests { "Pending removal task should have deleted the workspace row when awaited" ); } + + #[gpui::test] + async fn test_create_workspace_bounds_observer_uses_fresh_id(cx: &mut gpui::TestAppContext) { + use crate::multi_workspace::MultiWorkspace; + use feature_flags::FeatureFlagAppExt; + use project::Project; + + crate::tests::init_test(cx); + + cx.update(|cx| { + cx.set_staff(true); + cx.update_flags(true, vec!["agent-v2".to_string()]); + }); + + let fs = fs::FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + multi_workspace.update_in(cx, |mw, _, cx| { + mw.set_random_database_id(cx); + }); + + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_workspace(window, cx); + }); + + cx.run_until_parked(); + + let new_workspace_db_id = + multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).database_id()); + assert!( + new_workspace_db_id.is_some(), + "After run_until_parked, the workspace should have a database_id" + ); + + let workspace_id = new_workspace_db_id.unwrap(); + + assert!( + DB.workspace_for_id(workspace_id).is_some(), + "The workspace row should exist in the DB" + ); + + cx.simulate_resize(gpui::size(px(1024.0), px(768.0))); + + // Advance the clock past the 100ms debounce timer so the bounds + // observer task fires + cx.executor().advance_clock(Duration::from_millis(200)); + cx.run_until_parked(); + + let serialized = DB + .workspace_for_id(workspace_id) + .expect("workspace row should still exist"); + assert!( + serialized.window_bounds.is_some(), + "The bounds observer should write bounds for the workspace's real DB ID, \ + even when the workspace was created via create_workspace (where the ID \ + is assigned asynchronously after construction)." + ); + } + + #[gpui::test] + async fn test_flush_serialization_writes_bounds(cx: &mut gpui::TestAppContext) { + use crate::multi_workspace::MultiWorkspace; + use feature_flags::FeatureFlagAppExt; + use project::Project; + + crate::tests::init_test(cx); + + cx.update(|cx| { + cx.set_staff(true); + cx.update_flags(true, vec!["agent-v2".to_string()]); + }); + + let fs = fs::FakeFs::new(cx.executor()); + let dir = tempfile::TempDir::with_prefix("flush_bounds_test").unwrap(); + fs.insert_tree(dir.path(), json!({})).await; + + let project = Project::test(fs.clone(), [dir.path()], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace_id = DB.next_id().await.unwrap(); + multi_workspace.update_in(cx, |mw, _, cx| { + mw.workspace().update(cx, |ws, _cx| { + ws.set_database_id(workspace_id); + }); + }); + + let task = multi_workspace.update_in(cx, |mw, window, cx| { + mw.workspace() + .update(cx, |ws, cx| ws.flush_serialization(window, cx)) + }); + task.await; + + let after = DB + .workspace_for_id(workspace_id) + .expect("workspace row should exist after flush_serialization"); + assert!( + !after.paths.is_empty(), + "flush_serialization should have written paths via save_workspace" + ); + assert!( + after.window_bounds.is_some(), + "flush_serialization should ensure window bounds are persisted to the DB \ + before the process exits." + ); + } } diff --git a/crates/workspace/src/shared_screen.rs b/crates/workspace/src/shared_screen.rs index fc4ae7292a0478..136f552fee2323 100644 --- a/crates/workspace/src/shared_screen.rs +++ b/crates/workspace/src/shared_screen.rs @@ -2,10 +2,9 @@ use crate::{ ItemNavHistory, WorkspaceId, item::{Item, ItemEvent}, }; -use call::{RemoteVideoTrack, RemoteVideoTrackView, Room}; use client::{User, proto::PeerId}; use gpui::{ - AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, + AnyView, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, ParentElement, Render, SharedString, Styled, Task, div, }; use std::sync::Arc; @@ -19,45 +18,26 @@ pub struct SharedScreen { pub peer_id: PeerId, user: Arc, nav_history: Option, - view: Entity, + view: AnyView, + clone_view: fn(&AnyView, &mut Window, &mut App) -> AnyView, focus: FocusHandle, } impl SharedScreen { pub fn new( - track: RemoteVideoTrack, peer_id: PeerId, user: Arc, - room: Entity, - window: &mut Window, + view: AnyView, + clone_view: fn(&AnyView, &mut Window, &mut App) -> AnyView, cx: &mut Context, ) -> Self { - let my_sid = track.sid(); - cx.subscribe(&room, move |_, _, ev, cx| { - if let call::room::Event::RemoteVideoTrackUnsubscribed { sid } = ev - && sid == &my_sid - { - cx.emit(Event::Close) - } - }) - .detach(); - - cx.observe_release(&room, |_, _, cx| { - cx.emit(Event::Close); - }) - .detach(); - - let view = cx.new(|cx| RemoteVideoTrackView::new(track.clone(), window, cx)); - cx.subscribe(&view, |_, _, ev, cx| match ev { - call::RemoteVideoTrackViewEvent::Close => cx.emit(Event::Close), - }) - .detach(); Self { view, peer_id, user, nav_history: Default::default(), focus: cx.focus_handle(), + clone_view, } } } @@ -124,12 +104,15 @@ impl Item for SharedScreen { window: &mut Window, cx: &mut Context, ) -> Task>> { + let clone_view = self.clone_view; + let cloned_view = clone_view(&self.view, window, cx); Task::ready(Some(cx.new(|cx| Self { - view: self.view.update(cx, |view, cx| view.clone(window, cx)), + view: cloned_view, peer_id: self.peer_id, user: self.user.clone(), nav_history: Default::default(), focus: cx.focus_handle(), + clone_view, }))) } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 4db38daa2ae671..bcc6f2ccc26c96 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -12,6 +12,7 @@ mod persistence; pub mod searchable; mod security_modal; pub mod shared_screen; +pub use shared_screen::SharedScreen; mod status_bar; pub mod tasks; mod theme_preview; @@ -31,13 +32,13 @@ pub use path_list::PathList; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; use anyhow::{Context as _, Result, anyhow}; -use call::{ActiveCall, call_settings::CallSettings}; use client::{ - ChannelId, Client, ErrorExt, Status, TypedEnvelope, UserStore, + ChannelId, Client, ErrorExt, ParticipantIndex, Status, TypedEnvelope, User, UserStore, proto::{self, ErrorCode, PanelId, PeerId}, }; use collections::{HashMap, HashSet, hash_map}; use dock::{Dock, DockPosition, PanelButtons, PanelHandle, RESIZE_HANDLE_SIZE}; +use fs::Fs; use futures::{ Future, FutureExt, StreamExt, channel::{ @@ -97,7 +98,7 @@ use session::AppSession; use settings::{ CenteredPaddingSettings, Settings, SettingsLocation, SettingsStore, update_settings_file, }; -use shared_screen::SharedScreen; + use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, @@ -209,6 +210,8 @@ actions!( ActivateNextPane, /// Activates the previous pane in the workspace. ActivatePreviousPane, + /// Activates the last pane in the workspace. + ActivateLastPane, /// Switches to the next window. ActivateNextWindow, /// Switches to the previous window. @@ -1179,6 +1182,7 @@ pub enum Event { }, ZoomChanged, ModalOpened, + Activate, } #[derive(Debug, Clone)] @@ -1247,7 +1251,7 @@ pub struct Workspace { last_active_center_pane: Option>, last_active_view_id: Option, status_bar: Entity, - modal_layer: Entity, + pub(crate) modal_layer: Entity, toast_layer: Entity, titlebar_item: Option, notifications: Notifications, @@ -1258,7 +1262,7 @@ pub struct Workspace { window_edited: bool, last_window_title: Option, dirty_items: HashMap, - active_call: Option<(Entity, Vec)>, + active_call: Option<(GlobalAnyActiveCall, Vec)>, leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>, database_id: Option, app_state: Arc, @@ -1572,8 +1576,12 @@ impl Workspace { let session_id = app_state.session.read(cx).id().to_owned(); let mut active_call = None; - if let Some(call) = ActiveCall::try_global(cx) { - let subscriptions = vec![cx.subscribe_in(&call, window, Self::on_active_call_event)]; + if let Some(call) = GlobalAnyActiveCall::try_global(cx).cloned() { + let subscriptions = + vec![ + call.0 + .subscribe(window, cx, Box::new(Self::on_active_call_event)), + ]; active_call = Some((call, subscriptions)); } @@ -1594,36 +1602,7 @@ impl Workspace { .timer(Duration::from_millis(100)) .await; this.update_in(cx, |this, window, cx| { - if let Some(display) = window.display(cx) - && let Ok(display_uuid) = display.uuid() - { - let window_bounds = window.inner_window_bounds(); - let has_paths = !this.root_paths(cx).is_empty(); - if !has_paths { - cx.background_executor() - .spawn(persistence::write_default_window_bounds( - window_bounds, - display_uuid, - )) - .detach_and_log_err(cx); - } - if let Some(database_id) = workspace_id { - cx.background_executor() - .spawn(DB.set_window_open_status( - database_id, - SerializedWindowBounds(window_bounds), - display_uuid, - )) - .detach_and_log_err(cx); - } else { - cx.background_executor() - .spawn(persistence::write_default_window_bounds( - window_bounds, - display_uuid, - )) - .detach_and_log_err(cx); - } - } + this.save_window_bounds(window, cx).detach(); this.bounds_save_task_queued.take(); }) .ok(); @@ -2651,17 +2630,6 @@ impl Workspace { }); } - pub fn close_window(&mut self, _: &CloseWindow, window: &mut Window, cx: &mut Context) { - let prepare = self.prepare_to_close(CloseIntent::CloseWindow, window, cx); - cx.spawn_in(window, async move |_, cx| { - if prepare.await? { - cx.update(|window, _cx| window.remove_window())?; - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx) - } - pub fn move_focused_panel_to_next_position( &mut self, _: &MoveFocusedPanelToNextPosition, @@ -2692,7 +2660,7 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) -> Task> { - let active_call = self.active_call().cloned(); + let active_call = self.active_global_call(); cx.spawn_in(window, async move |this, cx| { this.update(cx, |this, _| { @@ -2734,9 +2702,12 @@ impl Workspace { if let Some(active_call) = active_call && workspace_count == 1 - && active_call.read_with(cx, |call, _| call.room().is_some()) + && cx + .update(|_window, cx| active_call.0.is_in_room(cx)) + .unwrap_or(false) { if close_intent == CloseIntent::CloseWindow { + this.update(cx, |_, cx| cx.emit(Event::Activate))?; let answer = cx.update(|window, cx| { window.prompt( PromptLevel::Warning, @@ -2750,14 +2721,13 @@ impl Workspace { if answer.await.log_err() == Some(1) { return anyhow::Ok(false); } else { - active_call - .update(cx, |call, cx| call.hang_up(cx)) - .await - .log_err(); + if let Ok(task) = cx.update(|_window, cx| active_call.0.hang_up(cx)) { + task.await.log_err(); + } } } if close_intent == CloseIntent::ReplaceWindow { - _ = active_call.update(cx, |this, cx| { + _ = cx.update(|_window, cx| { let multi_workspace = cx .windows() .iter() @@ -2771,10 +2741,10 @@ impl Workspace { .project .clone(); if project.read(cx).is_shared() { - this.unshare_project(project, cx)?; + active_call.0.unshare_project(project, cx)?; } Ok::<_, anyhow::Error>(()) - })?; + }); } } @@ -2926,6 +2896,10 @@ impl Workspace { futures::future::try_join_all(serialize_tasks).await?; + if !remaining_dirty_items.is_empty() { + workspace.update(cx, |_, cx| cx.emit(Event::Activate))?; + } + if remaining_dirty_items.len() > 1 { let answer = workspace.update_in(cx, |_, window, cx| { let detail = Pane::file_names_for_prompt( @@ -4267,14 +4241,7 @@ impl Workspace { .find_pane_in_direction(direction, cx) .unwrap_or_else(|| self.active_pane.clone()); let new_pane = self.add_pane(window, cx); - if self - .center - .split(&split_off_pane, &new_pane, direction, cx) - .log_err() - .is_none() - { - return; - }; + self.center.split(&split_off_pane, &new_pane, direction, cx); new_pane } }; @@ -4325,6 +4292,11 @@ impl Workspace { } } + pub fn activate_last_pane(&mut self, window: &mut Window, cx: &mut App) { + let last_pane = self.center.last_pane(); + window.focus(&last_pane.focus_handle(cx), cx); + } + pub fn activate_pane_in_direction( &mut self, direction: SplitDirection, @@ -4452,14 +4424,8 @@ impl Workspace { return; } let new_pane = self.add_pane(window, cx); - if self - .center - .split(&self.active_pane, &new_pane, action.direction, cx) - .log_err() - .is_none() - { - return; - }; + self.center + .split(&self.active_pane, &new_pane, action.direction, cx); new_pane } }; @@ -4757,8 +4723,7 @@ impl Workspace { ) -> Entity { let new_pane = self.add_pane(window, cx); self.center - .split(&pane_to_split, &new_pane, split_direction, cx) - .unwrap(); + .split(&pane_to_split, &new_pane, split_direction, cx); cx.notify(); new_pane } @@ -4777,7 +4742,7 @@ impl Workspace { new_pane.update(cx, |pane, cx| { pane.add_item(item, true, true, None, window, cx) }); - self.center.split(&pane, &new_pane, direction, cx).unwrap(); + self.center.split(&pane, &new_pane, direction, cx); cx.notify(); } @@ -4804,7 +4769,7 @@ impl Workspace { pane.set_nav_history(nav_history, cx); pane.add_item(clone, true, true, None, window, cx) }); - this.center.split(&pane, &new_pane, direction, cx).unwrap(); + this.center.split(&pane, &new_pane, direction, cx); cx.notify(); new_pane }) @@ -4900,10 +4865,21 @@ impl Workspace { } pub fn pane_for(&self, handle: &dyn ItemHandle) -> Option> { - let weak_pane = self.panes_by_item.get(&handle.item_id())?; + self.pane_for_item_id(handle.item_id()) + } + + pub fn pane_for_item_id(&self, item_id: EntityId) -> Option> { + let weak_pane = self.panes_by_item.get(&item_id)?; weak_pane.upgrade() } + pub fn pane_for_entity_id(&self, entity_id: EntityId) -> Option> { + self.panes + .iter() + .find(|pane| pane.entity_id() == entity_id) + .cloned() + } + fn collaborator_left(&mut self, peer_id: PeerId, window: &mut Window, cx: &mut Context) { self.follower_states.retain(|leader_id, state| { if *leader_id == CollaboratorId::PeerId(peer_id) { @@ -4944,7 +4920,7 @@ impl Workspace { match leader_id { CollaboratorId::PeerId(leader_peer_id) => { - let room_id = self.active_call()?.read(cx).room()?.read(cx).id(); + let room_id = self.active_call()?.room_id(cx)?; let project_id = self.project.read(cx).remote_id(); let request = self.app_state.client.request(proto::Follow { room_id, @@ -5038,20 +5014,21 @@ impl Workspace { let leader_id = leader_id.into(); if let CollaboratorId::PeerId(peer_id) = leader_id { - let Some(room) = ActiveCall::global(cx).read(cx).room() else { + let Some(active_call) = GlobalAnyActiveCall::try_global(cx) else { return; }; - let room = room.read(cx); - let Some(remote_participant) = room.remote_participant_for_peer_id(peer_id) else { + let Some(remote_participant) = + active_call.0.remote_participant_for_peer_id(peer_id, cx) + else { return; }; let project = self.project.read(cx); let other_project_id = match remote_participant.location { - call::ParticipantLocation::External => None, - call::ParticipantLocation::UnsharedProject => None, - call::ParticipantLocation::SharedProject { project_id } => { + ParticipantLocation::External => None, + ParticipantLocation::UnsharedProject => None, + ParticipantLocation::SharedProject { project_id } => { if Some(project_id) == project.remote_id() { None } else { @@ -5097,7 +5074,7 @@ impl Workspace { if let CollaboratorId::PeerId(leader_peer_id) = leader_id { let project_id = self.project.read(cx).remote_id(); - let room_id = self.active_call()?.read(cx).room()?.read(cx).id(); + let room_id = self.active_call()?.room_id(cx)?; self.app_state .client .send(proto::Unfollow { @@ -5740,20 +5717,19 @@ impl Workspace { cx: &mut Context, ) -> Option<(Option, Box)> { let call = self.active_call()?; - let room = call.read(cx).room()?.read(cx); - let participant = room.remote_participant_for_peer_id(peer_id)?; + let participant = call.remote_participant_for_peer_id(peer_id, cx)?; let leader_in_this_app; let leader_in_this_project; match participant.location { - call::ParticipantLocation::SharedProject { project_id } => { + ParticipantLocation::SharedProject { project_id } => { leader_in_this_app = true; leader_in_this_project = Some(project_id) == self.project.read(cx).remote_id(); } - call::ParticipantLocation::UnsharedProject => { + ParticipantLocation::UnsharedProject => { leader_in_this_app = true; leader_in_this_project = false; } - call::ParticipantLocation::External => { + ParticipantLocation::External => { leader_in_this_app = false; leader_in_this_project = false; } @@ -5781,19 +5757,8 @@ impl Workspace { window: &mut Window, cx: &mut App, ) -> Option> { - let call = self.active_call()?; - let room = call.read(cx).room()?.clone(); - let participant = room.read(cx).remote_participant_for_peer_id(peer_id)?; - let track = participant.video_tracks.values().next()?.clone(); - let user = participant.user.clone(); - - for item in pane.read(cx).items_of_type::() { - if item.read(cx).peer_id == peer_id { - return Some(item); - } - } - - Some(cx.new(|cx| SharedScreen::new(track, peer_id, user.clone(), room.clone(), window, cx))) + self.active_call()? + .create_shared_screen(peer_id, pane, window, cx) } pub fn on_window_activation_changed(&mut self, window: &mut Window, cx: &mut Context) { @@ -5824,23 +5789,25 @@ impl Workspace { } } - pub fn active_call(&self) -> Option<&Entity> { - self.active_call.as_ref().map(|(call, _)| call) + pub fn active_call(&self) -> Option<&dyn AnyActiveCall> { + self.active_call.as_ref().map(|(call, _)| &*call.0) + } + + pub fn active_global_call(&self) -> Option { + self.active_call.as_ref().map(|(call, _)| call.clone()) } fn on_active_call_event( &mut self, - _: &Entity, - event: &call::room::Event, + event: &ActiveCallEvent, window: &mut Window, cx: &mut Context, ) { match event { - call::room::Event::ParticipantLocationChanged { participant_id } - | call::room::Event::RemoteVideoTracksChanged { participant_id } => { + ActiveCallEvent::ParticipantLocationChanged { participant_id } + | ActiveCallEvent::RemoteVideoTracksChanged { participant_id } => { self.leader_updated(participant_id, window, cx); } - _ => {} } } @@ -5856,6 +5823,40 @@ impl Workspace { self.session_id.clone() } + fn save_window_bounds(&self, window: &mut Window, cx: &mut App) -> Task<()> { + let Some(display) = window.display(cx) else { + return Task::ready(()); + }; + let Ok(display_uuid) = display.uuid() else { + return Task::ready(()); + }; + + let window_bounds = window.inner_window_bounds(); + let database_id = self.database_id; + let has_paths = !self.root_paths(cx).is_empty(); + + cx.background_executor().spawn(async move { + if !has_paths { + persistence::write_default_window_bounds(window_bounds, display_uuid) + .await + .log_err(); + } + if let Some(database_id) = database_id { + DB.set_window_open_status( + database_id, + SerializedWindowBounds(window_bounds), + display_uuid, + ) + .await + .log_err(); + } else { + persistence::write_default_window_bounds(window_bounds, display_uuid) + .await + .log_err(); + } + }) + } + /// Bypass the 200ms serialization throttle and write workspace state to /// the DB immediately. Returns a task the caller can await to ensure the /// write completes. Used by the quit handler so the most recent state @@ -5863,7 +5864,14 @@ impl Workspace { pub fn flush_serialization(&mut self, window: &mut Window, cx: &mut App) -> Task<()> { self._schedule_serialize_workspace.take(); self._serialize_workspace_task.take(); - self.serialize_workspace_internal(window, cx) + self.bounds_save_task_queued.take(); + + let bounds_task = self.save_window_bounds(window, cx); + let serialize_task = self.serialize_workspace_internal(window, cx); + cx.spawn(async move |_| { + bounds_task.await; + serialize_task.await; + }) } pub fn root_paths(&self, cx: &App) -> Vec> { @@ -6325,7 +6333,47 @@ impl Workspace { }) } - fn actions(&self, div: Div, window: &mut Window, cx: &mut Context) -> Div { + pub fn key_context(&self, cx: &App) -> KeyContext { + let mut context = KeyContext::new_with_defaults(); + context.add("Workspace"); + context.set("keyboard_layout", cx.keyboard_layout().name().to_string()); + if let Some(status) = self + .debugger_provider + .as_ref() + .and_then(|provider| provider.active_thread_state(cx)) + { + match status { + ThreadStatus::Running | ThreadStatus::Stepping => { + context.add("debugger_running"); + } + ThreadStatus::Stopped => context.add("debugger_stopped"), + ThreadStatus::Exited | ThreadStatus::Ended => {} + } + } + + if self.left_dock.read(cx).is_open() { + if let Some(active_panel) = self.left_dock.read(cx).active_panel() { + context.set("left_dock", active_panel.panel_key()); + } + } + + if self.right_dock.read(cx).is_open() { + if let Some(active_panel) = self.right_dock.read(cx).active_panel() { + context.set("right_dock", active_panel.panel_key()); + } + } + + if self.bottom_dock.read(cx).is_open() { + if let Some(active_panel) = self.bottom_dock.read(cx).active_panel() { + context.set("bottom_dock", active_panel.panel_key()); + } + } + + context + } + + /// Multiworkspace uses this to add workspace action handling to itself + pub fn actions(&self, div: Div, window: &mut Window, cx: &mut Context) -> Div { self.add_workspace_actions_listeners(div, window, cx) .on_action(cx.listener( |_workspace, action_sequence: &settings::ActionSequence, window, cx| { @@ -6341,7 +6389,6 @@ impl Workspace { .on_action(cx.listener(Self::send_keystrokes)) .on_action(cx.listener(Self::add_folder_to_project)) .on_action(cx.listener(Self::follow_next_collaborator)) - .on_action(cx.listener(Self::close_window)) .on_action(cx.listener(Self::activate_pane_at_index)) .on_action(cx.listener(Self::move_item_to_pane_at_index)) .on_action(cx.listener(Self::move_focused_panel_to_next_position)) @@ -6373,6 +6420,9 @@ impl Workspace { .on_action(cx.listener(|workspace, _: &ActivateNextPane, window, cx| { workspace.activate_next_pane(window, cx) })) + .on_action(cx.listener(|workspace, _: &ActivateLastPane, window, cx| { + workspace.activate_last_pane(window, cx) + })) .on_action( cx.listener(|workspace, _: &ActivateNextWindow, _window, cx| { workspace.activate_next_window(cx) @@ -6395,9 +6445,6 @@ impl Workspace { .on_action(cx.listener(|workspace, _: &ActivatePaneDown, window, cx| { workspace.activate_pane_in_direction(SplitDirection::Down, window, cx) })) - .on_action(cx.listener(|workspace, _: &ActivateNextPane, window, cx| { - workspace.activate_next_pane(window, cx) - })) .on_action(cx.listener( |workspace, action: &MoveItemToPaneInDirection, window, cx| { workspace.move_item_to_pane_in_direction(action, window, cx) @@ -7027,6 +7074,98 @@ impl Workspace { } } +pub trait AnyActiveCall { + fn entity(&self) -> AnyEntity; + fn is_in_room(&self, _: &App) -> bool; + fn room_id(&self, _: &App) -> Option; + fn channel_id(&self, _: &App) -> Option; + fn hang_up(&self, _: &mut App) -> Task>; + fn unshare_project(&self, _: Entity, _: &mut App) -> Result<()>; + fn remote_participant_for_peer_id(&self, _: PeerId, _: &App) -> Option; + fn is_sharing_project(&self, _: &App) -> bool; + fn has_remote_participants(&self, _: &App) -> bool; + fn local_participant_is_guest(&self, _: &App) -> bool; + fn client(&self, _: &App) -> Arc; + fn share_on_join(&self, _: &App) -> bool; + fn join_channel(&self, _: ChannelId, _: &mut App) -> Task>; + fn room_update_completed(&self, _: &mut App) -> Task<()>; + fn most_active_project(&self, _: &App) -> Option<(u64, u64)>; + fn share_project(&self, _: Entity, _: &mut App) -> Task>; + fn join_project( + &self, + _: u64, + _: Arc, + _: Arc, + _: &mut App, + ) -> Task>>; + fn peer_id_for_user_in_room(&self, _: u64, _: &App) -> Option; + fn subscribe( + &self, + _: &mut Window, + _: &mut Context, + _: Box)>, + ) -> Subscription; + fn create_shared_screen( + &self, + _: PeerId, + _: &Entity, + _: &mut Window, + _: &mut App, + ) -> Option>; +} + +#[derive(Clone)] +pub struct GlobalAnyActiveCall(pub Arc); +impl Global for GlobalAnyActiveCall {} + +impl GlobalAnyActiveCall { + pub(crate) fn try_global(cx: &App) -> Option<&Self> { + cx.try_global() + } + + pub(crate) fn global(cx: &App) -> &Self { + cx.global() + } +} +/// Workspace-local view of a remote participant's location. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParticipantLocation { + SharedProject { project_id: u64 }, + UnsharedProject, + External, +} + +impl ParticipantLocation { + pub fn from_proto(location: Option) -> Result { + match location + .and_then(|l| l.variant) + .context("participant location was not provided")? + { + proto::participant_location::Variant::SharedProject(project) => { + Ok(Self::SharedProject { + project_id: project.id, + }) + } + proto::participant_location::Variant::UnsharedProject(_) => Ok(Self::UnsharedProject), + proto::participant_location::Variant::External(_) => Ok(Self::External), + } + } +} +/// Workspace-local view of a remote collaborator's state. +/// This is the subset of `call::RemoteParticipant` that workspace needs. +#[derive(Clone)] +pub struct RemoteCollaborator { + pub user: Arc, + pub peer_id: PeerId, + pub location: ParticipantLocation, + pub participant_index: ParticipantIndex, +} + +pub enum ActiveCallEvent { + ParticipantLocationChanged { participant_id: PeerId }, + RemoteVideoTracksChanged { participant_id: PeerId }, +} + fn leader_border_for_pane( follower_states: &HashMap, pane: &Entity, @@ -7043,8 +7182,9 @@ fn leader_border_for_pane( let mut leader_color = match leader_id { CollaboratorId::PeerId(leader_peer_id) => { - let room = ActiveCall::try_global(cx)?.read(cx).room()?.read(cx); - let leader = room.remote_participant_for_peer_id(leader_peer_id)?; + let leader = GlobalAnyActiveCall::try_global(cx)? + .0 + .remote_participant_for_peer_id(leader_peer_id, cx)?; cx.theme() .players() @@ -7290,40 +7430,6 @@ impl Render for Workspace { if FIRST_PAINT.swap(false, std::sync::atomic::Ordering::Relaxed) { log::info!("Rendered first frame"); } - let mut context = KeyContext::new_with_defaults(); - context.add("Workspace"); - context.set("keyboard_layout", cx.keyboard_layout().name().to_string()); - if let Some(status) = self - .debugger_provider - .as_ref() - .and_then(|provider| provider.active_thread_state(cx)) - { - match status { - ThreadStatus::Running | ThreadStatus::Stepping => { - context.add("debugger_running"); - } - ThreadStatus::Stopped => context.add("debugger_stopped"), - ThreadStatus::Exited | ThreadStatus::Ended => {} - } - } - - if self.left_dock.read(cx).is_open() { - if let Some(active_panel) = self.left_dock.read(cx).active_panel() { - context.set("left_dock", active_panel.panel_key()); - } - } - - if self.right_dock.read(cx).is_open() { - if let Some(active_panel) = self.right_dock.read(cx).active_panel() { - context.set("right_dock", active_panel.panel_key()); - } - } - - if self.bottom_dock.read(cx).is_open() { - if let Some(active_panel) = self.bottom_dock.read(cx).active_panel() { - context.set("bottom_dock", active_panel.panel_key()); - } - } let centered_layout = self.centered_layout && self.center.panes().len() == 1 @@ -7361,8 +7467,7 @@ impl Render for Workspace { .collect::>(); let bottom_dock_layout = WorkspaceSettings::get_global(cx).bottom_dock_layout; - self.actions(div(), window, cx) - .key_context(context) + div() .relative() .size_full() .flex() @@ -7762,7 +7867,6 @@ impl Render for Workspace { .when(self.status_bar_visible(cx), |parent| { parent.child(self.status_bar.clone()) }) - .child(self.modal_layer.clone()) .child(self.toast_layer.clone()), ) } @@ -7786,8 +7890,8 @@ impl WorkspaceStore { update: proto::update_followers::Variant, cx: &App, ) -> Option<()> { - let active_call = ActiveCall::try_global(cx)?; - let room_id = active_call.read(cx).room()?.read(cx).id(); + let active_call = GlobalAnyActiveCall::try_global(cx)?; + let room_id = active_call.0.room_id(cx)?; self.client .send(proto::UpdateFollowers { room_id, @@ -8100,33 +8204,28 @@ async fn join_channel_internal( app_state: &Arc, requesting_window: Option>, requesting_workspace: Option>, - active_call: &Entity, + active_call: &dyn AnyActiveCall, cx: &mut AsyncApp, ) -> Result { - let (should_prompt, open_room) = active_call.update(cx, |active_call, cx| { - let Some(room) = active_call.room().map(|room| room.read(cx)) else { - return (false, None); - }; + let (should_prompt, already_in_channel) = cx.update(|cx| { + if !active_call.is_in_room(cx) { + return (false, false); + } - let already_in_channel = room.channel_id() == Some(channel_id); - let should_prompt = room.is_sharing_project() - && !room.remote_participants().is_empty() + let already_in_channel = active_call.channel_id(cx) == Some(channel_id); + let should_prompt = active_call.is_sharing_project(cx) + && active_call.has_remote_participants(cx) && !already_in_channel; - let open_room = if already_in_channel { - active_call.room().cloned() - } else { - None - }; - (should_prompt, open_room) + (should_prompt, already_in_channel) }); - if let Some(room) = open_room { - let task = room.update(cx, |room, cx| { - if let Some((project, host)) = room.most_active_project(cx) { - return Some(join_in_room_project(project, host, app_state.clone(), cx)); + if already_in_channel { + let task = cx.update(|cx| { + if let Some((project, host)) = active_call.most_active_project(cx) { + Some(join_in_room_project(project, host, app_state.clone(), cx)) + } else { + None } - - None }); if let Some(task) = task { task.await?; @@ -8152,11 +8251,11 @@ async fn join_channel_internal( return Ok(false); } } else { - return Ok(false); // unreachable!() hopefully + return Ok(false); } } - let client = cx.update(|cx| active_call.read(cx).client()); + let client = cx.update(|cx| active_call.client(cx)); let mut client_status = client.status(); @@ -8184,33 +8283,30 @@ async fn join_channel_internal( } } - let room = active_call - .update(cx, |active_call, cx| { - active_call.join_channel(channel_id, cx) - }) + let joined = cx + .update(|cx| active_call.join_channel(channel_id, cx)) .await?; - let Some(room) = room else { + if !joined { return anyhow::Ok(true); - }; + } - room.update(cx, |room, _| room.room_update_completed()) - .await; + cx.update(|cx| active_call.room_update_completed(cx)).await; - let task = room.update(cx, |room, cx| { - if let Some((project, host)) = room.most_active_project(cx) { + let task = cx.update(|cx| { + if let Some((project, host)) = active_call.most_active_project(cx) { return Some(join_in_room_project(project, host, app_state.clone(), cx)); } // If you are the first to join a channel, see if you should share your project. - if room.remote_participants().is_empty() - && !room.local_participant_is_guest() + if !active_call.has_remote_participants(cx) + && !active_call.local_participant_is_guest(cx) && let Some(workspace) = requesting_workspace.as_ref().and_then(|w| w.upgrade()) { let project = workspace.update(cx, |workspace, cx| { let project = workspace.project.read(cx); - if !CallSettings::get_global(cx).share_on_join { + if !active_call.share_on_join(cx) { return None; } @@ -8227,9 +8323,9 @@ async fn join_channel_internal( } }); if let Some(project) = project { - return Some(cx.spawn(async move |room, cx| { - room.update(cx, |room, cx| room.share_project(project, cx))? - .await?; + let share_task = active_call.share_project(project, cx); + return Some(cx.spawn(async move |_cx| -> Result<()> { + share_task.await?; Ok(()) })); } @@ -8251,14 +8347,14 @@ pub fn join_channel( requesting_workspace: Option>, cx: &mut App, ) -> Task> { - let active_call = ActiveCall::global(cx); + let active_call = GlobalAnyActiveCall::global(cx).clone(); cx.spawn(async move |cx| { let result = join_channel_internal( channel_id, &app_state, requesting_window, requesting_workspace, - &active_call, + &*active_call.0, cx, ) .await; @@ -9102,13 +9198,10 @@ pub fn join_in_room_project( .ok(); existing_window } else { - let active_call = cx.update(|cx| ActiveCall::global(cx)); - let room = active_call - .read_with(cx, |call, _| call.room().cloned()) - .context("not in a call")?; - let project = room - .update(cx, |room, cx| { - room.join_project( + let active_call = cx.update(|cx| GlobalAnyActiveCall::global(cx).clone()); + let project = cx + .update(|cx| { + active_call.0.join_project( project_id, app_state.languages.clone(), app_state.fs.clone(), @@ -9137,27 +9230,21 @@ pub fn join_in_room_project( // We set the active workspace above, so this is the correct workspace. let workspace = multi_workspace.workspace().clone(); workspace.update(cx, |workspace, cx| { - if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { - let follow_peer_id = room - .read(cx) - .remote_participants() - .iter() - .find(|(_, participant)| participant.user.id == follow_user_id) - .map(|(_, p)| p.peer_id) - .or_else(|| { - // If we couldn't follow the given user, follow the host instead. - let collaborator = workspace - .project() - .read(cx) - .collaborators() - .values() - .find(|collaborator| collaborator.is_host)?; - Some(collaborator.peer_id) - }); + let follow_peer_id = GlobalAnyActiveCall::try_global(cx) + .and_then(|call| call.0.peer_id_for_user_in_room(follow_user_id, cx)) + .or_else(|| { + // If we couldn't follow the given user, follow the host instead. + let collaborator = workspace + .project() + .read(cx) + .collaborators() + .values() + .find(|collaborator| collaborator.is_host)?; + Some(collaborator.peer_id) + }); - if let Some(follow_peer_id) = follow_peer_id { - workspace.follow(follow_peer_id, window, cx); - } + if let Some(follow_peer_id) = follow_peer_id { + workspace.follow(follow_peer_id, window, cx); } }); })?; @@ -9963,6 +10050,87 @@ mod tests { assert!(!task.await.unwrap()); } + #[gpui::test] + async fn test_multi_workspace_close_window_multiple_workspaces_cancel(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({ "one": "" })).await; + + let project_a = Project::test(fs.clone(), ["root".as_ref()], cx).await; + let project_b = Project::test(fs, ["root".as_ref()], cx).await; + let multi_workspace_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + cx.run_until_parked(); + + let workspace_a = multi_workspace_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + let workspace_b = multi_workspace_handle + .update(cx, |mw, window, cx| { + mw.test_add_workspace(project_b, window, cx) + }) + .unwrap(); + + // Activate workspace A + multi_workspace_handle + .update(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx); + + // Workspace A has a clean item + let item_a = cx.new(TestItem::new); + workspace_a.update_in(cx, |w, window, cx| { + w.add_item_to_active_pane(Box::new(item_a.clone()), None, true, window, cx) + }); + + // Workspace B has a dirty item + let item_b = cx.new(|cx| TestItem::new(cx).with_dirty(true)); + workspace_b.update_in(cx, |w, window, cx| { + w.add_item_to_active_pane(Box::new(item_b.clone()), None, true, window, cx) + }); + + // Verify workspace A is active + multi_workspace_handle + .read_with(cx, |mw, _| { + assert_eq!(mw.active_workspace_index(), 0); + }) + .unwrap(); + + // Dispatch CloseWindow — workspace A will pass, workspace B will prompt + multi_workspace_handle + .update(cx, |mw, window, cx| { + mw.close_window(&CloseWindow, window, cx); + }) + .unwrap(); + cx.run_until_parked(); + + // Workspace B should now be active since it has dirty items that need attention + multi_workspace_handle + .read_with(cx, |mw, _| { + assert_eq!( + mw.active_workspace_index(), + 1, + "workspace B should be activated when it prompts" + ); + }) + .unwrap(); + + // User cancels the save prompt from workspace B + cx.simulate_prompt_answer("Cancel"); + cx.run_until_parked(); + + // Window should still exist because workspace B's close was cancelled + assert!( + multi_workspace_handle.update(cx, |_, _, _| ()).is_ok(), + "window should still exist after cancelling one workspace's close" + ); + } + #[gpui::test] async fn test_close_window_with_serializable_items(cx: &mut TestAppContext) { init_test(cx); @@ -10468,6 +10636,58 @@ mod tests { }); } + #[gpui::test] + async fn test_activate_last_pane(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + workspace.update_in(cx, |workspace, window, cx| { + let first_item = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "1.txt", cx)]) + }); + workspace.add_item_to_active_pane(Box::new(first_item), None, true, window, cx); + workspace.split_pane( + workspace.active_pane().clone(), + SplitDirection::Right, + window, + cx, + ); + workspace.split_pane( + workspace.active_pane().clone(), + SplitDirection::Right, + window, + cx, + ); + }); + + let (first_pane_id, target_last_pane_id) = workspace.update(cx, |workspace, _cx| { + let panes = workspace.center.panes(); + assert!(panes.len() >= 2); + ( + panes.first().expect("at least one pane").entity_id(), + panes.last().expect("at least one pane").entity_id(), + ) + }); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.activate_pane_at_index(&ActivatePane(0), window, cx); + }); + workspace.update(cx, |workspace, _| { + assert_eq!(workspace.active_pane().entity_id(), first_pane_id); + assert_ne!(workspace.active_pane().entity_id(), target_last_pane_id); + }); + + cx.dispatch_action(ActivateLastPane); + + workspace.update(cx, |workspace, _| { + assert_eq!(workspace.active_pane().entity_id(), target_last_pane_id); + }); + } + #[gpui::test] async fn test_toggle_docks_and_panels(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -10917,8 +11137,9 @@ mod tests { init_test(cx); let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); // Open two docks (left and right) with one panel each let (left_panel, right_panel) = workspace.update_in(cx, |workspace, window, cx| { @@ -11349,8 +11570,9 @@ mod tests { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let (panel_1, panel_2) = workspace.update_in(cx, |workspace, window, cx| { let panel_1 = cx.new(|cx| TestPanel::new(DockPosition::Left, 100, cx)); @@ -12257,8 +12479,9 @@ mod tests { init_test(cx); let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); // Add a new panel to the right dock, opening the dock and setting the // focus to the new panel. @@ -12947,8 +13170,9 @@ mod tests { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let panel = workspace.update_in(cx, |workspace, window, cx| { let panel = cx.new(|cx| TestPanel::new(DockPosition::Right, 100, cx)); workspace.add_panel(panel.clone(), window, cx); @@ -13012,6 +13236,7 @@ mod tests { let multi_workspace_handle = cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + cx.run_until_parked(); let workspace_a = multi_workspace_handle .read_with(cx, |mw, _| mw.workspace().clone()) diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 638a57747b3b1f..aef846dc92f6ae 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.226.0" +version = "0.227.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index 84b52452919942..b291b9c8493db7 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -397,7 +397,7 @@ struct BuildTiming { duration_ms: f32, first_crate: String, target: String, - lock_wait_ms: f32, + blocked_ms: f32, command: String, } @@ -452,7 +452,7 @@ async fn upload_build_timings(_client: Arc) -> Result<()> { duration_ms = timing.duration_ms, first_crate = timing.first_crate, target = timing.target, - lock_wait_ms = timing.lock_wait_ms, + blocked_ms = timing.blocked_ms, command = timing.command, cpu_count = cpu_count, ram_size_gb = ram_size_gb diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 6b1aca5939bf9a..b7471321db2030 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -1945,7 +1945,6 @@ impl AgentServer for StubAgentServer { fn connect( &self, - _root_dir: Option<&Path>, _delegate: AgentServerDelegate, _cx: &mut App, ) -> gpui::Task, Option)>> { @@ -1963,7 +1962,7 @@ fn run_agent_thread_view_test( cx: &mut VisualTestAppContext, update_baseline: bool, ) -> Result { - use agent::AgentTool; + use agent::{AgentTool, ToolInput}; use agent_ui::AgentPanel; // Create a temporary directory with the test image @@ -2048,7 +2047,10 @@ fn run_agent_thread_view_test( start_line: None, end_line: None, }; - let run_task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + let run_task = cx.update(|cx| { + tool.clone() + .run(ToolInput::resolved(input), event_stream, cx) + }); cx.background_executor.allow_parking(); let run_result = cx.foreground_executor.block_test(run_task); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index cadb9fcae1bab6..28446c1e75fb7f 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -376,8 +376,19 @@ pub fn initialize_workspace( return; }; let multi_workspace_handle = cx.entity(); - let sidebar = cx.new(|cx| Sidebar::new(multi_workspace_handle, window, cx)); + let sidebar = cx.new(|cx| Sidebar::new(multi_workspace_handle.clone(), window, cx)); multi_workspace.register_sidebar(sidebar, window, cx); + + let multi_workspace_handle = multi_workspace_handle.downgrade(); + window.on_window_should_close(cx, move |window, cx| { + multi_workspace_handle + .update(cx, |multi_workspace, cx| { + // We'll handle closing asynchronously + multi_workspace.close_window(&CloseWindow, window, cx); + false + }) + .unwrap_or(true) + }); }) .detach(); @@ -485,17 +496,6 @@ pub fn initialize_workspace( status_bar.add_right_item(image_info, window, cx); }); - let handle = cx.entity().downgrade(); - window.on_window_should_close(cx, move |window, cx| { - handle - .update(cx, |workspace, cx| { - // We'll handle closing asynchronously - workspace.close_window(&CloseWindow, window, cx); - false - }) - .unwrap_or(true) - }); - initialize_panels(prompt_builder.clone(), window, cx); register_actions(app_state.clone(), workspace, window, cx); diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index 40582c8e13ff82..debcb605f222dc 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -2,7 +2,7 @@ use collab_ui::collab_panel; use gpui::{App, Menu, MenuItem, OsAction}; use release_channel::ReleaseChannel; use terminal_view::terminal_panel; -use zed_actions::{ToggleFocus as ToggleDebugPanel, dev}; +use zed_actions::{debug_panel, dev}; pub fn app_menus(cx: &mut App) -> Vec

{ use zed_actions::Quit; @@ -43,7 +43,7 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::action("Outline Panel", outline_panel::ToggleFocus), MenuItem::action("Collab Panel", collab_panel::ToggleFocus), MenuItem::action("Terminal Panel", terminal_panel::ToggleFocus), - MenuItem::action("Debugger Panel", ToggleDebugPanel), + MenuItem::action("Debugger Panel", debug_panel::ToggleFocus), MenuItem::separator(), MenuItem::action("Diagnostics", diagnostics::Deploy), MenuItem::separator(), diff --git a/crates/zed/src/zed/edit_prediction_registry.rs b/crates/zed/src/zed/edit_prediction_registry.rs index 326ddef2d4b1e0..9381dae22b055b 100644 --- a/crates/zed/src/zed/edit_prediction_registry.rs +++ b/crates/zed/src/zed/edit_prediction_registry.rs @@ -60,13 +60,13 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { cx.on_action(clear_edit_prediction_store_edit_history); - let mut provider_config = edit_prediction_provider_config_for_settings(cx); cx.subscribe(&user_store, { let editors = editors.clone(); let client = client.clone(); move |user_store, event, cx| { if let client::user::Event::PrivateUserInfoUpdated = event { + let provider_config = edit_prediction_provider_config_for_settings(cx); assign_edit_prediction_providers( &editors, provider_config, @@ -80,18 +80,39 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { .detach(); cx.observe_global::({ + let editors = editors.clone(); + let client = client.clone(); let user_store = user_store.clone(); + let mut previous_config = edit_prediction_provider_config_for_settings(cx); move |cx| { let new_provider_config = edit_prediction_provider_config_for_settings(cx); - if new_provider_config != provider_config { + if new_provider_config != previous_config { telemetry::event!( "Edit Prediction Provider Changed", - from = provider_config.map(|config| config.name()), + from = previous_config.map(|config| config.name()), to = new_provider_config.map(|config| config.name()) ); - provider_config = new_provider_config; + previous_config = new_provider_config; + assign_edit_prediction_providers( + &editors, + new_provider_config, + &client, + user_store.clone(), + cx, + ); + } + } + }) + .detach(); + + cx.observe_flag::({ + let mut previous_config = edit_prediction_provider_config_for_settings(cx); + move |_is_enabled, cx| { + let new_provider_config = edit_prediction_provider_config_for_settings(cx); + if new_provider_config != previous_config { + previous_config = new_provider_config; assign_edit_prediction_providers( &editors, new_provider_config, @@ -324,3 +345,101 @@ fn assign_edit_prediction_provider( } } } + +#[cfg(test)] +mod tests { + use super::*; + use editor::MultiBuffer; + use gpui::{BorrowAppContext, TestAppContext}; + use settings::{EditPredictionProvider, SettingsStore}; + use workspace::AppState; + + #[gpui::test] + async fn test_subscribe_uses_stale_provider_config_after_settings_change( + cx: &mut TestAppContext, + ) { + let app_state = cx.update(|cx| { + let app_state = AppState::test(cx); + client::init(&app_state.client, cx); + language_model::init(app_state.client.clone(), cx); + editor::init(cx); + app_state + }); + + // Override the default provider to None so the subscribe closure + // captures None at init time. (The test default is Zed/Zeta1, which + // is a no-op on project-less editors and would mask the bug.) + cx.update(|cx| { + cx.update_global::(|store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.edit_predictions = + Some(settings::EditPredictionSettingsContent { + provider: Some(EditPredictionProvider::None), + ..Default::default() + }); + }); + }); + }); + + cx.update(|cx| { + init(app_state.client.clone(), app_state.user_store.clone(), cx); + }); + + // Create an editor in a window so observe_new registers it. + let editor = cx.add_window(|window, cx| { + let buffer = cx.new(|_cx| MultiBuffer::new(language::Capability::ReadWrite)); + Editor::new(editor::EditorMode::full(), buffer, None, window, cx) + }); + + editor + .update(cx, |editor, _window, _cx| { + assert!( + editor.edit_prediction_provider().is_none(), + "editor should start with no provider when settings = None" + ); + }) + .unwrap(); + + // Change settings to Codestral. The observe_global closure updates its + // own copy of provider_config and assigns Codestral to all editors. + cx.update(|cx| { + cx.update_global::(|store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.edit_predictions = + Some(settings::EditPredictionSettingsContent { + provider: Some(EditPredictionProvider::Codestral), + ..Default::default() + }); + }); + }); + }); + + editor + .update(cx, |editor, _window, _cx| { + assert!( + editor.edit_prediction_provider().is_some(), + "editor should have a provider after changing settings to Codestral" + ); + }) + .unwrap(); + + // Emit PrivateUserInfoUpdated. The subscribe closure should use the + // CURRENT provider config (Codestral), but due to the bug it uses the + // stale init-time value (None) and clears the provider. + cx.update(|cx| { + app_state.user_store.update(cx, |_, cx| { + cx.emit(client::user::Event::PrivateUserInfoUpdated); + }); + }); + cx.run_until_parked(); + + editor + .update(cx, |editor, _window, _cx| { + assert!( + editor.edit_prediction_provider().is_some(), + "BUG: subscribe closure used stale provider_config (None) instead of current (Codestral)" + ); + }) + .unwrap(); + } +} diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 848673b9377a49..ae785bb4a0c792 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -639,15 +639,19 @@ actions!( ] ); -actions!( - debug_panel, - [ - /// Toggles the debug panel. - Toggle, - /// Toggles focus on the debug panel. - ToggleFocus - ] -); +pub mod debug_panel { + use gpui::actions; + actions!( + debug_panel, + [ + /// Toggles the debug panel. + Toggle, + /// Toggles focus on the debug panel. + ToggleFocus + ] + ); +} + actions!( debugger, [ @@ -732,3 +736,17 @@ pub mod preview { ); } } + +pub mod notebook { + use gpui::actions; + + actions!( + notebook, + [ + /// Move to down in cells + NotebookMoveDown, + /// Move to up in cells + NotebookMoveUp, + ] + ); +} diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 7391683d34d801..bdd5afffa975ad 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -213,6 +213,8 @@ pub struct RelatedFile { pub struct RelatedExcerpt { pub row_range: Range, pub text: Arc, + #[serde(default)] + pub order: usize, } pub fn prompt_input_contains_special_tokens(input: &ZetaPromptInput, format: ZetaFormat) -> bool { @@ -356,6 +358,7 @@ fn format_zeta_prompt_with_budget( let related_files_section = format_related_files_within_budget( &input.related_files, "<|file_sep|>", + "", budget_after_edit_history, ); @@ -419,53 +422,98 @@ fn format_edit_history_within_budget( result } -fn format_related_files_within_budget( +fn excerpt_rendered_tokens(excerpt: &RelatedExcerpt, file_max_row: u32) -> usize { + let needs_newline = !excerpt.text.ends_with('\n'); + let needs_ellipsis = excerpt.row_range.end < file_max_row; + let len = excerpt.text.len() + + if needs_newline { "\n".len() } else { 0 } + + if needs_ellipsis { "...\n".len() } else { 0 }; + estimate_tokens(len) +} + +pub fn format_related_files_within_budget( related_files: &[RelatedFile], - file_marker: &str, + file_prefix: &str, + file_suffix: &str, max_tokens: usize, ) -> String { - let mut result = String::new(); - let mut total_tokens = 0; + struct ExcerptCandidate { + file_ix: usize, + excerpt_ix: usize, + order: usize, + } - for file in related_files { - let path_str = file.path.to_string_lossy(); - let header = format!("{}{}\n", file_marker, path_str); - let header_tokens = estimate_tokens(header.len()); + let mut excerpt_candidates: Vec = related_files + .iter() + .enumerate() + .flat_map(|(file_ix, file)| { + file.excerpts + .iter() + .enumerate() + .map(move |(excerpt_ix, e)| ExcerptCandidate { + file_ix, + excerpt_ix, + order: e.order, + }) + }) + .collect(); - if total_tokens + header_tokens > max_tokens { + // Pre-compute file header strings and their token costs. + let file_headers: Vec = related_files + .iter() + .map(|file| { + let path_str = file.path.to_string_lossy(); + format!("{}{}\n", file_prefix, path_str) + }) + .collect(); + + // Sort the excerpts by their order and determine how many fit within the budget. + let mut total_tokens = 0; + let mut included_excerpt_count = 0_usize; + let mut included_file_indices = vec![false; related_files.len()]; + excerpt_candidates.sort_by_key(|e| (e.order, e.file_ix, e.excerpt_ix)); + for candidate in &excerpt_candidates { + let file = &related_files[candidate.file_ix]; + let excerpt = &file.excerpts[candidate.excerpt_ix]; + let file_already_included = included_file_indices[candidate.file_ix]; + let header_cost = if file_already_included { + 0 + } else { + estimate_tokens(file_headers[candidate.file_ix].len() + file_suffix.len()) + }; + let excerpt_cost = excerpt_rendered_tokens(excerpt, file.max_row); + if total_tokens + header_cost + excerpt_cost > max_tokens { break; } + total_tokens += header_cost + excerpt_cost; + if !file_already_included { + included_file_indices[candidate.file_ix] = true; + } + included_excerpt_count += 1; + } - let mut file_tokens = header_tokens; - let mut excerpts_to_include = 0; + excerpt_candidates.truncate(included_excerpt_count); + excerpt_candidates.sort_unstable_by_key(|c| (c.file_ix, c.excerpt_ix)); - for excerpt in &file.excerpts { - let needs_newline = !excerpt.text.ends_with('\n'); - let needs_ellipsis = excerpt.row_range.end < file.max_row; - let excerpt_len = excerpt.text.len() - + if needs_newline { "\n".len() } else { 0 } - + if needs_ellipsis { "...\n".len() } else { 0 }; - - let excerpt_tokens = estimate_tokens(excerpt_len); - if total_tokens + file_tokens + excerpt_tokens > max_tokens { - break; + // Render all of the files that fit within the token budget, in the original order. + let mut result = String::new(); + let mut last_file_ix = None; + for candidate in &excerpt_candidates { + if last_file_ix != Some(candidate.file_ix) { + if last_file_ix.is_some() { + result.push_str(file_suffix); } - file_tokens += excerpt_tokens; - excerpts_to_include += 1; + result.push_str(&file_headers[candidate.file_ix]); + last_file_ix = Some(candidate.file_ix); } - - if excerpts_to_include > 0 { - total_tokens += file_tokens; - result.push_str(&header); - for excerpt in file.excerpts.iter().take(excerpts_to_include) { - result.push_str(&excerpt.text); - if !result.ends_with('\n') { - result.push('\n'); - } - if excerpt.row_range.end < file.max_row { - result.push_str("...\n"); - } - } + let file = &related_files[candidate.file_ix]; + let excerpt = &file.excerpts[candidate.excerpt_ix]; + result.push_str(&excerpt.text); + if !result.ends_with('\n') { + result.push('\n'); + } + if excerpt.row_range.end < file.max_row { + result.push_str("...\n"); } } @@ -842,6 +890,7 @@ pub mod seed_coder { let related_files_section = super::format_related_files_within_budget( related_files, FILE_MARKER, + "", budget_after_edit_history, ); @@ -1136,6 +1185,7 @@ mod tests { excerpts: vec![RelatedExcerpt { row_range: 0..content.lines().count() as u32, text: content.into(), + order: 0, }], in_open_source_repo: false, } @@ -1244,14 +1294,17 @@ mod tests { RelatedExcerpt { row_range: 0..10, text: "first excerpt\n".into(), + order: 0, }, RelatedExcerpt { row_range: 10..20, text: "second excerpt\n".into(), + order: 0, }, RelatedExcerpt { row_range: 20..30, text: "third excerpt\n".into(), + order: 0, }, ], }], @@ -1291,6 +1344,149 @@ mod tests { ); } + #[test] + fn test_truncation_prioritizes_lower_order_excerpts() { + // Two files: file_a has a high-order excerpt, file_b has a low-order one. + // With tight budget, only the lower-order excerpt from file_b should be included. + let input = make_input( + "x", + 0..1, + 0, + vec![], + vec![ + RelatedFile { + path: Path::new("file_a.rs").into(), + max_row: 10, + in_open_source_repo: false, + excerpts: vec![RelatedExcerpt { + row_range: 0..10, + text: "low priority content\n".into(), + order: 5, + }], + }, + RelatedFile { + path: Path::new("file_b.rs").into(), + max_row: 10, + in_open_source_repo: false, + excerpts: vec![RelatedExcerpt { + row_range: 0..10, + text: "high priority content\n".into(), + order: 1, + }], + }, + ], + ); + + // With large budget, both files included; rendered in stable lexicographic order. + assert_eq!( + format_with_budget(&input, 10000), + indoc! {r#" + <|file_sep|>file_a.rs + low priority content + <|file_sep|>file_b.rs + high priority content + <|file_sep|>test.rs + <|fim_prefix|> + <|fim_middle|>current + <|user_cursor|>x + <|fim_suffix|> + <|fim_middle|>updated + "#} + ); + + // With tight budget, only file_b (lower order) fits. + // Cursor section is ~37 tokens, so budget 52 leaves ~15 for related files. + // file_b header (7) + excerpt (7) = 14 tokens, which fits. + // file_a would need another 14 tokens, which doesn't fit. + assert_eq!( + format_with_budget(&input, 52), + indoc! {r#" + <|file_sep|>file_b.rs + high priority content + <|file_sep|>test.rs + <|fim_prefix|> + <|fim_middle|>current + <|user_cursor|>x + <|fim_suffix|> + <|fim_middle|>updated + "#} + ); + } + + #[test] + fn test_truncation_drops_high_order_excerpts_within_file() { + // A single file has excerpts at order 1 and order 3. With a tight budget, + // only the order-1 excerpts are included while the order-3 excerpt is + // dropped — even though they belong to the same file. This also preserves + // the parent invariant: parent outline items have order ≤ their best + // child, so they're always included when any child is. + let input = make_input( + "x", + 0..1, + 0, + vec![], + vec![RelatedFile { + path: Path::new("mod.rs").into(), + max_row: 30, + in_open_source_repo: false, + excerpts: vec![ + RelatedExcerpt { + row_range: 0..5, + text: "mod header\n".into(), + order: 1, + }, + RelatedExcerpt { + row_range: 5..15, + text: "important fn\n".into(), + order: 1, + }, + RelatedExcerpt { + row_range: 15..30, + text: "less important fn\n".into(), + order: 3, + }, + ], + }], + ); + + // With large budget, all three excerpts included. + assert_eq!( + format_with_budget(&input, 10000), + indoc! {r#" + <|file_sep|>mod.rs + mod header + ... + important fn + ... + less important fn + <|file_sep|>test.rs + <|fim_prefix|> + <|fim_middle|>current + <|user_cursor|>x + <|fim_suffix|> + <|fim_middle|>updated + "#} + ); + + // With tight budget, only order<=1 excerpts included (header + important fn). + assert_eq!( + format_with_budget(&input, 55), + indoc! {r#" + <|file_sep|>mod.rs + mod header + ... + important fn + ... + <|file_sep|>test.rs + <|fim_prefix|> + <|fim_middle|>current + <|user_cursor|>x + <|fim_suffix|> + <|fim_middle|>updated + "#} + ); + } + #[test] fn test_truncation_drops_older_events_first() { let input = make_input( @@ -1463,6 +1659,72 @@ mod tests { ); } + #[test] + fn test_seed_coder_truncation_prioritizes_lower_order() { + let input = make_input( + "code", + 0..4, + 2, + vec![], + vec![ + RelatedFile { + path: Path::new("low_prio.rs").into(), + max_row: 5, + in_open_source_repo: false, + excerpts: vec![RelatedExcerpt { + row_range: 0..5, + text: "low prio\n".into(), + order: 10, + }], + }, + RelatedFile { + path: Path::new("high_prio.rs").into(), + max_row: 5, + in_open_source_repo: false, + excerpts: vec![RelatedExcerpt { + row_range: 0..5, + text: "high prio\n".into(), + order: 1, + }], + }, + ], + ); + + // With large budget, both included; rendered in stable lexicographic order. + assert_eq!( + format_seed_coder(&input), + indoc! {r#" + <[fim-suffix]> + <[fim-prefix]>low_prio.rs + low prio + high_prio.rs + high prio + + test.rs + <<<<<<< CURRENT + co<|user_cursor|>de + ======= + <[fim-middle]>"#} + ); + + // With tight budget, only high_prio included. + // Cursor sections cost 25 tokens, so budget 44 leaves 19 for related files. + // high_prio header (7) + excerpt (3) = 10, fits. low_prio would add 10 more = 20 > 19. + assert_eq!( + format_seed_coder_with_budget(&input, 44), + indoc! {r#" + <[fim-suffix]> + <[fim-prefix]>high_prio.rs + high prio + + test.rs + <<<<<<< CURRENT + co<|user_cursor|>de + ======= + <[fim-middle]>"#} + ); + } + #[test] fn test_seed_coder_clean_output() { let output_with_marker = "new code\n>>>>>>> UPDATED\n"; diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index b62512d469bee4..7e183d38550d36 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -114,8 +114,6 @@ The agent can search your codebase to find relevant context, but providing it ex Add context by typing `@` in the message editor. You can mention files, directories, symbols, previous threads, rules files, and diagnostics. -Copying images and pasting them in the panel's message editor is also supported. - When you paste multi-line code selections copied from a buffer, Zed automatically formats them as @-mentions with the file context. To paste content without this automatic formatting, use {#kb agent::PasteRaw} to paste raw text directly. @@ -123,6 +121,14 @@ To paste content without this automatic formatting, use {#kb agent::PasteRaw} to Additionally, you can also select text in a buffer or terminal and add it as context by using the {#kb agent::AddSelectionToThread} keybinding, running the {#action agent::AddSelectionToThread} action, or choosing the "Selection" item in the `+` menu in the message editor. +### Images as Context + +It's also possible to attach images in your prompt for providers that support vision models. +OpenAI GPT-4o and later, Anthropic Claude 3 and later, Google Gemini 1.5 and 2.0, and Bedrock vision models (Claude 3+, Amazon Nova Pro and Lite, Meta Llama 3.2 Vision, Mistral Pixtral) all support image inputs. + +To add an image, you can either search in your project's directory by @-mentioning it, or drag it from your file system directly into the agent panel message editor. +Copying an image and pasting it is also supported. + ## Token Usage {#token-usage} Zed surfaces how many tokens you are consuming for your currently active thread near the profile selector in the panel's message editor. @@ -168,7 +174,7 @@ You can explore the exact tools enabled in each profile by clicking on the profi Alternatively, you can also use either the command palette, by running {#action agent::ManageProfiles}, or the keybinding directly, {#kb agent::ManageProfiles}, to have access to the profile management modal. -Use {#kb agent::CycleModeSelector} to switch between profiles without opening the modal. +Use {#kb agent::CycleModeSelector} to cycle through available profiles without opening the modal. #### Custom Profiles {#custom-profiles} diff --git a/docs/src/ai/agent-settings.md b/docs/src/ai/agent-settings.md index af02bd5f2072ee..0547f19c9ca0e5 100644 --- a/docs/src/ai/agent-settings.md +++ b/docs/src/ai/agent-settings.md @@ -290,10 +290,10 @@ See the [Tool Permissions](./tool-permissions.md) documentation for more example > **Note:** Before Zed v0.224.0, tool approval was controlled by the `agent.always_allow_tool_actions` boolean (default `false`). Set it to `true` to auto-approve tool actions, or leave it `false` to require confirmation for edits and tool calls. -### Single-file Review +### Edit Display Mode Control whether to display review actions (accept & reject) in single buffers after the agent is done performing edits. -The default value is `true`. +The default value is `false`. ```json [settings] { @@ -303,8 +303,6 @@ The default value is `true`. } ``` -When set to `false`, these controls are only available in the multibuffer review tab. - ### Sound Notification Control whether to hear a notification sound when the agent is done generating changes or needs your input. diff --git a/docs/src/ai/edit-prediction.md b/docs/src/ai/edit-prediction.md index 56b1c1260ec98b..973dc9546a8b81 100644 --- a/docs/src/ai/edit-prediction.md +++ b/docs/src/ai/edit-prediction.md @@ -301,8 +301,6 @@ Edit Prediction also works with other providers. ### GitHub Copilot {#github-copilot} -> **Changed in Preview (v0.225).** See [release notes](/releases#0.225). - To use GitHub Copilot as your provider, set this in your settings file ([how to edit](../configuring-zed.md#settings-files)): ```json [settings] @@ -408,13 +406,49 @@ After adding your API key, Codestral will appear in the provider dropdown in the ### Self-Hosted OpenAI-compatible servers -To configure Zed to use an arbitrary server for edit predictions: +> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release. -1. Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows) -2. Search for "Edit Predictions" and click **Configure Providers** -3. Find the "OpenAI-compatible API" section and enter the URL and model name. You can also select a prompt format that Zed should use. Zed currently supports several FIM prompt formats, as well as Zed's own Zeta prompt format. If you do not select a prompt format, Zed will attempt to infer it from the model name. +You can use any self-hosted server that implements the OpenAI completion API format. This works with vLLM, llama.cpp server, LocalAI, and other compatible servers. + +#### Configuration + +Set `open_ai_compatible_api` as your provider and configure the API endpoint: -The URL must accept requests according to OpenAI's [Completions API](https://developers.openai.com/api/reference/resources/completions/methods/create) +```json [settings] +{ + "edit_predictions": { + "provider": "open_ai_compatible_api", + "open_ai_compatible_api": { + "api_url": "http://localhost:8080/v1/completions", + "model": "deepseek-coder-6.7b-base", + "prompt_format": "deepseek_coder", + "max_output_tokens": 64 + } + } +} +``` + +The `prompt_format` setting controls how code context is formatted for the model. Use `"infer"` to detect the format from the model name, or specify one explicitly: + +- `code_llama` - CodeLlama format: `
 prefix  suffix `
+- `star_coder` - StarCoder format: `prefixsuffix`
+- `deepseek_coder` - DeepSeek format with special unicode markers
+- `qwen` - Qwen/CodeGemma format: `<|fim_prefix|>prefix<|fim_suffix|>suffix<|fim_middle|>`
+- `codestral` - Codestral format: `[SUFFIX]suffix[PREFIX]prefix`
+- `glm` - GLM-4 format with code markers
+- `infer` - Auto-detect from model name (default)
+
+Your server must implement the OpenAI `/v1/completions` endpoint. Edit predictions will send POST requests with this format:
+
+```json
+{
+  "model": "your-model-name",
+  "prompt": "formatted-code-context",
+  "max_tokens": 256,
+  "temperature": 0.2,
+  "stop": ["<|endoftext|>", ...]
+}
+```
 
 ## See also
 
diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md
index 696427ae6981df..8c2dc976fcccfe 100644
--- a/docs/src/ai/llm-providers.md
+++ b/docs/src/ai/llm-providers.md
@@ -151,7 +151,9 @@ For the most up-to-date supported regions and models, refer to the [Supported Mo
 
 #### Extended Context Window {#bedrock-extended-context}
 
-Anthropic models on Bedrock support a [1M token extended context window](https://docs.anthropic.com/en/docs/build-with-claude/extended-context) beta. To enable this feature, add `"allow_extended_context": true` to your Bedrock configuration:
+> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release.
+
+Anthropic models on Bedrock support a 1M token extended context window through the `anthropic_beta` API parameter. To enable this feature, set `"allow_extended_context": true` in your Bedrock configuration:
 
 ```json [settings]
 {
@@ -166,9 +168,13 @@ Anthropic models on Bedrock support a [1M token extended context window](https:/
 }
 ```
 
-When enabled, Zed will include the `anthropic_beta` field in requests to Bedrock, enabling the 1M token context window for supported Anthropic models such as Claude Sonnet 4.5 and Claude Opus 4.6.
+Zed enables extended context for supported models (Claude Sonnet 4.5 and Claude Opus 4.6). Extended context usage may increase API costs—refer to AWS Bedrock pricing for details.
+
+#### Image Support {#bedrock-image-support}
+
+> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release.
 
-> **Note**: Extended context usage may incur additional API costs. Refer to your AWS Bedrock pricing for details.
+Bedrock models that support vision (Claude 3 and later, Amazon Nova Pro and Lite, Meta Llama 3.2 Vision models, Mistral Pixtral) can receive images in conversations and tool results.
 
 ### Anthropic {#anthropic}
 
@@ -303,6 +309,15 @@ Here is an example of a custom Google AI model you could add to your Zed setting
   "language_models": {
     "google": {
       "available_models": [
+        {
+          "name": "gemini-3.1-pro-preview",
+          "display_name": "Gemini 3.1 Pro",
+          "max_tokens": 1000000,
+          "mode": {
+            "type": "thinking",
+            "budget_tokens": 24000
+          }
+        },
         {
           "name": "gemini-3-flash-preview",
           "display_name": "Gemini 3 Flash (Thinking)",
@@ -614,6 +629,25 @@ The OpenRouter API key will be saved in your keychain.
 
 Zed will also use the `OPENROUTER_API_KEY` environment variable if it's defined.
 
+> **Changed in Preview (v0.225).** See [release notes](/releases#0.225).
+
+When using OpenRouter as your assistant provider, you must explicitly select a model in your settings. OpenRouter no longer provides a default model selection.
+
+Configure your preferred OpenRouter model in `settings.json`:
+
+```json [settings]
+{
+  "agent": {
+    "default_model": {
+      "provider": "openrouter",
+      "model": "openrouter/auto"
+    }
+  }
+}
+```
+
+The `openrouter/auto` model automatically routes your requests to the most appropriate available model. You can also specify any model available through OpenRouter's API.
+
 #### Custom Models {#openrouter-custom-models}
 
 You can add custom models to the OpenRouter provider by adding the following to your Zed settings file ([how to edit](../configuring-zed.md#settings-files)):
diff --git a/docs/src/ai/mcp.md b/docs/src/ai/mcp.md
index 1a3ee231f204eb..08608a9871b7f8 100644
--- a/docs/src/ai/mcp.md
+++ b/docs/src/ai/mcp.md
@@ -86,7 +86,7 @@ Once installation is complete, you can return to the Agent Panel and start promp
 How reliably MCP tools get called can vary from model to model.
 Mentioning the MCP server by name can help the model pick tools from that server.
 
-If you want to _ensure_ a given MCP server will be used, you can create [a custom profile](./agent-panel.md#custom-profiles) where all built-in tools (or the ones that could cause conflicts with the server's tools) are turned off and only the tools coming from the MCP server are turned on.
+However, if you want to _ensure_ a given MCP server will be used, you can create [a custom profile](./agent-panel.md#custom-profiles) where all built-in tools (or the ones that could cause conflicts with the server's tools) are turned off and only the tools coming from the MCP server are turned on.
 
 As an example, [the Dagger team suggests](https://container-use.com/agent-integrations#zed) doing that with their [Container Use MCP server](https://zed.dev/extensions/mcp-server-container-use):
 
@@ -156,3 +156,15 @@ Note that for [external agents](./external-agents.md) connected through the [Age
 
 Regarding the built-in ones, Claude Agent and Codex both support it, and Gemini CLI does not yet.
 In the meantime, learn how to add MCP server support to Gemini CLI through [their documentation](https://github.com/google-gemini/gemini-cli?tab=readme-ov-file#using-mcp-servers).
+
+### Error Handling
+
+When a MCP server encounters an error while processing a tool call, the agent receives the error message directly and the operation fails.
+Common error scenarios include:
+
+- Invalid parameters passed to the tool
+- Server-side failures (database connection issues, rate limits)
+- Unsupported operations or missing resources
+
+The error message from the context server will be shown in the agent's response, allowing you to diagnose and correct the issue.
+Check the context server's logs or documentation for details about specific error codes.
diff --git a/docs/src/ai/models.md b/docs/src/ai/models.md
index 09c26305d277d9..a86b873ef8aff1 100644
--- a/docs/src/ai/models.md
+++ b/docs/src/ai/models.md
@@ -43,6 +43,8 @@ Zed's plans offer hosted versions of major LLMs with higher rate limits than dir
 |                        | OpenAI    | Cached Input        | $0.005                       | $0.0055                 |
 | Gemini 3.1 Pro         | Google    | Input               | $2.00                        | $2.20                   |
 |                        | Google    | Output              | $12.00                       | $13.20                  |
+| Gemini 3.1 Pro         | Google    | Input               | $2.00                        | $2.20                   |
+|                        | Google    | Output              | $12.00                       | $13.20                  |
 | Gemini 3 Pro           | Google    | Input               | $2.00                        | $2.20                   |
 |                        | Google    | Output              | $12.00                       | $13.20                  |
 | Gemini 3 Flash         | Google    | Input               | $0.30                        | $0.33                   |
@@ -68,7 +70,7 @@ As of February 19, 2026, Zed Pro serves newer model versions in place of the ret
 - Claude Sonnet 4 → Claude Sonnet 4.5 or Claude Sonnet 4.6
 - Claude Sonnet 3.7 (retired Feb 19) → Claude Sonnet 4.5 or Claude Sonnet 4.6
 - GPT-5.1 and GPT-5 → GPT-5.2 or GPT-5.2 Codex
-- Gemini 2.5 Pro → Gemini 3 Pro
+- Gemini 2.5 Pro → Gemini 3 Pro or Gemini 3.1 Pro
 - Gemini 2.5 Flash → Gemini 3 Flash
 
 ## Usage {#usage}
diff --git a/docs/src/collaboration/overview.md b/docs/src/collaboration/overview.md
index ce74a74ddc6c83..97efdae088d169 100644
--- a/docs/src/collaboration/overview.md
+++ b/docs/src/collaboration/overview.md
@@ -19,3 +19,32 @@ The Collaboration Panel has two sections:
 > **Warning:** Sharing a project gives collaborators access to your local file system within that project. Only collaborate with people you trust.
 
 See the [Data and Privacy FAQs](https://zed.dev/faq#data-and-privacy) for more details.
+
+## Audio Settings {#audio-settings}
+
+### Selecting Audio Devices
+
+> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release.
+
+You can select specific input and output audio devices instead of using system defaults. To configure audio devices:
+
+1. Open {#kb zed::OpenSettings}
+2. Navigate to **Collaboration** > **Experimental**
+3. Use the **Output Audio Device** and **Input Audio Device** dropdowns to select your preferred devices
+
+Changes take effect immediately. If you select a device that becomes unavailable, Zed falls back to system defaults.
+
+To test your audio configuration, click **Test Audio** in the same section. This opens a window where you can verify your microphone and speaker work correctly with the selected devices.
+
+**JSON configuration:**
+
+```json [settings]
+{
+  "audio": {
+    "experimental.output_audio_device": "Device Name (device-id)",
+    "experimental.input_audio_device": "Device Name (device-id)"
+  }
+}
+```
+
+Set either value to `null` to use system defaults.
diff --git a/docs/src/configuring-languages.md b/docs/src/configuring-languages.md
index 90fec03c0b96a0..4e9bbce822f2f0 100644
--- a/docs/src/configuring-languages.md
+++ b/docs/src/configuring-languages.md
@@ -136,6 +136,10 @@ Not all languages in Zed support toolchain discovery and selection, but for thos
 
 ### Configuring Language Servers
 
+> **Changed in Preview (v0.225).** See [release notes](/releases#0.225).
+
+When configuring language servers in your `settings.json`, autocomplete suggestions include all available LSP adapters recognized by Zed, not only those currently active for loaded languages. This helps you discover and configure language servers before opening files that use them.
+
 Many language servers accept custom configuration options. You can set these in the `lsp` section of your `settings.json`:
 
 ```json [settings]
diff --git a/docs/src/debugger.md b/docs/src/debugger.md
index 2a84821cac8809..c659c1410b3816 100644
--- a/docs/src/debugger.md
+++ b/docs/src/debugger.md
@@ -163,6 +163,16 @@ Some debug adapters (e.g. CodeLLDB and JavaScript) will also _verify_ whether yo
 All breakpoints enabled for a given project are also listed in "Breakpoints" item in your debugging session UI. From "Breakpoints" item in your UI you can also manage exception breakpoints.
 The debug adapter will then stop whenever an exception of a given kind occurs. Which exception types are supported depends on the debug adapter.
 
+## Working with Split Panes
+
+> **Changed in Preview (v0.225).** See [release notes](/releases#0.225).
+
+When debugging with multiple split panes open, Zed shows the active debug line in one pane and preserves your layout in others. If you have the same file open in multiple panes, the debugger picks a pane where the file is already the active tab—it won't switch tabs in panes where the file is inactive.
+
+Once the debugger picks a pane, it continues using that pane for subsequent breakpoints during the session. If you drag the tab with the active debug line to a different split, the debugger tracks the move and uses the new pane.
+
+This ensures the debugger doesn't disrupt your workflow when stepping through code across different files.
+
 ## Settings
 
 The settings for the debugger are grouped under the `debugger` key in `settings.json`:
diff --git a/docs/src/development.md b/docs/src/development.md
index 529ce2a69c08f2..d8ea0b3f980317 100644
--- a/docs/src/development.md
+++ b/docs/src/development.md
@@ -86,6 +86,36 @@ For benchmarking unit tests, annotate them with the `#[perf]` attribute from the
 perf-test -p $CRATE` to benchmark them. See the rustdoc documentation on `crates/util_macros` and `tooling/perf` for
 in-depth examples and explanations.
 
+## ETW Profiling on Windows
+
+> **Changed in Preview (v0.225).** See [release notes](/releases#0.225).
+
+Zed supports Event Tracing for Windows (ETW) to capture detailed performance data. You can record CPU, GPU, disk I/O, and file I/O activity, with optional heap allocation tracking.
+
+### Recording a trace
+
+Open the command palette and run:
+
+- **`etw_tracing: Record Etw Trace`** — Records CPU, GPU, and I/O activity
+- **`etw_tracing: Record Etw Trace With Heap Tracing`** — Includes heap allocation data for the Zed process
+
+Zed prompts you to choose a save location for the `.etl` trace file.
+
+### Saving or canceling
+
+While recording:
+
+- **`etw_tracing: Save Etw Trace`** — Stops recording and saves the trace to disk
+- **`etw_tracing: Cancel Etw Trace`** — Stops recording without saving
+
+Zed buffers trace data in memory. Recordings automatically save after 60 seconds if you don't manually stop them.
+
+### Analyzing traces
+
+Open `.etl` files with [Windows Performance Analyzer](https://learn.microsoft.com/en-us/windows-hardware/test/wpt/windows-performance-analyzer) to inspect CPU stacks, GPU usage, disk I/O patterns, and heap allocations.
+
+**Note for existing keybindings**: The `etw_tracing::StopEtwTrace` action was renamed to `etw_tracing::SaveEtwTrace`. Update any custom keybindings.
+
 ## Contributor links
 
 - [CONTRIBUTING.md](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md)
diff --git a/docs/src/finding-navigating.md b/docs/src/finding-navigating.md
index b5f6e3fff77428..f1d3536f8c909f 100644
--- a/docs/src/finding-navigating.md
+++ b/docs/src/finding-navigating.md
@@ -19,8 +19,6 @@ Open any file in your project with {#kb file_finder::Toggle}. Type part of the f
 
 ## Project Search
 
-> **Changed in Preview (v0.225).** See [release notes](/releases#0.225).
-
 Search across all files with {#kb pane::DeploySearch}. Start typing in the search field to begin searching—results appear as you type.
 
 Results appear in a [multibuffer](./multibuffers.md), letting you edit matches in place.
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index be5281b5609174..af6a41c26a6f70 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -11,6 +11,14 @@ This guide covers the essential commands, environment setup, and navigation basi
 
 ## Quick Start
 
+### Welcome Page
+
+> **Changed in Preview (v0.225).** See [release notes](/releases#0.225).
+
+When you open Zed without a folder, you see the welcome page in the main editor area. The welcome page offers quick actions to open a folder, clone a repository, or view documentation. Once you open a folder or file, the welcome page disappears. If you split the editor into multiple panes, the welcome page appears only in the center pane when empty—other panes show a standard empty state.
+
+To reopen the welcome page, close all items in the center pane or use the command palette to search for "Welcome".
+
 ### 1. Open a Project
 
 Open a folder from the command line:
diff --git a/docs/src/git.md b/docs/src/git.md
index 5e46dfc322a21d..b33aa0690cbad9 100644
--- a/docs/src/git.md
+++ b/docs/src/git.md
@@ -72,8 +72,6 @@ To disable word diff for specific languages only, add this to your settings.json
 
 ### Diff View Styles
 
-> **Changed in Preview (v0.225).** See [release notes](/releases#0.225).
-
 Zed displays diffs in two modes: **split** (side-by-side comparison) or **unified** (inline changes). Split view is the default.
 
 #### Changing the diff view
diff --git a/docs/src/globs.md b/docs/src/globs.md
index 26ecf51da8c042..f1fb584ee568d2 100644
--- a/docs/src/globs.md
+++ b/docs/src/globs.md
@@ -24,8 +24,6 @@ A glob "pattern" is used to match a file name or complete file path. For example
 
 ### Multiple Patterns
 
-> **Changed in Preview (v0.225).** See [release notes](/releases#0.225).
-
 You can specify multiple glob patterns in Project Search filters by separating them with commas. When using comma-separated patterns, Zed correctly handles braces within individual patterns:
 
 - `*.ts, *.tsx` — Match TypeScript and TSX files
diff --git a/docs/src/languages/json.md b/docs/src/languages/json.md
index 253669b2fca3a5..41644a8b0556c3 100644
--- a/docs/src/languages/json.md
+++ b/docs/src/languages/json.md
@@ -54,8 +54,6 @@ For example to for a `.luarc.json` for use with [lua-language-server](https://gi
 
 ### Schema Specification via Settings
 
-> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release.
-
 You can associate JSON Schemas with file paths using relative paths in your language server settings. Zed resolves paths relative to your project root:
 
 ```json [settings]
diff --git a/docs/src/languages/yaml.md b/docs/src/languages/yaml.md
index 907a07cc4366a2..b82e14d64bd20f 100644
--- a/docs/src/languages/yaml.md
+++ b/docs/src/languages/yaml.md
@@ -12,8 +12,6 @@ YAML support is available natively in Zed.
 
 ## Configuration
 
-> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release.
-
 You can configure various [yaml-language-server settings](https://github.com/redhat-developer/yaml-language-server?tab=readme-ov-file#language-server-settings) by adding them to your Zed settings.json in a `yaml-language-server` block under the `lsp` key.
 
 You can configure custom YAML schemas using relative paths. Zed resolves paths relative to your project root:
diff --git a/docs/src/linux.md b/docs/src/linux.md
index b1a82c332fa2ab..784e09696906db 100644
--- a/docs/src/linux.md
+++ b/docs/src/linux.md
@@ -65,7 +65,7 @@ We'd love your help making Zed available for everyone. If Zed is not yet availab
 
 The packages in this section provide binary installs for Zed but are not official packages within the associated distributions. These packages are maintained by community members and as such a higher level of caution should be taken when installing them.
 
-#### Debian
+#### Debian and Ubuntu
 
 Zed is available in [this community-maintained repository](https://debian.griffo.io/).
 
diff --git a/docs/src/outline-panel.md b/docs/src/outline-panel.md
index e5f1f911a4e025..1bacc3cacf4f55 100644
--- a/docs/src/outline-panel.md
+++ b/docs/src/outline-panel.md
@@ -7,7 +7,9 @@ description: Navigate code structure with Zed's outline panel. View symbols, jum
 
 In addition to the modal outline (`cmd-shift-o`), Zed offers an outline panel. The outline panel can be deployed via `cmd-shift-b` (`outline panel: toggle focus` via the command palette), or by clicking the `Outline Panel` button in the status bar.
 
-When viewing a "singleton" buffer (i.e., a single file on a tab), the outline panel works similarly to that of the outline modal-it displays the outline of the current buffer's symbols, as reported by tree-sitter. Clicking on an entry allows you to jump to the associated section in the file. The outline view will also automatically scroll to the section associated with the current cursor position within the file.
+> **Changed in Preview (v0.225).** See [release notes](/releases#0.225).
+
+When viewing a "singleton" buffer (i.e., a single file on a tab), the outline panel works similarly to that of the outline modal-it displays the outline of the current buffer's symbols. Each symbol entry shows its type prefix (such as "struct", "fn", "mod", "impl") along with the symbol name, helping you quickly identify what kind of symbol you're looking at. Clicking on an entry allows you to jump to the associated section in the file. The outline view will also automatically scroll to the section associated with the current cursor position within the file.
 
 ![Using the outline panel in a singleton buffer](https://zed.dev/img/outline-panel/singleton.png)
 
diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md
index 0211114aee3ce9..23b59f0b91002c 100644
--- a/docs/src/reference/all-settings.md
+++ b/docs/src/reference/all-settings.md
@@ -519,8 +519,6 @@ Note: Dirty files (files with unsaved changes) will not be automatically closed
 - `"unified"`: Show changes inline with added and deleted lines stacked vertically
 - `"split"`: Display old and new versions side by side in separate panes (default)
 
-> **Changed in Preview (v0.225).** Values renamed from `"stacked"`/`"side_by_side"` to `"unified"`/`"split"`.
-
 See [Git documentation](../git.md#diff-view-styles) for more details.
 
 ## Disable AI
@@ -2774,8 +2772,6 @@ These values take in the same options as the root-level settings with the same n
 
 ### Document Symbols
 
-> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release.
-
 - Description: Controls the source of document symbols used for outlines and breadcrumbs.
 - Setting: `document_symbols`
 - Default: `off`
@@ -5140,8 +5136,6 @@ See the [debugger page](../debugger.md) for more information about debugging sup
 
 ## Git Worktree Directory
 
-> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release.
-
 - Description: Directory where git worktrees are created, relative to the repository working directory.
 - Setting: `git.worktree_directory`
 - Default: `"../worktrees"`
diff --git a/docs/src/reference/cli.md b/docs/src/reference/cli.md
index b7c4fafd0b8b30..788e287c3abe1f 100644
--- a/docs/src/reference/cli.md
+++ b/docs/src/reference/cli.md
@@ -71,8 +71,6 @@ zed -n ~/projects/myproject
 
 ### `-a`, `--add`
 
-> **Changed in Preview (v0.225).** See [release notes](/releases#0.225).
-
 Add paths to the currently focused workspace instead of opening a new window. When multiple workspace windows are open, files open in the focused window:
 
 ```sh
diff --git a/docs/src/repl.md b/docs/src/repl.md
index f1cc0ef08ae384..2e782cb0c14e17 100644
--- a/docs/src/repl.md
+++ b/docs/src/repl.md
@@ -151,8 +151,6 @@ TBD: Improve Julia REPL instructions
 
 ## Changing which kernel is used per language {#changing-kernels}
 
-> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release.
-
 Zed automatically detects available kernels and organizes them in the kernel picker:
 
 - **Recommended**: The Python environment matching your active toolchain (if detected)
@@ -193,8 +191,6 @@ To configure a different default kernel for a language, you can assign a kernel
 
 ## Interactive Input
 
-> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release.
-
 When code execution requires user input (such as Python's `input()` function), the REPL displays an input prompt below the cell output.
 
 Type your response in the text field and press `Enter` to submit. The kernel receives your input and continues execution.
diff --git a/docs/src/tasks.md b/docs/src/tasks.md
index 9e0e38690096d6..0fa659eb2cc58f 100644
--- a/docs/src/tasks.md
+++ b/docs/src/tasks.md
@@ -223,6 +223,37 @@ This could be useful for launching a terminal application that you want to use i
 }
 ```
 
+## VS Code Task Format
+
+> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release.
+
+When importing VS Code tasks from `.vscode/tasks.json`, you can omit the `label` field. Zed automatically generates labels based on the task type:
+
+- **npm tasks**: `npm: