Compare commits
55 Commits
v0.159.2-p
...
batched-po
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd2bb8dac6 | ||
|
|
7bc4cb9868 | ||
|
|
f84f3ffeb7 | ||
|
|
c564a4a26c | ||
|
|
515fd7b75f | ||
|
|
662a4440cc | ||
|
|
5dee43b05c | ||
|
|
c8003c0697 | ||
|
|
83e2889d63 | ||
|
|
d49cd0019f | ||
|
|
0ba40bdfb8 | ||
|
|
f6cd97f6fd | ||
|
|
774a8bf039 | ||
|
|
4431ef1870 | ||
|
|
b3f0ba1430 | ||
|
|
a5f52f0f04 | ||
|
|
63524a2354 | ||
|
|
90edb7189f | ||
|
|
518f6b529b | ||
|
|
fb97e462de | ||
|
|
5b7fa05a87 | ||
|
|
d310a1269f | ||
|
|
9818835c9d | ||
|
|
f3b7f5944d | ||
|
|
fc5cde9434 | ||
|
|
6ea4662326 | ||
|
|
9d12308d06 | ||
|
|
21137d2ba7 | ||
|
|
273cb1921f | ||
|
|
cfa20ff221 | ||
|
|
759d136fe6 | ||
|
|
322aa41ad6 | ||
|
|
3e2f1d733c | ||
|
|
3fed738d2f | ||
|
|
5893e85708 | ||
|
|
1356665ed3 | ||
|
|
9739da8de3 | ||
|
|
249c8a4d96 | ||
|
|
f919fa92de | ||
|
|
21b58643fa | ||
|
|
6a0bcca9ec | ||
|
|
84328c303b | ||
|
|
f7b2b41df9 | ||
|
|
7a6b6435c4 | ||
|
|
bdb54decdc | ||
|
|
b5c41eeb98 | ||
|
|
719a7f7890 | ||
|
|
1b84fee708 | ||
|
|
58e5d4ff02 | ||
|
|
85ff03cde0 | ||
|
|
a3f0bb4547 | ||
|
|
93b20008e0 | ||
|
|
188a893fd0 | ||
|
|
052b746fbd | ||
|
|
80f89059aa |
@@ -1,3 +1,3 @@
|
||||
# Code of Conduct
|
||||
|
||||
The Code of Conduct for this repository can be found online at [zed.dev/docs/code-of-conduct](https://zed.dev/docs/code-of-conduct).
|
||||
The Code of Conduct for this repository can be found online at [zed.dev/code-of-conduct](https://zed.dev/code-of-conduct).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Thanks for your interest in contributing to Zed, the collaborative platform that is also a code editor!
|
||||
|
||||
All activity in Zed forums is subject to our [Code of Conduct](https://zed.dev/docs/code-of-conduct). Additionally, contributors must sign our [Contributor License Agreement](https://zed.dev/cla) before their contributions can be merged.
|
||||
All activity in Zed forums is subject to our [Code of Conduct](https://zed.dev/code-of-conduct). Additionally, contributors must sign our [Contributor License Agreement](https://zed.dev/cla) before their contributions can be merged.
|
||||
|
||||
## Contribution ideas
|
||||
|
||||
|
||||
84
Cargo.lock
generated
84
Cargo.lock
generated
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"project",
|
||||
"smallvec",
|
||||
"ui",
|
||||
"util",
|
||||
"workspace",
|
||||
]
|
||||
|
||||
@@ -853,7 +854,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"http-types",
|
||||
"hyper 0.14.30",
|
||||
"hyper 0.14.31",
|
||||
"hyper-rustls 0.24.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1349,7 +1350,7 @@ dependencies = [
|
||||
"http-body 0.4.6",
|
||||
"http-body 1.0.1",
|
||||
"httparse",
|
||||
"hyper 0.14.30",
|
||||
"hyper 0.14.31",
|
||||
"hyper-rustls 0.24.2",
|
||||
"once_cell",
|
||||
"pin-project-lite",
|
||||
@@ -1440,7 +1441,7 @@ dependencies = [
|
||||
"headers",
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"hyper 0.14.30",
|
||||
"hyper 0.14.31",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
@@ -1586,7 +1587,7 @@ dependencies = [
|
||||
"bitflags 2.6.0",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.10.5",
|
||||
"itertools 0.12.1",
|
||||
"lazy_static",
|
||||
"lazycell",
|
||||
"proc-macro2",
|
||||
@@ -2365,7 +2366,7 @@ dependencies = [
|
||||
"clickhouse-derive",
|
||||
"clickhouse-rs-cityhash-sys",
|
||||
"futures 0.3.30",
|
||||
"hyper 0.14.30",
|
||||
"hyper 0.14.31",
|
||||
"hyper-tls",
|
||||
"lz4",
|
||||
"sealed",
|
||||
@@ -2568,7 +2569,7 @@ dependencies = [
|
||||
"gpui",
|
||||
"hex",
|
||||
"http_client",
|
||||
"hyper 0.14.30",
|
||||
"hyper 0.14.31",
|
||||
"indoc",
|
||||
"jsonwebtoken",
|
||||
"language",
|
||||
@@ -3717,6 +3718,7 @@ dependencies = [
|
||||
"tree-sitter-rust",
|
||||
"tree-sitter-typescript",
|
||||
"ui",
|
||||
"unicode-segmentation",
|
||||
"unindent",
|
||||
"url",
|
||||
"util",
|
||||
@@ -4908,12 +4910,13 @@ dependencies = [
|
||||
"git",
|
||||
"gpui",
|
||||
"http_client",
|
||||
"indoc",
|
||||
"pretty_assertions",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"unindent",
|
||||
"url",
|
||||
"util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5567,9 +5570,9 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "0.14.30"
|
||||
version = "0.14.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9"
|
||||
checksum = "8c08302e8fa335b151b788c775ff56e7a03ae64ff85c548ee820fecb70356e85"
|
||||
dependencies = [
|
||||
"bytes 1.7.2",
|
||||
"futures-channel",
|
||||
@@ -5617,7 +5620,7 @@ checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"hyper 0.14.30",
|
||||
"hyper 0.14.31",
|
||||
"log",
|
||||
"rustls 0.21.12",
|
||||
"rustls-native-certs 0.6.3",
|
||||
@@ -5650,7 +5653,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
|
||||
dependencies = [
|
||||
"bytes 1.7.2",
|
||||
"hyper 0.14.30",
|
||||
"hyper 0.14.31",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
@@ -6152,6 +6155,20 @@ dependencies = [
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyter-serde"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a444fb3f87ee6885eb316028cc998c7d84811663ef95d78c419419423d5a054"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
@@ -7135,6 +7152,21 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nbformat"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146074ad45cab20f5d98ccded164826158471f21d04f96e40b9872529e10979d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"jupyter-serde",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.8.0"
|
||||
@@ -9522,6 +9554,7 @@ dependencies = [
|
||||
"async-watch",
|
||||
"backtrace",
|
||||
"cargo_toml",
|
||||
"chrono",
|
||||
"clap",
|
||||
"client",
|
||||
"clock",
|
||||
@@ -9541,6 +9574,8 @@ dependencies = [
|
||||
"node_runtime",
|
||||
"paths",
|
||||
"project",
|
||||
"proto",
|
||||
"release_channel",
|
||||
"remote",
|
||||
"reqwest_client",
|
||||
"rpc",
|
||||
@@ -9550,6 +9585,7 @@ dependencies = [
|
||||
"settings",
|
||||
"shellexpand 2.1.2",
|
||||
"smol",
|
||||
"telemetry_events",
|
||||
"toml 0.8.19",
|
||||
"util",
|
||||
"worktree",
|
||||
@@ -9577,6 +9613,7 @@ dependencies = [
|
||||
"command_palette_hooks",
|
||||
"editor",
|
||||
"env_logger 0.11.5",
|
||||
"feature_flags",
|
||||
"futures 0.3.30",
|
||||
"gpui",
|
||||
"http_client",
|
||||
@@ -9586,7 +9623,9 @@ dependencies = [
|
||||
"languages",
|
||||
"log",
|
||||
"markdown_preview",
|
||||
"menu",
|
||||
"multi_buffer",
|
||||
"nbformat",
|
||||
"project",
|
||||
"runtimelib",
|
||||
"schemars",
|
||||
@@ -9621,7 +9660,7 @@ dependencies = [
|
||||
"h2 0.3.26",
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"hyper 0.14.30",
|
||||
"hyper 0.14.31",
|
||||
"hyper-tls",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
@@ -9860,6 +9899,7 @@ dependencies = [
|
||||
"gpui",
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"rayon",
|
||||
"smallvec",
|
||||
"sum_tree",
|
||||
"unicode-segmentation",
|
||||
@@ -9925,9 +9965,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "runtimelib"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7d76d28b882a7b889ebb04e79bc2b160b3061821ea596ff0f4a838fc7a76db0"
|
||||
checksum = "263588fe9593333c4bfde258c9021fc64e766ea434e070c6b67c7100536d6499"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-dispatcher",
|
||||
@@ -9939,6 +9979,7 @@ dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"futures 0.3.30",
|
||||
"glob",
|
||||
"jupyter-serde",
|
||||
"rand 0.8.5",
|
||||
"ring 0.17.8",
|
||||
"serde",
|
||||
@@ -13411,7 +13452,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"headers",
|
||||
"http 0.2.12",
|
||||
"hyper 0.14.30",
|
||||
"hyper 0.14.31",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
@@ -14124,7 +14165,7 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -14729,6 +14770,7 @@ dependencies = [
|
||||
"fuzzy",
|
||||
"git",
|
||||
"git2",
|
||||
"git_hosting_providers",
|
||||
"gpui",
|
||||
"http_client",
|
||||
"ignore",
|
||||
@@ -14991,7 +15033,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "0.159.2"
|
||||
version = "0.161.0"
|
||||
dependencies = [
|
||||
"activity_indicator",
|
||||
"anyhow",
|
||||
@@ -15056,6 +15098,7 @@ dependencies = [
|
||||
"project",
|
||||
"project_panel",
|
||||
"project_symbols",
|
||||
"proto",
|
||||
"quick_action_bar",
|
||||
"recent_projects",
|
||||
"release_channel",
|
||||
@@ -15130,13 +15173,6 @@ dependencies = [
|
||||
"zed_extension_api 0.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zed_dart"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"zed_extension_api 0.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zed_deno"
|
||||
version = "0.0.2"
|
||||
|
||||
@@ -138,7 +138,6 @@ members = [
|
||||
"extensions/astro",
|
||||
"extensions/clojure",
|
||||
"extensions/csharp",
|
||||
"extensions/dart",
|
||||
"extensions/deno",
|
||||
"extensions/elixir",
|
||||
"extensions/elm",
|
||||
@@ -371,6 +370,7 @@ linkify = "0.10.0"
|
||||
log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] }
|
||||
markup5ever_rcdom = "0.3.0"
|
||||
nanoid = "0.4"
|
||||
nbformat = "0.3.1"
|
||||
nix = "0.29"
|
||||
num-format = "0.4.4"
|
||||
once_cell = "1.19.0"
|
||||
@@ -391,6 +391,7 @@ prost-build = "0.9"
|
||||
prost-types = "0.9"
|
||||
pulldown-cmark = { version = "0.12.0", default-features = false }
|
||||
rand = "0.8.5"
|
||||
rayon = "1.8"
|
||||
regex = "1.5"
|
||||
repair_json = "0.1.0"
|
||||
reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "fd110f6998da16bbca97b6dddda9be7827c50e29", default-features = false, features = [
|
||||
@@ -402,7 +403,7 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "fd110f
|
||||
"stream",
|
||||
] }
|
||||
rsa = "0.9.6"
|
||||
runtimelib = { version = "0.15", default-features = false, features = [
|
||||
runtimelib = { version = "0.16.0", default-features = false, features = [
|
||||
"async-dispatcher-runtime",
|
||||
] }
|
||||
rustc-demangle = "0.1.23"
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"gitignore": "vcs",
|
||||
"gitkeep": "vcs",
|
||||
"gitmodules": "vcs",
|
||||
"gleam": "gleam",
|
||||
"go": "go",
|
||||
"gql": "graphql",
|
||||
"graphql": "graphql",
|
||||
@@ -83,6 +84,7 @@
|
||||
"j2k": "image",
|
||||
"java": "java",
|
||||
"jfif": "image",
|
||||
"jl": "julia",
|
||||
"jp2": "image",
|
||||
"jpeg": "image",
|
||||
"jpg": "image",
|
||||
@@ -90,7 +92,6 @@
|
||||
"json": "storage",
|
||||
"jsonc": "storage",
|
||||
"jsx": "react",
|
||||
"julia": "julia",
|
||||
"jxl": "image",
|
||||
"kt": "kotlin",
|
||||
"ldf": "storage",
|
||||
@@ -264,6 +265,9 @@
|
||||
"fsharp": {
|
||||
"icon": "icons/file_icons/fsharp.svg"
|
||||
},
|
||||
"gleam": {
|
||||
"icon": "icons/file_icons/gleam.svg"
|
||||
},
|
||||
"go": {
|
||||
"icon": "icons/file_icons/go.svg"
|
||||
},
|
||||
|
||||
6
assets/icons/file_icons/gleam.svg
Normal file
6
assets/icons/file_icons/gleam.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path fill-rule="evenodd" fill="black" d="M 3.828125 14.601562 C 3.894531 15.726562 5.183594 16.375 6.132812 15.785156 L 6.136719 15.785156 L 8.988281 13.824219 C 8.996094 13.816406 9.007812 13.8125 9.015625 13.804688 C 9.203125 13.675781 9.4375 13.636719 9.65625 13.691406 L 12.988281 14.550781 C 14.105469 14.839844 15.140625 13.769531 14.8125 12.667969 L 13.832031 9.386719 C 13.769531 9.167969 13.800781 8.9375 13.921875 8.75 C 13.921875 8.746094 13.925781 8.746094 13.925781 8.746094 L 15.777344 5.863281 L 15.777344 5.859375 C 15.78125 5.851562 15.785156 5.84375 15.789062 5.835938 L 15.792969 5.835938 C 16.382812 4.871094 15.6875 3.582031 14.542969 3.554688 L 11.109375 3.472656 C 10.878906 3.464844 10.664062 3.359375 10.519531 3.183594 L 8.339844 0.542969 C 8.019531 0.152344 7.550781 -0.015625 7.105469 0.0078125 L 7.101562 0.0078125 C 7.039062 0.0117188 6.976562 0.0195312 6.914062 0.0273438 C 6.414062 0.117188 5.945312 0.453125 5.75 1 L 4.609375 4.222656 C 4.535156 4.4375 4.367188 4.613281 4.152344 4.695312 L 0.957031 5.945312 C -0.121094 6.363281 -0.328125 7.835938 0.589844 8.535156 L 3.316406 10.609375 C 3.5 10.75 3.609375 10.960938 3.625 11.191406 Z M 7.515625 1.847656 C 7.421875 1.730469 7.296875 1.695312 7.183594 1.714844 C 7.066406 1.734375 6.960938 1.8125 6.914062 1.953125 L 5.867188 4.902344 C 5.699219 5.382812 5.328125 5.765625 4.851562 5.949219 L 1.925781 7.09375 C 1.785156 7.148438 1.710938 7.253906 1.695312 7.371094 C 1.679688 7.484375 1.71875 7.605469 1.839844 7.695312 L 4.335938 9.597656 C 4.742188 9.90625 4.992188 10.375 5.023438 10.882812 L 5.207031 14.003906 C 5.214844 14.152344 5.296875 14.253906 5.398438 14.304688 C 5.503906 14.355469 5.632812 14.355469 5.757812 14.269531 L 8.347656 12.492188 C 8.765625 12.207031 9.292969 12.113281 9.785156 12.242188 L 12.824219 13.027344 C 12.972656 13.066406 13.09375 13.023438 13.175781 12.9375 C 13.257812 12.855469 13.296875 12.734375 13.253906 12.589844 L 12.355469 9.589844 C 12.210938 9.105469 12.285156 8.578125 12.558594 8.148438 L 14.253906 5.511719 C 14.335938 5.386719 14.332031 5.257812 14.277344 5.15625 C 14.222656 5.054688 14.117188 4.980469 13.964844 4.976562 L 10.824219 4.902344 C 10.316406 4.886719 9.835938 4.65625 9.511719 4.261719 Z M 7.515625 1.847656 "/>
|
||||
<path fill="black" d="M 5.71875 7.257812 C 5.671875 7.25 5.628906 7.246094 5.582031 7.246094 C 5.09375 7.246094 4.695312 7.644531 4.695312 8.128906 C 4.695312 8.613281 5.09375 9.011719 5.582031 9.011719 C 6.070312 9.011719 6.46875 8.613281 6.46875 8.128906 C 6.46875 7.6875 6.140625 7.320312 5.71875 7.257812 Z M 5.71875 7.257812 "/>
|
||||
<path fill="black" d="M 11.019531 7.953125 C 10.976562 7.957031 10.929688 7.960938 10.886719 7.960938 C 10.398438 7.960938 10 7.5625 10 7.078125 C 10 6.59375 10.398438 6.195312 10.886719 6.195312 C 11.371094 6.195312 11.773438 6.59375 11.773438 7.078125 C 11.773438 7.519531 11.445312 7.886719 11.019531 7.953125 Z M 11.019531 7.953125 "/>
|
||||
<path fill="black" d="M 7.269531 9.089844 C 7.53125 8.988281 7.828125 9.113281 7.933594 9.375 C 8.125 9.859375 8.503906 9.996094 8.796875 9.949219 C 9.082031 9.898438 9.378906 9.664062 9.378906 9.136719 C 9.378906 8.855469 9.605469 8.628906 9.886719 8.628906 C 10.167969 8.628906 10.398438 8.855469 10.398438 9.136719 C 10.398438 10.140625 9.757812 10.816406 8.96875 10.949219 C 8.1875 11.078125 7.351562 10.664062 6.988281 9.75 C 6.882812 9.488281 7.011719 9.195312 7.269531 9.089844 Z M 7.269531 9.089844 "/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
7
assets/icons/list_x.svg
Normal file
7
assets/icons/list_x.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.33333 8H3" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.6667 4H3" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.6667 12H3" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.6667 6.66663L11 9.33329" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11 6.66663L13.6667 9.33329" stroke="#FBF1C7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 579 B |
@@ -532,6 +532,7 @@
|
||||
"context": "ContextEditor > Editor",
|
||||
"bindings": {
|
||||
"ctrl-enter": "assistant::Assist",
|
||||
"ctrl-shift-enter": "assistant::Edit",
|
||||
"ctrl-s": "workspace::Save",
|
||||
"ctrl->": "assistant::QuoteSelection",
|
||||
"ctrl-<": "assistant::InsertIntoEditor",
|
||||
|
||||
@@ -201,6 +201,7 @@
|
||||
"context": "ContextEditor > Editor",
|
||||
"bindings": {
|
||||
"cmd-enter": "assistant::Assist",
|
||||
"cmd-shift-enter": "assistant::Edit",
|
||||
"cmd-s": "workspace::Save",
|
||||
"cmd->": "assistant::QuoteSelection",
|
||||
"cmd-<": "assistant::InsertIntoEditor",
|
||||
@@ -349,6 +350,7 @@
|
||||
"alt-cmd-]": "editor::UnfoldLines",
|
||||
"cmd-k cmd-l": "editor::ToggleFold",
|
||||
"cmd-k cmd-[": "editor::FoldRecursive",
|
||||
"cmd-k cmd-]": "editor::UnfoldRecursive",
|
||||
"cmd-k cmd-1": ["editor::FoldAtLevel", { "level": 1 }],
|
||||
"cmd-k cmd-2": ["editor::FoldAtLevel", { "level": 2 }],
|
||||
"cmd-k cmd-3": ["editor::FoldAtLevel", { "level": 3 }],
|
||||
|
||||
@@ -346,8 +346,6 @@
|
||||
"git_status": true,
|
||||
// Amount of indentation for nested items.
|
||||
"indent_size": 20,
|
||||
// Whether to show indent guides in the project panel.
|
||||
"indent_guides": true,
|
||||
// Whether to reveal it in the project panel automatically,
|
||||
// when a corresponding project entry becomes active.
|
||||
// Gitignored entries are never auto revealed.
|
||||
@@ -371,6 +369,17 @@
|
||||
/// 5. Never show the scrollbar:
|
||||
/// "never"
|
||||
"show": null
|
||||
},
|
||||
// Settings related to indent guides in the project panel.
|
||||
"indent_guides": {
|
||||
// When to show indent guides in the project panel.
|
||||
// This setting can take two values:
|
||||
//
|
||||
// 1. Always show indent guides:
|
||||
// "always"
|
||||
// 2. Never show indent guides:
|
||||
// "never"
|
||||
"show": "always"
|
||||
}
|
||||
},
|
||||
"outline_panel": {
|
||||
@@ -388,15 +397,24 @@
|
||||
"git_status": true,
|
||||
// Amount of indentation for nested items.
|
||||
"indent_size": 20,
|
||||
// Whether to show indent guides in the outline panel.
|
||||
"indent_guides": true,
|
||||
// Whether to reveal it in the outline panel automatically,
|
||||
// when a corresponding outline entry becomes active.
|
||||
// Gitignored entries are never auto revealed.
|
||||
"auto_reveal_entries": true,
|
||||
/// Whether to fold directories automatically
|
||||
/// when a directory has only one directory inside.
|
||||
"auto_fold_dirs": true
|
||||
"auto_fold_dirs": true,
|
||||
// Settings related to indent guides in the outline panel.
|
||||
"indent_guides": {
|
||||
// When to show indent guides in the outline panel.
|
||||
// This setting can take two values:
|
||||
//
|
||||
// 1. Always show indent guides:
|
||||
// "always"
|
||||
// 2. Never show indent guides:
|
||||
// "never"
|
||||
"show": "always"
|
||||
}
|
||||
},
|
||||
"collaboration_panel": {
|
||||
// Whether to show the collaboration panel button in the status bar.
|
||||
|
||||
@@ -23,6 +23,7 @@ language.workspace = true
|
||||
project.workspace = true
|
||||
smallvec.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
workspace.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -13,7 +13,8 @@ use language::{
|
||||
use project::{EnvironmentErrorMessage, LanguageServerProgress, Project, WorktreeId};
|
||||
use smallvec::SmallVec;
|
||||
use std::{cmp::Reverse, fmt::Write, sync::Arc, time::Duration};
|
||||
use ui::{prelude::*, ButtonLike, ContextMenu, PopoverMenu, PopoverMenuHandle};
|
||||
use ui::{prelude::*, ButtonLike, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip};
|
||||
use util::truncate_and_trailoff;
|
||||
use workspace::{item::ItemHandle, StatusItemView, Workspace};
|
||||
|
||||
actions!(activity_indicator, [ShowErrorMessage]);
|
||||
@@ -446,6 +447,8 @@ impl ActivityIndicator {
|
||||
|
||||
impl EventEmitter<Event> for ActivityIndicator {}
|
||||
|
||||
const MAX_MESSAGE_LEN: usize = 50;
|
||||
|
||||
impl Render for ActivityIndicator {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let result = h_flex()
|
||||
@@ -456,6 +459,7 @@ impl Render for ActivityIndicator {
|
||||
return result;
|
||||
};
|
||||
let this = cx.view().downgrade();
|
||||
let truncate_content = content.message.len() > MAX_MESSAGE_LEN;
|
||||
result.gap_2().child(
|
||||
PopoverMenu::new("activity-indicator-popover")
|
||||
.trigger(
|
||||
@@ -464,7 +468,21 @@ impl Render for ActivityIndicator {
|
||||
.id("activity-indicator-status")
|
||||
.gap_2()
|
||||
.children(content.icon)
|
||||
.child(Label::new(content.message).size(LabelSize::Small))
|
||||
.map(|button| {
|
||||
if truncate_content {
|
||||
button
|
||||
.child(
|
||||
Label::new(truncate_and_trailoff(
|
||||
&content.message,
|
||||
MAX_MESSAGE_LEN,
|
||||
))
|
||||
.size(LabelSize::Small),
|
||||
)
|
||||
.tooltip(move |cx| Tooltip::text(&content.message, cx))
|
||||
} else {
|
||||
button.child(Label::new(content.message).size(LabelSize::Small))
|
||||
}
|
||||
})
|
||||
.when_some(content.on_click, |this, handler| {
|
||||
this.on_click(cx.listener(move |this, _, cx| {
|
||||
handler(this, cx);
|
||||
|
||||
@@ -41,12 +41,10 @@ use prompts::PromptLoadingParams;
|
||||
use semantic_index::{CloudEmbeddingProvider, SemanticDb};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{update_settings_file, Settings, SettingsStore};
|
||||
use slash_command::workflow_command::WorkflowSlashCommand;
|
||||
use slash_command::{
|
||||
auto_command, cargo_workspace_command, context_server_command, default_command, delta_command,
|
||||
diagnostics_command, docs_command, fetch_command, file_command, now_command, project_command,
|
||||
prompt_command, search_command, symbols_command, tab_command, terminal_command,
|
||||
workflow_command,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -59,6 +57,7 @@ actions!(
|
||||
assistant,
|
||||
[
|
||||
Assist,
|
||||
Edit,
|
||||
Split,
|
||||
CopyCode,
|
||||
CycleMessageRole,
|
||||
@@ -444,22 +443,6 @@ fn register_slash_commands(prompt_builder: Option<Arc<PromptBuilder>>, cx: &mut
|
||||
slash_command_registry.register_command(fetch_command::FetchSlashCommand, false);
|
||||
|
||||
if let Some(prompt_builder) = prompt_builder {
|
||||
cx.observe_global::<SettingsStore>({
|
||||
let slash_command_registry = slash_command_registry.clone();
|
||||
let prompt_builder = prompt_builder.clone();
|
||||
move |cx| {
|
||||
if AssistantSettings::get_global(cx).are_live_diffs_enabled(cx) {
|
||||
slash_command_registry.register_command(
|
||||
workflow_command::WorkflowSlashCommand::new(prompt_builder.clone()),
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
slash_command_registry.unregister_command_by_name(WorkflowSlashCommand::NAME);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
cx.observe_flag::<project_command::ProjectSlashCommandFeatureFlag, _>({
|
||||
let slash_command_registry = slash_command_registry.clone();
|
||||
move |is_enabled, _cx| {
|
||||
|
||||
@@ -13,10 +13,11 @@ use crate::{
|
||||
terminal_inline_assistant::TerminalInlineAssistant,
|
||||
Assist, AssistantPatch, AssistantPatchStatus, CacheStatus, ConfirmCommand, Content, Context,
|
||||
ContextEvent, ContextId, ContextStore, ContextStoreEvent, CopyCode, CycleMessageRole,
|
||||
DeployHistory, DeployPromptLibrary, InlineAssistant, InsertDraggedFiles, InsertIntoEditor,
|
||||
Message, MessageId, MessageMetadata, MessageStatus, ModelPickerDelegate, ModelSelector,
|
||||
NewContext, PendingSlashCommand, PendingSlashCommandStatus, QuoteSelection,
|
||||
RemoteContextMetadata, SavedContextMetadata, Split, ToggleFocus, ToggleModelSelector,
|
||||
DeployHistory, DeployPromptLibrary, Edit, InlineAssistant, InsertDraggedFiles,
|
||||
InsertIntoEditor, Message, MessageId, MessageMetadata, MessageStatus, ModelPickerDelegate,
|
||||
ModelSelector, NewContext, PendingSlashCommand, PendingSlashCommandStatus, QuoteSelection,
|
||||
RemoteContextMetadata, RequestType, SavedContextMetadata, Split, ToggleFocus,
|
||||
ToggleModelSelector,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use assistant_slash_command::{SlashCommand, SlashCommandOutputSection};
|
||||
@@ -1461,6 +1462,7 @@ type MessageHeader = MessageMetadata;
|
||||
|
||||
#[derive(Clone)]
|
||||
enum AssistError {
|
||||
FileRequired,
|
||||
PaymentRequired,
|
||||
MaxMonthlySpendReached,
|
||||
Message(SharedString),
|
||||
@@ -1588,23 +1590,11 @@ impl ContextEditor {
|
||||
}
|
||||
|
||||
fn assist(&mut self, _: &Assist, cx: &mut ViewContext<Self>) {
|
||||
let provider = LanguageModelRegistry::read_global(cx).active_provider();
|
||||
if provider
|
||||
.as_ref()
|
||||
.map_or(false, |provider| provider.must_accept_terms(cx))
|
||||
{
|
||||
self.show_accept_terms = true;
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
self.send_to_model(RequestType::Chat, cx);
|
||||
}
|
||||
|
||||
if self.focus_active_patch(cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.last_error = None;
|
||||
self.send_to_model(cx);
|
||||
cx.notify();
|
||||
fn edit(&mut self, _: &Edit, cx: &mut ViewContext<Self>) {
|
||||
self.send_to_model(RequestType::SuggestEdits, cx);
|
||||
}
|
||||
|
||||
fn focus_active_patch(&mut self, cx: &mut ViewContext<Self>) -> bool {
|
||||
@@ -1622,8 +1612,30 @@ impl ContextEditor {
|
||||
false
|
||||
}
|
||||
|
||||
fn send_to_model(&mut self, cx: &mut ViewContext<Self>) {
|
||||
if let Some(user_message) = self.context.update(cx, |context, cx| context.assist(cx)) {
|
||||
fn send_to_model(&mut self, request_type: RequestType, cx: &mut ViewContext<Self>) {
|
||||
let provider = LanguageModelRegistry::read_global(cx).active_provider();
|
||||
if provider
|
||||
.as_ref()
|
||||
.map_or(false, |provider| provider.must_accept_terms(cx))
|
||||
{
|
||||
self.show_accept_terms = true;
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
if self.focus_active_patch(cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.last_error = None;
|
||||
|
||||
if request_type == RequestType::SuggestEdits && !self.context.read(cx).contains_files(cx) {
|
||||
self.last_error = Some(AssistError::FileRequired);
|
||||
cx.notify();
|
||||
} else if let Some(user_message) = self
|
||||
.context
|
||||
.update(cx, |context, cx| context.assist(request_type, cx))
|
||||
{
|
||||
let new_selection = {
|
||||
let cursor = user_message
|
||||
.start
|
||||
@@ -1640,6 +1652,8 @@ impl ContextEditor {
|
||||
// Avoid scrolling to the new cursor position so the assistant's output is stable.
|
||||
cx.defer(|this, _| this.scroll_position = None);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext<Self>) {
|
||||
@@ -1667,8 +1681,10 @@ impl ContextEditor {
|
||||
});
|
||||
}
|
||||
|
||||
fn cursors(&self, cx: &AppContext) -> Vec<usize> {
|
||||
let selections = self.editor.read(cx).selections.all::<usize>(cx);
|
||||
fn cursors(&self, cx: &mut WindowContext) -> Vec<usize> {
|
||||
let selections = self
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.selections.all::<usize>(cx));
|
||||
selections
|
||||
.into_iter()
|
||||
.map(|selection| selection.head())
|
||||
@@ -2375,7 +2391,9 @@ impl ContextEditor {
|
||||
}
|
||||
|
||||
fn update_active_patch(&mut self, cx: &mut ViewContext<Self>) {
|
||||
let newest_cursor = self.editor.read(cx).selections.newest::<Point>(cx).head();
|
||||
let newest_cursor = self.editor.update(cx, |editor, cx| {
|
||||
editor.selections.newest::<Point>(cx).head()
|
||||
});
|
||||
let context = self.context.read(cx);
|
||||
|
||||
let new_patch = context.patch_containing(newest_cursor, cx).cloned();
|
||||
@@ -2782,39 +2800,40 @@ impl ContextEditor {
|
||||
) -> Option<(String, bool)> {
|
||||
const CODE_FENCE_DELIMITER: &'static str = "```";
|
||||
|
||||
let context_editor = context_editor_view.read(cx).editor.read(cx);
|
||||
let context_editor = context_editor_view.read(cx).editor.clone();
|
||||
context_editor.update(cx, |context_editor, cx| {
|
||||
if context_editor.selections.newest::<Point>(cx).is_empty() {
|
||||
let snapshot = context_editor.buffer().read(cx).snapshot(cx);
|
||||
let (_, _, snapshot) = snapshot.as_singleton()?;
|
||||
|
||||
if context_editor.selections.newest::<Point>(cx).is_empty() {
|
||||
let snapshot = context_editor.buffer().read(cx).snapshot(cx);
|
||||
let (_, _, snapshot) = snapshot.as_singleton()?;
|
||||
let head = context_editor.selections.newest::<Point>(cx).head();
|
||||
let offset = snapshot.point_to_offset(head);
|
||||
|
||||
let head = context_editor.selections.newest::<Point>(cx).head();
|
||||
let offset = snapshot.point_to_offset(head);
|
||||
let surrounding_code_block_range = find_surrounding_code_block(snapshot, offset)?;
|
||||
let mut text = snapshot
|
||||
.text_for_range(surrounding_code_block_range)
|
||||
.collect::<String>();
|
||||
|
||||
let surrounding_code_block_range = find_surrounding_code_block(snapshot, offset)?;
|
||||
let mut text = snapshot
|
||||
.text_for_range(surrounding_code_block_range)
|
||||
.collect::<String>();
|
||||
// If there is no newline trailing the closing three-backticks, then
|
||||
// tree-sitter-md extends the range of the content node to include
|
||||
// the backticks.
|
||||
if text.ends_with(CODE_FENCE_DELIMITER) {
|
||||
text.drain((text.len() - CODE_FENCE_DELIMITER.len())..);
|
||||
}
|
||||
|
||||
// If there is no newline trailing the closing three-backticks, then
|
||||
// tree-sitter-md extends the range of the content node to include
|
||||
// the backticks.
|
||||
if text.ends_with(CODE_FENCE_DELIMITER) {
|
||||
text.drain((text.len() - CODE_FENCE_DELIMITER.len())..);
|
||||
(!text.is_empty()).then_some((text, true))
|
||||
} else {
|
||||
let anchor = context_editor.selections.newest_anchor();
|
||||
let text = context_editor
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.read(cx)
|
||||
.text_for_range(anchor.range())
|
||||
.collect::<String>();
|
||||
|
||||
(!text.is_empty()).then_some((text, false))
|
||||
}
|
||||
|
||||
(!text.is_empty()).then_some((text, true))
|
||||
} else {
|
||||
let anchor = context_editor.selections.newest_anchor();
|
||||
let text = context_editor
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.read(cx)
|
||||
.text_for_range(anchor.range())
|
||||
.collect::<String>();
|
||||
|
||||
(!text.is_empty()).then_some((text, false))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_selection(
|
||||
@@ -3644,7 +3663,13 @@ impl ContextEditor {
|
||||
button.tooltip(move |_| tooltip.clone())
|
||||
})
|
||||
.layer(ElevationIndex::ModalSurface)
|
||||
.child(Label::new("Send"))
|
||||
.child(Label::new(
|
||||
if AssistantSettings::get_global(cx).are_live_diffs_enabled(cx) {
|
||||
"Chat"
|
||||
} else {
|
||||
"Send"
|
||||
},
|
||||
))
|
||||
.children(
|
||||
KeyBinding::for_action_in(&Assist, &focus_handle, cx)
|
||||
.map(|binding| binding.into_any_element()),
|
||||
@@ -3654,6 +3679,57 @@ impl ContextEditor {
|
||||
})
|
||||
}
|
||||
|
||||
fn render_edit_button(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let focus_handle = self.focus_handle(cx).clone();
|
||||
|
||||
let (style, tooltip) = match token_state(&self.context, cx) {
|
||||
Some(TokenState::NoTokensLeft { .. }) => (
|
||||
ButtonStyle::Tinted(TintColor::Negative),
|
||||
Some(Tooltip::text("Token limit reached", cx)),
|
||||
),
|
||||
Some(TokenState::HasMoreTokens {
|
||||
over_warn_threshold,
|
||||
..
|
||||
}) => {
|
||||
let (style, tooltip) = if over_warn_threshold {
|
||||
(
|
||||
ButtonStyle::Tinted(TintColor::Warning),
|
||||
Some(Tooltip::text("Token limit is close to exhaustion", cx)),
|
||||
)
|
||||
} else {
|
||||
(ButtonStyle::Filled, None)
|
||||
};
|
||||
(style, tooltip)
|
||||
}
|
||||
None => (ButtonStyle::Filled, None),
|
||||
};
|
||||
|
||||
let provider = LanguageModelRegistry::read_global(cx).active_provider();
|
||||
|
||||
let has_configuration_error = configuration_error(cx).is_some();
|
||||
let needs_to_accept_terms = self.show_accept_terms
|
||||
&& provider
|
||||
.as_ref()
|
||||
.map_or(false, |provider| provider.must_accept_terms(cx));
|
||||
let disabled = has_configuration_error || needs_to_accept_terms;
|
||||
|
||||
ButtonLike::new("edit_button")
|
||||
.disabled(disabled)
|
||||
.style(style)
|
||||
.when_some(tooltip, |button, tooltip| {
|
||||
button.tooltip(move |_| tooltip.clone())
|
||||
})
|
||||
.layer(ElevationIndex::ModalSurface)
|
||||
.child(Label::new("Suggest Edits"))
|
||||
.children(
|
||||
KeyBinding::for_action_in(&Edit, &focus_handle, cx)
|
||||
.map(|binding| binding.into_any_element()),
|
||||
)
|
||||
.on_click(move |_event, cx| {
|
||||
focus_handle.dispatch_action(&Edit, cx);
|
||||
})
|
||||
}
|
||||
|
||||
fn render_last_error(&self, cx: &mut ViewContext<Self>) -> Option<AnyElement> {
|
||||
let last_error = self.last_error.as_ref()?;
|
||||
|
||||
@@ -3668,6 +3744,7 @@ impl ContextEditor {
|
||||
.elevation_2(cx)
|
||||
.occlude()
|
||||
.child(match last_error {
|
||||
AssistError::FileRequired => self.render_file_required_error(cx),
|
||||
AssistError::PaymentRequired => self.render_payment_required_error(cx),
|
||||
AssistError::MaxMonthlySpendReached => {
|
||||
self.render_max_monthly_spend_reached_error(cx)
|
||||
@@ -3680,6 +3757,41 @@ impl ContextEditor {
|
||||
)
|
||||
}
|
||||
|
||||
fn render_file_required_error(&self, cx: &mut ViewContext<Self>) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.items_center()
|
||||
.child(Icon::new(IconName::Warning).color(Color::Warning))
|
||||
.child(
|
||||
Label::new("Suggest Edits needs a file to edit").weight(FontWeight::MEDIUM),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("error-message")
|
||||
.max_h_24()
|
||||
.overflow_y_scroll()
|
||||
.child(Label::new(
|
||||
"To include files, type /file or /tab in your prompt.",
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_end()
|
||||
.mt_1()
|
||||
.child(Button::new("dismiss", "Dismiss").on_click(cx.listener(
|
||||
|this, _, cx| {
|
||||
this.last_error = None;
|
||||
cx.notify();
|
||||
},
|
||||
))),
|
||||
)
|
||||
.into_any()
|
||||
}
|
||||
|
||||
fn render_payment_required_error(&self, cx: &mut ViewContext<Self>) -> AnyElement {
|
||||
const ERROR_MESSAGE: &str = "Free tier exceeded. Subscribe and add payment to continue using Zed LLMs. You'll be billed at cost for tokens used.";
|
||||
|
||||
@@ -3910,6 +4022,7 @@ impl Render for ContextEditor {
|
||||
.capture_action(cx.listener(ContextEditor::paste))
|
||||
.capture_action(cx.listener(ContextEditor::cycle_message_role))
|
||||
.capture_action(cx.listener(ContextEditor::confirm_command))
|
||||
.on_action(cx.listener(ContextEditor::edit))
|
||||
.on_action(cx.listener(ContextEditor::assist))
|
||||
.on_action(cx.listener(ContextEditor::split))
|
||||
.size_full()
|
||||
@@ -3974,7 +4087,21 @@ impl Render for ContextEditor {
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_end()
|
||||
.child(div().child(self.render_send_button(cx))),
|
||||
.when(
|
||||
AssistantSettings::get_global(cx).are_live_diffs_enabled(cx),
|
||||
|buttons| {
|
||||
buttons
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.child(self.render_edit_button(cx))
|
||||
.child(
|
||||
Label::new("or")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
},
|
||||
)
|
||||
.child(self.render_send_button(cx)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
mod context_tests;
|
||||
|
||||
use crate::{
|
||||
prompts::PromptBuilder, slash_command::SlashCommandLine, AssistantEdit, AssistantPatch,
|
||||
AssistantPatchStatus, MessageId, MessageStatus,
|
||||
prompts::PromptBuilder,
|
||||
slash_command::{file_command::FileCommandMetadata, SlashCommandLine},
|
||||
AssistantEdit, AssistantPatch, AssistantPatchStatus, MessageId, MessageStatus,
|
||||
};
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use assistant_slash_command::{
|
||||
@@ -66,6 +67,14 @@ impl ContextId {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RequestType {
|
||||
/// Request a normal chat response from the model.
|
||||
Chat,
|
||||
/// Add a preamble to the message, which tells the model to return a structured response that suggests edits.
|
||||
SuggestEdits,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ContextOperation {
|
||||
InsertMessage {
|
||||
@@ -981,6 +990,20 @@ impl Context {
|
||||
&self.slash_command_output_sections
|
||||
}
|
||||
|
||||
pub fn contains_files(&self, cx: &AppContext) -> bool {
|
||||
let buffer = self.buffer.read(cx);
|
||||
self.slash_command_output_sections.iter().any(|section| {
|
||||
section.is_valid(buffer)
|
||||
&& section
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| {
|
||||
serde_json::from_value::<FileCommandMetadata>(metadata.clone()).ok()
|
||||
})
|
||||
.is_some()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pending_tool_uses(&self) -> Vec<&PendingToolUse> {
|
||||
self.pending_tool_uses_by_id.values().collect()
|
||||
}
|
||||
@@ -1028,7 +1051,7 @@ impl Context {
|
||||
}
|
||||
|
||||
pub(crate) fn count_remaining_tokens(&mut self, cx: &mut ModelContext<Self>) {
|
||||
let request = self.to_completion_request(cx);
|
||||
let request = self.to_completion_request(RequestType::SuggestEdits, cx); // Conservatively assume SuggestEdits, since it takes more tokens.
|
||||
let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else {
|
||||
return;
|
||||
};
|
||||
@@ -1171,7 +1194,7 @@ impl Context {
|
||||
}
|
||||
|
||||
let request = {
|
||||
let mut req = self.to_completion_request(cx);
|
||||
let mut req = self.to_completion_request(RequestType::Chat, cx);
|
||||
// Skip the last message because it's likely to change and
|
||||
// therefore would be a waste to cache.
|
||||
req.messages.pop();
|
||||
@@ -1859,7 +1882,11 @@ impl Context {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn assist(&mut self, cx: &mut ModelContext<Self>) -> Option<MessageAnchor> {
|
||||
pub fn assist(
|
||||
&mut self,
|
||||
request_type: RequestType,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Option<MessageAnchor> {
|
||||
let model_registry = LanguageModelRegistry::read_global(cx);
|
||||
let provider = model_registry.active_provider()?;
|
||||
let model = model_registry.active_model()?;
|
||||
@@ -1872,7 +1899,7 @@ impl Context {
|
||||
// Compute which messages to cache, including the last one.
|
||||
self.mark_cache_anchors(&model.cache_configuration(), false, cx);
|
||||
|
||||
let mut request = self.to_completion_request(cx);
|
||||
let mut request = self.to_completion_request(request_type, cx);
|
||||
|
||||
if cx.has_flag::<ToolUseFeatureFlag>() {
|
||||
let tool_registry = ToolRegistry::global(cx);
|
||||
@@ -2074,7 +2101,11 @@ impl Context {
|
||||
Some(user_message)
|
||||
}
|
||||
|
||||
pub fn to_completion_request(&self, cx: &AppContext) -> LanguageModelRequest {
|
||||
pub fn to_completion_request(
|
||||
&self,
|
||||
request_type: RequestType,
|
||||
cx: &AppContext,
|
||||
) -> LanguageModelRequest {
|
||||
let buffer = self.buffer.read(cx);
|
||||
|
||||
let mut contents = self.contents(cx).peekable();
|
||||
@@ -2163,6 +2194,25 @@ impl Context {
|
||||
completion_request.messages.push(request_message);
|
||||
}
|
||||
|
||||
if let RequestType::SuggestEdits = request_type {
|
||||
if let Ok(preamble) = self.prompt_builder.generate_workflow_prompt() {
|
||||
let last_elem_index = completion_request.messages.len();
|
||||
|
||||
completion_request
|
||||
.messages
|
||||
.push(LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec![MessageContent::Text(preamble)],
|
||||
cache: false,
|
||||
});
|
||||
|
||||
// The preamble message should be sent right before the last actual user message.
|
||||
completion_request
|
||||
.messages
|
||||
.swap(last_elem_index, last_elem_index.saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
completion_request
|
||||
}
|
||||
|
||||
@@ -2477,7 +2527,7 @@ impl Context {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut request = self.to_completion_request(cx);
|
||||
let mut request = self.to_completion_request(RequestType::Chat, cx);
|
||||
request.messages.push(LanguageModelRequestMessage {
|
||||
role: Role::User,
|
||||
content: vec![
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
assistant_settings::AssistantSettings, humanize_token_count, prompts::PromptBuilder,
|
||||
AssistantPanel, AssistantPanelEvent, CharOperation, CycleNextInlineAssist,
|
||||
CyclePreviousInlineAssist, LineDiff, LineOperation, ModelSelector, StreamingDiff,
|
||||
CyclePreviousInlineAssist, LineDiff, LineOperation, ModelSelector, RequestType, StreamingDiff,
|
||||
};
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use client::{telemetry::Telemetry, ErrorExt};
|
||||
@@ -189,11 +189,16 @@ impl InlineAssistant {
|
||||
initial_prompt: Option<String>,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
|
||||
let (snapshot, initial_selections) = editor.update(cx, |editor, cx| {
|
||||
(
|
||||
editor.buffer().read(cx).snapshot(cx),
|
||||
editor.selections.all::<Point>(cx),
|
||||
)
|
||||
});
|
||||
|
||||
let mut selections = Vec::<Selection<Point>>::new();
|
||||
let mut newest_selection = None;
|
||||
for mut selection in editor.read(cx).selections.all::<Point>(cx) {
|
||||
for mut selection in initial_selections {
|
||||
if selection.end > selection.start {
|
||||
selection.start.column = 0;
|
||||
// If the selection ends at the start of the line, we don't want to include it.
|
||||
@@ -566,10 +571,13 @@ impl InlineAssistant {
|
||||
return;
|
||||
};
|
||||
|
||||
let editor = editor.read(cx);
|
||||
if editor.selections.count() == 1 {
|
||||
let selection = editor.selections.newest::<usize>(cx);
|
||||
let buffer = editor.buffer().read(cx).snapshot(cx);
|
||||
if editor.read(cx).selections.count() == 1 {
|
||||
let (selection, buffer) = editor.update(cx, |editor, cx| {
|
||||
(
|
||||
editor.selections.newest::<usize>(cx),
|
||||
editor.buffer().read(cx).snapshot(cx),
|
||||
)
|
||||
});
|
||||
for assist_id in &editor_assists.assist_ids {
|
||||
let assist = &self.assists[assist_id];
|
||||
let assist_range = assist.range.to_offset(&buffer);
|
||||
@@ -594,10 +602,13 @@ impl InlineAssistant {
|
||||
return;
|
||||
};
|
||||
|
||||
let editor = editor.read(cx);
|
||||
if editor.selections.count() == 1 {
|
||||
let selection = editor.selections.newest::<usize>(cx);
|
||||
let buffer = editor.buffer().read(cx).snapshot(cx);
|
||||
if editor.read(cx).selections.count() == 1 {
|
||||
let (selection, buffer) = editor.update(cx, |editor, cx| {
|
||||
(
|
||||
editor.selections.newest::<usize>(cx),
|
||||
editor.buffer().read(cx).snapshot(cx),
|
||||
)
|
||||
});
|
||||
let mut closest_assist_fallback = None;
|
||||
for assist_id in &editor_assists.assist_ids {
|
||||
let assist = &self.assists[assist_id];
|
||||
@@ -2234,7 +2245,7 @@ impl InlineAssist {
|
||||
.read(cx)
|
||||
.active_context(cx)?
|
||||
.read(cx)
|
||||
.to_completion_request(cx),
|
||||
.to_completion_request(RequestType::Chat, cx),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -311,7 +311,7 @@ impl PromptBuilder {
|
||||
}
|
||||
|
||||
pub fn generate_workflow_prompt(&self) -> Result<String, RenderError> {
|
||||
self.handlebars.lock().render("edit_workflow", &())
|
||||
self.handlebars.lock().render("suggest_edits", &())
|
||||
}
|
||||
|
||||
pub fn generate_project_slash_command_prompt(
|
||||
|
||||
@@ -34,7 +34,6 @@ pub mod search_command;
|
||||
pub mod symbols_command;
|
||||
pub mod tab_command;
|
||||
pub mod terminal_command;
|
||||
pub mod workflow_command;
|
||||
|
||||
pub(crate) struct SlashCommandCompletionProvider {
|
||||
cancel_flag: Mutex<Arc<AtomicBool>>,
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use assistant_slash_command::{
|
||||
ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
|
||||
SlashCommandResult,
|
||||
};
|
||||
use gpui::{Task, WeakView};
|
||||
use language::{BufferSnapshot, LspAdapterDelegate};
|
||||
use ui::prelude::*;
|
||||
use workspace::Workspace;
|
||||
|
||||
use crate::prompts::PromptBuilder;
|
||||
|
||||
pub(crate) struct WorkflowSlashCommand {
|
||||
prompt_builder: Arc<PromptBuilder>,
|
||||
}
|
||||
|
||||
impl WorkflowSlashCommand {
|
||||
pub const NAME: &'static str = "workflow";
|
||||
|
||||
pub fn new(prompt_builder: Arc<PromptBuilder>) -> Self {
|
||||
Self { prompt_builder }
|
||||
}
|
||||
}
|
||||
|
||||
impl SlashCommand for WorkflowSlashCommand {
|
||||
fn name(&self) -> String {
|
||||
Self::NAME.into()
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
"Insert prompt to opt into the edit workflow".into()
|
||||
}
|
||||
|
||||
fn menu_text(&self) -> String {
|
||||
self.description()
|
||||
}
|
||||
|
||||
fn requires_argument(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn complete_argument(
|
||||
self: Arc<Self>,
|
||||
_arguments: &[String],
|
||||
_cancel: Arc<AtomicBool>,
|
||||
_workspace: Option<WeakView<Workspace>>,
|
||||
_cx: &mut WindowContext,
|
||||
) -> Task<Result<Vec<ArgumentCompletion>>> {
|
||||
Task::ready(Ok(Vec::new()))
|
||||
}
|
||||
|
||||
fn run(
|
||||
self: Arc<Self>,
|
||||
_arguments: &[String],
|
||||
_context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
|
||||
_context_buffer: BufferSnapshot,
|
||||
_workspace: WeakView<Workspace>,
|
||||
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
|
||||
cx: &mut WindowContext,
|
||||
) -> Task<SlashCommandResult> {
|
||||
let prompt_builder = self.prompt_builder.clone();
|
||||
cx.spawn(|_cx| async move {
|
||||
let text = prompt_builder.generate_workflow_prompt()?;
|
||||
let range = 0..text.len();
|
||||
|
||||
Ok(SlashCommandOutput {
|
||||
text,
|
||||
sections: vec![SlashCommandOutputSection {
|
||||
range,
|
||||
icon: IconName::Route,
|
||||
label: "Workflow".into(),
|
||||
metadata: None,
|
||||
}],
|
||||
run_commands_in_text: false,
|
||||
}
|
||||
.to_event_stream())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
humanize_token_count, prompts::PromptBuilder, AssistantPanel, AssistantPanelEvent,
|
||||
ModelSelector, DEFAULT_CONTEXT_LINES,
|
||||
ModelSelector, RequestType, DEFAULT_CONTEXT_LINES,
|
||||
};
|
||||
use anyhow::{Context as _, Result};
|
||||
use client::telemetry::Telemetry;
|
||||
@@ -251,7 +251,7 @@ impl TerminalInlineAssistant {
|
||||
.read(cx)
|
||||
.active_context(cx)?
|
||||
.read(cx)
|
||||
.to_completion_request(cx),
|
||||
.to_completion_request(RequestType::Chat, cx),
|
||||
)
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -84,9 +84,9 @@ pub struct AutoUpdater {
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JsonRelease {
|
||||
version: String,
|
||||
url: String,
|
||||
pub struct JsonRelease {
|
||||
pub version: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
struct MacOsUnmounter {
|
||||
@@ -482,7 +482,7 @@ impl AutoUpdater {
|
||||
release_channel: ReleaseChannel,
|
||||
version: Option<SemanticVersion>,
|
||||
cx: &mut AsyncAppContext,
|
||||
) -> Result<(String, String)> {
|
||||
) -> Result<(JsonRelease, String)> {
|
||||
let this = cx.update(|cx| {
|
||||
cx.default_global::<GlobalAutoUpdate>()
|
||||
.0
|
||||
@@ -504,7 +504,7 @@ impl AutoUpdater {
|
||||
let update_request_body = build_remote_server_update_request_body(cx)?;
|
||||
let body = serde_json::to_string(&update_request_body)?;
|
||||
|
||||
Ok((release.url, body))
|
||||
Ok((release, body))
|
||||
}
|
||||
|
||||
async fn get_release(
|
||||
|
||||
@@ -48,6 +48,7 @@ pub struct Collaborator {
|
||||
pub peer_id: proto::PeerId,
|
||||
pub replica_id: ReplicaId,
|
||||
pub user_id: UserId,
|
||||
pub is_host: bool,
|
||||
}
|
||||
|
||||
impl PartialOrd for User {
|
||||
@@ -824,6 +825,7 @@ impl Collaborator {
|
||||
peer_id: message.peer_id.ok_or_else(|| anyhow!("invalid peer id"))?,
|
||||
replica_id: message.replica_id as ReplicaId,
|
||||
user_id: message.user_id as UserId,
|
||||
is_host: message.is_host,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -740,6 +740,7 @@ impl ProjectCollaborator {
|
||||
peer_id: Some(self.connection_id.into()),
|
||||
replica_id: self.replica_id.0 as u32,
|
||||
user_id: self.user_id.to_proto(),
|
||||
is_host: self.is_host,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ impl Database {
|
||||
peer_id: Some(collaborator.connection().into()),
|
||||
user_id: collaborator.user_id.to_proto(),
|
||||
replica_id: collaborator.replica_id.0 as u32,
|
||||
is_host: false,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
@@ -222,6 +223,7 @@ impl Database {
|
||||
peer_id: Some(collaborator.connection().into()),
|
||||
user_id: collaborator.user_id.to_proto(),
|
||||
replica_id: collaborator.replica_id.0 as u32,
|
||||
is_host: false,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
@@ -257,6 +259,7 @@ impl Database {
|
||||
peer_id: Some(db_collaborator.connection().into()),
|
||||
replica_id: db_collaborator.replica_id.0 as u32,
|
||||
user_id: db_collaborator.user_id.to_proto(),
|
||||
is_host: false,
|
||||
})
|
||||
} else {
|
||||
collaborator_ids_to_remove.push(db_collaborator.id);
|
||||
@@ -385,6 +388,7 @@ impl Database {
|
||||
peer_id: Some(connection.into()),
|
||||
replica_id: row.replica_id.0 as u32,
|
||||
user_id: row.user_id.to_proto(),
|
||||
is_host: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -121,11 +121,13 @@ async fn test_channel_buffers(db: &Arc<Database>) {
|
||||
user_id: a_id.to_proto(),
|
||||
peer_id: Some(rpc::proto::PeerId { id: 1, owner_id }),
|
||||
replica_id: 0,
|
||||
is_host: false,
|
||||
},
|
||||
rpc::proto::Collaborator {
|
||||
user_id: b_id.to_proto(),
|
||||
peer_id: Some(rpc::proto::PeerId { id: 2, owner_id }),
|
||||
replica_id: 1,
|
||||
is_host: false,
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1827,6 +1827,7 @@ fn join_project_internal(
|
||||
peer_id: Some(session.connection_id.into()),
|
||||
replica_id: replica_id.0 as u32,
|
||||
user_id: guest_user_id.to_proto(),
|
||||
is_host: false,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -1978,6 +1978,7 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
|
||||
enabled: false,
|
||||
delay_ms: None,
|
||||
min_column: None,
|
||||
show_commit_summary: false,
|
||||
});
|
||||
cx_a.update(|cx| {
|
||||
SettingsStore::update_global(cx, |store, cx| {
|
||||
|
||||
@@ -1957,9 +1957,10 @@ async fn test_following_to_channel_notes_without_a_shared_project(
|
||||
});
|
||||
channel_notes_1_b.update(cx_b, |notes, cx| {
|
||||
assert_eq!(notes.channel(cx).unwrap().name, "channel-1");
|
||||
let editor = notes.editor.read(cx);
|
||||
assert_eq!(editor.text(cx), "Hello from A.");
|
||||
assert_eq!(editor.selections.ranges::<usize>(cx), &[3..4]);
|
||||
notes.editor.update(cx, |editor, cx| {
|
||||
assert_eq!(editor.text(cx), "Hello from A.");
|
||||
assert_eq!(editor.selections.ranges::<usize>(cx), &[3..4]);
|
||||
})
|
||||
});
|
||||
|
||||
// Client A opens the notes for channel 2.
|
||||
|
||||
@@ -21,8 +21,8 @@ use language::{
|
||||
language_settings::{
|
||||
AllLanguageSettings, Formatter, FormatterList, PrettierSettings, SelectedFormatter,
|
||||
},
|
||||
tree_sitter_rust, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language, LanguageConfig,
|
||||
LanguageMatcher, LineEnding, OffsetRangeExt, Point, Rope,
|
||||
tree_sitter_rust, tree_sitter_typescript, Diagnostic, DiagnosticEntry, FakeLspAdapter,
|
||||
Language, LanguageConfig, LanguageMatcher, LineEnding, OffsetRangeExt, Point, Rope,
|
||||
};
|
||||
use live_kit_client::MacOSDisplay;
|
||||
use lsp::LanguageServerId;
|
||||
@@ -4461,7 +4461,7 @@ async fn test_prettier_formatting_buffer(
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
Some(tree_sitter_rust::LANGUAGE.into()),
|
||||
Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
|
||||
)));
|
||||
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
|
||||
"TypeScript",
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
use crate::tests::TestServer;
|
||||
use call::ActiveCall;
|
||||
use collections::HashSet;
|
||||
use fs::{FakeFs, Fs as _};
|
||||
use gpui::{BackgroundExecutor, Context as _, TestAppContext};
|
||||
use futures::StreamExt as _;
|
||||
use gpui::{BackgroundExecutor, Context as _, TestAppContext, UpdateGlobal as _};
|
||||
use http_client::BlockedHttpClient;
|
||||
use language::{language_settings::language_settings, LanguageRegistry};
|
||||
use language::{
|
||||
language_settings::{
|
||||
language_settings, AllLanguageSettings, Formatter, FormatterList, PrettierSettings,
|
||||
SelectedFormatter,
|
||||
},
|
||||
tree_sitter_typescript, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher,
|
||||
LanguageRegistry,
|
||||
};
|
||||
use node_runtime::NodeRuntime;
|
||||
use project::ProjectPath;
|
||||
use project::{
|
||||
lsp_store::{FormatTarget, FormatTrigger},
|
||||
ProjectPath,
|
||||
};
|
||||
use remote::SshRemoteClient;
|
||||
use remote_server::{HeadlessAppState, HeadlessProject};
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
#[gpui::test(iterations = 10)]
|
||||
@@ -304,3 +317,181 @@ async fn test_ssh_collaboration_git_branches(
|
||||
|
||||
assert_eq!(server_branch.as_ref(), "totally-new-branch");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_ssh_collaboration_formatting_with_prettier(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
server_cx: &mut TestAppContext,
|
||||
) {
|
||||
cx_a.set_name("a");
|
||||
cx_b.set_name("b");
|
||||
server_cx.set_name("server");
|
||||
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
server
|
||||
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
|
||||
.await;
|
||||
|
||||
let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
|
||||
let remote_fs = FakeFs::new(server_cx.executor());
|
||||
let buffer_text = "let one = \"two\"";
|
||||
let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX;
|
||||
remote_fs
|
||||
.insert_tree("/project", serde_json::json!({ "a.ts": buffer_text }))
|
||||
.await;
|
||||
|
||||
let test_plugin = "test_plugin";
|
||||
let ts_lang = Arc::new(Language::new(
|
||||
LanguageConfig {
|
||||
name: "TypeScript".into(),
|
||||
matcher: LanguageMatcher {
|
||||
path_suffixes: vec!["ts".to_string()],
|
||||
..LanguageMatcher::default()
|
||||
},
|
||||
..LanguageConfig::default()
|
||||
},
|
||||
Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
|
||||
));
|
||||
client_a.language_registry().add(ts_lang.clone());
|
||||
client_b.language_registry().add(ts_lang.clone());
|
||||
|
||||
let languages = Arc::new(LanguageRegistry::new(server_cx.executor()));
|
||||
let mut fake_language_servers = languages.register_fake_lsp(
|
||||
"TypeScript",
|
||||
FakeLspAdapter {
|
||||
prettier_plugins: vec![test_plugin],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
// User A connects to the remote project via SSH.
|
||||
server_cx.update(HeadlessProject::init);
|
||||
let remote_http_client = Arc::new(BlockedHttpClient);
|
||||
let _headless_project = server_cx.new_model(|cx| {
|
||||
client::init_settings(cx);
|
||||
HeadlessProject::new(
|
||||
HeadlessAppState {
|
||||
session: server_ssh,
|
||||
fs: remote_fs.clone(),
|
||||
http_client: remote_http_client,
|
||||
node_runtime: NodeRuntime::unavailable(),
|
||||
languages,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await;
|
||||
let (project_a, worktree_id) = client_a
|
||||
.build_ssh_project("/project", client_ssh, cx_a)
|
||||
.await;
|
||||
|
||||
// While the SSH worktree is being scanned, user A shares the remote project.
|
||||
let active_call_a = cx_a.read(ActiveCall::global);
|
||||
let project_id = active_call_a
|
||||
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// User B joins the project.
|
||||
let project_b = client_b.join_remote_project(project_id, cx_b).await;
|
||||
executor.run_until_parked();
|
||||
|
||||
// Opens the buffer and formats it
|
||||
let buffer_b = project_b
|
||||
.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.ts"), cx))
|
||||
.await
|
||||
.expect("user B opens buffer for formatting");
|
||||
|
||||
cx_a.update(|cx| {
|
||||
SettingsStore::update_global(cx, |store, cx| {
|
||||
store.update_user_settings::<AllLanguageSettings>(cx, |file| {
|
||||
file.defaults.formatter = Some(SelectedFormatter::Auto);
|
||||
file.defaults.prettier = Some(PrettierSettings {
|
||||
allowed: true,
|
||||
..PrettierSettings::default()
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
cx_b.update(|cx| {
|
||||
SettingsStore::update_global(cx, |store, cx| {
|
||||
store.update_user_settings::<AllLanguageSettings>(cx, |file| {
|
||||
file.defaults.formatter = Some(SelectedFormatter::List(FormatterList(
|
||||
vec![Formatter::LanguageServer { name: None }].into(),
|
||||
)));
|
||||
file.defaults.prettier = Some(PrettierSettings {
|
||||
allowed: true,
|
||||
..PrettierSettings::default()
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
let fake_language_server = fake_language_servers.next().await.unwrap();
|
||||
fake_language_server.handle_request::<lsp::request::Formatting, _, _>(|_, _| async move {
|
||||
panic!(
|
||||
"Unexpected: prettier should be preferred since it's enabled and language supports it"
|
||||
)
|
||||
});
|
||||
|
||||
project_b
|
||||
.update(cx_b, |project, cx| {
|
||||
project.format(
|
||||
HashSet::from_iter([buffer_b.clone()]),
|
||||
true,
|
||||
FormatTrigger::Save,
|
||||
FormatTarget::Buffer,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
assert_eq!(
|
||||
buffer_b.read_with(cx_b, |buffer, _| buffer.text()),
|
||||
buffer_text.to_string() + "\n" + prettier_format_suffix,
|
||||
"Prettier formatting was not applied to client buffer after client's request"
|
||||
);
|
||||
|
||||
// User A opens and formats the same buffer too
|
||||
let buffer_a = project_a
|
||||
.update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.ts"), cx))
|
||||
.await
|
||||
.expect("user A opens buffer for formatting");
|
||||
|
||||
cx_a.update(|cx| {
|
||||
SettingsStore::update_global(cx, |store, cx| {
|
||||
store.update_user_settings::<AllLanguageSettings>(cx, |file| {
|
||||
file.defaults.formatter = Some(SelectedFormatter::Auto);
|
||||
file.defaults.prettier = Some(PrettierSettings {
|
||||
allowed: true,
|
||||
..PrettierSettings::default()
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
project_a
|
||||
.update(cx_a, |project, cx| {
|
||||
project.format(
|
||||
HashSet::from_iter([buffer_a.clone()]),
|
||||
true,
|
||||
FormatTrigger::Manual,
|
||||
FormatTarget::Buffer,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
executor.run_until_parked();
|
||||
assert_eq!(
|
||||
buffer_b.read_with(cx_b, |buffer, _| buffer.text()),
|
||||
buffer_text.to_string() + "\n" + prettier_format_suffix + "\n" + prettier_format_suffix,
|
||||
"Prettier formatting was not applied to client buffer after host's request"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,11 +136,12 @@ impl DiagnosticIndicator {
|
||||
}
|
||||
|
||||
fn update(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
|
||||
let editor = editor.read(cx);
|
||||
let buffer = editor.buffer().read(cx);
|
||||
let cursor_position = editor.selections.newest::<usize>(cx).head();
|
||||
let (buffer, cursor_position) = editor.update(cx, |editor, cx| {
|
||||
let buffer = editor.buffer().read(cx).snapshot(cx);
|
||||
let cursor_position = editor.selections.newest::<usize>(cx).head();
|
||||
(buffer, cursor_position)
|
||||
});
|
||||
let new_diagnostic = buffer
|
||||
.snapshot(cx)
|
||||
.diagnostics_in_range::<_, usize>(cursor_position..cursor_position, false)
|
||||
.filter(|entry| !entry.range.is_empty())
|
||||
.min_by_key(|entry| (entry.diagnostic.severity, entry.range.len()))
|
||||
|
||||
@@ -76,6 +76,7 @@ theme.workspace = true
|
||||
tree-sitter-html = { workspace = true, optional = true }
|
||||
tree-sitter-rust = { workspace = true, optional = true }
|
||||
tree-sitter-typescript = { workspace = true, optional = true }
|
||||
unicode-segmentation.workspace = true
|
||||
unindent = { workspace = true, optional = true }
|
||||
ui.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
@@ -21,6 +21,7 @@ mod block_map;
|
||||
mod crease_map;
|
||||
mod fold_map;
|
||||
mod inlay_map;
|
||||
pub(crate) mod invisibles;
|
||||
mod tab_map;
|
||||
mod wrap_map;
|
||||
|
||||
@@ -42,6 +43,7 @@ use gpui::{
|
||||
pub(crate) use inlay_map::Inlay;
|
||||
use inlay_map::{InlayMap, InlaySnapshot};
|
||||
pub use inlay_map::{InlayOffset, InlayPoint};
|
||||
use invisibles::{is_invisible, replacement};
|
||||
use language::{
|
||||
language_settings::language_settings, ChunkRenderer, OffsetUtf16, Point,
|
||||
Subscription as BufferSubscription,
|
||||
@@ -56,6 +58,7 @@ use std::{
|
||||
any::TypeId,
|
||||
borrow::Cow,
|
||||
fmt::Debug,
|
||||
iter,
|
||||
num::NonZeroU32,
|
||||
ops::{Add, Range, Sub},
|
||||
sync::Arc,
|
||||
@@ -63,7 +66,8 @@ use std::{
|
||||
use sum_tree::{Bias, TreeMap};
|
||||
use tab_map::{TabMap, TabSnapshot};
|
||||
use text::LineIndent;
|
||||
use ui::WindowContext;
|
||||
use ui::{div, px, IntoElement, ParentElement, SharedString, Styled, WindowContext};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use wrap_map::{WrapMap, WrapSnapshot};
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
@@ -461,6 +465,98 @@ pub struct HighlightedChunk<'a> {
|
||||
pub renderer: Option<ChunkRenderer>,
|
||||
}
|
||||
|
||||
impl<'a> HighlightedChunk<'a> {
|
||||
fn highlight_invisibles(
|
||||
self,
|
||||
editor_style: &'a EditorStyle,
|
||||
) -> impl Iterator<Item = Self> + 'a {
|
||||
let mut chars = self.text.chars().peekable();
|
||||
let mut text = self.text;
|
||||
let style = self.style;
|
||||
let is_tab = self.is_tab;
|
||||
let renderer = self.renderer;
|
||||
iter::from_fn(move || {
|
||||
let mut prefix_len = 0;
|
||||
while let Some(&ch) = chars.peek() {
|
||||
if !is_invisible(ch) {
|
||||
prefix_len += ch.len_utf8();
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
if prefix_len > 0 {
|
||||
let (prefix, suffix) = text.split_at(prefix_len);
|
||||
text = suffix;
|
||||
return Some(HighlightedChunk {
|
||||
text: prefix,
|
||||
style,
|
||||
is_tab,
|
||||
renderer: renderer.clone(),
|
||||
});
|
||||
}
|
||||
chars.next();
|
||||
let (prefix, suffix) = text.split_at(ch.len_utf8());
|
||||
text = suffix;
|
||||
if let Some(replacement) = replacement(ch) {
|
||||
let background = editor_style.status.hint_background;
|
||||
let underline = editor_style.status.hint;
|
||||
return Some(HighlightedChunk {
|
||||
text: prefix,
|
||||
style: None,
|
||||
is_tab: false,
|
||||
renderer: Some(ChunkRenderer {
|
||||
render: Arc::new(move |_| {
|
||||
div()
|
||||
.child(replacement)
|
||||
.bg(background)
|
||||
.text_decoration_1()
|
||||
.text_decoration_color(underline)
|
||||
.into_any_element()
|
||||
}),
|
||||
constrain_width: false,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
let invisible_highlight = HighlightStyle {
|
||||
background_color: Some(editor_style.status.hint_background),
|
||||
underline: Some(UnderlineStyle {
|
||||
color: Some(editor_style.status.hint),
|
||||
thickness: px(1.),
|
||||
wavy: false,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let invisible_style = if let Some(mut style) = style {
|
||||
style.highlight(invisible_highlight);
|
||||
style
|
||||
} else {
|
||||
invisible_highlight
|
||||
};
|
||||
|
||||
return Some(HighlightedChunk {
|
||||
text: prefix,
|
||||
style: Some(invisible_style),
|
||||
is_tab: false,
|
||||
renderer: renderer.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !text.is_empty() {
|
||||
let remainder = text;
|
||||
text = "";
|
||||
Some(HighlightedChunk {
|
||||
text: remainder,
|
||||
style,
|
||||
is_tab,
|
||||
renderer: renderer.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DisplaySnapshot {
|
||||
pub buffer_snapshot: MultiBufferSnapshot,
|
||||
@@ -564,6 +660,28 @@ impl DisplaySnapshot {
|
||||
new_start..new_end
|
||||
}
|
||||
|
||||
pub fn clip_buffer_points(
|
||||
&self,
|
||||
points: impl IntoIterator<Item = (MultiBufferPoint, Bias)>,
|
||||
) -> impl Iterator<Item = MultiBufferPoint> {
|
||||
let block_points = self.block_snapshot.to_block_points(
|
||||
self.wrap_snapshot.to_wrap_points(
|
||||
self.tab_snapshot.to_tab_points(
|
||||
self.fold_snapshot
|
||||
.to_fold_points(self.inlay_snapshot.to_inlay_points(points)),
|
||||
),
|
||||
),
|
||||
);
|
||||
self.inlay_snapshot.to_buffer_points(
|
||||
self.fold_snapshot.to_inlay_points(
|
||||
self.tab_snapshot.to_fold_points(
|
||||
self.wrap_snapshot
|
||||
.to_tab_points(self.block_snapshot.to_wrap_points(block_points)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn point_to_display_point(&self, point: MultiBufferPoint, bias: Bias) -> DisplayPoint {
|
||||
let inlay_point = self.inlay_snapshot.to_inlay_point(point);
|
||||
let fold_point = self.fold_snapshot.to_fold_point(inlay_point, bias);
|
||||
@@ -675,7 +793,7 @@ impl DisplaySnapshot {
|
||||
suggestion: Some(editor_style.suggestions_style),
|
||||
},
|
||||
)
|
||||
.map(|chunk| {
|
||||
.flat_map(|chunk| {
|
||||
let mut highlight_style = chunk
|
||||
.syntax_highlight_id
|
||||
.and_then(|id| id.style(&editor_style.syntax));
|
||||
@@ -718,6 +836,7 @@ impl DisplaySnapshot {
|
||||
is_tab: chunk.is_tab,
|
||||
renderer: chunk.renderer,
|
||||
}
|
||||
.highlight_invisibles(editor_style)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -784,12 +903,10 @@ impl DisplaySnapshot {
|
||||
layout_line.closest_index_for_x(x) as u32
|
||||
}
|
||||
|
||||
pub fn display_chars_at(
|
||||
&self,
|
||||
mut point: DisplayPoint,
|
||||
) -> impl Iterator<Item = (char, DisplayPoint)> + '_ {
|
||||
pub fn grapheme_at(&self, mut point: DisplayPoint) -> Option<SharedString> {
|
||||
point = DisplayPoint(self.block_snapshot.clip_point(point.0, Bias::Left));
|
||||
self.text_chunks(point.row())
|
||||
let chars = self
|
||||
.text_chunks(point.row())
|
||||
.flat_map(str::chars)
|
||||
.skip_while({
|
||||
let mut column = 0;
|
||||
@@ -799,16 +916,24 @@ impl DisplaySnapshot {
|
||||
!at_point
|
||||
}
|
||||
})
|
||||
.map(move |ch| {
|
||||
let result = (ch, point);
|
||||
if ch == '\n' {
|
||||
*point.row_mut() += 1;
|
||||
*point.column_mut() = 0;
|
||||
} else {
|
||||
*point.column_mut() += ch.len_utf8() as u32;
|
||||
.take_while({
|
||||
let mut prev = false;
|
||||
move |char| {
|
||||
let now = char.is_ascii();
|
||||
let end = char.is_ascii() && (char.is_ascii_whitespace() || prev);
|
||||
prev = now;
|
||||
!end
|
||||
}
|
||||
result
|
||||
})
|
||||
});
|
||||
chars.collect::<String>().graphemes(true).next().map(|s| {
|
||||
if let Some(invisible) = s.chars().next().filter(|&c| is_invisible(c)) {
|
||||
replacement(invisible).unwrap_or(s).to_owned().into()
|
||||
} else if s == "\n" {
|
||||
" ".into()
|
||||
} else {
|
||||
s.to_owned().into()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn buffer_chars_at(&self, mut offset: usize) -> impl Iterator<Item = (char, usize)> + '_ {
|
||||
@@ -1157,16 +1282,21 @@ pub mod tests {
|
||||
use super::*;
|
||||
use crate::{movement, test::marked_display_snapshot};
|
||||
use block_map::BlockPlacement;
|
||||
use gpui::{div, font, observe, px, AppContext, BorrowAppContext, Context, Element, Hsla};
|
||||
use gpui::{
|
||||
div, font, observe, px, AppContext, BorrowAppContext, Context, Element, Hsla, Rgba,
|
||||
};
|
||||
use language::{
|
||||
language_settings::{AllLanguageSettings, AllLanguageSettingsContent},
|
||||
Buffer, Language, LanguageConfig, LanguageMatcher,
|
||||
Buffer, Diagnostic, DiagnosticEntry, DiagnosticSet, Language, LanguageConfig,
|
||||
LanguageMatcher,
|
||||
};
|
||||
use lsp::LanguageServerId;
|
||||
use project::Project;
|
||||
use rand::{prelude::*, Rng};
|
||||
use settings::SettingsStore;
|
||||
use smol::stream::StreamExt;
|
||||
use std::{env, sync::Arc};
|
||||
use text::PointUtf16;
|
||||
use theme::{LoadThemes, SyntaxTheme};
|
||||
use unindent::Unindent as _;
|
||||
use util::test::{marked_text_ranges, sample_text};
|
||||
@@ -1821,6 +1951,125 @@ pub mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_chunks_with_diagnostics_across_blocks(cx: &mut gpui::TestAppContext) {
|
||||
cx.background_executor
|
||||
.set_block_on_ticks(usize::MAX..=usize::MAX);
|
||||
|
||||
let text = r#"
|
||||
struct A {
|
||||
b: usize;
|
||||
}
|
||||
const c: usize = 1;
|
||||
"#
|
||||
.unindent();
|
||||
|
||||
cx.update(|cx| init_test(cx, |_| {}));
|
||||
|
||||
let buffer = cx.new_model(|cx| Buffer::local(text, cx));
|
||||
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.update_diagnostics(
|
||||
LanguageServerId(0),
|
||||
DiagnosticSet::new(
|
||||
[DiagnosticEntry {
|
||||
range: PointUtf16::new(0, 0)..PointUtf16::new(2, 1),
|
||||
diagnostic: Diagnostic {
|
||||
severity: DiagnosticSeverity::ERROR,
|
||||
group_id: 1,
|
||||
message: "hi".into(),
|
||||
..Default::default()
|
||||
},
|
||||
}],
|
||||
buffer,
|
||||
),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx));
|
||||
let buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx));
|
||||
|
||||
let map = cx.new_model(|cx| {
|
||||
DisplayMap::new(
|
||||
buffer,
|
||||
font("Courier"),
|
||||
px(16.0),
|
||||
None,
|
||||
true,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
FoldPlaceholder::test(),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let black = gpui::black().to_rgb();
|
||||
let red = gpui::red().to_rgb();
|
||||
|
||||
// Insert a block in the middle of a multi-line diagnostic.
|
||||
map.update(cx, |map, cx| {
|
||||
map.highlight_text(
|
||||
TypeId::of::<usize>(),
|
||||
vec![
|
||||
buffer_snapshot.anchor_before(Point::new(3, 9))
|
||||
..buffer_snapshot.anchor_after(Point::new(3, 14)),
|
||||
buffer_snapshot.anchor_before(Point::new(3, 17))
|
||||
..buffer_snapshot.anchor_after(Point::new(3, 18)),
|
||||
],
|
||||
red.into(),
|
||||
);
|
||||
map.insert_blocks(
|
||||
[BlockProperties {
|
||||
placement: BlockPlacement::Below(
|
||||
buffer_snapshot.anchor_before(Point::new(1, 0)),
|
||||
),
|
||||
height: 1,
|
||||
style: BlockStyle::Sticky,
|
||||
render: Box::new(|_| div().into_any()),
|
||||
priority: 0,
|
||||
}],
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
|
||||
let mut chunks = Vec::<(String, Option<DiagnosticSeverity>, Rgba)>::new();
|
||||
for chunk in snapshot.chunks(DisplayRow(0)..DisplayRow(5), true, Default::default()) {
|
||||
let color = chunk
|
||||
.highlight_style
|
||||
.and_then(|style| style.color)
|
||||
.map_or(black, |color| color.to_rgb());
|
||||
if let Some((last_chunk, last_severity, last_color)) = chunks.last_mut() {
|
||||
if *last_severity == chunk.diagnostic_severity && *last_color == color {
|
||||
last_chunk.push_str(chunk.text);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
chunks.push((chunk.text.to_string(), chunk.diagnostic_severity, color));
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
chunks,
|
||||
[
|
||||
(
|
||||
"struct A {\n b: usize;\n".into(),
|
||||
Some(DiagnosticSeverity::ERROR),
|
||||
black
|
||||
),
|
||||
("\n".into(), None, black),
|
||||
("}".into(), Some(DiagnosticSeverity::ERROR), black),
|
||||
("\nconst c: ".into(), None, black),
|
||||
("usize".into(), None, red),
|
||||
(" = ".into(), None, black),
|
||||
("1".into(), None, red),
|
||||
(";\n".into(), None, black),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// todo(linux) fails due to pixel differences in text rendering
|
||||
#[cfg(target_os = "macos")]
|
||||
#[gpui::test]
|
||||
|
||||
@@ -79,10 +79,7 @@ impl FoldPoint {
|
||||
}
|
||||
|
||||
pub fn to_inlay_point(self, snapshot: &FoldSnapshot) -> InlayPoint {
|
||||
let mut cursor = snapshot.transforms.cursor::<(FoldPoint, InlayPoint)>(&());
|
||||
cursor.seek(&self, Bias::Right, &());
|
||||
let overshoot = self.0 - cursor.start().0 .0;
|
||||
InlayPoint(cursor.start().1 .0 + overshoot)
|
||||
snapshot.to_inlay_point(self)
|
||||
}
|
||||
|
||||
pub fn to_offset(self, snapshot: &FoldSnapshot) -> FoldOffset {
|
||||
@@ -618,6 +615,48 @@ impl FoldSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_fold_points<'a>(
|
||||
&'a self,
|
||||
points: impl 'a + IntoIterator<Item = (InlayPoint, Bias)>,
|
||||
) -> impl 'a + Iterator<Item = FoldPoint> {
|
||||
let mut cursor = self.transforms.cursor::<(InlayPoint, FoldPoint)>(&());
|
||||
points.into_iter().map(move |(point, bias)| {
|
||||
cursor.seek_forward(&point, Bias::Right, &());
|
||||
if cursor.item().map_or(false, |t| t.is_fold()) {
|
||||
if bias == Bias::Left || point == cursor.start().0 {
|
||||
cursor.start().1
|
||||
} else {
|
||||
cursor.end(&()).1
|
||||
}
|
||||
} else {
|
||||
let overshoot = point.0 - cursor.start().0 .0;
|
||||
FoldPoint(cmp::min(
|
||||
cursor.start().1 .0 + overshoot,
|
||||
cursor.end(&()).1 .0,
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_inlay_point(&self, point: FoldPoint) -> InlayPoint {
|
||||
let mut cursor = self.transforms.cursor::<(FoldPoint, InlayPoint)>(&());
|
||||
cursor.seek(&point, Bias::Right, &());
|
||||
let overshoot = point.0 - cursor.start().0 .0;
|
||||
InlayPoint(cursor.start().1 .0 + overshoot)
|
||||
}
|
||||
|
||||
pub fn to_inlay_points<'a>(
|
||||
&'a self,
|
||||
points: impl 'a + IntoIterator<Item = FoldPoint>,
|
||||
) -> impl 'a + Iterator<Item = InlayPoint> {
|
||||
let mut cursor = self.transforms.cursor::<(FoldPoint, InlayPoint)>(&());
|
||||
points.into_iter().map(move |point| {
|
||||
cursor.seek(&point, Bias::Right, &());
|
||||
let overshoot = point.0 - cursor.start().0 .0;
|
||||
InlayPoint(cursor.start().1 .0 + overshoot)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn len(&self) -> FoldOffset {
|
||||
FoldOffset(self.transforms.summary().output.len)
|
||||
}
|
||||
|
||||
@@ -255,6 +255,22 @@ impl<'a> InlayChunks<'a> {
|
||||
self.buffer_chunk = None;
|
||||
self.output_offset = new_range.start;
|
||||
self.max_output_offset = new_range.end;
|
||||
|
||||
let mut highlight_endpoints = Vec::new();
|
||||
if let Some(text_highlights) = self.highlights.text_highlights {
|
||||
if !text_highlights.is_empty() {
|
||||
self.snapshot.apply_text_highlights(
|
||||
&mut self.transforms,
|
||||
&new_range,
|
||||
text_highlights,
|
||||
&mut highlight_endpoints,
|
||||
);
|
||||
self.transforms.seek(&new_range.start, Bias::Right, &());
|
||||
highlight_endpoints.sort();
|
||||
}
|
||||
}
|
||||
self.highlight_endpoints = highlight_endpoints.into_iter().peekable();
|
||||
self.active_highlights.clear();
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> InlayOffset {
|
||||
@@ -773,6 +789,25 @@ impl InlaySnapshot {
|
||||
None => self.buffer.max_point(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_buffer_points<'a>(
|
||||
&'a self,
|
||||
points: impl 'a + IntoIterator<Item = InlayPoint>,
|
||||
) -> impl 'a + Iterator<Item = Point> {
|
||||
let mut cursor = self.transforms.cursor::<(InlayPoint, Point)>(&());
|
||||
points.into_iter().map(move |point| {
|
||||
cursor.seek_forward(&point, Bias::Right, &());
|
||||
match cursor.item() {
|
||||
Some(Transform::Isomorphic(_)) => {
|
||||
let overshoot = point.0 - cursor.start().0 .0;
|
||||
cursor.start().1 + overshoot
|
||||
}
|
||||
Some(Transform::Inlay(_)) => cursor.start().1,
|
||||
None => self.buffer.max_point(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_buffer_offset(&self, offset: InlayOffset) -> usize {
|
||||
let mut cursor = self.transforms.cursor::<(InlayOffset, usize)>(&());
|
||||
cursor.seek(&offset, Bias::Right, &());
|
||||
@@ -819,6 +854,7 @@ impl InlaySnapshot {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_inlay_point(&self, point: Point) -> InlayPoint {
|
||||
let mut cursor = self.transforms.cursor::<(Point, InlayPoint)>(&());
|
||||
cursor.seek(&point, Bias::Left, &());
|
||||
@@ -853,6 +889,45 @@ impl InlaySnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_inlay_points<'a>(
|
||||
&'a self,
|
||||
points: impl 'a + IntoIterator<Item = Point>,
|
||||
) -> impl 'a + Iterator<Item = InlayPoint> {
|
||||
let mut cursor = self.transforms.cursor::<(Point, InlayPoint)>(&());
|
||||
points.into_iter().map(move |point| {
|
||||
cursor.seek_forward(&point, Bias::Left, &());
|
||||
loop {
|
||||
match cursor.item() {
|
||||
Some(Transform::Isomorphic(_)) => {
|
||||
if point == cursor.end(&()).0 {
|
||||
while let Some(Transform::Inlay(inlay)) = cursor.next_item() {
|
||||
if inlay.position.bias() == Bias::Right {
|
||||
break;
|
||||
} else {
|
||||
cursor.next(&());
|
||||
}
|
||||
}
|
||||
return cursor.end(&()).1;
|
||||
} else {
|
||||
let overshoot = point - cursor.start().0;
|
||||
return InlayPoint(cursor.start().1 .0 + overshoot);
|
||||
}
|
||||
}
|
||||
Some(Transform::Inlay(inlay)) => {
|
||||
if inlay.position.bias() == Bias::Left {
|
||||
cursor.next(&());
|
||||
} else {
|
||||
return cursor.start().1;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
return self.max_point();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clip_point(&self, mut point: InlayPoint, mut bias: Bias) -> InlayPoint {
|
||||
let mut cursor = self.transforms.cursor::<(InlayPoint, Point)>(&());
|
||||
cursor.seek(&point, Bias::Left, &());
|
||||
|
||||
129
crates/editor/src/display_map/invisibles.rs
Normal file
129
crates/editor/src/display_map/invisibles.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
// Invisibility in a Unicode context is not well defined, so we have to guess.
|
||||
//
|
||||
// We highlight all ASCII control codes, and unicode whitespace because they are likely
|
||||
// confused with an ASCII space in a programming context (U+0020).
|
||||
//
|
||||
// We also highlight the handful of blank non-space characters:
|
||||
// U+2800 BRAILLE PATTERN BLANK - Category: So
|
||||
// U+115F HANGUL CHOSEONG FILLER - Category: Lo
|
||||
// U+1160 HANGUL CHOSEONG FILLER - Category: Lo
|
||||
// U+3164 HANGUL FILLER - Category: Lo
|
||||
// U+FFA0 HALFWIDTH HANGUL FILLER - Category: Lo
|
||||
// U+FFFC OBJECT REPLACEMENT CHARACTER - Category: So
|
||||
//
|
||||
// For the rest of Unicode, invisibility happens for two reasons:
|
||||
// * A Format character (like a byte order mark or right-to-left override)
|
||||
// * An invisible Nonspacing Mark character (like U+034F, or variation selectors)
|
||||
//
|
||||
// We don't consider unassigned codepoints invisible as the font renderer already shows
|
||||
// a replacement character in that case (and there are a *lot* of them)
|
||||
//
|
||||
// Control characters are mostly fine to highlight; except:
|
||||
// * U+E0020..=U+E007F are used in emoji flags. We don't highlight them right now, but we could if we tightened our heuristics.
|
||||
// * U+200D is used to join characters. We highlight this but don't replace it. As our font system ignores mid-glyph highlights this mostly works to highlight unexpected uses.
|
||||
//
|
||||
// Nonspacing marks are handled like U+200D. This means that mid-glyph we ignore them, but
|
||||
// probably causes issues with end-of-glyph usage.
|
||||
//
|
||||
// ref: https://invisible-characters.com
|
||||
// ref: https://www.compart.com/en/unicode/category/Cf
|
||||
// ref: https://gist.github.com/ConradIrwin/f759e1fc29267143c4c7895aa495dca5?h=1
|
||||
// ref: https://unicode.org/Public/emoji/13.0/emoji-test.txt
|
||||
// https://github.com/bits/UTF-8-Unicode-Test-Documents/blob/master/UTF-8_sequence_separated/utf8_sequence_0-0x10ffff_assigned_including-unprintable-asis.txt
|
||||
pub fn is_invisible(c: char) -> bool {
|
||||
if c <= '\u{1f}' {
|
||||
c != '\t' && c != '\n' && c != '\r'
|
||||
} else if c >= '\u{7f}' {
|
||||
c <= '\u{9f}'
|
||||
|| (c.is_whitespace() && c != IDEOGRAPHIC_SPACE)
|
||||
|| contains(c, &FORMAT)
|
||||
|| contains(c, &OTHER)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
// 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> {
|
||||
if c <= '\x1f' {
|
||||
Some(C0_SYMBOLS[c as usize])
|
||||
} else if c == '\x7f' {
|
||||
Some(DEL)
|
||||
} else if contains(c, &PRESERVE) {
|
||||
None
|
||||
} else {
|
||||
Some("\u{2007}") // fixed width space
|
||||
}
|
||||
}
|
||||
// IDEOGRAPHIC SPACE is common alongside Chinese and other wide character sets.
|
||||
// We don't highlight this for now (as it already shows up wide in the editor),
|
||||
// but could if we tracked state in the classifier.
|
||||
const IDEOGRAPHIC_SPACE: char = '\u{3000}';
|
||||
|
||||
const C0_SYMBOLS: &'static [&'static str] = &[
|
||||
"␀", "␁", "␂", "␃", "␄", "␅", "␆", "␇", "␈", "␉", "␊", "␋", "␌", "␍", "␎", "␏", "␐", "␑", "␒",
|
||||
"␓", "␔", "␕", "␖", "␗", "␘", "␙", "␚", "␛", "␜", "␝", "␞", "␟",
|
||||
];
|
||||
const DEL: &'static str = "␡";
|
||||
|
||||
// generated using ucd-generate: ucd-generate general-category --include Format --chars ucd-16.0.0
|
||||
pub const FORMAT: &'static [(char, char)] = &[
|
||||
('\u{ad}', '\u{ad}'),
|
||||
('\u{600}', '\u{605}'),
|
||||
('\u{61c}', '\u{61c}'),
|
||||
('\u{6dd}', '\u{6dd}'),
|
||||
('\u{70f}', '\u{70f}'),
|
||||
('\u{890}', '\u{891}'),
|
||||
('\u{8e2}', '\u{8e2}'),
|
||||
('\u{180e}', '\u{180e}'),
|
||||
('\u{200b}', '\u{200f}'),
|
||||
('\u{202a}', '\u{202e}'),
|
||||
('\u{2060}', '\u{2064}'),
|
||||
('\u{2066}', '\u{206f}'),
|
||||
('\u{feff}', '\u{feff}'),
|
||||
('\u{fff9}', '\u{fffb}'),
|
||||
('\u{110bd}', '\u{110bd}'),
|
||||
('\u{110cd}', '\u{110cd}'),
|
||||
('\u{13430}', '\u{1343f}'),
|
||||
('\u{1bca0}', '\u{1bca3}'),
|
||||
('\u{1d173}', '\u{1d17a}'),
|
||||
('\u{e0001}', '\u{e0001}'),
|
||||
('\u{e0020}', '\u{e007f}'),
|
||||
];
|
||||
|
||||
// hand-made base on https://invisible-characters.com (Excluding Cf)
|
||||
pub const OTHER: &'static [(char, char)] = &[
|
||||
('\u{034f}', '\u{034f}'),
|
||||
('\u{115F}', '\u{1160}'),
|
||||
('\u{17b4}', '\u{17b5}'),
|
||||
('\u{180b}', '\u{180d}'),
|
||||
('\u{2800}', '\u{2800}'),
|
||||
('\u{3164}', '\u{3164}'),
|
||||
('\u{fe00}', '\u{fe0d}'),
|
||||
('\u{ffa0}', '\u{ffa0}'),
|
||||
('\u{fffc}', '\u{fffc}'),
|
||||
('\u{e0100}', '\u{e01ef}'),
|
||||
];
|
||||
|
||||
// a subset of FORMAT/OTHER that may appear within glyphs
|
||||
const PRESERVE: &'static [(char, char)] = &[
|
||||
('\u{034f}', '\u{034f}'),
|
||||
('\u{200d}', '\u{200d}'),
|
||||
('\u{17b4}', '\u{17b5}'),
|
||||
('\u{180b}', '\u{180d}'),
|
||||
('\u{e0061}', '\u{e007a}'),
|
||||
('\u{e007f}', '\u{e007f}'),
|
||||
];
|
||||
|
||||
fn contains(c: char, list: &[(char, char)]) -> bool {
|
||||
for (start, end) in list {
|
||||
if c < *start {
|
||||
return false;
|
||||
}
|
||||
if c <= *end {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -304,6 +304,14 @@ impl TabSnapshot {
|
||||
TabPoint::new(input.row(), expanded)
|
||||
}
|
||||
|
||||
pub fn to_tab_points<'a>(
|
||||
&'a self,
|
||||
points: impl 'a + IntoIterator<Item = FoldPoint>,
|
||||
) -> impl 'a + Iterator<Item = TabPoint> {
|
||||
// todo!("make this efficient")
|
||||
points.into_iter().map(|point| self.to_tab_point(point))
|
||||
}
|
||||
|
||||
pub fn to_fold_point(&self, output: TabPoint, bias: Bias) -> (FoldPoint, u32, u32) {
|
||||
let chars = self.fold_snapshot.chars_at(FoldPoint::new(output.row(), 0));
|
||||
let expanded = output.column();
|
||||
@@ -316,6 +324,16 @@ impl TabSnapshot {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn to_fold_points<'a>(
|
||||
&'a self,
|
||||
points: impl 'a + IntoIterator<Item = (TabPoint, Bias)>,
|
||||
) -> impl 'a + Iterator<Item = FoldPoint> {
|
||||
// todo!("make this efficient")
|
||||
points
|
||||
.into_iter()
|
||||
.map(|(point, bias)| self.to_fold_point(point, bias).0)
|
||||
}
|
||||
|
||||
pub fn make_tab_point(&self, point: Point, bias: Bias) -> TabPoint {
|
||||
let inlay_point = self.fold_snapshot.inlay_snapshot.to_inlay_point(point);
|
||||
let fold_point = self.fold_snapshot.to_fold_point(inlay_point, bias);
|
||||
|
||||
@@ -761,6 +761,12 @@ impl WrapSnapshot {
|
||||
WrapPoint(cursor.start().1 .0 + (point.0 - cursor.start().0 .0))
|
||||
}
|
||||
|
||||
pub fn tab_points_to_wrap_points(
|
||||
&self,
|
||||
points: impl IntoIterator<Item = TabPoint>,
|
||||
) -> impl Iterator<Item = WrapPoint> {
|
||||
}
|
||||
|
||||
pub fn clip_point(&self, mut point: WrapPoint, bias: Bias) -> WrapPoint {
|
||||
if bias == Bias::Left {
|
||||
let mut cursor = self.transforms.cursor::<WrapPoint>(&());
|
||||
|
||||
@@ -3244,9 +3244,21 @@ impl Editor {
|
||||
}
|
||||
|
||||
if enabled && pair.start.ends_with(text.as_ref()) {
|
||||
bracket_pair = Some(pair.clone());
|
||||
is_bracket_pair_start = true;
|
||||
break;
|
||||
let prefix_len = pair.start.len() - text.len();
|
||||
let preceding_text_matches_prefix = prefix_len == 0
|
||||
|| (selection.start.column >= (prefix_len as u32)
|
||||
&& snapshot.contains_str_at(
|
||||
Point::new(
|
||||
selection.start.row,
|
||||
selection.start.column - (prefix_len as u32),
|
||||
),
|
||||
&pair.start[..prefix_len],
|
||||
));
|
||||
if preceding_text_matches_prefix {
|
||||
bracket_pair = Some(pair.clone());
|
||||
is_bracket_pair_start = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if pair.end.as_str() == text.as_ref() {
|
||||
bracket_pair = Some(pair.clone());
|
||||
@@ -3263,8 +3275,6 @@ impl Editor {
|
||||
self.use_auto_surround && snapshot_settings.use_auto_surround;
|
||||
if selection.is_empty() {
|
||||
if is_bracket_pair_start {
|
||||
let prefix_len = bracket_pair.start.len() - text.len();
|
||||
|
||||
// If the inserted text is a suffix of an opening bracket and the
|
||||
// selection is preceded by the rest of the opening bracket, then
|
||||
// insert the closing bracket.
|
||||
@@ -3272,15 +3282,6 @@ impl Editor {
|
||||
.chars_at(selection.start)
|
||||
.next()
|
||||
.map_or(true, |c| scope.should_autoclose_before(c));
|
||||
let preceding_text_matches_prefix = prefix_len == 0
|
||||
|| (selection.start.column >= (prefix_len as u32)
|
||||
&& snapshot.contains_str_at(
|
||||
Point::new(
|
||||
selection.start.row,
|
||||
selection.start.column - (prefix_len as u32),
|
||||
),
|
||||
&bracket_pair.start[..prefix_len],
|
||||
));
|
||||
|
||||
let is_closing_quote = if bracket_pair.end == bracket_pair.start
|
||||
&& bracket_pair.start.len() == 1
|
||||
@@ -3299,7 +3300,6 @@ impl Editor {
|
||||
if autoclose
|
||||
&& bracket_pair.close
|
||||
&& following_text_allows_autoclose
|
||||
&& preceding_text_matches_prefix
|
||||
&& !is_closing_quote
|
||||
{
|
||||
let anchor = snapshot.anchor_before(selection.end);
|
||||
@@ -9629,8 +9629,8 @@ impl Editor {
|
||||
let Some(provider) = self.semantics_provider.clone() else {
|
||||
return Task::ready(Ok(Navigated::No));
|
||||
};
|
||||
let buffer = self.buffer.read(cx);
|
||||
let head = self.selections.newest::<usize>(cx).head();
|
||||
let buffer = self.buffer.read(cx);
|
||||
let (buffer, head) = if let Some(text_anchor) = buffer.text_anchor_for_position(head, cx) {
|
||||
text_anchor
|
||||
} else {
|
||||
@@ -9937,8 +9937,8 @@ impl Editor {
|
||||
_: &FindAllReferences,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Option<Task<Result<Navigated>>> {
|
||||
let multi_buffer = self.buffer.read(cx);
|
||||
let selection = self.selections.newest::<usize>(cx);
|
||||
let multi_buffer = self.buffer.read(cx);
|
||||
let head = selection.head();
|
||||
|
||||
let multi_buffer_snapshot = multi_buffer.snapshot(cx);
|
||||
@@ -10345,8 +10345,9 @@ impl Editor {
|
||||
self.show_local_selections = true;
|
||||
|
||||
if moving_cursor {
|
||||
let rename_editor = rename.editor.read(cx);
|
||||
let cursor_in_rename_editor = rename_editor.selections.newest::<usize>(cx).head();
|
||||
let cursor_in_rename_editor = rename.editor.update(cx, |editor, cx| {
|
||||
editor.selections.newest::<usize>(cx).head()
|
||||
});
|
||||
|
||||
// Update the selection to match the position of the selection inside
|
||||
// the rename editor.
|
||||
@@ -10460,7 +10461,7 @@ impl Editor {
|
||||
|
||||
fn cancel_language_server_work(
|
||||
&mut self,
|
||||
_: &CancelLanguageServerWork,
|
||||
_: &actions::CancelLanguageServerWork,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(project) = self.project.clone() {
|
||||
@@ -11592,9 +11593,9 @@ impl Editor {
|
||||
}
|
||||
|
||||
pub fn copy_file_location(&mut self, _: &CopyFileLocation, cx: &mut ViewContext<Self>) {
|
||||
let selection = self.selections.newest::<Point>(cx).start.row + 1;
|
||||
if let Some(file) = self.target_file(cx) {
|
||||
if let Some(path) = file.path().to_str() {
|
||||
let selection = self.selections.newest::<Point>(cx).start.row + 1;
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(format!("{path}:{selection}")));
|
||||
}
|
||||
}
|
||||
@@ -12370,9 +12371,10 @@ impl Editor {
|
||||
return;
|
||||
};
|
||||
|
||||
let selections = self.selections.all::<usize>(cx);
|
||||
let buffer = self.buffer.read(cx);
|
||||
let mut new_selections_by_buffer = HashMap::default();
|
||||
for selection in self.selections.all::<usize>(cx) {
|
||||
for selection in selections {
|
||||
for (buffer, range, _) in
|
||||
buffer.range_to_buffer_ranges(selection.start..selection.end, cx)
|
||||
{
|
||||
@@ -12417,6 +12419,7 @@ impl Editor {
|
||||
}
|
||||
|
||||
fn open_excerpts_common(&mut self, split: bool, cx: &mut ViewContext<Self>) {
|
||||
let selections = self.selections.all::<usize>(cx);
|
||||
let buffer = self.buffer.read(cx);
|
||||
if buffer.is_singleton() {
|
||||
cx.propagate();
|
||||
@@ -12429,7 +12432,7 @@ impl Editor {
|
||||
};
|
||||
|
||||
let mut new_selections_by_buffer = HashMap::default();
|
||||
for selection in self.selections.all::<usize>(cx) {
|
||||
for selection in selections {
|
||||
for (mut buffer_handle, mut range, _) in
|
||||
buffer.range_to_buffer_ranges(selection.range(), cx)
|
||||
{
|
||||
@@ -12545,7 +12548,7 @@ impl Editor {
|
||||
fn selection_replacement_ranges(
|
||||
&self,
|
||||
range: Range<OffsetUtf16>,
|
||||
cx: &AppContext,
|
||||
cx: &mut AppContext,
|
||||
) -> Vec<Range<OffsetUtf16>> {
|
||||
let selections = self.selections.all::<OffsetUtf16>(cx);
|
||||
let newest_selection = selections
|
||||
@@ -14188,7 +14191,7 @@ pub fn diagnostic_block_renderer(
|
||||
.relative()
|
||||
.size_full()
|
||||
.pl(cx.gutter_dimensions.width)
|
||||
.w(cx.max_width + cx.gutter_dimensions.width)
|
||||
.w(cx.max_width - cx.gutter_dimensions.full_width())
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
|
||||
@@ -68,6 +68,7 @@ use sum_tree::Bias;
|
||||
use theme::{ActiveTheme, Appearance, PlayerColor};
|
||||
use ui::prelude::*;
|
||||
use ui::{h_flex, ButtonLike, ButtonStyle, ContextMenu, Tooltip};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use util::RangeExt;
|
||||
use util::ResultExt;
|
||||
use workspace::{item::Item, Workspace};
|
||||
@@ -823,129 +824,131 @@ impl EditorElement {
|
||||
let mut selections: Vec<(PlayerColor, Vec<SelectionLayout>)> = Vec::new();
|
||||
let mut active_rows = BTreeMap::new();
|
||||
let mut newest_selection_head = None;
|
||||
let editor = self.editor.read(cx);
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
if editor.show_local_selections {
|
||||
let mut local_selections: Vec<Selection<Point>> = editor
|
||||
.selections
|
||||
.disjoint_in_range(start_anchor..end_anchor, cx);
|
||||
local_selections.extend(editor.selections.pending(cx));
|
||||
let mut layouts = Vec::new();
|
||||
let newest = editor.selections.newest(cx);
|
||||
for selection in local_selections.drain(..) {
|
||||
let is_empty = selection.start == selection.end;
|
||||
let is_newest = selection == newest;
|
||||
|
||||
if editor.show_local_selections {
|
||||
let mut local_selections: Vec<Selection<Point>> = editor
|
||||
.selections
|
||||
.disjoint_in_range(start_anchor..end_anchor, cx);
|
||||
local_selections.extend(editor.selections.pending(cx));
|
||||
let mut layouts = Vec::new();
|
||||
let newest = editor.selections.newest(cx);
|
||||
for selection in local_selections.drain(..) {
|
||||
let is_empty = selection.start == selection.end;
|
||||
let is_newest = selection == newest;
|
||||
let layout = SelectionLayout::new(
|
||||
selection,
|
||||
editor.selections.line_mode,
|
||||
editor.cursor_shape,
|
||||
&snapshot.display_snapshot,
|
||||
is_newest,
|
||||
editor.leader_peer_id.is_none(),
|
||||
None,
|
||||
);
|
||||
if is_newest {
|
||||
newest_selection_head = Some(layout.head);
|
||||
}
|
||||
|
||||
let layout = SelectionLayout::new(
|
||||
selection,
|
||||
editor.selections.line_mode,
|
||||
editor.cursor_shape,
|
||||
&snapshot.display_snapshot,
|
||||
is_newest,
|
||||
editor.leader_peer_id.is_none(),
|
||||
None,
|
||||
);
|
||||
if is_newest {
|
||||
newest_selection_head = Some(layout.head);
|
||||
for row in cmp::max(layout.active_rows.start.0, start_row.0)
|
||||
..=cmp::min(layout.active_rows.end.0, end_row.0)
|
||||
{
|
||||
let contains_non_empty_selection =
|
||||
active_rows.entry(DisplayRow(row)).or_insert(!is_empty);
|
||||
*contains_non_empty_selection |= !is_empty;
|
||||
}
|
||||
layouts.push(layout);
|
||||
}
|
||||
|
||||
for row in cmp::max(layout.active_rows.start.0, start_row.0)
|
||||
..=cmp::min(layout.active_rows.end.0, end_row.0)
|
||||
{
|
||||
let contains_non_empty_selection =
|
||||
active_rows.entry(DisplayRow(row)).or_insert(!is_empty);
|
||||
*contains_non_empty_selection |= !is_empty;
|
||||
}
|
||||
layouts.push(layout);
|
||||
let player = if editor.read_only(cx) {
|
||||
cx.theme().players().read_only()
|
||||
} else {
|
||||
self.style.local_player
|
||||
};
|
||||
|
||||
selections.push((player, layouts));
|
||||
}
|
||||
|
||||
let player = if editor.read_only(cx) {
|
||||
cx.theme().players().read_only()
|
||||
} else {
|
||||
self.style.local_player
|
||||
};
|
||||
|
||||
selections.push((player, layouts));
|
||||
}
|
||||
|
||||
if let Some(collaboration_hub) = &editor.collaboration_hub {
|
||||
// When following someone, render the local selections in their color.
|
||||
if let Some(leader_id) = editor.leader_peer_id {
|
||||
if let Some(collaborator) = collaboration_hub.collaborators(cx).get(&leader_id) {
|
||||
if let Some(participant_index) = collaboration_hub
|
||||
.user_participant_indices(cx)
|
||||
.get(&collaborator.user_id)
|
||||
if let Some(collaboration_hub) = &editor.collaboration_hub {
|
||||
// When following someone, render the local selections in their color.
|
||||
if let Some(leader_id) = editor.leader_peer_id {
|
||||
if let Some(collaborator) = collaboration_hub.collaborators(cx).get(&leader_id)
|
||||
{
|
||||
if let Some((local_selection_style, _)) = selections.first_mut() {
|
||||
*local_selection_style = cx
|
||||
.theme()
|
||||
.players()
|
||||
.color_for_participant(participant_index.0);
|
||||
if let Some(participant_index) = collaboration_hub
|
||||
.user_participant_indices(cx)
|
||||
.get(&collaborator.user_id)
|
||||
{
|
||||
if let Some((local_selection_style, _)) = selections.first_mut() {
|
||||
*local_selection_style = cx
|
||||
.theme()
|
||||
.players()
|
||||
.color_for_participant(participant_index.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut remote_selections = HashMap::default();
|
||||
for selection in snapshot.remote_selections_in_range(
|
||||
&(start_anchor..end_anchor),
|
||||
collaboration_hub.as_ref(),
|
||||
cx,
|
||||
) {
|
||||
let selection_style = Self::get_participant_color(selection.participant_index, cx);
|
||||
let mut remote_selections = HashMap::default();
|
||||
for selection in snapshot.remote_selections_in_range(
|
||||
&(start_anchor..end_anchor),
|
||||
collaboration_hub.as_ref(),
|
||||
cx,
|
||||
) {
|
||||
let selection_style =
|
||||
Self::get_participant_color(selection.participant_index, cx);
|
||||
|
||||
// Don't re-render the leader's selections, since the local selections
|
||||
// match theirs.
|
||||
if Some(selection.peer_id) == editor.leader_peer_id {
|
||||
continue;
|
||||
// Don't re-render the leader's selections, since the local selections
|
||||
// match theirs.
|
||||
if Some(selection.peer_id) == editor.leader_peer_id {
|
||||
continue;
|
||||
}
|
||||
let key = HoveredCursor {
|
||||
replica_id: selection.replica_id,
|
||||
selection_id: selection.selection.id,
|
||||
};
|
||||
|
||||
let is_shown =
|
||||
editor.show_cursor_names || editor.hovered_cursors.contains_key(&key);
|
||||
|
||||
remote_selections
|
||||
.entry(selection.replica_id)
|
||||
.or_insert((selection_style, Vec::new()))
|
||||
.1
|
||||
.push(SelectionLayout::new(
|
||||
selection.selection,
|
||||
selection.line_mode,
|
||||
selection.cursor_shape,
|
||||
&snapshot.display_snapshot,
|
||||
false,
|
||||
false,
|
||||
if is_shown { selection.user_name } else { None },
|
||||
));
|
||||
}
|
||||
let key = HoveredCursor {
|
||||
replica_id: selection.replica_id,
|
||||
selection_id: selection.selection.id,
|
||||
|
||||
selections.extend(remote_selections.into_values());
|
||||
} else if !editor.is_focused(cx) && editor.show_cursor_when_unfocused {
|
||||
let player = if editor.read_only(cx) {
|
||||
cx.theme().players().read_only()
|
||||
} else {
|
||||
self.style.local_player
|
||||
};
|
||||
|
||||
let is_shown =
|
||||
editor.show_cursor_names || editor.hovered_cursors.contains_key(&key);
|
||||
|
||||
remote_selections
|
||||
.entry(selection.replica_id)
|
||||
.or_insert((selection_style, Vec::new()))
|
||||
.1
|
||||
.push(SelectionLayout::new(
|
||||
selection.selection,
|
||||
selection.line_mode,
|
||||
selection.cursor_shape,
|
||||
&snapshot.display_snapshot,
|
||||
false,
|
||||
false,
|
||||
if is_shown { selection.user_name } else { None },
|
||||
));
|
||||
let layouts = snapshot
|
||||
.buffer_snapshot
|
||||
.selections_in_range(&(start_anchor..end_anchor), true)
|
||||
.map(move |(_, line_mode, cursor_shape, selection)| {
|
||||
SelectionLayout::new(
|
||||
selection,
|
||||
line_mode,
|
||||
cursor_shape,
|
||||
&snapshot.display_snapshot,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
selections.push((player, layouts));
|
||||
}
|
||||
|
||||
selections.extend(remote_selections.into_values());
|
||||
} else if !editor.is_focused(cx) && editor.show_cursor_when_unfocused {
|
||||
let player = if editor.read_only(cx) {
|
||||
cx.theme().players().read_only()
|
||||
} else {
|
||||
self.style.local_player
|
||||
};
|
||||
let layouts = snapshot
|
||||
.buffer_snapshot
|
||||
.selections_in_range(&(start_anchor..end_anchor), true)
|
||||
.map(move |(_, line_mode, cursor_shape, selection)| {
|
||||
SelectionLayout::new(
|
||||
selection,
|
||||
line_mode,
|
||||
cursor_shape,
|
||||
&snapshot.display_snapshot,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
selections.push((player, layouts));
|
||||
}
|
||||
});
|
||||
(selections, active_rows, newest_selection_head)
|
||||
}
|
||||
|
||||
@@ -1027,24 +1030,17 @@ impl EditorElement {
|
||||
}
|
||||
let block_text = if let CursorShape::Block = selection.cursor_shape {
|
||||
snapshot
|
||||
.display_chars_at(cursor_position)
|
||||
.next()
|
||||
.grapheme_at(cursor_position)
|
||||
.or_else(|| {
|
||||
if cursor_column == 0 {
|
||||
snapshot
|
||||
.placeholder_text()
|
||||
.and_then(|s| s.chars().next())
|
||||
.map(|c| (c, cursor_position))
|
||||
snapshot.placeholder_text().and_then(|s| {
|
||||
s.graphemes(true).next().map(|s| s.to_string().into())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.and_then(|(character, _)| {
|
||||
let text = if character == '\n' {
|
||||
SharedString::from(" ")
|
||||
} else {
|
||||
SharedString::from(character.to_string())
|
||||
};
|
||||
.and_then(|text| {
|
||||
let len = text.len();
|
||||
|
||||
let font = cursor_row_layout
|
||||
@@ -1854,23 +1850,25 @@ impl EditorElement {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let editor = self.editor.read(cx);
|
||||
let newest_selection_head = newest_selection_head.unwrap_or_else(|| {
|
||||
let newest = editor.selections.newest::<Point>(cx);
|
||||
SelectionLayout::new(
|
||||
newest,
|
||||
editor.selections.line_mode,
|
||||
editor.cursor_shape,
|
||||
&snapshot.display_snapshot,
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.head
|
||||
let (newest_selection_head, is_relative) = self.editor.update(cx, |editor, cx| {
|
||||
let newest_selection_head = newest_selection_head.unwrap_or_else(|| {
|
||||
let newest = editor.selections.newest::<Point>(cx);
|
||||
SelectionLayout::new(
|
||||
newest,
|
||||
editor.selections.line_mode,
|
||||
editor.cursor_shape,
|
||||
&snapshot.display_snapshot,
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.head
|
||||
});
|
||||
let is_relative = editor.should_use_relative_line_numbers(cx);
|
||||
(newest_selection_head, is_relative)
|
||||
});
|
||||
let font_size = self.style.text.font_size.to_pixels(cx.rem_size());
|
||||
|
||||
let is_relative = editor.should_use_relative_line_numbers(cx);
|
||||
let relative_to = if is_relative {
|
||||
Some(newest_selection_head.row())
|
||||
} else {
|
||||
@@ -4159,7 +4157,16 @@ fn render_inline_blame_entry(
|
||||
let relative_timestamp = blame_entry_relative_timestamp(&blame_entry);
|
||||
|
||||
let author = blame_entry.author.as_deref().unwrap_or_default();
|
||||
let text = format!("{}, {}", author, relative_timestamp);
|
||||
let summary_enabled = ProjectSettings::get_global(cx)
|
||||
.git
|
||||
.show_inline_commit_summary();
|
||||
|
||||
let text = match blame_entry.summary.as_ref() {
|
||||
Some(summary) if summary_enabled => {
|
||||
format!("{}, {} - {}", author, relative_timestamp, summary)
|
||||
}
|
||||
_ => format!("{}, {}", author, relative_timestamp),
|
||||
};
|
||||
|
||||
let details = blame.read(cx).details_for_entry(&blame_entry);
|
||||
|
||||
|
||||
@@ -368,12 +368,15 @@ impl GitBlame {
|
||||
.spawn({
|
||||
let snapshot = snapshot.clone();
|
||||
async move {
|
||||
let Blame {
|
||||
let Some(Blame {
|
||||
entries,
|
||||
permalinks,
|
||||
messages,
|
||||
remote_url,
|
||||
} = blame.await?;
|
||||
}) = blame.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row);
|
||||
let commit_details = parse_commit_messages(
|
||||
@@ -385,13 +388,16 @@ impl GitBlame {
|
||||
)
|
||||
.await;
|
||||
|
||||
anyhow::Ok((entries, commit_details))
|
||||
anyhow::Ok(Some((entries, commit_details)))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(&mut cx, |this, cx| match result {
|
||||
Ok((entries, commit_details)) => {
|
||||
Ok(None) => {
|
||||
// Nothing to do, e.g. no repository found
|
||||
}
|
||||
Ok(Some((entries, commit_details))) => {
|
||||
this.buffer_edits = buffer_edits;
|
||||
this.buffer_snapshot = snapshot;
|
||||
this.entries = entries;
|
||||
@@ -410,11 +416,7 @@ impl GitBlame {
|
||||
} else {
|
||||
// If we weren't triggered by a user, we just log errors in the background, instead of sending
|
||||
// notifications.
|
||||
// Except for `NoRepositoryError`, which can happen often if a user has inline-blame turned on
|
||||
// and opens a non-git file.
|
||||
if error.downcast_ref::<project::NoRepositoryError>().is_none() {
|
||||
log::error!("failed to get git blame data: {error:?}");
|
||||
}
|
||||
log::error!("failed to get git blame data: {error:?}");
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
display_map::{InlayOffset, ToDisplayPoint},
|
||||
display_map::{invisibles::is_invisible, InlayOffset, ToDisplayPoint},
|
||||
hover_links::{InlayHighlight, RangeInEditor},
|
||||
scroll::ScrollAmount,
|
||||
Anchor, AnchorRangeExt, DisplayPoint, DisplayRow, Editor, EditorSettings, EditorSnapshot,
|
||||
@@ -11,7 +11,7 @@ use gpui::{
|
||||
StyleRefinement, Styled, Task, TextStyleRefinement, View, ViewContext,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use language::{DiagnosticEntry, Language, LanguageRegistry};
|
||||
use language::{Diagnostic, DiagnosticEntry, Language, LanguageRegistry};
|
||||
use lsp::DiagnosticSeverity;
|
||||
use markdown::{Markdown, MarkdownStyle};
|
||||
use multi_buffer::ToOffset;
|
||||
@@ -259,7 +259,7 @@ fn show_hover(
|
||||
}
|
||||
|
||||
// If there's a diagnostic, assign it on the hover state and notify
|
||||
let local_diagnostic = snapshot
|
||||
let mut local_diagnostic = snapshot
|
||||
.buffer_snapshot
|
||||
.diagnostics_in_range::<_, usize>(anchor..anchor, false)
|
||||
// Find the entry with the most specific range
|
||||
@@ -280,6 +280,41 @@ fn show_hover(
|
||||
range: entry.range.to_anchors(&snapshot.buffer_snapshot),
|
||||
})
|
||||
});
|
||||
if let Some(invisible) = snapshot
|
||||
.buffer_snapshot
|
||||
.chars_at(anchor)
|
||||
.next()
|
||||
.filter(|&c| is_invisible(c))
|
||||
{
|
||||
let after = snapshot.buffer_snapshot.anchor_after(
|
||||
anchor.to_offset(&snapshot.buffer_snapshot) + invisible.len_utf8(),
|
||||
);
|
||||
local_diagnostic = Some(DiagnosticEntry {
|
||||
diagnostic: Diagnostic {
|
||||
severity: DiagnosticSeverity::HINT,
|
||||
message: format!("Unicode character U+{:02X}", invisible as u32),
|
||||
..Default::default()
|
||||
},
|
||||
range: anchor..after,
|
||||
})
|
||||
} else if let Some(invisible) = snapshot
|
||||
.buffer_snapshot
|
||||
.reversed_chars_at(anchor)
|
||||
.next()
|
||||
.filter(|&c| is_invisible(c))
|
||||
{
|
||||
let before = snapshot.buffer_snapshot.anchor_before(
|
||||
anchor.to_offset(&snapshot.buffer_snapshot) - invisible.len_utf8(),
|
||||
);
|
||||
local_diagnostic = Some(DiagnosticEntry {
|
||||
diagnostic: Diagnostic {
|
||||
severity: DiagnosticSeverity::HINT,
|
||||
message: format!("Unicode character U+{:02X}", invisible as u32),
|
||||
..Default::default()
|
||||
},
|
||||
range: before..anchor,
|
||||
})
|
||||
}
|
||||
|
||||
let diagnostic_popover = if let Some(local_diagnostic) = local_diagnostic {
|
||||
let text = match local_diagnostic.diagnostic.source {
|
||||
|
||||
@@ -41,9 +41,9 @@ pub(super) fn refresh_linked_ranges(this: &mut Editor, cx: &mut ViewContext<Edit
|
||||
return None;
|
||||
}
|
||||
let project = this.project.clone()?;
|
||||
let selections = this.selections.all::<usize>(cx);
|
||||
let buffer = this.buffer.read(cx);
|
||||
let mut applicable_selections = vec![];
|
||||
let selections = this.selections.all::<usize>(cx);
|
||||
let snapshot = buffer.snapshot(cx);
|
||||
for selection in selections {
|
||||
let cursor_position = selection.head();
|
||||
|
||||
@@ -8,14 +8,14 @@ use std::{
|
||||
use collections::HashMap;
|
||||
use gpui::{AppContext, Model, Pixels};
|
||||
use itertools::Itertools;
|
||||
use language::{Bias, Point, Selection, SelectionGoal, TextDimension, ToPoint};
|
||||
use language::{Bias, Point, Selection, SelectionGoal, TextDimension};
|
||||
use util::post_inc;
|
||||
|
||||
use crate::{
|
||||
display_map::{DisplayMap, DisplaySnapshot, ToDisplayPoint},
|
||||
movement::TextLayoutDetails,
|
||||
Anchor, DisplayPoint, DisplayRow, ExcerptId, MultiBuffer, MultiBufferSnapshot, SelectMode,
|
||||
ToOffset,
|
||||
ToOffset, ToPoint,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -96,7 +96,7 @@ impl SelectionsCollection {
|
||||
|
||||
pub fn pending<D: TextDimension + Ord + Sub<D, Output = D>>(
|
||||
&self,
|
||||
cx: &AppContext,
|
||||
cx: &mut AppContext,
|
||||
) -> Option<Selection<D>> {
|
||||
self.pending_anchor()
|
||||
.as_ref()
|
||||
@@ -107,7 +107,7 @@ impl SelectionsCollection {
|
||||
self.pending.as_ref().map(|pending| pending.mode.clone())
|
||||
}
|
||||
|
||||
pub fn all<'a, D>(&self, cx: &AppContext) -> Vec<Selection<D>>
|
||||
pub fn all<'a, D>(&self, cx: &mut AppContext) -> Vec<Selection<D>>
|
||||
where
|
||||
D: 'a + TextDimension + Ord + Sub<D, Output = D>,
|
||||
{
|
||||
@@ -194,7 +194,7 @@ impl SelectionsCollection {
|
||||
pub fn disjoint_in_range<'a, D>(
|
||||
&self,
|
||||
range: Range<Anchor>,
|
||||
cx: &AppContext,
|
||||
cx: &mut AppContext,
|
||||
) -> Vec<Selection<D>>
|
||||
where
|
||||
D: 'a + TextDimension + Ord + Sub<D, Output = D> + std::fmt::Debug,
|
||||
@@ -239,9 +239,10 @@ impl SelectionsCollection {
|
||||
|
||||
pub fn newest<D: TextDimension + Ord + Sub<D, Output = D>>(
|
||||
&self,
|
||||
cx: &AppContext,
|
||||
cx: &mut AppContext,
|
||||
) -> Selection<D> {
|
||||
resolve(self.newest_anchor(), &self.buffer(cx))
|
||||
let buffer = self.buffer(cx);
|
||||
self.newest_anchor().map(|p| p.summary::<D>(&buffer))
|
||||
}
|
||||
|
||||
pub fn newest_display(&self, cx: &mut AppContext) -> Selection<DisplayPoint> {
|
||||
@@ -262,9 +263,10 @@ impl SelectionsCollection {
|
||||
|
||||
pub fn oldest<D: TextDimension + Ord + Sub<D, Output = D>>(
|
||||
&self,
|
||||
cx: &AppContext,
|
||||
cx: &mut AppContext,
|
||||
) -> Selection<D> {
|
||||
resolve(self.oldest_anchor(), &self.buffer(cx))
|
||||
let buffer = self.buffer(cx);
|
||||
self.oldest_anchor().map(|p| p.summary::<D>(&buffer))
|
||||
}
|
||||
|
||||
pub fn first_anchor(&self) -> Selection<Anchor> {
|
||||
@@ -276,14 +278,14 @@ impl SelectionsCollection {
|
||||
|
||||
pub fn first<D: TextDimension + Ord + Sub<D, Output = D>>(
|
||||
&self,
|
||||
cx: &AppContext,
|
||||
cx: &mut AppContext,
|
||||
) -> Selection<D> {
|
||||
self.all(cx).first().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn last<D: TextDimension + Ord + Sub<D, Output = D>>(
|
||||
&self,
|
||||
cx: &AppContext,
|
||||
cx: &mut AppContext,
|
||||
) -> Selection<D> {
|
||||
self.all(cx).last().unwrap().clone()
|
||||
}
|
||||
@@ -298,7 +300,7 @@ impl SelectionsCollection {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn ranges<D: TextDimension + Ord + Sub<D, Output = D> + std::fmt::Debug>(
|
||||
&self,
|
||||
cx: &AppContext,
|
||||
cx: &mut AppContext,
|
||||
) -> Vec<Range<D>> {
|
||||
self.all::<D>(cx)
|
||||
.iter()
|
||||
@@ -475,7 +477,7 @@ impl<'a> MutableSelectionsCollection<'a> {
|
||||
where
|
||||
T: 'a + ToOffset + ToPoint + TextDimension + Ord + Sub<T, Output = T> + std::marker::Copy,
|
||||
{
|
||||
let mut selections = self.all(self.cx);
|
||||
let mut selections = self.collection.all(self.cx);
|
||||
let mut start = range.start.to_offset(&self.buffer());
|
||||
let mut end = range.end.to_offset(&self.buffer());
|
||||
let reversed = if start > end {
|
||||
@@ -649,6 +651,7 @@ impl<'a> MutableSelectionsCollection<'a> {
|
||||
let mut changed = false;
|
||||
let display_map = self.display_map();
|
||||
let selections = self
|
||||
.collection
|
||||
.all::<Point>(self.cx)
|
||||
.into_iter()
|
||||
.map(|selection| {
|
||||
@@ -676,6 +679,7 @@ impl<'a> MutableSelectionsCollection<'a> {
|
||||
let mut changed = false;
|
||||
let snapshot = self.buffer().clone();
|
||||
let selections = self
|
||||
.collection
|
||||
.all::<usize>(self.cx)
|
||||
.into_iter()
|
||||
.map(|selection| {
|
||||
@@ -869,10 +873,3 @@ where
|
||||
goal: s.goal,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve<D: TextDimension + Ord + Sub<D, Output = D>>(
|
||||
selection: &Selection<Anchor>,
|
||||
buffer: &MultiBufferSnapshot,
|
||||
) -> Selection<D> {
|
||||
selection.map(|p| p.summary::<D>(buffer))
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@ impl FeatureFlag for ZedPro {
|
||||
const NAME: &'static str = "zed-pro";
|
||||
}
|
||||
|
||||
pub struct NotebookFeatureFlag;
|
||||
|
||||
impl FeatureFlag for NotebookFeatureFlag {
|
||||
const NAME: &'static str = "notebooks";
|
||||
}
|
||||
|
||||
pub struct AutoCommand {}
|
||||
impl FeatureFlag for AutoCommand {
|
||||
const NAME: &'static str = "auto-command";
|
||||
|
||||
@@ -4,7 +4,7 @@ use gpui::{HighlightStyle, Model, StyledText};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use project::{Entry, PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
atomic::{self, AtomicBool},
|
||||
Arc,
|
||||
@@ -254,6 +254,7 @@ impl PickerDelegate for NewPathDelegate {
|
||||
.trim()
|
||||
.trim_start_matches("./")
|
||||
.trim_start_matches('/');
|
||||
|
||||
let (dir, suffix) = if let Some(index) = query.rfind('/') {
|
||||
let suffix = if index + 1 < query.len() {
|
||||
Some(query[index + 1..].to_string())
|
||||
@@ -317,6 +318,14 @@ impl PickerDelegate for NewPathDelegate {
|
||||
})
|
||||
}
|
||||
|
||||
fn confirm_completion(
|
||||
&mut self,
|
||||
_: String,
|
||||
cx: &mut ViewContext<Picker<Self>>,
|
||||
) -> Option<String> {
|
||||
self.confirm_update_query(cx)
|
||||
}
|
||||
|
||||
fn confirm_update_query(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<String> {
|
||||
let m = self.matches.get(self.selected_index)?;
|
||||
if m.is_dir(self.project.read(cx), cx) {
|
||||
@@ -422,7 +431,32 @@ impl NewPathDelegate {
|
||||
) {
|
||||
cx.notify();
|
||||
if query.is_empty() {
|
||||
self.matches = vec![];
|
||||
self.matches = self
|
||||
.project
|
||||
.read(cx)
|
||||
.worktrees(cx)
|
||||
.flat_map(|worktree| {
|
||||
let worktree_id = worktree.read(cx).id();
|
||||
worktree
|
||||
.read(cx)
|
||||
.child_entries(Path::new(""))
|
||||
.filter_map(move |entry| {
|
||||
entry.is_dir().then(|| Match {
|
||||
path_match: Some(PathMatch {
|
||||
score: 1.0,
|
||||
positions: Default::default(),
|
||||
worktree_id: worktree_id.to_usize(),
|
||||
path: entry.path.clone(),
|
||||
path_prefix: "".into(),
|
||||
is_dir: entry.is_dir(),
|
||||
distance_to_relative_ancestor: 0,
|
||||
}),
|
||||
suffix: None,
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -220,7 +220,11 @@ impl PickerDelegate for OpenPathDelegate {
|
||||
})
|
||||
}
|
||||
|
||||
fn confirm_completion(&self, query: String) -> Option<String> {
|
||||
fn confirm_completion(
|
||||
&mut self,
|
||||
query: String,
|
||||
_: &mut ViewContext<Picker<Self>>,
|
||||
) -> Option<String> {
|
||||
Some(
|
||||
maybe!({
|
||||
let m = self.matches.get(self.selected_index)?;
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
pub mod blame;
|
||||
pub mod commit;
|
||||
pub mod diff;
|
||||
mod hosting_provider;
|
||||
mod remote;
|
||||
pub mod repository;
|
||||
pub mod status;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -7,15 +13,9 @@ use std::fmt;
|
||||
use std::str::FromStr;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub use git2 as libgit;
|
||||
|
||||
pub use crate::hosting_provider::*;
|
||||
|
||||
pub mod blame;
|
||||
pub mod commit;
|
||||
pub mod diff;
|
||||
pub mod repository;
|
||||
pub mod status;
|
||||
pub use crate::remote::*;
|
||||
pub use git2 as libgit;
|
||||
|
||||
pub static DOT_GIT: LazyLock<&'static OsStr> = LazyLock::new(|| OsStr::new(".git"));
|
||||
pub static COOKIES: LazyLock<&'static OsStr> = LazyLock::new(|| OsStr::new("cookies"));
|
||||
|
||||
@@ -69,7 +69,7 @@ pub trait GitHostingProvider {
|
||||
/// Returns a formatted range of line numbers to be placed in a permalink URL.
|
||||
fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String;
|
||||
|
||||
fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>>;
|
||||
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote>;
|
||||
|
||||
fn extract_pull_request(
|
||||
&self,
|
||||
@@ -111,6 +111,12 @@ impl GitHostingProviderRegistry {
|
||||
cx.global::<GlobalGitHostingProviderRegistry>().0.clone()
|
||||
}
|
||||
|
||||
/// Returns the global [`GitHostingProviderRegistry`], if one is set.
|
||||
pub fn try_global(cx: &AppContext) -> Option<Arc<Self>> {
|
||||
cx.try_global::<GlobalGitHostingProviderRegistry>()
|
||||
.map(|registry| registry.0.clone())
|
||||
}
|
||||
|
||||
/// Returns the global [`GitHostingProviderRegistry`].
|
||||
///
|
||||
/// Inserts a default [`GitHostingProviderRegistry`] if one does not yet exist.
|
||||
@@ -153,10 +159,10 @@ impl GitHostingProviderRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ParsedGitRemote<'a> {
|
||||
pub owner: &'a str,
|
||||
pub repo: &'a str,
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct ParsedGitRemote {
|
||||
pub owner: Arc<str>,
|
||||
pub repo: Arc<str>,
|
||||
}
|
||||
|
||||
pub fn parse_git_remote_url(
|
||||
|
||||
85
crates/git/src/remote.rs
Normal file
85
crates/git/src/remote.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use derive_more::Deref;
|
||||
use url::Url;
|
||||
|
||||
/// The URL to a Git remote.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Deref)]
|
||||
pub struct RemoteUrl(Url);
|
||||
|
||||
impl std::str::FromStr for RemoteUrl {
|
||||
type Err = url::ParseError;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
||||
if input.starts_with("git@") {
|
||||
// Rewrite remote URLs like `git@github.com:user/repo.git` to `ssh://git@github.com/user/repo.git`
|
||||
let ssh_url = input.replacen(':', "/", 1).replace("git@", "ssh://git@");
|
||||
Ok(RemoteUrl(Url::parse(&ssh_url)?))
|
||||
} else {
|
||||
Ok(RemoteUrl(Url::parse(input)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parsing_valid_remote_urls() {
|
||||
let valid_urls = vec![
|
||||
(
|
||||
"https://github.com/octocat/zed.git",
|
||||
"https",
|
||||
"github.com",
|
||||
"/octocat/zed.git",
|
||||
),
|
||||
(
|
||||
"git@github.com:octocat/zed.git",
|
||||
"ssh",
|
||||
"github.com",
|
||||
"/octocat/zed.git",
|
||||
),
|
||||
(
|
||||
"ssh://git@github.com/octocat/zed.git",
|
||||
"ssh",
|
||||
"github.com",
|
||||
"/octocat/zed.git",
|
||||
),
|
||||
(
|
||||
"file:///path/to/local/zed",
|
||||
"file",
|
||||
"",
|
||||
"/path/to/local/zed",
|
||||
),
|
||||
];
|
||||
|
||||
for (input, expected_scheme, expected_host, expected_path) in valid_urls {
|
||||
let parsed = input.parse::<RemoteUrl>().expect("failed to parse URL");
|
||||
let url = parsed.0;
|
||||
assert_eq!(
|
||||
url.scheme(),
|
||||
expected_scheme,
|
||||
"unexpected scheme for {input:?}",
|
||||
);
|
||||
assert_eq!(
|
||||
url.host_str().unwrap_or(""),
|
||||
expected_host,
|
||||
"unexpected host for {input:?}",
|
||||
);
|
||||
assert_eq!(url.path(), expected_path, "unexpected path for {input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parsing_invalid_remote_urls() {
|
||||
let invalid_urls = vec!["not_a_url", "http://"];
|
||||
|
||||
for url in invalid_urls {
|
||||
assert!(
|
||||
url.parse::<RemoteUrl>().is_err(),
|
||||
"expected \"{url}\" to not parse as a Git remote URL",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,9 @@ regex.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
url.workspace = true
|
||||
util.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
unindent.workspace = true
|
||||
indoc.workspace = true
|
||||
serde_json.workspace = true
|
||||
pretty_assertions.workspace = true
|
||||
|
||||
@@ -2,6 +2,7 @@ mod providers;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use git::repository::GitRepository;
|
||||
use git::GitHostingProviderRegistry;
|
||||
use gpui::AppContext;
|
||||
|
||||
@@ -10,17 +11,27 @@ pub use crate::providers::*;
|
||||
/// Initializes the Git hosting providers.
|
||||
pub fn init(cx: &AppContext) {
|
||||
let provider_registry = GitHostingProviderRegistry::global(cx);
|
||||
|
||||
// The providers are stored in a `BTreeMap`, so insertion order matters.
|
||||
// GitHub comes first.
|
||||
provider_registry.register_hosting_provider(Arc::new(Github));
|
||||
|
||||
// Then GitLab.
|
||||
provider_registry.register_hosting_provider(Arc::new(Gitlab));
|
||||
|
||||
// Then the other providers, in the order they were added.
|
||||
provider_registry.register_hosting_provider(Arc::new(Gitee));
|
||||
provider_registry.register_hosting_provider(Arc::new(Bitbucket));
|
||||
provider_registry.register_hosting_provider(Arc::new(Sourcehut));
|
||||
provider_registry.register_hosting_provider(Arc::new(Codeberg));
|
||||
provider_registry.register_hosting_provider(Arc::new(Gitee));
|
||||
provider_registry.register_hosting_provider(Arc::new(Github));
|
||||
provider_registry.register_hosting_provider(Arc::new(Gitlab::new()));
|
||||
provider_registry.register_hosting_provider(Arc::new(Sourcehut));
|
||||
}
|
||||
|
||||
/// Registers additional Git hosting providers.
|
||||
///
|
||||
/// These require information from the Git repository to construct, so their
|
||||
/// registration is deferred until we have a Git repository initialized.
|
||||
pub fn register_additional_providers(
|
||||
provider_registry: Arc<GitHostingProviderRegistry>,
|
||||
repository: Arc<dyn GitRepository>,
|
||||
) {
|
||||
let Some(origin_url) = repository.remote_url("origin") else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(gitlab_self_hosted) = Gitlab::from_remote_url(&origin_url) {
|
||||
provider_registry.register_hosting_provider(Arc::new(gitlab_self_hosted));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use url::Url;
|
||||
|
||||
use git::{BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote};
|
||||
use git::{
|
||||
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
|
||||
RemoteUrl,
|
||||
};
|
||||
|
||||
pub struct Bitbucket;
|
||||
|
||||
@@ -25,18 +30,22 @@ impl GitHostingProvider for Bitbucket {
|
||||
format!("lines-{start_line}:{end_line}")
|
||||
}
|
||||
|
||||
fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
|
||||
if url.contains("bitbucket.org") {
|
||||
let (_, repo_with_owner) = url.trim_end_matches(".git").split_once("bitbucket.org")?;
|
||||
let (owner, repo) = repo_with_owner
|
||||
.trim_start_matches('/')
|
||||
.trim_start_matches(':')
|
||||
.split_once('/')?;
|
||||
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
|
||||
let url = RemoteUrl::from_str(url).ok()?;
|
||||
|
||||
return Some(ParsedGitRemote { owner, repo });
|
||||
let host = url.host_str()?;
|
||||
if host != "bitbucket.org" {
|
||||
return None;
|
||||
}
|
||||
|
||||
None
|
||||
let mut path_segments = url.path_segments()?;
|
||||
let owner = path_segments.next()?;
|
||||
let repo = path_segments.next()?.trim_end_matches(".git");
|
||||
|
||||
Some(ParsedGitRemote {
|
||||
owner: owner.into(),
|
||||
repo: repo.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_commit_permalink(
|
||||
@@ -75,53 +84,62 @@ impl GitHostingProvider for Bitbucket {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use git::{parse_git_remote_url, GitHostingProviderRegistry};
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_remote_url_bitbucket_https_with_username() {
|
||||
let provider_registry = Arc::new(GitHostingProviderRegistry::new());
|
||||
provider_registry.register_hosting_provider(Arc::new(Bitbucket));
|
||||
let url = "https://thorstenballzed@bitbucket.org/thorstenzed/testingrepo.git";
|
||||
let (provider, parsed) = parse_git_remote_url(provider_registry, url).unwrap();
|
||||
assert_eq!(provider.name(), "Bitbucket");
|
||||
assert_eq!(parsed.owner, "thorstenzed");
|
||||
assert_eq!(parsed.repo, "testingrepo");
|
||||
fn test_parse_remote_url_given_ssh_url() {
|
||||
let parsed_remote = Bitbucket
|
||||
.parse_remote_url("git@bitbucket.org:zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_remote_url_bitbucket_https_without_username() {
|
||||
let provider_registry = Arc::new(GitHostingProviderRegistry::new());
|
||||
provider_registry.register_hosting_provider(Arc::new(Bitbucket));
|
||||
let url = "https://bitbucket.org/thorstenzed/testingrepo.git";
|
||||
let (provider, parsed) = parse_git_remote_url(provider_registry, url).unwrap();
|
||||
assert_eq!(provider.name(), "Bitbucket");
|
||||
assert_eq!(parsed.owner, "thorstenzed");
|
||||
assert_eq!(parsed.repo, "testingrepo");
|
||||
fn test_parse_remote_url_given_https_url() {
|
||||
let parsed_remote = Bitbucket
|
||||
.parse_remote_url("https://bitbucket.org/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_remote_url_bitbucket_git() {
|
||||
let provider_registry = Arc::new(GitHostingProviderRegistry::new());
|
||||
provider_registry.register_hosting_provider(Arc::new(Bitbucket));
|
||||
let url = "git@bitbucket.org:thorstenzed/testingrepo.git";
|
||||
let (provider, parsed) = parse_git_remote_url(provider_registry, url).unwrap();
|
||||
assert_eq!(provider.name(), "Bitbucket");
|
||||
assert_eq!(parsed.owner, "thorstenzed");
|
||||
assert_eq!(parsed.repo, "testingrepo");
|
||||
fn test_parse_remote_url_given_https_url_with_username() {
|
||||
let parsed_remote = Bitbucket
|
||||
.parse_remote_url("https://thorstenballzed@bitbucket.org/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_bitbucket_permalink_from_ssh_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "thorstenzed",
|
||||
repo: "testingrepo",
|
||||
};
|
||||
fn test_build_bitbucket_permalink() {
|
||||
let permalink = Bitbucket.build_permalink(
|
||||
remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "f00b4r",
|
||||
path: "main.rs",
|
||||
@@ -129,18 +147,17 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://bitbucket.org/thorstenzed/testingrepo/src/f00b4r/main.rs";
|
||||
let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_bitbucket_permalink_from_ssh_url_single_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "thorstenzed",
|
||||
repo: "testingrepo",
|
||||
};
|
||||
fn test_build_bitbucket_permalink_with_single_line_selection() {
|
||||
let permalink = Bitbucket.build_permalink(
|
||||
remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "f00b4r",
|
||||
path: "main.rs",
|
||||
@@ -148,19 +165,17 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url =
|
||||
"https://bitbucket.org/thorstenzed/testingrepo/src/f00b4r/main.rs#lines-7";
|
||||
let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_bitbucket_permalink_from_ssh_url_multi_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "thorstenzed",
|
||||
repo: "testingrepo",
|
||||
};
|
||||
fn test_build_bitbucket_permalink_with_multi_line_selection() {
|
||||
let permalink = Bitbucket.build_permalink(
|
||||
remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "f00b4r",
|
||||
path: "main.rs",
|
||||
@@ -169,7 +184,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let expected_url =
|
||||
"https://bitbucket.org/thorstenzed/testingrepo/src/f00b4r/main.rs#lines-24:48";
|
||||
"https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-24:48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
@@ -9,6 +10,7 @@ use url::Url;
|
||||
|
||||
use git::{
|
||||
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, Oid, ParsedGitRemote,
|
||||
RemoteUrl,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -103,19 +105,22 @@ impl GitHostingProvider for Codeberg {
|
||||
format!("L{start_line}-L{end_line}")
|
||||
}
|
||||
|
||||
fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
|
||||
if url.starts_with("git@codeberg.org:") || url.starts_with("https://codeberg.org/") {
|
||||
let repo_with_owner = url
|
||||
.trim_start_matches("git@codeberg.org:")
|
||||
.trim_start_matches("https://codeberg.org/")
|
||||
.trim_end_matches(".git");
|
||||
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
|
||||
let url = RemoteUrl::from_str(url).ok()?;
|
||||
|
||||
let (owner, repo) = repo_with_owner.split_once('/')?;
|
||||
|
||||
return Some(ParsedGitRemote { owner, repo });
|
||||
let host = url.host_str()?;
|
||||
if host != "codeberg.org" {
|
||||
return None;
|
||||
}
|
||||
|
||||
None
|
||||
let mut path_segments = url.path_segments()?;
|
||||
let owner = path_segments.next()?;
|
||||
let repo = path_segments.next()?.trim_end_matches(".git");
|
||||
|
||||
Some(ParsedGitRemote {
|
||||
owner: owner.into(),
|
||||
repo: repo.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_commit_permalink(
|
||||
@@ -170,16 +175,47 @@ impl GitHostingProvider for Codeberg {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_codeberg_permalink_from_ssh_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "rajveermalviya",
|
||||
repo: "zed",
|
||||
};
|
||||
fn test_parse_remote_url_given_ssh_url() {
|
||||
let parsed_remote = Codeberg
|
||||
.parse_remote_url("git@codeberg.org:zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_https_url() {
|
||||
let parsed_remote = Codeberg
|
||||
.parse_remote_url("https://codeberg.org/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_codeberg_permalink() {
|
||||
let permalink = Codeberg.build_permalink(
|
||||
remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
@@ -187,18 +223,17 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs";
|
||||
let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_codeberg_permalink_from_ssh_url_single_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "rajveermalviya",
|
||||
repo: "zed",
|
||||
};
|
||||
fn test_build_codeberg_permalink_with_single_line_selection() {
|
||||
let permalink = Codeberg.build_permalink(
|
||||
remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
@@ -206,18 +241,17 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7";
|
||||
let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_codeberg_permalink_from_ssh_url_multi_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "rajveermalviya",
|
||||
repo: "zed",
|
||||
};
|
||||
fn test_build_codeberg_permalink_with_multi_line_selection() {
|
||||
let permalink = Codeberg.build_permalink(
|
||||
remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
@@ -225,64 +259,7 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_codeberg_permalink_from_https_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "rajveermalviya",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Codeberg.build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams {
|
||||
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
path: "crates/zed/src/main.rs",
|
||||
selection: None,
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/zed/src/main.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_codeberg_permalink_from_https_url_single_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "rajveermalviya",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Codeberg.build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams {
|
||||
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
path: "crates/zed/src/main.rs",
|
||||
selection: Some(6..6),
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/zed/src/main.rs#L7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_codeberg_permalink_from_https_url_multi_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "rajveermalviya",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Codeberg.build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams {
|
||||
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
path: "crates/zed/src/main.rs",
|
||||
selection: Some(23..47),
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://codeberg.org/rajveermalviya/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/zed/src/main.rs#L24-L48";
|
||||
let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use url::Url;
|
||||
|
||||
use git::{BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote};
|
||||
use git::{
|
||||
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
|
||||
RemoteUrl,
|
||||
};
|
||||
|
||||
pub struct Gitee;
|
||||
|
||||
@@ -25,19 +30,22 @@ impl GitHostingProvider for Gitee {
|
||||
format!("L{start_line}-{end_line}")
|
||||
}
|
||||
|
||||
fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
|
||||
if url.starts_with("git@gitee.com:") || url.starts_with("https://gitee.com/") {
|
||||
let repo_with_owner = url
|
||||
.trim_start_matches("git@gitee.com:")
|
||||
.trim_start_matches("https://gitee.com/")
|
||||
.trim_end_matches(".git");
|
||||
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
|
||||
let url = RemoteUrl::from_str(url).ok()?;
|
||||
|
||||
let (owner, repo) = repo_with_owner.split_once('/')?;
|
||||
|
||||
return Some(ParsedGitRemote { owner, repo });
|
||||
let host = url.host_str()?;
|
||||
if host != "gitee.com" {
|
||||
return None;
|
||||
}
|
||||
|
||||
None
|
||||
let mut path_segments = url.path_segments()?;
|
||||
let owner = path_segments.next()?;
|
||||
let repo = path_segments.next()?.trim_end_matches(".git");
|
||||
|
||||
Some(ParsedGitRemote {
|
||||
owner: owner.into(),
|
||||
repo: repo.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_commit_permalink(
|
||||
@@ -76,16 +84,47 @@ impl GitHostingProvider for Gitee {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_gitee_permalink_from_ssh_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "libkitten",
|
||||
repo: "zed",
|
||||
};
|
||||
fn test_parse_remote_url_given_ssh_url() {
|
||||
let parsed_remote = Gitee
|
||||
.parse_remote_url("git@gitee.com:zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_https_url() {
|
||||
let parsed_remote = Gitee
|
||||
.parse_remote_url("https://gitee.com/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitee_permalink() {
|
||||
let permalink = Gitee.build_permalink(
|
||||
remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
@@ -93,18 +132,17 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs";
|
||||
let expected_url = "https://gitee.com/zed-industries/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitee_permalink_from_ssh_url_single_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "libkitten",
|
||||
repo: "zed",
|
||||
};
|
||||
fn test_build_gitee_permalink_with_single_line_selection() {
|
||||
let permalink = Gitee.build_permalink(
|
||||
remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
@@ -112,18 +150,17 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L7";
|
||||
let expected_url = "https://gitee.com/zed-industries/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitee_permalink_from_ssh_url_multi_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "libkitten",
|
||||
repo: "zed",
|
||||
};
|
||||
fn test_build_gitee_permalink_with_multi_line_selection() {
|
||||
let permalink = Gitee.build_permalink(
|
||||
remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
@@ -131,64 +168,7 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L24-48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitee_permalink_from_https_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "libkitten",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Gitee.build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams {
|
||||
sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
|
||||
path: "crates/zed/src/main.rs",
|
||||
selection: None,
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/zed/src/main.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitee_permalink_from_https_url_single_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "libkitten",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Gitee.build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams {
|
||||
sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
|
||||
path: "crates/zed/src/main.rs",
|
||||
selection: Some(6..6),
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/zed/src/main.rs#L7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitee_permalink_from_https_url_multi_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "libkitten",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Gitee.build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams {
|
||||
sha: "e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
|
||||
path: "crates/zed/src/main.rs",
|
||||
selection: Some(23..47),
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://gitee.com/libkitten/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/zed/src/main.rs#L24-48";
|
||||
let expected_url = "https://gitee.com/zed-industries/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L24-48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
@@ -10,7 +11,7 @@ use url::Url;
|
||||
|
||||
use git::{
|
||||
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, Oid, ParsedGitRemote,
|
||||
PullRequest,
|
||||
PullRequest, RemoteUrl,
|
||||
};
|
||||
|
||||
fn pull_request_number_regex() -> &'static Regex {
|
||||
@@ -107,19 +108,22 @@ impl GitHostingProvider for Github {
|
||||
format!("L{start_line}-L{end_line}")
|
||||
}
|
||||
|
||||
fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
|
||||
if url.starts_with("git@github.com:") || url.starts_with("https://github.com/") {
|
||||
let repo_with_owner = url
|
||||
.trim_start_matches("git@github.com:")
|
||||
.trim_start_matches("https://github.com/")
|
||||
.trim_end_matches(".git");
|
||||
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
|
||||
let url = RemoteUrl::from_str(url).ok()?;
|
||||
|
||||
let (owner, repo) = repo_with_owner.split_once('/')?;
|
||||
|
||||
return Some(ParsedGitRemote { owner, repo });
|
||||
let host = url.host_str()?;
|
||||
if host != "github.com" {
|
||||
return None;
|
||||
}
|
||||
|
||||
None
|
||||
let mut path_segments = url.path_segments()?;
|
||||
let owner = path_segments.next()?;
|
||||
let repo = path_segments.next()?.trim_end_matches(".git");
|
||||
|
||||
Some(ParsedGitRemote {
|
||||
owner: owner.into(),
|
||||
repo: repo.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_commit_permalink(
|
||||
@@ -193,16 +197,61 @@ impl GitHostingProvider for Github {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// TODO: Replace with `indoc`.
|
||||
use unindent::Unindent;
|
||||
use indoc::indoc;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_ssh_url() {
|
||||
let parsed_remote = Github
|
||||
.parse_remote_url("git@github.com:zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_https_url() {
|
||||
let parsed_remote = Github
|
||||
.parse_remote_url("https://github.com/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_https_url_with_username() {
|
||||
let parsed_remote = Github
|
||||
.parse_remote_url("https://jlannister@github.com/some-org/some-repo.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "some-org".into(),
|
||||
repo: "some-repo".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_github_permalink_from_ssh_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries",
|
||||
repo: "zed",
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
};
|
||||
let permalink = Github.build_permalink(
|
||||
remote,
|
||||
@@ -218,51 +267,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_github_permalink_from_ssh_url_single_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries",
|
||||
repo: "zed",
|
||||
};
|
||||
fn test_build_github_permalink() {
|
||||
let permalink = Github.build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams {
|
||||
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
selection: Some(6..6),
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_github_permalink_from_ssh_url_multi_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Github.build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams {
|
||||
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
selection: Some(23..47),
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_github_permalink_from_https_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Github.build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams {
|
||||
sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
|
||||
path: "crates/zed/src/main.rs",
|
||||
@@ -275,55 +285,53 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_github_permalink_from_https_url_single_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries",
|
||||
repo: "zed",
|
||||
};
|
||||
fn test_build_github_permalink_with_single_line_selection() {
|
||||
let permalink = Github.build_permalink(
|
||||
remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
|
||||
path: "crates/zed/src/main.rs",
|
||||
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
selection: Some(6..6),
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L7";
|
||||
let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_github_permalink_from_https_url_multi_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries",
|
||||
repo: "zed",
|
||||
};
|
||||
fn test_build_github_permalink_with_multi_line_selection() {
|
||||
let permalink = Github.build_permalink(
|
||||
remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
|
||||
path: "crates/zed/src/main.rs",
|
||||
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
selection: Some(23..47),
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L24-L48";
|
||||
let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_pull_requests() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries",
|
||||
repo: "zed",
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
};
|
||||
|
||||
let message = "This does not contain a pull request";
|
||||
assert!(Github.extract_pull_request(&remote, message).is_none());
|
||||
|
||||
// Pull request number at end of first line
|
||||
let message = r#"
|
||||
let message = indoc! {r#"
|
||||
project panel: do not expand collapsed worktrees on "collapse all entries" (#10687)
|
||||
|
||||
Fixes #10597
|
||||
@@ -332,7 +340,7 @@ mod tests {
|
||||
|
||||
- Fixed "project panel: collapse all entries" expanding collapsed worktrees.
|
||||
"#
|
||||
.unindent();
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
Github
|
||||
@@ -344,12 +352,12 @@ mod tests {
|
||||
);
|
||||
|
||||
// Pull request number in middle of line, which we want to ignore
|
||||
let message = r#"
|
||||
let message = indoc! {r#"
|
||||
Follow-up to #10687 to fix problems
|
||||
|
||||
See the original PR, this is a fix.
|
||||
"#
|
||||
.unindent();
|
||||
};
|
||||
assert_eq!(Github.extract_pull_request(&remote, &message), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,60 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use url::Url;
|
||||
use util::maybe;
|
||||
|
||||
use git::{BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote};
|
||||
use git::{
|
||||
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
|
||||
RemoteUrl,
|
||||
};
|
||||
|
||||
pub struct Gitlab;
|
||||
#[derive(Debug)]
|
||||
pub struct Gitlab {
|
||||
name: String,
|
||||
base_url: Url,
|
||||
}
|
||||
|
||||
impl Gitlab {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
name: "GitLab".to_string(),
|
||||
base_url: Url::parse("https://gitlab.com").unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_remote_url(remote_url: &str) -> Result<Self> {
|
||||
let host = maybe!({
|
||||
if let Some(remote_url) = remote_url.strip_prefix("git@") {
|
||||
if let Some((host, _)) = remote_url.trim_start_matches("git@").split_once(':') {
|
||||
return Some(host.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Url::parse(&remote_url)
|
||||
.ok()
|
||||
.and_then(|remote_url| remote_url.host_str().map(|host| host.to_string()))
|
||||
})
|
||||
.ok_or_else(|| anyhow!("URL has no host"))?;
|
||||
|
||||
if !host.contains("gitlab") {
|
||||
bail!("not a GitLab URL");
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
name: "GitLab Self-Hosted".to_string(),
|
||||
base_url: Url::parse(&format!("https://{}", host))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl GitHostingProvider for Gitlab {
|
||||
fn name(&self) -> String {
|
||||
"GitLab".to_string()
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn base_url(&self) -> Url {
|
||||
Url::parse("https://gitlab.com").unwrap()
|
||||
self.base_url.clone()
|
||||
}
|
||||
|
||||
fn supports_avatars(&self) -> bool {
|
||||
@@ -25,19 +69,22 @@ impl GitHostingProvider for Gitlab {
|
||||
format!("L{start_line}-{end_line}")
|
||||
}
|
||||
|
||||
fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
|
||||
if url.starts_with("git@gitlab.com:") || url.starts_with("https://gitlab.com/") {
|
||||
let repo_with_owner = url
|
||||
.trim_start_matches("git@gitlab.com:")
|
||||
.trim_start_matches("https://gitlab.com/")
|
||||
.trim_end_matches(".git");
|
||||
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
|
||||
let url = RemoteUrl::from_str(url).ok()?;
|
||||
|
||||
let (owner, repo) = repo_with_owner.split_once('/')?;
|
||||
|
||||
return Some(ParsedGitRemote { owner, repo });
|
||||
let host = url.host_str()?;
|
||||
if host != self.base_url.host_str()? {
|
||||
return None;
|
||||
}
|
||||
|
||||
None
|
||||
let mut path_segments = url.path_segments()?.collect::<Vec<_>>();
|
||||
let repo = path_segments.pop()?.trim_end_matches(".git");
|
||||
let owner = path_segments.join("/");
|
||||
|
||||
Some(ParsedGitRemote {
|
||||
owner: owner.into(),
|
||||
repo: repo.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_commit_permalink(
|
||||
@@ -79,16 +126,82 @@ impl GitHostingProvider for Gitlab {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_gitlab_permalink_from_ssh_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Gitlab.build_permalink(
|
||||
remote,
|
||||
fn test_parse_remote_url_given_ssh_url() {
|
||||
let parsed_remote = Gitlab::new()
|
||||
.parse_remote_url("git@gitlab.com:zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_https_url() {
|
||||
let parsed_remote = Gitlab::new()
|
||||
.parse_remote_url("https://gitlab.com/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_self_hosted_ssh_url() {
|
||||
let remote_url = "git@gitlab.my-enterprise.com:zed-industries/zed.git";
|
||||
|
||||
let parsed_remote = Gitlab::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_self_hosted_https_url_with_subgroup() {
|
||||
let remote_url = "https://gitlab.my-enterprise.com/group/subgroup/zed.git";
|
||||
let parsed_remote = Gitlab::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "group/subgroup".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitlab_permalink() {
|
||||
let permalink = Gitlab::new().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
@@ -101,13 +214,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitlab_permalink_from_ssh_url_single_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Gitlab.build_permalink(
|
||||
remote,
|
||||
fn test_build_gitlab_permalink_with_single_line_selection() {
|
||||
let permalink = Gitlab::new().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
@@ -120,13 +232,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitlab_permalink_from_ssh_url_multi_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Gitlab.build_permalink(
|
||||
remote,
|
||||
fn test_build_gitlab_permalink_with_multi_line_selection() {
|
||||
let permalink = Gitlab::new().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
@@ -139,13 +250,36 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitlab_permalink_from_https_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Gitlab.build_permalink(
|
||||
remote,
|
||||
fn test_build_gitlab_self_hosted_permalink_from_ssh_url() {
|
||||
let gitlab =
|
||||
Gitlab::from_remote_url("git@gitlab.some-enterprise.com:zed-industries/zed.git")
|
||||
.unwrap();
|
||||
let permalink = gitlab.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
selection: None,
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://gitlab.some-enterprise.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitlab_self_hosted_permalink_from_https_url() {
|
||||
let gitlab =
|
||||
Gitlab::from_remote_url("https://gitlab-instance.big-co.com/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
let permalink = gitlab.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
|
||||
path: "crates/zed/src/main.rs",
|
||||
@@ -153,45 +287,7 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitlab_permalink_from_https_url_single_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Gitlab.build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams {
|
||||
sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
|
||||
path: "crates/zed/src/main.rs",
|
||||
selection: Some(6..6),
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitlab_permalink_from_https_url_multi_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Gitlab.build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams {
|
||||
sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
|
||||
path: "crates/zed/src/main.rs",
|
||||
selection: Some(23..47),
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L24-48";
|
||||
let expected_url = "https://gitlab-instance.big-co.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use url::Url;
|
||||
|
||||
use git::{BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote};
|
||||
use git::{
|
||||
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
|
||||
RemoteUrl,
|
||||
};
|
||||
|
||||
pub struct Sourcehut;
|
||||
|
||||
@@ -25,21 +30,27 @@ impl GitHostingProvider for Sourcehut {
|
||||
format!("L{start_line}-{end_line}")
|
||||
}
|
||||
|
||||
fn parse_remote_url<'a>(&self, url: &'a str) -> Option<ParsedGitRemote<'a>> {
|
||||
if url.starts_with("git@git.sr.ht:") || url.starts_with("https://git.sr.ht/") {
|
||||
// sourcehut indicates a repo with '.git' suffix as a separate repo.
|
||||
// For example, "git@git.sr.ht:~username/repo" and "git@git.sr.ht:~username/repo.git"
|
||||
// are two distinct repositories.
|
||||
let repo_with_owner = url
|
||||
.trim_start_matches("git@git.sr.ht:~")
|
||||
.trim_start_matches("https://git.sr.ht/~");
|
||||
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
|
||||
let url = RemoteUrl::from_str(url).ok()?;
|
||||
|
||||
let (owner, repo) = repo_with_owner.split_once('/')?;
|
||||
|
||||
return Some(ParsedGitRemote { owner, repo });
|
||||
let host = url.host_str()?;
|
||||
if host != "git.sr.ht" {
|
||||
return None;
|
||||
}
|
||||
|
||||
None
|
||||
let mut path_segments = url.path_segments()?;
|
||||
let owner = path_segments.next()?.trim_start_matches('~');
|
||||
// We don't trim the `.git` suffix here like we do elsewhere, as
|
||||
// sourcehut treats a repo with `.git` suffix as a separate repo.
|
||||
//
|
||||
// For example, `git@git.sr.ht:~username/repo` and `git@git.sr.ht:~username/repo.git`
|
||||
// are two distinct repositories.
|
||||
let repo = path_segments.next()?;
|
||||
|
||||
Some(ParsedGitRemote {
|
||||
owner: owner.into(),
|
||||
repo: repo.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_commit_permalink(
|
||||
@@ -78,16 +89,62 @@ impl GitHostingProvider for Sourcehut {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_sourcehut_permalink_from_ssh_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "rajveermalviya",
|
||||
repo: "zed",
|
||||
};
|
||||
fn test_parse_remote_url_given_ssh_url() {
|
||||
let parsed_remote = Sourcehut
|
||||
.parse_remote_url("git@git.sr.ht:~zed-industries/zed")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_ssh_url_with_git_suffix() {
|
||||
let parsed_remote = Sourcehut
|
||||
.parse_remote_url("git@git.sr.ht:~zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed.git".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_https_url() {
|
||||
let parsed_remote = Sourcehut
|
||||
.parse_remote_url("https://git.sr.ht/~zed-industries/zed")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sourcehut_permalink() {
|
||||
let permalink = Sourcehut.build_permalink(
|
||||
remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
@@ -95,18 +152,17 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
|
||||
let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sourcehut_permalink_from_ssh_url_with_git_prefix() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "rajveermalviya",
|
||||
repo: "zed.git",
|
||||
};
|
||||
fn test_build_sourcehut_permalink_with_git_suffix() {
|
||||
let permalink = Sourcehut.build_permalink(
|
||||
remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed.git".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
@@ -114,18 +170,17 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://git.sr.ht/~rajveermalviya/zed.git/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
|
||||
let expected_url = "https://git.sr.ht/~zed-industries/zed.git/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sourcehut_permalink_from_ssh_url_single_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "rajveermalviya",
|
||||
repo: "zed",
|
||||
};
|
||||
fn test_build_sourcehut_permalink_with_single_line_selection() {
|
||||
let permalink = Sourcehut.build_permalink(
|
||||
remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
@@ -133,18 +188,17 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L7";
|
||||
let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sourcehut_permalink_from_ssh_url_multi_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "rajveermalviya",
|
||||
repo: "zed",
|
||||
};
|
||||
fn test_build_sourcehut_permalink_with_multi_line_selection() {
|
||||
let permalink = Sourcehut.build_permalink(
|
||||
remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams {
|
||||
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
path: "crates/editor/src/git/permalink.rs",
|
||||
@@ -152,64 +206,7 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sourcehut_permalink_from_https_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "rajveermalviya",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Sourcehut.build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams {
|
||||
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
path: "crates/zed/src/main.rs",
|
||||
selection: None,
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/zed/src/main.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sourcehut_permalink_from_https_url_single_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "rajveermalviya",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Sourcehut.build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams {
|
||||
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
path: "crates/zed/src/main.rs",
|
||||
selection: Some(6..6),
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/zed/src/main.rs#L7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sourcehut_permalink_from_https_url_multi_line_selection() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "rajveermalviya",
|
||||
repo: "zed",
|
||||
};
|
||||
let permalink = Sourcehut.build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams {
|
||||
sha: "faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
path: "crates/zed/src/main.rs",
|
||||
selection: Some(23..47),
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://git.sr.ht/~rajveermalviya/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/zed/src/main.rs#L24-48";
|
||||
let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,34 +37,34 @@ impl CursorPosition {
|
||||
}
|
||||
|
||||
fn update_position(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
|
||||
let editor = editor.read(cx);
|
||||
let buffer = editor.buffer().read(cx).snapshot(cx);
|
||||
editor.update(cx, |editor, cx| {
|
||||
let buffer = editor.buffer().read(cx).snapshot(cx);
|
||||
|
||||
self.selected_count = Default::default();
|
||||
self.selected_count.selections = editor.selections.count();
|
||||
let mut last_selection: Option<Selection<usize>> = None;
|
||||
for selection in editor.selections.all::<usize>(cx) {
|
||||
self.selected_count.characters += buffer
|
||||
.text_for_range(selection.start..selection.end)
|
||||
.map(|t| t.chars().count())
|
||||
.sum::<usize>();
|
||||
if last_selection
|
||||
.as_ref()
|
||||
.map_or(true, |last_selection| selection.id > last_selection.id)
|
||||
{
|
||||
last_selection = Some(selection);
|
||||
}
|
||||
}
|
||||
for selection in editor.selections.all::<Point>(cx) {
|
||||
if selection.end != selection.start {
|
||||
self.selected_count.lines += (selection.end.row - selection.start.row) as usize;
|
||||
if selection.end.column != 0 {
|
||||
self.selected_count.lines += 1;
|
||||
self.selected_count = Default::default();
|
||||
self.selected_count.selections = editor.selections.count();
|
||||
let mut last_selection: Option<Selection<usize>> = None;
|
||||
for selection in editor.selections.all::<usize>(cx) {
|
||||
self.selected_count.characters += buffer
|
||||
.text_for_range(selection.start..selection.end)
|
||||
.map(|t| t.chars().count())
|
||||
.sum::<usize>();
|
||||
if last_selection
|
||||
.as_ref()
|
||||
.map_or(true, |last_selection| selection.id > last_selection.id)
|
||||
{
|
||||
last_selection = Some(selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.position = last_selection.map(|s| s.head().to_point(&buffer));
|
||||
|
||||
for selection in editor.selections.all::<Point>(cx) {
|
||||
if selection.end != selection.start {
|
||||
self.selected_count.lines += (selection.end.row - selection.start.row) as usize;
|
||||
if selection.end.column != 0 {
|
||||
self.selected_count.lines += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.position = last_selection.map(|s| s.head().to_point(&buffer));
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
|
||||
@@ -56,8 +56,8 @@ impl GoToLine {
|
||||
}
|
||||
|
||||
pub fn new(active_editor: View<Editor>, cx: &mut ViewContext<Self>) -> Self {
|
||||
let editor = active_editor.read(cx);
|
||||
let cursor = editor.selections.last::<Point>(cx).head();
|
||||
let cursor =
|
||||
active_editor.update(cx, |editor, cx| editor.selections.last::<Point>(cx).head());
|
||||
|
||||
let line = cursor.row + 1;
|
||||
let column = cursor.column + 1;
|
||||
|
||||
@@ -217,6 +217,7 @@ pub(crate) type KeystrokeObserver =
|
||||
type QuitHandler = Box<dyn FnOnce(&mut AppContext) -> LocalBoxFuture<'static, ()> + 'static>;
|
||||
type ReleaseListener = Box<dyn FnOnce(&mut dyn Any, &mut AppContext) + 'static>;
|
||||
type NewViewListener = Box<dyn FnMut(AnyView, &mut WindowContext) + 'static>;
|
||||
type NewModelListener = Box<dyn FnMut(AnyModel, &mut AppContext) + 'static>;
|
||||
|
||||
/// Contains the state of the full application, and passed as a reference to a variety of callbacks.
|
||||
/// Other contexts such as [ModelContext], [WindowContext], and [ViewContext] deref to this type, making it the most general context type.
|
||||
@@ -237,6 +238,7 @@ pub struct AppContext {
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
pub(crate) globals_by_type: FxHashMap<TypeId, Box<dyn Any>>,
|
||||
pub(crate) entities: EntityMap,
|
||||
pub(crate) new_model_observers: SubscriberSet<TypeId, NewModelListener>,
|
||||
pub(crate) new_view_observers: SubscriberSet<TypeId, NewViewListener>,
|
||||
pub(crate) windows: SlotMap<WindowId, Option<Window>>,
|
||||
pub(crate) window_handles: FxHashMap<WindowId, AnyWindowHandle>,
|
||||
@@ -296,6 +298,7 @@ impl AppContext {
|
||||
globals_by_type: FxHashMap::default(),
|
||||
entities,
|
||||
new_view_observers: SubscriberSet::new(),
|
||||
new_model_observers: SubscriberSet::new(),
|
||||
window_handles: FxHashMap::default(),
|
||||
windows: SlotMap::with_key(),
|
||||
keymap: Rc::new(RefCell::new(Keymap::default())),
|
||||
@@ -1016,6 +1019,7 @@ impl AppContext {
|
||||
activate();
|
||||
subscription
|
||||
}
|
||||
|
||||
/// Arrange for the given function to be invoked whenever a view of the specified type is created.
|
||||
/// The function will be passed a mutable reference to the view along with an appropriate context.
|
||||
pub fn observe_new_views<V: 'static>(
|
||||
@@ -1035,6 +1039,31 @@ impl AppContext {
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn new_model_observer(&self, key: TypeId, value: NewModelListener) -> Subscription {
|
||||
let (subscription, activate) = self.new_model_observers.insert(key, value);
|
||||
activate();
|
||||
subscription
|
||||
}
|
||||
|
||||
/// Arrange for the given function to be invoked whenever a view of the specified type is created.
|
||||
/// The function will be passed a mutable reference to the view along with an appropriate context.
|
||||
pub fn observe_new_models<T: 'static>(
|
||||
&self,
|
||||
on_new: impl 'static + Fn(&mut T, &mut ModelContext<T>),
|
||||
) -> Subscription {
|
||||
self.new_model_observer(
|
||||
TypeId::of::<T>(),
|
||||
Box::new(move |any_model: AnyModel, cx: &mut AppContext| {
|
||||
any_model
|
||||
.downcast::<T>()
|
||||
.unwrap()
|
||||
.update(cx, |model_state, cx| {
|
||||
on_new(model_state, cx);
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Observe the release of a model or view. The callback is invoked after the model or view
|
||||
/// has no more strong references but before it has been dropped.
|
||||
pub fn observe_release<E, T>(
|
||||
@@ -1346,8 +1375,21 @@ impl Context for AppContext {
|
||||
) -> Model<T> {
|
||||
self.update(|cx| {
|
||||
let slot = cx.entities.reserve();
|
||||
let model = slot.clone();
|
||||
let entity = build_model(&mut ModelContext::new(cx, slot.downgrade()));
|
||||
cx.entities.insert(slot, entity)
|
||||
cx.entities.insert(slot, entity);
|
||||
|
||||
// Non-generic part to avoid leaking SubscriberSet to invokers of `new_view`.
|
||||
fn notify_observers(cx: &mut AppContext, tid: TypeId, model: AnyModel) {
|
||||
cx.new_model_observers.clone().retain(&tid, |observer| {
|
||||
let any_model = model.clone();
|
||||
(observer)(any_model, cx);
|
||||
true
|
||||
});
|
||||
}
|
||||
notify_observers(cx, TypeId::of::<T>(), AnyModel::from(model.clone()));
|
||||
|
||||
model
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! A list element that can be used to render a large number of differently sized elements
|
||||
//! efficiently. Clients of this API need to ensure that elements outside of the scrolled
|
||||
//! area do not change their height for this element to function correctly. In order to minimize
|
||||
//! re-renders, this element's state is stored intrusively on your own views, so that your code
|
||||
//! can coordinate directly with the list element's cached state.
|
||||
//! area do not change their height for this element to function correctly. If your elements
|
||||
//! do change height, notify the list element via [`ListState::splice`] or [`ListState::reset`].
|
||||
//! In order to minimize re-renders, this element's state is stored intrusively
|
||||
//! on your own views, so that your code can coordinate directly with the list element's cached state.
|
||||
//!
|
||||
//! If all of your elements are the same height, see [`UniformList`] for a simpler API
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::{
|
||||
black, fill, point, px, size, Bounds, Hsla, LineLayout, Pixels, Point, Result, SharedString,
|
||||
StrikethroughStyle, UnderlineStyle, WindowContext, WrapBoundary, WrappedLineLayout,
|
||||
black, fill, point, px, size, Bounds, Half, Hsla, LineLayout, Pixels, Point, Result,
|
||||
SharedString, StrikethroughStyle, UnderlineStyle, WindowContext, WrapBoundary,
|
||||
WrappedLineLayout,
|
||||
};
|
||||
use derive_more::{Deref, DerefMut};
|
||||
use smallvec::SmallVec;
|
||||
@@ -129,8 +130,9 @@ fn paint_line(
|
||||
let text_system = cx.text_system().clone();
|
||||
let mut glyph_origin = origin;
|
||||
let mut prev_glyph_position = Point::default();
|
||||
let mut max_glyph_size = size(px(0.), px(0.));
|
||||
for (run_ix, run) in layout.runs.iter().enumerate() {
|
||||
let max_glyph_size = text_system.bounding_box(run.font_id, layout.font_size).size;
|
||||
max_glyph_size = text_system.bounding_box(run.font_id, layout.font_size).size;
|
||||
|
||||
for (glyph_ix, glyph) in run.glyphs.iter().enumerate() {
|
||||
glyph_origin.x += glyph.position.x - prev_glyph_position.x;
|
||||
@@ -139,6 +141,9 @@ fn paint_line(
|
||||
wraps.next();
|
||||
if let Some((background_origin, background_color)) = current_background.as_mut()
|
||||
{
|
||||
if glyph_origin.x == background_origin.x {
|
||||
background_origin.x -= max_glyph_size.width.half()
|
||||
}
|
||||
cx.paint_quad(fill(
|
||||
Bounds {
|
||||
origin: *background_origin,
|
||||
@@ -150,6 +155,9 @@ fn paint_line(
|
||||
background_origin.y += line_height;
|
||||
}
|
||||
if let Some((underline_origin, underline_style)) = current_underline.as_mut() {
|
||||
if glyph_origin.x == underline_origin.x {
|
||||
underline_origin.x -= max_glyph_size.width.half();
|
||||
};
|
||||
cx.paint_underline(
|
||||
*underline_origin,
|
||||
glyph_origin.x - underline_origin.x,
|
||||
@@ -161,6 +169,9 @@ fn paint_line(
|
||||
if let Some((strikethrough_origin, strikethrough_style)) =
|
||||
current_strikethrough.as_mut()
|
||||
{
|
||||
if glyph_origin.x == strikethrough_origin.x {
|
||||
strikethrough_origin.x -= max_glyph_size.width.half();
|
||||
};
|
||||
cx.paint_strikethrough(
|
||||
*strikethrough_origin,
|
||||
glyph_origin.x - strikethrough_origin.x,
|
||||
@@ -179,7 +190,18 @@ fn paint_line(
|
||||
let mut finished_underline: Option<(Point<Pixels>, UnderlineStyle)> = None;
|
||||
let mut finished_strikethrough: Option<(Point<Pixels>, StrikethroughStyle)> = None;
|
||||
if glyph.index >= run_end {
|
||||
if let Some(style_run) = decoration_runs.next() {
|
||||
let mut style_run = decoration_runs.next();
|
||||
|
||||
// ignore style runs that apply to a partial glyph
|
||||
while let Some(run) = style_run {
|
||||
if glyph.index < run_end + (run.len as usize) {
|
||||
break;
|
||||
}
|
||||
run_end += run.len as usize;
|
||||
style_run = decoration_runs.next();
|
||||
}
|
||||
|
||||
if let Some(style_run) = style_run {
|
||||
if let Some((_, background_color)) = &mut current_background {
|
||||
if style_run.background_color.as_ref() != Some(background_color) {
|
||||
finished_background = current_background.take();
|
||||
@@ -239,17 +261,24 @@ fn paint_line(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((background_origin, background_color)) = finished_background {
|
||||
if let Some((mut background_origin, background_color)) = finished_background {
|
||||
let mut width = glyph_origin.x - background_origin.x;
|
||||
if background_origin.x == glyph_origin.x {
|
||||
background_origin.x -= max_glyph_size.width.half();
|
||||
};
|
||||
cx.paint_quad(fill(
|
||||
Bounds {
|
||||
origin: background_origin,
|
||||
size: size(glyph_origin.x - background_origin.x, line_height),
|
||||
size: size(width, line_height),
|
||||
},
|
||||
background_color,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some((underline_origin, underline_style)) = finished_underline {
|
||||
if let Some((mut underline_origin, underline_style)) = finished_underline {
|
||||
if underline_origin.x == glyph_origin.x {
|
||||
underline_origin.x -= max_glyph_size.width.half();
|
||||
};
|
||||
cx.paint_underline(
|
||||
underline_origin,
|
||||
glyph_origin.x - underline_origin.x,
|
||||
@@ -257,7 +286,12 @@ fn paint_line(
|
||||
);
|
||||
}
|
||||
|
||||
if let Some((strikethrough_origin, strikethrough_style)) = finished_strikethrough {
|
||||
if let Some((mut strikethrough_origin, strikethrough_style)) =
|
||||
finished_strikethrough
|
||||
{
|
||||
if strikethrough_origin.x == glyph_origin.x {
|
||||
strikethrough_origin.x -= max_glyph_size.width.half();
|
||||
};
|
||||
cx.paint_strikethrough(
|
||||
strikethrough_origin,
|
||||
glyph_origin.x - strikethrough_origin.x,
|
||||
@@ -299,7 +333,10 @@ fn paint_line(
|
||||
last_line_end_x -= glyph.position.x;
|
||||
}
|
||||
|
||||
if let Some((background_origin, background_color)) = current_background.take() {
|
||||
if let Some((mut background_origin, background_color)) = current_background.take() {
|
||||
if last_line_end_x == background_origin.x {
|
||||
background_origin.x -= max_glyph_size.width.half()
|
||||
};
|
||||
cx.paint_quad(fill(
|
||||
Bounds {
|
||||
origin: background_origin,
|
||||
@@ -309,7 +346,10 @@ fn paint_line(
|
||||
));
|
||||
}
|
||||
|
||||
if let Some((underline_start, underline_style)) = current_underline.take() {
|
||||
if let Some((mut underline_start, underline_style)) = current_underline.take() {
|
||||
if last_line_end_x == underline_start.x {
|
||||
underline_start.x -= max_glyph_size.width.half()
|
||||
};
|
||||
cx.paint_underline(
|
||||
underline_start,
|
||||
last_line_end_x - underline_start.x,
|
||||
@@ -317,7 +357,10 @@ fn paint_line(
|
||||
);
|
||||
}
|
||||
|
||||
if let Some((strikethrough_start, strikethrough_style)) = current_strikethrough.take() {
|
||||
if let Some((mut strikethrough_start, strikethrough_style)) = current_strikethrough.take() {
|
||||
if last_line_end_x == strikethrough_start.x {
|
||||
strikethrough_start.x -= max_glyph_size.width.half()
|
||||
};
|
||||
cx.paint_strikethrough(
|
||||
strikethrough_start,
|
||||
last_line_end_x - strikethrough_start.x,
|
||||
|
||||
@@ -4103,6 +4103,10 @@ impl<'a> BufferChunks<'a> {
|
||||
diagnostic_endpoints
|
||||
.sort_unstable_by_key(|endpoint| (endpoint.offset, !endpoint.is_start));
|
||||
*diagnostics = diagnostic_endpoints.into_iter().peekable();
|
||||
self.hint_depth = 0;
|
||||
self.error_depth = 0;
|
||||
self.warning_depth = 0;
|
||||
self.information_depth = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ menu.workspace = true
|
||||
ollama = { workspace = true, features = ["schemars"] }
|
||||
open_ai = { workspace = true, features = ["schemars"] }
|
||||
parking_lot.workspace = true
|
||||
proto = { workspace = true, features = ["test-support"] }
|
||||
proto.workspace = true
|
||||
project.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
@@ -62,6 +62,7 @@ env_logger.workspace = true
|
||||
language = { workspace = true, features = ["test-support"] }
|
||||
log.workspace = true
|
||||
project = { workspace = true, features = ["test-support"] }
|
||||
proto = { workspace = true, features = ["test-support"] }
|
||||
rand.workspace = true
|
||||
text = { workspace = true, features = ["test-support"] }
|
||||
unindent.workspace = true
|
||||
|
||||
@@ -1237,6 +1237,22 @@ impl Render for LspLogToolbarItemView {
|
||||
view.show_rpc_trace_for_server(row.server_id, cx);
|
||||
}),
|
||||
);
|
||||
if server_selected && row.selected_entry == LogKind::Rpc {
|
||||
let selected_ix = menu.select_last();
|
||||
// Each language server has:
|
||||
// 1. A title.
|
||||
// 2. Server logs.
|
||||
// 3. Server trace.
|
||||
// 4. RPC messages.
|
||||
// 5. Server capabilities
|
||||
// Thus, if nth server's RPC is selected, the index of selected entry should match this formula
|
||||
let _expected_index = ix * 5 + 3;
|
||||
debug_assert_eq!(
|
||||
Some(_expected_index),
|
||||
selected_ix,
|
||||
"Could not scroll to a just added LSP menu item"
|
||||
);
|
||||
}
|
||||
menu = menu.entry(
|
||||
SERVER_CAPABILITIES,
|
||||
None,
|
||||
@@ -1244,14 +1260,6 @@ impl Render for LspLogToolbarItemView {
|
||||
view.show_capabilities_for_server(row.server_id, cx);
|
||||
}),
|
||||
);
|
||||
if server_selected && row.selected_entry == LogKind::Rpc {
|
||||
let selected_ix = menu.select_last();
|
||||
debug_assert_eq!(
|
||||
Some(ix * 4 + 3),
|
||||
selected_ix,
|
||||
"Could not scroll to a just added LSP menu item"
|
||||
);
|
||||
}
|
||||
}
|
||||
menu
|
||||
})
|
||||
|
||||
@@ -128,12 +128,14 @@ impl SyntaxTreeView {
|
||||
fn editor_updated(&mut self, did_reparse: bool, cx: &mut ViewContext<Self>) -> Option<()> {
|
||||
// Find which excerpt the cursor is in, and the position within that excerpted buffer.
|
||||
let editor_state = self.editor.as_mut()?;
|
||||
let editor = &editor_state.editor.read(cx);
|
||||
let selection_range = editor.selections.last::<usize>(cx).range();
|
||||
let multibuffer = editor.buffer().read(cx);
|
||||
let (buffer, range, excerpt_id) = multibuffer
|
||||
.range_to_buffer_ranges(selection_range, cx)
|
||||
.pop()?;
|
||||
let (buffer, range, excerpt_id) = editor_state.editor.update(cx, |editor, cx| {
|
||||
let selection_range = editor.selections.last::<usize>(cx).range();
|
||||
editor
|
||||
.buffer()
|
||||
.read(cx)
|
||||
.range_to_buffer_ranges(selection_range, cx)
|
||||
.pop()
|
||||
})?;
|
||||
|
||||
// If the cursor has moved into a different excerpt, retrieve a new syntax layer
|
||||
// from that buffer.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name = "C++"
|
||||
grammar = "cpp"
|
||||
path_suffixes = ["cc", "hh", "cpp", "h", "hpp", "cxx", "hxx", "c++", "ipp", "inl", "cu", "cuh"]
|
||||
line_comments = ["// "]
|
||||
line_comments = ["// ", "/// ", "//! "]
|
||||
autoclose_before = ";:.,=}])>"
|
||||
brackets = [
|
||||
{ start = "{", end = "}", close = true, newline = true },
|
||||
|
||||
@@ -325,7 +325,7 @@ fn load_config(name: &str) -> LanguageConfig {
|
||||
.with_context(|| format!("failed to load config.toml for language {name:?}"))
|
||||
.unwrap();
|
||||
|
||||
#[cfg(not(feature = "load-grammars"))]
|
||||
#[cfg(not(any(feature = "load-grammars", test)))]
|
||||
{
|
||||
config = LanguageConfig {
|
||||
name: config.name,
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
(attribute attribute: (identifier) @property)
|
||||
(type (identifier) @type)
|
||||
(generic_type (identifier) @type)
|
||||
|
||||
; Type alias
|
||||
(type_alias_statement "type" @keyword)
|
||||
|
||||
; TypeVar with constraints in type parameters
|
||||
(type
|
||||
(tuple (identifier) @type)
|
||||
)
|
||||
|
||||
; Function calls
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ line_comments = ["// ", "/// ", "//! "]
|
||||
autoclose_before = ";:.,=}])>"
|
||||
brackets = [
|
||||
{ start = "{", end = "}", close = true, newline = true },
|
||||
{ start = "r#\"", end = "\"#", close = true, newline = true },
|
||||
{ start = "r##\"", end = "\"##", close = true, newline = true },
|
||||
{ start = "r###\"", end = "\"###", close = true, newline = true },
|
||||
{ start = "r#\"", end = "\"#", close = true, newline = true, not_in = ["string", "comment"] },
|
||||
{ start = "r##\"", end = "\"##", close = true, newline = true, not_in = ["string", "comment"] },
|
||||
{ start = "r###\"", end = "\"###", close = true, newline = true, not_in = ["string", "comment"] },
|
||||
{ start = "[", end = "]", close = true, newline = true },
|
||||
{ start = "(", end = ")", close = true, newline = true },
|
||||
{ start = "<", end = ">", close = false, newline = true, not_in = ["string", "comment"] },
|
||||
|
||||
@@ -301,8 +301,8 @@ impl MarkdownPreviewView {
|
||||
this.parse_markdown_from_active_editor(true, cx);
|
||||
}
|
||||
EditorEvent::SelectionsChanged { .. } => {
|
||||
let editor = editor.read(cx);
|
||||
let selection_range = editor.selections.last::<usize>(cx).range();
|
||||
let selection_range =
|
||||
editor.update(cx, |editor, cx| editor.selections.last::<usize>(cx).range());
|
||||
this.selected_block = this.get_block_index_under_cursor(selection_range);
|
||||
this.list_state.scroll_to_reveal_item(this.selected_block);
|
||||
cx.notify();
|
||||
|
||||
@@ -194,9 +194,11 @@ impl PickerDelegate for OutlineViewDelegate {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let editor = self.active_editor.read(cx);
|
||||
let cursor_offset = editor.selections.newest::<usize>(cx).head();
|
||||
let buffer = editor.buffer().read(cx).snapshot(cx);
|
||||
let (buffer, cursor_offset) = self.active_editor.update(cx, |editor, cx| {
|
||||
let buffer = editor.buffer().read(cx).snapshot(cx);
|
||||
let cursor_offset = editor.selections.newest::<usize>(cx).head();
|
||||
(buffer, cursor_offset)
|
||||
});
|
||||
selected_index = self
|
||||
.outline
|
||||
.items
|
||||
|
||||
@@ -35,7 +35,7 @@ use itertools::Itertools;
|
||||
use language::{BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem};
|
||||
use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrev};
|
||||
|
||||
use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings};
|
||||
use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings, ShowIndentGuides};
|
||||
use project::{File, Fs, Item, Project};
|
||||
use search::{BufferSearchBar, ProjectSearchView};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -2410,11 +2410,9 @@ impl OutlinePanel {
|
||||
editor: &View<Editor>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Option<PanelEntry> {
|
||||
let selection = editor
|
||||
.read(cx)
|
||||
.selections
|
||||
.newest::<language::Point>(cx)
|
||||
.head();
|
||||
let selection = editor.update(cx, |editor, cx| {
|
||||
editor.selections.newest::<language::Point>(cx).head()
|
||||
});
|
||||
let editor_snapshot = editor.update(cx, |editor, cx| editor.snapshot(cx));
|
||||
let multi_buffer = editor.read(cx).buffer();
|
||||
let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx);
|
||||
@@ -3748,7 +3746,7 @@ impl Render for OutlinePanel {
|
||||
let pinned = self.pinned;
|
||||
let settings = OutlinePanelSettings::get_global(cx);
|
||||
let indent_size = settings.indent_size;
|
||||
let show_indent_guides = settings.indent_guides;
|
||||
let show_indent_guides = settings.indent_guides.show == ShowIndentGuides::Always;
|
||||
|
||||
let outline_panel = v_flex()
|
||||
.id("outline-panel")
|
||||
|
||||
@@ -10,6 +10,13 @@ pub enum OutlinePanelDockPosition {
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ShowIndentGuides {
|
||||
Always,
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
|
||||
pub struct OutlinePanelSettings {
|
||||
pub button: bool,
|
||||
@@ -19,11 +26,22 @@ pub struct OutlinePanelSettings {
|
||||
pub folder_icons: bool,
|
||||
pub git_status: bool,
|
||||
pub indent_size: f32,
|
||||
pub indent_guides: bool,
|
||||
pub indent_guides: IndentGuidesSettings,
|
||||
pub auto_reveal_entries: bool,
|
||||
pub auto_fold_dirs: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub struct IndentGuidesSettings {
|
||||
pub show: ShowIndentGuides,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub struct IndentGuidesSettingsContent {
|
||||
/// When to show the scrollbar in the outline panel.
|
||||
pub show: Option<ShowIndentGuides>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
|
||||
pub struct OutlinePanelSettingsContent {
|
||||
/// Whether to show the outline panel button in the status bar.
|
||||
@@ -54,10 +72,6 @@ pub struct OutlinePanelSettingsContent {
|
||||
///
|
||||
/// Default: 20
|
||||
pub indent_size: Option<f32>,
|
||||
/// Whether to show indent guides in the outline panel.
|
||||
///
|
||||
/// Default: true
|
||||
pub indent_guides: Option<bool>,
|
||||
/// Whether to reveal it in the outline panel automatically,
|
||||
/// when a corresponding project entry becomes active.
|
||||
/// Gitignored entries are never auto revealed.
|
||||
@@ -69,6 +83,8 @@ pub struct OutlinePanelSettingsContent {
|
||||
///
|
||||
/// Default: true
|
||||
pub auto_fold_dirs: Option<bool>,
|
||||
/// Settings related to indent guides in the outline panel.
|
||||
pub indent_guides: Option<IndentGuidesSettingsContent>,
|
||||
}
|
||||
|
||||
impl Settings for OutlinePanelSettings {
|
||||
|
||||
@@ -108,7 +108,11 @@ pub trait PickerDelegate: Sized + 'static {
|
||||
fn should_dismiss(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn confirm_completion(&self, _query: String) -> Option<String> {
|
||||
fn confirm_completion(
|
||||
&mut self,
|
||||
_query: String,
|
||||
_: &mut ViewContext<Picker<Self>>,
|
||||
) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -370,7 +374,7 @@ impl<D: PickerDelegate> Picker<D> {
|
||||
}
|
||||
|
||||
fn confirm_completion(&mut self, _: &ConfirmCompletion, cx: &mut ViewContext<Self>) {
|
||||
if let Some(new_query) = self.delegate.confirm_completion(self.query(cx)) {
|
||||
if let Some(new_query) = self.delegate.confirm_completion(self.query(cx), cx) {
|
||||
self.set_query(new_query, cx);
|
||||
} else {
|
||||
cx.propagate()
|
||||
|
||||
@@ -14,14 +14,14 @@ use std::{
|
||||
};
|
||||
use util::paths::PathMatcher;
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Prettier {
|
||||
Real(RealPrettier),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
Test(TestPrettier),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RealPrettier {
|
||||
default: bool,
|
||||
prettier_dir: PathBuf,
|
||||
@@ -29,7 +29,7 @@ pub struct RealPrettier {
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
#[derive(Clone)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestPrettier {
|
||||
prettier_dir: PathBuf,
|
||||
default: bool,
|
||||
@@ -329,11 +329,7 @@ impl Prettier {
|
||||
})?
|
||||
.context("prettier params calculation")?;
|
||||
|
||||
let response = local
|
||||
.server
|
||||
.request::<Format>(params)
|
||||
.await
|
||||
.context("prettier format request")?;
|
||||
let response = local.server.request::<Format>(params).await?;
|
||||
let diff_task = buffer.update(cx, |buffer, cx| buffer.diff(response.text, cx))?;
|
||||
Ok(diff_task.await)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
search::SearchQuery,
|
||||
worktree_store::{WorktreeStore, WorktreeStoreEvent},
|
||||
Item, NoRepositoryError, ProjectPath,
|
||||
Item, ProjectPath,
|
||||
};
|
||||
use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry};
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
@@ -1118,7 +1118,7 @@ impl BufferStore {
|
||||
buffer: &Model<Buffer>,
|
||||
version: Option<clock::Global>,
|
||||
cx: &AppContext,
|
||||
) -> Task<Result<Blame>> {
|
||||
) -> Task<Result<Option<Blame>>> {
|
||||
let buffer = buffer.read(cx);
|
||||
let Some(file) = File::from_dyn(buffer.file()) else {
|
||||
return Task::ready(Err(anyhow!("buffer has no file")));
|
||||
@@ -1130,7 +1130,7 @@ impl BufferStore {
|
||||
let blame_params = maybe!({
|
||||
let (repo_entry, local_repo_entry) = match worktree.repo_for_path(&file.path) {
|
||||
Some(repo_for_path) => repo_for_path,
|
||||
None => anyhow::bail!(NoRepositoryError {}),
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let relative_path = repo_entry
|
||||
@@ -1144,13 +1144,16 @@ impl BufferStore {
|
||||
None => buffer.as_rope().clone(),
|
||||
};
|
||||
|
||||
anyhow::Ok((repo, relative_path, content))
|
||||
anyhow::Ok(Some((repo, relative_path, content)))
|
||||
});
|
||||
|
||||
cx.background_executor().spawn(async move {
|
||||
let (repo, relative_path, content) = blame_params?;
|
||||
let Some((repo, relative_path, content)) = blame_params? else {
|
||||
return Ok(None);
|
||||
};
|
||||
repo.blame(&relative_path, content)
|
||||
.with_context(|| format!("Failed to blame {:?}", relative_path.0))
|
||||
.map(Some)
|
||||
})
|
||||
}
|
||||
Worktree::Remote(worktree) => {
|
||||
@@ -2112,7 +2115,13 @@ fn is_not_found_error(error: &anyhow::Error) -> bool {
|
||||
.is_some_and(|err| err.kind() == io::ErrorKind::NotFound)
|
||||
}
|
||||
|
||||
fn serialize_blame_buffer_response(blame: git::blame::Blame) -> proto::BlameBufferResponse {
|
||||
fn serialize_blame_buffer_response(blame: Option<git::blame::Blame>) -> proto::BlameBufferResponse {
|
||||
let Some(blame) = blame else {
|
||||
return proto::BlameBufferResponse {
|
||||
blame_response: None,
|
||||
};
|
||||
};
|
||||
|
||||
let entries = blame
|
||||
.entries
|
||||
.into_iter()
|
||||
@@ -2154,14 +2163,19 @@ fn serialize_blame_buffer_response(blame: git::blame::Blame) -> proto::BlameBuff
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
proto::BlameBufferResponse {
|
||||
entries,
|
||||
messages,
|
||||
permalinks,
|
||||
remote_url: blame.remote_url,
|
||||
blame_response: Some(proto::blame_buffer_response::BlameResponse {
|
||||
entries,
|
||||
messages,
|
||||
permalinks,
|
||||
remote_url: blame.remote_url,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_blame_buffer_response(response: proto::BlameBufferResponse) -> git::blame::Blame {
|
||||
fn deserialize_blame_buffer_response(
|
||||
response: proto::BlameBufferResponse,
|
||||
) -> Option<git::blame::Blame> {
|
||||
let response = response.blame_response?;
|
||||
let entries = response
|
||||
.entries
|
||||
.into_iter()
|
||||
@@ -2202,10 +2216,10 @@ fn deserialize_blame_buffer_response(response: proto::BlameBufferResponse) -> gi
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
Blame {
|
||||
Some(Blame {
|
||||
entries,
|
||||
permalinks,
|
||||
messages,
|
||||
remote_url: response.remote_url,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ use gpui::{
|
||||
Task, WeakModel,
|
||||
};
|
||||
use http_client::HttpClient;
|
||||
use itertools::Itertools as _;
|
||||
use language::{
|
||||
language_settings::{
|
||||
language_settings, FormatOnSave, Formatter, LanguageSettings, SelectedFormatter,
|
||||
@@ -144,7 +145,6 @@ pub struct LocalLspStore {
|
||||
HashMap<LanguageServerId, (LanguageServerName, Arc<LanguageServer>)>,
|
||||
prettier_store: Model<PrettierStore>,
|
||||
current_lsp_settings: HashMap<LanguageServerName, LspSettings>,
|
||||
last_formatting_failure: Option<String>,
|
||||
_subscription: gpui::Subscription,
|
||||
}
|
||||
|
||||
@@ -563,9 +563,7 @@ impl LocalLspStore {
|
||||
})?;
|
||||
prettier_store::format_with_prettier(&prettier, &buffer.handle, cx)
|
||||
.await
|
||||
.transpose()
|
||||
.ok()
|
||||
.flatten()
|
||||
.transpose()?
|
||||
}
|
||||
Formatter::External { command, arguments } => {
|
||||
Self::format_via_external_command(buffer, command, arguments.as_deref(), cx)
|
||||
@@ -675,6 +673,7 @@ impl LocalLspStore {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FormattableBuffer {
|
||||
handle: Model<Buffer>,
|
||||
abs_path: Option<PathBuf>,
|
||||
@@ -704,6 +703,7 @@ impl LspStoreMode {
|
||||
|
||||
pub struct LspStore {
|
||||
mode: LspStoreMode,
|
||||
last_formatting_failure: Option<String>,
|
||||
downstream_client: Option<(AnyProtoClient, u64)>,
|
||||
nonce: u128,
|
||||
buffer_store: Model<BufferStore>,
|
||||
@@ -786,6 +786,7 @@ impl LspStore {
|
||||
pub fn init(client: &AnyProtoClient) {
|
||||
client.add_model_request_handler(Self::handle_multi_lsp_query);
|
||||
client.add_model_request_handler(Self::handle_restart_language_servers);
|
||||
client.add_model_request_handler(Self::handle_cancel_language_server_work);
|
||||
client.add_model_message_handler(Self::handle_start_language_server);
|
||||
client.add_model_message_handler(Self::handle_update_language_server);
|
||||
client.add_model_message_handler(Self::handle_language_server_log);
|
||||
@@ -905,7 +906,6 @@ impl LspStore {
|
||||
language_server_watcher_registrations: Default::default(),
|
||||
current_lsp_settings: ProjectSettings::get_global(cx).lsp.clone(),
|
||||
buffers_being_formatted: Default::default(),
|
||||
last_formatting_failure: None,
|
||||
prettier_store,
|
||||
environment,
|
||||
http_client,
|
||||
@@ -915,6 +915,7 @@ impl LspStore {
|
||||
this.as_local_mut().unwrap().shutdown_language_servers(cx)
|
||||
}),
|
||||
}),
|
||||
last_formatting_failure: None,
|
||||
downstream_client: None,
|
||||
buffer_store,
|
||||
worktree_store,
|
||||
@@ -975,6 +976,7 @@ impl LspStore {
|
||||
upstream_project_id: project_id,
|
||||
}),
|
||||
downstream_client: None,
|
||||
last_formatting_failure: None,
|
||||
buffer_store,
|
||||
worktree_store,
|
||||
languages: languages.clone(),
|
||||
@@ -4043,6 +4045,20 @@ impl LspStore {
|
||||
.or_default()
|
||||
.insert(server_id, summary);
|
||||
}
|
||||
if let Some((downstream_client, project_id)) = &this.downstream_client {
|
||||
downstream_client
|
||||
.send(proto::UpdateDiagnosticSummary {
|
||||
project_id: *project_id,
|
||||
worktree_id: worktree_id.to_proto(),
|
||||
summary: Some(proto::DiagnosticSummary {
|
||||
path: project_path.path.to_string_lossy().to_string(),
|
||||
language_server_id: server_id.0 as u64,
|
||||
error_count: summary.error_count as u32,
|
||||
warning_count: summary.warning_count as u32,
|
||||
}),
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
cx.emit(LspStoreEvent::DiagnosticsUpdated {
|
||||
language_server_id: LanguageServerId(message.language_server_id as usize),
|
||||
path: project_path,
|
||||
@@ -4103,7 +4119,7 @@ impl LspStore {
|
||||
LanguageServerProgress {
|
||||
title: payload.title,
|
||||
is_disk_based_diagnostics_progress: false,
|
||||
is_cancellable: false,
|
||||
is_cancellable: payload.is_cancellable.unwrap_or(false),
|
||||
message: payload.message,
|
||||
percentage: payload.percentage.map(|p| p as usize),
|
||||
last_update_at: cx.background_executor().now(),
|
||||
@@ -4119,7 +4135,7 @@ impl LspStore {
|
||||
LanguageServerProgress {
|
||||
title: None,
|
||||
is_disk_based_diagnostics_progress: false,
|
||||
is_cancellable: false,
|
||||
is_cancellable: payload.is_cancellable.unwrap_or(false),
|
||||
message: payload.message,
|
||||
percentage: payload.percentage.map(|p| p as usize),
|
||||
last_update_at: cx.background_executor().now(),
|
||||
@@ -4620,6 +4636,7 @@ impl LspStore {
|
||||
token,
|
||||
message: report.message,
|
||||
percentage: report.percentage,
|
||||
is_cancellable: report.cancellable,
|
||||
},
|
||||
),
|
||||
})
|
||||
@@ -4653,6 +4670,7 @@ impl LspStore {
|
||||
title: progress.title,
|
||||
message: progress.message,
|
||||
percentage: progress.percentage.map(|p| p as u32),
|
||||
is_cancellable: Some(progress.is_cancellable),
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -4683,6 +4701,9 @@ impl LspStore {
|
||||
if progress.percentage.is_some() {
|
||||
entry.percentage = progress.percentage;
|
||||
}
|
||||
if progress.is_cancellable != entry.is_cancellable {
|
||||
entry.is_cancellable = progress.is_cancellable;
|
||||
}
|
||||
cx.notify();
|
||||
return true;
|
||||
}
|
||||
@@ -5153,22 +5174,52 @@ impl LspStore {
|
||||
mut cx: AsyncAppContext,
|
||||
) -> Result<proto::Ack> {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
let buffers: Vec<_> = envelope
|
||||
.payload
|
||||
.buffer_ids
|
||||
.into_iter()
|
||||
.flat_map(|buffer_id| {
|
||||
this.buffer_store
|
||||
.read(cx)
|
||||
.get(BufferId::new(buffer_id).log_err()?)
|
||||
})
|
||||
.collect();
|
||||
this.restart_language_servers_for_buffers(buffers, cx)
|
||||
let buffers = this.buffer_ids_to_buffers(envelope.payload.buffer_ids.into_iter(), cx);
|
||||
this.restart_language_servers_for_buffers(buffers, cx);
|
||||
})?;
|
||||
|
||||
Ok(proto::Ack {})
|
||||
}
|
||||
|
||||
pub async fn handle_cancel_language_server_work(
|
||||
this: Model<Self>,
|
||||
envelope: TypedEnvelope<proto::CancelLanguageServerWork>,
|
||||
mut cx: AsyncAppContext,
|
||||
) -> Result<proto::Ack> {
|
||||
this.update(&mut cx, |this, cx| {
|
||||
if let Some(work) = envelope.payload.work {
|
||||
match work {
|
||||
proto::cancel_language_server_work::Work::Buffers(buffers) => {
|
||||
let buffers =
|
||||
this.buffer_ids_to_buffers(buffers.buffer_ids.into_iter(), cx);
|
||||
this.cancel_language_server_work_for_buffers(buffers, cx);
|
||||
}
|
||||
proto::cancel_language_server_work::Work::LanguageServerWork(work) => {
|
||||
let server_id = LanguageServerId::from_proto(work.language_server_id);
|
||||
this.cancel_language_server_work(server_id, work.token, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(proto::Ack {})
|
||||
}
|
||||
|
||||
fn buffer_ids_to_buffers(
|
||||
&mut self,
|
||||
buffer_ids: impl Iterator<Item = u64>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Vec<Model<Buffer>> {
|
||||
buffer_ids
|
||||
.into_iter()
|
||||
.flat_map(|buffer_id| {
|
||||
self.buffer_store
|
||||
.read(cx)
|
||||
.get(BufferId::new(buffer_id).log_err()?)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
async fn handle_apply_additional_edits_for_completion(
|
||||
this: Model<Self>,
|
||||
envelope: TypedEnvelope<proto::ApplyCompletionAdditionalEdits>,
|
||||
@@ -5214,9 +5265,9 @@ impl LspStore {
|
||||
.map(language::proto::serialize_transaction),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn last_formatting_failure(&self) -> Option<&str> {
|
||||
self.as_local()
|
||||
.and_then(|local| local.last_formatting_failure.as_deref())
|
||||
self.last_formatting_failure.as_deref()
|
||||
}
|
||||
|
||||
pub fn environment_for_buffer(
|
||||
@@ -5287,23 +5338,16 @@ impl LspStore {
|
||||
cx.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
lsp_store.update(&mut cx, |lsp_store, _| {
|
||||
let local = lsp_store.as_local_mut().unwrap();
|
||||
match &result {
|
||||
Ok(_) => local.last_formatting_failure = None,
|
||||
Err(error) => {
|
||||
local.last_formatting_failure.replace(error.to_string());
|
||||
}
|
||||
}
|
||||
lsp_store.update_last_formatting_failure(&result);
|
||||
})?;
|
||||
|
||||
result
|
||||
})
|
||||
} else if let Some((client, project_id)) = self.upstream_client() {
|
||||
let buffer_store = self.buffer_store();
|
||||
cx.spawn(move |_, mut cx| async move {
|
||||
let response = client
|
||||
cx.spawn(move |lsp_store, mut cx| async move {
|
||||
let result = client
|
||||
.request(proto::FormatBuffers {
|
||||
project_id,
|
||||
trigger: trigger as i32,
|
||||
@@ -5314,13 +5358,21 @@ impl LspStore {
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
})
|
||||
.await?
|
||||
.transaction
|
||||
.ok_or_else(|| anyhow!("missing transaction"))?;
|
||||
.await
|
||||
.and_then(|result| result.transaction.context("missing transaction"));
|
||||
|
||||
lsp_store.update(&mut cx, |lsp_store, _| {
|
||||
lsp_store.update_last_formatting_failure(&result);
|
||||
})?;
|
||||
|
||||
let transaction_response = result?;
|
||||
buffer_store
|
||||
.update(&mut cx, |buffer_store, cx| {
|
||||
buffer_store.deserialize_project_transaction(response, push_to_history, cx)
|
||||
buffer_store.deserialize_project_transaction(
|
||||
transaction_response,
|
||||
push_to_history,
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await
|
||||
})
|
||||
@@ -5342,7 +5394,7 @@ impl LspStore {
|
||||
buffers.insert(this.buffer_store.read(cx).get_existing(buffer_id)?);
|
||||
}
|
||||
let trigger = FormatTrigger::from_proto(envelope.payload.trigger);
|
||||
Ok::<_, anyhow::Error>(this.format(buffers, false, trigger, FormatTarget::Buffer, cx))
|
||||
anyhow::Ok(this.format(buffers, false, trigger, FormatTarget::Buffer, cx))
|
||||
})??;
|
||||
|
||||
let project_transaction = format.await?;
|
||||
@@ -5914,7 +5966,6 @@ impl LspStore {
|
||||
let adapter = adapter.clone();
|
||||
if let Some(this) = this.upgrade() {
|
||||
adapter.process_diagnostics(&mut params);
|
||||
// Everything else has to be on the server, Can we make it on the client?
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.update_diagnostics(
|
||||
server_id,
|
||||
@@ -6714,16 +6765,89 @@ impl LspStore {
|
||||
buffers: impl IntoIterator<Item = Model<Buffer>>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let servers = buffers
|
||||
.into_iter()
|
||||
.flat_map(|buffer| {
|
||||
self.language_server_ids_for_buffer(buffer.read(cx), cx)
|
||||
.into_iter()
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
if let Some((client, project_id)) = self.upstream_client() {
|
||||
let request = client.request(proto::CancelLanguageServerWork {
|
||||
project_id,
|
||||
work: Some(proto::cancel_language_server_work::Work::Buffers(
|
||||
proto::cancel_language_server_work::Buffers {
|
||||
buffer_ids: buffers
|
||||
.into_iter()
|
||||
.map(|b| b.read(cx).remote_id().to_proto())
|
||||
.collect(),
|
||||
},
|
||||
)),
|
||||
});
|
||||
cx.background_executor()
|
||||
.spawn(request)
|
||||
.detach_and_log_err(cx);
|
||||
} else {
|
||||
let servers = buffers
|
||||
.into_iter()
|
||||
.flat_map(|buffer| {
|
||||
self.language_server_ids_for_buffer(buffer.read(cx), cx)
|
||||
.into_iter()
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
for server_id in servers {
|
||||
self.cancel_language_server_work(server_id, None, cx);
|
||||
for server_id in servers {
|
||||
self.cancel_language_server_work(server_id, None, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_language_server_work(
|
||||
&mut self,
|
||||
server_id: LanguageServerId,
|
||||
token_to_cancel: Option<String>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let Some(local) = self.as_local() {
|
||||
let status = self.language_server_statuses.get(&server_id);
|
||||
let server = local.language_servers.get(&server_id);
|
||||
if let Some((LanguageServerState::Running { server, .. }, status)) = server.zip(status)
|
||||
{
|
||||
for (token, progress) in &status.pending_work {
|
||||
if let Some(token_to_cancel) = token_to_cancel.as_ref() {
|
||||
if token != token_to_cancel {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if progress.is_cancellable {
|
||||
server
|
||||
.notify::<lsp::notification::WorkDoneProgressCancel>(
|
||||
WorkDoneProgressCancelParams {
|
||||
token: lsp::NumberOrString::String(token.clone()),
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
if progress.is_cancellable {
|
||||
server
|
||||
.notify::<lsp::notification::WorkDoneProgressCancel>(
|
||||
WorkDoneProgressCancelParams {
|
||||
token: lsp::NumberOrString::String(token.clone()),
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some((client, project_id)) = self.upstream_client() {
|
||||
let request = client.request(proto::CancelLanguageServerWork {
|
||||
project_id,
|
||||
work: Some(
|
||||
proto::cancel_language_server_work::Work::LanguageServerWork(
|
||||
proto::cancel_language_server_work::LanguageServerWork {
|
||||
language_server_id: server_id.to_proto(),
|
||||
token: token_to_cancel,
|
||||
},
|
||||
),
|
||||
),
|
||||
});
|
||||
cx.background_executor()
|
||||
.spawn(request)
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6854,47 +6978,6 @@ impl LspStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_language_server_work(
|
||||
&mut self,
|
||||
server_id: LanguageServerId,
|
||||
token_to_cancel: Option<String>,
|
||||
_cx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(local) = self.as_local() else {
|
||||
return;
|
||||
};
|
||||
let status = self.language_server_statuses.get(&server_id);
|
||||
let server = local.language_servers.get(&server_id);
|
||||
if let Some((LanguageServerState::Running { server, .. }, status)) = server.zip(status) {
|
||||
for (token, progress) in &status.pending_work {
|
||||
if let Some(token_to_cancel) = token_to_cancel.as_ref() {
|
||||
if token != token_to_cancel {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if progress.is_cancellable {
|
||||
server
|
||||
.notify::<lsp::notification::WorkDoneProgressCancel>(
|
||||
WorkDoneProgressCancelParams {
|
||||
token: lsp::NumberOrString::String(token.clone()),
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
if progress.is_cancellable {
|
||||
server
|
||||
.notify::<lsp::notification::WorkDoneProgressCancel>(
|
||||
WorkDoneProgressCancelParams {
|
||||
token: lsp::NumberOrString::String(token.clone()),
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wait_for_remote_buffer(
|
||||
&mut self,
|
||||
id: BufferId,
|
||||
@@ -7284,6 +7367,18 @@ impl LspStore {
|
||||
lsp_action,
|
||||
})
|
||||
}
|
||||
|
||||
fn update_last_formatting_failure<T>(&mut self, formatting_result: &anyhow::Result<T>) {
|
||||
match &formatting_result {
|
||||
Ok(_) => self.last_formatting_failure = None,
|
||||
Err(error) => {
|
||||
let error_string = format!("{error:#}");
|
||||
log::error!("Formatting failed: {error_string}");
|
||||
self.last_formatting_failure
|
||||
.replace(error_string.lines().join(" "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<LspStoreEvent> for LspStore {}
|
||||
|
||||
@@ -827,7 +827,7 @@ impl Project {
|
||||
ssh_proto.add_model_message_handler(Self::handle_toast);
|
||||
ssh_proto.add_model_request_handler(Self::handle_language_server_prompt_request);
|
||||
ssh_proto.add_model_message_handler(Self::handle_hide_toast);
|
||||
ssh_proto.add_model_request_handler(BufferStore::handle_update_buffer);
|
||||
ssh_proto.add_model_request_handler(Self::handle_update_buffer_from_ssh);
|
||||
BufferStore::init(&ssh_proto);
|
||||
LspStore::init(&ssh_proto);
|
||||
SettingsObserver::init(&ssh_proto);
|
||||
@@ -1333,7 +1333,7 @@ impl Project {
|
||||
}
|
||||
|
||||
pub fn host(&self) -> Option<&Collaborator> {
|
||||
self.collaborators.values().find(|c| c.replica_id == 0)
|
||||
self.collaborators.values().find(|c| c.is_host)
|
||||
}
|
||||
|
||||
pub fn set_worktrees_reordered(&mut self, worktrees_reordered: bool, cx: &mut AppContext) {
|
||||
@@ -3420,7 +3420,7 @@ impl Project {
|
||||
buffer: &Model<Buffer>,
|
||||
version: Option<clock::Global>,
|
||||
cx: &AppContext,
|
||||
) -> Task<Result<Blame>> {
|
||||
) -> Task<Result<Option<Blame>>> {
|
||||
self.buffer_store.read(cx).blame_buffer(buffer, version, cx)
|
||||
}
|
||||
|
||||
@@ -3495,7 +3495,7 @@ impl Project {
|
||||
.collaborators
|
||||
.remove(&old_peer_id)
|
||||
.ok_or_else(|| anyhow!("received UpdateProjectCollaborator for unknown peer"))?;
|
||||
let is_host = collaborator.replica_id == 0;
|
||||
let is_host = collaborator.is_host;
|
||||
this.collaborators.insert(new_peer_id, collaborator);
|
||||
|
||||
log::info!("peer {} became {}", old_peer_id, new_peer_id,);
|
||||
@@ -3653,6 +3653,24 @@ impl Project {
|
||||
})?
|
||||
}
|
||||
|
||||
async fn handle_update_buffer_from_ssh(
|
||||
this: Model<Self>,
|
||||
envelope: TypedEnvelope<proto::UpdateBuffer>,
|
||||
cx: AsyncAppContext,
|
||||
) -> Result<proto::Ack> {
|
||||
let buffer_store = this.read_with(&cx, |this, cx| {
|
||||
if let Some(remote_id) = this.remote_id() {
|
||||
let mut payload = envelope.payload.clone();
|
||||
payload.project_id = remote_id;
|
||||
cx.background_executor()
|
||||
.spawn(this.client.request(payload))
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
this.buffer_store.clone()
|
||||
})?;
|
||||
BufferStore::handle_update_buffer(buffer_store, envelope, cx).await
|
||||
}
|
||||
|
||||
async fn handle_update_buffer(
|
||||
this: Model<Self>,
|
||||
envelope: TypedEnvelope<proto::UpdateBuffer>,
|
||||
@@ -4255,17 +4273,6 @@ impl Completion {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NoRepositoryError {}
|
||||
|
||||
impl std::fmt::Display for NoRepositoryError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "no git repository for worktree found")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NoRepositoryError {}
|
||||
|
||||
pub fn sort_worktree_entries(entries: &mut [Entry]) {
|
||||
entries.sort_by(|entry_a, entry_b| {
|
||||
compare_paths(
|
||||
|
||||
@@ -111,6 +111,16 @@ impl GitSettings {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_inline_commit_summary(&self) -> bool {
|
||||
match self.inline_blame {
|
||||
Some(InlineBlameSettings {
|
||||
show_commit_summary,
|
||||
..
|
||||
}) => show_commit_summary,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -141,12 +151,21 @@ pub struct InlineBlameSettings {
|
||||
///
|
||||
/// Default: 0
|
||||
pub min_column: Option<u32>,
|
||||
/// Whether to show commit summary as part of the inline blame.
|
||||
///
|
||||
/// Default: false
|
||||
#[serde(default = "false_value")]
|
||||
pub show_commit_summary: bool,
|
||||
}
|
||||
|
||||
const fn true_value() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const fn false_value() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
pub struct BinarySettings {
|
||||
pub path: Option<String>,
|
||||
|
||||
@@ -30,7 +30,7 @@ use project::{
|
||||
relativize_path, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree,
|
||||
WorktreeId,
|
||||
};
|
||||
use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
|
||||
use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings, ShowIndentGuides};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smallvec::SmallVec;
|
||||
use std::{
|
||||
@@ -3043,7 +3043,8 @@ impl Render for ProjectPanel {
|
||||
let has_worktree = !self.visible_entries.is_empty();
|
||||
let project = self.project.read(cx);
|
||||
let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
|
||||
let indent_guides = ProjectPanelSettings::get_global(cx).indent_guides;
|
||||
let show_indent_guides =
|
||||
ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
|
||||
let is_local = project.is_local();
|
||||
|
||||
if has_worktree {
|
||||
@@ -3147,7 +3148,7 @@ impl Render for ProjectPanel {
|
||||
items
|
||||
}
|
||||
})
|
||||
.when(indent_guides, |list| {
|
||||
.when(show_indent_guides, |list| {
|
||||
list.with_decoration(
|
||||
ui::indent_guides(
|
||||
cx.view().clone(),
|
||||
|
||||
@@ -11,6 +11,13 @@ pub enum ProjectPanelDockPosition {
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ShowIndentGuides {
|
||||
Always,
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ProjectPanelSettings {
|
||||
pub button: bool,
|
||||
@@ -20,12 +27,23 @@ pub struct ProjectPanelSettings {
|
||||
pub folder_icons: bool,
|
||||
pub git_status: bool,
|
||||
pub indent_size: f32,
|
||||
pub indent_guides: bool,
|
||||
pub indent_guides: IndentGuidesSettings,
|
||||
pub auto_reveal_entries: bool,
|
||||
pub auto_fold_dirs: bool,
|
||||
pub scrollbar: ScrollbarSettings,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub struct IndentGuidesSettings {
|
||||
pub show: ShowIndentGuides,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub struct IndentGuidesSettingsContent {
|
||||
/// When to show the scrollbar in the project panel.
|
||||
pub show: Option<ShowIndentGuides>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
pub struct ScrollbarSettings {
|
||||
/// When to show the scrollbar in the project panel.
|
||||
@@ -72,10 +90,6 @@ pub struct ProjectPanelSettingsContent {
|
||||
///
|
||||
/// Default: 20
|
||||
pub indent_size: Option<f32>,
|
||||
/// Whether to show indent guides in the project panel.
|
||||
///
|
||||
/// Default: true
|
||||
pub indent_guides: Option<bool>,
|
||||
/// Whether to reveal it in the project panel automatically,
|
||||
/// when a corresponding project entry becomes active.
|
||||
/// Gitignored entries are never auto revealed.
|
||||
@@ -89,6 +103,8 @@ pub struct ProjectPanelSettingsContent {
|
||||
pub auto_fold_dirs: Option<bool>,
|
||||
/// Scrollbar-related settings
|
||||
pub scrollbar: Option<ScrollbarSettingsContent>,
|
||||
/// Settings related to indent guides in the project panel.
|
||||
pub indent_guides: Option<IndentGuidesSettingsContent>,
|
||||
}
|
||||
|
||||
impl Settings for ProjectPanelSettings {
|
||||
|
||||
@@ -289,7 +289,12 @@ message Envelope {
|
||||
ActiveToolchainResponse active_toolchain_response = 277;
|
||||
|
||||
GetPathMetadata get_path_metadata = 278;
|
||||
GetPathMetadataResponse get_path_metadata_response = 279; // current max
|
||||
GetPathMetadataResponse get_path_metadata_response = 279;
|
||||
|
||||
GetPanicFiles get_panic_files = 280;
|
||||
GetPanicFilesResponse get_panic_files_response = 281;
|
||||
|
||||
CancelLanguageServerWork cancel_language_server_work = 282; // current max
|
||||
}
|
||||
|
||||
reserved 87 to 88;
|
||||
@@ -1254,12 +1259,14 @@ message LspWorkStart {
|
||||
optional string title = 4;
|
||||
optional string message = 2;
|
||||
optional uint32 percentage = 3;
|
||||
optional bool is_cancellable = 5;
|
||||
}
|
||||
|
||||
message LspWorkProgress {
|
||||
string token = 1;
|
||||
optional string message = 2;
|
||||
optional uint32 percentage = 3;
|
||||
optional bool is_cancellable = 4;
|
||||
}
|
||||
|
||||
message LspWorkEnd {
|
||||
@@ -1721,6 +1728,7 @@ message Collaborator {
|
||||
PeerId peer_id = 1;
|
||||
uint32 replica_id = 2;
|
||||
uint64 user_id = 3;
|
||||
bool is_host = 4;
|
||||
}
|
||||
|
||||
message User {
|
||||
@@ -2116,10 +2124,16 @@ message CommitPermalink {
|
||||
}
|
||||
|
||||
message BlameBufferResponse {
|
||||
repeated BlameEntry entries = 1;
|
||||
repeated CommitMessage messages = 2;
|
||||
repeated CommitPermalink permalinks = 3;
|
||||
optional string remote_url = 4;
|
||||
message BlameResponse {
|
||||
repeated BlameEntry entries = 1;
|
||||
repeated CommitMessage messages = 2;
|
||||
repeated CommitPermalink permalinks = 3;
|
||||
optional string remote_url = 4;
|
||||
}
|
||||
|
||||
optional BlameResponse blame_response = 5;
|
||||
|
||||
reserved 1 to 4;
|
||||
}
|
||||
|
||||
message MultiLspQuery {
|
||||
@@ -2482,5 +2496,29 @@ message UpdateGitBranch {
|
||||
uint64 project_id = 1;
|
||||
string branch_name = 2;
|
||||
ProjectPath repository = 3;
|
||||
|
||||
}
|
||||
|
||||
message GetPanicFiles {
|
||||
}
|
||||
|
||||
message GetPanicFilesResponse {
|
||||
repeated string file_contents = 2;
|
||||
}
|
||||
|
||||
message CancelLanguageServerWork {
|
||||
uint64 project_id = 1;
|
||||
|
||||
oneof work {
|
||||
Buffers buffers = 2;
|
||||
LanguageServerWork language_server_work = 3;
|
||||
}
|
||||
|
||||
message Buffers {
|
||||
repeated uint64 buffer_ids = 2;
|
||||
}
|
||||
|
||||
message LanguageServerWork {
|
||||
uint64 language_server_id = 1;
|
||||
optional string token = 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,19 @@ impl ErrorExt for anyhow::Error {
|
||||
if let Some(rpc_error) = self.downcast_ref::<RpcError>() {
|
||||
rpc_error.to_proto()
|
||||
} else {
|
||||
ErrorCode::Internal.message(format!("{}", self)).to_proto()
|
||||
ErrorCode::Internal
|
||||
.message(
|
||||
format!("{self:#}")
|
||||
.lines()
|
||||
.fold(String::new(), |mut message, line| {
|
||||
if !message.is_empty() {
|
||||
message.push(' ');
|
||||
}
|
||||
message.push_str(line);
|
||||
message
|
||||
}),
|
||||
)
|
||||
.to_proto()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -363,7 +363,10 @@ messages!(
|
||||
(ActiveToolchain, Foreground),
|
||||
(ActiveToolchainResponse, Foreground),
|
||||
(GetPathMetadata, Background),
|
||||
(GetPathMetadataResponse, Background)
|
||||
(GetPathMetadataResponse, Background),
|
||||
(GetPanicFiles, Background),
|
||||
(GetPanicFilesResponse, Background),
|
||||
(CancelLanguageServerWork, Foreground),
|
||||
);
|
||||
|
||||
request_messages!(
|
||||
@@ -483,7 +486,9 @@ request_messages!(
|
||||
(ListToolchains, ListToolchainsResponse),
|
||||
(ActivateToolchain, Ack),
|
||||
(ActiveToolchain, ActiveToolchainResponse),
|
||||
(GetPathMetadata, GetPathMetadataResponse)
|
||||
(GetPathMetadata, GetPathMetadataResponse),
|
||||
(GetPanicFiles, GetPanicFilesResponse),
|
||||
(CancelLanguageServerWork, Ack),
|
||||
);
|
||||
|
||||
entity_messages!(
|
||||
@@ -566,7 +571,8 @@ entity_messages!(
|
||||
ListToolchains,
|
||||
ActivateToolchain,
|
||||
ActiveToolchain,
|
||||
GetPathMetadata
|
||||
GetPathMetadata,
|
||||
CancelLanguageServerWork,
|
||||
);
|
||||
|
||||
entity_messages!(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pub mod disconnected_overlay;
|
||||
mod remote_servers;
|
||||
mod ssh_connections;
|
||||
pub use ssh_connections::open_ssh_project;
|
||||
pub use ssh_connections::{is_connecting_over_ssh, open_ssh_project};
|
||||
|
||||
use disconnected_overlay::DisconnectedOverlay;
|
||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
|
||||
@@ -1204,7 +1204,7 @@ impl RemoteServerProjects {
|
||||
Modal::new("remote-projects", Some(self.scroll_handle.clone()))
|
||||
.header(
|
||||
ModalHeader::new()
|
||||
.child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall)),
|
||||
.child(Headline::new("Remote Projects (beta)").size(HeadlineSize::XSmall)),
|
||||
)
|
||||
.section(
|
||||
Section::new().padded(false).child(
|
||||
|
||||
@@ -14,7 +14,7 @@ use gpui::{AppContext, Model};
|
||||
use language::CursorShape;
|
||||
use markdown::{Markdown, MarkdownStyle};
|
||||
use release_channel::{AppVersion, ReleaseChannel};
|
||||
use remote::ssh_session::ServerBinary;
|
||||
use remote::ssh_session::{ServerBinary, ServerVersion};
|
||||
use remote::{SshConnectionOptions, SshPlatform, SshRemoteClient};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -446,7 +446,7 @@ impl remote::SshClientDelegate for SshClientDelegate {
|
||||
platform: SshPlatform,
|
||||
upload_binary_over_ssh: bool,
|
||||
cx: &mut AsyncAppContext,
|
||||
) -> oneshot::Receiver<Result<(ServerBinary, SemanticVersion)>> {
|
||||
) -> oneshot::Receiver<Result<(ServerBinary, ServerVersion)>> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let this = self.clone();
|
||||
cx.spawn(|mut cx| async move {
|
||||
@@ -491,7 +491,7 @@ impl SshClientDelegate {
|
||||
platform: SshPlatform,
|
||||
upload_binary_via_ssh: bool,
|
||||
cx: &mut AsyncAppContext,
|
||||
) -> Result<(ServerBinary, SemanticVersion)> {
|
||||
) -> Result<(ServerBinary, ServerVersion)> {
|
||||
let (version, release_channel) = cx.update(|cx| {
|
||||
let version = AppVersion::global(cx);
|
||||
let channel = ReleaseChannel::global(cx);
|
||||
@@ -505,7 +505,10 @@ impl SshClientDelegate {
|
||||
let result = self.build_local(cx, platform, version).await?;
|
||||
// Fall through to a remote binary if we're not able to compile a local binary
|
||||
if let Some((path, version)) = result {
|
||||
return Ok((ServerBinary::LocalBinary(path), version));
|
||||
return Ok((
|
||||
ServerBinary::LocalBinary(path),
|
||||
ServerVersion::Semantic(version),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,9 +543,12 @@ impl SshClientDelegate {
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok((ServerBinary::LocalBinary(binary_path), version))
|
||||
Ok((
|
||||
ServerBinary::LocalBinary(binary_path),
|
||||
ServerVersion::Semantic(version),
|
||||
))
|
||||
} else {
|
||||
let (request_url, request_body) = AutoUpdater::get_remote_server_release_url(
|
||||
let (release, request_body) = AutoUpdater::get_remote_server_release_url(
|
||||
platform.os,
|
||||
platform.arch,
|
||||
release_channel,
|
||||
@@ -560,9 +566,14 @@ impl SshClientDelegate {
|
||||
)
|
||||
})?;
|
||||
|
||||
let version = release
|
||||
.version
|
||||
.parse::<SemanticVersion>()
|
||||
.map(ServerVersion::Semantic)
|
||||
.unwrap_or_else(|_| ServerVersion::Commit(release.version));
|
||||
Ok((
|
||||
ServerBinary::ReleaseUrl {
|
||||
url: request_url,
|
||||
url: release.url,
|
||||
body: request_body,
|
||||
},
|
||||
version,
|
||||
@@ -678,6 +689,10 @@ impl SshClientDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_connecting_over_ssh(workspace: &Workspace, cx: &AppContext) -> bool {
|
||||
workspace.active_modal::<SshConnectionModal>(cx).is_some()
|
||||
}
|
||||
|
||||
pub fn connect_over_ssh(
|
||||
unique_identifier: String,
|
||||
connection_options: SshConnectionOptions,
|
||||
|
||||
@@ -227,6 +227,20 @@ pub enum ServerBinary {
|
||||
ReleaseUrl { url: String, body: String },
|
||||
}
|
||||
|
||||
pub enum ServerVersion {
|
||||
Semantic(SemanticVersion),
|
||||
Commit(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ServerVersion {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Semantic(version) => write!(f, "{}", version),
|
||||
Self::Commit(commit) => write!(f, "{}", commit),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait SshClientDelegate: Send + Sync {
|
||||
fn ask_password(
|
||||
&self,
|
||||
@@ -243,7 +257,7 @@ pub trait SshClientDelegate: Send + Sync {
|
||||
platform: SshPlatform,
|
||||
upload_binary_over_ssh: bool,
|
||||
cx: &mut AsyncAppContext,
|
||||
) -> oneshot::Receiver<Result<(ServerBinary, SemanticVersion)>>;
|
||||
) -> oneshot::Receiver<Result<(ServerBinary, ServerVersion)>>;
|
||||
fn set_status(&self, status: Option<&str>, cx: &mut AsyncAppContext);
|
||||
}
|
||||
|
||||
@@ -1009,7 +1023,7 @@ impl SshRemoteClient {
|
||||
server_cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "fake-server"));
|
||||
let connection: Arc<dyn RemoteConnection> = Arc::new(fake::FakeRemoteConnection {
|
||||
connection_options: opts.clone(),
|
||||
server_cx: fake::SendableCx::new(server_cx.to_async()),
|
||||
server_cx: fake::SendableCx::new(server_cx),
|
||||
server_channel: server_client.clone(),
|
||||
});
|
||||
|
||||
@@ -1274,6 +1288,7 @@ impl SshRemoteConnection {
|
||||
) -> Result<Self> {
|
||||
use futures::AsyncWriteExt as _;
|
||||
use futures::{io::BufReader, AsyncBufReadExt as _};
|
||||
use smol::net::unix::UnixStream;
|
||||
use smol::{fs::unix::PermissionsExt as _, net::unix::UnixListener};
|
||||
use util::ResultExt as _;
|
||||
|
||||
@@ -1290,6 +1305,9 @@ impl SshRemoteConnection {
|
||||
let listener =
|
||||
UnixListener::bind(&askpass_socket).context("failed to create askpass socket")?;
|
||||
|
||||
let (askpass_kill_master_tx, askpass_kill_master_rx) = oneshot::channel::<UnixStream>();
|
||||
let mut kill_tx = Some(askpass_kill_master_tx);
|
||||
|
||||
let askpass_task = cx.spawn({
|
||||
let delegate = delegate.clone();
|
||||
|mut cx| async move {
|
||||
@@ -1313,6 +1331,11 @@ impl SshRemoteConnection {
|
||||
.log_err()
|
||||
{
|
||||
stream.write_all(password.as_bytes()).await.log_err();
|
||||
} else {
|
||||
if let Some(kill_tx) = kill_tx.take() {
|
||||
kill_tx.send(stream).log_err();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1333,6 +1356,7 @@ impl SshRemoteConnection {
|
||||
// the connection and keep it open, allowing other ssh commands to reuse it
|
||||
// via a control socket.
|
||||
let socket_path = temp_dir.path().join("ssh.sock");
|
||||
|
||||
let mut master_process = process::Command::new("ssh")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
@@ -1355,20 +1379,28 @@ impl SshRemoteConnection {
|
||||
|
||||
// Wait for this ssh process to close its stdout, indicating that authentication
|
||||
// has completed.
|
||||
let stdout = master_process.stdout.as_mut().unwrap();
|
||||
let mut stdout = master_process.stdout.take().unwrap();
|
||||
let mut output = Vec::new();
|
||||
let connection_timeout = Duration::from_secs(10);
|
||||
|
||||
let result = select_biased! {
|
||||
_ = askpass_opened_rx.fuse() => {
|
||||
// If the askpass script has opened, that means the user is typing
|
||||
// their password, in which case we don't want to timeout anymore,
|
||||
// since we know a connection has been established.
|
||||
stdout.read_to_end(&mut output).await?;
|
||||
Ok(())
|
||||
select_biased! {
|
||||
stream = askpass_kill_master_rx.fuse() => {
|
||||
master_process.kill().ok();
|
||||
drop(stream);
|
||||
Err(anyhow!("SSH connection canceled"))
|
||||
}
|
||||
// If the askpass script has opened, that means the user is typing
|
||||
// their password, in which case we don't want to timeout anymore,
|
||||
// since we know a connection has been established.
|
||||
result = stdout.read_to_end(&mut output).fuse() => {
|
||||
result?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
result = stdout.read_to_end(&mut output).fuse() => {
|
||||
result?;
|
||||
_ = stdout.read_to_end(&mut output).fuse() => {
|
||||
Ok(())
|
||||
}
|
||||
_ = futures::FutureExt::fuse(smol::Timer::after(connection_timeout)) => {
|
||||
@@ -1704,44 +1736,63 @@ impl SshRemoteConnection {
|
||||
}
|
||||
}
|
||||
|
||||
if self.is_binary_in_use(dst_path).await? {
|
||||
log::info!("server binary is opened by another process. not updating");
|
||||
delegate.set_status(
|
||||
Some("Skipping update of remote development server, since it's still in use"),
|
||||
cx,
|
||||
);
|
||||
return Ok(());
|
||||
if cfg!(not(debug_assertions)) {
|
||||
// When we're not in dev mode, we don't want to switch out the binary if it's
|
||||
// still open.
|
||||
// In dev mode, that's fine, since we often kill Zed processes with Ctrl-C and want
|
||||
// to still replace the binary.
|
||||
if self.is_binary_in_use(dst_path).await? {
|
||||
log::info!("server binary is opened by another process. not updating");
|
||||
delegate.set_status(
|
||||
Some("Skipping update of remote development server, since it's still in use"),
|
||||
cx,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let upload_binary_over_ssh = self.socket.connection_options.upload_binary_over_ssh;
|
||||
let (binary, version) = delegate
|
||||
let (binary, new_server_version) = delegate
|
||||
.get_server_binary(platform, upload_binary_over_ssh, cx)
|
||||
.await??;
|
||||
|
||||
let mut remote_version = None;
|
||||
if cfg!(not(debug_assertions)) {
|
||||
if let Ok(installed_version) =
|
||||
let installed_version = if let Ok(version_output) =
|
||||
run_cmd(self.socket.ssh_command(dst_path).arg("version")).await
|
||||
{
|
||||
if let Ok(version) = installed_version.trim().parse::<SemanticVersion>() {
|
||||
remote_version = Some(version);
|
||||
if let Ok(version) = version_output.trim().parse::<SemanticVersion>() {
|
||||
Some(ServerVersion::Semantic(version))
|
||||
} else {
|
||||
log::warn!("failed to parse version of remote server: {installed_version:?}",);
|
||||
Some(ServerVersion::Commit(version_output.trim().to_string()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(remote_version) = remote_version {
|
||||
if remote_version == version {
|
||||
log::info!("remote development server present and matching client version");
|
||||
return Ok(());
|
||||
} else if remote_version > version {
|
||||
let error = anyhow!("The version of the remote server ({}) is newer than the Zed version ({}). Please update Zed.", remote_version, version);
|
||||
return Err(error);
|
||||
} else {
|
||||
log::info!(
|
||||
"remote development server has older version: {}. updating...",
|
||||
remote_version
|
||||
);
|
||||
if let Some(installed_version) = installed_version {
|
||||
use ServerVersion::*;
|
||||
match (installed_version, new_server_version) {
|
||||
(Semantic(installed), Semantic(new)) if installed == new => {
|
||||
log::info!("remote development server present and matching client version");
|
||||
return Ok(());
|
||||
}
|
||||
(Semantic(installed), Semantic(new)) if installed > new => {
|
||||
let error = anyhow!("The version of the remote server ({}) is newer than the Zed version ({}). Please update Zed.", installed, new);
|
||||
return Err(error);
|
||||
}
|
||||
(Commit(installed), Commit(new)) if installed == new => {
|
||||
log::info!(
|
||||
"remote development server present and matching client version {}",
|
||||
installed
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
(installed, _) => {
|
||||
log::info!(
|
||||
"remote development server has version: {}. updating...",
|
||||
installed
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1966,77 +2017,97 @@ impl ChannelClient {
|
||||
mut incoming_rx: mpsc::UnboundedReceiver<Envelope>,
|
||||
cx: &AsyncAppContext,
|
||||
) -> Task<Result<()>> {
|
||||
cx.spawn(|cx| {
|
||||
async move {
|
||||
let peer_id = PeerId { owner_id: 0, id: 0 };
|
||||
while let Some(incoming) = incoming_rx.next().await {
|
||||
let Some(this) = this.upgrade() else {
|
||||
return anyhow::Ok(());
|
||||
};
|
||||
if let Some(ack_id) = incoming.ack_id {
|
||||
let mut buffer = this.buffer.lock();
|
||||
while buffer.front().is_some_and(|msg| msg.id <= ack_id) {
|
||||
buffer.pop_front();
|
||||
cx.spawn(|cx| async move {
|
||||
let peer_id = PeerId { owner_id: 0, id: 0 };
|
||||
while let Some(incoming) = incoming_rx.next().await {
|
||||
let Some(this) = this.upgrade() else {
|
||||
return anyhow::Ok(());
|
||||
};
|
||||
if let Some(ack_id) = incoming.ack_id {
|
||||
let mut buffer = this.buffer.lock();
|
||||
while buffer.front().is_some_and(|msg| msg.id <= ack_id) {
|
||||
buffer.pop_front();
|
||||
}
|
||||
}
|
||||
if let Some(proto::envelope::Payload::FlushBufferedMessages(_)) = &incoming.payload
|
||||
{
|
||||
log::debug!(
|
||||
"{}:ssh message received. name:FlushBufferedMessages",
|
||||
this.name
|
||||
);
|
||||
{
|
||||
let buffer = this.buffer.lock();
|
||||
for envelope in buffer.iter() {
|
||||
this.outgoing_tx
|
||||
.lock()
|
||||
.unbounded_send(envelope.clone())
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
if let Some(proto::envelope::Payload::FlushBufferedMessages(_)) =
|
||||
&incoming.payload
|
||||
{
|
||||
log::debug!("{}:ssh message received. name:FlushBufferedMessages", this.name);
|
||||
{
|
||||
let buffer = this.buffer.lock();
|
||||
for envelope in buffer.iter() {
|
||||
this.outgoing_tx.lock().unbounded_send(envelope.clone()).ok();
|
||||
}
|
||||
let mut envelope = proto::Ack {}.into_envelope(0, Some(incoming.id), None);
|
||||
envelope.id = this.next_message_id.fetch_add(1, SeqCst);
|
||||
this.outgoing_tx.lock().unbounded_send(envelope).ok();
|
||||
continue;
|
||||
}
|
||||
|
||||
this.max_received.store(incoming.id, SeqCst);
|
||||
|
||||
if let Some(request_id) = incoming.responding_to {
|
||||
let request_id = MessageId(request_id);
|
||||
let sender = this.response_channels.lock().remove(&request_id);
|
||||
if let Some(sender) = sender {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
if incoming.payload.is_some() {
|
||||
sender.send((incoming, tx)).ok();
|
||||
}
|
||||
let mut envelope = proto::Ack{}.into_envelope(0, Some(incoming.id), None);
|
||||
envelope.id = this.next_message_id.fetch_add(1, SeqCst);
|
||||
this.outgoing_tx.lock().unbounded_send(envelope).ok();
|
||||
continue;
|
||||
rx.await.ok();
|
||||
}
|
||||
|
||||
this.max_received.store(incoming.id, SeqCst);
|
||||
|
||||
if let Some(request_id) = incoming.responding_to {
|
||||
let request_id = MessageId(request_id);
|
||||
let sender = this.response_channels.lock().remove(&request_id);
|
||||
if let Some(sender) = sender {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
if incoming.payload.is_some() {
|
||||
sender.send((incoming, tx)).ok();
|
||||
}
|
||||
rx.await.ok();
|
||||
}
|
||||
} else if let Some(envelope) =
|
||||
build_typed_envelope(peer_id, Instant::now(), incoming)
|
||||
{
|
||||
let type_name = envelope.payload_type_name();
|
||||
if let Some(future) = ProtoMessageHandlerSet::handle_message(
|
||||
&this.message_handlers,
|
||||
envelope,
|
||||
this.clone().into(),
|
||||
cx.clone(),
|
||||
) {
|
||||
log::debug!("{}:ssh message received. name:{type_name}", this.name);
|
||||
cx.foreground_executor().spawn(async move {
|
||||
} else if let Some(envelope) =
|
||||
build_typed_envelope(peer_id, Instant::now(), incoming)
|
||||
{
|
||||
let type_name = envelope.payload_type_name();
|
||||
if let Some(future) = ProtoMessageHandlerSet::handle_message(
|
||||
&this.message_handlers,
|
||||
envelope,
|
||||
this.clone().into(),
|
||||
cx.clone(),
|
||||
) {
|
||||
log::debug!("{}:ssh message received. name:{type_name}", this.name);
|
||||
cx.foreground_executor()
|
||||
.spawn(async move {
|
||||
match future.await {
|
||||
Ok(_) => {
|
||||
log::debug!("{}:ssh message handled. name:{type_name}", this.name);
|
||||
log::debug!(
|
||||
"{}:ssh message handled. name:{type_name}",
|
||||
this.name
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!(
|
||||
"{}:error handling message. type:{type_name}, error:{error}", this.name,
|
||||
"{}:error handling message. type:{}, error:{}",
|
||||
this.name,
|
||||
type_name,
|
||||
format!("{error:#}").lines().fold(
|
||||
String::new(),
|
||||
|mut message, line| {
|
||||
if !message.is_empty() {
|
||||
message.push(' ');
|
||||
}
|
||||
message.push_str(line);
|
||||
message
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}).detach()
|
||||
} else {
|
||||
log::error!("{}:unhandled ssh message name:{type_name}", this.name);
|
||||
}
|
||||
})
|
||||
.detach()
|
||||
} else {
|
||||
log::error!("{}:unhandled ssh message name:{type_name}", this.name);
|
||||
}
|
||||
}
|
||||
anyhow::Ok(())
|
||||
}
|
||||
anyhow::Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2224,12 +2295,12 @@ mod fake {
|
||||
},
|
||||
select_biased, FutureExt, SinkExt, StreamExt,
|
||||
};
|
||||
use gpui::{AsyncAppContext, SemanticVersion, Task};
|
||||
use gpui::{AsyncAppContext, Task, TestAppContext};
|
||||
use rpc::proto::Envelope;
|
||||
|
||||
use super::{
|
||||
ChannelClient, RemoteConnection, ServerBinary, SshClientDelegate, SshConnectionOptions,
|
||||
SshPlatform,
|
||||
ChannelClient, RemoteConnection, ServerBinary, ServerVersion, SshClientDelegate,
|
||||
SshConnectionOptions, SshPlatform,
|
||||
};
|
||||
|
||||
pub(super) struct FakeRemoteConnection {
|
||||
@@ -2239,15 +2310,19 @@ mod fake {
|
||||
}
|
||||
|
||||
pub(super) struct SendableCx(AsyncAppContext);
|
||||
// safety: you can only get the other cx on the main thread.
|
||||
impl SendableCx {
|
||||
pub(super) fn new(cx: AsyncAppContext) -> Self {
|
||||
Self(cx)
|
||||
// SAFETY: When run in test mode, GPUI is always single threaded.
|
||||
pub(super) fn new(cx: &TestAppContext) -> Self {
|
||||
Self(cx.to_async())
|
||||
}
|
||||
|
||||
// SAFETY: Enforce that we're on the main thread by requiring a valid AsyncAppContext
|
||||
fn get(&self, _: &AsyncAppContext) -> AsyncAppContext {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: There is no way to access a SendableCx from a different thread, see [`SendableCx::new`] and [`SendableCx::get`]
|
||||
unsafe impl Send for SendableCx {}
|
||||
unsafe impl Sync for SendableCx {}
|
||||
|
||||
@@ -2349,7 +2424,7 @@ mod fake {
|
||||
_: SshPlatform,
|
||||
_: bool,
|
||||
_: &mut AsyncAppContext,
|
||||
) -> oneshot::Receiver<Result<(ServerBinary, SemanticVersion)>> {
|
||||
) -> oneshot::Receiver<Result<(ServerBinary, ServerVersion)>> {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,10 @@ debug-embed = ["dep:rust-embed"]
|
||||
test-support = ["fs/test-support"]
|
||||
|
||||
[dependencies]
|
||||
async-watch.workspace = true
|
||||
anyhow.workspace = true
|
||||
async-watch.workspace = true
|
||||
backtrace = "0.3"
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
client.workspace = true
|
||||
env_logger.workspace = true
|
||||
@@ -39,8 +40,10 @@ languages.workspace = true
|
||||
log.workspace = true
|
||||
lsp.workspace = true
|
||||
node_runtime.workspace = true
|
||||
project.workspace = true
|
||||
paths = { workspace = true }
|
||||
project.workspace = true
|
||||
proto.workspace = true
|
||||
release_channel.workspace = true
|
||||
remote.workspace = true
|
||||
reqwest_client.workspace = true
|
||||
rpc.workspace = true
|
||||
@@ -50,6 +53,7 @@ serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
shellexpand.workspace = true
|
||||
smol.workspace = true
|
||||
telemetry_events.workspace = true
|
||||
util.workspace = true
|
||||
worktree.workspace = true
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::process::Command;
|
||||
|
||||
const ZED_MANIFEST: &str = include_str!("../zed/Cargo.toml");
|
||||
|
||||
fn main() {
|
||||
@@ -7,4 +9,23 @@ fn main() {
|
||||
"cargo:rustc-env=ZED_PKG_VERSION={}",
|
||||
zed_cargo_toml.package.unwrap().version.unwrap()
|
||||
);
|
||||
|
||||
// If we're building this for nightly, we want to set the ZED_COMMIT_SHA
|
||||
if let Some(release_channel) = std::env::var("ZED_RELEASE_CHANNEL").ok() {
|
||||
if release_channel.as_str() == "nightly" {
|
||||
// Populate git sha environment variable if git is available
|
||||
println!("cargo:rerun-if-changed=../../.git/logs/HEAD");
|
||||
if let Some(output) = Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
{
|
||||
let git_sha = String::from_utf8_lossy(&output.stdout);
|
||||
let git_sha = git_sha.trim();
|
||||
|
||||
println!("cargo:rustc-env=ZED_COMMIT_SHA={git_sha}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,12 @@ fn main() {
|
||||
}
|
||||
},
|
||||
Some(Commands::Version) => {
|
||||
println!("{}", env!("ZED_PKG_VERSION"));
|
||||
if let Some(build_sha) = option_env!("ZED_COMMIT_SHA") {
|
||||
println!("{}", build_sha);
|
||||
} else {
|
||||
println!("{}", env!("ZED_PKG_VERSION"));
|
||||
}
|
||||
|
||||
std::process::exit(0);
|
||||
}
|
||||
None => {
|
||||
|
||||
@@ -528,6 +528,172 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext
|
||||
})
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_remote_cancel_language_server_work(
|
||||
cx: &mut TestAppContext,
|
||||
server_cx: &mut TestAppContext,
|
||||
) {
|
||||
let fs = FakeFs::new(server_cx.executor());
|
||||
fs.insert_tree(
|
||||
"/code",
|
||||
json!({
|
||||
"project1": {
|
||||
".git": {},
|
||||
"README.md": "# project 1",
|
||||
"src": {
|
||||
"lib.rs": "fn one() -> usize { 1 }"
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (project, headless) = init_test(&fs, cx, server_cx).await;
|
||||
|
||||
fs.insert_tree(
|
||||
"/code/project1/.zed",
|
||||
json!({
|
||||
"settings.json": r#"
|
||||
{
|
||||
"languages": {"Rust":{"language_servers":["rust-analyzer"]}},
|
||||
"lsp": {
|
||||
"rust-analyzer": {
|
||||
"binary": {
|
||||
"path": "~/.cargo/bin/rust-analyzer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
cx.update_model(&project, |project, _| {
|
||||
project.languages().register_test_language(LanguageConfig {
|
||||
name: "Rust".into(),
|
||||
matcher: LanguageMatcher {
|
||||
path_suffixes: vec!["rs".into()],
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
project.languages().register_fake_lsp_adapter(
|
||||
"Rust",
|
||||
FakeLspAdapter {
|
||||
name: "rust-analyzer",
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
let mut fake_lsp = server_cx.update(|cx| {
|
||||
headless.read(cx).languages.register_fake_language_server(
|
||||
LanguageServerName("rust-analyzer".into()),
|
||||
Default::default(),
|
||||
None,
|
||||
)
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let worktree_id = project
|
||||
.update(cx, |project, cx| {
|
||||
project.find_or_create_worktree("/code/project1", true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.0
|
||||
.read_with(cx, |worktree, _| worktree.id());
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_buffer((worktree_id, Path::new("src/lib.rs")), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
let mut fake_lsp = fake_lsp.next().await.unwrap();
|
||||
|
||||
// Cancelling all language server work for a given buffer
|
||||
{
|
||||
// Two operations, one cancellable and one not.
|
||||
fake_lsp
|
||||
.start_progress_with(
|
||||
"another-token",
|
||||
lsp::WorkDoneProgressBegin {
|
||||
cancellable: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let progress_token = "the-progress-token";
|
||||
fake_lsp
|
||||
.start_progress_with(
|
||||
progress_token,
|
||||
lsp::WorkDoneProgressBegin {
|
||||
cancellable: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
project.update(cx, |project, cx| {
|
||||
project.cancel_language_server_work_for_buffers([buffer.clone()], cx)
|
||||
});
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
// Verify the cancellation was received on the server side
|
||||
let cancel_notification = fake_lsp
|
||||
.receive_notification::<lsp::notification::WorkDoneProgressCancel>()
|
||||
.await;
|
||||
assert_eq!(
|
||||
cancel_notification.token,
|
||||
lsp::NumberOrString::String(progress_token.into())
|
||||
);
|
||||
}
|
||||
|
||||
// Cancelling work by server_id and token
|
||||
{
|
||||
let server_id = fake_lsp.server.server_id();
|
||||
let progress_token = "the-progress-token";
|
||||
|
||||
fake_lsp
|
||||
.start_progress_with(
|
||||
progress_token,
|
||||
lsp::WorkDoneProgressBegin {
|
||||
cancellable: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
project.update(cx, |project, cx| {
|
||||
project.cancel_language_server_work(server_id, Some(progress_token.into()), cx)
|
||||
});
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
// Verify the cancellation was received on the server side
|
||||
let cancel_notification = fake_lsp
|
||||
.receive_notification::<lsp::notification::WorkDoneProgressCancel>()
|
||||
.await;
|
||||
assert_eq!(
|
||||
cancel_notification.token,
|
||||
lsp::NumberOrString::String(progress_token.into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_remote_reload(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
|
||||
let fs = FakeFs::new(server_cx.executor());
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use crate::headless_project::HeadlessAppState;
|
||||
use crate::HeadlessProject;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use client::ProxySettings;
|
||||
use chrono::Utc;
|
||||
use client::{telemetry, ProxySettings};
|
||||
use fs::{Fs, RealFs};
|
||||
use futures::channel::mpsc;
|
||||
use futures::{select, select_biased, AsyncRead, AsyncWrite, AsyncWriteExt, FutureExt, SinkExt};
|
||||
use git::GitHostingProviderRegistry;
|
||||
use gpui::{AppContext, Context as _, ModelContext, UpdateGlobal as _};
|
||||
use gpui::{AppContext, Context as _, Model, ModelContext, UpdateGlobal as _};
|
||||
use http_client::{read_proxy_from_env, Uri};
|
||||
use language::LanguageRegistry;
|
||||
use node_runtime::{NodeBinaryOptions, NodeRuntime};
|
||||
@@ -21,19 +22,23 @@ use remote::{
|
||||
};
|
||||
use reqwest_client::ReqwestClient;
|
||||
use rpc::proto::{self, Envelope, SSH_PROJECT_ID};
|
||||
use rpc::{AnyProtoClient, TypedEnvelope};
|
||||
use settings::{watch_config_file, Settings, SettingsStore};
|
||||
use smol::channel::{Receiver, Sender};
|
||||
use smol::io::AsyncReadExt;
|
||||
|
||||
use smol::Async;
|
||||
use smol::{net::unix::UnixListener, stream::StreamExt as _};
|
||||
use std::ffi::OsStr;
|
||||
use std::ops::ControlFlow;
|
||||
use std::{env, thread};
|
||||
use std::{
|
||||
io::Write,
|
||||
mem,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use telemetry_events::LocationData;
|
||||
use util::ResultExt;
|
||||
|
||||
fn init_logging_proxy() {
|
||||
@@ -131,16 +136,97 @@ fn init_panic_hook() {
|
||||
backtrace.drain(0..=ix);
|
||||
}
|
||||
|
||||
let thread = thread::current();
|
||||
let thread_name = thread.name().unwrap_or("<unnamed>");
|
||||
|
||||
log::error!(
|
||||
"panic occurred: {}\nBacktrace:\n{}",
|
||||
payload,
|
||||
backtrace.join("\n")
|
||||
&payload,
|
||||
(&backtrace).join("\n")
|
||||
);
|
||||
|
||||
let panic_data = telemetry_events::Panic {
|
||||
thread: thread_name.into(),
|
||||
payload: payload.clone(),
|
||||
location_data: info.location().map(|location| LocationData {
|
||||
file: location.file().into(),
|
||||
line: location.line(),
|
||||
}),
|
||||
app_version: format!(
|
||||
"remote-server-{}",
|
||||
option_env!("ZED_COMMIT_SHA").unwrap_or(&env!("ZED_PKG_VERSION"))
|
||||
),
|
||||
release_channel: release_channel::RELEASE_CHANNEL.display_name().into(),
|
||||
os_name: telemetry::os_name(),
|
||||
os_version: Some(telemetry::os_version()),
|
||||
architecture: env::consts::ARCH.into(),
|
||||
panicked_on: Utc::now().timestamp_millis(),
|
||||
backtrace,
|
||||
system_id: None, // Set on SSH client
|
||||
installation_id: None, // Set on SSH client
|
||||
session_id: "".to_string(), // Set on SSH client
|
||||
};
|
||||
|
||||
if let Some(panic_data_json) = serde_json::to_string(&panic_data).log_err() {
|
||||
let timestamp = chrono::Utc::now().format("%Y_%m_%d %H_%M_%S").to_string();
|
||||
let panic_file_path = paths::logs_dir().join(format!("zed-{timestamp}.panic"));
|
||||
let panic_file = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.create(true)
|
||||
.open(&panic_file_path)
|
||||
.log_err();
|
||||
if let Some(mut panic_file) = panic_file {
|
||||
writeln!(&mut panic_file, "{panic_data_json}").log_err();
|
||||
panic_file.flush().log_err();
|
||||
}
|
||||
}
|
||||
|
||||
std::process::abort();
|
||||
}));
|
||||
}
|
||||
|
||||
fn handle_panic_requests(project: &Model<HeadlessProject>, client: &Arc<ChannelClient>) {
|
||||
let client: AnyProtoClient = client.clone().into();
|
||||
client.add_request_handler(
|
||||
project.downgrade(),
|
||||
|_, _: TypedEnvelope<proto::GetPanicFiles>, _cx| async move {
|
||||
let mut children = smol::fs::read_dir(paths::logs_dir()).await?;
|
||||
let mut panic_files = Vec::new();
|
||||
while let Some(child) = children.next().await {
|
||||
let child = child?;
|
||||
let child_path = child.path();
|
||||
|
||||
if child_path.extension() != Some(OsStr::new("panic")) {
|
||||
continue;
|
||||
}
|
||||
let filename = if let Some(filename) = child_path.file_name() {
|
||||
filename.to_string_lossy()
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !filename.starts_with("zed") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_contents = smol::fs::read_to_string(&child_path)
|
||||
.await
|
||||
.context("error reading panic file")?;
|
||||
|
||||
panic_files.push(file_contents);
|
||||
|
||||
// We've done what we can, delete the file
|
||||
std::fs::remove_file(child_path)
|
||||
.context("error removing panic")
|
||||
.log_err();
|
||||
}
|
||||
anyhow::Ok(proto::GetPanicFilesResponse {
|
||||
file_contents: panic_files,
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
struct ServerListeners {
|
||||
stdin: UnixListener,
|
||||
stdout: UnixListener,
|
||||
@@ -368,7 +454,7 @@ pub fn execute_run(
|
||||
|
||||
HeadlessProject::new(
|
||||
HeadlessAppState {
|
||||
session,
|
||||
session: session.clone(),
|
||||
fs,
|
||||
http_client,
|
||||
node_runtime,
|
||||
@@ -378,6 +464,8 @@ pub fn execute_run(
|
||||
)
|
||||
});
|
||||
|
||||
handle_panic_requests(&project, &session);
|
||||
|
||||
mem::forget(project);
|
||||
});
|
||||
log::info!("gpui app is shut down. quitting.");
|
||||
|
||||
@@ -21,13 +21,16 @@ client.workspace = true
|
||||
collections.workspace = true
|
||||
command_palette_hooks.workspace = true
|
||||
editor.workspace = true
|
||||
feature_flags.workspace = true
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
image.workspace = true
|
||||
language.workspace = true
|
||||
log.workspace = true
|
||||
markdown_preview.workspace = true
|
||||
menu.workspace = true
|
||||
multi_buffer.workspace = true
|
||||
nbformat.workspace = true
|
||||
project.workspace = true
|
||||
runtimelib.workspace = true
|
||||
schemars.workspace = true
|
||||
|
||||
4
crates/repl/src/notebook.rs
Normal file
4
crates/repl/src/notebook.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
mod cell;
|
||||
mod notebook_ui;
|
||||
pub use cell::*;
|
||||
pub use notebook_ui::*;
|
||||
730
crates/repl/src/notebook/cell.rs
Normal file
730
crates/repl/src/notebook/cell.rs
Normal file
@@ -0,0 +1,730 @@
|
||||
#![allow(unused, dead_code)]
|
||||
use std::sync::Arc;
|
||||
|
||||
use editor::{Editor, EditorMode, MultiBuffer};
|
||||
use futures::future::Shared;
|
||||
use gpui::{prelude::*, AppContext, Hsla, Task, TextStyleRefinement, View};
|
||||
use language::{Buffer, Language, LanguageRegistry};
|
||||
use markdown_preview::{markdown_parser::parse_markdown, markdown_renderer::render_markdown_block};
|
||||
use nbformat::v4::{CellId, CellMetadata, CellType};
|
||||
use settings::Settings as _;
|
||||
use theme::ThemeSettings;
|
||||
use ui::{prelude::*, IconButtonShape};
|
||||
use util::ResultExt;
|
||||
|
||||
use crate::{
|
||||
notebook::{CODE_BLOCK_INSET, GUTTER_WIDTH},
|
||||
outputs::{plain::TerminalOutput, user_error::ErrorView, Output},
|
||||
};
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, PartialOrd)]
|
||||
pub enum CellPosition {
|
||||
First,
|
||||
Middle,
|
||||
Last,
|
||||
}
|
||||
|
||||
pub enum CellControlType {
|
||||
RunCell,
|
||||
RerunCell,
|
||||
ClearCell,
|
||||
CellOptions,
|
||||
CollapseCell,
|
||||
ExpandCell,
|
||||
}
|
||||
|
||||
impl CellControlType {
|
||||
fn icon_name(&self) -> IconName {
|
||||
match self {
|
||||
CellControlType::RunCell => IconName::Play,
|
||||
CellControlType::RerunCell => IconName::ArrowCircle,
|
||||
CellControlType::ClearCell => IconName::ListX,
|
||||
CellControlType::CellOptions => IconName::Ellipsis,
|
||||
CellControlType::CollapseCell => IconName::ChevronDown,
|
||||
CellControlType::ExpandCell => IconName::ChevronRight,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CellControl {
|
||||
button: IconButton,
|
||||
}
|
||||
|
||||
impl CellControl {
|
||||
fn new(id: impl Into<SharedString>, control_type: CellControlType) -> Self {
|
||||
let icon_name = control_type.icon_name();
|
||||
let id = id.into();
|
||||
let button = IconButton::new(id, icon_name)
|
||||
.icon_size(IconSize::Small)
|
||||
.shape(IconButtonShape::Square);
|
||||
Self { button }
|
||||
}
|
||||
}
|
||||
|
||||
impl Clickable for CellControl {
|
||||
fn on_click(self, handler: impl Fn(&gpui::ClickEvent, &mut WindowContext) + 'static) -> Self {
|
||||
let button = self.button.on_click(handler);
|
||||
Self { button }
|
||||
}
|
||||
|
||||
fn cursor_style(self, _cursor_style: gpui::CursorStyle) -> Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A notebook cell
|
||||
#[derive(Clone)]
|
||||
pub enum Cell {
|
||||
Code(View<CodeCell>),
|
||||
Markdown(View<MarkdownCell>),
|
||||
Raw(View<RawCell>),
|
||||
}
|
||||
|
||||
fn convert_outputs(outputs: &Vec<nbformat::v4::Output>, cx: &mut WindowContext) -> Vec<Output> {
|
||||
outputs
|
||||
.into_iter()
|
||||
.map(|output| match output {
|
||||
nbformat::v4::Output::Stream { text, .. } => Output::Stream {
|
||||
content: cx.new_view(|cx| TerminalOutput::from(&text.0, cx)),
|
||||
},
|
||||
nbformat::v4::Output::DisplayData(display_data) => {
|
||||
Output::new(&display_data.data, None, cx)
|
||||
}
|
||||
nbformat::v4::Output::ExecuteResult(execute_result) => {
|
||||
Output::new(&execute_result.data, None, cx)
|
||||
}
|
||||
nbformat::v4::Output::Error(error) => Output::ErrorOutput(ErrorView {
|
||||
ename: error.ename.clone(),
|
||||
evalue: error.evalue.clone(),
|
||||
traceback: cx.new_view(|cx| TerminalOutput::from(&error.traceback.join("\n"), cx)),
|
||||
}),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
pub fn load(
|
||||
cell: &nbformat::v4::Cell,
|
||||
languages: &Arc<LanguageRegistry>,
|
||||
notebook_language: Shared<Task<Option<Arc<Language>>>>,
|
||||
cx: &mut WindowContext,
|
||||
) -> Self {
|
||||
match cell {
|
||||
nbformat::v4::Cell::Markdown {
|
||||
id,
|
||||
metadata,
|
||||
source,
|
||||
attachments: _,
|
||||
} => {
|
||||
let source = source.join("");
|
||||
|
||||
let view = cx.new_view(|cx| {
|
||||
let markdown_parsing_task = {
|
||||
let languages = languages.clone();
|
||||
let source = source.clone();
|
||||
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
let parsed_markdown = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
parse_markdown(&source, None, Some(languages)).await
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(&mut cx, |cell: &mut MarkdownCell, _| {
|
||||
cell.parsed_markdown = Some(parsed_markdown);
|
||||
})
|
||||
.log_err();
|
||||
})
|
||||
};
|
||||
|
||||
MarkdownCell {
|
||||
markdown_parsing_task,
|
||||
languages: languages.clone(),
|
||||
id: id.clone(),
|
||||
metadata: metadata.clone(),
|
||||
source: source.clone(),
|
||||
parsed_markdown: None,
|
||||
selected: false,
|
||||
cell_position: None,
|
||||
}
|
||||
});
|
||||
|
||||
Cell::Markdown(view)
|
||||
}
|
||||
nbformat::v4::Cell::Code {
|
||||
id,
|
||||
metadata,
|
||||
execution_count,
|
||||
source,
|
||||
outputs,
|
||||
} => Cell::Code(cx.new_view(|cx| {
|
||||
let text = source.join("");
|
||||
|
||||
let buffer = cx.new_model(|cx| Buffer::local(text.clone(), cx));
|
||||
let multi_buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer.clone(), cx));
|
||||
|
||||
let editor_view = cx.new_view(|cx| {
|
||||
let mut editor = Editor::new(
|
||||
EditorMode::AutoHeight { max_lines: 1024 },
|
||||
multi_buffer,
|
||||
None,
|
||||
false,
|
||||
cx,
|
||||
);
|
||||
|
||||
let theme = ThemeSettings::get_global(cx);
|
||||
|
||||
let refinement = TextStyleRefinement {
|
||||
font_family: Some(theme.buffer_font.family.clone()),
|
||||
font_size: Some(theme.buffer_font_size.into()),
|
||||
color: Some(cx.theme().colors().editor_foreground),
|
||||
background_color: Some(gpui::transparent_black()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
editor.set_text(text, cx);
|
||||
editor.set_show_gutter(false, cx);
|
||||
editor.set_text_style_refinement(refinement);
|
||||
|
||||
// editor.set_read_only(true);
|
||||
editor
|
||||
});
|
||||
|
||||
let buffer = buffer.clone();
|
||||
let language_task = cx.spawn(|this, mut cx| async move {
|
||||
let language = notebook_language.await;
|
||||
|
||||
buffer.update(&mut cx, |buffer, cx| {
|
||||
buffer.set_language(language.clone(), cx);
|
||||
});
|
||||
});
|
||||
|
||||
CodeCell {
|
||||
id: id.clone(),
|
||||
metadata: metadata.clone(),
|
||||
execution_count: *execution_count,
|
||||
source: source.join(""),
|
||||
editor: editor_view,
|
||||
outputs: convert_outputs(outputs, cx),
|
||||
selected: false,
|
||||
language_task,
|
||||
cell_position: None,
|
||||
}
|
||||
})),
|
||||
nbformat::v4::Cell::Raw {
|
||||
id,
|
||||
metadata,
|
||||
source,
|
||||
} => Cell::Raw(cx.new_view(|_| RawCell {
|
||||
id: id.clone(),
|
||||
metadata: metadata.clone(),
|
||||
source: source.join(""),
|
||||
selected: false,
|
||||
cell_position: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait RenderableCell: Render {
|
||||
const CELL_TYPE: CellType;
|
||||
|
||||
fn id(&self) -> &CellId;
|
||||
fn cell_type(&self) -> CellType;
|
||||
fn metadata(&self) -> &CellMetadata;
|
||||
fn source(&self) -> &String;
|
||||
fn selected(&self) -> bool;
|
||||
fn set_selected(&mut self, selected: bool) -> &mut Self;
|
||||
fn selected_bg_color(&self, cx: &ViewContext<Self>) -> Hsla {
|
||||
if self.selected() {
|
||||
let mut color = cx.theme().colors().icon_accent;
|
||||
color.fade_out(0.9);
|
||||
color
|
||||
} else {
|
||||
// TODO: this is wrong
|
||||
cx.theme().colors().tab_bar_background
|
||||
}
|
||||
}
|
||||
fn control(&self, _cx: &ViewContext<Self>) -> Option<CellControl> {
|
||||
None
|
||||
}
|
||||
|
||||
fn cell_position_spacer(
|
||||
&self,
|
||||
is_first: bool,
|
||||
cx: &ViewContext<Self>,
|
||||
) -> Option<impl IntoElement> {
|
||||
let cell_position = self.cell_position();
|
||||
|
||||
if (cell_position == Some(&CellPosition::First) && is_first)
|
||||
|| (cell_position == Some(&CellPosition::Last) && !is_first)
|
||||
{
|
||||
Some(div().flex().w_full().h(Spacing::XLarge.px(cx)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn gutter(&self, cx: &ViewContext<Self>) -> impl IntoElement {
|
||||
let is_selected = self.selected();
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.h_full()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex()
|
||||
.flex_none()
|
||||
.justify_center()
|
||||
.h_full()
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w(px(1.))
|
||||
.h_full()
|
||||
.when(is_selected, |this| this.bg(cx.theme().colors().icon_accent))
|
||||
.when(!is_selected, |this| this.bg(cx.theme().colors().border)),
|
||||
),
|
||||
)
|
||||
.when_some(self.control(cx), |this, control| {
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(CODE_BLOCK_INSET - 2.0))
|
||||
.left_0()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.h(px(GUTTER_WIDTH + 12.0))
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.bg(cx.theme().colors().tab_bar_background)
|
||||
.child(control.button),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn cell_position(&self) -> Option<&CellPosition>;
|
||||
fn set_cell_position(&mut self, position: CellPosition) -> &mut Self;
|
||||
}
|
||||
|
||||
pub trait RunnableCell: RenderableCell {
|
||||
fn execution_count(&self) -> Option<i32>;
|
||||
fn set_execution_count(&mut self, count: i32) -> &mut Self;
|
||||
fn run(&mut self, cx: &mut ViewContext<Self>) -> ();
|
||||
}
|
||||
|
||||
pub struct MarkdownCell {
|
||||
id: CellId,
|
||||
metadata: CellMetadata,
|
||||
source: String,
|
||||
parsed_markdown: Option<markdown_preview::markdown_elements::ParsedMarkdown>,
|
||||
markdown_parsing_task: Task<()>,
|
||||
selected: bool,
|
||||
cell_position: Option<CellPosition>,
|
||||
languages: Arc<LanguageRegistry>,
|
||||
}
|
||||
|
||||
impl RenderableCell for MarkdownCell {
|
||||
const CELL_TYPE: CellType = CellType::Markdown;
|
||||
|
||||
fn id(&self) -> &CellId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn cell_type(&self) -> CellType {
|
||||
CellType::Markdown
|
||||
}
|
||||
|
||||
fn metadata(&self) -> &CellMetadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
fn source(&self) -> &String {
|
||||
&self.source
|
||||
}
|
||||
|
||||
fn selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
|
||||
fn set_selected(&mut self, selected: bool) -> &mut Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn control(&self, _: &ViewContext<Self>) -> Option<CellControl> {
|
||||
None
|
||||
}
|
||||
|
||||
fn cell_position(&self) -> Option<&CellPosition> {
|
||||
self.cell_position.as_ref()
|
||||
}
|
||||
|
||||
fn set_cell_position(&mut self, cell_position: CellPosition) -> &mut Self {
|
||||
self.cell_position = Some(cell_position);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for MarkdownCell {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let Some(parsed) = self.parsed_markdown.as_ref() else {
|
||||
return div();
|
||||
};
|
||||
|
||||
let mut markdown_render_context =
|
||||
markdown_preview::markdown_renderer::RenderContext::new(None, cx);
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
// TODO: Move base cell render into trait impl so we don't have to repeat this
|
||||
.children(self.cell_position_spacer(true, cx))
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.pr_6()
|
||||
.rounded_sm()
|
||||
.items_start()
|
||||
.gap(Spacing::Large.rems(cx))
|
||||
.bg(self.selected_bg_color(cx))
|
||||
.child(self.gutter(cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.size_full()
|
||||
.flex_1()
|
||||
.p_3()
|
||||
.font_ui(cx)
|
||||
.text_size(TextSize::Default.rems(cx))
|
||||
//
|
||||
.children(parsed.children.iter().map(|child| {
|
||||
div().relative().child(div().relative().child(
|
||||
render_markdown_block(child, &mut markdown_render_context),
|
||||
))
|
||||
})),
|
||||
),
|
||||
)
|
||||
// TODO: Move base cell render into trait impl so we don't have to repeat this
|
||||
.children(self.cell_position_spacer(false, cx))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CodeCell {
|
||||
id: CellId,
|
||||
metadata: CellMetadata,
|
||||
execution_count: Option<i32>,
|
||||
source: String,
|
||||
editor: View<editor::Editor>,
|
||||
outputs: Vec<Output>,
|
||||
selected: bool,
|
||||
cell_position: Option<CellPosition>,
|
||||
language_task: Task<()>,
|
||||
}
|
||||
|
||||
impl CodeCell {
|
||||
pub fn is_dirty(&self, cx: &AppContext) -> bool {
|
||||
self.editor.read(cx).buffer().read(cx).is_dirty(cx)
|
||||
}
|
||||
pub fn has_outputs(&self) -> bool {
|
||||
!self.outputs.is_empty()
|
||||
}
|
||||
|
||||
pub fn clear_outputs(&mut self) {
|
||||
self.outputs.clear();
|
||||
}
|
||||
|
||||
fn output_control(&self) -> Option<CellControlType> {
|
||||
if self.has_outputs() {
|
||||
Some(CellControlType::ClearCell)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gutter_output(&self, cx: &ViewContext<Self>) -> impl IntoElement {
|
||||
let is_selected = self.selected();
|
||||
|
||||
div()
|
||||
.relative()
|
||||
.h_full()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.child(
|
||||
div()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.flex()
|
||||
.flex_none()
|
||||
.justify_center()
|
||||
.h_full()
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w(px(1.))
|
||||
.h_full()
|
||||
.when(is_selected, |this| this.bg(cx.theme().colors().icon_accent))
|
||||
.when(!is_selected, |this| this.bg(cx.theme().colors().border)),
|
||||
),
|
||||
)
|
||||
.when(self.has_outputs(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(CODE_BLOCK_INSET - 2.0))
|
||||
.left_0()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.w(px(GUTTER_WIDTH))
|
||||
.h(px(GUTTER_WIDTH + 12.0))
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.bg(cx.theme().colors().tab_bar_background)
|
||||
.child(IconButton::new("control", IconName::Ellipsis)),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderableCell for CodeCell {
|
||||
const CELL_TYPE: CellType = CellType::Code;
|
||||
|
||||
fn id(&self) -> &CellId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn cell_type(&self) -> CellType {
|
||||
CellType::Code
|
||||
}
|
||||
|
||||
fn metadata(&self) -> &CellMetadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
fn source(&self) -> &String {
|
||||
&self.source
|
||||
}
|
||||
|
||||
fn control(&self, cx: &ViewContext<Self>) -> Option<CellControl> {
|
||||
let cell_control = if self.has_outputs() {
|
||||
CellControl::new("rerun-cell", CellControlType::RerunCell)
|
||||
} else {
|
||||
CellControl::new("run-cell", CellControlType::RunCell)
|
||||
.on_click(cx.listener(move |this, _, cx| this.run(cx)))
|
||||
};
|
||||
|
||||
Some(cell_control)
|
||||
}
|
||||
|
||||
fn selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
|
||||
fn set_selected(&mut self, selected: bool) -> &mut Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn cell_position(&self) -> Option<&CellPosition> {
|
||||
self.cell_position.as_ref()
|
||||
}
|
||||
|
||||
fn set_cell_position(&mut self, cell_position: CellPosition) -> &mut Self {
|
||||
self.cell_position = Some(cell_position);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RunnableCell for CodeCell {
|
||||
fn run(&mut self, cx: &mut ViewContext<Self>) {
|
||||
println!("Running code cell: {}", self.id);
|
||||
}
|
||||
|
||||
fn execution_count(&self) -> Option<i32> {
|
||||
self.execution_count
|
||||
.and_then(|count| if count > 0 { Some(count) } else { None })
|
||||
}
|
||||
|
||||
fn set_execution_count(&mut self, count: i32) -> &mut Self {
|
||||
self.execution_count = Some(count);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CodeCell {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
// TODO: Move base cell render into trait impl so we don't have to repeat this
|
||||
.children(self.cell_position_spacer(true, cx))
|
||||
// Editor portion
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.pr_6()
|
||||
.rounded_sm()
|
||||
.items_start()
|
||||
.gap(Spacing::Large.rems(cx))
|
||||
.bg(self.selected_bg_color(cx))
|
||||
.child(self.gutter(cx))
|
||||
.child(
|
||||
div().py_1p5().w_full().child(
|
||||
div()
|
||||
.flex()
|
||||
.size_full()
|
||||
.flex_1()
|
||||
.py_3()
|
||||
.px_5()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.child(div().w_full().child(self.editor.clone())),
|
||||
),
|
||||
),
|
||||
)
|
||||
// Output portion
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.pr_6()
|
||||
.rounded_sm()
|
||||
.items_start()
|
||||
.gap(Spacing::Large.rems(cx))
|
||||
.bg(self.selected_bg_color(cx))
|
||||
.child(self.gutter_output(cx))
|
||||
.child(
|
||||
div().py_1p5().w_full().child(
|
||||
div()
|
||||
.flex()
|
||||
.size_full()
|
||||
.flex_1()
|
||||
.py_3()
|
||||
.px_5()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
// .border_color(cx.theme().colors().border)
|
||||
// .bg(cx.theme().colors().editor_background)
|
||||
.child(div().w_full().children(self.outputs.iter().map(
|
||||
|output| {
|
||||
let content = match output {
|
||||
Output::Plain { content, .. } => {
|
||||
Some(content.clone().into_any_element())
|
||||
}
|
||||
Output::Markdown { content, .. } => {
|
||||
Some(content.clone().into_any_element())
|
||||
}
|
||||
Output::Stream { content, .. } => {
|
||||
Some(content.clone().into_any_element())
|
||||
}
|
||||
Output::Image { content, .. } => {
|
||||
Some(content.clone().into_any_element())
|
||||
}
|
||||
Output::Message(message) => Some(
|
||||
div().child(message.clone()).into_any_element(),
|
||||
),
|
||||
Output::Table { content, .. } => {
|
||||
Some(content.clone().into_any_element())
|
||||
}
|
||||
Output::ErrorOutput(error_view) => {
|
||||
error_view.render(cx)
|
||||
}
|
||||
Output::ClearOutputWaitMarker => None,
|
||||
};
|
||||
|
||||
div()
|
||||
// .w_full()
|
||||
// .mt_3()
|
||||
// .p_3()
|
||||
// .rounded_md()
|
||||
// .bg(cx.theme().colors().editor_background)
|
||||
// .border(px(1.))
|
||||
// .border_color(cx.theme().colors().border)
|
||||
// .shadow_sm()
|
||||
.children(content)
|
||||
},
|
||||
))),
|
||||
),
|
||||
),
|
||||
)
|
||||
// TODO: Move base cell render into trait impl so we don't have to repeat this
|
||||
.children(self.cell_position_spacer(false, cx))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RawCell {
|
||||
id: CellId,
|
||||
metadata: CellMetadata,
|
||||
source: String,
|
||||
selected: bool,
|
||||
cell_position: Option<CellPosition>,
|
||||
}
|
||||
|
||||
impl RenderableCell for RawCell {
|
||||
const CELL_TYPE: CellType = CellType::Raw;
|
||||
|
||||
fn id(&self) -> &CellId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn cell_type(&self) -> CellType {
|
||||
CellType::Raw
|
||||
}
|
||||
|
||||
fn metadata(&self) -> &CellMetadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
fn source(&self) -> &String {
|
||||
&self.source
|
||||
}
|
||||
|
||||
fn selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
|
||||
fn set_selected(&mut self, selected: bool) -> &mut Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn cell_position(&self) -> Option<&CellPosition> {
|
||||
self.cell_position.as_ref()
|
||||
}
|
||||
|
||||
fn set_cell_position(&mut self, cell_position: CellPosition) -> &mut Self {
|
||||
self.cell_position = Some(cell_position);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RawCell {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
// TODO: Move base cell render into trait impl so we don't have to repeat this
|
||||
.children(self.cell_position_spacer(true, cx))
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.pr_2()
|
||||
.rounded_sm()
|
||||
.items_start()
|
||||
.gap(Spacing::Large.rems(cx))
|
||||
.bg(self.selected_bg_color(cx))
|
||||
.child(self.gutter(cx))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.size_full()
|
||||
.flex_1()
|
||||
.p_3()
|
||||
.font_ui(cx)
|
||||
.text_size(TextSize::Default.rems(cx))
|
||||
.child(self.source.clone()),
|
||||
),
|
||||
)
|
||||
// TODO: Move base cell render into trait impl so we don't have to repeat this
|
||||
.children(self.cell_position_spacer(false, cx))
|
||||
}
|
||||
}
|
||||
672
crates/repl/src/notebook/notebook_ui.rs
Normal file
672
crates/repl/src/notebook/notebook_ui.rs
Normal file
@@ -0,0 +1,672 @@
|
||||
#![allow(unused, dead_code)]
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use client::proto::ViewId;
|
||||
use collections::HashMap;
|
||||
use feature_flags::{FeatureFlagAppExt as _, NotebookFeatureFlag};
|
||||
use futures::FutureExt;
|
||||
use gpui::{
|
||||
actions, list, prelude::*, AppContext, EventEmitter, FocusHandle, FocusableView,
|
||||
ListScrollEvent, ListState, Model, Task,
|
||||
};
|
||||
use language::LanguageRegistry;
|
||||
use project::{Project, ProjectEntryId, ProjectPath};
|
||||
use ui::{prelude::*, Tooltip};
|
||||
use workspace::item::ItemEvent;
|
||||
use workspace::{Item, ItemHandle, ProjectItem, ToolbarItemLocation};
|
||||
use workspace::{ToolbarItemEvent, ToolbarItemView};
|
||||
|
||||
use super::{Cell, CellPosition, RenderableCell};
|
||||
|
||||
use nbformat::v4::CellId;
|
||||
use nbformat::v4::Metadata as NotebookMetadata;
|
||||
|
||||
pub(crate) const DEFAULT_NOTEBOOK_FORMAT: i32 = 4;
|
||||
pub(crate) const DEFAULT_NOTEBOOK_FORMAT_MINOR: i32 = 0;
|
||||
|
||||
actions!(
|
||||
notebook,
|
||||
[
|
||||
OpenNotebook,
|
||||
RunAll,
|
||||
ClearOutputs,
|
||||
MoveCellUp,
|
||||
MoveCellDown,
|
||||
AddMarkdownBlock,
|
||||
AddCodeBlock,
|
||||
]
|
||||
);
|
||||
|
||||
pub(crate) const MAX_TEXT_BLOCK_WIDTH: f32 = 9999.0;
|
||||
pub(crate) const SMALL_SPACING_SIZE: f32 = 8.0;
|
||||
pub(crate) const MEDIUM_SPACING_SIZE: f32 = 12.0;
|
||||
pub(crate) const LARGE_SPACING_SIZE: f32 = 16.0;
|
||||
pub(crate) const GUTTER_WIDTH: f32 = 19.0;
|
||||
pub(crate) const CODE_BLOCK_INSET: f32 = MEDIUM_SPACING_SIZE;
|
||||
pub(crate) const CONTROL_SIZE: f32 = 20.0;
|
||||
|
||||
pub fn init(cx: &mut AppContext) {
|
||||
if cx.has_flag::<NotebookFeatureFlag>() || std::env::var("LOCAL_NOTEBOOK_DEV").is_ok() {
|
||||
workspace::register_project_item::<NotebookEditor>(cx);
|
||||
}
|
||||
|
||||
cx.observe_flag::<NotebookFeatureFlag, _>({
|
||||
move |is_enabled, cx| {
|
||||
if is_enabled {
|
||||
workspace::register_project_item::<NotebookEditor>(cx);
|
||||
} else {
|
||||
// todo: there is no way to unregister a project item, so if the feature flag
|
||||
// gets turned off they need to restart Zed.
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub struct NotebookEditor {
|
||||
languages: Arc<LanguageRegistry>,
|
||||
|
||||
focus_handle: FocusHandle,
|
||||
project: Model<Project>,
|
||||
path: ProjectPath,
|
||||
|
||||
remote_id: Option<ViewId>,
|
||||
cell_list: ListState,
|
||||
|
||||
metadata: NotebookMetadata,
|
||||
nbformat: i32,
|
||||
nbformat_minor: i32,
|
||||
selected_cell_index: usize,
|
||||
cell_order: Vec<CellId>,
|
||||
cell_map: HashMap<CellId, Cell>,
|
||||
}
|
||||
|
||||
impl NotebookEditor {
|
||||
pub fn new(
|
||||
project: Model<Project>,
|
||||
notebook_item: Model<NotebookItem>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
|
||||
let notebook = notebook_item.read(cx).notebook.clone();
|
||||
|
||||
let languages = project.read(cx).languages().clone();
|
||||
|
||||
let metadata = notebook.metadata;
|
||||
let nbformat = notebook.nbformat;
|
||||
let nbformat_minor = notebook.nbformat_minor;
|
||||
|
||||
let language_name = metadata
|
||||
.language_info
|
||||
.as_ref()
|
||||
.map(|l| l.name.clone())
|
||||
.or(metadata
|
||||
.kernelspec
|
||||
.as_ref()
|
||||
.and_then(|spec| spec.language.clone()));
|
||||
|
||||
let notebook_language = if let Some(language_name) = language_name {
|
||||
cx.spawn(|_, _| {
|
||||
let languages = languages.clone();
|
||||
async move { languages.language_for_name(&language_name).await.ok() }
|
||||
})
|
||||
.shared()
|
||||
} else {
|
||||
Task::ready(None).shared()
|
||||
};
|
||||
|
||||
let languages = project.read(cx).languages().clone();
|
||||
let notebook_language = cx
|
||||
.spawn(|_, _| {
|
||||
// todo: pull from notebook metadata
|
||||
const TODO: &'static str = "Python";
|
||||
let languages = languages.clone();
|
||||
async move { languages.language_for_name(TODO).await.ok() }
|
||||
})
|
||||
.shared();
|
||||
|
||||
let mut cell_order = vec![];
|
||||
let mut cell_map = HashMap::default();
|
||||
|
||||
for (index, cell) in notebook.cells.iter().enumerate() {
|
||||
let cell_id = cell.id();
|
||||
cell_order.push(cell_id.clone());
|
||||
cell_map.insert(
|
||||
cell_id.clone(),
|
||||
Cell::load(cell, &languages, notebook_language.clone(), cx),
|
||||
);
|
||||
}
|
||||
|
||||
let view = cx.view().downgrade();
|
||||
let cell_count = cell_order.len();
|
||||
let cell_order_for_list = cell_order.clone();
|
||||
let cell_map_for_list = cell_map.clone();
|
||||
|
||||
let cell_list = ListState::new(
|
||||
cell_count,
|
||||
gpui::ListAlignment::Top,
|
||||
// TODO: This is a totally random number,
|
||||
// not sure what this should be
|
||||
px(3000.),
|
||||
move |ix, cx| {
|
||||
let cell_order_for_list = cell_order_for_list.clone();
|
||||
let cell_id = cell_order_for_list[ix].clone();
|
||||
if let Some(view) = view.upgrade() {
|
||||
let cell_id = cell_id.clone();
|
||||
if let Some(cell) = cell_map_for_list.clone().get(&cell_id) {
|
||||
view.update(cx, |view, cx| {
|
||||
view.render_cell(ix, cell, cx).into_any_element()
|
||||
})
|
||||
} else {
|
||||
div().into_any()
|
||||
}
|
||||
} else {
|
||||
div().into_any()
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Self {
|
||||
languages: languages.clone(),
|
||||
focus_handle,
|
||||
project,
|
||||
path: notebook_item.read(cx).project_path.clone(),
|
||||
remote_id: None,
|
||||
cell_list,
|
||||
selected_cell_index: 0,
|
||||
metadata,
|
||||
nbformat,
|
||||
nbformat_minor,
|
||||
cell_order: cell_order.clone(),
|
||||
cell_map: cell_map.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_outputs(&self, cx: &ViewContext<Self>) -> bool {
|
||||
self.cell_map.values().any(|cell| {
|
||||
if let Cell::Code(code_cell) = cell {
|
||||
code_cell.read(cx).has_outputs()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn is_dirty(&self, cx: &AppContext) -> bool {
|
||||
self.cell_map.values().any(|cell| {
|
||||
if let Cell::Code(code_cell) = cell {
|
||||
code_cell.read(cx).is_dirty(cx)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn clear_outputs(&mut self, cx: &mut ViewContext<Self>) {
|
||||
for cell in self.cell_map.values() {
|
||||
if let Cell::Code(code_cell) = cell {
|
||||
code_cell.update(cx, |cell, _cx| {
|
||||
cell.clear_outputs();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_cells(&mut self, cx: &mut ViewContext<Self>) {
|
||||
println!("Cells would all run here, if that was implemented!");
|
||||
}
|
||||
|
||||
fn open_notebook(&mut self, _: &OpenNotebook, _cx: &mut ViewContext<Self>) {
|
||||
println!("Open notebook triggered");
|
||||
}
|
||||
|
||||
fn move_cell_up(&mut self, cx: &mut ViewContext<Self>) {
|
||||
println!("Move cell up triggered");
|
||||
}
|
||||
|
||||
fn move_cell_down(&mut self, cx: &mut ViewContext<Self>) {
|
||||
println!("Move cell down triggered");
|
||||
}
|
||||
|
||||
fn add_markdown_block(&mut self, cx: &mut ViewContext<Self>) {
|
||||
println!("Add markdown block triggered");
|
||||
}
|
||||
|
||||
fn add_code_block(&mut self, cx: &mut ViewContext<Self>) {
|
||||
println!("Add code block triggered");
|
||||
}
|
||||
|
||||
fn cell_count(&self) -> usize {
|
||||
self.cell_map.len()
|
||||
}
|
||||
|
||||
fn selected_index(&self) -> usize {
|
||||
self.selected_cell_index
|
||||
}
|
||||
|
||||
pub fn set_selected_index(
|
||||
&mut self,
|
||||
index: usize,
|
||||
jump_to_index: bool,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// let previous_index = self.selected_cell_index;
|
||||
self.selected_cell_index = index;
|
||||
let current_index = self.selected_cell_index;
|
||||
|
||||
// in the future we may have some `on_cell_change` event that we want to fire here
|
||||
|
||||
if jump_to_index {
|
||||
self.jump_to_cell(current_index, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_next(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
|
||||
let count = self.cell_count();
|
||||
if count > 0 {
|
||||
let index = self.selected_index();
|
||||
let ix = if index == count - 1 {
|
||||
count - 1
|
||||
} else {
|
||||
index + 1
|
||||
};
|
||||
self.set_selected_index(ix, true, cx);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_previous(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
|
||||
let count = self.cell_count();
|
||||
if count > 0 {
|
||||
let index = self.selected_index();
|
||||
let ix = if index == 0 { 0 } else { index - 1 };
|
||||
self.set_selected_index(ix, true, cx);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_first(&mut self, _: &menu::SelectFirst, cx: &mut ViewContext<Self>) {
|
||||
let count = self.cell_count();
|
||||
if count > 0 {
|
||||
self.set_selected_index(0, true, cx);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_last(&mut self, _: &menu::SelectLast, cx: &mut ViewContext<Self>) {
|
||||
let count = self.cell_count();
|
||||
if count > 0 {
|
||||
self.set_selected_index(count - 1, true, cx);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn jump_to_cell(&mut self, index: usize, _cx: &mut ViewContext<Self>) {
|
||||
self.cell_list.scroll_to_reveal_item(index);
|
||||
}
|
||||
|
||||
fn button_group(cx: &ViewContext<Self>) -> Div {
|
||||
v_flex()
|
||||
.gap(Spacing::Small.rems(cx))
|
||||
.items_center()
|
||||
.w(px(CONTROL_SIZE + 4.0))
|
||||
.overflow_hidden()
|
||||
.rounded(px(5.))
|
||||
.bg(cx.theme().colors().title_bar_background)
|
||||
.p_px()
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
}
|
||||
|
||||
fn render_notebook_control(
|
||||
id: impl Into<SharedString>,
|
||||
icon: IconName,
|
||||
_cx: &ViewContext<Self>,
|
||||
) -> IconButton {
|
||||
let id: ElementId = ElementId::Name(id.into());
|
||||
IconButton::new(id, icon).width(px(CONTROL_SIZE).into())
|
||||
}
|
||||
|
||||
fn render_notebook_controls(&self, cx: &ViewContext<Self>) -> impl IntoElement {
|
||||
let has_outputs = self.has_outputs(cx);
|
||||
|
||||
v_flex()
|
||||
.max_w(px(CONTROL_SIZE + 4.0))
|
||||
.items_center()
|
||||
.gap(Spacing::XXLarge.rems(cx))
|
||||
.justify_between()
|
||||
.flex_none()
|
||||
.h_full()
|
||||
.py(Spacing::XLarge.px(cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap(Spacing::Large.rems(cx))
|
||||
.child(
|
||||
Self::button_group(cx)
|
||||
.child(
|
||||
Self::render_notebook_control("run-all-cells", IconName::Play, cx)
|
||||
.tooltip(move |cx| {
|
||||
Tooltip::for_action("Execute all cells", &RunAll, cx)
|
||||
})
|
||||
.on_click(|_, cx| {
|
||||
cx.dispatch_action(Box::new(RunAll));
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Self::render_notebook_control(
|
||||
"clear-all-outputs",
|
||||
IconName::ListX,
|
||||
cx,
|
||||
)
|
||||
.disabled(!has_outputs)
|
||||
.tooltip(move |cx| {
|
||||
Tooltip::for_action("Clear all outputs", &ClearOutputs, cx)
|
||||
})
|
||||
.on_click(|_, cx| {
|
||||
cx.dispatch_action(Box::new(ClearOutputs));
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Self::button_group(cx)
|
||||
.child(
|
||||
Self::render_notebook_control(
|
||||
"move-cell-up",
|
||||
IconName::ArrowUp,
|
||||
cx,
|
||||
)
|
||||
.tooltip(move |cx| {
|
||||
Tooltip::for_action("Move cell up", &MoveCellUp, cx)
|
||||
})
|
||||
.on_click(|_, cx| {
|
||||
cx.dispatch_action(Box::new(MoveCellUp));
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Self::render_notebook_control(
|
||||
"move-cell-down",
|
||||
IconName::ArrowDown,
|
||||
cx,
|
||||
)
|
||||
.tooltip(move |cx| {
|
||||
Tooltip::for_action("Move cell down", &MoveCellDown, cx)
|
||||
})
|
||||
.on_click(|_, cx| {
|
||||
cx.dispatch_action(Box::new(MoveCellDown));
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Self::button_group(cx)
|
||||
.child(
|
||||
Self::render_notebook_control(
|
||||
"new-markdown-cell",
|
||||
IconName::Plus,
|
||||
cx,
|
||||
)
|
||||
.tooltip(move |cx| {
|
||||
Tooltip::for_action("Add markdown block", &AddMarkdownBlock, cx)
|
||||
})
|
||||
.on_click(|_, cx| {
|
||||
cx.dispatch_action(Box::new(AddMarkdownBlock));
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Self::render_notebook_control("new-code-cell", IconName::Code, cx)
|
||||
.tooltip(move |cx| {
|
||||
Tooltip::for_action("Add code block", &AddCodeBlock, cx)
|
||||
})
|
||||
.on_click(|_, cx| {
|
||||
cx.dispatch_action(Box::new(AddCodeBlock));
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap(Spacing::Large.rems(cx))
|
||||
.items_center()
|
||||
.child(Self::render_notebook_control(
|
||||
"more-menu",
|
||||
IconName::Ellipsis,
|
||||
cx,
|
||||
))
|
||||
.child(
|
||||
Self::button_group(cx)
|
||||
.child(IconButton::new("repl", IconName::ReplNeutral)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn cell_position(&self, index: usize) -> CellPosition {
|
||||
match index {
|
||||
0 => CellPosition::First,
|
||||
index if index == self.cell_count() - 1 => CellPosition::Last,
|
||||
_ => CellPosition::Middle,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_cell(
|
||||
&self,
|
||||
index: usize,
|
||||
cell: &Cell,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> impl IntoElement {
|
||||
let cell_position = self.cell_position(index);
|
||||
|
||||
let is_selected = index == self.selected_cell_index;
|
||||
|
||||
match cell {
|
||||
Cell::Code(cell) => {
|
||||
cell.update(cx, |cell, _cx| {
|
||||
cell.set_selected(is_selected)
|
||||
.set_cell_position(cell_position);
|
||||
});
|
||||
cell.clone().into_any_element()
|
||||
}
|
||||
Cell::Markdown(cell) => {
|
||||
cell.update(cx, |cell, _cx| {
|
||||
cell.set_selected(is_selected)
|
||||
.set_cell_position(cell_position);
|
||||
});
|
||||
cell.clone().into_any_element()
|
||||
}
|
||||
Cell::Raw(cell) => {
|
||||
cell.update(cx, |cell, _cx| {
|
||||
cell.set_selected(is_selected)
|
||||
.set_cell_position(cell_position);
|
||||
});
|
||||
cell.clone().into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NotebookEditor {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.key_context("notebook")
|
||||
.track_focus(&self.focus_handle)
|
||||
.on_action(cx.listener(|this, &OpenNotebook, cx| this.open_notebook(&OpenNotebook, cx)))
|
||||
.on_action(cx.listener(|this, &ClearOutputs, cx| this.clear_outputs(cx)))
|
||||
.on_action(cx.listener(|this, &RunAll, cx| this.run_cells(cx)))
|
||||
.on_action(cx.listener(|this, &MoveCellUp, cx| this.move_cell_up(cx)))
|
||||
.on_action(cx.listener(|this, &MoveCellDown, cx| this.move_cell_down(cx)))
|
||||
.on_action(cx.listener(|this, &AddMarkdownBlock, cx| this.add_markdown_block(cx)))
|
||||
.on_action(cx.listener(|this, &AddCodeBlock, cx| this.add_code_block(cx)))
|
||||
.on_action(cx.listener(Self::select_next))
|
||||
.on_action(cx.listener(Self::select_previous))
|
||||
.on_action(cx.listener(Self::select_first))
|
||||
.on_action(cx.listener(Self::select_last))
|
||||
.flex()
|
||||
.items_start()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.px(Spacing::XLarge.px(cx))
|
||||
.gap(Spacing::XLarge.px(cx))
|
||||
.bg(cx.theme().colors().tab_bar_background)
|
||||
.child(
|
||||
v_flex()
|
||||
.id("notebook-cells")
|
||||
.flex_1()
|
||||
.size_full()
|
||||
.overflow_y_scroll()
|
||||
.child(list(self.cell_list.clone()).size_full()),
|
||||
)
|
||||
.child(self.render_notebook_controls(cx))
|
||||
}
|
||||
}
|
||||
|
||||
impl FocusableView for NotebookEditor {
|
||||
fn focus_handle(&self, _: &AppContext) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NotebookItem {
|
||||
path: PathBuf,
|
||||
project_path: ProjectPath,
|
||||
notebook: nbformat::v4::Notebook,
|
||||
}
|
||||
|
||||
impl project::Item for NotebookItem {
|
||||
fn try_open(
|
||||
project: &Model<Project>,
|
||||
path: &ProjectPath,
|
||||
cx: &mut AppContext,
|
||||
) -> Option<Task<gpui::Result<Model<Self>>>> {
|
||||
let path = path.clone();
|
||||
let project = project.clone();
|
||||
|
||||
if path.path.extension().unwrap_or_default() == "ipynb" {
|
||||
Some(cx.spawn(|mut cx| async move {
|
||||
let abs_path = project
|
||||
.read_with(&cx, |project, cx| project.absolute_path(&path, cx))?
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to find the absolute path"))?;
|
||||
|
||||
let file_content = std::fs::read_to_string(abs_path.clone())?;
|
||||
let notebook = nbformat::parse_notebook(&file_content);
|
||||
|
||||
let notebook = match notebook {
|
||||
Ok(nbformat::Notebook::V4(notebook)) => notebook,
|
||||
Ok(nbformat::Notebook::Legacy(legacy_notebook)) => {
|
||||
// todo!(): Decide if we want to mutate the notebook by including Cell IDs
|
||||
// and any other conversions
|
||||
let notebook = nbformat::upgrade_legacy_notebook(legacy_notebook)?;
|
||||
notebook
|
||||
}
|
||||
Err(e) => {
|
||||
anyhow::bail!("Failed to parse notebook: {:?}", e);
|
||||
}
|
||||
};
|
||||
|
||||
cx.new_model(|_| NotebookItem {
|
||||
path: abs_path,
|
||||
project_path: path,
|
||||
notebook,
|
||||
})
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
|
||||
Some(self.project_path.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<()> for NotebookEditor {}
|
||||
|
||||
// pub struct NotebookControls {
|
||||
// pane_focused: bool,
|
||||
// active_item: Option<Box<dyn ItemHandle>>,
|
||||
// // subscription: Option<Subscription>,
|
||||
// }
|
||||
|
||||
// impl NotebookControls {
|
||||
// pub fn new() -> Self {
|
||||
// Self {
|
||||
// pane_focused: false,
|
||||
// active_item: Default::default(),
|
||||
// // subscription: Default::default(),
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// impl EventEmitter<ToolbarItemEvent> for NotebookControls {}
|
||||
|
||||
// impl Render for NotebookControls {
|
||||
// fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
// div().child("notebook controls")
|
||||
// }
|
||||
// }
|
||||
|
||||
// impl ToolbarItemView for NotebookControls {
|
||||
// fn set_active_pane_item(
|
||||
// &mut self,
|
||||
// active_pane_item: Option<&dyn workspace::ItemHandle>,
|
||||
// cx: &mut ViewContext<Self>,
|
||||
// ) -> workspace::ToolbarItemLocation {
|
||||
// cx.notify();
|
||||
// self.active_item = None;
|
||||
|
||||
// let Some(item) = active_pane_item else {
|
||||
// return ToolbarItemLocation::Hidden;
|
||||
// };
|
||||
|
||||
// ToolbarItemLocation::PrimaryLeft
|
||||
// }
|
||||
|
||||
// fn pane_focus_update(&mut self, pane_focused: bool, _: &mut ViewContext<Self>) {
|
||||
// self.pane_focused = pane_focused;
|
||||
// }
|
||||
// }
|
||||
|
||||
impl Item for NotebookEditor {
|
||||
type Event = ();
|
||||
|
||||
fn tab_content_text(&self, _cx: &WindowContext) -> Option<SharedString> {
|
||||
let path = self.path.path.clone();
|
||||
|
||||
path.file_stem()
|
||||
.map(|stem| stem.to_string_lossy().into_owned())
|
||||
.map(SharedString::from)
|
||||
}
|
||||
|
||||
fn tab_icon(&self, _cx: &ui::WindowContext) -> Option<Icon> {
|
||||
Some(IconName::Book.into())
|
||||
}
|
||||
|
||||
fn show_toolbar(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_dirty(&self, cx: &AppContext) -> bool {
|
||||
// self.is_dirty(cx)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement this to allow us to persist to the database, etc:
|
||||
// impl SerializableItem for NotebookEditor {}
|
||||
|
||||
impl ProjectItem for NotebookEditor {
|
||||
type Item = NotebookItem;
|
||||
|
||||
fn for_project_item(
|
||||
project: Model<Project>,
|
||||
item: Model<Self::Item>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
Self::new(project, item, cx)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user