diff --git a/.factory/prompts/docs-automation/phase2-explore.md b/.factory/prompts/docs-automation/phase2-explore.md new file mode 100644 index 0000000000000000000000000000000000000000..e8f0b1861912f9665d70e42dd02e1bb8a398b01e --- /dev/null +++ b/.factory/prompts/docs-automation/phase2-explore.md @@ -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 diff --git a/.factory/prompts/docs-automation/phase3-analyze.md b/.factory/prompts/docs-automation/phase3-analyze.md new file mode 100644 index 0000000000000000000000000000000000000000..8fc8622434d3be2e6be7997e9773c1b2435202c6 --- /dev/null +++ b/.factory/prompts/docs-automation/phase3-analyze.md @@ -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 diff --git a/.factory/prompts/docs-automation/phase4-plan.md b/.factory/prompts/docs-automation/phase4-plan.md new file mode 100644 index 0000000000000000000000000000000000000000..9e6a15814e7813cbf27afb0413987afa539feaf6 --- /dev/null +++ b/.factory/prompts/docs-automation/phase4-plan.md @@ -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 diff --git a/.factory/prompts/docs-automation/phase5-apply.md b/.factory/prompts/docs-automation/phase5-apply.md new file mode 100644 index 0000000000000000000000000000000000000000..9cc63071fccf880443b729baa06f0ddbd769276b --- /dev/null +++ b/.factory/prompts/docs-automation/phase5-apply.md @@ -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] +``` diff --git a/.factory/prompts/docs-automation/phase6-summarize.md b/.factory/prompts/docs-automation/phase6-summarize.md new file mode 100644 index 0000000000000000000000000000000000000000..b1480ac9431702539fa2a570c2b456bcdfae46af --- /dev/null +++ b/.factory/prompts/docs-automation/phase6-summarize.md @@ -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 diff --git a/.factory/prompts/docs-automation/phase7-commit.md b/.factory/prompts/docs-automation/phase7-commit.md new file mode 100644 index 0000000000000000000000000000000000000000..adfd92eec7d3058af3917f2663228b4f6ee5c445 --- /dev/null +++ b/.factory/prompts/docs-automation/phase7-commit.md @@ -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 +``` diff --git a/.github/workflows/community_close_stale_issues.yml b/.github/workflows/community_close_stale_issues.yml index 113e5ed131d1443c5481ff2966fac6a234561a20..6347b713257f49c02f981774faa0d0359e05e4d3 100644 --- a/.github/workflows/community_close_stale_issues.yml +++ b/.github/workflows/community_close_stale_issues.yml @@ -1,7 +1,7 @@ name: "Close Stale Issues" on: schedule: - - cron: "0 8 31 DEC *" + - cron: "0 2 * * 5" workflow_dispatch: inputs: debug-only: diff --git a/.github/workflows/docs_automation.yml b/.github/workflows/docs_automation.yml new file mode 100644 index 0000000000000000000000000000000000000000..e4aa79c7fc09d6d7735ac82e2315d68b923d5323 --- /dev/null +++ b/.github/workflows/docs_automation.yml @@ -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 diff --git a/assets/settings/default.json b/assets/settings/default.json index 154fe2d6e34e6573e95e7ffedbb46df8bbf10634..746ccb5986d0fd1d5ef11df525303e344a7393d2 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -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, diff --git a/crates/editor/src/code_context_menus.rs b/crates/editor/src/code_context_menus.rs index 96739defc506414f573e2454dc31f9c32d8e4adf..e5520be88e34307220126ebafdba6c6371a5db12 100644 --- a/crates/editor/src/code_context_menus.rs +++ b/crates/editor/src/code_context_menus.rs @@ -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; diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 8560705802264dad55b87dbf21e1f9aa7625edf8..d985a4d269f2eaeb3fa6056192095b7913b579b6 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -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> { 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> { + 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, - 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::>() + }) + .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, + prevent_auto_indent: bool, + }, + /// Clear the current line + ClearCurrentLine, + /// Unindent the current line and add continuation + UnindentCurrentLine { continuation: Arc }, +} + +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, @@ -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, + relative_to: DisplayRow, + count_wrapped_lines: bool, + ) -> HashMap { + 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 { diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index c0112c5eda406c9cb3b3b9d004d20853b710f6e1..613850428a8720ed37efa447a1312c262a05571a 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -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, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 4c3b44335bcad10be4303d545a8d2ad505938098..b2e355dc5158214eabd07d519649591be8a325a8 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -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, - relative_to: Option, - count_wrapped_lines: bool, - ) -> HashMap { - let mut relative_rows: HashMap = 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::>(); - - 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, buffer_rows: &[RowInfo], active_rows: &BTreeMap, - newest_selection_head: Option, + relative_line_base: Option, 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::(&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, window: &mut Window, cx: &mut App, ) -> Option { @@ -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::(&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::>(), &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::>(), &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::>(), &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::>(), &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, ) }) diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index 28fd9442193bbec663d3f72eaa805214375dd8ca..fc2ecb9205109532da2b43c97821b5352f27aff2 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -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; diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 532f9a099a823796706be48ed14cc7da820c5d8b..1323ee014f76ebde42b8dff436b2abed851d13f0 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -5203,7 +5203,7 @@ impl GitPanel { this.child( self.entry_label(path_name, path_color) - .truncate() + .truncate_start() .when(strikethrough, Label::strikethrough), ) }) diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 75600a9ee1b440a092a89456cbe8fbabe6fdccfa..96f815ac0b592600f22b3c9b9686571487ff77a2 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -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 { + 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 { - 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 { + 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. diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 1b1bfd778c7bc746c67551eb31cf70f60b1485ea..770c1f871432afbecc9ffd4e903dfeddcfcba6ee 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -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)) diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 22f4c46921132a7b8badfb7afd4fd38058c638b4..112775890ef6e478f0b2d347bc9c9ae56dac3c73 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -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; fn write_to_clipboard(&self, item: ClipboardItem); + #[cfg(any(target_os = "linux", target_os = "freebsd"))] fn read_from_primary(&self) -> Option; - fn read_from_clipboard(&self) -> Option; + #[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; + #[cfg(target_os = "macos")] + fn write_to_find_pasteboard(&self, item: ClipboardItem); fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task>; fn read_credentials(&self, url: &str) -> Task)>>>; diff --git a/crates/gpui/src/platform/mac.rs b/crates/gpui/src/platform/mac.rs index aa056846e6bc56e53d95c41a44444dbb89a16237..a229ec7dce928597ec73b1f4be50edd1ea3e5114 100644 --- a/crates/gpui/src/platform/mac.rs +++ b/crates/gpui/src/platform/mac.rs @@ -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; diff --git a/crates/gpui/src/platform/mac/attributed_string.rs b/crates/gpui/src/platform/mac/attributed_string.rs deleted file mode 100644 index 42fe1e5bf7a396a4eaa8ade26977a207d43b49b5..0000000000000000000000000000000000000000 --- a/crates/gpui/src/platform/mac/attributed_string.rs +++ /dev/null @@ -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); - } - } -} diff --git a/crates/gpui/src/platform/mac/pasteboard.rs b/crates/gpui/src/platform/mac/pasteboard.rs new file mode 100644 index 0000000000000000000000000000000000000000..38710951f15b25515d906afc738c5b971b1bb135 --- /dev/null +++ b/crates/gpui/src/platform/mac/pasteboard.rs @@ -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 { + // 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 { + 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::::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 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())) + ); + } +} diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index ee67f465e34bd8109246f68b311e225aa8f9fd0a..9b32c6735bf6215fecc0455defc4237fd25e8cb0 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -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, 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>, on_keyboard_layout_change: Option>, quit: Option>, @@ -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, @@ -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 { 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 { + 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> { @@ -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::::into(image.format).inner_mut()); - } - } -} - -fn try_clipboard_image(pasteboard: id, format: ImageFormat) -> Option { - 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 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 - } -} diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index dfada364667989792325e02f8530e6c91bdf4716..ca9d5e2c3b7d405e40f208f5406f879467eafc5c 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -32,6 +32,8 @@ pub(crate) struct TestPlatform { current_clipboard_item: Mutex>, #[cfg(any(target_os = "linux", target_os = "freebsd"))] current_primary_item: Mutex>, + #[cfg(target_os = "macos")] + current_find_pasteboard_item: Mutex>, pub(crate) prompts: RefCell, screen_capture_sources: RefCell>, pub opened_url: RefCell>, @@ -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 { + 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 { - 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 { + 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> { diff --git a/crates/gpui/src/queue.rs b/crates/gpui/src/queue.rs index 9e9da710977ee80df1853791918eebe5e7f01096..1ecec183c6c58a86e305343f7bcd1056cda7a581 100644 --- a/crates/gpui/src/queue.rs +++ b/crates/gpui/src/queue.rs @@ -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 { - high_priority: Vec, - medium_priority: Vec, - low_priority: Vec, + high_priority: VecDeque, + medium_priority: VecDeque, + low_priority: VecDeque, } impl PriorityQueues { @@ -42,9 +43,9 @@ impl PriorityQueueState { 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 PriorityQueueReceiver { pub(crate) fn new() -> (PriorityQueueSender, 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 PriorityQueueReceiver { 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 PriorityQueueReceiver { 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 PriorityQueueReceiver { 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()); } } diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 4d6e6f490d81d967692a3e9d8316af75a7a4d306..7481b8001e5752599b90625450d7adb0c66ea2ca 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -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 diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index e8088a84d7fc141d0a320988c6399afe2b93ce07..c5eef0d4496edea4d30c665c82dc0a9f00bb83be 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -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); diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index 95cd55d04443c6b2c351bf8533ccb57d49e8dcd9..457316f353a48fa112de1736b2b7eaa2d4c72313 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -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, @@ -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 { 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) { +fn update_runs_after_truncation( + result: &str, + ellipsis: &str, + runs: &mut Vec, + 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); } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 2ccd7edac86bced89048cbe5dbf196d8fbcf95f3..8df421feb968677be0abbb642a7127871881bcf3 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -876,7 +876,9 @@ pub struct Window { active: Rc>, hovered: Rc>, pub(crate) needs_present: Rc>, - pub(crate) last_input_timestamp: Rc>, + /// 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>, 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, + 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>> = 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, diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index a573e3d78a4de03c6ccf382c80bc33eaf0b5690d..290cad4e4497015ef63f79e58a0dacf231168c9f 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -827,6 +827,15 @@ pub struct LanguageConfig { /// Delimiters and configuration for recognizing and formatting documentation comments. #[serde(default, alias = "documentation")] pub documentation_comment: Option, + /// List markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `). + #[serde(default)] + pub unordered_list: Vec>, + /// Configuration for ordered lists with auto-incrementing numbers on newline (e.g., `1. ` becomes `2. `). + #[serde(default)] + pub ordered_list: Vec, + /// Configuration for task lists where multiple markers map to a single continuation prefix (e.g., `- [x] ` continues as `- [ ] `). + #[serde(default)] + pub task_list: Option, /// 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, } +/// 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>, + /// The marker to insert when continuing the list on a new line (e.g., `- [ ] `). + pub continuation: Arc, +} + #[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] { + &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. /// diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index fccaa545b79c1f24589889df8fcd163fbc5b6c7d..205f2431c6d9deeaa7661b583caa516bdc77ae79 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -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(), diff --git a/crates/language/src/toolchain.rs b/crates/language/src/toolchain.rs index 5717ffb5143e38bce736c354b43febc86e321f32..815ece30a1ed46ae65ec4af2ba64501ff3489718 100644 --- a/crates/language/src/toolchain.rs +++ b/crates/language/src/toolchain.rs @@ -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), + Subproject(Arc, Arc), Project, /// Available in all projects on this box. It wouldn't make sense to show suggestions across machines. Global, diff --git a/crates/languages/src/markdown/config.toml b/crates/languages/src/markdown/config.toml index 84c79d2538a0af470ec16d55fe9cf2d1ae05805b..423a4c008f6e8a64f3c4e883b0d6e2bde65c88ae 100644 --- a/crates/languages/src/markdown/config.toml +++ b/crates/languages/src/markdown/config.toml @@ -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 diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 5e31f2a90cf137f1e4d788952832e1eb2ee0ec35..25a19788fdb464f5f289ef3bc3513f21743e3a9a 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -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( diff --git a/crates/project/src/toolchain_store.rs b/crates/project/src/toolchain_store.rs index 21b74bd784d1d9af12fe43e3fe82051afc103b0d..7afc70827f85e1a1bafcad436409936876fd3b45 100644 --- a/crates/project/src/toolchain_store.rs +++ b/crates/project/src/toolchain_store.rs @@ -32,6 +32,7 @@ use crate::{ pub struct ToolchainStore { mode: ToolchainStoreInner, user_toolchains: BTreeMap>, + worktree_store: Entity, _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 { + pub(super) fn remote( + project_id: u64, + worktree_store: Entity, + client: AnyProtoClient, + cx: &mut Context, + ) -> 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, ) -> Task> { + 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 } diff --git a/crates/project/src/x.py b/crates/project/src/x.py new file mode 100644 index 0000000000000000000000000000000000000000..58947a58a41bcc2e2f8c2046b5e1c8c38c0fbbb8 --- /dev/null +++ b/crates/project/src/x.py @@ -0,0 +1 @@ +Gliwice makerspace \ No newline at end of file diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index 6c8eb49c1c2158322a275e064162b53e2f5f3d5e..d13e1c4934947e39b08e05eb32e2787548e621e1 100644 --- a/crates/remote/src/transport/ssh.rs +++ b/crates/remote/src/transport/ssh.rs @@ -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" ] ); diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 12b283ab22937b7952d18d63b1378d2914211f9b..be3331048bc78a91a8d3c5a3637d6bf6ea007e4d 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -106,7 +106,10 @@ pub struct BufferSearchBar { replacement_editor_focused: bool, active_searchable_item: Option>, active_match_index: Option, - active_searchable_item_subscription: Option, + #[cfg(target_os = "macos")] + active_searchable_item_subscriptions: Option<[Subscription; 2]>, + #[cfg(not(target_os = "macos"))] + active_searchable_item_subscriptions: Option, active_search: Option>, searchable_items_with_matches: HashMap, AnyVec>, pending_search: Option>, @@ -472,7 +475,7 @@ impl ToolbarItemView for BufferSearchBar { cx: &mut Context, ) -> 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) { 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); diff --git a/crates/settings/src/settings_content/language.rs b/crates/settings/src/settings_content/language.rs index f9c85f18f380a7ad82b0d8bc202fe3763ba3a832..cf8cf7b63589e84a96e6b9d92f23a4488479d1f3 100644 --- a/crates/settings/src/settings_content/language.rs +++ b/crates/settings/src/settings_content/language.rs @@ -363,6 +363,14 @@ pub struct LanguageSettingsContent { /// /// Default: true pub extend_comment_on_newline: Option, + /// Whether to continue markdown lists when pressing enter. + /// + /// Default: true + pub extend_list_on_newline: Option, + /// Whether to indent list items when pressing tab after a list marker. + /// + /// Default: true + pub indent_list_on_tab: Option, /// Inlay hint related settings. pub inlay_hints: Option, /// Whether to automatically type closing characters for you. For example, diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index d77754f611e8eb1746ee9061ce5b5e1dfdbdafdb..64343b05fd57c33eb9cfb0d8cb8674971266b464 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -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 diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index ed43d94e9d3d7c08c1ff4570e08726310360cd93..738a0b4502642423377bdf69b49d26250536761f 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -939,7 +939,6 @@ impl TerminalPanel { cx: &mut Context, ) -> Task>> { 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 => { diff --git a/crates/title_bar/build.rs b/crates/title_bar/build.rs new file mode 100644 index 0000000000000000000000000000000000000000..ef70268ad3127baf113824348cb3e8685392a52b --- /dev/null +++ b/crates/title_bar/build.rs @@ -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 = 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"); + } + } +} diff --git a/crates/title_bar/src/platforms/platform_mac.rs b/crates/title_bar/src/platforms/platform_mac.rs index c7becde6c1af48bf37e06c0d2dcf991ad3c9f19f..5e8e4e5087054e59f66527915ae97e352a9ff525 100644 --- a/crates/title_bar/src/platforms/platform_mac.rs +++ b/crates/title_bar/src/platforms/platform_mac.rs @@ -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.; diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 23572677919509d859a141cb09cce8f5822697ef..9b75d35eccafa3c30f23329d9c0ee890ed2b2405 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -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) -> Option { + fn render_remote_project_connection( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + 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) -> Option { + pub fn render_project_host( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { 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) -> impl IntoElement { + pub fn render_project_name( + &self, + window: &mut Window, + cx: &mut Context, + ) -> 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) -> Option { - let settings = TitleBarSettings::get_global(cx); + pub fn render_project_repo( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option { 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::() - }) - })?; - 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::() + }) + }); + + 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) -> 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) -> bool { + self.workspace + .update(cx, |workspace, cx| workspace.has_active_modal(window, cx)) + .unwrap_or(false) + } } diff --git a/crates/toolchain_selector/src/active_toolchain.rs b/crates/toolchain_selector/src/active_toolchain.rs index 03c152e3fd3df0c62ab2f5c7e4a4746875ac955a..06f7d1cdf3e27f43bdb5013038b943b9e5193680 100644 --- a/crates/toolchain_selector/src/active_toolchain.rs +++ b/crates/toolchain_selector/src/active_toolchain.rs @@ -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(), ) diff --git a/crates/toolchain_selector/src/toolchain_selector.rs b/crates/toolchain_selector/src/toolchain_selector.rs index f7262c248f15f0f68fcd7a903ee01cac6b22d0af..36ef2b960a8abfe684628cea465b68e6eab5e463 100644 --- a/crates/toolchain_selector/src/toolchain_selector.rs +++ b/crates/toolchain_selector/src/toolchain_selector.rs @@ -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, + worktree_root_path: Arc, } struct ScopePickerState { @@ -99,12 +101,17 @@ impl AddToolchainState { root_path: ProjectPath, window: &mut Window, cx: &mut Context, - ) -> Entity { + ) -> anyhow::Result> { 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, ) { 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 diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index 49e2de94a1f86196c10e41879797b02070517e65..d0f50c00336eb971621e2da7bbaf53cf09569caa 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -56,6 +56,12 @@ impl Label { pub fn set_text(&mut self, text: impl Into) { 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()), ], ), ]) diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index 31fb7bfd88f1343ac6145c86f228bdcbd6a22e10..03fde4083d5e9a8e07f38c830edd5116f14e6d70 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -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( diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 205097130d152fe255feb02a449956124586d8e6..2228c23f02beb954bdb26b2b36f078249e423d7d 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -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, + 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::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::(&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::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::().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; diff --git a/crates/workspace/src/modal_layer.rs b/crates/workspace/src/modal_layer.rs index 58667e7ffa8ad4fe5a22d293e4fc4aa71015a3bd..4087e1a398ac2b89257fea6b4dce53278d0872a8 100644 --- a/crates/workspace/src/modal_layer.rs +++ b/crates/workspace/src/modal_layer.rs @@ -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, + }, +} + #[derive(Debug)] pub enum DismissDecision { Dismiss(bool), @@ -58,6 +67,7 @@ pub struct ActiveModal { _subscriptions: [Subscription; 2], previous_focus_handle: Option, focus_handle: FocusHandle, + placement: ModalPlacement, } pub struct ModalLayer { @@ -87,6 +97,19 @@ impl ModalLayer { where V: ModalView, B: FnOnce(&mut Window, &mut Context) -> V, + { + self.toggle_modal_with_placement(window, cx, ModalPlacement::Centered, build_view); + } + + pub fn toggle_modal_with_placement( + &mut self, + window: &mut Window, + cx: &mut Context, + placement: ModalPlacement, + build_view: B, + ) where + V: ModalView, + B: FnOnce(&mut Window, &mut Context) -> V, { if let Some(active_modal) = &self.active_modal { let is_close = active_modal.modal.view().downcast::().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(&mut self, new_modal: Entity, window: &mut Window, cx: &mut Context) - where + fn show_modal( + &mut self, + new_modal: Entity, + placement: ModalPlacement, + window: &mut Window, + cx: &mut Context, + ) 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() } } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index f6256aee46b9e2b5c29c020e9ee12f6ff510210f..dd17c338a935571f4d0fe9d46b3b10fac9ffe218 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -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] diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 094d03494e726677dc43235d96fc62c076673bf5..8d20339ec952020416e4b8d5846bf44f5f8e9b98 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -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, ) -> BTreeMap> { - type RowKind = (WorkspaceId, u64, String, String, String, String, String); + type RowKind = (WorkspaceId, String, String, String, String, String, String); let toolchains: Vec = 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)>> { + ) -> Result, Arc)>> { 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, relative_worktree_path: Arc, 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(), diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 0c5c9ffa5d0bfb1f70ce6a861b0209f321222fc0..fa8e3a3dc2af33054907ea8a8c1ba095a3259207 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1204,6 +1204,7 @@ pub struct Workspace { last_open_dock_positions: Vec, removing: bool, utility_panes: UtilityPaneState, + next_modal_placement: Option, } impl EventEmitter 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.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) { + fn active_item_path_changed( + &mut self, + focus_changed: bool, + window: &mut Window, + cx: &mut Context, + ) { 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(&self, cx: &App) -> bool { + self.modal_layer.read(cx).active_modal::().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(&mut self, window: &mut Window, cx: &mut App, build: B) where B: FnOnce(&mut Window, &mut Context) -> 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>>> { 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); }); } diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..fdd61ff6aeaf8cd09ae0b017c5199e7033fba964 --- /dev/null +++ b/docs/AGENTS.md @@ -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 +![Alt text description](https://zed.dev/img/path/to/image.webp) +``` + +### 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 diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 8a638d9f7857e1a55aaa5589a77110a7b803bbfe..81318aa8885fe883acc394e7fe983d7721dd33a5 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -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. diff --git a/docs/src/languages/markdown.md b/docs/src/languages/markdown.md index 36ce734f7cfbcc066bb8026568209738655a6be9..64c9e7070569a23daa5bcb8aa4dace12e0021b03 100644 --- a/docs/src/languages/markdown.md +++ b/docs/src/languages/markdown.md @@ -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 `
` in Markdown files you can disable this behavior with: