Compare commits
108 Commits
workspace-
...
streaming-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44501581ee | ||
|
|
ae95142cc8 | ||
|
|
b1b8d596b9 | ||
|
|
6c025507b6 | ||
|
|
8f4b7aa5db | ||
|
|
3345666557 | ||
|
|
40c62cda5f | ||
|
|
349a48d937 | ||
|
|
a88af7351a | ||
|
|
efaf358876 | ||
|
|
06a226dc32 | ||
|
|
1763dd714b | ||
|
|
330e799293 | ||
|
|
51c900366d | ||
|
|
be75f17429 | ||
|
|
f373383fc1 | ||
|
|
263d9ff755 | ||
|
|
829ecda370 | ||
|
|
af5af9d7c5 | ||
|
|
97c0a0a86e | ||
|
|
7e964290bf | ||
|
|
b8a8b9c699 | ||
|
|
d1cec209d4 | ||
|
|
aceab76ae4 | ||
|
|
9c054f207e | ||
|
|
219d36f589 | ||
|
|
6fd9708eee | ||
|
|
99216acdec | ||
|
|
aef25a3bc3 | ||
|
|
9b07f36199 | ||
|
|
ff25fa24e7 | ||
|
|
05df3d1bd6 | ||
|
|
84f4d2630f | ||
|
|
b42930f5be | ||
|
|
cb2eef6fb6 | ||
|
|
73668c2c4b | ||
|
|
07f555ca3b | ||
|
|
b0e2b57462 | ||
|
|
d404d7964e | ||
|
|
69af9bedaf | ||
|
|
2ebbcf15ea | ||
|
|
631cab5e40 | ||
|
|
ba39a47c52 | ||
|
|
c34357e2ab | ||
|
|
6fdb666bb7 | ||
|
|
7a9e0b37ed | ||
|
|
d44ba92363 | ||
|
|
ca4d8fc900 | ||
|
|
352882af77 | ||
|
|
c5c4a6201b | ||
|
|
6327a5d665 | ||
|
|
57438d30e8 | ||
|
|
5c81dd7d39 | ||
|
|
aec4d5cb26 | ||
|
|
4c0750bd2f | ||
|
|
22b1a02e23 | ||
|
|
314ad5dd5f | ||
|
|
d3c68650c0 | ||
|
|
43339c6869 | ||
|
|
e505d6bf5b | ||
|
|
0200dda83d | ||
|
|
4db9ab15a7 | ||
|
|
5daadc0d30 | ||
|
|
431727fdd7 | ||
|
|
cee98f872a | ||
|
|
e99d68a66f | ||
|
|
6a3e8044b1 | ||
|
|
c2375a4164 | ||
|
|
9d54e63a11 | ||
|
|
2a919ad1d0 | ||
|
|
f13b2fd811 | ||
|
|
7c39153160 | ||
|
|
d0c2bef8c3 | ||
|
|
87b3fefdd1 | ||
|
|
66784c0b3f | ||
|
|
ad9c508a72 | ||
|
|
aaa506c061 | ||
|
|
a602c50a6c | ||
|
|
728c161e8d | ||
|
|
3975d8ea93 | ||
|
|
2d050a8130 | ||
|
|
e600e71c1c | ||
|
|
82d85fd2ed | ||
|
|
e061ebb46c | ||
|
|
387ee46c46 | ||
|
|
89d89b8b2d | ||
|
|
f07ae541ad | ||
|
|
ff0bb1f389 | ||
|
|
9c7eee24bc | ||
|
|
ec4719146a | ||
|
|
47f8f891c8 | ||
|
|
d9d3b8847b | ||
|
|
d7b90f4204 | ||
|
|
3e64f38ba0 | ||
|
|
82338e2c47 | ||
|
|
229e853874 | ||
|
|
ed13e05855 | ||
|
|
674fb7621f | ||
|
|
fe18c73a07 | ||
|
|
befacfe8c9 | ||
|
|
54f0a729c2 | ||
|
|
67f9b2b87f | ||
|
|
a4ec0af681 | ||
|
|
886d8c1cab | ||
|
|
ebc5c213a2 | ||
|
|
0a2d938ac5 | ||
|
|
fc01f496a9 | ||
|
|
db28b9bbde |
98
.github/workflows/ci.yml
vendored
98
.github/workflows/ci.yml
vendored
@@ -236,12 +236,24 @@ jobs:
|
||||
if: always()
|
||||
run: rm -rf ./../.cargo
|
||||
|
||||
windows_tests:
|
||||
windows_clippy:
|
||||
timeout-minutes: 60
|
||||
name: (Windows) Run Clippy and tests
|
||||
name: (Windows) Run Clippy
|
||||
if: github.repository_owner == 'zed-industries'
|
||||
runs-on: hosted-windows-2
|
||||
steps:
|
||||
# Temporarily Collect some metadata about the hardware behind our runners.
|
||||
- name: GHA Runner Info
|
||||
run: |
|
||||
Invoke-RestMethod -Headers @{"Metadata"="true"} -Method GET -Uri "http://169.254.169.254/metadata/instance/compute?api-version=2023-07-01" |
|
||||
ConvertTo-Json -Depth 10 |
|
||||
jq "{ vm_size: .vmSize, location: .location, os_disk_gb: (.storageProfile.osDisk.diskSizeGB | tonumber), rs_disk_gb: (.storageProfile.resourceDisk.size | tonumber / 1024) }"
|
||||
@{
|
||||
Cores = (Get-CimInstance Win32_Processor).NumberOfCores
|
||||
vCPUs = (Get-CimInstance Win32_Processor).NumberOfLogicalProcessors
|
||||
RamGb = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 2)
|
||||
cpuid = (Get-CimInstance Win32_Processor).Name.Trim()
|
||||
} | ConvertTo-Json
|
||||
# more info here:- https://github.com/rust-lang/cargo/issues/13020
|
||||
- name: Enable longer pathnames for git
|
||||
run: git config --system core.longpaths true
|
||||
@@ -275,6 +287,69 @@ jobs:
|
||||
working-directory: ${{ env.ZED_WORKSPACE }}
|
||||
run: ./script/clippy.ps1
|
||||
|
||||
- name: Check dev drive space
|
||||
working-directory: ${{ env.ZED_WORKSPACE }}
|
||||
# `setup-dev-driver.ps1` creates a 100GB drive, with CI taking up ~45GB of the drive.
|
||||
run: ./script/exit-ci-if-dev-drive-is-full.ps1 95
|
||||
|
||||
# Since the Windows runners are stateful, so we need to remove the config file to prevent potential bug.
|
||||
- name: Clean CI config file
|
||||
if: always()
|
||||
run: |
|
||||
if (Test-Path "${{ env.CARGO_HOME }}/config.toml") {
|
||||
Remove-Item -Path "${{ env.CARGO_HOME }}/config.toml" -Force
|
||||
}
|
||||
|
||||
# Windows CI takes twice as long as our other platforms and fast github hosted runners are expensive.
|
||||
# But we still want to do CI, so let's only run tests on main and come back to this when we're
|
||||
# ready to self host our Windows CI (e.g. during the push for full Windows support)
|
||||
windows_tests:
|
||||
timeout-minutes: 60
|
||||
name: (Windows) Run Tests
|
||||
if: ${{ github.repository_owner == 'zed-industries' && (github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'windows')) }}
|
||||
runs-on: hosted-windows-2
|
||||
steps:
|
||||
# Temporarily Collect some metadata about the hardware behind our runners.
|
||||
- name: GHA Runner Info
|
||||
run: |
|
||||
Invoke-RestMethod -Headers @{"Metadata"="true"} -Method GET -Uri "http://169.254.169.254/metadata/instance/compute?api-version=2023-07-01" |
|
||||
ConvertTo-Json -Depth 10 |
|
||||
jq "{ vm_size: .vmSize, location: .location, os_disk_gb: (.storageProfile.osDisk.diskSizeGB | tonumber), rs_disk_gb: (.storageProfile.resourceDisk.size | tonumber / 1024) }"
|
||||
@{
|
||||
Cores = (Get-CimInstance Win32_Processor).NumberOfCores
|
||||
vCPUs = (Get-CimInstance Win32_Processor).NumberOfLogicalProcessors
|
||||
RamGb = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 2)
|
||||
cpuid = (Get-CimInstance Win32_Processor).Name.Trim()
|
||||
} | ConvertTo-Json
|
||||
# more info here:- https://github.com/rust-lang/cargo/issues/13020
|
||||
- name: Enable longer pathnames for git
|
||||
run: git config --system core.longpaths true
|
||||
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
clean: false
|
||||
|
||||
- name: Create Dev Drive using ReFS
|
||||
run: ./script/setup-dev-driver.ps1
|
||||
|
||||
# actions/checkout does not let us clone into anywhere outside ${{ github.workspace }}, so we have to copy the clone...
|
||||
- name: Copy Git Repo to Dev Drive
|
||||
run: |
|
||||
Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.ZED_WORKSPACE }}" -Recurse
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
workspaces: ${{ env.ZED_WORKSPACE }}
|
||||
cache-provider: "github"
|
||||
|
||||
- name: Configure CI
|
||||
run: |
|
||||
mkdir -p ${{ env.CARGO_HOME }} -ErrorAction Ignore
|
||||
cp ./.cargo/ci-config.toml ${{ env.CARGO_HOME }}/config.toml
|
||||
|
||||
- name: Run tests
|
||||
uses: ./.github/actions/run_tests_windows
|
||||
with:
|
||||
@@ -292,7 +367,10 @@ jobs:
|
||||
# Since the Windows runners are stateful, so we need to remove the config file to prevent potential bug.
|
||||
- name: Clean CI config file
|
||||
if: always()
|
||||
run: Remove-Item -Path "${{ env.CARGO_HOME }}/config.toml" -Force
|
||||
run: |
|
||||
if (Test-Path "${{ env.CARGO_HOME }}/config.toml") {
|
||||
Remove-Item -Path "${{ env.CARGO_HOME }}/config.toml" -Force
|
||||
}
|
||||
|
||||
bundle-mac:
|
||||
timeout-minutes: 120
|
||||
@@ -422,6 +500,13 @@ jobs:
|
||||
name: zed-${{ github.event.pull_request.head.sha || github.sha }}-x86_64-unknown-linux-gnu.tar.gz
|
||||
path: target/release/zed-*.tar.gz
|
||||
|
||||
- name: Upload Linux remote server to workflow run if main branch or specific label
|
||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4
|
||||
if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }}
|
||||
with:
|
||||
name: zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-x86_64-unknown-linux-gnu.gz
|
||||
path: target/zed-remote-server-linux-x86_64.gz
|
||||
|
||||
- name: Upload app bundle to release
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
@@ -470,6 +555,13 @@ jobs:
|
||||
name: zed-${{ github.event.pull_request.head.sha || github.sha }}-aarch64-unknown-linux-gnu.tar.gz
|
||||
path: target/release/zed-*.tar.gz
|
||||
|
||||
- name: Upload Linux remote server to workflow run if main branch or specific label
|
||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4
|
||||
if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-bundling') }}
|
||||
with:
|
||||
name: zed-remote-server-${{ github.event.pull_request.head.sha || github.sha }}-aarch64-unknown-linux-gnu.gz
|
||||
path: target/zed-remote-server-linux-aarch64.gz
|
||||
|
||||
- name: Upload app bundle to release
|
||||
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
|
||||
with:
|
||||
|
||||
1735
Cargo.lock
generated
1735
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
14
Cargo.toml
14
Cargo.toml
@@ -3,6 +3,7 @@ resolver = "2"
|
||||
members = [
|
||||
"crates/activity_indicator",
|
||||
"crates/anthropic",
|
||||
"crates/askpass",
|
||||
"crates/assets",
|
||||
"crates/assistant",
|
||||
"crates/assistant2",
|
||||
@@ -117,6 +118,7 @@ members = [
|
||||
"crates/rope",
|
||||
"crates/rpc",
|
||||
"crates/schema_generator",
|
||||
"crates/scripting_tool",
|
||||
"crates/search",
|
||||
"crates/semantic_index",
|
||||
"crates/semantic_version",
|
||||
@@ -167,15 +169,10 @@ members = [
|
||||
# Extensions
|
||||
#
|
||||
|
||||
"extensions/csharp",
|
||||
"extensions/deno",
|
||||
"extensions/elixir",
|
||||
"extensions/emmet",
|
||||
"extensions/erlang",
|
||||
"extensions/glsl",
|
||||
"extensions/haskell",
|
||||
"extensions/html",
|
||||
"extensions/lua",
|
||||
"extensions/perplexity",
|
||||
"extensions/proto",
|
||||
"extensions/purescript",
|
||||
@@ -209,6 +206,7 @@ edition = "2021"
|
||||
activity_indicator = { path = "crates/activity_indicator" }
|
||||
ai = { path = "crates/ai" }
|
||||
anthropic = { path = "crates/anthropic" }
|
||||
askpass = { path = "crates/askpass" }
|
||||
assets = { path = "crates/assets" }
|
||||
assistant = { path = "crates/assistant" }
|
||||
assistant2 = { path = "crates/assistant2" }
|
||||
@@ -321,6 +319,7 @@ reqwest_client = { path = "crates/reqwest_client" }
|
||||
rich_text = { path = "crates/rich_text" }
|
||||
rope = { path = "crates/rope" }
|
||||
rpc = { path = "crates/rpc" }
|
||||
scripting_tool = { path = "crates/scripting_tool" }
|
||||
search = { path = "crates/search" }
|
||||
semantic_index = { path = "crates/semantic_index" }
|
||||
semantic_version = { path = "crates/semantic_version" }
|
||||
@@ -453,6 +452,7 @@ livekit = { git = "https://github.com/zed-industries/livekit-rust-sdks", rev = "
|
||||
], default-features = false }
|
||||
log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] }
|
||||
markup5ever_rcdom = "0.3.0"
|
||||
mlua = { version = "0.10", features = ["lua54", "vendored"] }
|
||||
nanoid = "0.4"
|
||||
nbformat = { version = "0.10.0" }
|
||||
nix = "0.29"
|
||||
@@ -538,7 +538,7 @@ tiny_http = "0.8"
|
||||
toml = "0.8"
|
||||
tokio = { version = "1" }
|
||||
tower-http = "0.4.4"
|
||||
tree-sitter = { version = "0.25.2", features = ["wasm"] }
|
||||
tree-sitter = { version = "0.25.3", features = ["wasm"] }
|
||||
tree-sitter-bash = "0.23"
|
||||
tree-sitter-c = "0.23"
|
||||
tree-sitter-cpp = "0.23"
|
||||
@@ -601,9 +601,11 @@ features = [
|
||||
version = "0.58"
|
||||
features = [
|
||||
"implement",
|
||||
"Foundation_Collections",
|
||||
"Foundation_Numerics",
|
||||
"Storage",
|
||||
"System_Threading",
|
||||
"UI_StartScreen",
|
||||
"UI_ViewManagement",
|
||||
"Wdk_System_SystemServices",
|
||||
"Win32_Globalization",
|
||||
|
||||
10
assets/icons/ai_edit.svg
Normal file
10
assets/icons/ai_edit.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5871 5.40624C12.8514 5.14195 13 4.78346 13 4.40965C13 4.03583 12.8516 3.67731 12.5873 3.41295C12.323 3.14859 11.9645 3.00005 11.5907 3C11.2169 2.99995 10.8584 3.14841 10.594 3.4127L3.92098 10.0874C3.80488 10.2031 3.71903 10.3456 3.67097 10.5024L3.01047 12.6784C2.99754 12.7217 2.99657 12.7676 3.00764 12.8113C3.01872 12.8551 3.04143 12.895 3.07337 12.9269C3.1053 12.9588 3.14528 12.9815 3.18905 12.9925C3.23282 13.0035 3.27875 13.0024 3.32197 12.9894L5.49849 12.3294C5.65508 12.2818 5.79758 12.1964 5.91349 12.0809L12.5871 5.40624Z" stroke="black" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 4L12 6" stroke="black" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.38818 3.53598V2.53598" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.56982 12.6995L9.56982 13.6995" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2.38818 6.53598H3.38818" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.5698 9.69949L12.5698 9.69949" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.38818 4.53598L3.38818 3.53598" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.5698 11.6995L12.5698 12.6995" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -739,7 +739,7 @@
|
||||
"tab": "git_panel::FocusEditor",
|
||||
"shift-tab": "git_panel::FocusEditor",
|
||||
"escape": "git_panel::ToggleFocus",
|
||||
"ctrl-enter": "git::ShowCommitEditor",
|
||||
"ctrl-enter": "git::Commit",
|
||||
"alt-enter": "menu::SecondaryConfirm"
|
||||
}
|
||||
},
|
||||
@@ -747,13 +747,20 @@
|
||||
"context": "GitCommit > Editor",
|
||||
"bindings": {
|
||||
"enter": "editor::Newline",
|
||||
"ctrl-enter": "git::Commit"
|
||||
"ctrl-enter": "git::Commit",
|
||||
"alt-l": "git::GenerateCommitMessage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "GitDiff > Editor",
|
||||
"bindings": {
|
||||
"ctrl-enter": "git::ShowCommitEditor"
|
||||
"ctrl-enter": "git::Commit"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "AskPass > Editor",
|
||||
"bindings": {
|
||||
"enter": "menu::Confirm"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -763,7 +770,8 @@
|
||||
"tab": "git_panel::FocusChanges",
|
||||
"shift-tab": "git_panel::FocusChanges",
|
||||
"ctrl-enter": "git::Commit",
|
||||
"alt-up": "git_panel::FocusChanges"
|
||||
"alt-up": "git_panel::FocusChanges",
|
||||
"alt-l": "git::GenerateCommitMessage"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -760,14 +760,21 @@
|
||||
"tab": "git_panel::FocusEditor",
|
||||
"shift-tab": "git_panel::FocusEditor",
|
||||
"escape": "git_panel::ToggleFocus",
|
||||
"cmd-enter": "git::ShowCommitEditor"
|
||||
"cmd-enter": "git::Commit"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "GitDiff > Editor",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-enter": "git::ShowCommitEditor"
|
||||
"cmd-enter": "git::Commit"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "AskPass > Editor",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"enter": "menu::Confirm"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -778,7 +785,9 @@
|
||||
"cmd-enter": "git::Commit",
|
||||
"tab": "git_panel::FocusChanges",
|
||||
"shift-tab": "git_panel::FocusChanges",
|
||||
"alt-up": "git_panel::FocusChanges"
|
||||
"alt-up": "git_panel::FocusChanges",
|
||||
"shift-escape": "git::ExpandCommitEditor",
|
||||
"alt-tab": "git::GenerateCommitMessage"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -786,7 +795,8 @@
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"enter": "editor::Newline",
|
||||
"cmd-enter": "git::Commit"
|
||||
"cmd-enter": "git::Commit",
|
||||
"alt-tab": "git::GenerateCommitMessage"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"a": ["vim::PushObject", { "around": true }],
|
||||
"left": "vim::Left",
|
||||
"h": "vim::Left",
|
||||
"backspace": "vim::Backspace",
|
||||
"backspace": "vim::WrappingLeft",
|
||||
"down": "vim::Down",
|
||||
"ctrl-j": "vim::Down",
|
||||
"j": "vim::Down",
|
||||
@@ -20,7 +20,7 @@
|
||||
"k": "vim::Up",
|
||||
"right": "vim::Right",
|
||||
"l": "vim::Right",
|
||||
"space": "vim::Space",
|
||||
"space": "vim::WrappingRight",
|
||||
"end": "vim::EndOfLine",
|
||||
"$": "vim::EndOfLine",
|
||||
"^": "vim::FirstNonWhitespace",
|
||||
@@ -247,7 +247,8 @@
|
||||
"context": "VimControl && VimCount",
|
||||
"bindings": {
|
||||
"0": ["vim::Number", 0],
|
||||
":": "vim::CountCommand"
|
||||
":": "vim::CountCommand",
|
||||
"%": "vim::GoToPercentage"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -720,8 +720,8 @@
|
||||
"remove_trailing_whitespace_on_save": true,
|
||||
// Whether to start a new line with a comment when a previous line is a comment as well.
|
||||
"extend_comment_on_newline": true,
|
||||
// Whether or not to ensure there's a single newline at the end of a buffer
|
||||
// when saving it.
|
||||
// Removes any lines containing only whitespace at the end of the file and
|
||||
// ensures just one newline at the end.
|
||||
"ensure_final_newline_on_save": true,
|
||||
// Whether or not to perform a buffer format before saving
|
||||
//
|
||||
@@ -1175,6 +1175,7 @@
|
||||
"format_on_save": "off",
|
||||
"use_on_type_format": false,
|
||||
"allow_rewrap": "anywhere",
|
||||
"soft_wrap": "bounded",
|
||||
"prettier": {
|
||||
"allowed": true
|
||||
}
|
||||
@@ -1298,6 +1299,11 @@
|
||||
// "semi": false,
|
||||
// "singleQuote": true
|
||||
},
|
||||
// Settings for auto-closing of JSX tags.
|
||||
"jsx_tag_auto_close": {
|
||||
// // Whether to auto-close JSX tags.
|
||||
// "enabled": true
|
||||
},
|
||||
// LSP Specific settings.
|
||||
"lsp": {
|
||||
// Specify the LSP name as a key here.
|
||||
|
||||
21
crates/askpass/Cargo.toml
Normal file
21
crates/askpass/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "askpass"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/askpass.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
smol.workspace = true
|
||||
tempfile.workspace = true
|
||||
util.workspace = true
|
||||
which.workspace = true
|
||||
194
crates/askpass/src/askpass.rs
Normal file
194
crates/askpass/src/askpass.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(unix)]
|
||||
use anyhow::Context as _;
|
||||
use futures::channel::{mpsc, oneshot};
|
||||
#[cfg(unix)]
|
||||
use futures::{io::BufReader, AsyncBufReadExt as _};
|
||||
#[cfg(unix)]
|
||||
use futures::{select_biased, AsyncWriteExt as _, FutureExt as _};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use gpui::{AsyncApp, BackgroundExecutor, Task};
|
||||
#[cfg(unix)]
|
||||
use smol::fs;
|
||||
#[cfg(unix)]
|
||||
use smol::{fs::unix::PermissionsExt as _, net::unix::UnixListener};
|
||||
#[cfg(unix)]
|
||||
use util::ResultExt as _;
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum AskPassResult {
|
||||
CancelledByUser,
|
||||
Timedout,
|
||||
}
|
||||
|
||||
pub struct AskPassDelegate {
|
||||
tx: mpsc::UnboundedSender<(String, oneshot::Sender<String>)>,
|
||||
_task: Task<()>,
|
||||
}
|
||||
|
||||
impl AskPassDelegate {
|
||||
pub fn new(
|
||||
cx: &mut AsyncApp,
|
||||
password_prompt: impl Fn(String, oneshot::Sender<String>, &mut AsyncApp) + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
let (tx, mut rx) = mpsc::unbounded::<(String, oneshot::Sender<String>)>();
|
||||
let task = cx.spawn(|mut cx| async move {
|
||||
while let Some((prompt, channel)) = rx.next().await {
|
||||
password_prompt(prompt, channel, &mut cx);
|
||||
}
|
||||
});
|
||||
Self { tx, _task: task }
|
||||
}
|
||||
|
||||
pub async fn ask_password(&mut self, prompt: String) -> anyhow::Result<String> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.tx.send((prompt, tx)).await?;
|
||||
Ok(rx.await?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub struct AskPassSession {
|
||||
script_path: PathBuf,
|
||||
_askpass_task: Task<()>,
|
||||
askpass_opened_rx: Option<oneshot::Receiver<()>>,
|
||||
askpass_kill_master_rx: Option<oneshot::Receiver<()>>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl AskPassSession {
|
||||
/// This will create a new AskPassSession.
|
||||
/// You must retain this session until the master process exits.
|
||||
#[must_use]
|
||||
pub async fn new(
|
||||
executor: &BackgroundExecutor,
|
||||
mut delegate: AskPassDelegate,
|
||||
) -> anyhow::Result<Self> {
|
||||
let temp_dir = tempfile::Builder::new().prefix("zed-askpass").tempdir()?;
|
||||
let askpass_socket = temp_dir.path().join("askpass.sock");
|
||||
let askpass_script_path = temp_dir.path().join("askpass.sh");
|
||||
let (askpass_opened_tx, askpass_opened_rx) = oneshot::channel::<()>();
|
||||
let listener =
|
||||
UnixListener::bind(&askpass_socket).context("failed to create askpass socket")?;
|
||||
|
||||
let (askpass_kill_master_tx, askpass_kill_master_rx) = oneshot::channel::<()>();
|
||||
let mut kill_tx = Some(askpass_kill_master_tx);
|
||||
|
||||
let askpass_task = executor.spawn(async move {
|
||||
let mut askpass_opened_tx = Some(askpass_opened_tx);
|
||||
|
||||
while let Ok((mut stream, _)) = listener.accept().await {
|
||||
if let Some(askpass_opened_tx) = askpass_opened_tx.take() {
|
||||
askpass_opened_tx.send(()).ok();
|
||||
}
|
||||
let mut buffer = Vec::new();
|
||||
let mut reader = BufReader::new(&mut stream);
|
||||
if reader.read_until(b'\0', &mut buffer).await.is_err() {
|
||||
buffer.clear();
|
||||
}
|
||||
let prompt = String::from_utf8_lossy(&buffer);
|
||||
if let Some(password) = delegate
|
||||
.ask_password(prompt.to_string())
|
||||
.await
|
||||
.context("failed to get askpass password")
|
||||
.log_err()
|
||||
{
|
||||
stream.write_all(password.as_bytes()).await.log_err();
|
||||
} else {
|
||||
if let Some(kill_tx) = kill_tx.take() {
|
||||
kill_tx.send(()).log_err();
|
||||
}
|
||||
// note: we expect the caller to drop this task when it's done.
|
||||
// We need to keep the stream open until the caller is done to avoid
|
||||
// spurious errors from ssh.
|
||||
std::future::pending::<()>().await;
|
||||
drop(stream);
|
||||
}
|
||||
}
|
||||
drop(temp_dir)
|
||||
});
|
||||
|
||||
anyhow::ensure!(
|
||||
which::which("nc").is_ok(),
|
||||
"Cannot find `nc` command (netcat), which is required to connect over SSH."
|
||||
);
|
||||
|
||||
// Create an askpass script that communicates back to this process.
|
||||
let askpass_script = format!(
|
||||
"{shebang}\n{print_args} | {nc} -U {askpass_socket} 2> /dev/null \n",
|
||||
// on macOS `brew install netcat` provides the GNU netcat implementation
|
||||
// which does not support -U.
|
||||
nc = if cfg!(target_os = "macos") {
|
||||
"/usr/bin/nc"
|
||||
} else {
|
||||
"nc"
|
||||
},
|
||||
askpass_socket = askpass_socket.display(),
|
||||
print_args = "printf '%s\\0' \"$@\"",
|
||||
shebang = "#!/bin/sh",
|
||||
);
|
||||
fs::write(&askpass_script_path, askpass_script).await?;
|
||||
fs::set_permissions(&askpass_script_path, std::fs::Permissions::from_mode(0o755)).await?;
|
||||
|
||||
Ok(Self {
|
||||
script_path: askpass_script_path,
|
||||
_askpass_task: askpass_task,
|
||||
askpass_kill_master_rx: Some(askpass_kill_master_rx),
|
||||
askpass_opened_rx: Some(askpass_opened_rx),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn script_path(&self) -> &Path {
|
||||
&self.script_path
|
||||
}
|
||||
|
||||
// This will run the askpass task forever, resolving as many authentication requests as needed.
|
||||
// The caller is responsible for examining the result of their own commands and cancelling this
|
||||
// future when this is no longer needed. Note that this can only be called once, but due to the
|
||||
// drop order this takes an &mut, so you can `drop()` it after you're done with the master process.
|
||||
pub async fn run(&mut self) -> AskPassResult {
|
||||
let connection_timeout = Duration::from_secs(10);
|
||||
let askpass_opened_rx = self.askpass_opened_rx.take().expect("Only call run once");
|
||||
let askpass_kill_master_rx = self
|
||||
.askpass_kill_master_rx
|
||||
.take()
|
||||
.expect("Only call run once");
|
||||
|
||||
select_biased! {
|
||||
_ = askpass_opened_rx.fuse() => {
|
||||
// Note: this await can only resolve after we are dropped.
|
||||
askpass_kill_master_rx.await.ok();
|
||||
return AskPassResult::CancelledByUser
|
||||
}
|
||||
|
||||
_ = futures::FutureExt::fuse(smol::Timer::after(connection_timeout)) => {
|
||||
return AskPassResult::Timedout
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub struct AskPassSession {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
impl AskPassSession {
|
||||
pub async fn new(_: &BackgroundExecutor, _: AskPassDelegate) -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
path: PathBuf::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn script_path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub async fn run(&mut self) -> AskPassResult {
|
||||
futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(10))).await;
|
||||
AskPassResult::Timedout
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ impl ConfigurationView {
|
||||
.bg(cx.theme().colors().surface_background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.when(configuration_view.is_none(), |this| {
|
||||
this.child(div().child(Label::new(format!(
|
||||
"No configuration view for {}",
|
||||
|
||||
@@ -35,10 +35,10 @@ use language_model::{
|
||||
report_assistant_event, LanguageModel, LanguageModelRegistry, LanguageModelRequest,
|
||||
LanguageModelRequestMessage, LanguageModelTextStream, Role,
|
||||
};
|
||||
use language_model_selector::{InlineLanguageModelSelector, LanguageModelSelector};
|
||||
use language_model_selector::{LanguageModelSelector, LanguageModelSelectorPopoverMenu};
|
||||
use multi_buffer::MultiBufferRow;
|
||||
use parking_lot::Mutex;
|
||||
use project::{CodeAction, ProjectTransaction};
|
||||
use project::{ActionVariant, CodeAction, ProjectTransaction};
|
||||
use prompt_store::PromptBuilder;
|
||||
use rope::Rope;
|
||||
use settings::{update_settings_file, Settings, SettingsStore};
|
||||
@@ -1589,10 +1589,29 @@ impl Render for PromptEditor {
|
||||
.w(gutter_dimensions.full_width() + (gutter_dimensions.margin / 2.0))
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
InlineLanguageModelSelector::new(self.language_model_selector.clone())
|
||||
.render(window, cx),
|
||||
)
|
||||
.child(LanguageModelSelectorPopoverMenu::new(
|
||||
self.language_model_selector.clone(),
|
||||
IconButton::new("context", IconName::SettingsAlt)
|
||||
.shape(IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted),
|
||||
move |window, cx| {
|
||||
Tooltip::with_meta(
|
||||
format!(
|
||||
"Using {}",
|
||||
LanguageModelRegistry::read_global(cx)
|
||||
.active_model()
|
||||
.map(|model| model.name().0)
|
||||
.unwrap_or_else(|| "No model selected".into()),
|
||||
),
|
||||
None,
|
||||
"Change Model",
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
gpui::Corner::TopRight,
|
||||
))
|
||||
.map(|el| {
|
||||
let CodegenStatus::Error(error) = self.codegen.read(cx).status(cx) else {
|
||||
return el;
|
||||
@@ -3550,10 +3569,10 @@ impl CodeActionProvider for AssistantCodeActionProvider {
|
||||
Task::ready(Ok(vec![CodeAction {
|
||||
server_id: language::LanguageServerId(0),
|
||||
range: snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end),
|
||||
lsp_action: lsp::CodeAction {
|
||||
lsp_action: ActionVariant::Action(Box::new(lsp::CodeAction {
|
||||
title: "Fix with Assistant".into(),
|
||||
..Default::default()
|
||||
},
|
||||
})),
|
||||
}]))
|
||||
} else {
|
||||
Task::ready(Ok(Vec::new()))
|
||||
|
||||
@@ -19,7 +19,7 @@ use language_model::{
|
||||
report_assistant_event, LanguageModelRegistry, LanguageModelRequest,
|
||||
LanguageModelRequestMessage, Role,
|
||||
};
|
||||
use language_model_selector::{InlineLanguageModelSelector, LanguageModelSelector};
|
||||
use language_model_selector::{LanguageModelSelector, LanguageModelSelectorPopoverMenu};
|
||||
use prompt_store::PromptBuilder;
|
||||
use settings::{update_settings_file, Settings};
|
||||
use std::{
|
||||
@@ -506,7 +506,7 @@ struct PromptEditor {
|
||||
impl EventEmitter<PromptEditorEvent> for PromptEditor {}
|
||||
|
||||
impl Render for PromptEditor {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let status = &self.codegen.read(cx).status;
|
||||
let buttons = match status {
|
||||
CodegenStatus::Idle => {
|
||||
@@ -641,10 +641,29 @@ impl Render for PromptEditor {
|
||||
.w_12()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
InlineLanguageModelSelector::new(self.language_model_selector.clone())
|
||||
.render(window, cx),
|
||||
)
|
||||
.child(LanguageModelSelectorPopoverMenu::new(
|
||||
self.language_model_selector.clone(),
|
||||
IconButton::new("change-model", IconName::SettingsAlt)
|
||||
.shape(IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted),
|
||||
move |window, cx| {
|
||||
Tooltip::with_meta(
|
||||
format!(
|
||||
"Using {}",
|
||||
LanguageModelRegistry::read_global(cx)
|
||||
.active_model()
|
||||
.map(|model| model.name().0)
|
||||
.unwrap_or_else(|| "No model selected".into()),
|
||||
),
|
||||
None,
|
||||
"Change Model",
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
gpui::Corner::TopRight,
|
||||
))
|
||||
.children(
|
||||
if let CodegenStatus::Error(error) = &self.codegen.read(cx).status {
|
||||
let error_message = SharedString::from(error.to_string());
|
||||
|
||||
@@ -4,9 +4,9 @@ use assistant_tool::ToolWorkingSet;
|
||||
use collections::HashMap;
|
||||
use editor::{Editor, MultiBuffer};
|
||||
use gpui::{
|
||||
list, AbsoluteLength, AnyElement, App, DefiniteLength, EdgesRefinement, Empty, Entity,
|
||||
Focusable, Length, ListAlignment, ListOffset, ListState, StyleRefinement, Subscription,
|
||||
TextStyleRefinement, UnderlineStyle, WeakEntity,
|
||||
list, AbsoluteLength, AnyElement, App, ClickEvent, DefiniteLength, EdgesRefinement, Empty,
|
||||
Entity, Focusable, Length, ListAlignment, ListOffset, ListState, StyleRefinement, Subscription,
|
||||
Task, TextStyleRefinement, UnderlineStyle, WeakEntity,
|
||||
};
|
||||
use language::{Buffer, LanguageRegistry};
|
||||
use language_model::{LanguageModelRegistry, LanguageModelToolUseId, Role};
|
||||
@@ -14,6 +14,7 @@ use markdown::{Markdown, MarkdownStyle};
|
||||
use settings::Settings as _;
|
||||
use theme::ThemeSettings;
|
||||
use ui::{prelude::*, Disclosure, KeyBinding};
|
||||
use util::ResultExt as _;
|
||||
use workspace::Workspace;
|
||||
|
||||
use crate::thread::{MessageId, RequestKind, Thread, ThreadError, ThreadEvent};
|
||||
@@ -27,6 +28,7 @@ pub struct ActiveThread {
|
||||
tools: Arc<ToolWorkingSet>,
|
||||
thread_store: Entity<ThreadStore>,
|
||||
thread: Entity<Thread>,
|
||||
save_thread_task: Option<Task<()>>,
|
||||
messages: Vec<MessageId>,
|
||||
list_state: ListState,
|
||||
rendered_messages_by_id: HashMap<MessageId, Entity<Markdown>>,
|
||||
@@ -61,6 +63,7 @@ impl ActiveThread {
|
||||
tools,
|
||||
thread_store,
|
||||
thread: thread.clone(),
|
||||
save_thread_task: None,
|
||||
messages: Vec::new(),
|
||||
rendered_messages_by_id: HashMap::default(),
|
||||
expanded_tool_uses: HashMap::default(),
|
||||
@@ -250,11 +253,7 @@ impl ActiveThread {
|
||||
self.last_error = Some(error.clone());
|
||||
}
|
||||
ThreadEvent::StreamedCompletion | ThreadEvent::SummaryChanged => {
|
||||
self.thread_store
|
||||
.update(cx, |thread_store, cx| {
|
||||
thread_store.save_thread(&self.thread, cx)
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
self.save_thread(cx);
|
||||
}
|
||||
ThreadEvent::StreamedAssistantText(message_id, text) => {
|
||||
if let Some(markdown) = self.rendered_messages_by_id.get_mut(&message_id) {
|
||||
@@ -273,12 +272,7 @@ impl ActiveThread {
|
||||
self.push_message(message_id, message_text, window, cx);
|
||||
}
|
||||
|
||||
self.thread_store
|
||||
.update(cx, |thread_store, cx| {
|
||||
thread_store.save_thread(&self.thread, cx)
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
self.save_thread(cx);
|
||||
cx.notify();
|
||||
}
|
||||
ThreadEvent::MessageEdited(message_id) => {
|
||||
@@ -291,23 +285,12 @@ impl ActiveThread {
|
||||
self.edited_message(message_id, message_text, window, cx);
|
||||
}
|
||||
|
||||
self.thread_store
|
||||
.update(cx, |thread_store, cx| {
|
||||
thread_store.save_thread(&self.thread, cx)
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
self.save_thread(cx);
|
||||
cx.notify();
|
||||
}
|
||||
ThreadEvent::MessageDeleted(message_id) => {
|
||||
self.deleted_message(message_id);
|
||||
|
||||
self.thread_store
|
||||
.update(cx, |thread_store, cx| {
|
||||
thread_store.save_thread(&self.thread, cx)
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
self.save_thread(cx);
|
||||
cx.notify();
|
||||
}
|
||||
ThreadEvent::UsePendingTools => {
|
||||
@@ -358,6 +341,25 @@ impl ActiveThread {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a task to save the active thread.
|
||||
///
|
||||
/// Only one task to save the thread will be in flight at a time.
|
||||
fn save_thread(&mut self, cx: &mut Context<Self>) {
|
||||
let thread = self.thread.clone();
|
||||
self.save_thread_task = Some(cx.spawn(|this, mut cx| async move {
|
||||
let task = this
|
||||
.update(&mut cx, |this, cx| {
|
||||
this.thread_store
|
||||
.update(cx, |thread_store, cx| thread_store.save_thread(&thread, cx))
|
||||
})
|
||||
.ok();
|
||||
|
||||
if let Some(task) = task {
|
||||
task.await.log_err();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn start_editing_message(
|
||||
&mut self,
|
||||
message_id: MessageId,
|
||||
@@ -452,6 +454,19 @@ impl ActiveThread {
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn handle_cancel_click(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.cancel_editing_message(&menu::Cancel, window, cx);
|
||||
}
|
||||
|
||||
fn handle_regenerate_click(
|
||||
&mut self,
|
||||
_: &ClickEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.confirm_editing_message(&menu::Confirm, window, cx);
|
||||
}
|
||||
|
||||
fn render_message(&self, ix: usize, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let message_id = self.messages[ix];
|
||||
let Some(message) = self.thread.read(cx).message(message_id) else {
|
||||
@@ -522,7 +537,8 @@ impl ActiveThread {
|
||||
.child(
|
||||
h_flex()
|
||||
.py_1()
|
||||
.px_2()
|
||||
.pl_2()
|
||||
.pr_1()
|
||||
.bg(colors.editor_foreground.opacity(0.05))
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
@@ -551,24 +567,38 @@ impl ActiveThread {
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("cancel-edit-message", "Cancel")
|
||||
.key_binding(KeyBinding::for_action_in(
|
||||
&menu::Cancel,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)),
|
||||
.label_size(LabelSize::Small)
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(
|
||||
&menu::Cancel,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
)
|
||||
.on_click(
|
||||
cx.listener(Self::handle_cancel_click),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new(
|
||||
"confirm-edit-message",
|
||||
"Regenerate",
|
||||
)
|
||||
.key_binding(KeyBinding::for_action_in(
|
||||
&menu::Confirm,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)),
|
||||
.label_size(LabelSize::Small)
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(
|
||||
&menu::Confirm,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
)
|
||||
.on_click(
|
||||
cx.listener(Self::handle_regenerate_click),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
@@ -576,19 +606,21 @@ impl ActiveThread {
|
||||
.when(
|
||||
edit_message_editor.is_none() && allow_editing_message,
|
||||
|this| {
|
||||
this.child(Button::new("edit-message", "Edit").on_click(
|
||||
cx.listener({
|
||||
let message_text = message.text.clone();
|
||||
move |this, _, window, cx| {
|
||||
this.start_editing_message(
|
||||
message_id,
|
||||
message_text.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}),
|
||||
))
|
||||
this.child(
|
||||
Button::new("edit-message", "Edit")
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener({
|
||||
let message_text = message.text.clone();
|
||||
move |this, _, window, cx| {
|
||||
this.start_editing_message(
|
||||
message_id,
|
||||
message_text.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -613,7 +645,7 @@ impl ActiveThread {
|
||||
Role::System => div().id(("message-container", ix)).py_1().px_2().child(
|
||||
v_flex()
|
||||
.bg(colors.editor_background)
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.child(message_content),
|
||||
),
|
||||
};
|
||||
@@ -642,7 +674,7 @@ impl ActiveThread {
|
||||
.pr_2()
|
||||
.bg(cx.theme().colors().editor_foreground.opacity(0.02))
|
||||
.when(is_open, |element| element.border_b_1().rounded_t(px(6.)))
|
||||
.when(!is_open, |element| element.rounded(px(6.)))
|
||||
.when(!is_open, |element| element.rounded_md())
|
||||
.border_color(cx.theme().colors().border)
|
||||
.child(
|
||||
h_flex()
|
||||
|
||||
@@ -134,7 +134,7 @@ impl AssistantConfiguration {
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.map(|parent| match configuration_view {
|
||||
Some(configuration_view) => parent.child(configuration_view),
|
||||
None => parent.child(div().child(Label::new(format!(
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
use assistant_settings::AssistantSettings;
|
||||
use fs::Fs;
|
||||
use gpui::{Entity, FocusHandle};
|
||||
use language_model_selector::{AssistantLanguageModelSelector, LanguageModelSelector};
|
||||
use gpui::{Entity, FocusHandle, SharedString};
|
||||
use language_model::LanguageModelRegistry;
|
||||
use language_model_selector::{
|
||||
LanguageModelSelector, LanguageModelSelectorPopoverMenu, ToggleModelSelector,
|
||||
};
|
||||
use settings::update_settings_file;
|
||||
use std::sync::Arc;
|
||||
use ui::prelude::*;
|
||||
use ui::{prelude::*, ButtonLike, PopoverMenuHandle, Tooltip};
|
||||
|
||||
pub struct AssistantModelSelector {
|
||||
pub selector: Entity<LanguageModelSelector>,
|
||||
selector: Entity<LanguageModelSelector>,
|
||||
menu_handle: PopoverMenuHandle<LanguageModelSelector>,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl AssistantModelSelector {
|
||||
pub(crate) fn new(
|
||||
fs: Arc<dyn Fs>,
|
||||
menu_handle: PopoverMenuHandle<LanguageModelSelector>,
|
||||
focus_handle: FocusHandle,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
@@ -33,14 +38,54 @@ impl AssistantModelSelector {
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
menu_handle,
|
||||
focus_handle,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.menu_handle.toggle(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AssistantModelSelector {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
AssistantLanguageModelSelector::new(self.focus_handle.clone(), self.selector.clone())
|
||||
.render(window, cx)
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let active_model = LanguageModelRegistry::read_global(cx).active_model();
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
let model_name = match active_model {
|
||||
Some(model) => model.name().0,
|
||||
_ => SharedString::from("No model selected"),
|
||||
};
|
||||
|
||||
LanguageModelSelectorPopoverMenu::new(
|
||||
self.selector.clone(),
|
||||
ButtonLike::new("active-model")
|
||||
.style(ButtonStyle::Subtle)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
Label::new(model_name)
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(
|
||||
Icon::new(IconName::ChevronDown)
|
||||
.color(Color::Muted)
|
||||
.size(IconSize::XSmall),
|
||||
),
|
||||
),
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Change Model",
|
||||
&ToggleModelSelector,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
gpui::Corner::BottomRight,
|
||||
)
|
||||
.with_handle(self.menu_handle.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1023,12 +1023,7 @@ impl Render for AssistantPanel {
|
||||
.map(|parent| match self.active_view {
|
||||
ActiveView::Thread => parent
|
||||
.child(self.render_active_thread_or_empty_state(window, cx))
|
||||
.child(
|
||||
h_flex()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.child(self.message_editor.clone()),
|
||||
)
|
||||
.child(h_flex().child(self.message_editor.clone()))
|
||||
.children(self.render_last_error(cx)),
|
||||
ActiveView::History => parent.child(self.history.clone()),
|
||||
ActiveView::PromptEditor => parent.children(self.context_editor.clone()),
|
||||
|
||||
@@ -208,12 +208,18 @@ impl ContextStore {
|
||||
let mut text_tasks = Vec::new();
|
||||
this.update(&mut cx, |_, cx| {
|
||||
for (path, buffer_entity) in files.into_iter().zip(buffers) {
|
||||
let buffer_entity = buffer_entity?;
|
||||
let buffer = buffer_entity.read(cx);
|
||||
let (buffer_info, text_task) =
|
||||
collect_buffer_info_and_text(path, buffer_entity, buffer, cx.to_async());
|
||||
buffer_infos.push(buffer_info);
|
||||
text_tasks.push(text_task);
|
||||
// Skip all binary files and other non-UTF8 files
|
||||
if let Ok(buffer_entity) = buffer_entity {
|
||||
let buffer = buffer_entity.read(cx);
|
||||
let (buffer_info, text_task) = collect_buffer_info_and_text(
|
||||
path,
|
||||
buffer_entity,
|
||||
buffer,
|
||||
cx.to_async(),
|
||||
);
|
||||
buffer_infos.push(buffer_info);
|
||||
text_tasks.push(text_task);
|
||||
}
|
||||
}
|
||||
anyhow::Ok(())
|
||||
})??;
|
||||
|
||||
@@ -27,6 +27,7 @@ use language::{Buffer, Point, Selection, TransactionId};
|
||||
use language_model::{report_assistant_event, LanguageModelRegistry};
|
||||
use multi_buffer::MultiBufferRow;
|
||||
use parking_lot::Mutex;
|
||||
use project::ActionVariant;
|
||||
use project::{CodeAction, ProjectTransaction};
|
||||
use prompt_store::PromptBuilder;
|
||||
use settings::{Settings, SettingsStore};
|
||||
@@ -1727,10 +1728,10 @@ impl CodeActionProvider for AssistantCodeActionProvider {
|
||||
Task::ready(Ok(vec![CodeAction {
|
||||
server_id: language::LanguageServerId(0),
|
||||
range: snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end),
|
||||
lsp_action: lsp::CodeAction {
|
||||
lsp_action: ActionVariant::Action(Box::new(lsp::CodeAction {
|
||||
title: "Fix with Assistant".into(),
|
||||
..Default::default()
|
||||
},
|
||||
})),
|
||||
}]))
|
||||
} else {
|
||||
Task::ready(Ok(Vec::new()))
|
||||
|
||||
@@ -20,6 +20,7 @@ use gpui::{
|
||||
EventEmitter, FocusHandle, Focusable, FontWeight, Subscription, TextStyle, WeakEntity, Window,
|
||||
};
|
||||
use language_model::{LanguageModel, LanguageModelRegistry};
|
||||
use language_model_selector::ToggleModelSelector;
|
||||
use parking_lot::Mutex;
|
||||
use settings::Settings;
|
||||
use std::cmp;
|
||||
@@ -102,11 +103,9 @@ impl<T: 'static> Render for PromptEditor<T> {
|
||||
.items_start()
|
||||
.cursor(CursorStyle::Arrow)
|
||||
.on_action(cx.listener(Self::toggle_context_picker))
|
||||
.on_action(cx.listener(|this, action, window, cx| {
|
||||
let selector = this.model_selector.read(cx).selector.clone();
|
||||
selector.update(cx, |selector, cx| {
|
||||
selector.toggle_model_selector(action, window, cx);
|
||||
})
|
||||
.on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
|
||||
this.model_selector
|
||||
.update(cx, |model_selector, cx| model_selector.toggle(window, cx));
|
||||
}))
|
||||
.on_action(cx.listener(Self::confirm))
|
||||
.on_action(cx.listener(Self::cancel))
|
||||
@@ -858,6 +857,7 @@ impl PromptEditor<BufferCodegen> {
|
||||
editor
|
||||
});
|
||||
let context_picker_menu_handle = PopoverMenuHandle::default();
|
||||
let model_selector_menu_handle = PopoverMenuHandle::default();
|
||||
|
||||
let context_strip = cx.new(|cx| {
|
||||
ContextStrip::new(
|
||||
@@ -881,7 +881,13 @@ impl PromptEditor<BufferCodegen> {
|
||||
context_strip,
|
||||
context_picker_menu_handle,
|
||||
model_selector: cx.new(|cx| {
|
||||
AssistantModelSelector::new(fs, prompt_editor.focus_handle(cx), window, cx)
|
||||
AssistantModelSelector::new(
|
||||
fs,
|
||||
model_selector_menu_handle,
|
||||
prompt_editor.focus_handle(cx),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
edited_since_done: false,
|
||||
prompt_history,
|
||||
@@ -1006,6 +1012,7 @@ impl PromptEditor<TerminalCodegen> {
|
||||
editor
|
||||
});
|
||||
let context_picker_menu_handle = PopoverMenuHandle::default();
|
||||
let model_selector_menu_handle = PopoverMenuHandle::default();
|
||||
|
||||
let context_strip = cx.new(|cx| {
|
||||
ContextStrip::new(
|
||||
@@ -1029,7 +1036,13 @@ impl PromptEditor<TerminalCodegen> {
|
||||
context_strip,
|
||||
context_picker_menu_handle,
|
||||
model_selector: cx.new(|cx| {
|
||||
AssistantModelSelector::new(fs, prompt_editor.focus_handle(cx), window, cx)
|
||||
AssistantModelSelector::new(
|
||||
fs,
|
||||
model_selector_menu_handle.clone(),
|
||||
prompt_editor.focus_handle(cx),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
edited_since_done: false,
|
||||
prompt_history,
|
||||
|
||||
@@ -4,10 +4,11 @@ use editor::actions::MoveUp;
|
||||
use editor::{Editor, EditorElement, EditorEvent, EditorStyle};
|
||||
use fs::Fs;
|
||||
use gpui::{
|
||||
pulsating_between, Animation, AnimationExt, App, DismissEvent, Entity, Focusable, Subscription,
|
||||
TextStyle, WeakEntity,
|
||||
Animation, AnimationExt, App, DismissEvent, Entity, Focusable, Subscription, TextStyle,
|
||||
WeakEntity,
|
||||
};
|
||||
use language_model::LanguageModelRegistry;
|
||||
use language_model_selector::ToggleModelSelector;
|
||||
use rope::Point;
|
||||
use settings::Settings;
|
||||
use std::time::Duration;
|
||||
@@ -15,7 +16,7 @@ use text::Bias;
|
||||
use theme::ThemeSettings;
|
||||
use ui::{
|
||||
prelude::*, ButtonLike, KeyBinding, PlatformStyle, PopoverMenu, PopoverMenuHandle, Switch,
|
||||
TintColor, Tooltip,
|
||||
Tooltip,
|
||||
};
|
||||
use vim_mode_setting::VimModeSetting;
|
||||
use workspace::Workspace;
|
||||
@@ -53,6 +54,7 @@ impl MessageEditor {
|
||||
let context_store = cx.new(|_cx| ContextStore::new(workspace.clone()));
|
||||
let context_picker_menu_handle = PopoverMenuHandle::default();
|
||||
let inline_context_picker_menu_handle = PopoverMenuHandle::default();
|
||||
let model_selector_menu_handle = PopoverMenuHandle::default();
|
||||
|
||||
let editor = cx.new(|cx| {
|
||||
let mut editor = Editor::auto_height(10, window, cx);
|
||||
@@ -105,8 +107,15 @@ impl MessageEditor {
|
||||
context_picker_menu_handle,
|
||||
inline_context_picker,
|
||||
inline_context_picker_menu_handle,
|
||||
model_selector: cx
|
||||
.new(|cx| AssistantModelSelector::new(fs, editor.focus_handle(cx), window, cx)),
|
||||
model_selector: cx.new(|cx| {
|
||||
AssistantModelSelector::new(
|
||||
fs,
|
||||
model_selector_menu_handle,
|
||||
editor.focus_handle(cx),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
use_tools: false,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -289,168 +298,210 @@ impl Render for MessageEditor {
|
||||
let linux = platform == PlatformStyle::Linux;
|
||||
let windows = platform == PlatformStyle::Windows;
|
||||
let button_width = if linux || windows || vim_mode_enabled {
|
||||
px(92.)
|
||||
px(82.)
|
||||
} else {
|
||||
px(64.)
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.key_context("MessageEditor")
|
||||
.on_action(cx.listener(Self::chat))
|
||||
.on_action(cx.listener(|this, action, window, cx| {
|
||||
let selector = this.model_selector.read(cx).selector.clone();
|
||||
selector.update(cx, |this, cx| {
|
||||
this.toggle_model_selector(action, window, cx);
|
||||
})
|
||||
}))
|
||||
.on_action(cx.listener(Self::toggle_context_picker))
|
||||
.on_action(cx.listener(Self::remove_all_context))
|
||||
.on_action(cx.listener(Self::move_up))
|
||||
.on_action(cx.listener(Self::toggle_chat_mode))
|
||||
.size_full()
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.bg(bg_color)
|
||||
.child(self.context_strip.clone())
|
||||
.when(is_streaming_completion, |parent| {
|
||||
let focus_handle = self.editor.focus_handle(cx).clone();
|
||||
parent.child(
|
||||
h_flex().py_3().w_full().justify_center().child(
|
||||
h_flex()
|
||||
.flex_none()
|
||||
.pl_2()
|
||||
.pr_1()
|
||||
.py_1()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.rounded_lg()
|
||||
.shadow_md()
|
||||
.gap_1()
|
||||
.child(
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Muted)
|
||||
.with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| {
|
||||
icon.transform(gpui::Transformation::rotate(
|
||||
gpui::percentage(delta),
|
||||
))
|
||||
},
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Label::new("Generating…")
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(ui::Divider::vertical())
|
||||
.child(
|
||||
Button::new("cancel-generation", "Cancel")
|
||||
.label_size(LabelSize::XSmall)
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(
|
||||
&editor::actions::Cancel,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.map(|kb| kb.size(rems_from_px(10.))),
|
||||
)
|
||||
.on_click(move |_event, window, cx| {
|
||||
focus_handle.dispatch_action(
|
||||
&editor::actions::Cancel,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_5()
|
||||
.child({
|
||||
let settings = ThemeSettings::get_global(cx);
|
||||
let text_style = TextStyle {
|
||||
color: cx.theme().colors().text,
|
||||
font_family: settings.ui_font.family.clone(),
|
||||
font_features: settings.ui_font.features.clone(),
|
||||
font_size: font_size.into(),
|
||||
font_weight: settings.ui_font.weight,
|
||||
line_height: line_height.into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
EditorElement::new(
|
||||
&self.editor,
|
||||
EditorStyle {
|
||||
background: bg_color,
|
||||
local_player: cx.theme().players().local(),
|
||||
text: text_style,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
})
|
||||
.key_context("MessageEditor")
|
||||
.on_action(cx.listener(Self::chat))
|
||||
.on_action(cx.listener(|this, _: &ToggleModelSelector, window, cx| {
|
||||
this.model_selector
|
||||
.update(cx, |model_selector, cx| model_selector.toggle(window, cx));
|
||||
}))
|
||||
.on_action(cx.listener(Self::toggle_context_picker))
|
||||
.on_action(cx.listener(Self::remove_all_context))
|
||||
.on_action(cx.listener(Self::move_up))
|
||||
.on_action(cx.listener(Self::toggle_chat_mode))
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.bg(bg_color)
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.child(self.context_strip.clone())
|
||||
.child(
|
||||
PopoverMenu::new("inline-context-picker")
|
||||
.menu(move |window, cx| {
|
||||
inline_context_picker.update(cx, |this, cx| {
|
||||
this.init(window, cx);
|
||||
});
|
||||
v_flex()
|
||||
.gap_5()
|
||||
.child({
|
||||
let settings = ThemeSettings::get_global(cx);
|
||||
let text_style = TextStyle {
|
||||
color: cx.theme().colors().text,
|
||||
font_family: settings.ui_font.family.clone(),
|
||||
font_features: settings.ui_font.features.clone(),
|
||||
font_size: font_size.into(),
|
||||
font_weight: settings.ui_font.weight,
|
||||
line_height: line_height.into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Some(inline_context_picker.clone())
|
||||
EditorElement::new(
|
||||
&self.editor,
|
||||
EditorStyle {
|
||||
background: bg_color,
|
||||
local_player: cx.theme().players().local(),
|
||||
text: text_style,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
})
|
||||
.attach(gpui::Corner::TopLeft)
|
||||
.anchor(gpui::Corner::BottomLeft)
|
||||
.offset(gpui::Point {
|
||||
x: px(0.0),
|
||||
y: (-ThemeSettings::get_global(cx).ui_font_size(cx) * 2) - px(4.0),
|
||||
})
|
||||
.with_handle(self.inline_context_picker_menu_handle.clone()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
Switch::new("use-tools", self.use_tools.into())
|
||||
.label("Tools")
|
||||
.on_click(cx.listener(|this, selection, _window, _cx| {
|
||||
this.use_tools = match selection {
|
||||
ToggleState::Selected => true,
|
||||
ToggleState::Unselected
|
||||
| ToggleState::Indeterminate => false,
|
||||
};
|
||||
}))
|
||||
.key_binding(KeyBinding::for_action_in(
|
||||
&ChatMode,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)),
|
||||
PopoverMenu::new("inline-context-picker")
|
||||
.menu(move |window, cx| {
|
||||
inline_context_picker.update(cx, |this, cx| {
|
||||
this.init(window, cx);
|
||||
});
|
||||
|
||||
Some(inline_context_picker.clone())
|
||||
})
|
||||
.attach(gpui::Corner::TopLeft)
|
||||
.anchor(gpui::Corner::BottomLeft)
|
||||
.offset(gpui::Point {
|
||||
x: px(0.0),
|
||||
y: (-ThemeSettings::get_global(cx).ui_font_size(cx) * 2)
|
||||
- px(4.0),
|
||||
})
|
||||
.with_handle(self.inline_context_picker_menu_handle.clone()),
|
||||
)
|
||||
.child(h_flex().gap_1().child(self.model_selector.clone()).child(
|
||||
if is_streaming_completion {
|
||||
ButtonLike::new("cancel-generation")
|
||||
.width(button_width.into())
|
||||
.style(ButtonStyle::Tinted(TintColor::Accent))
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.child(
|
||||
Label::new("Cancel")
|
||||
.size(LabelSize::Small)
|
||||
.with_animation(
|
||||
"pulsating-label",
|
||||
Animation::new(Duration::from_secs(2))
|
||||
.repeat()
|
||||
.with_easing(pulsating_between(
|
||||
0.4, 0.8,
|
||||
)),
|
||||
|label, delta| label.alpha(delta),
|
||||
),
|
||||
)
|
||||
.children(
|
||||
KeyBinding::for_action_in(
|
||||
&editor::actions::Cancel,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.map(|binding| binding.into_any_element()),
|
||||
),
|
||||
)
|
||||
.on_click(move |_event, window, cx| {
|
||||
focus_handle.dispatch_action(
|
||||
&editor::actions::Cancel,
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
Switch::new("use-tools", self.use_tools.into())
|
||||
.label("Tools")
|
||||
.on_click(cx.listener(
|
||||
|this, selection, _window, _cx| {
|
||||
this.use_tools = match selection {
|
||||
ToggleState::Selected => true,
|
||||
ToggleState::Unselected
|
||||
| ToggleState::Indeterminate => false,
|
||||
};
|
||||
},
|
||||
))
|
||||
.key_binding(KeyBinding::for_action_in(
|
||||
&ChatMode,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
} else {
|
||||
ButtonLike::new("submit-message")
|
||||
.width(button_width.into())
|
||||
.style(ButtonStyle::Filled)
|
||||
.disabled(is_editor_empty || !is_model_selected)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.child(
|
||||
Label::new("Submit")
|
||||
.size(LabelSize::Small)
|
||||
.color(submit_label_color),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
h_flex().gap_1().child(self.model_selector.clone()).child(
|
||||
ButtonLike::new("submit-message")
|
||||
.width(button_width.into())
|
||||
.style(ButtonStyle::Filled)
|
||||
.disabled(
|
||||
is_editor_empty
|
||||
|| !is_model_selected
|
||||
|| is_streaming_completion,
|
||||
)
|
||||
.children(
|
||||
KeyBinding::for_action_in(
|
||||
&Chat,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.map(|binding| binding.into_any_element()),
|
||||
),
|
||||
)
|
||||
.on_click(move |_event, window, cx| {
|
||||
focus_handle.dispatch_action(&Chat, window, cx);
|
||||
})
|
||||
.when(is_editor_empty, |button| {
|
||||
button
|
||||
.tooltip(Tooltip::text("Type a message to submit"))
|
||||
})
|
||||
.when(!is_model_selected, |button| {
|
||||
button.tooltip(Tooltip::text(
|
||||
"Select a model to continue",
|
||||
))
|
||||
})
|
||||
},
|
||||
)),
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.child(
|
||||
Label::new("Submit")
|
||||
.size(LabelSize::Small)
|
||||
.color(submit_label_color),
|
||||
)
|
||||
.children(
|
||||
KeyBinding::for_action_in(
|
||||
&Chat,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.map(|binding| {
|
||||
binding
|
||||
.when(vim_mode_enabled, |kb| {
|
||||
kb.size(rems_from_px(12.))
|
||||
})
|
||||
.into_any_element()
|
||||
}),
|
||||
),
|
||||
)
|
||||
.on_click(move |_event, window, cx| {
|
||||
focus_handle.dispatch_action(&Chat, window, cx);
|
||||
})
|
||||
.when(is_editor_empty, |button| {
|
||||
button.tooltip(Tooltip::text(
|
||||
"Type a message to submit",
|
||||
))
|
||||
})
|
||||
.when(is_streaming_completion, |button| {
|
||||
button.tooltip(Tooltip::text(
|
||||
"Cancel to submit a new message",
|
||||
))
|
||||
})
|
||||
.when(!is_model_selected, |button| {
|
||||
button.tooltip(Tooltip::text(
|
||||
"Select a model to continue",
|
||||
))
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ impl RenderOnce for ContextPill {
|
||||
.pl_1()
|
||||
.pb(px(1.))
|
||||
.border_1()
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.gap_1()
|
||||
.child(self.icon().size(IconSize::XSmall).color(Color::Muted));
|
||||
|
||||
|
||||
@@ -37,7 +37,9 @@ use language_model::{
|
||||
LanguageModelImage, LanguageModelProvider, LanguageModelProviderTosView, LanguageModelRegistry,
|
||||
Role,
|
||||
};
|
||||
use language_model_selector::{AssistantLanguageModelSelector, LanguageModelSelector};
|
||||
use language_model_selector::{
|
||||
LanguageModelSelector, LanguageModelSelectorPopoverMenu, ToggleModelSelector,
|
||||
};
|
||||
use multi_buffer::MultiBufferRow;
|
||||
use picker::Picker;
|
||||
use project::lsp_store::LocalLspAdapterDelegate;
|
||||
@@ -52,7 +54,7 @@ use ui::{
|
||||
Tooltip,
|
||||
};
|
||||
use util::{maybe, ResultExt};
|
||||
use workspace::searchable::SearchableItemHandle;
|
||||
use workspace::searchable::{Direction, SearchableItemHandle};
|
||||
use workspace::{
|
||||
item::{self, FollowableItem, Item, ItemHandle},
|
||||
notifications::NotificationId,
|
||||
@@ -196,6 +198,7 @@ pub struct ContextEditor {
|
||||
// context editor, we keep a reference here.
|
||||
dragged_file_worktrees: Vec<Entity<Worktree>>,
|
||||
language_model_selector: Entity<LanguageModelSelector>,
|
||||
language_model_selector_menu_handle: PopoverMenuHandle<LanguageModelSelector>,
|
||||
}
|
||||
|
||||
pub const DEFAULT_TAB_TITLE: &str = "New Chat";
|
||||
@@ -249,21 +252,6 @@ impl ContextEditor {
|
||||
cx.observe_global_in::<SettingsStore>(window, Self::settings_changed),
|
||||
];
|
||||
|
||||
let fs_clone = fs.clone();
|
||||
let language_model_selector = cx.new(|cx| {
|
||||
LanguageModelSelector::new(
|
||||
move |model, cx| {
|
||||
update_settings_file::<AssistantSettings>(
|
||||
fs_clone.clone(),
|
||||
cx,
|
||||
move |settings, _| settings.set_model(model.clone()),
|
||||
);
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let sections = context.read(cx).slash_command_output_sections().to_vec();
|
||||
let patch_ranges = context.read(cx).patch_ranges().collect::<Vec<_>>();
|
||||
let slash_commands = context.read(cx).slash_commands().clone();
|
||||
@@ -276,7 +264,7 @@ impl ContextEditor {
|
||||
image_blocks: Default::default(),
|
||||
scroll_position: None,
|
||||
remote_id: None,
|
||||
fs,
|
||||
fs: fs.clone(),
|
||||
workspace,
|
||||
project,
|
||||
pending_slash_command_creases: HashMap::default(),
|
||||
@@ -288,7 +276,20 @@ impl ContextEditor {
|
||||
show_accept_terms: false,
|
||||
slash_menu_handle: Default::default(),
|
||||
dragged_file_worktrees: Vec::new(),
|
||||
language_model_selector,
|
||||
language_model_selector: cx.new(|cx| {
|
||||
LanguageModelSelector::new(
|
||||
move |model, cx| {
|
||||
update_settings_file::<AssistantSettings>(
|
||||
fs.clone(),
|
||||
cx,
|
||||
move |settings, _| settings.set_model(model.clone()),
|
||||
);
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
language_model_selector_menu_handle: PopoverMenuHandle::default(),
|
||||
};
|
||||
this.update_message_headers(cx);
|
||||
this.update_image_blocks(cx);
|
||||
@@ -1240,7 +1241,7 @@ impl ContextEditor {
|
||||
.child("Press")
|
||||
.child(
|
||||
h_flex()
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.px_1()
|
||||
.mr_0p5()
|
||||
.border_1()
|
||||
@@ -2091,7 +2092,7 @@ impl ContextEditor {
|
||||
.ml(gutter_width)
|
||||
.pb_1()
|
||||
.w(max_width - gutter_width)
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.border_1()
|
||||
.border_color(theme.colors().border_variant)
|
||||
.overflow_hidden()
|
||||
@@ -2388,6 +2389,46 @@ impl ContextEditor {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_language_model_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let active_model = LanguageModelRegistry::read_global(cx).active_model();
|
||||
let focus_handle = self.editor().focus_handle(cx).clone();
|
||||
let model_name = match active_model {
|
||||
Some(model) => model.name().0,
|
||||
None => SharedString::from("No model selected"),
|
||||
};
|
||||
|
||||
LanguageModelSelectorPopoverMenu::new(
|
||||
self.language_model_selector.clone(),
|
||||
ButtonLike::new("active-model")
|
||||
.style(ButtonStyle::Subtle)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
Label::new(model_name)
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(
|
||||
Icon::new(IconName::ChevronDown)
|
||||
.color(Color::Muted)
|
||||
.size(IconSize::XSmall),
|
||||
),
|
||||
),
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Change Model",
|
||||
&ToggleModelSelector,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
gpui::Corner::BottomLeft,
|
||||
)
|
||||
.with_handle(self.language_model_selector_menu_handle.clone())
|
||||
}
|
||||
|
||||
fn render_last_error(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let last_error = self.last_error.as_ref()?;
|
||||
|
||||
@@ -2832,7 +2873,7 @@ impl Render for ContextEditor {
|
||||
None
|
||||
};
|
||||
|
||||
let language_model_selector = self.language_model_selector.clone();
|
||||
let language_model_selector = self.language_model_selector_menu_handle.clone();
|
||||
v_flex()
|
||||
.key_context("ContextEditor")
|
||||
.capture_action(cx.listener(ContextEditor::cancel))
|
||||
@@ -2845,10 +2886,8 @@ impl Render for ContextEditor {
|
||||
.on_action(cx.listener(ContextEditor::edit))
|
||||
.on_action(cx.listener(ContextEditor::assist))
|
||||
.on_action(cx.listener(ContextEditor::split))
|
||||
.on_action(move |action, window, cx| {
|
||||
language_model_selector.update(cx, |this, cx| {
|
||||
this.toggle_model_selector(action, window, cx);
|
||||
})
|
||||
.on_action(move |_: &ToggleModelSelector, window, cx| {
|
||||
language_model_selector.toggle(window, cx);
|
||||
})
|
||||
.size_full()
|
||||
.children(self.render_notice(cx))
|
||||
@@ -2887,14 +2926,11 @@ impl Render for ContextEditor {
|
||||
.gap_1()
|
||||
.child(self.render_inject_context_menu(cx))
|
||||
.child(ui::Divider::vertical())
|
||||
.child(div().pl_0p5().child({
|
||||
let focus_handle = self.editor().focus_handle(cx).clone();
|
||||
AssistantLanguageModelSelector::new(
|
||||
focus_handle,
|
||||
self.language_model_selector.clone(),
|
||||
)
|
||||
.render(window, cx)
|
||||
})),
|
||||
.child(
|
||||
div()
|
||||
.pl_0p5()
|
||||
.child(self.render_language_model_selector(cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -3070,12 +3106,13 @@ impl SearchableItem for ContextEditor {
|
||||
|
||||
fn active_match_index(
|
||||
&mut self,
|
||||
direction: Direction,
|
||||
matches: &[Self::Match],
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.active_match_index(matches, window, cx)
|
||||
editor.active_match_index(direction, matches, window, cx)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3385,7 +3422,7 @@ fn invoked_slash_command_fold_placeholder(
|
||||
.ml_6()
|
||||
.gap_2()
|
||||
.bg(cx.theme().colors().surface_background)
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.child(Label::new(format!("/{}", command.name.clone())))
|
||||
.map(|parent| match &command.status {
|
||||
InvokedSlashCommandStatus::Running(_) => {
|
||||
|
||||
@@ -140,7 +140,7 @@ impl ResolvedPatch {
|
||||
buffer.edit(
|
||||
edits,
|
||||
Some(AutoindentMode::Block {
|
||||
original_start_columns: Vec::new(),
|
||||
original_indent_columns: Vec::new(),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ anyhow.workspace = true
|
||||
assistant_tool.workspace = true
|
||||
chrono.workspace = true
|
||||
gpui.workspace = true
|
||||
project.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
mod list_worktrees_tool;
|
||||
mod now_tool;
|
||||
mod read_file_tool;
|
||||
|
||||
use assistant_tool::ToolRegistry;
|
||||
use gpui::App;
|
||||
|
||||
use crate::list_worktrees_tool::ListWorktreesTool;
|
||||
use crate::now_tool::NowTool;
|
||||
use crate::read_file_tool::ReadFileTool;
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
assistant_tool::init(cx);
|
||||
|
||||
let registry = ToolRegistry::global(cx);
|
||||
registry.register_tool(NowTool);
|
||||
registry.register_tool(ListWorktreesTool);
|
||||
registry.register_tool(ReadFileTool);
|
||||
}
|
||||
|
||||
84
crates/assistant_tools/src/list_worktrees_tool.rs
Normal file
84
crates/assistant_tools/src/list_worktrees_tool.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use assistant_tool::Tool;
|
||||
use gpui::{App, Task, WeakEntity, Window};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use workspace::Workspace;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ListWorktreesToolInput {}
|
||||
|
||||
pub struct ListWorktreesTool;
|
||||
|
||||
impl Tool for ListWorktreesTool {
|
||||
fn name(&self) -> String {
|
||||
"list-worktrees".into()
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
"Lists all worktrees in the current project. Use this tool when you need to find available worktrees and their IDs.".into()
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn run(
|
||||
self: Arc<Self>,
|
||||
_input: serde_json::Value,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
_window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<String>> {
|
||||
let Some(workspace) = workspace.upgrade() else {
|
||||
return Task::ready(Err(anyhow!("workspace dropped")));
|
||||
};
|
||||
|
||||
let project = workspace.read(cx).project().clone();
|
||||
|
||||
cx.spawn(|cx| async move {
|
||||
cx.update(|cx| {
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WorktreeInfo {
|
||||
id: usize,
|
||||
root_name: String,
|
||||
root_dir: Option<String>,
|
||||
}
|
||||
|
||||
let worktrees = project.update(cx, |project, cx| {
|
||||
project
|
||||
.visible_worktrees(cx)
|
||||
.map(|worktree| {
|
||||
worktree.read_with(cx, |worktree, _cx| WorktreeInfo {
|
||||
id: worktree.id().to_usize(),
|
||||
root_dir: worktree
|
||||
.root_dir()
|
||||
.map(|root_dir| root_dir.to_string_lossy().to_string()),
|
||||
root_name: worktree.root_name().to_string(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
if worktrees.is_empty() {
|
||||
return Ok("No worktrees found in the current project.".to_string());
|
||||
}
|
||||
|
||||
let mut result = String::from("Worktrees in the current project:\n\n");
|
||||
for worktree in worktrees {
|
||||
result.push_str(&serde_json::to_string(&worktree)?);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
})?
|
||||
})
|
||||
}
|
||||
}
|
||||
69
crates/assistant_tools/src/read_file_tool.rs
Normal file
69
crates/assistant_tools/src/read_file_tool.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use assistant_tool::Tool;
|
||||
use gpui::{App, Task, WeakEntity, Window};
|
||||
use project::{ProjectPath, WorktreeId};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use workspace::Workspace;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ReadFileToolInput {
|
||||
/// The ID of the worktree in which the file resides.
|
||||
pub worktree_id: usize,
|
||||
/// The path to the file to read.
|
||||
///
|
||||
/// This path is relative to the worktree root, it must not be an absolute path.
|
||||
pub path: Arc<Path>,
|
||||
}
|
||||
|
||||
pub struct ReadFileTool;
|
||||
|
||||
impl Tool for ReadFileTool {
|
||||
fn name(&self) -> String {
|
||||
"read-file".into()
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
"Reads the content of a file specified by a worktree ID and path. Use this tool when you need to access the contents of a file in the project.".into()
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
let schema = schemars::schema_for!(ReadFileToolInput);
|
||||
serde_json::to_value(&schema).unwrap()
|
||||
}
|
||||
|
||||
fn run(
|
||||
self: Arc<Self>,
|
||||
input: serde_json::Value,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
_window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<String>> {
|
||||
let Some(workspace) = workspace.upgrade() else {
|
||||
return Task::ready(Err(anyhow!("workspace dropped")));
|
||||
};
|
||||
|
||||
let input = match serde_json::from_value::<ReadFileToolInput>(input) {
|
||||
Ok(input) => input,
|
||||
Err(err) => return Task::ready(Err(anyhow!(err))),
|
||||
};
|
||||
|
||||
let project = workspace.read(cx).project().clone();
|
||||
let project_path = ProjectPath {
|
||||
worktree_id: WorktreeId::from_usize(input.worktree_id),
|
||||
path: input.path,
|
||||
};
|
||||
cx.spawn(|cx| async move {
|
||||
let buffer = cx
|
||||
.update(|cx| {
|
||||
project.update(cx, |project, cx| project.open_buffer(project_path, cx))
|
||||
})?
|
||||
.await?;
|
||||
|
||||
cx.update(|cx| buffer.read(cx).text())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -663,11 +663,13 @@ impl std::fmt::Debug for BufferDiff {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BufferDiffEvent {
|
||||
DiffChanged {
|
||||
changed_range: Option<Range<text::Anchor>>,
|
||||
},
|
||||
LanguageChanged,
|
||||
HunksStagedOrUnstaged(Option<Rope>),
|
||||
}
|
||||
|
||||
impl EventEmitter<BufferDiffEvent> for BufferDiff {}
|
||||
@@ -762,6 +764,17 @@ impl BufferDiff {
|
||||
self.secondary_diff.clone()
|
||||
}
|
||||
|
||||
pub fn clear_pending_hunks(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(secondary_diff) = &self.secondary_diff {
|
||||
secondary_diff.update(cx, |diff, _| {
|
||||
diff.inner.pending_hunks.clear();
|
||||
});
|
||||
cx.emit(BufferDiffEvent::DiffChanged {
|
||||
changed_range: Some(Anchor::MIN..Anchor::MAX),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stage_or_unstage_hunks(
|
||||
&mut self,
|
||||
stage: bool,
|
||||
@@ -784,6 +797,9 @@ impl BufferDiff {
|
||||
}
|
||||
});
|
||||
}
|
||||
cx.emit(BufferDiffEvent::HunksStagedOrUnstaged(
|
||||
new_index_text.clone(),
|
||||
));
|
||||
if let Some((first, last)) = hunks.first().zip(hunks.last()) {
|
||||
let changed_range = first.buffer_range.start..last.buffer_range.end;
|
||||
cx.emit(BufferDiffEvent::DiffChanged {
|
||||
@@ -900,6 +916,14 @@ impl BufferDiff {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hunks<'a>(
|
||||
&'a self,
|
||||
buffer_snapshot: &'a text::BufferSnapshot,
|
||||
cx: &'a App,
|
||||
) -> impl 'a + Iterator<Item = DiffHunk> {
|
||||
self.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, buffer_snapshot, cx)
|
||||
}
|
||||
|
||||
pub fn hunks_intersecting_range<'a>(
|
||||
&'a self,
|
||||
range: Range<text::Anchor>,
|
||||
|
||||
@@ -418,6 +418,8 @@ impl Telemetry {
|
||||
|
||||
fn report_event(self: &Arc<Self>, event: Event) {
|
||||
let mut state = self.state.lock();
|
||||
// RUST_LOG=telemetry=trace to debug telemetry events
|
||||
log::trace!(target: "telemetry", "{:?}", event);
|
||||
|
||||
if !state.settings.metrics {
|
||||
return;
|
||||
|
||||
@@ -308,7 +308,7 @@ impl Server {
|
||||
.add_request_handler(forward_read_only_project_request::<proto::InlayHints>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::ResolveInlayHint>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::OpenBufferByPath>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::GitBranches>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::GitGetBranches>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::OpenUnstagedDiff>)
|
||||
.add_request_handler(forward_read_only_project_request::<proto::OpenUncommittedDiff>)
|
||||
.add_request_handler(
|
||||
@@ -393,9 +393,6 @@ impl Server {
|
||||
.add_request_handler(forward_mutating_project_request::<proto::OpenContext>)
|
||||
.add_request_handler(forward_mutating_project_request::<proto::CreateContext>)
|
||||
.add_request_handler(forward_mutating_project_request::<proto::SynchronizeContexts>)
|
||||
.add_request_handler(forward_mutating_project_request::<proto::Push>)
|
||||
.add_request_handler(forward_mutating_project_request::<proto::Pull>)
|
||||
.add_request_handler(forward_mutating_project_request::<proto::Fetch>)
|
||||
.add_request_handler(forward_mutating_project_request::<proto::Stage>)
|
||||
.add_request_handler(forward_mutating_project_request::<proto::Unstage>)
|
||||
.add_request_handler(forward_mutating_project_request::<proto::Commit>)
|
||||
@@ -405,6 +402,10 @@ impl Server {
|
||||
.add_request_handler(forward_read_only_project_request::<proto::GitCheckoutFiles>)
|
||||
.add_request_handler(forward_mutating_project_request::<proto::SetIndexText>)
|
||||
.add_request_handler(forward_mutating_project_request::<proto::OpenCommitMessageBuffer>)
|
||||
.add_request_handler(forward_mutating_project_request::<proto::GitDiff>)
|
||||
.add_request_handler(forward_mutating_project_request::<proto::GitCreateBranch>)
|
||||
.add_request_handler(forward_mutating_project_request::<proto::GitChangeBranch>)
|
||||
.add_request_handler(forward_mutating_project_request::<proto::CheckForPushedCommits>)
|
||||
.add_message_handler(broadcast_project_message_from_host::<proto::AdvertiseContexts>)
|
||||
.add_message_handler(update_context)
|
||||
.add_request_handler({
|
||||
|
||||
@@ -2027,6 +2027,15 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
|
||||
.unwrap()
|
||||
.downcast::<Editor>()
|
||||
.unwrap();
|
||||
let buffer_id_b = editor_b.update(cx_b, |editor_b, cx| {
|
||||
editor_b
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.as_singleton()
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.remote_id()
|
||||
});
|
||||
|
||||
// client_b now requests git blame for the open buffer
|
||||
editor_b.update_in(cx_b, |editor_b, window, cx| {
|
||||
@@ -2045,6 +2054,7 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
|
||||
&(0..4)
|
||||
.map(|row| RowInfo {
|
||||
buffer_row: Some(row),
|
||||
buffer_id: Some(buffer_id_b),
|
||||
..Default::default()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -2092,6 +2102,7 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
|
||||
&(0..4)
|
||||
.map(|row| RowInfo {
|
||||
buffer_row: Some(row),
|
||||
buffer_id: Some(buffer_id_b),
|
||||
..Default::default()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -2127,6 +2138,7 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
|
||||
&(0..4)
|
||||
.map(|row| RowInfo {
|
||||
buffer_row: Some(row),
|
||||
buffer_id: Some(buffer_id_b),
|
||||
..Default::default()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
|
||||
@@ -6741,19 +6741,24 @@ async fn test_remote_git_branches(
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let (project_a, worktree_id) = client_a.build_local_project("/project", cx_a).await;
|
||||
|
||||
let project_id = active_call_a
|
||||
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let project_b = client_b.join_remote_project(project_id, cx_b).await;
|
||||
|
||||
let root_path = ProjectPath::root_path(worktree_id);
|
||||
// Client A sees that a guest has joined.
|
||||
// Client A sees that a guest has joined and the repo has been populated
|
||||
executor.run_until_parked();
|
||||
|
||||
let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap());
|
||||
|
||||
let root_path = ProjectPath::root_path(worktree_id);
|
||||
|
||||
let branches_b = cx_b
|
||||
.update(|cx| project_b.update(cx, |project, cx| project.branches(root_path.clone(), cx)))
|
||||
.update(|cx| repo_b.update(cx, |repository, _| repository.branches()))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let new_branch = branches[2];
|
||||
@@ -6765,13 +6770,10 @@ async fn test_remote_git_branches(
|
||||
|
||||
assert_eq!(branches_b, branches_set);
|
||||
|
||||
cx_b.update(|cx| {
|
||||
project_b.update(cx, |project, cx| {
|
||||
project.update_or_create_branch(root_path.clone(), new_branch.to_string(), cx)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx_b.update(|cx| repo_b.read(cx).change_branch(new_branch.to_string()))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
@@ -6789,11 +6791,21 @@ async fn test_remote_git_branches(
|
||||
|
||||
// Also try creating a new branch
|
||||
cx_b.update(|cx| {
|
||||
project_b.update(cx, |project, cx| {
|
||||
project.update_or_create_branch(root_path.clone(), "totally-new-branch".to_string(), cx)
|
||||
})
|
||||
repo_b
|
||||
.read(cx)
|
||||
.create_branch("totally-new-branch".to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
cx_b.update(|cx| {
|
||||
repo_b
|
||||
.read(cx)
|
||||
.change_branch("totally-new-branch".to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
@@ -276,11 +276,13 @@ async fn test_ssh_collaboration_git_branches(
|
||||
// has some git repositories
|
||||
executor.run_until_parked();
|
||||
|
||||
let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap());
|
||||
let root_path = ProjectPath::root_path(worktree_id);
|
||||
|
||||
let branches_b = cx_b
|
||||
.update(|cx| project_b.update(cx, |project, cx| project.branches(root_path.clone(), cx)))
|
||||
.update(|cx| repo_b.read(cx).branches())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let new_branch = branches[2];
|
||||
@@ -292,13 +294,10 @@ async fn test_ssh_collaboration_git_branches(
|
||||
|
||||
assert_eq!(&branches_b, &branches_set);
|
||||
|
||||
cx_b.update(|cx| {
|
||||
project_b.update(cx, |project, cx| {
|
||||
project.update_or_create_branch(root_path.clone(), new_branch.to_string(), cx)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx_b.update(|cx| repo_b.read(cx).change_branch(new_branch.to_string()))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
@@ -318,11 +317,21 @@ async fn test_ssh_collaboration_git_branches(
|
||||
|
||||
// Also try creating a new branch
|
||||
cx_b.update(|cx| {
|
||||
project_b.update(cx, |project, cx| {
|
||||
project.update_or_create_branch(root_path.clone(), "totally-new-branch".to_string(), cx)
|
||||
})
|
||||
repo_b
|
||||
.read(cx)
|
||||
.create_branch("totally-new-branch".to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
cx_b.update(|cx| {
|
||||
repo_b
|
||||
.read(cx)
|
||||
.change_branch("totally-new-branch".to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
@@ -323,7 +323,7 @@ impl ChatPanel {
|
||||
.my_0p5()
|
||||
.px_0p5()
|
||||
.gap_x_1()
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.child(Icon::new(IconName::ReplyArrowRight).color(Color::Muted))
|
||||
.when(reply_to_message.is_none(), |el| {
|
||||
el.child(
|
||||
@@ -358,7 +358,7 @@ impl ChatPanel {
|
||||
.my_0p5()
|
||||
.px_0p5()
|
||||
.gap_x_1()
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.overflow_hidden()
|
||||
.hover(|style| style.bg(cx.theme().colors().element_background))
|
||||
.child(Icon::new(IconName::ReplyArrowRight).color(Color::Muted))
|
||||
@@ -476,7 +476,7 @@ impl ChatPanel {
|
||||
div()
|
||||
.group("")
|
||||
.bg(background)
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.overflow_hidden()
|
||||
.px_1p5()
|
||||
.py_0p5()
|
||||
@@ -563,7 +563,7 @@ impl ChatPanel {
|
||||
.child(
|
||||
div()
|
||||
.px_1()
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.text_ui_xs(cx)
|
||||
.bg(cx.theme().colors().background)
|
||||
.child("New messages"),
|
||||
@@ -589,7 +589,7 @@ impl ChatPanel {
|
||||
div()
|
||||
.w_6()
|
||||
.bg(cx.theme().colors().element_background)
|
||||
.hover(|style| style.bg(cx.theme().colors().element_hover).rounded_md())
|
||||
.hover(|style| style.bg(cx.theme().colors().element_hover).rounded_sm())
|
||||
.child(child)
|
||||
}
|
||||
|
||||
@@ -604,7 +604,7 @@ impl ChatPanel {
|
||||
.absolute()
|
||||
.right_2()
|
||||
.overflow_hidden()
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.border_color(cx.theme().colors().element_selected)
|
||||
.border_1()
|
||||
.when(!self.has_open_menu(message_id), |el| {
|
||||
|
||||
@@ -531,7 +531,7 @@ impl Render for MessageEditor {
|
||||
.px_2()
|
||||
.py_1()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.child(EditorElement::new(
|
||||
&self.editor,
|
||||
EditorStyle {
|
||||
|
||||
@@ -300,7 +300,7 @@ impl NotificationPanel {
|
||||
.hover(|style| {
|
||||
style
|
||||
.bg(cx.theme().colors().element_selected)
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
})
|
||||
.child(Label::new(relative_timestamp).color(Color::Muted))
|
||||
.tooltip(move |_, cx| {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::fmt::Display;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
@@ -8,7 +9,7 @@ use parking_lot::RwLock;
|
||||
use theme::ActiveTheme;
|
||||
|
||||
pub trait Component {
|
||||
fn scope() -> Option<&'static str>;
|
||||
fn scope() -> Option<ComponentScope>;
|
||||
fn name() -> &'static str {
|
||||
std::any::type_name::<Self>()
|
||||
}
|
||||
@@ -31,7 +32,7 @@ pub static COMPONENT_DATA: LazyLock<RwLock<ComponentRegistry>> =
|
||||
LazyLock::new(|| RwLock::new(ComponentRegistry::new()));
|
||||
|
||||
pub struct ComponentRegistry {
|
||||
components: Vec<(Option<&'static str>, &'static str, Option<&'static str>)>,
|
||||
components: Vec<(Option<ComponentScope>, &'static str, Option<&'static str>)>,
|
||||
previews: HashMap<&'static str, fn(&mut Window, &mut App) -> AnyElement>,
|
||||
}
|
||||
|
||||
@@ -78,7 +79,7 @@ pub struct ComponentId(pub &'static str);
|
||||
#[derive(Clone)]
|
||||
pub struct ComponentMetadata {
|
||||
name: SharedString,
|
||||
scope: Option<SharedString>,
|
||||
scope: Option<ComponentScope>,
|
||||
description: Option<SharedString>,
|
||||
preview: Option<fn(&mut Window, &mut App) -> AnyElement>,
|
||||
}
|
||||
@@ -88,7 +89,7 @@ impl ComponentMetadata {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
pub fn scope(&self) -> Option<SharedString> {
|
||||
pub fn scope(&self) -> Option<ComponentScope> {
|
||||
self.scope.clone()
|
||||
}
|
||||
|
||||
@@ -152,14 +153,14 @@ pub fn components() -> AllComponents {
|
||||
let data = COMPONENT_DATA.read();
|
||||
let mut all_components = AllComponents::new();
|
||||
|
||||
for &(scope, name, description) in &data.components {
|
||||
let scope = scope.map(Into::into);
|
||||
for (ref scope, name, description) in &data.components {
|
||||
let preview = data.previews.get(name).cloned();
|
||||
let component_name = SharedString::new_static(name);
|
||||
all_components.insert(
|
||||
ComponentId(name),
|
||||
ComponentMetadata {
|
||||
name: name.into(),
|
||||
scope,
|
||||
name: component_name,
|
||||
scope: scope.clone(),
|
||||
description: description.map(Into::into),
|
||||
preview,
|
||||
},
|
||||
@@ -169,6 +170,59 @@ pub fn components() -> AllComponents {
|
||||
all_components
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum ComponentScope {
|
||||
Layout,
|
||||
Input,
|
||||
Notification,
|
||||
Editor,
|
||||
Collaboration,
|
||||
VersionControl,
|
||||
Unknown(SharedString),
|
||||
}
|
||||
|
||||
impl Display for ComponentScope {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ComponentScope::Layout => write!(f, "Layout"),
|
||||
ComponentScope::Input => write!(f, "Input"),
|
||||
ComponentScope::Notification => write!(f, "Notification"),
|
||||
ComponentScope::Editor => write!(f, "Editor"),
|
||||
ComponentScope::Collaboration => write!(f, "Collaboration"),
|
||||
ComponentScope::VersionControl => write!(f, "Version Control"),
|
||||
ComponentScope::Unknown(name) => write!(f, "Unknown: {}", name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ComponentScope {
|
||||
fn from(value: &str) -> Self {
|
||||
match value {
|
||||
"Layout" => ComponentScope::Layout,
|
||||
"Input" => ComponentScope::Input,
|
||||
"Notification" => ComponentScope::Notification,
|
||||
"Editor" => ComponentScope::Editor,
|
||||
"Collaboration" => ComponentScope::Collaboration,
|
||||
"Version Control" | "VersionControl" => ComponentScope::VersionControl,
|
||||
_ => ComponentScope::Unknown(SharedString::new(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ComponentScope {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"Layout" => ComponentScope::Layout,
|
||||
"Input" => ComponentScope::Input,
|
||||
"Notification" => ComponentScope::Notification,
|
||||
"Editor" => ComponentScope::Editor,
|
||||
"Collaboration" => ComponentScope::Collaboration,
|
||||
"Version Control" | "VersionControl" => ComponentScope::VersionControl,
|
||||
_ => ComponentScope::Unknown(SharedString::new(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which side of the preview to show labels on
|
||||
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExampleLabelSide {
|
||||
@@ -177,8 +231,8 @@ pub enum ExampleLabelSide {
|
||||
/// Right side
|
||||
Right,
|
||||
/// Top side
|
||||
Top,
|
||||
#[default]
|
||||
Top,
|
||||
/// Bottom side
|
||||
Bottom,
|
||||
}
|
||||
@@ -208,6 +262,7 @@ impl RenderOnce for ComponentExample {
|
||||
.text_size(px(10.))
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.when(self.grow, |this| this.flex_1())
|
||||
.when(!self.grow, |this| this.flex_none())
|
||||
.child(self.element)
|
||||
.child(self.variant_name)
|
||||
.into_any_element()
|
||||
|
||||
@@ -15,7 +15,11 @@ path = "src/component_preview.rs"
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
client.workspace = true
|
||||
component.workspace = true
|
||||
gpui.workspace = true
|
||||
languages.workspace = true
|
||||
project.workspace = true
|
||||
ui.workspace = true
|
||||
workspace.workspace = true
|
||||
notifications.workspace = true
|
||||
|
||||
@@ -2,18 +2,49 @@
|
||||
//!
|
||||
//! A view for exploring Zed components.
|
||||
|
||||
use std::iter::Iterator;
|
||||
use std::sync::Arc;
|
||||
|
||||
use client::UserStore;
|
||||
use component::{components, ComponentMetadata};
|
||||
use gpui::{list, prelude::*, uniform_list, App, EventEmitter, FocusHandle, Focusable, Window};
|
||||
use gpui::{
|
||||
list, prelude::*, uniform_list, App, Entity, EventEmitter, FocusHandle, Focusable, Task,
|
||||
WeakEntity, Window,
|
||||
};
|
||||
|
||||
use gpui::{ListState, ScrollHandle, UniformListScrollHandle};
|
||||
use ui::{prelude::*, ListItem};
|
||||
use languages::LanguageRegistry;
|
||||
use notifications::status_toast::{StatusToast, ToastIcon};
|
||||
use project::Project;
|
||||
use ui::{prelude::*, Divider, ListItem, ListSubHeader};
|
||||
|
||||
use workspace::{item::ItemEvent, Item, Workspace, WorkspaceId};
|
||||
use workspace::{AppState, ItemId, SerializableItem};
|
||||
|
||||
pub fn init(app_state: Arc<AppState>, cx: &mut App) {
|
||||
let app_state = app_state.clone();
|
||||
|
||||
cx.observe_new(move |workspace: &mut Workspace, _, cx| {
|
||||
let app_state = app_state.clone();
|
||||
let weak_workspace = cx.entity().downgrade();
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
cx.observe_new(|workspace: &mut Workspace, _, _cx| {
|
||||
workspace.register_action(
|
||||
|workspace, _: &workspace::OpenComponentPreview, window, cx| {
|
||||
let component_preview = cx.new(|cx| ComponentPreview::new(window, cx));
|
||||
move |workspace, _: &workspace::OpenComponentPreview, window, cx| {
|
||||
let app_state = app_state.clone();
|
||||
|
||||
let language_registry = app_state.languages.clone();
|
||||
let user_store = app_state.user_store.clone();
|
||||
|
||||
let component_preview = cx.new(|cx| {
|
||||
ComponentPreview::new(
|
||||
weak_workspace.clone(),
|
||||
language_registry,
|
||||
user_store,
|
||||
None,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
workspace.add_item_to_active_pane(
|
||||
Box::new(component_preview),
|
||||
None,
|
||||
@@ -27,6 +58,23 @@ pub fn init(cx: &mut App) {
|
||||
.detach();
|
||||
}
|
||||
|
||||
enum PreviewEntry {
|
||||
Component(ComponentMetadata),
|
||||
SectionHeader(SharedString),
|
||||
}
|
||||
|
||||
impl From<ComponentMetadata> for PreviewEntry {
|
||||
fn from(component: ComponentMetadata) -> Self {
|
||||
PreviewEntry::Component(component)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SharedString> for PreviewEntry {
|
||||
fn from(section_header: SharedString) -> Self {
|
||||
PreviewEntry::SectionHeader(section_header)
|
||||
}
|
||||
}
|
||||
|
||||
struct ComponentPreview {
|
||||
focus_handle: FocusHandle,
|
||||
_view_scroll_handle: ScrollHandle,
|
||||
@@ -34,31 +82,55 @@ struct ComponentPreview {
|
||||
components: Vec<ComponentMetadata>,
|
||||
component_list: ListState,
|
||||
selected_index: usize,
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
user_store: Entity<UserStore>,
|
||||
}
|
||||
|
||||
impl ComponentPreview {
|
||||
pub fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
pub fn new(
|
||||
workspace: WeakEntity<Workspace>,
|
||||
language_registry: Arc<LanguageRegistry>,
|
||||
user_store: Entity<UserStore>,
|
||||
selected_index: impl Into<Option<usize>>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let components = components().all_sorted();
|
||||
let initial_length = components.len();
|
||||
let selected_index = selected_index.into().unwrap_or(0);
|
||||
|
||||
let component_list = ListState::new(initial_length, gpui::ListAlignment::Top, px(500.0), {
|
||||
let this = cx.entity().downgrade();
|
||||
move |ix, window: &mut Window, cx: &mut App| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.render_preview(ix, window, cx).into_any_element()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
});
|
||||
let component_list =
|
||||
ListState::new(initial_length, gpui::ListAlignment::Top, px(1500.0), {
|
||||
let this = cx.entity().downgrade();
|
||||
move |ix, window: &mut Window, cx: &mut App| {
|
||||
this.update(cx, |this, cx| {
|
||||
let component = this.get_component(ix);
|
||||
this.render_preview(ix, &component, window, cx)
|
||||
.into_any_element()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
let mut component_preview = Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
_view_scroll_handle: ScrollHandle::new(),
|
||||
nav_scroll_handle: UniformListScrollHandle::new(),
|
||||
language_registry,
|
||||
user_store,
|
||||
workspace,
|
||||
components,
|
||||
component_list,
|
||||
selected_index: 0,
|
||||
selected_index,
|
||||
};
|
||||
|
||||
if component_preview.selected_index > 0 {
|
||||
component_preview.scroll_to_preview(component_preview.selected_index, cx);
|
||||
}
|
||||
|
||||
component_preview.update_component_list(cx);
|
||||
|
||||
component_preview
|
||||
}
|
||||
|
||||
fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context<Self>) {
|
||||
@@ -71,32 +143,158 @@ impl ComponentPreview {
|
||||
self.components[ix].clone()
|
||||
}
|
||||
|
||||
fn scope_ordered_entries(&self) -> Vec<PreviewEntry> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Group components by scope
|
||||
let mut scope_groups: HashMap<Option<ComponentScope>, Vec<ComponentMetadata>> =
|
||||
HashMap::default();
|
||||
|
||||
for component in &self.components {
|
||||
scope_groups
|
||||
.entry(component.scope())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(component.clone());
|
||||
}
|
||||
|
||||
// Sort components within each scope by name
|
||||
for components in scope_groups.values_mut() {
|
||||
components.sort_by_key(|c| c.name().to_lowercase());
|
||||
}
|
||||
|
||||
// Build entries with scopes in a defined order
|
||||
let mut entries = Vec::new();
|
||||
|
||||
// Define scope order (we want Unknown at the end)
|
||||
let known_scopes = [
|
||||
ComponentScope::Layout,
|
||||
ComponentScope::Input,
|
||||
ComponentScope::Editor,
|
||||
ComponentScope::Notification,
|
||||
ComponentScope::Collaboration,
|
||||
ComponentScope::VersionControl,
|
||||
];
|
||||
|
||||
// First add components with known scopes
|
||||
for scope in known_scopes.iter() {
|
||||
let scope_key = Some(scope.clone());
|
||||
if let Some(components) = scope_groups.remove(&scope_key) {
|
||||
if !components.is_empty() {
|
||||
// Add section header
|
||||
entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
|
||||
|
||||
// Add all components under this scope
|
||||
for component in components {
|
||||
entries.push(PreviewEntry::Component(component));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle components with Unknown scope
|
||||
for (scope, components) in &scope_groups {
|
||||
if let Some(ComponentScope::Unknown(_)) = scope {
|
||||
if !components.is_empty() {
|
||||
// Add the unknown scope header
|
||||
if let Some(scope_value) = scope {
|
||||
entries.push(PreviewEntry::SectionHeader(scope_value.to_string().into()));
|
||||
}
|
||||
|
||||
// Add all components under this unknown scope
|
||||
for component in components {
|
||||
entries.push(PreviewEntry::Component(component.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle components with no scope
|
||||
if let Some(components) = scope_groups.get(&None) {
|
||||
if !components.is_empty() {
|
||||
entries.push(PreviewEntry::SectionHeader("Uncategorized".into()));
|
||||
|
||||
for component in components {
|
||||
entries.push(PreviewEntry::Component(component.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
fn render_sidebar_entry(
|
||||
&self,
|
||||
ix: usize,
|
||||
entry: &PreviewEntry,
|
||||
selected: bool,
|
||||
cx: &Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let component = self.get_component(ix);
|
||||
match entry {
|
||||
PreviewEntry::Component(component_metadata) => ListItem::new(ix)
|
||||
.child(Label::new(component_metadata.name().clone()).color(Color::Default))
|
||||
.selectable(true)
|
||||
.toggle_state(selected)
|
||||
.inset(true)
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.scroll_to_preview(ix, cx);
|
||||
}))
|
||||
.into_any_element(),
|
||||
PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
|
||||
.inset(true)
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
ListItem::new(ix)
|
||||
.child(Label::new(component.name().clone()).color(Color::Default))
|
||||
.selectable(true)
|
||||
.toggle_state(selected)
|
||||
.inset(true)
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.scroll_to_preview(ix, cx);
|
||||
}))
|
||||
fn update_component_list(&mut self, cx: &mut Context<Self>) {
|
||||
let new_len = self.scope_ordered_entries().len();
|
||||
let entries = self.scope_ordered_entries();
|
||||
let weak_entity = cx.entity().downgrade();
|
||||
|
||||
let new_list = ListState::new(
|
||||
new_len,
|
||||
gpui::ListAlignment::Top,
|
||||
px(1500.0),
|
||||
move |ix, window, cx| {
|
||||
let entry = &entries[ix];
|
||||
|
||||
weak_entity
|
||||
.update(cx, |this, cx| match entry {
|
||||
PreviewEntry::Component(component) => this
|
||||
.render_preview(ix, component, window, cx)
|
||||
.into_any_element(),
|
||||
PreviewEntry::SectionHeader(shared_string) => this
|
||||
.render_scope_header(ix, shared_string.clone(), window, cx)
|
||||
.into_any_element(),
|
||||
})
|
||||
.unwrap()
|
||||
},
|
||||
);
|
||||
|
||||
self.component_list = new_list;
|
||||
}
|
||||
|
||||
fn render_scope_header(
|
||||
&self,
|
||||
_ix: usize,
|
||||
title: SharedString,
|
||||
_window: &Window,
|
||||
_cx: &App,
|
||||
) -> impl IntoElement {
|
||||
h_flex()
|
||||
.w_full()
|
||||
.h_10()
|
||||
.items_center()
|
||||
.child(Headline::new(title).size(HeadlineSize::XSmall))
|
||||
.child(Divider::horizontal())
|
||||
}
|
||||
|
||||
fn render_preview(
|
||||
&self,
|
||||
ix: usize,
|
||||
_ix: usize,
|
||||
component: &ComponentMetadata,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
cx: &mut App,
|
||||
) -> impl IntoElement {
|
||||
let component = self.get_component(ix);
|
||||
|
||||
let name = component.name();
|
||||
let scope = component.scope();
|
||||
|
||||
@@ -108,7 +306,7 @@ impl ComponentPreview {
|
||||
v_flex()
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.w_full()
|
||||
.gap_4()
|
||||
.py_4()
|
||||
@@ -142,10 +340,32 @@ impl ComponentPreview {
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn test_status_toast(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(workspace) = self.workspace.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
let status_toast = StatusToast::new(
|
||||
"`zed/new-notification-system` created!",
|
||||
window,
|
||||
cx,
|
||||
|this, _, cx| {
|
||||
this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
|
||||
.action(
|
||||
"Open Pull Request",
|
||||
cx.listener(|_, _, _, cx| cx.open_url("https://github.com/")),
|
||||
)
|
||||
},
|
||||
);
|
||||
workspace.toggle_status_toast(window, cx, status_toast)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ComponentPreview {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
|
||||
let sidebar_entries = self.scope_ordered_entries();
|
||||
|
||||
h_flex()
|
||||
.id("component-preview")
|
||||
.key_context("ComponentPreview")
|
||||
@@ -156,21 +376,44 @@ impl Render for ComponentPreview {
|
||||
.px_2()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.child(
|
||||
uniform_list(
|
||||
cx.entity().clone(),
|
||||
"component-nav",
|
||||
self.components.len(),
|
||||
move |this, range, _window, cx| {
|
||||
range
|
||||
.map(|ix| this.render_sidebar_entry(ix, ix == this.selected_index, cx))
|
||||
.collect()
|
||||
},
|
||||
)
|
||||
.track_scroll(self.nav_scroll_handle.clone())
|
||||
.pt_4()
|
||||
.w(px(240.))
|
||||
.h_full()
|
||||
.flex_grow(),
|
||||
v_flex()
|
||||
.h_full()
|
||||
.child(
|
||||
uniform_list(
|
||||
cx.entity().clone(),
|
||||
"component-nav",
|
||||
sidebar_entries.len(),
|
||||
move |this, range, _window, cx| {
|
||||
range
|
||||
.map(|ix| {
|
||||
this.render_sidebar_entry(
|
||||
ix,
|
||||
&sidebar_entries[ix],
|
||||
ix == this.selected_index,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
},
|
||||
)
|
||||
.track_scroll(self.nav_scroll_handle.clone())
|
||||
.pt_4()
|
||||
.w(px(240.))
|
||||
.h_full()
|
||||
.flex_1(),
|
||||
)
|
||||
.child(
|
||||
div().w_full().pb_4().child(
|
||||
Button::new("toast-test", "Launch Toast")
|
||||
.on_click(cx.listener({
|
||||
move |this, _, window, cx| {
|
||||
this.test_status_toast(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
}))
|
||||
.full_width(),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
@@ -213,13 +456,26 @@ impl Item for ComponentPreview {
|
||||
fn clone_on_split(
|
||||
&self,
|
||||
_workspace_id: Option<WorkspaceId>,
|
||||
window: &mut Window,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<gpui::Entity<Self>>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
Some(cx.new(|cx| Self::new(window, cx)))
|
||||
let language_registry = self.language_registry.clone();
|
||||
let user_store = self.user_store.clone();
|
||||
let weak_workspace = self.workspace.clone();
|
||||
let selected_index = self.selected_index;
|
||||
|
||||
Some(cx.new(|cx| {
|
||||
Self::new(
|
||||
weak_workspace,
|
||||
language_registry,
|
||||
user_store,
|
||||
selected_index,
|
||||
cx,
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
|
||||
@@ -227,6 +483,59 @@ impl Item for ComponentPreview {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: impl serializable item for component preview so it will restore with the workspace
|
||||
// ref: https://github.com/zed-industries/zed/blob/32201ac70a501e63dfa2ade9c00f85aea2d4dd94/crates/image_viewer/src/image_viewer.rs#L199
|
||||
// Use `ImageViewer` as a model for how to do it, except it'll be even simpler
|
||||
impl SerializableItem for ComponentPreview {
|
||||
fn serialized_item_kind() -> &'static str {
|
||||
"ComponentPreview"
|
||||
}
|
||||
|
||||
fn deserialize(
|
||||
project: Entity<Project>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
_workspace_id: WorkspaceId,
|
||||
_item_id: ItemId,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Task<gpui::Result<Entity<Self>>> {
|
||||
let user_store = project.read(cx).user_store().clone();
|
||||
let language_registry = project.read(cx).languages().clone();
|
||||
|
||||
window.spawn(cx, |mut cx| async move {
|
||||
let user_store = user_store.clone();
|
||||
let language_registry = language_registry.clone();
|
||||
let weak_workspace = workspace.clone();
|
||||
cx.update(|_, cx| {
|
||||
Ok(cx.new(|cx| {
|
||||
ComponentPreview::new(weak_workspace, language_registry, user_store, None, cx)
|
||||
}))
|
||||
})?
|
||||
})
|
||||
}
|
||||
|
||||
fn cleanup(
|
||||
_workspace_id: WorkspaceId,
|
||||
_alive_items: Vec<ItemId>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut App,
|
||||
) -> Task<gpui::Result<()>> {
|
||||
Task::ready(Ok(()))
|
||||
// window.spawn(cx, |_| {
|
||||
// ...
|
||||
// })
|
||||
}
|
||||
|
||||
fn serialize(
|
||||
&mut self,
|
||||
_workspace: &mut Workspace,
|
||||
_item_id: ItemId,
|
||||
_closing: bool,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<Task<gpui::Result<()>>> {
|
||||
// TODO: Serialize the active index so we can re-open to the same place
|
||||
None
|
||||
}
|
||||
|
||||
fn should_serialize(&self, _event: &Self::Event) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ impl CopilotCodeVerification {
|
||||
.p_1()
|
||||
.border_1()
|
||||
.border_muted(cx)
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.cursor_pointer()
|
||||
.justify_between()
|
||||
.on_mouse_down(gpui::MouseButton::Left, {
|
||||
|
||||
@@ -973,7 +973,7 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.px_1()
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.bg(color.surface_background.opacity(0.5))
|
||||
.map(|stack| {
|
||||
stack.child(
|
||||
|
||||
@@ -94,6 +94,7 @@ ctor.workspace = true
|
||||
env_logger.workspace = true
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
language = { workspace = true, features = ["test-support"] }
|
||||
languages = {workspace = true, features = ["test-support"] }
|
||||
lsp = { workspace = true, features = ["test-support"] }
|
||||
multi_buffer = { workspace = true, features = ["test-support"] }
|
||||
project = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -7,7 +7,6 @@ use std::time::Duration;
|
||||
|
||||
pub struct BlinkManager {
|
||||
blink_interval: Duration,
|
||||
|
||||
blink_epoch: usize,
|
||||
blinking_paused: bool,
|
||||
visible: bool,
|
||||
@@ -24,7 +23,6 @@ impl BlinkManager {
|
||||
|
||||
Self {
|
||||
blink_interval,
|
||||
|
||||
blink_epoch: 0,
|
||||
blinking_paused: false,
|
||||
visible: true,
|
||||
|
||||
@@ -2,12 +2,13 @@ use anyhow::Context as _;
|
||||
use gpui::{App, Context, Entity, Window};
|
||||
use language::Language;
|
||||
use url::Url;
|
||||
use workspace::{OpenOptions, OpenVisible};
|
||||
|
||||
use crate::lsp_ext::find_specific_language_server_in_selection;
|
||||
|
||||
use crate::{element::register_action, Editor, SwitchSourceHeader};
|
||||
|
||||
const CLANGD_SERVER_NAME: &str = "clangd";
|
||||
use project::lsp_store::clangd_ext::CLANGD_SERVER_NAME;
|
||||
|
||||
fn is_c_language(language: &Language) -> bool {
|
||||
return language.name() == "C++".into() || language.name() == "C".into();
|
||||
@@ -46,7 +47,7 @@ pub fn switch_source_header(
|
||||
project.request_lsp(
|
||||
buffer,
|
||||
project::LanguageServerToQuery::Other(server_to_query),
|
||||
project::lsp_ext_command::SwitchSourceHeader,
|
||||
project::lsp_store::lsp_ext_command::SwitchSourceHeader,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
@@ -72,7 +73,7 @@ pub fn switch_source_header(
|
||||
|
||||
workspace
|
||||
.update_in(&mut cx, |workspace, window, cx| {
|
||||
workspace.open_abs_path(path, false, window, cx)
|
||||
workspace.open_abs_path(path, OpenOptions { visible: Some(OpenVisible::None), ..Default::default() }, window, cx)
|
||||
})
|
||||
.with_context(|| {
|
||||
format!(
|
||||
|
||||
@@ -534,7 +534,7 @@ impl CompletionsMenu {
|
||||
};
|
||||
let color_swatch = completion
|
||||
.color()
|
||||
.map(|color| div().size_4().bg(color).rounded_sm());
|
||||
.map(|color| div().size_4().bg(color).rounded_xs());
|
||||
|
||||
div().min_w(px(280.)).max_w(px(540.)).child(
|
||||
ListItem::new(mat.candidate_id)
|
||||
@@ -851,7 +851,7 @@ impl CodeActionsItem {
|
||||
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Self::CodeAction { action, .. } => action.lsp_action.title.clone(),
|
||||
Self::CodeAction { action, .. } => action.lsp_action.title().to_owned(),
|
||||
Self::Task(_, task) => task.resolved_label.clone(),
|
||||
}
|
||||
}
|
||||
@@ -984,7 +984,7 @@ impl CodeActionsMenu {
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
// TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here.
|
||||
action.lsp_action.title.replace("\n", ""),
|
||||
action.lsp_action.title().replace("\n", ""),
|
||||
)
|
||||
.when(selected, |this| {
|
||||
this.text_color(colors.text_accent)
|
||||
@@ -1029,7 +1029,7 @@ impl CodeActionsMenu {
|
||||
.max_by_key(|(_, action)| match action {
|
||||
CodeActionsItem::Task(_, task) => task.resolved_label.chars().count(),
|
||||
CodeActionsItem::CodeAction { action, .. } => {
|
||||
action.lsp_action.title.chars().count()
|
||||
action.lsp_action.title().chars().count()
|
||||
}
|
||||
})
|
||||
.map(|(ix, _)| ix),
|
||||
|
||||
@@ -43,7 +43,7 @@ use gpui::{App, Context, Entity, Font, HighlightStyle, LineLayout, Pixels, Under
|
||||
pub use inlay_map::Inlay;
|
||||
use inlay_map::{InlayMap, InlaySnapshot};
|
||||
pub use inlay_map::{InlayOffset, InlayPoint};
|
||||
use invisibles::{is_invisible, replacement};
|
||||
pub use invisibles::{is_invisible, replacement};
|
||||
use language::{
|
||||
language_settings::language_settings, ChunkRenderer, OffsetUtf16, Point,
|
||||
Subscription as BufferSubscription,
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn is_invisible(c: char) -> bool {
|
||||
// ASCII control characters have fancy unicode glyphs, everything else
|
||||
// is replaced by a space - unless it is used in combining characters in
|
||||
// which case we need to leave it in the string.
|
||||
pub(crate) fn replacement(c: char) -> Option<&'static str> {
|
||||
pub fn replacement(c: char) -> Option<&'static str> {
|
||||
if c <= '\x1f' {
|
||||
Some(C0_SYMBOLS[c as usize])
|
||||
} else if c == '\x7f' {
|
||||
|
||||
@@ -28,6 +28,7 @@ mod hover_popover;
|
||||
mod indent_guides;
|
||||
mod inlay_hint_cache;
|
||||
pub mod items;
|
||||
mod jsx_tag_auto_close;
|
||||
mod linked_editing_ranges;
|
||||
mod lsp_ext;
|
||||
mod mouse_context_menu;
|
||||
@@ -260,6 +261,7 @@ enum DisplayDiffHunk {
|
||||
display_row: DisplayRow,
|
||||
},
|
||||
Unfolded {
|
||||
is_created_file: bool,
|
||||
diff_base_byte_range: Range<usize>,
|
||||
display_row_range: Range<DisplayRow>,
|
||||
multi_buffer_range: Range<Anchor>,
|
||||
@@ -724,6 +726,7 @@ pub struct Editor {
|
||||
use_autoclose: bool,
|
||||
use_auto_surround: bool,
|
||||
auto_replace_emoji_shortcode: bool,
|
||||
jsx_tag_auto_close_enabled_in_any_buffer: bool,
|
||||
show_git_blame_gutter: bool,
|
||||
show_git_blame_inline: bool,
|
||||
show_git_blame_inline_delay_task: Option<Task<()>>,
|
||||
@@ -1010,8 +1013,8 @@ pub struct ClipboardSelection {
|
||||
pub len: usize,
|
||||
/// Whether this was a full-line selection.
|
||||
pub is_entire_line: bool,
|
||||
/// The column where this selection originally started.
|
||||
pub start_column: u32,
|
||||
/// The indentation of the first line when this content was originally copied.
|
||||
pub first_line_indent: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -1199,7 +1202,7 @@ impl Editor {
|
||||
.bg(cx.theme().colors().ghost_element_background)
|
||||
.hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
|
||||
.active(|style| style.bg(cx.theme().colors().ghost_element_active))
|
||||
.rounded_sm()
|
||||
.rounded_xs()
|
||||
.size_full()
|
||||
.cursor_pointer()
|
||||
.child("⋯")
|
||||
@@ -1410,6 +1413,7 @@ impl Editor {
|
||||
use_autoclose: true,
|
||||
use_auto_surround: true,
|
||||
auto_replace_emoji_shortcode: false,
|
||||
jsx_tag_auto_close_enabled_in_any_buffer: false,
|
||||
leader_peer_id: None,
|
||||
remote_id: None,
|
||||
hover_state: Default::default(),
|
||||
@@ -1493,6 +1497,7 @@ impl Editor {
|
||||
|
||||
this.end_selection(window, cx);
|
||||
this.scroll_manager.show_scrollbar(window, cx);
|
||||
jsx_tag_auto_close::refresh_enabled_in_any_buffer(&mut this, &buffer, cx);
|
||||
|
||||
if mode == EditorMode::Full {
|
||||
let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars();
|
||||
@@ -2233,6 +2238,49 @@ impl Editor {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn sync_selections(
|
||||
&mut self,
|
||||
other: Entity<Editor>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> gpui::Subscription {
|
||||
let other_selections = other.read(cx).selections.disjoint.to_vec();
|
||||
self.selections.change_with(cx, |selections| {
|
||||
selections.select_anchors(other_selections);
|
||||
});
|
||||
|
||||
let other_subscription =
|
||||
cx.subscribe(&other, |this, other, other_evt, cx| match other_evt {
|
||||
EditorEvent::SelectionsChanged { local: true } => {
|
||||
let other_selections = other.read(cx).selections.disjoint.to_vec();
|
||||
if other_selections.is_empty() {
|
||||
return;
|
||||
}
|
||||
this.selections.change_with(cx, |selections| {
|
||||
selections.select_anchors(other_selections);
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
let this_subscription =
|
||||
cx.subscribe_self::<EditorEvent>(move |this, this_evt, cx| match this_evt {
|
||||
EditorEvent::SelectionsChanged { local: true } => {
|
||||
let these_selections = this.selections.disjoint.to_vec();
|
||||
if these_selections.is_empty() {
|
||||
return;
|
||||
}
|
||||
other.update(cx, |other_editor, cx| {
|
||||
other_editor.selections.change_with(cx, |selections| {
|
||||
selections.select_anchors(these_selections);
|
||||
})
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
Subscription::join(other_subscription, this_subscription)
|
||||
}
|
||||
|
||||
pub fn change_selections<R>(
|
||||
&mut self,
|
||||
autoscroll: Option<Autoscroll>,
|
||||
@@ -2307,7 +2355,7 @@ impl Editor {
|
||||
pub fn edit_with_block_indent<I, S, T>(
|
||||
&mut self,
|
||||
edits: I,
|
||||
original_start_columns: Vec<u32>,
|
||||
original_indent_columns: Vec<Option<u32>>,
|
||||
cx: &mut Context<Self>,
|
||||
) where
|
||||
I: IntoIterator<Item = (Range<S>, T)>,
|
||||
@@ -2322,7 +2370,7 @@ impl Editor {
|
||||
buffer.edit(
|
||||
edits,
|
||||
Some(AutoindentMode::Block {
|
||||
original_start_columns,
|
||||
original_indent_columns,
|
||||
}),
|
||||
cx,
|
||||
)
|
||||
@@ -3058,6 +3106,9 @@ impl Editor {
|
||||
drop(snapshot);
|
||||
|
||||
self.transact(window, cx, |this, window, cx| {
|
||||
let initial_buffer_versions =
|
||||
jsx_tag_auto_close::construct_initial_buffer_versions_map(this, &edits, cx);
|
||||
|
||||
this.buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit(edits, this.autoindent_mode.clone(), cx);
|
||||
});
|
||||
@@ -3145,6 +3196,7 @@ impl Editor {
|
||||
this.trigger_completion_on_input(&text, trigger_in_words, window, cx);
|
||||
linked_editing_ranges::refresh_linked_ranges(this, window, cx);
|
||||
this.refresh_inline_completion(true, false, window, cx);
|
||||
jsx_tag_auto_close::handle_from(this, initial_buffer_versions, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3429,7 +3481,7 @@ impl Editor {
|
||||
|
||||
pub fn insert(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let autoindent = text.is_empty().not().then(|| AutoindentMode::Block {
|
||||
original_start_columns: Vec::new(),
|
||||
original_indent_columns: Vec::new(),
|
||||
});
|
||||
self.insert_with_autoindent_mode(text, autoindent, window, cx);
|
||||
}
|
||||
@@ -6499,7 +6551,7 @@ impl Editor {
|
||||
.pl_1()
|
||||
.pr(padding_right)
|
||||
.gap_1()
|
||||
.rounded(px(6.))
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.bg(Self::edit_prediction_line_popover_bg_color(cx))
|
||||
.border_color(Self::edit_prediction_callout_popover_border_color(cx))
|
||||
@@ -7806,7 +7858,7 @@ impl Editor {
|
||||
for hunk in &hunks {
|
||||
self.prepare_restore_change(&mut revert_changes, hunk, cx);
|
||||
}
|
||||
self.do_stage_or_unstage(false, buffer_id, hunks.into_iter(), window, cx);
|
||||
self.do_stage_or_unstage(false, buffer_id, hunks.into_iter(), cx);
|
||||
}
|
||||
drop(chunk_by);
|
||||
if !revert_changes.is_empty() {
|
||||
@@ -7844,6 +7896,9 @@ impl Editor {
|
||||
hunk: &MultiBufferDiffHunk,
|
||||
cx: &mut App,
|
||||
) -> Option<()> {
|
||||
if hunk.is_created_file() {
|
||||
return None;
|
||||
}
|
||||
let buffer = self.buffer.read(cx);
|
||||
let diff = buffer.diff_for(hunk.buffer_id)?;
|
||||
let buffer = buffer.buffer(hunk.buffer_id)?;
|
||||
@@ -8653,7 +8708,9 @@ impl Editor {
|
||||
clipboard_selections.push(ClipboardSelection {
|
||||
len,
|
||||
is_entire_line,
|
||||
start_column: selection.start.column,
|
||||
first_line_indent: buffer
|
||||
.indent_size_for_line(MultiBufferRow(selection.start.row))
|
||||
.len,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8732,7 +8789,7 @@ impl Editor {
|
||||
clipboard_selections.push(ClipboardSelection {
|
||||
len,
|
||||
is_entire_line,
|
||||
start_column: start.column,
|
||||
first_line_indent: buffer.indent_size_for_line(MultiBufferRow(start.row)).len,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8762,8 +8819,8 @@ impl Editor {
|
||||
let old_selections = this.selections.all::<usize>(cx);
|
||||
let all_selections_were_entire_line =
|
||||
clipboard_selections.iter().all(|s| s.is_entire_line);
|
||||
let first_selection_start_column =
|
||||
clipboard_selections.first().map(|s| s.start_column);
|
||||
let first_selection_indent_column =
|
||||
clipboard_selections.first().map(|s| s.first_line_indent);
|
||||
if clipboard_selections.len() != old_selections.len() {
|
||||
clipboard_selections.drain(..);
|
||||
}
|
||||
@@ -8778,21 +8835,21 @@ impl Editor {
|
||||
|
||||
let mut start_offset = 0;
|
||||
let mut edits = Vec::new();
|
||||
let mut original_start_columns = Vec::new();
|
||||
let mut original_indent_columns = Vec::new();
|
||||
for (ix, selection) in old_selections.iter().enumerate() {
|
||||
let to_insert;
|
||||
let entire_line;
|
||||
let original_start_column;
|
||||
let original_indent_column;
|
||||
if let Some(clipboard_selection) = clipboard_selections.get(ix) {
|
||||
let end_offset = start_offset + clipboard_selection.len;
|
||||
to_insert = &clipboard_text[start_offset..end_offset];
|
||||
entire_line = clipboard_selection.is_entire_line;
|
||||
start_offset = end_offset + 1;
|
||||
original_start_column = Some(clipboard_selection.start_column);
|
||||
original_indent_column = Some(clipboard_selection.first_line_indent);
|
||||
} else {
|
||||
to_insert = clipboard_text.as_str();
|
||||
entire_line = all_selections_were_entire_line;
|
||||
original_start_column = first_selection_start_column
|
||||
original_indent_column = first_selection_indent_column
|
||||
}
|
||||
|
||||
// If the corresponding selection was empty when this slice of the
|
||||
@@ -8808,7 +8865,7 @@ impl Editor {
|
||||
};
|
||||
|
||||
edits.push((range, to_insert));
|
||||
original_start_columns.extend(original_start_column);
|
||||
original_indent_columns.push(original_indent_column);
|
||||
}
|
||||
drop(snapshot);
|
||||
|
||||
@@ -8816,7 +8873,7 @@ impl Editor {
|
||||
edits,
|
||||
if auto_indent_on_paste {
|
||||
Some(AutoindentMode::Block {
|
||||
original_start_columns,
|
||||
original_indent_columns,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -11507,15 +11564,16 @@ impl Editor {
|
||||
direction: Direction,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Editor>,
|
||||
) -> Option<MultiBufferDiffHunk> {
|
||||
let hunk = if direction == Direction::Next {
|
||||
) {
|
||||
let row = if direction == Direction::Next {
|
||||
self.hunk_after_position(snapshot, position)
|
||||
.map(|hunk| hunk.row_range.start)
|
||||
} else {
|
||||
self.hunk_before_position(snapshot, position)
|
||||
};
|
||||
|
||||
if let Some(hunk) = &hunk {
|
||||
let destination = Point::new(hunk.row_range.start.0, 0);
|
||||
if let Some(row) = row {
|
||||
let destination = Point::new(row.0, 0);
|
||||
let autoscroll = Autoscroll::center();
|
||||
|
||||
self.unfold_ranges(&[destination..destination], false, false, cx);
|
||||
@@ -11523,8 +11581,6 @@ impl Editor {
|
||||
s.select_ranges([destination..destination]);
|
||||
});
|
||||
}
|
||||
|
||||
hunk
|
||||
}
|
||||
|
||||
fn hunk_after_position(
|
||||
@@ -11565,7 +11621,7 @@ impl Editor {
|
||||
&mut self,
|
||||
snapshot: &EditorSnapshot,
|
||||
position: Point,
|
||||
) -> Option<MultiBufferDiffHunk> {
|
||||
) -> Option<MultiBufferRow> {
|
||||
snapshot
|
||||
.buffer_snapshot
|
||||
.diff_hunk_before(position)
|
||||
@@ -13621,13 +13677,13 @@ impl Editor {
|
||||
pub fn toggle_staged_selected_diff_hunks(
|
||||
&mut self,
|
||||
_: &::git::ToggleStaged,
|
||||
window: &mut Window,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let snapshot = self.buffer.read(cx).snapshot(cx);
|
||||
let ranges: Vec<_> = self.selections.disjoint.iter().map(|s| s.range()).collect();
|
||||
let stage = self.has_stageable_diff_hunks_in_ranges(&ranges, &snapshot);
|
||||
self.stage_or_unstage_diff_hunks(stage, &ranges, window, cx);
|
||||
self.stage_or_unstage_diff_hunks(stage, ranges, cx);
|
||||
}
|
||||
|
||||
pub fn stage_and_next(
|
||||
@@ -13651,16 +13707,53 @@ impl Editor {
|
||||
pub fn stage_or_unstage_diff_hunks(
|
||||
&mut self,
|
||||
stage: bool,
|
||||
ranges: &[Range<Anchor>],
|
||||
window: &mut Window,
|
||||
ranges: Vec<Range<Anchor>>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let snapshot = self.buffer.read(cx).snapshot(cx);
|
||||
let chunk_by = self
|
||||
.diff_hunks_in_ranges(&ranges, &snapshot)
|
||||
.chunk_by(|hunk| hunk.buffer_id);
|
||||
for (buffer_id, hunks) in &chunk_by {
|
||||
self.do_stage_or_unstage(stage, buffer_id, hunks, window, cx);
|
||||
let task = self.save_buffers_for_ranges_if_needed(&ranges, cx);
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
task.await?;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
let snapshot = this.buffer.read(cx).snapshot(cx);
|
||||
let chunk_by = this
|
||||
.diff_hunks_in_ranges(&ranges, &snapshot)
|
||||
.chunk_by(|hunk| hunk.buffer_id);
|
||||
for (buffer_id, hunks) in &chunk_by {
|
||||
this.do_stage_or_unstage(stage, buffer_id, hunks, cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
fn save_buffers_for_ranges_if_needed(
|
||||
&mut self,
|
||||
ranges: &[Range<Anchor>],
|
||||
cx: &mut Context<'_, Editor>,
|
||||
) -> Task<Result<()>> {
|
||||
let multibuffer = self.buffer.read(cx);
|
||||
let snapshot = multibuffer.read(cx);
|
||||
let buffer_ids: HashSet<_> = ranges
|
||||
.iter()
|
||||
.flat_map(|range| snapshot.buffer_ids_for_range(range.clone()))
|
||||
.collect();
|
||||
drop(snapshot);
|
||||
|
||||
let mut buffers = HashSet::default();
|
||||
for buffer_id in buffer_ids {
|
||||
if let Some(buffer_entity) = multibuffer.buffer(buffer_id) {
|
||||
let buffer = buffer_entity.read(cx);
|
||||
if buffer.file().is_some_and(|file| file.disk_state().exists()) && buffer.is_dirty()
|
||||
{
|
||||
buffers.insert(buffer_entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(project) = &self.project {
|
||||
project.update(cx, |project, cx| project.save_buffers(buffers, cx))
|
||||
} else {
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13673,7 +13766,7 @@ impl Editor {
|
||||
let ranges = self.selections.disjoint_anchor_ranges().collect::<Vec<_>>();
|
||||
|
||||
if ranges.iter().any(|range| range.start != range.end) {
|
||||
self.stage_or_unstage_diff_hunks(stage, &ranges[..], window, cx);
|
||||
self.stage_or_unstage_diff_hunks(stage, ranges, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -13692,7 +13785,7 @@ impl Editor {
|
||||
if run_twice {
|
||||
self.go_to_next_hunk(&GoToHunk, window, cx);
|
||||
}
|
||||
self.stage_or_unstage_diff_hunks(stage, &ranges[..], window, cx);
|
||||
self.stage_or_unstage_diff_hunks(stage, ranges, cx);
|
||||
self.go_to_next_hunk(&GoToHunk, window, cx);
|
||||
}
|
||||
|
||||
@@ -13701,31 +13794,16 @@ impl Editor {
|
||||
stage: bool,
|
||||
buffer_id: BufferId,
|
||||
hunks: impl Iterator<Item = MultiBufferDiffHunk>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let Some(project) = self.project.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(buffer) = project.read(cx).buffer_for_id(buffer_id, cx) else {
|
||||
return;
|
||||
};
|
||||
let Some(diff) = self.buffer.read(cx).diff_for(buffer_id) else {
|
||||
return;
|
||||
};
|
||||
) -> Option<()> {
|
||||
let project = self.project.as_ref()?;
|
||||
let buffer = project.read(cx).buffer_for_id(buffer_id, cx)?;
|
||||
let diff = self.buffer.read(cx).diff_for(buffer_id)?;
|
||||
let buffer_snapshot = buffer.read(cx).snapshot();
|
||||
let file_exists = buffer_snapshot
|
||||
.file()
|
||||
.is_some_and(|file| file.disk_state().exists());
|
||||
let Some((repo, path)) = project
|
||||
.read(cx)
|
||||
.repository_and_path_for_buffer_id(buffer_id, cx)
|
||||
else {
|
||||
log::debug!("no git repo for buffer id");
|
||||
return;
|
||||
};
|
||||
|
||||
let new_index_text = diff.update(cx, |diff, cx| {
|
||||
diff.update(cx, |diff, cx| {
|
||||
diff.stage_or_unstage_hunks(
|
||||
stage,
|
||||
&hunks
|
||||
@@ -13741,20 +13819,7 @@ impl Editor {
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
if file_exists {
|
||||
let buffer_store = project.read(cx).buffer_store().clone();
|
||||
buffer_store
|
||||
.update(cx, |buffer_store, cx| buffer_store.save_buffer(buffer, cx))
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
let recv = repo
|
||||
.read(cx)
|
||||
.set_index_text(&path, new_index_text.map(|rope| rope.to_string()));
|
||||
|
||||
cx.background_spawn(async move { recv.await? })
|
||||
.detach_and_notify_err(window, cx);
|
||||
None
|
||||
}
|
||||
|
||||
pub fn expand_selected_diff_hunks(&mut self, cx: &mut Context<Self>) {
|
||||
@@ -15342,6 +15407,7 @@ impl Editor {
|
||||
let buffer = self.buffer.read(cx);
|
||||
self.registered_buffers
|
||||
.retain(|buffer_id, _| buffer.buffer(*buffer_id).is_some());
|
||||
jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx);
|
||||
cx.emit(EditorEvent::ExcerptsRemoved { ids: ids.clone() })
|
||||
}
|
||||
multi_buffer::Event::ExcerptsEdited {
|
||||
@@ -15361,6 +15427,7 @@ impl Editor {
|
||||
}
|
||||
multi_buffer::Event::Reparsed(buffer_id) => {
|
||||
self.tasks_update_task = Some(self.refresh_runnables(window, cx));
|
||||
jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx);
|
||||
|
||||
cx.emit(EditorEvent::Reparsed(*buffer_id));
|
||||
}
|
||||
@@ -15369,6 +15436,7 @@ impl Editor {
|
||||
}
|
||||
multi_buffer::Event::LanguageChanged(buffer_id) => {
|
||||
linked_editing_ranges::refresh_linked_ranges(self, window, cx);
|
||||
jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx);
|
||||
cx.emit(EditorEvent::Reparsed(*buffer_id));
|
||||
cx.notify();
|
||||
}
|
||||
@@ -16269,7 +16337,7 @@ fn get_uncommitted_diff_for_buffer(
|
||||
}
|
||||
});
|
||||
cx.spawn(|mut cx| async move {
|
||||
let diffs = futures::future::join_all(tasks).await;
|
||||
let diffs = future::join_all(tasks).await;
|
||||
buffer
|
||||
.update(&mut cx, |buffer, cx| {
|
||||
for diff in diffs.into_iter().flatten() {
|
||||
@@ -17218,6 +17286,7 @@ impl EditorSnapshot {
|
||||
if hunk_display_end.column() > 0 {
|
||||
end_row.0 += 1;
|
||||
}
|
||||
let is_created_file = hunk.is_created_file();
|
||||
DisplayDiffHunk::Unfolded {
|
||||
status: hunk.status(),
|
||||
diff_base_byte_range: hunk.diff_base_byte_range,
|
||||
@@ -17227,6 +17296,7 @@ impl EditorSnapshot {
|
||||
hunk.buffer_id,
|
||||
hunk.buffer_range,
|
||||
),
|
||||
is_created_file,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4931,6 +4931,34 @@ async fn test_paste_multiline(cx: &mut TestAppContext) {
|
||||
)
|
||||
);
|
||||
"});
|
||||
|
||||
// Copy an indented block, starting mid-line
|
||||
cx.set_state(indoc! {"
|
||||
const a: B = (
|
||||
c(),
|
||||
somethin«g(
|
||||
e,
|
||||
f
|
||||
)ˇ»
|
||||
);
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| e.copy(&Copy, window, cx));
|
||||
|
||||
// Paste it on a line with a lower indent level
|
||||
cx.update_editor(|e, window, cx| e.move_to_end(&Default::default(), window, cx));
|
||||
cx.update_editor(|e, window, cx| e.paste(&Paste, window, cx));
|
||||
cx.assert_editor_state(indoc! {"
|
||||
const a: B = (
|
||||
c(),
|
||||
something(
|
||||
e,
|
||||
f
|
||||
)
|
||||
);
|
||||
g(
|
||||
e,
|
||||
f
|
||||
)ˇ"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
@@ -16779,6 +16807,245 @@ async fn test_tree_sitter_brackets_newline_insertion(cx: &mut TestAppContext) {
|
||||
"});
|
||||
}
|
||||
|
||||
mod autoclose_tags {
|
||||
use super::*;
|
||||
use language::language_settings::JsxTagAutoCloseSettings;
|
||||
use languages::language;
|
||||
|
||||
async fn test_setup(cx: &mut TestAppContext) -> EditorTestContext {
|
||||
init_test(cx, |settings| {
|
||||
settings.defaults.jsx_tag_auto_close = Some(JsxTagAutoCloseSettings { enabled: true });
|
||||
});
|
||||
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
cx.update_buffer(|buffer, cx| {
|
||||
let language = language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into());
|
||||
|
||||
buffer.set_language(Some(language), cx)
|
||||
});
|
||||
|
||||
cx
|
||||
}
|
||||
|
||||
macro_rules! check {
|
||||
($name:ident, $initial:literal + $input:literal => $expected:expr) => {
|
||||
#[gpui::test]
|
||||
async fn $name(cx: &mut TestAppContext) {
|
||||
let mut cx = test_setup(cx).await;
|
||||
cx.set_state($initial);
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input($input, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.assert_editor_state($expected);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
check!(
|
||||
test_basic,
|
||||
"<divˇ" + ">" => "<div>ˇ</div>"
|
||||
);
|
||||
|
||||
check!(
|
||||
test_basic_nested,
|
||||
"<div><divˇ</div>" + ">" => "<div><div>ˇ</div></div>"
|
||||
);
|
||||
|
||||
check!(
|
||||
test_basic_ignore_already_closed,
|
||||
"<div><divˇ</div></div>" + ">" => "<div><div>ˇ</div></div>"
|
||||
);
|
||||
|
||||
check!(
|
||||
test_doesnt_autoclose_closing_tag,
|
||||
"</divˇ" + ">" => "</div>ˇ"
|
||||
);
|
||||
|
||||
check!(
|
||||
test_jsx_attr,
|
||||
"<div attr={</div>}ˇ" + ">" => "<div attr={</div>}>ˇ</div>"
|
||||
);
|
||||
|
||||
check!(
|
||||
test_ignores_closing_tags_in_expr_block,
|
||||
"<div><divˇ{</div>}</div>" + ">" => "<div><div>ˇ</div>{</div>}</div>"
|
||||
);
|
||||
|
||||
check!(
|
||||
test_doesnt_autoclose_on_gt_in_expr,
|
||||
"<div attr={1 ˇ" + ">" => "<div attr={1 >ˇ"
|
||||
);
|
||||
|
||||
check!(
|
||||
test_ignores_closing_tags_with_different_tag_names,
|
||||
"<div><divˇ</div></span>" + ">" => "<div><div>ˇ</div></div></span>"
|
||||
);
|
||||
|
||||
check!(
|
||||
test_autocloses_in_jsx_expression,
|
||||
"<div>{<divˇ}</div>" + ">" => "<div>{<div>ˇ</div>}</div>"
|
||||
);
|
||||
|
||||
check!(
|
||||
test_doesnt_autoclose_already_closed_in_jsx_expression,
|
||||
"<div>{<divˇ</div>}</div>" + ">" => "<div>{<div>ˇ</div>}</div>"
|
||||
);
|
||||
|
||||
check!(
|
||||
test_autocloses_fragment,
|
||||
"<ˇ" + ">" => "<>ˇ</>"
|
||||
);
|
||||
|
||||
check!(
|
||||
test_does_not_include_type_argument_in_autoclose_tag_name,
|
||||
"<Component<T> attr={boolean_value}ˇ" + ">" => "<Component<T> attr={boolean_value}>ˇ</Component>"
|
||||
);
|
||||
|
||||
check!(
|
||||
test_does_not_autoclose_doctype,
|
||||
"<!DOCTYPE htmlˇ" + ">" => "<!DOCTYPE html>ˇ"
|
||||
);
|
||||
|
||||
check!(
|
||||
test_does_not_autoclose_comment,
|
||||
"<!-- comment --ˇ" + ">" => "<!-- comment -->ˇ"
|
||||
);
|
||||
|
||||
check!(
|
||||
test_multi_cursor_autoclose_same_tag,
|
||||
r#"
|
||||
<divˇ
|
||||
<divˇ
|
||||
"#
|
||||
+ ">" =>
|
||||
r#"
|
||||
<div>ˇ</div>
|
||||
<div>ˇ</div>
|
||||
"#
|
||||
);
|
||||
|
||||
check!(
|
||||
test_multi_cursor_autoclose_different_tags,
|
||||
r#"
|
||||
<divˇ
|
||||
<spanˇ
|
||||
"#
|
||||
+ ">" =>
|
||||
r#"
|
||||
<div>ˇ</div>
|
||||
<span>ˇ</span>
|
||||
"#
|
||||
);
|
||||
|
||||
check!(
|
||||
test_multi_cursor_autoclose_some_dont_autoclose_others,
|
||||
r#"
|
||||
<divˇ
|
||||
<div /ˇ
|
||||
<spanˇ</span>
|
||||
<!DOCTYPE htmlˇ
|
||||
</headˇ
|
||||
<Component<T>ˇ
|
||||
ˇ
|
||||
"#
|
||||
+ ">" =>
|
||||
r#"
|
||||
<div>ˇ</div>
|
||||
<div />ˇ
|
||||
<span>ˇ</span>
|
||||
<!DOCTYPE html>ˇ
|
||||
</head>ˇ
|
||||
<Component<T>>ˇ</Component>
|
||||
>ˇ
|
||||
"#
|
||||
);
|
||||
|
||||
check!(
|
||||
test_doesnt_mess_up_trailing_text,
|
||||
"<divˇfoobar" + ">" => "<div>ˇ</div>foobar"
|
||||
);
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_multibuffer(cx: &mut TestAppContext) {
|
||||
init_test(cx, |settings| {
|
||||
settings.defaults.jsx_tag_auto_close = Some(JsxTagAutoCloseSettings { enabled: true });
|
||||
});
|
||||
|
||||
let buffer_a = cx.new(|cx| {
|
||||
let mut buf = language::Buffer::local("<div", cx);
|
||||
buf.set_language(
|
||||
Some(language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into())),
|
||||
cx,
|
||||
);
|
||||
buf
|
||||
});
|
||||
let buffer_b = cx.new(|cx| {
|
||||
let mut buf = language::Buffer::local("<pre", cx);
|
||||
buf.set_language(
|
||||
Some(language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into())),
|
||||
cx,
|
||||
);
|
||||
buf
|
||||
});
|
||||
let buffer_c = cx.new(|cx| {
|
||||
let buf = language::Buffer::local("<span", cx);
|
||||
buf
|
||||
});
|
||||
let buffer = cx.new(|cx| {
|
||||
let mut buf = MultiBuffer::new(language::Capability::ReadWrite);
|
||||
buf.push_excerpts(
|
||||
buffer_a,
|
||||
[ExcerptRange {
|
||||
context: text::Anchor::MIN..text::Anchor::MAX,
|
||||
primary: None,
|
||||
}],
|
||||
cx,
|
||||
);
|
||||
buf.push_excerpts(
|
||||
buffer_b,
|
||||
[ExcerptRange {
|
||||
context: text::Anchor::MIN..text::Anchor::MAX,
|
||||
primary: None,
|
||||
}],
|
||||
cx,
|
||||
);
|
||||
buf.push_excerpts(
|
||||
buffer_c,
|
||||
[ExcerptRange {
|
||||
context: text::Anchor::MIN..text::Anchor::MAX,
|
||||
primary: None,
|
||||
}],
|
||||
cx,
|
||||
);
|
||||
buf
|
||||
});
|
||||
let editor = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx));
|
||||
|
||||
let mut cx = EditorTestContext::for_editor(editor, cx).await;
|
||||
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.change_selections(None, window, cx, |selections| {
|
||||
selections.select(vec![
|
||||
Selection::from_offset(4),
|
||||
Selection::from_offset(9),
|
||||
Selection::from_offset(15),
|
||||
])
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_editor(|editor, window, cx| {
|
||||
editor.handle_input(">", window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.assert_editor_state("<div>ˇ</div>\n<pre>ˇ</pre>\n<span>ˇ");
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_range(row: usize, column: usize) -> Range<DisplayPoint> {
|
||||
let point = DisplayPoint::new(DisplayRow(row as u32), column as u32);
|
||||
point..point
|
||||
|
||||
@@ -77,7 +77,7 @@ use ui::{
|
||||
POPOVER_Y_PADDING,
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use util::{debug_panic, maybe, RangeExt, ResultExt};
|
||||
use util::{debug_panic, RangeExt, ResultExt};
|
||||
use workspace::{item::Item, notifications::NotifyTaskExt};
|
||||
|
||||
const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 7.;
|
||||
@@ -1724,7 +1724,7 @@ impl EditorElement {
|
||||
.h(line_height)
|
||||
.w_full()
|
||||
.px_1()
|
||||
.rounded_sm()
|
||||
.rounded_xs()
|
||||
.opacity(opacity)
|
||||
.bg(severity_to_color(&diagnostic_to_render.severity)
|
||||
.color(cx)
|
||||
@@ -2018,7 +2018,7 @@ impl EditorElement {
|
||||
scroll_pixel_position: gpui::Point<Pixels>,
|
||||
gutter_dimensions: &GutterDimensions,
|
||||
gutter_hitbox: &Hitbox,
|
||||
rows_with_hunk_bounds: &HashMap<DisplayRow, Bounds<Pixels>>,
|
||||
display_hunks: &[(DisplayDiffHunk, Option<Hitbox>)],
|
||||
snapshot: &EditorSnapshot,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
@@ -2094,7 +2094,7 @@ impl EditorElement {
|
||||
gutter_dimensions,
|
||||
scroll_pixel_position,
|
||||
gutter_hitbox,
|
||||
rows_with_hunk_bounds,
|
||||
display_hunks,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
@@ -2112,7 +2112,7 @@ impl EditorElement {
|
||||
scroll_pixel_position: gpui::Point<Pixels>,
|
||||
gutter_dimensions: &GutterDimensions,
|
||||
gutter_hitbox: &Hitbox,
|
||||
rows_with_hunk_bounds: &HashMap<DisplayRow, Bounds<Pixels>>,
|
||||
display_hunks: &[(DisplayDiffHunk, Option<Hitbox>)],
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<AnyElement> {
|
||||
@@ -2137,7 +2137,7 @@ impl EditorElement {
|
||||
gutter_dimensions,
|
||||
scroll_pixel_position,
|
||||
gutter_hitbox,
|
||||
rows_with_hunk_bounds,
|
||||
display_hunks,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
@@ -2676,24 +2676,21 @@ impl EditorElement {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Div {
|
||||
let file_status = maybe!({
|
||||
let project = self.editor.read(cx).project.as_ref()?.read(cx);
|
||||
let (repo, path) =
|
||||
project.repository_and_path_for_buffer_id(for_excerpt.buffer_id, cx)?;
|
||||
let status = repo.read(cx).repository_entry.status_for_path(&path)?;
|
||||
Some(status.status)
|
||||
})
|
||||
.filter(|_| {
|
||||
self.editor
|
||||
.read(cx)
|
||||
.buffer
|
||||
.read(cx)
|
||||
.all_diff_hunks_expanded()
|
||||
});
|
||||
|
||||
let include_root = self
|
||||
.editor
|
||||
let editor = self.editor.read(cx);
|
||||
let file_status = editor
|
||||
.buffer
|
||||
.read(cx)
|
||||
.all_diff_hunks_expanded()
|
||||
.then(|| {
|
||||
editor
|
||||
.project
|
||||
.as_ref()?
|
||||
.read(cx)
|
||||
.status_for_buffer_id(for_excerpt.buffer_id, cx)
|
||||
})
|
||||
.flatten();
|
||||
|
||||
let include_root = editor
|
||||
.project
|
||||
.as_ref()
|
||||
.map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
|
||||
@@ -2705,7 +2702,7 @@ impl EditorElement {
|
||||
let parent_path = path.as_ref().and_then(|path| {
|
||||
Some(path.parent()?.to_string_lossy().to_string() + std::path::MAIN_SEPARATOR_STR)
|
||||
});
|
||||
let focus_handle = self.editor.focus_handle(cx);
|
||||
let focus_handle = editor.focus_handle(cx);
|
||||
let colors = cx.theme().colors();
|
||||
|
||||
div()
|
||||
@@ -2720,7 +2717,7 @@ impl EditorElement {
|
||||
.flex_basis(Length::Definite(DefiniteLength::Fraction(0.667)))
|
||||
.pl_0p5()
|
||||
.pr_5()
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.shadow_md()
|
||||
.border_1()
|
||||
.map(|div| {
|
||||
@@ -2744,7 +2741,7 @@ impl EditorElement {
|
||||
header.child(
|
||||
div()
|
||||
.hover(|style| style.bg(colors.element_selected))
|
||||
.rounded_sm()
|
||||
.rounded_xs()
|
||||
.child(
|
||||
ButtonLike::new("toggle-buffer-fold")
|
||||
.style(ui::ButtonStyle::Transparent)
|
||||
@@ -2778,8 +2775,7 @@ impl EditorElement {
|
||||
)
|
||||
})
|
||||
.children(
|
||||
self.editor
|
||||
.read(cx)
|
||||
editor
|
||||
.addons
|
||||
.values()
|
||||
.filter_map(|addon| {
|
||||
@@ -3952,6 +3948,7 @@ impl EditorElement {
|
||||
display_row_range,
|
||||
multi_buffer_range,
|
||||
status,
|
||||
is_created_file,
|
||||
..
|
||||
} = &hunk
|
||||
{
|
||||
@@ -3983,6 +3980,7 @@ impl EditorElement {
|
||||
display_row_range.start.0,
|
||||
status,
|
||||
multi_buffer_range.clone(),
|
||||
*is_created_file,
|
||||
line_height,
|
||||
&editor,
|
||||
cx,
|
||||
@@ -5689,7 +5687,7 @@ fn prepaint_gutter_button(
|
||||
gutter_dimensions: &GutterDimensions,
|
||||
scroll_pixel_position: gpui::Point<Pixels>,
|
||||
gutter_hitbox: &Hitbox,
|
||||
rows_with_hunk_bounds: &HashMap<DisplayRow, Bounds<Pixels>>,
|
||||
display_hunks: &[(DisplayDiffHunk, Option<Hitbox>)],
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
@@ -5701,9 +5699,23 @@ fn prepaint_gutter_button(
|
||||
let indicator_size = button.layout_as_root(available_space, window, cx);
|
||||
|
||||
let blame_width = gutter_dimensions.git_blame_entries_width;
|
||||
let gutter_width = rows_with_hunk_bounds
|
||||
.get(&row)
|
||||
.map(|bounds| bounds.size.width);
|
||||
let gutter_width = display_hunks
|
||||
.binary_search_by(|(hunk, _)| match hunk {
|
||||
DisplayDiffHunk::Folded { display_row } => display_row.cmp(&row),
|
||||
DisplayDiffHunk::Unfolded {
|
||||
display_row_range, ..
|
||||
} => {
|
||||
if display_row_range.end <= row {
|
||||
Ordering::Less
|
||||
} else if display_row_range.start > row {
|
||||
Ordering::Greater
|
||||
} else {
|
||||
Ordering::Equal
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok()
|
||||
.and_then(|ix| Some(display_hunks[ix].1.as_ref()?.size.width));
|
||||
let left_offset = blame_width.max(gutter_width).unwrap_or_default();
|
||||
|
||||
let mut x = left_offset;
|
||||
@@ -7219,27 +7231,6 @@ impl Element for EditorElement {
|
||||
|
||||
let gutter_settings = EditorSettings::get_global(cx).gutter;
|
||||
|
||||
let rows_with_hunk_bounds = display_hunks
|
||||
.iter()
|
||||
.filter_map(|(hunk, hitbox)| Some((hunk, hitbox.as_ref()?.bounds)))
|
||||
.fold(
|
||||
HashMap::default(),
|
||||
|mut rows_with_hunk_bounds, (hunk, bounds)| {
|
||||
match hunk {
|
||||
DisplayDiffHunk::Folded { display_row } => {
|
||||
rows_with_hunk_bounds.insert(*display_row, bounds);
|
||||
}
|
||||
DisplayDiffHunk::Unfolded {
|
||||
display_row_range, ..
|
||||
} => {
|
||||
for display_row in display_row_range.iter_rows() {
|
||||
rows_with_hunk_bounds.insert(display_row, bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
rows_with_hunk_bounds
|
||||
},
|
||||
);
|
||||
let mut code_actions_indicator = None;
|
||||
if let Some(newest_selection_head) = newest_selection_head {
|
||||
let newest_selection_point =
|
||||
@@ -7289,7 +7280,7 @@ impl Element for EditorElement {
|
||||
scroll_pixel_position,
|
||||
&gutter_dimensions,
|
||||
&gutter_hitbox,
|
||||
&rows_with_hunk_bounds,
|
||||
&display_hunks,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
@@ -7317,7 +7308,7 @@ impl Element for EditorElement {
|
||||
scroll_pixel_position,
|
||||
&gutter_dimensions,
|
||||
&gutter_hitbox,
|
||||
&rows_with_hunk_bounds,
|
||||
&display_hunks,
|
||||
&snapshot,
|
||||
window,
|
||||
cx,
|
||||
@@ -8797,6 +8788,7 @@ fn diff_hunk_controls(
|
||||
row: u32,
|
||||
status: &DiffHunkStatus,
|
||||
hunk_range: Range<Anchor>,
|
||||
is_created_file: bool,
|
||||
line_height: Pixels,
|
||||
editor: &Entity<Editor>,
|
||||
cx: &mut App,
|
||||
@@ -8812,6 +8804,7 @@ fn diff_hunk_controls(
|
||||
.rounded_b_lg()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.gap_1()
|
||||
.occlude()
|
||||
.child(if status.has_secondary_hunk() {
|
||||
Button::new(("stage", row as u64), "Stage")
|
||||
.alpha(if status.is_pending() { 0.66 } else { 1.0 })
|
||||
@@ -8829,12 +8822,11 @@ fn diff_hunk_controls(
|
||||
})
|
||||
.on_click({
|
||||
let editor = editor.clone();
|
||||
move |_event, window, cx| {
|
||||
move |_event, _window, cx| {
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.stage_or_unstage_diff_hunks(
|
||||
true,
|
||||
&[hunk_range.start..hunk_range.start],
|
||||
window,
|
||||
vec![hunk_range.start..hunk_range.start],
|
||||
cx,
|
||||
);
|
||||
});
|
||||
@@ -8857,12 +8849,11 @@ fn diff_hunk_controls(
|
||||
})
|
||||
.on_click({
|
||||
let editor = editor.clone();
|
||||
move |_event, window, cx| {
|
||||
move |_event, _window, cx| {
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.stage_or_unstage_diff_hunks(
|
||||
false,
|
||||
&[hunk_range.start..hunk_range.start],
|
||||
window,
|
||||
vec![hunk_range.start..hunk_range.start],
|
||||
cx,
|
||||
);
|
||||
});
|
||||
@@ -8892,7 +8883,8 @@ fn diff_hunk_controls(
|
||||
editor.restore_hunks_in_ranges(vec![point..point], window, cx);
|
||||
});
|
||||
}
|
||||
}),
|
||||
})
|
||||
.disabled(is_created_file),
|
||||
)
|
||||
.when(
|
||||
!editor.read(cx).buffer().read(cx).all_diff_hunks_expanded(),
|
||||
|
||||
@@ -195,9 +195,12 @@ impl GitBlame {
|
||||
) -> impl 'a + Iterator<Item = Option<BlameEntry>> {
|
||||
self.sync(cx);
|
||||
|
||||
let buffer_id = self.buffer_snapshot.remote_id();
|
||||
let mut cursor = self.entries.cursor::<u32>(&());
|
||||
rows.into_iter().map(move |info| {
|
||||
let row = info.buffer_row?;
|
||||
let row = info
|
||||
.buffer_row
|
||||
.filter(|_| info.buffer_id == Some(buffer_id))?;
|
||||
cursor.seek_forward(&row, Bias::Right, &());
|
||||
cursor.item()?.blame.clone()
|
||||
})
|
||||
@@ -535,6 +538,7 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
use std::{cmp, env, ops::Range, path::Path};
|
||||
use text::BufferId;
|
||||
use unindent::Unindent as _;
|
||||
use util::{path, RandomCharIter};
|
||||
|
||||
@@ -552,16 +556,18 @@ mod tests {
|
||||
#[track_caller]
|
||||
fn assert_blame_rows(
|
||||
blame: &mut GitBlame,
|
||||
buffer_id: BufferId,
|
||||
rows: Range<u32>,
|
||||
expected: Vec<Option<BlameEntry>>,
|
||||
cx: &mut Context<GitBlame>,
|
||||
) {
|
||||
assert_eq!(
|
||||
pretty_assertions::assert_eq!(
|
||||
blame
|
||||
.blame_for_rows(
|
||||
&rows
|
||||
.map(|row| RowInfo {
|
||||
buffer_row: Some(row),
|
||||
buffer_id: Some(buffer_id),
|
||||
..Default::default()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -694,6 +700,7 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let buffer_id = buffer.update(cx, |buffer, _| buffer.remote_id());
|
||||
|
||||
let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
|
||||
|
||||
@@ -701,12 +708,13 @@ mod tests {
|
||||
|
||||
git_blame.update(cx, |blame, cx| {
|
||||
// All lines
|
||||
assert_eq!(
|
||||
pretty_assertions::assert_eq!(
|
||||
blame
|
||||
.blame_for_rows(
|
||||
&(0..8)
|
||||
.map(|buffer_row| RowInfo {
|
||||
buffer_row: Some(buffer_row),
|
||||
buffer_id: Some(buffer_id),
|
||||
..Default::default()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -725,12 +733,13 @@ mod tests {
|
||||
]
|
||||
);
|
||||
// Subset of lines
|
||||
assert_eq!(
|
||||
pretty_assertions::assert_eq!(
|
||||
blame
|
||||
.blame_for_rows(
|
||||
&(1..4)
|
||||
.map(|buffer_row| RowInfo {
|
||||
buffer_row: Some(buffer_row),
|
||||
buffer_id: Some(buffer_id),
|
||||
..Default::default()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
@@ -744,12 +753,13 @@ mod tests {
|
||||
]
|
||||
);
|
||||
// Subset of lines, with some not displayed
|
||||
assert_eq!(
|
||||
pretty_assertions::assert_eq!(
|
||||
blame
|
||||
.blame_for_rows(
|
||||
&[
|
||||
RowInfo {
|
||||
buffer_row: Some(1),
|
||||
buffer_id: Some(buffer_id),
|
||||
..Default::default()
|
||||
},
|
||||
Default::default(),
|
||||
@@ -800,6 +810,7 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let buffer_id = buffer.update(cx, |buffer, _| buffer.remote_id());
|
||||
|
||||
let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
|
||||
|
||||
@@ -810,6 +821,7 @@ mod tests {
|
||||
// lines.
|
||||
assert_blame_rows(
|
||||
blame,
|
||||
buffer_id,
|
||||
0..4,
|
||||
vec![
|
||||
Some(blame_entry("1b1b1b", 0..4)),
|
||||
@@ -828,6 +840,7 @@ mod tests {
|
||||
git_blame.update(cx, |blame, cx| {
|
||||
assert_blame_rows(
|
||||
blame,
|
||||
buffer_id,
|
||||
0..2,
|
||||
vec![None, Some(blame_entry("1b1b1b", 0..4))],
|
||||
cx,
|
||||
@@ -840,6 +853,7 @@ mod tests {
|
||||
git_blame.update(cx, |blame, cx| {
|
||||
assert_blame_rows(
|
||||
blame,
|
||||
buffer_id,
|
||||
1..4,
|
||||
vec![
|
||||
None,
|
||||
@@ -852,7 +866,13 @@ mod tests {
|
||||
|
||||
// Before we insert a newline at the end, sanity check:
|
||||
git_blame.update(cx, |blame, cx| {
|
||||
assert_blame_rows(blame, 3..4, vec![Some(blame_entry("1b1b1b", 0..4))], cx);
|
||||
assert_blame_rows(
|
||||
blame,
|
||||
buffer_id,
|
||||
3..4,
|
||||
vec![Some(blame_entry("1b1b1b", 0..4))],
|
||||
cx,
|
||||
);
|
||||
});
|
||||
// Insert a newline at the end
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
@@ -862,6 +882,7 @@ mod tests {
|
||||
git_blame.update(cx, |blame, cx| {
|
||||
assert_blame_rows(
|
||||
blame,
|
||||
buffer_id,
|
||||
3..5,
|
||||
vec![Some(blame_entry("1b1b1b", 0..4)), None],
|
||||
cx,
|
||||
@@ -870,7 +891,13 @@ mod tests {
|
||||
|
||||
// Before we insert a newline at the start, sanity check:
|
||||
git_blame.update(cx, |blame, cx| {
|
||||
assert_blame_rows(blame, 2..3, vec![Some(blame_entry("1b1b1b", 0..4))], cx);
|
||||
assert_blame_rows(
|
||||
blame,
|
||||
buffer_id,
|
||||
2..3,
|
||||
vec![Some(blame_entry("1b1b1b", 0..4))],
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
// Usage example
|
||||
@@ -882,6 +909,7 @@ mod tests {
|
||||
git_blame.update(cx, |blame, cx| {
|
||||
assert_blame_rows(
|
||||
blame,
|
||||
buffer_id,
|
||||
2..4,
|
||||
vec![None, Some(blame_entry("1b1b1b", 0..4))],
|
||||
cx,
|
||||
|
||||
@@ -241,8 +241,10 @@ impl Editor {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
return self.navigate_to_hover_links(None, links, modifiers.alt, window, cx);
|
||||
let navigate_task =
|
||||
self.navigate_to_hover_links(None, links, modifiers.alt, window, cx);
|
||||
self.select(SelectPhase::End, window, cx);
|
||||
return navigate_task;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +260,7 @@ impl Editor {
|
||||
cx,
|
||||
);
|
||||
|
||||
if point.as_valid().is_some() {
|
||||
let navigate_task = if point.as_valid().is_some() {
|
||||
if modifiers.shift {
|
||||
self.go_to_type_definition(&GoToTypeDefinition, window, cx)
|
||||
} else {
|
||||
@@ -266,7 +268,9 @@ impl Editor {
|
||||
}
|
||||
} else {
|
||||
Task::ready(Ok(Navigated::No))
|
||||
}
|
||||
};
|
||||
self.select(SelectPhase::End, window, cx);
|
||||
return navigate_task;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ use theme::ThemeSettings;
|
||||
use ui::{prelude::*, theme_is_transparent, Scrollbar, ScrollbarState};
|
||||
use url::Url;
|
||||
use util::TryFutureExt;
|
||||
use workspace::Workspace;
|
||||
use workspace::{OpenOptions, OpenVisible, Workspace};
|
||||
pub const HOVER_REQUEST_DELAY_MILLIS: u64 = 200;
|
||||
|
||||
pub const MIN_POPOVER_CHARACTER_WIDTH: f32 = 20.;
|
||||
@@ -632,8 +632,15 @@ pub fn open_markdown_url(link: SharedString, window: &mut Window, cx: &mut App)
|
||||
if uri.scheme() == "file" {
|
||||
if let Some(workspace) = window.root::<Workspace>().flatten() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
let task =
|
||||
workspace.open_abs_path(PathBuf::from(uri.path()), false, window, cx);
|
||||
let task = workspace.open_abs_path(
|
||||
PathBuf::from(uri.path()),
|
||||
OpenOptions {
|
||||
visible: Some(OpenVisible::None),
|
||||
..Default::default()
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
cx.spawn_in(window, |_, mut cx| async move {
|
||||
let item = task.await?;
|
||||
|
||||
@@ -38,10 +38,14 @@ use text::{BufferId, Selection};
|
||||
use theme::{Theme, ThemeSettings};
|
||||
use ui::{prelude::*, IconDecorationKind};
|
||||
use util::{paths::PathExt, ResultExt, TryFutureExt};
|
||||
use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams};
|
||||
use workspace::{
|
||||
item::{BreadcrumbText, FollowEvent},
|
||||
searchable::SearchOptions,
|
||||
OpenVisible,
|
||||
};
|
||||
use workspace::{
|
||||
item::{Dedup, ItemSettings, SerializableItem, TabContentParams},
|
||||
OpenOptions,
|
||||
};
|
||||
use workspace::{
|
||||
item::{FollowableItem, Item, ItemEvent, ProjectItem},
|
||||
@@ -1157,7 +1161,15 @@ impl SerializableItem for Editor {
|
||||
}
|
||||
None => {
|
||||
let open_by_abs_path = workspace.update(cx, |workspace, cx| {
|
||||
workspace.open_abs_path(abs_path.clone(), false, window, cx)
|
||||
workspace.open_abs_path(
|
||||
abs_path.clone(),
|
||||
OpenOptions {
|
||||
visible: Some(OpenVisible::None),
|
||||
..Default::default()
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
window.spawn(cx, |mut cx| async move {
|
||||
let editor = open_by_abs_path?.await?.downcast::<Editor>().with_context(|| format!("Failed to downcast to Editor after opening abs path {abs_path:?}"))?;
|
||||
@@ -1589,11 +1601,13 @@ impl SearchableItem for Editor {
|
||||
|
||||
fn active_match_index(
|
||||
&mut self,
|
||||
direction: Direction,
|
||||
matches: &[Range<Anchor>],
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
active_match_index(
|
||||
direction,
|
||||
matches,
|
||||
&self.selections.newest_anchor().head(),
|
||||
&self.buffer().read(cx).snapshot(cx),
|
||||
@@ -1606,6 +1620,7 @@ impl SearchableItem for Editor {
|
||||
}
|
||||
|
||||
pub fn active_match_index(
|
||||
direction: Direction,
|
||||
ranges: &[Range<Anchor>],
|
||||
cursor: &Anchor,
|
||||
buffer: &MultiBufferSnapshot,
|
||||
@@ -1613,7 +1628,7 @@ pub fn active_match_index(
|
||||
if ranges.is_empty() {
|
||||
None
|
||||
} else {
|
||||
match ranges.binary_search_by(|probe| {
|
||||
let r = ranges.binary_search_by(|probe| {
|
||||
if probe.end.cmp(cursor, buffer).is_lt() {
|
||||
Ordering::Less
|
||||
} else if probe.start.cmp(cursor, buffer).is_gt() {
|
||||
@@ -1621,8 +1636,15 @@ pub fn active_match_index(
|
||||
} else {
|
||||
Ordering::Equal
|
||||
}
|
||||
}) {
|
||||
Ok(i) | Err(i) => Some(cmp::min(i, ranges.len() - 1)),
|
||||
});
|
||||
match direction {
|
||||
Direction::Prev => match r {
|
||||
Ok(i) => Some(i),
|
||||
Err(i) => Some(i.saturating_sub(1)),
|
||||
},
|
||||
Direction::Next => match r {
|
||||
Ok(i) | Err(i) => Some(cmp::min(i, ranges.len() - 1)),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
616
crates/editor/src/jsx_tag_auto_close.rs
Normal file
616
crates/editor/src/jsx_tag_auto_close.rs
Normal file
@@ -0,0 +1,616 @@
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use collections::HashMap;
|
||||
use gpui::{Context, Entity, Window};
|
||||
use multi_buffer::{MultiBuffer, ToOffset};
|
||||
use std::ops::Range;
|
||||
use util::ResultExt as _;
|
||||
|
||||
use language::{BufferSnapshot, JsxTagAutoCloseConfig, Node};
|
||||
use text::{Anchor, OffsetRangeExt as _};
|
||||
|
||||
use crate::Editor;
|
||||
|
||||
pub struct JsxTagCompletionState {
|
||||
edit_index: usize,
|
||||
open_tag_range: Range<usize>,
|
||||
}
|
||||
|
||||
/// Index of the named child within an open or close tag
|
||||
/// that corresponds to the tag name
|
||||
/// Note that this is not configurable, i.e. we assume the first
|
||||
/// named child of a tag node is the tag name
|
||||
const TS_NODE_TAG_NAME_CHILD_INDEX: usize = 0;
|
||||
|
||||
/// Maximum number of parent elements to walk back when checking if an open tag
|
||||
/// is already closed.
|
||||
///
|
||||
/// See the comment in `generate_auto_close_edits` for more details
|
||||
const ALREADY_CLOSED_PARENT_ELEMENT_WALK_BACK_LIMIT: usize = 2;
|
||||
|
||||
pub(crate) fn should_auto_close(
|
||||
buffer: &BufferSnapshot,
|
||||
edited_ranges: &[Range<usize>],
|
||||
config: &JsxTagAutoCloseConfig,
|
||||
) -> Option<Vec<JsxTagCompletionState>> {
|
||||
let mut to_auto_edit = vec![];
|
||||
for (index, edited_range) in edited_ranges.iter().enumerate() {
|
||||
let text = buffer
|
||||
.text_for_range(edited_range.clone())
|
||||
.collect::<String>();
|
||||
if !text.ends_with(">") {
|
||||
continue;
|
||||
}
|
||||
let Some(layer) = buffer.smallest_syntax_layer_containing(edited_range.clone()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(node) = layer
|
||||
.node()
|
||||
.named_descendant_for_byte_range(edited_range.start, edited_range.end)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut jsx_open_tag_node = node;
|
||||
if node.grammar_name() != config.open_tag_node_name {
|
||||
if let Some(parent) = node.parent() {
|
||||
if parent.grammar_name() == config.open_tag_node_name {
|
||||
jsx_open_tag_node = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
if jsx_open_tag_node.grammar_name() != config.open_tag_node_name {
|
||||
continue;
|
||||
}
|
||||
|
||||
let first_two_chars: Option<[char; 2]> = {
|
||||
let mut chars = buffer
|
||||
.text_for_range(jsx_open_tag_node.byte_range())
|
||||
.flat_map(|chunk| chunk.chars());
|
||||
if let (Some(c1), Some(c2)) = (chars.next(), chars.next()) {
|
||||
Some([c1, c2])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(chars) = first_two_chars {
|
||||
if chars[0] != '<' {
|
||||
continue;
|
||||
}
|
||||
if chars[1] == '!' || chars[1] == '/' {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
to_auto_edit.push(JsxTagCompletionState {
|
||||
edit_index: index,
|
||||
open_tag_range: jsx_open_tag_node.byte_range(),
|
||||
});
|
||||
}
|
||||
if to_auto_edit.is_empty() {
|
||||
return None;
|
||||
} else {
|
||||
return Some(to_auto_edit);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn generate_auto_close_edits(
|
||||
buffer: &BufferSnapshot,
|
||||
ranges: &[Range<usize>],
|
||||
config: &JsxTagAutoCloseConfig,
|
||||
state: Vec<JsxTagCompletionState>,
|
||||
) -> Result<Vec<(Range<Anchor>, String)>> {
|
||||
let mut edits = Vec::with_capacity(state.len());
|
||||
for auto_edit in state {
|
||||
let edited_range = ranges[auto_edit.edit_index].clone();
|
||||
let Some(layer) = buffer.smallest_syntax_layer_containing(edited_range.clone()) else {
|
||||
continue;
|
||||
};
|
||||
let layer_root_node = layer.node();
|
||||
let Some(open_tag) = layer_root_node.descendant_for_byte_range(
|
||||
auto_edit.open_tag_range.start,
|
||||
auto_edit.open_tag_range.end,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
assert!(open_tag.kind() == config.open_tag_node_name);
|
||||
let tag_name = open_tag
|
||||
.named_child(TS_NODE_TAG_NAME_CHILD_INDEX)
|
||||
.filter(|node| node.kind() == config.tag_name_node_name)
|
||||
.map_or("".to_string(), |node| {
|
||||
buffer.text_for_range(node.byte_range()).collect::<String>()
|
||||
});
|
||||
|
||||
/*
|
||||
* Naive check to see if the tag is already closed
|
||||
* Essentially all we do is count the number of open and close tags
|
||||
* with the same tag name as the open tag just entered by the user
|
||||
* The search is limited to some scope determined by
|
||||
* `ALREADY_CLOSED_PARENT_ELEMENT_WALK_BACK_LIMIT`
|
||||
*
|
||||
* The limit is preferable to walking up the tree until we find a non-tag node,
|
||||
* and then checking the entire tree, as this is unnecessarily expensive, and
|
||||
* risks false positives
|
||||
* eg. a `</div>` tag without a corresponding opening tag exists 25 lines away
|
||||
* and the user typed in `<div>`, intuitively we still want to auto-close it because
|
||||
* the other `</div>` tag is almost certainly not supposed to be the closing tag for the
|
||||
* current element
|
||||
*
|
||||
* We have to walk up the tree some amount because tree-sitters error correction is not
|
||||
* designed to handle this case, and usually does not represent the tree structure
|
||||
* in the way we might expect,
|
||||
*
|
||||
* We half to walk up the tree until we hit an element with a different open tag name (`doing_deep_search == true`)
|
||||
* because tree-sitter may pair the new open tag with the root of the tree's closing tag leaving the
|
||||
* root's opening tag unclosed.
|
||||
* e.g
|
||||
* ```
|
||||
* <div>
|
||||
* <div>|cursor here|
|
||||
* </div>
|
||||
* ```
|
||||
* in Astro/vue/svelte tree-sitter represented the tree as
|
||||
* (
|
||||
* (jsx_element
|
||||
* (jsx_opening_element
|
||||
* "<div>")
|
||||
* )
|
||||
* (jsx_element
|
||||
* (jsx_opening_element
|
||||
* "<div>") // <- cursor is here
|
||||
* (jsx_closing_element
|
||||
* "</div>")
|
||||
* )
|
||||
* )
|
||||
* so if we only walked to the first `jsx_element` node,
|
||||
* we would mistakenly identify the div entered by the
|
||||
* user as already being closed, despite this clearly
|
||||
* being false
|
||||
*
|
||||
* The errors with the tree-sitter tree caused by error correction,
|
||||
* are also why the naive algorithm was chosen, as the alternative
|
||||
* approach would be to maintain or construct a full parse tree (like tree-sitter)
|
||||
* that better represents errors in a way that we can simply check
|
||||
* the enclosing scope of the entered tag for a closing tag
|
||||
* This is far more complex and expensive, and was deemed impractical
|
||||
* given that the naive algorithm is sufficient in the majority of cases.
|
||||
*/
|
||||
{
|
||||
let tag_node_name_equals = |node: &Node, tag_name_node_name: &str, name: &str| {
|
||||
let is_empty = name.len() == 0;
|
||||
if let Some(node_name) = node.named_child(TS_NODE_TAG_NAME_CHILD_INDEX) {
|
||||
if node_name.kind() != tag_name_node_name {
|
||||
return is_empty;
|
||||
}
|
||||
let range = node_name.byte_range();
|
||||
return buffer.text_for_range(range).equals_str(name);
|
||||
}
|
||||
return is_empty;
|
||||
};
|
||||
|
||||
let tree_root_node = {
|
||||
let mut ancestors = Vec::with_capacity(
|
||||
// estimate of max, not based on any data,
|
||||
// but trying to avoid excessive reallocation
|
||||
16,
|
||||
);
|
||||
ancestors.push(layer_root_node);
|
||||
let mut cur = layer_root_node;
|
||||
// walk down the tree until we hit the open tag
|
||||
// note: this is what node.parent() does internally
|
||||
while let Some(descendant) = cur.child_with_descendant(open_tag) {
|
||||
if descendant == open_tag {
|
||||
break;
|
||||
}
|
||||
ancestors.push(descendant);
|
||||
cur = descendant;
|
||||
}
|
||||
|
||||
assert!(ancestors.len() > 0);
|
||||
|
||||
let mut tree_root_node = open_tag;
|
||||
|
||||
let mut parent_element_node_count = 0;
|
||||
let mut doing_deep_search = false;
|
||||
|
||||
for &ancestor in ancestors.iter().rev() {
|
||||
tree_root_node = ancestor;
|
||||
let is_element = ancestor.kind() == config.jsx_element_node_name;
|
||||
let is_error = ancestor.is_error();
|
||||
if is_error || !is_element {
|
||||
break;
|
||||
}
|
||||
if is_element {
|
||||
let is_first = parent_element_node_count == 0;
|
||||
if !is_first {
|
||||
let has_open_tag_with_same_tag_name = ancestor
|
||||
.named_child(0)
|
||||
.filter(|n| n.kind() == config.open_tag_node_name)
|
||||
.map_or(false, |element_open_tag_node| {
|
||||
tag_node_name_equals(
|
||||
&element_open_tag_node,
|
||||
&config.tag_name_node_name,
|
||||
&tag_name,
|
||||
)
|
||||
});
|
||||
if has_open_tag_with_same_tag_name {
|
||||
doing_deep_search = true;
|
||||
} else if doing_deep_search {
|
||||
break;
|
||||
}
|
||||
}
|
||||
parent_element_node_count += 1;
|
||||
if !doing_deep_search
|
||||
&& parent_element_node_count
|
||||
>= ALREADY_CLOSED_PARENT_ELEMENT_WALK_BACK_LIMIT
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
tree_root_node
|
||||
};
|
||||
|
||||
let mut unclosed_open_tag_count: i32 = 0;
|
||||
|
||||
let mut cursor = layer_root_node.walk();
|
||||
|
||||
let mut stack = Vec::with_capacity(tree_root_node.descendant_count());
|
||||
stack.extend(tree_root_node.children(&mut cursor));
|
||||
|
||||
let mut has_erroneous_close_tag = false;
|
||||
let mut erroneous_close_tag_node_name = "";
|
||||
let mut erroneous_close_tag_name_node_name = "";
|
||||
if let Some(name) = config.erroneous_close_tag_node_name.as_deref() {
|
||||
has_erroneous_close_tag = true;
|
||||
erroneous_close_tag_node_name = name;
|
||||
erroneous_close_tag_name_node_name = config
|
||||
.erroneous_close_tag_name_node_name
|
||||
.as_deref()
|
||||
.unwrap_or(&config.tag_name_node_name);
|
||||
}
|
||||
|
||||
let is_after_open_tag = |node: &Node| {
|
||||
return node.start_byte() < open_tag.start_byte()
|
||||
&& node.end_byte() < open_tag.start_byte();
|
||||
};
|
||||
|
||||
// perf: use cursor for more efficient traversal
|
||||
// if child -> go to child
|
||||
// else if next sibling -> go to next sibling
|
||||
// else -> go to parent
|
||||
// if parent == tree_root_node -> break
|
||||
while let Some(node) = stack.pop() {
|
||||
let kind = node.kind();
|
||||
if kind == config.open_tag_node_name {
|
||||
if tag_node_name_equals(&node, &config.tag_name_node_name, &tag_name) {
|
||||
unclosed_open_tag_count += 1;
|
||||
}
|
||||
} else if kind == config.close_tag_node_name {
|
||||
if tag_node_name_equals(&node, &config.tag_name_node_name, &tag_name) {
|
||||
unclosed_open_tag_count -= 1;
|
||||
}
|
||||
} else if has_erroneous_close_tag && kind == erroneous_close_tag_node_name {
|
||||
if tag_node_name_equals(&node, erroneous_close_tag_name_node_name, &tag_name) {
|
||||
if !is_after_open_tag(&node) {
|
||||
unclosed_open_tag_count -= 1;
|
||||
}
|
||||
}
|
||||
} else if kind == config.jsx_element_node_name {
|
||||
// perf: filter only open,close,element,erroneous nodes
|
||||
stack.extend(node.children(&mut cursor));
|
||||
}
|
||||
}
|
||||
|
||||
if unclosed_open_tag_count <= 0 {
|
||||
// skip if already closed
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let edit_anchor = buffer.anchor_after(edited_range.end);
|
||||
let edit_range = edit_anchor..edit_anchor;
|
||||
edits.push((edit_range, format!("</{}>", tag_name)));
|
||||
}
|
||||
return Ok(edits);
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_enabled_in_any_buffer(
|
||||
editor: &mut Editor,
|
||||
multi_buffer: &Entity<MultiBuffer>,
|
||||
cx: &Context<Editor>,
|
||||
) {
|
||||
editor.jsx_tag_auto_close_enabled_in_any_buffer = {
|
||||
let multi_buffer = multi_buffer.read(cx);
|
||||
let mut found_enabled = false;
|
||||
multi_buffer.for_each_buffer(|buffer| {
|
||||
let buffer = buffer.read(cx);
|
||||
let snapshot = buffer.snapshot();
|
||||
for syntax_layer in snapshot.syntax_layers() {
|
||||
let language = syntax_layer.language;
|
||||
if language.config().jsx_tag_auto_close.is_none() {
|
||||
continue;
|
||||
}
|
||||
let language_settings = language::language_settings::language_settings(
|
||||
Some(language.name()),
|
||||
snapshot.file(),
|
||||
cx,
|
||||
);
|
||||
if language_settings.jsx_tag_auto_close.enabled {
|
||||
found_enabled = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
found_enabled
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) type InitialBufferVersionsMap = HashMap<language::BufferId, clock::Global>;
|
||||
|
||||
pub(crate) fn construct_initial_buffer_versions_map<
|
||||
D: ToOffset + Copy,
|
||||
_S: Into<std::sync::Arc<str>>,
|
||||
>(
|
||||
editor: &Editor,
|
||||
edits: &[(Range<D>, _S)],
|
||||
cx: &Context<Editor>,
|
||||
) -> InitialBufferVersionsMap {
|
||||
let mut initial_buffer_versions = InitialBufferVersionsMap::default();
|
||||
|
||||
if !editor.jsx_tag_auto_close_enabled_in_any_buffer {
|
||||
return initial_buffer_versions;
|
||||
}
|
||||
|
||||
for (edit_range, _) in edits {
|
||||
let edit_range_buffer = editor
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.excerpt_containing(edit_range.end, cx)
|
||||
.map(|e| e.1);
|
||||
if let Some(buffer) = edit_range_buffer {
|
||||
let (buffer_id, buffer_version) =
|
||||
buffer.read_with(cx, |buffer, _| (buffer.remote_id(), buffer.version.clone()));
|
||||
initial_buffer_versions.insert(buffer_id, buffer_version);
|
||||
}
|
||||
}
|
||||
return initial_buffer_versions;
|
||||
}
|
||||
|
||||
pub(crate) fn handle_from(
|
||||
editor: &Editor,
|
||||
initial_buffer_versions: InitialBufferVersionsMap,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Editor>,
|
||||
) {
|
||||
if !editor.jsx_tag_auto_close_enabled_in_any_buffer {
|
||||
return;
|
||||
}
|
||||
|
||||
struct JsxAutoCloseEditContext {
|
||||
buffer: Entity<language::Buffer>,
|
||||
config: language::JsxTagAutoCloseConfig,
|
||||
edits: Vec<Range<usize>>,
|
||||
}
|
||||
|
||||
let mut edit_contexts =
|
||||
HashMap::<(language::BufferId, language::LanguageId), JsxAutoCloseEditContext>::default();
|
||||
|
||||
for (buffer_id, buffer_version_initial) in initial_buffer_versions {
|
||||
let Some(buffer) = editor.buffer.read(cx).buffer(buffer_id) else {
|
||||
continue;
|
||||
};
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
for edit in buffer.read(cx).edits_since(&buffer_version_initial) {
|
||||
let Some(language) = snapshot.language_at(edit.new.end) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(config) = language.config().jsx_tag_auto_close.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let language_settings = snapshot.settings_at(edit.new.end, cx);
|
||||
if !language_settings.jsx_tag_auto_close.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
edit_contexts
|
||||
.entry((snapshot.remote_id(), language.id()))
|
||||
.or_insert_with(|| JsxAutoCloseEditContext {
|
||||
buffer: buffer.clone(),
|
||||
config: config.clone(),
|
||||
edits: vec![],
|
||||
})
|
||||
.edits
|
||||
.push(edit.new);
|
||||
}
|
||||
}
|
||||
|
||||
for ((buffer_id, _), auto_close_context) in edit_contexts {
|
||||
let JsxAutoCloseEditContext {
|
||||
buffer,
|
||||
config: jsx_tag_auto_close_config,
|
||||
edits: edited_ranges,
|
||||
} = auto_close_context;
|
||||
|
||||
let (buffer_version_initial, mut buffer_parse_status_rx) =
|
||||
buffer.read_with(cx, |buffer, _| (buffer.version(), buffer.parse_status()));
|
||||
|
||||
cx.spawn_in(window, |this, mut cx| async move {
|
||||
let Some(buffer_parse_status) = buffer_parse_status_rx.recv().await.ok() else {
|
||||
return Some(());
|
||||
};
|
||||
if buffer_parse_status == language::ParseStatus::Parsing {
|
||||
let Some(language::ParseStatus::Idle) = buffer_parse_status_rx.recv().await.ok()
|
||||
else {
|
||||
return Some(());
|
||||
};
|
||||
}
|
||||
|
||||
let buffer_snapshot = buffer.read_with(&cx, |buf, _| buf.snapshot()).ok()?;
|
||||
|
||||
let Some(edit_behavior_state) =
|
||||
should_auto_close(&buffer_snapshot, &edited_ranges, &jsx_tag_auto_close_config)
|
||||
else {
|
||||
return Some(());
|
||||
};
|
||||
|
||||
let ensure_no_edits_since_start = || -> Option<()> {
|
||||
// <div>wef,wefwef
|
||||
let has_edits_since_start = this
|
||||
.read_with(&cx, |this, cx| {
|
||||
this.buffer.read_with(cx, |buffer, cx| {
|
||||
buffer.buffer(buffer_id).map_or(true, |buffer| {
|
||||
buffer.read_with(cx, |buffer, _| {
|
||||
buffer.has_edits_since(&buffer_version_initial)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
if has_edits_since_start {
|
||||
Err(anyhow!(
|
||||
"Auto-close Operation Failed - Buffer has edits since start"
|
||||
))
|
||||
.log_err()?;
|
||||
}
|
||||
|
||||
Some(())
|
||||
};
|
||||
|
||||
ensure_no_edits_since_start()?;
|
||||
|
||||
let edits = cx
|
||||
.background_executor()
|
||||
.spawn({
|
||||
let buffer_snapshot = buffer_snapshot.clone();
|
||||
async move {
|
||||
generate_auto_close_edits(
|
||||
&buffer_snapshot,
|
||||
&edited_ranges,
|
||||
&jsx_tag_auto_close_config,
|
||||
edit_behavior_state,
|
||||
)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let edits = edits
|
||||
.context("Auto-close Operation Failed - Failed to compute edits")
|
||||
.log_err()?;
|
||||
|
||||
if edits.is_empty() {
|
||||
return Some(());
|
||||
}
|
||||
|
||||
// check again after awaiting background task before applying edits
|
||||
ensure_no_edits_since_start()?;
|
||||
|
||||
let multi_buffer_snapshot = this
|
||||
.read_with(&cx, |this, cx| {
|
||||
this.buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx))
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
let mut base_selections = Vec::new();
|
||||
let mut buffer_selection_map = HashMap::default();
|
||||
|
||||
{
|
||||
let selections = this
|
||||
.read_with(&cx, |this, _| this.selections.disjoint_anchors().clone())
|
||||
.ok()?;
|
||||
for selection in selections.iter() {
|
||||
let Some(selection_buffer_offset_head) =
|
||||
multi_buffer_snapshot.point_to_buffer_offset(selection.head())
|
||||
else {
|
||||
base_selections.push(selection.clone());
|
||||
continue;
|
||||
};
|
||||
let Some(selection_buffer_offset_tail) =
|
||||
multi_buffer_snapshot.point_to_buffer_offset(selection.tail())
|
||||
else {
|
||||
base_selections.push(selection.clone());
|
||||
continue;
|
||||
};
|
||||
|
||||
let is_entirely_in_buffer = selection_buffer_offset_head.0.remote_id()
|
||||
== buffer_id
|
||||
&& selection_buffer_offset_tail.0.remote_id() == buffer_id;
|
||||
if !is_entirely_in_buffer {
|
||||
base_selections.push(selection.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
let selection_buffer_offset_head = selection_buffer_offset_head.1;
|
||||
let selection_buffer_offset_tail = selection_buffer_offset_tail.1;
|
||||
buffer_selection_map.insert(
|
||||
(selection_buffer_offset_head, selection_buffer_offset_tail),
|
||||
(selection.clone(), None),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut any_selections_need_update = false;
|
||||
for edit in &edits {
|
||||
let edit_range_offset = edit.0.to_offset(&buffer_snapshot);
|
||||
if edit_range_offset.start != edit_range_offset.end {
|
||||
continue;
|
||||
}
|
||||
if let Some(selection) =
|
||||
buffer_selection_map.get_mut(&(edit_range_offset.start, edit_range_offset.end))
|
||||
{
|
||||
if selection.0.head().bias() != text::Bias::Right
|
||||
|| selection.0.tail().bias() != text::Bias::Right
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if selection.1.is_none() {
|
||||
any_selections_need_update = true;
|
||||
selection.1 = Some(
|
||||
selection
|
||||
.0
|
||||
.clone()
|
||||
.map(|anchor| multi_buffer_snapshot.anchor_before(anchor)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
.update(&mut cx, |buffer, cx| {
|
||||
buffer.edit(edits, None, cx);
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
if any_selections_need_update {
|
||||
let multi_buffer_snapshot = this
|
||||
.read_with(&cx, |this, cx| {
|
||||
this.buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx))
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
base_selections.extend(buffer_selection_map.values().map(|selection| {
|
||||
match &selection.1 {
|
||||
Some(left_biased_selection) => left_biased_selection.clone(),
|
||||
None => selection.0.clone(),
|
||||
}
|
||||
}));
|
||||
|
||||
let base_selections = base_selections
|
||||
.into_iter()
|
||||
.map(|selection| {
|
||||
selection.map(|anchor| anchor.to_offset(&multi_buffer_snapshot))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
this.update_in(&mut cx, |this, window, cx| {
|
||||
this.change_selections_inner(None, false, window, cx, |s| {
|
||||
s.select(base_selections);
|
||||
});
|
||||
})
|
||||
.ok()?;
|
||||
}
|
||||
|
||||
Some(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use anyhow::Context as _;
|
||||
use gpui::{App, AppContext as _, Context, Entity, Window};
|
||||
use language::{Capability, Language};
|
||||
use multi_buffer::MultiBuffer;
|
||||
use project::lsp_ext_command::ExpandMacro;
|
||||
use project::lsp_store::{lsp_ext_command::ExpandMacro, rust_analyzer_ext::RUST_ANALYZER_NAME};
|
||||
use text::ToPointUtf16;
|
||||
|
||||
use crate::{
|
||||
@@ -12,8 +12,6 @@ use crate::{
|
||||
ExpandMacroRecursively, OpenDocs,
|
||||
};
|
||||
|
||||
const RUST_ANALYZER_NAME: &str = "rust-analyzer";
|
||||
|
||||
fn is_rust_language(language: &Language) -> bool {
|
||||
language.name() == "Rust".into()
|
||||
}
|
||||
@@ -131,7 +129,7 @@ pub fn open_docs(editor: &mut Editor, _: &OpenDocs, window: &mut Window, cx: &mu
|
||||
project.request_lsp(
|
||||
buffer,
|
||||
project::LanguageServerToQuery::Other(server_to_query),
|
||||
project::lsp_ext_command::OpenDocs { position },
|
||||
project::lsp_store::lsp_ext_command::OpenDocs { position },
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ use gpui::{
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use language::{Buffer, BufferSnapshot, LanguageRegistry};
|
||||
use multi_buffer::{ExcerptRange, MultiBufferRow};
|
||||
use multi_buffer::{Anchor, ExcerptRange, MultiBufferRow};
|
||||
use parking_lot::RwLock;
|
||||
use project::{FakeFs, Project};
|
||||
use std::{
|
||||
@@ -399,7 +399,7 @@ impl EditorTestContext {
|
||||
.split("[EXCERPT]\n")
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let (selections, excerpts) = self.update_editor(|editor, _, cx| {
|
||||
let (multibuffer_snapshot, selections, excerpts) = self.update_editor(|editor, _, cx| {
|
||||
let multibuffer_snapshot = editor.buffer.read(cx).snapshot(cx);
|
||||
|
||||
let selections = editor.selections.disjoint_anchors();
|
||||
@@ -408,10 +408,15 @@ impl EditorTestContext {
|
||||
.map(|(e_id, snapshot, range)| (e_id, snapshot.clone(), range))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
(selections, excerpts)
|
||||
(multibuffer_snapshot, selections, excerpts)
|
||||
});
|
||||
|
||||
assert_eq!(excerpts.len(), expected_excerpts.len());
|
||||
assert!(
|
||||
excerpts.len() == expected_excerpts.len(),
|
||||
"should have {} excerpts, got {}",
|
||||
expected_excerpts.len(),
|
||||
excerpts.len()
|
||||
);
|
||||
|
||||
for (ix, (excerpt_id, snapshot, range)) in excerpts.into_iter().enumerate() {
|
||||
let is_folded = self
|
||||
@@ -435,8 +440,12 @@ impl EditorTestContext {
|
||||
}
|
||||
assert!(!is_folded, "excerpt {} should not be folded", ix);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.text_for_range(range.context.clone())
|
||||
multibuffer_snapshot
|
||||
.text_for_range(Anchor::range_in_buffer(
|
||||
excerpt_id,
|
||||
snapshot.remote_id(),
|
||||
range.context.clone()
|
||||
))
|
||||
.collect::<String>(),
|
||||
expected_text
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use anyhow::{anyhow, bail, Context as _, Result};
|
||||
use collections::{BTreeMap, HashMap};
|
||||
use fs::Fs;
|
||||
use language::LanguageName;
|
||||
@@ -85,6 +85,61 @@ pub struct ExtensionManifest {
|
||||
pub indexed_docs_providers: BTreeMap<Arc<str>, IndexedDocsProviderEntry>,
|
||||
#[serde(default)]
|
||||
pub snippets: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<ExtensionCapability>,
|
||||
}
|
||||
|
||||
impl ExtensionManifest {
|
||||
pub fn allow_exec(
|
||||
&self,
|
||||
desired_command: &str,
|
||||
desired_args: &[impl AsRef<str> + std::fmt::Debug],
|
||||
) -> Result<()> {
|
||||
let is_allowed = self.capabilities.iter().any(|capability| match capability {
|
||||
ExtensionCapability::ProcessExec { command, args } if command == desired_command => {
|
||||
for (ix, arg) in args.iter().enumerate() {
|
||||
if arg == "**" {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ix >= desired_args.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if arg != "*" && arg != desired_args[ix].as_ref() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if args.len() < desired_args.len() {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
|
||||
if !is_allowed {
|
||||
bail!(
|
||||
"capability for process:exec {desired_command} {desired_args:?} was not listed in the extension manifest",
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A capability for an extension.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum ExtensionCapability {
|
||||
#[serde(rename = "process:exec")]
|
||||
ProcessExec {
|
||||
/// The command to execute.
|
||||
command: String,
|
||||
/// The arguments to pass to the command. Use `*` for a single wildcard argument.
|
||||
/// If the last element is `**`, then any trailing arguments are allowed.
|
||||
args: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
|
||||
@@ -218,5 +273,104 @@ fn manifest_from_old_manifest(
|
||||
slash_commands: BTreeMap::default(),
|
||||
indexed_docs_providers: BTreeMap::default(),
|
||||
snippets: None,
|
||||
capabilities: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn extension_manifest() -> ExtensionManifest {
|
||||
ExtensionManifest {
|
||||
id: "test".into(),
|
||||
name: "Test".to_string(),
|
||||
version: "1.0.0".into(),
|
||||
schema_version: SchemaVersion::ZERO,
|
||||
description: None,
|
||||
repository: None,
|
||||
authors: vec![],
|
||||
lib: Default::default(),
|
||||
themes: vec![],
|
||||
icon_themes: vec![],
|
||||
languages: vec![],
|
||||
grammars: BTreeMap::default(),
|
||||
language_servers: BTreeMap::default(),
|
||||
context_servers: BTreeMap::default(),
|
||||
slash_commands: BTreeMap::default(),
|
||||
indexed_docs_providers: BTreeMap::default(),
|
||||
snippets: None,
|
||||
capabilities: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_exact_match() {
|
||||
let manifest = ExtensionManifest {
|
||||
capabilities: vec![ExtensionCapability::ProcessExec {
|
||||
command: "ls".to_string(),
|
||||
args: vec!["-la".to_string()],
|
||||
}],
|
||||
..extension_manifest()
|
||||
};
|
||||
|
||||
assert!(manifest.allow_exec("ls", &["-la"]).is_ok());
|
||||
assert!(manifest.allow_exec("ls", &["-l"]).is_err());
|
||||
assert!(manifest.allow_exec("pwd", &[] as &[&str]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_wildcard_arg() {
|
||||
let manifest = ExtensionManifest {
|
||||
capabilities: vec![ExtensionCapability::ProcessExec {
|
||||
command: "git".to_string(),
|
||||
args: vec!["*".to_string()],
|
||||
}],
|
||||
..extension_manifest()
|
||||
};
|
||||
|
||||
assert!(manifest.allow_exec("git", &["status"]).is_ok());
|
||||
assert!(manifest.allow_exec("git", &["commit"]).is_ok());
|
||||
assert!(manifest.allow_exec("git", &["status", "-s"]).is_err()); // too many args
|
||||
assert!(manifest.allow_exec("npm", &["install"]).is_err()); // wrong command
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_double_wildcard() {
|
||||
let manifest = ExtensionManifest {
|
||||
capabilities: vec![ExtensionCapability::ProcessExec {
|
||||
command: "cargo".to_string(),
|
||||
args: vec!["test".to_string(), "**".to_string()],
|
||||
}],
|
||||
..extension_manifest()
|
||||
};
|
||||
|
||||
assert!(manifest.allow_exec("cargo", &["test"]).is_ok());
|
||||
assert!(manifest.allow_exec("cargo", &["test", "--all"]).is_ok());
|
||||
assert!(manifest
|
||||
.allow_exec("cargo", &["test", "--all", "--no-fail-fast"])
|
||||
.is_ok());
|
||||
assert!(manifest.allow_exec("cargo", &["build"]).is_err()); // wrong first arg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_mixed_wildcards() {
|
||||
let manifest = ExtensionManifest {
|
||||
capabilities: vec![ExtensionCapability::ProcessExec {
|
||||
command: "docker".to_string(),
|
||||
args: vec!["run".to_string(), "*".to_string(), "**".to_string()],
|
||||
}],
|
||||
..extension_manifest()
|
||||
};
|
||||
|
||||
assert!(manifest.allow_exec("docker", &["run", "nginx"]).is_ok());
|
||||
assert!(manifest.allow_exec("docker", &["run"]).is_err());
|
||||
assert!(manifest
|
||||
.allow_exec("docker", &["run", "ubuntu", "bash"])
|
||||
.is_ok());
|
||||
assert!(manifest
|
||||
.allow_exec("docker", &["run", "alpine", "sh", "-c", "echo hello"])
|
||||
.is_ok());
|
||||
assert!(manifest.allow_exec("docker", &["ps"]).is_err()); // wrong first arg
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
slash_commands: BTreeMap::default(),
|
||||
indexed_docs_providers: BTreeMap::default(),
|
||||
snippets: None,
|
||||
capabilities: Vec::new(),
|
||||
}),
|
||||
dev: false,
|
||||
},
|
||||
@@ -191,6 +192,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
slash_commands: BTreeMap::default(),
|
||||
indexed_docs_providers: BTreeMap::default(),
|
||||
snippets: None,
|
||||
capabilities: Vec::new(),
|
||||
}),
|
||||
dev: false,
|
||||
},
|
||||
@@ -356,6 +358,7 @@ async fn test_extension_store(cx: &mut TestAppContext) {
|
||||
slash_commands: BTreeMap::default(),
|
||||
indexed_docs_providers: BTreeMap::default(),
|
||||
snippets: None,
|
||||
capabilities: Vec::new(),
|
||||
}),
|
||||
dev: false,
|
||||
},
|
||||
|
||||
@@ -592,6 +592,8 @@ impl process::Host for WasmState {
|
||||
command: process::Command,
|
||||
) -> wasmtime::Result<Result<process::Output, String>> {
|
||||
maybe!(async {
|
||||
self.manifest.allow_exec(&command.command, &command.args)?;
|
||||
|
||||
let output = util::command::new_smol_command(command.command.as_str())
|
||||
.args(&command.args)
|
||||
.envs(command.env)
|
||||
|
||||
@@ -40,7 +40,7 @@ impl RenderOnce for ExtensionCard {
|
||||
.bg(cx.theme().colors().elevated_surface_background)
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.children(self.children)
|
||||
.when(self.overridden_by_dev_extension, |card| {
|
||||
card.child(
|
||||
|
||||
@@ -632,7 +632,7 @@ impl ExtensionsPage {
|
||||
.px_0p5()
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.child(
|
||||
Label::new(label).size(LabelSize::XSmall),
|
||||
)
|
||||
|
||||
@@ -470,7 +470,7 @@ impl Render for FeedbackModal {
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.p_2()
|
||||
.border_1()
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.child(self.feedback_editor.clone()),
|
||||
)
|
||||
@@ -482,7 +482,7 @@ impl Render for FeedbackModal {
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.p_2()
|
||||
.border_1()
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.border_color(if self.valid_email_address() {
|
||||
cx.theme().colors().border
|
||||
} else {
|
||||
|
||||
@@ -42,8 +42,8 @@ use ui::{
|
||||
};
|
||||
use util::{maybe, paths::PathWithPosition, post_inc, ResultExt};
|
||||
use workspace::{
|
||||
item::PreviewTabsSettings, notifications::NotifyResultExt, pane, ModalView, SplitDirection,
|
||||
Workspace,
|
||||
item::PreviewTabsSettings, notifications::NotifyResultExt, pane, ModalView, OpenOptions,
|
||||
OpenVisible, SplitDirection, Workspace,
|
||||
};
|
||||
|
||||
actions!(file_finder, [SelectPrevious, ToggleMenu]);
|
||||
@@ -1239,7 +1239,10 @@ impl PickerDelegate for FileFinderDelegate {
|
||||
} else {
|
||||
workspace.open_abs_path(
|
||||
abs_path.to_path_buf(),
|
||||
false,
|
||||
OpenOptions {
|
||||
visible: Some(OpenVisible::None),
|
||||
..Default::default()
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ use menu::{Confirm, SelectNext, SelectPrevious};
|
||||
use project::{RemoveOptions, FS_WATCH_LATENCY};
|
||||
use serde_json::json;
|
||||
use util::path;
|
||||
use workspace::{AppState, ToggleFileFinder, Workspace};
|
||||
use workspace::{AppState, OpenOptions, ToggleFileFinder, Workspace};
|
||||
|
||||
#[ctor::ctor]
|
||||
fn init_logger() {
|
||||
@@ -951,7 +951,10 @@ async fn test_external_files_history(cx: &mut gpui::TestAppContext) {
|
||||
.update_in(cx, |workspace, window, cx| {
|
||||
workspace.open_abs_path(
|
||||
PathBuf::from(path!("/external-src/test/third.rs")),
|
||||
false,
|
||||
OpenOptions {
|
||||
visible: Some(OpenVisible::None),
|
||||
..Default::default()
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
|
||||
@@ -1448,6 +1448,12 @@ impl FakeFs {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_error_message_for_index_write(&self, dot_git: &Path, message: Option<String>) {
|
||||
self.with_git_state(dot_git, true, |state| {
|
||||
state.simulated_index_write_error_message = message;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn paths(&self, include_dot_git: bool) -> Vec<PathBuf> {
|
||||
let mut result = Vec::new();
|
||||
let mut queue = collections::VecDeque::new();
|
||||
|
||||
@@ -16,6 +16,7 @@ test-support = []
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
askpass.workspace = true
|
||||
async-trait.workspace = true
|
||||
collections.workspace = true
|
||||
derive_more.workspace = true
|
||||
@@ -34,7 +35,7 @@ text.workspace = true
|
||||
time.workspace = true
|
||||
url.workspace = true
|
||||
util.workspace = true
|
||||
tempfile.workspace = true
|
||||
futures.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions.workspace = true
|
||||
|
||||
@@ -8,9 +8,6 @@ pub mod status;
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use gpui::action_with_deprecated_aliases;
|
||||
use gpui::actions;
|
||||
use gpui::impl_actions;
|
||||
use repository::PushOptions;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt;
|
||||
@@ -31,13 +28,6 @@ pub static COMMIT_MESSAGE: LazyLock<&'static OsStr> =
|
||||
LazyLock::new(|| OsStr::new("COMMIT_EDITMSG"));
|
||||
pub static INDEX_LOCK: LazyLock<&'static OsStr> = LazyLock::new(|| OsStr::new("index.lock"));
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Deserialize, JsonSchema)]
|
||||
pub struct Push {
|
||||
pub options: Option<PushOptions>,
|
||||
}
|
||||
|
||||
impl_actions!(git, [Push]);
|
||||
|
||||
actions!(
|
||||
git,
|
||||
[
|
||||
@@ -54,10 +44,13 @@ actions!(
|
||||
RestoreTrackedFiles,
|
||||
TrashUntrackedFiles,
|
||||
Uncommit,
|
||||
Push,
|
||||
ForcePush,
|
||||
Pull,
|
||||
Fetch,
|
||||
Commit,
|
||||
ShowCommitEditor,
|
||||
ExpandCommitEditor,
|
||||
GenerateCommitMessage
|
||||
]
|
||||
);
|
||||
action_with_deprecated_aliases!(git, RestoreFile, ["editor::RevertFile"]);
|
||||
|
||||
@@ -2,7 +2,9 @@ use crate::status::FileStatus;
|
||||
use crate::GitHostingProviderRegistry;
|
||||
use crate::{blame::Blame, status::GitStatus};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use askpass::{AskPassResult, AskPassSession};
|
||||
use collections::{HashMap, HashSet};
|
||||
use futures::{select_biased, FutureExt as _};
|
||||
use git2::BranchType;
|
||||
use gpui::SharedString;
|
||||
use parking_lot::Mutex;
|
||||
@@ -11,8 +13,6 @@ use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use std::borrow::Borrow;
|
||||
use std::io::Write as _;
|
||||
#[cfg(not(windows))]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process::Stdio;
|
||||
use std::sync::LazyLock;
|
||||
use std::{
|
||||
@@ -21,9 +21,11 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
use sum_tree::MapSeekTarget;
|
||||
use util::command::new_std_command;
|
||||
use util::command::{new_smol_command, new_std_command};
|
||||
use util::ResultExt;
|
||||
|
||||
pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user";
|
||||
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||
pub struct Branch {
|
||||
pub is_head: bool,
|
||||
@@ -106,6 +108,7 @@ pub struct CommitSummary {
|
||||
pub subject: SharedString,
|
||||
/// This is a unix timestamp
|
||||
pub commit_timestamp: i64,
|
||||
pub has_parent: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||
@@ -199,10 +202,29 @@ pub trait GitRepository: Send + Sync {
|
||||
branch_name: &str,
|
||||
upstream_name: &str,
|
||||
options: Option<PushOptions>,
|
||||
askpass: AskPassSession,
|
||||
) -> Result<RemoteCommandOutput>;
|
||||
fn pull(&self, branch_name: &str, upstream_name: &str) -> Result<RemoteCommandOutput>;
|
||||
|
||||
fn pull(
|
||||
&self,
|
||||
branch_name: &str,
|
||||
upstream_name: &str,
|
||||
askpass: AskPassSession,
|
||||
) -> Result<RemoteCommandOutput>;
|
||||
fn fetch(&self, askpass: AskPassSession) -> Result<RemoteCommandOutput>;
|
||||
|
||||
fn get_remotes(&self, branch_name: Option<&str>) -> Result<Vec<Remote>>;
|
||||
fn fetch(&self) -> Result<RemoteCommandOutput>;
|
||||
|
||||
/// returns a list of remote branches that contain HEAD
|
||||
fn check_for_pushed_commit(&self) -> Result<Vec<SharedString>>;
|
||||
|
||||
/// Run git diff
|
||||
fn diff(&self, diff: DiffType) -> Result<String>;
|
||||
}
|
||||
|
||||
pub enum DiffType {
|
||||
HeadToIndex,
|
||||
HeadToWorktree,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
|
||||
@@ -427,6 +449,15 @@ impl GitRepository for RealGitRepository {
|
||||
true
|
||||
})
|
||||
.ok();
|
||||
if let Some(oid) = self
|
||||
.repository
|
||||
.lock()
|
||||
.find_reference("CHERRY_PICK_HEAD")
|
||||
.ok()
|
||||
.and_then(|reference| reference.target())
|
||||
{
|
||||
shas.push(oid.to_string())
|
||||
}
|
||||
shas
|
||||
}
|
||||
|
||||
@@ -462,6 +493,7 @@ impl GitRepository for RealGitRepository {
|
||||
let fields = [
|
||||
"%(HEAD)",
|
||||
"%(objectname)",
|
||||
"%(parent)",
|
||||
"%(refname)",
|
||||
"%(upstream)",
|
||||
"%(upstream:track)",
|
||||
@@ -553,6 +585,28 @@ impl GitRepository for RealGitRepository {
|
||||
)
|
||||
}
|
||||
|
||||
fn diff(&self, diff: DiffType) -> Result<String> {
|
||||
let working_directory = self.working_directory()?;
|
||||
let args = match diff {
|
||||
DiffType::HeadToIndex => Some("--staged"),
|
||||
DiffType::HeadToWorktree => None,
|
||||
};
|
||||
|
||||
let output = new_std_command(&self.git_binary_path)
|
||||
.current_dir(&working_directory)
|
||||
.args(["diff"])
|
||||
.args(args)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to run git diff:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
fn stage_paths(&self, paths: &[RepoPath]) -> Result<()> {
|
||||
let working_directory = self.working_directory()?;
|
||||
|
||||
@@ -563,7 +617,6 @@ impl GitRepository for RealGitRepository {
|
||||
.args(paths.iter().map(|p| p.as_ref()))
|
||||
.output()?;
|
||||
|
||||
// TODO: Get remote response out of this and show it to the user
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to stage paths:\n{}",
|
||||
@@ -584,7 +637,6 @@ impl GitRepository for RealGitRepository {
|
||||
.args(paths.iter().map(|p| p.as_ref()))
|
||||
.output()?;
|
||||
|
||||
// TODO: Get remote response out of this and show it to the user
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to unstage:\n{}",
|
||||
@@ -610,7 +662,6 @@ impl GitRepository for RealGitRepository {
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
// TODO: Get remote response out of this and show it to the user
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to commit:\n{}",
|
||||
@@ -625,15 +676,15 @@ impl GitRepository for RealGitRepository {
|
||||
branch_name: &str,
|
||||
remote_name: &str,
|
||||
options: Option<PushOptions>,
|
||||
ask_pass: AskPassSession,
|
||||
) -> Result<RemoteCommandOutput> {
|
||||
let working_directory = self.working_directory()?;
|
||||
|
||||
// We do this on every operation to ensure that the askpass script exists and is executable.
|
||||
#[cfg(not(windows))]
|
||||
let (askpass_script_path, _temp_dir) = setup_askpass()?;
|
||||
|
||||
let mut command = new_std_command("git");
|
||||
let mut command = new_smol_command("git");
|
||||
command
|
||||
.env("GIT_ASKPASS", ask_pass.script_path())
|
||||
.env("SSH_ASKPASS", ask_pass.script_path())
|
||||
.env("SSH_ASKPASS_REQUIRE", "force")
|
||||
.current_dir(&working_directory)
|
||||
.args(["push"])
|
||||
.args(options.map(|option| match option {
|
||||
@@ -642,91 +693,46 @@ impl GitRepository for RealGitRepository {
|
||||
}))
|
||||
.arg(remote_name)
|
||||
.arg(format!("{}:{}", branch_name, branch_name));
|
||||
let git_process = command.spawn()?;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
command.env("GIT_ASKPASS", askpass_script_path);
|
||||
}
|
||||
|
||||
let output = command.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to push:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
} else {
|
||||
return Ok(RemoteCommandOutput {
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
});
|
||||
}
|
||||
run_remote_command(ask_pass, git_process)
|
||||
}
|
||||
|
||||
fn pull(&self, branch_name: &str, remote_name: &str) -> Result<RemoteCommandOutput> {
|
||||
fn pull(
|
||||
&self,
|
||||
branch_name: &str,
|
||||
remote_name: &str,
|
||||
ask_pass: AskPassSession,
|
||||
) -> Result<RemoteCommandOutput> {
|
||||
let working_directory = self.working_directory()?;
|
||||
|
||||
// We do this on every operation to ensure that the askpass script exists and is executable.
|
||||
#[cfg(not(windows))]
|
||||
let (askpass_script_path, _temp_dir) = setup_askpass()?;
|
||||
|
||||
let mut command = new_std_command("git");
|
||||
let mut command = new_smol_command("git");
|
||||
command
|
||||
.env("GIT_ASKPASS", ask_pass.script_path())
|
||||
.env("SSH_ASKPASS", ask_pass.script_path())
|
||||
.env("SSH_ASKPASS_REQUIRE", "force")
|
||||
.current_dir(&working_directory)
|
||||
.args(["pull"])
|
||||
.arg(remote_name)
|
||||
.arg(branch_name);
|
||||
let git_process = command.spawn()?;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
command.env("GIT_ASKPASS", askpass_script_path);
|
||||
}
|
||||
|
||||
let output = command.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to pull:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
} else {
|
||||
return Ok(RemoteCommandOutput {
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
});
|
||||
}
|
||||
run_remote_command(ask_pass, git_process)
|
||||
}
|
||||
|
||||
fn fetch(&self) -> Result<RemoteCommandOutput> {
|
||||
fn fetch(&self, ask_pass: AskPassSession) -> Result<RemoteCommandOutput> {
|
||||
let working_directory = self.working_directory()?;
|
||||
|
||||
// We do this on every operation to ensure that the askpass script exists and is executable.
|
||||
#[cfg(not(windows))]
|
||||
let (askpass_script_path, _temp_dir) = setup_askpass()?;
|
||||
|
||||
let mut command = new_std_command("git");
|
||||
let mut command = new_smol_command("git");
|
||||
command
|
||||
.env("GIT_ASKPASS", ask_pass.script_path())
|
||||
.env("SSH_ASKPASS", ask_pass.script_path())
|
||||
.env("SSH_ASKPASS_REQUIRE", "force")
|
||||
.current_dir(&working_directory)
|
||||
.args(["fetch", "--all"]);
|
||||
let git_process = command.spawn()?;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
command.env("GIT_ASKPASS", askpass_script_path);
|
||||
}
|
||||
|
||||
let output = command.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to fetch:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
} else {
|
||||
return Ok(RemoteCommandOutput {
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
});
|
||||
}
|
||||
run_remote_command(ask_pass, git_process)
|
||||
}
|
||||
|
||||
fn get_remotes(&self, branch_name: Option<&str>) -> Result<Vec<Remote>> {
|
||||
@@ -770,18 +776,88 @@ impl GitRepository for RealGitRepository {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn check_for_pushed_commit(&self) -> Result<Vec<SharedString>> {
|
||||
let working_directory = self.working_directory()?;
|
||||
let git_cmd = |args: &[&str]| -> Result<String> {
|
||||
let output = new_std_command(&self.git_binary_path)
|
||||
.current_dir(&working_directory)
|
||||
.args(args)
|
||||
.output()?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8(output.stdout)?)
|
||||
} else {
|
||||
Err(anyhow!(String::from_utf8_lossy(&output.stderr).to_string()))
|
||||
}
|
||||
};
|
||||
|
||||
let head = git_cmd(&["rev-parse", "HEAD"])
|
||||
.context("Failed to get HEAD")?
|
||||
.trim()
|
||||
.to_owned();
|
||||
|
||||
let mut remote_branches = vec![];
|
||||
let mut add_if_matching = |remote_head: &str| {
|
||||
if let Ok(merge_base) = git_cmd(&["merge-base", &head, remote_head]) {
|
||||
if merge_base.trim() == head {
|
||||
if let Some(s) = remote_head.strip_prefix("refs/remotes/") {
|
||||
remote_branches.push(s.to_owned().into());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// check the main branch of each remote
|
||||
let remotes = git_cmd(&["remote"]).context("Failed to get remotes")?;
|
||||
for remote in remotes.lines() {
|
||||
if let Ok(remote_head) =
|
||||
git_cmd(&["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")])
|
||||
{
|
||||
add_if_matching(remote_head.trim());
|
||||
}
|
||||
}
|
||||
|
||||
// ... and the remote branch that the checked-out one is tracking
|
||||
if let Ok(remote_head) = git_cmd(&["rev-parse", "--symbolic-full-name", "@{u}"]) {
|
||||
add_if_matching(remote_head.trim());
|
||||
}
|
||||
|
||||
Ok(remote_branches)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn setup_askpass() -> Result<(PathBuf, tempfile::TempDir), anyhow::Error> {
|
||||
let temp_dir = tempfile::Builder::new()
|
||||
.prefix("zed-git-askpass")
|
||||
.tempdir()?;
|
||||
let askpass_script = "#!/bin/sh\necho ''";
|
||||
let askpass_script_path = temp_dir.path().join("git-askpass.sh");
|
||||
std::fs::write(&askpass_script_path, askpass_script)?;
|
||||
std::fs::set_permissions(&askpass_script_path, std::fs::Permissions::from_mode(0o755))?;
|
||||
Ok((askpass_script_path, temp_dir))
|
||||
fn run_remote_command(
|
||||
mut ask_pass: AskPassSession,
|
||||
git_process: smol::process::Child,
|
||||
) -> std::result::Result<RemoteCommandOutput, anyhow::Error> {
|
||||
smol::block_on(async {
|
||||
select_biased! {
|
||||
result = ask_pass.run().fuse() => {
|
||||
match result {
|
||||
AskPassResult::CancelledByUser => {
|
||||
Err(anyhow!(REMOTE_CANCELLED_BY_USER))?
|
||||
}
|
||||
AskPassResult::Timedout => {
|
||||
Err(anyhow!("Connecting to host timed out"))?
|
||||
}
|
||||
}
|
||||
}
|
||||
output = git_process.output().fuse() => {
|
||||
let output = output?;
|
||||
if !output.status.success() {
|
||||
Err(anyhow!(
|
||||
"Operation failed:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
))
|
||||
} else {
|
||||
Ok(RemoteCommandOutput {
|
||||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -799,6 +875,7 @@ pub struct FakeGitRepositoryState {
|
||||
pub statuses: HashMap<RepoPath, FileStatus>,
|
||||
pub current_branch_name: Option<String>,
|
||||
pub branches: HashSet<String>,
|
||||
pub simulated_index_write_error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl FakeGitRepository {
|
||||
@@ -818,6 +895,7 @@ impl FakeGitRepositoryState {
|
||||
statuses: Default::default(),
|
||||
current_branch_name: Default::default(),
|
||||
branches: Default::default(),
|
||||
simulated_index_write_error_message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -837,6 +915,9 @@ impl GitRepository for FakeGitRepository {
|
||||
|
||||
fn set_index_text(&self, path: &RepoPath, content: Option<String>) -> anyhow::Result<()> {
|
||||
let mut state = self.state.lock();
|
||||
if let Some(message) = state.simulated_index_write_error_message.clone() {
|
||||
return Err(anyhow::anyhow!(message));
|
||||
}
|
||||
if let Some(content) = content {
|
||||
state.index_contents.insert(path.clone(), content);
|
||||
} else {
|
||||
@@ -972,21 +1053,35 @@ impl GitRepository for FakeGitRepository {
|
||||
_branch: &str,
|
||||
_remote: &str,
|
||||
_options: Option<PushOptions>,
|
||||
_ask_pass: AskPassSession,
|
||||
) -> Result<RemoteCommandOutput> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn pull(&self, _branch: &str, _remote: &str) -> Result<RemoteCommandOutput> {
|
||||
fn pull(
|
||||
&self,
|
||||
_branch: &str,
|
||||
_remote: &str,
|
||||
_ask_pass: AskPassSession,
|
||||
) -> Result<RemoteCommandOutput> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn fetch(&self) -> Result<RemoteCommandOutput> {
|
||||
fn fetch(&self, _ask_pass: AskPassSession) -> Result<RemoteCommandOutput> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn get_remotes(&self, _branch: Option<&str>) -> Result<Vec<Remote>> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn check_for_pushed_commit(&self) -> Result<Vec<SharedString>> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn diff(&self, _diff: DiffType) -> Result<String> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
|
||||
@@ -1117,6 +1212,7 @@ fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
|
||||
let mut fields = line.split('\x00');
|
||||
let is_current_branch = fields.next().context("no HEAD")? == "*";
|
||||
let head_sha: SharedString = fields.next().context("no objectname")?.to_string().into();
|
||||
let parent_sha: SharedString = fields.next().context("no parent")?.to_string().into();
|
||||
let ref_name: SharedString = fields
|
||||
.next()
|
||||
.context("no refname")?
|
||||
@@ -1140,6 +1236,7 @@ fn parse_branch_input(input: &str) -> Result<Vec<Branch>> {
|
||||
sha: head_sha,
|
||||
subject,
|
||||
commit_timestamp: commiterdate,
|
||||
has_parent: !parent_sha.is_empty(),
|
||||
}),
|
||||
upstream: if upstream_name.is_empty() {
|
||||
None
|
||||
@@ -1192,7 +1289,7 @@ fn parse_upstream_track(upstream_track: &str) -> Result<UpstreamTracking> {
|
||||
fn test_branches_parsing() {
|
||||
// suppress "help: octal escapes are not supported, `\0` is always null"
|
||||
#[allow(clippy::octal_escapes)]
|
||||
let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
|
||||
let input = "*\0060964da10574cd9bf06463a53bf6e0769c5c45e\0\0refs/heads/zed-patches\0refs/remotes/origin/zed-patches\0\01733187470\0generated protobuf\n";
|
||||
assert_eq!(
|
||||
parse_branch_input(&input).unwrap(),
|
||||
vec![Branch {
|
||||
@@ -1209,6 +1306,7 @@ fn test_branches_parsing() {
|
||||
sha: "060964da10574cd9bf06463a53bf6e0769c5c45e".into(),
|
||||
subject: "generated protobuf".into(),
|
||||
commit_timestamp: 1733187470,
|
||||
has_parent: false,
|
||||
})
|
||||
}]
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ test-support = ["multi_buffer/test-support"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
askpass.workspace= true
|
||||
buffer_diff.workspace = true
|
||||
collections.workspace = true
|
||||
component.workspace = true
|
||||
@@ -30,6 +31,7 @@ git.workspace = true
|
||||
gpui.workspace = true
|
||||
itertools.workspace = true
|
||||
language.workspace = true
|
||||
language_model.workspace = true
|
||||
linkify.workspace = true
|
||||
linkme.workspace = true
|
||||
log.workspace = true
|
||||
@@ -46,6 +48,7 @@ serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
smallvec.workspace = true
|
||||
strum.workspace = true
|
||||
telemetry.workspace = true
|
||||
theme.workspace = true
|
||||
time.workspace = true
|
||||
ui.workspace = true
|
||||
@@ -61,6 +64,7 @@ ctor.workspace = true
|
||||
env_logger.workspace = true
|
||||
editor = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
pretty_assertions.workspace = true
|
||||
project = { workspace = true, features = ["test-support"] }
|
||||
settings = { workspace = true, features = ["test-support"] }
|
||||
unindent.workspace = true
|
||||
|
||||
101
crates/git_ui/src/askpass_modal.rs
Normal file
101
crates/git_ui/src/askpass_modal.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use editor::Editor;
|
||||
use futures::channel::oneshot;
|
||||
use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Styled};
|
||||
use ui::{
|
||||
div, h_flex, v_flex, ActiveTheme, App, Context, DynamicSpacing, Headline, HeadlineSize, Icon,
|
||||
IconName, IconSize, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
StyledExt, StyledTypography, Window,
|
||||
};
|
||||
use workspace::ModalView;
|
||||
|
||||
pub(crate) struct AskPassModal {
|
||||
operation: SharedString,
|
||||
prompt: SharedString,
|
||||
editor: Entity<Editor>,
|
||||
tx: Option<oneshot::Sender<String>>,
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for AskPassModal {}
|
||||
impl ModalView for AskPassModal {}
|
||||
impl Focusable for AskPassModal {
|
||||
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
|
||||
self.editor.focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl AskPassModal {
|
||||
pub fn new(
|
||||
operation: SharedString,
|
||||
prompt: SharedString,
|
||||
tx: oneshot::Sender<String>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let editor = cx.new(|cx| {
|
||||
let mut editor = Editor::single_line(window, cx);
|
||||
if prompt.contains("yes/no") {
|
||||
editor.set_masked(false, cx);
|
||||
} else {
|
||||
editor.set_masked(true, cx);
|
||||
}
|
||||
editor
|
||||
});
|
||||
Self {
|
||||
operation,
|
||||
prompt,
|
||||
editor,
|
||||
tx: Some(tx),
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(tx) = self.tx.take() {
|
||||
tx.send(self.editor.read(cx).text(cx)).ok();
|
||||
}
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AskPassModal {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.key_context("PasswordPrompt")
|
||||
.on_action(cx.listener(Self::cancel))
|
||||
.on_action(cx.listener(Self::confirm))
|
||||
.elevation_2(cx)
|
||||
.size_full()
|
||||
.font_buffer(cx)
|
||||
.child(
|
||||
h_flex()
|
||||
.px(DynamicSpacing::Base12.rems(cx))
|
||||
.pt(DynamicSpacing::Base08.rems(cx))
|
||||
.pb(DynamicSpacing::Base04.rems(cx))
|
||||
.rounded_t_sm()
|
||||
.w_full()
|
||||
.gap_1p5()
|
||||
.child(Icon::new(IconName::GitBranch).size(IconSize::XSmall))
|
||||
.child(h_flex().gap_1().overflow_x_hidden().child(
|
||||
div().max_w_96().overflow_x_hidden().text_ellipsis().child(
|
||||
Headline::new(self.operation.clone()).size(HeadlineSize::XSmall),
|
||||
),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_buffer(cx)
|
||||
.py_2()
|
||||
.px_3()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.child(self.prompt.clone())
|
||||
.child(self.editor.clone()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use anyhow::{anyhow, Context as _};
|
||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
|
||||
use git::repository::Branch;
|
||||
use gpui::{
|
||||
rems, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
rems, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
||||
Task, Window,
|
||||
};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use project::{Project, ProjectPath};
|
||||
use project::git::Repository;
|
||||
use std::sync::Arc;
|
||||
use ui::{
|
||||
prelude::*, HighlightedLabel, ListItem, ListItemSpacing, PopoverMenuHandle, TriggerablePopover,
|
||||
};
|
||||
use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing, PopoverMenuHandle};
|
||||
use util::ResultExt;
|
||||
use workspace::notifications::DetachAndPromptErr;
|
||||
use workspace::{ModalView, Workspace};
|
||||
@@ -30,36 +28,21 @@ pub fn open(
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
let project = workspace.project().clone();
|
||||
let this = cx.entity();
|
||||
let repository = workspace.project().read(cx).active_repository(cx).clone();
|
||||
let style = BranchListStyle::Modal;
|
||||
cx.spawn_in(window, |_, mut cx| async move {
|
||||
// Modal branch picker has a longer trailoff than a popover one.
|
||||
let delegate = BranchListDelegate::new(project.clone(), style, 70, &cx).await?;
|
||||
|
||||
this.update_in(&mut cx, move |workspace, window, cx| {
|
||||
workspace.toggle_modal(window, cx, |window, cx| {
|
||||
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
|
||||
let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
|
||||
cx.emit(DismissEvent);
|
||||
});
|
||||
|
||||
let mut list = BranchList::new(project, style, 34., cx);
|
||||
list._subscription = Some(_subscription);
|
||||
list.picker = Some(picker);
|
||||
list
|
||||
})
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
workspace.toggle_modal(window, cx, |window, cx| {
|
||||
BranchList::new(repository, style, 34., window, cx)
|
||||
})
|
||||
.detach_and_prompt_err("Failed to read branches", window, cx, |_, _, _| None)
|
||||
}
|
||||
|
||||
pub fn popover(project: Entity<Project>, window: &mut Window, cx: &mut App) -> Entity<BranchList> {
|
||||
pub fn popover(
|
||||
repository: Option<Entity<Repository>>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<BranchList> {
|
||||
cx.new(|cx| {
|
||||
let mut list = BranchList::new(project, BranchListStyle::Popover, 15., cx);
|
||||
list.reload_branches(window, cx);
|
||||
let list = BranchList::new(repository, BranchListStyle::Popover, 15., window, cx);
|
||||
list.focus_handle(cx).focus(window);
|
||||
list
|
||||
})
|
||||
}
|
||||
@@ -72,59 +55,53 @@ enum BranchListStyle {
|
||||
|
||||
pub struct BranchList {
|
||||
rem_width: f32,
|
||||
popover_handle: PopoverMenuHandle<Self>,
|
||||
default_focus_handle: FocusHandle,
|
||||
project: Entity<Project>,
|
||||
style: BranchListStyle,
|
||||
pub picker: Option<Entity<Picker<BranchListDelegate>>>,
|
||||
_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl TriggerablePopover for BranchList {
|
||||
fn menu_handle(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut gpui::Context<Self>,
|
||||
) -> PopoverMenuHandle<Self> {
|
||||
self.popover_handle.clone()
|
||||
}
|
||||
pub popover_handle: PopoverMenuHandle<Self>,
|
||||
pub picker: Entity<Picker<BranchListDelegate>>,
|
||||
_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl BranchList {
|
||||
fn new(project: Entity<Project>, style: BranchListStyle, rem_width: f32, cx: &mut App) -> Self {
|
||||
fn new(
|
||||
repository: Option<Entity<Repository>>,
|
||||
style: BranchListStyle,
|
||||
rem_width: f32,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let popover_handle = PopoverMenuHandle::default();
|
||||
Self {
|
||||
project,
|
||||
picker: None,
|
||||
rem_width,
|
||||
popover_handle,
|
||||
default_focus_handle: cx.focus_handle(),
|
||||
style,
|
||||
_subscription: None,
|
||||
}
|
||||
}
|
||||
let all_branches_request = repository
|
||||
.clone()
|
||||
.map(|repository| repository.read(cx).branches());
|
||||
|
||||
fn reload_branches(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let project = self.project.clone();
|
||||
let style = self.style;
|
||||
cx.spawn_in(window, |this, mut cx| async move {
|
||||
let delegate = BranchListDelegate::new(project, style, 20, &cx).await?;
|
||||
let picker =
|
||||
cx.new_window_entity(|window, cx| Picker::uniform_list(delegate, window, cx))?;
|
||||
let all_branches = all_branches_request
|
||||
.context("No active repository")?
|
||||
.await??;
|
||||
|
||||
this.update(&mut cx, |branch_list, cx| {
|
||||
let subscription =
|
||||
cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| cx.emit(DismissEvent));
|
||||
|
||||
branch_list.picker = Some(picker);
|
||||
branch_list._subscription = Some(subscription);
|
||||
|
||||
cx.notify();
|
||||
this.update_in(&mut cx, |this, window, cx| {
|
||||
this.picker.update(cx, |picker, cx| {
|
||||
picker.delegate.all_branches = Some(all_branches);
|
||||
picker.refresh(window, cx);
|
||||
})
|
||||
})?;
|
||||
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
let delegate = BranchListDelegate::new(repository.clone(), style, 20);
|
||||
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
|
||||
|
||||
let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
|
||||
cx.emit(DismissEvent);
|
||||
});
|
||||
|
||||
Self {
|
||||
picker,
|
||||
rem_width,
|
||||
popover_handle,
|
||||
_subscription,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ModalView for BranchList {}
|
||||
@@ -132,10 +109,7 @@ impl EventEmitter<DismissEvent> for BranchList {}
|
||||
|
||||
impl Focusable for BranchList {
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
self.picker
|
||||
.as_ref()
|
||||
.map(|picker| picker.focus_handle(cx))
|
||||
.unwrap_or_else(|| self.default_focus_handle.clone())
|
||||
self.picker.focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,24 +117,13 @@ impl Render for BranchList {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.w(rems(self.rem_width))
|
||||
.map(|parent| match self.picker.as_ref() {
|
||||
Some(picker) => parent.child(picker.clone()).on_mouse_down_out({
|
||||
let picker = picker.clone();
|
||||
cx.listener(move |_, _, window, cx| {
|
||||
picker.update(cx, |this, cx| {
|
||||
this.cancel(&Default::default(), window, cx);
|
||||
})
|
||||
.child(self.picker.clone())
|
||||
.on_mouse_down_out({
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.picker.update(cx, |this, cx| {
|
||||
this.cancel(&Default::default(), window, cx);
|
||||
})
|
||||
}),
|
||||
None => parent.child(
|
||||
h_flex()
|
||||
.id("branch-picker-error")
|
||||
.on_click(
|
||||
cx.listener(|this, _, window, cx| this.reload_branches(window, cx)),
|
||||
)
|
||||
.child("Could not load branches.")
|
||||
.child("Click to retry"),
|
||||
),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -184,8 +147,8 @@ impl BranchEntry {
|
||||
|
||||
pub struct BranchListDelegate {
|
||||
matches: Vec<BranchEntry>,
|
||||
all_branches: Vec<Branch>,
|
||||
project: Entity<Project>,
|
||||
all_branches: Option<Vec<Branch>>,
|
||||
repo: Option<Entity<Repository>>,
|
||||
style: BranchListStyle,
|
||||
selected_index: usize,
|
||||
last_query: String,
|
||||
@@ -194,33 +157,20 @@ pub struct BranchListDelegate {
|
||||
}
|
||||
|
||||
impl BranchListDelegate {
|
||||
async fn new(
|
||||
project: Entity<Project>,
|
||||
fn new(
|
||||
repo: Option<Entity<Repository>>,
|
||||
style: BranchListStyle,
|
||||
branch_name_trailoff_after: usize,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<Self> {
|
||||
let all_branches_request = cx.update(|cx| {
|
||||
let project = project.read(cx);
|
||||
let first_worktree = project
|
||||
.visible_worktrees(cx)
|
||||
.next()
|
||||
.context("No worktrees found")?;
|
||||
let project_path = ProjectPath::root_path(first_worktree.read(cx).id());
|
||||
anyhow::Ok(project.branches(project_path, cx))
|
||||
})??;
|
||||
|
||||
let all_branches = all_branches_request.await?;
|
||||
|
||||
Ok(Self {
|
||||
) -> Self {
|
||||
Self {
|
||||
matches: vec![],
|
||||
project,
|
||||
repo,
|
||||
style,
|
||||
all_branches,
|
||||
all_branches: None,
|
||||
selected_index: 0,
|
||||
last_query: Default::default(),
|
||||
branch_name_trailoff_after,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn branch_count(&self) -> usize {
|
||||
@@ -261,32 +211,31 @@ impl PickerDelegate for BranchListDelegate {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Task<()> {
|
||||
let Some(mut all_branches) = self.all_branches.clone() else {
|
||||
return Task::ready(());
|
||||
};
|
||||
|
||||
cx.spawn_in(window, move |picker, mut cx| async move {
|
||||
let candidates = picker.update(&mut cx, |picker, _| {
|
||||
const RECENT_BRANCHES_COUNT: usize = 10;
|
||||
let mut branches = picker.delegate.all_branches.clone();
|
||||
if query.is_empty() {
|
||||
if branches.len() > RECENT_BRANCHES_COUNT {
|
||||
// Truncate list of recent branches
|
||||
// Do a partial sort to show recent-ish branches first.
|
||||
branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
|
||||
rhs.priority_key().cmp(&lhs.priority_key())
|
||||
});
|
||||
branches.truncate(RECENT_BRANCHES_COUNT);
|
||||
}
|
||||
branches.sort_unstable_by(|lhs, rhs| {
|
||||
rhs.is_head.cmp(&lhs.is_head).then(lhs.name.cmp(&rhs.name))
|
||||
const RECENT_BRANCHES_COUNT: usize = 10;
|
||||
if query.is_empty() {
|
||||
if all_branches.len() > RECENT_BRANCHES_COUNT {
|
||||
// Truncate list of recent branches
|
||||
// Do a partial sort to show recent-ish branches first.
|
||||
all_branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
|
||||
rhs.priority_key().cmp(&lhs.priority_key())
|
||||
});
|
||||
all_branches.truncate(RECENT_BRANCHES_COUNT);
|
||||
}
|
||||
branches
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
|
||||
.collect::<Vec<StringMatchCandidate>>()
|
||||
});
|
||||
let Some(candidates) = candidates.log_err() else {
|
||||
return;
|
||||
};
|
||||
all_branches.sort_unstable_by(|lhs, rhs| {
|
||||
rhs.is_head.cmp(&lhs.is_head).then(lhs.name.cmp(&rhs.name))
|
||||
});
|
||||
}
|
||||
|
||||
let candidates = all_branches
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
|
||||
.collect::<Vec<StringMatchCandidate>>();
|
||||
let matches: Vec<BranchEntry> = if query.is_empty() {
|
||||
candidates
|
||||
.into_iter()
|
||||
@@ -334,14 +283,16 @@ impl PickerDelegate for BranchListDelegate {
|
||||
return;
|
||||
};
|
||||
|
||||
let current_branch = self.project.update(cx, |project, cx| {
|
||||
project
|
||||
.active_repository(cx)
|
||||
.and_then(|repo| repo.read(cx).current_branch())
|
||||
.map(|branch| branch.name.to_string())
|
||||
let current_branch = self.repo.as_ref().map(|repo| {
|
||||
repo.update(cx, |repo, _| {
|
||||
repo.current_branch().map(|branch| branch.name.clone())
|
||||
})
|
||||
});
|
||||
|
||||
if current_branch == Some(branch.name().to_string()) {
|
||||
if current_branch
|
||||
.flatten()
|
||||
.is_some_and(|current_branch| current_branch == branch.name())
|
||||
{
|
||||
cx.emit(DismissEvent);
|
||||
return;
|
||||
}
|
||||
@@ -350,19 +301,33 @@ impl PickerDelegate for BranchListDelegate {
|
||||
let branch = branch.clone();
|
||||
|picker, mut cx| async move {
|
||||
let branch_change_task = picker.update(&mut cx, |this, cx| {
|
||||
let project = this.delegate.project.read(cx);
|
||||
let branch_to_checkout = match branch {
|
||||
BranchEntry::Branch(branch) => branch.string,
|
||||
BranchEntry::History(string) => string,
|
||||
BranchEntry::NewBranch { name: branch_name } => branch_name,
|
||||
};
|
||||
let worktree = project
|
||||
.visible_worktrees(cx)
|
||||
.next()
|
||||
.context("worktree disappeared")?;
|
||||
let repository = ProjectPath::root_path(worktree.read(cx).id());
|
||||
let repo = this
|
||||
.delegate
|
||||
.repo
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("No active repository"))?
|
||||
.clone();
|
||||
|
||||
anyhow::Ok(project.update_or_create_branch(repository, branch_to_checkout, cx))
|
||||
let cx = cx.to_async();
|
||||
|
||||
anyhow::Ok(async move {
|
||||
match branch {
|
||||
BranchEntry::Branch(StringMatch {
|
||||
string: branch_name,
|
||||
..
|
||||
})
|
||||
| BranchEntry::History(branch_name) => {
|
||||
cx.update(|cx| repo.read(cx).change_branch(branch_name))?
|
||||
.await?
|
||||
}
|
||||
BranchEntry::NewBranch { name: branch_name } => {
|
||||
cx.update(|cx| repo.read(cx).create_branch(branch_name.clone()))?
|
||||
.await??;
|
||||
cx.update(|cx| repo.read(cx).change_branch(branch_name))?
|
||||
.await?
|
||||
}
|
||||
}
|
||||
})
|
||||
})??;
|
||||
|
||||
branch_change_task.await?;
|
||||
@@ -370,7 +335,7 @@ impl PickerDelegate for BranchListDelegate {
|
||||
picker.update(&mut cx, |_, cx| {
|
||||
cx.emit(DismissEvent);
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
anyhow::Ok(())
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
17
crates/git_ui/src/commit_message_prompt.txt
Normal file
17
crates/git_ui/src/commit_message_prompt.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
You are an expert at writing Git commits. Your job is to write a short clear commit message that summarizes the changes.
|
||||
|
||||
If you can accurately express the change in just the subject line, don't include anything in the message body. Only use the body when it is providing *useful* information.
|
||||
|
||||
Don't repeat information from the subject line in the message body.
|
||||
|
||||
Only return the commit message in your response. Do not include any additional meta-commentary about the task.
|
||||
|
||||
Follow good Git style:
|
||||
|
||||
- Separate the subject from the body with a blank line
|
||||
- Try to limit the subject line to 50 characters
|
||||
- Capitalize the subject line
|
||||
- Do not end the subject line with any punctuation
|
||||
- Use the imperative mood in the subject line
|
||||
- Wrap the body at 72 characters
|
||||
- Keep the body short and concise (omit it entirely if not useful)
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
use crate::branch_picker::{self, BranchList};
|
||||
use crate::git_panel::{commit_message_editor, GitPanel};
|
||||
use git::{Commit, ShowCommitEditor};
|
||||
use git::Commit;
|
||||
use panel::{panel_button, panel_editor_style, panel_filled_button};
|
||||
use project::Project;
|
||||
use ui::{prelude::*, KeybindingHint, PopoverButton, Tooltip, TriggerablePopover};
|
||||
use ui::{prelude::*, KeybindingHint, PopoverMenu, Tooltip};
|
||||
|
||||
use editor::{Editor, EditorElement};
|
||||
use gpui::*;
|
||||
@@ -110,66 +109,59 @@ struct RestoreDock {
|
||||
|
||||
impl CommitModal {
|
||||
pub fn register(workspace: &mut Workspace, _: &mut Window, _cx: &mut Context<Workspace>) {
|
||||
workspace.register_action(|workspace, _: &ShowCommitEditor, window, cx| {
|
||||
let Some(git_panel) = workspace.panel::<GitPanel>(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (can_open_commit_editor, conflict) = git_panel.update(cx, |git_panel, cx| {
|
||||
let can_open_commit_editor = git_panel.can_open_commit_editor();
|
||||
let conflict = git_panel.has_unstaged_conflicts();
|
||||
if can_open_commit_editor {
|
||||
git_panel.set_modal_open(true, cx);
|
||||
}
|
||||
(can_open_commit_editor, conflict)
|
||||
});
|
||||
if !can_open_commit_editor {
|
||||
let message = if conflict {
|
||||
"There are still conflicts. You must stage these before committing."
|
||||
} else {
|
||||
"No changes to commit."
|
||||
};
|
||||
let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
|
||||
cx.spawn(|_, _| async move {
|
||||
prompt.await.ok();
|
||||
})
|
||||
.detach();
|
||||
return;
|
||||
}
|
||||
|
||||
let dock = workspace.dock_at_position(git_panel.position(window, cx));
|
||||
let is_open = dock.read(cx).is_open();
|
||||
let active_index = dock.read(cx).active_panel_index();
|
||||
let dock = dock.downgrade();
|
||||
let restore_dock_position = RestoreDock {
|
||||
dock,
|
||||
is_open,
|
||||
active_index,
|
||||
};
|
||||
|
||||
let project = workspace.project().clone();
|
||||
workspace.open_panel::<GitPanel>(window, cx);
|
||||
workspace.toggle_modal(window, cx, move |window, cx| {
|
||||
CommitModal::new(git_panel, restore_dock_position, project, window, cx)
|
||||
})
|
||||
workspace.register_action(|workspace, _: &Commit, window, cx| {
|
||||
CommitModal::toggle(workspace, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<'_, Workspace>) {
|
||||
let Some(git_panel) = workspace.panel::<GitPanel>(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
git_panel.update(cx, |git_panel, cx| {
|
||||
git_panel.set_modal_open(true, cx);
|
||||
});
|
||||
|
||||
let dock = workspace.dock_at_position(git_panel.position(window, cx));
|
||||
let is_open = dock.read(cx).is_open();
|
||||
let active_index = dock.read(cx).active_panel_index();
|
||||
let dock = dock.downgrade();
|
||||
let restore_dock_position = RestoreDock {
|
||||
dock,
|
||||
is_open,
|
||||
active_index,
|
||||
};
|
||||
|
||||
workspace.open_panel::<GitPanel>(window, cx);
|
||||
workspace.toggle_modal(window, cx, move |window, cx| {
|
||||
CommitModal::new(git_panel, restore_dock_position, window, cx)
|
||||
})
|
||||
}
|
||||
|
||||
fn new(
|
||||
git_panel: Entity<GitPanel>,
|
||||
restore_dock: RestoreDock,
|
||||
project: Entity<Project>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let panel = git_panel.read(cx);
|
||||
let active_repository = panel.active_repository.clone();
|
||||
let suggested_commit_message = panel.suggest_commit_message();
|
||||
|
||||
let commit_editor = git_panel.update(cx, |git_panel, cx| {
|
||||
git_panel.set_modal_open(true, cx);
|
||||
let buffer = git_panel.commit_message_buffer(cx).clone();
|
||||
let panel_editor = git_panel.commit_editor.clone();
|
||||
let project = git_panel.project.clone();
|
||||
cx.new(|cx| commit_message_editor(buffer, None, project.clone(), false, window, cx))
|
||||
|
||||
cx.new(|cx| {
|
||||
let mut editor =
|
||||
commit_message_editor(buffer, None, project.clone(), false, window, cx);
|
||||
editor.sync_selections(panel_editor, cx).detach();
|
||||
|
||||
editor
|
||||
})
|
||||
});
|
||||
|
||||
let commit_message = commit_editor.read(cx).text(cx);
|
||||
@@ -198,7 +190,7 @@ impl CommitModal {
|
||||
let properties = ModalContainerProperties::new(window, 50);
|
||||
|
||||
Self {
|
||||
branch_list: branch_picker::popover(project.clone(), window, cx),
|
||||
branch_list: branch_picker::popover(active_repository.clone(), window, cx),
|
||||
git_panel,
|
||||
commit_editor,
|
||||
restore_dock,
|
||||
@@ -242,7 +234,7 @@ impl CommitModal {
|
||||
pub fn render_footer(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let git_panel = self.git_panel.clone();
|
||||
|
||||
let (branch, can_commit, tooltip, commit_label, co_authors) =
|
||||
let (branch, can_commit, tooltip, commit_label, co_authors, generate_commit_message) =
|
||||
self.git_panel.update(cx, |git_panel, cx| {
|
||||
let branch = git_panel
|
||||
.active_repository
|
||||
@@ -257,7 +249,15 @@ impl CommitModal {
|
||||
let (can_commit, tooltip) = git_panel.configure_commit_button(cx);
|
||||
let title = git_panel.commit_button_title();
|
||||
let co_authors = git_panel.render_co_authors(cx);
|
||||
(branch, can_commit, tooltip, title, co_authors)
|
||||
let generate_commit_message = git_panel.render_generate_commit_message_button(cx);
|
||||
(
|
||||
branch,
|
||||
can_commit,
|
||||
tooltip,
|
||||
title,
|
||||
co_authors,
|
||||
generate_commit_message,
|
||||
)
|
||||
});
|
||||
|
||||
let branch_picker_button = panel_button(branch)
|
||||
@@ -275,12 +275,20 @@ impl CommitModal {
|
||||
}))
|
||||
.style(ButtonStyle::Transparent);
|
||||
|
||||
let branch_picker = PopoverButton::new(
|
||||
self.branch_list.clone(),
|
||||
Corner::BottomLeft,
|
||||
branch_picker_button,
|
||||
Tooltip::for_action_title("Switch Branch", &zed_actions::git::Branch),
|
||||
);
|
||||
let branch_picker = PopoverMenu::new("popover-button")
|
||||
.menu({
|
||||
let branch_list = self.branch_list.clone();
|
||||
move |_window, _cx| Some(branch_list.clone())
|
||||
})
|
||||
.trigger_with_tooltip(
|
||||
branch_picker_button,
|
||||
Tooltip::for_action_title("Switch Branch", &zed_actions::git::Branch),
|
||||
)
|
||||
.anchor(Corner::BottomLeft)
|
||||
.offset(gpui::Point {
|
||||
x: px(0.0),
|
||||
y: px(-2.0),
|
||||
});
|
||||
|
||||
let close_kb_hint =
|
||||
if let Some(close_kb) = ui::KeyBinding::for_action(&menu::Cancel, window, cx) {
|
||||
@@ -296,11 +304,15 @@ impl CommitModal {
|
||||
git_panel.update(cx, |git_panel, cx| git_panel.editor_focus_handle(cx));
|
||||
|
||||
let commit_button = panel_filled_button(commit_label)
|
||||
.tooltip(move |window, cx| {
|
||||
Tooltip::for_action_in(tooltip, &Commit, &panel_editor_focus_handle, window, cx)
|
||||
.tooltip({
|
||||
let panel_editor_focus_handle = panel_editor_focus_handle.clone();
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(tooltip, &Commit, &panel_editor_focus_handle, window, cx)
|
||||
}
|
||||
})
|
||||
.disabled(!can_commit)
|
||||
.on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
|
||||
telemetry::event!("Git Committed", source = "Git Modal");
|
||||
this.git_panel
|
||||
.update(cx, |git_panel, cx| git_panel.commit_changes(window, cx));
|
||||
cx.emit(DismissEvent);
|
||||
@@ -318,7 +330,8 @@ impl CommitModal {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(branch_picker.render(window, cx))
|
||||
.child(branch_picker)
|
||||
.children(generate_commit_message)
|
||||
.children(co_authors),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
@@ -339,6 +352,7 @@ impl CommitModal {
|
||||
}
|
||||
|
||||
fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
|
||||
telemetry::event!("Git Committed", source = "Git Modal");
|
||||
self.git_panel
|
||||
.update(cx, |git_panel, cx| git_panel.commit_changes(window, cx));
|
||||
cx.emit(DismissEvent);
|
||||
@@ -361,7 +375,7 @@ impl Render for CommitModal {
|
||||
.on_action(
|
||||
cx.listener(|this, _: &zed_actions::git::Branch, window, cx| {
|
||||
this.branch_list.update(cx, |branch_list, cx| {
|
||||
branch_list.menu_handle(window, cx).toggle(window, cx);
|
||||
branch_list.popover_handle.toggle(window, cx);
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ use project_diff::ProjectDiff;
|
||||
use ui::{ActiveTheme, Color, Icon, IconName, IntoElement};
|
||||
use workspace::Workspace;
|
||||
|
||||
mod askpass_modal;
|
||||
pub mod branch_picker;
|
||||
mod commit_modal;
|
||||
pub mod git_panel;
|
||||
@@ -20,30 +21,43 @@ pub fn init(cx: &mut App) {
|
||||
branch_picker::init(cx);
|
||||
cx.observe_new(ProjectDiff::register).detach();
|
||||
commit_modal::init(cx);
|
||||
git_panel::init(cx);
|
||||
|
||||
cx.observe_new(|workspace: &mut Workspace, _, _| {
|
||||
workspace.register_action(|workspace, fetch: &git::Fetch, window, cx| {
|
||||
cx.observe_new(|workspace: &mut Workspace, _, cx| {
|
||||
let project = workspace.project().read(cx);
|
||||
if project.is_via_collab() {
|
||||
return;
|
||||
}
|
||||
workspace.register_action(|workspace, _: &git::Fetch, window, cx| {
|
||||
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
||||
return;
|
||||
};
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.fetch(fetch, window, cx);
|
||||
panel.fetch(window, cx);
|
||||
});
|
||||
});
|
||||
workspace.register_action(|workspace, push: &git::Push, window, cx| {
|
||||
workspace.register_action(|workspace, _: &git::Push, window, cx| {
|
||||
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
||||
return;
|
||||
};
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.push(push, window, cx);
|
||||
panel.push(false, window, cx);
|
||||
});
|
||||
});
|
||||
workspace.register_action(|workspace, pull: &git::Pull, window, cx| {
|
||||
workspace.register_action(|workspace, _: &git::ForcePush, window, cx| {
|
||||
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
||||
return;
|
||||
};
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.pull(pull, window, cx);
|
||||
panel.push(true, window, cx);
|
||||
});
|
||||
});
|
||||
workspace.register_action(|workspace, _: &git::Pull, window, cx| {
|
||||
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
||||
return;
|
||||
};
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.pull(window, cx);
|
||||
});
|
||||
});
|
||||
})
|
||||
|
||||
@@ -10,8 +10,7 @@ use editor::{
|
||||
use feature_flags::FeatureFlagViewExt;
|
||||
use futures::StreamExt;
|
||||
use git::{
|
||||
status::FileStatus, ShowCommitEditor, StageAll, StageAndNext, ToggleStaged, UnstageAll,
|
||||
UnstageAndNext,
|
||||
status::FileStatus, Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext,
|
||||
};
|
||||
use gpui::{
|
||||
actions, Action, AnyElement, AnyView, App, AppContext as _, AsyncWindowContext, Entity,
|
||||
@@ -19,7 +18,10 @@ use gpui::{
|
||||
};
|
||||
use language::{Anchor, Buffer, Capability, OffsetRangeExt};
|
||||
use multi_buffer::{MultiBuffer, PathKey};
|
||||
use project::{git::GitStore, Project, ProjectPath};
|
||||
use project::{
|
||||
git::{GitEvent, GitStore},
|
||||
Project, ProjectPath,
|
||||
};
|
||||
use std::any::{Any, TypeId};
|
||||
use theme::ActiveTheme;
|
||||
use ui::{prelude::*, vertical_divider, Tooltip};
|
||||
@@ -88,6 +90,14 @@ impl ProjectDiff {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
telemetry::event!(
|
||||
"Git Diff Opened",
|
||||
source = if entry.is_some() {
|
||||
"Git Panel"
|
||||
} else {
|
||||
"Action"
|
||||
}
|
||||
);
|
||||
let project_diff = if let Some(existing) = workspace.item_of_type::<Self>(cx) {
|
||||
workspace.activate_item(&existing, true, true, window, cx);
|
||||
existing
|
||||
@@ -141,8 +151,13 @@ impl ProjectDiff {
|
||||
let git_store_subscription = cx.subscribe_in(
|
||||
&git_store,
|
||||
window,
|
||||
move |this, _git_store, _event, _window, _cx| {
|
||||
*this.update_needed.borrow_mut() = ();
|
||||
move |this, _git_store, event, _window, _cx| match event {
|
||||
GitEvent::ActiveRepositoryChanged
|
||||
| GitEvent::FileSystemUpdated
|
||||
| GitEvent::GitStateUpdated => {
|
||||
*this.update_needed.borrow_mut() = ();
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -151,7 +166,7 @@ impl ProjectDiff {
|
||||
let this = cx.weak_entity();
|
||||
|cx| Self::handle_status_updates(this, recv, cx)
|
||||
});
|
||||
// Kick of a refresh immediately
|
||||
// Kick off a refresh immediately
|
||||
*send.borrow_mut() = ();
|
||||
|
||||
Self {
|
||||
@@ -257,14 +272,12 @@ impl ProjectDiff {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut can_open_commit_editor = false;
|
||||
let mut stage_all = false;
|
||||
let mut unstage_all = false;
|
||||
self.workspace
|
||||
.read_with(cx, |workspace, cx| {
|
||||
if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
|
||||
let git_panel = git_panel.read(cx);
|
||||
can_open_commit_editor = git_panel.can_open_commit_editor();
|
||||
stage_all = git_panel.can_stage_all();
|
||||
unstage_all = git_panel.can_unstage_all();
|
||||
}
|
||||
@@ -276,7 +289,6 @@ impl ProjectDiff {
|
||||
unstage: has_staged_hunks,
|
||||
prev_next,
|
||||
selection,
|
||||
can_open_commit_editor,
|
||||
stage_all,
|
||||
unstage_all,
|
||||
};
|
||||
@@ -773,7 +785,6 @@ struct ButtonStates {
|
||||
selection: bool,
|
||||
stage_all: bool,
|
||||
unstage_all: bool,
|
||||
can_open_commit_editor: bool,
|
||||
}
|
||||
|
||||
impl Render for ProjectDiffToolbar {
|
||||
@@ -917,26 +928,27 @@ impl Render for ProjectDiffToolbar {
|
||||
)
|
||||
.child(
|
||||
Button::new("commit", "Commit")
|
||||
.disabled(!button_states.can_open_commit_editor)
|
||||
.tooltip(Tooltip::for_action_title_in(
|
||||
"Commit",
|
||||
&ShowCommitEditor,
|
||||
&Commit,
|
||||
&focus_handle,
|
||||
))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.dispatch_action(&ShowCommitEditor, window, cx);
|
||||
this.dispatch_action(&Commit, window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use collections::HashMap;
|
||||
use editor::test::editor_test_context::assert_state_with_diff;
|
||||
use db::indoc;
|
||||
use editor::test::editor_test_context::{assert_state_with_diff, EditorTestContext};
|
||||
use git::status::{StatusCode, TrackedStatus};
|
||||
use gpui::TestAppContext;
|
||||
use project::FakeFs;
|
||||
@@ -1020,9 +1032,6 @@ mod tests {
|
||||
editor.update_in(cx, |editor, window, cx| {
|
||||
editor.git_restore(&Default::default(), window, cx);
|
||||
});
|
||||
fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
|
||||
state.statuses = HashMap::default();
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
assert_state_with_diff(&editor, cx, &"ˇ".unindent());
|
||||
@@ -1227,4 +1236,93 @@ mod tests {
|
||||
.unindent(),
|
||||
);
|
||||
}
|
||||
|
||||
use crate::project_diff::{self, ProjectDiff};
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
"/a",
|
||||
json!({
|
||||
".git":{},
|
||||
"a.txt": "created\n",
|
||||
"b.txt": "really changed\n",
|
||||
"c.txt": "unchanged\n"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
fs.set_git_content_for_repo(
|
||||
Path::new("/a/.git"),
|
||||
&[
|
||||
("b.txt".into(), "before\n".to_string(), None),
|
||||
("c.txt".into(), "unchanged\n".to_string(), None),
|
||||
("d.txt".into(), "deleted\n".to_string(), None),
|
||||
],
|
||||
);
|
||||
|
||||
let project = Project::test(fs, [Path::new("/a")], cx).await;
|
||||
let (workspace, cx) =
|
||||
cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.focus(&workspace);
|
||||
cx.update(|window, cx| {
|
||||
window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let item = workspace.update(cx, |workspace, cx| {
|
||||
workspace.active_item_as::<ProjectDiff>(cx).unwrap()
|
||||
});
|
||||
cx.focus(&item);
|
||||
let editor = item.update(cx, |item, _| item.editor.clone());
|
||||
|
||||
let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
|
||||
|
||||
cx.assert_excerpts_with_selections(indoc!(
|
||||
"
|
||||
[EXCERPT]
|
||||
before
|
||||
really changed
|
||||
[EXCERPT]
|
||||
[FOLDED]
|
||||
[EXCERPT]
|
||||
ˇcreated
|
||||
"
|
||||
));
|
||||
|
||||
cx.dispatch_action(editor::actions::GoToPreviousHunk);
|
||||
|
||||
cx.assert_excerpts_with_selections(indoc!(
|
||||
"
|
||||
[EXCERPT]
|
||||
before
|
||||
really changed
|
||||
[EXCERPT]
|
||||
ˇ[FOLDED]
|
||||
[EXCERPT]
|
||||
created
|
||||
"
|
||||
));
|
||||
|
||||
cx.dispatch_action(editor::actions::GoToPreviousHunk);
|
||||
|
||||
cx.assert_excerpts_with_selections(indoc!(
|
||||
"
|
||||
[EXCERPT]
|
||||
ˇbefore
|
||||
really changed
|
||||
[EXCERPT]
|
||||
[FOLDED]
|
||||
[EXCERPT]
|
||||
created
|
||||
"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +1,84 @@
|
||||
use gpui::{
|
||||
AnyElement, AnyView, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
Subscription, Task, WeakEntity,
|
||||
AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use project::{
|
||||
git::{GitStore, Repository},
|
||||
Project,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, PopoverTrigger};
|
||||
use ui::{prelude::*, ListItem, ListItemSpacing};
|
||||
|
||||
pub struct RepositorySelector {
|
||||
picker: Entity<Picker<RepositorySelectorDelegate>>,
|
||||
/// The task used to update the picker's matches when there is a change to
|
||||
/// the repository list.
|
||||
update_matches_task: Option<Task<()>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl RepositorySelector {
|
||||
pub fn new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let git_store = project.read(cx).git_store().clone();
|
||||
let all_repositories = git_store.read(cx).all_repositories();
|
||||
let filtered_repositories = all_repositories.clone();
|
||||
pub fn new(
|
||||
project_handle: Entity<Project>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let git_store = project_handle.read(cx).git_store().clone();
|
||||
let repository_entries = git_store.update(cx, |git_store, cx| {
|
||||
filtered_repository_entries(git_store, cx)
|
||||
});
|
||||
let project = project_handle.read(cx);
|
||||
let filtered_repositories = repository_entries.clone();
|
||||
|
||||
let widest_item_ix = repository_entries.iter().position_max_by(|a, b| {
|
||||
a.read(cx)
|
||||
.display_name(project, cx)
|
||||
.len()
|
||||
.cmp(&b.read(cx).display_name(project, cx).len())
|
||||
});
|
||||
|
||||
let delegate = RepositorySelectorDelegate {
|
||||
project: project.downgrade(),
|
||||
project: project_handle.downgrade(),
|
||||
repository_selector: cx.entity().downgrade(),
|
||||
repository_entries: all_repositories,
|
||||
repository_entries,
|
||||
filtered_repositories,
|
||||
selected_index: 0,
|
||||
};
|
||||
|
||||
let picker = cx.new(|cx| {
|
||||
Picker::nonsearchable_uniform_list(delegate, window, cx)
|
||||
.widest_item(widest_item_ix)
|
||||
.max_height(Some(rems(20.).into()))
|
||||
.width(rems(15.))
|
||||
});
|
||||
|
||||
let _subscriptions =
|
||||
vec![cx.subscribe_in(&git_store, window, Self::handle_project_git_event)];
|
||||
|
||||
RepositorySelector {
|
||||
picker,
|
||||
update_matches_task: None,
|
||||
_subscriptions,
|
||||
}
|
||||
RepositorySelector { picker }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn repositories_len(&self, cx: &App) -> usize {
|
||||
self.picker.read(cx).delegate.repository_entries.len()
|
||||
}
|
||||
|
||||
fn handle_project_git_event(
|
||||
&mut self,
|
||||
git_store: &Entity<GitStore>,
|
||||
_event: &project::git::GitEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// TODO handle events individually
|
||||
let task = self.picker.update(cx, |this, cx| {
|
||||
let query = this.query(cx);
|
||||
this.delegate.repository_entries = git_store.read(cx).all_repositories();
|
||||
this.delegate.update_matches(query, window, cx)
|
||||
});
|
||||
self.update_matches_task = Some(task);
|
||||
}
|
||||
pub(crate) fn filtered_repository_entries(
|
||||
git_store: &GitStore,
|
||||
cx: &App,
|
||||
) -> Vec<Entity<Repository>> {
|
||||
let mut repository_entries = git_store.all_repositories();
|
||||
repository_entries.sort_by_key(|repo| {
|
||||
let repo = repo.read(cx);
|
||||
(
|
||||
repo.dot_git_abs_path.clone(),
|
||||
repo.worktree_abs_path.clone(),
|
||||
)
|
||||
});
|
||||
// Remove any entry that comes from a single file worktree and represents a repository that is also represented by a non-single-file worktree.
|
||||
repository_entries
|
||||
.chunk_by(|a, b| a.read(cx).dot_git_abs_path == b.read(cx).dot_git_abs_path)
|
||||
.flat_map(|chunk| {
|
||||
let has_non_single_file_worktree = chunk
|
||||
.iter()
|
||||
.any(|repo| !repo.read(cx).is_from_single_file_worktree);
|
||||
chunk
|
||||
.iter()
|
||||
.filter(move |repo| {
|
||||
!repo.read(cx).is_from_single_file_worktree || !has_non_single_file_worktree
|
||||
})
|
||||
.cloned()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for RepositorySelector {}
|
||||
@@ -82,54 +95,6 @@ impl Render for RepositorySelector {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct RepositorySelectorPopoverMenu<T, TT>
|
||||
where
|
||||
T: PopoverTrigger + ButtonCommon,
|
||||
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
|
||||
{
|
||||
repository_selector: Entity<RepositorySelector>,
|
||||
trigger: T,
|
||||
tooltip: TT,
|
||||
handle: Option<PopoverMenuHandle<RepositorySelector>>,
|
||||
}
|
||||
|
||||
impl<T, TT> RepositorySelectorPopoverMenu<T, TT>
|
||||
where
|
||||
T: PopoverTrigger + ButtonCommon,
|
||||
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
|
||||
{
|
||||
pub fn new(repository_selector: Entity<RepositorySelector>, trigger: T, tooltip: TT) -> Self {
|
||||
Self {
|
||||
repository_selector,
|
||||
trigger,
|
||||
tooltip,
|
||||
handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_handle(mut self, handle: PopoverMenuHandle<RepositorySelector>) -> Self {
|
||||
self.handle = Some(handle);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, TT> RenderOnce for RepositorySelectorPopoverMenu<T, TT>
|
||||
where
|
||||
T: PopoverTrigger + ButtonCommon,
|
||||
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
|
||||
{
|
||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let repository_selector = self.repository_selector.clone();
|
||||
|
||||
PopoverMenu::new("repository-switcher")
|
||||
.menu(move |_window, _cx| Some(repository_selector.clone()))
|
||||
.trigger_with_tooltip(self.trigger, self.tooltip)
|
||||
.attach(gpui::Corner::BottomLeft)
|
||||
.when_some(self.handle.clone(), |menu, handle| menu.with_handle(handle))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RepositorySelectorDelegate {
|
||||
project: WeakEntity<Project>,
|
||||
repository_selector: WeakEntity<RepositorySelector>,
|
||||
@@ -238,7 +203,6 @@ impl PickerDelegate for RepositorySelectorDelegate {
|
||||
let project = self.project.upgrade()?;
|
||||
let repo_info = self.filtered_repositories.get(ix)?;
|
||||
let display_name = repo_info.read(cx).display_name(project.read(cx), cx);
|
||||
// TODO: Implement repository item rendering
|
||||
Some(
|
||||
ListItem::new(ix)
|
||||
.inset(true)
|
||||
|
||||
@@ -402,7 +402,7 @@ impl Render for DataTable {
|
||||
.overflow_hidden()
|
||||
.border_1()
|
||||
.border_color(rgb(0xE0E0E0))
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
|
||||
@@ -69,7 +69,7 @@ struct ImageLoadingExample {}
|
||||
|
||||
impl ImageLoadingExample {
|
||||
fn loading_element() -> impl IntoElement {
|
||||
div().size_full().flex_none().p_0p5().rounded_sm().child(
|
||||
div().size_full().flex_none().p_0p5().rounded_xs().child(
|
||||
div().size_full().with_animation(
|
||||
"loading-bg",
|
||||
Animation::new(Duration::from_secs(3))
|
||||
@@ -89,7 +89,7 @@ impl ImageLoadingExample {
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_sm()
|
||||
.rounded_xs()
|
||||
.text_sm()
|
||||
.text_color(fallback_color)
|
||||
.border_1()
|
||||
|
||||
@@ -140,7 +140,7 @@ impl TextInput {
|
||||
));
|
||||
}
|
||||
}
|
||||
fn cut(&mut self, _: &Copy, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.selected_range.is_empty() {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(
|
||||
(&self.content[self.selected_range.clone()]).to_string(),
|
||||
|
||||
@@ -16,7 +16,7 @@ fn button(text: &str, on_click: impl Fn(&mut Window, &mut App) + 'static) -> imp
|
||||
.active(|this| this.opacity(0.85))
|
||||
.border_1()
|
||||
.border_color(rgb(0xe0e0e0))
|
||||
.rounded_md()
|
||||
.rounded_sm()
|
||||
.cursor_pointer()
|
||||
.child(text.to_string())
|
||||
.on_click(move |_, window, cx| on_click(window, cx))
|
||||
|
||||
@@ -1426,6 +1426,11 @@ impl App {
|
||||
self.platform.set_dock_menu(menus, &self.keymap.borrow());
|
||||
}
|
||||
|
||||
/// Performs the action associated with the given dock menu item, only used on Windows for now.
|
||||
pub fn perform_dock_menu_action(&self, action: usize) {
|
||||
self.platform.perform_dock_menu_action(action);
|
||||
}
|
||||
|
||||
/// Adds given path to the bottom of the list of recent paths for the application.
|
||||
/// The list is usually shown on the application icon's context menu in the dock,
|
||||
/// and allows to open the recent files via that context menu.
|
||||
|
||||
@@ -90,6 +90,21 @@ impl<'a, T: 'static> Context<'a, T> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Subscribe to an event type from ourself
|
||||
pub fn subscribe_self<Evt>(
|
||||
&mut self,
|
||||
mut on_event: impl FnMut(&mut T, &Evt, &mut Context<'_, T>) + 'static,
|
||||
) -> Subscription
|
||||
where
|
||||
T: 'static + EventEmitter<Evt>,
|
||||
Evt: 'static,
|
||||
{
|
||||
let this = self.entity();
|
||||
self.app.subscribe(&this, move |this, evt, cx| {
|
||||
this.update(cx, |this, cx| on_event(this, evt, cx))
|
||||
})
|
||||
}
|
||||
|
||||
/// Register a callback to be invoked when GPUI releases this entity.
|
||||
pub fn on_release(&self, on_release: impl FnOnce(&mut T, &mut App) + 'static) -> Subscription
|
||||
where
|
||||
|
||||
@@ -188,6 +188,11 @@ mod easing {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Quint ease-out function, which starts quickly and decelerates to a stop
|
||||
pub fn ease_out_quint() -> impl Fn(f32) -> f32 {
|
||||
move |delta| 1.0 - (1.0 - delta).powi(5)
|
||||
}
|
||||
|
||||
/// Apply the given easing function, first in the forward direction and then in the reverse direction
|
||||
pub fn bounce(easing: impl Fn(f32) -> f32) -> impl Fn(f32) -> f32 {
|
||||
move |delta| {
|
||||
|
||||
@@ -189,6 +189,7 @@ pub(crate) trait Platform: 'static {
|
||||
}
|
||||
|
||||
fn set_dock_menu(&self, menu: Vec<MenuItem>, keymap: &Keymap);
|
||||
fn perform_dock_menu_action(&self, _action: usize) {}
|
||||
fn add_recent_document(&self, _path: &Path) {}
|
||||
fn on_app_menu_action(&self, callback: Box<dyn FnMut(&dyn Action)>);
|
||||
fn on_will_open_app_menu(&self, callback: Box<dyn FnMut()>);
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::*;
|
||||
pub(crate) const WM_GPUI_CURSOR_STYLE_CHANGED: u32 = WM_USER + 1;
|
||||
pub(crate) const WM_GPUI_CLOSE_ONE_WINDOW: u32 = WM_USER + 2;
|
||||
pub(crate) const WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD: u32 = WM_USER + 3;
|
||||
pub(crate) const WM_GPUI_DOCK_MENU_ACTION: u32 = WM_USER + 4;
|
||||
|
||||
const SIZE_MOVE_LOOP_TIMER_ID: usize = 1;
|
||||
const AUTO_HIDE_TASKBAR_THICKNESS_PX: i32 = 1;
|
||||
|
||||
@@ -25,7 +25,10 @@ use windows::{
|
||||
System::{Com::*, LibraryLoader::*, Ole::*, SystemInformation::*, Threading::*},
|
||||
UI::{Input::KeyboardAndMouse::*, Shell::*, WindowsAndMessaging::*},
|
||||
},
|
||||
UI::ViewManagement::UISettings,
|
||||
UI::{
|
||||
StartScreen::{JumpList, JumpListItem},
|
||||
ViewManagement::UISettings,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{platform::blade::BladeContext, *};
|
||||
@@ -49,6 +52,7 @@ pub(crate) struct WindowsPlatform {
|
||||
pub(crate) struct WindowsPlatformState {
|
||||
callbacks: PlatformCallbacks,
|
||||
menus: Vec<OwnedMenu>,
|
||||
dock_menu_actions: Vec<Box<dyn Action>>,
|
||||
// NOTE: standard cursor handles don't need to close.
|
||||
pub(crate) current_cursor: HCURSOR,
|
||||
}
|
||||
@@ -66,10 +70,12 @@ struct PlatformCallbacks {
|
||||
impl WindowsPlatformState {
|
||||
fn new() -> Self {
|
||||
let callbacks = PlatformCallbacks::default();
|
||||
let dock_menu_actions = Vec::new();
|
||||
let current_cursor = load_cursor(CursorStyle::Arrow);
|
||||
|
||||
Self {
|
||||
callbacks,
|
||||
dock_menu_actions,
|
||||
current_cursor,
|
||||
menus: Vec::new(),
|
||||
}
|
||||
@@ -184,6 +190,24 @@ impl WindowsPlatform {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_dock_action_event(&self, action_idx: usize) {
|
||||
let mut lock = self.state.borrow_mut();
|
||||
if let Some(mut callback) = lock.callbacks.app_menu_action.take() {
|
||||
let Some(action) = lock
|
||||
.dock_menu_actions
|
||||
.get(action_idx)
|
||||
.map(|action| action.boxed_clone())
|
||||
else {
|
||||
lock.callbacks.app_menu_action = Some(callback);
|
||||
log::error!("Dock menu for index {action_idx} not found");
|
||||
return;
|
||||
};
|
||||
drop(lock);
|
||||
callback(&*action);
|
||||
self.state.borrow_mut().callbacks.app_menu_action = Some(callback);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns true if the app should quit.
|
||||
fn handle_events(&self) -> bool {
|
||||
let mut msg = MSG::default();
|
||||
@@ -191,7 +215,9 @@ impl WindowsPlatform {
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
match msg.message {
|
||||
WM_QUIT => return true,
|
||||
WM_GPUI_CLOSE_ONE_WINDOW | WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD => {
|
||||
WM_GPUI_CLOSE_ONE_WINDOW
|
||||
| WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD
|
||||
| WM_GPUI_DOCK_MENU_ACTION => {
|
||||
if self.handle_gpui_evnets(msg.message, msg.wParam, msg.lParam, &msg) {
|
||||
return true;
|
||||
}
|
||||
@@ -227,10 +253,40 @@ impl WindowsPlatform {
|
||||
}
|
||||
}
|
||||
WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD => self.run_foreground_task(),
|
||||
WM_GPUI_DOCK_MENU_ACTION => self.handle_dock_action_event(lparam.0 as _),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn configure_jump_list(&self, menus: Vec<MenuItem>) -> Result<()> {
|
||||
let jump_list = JumpList::LoadCurrentAsync()?.get()?;
|
||||
let items = jump_list.Items()?;
|
||||
items.Clear()?;
|
||||
let mut actions = Vec::new();
|
||||
for item in menus.into_iter() {
|
||||
let item = match item {
|
||||
MenuItem::Separator => JumpListItem::CreateSeparator()?,
|
||||
MenuItem::Submenu(_) => {
|
||||
log::error!("Set `MenuItemSubmenu` for dock menu on Windows is not supported.");
|
||||
continue;
|
||||
}
|
||||
MenuItem::Action { name, action, .. } => {
|
||||
let idx = actions.len();
|
||||
actions.push(action.boxed_clone());
|
||||
let item_args = format!("--dock-action {}", idx);
|
||||
JumpListItem::CreateWithArguments(
|
||||
&HSTRING::from(item_args),
|
||||
&HSTRING::from(name.as_ref()),
|
||||
)?
|
||||
}
|
||||
};
|
||||
items.Append(&item)?;
|
||||
}
|
||||
jump_list.SaveAsync()?.get()?;
|
||||
self.state.borrow_mut().dock_menu_actions = actions;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Platform for WindowsPlatform {
|
||||
@@ -479,8 +535,9 @@ impl Platform for WindowsPlatform {
|
||||
Some(self.state.borrow().menus.clone())
|
||||
}
|
||||
|
||||
// todo(windows)
|
||||
fn set_dock_menu(&self, _menus: Vec<MenuItem>, _keymap: &Keymap) {}
|
||||
fn set_dock_menu(&self, menus: Vec<MenuItem>, _keymap: &Keymap) {
|
||||
self.configure_jump_list(menus).log_err();
|
||||
}
|
||||
|
||||
fn on_app_menu_action(&self, callback: Box<dyn FnMut(&dyn Action)>) {
|
||||
self.state.borrow_mut().callbacks.app_menu_action = Some(callback);
|
||||
@@ -599,6 +656,18 @@ impl Platform for WindowsPlatform {
|
||||
fn register_url_scheme(&self, _: &str) -> Task<anyhow::Result<()>> {
|
||||
Task::ready(Err(anyhow!("register_url_scheme unimplemented")))
|
||||
}
|
||||
|
||||
fn perform_dock_menu_action(&self, action: usize) {
|
||||
unsafe {
|
||||
PostThreadMessageW(
|
||||
self.main_thread_id_win32,
|
||||
WM_GPUI_DOCK_MENU_ACTION,
|
||||
WPARAM(self.validation_number),
|
||||
LPARAM(action as isize),
|
||||
)
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WindowsPlatform {
|
||||
|
||||
@@ -168,6 +168,23 @@ impl Subscription {
|
||||
pub fn detach(mut self) {
|
||||
self.unsubscribe.take();
|
||||
}
|
||||
|
||||
/// Joins two subscriptions into a single subscription. Detach will
|
||||
/// detach both interior subscriptions.
|
||||
pub fn join(mut subscription_a: Self, mut subscription_b: Self) -> Self {
|
||||
let a_unsubscribe = subscription_a.unsubscribe.take();
|
||||
let b_unsubscribe = subscription_b.unsubscribe.take();
|
||||
Self {
|
||||
unsubscribe: Some(Box::new(move || {
|
||||
if let Some(self_unsubscribe) = a_unsubscribe {
|
||||
self_unsubscribe();
|
||||
}
|
||||
if let Some(other_unsubscribe) = b_unsubscribe {
|
||||
other_unsubscribe();
|
||||
}
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Subscription {
|
||||
|
||||
@@ -135,7 +135,7 @@ impl Render for FallbackPromptRenderer {
|
||||
.border_1()
|
||||
.border_color(opaque_grey(0.2, 0.5))
|
||||
.mt_1()
|
||||
.rounded_sm()
|
||||
.rounded_xs()
|
||||
.cursor_pointer()
|
||||
.text_sm()
|
||||
.child(action.clone())
|
||||
|
||||
@@ -1233,15 +1233,20 @@ fn corner_suffixes() -> Vec<CornerStyleSuffix> {
|
||||
doc_string_suffix: "0px",
|
||||
},
|
||||
CornerStyleSuffix {
|
||||
suffix: "sm",
|
||||
suffix: "xs",
|
||||
radius_tokens: quote! { rems(0.125) },
|
||||
doc_string_suffix: "2px (0.125rem)",
|
||||
},
|
||||
CornerStyleSuffix {
|
||||
suffix: "md",
|
||||
suffix: "sm",
|
||||
radius_tokens: quote! { rems(0.25) },
|
||||
doc_string_suffix: "4px (0.25rem)",
|
||||
},
|
||||
CornerStyleSuffix {
|
||||
suffix: "md",
|
||||
radius_tokens: quote! { rems(0.375) },
|
||||
doc_string_suffix: "6px (0.375rem)",
|
||||
},
|
||||
CornerStyleSuffix {
|
||||
suffix: "lg",
|
||||
radius_tokens: quote! { rems(0.5) },
|
||||
|
||||
@@ -133,13 +133,31 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap
|
||||
.await?;
|
||||
new_workspace
|
||||
.update(&mut cx, |workspace, window, cx| {
|
||||
workspace.open_paths(vec![entry_path], OpenVisible::All, None, window, cx)
|
||||
workspace.open_paths(
|
||||
vec![entry_path],
|
||||
workspace::OpenOptions {
|
||||
visible: Some(OpenVisible::All),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await
|
||||
} else {
|
||||
view_snapshot
|
||||
.update_in(&mut cx, |workspace, window, cx| {
|
||||
workspace.open_paths(vec![entry_path], OpenVisible::All, None, window, cx)
|
||||
workspace.open_paths(
|
||||
vec![entry_path],
|
||||
workspace::OpenOptions {
|
||||
visible: Some(OpenVisible::All),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await
|
||||
};
|
||||
|
||||
@@ -401,17 +401,16 @@ pub enum AutoindentMode {
|
||||
/// Apply the same indentation adjustment to all of the lines
|
||||
/// in a given insertion.
|
||||
Block {
|
||||
/// The original start column of each insertion, if it was
|
||||
/// copied from elsewhere.
|
||||
/// The original indentation column of the first line of each
|
||||
/// insertion, if it has been copied.
|
||||
///
|
||||
/// Knowing this start column makes it possible to preserve the
|
||||
/// relative indentation of every line in the insertion from
|
||||
/// when it was copied.
|
||||
/// Knowing this makes it possible to preserve the relative indentation
|
||||
/// of every line in the insertion from when it was copied.
|
||||
///
|
||||
/// If the start column is `a`, and the first line of insertion
|
||||
/// If the original indent column is `a`, and the first line of insertion
|
||||
/// is then auto-indented to column `b`, then every other line of
|
||||
/// the insertion will be auto-indented to column `b - a`
|
||||
original_start_columns: Vec<u32>,
|
||||
original_indent_columns: Vec<Option<u32>>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2206,15 +2205,20 @@ impl Buffer {
|
||||
|
||||
let mut original_indent_column = None;
|
||||
if let AutoindentMode::Block {
|
||||
original_start_columns,
|
||||
original_indent_columns,
|
||||
} = &mode
|
||||
{
|
||||
original_indent_column = Some(
|
||||
original_start_columns.get(ix).copied().unwrap_or(0)
|
||||
+ indent_size_for_text(
|
||||
new_text[range_of_insertion_to_indent.clone()].chars(),
|
||||
)
|
||||
.len,
|
||||
original_indent_columns
|
||||
.get(ix)
|
||||
.copied()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| {
|
||||
indent_size_for_text(
|
||||
new_text[range_of_insertion_to_indent.clone()].chars(),
|
||||
)
|
||||
.len
|
||||
}),
|
||||
);
|
||||
|
||||
// Avoid auto-indenting the line after the edit.
|
||||
@@ -3080,6 +3084,25 @@ impl BufferSnapshot {
|
||||
.last()
|
||||
}
|
||||
|
||||
pub fn smallest_syntax_layer_containing<D: ToOffset>(
|
||||
&self,
|
||||
range: Range<D>,
|
||||
) -> Option<SyntaxLayer> {
|
||||
let range = range.to_offset(self);
|
||||
return self
|
||||
.syntax
|
||||
.layers_for_range(range, &self.text, false)
|
||||
.max_by(|a, b| {
|
||||
if a.depth != b.depth {
|
||||
a.depth.cmp(&b.depth)
|
||||
} else if a.offset.0 != b.offset.0 {
|
||||
a.offset.0.cmp(&b.offset.0)
|
||||
} else {
|
||||
a.node().end_byte().cmp(&b.node().end_byte()).reverse()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the main [`Language`].
|
||||
pub fn language(&self) -> Option<&Arc<Language>> {
|
||||
self.language.as_ref()
|
||||
|
||||
@@ -1643,7 +1643,7 @@ fn test_autoindent_block_mode(cx: &mut App) {
|
||||
// indent level, but the indentation of the first line was not included in
|
||||
// the copied text. This information is retained in the
|
||||
// 'original_indent_columns' vector.
|
||||
let original_indent_columns = vec![4];
|
||||
let original_indent_columns = vec![Some(4)];
|
||||
let inserted_text = r#"
|
||||
"
|
||||
c
|
||||
@@ -1658,7 +1658,7 @@ fn test_autoindent_block_mode(cx: &mut App) {
|
||||
buffer.edit(
|
||||
[(Point::new(2, 0)..Point::new(2, 0), inserted_text.clone())],
|
||||
Some(AutoindentMode::Block {
|
||||
original_start_columns: original_indent_columns.clone(),
|
||||
original_indent_columns: original_indent_columns.clone(),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
@@ -1686,7 +1686,7 @@ fn test_autoindent_block_mode(cx: &mut App) {
|
||||
buffer.edit(
|
||||
[(Point::new(2, 8)..Point::new(2, 8), inserted_text)],
|
||||
Some(AutoindentMode::Block {
|
||||
original_start_columns: original_indent_columns.clone(),
|
||||
original_indent_columns: original_indent_columns.clone(),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
@@ -1735,7 +1735,7 @@ fn test_autoindent_block_mode_without_original_indent_columns(cx: &mut App) {
|
||||
buffer.edit(
|
||||
[(Point::new(2, 0)..Point::new(2, 0), inserted_text)],
|
||||
Some(AutoindentMode::Block {
|
||||
original_start_columns: original_indent_columns.clone(),
|
||||
original_indent_columns: original_indent_columns.clone(),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
@@ -1766,7 +1766,7 @@ fn test_autoindent_block_mode_without_original_indent_columns(cx: &mut App) {
|
||||
buffer.edit(
|
||||
[(Point::new(2, 12)..Point::new(2, 12), inserted_text)],
|
||||
Some(AutoindentMode::Block {
|
||||
original_start_columns: Vec::new(),
|
||||
original_indent_columns: Vec::new(),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
@@ -1822,7 +1822,7 @@ fn test_autoindent_block_mode_multiple_adjacent_ranges(cx: &mut App) {
|
||||
(ranges_to_replace[2].clone(), "fn three() {\n 103\n}\n"),
|
||||
],
|
||||
Some(AutoindentMode::Block {
|
||||
original_start_columns: vec![0, 0, 0],
|
||||
original_indent_columns: vec![Some(0), Some(0), Some(0)],
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -680,6 +680,9 @@ pub struct LanguageConfig {
|
||||
/// languages, but should not appear to the user as a distinct language.
|
||||
#[serde(default)]
|
||||
pub hidden: bool,
|
||||
/// If configured, this language contains JSX style tags, and should support auto-closing of those tags.
|
||||
#[serde(default)]
|
||||
pub jsx_tag_auto_close: Option<JsxTagAutoCloseConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
|
||||
@@ -697,6 +700,34 @@ pub struct LanguageMatcher {
|
||||
pub first_line_pattern: Option<Regex>,
|
||||
}
|
||||
|
||||
/// The configuration for JSX tag auto-closing.
|
||||
#[derive(Clone, Deserialize, JsonSchema)]
|
||||
pub struct JsxTagAutoCloseConfig {
|
||||
/// The name of the node for a opening tag
|
||||
pub open_tag_node_name: String,
|
||||
/// The name of the node for an closing tag
|
||||
pub close_tag_node_name: String,
|
||||
/// The name of the node for a complete element with children for open and close tags
|
||||
pub jsx_element_node_name: String,
|
||||
/// The name of the node found within both opening and closing
|
||||
/// tags that describes the tag name
|
||||
pub tag_name_node_name: String,
|
||||
/// Some grammars are smart enough to detect a closing tag
|
||||
/// that is not valid i.e. doesn't match it's corresponding
|
||||
/// opening tag or does not have a corresponding opening tag
|
||||
/// This should be set to the name of the node for invalid
|
||||
/// closing tags if the grammar contains such a node, otherwise
|
||||
/// detecting already closed tags will not work properly
|
||||
#[serde(default)]
|
||||
pub erroneous_close_tag_node_name: Option<String>,
|
||||
/// See above for erroneous_close_tag_node_name for details
|
||||
/// This should be set if the node used for the tag name
|
||||
/// within erroneous closing tags is different from the
|
||||
/// normal tag name node name
|
||||
#[serde(default)]
|
||||
pub erroneous_close_tag_name_node_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Represents a language for the given range. Some languages (e.g. HTML)
|
||||
/// interleave several languages together, thus a single buffer might actually contain
|
||||
/// several nested scopes.
|
||||
@@ -767,6 +798,7 @@ impl Default for LanguageConfig {
|
||||
soft_wrap: None,
|
||||
prettier_parser_name: None,
|
||||
hidden: false,
|
||||
jsx_tag_auto_close: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -888,7 +920,7 @@ pub struct BracketPair {
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
|
||||
pub(crate) struct LanguageId(usize);
|
||||
pub struct LanguageId(usize);
|
||||
|
||||
impl LanguageId {
|
||||
pub(crate) fn new() -> Self {
|
||||
@@ -1056,6 +1088,10 @@ impl Language {
|
||||
Self::new_with_id(LanguageId::new(), config, ts_language)
|
||||
}
|
||||
|
||||
pub fn id(&self) -> LanguageId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn new_with_id(
|
||||
id: LanguageId,
|
||||
config: LanguageConfig,
|
||||
|
||||
@@ -100,6 +100,8 @@ pub struct LanguageSettings {
|
||||
pub formatter: SelectedFormatter,
|
||||
/// Zed's Prettier integration settings.
|
||||
pub prettier: PrettierSettings,
|
||||
/// Whether to automatically close JSX tags.
|
||||
pub jsx_tag_auto_close: JsxTagAutoCloseSettings,
|
||||
/// Whether to use language servers to provide code intelligence.
|
||||
pub enable_language_server: bool,
|
||||
/// The list of language servers to use (or disable) for this language.
|
||||
@@ -374,6 +376,9 @@ pub struct LanguageSettingsContent {
|
||||
/// Default: off
|
||||
#[serde(default)]
|
||||
pub prettier: Option<PrettierSettings>,
|
||||
/// Whether to automatically close JSX tags.
|
||||
#[serde(default)]
|
||||
pub jsx_tag_auto_close: Option<JsxTagAutoCloseSettings>,
|
||||
/// Whether to use language servers to provide code intelligence.
|
||||
///
|
||||
/// Default: true
|
||||
@@ -1335,6 +1340,10 @@ fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent
|
||||
);
|
||||
merge(&mut settings.formatter, src.formatter.clone());
|
||||
merge(&mut settings.prettier, src.prettier.clone());
|
||||
merge(
|
||||
&mut settings.jsx_tag_auto_close,
|
||||
src.jsx_tag_auto_close.clone(),
|
||||
);
|
||||
merge(&mut settings.format_on_save, src.format_on_save.clone());
|
||||
merge(
|
||||
&mut settings.remove_trailing_whitespace_on_save,
|
||||
@@ -1398,6 +1407,13 @@ pub struct PrettierSettings {
|
||||
pub options: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct JsxTagAutoCloseSettings {
|
||||
/// Enables or disables auto-closing of JSX tags.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::TestAppContext;
|
||||
|
||||
@@ -121,9 +121,9 @@ impl SyntaxLayerContent {
|
||||
pub struct SyntaxLayer<'a> {
|
||||
/// The language for this layer.
|
||||
pub language: &'a Arc<Language>,
|
||||
depth: usize,
|
||||
pub(crate) depth: usize,
|
||||
tree: &'a Tree,
|
||||
offset: (usize, tree_sitter::Point),
|
||||
pub(crate) offset: (usize, tree_sitter::Point),
|
||||
}
|
||||
|
||||
/// A layer of syntax highlighting. Like [SyntaxLayer], but holding
|
||||
@@ -133,7 +133,7 @@ pub struct OwnedSyntaxLayer {
|
||||
/// The language for this layer.
|
||||
pub language: Arc<Language>,
|
||||
tree: tree_sitter::Tree,
|
||||
offset: (usize, tree_sitter::Point),
|
||||
pub offset: (usize, tree_sitter::Point),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use feature_flags::ZedPro;
|
||||
use gpui::{
|
||||
action_with_deprecated_aliases, Action, AnyElement, App, Corner, DismissEvent, Entity,
|
||||
action_with_deprecated_aliases, Action, AnyElement, AnyView, App, Corner, DismissEvent, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, Subscription, Task, WeakEntity,
|
||||
};
|
||||
use language_model::{
|
||||
@@ -10,10 +10,7 @@ use language_model::{
|
||||
};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use proto::Plan;
|
||||
use ui::{
|
||||
prelude::*, ButtonLike, IconButtonShape, ListItem, ListItemSpacing, PopoverButton,
|
||||
PopoverMenuHandle, Tooltip, TriggerablePopover,
|
||||
};
|
||||
use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, PopoverTrigger};
|
||||
use workspace::ShowConfiguration;
|
||||
|
||||
action_with_deprecated_aliases!(
|
||||
@@ -31,7 +28,6 @@ pub struct LanguageModelSelector {
|
||||
/// The task used to update the picker's matches when there is a change to
|
||||
/// the language model registry.
|
||||
update_matches_task: Option<Task<()>>,
|
||||
popover_menu_handle: PopoverMenuHandle<LanguageModelSelector>,
|
||||
_authenticate_all_providers_task: Task<()>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
@@ -63,7 +59,6 @@ impl LanguageModelSelector {
|
||||
LanguageModelSelector {
|
||||
picker,
|
||||
update_matches_task: None,
|
||||
popover_menu_handle: PopoverMenuHandle::default(),
|
||||
_authenticate_all_providers_task: Self::authenticate_all_providers(cx),
|
||||
_subscriptions: vec![cx.subscribe_in(
|
||||
&LanguageModelRegistry::global(cx),
|
||||
@@ -73,15 +68,6 @@ impl LanguageModelSelector {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_model_selector(
|
||||
&mut self,
|
||||
_: &ToggleModelSelector,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.popover_menu_handle.toggle(window, cx);
|
||||
}
|
||||
|
||||
fn handle_language_model_registry_event(
|
||||
&mut self,
|
||||
_registry: &Entity<LanguageModelRegistry>,
|
||||
@@ -201,13 +187,62 @@ impl Render for LanguageModelSelector {
|
||||
}
|
||||
}
|
||||
|
||||
impl TriggerablePopover for LanguageModelSelector {
|
||||
fn menu_handle(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut gpui::Context<Self>,
|
||||
) -> PopoverMenuHandle<Self> {
|
||||
self.popover_menu_handle.clone()
|
||||
#[derive(IntoElement)]
|
||||
pub struct LanguageModelSelectorPopoverMenu<T, TT>
|
||||
where
|
||||
T: PopoverTrigger + ButtonCommon,
|
||||
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
|
||||
{
|
||||
language_model_selector: Entity<LanguageModelSelector>,
|
||||
trigger: T,
|
||||
tooltip: TT,
|
||||
handle: Option<PopoverMenuHandle<LanguageModelSelector>>,
|
||||
anchor: Corner,
|
||||
}
|
||||
|
||||
impl<T, TT> LanguageModelSelectorPopoverMenu<T, TT>
|
||||
where
|
||||
T: PopoverTrigger + ButtonCommon,
|
||||
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
|
||||
{
|
||||
pub fn new(
|
||||
language_model_selector: Entity<LanguageModelSelector>,
|
||||
trigger: T,
|
||||
tooltip: TT,
|
||||
anchor: Corner,
|
||||
) -> Self {
|
||||
Self {
|
||||
language_model_selector,
|
||||
trigger,
|
||||
tooltip,
|
||||
handle: None,
|
||||
anchor,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_handle(mut self, handle: PopoverMenuHandle<LanguageModelSelector>) -> Self {
|
||||
self.handle = Some(handle);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, TT> RenderOnce for LanguageModelSelectorPopoverMenu<T, TT>
|
||||
where
|
||||
T: PopoverTrigger + ButtonCommon,
|
||||
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
|
||||
{
|
||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let language_model_selector = self.language_model_selector.clone();
|
||||
|
||||
PopoverMenu::new("model-switcher")
|
||||
.menu(move |_window, _cx| Some(language_model_selector.clone()))
|
||||
.trigger_with_tooltip(self.trigger, self.tooltip)
|
||||
.anchor(self.anchor)
|
||||
.when_some(self.handle.clone(), |menu, handle| menu.with_handle(handle))
|
||||
.offset(gpui::Point {
|
||||
x: px(0.0),
|
||||
y: px(-2.0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,98 +527,3 @@ impl PickerDelegate for LanguageModelPickerDelegate {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InlineLanguageModelSelector {
|
||||
selector: Entity<LanguageModelSelector>,
|
||||
}
|
||||
|
||||
impl InlineLanguageModelSelector {
|
||||
pub fn new(selector: Entity<LanguageModelSelector>) -> Self {
|
||||
Self { selector }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for InlineLanguageModelSelector {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
PopoverButton::new(
|
||||
self.selector,
|
||||
gpui::Corner::TopRight,
|
||||
IconButton::new("context", IconName::SettingsAlt)
|
||||
.shape(IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted),
|
||||
move |window, cx| {
|
||||
Tooltip::with_meta(
|
||||
format!(
|
||||
"Using {}",
|
||||
LanguageModelRegistry::read_global(cx)
|
||||
.active_model()
|
||||
.map(|model| model.name().0)
|
||||
.unwrap_or_else(|| "No model selected".into()),
|
||||
),
|
||||
None,
|
||||
"Change Model",
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)
|
||||
.render(window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AssistantLanguageModelSelector {
|
||||
focus_handle: FocusHandle,
|
||||
selector: Entity<LanguageModelSelector>,
|
||||
}
|
||||
|
||||
impl AssistantLanguageModelSelector {
|
||||
pub fn new(focus_handle: FocusHandle, selector: Entity<LanguageModelSelector>) -> Self {
|
||||
Self {
|
||||
focus_handle,
|
||||
selector,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for AssistantLanguageModelSelector {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let active_model = LanguageModelRegistry::read_global(cx).active_model();
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
let model_name = match active_model {
|
||||
Some(model) => model.name().0,
|
||||
_ => SharedString::from("No model selected"),
|
||||
};
|
||||
|
||||
PopoverButton::new(
|
||||
self.selector.clone(),
|
||||
Corner::BottomRight,
|
||||
ButtonLike::new("active-model")
|
||||
.style(ButtonStyle::Subtle)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
Label::new(model_name)
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(
|
||||
Icon::new(IconName::ChevronDown)
|
||||
.color(Color::Muted)
|
||||
.size(IconSize::XSmall),
|
||||
),
|
||||
),
|
||||
move |window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Change Model",
|
||||
&ToggleModelSelector,
|
||||
&focus_handle,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)
|
||||
.render(window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user