Compare commits
105 Commits
v0.131.6
...
rust-analy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bc1a41fd4 | ||
|
|
575eb792fb | ||
|
|
4f776f9ebe | ||
|
|
0c77e1ce45 | ||
|
|
904b740e16 | ||
|
|
f2fc84ab44 | ||
|
|
fda3c91f16 | ||
|
|
3eb8464d19 | ||
|
|
58f57491b1 | ||
|
|
3e44e97177 | ||
|
|
fda21232ae | ||
|
|
57a736d74a | ||
|
|
015e2ecd19 | ||
|
|
5037f466f6 | ||
|
|
f28fde5e58 | ||
|
|
d1928f084e | ||
|
|
ad22bddffa | ||
|
|
da0d968a2c | ||
|
|
200e36311c | ||
|
|
db48c75231 | ||
|
|
1911a9f39b | ||
|
|
faebce8cd0 | ||
|
|
573ba83034 | ||
|
|
97c5cffbe3 | ||
|
|
556ecd94c2 | ||
|
|
3289188e0a | ||
|
|
5e4f707951 | ||
|
|
5d7642d77d | ||
|
|
e64ecdc9ab | ||
|
|
ba9c5929af | ||
|
|
ad8dd1771a | ||
|
|
cb6d0639db | ||
|
|
065f15e9a6 | ||
|
|
104558115f | ||
|
|
4e6f24a841 | ||
|
|
f3a78f613a | ||
|
|
8bca9cea26 | ||
|
|
28586060a1 | ||
|
|
49371b44cb | ||
|
|
4b40e83b8b | ||
|
|
dffddaec4c | ||
|
|
a4d6c5da7c | ||
|
|
3ea17248c8 | ||
|
|
e0e1103228 | ||
|
|
65c9e7d3d1 | ||
|
|
b5b872656b | ||
|
|
f4d9a97195 | ||
|
|
7b01a29f5a | ||
|
|
04e89c4c51 | ||
|
|
0ab5a524b0 | ||
|
|
cd5ddfe34b | ||
|
|
0a4c3488dd | ||
|
|
a1cbc23fee | ||
|
|
298e9c9387 | ||
|
|
6e1ba7e936 | ||
|
|
bc0c2e0cae | ||
|
|
29a50573a9 | ||
|
|
08786fa7bf | ||
|
|
f2d61f3ea5 | ||
|
|
98533079e4 | ||
|
|
27ba165046 | ||
|
|
32806b8320 | ||
|
|
3ab9700155 | ||
|
|
9d96ae6e78 | ||
|
|
8d7f5eab79 | ||
|
|
f6c85b28d5 | ||
|
|
ea4419076e | ||
|
|
edb1ea2433 | ||
|
|
86aa352ad9 | ||
|
|
253aa28375 | ||
|
|
165d6b9edb | ||
|
|
0ac31302d3 | ||
|
|
176f440158 | ||
|
|
c6028f6651 | ||
|
|
c38f72d194 | ||
|
|
47f698d5a3 | ||
|
|
bcd2ca6196 | ||
|
|
78d6beee80 | ||
|
|
2d21f6debf | ||
|
|
837b7111b3 | ||
|
|
ea165e134d | ||
|
|
15758c10bf | ||
|
|
2f616fe8eb | ||
|
|
fef0516f5b | ||
|
|
eb6f7c1240 | ||
|
|
36a87d0f5c | ||
|
|
43c115a747 | ||
|
|
859c5279c4 | ||
|
|
13c14d9b96 | ||
|
|
3b68665277 | ||
|
|
339b29ef17 | ||
|
|
d1ad96782c | ||
|
|
b0eda77d73 | ||
|
|
fd3ee5a9d0 | ||
|
|
8cbdd9e0fa | ||
|
|
322f68f3d6 | ||
|
|
195f9d9b24 | ||
|
|
3a6e0bb9b6 | ||
|
|
fdddbfc179 | ||
|
|
3648d79ddb | ||
|
|
081e9b9a60 | ||
|
|
3cf93dfcf6 | ||
|
|
53d0cc6146 | ||
|
|
03d853d344 | ||
|
|
d03f1c4cab |
8
.github/workflows/danger.yml
vendored
@@ -32,4 +32,10 @@ jobs:
|
||||
- name: Run Danger
|
||||
run: pnpm run --dir script/danger danger ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# This GitHub token is not used, but the value needs to be here to prevent
|
||||
# Danger from throwing an error.
|
||||
GITHUB_TOKEN: "not_a_real_token"
|
||||
# All requests are instead proxied through an instance of
|
||||
# https://github.com/maxdeviant/danger-proxy that allows Danger to securely
|
||||
# authenticate with GitHub while still being able to run on PRs from forks.
|
||||
DANGER_GITHUB_API_BASE_URL: "https://danger-proxy.fly.dev/github"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{
|
||||
"label": "clippy",
|
||||
"command": "cargo",
|
||||
"args": ["xtask", "clippy"]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
Thanks for your interest in contributing to Zed, the collaborative platform that is also a code editor!
|
||||
|
||||
We want to avoid anyone spending time on a pull request that may not be accepted, so we suggest you discuss your ideas with the team and community before starting on major changes. Bug fixes, however, are almost always welcome.
|
||||
|
||||
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.
|
||||
|
||||
## Contribution ideas
|
||||
@@ -13,7 +11,7 @@ If you're looking for ideas about what to work on, check out:
|
||||
- Our [public roadmap](https://zed.dev/roadmap) contains a rough outline of our near-term priorities for Zed.
|
||||
- Our [top-ranking issues](https://github.com/zed-industries/zed/issues/5393) based on votes by the community.
|
||||
|
||||
Outside of a handful of extremely popular languages and themes, we are generally not looking to extend Zed's language or theme support by directly building them into Zed. We really want to build a plugin system to handle making the editor extensible going forward. If you are passionate about shipping new languages or themes we suggest contributing to the extension system to help us get there faster.
|
||||
For adding themes or support for a new language to Zed, check out our [extension docs](https://github.com/zed-industries/extensions/blob/main/AUTHORING_EXTENSIONS.md).
|
||||
|
||||
## Proposing changes
|
||||
|
||||
|
||||
701
Cargo.lock
generated
29
Cargo.toml
@@ -38,6 +38,7 @@ members = [
|
||||
"crates/google_ai",
|
||||
"crates/gpui",
|
||||
"crates/gpui_macros",
|
||||
"crates/headless",
|
||||
"crates/image_viewer",
|
||||
"crates/install_cli",
|
||||
"crates/journal",
|
||||
@@ -72,6 +73,7 @@ members = [
|
||||
"crates/task",
|
||||
"crates/tasks_ui",
|
||||
"crates/search",
|
||||
"crates/semantic_index",
|
||||
"crates/semantic_version",
|
||||
"crates/settings",
|
||||
"crates/snippet",
|
||||
@@ -90,6 +92,7 @@ members = [
|
||||
"crates/telemetry_events",
|
||||
"crates/time_format",
|
||||
"crates/ui",
|
||||
"crates/ui_text_field",
|
||||
"crates/util",
|
||||
"crates/vcs_menu",
|
||||
"crates/vim",
|
||||
@@ -103,17 +106,22 @@ members = [
|
||||
"extensions/clojure",
|
||||
"extensions/csharp",
|
||||
"extensions/dart",
|
||||
"extensions/elm",
|
||||
"extensions/emmet",
|
||||
"extensions/erlang",
|
||||
"extensions/gleam",
|
||||
"extensions/haskell",
|
||||
"extensions/html",
|
||||
"extensions/lua",
|
||||
"extensions/ocaml",
|
||||
"extensions/php",
|
||||
"extensions/prisma",
|
||||
"extensions/purescript",
|
||||
"extensions/svelte",
|
||||
"extensions/terraform",
|
||||
"extensions/toml",
|
||||
"extensions/uiua",
|
||||
"extensions/vue",
|
||||
"extensions/zig",
|
||||
|
||||
"tooling/xtask",
|
||||
@@ -161,6 +169,7 @@ go_to_line = { path = "crates/go_to_line" }
|
||||
google_ai = { path = "crates/google_ai" }
|
||||
gpui = { path = "crates/gpui" }
|
||||
gpui_macros = { path = "crates/gpui_macros" }
|
||||
headless = { path = "crates/headless" }
|
||||
install_cli = { path = "crates/install_cli" }
|
||||
image_viewer = { path = "crates/image_viewer" }
|
||||
journal = { path = "crates/journal" }
|
||||
@@ -214,6 +223,7 @@ theme_selector = { path = "crates/theme_selector" }
|
||||
telemetry_events = { path = "crates/telemetry_events" }
|
||||
time_format = { path = "crates/time_format" }
|
||||
ui = { path = "crates/ui" }
|
||||
ui_text_field = { path = "crates/ui_text_field" }
|
||||
util = { path = "crates/util" }
|
||||
vcs_menu = { path = "crates/vcs_menu" }
|
||||
vim = { path = "crates/vim" }
|
||||
@@ -238,15 +248,18 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
clickhouse = { version = "0.11.6" }
|
||||
ctor = "0.2.6"
|
||||
ctrlc = "3.4.4"
|
||||
core-foundation = { version = "0.9.3" }
|
||||
core-foundation-sys = "0.8.6"
|
||||
derive_more = "0.99.17"
|
||||
emojis = "0.6.1"
|
||||
env_logger = "0.9"
|
||||
futures = "0.3"
|
||||
futures-batch = "0.6.1"
|
||||
futures-lite = "1.13"
|
||||
git2 = { version = "0.15", default-features = false }
|
||||
git2 = { version = "0.18", default-features = false }
|
||||
globset = "0.4"
|
||||
heed = { git = "https://github.com/meilisearch/heed", rev = "036ac23f73a021894974b9adc815bc95b3e0482a", features = ["read-txn-no-tls"] }
|
||||
hex = "0.4.3"
|
||||
ignore = "0.4.22"
|
||||
indoc = "1"
|
||||
@@ -285,6 +298,7 @@ serde_json_lenient = { version = "0.1", features = [
|
||||
] }
|
||||
serde_repr = "0.1"
|
||||
sha2 = "0.10"
|
||||
shlex = "1.3"
|
||||
shellexpand = "2.1.0"
|
||||
smallvec = { version = "1.6", features = ["union"] }
|
||||
smol = "1.2"
|
||||
@@ -310,37 +324,28 @@ tree-sitter-c = "0.20.1"
|
||||
tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "f44509141e7e483323d2ec178f2d2e6c0fc041c1" }
|
||||
tree-sitter-css = { git = "https://github.com/tree-sitter/tree-sitter-css", rev = "769203d0f9abe1a9a691ac2b9fe4bb4397a73c51" }
|
||||
tree-sitter-elixir = { git = "https://github.com/elixir-lang/tree-sitter-elixir", rev = "a2861e88a730287a60c11ea9299c033c7d076e30" }
|
||||
tree-sitter-elm = { git = "https://github.com/elm-tooling/tree-sitter-elm", rev = "692c50c0b961364c40299e73c1306aecb5d20f40" }
|
||||
tree-sitter-embedded-template = "0.20.0"
|
||||
tree-sitter-glsl = { git = "https://github.com/theHamsta/tree-sitter-glsl", rev = "2a56fb7bc8bb03a1892b4741279dd0a8758b7fb3" }
|
||||
tree-sitter-go = { git = "https://github.com/tree-sitter/tree-sitter-go", rev = "aeb2f33b366fd78d5789ff104956ce23508b85db" }
|
||||
tree-sitter-gomod = { git = "https://github.com/camdencheek/tree-sitter-go-mod" }
|
||||
tree-sitter-gowork = { git = "https://github.com/d1y/tree-sitter-go-work" }
|
||||
tree-sitter-hcl = { git = "https://github.com/MichaHoffmann/tree-sitter-hcl", rev = "v1.1.0" }
|
||||
rustc-demangle = "0.1.23"
|
||||
tree-sitter-heex = { git = "https://github.com/phoenixframework/tree-sitter-heex", rev = "2e1348c3cf2c9323e87c2744796cf3f3868aa82a" }
|
||||
tree-sitter-html = "0.19.0"
|
||||
tree-sitter-jsdoc = { git = "https://github.com/tree-sitter/tree-sitter-jsdoc", ref = "6a6cf9e7341af32d8e2b2e24a37fbfebefc3dc55" }
|
||||
tree-sitter-json = { git = "https://github.com/tree-sitter/tree-sitter-json", rev = "40a81c01a40ac48744e0c8ccabbaba1920441199" }
|
||||
tree-sitter-lua = "0.0.14"
|
||||
tree-sitter-markdown = { git = "https://github.com/MDeiml/tree-sitter-markdown", rev = "330ecab87a3e3a7211ac69bbadc19eabecdb1cca" }
|
||||
tree-sitter-nix = { git = "https://github.com/nix-community/tree-sitter-nix", rev = "66e3e9ce9180ae08fc57372061006ef83f0abde7" }
|
||||
tree-sitter-nu = { git = "https://github.com/nushell/tree-sitter-nu", rev = "7dd29f9616822e5fc259f5b4ae6c4ded9a71a132" }
|
||||
tree-sitter-ocaml = { git = "https://github.com/tree-sitter/tree-sitter-ocaml", rev = "4abfdc1c7af2c6c77a370aee974627be1c285b3b" }
|
||||
tree-sitter-proto = { git = "https://github.com/rewinfrey/tree-sitter-proto", rev = "36d54f288aee112f13a67b550ad32634d0c2cb52" }
|
||||
tree-sitter-python = "0.20.2"
|
||||
tree-sitter-racket = { git = "https://github.com/zed-industries/tree-sitter-racket", rev = "eb010cf2c674c6fd9a6316a84e28ef90190fe51a" }
|
||||
tree-sitter-regex = "0.20.0"
|
||||
tree-sitter-ruby = "0.20.0"
|
||||
tree-sitter-rust = "0.20.3"
|
||||
tree-sitter-scheme = { git = "https://github.com/6cdh/tree-sitter-scheme", rev = "af0fd1fa452cb2562dc7b5c8a8c55551c39273b9" }
|
||||
tree-sitter-typescript = { git = "https://github.com/tree-sitter/tree-sitter-typescript", rev = "5d20856f34315b068c41edaee2ac8a100081d259" }
|
||||
tree-sitter-vue = { git = "https://github.com/zed-industries/tree-sitter-vue", rev = "6608d9d60c386f19d80af7d8132322fa11199c42" }
|
||||
tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "f545a41f57502e1b5ddf2a6668896c1b0620f930" }
|
||||
unindent = "0.1.7"
|
||||
unicase = "2.6"
|
||||
unicode-segmentation = "1.10"
|
||||
url = "2.2"
|
||||
uuid = { version = "1.1.2", features = ["v4"] }
|
||||
uuid = { version = "1.1.2", features = ["v4", "v5"] }
|
||||
wasmparser = "0.201"
|
||||
wasm-encoder = "0.201"
|
||||
wasmtime = { version = "19.0.0", default-features = false, features = [
|
||||
|
||||
2
Procfile
@@ -1,3 +1,3 @@
|
||||
collab: RUST_LOG=${RUST_LOG:-warn,tower_http=info,collab=info} cargo run --package=collab serve
|
||||
collab: RUST_LOG=${RUST_LOG:-info} cargo run --package=collab serve
|
||||
livekit: livekit-server --dev
|
||||
blob_store: ./script/run-local-minio
|
||||
|
||||
@@ -17,7 +17,7 @@ Support for additional platforms is on our [roadmap](https://zed.dev/roadmap):
|
||||
For macOS users, you can also install Zed using [Homebrew](https://brew.sh/):
|
||||
|
||||
```sh
|
||||
brew install zed
|
||||
brew install --cask zed
|
||||
```
|
||||
|
||||
Alternatively, to install the Preview release:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 10L21 7L17 3L14 6M18 10L8 20H4V16L14 6M18 10L14 6" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m12 6.668 2-2L11.332 2l-2 2M12 6.668l-6.668 6.664H2.668v-2.664L9.332 4M12 6.668 9.332 4" stroke="black" stroke-width="1" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 379 B After Width: | Height: | Size: 239 B |
4
assets/icons/regex.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="4" cy="11" r="1" fill="#787D87"/>
|
||||
<path d="M9 2.5V5M9 5V7.5M9 5H11.5M9 5H6.5M9 5L10.6667 3.33333M9 5L7.33333 6.6667M9 5L10.6667 6.6667M9 5L7.33333 3.33333" stroke="#787D87" stroke-width="1.25" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 333 B |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 17V15.8C20 14.1198 20 13.2798 19.673 12.638C19.3854 12.0735 18.9265 11.6146 18.362 11.327C17.7202 11 16.8802 11 15.2 11H4M4 11L8 7M4 11L8 15" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 468 B |
@@ -1,56 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
|
||||
<svg
|
||||
width="800px"
|
||||
height="800px"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="reply-svgrepo-com.svg"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="1"
|
||||
inkscape:deskcolor="#505050"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.39996789"
|
||||
inkscape:cx="435.03492"
|
||||
inkscape:cy="417.53351"
|
||||
inkscape:window-width="1440"
|
||||
inkscape:window-height="847"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1" />
|
||||
<g
|
||||
id="SVGRepo_bgCarrier"
|
||||
stroke-width="0" />
|
||||
<g
|
||||
id="SVGRepo_tracerCarrier"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<g
|
||||
id="SVGRepo_iconCarrier"
|
||||
transform="matrix(-1,0,0,1,24.001548,0)">
|
||||
<path
|
||||
d="M 20,17 V 15.8 C 20,14.1198 20,13.2798 19.673,12.638 19.3854,12.0735 18.9265,11.6146 18.362,11.327 17.7202,11 16.8802,11 15.2,11 H 4 m 0,0 4,-4 m -4,4 4,4"
|
||||
stroke="#000000"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
id="path1" />
|
||||
</g>
|
||||
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.668 11.332v-.797c0-1.12 0-1.683.219-2.11.191-.374.496-.683.87-.874.43-.219.99-.219 2.11-.219h7.469m0 0-2.668-2.664m2.668 2.664L10.668 10" stroke="black" stroke-width="1.33334" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 296 B |
@@ -1,5 +1,5 @@
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.5 7V9.5M9.5 12V9.5M12 9.5H9.5M7 9.5H9.5M9.5 9.5L11.1667 7.83333M9.5 9.5L7.83333 11.1667M9.5 9.5L11.1667 11.1667M9.5 9.5L7.83333 7.83333" stroke="#11181C" stroke-width="1.25" stroke-linecap="round"/>
|
||||
<path d="M2.19366 3.84943C2.19188 4.26418 2.32864 4.59864 2.60673 4.84707C2.88052 5.09166 3.25136 5.26933 3.71609 5.3824C3.71616 5.38242 3.71623 5.38243 3.7163 5.38245L4.30919 5.53134L4.30919 5.53134L4.30965 5.53145C4.50649 5.57891 4.67124 5.63133 4.80447 5.68843L4.80469 5.68852C4.93838 5.74508 5.03564 5.81206 5.10001 5.8877L5.10001 5.8877L5.10041 5.88816C5.16432 5.96142 5.19716 6.05222 5.19716 6.16389C5.19716 6.28412 5.1609 6.38933 5.0882 6.48141C5.01496 6.57418 4.91031 6.64838 4.77141 6.70259L4.77121 6.70266C4.63472 6.75659 4.47185 6.7843 4.28146 6.7843C4.08801 6.7843 3.91607 6.75496 3.76491 6.69726C3.61654 6.6382 3.49924 6.55209 3.41132 6.43942C3.3502 6.35821 3.30747 6.26204 3.28375 6.14992C3.26238 6.04888 3.1772 5.96225 3.06518 5.96225H2.26366C2.14682 5.96225 2.04842 6.05919 2.0592 6.18012C2.08842 6.50802 2.1826 6.79102 2.34331 7.02735L2.34352 7.02767C2.53217 7.30057 2.79377 7.50587 3.12633 7.64399L3.12642 7.64402C3.46009 7.78185 3.84993 7.85 4.29476 7.85C4.74293 7.85 5.12859 7.7828 5.45023 7.64651L5.45036 7.64646C5.77328 7.50857 6.02259 7.31417 6.19551 7.06217C6.37037 6.80817 6.4579 6.50901 6.45972 6.16682L6.45972 6.16616C6.4579 5.9333 6.41513 5.72482 6.33012 5.54178C6.2474 5.35987 6.13061 5.20175 5.98007 5.06773C5.83038 4.93448 5.65389 4.82273 5.4511 4.7322C5.24919 4.64206 5.02795 4.57016 4.78757 4.51632L4.29841 4.39935L4.29841 4.39934L4.29771 4.39919C4.18081 4.37301 4.07116 4.34168 3.9687 4.30523C3.86715 4.26734 3.77847 4.22375 3.70232 4.17471C3.62796 4.12508 3.57037 4.06717 3.52849 4.00124C3.49012 3.93815 3.47157 3.86312 3.47481 3.77407L3.47484 3.77407V3.77225C3.47484 3.66563 3.50527 3.57146 3.56612 3.48808C3.6287 3.40475 3.71977 3.33801 3.84235 3.28931L3.84235 3.28932L3.84289 3.28909C3.96465 3.23906 4.1165 3.21304 4.30008 3.21304C4.57006 3.21304 4.77746 3.27105 4.92754 3.38154C5.04235 3.46608 5.11838 3.57594 5.15673 3.71259C5.18352 3.80802 5.26636 3.89142 5.37611 3.89142H6.17259C6.28852 3.89142 6.38806 3.7953 6.37515 3.67382C6.34686 3.4077 6.26051 3.16831 6.1158 2.95658C5.94159 2.70169 5.6982 2.50368 5.38762 2.36201L5.36687 2.4075M2.19366 3.84943C2.19187 3.51004 2.28242 3.21139 2.46644 2.9556L2.46658 2.9554C2.65148 2.70093 2.90447 2.50326 3.22368 2.36179C3.54316 2.2202 3.90494 2.15 4.30807 2.15C4.71809 2.15 5.07841 2.22014 5.38773 2.36206L5.36687 2.4075M2.19366 3.84943C2.19366 3.84951 2.19366 3.84959 2.19366 3.84967L2.24366 3.8494L2.19366 3.84918C2.19366 3.84926 2.19366 3.84935 2.19366 3.84943ZM5.36687 2.4075C5.06537 2.26917 4.71244 2.2 4.30807 2.2C3.91079 2.2 3.55608 2.26917 3.24394 2.4075C2.93179 2.54584 2.68616 2.73827 2.50703 2.9848L3.82389 3.24285L3.82389 3.24285C3.95336 3.18964 4.11209 3.16304 4.30008 3.16304C4.57676 3.16304 4.79579 3.22245 4.95718 3.34128C5.08094 3.43239 5.1635 3.55166 5.20487 3.69908C5.2271 3.77827 5.29386 3.84142 5.37611 3.84142H6.17259C6.26198 3.84142 6.33488 3.76799 6.32543 3.6791C6.29797 3.4208 6.21433 3.18936 6.07452 2.9848C5.90603 2.73827 5.67015 2.54584 5.36687 2.4075ZM4.78958 6.74917C4.64593 6.80592 4.47655 6.8343 4.28146 6.8343C4.08283 6.8343 3.90458 6.80415 3.74674 6.74384C3.59067 6.68177 3.46563 6.59043 3.37163 6.46983L4.78958 6.74917ZM4.78958 6.74917C4.93502 6.69241 5.04764 6.61349 5.12745 6.5124M4.78958 6.74917L5.12745 6.5124M5.12745 6.5124C5.20726 6.4113 5.24716 6.29514 5.24716 6.16389M5.12745 6.5124L5.24716 6.16389M5.24716 6.16389C5.24716 6.04152 5.2108 5.93865 5.13809 5.85529L5.24716 6.16389Z" fill="#687076" stroke="#687076" stroke-width="0.1"/>
|
||||
<path d="M9.5 7V9.5M9.5 9.5V12M9.5 9.5H12M9.5 9.5H7M9.5 9.5L11.1667 7.83333M9.5 9.5L7.83333 11.1667M9.5 9.5L11.1667 11.1667M9.5 9.5L7.83333 7.83333" stroke="#687076" stroke-width="1.25" stroke-linecap="round"/>
|
||||
<path d="M2.19368 3.84945C2.1919 4.2642 2.32866 4.59866 2.60675 4.84709C2.88054 5.09168 3.25138 5.26935 3.71611 5.38242L4.30921 5.53136C4.50605 5.57882 4.67126 5.63135 4.80449 5.68845C4.93818 5.74501 5.03566 5.81208 5.10003 5.88772C5.16394 5.96098 5.19718 6.05224 5.19718 6.16391C5.19718 6.28414 5.16092 6.38935 5.08822 6.48143C5.01498 6.5742 4.91033 6.6484 4.77143 6.70261C4.63494 6.75654 4.47187 6.78432 4.28148 6.78432C4.08803 6.78432 3.91609 6.75498 3.76493 6.69728C3.61656 6.63822 3.49926 6.55211 3.41134 6.43944C3.35022 6.35823 3.30749 6.26206 3.28377 6.14994C3.2624 6.0489 3.17722 5.96227 3.0652 5.96227H2.26368C2.14684 5.96227 2.04844 6.05921 2.05922 6.18014C2.08844 6.50804 2.18262 6.79104 2.34333 7.02737C2.53198 7.30027 2.79379 7.50589 3.12635 7.64401C3.46002 7.78184 3.84995 7.85002 4.29478 7.85002C4.74295 7.85002 5.12861 7.78282 5.45025 7.64653C5.77317 7.50864 6.02261 7.31419 6.19553 7.06219C6.37039 6.80819 6.45792 6.50903 6.45974 6.16684C6.45792 5.93398 6.41515 5.72484 6.33014 5.5418C6.24742 5.35989 6.13063 5.20177 5.98009 5.06775C5.8304 4.9345 5.65391 4.82275 5.45112 4.73222C5.24921 4.64208 5.02797 4.57018 4.78759 4.51634L4.29843 4.39937C4.18153 4.37319 4.07118 4.3417 3.96872 4.30525C3.86717 4.26736 3.77849 4.22377 3.70234 4.17473C3.62798 4.1251 3.57039 4.06719 3.52851 4.00126C3.49014 3.93817 3.47159 3.86314 3.47483 3.77409L3.47486 3.77227C3.47486 3.66565 3.50529 3.57148 3.56614 3.4881C3.62872 3.40477 3.71979 3.33803 3.84237 3.28933C3.96413 3.2393 4.11652 3.21306 4.3001 3.21306C4.57008 3.21306 4.77748 3.27107 4.92756 3.38156C5.04237 3.4661 5.1184 3.57596 5.15675 3.71261C5.18354 3.80804 5.26638 3.89144 5.37613 3.89144H6.17261C6.28854 3.89144 6.38808 3.79532 6.37517 3.67384C6.34688 3.40772 6.26053 3.16833 6.11582 2.9566C5.94161 2.70171 5.69822 2.5037 5.38764 2.36203L5.36689 2.40752M2.19368 3.84945C2.19189 3.51006 2.28244 3.21141 2.46646 2.95562C2.65136 2.70115 2.90449 2.50328 3.2237 2.36181C3.54318 2.22022 3.90496 2.15002 4.30809 2.15002C4.71811 2.15002 5.07832 2.22011 5.38764 2.36203L5.36689 2.40752M4.7896 6.74919C4.93504 6.69243 5.04766 6.61351 5.12747 6.51242ZM4.7896 6.74919L5.12747 6.51242ZM5.12747 6.51242C5.20728 6.41132 5.24718 6.29516 5.24718 6.16391ZM5.12747 6.51242L5.24718 6.16391ZM5.24718 6.16391C5.24718 6.04154 5.21082 5.93867 5.13811 5.85531L5.24718 6.16391Z" fill="#687076"/>
|
||||
<path d="M2.19368 3.84945C2.1919 4.2642 2.32866 4.59866 2.60675 4.84709C2.88054 5.09168 3.25138 5.26935 3.71611 5.38242L4.30921 5.53136C4.50605 5.57882 4.67126 5.63135 4.80449 5.68845C4.93818 5.74501 5.03566 5.81208 5.10003 5.88772C5.16394 5.96098 5.19718 6.05224 5.19718 6.16391C5.19718 6.28414 5.16092 6.38935 5.08822 6.48143C5.01498 6.5742 4.91033 6.6484 4.77143 6.70261C4.63494 6.75654 4.47187 6.78432 4.28148 6.78432C4.08803 6.78432 3.91609 6.75498 3.76493 6.69728C3.61656 6.63822 3.49926 6.55211 3.41134 6.43944C3.35022 6.35823 3.30749 6.26206 3.28377 6.14994C3.2624 6.0489 3.17722 5.96227 3.0652 5.96227H2.26368C2.14684 5.96227 2.04844 6.05921 2.05922 6.18014C2.08844 6.50804 2.18262 6.79104 2.34333 7.02737C2.53198 7.30027 2.79379 7.50589 3.12635 7.64401C3.46002 7.78184 3.84995 7.85002 4.29478 7.85002C4.74295 7.85002 5.12861 7.78282 5.45025 7.64653C5.77317 7.50864 6.02261 7.31419 6.19553 7.06219C6.37039 6.80819 6.45792 6.50903 6.45974 6.16684C6.45792 5.93398 6.41515 5.72484 6.33014 5.5418C6.24742 5.35989 6.13063 5.20177 5.98009 5.06775C5.8304 4.9345 5.65391 4.82275 5.45112 4.73222C5.24921 4.64208 5.02797 4.57018 4.78759 4.51634L4.29843 4.39937C4.18153 4.37319 4.07118 4.3417 3.96872 4.30525C3.86717 4.26736 3.77849 4.22377 3.70234 4.17473C3.62798 4.1251 3.57039 4.06719 3.52851 4.00126C3.49014 3.93817 3.47159 3.86314 3.47483 3.77409L3.47486 3.77227C3.47486 3.66565 3.50529 3.57148 3.56614 3.4881C3.62872 3.40477 3.71979 3.33803 3.84237 3.28933C3.96413 3.2393 4.11652 3.21306 4.3001 3.21306C4.57008 3.21306 4.77748 3.27107 4.92756 3.38156C5.04237 3.4661 5.1184 3.57596 5.15675 3.71261C5.18354 3.80804 5.26638 3.89144 5.37613 3.89144H6.17261C6.28854 3.89144 6.38808 3.79532 6.37517 3.67384C6.34688 3.40772 6.26053 3.16833 6.11582 2.9566C5.94161 2.70171 5.69822 2.5037 5.38764 2.36203M2.19368 3.84945C2.19189 3.51006 2.28244 3.21141 2.46646 2.95562C2.65136 2.70115 2.90449 2.50328 3.2237 2.36181C3.54318 2.22022 3.90496 2.15002 4.30809 2.15002C4.71811 2.15002 5.07832 2.22011 5.38764 2.36203M2.19368 3.84945L2.24368 3.84942M5.38764 2.36203L5.36689 2.40752M5.36689 2.40752C5.06539 2.26919 4.71246 2.20002 4.30809 2.20002C3.91081 2.20002 3.5561 2.26919 3.24396 2.40752C2.93181 2.54586 2.68618 2.73829 2.50705 2.98482M5.36689 2.40752C5.67017 2.54586 5.90605 2.73829 6.07454 2.98482C6.21435 3.18938 6.29799 3.42082 6.32545 3.67912C6.3349 3.76801 6.262 3.84144 6.17261 3.84144H5.37613C5.29388 3.84144 5.22712 3.77829 5.20489 3.6991C5.16352 3.55168 5.08096 3.43241 4.9572 3.3413C4.79581 3.22247 4.57678 3.16306 4.3001 3.16306M4.7896 6.74919C4.64595 6.80594 4.47657 6.83432 4.28148 6.83432C4.08285 6.83432 3.9046 6.80417 3.74676 6.74386C3.59069 6.68179 3.46565 6.59045 3.37165 6.46985M4.7896 6.74919C4.93504 6.69243 5.04766 6.61351 5.12747 6.51242M4.7896 6.74919L5.12747 6.51242M5.12747 6.51242C5.20728 6.41132 5.24718 6.29516 5.24718 6.16391M5.12747 6.51242L5.24718 6.16391M5.24718 6.16391C5.24718 6.04154 5.21082 5.93867 5.13811 5.85531L5.24718 6.16391Z" stroke="#687076" stroke-width="0.1"/>
|
||||
</svg>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 5.5 KiB |
5
assets/icons/server.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.99993 6.85713C11.1558 6.85713 13.7142 5.83379 13.7142 4.57142C13.7142 3.30905 11.1558 2.28571 7.99993 2.28571C4.84402 2.28571 2.28564 3.30905 2.28564 4.57142C2.28564 5.83379 4.84402 6.85713 7.99993 6.85713Z" fill="black" stroke="black" stroke-width="1.5"/>
|
||||
<path d="M13.7142 4.57141V11.4286C13.7142 12.691 11.1558 13.7143 7.99993 13.7143C4.84402 13.7143 2.28564 12.691 2.28564 11.4286V4.57141" stroke="black" stroke-width="1.5"/>
|
||||
<path d="M13.7142 8C13.7142 9.26237 11.1558 10.2857 7.99993 10.2857C4.84402 10.2857 2.28564 9.26237 2.28564 8" stroke="black" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 692 B |
1
assets/icons/trash.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
|
||||
|
After Width: | Height: | Size: 409 B |
@@ -212,7 +212,6 @@
|
||||
"enter": "search::SelectNextMatch",
|
||||
"shift-enter": "search::SelectPrevMatch",
|
||||
"alt-enter": "search::SelectAllMatches",
|
||||
"alt-tab": "search::CycleMode",
|
||||
"ctrl-f": "search::FocusSearch",
|
||||
"ctrl-h": "search::ToggleReplace"
|
||||
}
|
||||
@@ -235,11 +234,10 @@
|
||||
"context": "ProjectSearchBar",
|
||||
"bindings": {
|
||||
"escape": "project_search::ToggleFocus",
|
||||
"alt-tab": "search::CycleMode",
|
||||
"ctrl-shift-f": "search::FocusSearch",
|
||||
"ctrl-shift-h": "search::ToggleReplace",
|
||||
"alt-ctrl-g": "search::ActivateRegexMode",
|
||||
"alt-ctrl-x": "search::ActivateTextMode"
|
||||
"alt-ctrl-g": "search::ToggleRegex",
|
||||
"alt-ctrl-x": "search::ToggleRegex"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -260,10 +258,9 @@
|
||||
"context": "ProjectSearchView",
|
||||
"bindings": {
|
||||
"escape": "project_search::ToggleFocus",
|
||||
"alt-tab": "search::CycleMode",
|
||||
"ctrl-shift-h": "search::ToggleReplace",
|
||||
"alt-ctrl-g": "search::ActivateRegexMode",
|
||||
"alt-ctrl-x": "search::ActivateTextMode"
|
||||
"alt-ctrl-g": "search::ToggleRegex",
|
||||
"alt-ctrl-x": "search::ToggleRegex"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -283,10 +280,10 @@
|
||||
"alt-enter": "search::SelectAllMatches",
|
||||
"alt-c": "search::ToggleCaseSensitive",
|
||||
"alt-w": "search::ToggleWholeWord",
|
||||
"alt-r": "search::CycleMode",
|
||||
"alt-r": "search::ToggleRegex",
|
||||
"alt-ctrl-f": "project_search::ToggleFilters",
|
||||
"ctrl-alt-shift-r": "search::ActivateRegexMode",
|
||||
"ctrl-alt-shift-x": "search::ActivateTextMode"
|
||||
"ctrl-alt-shift-r": "search::ToggleRegex",
|
||||
"ctrl-alt-shift-x": "search::ToggleRegex"
|
||||
}
|
||||
},
|
||||
// Bindings from VS Code
|
||||
@@ -608,6 +605,8 @@
|
||||
{
|
||||
"context": "TabSwitcher",
|
||||
"bindings": {
|
||||
"ctrl-up": "menu::SelectPrev",
|
||||
"ctrl-down": "menu::SelectNext",
|
||||
"ctrl-shift-tab": "menu::SelectPrev",
|
||||
"ctrl-backspace": "tab_switcher::CloseSelectedItem"
|
||||
}
|
||||
|
||||
@@ -233,7 +233,6 @@
|
||||
"enter": "search::SelectNextMatch",
|
||||
"shift-enter": "search::SelectPrevMatch",
|
||||
"alt-enter": "search::SelectAllMatches",
|
||||
"alt-tab": "search::CycleMode",
|
||||
"cmd-f": "search::FocusSearch",
|
||||
"cmd-alt-f": "search::ToggleReplace"
|
||||
}
|
||||
@@ -256,11 +255,10 @@
|
||||
"context": "ProjectSearchBar",
|
||||
"bindings": {
|
||||
"escape": "project_search::ToggleFocus",
|
||||
"alt-tab": "search::CycleMode",
|
||||
"cmd-shift-f": "search::FocusSearch",
|
||||
"cmd-shift-h": "search::ToggleReplace",
|
||||
"alt-cmd-g": "search::ActivateRegexMode",
|
||||
"alt-cmd-x": "search::ActivateTextMode"
|
||||
"alt-cmd-g": "search::ToggleRegex",
|
||||
"alt-cmd-x": "search::ToggleRegex"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -281,10 +279,9 @@
|
||||
"context": "ProjectSearchView",
|
||||
"bindings": {
|
||||
"escape": "project_search::ToggleFocus",
|
||||
"alt-tab": "search::CycleMode",
|
||||
"cmd-shift-h": "search::ToggleReplace",
|
||||
"alt-cmd-g": "search::ActivateRegexMode",
|
||||
"alt-cmd-x": "search::ActivateTextMode"
|
||||
"alt-cmd-g": "search::ToggleRegex",
|
||||
"alt-cmd-x": "search::ToggleRegex"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -306,10 +303,9 @@
|
||||
"alt-enter": "search::SelectAllMatches",
|
||||
"alt-cmd-c": "search::ToggleCaseSensitive",
|
||||
"alt-cmd-w": "search::ToggleWholeWord",
|
||||
"alt-tab": "search::CycleMode",
|
||||
"alt-cmd-f": "project_search::ToggleFilters",
|
||||
"alt-cmd-g": "search::ActivateRegexMode",
|
||||
"alt-cmd-x": "search::ActivateTextMode"
|
||||
"alt-cmd-g": "search::ToggleRegex",
|
||||
"alt-cmd-x": "search::ToggleRegex"
|
||||
}
|
||||
},
|
||||
// Bindings from VS Code
|
||||
@@ -617,6 +613,8 @@
|
||||
{
|
||||
"context": "TabSwitcher",
|
||||
"bindings": {
|
||||
"ctrl-up": "menu::SelectPrev",
|
||||
"ctrl-down": "menu::SelectNext",
|
||||
"ctrl-shift-tab": "menu::SelectPrev",
|
||||
"ctrl-backspace": "tab_switcher::CloseSelectedItem"
|
||||
}
|
||||
|
||||
@@ -234,6 +234,8 @@
|
||||
"displayLines": true
|
||||
}
|
||||
],
|
||||
"g ]": "editor::GoToDiagnostic",
|
||||
"g [": "editor::GoToPrevDiagnostic",
|
||||
"shift-h": "vim::WindowTop",
|
||||
"shift-m": "vim::WindowMiddle",
|
||||
"shift-l": "vim::WindowBottom",
|
||||
@@ -367,6 +369,15 @@
|
||||
"< <": "vim::Outdent",
|
||||
"ctrl-pagedown": "pane::ActivateNextItem",
|
||||
"ctrl-pageup": "pane::ActivatePrevItem",
|
||||
// tree-sitter related commands
|
||||
"[ x": "editor::SelectLargerSyntaxNode",
|
||||
"] x": "editor::SelectSmallerSyntaxNode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Editor && vim_mode == visual && vim_operator == none && !VimWaiting",
|
||||
"bindings": {
|
||||
// tree-sitter related commands
|
||||
"[ x": "editor::SelectLargerSyntaxNode",
|
||||
"] x": "editor::SelectSmallerSyntaxNode"
|
||||
}
|
||||
@@ -532,6 +543,18 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Editor && vim_mode == normal",
|
||||
"bindings": {
|
||||
"g c c": "editor::ToggleComments"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Editor && vim_mode == visual",
|
||||
"bindings": {
|
||||
"g c": "editor::ToggleComments"
|
||||
}
|
||||
},
|
||||
{
|
||||
"context": "Editor && vim_mode == insert",
|
||||
"bindings": {
|
||||
@@ -590,17 +613,18 @@
|
||||
"%": "project_panel::NewFile",
|
||||
"/": "project_panel::NewSearchInDirectory",
|
||||
"d": "project_panel::NewDirectory",
|
||||
"enter": "project_panel::Open",
|
||||
"enter": "project_panel::OpenPermanent",
|
||||
"escape": "project_panel::ToggleFocus",
|
||||
"h": "project_panel::CollapseSelectedEntry",
|
||||
"j": "menu::SelectNext",
|
||||
"k": "menu::SelectPrev",
|
||||
"l": "project_panel::ExpandSelectedEntry",
|
||||
"o": "project_panel::Open",
|
||||
"o": "project_panel::OpenPermanent",
|
||||
"shift-d": "project_panel::Delete",
|
||||
"shift-r": "project_panel::Rename",
|
||||
"t": "project_panel::Open",
|
||||
"v": "project_panel::Open",
|
||||
"t": "project_panel::OpenPermanent",
|
||||
"v": "project_panel::OpenPermanent",
|
||||
"p": "project_panel::Open",
|
||||
"x": "project_panel::RevealInFinder"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@
|
||||
"hover_popover_enabled": true,
|
||||
// Whether to confirm before quitting Zed.
|
||||
"confirm_quit": false,
|
||||
// Whether to restore last closed project when fresh Zed instance is opened.
|
||||
"restore_on_startup": "last_workspace",
|
||||
// Whether the cursor blinks in the editor.
|
||||
"cursor_blink": true,
|
||||
// Whether to pop the completions menu while typing in an editor without
|
||||
@@ -169,6 +171,9 @@
|
||||
},
|
||||
// The number of lines to keep above/below the cursor when scrolling.
|
||||
"vertical_scroll_margin": 3,
|
||||
// Scroll sensitivity multiplier. This multiplier is applied
|
||||
// to both the horizontal and vertical delta values while scrolling.
|
||||
"scroll_sensitivity": 1.0,
|
||||
"relative_line_numbers": false,
|
||||
// When to populate a new search's query based on the text under the cursor.
|
||||
// This setting can take the following three values:
|
||||
@@ -212,7 +217,10 @@
|
||||
// Whether to reveal it in the project panel automatically,
|
||||
// when a corresponding project entry becomes active.
|
||||
// Gitignored entries are never auto revealed.
|
||||
"auto_reveal_entries": true
|
||||
"auto_reveal_entries": true,
|
||||
/// Whether to fold directories automatically
|
||||
/// when a directory has only one directory inside.
|
||||
"auto_fold_dirs": false
|
||||
},
|
||||
"collaboration_panel": {
|
||||
// Whether to show the collaboration panel button in the status bar.
|
||||
@@ -296,6 +304,16 @@
|
||||
// Position of the close button on the editor tabs.
|
||||
"close_position": "right"
|
||||
},
|
||||
// Settings related to preview tabs.
|
||||
"preview_tabs": {
|
||||
// Whether preview tabs should be enabled.
|
||||
// Preview tabs allow you to open files in preview mode, where they close automatically
|
||||
// when you switch to another file unless you explicitly pin them.
|
||||
// This is useful for quickly viewing files without cluttering your workspace.
|
||||
"enabled": true,
|
||||
// Whether to open files in preview mode when selected from the file finder.
|
||||
"enable_preview_from_file_finder": false
|
||||
},
|
||||
// Whether or not to remove any trailing whitespace from lines of a buffer
|
||||
// before saving it.
|
||||
"remove_trailing_whitespace_on_save": true,
|
||||
@@ -375,7 +393,15 @@
|
||||
// "git_gutter": "tracked_files"
|
||||
// 2. Hide the gutter
|
||||
// "git_gutter": "hide"
|
||||
"git_gutter": "tracked_files"
|
||||
"git_gutter": "tracked_files",
|
||||
// Control whether the git blame information is shown inline,
|
||||
// in the currently focused line.
|
||||
"inline_blame": {
|
||||
"enabled": false
|
||||
// Sets a delay after which the inline blame information is shown.
|
||||
// Delay is restarted with every cursor movement.
|
||||
// "delay_ms": 600
|
||||
}
|
||||
},
|
||||
"copilot": {
|
||||
// The set of glob patterns for which copilot should be disabled
|
||||
|
||||
@@ -46,6 +46,7 @@ use ui::{
|
||||
};
|
||||
use util::{paths::CONVERSATIONS_DIR, post_inc, ResultExt, TryFutureExt};
|
||||
use uuid::Uuid;
|
||||
use workspace::notifications::NotificationId;
|
||||
use workspace::{
|
||||
dock::{DockPosition, Panel, PanelEvent},
|
||||
searchable::Direction,
|
||||
@@ -418,10 +419,14 @@ impl AssistantPanel {
|
||||
if pending_assist.inline_assistant.is_none() {
|
||||
if let Some(workspace) = this.workspace.upgrade() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.show_toast(
|
||||
Toast::new(inline_assist_id, error),
|
||||
cx,
|
||||
);
|
||||
struct InlineAssistantError;
|
||||
|
||||
let id =
|
||||
NotificationId::identified::<InlineAssistantError>(
|
||||
inline_assist_id,
|
||||
);
|
||||
|
||||
workspace.show_toast(Toast::new(id, error), cx);
|
||||
})
|
||||
}
|
||||
|
||||
@@ -620,10 +625,10 @@ impl AssistantPanel {
|
||||
// If Markdown or No Language is Known, increase the randomness for more creative output
|
||||
// If Code, decrease temperature to get more deterministic outputs
|
||||
let temperature = if let Some(language) = language_name.clone() {
|
||||
if language.as_ref() != "Markdown" {
|
||||
0.5
|
||||
} else {
|
||||
if language.as_ref() == "Markdown" {
|
||||
1.0
|
||||
} else {
|
||||
0.5
|
||||
}
|
||||
} else {
|
||||
1.0
|
||||
@@ -696,7 +701,7 @@ impl AssistantPanel {
|
||||
} else {
|
||||
editor.highlight_background::<PendingInlineAssist>(
|
||||
&background_ranges,
|
||||
|theme| theme.editor_active_line_background, // todo!("use the appropriate color")
|
||||
|theme| theme.editor_active_line_background, // TODO use the appropriate color
|
||||
cx,
|
||||
);
|
||||
}
|
||||
@@ -2060,7 +2065,7 @@ impl ConversationEditor {
|
||||
workspace: workspace.downgrade(),
|
||||
_subscriptions,
|
||||
};
|
||||
this.update_active_buffer(workspace, cx);
|
||||
cx.defer(|this, cx| this.update_active_buffer(workspace, cx));
|
||||
this.update_message_headers(cx);
|
||||
this
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ use util::{
|
||||
http::{HttpClient, HttpClientWithUrl},
|
||||
ResultExt,
|
||||
};
|
||||
use workspace::notifications::NotificationId;
|
||||
use workspace::Workspace;
|
||||
|
||||
const SHOULD_SHOW_UPDATE_NOTIFICATION_KEY: &str = "auto-updater-should-show-updated-notification";
|
||||
@@ -262,9 +263,11 @@ pub fn notify_of_any_new_update(cx: &mut ViewContext<Workspace>) -> Option<()> {
|
||||
let should_show_notification = should_show_notification.await?;
|
||||
if should_show_notification {
|
||||
workspace.update(&mut cx, |workspace, cx| {
|
||||
workspace.show_notification(0, cx, |cx| {
|
||||
cx.new_view(|_| UpdateNotification::new(version))
|
||||
});
|
||||
workspace.show_notification(
|
||||
NotificationId::unique::<UpdateNotification>(),
|
||||
cx,
|
||||
|cx| cx.new_view(|_| UpdateNotification::new(version)),
|
||||
);
|
||||
updater
|
||||
.read(cx)
|
||||
.set_should_show_update_notification(false, cx)
|
||||
|
||||
@@ -52,12 +52,19 @@ impl Render for Breadcrumbs {
|
||||
Some(BreadcrumbText {
|
||||
text: "⋯".into(),
|
||||
highlights: None,
|
||||
font: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let highlighted_segments = segments.into_iter().map(|segment| {
|
||||
let mut text_style = cx.text_style();
|
||||
if let Some(font) = segment.font {
|
||||
text_style.font_family = font.family;
|
||||
text_style.font_features = font.features;
|
||||
text_style.font_style = font.style;
|
||||
text_style.font_weight = font.weight;
|
||||
}
|
||||
text_style.color = Color::Muted.color(cx);
|
||||
|
||||
StyledText::new(segment.text.replace('\n', ""))
|
||||
|
||||
@@ -432,7 +432,9 @@ impl ActiveCall {
|
||||
room: Option<Model<Room>>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
if room.as_ref() != self.room.as_ref().map(|room| &room.0) {
|
||||
if room.as_ref() == self.room.as_ref().map(|room| &room.0) {
|
||||
Task::ready(Ok(()))
|
||||
} else {
|
||||
cx.notify();
|
||||
if let Some(room) = room {
|
||||
if room.read(cx).status().is_offline() {
|
||||
@@ -462,8 +464,6 @@ impl ActiveCall {
|
||||
self.room = None;
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
} else {
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1182,7 +1182,7 @@ impl Room {
|
||||
cx.emit(Event::RemoteProjectJoined { project_id: id });
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
let project =
|
||||
Project::remote(id, client, user_store, language_registry, fs, cx.clone()).await?;
|
||||
Project::in_room(id, client, user_store, language_registry, fs, cx.clone()).await?;
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.joined_projects.retain(|project| {
|
||||
|
||||
@@ -11,7 +11,9 @@ pub use channel_chat::{
|
||||
mentions_to_proto, ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId,
|
||||
MessageParams,
|
||||
};
|
||||
pub use channel_store::{Channel, ChannelEvent, ChannelMembership, ChannelStore};
|
||||
pub use channel_store::{
|
||||
Channel, ChannelEvent, ChannelMembership, ChannelStore, DevServer, RemoteProject,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod channel_store_tests;
|
||||
|
||||
@@ -3,7 +3,10 @@ mod channel_index;
|
||||
use crate::{channel_buffer::ChannelBuffer, channel_chat::ChannelChat, ChannelMessage};
|
||||
use anyhow::{anyhow, Result};
|
||||
use channel_index::ChannelIndex;
|
||||
use client::{ChannelId, Client, ClientSettings, ProjectId, Subscription, User, UserId, UserStore};
|
||||
use client::{
|
||||
ChannelId, Client, ClientSettings, DevServerId, ProjectId, RemoteProjectId, Subscription, User,
|
||||
UserId, UserStore,
|
||||
};
|
||||
use collections::{hash_map, HashMap, HashSet};
|
||||
use futures::{channel::mpsc, future::Shared, Future, FutureExt, StreamExt};
|
||||
use gpui::{
|
||||
@@ -12,7 +15,7 @@ use gpui::{
|
||||
};
|
||||
use language::Capability;
|
||||
use rpc::{
|
||||
proto::{self, ChannelRole, ChannelVisibility},
|
||||
proto::{self, ChannelRole, ChannelVisibility, DevServerStatus},
|
||||
TypedEnvelope,
|
||||
};
|
||||
use settings::Settings;
|
||||
@@ -40,7 +43,6 @@ pub struct HostedProject {
|
||||
name: SharedString,
|
||||
_visibility: proto::ChannelVisibility,
|
||||
}
|
||||
|
||||
impl From<proto::HostedProject> for HostedProject {
|
||||
fn from(project: proto::HostedProject) -> Self {
|
||||
Self {
|
||||
@@ -52,12 +54,56 @@ impl From<proto::HostedProject> for HostedProject {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteProject {
|
||||
pub id: RemoteProjectId,
|
||||
pub project_id: Option<ProjectId>,
|
||||
pub channel_id: ChannelId,
|
||||
pub name: SharedString,
|
||||
pub path: SharedString,
|
||||
pub dev_server_id: DevServerId,
|
||||
}
|
||||
|
||||
impl From<proto::RemoteProject> for RemoteProject {
|
||||
fn from(project: proto::RemoteProject) -> Self {
|
||||
Self {
|
||||
id: RemoteProjectId(project.id),
|
||||
project_id: project.project_id.map(|id| ProjectId(id)),
|
||||
channel_id: ChannelId(project.channel_id),
|
||||
name: project.name.into(),
|
||||
path: project.path.into(),
|
||||
dev_server_id: DevServerId(project.dev_server_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DevServer {
|
||||
pub id: DevServerId,
|
||||
pub channel_id: ChannelId,
|
||||
pub name: SharedString,
|
||||
pub status: DevServerStatus,
|
||||
}
|
||||
|
||||
impl From<proto::DevServer> for DevServer {
|
||||
fn from(dev_server: proto::DevServer) -> Self {
|
||||
Self {
|
||||
id: DevServerId(dev_server.dev_server_id),
|
||||
channel_id: ChannelId(dev_server.channel_id),
|
||||
status: dev_server.status(),
|
||||
name: dev_server.name.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ChannelStore {
|
||||
pub channel_index: ChannelIndex,
|
||||
channel_invitations: Vec<Arc<Channel>>,
|
||||
channel_participants: HashMap<ChannelId, Vec<Arc<User>>>,
|
||||
channel_states: HashMap<ChannelId, ChannelState>,
|
||||
hosted_projects: HashMap<ProjectId, HostedProject>,
|
||||
remote_projects: HashMap<RemoteProjectId, RemoteProject>,
|
||||
dev_servers: HashMap<DevServerId, DevServer>,
|
||||
|
||||
outgoing_invites: HashSet<(ChannelId, UserId)>,
|
||||
update_channels_tx: mpsc::UnboundedSender<proto::UpdateChannels>,
|
||||
@@ -87,6 +133,8 @@ pub struct ChannelState {
|
||||
observed_chat_message: Option<u64>,
|
||||
role: Option<ChannelRole>,
|
||||
projects: HashSet<ProjectId>,
|
||||
dev_servers: HashSet<DevServerId>,
|
||||
remote_projects: HashSet<RemoteProjectId>,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
@@ -217,6 +265,8 @@ impl ChannelStore {
|
||||
channel_index: ChannelIndex::default(),
|
||||
channel_participants: Default::default(),
|
||||
hosted_projects: Default::default(),
|
||||
remote_projects: Default::default(),
|
||||
dev_servers: Default::default(),
|
||||
outgoing_invites: Default::default(),
|
||||
opened_buffers: Default::default(),
|
||||
opened_chats: Default::default(),
|
||||
@@ -316,6 +366,40 @@ impl ChannelStore {
|
||||
projects
|
||||
}
|
||||
|
||||
pub fn dev_servers_for_id(&self, channel_id: ChannelId) -> Vec<DevServer> {
|
||||
let mut dev_servers: Vec<DevServer> = self
|
||||
.channel_states
|
||||
.get(&channel_id)
|
||||
.map(|state| state.dev_servers.clone())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.flat_map(|id| self.dev_servers.get(&id).cloned())
|
||||
.collect();
|
||||
dev_servers.sort_by_key(|s| (s.name.clone(), s.id));
|
||||
dev_servers
|
||||
}
|
||||
|
||||
pub fn find_dev_server_by_id(&self, id: DevServerId) -> Option<&DevServer> {
|
||||
self.dev_servers.get(&id)
|
||||
}
|
||||
|
||||
pub fn find_remote_project_by_id(&self, id: RemoteProjectId) -> Option<&RemoteProject> {
|
||||
self.remote_projects.get(&id)
|
||||
}
|
||||
|
||||
pub fn remote_projects_for_id(&self, channel_id: ChannelId) -> Vec<RemoteProject> {
|
||||
let mut remote_projects: Vec<RemoteProject> = self
|
||||
.channel_states
|
||||
.get(&channel_id)
|
||||
.map(|state| state.remote_projects.clone())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.flat_map(|id| self.remote_projects.get(&id).cloned())
|
||||
.collect();
|
||||
remote_projects.sort_by_key(|p| (p.name.clone(), p.id));
|
||||
remote_projects
|
||||
}
|
||||
|
||||
pub fn has_open_channel_buffer(&self, channel_id: ChannelId, _cx: &AppContext) -> bool {
|
||||
if let Some(buffer) = self.opened_buffers.get(&channel_id) {
|
||||
if let OpenedModelHandle::Open(buffer) = buffer {
|
||||
@@ -818,6 +902,45 @@ impl ChannelStore {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_remote_project(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
dev_server_id: DevServerId,
|
||||
name: String,
|
||||
path: String,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<proto::CreateRemoteProjectResponse>> {
|
||||
let client = self.client.clone();
|
||||
cx.background_executor().spawn(async move {
|
||||
client
|
||||
.request(proto::CreateRemoteProject {
|
||||
channel_id: channel_id.0,
|
||||
dev_server_id: dev_server_id.0,
|
||||
name,
|
||||
path,
|
||||
})
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_dev_server(
|
||||
&mut self,
|
||||
channel_id: ChannelId,
|
||||
name: String,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Task<Result<proto::CreateDevServerResponse>> {
|
||||
let client = self.client.clone();
|
||||
cx.background_executor().spawn(async move {
|
||||
let result = client
|
||||
.request(proto::CreateDevServer {
|
||||
channel_id: channel_id.0,
|
||||
name,
|
||||
})
|
||||
.await?;
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_channel_member_details(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
@@ -1098,7 +1221,11 @@ impl ChannelStore {
|
||||
|| !payload.latest_channel_message_ids.is_empty()
|
||||
|| !payload.latest_channel_buffer_versions.is_empty()
|
||||
|| !payload.hosted_projects.is_empty()
|
||||
|| !payload.deleted_hosted_projects.is_empty();
|
||||
|| !payload.deleted_hosted_projects.is_empty()
|
||||
|| !payload.dev_servers.is_empty()
|
||||
|| !payload.deleted_dev_servers.is_empty()
|
||||
|| !payload.remote_projects.is_empty()
|
||||
|| !payload.deleted_remote_projects.is_empty();
|
||||
|
||||
if channels_changed {
|
||||
if !payload.delete_channels.is_empty() {
|
||||
@@ -1186,6 +1313,60 @@ impl ChannelStore {
|
||||
.remove_hosted_project(old_project.project_id);
|
||||
}
|
||||
}
|
||||
|
||||
for remote_project in payload.remote_projects {
|
||||
let remote_project: RemoteProject = remote_project.into();
|
||||
if let Some(old_remote_project) = self
|
||||
.remote_projects
|
||||
.insert(remote_project.id, remote_project.clone())
|
||||
{
|
||||
self.channel_states
|
||||
.entry(old_remote_project.channel_id)
|
||||
.or_default()
|
||||
.remove_remote_project(old_remote_project.id);
|
||||
}
|
||||
self.channel_states
|
||||
.entry(remote_project.channel_id)
|
||||
.or_default()
|
||||
.add_remote_project(remote_project.id);
|
||||
}
|
||||
|
||||
for remote_project_id in payload.deleted_remote_projects {
|
||||
let remote_project_id = RemoteProjectId(remote_project_id);
|
||||
|
||||
if let Some(old_project) = self.remote_projects.remove(&remote_project_id) {
|
||||
self.channel_states
|
||||
.entry(old_project.channel_id)
|
||||
.or_default()
|
||||
.remove_remote_project(old_project.id);
|
||||
}
|
||||
}
|
||||
|
||||
for dev_server in payload.dev_servers {
|
||||
let dev_server: DevServer = dev_server.into();
|
||||
if let Some(old_server) = self.dev_servers.insert(dev_server.id, dev_server.clone())
|
||||
{
|
||||
self.channel_states
|
||||
.entry(old_server.channel_id)
|
||||
.or_default()
|
||||
.remove_dev_server(old_server.id);
|
||||
}
|
||||
self.channel_states
|
||||
.entry(dev_server.channel_id)
|
||||
.or_default()
|
||||
.add_dev_server(dev_server.id);
|
||||
}
|
||||
|
||||
for dev_server_id in payload.deleted_dev_servers {
|
||||
let dev_server_id = DevServerId(dev_server_id);
|
||||
|
||||
if let Some(old_server) = self.dev_servers.remove(&dev_server_id) {
|
||||
self.channel_states
|
||||
.entry(old_server.channel_id)
|
||||
.or_default()
|
||||
.remove_dev_server(old_server.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
@@ -1300,4 +1481,20 @@ impl ChannelState {
|
||||
fn remove_hosted_project(&mut self, project_id: ProjectId) {
|
||||
self.projects.remove(&project_id);
|
||||
}
|
||||
|
||||
fn add_remote_project(&mut self, remote_project_id: RemoteProjectId) {
|
||||
self.remote_projects.insert(remote_project_id);
|
||||
}
|
||||
|
||||
fn remove_remote_project(&mut self, remote_project_id: RemoteProjectId) {
|
||||
self.remote_projects.remove(&remote_project_id);
|
||||
}
|
||||
|
||||
fn add_dev_server(&mut self, dev_server_id: DevServerId) {
|
||||
self.dev_servers.insert(dev_server_id);
|
||||
}
|
||||
|
||||
fn remove_dev_server(&mut self, dev_server_id: DevServerId) {
|
||||
self.dev_servers.remove(&dev_server_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
channel.next_event(cx),
|
||||
channel.next_event(cx).await,
|
||||
ChannelChatEvent::MessagesUpdated {
|
||||
old_range: 2..2,
|
||||
new_count: 1,
|
||||
@@ -317,7 +317,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
channel.next_event(cx),
|
||||
channel.next_event(cx).await,
|
||||
ChannelChatEvent::MessagesUpdated {
|
||||
old_range: 0..0,
|
||||
new_count: 2,
|
||||
|
||||
@@ -759,8 +759,9 @@ impl Client {
|
||||
read_credentials_from_keychain(cx).await.is_some()
|
||||
}
|
||||
|
||||
pub fn set_dev_server_token(&self, token: DevServerToken) {
|
||||
pub fn set_dev_server_token(&self, token: DevServerToken) -> &Self {
|
||||
self.state.write().credentials = Some(Credentials::DevServer { token });
|
||||
self
|
||||
}
|
||||
|
||||
#[async_recursion(?Send)]
|
||||
|
||||
@@ -27,6 +27,12 @@ impl std::fmt::Display for ChannelId {
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
|
||||
pub struct ProjectId(pub u64);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
|
||||
pub struct DevServerId(pub u64);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
|
||||
pub struct RemoteProjectId(pub u64);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ParticipantIndex(pub u32);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
DATABASE_URL = "postgres://postgres@localhost/zed"
|
||||
# DATABASE_URL = "sqlite:////home/zed/.config/zed/db.sqlite3?mode=rwc"
|
||||
# DATABASE_URL = "sqlite:////root/0/zed/db.sqlite3?mode=rwc"
|
||||
DATABASE_MAX_CONNECTIONS = 5
|
||||
HTTP_PORT = 8080
|
||||
API_TOKEN = "secret"
|
||||
|
||||
@@ -63,8 +63,8 @@ tokio.workspace = true
|
||||
toml.workspace = true
|
||||
tower = "0.4"
|
||||
tower-http = { workspace = true, features = ["trace"] }
|
||||
tracing = "0.1.34"
|
||||
tracing-subscriber = { version = "0.3.11", features = ["env-filter", "json", "registry", "tracing-log"] }
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json", "registry", "tracing-log"] } # workaround for https://github.com/tokio-rs/tracing/issues/2927
|
||||
util.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
@@ -102,3 +102,4 @@ theme.workspace = true
|
||||
unindent.workspace = true
|
||||
util.workspace = true
|
||||
workspace = { workspace = true, features = ["test-support"] }
|
||||
headless.workspace = true
|
||||
|
||||
@@ -45,12 +45,13 @@ CREATE UNIQUE INDEX "index_rooms_on_channel_id" ON "rooms" ("channel_id");
|
||||
|
||||
CREATE TABLE "projects" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"room_id" INTEGER REFERENCES rooms (id) ON DELETE CASCADE NOT NULL,
|
||||
"room_id" INTEGER REFERENCES rooms (id) ON DELETE CASCADE,
|
||||
"host_user_id" INTEGER REFERENCES users (id),
|
||||
"host_connection_id" INTEGER,
|
||||
"host_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE,
|
||||
"unregistered" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
"hosted_project_id" INTEGER REFERENCES hosted_projects (id)
|
||||
"hosted_project_id" INTEGER REFERENCES hosted_projects (id),
|
||||
"remote_project_id" INTEGER REFERENCES remote_projects(id)
|
||||
);
|
||||
CREATE INDEX "index_projects_on_host_connection_server_id" ON "projects" ("host_connection_server_id");
|
||||
CREATE INDEX "index_projects_on_host_connection_id_and_host_connection_server_id" ON "projects" ("host_connection_id", "host_connection_server_id");
|
||||
@@ -397,7 +398,9 @@ CREATE TABLE hosted_projects (
|
||||
channel_id INTEGER NOT NULL REFERENCES channels(id),
|
||||
name TEXT NOT NULL,
|
||||
visibility TEXT NOT NULL,
|
||||
deleted_at TIMESTAMP NULL
|
||||
deleted_at TIMESTAMP NULL,
|
||||
dev_server_id INTEGER REFERENCES dev_servers(id),
|
||||
dev_server_path TEXT
|
||||
);
|
||||
CREATE INDEX idx_hosted_projects_on_channel_id ON hosted_projects (channel_id);
|
||||
CREATE UNIQUE INDEX uix_hosted_projects_on_channel_id_and_name ON hosted_projects (channel_id, name) WHERE (deleted_at IS NULL);
|
||||
@@ -409,3 +412,13 @@ CREATE TABLE dev_servers (
|
||||
hashed_token TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_dev_servers_on_channel_id ON dev_servers (channel_id);
|
||||
|
||||
CREATE TABLE remote_projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel_id INTEGER NOT NULL REFERENCES channels(id),
|
||||
dev_server_id INTEGER NOT NULL REFERENCES dev_servers(id),
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE hosted_projects ADD COLUMN remote_project_id INTEGER REFERENCES remote_projects(id);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE remote_projects (
|
||||
id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
channel_id INT NOT NULL REFERENCES channels(id),
|
||||
dev_server_id INT NOT NULL REFERENCES dev_servers(id),
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE projects ADD COLUMN remote_project_id INTEGER REFERENCES remote_projects(id);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS "embeddings" (
|
||||
"model" TEXT,
|
||||
"digest" BYTEA,
|
||||
"dimensions" FLOAT4[1536],
|
||||
"retrieved_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY ("model", "digest")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_retrieved_at_on_embeddings" ON "embeddings" ("retrieved_at");
|
||||
@@ -106,8 +106,12 @@ async fn get_extension_versions(
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DownloadLatestExtensionParams {
|
||||
struct DownloadLatestExtensionPathParams {
|
||||
extension_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DownloadLatestExtensionQueryParams {
|
||||
min_schema_version: Option<i32>,
|
||||
max_schema_version: Option<i32>,
|
||||
min_wasm_api_version: Option<SemanticVersion>,
|
||||
@@ -116,13 +120,14 @@ struct DownloadLatestExtensionParams {
|
||||
|
||||
async fn download_latest_extension(
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
Path(params): Path<DownloadLatestExtensionParams>,
|
||||
Path(params): Path<DownloadLatestExtensionPathParams>,
|
||||
Query(query): Query<DownloadLatestExtensionQueryParams>,
|
||||
) -> Result<Redirect> {
|
||||
let constraints = maybe!({
|
||||
let min_schema_version = params.min_schema_version?;
|
||||
let max_schema_version = params.max_schema_version?;
|
||||
let min_wasm_api_version = params.min_wasm_api_version?;
|
||||
let max_wasm_api_version = params.max_wasm_api_version?;
|
||||
let min_schema_version = query.min_schema_version?;
|
||||
let max_schema_version = query.max_schema_version?;
|
||||
let min_wasm_api_version = query.min_wasm_api_version?;
|
||||
let max_wasm_api_version = query.max_wasm_api_version?;
|
||||
|
||||
Some(ExtensionVersionConstraints {
|
||||
schema_versions: min_schema_version..=max_schema_version,
|
||||
|
||||
@@ -10,6 +10,7 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
};
|
||||
use prometheus::{exponential_buckets, register_histogram, Histogram};
|
||||
pub use rpc::auth::random_token;
|
||||
use scrypt::{
|
||||
password_hash::{PasswordHash, PasswordVerifier},
|
||||
Scrypt,
|
||||
@@ -152,7 +153,7 @@ pub async fn create_access_token(
|
||||
/// Hashing prevents anyone with access to the database being able to login.
|
||||
/// As the token is randomly generated, we don't need to worry about scrypt-style
|
||||
/// protection.
|
||||
fn hash_access_token(token: &str) -> String {
|
||||
pub fn hash_access_token(token: &str) -> String {
|
||||
let digest = sha2::Sha256::digest(token);
|
||||
format!(
|
||||
"$sha256${}",
|
||||
@@ -230,18 +231,15 @@ pub async fn verify_access_token(
|
||||
})
|
||||
}
|
||||
|
||||
// a dev_server_token has the format <id>.<base64>. This is to make them
|
||||
// relatively easy to copy/paste around.
|
||||
pub fn generate_dev_server_token(id: usize, access_token: String) -> String {
|
||||
format!("{}.{}", id, access_token)
|
||||
}
|
||||
|
||||
pub async fn verify_dev_server_token(
|
||||
dev_server_token: &str,
|
||||
db: &Arc<Database>,
|
||||
) -> anyhow::Result<dev_server::Model> {
|
||||
let mut parts = dev_server_token.splitn(2, '.');
|
||||
let id = DevServerId(parts.next().unwrap_or_default().parse()?);
|
||||
let token = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("invalid dev server token format"))?;
|
||||
|
||||
let (id, token) = split_dev_server_token(dev_server_token)?;
|
||||
let token_hash = hash_access_token(&token);
|
||||
let server = db.get_dev_server(id).await?;
|
||||
|
||||
@@ -257,6 +255,17 @@ pub async fn verify_dev_server_token(
|
||||
}
|
||||
}
|
||||
|
||||
// a dev_server_token has the format <id>.<base64>. This is to make them
|
||||
// relatively easy to copy/paste around.
|
||||
pub fn split_dev_server_token(dev_server_token: &str) -> anyhow::Result<(DevServerId, &str)> {
|
||||
let mut parts = dev_server_token.splitn(2, '.');
|
||||
let id = DevServerId(parts.next().unwrap_or_default().parse()?);
|
||||
let token = parts
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("invalid dev server token format"))?;
|
||||
Ok((id, token))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use rand::thread_rng;
|
||||
|
||||
@@ -56,6 +56,7 @@ pub struct Database {
|
||||
options: ConnectOptions,
|
||||
pool: DatabaseConnection,
|
||||
rooms: DashMap<RoomId, Arc<Mutex<()>>>,
|
||||
projects: DashMap<ProjectId, Arc<Mutex<()>>>,
|
||||
rng: Mutex<StdRng>,
|
||||
executor: Executor,
|
||||
notification_kinds_by_id: HashMap<NotificationKindId, &'static str>,
|
||||
@@ -74,6 +75,7 @@ impl Database {
|
||||
options: options.clone(),
|
||||
pool: sea_orm::Database::connect(options).await?,
|
||||
rooms: DashMap::with_capacity(16384),
|
||||
projects: DashMap::with_capacity(16384),
|
||||
rng: Mutex::new(StdRng::seed_from_u64(0)),
|
||||
notification_kinds_by_id: HashMap::default(),
|
||||
notification_kinds_by_name: HashMap::default(),
|
||||
@@ -86,6 +88,7 @@ impl Database {
|
||||
#[cfg(test)]
|
||||
pub fn reset(&self) {
|
||||
self.rooms.clear();
|
||||
self.projects.clear();
|
||||
}
|
||||
|
||||
/// Runs the database migrations.
|
||||
@@ -190,7 +193,10 @@ impl Database {
|
||||
}
|
||||
|
||||
/// The same as room_transaction, but if you need to only optionally return a Room.
|
||||
async fn optional_room_transaction<F, Fut, T>(&self, f: F) -> Result<Option<RoomGuard<T>>>
|
||||
async fn optional_room_transaction<F, Fut, T>(
|
||||
&self,
|
||||
f: F,
|
||||
) -> Result<Option<TransactionGuard<T>>>
|
||||
where
|
||||
F: Send + Fn(TransactionHandle) -> Fut,
|
||||
Fut: Send + Future<Output = Result<Option<(RoomId, T)>>>,
|
||||
@@ -205,7 +211,7 @@ impl Database {
|
||||
let _guard = lock.lock_owned().await;
|
||||
match tx.commit().await.map_err(Into::into) {
|
||||
Ok(()) => {
|
||||
return Ok(Some(RoomGuard {
|
||||
return Ok(Some(TransactionGuard {
|
||||
data,
|
||||
_guard,
|
||||
_not_send: PhantomData,
|
||||
@@ -240,10 +246,63 @@ impl Database {
|
||||
self.run(body).await
|
||||
}
|
||||
|
||||
async fn project_transaction<F, Fut, T>(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
f: F,
|
||||
) -> Result<TransactionGuard<T>>
|
||||
where
|
||||
F: Send + Fn(TransactionHandle) -> Fut,
|
||||
Fut: Send + Future<Output = Result<T>>,
|
||||
{
|
||||
let room_id = Database::room_id_for_project(&self, project_id).await?;
|
||||
let body = async {
|
||||
let mut i = 0;
|
||||
loop {
|
||||
let lock = if let Some(room_id) = room_id {
|
||||
self.rooms.entry(room_id).or_default().clone()
|
||||
} else {
|
||||
self.projects.entry(project_id).or_default().clone()
|
||||
};
|
||||
let _guard = lock.lock_owned().await;
|
||||
let (tx, result) = self.with_transaction(&f).await?;
|
||||
match result {
|
||||
Ok(data) => match tx.commit().await.map_err(Into::into) {
|
||||
Ok(()) => {
|
||||
return Ok(TransactionGuard {
|
||||
data,
|
||||
_guard,
|
||||
_not_send: PhantomData,
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
if !self.retry_on_serialization_error(&error, i).await {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
tx.rollback().await?;
|
||||
if !self.retry_on_serialization_error(&error, i).await {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
};
|
||||
|
||||
self.run(body).await
|
||||
}
|
||||
|
||||
/// room_transaction runs the block in a transaction. It returns a RoomGuard, that keeps
|
||||
/// the database locked until it is dropped. This ensures that updates sent to clients are
|
||||
/// properly serialized with respect to database changes.
|
||||
async fn room_transaction<F, Fut, T>(&self, room_id: RoomId, f: F) -> Result<RoomGuard<T>>
|
||||
async fn room_transaction<F, Fut, T>(
|
||||
&self,
|
||||
room_id: RoomId,
|
||||
f: F,
|
||||
) -> Result<TransactionGuard<T>>
|
||||
where
|
||||
F: Send + Fn(TransactionHandle) -> Fut,
|
||||
Fut: Send + Future<Output = Result<T>>,
|
||||
@@ -257,7 +316,7 @@ impl Database {
|
||||
match result {
|
||||
Ok(data) => match tx.commit().await.map_err(Into::into) {
|
||||
Ok(()) => {
|
||||
return Ok(RoomGuard {
|
||||
return Ok(TransactionGuard {
|
||||
data,
|
||||
_guard,
|
||||
_not_send: PhantomData,
|
||||
@@ -399,15 +458,16 @@ impl Deref for TransactionHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`RoomGuard`] keeps a database transaction alive until it is dropped.
|
||||
/// so that updates to rooms are serialized.
|
||||
pub struct RoomGuard<T> {
|
||||
/// [`TransactionGuard`] keeps a database transaction alive until it is dropped.
|
||||
/// It wraps data that depends on the state of the database and prevents an additional
|
||||
/// transaction from starting that would invalidate that data.
|
||||
pub struct TransactionGuard<T> {
|
||||
data: T,
|
||||
_guard: OwnedMutexGuard<()>,
|
||||
_not_send: PhantomData<Rc<()>>,
|
||||
}
|
||||
|
||||
impl<T> Deref for RoomGuard<T> {
|
||||
impl<T> Deref for TransactionGuard<T> {
|
||||
type Target = T;
|
||||
|
||||
fn deref(&self) -> &T {
|
||||
@@ -415,13 +475,13 @@ impl<T> Deref for RoomGuard<T> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DerefMut for RoomGuard<T> {
|
||||
impl<T> DerefMut for TransactionGuard<T> {
|
||||
fn deref_mut(&mut self) -> &mut T {
|
||||
&mut self.data
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> RoomGuard<T> {
|
||||
impl<T> TransactionGuard<T> {
|
||||
/// Returns the inner value of the guard.
|
||||
pub fn into_inner(self) -> T {
|
||||
self.data
|
||||
@@ -518,6 +578,7 @@ pub struct MembershipUpdated {
|
||||
|
||||
/// The result of setting a member's role.
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum SetMemberRoleResult {
|
||||
InviteUpdated(Channel),
|
||||
MembershipUpdated(MembershipUpdated),
|
||||
@@ -594,6 +655,8 @@ pub struct ChannelsForUser {
|
||||
pub channel_memberships: Vec<channel_member::Model>,
|
||||
pub channel_participants: HashMap<ChannelId, Vec<UserId>>,
|
||||
pub hosted_projects: Vec<proto::HostedProject>,
|
||||
pub dev_servers: Vec<dev_server::Model>,
|
||||
pub remote_projects: Vec<proto::RemoteProject>,
|
||||
|
||||
pub observed_buffer_versions: Vec<proto::ChannelBufferVersion>,
|
||||
pub observed_channel_messages: Vec<proto::ChannelMessageId>,
|
||||
@@ -635,6 +698,30 @@ pub struct RejoinedProject {
|
||||
pub language_servers: Vec<proto::LanguageServer>,
|
||||
}
|
||||
|
||||
impl RejoinedProject {
|
||||
pub fn to_proto(&self) -> proto::RejoinedProject {
|
||||
proto::RejoinedProject {
|
||||
id: self.id.to_proto(),
|
||||
worktrees: self
|
||||
.worktrees
|
||||
.iter()
|
||||
.map(|worktree| proto::WorktreeMetadata {
|
||||
id: worktree.id,
|
||||
root_name: worktree.root_name.clone(),
|
||||
visible: worktree.visible,
|
||||
abs_path: worktree.abs_path.clone(),
|
||||
})
|
||||
.collect(),
|
||||
collaborators: self
|
||||
.collaborators
|
||||
.iter()
|
||||
.map(|collaborator| collaborator.to_proto())
|
||||
.collect(),
|
||||
language_servers: self.language_servers.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RejoinedWorktree {
|
||||
pub id: u64,
|
||||
|
||||
@@ -84,6 +84,7 @@ id_type!(NotificationId);
|
||||
id_type!(NotificationKindId);
|
||||
id_type!(ProjectCollaboratorId);
|
||||
id_type!(ProjectId);
|
||||
id_type!(RemoteProjectId);
|
||||
id_type!(ReplicaId);
|
||||
id_type!(RoomId);
|
||||
id_type!(RoomParticipantId);
|
||||
@@ -270,3 +271,18 @@ impl Into<i32> for ChannelVisibility {
|
||||
proto.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, PartialEq)]
|
||||
pub enum PrincipalId {
|
||||
UserId(UserId),
|
||||
DevServerId(DevServerId),
|
||||
}
|
||||
|
||||
/// Indicate whether a [Buffer] has permissions to edit.
|
||||
#[derive(PartialEq, Clone, Copy, Debug)]
|
||||
pub enum Capability {
|
||||
/// The buffer is a mutable replica.
|
||||
ReadWrite,
|
||||
/// The buffer is a read-only replica.
|
||||
ReadOnly,
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ pub mod channels;
|
||||
pub mod contacts;
|
||||
pub mod contributors;
|
||||
pub mod dev_servers;
|
||||
pub mod embeddings;
|
||||
pub mod extensions;
|
||||
pub mod hosted_projects;
|
||||
pub mod messages;
|
||||
pub mod notifications;
|
||||
pub mod projects;
|
||||
pub mod rate_buckets;
|
||||
pub mod remote_projects;
|
||||
pub mod rooms;
|
||||
pub mod servers;
|
||||
pub mod users;
|
||||
|
||||
@@ -529,127 +529,133 @@ impl Database {
|
||||
ancestor_channel: Option<&channel::Model>,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<ChannelsForUser> {
|
||||
let mut filter = channel_member::Column::UserId
|
||||
.eq(user_id)
|
||||
.and(channel_member::Column::Accepted.eq(true));
|
||||
// let mut filter = channel_member::Column::UserId
|
||||
// .eq(user_id)
|
||||
// .and(channel_member::Column::Accepted.eq(true));
|
||||
|
||||
if let Some(ancestor) = ancestor_channel {
|
||||
filter = filter.and(channel_member::Column::ChannelId.eq(ancestor.root_id()));
|
||||
}
|
||||
// if let Some(ancestor) = ancestor_channel {
|
||||
// filter = filter.and(channel_member::Column::ChannelId.eq(ancestor.root_id()));
|
||||
// }
|
||||
|
||||
let channel_memberships = channel_member::Entity::find()
|
||||
.filter(filter)
|
||||
.all(tx)
|
||||
.await?;
|
||||
// let channel_memberships = channel_member::Entity::find()
|
||||
// .filter(filter)
|
||||
// .all(tx)
|
||||
// .await?;
|
||||
|
||||
let channels = channel::Entity::find()
|
||||
.filter(channel::Column::Id.is_in(channel_memberships.iter().map(|m| m.channel_id)))
|
||||
.all(tx)
|
||||
.await?;
|
||||
// let channels = channel::Entity::find()
|
||||
// .filter(channel::Column::Id.is_in(channel_memberships.iter().map(|m| m.channel_id)))
|
||||
// .all(tx)
|
||||
// .await?;
|
||||
|
||||
let mut descendants = self
|
||||
.get_channel_descendants_excluding_self(channels.iter(), tx)
|
||||
.await?;
|
||||
// let mut descendants = self
|
||||
// .get_channel_descendants_excluding_self(channels.iter(), tx)
|
||||
// .await?;
|
||||
|
||||
for channel in channels {
|
||||
if let Err(ix) = descendants.binary_search_by_key(&channel.path(), |c| c.path()) {
|
||||
descendants.insert(ix, channel);
|
||||
}
|
||||
}
|
||||
// for channel in channels {
|
||||
// if let Err(ix) = descendants.binary_search_by_key(&channel.path(), |c| c.path()) {
|
||||
// descendants.insert(ix, channel);
|
||||
// }
|
||||
// }
|
||||
|
||||
let roles_by_channel_id = channel_memberships
|
||||
.iter()
|
||||
.map(|membership| (membership.channel_id, membership.role))
|
||||
.collect::<HashMap<_, _>>();
|
||||
// let roles_by_channel_id = channel_memberships
|
||||
// .iter()
|
||||
// .map(|membership| (membership.channel_id, membership.role))
|
||||
// .collect::<HashMap<_, _>>();
|
||||
|
||||
let channels: Vec<Channel> = descendants
|
||||
.into_iter()
|
||||
.filter_map(|channel| {
|
||||
let parent_role = roles_by_channel_id.get(&channel.root_id())?;
|
||||
if parent_role.can_see_channel(channel.visibility) {
|
||||
Some(Channel::from_model(channel))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// let channels: Vec<Channel> = descendants
|
||||
// .into_iter()
|
||||
// .filter_map(|channel| {
|
||||
// let parent_role = roles_by_channel_id.get(&channel.root_id())?;
|
||||
// if parent_role.can_see_channel(channel.visibility) {
|
||||
// Some(Channel::from_model(channel))
|
||||
// } else {
|
||||
// None
|
||||
// }
|
||||
// })
|
||||
// .collect();
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryUserIdsAndChannelIds {
|
||||
ChannelId,
|
||||
UserId,
|
||||
}
|
||||
// #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
// enum QueryUserIdsAndChannelIds {
|
||||
// ChannelId,
|
||||
// UserId,
|
||||
// }
|
||||
|
||||
let mut channel_participants: HashMap<ChannelId, Vec<UserId>> = HashMap::default();
|
||||
{
|
||||
let mut rows = room_participant::Entity::find()
|
||||
.inner_join(room::Entity)
|
||||
.filter(room::Column::ChannelId.is_in(channels.iter().map(|c| c.id)))
|
||||
.select_only()
|
||||
.column(room::Column::ChannelId)
|
||||
.column(room_participant::Column::UserId)
|
||||
.into_values::<_, QueryUserIdsAndChannelIds>()
|
||||
.stream(tx)
|
||||
.await?;
|
||||
while let Some(row) = rows.next().await {
|
||||
let row: (ChannelId, UserId) = row?;
|
||||
channel_participants.entry(row.0).or_default().push(row.1)
|
||||
}
|
||||
}
|
||||
// let mut channel_participants: HashMap<ChannelId, Vec<UserId>> = HashMap::default();
|
||||
// {
|
||||
// let mut rows = room_participant::Entity::find()
|
||||
// .inner_join(room::Entity)
|
||||
// .filter(room::Column::ChannelId.is_in(channels.iter().map(|c| c.id)))
|
||||
// .select_only()
|
||||
// .column(room::Column::ChannelId)
|
||||
// .column(room_participant::Column::UserId)
|
||||
// .into_values::<_, QueryUserIdsAndChannelIds>()
|
||||
// .stream(tx)
|
||||
// .await?;
|
||||
// while let Some(row) = rows.next().await {
|
||||
// let row: (ChannelId, UserId) = row?;
|
||||
// channel_participants.entry(row.0).or_default().push(row.1)
|
||||
// }
|
||||
// }
|
||||
|
||||
let channel_ids = channels.iter().map(|c| c.id).collect::<Vec<_>>();
|
||||
// let channel_ids = channels.iter().map(|c| c.id).collect::<Vec<_>>();
|
||||
|
||||
let mut channel_ids_by_buffer_id = HashMap::default();
|
||||
let mut latest_buffer_versions: Vec<ChannelBufferVersion> = vec![];
|
||||
let mut rows = buffer::Entity::find()
|
||||
.filter(buffer::Column::ChannelId.is_in(channel_ids.iter().copied()))
|
||||
.stream(tx)
|
||||
.await?;
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
channel_ids_by_buffer_id.insert(row.id, row.channel_id);
|
||||
latest_buffer_versions.push(ChannelBufferVersion {
|
||||
channel_id: row.channel_id.0 as u64,
|
||||
epoch: row.latest_operation_epoch.unwrap_or_default() as u64,
|
||||
version: if let Some((latest_lamport_timestamp, latest_replica_id)) = row
|
||||
.latest_operation_lamport_timestamp
|
||||
.zip(row.latest_operation_replica_id)
|
||||
{
|
||||
vec![VectorClockEntry {
|
||||
timestamp: latest_lamport_timestamp as u32,
|
||||
replica_id: latest_replica_id as u32,
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
});
|
||||
}
|
||||
drop(rows);
|
||||
// let mut channel_ids_by_buffer_id = HashMap::default();
|
||||
// let mut latest_buffer_versions: Vec<ChannelBufferVersion> = vec![];
|
||||
// let mut rows = buffer::Entity::find()
|
||||
// .filter(buffer::Column::ChannelId.is_in(channel_ids.iter().copied()))
|
||||
// .stream(tx)
|
||||
// .await?;
|
||||
// while let Some(row) = rows.next().await {
|
||||
// let row = row?;
|
||||
// channel_ids_by_buffer_id.insert(row.id, row.channel_id);
|
||||
// latest_buffer_versions.push(ChannelBufferVersion {
|
||||
// channel_id: row.channel_id.0 as u64,
|
||||
// epoch: row.latest_operation_epoch.unwrap_or_default() as u64,
|
||||
// version: if let Some((latest_lamport_timestamp, latest_replica_id)) = row
|
||||
// .latest_operation_lamport_timestamp
|
||||
// .zip(row.latest_operation_replica_id)
|
||||
// {
|
||||
// vec![VectorClockEntry {
|
||||
// timestamp: latest_lamport_timestamp as u32,
|
||||
// replica_id: latest_replica_id as u32,
|
||||
// }]
|
||||
// } else {
|
||||
// vec![]
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// drop(rows);
|
||||
|
||||
let latest_channel_messages = self.latest_channel_messages(&channel_ids, tx).await?;
|
||||
// let latest_channel_messages = self.latest_channel_messages(&channel_ids, tx).await?;
|
||||
|
||||
let observed_buffer_versions = self
|
||||
.observed_channel_buffer_changes(&channel_ids_by_buffer_id, user_id, tx)
|
||||
.await?;
|
||||
// let observed_buffer_versions = self
|
||||
// .observed_channel_buffer_changes(&channel_ids_by_buffer_id, user_id, tx)
|
||||
// .await?;
|
||||
|
||||
let observed_channel_messages = self
|
||||
.observed_channel_messages(&channel_ids, user_id, tx)
|
||||
.await?;
|
||||
// let observed_channel_messages = self
|
||||
// .observed_channel_messages(&channel_ids, user_id, tx)
|
||||
// .await?;
|
||||
|
||||
let hosted_projects = self
|
||||
.get_hosted_projects(&channel_ids, &roles_by_channel_id, tx)
|
||||
.await?;
|
||||
// let hosted_projects = self
|
||||
// .get_hosted_projects(&channel_ids, &roles_by_channel_id, tx)
|
||||
// .await?;
|
||||
|
||||
Ok(ChannelsForUser {
|
||||
channel_memberships,
|
||||
channels,
|
||||
hosted_projects,
|
||||
channel_participants,
|
||||
latest_buffer_versions,
|
||||
latest_channel_messages,
|
||||
observed_buffer_versions,
|
||||
observed_channel_messages,
|
||||
})
|
||||
// let dev_servers = self.get_dev_servers(&channel_ids, tx).await?;
|
||||
// let remote_projects = self.get_remote_projects(&channel_ids, tx).await?;
|
||||
|
||||
// Ok(ChannelsForUser {
|
||||
// channel_memberships,
|
||||
// channels,
|
||||
// hosted_projects,
|
||||
// dev_servers,
|
||||
// remote_projects,
|
||||
// channel_participants,
|
||||
// latest_buffer_versions,
|
||||
// latest_channel_messages,
|
||||
// observed_buffer_versions,
|
||||
// observed_channel_messages,
|
||||
// })
|
||||
Err(anyhow!("not implemented"))
|
||||
}
|
||||
|
||||
/// Sets the role for the specified channel member.
|
||||
|
||||
@@ -3,88 +3,89 @@ use super::*;
|
||||
impl Database {
|
||||
/// Retrieves the contacts for the user with the given ID.
|
||||
pub async fn get_contacts(&self, user_id: UserId) -> Result<Vec<Contact>> {
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct ContactWithUserBusyStatuses {
|
||||
user_id_a: UserId,
|
||||
user_id_b: UserId,
|
||||
a_to_b: bool,
|
||||
accepted: bool,
|
||||
user_a_busy: bool,
|
||||
user_b_busy: bool,
|
||||
}
|
||||
Ok(vec![])
|
||||
// #[derive(Debug, FromQueryResult)]
|
||||
// struct ContactWithUserBusyStatuses {
|
||||
// user_id_a: UserId,
|
||||
// user_id_b: UserId,
|
||||
// a_to_b: bool,
|
||||
// accepted: bool,
|
||||
// user_a_busy: bool,
|
||||
// user_b_busy: bool,
|
||||
// }
|
||||
|
||||
self.transaction(|tx| async move {
|
||||
let user_a_participant = Alias::new("user_a_participant");
|
||||
let user_b_participant = Alias::new("user_b_participant");
|
||||
let mut db_contacts = contact::Entity::find()
|
||||
.column_as(
|
||||
Expr::col((user_a_participant.clone(), room_participant::Column::Id))
|
||||
.is_not_null(),
|
||||
"user_a_busy",
|
||||
)
|
||||
.column_as(
|
||||
Expr::col((user_b_participant.clone(), room_participant::Column::Id))
|
||||
.is_not_null(),
|
||||
"user_b_busy",
|
||||
)
|
||||
.filter(
|
||||
contact::Column::UserIdA
|
||||
.eq(user_id)
|
||||
.or(contact::Column::UserIdB.eq(user_id)),
|
||||
)
|
||||
.join_as(
|
||||
JoinType::LeftJoin,
|
||||
contact::Relation::UserARoomParticipant.def(),
|
||||
user_a_participant,
|
||||
)
|
||||
.join_as(
|
||||
JoinType::LeftJoin,
|
||||
contact::Relation::UserBRoomParticipant.def(),
|
||||
user_b_participant,
|
||||
)
|
||||
.into_model::<ContactWithUserBusyStatuses>()
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
// self.transaction(|tx| async move {
|
||||
// let user_a_participant = Alias::new("user_a_participant");
|
||||
// let user_b_participant = Alias::new("user_b_participant");
|
||||
// let mut db_contacts = contact::Entity::find()
|
||||
// .column_as(
|
||||
// Expr::col((user_a_participant.clone(), room_participant::Column::Id))
|
||||
// .is_not_null(),
|
||||
// "user_a_busy",
|
||||
// )
|
||||
// .column_as(
|
||||
// Expr::col((user_b_participant.clone(), room_participant::Column::Id))
|
||||
// .is_not_null(),
|
||||
// "user_b_busy",
|
||||
// )
|
||||
// .filter(
|
||||
// contact::Column::UserIdA
|
||||
// .eq(user_id)
|
||||
// .or(contact::Column::UserIdB.eq(user_id)),
|
||||
// )
|
||||
// .join_as(
|
||||
// JoinType::LeftJoin,
|
||||
// contact::Relation::UserARoomParticipant.def(),
|
||||
// user_a_participant,
|
||||
// )
|
||||
// .join_as(
|
||||
// JoinType::LeftJoin,
|
||||
// contact::Relation::UserBRoomParticipant.def(),
|
||||
// user_b_participant,
|
||||
// )
|
||||
// .into_model::<ContactWithUserBusyStatuses>()
|
||||
// .stream(&*tx)
|
||||
// .await?;
|
||||
|
||||
let mut contacts = Vec::new();
|
||||
while let Some(db_contact) = db_contacts.next().await {
|
||||
let db_contact = db_contact?;
|
||||
if db_contact.user_id_a == user_id {
|
||||
if db_contact.accepted {
|
||||
contacts.push(Contact::Accepted {
|
||||
user_id: db_contact.user_id_b,
|
||||
busy: db_contact.user_b_busy,
|
||||
});
|
||||
} else if db_contact.a_to_b {
|
||||
contacts.push(Contact::Outgoing {
|
||||
user_id: db_contact.user_id_b,
|
||||
})
|
||||
} else {
|
||||
contacts.push(Contact::Incoming {
|
||||
user_id: db_contact.user_id_b,
|
||||
});
|
||||
}
|
||||
} else if db_contact.accepted {
|
||||
contacts.push(Contact::Accepted {
|
||||
user_id: db_contact.user_id_a,
|
||||
busy: db_contact.user_a_busy,
|
||||
});
|
||||
} else if db_contact.a_to_b {
|
||||
contacts.push(Contact::Incoming {
|
||||
user_id: db_contact.user_id_a,
|
||||
});
|
||||
} else {
|
||||
contacts.push(Contact::Outgoing {
|
||||
user_id: db_contact.user_id_a,
|
||||
});
|
||||
}
|
||||
}
|
||||
// let mut contacts = Vec::new();
|
||||
// while let Some(db_contact) = db_contacts.next().await {
|
||||
// let db_contact = db_contact?;
|
||||
// if db_contact.user_id_a == user_id {
|
||||
// if db_contact.accepted {
|
||||
// contacts.push(Contact::Accepted {
|
||||
// user_id: db_contact.user_id_b,
|
||||
// busy: db_contact.user_b_busy,
|
||||
// });
|
||||
// } else if db_contact.a_to_b {
|
||||
// contacts.push(Contact::Outgoing {
|
||||
// user_id: db_contact.user_id_b,
|
||||
// })
|
||||
// } else {
|
||||
// contacts.push(Contact::Incoming {
|
||||
// user_id: db_contact.user_id_b,
|
||||
// });
|
||||
// }
|
||||
// } else if db_contact.accepted {
|
||||
// contacts.push(Contact::Accepted {
|
||||
// user_id: db_contact.user_id_a,
|
||||
// busy: db_contact.user_a_busy,
|
||||
// });
|
||||
// } else if db_contact.a_to_b {
|
||||
// contacts.push(Contact::Incoming {
|
||||
// user_id: db_contact.user_id_a,
|
||||
// });
|
||||
// } else {
|
||||
// contacts.push(Contact::Outgoing {
|
||||
// user_id: db_contact.user_id_a,
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
contacts.sort_unstable_by_key(|contact| contact.user_id());
|
||||
// contacts.sort_unstable_by_key(|contact| contact.user_id());
|
||||
|
||||
Ok(contacts)
|
||||
})
|
||||
.await
|
||||
// Ok(contacts)
|
||||
// })
|
||||
// .await
|
||||
}
|
||||
|
||||
/// Returns whether the given user is a busy (on a call).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use sea_orm::EntityTrait;
|
||||
use sea_orm::{ActiveValue, ColumnTrait, DatabaseTransaction, EntityTrait, QueryFilter};
|
||||
|
||||
use super::{dev_server, Database, DevServerId};
|
||||
use super::{channel, dev_server, ChannelId, Database, DevServerId, UserId};
|
||||
|
||||
impl Database {
|
||||
pub async fn get_dev_server(
|
||||
@@ -15,4 +15,42 @@ impl Database {
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_dev_servers(
|
||||
&self,
|
||||
channel_ids: &Vec<ChannelId>,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> crate::Result<Vec<dev_server::Model>> {
|
||||
let servers = dev_server::Entity::find()
|
||||
.filter(dev_server::Column::ChannelId.is_in(channel_ids.iter().map(|id| id.0)))
|
||||
.all(tx)
|
||||
.await?;
|
||||
Ok(servers)
|
||||
}
|
||||
|
||||
pub async fn create_dev_server(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
name: &str,
|
||||
hashed_access_token: &str,
|
||||
user_id: UserId,
|
||||
) -> crate::Result<(channel::Model, dev_server::Model)> {
|
||||
self.transaction(|tx| async move {
|
||||
let channel = self.get_channel_internal(channel_id, &tx).await?;
|
||||
self.check_user_is_channel_admin(&channel, user_id, &tx)
|
||||
.await?;
|
||||
|
||||
let dev_server = dev_server::Entity::insert(dev_server::ActiveModel {
|
||||
id: ActiveValue::NotSet,
|
||||
hashed_token: ActiveValue::Set(hashed_access_token.to_string()),
|
||||
channel_id: ActiveValue::Set(channel_id),
|
||||
name: ActiveValue::Set(name.to_string()),
|
||||
})
|
||||
.exec_with_returning(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok((channel, dev_server))
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
94
crates/collab/src/db/queries/embeddings.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use super::*;
|
||||
use time::Duration;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
impl Database {
|
||||
pub async fn get_embeddings(
|
||||
&self,
|
||||
model: &str,
|
||||
digests: &[Vec<u8>],
|
||||
) -> Result<HashMap<Vec<u8>, Vec<f32>>> {
|
||||
self.weak_transaction(|tx| async move {
|
||||
let embeddings = {
|
||||
let mut db_embeddings = embedding::Entity::find()
|
||||
.filter(
|
||||
embedding::Column::Model.eq(model).and(
|
||||
embedding::Column::Digest
|
||||
.is_in(digests.iter().map(|digest| digest.as_slice())),
|
||||
),
|
||||
)
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut embeddings = HashMap::default();
|
||||
while let Some(db_embedding) = db_embeddings.next().await {
|
||||
let db_embedding = db_embedding?;
|
||||
embeddings.insert(db_embedding.digest, db_embedding.dimensions);
|
||||
}
|
||||
embeddings
|
||||
};
|
||||
|
||||
if !embeddings.is_empty() {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let retrieved_at = PrimitiveDateTime::new(now.date(), now.time());
|
||||
|
||||
embedding::Entity::update_many()
|
||||
.filter(
|
||||
embedding::Column::Digest
|
||||
.is_in(embeddings.keys().map(|digest| digest.as_slice())),
|
||||
)
|
||||
.col_expr(embedding::Column::RetrievedAt, Expr::value(retrieved_at))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(embeddings)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn save_embeddings(
|
||||
&self,
|
||||
model: &str,
|
||||
embeddings: &HashMap<Vec<u8>, Vec<f32>>,
|
||||
) -> Result<()> {
|
||||
self.weak_transaction(|tx| async move {
|
||||
embedding::Entity::insert_many(embeddings.iter().map(|(digest, dimensions)| {
|
||||
let now_offset_datetime = OffsetDateTime::now_utc();
|
||||
let retrieved_at =
|
||||
PrimitiveDateTime::new(now_offset_datetime.date(), now_offset_datetime.time());
|
||||
|
||||
embedding::ActiveModel {
|
||||
model: ActiveValue::set(model.to_string()),
|
||||
digest: ActiveValue::set(digest.clone()),
|
||||
dimensions: ActiveValue::set(dimensions.clone()),
|
||||
retrieved_at: ActiveValue::set(retrieved_at),
|
||||
}
|
||||
}))
|
||||
.on_conflict(
|
||||
OnConflict::columns([embedding::Column::Model, embedding::Column::Digest])
|
||||
.do_nothing()
|
||||
.to_owned(),
|
||||
)
|
||||
.exec_without_returning(&*tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn purge_old_embeddings(&self) -> Result<()> {
|
||||
self.weak_transaction(|tx| async move {
|
||||
embedding::Entity::delete_many()
|
||||
.filter(
|
||||
embedding::Column::RetrievedAt
|
||||
.lte(OffsetDateTime::now_utc() - Duration::days(60)),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -432,53 +432,53 @@ impl Database {
|
||||
channel_ids: &[ChannelId],
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Vec<proto::ChannelMessageId>> {
|
||||
let mut values = String::new();
|
||||
for id in channel_ids {
|
||||
if !values.is_empty() {
|
||||
values.push_str(", ");
|
||||
}
|
||||
write!(&mut values, "({})", id).unwrap();
|
||||
}
|
||||
// let mut values = String::new();
|
||||
// for id in channel_ids {
|
||||
// if !values.is_empty() {
|
||||
// values.push_str(", ");
|
||||
// }
|
||||
// write!(&mut values, "({})", id).unwrap();
|
||||
// }
|
||||
|
||||
if values.is_empty() {
|
||||
return Ok(Vec::default());
|
||||
}
|
||||
// if values.is_empty() {
|
||||
// return Ok(Vec::default());
|
||||
// }
|
||||
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT
|
||||
*
|
||||
FROM (
|
||||
SELECT
|
||||
*,
|
||||
row_number() OVER (
|
||||
PARTITION BY channel_id
|
||||
ORDER BY id DESC
|
||||
) as row_number
|
||||
FROM channel_messages
|
||||
WHERE
|
||||
channel_id in ({values})
|
||||
) AS messages
|
||||
WHERE
|
||||
row_number = 1
|
||||
"#,
|
||||
);
|
||||
// let sql = format!(
|
||||
// r#"
|
||||
// SELECT
|
||||
// *
|
||||
// FROM (
|
||||
// SELECT
|
||||
// *,
|
||||
// row_number() OVER (
|
||||
// PARTITION BY channel_id
|
||||
// ORDER BY id DESC
|
||||
// ) as row_number
|
||||
// FROM channel_messages
|
||||
// WHERE
|
||||
// channel_id in ({values})
|
||||
// ) AS messages
|
||||
// WHERE
|
||||
// row_number = 1
|
||||
// "#,
|
||||
// );
|
||||
|
||||
let stmt = Statement::from_string(self.pool.get_database_backend(), sql);
|
||||
let mut last_messages = channel_message::Model::find_by_statement(stmt)
|
||||
.stream(tx)
|
||||
.await?;
|
||||
// let stmt = Statement::from_string(self.pool.get_database_backend(), sql);
|
||||
// let mut last_messages = channel_message::Model::find_by_statement(stmt)
|
||||
// .stream(tx)
|
||||
// .await?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
while let Some(result) = last_messages.next().await {
|
||||
let message = result?;
|
||||
results.push(proto::ChannelMessageId {
|
||||
channel_id: message.channel_id.to_proto(),
|
||||
message_id: message.id.to_proto(),
|
||||
});
|
||||
}
|
||||
// let mut results = Vec::new();
|
||||
// while let Some(result) = last_messages.next().await {
|
||||
// let message = result?;
|
||||
// results.push(proto::ChannelMessageId {
|
||||
// channel_id: message.channel_id.to_proto(),
|
||||
// message_id: message.id.to_proto(),
|
||||
// });
|
||||
// }
|
||||
|
||||
Ok(results)
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
fn get_notification_kind_id_by_name(&self, notification_kind: &str) -> Option<i32> {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use util::ResultExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl Database {
|
||||
@@ -28,7 +30,7 @@ impl Database {
|
||||
room_id: RoomId,
|
||||
connection: ConnectionId,
|
||||
worktrees: &[proto::WorktreeMetadata],
|
||||
) -> Result<RoomGuard<(ProjectId, proto::Room)>> {
|
||||
) -> Result<TransactionGuard<(ProjectId, proto::Room)>> {
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let participant = room_participant::Entity::find()
|
||||
.filter(
|
||||
@@ -65,6 +67,7 @@ impl Database {
|
||||
))),
|
||||
id: ActiveValue::NotSet,
|
||||
hosted_project_id: ActiveValue::Set(None),
|
||||
remote_project_id: ActiveValue::Set(None),
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
@@ -108,20 +111,22 @@ impl Database {
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
connection: ConnectionId,
|
||||
) -> Result<RoomGuard<(proto::Room, Vec<ConnectionId>)>> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
) -> Result<TransactionGuard<(Option<proto::Room>, Vec<ConnectionId>)>> {
|
||||
self.project_transaction(project_id, |tx| async move {
|
||||
let guest_connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
|
||||
|
||||
let project = project::Entity::find_by_id(project_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("project not found"))?;
|
||||
if project.host_connection()? == connection {
|
||||
let room = if let Some(room_id) = project.room_id {
|
||||
Some(self.get_room(room_id, &tx).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
project::Entity::delete(project.into_active_model())
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
let room = self.get_room(room_id, &tx).await?;
|
||||
Ok((room, guest_connection_ids))
|
||||
} else {
|
||||
Err(anyhow!("cannot unshare a project hosted by another user"))?
|
||||
@@ -136,9 +141,8 @@ impl Database {
|
||||
project_id: ProjectId,
|
||||
connection: ConnectionId,
|
||||
worktrees: &[proto::WorktreeMetadata],
|
||||
) -> Result<RoomGuard<(proto::Room, Vec<ConnectionId>)>> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
) -> Result<TransactionGuard<(Option<proto::Room>, Vec<ConnectionId>)>> {
|
||||
self.project_transaction(project_id, |tx| async move {
|
||||
let project = project::Entity::find_by_id(project_id)
|
||||
.filter(
|
||||
Condition::all()
|
||||
@@ -154,12 +158,14 @@ impl Database {
|
||||
self.update_project_worktrees(project.id, worktrees, &tx)
|
||||
.await?;
|
||||
|
||||
let room_id = project
|
||||
.room_id
|
||||
.ok_or_else(|| anyhow!("project not in a room"))?;
|
||||
|
||||
let guest_connection_ids = self.project_guest_connection_ids(project.id, &tx).await?;
|
||||
let room = self.get_room(room_id, &tx).await?;
|
||||
|
||||
let room = if let Some(room_id) = project.room_id {
|
||||
Some(self.get_room(room_id, &tx).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok((room, guest_connection_ids))
|
||||
})
|
||||
.await
|
||||
@@ -204,11 +210,10 @@ impl Database {
|
||||
&self,
|
||||
update: &proto::UpdateWorktree,
|
||||
connection: ConnectionId,
|
||||
) -> Result<RoomGuard<Vec<ConnectionId>>> {
|
||||
) -> Result<TransactionGuard<Vec<ConnectionId>>> {
|
||||
let project_id = ProjectId::from_proto(update.project_id);
|
||||
let worktree_id = update.worktree_id as i64;
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
self.project_transaction(project_id, |tx| async move {
|
||||
// Ensure the update comes from the host.
|
||||
let _project = project::Entity::find_by_id(project_id)
|
||||
.filter(
|
||||
@@ -360,11 +365,10 @@ impl Database {
|
||||
&self,
|
||||
update: &proto::UpdateDiagnosticSummary,
|
||||
connection: ConnectionId,
|
||||
) -> Result<RoomGuard<Vec<ConnectionId>>> {
|
||||
) -> Result<TransactionGuard<Vec<ConnectionId>>> {
|
||||
let project_id = ProjectId::from_proto(update.project_id);
|
||||
let worktree_id = update.worktree_id as i64;
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
self.project_transaction(project_id, |tx| async move {
|
||||
let summary = update
|
||||
.summary
|
||||
.as_ref()
|
||||
@@ -415,10 +419,9 @@ impl Database {
|
||||
&self,
|
||||
update: &proto::StartLanguageServer,
|
||||
connection: ConnectionId,
|
||||
) -> Result<RoomGuard<Vec<ConnectionId>>> {
|
||||
) -> Result<TransactionGuard<Vec<ConnectionId>>> {
|
||||
let project_id = ProjectId::from_proto(update.project_id);
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
self.project_transaction(project_id, |tx| async move {
|
||||
let server = update
|
||||
.server
|
||||
.as_ref()
|
||||
@@ -461,10 +464,9 @@ impl Database {
|
||||
&self,
|
||||
update: &proto::UpdateWorktreeSettings,
|
||||
connection: ConnectionId,
|
||||
) -> Result<RoomGuard<Vec<ConnectionId>>> {
|
||||
) -> Result<TransactionGuard<Vec<ConnectionId>>> {
|
||||
let project_id = ProjectId::from_proto(update.project_id);
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
self.project_transaction(project_id, |tx| async move {
|
||||
// Ensure the update comes from the host.
|
||||
let project = project::Entity::find_by_id(project_id)
|
||||
.one(&*tx)
|
||||
@@ -542,46 +544,36 @@ impl Database {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_project(&self, id: ProjectId) -> Result<project::Model> {
|
||||
self.transaction(|tx| async move {
|
||||
Ok(project::Entity::find_by_id(id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such project"))?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Adds the given connection to the specified project
|
||||
/// in the current room.
|
||||
pub async fn join_project_in_room(
|
||||
pub async fn join_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
connection: ConnectionId,
|
||||
) -> Result<RoomGuard<(Project, ReplicaId)>> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let participant = room_participant::Entity::find()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(
|
||||
room_participant::Column::AnsweringConnectionId
|
||||
.eq(connection.id as i32),
|
||||
)
|
||||
.add(
|
||||
room_participant::Column::AnsweringConnectionServerId
|
||||
.eq(connection.owner_id as i32),
|
||||
),
|
||||
user_id: UserId,
|
||||
) -> Result<TransactionGuard<(Project, ReplicaId)>> {
|
||||
self.project_transaction(project_id, |tx| async move {
|
||||
let (project, role) = self
|
||||
.access_project(
|
||||
project_id,
|
||||
connection,
|
||||
PrincipalId::UserId(user_id),
|
||||
Capability::ReadOnly,
|
||||
&tx,
|
||||
)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("must join a room first"))?;
|
||||
|
||||
let project = project::Entity::find_by_id(project_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such project"))?;
|
||||
if project.room_id != Some(participant.room_id) {
|
||||
return Err(anyhow!("no such project"))?;
|
||||
}
|
||||
self.join_project_internal(
|
||||
project,
|
||||
participant.user_id,
|
||||
connection,
|
||||
participant.role.unwrap_or(ChannelRole::Member),
|
||||
&tx,
|
||||
)
|
||||
.await
|
||||
.await?;
|
||||
self.join_project_internal(project, user_id, connection, role, &tx)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -814,9 +806,8 @@ impl Database {
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
connection: ConnectionId,
|
||||
) -> Result<RoomGuard<(proto::Room, LeftProject)>> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
) -> Result<TransactionGuard<(Option<proto::Room>, LeftProject)>> {
|
||||
self.project_transaction(project_id, |tx| async move {
|
||||
let result = project_collaborator::Entity::delete_many()
|
||||
.filter(
|
||||
Condition::all()
|
||||
@@ -871,7 +862,12 @@ impl Database {
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
let room = self.get_room(room_id, &tx).await?;
|
||||
let room = if let Some(room_id) = project.room_id {
|
||||
Some(self.get_room(room_id, &tx).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let left_project = LeftProject {
|
||||
id: project_id,
|
||||
host_user_id: project.host_user_id,
|
||||
@@ -888,17 +884,15 @@ impl Database {
|
||||
project_id: ProjectId,
|
||||
connection_id: ConnectionId,
|
||||
) -> Result<()> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
project_collaborator::Entity::find()
|
||||
self.project_transaction(project_id, |tx| async move {
|
||||
project::Entity::find()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(project_collaborator::Column::ProjectId.eq(project_id))
|
||||
.add(project_collaborator::Column::IsHost.eq(true))
|
||||
.add(project_collaborator::Column::ConnectionId.eq(connection_id.id))
|
||||
.add(project::Column::Id.eq(project_id))
|
||||
.add(project::Column::HostConnectionId.eq(Some(connection_id.id as i32)))
|
||||
.add(
|
||||
project_collaborator::Column::ConnectionServerId
|
||||
.eq(connection_id.owner_id),
|
||||
project::Column::HostConnectionServerId
|
||||
.eq(Some(connection_id.owner_id as i32)),
|
||||
),
|
||||
)
|
||||
.one(&*tx)
|
||||
@@ -911,39 +905,90 @@ impl Database {
|
||||
.map(|guard| guard.into_inner())
|
||||
}
|
||||
|
||||
/// Returns the current project if the given user is authorized to access it with the specified capability.
|
||||
pub async fn access_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
connection_id: ConnectionId,
|
||||
principal_id: PrincipalId,
|
||||
capability: Capability,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<(project::Model, ChannelRole)> {
|
||||
let (project, remote_project) = project::Entity::find_by_id(project_id)
|
||||
.find_also_related(remote_project::Entity)
|
||||
.one(tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such project"))?;
|
||||
|
||||
let user_id = match principal_id {
|
||||
PrincipalId::DevServerId(_) => {
|
||||
if project
|
||||
.host_connection()
|
||||
.is_ok_and(|connection| connection == connection_id)
|
||||
{
|
||||
return Ok((project, ChannelRole::Admin));
|
||||
}
|
||||
return Err(anyhow!("not the project host"))?;
|
||||
}
|
||||
PrincipalId::UserId(user_id) => user_id,
|
||||
};
|
||||
|
||||
let role = if let Some(remote_project) = remote_project {
|
||||
let channel = channel::Entity::find_by_id(remote_project.channel_id)
|
||||
.one(tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such channel"))?;
|
||||
|
||||
self.check_user_is_channel_participant(&channel, user_id, &tx)
|
||||
.await?
|
||||
} else if let Some(room_id) = project.room_id {
|
||||
// what's the users role?
|
||||
let current_participant = room_participant::Entity::find()
|
||||
.filter(room_participant::Column::RoomId.eq(room_id))
|
||||
.filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.id))
|
||||
.one(tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such room"))?;
|
||||
|
||||
current_participant.role.unwrap_or(ChannelRole::Guest)
|
||||
} else {
|
||||
return Err(anyhow!("not authorized to read projects"))?;
|
||||
};
|
||||
|
||||
match capability {
|
||||
Capability::ReadWrite => {
|
||||
if !role.can_edit_projects() {
|
||||
return Err(anyhow!("not authorized to edit projects"))?;
|
||||
}
|
||||
}
|
||||
Capability::ReadOnly => {
|
||||
if !role.can_read_projects() {
|
||||
return Err(anyhow!("not authorized to read projects"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((project, role))
|
||||
}
|
||||
|
||||
/// Returns the host connection for a read-only request to join a shared project.
|
||||
pub async fn host_for_read_only_project_request(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
connection_id: ConnectionId,
|
||||
user_id: UserId,
|
||||
) -> Result<ConnectionId> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let current_participant = room_participant::Entity::find()
|
||||
.filter(room_participant::Column::RoomId.eq(room_id))
|
||||
.filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.id))
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such room"))?;
|
||||
|
||||
if !current_participant
|
||||
.role
|
||||
.map_or(false, |role| role.can_read_projects())
|
||||
{
|
||||
Err(anyhow!("not authorized to read projects"))?;
|
||||
}
|
||||
|
||||
let host = project_collaborator::Entity::find()
|
||||
.filter(
|
||||
project_collaborator::Column::ProjectId
|
||||
.eq(project_id)
|
||||
.and(project_collaborator::Column::IsHost.eq(true)),
|
||||
self.project_transaction(project_id, |tx| async move {
|
||||
let (project, _) = self
|
||||
.access_project(
|
||||
project_id,
|
||||
connection_id,
|
||||
PrincipalId::UserId(user_id),
|
||||
Capability::ReadOnly,
|
||||
&tx,
|
||||
)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("failed to read project host"))?;
|
||||
|
||||
Ok(host.connection())
|
||||
.await?;
|
||||
project.host_connection()
|
||||
})
|
||||
.await
|
||||
.map(|guard| guard.into_inner())
|
||||
@@ -954,83 +999,56 @@ impl Database {
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
connection_id: ConnectionId,
|
||||
user_id: UserId,
|
||||
) -> Result<ConnectionId> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let current_participant = room_participant::Entity::find()
|
||||
.filter(room_participant::Column::RoomId.eq(room_id))
|
||||
.filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.id))
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such room"))?;
|
||||
|
||||
if !current_participant
|
||||
.role
|
||||
.map_or(false, |role| role.can_edit_projects())
|
||||
{
|
||||
Err(anyhow!("not authorized to edit projects"))?;
|
||||
}
|
||||
|
||||
let host = project_collaborator::Entity::find()
|
||||
.filter(
|
||||
project_collaborator::Column::ProjectId
|
||||
.eq(project_id)
|
||||
.and(project_collaborator::Column::IsHost.eq(true)),
|
||||
self.project_transaction(project_id, |tx| async move {
|
||||
let (project, _) = self
|
||||
.access_project(
|
||||
project_id,
|
||||
connection_id,
|
||||
PrincipalId::UserId(user_id),
|
||||
Capability::ReadWrite,
|
||||
&tx,
|
||||
)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("failed to read project host"))?;
|
||||
|
||||
Ok(host.connection())
|
||||
.await?;
|
||||
project.host_connection()
|
||||
})
|
||||
.await
|
||||
.map(|guard| guard.into_inner())
|
||||
}
|
||||
|
||||
pub async fn project_collaborators_for_buffer_update(
|
||||
pub async fn connections_for_buffer_update(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
principal_id: PrincipalId,
|
||||
connection_id: ConnectionId,
|
||||
requires_write: bool,
|
||||
) -> Result<RoomGuard<Vec<ProjectCollaborator>>> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let current_participant = room_participant::Entity::find()
|
||||
.filter(room_participant::Column::RoomId.eq(room_id))
|
||||
.filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.id))
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such room"))?;
|
||||
capability: Capability,
|
||||
) -> Result<TransactionGuard<(ConnectionId, Vec<ConnectionId>)>> {
|
||||
self.project_transaction(project_id, |tx| async move {
|
||||
// Authorize
|
||||
let (project, _) = self
|
||||
.access_project(project_id, connection_id, principal_id, capability, &tx)
|
||||
.await?;
|
||||
|
||||
if requires_write
|
||||
&& !current_participant
|
||||
.role
|
||||
.map_or(false, |role| role.can_edit_projects())
|
||||
{
|
||||
Err(anyhow!("not authorized to edit projects"))?;
|
||||
}
|
||||
let host_connection_id = project.host_connection()?;
|
||||
|
||||
let collaborators = project_collaborator::Entity::find()
|
||||
.filter(project_collaborator::Column::ProjectId.eq(project_id))
|
||||
.all(&*tx)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|collaborator| ProjectCollaborator {
|
||||
connection_id: collaborator.connection(),
|
||||
user_id: collaborator.user_id,
|
||||
replica_id: collaborator.replica_id,
|
||||
is_host: collaborator.is_host,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
.await?;
|
||||
|
||||
if collaborators
|
||||
.iter()
|
||||
.any(|collaborator| collaborator.connection_id == connection_id)
|
||||
{
|
||||
Ok(collaborators)
|
||||
} else {
|
||||
Err(anyhow!("no such project"))?
|
||||
}
|
||||
let guest_connection_ids = collaborators
|
||||
.into_iter()
|
||||
.filter_map(|collaborator| {
|
||||
if collaborator.is_host {
|
||||
None
|
||||
} else {
|
||||
Some(collaborator.connection())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok((host_connection_id, guest_connection_ids))
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -1043,24 +1061,39 @@ impl Database {
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
connection_id: ConnectionId,
|
||||
) -> Result<RoomGuard<HashSet<ConnectionId>>> {
|
||||
let room_id = self.room_id_for_project(project_id).await?;
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
exclude_dev_server: bool,
|
||||
) -> Result<TransactionGuard<HashSet<ConnectionId>>> {
|
||||
self.project_transaction(project_id, |tx| async move {
|
||||
let project = project::Entity::find_by_id(project_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no such project"))?;
|
||||
|
||||
let mut collaborators = project_collaborator::Entity::find()
|
||||
.filter(project_collaborator::Column::ProjectId.eq(project_id))
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut connection_ids = HashSet::default();
|
||||
if let Some(host_connection) = project.host_connection().log_err() {
|
||||
if !exclude_dev_server {
|
||||
connection_ids.insert(host_connection);
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(collaborator) = collaborators.next().await {
|
||||
let collaborator = collaborator?;
|
||||
connection_ids.insert(collaborator.connection());
|
||||
}
|
||||
|
||||
if connection_ids.contains(&connection_id) {
|
||||
if connection_ids.contains(&connection_id)
|
||||
|| Some(connection_id) == project.host_connection().ok()
|
||||
{
|
||||
Ok(connection_ids)
|
||||
} else {
|
||||
Err(anyhow!("no such project"))?
|
||||
Err(anyhow!(
|
||||
"can only send project updates to a project you're in"
|
||||
))?
|
||||
}
|
||||
})
|
||||
.await
|
||||
@@ -1089,15 +1122,12 @@ impl Database {
|
||||
}
|
||||
|
||||
/// Returns the [`RoomId`] for the given project.
|
||||
pub async fn room_id_for_project(&self, project_id: ProjectId) -> Result<RoomId> {
|
||||
pub async fn room_id_for_project(&self, project_id: ProjectId) -> Result<Option<RoomId>> {
|
||||
self.transaction(|tx| async move {
|
||||
let project = project::Entity::find_by_id(project_id)
|
||||
Ok(project::Entity::find_by_id(project_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("project {} not found", project_id))?;
|
||||
Ok(project
|
||||
.room_id
|
||||
.ok_or_else(|| anyhow!("project not in room"))?)
|
||||
.and_then(|project| project.room_id))
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -1142,7 +1172,7 @@ impl Database {
|
||||
project_id: ProjectId,
|
||||
leader_connection: ConnectionId,
|
||||
follower_connection: ConnectionId,
|
||||
) -> Result<RoomGuard<proto::Room>> {
|
||||
) -> Result<TransactionGuard<proto::Room>> {
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
follower::ActiveModel {
|
||||
room_id: ActiveValue::set(room_id),
|
||||
@@ -1173,7 +1203,7 @@ impl Database {
|
||||
project_id: ProjectId,
|
||||
leader_connection: ConnectionId,
|
||||
follower_connection: ConnectionId,
|
||||
) -> Result<RoomGuard<proto::Room>> {
|
||||
) -> Result<TransactionGuard<proto::Room>> {
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
follower::Entity::delete_many()
|
||||
.filter(
|
||||
|
||||
261
crates/collab/src/db/queries/remote_projects.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
use anyhow::anyhow;
|
||||
use rpc::{proto, ConnectionId};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ActiveValue, ColumnTrait, Condition, DatabaseTransaction, EntityTrait,
|
||||
ModelTrait, QueryFilter,
|
||||
};
|
||||
|
||||
use crate::db::ProjectId;
|
||||
|
||||
use super::{
|
||||
channel, project, project_collaborator, remote_project, worktree, ChannelId, Database,
|
||||
DevServerId, RejoinedProject, RemoteProjectId, ResharedProject, ServerId, UserId,
|
||||
};
|
||||
|
||||
impl Database {
|
||||
pub async fn get_remote_project(
|
||||
&self,
|
||||
remote_project_id: RemoteProjectId,
|
||||
) -> crate::Result<remote_project::Model> {
|
||||
self.transaction(|tx| async move {
|
||||
Ok(remote_project::Entity::find_by_id(remote_project_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no remote project with id {}", remote_project_id))?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_remote_projects(
|
||||
&self,
|
||||
channel_ids: &Vec<ChannelId>,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> crate::Result<Vec<proto::RemoteProject>> {
|
||||
let servers = remote_project::Entity::find()
|
||||
.filter(remote_project::Column::ChannelId.is_in(channel_ids.iter().map(|id| id.0)))
|
||||
.find_also_related(project::Entity)
|
||||
.all(tx)
|
||||
.await?;
|
||||
Ok(servers
|
||||
.into_iter()
|
||||
.map(|(remote_project, project)| proto::RemoteProject {
|
||||
id: remote_project.id.to_proto(),
|
||||
project_id: project.map(|p| p.id.to_proto()),
|
||||
channel_id: remote_project.channel_id.to_proto(),
|
||||
name: remote_project.name,
|
||||
dev_server_id: remote_project.dev_server_id.to_proto(),
|
||||
path: remote_project.path,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_remote_projects_for_dev_server(
|
||||
&self,
|
||||
dev_server_id: DevServerId,
|
||||
) -> crate::Result<Vec<proto::RemoteProject>> {
|
||||
self.transaction(|tx| async move {
|
||||
let servers = remote_project::Entity::find()
|
||||
.filter(remote_project::Column::DevServerId.eq(dev_server_id))
|
||||
.find_also_related(project::Entity)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
Ok(servers
|
||||
.into_iter()
|
||||
.map(|(remote_project, project)| proto::RemoteProject {
|
||||
id: remote_project.id.to_proto(),
|
||||
project_id: project.map(|p| p.id.to_proto()),
|
||||
channel_id: remote_project.channel_id.to_proto(),
|
||||
name: remote_project.name,
|
||||
dev_server_id: remote_project.dev_server_id.to_proto(),
|
||||
path: remote_project.path,
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_stale_dev_server_projects(
|
||||
&self,
|
||||
connection: ConnectionId,
|
||||
) -> crate::Result<Vec<ProjectId>> {
|
||||
self.transaction(|tx| async move {
|
||||
let projects = project::Entity::find()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(project::Column::HostConnectionId.eq(connection.id))
|
||||
.add(project::Column::HostConnectionServerId.eq(connection.owner_id)),
|
||||
)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(projects.into_iter().map(|p| p.id).collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_remote_project(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
dev_server_id: DevServerId,
|
||||
name: &str,
|
||||
path: &str,
|
||||
user_id: UserId,
|
||||
) -> crate::Result<(channel::Model, remote_project::Model)> {
|
||||
self.transaction(|tx| async move {
|
||||
let channel = self.get_channel_internal(channel_id, &tx).await?;
|
||||
self.check_user_is_channel_admin(&channel, user_id, &tx)
|
||||
.await?;
|
||||
|
||||
let project = remote_project::Entity::insert(remote_project::ActiveModel {
|
||||
name: ActiveValue::Set(name.to_string()),
|
||||
id: ActiveValue::NotSet,
|
||||
channel_id: ActiveValue::Set(channel_id),
|
||||
dev_server_id: ActiveValue::Set(dev_server_id),
|
||||
path: ActiveValue::Set(path.to_string()),
|
||||
})
|
||||
.exec_with_returning(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok((channel, project))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn share_remote_project(
|
||||
&self,
|
||||
remote_project_id: RemoteProjectId,
|
||||
dev_server_id: DevServerId,
|
||||
connection: ConnectionId,
|
||||
worktrees: &[proto::WorktreeMetadata],
|
||||
) -> crate::Result<proto::RemoteProject> {
|
||||
self.transaction(|tx| async move {
|
||||
let remote_project = remote_project::Entity::find_by_id(remote_project_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("no remote project with id {}", remote_project_id))?;
|
||||
|
||||
if remote_project.dev_server_id != dev_server_id {
|
||||
return Err(anyhow!("remote project shared from wrong server"))?;
|
||||
}
|
||||
|
||||
let project = project::ActiveModel {
|
||||
room_id: ActiveValue::Set(None),
|
||||
host_user_id: ActiveValue::Set(None),
|
||||
host_connection_id: ActiveValue::set(Some(connection.id as i32)),
|
||||
host_connection_server_id: ActiveValue::set(Some(ServerId(
|
||||
connection.owner_id as i32,
|
||||
))),
|
||||
id: ActiveValue::NotSet,
|
||||
hosted_project_id: ActiveValue::Set(None),
|
||||
remote_project_id: ActiveValue::Set(Some(remote_project_id)),
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
|
||||
if !worktrees.is_empty() {
|
||||
worktree::Entity::insert_many(worktrees.iter().map(|worktree| {
|
||||
worktree::ActiveModel {
|
||||
id: ActiveValue::set(worktree.id as i64),
|
||||
project_id: ActiveValue::set(project.id),
|
||||
abs_path: ActiveValue::set(worktree.abs_path.clone()),
|
||||
root_name: ActiveValue::set(worktree.root_name.clone()),
|
||||
visible: ActiveValue::set(worktree.visible),
|
||||
scan_id: ActiveValue::set(0),
|
||||
completed_scan_id: ActiveValue::set(0),
|
||||
}
|
||||
}))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(remote_project.to_proto(Some(project)))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn reshare_remote_projects(
|
||||
&self,
|
||||
reshared_projects: &Vec<proto::UpdateProject>,
|
||||
dev_server_id: DevServerId,
|
||||
connection: ConnectionId,
|
||||
) -> crate::Result<Vec<ResharedProject>> {
|
||||
// todo!() project_transaction? (maybe we can make the lock per-dev-server instead of per-project?)
|
||||
self.transaction(|tx| async move {
|
||||
let mut ret = Vec::new();
|
||||
for reshared_project in reshared_projects {
|
||||
let project_id = ProjectId::from_proto(reshared_project.project_id);
|
||||
let (project, remote_project) = project::Entity::find_by_id(project_id)
|
||||
.find_also_related(remote_project::Entity)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow!("project does not exist"))?;
|
||||
|
||||
if remote_project.map(|rp| rp.dev_server_id) != Some(dev_server_id) {
|
||||
return Err(anyhow!("remote project reshared from wrong server"))?;
|
||||
}
|
||||
|
||||
let Ok(old_connection_id) = project.host_connection() else {
|
||||
return Err(anyhow!("remote project was not shared"))?;
|
||||
};
|
||||
|
||||
project::Entity::update(project::ActiveModel {
|
||||
id: ActiveValue::set(project_id),
|
||||
host_connection_id: ActiveValue::set(Some(connection.id as i32)),
|
||||
host_connection_server_id: ActiveValue::set(Some(ServerId(
|
||||
connection.owner_id as i32,
|
||||
))),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
let collaborators = project
|
||||
.find_related(project_collaborator::Entity)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
self.update_project_worktrees(project_id, &reshared_project.worktrees, &tx)
|
||||
.await?;
|
||||
|
||||
ret.push(super::ResharedProject {
|
||||
id: project_id,
|
||||
old_connection_id,
|
||||
collaborators: collaborators
|
||||
.iter()
|
||||
.map(|collaborator| super::ProjectCollaborator {
|
||||
connection_id: collaborator.connection(),
|
||||
user_id: collaborator.user_id,
|
||||
replica_id: collaborator.replica_id,
|
||||
is_host: collaborator.is_host,
|
||||
})
|
||||
.collect(),
|
||||
worktrees: reshared_project.worktrees.clone(),
|
||||
});
|
||||
}
|
||||
Ok(ret)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn rejoin_remote_projects(
|
||||
&self,
|
||||
rejoined_projects: &Vec<proto::RejoinProject>,
|
||||
user_id: UserId,
|
||||
connection_id: ConnectionId,
|
||||
) -> crate::Result<Vec<RejoinedProject>> {
|
||||
// todo!() project_transaction? (maybe we can make the lock per-dev-server instead of per-project?)
|
||||
self.transaction(|tx| async move {
|
||||
let mut ret = Vec::new();
|
||||
for rejoined_project in rejoined_projects {
|
||||
if let Some(project) = self
|
||||
.rejoin_project_internal(&tx, rejoined_project, user_id, connection_id)
|
||||
.await?
|
||||
{
|
||||
ret.push(project);
|
||||
}
|
||||
}
|
||||
Ok(ret)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ impl Database {
|
||||
&self,
|
||||
room_id: RoomId,
|
||||
new_server_id: ServerId,
|
||||
) -> Result<RoomGuard<RefreshedRoom>> {
|
||||
) -> Result<TransactionGuard<RefreshedRoom>> {
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let stale_participant_filter = Condition::all()
|
||||
.add(room_participant::Column::RoomId.eq(room_id))
|
||||
@@ -149,7 +149,7 @@ impl Database {
|
||||
calling_connection: ConnectionId,
|
||||
called_user_id: UserId,
|
||||
initial_project_id: Option<ProjectId>,
|
||||
) -> Result<RoomGuard<(proto::Room, proto::IncomingCall)>> {
|
||||
) -> Result<TransactionGuard<(proto::Room, proto::IncomingCall)>> {
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let caller = room_participant::Entity::find()
|
||||
.filter(
|
||||
@@ -201,7 +201,7 @@ impl Database {
|
||||
&self,
|
||||
room_id: RoomId,
|
||||
called_user_id: UserId,
|
||||
) -> Result<RoomGuard<proto::Room>> {
|
||||
) -> Result<TransactionGuard<proto::Room>> {
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
room_participant::Entity::delete_many()
|
||||
.filter(
|
||||
@@ -221,7 +221,7 @@ impl Database {
|
||||
&self,
|
||||
expected_room_id: Option<RoomId>,
|
||||
user_id: UserId,
|
||||
) -> Result<Option<RoomGuard<proto::Room>>> {
|
||||
) -> Result<Option<TransactionGuard<proto::Room>>> {
|
||||
self.optional_room_transaction(|tx| async move {
|
||||
let mut filter = Condition::all()
|
||||
.add(room_participant::Column::UserId.eq(user_id))
|
||||
@@ -258,7 +258,7 @@ impl Database {
|
||||
room_id: RoomId,
|
||||
calling_connection: ConnectionId,
|
||||
called_user_id: UserId,
|
||||
) -> Result<RoomGuard<proto::Room>> {
|
||||
) -> Result<TransactionGuard<proto::Room>> {
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let participant = room_participant::Entity::find()
|
||||
.filter(
|
||||
@@ -294,7 +294,7 @@ impl Database {
|
||||
room_id: RoomId,
|
||||
user_id: UserId,
|
||||
connection: ConnectionId,
|
||||
) -> Result<RoomGuard<JoinRoom>> {
|
||||
) -> Result<TransactionGuard<JoinRoom>> {
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryChannelId {
|
||||
@@ -472,7 +472,7 @@ impl Database {
|
||||
rejoin_room: proto::RejoinRoom,
|
||||
user_id: UserId,
|
||||
connection: ConnectionId,
|
||||
) -> Result<RoomGuard<RejoinedRoom>> {
|
||||
) -> Result<TransactionGuard<RejoinedRoom>> {
|
||||
let room_id = RoomId::from_proto(rejoin_room.id);
|
||||
self.room_transaction(room_id, |tx| async {
|
||||
let tx = tx;
|
||||
@@ -572,180 +572,12 @@ impl Database {
|
||||
|
||||
let mut rejoined_projects = Vec::new();
|
||||
for rejoined_project in &rejoin_room.rejoined_projects {
|
||||
let project_id = ProjectId::from_proto(rejoined_project.id);
|
||||
let Some(project) = project::Entity::find_by_id(project_id).one(&*tx).await? else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut worktrees = Vec::new();
|
||||
let db_worktrees = project.find_related(worktree::Entity).all(&*tx).await?;
|
||||
for db_worktree in db_worktrees {
|
||||
let mut worktree = RejoinedWorktree {
|
||||
id: db_worktree.id as u64,
|
||||
abs_path: db_worktree.abs_path,
|
||||
root_name: db_worktree.root_name,
|
||||
visible: db_worktree.visible,
|
||||
updated_entries: Default::default(),
|
||||
removed_entries: Default::default(),
|
||||
updated_repositories: Default::default(),
|
||||
removed_repositories: Default::default(),
|
||||
diagnostic_summaries: Default::default(),
|
||||
settings_files: Default::default(),
|
||||
scan_id: db_worktree.scan_id as u64,
|
||||
completed_scan_id: db_worktree.completed_scan_id as u64,
|
||||
};
|
||||
|
||||
let rejoined_worktree = rejoined_project
|
||||
.worktrees
|
||||
.iter()
|
||||
.find(|worktree| worktree.id == db_worktree.id as u64);
|
||||
|
||||
// File entries
|
||||
{
|
||||
let entry_filter = if let Some(rejoined_worktree) = rejoined_worktree {
|
||||
worktree_entry::Column::ScanId.gt(rejoined_worktree.scan_id)
|
||||
} else {
|
||||
worktree_entry::Column::IsDeleted.eq(false)
|
||||
};
|
||||
|
||||
let mut db_entries = worktree_entry::Entity::find()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(worktree_entry::Column::ProjectId.eq(project.id))
|
||||
.add(worktree_entry::Column::WorktreeId.eq(worktree.id))
|
||||
.add(entry_filter),
|
||||
)
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
|
||||
while let Some(db_entry) = db_entries.next().await {
|
||||
let db_entry = db_entry?;
|
||||
if db_entry.is_deleted {
|
||||
worktree.removed_entries.push(db_entry.id as u64);
|
||||
} else {
|
||||
worktree.updated_entries.push(proto::Entry {
|
||||
id: db_entry.id as u64,
|
||||
is_dir: db_entry.is_dir,
|
||||
path: db_entry.path,
|
||||
inode: db_entry.inode as u64,
|
||||
mtime: Some(proto::Timestamp {
|
||||
seconds: db_entry.mtime_seconds as u64,
|
||||
nanos: db_entry.mtime_nanos as u32,
|
||||
}),
|
||||
is_symlink: db_entry.is_symlink,
|
||||
is_ignored: db_entry.is_ignored,
|
||||
is_external: db_entry.is_external,
|
||||
git_status: db_entry.git_status.map(|status| status as i32),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Repository Entries
|
||||
{
|
||||
let repository_entry_filter =
|
||||
if let Some(rejoined_worktree) = rejoined_worktree {
|
||||
worktree_repository::Column::ScanId.gt(rejoined_worktree.scan_id)
|
||||
} else {
|
||||
worktree_repository::Column::IsDeleted.eq(false)
|
||||
};
|
||||
|
||||
let mut db_repositories = worktree_repository::Entity::find()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(worktree_repository::Column::ProjectId.eq(project.id))
|
||||
.add(worktree_repository::Column::WorktreeId.eq(worktree.id))
|
||||
.add(repository_entry_filter),
|
||||
)
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
|
||||
while let Some(db_repository) = db_repositories.next().await {
|
||||
let db_repository = db_repository?;
|
||||
if db_repository.is_deleted {
|
||||
worktree
|
||||
.removed_repositories
|
||||
.push(db_repository.work_directory_id as u64);
|
||||
} else {
|
||||
worktree.updated_repositories.push(proto::RepositoryEntry {
|
||||
work_directory_id: db_repository.work_directory_id as u64,
|
||||
branch: db_repository.branch,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
worktrees.push(worktree);
|
||||
}
|
||||
|
||||
let language_servers = project
|
||||
.find_related(language_server::Entity)
|
||||
.all(&*tx)
|
||||
if let Some(rejoined_project) = self
|
||||
.rejoin_project_internal(&tx, rejoined_project, user_id, connection)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|language_server| proto::LanguageServer {
|
||||
id: language_server.id as u64,
|
||||
name: language_server.name,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
{
|
||||
let mut db_settings_files = worktree_settings_file::Entity::find()
|
||||
.filter(worktree_settings_file::Column::ProjectId.eq(project_id))
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
while let Some(db_settings_file) = db_settings_files.next().await {
|
||||
let db_settings_file = db_settings_file?;
|
||||
if let Some(worktree) = worktrees
|
||||
.iter_mut()
|
||||
.find(|w| w.id == db_settings_file.worktree_id as u64)
|
||||
{
|
||||
worktree.settings_files.push(WorktreeSettingsFile {
|
||||
path: db_settings_file.path,
|
||||
content: db_settings_file.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
rejoined_projects.push(rejoined_project);
|
||||
}
|
||||
|
||||
let mut collaborators = project
|
||||
.find_related(project_collaborator::Entity)
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
let self_collaborator = if let Some(self_collaborator_ix) = collaborators
|
||||
.iter()
|
||||
.position(|collaborator| collaborator.user_id == user_id)
|
||||
{
|
||||
collaborators.swap_remove(self_collaborator_ix)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let old_connection_id = self_collaborator.connection();
|
||||
project_collaborator::Entity::update(project_collaborator::ActiveModel {
|
||||
connection_id: ActiveValue::set(connection.id as i32),
|
||||
connection_server_id: ActiveValue::set(ServerId(connection.owner_id as i32)),
|
||||
..self_collaborator.into_active_model()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
let collaborators = collaborators
|
||||
.into_iter()
|
||||
.map(|collaborator| ProjectCollaborator {
|
||||
connection_id: collaborator.connection(),
|
||||
user_id: collaborator.user_id,
|
||||
replica_id: collaborator.replica_id,
|
||||
is_host: collaborator.is_host,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
rejoined_projects.push(RejoinedProject {
|
||||
id: project_id,
|
||||
old_connection_id,
|
||||
collaborators,
|
||||
worktrees,
|
||||
language_servers,
|
||||
});
|
||||
}
|
||||
|
||||
let (channel, room) = self.get_channel_room(room_id, &tx).await?;
|
||||
@@ -760,10 +592,192 @@ impl Database {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn rejoin_project_internal(
|
||||
&self,
|
||||
tx: &DatabaseTransaction,
|
||||
rejoined_project: &proto::RejoinProject,
|
||||
user_id: UserId,
|
||||
connection: ConnectionId,
|
||||
) -> Result<Option<RejoinedProject>> {
|
||||
let project_id = ProjectId::from_proto(rejoined_project.id);
|
||||
let Some(project) = project::Entity::find_by_id(project_id).one(tx).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut worktrees = Vec::new();
|
||||
let db_worktrees = project.find_related(worktree::Entity).all(tx).await?;
|
||||
for db_worktree in db_worktrees {
|
||||
let mut worktree = RejoinedWorktree {
|
||||
id: db_worktree.id as u64,
|
||||
abs_path: db_worktree.abs_path,
|
||||
root_name: db_worktree.root_name,
|
||||
visible: db_worktree.visible,
|
||||
updated_entries: Default::default(),
|
||||
removed_entries: Default::default(),
|
||||
updated_repositories: Default::default(),
|
||||
removed_repositories: Default::default(),
|
||||
diagnostic_summaries: Default::default(),
|
||||
settings_files: Default::default(),
|
||||
scan_id: db_worktree.scan_id as u64,
|
||||
completed_scan_id: db_worktree.completed_scan_id as u64,
|
||||
};
|
||||
|
||||
let rejoined_worktree = rejoined_project
|
||||
.worktrees
|
||||
.iter()
|
||||
.find(|worktree| worktree.id == db_worktree.id as u64);
|
||||
|
||||
// File entries
|
||||
{
|
||||
let entry_filter = if let Some(rejoined_worktree) = rejoined_worktree {
|
||||
worktree_entry::Column::ScanId.gt(rejoined_worktree.scan_id)
|
||||
} else {
|
||||
worktree_entry::Column::IsDeleted.eq(false)
|
||||
};
|
||||
|
||||
let mut db_entries = worktree_entry::Entity::find()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(worktree_entry::Column::ProjectId.eq(project.id))
|
||||
.add(worktree_entry::Column::WorktreeId.eq(worktree.id))
|
||||
.add(entry_filter),
|
||||
)
|
||||
.stream(tx)
|
||||
.await?;
|
||||
|
||||
while let Some(db_entry) = db_entries.next().await {
|
||||
let db_entry = db_entry?;
|
||||
if db_entry.is_deleted {
|
||||
worktree.removed_entries.push(db_entry.id as u64);
|
||||
} else {
|
||||
worktree.updated_entries.push(proto::Entry {
|
||||
id: db_entry.id as u64,
|
||||
is_dir: db_entry.is_dir,
|
||||
path: db_entry.path,
|
||||
inode: db_entry.inode as u64,
|
||||
mtime: Some(proto::Timestamp {
|
||||
seconds: db_entry.mtime_seconds as u64,
|
||||
nanos: db_entry.mtime_nanos as u32,
|
||||
}),
|
||||
is_symlink: db_entry.is_symlink,
|
||||
is_ignored: db_entry.is_ignored,
|
||||
is_external: db_entry.is_external,
|
||||
git_status: db_entry.git_status.map(|status| status as i32),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Repository Entries
|
||||
{
|
||||
let repository_entry_filter = if let Some(rejoined_worktree) = rejoined_worktree {
|
||||
worktree_repository::Column::ScanId.gt(rejoined_worktree.scan_id)
|
||||
} else {
|
||||
worktree_repository::Column::IsDeleted.eq(false)
|
||||
};
|
||||
|
||||
let mut db_repositories = worktree_repository::Entity::find()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(worktree_repository::Column::ProjectId.eq(project.id))
|
||||
.add(worktree_repository::Column::WorktreeId.eq(worktree.id))
|
||||
.add(repository_entry_filter),
|
||||
)
|
||||
.stream(tx)
|
||||
.await?;
|
||||
|
||||
while let Some(db_repository) = db_repositories.next().await {
|
||||
let db_repository = db_repository?;
|
||||
if db_repository.is_deleted {
|
||||
worktree
|
||||
.removed_repositories
|
||||
.push(db_repository.work_directory_id as u64);
|
||||
} else {
|
||||
worktree.updated_repositories.push(proto::RepositoryEntry {
|
||||
work_directory_id: db_repository.work_directory_id as u64,
|
||||
branch: db_repository.branch,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
worktrees.push(worktree);
|
||||
}
|
||||
|
||||
let language_servers = project
|
||||
.find_related(language_server::Entity)
|
||||
.all(tx)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|language_server| proto::LanguageServer {
|
||||
id: language_server.id as u64,
|
||||
name: language_server.name,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
{
|
||||
let mut db_settings_files = worktree_settings_file::Entity::find()
|
||||
.filter(worktree_settings_file::Column::ProjectId.eq(project_id))
|
||||
.stream(tx)
|
||||
.await?;
|
||||
while let Some(db_settings_file) = db_settings_files.next().await {
|
||||
let db_settings_file = db_settings_file?;
|
||||
if let Some(worktree) = worktrees
|
||||
.iter_mut()
|
||||
.find(|w| w.id == db_settings_file.worktree_id as u64)
|
||||
{
|
||||
worktree.settings_files.push(WorktreeSettingsFile {
|
||||
path: db_settings_file.path,
|
||||
content: db_settings_file.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut collaborators = project
|
||||
.find_related(project_collaborator::Entity)
|
||||
.all(tx)
|
||||
.await?;
|
||||
let self_collaborator = if let Some(self_collaborator_ix) = collaborators
|
||||
.iter()
|
||||
.position(|collaborator| collaborator.user_id == user_id)
|
||||
{
|
||||
collaborators.swap_remove(self_collaborator_ix)
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
let old_connection_id = self_collaborator.connection();
|
||||
project_collaborator::Entity::update(project_collaborator::ActiveModel {
|
||||
connection_id: ActiveValue::set(connection.id as i32),
|
||||
connection_server_id: ActiveValue::set(ServerId(connection.owner_id as i32)),
|
||||
..self_collaborator.into_active_model()
|
||||
})
|
||||
.exec(tx)
|
||||
.await?;
|
||||
|
||||
let collaborators = collaborators
|
||||
.into_iter()
|
||||
.map(|collaborator| ProjectCollaborator {
|
||||
connection_id: collaborator.connection(),
|
||||
user_id: collaborator.user_id,
|
||||
replica_id: collaborator.replica_id,
|
||||
is_host: collaborator.is_host,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
return Ok(Some(RejoinedProject {
|
||||
id: project_id,
|
||||
old_connection_id,
|
||||
collaborators,
|
||||
worktrees,
|
||||
language_servers,
|
||||
}));
|
||||
}
|
||||
|
||||
pub async fn leave_room(
|
||||
&self,
|
||||
connection: ConnectionId,
|
||||
) -> Result<Option<RoomGuard<LeftRoom>>> {
|
||||
) -> Result<Option<TransactionGuard<LeftRoom>>> {
|
||||
self.optional_room_transaction(|tx| async move {
|
||||
let leaving_participant = room_participant::Entity::find()
|
||||
.filter(
|
||||
@@ -935,7 +949,7 @@ impl Database {
|
||||
room_id: RoomId,
|
||||
connection: ConnectionId,
|
||||
location: proto::ParticipantLocation,
|
||||
) -> Result<RoomGuard<proto::Room>> {
|
||||
) -> Result<TransactionGuard<proto::Room>> {
|
||||
self.room_transaction(room_id, |tx| async {
|
||||
let tx = tx;
|
||||
let location_kind;
|
||||
@@ -997,7 +1011,7 @@ impl Database {
|
||||
room_id: RoomId,
|
||||
user_id: UserId,
|
||||
role: ChannelRole,
|
||||
) -> Result<RoomGuard<proto::Room>> {
|
||||
) -> Result<TransactionGuard<proto::Room>> {
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
room_participant::Entity::find()
|
||||
.filter(
|
||||
@@ -1150,7 +1164,7 @@ impl Database {
|
||||
&self,
|
||||
room_id: RoomId,
|
||||
connection_id: ConnectionId,
|
||||
) -> Result<RoomGuard<HashSet<ConnectionId>>> {
|
||||
) -> Result<TransactionGuard<HashSet<ConnectionId>>> {
|
||||
self.room_transaction(room_id, |tx| async move {
|
||||
let mut participants = room_participant::Entity::find()
|
||||
.filter(room_participant::Column::RoomId.eq(room_id))
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod channel_message_mention;
|
||||
pub mod contact;
|
||||
pub mod contributor;
|
||||
pub mod dev_server;
|
||||
pub mod embedding;
|
||||
pub mod extension;
|
||||
pub mod extension_version;
|
||||
pub mod feature_flag;
|
||||
@@ -24,6 +25,7 @@ pub mod observed_channel_messages;
|
||||
pub mod project;
|
||||
pub mod project_collaborator;
|
||||
pub mod rate_buckets;
|
||||
pub mod remote_project;
|
||||
pub mod room;
|
||||
pub mod room_participant;
|
||||
pub mod server;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::db::{ChannelId, DevServerId};
|
||||
use rpc::proto;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
@@ -15,3 +16,14 @@ impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl Model {
|
||||
pub fn to_proto(&self, status: proto::DevServerStatus) -> proto::DevServer {
|
||||
proto::DevServer {
|
||||
dev_server_id: self.id.to_proto(),
|
||||
channel_id: self.channel_id.to_proto(),
|
||||
name: self.name.clone(),
|
||||
status: status as i32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
crates/collab/src/db/tables/embedding.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use time::PrimitiveDateTime;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "embeddings")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub model: String,
|
||||
#[sea_orm(primary_key)]
|
||||
pub digest: Vec<u8>,
|
||||
pub dimensions: Vec<f32>,
|
||||
pub retrieved_at: PrimitiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::db::{HostedProjectId, ProjectId, Result, RoomId, ServerId, UserId};
|
||||
use crate::db::{HostedProjectId, ProjectId, RemoteProjectId, Result, RoomId, ServerId, UserId};
|
||||
use anyhow::anyhow;
|
||||
use rpc::ConnectionId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
@@ -13,6 +13,7 @@ pub struct Model {
|
||||
pub host_connection_id: Option<i32>,
|
||||
pub host_connection_server_id: Option<ServerId>,
|
||||
pub hosted_project_id: Option<HostedProjectId>,
|
||||
pub remote_project_id: Option<RemoteProjectId>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
@@ -56,6 +57,12 @@ pub enum Relation {
|
||||
to = "super::hosted_project::Column::Id"
|
||||
)]
|
||||
HostedProject,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::remote_project::Entity",
|
||||
from = "Column::RemoteProjectId",
|
||||
to = "super::remote_project::Column::Id"
|
||||
)]
|
||||
RemoteProject,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
@@ -94,4 +101,10 @@ impl Related<super::hosted_project::Entity> for Entity {
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::remote_project::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::RemoteProject.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
42
crates/collab/src/db/tables/remote_project.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use super::project;
|
||||
use crate::db::{ChannelId, DevServerId, RemoteProjectId};
|
||||
use rpc::proto;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "remote_projects")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: RemoteProjectId,
|
||||
pub channel_id: ChannelId,
|
||||
pub dev_server_id: DevServerId,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_one = "super::project::Entity")]
|
||||
Project,
|
||||
}
|
||||
|
||||
impl Related<super::project::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Project.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn to_proto(&self, project: Option<project::Model>) -> proto::RemoteProject {
|
||||
proto::RemoteProject {
|
||||
id: self.id.to_proto(),
|
||||
project_id: project.map(|p| p.id.to_proto()),
|
||||
channel_id: self.channel_id.to_proto(),
|
||||
dev_server_id: self.dev_server_id.to_proto(),
|
||||
name: self.name.clone(),
|
||||
path: self.path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod buffer_tests;
|
||||
mod channel_tests;
|
||||
mod contributor_tests;
|
||||
mod db_tests;
|
||||
mod embedding_tests;
|
||||
mod extension_tests;
|
||||
mod feature_flag_tests;
|
||||
mod message_tests;
|
||||
|
||||
84
crates/collab/src/db/tests/embedding_tests.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use super::TestDb;
|
||||
use crate::db::embedding;
|
||||
use collections::HashMap;
|
||||
use sea_orm::{sea_query::Expr, ColumnTrait, EntityTrait, QueryFilter};
|
||||
use std::ops::Sub;
|
||||
use time::{Duration, OffsetDateTime, PrimitiveDateTime};
|
||||
|
||||
// SQLite does not support array arguments, so we only test this against a real postgres instance
|
||||
#[gpui::test]
|
||||
async fn test_get_embeddings_postgres(cx: &mut gpui::TestAppContext) {
|
||||
let test_db = TestDb::postgres(cx.executor().clone());
|
||||
let db = test_db.db();
|
||||
|
||||
let provider = "test_model";
|
||||
let digest1 = vec![1, 2, 3];
|
||||
let digest2 = vec![4, 5, 6];
|
||||
let embeddings = HashMap::from_iter([
|
||||
(digest1.clone(), vec![0.1, 0.2, 0.3]),
|
||||
(digest2.clone(), vec![0.4, 0.5, 0.6]),
|
||||
]);
|
||||
|
||||
// Save embeddings
|
||||
db.save_embeddings(provider, &embeddings).await.unwrap();
|
||||
|
||||
// Retrieve embeddings
|
||||
let retrieved_embeddings = db
|
||||
.get_embeddings(provider, &[digest1.clone(), digest2.clone()])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(retrieved_embeddings.len(), 2);
|
||||
assert!(retrieved_embeddings.contains_key(&digest1));
|
||||
assert!(retrieved_embeddings.contains_key(&digest2));
|
||||
|
||||
// Check if the retrieved embeddings are correct
|
||||
assert_eq!(retrieved_embeddings[&digest1], vec![0.1, 0.2, 0.3]);
|
||||
assert_eq!(retrieved_embeddings[&digest2], vec![0.4, 0.5, 0.6]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_purge_old_embeddings(cx: &mut gpui::TestAppContext) {
|
||||
let test_db = TestDb::postgres(cx.executor().clone());
|
||||
let db = test_db.db();
|
||||
|
||||
let model = "test_model";
|
||||
let digest = vec![7, 8, 9];
|
||||
let embeddings = HashMap::from_iter([(digest.clone(), vec![0.7, 0.8, 0.9])]);
|
||||
|
||||
// Save old embeddings
|
||||
db.save_embeddings(model, &embeddings).await.unwrap();
|
||||
|
||||
// Reach into the DB and change the retrieved at to be > 60 days
|
||||
db.weak_transaction(|tx| {
|
||||
let digest = digest.clone();
|
||||
async move {
|
||||
let sixty_days_ago = OffsetDateTime::now_utc().sub(Duration::days(61));
|
||||
let retrieved_at = PrimitiveDateTime::new(sixty_days_ago.date(), sixty_days_ago.time());
|
||||
|
||||
embedding::Entity::update_many()
|
||||
.filter(
|
||||
embedding::Column::Model
|
||||
.eq(model)
|
||||
.and(embedding::Column::Digest.eq(digest)),
|
||||
)
|
||||
.col_expr(embedding::Column::RetrievedAt, Expr::value(retrieved_at))
|
||||
.exec(&*tx)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Purge old embeddings
|
||||
db.purge_old_embeddings().await.unwrap();
|
||||
|
||||
// Try to retrieve the purged embeddings
|
||||
let retrieved_embeddings = db.get_embeddings(model, &[digest.clone()]).await.unwrap();
|
||||
assert!(
|
||||
retrieved_embeddings.is_empty(),
|
||||
"Old embeddings should have been purged"
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@ use axum::{
|
||||
Extension, Router,
|
||||
};
|
||||
use collab::{
|
||||
api::fetch_extensions_from_blob_store_periodically, db, env, executor::Executor, AppState,
|
||||
Config, RateLimiter, Result,
|
||||
api::fetch_extensions_from_blob_store_periodically, db, env, executor::Executor,
|
||||
rpc::ResultExt, AppState, Config, RateLimiter, Result,
|
||||
};
|
||||
use db::Database;
|
||||
use std::{
|
||||
@@ -23,7 +23,7 @@ use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{
|
||||
filter::EnvFilter, fmt::format::JsonFields, util::SubscriberInitExt, Layer,
|
||||
};
|
||||
use util::ResultExt;
|
||||
use util::ResultExt as _;
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const REVISION: Option<&'static str> = option_env!("GITHUB_SHA");
|
||||
@@ -90,6 +90,7 @@ async fn main() -> Result<()> {
|
||||
};
|
||||
|
||||
if is_collab {
|
||||
state.db.purge_old_embeddings().await.trace_err();
|
||||
RateLimiter::save_periodically(state.rate_limiter.clone(), state.executor.clone());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::db::{ChannelId, ChannelRole, UserId};
|
||||
use crate::db::{ChannelId, ChannelRole, DevServerId, PrincipalId, UserId};
|
||||
use anyhow::{anyhow, Result};
|
||||
use collections::{BTreeMap, HashMap, HashSet};
|
||||
use rpc::ConnectionId;
|
||||
use rpc::{proto, ConnectionId};
|
||||
use semantic_version::SemanticVersion;
|
||||
use serde::Serialize;
|
||||
use std::fmt;
|
||||
@@ -10,12 +10,13 @@ use tracing::instrument;
|
||||
#[derive(Default, Serialize)]
|
||||
pub struct ConnectionPool {
|
||||
connections: BTreeMap<ConnectionId, Connection>,
|
||||
connected_users: BTreeMap<UserId, ConnectedUser>,
|
||||
connected_users: BTreeMap<UserId, ConnectedPrincipal>,
|
||||
connected_dev_servers: BTreeMap<DevServerId, ConnectionId>,
|
||||
channels: ChannelPool,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
struct ConnectedUser {
|
||||
struct ConnectedPrincipal {
|
||||
connection_ids: HashSet<ConnectionId>,
|
||||
}
|
||||
|
||||
@@ -30,13 +31,13 @@ impl fmt::Display for ZedVersion {
|
||||
|
||||
impl ZedVersion {
|
||||
pub fn can_collaborate(&self) -> bool {
|
||||
self.0 >= SemanticVersion::new(0, 127, 3)
|
||||
self.0 >= SemanticVersion::new(0, 129, 2)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Connection {
|
||||
pub user_id: UserId,
|
||||
pub principal_id: PrincipalId,
|
||||
pub admin: bool,
|
||||
pub zed_version: ZedVersion,
|
||||
}
|
||||
@@ -59,7 +60,7 @@ impl ConnectionPool {
|
||||
self.connections.insert(
|
||||
connection_id,
|
||||
Connection {
|
||||
user_id,
|
||||
principal_id: PrincipalId::UserId(user_id),
|
||||
admin,
|
||||
zed_version,
|
||||
},
|
||||
@@ -68,6 +69,25 @@ impl ConnectionPool {
|
||||
connected_user.connection_ids.insert(connection_id);
|
||||
}
|
||||
|
||||
pub fn add_dev_server(
|
||||
&mut self,
|
||||
connection_id: ConnectionId,
|
||||
dev_server_id: DevServerId,
|
||||
zed_version: ZedVersion,
|
||||
) {
|
||||
self.connections.insert(
|
||||
connection_id,
|
||||
Connection {
|
||||
principal_id: PrincipalId::DevServerId(dev_server_id),
|
||||
admin: false,
|
||||
zed_version,
|
||||
},
|
||||
);
|
||||
|
||||
self.connected_dev_servers
|
||||
.insert(dev_server_id, connection_id);
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub fn remove_connection(&mut self, connection_id: ConnectionId) -> Result<()> {
|
||||
let connection = self
|
||||
@@ -75,12 +95,18 @@ impl ConnectionPool {
|
||||
.get_mut(&connection_id)
|
||||
.ok_or_else(|| anyhow!("no such connection"))?;
|
||||
|
||||
let user_id = connection.user_id;
|
||||
let connected_user = self.connected_users.get_mut(&user_id).unwrap();
|
||||
connected_user.connection_ids.remove(&connection_id);
|
||||
if connected_user.connection_ids.is_empty() {
|
||||
self.connected_users.remove(&user_id);
|
||||
self.channels.remove_user(&user_id);
|
||||
match connection.principal_id {
|
||||
PrincipalId::UserId(user_id) => {
|
||||
let connected_user = self.connected_users.get_mut(&user_id).unwrap();
|
||||
connected_user.connection_ids.remove(&connection_id);
|
||||
if connected_user.connection_ids.is_empty() {
|
||||
self.connected_users.remove(&user_id);
|
||||
self.channels.remove_user(&user_id);
|
||||
}
|
||||
}
|
||||
PrincipalId::DevServerId(dev_server_id) => {
|
||||
self.connected_dev_servers.remove(&dev_server_id);
|
||||
}
|
||||
}
|
||||
self.connections.remove(&connection_id).unwrap();
|
||||
Ok(())
|
||||
@@ -110,6 +136,18 @@ impl ConnectionPool {
|
||||
.copied()
|
||||
}
|
||||
|
||||
pub fn dev_server_status(&self, dev_server_id: DevServerId) -> proto::DevServerStatus {
|
||||
if self.dev_server_connection_id(dev_server_id).is_some() {
|
||||
proto::DevServerStatus::Online
|
||||
} else {
|
||||
proto::DevServerStatus::Offline
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dev_server_connection_id(&self, dev_server_id: DevServerId) -> Option<ConnectionId> {
|
||||
self.connected_dev_servers.get(&dev_server_id).copied()
|
||||
}
|
||||
|
||||
pub fn channel_user_ids(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
@@ -154,22 +192,39 @@ impl ConnectionPool {
|
||||
#[cfg(test)]
|
||||
pub fn check_invariants(&self) {
|
||||
for (connection_id, connection) in &self.connections {
|
||||
assert!(self
|
||||
.connected_users
|
||||
.get(&connection.user_id)
|
||||
.unwrap()
|
||||
.connection_ids
|
||||
.contains(connection_id));
|
||||
match &connection.principal_id {
|
||||
PrincipalId::UserId(user_id) => {
|
||||
assert!(self
|
||||
.connected_users
|
||||
.get(user_id)
|
||||
.unwrap()
|
||||
.connection_ids
|
||||
.contains(connection_id));
|
||||
}
|
||||
PrincipalId::DevServerId(dev_server_id) => {
|
||||
assert_eq!(
|
||||
self.connected_dev_servers.get(&dev_server_id).unwrap(),
|
||||
connection_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (user_id, state) in &self.connected_users {
|
||||
for connection_id in &state.connection_ids {
|
||||
assert_eq!(
|
||||
self.connections.get(connection_id).unwrap().user_id,
|
||||
*user_id
|
||||
self.connections.get(connection_id).unwrap().principal_id,
|
||||
PrincipalId::UserId(*user_id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (dev_server_id, connection_id) in &self.connected_dev_servers {
|
||||
assert_eq!(
|
||||
self.connections.get(connection_id).unwrap().principal_id,
|
||||
PrincipalId::DevServerId(*dev_server_id)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ mod channel_buffer_tests;
|
||||
mod channel_guest_tests;
|
||||
mod channel_message_tests;
|
||||
mod channel_tests;
|
||||
mod dev_server_tests;
|
||||
mod editor_tests;
|
||||
mod following_tests;
|
||||
mod integration_tests;
|
||||
|
||||
110
crates/collab/src/tests/dev_server_tests.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use std::path::Path;
|
||||
|
||||
use editor::Editor;
|
||||
use fs::Fs;
|
||||
use gpui::VisualTestContext;
|
||||
use rpc::proto::DevServerStatus;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::tests::TestServer;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppContext) {
|
||||
let (server, client) = TestServer::start1(cx).await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel("test", None, (&client, cx), &mut [])
|
||||
.await;
|
||||
|
||||
let resp = client
|
||||
.channel_store()
|
||||
.update(cx, |store, cx| {
|
||||
store.create_dev_server(channel_id, "server-1".to_string(), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
client.channel_store().update(cx, |store, _| {
|
||||
assert_eq!(store.dev_servers_for_id(channel_id).len(), 1);
|
||||
assert_eq!(store.dev_servers_for_id(channel_id)[0].name, "server-1");
|
||||
assert_eq!(
|
||||
store.dev_servers_for_id(channel_id)[0].status,
|
||||
DevServerStatus::Offline
|
||||
);
|
||||
});
|
||||
|
||||
let dev_server = server.create_dev_server(resp.access_token, cx2).await;
|
||||
cx.executor().run_until_parked();
|
||||
client.channel_store().update(cx, |store, _| {
|
||||
assert_eq!(
|
||||
store.dev_servers_for_id(channel_id)[0].status,
|
||||
DevServerStatus::Online
|
||||
);
|
||||
});
|
||||
|
||||
dev_server
|
||||
.fs()
|
||||
.insert_tree(
|
||||
"/remote",
|
||||
json!({
|
||||
"1.txt": "remote\nremote\nremote",
|
||||
"2.js": "function two() { return 2; }",
|
||||
"3.rs": "mod test",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
client
|
||||
.channel_store()
|
||||
.update(cx, |store, cx| {
|
||||
store.create_remote_project(
|
||||
channel_id,
|
||||
client::DevServerId(resp.dev_server_id),
|
||||
"project-1".to_string(),
|
||||
"/remote".to_string(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
let remote_workspace = client
|
||||
.channel_store()
|
||||
.update(cx, |store, cx| {
|
||||
let projects = store.remote_projects_for_id(channel_id);
|
||||
assert_eq!(projects.len(), 1);
|
||||
assert_eq!(projects[0].name, "project-1");
|
||||
workspace::join_remote_project(
|
||||
projects[0].project_id.unwrap(),
|
||||
client.app_state.clone(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
let cx2 = VisualTestContext::from_window(remote_workspace.into(), cx).as_mut();
|
||||
cx2.simulate_keystrokes("cmd-p 1 enter");
|
||||
|
||||
let editor = remote_workspace
|
||||
.update(cx2, |ws, cx| {
|
||||
ws.active_item_as::<Editor>(cx).unwrap().clone()
|
||||
})
|
||||
.unwrap();
|
||||
editor.update(cx2, |ed, cx| {
|
||||
assert_eq!(ed.text(cx).to_string(), "remote\nremote\nremote");
|
||||
});
|
||||
cx2.simulate_input("wow!");
|
||||
cx2.simulate_keystrokes("cmd-s");
|
||||
|
||||
let content = dev_server
|
||||
.fs()
|
||||
.load(&Path::new("/remote/1.txt"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(content, "wow!remote\nremote\nremote\n");
|
||||
}
|
||||
@@ -2100,14 +2100,12 @@ async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestA
|
||||
|
||||
blame.update(cx, |blame, _| {
|
||||
for (idx, entry) in entries.iter().flatten().enumerate() {
|
||||
let details = blame.details_for_entry(entry).unwrap();
|
||||
assert_eq!(details.message, format!("message for idx-{}", idx));
|
||||
assert_eq!(
|
||||
blame.permalink_for_entry(entry).unwrap().to_string(),
|
||||
details.permalink.unwrap().to_string(),
|
||||
format!("http://example.com/codehost/idx-{}", idx)
|
||||
);
|
||||
assert_eq!(
|
||||
blame.message_for_entry(entry).unwrap(),
|
||||
format!("message for idx-{}", idx)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
use unindent::Unindent as _;
|
||||
use workspace::Pane;
|
||||
|
||||
#[ctor::ctor]
|
||||
fn init_logger() {
|
||||
@@ -3759,7 +3760,7 @@ async fn test_leaving_project(
|
||||
|
||||
// Client B can't join the project, unless they re-join the room.
|
||||
cx_b.spawn(|cx| {
|
||||
Project::remote(
|
||||
Project::in_room(
|
||||
project_id,
|
||||
client_b.app_state.client.clone(),
|
||||
client_b.user_store().clone(),
|
||||
@@ -6127,3 +6128,269 @@ async fn test_join_after_restart(cx1: &mut TestAppContext, cx2: &mut TestAppCont
|
||||
let client2 = server.create_client(cx2, "user_a").await;
|
||||
join_channel(channel2, &client2, cx2).await.unwrap();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_preview_tabs(cx: &mut TestAppContext) {
|
||||
let (_server, client) = TestServer::start1(cx).await;
|
||||
let (workspace, cx) = client.build_test_workspace(cx).await;
|
||||
let project = workspace.update(cx, |workspace, _| workspace.project().clone());
|
||||
|
||||
let worktree_id = project.update(cx, |project, cx| {
|
||||
project.worktrees().next().unwrap().read(cx).id()
|
||||
});
|
||||
|
||||
let path_1 = ProjectPath {
|
||||
worktree_id,
|
||||
path: Path::new("1.txt").into(),
|
||||
};
|
||||
let path_2 = ProjectPath {
|
||||
worktree_id,
|
||||
path: Path::new("2.js").into(),
|
||||
};
|
||||
let path_3 = ProjectPath {
|
||||
worktree_id,
|
||||
path: Path::new("3.rs").into(),
|
||||
};
|
||||
|
||||
let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
|
||||
|
||||
let get_path = |pane: &Pane, idx: usize, cx: &AppContext| {
|
||||
pane.item_for_index(idx).unwrap().project_path(cx).unwrap()
|
||||
};
|
||||
|
||||
// Opening item 3 as a "permanent" tab
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.open_path(path_3.clone(), None, false, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
pane.update(cx, |pane, cx| {
|
||||
assert_eq!(pane.items_len(), 1);
|
||||
assert_eq!(get_path(pane, 0, cx), path_3.clone());
|
||||
assert_eq!(pane.preview_item_id(), None);
|
||||
|
||||
assert!(!pane.can_navigate_backward());
|
||||
assert!(!pane.can_navigate_forward());
|
||||
});
|
||||
|
||||
// Open item 1 as preview
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.open_path_preview(path_1.clone(), None, true, true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
pane.update(cx, |pane, cx| {
|
||||
assert_eq!(pane.items_len(), 2);
|
||||
assert_eq!(get_path(pane, 0, cx), path_3.clone());
|
||||
assert_eq!(get_path(pane, 1, cx), path_1.clone());
|
||||
assert_eq!(
|
||||
pane.preview_item_id(),
|
||||
Some(pane.items().nth(1).unwrap().item_id())
|
||||
);
|
||||
|
||||
assert!(pane.can_navigate_backward());
|
||||
assert!(!pane.can_navigate_forward());
|
||||
});
|
||||
|
||||
// Open item 2 as preview
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.open_path_preview(path_2.clone(), None, true, true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
pane.update(cx, |pane, cx| {
|
||||
assert_eq!(pane.items_len(), 2);
|
||||
assert_eq!(get_path(pane, 0, cx), path_3.clone());
|
||||
assert_eq!(get_path(pane, 1, cx), path_2.clone());
|
||||
assert_eq!(
|
||||
pane.preview_item_id(),
|
||||
Some(pane.items().nth(1).unwrap().item_id())
|
||||
);
|
||||
|
||||
assert!(pane.can_navigate_backward());
|
||||
assert!(!pane.can_navigate_forward());
|
||||
});
|
||||
|
||||
// Going back should show item 1 as preview
|
||||
workspace
|
||||
.update(cx, |workspace, cx| workspace.go_back(pane.downgrade(), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
pane.update(cx, |pane, cx| {
|
||||
assert_eq!(pane.items_len(), 2);
|
||||
assert_eq!(get_path(pane, 0, cx), path_3.clone());
|
||||
assert_eq!(get_path(pane, 1, cx), path_1.clone());
|
||||
assert_eq!(
|
||||
pane.preview_item_id(),
|
||||
Some(pane.items().nth(1).unwrap().item_id())
|
||||
);
|
||||
|
||||
assert!(pane.can_navigate_backward());
|
||||
assert!(pane.can_navigate_forward());
|
||||
});
|
||||
|
||||
// Closing item 1
|
||||
pane.update(cx, |pane, cx| {
|
||||
pane.close_item_by_id(
|
||||
pane.active_item().unwrap().item_id(),
|
||||
workspace::SaveIntent::Skip,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
pane.update(cx, |pane, cx| {
|
||||
assert_eq!(pane.items_len(), 1);
|
||||
assert_eq!(get_path(pane, 0, cx), path_3.clone());
|
||||
assert_eq!(pane.preview_item_id(), None);
|
||||
|
||||
assert!(pane.can_navigate_backward());
|
||||
assert!(!pane.can_navigate_forward());
|
||||
});
|
||||
|
||||
// Going back should show item 1 as preview
|
||||
workspace
|
||||
.update(cx, |workspace, cx| workspace.go_back(pane.downgrade(), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
pane.update(cx, |pane, cx| {
|
||||
assert_eq!(pane.items_len(), 2);
|
||||
assert_eq!(get_path(pane, 0, cx), path_3.clone());
|
||||
assert_eq!(get_path(pane, 1, cx), path_1.clone());
|
||||
assert_eq!(
|
||||
pane.preview_item_id(),
|
||||
Some(pane.items().nth(1).unwrap().item_id())
|
||||
);
|
||||
|
||||
assert!(pane.can_navigate_backward());
|
||||
assert!(pane.can_navigate_forward());
|
||||
});
|
||||
|
||||
// Close permanent tab
|
||||
pane.update(cx, |pane, cx| {
|
||||
let id = pane.items().nth(0).unwrap().item_id();
|
||||
pane.close_item_by_id(id, workspace::SaveIntent::Skip, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
pane.update(cx, |pane, cx| {
|
||||
assert_eq!(pane.items_len(), 1);
|
||||
assert_eq!(get_path(pane, 0, cx), path_1.clone());
|
||||
assert_eq!(
|
||||
pane.preview_item_id(),
|
||||
Some(pane.items().nth(0).unwrap().item_id())
|
||||
);
|
||||
|
||||
assert!(pane.can_navigate_backward());
|
||||
assert!(pane.can_navigate_forward());
|
||||
});
|
||||
|
||||
// Split pane to the right
|
||||
pane.update(cx, |pane, cx| {
|
||||
pane.split(workspace::SplitDirection::Right, cx);
|
||||
});
|
||||
|
||||
let right_pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
|
||||
|
||||
pane.update(cx, |pane, cx| {
|
||||
assert_eq!(pane.items_len(), 1);
|
||||
assert_eq!(get_path(pane, 0, cx), path_1.clone());
|
||||
assert_eq!(
|
||||
pane.preview_item_id(),
|
||||
Some(pane.items().nth(0).unwrap().item_id())
|
||||
);
|
||||
|
||||
assert!(pane.can_navigate_backward());
|
||||
assert!(pane.can_navigate_forward());
|
||||
});
|
||||
|
||||
right_pane.update(cx, |pane, cx| {
|
||||
assert_eq!(pane.items_len(), 1);
|
||||
assert_eq!(get_path(pane, 0, cx), path_1.clone());
|
||||
assert_eq!(pane.preview_item_id(), None);
|
||||
|
||||
assert!(!pane.can_navigate_backward());
|
||||
assert!(!pane.can_navigate_forward());
|
||||
});
|
||||
|
||||
// Open item 2 as preview in right pane
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.open_path_preview(path_2.clone(), None, true, true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
pane.update(cx, |pane, cx| {
|
||||
assert_eq!(pane.items_len(), 1);
|
||||
assert_eq!(get_path(pane, 0, cx), path_1.clone());
|
||||
assert_eq!(
|
||||
pane.preview_item_id(),
|
||||
Some(pane.items().nth(0).unwrap().item_id())
|
||||
);
|
||||
|
||||
assert!(pane.can_navigate_backward());
|
||||
assert!(pane.can_navigate_forward());
|
||||
});
|
||||
|
||||
right_pane.update(cx, |pane, cx| {
|
||||
assert_eq!(pane.items_len(), 2);
|
||||
assert_eq!(get_path(pane, 0, cx), path_1.clone());
|
||||
assert_eq!(get_path(pane, 1, cx), path_2.clone());
|
||||
assert_eq!(
|
||||
pane.preview_item_id(),
|
||||
Some(pane.items().nth(1).unwrap().item_id())
|
||||
);
|
||||
|
||||
assert!(pane.can_navigate_backward());
|
||||
assert!(!pane.can_navigate_forward());
|
||||
});
|
||||
|
||||
// Focus left pane
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.activate_pane_in_direction(workspace::SplitDirection::Left, cx)
|
||||
});
|
||||
|
||||
// Open item 2 as preview in left pane
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.open_path_preview(path_2.clone(), None, true, true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
pane.update(cx, |pane, cx| {
|
||||
assert_eq!(pane.items_len(), 1);
|
||||
assert_eq!(get_path(pane, 0, cx), path_2.clone());
|
||||
assert_eq!(
|
||||
pane.preview_item_id(),
|
||||
Some(pane.items().nth(0).unwrap().item_id())
|
||||
);
|
||||
|
||||
assert!(pane.can_navigate_backward());
|
||||
assert!(!pane.can_navigate_forward());
|
||||
});
|
||||
|
||||
right_pane.update(cx, |pane, cx| {
|
||||
assert_eq!(pane.items_len(), 2);
|
||||
assert_eq!(get_path(pane, 0, cx), path_1.clone());
|
||||
assert_eq!(get_path(pane, 1, cx), path_2.clone());
|
||||
assert_eq!(
|
||||
pane.preview_item_id(),
|
||||
Some(pane.items().nth(1).unwrap().item_id())
|
||||
);
|
||||
|
||||
assert!(pane.can_navigate_backward());
|
||||
assert!(!pane.can_navigate_forward());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1347,13 +1347,11 @@ impl RandomizedTest for ProjectCollaborationTest {
|
||||
client.username
|
||||
);
|
||||
|
||||
let host_saved_version_fingerprint =
|
||||
host_buffer.read_with(host_cx, |b, _| b.saved_version_fingerprint());
|
||||
let guest_saved_version_fingerprint =
|
||||
guest_buffer.read_with(client_cx, |b, _| b.saved_version_fingerprint());
|
||||
let host_is_dirty = host_buffer.read_with(host_cx, |b, _| b.is_dirty());
|
||||
let guest_is_dirty = guest_buffer.read_with(client_cx, |b, _| b.is_dirty());
|
||||
assert_eq!(
|
||||
guest_saved_version_fingerprint, host_saved_version_fingerprint,
|
||||
"guest {} saved fingerprint does not match host's for path {path:?} in project {project_id}",
|
||||
guest_is_dirty, host_is_dirty,
|
||||
"guest {} dirty state does not match host's for path {path:?} in project {project_id}",
|
||||
client.username
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::{
|
||||
auth::split_dev_server_token,
|
||||
db::{tests::TestDb, NewUserParams, UserId},
|
||||
executor::Executor,
|
||||
rpc::{Principal, Server, ZedVersion, CLEANUP_TIMEOUT, RECONNECT_TIMEOUT},
|
||||
@@ -302,6 +303,130 @@ impl TestServer {
|
||||
client
|
||||
}
|
||||
|
||||
pub async fn create_dev_server(
|
||||
&self,
|
||||
access_token: String,
|
||||
cx: &mut TestAppContext,
|
||||
) -> TestClient {
|
||||
cx.update(|cx| {
|
||||
if cx.has_global::<SettingsStore>() {
|
||||
panic!("Same cx used to create two test clients")
|
||||
}
|
||||
let settings = SettingsStore::test(cx);
|
||||
cx.set_global(settings);
|
||||
release_channel::init("0.0.0", cx);
|
||||
client::init_settings(cx);
|
||||
});
|
||||
let (dev_server_id, _) = split_dev_server_token(&access_token).unwrap();
|
||||
|
||||
let clock = Arc::new(FakeSystemClock::default());
|
||||
let http = FakeHttpClient::with_404_response();
|
||||
let mut client = cx.update(|cx| Client::new(clock, http.clone(), cx));
|
||||
let server = self.server.clone();
|
||||
let db = self.app_state.db.clone();
|
||||
let connection_killers = self.connection_killers.clone();
|
||||
let forbid_connections = self.forbid_connections.clone();
|
||||
Arc::get_mut(&mut client)
|
||||
.unwrap()
|
||||
.set_id(1)
|
||||
.set_dev_server_token(client::DevServerToken(access_token.clone()))
|
||||
.override_establish_connection(move |credentials, cx| {
|
||||
assert_eq!(
|
||||
credentials,
|
||||
&Credentials::DevServer {
|
||||
token: client::DevServerToken(access_token.to_string())
|
||||
}
|
||||
);
|
||||
|
||||
let server = server.clone();
|
||||
let db = db.clone();
|
||||
let connection_killers = connection_killers.clone();
|
||||
let forbid_connections = forbid_connections.clone();
|
||||
cx.spawn(move |cx| async move {
|
||||
if forbid_connections.load(SeqCst) {
|
||||
Err(EstablishConnectionError::other(anyhow!(
|
||||
"server is forbidding connections"
|
||||
)))
|
||||
} else {
|
||||
let (client_conn, server_conn, killed) =
|
||||
Connection::in_memory(cx.background_executor().clone());
|
||||
let (connection_id_tx, connection_id_rx) = oneshot::channel();
|
||||
let dev_server = db
|
||||
.get_dev_server(dev_server_id)
|
||||
.await
|
||||
.expect("retrieving dev_server failed");
|
||||
cx.background_executor()
|
||||
.spawn(server.handle_connection(
|
||||
server_conn,
|
||||
"dev-server".to_string(),
|
||||
Principal::DevServer(dev_server),
|
||||
ZedVersion(SemanticVersion::new(1, 0, 0)),
|
||||
Some(connection_id_tx),
|
||||
Executor::Deterministic(cx.background_executor().clone()),
|
||||
))
|
||||
.detach();
|
||||
let connection_id = connection_id_rx.await.map_err(|e| {
|
||||
EstablishConnectionError::Other(anyhow!(
|
||||
"{} (is server shutting down?)",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
connection_killers
|
||||
.lock()
|
||||
.insert(connection_id.into(), killed);
|
||||
Ok(client_conn)
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
|
||||
let workspace_store = cx.new_model(|cx| WorkspaceStore::new(client.clone(), cx));
|
||||
let language_registry = Arc::new(LanguageRegistry::test(cx.executor()));
|
||||
let app_state = Arc::new(workspace::AppState {
|
||||
client: client.clone(),
|
||||
user_store: user_store.clone(),
|
||||
workspace_store,
|
||||
languages: language_registry,
|
||||
fs: fs.clone(),
|
||||
build_window_options: |_, _| Default::default(),
|
||||
node_runtime: FakeNodeRuntime::new(),
|
||||
});
|
||||
|
||||
cx.update(|cx| {
|
||||
theme::init(theme::LoadThemes::JustBase, cx);
|
||||
Project::init(&client, cx);
|
||||
client::init(&client, cx);
|
||||
language::init(cx);
|
||||
editor::init(cx);
|
||||
workspace::init(app_state.clone(), cx);
|
||||
call::init(client.clone(), user_store.clone(), cx);
|
||||
channel::init(&client, user_store.clone(), cx);
|
||||
notifications::init(client.clone(), user_store, cx);
|
||||
collab_ui::init(&app_state, cx);
|
||||
file_finder::init(cx);
|
||||
menu::init();
|
||||
headless::init(
|
||||
client.clone(),
|
||||
headless::AppState {
|
||||
languages: app_state.languages.clone(),
|
||||
user_store: app_state.user_store.clone(),
|
||||
fs: fs.clone(),
|
||||
node_runtime: app_state.node_runtime.clone(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
TestClient {
|
||||
app_state,
|
||||
username: "dev-server".to_string(),
|
||||
channel_store: cx.read(ChannelStore::global).clone(),
|
||||
notification_store: cx.read(NotificationStore::global).clone(),
|
||||
state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disconnect_client(&self, peer_id: PeerId) {
|
||||
self.connection_killers
|
||||
.lock()
|
||||
|
||||
@@ -39,6 +39,7 @@ db.workspace = true
|
||||
editor.workspace = true
|
||||
emojis.workspace = true
|
||||
extensions_ui.workspace = true
|
||||
feature_flags.workspace = true
|
||||
futures.workspace = true
|
||||
fuzzy.workspace = true
|
||||
gpui.workspace = true
|
||||
|
||||
@@ -22,8 +22,9 @@ use std::{
|
||||
};
|
||||
use ui::{prelude::*, Label};
|
||||
use util::ResultExt;
|
||||
use workspace::notifications::NotificationId;
|
||||
use workspace::{
|
||||
item::{FollowableItem, Item, ItemEvent, ItemHandle},
|
||||
item::{FollowableItem, Item, ItemEvent, ItemHandle, TabContentParams},
|
||||
register_followable_item,
|
||||
searchable::SearchableItemHandle,
|
||||
ItemNavHistory, Pane, SaveIntent, Toast, ViewId, Workspace, WorkspaceId,
|
||||
@@ -269,7 +270,15 @@ impl ChannelView {
|
||||
cx.write_to_clipboard(ClipboardItem::new(link));
|
||||
self.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.show_toast(Toast::new(0, "Link copied to clipboard"), cx);
|
||||
struct CopyLinkForPositionToast;
|
||||
|
||||
workspace.show_toast(
|
||||
Toast::new(
|
||||
NotificationId::unique::<CopyLinkForPositionToast>(),
|
||||
"Link copied to clipboard",
|
||||
),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
@@ -365,7 +374,7 @@ impl Item for ChannelView {
|
||||
}
|
||||
}
|
||||
|
||||
fn tab_content(&self, _: Option<usize>, selected: bool, cx: &WindowContext) -> AnyElement {
|
||||
fn tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement {
|
||||
let label = if let Some(channel) = self.channel(cx) {
|
||||
match (
|
||||
self.channel_buffer.read(cx).buffer().read(cx).read_only(),
|
||||
@@ -379,7 +388,7 @@ impl Item for ChannelView {
|
||||
"channel notes (disconnected)".to_string()
|
||||
};
|
||||
Label::new(label)
|
||||
.color(if selected {
|
||||
.color(if params.selected {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
|
||||
@@ -531,6 +531,8 @@ impl ChatPanel {
|
||||
&self.languages,
|
||||
self.client.id(),
|
||||
&message,
|
||||
self.local_timezone,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
el.child(
|
||||
@@ -744,6 +746,8 @@ impl ChatPanel {
|
||||
language_registry: &Arc<LanguageRegistry>,
|
||||
current_user_id: u64,
|
||||
message: &channel::ChannelMessage,
|
||||
local_timezone: UtcOffset,
|
||||
cx: &AppContext,
|
||||
) -> RichText {
|
||||
let mentions = message
|
||||
.mentions
|
||||
@@ -754,24 +758,39 @@ impl ChatPanel {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
const MESSAGE_UPDATED: &str = " (edited)";
|
||||
const MESSAGE_EDITED: &str = " (edited)";
|
||||
|
||||
let mut body = message.body.clone();
|
||||
|
||||
if message.edited_at.is_some() {
|
||||
body.push_str(MESSAGE_UPDATED);
|
||||
body.push_str(MESSAGE_EDITED);
|
||||
}
|
||||
|
||||
let mut rich_text = rich_text::render_rich_text(body, &mentions, language_registry, None);
|
||||
|
||||
if message.edited_at.is_some() {
|
||||
let range = (rich_text.text.len() - MESSAGE_EDITED.len())..rich_text.text.len();
|
||||
rich_text.highlights.push((
|
||||
(rich_text.text.len() - MESSAGE_UPDATED.len())..rich_text.text.len(),
|
||||
range.clone(),
|
||||
Highlight::Highlight(HighlightStyle {
|
||||
fade_out: Some(0.8),
|
||||
color: Some(cx.theme().colors().text_muted),
|
||||
..Default::default()
|
||||
}),
|
||||
));
|
||||
|
||||
if let Some(edit_timestamp) = message.edited_at {
|
||||
let edit_timestamp_text = time_format::format_localized_timestamp(
|
||||
edit_timestamp,
|
||||
OffsetDateTime::now_utc(),
|
||||
local_timezone,
|
||||
time_format::TimestampFormat::Absolute,
|
||||
);
|
||||
|
||||
rich_text.custom_ranges.push(range);
|
||||
rich_text.set_tooltip_builder_for_custom_ranges(move |_, _, cx| {
|
||||
Some(Tooltip::text(edit_timestamp_text.clone(), cx))
|
||||
})
|
||||
}
|
||||
}
|
||||
rich_text
|
||||
}
|
||||
@@ -913,6 +932,10 @@ impl ChatPanel {
|
||||
|
||||
impl Render for ChatPanel {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let channel_id = self
|
||||
.active_chat
|
||||
.as_ref()
|
||||
.map(|(c, _)| c.read(cx).channel_id);
|
||||
let message_editor = self.message_editor.read(cx);
|
||||
|
||||
let reply_to_message_id = message_editor.reply_to_message_id();
|
||||
@@ -1022,6 +1045,7 @@ impl Render for ChatPanel {
|
||||
.child(
|
||||
div().flex_shrink().overflow_hidden().child(
|
||||
h_flex()
|
||||
.id(("reply-preview", reply_to_message_id))
|
||||
.child(Label::new("Replying to ").size(LabelSize::Small))
|
||||
.child(
|
||||
div().font_weight(FontWeight::BOLD).child(
|
||||
@@ -1031,7 +1055,20 @@ impl Render for ChatPanel {
|
||||
))
|
||||
.size(LabelSize::Small),
|
||||
),
|
||||
),
|
||||
)
|
||||
.when_some(channel_id, |this, channel_id| {
|
||||
this.cursor_pointer().on_click(cx.listener(
|
||||
move |chat_panel, _, cx| {
|
||||
chat_panel
|
||||
.select_channel(
|
||||
channel_id,
|
||||
reply_to_message_id.into(),
|
||||
cx,
|
||||
)
|
||||
.detach_and_log_err(cx)
|
||||
},
|
||||
))
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -1158,7 +1195,13 @@ mod tests {
|
||||
edited_at: None,
|
||||
};
|
||||
|
||||
let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message);
|
||||
let message = ChatPanel::render_markdown_with_mentions(
|
||||
&language_registry,
|
||||
102,
|
||||
&message,
|
||||
UtcOffset::UTC,
|
||||
cx,
|
||||
);
|
||||
|
||||
// Note that the "'" was replaced with ’ due to smart punctuation.
|
||||
let (body, ranges) = marked_text_ranges("«hi», «@abc», let’s «call» «@fgh»", false);
|
||||
@@ -1206,7 +1249,13 @@ mod tests {
|
||||
edited_at: None,
|
||||
};
|
||||
|
||||
let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message);
|
||||
let message = ChatPanel::render_markdown_with_mentions(
|
||||
&language_registry,
|
||||
102,
|
||||
&message,
|
||||
UtcOffset::UTC,
|
||||
cx,
|
||||
);
|
||||
|
||||
// Note that the "'" was replaced with ’ due to smart punctuation.
|
||||
let (body, ranges) =
|
||||
@@ -1247,7 +1296,13 @@ mod tests {
|
||||
edited_at: None,
|
||||
};
|
||||
|
||||
let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message);
|
||||
let message = ChatPanel::render_markdown_with_mentions(
|
||||
&language_registry,
|
||||
102,
|
||||
&message,
|
||||
UtcOffset::UTC,
|
||||
cx,
|
||||
);
|
||||
|
||||
// Note that the "'" was replaced with ’ due to smart punctuation.
|
||||
let (body, ranges) = marked_text_ranges(
|
||||
|
||||
@@ -557,6 +557,7 @@ mod tests {
|
||||
use clock::FakeSystemClock;
|
||||
use gpui::TestAppContext;
|
||||
use language::{Language, LanguageConfig};
|
||||
use project::Project;
|
||||
use rpc::proto;
|
||||
use settings::SettingsStore;
|
||||
use util::{http::FakeHttpClient, test::marked_text_ranges};
|
||||
@@ -630,6 +631,7 @@ mod tests {
|
||||
let client = Client::new(clock, http.clone(), cx);
|
||||
let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
|
||||
theme::init(theme::LoadThemes::JustBase, cx);
|
||||
Project::init_settings(cx);
|
||||
language::init(cx);
|
||||
editor::init(cx);
|
||||
client::init(&client, cx);
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
mod channel_modal;
|
||||
mod contact_finder;
|
||||
mod dev_server_modal;
|
||||
|
||||
use self::channel_modal::ChannelModal;
|
||||
use self::dev_server_modal::DevServerModal;
|
||||
use crate::{
|
||||
channel_view::ChannelView, chat_panel::ChatPanel, face_pile::FacePile,
|
||||
CollaborationPanelSettings,
|
||||
};
|
||||
use call::ActiveCall;
|
||||
use channel::{Channel, ChannelEvent, ChannelStore};
|
||||
use channel::{Channel, ChannelEvent, ChannelStore, RemoteProject};
|
||||
use client::{ChannelId, Client, Contact, ProjectId, User, UserStore};
|
||||
use contact_finder::ContactFinder;
|
||||
use db::kvp::KEY_VALUE_STORE;
|
||||
use editor::{Editor, EditorElement, EditorStyle};
|
||||
use feature_flags::{self, FeatureFlagAppExt};
|
||||
use fuzzy::{match_strings, StringMatchCandidate};
|
||||
use gpui::{
|
||||
actions, anchored, canvas, deferred, div, fill, list, point, prelude::*, px, AnyElement,
|
||||
@@ -24,7 +27,7 @@ use gpui::{
|
||||
use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrev};
|
||||
use project::{Fs, Project};
|
||||
use rpc::{
|
||||
proto::{self, ChannelVisibility, PeerId},
|
||||
proto::{self, ChannelVisibility, DevServerStatus, PeerId},
|
||||
ErrorCode, ErrorExt,
|
||||
};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
@@ -188,6 +191,7 @@ enum ListEntry {
|
||||
id: ProjectId,
|
||||
name: SharedString,
|
||||
},
|
||||
RemoteProject(channel::RemoteProject),
|
||||
Contact {
|
||||
contact: Arc<Contact>,
|
||||
calling: bool,
|
||||
@@ -278,10 +282,23 @@ impl CollabPanel {
|
||||
.push(cx.observe(&this.user_store, |this, _, cx| {
|
||||
this.update_entries(true, cx)
|
||||
}));
|
||||
this.subscriptions
|
||||
.push(cx.observe(&this.channel_store, |this, _, cx| {
|
||||
let mut has_opened = false;
|
||||
this.subscriptions.push(cx.observe(
|
||||
&this.channel_store,
|
||||
move |this, channel_store, cx| {
|
||||
if !has_opened {
|
||||
if !channel_store
|
||||
.read(cx)
|
||||
.dev_servers_for_id(ChannelId(1))
|
||||
.is_empty()
|
||||
{
|
||||
this.manage_remote_projects(ChannelId(1), cx);
|
||||
has_opened = true;
|
||||
}
|
||||
}
|
||||
this.update_entries(true, cx)
|
||||
}));
|
||||
},
|
||||
));
|
||||
this.subscriptions
|
||||
.push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
|
||||
this.subscriptions.push(cx.subscribe(
|
||||
@@ -569,6 +586,7 @@ impl CollabPanel {
|
||||
}
|
||||
|
||||
let hosted_projects = channel_store.projects_for_id(channel.id);
|
||||
let remote_projects = channel_store.remote_projects_for_id(channel.id);
|
||||
let has_children = channel_store
|
||||
.channel_at_index(mat.candidate_id + 1)
|
||||
.map_or(false, |next_channel| {
|
||||
@@ -604,7 +622,13 @@ impl CollabPanel {
|
||||
}
|
||||
|
||||
for (name, id) in hosted_projects {
|
||||
self.entries.push(ListEntry::HostedProject { id, name })
|
||||
self.entries.push(ListEntry::HostedProject { id, name });
|
||||
}
|
||||
|
||||
if cx.has_flag::<feature_flags::Remoting>() {
|
||||
for remote_project in remote_projects {
|
||||
self.entries.push(ListEntry::RemoteProject(remote_project));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1065,6 +1089,59 @@ impl CollabPanel {
|
||||
.tooltip(move |cx| Tooltip::text("Open Project", cx))
|
||||
}
|
||||
|
||||
fn render_remote_project(
|
||||
&self,
|
||||
remote_project: &RemoteProject,
|
||||
is_selected: bool,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> impl IntoElement {
|
||||
let id = remote_project.id;
|
||||
let name = remote_project.name.clone();
|
||||
let maybe_project_id = remote_project.project_id;
|
||||
|
||||
let dev_server = self
|
||||
.channel_store
|
||||
.read(cx)
|
||||
.find_dev_server_by_id(remote_project.dev_server_id);
|
||||
|
||||
let tooltip_text = SharedString::from(match dev_server {
|
||||
Some(dev_server) => format!("Open Remote Project ({})", dev_server.name),
|
||||
None => "Open Remote Project".to_string(),
|
||||
});
|
||||
|
||||
let dev_server_is_online = dev_server.map(|s| s.status) == Some(DevServerStatus::Online);
|
||||
|
||||
let dev_server_text_color = if dev_server_is_online {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Disabled
|
||||
};
|
||||
|
||||
ListItem::new(ElementId::NamedInteger(
|
||||
"remote-project".into(),
|
||||
id.0 as usize,
|
||||
))
|
||||
.indent_level(2)
|
||||
.indent_step_size(px(20.))
|
||||
.selected(is_selected)
|
||||
.on_click(cx.listener(move |this, _, cx| {
|
||||
//TODO display error message if dev server is offline
|
||||
if dev_server_is_online {
|
||||
if let Some(project_id) = maybe_project_id {
|
||||
this.join_remote_project(project_id, cx);
|
||||
}
|
||||
}
|
||||
}))
|
||||
.start_slot(
|
||||
h_flex()
|
||||
.relative()
|
||||
.gap_1()
|
||||
.child(IconButton::new(0, IconName::FileTree).icon_color(dev_server_text_color)),
|
||||
)
|
||||
.child(Label::new(name.clone()).color(dev_server_text_color))
|
||||
.tooltip(move |cx| Tooltip::text(tooltip_text.clone(), cx))
|
||||
}
|
||||
|
||||
fn has_subchannels(&self, ix: usize) -> bool {
|
||||
self.entries.get(ix).map_or(false, |entry| {
|
||||
if let ListEntry::Channel { has_children, .. } = entry {
|
||||
@@ -1266,11 +1343,24 @@ impl CollabPanel {
|
||||
}
|
||||
|
||||
if self.channel_store.read(cx).is_root_channel(channel_id) {
|
||||
context_menu = context_menu.separator().entry(
|
||||
"Manage Members",
|
||||
None,
|
||||
cx.handler_for(&this, move |this, cx| this.manage_members(channel_id, cx)),
|
||||
)
|
||||
context_menu = context_menu
|
||||
.separator()
|
||||
.entry(
|
||||
"Manage Members",
|
||||
None,
|
||||
cx.handler_for(&this, move |this, cx| {
|
||||
this.manage_members(channel_id, cx)
|
||||
}),
|
||||
)
|
||||
.when(cx.has_flag::<feature_flags::Remoting>(), |context_menu| {
|
||||
context_menu.entry(
|
||||
"Manage Remote Projects",
|
||||
None,
|
||||
cx.handler_for(&this, move |this, cx| {
|
||||
this.manage_remote_projects(channel_id, cx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
} else {
|
||||
context_menu = context_menu.entry(
|
||||
"Move this channel",
|
||||
@@ -1534,6 +1624,11 @@ impl CollabPanel {
|
||||
} => {
|
||||
// todo()
|
||||
}
|
||||
ListEntry::RemoteProject(project) => {
|
||||
if let Some(project_id) = project.project_id {
|
||||
self.join_remote_project(project_id, cx)
|
||||
}
|
||||
}
|
||||
|
||||
ListEntry::OutgoingRequest(_) => {}
|
||||
ListEntry::ChannelEditor { .. } => {}
|
||||
@@ -1706,6 +1801,18 @@ impl CollabPanel {
|
||||
self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, cx);
|
||||
}
|
||||
|
||||
fn manage_remote_projects(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
|
||||
let channel_store = self.channel_store.clone();
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.toggle_modal(cx, |cx| {
|
||||
DevServerModal::new(channel_store.clone(), channel_id, cx)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn remove_selected_channel(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
|
||||
if let Some(channel) = self.selected_channel() {
|
||||
self.remove_channel(channel.id, cx)
|
||||
@@ -2006,6 +2113,18 @@ impl CollabPanel {
|
||||
.detach_and_prompt_err("Failed to join channel", cx, |_, _| None)
|
||||
}
|
||||
|
||||
fn join_remote_project(&mut self, project_id: ProjectId, cx: &mut ViewContext<Self>) {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let app_state = workspace.read(cx).app_state().clone();
|
||||
workspace::join_remote_project(project_id, app_state, cx).detach_and_prompt_err(
|
||||
"Failed to join project",
|
||||
cx,
|
||||
|_, _| None,
|
||||
)
|
||||
}
|
||||
|
||||
fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
|
||||
let Some(workspace) = self.workspace.upgrade() else {
|
||||
return;
|
||||
@@ -2141,6 +2260,9 @@ impl CollabPanel {
|
||||
ListEntry::HostedProject { id, name } => self
|
||||
.render_channel_project(*id, name, is_selected, cx)
|
||||
.into_any_element(),
|
||||
ListEntry::RemoteProject(remote_project) => self
|
||||
.render_remote_project(remote_project, is_selected, cx)
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2519,7 +2641,9 @@ impl CollabPanel {
|
||||
const FACEPILE_LIMIT: usize = 3;
|
||||
let participants = self.channel_store.read(cx).channel_participants(channel_id);
|
||||
|
||||
let face_pile = if !participants.is_empty() {
|
||||
let face_pile = if participants.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
|
||||
let result = FacePile::new(
|
||||
participants
|
||||
@@ -2540,8 +2664,6 @@ impl CollabPanel {
|
||||
);
|
||||
|
||||
Some(result)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let width = self.width.unwrap_or(px(240.));
|
||||
@@ -2883,6 +3005,11 @@ impl PartialEq for ListEntry {
|
||||
return id == other_id;
|
||||
}
|
||||
}
|
||||
ListEntry::RemoteProject(project) => {
|
||||
if let ListEntry::RemoteProject(other) = other {
|
||||
return project.id == other.id;
|
||||
}
|
||||
}
|
||||
ListEntry::ChannelNotes { channel_id } => {
|
||||
if let ListEntry::ChannelNotes {
|
||||
channel_id: other_id,
|
||||
|
||||
622
crates/collab_ui/src/collab_panel/dev_server_modal.rs
Normal file
@@ -0,0 +1,622 @@
|
||||
use channel::{ChannelStore, DevServer, RemoteProject};
|
||||
use client::{ChannelId, DevServerId, RemoteProjectId};
|
||||
use editor::Editor;
|
||||
use gpui::{
|
||||
AppContext, ClipboardItem, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model,
|
||||
ScrollHandle, Task, View, ViewContext,
|
||||
};
|
||||
use rpc::proto::{self, CreateDevServerResponse, DevServerStatus};
|
||||
use ui::{prelude::*, Indicator, List, ListHeader, ModalContent, ModalHeader, Tooltip};
|
||||
use util::ResultExt;
|
||||
use workspace::ModalView;
|
||||
|
||||
pub struct DevServerModal {
|
||||
mode: Mode,
|
||||
focus_handle: FocusHandle,
|
||||
scroll_handle: ScrollHandle,
|
||||
channel_store: Model<ChannelStore>,
|
||||
channel_id: ChannelId,
|
||||
remote_project_name_editor: View<Editor>,
|
||||
remote_project_path_editor: View<Editor>,
|
||||
dev_server_name_editor: View<Editor>,
|
||||
_subscriptions: [gpui::Subscription; 2],
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CreateDevServer {
|
||||
creating: Option<Task<()>>,
|
||||
dev_server: Option<CreateDevServerResponse>,
|
||||
}
|
||||
|
||||
struct CreateRemoteProject {
|
||||
dev_server_id: DevServerId,
|
||||
creating: Option<Task<()>>,
|
||||
remote_project: Option<proto::RemoteProject>,
|
||||
}
|
||||
|
||||
enum Mode {
|
||||
Default,
|
||||
CreateRemoteProject(CreateRemoteProject),
|
||||
CreateDevServer(CreateDevServer),
|
||||
}
|
||||
|
||||
impl DevServerModal {
|
||||
pub fn new(
|
||||
channel_store: Model<ChannelStore>,
|
||||
channel_id: ChannelId,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let name_editor = cx.new_view(|cx| Editor::single_line(cx));
|
||||
let path_editor = cx.new_view(|cx| Editor::single_line(cx));
|
||||
let dev_server_name_editor = cx.new_view(|cx| {
|
||||
let mut editor = Editor::single_line(cx);
|
||||
editor.set_placeholder_text("Dev server name", cx);
|
||||
editor
|
||||
});
|
||||
|
||||
let focus_handle = cx.focus_handle();
|
||||
|
||||
let subscriptions = [
|
||||
cx.observe(&channel_store, |_, _, cx| {
|
||||
cx.notify();
|
||||
}),
|
||||
cx.on_focus_out(&focus_handle, |_, _cx| { /* cx.emit(DismissEvent) */ }),
|
||||
];
|
||||
|
||||
Self {
|
||||
mode: Mode::Default,
|
||||
focus_handle,
|
||||
scroll_handle: ScrollHandle::new(),
|
||||
channel_store,
|
||||
channel_id,
|
||||
remote_project_name_editor: name_editor,
|
||||
remote_project_path_editor: path_editor,
|
||||
dev_server_name_editor,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_remote_project(
|
||||
&mut self,
|
||||
dev_server_id: DevServerId,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let channel_id = self.channel_id;
|
||||
let name = self
|
||||
.remote_project_name_editor
|
||||
.read(cx)
|
||||
.text(cx)
|
||||
.trim()
|
||||
.to_string();
|
||||
let path = self
|
||||
.remote_project_path_editor
|
||||
.read(cx)
|
||||
.text(cx)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if name == "" {
|
||||
return;
|
||||
}
|
||||
if path == "" {
|
||||
return;
|
||||
}
|
||||
|
||||
let create = self.channel_store.update(cx, |store, cx| {
|
||||
store.create_remote_project(channel_id, dev_server_id, name, path, cx)
|
||||
});
|
||||
|
||||
let task = cx.spawn(|this, mut cx| async move {
|
||||
let result = create.await;
|
||||
if let Err(e) = &result {
|
||||
cx.prompt(
|
||||
gpui::PromptLevel::Critical,
|
||||
"Failed to create project",
|
||||
Some(&format!("{:?}. Please try again.", e)),
|
||||
&["Ok"],
|
||||
)
|
||||
.await
|
||||
.log_err();
|
||||
}
|
||||
this.update(&mut cx, |this, _| {
|
||||
this.mode = Mode::CreateRemoteProject(CreateRemoteProject {
|
||||
dev_server_id,
|
||||
creating: None,
|
||||
remote_project: result.ok().and_then(|r| r.remote_project),
|
||||
});
|
||||
})
|
||||
.log_err();
|
||||
});
|
||||
|
||||
self.mode = Mode::CreateRemoteProject(CreateRemoteProject {
|
||||
dev_server_id,
|
||||
creating: Some(task),
|
||||
remote_project: None,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn create_dev_server(&mut self, cx: &mut ViewContext<Self>) {
|
||||
let name = self
|
||||
.dev_server_name_editor
|
||||
.read(cx)
|
||||
.text(cx)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if name == "" {
|
||||
return;
|
||||
}
|
||||
|
||||
let dev_server = self.channel_store.update(cx, |store, cx| {
|
||||
store.create_dev_server(self.channel_id, name.clone(), cx)
|
||||
});
|
||||
|
||||
let task = cx.spawn(|this, mut cx| async move {
|
||||
match dev_server.await {
|
||||
Ok(dev_server) => {
|
||||
this.update(&mut cx, |this, _| {
|
||||
this.mode = Mode::CreateDevServer(CreateDevServer {
|
||||
creating: None,
|
||||
dev_server: Some(dev_server),
|
||||
});
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.prompt(
|
||||
gpui::PromptLevel::Critical,
|
||||
"Failed to create server",
|
||||
Some(&format!("{:?}. Please try again.", e)),
|
||||
&["Ok"],
|
||||
)
|
||||
.await
|
||||
.log_err();
|
||||
this.update(&mut cx, |this, _| {
|
||||
this.mode = Mode::CreateDevServer(Default::default());
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
self.mode = Mode::CreateDevServer(CreateDevServer {
|
||||
creating: Some(task),
|
||||
dev_server: None,
|
||||
});
|
||||
cx.notify()
|
||||
}
|
||||
|
||||
fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
|
||||
match self.mode {
|
||||
Mode::Default => cx.emit(DismissEvent),
|
||||
Mode::CreateRemoteProject(_) | Mode::CreateDevServer(_) => {
|
||||
self.mode = Mode::Default;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_dev_server(
|
||||
&mut self,
|
||||
dev_server: &DevServer,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> impl IntoElement {
|
||||
let channel_store = self.channel_store.read(cx);
|
||||
let dev_server_id = dev_server.id;
|
||||
let status = dev_server.status;
|
||||
|
||||
v_flex()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.group("dev-server")
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.id(("status", dev_server.id.0))
|
||||
.relative()
|
||||
.child(Icon::new(IconName::Server).size(IconSize::Small))
|
||||
.child(
|
||||
div().absolute().bottom_0().left(rems_from_px(8.0)).child(
|
||||
Indicator::dot().color(match status {
|
||||
DevServerStatus::Online => Color::Created,
|
||||
DevServerStatus::Offline => Color::Deleted,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.tooltip(move |cx| {
|
||||
Tooltip::text(
|
||||
match status {
|
||||
DevServerStatus::Online => "Online",
|
||||
DevServerStatus::Offline => "Offline",
|
||||
},
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(dev_server.name.clone())
|
||||
.child(
|
||||
h_flex()
|
||||
.visible_on_hover("dev-server")
|
||||
.gap_1()
|
||||
.child(
|
||||
IconButton::new("edit-dev-server", IconName::Pencil)
|
||||
.disabled(true) //TODO implement this on the collab side
|
||||
.tooltip(|cx| {
|
||||
Tooltip::text("Coming Soon - Edit dev server", cx)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("remove-dev-server", IconName::Trash)
|
||||
.disabled(true) //TODO implement this on the collab side
|
||||
.tooltip(|cx| {
|
||||
Tooltip::text("Coming Soon - Remove dev server", cx)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex().gap_1().child(
|
||||
IconButton::new("add-remote-project", IconName::Plus)
|
||||
.tooltip(|cx| Tooltip::text("Add a remote project", cx))
|
||||
.on_click(cx.listener(move |this, _, cx| {
|
||||
this.mode = Mode::CreateRemoteProject(CreateRemoteProject {
|
||||
dev_server_id,
|
||||
creating: None,
|
||||
remote_project: None,
|
||||
});
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.bg(cx.theme().colors().title_bar_background)
|
||||
.border()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.rounded_md()
|
||||
.my_1()
|
||||
.py_0p5()
|
||||
.px_3()
|
||||
.child(
|
||||
List::new().empty_message("No projects.").children(
|
||||
channel_store
|
||||
.remote_projects_for_id(dev_server.channel_id)
|
||||
.iter()
|
||||
.filter_map(|remote_project| {
|
||||
if remote_project.dev_server_id == dev_server.id {
|
||||
Some(self.render_remote_project(remote_project, cx))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
// .child(div().ml_8().child(
|
||||
// Button::new(("add-project", dev_server_id.0), "Add Project").on_click(cx.listener(
|
||||
// move |this, _, cx| {
|
||||
// this.mode = Mode::CreateRemoteProject(CreateRemoteProject {
|
||||
// dev_server_id,
|
||||
// creating: None,
|
||||
// remote_project: None,
|
||||
// });
|
||||
// cx.notify();
|
||||
// },
|
||||
// )),
|
||||
// ))
|
||||
}
|
||||
|
||||
fn render_remote_project(
|
||||
&mut self,
|
||||
project: &RemoteProject,
|
||||
_: &mut ViewContext<Self>,
|
||||
) -> impl IntoElement {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(Icon::new(IconName::FileTree))
|
||||
.child(Label::new(project.name.clone()))
|
||||
.child(Label::new(format!("({})", project.path.clone())).color(Color::Muted))
|
||||
}
|
||||
|
||||
fn render_create_dev_server(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let Mode::CreateDevServer(CreateDevServer {
|
||||
creating,
|
||||
dev_server,
|
||||
}) = &self.mode
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
self.dev_server_name_editor.update(cx, |editor, _| {
|
||||
editor.set_read_only(creating.is_some() || dev_server.is_some())
|
||||
});
|
||||
v_flex()
|
||||
.px_1()
|
||||
.pt_0p5()
|
||||
.gap_px()
|
||||
.child(
|
||||
v_flex().py_0p5().px_1().child(
|
||||
h_flex()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.child(
|
||||
IconButton::new("back", IconName::ArrowLeft)
|
||||
.style(ButtonStyle::Transparent)
|
||||
.on_click(cx.listener(|this, _: &gpui::ClickEvent, cx| {
|
||||
this.mode = Mode::Default;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(Headline::new("Register dev server")),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.ml_5()
|
||||
.gap_2()
|
||||
.child("Name")
|
||||
.child(self.dev_server_name_editor.clone())
|
||||
.on_action(
|
||||
cx.listener(|this, _: &menu::Confirm, cx| this.create_dev_server(cx)),
|
||||
)
|
||||
.when(creating.is_none() && dev_server.is_none(), |div| {
|
||||
div.child(
|
||||
Button::new("create-dev-server", "Create").on_click(cx.listener(
|
||||
move |this, _, cx| {
|
||||
this.create_dev_server(cx);
|
||||
},
|
||||
)),
|
||||
)
|
||||
})
|
||||
.when(creating.is_some() && dev_server.is_none(), |div| {
|
||||
div.child(Button::new("create-dev-server", "Creating...").disabled(true))
|
||||
}),
|
||||
)
|
||||
.when_some(dev_server.clone(), |div, dev_server| {
|
||||
let channel_store = self.channel_store.read(cx);
|
||||
let status = channel_store
|
||||
.find_dev_server_by_id(DevServerId(dev_server.dev_server_id))
|
||||
.map(|server| server.status)
|
||||
.unwrap_or(DevServerStatus::Offline);
|
||||
let instructions = SharedString::from(format!(
|
||||
"zed --dev-server-token {}",
|
||||
dev_server.access_token
|
||||
));
|
||||
div.child(
|
||||
v_flex()
|
||||
.ml_8()
|
||||
.gap_2()
|
||||
.child(Label::new(format!(
|
||||
"Please log into `{}` and run:",
|
||||
dev_server.name
|
||||
)))
|
||||
.child(instructions.clone())
|
||||
.child(
|
||||
IconButton::new("copy-access-token", IconName::Copy)
|
||||
.on_click(cx.listener(move |_, _, cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new(
|
||||
instructions.to_string(),
|
||||
))
|
||||
}))
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(|cx| Tooltip::text("Copy access token", cx)),
|
||||
)
|
||||
.when(status == DevServerStatus::Offline, |this| {
|
||||
this.child(Label::new("Waiting for connection..."))
|
||||
})
|
||||
.when(status == DevServerStatus::Online, |this| {
|
||||
this.child(Label::new("Connection established! 🎊")).child(
|
||||
Button::new("done", "Done").on_click(cx.listener(|this, _, cx| {
|
||||
this.mode = Mode::Default;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let channel_store = self.channel_store.read(cx);
|
||||
let dev_servers = channel_store.dev_servers_for_id(self.channel_id);
|
||||
// let dev_servers = Vec::new();
|
||||
|
||||
v_flex()
|
||||
.id("scroll-container")
|
||||
.h_full()
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&self.scroll_handle)
|
||||
.px_1()
|
||||
.pt_0p5()
|
||||
.gap_px()
|
||||
.child(
|
||||
ModalHeader::new("Manage Remote Project")
|
||||
.child(Headline::new("Remote Projects").size(HeadlineSize::Small)),
|
||||
)
|
||||
.child(
|
||||
ModalContent::new().child(
|
||||
List::new()
|
||||
.empty_message("No dev servers registered.")
|
||||
.header(Some(
|
||||
ListHeader::new("Dev Servers").end_slot(
|
||||
Button::new("register-dev-server-button", "New Server")
|
||||
.icon(IconName::Plus)
|
||||
.icon_position(IconPosition::Start)
|
||||
.tooltip(|cx| Tooltip::text("Register a new dev server", cx))
|
||||
.on_click(cx.listener(|this, _, cx| {
|
||||
this.mode = Mode::CreateDevServer(Default::default());
|
||||
this.dev_server_name_editor
|
||||
.read(cx)
|
||||
.focus_handle(cx)
|
||||
.focus(cx);
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
))
|
||||
.children(dev_servers.iter().map(|dev_server| {
|
||||
self.render_dev_server(dev_server, cx).into_any_element()
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_create_project(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let Mode::CreateRemoteProject(CreateRemoteProject {
|
||||
dev_server_id,
|
||||
creating,
|
||||
remote_project,
|
||||
}) = &self.mode
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let channel_store = self.channel_store.read(cx);
|
||||
let (dev_server_name, dev_server_status) = channel_store
|
||||
.find_dev_server_by_id(*dev_server_id)
|
||||
.map(|server| (server.name.clone(), server.status))
|
||||
.unwrap_or((SharedString::from(""), DevServerStatus::Offline));
|
||||
v_flex()
|
||||
.px_1()
|
||||
.pt_0p5()
|
||||
.gap_px()
|
||||
.child(
|
||||
ModalHeader::new("Manage Remote Project")
|
||||
.child(Headline::new("Manage Remote Projects")),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.py_0p5()
|
||||
.px_1()
|
||||
.child(div().px_1().py_0p5().child(
|
||||
IconButton::new("back", IconName::ArrowLeft).on_click(cx.listener(
|
||||
|this, _, cx| {
|
||||
this.mode = Mode::Default;
|
||||
cx.notify()
|
||||
},
|
||||
)),
|
||||
))
|
||||
.child("Add Project..."),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.ml_5()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.id(("status", dev_server_id.0))
|
||||
.relative()
|
||||
.child(Icon::new(IconName::Server))
|
||||
.child(div().absolute().bottom_0().left(rems_from_px(12.0)).child(
|
||||
Indicator::dot().color(match dev_server_status {
|
||||
DevServerStatus::Online => Color::Created,
|
||||
DevServerStatus::Offline => Color::Deleted,
|
||||
}),
|
||||
))
|
||||
.tooltip(move |cx| {
|
||||
Tooltip::text(
|
||||
match dev_server_status {
|
||||
DevServerStatus::Online => "Online",
|
||||
DevServerStatus::Offline => "Offline",
|
||||
},
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(dev_server_name.clone()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.ml_5()
|
||||
.gap_2()
|
||||
.child("Name")
|
||||
.child(self.remote_project_name_editor.clone())
|
||||
.on_action(cx.listener(|this, _: &menu::Confirm, cx| {
|
||||
cx.focus_view(&this.remote_project_path_editor)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.ml_5()
|
||||
.gap_2()
|
||||
.child("Path")
|
||||
.child(self.remote_project_path_editor.clone())
|
||||
.on_action(
|
||||
cx.listener(|this, _: &menu::Confirm, cx| this.create_dev_server(cx)),
|
||||
)
|
||||
.when(creating.is_none() && remote_project.is_none(), |div| {
|
||||
div.child(Button::new("create-remote-server", "Create").on_click({
|
||||
let dev_server_id = *dev_server_id;
|
||||
cx.listener(move |this, _, cx| {
|
||||
this.create_remote_project(dev_server_id, cx)
|
||||
})
|
||||
}))
|
||||
})
|
||||
.when(creating.is_some(), |div| {
|
||||
div.child(Button::new("create-dev-server", "Creating...").disabled(true))
|
||||
}),
|
||||
)
|
||||
.when_some(remote_project.clone(), |div, remote_project| {
|
||||
let channel_store = self.channel_store.read(cx);
|
||||
let status = channel_store
|
||||
.find_remote_project_by_id(RemoteProjectId(remote_project.id))
|
||||
.map(|project| {
|
||||
if project.project_id.is_some() {
|
||||
DevServerStatus::Online
|
||||
} else {
|
||||
DevServerStatus::Offline
|
||||
}
|
||||
})
|
||||
.unwrap_or(DevServerStatus::Offline);
|
||||
div.child(
|
||||
v_flex()
|
||||
.ml_5()
|
||||
.ml_8()
|
||||
.gap_2()
|
||||
.when(status == DevServerStatus::Offline, |this| {
|
||||
this.child(Label::new("Waiting for project..."))
|
||||
})
|
||||
.when(status == DevServerStatus::Online, |this| {
|
||||
this.child(Label::new("Project online! 🎊")).child(
|
||||
Button::new("done", "Done").on_click(cx.listener(|this, _, cx| {
|
||||
this.mode = Mode::Default;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
impl ModalView for DevServerModal {}
|
||||
|
||||
impl FocusableView for DevServerModal {
|
||||
fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<DismissEvent> for DevServerModal {}
|
||||
|
||||
impl Render for DevServerModal {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.track_focus(&self.focus_handle)
|
||||
.elevation_3(cx)
|
||||
.key_context("DevServerModal")
|
||||
.on_action(cx.listener(Self::cancel))
|
||||
.pb_4()
|
||||
.w(rems(34.))
|
||||
.min_h(rems(20.))
|
||||
.max_h(rems(40.))
|
||||
.child(match &self.mode {
|
||||
Mode::Default => self.render_default(cx).into_any_element(),
|
||||
Mode::CreateRemoteProject(_) => self.render_create_project(cx).into_any_element(),
|
||||
Mode::CreateDevServer(_) => self.render_create_dev_server(cx).into_any_element(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ use std::{sync::Arc, time::Duration};
|
||||
use time::{OffsetDateTime, UtcOffset};
|
||||
use ui::{h_flex, prelude::*, v_flex, Avatar, Button, Icon, IconButton, IconName, Label, Tooltip};
|
||||
use util::{ResultExt, TryFutureExt};
|
||||
use workspace::notifications::NotificationId;
|
||||
use workspace::{
|
||||
dock::{DockPosition, Panel, PanelEvent},
|
||||
Workspace,
|
||||
@@ -534,8 +535,10 @@ impl NotificationPanel {
|
||||
|
||||
self.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.dismiss_notification::<NotificationToast>(0, cx);
|
||||
workspace.show_notification(0, cx, |cx| {
|
||||
let id = NotificationId::unique::<NotificationToast>();
|
||||
|
||||
workspace.dismiss_notification(&id, cx);
|
||||
workspace.show_notification(id, cx, |cx| {
|
||||
let workspace = cx.view().downgrade();
|
||||
cx.new_view(|_| NotificationToast {
|
||||
notification_id,
|
||||
@@ -554,7 +557,8 @@ impl NotificationPanel {
|
||||
self.current_notification_toast.take();
|
||||
self.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.dismiss_notification::<NotificationToast>(0, cx)
|
||||
let id = NotificationId::unique::<NotificationToast>();
|
||||
workspace.dismiss_notification(&id, cx)
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
@@ -1255,7 +1255,6 @@ mod tests {
|
||||
&self,
|
||||
_: BufferId,
|
||||
_: &clock::Global,
|
||||
_: language::RopeFingerprint,
|
||||
_: language::LineEnding,
|
||||
_: Option<std::time::SystemTime>,
|
||||
_: &mut AppContext,
|
||||
|
||||
@@ -14,6 +14,7 @@ use language::{
|
||||
use settings::{update_settings_file, Settings, SettingsStore};
|
||||
use std::{path::Path, sync::Arc};
|
||||
use util::{paths, ResultExt};
|
||||
use workspace::notifications::NotificationId;
|
||||
use workspace::{
|
||||
create_and_open_local_file,
|
||||
item::ItemHandle,
|
||||
@@ -25,8 +26,10 @@ use workspace::{
|
||||
use zed_actions::OpenBrowser;
|
||||
|
||||
const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot";
|
||||
const COPILOT_STARTING_TOAST_ID: usize = 1337;
|
||||
const COPILOT_ERROR_TOAST_ID: usize = 1338;
|
||||
|
||||
struct CopilotStartingToast;
|
||||
|
||||
struct CopilotErrorToast;
|
||||
|
||||
pub struct CopilotButton {
|
||||
editor_subscription: Option<(Subscription, usize)>,
|
||||
@@ -74,7 +77,7 @@ impl Render for CopilotButton {
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace.show_toast(
|
||||
Toast::new(
|
||||
COPILOT_ERROR_TOAST_ID,
|
||||
NotificationId::unique::<CopilotErrorToast>(),
|
||||
format!("Copilot can't be started: {}", e),
|
||||
)
|
||||
.on_click(
|
||||
@@ -350,7 +353,10 @@ pub fn initiate_sign_in(cx: &mut WindowContext) {
|
||||
|
||||
let Ok(workspace) = workspace.update(cx, |workspace, cx| {
|
||||
workspace.show_toast(
|
||||
Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot is starting..."),
|
||||
Toast::new(
|
||||
NotificationId::unique::<CopilotStartingToast>(),
|
||||
"Copilot is starting...",
|
||||
),
|
||||
cx,
|
||||
);
|
||||
workspace.weak_handle()
|
||||
@@ -364,11 +370,17 @@ pub fn initiate_sign_in(cx: &mut WindowContext) {
|
||||
workspace
|
||||
.update(&mut cx, |workspace, cx| match copilot.read(cx).status() {
|
||||
Status::Authorized => workspace.show_toast(
|
||||
Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot has started!"),
|
||||
Toast::new(
|
||||
NotificationId::unique::<CopilotStartingToast>(),
|
||||
"Copilot has started!",
|
||||
),
|
||||
cx,
|
||||
),
|
||||
_ => {
|
||||
workspace.dismiss_toast(COPILOT_STARTING_TOAST_ID, cx);
|
||||
workspace.dismiss_toast(
|
||||
&NotificationId::unique::<CopilotStartingToast>(),
|
||||
cx,
|
||||
);
|
||||
copilot
|
||||
.update(cx, |copilot, cx| copilot.sign_in(cx))
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
@@ -38,7 +38,7 @@ pub use toolbar_controls::ToolbarControls;
|
||||
use ui::{h_flex, prelude::*, Icon, IconName, Label};
|
||||
use util::TryFutureExt;
|
||||
use workspace::{
|
||||
item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
|
||||
item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
|
||||
ItemNavHistory, Pane, ToolbarItemLocation, Workspace,
|
||||
};
|
||||
|
||||
@@ -136,8 +136,15 @@ impl ProjectDiagnosticsEditor {
|
||||
.entry(*language_server_id)
|
||||
.or_default()
|
||||
.insert(path.clone());
|
||||
if this.editor.read(cx).selections.all::<usize>(cx).is_empty()
|
||||
&& !this.is_dirty(cx)
|
||||
|
||||
if this.is_dirty(cx) {
|
||||
return;
|
||||
}
|
||||
let selections = this.editor.read(cx).selections.all::<usize>(cx);
|
||||
if selections.len() < 2
|
||||
&& selections
|
||||
.first()
|
||||
.map_or(true, |selection| selection.end == selection.start)
|
||||
{
|
||||
this.update_excerpts(Some(*language_server_id), cx);
|
||||
}
|
||||
@@ -645,10 +652,10 @@ impl Item for ProjectDiagnosticsEditor {
|
||||
Some("Project Diagnostics".into())
|
||||
}
|
||||
|
||||
fn tab_content(&self, _detail: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
|
||||
fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement {
|
||||
if self.summary.error_count == 0 && self.summary.warning_count == 0 {
|
||||
Label::new("No problems")
|
||||
.color(if selected {
|
||||
.color(if params.selected {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
@@ -663,7 +670,7 @@ impl Item for ProjectDiagnosticsEditor {
|
||||
.gap_1()
|
||||
.child(Icon::new(IconName::XCircle).color(Color::Error))
|
||||
.child(Label::new(self.summary.error_count.to_string()).color(
|
||||
if selected {
|
||||
if params.selected {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
@@ -677,7 +684,7 @@ impl Item for ProjectDiagnosticsEditor {
|
||||
.gap_1()
|
||||
.child(Icon::new(IconName::ExclamationTriangle).color(Color::Warning))
|
||||
.child(Label::new(self.summary.warning_count.to_string()).color(
|
||||
if selected {
|
||||
if params.selected {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
|
||||
@@ -245,6 +245,7 @@ gpui::actions!(
|
||||
Tab,
|
||||
TabPrev,
|
||||
ToggleGitBlame,
|
||||
ToggleGitBlameInline,
|
||||
ToggleInlayHints,
|
||||
ToggleLineNumbers,
|
||||
ToggleSoftWrap,
|
||||
|
||||
@@ -233,11 +233,11 @@ impl FoldMap {
|
||||
}
|
||||
|
||||
pub fn set_ellipses_color(&mut self, color: Hsla) -> bool {
|
||||
if self.ellipses_color != Some(color) {
|
||||
if self.ellipses_color == Some(color) {
|
||||
false
|
||||
} else {
|
||||
self.ellipses_color = Some(color);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,12 +126,12 @@ impl WrapMap {
|
||||
) -> bool {
|
||||
let font_with_size = (font, font_size);
|
||||
|
||||
if font_with_size != self.font_with_size {
|
||||
if font_with_size == self.font_with_size {
|
||||
false
|
||||
} else {
|
||||
self.font_with_size = font_with_size;
|
||||
self.rewrap(cx);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,8 @@ use ui::{
|
||||
Tooltip,
|
||||
};
|
||||
use util::{defer, maybe, post_inc, RangeExt, ResultExt, TryFutureExt};
|
||||
use workspace::item::ItemHandle;
|
||||
use workspace::notifications::NotificationId;
|
||||
use workspace::Toast;
|
||||
use workspace::{
|
||||
searchable::SearchEvent, ItemNavHistory, SplitDirection, ViewId, Workspace, WorkspaceId,
|
||||
@@ -153,7 +155,7 @@ pub fn render_parsed_markdown(
|
||||
parsed: &language::ParsedMarkdown,
|
||||
editor_style: &EditorStyle,
|
||||
workspace: Option<WeakView<Workspace>>,
|
||||
cx: &mut ViewContext<Editor>,
|
||||
cx: &mut WindowContext,
|
||||
) -> InteractiveText {
|
||||
let code_span_background_color = cx
|
||||
.theme()
|
||||
@@ -461,7 +463,9 @@ pub struct Editor {
|
||||
editor_actions: Vec<Box<dyn Fn(&mut ViewContext<Self>)>>,
|
||||
use_autoclose: bool,
|
||||
auto_replace_emoji_shortcode: bool,
|
||||
show_git_blame: bool,
|
||||
show_git_blame_gutter: bool,
|
||||
show_git_blame_inline: bool,
|
||||
show_git_blame_inline_delay_task: Option<Task<()>>,
|
||||
blame: Option<Model<GitBlame>>,
|
||||
blame_subscription: Option<Subscription>,
|
||||
custom_context_menu: Option<
|
||||
@@ -470,13 +474,15 @@ pub struct Editor {
|
||||
+ Fn(&mut Self, DisplayPoint, &mut ViewContext<Self>) -> Option<View<ui::ContextMenu>>,
|
||||
>,
|
||||
>,
|
||||
last_bounds: Option<Bounds<Pixels>>,
|
||||
expect_bounds_change: Option<Bounds<Pixels>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EditorSnapshot {
|
||||
pub mode: EditorMode,
|
||||
show_gutter: bool,
|
||||
show_git_blame: bool,
|
||||
render_git_blame_gutter: bool,
|
||||
pub display_snapshot: DisplaySnapshot,
|
||||
pub placeholder_text: Option<Arc<str>>,
|
||||
is_focused: bool,
|
||||
@@ -1484,6 +1490,8 @@ impl Editor {
|
||||
inlay_hint_cache: InlayHintCache::new(inlay_hint_settings),
|
||||
gutter_hovered: false,
|
||||
pixel_position_of_newest_cursor: None,
|
||||
last_bounds: None,
|
||||
expect_bounds_change: None,
|
||||
gutter_width: Default::default(),
|
||||
style: None,
|
||||
show_cursor_names: false,
|
||||
@@ -1492,7 +1500,9 @@ impl Editor {
|
||||
vim_replace_map: Default::default(),
|
||||
show_inline_completions: mode == EditorMode::Full,
|
||||
custom_context_menu: None,
|
||||
show_git_blame: false,
|
||||
show_git_blame_gutter: false,
|
||||
show_git_blame_inline: false,
|
||||
show_git_blame_inline_delay_task: None,
|
||||
blame: None,
|
||||
blame_subscription: None,
|
||||
_subscriptions: vec![
|
||||
@@ -1524,6 +1534,10 @@ impl Editor {
|
||||
if mode == EditorMode::Full {
|
||||
let should_auto_hide_scrollbars = cx.should_auto_hide_scrollbars();
|
||||
cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars));
|
||||
|
||||
if ProjectSettings::get_global(cx).git.inline_blame_enabled() {
|
||||
this.start_git_blame_inline(false, cx);
|
||||
}
|
||||
}
|
||||
|
||||
this.report_editor_event("open", None, cx);
|
||||
@@ -1640,10 +1654,7 @@ impl Editor {
|
||||
EditorSnapshot {
|
||||
mode: self.mode,
|
||||
show_gutter: self.show_gutter,
|
||||
show_git_blame: self
|
||||
.blame
|
||||
.as_ref()
|
||||
.map_or(false, |blame| blame.read(cx).has_generated_entries()),
|
||||
render_git_blame_gutter: self.render_git_blame_gutter(cx),
|
||||
display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)),
|
||||
scroll_anchor: self.scroll_manager.anchor(),
|
||||
ongoing_scroll: self.scroll_manager.ongoing_scroll(),
|
||||
@@ -1909,6 +1920,7 @@ impl Editor {
|
||||
self.refresh_document_highlights(cx);
|
||||
refresh_matching_bracket_highlights(self, cx);
|
||||
self.discard_inline_completion(cx);
|
||||
self.start_inline_blame_timer(cx);
|
||||
}
|
||||
|
||||
self.blink_manager.update(cx, BlinkManager::pause_blinking);
|
||||
@@ -3788,6 +3800,22 @@ impl Editor {
|
||||
None
|
||||
}
|
||||
|
||||
fn start_inline_blame_timer(&mut self, cx: &mut ViewContext<Self>) {
|
||||
if let Some(delay) = ProjectSettings::get_global(cx).git.inline_blame_delay() {
|
||||
self.show_git_blame_inline = false;
|
||||
|
||||
self.show_git_blame_inline_delay_task = Some(cx.spawn(|this, mut cx| async move {
|
||||
cx.background_executor().timer(delay).await;
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.show_git_blame_inline = true;
|
||||
cx.notify();
|
||||
})
|
||||
.log_err();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_document_highlights(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
|
||||
if self.pending_rename.is_some() {
|
||||
return None;
|
||||
@@ -7997,11 +8025,16 @@ impl Editor {
|
||||
cx,
|
||||
);
|
||||
});
|
||||
let item = Box::new(editor);
|
||||
if split {
|
||||
workspace.split_item(SplitDirection::Right, Box::new(editor), cx);
|
||||
workspace.split_item(SplitDirection::Right, item.clone(), cx);
|
||||
} else {
|
||||
workspace.add_item_to_active_pane(Box::new(editor), cx);
|
||||
workspace.add_item_to_active_pane(item.clone(), cx);
|
||||
}
|
||||
workspace.active_pane().clone().update(cx, |pane, cx| {
|
||||
let item_id = item.item_id();
|
||||
pane.set_preview_item_id(Some(item_id), cx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
|
||||
@@ -8832,40 +8865,83 @@ impl Editor {
|
||||
}
|
||||
|
||||
pub fn toggle_git_blame(&mut self, _: &ToggleGitBlame, cx: &mut ViewContext<Self>) {
|
||||
if !self.show_git_blame {
|
||||
if let Err(error) = self.show_git_blame_internal(cx) {
|
||||
log::error!("failed to toggle on 'git blame': {}", error);
|
||||
return;
|
||||
}
|
||||
self.show_git_blame = true
|
||||
} else {
|
||||
self.blame_subscription.take();
|
||||
self.blame.take();
|
||||
self.show_git_blame = false
|
||||
self.show_git_blame_gutter = !self.show_git_blame_gutter;
|
||||
|
||||
if self.show_git_blame_gutter && !self.has_blame_entries(cx) {
|
||||
self.start_git_blame(true, cx);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn show_git_blame_internal(&mut self, cx: &mut ViewContext<Self>) -> Result<()> {
|
||||
pub fn toggle_git_blame_inline(
|
||||
&mut self,
|
||||
_: &ToggleGitBlameInline,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.toggle_git_blame_inline_internal(true, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn start_git_blame(&mut self, user_triggered: bool, cx: &mut ViewContext<Self>) {
|
||||
if let Some(project) = self.project.as_ref() {
|
||||
let Some(buffer) = self.buffer().read(cx).as_singleton() else {
|
||||
anyhow::bail!("git blame not available in multi buffers")
|
||||
return;
|
||||
};
|
||||
|
||||
let project = project.clone();
|
||||
let blame = cx.new_model(|cx| GitBlame::new(buffer, project, cx));
|
||||
let blame = cx.new_model(|cx| GitBlame::new(buffer, project, user_triggered, cx));
|
||||
self.blame_subscription = Some(cx.observe(&blame, |_, _, cx| cx.notify()));
|
||||
self.blame = Some(blame);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
fn toggle_git_blame_inline_internal(
|
||||
&mut self,
|
||||
user_triggered: bool,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if self.show_git_blame_inline || self.show_git_blame_inline_delay_task.is_some() {
|
||||
self.show_git_blame_inline = false;
|
||||
self.show_git_blame_inline_delay_task.take();
|
||||
} else {
|
||||
self.start_git_blame_inline(user_triggered, cx);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn start_git_blame_inline(&mut self, user_triggered: bool, cx: &mut ViewContext<Self>) {
|
||||
if let Some(inline_blame_settings) = ProjectSettings::get_global(cx).git.inline_blame {
|
||||
if inline_blame_settings.enabled {
|
||||
self.start_git_blame(user_triggered, cx);
|
||||
|
||||
if inline_blame_settings.delay_ms.is_some() {
|
||||
self.start_inline_blame_timer(cx);
|
||||
} else {
|
||||
self.show_git_blame_inline = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn blame(&self) -> Option<&Model<GitBlame>> {
|
||||
self.blame.as_ref()
|
||||
}
|
||||
|
||||
pub fn render_git_blame_gutter(&mut self, cx: &mut WindowContext) -> bool {
|
||||
self.show_git_blame_gutter && self.has_blame_entries(cx)
|
||||
}
|
||||
|
||||
pub fn render_git_blame_inline(&mut self, cx: &mut WindowContext) -> bool {
|
||||
self.show_git_blame_inline && self.has_blame_entries(cx)
|
||||
}
|
||||
|
||||
fn has_blame_entries(&self, cx: &mut WindowContext) -> bool {
|
||||
self.blame()
|
||||
.map_or(false, |blame| blame.read(cx).has_generated_entries())
|
||||
}
|
||||
|
||||
fn get_permalink_to_line(&mut self, cx: &mut ViewContext<Self>) -> Result<url::Url> {
|
||||
let (path, repo) = maybe!({
|
||||
let project_handle = self.project.as_ref()?.clone();
|
||||
@@ -8922,7 +8998,12 @@ impl Editor {
|
||||
|
||||
if let Some(workspace) = self.workspace() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.show_toast(Toast::new(0x156a5f9ee, message), cx)
|
||||
struct CopyPermalinkToLine;
|
||||
|
||||
workspace.show_toast(
|
||||
Toast::new(NotificationId::unique::<CopyPermalinkToLine>(), message),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8943,7 +9024,12 @@ impl Editor {
|
||||
|
||||
if let Some(workspace) = self.workspace() {
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.show_toast(Toast::new(0x45a8978, message), cx)
|
||||
struct OpenPermalinkToLine;
|
||||
|
||||
workspace.show_toast(
|
||||
Toast::new(NotificationId::unique::<OpenPermalinkToLine>(), message),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9425,6 +9511,14 @@ impl Editor {
|
||||
let editor_settings = EditorSettings::get_global(cx);
|
||||
self.scroll_manager.vertical_scroll_margin = editor_settings.vertical_scroll_margin;
|
||||
self.show_breadcrumbs = editor_settings.toolbar.breadcrumbs;
|
||||
|
||||
if self.mode == EditorMode::Full {
|
||||
let inline_blame_enabled = ProjectSettings::get_global(cx).git.inline_blame_enabled();
|
||||
if self.show_git_blame_inline != inline_blame_enabled {
|
||||
self.toggle_git_blame_inline_internal(false, cx);
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -9506,7 +9600,11 @@ impl Editor {
|
||||
cx.spawn(|_, mut cx| async move {
|
||||
let workspace = workspace.ok_or_else(|| anyhow!("cannot jump without workspace"))?;
|
||||
let editor = workspace.update(&mut cx, |workspace, cx| {
|
||||
workspace.open_path(path, None, true, cx)
|
||||
// Reset the preview item id before opening the new item
|
||||
workspace.active_pane().update(cx, |pane, cx| {
|
||||
pane.set_preview_item_id(None, cx);
|
||||
});
|
||||
workspace.open_path_preview(path, None, true, true, cx)
|
||||
})?;
|
||||
let editor = editor
|
||||
.await?
|
||||
@@ -10033,7 +10131,7 @@ impl EditorSnapshot {
|
||||
};
|
||||
|
||||
let git_blame_entries_width = self
|
||||
.show_git_blame
|
||||
.render_git_blame_gutter
|
||||
.then_some(em_width * GIT_BLAME_GUTTER_WIDTH_CHARS);
|
||||
|
||||
let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO);
|
||||
@@ -10568,6 +10666,11 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, _is_valid: bool) -> Ren
|
||||
|
||||
let mut text_style = cx.text_style().clone();
|
||||
text_style.color = diagnostic_style(diagnostic.severity, true, cx.theme().status());
|
||||
let theme_settings = ThemeSettings::get_global(cx);
|
||||
text_style.font_family = theme_settings.buffer_font.family.clone();
|
||||
text_style.font_style = theme_settings.buffer_font.style;
|
||||
text_style.font_features = theme_settings.buffer_font.features;
|
||||
text_style.font_weight = theme_settings.buffer_font.weight;
|
||||
|
||||
let multi_line_diagnostic = diagnostic.message.contains('\n');
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ pub struct EditorSettings {
|
||||
pub scrollbar: Scrollbar,
|
||||
pub gutter: Gutter,
|
||||
pub vertical_scroll_margin: f32,
|
||||
pub scroll_sensitivity: f32,
|
||||
pub relative_line_numbers: bool,
|
||||
pub seed_search_query_from_cursor: SeedQuerySetting,
|
||||
pub multi_cursor_modifier: MultiCursorModifier,
|
||||
@@ -138,6 +139,11 @@ pub struct EditorSettingsContent {
|
||||
///
|
||||
/// Default: 3.
|
||||
pub vertical_scroll_margin: Option<f32>,
|
||||
/// Scroll sensitivity multiplier. This multiplier is applied
|
||||
/// to both the horizontal and vertical delta values while scrolling.
|
||||
///
|
||||
/// Default: 1.0
|
||||
pub scroll_sensitivity: Option<f32>,
|
||||
/// Whether the line numbers on editors gutter are relative or not.
|
||||
///
|
||||
/// Default: false
|
||||
@@ -178,7 +184,7 @@ pub struct ToolbarContent {
|
||||
}
|
||||
|
||||
/// Scrollbar related settings
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
|
||||
pub struct ScrollbarContent {
|
||||
/// When to show the scrollbar in the editor.
|
||||
///
|
||||
|
||||
@@ -4,7 +4,10 @@ use crate::{
|
||||
TransformBlock,
|
||||
},
|
||||
editor_settings::{DoubleClickInMultibuffer, MultiCursorModifier, ShowScrollbar},
|
||||
git::{blame::GitBlame, diff_hunk_to_display, DisplayDiffHunk},
|
||||
git::{
|
||||
blame::{CommitDetails, GitBlame},
|
||||
diff_hunk_to_display, DisplayDiffHunk,
|
||||
},
|
||||
hover_popover::{
|
||||
self, hover_at, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT,
|
||||
},
|
||||
@@ -21,13 +24,13 @@ use collections::{BTreeMap, HashMap};
|
||||
use git::{blame::BlameEntry, diff::DiffHunkStatus, Oid};
|
||||
use gpui::{
|
||||
anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg,
|
||||
transparent_black, Action, AnchorCorner, AnyElement, AnyView, AvailableSpace, Bounds,
|
||||
ClipboardItem, ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element,
|
||||
ElementContext, ElementInputHandler, Entity, Hitbox, Hsla, InteractiveElement, IntoElement,
|
||||
transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem,
|
||||
ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementContext,
|
||||
ElementInputHandler, Entity, Hitbox, Hsla, InteractiveElement, IntoElement,
|
||||
ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
|
||||
ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size, Stateful,
|
||||
StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, TextStyleRefinement, View,
|
||||
ViewContext, WindowContext,
|
||||
ParentElement, Pixels, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
|
||||
Size, Stateful, StatefulInteractiveElement, Style, Styled, TextRun, TextStyle,
|
||||
TextStyleRefinement, View, ViewContext, WeakView, WindowContext,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use language::language_settings::ShowWhitespaceSetting;
|
||||
@@ -49,11 +52,11 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
use sum_tree::Bias;
|
||||
use theme::{ActiveTheme, PlayerColor};
|
||||
use theme::{ActiveTheme, PlayerColor, ThemeSettings};
|
||||
use ui::{h_flex, ButtonLike, ButtonStyle, ContextMenu, Tooltip};
|
||||
use ui::{prelude::*, tooltip_container};
|
||||
use util::ResultExt;
|
||||
use workspace::item::Item;
|
||||
use workspace::{item::Item, Workspace};
|
||||
|
||||
struct SelectionLayout {
|
||||
head: DisplayPoint,
|
||||
@@ -303,6 +306,7 @@ impl EditorElement {
|
||||
register_action(view, cx, Editor::copy_permalink_to_line);
|
||||
register_action(view, cx, Editor::open_permalink_to_line);
|
||||
register_action(view, cx, Editor::toggle_git_blame);
|
||||
register_action(view, cx, Editor::toggle_git_blame_inline);
|
||||
register_action(view, cx, |editor, action, cx| {
|
||||
if let Some(task) = editor.format(action, cx) {
|
||||
task.detach_and_log_err(cx);
|
||||
@@ -1092,6 +1096,59 @@ impl EditorElement {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn layout_inline_blame(
|
||||
&self,
|
||||
start_row: u32,
|
||||
row: u32,
|
||||
line_layouts: &[LineWithInvisibles],
|
||||
em_width: Pixels,
|
||||
content_origin: gpui::Point<Pixels>,
|
||||
scroll_pixel_position: gpui::Point<Pixels>,
|
||||
line_height: Pixels,
|
||||
cx: &mut ElementContext,
|
||||
) -> Option<AnyElement> {
|
||||
if !self
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.render_git_blame_inline(cx))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let blame = self.editor.read(cx).blame.clone()?;
|
||||
let workspace = self
|
||||
.editor
|
||||
.read(cx)
|
||||
.workspace
|
||||
.as_ref()
|
||||
.map(|(w, _)| w.clone());
|
||||
let blame_entry = blame
|
||||
.update(cx, |blame, cx| blame.blame_for_rows([Some(row)], cx).next())
|
||||
.flatten()?;
|
||||
|
||||
let mut element =
|
||||
render_inline_blame_entry(&blame, blame_entry, &self.style, workspace, cx);
|
||||
|
||||
let start_y =
|
||||
content_origin.y + line_height * (row as f32 - scroll_pixel_position.y / line_height);
|
||||
|
||||
let start_x = {
|
||||
const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 6.;
|
||||
|
||||
let line_layout = &line_layouts[(row - start_row) as usize];
|
||||
let line_width = line_layout.line.width;
|
||||
|
||||
content_origin.x + line_width + (em_width * INLINE_BLAME_PADDING_EM_WIDTHS)
|
||||
};
|
||||
|
||||
let absolute_offset = point(start_x, start_y);
|
||||
let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent);
|
||||
|
||||
element.layout(absolute_offset, available_space, cx);
|
||||
|
||||
Some(element)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn layout_blame_entries(
|
||||
&self,
|
||||
@@ -1103,10 +1160,14 @@ impl EditorElement {
|
||||
max_width: Option<Pixels>,
|
||||
cx: &mut ElementContext,
|
||||
) -> Option<Vec<AnyElement>> {
|
||||
let Some(blame) = self.editor.read(cx).blame.as_ref().cloned() else {
|
||||
if !self
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.render_git_blame_gutter(cx))
|
||||
{
|
||||
return None;
|
||||
};
|
||||
}
|
||||
|
||||
let blame = self.editor.read(cx).blame.clone()?;
|
||||
let blamed_rows: Vec<_> = blame.update(cx, |blame, cx| {
|
||||
blame.blame_for_rows(buffer_rows, cx).collect()
|
||||
});
|
||||
@@ -1120,7 +1181,6 @@ impl EditorElement {
|
||||
let start_x = em_width * 1;
|
||||
|
||||
let mut last_used_color: Option<(PlayerColor, Oid)> = None;
|
||||
let text_style = &self.style.text;
|
||||
|
||||
let shaped_lines = blamed_rows
|
||||
.into_iter()
|
||||
@@ -1131,7 +1191,7 @@ impl EditorElement {
|
||||
ix,
|
||||
&blame,
|
||||
blame_entry,
|
||||
text_style,
|
||||
&self.style,
|
||||
&mut last_used_color,
|
||||
self.editor.clone(),
|
||||
cx,
|
||||
@@ -2256,6 +2316,7 @@ impl EditorElement {
|
||||
self.paint_lines(&invisible_display_ranges, layout, cx);
|
||||
self.paint_redactions(layout, cx);
|
||||
self.paint_cursors(layout, cx);
|
||||
self.paint_inline_blame(layout, cx);
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -2730,6 +2791,14 @@ impl EditorElement {
|
||||
})
|
||||
}
|
||||
|
||||
fn paint_inline_blame(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) {
|
||||
if let Some(mut inline_blame) = layout.inline_blame.take() {
|
||||
cx.paint_layer(layout.text_hitbox.bounds, |cx| {
|
||||
inline_blame.paint(cx);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_blocks(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) {
|
||||
for mut block in layout.blocks.drain(..) {
|
||||
block.element.paint(cx);
|
||||
@@ -2749,6 +2818,10 @@ impl EditorElement {
|
||||
let hitbox = layout.hitbox.clone();
|
||||
let mut delta = ScrollDelta::default();
|
||||
|
||||
// Set a minimum scroll_sensitivity of 0.01 to make sure the user doesn't
|
||||
// accidentally turn off their scrolling.
|
||||
let scroll_sensitivity = EditorSettings::get_global(cx).scroll_sensitivity.max(0.01);
|
||||
|
||||
move |event: &ScrollWheelEvent, phase, cx| {
|
||||
if phase == DispatchPhase::Bubble && hitbox.is_hovered(cx) {
|
||||
delta = delta.coalesce(event.delta);
|
||||
@@ -2773,8 +2846,11 @@ impl EditorElement {
|
||||
};
|
||||
|
||||
let scroll_position = position_map.snapshot.scroll_position();
|
||||
let x = (scroll_position.x * max_glyph_width - delta.x) / max_glyph_width;
|
||||
let y = (scroll_position.y * line_height - delta.y) / line_height;
|
||||
let x = (scroll_position.x * max_glyph_width
|
||||
- (delta.x * scroll_sensitivity))
|
||||
/ max_glyph_width;
|
||||
let y = (scroll_position.y * line_height - (delta.y * scroll_sensitivity))
|
||||
/ line_height;
|
||||
let scroll_position =
|
||||
point(x, y).clamp(&point(0., 0.), &position_map.scroll_max);
|
||||
editor.scroll(scroll_position, axis, cx);
|
||||
@@ -2894,11 +2970,192 @@ impl EditorElement {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_inline_blame_entry(
|
||||
blame: &gpui::Model<GitBlame>,
|
||||
blame_entry: BlameEntry,
|
||||
style: &EditorStyle,
|
||||
workspace: Option<WeakView<Workspace>>,
|
||||
cx: &mut ElementContext<'_>,
|
||||
) -> AnyElement {
|
||||
let relative_timestamp = blame_entry_relative_timestamp(&blame_entry, cx);
|
||||
|
||||
let author = blame_entry.author.as_deref().unwrap_or_default();
|
||||
let text = format!("{}, {}", author, relative_timestamp);
|
||||
|
||||
let details = blame.read(cx).details_for_entry(&blame_entry);
|
||||
|
||||
let tooltip = cx.new_view(|_| BlameEntryTooltip::new(blame_entry, details, style, workspace));
|
||||
|
||||
h_flex()
|
||||
.id("inline-blame")
|
||||
.w_full()
|
||||
.font(style.text.font().family)
|
||||
.text_color(cx.theme().status().hint)
|
||||
.line_height(style.text.line_height)
|
||||
.child(Icon::new(IconName::FileGit).color(Color::Hint))
|
||||
.child(text)
|
||||
.gap_2()
|
||||
.hoverable_tooltip(move |_| tooltip.clone().into())
|
||||
.into_any()
|
||||
}
|
||||
|
||||
fn blame_entry_timestamp(
|
||||
blame_entry: &BlameEntry,
|
||||
format: time_format::TimestampFormat,
|
||||
cx: &WindowContext,
|
||||
) -> String {
|
||||
match blame_entry.author_offset_date_time() {
|
||||
Ok(timestamp) => time_format::format_localized_timestamp(
|
||||
timestamp,
|
||||
time::OffsetDateTime::now_utc(),
|
||||
cx.local_timezone(),
|
||||
format,
|
||||
),
|
||||
Err(_) => "Error parsing date".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn blame_entry_relative_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String {
|
||||
blame_entry_timestamp(blame_entry, time_format::TimestampFormat::Relative, cx)
|
||||
}
|
||||
|
||||
fn blame_entry_absolute_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String {
|
||||
blame_entry_timestamp(
|
||||
blame_entry,
|
||||
time_format::TimestampFormat::MediumAbsolute,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
struct BlameEntryTooltip {
|
||||
blame_entry: BlameEntry,
|
||||
details: Option<CommitDetails>,
|
||||
style: EditorStyle,
|
||||
workspace: Option<WeakView<Workspace>>,
|
||||
scroll_handle: ScrollHandle,
|
||||
}
|
||||
|
||||
impl BlameEntryTooltip {
|
||||
fn new(
|
||||
blame_entry: BlameEntry,
|
||||
details: Option<CommitDetails>,
|
||||
style: &EditorStyle,
|
||||
workspace: Option<WeakView<Workspace>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
style: style.clone(),
|
||||
blame_entry,
|
||||
details,
|
||||
workspace,
|
||||
scroll_handle: ScrollHandle::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for BlameEntryTooltip {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let author = self
|
||||
.blame_entry
|
||||
.author
|
||||
.clone()
|
||||
.unwrap_or("<no name>".to_string());
|
||||
|
||||
let author_email = self.blame_entry.author_mail.clone();
|
||||
|
||||
let pretty_commit_id = format!("{}", self.blame_entry.sha);
|
||||
let short_commit_id = pretty_commit_id.chars().take(6).collect::<String>();
|
||||
let absolute_timestamp = blame_entry_absolute_timestamp(&self.blame_entry, cx);
|
||||
|
||||
let message = self
|
||||
.details
|
||||
.as_ref()
|
||||
.map(|details| {
|
||||
crate::render_parsed_markdown(
|
||||
"blame-message",
|
||||
&details.parsed_message,
|
||||
&self.style,
|
||||
self.workspace.clone(),
|
||||
cx,
|
||||
)
|
||||
.into_any()
|
||||
})
|
||||
.unwrap_or("<no commit message>".into_any());
|
||||
|
||||
let ui_font_size = ThemeSettings::get_global(cx).ui_font_size;
|
||||
let message_max_height = cx.line_height() * 12 + (ui_font_size / 0.4);
|
||||
|
||||
tooltip_container(cx, move |this, cx| {
|
||||
this.occlude()
|
||||
.on_mouse_move(|_, cx| cx.stop_propagation())
|
||||
.child(
|
||||
v_flex()
|
||||
.w(gpui::rems(30.))
|
||||
.gap_4()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(author)
|
||||
.when_some(author_email, |this, author_email| {
|
||||
this.child(
|
||||
div()
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.child(author_email),
|
||||
)
|
||||
})
|
||||
.pb_1()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("inline-blame-commit-message")
|
||||
.occlude()
|
||||
.child(message)
|
||||
.max_h(message_max_height)
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&self.scroll_handle),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.child(absolute_timestamp)
|
||||
.child(
|
||||
Button::new("commit-sha-button", short_commit_id.clone())
|
||||
.style(ButtonStyle::Transparent)
|
||||
.color(Color::Muted)
|
||||
.icon(IconName::FileGit)
|
||||
.icon_color(Color::Muted)
|
||||
.icon_position(IconPosition::Start)
|
||||
.disabled(
|
||||
self.details.as_ref().map_or(true, |details| {
|
||||
details.permalink.is_none()
|
||||
}),
|
||||
)
|
||||
.when_some(
|
||||
self.details
|
||||
.as_ref()
|
||||
.and_then(|details| details.permalink.clone()),
|
||||
|this, url| {
|
||||
this.on_click(move |_, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.open_url(url.as_str())
|
||||
})
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn render_blame_entry(
|
||||
ix: usize,
|
||||
blame: &gpui::Model<GitBlame>,
|
||||
blame_entry: BlameEntry,
|
||||
text_style: &TextStyle,
|
||||
style: &EditorStyle,
|
||||
last_used_color: &mut Option<(PlayerColor, Oid)>,
|
||||
editor: View<Editor>,
|
||||
cx: &mut ElementContext<'_>,
|
||||
@@ -2918,29 +3175,26 @@ fn render_blame_entry(
|
||||
};
|
||||
last_used_color.replace((sha_color, blame_entry.sha));
|
||||
|
||||
let relative_timestamp = match blame_entry.author_offset_date_time() {
|
||||
Ok(timestamp) => time_format::format_localized_timestamp(
|
||||
timestamp,
|
||||
time::OffsetDateTime::now_utc(),
|
||||
cx.local_timezone(),
|
||||
time_format::TimestampFormat::Relative,
|
||||
),
|
||||
Err(_) => "Error parsing date".to_string(),
|
||||
};
|
||||
let relative_timestamp = blame_entry_relative_timestamp(&blame_entry, cx);
|
||||
|
||||
let pretty_commit_id = format!("{}", blame_entry.sha);
|
||||
let short_commit_id = pretty_commit_id.clone().chars().take(6).collect::<String>();
|
||||
let short_commit_id = pretty_commit_id.chars().take(6).collect::<String>();
|
||||
|
||||
let author_name = blame_entry.author.as_deref().unwrap_or("<no name>");
|
||||
let name = util::truncate_and_trailoff(author_name, 20);
|
||||
|
||||
let permalink = blame.read(cx).permalink_for_entry(&blame_entry);
|
||||
let commit_message = blame.read(cx).message_for_entry(&blame_entry);
|
||||
let details = blame.read(cx).details_for_entry(&blame_entry);
|
||||
|
||||
let workspace = editor.read(cx).workspace.as_ref().map(|(w, _)| w.clone());
|
||||
|
||||
let tooltip = cx.new_view(|_| {
|
||||
BlameEntryTooltip::new(blame_entry.clone(), details.clone(), style, workspace)
|
||||
});
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.font(text_style.font().family)
|
||||
.line_height(text_style.line_height)
|
||||
.font(style.text.font().family)
|
||||
.line_height(style.text.line_height)
|
||||
.id(("blame", ix))
|
||||
.children([
|
||||
div()
|
||||
@@ -2962,21 +3216,17 @@ fn render_blame_entry(
|
||||
}
|
||||
})
|
||||
.hover(|style| style.bg(cx.theme().colors().element_hover))
|
||||
.when_some(permalink, |this, url| {
|
||||
let url = url.clone();
|
||||
this.cursor_pointer().on_click(move |_, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.open_url(url.as_str())
|
||||
})
|
||||
})
|
||||
.tooltip(move |cx| {
|
||||
BlameEntryTooltip::new(
|
||||
sha_color.cursor,
|
||||
commit_message.clone(),
|
||||
blame_entry.clone(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.when_some(
|
||||
details.and_then(|details| details.permalink),
|
||||
|this, url| {
|
||||
let url = url.clone();
|
||||
this.cursor_pointer().on_click(move |_, cx| {
|
||||
cx.stop_propagation();
|
||||
cx.open_url(url.as_str())
|
||||
})
|
||||
},
|
||||
)
|
||||
.hoverable_tooltip(move |_| tooltip.clone().into())
|
||||
.into_any()
|
||||
}
|
||||
|
||||
@@ -2999,84 +3249,6 @@ fn deploy_blame_entry_context_menu(
|
||||
});
|
||||
}
|
||||
|
||||
struct BlameEntryTooltip {
|
||||
color: Hsla,
|
||||
commit_message: Option<String>,
|
||||
blame_entry: BlameEntry,
|
||||
}
|
||||
|
||||
impl BlameEntryTooltip {
|
||||
fn new(
|
||||
color: Hsla,
|
||||
commit_message: Option<String>,
|
||||
blame_entry: BlameEntry,
|
||||
cx: &mut WindowContext,
|
||||
) -> AnyView {
|
||||
cx.new_view(|_cx| Self {
|
||||
color,
|
||||
commit_message,
|
||||
blame_entry,
|
||||
})
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for BlameEntryTooltip {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let author = self
|
||||
.blame_entry
|
||||
.author
|
||||
.clone()
|
||||
.unwrap_or("<no name>".to_string());
|
||||
let author_email = self.blame_entry.author_mail.clone().unwrap_or_default();
|
||||
let absolute_timestamp = match self.blame_entry.author_offset_date_time() {
|
||||
Ok(timestamp) => time_format::format_localized_timestamp(
|
||||
timestamp,
|
||||
time::OffsetDateTime::now_utc(),
|
||||
cx.local_timezone(),
|
||||
time_format::TimestampFormat::Absolute,
|
||||
),
|
||||
Err(_) => "Error parsing date".to_string(),
|
||||
};
|
||||
|
||||
let message = match &self.commit_message {
|
||||
Some(message) => util::truncate_lines_and_trailoff(message, 15),
|
||||
None => self.blame_entry.summary.clone().unwrap_or_default(),
|
||||
};
|
||||
|
||||
let pretty_commit_id = format!("{}", self.blame_entry.sha);
|
||||
|
||||
tooltip_container(cx, move |this, cx| {
|
||||
this.occlude()
|
||||
.on_mouse_move(|_, cx| cx.stop_propagation())
|
||||
.child(
|
||||
v_flex()
|
||||
.child(
|
||||
h_flex()
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().colors().text_muted)
|
||||
.child("Commit")
|
||||
.pr_2(),
|
||||
)
|
||||
.child(
|
||||
div().text_color(self.color).child(pretty_commit_id.clone()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.child(format!(
|
||||
"{} {} - {}",
|
||||
author, author_email, absolute_timestamp
|
||||
))
|
||||
.text_color(cx.theme().colors().text_muted),
|
||||
)
|
||||
.child(div().child(message)),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct LineWithInvisibles {
|
||||
pub line: ShapedLine,
|
||||
@@ -3205,13 +3377,9 @@ impl LineWithInvisibles {
|
||||
let line_y =
|
||||
line_height * (row as f32 - layout.position_map.scroll_pixel_position.y / line_height);
|
||||
|
||||
self.line
|
||||
.paint(
|
||||
content_origin + gpui::point(-layout.position_map.scroll_pixel_position.x, line_y),
|
||||
line_height,
|
||||
cx,
|
||||
)
|
||||
.log_err();
|
||||
let line_origin =
|
||||
content_origin + gpui::point(-layout.position_map.scroll_pixel_position.x, line_y);
|
||||
self.line.paint(line_origin, line_height, cx).log_err();
|
||||
|
||||
self.draw_invisibles(
|
||||
&selection_ranges,
|
||||
@@ -3371,6 +3539,7 @@ impl Element for EditorElement {
|
||||
let overscroll = size(em_width, px(0.));
|
||||
|
||||
snapshot = self.editor.update(cx, |editor, cx| {
|
||||
editor.last_bounds = Some(bounds);
|
||||
editor.gutter_width = gutter_dimensions.width;
|
||||
editor.set_visible_line_count(bounds.size.height / line_height, cx);
|
||||
|
||||
@@ -3419,7 +3588,7 @@ impl Element for EditorElement {
|
||||
|
||||
let autoscroll_horizontally = self.editor.update(cx, |editor, cx| {
|
||||
let autoscroll_horizontally =
|
||||
editor.autoscroll_vertically(bounds.size.height, line_height, cx);
|
||||
editor.autoscroll_vertically(bounds, line_height, cx);
|
||||
snapshot = editor.snapshot(cx);
|
||||
autoscroll_horizontally
|
||||
});
|
||||
@@ -3489,16 +3658,6 @@ impl Element for EditorElement {
|
||||
|
||||
let display_hunks = self.layout_git_gutters(start_row..end_row, &snapshot);
|
||||
|
||||
let blamed_display_rows = self.layout_blame_entries(
|
||||
buffer_rows,
|
||||
em_width,
|
||||
scroll_position,
|
||||
line_height,
|
||||
&gutter_hitbox,
|
||||
gutter_dimensions.git_blame_entries_width,
|
||||
cx,
|
||||
);
|
||||
|
||||
let mut max_visible_line_width = Pixels::ZERO;
|
||||
let line_layouts =
|
||||
self.layout_lines(start_row..end_row, &line_numbers, &snapshot, cx);
|
||||
@@ -3527,6 +3686,37 @@ impl Element for EditorElement {
|
||||
cx,
|
||||
);
|
||||
|
||||
let scroll_pixel_position = point(
|
||||
scroll_position.x * em_width,
|
||||
scroll_position.y * line_height,
|
||||
);
|
||||
|
||||
let mut inline_blame = None;
|
||||
if let Some(newest_selection_head) = newest_selection_head {
|
||||
if (start_row..end_row).contains(&newest_selection_head.row()) {
|
||||
inline_blame = self.layout_inline_blame(
|
||||
start_row,
|
||||
newest_selection_head.row(),
|
||||
&line_layouts,
|
||||
em_width,
|
||||
content_origin,
|
||||
scroll_pixel_position,
|
||||
line_height,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let blamed_display_rows = self.layout_blame_entries(
|
||||
buffer_rows,
|
||||
em_width,
|
||||
scroll_position,
|
||||
line_height,
|
||||
&gutter_hitbox,
|
||||
gutter_dimensions.git_blame_entries_width,
|
||||
cx,
|
||||
);
|
||||
|
||||
let scroll_max = point(
|
||||
((scroll_width - text_hitbox.size.width) / em_width).max(0.0),
|
||||
max_row as f32,
|
||||
@@ -3554,11 +3744,6 @@ impl Element for EditorElement {
|
||||
}
|
||||
});
|
||||
|
||||
let scroll_pixel_position = point(
|
||||
scroll_position.x * em_width,
|
||||
scroll_position.y * line_height,
|
||||
);
|
||||
|
||||
cx.with_element_id(Some("blocks"), |cx| {
|
||||
self.layout_blocks(
|
||||
&mut blocks,
|
||||
@@ -3727,6 +3912,7 @@ impl Element for EditorElement {
|
||||
line_numbers,
|
||||
display_hunks,
|
||||
blamed_display_rows,
|
||||
inline_blame,
|
||||
folds,
|
||||
blocks,
|
||||
cursors,
|
||||
@@ -3814,6 +4000,7 @@ pub struct EditorLayout {
|
||||
line_numbers: Vec<Option<ShapedLine>>,
|
||||
display_hunks: Vec<DisplayDiffHunk>,
|
||||
blamed_display_rows: Option<Vec<AnyElement>>,
|
||||
inline_blame: Option<AnyElement>,
|
||||
folds: Vec<FoldLayout>,
|
||||
blocks: Vec<BlockLayout>,
|
||||
highlighted_ranges: Vec<(Range<DisplayPoint>, Hsla)>,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use collections::HashMap;
|
||||
use git::{
|
||||
@@ -5,7 +7,7 @@ use git::{
|
||||
Oid,
|
||||
};
|
||||
use gpui::{Model, ModelContext, Subscription, Task};
|
||||
use language::{Bias, Buffer, BufferSnapshot, Edit};
|
||||
use language::{markdown, Bias, Buffer, BufferSnapshot, Edit, LanguageRegistry, ParsedMarkdown};
|
||||
use project::{Item, Project};
|
||||
use smallvec::SmallVec;
|
||||
use sum_tree::SumTree;
|
||||
@@ -44,16 +46,23 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CommitDetails {
|
||||
pub message: String,
|
||||
pub parsed_message: ParsedMarkdown,
|
||||
pub permalink: Option<Url>,
|
||||
}
|
||||
|
||||
pub struct GitBlame {
|
||||
project: Model<Project>,
|
||||
buffer: Model<Buffer>,
|
||||
entries: SumTree<GitBlameEntry>,
|
||||
permalinks: HashMap<Oid, Url>,
|
||||
messages: HashMap<Oid, String>,
|
||||
commit_details: HashMap<Oid, CommitDetails>,
|
||||
buffer_snapshot: BufferSnapshot,
|
||||
buffer_edits: text::Subscription,
|
||||
task: Task<Result<()>>,
|
||||
generated: bool,
|
||||
user_triggered: bool,
|
||||
_refresh_subscription: Subscription,
|
||||
}
|
||||
|
||||
@@ -61,6 +70,7 @@ impl GitBlame {
|
||||
pub fn new(
|
||||
buffer: Model<Buffer>,
|
||||
project: Model<Project>,
|
||||
user_triggered: bool,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let entries = SumTree::from_item(
|
||||
@@ -102,8 +112,8 @@ impl GitBlame {
|
||||
buffer_snapshot,
|
||||
entries,
|
||||
buffer_edits,
|
||||
permalinks: HashMap::default(),
|
||||
messages: HashMap::default(),
|
||||
user_triggered,
|
||||
commit_details: HashMap::default(),
|
||||
task: Task::ready(Ok(())),
|
||||
generated: false,
|
||||
_refresh_subscription: refresh_subscription,
|
||||
@@ -116,12 +126,8 @@ impl GitBlame {
|
||||
self.generated
|
||||
}
|
||||
|
||||
pub fn permalink_for_entry(&self, entry: &BlameEntry) -> Option<Url> {
|
||||
self.permalinks.get(&entry.sha).cloned()
|
||||
}
|
||||
|
||||
pub fn message_for_entry(&self, entry: &BlameEntry) -> Option<String> {
|
||||
self.messages.get(&entry.sha).cloned()
|
||||
pub fn details_for_entry(&self, entry: &BlameEntry) -> Option<CommitDetails> {
|
||||
self.commit_details.get(&entry.sha).cloned()
|
||||
}
|
||||
|
||||
pub fn blame_for_rows<'a>(
|
||||
@@ -254,9 +260,10 @@ impl GitBlame {
|
||||
let buffer_edits = self.buffer.update(cx, |buffer, _| buffer.subscribe());
|
||||
let snapshot = self.buffer.read(cx).snapshot();
|
||||
let blame = self.project.read(cx).blame_buffer(&self.buffer, None, cx);
|
||||
let languages = self.project.read(cx).languages().clone();
|
||||
|
||||
self.task = cx.spawn(|this, mut cx| async move {
|
||||
let (entries, permalinks, messages) = cx
|
||||
let result = cx
|
||||
.background_executor()
|
||||
.spawn({
|
||||
let snapshot = snapshot.clone();
|
||||
@@ -267,58 +274,121 @@ impl GitBlame {
|
||||
messages,
|
||||
} = blame.await?;
|
||||
|
||||
let mut current_row = 0;
|
||||
let mut entries = SumTree::from_iter(
|
||||
entries.into_iter().flat_map(|entry| {
|
||||
let mut entries = SmallVec::<[GitBlameEntry; 2]>::new();
|
||||
let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row);
|
||||
let commit_details =
|
||||
parse_commit_messages(messages, &permalinks, &languages).await;
|
||||
|
||||
if entry.range.start > current_row {
|
||||
let skipped_rows = entry.range.start - current_row;
|
||||
entries.push(GitBlameEntry {
|
||||
rows: skipped_rows,
|
||||
blame: None,
|
||||
});
|
||||
}
|
||||
entries.push(GitBlameEntry {
|
||||
rows: entry.range.len() as u32,
|
||||
blame: Some(entry.clone()),
|
||||
});
|
||||
|
||||
current_row = entry.range.end;
|
||||
entries
|
||||
}),
|
||||
&(),
|
||||
);
|
||||
|
||||
let max_row = snapshot.max_point().row;
|
||||
if max_row >= current_row {
|
||||
entries.push(
|
||||
GitBlameEntry {
|
||||
rows: (max_row + 1) - current_row,
|
||||
blame: None,
|
||||
},
|
||||
&(),
|
||||
);
|
||||
}
|
||||
|
||||
anyhow::Ok((entries, permalinks, messages))
|
||||
anyhow::Ok((entries, commit_details))
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
.await;
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.buffer_edits = buffer_edits;
|
||||
this.buffer_snapshot = snapshot;
|
||||
this.entries = entries;
|
||||
this.permalinks = permalinks;
|
||||
this.messages = messages;
|
||||
this.generated = true;
|
||||
cx.notify();
|
||||
this.update(&mut cx, |this, cx| match result {
|
||||
Ok((entries, commit_details)) => {
|
||||
this.buffer_edits = buffer_edits;
|
||||
this.buffer_snapshot = snapshot;
|
||||
this.entries = entries;
|
||||
this.commit_details = commit_details;
|
||||
this.generated = true;
|
||||
cx.notify();
|
||||
}
|
||||
Err(error) => this.project.update(cx, |_, cx| {
|
||||
if this.user_triggered {
|
||||
log::error!("failed to get git blame data: {error:?}");
|
||||
let notification = format!("{:#}", error).trim().to_string();
|
||||
cx.emit(project::Event::Notification(notification));
|
||||
} 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:?}");
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn build_blame_entry_sum_tree(entries: Vec<BlameEntry>, max_row: u32) -> SumTree<GitBlameEntry> {
|
||||
let mut current_row = 0;
|
||||
let mut entries = SumTree::from_iter(
|
||||
entries.into_iter().flat_map(|entry| {
|
||||
let mut entries = SmallVec::<[GitBlameEntry; 2]>::new();
|
||||
|
||||
if entry.range.start > current_row {
|
||||
let skipped_rows = entry.range.start - current_row;
|
||||
entries.push(GitBlameEntry {
|
||||
rows: skipped_rows,
|
||||
blame: None,
|
||||
});
|
||||
}
|
||||
entries.push(GitBlameEntry {
|
||||
rows: entry.range.len() as u32,
|
||||
blame: Some(entry.clone()),
|
||||
});
|
||||
|
||||
current_row = entry.range.end;
|
||||
entries
|
||||
}),
|
||||
&(),
|
||||
);
|
||||
|
||||
if max_row >= current_row {
|
||||
entries.push(
|
||||
GitBlameEntry {
|
||||
rows: (max_row + 1) - current_row,
|
||||
blame: None,
|
||||
},
|
||||
&(),
|
||||
);
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
async fn parse_commit_messages(
|
||||
messages: impl IntoIterator<Item = (Oid, String)>,
|
||||
permalinks: &HashMap<Oid, Url>,
|
||||
languages: &Arc<LanguageRegistry>,
|
||||
) -> HashMap<Oid, CommitDetails> {
|
||||
let mut commit_details = HashMap::default();
|
||||
for (oid, message) in messages {
|
||||
let parsed_message = parse_markdown(&message, &languages).await;
|
||||
let permalink = permalinks.get(&oid).cloned();
|
||||
|
||||
commit_details.insert(
|
||||
oid,
|
||||
CommitDetails {
|
||||
message,
|
||||
parsed_message,
|
||||
permalink,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
commit_details
|
||||
}
|
||||
|
||||
async fn parse_markdown(text: &str, language_registry: &Arc<LanguageRegistry>) -> ParsedMarkdown {
|
||||
let mut parsed_message = ParsedMarkdown::default();
|
||||
|
||||
markdown::parse_markdown_block(
|
||||
text,
|
||||
language_registry,
|
||||
None,
|
||||
&mut parsed_message.text,
|
||||
&mut parsed_message.highlights,
|
||||
&mut parsed_message.region_ranges,
|
||||
&mut parsed_message.regions,
|
||||
)
|
||||
.await;
|
||||
|
||||
parsed_message
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -359,6 +429,54 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_blame_error_notifications(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
"/my-repo",
|
||||
json!({
|
||||
".git": {},
|
||||
"file.txt": r#"
|
||||
irrelevant contents
|
||||
"#
|
||||
.unindent()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Creating a GitBlame without a corresponding blame state
|
||||
// will result in an error.
|
||||
|
||||
let project = Project::test(fs, ["/my-repo".as_ref()], cx).await;
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_local_buffer("/my-repo/file.txt", cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project.clone(), true, cx));
|
||||
|
||||
let event = project.next_event(cx).await;
|
||||
assert_eq!(
|
||||
event,
|
||||
project::Event::Notification(
|
||||
"Failed to blame \"file.txt\": failed to get blame for \"file.txt\"".to_string()
|
||||
)
|
||||
);
|
||||
|
||||
blame.update(cx, |blame, cx| {
|
||||
assert_eq!(
|
||||
blame
|
||||
.blame_for_rows((0..1).map(Some), cx)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![None]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_blame_for_rows(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx);
|
||||
@@ -408,7 +526,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));
|
||||
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
@@ -488,7 +606,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));
|
||||
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
|
||||
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
@@ -637,7 +755,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, cx));
|
||||
let git_blame = cx.new_model(|cx| GitBlame::new(buffer.clone(), project, false, cx));
|
||||
cx.executor().run_until_parked();
|
||||
git_blame.update(cx, |blame, cx| blame.check_invariants(cx));
|
||||
|
||||
|
||||
@@ -238,7 +238,9 @@ fn show_hover(
|
||||
let task = cx.spawn(|this, mut cx| {
|
||||
async move {
|
||||
// If we need to delay, delay a set amount initially before making the lsp request
|
||||
let delay = if !ignore_timeout {
|
||||
let delay = if ignore_timeout {
|
||||
None
|
||||
} else {
|
||||
// Construct delay task to wait for later
|
||||
let total_delay = Some(
|
||||
cx.background_executor()
|
||||
@@ -249,8 +251,6 @@ fn show_hover(
|
||||
.timer(Duration::from_millis(HOVER_REQUEST_DELAY_MILLIS))
|
||||
.await;
|
||||
total_delay
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// query the LSP for hover info
|
||||
|
||||
@@ -19,7 +19,7 @@ use project::repository::GitFileStatus;
|
||||
use project::{search::SearchQuery, FormatTrigger, Item as _, Project, ProjectPath};
|
||||
use rpc::proto::{self, update_view, PeerId};
|
||||
use settings::Settings;
|
||||
use workspace::item::ItemSettings;
|
||||
use workspace::item::{ItemSettings, TabContentParams};
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
@@ -30,7 +30,7 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
use text::{BufferId, Selection};
|
||||
use theme::Theme;
|
||||
use theme::{Theme, ThemeSettings};
|
||||
use ui::{h_flex, prelude::*, Label};
|
||||
use util::{paths::PathExt, ResultExt, TryFutureExt};
|
||||
use workspace::item::{BreadcrumbText, FollowEvent, FollowableItemHandle};
|
||||
@@ -594,7 +594,7 @@ impl Item for Editor {
|
||||
Some(path.to_string_lossy().to_string().into())
|
||||
}
|
||||
|
||||
fn tab_content(&self, detail: Option<usize>, selected: bool, cx: &WindowContext) -> AnyElement {
|
||||
fn tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement {
|
||||
let label_color = if ItemSettings::get_global(cx).git_status {
|
||||
self.buffer()
|
||||
.read(cx)
|
||||
@@ -602,14 +602,14 @@ impl Item for Editor {
|
||||
.and_then(|buffer| buffer.read(cx).project_path(cx))
|
||||
.and_then(|path| self.project.as_ref()?.read(cx).entry_for_path(&path, cx))
|
||||
.map(|entry| {
|
||||
entry_git_aware_label_color(entry.git_status, entry.is_ignored, selected)
|
||||
entry_git_aware_label_color(entry.git_status, entry.is_ignored, params.selected)
|
||||
})
|
||||
.unwrap_or_else(|| entry_label_color(selected))
|
||||
.unwrap_or_else(|| entry_label_color(params.selected))
|
||||
} else {
|
||||
entry_label_color(selected)
|
||||
entry_label_color(params.selected)
|
||||
};
|
||||
|
||||
let description = detail.and_then(|detail| {
|
||||
let description = params.detail.and_then(|detail| {
|
||||
let path = path_for_buffer(&self.buffer, detail, false, cx)?;
|
||||
let description = path.to_string_lossy();
|
||||
let description = description.trim();
|
||||
@@ -623,7 +623,11 @@ impl Item for Editor {
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(Label::new(self.title(cx).to_string()).color(label_color))
|
||||
.child(
|
||||
Label::new(self.title(cx).to_string())
|
||||
.color(label_color)
|
||||
.italic(params.preview),
|
||||
)
|
||||
.when_some(description, |this, description| {
|
||||
this.child(
|
||||
Label::new(description)
|
||||
@@ -731,9 +735,8 @@ impl Item for Editor {
|
||||
buffer
|
||||
.update(&mut cx, |buffer, cx| {
|
||||
let version = buffer.saved_version().clone();
|
||||
let fingerprint = buffer.saved_version_fingerprint();
|
||||
let mtime = buffer.saved_mtime();
|
||||
buffer.did_save(version, fingerprint, mtime, cx);
|
||||
buffer.did_save(version, mtime, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
@@ -824,13 +827,18 @@ impl Item for Editor {
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "untitled".to_string());
|
||||
|
||||
let settings = ThemeSettings::get_global(cx);
|
||||
|
||||
let mut breadcrumbs = vec![BreadcrumbText {
|
||||
text: filename,
|
||||
highlights: None,
|
||||
font: Some(settings.buffer_font.clone()),
|
||||
}];
|
||||
|
||||
breadcrumbs.extend(symbols.into_iter().map(|symbol| BreadcrumbText {
|
||||
text: symbol.text,
|
||||
highlights: Some(symbol.highlight_ranges),
|
||||
font: Some(settings.buffer_font.clone()),
|
||||
}));
|
||||
Some(breadcrumbs)
|
||||
}
|
||||
@@ -1163,6 +1171,10 @@ impl SearchableItem for Editor {
|
||||
&self.buffer().read(cx).snapshot(cx),
|
||||
)
|
||||
}
|
||||
|
||||
fn search_bar_visibility_changed(&mut self, _visible: bool, _cx: &mut ViewContext<Self>) {
|
||||
self.expect_bounds_change = self.last_bounds;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_match_index(
|
||||
|
||||
@@ -47,6 +47,12 @@ pub fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
|
||||
pub fn saturating_left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
|
||||
if point.column() > 0 {
|
||||
*point.column_mut() -= 1;
|
||||
} else if point.column() == 0 {
|
||||
// If the current sofr_wrap mode is used, the column corresponding to the display is 0,
|
||||
// which does not necessarily mean that the actual beginning of a paragraph
|
||||
if map.display_point_to_fold_point(point, Bias::Left).column() > 0 {
|
||||
return left(map, point);
|
||||
}
|
||||
}
|
||||
map.clip_point(point, Bias::Left)
|
||||
}
|
||||
|
||||
@@ -45,11 +45,11 @@ impl ScrollAnchor {
|
||||
|
||||
pub fn scroll_position(&self, snapshot: &DisplaySnapshot) -> gpui::Point<f32> {
|
||||
let mut scroll_position = self.offset;
|
||||
if self.anchor != Anchor::min() {
|
||||
if self.anchor == Anchor::min() {
|
||||
scroll_position.y = 0.;
|
||||
} else {
|
||||
let scroll_top = self.anchor.to_display_point(snapshot).row() as f32;
|
||||
scroll_position.y = scroll_top + scroll_position.y;
|
||||
} else {
|
||||
scroll_position.y = 0.;
|
||||
}
|
||||
scroll_position
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::{cmp, f32};
|
||||
|
||||
use gpui::{px, Pixels, ViewContext};
|
||||
use gpui::{px, Bounds, Pixels, ViewContext};
|
||||
use language::Point;
|
||||
|
||||
use crate::{display_map::ToDisplayPoint, Editor, EditorMode, LineWithInvisibles};
|
||||
@@ -63,13 +63,23 @@ impl AutoscrollStrategy {
|
||||
impl Editor {
|
||||
pub fn autoscroll_vertically(
|
||||
&mut self,
|
||||
viewport_height: Pixels,
|
||||
bounds: Bounds<Pixels>,
|
||||
line_height: Pixels,
|
||||
cx: &mut ViewContext<Editor>,
|
||||
) -> bool {
|
||||
let viewport_height = bounds.size.height;
|
||||
let visible_lines = viewport_height / line_height;
|
||||
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
|
||||
let mut scroll_position = self.scroll_manager.scroll_position(&display_map);
|
||||
let original_y = scroll_position.y;
|
||||
if let Some(last_bounds) = self.expect_bounds_change.take() {
|
||||
if scroll_position.y != 0. {
|
||||
scroll_position.y += (bounds.top() - last_bounds.top()) / line_height;
|
||||
if scroll_position.y < 0. {
|
||||
scroll_position.y = 0.;
|
||||
}
|
||||
}
|
||||
}
|
||||
let max_scroll_top = if matches!(self.mode, EditorMode::AutoHeight { .. }) {
|
||||
(display_map.max_point().row() as f32 - visible_lines + 1.).max(0.)
|
||||
} else {
|
||||
@@ -77,6 +87,9 @@ impl Editor {
|
||||
};
|
||||
if scroll_position.y > max_scroll_top {
|
||||
scroll_position.y = max_scroll_top;
|
||||
}
|
||||
|
||||
if original_y != scroll_position.y {
|
||||
self.set_scroll_position(scroll_position, cx);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ collections.workspace = true
|
||||
fs.workspace = true
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
isahc.workspace = true
|
||||
language.workspace = true
|
||||
log.workspace = true
|
||||
lsp.workspace = true
|
||||
|
||||
@@ -10,7 +10,7 @@ use gpui::AsyncAppContext;
|
||||
use language::{
|
||||
CodeLabel, HighlightId, Language, LanguageServerName, LspAdapter, LspAdapterDelegate,
|
||||
};
|
||||
use lsp::LanguageServerBinary;
|
||||
use lsp::{CodeActionKind, LanguageServerBinary};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::ops::Range;
|
||||
@@ -129,6 +129,23 @@ impl LspAdapter for ExtensionLspAdapter {
|
||||
None
|
||||
}
|
||||
|
||||
fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
|
||||
let code_action_kinds = self
|
||||
.extension
|
||||
.manifest
|
||||
.language_servers
|
||||
.get(&self.language_server_id)
|
||||
.and_then(|server| server.code_action_kinds.clone());
|
||||
|
||||
code_action_kinds.or(Some(vec![
|
||||
CodeActionKind::EMPTY,
|
||||
CodeActionKind::QUICKFIX,
|
||||
CodeActionKind::REFACTOR,
|
||||
CodeActionKind::REFACTOR_EXTRACT,
|
||||
CodeActionKind::SOURCE,
|
||||
]))
|
||||
}
|
||||
|
||||
fn language_ids(&self) -> HashMap<String, String> {
|
||||
// TODO: The language IDs can be provided via the language server options
|
||||
// in `extension.toml now but we're leaving these existing usages in place temporarily
|
||||
@@ -295,10 +312,10 @@ fn labels_from_wit(
|
||||
.into_iter()
|
||||
.map(|label| {
|
||||
let label = label?;
|
||||
let runs = if !label.code.is_empty() {
|
||||
language.highlight_text(&label.code.as_str().into(), 0..label.code.len())
|
||||
} else {
|
||||
let runs = if label.code.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
language.highlight_text(&label.code.as_str().into(), 0..label.code.len())
|
||||
};
|
||||
build_code_label(&label, &runs, &language)
|
||||
})
|
||||
|
||||
@@ -106,6 +106,8 @@ pub struct LanguageServerManifestEntry {
|
||||
languages: Vec<Arc<str>>,
|
||||
#[serde(default)]
|
||||
pub language_ids: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub code_action_kinds: Option<Vec<lsp::CodeActionKind>>,
|
||||
}
|
||||
|
||||
impl LanguageServerManifestEntry {
|
||||
|
||||
@@ -606,7 +606,22 @@ impl ExtensionStore {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
|
||||
let content_length = response
|
||||
.headers()
|
||||
.get(isahc::http::header::CONTENT_LENGTH)
|
||||
.and_then(|value| value.to_str().ok()?.parse::<usize>().ok());
|
||||
|
||||
let mut body = BufReader::new(response.body_mut());
|
||||
let mut tar_gz_bytes = Vec::new();
|
||||
body.read_to_end(&mut tar_gz_bytes).await?;
|
||||
|
||||
if let Some(content_length) = content_length {
|
||||
let actual_len = tar_gz_bytes.len();
|
||||
if content_length != actual_len {
|
||||
bail!("downloaded extension size {actual_len} does not match content length {content_length}");
|
||||
}
|
||||
}
|
||||
let decompressed_bytes = GzipDecoder::new(BufReader::new(tar_gz_bytes.as_slice()));
|
||||
let archive = Archive::new(decompressed_bytes);
|
||||
archive.unpack(extension_dir).await?;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
|
||||
@@ -198,13 +198,15 @@ pub fn parse_wasm_extension_version(
|
||||
extension_id: &str,
|
||||
wasm_bytes: &[u8],
|
||||
) -> Result<SemanticVersion> {
|
||||
let mut version = None;
|
||||
|
||||
for part in wasmparser::Parser::new(0).parse_all(wasm_bytes) {
|
||||
if let wasmparser::Payload::CustomSection(s) = part? {
|
||||
if let wasmparser::Payload::CustomSection(s) =
|
||||
part.context("error parsing wasm extension")?
|
||||
{
|
||||
if s.name() == "zed:api-version" {
|
||||
let version = parse_wasm_extension_version_custom_section(s.data());
|
||||
if let Some(version) = version {
|
||||
return Ok(version);
|
||||
} else {
|
||||
version = parse_wasm_extension_version_custom_section(s.data());
|
||||
if version.is_none() {
|
||||
bail!(
|
||||
"extension {} has invalid zed:api-version section: {:?}",
|
||||
extension_id,
|
||||
@@ -214,7 +216,13 @@ pub fn parse_wasm_extension_version(
|
||||
}
|
||||
}
|
||||
}
|
||||
bail!("extension {} has no zed:api-version section", extension_id)
|
||||
|
||||
// The reason we wait until we're done parsing all of the Wasm bytes to return the version
|
||||
// is to work around a panic that can happen inside of Wasmtime when the bytes are invalid.
|
||||
//
|
||||
// By parsing the entirety of the Wasm bytes before we return, we're able to detect this problem
|
||||
// earlier as an `Err` rather than as a panic.
|
||||
version.ok_or_else(|| anyhow!("extension {} has no zed:api-version section", extension_id))
|
||||
}
|
||||
|
||||
fn parse_wasm_extension_version_custom_section(data: &[u8]) -> Option<SemanticVersion> {
|
||||
|
||||
@@ -4,15 +4,22 @@ use ui::prelude::*;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ExtensionCard {
|
||||
overridden_by_dev_extension: bool,
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
}
|
||||
|
||||
impl ExtensionCard {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
overridden_by_dev_extension: false,
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn overridden_by_dev_extension(mut self, overridden: bool) -> Self {
|
||||
self.overridden_by_dev_extension = overridden;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for ExtensionCard {
|
||||
@@ -34,7 +41,24 @@ impl RenderOnce for ExtensionCard {
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.rounded_md()
|
||||
.children(self.children),
|
||||
.children(self.children)
|
||||
.when(self.overridden_by_dev_extension, |card| {
|
||||
card.child(
|
||||
h_flex()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.occlude()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.bg(theme::color_alpha(
|
||||
cx.theme().colors().elevated_surface_background,
|
||||
0.8,
|
||||
))
|
||||
.child(Label::new("Overridden by dev extension.")),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,66 +5,80 @@ use std::sync::{Arc, OnceLock};
|
||||
use db::kvp::KEY_VALUE_STORE;
|
||||
use editor::Editor;
|
||||
use extension::ExtensionStore;
|
||||
use gpui::{Entity, Model, VisualContext};
|
||||
use gpui::{Model, VisualContext};
|
||||
use language::Buffer;
|
||||
use ui::ViewContext;
|
||||
use workspace::{notifications::simple_message_notification, Workspace};
|
||||
use ui::{SharedString, ViewContext};
|
||||
use workspace::{
|
||||
notifications::{simple_message_notification, NotificationId},
|
||||
Workspace,
|
||||
};
|
||||
|
||||
const SUGGESTIONS_BY_EXTENSION_ID: &[(&str, &[&str])] = &[
|
||||
("astro", &["astro"]),
|
||||
("beancount", &["beancount"]),
|
||||
("clojure", &["bb", "clj", "cljc", "cljs", "edn"]),
|
||||
("csharp", &["cs"]),
|
||||
("dart", &["dart"]),
|
||||
("dockerfile", &["Dockerfile"]),
|
||||
("elisp", &["el"]),
|
||||
("elm", &["elm"]),
|
||||
("erlang", &["erl", "hrl"]),
|
||||
("fish", &["fish"]),
|
||||
(
|
||||
"git-firefly",
|
||||
&[
|
||||
".gitconfig",
|
||||
".gitignore",
|
||||
"COMMIT_EDITMSG",
|
||||
"EDIT_DESCRIPTION",
|
||||
"MERGE_MSG",
|
||||
"NOTES_EDITMSG",
|
||||
"TAG_EDITMSG",
|
||||
"git-rebase-todo",
|
||||
],
|
||||
),
|
||||
("gleam", &["gleam"]),
|
||||
("glsl", &["vert", "frag"]),
|
||||
("graphql", &["gql", "graphql"]),
|
||||
("haskell", &["hs"]),
|
||||
("html", &["htm", "html", "shtml"]),
|
||||
("java", &["java"]),
|
||||
("kotlin", &["kt"]),
|
||||
("latex", &["tex"]),
|
||||
("lua", &["lua"]),
|
||||
("make", &["Makefile"]),
|
||||
("nix", &["nix"]),
|
||||
("nu", &["nu"]),
|
||||
("ocaml", &["ml", "mli"]),
|
||||
("php", &["php"]),
|
||||
("prisma", &["prisma"]),
|
||||
("purescript", &["purs"]),
|
||||
("r", &["r", "R"]),
|
||||
("racket", &["rkt"]),
|
||||
("sql", &["sql"]),
|
||||
("scheme", &["scm"]),
|
||||
("svelte", &["svelte"]),
|
||||
("swift", &["swift"]),
|
||||
("templ", &["templ"]),
|
||||
("terraform", &["tf", "tfvars", "hcl"]),
|
||||
("toml", &["Cargo.lock", "toml"]),
|
||||
("vue", &["vue"]),
|
||||
("wgsl", &["wgsl"]),
|
||||
("zig", &["zig"]),
|
||||
];
|
||||
|
||||
fn suggested_extensions() -> &'static HashMap<&'static str, Arc<str>> {
|
||||
static SUGGESTED: OnceLock<HashMap<&str, Arc<str>>> = OnceLock::new();
|
||||
SUGGESTED.get_or_init(|| {
|
||||
[
|
||||
("astro", "astro"),
|
||||
("beancount", "beancount"),
|
||||
("clojure", "bb"),
|
||||
("clojure", "clj"),
|
||||
("clojure", "cljc"),
|
||||
("clojure", "cljs"),
|
||||
("clojure", "edn"),
|
||||
("csharp", "cs"),
|
||||
("dart", "dart"),
|
||||
("dockerfile", "Dockerfile"),
|
||||
("elisp", "el"),
|
||||
("erlang", "erl"),
|
||||
("erlang", "hrl"),
|
||||
("fish", "fish"),
|
||||
("git-firefly", ".gitconfig"),
|
||||
("git-firefly", ".gitignore"),
|
||||
("git-firefly", "COMMIT_EDITMSG"),
|
||||
("git-firefly", "EDIT_DESCRIPTION"),
|
||||
("git-firefly", "MERGE_MSG"),
|
||||
("git-firefly", "NOTES_EDITMSG"),
|
||||
("git-firefly", "TAG_EDITMSG"),
|
||||
("git-firefly", "git-rebase-todo"),
|
||||
("gleam", "gleam"),
|
||||
("graphql", "gql"),
|
||||
("graphql", "graphql"),
|
||||
("haskell", "hs"),
|
||||
("html", "htm"),
|
||||
("html", "html"),
|
||||
("html", "shtml"),
|
||||
("java", "java"),
|
||||
("kotlin", "kt"),
|
||||
("latex", "tex"),
|
||||
("make", "Makefile"),
|
||||
("nix", "nix"),
|
||||
("php", "php"),
|
||||
("prisma", "prisma"),
|
||||
("purescript", "purs"),
|
||||
("r", "r"),
|
||||
("r", "R"),
|
||||
("sql", "sql"),
|
||||
("svelte", "svelte"),
|
||||
("swift", "swift"),
|
||||
("templ", "templ"),
|
||||
("toml", "Cargo.lock"),
|
||||
("toml", "toml"),
|
||||
("wgsl", "wgsl"),
|
||||
("zig", "zig"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, file)| (file, name.into()))
|
||||
.collect()
|
||||
static SUGGESTIONS_BY_PATH_SUFFIX: OnceLock<HashMap<&str, Arc<str>>> = OnceLock::new();
|
||||
SUGGESTIONS_BY_PATH_SUFFIX.get_or_init(|| {
|
||||
SUGGESTIONS_BY_EXTENSION_ID
|
||||
.into_iter()
|
||||
.flat_map(|(name, path_suffixes)| {
|
||||
let name = Arc::<str>::from(*name);
|
||||
path_suffixes
|
||||
.into_iter()
|
||||
.map(move |suffix| (*suffix, name.clone()))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -140,7 +154,13 @@ pub(crate) fn suggest(buffer: Model<Buffer>, cx: &mut ViewContext<Workspace>) {
|
||||
return;
|
||||
}
|
||||
|
||||
workspace.show_notification(buffer.entity_id().as_u64() as usize, cx, |cx| {
|
||||
struct ExtensionSuggestionNotification;
|
||||
|
||||
let notification_id = NotificationId::identified::<ExtensionSuggestionNotification>(
|
||||
SharedString::from(extension_id.clone()),
|
||||
);
|
||||
|
||||
workspace.show_notification(notification_id, cx, |cx| {
|
||||
cx.new_view(move |_cx| {
|
||||
simple_message_notification::MessageNotification::new(format!(
|
||||
"Do you want to install the recommended '{}' extension for '{}' files?",
|
||||
|
||||
@@ -23,6 +23,7 @@ use std::{ops::Range, sync::Arc};
|
||||
use theme::ThemeSettings;
|
||||
use ui::{popover_menu, prelude::*, ContextMenu, ToggleButton, Tooltip};
|
||||
use util::ResultExt as _;
|
||||
use workspace::item::TabContentParams;
|
||||
use workspace::{
|
||||
item::{Item, ItemEvent},
|
||||
Workspace, WorkspaceId,
|
||||
@@ -189,6 +190,15 @@ impl ExtensionsPage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether a dev extension currently exists for the extension with the given ID.
|
||||
fn dev_extension_exists(extension_id: &str, cx: &mut ViewContext<Self>) -> bool {
|
||||
let extension_store = ExtensionStore::global(cx).read(cx);
|
||||
|
||||
extension_store
|
||||
.dev_extensions()
|
||||
.any(|dev_extension| dev_extension.id.as_ref() == extension_id)
|
||||
}
|
||||
|
||||
fn extension_status(extension_id: &str, cx: &mut ViewContext<Self>) -> ExtensionStatus {
|
||||
let extension_store = ExtensionStore::global(cx).read(cx);
|
||||
|
||||
@@ -416,13 +426,21 @@ impl ExtensionsPage {
|
||||
) -> ExtensionCard {
|
||||
let this = cx.view().clone();
|
||||
let status = Self::extension_status(&extension.id, cx);
|
||||
let has_dev_extension = Self::dev_extension_exists(&extension.id, cx);
|
||||
|
||||
let extension_id = extension.id.clone();
|
||||
let (install_or_uninstall_button, upgrade_button) =
|
||||
self.buttons_for_entry(extension, &status, cx);
|
||||
self.buttons_for_entry(extension, &status, has_dev_extension, cx);
|
||||
let version = extension.manifest.version.clone();
|
||||
let repository_url = extension.manifest.repository.clone();
|
||||
|
||||
let installed_version = match status {
|
||||
ExtensionStatus::Installed(installed_version) => Some(installed_version),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
ExtensionCard::new()
|
||||
.overridden_by_dev_extension(has_dev_extension)
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
@@ -434,9 +452,14 @@ impl ExtensionsPage {
|
||||
Headline::new(extension.manifest.name.clone())
|
||||
.size(HeadlineSize::Medium),
|
||||
)
|
||||
.child(
|
||||
Headline::new(format!("v{}", extension.manifest.version))
|
||||
.size(HeadlineSize::XSmall),
|
||||
.child(Headline::new(format!("v{version}")).size(HeadlineSize::XSmall))
|
||||
.children(
|
||||
installed_version
|
||||
.filter(|installed_version| *installed_version != version)
|
||||
.map(|installed_version| {
|
||||
Headline::new(format!("(v{installed_version} installed)",))
|
||||
.size(HeadlineSize::XSmall)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -576,16 +599,24 @@ impl ExtensionsPage {
|
||||
&self,
|
||||
extension: &ExtensionMetadata,
|
||||
status: &ExtensionStatus,
|
||||
has_dev_extension: bool,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> (Button, Option<Button>) {
|
||||
let is_compatible = extension::is_version_compatible(&extension);
|
||||
let disabled = !is_compatible;
|
||||
|
||||
if has_dev_extension {
|
||||
// If we have a dev extension for the given extension, just treat it as uninstalled.
|
||||
// The button here is a placeholder, as it won't be interactable anyways.
|
||||
return (
|
||||
Button::new(SharedString::from(extension.id.clone()), "Install"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
match status.clone() {
|
||||
ExtensionStatus::NotInstalled => (
|
||||
Button::new(SharedString::from(extension.id.clone()), "Install")
|
||||
.disabled(disabled)
|
||||
.on_click(cx.listener({
|
||||
Button::new(SharedString::from(extension.id.clone()), "Install").on_click(
|
||||
cx.listener({
|
||||
let extension_id = extension.id.clone();
|
||||
move |this, _, cx| {
|
||||
this.telemetry
|
||||
@@ -594,7 +625,8 @@ impl ExtensionsPage {
|
||||
store.install_latest_extension(extension_id.clone(), cx)
|
||||
});
|
||||
}
|
||||
})),
|
||||
}),
|
||||
),
|
||||
None,
|
||||
),
|
||||
ExtensionStatus::Installing => (
|
||||
@@ -625,7 +657,20 @@ impl ExtensionsPage {
|
||||
} else {
|
||||
Some(
|
||||
Button::new(SharedString::from(extension.id.clone()), "Upgrade")
|
||||
.disabled(disabled)
|
||||
.when(!is_compatible, |upgrade_button| {
|
||||
upgrade_button.disabled(true).tooltip({
|
||||
let version = extension.manifest.version.clone();
|
||||
move |cx| {
|
||||
Tooltip::text(
|
||||
format!(
|
||||
"v{version} is not compatible with this version of Zed.",
|
||||
),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
.disabled(!is_compatible)
|
||||
.on_click(cx.listener({
|
||||
let extension_id = extension.id.clone();
|
||||
let version = extension.manifest.version.clone();
|
||||
@@ -925,9 +970,9 @@ impl FocusableView for ExtensionsPage {
|
||||
impl Item for ExtensionsPage {
|
||||
type Event = ItemEvent;
|
||||
|
||||
fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
|
||||
fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement {
|
||||
Label::new("Extensions")
|
||||
.color(if selected {
|
||||
.color(if params.selected {
|
||||
Color::Default
|
||||
} else {
|
||||
Color::Muted
|
||||
|
||||
@@ -18,6 +18,11 @@ pub trait FeatureFlag {
|
||||
const NAME: &'static str;
|
||||
}
|
||||
|
||||
pub struct Remoting {}
|
||||
impl FeatureFlag for Remoting {
|
||||
const NAME: &'static str = "remoting";
|
||||
}
|
||||
|
||||
pub trait FeatureFlagViewExt<V: 'static> {
|
||||
fn observe_flag<T: FeatureFlag, F>(&mut self, callback: F) -> Subscription
|
||||
where
|
||||
|
||||
@@ -17,6 +17,7 @@ use regex::Regex;
|
||||
use serde_derive::Serialize;
|
||||
use ui::{prelude::*, Button, ButtonStyle, IconPosition, Tooltip};
|
||||
use util::{http::HttpClient, ResultExt};
|
||||
use workspace::notifications::NotificationId;
|
||||
use workspace::{DismissDecision, ModalView, Toast, Workspace};
|
||||
|
||||
use crate::{system_specs::SystemSpecs, GiveFeedback, OpenZedRepo};
|
||||
@@ -127,11 +128,11 @@ impl FeedbackModal {
|
||||
let is_local_project = project.read(cx).is_local();
|
||||
|
||||
if !is_local_project {
|
||||
const TOAST_ID: usize = 0xdeadbeef;
|
||||
struct FeedbackInRemoteProject;
|
||||
|
||||
workspace.show_toast(
|
||||
Toast::new(
|
||||
TOAST_ID,
|
||||
NotificationId::unique::<FeedbackInRemoteProject>(),
|
||||
"You can only submit feedback in your own project.",
|
||||
),
|
||||
cx,
|
||||
|
||||
@@ -22,6 +22,7 @@ itertools = "0.11"
|
||||
menu.workspace = true
|
||||
picker.workspace = true
|
||||
project.workspace = true
|
||||
settings.workspace = true
|
||||
text.workspace = true
|
||||
theme.workspace = true
|
||||
ui.workspace = true
|
||||
|
||||
@@ -12,6 +12,7 @@ use gpui::{
|
||||
use itertools::Itertools;
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
|
||||
use settings::Settings;
|
||||
use std::{
|
||||
cmp,
|
||||
path::{Path, PathBuf},
|
||||
@@ -23,7 +24,7 @@ use std::{
|
||||
use text::Point;
|
||||
use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
|
||||
use util::{paths::PathLikeWithPosition, post_inc, ResultExt};
|
||||
use workspace::{ModalView, Workspace};
|
||||
use workspace::{item::PreviewTabsSettings, ModalView, Workspace};
|
||||
|
||||
actions!(file_finder, [Toggle, SelectPrev]);
|
||||
|
||||
@@ -782,13 +783,24 @@ impl PickerDelegate for FileFinderDelegate {
|
||||
if let Some(m) = self.matches.get(self.selected_index()) {
|
||||
if let Some(workspace) = self.workspace.upgrade() {
|
||||
let open_task = workspace.update(cx, move |workspace, cx| {
|
||||
let split_or_open = |workspace: &mut Workspace, project_path, cx| {
|
||||
if secondary {
|
||||
workspace.split_path(project_path, cx)
|
||||
} else {
|
||||
workspace.open_path(project_path, None, true, cx)
|
||||
}
|
||||
};
|
||||
let split_or_open =
|
||||
|workspace: &mut Workspace,
|
||||
project_path,
|
||||
cx: &mut ViewContext<Workspace>| {
|
||||
let allow_preview =
|
||||
PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder;
|
||||
if secondary {
|
||||
workspace.split_path_preview(project_path, allow_preview, cx)
|
||||
} else {
|
||||
workspace.open_path_preview(
|
||||
project_path,
|
||||
None,
|
||||
true,
|
||||
allow_preview,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
};
|
||||
match m {
|
||||
Match::History(history_match, _) => {
|
||||
let worktree_id = history_match.project.worktree_id;
|
||||
|
||||
@@ -78,6 +78,7 @@ fn run_git_blame(
|
||||
.arg(path.as_os_str())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| anyhow!("Failed to start git blame process: {}", e))?;
|
||||
|
||||
|
||||
@@ -12,11 +12,7 @@ workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
test-support = [
|
||||
"backtrace",
|
||||
"collections/test-support",
|
||||
"util/test-support",
|
||||
]
|
||||
test-support = ["backtrace", "collections/test-support", "util/test-support"]
|
||||
runtime_shaders = []
|
||||
macos-blade = ["blade-graphics", "blade-macros", "blade-rwh", "bytemuck"]
|
||||
|
||||
@@ -54,9 +50,8 @@ profiling.workspace = true
|
||||
rand.workspace = true
|
||||
raw-window-handle = "0.6"
|
||||
refineable.workspace = true
|
||||
resvg = "0.14"
|
||||
tiny-skia = "0.5"
|
||||
usvg = { version = "0.14", features = [] }
|
||||
resvg = { version = "0.41.0", default-features = false }
|
||||
usvg = { version = "0.41.0", default-features = false }
|
||||
schemars.workspace = true
|
||||
seahash = "4.1"
|
||||
semantic_version.workspace = true
|
||||
@@ -71,7 +66,7 @@ taffy = { git = "https://github.com/DioxusLabs/taffy", rev = "1876f72bee5e376023
|
||||
thiserror.workspace = true
|
||||
time.workspace = true
|
||||
util.workspace = true
|
||||
uuid = { version = "1.1.2", features = ["v4", "v5"] }
|
||||
uuid.workspace = true
|
||||
waker-fn = "1.1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -182,7 +182,9 @@ impl ActionRegistry {
|
||||
macro_rules! actions {
|
||||
($namespace:path, [ $($name:ident),* $(,)? ]) => {
|
||||
$(
|
||||
/// The `$name` action see [`gpui::actions!`]
|
||||
#[doc = "The `"]
|
||||
#[doc = stringify!($name)]
|
||||
#[doc = "` action, see [`gpui::actions!`]"]
|
||||
#[derive(::std::cmp::PartialEq, ::std::clone::Clone, ::std::default::Default, ::std::fmt::Debug, gpui::private::serde_derive::Deserialize)]
|
||||
#[serde(crate = "gpui::private::serde")]
|
||||
pub struct $name;
|
||||
|
||||
@@ -1416,8 +1416,8 @@ pub struct AnyTooltip {
|
||||
/// The view used to display the tooltip
|
||||
pub view: AnyView,
|
||||
|
||||
/// The offset from the cursor to use, relative to the parent view
|
||||
pub cursor_offset: Point<Pixels>,
|
||||
/// The absolute position of the mouse when the tooltip was deployed.
|
||||
pub mouse_position: Point<Pixels>,
|
||||
}
|
||||
|
||||
/// A keystroke event, and potentially the associated action
|
||||
|
||||