Compare commits
61 Commits
nightly-tr
...
chat-or-ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e926b7d9 | ||
|
|
3bac98a056 | ||
|
|
3c32f01a8f | ||
|
|
9d61cd5120 | ||
|
|
411f64b374 | ||
|
|
4ae2f93086 | ||
|
|
65fb2782eb | ||
|
|
e6b9a8ef9b | ||
|
|
398d0396b6 | ||
|
|
e9e4c770ca | ||
|
|
4be9da2641 | ||
|
|
c186e99a3d | ||
|
|
4df882c295 | ||
|
|
17f2929b4c | ||
|
|
5ad392035e | ||
|
|
8c910540ed | ||
|
|
455f241c6a | ||
|
|
498ecd6404 | ||
|
|
3216de7eb5 | ||
|
|
57369b5a54 | ||
|
|
f9d4272e13 | ||
|
|
378a2cf9d8 | ||
|
|
f1d01d59ac | ||
|
|
78093b8e76 | ||
|
|
a41e973782 | ||
|
|
9a3d8733ce | ||
|
|
c888101e4b | ||
|
|
0c04fb9862 | ||
|
|
f6fad3b09e | ||
|
|
6614feff97 | ||
|
|
08b1545c85 | ||
|
|
fedd177b08 | ||
|
|
4288096ca1 | ||
|
|
256c31a5d9 | ||
|
|
c8b6ad9666 | ||
|
|
0e22c9f275 | ||
|
|
56f69be2e7 | ||
|
|
02f63e49ed | ||
|
|
3dcc638537 | ||
|
|
d35b646dbb | ||
|
|
338bf3fd28 | ||
|
|
879a2ea06f | ||
|
|
7a5003bea2 | ||
|
|
f8f3f369f6 | ||
|
|
474e670bbd | ||
|
|
fe0bcc063c | ||
|
|
69abe71bf7 | ||
|
|
9c3d80d6e8 | ||
|
|
834d50f0db | ||
|
|
bcdb10b3cb | ||
|
|
598939d186 | ||
|
|
9d944d0662 | ||
|
|
7d2628e805 | ||
|
|
84df3a0cad | ||
|
|
eb76065ad3 | ||
|
|
84018d7a2d | ||
|
|
57c55b32e1 | ||
|
|
a4357c429a | ||
|
|
103665ee28 | ||
|
|
2f960c4aba | ||
|
|
109ebc5f27 |
6
.github/actions/check_style/action.yml
vendored
6
.github/actions/check_style/action.yml
vendored
@@ -7,9 +7,3 @@ runs:
|
||||
- name: cargo fmt
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Find modified migrations
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: |
|
||||
export SQUAWK_GITHUB_TOKEN=${{ github.token }}
|
||||
. ./script/squawk
|
||||
|
||||
12
.github/pull_request_template.md
vendored
12
.github/pull_request_template.md
vendored
@@ -2,14 +2,4 @@ Closes #ISSUE
|
||||
|
||||
Release Notes:
|
||||
|
||||
- Added/Fixed/Improved ...
|
||||
|
||||
Optionally, include screenshots / media showcasing your addition that can be included in the release notes.
|
||||
|
||||
### Or...
|
||||
|
||||
Closes #ISSUE
|
||||
|
||||
Release Notes:
|
||||
|
||||
- N/A
|
||||
- N/A *or* Added/Fixed/Improved ...
|
||||
|
||||
52
.github/workflows/ci.yml
vendored
52
.github/workflows/ci.yml
vendored
@@ -26,9 +26,10 @@ env:
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
style:
|
||||
migration_checks:
|
||||
name: Check Postgres and Protobuf migrations, mergability
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
timeout-minutes: 60
|
||||
name: Check formatting and spelling
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- test
|
||||
@@ -37,25 +38,16 @@ jobs:
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
||||
with:
|
||||
clean: false
|
||||
fetch-depth: 0
|
||||
fetch-depth: 0 # fetch full history
|
||||
|
||||
- name: Remove untracked files
|
||||
run: git clean -df
|
||||
|
||||
- name: Check spelling
|
||||
run: script/check-spelling
|
||||
|
||||
- name: Run style checks
|
||||
uses: ./.github/actions/check_style
|
||||
|
||||
- name: Check unused dependencies
|
||||
uses: bnjbvr/cargo-machete@main
|
||||
|
||||
- name: Check licenses are present
|
||||
run: script/check-licenses
|
||||
|
||||
- name: Check license generation
|
||||
run: script/generate-licenses /tmp/zed_licenses_output
|
||||
- name: Find modified migrations
|
||||
shell: bash -euxo pipefail {0}
|
||||
run: |
|
||||
export SQUAWK_GITHUB_TOKEN=${{ github.token }}
|
||||
. ./script/squawk
|
||||
|
||||
- name: Ensure fresh merge
|
||||
shell: bash -euxo pipefail {0}
|
||||
@@ -77,6 +69,24 @@ jobs:
|
||||
input: "crates/proto/proto/"
|
||||
against: "https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/proto/proto/"
|
||||
|
||||
style:
|
||||
timeout-minutes: 60
|
||||
name: Check formatting and spelling
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on:
|
||||
- buildjet-8vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4
|
||||
|
||||
- name: Run style checks
|
||||
uses: ./.github/actions/check_style
|
||||
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@v1.24.6
|
||||
with:
|
||||
config: ./typos.toml
|
||||
|
||||
macos_tests:
|
||||
timeout-minutes: 60
|
||||
name: (macOS) Run Clippy and tests
|
||||
@@ -92,6 +102,14 @@ jobs:
|
||||
- name: cargo clippy
|
||||
run: ./script/clippy
|
||||
|
||||
- name: Check unused dependencies
|
||||
uses: bnjbvr/cargo-machete@main
|
||||
|
||||
- name: Check licenses
|
||||
run: |
|
||||
script/check-licenses
|
||||
script/generate-licenses /tmp/zed_licenses_output
|
||||
|
||||
- name: Run tests
|
||||
uses: ./.github/actions/run_tests
|
||||
|
||||
|
||||
1
.github/workflows/deploy_cloudflare.yml
vendored
1
.github/workflows/deploy_cloudflare.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
jobs:
|
||||
deploy-docs:
|
||||
name: Deploy Docs
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
8
.github/workflows/docs.yml
vendored
8
.github/workflows/docs.yml
vendored
@@ -11,6 +11,7 @@ on:
|
||||
jobs:
|
||||
check_formatting:
|
||||
name: "Check formatting"
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -29,5 +30,8 @@ jobs:
|
||||
false
|
||||
}
|
||||
|
||||
- name: Check spelling
|
||||
run: script/check-spelling docs/
|
||||
- name: Check for Typos with Typos-CLI
|
||||
uses: crate-ci/typos@v1.24.6
|
||||
with:
|
||||
config: ./typos.toml
|
||||
files: ./docs/
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
uses: astral-sh/setup-uv@f3bcaebff5eace81a1c062af9f9011aae482ca9d # v3
|
||||
with:
|
||||
version: "latest"
|
||||
enable-cache: true
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
uses: astral-sh/setup-uv@f3bcaebff5eace81a1c062af9f9011aae482ca9d # v3
|
||||
with:
|
||||
version: "latest"
|
||||
enable-cache: true
|
||||
|
||||
28
Cargo.lock
generated
28
Cargo.lock
generated
@@ -412,6 +412,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"paths",
|
||||
"picker",
|
||||
"pretty_assertions",
|
||||
"project",
|
||||
"proto",
|
||||
"rand 0.8.5",
|
||||
@@ -8472,6 +8473,7 @@ dependencies = [
|
||||
"terminal",
|
||||
"text",
|
||||
"unindent",
|
||||
"url",
|
||||
"util",
|
||||
"which 6.0.3",
|
||||
"windows 0.58.0",
|
||||
@@ -8973,6 +8975,7 @@ dependencies = [
|
||||
"futures 0.3.30",
|
||||
"fuzzy",
|
||||
"gpui",
|
||||
"itertools 0.13.0",
|
||||
"language",
|
||||
"log",
|
||||
"menu",
|
||||
@@ -9126,6 +9129,7 @@ dependencies = [
|
||||
"rpc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"smol",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
@@ -9146,6 +9150,8 @@ dependencies = [
|
||||
"env_logger",
|
||||
"fs",
|
||||
"futures 0.3.30",
|
||||
"git",
|
||||
"git_hosting_providers",
|
||||
"gpui",
|
||||
"http_client",
|
||||
"language",
|
||||
@@ -12285,9 +12291,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-c"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e795ad541f7ae6a80d22975296340a75a12a29afd3a7089f4368021613728e17"
|
||||
checksum = "c8b3fb515e498e258799a31d78e6603767cd6892770d9e2290ec00af5c3ad80b"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
@@ -12295,9 +12301,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-cpp"
|
||||
version = "0.23.0"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0a588a816017469b69f2e3544742e34a5a59dddfb4b9457b657a6052e2ea39c"
|
||||
checksum = "1d67e862242878d6ee50e1e5814f267ee3eea0168aea2cdbd700ccfb4c74b6d3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
@@ -12325,9 +12331,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-elixir"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6174acad8a059851f6f768d7893f4b25eedc80eb6643283d545dd71bbb38222a"
|
||||
checksum = "97bf0efa4be41120018f23305b105ad4dfd3be1b7f302dc4071d0e6c2dec3a32"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
@@ -14577,7 +14583,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "0.158.0"
|
||||
version = "0.159.0"
|
||||
dependencies = [
|
||||
"activity_indicator",
|
||||
"anyhow",
|
||||
@@ -14698,7 +14704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed_astro"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"zed_extension_api 0.1.0",
|
||||
@@ -14734,7 +14740,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed_elixir"
|
||||
version = "0.0.9"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"zed_extension_api 0.1.0",
|
||||
]
|
||||
@@ -14796,7 +14802,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed_html"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"zed_extension_api 0.1.0",
|
||||
]
|
||||
@@ -14903,7 +14909,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed_zig"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"zed_extension_api 0.1.0",
|
||||
]
|
||||
|
||||
1
assets/icons/diff.svg
Normal file
1
assets/icons/diff.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-diff"><path d="M12 3v14"/><path d="M5 10h14"/><path d="M5 21h14"/></svg>
|
||||
|
After Width: | Height: | Size: 275 B |
@@ -1,85 +1,33 @@
|
||||
<task_description>
|
||||
|
||||
# Code Change Workflow
|
||||
The user of a code editor wants to make a change to their codebase.
|
||||
You must describe the change using the following XML structure:
|
||||
|
||||
Your task is to guide the user through code changes using a series of steps. Each step should describe a high-level change, which can consist of multiple edits to distinct locations in the codebase.
|
||||
|
||||
## Output Example
|
||||
|
||||
Provide output as XML, with the following format:
|
||||
|
||||
<step>
|
||||
Update the Person struct to store an age
|
||||
|
||||
```rust
|
||||
struct Person {
|
||||
// existing fields...
|
||||
age: u8,
|
||||
height: f32,
|
||||
// existing fields...
|
||||
}
|
||||
|
||||
impl Person {
|
||||
fn age(&self) -> u8 {
|
||||
self.age
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<edit>
|
||||
<path>src/person.rs</path>
|
||||
<operation>insert_before</operation>
|
||||
<search>height: f32,</search>
|
||||
<description>Add the age field</description>
|
||||
</edit>
|
||||
|
||||
<edit>
|
||||
<path>src/person.rs</path>
|
||||
<operation>insert_after</operation>
|
||||
<search>impl Person {</search>
|
||||
<description>Add the age getter</description>
|
||||
</edit>
|
||||
</step>
|
||||
|
||||
## Output Format
|
||||
|
||||
First, each `<step>` must contain a written description of the change that should be made. The description should begin with a high-level overview, and can contain markdown code blocks as well. The description should be self-contained and actionable.
|
||||
|
||||
After the description, each `<step>` must contain one or more `<edit>` tags, each of which refer to a specific range in a source file. Each `<edit>` tag must contain the following child tags:
|
||||
|
||||
### `<path>` (required)
|
||||
|
||||
This tag contains the path to the file that will be changed. It can be an existing path, or a path that should be created.
|
||||
|
||||
### `<search>` (optional)
|
||||
|
||||
This tag contains a search string to locate in the source file, e.g. `pub fn baz() {`. If not provided, the new content will be inserted at the top of the file. Make sure to produce a string that exists in the source file and that isn't ambiguous. When there's ambiguity, add more lines to the search to eliminate it.
|
||||
|
||||
### `<description>` (required)
|
||||
|
||||
This tag contains a single-line description of the edit that should be made at the given location.
|
||||
|
||||
### `<operation>` (required)
|
||||
|
||||
This tag indicates what type of change should be made, relative to the given location. It can be one of the following:
|
||||
- `update`: Rewrites the specified string entirely based on the given description.
|
||||
- `create`: Creates a new file with the given path based on the provided description.
|
||||
- `insert_before`: Inserts new text based on the given description before the specified search string.
|
||||
- `insert_after`: Inserts new text based on the given description after the specified search string.
|
||||
- `delete`: Deletes the specified string from the containing file.
|
||||
- <patch> - A group of related code changes.
|
||||
Child tags:
|
||||
- <title> (required) - A high-level description of the changes. This should be as short
|
||||
as possible, possibly using common abbreviations.
|
||||
- <edit> (1 or more) - An edit to make at a particular range within a file.
|
||||
Includes the following child tags:
|
||||
- <path> (required) - The path to the file that will be changed.
|
||||
- <description> (optional) - An arbitrarily-long comment that describes the purpose
|
||||
of this edit.
|
||||
- <old_text> (optional) - An excerpt from the file's current contents that uniquely
|
||||
identifies a range within the file where the edit should occur. If this tag is not
|
||||
specified, then the entire file will be used as the range.
|
||||
- <new_text> (required) - The new text to insert into the file.
|
||||
- <operation> (required) - The type of change that should occur at the given range
|
||||
of the file. Must be one of the following values:
|
||||
- `update`: Replaces the entire range with the new text.
|
||||
- `insert_before`: Inserts the new text before the range.
|
||||
- `insert_after`: Inserts new text after the range.
|
||||
- `create`: Creates a new file with the given path and the new text.
|
||||
- `delete`: Deletes the specified range from the file.
|
||||
|
||||
<guidelines>
|
||||
- There's no need to describe *what* to do, just *where* to do it.
|
||||
- Only reference locations that actually exist (unless you're creating a file).
|
||||
- If creating a file, assume any subsequent updates are included at the time of creation.
|
||||
- Don't create and then update a file. Always create new files in one hot.
|
||||
- Prefer multiple edits to smaller regions, as opposed to one big edit to a larger region.
|
||||
- Don't produce edits that intersect each other. In that case, merge them into a bigger edit.
|
||||
- Never nest an edit with another edit. Never include CDATA. All edits are leaf nodes.
|
||||
- Descriptions are required for all edits except delete.
|
||||
- When generating multiple edits, ensure the descriptions are specific to each individual operation.
|
||||
- Avoid referring to the search string in the description. Focus on the change to be made, not the location where it's made. That's implicit with the `search` string you provide.
|
||||
- Don't generate multiple edits at the same location. Instead, combine them together in a single edit with a succinct combined description.
|
||||
- Never provide multiple edits whose ranges intersect each other. Instead, merge them into one edit.
|
||||
- Prefer multiple edits to smaller, disjoint ranges, rather than one edit to a larger range.
|
||||
- There's no need to escape angle brackets within XML tags.
|
||||
- Always ensure imports are added if you're referencing symbols that are not in scope.
|
||||
</guidelines>
|
||||
|
||||
@@ -124,189 +72,137 @@ Update all shapes to store their origin as an (x, y) tuple and implement Display
|
||||
<message role="assistant">
|
||||
We'll need to update both the rectangle and circle modules.
|
||||
|
||||
<step>
|
||||
Add origin fields to both shape types.
|
||||
|
||||
```rust
|
||||
struct Rectangle {
|
||||
// existing fields ...
|
||||
origin: (f64, f64),
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
struct Circle {
|
||||
// existing fields ...
|
||||
origin: (f64, f64),
|
||||
}
|
||||
```
|
||||
<patch>
|
||||
<title>Add origins and display impls to shapes</title>
|
||||
<edit>
|
||||
<path>src/shapes/rectangle.rs</path>
|
||||
<description>Add the origin field to Rectangle struct</description>
|
||||
<operation>insert_after</operation>
|
||||
<old_text>
|
||||
pub struct Rectangle {
|
||||
</old_text>
|
||||
<new_text>
|
||||
origin: (f64, f64),
|
||||
</new_text>
|
||||
</edit>
|
||||
|
||||
<edit>
|
||||
<path>src/shapes/rectangle.rs</path>
|
||||
<operation>insert_before</operation>
|
||||
<search>
|
||||
width: f64,
|
||||
height: f64,
|
||||
</search>
|
||||
<description>Add the origin field to Rectangle</description>
|
||||
<description>Update the Rectangle's new function to take an origin parameter</description>
|
||||
<operation>update</operation>
|
||||
<old_text>
|
||||
fn new(width: f64, height: f64) -> Self {
|
||||
Rectangle { width, height }
|
||||
}
|
||||
</old_text>
|
||||
<new_text>
|
||||
fn new(origin: (f64, f64), width: f64, height: f64) -> Self {
|
||||
Rectangle { origin, width, height }
|
||||
}
|
||||
</new_text>
|
||||
</edit>
|
||||
|
||||
<edit>
|
||||
<path>src/shapes/circle.rs</path>
|
||||
<operation>insert_before</operation>
|
||||
<search>
|
||||
<description>Add the origin field to Circle struct</description>
|
||||
<operation>insert_after</operation>
|
||||
<old_text>
|
||||
pub struct Circle {
|
||||
radius: f64,
|
||||
</search>
|
||||
<description>Add the origin field to Circle</description>
|
||||
</edit>
|
||||
|
||||
<step>
|
||||
Update both shape's constructors to take an origin.
|
||||
|
||||
<edit>
|
||||
<path>src/shapes/rectangle.rs</path>
|
||||
<operation>update</operation>
|
||||
<search>
|
||||
fn new(width: f64, height: f64) -> Self {
|
||||
Rectangle { width, height }
|
||||
}
|
||||
</search>
|
||||
<description>Update the Rectangle new function to take an origin</description>
|
||||
</old_text>
|
||||
<new_text>
|
||||
origin: (f64, f64),
|
||||
</new_text>
|
||||
</edit>
|
||||
|
||||
<edit>
|
||||
<path>src/shapes/circle.rs</path>
|
||||
<description>Update the Circle's new function to take an origin parameter</description>
|
||||
<operation>update</operation>
|
||||
<search>
|
||||
fn new(radius: f64) -> Self {
|
||||
Circle { radius }
|
||||
}
|
||||
</search>
|
||||
<description>Update the Circle new function to take an origin</description>
|
||||
<old_text>
|
||||
fn new(radius: f64) -> Self {
|
||||
Circle { radius }
|
||||
}
|
||||
</old_text>
|
||||
<new_text>
|
||||
fn new(origin: (f64, f64), radius: f64) -> Self {
|
||||
Circle { origin, radius }
|
||||
}
|
||||
</new_text>
|
||||
</edit>
|
||||
</step>
|
||||
|
||||
<step>
|
||||
Implement Display for both shapes
|
||||
|
||||
<edit>
|
||||
<path>src/shapes/rectangle.rs</path>
|
||||
<description>Add an import for the std::fmt module</description>
|
||||
<operation>insert_before</operation>
|
||||
<search>
|
||||
<old_text>
|
||||
struct Rectangle {
|
||||
</search>
|
||||
<description>Add an import for the `std::fmt` module</description>
|
||||
</old_text>
|
||||
<new_text>
|
||||
use std::fmt;
|
||||
|
||||
</new_text>
|
||||
</edit>
|
||||
|
||||
<edit>
|
||||
<path>src/shapes/rectangle.rs</path>
|
||||
<description>Add a Display implementation for Rectangle</description>
|
||||
<operation>insert_after</operation>
|
||||
<search>
|
||||
<old_text>
|
||||
Rectangle { width, height }
|
||||
}
|
||||
}
|
||||
</search>
|
||||
<description>Add a Display implementation for Rectangle</description>
|
||||
</old_text>
|
||||
<new_text>
|
||||
impl fmt::Display for Rectangle {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.format_struct(f, "Rectangle")
|
||||
.field("origin", &self.origin)
|
||||
.field("width", &self.width)
|
||||
.field("height", &self.height)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
</new_text>
|
||||
</edit>
|
||||
|
||||
<edit>
|
||||
<path>src/shapes/circle.rs</path>
|
||||
<operation>insert_before</operation>
|
||||
<search>
|
||||
struct Circle {
|
||||
</search>
|
||||
<description>Add an import for the `std::fmt` module</description>
|
||||
<operation>insert_before</operation>
|
||||
<old_text>
|
||||
struct Circle {
|
||||
</old_text>
|
||||
<new_text>
|
||||
use std::fmt;
|
||||
</new_text>
|
||||
</edit>
|
||||
|
||||
<edit>
|
||||
<path>src/shapes/circle.rs</path>
|
||||
<description>Add a Display implementation for Circle</description>
|
||||
<operation>insert_after</operation>
|
||||
<search>
|
||||
<old_text>
|
||||
Circle { radius }
|
||||
}
|
||||
}
|
||||
</search>
|
||||
<description>Add a Display implementation for Circle</description>
|
||||
</old_text>
|
||||
<new_text>
|
||||
impl fmt::Display for Rectangle {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.format_struct(f, "Rectangle")
|
||||
.field("origin", &self.origin)
|
||||
.field("width", &self.width)
|
||||
.field("height", &self.height)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
</new_text>
|
||||
</edit>
|
||||
</step>
|
||||
</patch>
|
||||
|
||||
</message>
|
||||
</example>
|
||||
|
||||
<example number=2>
|
||||
<message role="user">
|
||||
|
||||
```rs src/user.rs
|
||||
struct User {
|
||||
pub name: String,
|
||||
age: u32,
|
||||
email: String,
|
||||
}
|
||||
|
||||
impl User {
|
||||
fn new(name: String, age: u32, email: String) -> Self {
|
||||
User { name, age, email }
|
||||
}
|
||||
|
||||
pub fn print_info(&self) {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Let's print all the user information and delete the email field.
|
||||
</message>
|
||||
|
||||
<message role="assistant">
|
||||
<step>
|
||||
Update the 'print_info' method to print user information
|
||||
|
||||
```rust
|
||||
impl User {
|
||||
// ... other methods ...
|
||||
|
||||
pub fn print_info(&self) {
|
||||
println!("Name: {name}, Age: {age}", name = self.name, age = self.age);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<edit>
|
||||
<path>src/user.rs</path>
|
||||
<operation>update</operation>
|
||||
<search>
|
||||
pub fn print_info(&self) {
|
||||
todo!()
|
||||
}
|
||||
</search>
|
||||
<description>Print all the user information</description>
|
||||
</edit>
|
||||
</step>
|
||||
|
||||
<step>
|
||||
Remove the 'email' field from the User struct
|
||||
|
||||
<edit>
|
||||
<path>src/user.rs</path>
|
||||
<operation>delete</operation>
|
||||
<search>
|
||||
email: String,
|
||||
</search>
|
||||
</edit>
|
||||
|
||||
<edit>
|
||||
<path>src/user.rs</path>
|
||||
<operation>update</operation>
|
||||
<symbol>
|
||||
fn new(name: String, age: u32, email: String) -> Self {
|
||||
User { name, age, email }
|
||||
}
|
||||
</symbol>
|
||||
<description>Remove email parameter from new method</description>
|
||||
</edit>
|
||||
</step>
|
||||
</message>
|
||||
</example>
|
||||
|
||||
You should think step by step. When possible, produce smaller, coherent logical steps as opposed to one big step that combines lots of heterogeneous edits.
|
||||
|
||||
</task_description>
|
||||
|
||||
@@ -1,496 +0,0 @@
|
||||
<overview>
|
||||
Your task is to map a step from a workflow to locations in source code where code needs to be changed to fulfill that step.
|
||||
Given a workflow containing background context plus a series of <step> tags, you will resolve *one* of these step tags to resolve to one or more locations in the code.
|
||||
With each location, you will produce a brief, one-line description of the changes to be made.
|
||||
|
||||
<guidelines>
|
||||
- There's no need to describe *what* to do, just *where* to do it.
|
||||
- Only reference locations that actually exist (unless you're creating a file).
|
||||
- If creating a file, assume any subsequent updates are included at the time of creation.
|
||||
- Don't create and then update a file. Always create new files in shot.
|
||||
- Prefer updating symbols lower in the syntax tree if possible.
|
||||
- Never include suggestions on a parent symbol and one of its children in the same suggestions block.
|
||||
- Never nest an operation with another operation or include CDATA or other content. All suggestions are leaf nodes.
|
||||
- Descriptions are required for all suggestions except delete.
|
||||
- When generating multiple suggestions, ensure the descriptions are specific to each individual operation.
|
||||
- Avoid referring to the location in the description. Focus on the change to be made, not the location where it's made. That's implicit with the symbol you provide.
|
||||
- Don't generate multiple suggestions at the same location. Instead, combine them together in a single operation with a succinct combined description.
|
||||
- To add imports respond with a suggestion where the `"symbol"` key is set to `"#imports"`
|
||||
</guidelines>
|
||||
</overview>
|
||||
|
||||
<examples>
|
||||
<example>
|
||||
<workflow_context>
|
||||
<message role="user">
|
||||
```rs src/rectangle.rs
|
||||
struct Rectangle {
|
||||
width: f64,
|
||||
height: f64,
|
||||
}
|
||||
|
||||
impl Rectangle {
|
||||
fn new(width: f64, height: f64) -> Self {
|
||||
Rectangle { width, height }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
We need to add methods to calculate the area and perimeter of the rectangle. Can you help with that?
|
||||
</message>
|
||||
<message role="assistant">
|
||||
Sure, I can help with that!
|
||||
|
||||
<step>Add new methods 'calculate_area' and 'calculate_perimeter' to the Rectangle struct</step>
|
||||
<step>Implement the 'Display' trait for the Rectangle struct</step>
|
||||
</message>
|
||||
</workflow_context>
|
||||
|
||||
<step_to_resolve>
|
||||
Add new methods 'calculate_area' and 'calculate_perimeter' to the Rectangle struct
|
||||
</step_to_resolve>
|
||||
|
||||
<incorrect_output reason="NEVER append multiple children at the same location.">
|
||||
{
|
||||
"title": "Add Rectangle methods",
|
||||
"suggestions": [
|
||||
{
|
||||
"kind": "AppendChild",
|
||||
"path": "src/shapes.rs",
|
||||
"symbol": "impl Rectangle",
|
||||
"description": "Add calculate_area method"
|
||||
},
|
||||
{
|
||||
"kind": "AppendChild",
|
||||
"path": "src/shapes.rs",
|
||||
"symbol": "impl Rectangle",
|
||||
"description": "Add calculate_perimeter method"
|
||||
}
|
||||
]
|
||||
}
|
||||
</incorrect_output>
|
||||
|
||||
<correct_output>
|
||||
{
|
||||
"title": "Add Rectangle methods",
|
||||
"suggestions": [
|
||||
{
|
||||
"kind": "AppendChild",
|
||||
"path": "src/shapes.rs",
|
||||
"symbol": "impl Rectangle",
|
||||
"description": "Add calculate area and perimeter methods"
|
||||
}
|
||||
]
|
||||
}
|
||||
</correct_output>
|
||||
|
||||
<step_to_resolve>
|
||||
Implement the 'Display' trait for the Rectangle struct
|
||||
</step_to_resolve>
|
||||
|
||||
<output>
|
||||
{
|
||||
"title": "Implement Display for Rectangle",
|
||||
"suggestions": [
|
||||
{
|
||||
"kind": "InsertSiblingAfter",
|
||||
"path": "src/shapes.rs",
|
||||
"symbol": "impl Rectangle",
|
||||
"description": "Implement Display trait for Rectangle"
|
||||
}
|
||||
]
|
||||
}
|
||||
</output>
|
||||
|
||||
<example>
|
||||
<workflow_context>
|
||||
<message role="user">
|
||||
```rs src/user.rs
|
||||
struct User {
|
||||
pub name: String,
|
||||
age: u32,
|
||||
email: String,
|
||||
}
|
||||
|
||||
impl User {
|
||||
fn new(name: String, age: u32, email: String) -> Self {
|
||||
User { name, age, email }
|
||||
}
|
||||
|
||||
pub fn print_info(&self) {
|
||||
println!("Name: {}, Age: {}, Email: {}", self.name, self.age, self.email);
|
||||
}
|
||||
}
|
||||
```
|
||||
</message>
|
||||
<message role="assistant">
|
||||
Certainly!
|
||||
<step>Update the 'print_info' method to use formatted output</step>
|
||||
<step>Remove the 'email' field from the User struct</step>
|
||||
</message>
|
||||
</workflow_context>
|
||||
|
||||
<step_to_resolve>
|
||||
Update the 'print_info' method to use formatted output
|
||||
</step_to_resolve>
|
||||
|
||||
<output>
|
||||
{
|
||||
"title": "Use formatted output",
|
||||
"suggestions": [
|
||||
{
|
||||
"kind": "Update",
|
||||
"path": "src/user.rs",
|
||||
"symbol": "impl User pub fn print_info",
|
||||
"description": "Use formatted output"
|
||||
}
|
||||
]
|
||||
}
|
||||
</output>
|
||||
|
||||
<step_to_resolve>
|
||||
Remove the 'email' field from the User struct
|
||||
</step_to_resolve>
|
||||
|
||||
<output>
|
||||
{
|
||||
"title": "Remove email field",
|
||||
"suggestions": [
|
||||
{
|
||||
"kind": "Delete",
|
||||
"path": "src/user.rs",
|
||||
"symbol": "struct User email"
|
||||
}
|
||||
]
|
||||
}
|
||||
</output>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
<workflow_context>
|
||||
<message role="user">
|
||||
```rs src/vehicle.rs
|
||||
struct Vehicle {
|
||||
make: String,
|
||||
model: String,
|
||||
year: u32,
|
||||
}
|
||||
|
||||
impl Vehicle {
|
||||
fn new(make: String, model: String, year: u32) -> Self {
|
||||
Vehicle { make, model, year }
|
||||
}
|
||||
|
||||
fn print_year(&self) {
|
||||
println!("Year: {}", self.year);
|
||||
}
|
||||
}
|
||||
```
|
||||
</message>
|
||||
<message role="assistant">
|
||||
<step>Add a 'use std::fmt;' statement at the beginning of the file</step>
|
||||
<step>Add a new method 'start_engine' in the Vehicle impl block</step>
|
||||
</message>
|
||||
</workflow_context>
|
||||
|
||||
<step_to_resolve>
|
||||
Add a 'use std::fmt;' statement at the beginning of the file
|
||||
</step_to_resolve>
|
||||
|
||||
<output>
|
||||
{
|
||||
"title": "Add use std::fmt statement",
|
||||
"suggestions": [
|
||||
{
|
||||
"kind": "PrependChild",
|
||||
"path": "src/vehicle.rs",
|
||||
"symbol": "#imports",
|
||||
"description": "Add 'use std::fmt' statement"
|
||||
}
|
||||
]
|
||||
}
|
||||
</output>
|
||||
|
||||
<step_to_resolve>
|
||||
Add a new method 'start_engine' in the Vehicle impl block
|
||||
</step_to_resolve>
|
||||
|
||||
<output>
|
||||
{
|
||||
"title": "Add start_engine method",
|
||||
"suggestions": [
|
||||
{
|
||||
"kind": "InsertSiblingAfter",
|
||||
"path": "src/vehicle.rs",
|
||||
"symbol": "impl Vehicle fn new",
|
||||
"description": "Add start_engine method"
|
||||
}
|
||||
]
|
||||
}
|
||||
</output>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
<workflow_context>
|
||||
<message role="user">
|
||||
```rs src/employee.rs
|
||||
struct Employee {
|
||||
name: String,
|
||||
position: String,
|
||||
salary: u32,
|
||||
department: String,
|
||||
}
|
||||
|
||||
impl Employee {
|
||||
fn new(name: String, position: String, salary: u32, department: String) -> Self {
|
||||
Employee { name, position, salary, department }
|
||||
}
|
||||
|
||||
fn print_details(&self) {
|
||||
println!("Name: {}, Position: {}, Salary: {}, Department: {}",
|
||||
self.name, self.position, self.salary, self.department);
|
||||
}
|
||||
|
||||
fn give_raise(&mut self, amount: u32) {
|
||||
self.salary += amount;
|
||||
}
|
||||
}
|
||||
```
|
||||
</message>
|
||||
<message role="assistant">
|
||||
<step>Make salary an f32</step>
|
||||
<step>Remove the 'department' field and update the 'print_details' method</step>
|
||||
</message>
|
||||
</workflow_context>
|
||||
|
||||
<step_to_resolve>
|
||||
Make salary an f32
|
||||
</step_to_resolve>
|
||||
|
||||
<incorrect_output reason="NEVER include suggestions on a parent symbol and one of its children in the same suggestions block.">
|
||||
{
|
||||
"title": "Change salary to f32",
|
||||
"suggestions": [
|
||||
{
|
||||
"kind": "Update",
|
||||
"path": "src/employee.rs",
|
||||
"symbol": "struct Employee",
|
||||
"description": "Change the type of salary to an f32"
|
||||
},
|
||||
{
|
||||
"kind": "Update",
|
||||
"path": "src/employee.rs",
|
||||
"symbol": "struct Employee salary",
|
||||
"description": "Change the type to an f32"
|
||||
}
|
||||
]
|
||||
}
|
||||
</incorrect_output>
|
||||
|
||||
<correct_output>
|
||||
{
|
||||
"title": "Change salary to f32",
|
||||
"suggestions": [
|
||||
{
|
||||
"kind": "Update",
|
||||
"path": "src/employee.rs",
|
||||
"symbol": "struct Employee salary",
|
||||
"description": "Change the type to an f32"
|
||||
}
|
||||
]
|
||||
}
|
||||
</correct_output>
|
||||
|
||||
<step_to_resolve>
|
||||
Remove the 'department' field and update the 'print_details' method
|
||||
</step_to_resolve>
|
||||
|
||||
<output>
|
||||
{
|
||||
"title": "Remove department",
|
||||
"suggestions": [
|
||||
{
|
||||
"kind": "Delete",
|
||||
"path": "src/employee.rs",
|
||||
"symbol": "struct Employee department"
|
||||
},
|
||||
{
|
||||
"kind": "Update",
|
||||
"path": "src/employee.rs",
|
||||
"symbol": "impl Employee fn print_details",
|
||||
"description": "Don't print the 'department' field"
|
||||
}
|
||||
]
|
||||
}
|
||||
</output>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
<workflow_context>
|
||||
<message role="user">
|
||||
```rs src/game.rs
|
||||
struct Player {
|
||||
name: String,
|
||||
health: i32,
|
||||
pub score: u32,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn new(name: String) -> Self {
|
||||
Player { name, health: 100, score: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
struct Game {
|
||||
players: Vec<Player>,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
fn new() -> Self {
|
||||
Game { players: Vec::new() }
|
||||
}
|
||||
}
|
||||
```
|
||||
</message>
|
||||
<message role="assistant">
|
||||
<step>Add a 'level' field to Player and update the 'new' method</step>
|
||||
</message>
|
||||
</workflow_context>
|
||||
|
||||
<step_to_resolve>
|
||||
Add a 'level' field to Player and update the 'new' method
|
||||
</step_to_resolve>
|
||||
|
||||
<output>
|
||||
{
|
||||
"title": "Add level field to Player",
|
||||
"suggestions": [
|
||||
{
|
||||
"kind": "InsertSiblingAfter",
|
||||
"path": "src/game.rs",
|
||||
"symbol": "struct Player pub score",
|
||||
"description": "Add level field to Player"
|
||||
},
|
||||
{
|
||||
"kind": "Update",
|
||||
"path": "src/game.rs",
|
||||
"symbol": "impl Player pub fn new",
|
||||
"description": "Initialize level in new method"
|
||||
}
|
||||
]
|
||||
}
|
||||
</output>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
<workflow_context>
|
||||
<message role="user">
|
||||
```rs src/config.rs
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct Config {
|
||||
settings: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn new() -> Self {
|
||||
Config { settings: HashMap::new() }
|
||||
}
|
||||
}
|
||||
```
|
||||
</message>
|
||||
<message role="assistant">
|
||||
<step>Add a 'load_from_file' method to Config and import necessary modules</step>
|
||||
</message>
|
||||
</workflow_context>
|
||||
|
||||
<step_to_resolve>
|
||||
Add a 'load_from_file' method to Config and import necessary modules
|
||||
</step_to_resolve>
|
||||
|
||||
<output>
|
||||
{
|
||||
"title": "Add load_from_file method",
|
||||
"suggestions": [
|
||||
{
|
||||
"kind": "PrependChild",
|
||||
"path": "src/config.rs",
|
||||
"symbol": "#imports",
|
||||
"description": "Import std::fs and std::io modules"
|
||||
},
|
||||
{
|
||||
"kind": "AppendChild",
|
||||
"path": "src/config.rs",
|
||||
"symbol": "impl Config",
|
||||
"description": "Add load_from_file method"
|
||||
}
|
||||
]
|
||||
}
|
||||
</output>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
<workflow_context>
|
||||
<message role="user">
|
||||
```rs src/database.rs
|
||||
pub(crate) struct Database {
|
||||
connection: Connection,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
fn new(url: &str) -> Result<Self, Error> {
|
||||
let connection = Connection::connect(url)?;
|
||||
Ok(Database { connection })
|
||||
}
|
||||
|
||||
async fn query(&self, sql: &str) -> Result<Vec<Row>, Error> {
|
||||
self.connection.query(sql, &[])
|
||||
}
|
||||
}
|
||||
```
|
||||
</message>
|
||||
<message role="assistant">
|
||||
<step>Add error handling to the 'query' method and create a custom error type</step>
|
||||
</message>
|
||||
</workflow_context>
|
||||
|
||||
<step_to_resolve>
|
||||
Add error handling to the 'query' method and create a custom error type
|
||||
</step_to_resolve>
|
||||
|
||||
<output>
|
||||
{
|
||||
"title": "Add error handling to query",
|
||||
"suggestions": [
|
||||
{
|
||||
"kind": "PrependChild",
|
||||
"path": "src/database.rs",
|
||||
"description": "Import necessary error handling modules"
|
||||
},
|
||||
{
|
||||
"kind": "InsertSiblingBefore",
|
||||
"path": "src/database.rs",
|
||||
"symbol": "pub(crate) struct Database",
|
||||
"description": "Define custom DatabaseError enum"
|
||||
},
|
||||
{
|
||||
"kind": "Update",
|
||||
"path": "src/database.rs",
|
||||
"symbol": "impl Database async fn query",
|
||||
"description": "Implement error handling in query method"
|
||||
}
|
||||
]
|
||||
}
|
||||
</output>
|
||||
</example>
|
||||
</examples>
|
||||
|
||||
Now generate the suggestions for the following step:
|
||||
|
||||
<workflow_context>
|
||||
{{{workflow_context}}}
|
||||
</workflow_context>
|
||||
|
||||
<step_to_resolve>
|
||||
{{{step_to_resolve}}}
|
||||
</step_to_resolve>
|
||||
@@ -712,10 +712,10 @@
|
||||
// May take 2 values:
|
||||
// 1. Rely on default platform handling of option key, on macOS
|
||||
// this means generating certain unicode characters
|
||||
// "option_to_meta": false,
|
||||
// "option_as_meta": false,
|
||||
// 2. Make the option keys behave as a 'meta' key, e.g. for emacs
|
||||
// "option_to_meta": true,
|
||||
"option_as_meta": true,
|
||||
// "option_as_meta": true,
|
||||
"option_as_meta": false,
|
||||
// Whether or not selecting text in the terminal will automatically
|
||||
// copy to the system clipboard.
|
||||
"copy_on_select": false,
|
||||
|
||||
@@ -97,6 +97,7 @@ language = { workspace = true, features = ["test-support"] }
|
||||
language_model = { workspace = true, features = ["test-support"] }
|
||||
languages = { workspace = true, features = ["test-support"] }
|
||||
log.workspace = true
|
||||
pretty_assertions.workspace = true
|
||||
project = { workspace = true, features = ["test-support"] }
|
||||
rand.workspace = true
|
||||
serde_json_lenient.workspace = true
|
||||
|
||||
@@ -6,6 +6,7 @@ mod context;
|
||||
pub mod context_store;
|
||||
mod inline_assistant;
|
||||
mod model_selector;
|
||||
mod patch;
|
||||
mod prompt_library;
|
||||
mod prompts;
|
||||
mod slash_command;
|
||||
@@ -14,7 +15,6 @@ pub mod slash_command_settings;
|
||||
mod streaming_diff;
|
||||
mod terminal_inline_assistant;
|
||||
mod tools;
|
||||
mod workflow;
|
||||
|
||||
pub use assistant_panel::{AssistantPanel, AssistantPanelEvent};
|
||||
use assistant_settings::AssistantSettings;
|
||||
@@ -35,11 +35,13 @@ use language_model::{
|
||||
LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, LanguageModelResponseMessage,
|
||||
};
|
||||
pub(crate) use model_selector::*;
|
||||
pub use patch::*;
|
||||
pub use prompts::PromptBuilder;
|
||||
use prompts::PromptLoadingParams;
|
||||
use semantic_index::{CloudEmbeddingProvider, SemanticDb};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{update_settings_file, Settings, SettingsStore};
|
||||
use slash_command::workflow_command::WorkflowSlashCommand;
|
||||
use slash_command::{
|
||||
auto_command, cargo_workspace_command, context_server_command, default_command, delta_command,
|
||||
diagnostics_command, docs_command, fetch_command, file_command, now_command, project_command,
|
||||
@@ -50,14 +52,15 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
pub(crate) use streaming_diff::*;
|
||||
use util::ResultExt;
|
||||
pub use workflow::*;
|
||||
|
||||
use crate::slash_command_settings::SlashCommandSettings;
|
||||
|
||||
actions!(
|
||||
assistant,
|
||||
[
|
||||
Assist,
|
||||
AssistLegacy,
|
||||
AssistChat,
|
||||
AssistEdit,
|
||||
Split,
|
||||
CopyCode,
|
||||
CycleMessageRole,
|
||||
@@ -393,12 +396,25 @@ fn register_slash_commands(prompt_builder: Option<Arc<PromptBuilder>>, cx: &mut
|
||||
slash_command_registry.register_command(now_command::NowSlashCommand, false);
|
||||
slash_command_registry.register_command(diagnostics_command::DiagnosticsSlashCommand, true);
|
||||
slash_command_registry.register_command(fetch_command::FetchSlashCommand, false);
|
||||
slash_command_registry.register_command(fetch_command::FetchSlashCommand, false);
|
||||
|
||||
if let Some(prompt_builder) = prompt_builder {
|
||||
slash_command_registry.register_command(
|
||||
workflow_command::WorkflowSlashCommand::new(prompt_builder.clone()),
|
||||
true,
|
||||
);
|
||||
cx.observe_global::<SettingsStore>({
|
||||
let slash_command_registry = slash_command_registry.clone();
|
||||
let prompt_builder = prompt_builder.clone();
|
||||
move |cx| {
|
||||
if AssistantSettings::get_global(cx).are_live_diffs_enabled(cx) {
|
||||
slash_command_registry.register_command(
|
||||
workflow_command::WorkflowSlashCommand::new(prompt_builder.clone()),
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
slash_command_registry.unregister_command_by_name(WorkflowSlashCommand::NAME);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.observe_flag::<project_command::ProjectSlashCommandFeatureFlag, _>({
|
||||
let slash_command_registry = slash_command_registry.clone();
|
||||
move |is_enabled, _cx| {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use ::open_ai::Model as OpenAiModel;
|
||||
use anthropic::Model as AnthropicModel;
|
||||
use feature_flags::FeatureFlagAppExt;
|
||||
use fs::Fs;
|
||||
use gpui::{AppContext, Pixels};
|
||||
use language_model::provider::open_ai;
|
||||
@@ -61,6 +62,13 @@ pub struct AssistantSettings {
|
||||
pub default_model: LanguageModelSelection,
|
||||
pub inline_alternatives: Vec<LanguageModelSelection>,
|
||||
pub using_outdated_settings_version: bool,
|
||||
pub enable_experimental_live_diffs: bool,
|
||||
}
|
||||
|
||||
impl AssistantSettings {
|
||||
pub fn are_live_diffs_enabled(&self, cx: &AppContext) -> bool {
|
||||
cx.is_staff() || self.enable_experimental_live_diffs
|
||||
}
|
||||
}
|
||||
|
||||
/// Assistant panel settings
|
||||
@@ -238,6 +246,7 @@ impl AssistantSettingsContent {
|
||||
}
|
||||
}),
|
||||
inline_alternatives: None,
|
||||
enable_experimental_live_diffs: None,
|
||||
},
|
||||
VersionedAssistantSettingsContent::V2(settings) => settings.clone(),
|
||||
},
|
||||
@@ -257,6 +266,7 @@ impl AssistantSettingsContent {
|
||||
.to_string(),
|
||||
}),
|
||||
inline_alternatives: None,
|
||||
enable_experimental_live_diffs: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -373,6 +383,7 @@ impl Default for VersionedAssistantSettingsContent {
|
||||
default_height: None,
|
||||
default_model: None,
|
||||
inline_alternatives: None,
|
||||
enable_experimental_live_diffs: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -403,6 +414,10 @@ pub struct AssistantSettingsContentV2 {
|
||||
default_model: Option<LanguageModelSelection>,
|
||||
/// Additional models with which to generate alternatives when performing inline assists.
|
||||
inline_alternatives: Option<Vec<LanguageModelSelection>>,
|
||||
/// Enable experimental live diffs in the assistant panel.
|
||||
///
|
||||
/// Default: false
|
||||
enable_experimental_live_diffs: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||
@@ -525,7 +540,10 @@ impl Settings for AssistantSettings {
|
||||
);
|
||||
merge(&mut settings.default_model, value.default_model);
|
||||
merge(&mut settings.inline_alternatives, value.inline_alternatives);
|
||||
// merge(&mut settings.infer_context, value.infer_context); TODO re-enable this once we ship context inference
|
||||
merge(
|
||||
&mut settings.enable_experimental_live_diffs,
|
||||
value.enable_experimental_live_diffs,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(settings)
|
||||
@@ -584,6 +602,7 @@ mod tests {
|
||||
dock: None,
|
||||
default_width: None,
|
||||
default_height: None,
|
||||
enable_experimental_live_diffs: None,
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
mod context_tests;
|
||||
|
||||
use crate::{
|
||||
prompts::PromptBuilder, slash_command::SlashCommandLine, MessageId, MessageStatus,
|
||||
WorkflowStep, WorkflowStepEdit, WorkflowStepResolution, WorkflowSuggestionGroup,
|
||||
prompts::PromptBuilder, slash_command::SlashCommandLine, AssistantEdit, AssistantPatch,
|
||||
AssistantPatchStatus, MessageId, MessageStatus,
|
||||
};
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use assistant_slash_command::{
|
||||
@@ -15,13 +15,10 @@ use clock::ReplicaId;
|
||||
use collections::{HashMap, HashSet};
|
||||
use feature_flags::{FeatureFlag, FeatureFlagAppExt};
|
||||
use fs::{Fs, RemoveOptions};
|
||||
use futures::{
|
||||
future::{self, Shared},
|
||||
FutureExt, StreamExt,
|
||||
};
|
||||
use futures::{future::Shared, FutureExt, StreamExt};
|
||||
use gpui::{
|
||||
AppContext, AsyncAppContext, Context as _, EventEmitter, Model, ModelContext, RenderImage,
|
||||
SharedString, Subscription, Task,
|
||||
AppContext, Context as _, EventEmitter, Model, ModelContext, RenderImage, SharedString,
|
||||
Subscription, Task,
|
||||
};
|
||||
|
||||
use language::{AnchorRangeExt, Bias, Buffer, LanguageRegistry, OffsetRangeExt, Point, ToOffset};
|
||||
@@ -38,7 +35,7 @@ use project::Project;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smallvec::SmallVec;
|
||||
use std::{
|
||||
cmp::{self, max, Ordering},
|
||||
cmp::{max, Ordering},
|
||||
fmt::Debug,
|
||||
iter, mem,
|
||||
ops::Range,
|
||||
@@ -116,6 +113,10 @@ impl ContextOperation {
|
||||
message.status.context("invalid status")?,
|
||||
),
|
||||
timestamp: id.0,
|
||||
// kind: {
|
||||
// let todo = (); // TODO: Should these go in the protocol?
|
||||
// MessageKind::Legacy
|
||||
// },
|
||||
cache: None,
|
||||
},
|
||||
version: language::proto::deserialize_version(&insert.version),
|
||||
@@ -131,6 +132,10 @@ impl ContextOperation {
|
||||
timestamp: language::proto::deserialize_timestamp(
|
||||
update.timestamp.context("invalid timestamp")?,
|
||||
),
|
||||
// kind: {
|
||||
// let todo = (); // TODO: Should these go in the protocol?
|
||||
// MessageKind::Legacy
|
||||
// },
|
||||
cache: None,
|
||||
},
|
||||
version: language::proto::deserialize_version(&update.version),
|
||||
@@ -300,7 +305,7 @@ pub enum ContextEvent {
|
||||
MessagesEdited,
|
||||
SummaryChanged,
|
||||
StreamedCompletion,
|
||||
WorkflowStepsUpdated {
|
||||
PatchesUpdated {
|
||||
removed: Vec<Range<language::Anchor>>,
|
||||
updated: Vec<Range<language::Anchor>>,
|
||||
},
|
||||
@@ -354,16 +359,28 @@ pub struct MessageMetadata {
|
||||
pub role: Role,
|
||||
pub status: MessageStatus,
|
||||
pub(crate) timestamp: clock::Lamport,
|
||||
// pub kind: MessageKind,
|
||||
#[serde(skip)]
|
||||
pub cache: Option<MessageCacheMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub enum MessageKind {
|
||||
/// No preamble
|
||||
Legacy,
|
||||
/// Preamble along the lines of "This is a chat message:"
|
||||
Chat,
|
||||
/// Preamble along the lines of "This is an edit message:"
|
||||
Edit,
|
||||
}
|
||||
|
||||
impl From<&Message> for MessageMetadata {
|
||||
fn from(message: &Message) -> Self {
|
||||
Self {
|
||||
role: message.role,
|
||||
status: message.status.clone(),
|
||||
timestamp: message.id.0,
|
||||
// kind: message.kind,
|
||||
cache: message.cache.clone(),
|
||||
}
|
||||
}
|
||||
@@ -393,6 +410,7 @@ pub struct Message {
|
||||
pub id: MessageId,
|
||||
pub role: Role,
|
||||
pub status: MessageStatus,
|
||||
pub kind: MessageKind,
|
||||
pub cache: Option<MessageCacheMetadata>,
|
||||
}
|
||||
|
||||
@@ -454,13 +472,14 @@ pub struct XmlTag {
|
||||
#[derive(Copy, Clone, Debug, strum::EnumString, PartialEq, Eq, strum::AsRefStr)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum XmlTagKind {
|
||||
Step,
|
||||
Patch,
|
||||
Title,
|
||||
Edit,
|
||||
Path,
|
||||
Search,
|
||||
Within,
|
||||
Operation,
|
||||
Description,
|
||||
OldText,
|
||||
NewText,
|
||||
Operation,
|
||||
}
|
||||
|
||||
pub struct Context {
|
||||
@@ -490,7 +509,7 @@ pub struct Context {
|
||||
_subscriptions: Vec<Subscription>,
|
||||
telemetry: Option<Arc<Telemetry>>,
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
workflow_steps: Vec<WorkflowStep>,
|
||||
patches: Vec<AssistantPatch>,
|
||||
xml_tags: Vec<XmlTag>,
|
||||
project: Option<Model<Project>>,
|
||||
prompt_builder: Arc<PromptBuilder>,
|
||||
@@ -506,7 +525,7 @@ impl ContextAnnotation for PendingSlashCommand {
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextAnnotation for WorkflowStep {
|
||||
impl ContextAnnotation for AssistantPatch {
|
||||
fn range(&self) -> &Range<language::Anchor> {
|
||||
&self.range
|
||||
}
|
||||
@@ -591,7 +610,7 @@ impl Context {
|
||||
telemetry,
|
||||
project,
|
||||
language_registry,
|
||||
workflow_steps: Vec::new(),
|
||||
patches: Vec::new(),
|
||||
xml_tags: Vec::new(),
|
||||
prompt_builder,
|
||||
};
|
||||
@@ -929,48 +948,49 @@ impl Context {
|
||||
self.summary.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn workflow_step_containing(
|
||||
pub(crate) fn patch_containing(
|
||||
&self,
|
||||
offset: usize,
|
||||
position: Point,
|
||||
cx: &AppContext,
|
||||
) -> Option<&WorkflowStep> {
|
||||
) -> Option<&AssistantPatch> {
|
||||
let buffer = self.buffer.read(cx);
|
||||
let index = self
|
||||
.workflow_steps
|
||||
.binary_search_by(|step| {
|
||||
let step_range = step.range.to_offset(&buffer);
|
||||
if offset < step_range.start {
|
||||
Ordering::Greater
|
||||
} else if offset > step_range.end {
|
||||
Ordering::Less
|
||||
} else {
|
||||
Ordering::Equal
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
Some(&self.workflow_steps[index])
|
||||
let index = self.patches.binary_search_by(|patch| {
|
||||
let patch_range = patch.range.to_point(&buffer);
|
||||
if position < patch_range.start {
|
||||
Ordering::Greater
|
||||
} else if position > patch_range.end {
|
||||
Ordering::Less
|
||||
} else {
|
||||
Ordering::Equal
|
||||
}
|
||||
});
|
||||
if let Ok(ix) = index {
|
||||
Some(&self.patches[ix])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn workflow_step_ranges(&self) -> impl Iterator<Item = Range<language::Anchor>> + '_ {
|
||||
self.workflow_steps.iter().map(|step| step.range.clone())
|
||||
pub fn patch_ranges(&self) -> impl Iterator<Item = Range<language::Anchor>> + '_ {
|
||||
self.patches.iter().map(|patch| patch.range.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn workflow_step_for_range(
|
||||
pub(crate) fn patch_for_range(
|
||||
&self,
|
||||
range: &Range<language::Anchor>,
|
||||
cx: &AppContext,
|
||||
) -> Option<&WorkflowStep> {
|
||||
) -> Option<&AssistantPatch> {
|
||||
let buffer = self.buffer.read(cx);
|
||||
let index = self.workflow_step_index_for_range(range, buffer).ok()?;
|
||||
Some(&self.workflow_steps[index])
|
||||
let index = self.patch_index_for_range(range, buffer).ok()?;
|
||||
Some(&self.patches[index])
|
||||
}
|
||||
|
||||
fn workflow_step_index_for_range(
|
||||
fn patch_index_for_range(
|
||||
&self,
|
||||
tagged_range: &Range<text::Anchor>,
|
||||
buffer: &text::BufferSnapshot,
|
||||
) -> Result<usize, usize> {
|
||||
self.workflow_steps
|
||||
self.patches
|
||||
.binary_search_by(|probe| probe.range.cmp(&tagged_range, buffer))
|
||||
}
|
||||
|
||||
@@ -1018,8 +1038,6 @@ impl Context {
|
||||
language::BufferEvent::Edited => {
|
||||
self.count_remaining_tokens(cx);
|
||||
self.reparse(cx);
|
||||
// Use `inclusive = true` to invalidate a step when an edit occurs
|
||||
// at the start/end of a parsed step.
|
||||
cx.emit(ContextEvent::MessagesEdited);
|
||||
}
|
||||
_ => {}
|
||||
@@ -1248,8 +1266,8 @@ impl Context {
|
||||
|
||||
let mut removed_slash_command_ranges = Vec::new();
|
||||
let mut updated_slash_commands = Vec::new();
|
||||
let mut removed_steps = Vec::new();
|
||||
let mut updated_steps = Vec::new();
|
||||
let mut removed_patches = Vec::new();
|
||||
let mut updated_patches = Vec::new();
|
||||
while let Some(mut row_range) = row_ranges.next() {
|
||||
while let Some(next_row_range) = row_ranges.peek() {
|
||||
if row_range.end >= next_row_range.start {
|
||||
@@ -1273,11 +1291,11 @@ impl Context {
|
||||
&mut removed_slash_command_ranges,
|
||||
cx,
|
||||
);
|
||||
self.reparse_workflow_steps_in_range(
|
||||
self.reparse_patches_in_range(
|
||||
start..end,
|
||||
&buffer,
|
||||
&mut updated_steps,
|
||||
&mut removed_steps,
|
||||
&mut updated_patches,
|
||||
&mut removed_patches,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
@@ -1289,10 +1307,10 @@ impl Context {
|
||||
});
|
||||
}
|
||||
|
||||
if !updated_steps.is_empty() || !removed_steps.is_empty() {
|
||||
cx.emit(ContextEvent::WorkflowStepsUpdated {
|
||||
removed: removed_steps,
|
||||
updated: updated_steps,
|
||||
if !updated_patches.is_empty() || !removed_patches.is_empty() {
|
||||
cx.emit(ContextEvent::PatchesUpdated {
|
||||
removed: removed_patches,
|
||||
updated: updated_patches,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1354,7 +1372,7 @@ impl Context {
|
||||
removed.extend(removed_commands.map(|command| command.source_range));
|
||||
}
|
||||
|
||||
fn reparse_workflow_steps_in_range(
|
||||
fn reparse_patches_in_range(
|
||||
&mut self,
|
||||
range: Range<text::Anchor>,
|
||||
buffer: &BufferSnapshot,
|
||||
@@ -1369,41 +1387,32 @@ impl Context {
|
||||
self.xml_tags
|
||||
.splice(intersecting_tags_range.clone(), new_tags);
|
||||
|
||||
// Find which steps intersect the changed range.
|
||||
let intersecting_steps_range =
|
||||
self.indices_intersecting_buffer_range(&self.workflow_steps, range.clone(), cx);
|
||||
// Find which patches intersect the changed range.
|
||||
let intersecting_patches_range =
|
||||
self.indices_intersecting_buffer_range(&self.patches, range.clone(), cx);
|
||||
|
||||
// Reparse all tags after the last unchanged step before the change.
|
||||
// Reparse all tags after the last unchanged patch before the change.
|
||||
let mut tags_start_ix = 0;
|
||||
if let Some(preceding_unchanged_step) =
|
||||
self.workflow_steps[..intersecting_steps_range.start].last()
|
||||
if let Some(preceding_unchanged_patch) =
|
||||
self.patches[..intersecting_patches_range.start].last()
|
||||
{
|
||||
tags_start_ix = match self.xml_tags.binary_search_by(|tag| {
|
||||
tag.range
|
||||
.start
|
||||
.cmp(&preceding_unchanged_step.range.end, buffer)
|
||||
.cmp(&preceding_unchanged_patch.range.end, buffer)
|
||||
.then(Ordering::Less)
|
||||
}) {
|
||||
Ok(ix) | Err(ix) => ix,
|
||||
};
|
||||
}
|
||||
|
||||
// Rebuild the edit suggestions in the range.
|
||||
let mut new_steps = self.parse_steps(tags_start_ix, range.end, buffer);
|
||||
|
||||
if let Some(project) = self.project() {
|
||||
for step in &mut new_steps {
|
||||
Self::resolve_workflow_step_internal(step, &project, cx);
|
||||
}
|
||||
}
|
||||
|
||||
updated.extend(new_steps.iter().map(|step| step.range.clone()));
|
||||
let removed_steps = self
|
||||
.workflow_steps
|
||||
.splice(intersecting_steps_range, new_steps);
|
||||
// Rebuild the patches in the range.
|
||||
let new_patches = self.parse_patches(tags_start_ix, range.end, buffer, cx);
|
||||
updated.extend(new_patches.iter().map(|patch| patch.range.clone()));
|
||||
let removed_patches = self.patches.splice(intersecting_patches_range, new_patches);
|
||||
removed.extend(
|
||||
removed_steps
|
||||
.map(|step| step.range)
|
||||
removed_patches
|
||||
.map(|patch| patch.range)
|
||||
.filter(|range| !updated.contains(&range)),
|
||||
);
|
||||
}
|
||||
@@ -1464,60 +1473,95 @@ impl Context {
|
||||
tags
|
||||
}
|
||||
|
||||
fn parse_steps(
|
||||
fn parse_patches(
|
||||
&mut self,
|
||||
tags_start_ix: usize,
|
||||
buffer_end: text::Anchor,
|
||||
buffer: &BufferSnapshot,
|
||||
) -> Vec<WorkflowStep> {
|
||||
let mut new_steps = Vec::new();
|
||||
let mut pending_step = None;
|
||||
let mut edit_step_depth = 0;
|
||||
cx: &AppContext,
|
||||
) -> Vec<AssistantPatch> {
|
||||
let mut new_patches = Vec::new();
|
||||
let mut pending_patch = None;
|
||||
let mut patch_tag_depth = 0;
|
||||
let mut tags = self.xml_tags[tags_start_ix..].iter().peekable();
|
||||
'tags: while let Some(tag) = tags.next() {
|
||||
if tag.range.start.cmp(&buffer_end, buffer).is_gt() && edit_step_depth == 0 {
|
||||
if tag.range.start.cmp(&buffer_end, buffer).is_gt() && patch_tag_depth == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
if tag.kind == XmlTagKind::Step && tag.is_open_tag {
|
||||
edit_step_depth += 1;
|
||||
let edit_start = tag.range.start;
|
||||
let mut edits = Vec::new();
|
||||
let mut step = WorkflowStep {
|
||||
range: edit_start..edit_start,
|
||||
leading_tags_end: tag.range.end,
|
||||
trailing_tag_start: None,
|
||||
if tag.kind == XmlTagKind::Patch && tag.is_open_tag {
|
||||
patch_tag_depth += 1;
|
||||
let patch_start = tag.range.start;
|
||||
let mut edits = Vec::<Result<AssistantEdit>>::new();
|
||||
let mut patch = AssistantPatch {
|
||||
range: patch_start..patch_start,
|
||||
title: String::new().into(),
|
||||
edits: Default::default(),
|
||||
resolution: None,
|
||||
resolution_task: None,
|
||||
status: crate::AssistantPatchStatus::Pending,
|
||||
};
|
||||
|
||||
while let Some(tag) = tags.next() {
|
||||
step.trailing_tag_start.get_or_insert(tag.range.start);
|
||||
if tag.kind == XmlTagKind::Patch && !tag.is_open_tag {
|
||||
patch_tag_depth -= 1;
|
||||
if patch_tag_depth == 0 {
|
||||
patch.range.end = tag.range.end;
|
||||
|
||||
if tag.kind == XmlTagKind::Step && !tag.is_open_tag {
|
||||
// step.trailing_tag_start = Some(tag.range.start);
|
||||
edit_step_depth -= 1;
|
||||
if edit_step_depth == 0 {
|
||||
step.range.end = tag.range.end;
|
||||
step.edits = edits.into();
|
||||
new_steps.push(step);
|
||||
// Include the line immediately after this <patch> tag if it's empty.
|
||||
let patch_end_offset = patch.range.end.to_offset(buffer);
|
||||
let mut patch_end_chars = buffer.chars_at(patch_end_offset);
|
||||
if patch_end_chars.next() == Some('\n')
|
||||
&& patch_end_chars.next().map_or(true, |ch| ch == '\n')
|
||||
{
|
||||
let messages = self.messages_for_offsets(
|
||||
[patch_end_offset, patch_end_offset + 1],
|
||||
cx,
|
||||
);
|
||||
if messages.len() == 1 {
|
||||
patch.range.end = buffer.anchor_before(patch_end_offset + 1);
|
||||
}
|
||||
}
|
||||
|
||||
edits.sort_unstable_by(|a, b| {
|
||||
if let (Ok(a), Ok(b)) = (a, b) {
|
||||
a.path.cmp(&b.path)
|
||||
} else {
|
||||
Ordering::Equal
|
||||
}
|
||||
});
|
||||
patch.edits = edits.into();
|
||||
patch.status = AssistantPatchStatus::Ready;
|
||||
new_patches.push(patch);
|
||||
continue 'tags;
|
||||
}
|
||||
}
|
||||
|
||||
if tag.kind == XmlTagKind::Title && tag.is_open_tag {
|
||||
let content_start = tag.range.end;
|
||||
while let Some(tag) = tags.next() {
|
||||
if tag.kind == XmlTagKind::Title && !tag.is_open_tag {
|
||||
let content_end = tag.range.start;
|
||||
patch.title =
|
||||
trimmed_text_in_range(buffer, content_start..content_end)
|
||||
.into();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tag.kind == XmlTagKind::Edit && tag.is_open_tag {
|
||||
let mut path = None;
|
||||
let mut search = None;
|
||||
let mut old_text = None;
|
||||
let mut new_text = None;
|
||||
let mut operation = None;
|
||||
let mut description = None;
|
||||
|
||||
while let Some(tag) = tags.next() {
|
||||
if tag.kind == XmlTagKind::Edit && !tag.is_open_tag {
|
||||
edits.push(WorkflowStepEdit::new(
|
||||
edits.push(AssistantEdit::new(
|
||||
path,
|
||||
operation,
|
||||
search,
|
||||
old_text,
|
||||
new_text,
|
||||
description,
|
||||
));
|
||||
break;
|
||||
@@ -1526,7 +1570,8 @@ impl Context {
|
||||
if tag.is_open_tag
|
||||
&& [
|
||||
XmlTagKind::Path,
|
||||
XmlTagKind::Search,
|
||||
XmlTagKind::OldText,
|
||||
XmlTagKind::NewText,
|
||||
XmlTagKind::Operation,
|
||||
XmlTagKind::Description,
|
||||
]
|
||||
@@ -1538,15 +1583,18 @@ impl Context {
|
||||
if tag.kind == kind && !tag.is_open_tag {
|
||||
let tag = tags.next().unwrap();
|
||||
let content_end = tag.range.start;
|
||||
let mut content = buffer
|
||||
.text_for_range(content_start..content_end)
|
||||
.collect::<String>();
|
||||
content.truncate(content.trim_end().len());
|
||||
let content = trimmed_text_in_range(
|
||||
buffer,
|
||||
content_start..content_end,
|
||||
);
|
||||
match kind {
|
||||
XmlTagKind::Path => path = Some(content),
|
||||
XmlTagKind::Operation => operation = Some(content),
|
||||
XmlTagKind::Search => {
|
||||
search = Some(content).filter(|s| !s.is_empty())
|
||||
XmlTagKind::OldText => {
|
||||
old_text = Some(content).filter(|s| !s.is_empty())
|
||||
}
|
||||
XmlTagKind::NewText => {
|
||||
new_text = Some(content).filter(|s| !s.is_empty())
|
||||
}
|
||||
XmlTagKind::Description => {
|
||||
description =
|
||||
@@ -1561,162 +1609,28 @@ impl Context {
|
||||
}
|
||||
}
|
||||
|
||||
pending_step = Some(step);
|
||||
patch.edits = edits.into();
|
||||
pending_patch = Some(patch);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mut pending_step) = pending_step {
|
||||
pending_step.range.end = text::Anchor::MAX;
|
||||
new_steps.push(pending_step);
|
||||
}
|
||||
|
||||
new_steps
|
||||
}
|
||||
|
||||
pub fn resolve_workflow_step(
|
||||
&mut self,
|
||||
tagged_range: Range<text::Anchor>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Option<()> {
|
||||
let index = self
|
||||
.workflow_step_index_for_range(&tagged_range, self.buffer.read(cx))
|
||||
.ok()?;
|
||||
let step = &mut self.workflow_steps[index];
|
||||
let project = self.project.as_ref()?;
|
||||
step.resolution.take();
|
||||
Self::resolve_workflow_step_internal(step, project, cx);
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_workflow_step_internal(
|
||||
step: &mut WorkflowStep,
|
||||
project: &Model<Project>,
|
||||
cx: &mut ModelContext<'_, Context>,
|
||||
) {
|
||||
step.resolution_task = Some(cx.spawn({
|
||||
let range = step.range.clone();
|
||||
let edits = step.edits.clone();
|
||||
let project = project.clone();
|
||||
|this, mut cx| async move {
|
||||
let suggestion_groups =
|
||||
Self::compute_step_resolution(project, edits, &mut cx).await;
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
let buffer = this.buffer.read(cx).text_snapshot();
|
||||
let ix = this.workflow_step_index_for_range(&range, &buffer).ok();
|
||||
if let Some(ix) = ix {
|
||||
let step = &mut this.workflow_steps[ix];
|
||||
|
||||
let resolution = suggestion_groups.map(|suggestion_groups| {
|
||||
let mut title = String::new();
|
||||
for mut chunk in buffer.text_for_range(
|
||||
step.leading_tags_end
|
||||
..step.trailing_tag_start.unwrap_or(step.range.end),
|
||||
) {
|
||||
if title.is_empty() {
|
||||
chunk = chunk.trim_start();
|
||||
}
|
||||
if let Some((prefix, _)) = chunk.split_once('\n') {
|
||||
title.push_str(prefix);
|
||||
break;
|
||||
} else {
|
||||
title.push_str(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
WorkflowStepResolution {
|
||||
title,
|
||||
suggestion_groups,
|
||||
}
|
||||
});
|
||||
|
||||
step.resolution = Some(Arc::new(resolution));
|
||||
cx.emit(ContextEvent::WorkflowStepsUpdated {
|
||||
removed: vec![],
|
||||
updated: vec![range],
|
||||
})
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async fn compute_step_resolution(
|
||||
project: Model<Project>,
|
||||
edits: Arc<[Result<WorkflowStepEdit>]>,
|
||||
cx: &mut AsyncAppContext,
|
||||
) -> Result<HashMap<Model<Buffer>, Vec<WorkflowSuggestionGroup>>> {
|
||||
let mut suggestion_tasks = Vec::new();
|
||||
for edit in edits.iter() {
|
||||
let edit = edit.as_ref().map_err(|e| anyhow!("{e}"))?;
|
||||
suggestion_tasks.push(edit.resolve(project.clone(), cx.clone()));
|
||||
}
|
||||
|
||||
// Expand the context ranges of each suggestion and group suggestions with overlapping context ranges.
|
||||
let suggestions = future::try_join_all(suggestion_tasks).await?;
|
||||
|
||||
let mut suggestions_by_buffer = HashMap::default();
|
||||
for (buffer, suggestion) in suggestions {
|
||||
suggestions_by_buffer
|
||||
.entry(buffer)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(suggestion);
|
||||
}
|
||||
|
||||
let mut suggestion_groups_by_buffer = HashMap::default();
|
||||
for (buffer, mut suggestions) in suggestions_by_buffer {
|
||||
let mut suggestion_groups = Vec::<WorkflowSuggestionGroup>::new();
|
||||
let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot())?;
|
||||
// Sort suggestions by their range so that earlier, larger ranges come first
|
||||
suggestions.sort_by(|a, b| a.range().cmp(&b.range(), &snapshot));
|
||||
|
||||
// Merge overlapping suggestions
|
||||
suggestions.dedup_by(|a, b| b.try_merge(a, &snapshot));
|
||||
|
||||
// Create context ranges for each suggestion
|
||||
for suggestion in suggestions {
|
||||
let context_range = {
|
||||
let suggestion_point_range = suggestion.range().to_point(&snapshot);
|
||||
let start_row = suggestion_point_range.start.row.saturating_sub(5);
|
||||
let end_row =
|
||||
cmp::min(suggestion_point_range.end.row + 5, snapshot.max_point().row);
|
||||
let start = snapshot.anchor_before(Point::new(start_row, 0));
|
||||
let end =
|
||||
snapshot.anchor_after(Point::new(end_row, snapshot.line_len(end_row)));
|
||||
start..end
|
||||
};
|
||||
|
||||
if let Some(last_group) = suggestion_groups.last_mut() {
|
||||
if last_group
|
||||
.context_range
|
||||
.end
|
||||
.cmp(&context_range.start, &snapshot)
|
||||
.is_ge()
|
||||
{
|
||||
// Merge with the previous group if context ranges overlap
|
||||
last_group.context_range.end = context_range.end;
|
||||
last_group.suggestions.push(suggestion);
|
||||
} else {
|
||||
// Create a new group
|
||||
suggestion_groups.push(WorkflowSuggestionGroup {
|
||||
context_range,
|
||||
suggestions: vec![suggestion],
|
||||
});
|
||||
}
|
||||
if let Some(mut pending_patch) = pending_patch {
|
||||
let patch_start = pending_patch.range.start.to_offset(buffer);
|
||||
if let Some(message) = self.message_for_offset(patch_start, cx) {
|
||||
if message.anchor_range.end == text::Anchor::MAX {
|
||||
pending_patch.range.end = text::Anchor::MAX;
|
||||
} else {
|
||||
// Create the first group
|
||||
suggestion_groups.push(WorkflowSuggestionGroup {
|
||||
context_range,
|
||||
suggestions: vec![suggestion],
|
||||
});
|
||||
let message_end = buffer.anchor_after(message.offset_range.end - 1);
|
||||
pending_patch.range.end = message_end;
|
||||
}
|
||||
} else {
|
||||
pending_patch.range.end = text::Anchor::MAX;
|
||||
}
|
||||
|
||||
suggestion_groups_by_buffer.insert(buffer, suggestion_groups);
|
||||
new_patches.push(pending_patch);
|
||||
}
|
||||
|
||||
Ok(suggestion_groups_by_buffer)
|
||||
new_patches
|
||||
}
|
||||
|
||||
pub fn pending_command_for_position(
|
||||
@@ -1972,7 +1886,11 @@ impl Context {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn assist(&mut self, cx: &mut ModelContext<Self>) -> Option<MessageAnchor> {
|
||||
pub fn assist(
|
||||
&mut self,
|
||||
message_kind: MessageKind,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Option<MessageAnchor> {
|
||||
let model_registry = LanguageModelRegistry::read_global(cx);
|
||||
let provider = model_registry.active_provider()?;
|
||||
let model = model_registry.active_model()?;
|
||||
@@ -1982,6 +1900,14 @@ impl Context {
|
||||
log::info!("completion provider has no credentials");
|
||||
return None;
|
||||
}
|
||||
|
||||
let last_message = self
|
||||
.messages(cx)
|
||||
.find(|message| message.id == last_message_id);
|
||||
|
||||
// Mutate this so that future completion requests include past preambles too.
|
||||
last_message.kind = message_kind;
|
||||
|
||||
// Compute which messages to cache, including the last one.
|
||||
self.mark_cache_anchors(&model.cache_configuration(), false, cx);
|
||||
|
||||
@@ -2315,11 +2241,11 @@ impl Context {
|
||||
let mut updated = Vec::new();
|
||||
let mut removed = Vec::new();
|
||||
for range in ranges {
|
||||
self.reparse_workflow_steps_in_range(range, &buffer, &mut updated, &mut removed, cx);
|
||||
self.reparse_patches_in_range(range, &buffer, &mut updated, &mut removed, cx);
|
||||
}
|
||||
|
||||
if !updated.is_empty() || !removed.is_empty() {
|
||||
cx.emit(ContextEvent::WorkflowStepsUpdated { removed, updated })
|
||||
cx.emit(ContextEvent::PatchesUpdated { removed, updated })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2825,6 +2751,24 @@ impl Context {
|
||||
}
|
||||
}
|
||||
|
||||
fn trimmed_text_in_range(buffer: &BufferSnapshot, range: Range<text::Anchor>) -> String {
|
||||
let mut is_start = true;
|
||||
let mut content = buffer
|
||||
.text_for_range(range)
|
||||
.map(|mut chunk| {
|
||||
if is_start {
|
||||
chunk = chunk.trim_start_matches('\n');
|
||||
if !chunk.is_empty() {
|
||||
is_start = false;
|
||||
}
|
||||
}
|
||||
chunk
|
||||
})
|
||||
.collect::<String>();
|
||||
content.truncate(content.trim_end().len());
|
||||
content
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ContextVersion {
|
||||
context: clock::Global,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use super::{MessageCacheMetadata, WorkflowStepEdit};
|
||||
use super::{AssistantEdit, MessageCacheMetadata};
|
||||
use crate::{
|
||||
assistant_panel, prompt_library, slash_command::file_command, CacheStatus, Context,
|
||||
ContextEvent, ContextId, ContextOperation, MessageId, MessageStatus, PromptBuilder,
|
||||
WorkflowStepEditKind,
|
||||
assistant_panel, prompt_library, slash_command::file_command, AssistantEditKind, CacheStatus,
|
||||
Context, ContextEvent, ContextId, ContextOperation, MessageId, MessageStatus, PromptBuilder,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use assistant_slash_command::{
|
||||
@@ -15,6 +14,7 @@ use gpui::{AppContext, Model, SharedString, Task, TestAppContext, WeakView};
|
||||
use language::{Buffer, BufferSnapshot, LanguageRegistry, LspAdapterDelegate};
|
||||
use language_model::{LanguageModelCacheConfiguration, LanguageModelRegistry, Role};
|
||||
use parking_lot::Mutex;
|
||||
use pretty_assertions::assert_eq;
|
||||
use project::Project;
|
||||
use rand::prelude::*;
|
||||
use serde_json::json;
|
||||
@@ -478,7 +478,15 @@ async fn test_slash_commands(cx: &mut TestAppContext) {
|
||||
#[gpui::test]
|
||||
async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
|
||||
cx.update(prompt_library::init);
|
||||
let settings_store = cx.update(SettingsStore::test);
|
||||
let mut settings_store = cx.update(SettingsStore::test);
|
||||
cx.update(|cx| {
|
||||
settings_store
|
||||
.set_user_settings(
|
||||
r#"{ "assistant": { "enable_experimental_live_diffs": true } }"#,
|
||||
cx,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
cx.set_global(settings_store);
|
||||
cx.update(language::init);
|
||||
cx.update(Project::init_settings);
|
||||
@@ -520,7 +528,7 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
|
||||
»",
|
||||
cx,
|
||||
);
|
||||
expect_steps(
|
||||
expect_patches(
|
||||
&context,
|
||||
"
|
||||
|
||||
@@ -539,17 +547,17 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
|
||||
one
|
||||
two
|
||||
«
|
||||
<step»",
|
||||
<patch»",
|
||||
cx,
|
||||
);
|
||||
expect_steps(
|
||||
expect_patches(
|
||||
&context,
|
||||
"
|
||||
|
||||
one
|
||||
two
|
||||
|
||||
<step",
|
||||
<patch",
|
||||
&[],
|
||||
cx,
|
||||
);
|
||||
@@ -563,36 +571,24 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
|
||||
one
|
||||
two
|
||||
|
||||
<step«>
|
||||
Add a second function
|
||||
|
||||
```rust
|
||||
fn two() {}
|
||||
```
|
||||
|
||||
<patch«>
|
||||
<edit>»",
|
||||
cx,
|
||||
);
|
||||
expect_steps(
|
||||
expect_patches(
|
||||
&context,
|
||||
"
|
||||
|
||||
one
|
||||
two
|
||||
|
||||
«<step>
|
||||
Add a second function
|
||||
|
||||
```rust
|
||||
fn two() {}
|
||||
```
|
||||
|
||||
«<patch>
|
||||
<edit>»",
|
||||
&[&[]],
|
||||
cx,
|
||||
);
|
||||
|
||||
// The full suggestion is added
|
||||
// The full patch is added
|
||||
edit(
|
||||
&context,
|
||||
"
|
||||
@@ -600,51 +596,46 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
|
||||
one
|
||||
two
|
||||
|
||||
<step>
|
||||
Add a second function
|
||||
|
||||
```rust
|
||||
fn two() {}
|
||||
```
|
||||
|
||||
<patch>
|
||||
<edit>«
|
||||
<description>add a `two` function</description>
|
||||
<path>src/lib.rs</path>
|
||||
<operation>insert_after</operation>
|
||||
<search>fn one</search>
|
||||
<description>add a `two` function</description>
|
||||
<old_text>fn one</old_text>
|
||||
<new_text>
|
||||
fn two() {}
|
||||
</new_text>
|
||||
</edit>
|
||||
</step>
|
||||
</patch>
|
||||
|
||||
also,»",
|
||||
cx,
|
||||
);
|
||||
expect_steps(
|
||||
expect_patches(
|
||||
&context,
|
||||
"
|
||||
|
||||
one
|
||||
two
|
||||
|
||||
«<step>
|
||||
Add a second function
|
||||
|
||||
```rust
|
||||
fn two() {}
|
||||
```
|
||||
|
||||
«<patch>
|
||||
<edit>
|
||||
<description>add a `two` function</description>
|
||||
<path>src/lib.rs</path>
|
||||
<operation>insert_after</operation>
|
||||
<search>fn one</search>
|
||||
<description>add a `two` function</description>
|
||||
<old_text>fn one</old_text>
|
||||
<new_text>
|
||||
fn two() {}
|
||||
</new_text>
|
||||
</edit>
|
||||
</step>»
|
||||
|
||||
</patch>
|
||||
»
|
||||
also,",
|
||||
&[&[WorkflowStepEdit {
|
||||
&[&[AssistantEdit {
|
||||
path: "src/lib.rs".into(),
|
||||
kind: WorkflowStepEditKind::InsertAfter {
|
||||
search: "fn one".into(),
|
||||
kind: AssistantEditKind::InsertAfter {
|
||||
old_text: "fn one".into(),
|
||||
new_text: "fn two() {}".into(),
|
||||
description: "add a `two` function".into(),
|
||||
},
|
||||
}]],
|
||||
@@ -659,51 +650,46 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
|
||||
one
|
||||
two
|
||||
|
||||
<step>
|
||||
Add a second function
|
||||
|
||||
```rust
|
||||
fn two() {}
|
||||
```
|
||||
|
||||
<patch>
|
||||
<edit>
|
||||
<description>add a `two` function</description>
|
||||
<path>src/lib.rs</path>
|
||||
<operation>insert_after</operation>
|
||||
<search>«fn zero»</search>
|
||||
<description>add a `two` function</description>
|
||||
<old_text>«fn zero»</old_text>
|
||||
<new_text>
|
||||
fn two() {}
|
||||
</new_text>
|
||||
</edit>
|
||||
</step>
|
||||
</patch>
|
||||
|
||||
also,",
|
||||
cx,
|
||||
);
|
||||
expect_steps(
|
||||
expect_patches(
|
||||
&context,
|
||||
"
|
||||
|
||||
one
|
||||
two
|
||||
|
||||
«<step>
|
||||
Add a second function
|
||||
|
||||
```rust
|
||||
fn two() {}
|
||||
```
|
||||
|
||||
«<patch>
|
||||
<edit>
|
||||
<description>add a `two` function</description>
|
||||
<path>src/lib.rs</path>
|
||||
<operation>insert_after</operation>
|
||||
<search>fn zero</search>
|
||||
<description>add a `two` function</description>
|
||||
<old_text>fn zero</old_text>
|
||||
<new_text>
|
||||
fn two() {}
|
||||
</new_text>
|
||||
</edit>
|
||||
</step>»
|
||||
|
||||
</patch>
|
||||
»
|
||||
also,",
|
||||
&[&[WorkflowStepEdit {
|
||||
&[&[AssistantEdit {
|
||||
path: "src/lib.rs".into(),
|
||||
kind: WorkflowStepEditKind::InsertAfter {
|
||||
search: "fn zero".into(),
|
||||
kind: AssistantEditKind::InsertAfter {
|
||||
old_text: "fn zero".into(),
|
||||
new_text: "fn two() {}".into(),
|
||||
description: "add a `two` function".into(),
|
||||
},
|
||||
}]],
|
||||
@@ -715,27 +701,24 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
|
||||
context.cycle_message_roles(HashSet::from_iter([assistant_message_id]), cx);
|
||||
context.cycle_message_roles(HashSet::from_iter([assistant_message_id]), cx);
|
||||
});
|
||||
expect_steps(
|
||||
expect_patches(
|
||||
&context,
|
||||
"
|
||||
|
||||
one
|
||||
two
|
||||
|
||||
<step>
|
||||
Add a second function
|
||||
|
||||
```rust
|
||||
fn two() {}
|
||||
```
|
||||
|
||||
<patch>
|
||||
<edit>
|
||||
<description>add a `two` function</description>
|
||||
<path>src/lib.rs</path>
|
||||
<operation>insert_after</operation>
|
||||
<search>fn zero</search>
|
||||
<description>add a `two` function</description>
|
||||
<old_text>fn zero</old_text>
|
||||
<new_text>
|
||||
fn two() {}
|
||||
</new_text>
|
||||
</edit>
|
||||
</step>
|
||||
</patch>
|
||||
|
||||
also,",
|
||||
&[],
|
||||
@@ -746,33 +729,31 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
|
||||
context.update(cx, |context, cx| {
|
||||
context.cycle_message_roles(HashSet::from_iter([assistant_message_id]), cx);
|
||||
});
|
||||
expect_steps(
|
||||
expect_patches(
|
||||
&context,
|
||||
"
|
||||
|
||||
one
|
||||
two
|
||||
|
||||
«<step>
|
||||
Add a second function
|
||||
|
||||
```rust
|
||||
fn two() {}
|
||||
```
|
||||
|
||||
«<patch>
|
||||
<edit>
|
||||
<description>add a `two` function</description>
|
||||
<path>src/lib.rs</path>
|
||||
<operation>insert_after</operation>
|
||||
<search>fn zero</search>
|
||||
<description>add a `two` function</description>
|
||||
<old_text>fn zero</old_text>
|
||||
<new_text>
|
||||
fn two() {}
|
||||
</new_text>
|
||||
</edit>
|
||||
</step>»
|
||||
|
||||
</patch>
|
||||
»
|
||||
also,",
|
||||
&[&[WorkflowStepEdit {
|
||||
&[&[AssistantEdit {
|
||||
path: "src/lib.rs".into(),
|
||||
kind: WorkflowStepEditKind::InsertAfter {
|
||||
search: "fn zero".into(),
|
||||
kind: AssistantEditKind::InsertAfter {
|
||||
old_text: "fn zero".into(),
|
||||
new_text: "fn two() {}".into(),
|
||||
description: "add a `two` function".into(),
|
||||
},
|
||||
}]],
|
||||
@@ -792,33 +773,31 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
|
||||
cx,
|
||||
)
|
||||
});
|
||||
expect_steps(
|
||||
expect_patches(
|
||||
&deserialized_context,
|
||||
"
|
||||
|
||||
one
|
||||
two
|
||||
|
||||
«<step>
|
||||
Add a second function
|
||||
|
||||
```rust
|
||||
fn two() {}
|
||||
```
|
||||
|
||||
«<patch>
|
||||
<edit>
|
||||
<description>add a `two` function</description>
|
||||
<path>src/lib.rs</path>
|
||||
<operation>insert_after</operation>
|
||||
<search>fn zero</search>
|
||||
<description>add a `two` function</description>
|
||||
<old_text>fn zero</old_text>
|
||||
<new_text>
|
||||
fn two() {}
|
||||
</new_text>
|
||||
</edit>
|
||||
</step>»
|
||||
|
||||
</patch>
|
||||
»
|
||||
also,",
|
||||
&[&[WorkflowStepEdit {
|
||||
&[&[AssistantEdit {
|
||||
path: "src/lib.rs".into(),
|
||||
kind: WorkflowStepEditKind::InsertAfter {
|
||||
search: "fn zero".into(),
|
||||
kind: AssistantEditKind::InsertAfter {
|
||||
old_text: "fn zero".into(),
|
||||
new_text: "fn two() {}".into(),
|
||||
description: "add a `two` function".into(),
|
||||
},
|
||||
}]],
|
||||
@@ -834,48 +813,58 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
|
||||
cx.executor().run_until_parked();
|
||||
}
|
||||
|
||||
fn expect_steps(
|
||||
#[track_caller]
|
||||
fn expect_patches(
|
||||
context: &Model<Context>,
|
||||
expected_marked_text: &str,
|
||||
expected_suggestions: &[&[WorkflowStepEdit]],
|
||||
expected_suggestions: &[&[AssistantEdit]],
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
context.update(cx, |context, cx| {
|
||||
let expected_marked_text = expected_marked_text.unindent();
|
||||
let (expected_text, expected_ranges) = marked_text_ranges(&expected_marked_text, false);
|
||||
let expected_marked_text = expected_marked_text.unindent();
|
||||
let (expected_text, _) = marked_text_ranges(&expected_marked_text, false);
|
||||
|
||||
let (buffer_text, ranges, patches) = context.update(cx, |context, cx| {
|
||||
context.buffer.read_with(cx, |buffer, _| {
|
||||
assert_eq!(buffer.text(), expected_text);
|
||||
let ranges = context
|
||||
.workflow_steps
|
||||
.patches
|
||||
.iter()
|
||||
.map(|entry| entry.range.to_offset(buffer))
|
||||
.collect::<Vec<_>>();
|
||||
let marked = generate_marked_text(&expected_text, &ranges, false);
|
||||
assert_eq!(
|
||||
marked,
|
||||
expected_marked_text,
|
||||
"unexpected suggestion ranges. actual: {ranges:?}, expected: {expected_ranges:?}"
|
||||
);
|
||||
let suggestions = context
|
||||
.workflow_steps
|
||||
.iter()
|
||||
.map(|step| {
|
||||
step.edits
|
||||
.iter()
|
||||
.map(|edit| {
|
||||
let edit = edit.as_ref().unwrap();
|
||||
WorkflowStepEdit {
|
||||
path: edit.path.clone(),
|
||||
kind: edit.kind.clone(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(suggestions, expected_suggestions);
|
||||
});
|
||||
(
|
||||
buffer.text(),
|
||||
ranges,
|
||||
context
|
||||
.patches
|
||||
.iter()
|
||||
.map(|step| step.edits.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
assert_eq!(buffer_text, expected_text);
|
||||
|
||||
let actual_marked_text = generate_marked_text(&expected_text, &ranges, false);
|
||||
assert_eq!(actual_marked_text, expected_marked_text);
|
||||
|
||||
assert_eq!(
|
||||
patches
|
||||
.iter()
|
||||
.map(|patch| {
|
||||
patch
|
||||
.iter()
|
||||
.map(|edit| {
|
||||
let edit = edit.as_ref().unwrap();
|
||||
AssistantEdit {
|
||||
path: edit.path.clone(),
|
||||
kind: edit.kind.clone(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
expected_suggestions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,13 +82,6 @@ pub struct InlineAssistant {
|
||||
assists: HashMap<InlineAssistId, InlineAssist>,
|
||||
assists_by_editor: HashMap<WeakView<Editor>, EditorInlineAssists>,
|
||||
assist_groups: HashMap<InlineAssistGroupId, InlineAssistGroup>,
|
||||
assist_observations: HashMap<
|
||||
InlineAssistId,
|
||||
(
|
||||
async_watch::Sender<AssistStatus>,
|
||||
async_watch::Receiver<AssistStatus>,
|
||||
),
|
||||
>,
|
||||
confirmed_assists: HashMap<InlineAssistId, Model<CodegenAlternative>>,
|
||||
prompt_history: VecDeque<String>,
|
||||
prompt_builder: Arc<PromptBuilder>,
|
||||
@@ -96,19 +89,6 @@ pub struct InlineAssistant {
|
||||
fs: Arc<dyn Fs>,
|
||||
}
|
||||
|
||||
pub enum AssistStatus {
|
||||
Idle,
|
||||
Started,
|
||||
Stopped,
|
||||
Finished,
|
||||
}
|
||||
|
||||
impl AssistStatus {
|
||||
pub fn is_done(&self) -> bool {
|
||||
matches!(self, Self::Stopped | Self::Finished)
|
||||
}
|
||||
}
|
||||
|
||||
impl Global for InlineAssistant {}
|
||||
|
||||
impl InlineAssistant {
|
||||
@@ -123,7 +103,6 @@ impl InlineAssistant {
|
||||
assists: HashMap::default(),
|
||||
assists_by_editor: HashMap::default(),
|
||||
assist_groups: HashMap::default(),
|
||||
assist_observations: HashMap::default(),
|
||||
confirmed_assists: HashMap::default(),
|
||||
prompt_history: VecDeque::default(),
|
||||
prompt_builder,
|
||||
@@ -835,17 +814,6 @@ impl InlineAssistant {
|
||||
.insert(assist_id, confirmed_alternative);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the assist from the status updates map
|
||||
self.assist_observations.remove(&assist_id);
|
||||
}
|
||||
|
||||
pub fn undo_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) -> bool {
|
||||
let Some(codegen) = self.confirmed_assists.remove(&assist_id) else {
|
||||
return false;
|
||||
};
|
||||
codegen.update(cx, |this, cx| this.undo(cx));
|
||||
true
|
||||
}
|
||||
|
||||
fn dismiss_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) -> bool {
|
||||
@@ -1039,10 +1007,6 @@ impl InlineAssistant {
|
||||
codegen.start(user_prompt, assistant_panel_context, cx)
|
||||
})
|
||||
.log_err();
|
||||
|
||||
if let Some((tx, _)) = self.assist_observations.get(&assist_id) {
|
||||
tx.send(AssistStatus::Started).ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) {
|
||||
@@ -1053,25 +1017,6 @@ impl InlineAssistant {
|
||||
};
|
||||
|
||||
assist.codegen.update(cx, |codegen, cx| codegen.stop(cx));
|
||||
|
||||
if let Some((tx, _)) = self.assist_observations.get(&assist_id) {
|
||||
tx.send(AssistStatus::Stopped).ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assist_status(&self, assist_id: InlineAssistId, cx: &AppContext) -> InlineAssistStatus {
|
||||
if let Some(assist) = self.assists.get(&assist_id) {
|
||||
match assist.codegen.read(cx).status(cx) {
|
||||
CodegenStatus::Idle => InlineAssistStatus::Idle,
|
||||
CodegenStatus::Pending => InlineAssistStatus::Pending,
|
||||
CodegenStatus::Done => InlineAssistStatus::Done,
|
||||
CodegenStatus::Error(_) => InlineAssistStatus::Error,
|
||||
}
|
||||
} else if self.confirmed_assists.contains_key(&assist_id) {
|
||||
InlineAssistStatus::Confirmed
|
||||
} else {
|
||||
InlineAssistStatus::Canceled
|
||||
}
|
||||
}
|
||||
|
||||
fn update_editor_highlights(&self, editor: &View<Editor>, cx: &mut WindowContext) {
|
||||
@@ -1257,42 +1202,6 @@ impl InlineAssistant {
|
||||
.collect();
|
||||
})
|
||||
}
|
||||
|
||||
pub fn observe_assist(
|
||||
&mut self,
|
||||
assist_id: InlineAssistId,
|
||||
) -> async_watch::Receiver<AssistStatus> {
|
||||
if let Some((_, rx)) = self.assist_observations.get(&assist_id) {
|
||||
rx.clone()
|
||||
} else {
|
||||
let (tx, rx) = async_watch::channel(AssistStatus::Idle);
|
||||
self.assist_observations.insert(assist_id, (tx, rx.clone()));
|
||||
rx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum InlineAssistStatus {
|
||||
Idle,
|
||||
Pending,
|
||||
Done,
|
||||
Error,
|
||||
Confirmed,
|
||||
Canceled,
|
||||
}
|
||||
|
||||
impl InlineAssistStatus {
|
||||
pub(crate) fn is_pending(&self) -> bool {
|
||||
matches!(self, Self::Pending)
|
||||
}
|
||||
|
||||
pub(crate) fn is_confirmed(&self) -> bool {
|
||||
matches!(self, Self::Confirmed)
|
||||
}
|
||||
|
||||
pub(crate) fn is_done(&self) -> bool {
|
||||
matches!(self, Self::Done)
|
||||
}
|
||||
}
|
||||
|
||||
struct EditorInlineAssists {
|
||||
@@ -2290,8 +2199,6 @@ impl InlineAssist {
|
||||
|
||||
if assist.decorations.is_none() {
|
||||
this.finish_assist(assist_id, false, cx);
|
||||
} else if let Some(tx) = this.assist_observations.get(&assist_id) {
|
||||
tx.0.send(AssistStatus::Finished).ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
746
crates/assistant/src/patch.rs
Normal file
746
crates/assistant/src/patch.rs
Normal file
@@ -0,0 +1,746 @@
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use collections::HashMap;
|
||||
use editor::ProposedChangesEditor;
|
||||
use futures::{future, TryFutureExt as _};
|
||||
use gpui::{AppContext, AsyncAppContext, Model, SharedString};
|
||||
use language::{AutoindentMode, Buffer, BufferSnapshot};
|
||||
use project::{Project, ProjectPath};
|
||||
use std::{cmp, ops::Range, path::Path, sync::Arc};
|
||||
use text::{AnchorRangeExt as _, Bias, OffsetRangeExt as _, Point};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct AssistantPatch {
|
||||
pub range: Range<language::Anchor>,
|
||||
pub title: SharedString,
|
||||
pub edits: Arc<[Result<AssistantEdit>]>,
|
||||
pub status: AssistantPatchStatus,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum AssistantPatchStatus {
|
||||
Pending,
|
||||
Ready,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct AssistantEdit {
|
||||
pub path: String,
|
||||
pub kind: AssistantEditKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AssistantEditKind {
|
||||
Update {
|
||||
old_text: String,
|
||||
new_text: String,
|
||||
description: String,
|
||||
},
|
||||
Create {
|
||||
new_text: String,
|
||||
description: String,
|
||||
},
|
||||
InsertBefore {
|
||||
old_text: String,
|
||||
new_text: String,
|
||||
description: String,
|
||||
},
|
||||
InsertAfter {
|
||||
old_text: String,
|
||||
new_text: String,
|
||||
description: String,
|
||||
},
|
||||
Delete {
|
||||
old_text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct ResolvedPatch {
|
||||
pub edit_groups: HashMap<Model<Buffer>, Vec<ResolvedEditGroup>>,
|
||||
pub errors: Vec<AssistantPatchResolutionError>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ResolvedEditGroup {
|
||||
pub context_range: Range<language::Anchor>,
|
||||
pub edits: Vec<ResolvedEdit>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ResolvedEdit {
|
||||
range: Range<language::Anchor>,
|
||||
new_text: String,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct AssistantPatchResolutionError {
|
||||
pub edit_ix: usize,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum SearchDirection {
|
||||
Up,
|
||||
Left,
|
||||
Diagonal,
|
||||
}
|
||||
|
||||
// A measure of the currently quality of an in-progress fuzzy search.
|
||||
//
|
||||
// Uses 60 bits to store a numeric cost, and 4 bits to store the preceding
|
||||
// operation in the search.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct SearchState {
|
||||
score: u32,
|
||||
direction: SearchDirection,
|
||||
}
|
||||
|
||||
impl SearchState {
|
||||
fn new(score: u32, direction: SearchDirection) -> Self {
|
||||
Self { score, direction }
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvedPatch {
|
||||
pub fn apply(&self, editor: &ProposedChangesEditor, cx: &mut AppContext) {
|
||||
for (buffer, groups) in &self.edit_groups {
|
||||
let branch = editor.branch_buffer_for_base(buffer).unwrap();
|
||||
Self::apply_edit_groups(groups, &branch, cx);
|
||||
}
|
||||
editor.recalculate_all_buffer_diffs();
|
||||
}
|
||||
|
||||
fn apply_edit_groups(
|
||||
groups: &Vec<ResolvedEditGroup>,
|
||||
buffer: &Model<Buffer>,
|
||||
cx: &mut AppContext,
|
||||
) {
|
||||
let mut edits = Vec::new();
|
||||
for group in groups {
|
||||
for suggestion in &group.edits {
|
||||
edits.push((suggestion.range.clone(), suggestion.new_text.clone()));
|
||||
}
|
||||
}
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit(
|
||||
edits,
|
||||
Some(AutoindentMode::Block {
|
||||
original_indent_columns: Vec::new(),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvedEdit {
|
||||
pub fn try_merge(&mut self, other: &Self, buffer: &text::BufferSnapshot) -> bool {
|
||||
let range = &self.range;
|
||||
let other_range = &other.range;
|
||||
|
||||
// Don't merge if we don't contain the other suggestion.
|
||||
if range.start.cmp(&other_range.start, buffer).is_gt()
|
||||
|| range.end.cmp(&other_range.end, buffer).is_lt()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(description) = &mut self.description {
|
||||
if let Some(other_description) = &other.description {
|
||||
description.push('\n');
|
||||
description.push_str(other_description);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl AssistantEdit {
|
||||
pub fn new(
|
||||
path: Option<String>,
|
||||
operation: Option<String>,
|
||||
old_text: Option<String>,
|
||||
new_text: Option<String>,
|
||||
description: Option<String>,
|
||||
) -> Result<Self> {
|
||||
let path = path.ok_or_else(|| anyhow!("missing path"))?;
|
||||
let operation = operation.ok_or_else(|| anyhow!("missing operation"))?;
|
||||
|
||||
let kind = match operation.as_str() {
|
||||
"update" => AssistantEditKind::Update {
|
||||
old_text: old_text.ok_or_else(|| anyhow!("missing old_text"))?,
|
||||
new_text: new_text.ok_or_else(|| anyhow!("missing new_text"))?,
|
||||
description: description.ok_or_else(|| anyhow!("missing description"))?,
|
||||
},
|
||||
"insert_before" => AssistantEditKind::InsertBefore {
|
||||
old_text: old_text.ok_or_else(|| anyhow!("missing old_text"))?,
|
||||
new_text: new_text.ok_or_else(|| anyhow!("missing new_text"))?,
|
||||
description: description.ok_or_else(|| anyhow!("missing description"))?,
|
||||
},
|
||||
"insert_after" => AssistantEditKind::InsertAfter {
|
||||
old_text: old_text.ok_or_else(|| anyhow!("missing old_text"))?,
|
||||
new_text: new_text.ok_or_else(|| anyhow!("missing new_text"))?,
|
||||
description: description.ok_or_else(|| anyhow!("missing description"))?,
|
||||
},
|
||||
"delete" => AssistantEditKind::Delete {
|
||||
old_text: old_text.ok_or_else(|| anyhow!("missing old_text"))?,
|
||||
},
|
||||
"create" => AssistantEditKind::Create {
|
||||
description: description.ok_or_else(|| anyhow!("missing description"))?,
|
||||
new_text: new_text.ok_or_else(|| anyhow!("missing new_text"))?,
|
||||
},
|
||||
_ => Err(anyhow!("unknown operation {operation:?}"))?,
|
||||
};
|
||||
|
||||
Ok(Self { path, kind })
|
||||
}
|
||||
|
||||
pub async fn resolve(
|
||||
&self,
|
||||
project: Model<Project>,
|
||||
mut cx: AsyncAppContext,
|
||||
) -> Result<(Model<Buffer>, ResolvedEdit)> {
|
||||
let path = self.path.clone();
|
||||
let kind = self.kind.clone();
|
||||
let buffer = project
|
||||
.update(&mut cx, |project, cx| {
|
||||
let project_path = project
|
||||
.find_project_path(Path::new(&path), cx)
|
||||
.or_else(|| {
|
||||
// If we couldn't find a project path for it, put it in the active worktree
|
||||
// so that when we create the buffer, it can be saved.
|
||||
let worktree = project
|
||||
.active_entry()
|
||||
.and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
|
||||
.or_else(|| project.worktrees(cx).next())?;
|
||||
let worktree = worktree.read(cx);
|
||||
|
||||
Some(ProjectPath {
|
||||
worktree_id: worktree.id(),
|
||||
path: Arc::from(Path::new(&path)),
|
||||
})
|
||||
})
|
||||
.with_context(|| format!("worktree not found for {:?}", path))?;
|
||||
anyhow::Ok(project.open_buffer(project_path, cx))
|
||||
})??
|
||||
.await?;
|
||||
|
||||
let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?;
|
||||
let suggestion = cx
|
||||
.background_executor()
|
||||
.spawn(async move { kind.resolve(&snapshot) })
|
||||
.await;
|
||||
|
||||
Ok((buffer, suggestion))
|
||||
}
|
||||
}
|
||||
|
||||
impl AssistantEditKind {
|
||||
fn resolve(self, snapshot: &BufferSnapshot) -> ResolvedEdit {
|
||||
match self {
|
||||
Self::Update {
|
||||
old_text,
|
||||
new_text,
|
||||
description,
|
||||
} => {
|
||||
let range = Self::resolve_location(&snapshot, &old_text);
|
||||
ResolvedEdit {
|
||||
range,
|
||||
new_text,
|
||||
description: Some(description),
|
||||
}
|
||||
}
|
||||
Self::Create {
|
||||
new_text,
|
||||
description,
|
||||
} => ResolvedEdit {
|
||||
range: text::Anchor::MIN..text::Anchor::MAX,
|
||||
description: Some(description),
|
||||
new_text,
|
||||
},
|
||||
Self::InsertBefore {
|
||||
old_text,
|
||||
mut new_text,
|
||||
description,
|
||||
} => {
|
||||
let range = Self::resolve_location(&snapshot, &old_text);
|
||||
new_text.push('\n');
|
||||
ResolvedEdit {
|
||||
range: range.start..range.start,
|
||||
new_text,
|
||||
description: Some(description),
|
||||
}
|
||||
}
|
||||
Self::InsertAfter {
|
||||
old_text,
|
||||
mut new_text,
|
||||
description,
|
||||
} => {
|
||||
let range = Self::resolve_location(&snapshot, &old_text);
|
||||
new_text.insert(0, '\n');
|
||||
ResolvedEdit {
|
||||
range: range.end..range.end,
|
||||
new_text,
|
||||
description: Some(description),
|
||||
}
|
||||
}
|
||||
Self::Delete { old_text } => {
|
||||
let range = Self::resolve_location(&snapshot, &old_text);
|
||||
ResolvedEdit {
|
||||
range,
|
||||
new_text: String::new(),
|
||||
description: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_location(buffer: &text::BufferSnapshot, search_query: &str) -> Range<text::Anchor> {
|
||||
const INSERTION_COST: u32 = 3;
|
||||
const WHITESPACE_INSERTION_COST: u32 = 1;
|
||||
const DELETION_COST: u32 = 3;
|
||||
const WHITESPACE_DELETION_COST: u32 = 1;
|
||||
const EQUALITY_BONUS: u32 = 5;
|
||||
|
||||
struct Matrix {
|
||||
cols: usize,
|
||||
data: Vec<SearchState>,
|
||||
}
|
||||
|
||||
impl Matrix {
|
||||
fn new(rows: usize, cols: usize) -> Self {
|
||||
Matrix {
|
||||
cols,
|
||||
data: vec![SearchState::new(0, SearchDirection::Diagonal); rows * cols],
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, row: usize, col: usize) -> SearchState {
|
||||
self.data[row * self.cols + col]
|
||||
}
|
||||
|
||||
fn set(&mut self, row: usize, col: usize, cost: SearchState) {
|
||||
self.data[row * self.cols + col] = cost;
|
||||
}
|
||||
}
|
||||
|
||||
let buffer_len = buffer.len();
|
||||
let query_len = search_query.len();
|
||||
let mut matrix = Matrix::new(query_len + 1, buffer_len + 1);
|
||||
|
||||
for (row, query_byte) in search_query.bytes().enumerate() {
|
||||
for (col, buffer_byte) in buffer.bytes_in_range(0..buffer.len()).flatten().enumerate() {
|
||||
let deletion_cost = if query_byte.is_ascii_whitespace() {
|
||||
WHITESPACE_DELETION_COST
|
||||
} else {
|
||||
DELETION_COST
|
||||
};
|
||||
let insertion_cost = if buffer_byte.is_ascii_whitespace() {
|
||||
WHITESPACE_INSERTION_COST
|
||||
} else {
|
||||
INSERTION_COST
|
||||
};
|
||||
|
||||
let up = SearchState::new(
|
||||
matrix.get(row, col + 1).score.saturating_sub(deletion_cost),
|
||||
SearchDirection::Up,
|
||||
);
|
||||
let left = SearchState::new(
|
||||
matrix
|
||||
.get(row + 1, col)
|
||||
.score
|
||||
.saturating_sub(insertion_cost),
|
||||
SearchDirection::Left,
|
||||
);
|
||||
let diagonal = SearchState::new(
|
||||
if query_byte == *buffer_byte {
|
||||
matrix.get(row, col).score.saturating_add(EQUALITY_BONUS)
|
||||
} else {
|
||||
matrix
|
||||
.get(row, col)
|
||||
.score
|
||||
.saturating_sub(deletion_cost + insertion_cost)
|
||||
},
|
||||
SearchDirection::Diagonal,
|
||||
);
|
||||
matrix.set(row + 1, col + 1, up.max(left).max(diagonal));
|
||||
}
|
||||
}
|
||||
|
||||
// Traceback to find the best match
|
||||
let mut best_buffer_end = buffer_len;
|
||||
let mut best_score = 0;
|
||||
for col in 1..=buffer_len {
|
||||
let score = matrix.get(query_len, col).score;
|
||||
if score > best_score {
|
||||
best_score = score;
|
||||
best_buffer_end = col;
|
||||
}
|
||||
}
|
||||
|
||||
let mut query_ix = query_len;
|
||||
let mut buffer_ix = best_buffer_end;
|
||||
while query_ix > 0 && buffer_ix > 0 {
|
||||
let current = matrix.get(query_ix, buffer_ix);
|
||||
match current.direction {
|
||||
SearchDirection::Diagonal => {
|
||||
query_ix -= 1;
|
||||
buffer_ix -= 1;
|
||||
}
|
||||
SearchDirection::Up => {
|
||||
query_ix -= 1;
|
||||
}
|
||||
SearchDirection::Left => {
|
||||
buffer_ix -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut start = buffer.offset_to_point(buffer.clip_offset(buffer_ix, Bias::Left));
|
||||
start.column = 0;
|
||||
let mut end = buffer.offset_to_point(buffer.clip_offset(best_buffer_end, Bias::Right));
|
||||
if end.column > 0 {
|
||||
end.column = buffer.line_len(end.row);
|
||||
}
|
||||
|
||||
buffer.anchor_after(start)..buffer.anchor_before(end)
|
||||
}
|
||||
}
|
||||
|
||||
impl AssistantPatch {
|
||||
pub(crate) async fn resolve(
|
||||
&self,
|
||||
project: Model<Project>,
|
||||
cx: &mut AsyncAppContext,
|
||||
) -> ResolvedPatch {
|
||||
let mut resolve_tasks = Vec::new();
|
||||
for (ix, edit) in self.edits.iter().enumerate() {
|
||||
if let Ok(edit) = edit.as_ref() {
|
||||
resolve_tasks.push(
|
||||
edit.resolve(project.clone(), cx.clone())
|
||||
.map_err(move |error| (ix, error)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let edits = future::join_all(resolve_tasks).await;
|
||||
let mut errors = Vec::new();
|
||||
let mut edits_by_buffer = HashMap::default();
|
||||
for entry in edits {
|
||||
match entry {
|
||||
Ok((buffer, edit)) => {
|
||||
edits_by_buffer
|
||||
.entry(buffer)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(edit);
|
||||
}
|
||||
Err((edit_ix, error)) => errors.push(AssistantPatchResolutionError {
|
||||
edit_ix,
|
||||
message: error.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Expand the context ranges of each edit and group edits with overlapping context ranges.
|
||||
let mut edit_groups_by_buffer = HashMap::default();
|
||||
for (buffer, edits) in edits_by_buffer {
|
||||
if let Ok(snapshot) = buffer.update(cx, |buffer, _| buffer.text_snapshot()) {
|
||||
edit_groups_by_buffer.insert(buffer, Self::group_edits(edits, &snapshot));
|
||||
}
|
||||
}
|
||||
|
||||
ResolvedPatch {
|
||||
edit_groups: edit_groups_by_buffer,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
fn group_edits(
|
||||
mut edits: Vec<ResolvedEdit>,
|
||||
snapshot: &text::BufferSnapshot,
|
||||
) -> Vec<ResolvedEditGroup> {
|
||||
let mut edit_groups = Vec::<ResolvedEditGroup>::new();
|
||||
// Sort edits by their range so that earlier, larger ranges come first
|
||||
edits.sort_by(|a, b| a.range.cmp(&b.range, &snapshot));
|
||||
|
||||
// Merge overlapping edits
|
||||
edits.dedup_by(|a, b| b.try_merge(a, &snapshot));
|
||||
|
||||
// Create context ranges for each edit
|
||||
for edit in edits {
|
||||
let context_range = {
|
||||
let edit_point_range = edit.range.to_point(&snapshot);
|
||||
let start_row = edit_point_range.start.row.saturating_sub(5);
|
||||
let end_row = cmp::min(edit_point_range.end.row + 5, snapshot.max_point().row);
|
||||
let start = snapshot.anchor_before(Point::new(start_row, 0));
|
||||
let end = snapshot.anchor_after(Point::new(end_row, snapshot.line_len(end_row)));
|
||||
start..end
|
||||
};
|
||||
|
||||
if let Some(last_group) = edit_groups.last_mut() {
|
||||
if last_group
|
||||
.context_range
|
||||
.end
|
||||
.cmp(&context_range.start, &snapshot)
|
||||
.is_ge()
|
||||
{
|
||||
// Merge with the previous group if context ranges overlap
|
||||
last_group.context_range.end = context_range.end;
|
||||
last_group.edits.push(edit);
|
||||
} else {
|
||||
// Create a new group
|
||||
edit_groups.push(ResolvedEditGroup {
|
||||
context_range,
|
||||
edits: vec![edit],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Create the first group
|
||||
edit_groups.push(ResolvedEditGroup {
|
||||
context_range,
|
||||
edits: vec![edit],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
edit_groups
|
||||
}
|
||||
|
||||
pub fn path_count(&self) -> usize {
|
||||
self.paths().count()
|
||||
}
|
||||
|
||||
pub fn paths(&self) -> impl '_ + Iterator<Item = &str> {
|
||||
let mut prev_path = None;
|
||||
self.edits.iter().filter_map(move |edit| {
|
||||
if let Ok(edit) = edit {
|
||||
let path = Some(edit.path.as_str());
|
||||
if path != prev_path {
|
||||
prev_path = path;
|
||||
return path;
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for AssistantPatch {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.range == other.range
|
||||
&& self.title == other.title
|
||||
&& Arc::ptr_eq(&self.edits, &other.edits)
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for AssistantPatch {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::{AppContext, Context};
|
||||
use language::{
|
||||
language_settings::AllLanguageSettings, Language, LanguageConfig, LanguageMatcher,
|
||||
};
|
||||
use settings::SettingsStore;
|
||||
use text::{OffsetRangeExt, Point};
|
||||
use ui::BorrowAppContext;
|
||||
use unindent::Unindent as _;
|
||||
|
||||
#[gpui::test]
|
||||
fn test_resolve_location(cx: &mut AppContext) {
|
||||
{
|
||||
let buffer = cx.new_model(|cx| {
|
||||
Buffer::local(
|
||||
concat!(
|
||||
" Lorem\n",
|
||||
" ipsum\n",
|
||||
" dolor sit amet\n",
|
||||
" consecteur",
|
||||
),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
assert_eq!(
|
||||
AssistantEditKind::resolve_location(&snapshot, "ipsum\ndolor").to_point(&snapshot),
|
||||
Point::new(1, 0)..Point::new(2, 18)
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let buffer = cx.new_model(|cx| {
|
||||
Buffer::local(
|
||||
concat!(
|
||||
"fn foo1(a: usize) -> usize {\n",
|
||||
" 40\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"fn foo2(b: usize) -> usize {\n",
|
||||
" 42\n",
|
||||
"}\n",
|
||||
),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
assert_eq!(
|
||||
AssistantEditKind::resolve_location(&snapshot, "fn foo1(b: usize) {\n40\n}")
|
||||
.to_point(&snapshot),
|
||||
Point::new(0, 0)..Point::new(2, 1)
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let buffer = cx.new_model(|cx| {
|
||||
Buffer::local(
|
||||
concat!(
|
||||
"fn main() {\n",
|
||||
" Foo\n",
|
||||
" .bar()\n",
|
||||
" .baz()\n",
|
||||
" .qux()\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"fn foo2(b: usize) -> usize {\n",
|
||||
" 42\n",
|
||||
"}\n",
|
||||
),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
assert_eq!(
|
||||
AssistantEditKind::resolve_location(&snapshot, "Foo.bar.baz.qux()")
|
||||
.to_point(&snapshot),
|
||||
Point::new(1, 0)..Point::new(4, 14)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_resolve_edits(cx: &mut AppContext) {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
language::init(cx);
|
||||
cx.update_global::<SettingsStore, _>(|settings, cx| {
|
||||
settings.update_user_settings::<AllLanguageSettings>(cx, |_| {});
|
||||
});
|
||||
|
||||
assert_edits(
|
||||
"
|
||||
/// A person
|
||||
struct Person {
|
||||
name: String,
|
||||
age: usize,
|
||||
}
|
||||
|
||||
/// A dog
|
||||
struct Dog {
|
||||
weight: f32,
|
||||
}
|
||||
|
||||
impl Person {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
}
|
||||
"
|
||||
.unindent(),
|
||||
vec![
|
||||
AssistantEditKind::Update {
|
||||
old_text: "
|
||||
name: String,
|
||||
"
|
||||
.unindent(),
|
||||
new_text: "
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
"
|
||||
.unindent(),
|
||||
description: "".into(),
|
||||
},
|
||||
AssistantEditKind::Update {
|
||||
old_text: "
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
"
|
||||
.unindent(),
|
||||
new_text: "
|
||||
fn name(&self) -> String {
|
||||
format!(\"{} {}\", self.first_name, self.last_name)
|
||||
}
|
||||
"
|
||||
.unindent(),
|
||||
description: "".into(),
|
||||
},
|
||||
],
|
||||
"
|
||||
/// A person
|
||||
struct Person {
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
age: usize,
|
||||
}
|
||||
|
||||
/// A dog
|
||||
struct Dog {
|
||||
weight: f32,
|
||||
}
|
||||
|
||||
impl Person {
|
||||
fn name(&self) -> String {
|
||||
format!(\"{} {}\", self.first_name, self.last_name)
|
||||
}
|
||||
}
|
||||
"
|
||||
.unindent(),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_edits(
|
||||
old_text: String,
|
||||
edits: Vec<AssistantEditKind>,
|
||||
new_text: String,
|
||||
cx: &mut AppContext,
|
||||
) {
|
||||
let buffer =
|
||||
cx.new_model(|cx| Buffer::local(old_text, cx).with_language(Arc::new(rust_lang()), cx));
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
let resolved_edits = edits
|
||||
.into_iter()
|
||||
.map(|kind| kind.resolve(&snapshot))
|
||||
.collect();
|
||||
let edit_groups = AssistantPatch::group_edits(resolved_edits, &snapshot);
|
||||
ResolvedPatch::apply_edit_groups(&edit_groups, &buffer, cx);
|
||||
let actual_new_text = buffer.read(cx).text();
|
||||
pretty_assertions::assert_eq!(actual_new_text, new_text);
|
||||
}
|
||||
|
||||
fn rust_lang() -> Language {
|
||||
Language::new(
|
||||
LanguageConfig {
|
||||
name: "Rust".into(),
|
||||
matcher: LanguageMatcher {
|
||||
path_suffixes: vec!["rs".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
Some(language::tree_sitter_rust::LANGUAGE.into()),
|
||||
)
|
||||
.with_indents_query(
|
||||
r#"
|
||||
(call_expression) @indent
|
||||
(field_expression) @indent
|
||||
(_ "(" ")" @end) @indent
|
||||
(_ "{" "}" @end) @indent
|
||||
"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
@@ -45,15 +45,6 @@ pub struct ProjectSlashCommandPromptContext {
|
||||
pub context_buffer: String,
|
||||
}
|
||||
|
||||
/// Context required to generate a workflow step resolution prompt.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct StepResolutionContext {
|
||||
/// The full context, including <step>...</step> tags
|
||||
pub workflow_context: String,
|
||||
/// The text of the specific step from the context to resolve
|
||||
pub step_to_resolve: String,
|
||||
}
|
||||
|
||||
pub struct PromptLoadingParams<'a> {
|
||||
pub fs: Arc<dyn Fs>,
|
||||
pub repo_path: Option<PathBuf>,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::create_label_for_command;
|
||||
use anyhow::{anyhow, Result};
|
||||
use assistant_slash_command::{
|
||||
AfterCompletion, ArgumentCompletion, SlashCommand, SlashCommandOutput,
|
||||
@@ -6,9 +7,9 @@ use assistant_slash_command::{
|
||||
use collections::HashMap;
|
||||
use context_servers::{
|
||||
manager::{ContextServer, ContextServerManager},
|
||||
protocol::PromptInfo,
|
||||
types::Prompt,
|
||||
};
|
||||
use gpui::{Task, WeakView, WindowContext};
|
||||
use gpui::{AppContext, Task, WeakView, WindowContext};
|
||||
use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
@@ -18,11 +19,11 @@ use workspace::Workspace;
|
||||
|
||||
pub struct ContextServerSlashCommand {
|
||||
server_id: String,
|
||||
prompt: PromptInfo,
|
||||
prompt: Prompt,
|
||||
}
|
||||
|
||||
impl ContextServerSlashCommand {
|
||||
pub fn new(server: &Arc<ContextServer>, prompt: PromptInfo) -> Self {
|
||||
pub fn new(server: &Arc<ContextServer>, prompt: Prompt) -> Self {
|
||||
Self {
|
||||
server_id: server.id.clone(),
|
||||
prompt,
|
||||
@@ -35,12 +36,28 @@ impl SlashCommand for ContextServerSlashCommand {
|
||||
self.prompt.name.clone()
|
||||
}
|
||||
|
||||
fn label(&self, cx: &AppContext) -> language::CodeLabel {
|
||||
let mut parts = vec![self.prompt.name.as_str()];
|
||||
if let Some(args) = &self.prompt.arguments {
|
||||
if let Some(arg) = args.first() {
|
||||
parts.push(arg.name.as_str());
|
||||
}
|
||||
}
|
||||
create_label_for_command(&parts[0], &parts[1..], cx)
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
format!("Run context server command: {}", self.prompt.name)
|
||||
match &self.prompt.description {
|
||||
Some(desc) => desc.clone(),
|
||||
None => format!("Run '{}' from {}", self.prompt.name, self.server_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn menu_text(&self) -> String {
|
||||
format!("Run '{}' from {}", self.prompt.name, self.server_id)
|
||||
match &self.prompt.description {
|
||||
Some(desc) => desc.clone(),
|
||||
None => format!("Run '{}' from {}", self.prompt.name, self.server_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn requires_argument(&self) -> bool {
|
||||
@@ -154,7 +171,7 @@ impl SlashCommand for ContextServerSlashCommand {
|
||||
}
|
||||
}
|
||||
|
||||
fn completion_argument(prompt: &PromptInfo, arguments: &[String]) -> Result<(String, String)> {
|
||||
fn completion_argument(prompt: &Prompt, arguments: &[String]) -> Result<(String, String)> {
|
||||
if arguments.is_empty() {
|
||||
return Err(anyhow!("No arguments given"));
|
||||
}
|
||||
@@ -170,7 +187,7 @@ fn completion_argument(prompt: &PromptInfo, arguments: &[String]) -> Result<(Str
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_arguments(prompt: &PromptInfo, arguments: &[String]) -> Result<HashMap<String, String>> {
|
||||
fn prompt_arguments(prompt: &Prompt, arguments: &[String]) -> Result<HashMap<String, String>> {
|
||||
match &prompt.arguments {
|
||||
Some(args) if args.len() > 1 => Err(anyhow!(
|
||||
"Prompt has more than one argument, which is not supported"
|
||||
@@ -199,7 +216,7 @@ fn prompt_arguments(prompt: &PromptInfo, arguments: &[String]) -> Result<HashMap
|
||||
/// MCP servers can return prompts with multiple arguments. Since we only
|
||||
/// support one argument, we ignore all others. This is the necessary predicate
|
||||
/// for this.
|
||||
pub fn acceptable_prompt(prompt: &PromptInfo) -> bool {
|
||||
pub fn acceptable_prompt(prompt: &Prompt) -> bool {
|
||||
match &prompt.arguments {
|
||||
None => true,
|
||||
Some(args) if args.len() <= 1 => true,
|
||||
|
||||
@@ -18,6 +18,8 @@ pub(crate) struct WorkflowSlashCommand {
|
||||
}
|
||||
|
||||
impl WorkflowSlashCommand {
|
||||
pub const NAME: &'static str = "workflow";
|
||||
|
||||
pub fn new(prompt_builder: Arc<PromptBuilder>) -> Self {
|
||||
Self { prompt_builder }
|
||||
}
|
||||
@@ -25,7 +27,7 @@ impl WorkflowSlashCommand {
|
||||
|
||||
impl SlashCommand for WorkflowSlashCommand {
|
||||
fn name(&self) -> String {
|
||||
"workflow".into()
|
||||
Self::NAME.into()
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
use crate::{AssistantPanel, InlineAssistId, InlineAssistant};
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use collections::HashMap;
|
||||
use editor::Editor;
|
||||
use gpui::AsyncAppContext;
|
||||
use gpui::{Model, Task, UpdateGlobal as _, View, WeakView, WindowContext};
|
||||
use language::{Buffer, BufferSnapshot};
|
||||
use project::{Project, ProjectPath};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{ops::Range, path::Path, sync::Arc};
|
||||
use text::Bias;
|
||||
use workspace::Workspace;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WorkflowStep {
|
||||
pub range: Range<language::Anchor>,
|
||||
pub leading_tags_end: text::Anchor,
|
||||
pub trailing_tag_start: Option<text::Anchor>,
|
||||
pub edits: Arc<[Result<WorkflowStepEdit>]>,
|
||||
pub resolution_task: Option<Task<()>>,
|
||||
pub resolution: Option<Arc<Result<WorkflowStepResolution>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct WorkflowStepEdit {
|
||||
pub path: String,
|
||||
pub kind: WorkflowStepEditKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct WorkflowStepResolution {
|
||||
pub title: String,
|
||||
pub suggestion_groups: HashMap<Model<Buffer>, Vec<WorkflowSuggestionGroup>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WorkflowSuggestionGroup {
|
||||
pub context_range: Range<language::Anchor>,
|
||||
pub suggestions: Vec<WorkflowSuggestion>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum WorkflowSuggestion {
|
||||
Update {
|
||||
range: Range<language::Anchor>,
|
||||
description: String,
|
||||
},
|
||||
CreateFile {
|
||||
description: String,
|
||||
},
|
||||
InsertBefore {
|
||||
position: language::Anchor,
|
||||
description: String,
|
||||
},
|
||||
InsertAfter {
|
||||
position: language::Anchor,
|
||||
description: String,
|
||||
},
|
||||
Delete {
|
||||
range: Range<language::Anchor>,
|
||||
},
|
||||
}
|
||||
|
||||
impl WorkflowSuggestion {
|
||||
pub fn range(&self) -> Range<language::Anchor> {
|
||||
match self {
|
||||
Self::Update { range, .. } => range.clone(),
|
||||
Self::CreateFile { .. } => language::Anchor::MIN..language::Anchor::MAX,
|
||||
Self::InsertBefore { position, .. } | Self::InsertAfter { position, .. } => {
|
||||
*position..*position
|
||||
}
|
||||
Self::Delete { range, .. } => range.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Update { description, .. }
|
||||
| Self::CreateFile { description }
|
||||
| Self::InsertBefore { description, .. }
|
||||
| Self::InsertAfter { description, .. } => Some(description),
|
||||
Self::Delete { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn description_mut(&mut self) -> Option<&mut String> {
|
||||
match self {
|
||||
Self::Update { description, .. }
|
||||
| Self::CreateFile { description }
|
||||
| Self::InsertBefore { description, .. }
|
||||
| Self::InsertAfter { description, .. } => Some(description),
|
||||
Self::Delete { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_merge(&mut self, other: &Self, buffer: &BufferSnapshot) -> bool {
|
||||
let range = self.range();
|
||||
let other_range = other.range();
|
||||
|
||||
// Don't merge if we don't contain the other suggestion.
|
||||
if range.start.cmp(&other_range.start, buffer).is_gt()
|
||||
|| range.end.cmp(&other_range.end, buffer).is_lt()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(description) = self.description_mut() {
|
||||
if let Some(other_description) = other.description() {
|
||||
description.push('\n');
|
||||
description.push_str(other_description);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn show(
|
||||
&self,
|
||||
editor: &View<Editor>,
|
||||
excerpt_id: editor::ExcerptId,
|
||||
workspace: &WeakView<Workspace>,
|
||||
assistant_panel: &View<AssistantPanel>,
|
||||
cx: &mut WindowContext,
|
||||
) -> Option<InlineAssistId> {
|
||||
let mut initial_transaction_id = None;
|
||||
let initial_prompt;
|
||||
let suggestion_range;
|
||||
let buffer = editor.read(cx).buffer().clone();
|
||||
let snapshot = buffer.read(cx).snapshot(cx);
|
||||
|
||||
match self {
|
||||
Self::Update {
|
||||
range, description, ..
|
||||
} => {
|
||||
initial_prompt = description.clone();
|
||||
suggestion_range = snapshot.anchor_in_excerpt(excerpt_id, range.start)?
|
||||
..snapshot.anchor_in_excerpt(excerpt_id, range.end)?;
|
||||
}
|
||||
Self::CreateFile { description } => {
|
||||
initial_prompt = description.clone();
|
||||
suggestion_range = editor::Anchor::min()..editor::Anchor::min();
|
||||
}
|
||||
Self::InsertBefore {
|
||||
position,
|
||||
description,
|
||||
..
|
||||
} => {
|
||||
let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
|
||||
initial_prompt = description.clone();
|
||||
suggestion_range = buffer.update(cx, |buffer, cx| {
|
||||
buffer.start_transaction(cx);
|
||||
let line_start = buffer.insert_empty_line(position, true, true, cx);
|
||||
initial_transaction_id = buffer.end_transaction(cx);
|
||||
buffer.refresh_preview(cx);
|
||||
|
||||
let line_start = buffer.read(cx).anchor_before(line_start);
|
||||
line_start..line_start
|
||||
});
|
||||
}
|
||||
Self::InsertAfter {
|
||||
position,
|
||||
description,
|
||||
..
|
||||
} => {
|
||||
let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
|
||||
initial_prompt = description.clone();
|
||||
suggestion_range = buffer.update(cx, |buffer, cx| {
|
||||
buffer.start_transaction(cx);
|
||||
let line_start = buffer.insert_empty_line(position, true, true, cx);
|
||||
initial_transaction_id = buffer.end_transaction(cx);
|
||||
buffer.refresh_preview(cx);
|
||||
|
||||
let line_start = buffer.read(cx).anchor_before(line_start);
|
||||
line_start..line_start
|
||||
});
|
||||
}
|
||||
Self::Delete { range, .. } => {
|
||||
initial_prompt = "Delete".to_string();
|
||||
suggestion_range = snapshot.anchor_in_excerpt(excerpt_id, range.start)?
|
||||
..snapshot.anchor_in_excerpt(excerpt_id, range.end)?;
|
||||
}
|
||||
}
|
||||
|
||||
InlineAssistant::update_global(cx, |inline_assistant, cx| {
|
||||
Some(inline_assistant.suggest_assist(
|
||||
editor,
|
||||
suggestion_range,
|
||||
initial_prompt,
|
||||
initial_transaction_id,
|
||||
false,
|
||||
Some(workspace.clone()),
|
||||
Some(assistant_panel),
|
||||
cx,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkflowStepEdit {
|
||||
pub fn new(
|
||||
path: Option<String>,
|
||||
operation: Option<String>,
|
||||
search: Option<String>,
|
||||
description: Option<String>,
|
||||
) -> Result<Self> {
|
||||
let path = path.ok_or_else(|| anyhow!("missing path"))?;
|
||||
let operation = operation.ok_or_else(|| anyhow!("missing operation"))?;
|
||||
|
||||
let kind = match operation.as_str() {
|
||||
"update" => WorkflowStepEditKind::Update {
|
||||
search: search.ok_or_else(|| anyhow!("missing search"))?,
|
||||
description: description.ok_or_else(|| anyhow!("missing description"))?,
|
||||
},
|
||||
"insert_before" => WorkflowStepEditKind::InsertBefore {
|
||||
search: search.ok_or_else(|| anyhow!("missing search"))?,
|
||||
description: description.ok_or_else(|| anyhow!("missing description"))?,
|
||||
},
|
||||
"insert_after" => WorkflowStepEditKind::InsertAfter {
|
||||
search: search.ok_or_else(|| anyhow!("missing search"))?,
|
||||
description: description.ok_or_else(|| anyhow!("missing description"))?,
|
||||
},
|
||||
"delete" => WorkflowStepEditKind::Delete {
|
||||
search: search.ok_or_else(|| anyhow!("missing search"))?,
|
||||
},
|
||||
"create" => WorkflowStepEditKind::Create {
|
||||
description: description.ok_or_else(|| anyhow!("missing description"))?,
|
||||
},
|
||||
_ => Err(anyhow!("unknown operation {operation:?}"))?,
|
||||
};
|
||||
|
||||
Ok(Self { path, kind })
|
||||
}
|
||||
|
||||
pub async fn resolve(
|
||||
&self,
|
||||
project: Model<Project>,
|
||||
mut cx: AsyncAppContext,
|
||||
) -> Result<(Model<Buffer>, super::WorkflowSuggestion)> {
|
||||
let path = self.path.clone();
|
||||
let kind = self.kind.clone();
|
||||
let buffer = project
|
||||
.update(&mut cx, |project, cx| {
|
||||
let project_path = project
|
||||
.find_project_path(Path::new(&path), cx)
|
||||
.or_else(|| {
|
||||
// If we couldn't find a project path for it, put it in the active worktree
|
||||
// so that when we create the buffer, it can be saved.
|
||||
let worktree = project
|
||||
.active_entry()
|
||||
.and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
|
||||
.or_else(|| project.worktrees(cx).next())?;
|
||||
let worktree = worktree.read(cx);
|
||||
|
||||
Some(ProjectPath {
|
||||
worktree_id: worktree.id(),
|
||||
path: Arc::from(Path::new(&path)),
|
||||
})
|
||||
})
|
||||
.with_context(|| format!("worktree not found for {:?}", path))?;
|
||||
anyhow::Ok(project.open_buffer(project_path, cx))
|
||||
})??
|
||||
.await?;
|
||||
|
||||
let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?;
|
||||
let suggestion = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
match kind {
|
||||
WorkflowStepEditKind::Update {
|
||||
search,
|
||||
description,
|
||||
} => {
|
||||
let range = Self::resolve_location(&snapshot, &search);
|
||||
WorkflowSuggestion::Update { range, description }
|
||||
}
|
||||
WorkflowStepEditKind::Create { description } => {
|
||||
WorkflowSuggestion::CreateFile { description }
|
||||
}
|
||||
WorkflowStepEditKind::InsertBefore {
|
||||
search,
|
||||
description,
|
||||
} => {
|
||||
let range = Self::resolve_location(&snapshot, &search);
|
||||
WorkflowSuggestion::InsertBefore {
|
||||
position: range.start,
|
||||
description,
|
||||
}
|
||||
}
|
||||
WorkflowStepEditKind::InsertAfter {
|
||||
search,
|
||||
description,
|
||||
} => {
|
||||
let range = Self::resolve_location(&snapshot, &search);
|
||||
WorkflowSuggestion::InsertAfter {
|
||||
position: range.end,
|
||||
description,
|
||||
}
|
||||
}
|
||||
WorkflowStepEditKind::Delete { search } => {
|
||||
let range = Self::resolve_location(&snapshot, &search);
|
||||
WorkflowSuggestion::Delete { range }
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok((buffer, suggestion))
|
||||
}
|
||||
|
||||
fn resolve_location(buffer: &text::BufferSnapshot, search_query: &str) -> Range<text::Anchor> {
|
||||
const INSERTION_SCORE: f64 = -1.0;
|
||||
const DELETION_SCORE: f64 = -1.0;
|
||||
const REPLACEMENT_SCORE: f64 = -1.0;
|
||||
const EQUALITY_SCORE: f64 = 5.0;
|
||||
|
||||
struct Matrix {
|
||||
cols: usize,
|
||||
data: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Matrix {
|
||||
fn new(rows: usize, cols: usize) -> Self {
|
||||
Matrix {
|
||||
cols,
|
||||
data: vec![0.0; rows * cols],
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, row: usize, col: usize) -> f64 {
|
||||
self.data[row * self.cols + col]
|
||||
}
|
||||
|
||||
fn set(&mut self, row: usize, col: usize, value: f64) {
|
||||
self.data[row * self.cols + col] = value;
|
||||
}
|
||||
}
|
||||
|
||||
let buffer_len = buffer.len();
|
||||
let query_len = search_query.len();
|
||||
let mut matrix = Matrix::new(query_len + 1, buffer_len + 1);
|
||||
|
||||
for (i, query_byte) in search_query.bytes().enumerate() {
|
||||
for (j, buffer_byte) in buffer.bytes_in_range(0..buffer.len()).flatten().enumerate() {
|
||||
let match_score = if query_byte == *buffer_byte {
|
||||
EQUALITY_SCORE
|
||||
} else {
|
||||
REPLACEMENT_SCORE
|
||||
};
|
||||
let up = matrix.get(i + 1, j) + DELETION_SCORE;
|
||||
let left = matrix.get(i, j + 1) + INSERTION_SCORE;
|
||||
let diagonal = matrix.get(i, j) + match_score;
|
||||
let score = up.max(left.max(diagonal)).max(0.);
|
||||
matrix.set(i + 1, j + 1, score);
|
||||
}
|
||||
}
|
||||
|
||||
// Traceback to find the best match
|
||||
let mut best_buffer_end = buffer_len;
|
||||
let mut best_score = 0.0;
|
||||
for col in 1..=buffer_len {
|
||||
let score = matrix.get(query_len, col);
|
||||
if score > best_score {
|
||||
best_score = score;
|
||||
best_buffer_end = col;
|
||||
}
|
||||
}
|
||||
|
||||
let mut query_ix = query_len;
|
||||
let mut buffer_ix = best_buffer_end;
|
||||
while query_ix > 0 && buffer_ix > 0 {
|
||||
let current = matrix.get(query_ix, buffer_ix);
|
||||
let up = matrix.get(query_ix - 1, buffer_ix);
|
||||
let left = matrix.get(query_ix, buffer_ix - 1);
|
||||
if current == left + INSERTION_SCORE {
|
||||
buffer_ix -= 1;
|
||||
} else if current == up + DELETION_SCORE {
|
||||
query_ix -= 1;
|
||||
} else {
|
||||
query_ix -= 1;
|
||||
buffer_ix -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut start = buffer.offset_to_point(buffer.clip_offset(buffer_ix, Bias::Left));
|
||||
start.column = 0;
|
||||
let mut end = buffer.offset_to_point(buffer.clip_offset(best_buffer_end, Bias::Right));
|
||||
end.column = buffer.line_len(end.row);
|
||||
|
||||
buffer.anchor_after(start)..buffer.anchor_before(end)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "operation")]
|
||||
pub enum WorkflowStepEditKind {
|
||||
/// Rewrites the specified text entirely based on the given description.
|
||||
/// This operation completely replaces the given text.
|
||||
Update {
|
||||
/// A string in the source text to apply the update to.
|
||||
search: String,
|
||||
/// A brief description of the transformation to apply to the symbol.
|
||||
description: String,
|
||||
},
|
||||
/// Creates a new file with the given path based on the provided description.
|
||||
/// This operation adds a new file to the codebase.
|
||||
Create {
|
||||
/// A brief description of the file to be created.
|
||||
description: String,
|
||||
},
|
||||
/// Inserts text before the specified text in the source file.
|
||||
InsertBefore {
|
||||
/// A string in the source text to insert text before.
|
||||
search: String,
|
||||
/// A brief description of how the new text should be generated.
|
||||
description: String,
|
||||
},
|
||||
/// Inserts text after the specified text in the source file.
|
||||
InsertAfter {
|
||||
/// A string in the source text to insert text after.
|
||||
search: String,
|
||||
/// A brief description of how the new text should be generated.
|
||||
description: String,
|
||||
},
|
||||
/// Deletes the specified symbol from the containing file.
|
||||
Delete {
|
||||
/// A string in the source text to delete.
|
||||
search: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gpui::{AppContext, Context};
|
||||
use text::{OffsetRangeExt, Point};
|
||||
|
||||
#[gpui::test]
|
||||
fn test_resolve_location(cx: &mut AppContext) {
|
||||
{
|
||||
let buffer = cx.new_model(|cx| {
|
||||
Buffer::local(
|
||||
concat!(
|
||||
" Lorem\n",
|
||||
" ipsum\n",
|
||||
" dolor sit amet\n",
|
||||
" consecteur",
|
||||
),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
assert_eq!(
|
||||
WorkflowStepEdit::resolve_location(&snapshot, "ipsum\ndolor").to_point(&snapshot),
|
||||
Point::new(1, 0)..Point::new(2, 18)
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let buffer = cx.new_model(|cx| {
|
||||
Buffer::local(
|
||||
concat!(
|
||||
"fn foo1(a: usize) -> usize {\n",
|
||||
" 42\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"fn foo2(b: usize) -> usize {\n",
|
||||
" 42\n",
|
||||
"}\n",
|
||||
),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
assert_eq!(
|
||||
WorkflowStepEdit::resolve_location(&snapshot, "fn foo1(b: usize) {\n42\n}")
|
||||
.to_point(&snapshot),
|
||||
Point::new(0, 0)..Point::new(2, 1)
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let buffer = cx.new_model(|cx| {
|
||||
Buffer::local(
|
||||
concat!(
|
||||
"fn main() {\n",
|
||||
" Foo\n",
|
||||
" .bar()\n",
|
||||
" .baz()\n",
|
||||
" .qux()\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"fn foo2(b: usize) -> usize {\n",
|
||||
" 42\n",
|
||||
"}\n",
|
||||
),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
assert_eq!(
|
||||
WorkflowStepEdit::resolve_location(&snapshot, "Foo.bar.baz.qux()")
|
||||
.to_point(&snapshot),
|
||||
Point::new(1, 0)..Point::new(4, 14)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,12 @@ spec:
|
||||
secretKeyRef:
|
||||
name: slack
|
||||
key: panics_webhook
|
||||
- name: STRIPE_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: stripe
|
||||
key: api_key
|
||||
optional: true
|
||||
- name: COMPLETE_WITH_LANGUAGE_MODEL_RATE_LIMIT_PER_HOUR
|
||||
value: "1000"
|
||||
- name: SUPERMAVEN_ADMIN_API_KEY
|
||||
|
||||
@@ -19,8 +19,8 @@ use stripe::{
|
||||
};
|
||||
use util::ResultExt;
|
||||
|
||||
use crate::llm::DEFAULT_MAX_MONTHLY_SPEND;
|
||||
use crate::rpc::ResultExt as _;
|
||||
use crate::llm::{DEFAULT_MAX_MONTHLY_SPEND, FREE_TIER_MONTHLY_SPENDING_LIMIT};
|
||||
use crate::rpc::{ResultExt as _, Server};
|
||||
use crate::{
|
||||
db::{
|
||||
billing_customer, BillingSubscriptionId, CreateBillingCustomerParams,
|
||||
@@ -50,6 +50,7 @@ pub fn router() -> Router {
|
||||
"/billing/subscriptions/manage",
|
||||
post(manage_billing_subscription),
|
||||
)
|
||||
.route("/billing/monthly_spend", get(get_monthly_spend))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -404,7 +405,7 @@ const NUMBER_OF_ALREADY_PROCESSED_PAGES_BEFORE_WE_STOP: usize = 4;
|
||||
|
||||
/// Polls the Stripe events API periodically to reconcile the records in our
|
||||
/// database with the data in Stripe.
|
||||
pub fn poll_stripe_events_periodically(app: Arc<AppState>) {
|
||||
pub fn poll_stripe_events_periodically(app: Arc<AppState>, rpc_server: Arc<Server>) {
|
||||
let Some(stripe_client) = app.stripe_client.clone() else {
|
||||
log::warn!("failed to retrieve Stripe client");
|
||||
return;
|
||||
@@ -415,7 +416,9 @@ pub fn poll_stripe_events_periodically(app: Arc<AppState>) {
|
||||
let executor = executor.clone();
|
||||
async move {
|
||||
loop {
|
||||
poll_stripe_events(&app, &stripe_client).await.log_err();
|
||||
poll_stripe_events(&app, &rpc_server, &stripe_client)
|
||||
.await
|
||||
.log_err();
|
||||
|
||||
executor.sleep(POLL_EVENTS_INTERVAL).await;
|
||||
}
|
||||
@@ -425,6 +428,7 @@ pub fn poll_stripe_events_periodically(app: Arc<AppState>) {
|
||||
|
||||
async fn poll_stripe_events(
|
||||
app: &Arc<AppState>,
|
||||
rpc_server: &Arc<Server>,
|
||||
stripe_client: &stripe::Client,
|
||||
) -> anyhow::Result<()> {
|
||||
fn event_type_to_string(event_type: EventType) -> String {
|
||||
@@ -449,29 +453,28 @@ async fn poll_stripe_events(
|
||||
let mut pages_of_already_processed_events = 0;
|
||||
let mut unprocessed_events = Vec::new();
|
||||
|
||||
log::info!(
|
||||
"Stripe events: starting retrieval for {}",
|
||||
event_types.join(", ")
|
||||
);
|
||||
let mut params = ListEvents::new();
|
||||
params.types = Some(event_types.clone());
|
||||
params.limit = Some(EVENTS_LIMIT_PER_PAGE);
|
||||
|
||||
let mut event_pages = stripe::Event::list(&stripe_client, ¶ms)
|
||||
.await?
|
||||
.paginate(params);
|
||||
|
||||
loop {
|
||||
if pages_of_already_processed_events >= NUMBER_OF_ALREADY_PROCESSED_PAGES_BEFORE_WE_STOP {
|
||||
log::info!("saw {pages_of_already_processed_events} pages of already-processed events: stopping event retrieval");
|
||||
break;
|
||||
}
|
||||
|
||||
log::info!("retrieving events from Stripe: {}", event_types.join(", "));
|
||||
|
||||
let mut params = ListEvents::new();
|
||||
params.types = Some(event_types.clone());
|
||||
params.limit = Some(EVENTS_LIMIT_PER_PAGE);
|
||||
|
||||
let events = stripe::Event::list(stripe_client, ¶ms).await?;
|
||||
|
||||
let processed_event_ids = {
|
||||
let event_ids = &events
|
||||
let event_ids = event_pages
|
||||
.page
|
||||
.data
|
||||
.iter()
|
||||
.map(|event| event.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
app.db
|
||||
.get_processed_stripe_events_by_event_ids(event_ids)
|
||||
.get_processed_stripe_events_by_event_ids(&event_ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|event| event.stripe_event_id)
|
||||
@@ -479,13 +482,13 @@ async fn poll_stripe_events(
|
||||
};
|
||||
|
||||
let mut processed_events_in_page = 0;
|
||||
let events_in_page = events.data.len();
|
||||
for event in events.data {
|
||||
let events_in_page = event_pages.page.data.len();
|
||||
for event in &event_pages.page.data {
|
||||
if processed_event_ids.contains(&event.id.to_string()) {
|
||||
processed_events_in_page += 1;
|
||||
log::debug!("Stripe event {} already processed: skipping", event.id);
|
||||
log::debug!("Stripe events: already processed '{}', skipping", event.id);
|
||||
} else {
|
||||
unprocessed_events.push(event);
|
||||
unprocessed_events.push(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,15 +496,21 @@ async fn poll_stripe_events(
|
||||
pages_of_already_processed_events += 1;
|
||||
}
|
||||
|
||||
if !events.has_more {
|
||||
if event_pages.page.has_more {
|
||||
if pages_of_already_processed_events >= NUMBER_OF_ALREADY_PROCESSED_PAGES_BEFORE_WE_STOP
|
||||
{
|
||||
log::info!("Stripe events: stopping, saw {pages_of_already_processed_events} pages of already-processed events");
|
||||
break;
|
||||
} else {
|
||||
log::info!("Stripe events: retrieving next page");
|
||||
event_pages = event_pages.next(&stripe_client).await?;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"unprocessed events from Stripe: {}",
|
||||
unprocessed_events.len()
|
||||
);
|
||||
log::info!("Stripe events: unprocessed {}", unprocessed_events.len());
|
||||
|
||||
// Sort all of the unprocessed events in ascending order, so we can handle them in the order they occurred.
|
||||
unprocessed_events.sort_by(|a, b| a.created.cmp(&b.created).then_with(|| a.id.cmp(&b.id)));
|
||||
@@ -517,12 +526,12 @@ async fn poll_stripe_events(
|
||||
// If the event has happened too far in the past, we don't want to
|
||||
// process it and risk overwriting other more-recent updates.
|
||||
//
|
||||
// 1 hour was chosen arbitrarily. This could be made longer or shorter.
|
||||
let one_hour = Duration::from_secs(60 * 60);
|
||||
let an_hour_ago = Utc::now() - one_hour;
|
||||
if an_hour_ago.timestamp() > event.created {
|
||||
// 1 day was chosen arbitrarily. This could be made longer or shorter.
|
||||
let one_day = Duration::from_secs(24 * 60 * 60);
|
||||
let a_day_ago = Utc::now() - one_day;
|
||||
if a_day_ago.timestamp() > event.created {
|
||||
log::info!(
|
||||
"Stripe event {} is more than {one_hour:?} old, marking as processed",
|
||||
"Stripe events: event '{}' is more than {one_day:?} old, marking as processed",
|
||||
event_id
|
||||
);
|
||||
app.db
|
||||
@@ -541,7 +550,7 @@ async fn poll_stripe_events(
|
||||
| EventType::CustomerSubscriptionPaused
|
||||
| EventType::CustomerSubscriptionResumed
|
||||
| EventType::CustomerSubscriptionDeleted => {
|
||||
handle_customer_subscription_event(app, stripe_client, event).await
|
||||
handle_customer_subscription_event(app, rpc_server, stripe_client, event).await
|
||||
}
|
||||
_ => Ok(()),
|
||||
};
|
||||
@@ -609,6 +618,7 @@ async fn handle_customer_event(
|
||||
|
||||
async fn handle_customer_subscription_event(
|
||||
app: &Arc<AppState>,
|
||||
rpc_server: &Arc<Server>,
|
||||
stripe_client: &stripe::Client,
|
||||
event: stripe::Event,
|
||||
) -> anyhow::Result<()> {
|
||||
@@ -654,9 +664,52 @@ async fn handle_customer_subscription_event(
|
||||
.await?;
|
||||
}
|
||||
|
||||
// When the user's subscription changes, we want to refresh their LLM tokens
|
||||
// to either grant/revoke access.
|
||||
rpc_server
|
||||
.refresh_llm_tokens_for_user(billing_customer.user_id)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GetMonthlySpendParams {
|
||||
github_user_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GetMonthlySpendResponse {
|
||||
monthly_spend_in_cents: i32,
|
||||
}
|
||||
|
||||
async fn get_monthly_spend(
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
Query(params): Query<GetMonthlySpendParams>,
|
||||
) -> Result<Json<GetMonthlySpendResponse>> {
|
||||
let user = app
|
||||
.db
|
||||
.get_user_by_github_user_id(params.github_user_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("user not found"))?;
|
||||
|
||||
let Some(llm_db) = app.llm_db.clone() else {
|
||||
return Err(Error::http(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"LLM database not available".into(),
|
||||
));
|
||||
};
|
||||
|
||||
let monthly_spend = llm_db
|
||||
.get_user_spending_for_month(user.id, Utc::now())
|
||||
.await?
|
||||
.saturating_sub(FREE_TIER_MONTHLY_SPENDING_LIMIT);
|
||||
|
||||
Ok(Json(GetMonthlySpendResponse {
|
||||
monthly_spend_in_cents: monthly_spend.0 as i32,
|
||||
}))
|
||||
}
|
||||
|
||||
impl From<SubscriptionStatus> for StripeSubscriptionStatus {
|
||||
fn from(value: SubscriptionStatus) -> Self {
|
||||
match value {
|
||||
@@ -738,6 +791,7 @@ pub fn sync_llm_usage_with_stripe_periodically(app: Arc<AppState>) {
|
||||
loop {
|
||||
sync_with_stripe(&app, &llm_db, &stripe_billing)
|
||||
.await
|
||||
.context("failed to sync LLM usage to Stripe")
|
||||
.trace_err();
|
||||
executor.sleep(SYNC_LLM_USAGE_WITH_STRIPE_INTERVAL).await;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
Copy,
|
||||
derive_more::Add,
|
||||
derive_more::AddAssign,
|
||||
derive_more::Sub,
|
||||
derive_more::SubAssign,
|
||||
)]
|
||||
pub struct Cents(pub u32);
|
||||
|
||||
|
||||
@@ -435,7 +435,7 @@ fn normalize_model_name(known_models: Vec<String>, name: String) -> String {
|
||||
|
||||
/// The maximum monthly spending an individual user can reach on the free tier
|
||||
/// before they have to pay.
|
||||
pub const FREE_TIER_MONTHLY_SPENDING_LIMIT: Cents = Cents::from_dollars(5);
|
||||
pub const FREE_TIER_MONTHLY_SPENDING_LIMIT: Cents = Cents::from_dollars(10);
|
||||
|
||||
/// The default value to use for maximum spend per month if the user did not
|
||||
/// explicitly set a maximum spend.
|
||||
@@ -443,9 +443,6 @@ pub const FREE_TIER_MONTHLY_SPENDING_LIMIT: Cents = Cents::from_dollars(5);
|
||||
/// Used to prevent surprise bills.
|
||||
pub const DEFAULT_MAX_MONTHLY_SPEND: Cents = Cents::from_dollars(10);
|
||||
|
||||
/// The maximum lifetime spending an individual user can reach before being cut off.
|
||||
const LIFETIME_SPENDING_LIMIT: Cents = Cents::from_dollars(1_000);
|
||||
|
||||
async fn check_usage_limit(
|
||||
state: &Arc<LlmState>,
|
||||
provider: LanguageModelProvider,
|
||||
@@ -472,7 +469,9 @@ async fn check_usage_limit(
|
||||
));
|
||||
}
|
||||
|
||||
if usage.spending_this_month >= Cents(claims.max_monthly_spend_in_cents) {
|
||||
if (usage.spending_this_month - FREE_TIER_MONTHLY_SPENDING_LIMIT)
|
||||
>= Cents(claims.max_monthly_spend_in_cents)
|
||||
{
|
||||
return Err(Error::Http(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Maximum spending limit reached for this month.".to_string(),
|
||||
@@ -487,14 +486,6 @@ async fn check_usage_limit(
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Remove this once we've rolled out monthly spending limits.
|
||||
if usage.lifetime_spending >= LIFETIME_SPENDING_LIMIT {
|
||||
return Err(Error::http(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Maximum spending limit reached.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let active_users = state.get_active_user_count(provider, model_name).await?;
|
||||
|
||||
let users_in_recent_minutes = active_users.users_in_recent_minutes.max(1);
|
||||
|
||||
@@ -412,7 +412,7 @@ impl LlmDatabase {
|
||||
if !is_staff
|
||||
&& spending_this_month > FREE_TIER_MONTHLY_SPENDING_LIMIT
|
||||
&& has_llm_subscription
|
||||
&& spending_this_month <= max_monthly_spend
|
||||
&& (spending_this_month - FREE_TIER_MONTHLY_SPENDING_LIMIT) <= max_monthly_spend
|
||||
{
|
||||
billing_event::ActiveModel {
|
||||
id: ActiveValue::not_set(),
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use crate::{
|
||||
db::UserId,
|
||||
llm::{
|
||||
db::{
|
||||
queries::{providers::ModelParams, usages::Usage},
|
||||
LlmDatabase, TokenUsage,
|
||||
},
|
||||
db::{queries::providers::ModelParams, LlmDatabase, TokenUsage},
|
||||
FREE_TIER_MONTHLY_SPENDING_LIMIT,
|
||||
},
|
||||
test_llm_db, Cents,
|
||||
@@ -45,24 +42,17 @@ async fn test_billing_limit_exceeded(db: &mut LlmDatabase) {
|
||||
|
||||
let user_id = UserId::from_proto(123);
|
||||
|
||||
let max_monthly_spend = Cents::from_dollars(10);
|
||||
let max_monthly_spend = Cents::from_dollars(11);
|
||||
|
||||
// Record usage that brings us close to the limit but doesn't exceed it
|
||||
// Let's say we use $9.50 worth of tokens
|
||||
let tokens_to_use = 190_000_000; // This will cost $9.50 at $0.05 per 1 million tokens
|
||||
// Let's say we use $10.50 worth of tokens
|
||||
let tokens_to_use = 210_000_000; // This will cost $10.50 at $0.05 per 1 million tokens
|
||||
let usage = TokenUsage {
|
||||
input: tokens_to_use,
|
||||
input_cache_creation: 0,
|
||||
input_cache_read: 0,
|
||||
output: 0,
|
||||
};
|
||||
let cost = Cents::new(tokens_to_use as u32 / 1_000_000 * PRICE_PER_MILLION_INPUT_TOKENS as u32);
|
||||
|
||||
assert_eq!(
|
||||
cost,
|
||||
Cents::new(950),
|
||||
"expected the cost to be $9.50, based on the inputs, but it wasn't"
|
||||
);
|
||||
|
||||
// Verify that before we record any usage, there are 0 billing events
|
||||
let billing_events = db.get_billing_events().await.unwrap();
|
||||
@@ -83,29 +73,9 @@ async fn test_billing_limit_exceeded(db: &mut LlmDatabase) {
|
||||
|
||||
// Verify the recorded usage and spending
|
||||
let recorded_usage = db.get_usage(user_id, provider, model, now).await.unwrap();
|
||||
|
||||
// Verify that we exceeded the free tier usage
|
||||
assert!(
|
||||
recorded_usage.spending_this_month > FREE_TIER_MONTHLY_SPENDING_LIMIT,
|
||||
"Expected spending to exceed free tier limit"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
recorded_usage,
|
||||
Usage {
|
||||
requests_this_minute: 1,
|
||||
tokens_this_minute: tokens_to_use,
|
||||
tokens_this_day: tokens_to_use,
|
||||
tokens_this_month: TokenUsage {
|
||||
input: tokens_to_use,
|
||||
input_cache_creation: 0,
|
||||
input_cache_read: 0,
|
||||
output: 0,
|
||||
},
|
||||
spending_this_month: Cents::new(950),
|
||||
lifetime_spending: Cents::new(950),
|
||||
}
|
||||
);
|
||||
assert_eq!(recorded_usage.spending_this_month, Cents::new(1050));
|
||||
assert!(recorded_usage.spending_this_month > FREE_TIER_MONTHLY_SPENDING_LIMIT);
|
||||
|
||||
// Verify that there is one `billing_event` record
|
||||
let billing_events = db.get_billing_events().await.unwrap();
|
||||
@@ -118,7 +88,35 @@ async fn test_billing_limit_exceeded(db: &mut LlmDatabase) {
|
||||
assert_eq!(billing_event.input_cache_read_tokens, 0);
|
||||
assert_eq!(billing_event.output_tokens, 0);
|
||||
|
||||
let tokens_to_exceed = 20_000_000; // This will cost $1.00 more, pushing us from $9.50 to $10.50, which is over the $10 monthly maximum limit
|
||||
// Record usage that puts us at $20.50
|
||||
let usage_2 = TokenUsage {
|
||||
input: 200_000_000, // This will cost $10 more, pushing us from $10.50 to $20.50,
|
||||
input_cache_creation: 0,
|
||||
input_cache_read: 0,
|
||||
output: 0,
|
||||
};
|
||||
db.record_usage(
|
||||
user_id,
|
||||
false,
|
||||
provider,
|
||||
model,
|
||||
usage_2,
|
||||
true,
|
||||
max_monthly_spend,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify the updated usage and spending
|
||||
let updated_usage = db.get_usage(user_id, provider, model, now).await.unwrap();
|
||||
assert_eq!(updated_usage.spending_this_month, Cents::new(2050));
|
||||
|
||||
// Verify that there are now two billing events
|
||||
let billing_events = db.get_billing_events().await.unwrap();
|
||||
assert_eq!(billing_events.len(), 2);
|
||||
|
||||
let tokens_to_exceed = 20_000_000; // This will cost $1.00 more, pushing us from $20.50 to $21.50, which is over the $11 monthly maximum limit
|
||||
let usage_exceeding = TokenUsage {
|
||||
input: tokens_to_exceed,
|
||||
input_cache_creation: 0,
|
||||
@@ -139,27 +137,12 @@ async fn test_billing_limit_exceeded(db: &mut LlmDatabase) {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify that there is still one billing record
|
||||
let billing_events = db.get_billing_events().await.unwrap();
|
||||
assert_eq!(billing_events.len(), 1);
|
||||
|
||||
// Verify the updated usage and spending
|
||||
let updated_usage = db.get_usage(user_id, provider, model, now).await.unwrap();
|
||||
assert_eq!(
|
||||
updated_usage,
|
||||
Usage {
|
||||
requests_this_minute: 2,
|
||||
tokens_this_minute: tokens_to_use + tokens_to_exceed,
|
||||
tokens_this_day: tokens_to_use + tokens_to_exceed,
|
||||
tokens_this_month: TokenUsage {
|
||||
input: tokens_to_use + tokens_to_exceed,
|
||||
input_cache_creation: 0,
|
||||
input_cache_read: 0,
|
||||
output: 0,
|
||||
},
|
||||
spending_this_month: Cents::new(1050),
|
||||
lifetime_spending: Cents::new(1050),
|
||||
}
|
||||
);
|
||||
assert_eq!(updated_usage.spending_this_month, Cents::new(2150));
|
||||
|
||||
// Verify that we never exceed the user max spending for the user
|
||||
// and avoid charging them.
|
||||
let billing_events = db.get_billing_events().await.unwrap();
|
||||
assert_eq!(billing_events.len(), 2);
|
||||
}
|
||||
|
||||
@@ -132,6 +132,8 @@ async fn main() -> Result<()> {
|
||||
let rpc_server = collab::rpc::Server::new(epoch, state.clone());
|
||||
rpc_server.start().await?;
|
||||
|
||||
poll_stripe_events_periodically(state.clone(), rpc_server.clone());
|
||||
|
||||
app = app
|
||||
.merge(collab::api::routes(rpc_server.clone()))
|
||||
.merge(collab::rpc::routes(rpc_server.clone()));
|
||||
@@ -140,7 +142,6 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
if mode.is_api() {
|
||||
poll_stripe_events_periodically(state.clone());
|
||||
fetch_extensions_from_blob_store_periodically(state.clone());
|
||||
spawn_user_backfiller(state.clone());
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use crate::{llm, Cents, Result};
|
||||
use anyhow::Context;
|
||||
use chrono::Utc;
|
||||
use chrono::{Datelike, Utc};
|
||||
use collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
@@ -45,7 +45,13 @@ impl StripeBilling {
|
||||
|
||||
let (meters, prices) = futures::try_join!(
|
||||
StripeMeter::list(&self.client),
|
||||
stripe::Price::list(&self.client, &stripe::ListPrices::default())
|
||||
stripe::Price::list(
|
||||
&self.client,
|
||||
&stripe::ListPrices {
|
||||
limit: Some(100),
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
)?;
|
||||
|
||||
for meter in meters.data {
|
||||
@@ -343,10 +349,20 @@ impl StripeBilling {
|
||||
model: &StripeModel,
|
||||
success_url: &str,
|
||||
) -> Result<String> {
|
||||
let first_of_next_month = Utc::now()
|
||||
.checked_add_months(chrono::Months::new(1))
|
||||
.unwrap()
|
||||
.with_day(1)
|
||||
.unwrap();
|
||||
|
||||
let mut params = stripe::CreateCheckoutSession::new();
|
||||
params.mode = Some(stripe::CheckoutSessionMode::Subscription);
|
||||
params.customer = Some(customer_id);
|
||||
params.client_reference_id = Some(github_login);
|
||||
params.subscription_data = Some(stripe::CreateCheckoutSessionSubscriptionData {
|
||||
billing_cycle_anchor: Some(first_of_next_month.timestamp()),
|
||||
..Default::default()
|
||||
});
|
||||
params.line_items = Some(
|
||||
[
|
||||
&model.input_tokens_price.id,
|
||||
@@ -396,9 +412,12 @@ impl StripeMeter {
|
||||
|
||||
pub fn list(client: &stripe::Client) -> stripe::Response<stripe::List<Self>> {
|
||||
#[derive(Serialize)]
|
||||
struct Params {}
|
||||
struct Params {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
limit: Option<u64>,
|
||||
}
|
||||
|
||||
client.get_query("/billing/meters", Params {})
|
||||
client.get_query("/billing/meters", Params { limit: Some(100) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ use language::{
|
||||
use live_kit_client::MacOSDisplay;
|
||||
use lsp::LanguageServerId;
|
||||
use parking_lot::Mutex;
|
||||
use project::lsp_store::FormatTarget;
|
||||
use project::{
|
||||
lsp_store::FormatTrigger, search::SearchQuery, search::SearchResult, DiagnosticSummary,
|
||||
HoverBlockKind, Project, ProjectPath,
|
||||
@@ -4417,6 +4418,7 @@ async fn test_formatting_buffer(
|
||||
HashSet::from_iter([buffer_b.clone()]),
|
||||
true,
|
||||
FormatTrigger::Save,
|
||||
FormatTarget::Buffer,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
@@ -4450,6 +4452,7 @@ async fn test_formatting_buffer(
|
||||
HashSet::from_iter([buffer_b.clone()]),
|
||||
true,
|
||||
FormatTrigger::Save,
|
||||
FormatTarget::Buffer,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
@@ -4555,6 +4558,7 @@ async fn test_prettier_formatting_buffer(
|
||||
HashSet::from_iter([buffer_b.clone()]),
|
||||
true,
|
||||
FormatTrigger::Save,
|
||||
FormatTarget::Buffer,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
@@ -4574,6 +4578,7 @@ async fn test_prettier_formatting_buffer(
|
||||
HashSet::from_iter([buffer_a.clone()]),
|
||||
true,
|
||||
FormatTrigger::Manual,
|
||||
FormatTarget::Buffer,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ const JSON_RPC_VERSION: &str = "2.0";
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
type ResponseHandler = Box<dyn Send + FnOnce(Result<String, Error>)>;
|
||||
type NotificationHandler = Box<dyn Send + FnMut(RequestId, Value, AsyncAppContext)>;
|
||||
type NotificationHandler = Box<dyn Send + FnMut(Value, AsyncAppContext)>;
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
@@ -94,7 +94,6 @@ enum CspResult<T> {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Notification<'a, T> {
|
||||
jsonrpc: &'static str,
|
||||
id: RequestId,
|
||||
#[serde(borrow)]
|
||||
method: &'a str,
|
||||
params: T,
|
||||
@@ -103,7 +102,6 @@ struct Notification<'a, T> {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct AnyNotification<'a> {
|
||||
jsonrpc: &'a str,
|
||||
id: RequestId,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Option<Value>,
|
||||
@@ -246,11 +244,7 @@ impl Client {
|
||||
if let Some(handler) =
|
||||
notification_handlers.get_mut(notification.method.as_str())
|
||||
{
|
||||
handler(
|
||||
notification.id,
|
||||
notification.params.unwrap_or(Value::Null),
|
||||
cx.clone(),
|
||||
);
|
||||
handler(notification.params.unwrap_or(Value::Null), cx.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,10 +372,8 @@ impl Client {
|
||||
/// Sends a notification to the context server without expecting a response.
|
||||
/// This function serializes the notification and sends it through the outbound channel.
|
||||
pub fn notify(&self, method: &str, params: impl Serialize) -> Result<()> {
|
||||
let id = self.next_id.fetch_add(1, SeqCst);
|
||||
let notification = serde_json::to_string(&Notification {
|
||||
jsonrpc: JSON_RPC_VERSION,
|
||||
id: RequestId::Int(id),
|
||||
method,
|
||||
params,
|
||||
})
|
||||
@@ -390,13 +382,13 @@ impl Client {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn on_notification<F>(&self, method: &'static str, mut f: F)
|
||||
pub fn on_notification<F>(&self, method: &'static str, f: F)
|
||||
where
|
||||
F: 'static + Send + FnMut(Value, AsyncAppContext),
|
||||
{
|
||||
self.notification_handlers
|
||||
.lock()
|
||||
.insert(method, Box::new(move |_, params, cx| f(params, cx)));
|
||||
.insert(method, Box::new(f));
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
|
||||
@@ -85,7 +85,7 @@ impl ContextServer {
|
||||
)?;
|
||||
|
||||
let protocol = crate::protocol::ModelContextProtocol::new(client);
|
||||
let client_info = types::EntityInfo {
|
||||
let client_info = types::Implementation {
|
||||
name: "Zed".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
};
|
||||
|
||||
@@ -11,8 +11,6 @@ use collections::HashMap;
|
||||
use crate::client::Client;
|
||||
use crate::types;
|
||||
|
||||
pub use types::PromptInfo;
|
||||
|
||||
const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
pub struct ModelContextProtocol {
|
||||
@@ -26,7 +24,7 @@ impl ModelContextProtocol {
|
||||
|
||||
pub async fn initialize(
|
||||
self,
|
||||
client_info: types::EntityInfo,
|
||||
client_info: types::Implementation,
|
||||
) -> Result<InitializedContextServerProtocol> {
|
||||
let params = types::InitializeParams {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
@@ -96,7 +94,7 @@ impl InitializedContextServerProtocol {
|
||||
}
|
||||
|
||||
/// List the MCP prompts.
|
||||
pub async fn list_prompts(&self) -> Result<Vec<types::PromptInfo>> {
|
||||
pub async fn list_prompts(&self) -> Result<Vec<types::Prompt>> {
|
||||
self.check_capability(ServerCapability::Prompts)?;
|
||||
|
||||
let response: types::PromptsListResponse = self
|
||||
@@ -107,6 +105,18 @@ impl InitializedContextServerProtocol {
|
||||
Ok(response.prompts)
|
||||
}
|
||||
|
||||
/// List the MCP resources.
|
||||
pub async fn list_resources(&self) -> Result<types::ResourcesListResponse> {
|
||||
self.check_capability(ServerCapability::Resources)?;
|
||||
|
||||
let response: types::ResourcesListResponse = self
|
||||
.inner
|
||||
.request(types::RequestType::ResourcesList.as_str(), ())
|
||||
.await?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Executes a prompt with the given arguments and returns the result.
|
||||
pub async fn run_prompt<P: AsRef<str>>(
|
||||
&self,
|
||||
|
||||
@@ -15,6 +15,7 @@ pub enum RequestType {
|
||||
PromptsGet,
|
||||
PromptsList,
|
||||
CompletionComplete,
|
||||
Ping,
|
||||
}
|
||||
|
||||
impl RequestType {
|
||||
@@ -30,6 +31,7 @@ impl RequestType {
|
||||
RequestType::PromptsGet => "prompts/get",
|
||||
RequestType::PromptsList => "prompts/list",
|
||||
RequestType::CompletionComplete => "completion/complete",
|
||||
RequestType::Ping => "ping",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,14 +41,15 @@ impl RequestType {
|
||||
pub struct InitializeParams {
|
||||
pub protocol_version: u32,
|
||||
pub capabilities: ClientCapabilities,
|
||||
pub client_info: EntityInfo,
|
||||
pub client_info: Implementation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallToolParams {
|
||||
pub name: String,
|
||||
pub arguments: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -77,6 +80,7 @@ pub struct LoggingSetLevelParams {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptsGetParams {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
@@ -101,6 +105,13 @@ pub struct PromptReference {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourceReference {
|
||||
pub r#type: PromptReferenceType,
|
||||
pub uri: Url,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PromptReferenceType {
|
||||
@@ -110,13 +121,6 @@ pub enum PromptReferenceType {
|
||||
Resource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourceReference {
|
||||
pub r#type: String,
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompletionArgument {
|
||||
@@ -129,7 +133,7 @@ pub struct CompletionArgument {
|
||||
pub struct InitializeResponse {
|
||||
pub protocol_version: u32,
|
||||
pub capabilities: ServerCapabilities,
|
||||
pub server_info: EntityInfo,
|
||||
pub server_info: Implementation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -141,13 +145,39 @@ pub struct ResourcesReadResponse {
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourcesListResponse {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource_templates: Option<Vec<ResourceTemplate>>,
|
||||
pub resources: Vec<Resource>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resources: Option<Vec<Resource>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SamplingMessage {
|
||||
pub role: SamplingRole,
|
||||
pub content: SamplingContent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SamplingRole {
|
||||
User,
|
||||
Assistant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum SamplingContent {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "image")]
|
||||
Image { data: String, mime_type: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptsGetResponse {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub prompt: String,
|
||||
}
|
||||
@@ -155,7 +185,7 @@ pub struct PromptsGetResponse {
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptsListResponse {
|
||||
pub prompts: Vec<PromptInfo>,
|
||||
pub prompts: Vec<Prompt>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -168,61 +198,91 @@ pub struct CompletionCompleteResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompletionResult {
|
||||
pub values: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub has_more: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptInfo {
|
||||
pub struct Prompt {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<Vec<PromptArgument>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptArgument {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub required: Option<bool>,
|
||||
}
|
||||
|
||||
// Shared Types
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experimental: Option<HashMap<String, serde_json::Value>>,
|
||||
pub sampling: Option<HashMap<String, serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sampling: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experimental: Option<HashMap<String, serde_json::Value>>,
|
||||
pub logging: Option<HashMap<String, serde_json::Value>>,
|
||||
pub prompts: Option<HashMap<String, serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub logging: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompts: Option<PromptsCapabilities>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resources: Option<ResourcesCapabilities>,
|
||||
pub tools: Option<HashMap<String, serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<ToolsCapabilities>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PromptsCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub list_changed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourcesCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subscribe: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub list_changed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ToolsCapabilities {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub list_changed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Tool {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EntityInfo {
|
||||
pub struct Implementation {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
@@ -231,6 +291,10 @@ pub struct EntityInfo {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Resource {
|
||||
pub uri: Url,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
@@ -238,17 +302,23 @@ pub struct Resource {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourceContent {
|
||||
pub uri: Url,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
pub data: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blob: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResourceTemplate {
|
||||
pub uri_template: String,
|
||||
pub name: Option<String>,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -260,13 +330,16 @@ pub enum LoggingLevel {
|
||||
Error,
|
||||
}
|
||||
|
||||
// Client Notifications
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum NotificationType {
|
||||
Initialized,
|
||||
Progress,
|
||||
Message,
|
||||
ResourcesUpdated,
|
||||
ResourcesListChanged,
|
||||
ToolsListChanged,
|
||||
PromptsListChanged,
|
||||
}
|
||||
|
||||
impl NotificationType {
|
||||
@@ -274,6 +347,11 @@ impl NotificationType {
|
||||
match self {
|
||||
NotificationType::Initialized => "notifications/initialized",
|
||||
NotificationType::Progress => "notifications/progress",
|
||||
NotificationType::Message => "notifications/message",
|
||||
NotificationType::ResourcesUpdated => "notifications/resources/updated",
|
||||
NotificationType::ResourcesListChanged => "notifications/resources/list_changed",
|
||||
NotificationType::ToolsListChanged => "notifications/tools/list_changed",
|
||||
NotificationType::PromptsListChanged => "notifications/prompts/list_changed",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,12 +366,13 @@ pub enum ClientNotification {
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProgressParams {
|
||||
pub progress_token: String,
|
||||
pub progress_token: ProgressToken,
|
||||
pub progress: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total: Option<f64>,
|
||||
}
|
||||
|
||||
// Helper Types that don't map directly to the protocol
|
||||
pub type ProgressToken = String;
|
||||
|
||||
pub enum CompletionTotal {
|
||||
Exact(u32),
|
||||
|
||||
@@ -237,6 +237,7 @@ gpui::actions!(
|
||||
ToggleFold,
|
||||
ToggleFoldRecursive,
|
||||
Format,
|
||||
FormatSelections,
|
||||
GoToDeclaration,
|
||||
GoToDeclarationSplit,
|
||||
GoToDefinition,
|
||||
|
||||
@@ -48,7 +48,6 @@ mod signature_help;
|
||||
pub mod test;
|
||||
|
||||
use ::git::diff::DiffHunkStatus;
|
||||
use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry};
|
||||
pub(crate) use actions::*;
|
||||
use aho_corasick::AhoCorasick;
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
@@ -96,10 +95,12 @@ use language::{
|
||||
CursorShape, Diagnostic, Documentation, IndentKind, IndentSize, Language, OffsetRangeExt,
|
||||
Point, Selection, SelectionGoal, TransactionId,
|
||||
};
|
||||
use language::{point_to_lsp, BufferRow, CharClassifier, Runnable, RunnableRange};
|
||||
use language::{
|
||||
point_to_lsp, BufferRow, CharClassifier, LanguageServerName, Runnable, RunnableRange,
|
||||
};
|
||||
use linked_editing_ranges::refresh_linked_ranges;
|
||||
pub use proposed_changes_editor::{
|
||||
ProposedChangesBuffer, ProposedChangesEditor, ProposedChangesEditorToolbar,
|
||||
ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar,
|
||||
};
|
||||
use similar::{ChangeTag, TextDiff};
|
||||
use task::{ResolvedTask, TaskTemplate, TaskVariables};
|
||||
@@ -122,7 +123,7 @@ use multi_buffer::{
|
||||
use ordered_float::OrderedFloat;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use project::{
|
||||
lsp_store::FormatTrigger,
|
||||
lsp_store::{FormatTarget, FormatTrigger},
|
||||
project_settings::{GitGutterSetting, ProjectSettings},
|
||||
CodeAction, Completion, CompletionIntent, DocumentHighlight, InlayHint, Item, Location,
|
||||
LocationLink, Project, ProjectPath, ProjectTransaction, TaskSourceKind,
|
||||
@@ -9893,21 +9894,19 @@ impl Editor {
|
||||
&self,
|
||||
lsp_location: lsp::Location,
|
||||
server_id: LanguageServerId,
|
||||
cx: &mut ViewContext<Editor>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Task<anyhow::Result<Option<Location>>> {
|
||||
let Some(project) = self.project.clone() else {
|
||||
return Task::Ready(Some(Ok(None)));
|
||||
};
|
||||
|
||||
cx.spawn(move |editor, mut cx| async move {
|
||||
let location_task = editor.update(&mut cx, |editor, cx| {
|
||||
let location_task = editor.update(&mut cx, |_, cx| {
|
||||
project.update(cx, |project, cx| {
|
||||
let language_server_name =
|
||||
editor.buffer.read(cx).as_singleton().and_then(|buffer| {
|
||||
project
|
||||
.language_server_for_buffer(buffer.read(cx), server_id, cx)
|
||||
.map(|(lsp_adapter, _)| lsp_adapter.name.clone())
|
||||
});
|
||||
let language_server_name = project
|
||||
.language_server_statuses(cx)
|
||||
.find(|(id, _)| server_id == *id)
|
||||
.map(|(_, status)| LanguageServerName::from(status.name.as_str()));
|
||||
language_server_name.map(|language_server_name| {
|
||||
project.open_local_buffer_via_lsp(
|
||||
lsp_location.uri.clone(),
|
||||
@@ -10386,13 +10385,39 @@ impl Editor {
|
||||
None => return None,
|
||||
};
|
||||
|
||||
Some(self.perform_format(project, FormatTrigger::Manual, cx))
|
||||
Some(self.perform_format(project, FormatTrigger::Manual, FormatTarget::Buffer, cx))
|
||||
}
|
||||
|
||||
fn format_selections(
|
||||
&mut self,
|
||||
_: &FormatSelections,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Option<Task<Result<()>>> {
|
||||
let project = match &self.project {
|
||||
Some(project) => project.clone(),
|
||||
None => return None,
|
||||
};
|
||||
|
||||
let selections = self
|
||||
.selections
|
||||
.all_adjusted(cx)
|
||||
.into_iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect_vec();
|
||||
|
||||
Some(self.perform_format(
|
||||
project,
|
||||
FormatTrigger::Manual,
|
||||
FormatTarget::Ranges(selections),
|
||||
cx,
|
||||
))
|
||||
}
|
||||
|
||||
fn perform_format(
|
||||
&mut self,
|
||||
project: Model<Project>,
|
||||
trigger: FormatTrigger,
|
||||
target: FormatTarget,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let buffer = self.buffer().clone();
|
||||
@@ -10402,7 +10427,9 @@ impl Editor {
|
||||
}
|
||||
|
||||
let mut timeout = cx.background_executor().timer(FORMAT_TIMEOUT).fuse();
|
||||
let format = project.update(cx, |project, cx| project.format(buffers, true, trigger, cx));
|
||||
let format = project.update(cx, |project, cx| {
|
||||
project.format(buffers, true, trigger, target, cx)
|
||||
});
|
||||
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
let transaction = futures::select_biased! {
|
||||
@@ -11460,11 +11487,8 @@ impl Editor {
|
||||
snapshot.line_len(buffer_row) == 0
|
||||
}
|
||||
|
||||
fn get_permalink_to_line(&mut self, cx: &mut ViewContext<Self>) -> Result<url::Url> {
|
||||
let (path, selection, repo) = maybe!({
|
||||
let project_handle = self.project.as_ref()?.clone();
|
||||
let project = project_handle.read(cx);
|
||||
|
||||
fn get_permalink_to_line(&mut self, cx: &mut ViewContext<Self>) -> Task<Result<url::Url>> {
|
||||
let buffer_and_selection = maybe!({
|
||||
let selection = self.selections.newest::<Point>(cx);
|
||||
let selection_range = selection.range();
|
||||
|
||||
@@ -11488,64 +11512,58 @@ impl Editor {
|
||||
(buffer.clone(), selection)
|
||||
};
|
||||
|
||||
let path = buffer
|
||||
.read(cx)
|
||||
.file()?
|
||||
.as_local()?
|
||||
.path()
|
||||
.to_str()?
|
||||
.to_string();
|
||||
let repo = project.get_repo(&buffer.read(cx).project_path(cx)?, cx)?;
|
||||
Some((path, selection, repo))
|
||||
Some((buffer, selection))
|
||||
});
|
||||
|
||||
let Some((buffer, selection)) = buffer_and_selection else {
|
||||
return Task::ready(Err(anyhow!("failed to determine buffer and selection")));
|
||||
};
|
||||
|
||||
let Some(project) = self.project.as_ref() else {
|
||||
return Task::ready(Err(anyhow!("editor does not have project")));
|
||||
};
|
||||
|
||||
project.update(cx, |project, cx| {
|
||||
project.get_permalink_to_line(&buffer, selection, cx)
|
||||
})
|
||||
.ok_or_else(|| anyhow!("unable to open git repository"))?;
|
||||
|
||||
const REMOTE_NAME: &str = "origin";
|
||||
let origin_url = repo
|
||||
.remote_url(REMOTE_NAME)
|
||||
.ok_or_else(|| anyhow!("remote \"{REMOTE_NAME}\" not found"))?;
|
||||
let sha = repo
|
||||
.head_sha()
|
||||
.ok_or_else(|| anyhow!("failed to read HEAD SHA"))?;
|
||||
|
||||
let (provider, remote) =
|
||||
parse_git_remote_url(GitHostingProviderRegistry::default_global(cx), &origin_url)
|
||||
.ok_or_else(|| anyhow!("failed to parse Git remote URL"))?;
|
||||
|
||||
Ok(provider.build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams {
|
||||
sha: &sha,
|
||||
path: &path,
|
||||
selection: Some(selection),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn copy_permalink_to_line(&mut self, _: &CopyPermalinkToLine, cx: &mut ViewContext<Self>) {
|
||||
let permalink = self.get_permalink_to_line(cx);
|
||||
let permalink_task = self.get_permalink_to_line(cx);
|
||||
let workspace = self.workspace();
|
||||
|
||||
match permalink {
|
||||
Ok(permalink) => {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(permalink.to_string()));
|
||||
}
|
||||
Err(err) => {
|
||||
let message = format!("Failed to copy permalink: {err}");
|
||||
|
||||
Err::<(), anyhow::Error>(err).log_err();
|
||||
|
||||
if let Some(workspace) = self.workspace() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
struct CopyPermalinkToLine;
|
||||
|
||||
workspace.show_toast(
|
||||
Toast::new(NotificationId::unique::<CopyPermalinkToLine>(), message),
|
||||
cx,
|
||||
)
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
match permalink_task.await {
|
||||
Ok(permalink) => {
|
||||
cx.update(|cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(permalink.to_string()));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(err) => {
|
||||
let message = format!("Failed to copy permalink: {err}");
|
||||
|
||||
Err::<(), anyhow::Error>(err).log_err();
|
||||
|
||||
if let Some(workspace) = workspace {
|
||||
workspace
|
||||
.update(&mut cx, |workspace, cx| {
|
||||
struct CopyPermalinkToLine;
|
||||
|
||||
workspace.show_toast(
|
||||
Toast::new(
|
||||
NotificationId::unique::<CopyPermalinkToLine>(),
|
||||
message,
|
||||
),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn copy_file_location(&mut self, _: &CopyFileLocation, cx: &mut ViewContext<Self>) {
|
||||
@@ -11558,29 +11576,41 @@ impl Editor {
|
||||
}
|
||||
|
||||
pub fn open_permalink_to_line(&mut self, _: &OpenPermalinkToLine, cx: &mut ViewContext<Self>) {
|
||||
let permalink = self.get_permalink_to_line(cx);
|
||||
let permalink_task = self.get_permalink_to_line(cx);
|
||||
let workspace = self.workspace();
|
||||
|
||||
match permalink {
|
||||
Ok(permalink) => {
|
||||
cx.open_url(permalink.as_ref());
|
||||
}
|
||||
Err(err) => {
|
||||
let message = format!("Failed to open permalink: {err}");
|
||||
|
||||
Err::<(), anyhow::Error>(err).log_err();
|
||||
|
||||
if let Some(workspace) = self.workspace() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
struct OpenPermalinkToLine;
|
||||
|
||||
workspace.show_toast(
|
||||
Toast::new(NotificationId::unique::<OpenPermalinkToLine>(), message),
|
||||
cx,
|
||||
)
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
match permalink_task.await {
|
||||
Ok(permalink) => {
|
||||
cx.update(|cx| {
|
||||
cx.open_url(permalink.as_ref());
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(err) => {
|
||||
let message = format!("Failed to open permalink: {err}");
|
||||
|
||||
Err::<(), anyhow::Error>(err).log_err();
|
||||
|
||||
if let Some(workspace) = workspace {
|
||||
workspace
|
||||
.update(&mut cx, |workspace, cx| {
|
||||
struct OpenPermalinkToLine;
|
||||
|
||||
workspace.show_toast(
|
||||
Toast::new(
|
||||
NotificationId::unique::<OpenPermalinkToLine>(),
|
||||
message,
|
||||
),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Adds a row highlight for the given range. If a row has multiple highlights, the
|
||||
@@ -12333,10 +12363,15 @@ impl Editor {
|
||||
|
||||
let proposed_changes_buffers = new_selections_by_buffer
|
||||
.into_iter()
|
||||
.map(|(buffer, ranges)| ProposedChangesBuffer { buffer, ranges })
|
||||
.map(|(buffer, ranges)| ProposedChangeLocation { buffer, ranges })
|
||||
.collect::<Vec<_>>();
|
||||
let proposed_changes_editor = cx.new_view(|cx| {
|
||||
ProposedChangesEditor::new(proposed_changes_buffers, self.project.clone(), cx)
|
||||
ProposedChangesEditor::new(
|
||||
"Proposed changes",
|
||||
proposed_changes_buffers,
|
||||
self.project.clone(),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
cx.window_context().defer(move |cx| {
|
||||
|
||||
@@ -7076,7 +7076,12 @@ async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
|
||||
|
||||
let format = editor
|
||||
.update(cx, |editor, cx| {
|
||||
editor.perform_format(project.clone(), FormatTrigger::Manual, cx)
|
||||
editor.perform_format(
|
||||
project.clone(),
|
||||
FormatTrigger::Manual,
|
||||
FormatTarget::Buffer,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
fake_server
|
||||
@@ -7112,7 +7117,7 @@ async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
|
||||
});
|
||||
let format = editor
|
||||
.update(cx, |editor, cx| {
|
||||
editor.perform_format(project, FormatTrigger::Manual, cx)
|
||||
editor.perform_format(project, FormatTrigger::Manual, FormatTarget::Buffer, cx)
|
||||
})
|
||||
.unwrap();
|
||||
cx.executor().advance_clock(super::FORMAT_TIMEOUT);
|
||||
@@ -10309,7 +10314,12 @@ async fn test_document_format_with_prettier(cx: &mut gpui::TestAppContext) {
|
||||
|
||||
editor
|
||||
.update(cx, |editor, cx| {
|
||||
editor.perform_format(project.clone(), FormatTrigger::Manual, cx)
|
||||
editor.perform_format(
|
||||
project.clone(),
|
||||
FormatTrigger::Manual,
|
||||
FormatTarget::Buffer,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
.await;
|
||||
@@ -10323,7 +10333,12 @@ async fn test_document_format_with_prettier(cx: &mut gpui::TestAppContext) {
|
||||
settings.defaults.formatter = Some(language_settings::SelectedFormatter::Auto)
|
||||
});
|
||||
let format = editor.update(cx, |editor, cx| {
|
||||
editor.perform_format(project.clone(), FormatTrigger::Manual, cx)
|
||||
editor.perform_format(
|
||||
project.clone(),
|
||||
FormatTrigger::Manual,
|
||||
FormatTarget::Buffer,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
format.await.unwrap();
|
||||
assert_eq!(
|
||||
|
||||
@@ -376,6 +376,13 @@ impl EditorElement {
|
||||
cx.propagate();
|
||||
}
|
||||
});
|
||||
register_action(view, cx, |editor, action, cx| {
|
||||
if let Some(task) = editor.format_selections(action, cx) {
|
||||
task.detach_and_log_err(cx);
|
||||
} else {
|
||||
cx.propagate();
|
||||
}
|
||||
});
|
||||
register_action(view, cx, Editor::restart_language_server);
|
||||
register_action(view, cx, Editor::cancel_language_server_work);
|
||||
register_action(view, cx, Editor::show_character_palette);
|
||||
|
||||
@@ -525,7 +525,7 @@ async fn parse_blocks(
|
||||
font_family: Some(buffer_font_family),
|
||||
..Default::default()
|
||||
},
|
||||
rule_color: Color::Muted.color(cx),
|
||||
rule_color: cx.theme().colors().border,
|
||||
block_quote_border_color: Color::Muted.color(cx),
|
||||
block_quote: TextStyleRefinement {
|
||||
color: Some(Color::Muted.color(cx)),
|
||||
|
||||
@@ -27,6 +27,7 @@ use rpc::proto::{self, update_view, PeerId};
|
||||
use settings::Settings;
|
||||
use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams};
|
||||
|
||||
use project::lsp_store::FormatTarget;
|
||||
use std::{
|
||||
any::TypeId,
|
||||
borrow::Cow,
|
||||
@@ -722,7 +723,12 @@ impl Item for Editor {
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
if format {
|
||||
this.update(&mut cx, |editor, cx| {
|
||||
editor.perform_format(project.clone(), FormatTrigger::Save, cx)
|
||||
editor.perform_format(
|
||||
project.clone(),
|
||||
FormatTrigger::Save,
|
||||
FormatTarget::Buffer,
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use crate::actions::FormatSelections;
|
||||
use crate::{
|
||||
actions::Format, selections_collection::SelectionsCollection, Copy, CopyPermalinkToLine, Cut,
|
||||
DisplayPoint, DisplaySnapshot, Editor, EditorMode, FindAllReferences, GoToDeclaration,
|
||||
@@ -8,6 +7,8 @@ use crate::{
|
||||
};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{DismissEvent, Pixels, Point, Subscription, View, ViewContext};
|
||||
use std::ops::Range;
|
||||
use text::PointUtf16;
|
||||
use workspace::OpenInTerminal;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -164,6 +165,12 @@ pub fn deploy_context_menu(
|
||||
} else {
|
||||
"Reveal in File Manager"
|
||||
};
|
||||
let has_selections = editor
|
||||
.selections
|
||||
.all::<PointUtf16>(cx)
|
||||
.into_iter()
|
||||
.any(|s| !s.is_empty());
|
||||
|
||||
ui::ContextMenu::build(cx, |menu, _cx| {
|
||||
let builder = menu
|
||||
.on_blur_subscription(Subscription::new(|| {}))
|
||||
@@ -175,6 +182,9 @@ pub fn deploy_context_menu(
|
||||
.separator()
|
||||
.action("Rename Symbol", Box::new(Rename))
|
||||
.action("Format Buffer", Box::new(Format))
|
||||
.when(has_selections, |cx| {
|
||||
cx.action("Format Selections", Box::new(FormatSelections))
|
||||
})
|
||||
.action(
|
||||
"Code Actions",
|
||||
Box::new(ToggleCodeActions {
|
||||
|
||||
@@ -16,16 +16,24 @@ use workspace::{
|
||||
|
||||
pub struct ProposedChangesEditor {
|
||||
editor: View<Editor>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
multibuffer: Model<MultiBuffer>,
|
||||
title: SharedString,
|
||||
buffer_entries: Vec<BufferEntry>,
|
||||
_recalculate_diffs_task: Task<Option<()>>,
|
||||
recalculate_diffs_tx: mpsc::UnboundedSender<RecalculateDiff>,
|
||||
}
|
||||
|
||||
pub struct ProposedChangesBuffer<T> {
|
||||
pub struct ProposedChangeLocation<T> {
|
||||
pub buffer: Model<Buffer>,
|
||||
pub ranges: Vec<Range<T>>,
|
||||
}
|
||||
|
||||
struct BufferEntry {
|
||||
base: Model<Buffer>,
|
||||
branch: Model<Buffer>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
pub struct ProposedChangesEditorToolbar {
|
||||
current_editor: Option<View<ProposedChangesEditor>>,
|
||||
}
|
||||
@@ -43,32 +51,14 @@ struct BranchBufferSemanticsProvider(Rc<dyn SemanticsProvider>);
|
||||
|
||||
impl ProposedChangesEditor {
|
||||
pub fn new<T: ToOffset>(
|
||||
buffers: Vec<ProposedChangesBuffer<T>>,
|
||||
title: impl Into<SharedString>,
|
||||
locations: Vec<ProposedChangeLocation<T>>,
|
||||
project: Option<Model<Project>>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let mut subscriptions = Vec::new();
|
||||
let multibuffer = cx.new_model(|_| MultiBuffer::new(Capability::ReadWrite));
|
||||
|
||||
for buffer in buffers {
|
||||
let branch_buffer = buffer.buffer.update(cx, |buffer, cx| buffer.branch(cx));
|
||||
subscriptions.push(cx.subscribe(&branch_buffer, Self::on_buffer_event));
|
||||
|
||||
multibuffer.update(cx, |multibuffer, cx| {
|
||||
multibuffer.push_excerpts(
|
||||
branch_buffer,
|
||||
buffer.ranges.into_iter().map(|range| ExcerptRange {
|
||||
context: range,
|
||||
primary: None,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let (recalculate_diffs_tx, mut recalculate_diffs_rx) = mpsc::unbounded();
|
||||
|
||||
Self {
|
||||
let mut this = Self {
|
||||
editor: cx.new_view(|cx| {
|
||||
let mut editor = Editor::for_multibuffer(multibuffer.clone(), project, true, cx);
|
||||
editor.set_expand_all_diff_hunks();
|
||||
@@ -81,6 +71,9 @@ impl ProposedChangesEditor {
|
||||
);
|
||||
editor
|
||||
}),
|
||||
multibuffer,
|
||||
title: title.into(),
|
||||
buffer_entries: Vec::new(),
|
||||
recalculate_diffs_tx,
|
||||
_recalculate_diffs_task: cx.spawn(|_, mut cx| async move {
|
||||
let mut buffers_to_diff = HashSet::default();
|
||||
@@ -112,7 +105,100 @@ impl ProposedChangesEditor {
|
||||
}
|
||||
None
|
||||
}),
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
this.reset_locations(locations, cx);
|
||||
this
|
||||
}
|
||||
|
||||
pub fn branch_buffer_for_base(&self, base_buffer: &Model<Buffer>) -> Option<Model<Buffer>> {
|
||||
self.buffer_entries.iter().find_map(|entry| {
|
||||
if &entry.base == base_buffer {
|
||||
Some(entry.branch.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_title(&mut self, title: SharedString, cx: &mut ViewContext<Self>) {
|
||||
self.title = title;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn reset_locations<T: ToOffset>(
|
||||
&mut self,
|
||||
locations: Vec<ProposedChangeLocation<T>>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// Undo all branch changes
|
||||
for entry in &self.buffer_entries {
|
||||
let base_version = entry.base.read(cx).version();
|
||||
entry.branch.update(cx, |buffer, cx| {
|
||||
let undo_counts = buffer
|
||||
.operations()
|
||||
.iter()
|
||||
.filter_map(|(timestamp, _)| {
|
||||
if !base_version.observed(*timestamp) {
|
||||
Some((*timestamp, u32::MAX))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
buffer.undo_operations(undo_counts, cx);
|
||||
});
|
||||
}
|
||||
|
||||
self.multibuffer.update(cx, |multibuffer, cx| {
|
||||
multibuffer.clear(cx);
|
||||
});
|
||||
|
||||
let mut buffer_entries = Vec::new();
|
||||
for location in locations {
|
||||
let branch_buffer;
|
||||
if let Some(ix) = self
|
||||
.buffer_entries
|
||||
.iter()
|
||||
.position(|entry| entry.base == location.buffer)
|
||||
{
|
||||
let entry = self.buffer_entries.remove(ix);
|
||||
branch_buffer = entry.branch.clone();
|
||||
buffer_entries.push(entry);
|
||||
} else {
|
||||
branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx));
|
||||
buffer_entries.push(BufferEntry {
|
||||
branch: branch_buffer.clone(),
|
||||
base: location.buffer.clone(),
|
||||
_subscription: cx.subscribe(&branch_buffer, Self::on_buffer_event),
|
||||
});
|
||||
}
|
||||
|
||||
self.multibuffer.update(cx, |multibuffer, cx| {
|
||||
multibuffer.push_excerpts(
|
||||
branch_buffer,
|
||||
location.ranges.into_iter().map(|range| ExcerptRange {
|
||||
context: range,
|
||||
primary: None,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
self.buffer_entries = buffer_entries;
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.change_selections(None, cx, |selections| selections.refresh())
|
||||
});
|
||||
}
|
||||
|
||||
pub fn recalculate_all_buffer_diffs(&self) {
|
||||
for (ix, entry) in self.buffer_entries.iter().enumerate().rev() {
|
||||
self.recalculate_diffs_tx
|
||||
.unbounded_send(RecalculateDiff {
|
||||
buffer: entry.branch.clone(),
|
||||
debounce: ix > 0,
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,11 +248,11 @@ impl Item for ProposedChangesEditor {
|
||||
type Event = EditorEvent;
|
||||
|
||||
fn tab_icon(&self, _cx: &ui::WindowContext) -> Option<Icon> {
|
||||
Some(Icon::new(IconName::Pencil))
|
||||
Some(Icon::new(IconName::Diff))
|
||||
}
|
||||
|
||||
fn tab_content_text(&self, _cx: &WindowContext) -> Option<SharedString> {
|
||||
Some("Proposed changes".into())
|
||||
Some(self.title.clone())
|
||||
}
|
||||
|
||||
fn as_searchable(&self, _: &View<Self>) -> Option<Box<dyn SearchableItemHandle>> {
|
||||
|
||||
@@ -434,12 +434,10 @@ impl<T> Clone for Model<T> {
|
||||
|
||||
impl<T> std::fmt::Debug for Model<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"Model {{ entity_id: {:?}, entity_type: {:?} }}",
|
||||
self.any_model.entity_id,
|
||||
type_name::<T>()
|
||||
)
|
||||
f.debug_struct("Model")
|
||||
.field("entity_id", &self.any_model.entity_id)
|
||||
.field("entity_type", &type_name::<T>())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,7 +567,10 @@ pub struct WeakModel<T> {
|
||||
|
||||
impl<T> std::fmt::Debug for WeakModel<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct(type_name::<WeakModel<T>>()).finish()
|
||||
f.debug_struct(&type_name::<Self>())
|
||||
.field("entity_id", &self.any_model.entity_id)
|
||||
.field("entity_type", &type_name::<T>())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,14 +133,7 @@ impl<E: IntoElement + 'static> Element for AnimationElement<E> {
|
||||
let mut element = (self.animator)(element, delta).into_any_element();
|
||||
|
||||
if !done {
|
||||
let parent_id = cx.parent_view_id();
|
||||
cx.on_next_frame(move |cx| {
|
||||
if let Some(parent_id) = parent_id {
|
||||
cx.notify(parent_id)
|
||||
} else {
|
||||
cx.refresh()
|
||||
}
|
||||
})
|
||||
cx.request_animation_frame();
|
||||
}
|
||||
|
||||
((element.request_layout(cx), element), state)
|
||||
|
||||
@@ -2575,4 +2575,9 @@ impl ScrollHandle {
|
||||
pub fn set_logical_scroll_top(&self, ix: usize, px: Pixels) {
|
||||
self.0.borrow_mut().requested_scroll_top = Some((ix, px));
|
||||
}
|
||||
|
||||
/// Get the count of children for scrollable item.
|
||||
pub fn children_count(&self) -> usize {
|
||||
self.0.borrow().child_bounds.len()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,12 +52,13 @@ pub struct WindowsWindowState {
|
||||
|
||||
pub display: WindowsDisplay,
|
||||
fullscreen: Option<StyleAndBounds>,
|
||||
initial_placement: Option<WINDOWPLACEMENT>,
|
||||
initial_placement: Option<WindowOpenStatus>,
|
||||
hwnd: HWND,
|
||||
}
|
||||
|
||||
pub(crate) struct WindowsWindowStatePtr {
|
||||
hwnd: HWND,
|
||||
this: Weak<Self>,
|
||||
pub(crate) state: RefCell<WindowsWindowState>,
|
||||
pub(crate) handle: AnyWindowHandle,
|
||||
pub(crate) hide_title_bar: bool,
|
||||
@@ -222,9 +223,10 @@ impl WindowsWindowStatePtr {
|
||||
context.display,
|
||||
)?);
|
||||
|
||||
Ok(Rc::new(Self {
|
||||
state,
|
||||
Ok(Rc::new_cyclic(|this| Self {
|
||||
hwnd,
|
||||
this: this.clone(),
|
||||
state,
|
||||
handle: context.handle,
|
||||
hide_title_bar: context.hide_title_bar,
|
||||
is_movable: context.is_movable,
|
||||
@@ -235,11 +237,86 @@ impl WindowsWindowStatePtr {
|
||||
}))
|
||||
}
|
||||
|
||||
fn toggle_fullscreen(&self) {
|
||||
let Some(state_ptr) = self.this.upgrade() else {
|
||||
log::error!("Unable to toggle fullscreen: window has been dropped");
|
||||
return;
|
||||
};
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
let mut lock = state_ptr.state.borrow_mut();
|
||||
let StyleAndBounds {
|
||||
style,
|
||||
x,
|
||||
y,
|
||||
cx,
|
||||
cy,
|
||||
} = if let Some(state) = lock.fullscreen.take() {
|
||||
state
|
||||
} else {
|
||||
let (window_bounds, _) = lock.calculate_window_bounds();
|
||||
lock.fullscreen_restore_bounds = window_bounds;
|
||||
let style =
|
||||
WINDOW_STYLE(unsafe { get_window_long(state_ptr.hwnd, GWL_STYLE) } as _);
|
||||
let mut rc = RECT::default();
|
||||
unsafe { GetWindowRect(state_ptr.hwnd, &mut rc) }.log_err();
|
||||
let _ = lock.fullscreen.insert(StyleAndBounds {
|
||||
style,
|
||||
x: rc.left,
|
||||
y: rc.top,
|
||||
cx: rc.right - rc.left,
|
||||
cy: rc.bottom - rc.top,
|
||||
});
|
||||
let style = style
|
||||
& !(WS_THICKFRAME
|
||||
| WS_SYSMENU
|
||||
| WS_MAXIMIZEBOX
|
||||
| WS_MINIMIZEBOX
|
||||
| WS_CAPTION);
|
||||
let physical_bounds = lock.display.physical_bounds();
|
||||
StyleAndBounds {
|
||||
style,
|
||||
x: physical_bounds.left().0,
|
||||
y: physical_bounds.top().0,
|
||||
cx: physical_bounds.size.width.0,
|
||||
cy: physical_bounds.size.height.0,
|
||||
}
|
||||
};
|
||||
drop(lock);
|
||||
unsafe { set_window_long(state_ptr.hwnd, GWL_STYLE, style.0 as isize) };
|
||||
unsafe {
|
||||
SetWindowPos(
|
||||
state_ptr.hwnd,
|
||||
HWND::default(),
|
||||
x,
|
||||
y,
|
||||
cx,
|
||||
cy,
|
||||
SWP_FRAMECHANGED | SWP_NOACTIVATE | SWP_NOZORDER,
|
||||
)
|
||||
}
|
||||
.log_err();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn set_window_placement(&self) -> Result<()> {
|
||||
let Some(placement) = self.state.borrow_mut().initial_placement.take() else {
|
||||
let Some(open_status) = self.state.borrow_mut().initial_placement.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
unsafe { SetWindowPlacement(self.hwnd, &placement)? };
|
||||
match open_status.state {
|
||||
WindowOpenState::Maximized => unsafe {
|
||||
SetWindowPlacement(self.hwnd, &open_status.placement)?;
|
||||
ShowWindowAsync(self.hwnd, SW_MAXIMIZE).ok()?;
|
||||
},
|
||||
WindowOpenState::Fullscreen => {
|
||||
unsafe { SetWindowPlacement(self.hwnd, &open_status.placement)? };
|
||||
self.toggle_fullscreen();
|
||||
}
|
||||
WindowOpenState::Windowed => unsafe {
|
||||
SetWindowPlacement(self.hwnd, &open_status.placement)?;
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -361,7 +438,10 @@ impl WindowsWindow {
|
||||
if params.show {
|
||||
unsafe { SetWindowPlacement(hwnd, &placement)? };
|
||||
} else {
|
||||
state_ptr.state.borrow_mut().initial_placement = Some(placement);
|
||||
state_ptr.state.borrow_mut().initial_placement = Some(WindowOpenStatus {
|
||||
placement,
|
||||
state: WindowOpenState::Windowed,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self(state_ptr))
|
||||
@@ -579,68 +659,21 @@ impl PlatformWindow for WindowsWindow {
|
||||
}
|
||||
|
||||
fn zoom(&self) {
|
||||
unsafe { ShowWindowAsync(self.0.hwnd, SW_MAXIMIZE).ok().log_err() };
|
||||
unsafe {
|
||||
if IsWindowVisible(self.0.hwnd).as_bool() {
|
||||
ShowWindowAsync(self.0.hwnd, SW_MAXIMIZE).ok().log_err();
|
||||
} else if let Some(status) = self.0.state.borrow_mut().initial_placement.as_mut() {
|
||||
status.state = WindowOpenState::Maximized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_fullscreen(&self) {
|
||||
let state_ptr = self.0.clone();
|
||||
self.0
|
||||
.executor
|
||||
.spawn(async move {
|
||||
let mut lock = state_ptr.state.borrow_mut();
|
||||
let StyleAndBounds {
|
||||
style,
|
||||
x,
|
||||
y,
|
||||
cx,
|
||||
cy,
|
||||
} = if let Some(state) = lock.fullscreen.take() {
|
||||
state
|
||||
} else {
|
||||
let (window_bounds, _) = lock.calculate_window_bounds();
|
||||
lock.fullscreen_restore_bounds = window_bounds;
|
||||
let style =
|
||||
WINDOW_STYLE(unsafe { get_window_long(state_ptr.hwnd, GWL_STYLE) } as _);
|
||||
let mut rc = RECT::default();
|
||||
unsafe { GetWindowRect(state_ptr.hwnd, &mut rc) }.log_err();
|
||||
let _ = lock.fullscreen.insert(StyleAndBounds {
|
||||
style,
|
||||
x: rc.left,
|
||||
y: rc.top,
|
||||
cx: rc.right - rc.left,
|
||||
cy: rc.bottom - rc.top,
|
||||
});
|
||||
let style = style
|
||||
& !(WS_THICKFRAME
|
||||
| WS_SYSMENU
|
||||
| WS_MAXIMIZEBOX
|
||||
| WS_MINIMIZEBOX
|
||||
| WS_CAPTION);
|
||||
let physical_bounds = lock.display.physical_bounds();
|
||||
StyleAndBounds {
|
||||
style,
|
||||
x: physical_bounds.left().0,
|
||||
y: physical_bounds.top().0,
|
||||
cx: physical_bounds.size.width.0,
|
||||
cy: physical_bounds.size.height.0,
|
||||
}
|
||||
};
|
||||
drop(lock);
|
||||
unsafe { set_window_long(state_ptr.hwnd, GWL_STYLE, style.0 as isize) };
|
||||
unsafe {
|
||||
SetWindowPos(
|
||||
state_ptr.hwnd,
|
||||
HWND::default(),
|
||||
x,
|
||||
y,
|
||||
cx,
|
||||
cy,
|
||||
SWP_FRAMECHANGED | SWP_NOACTIVATE | SWP_NOZORDER,
|
||||
)
|
||||
}
|
||||
.log_err();
|
||||
})
|
||||
.detach();
|
||||
if unsafe { IsWindowVisible(self.0.hwnd).as_bool() } {
|
||||
self.0.toggle_fullscreen();
|
||||
} else if let Some(status) = self.0.state.borrow_mut().initial_placement.as_mut() {
|
||||
status.state = WindowOpenState::Fullscreen;
|
||||
}
|
||||
}
|
||||
|
||||
fn is_fullscreen(&self) -> bool {
|
||||
@@ -925,6 +958,17 @@ impl WindowBorderOffset {
|
||||
}
|
||||
}
|
||||
|
||||
struct WindowOpenStatus {
|
||||
placement: WINDOWPLACEMENT,
|
||||
state: WindowOpenState,
|
||||
}
|
||||
|
||||
enum WindowOpenState {
|
||||
Maximized,
|
||||
Fullscreen,
|
||||
Windowed,
|
||||
}
|
||||
|
||||
fn register_wnd_class(icon_handle: HICON) -> PCWSTR {
|
||||
const CLASS_NAME: PCWSTR = w!("Zed::Window");
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ impl project::Item for ImageItem {
|
||||
.path
|
||||
.extension()
|
||||
.and_then(OsStr::to_str)
|
||||
.map(str::to_lowercase)
|
||||
.unwrap_or_default();
|
||||
let ext = ext.as_str();
|
||||
|
||||
// Only open the item if it's a binary image (no SVGs, etc.)
|
||||
// Since we do not have a way to toggle to an editor
|
||||
|
||||
@@ -20,6 +20,7 @@ use anyhow::{anyhow, Context, Result};
|
||||
use async_watch as watch;
|
||||
use clock::Lamport;
|
||||
pub use clock::ReplicaId;
|
||||
use collections::HashMap;
|
||||
use futures::channel::oneshot;
|
||||
use gpui::{
|
||||
AnyElement, AppContext, Context as _, EventEmitter, HighlightStyle, Model, ModelContext,
|
||||
@@ -910,10 +911,8 @@ impl Buffer {
|
||||
self.apply_ops([operation.clone()], cx);
|
||||
|
||||
if let Some(timestamp) = operation_to_undo {
|
||||
let operation = self
|
||||
.text
|
||||
.undo_operations([(timestamp, u32::MAX)].into_iter().collect());
|
||||
self.send_operation(Operation::Buffer(operation), true, cx);
|
||||
let counts = [(timestamp, u32::MAX)].into_iter().collect();
|
||||
self.undo_operations(counts, cx);
|
||||
}
|
||||
|
||||
self.diff_base_version += 1;
|
||||
@@ -2331,6 +2330,18 @@ impl Buffer {
|
||||
undone
|
||||
}
|
||||
|
||||
pub fn undo_operations(
|
||||
&mut self,
|
||||
counts: HashMap<Lamport, u32>,
|
||||
cx: &mut ModelContext<Buffer>,
|
||||
) {
|
||||
let was_dirty = self.is_dirty();
|
||||
let operation = self.text.undo_operations(counts);
|
||||
let old_version = self.version.clone();
|
||||
self.send_operation(Operation::Buffer(operation), true, cx);
|
||||
self.did_edit(&old_version, was_dirty, cx);
|
||||
}
|
||||
|
||||
/// Manually redoes a specific transaction in the buffer's redo history.
|
||||
pub fn redo(&mut self, cx: &mut ModelContext<Self>) -> Option<TransactionId> {
|
||||
let was_dirty = self.is_dirty();
|
||||
|
||||
@@ -9,7 +9,9 @@ license = "GPL-3.0-or-later"
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
test-support = [
|
||||
"tree-sitter"
|
||||
]
|
||||
load-grammars = [
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-c",
|
||||
@@ -75,6 +77,7 @@ tree-sitter-yaml = { workspace = true, optional = true }
|
||||
util.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tree-sitter.workspace = true
|
||||
text.workspace = true
|
||||
theme = { workspace = true, features = ["test-support"] }
|
||||
unindent.workspace = true
|
||||
|
||||
@@ -101,10 +101,10 @@ pub fn logs_dir() -> &'static PathBuf {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the path to the zed server directory on this ssh host.
|
||||
/// Returns the path to the Zed server directory on this SSH host.
|
||||
pub fn remote_server_state_dir() -> &'static PathBuf {
|
||||
static REMOTE_SERVER_STATE: OnceLock<PathBuf> = OnceLock::new();
|
||||
REMOTE_SERVER_STATE.get_or_init(|| return support_dir().join("server_state"))
|
||||
REMOTE_SERVER_STATE.get_or_init(|| support_dir().join("server_state"))
|
||||
}
|
||||
|
||||
/// Returns the path to the `Zed.log` file.
|
||||
|
||||
@@ -69,6 +69,7 @@ snippet_provider.workspace = true
|
||||
terminal.workspace = true
|
||||
text.workspace = true
|
||||
util.workspace = true
|
||||
url.workspace = true
|
||||
which.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::{
|
||||
worktree_store::{WorktreeStore, WorktreeStoreEvent},
|
||||
Item, NoRepositoryError, ProjectPath,
|
||||
};
|
||||
use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry};
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use client::Client;
|
||||
use collections::{hash_map, HashMap, HashSet};
|
||||
@@ -23,7 +24,7 @@ use language::{
|
||||
};
|
||||
use rpc::{proto, AnyProtoClient, ErrorExt as _, TypedEnvelope};
|
||||
use smol::channel::Receiver;
|
||||
use std::{io, path::Path, str::FromStr as _, sync::Arc, time::Instant};
|
||||
use std::{io, ops::Range, path::Path, str::FromStr as _, sync::Arc, time::Instant};
|
||||
use text::BufferId;
|
||||
use util::{debug_panic, maybe, ResultExt as _, TryFutureExt};
|
||||
use worktree::{File, PathChange, ProjectEntryId, UpdatedGitRepositoriesSet, Worktree, WorktreeId};
|
||||
@@ -971,6 +972,7 @@ impl BufferStore {
|
||||
client.add_model_request_handler(Self::handle_save_buffer);
|
||||
client.add_model_request_handler(Self::handle_blame_buffer);
|
||||
client.add_model_request_handler(Self::handle_reload_buffers);
|
||||
client.add_model_request_handler(Self::handle_get_permalink_to_line);
|
||||
}
|
||||
|
||||
/// Creates a buffer store, optionally retaining its buffers.
|
||||
@@ -1170,6 +1172,78 @@ impl BufferStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_permalink_to_line(
|
||||
&self,
|
||||
buffer: &Model<Buffer>,
|
||||
selection: Range<u32>,
|
||||
cx: &AppContext,
|
||||
) -> Task<Result<url::Url>> {
|
||||
let buffer = buffer.read(cx);
|
||||
let Some(file) = File::from_dyn(buffer.file()) else {
|
||||
return Task::ready(Err(anyhow!("buffer has no file")));
|
||||
};
|
||||
|
||||
match file.worktree.clone().read(cx) {
|
||||
Worktree::Local(worktree) => {
|
||||
let Some(repo) = worktree.local_git_repo(file.path()) else {
|
||||
return Task::ready(Err(anyhow!("no repository for buffer found")));
|
||||
};
|
||||
|
||||
let path = file.path().clone();
|
||||
|
||||
cx.spawn(|cx| async move {
|
||||
const REMOTE_NAME: &str = "origin";
|
||||
let origin_url = repo
|
||||
.remote_url(REMOTE_NAME)
|
||||
.ok_or_else(|| anyhow!("remote \"{REMOTE_NAME}\" not found"))?;
|
||||
|
||||
let sha = repo
|
||||
.head_sha()
|
||||
.ok_or_else(|| anyhow!("failed to read HEAD SHA"))?;
|
||||
|
||||
let provider_registry =
|
||||
cx.update(GitHostingProviderRegistry::default_global)?;
|
||||
|
||||
let (provider, remote) =
|
||||
parse_git_remote_url(provider_registry, &origin_url)
|
||||
.ok_or_else(|| anyhow!("failed to parse Git remote URL"))?;
|
||||
|
||||
let path = path
|
||||
.to_str()
|
||||
.context("failed to convert buffer path to string")?;
|
||||
|
||||
Ok(provider.build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams {
|
||||
sha: &sha,
|
||||
path,
|
||||
selection: Some(selection),
|
||||
},
|
||||
))
|
||||
})
|
||||
}
|
||||
Worktree::Remote(worktree) => {
|
||||
let buffer_id = buffer.remote_id();
|
||||
let project_id = worktree.project_id();
|
||||
let client = worktree.client();
|
||||
cx.spawn(|_| async move {
|
||||
let response = client
|
||||
.request(proto::GetPermalinkToLine {
|
||||
project_id,
|
||||
buffer_id: buffer_id.into(),
|
||||
selection: Some(proto::Range {
|
||||
start: selection.start as u64,
|
||||
end: selection.end as u64,
|
||||
}),
|
||||
})
|
||||
.await?;
|
||||
|
||||
url::Url::parse(&response.permalink).context("failed to parse permalink")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_buffer(&mut self, buffer: Model<Buffer>, cx: &mut ModelContext<Self>) -> Result<()> {
|
||||
let remote_id = buffer.read(cx).remote_id();
|
||||
let is_remote = buffer.read(cx).replica_id() != 0;
|
||||
@@ -1775,6 +1849,31 @@ impl BufferStore {
|
||||
Ok(serialize_blame_buffer_response(blame))
|
||||
}
|
||||
|
||||
pub async fn handle_get_permalink_to_line(
|
||||
this: Model<Self>,
|
||||
envelope: TypedEnvelope<proto::GetPermalinkToLine>,
|
||||
mut cx: AsyncAppContext,
|
||||
) -> Result<proto::GetPermalinkToLineResponse> {
|
||||
let buffer_id = BufferId::new(envelope.payload.buffer_id)?;
|
||||
// let version = deserialize_version(&envelope.payload.version);
|
||||
let selection = {
|
||||
let proto_selection = envelope
|
||||
.payload
|
||||
.selection
|
||||
.context("no selection to get permalink for defined")?;
|
||||
proto_selection.start as u32..proto_selection.end as u32
|
||||
};
|
||||
let buffer = this.read_with(&cx, |this, _| this.get_existing(buffer_id))??;
|
||||
let permalink = this
|
||||
.update(&mut cx, |this, cx| {
|
||||
this.get_permalink_to_line(&buffer, selection, cx)
|
||||
})?
|
||||
.await?;
|
||||
Ok(proto::GetPermalinkToLineResponse {
|
||||
permalink: permalink.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn wait_for_loading_buffer(
|
||||
mut receiver: postage::watch::Receiver<Option<Result<Model<Buffer>, Arc<anyhow::Error>>>>,
|
||||
) -> Result<Model<Buffer>, Arc<anyhow::Error>> {
|
||||
|
||||
@@ -112,8 +112,18 @@ impl ProjectEnvironment {
|
||||
let worktree = worktree_id.zip(worktree_abs_path);
|
||||
|
||||
let cli_environment = self.get_cli_environment();
|
||||
if cli_environment.is_some() {
|
||||
Task::ready(cli_environment)
|
||||
if let Some(environment) = cli_environment {
|
||||
cx.spawn(|_, _| async move {
|
||||
let path = environment
|
||||
.get("PATH")
|
||||
.map(|path| path.as_str())
|
||||
.unwrap_or_default();
|
||||
log::info!(
|
||||
"using project environment variables from CLI. PATH={:?}",
|
||||
path
|
||||
);
|
||||
Some(environment)
|
||||
})
|
||||
} else if let Some((worktree_id, worktree_abs_path)) = worktree {
|
||||
self.get_worktree_env(worktree_id, worktree_abs_path, cx)
|
||||
} else {
|
||||
@@ -143,6 +153,15 @@ impl ProjectEnvironment {
|
||||
.await;
|
||||
|
||||
if let Some(shell_env) = shell_env.as_mut() {
|
||||
let path = shell_env
|
||||
.get("PATH")
|
||||
.map(|path| path.as_str())
|
||||
.unwrap_or_default();
|
||||
log::info!(
|
||||
"using project environment variables shell launched in {:?}. PATH={:?}",
|
||||
worktree_abs_path,
|
||||
path
|
||||
);
|
||||
this.update(&mut cx, |this, _| {
|
||||
this.cached_shell_environments
|
||||
.insert(worktree_id, shell_env.clone());
|
||||
|
||||
@@ -2439,15 +2439,13 @@ impl InlayHints {
|
||||
ResolveState::Resolved => (0, None),
|
||||
ResolveState::CanResolve(server_id, resolve_data) => (
|
||||
1,
|
||||
resolve_data
|
||||
.map(|json_data| {
|
||||
Some(proto::resolve_state::LspResolveState {
|
||||
server_id: server_id.0 as u64,
|
||||
value: resolve_data.map(|json_data| {
|
||||
serde_json::to_string(&json_data)
|
||||
.expect("failed to serialize resolve json data")
|
||||
})
|
||||
.map(|value| proto::resolve_state::LspResolveState {
|
||||
server_id: server_id.0 as u64,
|
||||
value,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
ResolveState::Resolving => (2, None),
|
||||
};
|
||||
@@ -2515,9 +2513,11 @@ impl InlayHints {
|
||||
let resolve_state_data = resolve_state
|
||||
.lsp_resolve_state.as_ref()
|
||||
.map(|lsp_resolve_state| {
|
||||
serde_json::from_str::<Option<lsp::LSPAny>>(&lsp_resolve_state.value)
|
||||
.with_context(|| format!("incorrect proto inlay hint message: non-json resolve state {lsp_resolve_state:?}"))
|
||||
.map(|state| (LanguageServerId(lsp_resolve_state.server_id as usize), state))
|
||||
let value = lsp_resolve_state.value.as_deref().map(|value| {
|
||||
serde_json::from_str::<Option<lsp::LSPAny>>(value)
|
||||
.with_context(|| format!("incorrect proto inlay hint message: non-json resolve state {lsp_resolve_state:?}"))
|
||||
}).transpose()?.flatten();
|
||||
anyhow::Ok((LanguageServerId(lsp_resolve_state.server_id as usize), value))
|
||||
})
|
||||
.transpose()?;
|
||||
let resolve_state = match resolve_state.state {
|
||||
|
||||
@@ -72,7 +72,7 @@ use std::{
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use text::{Anchor, BufferId, LineEnding};
|
||||
use text::{Anchor, BufferId, LineEnding, Point, Selection};
|
||||
use util::{
|
||||
debug_panic, defer, maybe, merge_json_value_into, post_inc, ResultExt, TryFutureExt as _,
|
||||
};
|
||||
@@ -96,6 +96,20 @@ pub enum FormatTrigger {
|
||||
Manual,
|
||||
}
|
||||
|
||||
pub enum FormatTarget {
|
||||
Buffer,
|
||||
Ranges(Vec<Selection<Point>>),
|
||||
}
|
||||
|
||||
impl FormatTarget {
|
||||
pub fn as_selections(&self) -> Option<&[Selection<Point>]> {
|
||||
match self {
|
||||
FormatTarget::Buffer => None,
|
||||
FormatTarget::Ranges(selections) => Some(selections.as_slice()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Currently, formatting operations are represented differently depending on
|
||||
// whether they come from a language server or an external command.
|
||||
#[derive(Debug)]
|
||||
@@ -161,6 +175,7 @@ impl LocalLspStore {
|
||||
mut buffers: Vec<FormattableBuffer>,
|
||||
push_to_history: bool,
|
||||
trigger: FormatTrigger,
|
||||
target: FormatTarget,
|
||||
mut cx: AsyncAppContext,
|
||||
) -> anyhow::Result<ProjectTransaction> {
|
||||
// Do not allow multiple concurrent formatting requests for the
|
||||
@@ -286,6 +301,7 @@ impl LocalLspStore {
|
||||
if prettier_settings.allowed {
|
||||
Self::perform_format(
|
||||
&Formatter::Prettier,
|
||||
&target,
|
||||
server_and_buffer,
|
||||
lsp_store.clone(),
|
||||
buffer,
|
||||
@@ -299,6 +315,7 @@ impl LocalLspStore {
|
||||
} else {
|
||||
Self::perform_format(
|
||||
&Formatter::LanguageServer { name: None },
|
||||
&target,
|
||||
server_and_buffer,
|
||||
lsp_store.clone(),
|
||||
buffer,
|
||||
@@ -310,9 +327,8 @@ impl LocalLspStore {
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.log_err()
|
||||
.flatten();
|
||||
}?;
|
||||
|
||||
if let Some(op) = diff {
|
||||
format_operations.push(op);
|
||||
}
|
||||
@@ -321,6 +337,7 @@ impl LocalLspStore {
|
||||
for formatter in formatters.as_ref() {
|
||||
let diff = Self::perform_format(
|
||||
formatter,
|
||||
&target,
|
||||
server_and_buffer,
|
||||
lsp_store.clone(),
|
||||
buffer,
|
||||
@@ -330,9 +347,7 @@ impl LocalLspStore {
|
||||
&mut project_transaction,
|
||||
&mut cx,
|
||||
)
|
||||
.await
|
||||
.log_err()
|
||||
.flatten();
|
||||
.await?;
|
||||
if let Some(op) = diff {
|
||||
format_operations.push(op);
|
||||
}
|
||||
@@ -346,6 +361,7 @@ impl LocalLspStore {
|
||||
for formatter in formatters.as_ref() {
|
||||
let diff = Self::perform_format(
|
||||
formatter,
|
||||
&target,
|
||||
server_and_buffer,
|
||||
lsp_store.clone(),
|
||||
buffer,
|
||||
@@ -355,9 +371,7 @@ impl LocalLspStore {
|
||||
&mut project_transaction,
|
||||
&mut cx,
|
||||
)
|
||||
.await
|
||||
.log_err()
|
||||
.flatten();
|
||||
.await?;
|
||||
if let Some(op) = diff {
|
||||
format_operations.push(op);
|
||||
}
|
||||
@@ -373,6 +387,7 @@ impl LocalLspStore {
|
||||
if prettier_settings.allowed {
|
||||
Self::perform_format(
|
||||
&Formatter::Prettier,
|
||||
&target,
|
||||
server_and_buffer,
|
||||
lsp_store.clone(),
|
||||
buffer,
|
||||
@@ -384,8 +399,14 @@ impl LocalLspStore {
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
let formatter = Formatter::LanguageServer {
|
||||
name: primary_language_server
|
||||
.as_ref()
|
||||
.map(|server| server.name().to_string()),
|
||||
};
|
||||
Self::perform_format(
|
||||
&Formatter::LanguageServer { name: None },
|
||||
&formatter,
|
||||
&target,
|
||||
server_and_buffer,
|
||||
lsp_store.clone(),
|
||||
buffer,
|
||||
@@ -397,9 +418,7 @@ impl LocalLspStore {
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.log_err()
|
||||
.flatten();
|
||||
}?;
|
||||
|
||||
if let Some(op) = diff {
|
||||
format_operations.push(op)
|
||||
@@ -410,6 +429,7 @@ impl LocalLspStore {
|
||||
// format with formatter
|
||||
let diff = Self::perform_format(
|
||||
formatter,
|
||||
&target,
|
||||
server_and_buffer,
|
||||
lsp_store.clone(),
|
||||
buffer,
|
||||
@@ -419,9 +439,7 @@ impl LocalLspStore {
|
||||
&mut project_transaction,
|
||||
&mut cx,
|
||||
)
|
||||
.await
|
||||
.log_err()
|
||||
.flatten();
|
||||
.await?;
|
||||
if let Some(op) = diff {
|
||||
format_operations.push(op);
|
||||
}
|
||||
@@ -483,6 +501,7 @@ impl LocalLspStore {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn perform_format(
|
||||
formatter: &Formatter,
|
||||
format_target: &FormatTarget,
|
||||
primary_server_and_buffer: Option<(&Arc<LanguageServer>, &PathBuf)>,
|
||||
lsp_store: WeakModel<LspStore>,
|
||||
buffer: &FormattableBuffer,
|
||||
@@ -506,18 +525,33 @@ impl LocalLspStore {
|
||||
language_server
|
||||
};
|
||||
|
||||
Some(FormatOperation::Lsp(
|
||||
LspStore::format_via_lsp(
|
||||
&lsp_store,
|
||||
&buffer.handle,
|
||||
buffer_abs_path,
|
||||
language_server,
|
||||
settings,
|
||||
cx,
|
||||
)
|
||||
.await
|
||||
.context("failed to format via language server")?,
|
||||
))
|
||||
match format_target {
|
||||
FormatTarget::Buffer => Some(FormatOperation::Lsp(
|
||||
LspStore::format_via_lsp(
|
||||
&lsp_store,
|
||||
&buffer.handle,
|
||||
buffer_abs_path,
|
||||
language_server,
|
||||
settings,
|
||||
cx,
|
||||
)
|
||||
.await
|
||||
.context("failed to format via language server")?,
|
||||
)),
|
||||
FormatTarget::Ranges(selections) => Some(FormatOperation::Lsp(
|
||||
LspStore::format_range_via_lsp(
|
||||
&lsp_store,
|
||||
&buffer.handle,
|
||||
selections.as_slice(),
|
||||
buffer_abs_path,
|
||||
language_server,
|
||||
settings,
|
||||
cx,
|
||||
)
|
||||
.await
|
||||
.context("failed to format ranges via language server")?,
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -1203,16 +1237,19 @@ impl LspStore {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(prettier_plugins) = prettier_store::prettier_plugins_for_language(&settings) {
|
||||
let prettier_store = self.as_local().map(|s| s.prettier_store.clone());
|
||||
if let Some(prettier_store) = prettier_store {
|
||||
prettier_store.update(cx, |prettier_store, cx| {
|
||||
prettier_store.install_default_prettier(
|
||||
worktree_id,
|
||||
prettier_plugins.iter().map(|s| Arc::from(s.as_str())),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
if settings.prettier.allowed {
|
||||
if let Some(prettier_plugins) = prettier_store::prettier_plugins_for_language(&settings)
|
||||
{
|
||||
let prettier_store = self.as_local().map(|s| s.prettier_store.clone());
|
||||
if let Some(prettier_store) = prettier_store {
|
||||
prettier_store.update(cx, |prettier_store, cx| {
|
||||
prettier_store.install_default_prettier(
|
||||
worktree_id,
|
||||
prettier_plugins.iter().map(|s| Arc::from(s.as_str())),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1859,10 +1896,9 @@ impl LspStore {
|
||||
} else if matches!(range_formatting_provider, Some(p) if *p != OneOf::Left(false)) {
|
||||
let buffer_start = lsp::Position::new(0, 0);
|
||||
let buffer_end = buffer.update(cx, |b, _| point_to_lsp(b.max_point_utf16()))?;
|
||||
|
||||
language_server
|
||||
.request::<lsp::request::RangeFormatting>(lsp::DocumentRangeFormattingParams {
|
||||
text_document,
|
||||
text_document: text_document.clone(),
|
||||
range: lsp::Range::new(buffer_start, buffer_end),
|
||||
options: lsp_command::lsp_formatting_options(settings),
|
||||
work_done_progress_params: Default::default(),
|
||||
@@ -1878,7 +1914,62 @@ impl LspStore {
|
||||
})?
|
||||
.await
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
Ok(Vec::with_capacity(0))
|
||||
}
|
||||
}
|
||||
pub async fn format_range_via_lsp(
|
||||
this: &WeakModel<Self>,
|
||||
buffer: &Model<Buffer>,
|
||||
selections: &[Selection<Point>],
|
||||
abs_path: &Path,
|
||||
language_server: &Arc<LanguageServer>,
|
||||
settings: &LanguageSettings,
|
||||
cx: &mut AsyncAppContext,
|
||||
) -> Result<Vec<(Range<Anchor>, String)>> {
|
||||
let capabilities = &language_server.capabilities();
|
||||
let range_formatting_provider = capabilities.document_range_formatting_provider.as_ref();
|
||||
if range_formatting_provider.map_or(false, |provider| provider == &OneOf::Left(false)) {
|
||||
return Err(anyhow!(
|
||||
"{} language server does not support range formatting",
|
||||
language_server.name()
|
||||
));
|
||||
}
|
||||
|
||||
let uri = lsp::Url::from_file_path(abs_path)
|
||||
.map_err(|_| anyhow!("failed to convert abs path to uri"))?;
|
||||
let text_document = lsp::TextDocumentIdentifier::new(uri);
|
||||
|
||||
let lsp_edits = {
|
||||
let ranges = selections.into_iter().map(|s| {
|
||||
let start = lsp::Position::new(s.start.row, s.start.column);
|
||||
let end = lsp::Position::new(s.end.row, s.end.column);
|
||||
lsp::Range::new(start, end)
|
||||
});
|
||||
|
||||
let mut edits = None;
|
||||
for range in ranges {
|
||||
if let Some(mut edit) = language_server
|
||||
.request::<lsp::request::RangeFormatting>(lsp::DocumentRangeFormattingParams {
|
||||
text_document: text_document.clone(),
|
||||
range,
|
||||
options: lsp_command::lsp_formatting_options(settings),
|
||||
work_done_progress_params: Default::default(),
|
||||
})
|
||||
.await?
|
||||
{
|
||||
edits.get_or_insert_with(Vec::new).append(&mut edit);
|
||||
}
|
||||
}
|
||||
edits
|
||||
};
|
||||
|
||||
if let Some(lsp_edits) = lsp_edits {
|
||||
this.update(cx, |this, cx| {
|
||||
this.edits_from_lsp(buffer, lsp_edits, language_server.server_id(), None, cx)
|
||||
})?
|
||||
.await
|
||||
} else {
|
||||
Ok(Vec::with_capacity(0))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2648,44 +2739,44 @@ impl LspStore {
|
||||
};
|
||||
|
||||
requests.push(
|
||||
server
|
||||
.request::<lsp::request::WorkspaceSymbolRequest>(
|
||||
lsp::WorkspaceSymbolParams {
|
||||
query: query.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.log_err()
|
||||
.map(move |response| {
|
||||
let lsp_symbols = response.flatten().map(|symbol_response| match symbol_response {
|
||||
lsp::WorkspaceSymbolResponse::Flat(flat_responses) => {
|
||||
flat_responses.into_iter().map(|lsp_symbol| {
|
||||
(lsp_symbol.name, lsp_symbol.kind, lsp_symbol.location)
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
lsp::WorkspaceSymbolResponse::Nested(nested_responses) => {
|
||||
nested_responses.into_iter().filter_map(|lsp_symbol| {
|
||||
let location = match lsp_symbol.location {
|
||||
OneOf::Left(location) => location,
|
||||
OneOf::Right(_) => {
|
||||
log::error!("Unexpected: client capabilities forbid symbol resolutions in workspace.symbol.resolveSupport");
|
||||
return None
|
||||
}
|
||||
};
|
||||
Some((lsp_symbol.name, lsp_symbol.kind, location))
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
}).unwrap_or_default();
|
||||
server
|
||||
.request::<lsp::request::WorkspaceSymbolRequest>(
|
||||
lsp::WorkspaceSymbolParams {
|
||||
query: query.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.log_err()
|
||||
.map(move |response| {
|
||||
let lsp_symbols = response.flatten().map(|symbol_response| match symbol_response {
|
||||
lsp::WorkspaceSymbolResponse::Flat(flat_responses) => {
|
||||
flat_responses.into_iter().map(|lsp_symbol| {
|
||||
(lsp_symbol.name, lsp_symbol.kind, lsp_symbol.location)
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
lsp::WorkspaceSymbolResponse::Nested(nested_responses) => {
|
||||
nested_responses.into_iter().filter_map(|lsp_symbol| {
|
||||
let location = match lsp_symbol.location {
|
||||
OneOf::Left(location) => location,
|
||||
OneOf::Right(_) => {
|
||||
log::error!("Unexpected: client capabilities forbid symbol resolutions in workspace.symbol.resolveSupport");
|
||||
return None
|
||||
}
|
||||
};
|
||||
Some((lsp_symbol.name, lsp_symbol.kind, location))
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
}).unwrap_or_default();
|
||||
|
||||
WorkspaceSymbolsResult {
|
||||
lsp_adapter,
|
||||
language,
|
||||
worktree: worktree_handle.downgrade(),
|
||||
worktree_abs_path,
|
||||
lsp_symbols,
|
||||
}
|
||||
}),
|
||||
);
|
||||
WorkspaceSymbolsResult {
|
||||
lsp_adapter,
|
||||
language,
|
||||
worktree: worktree_handle.downgrade(),
|
||||
worktree_abs_path,
|
||||
lsp_symbols,
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
@@ -3412,7 +3503,7 @@ impl LspStore {
|
||||
language_server_name: LanguageServerName,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<Model<Buffer>>> {
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
cx.spawn(move |lsp_store, mut cx| async move {
|
||||
// Escape percent-encoded string.
|
||||
let current_scheme = abs_path.scheme().to_owned();
|
||||
let _ = abs_path.set_scheme("file");
|
||||
@@ -3421,9 +3512,9 @@ impl LspStore {
|
||||
.to_file_path()
|
||||
.map_err(|_| anyhow!("can't convert URI to path"))?;
|
||||
let p = abs_path.clone();
|
||||
let yarn_worktree = this
|
||||
.update(&mut cx, move |this, cx| {
|
||||
this.as_local().unwrap().yarn.update(cx, |_, cx| {
|
||||
let yarn_worktree = lsp_store
|
||||
.update(&mut cx, move |lsp_store, cx| match lsp_store.as_local() {
|
||||
Some(local_lsp_store) => local_lsp_store.yarn.update(cx, |_, cx| {
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let t = this
|
||||
.update(&mut cx, |this, cx| {
|
||||
@@ -3432,7 +3523,8 @@ impl LspStore {
|
||||
.ok()?;
|
||||
t.await
|
||||
})
|
||||
})
|
||||
}),
|
||||
None => Task::ready(None),
|
||||
})?
|
||||
.await;
|
||||
let (worktree_root_target, known_relative_path) =
|
||||
@@ -3442,8 +3534,8 @@ impl LspStore {
|
||||
(Arc::<Path>::from(abs_path.as_path()), None)
|
||||
};
|
||||
let (worktree, relative_path) = if let Some(result) =
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.worktree_store.update(cx, |worktree_store, cx| {
|
||||
lsp_store.update(&mut cx, |lsp_store, cx| {
|
||||
lsp_store.worktree_store.update(cx, |worktree_store, cx| {
|
||||
worktree_store.find_worktree(&worktree_root_target, cx)
|
||||
})
|
||||
})? {
|
||||
@@ -3451,22 +3543,25 @@ impl LspStore {
|
||||
known_relative_path.unwrap_or_else(|| Arc::<Path>::from(result.1));
|
||||
(result.0, relative_path)
|
||||
} else {
|
||||
let worktree = this
|
||||
.update(&mut cx, |this, cx| {
|
||||
this.worktree_store.update(cx, |worktree_store, cx| {
|
||||
let worktree = lsp_store
|
||||
.update(&mut cx, |lsp_store, cx| {
|
||||
lsp_store.worktree_store.update(cx, |worktree_store, cx| {
|
||||
worktree_store.create_worktree(&worktree_root_target, false, cx)
|
||||
})
|
||||
})?
|
||||
.await?;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.register_language_server(
|
||||
worktree.read(cx).id(),
|
||||
language_server_name,
|
||||
language_server_id,
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
let worktree_root = worktree.update(&mut cx, |this, _| this.abs_path())?;
|
||||
if worktree.update(&mut cx, |worktree, _| worktree.is_local())? {
|
||||
lsp_store
|
||||
.update(&mut cx, |lsp_store, cx| {
|
||||
lsp_store.register_language_server(
|
||||
worktree.read(cx).id(),
|
||||
language_server_name,
|
||||
language_server_id,
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
let worktree_root = worktree.update(&mut cx, |worktree, _| worktree.abs_path())?;
|
||||
let relative_path = if let Some(known_path) = known_relative_path {
|
||||
known_path
|
||||
} else {
|
||||
@@ -3478,12 +3573,13 @@ impl LspStore {
|
||||
worktree_id: worktree.update(&mut cx, |worktree, _| worktree.id())?,
|
||||
path: relative_path,
|
||||
};
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.buffer_store().update(cx, |buffer_store, cx| {
|
||||
buffer_store.open_buffer(project_path, cx)
|
||||
})
|
||||
})?
|
||||
.await
|
||||
lsp_store
|
||||
.update(&mut cx, |lsp_store, cx| {
|
||||
lsp_store.buffer_store().update(cx, |buffer_store, cx| {
|
||||
buffer_store.open_buffer(project_path, cx)
|
||||
})
|
||||
})?
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4579,16 +4675,16 @@ impl LspStore {
|
||||
|
||||
if registrations.remove(registration_id).is_some() {
|
||||
log::info!(
|
||||
"language server {}: unregistered workspace/DidChangeWatchedFiles capability with id {}",
|
||||
language_server_id,
|
||||
registration_id
|
||||
);
|
||||
"language server {}: unregistered workspace/DidChangeWatchedFiles capability with id {}",
|
||||
language_server_id,
|
||||
registration_id
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"language server {}: failed to unregister workspace/DidChangeWatchedFiles capability with id {}. not registered.",
|
||||
language_server_id,
|
||||
registration_id
|
||||
);
|
||||
"language server {}: failed to unregister workspace/DidChangeWatchedFiles capability with id {}. not registered.",
|
||||
language_server_id,
|
||||
registration_id
|
||||
);
|
||||
}
|
||||
|
||||
self.rebuild_watched_paths(language_server_id, cx);
|
||||
@@ -5078,6 +5174,7 @@ impl LspStore {
|
||||
buffers: HashSet<Model<Buffer>>,
|
||||
push_to_history: bool,
|
||||
trigger: FormatTrigger,
|
||||
target: FormatTarget,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<anyhow::Result<ProjectTransaction>> {
|
||||
if let Some(_) = self.as_local() {
|
||||
@@ -5114,6 +5211,7 @@ impl LspStore {
|
||||
formattable_buffers,
|
||||
push_to_history,
|
||||
trigger,
|
||||
target,
|
||||
cx.clone(),
|
||||
)
|
||||
.await;
|
||||
@@ -5172,7 +5270,7 @@ impl LspStore {
|
||||
buffers.insert(this.buffer_store.read(cx).get_existing(buffer_id)?);
|
||||
}
|
||||
let trigger = FormatTrigger::from_proto(envelope.payload.trigger);
|
||||
Ok::<_, anyhow::Error>(this.format(buffers, false, trigger, cx))
|
||||
Ok::<_, anyhow::Error>(this.format(buffers, false, trigger, FormatTarget::Buffer, cx))
|
||||
})??;
|
||||
|
||||
let project_transaction = format.await?;
|
||||
@@ -6485,11 +6583,11 @@ impl LspStore {
|
||||
})?;
|
||||
|
||||
let found_snapshot = snapshots
|
||||
.binary_search_by_key(&version, |e| e.version)
|
||||
.map(|ix| snapshots[ix].snapshot.clone())
|
||||
.map_err(|_| {
|
||||
anyhow!("snapshot not found for buffer {buffer_id} server {server_id} at version {version}")
|
||||
})?;
|
||||
.binary_search_by_key(&version, |e| e.version)
|
||||
.map(|ix| snapshots[ix].snapshot.clone())
|
||||
.map_err(|_| {
|
||||
anyhow!("snapshot not found for buffer {buffer_id} server {server_id} at version {version}")
|
||||
})?;
|
||||
|
||||
snapshots.retain(|snapshot| snapshot.version + OLD_VERSIONS_TO_RETAIN >= version);
|
||||
Ok(found_snapshot)
|
||||
@@ -7203,74 +7301,74 @@ impl LanguageServerWatchedPathsBuilder {
|
||||
let project = cx.weak_model();
|
||||
|
||||
cx.new_model(|cx| {
|
||||
let this_id = cx.entity_id();
|
||||
const LSP_ABS_PATH_OBSERVE: Duration = Duration::from_millis(100);
|
||||
let abs_paths = self
|
||||
.abs_paths
|
||||
.into_iter()
|
||||
.map(|(abs_path, globset)| {
|
||||
let task = cx.spawn({
|
||||
let abs_path = abs_path.clone();
|
||||
let fs = fs.clone();
|
||||
let this_id = cx.entity_id();
|
||||
const LSP_ABS_PATH_OBSERVE: Duration = Duration::from_millis(100);
|
||||
let abs_paths = self
|
||||
.abs_paths
|
||||
.into_iter()
|
||||
.map(|(abs_path, globset)| {
|
||||
let task = cx.spawn({
|
||||
let abs_path = abs_path.clone();
|
||||
let fs = fs.clone();
|
||||
|
||||
let lsp_store = project.clone();
|
||||
|_, mut cx| async move {
|
||||
maybe!(async move {
|
||||
let mut push_updates =
|
||||
fs.watch(&abs_path, LSP_ABS_PATH_OBSERVE).await;
|
||||
while let Some(update) = push_updates.0.next().await {
|
||||
let action = lsp_store
|
||||
.update(&mut cx, |this, cx| {
|
||||
let Some(local) = this.as_local() else {
|
||||
return ControlFlow::Break(());
|
||||
};
|
||||
let Some(watcher) = local
|
||||
.language_server_watched_paths
|
||||
.get(&language_server_id)
|
||||
else {
|
||||
return ControlFlow::Break(());
|
||||
};
|
||||
if watcher.entity_id() != this_id {
|
||||
// This watcher is no longer registered on the project, which means that we should
|
||||
// cease operations.
|
||||
return ControlFlow::Break(());
|
||||
}
|
||||
let (globs, _) = watcher
|
||||
.read(cx)
|
||||
.abs_paths
|
||||
.get(&abs_path)
|
||||
.expect(
|
||||
"Watched abs path is not registered with a watcher",
|
||||
);
|
||||
let matching_entries = update
|
||||
.into_iter()
|
||||
.filter(|event| globs.is_match(&event.path))
|
||||
.collect::<Vec<_>>();
|
||||
this.lsp_notify_abs_paths_changed(
|
||||
language_server_id,
|
||||
matching_entries,
|
||||
);
|
||||
ControlFlow::Continue(())
|
||||
})
|
||||
.ok()?;
|
||||
let lsp_store = project.clone();
|
||||
|_, mut cx| async move {
|
||||
maybe!(async move {
|
||||
let mut push_updates =
|
||||
fs.watch(&abs_path, LSP_ABS_PATH_OBSERVE).await;
|
||||
while let Some(update) = push_updates.0.next().await {
|
||||
let action = lsp_store
|
||||
.update(&mut cx, |this, cx| {
|
||||
let Some(local) = this.as_local() else {
|
||||
return ControlFlow::Break(());
|
||||
};
|
||||
let Some(watcher) = local
|
||||
.language_server_watched_paths
|
||||
.get(&language_server_id)
|
||||
else {
|
||||
return ControlFlow::Break(());
|
||||
};
|
||||
if watcher.entity_id() != this_id {
|
||||
// This watcher is no longer registered on the project, which means that we should
|
||||
// cease operations.
|
||||
return ControlFlow::Break(());
|
||||
}
|
||||
let (globs, _) = watcher
|
||||
.read(cx)
|
||||
.abs_paths
|
||||
.get(&abs_path)
|
||||
.expect(
|
||||
"Watched abs path is not registered with a watcher",
|
||||
);
|
||||
let matching_entries = update
|
||||
.into_iter()
|
||||
.filter(|event| globs.is_match(&event.path))
|
||||
.collect::<Vec<_>>();
|
||||
this.lsp_notify_abs_paths_changed(
|
||||
language_server_id,
|
||||
matching_entries,
|
||||
);
|
||||
ControlFlow::Continue(())
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
if action.is_break() {
|
||||
break;
|
||||
if action.is_break() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
});
|
||||
(abs_path, (globset, task))
|
||||
})
|
||||
.collect();
|
||||
LanguageServerWatchedPaths {
|
||||
worktree_paths: self.worktree_paths,
|
||||
abs_paths,
|
||||
}
|
||||
})
|
||||
Some(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
});
|
||||
(abs_path, (globset, task))
|
||||
})
|
||||
.collect();
|
||||
LanguageServerWatchedPaths {
|
||||
worktree_paths: self.worktree_paths,
|
||||
abs_paths,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -610,11 +610,13 @@ impl PrettierStore {
|
||||
) {
|
||||
let mut prettier_plugins_by_worktree = HashMap::default();
|
||||
for (worktree, language_settings) in language_formatters_to_check {
|
||||
if let Some(plugins) = prettier_plugins_for_language(&language_settings) {
|
||||
prettier_plugins_by_worktree
|
||||
.entry(worktree)
|
||||
.or_insert_with(HashSet::default)
|
||||
.extend(plugins.iter().cloned());
|
||||
if language_settings.prettier.allowed {
|
||||
if let Some(plugins) = prettier_plugins_for_language(&language_settings) {
|
||||
prettier_plugins_by_worktree
|
||||
.entry(worktree)
|
||||
.or_insert_with(HashSet::default)
|
||||
.extend(plugins.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (worktree, prettier_plugins) in prettier_plugins_by_worktree {
|
||||
|
||||
@@ -2505,10 +2505,11 @@ impl Project {
|
||||
buffers: HashSet<Model<Buffer>>,
|
||||
push_to_history: bool,
|
||||
trigger: lsp_store::FormatTrigger,
|
||||
target: lsp_store::FormatTarget,
|
||||
cx: &mut ModelContext<Project>,
|
||||
) -> Task<anyhow::Result<ProjectTransaction>> {
|
||||
self.lsp_store.update(cx, |lsp_store, cx| {
|
||||
lsp_store.format(buffers, push_to_history, trigger, cx)
|
||||
lsp_store.format(buffers, push_to_history, trigger, target, cx)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3462,6 +3463,17 @@ impl Project {
|
||||
self.buffer_store.read(cx).blame_buffer(buffer, version, cx)
|
||||
}
|
||||
|
||||
pub fn get_permalink_to_line(
|
||||
&self,
|
||||
buffer: &Model<Buffer>,
|
||||
selection: Range<u32>,
|
||||
cx: &AppContext,
|
||||
) -> Task<Result<url::Url>> {
|
||||
self.buffer_store
|
||||
.read(cx)
|
||||
.get_permalink_to_line(buffer, selection, cx)
|
||||
}
|
||||
|
||||
// RPC message handlers
|
||||
|
||||
async fn handle_unshare_project(
|
||||
@@ -3978,17 +3990,6 @@ impl Project {
|
||||
.read(cx)
|
||||
.language_servers_for_buffer(buffer, cx)
|
||||
}
|
||||
|
||||
pub fn language_server_for_buffer<'a>(
|
||||
&'a self,
|
||||
buffer: &'a Buffer,
|
||||
server_id: LanguageServerId,
|
||||
cx: &'a AppContext,
|
||||
) -> Option<(&'a Arc<CachedLspAdapter>, &'a Arc<LanguageServer>)> {
|
||||
self.lsp_store
|
||||
.read(cx)
|
||||
.language_server_for_buffer(buffer, server_id, cx)
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_code_actions(code_actions: &HashMap<String, bool>) -> Vec<lsp::CodeActionKind> {
|
||||
|
||||
@@ -6,6 +6,7 @@ use itertools::Itertools;
|
||||
use settings::{Settings, SettingsLocation};
|
||||
use smol::channel::bounded;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
env::{self},
|
||||
iter,
|
||||
path::{Path, PathBuf},
|
||||
@@ -341,10 +342,9 @@ pub fn wrap_for_ssh(
|
||||
venv_directory: Option<PathBuf>,
|
||||
) -> (String, Vec<String>) {
|
||||
let to_run = if let Some((command, args)) = command {
|
||||
iter::once(command)
|
||||
.chain(args)
|
||||
.filter_map(|arg| shlex::try_quote(arg).ok())
|
||||
.join(" ")
|
||||
let command = Cow::Borrowed(command.as_str());
|
||||
let args = args.iter().filter_map(|arg| shlex::try_quote(arg).ok());
|
||||
iter::once(command).chain(args).join(" ")
|
||||
} else {
|
||||
"exec ${SHELL:-sh} -l".to_string()
|
||||
};
|
||||
@@ -390,9 +390,7 @@ pub fn wrap_for_ssh(
|
||||
SshCommand::Direct(ssh_args) => ("ssh".to_string(), ssh_args.clone()),
|
||||
};
|
||||
|
||||
if command.is_none() {
|
||||
args.push("-t".to_string())
|
||||
}
|
||||
args.push("-t".to_string());
|
||||
args.push(shell_invocation);
|
||||
(program, args)
|
||||
}
|
||||
|
||||
@@ -247,14 +247,9 @@ impl WorktreeStore {
|
||||
if abs_path.starts_with("/~") {
|
||||
abs_path = abs_path[1..].to_string();
|
||||
}
|
||||
if abs_path.is_empty() {
|
||||
if abs_path.is_empty() || abs_path == "/" {
|
||||
abs_path = "~/".to_string();
|
||||
}
|
||||
let root_name = PathBuf::from(abs_path.clone())
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let this = this.upgrade().context("Dropped worktree store")?;
|
||||
|
||||
@@ -272,6 +267,11 @@ impl WorktreeStore {
|
||||
return Ok(existing_worktree);
|
||||
}
|
||||
|
||||
let root_name = PathBuf::from(&response.canonicalized_path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or(response.canonicalized_path.to_string());
|
||||
|
||||
let worktree = cx.update(|cx| {
|
||||
Worktree::remote(
|
||||
SSH_PROJECT_ID,
|
||||
@@ -280,7 +280,7 @@ impl WorktreeStore {
|
||||
id: response.worktree_id,
|
||||
root_name,
|
||||
visible,
|
||||
abs_path,
|
||||
abs_path: response.canonicalized_path,
|
||||
},
|
||||
client,
|
||||
cx,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
mod project_panel_settings;
|
||||
mod scrollbar;
|
||||
|
||||
use client::{ErrorCode, ErrorExt};
|
||||
use scrollbar::ProjectPanelScrollbar;
|
||||
use settings::{Settings, SettingsStore};
|
||||
use ui::{Scrollbar, ScrollbarState};
|
||||
|
||||
use db::kvp::KEY_VALUE_STORE;
|
||||
use editor::{
|
||||
@@ -14,16 +14,14 @@ use file_icons::FileIcons;
|
||||
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use collections::{hash_map, BTreeSet, HashMap};
|
||||
use core::f32;
|
||||
use git::repository::GitFileStatus;
|
||||
use gpui::{
|
||||
actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement,
|
||||
AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, DragMoveEvent,
|
||||
Entity, EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement,
|
||||
KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Model, MouseButton,
|
||||
MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled,
|
||||
Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView,
|
||||
WindowContext,
|
||||
EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement, KeyContext,
|
||||
ListHorizontalSizingBehavior, ListSizingBehavior, Model, MouseButton, MouseDownEvent,
|
||||
ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled, Subscription, Task,
|
||||
UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView, WindowContext,
|
||||
};
|
||||
use indexmap::IndexMap;
|
||||
use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
|
||||
@@ -34,12 +32,11 @@ use project::{
|
||||
use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
cell::{Cell, OnceCell},
|
||||
cell::OnceCell,
|
||||
collections::HashSet,
|
||||
ffi::OsStr,
|
||||
ops::Range,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
@@ -59,8 +56,8 @@ const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
|
||||
pub struct ProjectPanel {
|
||||
project: Model<Project>,
|
||||
fs: Arc<dyn Fs>,
|
||||
scroll_handle: UniformListScrollHandle,
|
||||
focus_handle: FocusHandle,
|
||||
scroll_handle: UniformListScrollHandle,
|
||||
visible_entries: Vec<(WorktreeId, Vec<Entry>, OnceCell<HashSet<Arc<Path>>>)>,
|
||||
/// Maps from leaf project entry ID to the currently selected ancestor.
|
||||
/// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several
|
||||
@@ -82,8 +79,8 @@ pub struct ProjectPanel {
|
||||
width: Option<Pixels>,
|
||||
pending_serialization: Task<Option<()>>,
|
||||
show_scrollbar: bool,
|
||||
vertical_scrollbar_drag_thumb_offset: Rc<Cell<Option<f32>>>,
|
||||
horizontal_scrollbar_drag_thumb_offset: Rc<Cell<Option<f32>>>,
|
||||
vertical_scrollbar_state: ScrollbarState,
|
||||
horizontal_scrollbar_state: ScrollbarState,
|
||||
hide_scrollbar_task: Option<Task<()>>,
|
||||
max_width_item_index: Option<usize>,
|
||||
}
|
||||
@@ -297,10 +294,10 @@ impl ProjectPanel {
|
||||
})
|
||||
.detach();
|
||||
|
||||
let scroll_handle = UniformListScrollHandle::new();
|
||||
let mut this = Self {
|
||||
project: project.clone(),
|
||||
fs: workspace.app_state().fs.clone(),
|
||||
scroll_handle: UniformListScrollHandle::new(),
|
||||
focus_handle,
|
||||
visible_entries: Default::default(),
|
||||
ancestors: Default::default(),
|
||||
@@ -320,9 +317,12 @@ impl ProjectPanel {
|
||||
pending_serialization: Task::ready(None),
|
||||
show_scrollbar: !Self::should_autohide_scrollbar(cx),
|
||||
hide_scrollbar_task: None,
|
||||
vertical_scrollbar_drag_thumb_offset: Default::default(),
|
||||
horizontal_scrollbar_drag_thumb_offset: Default::default(),
|
||||
vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
|
||||
.parent_view(cx.view()),
|
||||
horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
|
||||
.parent_view(cx.view()),
|
||||
max_width_item_index: None,
|
||||
scroll_handle,
|
||||
};
|
||||
this.update_visible_entries(None, cx);
|
||||
|
||||
@@ -2606,37 +2606,11 @@ impl ProjectPanel {
|
||||
}
|
||||
|
||||
fn render_vertical_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
|
||||
if !Self::should_show_scrollbar(cx) {
|
||||
return None;
|
||||
}
|
||||
let scroll_handle = self.scroll_handle.0.borrow();
|
||||
let total_list_length = scroll_handle
|
||||
.last_item_size
|
||||
.filter(|_| {
|
||||
self.show_scrollbar || self.vertical_scrollbar_drag_thumb_offset.get().is_some()
|
||||
})?
|
||||
.contents
|
||||
.height
|
||||
.0 as f64;
|
||||
let current_offset = scroll_handle.base_handle.offset().y.0.min(0.).abs() as f64;
|
||||
let mut percentage = current_offset / total_list_length;
|
||||
let end_offset = (current_offset + scroll_handle.base_handle.bounds().size.height.0 as f64)
|
||||
/ total_list_length;
|
||||
// Uniform scroll handle might briefly report an offset greater than the length of a list;
|
||||
// in such case we'll adjust the starting offset as well to keep the scrollbar thumb length stable.
|
||||
let overshoot = (end_offset - 1.).clamp(0., 1.);
|
||||
if overshoot > 0. {
|
||||
percentage -= overshoot;
|
||||
}
|
||||
const MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT: f64 = 0.005;
|
||||
if percentage + MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT > 1.0 || end_offset > total_list_length
|
||||
if !Self::should_show_scrollbar(cx)
|
||||
|| !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if total_list_length < scroll_handle.base_handle.bounds().size.height.0 as f64 {
|
||||
return None;
|
||||
}
|
||||
let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT, 1.);
|
||||
Some(
|
||||
div()
|
||||
.occlude()
|
||||
@@ -2654,7 +2628,7 @@ impl ProjectPanel {
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
cx.listener(|this, _, cx| {
|
||||
if this.vertical_scrollbar_drag_thumb_offset.get().is_none()
|
||||
if !this.vertical_scrollbar_state.is_dragging()
|
||||
&& !this.focus_handle.contains_focused(cx)
|
||||
{
|
||||
this.hide_scrollbar(cx);
|
||||
@@ -2674,48 +2648,20 @@ impl ProjectPanel {
|
||||
.bottom_1()
|
||||
.w(px(12.))
|
||||
.cursor_default()
|
||||
.child(ProjectPanelScrollbar::vertical(
|
||||
percentage as f32..end_offset as f32,
|
||||
self.scroll_handle.clone(),
|
||||
self.vertical_scrollbar_drag_thumb_offset.clone(),
|
||||
cx.view().entity_id(),
|
||||
.children(Scrollbar::vertical(
|
||||
// percentage as f32..end_offset as f32,
|
||||
self.vertical_scrollbar_state.clone(),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_horizontal_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
|
||||
if !Self::should_show_scrollbar(cx) {
|
||||
return None;
|
||||
}
|
||||
let scroll_handle = self.scroll_handle.0.borrow();
|
||||
let longest_item_width = scroll_handle
|
||||
.last_item_size
|
||||
.filter(|_| {
|
||||
self.show_scrollbar || self.horizontal_scrollbar_drag_thumb_offset.get().is_some()
|
||||
})
|
||||
.filter(|size| size.contents.width > size.item.width)?
|
||||
.contents
|
||||
.width
|
||||
.0 as f64;
|
||||
let current_offset = scroll_handle.base_handle.offset().x.0.min(0.).abs() as f64;
|
||||
let mut percentage = current_offset / longest_item_width;
|
||||
let end_offset = (current_offset + scroll_handle.base_handle.bounds().size.width.0 as f64)
|
||||
/ longest_item_width;
|
||||
// Uniform scroll handle might briefly report an offset greater than the length of a list;
|
||||
// in such case we'll adjust the starting offset as well to keep the scrollbar thumb length stable.
|
||||
let overshoot = (end_offset - 1.).clamp(0., 1.);
|
||||
if overshoot > 0. {
|
||||
percentage -= overshoot;
|
||||
}
|
||||
const MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH: f64 = 0.005;
|
||||
if percentage + MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH > 1.0 || end_offset > longest_item_width
|
||||
if !Self::should_show_scrollbar(cx)
|
||||
|| !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
|
||||
return None;
|
||||
}
|
||||
let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH, 1.);
|
||||
|
||||
Some(
|
||||
div()
|
||||
.occlude()
|
||||
@@ -2733,7 +2679,7 @@ impl ProjectPanel {
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
cx.listener(|this, _, cx| {
|
||||
if this.horizontal_scrollbar_drag_thumb_offset.get().is_none()
|
||||
if !this.horizontal_scrollbar_state.is_dragging()
|
||||
&& !this.focus_handle.contains_focused(cx)
|
||||
{
|
||||
this.hide_scrollbar(cx);
|
||||
@@ -2754,11 +2700,9 @@ impl ProjectPanel {
|
||||
.h(px(12.))
|
||||
.cursor_default()
|
||||
.when(self.width.is_some(), |this| {
|
||||
this.child(ProjectPanelScrollbar::horizontal(
|
||||
percentage as f32..end_offset as f32,
|
||||
self.scroll_handle.clone(),
|
||||
self.horizontal_scrollbar_drag_thumb_offset.clone(),
|
||||
cx.view().entity_id(),
|
||||
this.children(Scrollbar::horizontal(
|
||||
//percentage as f32..end_offset as f32,
|
||||
self.horizontal_scrollbar_state.clone(),
|
||||
))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
use std::{cell::Cell, ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
point, quad, Bounds, ContentMask, Corners, Edges, EntityId, Hitbox, Hsla, MouseDownEvent,
|
||||
MouseMoveEvent, MouseUpEvent, ScrollWheelEvent, Style, UniformListScrollHandle,
|
||||
};
|
||||
use ui::{prelude::*, px, relative, IntoElement};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ScrollbarKind {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
pub(crate) struct ProjectPanelScrollbar {
|
||||
thumb: Range<f32>,
|
||||
scroll: UniformListScrollHandle,
|
||||
// If Some(), there's an active drag, offset by percentage from the top of thumb.
|
||||
scrollbar_drag_state: Rc<Cell<Option<f32>>>,
|
||||
kind: ScrollbarKind,
|
||||
parent_id: EntityId,
|
||||
}
|
||||
|
||||
impl ProjectPanelScrollbar {
|
||||
pub(crate) fn vertical(
|
||||
thumb: Range<f32>,
|
||||
scroll: UniformListScrollHandle,
|
||||
scrollbar_drag_state: Rc<Cell<Option<f32>>>,
|
||||
parent_id: EntityId,
|
||||
) -> Self {
|
||||
Self {
|
||||
thumb,
|
||||
scroll,
|
||||
scrollbar_drag_state,
|
||||
kind: ScrollbarKind::Vertical,
|
||||
parent_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn horizontal(
|
||||
thumb: Range<f32>,
|
||||
scroll: UniformListScrollHandle,
|
||||
scrollbar_drag_state: Rc<Cell<Option<f32>>>,
|
||||
parent_id: EntityId,
|
||||
) -> Self {
|
||||
Self {
|
||||
thumb,
|
||||
scroll,
|
||||
scrollbar_drag_state,
|
||||
kind: ScrollbarKind::Horizontal,
|
||||
parent_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl gpui::Element for ProjectPanelScrollbar {
|
||||
type RequestLayoutState = ();
|
||||
|
||||
type PrepaintState = Hitbox;
|
||||
|
||||
fn id(&self) -> Option<ui::ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&gpui::GlobalElementId>,
|
||||
cx: &mut ui::WindowContext,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
style.flex_grow = 1.;
|
||||
style.flex_shrink = 1.;
|
||||
if self.kind == ScrollbarKind::Vertical {
|
||||
style.size.width = px(12.).into();
|
||||
style.size.height = relative(1.).into();
|
||||
} else {
|
||||
style.size.width = relative(1.).into();
|
||||
style.size.height = px(12.).into();
|
||||
}
|
||||
|
||||
(cx.request_layout(style, None), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&gpui::GlobalElementId>,
|
||||
bounds: Bounds<ui::Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
cx: &mut ui::WindowContext,
|
||||
) -> Self::PrepaintState {
|
||||
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
|
||||
cx.insert_hitbox(bounds, false)
|
||||
})
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&gpui::GlobalElementId>,
|
||||
bounds: Bounds<ui::Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
_prepaint: &mut Self::PrepaintState,
|
||||
cx: &mut ui::WindowContext,
|
||||
) {
|
||||
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
|
||||
let colors = cx.theme().colors();
|
||||
let thumb_background = colors.scrollbar_thumb_background;
|
||||
let is_vertical = self.kind == ScrollbarKind::Vertical;
|
||||
let extra_padding = px(5.0);
|
||||
let padded_bounds = if is_vertical {
|
||||
Bounds::from_corners(
|
||||
bounds.origin + point(Pixels::ZERO, extra_padding),
|
||||
bounds.lower_right() - point(Pixels::ZERO, extra_padding * 3),
|
||||
)
|
||||
} else {
|
||||
Bounds::from_corners(
|
||||
bounds.origin + point(extra_padding, Pixels::ZERO),
|
||||
bounds.lower_right() - point(extra_padding * 3, Pixels::ZERO),
|
||||
)
|
||||
};
|
||||
|
||||
let mut thumb_bounds = if is_vertical {
|
||||
let thumb_offset = self.thumb.start * padded_bounds.size.height;
|
||||
let thumb_end = self.thumb.end * padded_bounds.size.height;
|
||||
let thumb_upper_left = point(
|
||||
padded_bounds.origin.x,
|
||||
padded_bounds.origin.y + thumb_offset,
|
||||
);
|
||||
let thumb_lower_right = point(
|
||||
padded_bounds.origin.x + padded_bounds.size.width,
|
||||
padded_bounds.origin.y + thumb_end,
|
||||
);
|
||||
Bounds::from_corners(thumb_upper_left, thumb_lower_right)
|
||||
} else {
|
||||
let thumb_offset = self.thumb.start * padded_bounds.size.width;
|
||||
let thumb_end = self.thumb.end * padded_bounds.size.width;
|
||||
let thumb_upper_left = point(
|
||||
padded_bounds.origin.x + thumb_offset,
|
||||
padded_bounds.origin.y,
|
||||
);
|
||||
let thumb_lower_right = point(
|
||||
padded_bounds.origin.x + thumb_end,
|
||||
padded_bounds.origin.y + padded_bounds.size.height,
|
||||
);
|
||||
Bounds::from_corners(thumb_upper_left, thumb_lower_right)
|
||||
};
|
||||
let corners = if is_vertical {
|
||||
thumb_bounds.size.width /= 1.5;
|
||||
Corners::all(thumb_bounds.size.width / 2.0)
|
||||
} else {
|
||||
thumb_bounds.size.height /= 1.5;
|
||||
Corners::all(thumb_bounds.size.height / 2.0)
|
||||
};
|
||||
cx.paint_quad(quad(
|
||||
thumb_bounds,
|
||||
corners,
|
||||
thumb_background,
|
||||
Edges::default(),
|
||||
Hsla::transparent_black(),
|
||||
));
|
||||
|
||||
let scroll = self.scroll.clone();
|
||||
let kind = self.kind;
|
||||
let thumb_percentage_size = self.thumb.end - self.thumb.start;
|
||||
|
||||
cx.on_mouse_event({
|
||||
let scroll = self.scroll.clone();
|
||||
let is_dragging = self.scrollbar_drag_state.clone();
|
||||
move |event: &MouseDownEvent, phase, _cx| {
|
||||
if phase.bubble() && bounds.contains(&event.position) {
|
||||
if !thumb_bounds.contains(&event.position) {
|
||||
let scroll = scroll.0.borrow();
|
||||
if let Some(item_size) = scroll.last_item_size {
|
||||
match kind {
|
||||
ScrollbarKind::Horizontal => {
|
||||
let percentage = (event.position.x - bounds.origin.x)
|
||||
/ bounds.size.width;
|
||||
let max_offset = item_size.contents.width;
|
||||
let percentage = percentage.min(1. - thumb_percentage_size);
|
||||
scroll.base_handle.set_offset(point(
|
||||
-max_offset * percentage,
|
||||
scroll.base_handle.offset().y,
|
||||
));
|
||||
}
|
||||
ScrollbarKind::Vertical => {
|
||||
let percentage = (event.position.y - bounds.origin.y)
|
||||
/ bounds.size.height;
|
||||
let max_offset = item_size.contents.height;
|
||||
let percentage = percentage.min(1. - thumb_percentage_size);
|
||||
scroll.base_handle.set_offset(point(
|
||||
scroll.base_handle.offset().x,
|
||||
-max_offset * percentage,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let thumb_offset = if is_vertical {
|
||||
(event.position.y - thumb_bounds.origin.y) / bounds.size.height
|
||||
} else {
|
||||
(event.position.x - thumb_bounds.origin.x) / bounds.size.width
|
||||
};
|
||||
is_dragging.set(Some(thumb_offset));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
cx.on_mouse_event({
|
||||
let scroll = self.scroll.clone();
|
||||
move |event: &ScrollWheelEvent, phase, cx| {
|
||||
if phase.bubble() && bounds.contains(&event.position) {
|
||||
let scroll = scroll.0.borrow_mut();
|
||||
let current_offset = scroll.base_handle.offset();
|
||||
|
||||
scroll
|
||||
.base_handle
|
||||
.set_offset(current_offset + event.delta.pixel_delta(cx.line_height()));
|
||||
}
|
||||
}
|
||||
});
|
||||
let drag_state = self.scrollbar_drag_state.clone();
|
||||
let view_id = self.parent_id;
|
||||
let kind = self.kind;
|
||||
cx.on_mouse_event(move |event: &MouseMoveEvent, _, cx| {
|
||||
if let Some(drag_state) = drag_state.get().filter(|_| event.dragging()) {
|
||||
let scroll = scroll.0.borrow();
|
||||
if let Some(item_size) = scroll.last_item_size {
|
||||
match kind {
|
||||
ScrollbarKind::Horizontal => {
|
||||
let max_offset = item_size.contents.width;
|
||||
let percentage = (event.position.x - bounds.origin.x)
|
||||
/ bounds.size.width
|
||||
- drag_state;
|
||||
|
||||
let percentage = percentage.min(1. - thumb_percentage_size);
|
||||
scroll.base_handle.set_offset(point(
|
||||
-max_offset * percentage,
|
||||
scroll.base_handle.offset().y,
|
||||
));
|
||||
}
|
||||
ScrollbarKind::Vertical => {
|
||||
let max_offset = item_size.contents.height;
|
||||
let percentage = (event.position.y - bounds.origin.y)
|
||||
/ bounds.size.height
|
||||
- drag_state;
|
||||
|
||||
let percentage = percentage.min(1. - thumb_percentage_size);
|
||||
scroll.base_handle.set_offset(point(
|
||||
scroll.base_handle.offset().x,
|
||||
-max_offset * percentage,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
cx.notify(view_id);
|
||||
}
|
||||
} else {
|
||||
drag_state.set(None);
|
||||
}
|
||||
});
|
||||
let is_dragging = self.scrollbar_drag_state.clone();
|
||||
cx.on_mouse_event(move |_event: &MouseUpEvent, phase, cx| {
|
||||
if phase.bubble() {
|
||||
is_dragging.set(None);
|
||||
cx.notify(view_id);
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for ProjectPanelScrollbar {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -292,7 +292,10 @@ message Envelope {
|
||||
Toast toast = 261;
|
||||
HideToast hide_toast = 262;
|
||||
|
||||
OpenServerSettings open_server_settings = 263; // current max
|
||||
OpenServerSettings open_server_settings = 263;
|
||||
|
||||
GetPermalinkToLine get_permalink_to_line = 264;
|
||||
GetPermalinkToLineResponse get_permalink_to_line_response = 265; // current max
|
||||
}
|
||||
|
||||
reserved 87 to 88;
|
||||
@@ -1207,7 +1210,7 @@ message ResolveState {
|
||||
}
|
||||
|
||||
message LspResolveState {
|
||||
string value = 1;
|
||||
optional string value = 1;
|
||||
uint64 server_id = 2;
|
||||
}
|
||||
}
|
||||
@@ -2464,6 +2467,7 @@ message AddWorktree {
|
||||
|
||||
message AddWorktreeResponse {
|
||||
uint64 worktree_id = 1;
|
||||
string canonicalized_path = 2;
|
||||
}
|
||||
|
||||
message UpdateUserSettings {
|
||||
@@ -2507,3 +2511,13 @@ message HideToast {
|
||||
message OpenServerSettings {
|
||||
uint64 project_id = 1;
|
||||
}
|
||||
|
||||
message GetPermalinkToLine {
|
||||
uint64 project_id = 1;
|
||||
uint64 buffer_id = 2;
|
||||
Range selection = 3;
|
||||
}
|
||||
|
||||
message GetPermalinkToLineResponse {
|
||||
string permalink = 1;
|
||||
}
|
||||
|
||||
@@ -370,6 +370,8 @@ messages!(
|
||||
(Toast, Background),
|
||||
(HideToast, Background),
|
||||
(OpenServerSettings, Foreground),
|
||||
(GetPermalinkToLine, Foreground),
|
||||
(GetPermalinkToLineResponse, Foreground),
|
||||
);
|
||||
|
||||
request_messages!(
|
||||
@@ -494,7 +496,8 @@ request_messages!(
|
||||
(CheckFileExists, CheckFileExistsResponse),
|
||||
(ShutdownRemoteServer, Ack),
|
||||
(RemoveWorktree, Ack),
|
||||
(OpenServerSettings, OpenBufferResponse)
|
||||
(OpenServerSettings, OpenBufferResponse),
|
||||
(GetPermalinkToLine, GetPermalinkToLineResponse),
|
||||
);
|
||||
|
||||
entity_messages!(
|
||||
@@ -571,7 +574,7 @@ entity_messages!(
|
||||
Toast,
|
||||
HideToast,
|
||||
OpenServerSettings,
|
||||
|
||||
GetPermalinkToLine,
|
||||
);
|
||||
|
||||
entity_messages!(
|
||||
|
||||
@@ -22,6 +22,7 @@ file_finder.workspace = true
|
||||
futures.workspace = true
|
||||
fuzzy.workspace = true
|
||||
gpui.workspace = true
|
||||
itertools.workspace = true
|
||||
log.workspace = true
|
||||
menu.workspace = true
|
||||
ordered-float.workspace = true
|
||||
|
||||
@@ -13,20 +13,19 @@ use futures::channel::oneshot;
|
||||
use futures::future::Shared;
|
||||
use futures::FutureExt;
|
||||
use gpui::canvas;
|
||||
use gpui::pulsating_between;
|
||||
use gpui::AsyncWindowContext;
|
||||
use gpui::ClipboardItem;
|
||||
use gpui::Subscription;
|
||||
use gpui::Task;
|
||||
use gpui::WeakView;
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle,
|
||||
FocusableView, FontWeight, Model, ScrollHandle, View, ViewContext,
|
||||
AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, FontWeight,
|
||||
Model, PromptLevel, ScrollHandle, View, ViewContext,
|
||||
};
|
||||
use picker::Picker;
|
||||
use project::terminals::wrap_for_ssh;
|
||||
use project::terminals::SshCommand;
|
||||
use project::Project;
|
||||
use remote::SshConnectionOptions;
|
||||
use rpc::proto::DevServerStatus;
|
||||
use settings::update_settings_file;
|
||||
use settings::Settings;
|
||||
@@ -34,6 +33,8 @@ use task::HideStrategy;
|
||||
use task::RevealStrategy;
|
||||
use task::SpawnInTerminal;
|
||||
use terminal_view::terminal_panel::TerminalPanel;
|
||||
use ui::Scrollbar;
|
||||
use ui::ScrollbarState;
|
||||
use ui::Section;
|
||||
use ui::{prelude::*, IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Tooltip};
|
||||
use util::ResultExt;
|
||||
@@ -58,16 +59,15 @@ pub struct DevServerProjects {
|
||||
mode: Mode,
|
||||
focus_handle: FocusHandle,
|
||||
scroll_handle: ScrollHandle,
|
||||
dev_server_store: Model<dev_server_projects::Store>,
|
||||
workspace: WeakView<Workspace>,
|
||||
_dev_server_subscription: Subscription,
|
||||
selectable_items: SelectableItemList,
|
||||
}
|
||||
|
||||
struct CreateDevServer {
|
||||
address_editor: View<Editor>,
|
||||
creating: Option<Task<Option<()>>>,
|
||||
address_error: Option<SharedString>,
|
||||
ssh_prompt: Option<View<SshPrompt>>,
|
||||
_creating: Option<Task<Option<()>>>,
|
||||
}
|
||||
|
||||
impl CreateDevServer {
|
||||
@@ -78,8 +78,9 @@ impl CreateDevServer {
|
||||
});
|
||||
Self {
|
||||
address_editor,
|
||||
creating: None,
|
||||
address_error: None,
|
||||
ssh_prompt: None,
|
||||
_creating: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,13 +302,19 @@ impl gpui::Render for ProjectPicker {
|
||||
}
|
||||
}
|
||||
enum Mode {
|
||||
Default,
|
||||
Default(ScrollbarState),
|
||||
ViewServerOptions(usize, SshConnection),
|
||||
EditNickname(EditNicknameState),
|
||||
ProjectPicker(View<ProjectPicker>),
|
||||
CreateDevServer(CreateDevServer),
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
fn default_mode() -> Self {
|
||||
let handle = ScrollHandle::new();
|
||||
Self::Default(ScrollbarState::new(handle))
|
||||
}
|
||||
}
|
||||
impl DevServerProjects {
|
||||
pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
|
||||
workspace.register_action(|workspace, _: &OpenRemote, cx| {
|
||||
@@ -325,11 +332,6 @@ impl DevServerProjects {
|
||||
|
||||
pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
let dev_server_store = dev_server_projects::Store::global(cx);
|
||||
|
||||
let subscription = cx.observe(&dev_server_store, |_, _, cx| {
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let mut base_style = cx.text_style();
|
||||
base_style.refine(&gpui::TextStyleRefinement {
|
||||
@@ -338,24 +340,22 @@ impl DevServerProjects {
|
||||
});
|
||||
|
||||
Self {
|
||||
mode: Mode::Default,
|
||||
mode: Mode::default_mode(),
|
||||
focus_handle,
|
||||
scroll_handle: ScrollHandle::new(),
|
||||
dev_server_store,
|
||||
workspace,
|
||||
_dev_server_subscription: subscription,
|
||||
selectable_items: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_item(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
|
||||
if !matches!(self.mode, Mode::Default | Mode::ViewServerOptions(_, _)) {
|
||||
if !matches!(self.mode, Mode::Default(_) | Mode::ViewServerOptions(_, _)) {
|
||||
return;
|
||||
}
|
||||
self.selectable_items.next(cx);
|
||||
}
|
||||
fn prev_item(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
|
||||
if !matches!(self.mode, Mode::Default | Mode::ViewServerOptions(_, _)) {
|
||||
if !matches!(self.mode, Mode::Default(_) | Mode::ViewServerOptions(_, _)) {
|
||||
return;
|
||||
}
|
||||
self.selectable_items.prev(cx);
|
||||
@@ -380,34 +380,22 @@ impl DevServerProjects {
|
||||
}
|
||||
|
||||
fn create_ssh_server(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
|
||||
let host = get_text(&editor, cx);
|
||||
if host.is_empty() {
|
||||
let input = get_text(&editor, cx);
|
||||
if input.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut host = host.trim_start_matches("ssh ");
|
||||
let mut username: Option<String> = None;
|
||||
let mut port: Option<u16> = None;
|
||||
|
||||
if let Some((u, rest)) = host.split_once('@') {
|
||||
host = rest;
|
||||
username = Some(u.to_string());
|
||||
}
|
||||
if let Some((rest, p)) = host.split_once(':') {
|
||||
host = rest;
|
||||
port = p.parse().ok()
|
||||
}
|
||||
|
||||
if let Some((rest, p)) = host.split_once(" -p") {
|
||||
host = rest;
|
||||
port = p.trim().parse().ok()
|
||||
}
|
||||
|
||||
let connection_options = remote::SshConnectionOptions {
|
||||
host: host.to_string(),
|
||||
username: username.clone(),
|
||||
port,
|
||||
password: None,
|
||||
let connection_options = match SshConnectionOptions::parse_command_line(&input) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
self.mode = Mode::CreateDevServer(CreateDevServer {
|
||||
address_editor: editor,
|
||||
address_error: Some(format!("could not parse: {:?}", e).into()),
|
||||
ssh_prompt: None,
|
||||
_creating: None,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx));
|
||||
|
||||
@@ -419,6 +407,7 @@ impl DevServerProjects {
|
||||
)
|
||||
.prompt_err("Failed to connect", cx, |_, _| None);
|
||||
|
||||
let address_editor = editor.clone();
|
||||
let creating = cx.spawn(move |this, mut cx| async move {
|
||||
match connection.await {
|
||||
Some(_) => this
|
||||
@@ -431,25 +420,38 @@ impl DevServerProjects {
|
||||
});
|
||||
|
||||
this.add_ssh_server(connection_options, cx);
|
||||
this.mode = Mode::Default;
|
||||
this.mode = Mode::default_mode();
|
||||
this.selectable_items.reset_selection();
|
||||
cx.notify()
|
||||
})
|
||||
.log_err(),
|
||||
None => this
|
||||
.update(&mut cx, |this, cx| {
|
||||
this.mode = Mode::CreateDevServer(CreateDevServer::new(cx));
|
||||
address_editor.update(cx, |this, _| {
|
||||
this.set_read_only(false);
|
||||
});
|
||||
this.mode = Mode::CreateDevServer(CreateDevServer {
|
||||
address_editor,
|
||||
address_error: None,
|
||||
ssh_prompt: None,
|
||||
_creating: None,
|
||||
});
|
||||
cx.notify()
|
||||
})
|
||||
.log_err(),
|
||||
};
|
||||
None
|
||||
});
|
||||
let mut state = CreateDevServer::new(cx);
|
||||
state.address_editor = editor;
|
||||
state.ssh_prompt = Some(ssh_prompt.clone());
|
||||
state.creating = Some(creating);
|
||||
self.mode = Mode::CreateDevServer(state);
|
||||
|
||||
editor.update(cx, |this, _| {
|
||||
this.set_read_only(true);
|
||||
});
|
||||
self.mode = Mode::CreateDevServer(CreateDevServer {
|
||||
address_editor: editor,
|
||||
address_error: None,
|
||||
ssh_prompt: Some(ssh_prompt.clone()),
|
||||
_creating: Some(creating),
|
||||
});
|
||||
}
|
||||
|
||||
fn view_server_options(
|
||||
@@ -535,7 +537,7 @@ impl DevServerProjects {
|
||||
|
||||
fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
|
||||
match &self.mode {
|
||||
Mode::Default | Mode::ViewServerOptions(_, _) => {
|
||||
Mode::Default(_) | Mode::ViewServerOptions(_, _) => {
|
||||
let items = std::mem::take(&mut self.selectable_items);
|
||||
items.confirm(self, cx);
|
||||
self.selectable_items = items;
|
||||
@@ -549,9 +551,6 @@ impl DevServerProjects {
|
||||
return;
|
||||
}
|
||||
|
||||
state.address_editor.update(cx, |this, _| {
|
||||
this.set_read_only(true);
|
||||
});
|
||||
self.create_ssh_server(state.address_editor.clone(), cx);
|
||||
}
|
||||
Mode::EditNickname(state) => {
|
||||
@@ -566,7 +565,7 @@ impl DevServerProjects {
|
||||
}
|
||||
}
|
||||
});
|
||||
self.mode = Mode::Default;
|
||||
self.mode = Mode::default_mode();
|
||||
self.selectable_items.reset_selection();
|
||||
self.focus_handle.focus(cx);
|
||||
}
|
||||
@@ -575,14 +574,14 @@ impl DevServerProjects {
|
||||
|
||||
fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
|
||||
match &self.mode {
|
||||
Mode::Default => cx.emit(DismissEvent),
|
||||
Mode::Default(_) => cx.emit(DismissEvent),
|
||||
Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
|
||||
self.mode = Mode::CreateDevServer(CreateDevServer::new(cx));
|
||||
self.selectable_items.reset_selection();
|
||||
cx.notify();
|
||||
}
|
||||
_ => {
|
||||
self.mode = Mode::Default;
|
||||
self.mode = Mode::default_mode();
|
||||
self.selectable_items.reset_selection();
|
||||
self.focus_handle(cx).focus(cx);
|
||||
cx.notify();
|
||||
@@ -814,6 +813,7 @@ impl DevServerProjects {
|
||||
port: connection_options.port,
|
||||
projects: vec![],
|
||||
nickname: None,
|
||||
args: connection_options.args.unwrap_or_default(),
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -827,10 +827,7 @@ impl DevServerProjects {
|
||||
|
||||
state.address_editor.update(cx, |editor, cx| {
|
||||
if editor.text(cx).is_empty() {
|
||||
editor.set_placeholder_text(
|
||||
"Enter the command you use to SSH into this server: e.g., ssh me@my.server",
|
||||
cx,
|
||||
);
|
||||
editor.set_placeholder_text("ssh user@example -p 2222", cx);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -856,27 +853,38 @@ impl DevServerProjects {
|
||||
.map(|this| {
|
||||
if let Some(ssh_prompt) = ssh_prompt {
|
||||
this.child(h_flex().w_full().child(ssh_prompt))
|
||||
} else if let Some(address_error) = &state.address_error {
|
||||
this.child(
|
||||
h_flex().p_2().w_full().gap_2().child(
|
||||
Label::new(address_error.clone())
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Error),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
let color = Color::Muted.color(cx);
|
||||
this.child(
|
||||
h_flex()
|
||||
.p_2()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.gap_1()
|
||||
.child(
|
||||
div().size_1p5().rounded_full().bg(color).with_animation(
|
||||
"pulse-ssh-waiting-for-connection",
|
||||
Animation::new(Duration::from_secs(2))
|
||||
.repeat()
|
||||
.with_easing(pulsating_between(0.2, 0.5)),
|
||||
move |this, progress| this.bg(color.opacity(progress)),
|
||||
),
|
||||
Label::new(
|
||||
"Enter the command you use to SSH into this server.",
|
||||
)
|
||||
.color(Color::Muted)
|
||||
.size(LabelSize::Small),
|
||||
)
|
||||
.child(
|
||||
Label::new("Waiting for connection…")
|
||||
.size(LabelSize::Small),
|
||||
Button::new("learn-more", "Learn more…")
|
||||
.label_size(LabelSize::Small)
|
||||
.size(ButtonSize::None)
|
||||
.color(Color::Accent)
|
||||
.style(ButtonStyle::Transparent)
|
||||
.on_click(|_, cx| {
|
||||
cx.open_url(
|
||||
"https://zed.dev/docs/remote-development",
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -988,46 +996,38 @@ impl DevServerProjects {
|
||||
.child({
|
||||
fn remove_ssh_server(
|
||||
dev_servers: View<DevServerProjects>,
|
||||
workspace: WeakView<Workspace>,
|
||||
index: usize,
|
||||
connection_string: SharedString,
|
||||
cx: &mut WindowContext<'_>,
|
||||
) {
|
||||
workspace
|
||||
.update(cx, |this, cx| {
|
||||
struct SshServerRemoval;
|
||||
let notification = format!(
|
||||
"Do you really want to remove server `{}`?",
|
||||
connection_string
|
||||
);
|
||||
this.show_toast(
|
||||
Toast::new(
|
||||
NotificationId::composite::<SshServerRemoval>(
|
||||
connection_string.clone(),
|
||||
),
|
||||
notification,
|
||||
)
|
||||
.on_click(
|
||||
"Yes, delete it",
|
||||
move |cx| {
|
||||
dev_servers.update(cx, |this, cx| {
|
||||
this.delete_ssh_server(index, cx);
|
||||
this.mode = Mode::Default;
|
||||
cx.notify();
|
||||
})
|
||||
},
|
||||
),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
let prompt_message = format!("Remove server `{}`?", connection_string);
|
||||
|
||||
let confirmation = cx.prompt(
|
||||
PromptLevel::Warning,
|
||||
&prompt_message,
|
||||
None,
|
||||
&["Yes, remove it", "No, keep it"],
|
||||
);
|
||||
|
||||
cx.spawn(|mut cx| async move {
|
||||
if confirmation.await.ok() == Some(0) {
|
||||
dev_servers
|
||||
.update(&mut cx, |this, cx| {
|
||||
this.delete_ssh_server(index, cx);
|
||||
this.mode = Mode::default_mode();
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
self.selectable_items.add_item(Box::new({
|
||||
let connection_string = connection_string.clone();
|
||||
move |this, cx| {
|
||||
move |_, cx| {
|
||||
remove_ssh_server(
|
||||
cx.view().clone(),
|
||||
this.workspace.clone(),
|
||||
index,
|
||||
connection_string.clone(),
|
||||
cx,
|
||||
@@ -1035,16 +1035,15 @@ impl DevServerProjects {
|
||||
}
|
||||
}));
|
||||
let is_selected = self.selectable_items.is_selected();
|
||||
ListItem::new("delete-server")
|
||||
ListItem::new("remove-server")
|
||||
.selected(is_selected)
|
||||
.inset(true)
|
||||
.spacing(ui::ListItemSpacing::Sparse)
|
||||
.start_slot(Icon::new(IconName::Trash).color(Color::Error))
|
||||
.child(Label::new("Delete Server").color(Color::Error))
|
||||
.on_click(cx.listener(move |this, _, cx| {
|
||||
.child(Label::new("Remove Server").color(Color::Error))
|
||||
.on_click(cx.listener(move |_, _, cx| {
|
||||
remove_ssh_server(
|
||||
cx.view().clone(),
|
||||
this.workspace.clone(),
|
||||
index,
|
||||
connection_string.clone(),
|
||||
cx,
|
||||
@@ -1055,7 +1054,7 @@ impl DevServerProjects {
|
||||
.child({
|
||||
self.selectable_items.add_item(Box::new({
|
||||
move |this, cx| {
|
||||
this.mode = Mode::Default;
|
||||
this.mode = Mode::default_mode();
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
@@ -1067,7 +1066,7 @@ impl DevServerProjects {
|
||||
.start_slot(Icon::new(IconName::ArrowLeft).color(Color::Muted))
|
||||
.child(Label::new("Go Back"))
|
||||
.on_click(cx.listener(|this, _, cx| {
|
||||
this.mode = Mode::Default;
|
||||
this.mode = Mode::default_mode();
|
||||
cx.notify()
|
||||
}))
|
||||
}),
|
||||
@@ -1099,8 +1098,12 @@ impl DevServerProjects {
|
||||
.child(h_flex().p_2().child(state.editor.clone()))
|
||||
}
|
||||
|
||||
fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let dev_servers = self.dev_server_store.read(cx).dev_servers();
|
||||
fn render_default(
|
||||
&mut self,
|
||||
scroll_state: ScrollbarState,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> impl IntoElement {
|
||||
let scroll_state = scroll_state.parent_view(cx.view());
|
||||
let ssh_connections = SshSettings::get_global(cx)
|
||||
.ssh_connections()
|
||||
.collect::<Vec<_>>();
|
||||
@@ -1124,64 +1127,78 @@ impl DevServerProjects {
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
let ui::ScrollableHandle::NonUniform(scroll_handle) = scroll_state.scroll_handle() else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
let mut modal_section = v_flex()
|
||||
.id("ssh-server-list")
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&scroll_handle)
|
||||
.size_full()
|
||||
.child(connect_button)
|
||||
.child(
|
||||
List::new()
|
||||
.empty_message(
|
||||
v_flex()
|
||||
.child(ListSeparator)
|
||||
.child(div().px_3().child(
|
||||
Label::new("No dev servers registered yet.").color(Color::Muted),
|
||||
))
|
||||
.into_any_element(),
|
||||
)
|
||||
.children(ssh_connections.iter().cloned().enumerate().map(
|
||||
|(ix, connection)| {
|
||||
self.render_ssh_connection(ix, connection, cx)
|
||||
.into_any_element()
|
||||
},
|
||||
)),
|
||||
h_flex().child(
|
||||
List::new()
|
||||
.empty_message(
|
||||
v_flex()
|
||||
.child(ListSeparator)
|
||||
.child(
|
||||
div().px_3().child(
|
||||
Label::new("No dev servers registered yet.")
|
||||
.color(Color::Muted),
|
||||
),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
.children(ssh_connections.iter().cloned().enumerate().map(
|
||||
|(ix, connection)| {
|
||||
self.render_ssh_connection(ix, connection, cx)
|
||||
.into_any_element()
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.into_any_element();
|
||||
|
||||
let server_count = format!("Servers: {}", ssh_connections.len() + dev_servers.len());
|
||||
|
||||
Modal::new("remote-projects", Some(self.scroll_handle.clone()))
|
||||
.header(
|
||||
ModalHeader::new().child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall))
|
||||
.child(Label::new(server_count).size(LabelSize::Small)),
|
||||
),
|
||||
ModalHeader::new()
|
||||
.child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall)),
|
||||
)
|
||||
.section(
|
||||
Section::new().padded(false).child(
|
||||
v_flex()
|
||||
h_flex()
|
||||
.min_h(rems(20.))
|
||||
.flex_1()
|
||||
.size_full()
|
||||
.child(ListSeparator)
|
||||
.child(
|
||||
canvas(
|
||||
|bounds, cx| {
|
||||
modal_section.prepaint_as_root(
|
||||
bounds.origin,
|
||||
bounds.size.into(),
|
||||
cx,
|
||||
);
|
||||
modal_section
|
||||
},
|
||||
|_, mut modal_section, cx| {
|
||||
modal_section.paint(cx);
|
||||
},
|
||||
)
|
||||
.size_full(),
|
||||
v_flex().size_full().child(ListSeparator).child(
|
||||
canvas(
|
||||
|bounds, cx| {
|
||||
modal_section.prepaint_as_root(
|
||||
bounds.origin,
|
||||
bounds.size.into(),
|
||||
cx,
|
||||
);
|
||||
modal_section
|
||||
},
|
||||
|_, mut modal_section, cx| {
|
||||
modal_section.paint(cx);
|
||||
},
|
||||
)
|
||||
.size_full(),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.occlude()
|
||||
.h_full()
|
||||
.absolute()
|
||||
.right_1()
|
||||
.top_1()
|
||||
.bottom_1()
|
||||
.w(px(12.))
|
||||
.children(Scrollbar::vertical(scroll_state)),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -1217,13 +1234,13 @@ impl Render for DevServerProjects {
|
||||
this.focus_handle(cx).focus(cx);
|
||||
}))
|
||||
.on_mouse_down_out(cx.listener(|this, _, cx| {
|
||||
if matches!(this.mode, Mode::Default) {
|
||||
if matches!(this.mode, Mode::Default(_)) {
|
||||
cx.emit(DismissEvent)
|
||||
}
|
||||
}))
|
||||
.w(rems(34.))
|
||||
.child(match &self.mode {
|
||||
Mode::Default => self.render_default(cx).into_any_element(),
|
||||
Mode::Default(state) => self.render_default(state.clone(), cx).into_any_element(),
|
||||
Mode::ViewServerOptions(index, connection) => self
|
||||
.render_view_options(*index, connection.clone(), cx)
|
||||
.into_any_element(),
|
||||
|
||||
@@ -13,6 +13,7 @@ use gpui::{
|
||||
Action, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView,
|
||||
Subscription, Task, View, ViewContext, WeakView,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use ordered_float::OrderedFloat;
|
||||
use picker::{
|
||||
highlighted_match_with_paths::{HighlightedMatchWithPaths, HighlightedText},
|
||||
@@ -21,7 +22,7 @@ use picker::{
|
||||
use rpc::proto::DevServerStatus;
|
||||
use serde::Deserialize;
|
||||
use settings::Settings;
|
||||
use ssh_connections::SshSettings;
|
||||
pub use ssh_connections::SshSettings;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
@@ -247,8 +248,9 @@ impl PickerDelegate for RecentProjectsDelegate {
|
||||
SerializedWorkspaceLocation::Local(paths, order) => order
|
||||
.order()
|
||||
.iter()
|
||||
.filter_map(|i| paths.paths().get(*i))
|
||||
.map(|path| path.compact().to_string_lossy().into_owned())
|
||||
.zip(paths.paths().iter())
|
||||
.sorted_by_key(|(i, _)| *i)
|
||||
.map(|(_, path)| path.compact().to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(""),
|
||||
SerializedWorkspaceLocation::DevServer(dev_server_project) => {
|
||||
@@ -384,11 +386,13 @@ impl PickerDelegate for RecentProjectsDelegate {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let args = SshSettings::get_global(cx).args_for(&ssh_project.host, ssh_project.port, &ssh_project.user);
|
||||
let connection_options = SshConnectionOptions {
|
||||
host: ssh_project.host.clone(),
|
||||
username: ssh_project.user.clone(),
|
||||
port: ssh_project.port,
|
||||
password: None,
|
||||
args,
|
||||
};
|
||||
|
||||
let paths = ssh_project.paths.iter().map(PathBuf::from).collect();
|
||||
@@ -445,8 +449,9 @@ impl PickerDelegate for RecentProjectsDelegate {
|
||||
order
|
||||
.order()
|
||||
.iter()
|
||||
.filter_map(|i| paths.paths().get(*i).cloned())
|
||||
.map(|path| path.compact())
|
||||
.zip(paths.paths().iter())
|
||||
.sorted_by_key(|(i, _)| **i)
|
||||
.map(|(_, path)| path.compact())
|
||||
.collect(),
|
||||
),
|
||||
SerializedWorkspaceLocation::Ssh(ssh_project) => Arc::new(ssh_project.ssh_urls()),
|
||||
|
||||
@@ -32,6 +32,23 @@ impl SshSettings {
|
||||
pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> {
|
||||
self.ssh_connections.clone().into_iter().flatten()
|
||||
}
|
||||
|
||||
pub fn args_for(
|
||||
&self,
|
||||
host: &str,
|
||||
port: Option<u16>,
|
||||
user: &Option<String>,
|
||||
) -> Option<Vec<String>> {
|
||||
self.ssh_connections()
|
||||
.filter_map(|conn| {
|
||||
if conn.host == host && &conn.username == user && conn.port == port {
|
||||
Some(conn.args)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.next()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -45,6 +62,9 @@ pub struct SshConnection {
|
||||
/// Name to use for this server in UI.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nickname: Option<SharedString>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
impl From<SshConnection> for SshConnectionOptions {
|
||||
fn from(val: SshConnection) -> Self {
|
||||
@@ -53,6 +73,7 @@ impl From<SshConnection> for SshConnectionOptions {
|
||||
username: val.username,
|
||||
port: val.port,
|
||||
password: None,
|
||||
args: Some(val.args),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,11 +172,9 @@ impl Render for SshPrompt {
|
||||
v_flex()
|
||||
.key_context("PasswordPrompt")
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.p_2()
|
||||
.justify_center()
|
||||
.flex_wrap()
|
||||
.child(if self.error_message.is_some() {
|
||||
Icon::new(IconName::XCircle)
|
||||
@@ -174,24 +193,19 @@ impl Render for SshPrompt {
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.ml_1()
|
||||
.child(Label::new("SSH Connection").size(LabelSize::Small)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_ellipsis()
|
||||
.overflow_x_hidden()
|
||||
.when_some(self.error_message.as_ref(), |el, error| {
|
||||
el.child(Label::new(format!("-{}", error)).size(LabelSize::Small))
|
||||
el.child(Label::new(format!("{}", error)).size(LabelSize::Small))
|
||||
})
|
||||
.when(
|
||||
self.error_message.is_none() && self.status_message.is_some(),
|
||||
|el| {
|
||||
el.child(
|
||||
Label::new(format!(
|
||||
"-{}",
|
||||
"-{}…",
|
||||
self.status_message.clone().unwrap()
|
||||
))
|
||||
.size(LabelSize::Small),
|
||||
|
||||
@@ -29,6 +29,7 @@ prost.workspace = true
|
||||
rpc = { workspace = true, features = ["gpui"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
shlex.workspace = true
|
||||
smol.workspace = true
|
||||
tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -61,9 +61,89 @@ pub struct SshConnectionOptions {
|
||||
pub username: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
pub password: Option<String>,
|
||||
pub args: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl SshConnectionOptions {
|
||||
pub fn parse_command_line(input: &str) -> Result<Self> {
|
||||
let input = input.trim_start_matches("ssh ");
|
||||
let mut hostname: Option<String> = None;
|
||||
let mut username: Option<String> = None;
|
||||
let mut port: Option<u16> = None;
|
||||
let mut args = Vec::new();
|
||||
|
||||
// disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W
|
||||
const ALLOWED_OPTS: &[&str] = &[
|
||||
"-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
|
||||
];
|
||||
const ALLOWED_ARGS: &[&str] = &[
|
||||
"-B", "-b", "-c", "-D", "-I", "-i", "-J", "-L", "-l", "-m", "-o", "-P", "-p", "-R",
|
||||
"-w",
|
||||
];
|
||||
|
||||
let mut tokens = shlex::split(input)
|
||||
.ok_or_else(|| anyhow!("invalid input"))?
|
||||
.into_iter();
|
||||
|
||||
'outer: while let Some(arg) = tokens.next() {
|
||||
if ALLOWED_OPTS.contains(&(&arg as &str)) {
|
||||
args.push(arg.to_string());
|
||||
continue;
|
||||
}
|
||||
if arg == "-p" {
|
||||
port = tokens.next().and_then(|arg| arg.parse().ok());
|
||||
continue;
|
||||
} else if let Some(p) = arg.strip_prefix("-p") {
|
||||
port = p.parse().ok();
|
||||
continue;
|
||||
}
|
||||
if arg == "-l" {
|
||||
username = tokens.next();
|
||||
continue;
|
||||
} else if let Some(l) = arg.strip_prefix("-l") {
|
||||
username = Some(l.to_string());
|
||||
continue;
|
||||
}
|
||||
for a in ALLOWED_ARGS {
|
||||
if arg == *a {
|
||||
args.push(arg);
|
||||
if let Some(next) = tokens.next() {
|
||||
args.push(next);
|
||||
}
|
||||
continue 'outer;
|
||||
} else if arg.starts_with(a) {
|
||||
args.push(arg);
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
if arg.starts_with("-") || hostname.is_some() {
|
||||
anyhow::bail!("unsupported argument: {:?}", arg);
|
||||
}
|
||||
let mut input = &arg as &str;
|
||||
if let Some((u, rest)) = input.split_once('@') {
|
||||
input = rest;
|
||||
username = Some(u.to_string());
|
||||
}
|
||||
if let Some((rest, p)) = input.split_once(':') {
|
||||
input = rest;
|
||||
port = p.parse().ok()
|
||||
}
|
||||
hostname = Some(input.to_string())
|
||||
}
|
||||
|
||||
let Some(hostname) = hostname else {
|
||||
anyhow::bail!("missing hostname");
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
host: hostname.to_string(),
|
||||
username: username.clone(),
|
||||
port,
|
||||
password: None,
|
||||
args: Some(args),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ssh_url(&self) -> String {
|
||||
let mut result = String::from("ssh://");
|
||||
if let Some(username) = &self.username {
|
||||
@@ -78,6 +158,10 @@ impl SshConnectionOptions {
|
||||
result
|
||||
}
|
||||
|
||||
pub fn additional_args(&self) -> Option<&Vec<String>> {
|
||||
self.args.as_ref()
|
||||
}
|
||||
|
||||
fn scp_url(&self) -> String {
|
||||
if let Some(username) = &self.username {
|
||||
format!("{}@{}", username, self.host)
|
||||
@@ -743,8 +827,20 @@ impl SshRemoteClient {
|
||||
|
||||
loop {
|
||||
select_biased! {
|
||||
_ = connection_activity_rx.next().fuse() => {
|
||||
result = connection_activity_rx.next().fuse() => {
|
||||
if result.is_none() {
|
||||
log::warn!("ssh heartbeat: connection activity channel has been dropped. stopping.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
keepalive_timer.set(cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse());
|
||||
|
||||
if missed_heartbeats != 0 {
|
||||
missed_heartbeats = 0;
|
||||
this.update(&mut cx, |this, mut cx| {
|
||||
this.handle_heartbeat_result(missed_heartbeats, &mut cx)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
_ = keepalive_timer => {
|
||||
log::debug!("Sending heartbeat to server...");
|
||||
@@ -757,6 +853,7 @@ impl SshRemoteClient {
|
||||
ping_result
|
||||
}
|
||||
};
|
||||
|
||||
if result.is_err() {
|
||||
missed_heartbeats += 1;
|
||||
log::warn!(
|
||||
@@ -975,16 +1072,9 @@ impl SshRemoteClient {
|
||||
SshRemoteConnection::new(connection_options, delegate.clone(), cx).await?;
|
||||
|
||||
let platform = ssh_connection.query_platform().await?;
|
||||
let (local_binary_path, version) = delegate.get_server_binary(platform, cx).await??;
|
||||
let remote_binary_path = delegate.remote_server_binary_path(platform, cx)?;
|
||||
ssh_connection
|
||||
.ensure_server_binary(
|
||||
&delegate,
|
||||
&local_binary_path,
|
||||
&remote_binary_path,
|
||||
version,
|
||||
cx,
|
||||
)
|
||||
.ensure_server_binary(&delegate, &remote_binary_path, platform, cx)
|
||||
.await?;
|
||||
|
||||
let socket = ssh_connection.socket.clone();
|
||||
@@ -1182,7 +1272,15 @@ impl SshRemoteConnection {
|
||||
.stderr(Stdio::piped())
|
||||
.env("SSH_ASKPASS_REQUIRE", "force")
|
||||
.env("SSH_ASKPASS", &askpass_script_path)
|
||||
.args(["-N", "-o", "ControlMaster=yes", "-o"])
|
||||
.args(connection_options.additional_args().unwrap_or(&Vec::new()))
|
||||
.args([
|
||||
"-N",
|
||||
"-o",
|
||||
"ControlPersist=no",
|
||||
"-o",
|
||||
"ControlMaster=yes",
|
||||
"-o",
|
||||
])
|
||||
.arg(format!("ControlPath={}", socket_path.display()))
|
||||
.arg(&url)
|
||||
.spawn()?;
|
||||
@@ -1241,11 +1339,19 @@ impl SshRemoteConnection {
|
||||
async fn ensure_server_binary(
|
||||
&self,
|
||||
delegate: &Arc<dyn SshClientDelegate>,
|
||||
src_path: &Path,
|
||||
dst_path: &Path,
|
||||
version: SemanticVersion,
|
||||
platform: SshPlatform,
|
||||
cx: &mut AsyncAppContext,
|
||||
) -> Result<()> {
|
||||
if std::env::var("ZED_USE_CACHED_REMOTE_SERVER").is_ok() {
|
||||
if let Ok(installed_version) =
|
||||
run_cmd(self.socket.ssh_command(dst_path).arg("version")).await
|
||||
{
|
||||
log::info!("using cached server binary version {}", installed_version);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let mut dst_path_gz = dst_path.to_path_buf();
|
||||
dst_path_gz.set_extension("gz");
|
||||
|
||||
@@ -1253,8 +1359,10 @@ impl SshRemoteConnection {
|
||||
run_cmd(self.socket.ssh_command("mkdir").arg("-p").arg(parent)).await?;
|
||||
}
|
||||
|
||||
let (src_path, version) = delegate.get_server_binary(platform, cx).await??;
|
||||
|
||||
let mut server_binary_exists = false;
|
||||
if cfg!(not(debug_assertions)) {
|
||||
if !server_binary_exists && cfg!(not(debug_assertions)) {
|
||||
if let Ok(installed_version) =
|
||||
run_cmd(self.socket.ssh_command(dst_path).arg("version")).await
|
||||
{
|
||||
@@ -1269,14 +1377,14 @@ impl SshRemoteConnection {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let src_stat = fs::metadata(src_path).await?;
|
||||
let src_stat = fs::metadata(&src_path).await?;
|
||||
let size = src_stat.len();
|
||||
let server_mode = 0o755;
|
||||
|
||||
let t0 = Instant::now();
|
||||
delegate.set_status(Some("uploading remote development server"), cx);
|
||||
log::info!("uploading remote development server ({}kb)", size / 1024);
|
||||
self.upload_file(src_path, &dst_path_gz)
|
||||
self.upload_file(&src_path, &dst_path_gz)
|
||||
.await
|
||||
.context("failed to upload server binary")?;
|
||||
log::info!("uploaded remote development server in {:?}", t0.elapsed());
|
||||
|
||||
@@ -30,6 +30,8 @@ client.workspace = true
|
||||
env_logger.workspace = true
|
||||
fs.workspace = true
|
||||
futures.workspace = true
|
||||
git.workspace = true
|
||||
git_hosting_providers.workspace = true
|
||||
gpui.workspace = true
|
||||
http_client.workspace = true
|
||||
language.workspace = true
|
||||
|
||||
@@ -154,6 +154,7 @@ impl HeadlessProject {
|
||||
client.add_request_handler(cx.weak_model(), Self::handle_remove_worktree);
|
||||
|
||||
client.add_model_request_handler(Self::handle_open_buffer_by_path);
|
||||
client.add_model_request_handler(Self::handle_open_new_buffer);
|
||||
client.add_model_request_handler(Self::handle_find_search_candidates);
|
||||
client.add_model_request_handler(Self::handle_open_server_settings);
|
||||
|
||||
@@ -275,7 +276,7 @@ impl HeadlessProject {
|
||||
let worktree = this
|
||||
.update(&mut cx.clone(), |this, _| {
|
||||
Worktree::local(
|
||||
Arc::from(canonicalized),
|
||||
Arc::from(canonicalized.as_path()),
|
||||
message.payload.visible,
|
||||
this.fs.clone(),
|
||||
this.next_entry_id.clone(),
|
||||
@@ -287,6 +288,7 @@ impl HeadlessProject {
|
||||
let response = this.update(&mut cx, |_, cx| {
|
||||
worktree.update(cx, |worktree, _| proto::AddWorktreeResponse {
|
||||
worktree_id: worktree.id().to_proto(),
|
||||
canonicalized_path: canonicalized.to_string_lossy().to_string(),
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -362,6 +364,32 @@ impl HeadlessProject {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn handle_open_new_buffer(
|
||||
this: Model<Self>,
|
||||
_message: TypedEnvelope<proto::OpenNewBuffer>,
|
||||
mut cx: AsyncAppContext,
|
||||
) -> Result<proto::OpenBufferResponse> {
|
||||
let (buffer_store, buffer) = this.update(&mut cx, |this, cx| {
|
||||
let buffer_store = this.buffer_store.clone();
|
||||
let buffer = this
|
||||
.buffer_store
|
||||
.update(cx, |buffer_store, cx| buffer_store.create_buffer(cx));
|
||||
anyhow::Ok((buffer_store, buffer))
|
||||
})??;
|
||||
|
||||
let buffer = buffer.await?;
|
||||
let buffer_id = buffer.read_with(&cx, |b, _| b.remote_id())?;
|
||||
buffer_store.update(&mut cx, |buffer_store, cx| {
|
||||
buffer_store
|
||||
.create_buffer_for_peer(&buffer, SSH_PEER_ID, cx)
|
||||
.detach_and_log_err(cx);
|
||||
})?;
|
||||
|
||||
Ok(proto::OpenBufferResponse {
|
||||
buffer_id: buffer_id.to_proto(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn handle_open_server_settings(
|
||||
this: Model<Self>,
|
||||
_: TypedEnvelope<proto::OpenServerSettings>,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#![cfg_attr(target_os = "windows", allow(unused, dead_code))]
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -40,13 +39,13 @@ fn main() {
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn main() -> Result<()> {
|
||||
fn main() {
|
||||
use remote::proxy::ProxyLaunchError;
|
||||
use remote_server::unix::{execute_proxy, execute_run};
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
let result = match cli.command {
|
||||
Some(Commands::Run {
|
||||
log_file,
|
||||
pid_file,
|
||||
@@ -74,11 +73,15 @@ fn main() -> Result<()> {
|
||||
},
|
||||
Some(Commands::Version) => {
|
||||
eprintln!("{}", env!("ZED_PKG_VERSION"));
|
||||
Ok(())
|
||||
std::process::exit(0);
|
||||
}
|
||||
None => {
|
||||
eprintln!("usage: remote <run|proxy|version>");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
if let Err(error) = result {
|
||||
log::error!("exiting due to error: {}", error);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use client::ProxySettings;
|
||||
use fs::{Fs, RealFs};
|
||||
use futures::channel::mpsc;
|
||||
use futures::{select, select_biased, AsyncRead, AsyncWrite, AsyncWriteExt, FutureExt, SinkExt};
|
||||
use git::GitHostingProviderRegistry;
|
||||
use gpui::{AppContext, Context as _, ModelContext, UpdateGlobal as _};
|
||||
use http_client::{read_proxy_from_env, Uri};
|
||||
use language::LanguageRegistry;
|
||||
@@ -186,7 +187,6 @@ fn start_server(
|
||||
log::info!("accepting new connections");
|
||||
let result = select! {
|
||||
streams = streams.fuse() => {
|
||||
log::warn!("stdin {:?}, stdout: {:?}, stderr: {:?}", streams.0, streams.1, streams.2);
|
||||
let (Some(Ok(stdin_stream)), Some(Ok(stdout_stream)), Some(Ok(stderr_stream))) = streams else {
|
||||
break;
|
||||
};
|
||||
@@ -211,8 +211,6 @@ fn start_server(
|
||||
break;
|
||||
};
|
||||
|
||||
log::info!("yep! we got connections");
|
||||
|
||||
let mut input_buffer = Vec::new();
|
||||
let mut output_buffer = Vec::new();
|
||||
loop {
|
||||
@@ -253,7 +251,6 @@ fn start_server(
|
||||
}
|
||||
}
|
||||
|
||||
// // TODO: How do we handle backpressure?
|
||||
log_message = log_rx.next().fuse() => {
|
||||
if let Some(log_message) = log_message {
|
||||
if let Err(error) = stderr_stream.write_all(&log_message).await {
|
||||
@@ -316,7 +313,9 @@ pub fn execute_run(
|
||||
|
||||
let listeners = ServerListeners::new(stdin_socket, stdout_socket, stderr_socket)?;
|
||||
|
||||
log::debug!("starting gpui app");
|
||||
log::info!("starting headless gpui app");
|
||||
|
||||
let git_hosting_provider_registry = Arc::new(GitHostingProviderRegistry::new());
|
||||
gpui::App::headless().run(move |cx| {
|
||||
settings::init(cx);
|
||||
HeadlessProject::init(cx);
|
||||
@@ -326,6 +325,9 @@ pub fn execute_run(
|
||||
|
||||
client::init_settings(cx);
|
||||
|
||||
GitHostingProviderRegistry::set_global(git_hosting_provider_registry, cx);
|
||||
git_hosting_providers::init(cx);
|
||||
|
||||
let project = cx.new_model(|cx| {
|
||||
let fs = Arc::new(RealFs::new(Default::default(), None));
|
||||
let node_settings_rx = initialize_settings(session.clone(), fs.clone(), cx);
|
||||
@@ -404,7 +406,7 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> {
|
||||
init_logging_proxy();
|
||||
init_panic_hook();
|
||||
|
||||
log::debug!("starting up. PID: {}", std::process::id());
|
||||
log::info!("starting proxy process. PID: {}", std::process::id());
|
||||
|
||||
let server_paths = ServerPaths::new(&identifier)?;
|
||||
|
||||
@@ -417,7 +419,7 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> {
|
||||
}
|
||||
} else {
|
||||
if let Some(pid) = server_pid {
|
||||
log::debug!("found server already running with PID {}. Killing process and cleaning up files...", pid);
|
||||
log::info!("proxy found server already running with PID {}. Killing process and cleaning up files...", pid);
|
||||
kill_running_server(pid, &server_paths)?;
|
||||
}
|
||||
|
||||
@@ -443,7 +445,9 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> {
|
||||
loop {
|
||||
match stream.read(&mut stderr_buffer).await {
|
||||
Ok(0) => {
|
||||
return anyhow::Ok(());
|
||||
let error =
|
||||
std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "stderr closed");
|
||||
Err(anyhow!(error))?;
|
||||
}
|
||||
Ok(n) => {
|
||||
stderr.write_all(&mut stderr_buffer[..n]).await?;
|
||||
@@ -463,6 +467,12 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> {
|
||||
result = stderr_task.fuse() => result,
|
||||
}
|
||||
}) {
|
||||
if let Some(error) = forwarding_result.downcast_ref::<std::io::Error>() {
|
||||
if error.kind() == std::io::ErrorKind::UnexpectedEof {
|
||||
log::error!("connection to server closed due to unexpected EOF");
|
||||
return Err(anyhow!("connection to server closed"));
|
||||
}
|
||||
}
|
||||
log::error!(
|
||||
"failed to forward messages: {:?}, terminating...",
|
||||
forwarding_result
|
||||
@@ -518,7 +528,10 @@ fn spawn_server(paths: &ServerPaths) -> Result<()> {
|
||||
.arg(&paths.stderr_socket)
|
||||
.spawn()?;
|
||||
|
||||
log::debug!("server started. PID: {:?}", server_process.id());
|
||||
log::info!(
|
||||
"proxy spawned server process. PID: {:?}",
|
||||
server_process.id()
|
||||
);
|
||||
|
||||
let mut total_time_waited = std::time::Duration::from_secs(0);
|
||||
let wait_duration = std::time::Duration::from_millis(20);
|
||||
|
||||
@@ -384,13 +384,6 @@ impl PickerDelegate for TasksModalDelegate {
|
||||
.start_slot::<Icon>(icon)
|
||||
.end_slot::<AnyElement>(history_run_icon)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
// .map(|this| {
|
||||
// if Some(ix) <= self.divider_index {
|
||||
// this.start_slot(Icon::new(IconName::HistoryRerun).size(IconSize::Small))
|
||||
// } else {
|
||||
// this.start_slot(v_flex().flex_none().size(IconSize::Small.rems()))
|
||||
// }
|
||||
// })
|
||||
.when_some(tooltip_label, |list_item, item_label| {
|
||||
list_item.tooltip(move |_| item_label.clone())
|
||||
})
|
||||
|
||||
@@ -151,7 +151,7 @@ pub struct TerminalSettingsContent {
|
||||
pub alternate_scroll: Option<AlternateScroll>,
|
||||
/// Sets whether the option key behaves as the meta key.
|
||||
///
|
||||
/// Default: true
|
||||
/// Default: false
|
||||
pub option_as_meta: Option<bool>,
|
||||
/// Whether or not selecting text in the terminal will automatically
|
||||
/// copy to the system clipboard.
|
||||
|
||||
@@ -395,7 +395,21 @@ impl TerminalPanel {
|
||||
let mut spawn_task = spawn_in_terminal.clone();
|
||||
// Set up shell args unconditionally, as tasks are always spawned inside of a shell.
|
||||
let Some((shell, mut user_args)) = (match spawn_in_terminal.shell.clone() {
|
||||
Shell::System => retrieve_system_shell().map(|shell| (shell, Vec::new())),
|
||||
Shell::System => {
|
||||
match self
|
||||
.workspace
|
||||
.update(cx, |workspace, cx| workspace.project().read(cx).is_local())
|
||||
{
|
||||
Ok(local) => {
|
||||
if local {
|
||||
retrieve_system_shell().map(|shell| (shell, Vec::new()))
|
||||
} else {
|
||||
Some(("\"${SHELL:-sh}\"".to_string(), Vec::new()))
|
||||
}
|
||||
}
|
||||
Err(_no_window_e) => return,
|
||||
}
|
||||
}
|
||||
Shell::Program(shell) => Some((shell, Vec::new())),
|
||||
Shell::WithArguments { program, args } => Some((program, args)),
|
||||
}) else {
|
||||
|
||||
@@ -1427,7 +1427,7 @@ impl Buffer {
|
||||
fn undo_or_redo(&mut self, transaction: Transaction) -> Operation {
|
||||
let mut counts = HashMap::default();
|
||||
for edit_id in transaction.edit_ids {
|
||||
counts.insert(edit_id, self.undo_map.undo_count(edit_id) + 1);
|
||||
counts.insert(edit_id, self.undo_map.undo_count(edit_id).saturating_add(1));
|
||||
}
|
||||
|
||||
let operation = self.undo_operations(counts);
|
||||
|
||||
@@ -24,8 +24,8 @@ use smallvec::SmallVec;
|
||||
use std::sync::Arc;
|
||||
use theme::ActiveTheme;
|
||||
use ui::{
|
||||
h_flex, prelude::*, Avatar, Button, ButtonLike, ButtonStyle, ContextMenu, Icon,
|
||||
IconButtonShape, IconName, IconSize, Indicator, PopoverMenu, Tooltip,
|
||||
h_flex, prelude::*, Avatar, Button, ButtonLike, ButtonStyle, ContextMenu, Icon, IconName,
|
||||
IconSize, IconWithIndicator, Indicator, PopoverMenu, Tooltip,
|
||||
};
|
||||
use util::ResultExt;
|
||||
use vcs_menu::{BranchList, OpenRecent as ToggleVcsMenu};
|
||||
@@ -281,8 +281,6 @@ impl TitleBar {
|
||||
}
|
||||
};
|
||||
|
||||
let indicator_border_color = cx.theme().colors().title_bar_background;
|
||||
|
||||
let icon_color = match self.project.read(cx).ssh_connection_state(cx)? {
|
||||
remote::ConnectionState::Connecting => Color::Info,
|
||||
remote::ConnectionState::Connected => Color::Default,
|
||||
@@ -293,42 +291,22 @@ impl TitleBar {
|
||||
|
||||
let meta = SharedString::from(meta);
|
||||
|
||||
let indicator = h_flex()
|
||||
// We're using the circle inside a circle approach because, otherwise, by using borders
|
||||
// we'd get a very thin, leaking indicator color, which is not what we want.
|
||||
.absolute()
|
||||
.size_2p5()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.bg(indicator_border_color)
|
||||
.size_2p5()
|
||||
.rounded_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.overflow_hidden()
|
||||
.child(Indicator::dot().color(indicator_color));
|
||||
|
||||
Some(
|
||||
div()
|
||||
.relative()
|
||||
ButtonLike::new("ssh-server-icon")
|
||||
.child(
|
||||
IconButton::new("ssh-server-icon", IconName::Server)
|
||||
.icon_size(IconSize::Small)
|
||||
.shape(IconButtonShape::Square)
|
||||
.icon_color(icon_color)
|
||||
.tooltip(move |cx| {
|
||||
Tooltip::with_meta(
|
||||
"Remote Project",
|
||||
Some(&OpenRemote),
|
||||
meta.clone(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.on_click(|_, cx| {
|
||||
cx.dispatch_action(OpenRemote.boxed_clone());
|
||||
}),
|
||||
IconWithIndicator::new(
|
||||
Icon::new(IconName::Server).color(icon_color),
|
||||
Some(Indicator::dot().color(indicator_color)),
|
||||
)
|
||||
.indicator_border_color(Some(cx.theme().colors().title_bar_background))
|
||||
.into_any_element(),
|
||||
)
|
||||
.child(indicator)
|
||||
.tooltip(move |cx| {
|
||||
Tooltip::with_meta("Remote Project", Some(&OpenRemote), meta.clone(), cx)
|
||||
})
|
||||
.on_click(|_, cx| {
|
||||
cx.dispatch_action(OpenRemote.boxed_clone());
|
||||
})
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ mod popover;
|
||||
mod popover_menu;
|
||||
mod radio;
|
||||
mod right_click_menu;
|
||||
mod scrollbar;
|
||||
mod settings_container;
|
||||
mod settings_group;
|
||||
mod stack;
|
||||
@@ -49,6 +50,7 @@ pub use popover::*;
|
||||
pub use popover_menu::*;
|
||||
pub use radio::*;
|
||||
pub use right_click_menu::*;
|
||||
pub use scrollbar::*;
|
||||
pub use settings_container::*;
|
||||
pub use settings_group::*;
|
||||
pub use stack::*;
|
||||
|
||||
@@ -170,6 +170,7 @@ pub enum IconName {
|
||||
Dash,
|
||||
DatabaseZap,
|
||||
Delete,
|
||||
Diff,
|
||||
Disconnected,
|
||||
Download,
|
||||
Ellipsis,
|
||||
@@ -473,13 +474,12 @@ impl RenderOnce for IconWithIndicator {
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
.w_2()
|
||||
.h_2()
|
||||
.border_1()
|
||||
.size_2p5()
|
||||
.border_2()
|
||||
.border_color(indicator_border_color)
|
||||
.rounded_full()
|
||||
.bottom_neg_0p5()
|
||||
.right_neg_1()
|
||||
.right_neg_0p5()
|
||||
.child(indicator),
|
||||
)
|
||||
})
|
||||
|
||||
396
crates/ui/src/components/scrollbar.rs
Normal file
396
crates/ui/src/components/scrollbar.rs
Normal file
@@ -0,0 +1,396 @@
|
||||
#![allow(missing_docs)]
|
||||
use std::{cell::Cell, ops::Range, rc::Rc};
|
||||
|
||||
use crate::{prelude::*, px, relative, IntoElement};
|
||||
use gpui::{
|
||||
point, quad, Along, Axis as ScrollbarAxis, Bounds, ContentMask, Corners, Edges, Element,
|
||||
ElementId, Entity, EntityId, GlobalElementId, Hitbox, Hsla, LayoutId, MouseDownEvent,
|
||||
MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollHandle, ScrollWheelEvent, Size, Style,
|
||||
UniformListScrollHandle, View, WindowContext,
|
||||
};
|
||||
|
||||
pub struct Scrollbar {
|
||||
thumb: Range<f32>,
|
||||
state: ScrollbarState,
|
||||
kind: ScrollbarAxis,
|
||||
}
|
||||
|
||||
/// Wrapper around scroll handles.
|
||||
#[derive(Clone)]
|
||||
pub enum ScrollableHandle {
|
||||
Uniform(UniformListScrollHandle),
|
||||
NonUniform(ScrollHandle),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ContentSize {
|
||||
size: Size<Pixels>,
|
||||
scroll_adjustment: Option<Point<Pixels>>,
|
||||
}
|
||||
|
||||
impl ScrollableHandle {
|
||||
fn content_size(&self) -> Option<ContentSize> {
|
||||
match self {
|
||||
ScrollableHandle::Uniform(handle) => Some(ContentSize {
|
||||
size: handle.0.borrow().last_item_size.map(|size| size.contents)?,
|
||||
scroll_adjustment: None,
|
||||
}),
|
||||
ScrollableHandle::NonUniform(handle) => {
|
||||
let last_children_index = handle.children_count().checked_sub(1)?;
|
||||
// todo: PO: this is slightly wrong for horizontal scrollbar, as the last item is not necessarily the longest one.
|
||||
let mut last_item = handle.bounds_for_item(last_children_index)?;
|
||||
last_item.size.height += last_item.origin.y;
|
||||
last_item.size.width += last_item.origin.x;
|
||||
let mut scroll_adjustment = None;
|
||||
if last_children_index != 0 {
|
||||
let first_item = handle.bounds_for_item(0)?;
|
||||
|
||||
scroll_adjustment = Some(first_item.origin);
|
||||
last_item.size.height -= first_item.origin.y;
|
||||
last_item.size.width -= first_item.origin.x;
|
||||
}
|
||||
Some(ContentSize {
|
||||
size: last_item.size,
|
||||
scroll_adjustment,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
fn set_offset(&self, point: Point<Pixels>) {
|
||||
let base_handle = match self {
|
||||
ScrollableHandle::Uniform(handle) => &handle.0.borrow().base_handle,
|
||||
ScrollableHandle::NonUniform(handle) => &handle,
|
||||
};
|
||||
base_handle.set_offset(point);
|
||||
}
|
||||
fn offset(&self) -> Point<Pixels> {
|
||||
let base_handle = match self {
|
||||
ScrollableHandle::Uniform(handle) => &handle.0.borrow().base_handle,
|
||||
ScrollableHandle::NonUniform(handle) => &handle,
|
||||
};
|
||||
base_handle.offset()
|
||||
}
|
||||
fn viewport(&self) -> Bounds<Pixels> {
|
||||
let base_handle = match self {
|
||||
ScrollableHandle::Uniform(handle) => &handle.0.borrow().base_handle,
|
||||
ScrollableHandle::NonUniform(handle) => &handle,
|
||||
};
|
||||
base_handle.bounds()
|
||||
}
|
||||
}
|
||||
impl From<UniformListScrollHandle> for ScrollableHandle {
|
||||
fn from(value: UniformListScrollHandle) -> Self {
|
||||
Self::Uniform(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ScrollHandle> for ScrollableHandle {
|
||||
fn from(value: ScrollHandle) -> Self {
|
||||
Self::NonUniform(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// A scrollbar state that should be persisted across frames.
|
||||
#[derive(Clone)]
|
||||
pub struct ScrollbarState {
|
||||
// If Some(), there's an active drag, offset by percentage from the origin of a thumb.
|
||||
drag: Rc<Cell<Option<f32>>>,
|
||||
parent_id: Option<EntityId>,
|
||||
scroll_handle: ScrollableHandle,
|
||||
}
|
||||
|
||||
impl ScrollbarState {
|
||||
pub fn new(scroll: impl Into<ScrollableHandle>) -> Self {
|
||||
Self {
|
||||
drag: Default::default(),
|
||||
parent_id: None,
|
||||
scroll_handle: scroll.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a parent view which should be notified whenever this Scrollbar gets a scroll event.
|
||||
pub fn parent_view<V: 'static>(mut self, v: &View<V>) -> Self {
|
||||
self.parent_id = Some(v.entity_id());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn scroll_handle(&self) -> ScrollableHandle {
|
||||
self.scroll_handle.clone()
|
||||
}
|
||||
|
||||
pub fn is_dragging(&self) -> bool {
|
||||
self.drag.get().is_some()
|
||||
}
|
||||
|
||||
fn thumb_range(&self, axis: ScrollbarAxis) -> Option<Range<f32>> {
|
||||
const MINIMUM_SCROLLBAR_PERCENTAGE_SIZE: f32 = 0.005;
|
||||
let ContentSize {
|
||||
size: main_dimension_size,
|
||||
scroll_adjustment,
|
||||
} = self.scroll_handle.content_size()?;
|
||||
let main_dimension_size = main_dimension_size.along(axis).0;
|
||||
let mut current_offset = self.scroll_handle.offset().along(axis).min(px(0.)).abs().0;
|
||||
if let Some(adjustment) = scroll_adjustment.and_then(|adjustment| {
|
||||
let adjust = adjustment.along(axis).0;
|
||||
if adjust < 0.0 {
|
||||
Some(adjust)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
current_offset -= adjustment;
|
||||
}
|
||||
let mut percentage = current_offset / main_dimension_size;
|
||||
let viewport_size = self.scroll_handle.viewport().size;
|
||||
|
||||
let end_offset = (current_offset + viewport_size.along(axis).0) / main_dimension_size;
|
||||
// Scroll handle might briefly report an offset greater than the length of a list;
|
||||
// in such case we'll adjust the starting offset as well to keep the scrollbar thumb length stable.
|
||||
let overshoot = (end_offset - 1.).clamp(0., 1.);
|
||||
if overshoot > 0. {
|
||||
percentage -= overshoot;
|
||||
}
|
||||
if percentage + MINIMUM_SCROLLBAR_PERCENTAGE_SIZE > 1.0 || end_offset > main_dimension_size
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if main_dimension_size < viewport_size.along(axis).0 {
|
||||
return None;
|
||||
}
|
||||
let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_SIZE, 1.);
|
||||
Some(percentage..end_offset)
|
||||
}
|
||||
}
|
||||
|
||||
impl Scrollbar {
|
||||
pub fn vertical(state: ScrollbarState) -> Option<Self> {
|
||||
Self::new(state, ScrollbarAxis::Vertical)
|
||||
}
|
||||
|
||||
pub fn horizontal(state: ScrollbarState) -> Option<Self> {
|
||||
Self::new(state, ScrollbarAxis::Horizontal)
|
||||
}
|
||||
fn new(state: ScrollbarState, kind: ScrollbarAxis) -> Option<Self> {
|
||||
let thumb = state.thumb_range(kind)?;
|
||||
Some(Self { thumb, state, kind })
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for Scrollbar {
|
||||
type RequestLayoutState = ();
|
||||
|
||||
type PrepaintState = Hitbox;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
cx: &mut WindowContext,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
style.flex_grow = 1.;
|
||||
style.flex_shrink = 1.;
|
||||
|
||||
if self.kind == ScrollbarAxis::Vertical {
|
||||
style.size.width = px(12.).into();
|
||||
style.size.height = relative(1.).into();
|
||||
} else {
|
||||
style.size.width = relative(1.).into();
|
||||
style.size.height = px(12.).into();
|
||||
}
|
||||
|
||||
(cx.request_layout(style, None), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
cx: &mut WindowContext,
|
||||
) -> Self::PrepaintState {
|
||||
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
|
||||
cx.insert_hitbox(bounds, false)
|
||||
})
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
_prepaint: &mut Self::PrepaintState,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
|
||||
let colors = cx.theme().colors();
|
||||
let thumb_background = colors.scrollbar_thumb_background;
|
||||
let is_vertical = self.kind == ScrollbarAxis::Vertical;
|
||||
let extra_padding = px(5.0);
|
||||
let padded_bounds = if is_vertical {
|
||||
Bounds::from_corners(
|
||||
bounds.origin + point(Pixels::ZERO, extra_padding),
|
||||
bounds.lower_right() - point(Pixels::ZERO, extra_padding * 3),
|
||||
)
|
||||
} else {
|
||||
Bounds::from_corners(
|
||||
bounds.origin + point(extra_padding, Pixels::ZERO),
|
||||
bounds.lower_right() - point(extra_padding * 3, Pixels::ZERO),
|
||||
)
|
||||
};
|
||||
|
||||
let mut thumb_bounds = if is_vertical {
|
||||
let thumb_offset = self.thumb.start * padded_bounds.size.height;
|
||||
let thumb_end = self.thumb.end * padded_bounds.size.height;
|
||||
let thumb_upper_left = point(
|
||||
padded_bounds.origin.x,
|
||||
padded_bounds.origin.y + thumb_offset,
|
||||
);
|
||||
let thumb_lower_right = point(
|
||||
padded_bounds.origin.x + padded_bounds.size.width,
|
||||
padded_bounds.origin.y + thumb_end,
|
||||
);
|
||||
Bounds::from_corners(thumb_upper_left, thumb_lower_right)
|
||||
} else {
|
||||
let thumb_offset = self.thumb.start * padded_bounds.size.width;
|
||||
let thumb_end = self.thumb.end * padded_bounds.size.width;
|
||||
let thumb_upper_left = point(
|
||||
padded_bounds.origin.x + thumb_offset,
|
||||
padded_bounds.origin.y,
|
||||
);
|
||||
let thumb_lower_right = point(
|
||||
padded_bounds.origin.x + thumb_end,
|
||||
padded_bounds.origin.y + padded_bounds.size.height,
|
||||
);
|
||||
Bounds::from_corners(thumb_upper_left, thumb_lower_right)
|
||||
};
|
||||
let corners = if is_vertical {
|
||||
thumb_bounds.size.width /= 1.5;
|
||||
Corners::all(thumb_bounds.size.width / 2.0)
|
||||
} else {
|
||||
thumb_bounds.size.height /= 1.5;
|
||||
Corners::all(thumb_bounds.size.height / 2.0)
|
||||
};
|
||||
cx.paint_quad(quad(
|
||||
thumb_bounds,
|
||||
corners,
|
||||
thumb_background,
|
||||
Edges::default(),
|
||||
Hsla::transparent_black(),
|
||||
));
|
||||
|
||||
let scroll = self.state.scroll_handle.clone();
|
||||
let kind = self.kind;
|
||||
let thumb_percentage_size = self.thumb.end - self.thumb.start;
|
||||
|
||||
cx.on_mouse_event({
|
||||
let scroll = scroll.clone();
|
||||
let state = self.state.clone();
|
||||
let axis = self.kind;
|
||||
move |event: &MouseDownEvent, phase, _cx| {
|
||||
if !(phase.bubble() && bounds.contains(&event.position)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if thumb_bounds.contains(&event.position) {
|
||||
let thumb_offset = (event.position.along(axis)
|
||||
- thumb_bounds.origin.along(axis))
|
||||
/ bounds.size.along(axis);
|
||||
state.drag.set(Some(thumb_offset));
|
||||
} else if let Some(ContentSize {
|
||||
size: item_size, ..
|
||||
}) = scroll.content_size()
|
||||
{
|
||||
match kind {
|
||||
ScrollbarAxis::Horizontal => {
|
||||
let percentage =
|
||||
(event.position.x - bounds.origin.x) / bounds.size.width;
|
||||
let max_offset = item_size.width;
|
||||
let percentage = percentage.min(1. - thumb_percentage_size);
|
||||
scroll
|
||||
.set_offset(point(-max_offset * percentage, scroll.offset().y));
|
||||
}
|
||||
ScrollbarAxis::Vertical => {
|
||||
let percentage =
|
||||
(event.position.y - bounds.origin.y) / bounds.size.height;
|
||||
let max_offset = item_size.height;
|
||||
let percentage = percentage.min(1. - thumb_percentage_size);
|
||||
scroll
|
||||
.set_offset(point(scroll.offset().x, -max_offset * percentage));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
cx.on_mouse_event({
|
||||
let scroll = scroll.clone();
|
||||
move |event: &ScrollWheelEvent, phase, cx| {
|
||||
if phase.bubble() && bounds.contains(&event.position) {
|
||||
let current_offset = scroll.offset();
|
||||
scroll
|
||||
.set_offset(current_offset + event.delta.pixel_delta(cx.line_height()));
|
||||
}
|
||||
}
|
||||
});
|
||||
let state = self.state.clone();
|
||||
let kind = self.kind;
|
||||
cx.on_mouse_event(move |event: &MouseMoveEvent, _, cx| {
|
||||
if let Some(drag_state) = state.drag.get().filter(|_| event.dragging()) {
|
||||
if let Some(ContentSize {
|
||||
size: item_size, ..
|
||||
}) = scroll.content_size()
|
||||
{
|
||||
match kind {
|
||||
ScrollbarAxis::Horizontal => {
|
||||
let max_offset = item_size.width;
|
||||
let percentage = (event.position.x - bounds.origin.x)
|
||||
/ bounds.size.width
|
||||
- drag_state;
|
||||
|
||||
let percentage = percentage.min(1. - thumb_percentage_size);
|
||||
scroll
|
||||
.set_offset(point(-max_offset * percentage, scroll.offset().y));
|
||||
}
|
||||
ScrollbarAxis::Vertical => {
|
||||
let max_offset = item_size.height;
|
||||
let percentage = (event.position.y - bounds.origin.y)
|
||||
/ bounds.size.height
|
||||
- drag_state;
|
||||
|
||||
let percentage = percentage.min(1. - thumb_percentage_size);
|
||||
scroll
|
||||
.set_offset(point(scroll.offset().x, -max_offset * percentage));
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(id) = state.parent_id {
|
||||
cx.notify(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.drag.set(None);
|
||||
}
|
||||
});
|
||||
let state = self.state.clone();
|
||||
cx.on_mouse_event(move |_event: &MouseUpEvent, phase, cx| {
|
||||
if phase.bubble() {
|
||||
state.drag.take();
|
||||
if let Some(id) = state.parent_id {
|
||||
cx.notify(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for Scrollbar {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -380,6 +380,8 @@ impl WorkspaceDb {
|
||||
&self,
|
||||
worktree_roots: &[P],
|
||||
) -> Option<SerializedWorkspace> {
|
||||
// paths are sorted before db interactions to ensure that the order of the paths
|
||||
// doesn't affect the workspace selection for existing workspaces
|
||||
let local_paths = LocalPaths::new(worktree_roots);
|
||||
|
||||
// Note that we re-assign the workspace_id here in case it's empty
|
||||
@@ -833,8 +835,8 @@ impl WorkspaceDb {
|
||||
}
|
||||
|
||||
query! {
|
||||
fn session_workspaces(session_id: String) -> Result<Vec<(LocalPaths, Option<u64>, Option<u64>)>> {
|
||||
SELECT local_paths, window_id, ssh_project_id
|
||||
fn session_workspaces(session_id: String) -> Result<Vec<(LocalPaths, LocalPathsOrder, Option<u64>, Option<u64>)>> {
|
||||
SELECT local_paths, local_paths_order, window_id, ssh_project_id
|
||||
FROM workspaces
|
||||
WHERE session_id = ?1 AND dev_server_project_id IS NULL
|
||||
ORDER BY timestamp DESC
|
||||
@@ -971,7 +973,7 @@ impl WorkspaceDb {
|
||||
) -> Result<Vec<SerializedWorkspaceLocation>> {
|
||||
let mut workspaces = Vec::new();
|
||||
|
||||
for (location, window_id, ssh_project_id) in
|
||||
for (location, order, window_id, ssh_project_id) in
|
||||
self.session_workspaces(last_session_id.to_owned())?
|
||||
{
|
||||
if let Some(ssh_project_id) = ssh_project_id {
|
||||
@@ -980,8 +982,7 @@ impl WorkspaceDb {
|
||||
} else if location.paths().iter().all(|path| path.exists())
|
||||
&& location.paths().iter().any(|path| path.is_dir())
|
||||
{
|
||||
let location =
|
||||
SerializedWorkspaceLocation::from_local_paths(location.paths().iter());
|
||||
let location = SerializedWorkspaceLocation::Local(location, order);
|
||||
workspaces.push((location, window_id.map(WindowId::from)));
|
||||
}
|
||||
}
|
||||
@@ -1603,27 +1604,56 @@ mod tests {
|
||||
window_id: Some(50),
|
||||
};
|
||||
|
||||
let workspace_6 = SerializedWorkspace {
|
||||
id: WorkspaceId(6),
|
||||
location: SerializedWorkspaceLocation::Local(
|
||||
LocalPaths::new(["/tmp6a", "/tmp6b", "/tmp6c"]),
|
||||
LocalPathsOrder::new([2, 1, 0]),
|
||||
),
|
||||
center_group: Default::default(),
|
||||
window_bounds: Default::default(),
|
||||
display: Default::default(),
|
||||
docks: Default::default(),
|
||||
centered_layout: false,
|
||||
session_id: Some("session-id-3".to_owned()),
|
||||
window_id: Some(60),
|
||||
};
|
||||
|
||||
db.save_workspace(workspace_1.clone()).await;
|
||||
db.save_workspace(workspace_2.clone()).await;
|
||||
db.save_workspace(workspace_3.clone()).await;
|
||||
db.save_workspace(workspace_4.clone()).await;
|
||||
db.save_workspace(workspace_5.clone()).await;
|
||||
db.save_workspace(workspace_6.clone()).await;
|
||||
|
||||
let locations = db.session_workspaces("session-id-1".to_owned()).unwrap();
|
||||
assert_eq!(locations.len(), 2);
|
||||
assert_eq!(locations[0].0, LocalPaths::new(["/tmp1"]));
|
||||
assert_eq!(locations[0].1, Some(10));
|
||||
assert_eq!(locations[0].1, LocalPathsOrder::new([0]));
|
||||
assert_eq!(locations[0].2, Some(10));
|
||||
assert_eq!(locations[1].0, LocalPaths::new(["/tmp2"]));
|
||||
assert_eq!(locations[1].1, Some(20));
|
||||
assert_eq!(locations[1].1, LocalPathsOrder::new([0]));
|
||||
assert_eq!(locations[1].2, Some(20));
|
||||
|
||||
let locations = db.session_workspaces("session-id-2".to_owned()).unwrap();
|
||||
assert_eq!(locations.len(), 2);
|
||||
assert_eq!(locations[0].0, LocalPaths::new(["/tmp3"]));
|
||||
assert_eq!(locations[0].1, Some(30));
|
||||
assert_eq!(locations[0].1, LocalPathsOrder::new([0]));
|
||||
assert_eq!(locations[0].2, Some(30));
|
||||
let empty_paths: Vec<&str> = Vec::new();
|
||||
assert_eq!(locations[1].0, LocalPaths::new(empty_paths.iter()));
|
||||
assert_eq!(locations[1].1, Some(50));
|
||||
assert_eq!(locations[1].2, Some(ssh_project.id.0));
|
||||
assert_eq!(locations[1].1, LocalPathsOrder::new([]));
|
||||
assert_eq!(locations[1].2, Some(50));
|
||||
assert_eq!(locations[1].3, Some(ssh_project.id.0));
|
||||
|
||||
let locations = db.session_workspaces("session-id-3".to_owned()).unwrap();
|
||||
assert_eq!(locations.len(), 1);
|
||||
assert_eq!(
|
||||
locations[0].0,
|
||||
LocalPaths::new(["/tmp6a", "/tmp6b", "/tmp6c"]),
|
||||
);
|
||||
assert_eq!(locations[0].1, LocalPathsOrder::new([2, 1, 0]));
|
||||
assert_eq!(locations[0].2, Some(60));
|
||||
}
|
||||
|
||||
fn default_workspace<P: AsRef<Path>>(
|
||||
@@ -1654,15 +1684,30 @@ mod tests {
|
||||
WorkspaceDb(open_test_db("test_serializing_workspaces_last_session_workspaces").await);
|
||||
|
||||
let workspaces = [
|
||||
(1, dir1.path().to_str().unwrap(), 9),
|
||||
(2, dir2.path().to_str().unwrap(), 5),
|
||||
(3, dir3.path().to_str().unwrap(), 8),
|
||||
(4, dir4.path().to_str().unwrap(), 2),
|
||||
(1, vec![dir1.path()], vec![0], 9),
|
||||
(2, vec![dir2.path()], vec![0], 5),
|
||||
(3, vec![dir3.path()], vec![0], 8),
|
||||
(4, vec![dir4.path()], vec![0], 2),
|
||||
(
|
||||
5,
|
||||
vec![dir1.path(), dir2.path(), dir3.path()],
|
||||
vec![0, 1, 2],
|
||||
3,
|
||||
),
|
||||
(
|
||||
6,
|
||||
vec![dir2.path(), dir3.path(), dir4.path()],
|
||||
vec![2, 1, 0],
|
||||
4,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(id, location, window_id)| SerializedWorkspace {
|
||||
.map(|(id, locations, order, window_id)| SerializedWorkspace {
|
||||
id: WorkspaceId(id),
|
||||
location: SerializedWorkspaceLocation::from_local_paths([location]),
|
||||
location: SerializedWorkspaceLocation::Local(
|
||||
LocalPaths::new(locations),
|
||||
LocalPathsOrder::new(order),
|
||||
),
|
||||
center_group: Default::default(),
|
||||
window_bounds: Default::default(),
|
||||
display: Default::default(),
|
||||
@@ -1681,28 +1726,44 @@ mod tests {
|
||||
WindowId::from(2), // Top
|
||||
WindowId::from(8),
|
||||
WindowId::from(5),
|
||||
WindowId::from(9), // Bottom
|
||||
WindowId::from(9),
|
||||
WindowId::from(3),
|
||||
WindowId::from(4), // Bottom
|
||||
]));
|
||||
|
||||
let have = db
|
||||
.last_session_workspace_locations("one-session", stack)
|
||||
.unwrap();
|
||||
assert_eq!(have.len(), 4);
|
||||
assert_eq!(have.len(), 6);
|
||||
assert_eq!(
|
||||
have[0],
|
||||
SerializedWorkspaceLocation::from_local_paths(&[dir4.path().to_str().unwrap()])
|
||||
SerializedWorkspaceLocation::from_local_paths(&[dir4.path()])
|
||||
);
|
||||
assert_eq!(
|
||||
have[1],
|
||||
SerializedWorkspaceLocation::from_local_paths([dir3.path().to_str().unwrap()])
|
||||
SerializedWorkspaceLocation::from_local_paths([dir3.path()])
|
||||
);
|
||||
assert_eq!(
|
||||
have[2],
|
||||
SerializedWorkspaceLocation::from_local_paths([dir2.path().to_str().unwrap()])
|
||||
SerializedWorkspaceLocation::from_local_paths([dir2.path()])
|
||||
);
|
||||
assert_eq!(
|
||||
have[3],
|
||||
SerializedWorkspaceLocation::from_local_paths([dir1.path().to_str().unwrap()])
|
||||
SerializedWorkspaceLocation::from_local_paths([dir1.path()])
|
||||
);
|
||||
assert_eq!(
|
||||
have[4],
|
||||
SerializedWorkspaceLocation::Local(
|
||||
LocalPaths::new([dir1.path(), dir2.path(), dir3.path()]),
|
||||
LocalPathsOrder::new([0, 1, 2]),
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
have[5],
|
||||
SerializedWorkspaceLocation::Local(
|
||||
LocalPaths::new([dir2.path(), dir3.path(), dir4.path()]),
|
||||
LocalPathsOrder::new([2, 1, 0]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use db::sqlez::{
|
||||
};
|
||||
use gpui::{AsyncWindowContext, Model, View, WeakView};
|
||||
use project::Project;
|
||||
use remote::{ssh_session::SshProjectId, SshConnectionOptions};
|
||||
use remote::ssh_session::SshProjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
@@ -50,15 +50,6 @@ impl SerializedSshProject {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn connection_options(&self) -> SshConnectionOptions {
|
||||
SshConnectionOptions {
|
||||
host: self.host.clone(),
|
||||
username: self.user.clone(),
|
||||
port: self.port,
|
||||
password: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StaticColumnCount for SerializedSshProject {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use project::TaskSourceKind;
|
||||
use remote::ConnectionState;
|
||||
use task::{ResolvedTask, TaskContext, TaskTemplate};
|
||||
use ui::ViewContext;
|
||||
|
||||
@@ -12,6 +13,19 @@ pub fn schedule_task(
|
||||
omit_history: bool,
|
||||
cx: &mut ViewContext<'_, Workspace>,
|
||||
) {
|
||||
match workspace.project.read(cx).ssh_connection_state(cx) {
|
||||
None | Some(ConnectionState::Connected) => {}
|
||||
Some(
|
||||
ConnectionState::Connecting
|
||||
| ConnectionState::Disconnected
|
||||
| ConnectionState::HeartbeatMissed
|
||||
| ConnectionState::Reconnecting,
|
||||
) => {
|
||||
log::warn!("Cannot schedule tasks when disconnected from a remote host");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(spawn_in_terminal) =
|
||||
task_to_resolve.resolve_task(&task_source_kind.to_id_base(), task_cx)
|
||||
{
|
||||
|
||||
@@ -46,7 +46,9 @@ use itertools::Itertools;
|
||||
use language::{LanguageRegistry, Rope};
|
||||
pub use modal_layer::*;
|
||||
use node_runtime::NodeRuntime;
|
||||
use notifications::{simple_message_notification::MessageNotification, NotificationHandle};
|
||||
use notifications::{
|
||||
simple_message_notification::MessageNotification, DetachAndPromptErr, NotificationHandle,
|
||||
};
|
||||
pub use pane::*;
|
||||
pub use pane_group::*;
|
||||
pub use persistence::{
|
||||
@@ -1101,20 +1103,28 @@ impl Workspace {
|
||||
|
||||
let mut paths_to_open = abs_paths;
|
||||
|
||||
let paths_order = serialized_workspace
|
||||
let workspace_location = serialized_workspace
|
||||
.as_ref()
|
||||
.map(|ws| &ws.location)
|
||||
.and_then(|loc| match loc {
|
||||
SerializedWorkspaceLocation::Local(_, order) => Some(order.order()),
|
||||
SerializedWorkspaceLocation::Local(paths, order) => {
|
||||
Some((paths.paths(), order.order()))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
|
||||
if let Some(paths_order) = paths_order {
|
||||
paths_to_open = paths_order
|
||||
if let Some((paths, order)) = workspace_location {
|
||||
// todo: should probably move this logic to a method on the SerializedWorkspaceLocation
|
||||
// it's only valid for Local and would be more clear there and be able to be tested
|
||||
// and reused elsewhere
|
||||
paths_to_open = order
|
||||
.iter()
|
||||
.filter_map(|i| paths_to_open.get(*i).cloned())
|
||||
.collect::<Vec<_>>();
|
||||
if paths_order.iter().enumerate().any(|(i, &j)| i != j) {
|
||||
.zip(paths.iter())
|
||||
.sorted_by_key(|(i, _)| *i)
|
||||
.map(|(_, path)| path.clone())
|
||||
.collect();
|
||||
|
||||
if order.iter().enumerate().any(|(i, &j)| i != j) {
|
||||
project_handle
|
||||
.update(&mut cx, |project, cx| {
|
||||
project.set_worktrees_reordered(true, cx);
|
||||
@@ -1887,11 +1897,7 @@ impl Workspace {
|
||||
directories: true,
|
||||
multiple: true,
|
||||
},
|
||||
if self.project.read(cx).is_via_ssh() {
|
||||
DirectoryLister::Project(self.project.clone())
|
||||
} else {
|
||||
DirectoryLister::Local(self.app_state.fs.clone())
|
||||
},
|
||||
DirectoryLister::Local(self.app_state.fs.clone()),
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -4359,17 +4365,17 @@ impl Workspace {
|
||||
.on_action(cx.listener(|workspace, action: &Save, cx| {
|
||||
workspace
|
||||
.save_active_item(action.save_intent.unwrap_or(SaveIntent::Save), cx)
|
||||
.detach_and_log_err(cx);
|
||||
.detach_and_prompt_err("Failed to save", cx, |_, _| None);
|
||||
}))
|
||||
.on_action(cx.listener(|workspace, _: &SaveWithoutFormat, cx| {
|
||||
workspace
|
||||
.save_active_item(SaveIntent::SaveWithoutFormat, cx)
|
||||
.detach_and_log_err(cx);
|
||||
.detach_and_prompt_err("Failed to save", cx, |_, _| None);
|
||||
}))
|
||||
.on_action(cx.listener(|workspace, _: &SaveAs, cx| {
|
||||
workspace
|
||||
.save_active_item(SaveIntent::SaveAs, cx)
|
||||
.detach_and_log_err(cx);
|
||||
.detach_and_prompt_err("Failed to save", cx, |_, _| None);
|
||||
}))
|
||||
.on_action(cx.listener(|workspace, _: &ActivatePreviousPane, cx| {
|
||||
workspace.activate_previous_pane(cx)
|
||||
@@ -5566,6 +5572,12 @@ pub fn open_ssh_project(
|
||||
};
|
||||
}
|
||||
|
||||
if project_paths_to_open.is_empty() {
|
||||
return Err(project_path_errors
|
||||
.pop()
|
||||
.unwrap_or_else(|| anyhow!("no paths given")));
|
||||
}
|
||||
|
||||
cx.update_window(window.into(), |_, cx| {
|
||||
cx.replace_root_view(|cx| {
|
||||
let mut workspace =
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description = "The fast, collaborative code editor."
|
||||
edition = "2021"
|
||||
name = "zed"
|
||||
version = "0.158.0"
|
||||
version = "0.159.0"
|
||||
publish = false
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["Zed Team <hi@zed.dev>"]
|
||||
|
||||
@@ -33,7 +33,7 @@ use assets::Assets;
|
||||
use node_runtime::{NodeBinaryOptions, NodeRuntime};
|
||||
use parking_lot::Mutex;
|
||||
use project::project_settings::ProjectSettings;
|
||||
use recent_projects::open_ssh_project;
|
||||
use recent_projects::{open_ssh_project, SshSettings};
|
||||
use release_channel::{AppCommitSha, AppVersion};
|
||||
use session::{AppSession, Session};
|
||||
use settings::{
|
||||
@@ -214,6 +214,7 @@ fn init_common(app_state: Arc<AppState>, cx: &mut AppContext) -> Arc<PromptBuild
|
||||
ThemeRegistry::global(cx),
|
||||
cx,
|
||||
);
|
||||
recent_projects::init(cx);
|
||||
prompt_builder
|
||||
}
|
||||
|
||||
@@ -248,7 +249,6 @@ fn init_ui(
|
||||
audio::init(Assets, cx);
|
||||
workspace::init(app_state.clone(), cx);
|
||||
|
||||
recent_projects::init(cx);
|
||||
go_to_line::init(cx);
|
||||
file_finder::init(cx);
|
||||
tab_switcher::init(cx);
|
||||
@@ -881,18 +881,25 @@ async fn restore_or_create_workspace(
|
||||
})?;
|
||||
task.await?;
|
||||
}
|
||||
SerializedWorkspaceLocation::Ssh(ssh_project) => {
|
||||
SerializedWorkspaceLocation::Ssh(ssh) => {
|
||||
let args = cx
|
||||
.update(|cx| {
|
||||
SshSettings::get_global(cx).args_for(&ssh.host, ssh.port, &ssh.user)
|
||||
})
|
||||
.ok()
|
||||
.flatten();
|
||||
let connection_options = SshConnectionOptions {
|
||||
host: ssh_project.host.clone(),
|
||||
username: ssh_project.user.clone(),
|
||||
port: ssh_project.port,
|
||||
args,
|
||||
host: ssh.host.clone(),
|
||||
username: ssh.user.clone(),
|
||||
port: ssh.port,
|
||||
password: None,
|
||||
};
|
||||
let app_state = app_state.clone();
|
||||
cx.spawn(move |mut cx| async move {
|
||||
recent_projects::open_ssh_project(
|
||||
connection_options,
|
||||
ssh_project.paths.into_iter().map(PathBuf::from).collect(),
|
||||
ssh.paths.into_iter().map(PathBuf::from).collect(),
|
||||
app_state,
|
||||
workspace::OpenOptions::default(),
|
||||
&mut cx,
|
||||
|
||||
@@ -16,8 +16,9 @@ use futures::future::join_all;
|
||||
use futures::{FutureExt, SinkExt, StreamExt};
|
||||
use gpui::{AppContext, AsyncAppContext, Global, WindowHandle};
|
||||
use language::{Bias, Point};
|
||||
use recent_projects::open_ssh_project;
|
||||
use recent_projects::{open_ssh_project, SshSettings};
|
||||
use remote::SshConnectionOptions;
|
||||
use settings::Settings;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -48,7 +49,7 @@ impl OpenRequest {
|
||||
} else if let Some(file) = url.strip_prefix("zed://file") {
|
||||
this.parse_file_path(file)
|
||||
} else if url.starts_with("ssh://") {
|
||||
this.parse_ssh_file_path(&url)?
|
||||
this.parse_ssh_file_path(&url, cx)?
|
||||
} else if let Some(request_path) = parse_zed_link(&url, cx) {
|
||||
this.parse_request_path(request_path).log_err();
|
||||
} else {
|
||||
@@ -65,7 +66,7 @@ impl OpenRequest {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ssh_file_path(&mut self, file: &str) -> Result<()> {
|
||||
fn parse_ssh_file_path(&mut self, file: &str, cx: &AppContext) -> Result<()> {
|
||||
let url = url::Url::parse(file)?;
|
||||
let host = url
|
||||
.host()
|
||||
@@ -77,11 +78,13 @@ impl OpenRequest {
|
||||
if !self.open_paths.is_empty() {
|
||||
return Err(anyhow!("cannot open both local and ssh paths"));
|
||||
}
|
||||
let args = SshSettings::get_global(cx).args_for(&host, port, &username);
|
||||
let connection = SshConnectionOptions {
|
||||
username,
|
||||
password,
|
||||
host,
|
||||
port,
|
||||
args,
|
||||
};
|
||||
if let Some(ssh_connection) = &self.ssh_connection {
|
||||
if *ssh_connection != connection {
|
||||
@@ -419,12 +422,25 @@ async fn open_workspaces(
|
||||
errored = true
|
||||
}
|
||||
}
|
||||
SerializedWorkspaceLocation::Ssh(ssh_project) => {
|
||||
SerializedWorkspaceLocation::Ssh(ssh) => {
|
||||
let app_state = app_state.clone();
|
||||
let args = cx
|
||||
.update(|cx| {
|
||||
SshSettings::get_global(cx).args_for(&ssh.host, ssh.port, &ssh.user)
|
||||
})
|
||||
.ok()
|
||||
.flatten();
|
||||
let connection_options = SshConnectionOptions {
|
||||
args,
|
||||
host: ssh.host.clone(),
|
||||
username: ssh.user.clone(),
|
||||
port: ssh.port,
|
||||
password: None,
|
||||
};
|
||||
cx.spawn(|mut cx| async move {
|
||||
open_ssh_project(
|
||||
ssh_project.connection_options(),
|
||||
ssh_project.paths.into_iter().map(PathBuf::from).collect(),
|
||||
connection_options,
|
||||
ssh.paths.into_iter().map(PathBuf::from).collect(),
|
||||
app_state,
|
||||
OpenOptions::default(),
|
||||
&mut cx,
|
||||
|
||||
@@ -1498,13 +1498,13 @@ List of `integer` column numbers
|
||||
"directories": [".env", "env", ".venv", "venv"],
|
||||
"activate_script": "default"
|
||||
}
|
||||
}
|
||||
},
|
||||
"env": {},
|
||||
"font_family": null,
|
||||
"font_features": null,
|
||||
"font_size": null,
|
||||
"line_height": "comfortable",
|
||||
"option_as_meta": true,
|
||||
"option_as_meta": false,
|
||||
"button": false,
|
||||
"shell": {},
|
||||
"toolbar": {
|
||||
@@ -1732,7 +1732,7 @@ See Buffer Font Features
|
||||
|
||||
- Description: Re-interprets the option keys to act like a 'meta' key, like in Emacs.
|
||||
- Setting: `option_as_meta`
|
||||
- Default: `true`
|
||||
- Default: `false`
|
||||
|
||||
**Options**
|
||||
|
||||
|
||||
@@ -38,6 +38,23 @@ If you want to disable Zed looking for a `clangd` binary, you can set `ignore_sy
|
||||
}
|
||||
```
|
||||
|
||||
## Arguments
|
||||
|
||||
You can pass any number of arguments to clangd. To see a full set of available options, run `clangd --help` from the command line. For example with `--function-arg-placeholders=0` completions contain only parentheses for function calls, while the default (`--function-arg-placeholders=1`) completions also contain placeholders for method parameters.
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"clangd": {
|
||||
"binary": {
|
||||
"path": "/path/to/clangd",
|
||||
"arguments": ["--function-arg-placeholders=0"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## More server configuration
|
||||
|
||||
In the root of your project, it is generally common to create a `.clangd` file to set extra configuration.
|
||||
|
||||
@@ -1,6 +1,203 @@
|
||||
# Java
|
||||
|
||||
Java language support in Zed is provided the [zed Java extension](https://github.com/zed-extensions/java).
|
||||
Report issues to: [https://github.com/zed-extensions/java/issues](https://github.com/zed-extensions/java/issues)
|
||||
There are two extensions that provide Java language support for Zed:
|
||||
|
||||
- Zed Java: [zed-extensions/java](https://github.com/zed-extensions/java) and
|
||||
- Java with Eclipse JDTLS: [zed-java-eclipse-jdtls](https://github.com/ABckh/zed-java-eclipse-jdtls).
|
||||
|
||||
Both use:
|
||||
|
||||
- Tree Sitter: [tree-sitter/tree-sitter-java](https://github.com/tree-sitter/tree-sitter-java)
|
||||
- Language Server: [eclipse-jdtls/eclipse.jdt.ls](https://github.com/eclipse-jdtls/eclipse.jdt.ls)
|
||||
|
||||
## Pre-requisites
|
||||
|
||||
You will need to install both a Java runtime (OpenJDK) and Eclipse JDT Language Server (`eclipse.jdt.ls`).
|
||||
|
||||
### Install OpenJDK
|
||||
|
||||
- MacOS: `brew install openjdk`
|
||||
- Ubuntu: `sudo add-apt-repository ppa:openjdk-23 && sudo apt-get install openjdk-23`
|
||||
- Windows: `choco install openjdk`
|
||||
- Arch Linux: `sudo pacman -S jre-openjdk-headless`
|
||||
|
||||
Or manually download and install [OpenJDK 23](https://jdk.java.net/23/).
|
||||
|
||||
### Install JDTLS
|
||||
|
||||
- MacOS: `brew install jdtls`
|
||||
- Arch: [`jdtls` from AUR](https://aur.archlinux.org/packages/jdtls)
|
||||
|
||||
Or manually download install:
|
||||
|
||||
- [JDTLS Milestone Builds](http://download.eclipse.org/jdtls/milestones/) (updated every two weeks)
|
||||
- [JDTLS Snapshot Builds](https://download.eclipse.org/jdtls/snapshots/) (frequent updates)
|
||||
|
||||
## Extension Install
|
||||
|
||||
You can install either by opening {#action zed::Extensions}({#kb zed::Extensions}) and searching for `java`.
|
||||
We recommend you install one or the other and not both.
|
||||
|
||||
## Settings / Initialization Options
|
||||
|
||||
See [JDTLS Language Server Settings & Capabilities](https://github.com/eclipse-jdtls/eclipse.jdt.ls/wiki/Language-Server-Settings-&-Capabilities) for a complete list of options.
|
||||
|
||||
Add the following to your Zed Settings by launching {#action zed::OpenSettings}({#kb zed::OpenSettings}).
|
||||
|
||||
### Zed Java Settings
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"jdtls": {
|
||||
"settings": {},
|
||||
"initialization_options": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Java with Eclipse JDTLS settings
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"java": {
|
||||
"settings": {},
|
||||
"initialization_options": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [Zed Java Readme](https://github.com/zed-extensions/java)
|
||||
- [Java with Eclipse JDTLS Readme](https://github.com/ABckh/zed-java-eclipse-jdtls)
|
||||
|
||||
### Support
|
||||
|
||||
If you have issues with either of these plugins, please open issues on their respective repositories:
|
||||
|
||||
- [Zed Java Issues](https://github.com/zed-extensions/java/issues)
|
||||
- [Java with Eclipse JDTLS Issues](https://github.com/ABckh/zed-java-eclipse-jdtls/issues)
|
||||
|
||||
## Example Configs
|
||||
|
||||
### Zed Java Classpath
|
||||
|
||||
You can optionally configure the class path that JDTLS uses with:
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"jdtls": {
|
||||
"settings": {
|
||||
"classpath": "/path/to/classes.jar:/path/to/more/classes/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Zed Java Initialization Options
|
||||
|
||||
There are also many more options you can pass directly to the language server, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"jdtls": {
|
||||
"initialization_options": {
|
||||
"bundles": [],
|
||||
"workspaceFolders": ["file:///home/snjeza/Project"],
|
||||
"settings": {
|
||||
"java": {
|
||||
"home": "/usr/local/jdk-9.0.1",
|
||||
"errors": {
|
||||
"incompleteClasspath": {
|
||||
"severity": "warning"
|
||||
}
|
||||
},
|
||||
"configuration": {
|
||||
"updateBuildConfiguration": "interactive",
|
||||
"maven": {
|
||||
"userSettings": null
|
||||
}
|
||||
},
|
||||
"trace": {
|
||||
"server": "verbose"
|
||||
},
|
||||
"import": {
|
||||
"gradle": {
|
||||
"enabled": true
|
||||
},
|
||||
"maven": {
|
||||
"enabled": true
|
||||
},
|
||||
"exclusions": [
|
||||
"**/node_modules/**",
|
||||
"**/.metadata/**",
|
||||
"**/archetype-resources/**",
|
||||
"**/META-INF/maven/**",
|
||||
"/**/test/**"
|
||||
]
|
||||
},
|
||||
"referencesCodeLens": {
|
||||
"enabled": false
|
||||
},
|
||||
"signatureHelp": {
|
||||
"enabled": false
|
||||
},
|
||||
"implementationsCodeLens": {
|
||||
"enabled": false
|
||||
},
|
||||
"format": {
|
||||
"enabled": true
|
||||
},
|
||||
"saveActions": {
|
||||
"organizeImports": false
|
||||
},
|
||||
"contentProvider": {
|
||||
"preferred": null
|
||||
},
|
||||
"autobuild": {
|
||||
"enabled": false
|
||||
},
|
||||
"completion": {
|
||||
"favoriteStaticMembers": [
|
||||
"org.junit.Assert.*",
|
||||
"org.junit.Assume.*",
|
||||
"org.junit.jupiter.api.Assertions.*",
|
||||
"org.junit.jupiter.api.Assumptions.*",
|
||||
"org.junit.jupiter.api.DynamicContainer.*",
|
||||
"org.junit.jupiter.api.DynamicTest.*"
|
||||
],
|
||||
"importOrder": ["java", "javax", "com", "org"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Java with Eclipse JTDLS Configuration {#zed-java-eclipse-configuration}
|
||||
|
||||
Configuration options match those provided in the [redhat-developer/vscode-java extension](https://github.com/redhat-developer/vscode-java#supported-vs-code-settings).
|
||||
|
||||
For example, to enable [Lombok Support](https://github.com/redhat-developer/vscode-java/wiki/Lombok-support):
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"java": {
|
||||
"settings": {
|
||||
"java.jdt.ls.lombokSupport.enabled:": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "zed_astro"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "astro"
|
||||
name = "Astro"
|
||||
description = "Astro support."
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
schema_version = 1
|
||||
authors = ["Alvaro Gaona <alvgaona@gmail.com>", "0xk1f0 <dev@k1f0.dev>"]
|
||||
repository = "https://github.com/zed-industries/zed"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "zed_elixir"
|
||||
version = "0.0.9"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "elixir"
|
||||
name = "Elixir"
|
||||
description = "Elixir support."
|
||||
version = "0.0.9"
|
||||
version = "0.1.0"
|
||||
schema_version = 1
|
||||
authors = ["Marshall Bowers <elliott.codes@gmail.com>"]
|
||||
repository = "https://github.com/zed-industries/zed"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "zed_html"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "html"
|
||||
name = "HTML"
|
||||
description = "HTML support."
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
schema_version = 1
|
||||
authors = ["Isaac Clayton <slightknack@gmail.com>"]
|
||||
repository = "https://github.com/zed-industries/zed"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user