Compare commits
68 Commits
vConradTes
...
git-panel-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb4067723b | ||
|
|
1c625f8783 | ||
|
|
4adec27a3d | ||
|
|
e8daab15ab | ||
|
|
6501b0c311 | ||
|
|
6c0069ca98 | ||
|
|
c8930e07a3 | ||
|
|
ab352f669e | ||
|
|
e79188261b | ||
|
|
ab62739605 | ||
|
|
cfbde91833 | ||
|
|
80b32ddaad | ||
|
|
53652cdb3f | ||
|
|
1d75a9c4b2 | ||
|
|
c5ab1d4679 | ||
|
|
1fdd95a9b3 | ||
|
|
49634f6041 | ||
|
|
2119ac42d7 | ||
|
|
e833d1af8d | ||
|
|
7be76c74d6 | ||
|
|
c2980cba18 | ||
|
|
a0be53a190 | ||
|
|
70feff3c7a | ||
|
|
f46990bac8 | ||
|
|
78f466559a | ||
|
|
4f158c1983 | ||
|
|
ddf762e368 | ||
|
|
f2cadad49a | ||
|
|
231d1b1d58 | ||
|
|
2bcfc12951 | ||
|
|
cf6ae01d07 | ||
|
|
2ad7ecbcf0 | ||
|
|
854c6873c7 | ||
|
|
da94f898e6 | ||
|
|
f62bfe1dfa | ||
|
|
a56693d9e8 | ||
|
|
b4b7a23c39 | ||
|
|
0d56ed7d91 | ||
|
|
e01e0b83c4 | ||
|
|
908ef03502 | ||
|
|
5f4d0dbaab | ||
|
|
c50f821613 | ||
|
|
7e491ac500 | ||
|
|
9e1e732db8 | ||
|
|
83351283e4 | ||
|
|
03acbb7de3 | ||
|
|
0268b17096 | ||
|
|
993919d360 | ||
|
|
8467a3dbd6 | ||
|
|
ee2e690657 | ||
|
|
28d019be2e | ||
|
|
a19d11184d | ||
|
|
38e2c7aa66 | ||
|
|
10d5d78ded | ||
|
|
dfd7e85d5d | ||
|
|
b8fcd3ea04 | ||
|
|
9be5e31aca | ||
|
|
58db38722b | ||
|
|
f2ad0d716f | ||
|
|
777b46533f | ||
|
|
b3dd51560b | ||
|
|
25489c2b7a | ||
|
|
dc372e8a84 | ||
|
|
1c4bb60209 | ||
|
|
97100ce52f | ||
|
|
dcf56144b5 | ||
|
|
46db753f79 | ||
|
|
1a807a7a6a |
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +1,4 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json
|
||||
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-config.json
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature Request
|
||||
|
||||
@@ -15,13 +15,13 @@ jobs:
|
||||
stale-issue-message: >
|
||||
Hi there! 👋
|
||||
|
||||
We're working to clean up our issue tracker by closing older issues that might not be relevant anymore. If you are able to reproduce this issue in the latest version of Zed, please let us know by commenting on this issue, and we will keep it open. If you can't reproduce it, feel free to close the issue yourself. Otherwise, we'll close it in 7 days.
|
||||
We're working to clean up our issue tracker by closing older bugs that might not be relevant anymore. If you are able to reproduce this issue in the latest version of Zed, please let us know by commenting on this issue, and it will be kept open. If you can't reproduce it, feel free to close the issue yourself. Otherwise, it will close automatically in 14 days.
|
||||
|
||||
Thanks for your help!
|
||||
close-issue-message: "This issue was closed due to inactivity. If you're still experiencing this problem, please open a new issue with a link to this issue."
|
||||
days-before-stale: 120
|
||||
days-before-close: 7
|
||||
any-of-issue-labels: "bug,panic / crash"
|
||||
days-before-stale: 60
|
||||
days-before-close: 14
|
||||
only-issue-types: "Bug,Crash"
|
||||
operations-per-run: 1000
|
||||
ascending: true
|
||||
enable-statistics: true
|
||||
|
||||
8
.github/workflows/run_agent_evals.yml
vendored
8
.github/workflows/run_agent_evals.yml
vendored
@@ -6,6 +6,9 @@ env:
|
||||
CARGO_INCREMENTAL: '0'
|
||||
RUST_BACKTRACE: '1'
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }}
|
||||
GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }}
|
||||
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
|
||||
ZED_EVAL_TELEMETRY: '1'
|
||||
MODEL_NAME: ${{ inputs.model_name }}
|
||||
@@ -48,6 +51,11 @@ jobs:
|
||||
- name: run_agent_evals::agent_evals::run_eval
|
||||
run: cargo run --package=eval -- --repetitions=8 --concurrency=1 --model "${MODEL_NAME}"
|
||||
shell: bash -euxo pipefail {0}
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }}
|
||||
GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }}
|
||||
- name: steps::cleanup_cargo_config
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
69
.github/workflows/run_cron_unit_evals.yml
vendored
Normal file
69
.github/workflows/run_cron_unit_evals.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
# Generated from xtask::workflows::run_cron_unit_evals
|
||||
# Rebuild with `cargo xtask workflows`.
|
||||
name: run_cron_unit_evals
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: '0'
|
||||
RUST_BACKTRACE: '1'
|
||||
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
|
||||
on:
|
||||
schedule:
|
||||
- cron: 47 1 * * 2
|
||||
workflow_dispatch: {}
|
||||
jobs:
|
||||
cron_unit_evals:
|
||||
runs-on: namespace-profile-16x32-ubuntu-2204
|
||||
steps:
|
||||
- name: steps::checkout_repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
with:
|
||||
clean: false
|
||||
- name: steps::setup_cargo_config
|
||||
run: |
|
||||
mkdir -p ./../.cargo
|
||||
cp ./.cargo/ci-config.toml ./../.cargo/config.toml
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: steps::cache_rust_dependencies_namespace
|
||||
uses: namespacelabs/nscloud-cache-action@v1
|
||||
with:
|
||||
cache: rust
|
||||
- name: steps::setup_linux
|
||||
run: ./script/linux
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: steps::install_mold
|
||||
run: ./script/install-mold
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: steps::download_wasi_sdk
|
||||
run: ./script/download-wasi-sdk
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: steps::cargo_install_nextest
|
||||
run: cargo install cargo-nextest --locked
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: steps::clear_target_dir_if_large
|
||||
run: ./script/clear-target-dir-if-larger-than 250
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: ./script/run-unit-evals
|
||||
run: ./script/run-unit-evals
|
||||
shell: bash -euxo pipefail {0}
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }}
|
||||
GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }}
|
||||
- name: steps::cleanup_cargo_config
|
||||
if: always()
|
||||
run: |
|
||||
rm -rf ./../.cargo
|
||||
shell: bash -euxo pipefail {0}
|
||||
- name: run_agent_evals::cron_unit_evals::send_failure_to_slack
|
||||
if: ${{ failure() }}
|
||||
uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: C04UDRNNJFQ
|
||||
text: "Unit Evals Failed: https://github.com/zed-industries/zed/actions/runs/${{ github.run_id }}"
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.ref_name == 'main' && github.sha || 'anysha' }}
|
||||
cancel-in-progress: true
|
||||
34
.github/workflows/run_unit_evals.yml
vendored
34
.github/workflows/run_unit_evals.yml
vendored
@@ -1,17 +1,26 @@
|
||||
# Generated from xtask::workflows::run_agent_evals
|
||||
# Generated from xtask::workflows::run_unit_evals
|
||||
# Rebuild with `cargo xtask workflows`.
|
||||
name: run_agent_evals
|
||||
name: run_unit_evals
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: '0'
|
||||
RUST_BACKTRACE: '1'
|
||||
ZED_CLIENT_CHECKSUM_SEED: ${{ secrets.ZED_CLIENT_CHECKSUM_SEED }}
|
||||
ZED_EVAL_TELEMETRY: '1'
|
||||
MODEL_NAME: ${{ inputs.model_name }}
|
||||
on:
|
||||
schedule:
|
||||
- cron: 47 1 * * 2
|
||||
workflow_dispatch: {}
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
model_name:
|
||||
description: model_name
|
||||
required: true
|
||||
type: string
|
||||
commit_sha:
|
||||
description: commit_sha
|
||||
required: true
|
||||
type: string
|
||||
jobs:
|
||||
unit_evals:
|
||||
run_unit_evals:
|
||||
runs-on: namespace-profile-16x32-ubuntu-2204
|
||||
steps:
|
||||
- name: steps::checkout_repo
|
||||
@@ -47,15 +56,10 @@ jobs:
|
||||
shell: bash -euxo pipefail {0}
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
- name: run_agent_evals::unit_evals::send_failure_to_slack
|
||||
if: ${{ failure() }}
|
||||
uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: C04UDRNNJFQ
|
||||
text: "Unit Evals Failed: https://github.com/zed-industries/zed/actions/runs/${{ github.run_id }}"
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }}
|
||||
GOOGLE_CLOUD_PROJECT: ${{ secrets.GOOGLE_CLOUD_PROJECT }}
|
||||
UNIT_EVAL_COMMIT: ${{ inputs.commit_sha }}
|
||||
- name: steps::cleanup_cargo_config
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
37
Cargo.lock
generated
37
Cargo.lock
generated
@@ -6248,7 +6248,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"nanorand",
|
||||
"spin",
|
||||
"spin 0.9.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6359,9 +6359,9 @@ checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "fork"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05dc8b302e04a1c27f4fe694439ef0f29779ca4edc205b7b58f00db04e29656d"
|
||||
checksum = "30268f1eefccc9d72f43692e8b89e659aeb52e84016c3b32b6e7e9f1c8f38f94"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -7287,6 +7287,7 @@ dependencies = [
|
||||
"calloop",
|
||||
"calloop-wayland-source",
|
||||
"cbindgen",
|
||||
"circular-buffer",
|
||||
"cocoa 0.26.0",
|
||||
"cocoa-foundation 0.2.0",
|
||||
"collections",
|
||||
@@ -7342,6 +7343,7 @@ dependencies = [
|
||||
"slotmap",
|
||||
"smallvec",
|
||||
"smol",
|
||||
"spin 0.10.0",
|
||||
"stacksafe",
|
||||
"strum 0.27.2",
|
||||
"sum_tree",
|
||||
@@ -9072,7 +9074,7 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
dependencies = [
|
||||
"spin",
|
||||
"spin 0.9.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10014,6 +10016,18 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniprofiler_ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"gpui",
|
||||
"serde_json",
|
||||
"smol",
|
||||
"util",
|
||||
"workspace",
|
||||
"zed_actions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -13078,6 +13092,7 @@ dependencies = [
|
||||
"settings",
|
||||
"smallvec",
|
||||
"telemetry",
|
||||
"tempfile",
|
||||
"theme",
|
||||
"ui",
|
||||
"util",
|
||||
@@ -15853,6 +15868,15 @@ dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spirv"
|
||||
version = "0.3.0+sdk-1.3.268.0"
|
||||
@@ -21146,7 +21170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "0.213.0"
|
||||
version = "0.214.0"
|
||||
dependencies = [
|
||||
"acp_tools",
|
||||
"activity_indicator",
|
||||
@@ -21164,6 +21188,7 @@ dependencies = [
|
||||
"breadcrumbs",
|
||||
"call",
|
||||
"channel",
|
||||
"chrono",
|
||||
"clap",
|
||||
"cli",
|
||||
"client",
|
||||
@@ -21221,6 +21246,7 @@ dependencies = [
|
||||
"menu",
|
||||
"migrator",
|
||||
"mimalloc",
|
||||
"miniprofiler_ui",
|
||||
"nc",
|
||||
"nix 0.29.0",
|
||||
"node_runtime",
|
||||
@@ -21693,6 +21719,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"settings",
|
||||
"smol",
|
||||
"strsim",
|
||||
"thiserror 2.0.17",
|
||||
"util",
|
||||
"uuid",
|
||||
|
||||
@@ -110,6 +110,7 @@ members = [
|
||||
"crates/menu",
|
||||
"crates/migrator",
|
||||
"crates/mistral",
|
||||
"crates/miniprofiler_ui",
|
||||
"crates/multi_buffer",
|
||||
"crates/nc",
|
||||
"crates/net",
|
||||
@@ -341,6 +342,7 @@ menu = { path = "crates/menu" }
|
||||
migrator = { path = "crates/migrator" }
|
||||
mistral = { path = "crates/mistral" }
|
||||
multi_buffer = { path = "crates/multi_buffer" }
|
||||
miniprofiler_ui = { path = "crates/miniprofiler_ui" }
|
||||
nc = { path = "crates/nc" }
|
||||
net = { path = "crates/net" }
|
||||
node_runtime = { path = "crates/node_runtime" }
|
||||
@@ -504,7 +506,7 @@ emojis = "0.6.1"
|
||||
env_logger = "0.11"
|
||||
exec = "0.3.1"
|
||||
fancy-regex = "0.14.0"
|
||||
fork = "0.2.0"
|
||||
fork = "0.4.0"
|
||||
futures = "0.3"
|
||||
futures-batch = "0.6.1"
|
||||
futures-lite = "1.13"
|
||||
|
||||
4
assets/icons/at_sign.svg
Normal file
4
assets/icons/at_sign.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.00156 10.3996C9.32705 10.3996 10.4016 9.32509 10.4016 7.99961C10.4016 6.67413 9.32705 5.59961 8.00156 5.59961C6.67608 5.59961 5.60156 6.67413 5.60156 7.99961C5.60156 9.32509 6.67608 10.3996 8.00156 10.3996Z" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.4 5.6V8.6C10.4 9.07739 10.5896 9.53523 10.9272 9.8728C11.2648 10.2104 11.7226 10.4 12.2 10.4C12.6774 10.4 13.1352 10.2104 13.4728 9.8728C13.8104 9.53523 14 9.07739 14 8.6V8C14 6.64839 13.5436 5.33636 12.7048 4.27651C11.8661 3.21665 10.694 2.47105 9.37852 2.16051C8.06306 1.84997 6.68129 1.99269 5.45707 2.56554C4.23285 3.13838 3.23791 4.1078 2.63344 5.31672C2.02898 6.52565 1.85041 7.90325 2.12667 9.22633C2.40292 10.5494 3.11782 11.7405 4.15552 12.6065C5.19323 13.4726 6.49295 13.9629 7.84411 13.998C9.19527 14.0331 10.5187 13.611 11.6 12.8" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -616,9 +616,13 @@
|
||||
"search": {
|
||||
// Whether to show the project search button in the status bar.
|
||||
"button": true,
|
||||
// Whether to only match on whole words.
|
||||
"whole_word": false,
|
||||
// Whether to match case sensitively.
|
||||
"case_sensitive": false,
|
||||
// Whether to include gitignored files in search results.
|
||||
"include_ignored": false,
|
||||
// Whether to interpret the search query as a regular expression.
|
||||
"regex": false,
|
||||
// Whether to center the cursor on each search match when navigating.
|
||||
"center_on_match": false
|
||||
@@ -744,8 +748,15 @@
|
||||
"hide_root": false,
|
||||
// Whether to hide the hidden entries in the project panel.
|
||||
"hide_hidden": false,
|
||||
// Whether to automatically open files when pasting them in the project panel.
|
||||
"open_file_on_paste": true
|
||||
// Settings for automatically opening files.
|
||||
"auto_open": {
|
||||
// Whether to automatically open newly created files in the editor.
|
||||
"on_create": true,
|
||||
// Whether to automatically open files after pasting or duplicating them.
|
||||
"on_paste": true,
|
||||
// Whether to automatically open files dropped from external sources.
|
||||
"on_drop": true
|
||||
}
|
||||
},
|
||||
"outline_panel": {
|
||||
// Whether to show the outline panel button in the status bar
|
||||
@@ -1539,6 +1550,8 @@
|
||||
// Default: 10_000, maximum: 100_000 (all bigger values set will be treated as 100_000), 0 disables the scrolling.
|
||||
// Existing terminals will not pick up this change until they are recreated.
|
||||
"max_scroll_history_lines": 10000,
|
||||
// The multiplier for scrolling speed in the terminal.
|
||||
"scroll_multiplier": 1.0,
|
||||
// The minimum APCA perceptual contrast between foreground and background colors.
|
||||
// APCA (Accessible Perceptual Contrast Algorithm) is more accurate than WCAG 2.x,
|
||||
// especially for dark mode. Values range from 0 to 106.
|
||||
|
||||
@@ -1866,10 +1866,14 @@ impl AcpThread {
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
.map(|c| c.git_checkpoint.clone());
|
||||
|
||||
// Cancel any in-progress generation before restoring
|
||||
let cancel_task = self.cancel(cx);
|
||||
let rewind = self.rewind(id.clone(), cx);
|
||||
let git_store = self.project.read(cx).git_store().clone();
|
||||
|
||||
cx.spawn(async move |_, cx| {
|
||||
cancel_task.await;
|
||||
rewind.await?;
|
||||
if let Some(checkpoint) = checkpoint {
|
||||
git_store
|
||||
@@ -1894,9 +1898,25 @@ impl AcpThread {
|
||||
cx.update(|cx| truncate.run(id.clone(), cx))?.await?;
|
||||
this.update(cx, |this, cx| {
|
||||
if let Some((ix, _)) = this.user_message_mut(&id) {
|
||||
// Collect all terminals from entries that will be removed
|
||||
let terminals_to_remove: Vec<acp::TerminalId> = this.entries[ix..]
|
||||
.iter()
|
||||
.flat_map(|entry| entry.terminals())
|
||||
.filter_map(|terminal| terminal.read(cx).id().clone().into())
|
||||
.collect();
|
||||
|
||||
let range = ix..this.entries.len();
|
||||
this.entries.truncate(ix);
|
||||
cx.emit(AcpThreadEvent::EntriesRemoved(range));
|
||||
|
||||
// Kill and remove the terminals
|
||||
for terminal_id in terminals_to_remove {
|
||||
if let Some(terminal) = this.terminals.remove(&terminal_id) {
|
||||
terminal.update(cx, |terminal, cx| {
|
||||
terminal.kill(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
this.action_log().update(cx, |action_log, cx| {
|
||||
action_log.reject_all_edits(Some(telemetry), cx)
|
||||
@@ -3803,4 +3823,314 @@ mod tests {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Tests that restoring a checkpoint properly cleans up terminals that were
|
||||
/// created after that checkpoint, and cancels any in-progress generation.
|
||||
///
|
||||
/// Reproduces issue #35142: When a checkpoint is restored, any terminal processes
|
||||
/// that were started after that checkpoint should be terminated, and any in-progress
|
||||
/// AI generation should be canceled.
|
||||
#[gpui::test]
|
||||
async fn test_restore_checkpoint_kills_terminal(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
let project = Project::test(fs, [], cx).await;
|
||||
let connection = Rc::new(FakeAgentConnection::new());
|
||||
let thread = cx
|
||||
.update(|cx| connection.new_thread(project, Path::new(path!("/test")), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send first user message to create a checkpoint
|
||||
cx.update(|cx| {
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.send(vec!["first message".into()], cx)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send second message (creates another checkpoint) - we'll restore to this one
|
||||
cx.update(|cx| {
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.send(vec!["second message".into()], cx)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create 2 terminals BEFORE the checkpoint that have completed running
|
||||
let terminal_id_1 = acp::TerminalId(uuid::Uuid::new_v4().to_string().into());
|
||||
let mock_terminal_1 = cx.new(|cx| {
|
||||
let builder = ::terminal::TerminalBuilder::new_display_only(
|
||||
::terminal::terminal_settings::CursorShape::default(),
|
||||
::terminal::terminal_settings::AlternateScroll::On,
|
||||
None,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
builder.subscribe(cx)
|
||||
});
|
||||
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.on_terminal_provider_event(
|
||||
TerminalProviderEvent::Created {
|
||||
terminal_id: terminal_id_1.clone(),
|
||||
label: "echo 'first'".to_string(),
|
||||
cwd: Some(PathBuf::from("/test")),
|
||||
output_byte_limit: None,
|
||||
terminal: mock_terminal_1.clone(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.on_terminal_provider_event(
|
||||
TerminalProviderEvent::Output {
|
||||
terminal_id: terminal_id_1.clone(),
|
||||
data: b"first\n".to_vec(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.on_terminal_provider_event(
|
||||
TerminalProviderEvent::Exit {
|
||||
terminal_id: terminal_id_1.clone(),
|
||||
status: acp::TerminalExitStatus {
|
||||
exit_code: Some(0),
|
||||
signal: None,
|
||||
meta: None,
|
||||
},
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
let terminal_id_2 = acp::TerminalId(uuid::Uuid::new_v4().to_string().into());
|
||||
let mock_terminal_2 = cx.new(|cx| {
|
||||
let builder = ::terminal::TerminalBuilder::new_display_only(
|
||||
::terminal::terminal_settings::CursorShape::default(),
|
||||
::terminal::terminal_settings::AlternateScroll::On,
|
||||
None,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
builder.subscribe(cx)
|
||||
});
|
||||
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.on_terminal_provider_event(
|
||||
TerminalProviderEvent::Created {
|
||||
terminal_id: terminal_id_2.clone(),
|
||||
label: "echo 'second'".to_string(),
|
||||
cwd: Some(PathBuf::from("/test")),
|
||||
output_byte_limit: None,
|
||||
terminal: mock_terminal_2.clone(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.on_terminal_provider_event(
|
||||
TerminalProviderEvent::Output {
|
||||
terminal_id: terminal_id_2.clone(),
|
||||
data: b"second\n".to_vec(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.on_terminal_provider_event(
|
||||
TerminalProviderEvent::Exit {
|
||||
terminal_id: terminal_id_2.clone(),
|
||||
status: acp::TerminalExitStatus {
|
||||
exit_code: Some(0),
|
||||
signal: None,
|
||||
meta: None,
|
||||
},
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
// Get the second message ID to restore to
|
||||
let second_message_id = thread.read_with(cx, |thread, _| {
|
||||
// At this point we have:
|
||||
// - Index 0: First user message (with checkpoint)
|
||||
// - Index 1: Second user message (with checkpoint)
|
||||
// No assistant responses because FakeAgentConnection just returns EndTurn
|
||||
let AgentThreadEntry::UserMessage(message) = &thread.entries[1] else {
|
||||
panic!("expected user message at index 1");
|
||||
};
|
||||
message.id.clone().unwrap()
|
||||
});
|
||||
|
||||
// Create a terminal AFTER the checkpoint we'll restore to.
|
||||
// This simulates the AI agent starting a long-running terminal command.
|
||||
let terminal_id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into());
|
||||
let mock_terminal = cx.new(|cx| {
|
||||
let builder = ::terminal::TerminalBuilder::new_display_only(
|
||||
::terminal::terminal_settings::CursorShape::default(),
|
||||
::terminal::terminal_settings::AlternateScroll::On,
|
||||
None,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
builder.subscribe(cx)
|
||||
});
|
||||
|
||||
// Register the terminal as created
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.on_terminal_provider_event(
|
||||
TerminalProviderEvent::Created {
|
||||
terminal_id: terminal_id.clone(),
|
||||
label: "sleep 1000".to_string(),
|
||||
cwd: Some(PathBuf::from("/test")),
|
||||
output_byte_limit: None,
|
||||
terminal: mock_terminal.clone(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
// Simulate the terminal producing output (still running)
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.on_terminal_provider_event(
|
||||
TerminalProviderEvent::Output {
|
||||
terminal_id: terminal_id.clone(),
|
||||
data: b"terminal is running...\n".to_vec(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
// Create a tool call entry that references this terminal
|
||||
// This represents the agent requesting a terminal command
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread
|
||||
.handle_session_update(
|
||||
acp::SessionUpdate::ToolCall(acp::ToolCall {
|
||||
id: acp::ToolCallId("terminal-tool-1".into()),
|
||||
title: "Running command".into(),
|
||||
kind: acp::ToolKind::Execute,
|
||||
status: acp::ToolCallStatus::InProgress,
|
||||
content: vec![acp::ToolCallContent::Terminal {
|
||||
terminal_id: terminal_id.clone(),
|
||||
}],
|
||||
locations: vec![],
|
||||
raw_input: Some(
|
||||
serde_json::json!({"command": "sleep 1000", "cd": "/test"}),
|
||||
),
|
||||
raw_output: None,
|
||||
meta: None,
|
||||
}),
|
||||
cx,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Verify terminal exists and is in the thread
|
||||
let terminal_exists_before =
|
||||
thread.read_with(cx, |thread, _| thread.terminals.contains_key(&terminal_id));
|
||||
assert!(
|
||||
terminal_exists_before,
|
||||
"Terminal should exist before checkpoint restore"
|
||||
);
|
||||
|
||||
// Verify the terminal's underlying task is still running (not completed)
|
||||
let terminal_running_before = thread.read_with(cx, |thread, _cx| {
|
||||
let terminal_entity = thread.terminals.get(&terminal_id).unwrap();
|
||||
terminal_entity.read_with(cx, |term, _cx| {
|
||||
term.output().is_none() // output is None means it's still running
|
||||
})
|
||||
});
|
||||
assert!(
|
||||
terminal_running_before,
|
||||
"Terminal should be running before checkpoint restore"
|
||||
);
|
||||
|
||||
// Verify we have the expected entries before restore
|
||||
let entry_count_before = thread.read_with(cx, |thread, _| thread.entries.len());
|
||||
assert!(
|
||||
entry_count_before > 1,
|
||||
"Should have multiple entries before restore"
|
||||
);
|
||||
|
||||
// Restore the checkpoint to the second message.
|
||||
// This should:
|
||||
// 1. Cancel any in-progress generation (via the cancel() call)
|
||||
// 2. Remove the terminal that was created after that point
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.restore_checkpoint(second_message_id, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify that no send_task is in progress after restore
|
||||
// (cancel() clears the send_task)
|
||||
let has_send_task_after = thread.read_with(cx, |thread, _| thread.send_task.is_some());
|
||||
assert!(
|
||||
!has_send_task_after,
|
||||
"Should not have a send_task after restore (cancel should have cleared it)"
|
||||
);
|
||||
|
||||
// Verify the entries were truncated (restoring to index 1 truncates at 1, keeping only index 0)
|
||||
let entry_count = thread.read_with(cx, |thread, _| thread.entries.len());
|
||||
assert_eq!(
|
||||
entry_count, 1,
|
||||
"Should have 1 entry after restore (only the first user message)"
|
||||
);
|
||||
|
||||
// Verify the 2 completed terminals from before the checkpoint still exist
|
||||
let terminal_1_exists = thread.read_with(cx, |thread, _| {
|
||||
thread.terminals.contains_key(&terminal_id_1)
|
||||
});
|
||||
assert!(
|
||||
terminal_1_exists,
|
||||
"Terminal 1 (from before checkpoint) should still exist"
|
||||
);
|
||||
|
||||
let terminal_2_exists = thread.read_with(cx, |thread, _| {
|
||||
thread.terminals.contains_key(&terminal_id_2)
|
||||
});
|
||||
assert!(
|
||||
terminal_2_exists,
|
||||
"Terminal 2 (from before checkpoint) should still exist"
|
||||
);
|
||||
|
||||
// Verify they're still in completed state
|
||||
let terminal_1_completed = thread.read_with(cx, |thread, _cx| {
|
||||
let terminal_entity = thread.terminals.get(&terminal_id_1).unwrap();
|
||||
terminal_entity.read_with(cx, |term, _cx| term.output().is_some())
|
||||
});
|
||||
assert!(terminal_1_completed, "Terminal 1 should still be completed");
|
||||
|
||||
let terminal_2_completed = thread.read_with(cx, |thread, _cx| {
|
||||
let terminal_entity = thread.terminals.get(&terminal_id_2).unwrap();
|
||||
terminal_entity.read_with(cx, |term, _cx| term.output().is_some())
|
||||
});
|
||||
assert!(terminal_2_completed, "Terminal 2 should still be completed");
|
||||
|
||||
// Verify the running terminal (created after checkpoint) was removed
|
||||
let terminal_3_exists =
|
||||
thread.read_with(cx, |thread, _| thread.terminals.contains_key(&terminal_id));
|
||||
assert!(
|
||||
!terminal_3_exists,
|
||||
"Terminal 3 (created after checkpoint) should have been removed"
|
||||
);
|
||||
|
||||
// Verify total count is 2 (the two from before the checkpoint)
|
||||
let terminal_count = thread.read_with(cx, |thread, _| thread.terminals.len());
|
||||
assert_eq!(
|
||||
terminal_count, 2,
|
||||
"Should have exactly 2 terminals (the completed ones from before checkpoint)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,9 +133,7 @@ impl LanguageModels {
|
||||
for model in provider.provided_models(cx) {
|
||||
let model_info = Self::map_language_model_to_info(&model, &provider);
|
||||
let model_id = model_info.id.clone();
|
||||
if !recommended_models.contains(&(model.provider_id(), model.id())) {
|
||||
provider_models.push(model_info);
|
||||
}
|
||||
provider_models.push(model_info);
|
||||
models.insert(model_id, model);
|
||||
}
|
||||
if !provider_models.is_empty() {
|
||||
|
||||
@@ -44,6 +44,25 @@ pub async fn get_buffer_content_or_outline(
|
||||
.collect::<Vec<_>>()
|
||||
})?;
|
||||
|
||||
// If no outline exists, fall back to first 1KB so the agent has some context
|
||||
if outline_items.is_empty() {
|
||||
let text = buffer.read_with(cx, |buffer, _| {
|
||||
let snapshot = buffer.snapshot();
|
||||
let len = snapshot.len().min(1024);
|
||||
let content = snapshot.text_for_range(0..len).collect::<String>();
|
||||
if let Some(path) = path {
|
||||
format!("# First 1KB of {path} (file too large to show full content, and no outline available)\n\n{content}")
|
||||
} else {
|
||||
format!("# First 1KB of file (file too large to show full content, and no outline available)\n\n{content}")
|
||||
}
|
||||
})?;
|
||||
|
||||
return Ok(BufferContent {
|
||||
text,
|
||||
is_outline: false,
|
||||
});
|
||||
}
|
||||
|
||||
let outline_text = render_outline(outline_items, None, 0, usize::MAX).await?;
|
||||
|
||||
let text = if let Some(path) = path {
|
||||
@@ -140,3 +159,62 @@ fn render_entries(
|
||||
|
||||
entries_rendered
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fs::FakeFs;
|
||||
use gpui::TestAppContext;
|
||||
use project::Project;
|
||||
use settings::SettingsStore;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_large_file_fallback_to_subset(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let settings = SettingsStore::test(cx);
|
||||
cx.set_global(settings);
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
let project = Project::test(fs, [], cx).await;
|
||||
|
||||
let content = "A".repeat(100 * 1024); // 100KB
|
||||
let content_len = content.len();
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| project.create_buffer(true, cx))
|
||||
.await
|
||||
.expect("failed to create buffer");
|
||||
|
||||
buffer.update(cx, |buffer, cx| buffer.set_text(content, cx));
|
||||
|
||||
let result = cx
|
||||
.spawn(|cx| async move { get_buffer_content_or_outline(buffer, None, &cx).await })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should contain some of the actual file content
|
||||
assert!(
|
||||
result.text.contains("AAAAAAAAAA"),
|
||||
"Result did not contain content subset"
|
||||
);
|
||||
|
||||
// Should be marked as not an outline (it's truncated content)
|
||||
assert!(
|
||||
!result.is_outline,
|
||||
"Large file without outline should not be marked as outline"
|
||||
);
|
||||
|
||||
// Should be reasonably sized (much smaller than original)
|
||||
assert!(
|
||||
result.text.len() < 50 * 1024,
|
||||
"Result size {} should be smaller than 50KB",
|
||||
result.text.len()
|
||||
);
|
||||
|
||||
// Should be significantly smaller than the original content
|
||||
assert!(
|
||||
result.text.len() < content_len / 10,
|
||||
"Result should be much smaller than original content"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,13 +50,14 @@ impl crate::AgentServer for CustomAgentServer {
|
||||
fn set_default_mode(&self, mode_id: Option<acp::SessionModeId>, fs: Arc<dyn Fs>, cx: &mut App) {
|
||||
let name = self.name();
|
||||
update_settings_file(fs, cx, move |settings, _| {
|
||||
settings
|
||||
if let Some(settings) = settings
|
||||
.agent_servers
|
||||
.get_or_insert_default()
|
||||
.custom
|
||||
.get_mut(&name)
|
||||
.unwrap()
|
||||
.default_mode = mode_id.map(|m| m.to_string())
|
||||
{
|
||||
settings.default_mode = mode_id.map(|m| m.to_string())
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,8 @@ impl ContextPickerCompletionProvider {
|
||||
icon_path: Some(mode.icon().path().into()),
|
||||
documentation: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
insert_text_mode: None,
|
||||
// This ensures that when a user accepts this completion, the
|
||||
// completion menu will still be shown after "@category " is
|
||||
@@ -146,6 +148,8 @@ impl ContextPickerCompletionProvider {
|
||||
documentation: None,
|
||||
insert_text_mode: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
icon_path: Some(icon_for_completion),
|
||||
confirm: Some(confirm_completion_callback(
|
||||
thread_entry.title().clone(),
|
||||
@@ -177,6 +181,8 @@ impl ContextPickerCompletionProvider {
|
||||
documentation: None,
|
||||
insert_text_mode: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
icon_path: Some(icon_path),
|
||||
confirm: Some(confirm_completion_callback(
|
||||
rule.title,
|
||||
@@ -233,6 +239,8 @@ impl ContextPickerCompletionProvider {
|
||||
documentation: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
icon_path: Some(completion_icon_path),
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
insert_text_mode: None,
|
||||
confirm: Some(confirm_completion_callback(
|
||||
file_name,
|
||||
@@ -284,6 +292,8 @@ impl ContextPickerCompletionProvider {
|
||||
documentation: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
icon_path: Some(icon_path),
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
insert_text_mode: None,
|
||||
confirm: Some(confirm_completion_callback(
|
||||
symbol.name.into(),
|
||||
@@ -316,6 +326,8 @@ impl ContextPickerCompletionProvider {
|
||||
documentation: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
icon_path: Some(icon_path),
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
insert_text_mode: None,
|
||||
confirm: Some(confirm_completion_callback(
|
||||
url_to_fetch.to_string().into(),
|
||||
@@ -384,6 +396,8 @@ impl ContextPickerCompletionProvider {
|
||||
icon_path: Some(action.icon().path().into()),
|
||||
documentation: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
insert_text_mode: None,
|
||||
// This ensures that when a user accepts this completion, the
|
||||
// completion menu will still be shown after "@category " is
|
||||
@@ -694,14 +708,18 @@ fn build_symbol_label(symbol_name: &str, file_name: &str, line: u32, cx: &App) -
|
||||
}
|
||||
|
||||
fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel {
|
||||
let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
|
||||
let path = cx
|
||||
.theme()
|
||||
.syntax()
|
||||
.highlight_id("variable")
|
||||
.map(HighlightId);
|
||||
let mut label = CodeLabelBuilder::default();
|
||||
|
||||
label.push_str(file_name, None);
|
||||
label.push_str(" ", None);
|
||||
|
||||
if let Some(directory) = directory {
|
||||
label.push_str(directory, comment_id);
|
||||
label.push_str(directory, path);
|
||||
}
|
||||
|
||||
label.build()
|
||||
@@ -770,6 +788,8 @@ impl CompletionProvider for ContextPickerCompletionProvider {
|
||||
)),
|
||||
source: project::CompletionSource::Custom,
|
||||
icon_path: None,
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
insert_text_mode: None,
|
||||
confirm: Some(Arc::new({
|
||||
let editor = editor.clone();
|
||||
|
||||
@@ -15,6 +15,7 @@ use editor::{
|
||||
EditorEvent, EditorMode, EditorSnapshot, EditorStyle, ExcerptId, FoldPlaceholder, Inlay,
|
||||
MultiBuffer, ToOffset,
|
||||
actions::Paste,
|
||||
code_context_menus::CodeContextMenu,
|
||||
display_map::{Crease, CreaseId, FoldId},
|
||||
scroll::Autoscroll,
|
||||
};
|
||||
@@ -272,6 +273,15 @@ impl MessageEditor {
|
||||
self.editor.read(cx).is_empty(cx)
|
||||
}
|
||||
|
||||
pub fn is_completions_menu_visible(&self, cx: &App) -> bool {
|
||||
self.editor
|
||||
.read(cx)
|
||||
.context_menu()
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible())
|
||||
}
|
||||
|
||||
pub fn mentions(&self) -> HashSet<MentionUri> {
|
||||
self.mention_set
|
||||
.mentions
|
||||
@@ -836,6 +846,45 @@ impl MessageEditor {
|
||||
cx.emit(MessageEditorEvent::Send)
|
||||
}
|
||||
|
||||
pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let editor = self.editor.clone();
|
||||
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
editor
|
||||
.update_in(cx, |editor, window, cx| {
|
||||
let menu_is_open =
|
||||
editor.context_menu().borrow().as_ref().is_some_and(|menu| {
|
||||
matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()
|
||||
});
|
||||
|
||||
let has_at_sign = {
|
||||
let snapshot = editor.display_snapshot(cx);
|
||||
let cursor = editor.selections.newest::<text::Point>(&snapshot).head();
|
||||
let offset = cursor.to_offset(&snapshot);
|
||||
if offset > 0 {
|
||||
snapshot
|
||||
.buffer_snapshot()
|
||||
.reversed_chars_at(offset)
|
||||
.next()
|
||||
.map(|sign| sign == '@')
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if menu_is_open && has_at_sign {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.insert("@", window, cx);
|
||||
editor.show_completions(&editor::actions::ShowCompletions, window, cx);
|
||||
})
|
||||
.log_err();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.send(cx);
|
||||
}
|
||||
@@ -2622,13 +2671,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_large_file_mention_uses_outline(cx: &mut TestAppContext) {
|
||||
async fn test_large_file_mention_fallback(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
|
||||
// Create a large file that exceeds AUTO_OUTLINE_SIZE
|
||||
const LINE: &str = "fn example_function() { /* some code */ }\n";
|
||||
// Using plain text without a configured language, so no outline is available
|
||||
const LINE: &str = "This is a line of text in the file\n";
|
||||
let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
|
||||
assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
|
||||
|
||||
@@ -2639,8 +2689,8 @@ mod tests {
|
||||
fs.insert_tree(
|
||||
"/project",
|
||||
json!({
|
||||
"large_file.rs": large_content.clone(),
|
||||
"small_file.rs": small_content,
|
||||
"large_file.txt": large_content.clone(),
|
||||
"small_file.txt": small_content,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -2686,7 +2736,7 @@ mod tests {
|
||||
let large_file_abs_path = project.read_with(cx, |project, cx| {
|
||||
let worktree = project.worktrees(cx).next().unwrap();
|
||||
let worktree_root = worktree.read(cx).abs_path();
|
||||
worktree_root.join("large_file.rs")
|
||||
worktree_root.join("large_file.txt")
|
||||
});
|
||||
let large_file_task = message_editor.update(cx, |editor, cx| {
|
||||
editor.confirm_mention_for_file(large_file_abs_path, cx)
|
||||
@@ -2695,11 +2745,20 @@ mod tests {
|
||||
let large_file_mention = large_file_task.await.unwrap();
|
||||
match large_file_mention {
|
||||
Mention::Text { content, .. } => {
|
||||
// Should contain outline header for large files
|
||||
assert!(content.contains("File outline for"));
|
||||
assert!(content.contains("file too large to show full content"));
|
||||
// Should not contain the full repeated content
|
||||
assert!(!content.contains(&LINE.repeat(100)));
|
||||
// Should contain some of the content but not all of it
|
||||
assert!(
|
||||
content.contains(LINE),
|
||||
"Should contain some of the file content"
|
||||
);
|
||||
assert!(
|
||||
!content.contains(&LINE.repeat(100)),
|
||||
"Should not contain the full file"
|
||||
);
|
||||
// Should be much smaller than original
|
||||
assert!(
|
||||
content.len() < large_content.len() / 10,
|
||||
"Should be significantly truncated"
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected Text mention for large file"),
|
||||
}
|
||||
@@ -2709,7 +2768,7 @@ mod tests {
|
||||
let small_file_abs_path = project.read_with(cx, |project, cx| {
|
||||
let worktree = project.worktrees(cx).next().unwrap();
|
||||
let worktree_root = worktree.read(cx).abs_path();
|
||||
worktree_root.join("small_file.rs")
|
||||
worktree_root.join("small_file.txt")
|
||||
});
|
||||
let small_file_task = message_editor.update(cx, |editor, cx| {
|
||||
editor.confirm_mention_for_file(small_file_abs_path, cx)
|
||||
@@ -2718,10 +2777,8 @@ mod tests {
|
||||
let small_file_mention = small_file_task.await.unwrap();
|
||||
match small_file_mention {
|
||||
Mention::Text { content, .. } => {
|
||||
// Should contain the actual content
|
||||
// Should contain the full actual content
|
||||
assert_eq!(content, small_content);
|
||||
// Should not contain outline header
|
||||
assert!(!content.contains("File outline for"));
|
||||
}
|
||||
_ => panic!("Expected Text mention for small file"),
|
||||
}
|
||||
|
||||
@@ -4188,6 +4188,8 @@ impl AcpThreadView {
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(self.render_add_context_button(cx))
|
||||
.child(self.render_follow_toggle(cx))
|
||||
.children(self.render_burn_mode_toggle(cx)),
|
||||
)
|
||||
@@ -4502,6 +4504,29 @@ impl AcpThreadView {
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_add_context_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let message_editor = self.message_editor.clone();
|
||||
let menu_visible = message_editor.read(cx).is_completions_menu_visible(cx);
|
||||
|
||||
IconButton::new("add-context", IconName::AtSign)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.when(!menu_visible, |this| {
|
||||
this.tooltip(move |_window, cx| {
|
||||
Tooltip::with_meta("Add Context", None, "Or type @ to include context", cx)
|
||||
})
|
||||
})
|
||||
.on_click(cx.listener(move |_this, _, window, cx| {
|
||||
let message_editor_clone = message_editor.clone();
|
||||
|
||||
window.defer(cx, move |window, cx| {
|
||||
message_editor_clone.update(cx, |message_editor, cx| {
|
||||
message_editor.trigger_completion_menu(window, cx);
|
||||
});
|
||||
});
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
|
||||
let workspace = self.workspace.clone();
|
||||
MarkdownElement::new(markdown, style).on_url_click(move |text, window, cx| {
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::{ops::Range, sync::Arc};
|
||||
|
||||
use agent::ContextServerRegistry;
|
||||
use anyhow::Result;
|
||||
use client::zed_urls;
|
||||
use cloud_llm_client::{Plan, PlanV1, PlanV2};
|
||||
use collections::HashMap;
|
||||
use context_server::ContextServerId;
|
||||
@@ -26,18 +27,20 @@ use language_model::{
|
||||
use language_models::AllLanguageModelSettings;
|
||||
use notifications::status_toast::{StatusToast, ToastIcon};
|
||||
use project::{
|
||||
agent_server_store::{AgentServerStore, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME},
|
||||
agent_server_store::{
|
||||
AgentServerStore, CLAUDE_CODE_NAME, CODEX_NAME, ExternalAgentServerName, GEMINI_NAME,
|
||||
},
|
||||
context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
|
||||
};
|
||||
use settings::{Settings, SettingsStore, update_settings_file};
|
||||
use ui::{
|
||||
Button, ButtonStyle, Chip, CommonAnimationExt, ContextMenu, Disclosure, Divider, DividerColor,
|
||||
ElevationIndex, IconName, IconPosition, IconSize, Indicator, LabelSize, PopoverMenu, Switch,
|
||||
SwitchColor, Tooltip, WithScrollbar, prelude::*,
|
||||
Button, ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure,
|
||||
Divider, DividerColor, ElevationIndex, IconName, IconPosition, IconSize, Indicator, LabelSize,
|
||||
PopoverMenu, Switch, SwitchColor, Tooltip, WithScrollbar, prelude::*,
|
||||
};
|
||||
use util::ResultExt as _;
|
||||
use workspace::{Workspace, create_and_open_local_file};
|
||||
use zed_actions::ExtensionCategoryFilter;
|
||||
use zed_actions::{ExtensionCategoryFilter, OpenBrowser};
|
||||
|
||||
pub(crate) use configure_context_server_modal::ConfigureContextServerModal;
|
||||
pub(crate) use configure_context_server_tools_modal::ConfigureContextServerToolsModal;
|
||||
@@ -415,6 +418,7 @@ impl AgentConfiguration {
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let providers = LanguageModelRegistry::read_global(cx).providers();
|
||||
|
||||
let popover_menu = PopoverMenu::new("add-provider-popover")
|
||||
.trigger(
|
||||
Button::new("add-provider", "Add Provider")
|
||||
@@ -425,7 +429,6 @@ impl AgentConfiguration {
|
||||
.icon_color(Color::Muted)
|
||||
.label_size(LabelSize::Small),
|
||||
)
|
||||
.anchor(gpui::Corner::TopRight)
|
||||
.menu({
|
||||
let workspace = self.workspace.clone();
|
||||
move |window, cx| {
|
||||
@@ -447,6 +450,11 @@ impl AgentConfiguration {
|
||||
})
|
||||
}))
|
||||
}
|
||||
})
|
||||
.anchor(gpui::Corner::TopRight)
|
||||
.offset(gpui::Point {
|
||||
x: px(0.0),
|
||||
y: px(2.0),
|
||||
});
|
||||
|
||||
v_flex()
|
||||
@@ -541,7 +549,6 @@ impl AgentConfiguration {
|
||||
.icon_color(Color::Muted)
|
||||
.label_size(LabelSize::Small),
|
||||
)
|
||||
.anchor(gpui::Corner::TopRight)
|
||||
.menu({
|
||||
move |window, cx| {
|
||||
Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
|
||||
@@ -564,6 +571,11 @@ impl AgentConfiguration {
|
||||
})
|
||||
}))
|
||||
}
|
||||
})
|
||||
.anchor(gpui::Corner::TopRight)
|
||||
.offset(gpui::Point {
|
||||
x: px(0.0),
|
||||
y: px(2.0),
|
||||
});
|
||||
|
||||
v_flex()
|
||||
@@ -943,7 +955,7 @@ impl AgentConfiguration {
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let user_defined_agents = user_defined_agents
|
||||
let user_defined_agents: Vec<_> = user_defined_agents
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) {
|
||||
@@ -951,27 +963,93 @@ impl AgentConfiguration {
|
||||
} else {
|
||||
AgentIcon::Name(IconName::Ai)
|
||||
};
|
||||
self.render_agent_server(icon, name, true)
|
||||
.into_any_element()
|
||||
(name, icon)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
.collect();
|
||||
|
||||
let add_agens_button = Button::new("add-agent", "Add Agent")
|
||||
.style(ButtonStyle::Outlined)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon(IconName::Plus)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(move |_, window, cx| {
|
||||
if let Some(workspace) = window.root().flatten() {
|
||||
let workspace = workspace.downgrade();
|
||||
window
|
||||
.spawn(cx, async |cx| {
|
||||
open_new_agent_servers_entry_in_settings_editor(workspace, cx).await
|
||||
let add_agent_popover = PopoverMenu::new("add-agent-server-popover")
|
||||
.trigger(
|
||||
Button::new("add-agent", "Add Agent")
|
||||
.style(ButtonStyle::Outlined)
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon(IconName::Plus)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.label_size(LabelSize::Small),
|
||||
)
|
||||
.menu({
|
||||
move |window, cx| {
|
||||
Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
|
||||
menu.entry("Install from Extensions", None, {
|
||||
|window, cx| {
|
||||
window.dispatch_action(
|
||||
zed_actions::Extensions {
|
||||
category_filter: Some(
|
||||
ExtensionCategoryFilter::AgentServers,
|
||||
),
|
||||
id: None,
|
||||
}
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
.entry("Add Custom Agent", None, {
|
||||
move |window, cx| {
|
||||
if let Some(workspace) = window.root().flatten() {
|
||||
let workspace = workspace.downgrade();
|
||||
window
|
||||
.spawn(cx, async |cx| {
|
||||
open_new_agent_servers_entry_in_settings_editor(
|
||||
workspace, cx,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.separator()
|
||||
.header("Learn More")
|
||||
.item(
|
||||
ContextMenuEntry::new("Agent Servers Docs")
|
||||
.icon(IconName::ArrowUpRight)
|
||||
.icon_color(Color::Muted)
|
||||
.icon_position(IconPosition::End)
|
||||
.handler({
|
||||
move |window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(OpenBrowser {
|
||||
url: zed_urls::agent_server_docs(cx),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
ContextMenuEntry::new("ACP Docs")
|
||||
.icon(IconName::ArrowUpRight)
|
||||
.icon_color(Color::Muted)
|
||||
.icon_position(IconPosition::End)
|
||||
.handler({
|
||||
move |window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(OpenBrowser {
|
||||
url: "https://agentclientprotocol.com/".into(),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
)
|
||||
}))
|
||||
}
|
||||
})
|
||||
.anchor(gpui::Corner::TopRight)
|
||||
.offset(gpui::Point {
|
||||
x: px(0.0),
|
||||
y: px(2.0),
|
||||
});
|
||||
|
||||
v_flex()
|
||||
@@ -982,7 +1060,7 @@ impl AgentConfiguration {
|
||||
.child(self.render_section_title(
|
||||
"External Agents",
|
||||
"All agents connected through the Agent Client Protocol.",
|
||||
add_agens_button.into_any_element(),
|
||||
add_agent_popover.into_any_element(),
|
||||
))
|
||||
.child(
|
||||
v_flex()
|
||||
@@ -993,26 +1071,29 @@ impl AgentConfiguration {
|
||||
AgentIcon::Name(IconName::AiClaude),
|
||||
"Claude Code",
|
||||
false,
|
||||
cx,
|
||||
))
|
||||
.child(Divider::horizontal().color(DividerColor::BorderFaded))
|
||||
.child(self.render_agent_server(
|
||||
AgentIcon::Name(IconName::AiOpenAi),
|
||||
"Codex CLI",
|
||||
false,
|
||||
cx,
|
||||
))
|
||||
.child(Divider::horizontal().color(DividerColor::BorderFaded))
|
||||
.child(self.render_agent_server(
|
||||
AgentIcon::Name(IconName::AiGemini),
|
||||
"Gemini CLI",
|
||||
false,
|
||||
cx,
|
||||
))
|
||||
.map(|mut parent| {
|
||||
for agent in user_defined_agents {
|
||||
for (name, icon) in user_defined_agents {
|
||||
parent = parent
|
||||
.child(
|
||||
Divider::horizontal().color(DividerColor::BorderFaded),
|
||||
)
|
||||
.child(agent);
|
||||
.child(self.render_agent_server(icon, name, true, cx));
|
||||
}
|
||||
parent
|
||||
}),
|
||||
@@ -1025,6 +1106,7 @@ impl AgentConfiguration {
|
||||
icon: AgentIcon,
|
||||
name: impl Into<SharedString>,
|
||||
external: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let name = name.into();
|
||||
let icon = match icon {
|
||||
@@ -1039,28 +1121,53 @@ impl AgentConfiguration {
|
||||
let tooltip_id = SharedString::new(format!("agent-source-{}", name));
|
||||
let tooltip_message = format!("The {} agent was installed from an extension.", name);
|
||||
|
||||
let agent_server_name = ExternalAgentServerName(name.clone());
|
||||
|
||||
let uninstall_btn_id = SharedString::from(format!("uninstall-{}", name));
|
||||
let uninstall_button = IconButton::new(uninstall_btn_id, IconName::Trash)
|
||||
.icon_color(Color::Muted)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(Tooltip::text("Uninstall Agent Extension"))
|
||||
.on_click(cx.listener(move |this, _, _window, cx| {
|
||||
let agent_name = agent_server_name.clone();
|
||||
|
||||
if let Some(ext_id) = this.agent_server_store.update(cx, |store, _cx| {
|
||||
store.get_extension_id_for_agent(&agent_name)
|
||||
}) {
|
||||
ExtensionStore::global(cx)
|
||||
.update(cx, |store, cx| store.uninstall_extension(ext_id, cx))
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
}));
|
||||
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.child(icon)
|
||||
.child(Label::new(name))
|
||||
.when(external, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.id(tooltip_id)
|
||||
.flex_none()
|
||||
.tooltip(Tooltip::text(tooltip_message))
|
||||
.child(
|
||||
Icon::new(IconName::ZedSrcExtension)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
),
|
||||
)
|
||||
})
|
||||
.gap_1()
|
||||
.justify_between()
|
||||
.child(
|
||||
Icon::new(IconName::Check)
|
||||
.color(Color::Success)
|
||||
.size(IconSize::Small),
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.child(icon)
|
||||
.child(Label::new(name))
|
||||
.when(external, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.id(tooltip_id)
|
||||
.flex_none()
|
||||
.tooltip(Tooltip::text(tooltip_message))
|
||||
.child(
|
||||
Icon::new(IconName::ZedSrcExtension)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Muted),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Icon::new(IconName::Check)
|
||||
.color(Color::Success)
|
||||
.size(IconSize::Small),
|
||||
),
|
||||
)
|
||||
.when(external, |this| this.child(uninstall_button))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1089,7 +1089,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_large_file_uses_outline(cx: &mut TestAppContext) {
|
||||
async fn test_large_file_uses_fallback(cx: &mut TestAppContext) {
|
||||
init_test_settings(cx);
|
||||
|
||||
// Create a large file that exceeds AUTO_OUTLINE_SIZE
|
||||
@@ -1101,16 +1101,16 @@ mod tests {
|
||||
|
||||
let file_context = load_context_for("file.txt", large_content, cx).await;
|
||||
|
||||
// Should contain some of the actual file content
|
||||
assert!(
|
||||
file_context
|
||||
.text
|
||||
.contains(&format!("# File outline for {}", path!("test/file.txt"))),
|
||||
"Large files should not get an outline"
|
||||
file_context.text.contains(LINE),
|
||||
"Should contain some of the file content"
|
||||
);
|
||||
|
||||
// Should be much smaller than original
|
||||
assert!(
|
||||
file_context.text.len() < content_len,
|
||||
"Outline should be smaller than original content"
|
||||
file_context.text.len() < content_len / 10,
|
||||
"Should be significantly smaller than original content"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +278,8 @@ impl ContextPickerCompletionProvider {
|
||||
icon_path: Some(mode.icon().path().into()),
|
||||
documentation: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
insert_text_mode: None,
|
||||
// This ensures that when a user accepts this completion, the
|
||||
// completion menu will still be shown after "@category " is
|
||||
@@ -386,6 +388,8 @@ impl ContextPickerCompletionProvider {
|
||||
icon_path: Some(action.icon().path().into()),
|
||||
documentation: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
insert_text_mode: None,
|
||||
// This ensures that when a user accepts this completion, the
|
||||
// completion menu will still be shown after "@category " is
|
||||
@@ -417,6 +421,8 @@ impl ContextPickerCompletionProvider {
|
||||
replace_range: source_range.clone(),
|
||||
new_text,
|
||||
label: CodeLabel::plain(thread_entry.title().to_string(), None),
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
documentation: None,
|
||||
insert_text_mode: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
@@ -484,6 +490,8 @@ impl ContextPickerCompletionProvider {
|
||||
replace_range: source_range.clone(),
|
||||
new_text,
|
||||
label: CodeLabel::plain(rules.title.to_string(), None),
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
documentation: None,
|
||||
insert_text_mode: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
@@ -524,6 +532,8 @@ impl ContextPickerCompletionProvider {
|
||||
documentation: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
icon_path: Some(IconName::ToolWeb.path().into()),
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
insert_text_mode: None,
|
||||
confirm: Some(confirm_completion_callback(
|
||||
IconName::ToolWeb.path().into(),
|
||||
@@ -612,6 +622,8 @@ impl ContextPickerCompletionProvider {
|
||||
documentation: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
icon_path: Some(completion_icon_path),
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
insert_text_mode: None,
|
||||
confirm: Some(confirm_completion_callback(
|
||||
crease_icon_path,
|
||||
@@ -689,6 +701,8 @@ impl ContextPickerCompletionProvider {
|
||||
documentation: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
icon_path: Some(IconName::Code.path().into()),
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
insert_text_mode: None,
|
||||
confirm: Some(confirm_completion_callback(
|
||||
IconName::Code.path().into(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::{cmp::Reverse, sync::Arc};
|
||||
|
||||
use collections::{HashSet, IndexMap};
|
||||
use collections::IndexMap;
|
||||
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
|
||||
use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task};
|
||||
use language_model::{
|
||||
@@ -57,7 +57,7 @@ fn all_models(cx: &App) -> GroupedModels {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let other = providers
|
||||
let all = providers
|
||||
.iter()
|
||||
.flat_map(|provider| {
|
||||
provider
|
||||
@@ -70,7 +70,7 @@ fn all_models(cx: &App) -> GroupedModels {
|
||||
})
|
||||
.collect();
|
||||
|
||||
GroupedModels::new(other, recommended)
|
||||
GroupedModels::new(all, recommended)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -210,33 +210,24 @@ impl LanguageModelPickerDelegate {
|
||||
|
||||
struct GroupedModels {
|
||||
recommended: Vec<ModelInfo>,
|
||||
other: IndexMap<LanguageModelProviderId, Vec<ModelInfo>>,
|
||||
all: IndexMap<LanguageModelProviderId, Vec<ModelInfo>>,
|
||||
}
|
||||
|
||||
impl GroupedModels {
|
||||
pub fn new(other: Vec<ModelInfo>, recommended: Vec<ModelInfo>) -> Self {
|
||||
let recommended_ids = recommended
|
||||
.iter()
|
||||
.map(|info| (info.model.provider_id(), info.model.id()))
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let mut other_by_provider: IndexMap<_, Vec<ModelInfo>> = IndexMap::default();
|
||||
for model in other {
|
||||
if recommended_ids.contains(&(model.model.provider_id(), model.model.id())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pub fn new(all: Vec<ModelInfo>, recommended: Vec<ModelInfo>) -> Self {
|
||||
let mut all_by_provider: IndexMap<_, Vec<ModelInfo>> = IndexMap::default();
|
||||
for model in all {
|
||||
let provider = model.model.provider_id();
|
||||
if let Some(models) = other_by_provider.get_mut(&provider) {
|
||||
if let Some(models) = all_by_provider.get_mut(&provider) {
|
||||
models.push(model);
|
||||
} else {
|
||||
other_by_provider.insert(provider, vec![model]);
|
||||
all_by_provider.insert(provider, vec![model]);
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
recommended,
|
||||
other: other_by_provider,
|
||||
all: all_by_provider,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,7 +243,7 @@ impl GroupedModels {
|
||||
);
|
||||
}
|
||||
|
||||
for models in self.other.values() {
|
||||
for models in self.all.values() {
|
||||
if models.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -267,20 +258,6 @@ impl GroupedModels {
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn model_infos(&self) -> Vec<ModelInfo> {
|
||||
let other = self
|
||||
.other
|
||||
.values()
|
||||
.flat_map(|model| model.iter())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
self.recommended
|
||||
.iter()
|
||||
.chain(&other)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
}
|
||||
|
||||
enum LanguageModelPickerEntry {
|
||||
@@ -425,8 +402,9 @@ impl PickerDelegate for LanguageModelPickerDelegate {
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let available_models = all_models
|
||||
.model_infos()
|
||||
.iter()
|
||||
.all
|
||||
.values()
|
||||
.flat_map(|models| models.iter())
|
||||
.filter(|m| configured_provider_ids.contains(&m.model.provider_id()))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
@@ -764,46 +742,52 @@ mod tests {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_exclude_recommended_models(_cx: &mut TestAppContext) {
|
||||
fn test_recommended_models_also_appear_in_other(_cx: &mut TestAppContext) {
|
||||
let recommended_models = create_models(vec![("zed", "claude")]);
|
||||
let all_models = create_models(vec![
|
||||
("zed", "claude"), // Should be filtered out from "other"
|
||||
("zed", "claude"), // Should also appear in "other"
|
||||
("zed", "gemini"),
|
||||
("copilot", "o3"),
|
||||
]);
|
||||
|
||||
let grouped_models = GroupedModels::new(all_models, recommended_models);
|
||||
|
||||
let actual_other_models = grouped_models
|
||||
.other
|
||||
let actual_all_models = grouped_models
|
||||
.all
|
||||
.values()
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Recommended models should not appear in "other"
|
||||
assert_models_eq(actual_other_models, vec!["zed/gemini", "copilot/o3"]);
|
||||
// Recommended models should also appear in "all"
|
||||
assert_models_eq(
|
||||
actual_all_models,
|
||||
vec!["zed/claude", "zed/gemini", "copilot/o3"],
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_dont_exclude_models_from_other_providers(_cx: &mut TestAppContext) {
|
||||
fn test_models_from_different_providers(_cx: &mut TestAppContext) {
|
||||
let recommended_models = create_models(vec![("zed", "claude")]);
|
||||
let all_models = create_models(vec![
|
||||
("zed", "claude"), // Should be filtered out from "other"
|
||||
("zed", "claude"), // Should also appear in "other"
|
||||
("zed", "gemini"),
|
||||
("copilot", "claude"), // Should not be filtered out from "other"
|
||||
("copilot", "claude"), // Different provider, should appear in "other"
|
||||
]);
|
||||
|
||||
let grouped_models = GroupedModels::new(all_models, recommended_models);
|
||||
|
||||
let actual_other_models = grouped_models
|
||||
.other
|
||||
let actual_all_models = grouped_models
|
||||
.all
|
||||
.values()
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Recommended models should not appear in "other"
|
||||
assert_models_eq(actual_other_models, vec!["zed/gemini", "copilot/claude"]);
|
||||
// All models should appear in "all" regardless of recommended status
|
||||
assert_models_eq(
|
||||
actual_all_models,
|
||||
vec!["zed/claude", "zed/gemini", "copilot/claude"],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,8 @@ impl SlashCommandCompletionProvider {
|
||||
new_text,
|
||||
label: command.label(cx),
|
||||
icon_path: None,
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
insert_text_mode: None,
|
||||
confirm,
|
||||
source: CompletionSource::Custom,
|
||||
@@ -232,6 +234,8 @@ impl SlashCommandCompletionProvider {
|
||||
icon_path: None,
|
||||
new_text,
|
||||
documentation: None,
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
confirm,
|
||||
insert_text_mode: None,
|
||||
source: CompletionSource::Custom,
|
||||
|
||||
@@ -350,8 +350,7 @@ impl AutoUpdater {
|
||||
|
||||
pub fn start_polling(&self, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
cx.spawn(async move |this, cx| {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if cfg!(target_os = "windows") {
|
||||
use util::ResultExt;
|
||||
|
||||
cleanup_windows()
|
||||
@@ -903,28 +902,16 @@ async fn install_release_macos(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn cleanup_windows() -> Result<()> {
|
||||
use util::ResultExt;
|
||||
|
||||
let parent = std::env::current_exe()?
|
||||
.parent()
|
||||
.context("No parent dir for Zed.exe")?
|
||||
.to_owned();
|
||||
|
||||
// keep in sync with crates/auto_update_helper/src/updater.rs
|
||||
smol::fs::remove_dir(parent.join("updates"))
|
||||
.await
|
||||
.context("failed to remove updates dir")
|
||||
.log_err();
|
||||
smol::fs::remove_dir(parent.join("install"))
|
||||
.await
|
||||
.context("failed to remove install dir")
|
||||
.log_err();
|
||||
smol::fs::remove_dir(parent.join("old"))
|
||||
.await
|
||||
.context("failed to remove old version dir")
|
||||
.log_err();
|
||||
_ = smol::fs::remove_dir(parent.join("updates")).await;
|
||||
_ = smol::fs::remove_dir(parent.join("install")).await;
|
||||
_ = smol::fs::remove_dir(parent.join("old")).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::{
|
||||
cell::LazyCell,
|
||||
path::Path,
|
||||
sync::LazyLock,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
@@ -13,8 +13,8 @@ use windows::Win32::{
|
||||
use crate::windows_impl::WM_JOB_UPDATED;
|
||||
|
||||
pub(crate) struct Job {
|
||||
pub apply: Box<dyn Fn(&Path) -> Result<()>>,
|
||||
pub rollback: Box<dyn Fn(&Path) -> Result<()>>,
|
||||
pub apply: Box<dyn Fn(&Path) -> Result<()> + Send + Sync>,
|
||||
pub rollback: Box<dyn Fn(&Path) -> Result<()> + Send + Sync>,
|
||||
}
|
||||
|
||||
impl Job {
|
||||
@@ -154,10 +154,8 @@ impl Job {
|
||||
}
|
||||
}
|
||||
|
||||
// app is single threaded
|
||||
#[cfg(not(test))]
|
||||
#[allow(clippy::declare_interior_mutable_const)]
|
||||
pub(crate) const JOBS: LazyCell<[Job; 22]> = LazyCell::new(|| {
|
||||
pub(crate) static JOBS: LazyLock<[Job; 22]> = LazyLock::new(|| {
|
||||
fn p(value: &str) -> &Path {
|
||||
Path::new(value)
|
||||
}
|
||||
@@ -206,10 +204,8 @@ pub(crate) const JOBS: LazyCell<[Job; 22]> = LazyCell::new(|| {
|
||||
]
|
||||
});
|
||||
|
||||
// app is single threaded
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::declare_interior_mutable_const)]
|
||||
pub(crate) const JOBS: LazyCell<[Job; 9]> = LazyCell::new(|| {
|
||||
pub(crate) static JOBS: LazyLock<[Job; 9]> = LazyLock::new(|| {
|
||||
fn p(value: &str) -> &Path {
|
||||
Path::new(value)
|
||||
}
|
||||
|
||||
@@ -51,3 +51,11 @@ pub fn external_agents_docs(cx: &App) -> String {
|
||||
server_url = server_url(cx)
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the URL to Zed agent servers documentation.
|
||||
pub fn agent_server_docs(cx: &App) -> String {
|
||||
format!(
|
||||
"{server_url}/docs/extensions/agent-servers",
|
||||
server_url = server_url(cx)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ pub struct SearchToolInput {
|
||||
}
|
||||
|
||||
/// Search for relevant code by path, syntax hierarchy, and content.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Hash)]
|
||||
pub struct SearchToolQuery {
|
||||
/// 1. A glob pattern to match file paths in the codebase to search in.
|
||||
pub glob: String,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
drop table embeddings;
|
||||
@@ -5,7 +5,6 @@ pub mod buffers;
|
||||
pub mod channels;
|
||||
pub mod contacts;
|
||||
pub mod contributors;
|
||||
pub mod embeddings;
|
||||
pub mod extensions;
|
||||
pub mod notifications;
|
||||
pub mod projects;
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
use super::*;
|
||||
use time::Duration;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
impl Database {
|
||||
pub async fn get_embeddings(
|
||||
&self,
|
||||
model: &str,
|
||||
digests: &[Vec<u8>],
|
||||
) -> Result<HashMap<Vec<u8>, Vec<f32>>> {
|
||||
self.transaction(|tx| async move {
|
||||
let embeddings = {
|
||||
let mut db_embeddings = embedding::Entity::find()
|
||||
.filter(
|
||||
embedding::Column::Model.eq(model).and(
|
||||
embedding::Column::Digest
|
||||
.is_in(digests.iter().map(|digest| digest.as_slice())),
|
||||
),
|
||||
)
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut embeddings = HashMap::default();
|
||||
while let Some(db_embedding) = db_embeddings.next().await {
|
||||
let db_embedding = db_embedding?;
|
||||
embeddings.insert(db_embedding.digest, db_embedding.dimensions);
|
||||
}
|
||||
embeddings
|
||||
};
|
||||
|
||||
if !embeddings.is_empty() {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let retrieved_at = PrimitiveDateTime::new(now.date(), now.time());
|
||||
|
||||
embedding::Entity::update_many()
|
||||
.filter(
|
||||
embedding::Column::Digest
|
||||
.is_in(embeddings.keys().map(|digest| digest.as_slice())),
|
||||
)
|
||||
.col_expr(embedding::Column::RetrievedAt, Expr::value(retrieved_at))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(embeddings)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn save_embeddings(
|
||||
&self,
|
||||
model: &str,
|
||||
embeddings: &HashMap<Vec<u8>, Vec<f32>>,
|
||||
) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
embedding::Entity::insert_many(embeddings.iter().map(|(digest, dimensions)| {
|
||||
let now_offset_datetime = OffsetDateTime::now_utc();
|
||||
let retrieved_at =
|
||||
PrimitiveDateTime::new(now_offset_datetime.date(), now_offset_datetime.time());
|
||||
|
||||
embedding::ActiveModel {
|
||||
model: ActiveValue::set(model.to_string()),
|
||||
digest: ActiveValue::set(digest.clone()),
|
||||
dimensions: ActiveValue::set(dimensions.clone()),
|
||||
retrieved_at: ActiveValue::set(retrieved_at),
|
||||
}
|
||||
}))
|
||||
.on_conflict(
|
||||
OnConflict::columns([embedding::Column::Model, embedding::Column::Digest])
|
||||
.do_nothing()
|
||||
.to_owned(),
|
||||
)
|
||||
.exec_without_returning(&*tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn purge_old_embeddings(&self) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
embedding::Entity::delete_many()
|
||||
.filter(
|
||||
embedding::Column::RetrievedAt
|
||||
.lte(OffsetDateTime::now_utc() - Duration::days(60)),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ pub mod channel_chat_participant;
|
||||
pub mod channel_member;
|
||||
pub mod contact;
|
||||
pub mod contributor;
|
||||
pub mod embedding;
|
||||
pub mod extension;
|
||||
pub mod extension_version;
|
||||
pub mod follower;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use time::PrimitiveDateTime;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "embeddings")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub model: String,
|
||||
#[sea_orm(primary_key)]
|
||||
pub digest: Vec<u8>,
|
||||
pub dimensions: Vec<f32>,
|
||||
pub retrieved_at: PrimitiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -39,25 +39,6 @@ pub enum Relation {
|
||||
Contributor,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// Returns the timestamp of when the user's account was created.
|
||||
///
|
||||
/// This will be the earlier of the `created_at` and `github_user_created_at` timestamps.
|
||||
pub fn account_created_at(&self) -> NaiveDateTime {
|
||||
let mut account_created_at = self.created_at;
|
||||
if let Some(github_created_at) = self.github_user_created_at {
|
||||
account_created_at = account_created_at.min(github_created_at);
|
||||
}
|
||||
|
||||
account_created_at
|
||||
}
|
||||
|
||||
/// Returns the age of the user's account.
|
||||
pub fn account_age(&self) -> chrono::Duration {
|
||||
chrono::Utc::now().naive_utc() - self.account_created_at()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::access_token::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::AccessToken.def()
|
||||
|
||||
@@ -2,9 +2,6 @@ mod buffer_tests;
|
||||
mod channel_tests;
|
||||
mod contributor_tests;
|
||||
mod db_tests;
|
||||
// we only run postgres tests on macos right now
|
||||
#[cfg(target_os = "macos")]
|
||||
mod embedding_tests;
|
||||
mod extension_tests;
|
||||
|
||||
use crate::migrations::run_database_migrations;
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
use super::TestDb;
|
||||
use crate::db::embedding;
|
||||
use collections::HashMap;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, sea_query::Expr};
|
||||
use std::ops::Sub;
|
||||
use time::{Duration, OffsetDateTime, PrimitiveDateTime};
|
||||
|
||||
// SQLite does not support array arguments, so we only test this against a real postgres instance
|
||||
#[gpui::test]
|
||||
async fn test_get_embeddings_postgres(cx: &mut gpui::TestAppContext) {
|
||||
let test_db = TestDb::postgres(cx.executor());
|
||||
let db = test_db.db();
|
||||
|
||||
let provider = "test_model";
|
||||
let digest1 = vec![1, 2, 3];
|
||||
let digest2 = vec![4, 5, 6];
|
||||
let embeddings = HashMap::from_iter([
|
||||
(digest1.clone(), vec![0.1, 0.2, 0.3]),
|
||||
(digest2.clone(), vec![0.4, 0.5, 0.6]),
|
||||
]);
|
||||
|
||||
// Save embeddings
|
||||
db.save_embeddings(provider, &embeddings).await.unwrap();
|
||||
|
||||
// Retrieve embeddings
|
||||
let retrieved_embeddings = db
|
||||
.get_embeddings(provider, &[digest1.clone(), digest2.clone()])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(retrieved_embeddings.len(), 2);
|
||||
assert!(retrieved_embeddings.contains_key(&digest1));
|
||||
assert!(retrieved_embeddings.contains_key(&digest2));
|
||||
|
||||
// Check if the retrieved embeddings are correct
|
||||
assert_eq!(retrieved_embeddings[&digest1], vec![0.1, 0.2, 0.3]);
|
||||
assert_eq!(retrieved_embeddings[&digest2], vec![0.4, 0.5, 0.6]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_purge_old_embeddings(cx: &mut gpui::TestAppContext) {
|
||||
let test_db = TestDb::postgres(cx.executor());
|
||||
let db = test_db.db();
|
||||
|
||||
let model = "test_model";
|
||||
let digest = vec![7, 8, 9];
|
||||
let embeddings = HashMap::from_iter([(digest.clone(), vec![0.7, 0.8, 0.9])]);
|
||||
|
||||
// Save old embeddings
|
||||
db.save_embeddings(model, &embeddings).await.unwrap();
|
||||
|
||||
// Reach into the DB and change the retrieved at to be > 60 days
|
||||
db.transaction(|tx| {
|
||||
let digest = digest.clone();
|
||||
async move {
|
||||
let sixty_days_ago = OffsetDateTime::now_utc().sub(Duration::days(61));
|
||||
let retrieved_at = PrimitiveDateTime::new(sixty_days_ago.date(), sixty_days_ago.time());
|
||||
|
||||
embedding::Entity::update_many()
|
||||
.filter(
|
||||
embedding::Column::Model
|
||||
.eq(model)
|
||||
.and(embedding::Column::Digest.eq(digest)),
|
||||
)
|
||||
.col_expr(embedding::Column::RetrievedAt, Expr::value(retrieved_at))
|
||||
.exec(&*tx)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Purge old embeddings
|
||||
db.purge_old_embeddings().await.unwrap();
|
||||
|
||||
// Try to retrieve the purged embeddings
|
||||
let retrieved_embeddings = db
|
||||
.get_embeddings(model, std::slice::from_ref(&digest))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
retrieved_embeddings.is_empty(),
|
||||
"Old embeddings should have been purged"
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use collab::llm::db::LlmDatabase;
|
||||
use collab::migrations::run_database_migrations;
|
||||
use collab::{
|
||||
AppState, Config, Result, api::fetch_extensions_from_blob_store_periodically, db, env,
|
||||
executor::Executor, rpc::ResultExt,
|
||||
executor::Executor,
|
||||
};
|
||||
use db::Database;
|
||||
use std::{
|
||||
@@ -95,8 +95,6 @@ async fn main() -> Result<()> {
|
||||
let state = AppState::new(config, Executor::Production).await?;
|
||||
|
||||
if mode.is_collab() {
|
||||
state.db.purge_old_embeddings().await.trace_err();
|
||||
|
||||
let epoch = state
|
||||
.db
|
||||
.create_server(&state.config.zed_environment)
|
||||
|
||||
@@ -677,6 +677,8 @@ impl ConsoleQueryBarCompletionProvider {
|
||||
),
|
||||
new_text: string_match.string.clone(),
|
||||
label: CodeLabel::plain(string_match.string.clone(), None),
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
icon_path: None,
|
||||
documentation: Some(CompletionDocumentation::MultiLineMarkdown(
|
||||
variable_value.into(),
|
||||
@@ -790,6 +792,8 @@ impl ConsoleQueryBarCompletionProvider {
|
||||
documentation: completion.detail.map(|detail| {
|
||||
CompletionDocumentation::MultiLineMarkdown(detail.into())
|
||||
}),
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
confirm: None,
|
||||
source: project::CompletionSource::Dap { sort_text },
|
||||
insert_text_mode: None,
|
||||
|
||||
@@ -370,11 +370,16 @@ impl BufferDiagnosticsEditor {
|
||||
continue;
|
||||
}
|
||||
|
||||
let languages = buffer_diagnostics_editor
|
||||
.read_with(cx, |b, cx| b.project.read(cx).languages().clone())
|
||||
.ok();
|
||||
|
||||
let diagnostic_blocks = cx.update(|_window, cx| {
|
||||
DiagnosticRenderer::diagnostic_blocks_for_group(
|
||||
group,
|
||||
buffer_snapshot.remote_id(),
|
||||
Some(Arc::new(buffer_diagnostics_editor.clone())),
|
||||
languages,
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -6,7 +6,7 @@ use editor::{
|
||||
hover_popover::diagnostics_markdown_style,
|
||||
};
|
||||
use gpui::{AppContext, Entity, Focusable, WeakEntity};
|
||||
use language::{BufferId, Diagnostic, DiagnosticEntryRef};
|
||||
use language::{BufferId, Diagnostic, DiagnosticEntryRef, LanguageRegistry};
|
||||
use lsp::DiagnosticSeverity;
|
||||
use markdown::{Markdown, MarkdownElement};
|
||||
use settings::Settings;
|
||||
@@ -27,6 +27,7 @@ impl DiagnosticRenderer {
|
||||
diagnostic_group: Vec<DiagnosticEntryRef<'_, Point>>,
|
||||
buffer_id: BufferId,
|
||||
diagnostics_editor: Option<Arc<dyn DiagnosticsToolbarEditor>>,
|
||||
language_registry: Option<Arc<LanguageRegistry>>,
|
||||
cx: &mut App,
|
||||
) -> Vec<DiagnosticBlock> {
|
||||
let Some(primary_ix) = diagnostic_group
|
||||
@@ -75,11 +76,14 @@ impl DiagnosticRenderer {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
results.push(DiagnosticBlock {
|
||||
initial_range: primary.range.clone(),
|
||||
severity: primary.diagnostic.severity,
|
||||
diagnostics_editor: diagnostics_editor.clone(),
|
||||
markdown: cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)),
|
||||
markdown: cx.new(|cx| {
|
||||
Markdown::new(markdown.into(), language_registry.clone(), None, cx)
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
if entry.range.start.row.abs_diff(primary.range.start.row) >= 5 {
|
||||
@@ -91,7 +95,9 @@ impl DiagnosticRenderer {
|
||||
initial_range: entry.range.clone(),
|
||||
severity: entry.diagnostic.severity,
|
||||
diagnostics_editor: diagnostics_editor.clone(),
|
||||
markdown: cx.new(|cx| Markdown::new(markdown.into(), None, None, cx)),
|
||||
markdown: cx.new(|cx| {
|
||||
Markdown::new(markdown.into(), language_registry.clone(), None, cx)
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -118,9 +124,16 @@ impl editor::DiagnosticRenderer for DiagnosticRenderer {
|
||||
buffer_id: BufferId,
|
||||
snapshot: EditorSnapshot,
|
||||
editor: WeakEntity<Editor>,
|
||||
language_registry: Option<Arc<LanguageRegistry>>,
|
||||
cx: &mut App,
|
||||
) -> Vec<BlockProperties<Anchor>> {
|
||||
let blocks = Self::diagnostic_blocks_for_group(diagnostic_group, buffer_id, None, cx);
|
||||
let blocks = Self::diagnostic_blocks_for_group(
|
||||
diagnostic_group,
|
||||
buffer_id,
|
||||
None,
|
||||
language_registry,
|
||||
cx,
|
||||
);
|
||||
|
||||
blocks
|
||||
.into_iter()
|
||||
@@ -146,9 +159,16 @@ impl editor::DiagnosticRenderer for DiagnosticRenderer {
|
||||
diagnostic_group: Vec<DiagnosticEntryRef<'_, Point>>,
|
||||
range: Range<Point>,
|
||||
buffer_id: BufferId,
|
||||
language_registry: Option<Arc<LanguageRegistry>>,
|
||||
cx: &mut App,
|
||||
) -> Option<Entity<Markdown>> {
|
||||
let blocks = Self::diagnostic_blocks_for_group(diagnostic_group, buffer_id, None, cx);
|
||||
let blocks = Self::diagnostic_blocks_for_group(
|
||||
diagnostic_group,
|
||||
buffer_id,
|
||||
None,
|
||||
language_registry,
|
||||
cx,
|
||||
);
|
||||
blocks
|
||||
.into_iter()
|
||||
.find_map(|block| (block.initial_range == range).then(|| block.markdown))
|
||||
@@ -206,6 +226,11 @@ impl DiagnosticBlock {
|
||||
self.markdown.clone(),
|
||||
diagnostics_markdown_style(bcx.window, cx),
|
||||
)
|
||||
.code_block_renderer(markdown::CodeBlockRenderer::Default {
|
||||
copy_button: false,
|
||||
copy_button_on_hover: false,
|
||||
border: false,
|
||||
})
|
||||
.on_url_click({
|
||||
move |link, window, cx| {
|
||||
editor
|
||||
|
||||
@@ -73,7 +73,7 @@ pub fn init(cx: &mut App) {
|
||||
}
|
||||
|
||||
pub(crate) struct ProjectDiagnosticsEditor {
|
||||
project: Entity<Project>,
|
||||
pub project: Entity<Project>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
focus_handle: FocusHandle,
|
||||
editor: Entity<Editor>,
|
||||
@@ -182,7 +182,6 @@ impl ProjectDiagnosticsEditor {
|
||||
project::Event::DiskBasedDiagnosticsFinished { language_server_id } => {
|
||||
log::debug!("disk based diagnostics finished for server {language_server_id}");
|
||||
this.close_diagnosticless_buffers(
|
||||
window,
|
||||
cx,
|
||||
this.editor.focus_handle(cx).contains_focused(window, cx)
|
||||
|| this.focus_handle.contains_focused(window, cx),
|
||||
@@ -247,10 +246,10 @@ impl ProjectDiagnosticsEditor {
|
||||
window.focus(&this.focus_handle);
|
||||
}
|
||||
}
|
||||
EditorEvent::Blurred => this.close_diagnosticless_buffers(window, cx, false),
|
||||
EditorEvent::Saved => this.close_diagnosticless_buffers(window, cx, true),
|
||||
EditorEvent::Blurred => this.close_diagnosticless_buffers(cx, false),
|
||||
EditorEvent::Saved => this.close_diagnosticless_buffers(cx, true),
|
||||
EditorEvent::SelectionsChanged { .. } => {
|
||||
this.close_diagnosticless_buffers(window, cx, true)
|
||||
this.close_diagnosticless_buffers(cx, true)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -298,12 +297,7 @@ impl ProjectDiagnosticsEditor {
|
||||
/// - have no diagnostics anymore
|
||||
/// - are saved (not dirty)
|
||||
/// - and, if `retain_selections` is true, do not have selections within them
|
||||
fn close_diagnosticless_buffers(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
retain_selections: bool,
|
||||
) {
|
||||
fn close_diagnosticless_buffers(&mut self, cx: &mut Context<Self>, retain_selections: bool) {
|
||||
let snapshot = self
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.display_snapshot(cx));
|
||||
@@ -447,7 +441,7 @@ impl ProjectDiagnosticsEditor {
|
||||
fn focus_out(&mut self, _: FocusOutEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.focus_handle.is_focused(window) && !self.editor.focus_handle(cx).is_focused(window)
|
||||
{
|
||||
self.close_diagnosticless_buffers(window, cx, false);
|
||||
self.close_diagnosticless_buffers(cx, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,8 +455,7 @@ impl ProjectDiagnosticsEditor {
|
||||
});
|
||||
}
|
||||
});
|
||||
self.multibuffer
|
||||
.update(cx, |multibuffer, cx| multibuffer.clear(cx));
|
||||
self.close_diagnosticless_buffers(cx, false);
|
||||
self.project.update(cx, |project, cx| {
|
||||
self.paths_to_update = project
|
||||
.diagnostic_summaries(false, cx)
|
||||
@@ -552,11 +545,15 @@ impl ProjectDiagnosticsEditor {
|
||||
if group_severity.is_none_or(|s| s > max_severity) {
|
||||
continue;
|
||||
}
|
||||
let languages = this
|
||||
.read_with(cx, |t, cx| t.project.read(cx).languages().clone())
|
||||
.ok();
|
||||
let more = cx.update(|_, cx| {
|
||||
crate::diagnostic_renderer::DiagnosticRenderer::diagnostic_blocks_for_group(
|
||||
group,
|
||||
buffer_snapshot.remote_id(),
|
||||
Some(diagnostics_toolbar_editor.clone()),
|
||||
languages,
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
@@ -564,6 +561,20 @@ impl ProjectDiagnosticsEditor {
|
||||
blocks.extend(more);
|
||||
}
|
||||
|
||||
let cmp_excerpts = |buffer_snapshot: &BufferSnapshot,
|
||||
a: &ExcerptRange<text::Anchor>,
|
||||
b: &ExcerptRange<text::Anchor>| {
|
||||
let context_start = || a.context.start.cmp(&b.context.start, buffer_snapshot);
|
||||
let context_end = || a.context.end.cmp(&b.context.end, buffer_snapshot);
|
||||
let primary_start = || a.primary.start.cmp(&b.primary.start, buffer_snapshot);
|
||||
let primary_end = || a.primary.end.cmp(&b.primary.end, buffer_snapshot);
|
||||
context_start()
|
||||
.then_with(context_end)
|
||||
.then_with(primary_start)
|
||||
.then_with(primary_end)
|
||||
.then(cmp::Ordering::Greater)
|
||||
};
|
||||
|
||||
let mut excerpt_ranges: Vec<ExcerptRange<_>> = this.update(cx, |this, cx| {
|
||||
this.multibuffer.update(cx, |multi_buffer, cx| {
|
||||
let is_dirty = multi_buffer
|
||||
@@ -575,10 +586,12 @@ impl ProjectDiagnosticsEditor {
|
||||
.excerpts_for_buffer(buffer_id, cx)
|
||||
.into_iter()
|
||||
.map(|(_, range)| range)
|
||||
.sorted_by(|a, b| cmp_excerpts(&buffer_snapshot, a, b))
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut result_blocks = vec![None; excerpt_ranges.len()];
|
||||
let context_lines = cx.update(|_, cx| multibuffer_context_lines(cx))?;
|
||||
for b in blocks {
|
||||
@@ -592,40 +605,14 @@ impl ProjectDiagnosticsEditor {
|
||||
buffer_snapshot = cx.update(|_, cx| buffer.read(cx).snapshot())?;
|
||||
let initial_range = buffer_snapshot.anchor_after(b.initial_range.start)
|
||||
..buffer_snapshot.anchor_before(b.initial_range.end);
|
||||
|
||||
let bin_search = |probe: &ExcerptRange<text::Anchor>| {
|
||||
let context_start = || {
|
||||
probe
|
||||
.context
|
||||
.start
|
||||
.cmp(&excerpt_range.start, &buffer_snapshot)
|
||||
};
|
||||
let context_end =
|
||||
|| probe.context.end.cmp(&excerpt_range.end, &buffer_snapshot);
|
||||
let primary_start = || {
|
||||
probe
|
||||
.primary
|
||||
.start
|
||||
.cmp(&initial_range.start, &buffer_snapshot)
|
||||
};
|
||||
let primary_end =
|
||||
|| probe.primary.end.cmp(&initial_range.end, &buffer_snapshot);
|
||||
context_start()
|
||||
.then_with(context_end)
|
||||
.then_with(primary_start)
|
||||
.then_with(primary_end)
|
||||
.then(cmp::Ordering::Greater)
|
||||
let excerpt_range = ExcerptRange {
|
||||
context: excerpt_range,
|
||||
primary: initial_range,
|
||||
};
|
||||
let i = excerpt_ranges
|
||||
.binary_search_by(bin_search)
|
||||
.binary_search_by(|probe| cmp_excerpts(&buffer_snapshot, probe, &excerpt_range))
|
||||
.unwrap_or_else(|i| i);
|
||||
excerpt_ranges.insert(
|
||||
i,
|
||||
ExcerptRange {
|
||||
context: excerpt_range,
|
||||
primary: initial_range,
|
||||
},
|
||||
);
|
||||
excerpt_ranges.insert(i, excerpt_range);
|
||||
result_blocks.insert(i, Some(b));
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ actions!(
|
||||
]
|
||||
);
|
||||
|
||||
const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
|
||||
const COPILOT_SETTINGS_PATH: &str = "/settings/copilot";
|
||||
const COPILOT_SETTINGS_URL: &str = concat!("https://github.com", "/settings/copilot");
|
||||
const PRIVACY_DOCS: &str = "https://zed.dev/docs/ai/privacy-and-security";
|
||||
|
||||
struct CopilotErrorToast;
|
||||
@@ -128,20 +129,21 @@ impl Render for EditPredictionButton {
|
||||
}),
|
||||
);
|
||||
}
|
||||
let this = cx.entity();
|
||||
let this = cx.weak_entity();
|
||||
|
||||
div().child(
|
||||
PopoverMenu::new("copilot")
|
||||
.menu(move |window, cx| {
|
||||
let current_status = Copilot::global(cx)?.read(cx).status();
|
||||
Some(match current_status {
|
||||
match current_status {
|
||||
Status::Authorized => this.update(cx, |this, cx| {
|
||||
this.build_copilot_context_menu(window, cx)
|
||||
}),
|
||||
_ => this.update(cx, |this, cx| {
|
||||
this.build_copilot_start_menu(window, cx)
|
||||
}),
|
||||
})
|
||||
}
|
||||
.ok()
|
||||
})
|
||||
.anchor(Corner::BottomRight)
|
||||
.trigger_with_tooltip(
|
||||
@@ -182,7 +184,7 @@ impl Render for EditPredictionButton {
|
||||
let icon = status.to_icon();
|
||||
let tooltip_text = status.to_tooltip();
|
||||
let has_menu = status.has_menu();
|
||||
let this = cx.entity();
|
||||
let this = cx.weak_entity();
|
||||
let fs = self.fs.clone();
|
||||
|
||||
div().child(
|
||||
@@ -209,9 +211,11 @@ impl Render for EditPredictionButton {
|
||||
)
|
||||
}))
|
||||
}
|
||||
SupermavenButtonStatus::Ready => Some(this.update(cx, |this, cx| {
|
||||
this.build_supermaven_context_menu(window, cx)
|
||||
})),
|
||||
SupermavenButtonStatus::Ready => this
|
||||
.update(cx, |this, cx| {
|
||||
this.build_supermaven_context_menu(window, cx)
|
||||
})
|
||||
.ok(),
|
||||
_ => None,
|
||||
})
|
||||
.anchor(Corner::BottomRight)
|
||||
@@ -233,15 +237,16 @@ impl Render for EditPredictionButton {
|
||||
let enabled = self.editor_enabled.unwrap_or(true);
|
||||
let has_api_key = CodestralCompletionProvider::has_api_key(cx);
|
||||
let fs = self.fs.clone();
|
||||
let this = cx.entity();
|
||||
let this = cx.weak_entity();
|
||||
|
||||
div().child(
|
||||
PopoverMenu::new("codestral")
|
||||
.menu(move |window, cx| {
|
||||
if has_api_key {
|
||||
Some(this.update(cx, |this, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.build_codestral_context_menu(window, cx)
|
||||
}))
|
||||
})
|
||||
.ok()
|
||||
} else {
|
||||
Some(ContextMenu::build(window, cx, |menu, _, _| {
|
||||
let fs = fs.clone();
|
||||
@@ -832,6 +837,16 @@ impl EditPredictionButton {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Entity<ContextMenu> {
|
||||
let all_language_settings = all_language_settings(None, cx);
|
||||
let copilot_config = copilot::copilot_chat::CopilotChatConfiguration {
|
||||
enterprise_uri: all_language_settings
|
||||
.edit_predictions
|
||||
.copilot
|
||||
.enterprise_uri
|
||||
.clone(),
|
||||
};
|
||||
let settings_url = copilot_settings_url(copilot_config.enterprise_uri.as_deref());
|
||||
|
||||
ContextMenu::build(window, cx, |menu, window, cx| {
|
||||
let menu = self.build_language_settings_menu(menu, window, cx);
|
||||
let menu =
|
||||
@@ -840,10 +855,7 @@ impl EditPredictionButton {
|
||||
menu.separator()
|
||||
.link(
|
||||
"Go to Copilot Settings",
|
||||
OpenBrowser {
|
||||
url: COPILOT_SETTINGS_URL.to_string(),
|
||||
}
|
||||
.boxed_clone(),
|
||||
OpenBrowser { url: settings_url }.boxed_clone(),
|
||||
)
|
||||
.action("Sign Out", copilot::SignOut.boxed_clone())
|
||||
})
|
||||
@@ -1172,3 +1184,99 @@ fn toggle_edit_prediction_mode(fs: Arc<dyn Fs>, mode: EditPredictionsMode, cx: &
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn copilot_settings_url(enterprise_uri: Option<&str>) -> String {
|
||||
match enterprise_uri {
|
||||
Some(uri) => {
|
||||
format!("{}{}", uri.trim_end_matches('/'), COPILOT_SETTINGS_PATH)
|
||||
}
|
||||
None => COPILOT_SETTINGS_URL.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_copilot_settings_url_with_enterprise_uri(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
});
|
||||
|
||||
cx.update_global(|settings_store: &mut SettingsStore, cx| {
|
||||
settings_store
|
||||
.set_user_settings(
|
||||
r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com"}}}"#,
|
||||
cx,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let url = cx.update(|cx| {
|
||||
let all_language_settings = all_language_settings(None, cx);
|
||||
copilot_settings_url(
|
||||
all_language_settings
|
||||
.edit_predictions
|
||||
.copilot
|
||||
.enterprise_uri
|
||||
.as_deref(),
|
||||
)
|
||||
});
|
||||
|
||||
assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_copilot_settings_url_with_enterprise_uri_trailing_slash(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
});
|
||||
|
||||
cx.update_global(|settings_store: &mut SettingsStore, cx| {
|
||||
settings_store
|
||||
.set_user_settings(
|
||||
r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com/"}}}"#,
|
||||
cx,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let url = cx.update(|cx| {
|
||||
let all_language_settings = all_language_settings(None, cx);
|
||||
copilot_settings_url(
|
||||
all_language_settings
|
||||
.edit_predictions
|
||||
.copilot
|
||||
.enterprise_uri
|
||||
.as_deref(),
|
||||
)
|
||||
});
|
||||
|
||||
assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_copilot_settings_url_without_enterprise_uri(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
});
|
||||
|
||||
let url = cx.update(|cx| {
|
||||
let all_language_settings = all_language_settings(None, cx);
|
||||
copilot_settings_url(
|
||||
all_language_settings
|
||||
.edit_predictions
|
||||
.copilot
|
||||
.enterprise_uri
|
||||
.as_deref(),
|
||||
)
|
||||
});
|
||||
|
||||
assert_eq!(url, "https://github.com/settings/copilot");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +305,8 @@ impl CompletionBuilder {
|
||||
icon_path: None,
|
||||
insert_text_mode: None,
|
||||
confirm: None,
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ use project::{CompletionDisplayOptions, CompletionSource};
|
||||
use task::DebugScenario;
|
||||
use task::TaskContext;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::{
|
||||
@@ -36,12 +35,13 @@ use util::ResultExt;
|
||||
|
||||
use crate::hover_popover::{hover_markdown_style, open_markdown_url};
|
||||
use crate::{
|
||||
CodeActionProvider, CompletionId, CompletionItemKind, CompletionProvider, DisplayRow, Editor,
|
||||
EditorStyle, ResolvedTasks,
|
||||
CodeActionProvider, CompletionId, CompletionProvider, DisplayRow, Editor, EditorStyle,
|
||||
ResolvedTasks,
|
||||
actions::{ConfirmCodeAction, ConfirmCompletion},
|
||||
split_words, styled_runs_for_code_label,
|
||||
};
|
||||
use crate::{CodeActionSource, EditorSettings};
|
||||
use collections::{HashSet, VecDeque};
|
||||
use settings::{Settings, SnippetSortOrder};
|
||||
|
||||
pub const MENU_GAP: Pixels = px(4.);
|
||||
@@ -220,7 +220,9 @@ pub struct CompletionsMenu {
|
||||
pub is_incomplete: bool,
|
||||
pub buffer: Entity<Buffer>,
|
||||
pub completions: Rc<RefCell<Box<[Completion]>>>,
|
||||
match_candidates: Arc<[StringMatchCandidate]>,
|
||||
/// String match candidate for each completion, grouped by `match_start`.
|
||||
match_candidates: Arc<[(Option<text::Anchor>, Vec<StringMatchCandidate>)]>,
|
||||
/// Entries displayed in the menu, which is a filtered and sorted subset of `match_candidates`.
|
||||
pub entries: Rc<RefCell<Box<[StringMatch]>>>,
|
||||
pub selected_item: usize,
|
||||
filter_task: Task<()>,
|
||||
@@ -308,6 +310,8 @@ impl CompletionsMenu {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(id, completion)| StringMatchCandidate::new(id, completion.label.filter_text()))
|
||||
.into_group_map_by(|candidate| completions[candidate.id].match_start)
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let completions_menu = Self {
|
||||
@@ -355,6 +359,8 @@ impl CompletionsMenu {
|
||||
replace_range: selection.start.text_anchor..selection.end.text_anchor,
|
||||
new_text: choice.to_string(),
|
||||
label: CodeLabel::plain(choice.to_string(), None),
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
icon_path: None,
|
||||
documentation: None,
|
||||
confirm: None,
|
||||
@@ -363,11 +369,14 @@ impl CompletionsMenu {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let match_candidates = choices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(id, completion)| StringMatchCandidate::new(id, completion))
|
||||
.collect();
|
||||
let match_candidates = Arc::new([(
|
||||
None,
|
||||
choices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(id, completion)| StringMatchCandidate::new(id, completion))
|
||||
.collect(),
|
||||
)]);
|
||||
let entries = choices
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -497,7 +506,7 @@ impl CompletionsMenu {
|
||||
cx: &mut Context<Editor>,
|
||||
) {
|
||||
self.scroll_handle
|
||||
.scroll_to_item(self.selected_item, ScrollStrategy::Top);
|
||||
.scroll_to_item(self.selected_item, ScrollStrategy::Nearest);
|
||||
if let Some(provider) = provider {
|
||||
let entries = self.entries.borrow();
|
||||
let entry = if self.selected_item < entries.len() {
|
||||
@@ -948,7 +957,7 @@ impl CompletionsMenu {
|
||||
}
|
||||
|
||||
let mat = &self.entries.borrow()[self.selected_item];
|
||||
let completions = self.completions.borrow_mut();
|
||||
let completions = self.completions.borrow();
|
||||
let multiline_docs = match completions[mat.candidate_id].documentation.as_ref() {
|
||||
Some(CompletionDocumentation::MultiLinePlainText(text)) => div().child(text.clone()),
|
||||
Some(CompletionDocumentation::SingleLineAndMultiLinePlainText {
|
||||
@@ -1026,57 +1035,74 @@ impl CompletionsMenu {
|
||||
|
||||
pub fn filter(
|
||||
&mut self,
|
||||
query: Option<Arc<String>>,
|
||||
query: Arc<String>,
|
||||
query_end: text::Anchor,
|
||||
buffer: &Entity<Buffer>,
|
||||
provider: Option<Rc<dyn CompletionProvider>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Editor>,
|
||||
) {
|
||||
self.cancel_filter.store(true, Ordering::Relaxed);
|
||||
if let Some(query) = query {
|
||||
self.cancel_filter = Arc::new(AtomicBool::new(false));
|
||||
let matches = self.do_async_filtering(query, cx);
|
||||
let id = self.id;
|
||||
self.filter_task = cx.spawn_in(window, async move |editor, cx| {
|
||||
let matches = matches.await;
|
||||
editor
|
||||
.update_in(cx, |editor, window, cx| {
|
||||
editor.with_completions_menu_matching_id(id, |this| {
|
||||
if let Some(this) = this {
|
||||
this.set_filter_results(matches, provider, window, cx);
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
} else {
|
||||
self.filter_task = Task::ready(());
|
||||
let matches = self.unfiltered_matches();
|
||||
self.set_filter_results(matches, provider, window, cx);
|
||||
}
|
||||
self.cancel_filter = Arc::new(AtomicBool::new(false));
|
||||
let matches = self.do_async_filtering(query, query_end, buffer, cx);
|
||||
let id = self.id;
|
||||
self.filter_task = cx.spawn_in(window, async move |editor, cx| {
|
||||
let matches = matches.await;
|
||||
editor
|
||||
.update_in(cx, |editor, window, cx| {
|
||||
editor.with_completions_menu_matching_id(id, |this| {
|
||||
if let Some(this) = this {
|
||||
this.set_filter_results(matches, provider, window, cx);
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn do_async_filtering(
|
||||
&self,
|
||||
query: Arc<String>,
|
||||
query_end: text::Anchor,
|
||||
buffer: &Entity<Buffer>,
|
||||
cx: &Context<Editor>,
|
||||
) -> Task<Vec<StringMatch>> {
|
||||
let matches_task = cx.background_spawn({
|
||||
let query = query.clone();
|
||||
let match_candidates = self.match_candidates.clone();
|
||||
let cancel_filter = self.cancel_filter.clone();
|
||||
let background_executor = cx.background_executor().clone();
|
||||
async move {
|
||||
fuzzy::match_strings(
|
||||
&match_candidates,
|
||||
&query,
|
||||
query.chars().any(|c| c.is_uppercase()),
|
||||
false,
|
||||
1000,
|
||||
&cancel_filter,
|
||||
background_executor,
|
||||
)
|
||||
.await
|
||||
let buffer_snapshot = buffer.read(cx).snapshot();
|
||||
let background_executor = cx.background_executor().clone();
|
||||
let match_candidates = self.match_candidates.clone();
|
||||
let cancel_filter = self.cancel_filter.clone();
|
||||
let default_query = query.clone();
|
||||
|
||||
let matches_task = cx.background_spawn(async move {
|
||||
let queries_and_candidates = match_candidates
|
||||
.iter()
|
||||
.map(|(query_start, candidates)| {
|
||||
let query_for_batch = match query_start {
|
||||
Some(start) => {
|
||||
Arc::new(buffer_snapshot.text_for_range(*start..query_end).collect())
|
||||
}
|
||||
None => default_query.clone(),
|
||||
};
|
||||
(query_for_batch, candidates)
|
||||
})
|
||||
.collect_vec();
|
||||
|
||||
let mut results = vec![];
|
||||
for (query, match_candidates) in queries_and_candidates {
|
||||
results.extend(
|
||||
fuzzy::match_strings(
|
||||
&match_candidates,
|
||||
&query,
|
||||
query.chars().any(|c| c.is_uppercase()),
|
||||
false,
|
||||
1000,
|
||||
&cancel_filter,
|
||||
background_executor.clone(),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
results
|
||||
});
|
||||
|
||||
let completions = self.completions.clone();
|
||||
@@ -1085,45 +1111,31 @@ impl CompletionsMenu {
|
||||
cx.foreground_executor().spawn(async move {
|
||||
let mut matches = matches_task.await;
|
||||
|
||||
let completions_ref = completions.borrow();
|
||||
|
||||
if sort_completions {
|
||||
matches = Self::sort_string_matches(
|
||||
matches,
|
||||
Some(&query),
|
||||
Some(&query), // used for non-snippets only
|
||||
snippet_sort_order,
|
||||
completions.borrow().as_ref(),
|
||||
&completions_ref,
|
||||
);
|
||||
}
|
||||
|
||||
// Remove duplicate snippet prefixes (e.g., "cool code" will match
|
||||
// the text "c c" in two places; we should only show the longer one)
|
||||
let mut snippets_seen = HashSet::<(usize, usize)>::default();
|
||||
matches.retain(|result| {
|
||||
match completions_ref[result.candidate_id].snippet_deduplication_key {
|
||||
Some(key) => snippets_seen.insert(key),
|
||||
None => true,
|
||||
}
|
||||
});
|
||||
|
||||
matches
|
||||
})
|
||||
}
|
||||
|
||||
/// Like `do_async_filtering` but there is no filter query, so no need to spawn tasks.
|
||||
pub fn unfiltered_matches(&self) -> Vec<StringMatch> {
|
||||
let mut matches = self
|
||||
.match_candidates
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(candidate_id, candidate)| StringMatch {
|
||||
candidate_id,
|
||||
score: Default::default(),
|
||||
positions: Default::default(),
|
||||
string: candidate.string.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
if self.sort_completions {
|
||||
matches = Self::sort_string_matches(
|
||||
matches,
|
||||
None,
|
||||
self.snippet_sort_order,
|
||||
self.completions.borrow().as_ref(),
|
||||
);
|
||||
}
|
||||
|
||||
matches
|
||||
}
|
||||
|
||||
pub fn set_filter_results(
|
||||
&mut self,
|
||||
matches: Vec<StringMatch>,
|
||||
@@ -1166,28 +1178,13 @@ impl CompletionsMenu {
|
||||
.and_then(|c| c.to_lowercase().next());
|
||||
|
||||
if snippet_sort_order == SnippetSortOrder::None {
|
||||
matches.retain(|string_match| {
|
||||
let completion = &completions[string_match.candidate_id];
|
||||
|
||||
let is_snippet = matches!(
|
||||
&completion.source,
|
||||
CompletionSource::Lsp { lsp_completion, .. }
|
||||
if lsp_completion.kind == Some(CompletionItemKind::SNIPPET)
|
||||
);
|
||||
|
||||
!is_snippet
|
||||
});
|
||||
matches
|
||||
.retain(|string_match| !completions[string_match.candidate_id].is_snippet_kind());
|
||||
}
|
||||
|
||||
matches.sort_unstable_by_key(|string_match| {
|
||||
let completion = &completions[string_match.candidate_id];
|
||||
|
||||
let is_snippet = matches!(
|
||||
&completion.source,
|
||||
CompletionSource::Lsp { lsp_completion, .. }
|
||||
if lsp_completion.kind == Some(CompletionItemKind::SNIPPET)
|
||||
);
|
||||
|
||||
let sort_text = match &completion.source {
|
||||
CompletionSource::Lsp { lsp_completion, .. } => lsp_completion.sort_text.as_deref(),
|
||||
CompletionSource::Dap { sort_text } => Some(sort_text.as_str()),
|
||||
@@ -1199,14 +1196,17 @@ impl CompletionsMenu {
|
||||
let score = string_match.score;
|
||||
let sort_score = Reverse(OrderedFloat(score));
|
||||
|
||||
let query_start_doesnt_match_split_words = query_start_lower
|
||||
.map(|query_char| {
|
||||
!split_words(&string_match.string).any(|word| {
|
||||
word.chars().next().and_then(|c| c.to_lowercase().next())
|
||||
== Some(query_char)
|
||||
// Snippets do their own first-letter matching logic elsewhere.
|
||||
let is_snippet = completion.is_snippet_kind();
|
||||
let query_start_doesnt_match_split_words = !is_snippet
|
||||
&& query_start_lower
|
||||
.map(|query_char| {
|
||||
!split_words(&string_match.string).any(|word| {
|
||||
word.chars().next().and_then(|c| c.to_lowercase().next())
|
||||
== Some(query_char)
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
.unwrap_or(false);
|
||||
|
||||
if query_start_doesnt_match_split_words {
|
||||
MatchTier::OtherMatch { sort_score }
|
||||
@@ -1218,6 +1218,7 @@ impl CompletionsMenu {
|
||||
SnippetSortOrder::None => Reverse(0),
|
||||
};
|
||||
let sort_positions = string_match.positions.clone();
|
||||
// This exact matching won't work for multi-word snippets, but it's fine
|
||||
let sort_exact = Reverse(if Some(completion.label.filter_text()) == query {
|
||||
1
|
||||
} else {
|
||||
|
||||
@@ -1097,7 +1097,7 @@ impl DisplaySnapshot {
|
||||
details: &TextLayoutDetails,
|
||||
) -> u32 {
|
||||
let layout_line = self.layout_row(display_row, details);
|
||||
layout_line.index_for_x(x) as u32
|
||||
layout_line.closest_index_for_x(x) as u32
|
||||
}
|
||||
|
||||
pub fn grapheme_at(&self, mut point: DisplayPoint) -> Option<SharedString> {
|
||||
|
||||
@@ -74,7 +74,7 @@ use ::git::{
|
||||
blame::{BlameEntry, ParsedCommitMessage},
|
||||
status::FileStatus,
|
||||
};
|
||||
use aho_corasick::AhoCorasick;
|
||||
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, BuildError};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use blink_manager::BlinkManager;
|
||||
use buffer_diff::DiffHunkStatus;
|
||||
@@ -117,8 +117,9 @@ use language::{
|
||||
AutoindentMode, BlockCommentConfig, BracketMatch, BracketPair, Buffer, BufferRow,
|
||||
BufferSnapshot, Capability, CharClassifier, CharKind, CharScopeContext, CodeLabel, CursorShape,
|
||||
DiagnosticEntryRef, DiffOptions, EditPredictionsMode, EditPreview, HighlightedText, IndentKind,
|
||||
IndentSize, Language, OffsetRangeExt, OutlineItem, Point, Runnable, RunnableRange, Selection,
|
||||
SelectionGoal, TextObject, TransactionId, TreeSitterOptions, WordsQuery,
|
||||
IndentSize, Language, LanguageRegistry, OffsetRangeExt, OutlineItem, Point, Runnable,
|
||||
RunnableRange, Selection, SelectionGoal, TextObject, TransactionId, TreeSitterOptions,
|
||||
WordsQuery,
|
||||
language_settings::{
|
||||
self, LspInsertMode, RewrapBehavior, WordsCompletionMode, all_language_settings,
|
||||
language_settings,
|
||||
@@ -371,6 +372,7 @@ pub trait DiagnosticRenderer {
|
||||
buffer_id: BufferId,
|
||||
snapshot: EditorSnapshot,
|
||||
editor: WeakEntity<Editor>,
|
||||
language_registry: Option<Arc<LanguageRegistry>>,
|
||||
cx: &mut App,
|
||||
) -> Vec<BlockProperties<Anchor>>;
|
||||
|
||||
@@ -379,6 +381,7 @@ pub trait DiagnosticRenderer {
|
||||
diagnostic_group: Vec<DiagnosticEntryRef<'_, Point>>,
|
||||
range: Range<Point>,
|
||||
buffer_id: BufferId,
|
||||
language_registry: Option<Arc<LanguageRegistry>>,
|
||||
cx: &mut App,
|
||||
) -> Option<Entity<markdown::Markdown>>;
|
||||
|
||||
@@ -1190,6 +1193,7 @@ pub struct Editor {
|
||||
refresh_colors_task: Task<()>,
|
||||
inlay_hints: Option<LspInlayHintData>,
|
||||
folding_newlines: Task<()>,
|
||||
select_next_is_case_sensitive: Option<bool>,
|
||||
pub lookup_key: Option<Box<dyn Any + Send + Sync>>,
|
||||
}
|
||||
|
||||
@@ -2333,6 +2337,7 @@ impl Editor {
|
||||
selection_drag_state: SelectionDragState::None,
|
||||
folding_newlines: Task::ready(()),
|
||||
lookup_key: None,
|
||||
select_next_is_case_sensitive: None,
|
||||
};
|
||||
|
||||
if is_minimap {
|
||||
@@ -3447,6 +3452,21 @@ impl Editor {
|
||||
Subscription::join(other_subscription, this_subscription)
|
||||
}
|
||||
|
||||
fn unfold_buffers_with_selections(&mut self, cx: &mut Context<Self>) {
|
||||
if self.buffer().read(cx).is_singleton() {
|
||||
return;
|
||||
}
|
||||
let snapshot = self.buffer.read(cx).snapshot(cx);
|
||||
let buffer_ids: HashSet<BufferId> = self
|
||||
.selections
|
||||
.disjoint_anchor_ranges()
|
||||
.flat_map(|range| snapshot.buffer_ids_for_range(range))
|
||||
.collect();
|
||||
for buffer_id in buffer_ids {
|
||||
self.unfold_buffer(buffer_id, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes selections using the provided mutation function. Changes to `self.selections` occur
|
||||
/// immediately, but when run within `transact` or `with_selection_effects_deferred` other
|
||||
/// effects of selection change occur at the end of the transaction.
|
||||
@@ -4059,17 +4079,24 @@ impl Editor {
|
||||
self.selection_mark_mode = false;
|
||||
self.selection_drag_state = SelectionDragState::None;
|
||||
|
||||
if self.dismiss_menus_and_popups(true, window, cx) {
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
if self.clear_expanded_diff_hunks(cx) {
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
if self.dismiss_menus_and_popups(true, window, cx) {
|
||||
if self.show_git_blame_gutter {
|
||||
self.show_git_blame_gutter = false;
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
if self.mode.is_full()
|
||||
&& self.change_selections(Default::default(), window, cx, |s| s.try_cancel())
|
||||
{
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4188,6 +4215,8 @@ impl Editor {
|
||||
|
||||
self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx);
|
||||
|
||||
self.unfold_buffers_with_selections(cx);
|
||||
|
||||
let selections = self.selections.all_adjusted(&self.display_snapshot(cx));
|
||||
let mut bracket_inserted = false;
|
||||
let mut edits = Vec::new();
|
||||
@@ -5491,7 +5520,14 @@ impl Editor {
|
||||
|
||||
if let Some(CodeContextMenu::Completions(menu)) = self.context_menu.borrow_mut().as_mut() {
|
||||
if filter_completions {
|
||||
menu.filter(query.clone(), provider.clone(), window, cx);
|
||||
menu.filter(
|
||||
query.clone().unwrap_or_default(),
|
||||
buffer_position.text_anchor,
|
||||
&buffer,
|
||||
provider.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
// When `is_incomplete` is false, no need to re-query completions when the current query
|
||||
// is a suffix of the initial query.
|
||||
@@ -5500,7 +5536,7 @@ impl Editor {
|
||||
// If the new query is a suffix of the old query (typing more characters) and
|
||||
// the previous result was complete, the existing completions can be filtered.
|
||||
//
|
||||
// Note that this is always true for snippet completions.
|
||||
// Note that snippet completions are always complete.
|
||||
let query_matches = match (&menu.initial_query, &query) {
|
||||
(Some(initial_query), Some(query)) => query.starts_with(initial_query.as_ref()),
|
||||
(None, _) => true,
|
||||
@@ -5630,12 +5666,15 @@ impl Editor {
|
||||
};
|
||||
|
||||
let mut words = if load_word_completions {
|
||||
cx.background_spawn(async move {
|
||||
buffer_snapshot.words_in_range(WordsQuery {
|
||||
fuzzy_contents: None,
|
||||
range: word_search_range,
|
||||
skip_digits,
|
||||
})
|
||||
cx.background_spawn({
|
||||
let buffer_snapshot = buffer_snapshot.clone();
|
||||
async move {
|
||||
buffer_snapshot.words_in_range(WordsQuery {
|
||||
fuzzy_contents: None,
|
||||
range: word_search_range,
|
||||
skip_digits,
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Task::ready(BTreeMap::default())
|
||||
@@ -5645,8 +5684,11 @@ impl Editor {
|
||||
&& provider.show_snippets()
|
||||
&& let Some(project) = self.project()
|
||||
{
|
||||
let char_classifier = buffer_snapshot
|
||||
.char_classifier_at(buffer_position)
|
||||
.scope_context(Some(CharScopeContext::Completion));
|
||||
project.update(cx, |project, cx| {
|
||||
snippet_completions(project, &buffer, buffer_position, cx)
|
||||
snippet_completions(project, &buffer, buffer_position, char_classifier, cx)
|
||||
})
|
||||
} else {
|
||||
Task::ready(Ok(CompletionResponse {
|
||||
@@ -5701,6 +5743,8 @@ impl Editor {
|
||||
replace_range: word_replace_range.clone(),
|
||||
new_text: word.clone(),
|
||||
label: CodeLabel::plain(word, None),
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
icon_path: None,
|
||||
documentation: None,
|
||||
source: CompletionSource::BufferWord {
|
||||
@@ -5749,11 +5793,12 @@ impl Editor {
|
||||
);
|
||||
|
||||
let query = if filter_completions { query } else { None };
|
||||
let matches_task = if let Some(query) = query {
|
||||
menu.do_async_filtering(query, cx)
|
||||
} else {
|
||||
Task::ready(menu.unfiltered_matches())
|
||||
};
|
||||
let matches_task = menu.do_async_filtering(
|
||||
query.unwrap_or_default(),
|
||||
buffer_position,
|
||||
&buffer,
|
||||
cx,
|
||||
);
|
||||
(menu, matches_task)
|
||||
}) else {
|
||||
return;
|
||||
@@ -5770,7 +5815,7 @@ impl Editor {
|
||||
return;
|
||||
};
|
||||
|
||||
// Only valid to take prev_menu because it the new menu is immediately set
|
||||
// Only valid to take prev_menu because either the new menu is immediately set
|
||||
// below, or the menu is hidden.
|
||||
if let Some(CodeContextMenu::Completions(prev_menu)) =
|
||||
editor.context_menu.borrow_mut().take()
|
||||
@@ -11018,6 +11063,10 @@ impl Editor {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.breakpoint_store.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) {
|
||||
let breakpoint = breakpoint.unwrap_or_else(|| Breakpoint {
|
||||
message: None,
|
||||
@@ -11077,6 +11126,10 @@ impl Editor {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.breakpoint_store.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) {
|
||||
let Some(breakpoint) = breakpoint.filter(|breakpoint| breakpoint.is_disabled()) else {
|
||||
continue;
|
||||
@@ -11096,6 +11149,10 @@ impl Editor {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.breakpoint_store.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) {
|
||||
let Some(breakpoint) = breakpoint.filter(|breakpoint| breakpoint.is_enabled()) else {
|
||||
continue;
|
||||
@@ -11115,6 +11172,10 @@ impl Editor {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.breakpoint_store.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
for (anchor, breakpoint) in self.breakpoints_at_cursors(window, cx) {
|
||||
if let Some(breakpoint) = breakpoint {
|
||||
self.edit_breakpoint_at_anchor(
|
||||
@@ -14645,7 +14706,7 @@ impl Editor {
|
||||
.collect::<String>();
|
||||
let is_empty = query.is_empty();
|
||||
let select_state = SelectNextState {
|
||||
query: AhoCorasick::new(&[query])?,
|
||||
query: self.build_query(&[query], cx)?,
|
||||
wordwise: true,
|
||||
done: is_empty,
|
||||
};
|
||||
@@ -14655,7 +14716,7 @@ impl Editor {
|
||||
}
|
||||
} else if let Some(selected_text) = selected_text {
|
||||
self.select_next_state = Some(SelectNextState {
|
||||
query: AhoCorasick::new(&[selected_text])?,
|
||||
query: self.build_query(&[selected_text], cx)?,
|
||||
wordwise: false,
|
||||
done: false,
|
||||
});
|
||||
@@ -14863,7 +14924,7 @@ impl Editor {
|
||||
.collect::<String>();
|
||||
let is_empty = query.is_empty();
|
||||
let select_state = SelectNextState {
|
||||
query: AhoCorasick::new(&[query.chars().rev().collect::<String>()])?,
|
||||
query: self.build_query(&[query.chars().rev().collect::<String>()], cx)?,
|
||||
wordwise: true,
|
||||
done: is_empty,
|
||||
};
|
||||
@@ -14873,7 +14934,8 @@ impl Editor {
|
||||
}
|
||||
} else if let Some(selected_text) = selected_text {
|
||||
self.select_prev_state = Some(SelectNextState {
|
||||
query: AhoCorasick::new(&[selected_text.chars().rev().collect::<String>()])?,
|
||||
query: self
|
||||
.build_query(&[selected_text.chars().rev().collect::<String>()], cx)?,
|
||||
wordwise: false,
|
||||
done: false,
|
||||
});
|
||||
@@ -14883,6 +14945,25 @@ impl Editor {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds an `AhoCorasick` automaton from the provided patterns, while
|
||||
/// setting the case sensitivity based on the global
|
||||
/// `SelectNextCaseSensitive` setting, if set, otherwise based on the
|
||||
/// editor's settings.
|
||||
fn build_query<I, P>(&self, patterns: I, cx: &Context<Self>) -> Result<AhoCorasick, BuildError>
|
||||
where
|
||||
I: IntoIterator<Item = P>,
|
||||
P: AsRef<[u8]>,
|
||||
{
|
||||
let case_sensitive = self.select_next_is_case_sensitive.map_or_else(
|
||||
|| EditorSettings::get_global(cx).search.case_sensitive,
|
||||
|value| value,
|
||||
);
|
||||
|
||||
let mut builder = AhoCorasickBuilder::new();
|
||||
builder.ascii_case_insensitive(!case_sensitive);
|
||||
builder.build(patterns)
|
||||
}
|
||||
|
||||
pub fn find_next_match(
|
||||
&mut self,
|
||||
_: &FindNextMatch,
|
||||
@@ -17869,8 +17950,18 @@ impl Editor {
|
||||
.diagnostic_group(buffer_id, diagnostic.diagnostic.group_id)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let blocks =
|
||||
renderer.render_group(diagnostic_group, buffer_id, snapshot, cx.weak_entity(), cx);
|
||||
let language_registry = self
|
||||
.project()
|
||||
.map(|project| project.read(cx).languages().clone());
|
||||
|
||||
let blocks = renderer.render_group(
|
||||
diagnostic_group,
|
||||
buffer_id,
|
||||
snapshot,
|
||||
cx.weak_entity(),
|
||||
language_registry,
|
||||
cx,
|
||||
);
|
||||
|
||||
let blocks = self.display_map.update(cx, |display_map, cx| {
|
||||
display_map.insert_blocks(blocks, cx).into_iter().collect()
|
||||
@@ -18857,10 +18948,17 @@ impl Editor {
|
||||
if self.buffer().read(cx).is_singleton() || self.is_buffer_folded(buffer_id, cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let folded_excerpts = self.buffer().read(cx).excerpts_for_buffer(buffer_id, cx);
|
||||
self.display_map.update(cx, |display_map, cx| {
|
||||
display_map.fold_buffers([buffer_id], cx)
|
||||
});
|
||||
|
||||
let snapshot = self.display_snapshot(cx);
|
||||
self.selections.change_with(&snapshot, |selections| {
|
||||
selections.remove_selections_from_buffer(buffer_id);
|
||||
});
|
||||
|
||||
cx.emit(EditorEvent::BufferFoldToggled {
|
||||
ids: folded_excerpts.iter().map(|&(id, _)| id).collect(),
|
||||
folded: true,
|
||||
@@ -23148,10 +23246,11 @@ impl CodeActionProvider for Entity<Project> {
|
||||
fn snippet_completions(
|
||||
project: &Project,
|
||||
buffer: &Entity<Buffer>,
|
||||
buffer_position: text::Anchor,
|
||||
buffer_anchor: text::Anchor,
|
||||
classifier: CharClassifier,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<CompletionResponse>> {
|
||||
let languages = buffer.read(cx).languages_at(buffer_position);
|
||||
let languages = buffer.read(cx).languages_at(buffer_anchor);
|
||||
let snippet_store = project.snippets().read(cx);
|
||||
|
||||
let scopes: Vec<_> = languages
|
||||
@@ -23180,97 +23279,146 @@ fn snippet_completions(
|
||||
let executor = cx.background_executor().clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let is_word_char = |c| classifier.is_word(c);
|
||||
|
||||
let mut is_incomplete = false;
|
||||
let mut completions: Vec<Completion> = Vec::new();
|
||||
for (scope, snippets) in scopes.into_iter() {
|
||||
let classifier =
|
||||
CharClassifier::new(Some(scope)).scope_context(Some(CharScopeContext::Completion));
|
||||
|
||||
const MAX_WORD_PREFIX_LEN: usize = 128;
|
||||
let last_word: String = snapshot
|
||||
.reversed_chars_for_range(text::Anchor::MIN..buffer_position)
|
||||
.take(MAX_WORD_PREFIX_LEN)
|
||||
.take_while(|c| classifier.is_word(*c))
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect();
|
||||
const MAX_PREFIX_LEN: usize = 128;
|
||||
let buffer_offset = text::ToOffset::to_offset(&buffer_anchor, &snapshot);
|
||||
let window_start = buffer_offset.saturating_sub(MAX_PREFIX_LEN);
|
||||
let window_start = snapshot.clip_offset(window_start, Bias::Left);
|
||||
|
||||
if last_word.is_empty() {
|
||||
return Ok(CompletionResponse {
|
||||
completions: vec![],
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
is_incomplete: true,
|
||||
});
|
||||
let max_buffer_window: String = snapshot
|
||||
.text_for_range(window_start..buffer_offset)
|
||||
.collect();
|
||||
|
||||
if max_buffer_window.is_empty() {
|
||||
return Ok(CompletionResponse {
|
||||
completions: vec![],
|
||||
display_options: CompletionDisplayOptions::default(),
|
||||
is_incomplete: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (_scope, snippets) in scopes.into_iter() {
|
||||
// Sort snippets by word count to match longer snippet prefixes first.
|
||||
let mut sorted_snippet_candidates = snippets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(snippet_ix, snippet)| {
|
||||
snippet
|
||||
.prefix
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(move |(prefix_ix, prefix)| {
|
||||
let word_count =
|
||||
snippet_candidate_suffixes(prefix, is_word_char).count();
|
||||
((snippet_ix, prefix_ix), prefix, word_count)
|
||||
})
|
||||
})
|
||||
.collect_vec();
|
||||
sorted_snippet_candidates
|
||||
.sort_unstable_by_key(|(_, _, word_count)| Reverse(*word_count));
|
||||
|
||||
// Each prefix may be matched multiple times; the completion menu must filter out duplicates.
|
||||
|
||||
let buffer_windows = snippet_candidate_suffixes(&max_buffer_window, is_word_char)
|
||||
.take(
|
||||
sorted_snippet_candidates
|
||||
.first()
|
||||
.map(|(_, _, word_count)| *word_count)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.collect_vec();
|
||||
|
||||
const MAX_RESULTS: usize = 100;
|
||||
// Each match also remembers how many characters from the buffer it consumed
|
||||
let mut matches: Vec<(StringMatch, usize)> = vec![];
|
||||
|
||||
let mut snippet_list_cutoff_index = 0;
|
||||
for (buffer_index, buffer_window) in buffer_windows.iter().enumerate().rev() {
|
||||
let word_count = buffer_index + 1;
|
||||
// Increase `snippet_list_cutoff_index` until we have all of the
|
||||
// snippets with sufficiently many words.
|
||||
while sorted_snippet_candidates
|
||||
.get(snippet_list_cutoff_index)
|
||||
.is_some_and(|(_ix, _prefix, snippet_word_count)| {
|
||||
*snippet_word_count >= word_count
|
||||
})
|
||||
{
|
||||
snippet_list_cutoff_index += 1;
|
||||
}
|
||||
|
||||
// Take only the candidates with at least `word_count` many words
|
||||
let snippet_candidates_at_word_len =
|
||||
&sorted_snippet_candidates[..snippet_list_cutoff_index];
|
||||
|
||||
let candidates = snippet_candidates_at_word_len
|
||||
.iter()
|
||||
.map(|(_snippet_ix, prefix, _snippet_word_count)| prefix)
|
||||
.enumerate() // index in `sorted_snippet_candidates`
|
||||
// First char must match
|
||||
.filter(|(_ix, prefix)| {
|
||||
itertools::equal(
|
||||
prefix
|
||||
.chars()
|
||||
.next()
|
||||
.into_iter()
|
||||
.flat_map(|c| c.to_lowercase()),
|
||||
buffer_window
|
||||
.chars()
|
||||
.next()
|
||||
.into_iter()
|
||||
.flat_map(|c| c.to_lowercase()),
|
||||
)
|
||||
})
|
||||
.map(|(ix, prefix)| StringMatchCandidate::new(ix, prefix))
|
||||
.collect::<Vec<StringMatchCandidate>>();
|
||||
|
||||
matches.extend(
|
||||
fuzzy::match_strings(
|
||||
&candidates,
|
||||
&buffer_window,
|
||||
buffer_window.chars().any(|c| c.is_uppercase()),
|
||||
true,
|
||||
MAX_RESULTS - matches.len(), // always prioritize longer snippets
|
||||
&Default::default(),
|
||||
executor.clone(),
|
||||
)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|string_match| (string_match, buffer_window.len())),
|
||||
);
|
||||
|
||||
if matches.len() >= MAX_RESULTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot);
|
||||
let to_lsp = |point: &text::Anchor| {
|
||||
let end = text::ToPointUtf16::to_point_utf16(point, &snapshot);
|
||||
point_to_lsp(end)
|
||||
};
|
||||
let lsp_end = to_lsp(&buffer_position);
|
||||
|
||||
let candidates = snippets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(ix, snippet)| {
|
||||
snippet
|
||||
.prefix
|
||||
.iter()
|
||||
.map(move |prefix| StringMatchCandidate::new(ix, prefix))
|
||||
})
|
||||
.collect::<Vec<StringMatchCandidate>>();
|
||||
|
||||
const MAX_RESULTS: usize = 100;
|
||||
let mut matches = fuzzy::match_strings(
|
||||
&candidates,
|
||||
&last_word,
|
||||
last_word.chars().any(|c| c.is_uppercase()),
|
||||
true,
|
||||
MAX_RESULTS,
|
||||
&Default::default(),
|
||||
executor.clone(),
|
||||
)
|
||||
.await;
|
||||
let lsp_end = to_lsp(&buffer_anchor);
|
||||
|
||||
if matches.len() >= MAX_RESULTS {
|
||||
is_incomplete = true;
|
||||
}
|
||||
|
||||
// Remove all candidates where the query's start does not match the start of any word in the candidate
|
||||
if let Some(query_start) = last_word.chars().next() {
|
||||
matches.retain(|string_match| {
|
||||
split_words(&string_match.string).any(|word| {
|
||||
// Check that the first codepoint of the word as lowercase matches the first
|
||||
// codepoint of the query as lowercase
|
||||
word.chars()
|
||||
.flat_map(|codepoint| codepoint.to_lowercase())
|
||||
.zip(query_start.to_lowercase())
|
||||
.all(|(word_cp, query_cp)| word_cp == query_cp)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
let matched_strings = matches
|
||||
.into_iter()
|
||||
.map(|m| m.string)
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
completions.extend(snippets.iter().filter_map(|snippet| {
|
||||
let matching_prefix = snippet
|
||||
.prefix
|
||||
.iter()
|
||||
.find(|prefix| matched_strings.contains(*prefix))?;
|
||||
let start = as_offset - last_word.len();
|
||||
completions.extend(matches.iter().map(|(string_match, buffer_window_len)| {
|
||||
let ((snippet_index, prefix_index), matching_prefix, _snippet_word_count) =
|
||||
sorted_snippet_candidates[string_match.candidate_id];
|
||||
let snippet = &snippets[snippet_index];
|
||||
let start = buffer_offset - buffer_window_len;
|
||||
let start = snapshot.anchor_before(start);
|
||||
let range = start..buffer_position;
|
||||
let range = start..buffer_anchor;
|
||||
let lsp_start = to_lsp(&start);
|
||||
let lsp_range = lsp::Range {
|
||||
start: lsp_start,
|
||||
end: lsp_end,
|
||||
};
|
||||
Some(Completion {
|
||||
Completion {
|
||||
replace_range: range,
|
||||
new_text: snippet.body.clone(),
|
||||
source: CompletionSource::Lsp {
|
||||
@@ -23300,7 +23448,11 @@ fn snippet_completions(
|
||||
}),
|
||||
lsp_defaults: None,
|
||||
},
|
||||
label: CodeLabel::plain(matching_prefix.clone(), None),
|
||||
label: CodeLabel {
|
||||
text: matching_prefix.clone(),
|
||||
runs: Vec::new(),
|
||||
filter_range: 0..matching_prefix.len(),
|
||||
},
|
||||
icon_path: None,
|
||||
documentation: Some(CompletionDocumentation::SingleLineAndMultiLinePlainText {
|
||||
single_line: snippet.name.clone().into(),
|
||||
@@ -23311,8 +23463,10 @@ fn snippet_completions(
|
||||
}),
|
||||
insert_text_mode: None,
|
||||
confirm: None,
|
||||
})
|
||||
}))
|
||||
match_start: Some(start),
|
||||
snippet_deduplication_key: Some((snippet_index, prefix_index)),
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(CompletionResponse {
|
||||
@@ -24558,6 +24712,33 @@ pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator<Item = &str> +
|
||||
})
|
||||
}
|
||||
|
||||
/// Given a string of text immediately before the cursor, iterates over possible
|
||||
/// strings a snippet could match to. More precisely: returns an iterator over
|
||||
/// suffixes of `text` created by splitting at word boundaries (before & after
|
||||
/// every non-word character).
|
||||
///
|
||||
/// Shorter suffixes are returned first.
|
||||
pub(crate) fn snippet_candidate_suffixes(
|
||||
text: &str,
|
||||
is_word_char: impl Fn(char) -> bool,
|
||||
) -> impl std::iter::Iterator<Item = &str> {
|
||||
let mut prev_index = text.len();
|
||||
let mut prev_codepoint = None;
|
||||
text.char_indices()
|
||||
.rev()
|
||||
.chain([(0, '\0')])
|
||||
.filter_map(move |(index, codepoint)| {
|
||||
let prev_index = std::mem::replace(&mut prev_index, index);
|
||||
let prev_codepoint = prev_codepoint.replace(codepoint)?;
|
||||
if is_word_char(prev_codepoint) && is_word_char(codepoint) {
|
||||
None
|
||||
} else {
|
||||
let chunk = &text[prev_index..]; // go to end of string
|
||||
Some(chunk)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub trait RangeToAnchorExt: Sized {
|
||||
fn to_anchors(self, snapshot: &MultiBufferSnapshot) -> Range<Anchor>;
|
||||
|
||||
|
||||
@@ -162,10 +162,15 @@ pub struct DragAndDropSelection {
|
||||
pub struct SearchSettings {
|
||||
/// Whether to show the project search button in the status bar.
|
||||
pub button: bool,
|
||||
/// Whether to only match on whole words.
|
||||
pub whole_word: bool,
|
||||
/// Whether to match case sensitively.
|
||||
pub case_sensitive: bool,
|
||||
/// Whether to include gitignored files in search results.
|
||||
pub include_ignored: bool,
|
||||
/// Whether to interpret the search query as a regular expression.
|
||||
pub regex: bool,
|
||||
/// Whether to center the cursor on each search match when navigating.
|
||||
pub center_on_match: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ use project::{
|
||||
};
|
||||
use serde_json::{self, json};
|
||||
use settings::{
|
||||
AllLanguageSettingsContent, IndentGuideBackgroundColoring, IndentGuideColoring,
|
||||
ProjectSettingsContent,
|
||||
AllLanguageSettingsContent, EditorSettingsContent, IndentGuideBackgroundColoring,
|
||||
IndentGuideColoring, ProjectSettingsContent, SearchSettingsContent,
|
||||
};
|
||||
use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant};
|
||||
use std::{
|
||||
@@ -8314,8 +8314,15 @@ async fn test_add_selection_above_below_multi_cursor_existing_state(cx: &mut Tes
|
||||
#[gpui::test]
|
||||
async fn test_select_next(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
|
||||
// Enable case sensitive search.
|
||||
update_test_editor_settings(&mut cx, |settings| {
|
||||
let mut search_settings = SearchSettingsContent::default();
|
||||
search_settings.case_sensitive = Some(true);
|
||||
settings.search = Some(search_settings);
|
||||
});
|
||||
|
||||
cx.set_state("abc\nˇabc abc\ndefabc\nabc");
|
||||
|
||||
cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx))
|
||||
@@ -8346,14 +8353,41 @@ async fn test_select_next(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|e, window, cx| e.select_next(&SelectNext::default(), window, cx))
|
||||
.unwrap();
|
||||
cx.assert_editor_state("abc\n«ˇabc» «ˇabc»\ndefabc\nabc");
|
||||
|
||||
// Test case sensitivity
|
||||
cx.set_state("«ˇfoo»\nFOO\nFoo\nfoo");
|
||||
cx.update_editor(|e, window, cx| {
|
||||
e.select_next(&SelectNext::default(), window, cx).unwrap();
|
||||
});
|
||||
cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»");
|
||||
|
||||
// Disable case sensitive search.
|
||||
update_test_editor_settings(&mut cx, |settings| {
|
||||
let mut search_settings = SearchSettingsContent::default();
|
||||
search_settings.case_sensitive = Some(false);
|
||||
settings.search = Some(search_settings);
|
||||
});
|
||||
|
||||
cx.set_state("«ˇfoo»\nFOO\nFoo");
|
||||
cx.update_editor(|e, window, cx| {
|
||||
e.select_next(&SelectNext::default(), window, cx).unwrap();
|
||||
e.select_next(&SelectNext::default(), window, cx).unwrap();
|
||||
});
|
||||
cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\n«ˇFoo»");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_select_all_matches(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
|
||||
// Enable case sensitive search.
|
||||
update_test_editor_settings(&mut cx, |settings| {
|
||||
let mut search_settings = SearchSettingsContent::default();
|
||||
search_settings.case_sensitive = Some(true);
|
||||
settings.search = Some(search_settings);
|
||||
});
|
||||
|
||||
// Test caret-only selections
|
||||
cx.set_state("abc\nˇabc abc\ndefabc\nabc");
|
||||
cx.update_editor(|e, window, cx| e.select_all_matches(&SelectAllMatches, window, cx))
|
||||
@@ -8398,6 +8432,26 @@ async fn test_select_all_matches(cx: &mut TestAppContext) {
|
||||
e.set_clip_at_line_ends(false, cx);
|
||||
});
|
||||
cx.assert_editor_state("«abcˇ»");
|
||||
|
||||
// Test case sensitivity
|
||||
cx.set_state("fˇoo\nFOO\nFoo");
|
||||
cx.update_editor(|e, window, cx| {
|
||||
e.select_all_matches(&SelectAllMatches, window, cx).unwrap();
|
||||
});
|
||||
cx.assert_editor_state("«fooˇ»\nFOO\nFoo");
|
||||
|
||||
// Disable case sensitive search.
|
||||
update_test_editor_settings(&mut cx, |settings| {
|
||||
let mut search_settings = SearchSettingsContent::default();
|
||||
search_settings.case_sensitive = Some(false);
|
||||
settings.search = Some(search_settings);
|
||||
});
|
||||
|
||||
cx.set_state("fˇoo\nFOO\nFoo");
|
||||
cx.update_editor(|e, window, cx| {
|
||||
e.select_all_matches(&SelectAllMatches, window, cx).unwrap();
|
||||
});
|
||||
cx.assert_editor_state("«fooˇ»\n«FOOˇ»\n«Fooˇ»");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
@@ -8769,8 +8823,15 @@ let foo = «2ˇ»;"#,
|
||||
#[gpui::test]
|
||||
async fn test_select_previous_with_single_selection(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
|
||||
// Enable case sensitive search.
|
||||
update_test_editor_settings(&mut cx, |settings| {
|
||||
let mut search_settings = SearchSettingsContent::default();
|
||||
search_settings.case_sensitive = Some(true);
|
||||
settings.search = Some(search_settings);
|
||||
});
|
||||
|
||||
cx.set_state("abc\n«ˇabc» abc\ndefabc\nabc");
|
||||
|
||||
cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx))
|
||||
@@ -8795,6 +8856,32 @@ async fn test_select_previous_with_single_selection(cx: &mut TestAppContext) {
|
||||
cx.update_editor(|e, window, cx| e.select_previous(&SelectPrevious::default(), window, cx))
|
||||
.unwrap();
|
||||
cx.assert_editor_state("«ˇabc»\n«ˇabc» «ˇabc»\ndef«ˇabc»\n«ˇabc»");
|
||||
|
||||
// Test case sensitivity
|
||||
cx.set_state("foo\nFOO\nFoo\n«ˇfoo»");
|
||||
cx.update_editor(|e, window, cx| {
|
||||
e.select_previous(&SelectPrevious::default(), window, cx)
|
||||
.unwrap();
|
||||
e.select_previous(&SelectPrevious::default(), window, cx)
|
||||
.unwrap();
|
||||
});
|
||||
cx.assert_editor_state("«ˇfoo»\nFOO\nFoo\n«ˇfoo»");
|
||||
|
||||
// Disable case sensitive search.
|
||||
update_test_editor_settings(&mut cx, |settings| {
|
||||
let mut search_settings = SearchSettingsContent::default();
|
||||
search_settings.case_sensitive = Some(false);
|
||||
settings.search = Some(search_settings);
|
||||
});
|
||||
|
||||
cx.set_state("foo\nFOO\n«ˇFoo»");
|
||||
cx.update_editor(|e, window, cx| {
|
||||
e.select_previous(&SelectPrevious::default(), window, cx)
|
||||
.unwrap();
|
||||
e.select_previous(&SelectPrevious::default(), window, cx)
|
||||
.unwrap();
|
||||
});
|
||||
cx.assert_editor_state("«ˇfoo»\n«ˇFOO»\n«ˇFoo»");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
@@ -11327,6 +11414,53 @@ async fn test_snippet_indentation(cx: &mut TestAppContext) {
|
||||
ˇ"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_snippet_with_multi_word_prefix(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
cx.update_editor(|editor, _, cx| {
|
||||
editor.project().unwrap().update(cx, |project, cx| {
|
||||
project.snippets().update(cx, |snippets, _cx| {
|
||||
let snippet = project::snippet_provider::Snippet {
|
||||
prefix: vec!["multi word".to_string()],
|
||||
body: "this is many words".to_string(),
|
||||
description: Some("description".to_string()),
|
||||
name: "multi-word snippet test".to_string(),
|
||||
};
|
||||
snippets.add_snippet_for_test(
|
||||
None,
|
||||
PathBuf::from("test_snippets.json"),
|
||||
vec![Arc::new(snippet)],
|
||||
);
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
for (input_to_simulate, should_match_snippet) in [
|
||||
("m", true),
|
||||
("m ", true),
|
||||
("m w", true),
|
||||
("aa m w", true),
|
||||
("aa m g", false),
|
||||
] {
|
||||
cx.set_state("ˇ");
|
||||
cx.simulate_input(input_to_simulate); // fails correctly
|
||||
|
||||
cx.update_editor(|editor, _, _| {
|
||||
let Some(CodeContextMenu::Completions(context_menu)) = &*editor.context_menu.borrow()
|
||||
else {
|
||||
assert!(!should_match_snippet); // no completions! don't even show the menu
|
||||
return;
|
||||
};
|
||||
assert!(context_menu.visible());
|
||||
let completions = context_menu.completions.borrow();
|
||||
|
||||
assert_eq!(!completions.is_empty(), should_match_snippet);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_document_format_during_save(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
@@ -17282,6 +17416,41 @@ fn test_split_words() {
|
||||
assert_eq!(split(":do_the_thing"), &[":", "do_", "the_", "thing"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_words_for_snippet_prefix() {
|
||||
fn split(text: &str) -> Vec<&str> {
|
||||
snippet_candidate_suffixes(text, |c| c.is_alphanumeric() || c == '_').collect()
|
||||
}
|
||||
|
||||
assert_eq!(split("HelloWorld"), &["HelloWorld"]);
|
||||
assert_eq!(split("hello_world"), &["hello_world"]);
|
||||
assert_eq!(split("_hello_world_"), &["_hello_world_"]);
|
||||
assert_eq!(split("Hello_World"), &["Hello_World"]);
|
||||
assert_eq!(split("helloWOrld"), &["helloWOrld"]);
|
||||
assert_eq!(split("helloworld"), &["helloworld"]);
|
||||
assert_eq!(
|
||||
split("this@is!@#$^many . symbols"),
|
||||
&[
|
||||
"symbols",
|
||||
" symbols",
|
||||
". symbols",
|
||||
" . symbols",
|
||||
" . symbols",
|
||||
" . symbols",
|
||||
"many . symbols",
|
||||
"^many . symbols",
|
||||
"$^many . symbols",
|
||||
"#$^many . symbols",
|
||||
"@#$^many . symbols",
|
||||
"!@#$^many . symbols",
|
||||
"is!@#$^many . symbols",
|
||||
"@is!@#$^many . symbols",
|
||||
"this@is!@#$^many . symbols",
|
||||
],
|
||||
);
|
||||
assert_eq!(split("a.s"), &["s", ".s", "a.s"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_move_to_enclosing_bracket(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
@@ -22291,7 +22460,7 @@ async fn test_folding_buffers(cx: &mut TestAppContext) {
|
||||
|
||||
assert_eq!(
|
||||
multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)),
|
||||
"\n\nB\n\n\n\n\n\n\nllll\nmmmm\nnnnn\n\n\nqqqq\nrrrr\n\n\nuuuu\n\n",
|
||||
"\n\naaaa\nBbbbb\ncccc\n\n\nffff\ngggg\n\n\njjjj\n\n\nllll\nmmmm\nnnnn\n\n\nqqqq\nrrrr\n\n\nuuuu\n\n",
|
||||
"After unfolding the first buffer, its and 2nd buffer's text should be displayed"
|
||||
);
|
||||
|
||||
@@ -22300,7 +22469,7 @@ async fn test_folding_buffers(cx: &mut TestAppContext) {
|
||||
});
|
||||
assert_eq!(
|
||||
multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)),
|
||||
"\n\nB\n\n\n\n\n\n\nllll\nmmmm\nnnnn\n\n\nqqqq\nrrrr\n\n\nuuuu\n\n\nvvvv\nwwww\nxxxx\n\n\n1111\n2222\n\n\n5555",
|
||||
"\n\naaaa\nBbbbb\ncccc\n\n\nffff\ngggg\n\n\njjjj\n\n\nllll\nmmmm\nnnnn\n\n\nqqqq\nrrrr\n\n\nuuuu\n\n\nvvvv\nwwww\nxxxx\n\n\n1111\n2222\n\n\n5555",
|
||||
"After unfolding the all buffers, all original text should be displayed"
|
||||
);
|
||||
}
|
||||
@@ -25533,6 +25702,195 @@ pub fn check_displayed_completions(expected: Vec<&'static str>, cx: &mut EditorL
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_mixed_completions_with_multi_word_snippet(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
let mut cx = EditorLspTestContext::new_rust(
|
||||
lsp::ServerCapabilities {
|
||||
completion_provider: Some(lsp::CompletionOptions {
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
cx.lsp
|
||||
.set_request_handler::<lsp::request::Completion, _, _>(move |_, _| async move {
|
||||
Ok(Some(lsp::CompletionResponse::Array(vec![
|
||||
lsp::CompletionItem {
|
||||
label: "unsafe".into(),
|
||||
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
|
||||
range: lsp::Range {
|
||||
start: lsp::Position {
|
||||
line: 0,
|
||||
character: 9,
|
||||
},
|
||||
end: lsp::Position {
|
||||
line: 0,
|
||||
character: 11,
|
||||
},
|
||||
},
|
||||
new_text: "unsafe".to_string(),
|
||||
})),
|
||||
insert_text_mode: Some(lsp::InsertTextMode::AS_IS),
|
||||
..Default::default()
|
||||
},
|
||||
])))
|
||||
});
|
||||
|
||||
cx.update_editor(|editor, _, cx| {
|
||||
editor.project().unwrap().update(cx, |project, cx| {
|
||||
project.snippets().update(cx, |snippets, _cx| {
|
||||
snippets.add_snippet_for_test(
|
||||
None,
|
||||
PathBuf::from("test_snippets.json"),
|
||||
vec![
|
||||
Arc::new(project::snippet_provider::Snippet {
|
||||
prefix: vec![
|
||||
"unlimited word count".to_string(),
|
||||
"unlimit word count".to_string(),
|
||||
"unlimited unknown".to_string(),
|
||||
],
|
||||
body: "this is many words".to_string(),
|
||||
description: Some("description".to_string()),
|
||||
name: "multi-word snippet test".to_string(),
|
||||
}),
|
||||
Arc::new(project::snippet_provider::Snippet {
|
||||
prefix: vec!["unsnip".to_string(), "@few".to_string()],
|
||||
body: "fewer words".to_string(),
|
||||
description: Some("alt description".to_string()),
|
||||
name: "other name".to_string(),
|
||||
}),
|
||||
Arc::new(project::snippet_provider::Snippet {
|
||||
prefix: vec!["ab aa".to_string()],
|
||||
body: "abcd".to_string(),
|
||||
description: None,
|
||||
name: "alphabet".to_string(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
let get_completions = |cx: &mut EditorLspTestContext| {
|
||||
cx.update_editor(|editor, _, _| match &*editor.context_menu.borrow() {
|
||||
Some(CodeContextMenu::Completions(context_menu)) => {
|
||||
let entries = context_menu.entries.borrow();
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| entry.string.clone())
|
||||
.collect_vec()
|
||||
}
|
||||
_ => vec![],
|
||||
})
|
||||
};
|
||||
|
||||
// snippets:
|
||||
// @foo
|
||||
// foo bar
|
||||
//
|
||||
// when typing:
|
||||
//
|
||||
// when typing:
|
||||
// - if I type a symbol "open the completions with snippets only"
|
||||
// - if I type a word character "open the completions menu" (if it had been open snippets only, clear it out)
|
||||
//
|
||||
// stuff we need:
|
||||
// - filtering logic change?
|
||||
// - remember how far back the completion started.
|
||||
|
||||
let test_cases: &[(&str, &[&str])] = &[
|
||||
(
|
||||
"un",
|
||||
&[
|
||||
"unsafe",
|
||||
"unlimit word count",
|
||||
"unlimited unknown",
|
||||
"unlimited word count",
|
||||
"unsnip",
|
||||
],
|
||||
),
|
||||
(
|
||||
"u ",
|
||||
&[
|
||||
"unlimit word count",
|
||||
"unlimited unknown",
|
||||
"unlimited word count",
|
||||
],
|
||||
),
|
||||
("u a", &["ab aa", "unsafe"]), // unsAfe
|
||||
(
|
||||
"u u",
|
||||
&[
|
||||
"unsafe",
|
||||
"unlimit word count",
|
||||
"unlimited unknown", // ranked highest among snippets
|
||||
"unlimited word count",
|
||||
"unsnip",
|
||||
],
|
||||
),
|
||||
("uw c", &["unlimit word count", "unlimited word count"]),
|
||||
(
|
||||
"u w",
|
||||
&[
|
||||
"unlimit word count",
|
||||
"unlimited word count",
|
||||
"unlimited unknown",
|
||||
],
|
||||
),
|
||||
("u w ", &["unlimit word count", "unlimited word count"]),
|
||||
(
|
||||
"u ",
|
||||
&[
|
||||
"unlimit word count",
|
||||
"unlimited unknown",
|
||||
"unlimited word count",
|
||||
],
|
||||
),
|
||||
("wor", &[]),
|
||||
("uf", &["unsafe"]),
|
||||
("af", &["unsafe"]),
|
||||
("afu", &[]),
|
||||
(
|
||||
"ue",
|
||||
&["unsafe", "unlimited unknown", "unlimited word count"],
|
||||
),
|
||||
("@", &["@few"]),
|
||||
("@few", &["@few"]),
|
||||
("@ ", &[]),
|
||||
("a@", &["@few"]),
|
||||
("a@f", &["@few", "unsafe"]),
|
||||
("a@fw", &["@few"]),
|
||||
("a", &["ab aa", "unsafe"]),
|
||||
("aa", &["ab aa"]),
|
||||
("aaa", &["ab aa"]),
|
||||
("ab", &["ab aa"]),
|
||||
("ab ", &["ab aa"]),
|
||||
("ab a", &["ab aa", "unsafe"]),
|
||||
("ab ab", &["ab aa"]),
|
||||
("ab ab aa", &["ab aa"]),
|
||||
];
|
||||
|
||||
for &(input_to_simulate, expected_completions) in test_cases {
|
||||
cx.set_state("fn a() { ˇ }\n");
|
||||
for c in input_to_simulate.split("") {
|
||||
cx.simulate_input(c);
|
||||
cx.run_until_parked();
|
||||
}
|
||||
let expected_completions = expected_completions
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect_vec();
|
||||
assert_eq!(
|
||||
get_completions(&mut cx),
|
||||
expected_completions,
|
||||
"< actual / expected >, input = {input_to_simulate:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle completion request passing a marked string specifying where the completion
|
||||
/// should be triggered from using '|' character, what range should be replaced, and what completions
|
||||
/// should be returned using '<' and '>' to delimit the range.
|
||||
@@ -25717,6 +26075,17 @@ pub(crate) fn update_test_project_settings(
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn update_test_editor_settings(
|
||||
cx: &mut TestAppContext,
|
||||
f: impl Fn(&mut EditorSettingsContent),
|
||||
) {
|
||||
cx.update(|cx| {
|
||||
SettingsStore::update_global(cx, |store, cx| {
|
||||
store.update_user_settings(cx, |settings| f(&mut settings.editor));
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn init_test(cx: &mut TestAppContext, f: fn(&mut AllLanguageSettingsContent)) {
|
||||
cx.update(|cx| {
|
||||
assets::Assets.load_test_fonts(cx);
|
||||
@@ -27355,3 +27724,213 @@ async fn test_next_prev_reference(cx: &mut TestAppContext) {
|
||||
_move(Direction::Prev, 2, &mut cx).await;
|
||||
cx.assert_editor_state(CYCLE_POSITIONS[1]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_multibuffer_selections_with_folding(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let (editor, cx) = cx.add_window_view(|window, cx| {
|
||||
let multi_buffer = MultiBuffer::build_multi(
|
||||
[
|
||||
("1\n2\n3\n", vec![Point::row_range(0..3)]),
|
||||
("1\n2\n3\n", vec![Point::row_range(0..3)]),
|
||||
],
|
||||
cx,
|
||||
);
|
||||
Editor::new(EditorMode::full(), multi_buffer, None, window, cx)
|
||||
});
|
||||
|
||||
let mut cx = EditorTestContext::for_editor_in(editor.clone(), cx).await;
|
||||
let buffer_ids = cx.multibuffer(|mb, _| mb.excerpt_buffer_ids());
|
||||
|
||||
cx.assert_excerpts_with_selections(indoc! {"
|
||||
[EXCERPT]
|
||||
ˇ1
|
||||
2
|
||||
3
|
||||
[EXCERPT]
|
||||
1
|
||||
2
|
||||
3
|
||||
"});
|
||||
|
||||
// Scenario 1: Unfolded buffers, position cursor on "2", select all matches, then insert
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.change_selections(None.into(), window, cx, |s| {
|
||||
s.select_ranges([2..3]);
|
||||
});
|
||||
});
|
||||
cx.assert_excerpts_with_selections(indoc! {"
|
||||
[EXCERPT]
|
||||
1
|
||||
2ˇ
|
||||
3
|
||||
[EXCERPT]
|
||||
1
|
||||
2
|
||||
3
|
||||
"});
|
||||
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor
|
||||
.select_all_matches(&SelectAllMatches, window, cx)
|
||||
.unwrap();
|
||||
});
|
||||
cx.assert_excerpts_with_selections(indoc! {"
|
||||
[EXCERPT]
|
||||
1
|
||||
2ˇ
|
||||
3
|
||||
[EXCERPT]
|
||||
1
|
||||
2ˇ
|
||||
3
|
||||
"});
|
||||
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("X", window, cx);
|
||||
});
|
||||
cx.assert_excerpts_with_selections(indoc! {"
|
||||
[EXCERPT]
|
||||
1
|
||||
Xˇ
|
||||
3
|
||||
[EXCERPT]
|
||||
1
|
||||
Xˇ
|
||||
3
|
||||
"});
|
||||
|
||||
// Scenario 2: Select "2", then fold second buffer before insertion
|
||||
cx.update_multibuffer(|mb, cx| {
|
||||
for buffer_id in buffer_ids.iter() {
|
||||
let buffer = mb.buffer(*buffer_id).unwrap();
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit([(0..buffer.len(), "1\n2\n3\n")], None, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Select "2" and select all matches
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.change_selections(None.into(), window, cx, |s| {
|
||||
s.select_ranges([2..3]);
|
||||
});
|
||||
editor
|
||||
.select_all_matches(&SelectAllMatches, window, cx)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Fold second buffer - should remove selections from folded buffer
|
||||
cx.update_editor(|editor, _, cx| {
|
||||
editor.fold_buffer(buffer_ids[1], cx);
|
||||
});
|
||||
cx.assert_excerpts_with_selections(indoc! {"
|
||||
[EXCERPT]
|
||||
1
|
||||
2ˇ
|
||||
3
|
||||
[EXCERPT]
|
||||
[FOLDED]
|
||||
"});
|
||||
|
||||
// Insert text - should only affect first buffer
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("Y", window, cx);
|
||||
});
|
||||
cx.update_editor(|editor, _, cx| {
|
||||
editor.unfold_buffer(buffer_ids[1], cx);
|
||||
});
|
||||
cx.assert_excerpts_with_selections(indoc! {"
|
||||
[EXCERPT]
|
||||
1
|
||||
Yˇ
|
||||
3
|
||||
[EXCERPT]
|
||||
1
|
||||
2
|
||||
3
|
||||
"});
|
||||
|
||||
// Scenario 3: Select "2", then fold first buffer before insertion
|
||||
cx.update_multibuffer(|mb, cx| {
|
||||
for buffer_id in buffer_ids.iter() {
|
||||
let buffer = mb.buffer(*buffer_id).unwrap();
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit([(0..buffer.len(), "1\n2\n3\n")], None, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Select "2" and select all matches
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.change_selections(None.into(), window, cx, |s| {
|
||||
s.select_ranges([2..3]);
|
||||
});
|
||||
editor
|
||||
.select_all_matches(&SelectAllMatches, window, cx)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Fold first buffer - should remove selections from folded buffer
|
||||
cx.update_editor(|editor, _, cx| {
|
||||
editor.fold_buffer(buffer_ids[0], cx);
|
||||
});
|
||||
cx.assert_excerpts_with_selections(indoc! {"
|
||||
[EXCERPT]
|
||||
[FOLDED]
|
||||
[EXCERPT]
|
||||
1
|
||||
2ˇ
|
||||
3
|
||||
"});
|
||||
|
||||
// Insert text - should only affect second buffer
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("Z", window, cx);
|
||||
});
|
||||
cx.update_editor(|editor, _, cx| {
|
||||
editor.unfold_buffer(buffer_ids[0], cx);
|
||||
});
|
||||
cx.assert_excerpts_with_selections(indoc! {"
|
||||
[EXCERPT]
|
||||
1
|
||||
2
|
||||
3
|
||||
[EXCERPT]
|
||||
1
|
||||
Zˇ
|
||||
3
|
||||
"});
|
||||
|
||||
// Edge case scenario: fold all buffers, then try to insert
|
||||
cx.update_editor(|editor, _, cx| {
|
||||
editor.fold_buffer(buffer_ids[0], cx);
|
||||
editor.fold_buffer(buffer_ids[1], cx);
|
||||
});
|
||||
cx.assert_excerpts_with_selections(indoc! {"
|
||||
[EXCERPT]
|
||||
ˇ[FOLDED]
|
||||
[EXCERPT]
|
||||
[FOLDED]
|
||||
"});
|
||||
|
||||
// Insert should work via default selection
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input("W", window, cx);
|
||||
});
|
||||
cx.update_editor(|editor, _, cx| {
|
||||
editor.unfold_buffer(buffer_ids[0], cx);
|
||||
editor.unfold_buffer(buffer_ids[1], cx);
|
||||
});
|
||||
cx.assert_excerpts_with_selections(indoc! {"
|
||||
[EXCERPT]
|
||||
Wˇ1
|
||||
2
|
||||
3
|
||||
[EXCERPT]
|
||||
1
|
||||
Z
|
||||
3
|
||||
"});
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ use crate::{
|
||||
EditDisplayMode, EditPrediction, Editor, EditorMode, EditorSettings, EditorSnapshot,
|
||||
EditorStyle, FILE_HEADER_HEIGHT, FocusedBlock, GutterDimensions, HalfPageDown, HalfPageUp,
|
||||
HandleInput, HoveredCursor, InlayHintRefreshReason, JumpData, LineDown, LineHighlight, LineUp,
|
||||
MAX_LINE_LEN, MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts,
|
||||
OpenExcerptsSplit, PageDown, PageUp, PhantomBreakpointIndicator, Point, RowExt, RowRangeExt,
|
||||
SelectPhase, SelectedTextHighlight, Selection, SelectionDragState, SelectionEffects,
|
||||
SizingBehavior, SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, ToggleFoldAll,
|
||||
MAX_LINE_LEN, MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, PageDown,
|
||||
PageUp, PhantomBreakpointIndicator, Point, RowExt, RowRangeExt, SelectPhase,
|
||||
SelectedTextHighlight, Selection, SelectionDragState, SelectionEffects, SizingBehavior,
|
||||
SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, ToggleFoldAll,
|
||||
code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP},
|
||||
display_map::{
|
||||
Block, BlockContext, BlockStyle, ChunkRendererId, DisplaySnapshot, EditorMargins,
|
||||
@@ -4042,24 +4042,17 @@ impl EditorElement {
|
||||
)
|
||||
.group_hover("", |div| div.underline()),
|
||||
)
|
||||
.on_click({
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |event, window, cx| {
|
||||
if event.modifiers().secondary() {
|
||||
focus_handle.dispatch_action(
|
||||
&OpenExcerptsSplit,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
} else {
|
||||
focus_handle.dispatch_action(
|
||||
&OpenExcerpts,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
.on_click(window.listener_for(&self.editor, {
|
||||
let jump_data = jump_data.clone();
|
||||
move |editor, e: &ClickEvent, window, cx| {
|
||||
editor.open_excerpts_common(
|
||||
Some(jump_data.clone()),
|
||||
e.modifiers().secondary(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
})),
|
||||
)
|
||||
.when_some(parent_path, |then, path| {
|
||||
then.child(div().child(path).text_color(
|
||||
@@ -4087,24 +4080,17 @@ impl EditorElement {
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.on_click({
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |event, window, cx| {
|
||||
if event.modifiers().secondary() {
|
||||
focus_handle.dispatch_action(
|
||||
&OpenExcerptsSplit,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
} else {
|
||||
focus_handle.dispatch_action(
|
||||
&OpenExcerpts,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
.on_click(window.listener_for(&self.editor, {
|
||||
let jump_data = jump_data.clone();
|
||||
move |editor, e: &ClickEvent, window, cx| {
|
||||
editor.open_excerpts_common(
|
||||
Some(jump_data.clone()),
|
||||
e.modifiers().secondary(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
})),
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -8620,7 +8606,7 @@ impl LineWithInvisibles {
|
||||
let fragment_end_x = fragment_start_x + shaped_line.width;
|
||||
if x < fragment_end_x {
|
||||
return Some(
|
||||
fragment_start_index + shaped_line.index_for_x(x - fragment_start_x),
|
||||
fragment_start_index + shaped_line.index_for_x(x - fragment_start_x)?,
|
||||
);
|
||||
}
|
||||
fragment_start_x = fragment_end_x;
|
||||
|
||||
@@ -341,7 +341,13 @@ fn show_hover(
|
||||
renderer
|
||||
.as_ref()
|
||||
.and_then(|renderer| {
|
||||
renderer.render_hover(group, point_range, buffer_id, cx)
|
||||
renderer.render_hover(
|
||||
group,
|
||||
point_range,
|
||||
buffer_id,
|
||||
language_registry.clone(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.context("no rendered diagnostic")
|
||||
})??;
|
||||
@@ -986,6 +992,11 @@ impl DiagnosticPopover {
|
||||
self.markdown.clone(),
|
||||
diagnostics_markdown_style(window, cx),
|
||||
)
|
||||
.code_block_renderer(markdown::CodeBlockRenderer::Default {
|
||||
copy_button: false,
|
||||
copy_button_on_hover: false,
|
||||
border: false,
|
||||
})
|
||||
.on_url_click(
|
||||
move |link, window, cx| {
|
||||
if let Some(renderer) = GlobalDiagnosticRenderer::global(cx)
|
||||
|
||||
@@ -1796,6 +1796,14 @@ impl SearchableItem for Editor {
|
||||
fn search_bar_visibility_changed(&mut self, _: bool, _: &mut Window, _: &mut Context<Self>) {
|
||||
self.expect_bounds_change = self.last_bounds;
|
||||
}
|
||||
|
||||
fn set_search_is_case_sensitive(
|
||||
&mut self,
|
||||
case_sensitive: Option<bool>,
|
||||
_cx: &mut Context<Self>,
|
||||
) {
|
||||
self.select_next_is_case_sensitive = case_sensitive;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_match_index(
|
||||
|
||||
@@ -372,7 +372,7 @@ impl SelectionsCollection {
|
||||
let is_empty = positions.start == positions.end;
|
||||
let line_len = display_map.line_len(row);
|
||||
let line = display_map.layout_row(row, text_layout_details);
|
||||
let start_col = line.index_for_x(positions.start) as u32;
|
||||
let start_col = line.closest_index_for_x(positions.start) as u32;
|
||||
|
||||
let (start, end) = if is_empty {
|
||||
let point = DisplayPoint::new(row, std::cmp::min(start_col, line_len));
|
||||
@@ -382,7 +382,7 @@ impl SelectionsCollection {
|
||||
return None;
|
||||
}
|
||||
let start = DisplayPoint::new(row, start_col);
|
||||
let end_col = line.index_for_x(positions.end) as u32;
|
||||
let end_col = line.closest_index_for_x(positions.end) as u32;
|
||||
let end = DisplayPoint::new(row, end_col);
|
||||
(start, end)
|
||||
};
|
||||
@@ -487,6 +487,43 @@ impl<'snap, 'a> MutableSelectionsCollection<'snap, 'a> {
|
||||
self.selections_changed |= changed;
|
||||
}
|
||||
|
||||
pub fn remove_selections_from_buffer(&mut self, buffer_id: language::BufferId) {
|
||||
let mut changed = false;
|
||||
|
||||
let filtered_selections: Arc<[Selection<Anchor>]> = {
|
||||
self.disjoint
|
||||
.iter()
|
||||
.filter(|selection| {
|
||||
if let Some(selection_buffer_id) =
|
||||
self.snapshot.buffer_id_for_anchor(selection.start)
|
||||
{
|
||||
let should_remove = selection_buffer_id == buffer_id;
|
||||
changed |= should_remove;
|
||||
!should_remove
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
|
||||
if filtered_selections.is_empty() {
|
||||
let default_anchor = self.snapshot.anchor_before(0);
|
||||
self.collection.disjoint = Arc::from([Selection {
|
||||
id: post_inc(&mut self.collection.next_selection_id),
|
||||
start: default_anchor,
|
||||
end: default_anchor,
|
||||
reversed: false,
|
||||
goal: SelectionGoal::None,
|
||||
}]);
|
||||
} else {
|
||||
self.collection.disjoint = filtered_selections;
|
||||
}
|
||||
|
||||
self.selections_changed |= changed;
|
||||
}
|
||||
|
||||
pub fn clear_pending(&mut self) {
|
||||
if self.collection.pending.is_some() {
|
||||
self.collection.pending = None;
|
||||
|
||||
@@ -59,6 +59,17 @@ impl EditorTestContext {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let language = project
|
||||
.read_with(cx, |project, _cx| {
|
||||
project.languages().language_for_name("Plain Text")
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.set_language(Some(language), cx);
|
||||
});
|
||||
|
||||
let editor = cx.add_window(|window, cx| {
|
||||
let editor = build_editor_with_project(
|
||||
project,
|
||||
|
||||
@@ -463,8 +463,8 @@ pub fn find_model(
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No language model with ID {}/{} was available. Available models: {}",
|
||||
selected.model.0,
|
||||
selected.provider.0,
|
||||
selected.model.0,
|
||||
model_registry
|
||||
.available_models(cx)
|
||||
.map(|model| format!("{}/{}", model.provider_id().0, model.id().0))
|
||||
|
||||
@@ -267,10 +267,9 @@ impl ExtensionManifest {
|
||||
|
||||
let mut extension_manifest_path = extension_dir.join("extension.json");
|
||||
if fs.is_file(&extension_manifest_path).await {
|
||||
let manifest_content = fs
|
||||
.load(&extension_manifest_path)
|
||||
.await
|
||||
.with_context(|| format!("failed to load {extension_name} extension.json"))?;
|
||||
let manifest_content = fs.load(&extension_manifest_path).await.with_context(|| {
|
||||
format!("loading {extension_name} extension.json, {extension_manifest_path:?}")
|
||||
})?;
|
||||
let manifest_json = serde_json::from_str::<OldExtensionManifest>(&manifest_content)
|
||||
.with_context(|| {
|
||||
format!("invalid extension.json for extension {extension_name}")
|
||||
@@ -279,10 +278,9 @@ impl ExtensionManifest {
|
||||
Ok(manifest_from_old_manifest(manifest_json, extension_name))
|
||||
} else {
|
||||
extension_manifest_path.set_extension("toml");
|
||||
let manifest_content = fs
|
||||
.load(&extension_manifest_path)
|
||||
.await
|
||||
.with_context(|| format!("failed to load {extension_name} extension.toml"))?;
|
||||
let manifest_content = fs.load(&extension_manifest_path).await.with_context(|| {
|
||||
format!("loading {extension_name} extension.toml, {extension_manifest_path:?}")
|
||||
})?;
|
||||
toml::from_str(&manifest_content).map_err(|err| {
|
||||
anyhow!("Invalid extension.toml for extension {extension_name}:\n{err}")
|
||||
})
|
||||
|
||||
@@ -763,17 +763,17 @@ impl WasmExtension {
|
||||
.fs
|
||||
.open_sync(&path)
|
||||
.await
|
||||
.context("failed to open wasm file")?;
|
||||
.context(format!("opening wasm file, path: {path:?}"))?;
|
||||
|
||||
let mut wasm_bytes = Vec::new();
|
||||
wasm_file
|
||||
.read_to_end(&mut wasm_bytes)
|
||||
.context("failed to read wasm")?;
|
||||
.context(format!("reading wasm file, path: {path:?}"))?;
|
||||
|
||||
wasm_host
|
||||
.load_extension(wasm_bytes, manifest, cx)
|
||||
.await
|
||||
.with_context(|| format!("failed to load wasm extension {}", manifest.id))
|
||||
.with_context(|| format!("loading wasm extension: {}", manifest.id))
|
||||
}
|
||||
|
||||
pub async fn call<T, Fn>(&self, f: Fn) -> Result<T>
|
||||
|
||||
@@ -75,6 +75,7 @@ const SUGGESTIONS_BY_EXTENSION_ID: &[(&str, &[&str])] = &[
|
||||
("vue", &["vue"]),
|
||||
("wgsl", &["wgsl"]),
|
||||
("wit", &["wit"]),
|
||||
("xml", &["xml"]),
|
||||
("zig", &["zig"]),
|
||||
];
|
||||
|
||||
|
||||
@@ -3452,3 +3452,99 @@ async fn test_paths_with_starting_slash(cx: &mut TestAppContext) {
|
||||
assert_eq!(active_editor.read(cx).title(cx), "file1.txt");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_clear_navigation_history(cx: &mut TestAppContext) {
|
||||
let app_state = init_test(cx);
|
||||
app_state
|
||||
.fs
|
||||
.as_fake()
|
||||
.insert_tree(
|
||||
path!("/src"),
|
||||
json!({
|
||||
"test": {
|
||||
"first.rs": "// First file",
|
||||
"second.rs": "// Second file",
|
||||
"third.rs": "// Third file",
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
|
||||
let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
|
||||
|
||||
workspace.update_in(cx, |_workspace, window, cx| window.focused(cx));
|
||||
|
||||
// Open some files to generate navigation history
|
||||
open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
|
||||
open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
|
||||
let history_before_clear =
|
||||
open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
|
||||
|
||||
assert_eq!(
|
||||
history_before_clear.len(),
|
||||
2,
|
||||
"Should have history items before clearing"
|
||||
);
|
||||
|
||||
// Verify that file finder shows history items
|
||||
let picker = open_file_picker(&workspace, cx);
|
||||
cx.simulate_input("fir");
|
||||
picker.update(cx, |finder, _| {
|
||||
let matches = collect_search_matches(finder);
|
||||
assert!(
|
||||
!matches.history.is_empty(),
|
||||
"File finder should show history items before clearing"
|
||||
);
|
||||
});
|
||||
workspace.update_in(cx, |_, window, cx| {
|
||||
window.dispatch_action(menu::Cancel.boxed_clone(), cx);
|
||||
});
|
||||
|
||||
// Verify navigation state before clear
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
let pane = workspace.active_pane();
|
||||
pane.read(cx).can_navigate_backward()
|
||||
});
|
||||
|
||||
// Clear navigation history
|
||||
cx.dispatch_action(workspace::ClearNavigationHistory);
|
||||
|
||||
// Verify that navigation is disabled immediately after clear
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
let pane = workspace.active_pane();
|
||||
assert!(
|
||||
!pane.read(cx).can_navigate_backward(),
|
||||
"Should not be able to navigate backward after clearing history"
|
||||
);
|
||||
assert!(
|
||||
!pane.read(cx).can_navigate_forward(),
|
||||
"Should not be able to navigate forward after clearing history"
|
||||
);
|
||||
});
|
||||
|
||||
// Verify that file finder no longer shows history items
|
||||
let picker = open_file_picker(&workspace, cx);
|
||||
cx.simulate_input("fir");
|
||||
picker.update(cx, |finder, _| {
|
||||
let matches = collect_search_matches(finder);
|
||||
assert!(
|
||||
matches.history.is_empty(),
|
||||
"File finder should not show history items after clearing"
|
||||
);
|
||||
});
|
||||
workspace.update_in(cx, |_, window, cx| {
|
||||
window.dispatch_action(menu::Cancel.boxed_clone(), cx);
|
||||
});
|
||||
|
||||
// Verify history is empty by opening a new file
|
||||
// (this should not show any previous history)
|
||||
let history_after_clear =
|
||||
open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
|
||||
assert_eq!(
|
||||
history_after_clear.len(),
|
||||
0,
|
||||
"Should have no history items after clearing"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -399,7 +399,12 @@ impl PickerDelegate for OpenPathDelegate {
|
||||
}
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if should_prepend_with_current_dir {
|
||||
|
||||
let current_dir_in_new_entries = new_entries
|
||||
.iter()
|
||||
.any(|entry| &entry.path.string == current_dir);
|
||||
|
||||
if should_prepend_with_current_dir && !current_dir_in_new_entries {
|
||||
new_entries.insert(
|
||||
0,
|
||||
CandidateInfo {
|
||||
|
||||
@@ -72,8 +72,8 @@ impl Watcher for FsWatcher {
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
log::trace!("path to watch is already watched: {path:?}");
|
||||
if self.registrations.lock().contains_key(path) {
|
||||
log::trace!("path to watch is already watched: {path:?}");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3980,9 +3980,9 @@ impl GitPanel {
|
||||
.map(|ops| ops.staging() || ops.staged())
|
||||
.or_else(|| {
|
||||
repo.status_for_path(&entry.repo_path)
|
||||
.map(|status| status.status.staging().has_staged())
|
||||
.and_then(|status| status.status.staging().as_bool())
|
||||
})
|
||||
.unwrap_or(entry.staging.has_staged());
|
||||
.or_else(|| entry.staging.as_bool());
|
||||
let mut is_staged: ToggleState = is_staging_or_staged.into();
|
||||
if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() {
|
||||
is_staged = ToggleState::Selected;
|
||||
@@ -4102,7 +4102,9 @@ impl GitPanel {
|
||||
}
|
||||
})
|
||||
.tooltip(move |_window, cx| {
|
||||
let action = if is_staging_or_staged {
|
||||
// If is_staging_or_staged is None, this implies the file was partially staged, and so
|
||||
// we allow the user to stage it in full by displaying `Stage` in the tooltip.
|
||||
let action = if is_staging_or_staged.unwrap_or(false) {
|
||||
"Unstage"
|
||||
} else {
|
||||
"Stage"
|
||||
|
||||
@@ -138,6 +138,8 @@ waker-fn = "1.2.0"
|
||||
lyon = "1.0"
|
||||
libc.workspace = true
|
||||
pin-project = "1.1.10"
|
||||
circular-buffer.workspace = true
|
||||
spin = "0.10.0"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
block = "0.1"
|
||||
|
||||
@@ -178,7 +178,7 @@ impl TextInput {
|
||||
if position.y > bounds.bottom() {
|
||||
return self.content.len();
|
||||
}
|
||||
line.index_for_x(position.x - bounds.left())
|
||||
line.closest_index_for_x(position.x - bounds.left())
|
||||
}
|
||||
|
||||
fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) {
|
||||
@@ -380,7 +380,7 @@ impl EntityInputHandler for TextInput {
|
||||
let last_layout = self.last_layout.as_ref()?;
|
||||
|
||||
assert_eq!(last_layout.text, self.content);
|
||||
let utf8_index = last_layout.index_for_x(point.x - line_point.x);
|
||||
let utf8_index = last_layout.index_for_x(point.x - line_point.x)?;
|
||||
Some(self.offset_to_utf16(utf8_index))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,10 @@ pub enum ScrollStrategy {
|
||||
/// May not be possible if there's not enough list items above the item scrolled to:
|
||||
/// in this case, the element will be placed at the closest possible position.
|
||||
Bottom,
|
||||
/// If the element is not visible attempt to place it at:
|
||||
/// - The top of the list's viewport if the target element is above currently visible elements.
|
||||
/// - The bottom of the list's viewport if the target element is above currently visible elements.
|
||||
Nearest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -391,39 +395,42 @@ impl Element for UniformList {
|
||||
scroll_offset.x = Pixels::ZERO;
|
||||
}
|
||||
|
||||
if let Some(deferred_scroll) = shared_scroll_to_item {
|
||||
let mut ix = deferred_scroll.item_index;
|
||||
if let Some(DeferredScrollToItem {
|
||||
mut item_index,
|
||||
mut strategy,
|
||||
offset,
|
||||
scroll_strict,
|
||||
}) = shared_scroll_to_item
|
||||
{
|
||||
if y_flipped {
|
||||
ix = self.item_count.saturating_sub(ix + 1);
|
||||
item_index = self.item_count.saturating_sub(item_index + 1);
|
||||
}
|
||||
let list_height = padded_bounds.size.height;
|
||||
let mut updated_scroll_offset = shared_scroll_offset.borrow_mut();
|
||||
let item_top = item_height * ix;
|
||||
let item_top = item_height * item_index;
|
||||
let item_bottom = item_top + item_height;
|
||||
let scroll_top = -updated_scroll_offset.y;
|
||||
let offset_pixels = item_height * deferred_scroll.offset;
|
||||
let mut scrolled_to_top = false;
|
||||
let offset_pixels = item_height * offset;
|
||||
|
||||
if item_top < scroll_top + offset_pixels {
|
||||
scrolled_to_top = true;
|
||||
// todo: using the padding here is wrong - this only works well for few scenarios
|
||||
updated_scroll_offset.y = -item_top + padding.top + offset_pixels;
|
||||
} else if item_bottom > scroll_top + list_height {
|
||||
scrolled_to_top = true;
|
||||
updated_scroll_offset.y = -(item_bottom - list_height);
|
||||
}
|
||||
// is the selected item above/below currently visible items
|
||||
let is_above = item_top < scroll_top + offset_pixels;
|
||||
let is_below = item_bottom > scroll_top + list_height;
|
||||
|
||||
if deferred_scroll.scroll_strict
|
||||
|| (scrolled_to_top
|
||||
&& (item_top < scroll_top + offset_pixels
|
||||
|| item_bottom > scroll_top + list_height))
|
||||
{
|
||||
match deferred_scroll.strategy {
|
||||
if scroll_strict || is_above || is_below {
|
||||
if strategy == ScrollStrategy::Nearest {
|
||||
if is_above {
|
||||
strategy = ScrollStrategy::Top;
|
||||
} else if is_below {
|
||||
strategy = ScrollStrategy::Bottom;
|
||||
}
|
||||
}
|
||||
|
||||
let max_scroll_offset =
|
||||
(content_height - list_height).max(Pixels::ZERO);
|
||||
match strategy {
|
||||
ScrollStrategy::Top => {
|
||||
updated_scroll_offset.y = -(item_top - offset_pixels)
|
||||
.max(Pixels::ZERO)
|
||||
.min(content_height - list_height)
|
||||
.max(Pixels::ZERO);
|
||||
.clamp(Pixels::ZERO, max_scroll_offset);
|
||||
}
|
||||
ScrollStrategy::Center => {
|
||||
let item_center = item_top + item_height / 2.0;
|
||||
@@ -431,18 +438,15 @@ impl Element for UniformList {
|
||||
let viewport_height = list_height - offset_pixels;
|
||||
let viewport_center = offset_pixels + viewport_height / 2.0;
|
||||
let target_scroll_top = item_center - viewport_center;
|
||||
|
||||
updated_scroll_offset.y = -target_scroll_top
|
||||
.max(Pixels::ZERO)
|
||||
.min(content_height - list_height)
|
||||
.max(Pixels::ZERO);
|
||||
updated_scroll_offset.y =
|
||||
-target_scroll_top.clamp(Pixels::ZERO, max_scroll_offset);
|
||||
}
|
||||
ScrollStrategy::Bottom => {
|
||||
updated_scroll_offset.y = -(item_bottom - list_height
|
||||
+ offset_pixels)
|
||||
.max(Pixels::ZERO)
|
||||
.min(content_height - list_height)
|
||||
.max(Pixels::ZERO);
|
||||
updated_scroll_offset.y = -(item_bottom - list_height)
|
||||
.clamp(Pixels::ZERO, max_scroll_offset);
|
||||
}
|
||||
ScrollStrategy::Nearest => {
|
||||
// Nearest, but the item is visible -> no scroll is required
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -695,3 +699,150 @@ impl InteractiveElement for UniformList {
|
||||
&mut self.interactivity
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::TestAppContext;
|
||||
|
||||
#[gpui::test]
|
||||
fn test_scroll_strategy_nearest(cx: &mut TestAppContext) {
|
||||
use crate::{
|
||||
Context, FocusHandle, ScrollStrategy, UniformListScrollHandle, Window, actions, div,
|
||||
prelude::*, px, uniform_list,
|
||||
};
|
||||
use std::ops::Range;
|
||||
|
||||
actions!(example, [SelectNext, SelectPrev]);
|
||||
|
||||
struct TestView {
|
||||
index: usize,
|
||||
length: usize,
|
||||
scroll_handle: UniformListScrollHandle,
|
||||
focus_handle: FocusHandle,
|
||||
visible_range: Range<usize>,
|
||||
}
|
||||
|
||||
impl TestView {
|
||||
pub fn select_next(
|
||||
&mut self,
|
||||
_: &SelectNext,
|
||||
window: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) {
|
||||
if self.index + 1 == self.length {
|
||||
self.index = 0
|
||||
} else {
|
||||
self.index += 1;
|
||||
}
|
||||
self.scroll_handle
|
||||
.scroll_to_item(self.index, ScrollStrategy::Nearest);
|
||||
window.refresh();
|
||||
}
|
||||
|
||||
pub fn select_previous(
|
||||
&mut self,
|
||||
_: &SelectPrev,
|
||||
window: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) {
|
||||
if self.index == 0 {
|
||||
self.index = self.length - 1
|
||||
} else {
|
||||
self.index -= 1;
|
||||
}
|
||||
self.scroll_handle
|
||||
.scroll_to_item(self.index, ScrollStrategy::Nearest);
|
||||
window.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TestView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.id("list-example")
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(Self::select_next))
|
||||
.on_action(cx.listener(Self::select_previous))
|
||||
.size_full()
|
||||
.child(
|
||||
uniform_list(
|
||||
"entries",
|
||||
self.length,
|
||||
cx.processor(|this, range: Range<usize>, _window, _cx| {
|
||||
this.visible_range = range.clone();
|
||||
range
|
||||
.map(|ix| div().id(ix).h(px(20.0)).child(format!("Item {ix}")))
|
||||
.collect()
|
||||
}),
|
||||
)
|
||||
.track_scroll(self.scroll_handle.clone())
|
||||
.h(px(200.0)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let (view, cx) = cx.add_window_view(|window, cx| {
|
||||
let focus_handle = cx.focus_handle();
|
||||
window.focus(&focus_handle);
|
||||
TestView {
|
||||
scroll_handle: UniformListScrollHandle::new(),
|
||||
index: 0,
|
||||
focus_handle,
|
||||
length: 47,
|
||||
visible_range: 0..0,
|
||||
}
|
||||
});
|
||||
|
||||
// 10 out of 47 items are visible
|
||||
|
||||
// First 9 times selecting next item does not scroll
|
||||
for ix in 1..10 {
|
||||
cx.dispatch_action(SelectNext);
|
||||
view.read_with(cx, |view, _| {
|
||||
assert_eq!(view.index, ix);
|
||||
assert_eq!(view.visible_range, 0..10);
|
||||
})
|
||||
}
|
||||
|
||||
// Now each time the list scrolls down by 1
|
||||
for ix in 10..47 {
|
||||
cx.dispatch_action(SelectNext);
|
||||
view.read_with(cx, |view, _| {
|
||||
assert_eq!(view.index, ix);
|
||||
assert_eq!(view.visible_range, ix - 9..ix + 1);
|
||||
})
|
||||
}
|
||||
|
||||
// After the last item we move back to the start
|
||||
cx.dispatch_action(SelectNext);
|
||||
view.read_with(cx, |view, _| {
|
||||
assert_eq!(view.index, 0);
|
||||
assert_eq!(view.visible_range, 0..10);
|
||||
});
|
||||
|
||||
// Return to the last element
|
||||
cx.dispatch_action(SelectPrev);
|
||||
view.read_with(cx, |view, _| {
|
||||
assert_eq!(view.index, 46);
|
||||
assert_eq!(view.visible_range, 37..47);
|
||||
});
|
||||
|
||||
// First 9 times selecting previous does not scroll
|
||||
for ix in (37..46).rev() {
|
||||
cx.dispatch_action(SelectPrev);
|
||||
view.read_with(cx, |view, _| {
|
||||
assert_eq!(view.index, ix);
|
||||
assert_eq!(view.visible_range, 37..47);
|
||||
})
|
||||
}
|
||||
|
||||
// Now each time the list scrolls up by 1
|
||||
for ix in (0..37).rev() {
|
||||
cx.dispatch_action(SelectPrev);
|
||||
view.read_with(cx, |view, _| {
|
||||
assert_eq!(view.index, ix);
|
||||
assert_eq!(view.visible_range, ix..ix + 10);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{App, PlatformDispatcher};
|
||||
use crate::{App, PlatformDispatcher, RunnableMeta, RunnableVariant};
|
||||
use async_task::Runnable;
|
||||
use futures::channel::mpsc;
|
||||
use smol::prelude::*;
|
||||
@@ -62,7 +62,7 @@ enum TaskState<T> {
|
||||
Ready(Option<T>),
|
||||
|
||||
/// A task that is currently running.
|
||||
Spawned(async_task::Task<T>),
|
||||
Spawned(async_task::Task<T, RunnableMeta>),
|
||||
}
|
||||
|
||||
impl<T> Task<T> {
|
||||
@@ -146,6 +146,7 @@ impl BackgroundExecutor {
|
||||
}
|
||||
|
||||
/// Enqueues the given future to be run to completion on a background thread.
|
||||
#[track_caller]
|
||||
pub fn spawn<R>(&self, future: impl Future<Output = R> + Send + 'static) -> Task<R>
|
||||
where
|
||||
R: Send + 'static,
|
||||
@@ -155,6 +156,7 @@ impl BackgroundExecutor {
|
||||
|
||||
/// Enqueues the given future to be run to completion on a background thread.
|
||||
/// The given label can be used to control the priority of the task in tests.
|
||||
#[track_caller]
|
||||
pub fn spawn_labeled<R>(
|
||||
&self,
|
||||
label: TaskLabel,
|
||||
@@ -166,14 +168,20 @@ impl BackgroundExecutor {
|
||||
self.spawn_internal::<R>(Box::pin(future), Some(label))
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn spawn_internal<R: Send + 'static>(
|
||||
&self,
|
||||
future: AnyFuture<R>,
|
||||
label: Option<TaskLabel>,
|
||||
) -> Task<R> {
|
||||
let dispatcher = self.dispatcher.clone();
|
||||
let (runnable, task) =
|
||||
async_task::spawn(future, move |runnable| dispatcher.dispatch(runnable, label));
|
||||
let location = core::panic::Location::caller();
|
||||
let (runnable, task) = async_task::Builder::new()
|
||||
.metadata(RunnableMeta { location })
|
||||
.spawn(
|
||||
move |_| future,
|
||||
move |runnable| dispatcher.dispatch(RunnableVariant::Meta(runnable), label),
|
||||
);
|
||||
runnable.schedule();
|
||||
Task(TaskState::Spawned(task))
|
||||
}
|
||||
@@ -281,7 +289,11 @@ impl BackgroundExecutor {
|
||||
});
|
||||
let mut cx = std::task::Context::from_waker(&waker);
|
||||
|
||||
let duration = Duration::from_secs(180);
|
||||
let duration = Duration::from_secs(
|
||||
option_env!("GPUI_TEST_TIMEOUT")
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(180),
|
||||
);
|
||||
let mut test_should_end_by = Instant::now() + duration;
|
||||
|
||||
loop {
|
||||
@@ -315,10 +327,8 @@ impl BackgroundExecutor {
|
||||
"parked with nothing left to run{waiting_message}{backtrace_message}",
|
||||
)
|
||||
}
|
||||
dispatcher.set_unparker(unparker.clone());
|
||||
parker.park_timeout(
|
||||
test_should_end_by.saturating_duration_since(Instant::now()),
|
||||
);
|
||||
dispatcher.push_unparker(unparker.clone());
|
||||
parker.park_timeout(Duration::from_millis(1));
|
||||
if Instant::now() > test_should_end_by {
|
||||
panic!("test timed out after {duration:?} with allow_parking")
|
||||
}
|
||||
@@ -370,10 +380,13 @@ impl BackgroundExecutor {
|
||||
if duration.is_zero() {
|
||||
return Task::ready(());
|
||||
}
|
||||
let (runnable, task) = async_task::spawn(async move {}, {
|
||||
let dispatcher = self.dispatcher.clone();
|
||||
move |runnable| dispatcher.dispatch_after(duration, runnable)
|
||||
});
|
||||
let location = core::panic::Location::caller();
|
||||
let (runnable, task) = async_task::Builder::new()
|
||||
.metadata(RunnableMeta { location })
|
||||
.spawn(move |_| async move {}, {
|
||||
let dispatcher = self.dispatcher.clone();
|
||||
move |runnable| dispatcher.dispatch_after(duration, RunnableVariant::Meta(runnable))
|
||||
});
|
||||
runnable.schedule();
|
||||
Task(TaskState::Spawned(task))
|
||||
}
|
||||
@@ -479,24 +492,29 @@ impl ForegroundExecutor {
|
||||
}
|
||||
|
||||
/// Enqueues the given Task to run on the main thread at some point in the future.
|
||||
#[track_caller]
|
||||
pub fn spawn<R>(&self, future: impl Future<Output = R> + 'static) -> Task<R>
|
||||
where
|
||||
R: 'static,
|
||||
{
|
||||
let dispatcher = self.dispatcher.clone();
|
||||
let location = core::panic::Location::caller();
|
||||
|
||||
#[track_caller]
|
||||
fn inner<R: 'static>(
|
||||
dispatcher: Arc<dyn PlatformDispatcher>,
|
||||
future: AnyLocalFuture<R>,
|
||||
location: &'static core::panic::Location<'static>,
|
||||
) -> Task<R> {
|
||||
let (runnable, task) = spawn_local_with_source_location(future, move |runnable| {
|
||||
dispatcher.dispatch_on_main_thread(runnable)
|
||||
});
|
||||
let (runnable, task) = spawn_local_with_source_location(
|
||||
future,
|
||||
move |runnable| dispatcher.dispatch_on_main_thread(RunnableVariant::Meta(runnable)),
|
||||
RunnableMeta { location },
|
||||
);
|
||||
runnable.schedule();
|
||||
Task(TaskState::Spawned(task))
|
||||
}
|
||||
inner::<R>(dispatcher, Box::pin(future))
|
||||
inner::<R>(dispatcher, Box::pin(future), location)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,14 +523,16 @@ impl ForegroundExecutor {
|
||||
/// Copy-modified from:
|
||||
/// <https://github.com/smol-rs/async-task/blob/ca9dbe1db9c422fd765847fa91306e30a6bb58a9/src/runnable.rs#L405>
|
||||
#[track_caller]
|
||||
fn spawn_local_with_source_location<Fut, S>(
|
||||
fn spawn_local_with_source_location<Fut, S, M>(
|
||||
future: Fut,
|
||||
schedule: S,
|
||||
) -> (Runnable<()>, async_task::Task<Fut::Output, ()>)
|
||||
metadata: M,
|
||||
) -> (Runnable<M>, async_task::Task<Fut::Output, M>)
|
||||
where
|
||||
Fut: Future + 'static,
|
||||
Fut::Output: 'static,
|
||||
S: async_task::Schedule<()> + Send + Sync + 'static,
|
||||
S: async_task::Schedule<M> + Send + Sync + 'static,
|
||||
M: 'static,
|
||||
{
|
||||
#[inline]
|
||||
fn thread_id() -> ThreadId {
|
||||
@@ -560,7 +580,11 @@ where
|
||||
location: Location::caller(),
|
||||
};
|
||||
|
||||
unsafe { async_task::spawn_unchecked(future, schedule) }
|
||||
unsafe {
|
||||
async_task::Builder::new()
|
||||
.metadata(metadata)
|
||||
.spawn_unchecked(move |_| future, schedule)
|
||||
}
|
||||
}
|
||||
|
||||
/// Scope manages a set of tasks that are enqueued and waited on together. See [`BackgroundExecutor::scoped`].
|
||||
@@ -590,6 +614,7 @@ impl<'a> Scope<'a> {
|
||||
}
|
||||
|
||||
/// Spawn a future into this scope.
|
||||
#[track_caller]
|
||||
pub fn spawn<F>(&mut self, f: F)
|
||||
where
|
||||
F: Future<Output = ()> + Send + 'a,
|
||||
|
||||
@@ -30,6 +30,7 @@ mod keymap;
|
||||
mod path_builder;
|
||||
mod platform;
|
||||
pub mod prelude;
|
||||
mod profiler;
|
||||
mod scene;
|
||||
mod shared_string;
|
||||
mod shared_uri;
|
||||
@@ -87,6 +88,7 @@ use key_dispatch::*;
|
||||
pub use keymap::*;
|
||||
pub use path_builder::*;
|
||||
pub use platform::*;
|
||||
pub use profiler::*;
|
||||
pub use refineable::*;
|
||||
pub use scene::*;
|
||||
pub use shared_string::*;
|
||||
|
||||
@@ -40,8 +40,8 @@ use crate::{
|
||||
DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun,
|
||||
ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput,
|
||||
Point, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Scene, ShapedGlyph,
|
||||
ShapedRun, SharedString, Size, SvgRenderer, SystemWindowTab, Task, TaskLabel, Window,
|
||||
WindowControlArea, hash, point, px, size,
|
||||
ShapedRun, SharedString, Size, SvgRenderer, SystemWindowTab, Task, TaskLabel, TaskTiming,
|
||||
ThreadTaskTimings, Window, WindowControlArea, hash, point, px, size,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use async_task::Runnable;
|
||||
@@ -559,14 +559,32 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// This type is public so that our test macro can generate and use it, but it should not
|
||||
/// be considered part of our public API.
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug)]
|
||||
pub struct RunnableMeta {
|
||||
/// Location of the runnable
|
||||
pub location: &'static core::panic::Location<'static>,
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub enum RunnableVariant {
|
||||
Meta(Runnable<RunnableMeta>),
|
||||
Compat(Runnable),
|
||||
}
|
||||
|
||||
/// This type is public so that our test macro can generate and use it, but it should not
|
||||
/// be considered part of our public API.
|
||||
#[doc(hidden)]
|
||||
pub trait PlatformDispatcher: Send + Sync {
|
||||
fn get_all_timings(&self) -> Vec<ThreadTaskTimings>;
|
||||
fn get_current_thread_timings(&self) -> Vec<TaskTiming>;
|
||||
fn is_main_thread(&self) -> bool;
|
||||
fn dispatch(&self, runnable: Runnable, label: Option<TaskLabel>);
|
||||
fn dispatch_on_main_thread(&self, runnable: Runnable);
|
||||
fn dispatch_after(&self, duration: Duration, runnable: Runnable);
|
||||
fn dispatch(&self, runnable: RunnableVariant, label: Option<TaskLabel>);
|
||||
fn dispatch_on_main_thread(&self, runnable: RunnableVariant);
|
||||
fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant);
|
||||
|
||||
fn now(&self) -> Instant {
|
||||
Instant::now()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::{PlatformDispatcher, TaskLabel};
|
||||
use async_task::Runnable;
|
||||
use crate::{
|
||||
GLOBAL_THREAD_TIMINGS, PlatformDispatcher, RunnableVariant, THREAD_TIMINGS, TaskLabel,
|
||||
TaskTiming, ThreadTaskTimings,
|
||||
};
|
||||
use calloop::{
|
||||
EventLoop,
|
||||
channel::{self, Sender},
|
||||
@@ -13,20 +15,20 @@ use util::ResultExt;
|
||||
|
||||
struct TimerAfter {
|
||||
duration: Duration,
|
||||
runnable: Runnable,
|
||||
runnable: RunnableVariant,
|
||||
}
|
||||
|
||||
pub(crate) struct LinuxDispatcher {
|
||||
main_sender: Sender<Runnable>,
|
||||
main_sender: Sender<RunnableVariant>,
|
||||
timer_sender: Sender<TimerAfter>,
|
||||
background_sender: flume::Sender<Runnable>,
|
||||
background_sender: flume::Sender<RunnableVariant>,
|
||||
_background_threads: Vec<thread::JoinHandle<()>>,
|
||||
main_thread_id: thread::ThreadId,
|
||||
}
|
||||
|
||||
impl LinuxDispatcher {
|
||||
pub fn new(main_sender: Sender<Runnable>) -> Self {
|
||||
let (background_sender, background_receiver) = flume::unbounded::<Runnable>();
|
||||
pub fn new(main_sender: Sender<RunnableVariant>) -> Self {
|
||||
let (background_sender, background_receiver) = flume::unbounded::<RunnableVariant>();
|
||||
let thread_count = std::thread::available_parallelism()
|
||||
.map(|i| i.get())
|
||||
.unwrap_or(1);
|
||||
@@ -40,7 +42,36 @@ impl LinuxDispatcher {
|
||||
for runnable in receiver {
|
||||
let start = Instant::now();
|
||||
|
||||
runnable.run();
|
||||
let mut location = match runnable {
|
||||
RunnableVariant::Meta(runnable) => {
|
||||
let location = runnable.metadata().location;
|
||||
let timing = TaskTiming {
|
||||
location,
|
||||
start,
|
||||
end: None,
|
||||
};
|
||||
Self::add_task_timing(timing);
|
||||
|
||||
runnable.run();
|
||||
timing
|
||||
}
|
||||
RunnableVariant::Compat(runnable) => {
|
||||
let location = core::panic::Location::caller();
|
||||
let timing = TaskTiming {
|
||||
location,
|
||||
start,
|
||||
end: None,
|
||||
};
|
||||
Self::add_task_timing(timing);
|
||||
|
||||
runnable.run();
|
||||
timing
|
||||
}
|
||||
};
|
||||
|
||||
let end = Instant::now();
|
||||
location.end = Some(end);
|
||||
Self::add_task_timing(location);
|
||||
|
||||
log::trace!(
|
||||
"background thread {}: ran runnable. took: {:?}",
|
||||
@@ -72,7 +103,36 @@ impl LinuxDispatcher {
|
||||
calloop::timer::Timer::from_duration(timer.duration),
|
||||
move |_, _, _| {
|
||||
if let Some(runnable) = runnable.take() {
|
||||
runnable.run();
|
||||
let start = Instant::now();
|
||||
let mut timing = match runnable {
|
||||
RunnableVariant::Meta(runnable) => {
|
||||
let location = runnable.metadata().location;
|
||||
let timing = TaskTiming {
|
||||
location,
|
||||
start,
|
||||
end: None,
|
||||
};
|
||||
Self::add_task_timing(timing);
|
||||
|
||||
runnable.run();
|
||||
timing
|
||||
}
|
||||
RunnableVariant::Compat(runnable) => {
|
||||
let timing = TaskTiming {
|
||||
location: core::panic::Location::caller(),
|
||||
start,
|
||||
end: None,
|
||||
};
|
||||
Self::add_task_timing(timing);
|
||||
|
||||
runnable.run();
|
||||
timing
|
||||
}
|
||||
};
|
||||
let end = Instant::now();
|
||||
|
||||
timing.end = Some(end);
|
||||
Self::add_task_timing(timing);
|
||||
}
|
||||
TimeoutAction::Drop
|
||||
},
|
||||
@@ -96,18 +156,53 @@ impl LinuxDispatcher {
|
||||
main_thread_id: thread::current().id(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn add_task_timing(timing: TaskTiming) {
|
||||
THREAD_TIMINGS.with(|timings| {
|
||||
let mut timings = timings.lock();
|
||||
let timings = &mut timings.timings;
|
||||
|
||||
if let Some(last_timing) = timings.iter_mut().rev().next() {
|
||||
if last_timing.location == timing.location {
|
||||
last_timing.end = timing.end;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
timings.push_back(timing);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformDispatcher for LinuxDispatcher {
|
||||
fn get_all_timings(&self) -> Vec<crate::ThreadTaskTimings> {
|
||||
let global_timings = GLOBAL_THREAD_TIMINGS.lock();
|
||||
ThreadTaskTimings::convert(&global_timings)
|
||||
}
|
||||
|
||||
fn get_current_thread_timings(&self) -> Vec<crate::TaskTiming> {
|
||||
THREAD_TIMINGS.with(|timings| {
|
||||
let timings = timings.lock();
|
||||
let timings = &timings.timings;
|
||||
|
||||
let mut vec = Vec::with_capacity(timings.len());
|
||||
|
||||
let (s1, s2) = timings.as_slices();
|
||||
vec.extend_from_slice(s1);
|
||||
vec.extend_from_slice(s2);
|
||||
vec
|
||||
})
|
||||
}
|
||||
|
||||
fn is_main_thread(&self) -> bool {
|
||||
thread::current().id() == self.main_thread_id
|
||||
}
|
||||
|
||||
fn dispatch(&self, runnable: Runnable, _: Option<TaskLabel>) {
|
||||
fn dispatch(&self, runnable: RunnableVariant, _: Option<TaskLabel>) {
|
||||
self.background_sender.send(runnable).unwrap();
|
||||
}
|
||||
|
||||
fn dispatch_on_main_thread(&self, runnable: Runnable) {
|
||||
fn dispatch_on_main_thread(&self, runnable: RunnableVariant) {
|
||||
self.main_sender.send(runnable).unwrap_or_else(|runnable| {
|
||||
// NOTE: Runnable may wrap a Future that is !Send.
|
||||
//
|
||||
@@ -121,7 +216,7 @@ impl PlatformDispatcher for LinuxDispatcher {
|
||||
});
|
||||
}
|
||||
|
||||
fn dispatch_after(&self, duration: Duration, runnable: Runnable) {
|
||||
fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) {
|
||||
self.timer_sender
|
||||
.send(TimerAfter { duration, runnable })
|
||||
.ok();
|
||||
|
||||
@@ -31,7 +31,10 @@ impl HeadlessClient {
|
||||
handle
|
||||
.insert_source(main_receiver, |event, _, _: &mut HeadlessClient| {
|
||||
if let calloop::channel::Event::Msg(runnable) = event {
|
||||
runnable.run();
|
||||
match runnable {
|
||||
crate::RunnableVariant::Meta(runnable) => runnable.run(),
|
||||
crate::RunnableVariant::Compat(runnable) => runnable.run(),
|
||||
};
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
|
||||
@@ -15,7 +15,6 @@ use std::{
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, anyhow};
|
||||
use async_task::Runnable;
|
||||
use calloop::{LoopSignal, channel::Channel};
|
||||
use futures::channel::oneshot;
|
||||
use util::ResultExt as _;
|
||||
@@ -26,7 +25,8 @@ use crate::{
|
||||
Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId,
|
||||
ForegroundExecutor, Keymap, LinuxDispatcher, Menu, MenuItem, OwnedMenu, PathPromptOptions,
|
||||
Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper,
|
||||
PlatformTextSystem, PlatformWindow, Point, Result, Task, WindowAppearance, WindowParams, px,
|
||||
PlatformTextSystem, PlatformWindow, Point, Result, RunnableVariant, Task, WindowAppearance,
|
||||
WindowParams, px,
|
||||
};
|
||||
|
||||
#[cfg(any(feature = "wayland", feature = "x11"))]
|
||||
@@ -105,8 +105,8 @@ pub(crate) struct LinuxCommon {
|
||||
}
|
||||
|
||||
impl LinuxCommon {
|
||||
pub fn new(signal: LoopSignal) -> (Self, Channel<Runnable>) {
|
||||
let (main_sender, main_receiver) = calloop::channel::channel::<Runnable>();
|
||||
pub fn new(signal: LoopSignal) -> (Self, Channel<RunnableVariant>) {
|
||||
let (main_sender, main_receiver) = calloop::channel::channel::<RunnableVariant>();
|
||||
|
||||
#[cfg(any(feature = "wayland", feature = "x11"))]
|
||||
let text_system = Arc::new(crate::CosmicTextSystem::new());
|
||||
|
||||
@@ -71,7 +71,6 @@ use super::{
|
||||
window::{ImeInput, WaylandWindowStatePtr},
|
||||
};
|
||||
|
||||
use crate::platform::{PlatformWindow, blade::BladeContext};
|
||||
use crate::{
|
||||
AnyWindowHandle, Bounds, Capslock, CursorStyle, DOUBLE_CLICK_INTERVAL, DevicePixels, DisplayId,
|
||||
FileDropEvent, ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke, LinuxCommon,
|
||||
@@ -80,6 +79,10 @@ use crate::{
|
||||
PlatformInput, PlatformKeyboardLayout, Point, SCROLL_LINES, ScrollDelta, ScrollWheelEvent,
|
||||
Size, TouchPhase, WindowParams, point, px, size,
|
||||
};
|
||||
use crate::{
|
||||
LinuxDispatcher, RunnableVariant, TaskTiming,
|
||||
platform::{PlatformWindow, blade::BladeContext},
|
||||
};
|
||||
use crate::{
|
||||
SharedString,
|
||||
platform::linux::{
|
||||
@@ -491,7 +494,37 @@ impl WaylandClient {
|
||||
move |event, _, _: &mut WaylandClientStatePtr| {
|
||||
if let calloop::channel::Event::Msg(runnable) = event {
|
||||
handle.insert_idle(|_| {
|
||||
runnable.run();
|
||||
let start = Instant::now();
|
||||
let mut timing = match runnable {
|
||||
RunnableVariant::Meta(runnable) => {
|
||||
let location = runnable.metadata().location;
|
||||
let timing = TaskTiming {
|
||||
location,
|
||||
start,
|
||||
end: None,
|
||||
};
|
||||
LinuxDispatcher::add_task_timing(timing);
|
||||
|
||||
runnable.run();
|
||||
timing
|
||||
}
|
||||
RunnableVariant::Compat(runnable) => {
|
||||
let location = core::panic::Location::caller();
|
||||
let timing = TaskTiming {
|
||||
location,
|
||||
start,
|
||||
end: None,
|
||||
};
|
||||
LinuxDispatcher::add_task_timing(timing);
|
||||
|
||||
runnable.run();
|
||||
timing
|
||||
}
|
||||
};
|
||||
|
||||
let end = Instant::now();
|
||||
timing.end = Some(end);
|
||||
LinuxDispatcher::add_task_timing(timing);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{Capslock, xcb_flush};
|
||||
use crate::{Capslock, LinuxDispatcher, RunnableVariant, TaskTiming, xcb_flush};
|
||||
use anyhow::{Context as _, anyhow};
|
||||
use ashpd::WindowIdentifier;
|
||||
use calloop::{
|
||||
@@ -313,7 +313,37 @@ impl X11Client {
|
||||
// events have higher priority and runnables are only worked off after the event
|
||||
// callbacks.
|
||||
handle.insert_idle(|_| {
|
||||
runnable.run();
|
||||
let start = Instant::now();
|
||||
let mut timing = match runnable {
|
||||
RunnableVariant::Meta(runnable) => {
|
||||
let location = runnable.metadata().location;
|
||||
let timing = TaskTiming {
|
||||
location,
|
||||
start,
|
||||
end: None,
|
||||
};
|
||||
LinuxDispatcher::add_task_timing(timing);
|
||||
|
||||
runnable.run();
|
||||
timing
|
||||
}
|
||||
RunnableVariant::Compat(runnable) => {
|
||||
let location = core::panic::Location::caller();
|
||||
let timing = TaskTiming {
|
||||
location,
|
||||
start,
|
||||
end: None,
|
||||
};
|
||||
LinuxDispatcher::add_task_timing(timing);
|
||||
|
||||
runnable.run();
|
||||
timing
|
||||
}
|
||||
};
|
||||
|
||||
let end = Instant::now();
|
||||
timing.end = Some(end);
|
||||
LinuxDispatcher::add_task_timing(timing);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
#![allow(non_camel_case_types)]
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use crate::{PlatformDispatcher, TaskLabel};
|
||||
use crate::{
|
||||
GLOBAL_THREAD_TIMINGS, PlatformDispatcher, RunnableMeta, RunnableVariant, THREAD_TIMINGS,
|
||||
TaskLabel, TaskTiming, ThreadTaskTimings,
|
||||
};
|
||||
|
||||
use async_task::Runnable;
|
||||
use objc::{
|
||||
class, msg_send,
|
||||
@@ -12,7 +16,7 @@ use objc::{
|
||||
use std::{
|
||||
ffi::c_void,
|
||||
ptr::{NonNull, addr_of},
|
||||
time::Duration,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// All items in the generated file are marked as pub, so we're gonna wrap it in a separate mod to prevent
|
||||
@@ -29,47 +33,155 @@ pub(crate) fn dispatch_get_main_queue() -> dispatch_queue_t {
|
||||
pub(crate) struct MacDispatcher;
|
||||
|
||||
impl PlatformDispatcher for MacDispatcher {
|
||||
fn get_all_timings(&self) -> Vec<ThreadTaskTimings> {
|
||||
let global_timings = GLOBAL_THREAD_TIMINGS.lock();
|
||||
ThreadTaskTimings::convert(&global_timings)
|
||||
}
|
||||
|
||||
fn get_current_thread_timings(&self) -> Vec<TaskTiming> {
|
||||
THREAD_TIMINGS.with(|timings| {
|
||||
let timings = &timings.lock().timings;
|
||||
|
||||
let mut vec = Vec::with_capacity(timings.len());
|
||||
|
||||
let (s1, s2) = timings.as_slices();
|
||||
vec.extend_from_slice(s1);
|
||||
vec.extend_from_slice(s2);
|
||||
vec
|
||||
})
|
||||
}
|
||||
|
||||
fn is_main_thread(&self) -> bool {
|
||||
let is_main_thread: BOOL = unsafe { msg_send![class!(NSThread), isMainThread] };
|
||||
is_main_thread == YES
|
||||
}
|
||||
|
||||
fn dispatch(&self, runnable: Runnable, _: Option<TaskLabel>) {
|
||||
fn dispatch(&self, runnable: RunnableVariant, _: Option<TaskLabel>) {
|
||||
let (context, trampoline) = match runnable {
|
||||
RunnableVariant::Meta(runnable) => (
|
||||
runnable.into_raw().as_ptr() as *mut c_void,
|
||||
Some(trampoline as unsafe extern "C" fn(*mut c_void)),
|
||||
),
|
||||
RunnableVariant::Compat(runnable) => (
|
||||
runnable.into_raw().as_ptr() as *mut c_void,
|
||||
Some(trampoline_compat as unsafe extern "C" fn(*mut c_void)),
|
||||
),
|
||||
};
|
||||
unsafe {
|
||||
dispatch_async_f(
|
||||
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH.try_into().unwrap(), 0),
|
||||
runnable.into_raw().as_ptr() as *mut c_void,
|
||||
Some(trampoline),
|
||||
context,
|
||||
trampoline,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_on_main_thread(&self, runnable: Runnable) {
|
||||
fn dispatch_on_main_thread(&self, runnable: RunnableVariant) {
|
||||
let (context, trampoline) = match runnable {
|
||||
RunnableVariant::Meta(runnable) => (
|
||||
runnable.into_raw().as_ptr() as *mut c_void,
|
||||
Some(trampoline as unsafe extern "C" fn(*mut c_void)),
|
||||
),
|
||||
RunnableVariant::Compat(runnable) => (
|
||||
runnable.into_raw().as_ptr() as *mut c_void,
|
||||
Some(trampoline_compat as unsafe extern "C" fn(*mut c_void)),
|
||||
),
|
||||
};
|
||||
unsafe {
|
||||
dispatch_async_f(
|
||||
dispatch_get_main_queue(),
|
||||
runnable.into_raw().as_ptr() as *mut c_void,
|
||||
Some(trampoline),
|
||||
);
|
||||
dispatch_async_f(dispatch_get_main_queue(), context, trampoline);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_after(&self, duration: Duration, runnable: Runnable) {
|
||||
fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) {
|
||||
let (context, trampoline) = match runnable {
|
||||
RunnableVariant::Meta(runnable) => (
|
||||
runnable.into_raw().as_ptr() as *mut c_void,
|
||||
Some(trampoline as unsafe extern "C" fn(*mut c_void)),
|
||||
),
|
||||
RunnableVariant::Compat(runnable) => (
|
||||
runnable.into_raw().as_ptr() as *mut c_void,
|
||||
Some(trampoline_compat as unsafe extern "C" fn(*mut c_void)),
|
||||
),
|
||||
};
|
||||
unsafe {
|
||||
let queue =
|
||||
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH.try_into().unwrap(), 0);
|
||||
let when = dispatch_time(DISPATCH_TIME_NOW as u64, duration.as_nanos() as i64);
|
||||
dispatch_after_f(
|
||||
when,
|
||||
queue,
|
||||
runnable.into_raw().as_ptr() as *mut c_void,
|
||||
Some(trampoline),
|
||||
);
|
||||
dispatch_after_f(when, queue, context, trampoline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn trampoline(runnable: *mut c_void) {
|
||||
let task = unsafe { Runnable::<()>::from_raw(NonNull::new_unchecked(runnable as *mut ())) };
|
||||
let task =
|
||||
unsafe { Runnable::<RunnableMeta>::from_raw(NonNull::new_unchecked(runnable as *mut ())) };
|
||||
|
||||
let location = task.metadata().location;
|
||||
|
||||
let start = Instant::now();
|
||||
let timing = TaskTiming {
|
||||
location,
|
||||
start,
|
||||
end: None,
|
||||
};
|
||||
|
||||
THREAD_TIMINGS.with(|timings| {
|
||||
let mut timings = timings.lock();
|
||||
let timings = &mut timings.timings;
|
||||
if let Some(last_timing) = timings.iter_mut().rev().next() {
|
||||
if last_timing.location == timing.location {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
timings.push_back(timing);
|
||||
});
|
||||
|
||||
task.run();
|
||||
let end = Instant::now();
|
||||
|
||||
THREAD_TIMINGS.with(|timings| {
|
||||
let mut timings = timings.lock();
|
||||
let timings = &mut timings.timings;
|
||||
let Some(last_timing) = timings.iter_mut().rev().next() else {
|
||||
return;
|
||||
};
|
||||
last_timing.end = Some(end);
|
||||
});
|
||||
}
|
||||
|
||||
extern "C" fn trampoline_compat(runnable: *mut c_void) {
|
||||
let task = unsafe { Runnable::<()>::from_raw(NonNull::new_unchecked(runnable as *mut ())) };
|
||||
|
||||
let location = core::panic::Location::caller();
|
||||
|
||||
let start = Instant::now();
|
||||
let timing = TaskTiming {
|
||||
location,
|
||||
start,
|
||||
end: None,
|
||||
};
|
||||
THREAD_TIMINGS.with(|timings| {
|
||||
let mut timings = timings.lock();
|
||||
let timings = &mut timings.timings;
|
||||
if let Some(last_timing) = timings.iter_mut().rev().next() {
|
||||
if last_timing.location == timing.location {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
timings.push_back(timing);
|
||||
});
|
||||
|
||||
task.run();
|
||||
let end = Instant::now();
|
||||
|
||||
THREAD_TIMINGS.with(|timings| {
|
||||
let mut timings = timings.lock();
|
||||
let timings = &mut timings.timings;
|
||||
let Some(last_timing) = timings.iter_mut().rev().next() else {
|
||||
return;
|
||||
};
|
||||
last_timing.end = Some(end);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -651,9 +651,12 @@ impl Platform for MacPlatform {
|
||||
|
||||
fn open_url(&self, url: &str) {
|
||||
unsafe {
|
||||
let url = NSURL::alloc(nil)
|
||||
.initWithString_(ns_string(url))
|
||||
.autorelease();
|
||||
let ns_url = NSURL::alloc(nil).initWithString_(ns_string(url));
|
||||
if ns_url.is_null() {
|
||||
log::error!("Failed to create NSURL from string: {}", url);
|
||||
return;
|
||||
}
|
||||
let url = ns_url.autorelease();
|
||||
let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace];
|
||||
msg_send![workspace, openURL: url]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::{PlatformDispatcher, TaskLabel};
|
||||
use async_task::Runnable;
|
||||
use crate::{PlatformDispatcher, RunnableVariant, TaskLabel};
|
||||
use backtrace::Backtrace;
|
||||
use collections::{HashMap, HashSet, VecDeque};
|
||||
use parking::Unparker;
|
||||
@@ -26,10 +25,10 @@ pub struct TestDispatcher {
|
||||
|
||||
struct TestDispatcherState {
|
||||
random: StdRng,
|
||||
foreground: HashMap<TestDispatcherId, VecDeque<Runnable>>,
|
||||
background: Vec<Runnable>,
|
||||
deprioritized_background: Vec<Runnable>,
|
||||
delayed: Vec<(Duration, Runnable)>,
|
||||
foreground: HashMap<TestDispatcherId, VecDeque<RunnableVariant>>,
|
||||
background: Vec<RunnableVariant>,
|
||||
deprioritized_background: Vec<RunnableVariant>,
|
||||
delayed: Vec<(Duration, RunnableVariant)>,
|
||||
start_time: Instant,
|
||||
time: Duration,
|
||||
is_main_thread: bool,
|
||||
@@ -39,7 +38,7 @@ struct TestDispatcherState {
|
||||
waiting_backtrace: Option<Backtrace>,
|
||||
deprioritized_task_labels: HashSet<TaskLabel>,
|
||||
block_on_ticks: RangeInclusive<usize>,
|
||||
last_parked: Option<Unparker>,
|
||||
unparkers: Vec<Unparker>,
|
||||
}
|
||||
|
||||
impl TestDispatcher {
|
||||
@@ -59,7 +58,7 @@ impl TestDispatcher {
|
||||
waiting_backtrace: None,
|
||||
deprioritized_task_labels: Default::default(),
|
||||
block_on_ticks: 0..=1000,
|
||||
last_parked: None,
|
||||
unparkers: Default::default(),
|
||||
};
|
||||
|
||||
TestDispatcher {
|
||||
@@ -175,7 +174,13 @@ impl TestDispatcher {
|
||||
let was_main_thread = state.is_main_thread;
|
||||
state.is_main_thread = main_thread;
|
||||
drop(state);
|
||||
runnable.run();
|
||||
|
||||
// todo(localcc): add timings to tests
|
||||
match runnable {
|
||||
RunnableVariant::Meta(runnable) => runnable.run(),
|
||||
RunnableVariant::Compat(runnable) => runnable.run(),
|
||||
};
|
||||
|
||||
self.state.lock().is_main_thread = was_main_thread;
|
||||
|
||||
true
|
||||
@@ -240,20 +245,14 @@ impl TestDispatcher {
|
||||
let block_on_ticks = lock.block_on_ticks.clone();
|
||||
lock.random.random_range(block_on_ticks)
|
||||
}
|
||||
pub fn unpark_last(&self) {
|
||||
self.state
|
||||
.lock()
|
||||
.last_parked
|
||||
.take()
|
||||
.as_ref()
|
||||
.map(Unparker::unpark);
|
||||
|
||||
pub fn unpark_all(&self) {
|
||||
self.state.lock().unparkers.retain(|parker| parker.unpark());
|
||||
}
|
||||
|
||||
pub fn set_unparker(&self, unparker: Unparker) {
|
||||
let last = { self.state.lock().last_parked.replace(unparker) };
|
||||
if let Some(last) = last {
|
||||
last.unpark();
|
||||
}
|
||||
pub fn push_unparker(&self, unparker: Unparker) {
|
||||
let mut state = self.state.lock();
|
||||
state.unparkers.push(unparker);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +267,14 @@ impl Clone for TestDispatcher {
|
||||
}
|
||||
|
||||
impl PlatformDispatcher for TestDispatcher {
|
||||
fn get_all_timings(&self) -> Vec<crate::ThreadTaskTimings> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn get_current_thread_timings(&self) -> Vec<crate::TaskTiming> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn is_main_thread(&self) -> bool {
|
||||
self.state.lock().is_main_thread
|
||||
}
|
||||
@@ -277,7 +284,7 @@ impl PlatformDispatcher for TestDispatcher {
|
||||
state.start_time + state.time
|
||||
}
|
||||
|
||||
fn dispatch(&self, runnable: Runnable, label: Option<TaskLabel>) {
|
||||
fn dispatch(&self, runnable: RunnableVariant, label: Option<TaskLabel>) {
|
||||
{
|
||||
let mut state = self.state.lock();
|
||||
if label.is_some_and(|label| state.deprioritized_task_labels.contains(&label)) {
|
||||
@@ -286,20 +293,20 @@ impl PlatformDispatcher for TestDispatcher {
|
||||
state.background.push(runnable);
|
||||
}
|
||||
}
|
||||
self.unpark_last();
|
||||
self.unpark_all();
|
||||
}
|
||||
|
||||
fn dispatch_on_main_thread(&self, runnable: Runnable) {
|
||||
fn dispatch_on_main_thread(&self, runnable: RunnableVariant) {
|
||||
self.state
|
||||
.lock()
|
||||
.foreground
|
||||
.entry(self.id)
|
||||
.or_default()
|
||||
.push_back(runnable);
|
||||
self.unpark_last();
|
||||
self.unpark_all();
|
||||
}
|
||||
|
||||
fn dispatch_after(&self, duration: std::time::Duration, runnable: Runnable) {
|
||||
fn dispatch_after(&self, duration: std::time::Duration, runnable: RunnableVariant) {
|
||||
let mut state = self.state.lock();
|
||||
let next_time = state.time + duration;
|
||||
let ix = match state.delayed.binary_search_by_key(&next_time, |e| e.0) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use std::{
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
thread::{ThreadId, current},
|
||||
time::Duration,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use async_task::Runnable;
|
||||
use flume::Sender;
|
||||
use util::ResultExt;
|
||||
use windows::{
|
||||
@@ -18,12 +17,13 @@ use windows::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
HWND, PlatformDispatcher, SafeHwnd, TaskLabel, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD,
|
||||
GLOBAL_THREAD_TIMINGS, HWND, PlatformDispatcher, RunnableVariant, SafeHwnd, THREAD_TIMINGS,
|
||||
TaskLabel, TaskTiming, ThreadTaskTimings, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD,
|
||||
};
|
||||
|
||||
pub(crate) struct WindowsDispatcher {
|
||||
pub(crate) wake_posted: AtomicBool,
|
||||
main_sender: Sender<Runnable>,
|
||||
main_sender: Sender<RunnableVariant>,
|
||||
main_thread_id: ThreadId,
|
||||
platform_window_handle: SafeHwnd,
|
||||
validation_number: usize,
|
||||
@@ -31,7 +31,7 @@ pub(crate) struct WindowsDispatcher {
|
||||
|
||||
impl WindowsDispatcher {
|
||||
pub(crate) fn new(
|
||||
main_sender: Sender<Runnable>,
|
||||
main_sender: Sender<RunnableVariant>,
|
||||
platform_window_handle: HWND,
|
||||
validation_number: usize,
|
||||
) -> Self {
|
||||
@@ -47,42 +47,115 @@ impl WindowsDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_on_threadpool(&self, runnable: Runnable) {
|
||||
fn dispatch_on_threadpool(&self, runnable: RunnableVariant) {
|
||||
let handler = {
|
||||
let mut task_wrapper = Some(runnable);
|
||||
WorkItemHandler::new(move |_| {
|
||||
task_wrapper.take().unwrap().run();
|
||||
Self::execute_runnable(task_wrapper.take().unwrap());
|
||||
Ok(())
|
||||
})
|
||||
};
|
||||
ThreadPool::RunWithPriorityAsync(&handler, WorkItemPriority::High).log_err();
|
||||
}
|
||||
|
||||
fn dispatch_on_threadpool_after(&self, runnable: Runnable, duration: Duration) {
|
||||
fn dispatch_on_threadpool_after(&self, runnable: RunnableVariant, duration: Duration) {
|
||||
let handler = {
|
||||
let mut task_wrapper = Some(runnable);
|
||||
TimerElapsedHandler::new(move |_| {
|
||||
task_wrapper.take().unwrap().run();
|
||||
Self::execute_runnable(task_wrapper.take().unwrap());
|
||||
Ok(())
|
||||
})
|
||||
};
|
||||
ThreadPoolTimer::CreateTimer(&handler, duration.into()).log_err();
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn execute_runnable(runnable: RunnableVariant) {
|
||||
let start = Instant::now();
|
||||
|
||||
let mut timing = match runnable {
|
||||
RunnableVariant::Meta(runnable) => {
|
||||
let location = runnable.metadata().location;
|
||||
let timing = TaskTiming {
|
||||
location,
|
||||
start,
|
||||
end: None,
|
||||
};
|
||||
Self::add_task_timing(timing);
|
||||
|
||||
runnable.run();
|
||||
|
||||
timing
|
||||
}
|
||||
RunnableVariant::Compat(runnable) => {
|
||||
let timing = TaskTiming {
|
||||
location: core::panic::Location::caller(),
|
||||
start,
|
||||
end: None,
|
||||
};
|
||||
Self::add_task_timing(timing);
|
||||
|
||||
runnable.run();
|
||||
|
||||
timing
|
||||
}
|
||||
};
|
||||
|
||||
let end = Instant::now();
|
||||
timing.end = Some(end);
|
||||
|
||||
Self::add_task_timing(timing);
|
||||
}
|
||||
|
||||
pub(crate) fn add_task_timing(timing: TaskTiming) {
|
||||
THREAD_TIMINGS.with(|timings| {
|
||||
let mut timings = timings.lock();
|
||||
let timings = &mut timings.timings;
|
||||
|
||||
if let Some(last_timing) = timings.iter_mut().rev().next() {
|
||||
if last_timing.location == timing.location {
|
||||
last_timing.end = timing.end;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
timings.push_back(timing);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformDispatcher for WindowsDispatcher {
|
||||
fn get_all_timings(&self) -> Vec<ThreadTaskTimings> {
|
||||
let global_thread_timings = GLOBAL_THREAD_TIMINGS.lock();
|
||||
ThreadTaskTimings::convert(&global_thread_timings)
|
||||
}
|
||||
|
||||
fn get_current_thread_timings(&self) -> Vec<crate::TaskTiming> {
|
||||
THREAD_TIMINGS.with(|timings| {
|
||||
let timings = timings.lock();
|
||||
let timings = &timings.timings;
|
||||
|
||||
let mut vec = Vec::with_capacity(timings.len());
|
||||
|
||||
let (s1, s2) = timings.as_slices();
|
||||
vec.extend_from_slice(s1);
|
||||
vec.extend_from_slice(s2);
|
||||
vec
|
||||
})
|
||||
}
|
||||
|
||||
fn is_main_thread(&self) -> bool {
|
||||
current().id() == self.main_thread_id
|
||||
}
|
||||
|
||||
fn dispatch(&self, runnable: Runnable, label: Option<TaskLabel>) {
|
||||
fn dispatch(&self, runnable: RunnableVariant, label: Option<TaskLabel>) {
|
||||
self.dispatch_on_threadpool(runnable);
|
||||
if let Some(label) = label {
|
||||
log::debug!("TaskLabel: {label:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_on_main_thread(&self, runnable: Runnable) {
|
||||
fn dispatch_on_main_thread(&self, runnable: RunnableVariant) {
|
||||
match self.main_sender.send(runnable) {
|
||||
Ok(_) => {
|
||||
if !self.wake_posted.swap(true, Ordering::AcqRel) {
|
||||
@@ -111,7 +184,7 @@ impl PlatformDispatcher for WindowsDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_after(&self, duration: Duration, runnable: Runnable) {
|
||||
fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) {
|
||||
self.dispatch_on_threadpool_after(runnable, duration);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ impl WindowsWindowInner {
|
||||
fn handle_timer_msg(&self, handle: HWND, wparam: WPARAM) -> Option<isize> {
|
||||
if wparam.0 == SIZE_MOVE_LOOP_TIMER_ID {
|
||||
for runnable in self.main_receiver.drain() {
|
||||
runnable.run();
|
||||
WindowsDispatcher::execute_runnable(runnable);
|
||||
}
|
||||
self.handle_paint_msg(handle)
|
||||
} else {
|
||||
@@ -487,14 +487,12 @@ impl WindowsWindowInner {
|
||||
let scale_factor = lock.scale_factor;
|
||||
let wheel_scroll_amount = match modifiers.shift {
|
||||
true => {
|
||||
self.system_settings
|
||||
.borrow()
|
||||
self.system_settings()
|
||||
.mouse_wheel_settings
|
||||
.wheel_scroll_chars
|
||||
}
|
||||
false => {
|
||||
self.system_settings
|
||||
.borrow()
|
||||
self.system_settings()
|
||||
.mouse_wheel_settings
|
||||
.wheel_scroll_lines
|
||||
}
|
||||
@@ -541,8 +539,7 @@ impl WindowsWindowInner {
|
||||
};
|
||||
let scale_factor = lock.scale_factor;
|
||||
let wheel_scroll_chars = self
|
||||
.system_settings
|
||||
.borrow()
|
||||
.system_settings()
|
||||
.mouse_wheel_settings
|
||||
.wheel_scroll_chars;
|
||||
drop(lock);
|
||||
@@ -677,8 +674,7 @@ impl WindowsWindowInner {
|
||||
// used by Chrome. However, it may result in one row of pixels being obscured
|
||||
// in our client area. But as Chrome says, "there seems to be no better solution."
|
||||
if is_maximized
|
||||
&& let Some(ref taskbar_position) =
|
||||
self.system_settings.borrow().auto_hide_taskbar_position
|
||||
&& let Some(ref taskbar_position) = self.system_settings().auto_hide_taskbar_position
|
||||
{
|
||||
// For the auto-hide taskbar, adjust in by 1 pixel on taskbar edge,
|
||||
// so the window isn't treated as a "fullscreen app", which would cause
|
||||
@@ -1072,7 +1068,7 @@ impl WindowsWindowInner {
|
||||
lock.border_offset.update(handle).log_err();
|
||||
// system settings may emit a window message which wants to take the refcell lock, so drop it
|
||||
drop(lock);
|
||||
self.system_settings.borrow_mut().update(display, wparam.0);
|
||||
self.system_settings_mut().update(display, wparam.0);
|
||||
} else {
|
||||
self.handle_system_theme_changed(handle, lparam)?;
|
||||
};
|
||||
@@ -1146,8 +1142,10 @@ impl WindowsWindowInner {
|
||||
require_presentation: false,
|
||||
force_render,
|
||||
});
|
||||
|
||||
self.state.borrow_mut().callbacks.request_frame = Some(request_frame);
|
||||
unsafe { ValidateRect(Some(handle), None).ok().log_err() };
|
||||
|
||||
Some(0)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ use std::{
|
||||
|
||||
use ::util::{ResultExt, paths::SanitizedPath};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use async_task::Runnable;
|
||||
use futures::channel::oneshot::{self, Receiver};
|
||||
use itertools::Itertools;
|
||||
use parking_lot::RwLock;
|
||||
@@ -46,7 +45,7 @@ struct WindowsPlatformInner {
|
||||
raw_window_handles: std::sync::Weak<RwLock<SmallVec<[SafeHwnd; 4]>>>,
|
||||
// The below members will never change throughout the entire lifecycle of the app.
|
||||
validation_number: usize,
|
||||
main_receiver: flume::Receiver<Runnable>,
|
||||
main_receiver: flume::Receiver<RunnableVariant>,
|
||||
dispatcher: Arc<WindowsDispatcher>,
|
||||
}
|
||||
|
||||
@@ -93,7 +92,7 @@ impl WindowsPlatform {
|
||||
OleInitialize(None).context("unable to initialize Windows OLE")?;
|
||||
}
|
||||
let directx_devices = DirectXDevices::new().context("Creating DirectX devices")?;
|
||||
let (main_sender, main_receiver) = flume::unbounded::<Runnable>();
|
||||
let (main_sender, main_receiver) = flume::unbounded::<RunnableVariant>();
|
||||
let validation_number = if usize::BITS == 64 {
|
||||
rand::random::<u64>() as usize
|
||||
} else {
|
||||
@@ -342,9 +341,8 @@ impl Platform for WindowsPlatform {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref mut callback) = self.inner.state.borrow_mut().callbacks.quit {
|
||||
callback();
|
||||
}
|
||||
self.inner
|
||||
.with_callback(|callbacks| &mut callbacks.quit, |callback| callback());
|
||||
}
|
||||
|
||||
fn quit(&self) {
|
||||
@@ -578,14 +576,13 @@ impl Platform for WindowsPlatform {
|
||||
|
||||
fn set_cursor_style(&self, style: CursorStyle) {
|
||||
let hcursor = load_cursor(style);
|
||||
let mut lock = self.inner.state.borrow_mut();
|
||||
if lock.current_cursor.map(|c| c.0) != hcursor.map(|c| c.0) {
|
||||
if self.inner.state.borrow_mut().current_cursor.map(|c| c.0) != hcursor.map(|c| c.0) {
|
||||
self.post_message(
|
||||
WM_GPUI_CURSOR_STYLE_CHANGED,
|
||||
WPARAM(0),
|
||||
LPARAM(hcursor.map_or(0, |c| c.0 as isize)),
|
||||
);
|
||||
lock.current_cursor = hcursor;
|
||||
self.inner.state.borrow_mut().current_cursor = hcursor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,6 +721,19 @@ impl WindowsPlatformInner {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Calls `project` to project to the corresponding callback field, removes it from callbacks, calls `f` with the callback and then puts the callback back.
|
||||
fn with_callback<T>(
|
||||
&self,
|
||||
project: impl Fn(&mut PlatformCallbacks) -> &mut Option<T>,
|
||||
f: impl FnOnce(&mut T),
|
||||
) {
|
||||
let callback = project(&mut self.state.borrow_mut().callbacks).take();
|
||||
if let Some(mut callback) = callback {
|
||||
f(&mut callback);
|
||||
*project(&mut self.state.borrow_mut().callbacks) = Some(callback)
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_msg(
|
||||
self: &Rc<Self>,
|
||||
handle: HWND,
|
||||
@@ -783,7 +793,7 @@ impl WindowsPlatformInner {
|
||||
fn run_foreground_task(&self) -> Option<isize> {
|
||||
loop {
|
||||
for runnable in self.main_receiver.drain() {
|
||||
runnable.run();
|
||||
WindowsDispatcher::execute_runnable(runnable);
|
||||
}
|
||||
|
||||
// Someone could enqueue a Runnable here. The flag is still true, so they will not PostMessage.
|
||||
@@ -794,7 +804,8 @@ impl WindowsPlatformInner {
|
||||
match self.main_receiver.try_recv() {
|
||||
Ok(runnable) => {
|
||||
let _ = dispatcher.wake_posted.swap(true, Ordering::AcqRel);
|
||||
runnable.run();
|
||||
|
||||
WindowsDispatcher::execute_runnable(runnable);
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
@@ -807,40 +818,36 @@ impl WindowsPlatformInner {
|
||||
}
|
||||
|
||||
fn handle_dock_action_event(&self, action_idx: usize) -> Option<isize> {
|
||||
let mut lock = self.state.borrow_mut();
|
||||
let mut callback = lock.callbacks.app_menu_action.take()?;
|
||||
let Some(action) = lock
|
||||
let Some(action) = self
|
||||
.state
|
||||
.borrow_mut()
|
||||
.jump_list
|
||||
.dock_menus
|
||||
.get(action_idx)
|
||||
.map(|dock_menu| dock_menu.action.boxed_clone())
|
||||
else {
|
||||
lock.callbacks.app_menu_action = Some(callback);
|
||||
log::error!("Dock menu for index {action_idx} not found");
|
||||
return Some(1);
|
||||
};
|
||||
drop(lock);
|
||||
callback(&*action);
|
||||
self.state.borrow_mut().callbacks.app_menu_action = Some(callback);
|
||||
self.with_callback(
|
||||
|callbacks| &mut callbacks.app_menu_action,
|
||||
|callback| callback(&*action),
|
||||
);
|
||||
Some(0)
|
||||
}
|
||||
|
||||
fn handle_keyboard_layout_change(&self) -> Option<isize> {
|
||||
let mut callback = self
|
||||
.state
|
||||
.borrow_mut()
|
||||
.callbacks
|
||||
.keyboard_layout_change
|
||||
.take()?;
|
||||
callback();
|
||||
self.state.borrow_mut().callbacks.keyboard_layout_change = Some(callback);
|
||||
self.with_callback(
|
||||
|callbacks| &mut callbacks.keyboard_layout_change,
|
||||
|callback| callback(),
|
||||
);
|
||||
Some(0)
|
||||
}
|
||||
|
||||
fn handle_device_lost(&self, lparam: LPARAM) -> Option<isize> {
|
||||
let mut lock = self.state.borrow_mut();
|
||||
let directx_devices = lparam.0 as *const DirectXDevices;
|
||||
let directx_devices = unsafe { &*directx_devices };
|
||||
let mut lock = self.state.borrow_mut();
|
||||
lock.directx_devices.take();
|
||||
lock.directx_devices = Some(directx_devices.clone());
|
||||
|
||||
@@ -866,7 +873,7 @@ pub(crate) struct WindowCreationInfo {
|
||||
pub(crate) windows_version: WindowsVersion,
|
||||
pub(crate) drop_target_helper: IDropTargetHelper,
|
||||
pub(crate) validation_number: usize,
|
||||
pub(crate) main_receiver: flume::Receiver<Runnable>,
|
||||
pub(crate) main_receiver: flume::Receiver<RunnableVariant>,
|
||||
pub(crate) platform_window_handle: HWND,
|
||||
pub(crate) disable_direct_composition: bool,
|
||||
pub(crate) directx_devices: DirectXDevices,
|
||||
@@ -876,8 +883,8 @@ struct PlatformWindowCreateContext {
|
||||
inner: Option<Result<Rc<WindowsPlatformInner>>>,
|
||||
raw_window_handles: std::sync::Weak<RwLock<SmallVec<[SafeHwnd; 4]>>>,
|
||||
validation_number: usize,
|
||||
main_sender: Option<flume::Sender<Runnable>>,
|
||||
main_receiver: Option<flume::Receiver<Runnable>>,
|
||||
main_sender: Option<flume::Sender<RunnableVariant>>,
|
||||
main_receiver: Option<flume::Receiver<RunnableVariant>>,
|
||||
directx_devices: Option<DirectXDevices>,
|
||||
dispatcher: Option<Arc<WindowsDispatcher>>,
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ use std::{
|
||||
|
||||
use ::util::ResultExt;
|
||||
use anyhow::{Context as _, Result};
|
||||
use async_task::Runnable;
|
||||
use futures::channel::oneshot::{self, Receiver};
|
||||
use raw_window_handle as rwh;
|
||||
use smallvec::SmallVec;
|
||||
@@ -63,14 +62,14 @@ pub(crate) struct WindowsWindowInner {
|
||||
hwnd: HWND,
|
||||
drop_target_helper: IDropTargetHelper,
|
||||
pub(crate) state: RefCell<WindowsWindowState>,
|
||||
pub(crate) system_settings: RefCell<WindowsSystemSettings>,
|
||||
system_settings: RefCell<WindowsSystemSettings>,
|
||||
pub(crate) handle: AnyWindowHandle,
|
||||
pub(crate) hide_title_bar: bool,
|
||||
pub(crate) is_movable: bool,
|
||||
pub(crate) executor: ForegroundExecutor,
|
||||
pub(crate) windows_version: WindowsVersion,
|
||||
pub(crate) validation_number: usize,
|
||||
pub(crate) main_receiver: flume::Receiver<Runnable>,
|
||||
pub(crate) main_receiver: flume::Receiver<RunnableVariant>,
|
||||
pub(crate) platform_window_handle: HWND,
|
||||
}
|
||||
|
||||
@@ -321,6 +320,14 @@ impl WindowsWindowInner {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn system_settings(&self) -> std::cell::Ref<'_, WindowsSystemSettings> {
|
||||
self.system_settings.borrow()
|
||||
}
|
||||
|
||||
pub(crate) fn system_settings_mut(&self) -> std::cell::RefMut<'_, WindowsSystemSettings> {
|
||||
self.system_settings.borrow_mut()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -349,7 +356,7 @@ struct WindowCreateContext {
|
||||
windows_version: WindowsVersion,
|
||||
drop_target_helper: IDropTargetHelper,
|
||||
validation_number: usize,
|
||||
main_receiver: flume::Receiver<Runnable>,
|
||||
main_receiver: flume::Receiver<RunnableVariant>,
|
||||
platform_window_handle: HWND,
|
||||
appearance: WindowAppearance,
|
||||
disable_direct_composition: bool,
|
||||
@@ -453,8 +460,9 @@ impl WindowsWindow {
|
||||
|
||||
// Failure to create a `WindowsWindowState` can cause window creation to fail,
|
||||
// so check the inner result first.
|
||||
let this = context.inner.take().unwrap()?;
|
||||
let this = context.inner.take().transpose()?;
|
||||
let hwnd = creation_result?;
|
||||
let this = this.unwrap();
|
||||
|
||||
register_drag_drop(&this)?;
|
||||
configure_dwm_dark_mode(hwnd, appearance);
|
||||
|
||||
218
crates/gpui/src/profiler.rs
Normal file
218
crates/gpui/src/profiler.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
use std::{
|
||||
cell::LazyCell,
|
||||
hash::Hasher,
|
||||
hash::{DefaultHasher, Hash},
|
||||
sync::Arc,
|
||||
thread::ThreadId,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct TaskTiming {
|
||||
pub location: &'static core::panic::Location<'static>,
|
||||
pub start: Instant,
|
||||
pub end: Option<Instant>,
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThreadTaskTimings {
|
||||
pub thread_name: Option<String>,
|
||||
pub thread_id: ThreadId,
|
||||
pub timings: Vec<TaskTiming>,
|
||||
}
|
||||
|
||||
impl ThreadTaskTimings {
|
||||
pub(crate) fn convert(timings: &[GlobalThreadTimings]) -> Vec<Self> {
|
||||
timings
|
||||
.iter()
|
||||
.filter_map(|t| match t.timings.upgrade() {
|
||||
Some(timings) => Some((t.thread_id, timings)),
|
||||
_ => None,
|
||||
})
|
||||
.map(|(thread_id, timings)| {
|
||||
let timings = timings.lock();
|
||||
let thread_name = timings.thread_name.clone();
|
||||
let timings = &timings.timings;
|
||||
|
||||
let mut vec = Vec::with_capacity(timings.len());
|
||||
|
||||
let (s1, s2) = timings.as_slices();
|
||||
vec.extend_from_slice(s1);
|
||||
vec.extend_from_slice(s2);
|
||||
|
||||
ThreadTaskTimings {
|
||||
thread_name,
|
||||
thread_id,
|
||||
timings: vec,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializable variant of [`core::panic::Location`]
|
||||
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
|
||||
pub struct SerializedLocation<'a> {
|
||||
/// Name of the source file
|
||||
pub file: &'a str,
|
||||
/// Line in the source file
|
||||
pub line: u32,
|
||||
/// Column in the source file
|
||||
pub column: u32,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a core::panic::Location<'a>> for SerializedLocation<'a> {
|
||||
fn from(value: &'a core::panic::Location<'a>) -> Self {
|
||||
SerializedLocation {
|
||||
file: value.file(),
|
||||
line: value.line(),
|
||||
column: value.column(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializable variant of [`TaskTiming`]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SerializedTaskTiming<'a> {
|
||||
/// Location of the timing
|
||||
#[serde(borrow)]
|
||||
pub location: SerializedLocation<'a>,
|
||||
/// Time at which the measurement was reported in nanoseconds
|
||||
pub start: u128,
|
||||
/// Duration of the measurement in nanoseconds
|
||||
pub duration: u128,
|
||||
}
|
||||
|
||||
impl<'a> SerializedTaskTiming<'a> {
|
||||
/// Convert an array of [`TaskTiming`] into their serializable format
|
||||
///
|
||||
/// # Params
|
||||
///
|
||||
/// `anchor` - [`Instant`] that should be earlier than all timings to use as base anchor
|
||||
pub fn convert(anchor: Instant, timings: &[TaskTiming]) -> Vec<SerializedTaskTiming<'static>> {
|
||||
let serialized = timings
|
||||
.iter()
|
||||
.map(|timing| {
|
||||
let start = timing.start.duration_since(anchor).as_nanos();
|
||||
let duration = timing
|
||||
.end
|
||||
.unwrap_or_else(|| Instant::now())
|
||||
.duration_since(timing.start)
|
||||
.as_nanos();
|
||||
SerializedTaskTiming {
|
||||
location: timing.location.into(),
|
||||
start,
|
||||
duration,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
serialized
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializable variant of [`ThreadTaskTimings`]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SerializedThreadTaskTimings<'a> {
|
||||
/// Thread name
|
||||
pub thread_name: Option<String>,
|
||||
/// Hash of the thread id
|
||||
pub thread_id: u64,
|
||||
/// Timing records for this thread
|
||||
#[serde(borrow)]
|
||||
pub timings: Vec<SerializedTaskTiming<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> SerializedThreadTaskTimings<'a> {
|
||||
/// Convert [`ThreadTaskTimings`] into their serializable format
|
||||
///
|
||||
/// # Params
|
||||
///
|
||||
/// `anchor` - [`Instant`] that should be earlier than all timings to use as base anchor
|
||||
pub fn convert(
|
||||
anchor: Instant,
|
||||
timings: ThreadTaskTimings,
|
||||
) -> SerializedThreadTaskTimings<'static> {
|
||||
let serialized_timings = SerializedTaskTiming::convert(anchor, &timings.timings);
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
timings.thread_id.hash(&mut hasher);
|
||||
let thread_id = hasher.finish();
|
||||
|
||||
SerializedThreadTaskTimings {
|
||||
thread_name: timings.thread_name,
|
||||
thread_id,
|
||||
timings: serialized_timings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Allow 20mb of task timing entries
|
||||
const MAX_TASK_TIMINGS: usize = (20 * 1024 * 1024) / core::mem::size_of::<TaskTiming>();
|
||||
|
||||
pub(crate) type TaskTimings = circular_buffer::CircularBuffer<MAX_TASK_TIMINGS, TaskTiming>;
|
||||
pub(crate) type GuardedTaskTimings = spin::Mutex<ThreadTimings>;
|
||||
|
||||
pub(crate) struct GlobalThreadTimings {
|
||||
pub thread_id: ThreadId,
|
||||
pub timings: std::sync::Weak<GuardedTaskTimings>,
|
||||
}
|
||||
|
||||
pub(crate) static GLOBAL_THREAD_TIMINGS: spin::Mutex<Vec<GlobalThreadTimings>> =
|
||||
spin::Mutex::new(Vec::new());
|
||||
|
||||
thread_local! {
|
||||
pub(crate) static THREAD_TIMINGS: LazyCell<Arc<GuardedTaskTimings>> = LazyCell::new(|| {
|
||||
let current_thread = std::thread::current();
|
||||
let thread_name = current_thread.name();
|
||||
let thread_id = current_thread.id();
|
||||
let timings = ThreadTimings::new(thread_name.map(|e| e.to_string()), thread_id);
|
||||
let timings = Arc::new(spin::Mutex::new(timings));
|
||||
|
||||
{
|
||||
let timings = Arc::downgrade(&timings);
|
||||
let global_timings = GlobalThreadTimings {
|
||||
thread_id: std::thread::current().id(),
|
||||
timings,
|
||||
};
|
||||
GLOBAL_THREAD_TIMINGS.lock().push(global_timings);
|
||||
}
|
||||
|
||||
timings
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) struct ThreadTimings {
|
||||
pub thread_name: Option<String>,
|
||||
pub thread_id: ThreadId,
|
||||
pub timings: Box<TaskTimings>,
|
||||
}
|
||||
|
||||
impl ThreadTimings {
|
||||
pub(crate) fn new(thread_name: Option<String>, thread_id: ThreadId) -> Self {
|
||||
ThreadTimings {
|
||||
thread_name,
|
||||
thread_id,
|
||||
timings: TaskTimings::boxed(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ThreadTimings {
|
||||
fn drop(&mut self) {
|
||||
let mut thread_timings = GLOBAL_THREAD_TIMINGS.lock();
|
||||
|
||||
let Some((index, _)) = thread_timings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, t)| t.thread_id == self.thread_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
thread_timings.swap_remove(index);
|
||||
}
|
||||
}
|
||||
@@ -54,9 +54,25 @@ pub struct ShapedGlyph {
|
||||
}
|
||||
|
||||
impl LineLayout {
|
||||
/// The index for the character at the given x coordinate
|
||||
pub fn index_for_x(&self, x: Pixels) -> Option<usize> {
|
||||
if x >= self.width {
|
||||
None
|
||||
} else {
|
||||
for run in self.runs.iter().rev() {
|
||||
for glyph in run.glyphs.iter().rev() {
|
||||
if glyph.position.x <= x {
|
||||
return Some(glyph.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// closest_index_for_x returns the character boundary closest to the given x coordinate
|
||||
/// (e.g. to handle aligning up/down arrow keys)
|
||||
pub fn index_for_x(&self, x: Pixels) -> usize {
|
||||
pub fn closest_index_for_x(&self, x: Pixels) -> usize {
|
||||
let mut prev_index = 0;
|
||||
let mut prev_x = px(0.);
|
||||
|
||||
@@ -262,10 +278,34 @@ impl WrappedLineLayout {
|
||||
}
|
||||
|
||||
/// The index corresponding to a given position in this layout for the given line height.
|
||||
///
|
||||
/// See also [`Self::closest_index_for_position`].
|
||||
pub fn index_for_position(
|
||||
&self,
|
||||
position: Point<Pixels>,
|
||||
line_height: Pixels,
|
||||
) -> Result<usize, usize> {
|
||||
self._index_for_position(position, line_height, false)
|
||||
}
|
||||
|
||||
/// The closest index to a given position in this layout for the given line height.
|
||||
///
|
||||
/// Closest means the character boundary closest to the given position.
|
||||
///
|
||||
/// See also [`LineLayout::closest_index_for_x`].
|
||||
pub fn closest_index_for_position(
|
||||
&self,
|
||||
position: Point<Pixels>,
|
||||
line_height: Pixels,
|
||||
) -> Result<usize, usize> {
|
||||
self._index_for_position(position, line_height, true)
|
||||
}
|
||||
|
||||
fn _index_for_position(
|
||||
&self,
|
||||
mut position: Point<Pixels>,
|
||||
line_height: Pixels,
|
||||
closest: bool,
|
||||
) -> Result<usize, usize> {
|
||||
let wrapped_line_ix = (position.y / line_height) as usize;
|
||||
|
||||
@@ -305,9 +345,16 @@ impl WrappedLineLayout {
|
||||
} else if position_in_unwrapped_line.x >= wrapped_line_end_x {
|
||||
Err(wrapped_line_end_index)
|
||||
} else {
|
||||
Ok(self
|
||||
.unwrapped_layout
|
||||
.index_for_x(position_in_unwrapped_line.x))
|
||||
if closest {
|
||||
Ok(self
|
||||
.unwrapped_layout
|
||||
.closest_index_for_x(position_in_unwrapped_line.x))
|
||||
} else {
|
||||
Ok(self
|
||||
.unwrapped_layout
|
||||
.index_for_x(position_in_unwrapped_line.x)
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ pub enum IconName {
|
||||
ArrowUp,
|
||||
ArrowUpRight,
|
||||
Attach,
|
||||
AtSign,
|
||||
AudioOff,
|
||||
AudioOn,
|
||||
Backspace,
|
||||
|
||||
@@ -664,6 +664,8 @@ impl CompletionProvider for RustStyleCompletionProvider {
|
||||
replace_range: replace_range.clone(),
|
||||
new_text: format!(".{}()", method.name),
|
||||
label: CodeLabel::plain(method.name.to_string(), None),
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
icon_path: None,
|
||||
documentation: method.documentation.map(|documentation| {
|
||||
CompletionDocumentation::MultiLineMarkdown(documentation.into())
|
||||
|
||||
@@ -173,9 +173,15 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap
|
||||
}
|
||||
|
||||
fn journal_dir(path: &str) -> Option<PathBuf> {
|
||||
shellexpand::full(path) //TODO handle this better
|
||||
.ok()
|
||||
.map(|dir| Path::new(&dir.to_string()).to_path_buf().join("journal"))
|
||||
let expanded = shellexpand::full(path).ok()?;
|
||||
let base_path = Path::new(expanded.as_ref());
|
||||
let absolute_path = if base_path.is_absolute() {
|
||||
base_path.to_path_buf()
|
||||
} else {
|
||||
log::warn!("Invalid journal path {path:?} (not absolute), falling back to home directory",);
|
||||
std::env::home_dir()?
|
||||
};
|
||||
Some(absolute_path.join("journal"))
|
||||
}
|
||||
|
||||
fn heading_entry(now: NaiveTime, hour_format: &HourFormat) -> String {
|
||||
@@ -224,4 +230,65 @@ mod tests {
|
||||
assert_eq!(actual_heading_entry, expected_heading_entry);
|
||||
}
|
||||
}
|
||||
|
||||
mod journal_dir_tests {
|
||||
use super::super::*;
|
||||
|
||||
#[test]
|
||||
#[cfg(target_family = "unix")]
|
||||
fn test_absolute_unix_path() {
|
||||
let result = journal_dir("/home/user");
|
||||
assert!(result.is_some());
|
||||
let path = result.unwrap();
|
||||
assert!(path.is_absolute());
|
||||
assert_eq!(path, PathBuf::from("/home/user/journal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tilde_expansion() {
|
||||
let result = journal_dir("~/documents");
|
||||
assert!(result.is_some());
|
||||
let path = result.unwrap();
|
||||
|
||||
assert!(path.is_absolute(), "Tilde should expand to absolute path");
|
||||
|
||||
if let Some(home) = std::env::home_dir() {
|
||||
assert_eq!(path, home.join("documents").join("journal"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relative_path_falls_back_to_home() {
|
||||
for relative_path in ["relative/path", "NONEXT/some/path", "../some/path"] {
|
||||
let result = journal_dir(relative_path);
|
||||
assert!(result.is_some(), "Failed for path: {}", relative_path);
|
||||
let path = result.unwrap();
|
||||
|
||||
assert!(
|
||||
path.is_absolute(),
|
||||
"Path should be absolute for input '{}', got: {:?}",
|
||||
relative_path,
|
||||
path
|
||||
);
|
||||
|
||||
if let Some(home) = std::env::home_dir() {
|
||||
assert_eq!(
|
||||
path,
|
||||
home.join("journal"),
|
||||
"Should fall back to home directory for input '{}'",
|
||||
relative_path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "windows")]
|
||||
fn test_absolute_path_windows_style() {
|
||||
let result = journal_dir("C:\\Users\\user\\Documents");
|
||||
assert!(result.is_some());
|
||||
let path = result.unwrap();
|
||||
assert_eq!(path, PathBuf::from("C:\\Users\\user\\Documents\\journal"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,22 +1030,22 @@
|
||||
"$ref": "#"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"$ref": "https://json.schemastore.org/eslintrc.json"
|
||||
"$ref": "https://www.schemastore.org/eslintrc.json"
|
||||
},
|
||||
"prettier": {
|
||||
"$ref": "https://json.schemastore.org/prettierrc.json"
|
||||
"$ref": "https://www.schemastore.org/prettierrc.json"
|
||||
},
|
||||
"stylelint": {
|
||||
"$ref": "https://json.schemastore.org/stylelintrc.json"
|
||||
"$ref": "https://www.schemastore.org/stylelintrc.json"
|
||||
},
|
||||
"ava": {
|
||||
"$ref": "https://json.schemastore.org/ava.json"
|
||||
"$ref": "https://www.schemastore.org/ava.json"
|
||||
},
|
||||
"release": {
|
||||
"$ref": "https://json.schemastore.org/semantic-release.json"
|
||||
"$ref": "https://www.schemastore.org/semantic-release.json"
|
||||
},
|
||||
"jscpd": {
|
||||
"$ref": "https://json.schemastore.org/jscpd.json"
|
||||
"$ref": "https://www.schemastore.org/jscpd.json"
|
||||
},
|
||||
"pnpm": {
|
||||
"description": "Defines pnpm specific configuration.",
|
||||
@@ -1305,5 +1305,5 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"$id": "https://json.schemastore.org/package.json"
|
||||
"$id": "https://www.schemastore.org/package.json"
|
||||
}
|
||||
|
||||
@@ -1466,7 +1466,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "https://json.schemastore.org/tsconfig",
|
||||
"id": "https://www.schemastore.org/tsconfig",
|
||||
"title": "JSON schema for the TypeScript compiler's configuration file",
|
||||
"type": "object"
|
||||
}
|
||||
|
||||
@@ -2993,6 +2993,8 @@ impl CompletionProvider for KeyContextCompletionProvider {
|
||||
documentation: None,
|
||||
source: project::CompletionSource::Custom,
|
||||
icon_path: None,
|
||||
match_start: None,
|
||||
snippet_deduplication_key: None,
|
||||
insert_text_mode: None,
|
||||
confirm: None,
|
||||
})
|
||||
|
||||
@@ -291,6 +291,7 @@ pub trait LspAdapterDelegate: Send + Sync {
|
||||
fn http_client(&self) -> Arc<dyn HttpClient>;
|
||||
fn worktree_id(&self) -> WorktreeId;
|
||||
fn worktree_root_path(&self) -> &Path;
|
||||
fn resolve_executable_path(&self, path: PathBuf) -> PathBuf;
|
||||
fn update_status(&self, language: LanguageServerName, status: BinaryStatus);
|
||||
fn registered_lsp_adapters(&self) -> Vec<Arc<dyn LspAdapter>>;
|
||||
async fn language_server_download_dir(&self, name: &LanguageServerName) -> Option<Arc<Path>>;
|
||||
|
||||
@@ -381,10 +381,13 @@ impl OllamaLanguageModel {
|
||||
thinking = Some(text)
|
||||
}
|
||||
MessageContent::ToolUse(tool_use) => {
|
||||
tool_calls.push(OllamaToolCall::Function(OllamaFunctionCall {
|
||||
name: tool_use.name.to_string(),
|
||||
arguments: tool_use.input,
|
||||
}));
|
||||
tool_calls.push(OllamaToolCall {
|
||||
id: Some(tool_use.id.to_string()),
|
||||
function: OllamaFunctionCall {
|
||||
name: tool_use.name.to_string(),
|
||||
arguments: tool_use.input,
|
||||
},
|
||||
});
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
@@ -575,25 +578,23 @@ fn map_to_language_model_completion_events(
|
||||
}
|
||||
|
||||
if let Some(tool_call) = tool_calls.and_then(|v| v.into_iter().next()) {
|
||||
match tool_call {
|
||||
OllamaToolCall::Function(function) => {
|
||||
let tool_id = format!(
|
||||
"{}-{}",
|
||||
&function.name,
|
||||
TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
let event =
|
||||
LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
|
||||
id: LanguageModelToolUseId::from(tool_id),
|
||||
name: Arc::from(function.name),
|
||||
raw_input: function.arguments.to_string(),
|
||||
input: function.arguments,
|
||||
is_input_complete: true,
|
||||
});
|
||||
events.push(Ok(event));
|
||||
state.used_tools = true;
|
||||
}
|
||||
}
|
||||
let OllamaToolCall { id, function } = tool_call;
|
||||
let id = id.unwrap_or_else(|| {
|
||||
format!(
|
||||
"{}-{}",
|
||||
&function.name,
|
||||
TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
)
|
||||
});
|
||||
let event = LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
|
||||
id: LanguageModelToolUseId::from(id),
|
||||
name: Arc::from(function.name),
|
||||
raw_input: function.arguments.to_string(),
|
||||
input: function.arguments,
|
||||
is_input_complete: true,
|
||||
});
|
||||
events.push(Ok(event));
|
||||
state.used_tools = true;
|
||||
} else if !content.is_empty() {
|
||||
events.push(Ok(LanguageModelCompletionEvent::Text(content)));
|
||||
}
|
||||
|
||||
@@ -1053,11 +1053,16 @@ impl Render for LspButton {
|
||||
(None, "All Servers Operational")
|
||||
};
|
||||
|
||||
let lsp_button = cx.entity();
|
||||
let lsp_button = cx.weak_entity();
|
||||
|
||||
div().child(
|
||||
PopoverMenu::new("lsp-tool")
|
||||
.menu(move |_, cx| lsp_button.read(cx).lsp_menu.clone())
|
||||
.menu(move |_, cx| {
|
||||
lsp_button
|
||||
.read_with(cx, |lsp_button, _| lsp_button.lsp_menu.clone())
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
.anchor(Corner::BottomLeft)
|
||||
.with_handle(self.popover_menu_handle.clone())
|
||||
.trigger_with_tooltip(
|
||||
|
||||
@@ -9,6 +9,36 @@
|
||||
(type_identifier) @type
|
||||
(predefined_type) @type.builtin
|
||||
|
||||
;; Highlights object literals by hijacking the statement_block pattern, but only if
|
||||
;; the statement block follows an object literal pattern
|
||||
((statement_block
|
||||
(labeled_statement
|
||||
;; highlight the label like a property name
|
||||
label: (statement_identifier) @property.name
|
||||
body: [
|
||||
;; match a terminating expression statement
|
||||
(expression_statement
|
||||
;; single identifier - treat as a type name
|
||||
[(identifier) @type.name
|
||||
;; object - treat as a property - type pair
|
||||
(object
|
||||
(pair
|
||||
key: (_) @property.name
|
||||
value: (_) @type.name))
|
||||
;; subscript_expression - treat as an array declaration
|
||||
(subscript_expression
|
||||
object: (_) @type.name
|
||||
index: (_)
|
||||
)
|
||||
;; templated string - treat each identifier contained as a type name
|
||||
(template_string
|
||||
(template_substitution
|
||||
(identifier) @type.name))
|
||||
])
|
||||
;; match a nested statement block
|
||||
(statement_block) @nested
|
||||
])))
|
||||
|
||||
(import_specifier
|
||||
"type"
|
||||
name: (identifier) @type
|
||||
@@ -79,6 +109,8 @@
|
||||
left: (identifier) @function
|
||||
right: [(function_expression) (arrow_function)])
|
||||
|
||||
(arrow_function) @function
|
||||
|
||||
; Literals
|
||||
|
||||
(this) @variable.special
|
||||
|
||||
@@ -6,11 +6,12 @@ use language::{LanguageName, LspAdapter, LspAdapterDelegate, LspInstaller, Toolc
|
||||
use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName};
|
||||
use node_runtime::{NodeRuntime, VersionStrategy};
|
||||
use project::{Fs, lsp_store::language_server_settings};
|
||||
use regex::Regex;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
sync::{Arc, LazyLock},
|
||||
};
|
||||
use util::{ResultExt, maybe, merge_json_value_into};
|
||||
|
||||
@@ -56,6 +57,20 @@ impl VtslsLspAdapter {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enhance_diagnostic_message(message: &str) -> Option<String> {
|
||||
static SINGLE_WORD_REGEX: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"'([^\s']*)'").expect("Failed to create REGEX"));
|
||||
|
||||
static MULTI_WORD_REGEX: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"'([^']+\s+[^']*)'").expect("Failed to create REGEX"));
|
||||
|
||||
let first = SINGLE_WORD_REGEX.replace_all(message, "`$1`").to_string();
|
||||
let second = MULTI_WORD_REGEX
|
||||
.replace_all(&first, "\n```typescript\n$1\n```\n")
|
||||
.to_string();
|
||||
Some(second)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TypeScriptVersions {
|
||||
@@ -274,6 +289,10 @@ impl LspAdapter for VtslsLspAdapter {
|
||||
Ok(default_workspace_configuration)
|
||||
}
|
||||
|
||||
fn diagnostic_message_to_markdown(&self, message: &str) -> Option<String> {
|
||||
VtslsLspAdapter::enhance_diagnostic_message(message)
|
||||
}
|
||||
|
||||
fn language_ids(&self) -> HashMap<LanguageName, String> {
|
||||
HashMap::from_iter([
|
||||
(LanguageName::new("TypeScript"), "typescript".into()),
|
||||
@@ -302,3 +321,41 @@ async fn get_cached_ts_server_binary(
|
||||
.await
|
||||
.log_err()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::vtsls::VtslsLspAdapter;
|
||||
|
||||
#[test]
|
||||
fn test_diagnostic_message_to_markdown() {
|
||||
// Leaves simple messages unchanged
|
||||
let message = "The expected type comes from the return type of this signature.";
|
||||
|
||||
let expected = "The expected type comes from the return type of this signature.";
|
||||
|
||||
assert_eq!(
|
||||
VtslsLspAdapter::enhance_diagnostic_message(message).expect("Should be some"),
|
||||
expected
|
||||
);
|
||||
|
||||
// Parses both multi-word and single-word correctly
|
||||
let message = "Property 'baz' is missing in type '{ foo: string; bar: string; }' but required in type 'User'.";
|
||||
|
||||
let expected = "Property `baz` is missing in type \n```typescript\n{ foo: string; bar: string; }\n```\n but required in type `User`.";
|
||||
|
||||
assert_eq!(
|
||||
VtslsLspAdapter::enhance_diagnostic_message(message).expect("Should be some"),
|
||||
expected
|
||||
);
|
||||
|
||||
// Parses multi-and-single word in any order, and ignores existing newlines
|
||||
let message = "Type '() => { foo: string; bar: string; }' is not assignable to type 'GetUserFunction'.\n Property 'baz' is missing in type '{ foo: string; bar: string; }' but required in type 'User'.";
|
||||
|
||||
let expected = "Type \n```typescript\n() => { foo: string; bar: string; }\n```\n is not assignable to type `GetUserFunction`.\n Property `baz` is missing in type \n```typescript\n{ foo: string; bar: string; }\n```\n but required in type `User`.";
|
||||
|
||||
assert_eq!(
|
||||
VtslsLspAdapter::enhance_diagnostic_message(message).expect("Should be some"),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,3 +135,9 @@ pub(crate) mod m_2025_10_21 {
|
||||
|
||||
pub(crate) use settings::make_relative_line_numbers_an_enum;
|
||||
}
|
||||
|
||||
pub(crate) mod m_2025_11_12 {
|
||||
mod settings;
|
||||
|
||||
pub(crate) use settings::SETTINGS_PATTERNS;
|
||||
}
|
||||
|
||||
84
crates/migrator/src/migrations/m_2025_11_12/settings.rs
Normal file
84
crates/migrator/src/migrations/m_2025_11_12/settings.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use std::ops::Range;
|
||||
use tree_sitter::{Query, QueryMatch};
|
||||
|
||||
use crate::MigrationPatterns;
|
||||
use crate::patterns::SETTINGS_NESTED_KEY_VALUE_PATTERN;
|
||||
|
||||
pub const SETTINGS_PATTERNS: MigrationPatterns = &[
|
||||
(
|
||||
SETTINGS_NESTED_KEY_VALUE_PATTERN,
|
||||
rename_open_file_on_paste_setting,
|
||||
),
|
||||
(
|
||||
SETTINGS_NESTED_KEY_VALUE_PATTERN,
|
||||
replace_open_file_on_paste_setting_value,
|
||||
),
|
||||
];
|
||||
|
||||
fn rename_open_file_on_paste_setting(
|
||||
contents: &str,
|
||||
mat: &QueryMatch,
|
||||
query: &Query,
|
||||
) -> Option<(Range<usize>, String)> {
|
||||
if !is_project_panel_open_file_on_paste(contents, mat, query) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let setting_name_ix = query.capture_index_for_name("setting_name")?;
|
||||
let setting_name_range = mat
|
||||
.nodes_for_capture_index(setting_name_ix)
|
||||
.next()?
|
||||
.byte_range();
|
||||
|
||||
Some((setting_name_range, "auto_open".to_string()))
|
||||
}
|
||||
|
||||
fn replace_open_file_on_paste_setting_value(
|
||||
contents: &str,
|
||||
mat: &QueryMatch,
|
||||
query: &Query,
|
||||
) -> Option<(Range<usize>, String)> {
|
||||
if !is_project_panel_open_file_on_paste(contents, mat, query) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let value_ix = query.capture_index_for_name("setting_value")?;
|
||||
let value_node = mat.nodes_for_capture_index(value_ix).next()?;
|
||||
let value_range = value_node.byte_range();
|
||||
let value_text = contents.get(value_range.clone())?.trim();
|
||||
|
||||
let normalized_value = match value_text {
|
||||
"true" => "true",
|
||||
"false" => "false",
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some((
|
||||
value_range,
|
||||
format!("{{ \"on_paste\": {normalized_value} }}"),
|
||||
))
|
||||
}
|
||||
|
||||
fn is_project_panel_open_file_on_paste(contents: &str, mat: &QueryMatch, query: &Query) -> bool {
|
||||
let parent_key_ix = match query.capture_index_for_name("parent_key") {
|
||||
Some(ix) => ix,
|
||||
None => return false,
|
||||
};
|
||||
let parent_range = match mat.nodes_for_capture_index(parent_key_ix).next() {
|
||||
Some(node) => node.byte_range(),
|
||||
None => return false,
|
||||
};
|
||||
if contents.get(parent_range) != Some("project_panel") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let setting_name_ix = match query.capture_index_for_name("setting_name") {
|
||||
Some(ix) => ix,
|
||||
None => return false,
|
||||
};
|
||||
let setting_name_range = match mat.nodes_for_capture_index(setting_name_ix).next() {
|
||||
Some(node) => node.byte_range(),
|
||||
None => return false,
|
||||
};
|
||||
contents.get(setting_name_range) == Some("open_file_on_paste")
|
||||
}
|
||||
@@ -215,6 +215,10 @@ pub fn migrate_settings(text: &str) -> Result<Option<String>> {
|
||||
MigrationType::Json(migrations::m_2025_10_16::restore_code_actions_on_format),
|
||||
MigrationType::Json(migrations::m_2025_10_17::make_file_finder_include_ignored_an_enum),
|
||||
MigrationType::Json(migrations::m_2025_10_21::make_relative_line_numbers_an_enum),
|
||||
MigrationType::TreeSitter(
|
||||
migrations::m_2025_11_12::SETTINGS_PATTERNS,
|
||||
&SETTINGS_QUERY_2025_11_12,
|
||||
),
|
||||
];
|
||||
run_migrations(text, migrations)
|
||||
}
|
||||
@@ -333,6 +337,10 @@ define_query!(
|
||||
SETTINGS_QUERY_2025_10_03,
|
||||
migrations::m_2025_10_03::SETTINGS_PATTERNS
|
||||
);
|
||||
define_query!(
|
||||
SETTINGS_QUERY_2025_11_12,
|
||||
migrations::m_2025_11_12::SETTINGS_PATTERNS
|
||||
);
|
||||
|
||||
// custom query
|
||||
static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock<Query> = LazyLock::new(|| {
|
||||
@@ -2193,4 +2201,49 @@ mod tests {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_project_panel_open_file_on_paste_migration() {
|
||||
assert_migrate_settings(
|
||||
&r#"
|
||||
{
|
||||
"project_panel": {
|
||||
"open_file_on_paste": true
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"project_panel": {
|
||||
"auto_open": { "on_paste": true }
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
assert_migrate_settings(
|
||||
&r#"
|
||||
{
|
||||
"project_panel": {
|
||||
"open_file_on_paste": false
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"project_panel": {
|
||||
"auto_open": { "on_paste": false }
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
23
crates/miniprofiler_ui/Cargo.toml
Normal file
23
crates/miniprofiler_ui/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "miniprofiler_ui"
|
||||
version = "0.1.0"
|
||||
publish.workspace = true
|
||||
edition.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/miniprofiler_ui.rs"
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
zed_actions.workspace = true
|
||||
workspace.workspace = true
|
||||
util.workspace = true
|
||||
serde_json.workspace = true
|
||||
smol.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
1
crates/miniprofiler_ui/LICENSE-GPL
Symbolic link
1
crates/miniprofiler_ui/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
393
crates/miniprofiler_ui/src/miniprofiler_ui.rs
Normal file
393
crates/miniprofiler_ui/src/miniprofiler_ui.rs
Normal file
@@ -0,0 +1,393 @@
|
||||
use std::{
|
||||
ops::Range,
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, Hsla, InteractiveElement, IntoElement, ParentElement, Render,
|
||||
ScrollHandle, SerializedTaskTiming, StatefulInteractiveElement, Styled, Task, TaskTiming,
|
||||
TitlebarOptions, WindowBounds, WindowHandle, WindowOptions, div, prelude::FluentBuilder, px,
|
||||
relative, size,
|
||||
};
|
||||
use util::ResultExt;
|
||||
use workspace::{
|
||||
Workspace,
|
||||
ui::{
|
||||
ActiveTheme, Button, ButtonCommon, ButtonStyle, Checkbox, Clickable, ToggleState,
|
||||
WithScrollbar, h_flex, v_flex,
|
||||
},
|
||||
};
|
||||
use zed_actions::OpenPerformanceProfiler;
|
||||
|
||||
pub fn init(startup_time: Instant, cx: &mut App) {
|
||||
cx.observe_new(move |workspace: &mut workspace::Workspace, _, _| {
|
||||
workspace.register_action(move |workspace, _: &OpenPerformanceProfiler, window, cx| {
|
||||
let window_handle = window
|
||||
.window_handle()
|
||||
.downcast::<Workspace>()
|
||||
.expect("Workspaces are root Windows");
|
||||
open_performance_profiler(startup_time, workspace, window_handle, cx);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn open_performance_profiler(
|
||||
startup_time: Instant,
|
||||
_workspace: &mut workspace::Workspace,
|
||||
workspace_handle: WindowHandle<Workspace>,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let existing_window = cx
|
||||
.windows()
|
||||
.into_iter()
|
||||
.find_map(|window| window.downcast::<ProfilerWindow>());
|
||||
|
||||
if let Some(existing_window) = existing_window {
|
||||
existing_window
|
||||
.update(cx, |profiler_window, window, _cx| {
|
||||
profiler_window.workspace = Some(workspace_handle);
|
||||
window.activate_window();
|
||||
})
|
||||
.log_err();
|
||||
return;
|
||||
}
|
||||
|
||||
let default_bounds = size(px(1280.), px(720.)); // 16:9
|
||||
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some("Profiler Window".into()),
|
||||
appears_transparent: false,
|
||||
traffic_light_position: None,
|
||||
}),
|
||||
focus: true,
|
||||
show: true,
|
||||
is_movable: true,
|
||||
kind: gpui::WindowKind::Normal,
|
||||
window_background: cx.theme().window_background_appearance(),
|
||||
window_decorations: None,
|
||||
window_min_size: Some(default_bounds),
|
||||
window_bounds: Some(WindowBounds::centered(default_bounds, cx)),
|
||||
..Default::default()
|
||||
},
|
||||
|_window, cx| ProfilerWindow::new(startup_time, Some(workspace_handle), cx),
|
||||
)
|
||||
.log_err();
|
||||
}
|
||||
|
||||
enum DataMode {
|
||||
Realtime(Option<Vec<TaskTiming>>),
|
||||
Snapshot(Vec<TaskTiming>),
|
||||
}
|
||||
|
||||
struct TimingBar {
|
||||
location: &'static core::panic::Location<'static>,
|
||||
start: Instant,
|
||||
end: Instant,
|
||||
color: Hsla,
|
||||
}
|
||||
|
||||
pub struct ProfilerWindow {
|
||||
startup_time: Instant,
|
||||
data: DataMode,
|
||||
include_self_timings: ToggleState,
|
||||
autoscroll: bool,
|
||||
scroll_handle: ScrollHandle,
|
||||
workspace: Option<WindowHandle<Workspace>>,
|
||||
_refresh: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl ProfilerWindow {
|
||||
pub fn new(
|
||||
startup_time: Instant,
|
||||
workspace_handle: Option<WindowHandle<Workspace>>,
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let entity = cx.new(|cx| ProfilerWindow {
|
||||
startup_time,
|
||||
data: DataMode::Realtime(None),
|
||||
include_self_timings: ToggleState::Unselected,
|
||||
autoscroll: true,
|
||||
scroll_handle: ScrollHandle::new(),
|
||||
workspace: workspace_handle,
|
||||
_refresh: Some(Self::begin_listen(cx)),
|
||||
});
|
||||
|
||||
entity
|
||||
}
|
||||
|
||||
fn begin_listen(cx: &mut Context<Self>) -> Task<()> {
|
||||
cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
let data = cx
|
||||
.foreground_executor()
|
||||
.dispatcher
|
||||
.get_current_thread_timings();
|
||||
|
||||
this.update(cx, |this: &mut ProfilerWindow, cx| {
|
||||
let scroll_offset = this.scroll_handle.offset();
|
||||
let max_offset = this.scroll_handle.max_offset();
|
||||
this.autoscroll = -scroll_offset.y >= (max_offset.height - px(5.0));
|
||||
|
||||
this.data = DataMode::Realtime(Some(data));
|
||||
|
||||
if this.autoscroll {
|
||||
this.scroll_handle.scroll_to_bottom();
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
|
||||
// yield to the executor
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_micros(1))
|
||||
.await;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn get_timings(&self) -> Option<&Vec<TaskTiming>> {
|
||||
match &self.data {
|
||||
DataMode::Realtime(data) => data.as_ref(),
|
||||
DataMode::Snapshot(data) => Some(data),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_timing(
|
||||
&self,
|
||||
value_range: Range<Instant>,
|
||||
item: TimingBar,
|
||||
cx: &App,
|
||||
) -> impl IntoElement {
|
||||
let time_ms = item.end.duration_since(item.start).as_secs_f32() * 1000f32;
|
||||
|
||||
let remap = value_range
|
||||
.end
|
||||
.duration_since(value_range.start)
|
||||
.as_secs_f32()
|
||||
* 1000f32;
|
||||
|
||||
let start = (item.start.duration_since(value_range.start).as_secs_f32() * 1000f32) / remap;
|
||||
let end = (item.end.duration_since(value_range.start).as_secs_f32() * 1000f32) / remap;
|
||||
|
||||
let bar_width = end - start.abs();
|
||||
|
||||
let location = item
|
||||
.location
|
||||
.file()
|
||||
.rsplit_once("/")
|
||||
.unwrap_or(("", item.location.file()))
|
||||
.1;
|
||||
let location = location.rsplit_once("\\").unwrap_or(("", location)).1;
|
||||
|
||||
let label = format!(
|
||||
"{}:{}:{}",
|
||||
location,
|
||||
item.location.line(),
|
||||
item.location.column()
|
||||
);
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.h(px(32.0))
|
||||
.child(
|
||||
div()
|
||||
.w(px(200.0))
|
||||
.flex_shrink_0()
|
||||
.overflow_hidden()
|
||||
.child(div().text_ellipsis().child(label)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.h(px(24.0))
|
||||
.bg(cx.theme().colors().background)
|
||||
.rounded_md()
|
||||
.p(px(2.0))
|
||||
.relative()
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.h_full()
|
||||
.rounded_sm()
|
||||
.bg(item.color)
|
||||
.left(relative(start.max(0f32)))
|
||||
.w(relative(bar_width)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w(px(60.0))
|
||||
.flex_shrink_0()
|
||||
.text_right()
|
||||
.child(format!("{:.1}ms", time_ms)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ProfilerWindow {
|
||||
fn render(
|
||||
&mut self,
|
||||
window: &mut gpui::Window,
|
||||
cx: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
v_flex()
|
||||
.id("profiler")
|
||||
.w_full()
|
||||
.h_full()
|
||||
.gap_2()
|
||||
.bg(cx.theme().colors().surface_background)
|
||||
.text_color(cx.theme().colors().text)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new(
|
||||
"switch-mode",
|
||||
match self.data {
|
||||
DataMode::Snapshot { .. } => "Resume",
|
||||
DataMode::Realtime(_) => "Pause",
|
||||
},
|
||||
)
|
||||
.style(ButtonStyle::Filled)
|
||||
.on_click(cx.listener(
|
||||
|this, _, _window, cx| {
|
||||
match &this.data {
|
||||
DataMode::Realtime(Some(data)) => {
|
||||
this._refresh = None;
|
||||
this.data = DataMode::Snapshot(data.clone());
|
||||
}
|
||||
DataMode::Snapshot { .. } => {
|
||||
this._refresh = Some(Self::begin_listen(cx));
|
||||
this.data = DataMode::Realtime(None);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Button::new("export-data", "Save")
|
||||
.style(ButtonStyle::Filled)
|
||||
.on_click(cx.listener(|this, _, _window, cx| {
|
||||
let Some(workspace) = this.workspace else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(data) = this.get_timings() else {
|
||||
return;
|
||||
};
|
||||
let timings =
|
||||
SerializedTaskTiming::convert(this.startup_time, &data);
|
||||
|
||||
let active_path = workspace
|
||||
.read_with(cx, |workspace, cx| {
|
||||
workspace.most_recent_active_path(cx)
|
||||
})
|
||||
.log_err()
|
||||
.flatten()
|
||||
.and_then(|p| p.parent().map(|p| p.to_owned()))
|
||||
.unwrap_or_else(|| PathBuf::default());
|
||||
|
||||
let path = cx.prompt_for_new_path(
|
||||
&active_path,
|
||||
Some("performance_profile.miniprof"),
|
||||
);
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let path = path.await;
|
||||
let path =
|
||||
path.log_err().and_then(|p| p.log_err()).flatten();
|
||||
|
||||
let Some(path) = path else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(timings) =
|
||||
serde_json::to_string(&timings).log_err()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
smol::fs::write(path, &timings).await.log_err();
|
||||
})
|
||||
.detach();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Checkbox::new("include-self", self.include_self_timings)
|
||||
.label("Include profiler timings")
|
||||
.on_click(cx.listener(|this, checked, _window, cx| {
|
||||
this.include_self_timings = *checked;
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.when_some(self.get_timings(), |div, e| {
|
||||
if e.len() == 0 {
|
||||
return div;
|
||||
}
|
||||
|
||||
let min = e[0].start;
|
||||
let max = e[e.len() - 1].end.unwrap_or_else(|| Instant::now());
|
||||
div.child(
|
||||
v_flex()
|
||||
.id("timings.bars")
|
||||
.overflow_scroll()
|
||||
.w_full()
|
||||
.h_full()
|
||||
.gap_2()
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.on_scroll_wheel(cx.listener(|this, _, _, _cx| {
|
||||
let scroll_offset = this.scroll_handle.offset();
|
||||
let max_offset = this.scroll_handle.max_offset();
|
||||
this.autoscroll = -scroll_offset.y >= (max_offset.height - px(5.0));
|
||||
}))
|
||||
.children(
|
||||
e.iter()
|
||||
.filter(|timing| {
|
||||
timing
|
||||
.end
|
||||
.unwrap_or_else(|| Instant::now())
|
||||
.duration_since(timing.start)
|
||||
.as_millis()
|
||||
>= 1
|
||||
})
|
||||
.filter(|timing| {
|
||||
if self.include_self_timings.selected() {
|
||||
true
|
||||
} else {
|
||||
!timing.location.file().ends_with("miniprofiler_ui.rs")
|
||||
}
|
||||
})
|
||||
.enumerate()
|
||||
.map(|(i, timing)| {
|
||||
self.render_timing(
|
||||
max.checked_sub(Duration::from_secs(10)).unwrap_or(min)
|
||||
..max,
|
||||
TimingBar {
|
||||
location: timing.location,
|
||||
start: timing.start,
|
||||
end: timing.end.unwrap_or_else(|| Instant::now()),
|
||||
color: cx.theme().accents().color_for_index(i as u32),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.vertical_scrollbar_for(self.scroll_handle.clone(), window, cx)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -102,9 +102,11 @@ pub enum ChatMessage {
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OllamaToolCall {
|
||||
Function(OllamaFunctionCall),
|
||||
pub struct OllamaToolCall {
|
||||
// TODO: Remove `Option` after most users have updated to Ollama v0.12.10,
|
||||
// which was released on the 4th of November 2025
|
||||
pub id: Option<String>,
|
||||
pub function: OllamaFunctionCall,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
@@ -444,6 +446,7 @@ mod tests {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_llama3.2:3b_145155",
|
||||
"function": {
|
||||
"name": "weather",
|
||||
"arguments": {
|
||||
@@ -479,6 +482,56 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// Backwards compatibility with Ollama versions prior to v0.12.10 November 2025
|
||||
// This test is a copy of `parse_tool_call()` with the `id` field omitted.
|
||||
#[test]
|
||||
fn parse_tool_call_pre_0_12_10() {
|
||||
let response = serde_json::json!({
|
||||
"model": "llama3.2:3b",
|
||||
"created_at": "2025-04-28T20:02:02.140489Z",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"name": "weather",
|
||||
"arguments": {
|
||||
"city": "london",
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"done_reason": "stop",
|
||||
"done": true,
|
||||
"total_duration": 2758629166u64,
|
||||
"load_duration": 1770059875,
|
||||
"prompt_eval_count": 147,
|
||||
"prompt_eval_duration": 684637583,
|
||||
"eval_count": 16,
|
||||
"eval_duration": 302561917,
|
||||
});
|
||||
|
||||
let result: ChatResponseDelta = serde_json::from_value(response).unwrap();
|
||||
match result.message {
|
||||
ChatMessage::Assistant {
|
||||
content,
|
||||
tool_calls: Some(tool_calls),
|
||||
images: _,
|
||||
thinking,
|
||||
} => {
|
||||
assert!(content.is_empty());
|
||||
assert!(thinking.is_none());
|
||||
|
||||
// When the `Option` around `id` is removed, this test should complain
|
||||
// and be subsequently deleted in favor of `parse_tool_call()`
|
||||
assert!(tool_calls.first().is_some_and(|call| call.id.is_none()))
|
||||
}
|
||||
_ => panic!("Deserialized wrong role"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_show_model() {
|
||||
let response = serde_json::json!({
|
||||
|
||||
@@ -6619,13 +6619,11 @@ outline: struct OutlineEntryExcerpt
|
||||
format!(
|
||||
r#"frontend-project/
|
||||
public/lottie/
|
||||
syntax-tree.json
|
||||
search: {{ "something": "«static»" }}
|
||||
syntax-tree.json <==== selected
|
||||
src/
|
||||
app/(site)/
|
||||
components/
|
||||
ErrorBoundary.tsx <==== selected
|
||||
search: «static»"#
|
||||
ErrorBoundary.tsx"#
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -6667,7 +6665,7 @@ outline: struct OutlineEntryExcerpt
|
||||
format!(
|
||||
r#"frontend-project/
|
||||
public/lottie/
|
||||
syntax-tree.json
|
||||
syntax-tree.json <==== selected
|
||||
search: {{ "something": "«static»" }}
|
||||
src/
|
||||
app/(site)/
|
||||
@@ -6678,7 +6676,7 @@ outline: struct OutlineEntryExcerpt
|
||||
page.tsx
|
||||
search: «static»
|
||||
components/
|
||||
ErrorBoundary.tsx <==== selected
|
||||
ErrorBoundary.tsx
|
||||
search: «static»"#
|
||||
)
|
||||
);
|
||||
|
||||
@@ -155,6 +155,12 @@ pub fn temp_dir() -> &'static PathBuf {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the hang traces directory.
|
||||
pub fn hang_traces_dir() -> &'static PathBuf {
|
||||
static LOGS_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
LOGS_DIR.get_or_init(|| data_dir().join("hang_traces"))
|
||||
}
|
||||
|
||||
/// Returns the path to the logs directory.
|
||||
pub fn logs_dir() -> &'static PathBuf {
|
||||
static LOGS_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
@@ -709,7 +709,7 @@ impl<D: PickerDelegate> Picker<D> {
|
||||
match &mut self.element_container {
|
||||
ElementContainer::List(state) => state.scroll_to_reveal_item(ix),
|
||||
ElementContainer::UniformList(scroll_handle) => {
|
||||
scroll_handle.scroll_to_item(ix, ScrollStrategy::Top)
|
||||
scroll_handle.scroll_to_item(ix, ScrollStrategy::Nearest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ test-support = [
|
||||
"client/test-support",
|
||||
"language/test-support",
|
||||
"settings/test-support",
|
||||
"snippet_provider/test-support",
|
||||
"text/test-support",
|
||||
"prettier/test-support",
|
||||
"worktree/test-support",
|
||||
@@ -107,6 +108,7 @@ pretty_assertions.workspace = true
|
||||
release_channel.workspace = true
|
||||
rpc = { workspace = true, features = ["test-support"] }
|
||||
settings = { workspace = true, features = ["test-support"] }
|
||||
snippet_provider = { workspace = true, features = ["test-support"] }
|
||||
unindent.workspace = true
|
||||
util = { workspace = true, features = ["test-support"] }
|
||||
worktree = { workspace = true, features = ["test-support"] }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user