Detailed changes
@@ -0,0 +1,55 @@
+# Phase 2: Explore Repository
+
+You are analyzing a codebase to understand its structure before reviewing documentation impact.
+
+## Objective
+Produce a structured overview of the repository to inform subsequent documentation analysis.
+
+## Instructions
+
+1. **Identify Primary Languages and Frameworks**
+ - Scan for Cargo.toml, package.json, or other manifest files
+ - Note the primary language(s) and key dependencies
+
+2. **Map Documentation Structure**
+ - This project uses **mdBook** (https://rust-lang.github.io/mdBook/)
+ - Documentation is in `docs/src/`
+ - Table of contents: `docs/src/SUMMARY.md` (mdBook format: https://rust-lang.github.io/mdBook/format/summary.html)
+ - Style guide: `docs/.rules`
+ - Agent guidelines: `docs/AGENTS.md`
+ - Formatting: Prettier (config in `docs/.prettierrc`)
+
+3. **Identify Build and Tooling**
+ - Note build systems (cargo, npm, etc.)
+ - Identify documentation tooling (mdbook, etc.)
+
+4. **Output Format**
+Produce a JSON summary:
+
+```json
+{
+ "primary_language": "Rust",
+ "frameworks": ["GPUI"],
+ "documentation": {
+ "system": "mdBook",
+ "location": "docs/src/",
+ "toc_file": "docs/src/SUMMARY.md",
+ "toc_format": "https://rust-lang.github.io/mdBook/format/summary.html",
+ "style_guide": "docs/.rules",
+ "agent_guidelines": "docs/AGENTS.md",
+ "formatter": "prettier",
+ "formatter_config": "docs/.prettierrc",
+ "custom_preprocessor": "docs_preprocessor (handles {#kb action::Name} syntax)"
+ },
+ "key_directories": {
+ "source": "crates/",
+ "docs": "docs/src/",
+ "extensions": "extensions/"
+ }
+}
+```
+
+## Constraints
+- Read-only: Do not modify any files
+- Focus on structure, not content details
+- Complete within 2 minutes
@@ -0,0 +1,57 @@
+# Phase 3: Analyze Changes
+
+You are analyzing code changes to understand their nature and scope.
+
+## Objective
+Produce a clear, neutral summary of what changed in the codebase.
+
+## Input
+You will receive:
+- List of changed files from the triggering commit/PR
+- Repository structure from Phase 2
+
+## Instructions
+
+1. **Categorize Changed Files**
+ - Source code (which crates/modules)
+ - Configuration
+ - Tests
+ - Documentation (already existing)
+ - Other
+
+2. **Analyze Each Change**
+ - Review diffs for files likely to impact documentation
+ - Focus on: public APIs, settings, keybindings, commands, user-visible behavior
+
+3. **Identify What Did NOT Change**
+ - Note stable interfaces or behaviors
+ - Important for avoiding unnecessary documentation updates
+
+4. **Output Format**
+Produce a markdown summary:
+
+```markdown
+## Change Analysis
+
+### Changed Files Summary
+| Category | Files | Impact Level |
+| --- | --- | --- |
+| Source - [crate] | file1.rs, file2.rs | High/Medium/Low |
+| Settings | settings.json | Medium |
+| Tests | test_*.rs | None |
+
+### Behavioral Changes
+- **[Feature/Area]**: Description of what changed from user perspective
+- **[Feature/Area]**: Description...
+
+### Unchanged Areas
+- [Area]: Confirmed no changes to [specific behavior]
+
+### Files Requiring Deeper Review
+- `path/to/file.rs`: Reason for deeper review
+```
+
+## Constraints
+- Read-only: Do not modify any files
+- Neutral tone: Describe what changed, not whether it's good/bad
+- Do not propose documentation changes yet
@@ -0,0 +1,76 @@
+# Phase 4: Plan Documentation Impact
+
+You are determining whether and how documentation should be updated based on code changes.
+
+## Objective
+Produce a structured documentation plan that will guide Phase 5 execution.
+
+## Documentation System
+This is an **mdBook** site (https://rust-lang.github.io/mdBook/):
+- `docs/src/SUMMARY.md` defines book structure per https://rust-lang.github.io/mdBook/format/summary.html
+- If adding new pages, they MUST be added to SUMMARY.md
+- Use `{#kb action::ActionName}` syntax for keybindings (custom preprocessor expands these)
+- Prettier formatting (80 char width) will be applied automatically
+
+## Input
+You will receive:
+- Change analysis from Phase 3
+- Repository structure from Phase 2
+- Documentation guidelines from `docs/AGENTS.md`
+
+## Instructions
+
+1. **Review AGENTS.md**
+ - Load and apply all rules from `docs/AGENTS.md`
+ - Respect scope boundaries (in-scope vs out-of-scope)
+
+2. **Evaluate Documentation Impact**
+ For each behavioral change from Phase 3:
+ - Does existing documentation cover this area?
+ - Is the documentation now inaccurate or incomplete?
+ - Classify per AGENTS.md "Change Classification" section
+
+3. **Identify Specific Updates**
+ For each required update:
+ - Exact file path
+ - Specific section or heading
+ - Type of change (update existing, add new, deprecate)
+ - Description of the change
+
+4. **Flag Uncertainty**
+ Explicitly mark:
+ - Assumptions you're making
+ - Areas where human confirmation is needed
+ - Ambiguous requirements
+
+5. **Output Format**
+Use the exact format specified in `docs/AGENTS.md` Phase 4 section:
+
+```markdown
+## Documentation Impact Assessment
+
+### Summary
+Brief description of code changes analyzed.
+
+### Documentation Updates Required: [Yes/No]
+
+### Planned Changes
+
+#### 1. [File Path]
+- **Section**: [Section name or "New section"]
+- **Change Type**: [Update/Add/Deprecate]
+- **Reason**: Why this change is needed
+- **Description**: What will be added/modified
+
+### Uncertainty Flags
+- [ ] [Description of any assumptions or areas needing confirmation]
+
+### No Changes Needed
+- [List files reviewed but not requiring updates, with brief reason]
+```
+
+## Constraints
+- Read-only: Do not modify any files
+- Conservative: When uncertain, flag for human review rather than planning changes
+- Scoped: Only plan changes that trace directly to code changes from Phase 3
+- No scope expansion: Do not plan "improvements" unrelated to triggering changes
@@ -0,0 +1,67 @@
+# Phase 5: Apply Documentation Plan
+
+You are executing a pre-approved documentation plan for an **mdBook** documentation site.
+
+## Objective
+Implement exactly the changes specified in the documentation plan from Phase 4.
+
+## Documentation System
+- **mdBook**: https://rust-lang.github.io/mdBook/
+- **SUMMARY.md**: Follows mdBook format (https://rust-lang.github.io/mdBook/format/summary.html)
+- **Prettier**: Will be run automatically after this phase (80 char line width)
+- **Custom preprocessor**: Use `{#kb action::ActionName}` for keybindings instead of hardcoding
+
+## Input
+You will receive:
+- Documentation plan from Phase 4
+- Documentation guidelines from `docs/AGENTS.md`
+- Style rules from `docs/.rules`
+
+## Instructions
+
+1. **Validate Plan**
+ - Confirm all planned files are within scope per AGENTS.md
+ - Verify no out-of-scope files are targeted
+
+2. **Execute Each Planned Change**
+ For each item in "Planned Changes":
+ - Navigate to the specified file
+ - Locate the specified section
+ - Apply the described change
+ - Follow style rules from `docs/.rules`
+
+3. **Style Compliance**
+ Every edit must follow `docs/.rules`:
+ - Second person, present tense
+ - No hedging words ("simply", "just", "easily")
+ - Proper keybinding format (`Cmd+Shift+P`)
+ - Settings Editor first, JSON second
+ - Correct terminology (folder not directory, etc.)
+
+4. **Preserve Context**
+ - Maintain surrounding content structure
+ - Keep consistent heading levels
+ - Preserve existing cross-references
+
+## Constraints
+- Execute ONLY changes listed in the plan
+- Do not discover new documentation targets
+- Do not make stylistic improvements outside planned sections
+- Do not expand scope beyond what Phase 4 specified
+- If a planned change cannot be applied (file missing, section not found), skip and note it
+
+## Output
+After applying changes, output a summary:
+
+```markdown
+## Applied Changes
+
+### Successfully Applied
+- `path/to/file.md`: [Brief description of change]
+
+### Skipped (Could Not Apply)
+- `path/to/file.md`: [Reason - e.g., "Section not found"]
+
+### Warnings
+- [Any issues encountered during application]
+```
@@ -0,0 +1,54 @@
+# Phase 6: Summarize Changes
+
+You are generating a summary of documentation updates for PR review.
+
+## Objective
+Create a clear, reviewable summary of all documentation changes made.
+
+## Input
+You will receive:
+- Applied changes report from Phase 5
+- Original change analysis from Phase 3
+- Git diff of documentation changes
+
+## Instructions
+
+1. **Gather Change Information**
+ - List all modified documentation files
+ - Identify the corresponding code changes that triggered each update
+
+2. **Generate Summary**
+ Use the format specified in `docs/AGENTS.md` Phase 6 section:
+
+```markdown
+## Documentation Update Summary
+
+### Changes Made
+| File | Change | Related Code |
+| --- | --- | --- |
+| docs/src/path.md | Brief description | PR #123 or commit SHA |
+
+### Rationale
+Brief explanation of why these updates were made, linking back to the triggering code changes.
+
+### Review Notes
+- Items reviewers should pay special attention to
+- Any uncertainty flags from Phase 4 that were addressed
+- Assumptions made during documentation
+```
+
+3. **Add Context for Reviewers**
+ - Highlight any changes that might be controversial
+ - Note if any planned changes were skipped and why
+ - Flag areas where reviewer expertise is especially needed
+
+## Output Format
+The summary should be suitable for:
+- PR description body
+- Commit message (condensed version)
+- Team communication
+
+## Constraints
+- Read-only (documentation changes already applied in Phase 5)
+- Factual: Describe what was done, not justify why it's good
+- Complete: Account for all changes, including skipped items
@@ -0,0 +1,67 @@
+# Phase 7: Commit and Open PR
+
+You are creating a git branch, committing documentation changes, and opening a PR.
+
+## Objective
+Package documentation updates into a reviewable pull request.
+
+## Input
+You will receive:
+- Summary from Phase 6
+- List of modified files
+
+## Instructions
+
+1. **Create Branch**
+ ```sh
+ git checkout -b docs/auto-update-{date}
+ ```
+ Use format: `docs/auto-update-YYYY-MM-DD` or `docs/auto-update-{short-sha}`
+
+2. **Stage and Commit**
+ - Stage only documentation files in `docs/src/`
+ - Do not stage any other files
+
+ Commit message format:
+ ```
+ docs: auto-update documentation for [brief description]
+
+ [Summary from Phase 6, condensed]
+
+ Triggered by: [commit SHA or PR reference]
+
+ Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
+ ```
+
+3. **Push Branch**
+ ```sh
+ git push -u origin docs/auto-update-{date}
+ ```
+
+4. **Create Pull Request**
+ Use the Phase 6 summary as the PR body.
+
+ PR Title: `docs: [Brief description of documentation updates]`
+
+ Labels (if available): `documentation`, `automated`
+
+ Base branch: `main`
+
+## Constraints
+- Do NOT auto-merge
+- Do NOT request specific reviewers (let CODEOWNERS handle it)
+- Do NOT modify files outside `docs/src/`
+- If no changes to commit, exit gracefully with message "No documentation changes to commit"
+
+## Output
+```markdown
+## PR Created
+
+- **Branch**: docs/auto-update-{date}
+- **PR URL**: https://github.com/zed-industries/zed/pull/XXXX
+- **Status**: Ready for review
+
+### Commit
+- SHA: {commit-sha}
+- Files: {count} documentation files modified
+```
@@ -1,7 +1,7 @@
name: "Close Stale Issues"
on:
schedule:
- - cron: "0 8 31 DEC *"
+ - cron: "0 2 * * 5"
workflow_dispatch:
inputs:
debug-only:
@@ -0,0 +1,256 @@
+name: Documentation Automation
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'crates/**'
+ - 'extensions/**'
+ workflow_dispatch:
+ inputs:
+ pr_number:
+ description: 'PR number to analyze (gets full PR diff)'
+ required: false
+ type: string
+ trigger_sha:
+ description: 'Commit SHA to analyze (ignored if pr_number is set)'
+ required: false
+ type: string
+
+permissions:
+ contents: write
+ pull-requests: write
+
+env:
+ FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
+ DROID_MODEL: claude-opus-4-5
+
+jobs:
+ docs-automation:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Install Droid CLI
+ run: |
+ curl -fsSL https://cli.factory.ai/install.sh | bash
+ echo "${HOME}/.factory/bin" >> "$GITHUB_PATH"
+
+ - name: Setup Node.js (for Prettier)
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install Prettier
+ run: npm install -g prettier
+
+ - name: Get changed files
+ id: changed
+ run: |
+ if [ -n "${{ inputs.pr_number }}" ]; then
+ # Get full PR diff
+ echo "Analyzing PR #${{ inputs.pr_number }}"
+ echo "source=pr" >> "$GITHUB_OUTPUT"
+ echo "ref=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT"
+ gh pr diff "${{ inputs.pr_number }}" --name-only > /tmp/changed_files.txt
+ elif [ -n "${{ inputs.trigger_sha }}" ]; then
+ # Get single commit diff
+ SHA="${{ inputs.trigger_sha }}"
+ echo "Analyzing commit $SHA"
+ echo "source=commit" >> "$GITHUB_OUTPUT"
+ echo "ref=$SHA" >> "$GITHUB_OUTPUT"
+ git diff --name-only "${SHA}^" "$SHA" > /tmp/changed_files.txt
+ else
+ # Default to current commit
+ SHA="${{ github.sha }}"
+ echo "Analyzing commit $SHA"
+ echo "source=commit" >> "$GITHUB_OUTPUT"
+ echo "ref=$SHA" >> "$GITHUB_OUTPUT"
+ git diff --name-only "${SHA}^" "$SHA" > /tmp/changed_files.txt || git diff --name-only HEAD~1 HEAD > /tmp/changed_files.txt
+ fi
+
+ echo "Changed files:"
+ cat /tmp/changed_files.txt
+ env:
+ GH_TOKEN: ${{ github.token }}
+
+ # Phase 0: Guardrails are loaded via AGENTS.md in each phase
+
+ # Phase 2: Explore Repository (Read-Only)
+ - name: "Phase 2: Explore Repository"
+ id: phase2
+ run: |
+ droid exec \
+ --model "$DROID_MODEL" \
+ --autonomy read-only \
+ --prompt-file .factory/prompts/docs-automation/phase2-explore.md \
+ --output /tmp/phase2-output.json \
+ --format json
+ echo "Repository exploration complete"
+ cat /tmp/phase2-output.json
+
+ # Phase 3: Analyze Changes (Read-Only)
+ - name: "Phase 3: Analyze Changes"
+ id: phase3
+ run: |
+ CHANGED_FILES=$(tr '\n' ' ' < /tmp/changed_files.txt)
+ droid exec \
+ --model "$DROID_MODEL" \
+ --autonomy read-only \
+ --prompt-file .factory/prompts/docs-automation/phase3-analyze.md \
+ --context "Changed files: $CHANGED_FILES" \
+ --context-file /tmp/phase2-output.json \
+ --output /tmp/phase3-output.md \
+ --format markdown
+ echo "Change analysis complete"
+ cat /tmp/phase3-output.md
+
+ # Phase 4: Plan Documentation Impact (Read-Only)
+ - name: "Phase 4: Plan Documentation Impact"
+ id: phase4
+ run: |
+ droid exec \
+ --model "$DROID_MODEL" \
+ --autonomy read-only \
+ --prompt-file .factory/prompts/docs-automation/phase4-plan.md \
+ --context-file /tmp/phase3-output.md \
+ --context-file docs/AGENTS.md \
+ --output /tmp/phase4-plan.md \
+ --format markdown
+ echo "Documentation plan complete"
+ cat /tmp/phase4-plan.md
+
+ # Check if updates are required
+ if grep -q "Documentation Updates Required: No" /tmp/phase4-plan.md; then
+ echo "updates_required=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "updates_required=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ # Phase 5: Apply Plan (Write-Enabled)
+ - name: "Phase 5: Apply Documentation Plan"
+ id: phase5
+ if: steps.phase4.outputs.updates_required == 'true'
+ run: |
+ droid exec \
+ --model "$DROID_MODEL" \
+ --autonomy medium \
+ --prompt-file .factory/prompts/docs-automation/phase5-apply.md \
+ --context-file /tmp/phase4-plan.md \
+ --context-file docs/AGENTS.md \
+ --context-file docs/.rules \
+ --output /tmp/phase5-report.md \
+ --format markdown
+ echo "Documentation updates applied"
+ cat /tmp/phase5-report.md
+
+ # Phase 5b: Format with Prettier
+ - name: "Phase 5b: Format with Prettier"
+ id: phase5b
+ if: steps.phase4.outputs.updates_required == 'true'
+ run: |
+ echo "Formatting documentation with Prettier..."
+ cd docs && prettier --write src/
+
+ echo "Verifying Prettier formatting passes..."
+ cd docs && prettier --check src/
+
+ echo "Prettier formatting complete"
+
+ # Phase 6: Summarize Changes
+ - name: "Phase 6: Summarize Changes"
+ id: phase6
+ if: steps.phase4.outputs.updates_required == 'true'
+ run: |
+ # Get git diff of docs
+ git diff docs/src/ > /tmp/docs-diff.txt || true
+
+ droid exec \
+ --model "$DROID_MODEL" \
+ --autonomy read-only \
+ --prompt-file .factory/prompts/docs-automation/phase6-summarize.md \
+ --context-file /tmp/phase5-report.md \
+ --context-file /tmp/phase3-output.md \
+ --context "Trigger SHA: ${{ steps.changed.outputs.sha }}" \
+ --output /tmp/phase6-summary.md \
+ --format markdown
+ echo "Summary generated"
+ cat /tmp/phase6-summary.md
+
+ # Phase 7: Commit and Open PR
+ - name: "Phase 7: Create PR"
+ id: phase7
+ if: steps.phase4.outputs.updates_required == 'true'
+ run: |
+ # Check if there are actual changes
+ if git diff --quiet docs/src/; then
+ echo "No documentation changes detected"
+ exit 0
+ fi
+
+ # Configure git
+ git config user.name "factory-droid[bot]"
+ git config user.email "138933559+factory-droid[bot]@users.noreply.github.com"
+
+ # Daily batch branch - one branch per day, multiple commits accumulate
+ BRANCH_NAME="docs/auto-update-$(date +%Y-%m-%d)"
+
+ # Check if branch already exists on remote
+ if git ls-remote --exit-code --heads origin "$BRANCH_NAME" > /dev/null 2>&1; then
+ echo "Branch $BRANCH_NAME exists, checking out and updating..."
+ git fetch origin "$BRANCH_NAME"
+ git checkout -B "$BRANCH_NAME" "origin/$BRANCH_NAME"
+ else
+ echo "Creating new branch $BRANCH_NAME..."
+ git checkout -b "$BRANCH_NAME"
+ fi
+
+ # Stage and commit
+ git add docs/src/
+ SUMMARY=$(head -50 < /tmp/phase6-summary.md)
+ git commit -m "docs: auto-update documentation
+
+ ${SUMMARY}
+
+ Triggered by: ${{ steps.changed.outputs.source }} ${{ steps.changed.outputs.ref }}
+
+ Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>"
+
+ # Push
+ git push -u origin "$BRANCH_NAME"
+
+ # Check if PR already exists for this branch
+ EXISTING_PR=$(gh pr list --head "$BRANCH_NAME" --json number --jq '.[0].number' || echo "")
+
+ if [ -n "$EXISTING_PR" ]; then
+ echo "PR #$EXISTING_PR already exists for branch $BRANCH_NAME, updated with new commit"
+ else
+ # Create new PR
+ gh pr create \
+ --title "docs: automated documentation update ($(date +%Y-%m-%d))" \
+ --body-file /tmp/phase6-summary.md \
+ --base main || true
+ echo "PR created on branch: $BRANCH_NAME"
+ fi
+ env:
+ GH_TOKEN: ${{ github.token }}
+
+ # Summary output
+ - name: "Summary"
+ if: always()
+ run: |
+ echo "## Documentation Automation Summary" >> "$GITHUB_STEP_SUMMARY"
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+
+ if [ "${{ steps.phase4.outputs.updates_required }}" == "false" ]; then
+ echo "No documentation updates required for this change." >> "$GITHUB_STEP_SUMMARY"
+ elif [ -f /tmp/phase6-summary.md ]; then
+ cat /tmp/phase6-summary.md >> "$GITHUB_STEP_SUMMARY"
+ else
+ echo "Workflow completed. Check individual phase outputs for details." >> "$GITHUB_STEP_SUMMARY"
+ fi
@@ -1178,6 +1178,10 @@
"remove_trailing_whitespace_on_save": true,
// Whether to start a new line with a comment when a previous line is a comment as well.
"extend_comment_on_newline": true,
+ // Whether to continue markdown lists when pressing enter.
+ "extend_list_on_newline": true,
+ // Whether to indent list items when pressing tab after a list marker.
+ "indent_list_on_tab": true,
// Removes any lines containing only whitespace at the end of the file and
// ensures just one newline at the end.
"ensure_final_newline_on_save": true,
@@ -1615,8 +1615,12 @@ impl CodeActionsMenu {
window.text_style().font(),
window.text_style().font_size.to_pixels(window.rem_size()),
);
- let is_truncated =
- line_wrapper.should_truncate_line(&label, CODE_ACTION_MENU_MAX_WIDTH, "…");
+ let is_truncated = line_wrapper.should_truncate_line(
+ &label,
+ CODE_ACTION_MENU_MAX_WIDTH,
+ "…",
+ gpui::TruncateFrom::End,
+ );
if is_truncated.is_none() {
return None;
@@ -163,6 +163,7 @@ use project::{
project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter, ProjectSettings},
};
use rand::seq::SliceRandom;
+use regex::Regex;
use rpc::{ErrorCode, ErrorExt, proto::PeerId};
use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager};
use selections_collection::{MutableSelectionsCollection, SelectionsCollection};
@@ -4787,82 +4788,146 @@ impl Editor {
let end = selection.end;
let selection_is_empty = start == end;
let language_scope = buffer.language_scope_at(start);
- let (comment_delimiter, doc_delimiter, newline_formatting) =
- if let Some(language) = &language_scope {
- let mut newline_formatting =
- NewlineFormatting::new(&buffer, start..end, language);
-
- // Comment extension on newline is allowed only for cursor selections
- let comment_delimiter = maybe!({
- if !selection_is_empty {
- return None;
- }
+ let (delimiter, newline_config) = if let Some(language) = &language_scope {
+ let needs_extra_newline = NewlineConfig::insert_extra_newline_brackets(
+ &buffer,
+ start..end,
+ language,
+ )
+ || NewlineConfig::insert_extra_newline_tree_sitter(
+ &buffer,
+ start..end,
+ );
- if !multi_buffer.language_settings(cx).extend_comment_on_newline
- {
- return None;
- }
+ let mut newline_config = NewlineConfig::Newline {
+ additional_indent: IndentSize::spaces(0),
+ extra_line_additional_indent: if needs_extra_newline {
+ Some(IndentSize::spaces(0))
+ } else {
+ None
+ },
+ prevent_auto_indent: false,
+ };
- return comment_delimiter_for_newline(
- &start_point,
- &buffer,
- language,
- );
- });
+ let comment_delimiter = maybe!({
+ if !selection_is_empty {
+ return None;
+ }
- let doc_delimiter = maybe!({
- if !selection_is_empty {
- return None;
- }
+ if !multi_buffer.language_settings(cx).extend_comment_on_newline {
+ return None;
+ }
- if !multi_buffer.language_settings(cx).extend_comment_on_newline
- {
- return None;
- }
+ return comment_delimiter_for_newline(
+ &start_point,
+ &buffer,
+ language,
+ );
+ });
- return documentation_delimiter_for_newline(
- &start_point,
- &buffer,
- language,
- &mut newline_formatting,
- );
- });
+ let doc_delimiter = maybe!({
+ if !selection_is_empty {
+ return None;
+ }
- (comment_delimiter, doc_delimiter, newline_formatting)
- } else {
- (None, None, NewlineFormatting::default())
- };
+ if !multi_buffer.language_settings(cx).extend_comment_on_newline {
+ return None;
+ }
- let prevent_auto_indent = doc_delimiter.is_some();
- let delimiter = comment_delimiter.or(doc_delimiter);
+ return documentation_delimiter_for_newline(
+ &start_point,
+ &buffer,
+ language,
+ &mut newline_config,
+ );
+ });
- let capacity_for_delimiter =
- delimiter.as_deref().map(str::len).unwrap_or_default();
- let mut new_text = String::with_capacity(
- 1 + capacity_for_delimiter
- + existing_indent.len as usize
- + newline_formatting.indent_on_newline.len as usize
- + newline_formatting.indent_on_extra_newline.len as usize,
- );
- new_text.push('\n');
- new_text.extend(existing_indent.chars());
- new_text.extend(newline_formatting.indent_on_newline.chars());
+ let list_delimiter = maybe!({
+ if !selection_is_empty {
+ return None;
+ }
- if let Some(delimiter) = &delimiter {
- new_text.push_str(delimiter);
- }
+ if !multi_buffer.language_settings(cx).extend_list_on_newline {
+ return None;
+ }
- if newline_formatting.insert_extra_newline {
- new_text.push('\n');
- new_text.extend(existing_indent.chars());
- new_text.extend(newline_formatting.indent_on_extra_newline.chars());
- }
+ return list_delimiter_for_newline(
+ &start_point,
+ &buffer,
+ language,
+ &mut newline_config,
+ );
+ });
+
+ (
+ comment_delimiter.or(doc_delimiter).or(list_delimiter),
+ newline_config,
+ )
+ } else {
+ (
+ None,
+ NewlineConfig::Newline {
+ additional_indent: IndentSize::spaces(0),
+ extra_line_additional_indent: None,
+ prevent_auto_indent: false,
+ },
+ )
+ };
+
+ let (edit_start, new_text, prevent_auto_indent) = match &newline_config {
+ NewlineConfig::ClearCurrentLine => {
+ let row_start =
+ buffer.point_to_offset(Point::new(start_point.row, 0));
+ (row_start, String::new(), false)
+ }
+ NewlineConfig::UnindentCurrentLine { continuation } => {
+ let row_start =
+ buffer.point_to_offset(Point::new(start_point.row, 0));
+ let tab_size = buffer.language_settings_at(start, cx).tab_size;
+ let tab_size_indent = IndentSize::spaces(tab_size.get());
+ let reduced_indent =
+ existing_indent.with_delta(Ordering::Less, tab_size_indent);
+ let mut new_text = String::new();
+ new_text.extend(reduced_indent.chars());
+ new_text.push_str(continuation);
+ (row_start, new_text, true)
+ }
+ NewlineConfig::Newline {
+ additional_indent,
+ extra_line_additional_indent,
+ prevent_auto_indent,
+ } => {
+ let capacity_for_delimiter =
+ delimiter.as_deref().map(str::len).unwrap_or_default();
+ let extra_line_len = extra_line_additional_indent
+ .map(|i| 1 + existing_indent.len as usize + i.len as usize)
+ .unwrap_or(0);
+ let mut new_text = String::with_capacity(
+ 1 + capacity_for_delimiter
+ + existing_indent.len as usize
+ + additional_indent.len as usize
+ + extra_line_len,
+ );
+ new_text.push('\n');
+ new_text.extend(existing_indent.chars());
+ new_text.extend(additional_indent.chars());
+ if let Some(delimiter) = &delimiter {
+ new_text.push_str(delimiter);
+ }
+ if let Some(extra_indent) = extra_line_additional_indent {
+ new_text.push('\n');
+ new_text.extend(existing_indent.chars());
+ new_text.extend(extra_indent.chars());
+ }
+ (start, new_text, *prevent_auto_indent)
+ }
+ };
let anchor = buffer.anchor_after(end);
let new_selection = selection.map(|_| anchor);
(
- ((start..end, new_text), prevent_auto_indent),
- (newline_formatting.insert_extra_newline, new_selection),
+ ((edit_start..end, new_text), prevent_auto_indent),
+ (newline_config.has_extra_line(), new_selection),
)
})
.unzip()
@@ -10387,6 +10452,22 @@ impl Editor {
}
prev_edited_row = selection.end.row;
+ // If cursor is after a list prefix, make selection non-empty to trigger line indent
+ if selection.is_empty() {
+ let cursor = selection.head();
+ let settings = buffer.language_settings_at(cursor, cx);
+ if settings.indent_list_on_tab {
+ if let Some(language) = snapshot.language_scope_at(Point::new(cursor.row, 0)) {
+ if is_list_prefix_row(MultiBufferRow(cursor.row), &snapshot, &language) {
+ row_delta = Self::indent_selection(
+ buffer, &snapshot, selection, &mut edits, row_delta, cx,
+ );
+ continue;
+ }
+ }
+ }
+ }
+
// If the selection is non-empty, then increase the indentation of the selected lines.
if !selection.is_empty() {
row_delta =
@@ -20394,7 +20475,7 @@ impl Editor {
EditorSettings::get_global(cx).gutter.line_numbers
}
- pub fn relative_line_numbers(&self, cx: &mut App) -> RelativeLineNumbers {
+ pub fn relative_line_numbers(&self, cx: &App) -> RelativeLineNumbers {
match (
self.use_relative_line_numbers,
EditorSettings::get_global(cx).relative_line_numbers,
@@ -23355,7 +23436,7 @@ fn documentation_delimiter_for_newline(
start_point: &Point,
buffer: &MultiBufferSnapshot,
language: &LanguageScope,
- newline_formatting: &mut NewlineFormatting,
+ newline_config: &mut NewlineConfig,
) -> Option<Arc<str>> {
let BlockCommentConfig {
start: start_tag,
@@ -23407,6 +23488,9 @@ fn documentation_delimiter_for_newline(
}
};
+ let mut needs_extra_line = false;
+ let mut extra_line_additional_indent = IndentSize::spaces(0);
+
let cursor_is_before_end_tag_if_exists = {
let mut char_position = 0u32;
let mut end_tag_offset = None;
@@ -23424,11 +23508,11 @@ fn documentation_delimiter_for_newline(
let cursor_is_before_end_tag = column <= end_tag_offset;
if cursor_is_after_start_tag {
if cursor_is_before_end_tag {
- newline_formatting.insert_extra_newline = true;
+ needs_extra_line = true;
}
let cursor_is_at_start_of_end_tag = column == end_tag_offset;
if cursor_is_at_start_of_end_tag {
- newline_formatting.indent_on_extra_newline.len = *len;
+ extra_line_additional_indent.len = *len;
}
}
cursor_is_before_end_tag
@@ -23440,39 +23524,240 @@ fn documentation_delimiter_for_newline(
if (cursor_is_after_start_tag || cursor_is_after_delimiter)
&& cursor_is_before_end_tag_if_exists
{
- if cursor_is_after_start_tag {
- newline_formatting.indent_on_newline.len = *len;
- }
+ let additional_indent = if cursor_is_after_start_tag {
+ IndentSize::spaces(*len)
+ } else {
+ IndentSize::spaces(0)
+ };
+
+ *newline_config = NewlineConfig::Newline {
+ additional_indent,
+ extra_line_additional_indent: if needs_extra_line {
+ Some(extra_line_additional_indent)
+ } else {
+ None
+ },
+ prevent_auto_indent: true,
+ };
Some(delimiter.clone())
} else {
None
}
}
-#[derive(Debug, Default)]
-struct NewlineFormatting {
- insert_extra_newline: bool,
- indent_on_newline: IndentSize,
- indent_on_extra_newline: IndentSize,
+const ORDERED_LIST_MAX_MARKER_LEN: usize = 16;
+
+fn list_delimiter_for_newline(
+ start_point: &Point,
+ buffer: &MultiBufferSnapshot,
+ language: &LanguageScope,
+ newline_config: &mut NewlineConfig,
+) -> Option<Arc<str>> {
+ let (snapshot, range) = buffer.buffer_line_for_row(MultiBufferRow(start_point.row))?;
+
+ let num_of_whitespaces = snapshot
+ .chars_for_range(range.clone())
+ .take_while(|c| c.is_whitespace())
+ .count();
+
+ let task_list_entries: Vec<_> = language
+ .task_list()
+ .into_iter()
+ .flat_map(|config| {
+ config
+ .prefixes
+ .iter()
+ .map(|prefix| (prefix.as_ref(), config.continuation.as_ref()))
+ })
+ .collect();
+ let unordered_list_entries: Vec<_> = language
+ .unordered_list()
+ .iter()
+ .map(|marker| (marker.as_ref(), marker.as_ref()))
+ .collect();
+
+ let all_entries: Vec<_> = task_list_entries
+ .into_iter()
+ .chain(unordered_list_entries)
+ .collect();
+
+ if let Some(max_prefix_len) = all_entries.iter().map(|(p, _)| p.len()).max() {
+ let candidate: String = snapshot
+ .chars_for_range(range.clone())
+ .skip(num_of_whitespaces)
+ .take(max_prefix_len)
+ .collect();
+
+ if let Some((prefix, continuation)) = all_entries
+ .iter()
+ .filter(|(prefix, _)| candidate.starts_with(*prefix))
+ .max_by_key(|(prefix, _)| prefix.len())
+ {
+ let end_of_prefix = num_of_whitespaces + prefix.len();
+ let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize;
+ let has_content_after_marker = snapshot
+ .chars_for_range(range)
+ .skip(end_of_prefix)
+ .any(|c| !c.is_whitespace());
+
+ if has_content_after_marker && cursor_is_after_prefix {
+ return Some((*continuation).into());
+ }
+
+ if start_point.column as usize == end_of_prefix {
+ if num_of_whitespaces == 0 {
+ *newline_config = NewlineConfig::ClearCurrentLine;
+ } else {
+ *newline_config = NewlineConfig::UnindentCurrentLine {
+ continuation: (*continuation).into(),
+ };
+ }
+ }
+
+ return None;
+ }
+ }
+
+ let candidate: String = snapshot
+ .chars_for_range(range.clone())
+ .skip(num_of_whitespaces)
+ .take(ORDERED_LIST_MAX_MARKER_LEN)
+ .collect();
+
+ for ordered_config in language.ordered_list() {
+ let regex = match Regex::new(&ordered_config.pattern) {
+ Ok(r) => r,
+ Err(_) => continue,
+ };
+
+ if let Some(captures) = regex.captures(&candidate) {
+ let full_match = captures.get(0)?;
+ let marker_len = full_match.len();
+ let end_of_prefix = num_of_whitespaces + marker_len;
+ let cursor_is_after_prefix = end_of_prefix <= start_point.column as usize;
+
+ let has_content_after_marker = snapshot
+ .chars_for_range(range)
+ .skip(end_of_prefix)
+ .any(|c| !c.is_whitespace());
+
+ if has_content_after_marker && cursor_is_after_prefix {
+ let number: u32 = captures.get(1)?.as_str().parse().ok()?;
+ let continuation = ordered_config
+ .format
+ .replace("{1}", &(number + 1).to_string());
+ return Some(continuation.into());
+ }
+
+ if start_point.column as usize == end_of_prefix {
+ let continuation = ordered_config.format.replace("{1}", "1");
+ if num_of_whitespaces == 0 {
+ *newline_config = NewlineConfig::ClearCurrentLine;
+ } else {
+ *newline_config = NewlineConfig::UnindentCurrentLine {
+ continuation: continuation.into(),
+ };
+ }
+ }
+
+ return None;
+ }
+ }
+
+ None
}
-impl NewlineFormatting {
- fn new(
- buffer: &MultiBufferSnapshot,
- range: Range<MultiBufferOffset>,
- language: &LanguageScope,
- ) -> Self {
- Self {
- insert_extra_newline: Self::insert_extra_newline_brackets(
- buffer,
- range.clone(),
- language,
- ) || Self::insert_extra_newline_tree_sitter(buffer, range),
- indent_on_newline: IndentSize::spaces(0),
- indent_on_extra_newline: IndentSize::spaces(0),
+fn is_list_prefix_row(
+ row: MultiBufferRow,
+ buffer: &MultiBufferSnapshot,
+ language: &LanguageScope,
+) -> bool {
+ let Some((snapshot, range)) = buffer.buffer_line_for_row(row) else {
+ return false;
+ };
+
+ let num_of_whitespaces = snapshot
+ .chars_for_range(range.clone())
+ .take_while(|c| c.is_whitespace())
+ .count();
+
+ let task_list_prefixes: Vec<_> = language
+ .task_list()
+ .into_iter()
+ .flat_map(|config| {
+ config
+ .prefixes
+ .iter()
+ .map(|p| p.as_ref())
+ .collect::<Vec<_>>()
+ })
+ .collect();
+ let unordered_list_markers: Vec<_> = language
+ .unordered_list()
+ .iter()
+ .map(|marker| marker.as_ref())
+ .collect();
+ let all_prefixes: Vec<_> = task_list_prefixes
+ .into_iter()
+ .chain(unordered_list_markers)
+ .collect();
+ if let Some(max_prefix_len) = all_prefixes.iter().map(|p| p.len()).max() {
+ let candidate: String = snapshot
+ .chars_for_range(range.clone())
+ .skip(num_of_whitespaces)
+ .take(max_prefix_len)
+ .collect();
+ if all_prefixes
+ .iter()
+ .any(|prefix| candidate.starts_with(*prefix))
+ {
+ return true;
}
}
+ let ordered_list_candidate: String = snapshot
+ .chars_for_range(range)
+ .skip(num_of_whitespaces)
+ .take(ORDERED_LIST_MAX_MARKER_LEN)
+ .collect();
+ for ordered_config in language.ordered_list() {
+ let regex = match Regex::new(&ordered_config.pattern) {
+ Ok(r) => r,
+ Err(_) => continue,
+ };
+ if let Some(captures) = regex.captures(&ordered_list_candidate) {
+ return captures.get(0).is_some();
+ }
+ }
+
+ false
+}
+
+#[derive(Debug)]
+enum NewlineConfig {
+ /// Insert newline with optional additional indent and optional extra blank line
+ Newline {
+ additional_indent: IndentSize,
+ extra_line_additional_indent: Option<IndentSize>,
+ prevent_auto_indent: bool,
+ },
+ /// Clear the current line
+ ClearCurrentLine,
+ /// Unindent the current line and add continuation
+ UnindentCurrentLine { continuation: Arc<str> },
+}
+
+impl NewlineConfig {
+ fn has_extra_line(&self) -> bool {
+ matches!(
+ self,
+ Self::Newline {
+ extra_line_additional_indent: Some(_),
+ ..
+ }
+ )
+ }
+
fn insert_extra_newline_brackets(
buffer: &MultiBufferSnapshot,
range: Range<MultiBufferOffset>,
@@ -25009,6 +25294,70 @@ impl EditorSnapshot {
let digit_count = self.widest_line_number().ilog10() + 1;
column_pixels(style, digit_count as usize, window)
}
+
+ /// Returns the line delta from `base` to `line` in the multibuffer, ignoring wrapped lines.
+ ///
+ /// This is positive if `base` is before `line`.
+ fn relative_line_delta(&self, base: DisplayRow, line: DisplayRow) -> i64 {
+ let point = DisplayPoint::new(line, 0).to_point(self);
+ self.relative_line_delta_to_point(base, point)
+ }
+
+ /// Returns the line delta from `base` to `point` in the multibuffer, ignoring wrapped lines.
+ ///
+ /// This is positive if `base` is before `point`.
+ pub fn relative_line_delta_to_point(&self, base: DisplayRow, point: Point) -> i64 {
+ let base_point = DisplayPoint::new(base, 0).to_point(self);
+ point.row as i64 - base_point.row as i64
+ }
+
+ /// Returns the line delta from `base` to `line` in the multibuffer, counting wrapped lines.
+ ///
+ /// This is positive if `base` is before `line`.
+ fn relative_wrapped_line_delta(&self, base: DisplayRow, line: DisplayRow) -> i64 {
+ let point = DisplayPoint::new(line, 0).to_point(self);
+ self.relative_wrapped_line_delta_to_point(base, point)
+ }
+
+ /// Returns the line delta from `base` to `point` in the multibuffer, counting wrapped lines.
+ ///
+ /// This is positive if `base` is before `point`.
+ pub fn relative_wrapped_line_delta_to_point(&self, base: DisplayRow, point: Point) -> i64 {
+ let base_point = DisplayPoint::new(base, 0).to_point(self);
+ let wrap_snapshot = self.wrap_snapshot();
+ let base_wrap_row = wrap_snapshot.make_wrap_point(base_point, Bias::Left).row();
+ let wrap_row = wrap_snapshot.make_wrap_point(point, Bias::Left).row();
+ wrap_row.0 as i64 - base_wrap_row.0 as i64
+ }
+
+ /// Returns the unsigned relative line number to display for each row in `rows`.
+ ///
+ /// Wrapped rows are excluded from the hashmap if `count_relative_lines` is `false`.
+ pub fn calculate_relative_line_numbers(
+ &self,
+ rows: &Range<DisplayRow>,
+ relative_to: DisplayRow,
+ count_wrapped_lines: bool,
+ ) -> HashMap<DisplayRow, u32> {
+ let initial_offset = if count_wrapped_lines {
+ self.relative_wrapped_line_delta(relative_to, rows.start)
+ } else {
+ self.relative_line_delta(relative_to, rows.start)
+ };
+ let display_row_infos = self
+ .row_infos(rows.start)
+ .take(rows.len())
+ .enumerate()
+ .map(|(i, row_info)| (DisplayRow(rows.start.0 + i as u32), row_info));
+ display_row_infos
+ .filter(|(_row, row_info)| {
+ row_info.buffer_row.is_some()
+ || (count_wrapped_lines && row_info.wrapped_buffer_row.is_some())
+ })
+ .enumerate()
+ .map(|(i, (row, _row_info))| (row, (initial_offset + i as i64).unsigned_abs() as u32))
+ .collect()
+ }
}
pub fn column_pixels(style: &EditorStyle, column: usize, window: &Window) -> Pixels {
@@ -36,7 +36,8 @@ use languages::markdown_lang;
use languages::rust_lang;
use lsp::CompletionParams;
use multi_buffer::{
- IndentGuide, MultiBufferFilterMode, MultiBufferOffset, MultiBufferOffsetUtf16, PathKey,
+ ExcerptRange, IndentGuide, MultiBuffer, MultiBufferFilterMode, MultiBufferOffset,
+ MultiBufferOffsetUtf16, PathKey,
};
use parking_lot::Mutex;
use pretty_assertions::{assert_eq, assert_ne};
@@ -28021,7 +28022,7 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
"
});
- // Case 2: Test adding new line after nested list preserves indent of previous line
+ // Case 2: Test adding new line after nested list continues the list with unchecked task
cx.set_state(&indoc! {"
- [ ] Item 1
- [ ] Item 1.a
@@ -28038,32 +28039,12 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
- [x] Item 2
- [x] Item 2.a
- [x] Item 2.b
- ˇ"
+ - [ ] ˇ"
});
- // Case 3: Test adding a new nested list item preserves indent
- cx.set_state(&indoc! {"
- - [ ] Item 1
- - [ ] Item 1.a
- - [x] Item 2
- - [x] Item 2.a
- - [x] Item 2.b
- ˇ"
- });
- cx.update_editor(|editor, window, cx| {
- editor.handle_input("-", window, cx);
- });
- cx.run_until_parked();
- cx.assert_editor_state(indoc! {"
- - [ ] Item 1
- - [ ] Item 1.a
- - [x] Item 2
- - [x] Item 2.a
- - [x] Item 2.b
- -ˇ"
- });
+ // Case 3: Test adding content to continued list item
cx.update_editor(|editor, window, cx| {
- editor.handle_input(" [x] Item 2.c", window, cx);
+ editor.handle_input("Item 2.c", window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
@@ -28072,10 +28053,10 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
- [x] Item 2
- [x] Item 2.a
- [x] Item 2.b
- - [x] Item 2.cˇ"
+ - [ ] Item 2.cˇ"
});
- // Case 4: Test adding new line after nested ordered list preserves indent of previous line
+ // Case 4: Test adding new line after nested ordered list continues with next number
cx.set_state(indoc! {"
1. Item 1
1. Item 1.a
@@ -28092,44 +28073,12 @@ async fn test_markdown_indents(cx: &mut gpui::TestAppContext) {
2. Item 2
1. Item 2.a
2. Item 2.b
- ˇ"
+ 3. ˇ"
});
- // Case 5: Adding new ordered list item preserves indent
- cx.set_state(indoc! {"
- 1. Item 1
- 1. Item 1.a
- 2. Item 2
- 1. Item 2.a
- 2. Item 2.b
- ˇ"
- });
+ // Case 5: Adding content to continued ordered list item
cx.update_editor(|editor, window, cx| {
- editor.handle_input("3", window, cx);
- });
- cx.run_until_parked();
- cx.assert_editor_state(indoc! {"
- 1. Item 1
- 1. Item 1.a
- 2. Item 2
- 1. Item 2.a
- 2. Item 2.b
- 3ˇ"
- });
- cx.update_editor(|editor, window, cx| {
- editor.handle_input(".", window, cx);
- });
- cx.run_until_parked();
- cx.assert_editor_state(indoc! {"
- 1. Item 1
- 1. Item 1.a
- 2. Item 2
- 1. Item 2.a
- 2. Item 2.b
- 3.ˇ"
- });
- cx.update_editor(|editor, window, cx| {
- editor.handle_input(" Item 2.c", window, cx);
+ editor.handle_input("Item 2.c", window, cx);
});
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
@@ -28685,6 +28634,130 @@ async fn test_sticky_scroll(cx: &mut TestAppContext) {
assert_eq!(sticky_headers(10.0), vec![]);
}
+#[gpui::test]
+fn test_relative_line_numbers(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ let buffer_1 = cx.new(|cx| Buffer::local("aaaaaaaaaa\nbbb\n", cx));
+ let buffer_2 = cx.new(|cx| Buffer::local("cccccccccc\nddd\n", cx));
+ let buffer_3 = cx.new(|cx| Buffer::local("eee\nffffffffff\n", cx));
+
+ let multibuffer = cx.new(|cx| {
+ let mut multibuffer = MultiBuffer::new(ReadWrite);
+ multibuffer.push_excerpts(
+ buffer_1.clone(),
+ [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))],
+ cx,
+ );
+ multibuffer.push_excerpts(
+ buffer_2.clone(),
+ [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))],
+ cx,
+ );
+ multibuffer.push_excerpts(
+ buffer_3.clone(),
+ [ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))],
+ cx,
+ );
+ multibuffer
+ });
+
+ // wrapped contents of multibuffer:
+ // aaa
+ // aaa
+ // aaa
+ // a
+ // bbb
+ //
+ // ccc
+ // ccc
+ // ccc
+ // c
+ // ddd
+ //
+ // eee
+ // fff
+ // fff
+ // fff
+ // f
+
+ let (editor, cx) = cx.add_window_view(|window, cx| build_editor(multibuffer, window, cx));
+ editor.update_in(cx, |editor, window, cx| {
+ editor.set_wrap_width(Some(30.0.into()), cx); // every 3 characters
+
+ // includes trailing newlines.
+ let expected_line_numbers = [2, 6, 7, 10, 14, 15, 18, 19, 23];
+ let expected_wrapped_line_numbers = [
+ 2, 3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15, 18, 19, 20, 21, 22, 23,
+ ];
+
+ editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
+ s.select_ranges([
+ Point::new(7, 0)..Point::new(7, 1), // second row of `ccc`
+ ]);
+ });
+
+ let snapshot = editor.snapshot(window, cx);
+
+ // these are all 0-indexed
+ let base_display_row = DisplayRow(11);
+ let base_row = 3;
+ let wrapped_base_row = 7;
+
+ // test not counting wrapped lines
+ let expected_relative_numbers = expected_line_numbers
+ .into_iter()
+ .enumerate()
+ .map(|(i, row)| (DisplayRow(row), i.abs_diff(base_row) as u32))
+ .collect_vec();
+ let actual_relative_numbers = snapshot
+ .calculate_relative_line_numbers(
+ &(DisplayRow(0)..DisplayRow(24)),
+ base_display_row,
+ false,
+ )
+ .into_iter()
+ .sorted()
+ .collect_vec();
+ assert_eq!(expected_relative_numbers, actual_relative_numbers);
+ // check `calculate_relative_line_numbers()` against `relative_line_delta()` for each line
+ for (display_row, relative_number) in expected_relative_numbers {
+ assert_eq!(
+ relative_number,
+ snapshot
+ .relative_line_delta(display_row, base_display_row)
+ .unsigned_abs() as u32,
+ );
+ }
+
+ // test counting wrapped lines
+ let expected_wrapped_relative_numbers = expected_wrapped_line_numbers
+ .into_iter()
+ .enumerate()
+ .map(|(i, row)| (DisplayRow(row), i.abs_diff(wrapped_base_row) as u32))
+ .collect_vec();
+ let actual_relative_numbers = snapshot
+ .calculate_relative_line_numbers(
+ &(DisplayRow(0)..DisplayRow(24)),
+ base_display_row,
+ true,
+ )
+ .into_iter()
+ .sorted()
+ .collect_vec();
+ assert_eq!(expected_wrapped_relative_numbers, actual_relative_numbers);
+ // check `calculate_relative_line_numbers()` against `relative_wrapped_line_delta()` for each line
+ for (display_row, relative_number) in expected_wrapped_relative_numbers {
+ assert_eq!(
+ relative_number,
+ snapshot
+ .relative_wrapped_line_delta(display_row, base_display_row)
+ .unsigned_abs() as u32,
+ );
+ }
+ });
+}
+
#[gpui::test]
async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -29497,6 +29570,524 @@ async fn test_find_references_single_case(cx: &mut TestAppContext) {
cx.assert_editor_state(after);
}
+#[gpui::test]
+async fn test_newline_task_list_continuation(cx: &mut TestAppContext) {
+ init_test(cx, |settings| {
+ settings.defaults.tab_size = Some(2.try_into().unwrap());
+ });
+
+ let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
+ let mut cx = EditorTestContext::new(cx).await;
+ cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
+
+ // Case 1: Adding newline after (whitespace + prefix + any non-whitespace) adds marker
+ cx.set_state(indoc! {"
+ - [ ] taskˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - [ ] task
+ - [ ] ˇ
+ "});
+
+ // Case 2: Works with checked task items too
+ cx.set_state(indoc! {"
+ - [x] completed taskˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - [x] completed task
+ - [ ] ˇ
+ "});
+
+ // Case 3: Cursor position doesn't matter - content after marker is what counts
+ cx.set_state(indoc! {"
+ - [ ] taˇsk
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - [ ] ta
+ - [ ] ˇsk
+ "});
+
+ // Case 4: Adding newline after (whitespace + prefix + some whitespace) does NOT add marker
+ cx.set_state(indoc! {"
+ - [ ] ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(
+ indoc! {"
+ - [ ]$$
+ ˇ
+ "}
+ .replace("$", " ")
+ .as_str(),
+ );
+
+ // Case 5: Adding newline with content adds marker preserving indentation
+ cx.set_state(indoc! {"
+ - [ ] task
+ - [ ] indentedˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - [ ] task
+ - [ ] indented
+ - [ ] ˇ
+ "});
+
+ // Case 6: Adding newline with cursor right after prefix, unindents
+ cx.set_state(indoc! {"
+ - [ ] task
+ - [ ] sub task
+ - [ ] ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - [ ] task
+ - [ ] sub task
+ - [ ] ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+
+ // Case 7: Adding newline with cursor right after prefix, removes marker
+ cx.assert_editor_state(indoc! {"
+ - [ ] task
+ - [ ] sub task
+ - [ ] ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - [ ] task
+ - [ ] sub task
+ ˇ
+ "});
+
+ // Case 8: Cursor before or inside prefix does not add marker
+ cx.set_state(indoc! {"
+ ˇ- [ ] task
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+
+ ˇ- [ ] task
+ "});
+
+ cx.set_state(indoc! {"
+ - [ˇ ] task
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - [
+ ˇ
+ ] task
+ "});
+}
+
+#[gpui::test]
+async fn test_newline_unordered_list_continuation(cx: &mut TestAppContext) {
+ init_test(cx, |settings| {
+ settings.defaults.tab_size = Some(2.try_into().unwrap());
+ });
+
+ let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
+ let mut cx = EditorTestContext::new(cx).await;
+ cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
+
+ // Case 1: Adding newline after (whitespace + marker + any non-whitespace) adds marker
+ cx.set_state(indoc! {"
+ - itemˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - item
+ - ˇ
+ "});
+
+ // Case 2: Works with different markers
+ cx.set_state(indoc! {"
+ * starred itemˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ * starred item
+ * ˇ
+ "});
+
+ cx.set_state(indoc! {"
+ + plus itemˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ + plus item
+ + ˇ
+ "});
+
+ // Case 3: Cursor position doesn't matter - content after marker is what counts
+ cx.set_state(indoc! {"
+ - itˇem
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - it
+ - ˇem
+ "});
+
+ // Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker
+ cx.set_state(indoc! {"
+ - ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(
+ indoc! {"
+ - $
+ ˇ
+ "}
+ .replace("$", " ")
+ .as_str(),
+ );
+
+ // Case 5: Adding newline with content adds marker preserving indentation
+ cx.set_state(indoc! {"
+ - item
+ - indentedˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - item
+ - indented
+ - ˇ
+ "});
+
+ // Case 6: Adding newline with cursor right after marker, unindents
+ cx.set_state(indoc! {"
+ - item
+ - sub item
+ - ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - item
+ - sub item
+ - ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+
+ // Case 7: Adding newline with cursor right after marker, removes marker
+ cx.assert_editor_state(indoc! {"
+ - item
+ - sub item
+ - ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ - item
+ - sub item
+ ˇ
+ "});
+
+ // Case 8: Cursor before or inside prefix does not add marker
+ cx.set_state(indoc! {"
+ ˇ- item
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+
+ ˇ- item
+ "});
+
+ cx.set_state(indoc! {"
+ -ˇ item
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ -
+ ˇitem
+ "});
+}
+
+#[gpui::test]
+async fn test_newline_ordered_list_continuation(cx: &mut TestAppContext) {
+ init_test(cx, |settings| {
+ settings.defaults.tab_size = Some(2.try_into().unwrap());
+ });
+
+ let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
+ let mut cx = EditorTestContext::new(cx).await;
+ cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
+
+ // Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number
+ cx.set_state(indoc! {"
+ 1. first itemˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ 1. first item
+ 2. ˇ
+ "});
+
+ // Case 2: Works with larger numbers
+ cx.set_state(indoc! {"
+ 10. tenth itemˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ 10. tenth item
+ 11. ˇ
+ "});
+
+ // Case 3: Cursor position doesn't matter - content after marker is what counts
+ cx.set_state(indoc! {"
+ 1. itˇem
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ 1. it
+ 2. ˇem
+ "});
+
+ // Case 4: Adding newline after (whitespace + marker + some whitespace) does NOT add marker
+ cx.set_state(indoc! {"
+ 1. ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(
+ indoc! {"
+ 1. $
+ ˇ
+ "}
+ .replace("$", " ")
+ .as_str(),
+ );
+
+ // Case 5: Adding newline with content adds marker preserving indentation
+ cx.set_state(indoc! {"
+ 1. item
+ 2. indentedˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ 1. item
+ 2. indented
+ 3. ˇ
+ "});
+
+ // Case 6: Adding newline with cursor right after marker, unindents
+ cx.set_state(indoc! {"
+ 1. item
+ 2. sub item
+ 3. ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ 1. item
+ 2. sub item
+ 1. ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+
+ // Case 7: Adding newline with cursor right after marker, removes marker
+ cx.assert_editor_state(indoc! {"
+ 1. item
+ 2. sub item
+ 1. ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ 1. item
+ 2. sub item
+ ˇ
+ "});
+
+ // Case 8: Cursor before or inside prefix does not add marker
+ cx.set_state(indoc! {"
+ ˇ1. item
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+
+ ˇ1. item
+ "});
+
+ cx.set_state(indoc! {"
+ 1ˇ. item
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ 1
+ ˇ. item
+ "});
+}
+
+#[gpui::test]
+async fn test_newline_should_not_autoindent_ordered_list(cx: &mut TestAppContext) {
+ init_test(cx, |settings| {
+ settings.defaults.tab_size = Some(2.try_into().unwrap());
+ });
+
+ let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
+ let mut cx = EditorTestContext::new(cx).await;
+ cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
+
+ // Case 1: Adding newline after (whitespace + marker + any non-whitespace) increments number
+ cx.set_state(indoc! {"
+ 1. first item
+ 1. sub first item
+ 2. sub second item
+ 3. ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.newline(&Newline, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ cx.assert_editor_state(indoc! {"
+ 1. first item
+ 1. sub first item
+ 2. sub second item
+ 1. ˇ
+ "});
+}
+
+#[gpui::test]
+async fn test_tab_list_indent(cx: &mut TestAppContext) {
+ init_test(cx, |settings| {
+ settings.defaults.tab_size = Some(2.try_into().unwrap());
+ });
+
+ let markdown_language = languages::language("markdown", tree_sitter_md::LANGUAGE.into());
+ let mut cx = EditorTestContext::new(cx).await;
+ cx.update_buffer(|buffer, cx| buffer.set_language(Some(markdown_language), cx));
+
+ // Case 1: Unordered list - cursor after prefix, adds indent before prefix
+ cx.set_state(indoc! {"
+ - ˇitem
+ "});
+ cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ let expected = indoc! {"
+ $$- ˇitem
+ "};
+ cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+ // Case 2: Task list - cursor after prefix
+ cx.set_state(indoc! {"
+ - [ ] ˇtask
+ "});
+ cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ let expected = indoc! {"
+ $$- [ ] ˇtask
+ "};
+ cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+ // Case 3: Ordered list - cursor after prefix
+ cx.set_state(indoc! {"
+ 1. ˇfirst
+ "});
+ cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ let expected = indoc! {"
+ $$1. ˇfirst
+ "};
+ cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+ // Case 4: With existing indentation - adds more indent
+ let initial = indoc! {"
+ $$- ˇitem
+ "};
+ cx.set_state(initial.replace("$", " ").as_str());
+ cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ let expected = indoc! {"
+ $$$$- ˇitem
+ "};
+ cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+ // Case 5: Empty list item
+ cx.set_state(indoc! {"
+ - ˇ
+ "});
+ cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ let expected = indoc! {"
+ $$- ˇ
+ "};
+ cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+ // Case 6: Cursor at end of line with content
+ cx.set_state(indoc! {"
+ - itemˇ
+ "});
+ cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ let expected = indoc! {"
+ $$- itemˇ
+ "};
+ cx.assert_editor_state(expected.replace("$", " ").as_str());
+
+ // Case 7: Cursor at start of list item, indents it
+ cx.set_state(indoc! {"
+ - item
+ ˇ - sub item
+ "});
+ cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ let expected = indoc! {"
+ - item
+ ˇ - sub item
+ "};
+ cx.assert_editor_state(expected);
+
+ // Case 8: Cursor at start of list item, moves the cursor when "indent_list_on_tab" is false
+ cx.update_editor(|_, _, cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings(cx, |settings| {
+ settings.project.all_languages.defaults.indent_list_on_tab = Some(false);
+ });
+ });
+ });
+ cx.set_state(indoc! {"
+ - item
+ ˇ - sub item
+ "});
+ cx.update_editor(|e, window, cx| e.tab(&Tab, window, cx));
+ cx.wait_for_autoindent_applied().await;
+ let expected = indoc! {"
+ - item
+ ˇ- sub item
+ "};
+ cx.assert_editor_state(expected);
+}
+
#[gpui::test]
async fn test_local_worktree_trust(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -66,7 +66,7 @@ use project::{
};
use settings::{
GitGutterSetting, GitHunkStyleSetting, IndentGuideBackgroundColoring, IndentGuideColoring,
- Settings,
+ RelativeLineNumbers, Settings,
};
use smallvec::{SmallVec, smallvec};
use std::{
@@ -194,8 +194,6 @@ pub struct EditorElement {
style: EditorStyle,
}
-type DisplayRowDelta = u32;
-
impl EditorElement {
pub(crate) const SCROLLBAR_WIDTH: Pixels = px(15.);
@@ -3225,64 +3223,6 @@ impl EditorElement {
.collect()
}
- fn calculate_relative_line_numbers(
- &self,
- snapshot: &EditorSnapshot,
- rows: &Range<DisplayRow>,
- relative_to: Option<DisplayRow>,
- count_wrapped_lines: bool,
- ) -> HashMap<DisplayRow, DisplayRowDelta> {
- let mut relative_rows: HashMap<DisplayRow, DisplayRowDelta> = Default::default();
- let Some(relative_to) = relative_to else {
- return relative_rows;
- };
-
- let start = rows.start.min(relative_to);
- let end = rows.end.max(relative_to);
-
- let buffer_rows = snapshot
- .row_infos(start)
- .take(1 + end.minus(start) as usize)
- .collect::<Vec<_>>();
-
- let head_idx = relative_to.minus(start);
- let mut delta = 1;
- let mut i = head_idx + 1;
- let should_count_line = |row_info: &RowInfo| {
- if count_wrapped_lines {
- row_info.buffer_row.is_some() || row_info.wrapped_buffer_row.is_some()
- } else {
- row_info.buffer_row.is_some()
- }
- };
- while i < buffer_rows.len() as u32 {
- if should_count_line(&buffer_rows[i as usize]) {
- if rows.contains(&DisplayRow(i + start.0)) {
- relative_rows.insert(DisplayRow(i + start.0), delta);
- }
- delta += 1;
- }
- i += 1;
- }
- delta = 1;
- i = head_idx.min(buffer_rows.len().saturating_sub(1) as u32);
- while i > 0 && buffer_rows[i as usize].buffer_row.is_none() && !count_wrapped_lines {
- i -= 1;
- }
-
- while i > 0 {
- i -= 1;
- if should_count_line(&buffer_rows[i as usize]) {
- if rows.contains(&DisplayRow(i + start.0)) {
- relative_rows.insert(DisplayRow(i + start.0), delta);
- }
- delta += 1;
- }
- }
-
- relative_rows
- }
-
fn layout_line_numbers(
&self,
gutter_hitbox: Option<&Hitbox>,
@@ -3292,7 +3232,7 @@ impl EditorElement {
rows: Range<DisplayRow>,
buffer_rows: &[RowInfo],
active_rows: &BTreeMap<DisplayRow, LineHighlightSpec>,
- newest_selection_head: Option<DisplayPoint>,
+ relative_line_base: Option<DisplayRow>,
snapshot: &EditorSnapshot,
window: &mut Window,
cx: &mut App,
@@ -3304,32 +3244,16 @@ impl EditorElement {
return Arc::default();
}
- let (newest_selection_head, relative) = self.editor.update(cx, |editor, cx| {
- let newest_selection_head = newest_selection_head.unwrap_or_else(|| {
- let newest = editor
- .selections
- .newest::<Point>(&editor.display_snapshot(cx));
- SelectionLayout::new(
- newest,
- editor.selections.line_mode(),
- editor.cursor_offset_on_selection,
- editor.cursor_shape,
- &snapshot.display_snapshot,
- true,
- true,
- None,
- )
- .head
- });
- let relative = editor.relative_line_numbers(cx);
- (newest_selection_head, relative)
- });
+ let relative = self.editor.read(cx).relative_line_numbers(cx);
let relative_line_numbers_enabled = relative.enabled();
- let relative_to = relative_line_numbers_enabled.then(|| newest_selection_head.row());
+ let relative_rows = if relative_line_numbers_enabled && let Some(base) = relative_line_base
+ {
+ snapshot.calculate_relative_line_numbers(&rows, base, relative.wrapped())
+ } else {
+ Default::default()
+ };
- let relative_rows =
- self.calculate_relative_line_numbers(snapshot, &rows, relative_to, relative.wrapped());
let mut line_number = String::new();
let segments = buffer_rows.iter().enumerate().flat_map(|(ix, row_info)| {
let display_row = DisplayRow(rows.start.0 + ix as u32);
@@ -4652,6 +4576,8 @@ impl EditorElement {
gutter_hitbox: &Hitbox,
text_hitbox: &Hitbox,
style: &EditorStyle,
+ relative_line_numbers: RelativeLineNumbers,
+ relative_to: Option<DisplayRow>,
window: &mut Window,
cx: &mut App,
) -> Option<StickyHeaders> {
@@ -4681,9 +4607,21 @@ impl EditorElement {
);
let line_number = show_line_numbers.then(|| {
- let number = (start_point.row + 1).to_string();
+ let relative_number = relative_to.and_then(|base| match relative_line_numbers {
+ RelativeLineNumbers::Disabled => None,
+ RelativeLineNumbers::Enabled => {
+ Some(snapshot.relative_line_delta_to_point(base, start_point))
+ }
+ RelativeLineNumbers::Wrapped => {
+ Some(snapshot.relative_wrapped_line_delta_to_point(base, start_point))
+ }
+ });
+ let number = relative_number
+ .filter(|&delta| delta != 0)
+ .map(|delta| delta.unsigned_abs() as u32)
+ .unwrap_or(start_point.row + 1);
let color = cx.theme().colors().editor_line_number;
- self.shape_line_number(SharedString::from(number), color, window)
+ self.shape_line_number(SharedString::from(number.to_string()), color, window)
});
lines.push(StickyHeaderLine::new(
@@ -9436,6 +9374,28 @@ impl Element for EditorElement {
window,
cx,
);
+
+ // relative rows are based on newest selection, even outside the visible area
+ let relative_row_base = self.editor.update(cx, |editor, cx| {
+ if editor.selections.count()==0 {
+ return None;
+ }
+ let newest = editor
+ .selections
+ .newest::<Point>(&editor.display_snapshot(cx));
+ Some(SelectionLayout::new(
+ newest,
+ editor.selections.line_mode(),
+ editor.cursor_offset_on_selection,
+ editor.cursor_shape,
+ &snapshot.display_snapshot,
+ true,
+ true,
+ None,
+ )
+ .head.row())
+ });
+
let mut breakpoint_rows = self.editor.update(cx, |editor, cx| {
editor.active_breakpoints(start_row..end_row, window, cx)
});
@@ -9453,7 +9413,7 @@ impl Element for EditorElement {
start_row..end_row,
&row_infos,
&active_rows,
- newest_selection_head,
+ relative_row_base,
&snapshot,
window,
cx,
@@ -9773,6 +9733,7 @@ impl Element for EditorElement {
&& is_singleton
&& EditorSettings::get_global(cx).sticky_scroll.enabled
{
+ let relative = self.editor.read(cx).relative_line_numbers(cx);
self.layout_sticky_headers(
&snapshot,
editor_width,
@@ -9784,6 +9745,8 @@ impl Element for EditorElement {
&gutter_hitbox,
&text_hitbox,
&style,
+ relative,
+ relative_row_base,
window,
cx,
)
@@ -11631,7 +11594,7 @@ mod tests {
}
#[gpui::test]
- fn test_shape_line_numbers(cx: &mut TestAppContext) {
+ fn test_layout_line_numbers(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let window = cx.add_window(|window, cx| {
let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx);
@@ -11671,7 +11634,7 @@ mod tests {
})
.collect::<Vec<_>>(),
&BTreeMap::default(),
- Some(DisplayPoint::new(DisplayRow(0), 0)),
+ Some(DisplayRow(0)),
&snapshot,
window,
cx,
@@ -11683,10 +11646,9 @@ mod tests {
let relative_rows = window
.update(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
- element.calculate_relative_line_numbers(
- &snapshot,
+ snapshot.calculate_relative_line_numbers(
&(DisplayRow(0)..DisplayRow(6)),
- Some(DisplayRow(3)),
+ DisplayRow(3),
false,
)
})
@@ -11702,10 +11664,9 @@ mod tests {
let relative_rows = window
.update(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
- element.calculate_relative_line_numbers(
- &snapshot,
+ snapshot.calculate_relative_line_numbers(
&(DisplayRow(3)..DisplayRow(6)),
- Some(DisplayRow(1)),
+ DisplayRow(1),
false,
)
})
@@ -11719,10 +11680,9 @@ mod tests {
let relative_rows = window
.update(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
- element.calculate_relative_line_numbers(
- &snapshot,
+ snapshot.calculate_relative_line_numbers(
&(DisplayRow(0)..DisplayRow(3)),
- Some(DisplayRow(6)),
+ DisplayRow(6),
false,
)
})
@@ -11759,7 +11719,7 @@ mod tests {
})
.collect::<Vec<_>>(),
&BTreeMap::default(),
- Some(DisplayPoint::new(DisplayRow(0), 0)),
+ Some(DisplayRow(0)),
&snapshot,
window,
cx,
@@ -11774,7 +11734,7 @@ mod tests {
}
#[gpui::test]
- fn test_shape_line_numbers_wrapping(cx: &mut TestAppContext) {
+ fn test_layout_line_numbers_wrapping(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let window = cx.add_window(|window, cx| {
let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx);
@@ -11819,7 +11779,7 @@ mod tests {
})
.collect::<Vec<_>>(),
&BTreeMap::default(),
- Some(DisplayPoint::new(DisplayRow(0), 0)),
+ Some(DisplayRow(0)),
&snapshot,
window,
cx,
@@ -11831,10 +11791,9 @@ mod tests {
let relative_rows = window
.update(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
- element.calculate_relative_line_numbers(
- &snapshot,
+ snapshot.calculate_relative_line_numbers(
&(DisplayRow(0)..DisplayRow(6)),
- Some(DisplayRow(3)),
+ DisplayRow(3),
true,
)
})
@@ -11871,7 +11830,7 @@ mod tests {
})
.collect::<Vec<_>>(),
&BTreeMap::from_iter([(DisplayRow(0), LineHighlightSpec::default())]),
- Some(DisplayPoint::new(DisplayRow(0), 0)),
+ Some(DisplayRow(0)),
&snapshot,
window,
cx,
@@ -11886,10 +11845,9 @@ mod tests {
let relative_rows = window
.update(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
- element.calculate_relative_line_numbers(
- &snapshot,
+ snapshot.calculate_relative_line_numbers(
&(DisplayRow(0)..DisplayRow(6)),
- Some(DisplayRow(3)),
+ DisplayRow(3),
true,
)
})
@@ -5,7 +5,7 @@ use crate::{
};
use gpui::{Bounds, Context, Pixels, Window};
use language::Point;
-use multi_buffer::Anchor;
+use multi_buffer::{Anchor, ToPoint};
use std::cmp;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
@@ -186,6 +186,19 @@ impl Editor {
}
}
+ let style = self.style(cx).clone();
+ let sticky_headers = self.sticky_headers(&style, cx).unwrap_or_default();
+ let visible_sticky_headers = sticky_headers
+ .iter()
+ .filter(|h| {
+ let buffer_snapshot = display_map.buffer_snapshot();
+ let buffer_range =
+ h.range.start.to_point(buffer_snapshot)..h.range.end.to_point(buffer_snapshot);
+
+ buffer_range.contains(&Point::new(target_top as u32, 0))
+ })
+ .count();
+
let margin = if matches!(self.mode, EditorMode::AutoHeight { .. }) {
0.
} else {
@@ -218,7 +231,7 @@ impl Editor {
let was_autoscrolled = match strategy {
AutoscrollStrategy::Fit | AutoscrollStrategy::Newest => {
let margin = margin.min(self.scroll_manager.vertical_scroll_margin);
- let target_top = (target_top - margin).max(0.0);
+ let target_top = (target_top - margin - visible_sticky_headers as f64).max(0.0);
let target_bottom = target_bottom + margin;
let start_row = scroll_position.y;
let end_row = start_row + visible_lines;
@@ -5203,7 +5203,7 @@ impl GitPanel {
this.child(
self.entry_label(path_name, path_color)
- .truncate()
+ .truncate_start()
.when(strikethrough, Label::strikethrough),
)
})
@@ -1077,11 +1077,9 @@ impl App {
self.platform.window_appearance()
}
- /// Writes data to the primary selection buffer.
- /// Only available on Linux.
- #[cfg(any(target_os = "linux", target_os = "freebsd"))]
- pub fn write_to_primary(&self, item: ClipboardItem) {
- self.platform.write_to_primary(item)
+ /// Reads data from the platform clipboard.
+ pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
+ self.platform.read_from_clipboard()
}
/// Writes data to the platform clipboard.
@@ -1096,9 +1094,31 @@ impl App {
self.platform.read_from_primary()
}
- /// Reads data from the platform clipboard.
- pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
- self.platform.read_from_clipboard()
+ /// Writes data to the primary selection buffer.
+ /// Only available on Linux.
+ #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+ pub fn write_to_primary(&self, item: ClipboardItem) {
+ self.platform.write_to_primary(item)
+ }
+
+ /// Reads data from macOS's "Find" pasteboard.
+ ///
+ /// Used to share the current search string between apps.
+ ///
+ /// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find
+ #[cfg(target_os = "macos")]
+ pub fn read_from_find_pasteboard(&self) -> Option<ClipboardItem> {
+ self.platform.read_from_find_pasteboard()
+ }
+
+ /// Writes data to macOS's "Find" pasteboard.
+ ///
+ /// Used to share the current search string between apps.
+ ///
+ /// https://developer.apple.com/documentation/appkit/nspasteboard/name-swift.struct/find
+ #[cfg(target_os = "macos")]
+ pub fn write_to_find_pasteboard(&self, item: ClipboardItem) {
+ self.platform.write_to_find_pasteboard(item)
}
/// Writes credentials to the platform keychain.
@@ -2,8 +2,8 @@ use crate::{
ActiveTooltip, AnyView, App, Bounds, DispatchPhase, Element, ElementId, GlobalElementId,
HighlightStyle, Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, SharedString, Size, TextOverflow,
- TextRun, TextStyle, TooltipId, WhiteSpace, Window, WrappedLine, WrappedLineLayout,
- register_tooltip_mouse_handlers, set_tooltip_on_window,
+ TextRun, TextStyle, TooltipId, TruncateFrom, WhiteSpace, Window, WrappedLine,
+ WrappedLineLayout, register_tooltip_mouse_handlers, set_tooltip_on_window,
};
use anyhow::Context as _;
use itertools::Itertools;
@@ -354,7 +354,7 @@ impl TextLayout {
None
};
- let (truncate_width, truncation_suffix) =
+ let (truncate_width, truncation_affix, truncate_from) =
if let Some(text_overflow) = text_style.text_overflow.clone() {
let width = known_dimensions.width.or(match available_space.width {
crate::AvailableSpace::Definite(x) => match text_style.line_clamp {
@@ -365,17 +365,24 @@ impl TextLayout {
});
match text_overflow {
- TextOverflow::Truncate(s) => (width, s),
+ TextOverflow::Truncate(s) => (width, s, TruncateFrom::End),
+ TextOverflow::TruncateStart(s) => (width, s, TruncateFrom::Start),
}
} else {
- (None, "".into())
+ (None, "".into(), TruncateFrom::End)
};
+ // Only use cached layout if:
+ // 1. We have a cached size
+ // 2. wrap_width matches (or both are None)
+ // 3. truncate_width is None (if truncate_width is Some, we need to re-layout
+ // because the previous layout may have been computed without truncation)
if let Some(text_layout) = element_state.0.borrow().as_ref()
- && text_layout.size.is_some()
+ && let Some(size) = text_layout.size
&& (wrap_width.is_none() || wrap_width == text_layout.wrap_width)
+ && truncate_width.is_none()
{
- return text_layout.size.unwrap();
+ return size;
}
let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size);
@@ -383,8 +390,9 @@ impl TextLayout {
line_wrapper.truncate_line(
text.clone(),
truncate_width,
- &truncation_suffix,
+ &truncation_affix,
&runs,
+ truncate_from,
)
} else {
(text.clone(), Cow::Borrowed(&*runs))
@@ -262,12 +262,18 @@ pub(crate) trait Platform: 'static {
fn set_cursor_style(&self, style: CursorStyle);
fn should_auto_hide_scrollbars(&self) -> bool;
- #[cfg(any(target_os = "linux", target_os = "freebsd"))]
- fn write_to_primary(&self, item: ClipboardItem);
+ fn read_from_clipboard(&self) -> Option<ClipboardItem>;
fn write_to_clipboard(&self, item: ClipboardItem);
+
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn read_from_primary(&self) -> Option<ClipboardItem>;
- fn read_from_clipboard(&self) -> Option<ClipboardItem>;
+ #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+ fn write_to_primary(&self, item: ClipboardItem);
+
+ #[cfg(target_os = "macos")]
+ fn read_from_find_pasteboard(&self) -> Option<ClipboardItem>;
+ #[cfg(target_os = "macos")]
+ fn write_to_find_pasteboard(&self, item: ClipboardItem);
fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task<Result<()>>;
fn read_credentials(&self, url: &str) -> Task<Result<Option<(String, Vec<u8>)>>>;
@@ -5,6 +5,7 @@ mod display;
mod display_link;
mod events;
mod keyboard;
+mod pasteboard;
#[cfg(feature = "screen-capture")]
mod screen_capture;
@@ -21,8 +22,6 @@ use metal_renderer as renderer;
#[cfg(feature = "macos-blade")]
use crate::platform::blade as renderer;
-mod attributed_string;
-
#[cfg(feature = "font-kit")]
mod open_type;
@@ -1,129 +0,0 @@
-use cocoa::base::id;
-use cocoa::foundation::NSRange;
-use objc::{class, msg_send, sel, sel_impl};
-
-/// The `cocoa` crate does not define NSAttributedString (and related Cocoa classes),
-/// which are needed for copying rich text (that is, text intermingled with images)
-/// to the clipboard. This adds access to those APIs.
-#[allow(non_snake_case)]
-pub trait NSAttributedString: Sized {
- unsafe fn alloc(_: Self) -> id {
- msg_send![class!(NSAttributedString), alloc]
- }
-
- unsafe fn init_attributed_string(self, string: id) -> id;
- unsafe fn appendAttributedString_(self, attr_string: id);
- unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id;
- unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id;
- unsafe fn string(self) -> id;
-}
-
-impl NSAttributedString for id {
- unsafe fn init_attributed_string(self, string: id) -> id {
- msg_send![self, initWithString: string]
- }
-
- unsafe fn appendAttributedString_(self, attr_string: id) {
- let _: () = msg_send![self, appendAttributedString: attr_string];
- }
-
- unsafe fn RTFDFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id {
- msg_send![self, RTFDFromRange: range documentAttributes: attrs]
- }
-
- unsafe fn RTFFromRange_documentAttributes_(self, range: NSRange, attrs: id) -> id {
- msg_send![self, RTFFromRange: range documentAttributes: attrs]
- }
-
- unsafe fn string(self) -> id {
- msg_send![self, string]
- }
-}
-
-pub trait NSMutableAttributedString: NSAttributedString {
- unsafe fn alloc(_: Self) -> id {
- msg_send![class!(NSMutableAttributedString), alloc]
- }
-}
-
-impl NSMutableAttributedString for id {}
-
-#[cfg(test)]
-mod tests {
- use crate::platform::mac::ns_string;
-
- use super::*;
- use cocoa::appkit::NSImage;
- use cocoa::base::nil;
- use cocoa::foundation::NSAutoreleasePool;
- #[test]
- #[ignore] // This was SIGSEGV-ing on CI but not locally; need to investigate https://github.com/zed-industries/zed/actions/runs/10362363230/job/28684225486?pr=15782#step:4:1348
- fn test_nsattributed_string() {
- // TODO move these to parent module once it's actually ready to be used
- #[allow(non_snake_case)]
- pub trait NSTextAttachment: Sized {
- unsafe fn alloc(_: Self) -> id {
- msg_send![class!(NSTextAttachment), alloc]
- }
- }
-
- impl NSTextAttachment for id {}
-
- unsafe {
- let image: id = {
- let img: id = msg_send![class!(NSImage), alloc];
- let img: id = msg_send![img, initWithContentsOfFile: ns_string("test.jpeg")];
- let img: id = msg_send![img, autorelease];
- img
- };
- let _size = image.size();
-
- let string = ns_string("Test String");
- let attr_string = NSMutableAttributedString::alloc(nil)
- .init_attributed_string(string)
- .autorelease();
- let hello_string = ns_string("Hello World");
- let hello_attr_string = NSAttributedString::alloc(nil)
- .init_attributed_string(hello_string)
- .autorelease();
- attr_string.appendAttributedString_(hello_attr_string);
-
- let attachment: id = msg_send![NSTextAttachment::alloc(nil), autorelease];
- let _: () = msg_send![attachment, setImage: image];
- let image_attr_string =
- msg_send![class!(NSAttributedString), attributedStringWithAttachment: attachment];
- attr_string.appendAttributedString_(image_attr_string);
-
- let another_string = ns_string("Another String");
- let another_attr_string = NSAttributedString::alloc(nil)
- .init_attributed_string(another_string)
- .autorelease();
- attr_string.appendAttributedString_(another_attr_string);
-
- let _len: cocoa::foundation::NSUInteger = msg_send![attr_string, length];
-
- ///////////////////////////////////////////////////
- // pasteboard.clearContents();
-
- let rtfd_data = attr_string.RTFDFromRange_documentAttributes_(
- NSRange::new(0, msg_send![attr_string, length]),
- nil,
- );
- assert_ne!(rtfd_data, nil);
- // if rtfd_data != nil {
- // pasteboard.setData_forType(rtfd_data, NSPasteboardTypeRTFD);
- // }
-
- // let rtf_data = attributed_string.RTFFromRange_documentAttributes_(
- // NSRange::new(0, attributed_string.length()),
- // nil,
- // );
- // if rtf_data != nil {
- // pasteboard.setData_forType(rtf_data, NSPasteboardTypeRTF);
- // }
-
- // let plain_text = attributed_string.string();
- // pasteboard.setString_forType(plain_text, NSPasteboardTypeString);
- }
- }
-}
@@ -0,0 +1,344 @@
+use core::slice;
+use std::ffi::c_void;
+
+use cocoa::{
+ appkit::{NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString, NSPasteboardTypeTIFF},
+ base::{id, nil},
+ foundation::NSData,
+};
+use objc::{msg_send, runtime::Object, sel, sel_impl};
+use strum::IntoEnumIterator as _;
+
+use crate::{
+ ClipboardEntry, ClipboardItem, ClipboardString, Image, ImageFormat, asset_cache::hash,
+ platform::mac::ns_string,
+};
+
+pub struct Pasteboard {
+ inner: id,
+ text_hash_type: id,
+ metadata_type: id,
+}
+
+impl Pasteboard {
+ pub fn general() -> Self {
+ unsafe { Self::new(NSPasteboard::generalPasteboard(nil)) }
+ }
+
+ pub fn find() -> Self {
+ unsafe { Self::new(NSPasteboard::pasteboardWithName(nil, NSPasteboardNameFind)) }
+ }
+
+ #[cfg(test)]
+ pub fn unique() -> Self {
+ unsafe { Self::new(NSPasteboard::pasteboardWithUniqueName(nil)) }
+ }
+
+ unsafe fn new(inner: id) -> Self {
+ Self {
+ inner,
+ text_hash_type: unsafe { ns_string("zed-text-hash") },
+ metadata_type: unsafe { ns_string("zed-metadata") },
+ }
+ }
+
+ pub fn read(&self) -> Option<ClipboardItem> {
+ // First, see if it's a string.
+ unsafe {
+ let pasteboard_types: id = self.inner.types();
+ let string_type: id = ns_string("public.utf8-plain-text");
+
+ if msg_send![pasteboard_types, containsObject: string_type] {
+ let data = self.inner.dataForType(string_type);
+ if data == nil {
+ return None;
+ } else if data.bytes().is_null() {
+ // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc
+ // "If the length of the NSData object is 0, this property returns nil."
+ return Some(self.read_string(&[]));
+ } else {
+ let bytes =
+ slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize);
+
+ return Some(self.read_string(bytes));
+ }
+ }
+
+ // If it wasn't a string, try the various supported image types.
+ for format in ImageFormat::iter() {
+ if let Some(item) = self.read_image(format) {
+ return Some(item);
+ }
+ }
+ }
+
+ // If it wasn't a string or a supported image type, give up.
+ None
+ }
+
+ fn read_image(&self, format: ImageFormat) -> Option<ClipboardItem> {
+ let mut ut_type: UTType = format.into();
+
+ unsafe {
+ let types: id = self.inner.types();
+ if msg_send![types, containsObject: ut_type.inner()] {
+ self.data_for_type(ut_type.inner_mut()).map(|bytes| {
+ let bytes = bytes.to_vec();
+ let id = hash(&bytes);
+
+ ClipboardItem {
+ entries: vec![ClipboardEntry::Image(Image { format, bytes, id })],
+ }
+ })
+ } else {
+ None
+ }
+ }
+ }
+
+ fn read_string(&self, text_bytes: &[u8]) -> ClipboardItem {
+ unsafe {
+ let text = String::from_utf8_lossy(text_bytes).to_string();
+ let metadata = self
+ .data_for_type(self.text_hash_type)
+ .and_then(|hash_bytes| {
+ let hash_bytes = hash_bytes.try_into().ok()?;
+ let hash = u64::from_be_bytes(hash_bytes);
+ let metadata = self.data_for_type(self.metadata_type)?;
+
+ if hash == ClipboardString::text_hash(&text) {
+ String::from_utf8(metadata.to_vec()).ok()
+ } else {
+ None
+ }
+ });
+
+ ClipboardItem {
+ entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })],
+ }
+ }
+ }
+
+ unsafe fn data_for_type(&self, kind: id) -> Option<&[u8]> {
+ unsafe {
+ let data = self.inner.dataForType(kind);
+ if data == nil {
+ None
+ } else {
+ Some(slice::from_raw_parts(
+ data.bytes() as *mut u8,
+ data.length() as usize,
+ ))
+ }
+ }
+ }
+
+ pub fn write(&self, item: ClipboardItem) {
+ unsafe {
+ match item.entries.as_slice() {
+ [] => {
+ // Writing an empty list of entries just clears the clipboard.
+ self.inner.clearContents();
+ }
+ [ClipboardEntry::String(string)] => {
+ self.write_plaintext(string);
+ }
+ [ClipboardEntry::Image(image)] => {
+ self.write_image(image);
+ }
+ [ClipboardEntry::ExternalPaths(_)] => {}
+ _ => {
+ // Agus NB: We're currently only writing string entries to the clipboard when we have more than one.
+ //
+ // This was the existing behavior before I refactored the outer clipboard code:
+ // https://github.com/zed-industries/zed/blob/65f7412a0265552b06ce122655369d6cc7381dd6/crates/gpui/src/platform/mac/platform.rs#L1060-L1110
+ //
+ // Note how `any_images` is always `false`. We should fix that, but that's orthogonal to the refactor.
+
+ let mut combined = ClipboardString {
+ text: String::new(),
+ metadata: None,
+ };
+
+ for entry in item.entries {
+ match entry {
+ ClipboardEntry::String(text) => {
+ combined.text.push_str(&text.text());
+ if combined.metadata.is_none() {
+ combined.metadata = text.metadata;
+ }
+ }
+ _ => {}
+ }
+ }
+
+ self.write_plaintext(&combined);
+ }
+ }
+ }
+ }
+
+ fn write_plaintext(&self, string: &ClipboardString) {
+ unsafe {
+ self.inner.clearContents();
+
+ let text_bytes = NSData::dataWithBytes_length_(
+ nil,
+ string.text.as_ptr() as *const c_void,
+ string.text.len() as u64,
+ );
+ self.inner
+ .setData_forType(text_bytes, NSPasteboardTypeString);
+
+ if let Some(metadata) = string.metadata.as_ref() {
+ let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes();
+ let hash_bytes = NSData::dataWithBytes_length_(
+ nil,
+ hash_bytes.as_ptr() as *const c_void,
+ hash_bytes.len() as u64,
+ );
+ self.inner.setData_forType(hash_bytes, self.text_hash_type);
+
+ let metadata_bytes = NSData::dataWithBytes_length_(
+ nil,
+ metadata.as_ptr() as *const c_void,
+ metadata.len() as u64,
+ );
+ self.inner
+ .setData_forType(metadata_bytes, self.metadata_type);
+ }
+ }
+ }
+
+ unsafe fn write_image(&self, image: &Image) {
+ unsafe {
+ self.inner.clearContents();
+
+ let bytes = NSData::dataWithBytes_length_(
+ nil,
+ image.bytes.as_ptr() as *const c_void,
+ image.bytes.len() as u64,
+ );
+
+ self.inner
+ .setData_forType(bytes, Into::<UTType>::into(image.format).inner_mut());
+ }
+ }
+}
+
+#[link(name = "AppKit", kind = "framework")]
+unsafe extern "C" {
+ /// [Apple's documentation](https://developer.apple.com/documentation/appkit/nspasteboardnamefind?language=objc)
+ pub static NSPasteboardNameFind: id;
+}
+
+impl From<ImageFormat> for UTType {
+ fn from(value: ImageFormat) -> Self {
+ match value {
+ ImageFormat::Png => Self::png(),
+ ImageFormat::Jpeg => Self::jpeg(),
+ ImageFormat::Tiff => Self::tiff(),
+ ImageFormat::Webp => Self::webp(),
+ ImageFormat::Gif => Self::gif(),
+ ImageFormat::Bmp => Self::bmp(),
+ ImageFormat::Svg => Self::svg(),
+ ImageFormat::Ico => Self::ico(),
+ }
+ }
+}
+
+// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/
+pub struct UTType(id);
+
+impl UTType {
+ pub fn png() -> Self {
+ // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png
+ Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType
+ }
+
+ pub fn jpeg() -> Self {
+ // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg
+ Self(unsafe { ns_string("public.jpeg") })
+ }
+
+ pub fn gif() -> Self {
+ // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif
+ Self(unsafe { ns_string("com.compuserve.gif") })
+ }
+
+ pub fn webp() -> Self {
+ // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp
+ Self(unsafe { ns_string("org.webmproject.webp") })
+ }
+
+ pub fn bmp() -> Self {
+ // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp
+ Self(unsafe { ns_string("com.microsoft.bmp") })
+ }
+
+ pub fn svg() -> Self {
+ // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg
+ Self(unsafe { ns_string("public.svg-image") })
+ }
+
+ pub fn ico() -> Self {
+ // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico
+ Self(unsafe { ns_string("com.microsoft.ico") })
+ }
+
+ pub fn tiff() -> Self {
+ // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff
+ Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType
+ }
+
+ fn inner(&self) -> *const Object {
+ self.0
+ }
+
+ pub fn inner_mut(&self) -> *mut Object {
+ self.0 as *mut _
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use cocoa::{appkit::NSPasteboardTypeString, foundation::NSData};
+
+ use crate::{ClipboardEntry, ClipboardItem, ClipboardString};
+
+ use super::*;
+
+ #[test]
+ fn test_string() {
+ let pasteboard = Pasteboard::unique();
+ assert_eq!(pasteboard.read(), None);
+
+ let item = ClipboardItem::new_string("1".to_string());
+ pasteboard.write(item.clone());
+ assert_eq!(pasteboard.read(), Some(item));
+
+ let item = ClipboardItem {
+ entries: vec![ClipboardEntry::String(
+ ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]),
+ )],
+ };
+ pasteboard.write(item.clone());
+ assert_eq!(pasteboard.read(), Some(item));
+
+ let text_from_other_app = "text from other app";
+ unsafe {
+ let bytes = NSData::dataWithBytes_length_(
+ nil,
+ text_from_other_app.as_ptr() as *const c_void,
+ text_from_other_app.len() as u64,
+ );
+ pasteboard
+ .inner
+ .setData_forType(bytes, NSPasteboardTypeString);
+ }
+ assert_eq!(
+ pasteboard.read(),
+ Some(ClipboardItem::new_string(text_from_other_app.to_string()))
+ );
+ }
+}
@@ -1,29 +1,24 @@
use super::{
- BoolExt, MacKeyboardLayout, MacKeyboardMapper,
- attributed_string::{NSAttributedString, NSMutableAttributedString},
- events::key_to_native,
- ns_string, renderer,
+ BoolExt, MacKeyboardLayout, MacKeyboardMapper, events::key_to_native, ns_string, renderer,
};
use crate::{
- Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString,
- CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher,
- MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform,
- PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
- PlatformWindow, Result, SystemMenuType, Task, WindowAppearance, WindowParams, hash,
+ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor,
+ KeyContext, Keymap, MacDispatcher, MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu,
+ PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper,
+ PlatformTextSystem, PlatformWindow, Result, SystemMenuType, Task, WindowAppearance,
+ WindowParams, platform::mac::pasteboard::Pasteboard,
};
use anyhow::{Context as _, anyhow};
use block::ConcreteBlock;
use cocoa::{
appkit::{
NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular,
- NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSPasteboard,
- NSPasteboardTypePNG, NSPasteboardTypeRTF, NSPasteboardTypeRTFD, NSPasteboardTypeString,
- NSPasteboardTypeTIFF, NSSavePanel, NSVisualEffectState, NSVisualEffectView, NSWindow,
+ NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSSavePanel,
+ NSVisualEffectState, NSVisualEffectView, NSWindow,
},
base::{BOOL, NO, YES, id, nil, selector},
foundation::{
- NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSProcessInfo, NSRange, NSString,
- NSUInteger, NSURL,
+ NSArray, NSAutoreleasePool, NSBundle, NSInteger, NSProcessInfo, NSString, NSUInteger, NSURL,
},
};
use core_foundation::{
@@ -49,7 +44,6 @@ use ptr::null_mut;
use semver::Version;
use std::{
cell::Cell,
- convert::TryInto,
ffi::{CStr, OsStr, c_void},
os::{raw::c_char, unix::ffi::OsStrExt},
path::{Path, PathBuf},
@@ -58,7 +52,6 @@ use std::{
slice, str,
sync::{Arc, OnceLock},
};
-use strum::IntoEnumIterator;
use util::{
ResultExt,
command::{new_smol_command, new_std_command},
@@ -164,9 +157,8 @@ pub(crate) struct MacPlatformState {
text_system: Arc<dyn PlatformTextSystem>,
renderer_context: renderer::Context,
headless: bool,
- pasteboard: id,
- text_hash_pasteboard_type: id,
- metadata_pasteboard_type: id,
+ general_pasteboard: Pasteboard,
+ find_pasteboard: Pasteboard,
reopen: Option<Box<dyn FnMut()>>,
on_keyboard_layout_change: Option<Box<dyn FnMut()>>,
quit: Option<Box<dyn FnMut()>>,
@@ -206,9 +198,8 @@ impl MacPlatform {
background_executor: BackgroundExecutor::new(dispatcher.clone()),
foreground_executor: ForegroundExecutor::new(dispatcher),
renderer_context: renderer::Context::default(),
- pasteboard: unsafe { NSPasteboard::generalPasteboard(nil) },
- text_hash_pasteboard_type: unsafe { ns_string("zed-text-hash") },
- metadata_pasteboard_type: unsafe { ns_string("zed-metadata") },
+ general_pasteboard: Pasteboard::general(),
+ find_pasteboard: Pasteboard::find(),
reopen: None,
quit: None,
menu_command: None,
@@ -224,20 +215,6 @@ impl MacPlatform {
}))
}
- unsafe fn read_from_pasteboard(&self, pasteboard: *mut Object, kind: id) -> Option<&[u8]> {
- unsafe {
- let data = pasteboard.dataForType(kind);
- if data == nil {
- None
- } else {
- Some(slice::from_raw_parts(
- data.bytes() as *mut u8,
- data.length() as usize,
- ))
- }
- }
- }
-
unsafe fn create_menu_bar(
&self,
menus: &Vec<Menu>,
@@ -1034,119 +1011,24 @@ impl Platform for MacPlatform {
}
}
- fn write_to_clipboard(&self, item: ClipboardItem) {
- use crate::ClipboardEntry;
-
- unsafe {
- // We only want to use NSAttributedString if there are multiple entries to write.
- if item.entries.len() <= 1 {
- match item.entries.first() {
- Some(entry) => match entry {
- ClipboardEntry::String(string) => {
- self.write_plaintext_to_clipboard(string);
- }
- ClipboardEntry::Image(image) => {
- self.write_image_to_clipboard(image);
- }
- ClipboardEntry::ExternalPaths(_) => {}
- },
- None => {
- // Writing an empty list of entries just clears the clipboard.
- let state = self.0.lock();
- state.pasteboard.clearContents();
- }
- }
- } else {
- let mut any_images = false;
- let attributed_string = {
- let mut buf = NSMutableAttributedString::alloc(nil)
- // TODO can we skip this? Or at least part of it?
- .init_attributed_string(ns_string(""))
- .autorelease();
-
- for entry in item.entries {
- if let ClipboardEntry::String(ClipboardString { text, metadata: _ }) = entry
- {
- let to_append = NSAttributedString::alloc(nil)
- .init_attributed_string(ns_string(&text))
- .autorelease();
-
- buf.appendAttributedString_(to_append);
- }
- }
-
- buf
- };
-
- let state = self.0.lock();
- state.pasteboard.clearContents();
-
- // Only set rich text clipboard types if we actually have 1+ images to include.
- if any_images {
- let rtfd_data = attributed_string.RTFDFromRange_documentAttributes_(
- NSRange::new(0, msg_send![attributed_string, length]),
- nil,
- );
- if rtfd_data != nil {
- state
- .pasteboard
- .setData_forType(rtfd_data, NSPasteboardTypeRTFD);
- }
-
- let rtf_data = attributed_string.RTFFromRange_documentAttributes_(
- NSRange::new(0, attributed_string.length()),
- nil,
- );
- if rtf_data != nil {
- state
- .pasteboard
- .setData_forType(rtf_data, NSPasteboardTypeRTF);
- }
- }
-
- let plain_text = attributed_string.string();
- state
- .pasteboard
- .setString_forType(plain_text, NSPasteboardTypeString);
- }
- }
- }
-
fn read_from_clipboard(&self) -> Option<ClipboardItem> {
let state = self.0.lock();
- let pasteboard = state.pasteboard;
-
- // First, see if it's a string.
- unsafe {
- let types: id = pasteboard.types();
- let string_type: id = ns_string("public.utf8-plain-text");
-
- if msg_send![types, containsObject: string_type] {
- let data = pasteboard.dataForType(string_type);
- if data == nil {
- return None;
- } else if data.bytes().is_null() {
- // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc
- // "If the length of the NSData object is 0, this property returns nil."
- return Some(self.read_string_from_clipboard(&state, &[]));
- } else {
- let bytes =
- slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize);
+ state.general_pasteboard.read()
+ }
- return Some(self.read_string_from_clipboard(&state, bytes));
- }
- }
+ fn write_to_clipboard(&self, item: ClipboardItem) {
+ let state = self.0.lock();
+ state.general_pasteboard.write(item);
+ }
- // If it wasn't a string, try the various supported image types.
- for format in ImageFormat::iter() {
- if let Some(item) = try_clipboard_image(pasteboard, format) {
- return Some(item);
- }
- }
- }
+ fn read_from_find_pasteboard(&self) -> Option<ClipboardItem> {
+ let state = self.0.lock();
+ state.find_pasteboard.read()
+ }
- // If it wasn't a string or a supported image type, give up.
- None
+ fn write_to_find_pasteboard(&self, item: ClipboardItem) {
+ let state = self.0.lock();
+ state.find_pasteboard.write(item);
}
fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task<Result<()>> {
@@ -1255,116 +1137,6 @@ impl Platform for MacPlatform {
}
}
-impl MacPlatform {
- unsafe fn read_string_from_clipboard(
- &self,
- state: &MacPlatformState,
- text_bytes: &[u8],
- ) -> ClipboardItem {
- unsafe {
- let text = String::from_utf8_lossy(text_bytes).to_string();
- let metadata = self
- .read_from_pasteboard(state.pasteboard, state.text_hash_pasteboard_type)
- .and_then(|hash_bytes| {
- let hash_bytes = hash_bytes.try_into().ok()?;
- let hash = u64::from_be_bytes(hash_bytes);
- let metadata = self
- .read_from_pasteboard(state.pasteboard, state.metadata_pasteboard_type)?;
-
- if hash == ClipboardString::text_hash(&text) {
- String::from_utf8(metadata.to_vec()).ok()
- } else {
- None
- }
- });
-
- ClipboardItem {
- entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })],
- }
- }
- }
-
- unsafe fn write_plaintext_to_clipboard(&self, string: &ClipboardString) {
- unsafe {
- let state = self.0.lock();
- state.pasteboard.clearContents();
-
- let text_bytes = NSData::dataWithBytes_length_(
- nil,
- string.text.as_ptr() as *const c_void,
- string.text.len() as u64,
- );
- state
- .pasteboard
- .setData_forType(text_bytes, NSPasteboardTypeString);
-
- if let Some(metadata) = string.metadata.as_ref() {
- let hash_bytes = ClipboardString::text_hash(&string.text).to_be_bytes();
- let hash_bytes = NSData::dataWithBytes_length_(
- nil,
- hash_bytes.as_ptr() as *const c_void,
- hash_bytes.len() as u64,
- );
- state
- .pasteboard
- .setData_forType(hash_bytes, state.text_hash_pasteboard_type);
-
- let metadata_bytes = NSData::dataWithBytes_length_(
- nil,
- metadata.as_ptr() as *const c_void,
- metadata.len() as u64,
- );
- state
- .pasteboard
- .setData_forType(metadata_bytes, state.metadata_pasteboard_type);
- }
- }
- }
-
- unsafe fn write_image_to_clipboard(&self, image: &Image) {
- unsafe {
- let state = self.0.lock();
- state.pasteboard.clearContents();
-
- let bytes = NSData::dataWithBytes_length_(
- nil,
- image.bytes.as_ptr() as *const c_void,
- image.bytes.len() as u64,
- );
-
- state
- .pasteboard
- .setData_forType(bytes, Into::<UTType>::into(image.format).inner_mut());
- }
- }
-}
-
-fn try_clipboard_image(pasteboard: id, format: ImageFormat) -> Option<ClipboardItem> {
- let mut ut_type: UTType = format.into();
-
- unsafe {
- let types: id = pasteboard.types();
- if msg_send![types, containsObject: ut_type.inner()] {
- let data = pasteboard.dataForType(ut_type.inner_mut());
- if data == nil {
- None
- } else {
- let bytes = Vec::from(slice::from_raw_parts(
- data.bytes() as *mut u8,
- data.length() as usize,
- ));
- let id = hash(&bytes);
-
- Some(ClipboardItem {
- entries: vec![ClipboardEntry::Image(Image { format, bytes, id })],
- })
- }
- } else {
- None
- }
- }
-}
-
unsafe fn path_from_objc(path: id) -> PathBuf {
let len = msg_send![path, lengthOfBytesUsingEncoding: NSUTF8StringEncoding];
let bytes = unsafe { path.UTF8String() as *const u8 };
@@ -1605,120 +1377,3 @@ mod security {
pub const errSecUserCanceled: OSStatus = -128;
pub const errSecItemNotFound: OSStatus = -25300;
}
-
-impl From<ImageFormat> for UTType {
- fn from(value: ImageFormat) -> Self {
- match value {
- ImageFormat::Png => Self::png(),
- ImageFormat::Jpeg => Self::jpeg(),
- ImageFormat::Tiff => Self::tiff(),
- ImageFormat::Webp => Self::webp(),
- ImageFormat::Gif => Self::gif(),
- ImageFormat::Bmp => Self::bmp(),
- ImageFormat::Svg => Self::svg(),
- ImageFormat::Ico => Self::ico(),
- }
- }
-}
-
-// See https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/
-struct UTType(id);
-
-impl UTType {
- pub fn png() -> Self {
- // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/png
- Self(unsafe { NSPasteboardTypePNG }) // This is a rare case where there's a built-in NSPasteboardType
- }
-
- pub fn jpeg() -> Self {
- // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/jpeg
- Self(unsafe { ns_string("public.jpeg") })
- }
-
- pub fn gif() -> Self {
- // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/gif
- Self(unsafe { ns_string("com.compuserve.gif") })
- }
-
- pub fn webp() -> Self {
- // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/webp
- Self(unsafe { ns_string("org.webmproject.webp") })
- }
-
- pub fn bmp() -> Self {
- // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/bmp
- Self(unsafe { ns_string("com.microsoft.bmp") })
- }
-
- pub fn svg() -> Self {
- // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/svg
- Self(unsafe { ns_string("public.svg-image") })
- }
-
- pub fn ico() -> Self {
- // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/ico
- Self(unsafe { ns_string("com.microsoft.ico") })
- }
-
- pub fn tiff() -> Self {
- // https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct/tiff
- Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType
- }
-
- fn inner(&self) -> *const Object {
- self.0
- }
-
- fn inner_mut(&self) -> *mut Object {
- self.0 as *mut _
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crate::ClipboardItem;
-
- use super::*;
-
- #[test]
- fn test_clipboard() {
- let platform = build_platform();
- assert_eq!(platform.read_from_clipboard(), None);
-
- let item = ClipboardItem::new_string("1".to_string());
- platform.write_to_clipboard(item.clone());
- assert_eq!(platform.read_from_clipboard(), Some(item));
-
- let item = ClipboardItem {
- entries: vec![ClipboardEntry::String(
- ClipboardString::new("2".to_string()).with_json_metadata(vec![3, 4]),
- )],
- };
- platform.write_to_clipboard(item.clone());
- assert_eq!(platform.read_from_clipboard(), Some(item));
-
- let text_from_other_app = "text from other app";
- unsafe {
- let bytes = NSData::dataWithBytes_length_(
- nil,
- text_from_other_app.as_ptr() as *const c_void,
- text_from_other_app.len() as u64,
- );
- platform
- .0
- .lock()
- .pasteboard
- .setData_forType(bytes, NSPasteboardTypeString);
- }
- assert_eq!(
- platform.read_from_clipboard(),
- Some(ClipboardItem::new_string(text_from_other_app.to_string()))
- );
- }
-
- fn build_platform() -> MacPlatform {
- let platform = MacPlatform::new(false);
- platform.0.lock().pasteboard = unsafe { NSPasteboard::pasteboardWithUniqueName(nil) };
- platform
- }
-}
@@ -32,6 +32,8 @@ pub(crate) struct TestPlatform {
current_clipboard_item: Mutex<Option<ClipboardItem>>,
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
current_primary_item: Mutex<Option<ClipboardItem>>,
+ #[cfg(target_os = "macos")]
+ current_find_pasteboard_item: Mutex<Option<ClipboardItem>>,
pub(crate) prompts: RefCell<TestPrompts>,
screen_capture_sources: RefCell<Vec<TestScreenCaptureSource>>,
pub opened_url: RefCell<Option<String>>,
@@ -117,6 +119,8 @@ impl TestPlatform {
current_clipboard_item: Mutex::new(None),
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
current_primary_item: Mutex::new(None),
+ #[cfg(target_os = "macos")]
+ current_find_pasteboard_item: Mutex::new(None),
weak: weak.clone(),
opened_url: Default::default(),
#[cfg(target_os = "windows")]
@@ -398,9 +402,8 @@ impl Platform for TestPlatform {
false
}
- #[cfg(any(target_os = "linux", target_os = "freebsd"))]
- fn write_to_primary(&self, item: ClipboardItem) {
- *self.current_primary_item.lock() = Some(item);
+ fn read_from_clipboard(&self) -> Option<ClipboardItem> {
+ self.current_clipboard_item.lock().clone()
}
fn write_to_clipboard(&self, item: ClipboardItem) {
@@ -412,8 +415,19 @@ impl Platform for TestPlatform {
self.current_primary_item.lock().clone()
}
- fn read_from_clipboard(&self) -> Option<ClipboardItem> {
- self.current_clipboard_item.lock().clone()
+ #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+ fn write_to_primary(&self, item: ClipboardItem) {
+ *self.current_primary_item.lock() = Some(item);
+ }
+
+ #[cfg(target_os = "macos")]
+ fn read_from_find_pasteboard(&self) -> Option<ClipboardItem> {
+ self.current_find_pasteboard_item.lock().clone()
+ }
+
+ #[cfg(target_os = "macos")]
+ fn write_to_find_pasteboard(&self, item: ClipboardItem) {
+ *self.current_find_pasteboard_item.lock() = Some(item);
}
fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task<Result<()>> {
@@ -1,4 +1,5 @@
use std::{
+ collections::VecDeque,
fmt,
iter::FusedIterator,
sync::{Arc, atomic::AtomicUsize},
@@ -9,9 +10,9 @@ use rand::{Rng, SeedableRng, rngs::SmallRng};
use crate::Priority;
struct PriorityQueues<T> {
- high_priority: Vec<T>,
- medium_priority: Vec<T>,
- low_priority: Vec<T>,
+ high_priority: VecDeque<T>,
+ medium_priority: VecDeque<T>,
+ low_priority: VecDeque<T>,
}
impl<T> PriorityQueues<T> {
@@ -42,9 +43,9 @@ impl<T> PriorityQueueState<T> {
let mut queues = self.queues.lock();
match priority {
Priority::Realtime(_) => unreachable!(),
- Priority::High => queues.high_priority.push(item),
- Priority::Medium => queues.medium_priority.push(item),
- Priority::Low => queues.low_priority.push(item),
+ Priority::High => queues.high_priority.push_back(item),
+ Priority::Medium => queues.medium_priority.push_back(item),
+ Priority::Low => queues.low_priority.push_back(item),
};
self.condvar.notify_one();
Ok(())
@@ -141,9 +142,9 @@ impl<T> PriorityQueueReceiver<T> {
pub(crate) fn new() -> (PriorityQueueSender<T>, Self) {
let state = PriorityQueueState {
queues: parking_lot::Mutex::new(PriorityQueues {
- high_priority: Vec::new(),
- medium_priority: Vec::new(),
- low_priority: Vec::new(),
+ high_priority: VecDeque::new(),
+ medium_priority: VecDeque::new(),
+ low_priority: VecDeque::new(),
}),
condvar: parking_lot::Condvar::new(),
receiver_count: AtomicUsize::new(1),
@@ -226,7 +227,7 @@ impl<T> PriorityQueueReceiver<T> {
if !queues.high_priority.is_empty() {
let flip = self.rand.random_ratio(P::High.probability(), mass);
if flip {
- return Ok(queues.high_priority.pop());
+ return Ok(queues.high_priority.pop_front());
}
mass -= P::High.probability();
}
@@ -234,7 +235,7 @@ impl<T> PriorityQueueReceiver<T> {
if !queues.medium_priority.is_empty() {
let flip = self.rand.random_ratio(P::Medium.probability(), mass);
if flip {
- return Ok(queues.medium_priority.pop());
+ return Ok(queues.medium_priority.pop_front());
}
mass -= P::Medium.probability();
}
@@ -242,7 +243,7 @@ impl<T> PriorityQueueReceiver<T> {
if !queues.low_priority.is_empty() {
let flip = self.rand.random_ratio(P::Low.probability(), mass);
if flip {
- return Ok(queues.low_priority.pop());
+ return Ok(queues.low_priority.pop_front());
}
}
@@ -334,9 +334,13 @@ pub enum WhiteSpace {
/// How to truncate text that overflows the width of the element
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum TextOverflow {
- /// Truncate the text when it doesn't fit, and represent this truncation by displaying the
- /// provided string.
+ /// Truncate the text at the end when it doesn't fit, and represent this truncation by
+ /// displaying the provided string (e.g., "very long te…").
Truncate(SharedString),
+ /// Truncate the text at the start when it doesn't fit, and represent this truncation by
+ /// displaying the provided string at the beginning (e.g., "…ong text here").
+ /// Typically more adequate for file paths where the end is more important than the beginning.
+ TruncateStart(SharedString),
}
/// How to align text within the element
@@ -75,13 +75,21 @@ pub trait Styled: Sized {
self
}
- /// Sets the truncate overflowing text with an ellipsis (…) if needed.
+ /// Sets the truncate overflowing text with an ellipsis (…) at the end if needed.
/// [Docs](https://tailwindcss.com/docs/text-overflow#ellipsis)
fn text_ellipsis(mut self) -> Self {
self.text_style().text_overflow = Some(TextOverflow::Truncate(ELLIPSIS));
self
}
+ /// Sets the truncate overflowing text with an ellipsis (…) at the start if needed.
+ /// Typically more adequate for file paths where the end is more important than the beginning.
+ /// Note: This doesn't exist in Tailwind CSS.
+ fn text_ellipsis_start(mut self) -> Self {
+ self.text_style().text_overflow = Some(TextOverflow::TruncateStart(ELLIPSIS));
+ self
+ }
+
/// Sets the text overflow behavior of the element.
fn text_overflow(mut self, overflow: TextOverflow) -> Self {
self.text_style().text_overflow = Some(overflow);
@@ -2,6 +2,15 @@ use crate::{FontId, FontRun, Pixels, PlatformTextSystem, SharedString, TextRun,
use collections::HashMap;
use std::{borrow::Cow, iter, sync::Arc};
+/// Determines whether to truncate text from the start or end.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum TruncateFrom {
+ /// Truncate text from the start.
+ Start,
+ /// Truncate text from the end.
+ End,
+}
+
/// The GPUI line wrapper, used to wrap lines of text to a given width.
pub struct LineWrapper {
platform_text_system: Arc<dyn PlatformTextSystem>,
@@ -129,29 +138,50 @@ impl LineWrapper {
}
/// Determines if a line should be truncated based on its width.
+ ///
+ /// Returns the truncation index in `line`.
pub fn should_truncate_line(
&mut self,
line: &str,
truncate_width: Pixels,
- truncation_suffix: &str,
+ truncation_affix: &str,
+ truncate_from: TruncateFrom,
) -> Option<usize> {
let mut width = px(0.);
- let suffix_width = truncation_suffix
+ let suffix_width = truncation_affix
.chars()
.map(|c| self.width_for_char(c))
.fold(px(0.0), |a, x| a + x);
let mut truncate_ix = 0;
- for (ix, c) in line.char_indices() {
- if width + suffix_width < truncate_width {
- truncate_ix = ix;
+ match truncate_from {
+ TruncateFrom::Start => {
+ for (ix, c) in line.char_indices().rev() {
+ if width + suffix_width < truncate_width {
+ truncate_ix = ix;
+ }
+
+ let char_width = self.width_for_char(c);
+ width += char_width;
+
+ if width.floor() > truncate_width {
+ return Some(truncate_ix);
+ }
+ }
}
+ TruncateFrom::End => {
+ for (ix, c) in line.char_indices() {
+ if width + suffix_width < truncate_width {
+ truncate_ix = ix;
+ }
- let char_width = self.width_for_char(c);
- width += char_width;
+ let char_width = self.width_for_char(c);
+ width += char_width;
- if width.floor() > truncate_width {
- return Some(truncate_ix);
+ if width.floor() > truncate_width {
+ return Some(truncate_ix);
+ }
+ }
}
}
@@ -163,16 +193,23 @@ impl LineWrapper {
&mut self,
line: SharedString,
truncate_width: Pixels,
- truncation_suffix: &str,
+ truncation_affix: &str,
runs: &'a [TextRun],
+ truncate_from: TruncateFrom,
) -> (SharedString, Cow<'a, [TextRun]>) {
if let Some(truncate_ix) =
- self.should_truncate_line(&line, truncate_width, truncation_suffix)
+ self.should_truncate_line(&line, truncate_width, truncation_affix, truncate_from)
{
- let result =
- SharedString::from(format!("{}{}", &line[..truncate_ix], truncation_suffix));
+ let result = match truncate_from {
+ TruncateFrom::Start => {
+ SharedString::from(format!("{truncation_affix}{}", &line[truncate_ix + 1..]))
+ }
+ TruncateFrom::End => {
+ SharedString::from(format!("{}{truncation_affix}", &line[..truncate_ix]))
+ }
+ };
let mut runs = runs.to_vec();
- update_runs_after_truncation(&result, truncation_suffix, &mut runs);
+ update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from);
(result, Cow::Owned(runs))
} else {
(line, Cow::Borrowed(runs))
@@ -245,15 +282,35 @@ impl LineWrapper {
}
}
-fn update_runs_after_truncation(result: &str, ellipsis: &str, runs: &mut Vec<TextRun>) {
+fn update_runs_after_truncation(
+ result: &str,
+ ellipsis: &str,
+ runs: &mut Vec<TextRun>,
+ truncate_from: TruncateFrom,
+) {
let mut truncate_at = result.len() - ellipsis.len();
- for (run_index, run) in runs.iter_mut().enumerate() {
- if run.len <= truncate_at {
- truncate_at -= run.len;
- } else {
- run.len = truncate_at + ellipsis.len();
- runs.truncate(run_index + 1);
- break;
+ match truncate_from {
+ TruncateFrom::Start => {
+ for (run_index, run) in runs.iter_mut().enumerate().rev() {
+ if run.len <= truncate_at {
+ truncate_at -= run.len;
+ } else {
+ run.len = truncate_at + ellipsis.len();
+ runs.splice(..run_index, std::iter::empty());
+ break;
+ }
+ }
+ }
+ TruncateFrom::End => {
+ for (run_index, run) in runs.iter_mut().enumerate() {
+ if run.len <= truncate_at {
+ truncate_at -= run.len;
+ } else {
+ run.len = truncate_at + ellipsis.len();
+ runs.truncate(run_index + 1);
+ break;
+ }
+ }
}
}
}
@@ -503,7 +560,7 @@ mod tests {
}
#[test]
- fn test_truncate_line() {
+ fn test_truncate_line_end() {
let mut wrapper = build_wrapper();
fn perform_test(
@@ -514,8 +571,13 @@ mod tests {
) {
let dummy_run_lens = vec![text.len()];
let dummy_runs = generate_test_runs(&dummy_run_lens);
- let (result, dummy_runs) =
- wrapper.truncate_line(text.into(), px(220.), ellipsis, &dummy_runs);
+ let (result, dummy_runs) = wrapper.truncate_line(
+ text.into(),
+ px(220.),
+ ellipsis,
+ &dummy_runs,
+ TruncateFrom::End,
+ );
assert_eq!(result, expected);
assert_eq!(dummy_runs.first().unwrap().len, result.len());
}
@@ -541,7 +603,50 @@ mod tests {
}
#[test]
- fn test_truncate_multiple_runs() {
+ fn test_truncate_line_start() {
+ let mut wrapper = build_wrapper();
+
+ fn perform_test(
+ wrapper: &mut LineWrapper,
+ text: &'static str,
+ expected: &'static str,
+ ellipsis: &str,
+ ) {
+ let dummy_run_lens = vec![text.len()];
+ let dummy_runs = generate_test_runs(&dummy_run_lens);
+ let (result, dummy_runs) = wrapper.truncate_line(
+ text.into(),
+ px(220.),
+ ellipsis,
+ &dummy_runs,
+ TruncateFrom::Start,
+ );
+ assert_eq!(result, expected);
+ assert_eq!(dummy_runs.first().unwrap().len, result.len());
+ }
+
+ perform_test(
+ &mut wrapper,
+ "aaaa bbbb cccc ddddd eeee fff gg",
+ "cccc ddddd eeee fff gg",
+ "",
+ );
+ perform_test(
+ &mut wrapper,
+ "aaaa bbbb cccc ddddd eeee fff gg",
+ "…ccc ddddd eeee fff gg",
+ "…",
+ );
+ perform_test(
+ &mut wrapper,
+ "aaaa bbbb cccc ddddd eeee fff gg",
+ "......dddd eeee fff gg",
+ "......",
+ );
+ }
+
+ #[test]
+ fn test_truncate_multiple_runs_end() {
let mut wrapper = build_wrapper();
fn perform_test(
@@ -554,7 +659,7 @@ mod tests {
) {
let dummy_runs = generate_test_runs(run_lens);
let (result, dummy_runs) =
- wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs);
+ wrapper.truncate_line(text.into(), line_width, "…", &dummy_runs, TruncateFrom::End);
assert_eq!(result, expected);
for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
assert_eq!(run.len, *result_len);
@@ -600,10 +705,75 @@ mod tests {
}
#[test]
- fn test_update_run_after_truncation() {
+ fn test_truncate_multiple_runs_start() {
+ let mut wrapper = build_wrapper();
+
+ #[track_caller]
+ fn perform_test(
+ wrapper: &mut LineWrapper,
+ text: &'static str,
+ expected: &str,
+ run_lens: &[usize],
+ result_run_len: &[usize],
+ line_width: Pixels,
+ ) {
+ let dummy_runs = generate_test_runs(run_lens);
+ let (result, dummy_runs) = wrapper.truncate_line(
+ text.into(),
+ line_width,
+ "…",
+ &dummy_runs,
+ TruncateFrom::Start,
+ );
+ assert_eq!(result, expected);
+ for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
+ assert_eq!(run.len, *result_len);
+ }
+ }
+ // Case 0: Normal
+ // Text: abcdefghijkl
+ // Runs: Run0 { len: 12, ... }
+ //
+ // Truncate res: …ijkl (truncate_at = 9)
+ // Run res: Run0 { string: …ijkl, len: 7, ... }
+ perform_test(&mut wrapper, "abcdefghijkl", "…ijkl", &[12], &[7], px(50.));
+ // Case 1: Drop some runs
+ // Text: abcdefghijkl
+ // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
+ //
+ // Truncate res: …ghijkl (truncate_at = 7)
+ // Runs res: Run0 { string: …gh, len: 5, ... }, Run1 { string: ijkl, len:
+ // 4, ... }
+ perform_test(
+ &mut wrapper,
+ "abcdefghijkl",
+ "…ghijkl",
+ &[4, 4, 4],
+ &[5, 4],
+ px(70.),
+ );
+ // Case 2: Truncate at start of some run
+ // Text: abcdefghijkl
+ // Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
+ //
+ // Truncate res: abcdefgh… (truncate_at = 3)
+ // Runs res: Run0 { string: …, len: 3, ... }, Run1 { string: efgh, len:
+ // 4, ... }, Run2 { string: ijkl, len: 4, ... }
+ perform_test(
+ &mut wrapper,
+ "abcdefghijkl",
+ "…efghijkl",
+ &[4, 4, 4],
+ &[3, 4, 4],
+ px(90.),
+ );
+ }
+
+ #[test]
+ fn test_update_run_after_truncation_end() {
fn perform_test(result: &str, run_lens: &[usize], result_run_lens: &[usize]) {
let mut dummy_runs = generate_test_runs(run_lens);
- update_runs_after_truncation(result, "…", &mut dummy_runs);
+ update_runs_after_truncation(result, "…", &mut dummy_runs, TruncateFrom::End);
for (run, result_len) in dummy_runs.iter().zip(result_run_lens) {
assert_eq!(run.len, *result_len);
}
@@ -876,7 +876,9 @@ pub struct Window {
active: Rc<Cell<bool>>,
hovered: Rc<Cell<bool>>,
pub(crate) needs_present: Rc<Cell<bool>>,
- pub(crate) last_input_timestamp: Rc<Cell<Instant>>,
+ /// Tracks recent input event timestamps to determine if input is arriving at a high rate.
+ /// Used to selectively enable VRR optimization only when input rate exceeds 60fps.
+ pub(crate) input_rate_tracker: Rc<RefCell<InputRateTracker>>,
last_input_modality: InputModality,
pub(crate) refreshing: bool,
pub(crate) activation_observers: SubscriberSet<(), AnyObserver>,
@@ -897,6 +899,51 @@ struct ModifierState {
saw_keystroke: bool,
}
+/// Tracks input event timestamps to determine if input is arriving at a high rate.
+/// Used for selective VRR (Variable Refresh Rate) optimization.
+#[derive(Clone, Debug)]
+pub(crate) struct InputRateTracker {
+ timestamps: Vec<Instant>,
+ window: Duration,
+ inputs_per_second: u32,
+ sustain_until: Instant,
+ sustain_duration: Duration,
+}
+
+impl Default for InputRateTracker {
+ fn default() -> Self {
+ Self {
+ timestamps: Vec::new(),
+ window: Duration::from_millis(100),
+ inputs_per_second: 60,
+ sustain_until: Instant::now(),
+ sustain_duration: Duration::from_secs(1),
+ }
+ }
+}
+
+impl InputRateTracker {
+ pub fn record_input(&mut self) {
+ let now = Instant::now();
+ self.timestamps.push(now);
+ self.prune_old_timestamps(now);
+
+ let min_events = self.inputs_per_second as u128 * self.window.as_millis() / 1000;
+ if self.timestamps.len() as u128 >= min_events {
+ self.sustain_until = now + self.sustain_duration;
+ }
+ }
+
+ pub fn is_high_rate(&self) -> bool {
+ Instant::now() < self.sustain_until
+ }
+
+ fn prune_old_timestamps(&mut self, now: Instant) {
+ self.timestamps
+ .retain(|&t| now.duration_since(t) <= self.window);
+ }
+}
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum DrawPhase {
None,
@@ -1047,7 +1094,7 @@ impl Window {
let hovered = Rc::new(Cell::new(platform_window.is_hovered()));
let needs_present = Rc::new(Cell::new(false));
let next_frame_callbacks: Rc<RefCell<Vec<FrameCallback>>> = Default::default();
- let last_input_timestamp = Rc::new(Cell::new(Instant::now()));
+ let input_rate_tracker = Rc::new(RefCell::new(InputRateTracker::default()));
platform_window
.request_decorations(window_decorations.unwrap_or(WindowDecorations::Server));
@@ -1075,7 +1122,7 @@ impl Window {
let active = active.clone();
let needs_present = needs_present.clone();
let next_frame_callbacks = next_frame_callbacks.clone();
- let last_input_timestamp = last_input_timestamp.clone();
+ let input_rate_tracker = input_rate_tracker.clone();
move |request_frame_options| {
let next_frame_callbacks = next_frame_callbacks.take();
if !next_frame_callbacks.is_empty() {
@@ -1088,12 +1135,12 @@ impl Window {
.log_err();
}
- // Keep presenting the current scene for 1 extra second since the
- // last input to prevent the display from underclocking the refresh rate.
+ // Keep presenting if input was recently arriving at a high rate (>= 60fps).
+ // Once high-rate input is detected, we sustain presentation for 1 second
+ // to prevent display underclocking during active input.
let needs_present = request_frame_options.require_presentation
|| needs_present.get()
- || (active.get()
- && last_input_timestamp.get().elapsed() < Duration::from_secs(1));
+ || (active.get() && input_rate_tracker.borrow_mut().is_high_rate());
if invalidator.is_dirty() || request_frame_options.force_render {
measure("frame duration", || {
@@ -1101,7 +1148,6 @@ impl Window {
.update(&mut cx, |_, window, cx| {
let arena_clear_needed = window.draw(cx);
window.present();
- // drop the arena elements after present to reduce latency
arena_clear_needed.clear();
})
.log_err();
@@ -1299,7 +1345,7 @@ impl Window {
active,
hovered,
needs_present,
- last_input_timestamp,
+ input_rate_tracker,
last_input_modality: InputModality::Mouse,
refreshing: false,
activation_observers: SubscriberSet::new(),
@@ -3691,8 +3737,6 @@ impl Window {
/// Dispatch a mouse or keyboard event on the window.
#[profiling::function]
pub fn dispatch_event(&mut self, event: PlatformInput, cx: &mut App) -> DispatchEventResult {
- self.last_input_timestamp.set(Instant::now());
-
// Track whether this input was keyboard-based for focus-visible styling
self.last_input_modality = match &event {
PlatformInput::KeyDown(_) | PlatformInput::ModifiersChanged(_) => {
@@ -3793,6 +3837,10 @@ impl Window {
self.dispatch_key_event(any_key_event, cx);
}
+ if self.invalidator.is_dirty() {
+ self.input_rate_tracker.borrow_mut().record_input();
+ }
+
DispatchEventResult {
propagate: cx.propagate_event,
default_prevented: self.default_prevented,
@@ -827,6 +827,15 @@ pub struct LanguageConfig {
/// Delimiters and configuration for recognizing and formatting documentation comments.
#[serde(default, alias = "documentation")]
pub documentation_comment: Option<BlockCommentConfig>,
+ /// List markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `).
+ #[serde(default)]
+ pub unordered_list: Vec<Arc<str>>,
+ /// Configuration for ordered lists with auto-incrementing numbers on newline (e.g., `1. ` becomes `2. `).
+ #[serde(default)]
+ pub ordered_list: Vec<OrderedListConfig>,
+ /// Configuration for task lists where multiple markers map to a single continuation prefix (e.g., `- [x] ` continues as `- [ ] `).
+ #[serde(default)]
+ pub task_list: Option<TaskListConfig>,
/// A list of additional regex patterns that should be treated as prefixes
/// for creating boundaries during rewrapping, ensuring content from one
/// prefixed section doesn't merge with another (e.g., markdown list items).
@@ -898,6 +907,24 @@ pub struct DecreaseIndentConfig {
pub valid_after: Vec<String>,
}
+/// Configuration for continuing ordered lists with auto-incrementing numbers.
+#[derive(Clone, Debug, Deserialize, JsonSchema)]
+pub struct OrderedListConfig {
+ /// A regex pattern with a capture group for the number portion (e.g., `(\\d+)\\. `).
+ pub pattern: String,
+ /// A format string where `{1}` is replaced with the incremented number (e.g., `{1}. `).
+ pub format: String,
+}
+
+/// Configuration for continuing task lists on newline.
+#[derive(Clone, Debug, Deserialize, JsonSchema)]
+pub struct TaskListConfig {
+ /// The list markers to match (e.g., `- [ ] `, `- [x] `).
+ pub prefixes: Vec<Arc<str>>,
+ /// The marker to insert when continuing the list on a new line (e.g., `- [ ] `).
+ pub continuation: Arc<str>,
+}
+
#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
pub struct LanguageMatcher {
/// Given a list of `LanguageConfig`'s, the language of a file can be determined based on the path extension matching any of the `path_suffixes`.
@@ -1068,6 +1095,9 @@ impl Default for LanguageConfig {
line_comments: Default::default(),
block_comment: Default::default(),
documentation_comment: Default::default(),
+ unordered_list: Default::default(),
+ ordered_list: Default::default(),
+ task_list: Default::default(),
rewrap_prefixes: Default::default(),
scope_opt_in_language_servers: Default::default(),
overrides: Default::default(),
@@ -2153,6 +2183,21 @@ impl LanguageScope {
self.language.config.documentation_comment.as_ref()
}
+ /// Returns list markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `).
+ pub fn unordered_list(&self) -> &[Arc<str>] {
+ &self.language.config.unordered_list
+ }
+
+ /// Returns configuration for ordered lists with auto-incrementing numbers (e.g., `1. ` becomes `2. `).
+ pub fn ordered_list(&self) -> &[OrderedListConfig] {
+ &self.language.config.ordered_list
+ }
+
+ /// Returns configuration for task list continuation, if any (e.g., `- [x] ` continues as `- [ ] `).
+ pub fn task_list(&self) -> Option<&TaskListConfig> {
+ self.language.config.task_list.as_ref()
+ }
+
/// Returns additional regex patterns that act as prefix markers for creating
/// boundaries during rewrapping.
///
@@ -122,6 +122,10 @@ pub struct LanguageSettings {
pub whitespace_map: WhitespaceMap,
/// Whether to start a new line with a comment when a previous line is a comment as well.
pub extend_comment_on_newline: bool,
+ /// Whether to continue markdown lists when pressing enter.
+ pub extend_list_on_newline: bool,
+ /// Whether to indent list items when pressing tab after a list marker.
+ pub indent_list_on_tab: bool,
/// Inlay hint related settings.
pub inlay_hints: InlayHintSettings,
/// Whether to automatically close brackets.
@@ -567,6 +571,8 @@ impl settings::Settings for AllLanguageSettings {
tab: SharedString::new(whitespace_map.tab.unwrap().to_string()),
},
extend_comment_on_newline: settings.extend_comment_on_newline.unwrap(),
+ extend_list_on_newline: settings.extend_list_on_newline.unwrap(),
+ indent_list_on_tab: settings.indent_list_on_tab.unwrap(),
inlay_hints: InlayHintSettings {
enabled: inlay_hints.enabled.unwrap(),
show_value_hints: inlay_hints.show_value_hints.unwrap(),
@@ -4,7 +4,10 @@
//! which is a set of tools used to interact with the projects written in said language.
//! For example, a Python project can have an associated virtual environment; a Rust project can have a toolchain override.
-use std::{path::PathBuf, sync::Arc};
+use std::{
+ path::{Path, PathBuf},
+ sync::Arc,
+};
use async_trait::async_trait;
use collections::HashMap;
@@ -36,7 +39,7 @@ pub struct Toolchain {
/// - Only in the subproject they're currently in.
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
pub enum ToolchainScope {
- Subproject(WorktreeId, Arc<RelPath>),
+ Subproject(Arc<Path>, Arc<RelPath>),
Project,
/// Available in all projects on this box. It wouldn't make sense to show suggestions across machines.
Global,
@@ -20,6 +20,9 @@ rewrap_prefixes = [
">\\s*",
"[-*+]\\s+\\[[\\sx]\\]\\s+"
]
+unordered_list = ["- ", "* ", "+ "]
+ordered_list = [{ pattern = "(\\d+)\\. ", format = "{1}. " }]
+task_list = { prefixes = ["- [ ] ", "- [x] "], continuation = "- [ ] " }
auto_indent_on_paste = false
auto_indent_using_last_non_empty_line = false
@@ -1330,7 +1330,12 @@ impl Project {
cx.subscribe(&buffer_store, Self::on_buffer_store_event)
.detach();
let toolchain_store = cx.new(|cx| {
- ToolchainStore::remote(REMOTE_SERVER_PROJECT_ID, remote.read(cx).proto_client(), cx)
+ ToolchainStore::remote(
+ REMOTE_SERVER_PROJECT_ID,
+ worktree_store.clone(),
+ remote.read(cx).proto_client(),
+ cx,
+ )
});
let task_store = cx.new(|cx| {
TaskStore::remote(
@@ -32,6 +32,7 @@ use crate::{
pub struct ToolchainStore {
mode: ToolchainStoreInner,
user_toolchains: BTreeMap<ToolchainScope, IndexSet<Toolchain>>,
+ worktree_store: Entity<WorktreeStore>,
_sub: Subscription,
}
@@ -66,7 +67,7 @@ impl ToolchainStore {
) -> Self {
let entity = cx.new(|_| LocalToolchainStore {
languages,
- worktree_store,
+ worktree_store: worktree_store.clone(),
project_environment,
active_toolchains: Default::default(),
manifest_tree,
@@ -77,12 +78,18 @@ impl ToolchainStore {
});
Self {
mode: ToolchainStoreInner::Local(entity),
+ worktree_store,
user_toolchains: Default::default(),
_sub,
}
}
- pub(super) fn remote(project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) -> Self {
+ pub(super) fn remote(
+ project_id: u64,
+ worktree_store: Entity<WorktreeStore>,
+ client: AnyProtoClient,
+ cx: &mut Context<Self>,
+ ) -> Self {
let entity = cx.new(|_| RemoteToolchainStore { client, project_id });
let _sub = cx.subscribe(&entity, |_, _, e: &ToolchainStoreEvent, cx| {
cx.emit(e.clone())
@@ -90,6 +97,7 @@ impl ToolchainStore {
Self {
mode: ToolchainStoreInner::Remote(entity),
user_toolchains: Default::default(),
+ worktree_store,
_sub,
}
}
@@ -165,12 +173,22 @@ impl ToolchainStore {
language_name: LanguageName,
cx: &mut Context<Self>,
) -> Task<Option<Toolchains>> {
+ let Some(worktree) = self
+ .worktree_store
+ .read(cx)
+ .worktree_for_id(path.worktree_id, cx)
+ else {
+ return Task::ready(None);
+ };
+ let target_root_path = worktree.read_with(cx, |this, _| this.abs_path());
+
let user_toolchains = self
.user_toolchains
.iter()
.filter(|(scope, _)| {
- if let ToolchainScope::Subproject(worktree_id, relative_path) = scope {
- path.worktree_id == *worktree_id && relative_path.starts_with(&path.path)
+ if let ToolchainScope::Subproject(subproject_root_path, relative_path) = scope {
+ target_root_path == *subproject_root_path
+ && relative_path.starts_with(&path.path)
} else {
true
}
@@ -0,0 +1 @@
+Gliwice makerspace
@@ -32,8 +32,7 @@ use tempfile::TempDir;
use util::{
paths::{PathStyle, RemotePathBuf},
rel_path::RelPath,
- shell::{Shell, ShellKind},
- shell_builder::ShellBuilder,
+ shell::ShellKind,
};
pub(crate) struct SshRemoteConnection {
@@ -1544,8 +1543,6 @@ fn build_command(
} else {
write!(exec, "{ssh_shell} -l")?;
};
- let (command, command_args) = ShellBuilder::new(&Shell::Program(ssh_shell.to_owned()), false)
- .build(Some(exec.clone()), &[]);
let mut args = Vec::new();
args.extend(ssh_args);
@@ -1556,8 +1553,7 @@ fn build_command(
}
args.push("-t".into());
- args.push(command);
- args.extend(command_args);
+ args.push(exec);
Ok(CommandTemplate {
program: "ssh".into(),
@@ -1597,9 +1593,6 @@ mod tests {
"-p",
"2222",
"-t",
- "/bin/fish",
- "-i",
- "-c",
"cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2"
]
);
@@ -1632,9 +1625,6 @@ mod tests {
"-L",
"1:foo:2",
"-t",
- "/bin/fish",
- "-i",
- "-c",
"cd && exec env INPUT_VA=val /bin/fish -l"
]
);
@@ -106,7 +106,10 @@ pub struct BufferSearchBar {
replacement_editor_focused: bool,
active_searchable_item: Option<Box<dyn SearchableItemHandle>>,
active_match_index: Option<usize>,
- active_searchable_item_subscription: Option<Subscription>,
+ #[cfg(target_os = "macos")]
+ active_searchable_item_subscriptions: Option<[Subscription; 2]>,
+ #[cfg(not(target_os = "macos"))]
+ active_searchable_item_subscriptions: Option<Subscription>,
active_search: Option<Arc<SearchQuery>>,
searchable_items_with_matches: HashMap<Box<dyn WeakSearchableItemHandle>, AnyVec<dyn Send>>,
pending_search: Option<Task<()>>,
@@ -472,7 +475,7 @@ impl ToolbarItemView for BufferSearchBar {
cx: &mut Context<Self>,
) -> ToolbarItemLocation {
cx.notify();
- self.active_searchable_item_subscription.take();
+ self.active_searchable_item_subscriptions.take();
self.active_searchable_item.take();
self.pending_search.take();
@@ -482,18 +485,58 @@ impl ToolbarItemView for BufferSearchBar {
{
let this = cx.entity().downgrade();
- self.active_searchable_item_subscription =
- Some(searchable_item_handle.subscribe_to_search_events(
- window,
- cx,
- Box::new(move |search_event, window, cx| {
- if let Some(this) = this.upgrade() {
- this.update(cx, |this, cx| {
- this.on_active_searchable_item_event(search_event, window, cx)
- });
+ let search_event_subscription = searchable_item_handle.subscribe_to_search_events(
+ window,
+ cx,
+ Box::new(move |search_event, window, cx| {
+ if let Some(this) = this.upgrade() {
+ this.update(cx, |this, cx| {
+ this.on_active_searchable_item_event(search_event, window, cx)
+ });
+ }
+ }),
+ );
+
+ #[cfg(target_os = "macos")]
+ {
+ let item_focus_handle = searchable_item_handle.item_focus_handle(cx);
+
+ self.active_searchable_item_subscriptions = Some([
+ search_event_subscription,
+ cx.on_focus(&item_focus_handle, window, |this, window, cx| {
+ if this.query_editor_focused || this.replacement_editor_focused {
+ // no need to read pasteboard since focus came from toolbar
+ return;
}
+
+ cx.defer_in(window, |this, window, cx| {
+ if let Some(item) = cx.read_from_find_pasteboard()
+ && let Some(text) = item.text()
+ {
+ if this.query(cx) != text {
+ let search_options = item
+ .metadata()
+ .and_then(|m| m.parse().ok())
+ .and_then(SearchOptions::from_bits)
+ .unwrap_or(this.search_options);
+
+ drop(this.search(
+ &text,
+ Some(search_options),
+ true,
+ window,
+ cx,
+ ));
+ }
+ }
+ });
}),
- ));
+ ]);
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ self.active_searchable_item_subscriptions = Some(search_event_subscription);
+ }
let is_project_search = searchable_item_handle.supported_options(cx).find_in_results;
self.active_searchable_item = Some(searchable_item_handle);
@@ -663,7 +706,7 @@ impl BufferSearchBar {
replacement_editor,
replacement_editor_focused: false,
active_searchable_item: None,
- active_searchable_item_subscription: None,
+ active_searchable_item_subscriptions: None,
active_match_index: None,
searchable_items_with_matches: Default::default(),
default_options: search_options,
@@ -904,11 +947,21 @@ impl BufferSearchBar {
});
self.set_search_options(options, cx);
self.clear_matches(window, cx);
+ #[cfg(target_os = "macos")]
+ self.update_find_pasteboard(cx);
cx.notify();
}
self.update_matches(!updated, add_to_history, window, cx)
}
+ #[cfg(target_os = "macos")]
+ pub fn update_find_pasteboard(&mut self, cx: &mut App) {
+ cx.write_to_find_pasteboard(gpui::ClipboardItem::new_string_with_metadata(
+ self.query(cx),
+ self.search_options.bits().to_string(),
+ ));
+ }
+
pub fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
if let Some(active_editor) = self.active_searchable_item.as_ref() {
let handle = active_editor.item_focus_handle(cx);
@@ -1098,11 +1151,12 @@ impl BufferSearchBar {
cx.spawn_in(window, async move |this, cx| {
if search.await.is_ok() {
this.update_in(cx, |this, window, cx| {
- this.activate_current_match(window, cx)
- })
- } else {
- Ok(())
+ this.activate_current_match(window, cx);
+ #[cfg(target_os = "macos")]
+ this.update_find_pasteboard(cx);
+ })?;
}
+ anyhow::Ok(())
})
.detach_and_log_err(cx);
}
@@ -1293,6 +1347,7 @@ impl BufferSearchBar {
.insert(active_searchable_item.downgrade(), matches);
this.update_match_index(window, cx);
+
if add_to_history {
this.search_history
.add(&mut this.search_history_cursor, query_text);
@@ -363,6 +363,14 @@ pub struct LanguageSettingsContent {
///
/// Default: true
pub extend_comment_on_newline: Option<bool>,
+ /// Whether to continue markdown lists when pressing enter.
+ ///
+ /// Default: true
+ pub extend_list_on_newline: Option<bool>,
+ /// Whether to indent list items when pressing tab after a list marker.
+ ///
+ /// Default: true
+ pub indent_list_on_tab: Option<bool>,
/// Inlay hint related settings.
pub inlay_hints: Option<InlayHintSettingsContent>,
/// Whether to automatically type closing characters for you. For example,
@@ -430,6 +430,8 @@ impl VsCodeSettings {
enable_language_server: None,
ensure_final_newline_on_save: self.read_bool("files.insertFinalNewline"),
extend_comment_on_newline: None,
+ extend_list_on_newline: None,
+ indent_list_on_tab: None,
format_on_save: self.read_bool("editor.guides.formatOnSave").map(|b| {
if b {
FormatOnSave::On
@@ -939,7 +939,6 @@ impl TerminalPanel {
cx: &mut Context<Self>,
) -> Task<Result<WeakEntity<Terminal>>> {
let reveal = spawn_task.reveal;
- let reveal_target = spawn_task.reveal_target;
let task_workspace = self.workspace.clone();
cx.spawn_in(window, async move |terminal_panel, cx| {
let project = terminal_panel.update(cx, |this, cx| {
@@ -955,6 +954,14 @@ impl TerminalPanel {
terminal_to_replace.set_terminal(new_terminal.clone(), window, cx);
})?;
+ let reveal_target = terminal_panel.update(cx, |panel, _| {
+ if panel.center.panes().iter().any(|p| **p == task_pane) {
+ RevealTarget::Dock
+ } else {
+ RevealTarget::Center
+ }
+ })?;
+
match reveal {
RevealStrategy::Always => match reveal_target {
RevealTarget::Center => {
@@ -0,0 +1,28 @@
+#![allow(clippy::disallowed_methods, reason = "build scripts are exempt")]
+
+fn main() {
+ println!("cargo::rustc-check-cfg=cfg(macos_sdk_26)");
+
+ #[cfg(target_os = "macos")]
+ {
+ use std::process::Command;
+
+ let output = Command::new("xcrun")
+ .args(["--sdk", "macosx", "--show-sdk-version"])
+ .output()
+ .unwrap();
+
+ let sdk_version = String::from_utf8(output.stdout).unwrap();
+ let major_version: Option<u32> = sdk_version
+ .trim()
+ .split('.')
+ .next()
+ .and_then(|v| v.parse().ok());
+
+ if let Some(major) = major_version
+ && major >= 26
+ {
+ println!("cargo:rustc-cfg=macos_sdk_26");
+ }
+ }
+}
@@ -1,6 +1,10 @@
-/// Use pixels here instead of a rem-based size because the macOS traffic
-/// lights are a static size, and don't scale with the rest of the UI.
-///
-/// Magic number: There is one extra pixel of padding on the left side due to
-/// the 1px border around the window on macOS apps.
+// Use pixels here instead of a rem-based size because the macOS traffic
+// lights are a static size, and don't scale with the rest of the UI.
+//
+// Magic number: There is one extra pixel of padding on the left side due to
+// the 1px border around the window on macOS apps.
+#[cfg(macos_sdk_26)]
+pub const TRAFFIC_LIGHT_PADDING: f32 = 78.;
+
+#[cfg(not(macos_sdk_26))]
pub const TRAFFIC_LIGHT_PADDING: f32 = 71.;
@@ -166,11 +166,11 @@ impl Render for TitleBar {
.when(title_bar_settings.show_project_items, |title_bar| {
title_bar
.children(self.render_restricted_mode(cx))
- .children(self.render_project_host(cx))
- .child(self.render_project_name(cx))
+ .children(self.render_project_host(window, cx))
+ .child(self.render_project_name(window, cx))
})
.when(title_bar_settings.show_branch_name, |title_bar| {
- title_bar.children(self.render_project_repo(cx))
+ title_bar.children(self.render_project_repo(window, cx))
})
})
})
@@ -350,7 +350,14 @@ impl TitleBar {
.next()
}
- fn render_remote_project_connection(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
+ fn render_remote_project_connection(
+ &self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Option<AnyElement> {
+ let workspace = self.workspace.clone();
+ let is_picker_open = self.is_picker_open(window, cx);
+
let options = self.project.read(cx).remote_connection_options(cx)?;
let host: SharedString = options.display_name().into();
@@ -395,7 +402,7 @@ impl TitleBar {
let meta = SharedString::from(meta);
Some(
- ButtonLike::new("ssh-server-icon")
+ ButtonLike::new("remote_project")
.child(
h_flex()
.gap_2()
@@ -410,26 +417,35 @@ impl TitleBar {
)
.child(Label::new(nickname).size(LabelSize::Small).truncate()),
)
- .tooltip(move |_window, cx| {
- Tooltip::with_meta(
- tooltip_title,
- Some(&OpenRemote {
- from_existing_connection: false,
- create_new_window: false,
- }),
- meta.clone(),
- cx,
- )
+ .when(!is_picker_open, |this| {
+ this.tooltip(move |_window, cx| {
+ Tooltip::with_meta(
+ tooltip_title,
+ Some(&OpenRemote {
+ from_existing_connection: false,
+ create_new_window: false,
+ }),
+ meta.clone(),
+ cx,
+ )
+ })
})
- .on_click(|_, window, cx| {
- window.dispatch_action(
- OpenRemote {
- from_existing_connection: false,
- create_new_window: false,
- }
- .boxed_clone(),
- cx,
- );
+ .on_click(move |event, window, cx| {
+ let position = event.position();
+ let _ = workspace.update(cx, |this, cx| {
+ this.set_next_modal_placement(workspace::ModalPlacement::Anchored {
+ position,
+ });
+
+ window.dispatch_action(
+ OpenRemote {
+ from_existing_connection: false,
+ create_new_window: false,
+ }
+ .boxed_clone(),
+ cx,
+ );
+ });
})
.into_any_element(),
)
@@ -447,39 +463,47 @@ impl TitleBar {
return None;
}
- Some(
- Button::new("restricted_mode_trigger", "Restricted Mode")
- .style(ButtonStyle::Tinted(TintColor::Warning))
- .label_size(LabelSize::Small)
- .color(Color::Warning)
- .icon(IconName::Warning)
- .icon_color(Color::Warning)
- .icon_size(IconSize::Small)
- .icon_position(IconPosition::Start)
- .tooltip(|_, cx| {
- Tooltip::with_meta(
- "You're in Restricted Mode",
- Some(&ToggleWorktreeSecurity),
- "Mark this project as trusted and unlock all features",
- cx,
- )
- })
- .on_click({
- cx.listener(move |this, _, window, cx| {
- this.workspace
- .update(cx, |workspace, cx| {
- workspace.show_worktree_trust_security_modal(true, window, cx)
- })
- .log_err();
- })
+ let button = Button::new("restricted_mode_trigger", "Restricted Mode")
+ .style(ButtonStyle::Tinted(TintColor::Warning))
+ .label_size(LabelSize::Small)
+ .color(Color::Warning)
+ .icon(IconName::Warning)
+ .icon_color(Color::Warning)
+ .icon_size(IconSize::Small)
+ .icon_position(IconPosition::Start)
+ .tooltip(|_, cx| {
+ Tooltip::with_meta(
+ "You're in Restricted Mode",
+ Some(&ToggleWorktreeSecurity),
+ "Mark this project as trusted and unlock all features",
+ cx,
+ )
+ })
+ .on_click({
+ cx.listener(move |this, _, window, cx| {
+ this.workspace
+ .update(cx, |workspace, cx| {
+ workspace.show_worktree_trust_security_modal(true, window, cx)
+ })
+ .log_err();
})
- .into_any_element(),
- )
+ });
+
+ if cfg!(macos_sdk_26) {
+ // Make up for Tahoe's traffic light buttons having less spacing around them
+ Some(div().child(button).ml_0p5().into_any_element())
+ } else {
+ Some(button.into_any_element())
+ }
}
- pub fn render_project_host(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
+ pub fn render_project_host(
+ &self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Option<AnyElement> {
if self.project.read(cx).is_via_remote_server() {
- return self.render_remote_project_connection(cx);
+ return self.render_remote_project_connection(window, cx);
}
if self.project.read(cx).is_disconnected(cx) {
@@ -487,7 +511,6 @@ impl TitleBar {
Button::new("disconnected", "Disconnected")
.disabled(true)
.color(Color::Disabled)
- .style(ButtonStyle::Subtle)
.label_size(LabelSize::Small)
.into_any_element(),
);
@@ -500,15 +523,19 @@ impl TitleBar {
.read(cx)
.participant_indices()
.get(&host_user.id)?;
+
Some(
Button::new("project_owner_trigger", host_user.github_login.clone())
.color(Color::Player(participant_index.0))
- .style(ButtonStyle::Subtle)
.label_size(LabelSize::Small)
- .tooltip(Tooltip::text(format!(
- "{} is sharing this project. Click to follow.",
- host_user.github_login
- )))
+ .tooltip(move |_, cx| {
+ let tooltip_title = format!(
+ "{} is sharing this project. Click to follow.",
+ host_user.github_login
+ );
+
+ Tooltip::with_meta(tooltip_title, None, "Click to Follow", cx)
+ })
.on_click({
let host_peer_id = host.peer_id;
cx.listener(move |this, _, window, cx| {
@@ -523,7 +550,14 @@ impl TitleBar {
)
}
- pub fn render_project_name(&self, cx: &mut Context<Self>) -> impl IntoElement {
+ pub fn render_project_name(
+ &self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> impl IntoElement {
+ let workspace = self.workspace.clone();
+ let is_picker_open = self.is_picker_open(window, cx);
+
let name = self.project_name(cx);
let is_project_selected = name.is_some();
let name = if let Some(name) = name {
@@ -533,19 +567,25 @@ impl TitleBar {
};
Button::new("project_name_trigger", name)
- .when(!is_project_selected, |b| b.color(Color::Muted))
- .style(ButtonStyle::Subtle)
.label_size(LabelSize::Small)
- .tooltip(move |_window, cx| {
- Tooltip::for_action(
- "Recent Projects",
- &zed_actions::OpenRecent {
- create_new_window: false,
- },
- cx,
- )
+ .when(!is_project_selected, |s| s.color(Color::Muted))
+ .when(!is_picker_open, |this| {
+ this.tooltip(move |_window, cx| {
+ Tooltip::for_action(
+ "Recent Projects",
+ &zed_actions::OpenRecent {
+ create_new_window: false,
+ },
+ cx,
+ )
+ })
})
- .on_click(cx.listener(move |_, _, window, cx| {
+ .on_click(move |event, window, cx| {
+ let position = event.position();
+ let _ = workspace.update(cx, |this, _cx| {
+ this.set_next_modal_placement(workspace::ModalPlacement::Anchored { position })
+ });
+
window.dispatch_action(
OpenRecent {
create_new_window: false,
@@ -553,84 +593,102 @@ impl TitleBar {
.boxed_clone(),
cx,
);
- }))
+ })
}
- pub fn render_project_repo(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
- let settings = TitleBarSettings::get_global(cx);
+ pub fn render_project_repo(
+ &self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Option<impl IntoElement> {
let repository = self.project.read(cx).active_repository(cx)?;
let repository_count = self.project.read(cx).repositories(cx).len();
let workspace = self.workspace.upgrade()?;
- let repo = repository.read(cx);
- let branch_name = repo
- .branch
- .as_ref()
- .map(|branch| branch.name())
- .map(|name| util::truncate_and_trailoff(name, MAX_BRANCH_NAME_LENGTH))
- .or_else(|| {
- repo.head_commit.as_ref().map(|commit| {
- commit
- .sha
- .chars()
- .take(MAX_SHORT_SHA_LENGTH)
- .collect::<String>()
- })
- })?;
- let project_name = self.project_name(cx);
- let repo_name = repo
- .work_directory_abs_path
- .file_name()
- .and_then(|name| name.to_str())
- .map(SharedString::new);
- let show_repo_name =
- repository_count > 1 && repo.branch.is_some() && repo_name != project_name;
- let branch_name = if let Some(repo_name) = repo_name.filter(|_| show_repo_name) {
- format!("{repo_name}/{branch_name}")
- } else {
- branch_name
+
+ let (branch_name, icon_info) = {
+ let repo = repository.read(cx);
+ let branch_name = repo
+ .branch
+ .as_ref()
+ .map(|branch| branch.name())
+ .map(|name| util::truncate_and_trailoff(name, MAX_BRANCH_NAME_LENGTH))
+ .or_else(|| {
+ repo.head_commit.as_ref().map(|commit| {
+ commit
+ .sha
+ .chars()
+ .take(MAX_SHORT_SHA_LENGTH)
+ .collect::<String>()
+ })
+ });
+
+ let branch_name = branch_name?;
+
+ let project_name = self.project_name(cx);
+ let repo_name = repo
+ .work_directory_abs_path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .map(SharedString::new);
+ let show_repo_name =
+ repository_count > 1 && repo.branch.is_some() && repo_name != project_name;
+ let branch_name = if let Some(repo_name) = repo_name.filter(|_| show_repo_name) {
+ format!("{repo_name}/{branch_name}")
+ } else {
+ branch_name
+ };
+
+ let status = repo.status_summary();
+ let tracked = status.index + status.worktree;
+ let icon_info = if status.conflict > 0 {
+ (IconName::Warning, Color::VersionControlConflict)
+ } else if tracked.modified > 0 {
+ (IconName::SquareDot, Color::VersionControlModified)
+ } else if tracked.added > 0 || status.untracked > 0 {
+ (IconName::SquarePlus, Color::VersionControlAdded)
+ } else if tracked.deleted > 0 {
+ (IconName::SquareMinus, Color::VersionControlDeleted)
+ } else {
+ (IconName::GitBranch, Color::Muted)
+ };
+
+ (branch_name, icon_info)
};
+ let is_picker_open = self.is_picker_open(window, cx);
+ let settings = TitleBarSettings::get_global(cx);
+
Some(
Button::new("project_branch_trigger", branch_name)
- .color(Color::Muted)
- .style(ButtonStyle::Subtle)
.label_size(LabelSize::Small)
- .tooltip(move |_window, cx| {
- Tooltip::with_meta(
- "Recent Branches",
- Some(&zed_actions::git::Branch),
- "Local branches only",
- cx,
- )
- })
- .on_click(move |_, window, cx| {
- let _ = workspace.update(cx, |this, cx| {
- window.focus(&this.active_pane().focus_handle(cx), cx);
- window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
- });
+ .color(Color::Muted)
+ .when(!is_picker_open, |this| {
+ this.tooltip(move |_window, cx| {
+ Tooltip::with_meta(
+ "Recent Branches",
+ Some(&zed_actions::git::Branch),
+ "Local branches only",
+ cx,
+ )
+ })
})
.when(settings.show_branch_icon, |branch_button| {
- let (icon, icon_color) = {
- let status = repo.status_summary();
- let tracked = status.index + status.worktree;
- if status.conflict > 0 {
- (IconName::Warning, Color::VersionControlConflict)
- } else if tracked.modified > 0 {
- (IconName::SquareDot, Color::VersionControlModified)
- } else if tracked.added > 0 || status.untracked > 0 {
- (IconName::SquarePlus, Color::VersionControlAdded)
- } else if tracked.deleted > 0 {
- (IconName::SquareMinus, Color::VersionControlDeleted)
- } else {
- (IconName::GitBranch, Color::Muted)
- }
- };
-
+ let (icon, icon_color) = icon_info;
branch_button
.icon(icon)
.icon_position(IconPosition::Start)
.icon_color(icon_color)
.icon_size(IconSize::Indicator)
+ })
+ .on_click(move |event, window, cx| {
+ let position = event.position();
+ let _ = workspace.update(cx, |this, cx| {
+ this.set_next_modal_placement(workspace::ModalPlacement::Anchored {
+ position,
+ });
+ window.focus(&this.active_pane().focus_handle(cx), cx);
+ window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
+ });
}),
)
}
@@ -722,7 +780,7 @@ impl TitleBar {
pub fn render_sign_in_button(&mut self, _: &mut Context<Self>) -> Button {
let client = self.client.clone();
- Button::new("sign_in", "Sign in")
+ Button::new("sign_in", "Sign In")
.label_size(LabelSize::Small)
.on_click(move |_, window, cx| {
let client = client.clone();
@@ -844,4 +902,10 @@ impl TitleBar {
})
.anchor(gpui::Corner::TopRight)
}
+
+ fn is_picker_open(&self, window: &mut Window, cx: &mut Context<Self>) -> bool {
+ self.workspace
+ .update(cx, |workspace, cx| workspace.has_active_modal(window, cx))
+ .unwrap_or(false)
+ }
}
@@ -198,10 +198,17 @@ impl ActiveToolchain {
.or_else(|| toolchains.toolchains.first())
.cloned();
if let Some(toolchain) = &default_choice {
+ let worktree_root_path = project
+ .read_with(cx, |this, cx| {
+ this.worktree_for_id(worktree_id, cx)
+ .map(|worktree| worktree.read(cx).abs_path())
+ })
+ .ok()
+ .flatten()?;
workspace::WORKSPACE_DB
.set_toolchain(
workspace_id,
- worktree_id,
+ worktree_root_path,
relative_path.clone(),
toolchain.clone(),
)
@@ -1,6 +1,7 @@
mod active_toolchain;
pub use active_toolchain::ActiveToolchain;
+use anyhow::Context as _;
use convert_case::Casing as _;
use editor::Editor;
use file_finder::OpenPathDelegate;
@@ -62,6 +63,7 @@ struct AddToolchainState {
language_name: LanguageName,
root_path: ProjectPath,
weak: WeakEntity<ToolchainSelector>,
+ worktree_root_path: Arc<Path>,
}
struct ScopePickerState {
@@ -99,12 +101,17 @@ impl AddToolchainState {
root_path: ProjectPath,
window: &mut Window,
cx: &mut Context<ToolchainSelector>,
- ) -> Entity<Self> {
+ ) -> anyhow::Result<Entity<Self>> {
let weak = cx.weak_entity();
-
- cx.new(|cx| {
+ let worktree_root_path = project
+ .read(cx)
+ .worktree_for_id(root_path.worktree_id, cx)
+ .map(|worktree| worktree.read(cx).abs_path())
+ .context("Could not find worktree")?;
+ Ok(cx.new(|cx| {
let (lister, rx) = Self::create_path_browser_delegate(project.clone(), cx);
let picker = cx.new(|cx| Picker::uniform_list(lister, window, cx));
+
Self {
state: AddState::Path {
_subscription: cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| {
@@ -118,8 +125,9 @@ impl AddToolchainState {
language_name,
root_path,
weak,
+ worktree_root_path,
}
- })
+ }))
}
fn create_path_browser_delegate(
@@ -237,7 +245,15 @@ impl AddToolchainState {
// Suggest a default scope based on the applicability.
let scope = if let Some(project_path) = resolved_toolchain_path {
if !root_path.path.as_ref().is_empty() && project_path.starts_with(&root_path) {
- ToolchainScope::Subproject(root_path.worktree_id, root_path.path)
+ let worktree_root_path = project
+ .read_with(cx, |this, cx| {
+ this.worktree_for_id(root_path.worktree_id, cx)
+ .map(|worktree| worktree.read(cx).abs_path())
+ })
+ .ok()
+ .flatten()
+ .context("Could not find a worktree with a given worktree ID")?;
+ ToolchainScope::Subproject(worktree_root_path, root_path.path)
} else {
ToolchainScope::Project
}
@@ -400,7 +416,7 @@ impl Render for AddToolchainState {
ToolchainScope::Global,
ToolchainScope::Project,
ToolchainScope::Subproject(
- self.root_path.worktree_id,
+ self.worktree_root_path.clone(),
self.root_path.path.clone(),
),
];
@@ -693,7 +709,7 @@ impl ToolchainSelector {
cx: &mut Context<Self>,
) {
if matches!(self.state, State::Search(_)) {
- self.state = State::AddToolchain(AddToolchainState::new(
+ let Ok(state) = AddToolchainState::new(
self.project.clone(),
self.language_name.clone(),
ProjectPath {
@@ -702,7 +718,10 @@ impl ToolchainSelector {
},
window,
cx,
- ));
+ ) else {
+ return;
+ };
+ self.state = State::AddToolchain(state);
self.state.focus_handle(cx).focus(window, cx);
cx.notify();
}
@@ -899,11 +918,17 @@ impl PickerDelegate for ToolchainSelectorDelegate {
{
let workspace = self.workspace.clone();
let worktree_id = self.worktree_id;
+ let worktree_abs_path_root = self.worktree_abs_path_root.clone();
let path = self.relative_path.clone();
let relative_path = self.relative_path.clone();
cx.spawn_in(window, async move |_, cx| {
workspace::WORKSPACE_DB
- .set_toolchain(workspace_id, worktree_id, relative_path, toolchain.clone())
+ .set_toolchain(
+ workspace_id,
+ worktree_abs_path_root,
+ relative_path,
+ toolchain.clone(),
+ )
.await
.log_err();
workspace
@@ -56,6 +56,12 @@ impl Label {
pub fn set_text(&mut self, text: impl Into<SharedString>) {
self.label = text.into();
}
+
+ /// Truncates the label from the start, keeping the end visible.
+ pub fn truncate_start(mut self) -> Self {
+ self.base = self.base.truncate_start();
+ self
+ }
}
// Style methods.
@@ -256,7 +262,8 @@ impl Component for Label {
"Special Cases",
vec![
single_example("Single Line", Label::new("Line 1\nLine 2\nLine 3").single_line().into_any_element()),
- single_example("Text Ellipsis", div().max_w_24().child(Label::new("This is a very long file name that should be truncated: very_long_file_name_with_many_words.rs").truncate()).into_any_element()),
+ single_example("Regular Truncation", div().max_w_24().child(Label::new("This is a very long file name that should be truncated: very_long_file_name_with_many_words.rs").truncate()).into_any_element()),
+ single_example("Start Truncation", div().max_w_24().child(Label::new("zed/crates/ui/src/components/label/truncate/label/label.rs").truncate_start()).into_any_element()),
],
),
])
@@ -56,7 +56,7 @@ pub trait LabelCommon {
/// Sets the alpha property of the label, overwriting the alpha value of the color.
fn alpha(self, alpha: f32) -> Self;
- /// Truncates overflowing text with an ellipsis (`…`) if needed.
+ /// Truncates overflowing text with an ellipsis (`…`) at the end if needed.
fn truncate(self) -> Self;
/// Sets the label to render as a single line.
@@ -88,6 +88,7 @@ pub struct LabelLike {
underline: bool,
single_line: bool,
truncate: bool,
+ truncate_start: bool,
}
impl Default for LabelLike {
@@ -113,6 +114,7 @@ impl LabelLike {
underline: false,
single_line: false,
truncate: false,
+ truncate_start: false,
}
}
}
@@ -126,6 +128,12 @@ impl LabelLike {
gpui::margin_style_methods!({
visibility: pub
});
+
+ /// Truncates overflowing text with an ellipsis (`…`) at the start if needed.
+ pub fn truncate_start(mut self) -> Self {
+ self.truncate_start = true;
+ self
+ }
}
impl LabelCommon for LabelLike {
@@ -169,7 +177,7 @@ impl LabelCommon for LabelLike {
self
}
- /// Truncates overflowing text with an ellipsis (`…`) if needed.
+ /// Truncates overflowing text with an ellipsis (`…`) at the end if needed.
fn truncate(mut self) -> Self {
self.truncate = true;
self
@@ -233,7 +241,16 @@ impl RenderOnce for LabelLike {
.when(self.strikethrough, |this| this.line_through())
.when(self.single_line, |this| this.whitespace_nowrap())
.when(self.truncate, |this| {
- this.overflow_x_hidden().text_ellipsis()
+ this.min_w_0()
+ .overflow_x_hidden()
+ .whitespace_nowrap()
+ .text_ellipsis()
+ })
+ .when(self.truncate_start, |this| {
+ this.min_w_0()
+ .overflow_x_hidden()
+ .whitespace_nowrap()
+ .text_ellipsis_start()
})
.text_color(color)
.font_weight(
@@ -230,6 +230,14 @@ struct VimEdit {
pub filename: String,
}
+/// Pastes the specified file's contents.
+#[derive(Clone, PartialEq, Action)]
+#[action(namespace = vim, no_json, no_register)]
+struct VimRead {
+ pub range: Option<CommandRange>,
+ pub filename: String,
+}
+
#[derive(Clone, PartialEq, Action)]
#[action(namespace = vim, no_json, no_register)]
struct VimNorm {
@@ -643,6 +651,107 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
});
});
+ Vim::action(editor, cx, |vim, action: &VimRead, window, cx| {
+ vim.update_editor(cx, |vim, editor, cx| {
+ let snapshot = editor.buffer().read(cx).snapshot(cx);
+ let end = if let Some(range) = action.range.clone() {
+ let Some(multi_range) = range.buffer_range(vim, editor, window, cx).log_err()
+ else {
+ return;
+ };
+
+ match &range.start {
+ // inserting text above the first line uses the command ":0r {name}"
+ Position::Line { row: 0, offset: 0 } if range.end.is_none() => {
+ snapshot.clip_point(Point::new(0, 0), Bias::Right)
+ }
+ _ => snapshot.clip_point(Point::new(multi_range.end.0 + 1, 0), Bias::Right),
+ }
+ } else {
+ let end_row = editor
+ .selections
+ .newest::<Point>(&editor.display_snapshot(cx))
+ .range()
+ .end
+ .row;
+ snapshot.clip_point(Point::new(end_row + 1, 0), Bias::Right)
+ };
+ let is_end_of_file = end == snapshot.max_point();
+ let edit_range = snapshot.anchor_before(end)..snapshot.anchor_before(end);
+
+ let mut text = if is_end_of_file {
+ String::from('\n')
+ } else {
+ String::new()
+ };
+
+ let mut task = None;
+ if action.filename.is_empty() {
+ text.push_str(
+ &editor
+ .buffer()
+ .read(cx)
+ .as_singleton()
+ .map(|buffer| buffer.read(cx).text())
+ .unwrap_or_default(),
+ );
+ } else {
+ if let Some(project) = editor.project().cloned() {
+ project.update(cx, |project, cx| {
+ let Some(worktree) = project.visible_worktrees(cx).next() else {
+ return;
+ };
+ let path_style = worktree.read(cx).path_style();
+ let Some(path) =
+ RelPath::new(Path::new(&action.filename), path_style).log_err()
+ else {
+ return;
+ };
+ task =
+ Some(worktree.update(cx, |worktree, cx| worktree.load_file(&path, cx)));
+ });
+ } else {
+ return;
+ }
+ };
+
+ cx.spawn_in(window, async move |editor, cx| {
+ if let Some(task) = task {
+ text.push_str(
+ &task
+ .await
+ .log_err()
+ .map(|loaded_file| loaded_file.text)
+ .unwrap_or_default(),
+ );
+ }
+
+ if !text.is_empty() && !is_end_of_file {
+ text.push('\n');
+ }
+
+ let _ = editor.update_in(cx, |editor, window, cx| {
+ editor.transact(window, cx, |editor, window, cx| {
+ editor.edit([(edit_range.clone(), text)], cx);
+ let snapshot = editor.buffer().read(cx).snapshot(cx);
+ editor.change_selections(Default::default(), window, cx, |s| {
+ let point = if is_end_of_file {
+ Point::new(
+ edit_range.start.to_point(&snapshot).row.saturating_add(1),
+ 0,
+ )
+ } else {
+ Point::new(edit_range.start.to_point(&snapshot).row, 0)
+ };
+ s.select_ranges([point..point]);
+ })
+ });
+ });
+ })
+ .detach();
+ });
+ });
+
Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| {
let keystrokes = action
.command
@@ -1338,6 +1447,27 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
VimCommand::new(("e", "dit"), editor::actions::ReloadFile)
.bang(editor::actions::ReloadFile)
.filename(|_, filename| Some(VimEdit { filename }.boxed_clone())),
+ VimCommand::new(
+ ("r", "ead"),
+ VimRead {
+ range: None,
+ filename: "".into(),
+ },
+ )
+ .filename(|_, filename| {
+ Some(
+ VimRead {
+ range: None,
+ filename,
+ }
+ .boxed_clone(),
+ )
+ })
+ .range(|action, range| {
+ let mut action: VimRead = action.as_any().downcast_ref::<VimRead>().unwrap().clone();
+ action.range.replace(range.clone());
+ Some(Box::new(action))
+ }),
VimCommand::new(("sp", "lit"), workspace::SplitHorizontal).filename(|_, filename| {
Some(
VimSplit {
@@ -2575,6 +2705,76 @@ mod test {
assert_eq!(fs.load(path).await.unwrap().replace("\r\n", "\n"), "@@\n");
}
+ #[gpui::test]
+ async fn test_command_read(cx: &mut TestAppContext) {
+ let mut cx = VimTestContext::new(cx, true).await;
+
+ let fs = cx.workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
+ let path = Path::new(path!("/root/dir/other.rs"));
+ fs.as_fake().insert_file(path, "1\n2\n3".into()).await;
+
+ cx.workspace(|workspace, _, cx| {
+ assert_active_item(workspace, path!("/root/dir/file.rs"), "", cx);
+ });
+
+ // File without trailing newline
+ cx.set_state("one\ntwo\nthreeˇ", Mode::Normal);
+ cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+ cx.simulate_keystrokes("enter");
+ cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3", Mode::Normal);
+
+ cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
+ cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+ cx.simulate_keystrokes("enter");
+ cx.assert_state("one\nˇ1\n2\n3\ntwo\nthree", Mode::Normal);
+
+ cx.set_state("one\nˇtwo\nthree", Mode::Normal);
+ cx.simulate_keystrokes(": 0 r space d i r / o t h e r . r s");
+ cx.simulate_keystrokes("enter");
+ cx.assert_state("ˇ1\n2\n3\none\ntwo\nthree", Mode::Normal);
+
+ cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual);
+ cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+ cx.simulate_keystrokes("enter");
+ cx.run_until_parked();
+ cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\nfive", Mode::Normal);
+
+ // Empty filename
+ cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
+ cx.simulate_keystrokes(": r");
+ cx.simulate_keystrokes("enter");
+ cx.assert_state("one\nˇone\ntwo\nthree\ntwo\nthree", Mode::Normal);
+
+ // File with trailing newline
+ fs.as_fake().insert_file(path, "1\n2\n3\n".into()).await;
+ cx.set_state("one\ntwo\nthreeˇ", Mode::Normal);
+ cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+ cx.simulate_keystrokes("enter");
+ cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal);
+
+ cx.set_state("oneˇ\ntwo\nthree", Mode::Normal);
+ cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+ cx.simulate_keystrokes("enter");
+ cx.assert_state("one\nˇ1\n2\n3\n\ntwo\nthree", Mode::Normal);
+
+ cx.set_state("one\n«ˇtwo\nthree\nfour»\nfive", Mode::Visual);
+ cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+ cx.simulate_keystrokes("enter");
+ cx.assert_state("one\ntwo\nthree\nfour\nˇ1\n2\n3\n\nfive", Mode::Normal);
+
+ cx.set_state("«one\ntwo\nthreeˇ»", Mode::Visual);
+ cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+ cx.simulate_keystrokes("enter");
+ cx.assert_state("one\ntwo\nthree\nˇ1\n2\n3\n", Mode::Normal);
+
+ // Empty file
+ fs.as_fake().insert_file(path, "".into()).await;
+ cx.set_state("ˇone\ntwo\nthree", Mode::Normal);
+ cx.simulate_keystrokes(": r space d i r / o t h e r . r s");
+ cx.simulate_keystrokes("enter");
+ cx.assert_state("one\nˇtwo\nthree", Mode::Normal);
+ }
+
#[gpui::test]
async fn test_command_quit(cx: &mut TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
@@ -1,9 +1,18 @@
use gpui::{
AnyView, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable as _, ManagedView,
- MouseButton, Subscription,
+ MouseButton, Pixels, Point, Subscription,
};
use ui::prelude::*;
+#[derive(Debug, Clone, Copy, Default)]
+pub enum ModalPlacement {
+ #[default]
+ Centered,
+ Anchored {
+ position: Point<Pixels>,
+ },
+}
+
#[derive(Debug)]
pub enum DismissDecision {
Dismiss(bool),
@@ -58,6 +67,7 @@ pub struct ActiveModal {
_subscriptions: [Subscription; 2],
previous_focus_handle: Option<FocusHandle>,
focus_handle: FocusHandle,
+ placement: ModalPlacement,
}
pub struct ModalLayer {
@@ -87,6 +97,19 @@ impl ModalLayer {
where
V: ModalView,
B: FnOnce(&mut Window, &mut Context<V>) -> V,
+ {
+ self.toggle_modal_with_placement(window, cx, ModalPlacement::Centered, build_view);
+ }
+
+ pub fn toggle_modal_with_placement<V, B>(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ placement: ModalPlacement,
+ build_view: B,
+ ) where
+ V: ModalView,
+ B: FnOnce(&mut Window, &mut Context<V>) -> V,
{
if let Some(active_modal) = &self.active_modal {
let is_close = active_modal.modal.view().downcast::<V>().is_ok();
@@ -96,12 +119,17 @@ impl ModalLayer {
}
}
let new_modal = cx.new(|cx| build_view(window, cx));
- self.show_modal(new_modal, window, cx);
+ self.show_modal(new_modal, placement, window, cx);
cx.emit(ModalOpenedEvent);
}
- fn show_modal<V>(&mut self, new_modal: Entity<V>, window: &mut Window, cx: &mut Context<Self>)
- where
+ fn show_modal<V>(
+ &mut self,
+ new_modal: Entity<V>,
+ placement: ModalPlacement,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) where
V: ModalView,
{
let focus_handle = cx.focus_handle();
@@ -123,6 +151,7 @@ impl ModalLayer {
],
previous_focus_handle: window.focused(cx),
focus_handle,
+ placement,
});
cx.defer_in(window, move |_, window, cx| {
window.focus(&new_modal.focus_handle(cx), cx);
@@ -183,6 +212,30 @@ impl Render for ModalLayer {
return active_modal.modal.view().into_any_element();
}
+ let content = h_flex()
+ .occlude()
+ .child(active_modal.modal.view())
+ .on_mouse_down(MouseButton::Left, |_, _, cx| {
+ cx.stop_propagation();
+ });
+
+ let positioned = match active_modal.placement {
+ ModalPlacement::Centered => v_flex()
+ .h(px(0.0))
+ .top_20()
+ .items_center()
+ .track_focus(&active_modal.focus_handle)
+ .child(content)
+ .into_any_element(),
+ ModalPlacement::Anchored { position } => div()
+ .absolute()
+ .left(position.x)
+ .top(position.y - px(20.))
+ .track_focus(&active_modal.focus_handle)
+ .child(content)
+ .into_any_element(),
+ };
+
div()
.absolute()
.size_full()
@@ -199,21 +252,7 @@ impl Render for ModalLayer {
this.hide_modal(window, cx);
}),
)
- .child(
- v_flex()
- .h(px(0.0))
- .top_20()
- .items_center()
- .track_focus(&active_modal.focus_handle)
- .child(
- h_flex()
- .occlude()
- .child(active_modal.modal.view())
- .on_mouse_down(MouseButton::Left, |_, _, cx| {
- cx.stop_propagation();
- }),
- ),
- )
+ .child(positioned)
.into_any_element()
}
}
@@ -1846,6 +1846,7 @@ impl Pane {
}
for item_to_close in items_to_close {
+ let mut should_close = true;
let mut should_save = true;
if save_intent == SaveIntent::Close {
workspace.update(cx, |workspace, cx| {
@@ -1861,7 +1862,7 @@ impl Pane {
{
Ok(success) => {
if !success {
- break;
+ should_close = false;
}
}
Err(err) => {
@@ -1880,23 +1881,25 @@ impl Pane {
})?;
match answer.await {
Ok(0) => {}
- Ok(1..) | Err(_) => break,
+ Ok(1..) | Err(_) => should_close = false,
}
}
}
}
// Remove the item from the pane.
- pane.update_in(cx, |pane, window, cx| {
- pane.remove_item(
- item_to_close.item_id(),
- false,
- pane.close_pane_if_empty,
- window,
- cx,
- );
- })
- .ok();
+ if should_close {
+ pane.update_in(cx, |pane, window, cx| {
+ pane.remove_item(
+ item_to_close.item_id(),
+ false,
+ pane.close_pane_if_empty,
+ window,
+ cx,
+ );
+ })
+ .ok();
+ }
}
pane.update(cx, |_, cx| cx.notify()).ok();
@@ -6614,6 +6617,60 @@ mod tests {
cx.simulate_prompt_answer("Discard all");
save.await.unwrap();
assert_item_labels(&pane, [], cx);
+
+ add_labeled_item(&pane, "A", true, cx).update(cx, |item, cx| {
+ item.project_items
+ .push(TestProjectItem::new_dirty(1, "A.txt", cx))
+ });
+ add_labeled_item(&pane, "B", true, cx).update(cx, |item, cx| {
+ item.project_items
+ .push(TestProjectItem::new_dirty(2, "B.txt", cx))
+ });
+ add_labeled_item(&pane, "C", true, cx).update(cx, |item, cx| {
+ item.project_items
+ .push(TestProjectItem::new_dirty(3, "C.txt", cx))
+ });
+ assert_item_labels(&pane, ["A^", "B^", "C*^"], cx);
+
+ let close_task = pane.update_in(cx, |pane, window, cx| {
+ pane.close_all_items(
+ &CloseAllItems {
+ save_intent: None,
+ close_pinned: false,
+ },
+ window,
+ cx,
+ )
+ });
+
+ cx.executor().run_until_parked();
+ cx.simulate_prompt_answer("Discard all");
+ close_task.await.unwrap();
+ assert_item_labels(&pane, [], cx);
+
+ add_labeled_item(&pane, "Clean1", false, cx);
+ add_labeled_item(&pane, "Dirty", true, cx).update(cx, |item, cx| {
+ item.project_items
+ .push(TestProjectItem::new_dirty(1, "Dirty.txt", cx))
+ });
+ add_labeled_item(&pane, "Clean2", false, cx);
+ assert_item_labels(&pane, ["Clean1", "Dirty^", "Clean2*"], cx);
+
+ let close_task = pane.update_in(cx, |pane, window, cx| {
+ pane.close_all_items(
+ &CloseAllItems {
+ save_intent: None,
+ close_pinned: false,
+ },
+ window,
+ cx,
+ )
+ });
+
+ cx.executor().run_until_parked();
+ cx.simulate_prompt_answer("Cancel");
+ close_task.await.unwrap();
+ assert_item_labels(&pane, ["Dirty*^"], cx);
}
#[gpui::test]
@@ -24,7 +24,6 @@ use project::{
};
use language::{LanguageName, Toolchain, ToolchainScope};
-use project::WorktreeId;
use remote::{
DockerConnectionOptions, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions,
};
@@ -845,6 +844,44 @@ impl Domain for WorkspaceDb {
host_name TEXT
) STRICT;
),
+ sql!(CREATE TABLE toolchains2 (
+ workspace_id INTEGER,
+ worktree_root_path TEXT NOT NULL,
+ language_name TEXT NOT NULL,
+ name TEXT NOT NULL,
+ path TEXT NOT NULL,
+ raw_json TEXT NOT NULL,
+ relative_worktree_path TEXT NOT NULL,
+ PRIMARY KEY (workspace_id, worktree_root_path, language_name, relative_worktree_path)) STRICT;
+ INSERT OR REPLACE INTO toolchains2
+ // The `instr(paths, '\n') = 0` part allows us to find all
+ // workspaces that have a single worktree, as `\n` is used as a
+ // separator when serializing the workspace paths, so if no `\n` is
+ // found, we know we have a single worktree.
+ SELECT toolchains.workspace_id, paths, language_name, name, path, raw_json, relative_worktree_path FROM toolchains INNER JOIN workspaces ON toolchains.workspace_id = workspaces.workspace_id AND instr(paths, '\n') = 0;
+ DROP TABLE toolchains;
+ ALTER TABLE toolchains2 RENAME TO toolchains;
+ ),
+ sql!(CREATE TABLE user_toolchains2 (
+ remote_connection_id INTEGER,
+ workspace_id INTEGER NOT NULL,
+ worktree_root_path TEXT NOT NULL,
+ relative_worktree_path TEXT NOT NULL,
+ language_name TEXT NOT NULL,
+ name TEXT NOT NULL,
+ path TEXT NOT NULL,
+ raw_json TEXT NOT NULL,
+
+ PRIMARY KEY (workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json)) STRICT;
+ INSERT OR REPLACE INTO user_toolchains2
+ // The `instr(paths, '\n') = 0` part allows us to find all
+ // workspaces that have a single worktree, as `\n` is used as a
+ // separator when serializing the workspace paths, so if no `\n` is
+ // found, we know we have a single worktree.
+ SELECT user_toolchains.remote_connection_id, user_toolchains.workspace_id, paths, relative_worktree_path, language_name, name, path, raw_json FROM user_toolchains INNER JOIN workspaces ON user_toolchains.workspace_id = workspaces.workspace_id AND instr(paths, '\n') = 0;
+ DROP TABLE user_toolchains;
+ ALTER TABLE user_toolchains2 RENAME TO user_toolchains;
+ ),
];
// Allow recovering from bad migration that was initially shipped to nightly
@@ -1030,11 +1067,11 @@ impl WorkspaceDb {
workspace_id: WorkspaceId,
remote_connection_id: Option<RemoteConnectionId>,
) -> BTreeMap<ToolchainScope, IndexSet<Toolchain>> {
- type RowKind = (WorkspaceId, u64, String, String, String, String, String);
+ type RowKind = (WorkspaceId, String, String, String, String, String, String);
let toolchains: Vec<RowKind> = self
.select_bound(sql! {
- SELECT workspace_id, worktree_id, relative_worktree_path,
+ SELECT workspace_id, worktree_root_path, relative_worktree_path,
language_name, name, path, raw_json
FROM user_toolchains WHERE remote_connection_id IS ?1 AND (
workspace_id IN (0, ?2)
@@ -1048,7 +1085,7 @@ impl WorkspaceDb {
for (
_workspace_id,
- worktree_id,
+ worktree_root_path,
relative_worktree_path,
language_name,
name,
@@ -1058,22 +1095,24 @@ impl WorkspaceDb {
{
// INTEGER's that are primary keys (like workspace ids, remote connection ids and such) start at 1, so we're safe to
let scope = if _workspace_id == WorkspaceId(0) {
- debug_assert_eq!(worktree_id, u64::MAX);
+ debug_assert_eq!(worktree_root_path, String::default());
debug_assert_eq!(relative_worktree_path, String::default());
ToolchainScope::Global
} else {
debug_assert_eq!(workspace_id, _workspace_id);
debug_assert_eq!(
- worktree_id == u64::MAX,
+ worktree_root_path == String::default(),
relative_worktree_path == String::default()
);
let Some(relative_path) = RelPath::unix(&relative_worktree_path).log_err() else {
continue;
};
- if worktree_id != u64::MAX && relative_worktree_path != String::default() {
+ if worktree_root_path != String::default()
+ && relative_worktree_path != String::default()
+ {
ToolchainScope::Subproject(
- WorktreeId::from_usize(worktree_id as usize),
+ Arc::from(worktree_root_path.as_ref()),
relative_path.into(),
)
} else {
@@ -1159,13 +1198,13 @@ impl WorkspaceDb {
for (scope, toolchains) in workspace.user_toolchains {
for toolchain in toolchains {
- let query = sql!(INSERT OR REPLACE INTO user_toolchains(remote_connection_id, workspace_id, worktree_id, relative_worktree_path, language_name, name, path, raw_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8));
- let (workspace_id, worktree_id, relative_worktree_path) = match scope {
- ToolchainScope::Subproject(worktree_id, ref path) => (Some(workspace.id), Some(worktree_id), Some(path.as_unix_str().to_owned())),
+ let query = sql!(INSERT OR REPLACE INTO user_toolchains(remote_connection_id, workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8));
+ let (workspace_id, worktree_root_path, relative_worktree_path) = match scope {
+ ToolchainScope::Subproject(ref worktree_root_path, ref path) => (Some(workspace.id), Some(worktree_root_path.to_string_lossy().into_owned()), Some(path.as_unix_str().to_owned())),
ToolchainScope::Project => (Some(workspace.id), None, None),
ToolchainScope::Global => (None, None, None),
};
- let args = (remote_connection_id, workspace_id.unwrap_or(WorkspaceId(0)), worktree_id.map_or(usize::MAX,|id| id.to_usize()), relative_worktree_path.unwrap_or_default(),
+ let args = (remote_connection_id, workspace_id.unwrap_or(WorkspaceId(0)), worktree_root_path.unwrap_or_default(), relative_worktree_path.unwrap_or_default(),
toolchain.language_name.as_ref().to_owned(), toolchain.name.to_string(), toolchain.path.to_string(), toolchain.as_json.to_string());
if let Err(err) = conn.exec_bound(query)?(args) {
log::error!("{err}");
@@ -1844,24 +1883,24 @@ impl WorkspaceDb {
pub(crate) async fn toolchains(
&self,
workspace_id: WorkspaceId,
- ) -> Result<Vec<(Toolchain, WorktreeId, Arc<RelPath>)>> {
+ ) -> Result<Vec<(Toolchain, Arc<Path>, Arc<RelPath>)>> {
self.write(move |this| {
let mut select = this
.select_bound(sql!(
SELECT
- name, path, worktree_id, relative_worktree_path, language_name, raw_json
+ name, path, worktree_root_path, relative_worktree_path, language_name, raw_json
FROM toolchains
WHERE workspace_id = ?
))
.context("select toolchains")?;
- let toolchain: Vec<(String, String, u64, String, String, String)> =
+ let toolchain: Vec<(String, String, String, String, String, String)> =
select(workspace_id)?;
Ok(toolchain
.into_iter()
.filter_map(
- |(name, path, worktree_id, relative_worktree_path, language, json)| {
+ |(name, path, worktree_root_path, relative_worktree_path, language, json)| {
Some((
Toolchain {
name: name.into(),
@@ -1869,7 +1908,7 @@ impl WorkspaceDb {
language_name: LanguageName::new(&language),
as_json: serde_json::Value::from_str(&json).ok()?,
},
- WorktreeId::from_proto(worktree_id),
+ Arc::from(worktree_root_path.as_ref()),
RelPath::from_proto(&relative_worktree_path).log_err()?,
))
},
@@ -1882,18 +1921,18 @@ impl WorkspaceDb {
pub async fn set_toolchain(
&self,
workspace_id: WorkspaceId,
- worktree_id: WorktreeId,
+ worktree_root_path: Arc<Path>,
relative_worktree_path: Arc<RelPath>,
toolchain: Toolchain,
) -> Result<()> {
log::debug!(
- "Setting toolchain for workspace, worktree: {worktree_id:?}, relative path: {relative_worktree_path:?}, toolchain: {}",
+ "Setting toolchain for workspace, worktree: {worktree_root_path:?}, relative path: {relative_worktree_path:?}, toolchain: {}",
toolchain.name
);
self.write(move |conn| {
let mut insert = conn
.exec_bound(sql!(
- INSERT INTO toolchains(workspace_id, worktree_id, relative_worktree_path, language_name, name, path, raw_json) VALUES (?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO toolchains(workspace_id, worktree_root_path, relative_worktree_path, language_name, name, path, raw_json) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO
UPDATE SET
name = ?5,
@@ -1904,7 +1943,7 @@ impl WorkspaceDb {
insert((
workspace_id,
- worktree_id.to_usize(),
+ worktree_root_path.to_string_lossy().into_owned(),
relative_worktree_path.as_unix_str(),
toolchain.language_name.as_ref(),
toolchain.name.as_ref(),
@@ -1204,6 +1204,7 @@ pub struct Workspace {
last_open_dock_positions: Vec<DockPosition>,
removing: bool,
utility_panes: UtilityPaneState,
+ next_modal_placement: Option<ModalPlacement>,
}
impl EventEmitter<Event> for Workspace {}
@@ -1620,6 +1621,7 @@ impl Workspace {
last_open_dock_positions: Vec::new(),
removing: false,
utility_panes: UtilityPaneState::default(),
+ next_modal_placement: None,
}
}
@@ -1697,8 +1699,22 @@ impl Workspace {
let toolchains = DB.toolchains(workspace_id).await?;
- for (toolchain, worktree_id, path) in toolchains {
+ for (toolchain, worktree_path, path) in toolchains {
let toolchain_path = PathBuf::from(toolchain.path.clone().to_string());
+ let Some(worktree_id) = project_handle.read_with(cx, |this, cx| {
+ this.find_worktree(&worktree_path, cx)
+ .and_then(|(worktree, rel_path)| {
+ if rel_path.is_empty() {
+ Some(worktree.read(cx).id())
+ } else {
+ None
+ }
+ })
+ })?
+ else {
+ // We did not find a worktree with a given path, but that's whatever.
+ continue;
+ };
if !app_state.fs.is_file(toolchain_path.as_path()).await {
continue;
}
@@ -4223,7 +4239,7 @@ impl Workspace {
cx: &mut Context<Self>,
) {
self.active_pane = pane.clone();
- self.active_item_path_changed(window, cx);
+ self.active_item_path_changed(true, window, cx);
self.last_active_center_pane = Some(pane.downgrade());
}
@@ -4280,7 +4296,7 @@ impl Workspace {
}
serialize_workspace = *focus_changed || pane != self.active_pane();
if pane == self.active_pane() {
- self.active_item_path_changed(window, cx);
+ self.active_item_path_changed(*focus_changed, window, cx);
self.update_active_view_for_followers(window, cx);
} else if *local {
self.set_active_pane(pane, window, cx);
@@ -4296,7 +4312,7 @@ impl Workspace {
}
pane::Event::ChangeItemTitle => {
if *pane == self.active_pane {
- self.active_item_path_changed(window, cx);
+ self.active_item_path_changed(false, window, cx);
}
serialize_workspace = false;
}
@@ -4465,7 +4481,7 @@ impl Workspace {
cx.notify();
} else {
- self.active_item_path_changed(window, cx);
+ self.active_item_path_changed(true, window, cx);
}
cx.emit(Event::PaneRemoved);
}
@@ -4719,14 +4735,19 @@ impl Workspace {
self.follower_states.contains_key(&id.into())
}
- fn active_item_path_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ fn active_item_path_changed(
+ &mut self,
+ focus_changed: bool,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
cx.emit(Event::ActiveItemChanged);
let active_entry = self.active_project_path(cx);
self.project.update(cx, |project, cx| {
project.set_active_path(active_entry.clone(), cx)
});
- if let Some(project_path) = &active_entry {
+ if focus_changed && let Some(project_path) = &active_entry {
let git_store_entity = self.project.read(cx).git_store().clone();
git_store_entity.update(cx, |git_store, cx| {
git_store.set_active_repo_for_path(project_path, cx);
@@ -6307,12 +6328,25 @@ impl Workspace {
self.modal_layer.read(cx).active_modal()
}
+ pub fn is_modal_open<V: 'static>(&self, cx: &App) -> bool {
+ self.modal_layer.read(cx).active_modal::<V>().is_some()
+ }
+
+ pub fn set_next_modal_placement(&mut self, placement: ModalPlacement) {
+ self.next_modal_placement = Some(placement);
+ }
+
+ fn take_next_modal_placement(&mut self) -> ModalPlacement {
+ self.next_modal_placement.take().unwrap_or_default()
+ }
+
pub fn toggle_modal<V: ModalView, B>(&mut self, window: &mut Window, cx: &mut App, build: B)
where
B: FnOnce(&mut Window, &mut Context<V>) -> V,
{
+ let placement = self.take_next_modal_placement();
self.modal_layer.update(cx, |modal_layer, cx| {
- modal_layer.toggle_modal(window, cx, build)
+ modal_layer.toggle_modal_with_placement(window, cx, placement, build)
})
}
@@ -8212,9 +8246,22 @@ async fn open_remote_project_inner(
cx: &mut AsyncApp,
) -> Result<Vec<Option<Box<dyn ItemHandle>>>> {
let toolchains = DB.toolchains(workspace_id).await?;
- for (toolchain, worktree_id, path) in toolchains {
+ for (toolchain, worktree_path, path) in toolchains {
project
.update(cx, |this, cx| {
+ let Some(worktree_id) =
+ this.find_worktree(&worktree_path, cx)
+ .and_then(|(worktree, rel_path)| {
+ if rel_path.is_empty() {
+ Some(worktree.read(cx).id())
+ } else {
+ None
+ }
+ })
+ else {
+ return Task::ready(None);
+ };
+
this.activate_toolchain(ProjectPath { worktree_id, path }, toolchain, cx)
})?
.await;
@@ -9377,7 +9424,7 @@ mod tests {
let right_pane = right_pane.await.unwrap();
cx.focus(&right_pane);
- let mut close = right_pane.update_in(cx, |pane, window, cx| {
+ let close = right_pane.update_in(cx, |pane, window, cx| {
pane.close_all_items(&CloseAllItems::default(), window, cx)
.unwrap()
});
@@ -9389,9 +9436,16 @@ mod tests {
assert!(!msg.contains("3.txt"));
assert!(!msg.contains("4.txt"));
+ // With best-effort close, cancelling item 1 keeps it open but items 4
+ // and (3,4) still close since their entries exist in left pane.
cx.simulate_prompt_answer("Cancel");
close.await;
+ right_pane.read_with(cx, |pane, _| {
+ assert_eq!(pane.items_len(), 1);
+ });
+
+ // Remove item 3 from left pane, making (2,3) the only item with entry 3.
left_pane
.update_in(cx, |left_pane, window, cx| {
left_pane.close_item_by_id(
@@ -9404,26 +9458,25 @@ mod tests {
.await
.unwrap();
- close = right_pane.update_in(cx, |pane, window, cx| {
+ let close = left_pane.update_in(cx, |pane, window, cx| {
pane.close_all_items(&CloseAllItems::default(), window, cx)
.unwrap()
});
cx.executor().run_until_parked();
let details = cx.pending_prompt().unwrap().1;
- assert!(details.contains("1.txt"));
- assert!(!details.contains("2.txt"));
+ assert!(details.contains("0.txt"));
assert!(details.contains("3.txt"));
- // ideally this assertion could be made, but today we can only
- // save whole items not project items, so the orphaned item 3 causes
- // 4 to be saved too.
- // assert!(!details.contains("4.txt"));
+ assert!(details.contains("4.txt"));
+ // Ideally 2.txt wouldn't appear since entry 2 still exists in item 2.
+ // But we can only save whole items, so saving (2,3) for entry 3 includes 2.
+ // assert!(!details.contains("2.txt"));
cx.simulate_prompt_answer("Save all");
-
cx.executor().run_until_parked();
close.await;
- right_pane.read_with(cx, |pane, _| {
+
+ left_pane.read_with(cx, |pane, _| {
assert_eq!(pane.items_len(), 0);
});
}
@@ -0,0 +1,353 @@
+# Documentation Automation Agent Guidelines
+
+This file governs automated documentation updates triggered by code changes. All automation phases must comply with these rules.
+
+## Documentation System
+
+This documentation uses **mdBook** (https://rust-lang.github.io/mdBook/).
+
+### Key Files
+
+- **`docs/src/SUMMARY.md`**: Table of contents following mdBook format (https://rust-lang.github.io/mdBook/format/summary.html)
+- **`docs/book.toml`**: mdBook configuration
+- **`docs/.prettierrc`**: Prettier config (80 char line width)
+
+### SUMMARY.md Format
+
+The `SUMMARY.md` file defines the book structure. Format rules:
+
+- Chapter titles are links: `[Title](./path/to/file.md)`
+- Nesting via indentation (2 spaces per level)
+- Separators: `---` for horizontal rules between sections
+- Draft chapters: `[Title]()` (empty parens, not yet written)
+
+Example:
+
+```markdown
+# Section Title
+
+- [Chapter](./chapter.md)
+ - [Nested Chapter](./nested.md)
+
+---
+
+# Another Section
+```
+
+### Custom Preprocessor
+
+The docs use a custom preprocessor (`docs_preprocessor`) that expands special commands:
+
+| Syntax | Purpose | Example |
+| ----------------------------- | ------------------------------------- | ------------------------------- |
+| `{#kb action::ActionName}` | Keybinding for action | `{#kb agent::ToggleFocus}` |
+| `{#action agent::ActionName}` | Action reference (renders as command) | `{#action agent::OpenSettings}` |
+
+**Rules:**
+
+- Always use preprocessor syntax for keybindings instead of hardcoding
+- Action names use `snake_case` in the namespace, `PascalCase` for the action
+- Common namespaces: `agent::`, `editor::`, `assistant::`, `vim::`
+
+### Formatting Requirements
+
+All documentation must pass **Prettier** formatting:
+
+```sh
+cd docs && npx prettier --check src/
+```
+
+Before any documentation change is considered complete:
+
+1. Run Prettier to format: `cd docs && npx prettier --write src/`
+2. Verify it passes: `cd docs && npx prettier --check src/`
+
+Prettier config: 80 character line width (`docs/.prettierrc`)
+
+### Section Anchors
+
+Use `{#anchor-id}` syntax for linkable section headers:
+
+```markdown
+## Getting Started {#getting-started}
+
+### Custom Models {#anthropic-custom-models}
+```
+
+Anchor IDs should be:
+
+- Lowercase with hyphens
+- Unique within the page
+- Descriptive (can include parent context like `anthropic-custom-models`)
+
+### Code Block Annotations
+
+Use annotations after the language identifier to indicate file context:
+
+```markdown
+\`\`\`json [settings]
+{
+"agent": { ... }
+}
+\`\`\`
+
+\`\`\`json [keymap]
+[
+{ "bindings": { ... } }
+]
+\`\`\`
+```
+
+Valid annotations: `[settings]` (for settings.json), `[keymap]` (for keymap.json)
+
+### Blockquote Formatting
+
+Use bold labels for callouts:
+
+```markdown
+> **Note:** Important information the user should know.
+
+> **Tip:** Helpful advice that saves time or improves workflow.
+
+> **Warn:** Caution about potential issues or gotchas.
+```
+
+### Image References
+
+Images are hosted externally. Reference format:
+
+```markdown
+
+```
+
+### Cross-Linking
+
+- Relative links for same-directory: `[Agent Panel](./agent-panel.md)`
+- With anchors: `[Custom Models](./llm-providers.md#anthropic-custom-models)`
+- Parent directory: `[Telemetry](../telemetry.md)`
+
+## Scope
+
+### In-Scope Documentation
+
+- All Markdown files in `docs/src/`
+- `docs/src/SUMMARY.md` (mdBook table of contents)
+- Language-specific docs in `docs/src/languages/`
+- Feature docs (AI, extensions, configuration, etc.)
+
+### Out-of-Scope (Do Not Modify)
+
+- `CHANGELOG.md`, `CONTRIBUTING.md`, `README.md` at repo root
+- Inline code comments and rustdoc
+- `CLAUDE.md`, `GEMINI.md`, or other AI instruction files
+- Build configuration (`book.toml`, theme files, `docs_preprocessor`)
+- Any file outside `docs/src/`
+
+## Page Structure Patterns
+
+### Standard Page Layout
+
+Most documentation pages follow this structure:
+
+1. **Title** (H1) - Single sentence or phrase
+2. **Overview/Introduction** - 1-3 paragraphs explaining what this is
+3. **Getting Started** `{#getting-started}` - Prerequisites and first steps
+4. **Main Content** - Feature details, organized by topic
+5. **Advanced/Configuration** - Power user options
+6. **See Also** (optional) - Related documentation links
+
+### Settings Documentation Pattern
+
+When documenting settings:
+
+1. Show the Settings Editor (UI) approach first
+2. Then show JSON as "Or add this to your settings.json:"
+3. Always show complete, valid JSON with surrounding structure:
+
+```json [settings]
+{
+ "agent": {
+ "default_model": {
+ "provider": "anthropic",
+ "model": "claude-sonnet-4"
+ }
+ }
+}
+```
+
+### Provider/Feature Documentation Pattern
+
+For each provider or distinct feature:
+
+1. H3 heading with anchor: `### Provider Name {#provider-name}`
+2. Brief description (1-2 sentences)
+3. Setup steps (numbered list)
+4. Configuration example (JSON code block)
+5. Custom models section if applicable: `#### Custom Models {#provider-custom-models}`
+
+## Style Rules
+
+Inherit all conventions from `docs/.rules`. Key points:
+
+### Voice
+
+- Second person ("you"), present tense
+- Direct and concise—no hedging ("simply", "just", "easily")
+- Honest about limitations; no promotional language
+
+### Formatting
+
+- Keybindings: backticks with `+` for simultaneous keys (`Cmd+Shift+P`)
+- Show both macOS and Linux/Windows variants when they differ
+- Use `sh` code blocks for terminal commands
+- Settings: show Settings Editor UI first, JSON as secondary
+
+### Terminology
+
+| Use | Instead of |
+| --------------- | -------------------------------------- |
+| folder | directory |
+| project | workspace |
+| Settings Editor | settings UI |
+| command palette | command bar |
+| panel | sidebar (be specific: "Project Panel") |
+
+## Zed-Specific Conventions
+
+### Recognized Rules Files
+
+When documenting rules/instructions for AI, note that Zed recognizes these files (in priority order):
+
+- `.rules`
+- `.cursorrules`
+- `.windsurfrules`
+- `.clinerules`
+- `.github/copilot-instructions.md`
+- `AGENT.md`
+- `AGENTS.md`
+- `CLAUDE.md`
+- `GEMINI.md`
+
+### Settings File Locations
+
+- macOS: `~/.config/zed/settings.json`
+- Linux: `~/.config/zed/settings.json`
+- Windows: `%AppData%\Zed\settings.json`
+
+### Keymap File Locations
+
+- macOS: `~/.config/zed/keymap.json`
+- Linux: `~/.config/zed/keymap.json`
+- Windows: `%AppData%\Zed\keymap.json`
+
+## Safety Constraints
+
+### Must Not
+
+- Delete existing documentation files
+- Remove sections documenting existing functionality
+- Change URLs or anchor links without verifying references
+- Modify `SUMMARY.md` structure without corresponding content
+- Add speculative documentation for unreleased features
+- Include internal implementation details not relevant to users
+
+### Must
+
+- Preserve existing structure when updating content
+- Maintain backward compatibility of documented settings/commands
+- Flag uncertainty explicitly rather than guessing
+- Link to related documentation when adding new sections
+
+## Change Classification
+
+### Requires Documentation Update
+
+- New user-facing features or commands
+- Changed keybindings or default behaviors
+- Modified settings schema or options
+- Deprecated or removed functionality
+- API changes affecting extensions
+
+### Does Not Require Documentation Update
+
+- Internal refactoring without behavioral changes
+- Performance optimizations (unless user-visible)
+- Bug fixes that restore documented behavior
+- Test changes
+- CI/CD changes
+
+## Output Format
+
+### Phase 4 Documentation Plan
+
+When generating a documentation plan, use this structure:
+
+```markdown
+## Documentation Impact Assessment
+
+### Summary
+
+Brief description of code changes analyzed.
+
+### Documentation Updates Required: [Yes/No]
+
+### Planned Changes
+
+#### 1. [File Path]
+
+- **Section**: [Section name or "New section"]
+- **Change Type**: [Update/Add/Deprecate]
+- **Reason**: Why this change is needed
+- **Description**: What will be added/modified
+
+#### 2. [File Path]
+
+...
+
+### Uncertainty Flags
+
+- [ ] [Description of any assumptions or areas needing confirmation]
+
+### No Changes Needed
+
+- [List files reviewed but not requiring updates, with brief reason]
+```
+
+### Phase 6 Summary Format
+
+```markdown
+## Documentation Update Summary
+
+### Changes Made
+
+| File | Change | Related Code |
+| -------------- | ----------------- | ----------------- |
+| path/to/doc.md | Brief description | link to PR/commit |
+
+### Rationale
+
+Brief explanation of why these updates were made.
+
+### Review Notes
+
+Any items reviewers should pay special attention to.
+```
+
+## Behavioral Guidelines
+
+### Conservative by Default
+
+- When uncertain whether to document something, flag it for human review
+- Prefer smaller, focused updates over broad rewrites
+- Do not "improve" documentation unrelated to the triggering code change
+
+### Traceability
+
+- Every documentation change should trace to a specific code change
+- Include references to relevant commits, PRs, or issues in summaries
+
+### Incremental Updates
+
+- Update existing sections rather than creating parallel documentation
+- Maintain consistency with surrounding content
+- Follow the established patterns in each documentation area
@@ -1585,6 +1585,26 @@ Positive `integer` value between 1 and 32. Values outside of this range will be
`boolean` values
+## Extend List On Newline
+
+- Description: Whether to continue lists when pressing Enter at the end of a list item. Supports unordered, ordered, and task lists. Pressing Enter on an empty list item removes the marker and exits the list.
+- Setting: `extend_list_on_newline`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
+## Indent List On Tab
+
+- Description: Whether to indent list items when pressing Tab on a line containing only a list marker. This enables quick creation of nested lists.
+- Setting: `indent_list_on_tab`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
## Status Bar
- Description: Control various elements in the status bar. Note that some items in the status bar have their own settings set elsewhere.
@@ -33,6 +33,40 @@ Zed supports using Prettier to automatically re-format Markdown documents. You c
},
```
+### List Continuation
+
+Zed automatically continues lists when you press Enter at the end of a list item. Supported list types:
+
+- Unordered lists (`-`, `*`, or `+` markers)
+- Ordered lists (numbers are auto-incremented)
+- Task lists (`- [ ]` and `- [x]`)
+
+Pressing Enter on an empty list item removes the marker and exits the list.
+
+To disable this behavior:
+
+```json [settings]
+ "languages": {
+ "Markdown": {
+ "extend_list_on_newline": false
+ }
+ },
+```
+
+### List Indentation
+
+Zed indents list items when you press Tab while the cursor is on a line containing only a list marker. This allows you to quickly create nested lists.
+
+To disable this behavior:
+
+```json [settings]
+ "languages": {
+ "Markdown": {
+ "indent_list_on_tab": false
+ }
+ },
+```
+
### Trailing Whitespace
By default Zed will remove trailing whitespace on save. If you rely on invisible trailing whitespace being converted to `<br />` in Markdown files you can disable this behavior with: