Detailed changes
@@ -20,9 +20,7 @@ jobs:
id: get-content
with:
stringToTruncate: |
- 📣 Zed ${{ github.event.release.tag_name }} was just released!
-
- Restart your Zed or head to ${{ steps.get-release-url.outputs.URL }} to grab it.
+ 📣 Zed [${{ github.event.release.tag_name }}](${{ steps.get-release-url.outputs.URL }}) was just released!
${{ github.event.release.body }}
maxLength: 2000
@@ -91,6 +91,7 @@ dependencies = [
"futures 0.3.28",
"gpui",
"isahc",
+ "language",
"lazy_static",
"log",
"matrixmultiply",
@@ -103,7 +104,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
- "tiktoken-rs 0.5.4",
+ "tiktoken-rs",
"util",
]
@@ -309,6 +310,7 @@ dependencies = [
"language",
"log",
"menu",
+ "multi_buffer",
"ordered-float 2.10.0",
"parking_lot 0.11.2",
"project",
@@ -316,12 +318,13 @@ dependencies = [
"regex",
"schemars",
"search",
+ "semantic_index",
"serde",
"serde_json",
"settings",
"smol",
"theme",
- "tiktoken-rs 0.4.5",
+ "tiktoken-rs",
"util",
"uuid 1.4.1",
"workspace",
@@ -1573,7 +1576,7 @@ dependencies = [
[[package]]
name = "collab"
-version = "0.24.0"
+version = "0.27.0"
dependencies = [
"anyhow",
"async-trait",
@@ -1609,6 +1612,7 @@ dependencies = [
"lsp",
"nanoid",
"node_runtime",
+ "notifications",
"parking_lot 0.11.2",
"pretty_assertions",
"project",
@@ -1664,20 +1668,26 @@ dependencies = [
"fuzzy",
"gpui",
"language",
+ "lazy_static",
"log",
"menu",
+ "notifications",
"picker",
"postage",
+ "pretty_assertions",
"project",
"recent_projects",
"rich_text",
+ "rpc",
"schemars",
"serde",
"serde_derive",
"settings",
+ "smallvec",
"theme",
"theme_selector",
"time",
+ "tree-sitter-markdown",
"util",
"vcs_menu",
"workspace",
@@ -1731,6 +1741,7 @@ dependencies = [
"theme",
"util",
"workspace",
+ "zed-actions",
]
[[package]]
@@ -1810,6 +1821,7 @@ dependencies = [
"log",
"lsp",
"node_runtime",
+ "parking_lot 0.11.2",
"rpc",
"serde",
"serde_derive",
@@ -2556,11 +2568,11 @@ dependencies = [
"lazy_static",
"log",
"lsp",
+ "multi_buffer",
"ordered-float 2.10.0",
"parking_lot 0.11.2",
"postage",
"project",
- "pulldown-cmark",
"rand 0.8.5",
"rich_text",
"rpc",
@@ -4244,6 +4256,7 @@ dependencies = [
"lsp",
"parking_lot 0.11.2",
"postage",
+ "pulldown-cmark",
"rand 0.8.5",
"regex",
"rpc",
@@ -4921,6 +4934,55 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7843ec2de400bcbc6a6328c958dc38e5359da6e93e72e37bc5246bf1ae776389"
+[[package]]
+name = "multi_buffer"
+version = "0.1.0"
+dependencies = [
+ "aho-corasick",
+ "anyhow",
+ "client",
+ "clock",
+ "collections",
+ "context_menu",
+ "convert_case 0.6.0",
+ "copilot",
+ "ctor",
+ "env_logger 0.9.3",
+ "futures 0.3.28",
+ "git",
+ "gpui",
+ "indoc",
+ "itertools 0.10.5",
+ "language",
+ "lazy_static",
+ "log",
+ "lsp",
+ "ordered-float 2.10.0",
+ "parking_lot 0.11.2",
+ "postage",
+ "project",
+ "pulldown-cmark",
+ "rand 0.8.5",
+ "rich_text",
+ "schemars",
+ "serde",
+ "serde_derive",
+ "settings",
+ "smallvec",
+ "smol",
+ "snippet",
+ "sum_tree",
+ "text",
+ "theme",
+ "tree-sitter",
+ "tree-sitter-html",
+ "tree-sitter-rust",
+ "tree-sitter-typescript",
+ "unindent",
+ "util",
+ "workspace",
+]
+
[[package]]
name = "multimap"
version = "0.8.3"
@@ -5070,6 +5132,26 @@ dependencies = [
"minimal-lexical",
]
+[[package]]
+name = "notifications"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "channel",
+ "client",
+ "clock",
+ "collections",
+ "db",
+ "feature_flags",
+ "gpui",
+ "rpc",
+ "settings",
+ "sum_tree",
+ "text",
+ "time",
+ "util",
+]
+
[[package]]
name = "ntapi"
version = "0.3.7"
@@ -5886,6 +5968,7 @@ dependencies = [
"log",
"lsp",
"node_runtime",
+ "parking_lot 0.11.2",
"serde",
"serde_derive",
"serde_json",
@@ -6831,8 +6914,10 @@ dependencies = [
"rsa 0.4.0",
"serde",
"serde_derive",
+ "serde_json",
"smol",
"smol-timeout",
+ "strum",
"tempdir",
"tracing",
"util",
@@ -7407,7 +7492,7 @@ dependencies = [
"smol",
"tempdir",
"theme",
- "tiktoken-rs 0.5.4",
+ "tiktoken-rs",
"tree-sitter",
"tree-sitter-cpp",
"tree-sitter-elixir",
@@ -7421,7 +7506,6 @@ dependencies = [
"unindent",
"util",
"workspace",
- "zed",
]
[[package]]
@@ -8713,21 +8797,6 @@ dependencies = [
"weezl",
]
-[[package]]
-name = "tiktoken-rs"
-version = "0.4.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52aacc1cff93ba9d5f198c62c49c77fa0355025c729eed3326beaf7f33bc8614"
-dependencies = [
- "anyhow",
- "base64 0.21.4",
- "bstr",
- "fancy-regex",
- "lazy_static",
- "parking_lot 0.12.1",
- "rustc-hash",
-]
-
[[package]]
name = "tiktoken-rs"
version = "0.5.4"
@@ -9148,8 +9217,8 @@ dependencies = [
[[package]]
name = "tree-sitter-bash"
-version = "0.19.0"
-source = "git+https://github.com/tree-sitter/tree-sitter-bash?rev=1b0321ee85701d5036c334a6f04761cdc672e64c#1b0321ee85701d5036c334a6f04761cdc672e64c"
+version = "0.20.4"
+source = "git+https://github.com/tree-sitter/tree-sitter-bash?rev=7331995b19b8f8aba2d5e26deb51d2195c18bc94#7331995b19b8f8aba2d5e26deb51d2195c18bc94"
dependencies = [
"cc",
"tree-sitter",
@@ -9388,6 +9457,15 @@ dependencies = [
"tree-sitter",
]
+[[package]]
+name = "tree-sitter-vue"
+version = "0.0.1"
+source = "git+https://github.com/zed-industries/tree-sitter-vue?rev=95b2890#95b28908d90e928c308866f7631e73ef6e1d4b5f"
+dependencies = [
+ "cc",
+ "tree-sitter",
+]
+
[[package]]
name = "tree-sitter-yaml"
version = "0.0.1"
@@ -9712,6 +9790,7 @@ name = "vcs_menu"
version = "0.1.0"
dependencies = [
"anyhow",
+ "fs",
"fuzzy",
"gpui",
"picker",
@@ -10656,9 +10735,10 @@ dependencies = [
[[package]]
name = "zed"
-version = "0.109.0"
+version = "0.111.0"
dependencies = [
"activity_indicator",
+ "ai",
"anyhow",
"assistant",
"async-compression",
@@ -10710,6 +10790,7 @@ dependencies = [
"log",
"lsp",
"node_runtime",
+ "notifications",
"num_cpus",
"outline",
"parking_lot 0.11.2",
@@ -10771,6 +10852,7 @@ dependencies = [
"tree-sitter-svelte",
"tree-sitter-toml",
"tree-sitter-typescript",
+ "tree-sitter-vue",
"tree-sitter-yaml",
"unindent",
"url",
@@ -10788,6 +10870,7 @@ name = "zed-actions"
version = "0.1.0"
dependencies = [
"gpui",
+ "serde",
]
[[package]]
@@ -58,7 +58,9 @@ members = [
"crates/lsp2",
"crates/media",
"crates/menu",
+ "crates/multi_buffer",
"crates/node_runtime",
+ "crates/notifications",
"crates/outline",
"crates/picker",
"crates/plugin",
@@ -133,6 +135,7 @@ serde_derive = { version = "1.0", features = ["deserialize_in_place"] }
serde_json = { version = "1.0", features = ["preserve_order", "raw_value"] }
smallvec = { version = "1.6", features = ["union"] }
smol = { version = "1.2" }
+strum = { version = "0.25.0", features = ["derive"] }
sysinfo = "0.29.10"
tempdir = { version = "0.3.7" }
thiserror = { version = "1.0.29" }
@@ -144,7 +147,7 @@ pretty_assertions = "1.3.0"
git2 = { version = "0.15", default-features = false}
uuid = { version = "1.1.2", features = ["v4"] }
-tree-sitter-bash = { git = "https://github.com/tree-sitter/tree-sitter-bash", rev = "1b0321ee85701d5036c334a6f04761cdc672e64c" }
+tree-sitter-bash = { git = "https://github.com/tree-sitter/tree-sitter-bash", rev = "7331995b19b8f8aba2d5e26deb51d2195c18bc94" }
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" }
@@ -170,7 +173,7 @@ tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml",
tree-sitter-lua = "0.0.14"
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 = "786689b0562b9799ce53e824cb45a1a2a04dc673"}
-
+tree-sitter-vue = {git = "https://github.com/zed-industries/tree-sitter-vue", rev = "95b2890"}
[patch.crates-io]
tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "35a6052fbcafc5e5fc0f9415b8652be7dcaf7222" }
async-task = { git = "https://github.com/zed-industries/async-task", rev = "341b57d6de98cdfd7b418567b8de2022ca993a6e" }
@@ -1,4 +1,4 @@
web: cd ../zed.dev && PORT=3000 npm run dev
-collab: cd crates/collab && RUST_LOG=${RUST_LOG:-collab=info} cargo run serve
+collab: cd crates/collab && RUST_LOG=${RUST_LOG:-warn,collab=info} cargo run serve
livekit: livekit-server --dev
postgrest: postgrest crates/collab/admin_api.conf
@@ -0,0 +1,8 @@
+<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path
+ fill-rule="evenodd"
+ clip-rule="evenodd"
@@ -0,0 +1,3 @@
+<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -0,0 +1,3 @@
+<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M3.74393 2.00204C3.41963 1.97524 3.13502 2.37572 3.10823 2.70001C3.08143 3.0243 3.32558 3.47321 3.64986 3.50001C7.99878 3.85934 11.1406 7.00122 11.5 11.3501C11.5267 11.6744 11.9756 12.0269 12.3 12C12.6243 11.9733 13.0247 11.5804 12.998 11.2561C12.5912 6.33295 8.66704 2.40882 3.74393 2.00204ZM2.9 6.00001C2.96411 5.68099 3.33084 5.29361 3.64986 5.35772C6.66377 5.96341 9.03654 8.33618 9.64223 11.3501C9.70634 11.6691 9.319 12.0359 8.99999 12.1C8.68097 12.1641 8.06411 11.819 7.99999 11.5C7.48788 8.95167 6.0483 7.51213 3.49999 7.00001C3.18097 6.9359 2.8359 6.31902 2.9 6.00001ZM2 9.20001C2.0641 8.88099 2.38635 8.65788 2.70537 8.722C4.50255 9.08317 5.91684 10.4975 6.27801 12.2946C6.34212 12.6137 6.13547 12.9242 5.81646 12.9883C5.49744 13.0525 4.86411 12.819 4.8 12.5C4.53239 11.1683 3.83158 10.4676 2.5 10.2C2.18098 10.1359 1.93588 9.51902 2 9.20001Z" fill="black"/>
+</svg>
@@ -0,0 +1,8 @@
+<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path
+ fill-rule="evenodd"
+ clip-rule="evenodd"
@@ -370,42 +370,15 @@
{
"context": "Pane",
"bindings": {
- "ctrl-1": [
- "pane::ActivateItem",
- 0
- ],
- "ctrl-2": [
- "pane::ActivateItem",
- 1
- ],
- "ctrl-3": [
- "pane::ActivateItem",
- 2
- ],
- "ctrl-4": [
- "pane::ActivateItem",
- 3
- ],
- "ctrl-5": [
- "pane::ActivateItem",
- 4
- ],
- "ctrl-6": [
- "pane::ActivateItem",
- 5
- ],
- "ctrl-7": [
- "pane::ActivateItem",
- 6
- ],
- "ctrl-8": [
- "pane::ActivateItem",
- 7
- ],
- "ctrl-9": [
- "pane::ActivateItem",
- 8
- ],
+ "ctrl-1": ["pane::ActivateItem", 0],
+ "ctrl-2": ["pane::ActivateItem", 1],
+ "ctrl-3": ["pane::ActivateItem", 2],
+ "ctrl-4": ["pane::ActivateItem", 3],
+ "ctrl-5": ["pane::ActivateItem", 4],
+ "ctrl-6": ["pane::ActivateItem", 5],
+ "ctrl-7": ["pane::ActivateItem", 6],
+ "ctrl-8": ["pane::ActivateItem", 7],
+ "ctrl-9": ["pane::ActivateItem", 8],
"ctrl-0": "pane::ActivateLastItem",
"ctrl--": "pane::GoBack",
"ctrl-_": "pane::GoForward",
@@ -416,42 +389,15 @@
{
"context": "Workspace",
"bindings": {
- "cmd-1": [
- "workspace::ActivatePane",
- 0
- ],
- "cmd-2": [
- "workspace::ActivatePane",
- 1
- ],
- "cmd-3": [
- "workspace::ActivatePane",
- 2
- ],
- "cmd-4": [
- "workspace::ActivatePane",
- 3
- ],
- "cmd-5": [
- "workspace::ActivatePane",
- 4
- ],
- "cmd-6": [
- "workspace::ActivatePane",
- 5
- ],
- "cmd-7": [
- "workspace::ActivatePane",
- 6
- ],
- "cmd-8": [
- "workspace::ActivatePane",
- 7
- ],
- "cmd-9": [
- "workspace::ActivatePane",
- 8
- ],
+ "cmd-1": ["workspace::ActivatePane", 0],
+ "cmd-2": ["workspace::ActivatePane", 1],
+ "cmd-3": ["workspace::ActivatePane", 2],
+ "cmd-4": ["workspace::ActivatePane", 3],
+ "cmd-5": ["workspace::ActivatePane", 4],
+ "cmd-6": ["workspace::ActivatePane", 5],
+ "cmd-7": ["workspace::ActivatePane", 6],
+ "cmd-8": ["workspace::ActivatePane", 7],
+ "cmd-9": ["workspace::ActivatePane", 8],
"cmd-b": "workspace::ToggleLeftDock",
"cmd-r": "workspace::ToggleRightDock",
"cmd-j": "workspace::ToggleBottomDock",
@@ -494,38 +440,14 @@
},
{
"bindings": {
- "cmd-k cmd-left": [
- "workspace::ActivatePaneInDirection",
- "Left"
- ],
- "cmd-k cmd-right": [
- "workspace::ActivatePaneInDirection",
- "Right"
- ],
- "cmd-k cmd-up": [
- "workspace::ActivatePaneInDirection",
- "Up"
- ],
- "cmd-k cmd-down": [
- "workspace::ActivatePaneInDirection",
- "Down"
- ],
- "cmd-k shift-left": [
- "workspace::SwapPaneInDirection",
- "Left"
- ],
- "cmd-k shift-right": [
- "workspace::SwapPaneInDirection",
- "Right"
- ],
- "cmd-k shift-up": [
- "workspace::SwapPaneInDirection",
- "Up"
- ],
- "cmd-k shift-down": [
- "workspace::SwapPaneInDirection",
- "Down"
- ]
+ "cmd-k cmd-left": ["workspace::ActivatePaneInDirection", "Left"],
+ "cmd-k cmd-right": ["workspace::ActivatePaneInDirection", "Right"],
+ "cmd-k cmd-up": ["workspace::ActivatePaneInDirection", "Up"],
+ "cmd-k cmd-down": ["workspace::ActivatePaneInDirection", "Down"],
+ "cmd-k shift-left": ["workspace::SwapPaneInDirection", "Left"],
+ "cmd-k shift-right": ["workspace::SwapPaneInDirection", "Right"],
+ "cmd-k shift-up": ["workspace::SwapPaneInDirection", "Up"],
+ "cmd-k shift-down": ["workspace::SwapPaneInDirection", "Down"]
}
},
// Bindings from Atom
@@ -627,14 +549,6 @@
"space": "collab_panel::InsertSpace"
}
},
- {
- "context": "(CollabPanel && not_editing) > Editor",
- "bindings": {
- "cmd-c": "collab_panel::StartLinkChannel",
- "cmd-x": "collab_panel::StartMoveChannel",
- "cmd-v": "collab_panel::MoveOrLinkToSelected"
- }
- },
{
"context": "ChannelModal",
"bindings": {
@@ -655,57 +569,21 @@
"cmd-v": "terminal::Paste",
"cmd-k": "terminal::Clear",
// Some nice conveniences
- "cmd-backspace": [
- "terminal::SendText",
- "\u0015"
- ],
- "cmd-right": [
- "terminal::SendText",
- "\u0005"
- ],
- "cmd-left": [
- "terminal::SendText",
- "\u0001"
- ],
+ "cmd-backspace": ["terminal::SendText", "\u0015"],
+ "cmd-right": ["terminal::SendText", "\u0005"],
+ "cmd-left": ["terminal::SendText", "\u0001"],
// Terminal.app compatibility
- "alt-left": [
- "terminal::SendText",
- "\u001bb"
- ],
- "alt-right": [
- "terminal::SendText",
- "\u001bf"
- ],
+ "alt-left": ["terminal::SendText", "\u001bb"],
+ "alt-right": ["terminal::SendText", "\u001bf"],
// There are conflicting bindings for these keys in the global context.
// these bindings override them, remove at your own risk:
- "up": [
- "terminal::SendKeystroke",
- "up"
- ],
- "pageup": [
- "terminal::SendKeystroke",
- "pageup"
- ],
- "down": [
- "terminal::SendKeystroke",
- "down"
- ],
- "pagedown": [
- "terminal::SendKeystroke",
- "pagedown"
- ],
- "escape": [
- "terminal::SendKeystroke",
- "escape"
- ],
- "enter": [
- "terminal::SendKeystroke",
- "enter"
- ],
- "ctrl-c": [
- "terminal::SendKeystroke",
- "ctrl-c"
- ]
+ "up": ["terminal::SendKeystroke", "up"],
+ "pageup": ["terminal::SendKeystroke", "pageup"],
+ "down": ["terminal::SendKeystroke", "down"],
+ "pagedown": ["terminal::SendKeystroke", "pagedown"],
+ "escape": ["terminal::SendKeystroke", "escape"],
+ "enter": ["terminal::SendKeystroke", "enter"],
+ "ctrl-c": ["terminal::SendKeystroke", "ctrl-c"]
}
}
]
@@ -39,6 +39,7 @@
"w": "vim::NextWordStart",
"{": "vim::StartOfParagraph",
"}": "vim::EndOfParagraph",
+ "|": "vim::GoToColumn",
"shift-w": [
"vim::NextWordStart",
{
@@ -97,14 +98,8 @@
"ctrl-o": "pane::GoBack",
"ctrl-i": "pane::GoForward",
"ctrl-]": "editor::GoToDefinition",
- "escape": [
- "vim::SwitchMode",
- "Normal"
- ],
- "ctrl+[": [
- "vim::SwitchMode",
- "Normal"
- ],
+ "escape": ["vim::SwitchMode", "Normal"],
+ "ctrl+[": ["vim::SwitchMode", "Normal"],
"v": "vim::ToggleVisual",
"shift-v": "vim::ToggleVisualLine",
"ctrl-v": "vim::ToggleVisualBlock",
@@ -233,123 +228,36 @@
}
],
// Count support
- "1": [
- "vim::Number",
- 1
- ],
- "2": [
- "vim::Number",
- 2
- ],
- "3": [
- "vim::Number",
- 3
- ],
- "4": [
- "vim::Number",
- 4
- ],
- "5": [
- "vim::Number",
- 5
- ],
- "6": [
- "vim::Number",
- 6
- ],
- "7": [
- "vim::Number",
- 7
- ],
- "8": [
- "vim::Number",
- 8
- ],
- "9": [
- "vim::Number",
- 9
- ],
+ "1": ["vim::Number", 1],
+ "2": ["vim::Number", 2],
+ "3": ["vim::Number", 3],
+ "4": ["vim::Number", 4],
+ "5": ["vim::Number", 5],
+ "6": ["vim::Number", 6],
+ "7": ["vim::Number", 7],
+ "8": ["vim::Number", 8],
+ "9": ["vim::Number", 9],
// window related commands (ctrl-w X)
- "ctrl-w left": [
- "workspace::ActivatePaneInDirection",
- "Left"
- ],
- "ctrl-w right": [
- "workspace::ActivatePaneInDirection",
- "Right"
- ],
- "ctrl-w up": [
- "workspace::ActivatePaneInDirection",
- "Up"
- ],
- "ctrl-w down": [
- "workspace::ActivatePaneInDirection",
- "Down"
- ],
- "ctrl-w h": [
- "workspace::ActivatePaneInDirection",
- "Left"
- ],
- "ctrl-w l": [
- "workspace::ActivatePaneInDirection",
- "Right"
- ],
- "ctrl-w k": [
- "workspace::ActivatePaneInDirection",
- "Up"
- ],
- "ctrl-w j": [
- "workspace::ActivatePaneInDirection",
- "Down"
- ],
- "ctrl-w ctrl-h": [
- "workspace::ActivatePaneInDirection",
- "Left"
- ],
- "ctrl-w ctrl-l": [
- "workspace::ActivatePaneInDirection",
- "Right"
- ],
- "ctrl-w ctrl-k": [
- "workspace::ActivatePaneInDirection",
- "Up"
- ],
- "ctrl-w ctrl-j": [
- "workspace::ActivatePaneInDirection",
- "Down"
- ],
- "ctrl-w shift-left": [
- "workspace::SwapPaneInDirection",
- "Left"
- ],
- "ctrl-w shift-right": [
- "workspace::SwapPaneInDirection",
- "Right"
- ],
- "ctrl-w shift-up": [
- "workspace::SwapPaneInDirection",
- "Up"
- ],
- "ctrl-w shift-down": [
- "workspace::SwapPaneInDirection",
- "Down"
- ],
- "ctrl-w shift-h": [
- "workspace::SwapPaneInDirection",
- "Left"
- ],
- "ctrl-w shift-l": [
- "workspace::SwapPaneInDirection",
- "Right"
- ],
- "ctrl-w shift-k": [
- "workspace::SwapPaneInDirection",
- "Up"
- ],
- "ctrl-w shift-j": [
- "workspace::SwapPaneInDirection",
- "Down"
- ],
+ "ctrl-w left": ["workspace::ActivatePaneInDirection", "Left"],
+ "ctrl-w right": ["workspace::ActivatePaneInDirection", "Right"],
+ "ctrl-w up": ["workspace::ActivatePaneInDirection", "Up"],
+ "ctrl-w down": ["workspace::ActivatePaneInDirection", "Down"],
+ "ctrl-w h": ["workspace::ActivatePaneInDirection", "Left"],
+ "ctrl-w l": ["workspace::ActivatePaneInDirection", "Right"],
+ "ctrl-w k": ["workspace::ActivatePaneInDirection", "Up"],
+ "ctrl-w j": ["workspace::ActivatePaneInDirection", "Down"],
+ "ctrl-w ctrl-h": ["workspace::ActivatePaneInDirection", "Left"],
+ "ctrl-w ctrl-l": ["workspace::ActivatePaneInDirection", "Right"],
+ "ctrl-w ctrl-k": ["workspace::ActivatePaneInDirection", "Up"],
+ "ctrl-w ctrl-j": ["workspace::ActivatePaneInDirection", "Down"],
+ "ctrl-w shift-left": ["workspace::SwapPaneInDirection", "Left"],
+ "ctrl-w shift-right": ["workspace::SwapPaneInDirection", "Right"],
+ "ctrl-w shift-up": ["workspace::SwapPaneInDirection", "Up"],
+ "ctrl-w shift-down": ["workspace::SwapPaneInDirection", "Down"],
+ "ctrl-w shift-h": ["workspace::SwapPaneInDirection", "Left"],
+ "ctrl-w shift-l": ["workspace::SwapPaneInDirection", "Right"],
+ "ctrl-w shift-k": ["workspace::SwapPaneInDirection", "Up"],
+ "ctrl-w shift-j": ["workspace::SwapPaneInDirection", "Down"],
"ctrl-w g t": "pane::ActivateNextItem",
"ctrl-w ctrl-g t": "pane::ActivateNextItem",
"ctrl-w g shift-t": "pane::ActivatePrevItem",
@@ -371,14 +279,8 @@
"ctrl-w ctrl-q": "pane::CloseAllItems",
"ctrl-w o": "workspace::CloseInactiveTabsAndPanes",
"ctrl-w ctrl-o": "workspace::CloseInactiveTabsAndPanes",
- "ctrl-w n": [
- "workspace::NewFileInDirection",
- "Up"
- ],
- "ctrl-w ctrl-n": [
- "workspace::NewFileInDirection",
- "Up"
- ]
+ "ctrl-w n": ["workspace::NewFileInDirection", "Up"],
+ "ctrl-w ctrl-n": ["workspace::NewFileInDirection", "Up"]
}
},
{
@@ -393,21 +295,12 @@
"context": "Editor && vim_mode == normal && vim_operator == none && !VimWaiting",
"bindings": {
".": "vim::Repeat",
- "c": [
- "vim::PushOperator",
- "Change"
- ],
+ "c": ["vim::PushOperator", "Change"],
"shift-c": "vim::ChangeToEndOfLine",
- "d": [
- "vim::PushOperator",
- "Delete"
- ],
+ "d": ["vim::PushOperator", "Delete"],
"shift-d": "vim::DeleteToEndOfLine",
"shift-j": "vim::JoinLines",
- "y": [
- "vim::PushOperator",
- "Yank"
- ],
+ "y": ["vim::PushOperator", "Yank"],
"shift-y": "vim::YankLine",
"i": "vim::InsertBefore",
"shift-i": "vim::InsertFirstNonWhitespace",
@@ -443,10 +336,7 @@
"backwards": true
}
],
- "r": [
- "vim::PushOperator",
- "Replace"
- ],
+ "r": ["vim::PushOperator", "Replace"],
"s": "vim::Substitute",
"shift-s": "vim::SubstituteLine",
"> >": "editor::Indent",
@@ -458,10 +348,7 @@
{
"context": "Editor && VimCount",
"bindings": {
- "0": [
- "vim::Number",
- 0
- ]
+ "0": ["vim::Number", 0]
}
},
{
@@ -497,12 +384,15 @@
"'": "vim::Quotes",
"`": "vim::BackQuotes",
"\"": "vim::DoubleQuotes",
+ "|": "vim::VerticalBars",
"(": "vim::Parentheses",
")": "vim::Parentheses",
+ "b": "vim::Parentheses",
"[": "vim::SquareBrackets",
"]": "vim::SquareBrackets",
"{": "vim::CurlyBrackets",
"}": "vim::CurlyBrackets",
+ "shift-b": "vim::CurlyBrackets",
"<": "vim::AngleBrackets",
">": "vim::AngleBrackets"
}
@@ -548,22 +438,10 @@
"shift-i": "vim::InsertBefore",
"shift-a": "vim::InsertAfter",
"shift-j": "vim::JoinLines",
- "r": [
- "vim::PushOperator",
- "Replace"
- ],
- "ctrl-c": [
- "vim::SwitchMode",
- "Normal"
- ],
- "escape": [
- "vim::SwitchMode",
- "Normal"
- ],
- "ctrl+[": [
- "vim::SwitchMode",
- "Normal"
- ],
+ "r": ["vim::PushOperator", "Replace"],
+ "ctrl-c": ["vim::SwitchMode", "Normal"],
+ "escape": ["vim::SwitchMode", "Normal"],
+ "ctrl+[": ["vim::SwitchMode", "Normal"],
">": "editor::Indent",
"<": "editor::Outdent",
"i": [
@@ -602,14 +480,8 @@
"bindings": {
"tab": "vim::Tab",
"enter": "vim::Enter",
- "escape": [
- "vim::SwitchMode",
- "Normal"
- ],
- "ctrl+[": [
- "vim::SwitchMode",
- "Normal"
- ]
+ "escape": ["vim::SwitchMode", "Normal"],
+ "ctrl+[": ["vim::SwitchMode", "Normal"]
}
},
{
@@ -50,6 +50,9 @@
// Whether to pop the completions menu while typing in an editor without
// explicitly requesting it.
"show_completions_on_input": true,
+ // Whether to display inline and alongside documentation for items in the
+ // completions menu
+ "show_completion_documentation": true,
// Whether to show wrap guides in the editor. Setting this to true will
// show a guide at the 'preferred_line_length' value if softwrap is set to
// 'preferred_line_length', and will show any additional guides as specified
@@ -139,6 +142,14 @@
// Default width of the channels panel.
"default_width": 240
},
+ "notification_panel": {
+ // Whether to show the collaboration panel button in the status bar.
+ "button": true,
+ // Where to dock channels panel. Can be 'left' or 'right'.
+ "dock": "right",
+ // Default width of the channels panel.
+ "default_width": 380
+ },
"assistant": {
// Whether to show the assistant panel button in the status bar.
"button": true,
@@ -11,6 +11,7 @@ doctest = false
[dependencies]
gpui = { path = "../gpui" }
util = { path = "../util" }
+language = { path = "../language" }
async-trait.workspace = true
anyhow.workspace = true
futures.workspace = true
@@ -1,2 +1,4 @@
pub mod completion;
pub mod embedding;
+pub mod models;
+pub mod templates;
@@ -53,6 +53,8 @@ pub struct OpenAIRequest {
pub model: String,
pub messages: Vec<RequestMessage>,
pub stream: bool,
+ pub stop: Vec<String>,
+ pub temperature: f32,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
@@ -2,7 +2,7 @@ use anyhow::{anyhow, Result};
use async_trait::async_trait;
use futures::AsyncReadExt;
use gpui::executor::Background;
-use gpui::serde_json;
+use gpui::{serde_json, AppContext};
use isahc::http::StatusCode;
use isahc::prelude::Configurable;
use isahc::{AsyncBody, Response};
@@ -20,9 +20,11 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use tiktoken_rs::{cl100k_base, CoreBPE};
use util::http::{HttpClient, Request};
+use util::ResultExt;
+
+use crate::completion::OPENAI_API_URL;
lazy_static! {
- static ref OPENAI_API_KEY: Option<String> = env::var("OPENAI_API_KEY").ok();
static ref OPENAI_BPE_TOKENIZER: CoreBPE = cl100k_base().unwrap();
}
@@ -85,25 +87,6 @@ impl Embedding {
}
}
-// impl FromSql for Embedding {
-// fn column_result(value: ValueRef) -> FromSqlResult<Self> {
-// let bytes = value.as_blob()?;
-// let embedding: Result<Vec<f32>, Box<bincode::ErrorKind>> = bincode::deserialize(bytes);
-// if embedding.is_err() {
-// return Err(rusqlite::types::FromSqlError::Other(embedding.unwrap_err()));
-// }
-// Ok(Embedding(embedding.unwrap()))
-// }
-// }
-
-// impl ToSql for Embedding {
-// fn to_sql(&self) -> rusqlite::Result<ToSqlOutput> {
-// let bytes = bincode::serialize(&self.0)
-// .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))?;
-// Ok(ToSqlOutput::Owned(rusqlite::types::Value::Blob(bytes)))
-// }
-// }
-
#[derive(Clone)]
pub struct OpenAIEmbeddings {
pub client: Arc<dyn HttpClient>,
@@ -139,8 +122,12 @@ struct OpenAIEmbeddingUsage {
#[async_trait]
pub trait EmbeddingProvider: Sync + Send {
- fn is_authenticated(&self) -> bool;
- async fn embed_batch(&self, spans: Vec<String>) -> Result<Vec<Embedding>>;
+ fn retrieve_credentials(&self, cx: &AppContext) -> Option<String>;
+ async fn embed_batch(
+ &self,
+ spans: Vec<String>,
+ api_key: Option<String>,
+ ) -> Result<Vec<Embedding>>;
fn max_tokens_per_batch(&self) -> usize;
fn truncate(&self, span: &str) -> (String, usize);
fn rate_limit_expiration(&self) -> Option<Instant>;
@@ -150,13 +137,17 @@ pub struct DummyEmbeddings {}
#[async_trait]
impl EmbeddingProvider for DummyEmbeddings {
- fn is_authenticated(&self) -> bool {
- true
+ fn retrieve_credentials(&self, _cx: &AppContext) -> Option<String> {
+ Some("Dummy API KEY".to_string())
}
fn rate_limit_expiration(&self) -> Option<Instant> {
None
}
- async fn embed_batch(&self, spans: Vec<String>) -> Result<Vec<Embedding>> {
+ async fn embed_batch(
+ &self,
+ spans: Vec<String>,
+ _api_key: Option<String>,
+ ) -> Result<Vec<Embedding>> {
// 1024 is the OpenAI Embeddings size for ada models.
// the model we will likely be starting with.
let dummy_vec = Embedding::from(vec![0.32 as f32; 1536]);
@@ -255,9 +246,21 @@ impl OpenAIEmbeddings {
#[async_trait]
impl EmbeddingProvider for OpenAIEmbeddings {
- fn is_authenticated(&self) -> bool {
- OPENAI_API_KEY.as_ref().is_some()
+ fn retrieve_credentials(&self, cx: &AppContext) -> Option<String> {
+ if let Ok(api_key) = env::var("OPENAI_API_KEY") {
+ Some(api_key)
+ } else if let Some((_, api_key)) = cx
+ .platform()
+ .read_credentials(OPENAI_API_URL)
+ .log_err()
+ .flatten()
+ {
+ String::from_utf8(api_key).log_err()
+ } else {
+ None
+ }
}
+
fn max_tokens_per_batch(&self) -> usize {
50000
}
@@ -280,13 +283,17 @@ impl EmbeddingProvider for OpenAIEmbeddings {
(output, tokens.len())
}
- async fn embed_batch(&self, spans: Vec<String>) -> Result<Vec<Embedding>> {
+ async fn embed_batch(
+ &self,
+ spans: Vec<String>,
+ api_key: Option<String>,
+ ) -> Result<Vec<Embedding>> {
const BACKOFF_SECONDS: [usize; 4] = [3, 5, 15, 45];
const MAX_RETRIES: usize = 4;
- let api_key = OPENAI_API_KEY
- .as_ref()
- .ok_or_else(|| anyhow!("no api key"))?;
+ let Some(api_key) = api_key else {
+ return Err(anyhow!("no open ai key provided"));
+ };
let mut request_number = 0;
let mut rate_limiting = false;
@@ -295,11 +302,12 @@ impl EmbeddingProvider for OpenAIEmbeddings {
while request_number < MAX_RETRIES {
response = self
.send_request(
- api_key,
+ &api_key,
spans.iter().map(|x| &**x).collect(),
request_timeout,
)
.await?;
+
request_number += 1;
match response.status() {
@@ -0,0 +1,66 @@
+use anyhow::anyhow;
+use tiktoken_rs::CoreBPE;
+use util::ResultExt;
+
+pub trait LanguageModel {
+ fn name(&self) -> String;
+ fn count_tokens(&self, content: &str) -> anyhow::Result<usize>;
+ fn truncate(&self, content: &str, length: usize) -> anyhow::Result<String>;
+ fn truncate_start(&self, content: &str, length: usize) -> anyhow::Result<String>;
+ fn capacity(&self) -> anyhow::Result<usize>;
+}
+
+pub struct OpenAILanguageModel {
+ name: String,
+ bpe: Option<CoreBPE>,
+}
+
+impl OpenAILanguageModel {
+ pub fn load(model_name: &str) -> Self {
+ let bpe = tiktoken_rs::get_bpe_from_model(model_name).log_err();
+ OpenAILanguageModel {
+ name: model_name.to_string(),
+ bpe,
+ }
+ }
+}
+
+impl LanguageModel for OpenAILanguageModel {
+ fn name(&self) -> String {
+ self.name.clone()
+ }
+ fn count_tokens(&self, content: &str) -> anyhow::Result<usize> {
+ if let Some(bpe) = &self.bpe {
+ anyhow::Ok(bpe.encode_with_special_tokens(content).len())
+ } else {
+ Err(anyhow!("bpe for open ai model was not retrieved"))
+ }
+ }
+ fn truncate(&self, content: &str, length: usize) -> anyhow::Result<String> {
+ if let Some(bpe) = &self.bpe {
+ let tokens = bpe.encode_with_special_tokens(content);
+ if tokens.len() > length {
+ bpe.decode(tokens[..length].to_vec())
+ } else {
+ bpe.decode(tokens)
+ }
+ } else {
+ Err(anyhow!("bpe for open ai model was not retrieved"))
+ }
+ }
+ fn truncate_start(&self, content: &str, length: usize) -> anyhow::Result<String> {
+ if let Some(bpe) = &self.bpe {
+ let tokens = bpe.encode_with_special_tokens(content);
+ if tokens.len() > length {
+ bpe.decode(tokens[length..].to_vec())
+ } else {
+ bpe.decode(tokens)
+ }
+ } else {
+ Err(anyhow!("bpe for open ai model was not retrieved"))
+ }
+ }
+ fn capacity(&self) -> anyhow::Result<usize> {
+ anyhow::Ok(tiktoken_rs::model::get_context_size(&self.name))
+ }
+}
@@ -0,0 +1,350 @@
+use std::cmp::Reverse;
+use std::ops::Range;
+use std::sync::Arc;
+
+use language::BufferSnapshot;
+use util::ResultExt;
+
+use crate::models::LanguageModel;
+use crate::templates::repository_context::PromptCodeSnippet;
+
+pub(crate) enum PromptFileType {
+ Text,
+ Code,
+}
+
+// TODO: Set this up to manage for defaults well
+pub struct PromptArguments {
+ pub model: Arc<dyn LanguageModel>,
+ pub user_prompt: Option<String>,
+ pub language_name: Option<String>,
+ pub project_name: Option<String>,
+ pub snippets: Vec<PromptCodeSnippet>,
+ pub reserved_tokens: usize,
+ pub buffer: Option<BufferSnapshot>,
+ pub selected_range: Option<Range<usize>>,
+}
+
+impl PromptArguments {
+ pub(crate) fn get_file_type(&self) -> PromptFileType {
+ if self
+ .language_name
+ .as_ref()
+ .and_then(|name| Some(!["Markdown", "Plain Text"].contains(&name.as_str())))
+ .unwrap_or(true)
+ {
+ PromptFileType::Code
+ } else {
+ PromptFileType::Text
+ }
+ }
+}
+
+pub trait PromptTemplate {
+ fn generate(
+ &self,
+ args: &PromptArguments,
+ max_token_length: Option<usize>,
+ ) -> anyhow::Result<(String, usize)>;
+}
+
+#[repr(i8)]
+#[derive(PartialEq, Eq, Ord)]
+pub enum PromptPriority {
+ Mandatory, // Ignores truncation
+ Ordered { order: usize }, // Truncates based on priority
+}
+
+impl PartialOrd for PromptPriority {
+ fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+ match (self, other) {
+ (Self::Mandatory, Self::Mandatory) => Some(std::cmp::Ordering::Equal),
+ (Self::Mandatory, Self::Ordered { .. }) => Some(std::cmp::Ordering::Greater),
+ (Self::Ordered { .. }, Self::Mandatory) => Some(std::cmp::Ordering::Less),
+ (Self::Ordered { order: a }, Self::Ordered { order: b }) => b.partial_cmp(a),
+ }
+ }
+}
+
+pub struct PromptChain {
+ args: PromptArguments,
+ templates: Vec<(PromptPriority, Box<dyn PromptTemplate>)>,
+}
+
+impl PromptChain {
+ pub fn new(
+ args: PromptArguments,
+ templates: Vec<(PromptPriority, Box<dyn PromptTemplate>)>,
+ ) -> Self {
+ PromptChain { args, templates }
+ }
+
+ pub fn generate(&self, truncate: bool) -> anyhow::Result<(String, usize)> {
+ // Argsort based on Prompt Priority
+ let seperator = "\n";
+ let seperator_tokens = self.args.model.count_tokens(seperator)?;
+ let mut sorted_indices = (0..self.templates.len()).collect::<Vec<_>>();
+ sorted_indices.sort_by_key(|&i| Reverse(&self.templates[i].0));
+
+ // If Truncate
+ let mut tokens_outstanding = if truncate {
+ Some(self.args.model.capacity()? - self.args.reserved_tokens)
+ } else {
+ None
+ };
+
+ let mut prompts = vec!["".to_string(); sorted_indices.len()];
+ for idx in sorted_indices {
+ let (_, template) = &self.templates[idx];
+
+ if let Some((template_prompt, prompt_token_count)) =
+ template.generate(&self.args, tokens_outstanding).log_err()
+ {
+ if template_prompt != "" {
+ prompts[idx] = template_prompt;
+
+ if let Some(remaining_tokens) = tokens_outstanding {
+ let new_tokens = prompt_token_count + seperator_tokens;
+ tokens_outstanding = if remaining_tokens > new_tokens {
+ Some(remaining_tokens - new_tokens)
+ } else {
+ Some(0)
+ };
+ }
+ }
+ }
+ }
+
+ prompts.retain(|x| x != "");
+
+ let full_prompt = prompts.join(seperator);
+ let total_token_count = self.args.model.count_tokens(&full_prompt)?;
+ anyhow::Ok((prompts.join(seperator), total_token_count))
+ }
+}
+
+#[cfg(test)]
+pub(crate) mod tests {
+ use super::*;
+
+ #[test]
+ pub fn test_prompt_chain() {
+ struct TestPromptTemplate {}
+ impl PromptTemplate for TestPromptTemplate {
+ fn generate(
+ &self,
+ args: &PromptArguments,
+ max_token_length: Option<usize>,
+ ) -> anyhow::Result<(String, usize)> {
+ let mut content = "This is a test prompt template".to_string();
+
+ let mut token_count = args.model.count_tokens(&content)?;
+ if let Some(max_token_length) = max_token_length {
+ if token_count > max_token_length {
+ content = args.model.truncate(&content, max_token_length)?;
+ token_count = max_token_length;
+ }
+ }
+
+ anyhow::Ok((content, token_count))
+ }
+ }
+
+ struct TestLowPriorityTemplate {}
+ impl PromptTemplate for TestLowPriorityTemplate {
+ fn generate(
+ &self,
+ args: &PromptArguments,
+ max_token_length: Option<usize>,
+ ) -> anyhow::Result<(String, usize)> {
+ let mut content = "This is a low priority test prompt template".to_string();
+
+ let mut token_count = args.model.count_tokens(&content)?;
+ if let Some(max_token_length) = max_token_length {
+ if token_count > max_token_length {
+ content = args.model.truncate(&content, max_token_length)?;
+ token_count = max_token_length;
+ }
+ }
+
+ anyhow::Ok((content, token_count))
+ }
+ }
+
+ #[derive(Clone)]
+ struct DummyLanguageModel {
+ capacity: usize,
+ }
+
+ impl LanguageModel for DummyLanguageModel {
+ fn name(&self) -> String {
+ "dummy".to_string()
+ }
+ fn count_tokens(&self, content: &str) -> anyhow::Result<usize> {
+ anyhow::Ok(content.chars().collect::<Vec<char>>().len())
+ }
+ fn truncate(&self, content: &str, length: usize) -> anyhow::Result<String> {
+ anyhow::Ok(
+ content.chars().collect::<Vec<char>>()[..length]
+ .into_iter()
+ .collect::<String>(),
+ )
+ }
+ fn truncate_start(&self, content: &str, length: usize) -> anyhow::Result<String> {
+ anyhow::Ok(
+ content.chars().collect::<Vec<char>>()[length..]
+ .into_iter()
+ .collect::<String>(),
+ )
+ }
+ fn capacity(&self) -> anyhow::Result<usize> {
+ anyhow::Ok(self.capacity)
+ }
+ }
+
+ let model: Arc<dyn LanguageModel> = Arc::new(DummyLanguageModel { capacity: 100 });
+ let args = PromptArguments {
+ model: model.clone(),
+ language_name: None,
+ project_name: None,
+ snippets: Vec::new(),
+ reserved_tokens: 0,
+ buffer: None,
+ selected_range: None,
+ user_prompt: None,
+ };
+
+ let templates: Vec<(PromptPriority, Box<dyn PromptTemplate>)> = vec![
+ (
+ PromptPriority::Ordered { order: 0 },
+ Box::new(TestPromptTemplate {}),
+ ),
+ (
+ PromptPriority::Ordered { order: 1 },
+ Box::new(TestLowPriorityTemplate {}),
+ ),
+ ];
+ let chain = PromptChain::new(args, templates);
+
+ let (prompt, token_count) = chain.generate(false).unwrap();
+
+ assert_eq!(
+ prompt,
+ "This is a test prompt template\nThis is a low priority test prompt template"
+ .to_string()
+ );
+
+ assert_eq!(model.count_tokens(&prompt).unwrap(), token_count);
+
+ // Testing with Truncation Off
+ // Should ignore capacity and return all prompts
+ let model: Arc<dyn LanguageModel> = Arc::new(DummyLanguageModel { capacity: 20 });
+ let args = PromptArguments {
+ model: model.clone(),
+ language_name: None,
+ project_name: None,
+ snippets: Vec::new(),
+ reserved_tokens: 0,
+ buffer: None,
+ selected_range: None,
+ user_prompt: None,
+ };
+
+ let templates: Vec<(PromptPriority, Box<dyn PromptTemplate>)> = vec![
+ (
+ PromptPriority::Ordered { order: 0 },
+ Box::new(TestPromptTemplate {}),
+ ),
+ (
+ PromptPriority::Ordered { order: 1 },
+ Box::new(TestLowPriorityTemplate {}),
+ ),
+ ];
+ let chain = PromptChain::new(args, templates);
+
+ let (prompt, token_count) = chain.generate(false).unwrap();
+
+ assert_eq!(
+ prompt,
+ "This is a test prompt template\nThis is a low priority test prompt template"
+ .to_string()
+ );
+
+ assert_eq!(model.count_tokens(&prompt).unwrap(), token_count);
+
+ // Testing with Truncation Off
+ // Should ignore capacity and return all prompts
+ let capacity = 20;
+ let model: Arc<dyn LanguageModel> = Arc::new(DummyLanguageModel { capacity });
+ let args = PromptArguments {
+ model: model.clone(),
+ language_name: None,
+ project_name: None,
+ snippets: Vec::new(),
+ reserved_tokens: 0,
+ buffer: None,
+ selected_range: None,
+ user_prompt: None,
+ };
+
+ let templates: Vec<(PromptPriority, Box<dyn PromptTemplate>)> = vec![
+ (
+ PromptPriority::Ordered { order: 0 },
+ Box::new(TestPromptTemplate {}),
+ ),
+ (
+ PromptPriority::Ordered { order: 1 },
+ Box::new(TestLowPriorityTemplate {}),
+ ),
+ (
+ PromptPriority::Ordered { order: 2 },
+ Box::new(TestLowPriorityTemplate {}),
+ ),
+ ];
+ let chain = PromptChain::new(args, templates);
+
+ let (prompt, token_count) = chain.generate(true).unwrap();
+
+ assert_eq!(prompt, "This is a test promp".to_string());
+ assert_eq!(token_count, capacity);
+
+ // Change Ordering of Prompts Based on Priority
+ let capacity = 120;
+ let reserved_tokens = 10;
+ let model: Arc<dyn LanguageModel> = Arc::new(DummyLanguageModel { capacity });
+ let args = PromptArguments {
+ model: model.clone(),
+ language_name: None,
+ project_name: None,
+ snippets: Vec::new(),
+ reserved_tokens,
+ buffer: None,
+ selected_range: None,
+ user_prompt: None,
+ };
+ let templates: Vec<(PromptPriority, Box<dyn PromptTemplate>)> = vec![
+ (
+ PromptPriority::Mandatory,
+ Box::new(TestLowPriorityTemplate {}),
+ ),
+ (
+ PromptPriority::Ordered { order: 0 },
+ Box::new(TestPromptTemplate {}),
+ ),
+ (
+ PromptPriority::Ordered { order: 1 },
+ Box::new(TestLowPriorityTemplate {}),
+ ),
+ ];
+ let chain = PromptChain::new(args, templates);
+
+ let (prompt, token_count) = chain.generate(true).unwrap();
+
+ assert_eq!(
+ prompt,
+ "This is a low priority test prompt template\nThis is a test prompt template\nThis is a low priority test prompt "
+ .to_string()
+ );
+ assert_eq!(token_count, capacity - reserved_tokens);
+ }
+}
@@ -0,0 +1,160 @@
+use anyhow::anyhow;
+use language::BufferSnapshot;
+use language::ToOffset;
+
+use crate::models::LanguageModel;
+use crate::templates::base::PromptArguments;
+use crate::templates::base::PromptTemplate;
+use std::fmt::Write;
+use std::ops::Range;
+use std::sync::Arc;
+
+fn retrieve_context(
+ buffer: &BufferSnapshot,
+ selected_range: &Option<Range<usize>>,
+ model: Arc<dyn LanguageModel>,
+ max_token_count: Option<usize>,
+) -> anyhow::Result<(String, usize, bool)> {
+ let mut prompt = String::new();
+ let mut truncated = false;
+ if let Some(selected_range) = selected_range {
+ let start = selected_range.start.to_offset(buffer);
+ let end = selected_range.end.to_offset(buffer);
+
+ let start_window = buffer.text_for_range(0..start).collect::<String>();
+
+ let mut selected_window = String::new();
+ if start == end {
+ write!(selected_window, "<|START|>").unwrap();
+ } else {
+ write!(selected_window, "<|START|").unwrap();
+ }
+
+ write!(
+ selected_window,
+ "{}",
+ buffer.text_for_range(start..end).collect::<String>()
+ )
+ .unwrap();
+
+ if start != end {
+ write!(selected_window, "|END|>").unwrap();
+ }
+
+ let end_window = buffer.text_for_range(end..buffer.len()).collect::<String>();
+
+ if let Some(max_token_count) = max_token_count {
+ let selected_tokens = model.count_tokens(&selected_window)?;
+ if selected_tokens > max_token_count {
+ return Err(anyhow!(
+ "selected range is greater than model context window, truncation not possible"
+ ));
+ };
+
+ let mut remaining_tokens = max_token_count - selected_tokens;
+ let start_window_tokens = model.count_tokens(&start_window)?;
+ let end_window_tokens = model.count_tokens(&end_window)?;
+ let outside_tokens = start_window_tokens + end_window_tokens;
+ if outside_tokens > remaining_tokens {
+ let (start_goal_tokens, end_goal_tokens) =
+ if start_window_tokens < end_window_tokens {
+ let start_goal_tokens = (remaining_tokens / 2).min(start_window_tokens);
+ remaining_tokens -= start_goal_tokens;
+ let end_goal_tokens = remaining_tokens.min(end_window_tokens);
+ (start_goal_tokens, end_goal_tokens)
+ } else {
+ let end_goal_tokens = (remaining_tokens / 2).min(end_window_tokens);
+ remaining_tokens -= end_goal_tokens;
+ let start_goal_tokens = remaining_tokens.min(start_window_tokens);
+ (start_goal_tokens, end_goal_tokens)
+ };
+
+ let truncated_start_window =
+ model.truncate_start(&start_window, start_goal_tokens)?;
+ let truncated_end_window = model.truncate(&end_window, end_goal_tokens)?;
+ writeln!(
+ prompt,
+ "{truncated_start_window}{selected_window}{truncated_end_window}"
+ )
+ .unwrap();
+ truncated = true;
+ } else {
+ writeln!(prompt, "{start_window}{selected_window}{end_window}").unwrap();
+ }
+ } else {
+ // If we dont have a selected range, include entire file.
+ writeln!(prompt, "{}", &buffer.text()).unwrap();
+
+ // Dumb truncation strategy
+ if let Some(max_token_count) = max_token_count {
+ if model.count_tokens(&prompt)? > max_token_count {
+ truncated = true;
+ prompt = model.truncate(&prompt, max_token_count)?;
+ }
+ }
+ }
+ }
+
+ let token_count = model.count_tokens(&prompt)?;
+ anyhow::Ok((prompt, token_count, truncated))
+}
+
+pub struct FileContext {}
+
+impl PromptTemplate for FileContext {
+ fn generate(
+ &self,
+ args: &PromptArguments,
+ max_token_length: Option<usize>,
+ ) -> anyhow::Result<(String, usize)> {
+ if let Some(buffer) = &args.buffer {
+ let mut prompt = String::new();
+ // Add Initial Preamble
+ // TODO: Do we want to add the path in here?
+ writeln!(
+ prompt,
+ "The file you are currently working on has the following content:"
+ )
+ .unwrap();
+
+ let language_name = args
+ .language_name
+ .clone()
+ .unwrap_or("".to_string())
+ .to_lowercase();
+
+ let (context, _, truncated) = retrieve_context(
+ buffer,
+ &args.selected_range,
+ args.model.clone(),
+ max_token_length,
+ )?;
+ writeln!(prompt, "```{language_name}\n{context}\n```").unwrap();
+
+ if truncated {
+ writeln!(prompt, "Note the content has been truncated and only represents a portion of the file.").unwrap();
+ }
+
+ if let Some(selected_range) = &args.selected_range {
+ let start = selected_range.start.to_offset(buffer);
+ let end = selected_range.end.to_offset(buffer);
+
+ if start == end {
+ writeln!(prompt, "In particular, the user's cursor is currently on the '<|START|>' span in the above content, with no text selected.").unwrap();
+ } else {
+ writeln!(prompt, "In particular, the user has selected a section of the text between the '<|START|' and '|END|>' spans.").unwrap();
+ }
+ }
+
+ // Really dumb truncation strategy
+ if let Some(max_tokens) = max_token_length {
+ prompt = args.model.truncate(&prompt, max_tokens)?;
+ }
+
+ let token_count = args.model.count_tokens(&prompt)?;
+ anyhow::Ok((prompt, token_count))
+ } else {
+ Err(anyhow!("no buffer provided to retrieve file context from"))
+ }
+ }
+}
@@ -0,0 +1,95 @@
+use crate::templates::base::{PromptArguments, PromptFileType, PromptTemplate};
+use anyhow::anyhow;
+use std::fmt::Write;
+
+pub fn capitalize(s: &str) -> String {
+ let mut c = s.chars();
+ match c.next() {
+ None => String::new(),
+ Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
+ }
+}
+
+pub struct GenerateInlineContent {}
+
+impl PromptTemplate for GenerateInlineContent {
+ fn generate(
+ &self,
+ args: &PromptArguments,
+ max_token_length: Option<usize>,
+ ) -> anyhow::Result<(String, usize)> {
+ let Some(user_prompt) = &args.user_prompt else {
+ return Err(anyhow!("user prompt not provided"));
+ };
+
+ let file_type = args.get_file_type();
+ let content_type = match &file_type {
+ PromptFileType::Code => "code",
+ PromptFileType::Text => "text",
+ };
+
+ let mut prompt = String::new();
+
+ if let Some(selected_range) = &args.selected_range {
+ if selected_range.start == selected_range.end {
+ writeln!(
+ prompt,
+ "Assume the cursor is located where the `<|START|>` span is."
+ )
+ .unwrap();
+ writeln!(
+ prompt,
+ "{} can't be replaced, so assume your answer will be inserted at the cursor.",
+ capitalize(content_type)
+ )
+ .unwrap();
+ writeln!(
+ prompt,
+ "Generate {content_type} based on the users prompt: {user_prompt}",
+ )
+ .unwrap();
+ } else {
+ writeln!(prompt, "Modify the user's selected {content_type} based upon the users prompt: '{user_prompt}'").unwrap();
+ writeln!(prompt, "You must reply with only the adjusted {content_type} (within the '<|START|' and '|END|>' spans) not the entire file.").unwrap();
+ writeln!(prompt, "Double check that you only return code and not the '<|START|' and '|END|'> spans").unwrap();
+ }
+ } else {
+ writeln!(
+ prompt,
+ "Generate {content_type} based on the users prompt: {user_prompt}"
+ )
+ .unwrap();
+ }
+
+ if let Some(language_name) = &args.language_name {
+ writeln!(
+ prompt,
+ "Your answer MUST always and only be valid {}.",
+ language_name
+ )
+ .unwrap();
+ }
+ writeln!(prompt, "Never make remarks about the output.").unwrap();
+ writeln!(
+ prompt,
+ "Do not return anything else, except the generated {content_type}."
+ )
+ .unwrap();
+
+ match file_type {
+ PromptFileType::Code => {
+ // writeln!(prompt, "Always wrap your code in a Markdown block.").unwrap();
+ }
+ _ => {}
+ }
+
+ // Really dumb truncation strategy
+ if let Some(max_tokens) = max_token_length {
+ prompt = args.model.truncate(&prompt, max_tokens)?;
+ }
+
+ let token_count = args.model.count_tokens(&prompt)?;
+
+ anyhow::Ok((prompt, token_count))
+ }
+}
@@ -0,0 +1,5 @@
+pub mod base;
+pub mod file_context;
+pub mod generate;
+pub mod preamble;
+pub mod repository_context;
@@ -0,0 +1,52 @@
+use crate::templates::base::{PromptArguments, PromptFileType, PromptTemplate};
+use std::fmt::Write;
+
+pub struct EngineerPreamble {}
+
+impl PromptTemplate for EngineerPreamble {
+ fn generate(
+ &self,
+ args: &PromptArguments,
+ max_token_length: Option<usize>,
+ ) -> anyhow::Result<(String, usize)> {
+ let mut prompts = Vec::new();
+
+ match args.get_file_type() {
+ PromptFileType::Code => {
+ prompts.push(format!(
+ "You are an expert {}engineer.",
+ args.language_name.clone().unwrap_or("".to_string()) + " "
+ ));
+ }
+ PromptFileType::Text => {
+ prompts.push("You are an expert engineer.".to_string());
+ }
+ }
+
+ if let Some(project_name) = args.project_name.clone() {
+ prompts.push(format!(
+ "You are currently working inside the '{project_name}' project in code editor Zed."
+ ));
+ }
+
+ if let Some(mut remaining_tokens) = max_token_length {
+ let mut prompt = String::new();
+ let mut total_count = 0;
+ for prompt_piece in prompts {
+ let prompt_token_count =
+ args.model.count_tokens(&prompt_piece)? + args.model.count_tokens("\n")?;
+ if remaining_tokens > prompt_token_count {
+ writeln!(prompt, "{prompt_piece}").unwrap();
+ remaining_tokens -= prompt_token_count;
+ total_count += prompt_token_count;
+ }
+ }
+
+ anyhow::Ok((prompt, total_count))
+ } else {
+ let prompt = prompts.join("\n");
+ let token_count = args.model.count_tokens(&prompt)?;
+ anyhow::Ok((prompt, token_count))
+ }
+ }
+}
@@ -0,0 +1,94 @@
+use crate::templates::base::{PromptArguments, PromptTemplate};
+use std::fmt::Write;
+use std::{ops::Range, path::PathBuf};
+
+use gpui::{AsyncAppContext, ModelHandle};
+use language::{Anchor, Buffer};
+
+#[derive(Clone)]
+pub struct PromptCodeSnippet {
+ path: Option<PathBuf>,
+ language_name: Option<String>,
+ content: String,
+}
+
+impl PromptCodeSnippet {
+ pub fn new(buffer: ModelHandle<Buffer>, range: Range<Anchor>, cx: &AsyncAppContext) -> Self {
+ let (content, language_name, file_path) = buffer.read_with(cx, |buffer, _| {
+ let snapshot = buffer.snapshot();
+ let content = snapshot.text_for_range(range.clone()).collect::<String>();
+
+ let language_name = buffer
+ .language()
+ .and_then(|language| Some(language.name().to_string().to_lowercase()));
+
+ let file_path = buffer
+ .file()
+ .and_then(|file| Some(file.path().to_path_buf()));
+
+ (content, language_name, file_path)
+ });
+
+ PromptCodeSnippet {
+ path: file_path,
+ language_name,
+ content,
+ }
+ }
+}
+
+impl ToString for PromptCodeSnippet {
+ fn to_string(&self) -> String {
+ let path = self
+ .path
+ .as_ref()
+ .and_then(|path| Some(path.to_string_lossy().to_string()))
+ .unwrap_or("".to_string());
+ let language_name = self.language_name.clone().unwrap_or("".to_string());
+ let content = self.content.clone();
+
+ format!("The below code snippet may be relevant from file: {path}\n```{language_name}\n{content}\n```")
+ }
+}
+
+pub struct RepositoryContext {}
+
+impl PromptTemplate for RepositoryContext {
+ fn generate(
+ &self,
+ args: &PromptArguments,
+ max_token_length: Option<usize>,
+ ) -> anyhow::Result<(String, usize)> {
+ const MAXIMUM_SNIPPET_TOKEN_COUNT: usize = 500;
+ let template = "You are working inside a large repository, here are a few code snippets that may be useful.";
+ let mut prompt = String::new();
+
+ let mut remaining_tokens = max_token_length.clone();
+ let seperator_token_length = args.model.count_tokens("\n")?;
+ for snippet in &args.snippets {
+ let mut snippet_prompt = template.to_string();
+ let content = snippet.to_string();
+ writeln!(snippet_prompt, "{content}").unwrap();
+
+ let token_count = args.model.count_tokens(&snippet_prompt)?;
+ if token_count <= MAXIMUM_SNIPPET_TOKEN_COUNT {
+ if let Some(tokens_left) = remaining_tokens {
+ if tokens_left >= token_count {
+ writeln!(prompt, "{snippet_prompt}").unwrap();
+ remaining_tokens = if tokens_left >= (token_count + seperator_token_length)
+ {
+ Some(tokens_left - token_count - seperator_token_length)
+ } else {
+ Some(0)
+ };
+ }
+ } else {
+ writeln!(prompt, "{snippet_prompt}").unwrap();
+ }
+ }
+ }
+
+ let total_token_count = args.model.count_tokens(&prompt)?;
+ anyhow::Ok((prompt, total_token_count))
+ }
+}
@@ -17,13 +17,17 @@ fs = { path = "../fs" }
gpui = { path = "../gpui" }
language = { path = "../language" }
menu = { path = "../menu" }
+multi_buffer = { path = "../multi_buffer" }
search = { path = "../search" }
settings = { path = "../settings" }
theme = { path = "../theme" }
util = { path = "../util" }
workspace = { path = "../workspace" }
-uuid.workspace = true
+semantic_index = { path = "../semantic_index" }
+project = { path = "../project" }
+uuid.workspace = true
+log.workspace = true
anyhow.workspace = true
chrono = { version = "0.4", features = ["serde"] }
futures.workspace = true
@@ -36,7 +40,7 @@ schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
smol.workspace = true
-tiktoken-rs = "0.4"
+tiktoken-rs = "0.5"
[dev-dependencies]
editor = { path = "../editor", features = ["test-support"] }
@@ -5,8 +5,11 @@ use crate::{
MessageId, MessageMetadata, MessageStatus, Role, SavedConversation, SavedConversationMetadata,
SavedMessage,
};
-use ai::completion::{
- stream_completion, OpenAICompletionProvider, OpenAIRequest, RequestMessage, OPENAI_API_URL,
+use ai::{
+ completion::{
+ stream_completion, OpenAICompletionProvider, OpenAIRequest, RequestMessage, OPENAI_API_URL,
+ },
+ templates::repository_context::PromptCodeSnippet,
};
use anyhow::{anyhow, Result};
use chrono::{DateTime, Local};
@@ -29,13 +32,15 @@ use gpui::{
},
fonts::HighlightStyle,
geometry::vector::{vec2f, Vector2F},
- platform::{CursorStyle, MouseButton},
+ platform::{CursorStyle, MouseButton, PromptLevel},
Action, AnyElement, AppContext, AsyncAppContext, ClipboardItem, Element, Entity, ModelContext,
- ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
- WindowContext,
+ ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext, ViewHandle,
+ WeakModelHandle, WeakViewHandle, WindowContext,
};
use language::{language_settings::SoftWrap, Buffer, LanguageRegistry, ToOffset as _};
+use project::Project;
use search::BufferSearchBar;
+use semantic_index::{SemanticIndex, SemanticIndexStatus};
use settings::SettingsStore;
use std::{
cell::{Cell, RefCell},
@@ -46,7 +51,7 @@ use std::{
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
- time::Duration,
+ time::{Duration, Instant},
};
use theme::{
components::{action_button::Button, ComponentExt},
@@ -72,6 +77,7 @@ actions!(
ResetKey,
InlineAssist,
ToggleIncludeConversation,
+ ToggleRetrieveContext,
]
);
@@ -108,6 +114,7 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(InlineAssistant::confirm);
cx.add_action(InlineAssistant::cancel);
cx.add_action(InlineAssistant::toggle_include_conversation);
+ cx.add_action(InlineAssistant::toggle_retrieve_context);
cx.add_action(InlineAssistant::move_up);
cx.add_action(InlineAssistant::move_down);
}
@@ -145,6 +152,8 @@ pub struct AssistantPanel {
include_conversation_in_next_inline_assist: bool,
inline_prompt_history: VecDeque<String>,
_watch_saved_conversations: Task<Result<()>>,
+ semantic_index: Option<ModelHandle<SemanticIndex>>,
+ retrieve_context_in_next_inline_assist: bool,
}
impl AssistantPanel {
@@ -191,6 +200,9 @@ impl AssistantPanel {
toolbar.add_item(cx.add_view(|cx| BufferSearchBar::new(cx)), cx);
toolbar
});
+
+ let semantic_index = SemanticIndex::global(cx);
+
let mut this = Self {
workspace: workspace_handle,
active_editor_index: Default::default(),
@@ -215,6 +227,8 @@ impl AssistantPanel {
include_conversation_in_next_inline_assist: false,
inline_prompt_history: Default::default(),
_watch_saved_conversations,
+ semantic_index,
+ retrieve_context_in_next_inline_assist: false,
};
let mut old_dock_position = this.position(cx);
@@ -262,12 +276,19 @@ impl AssistantPanel {
return;
};
+ let project = workspace.project();
+
this.update(cx, |assistant, cx| {
- assistant.new_inline_assist(&active_editor, cx)
+ assistant.new_inline_assist(&active_editor, cx, project)
});
}
- fn new_inline_assist(&mut self, editor: &ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
+ fn new_inline_assist(
+ &mut self,
+ editor: &ViewHandle<Editor>,
+ cx: &mut ViewContext<Self>,
+ project: &ModelHandle<Project>,
+ ) {
let api_key = if let Some(api_key) = self.api_key.borrow().clone() {
api_key
} else {
@@ -275,7 +296,7 @@ impl AssistantPanel {
};
let selection = editor.read(cx).selections.newest_anchor().clone();
- if selection.start.excerpt_id() != selection.end.excerpt_id() {
+ if selection.start.excerpt_id != selection.end.excerpt_id {
return;
}
let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
@@ -312,6 +333,27 @@ impl AssistantPanel {
Codegen::new(editor.read(cx).buffer().clone(), codegen_kind, provider, cx)
});
+ if let Some(semantic_index) = self.semantic_index.clone() {
+ let project = project.clone();
+ cx.spawn(|_, mut cx| async move {
+ let previously_indexed = semantic_index
+ .update(&mut cx, |index, cx| {
+ index.project_previously_indexed(&project, cx)
+ })
+ .await
+ .unwrap_or(false);
+ if previously_indexed {
+ let _ = semantic_index
+ .update(&mut cx, |index, cx| {
+ index.index_project(project.clone(), cx)
+ })
+ .await;
+ }
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
let measurements = Rc::new(Cell::new(BlockMeasurements::default()));
let inline_assistant = cx.add_view(|cx| {
let assistant = InlineAssistant::new(
@@ -322,6 +364,9 @@ impl AssistantPanel {
codegen.clone(),
self.workspace.clone(),
cx,
+ self.retrieve_context_in_next_inline_assist,
+ self.semantic_index.clone(),
+ project.clone(),
);
cx.focus_self();
assistant
@@ -362,6 +407,7 @@ impl AssistantPanel {
editor: editor.downgrade(),
inline_assistant: Some((block_id, inline_assistant.clone())),
codegen: codegen.clone(),
+ project: project.downgrade(),
_subscriptions: vec![
cx.subscribe(&inline_assistant, Self::handle_inline_assistant_event),
cx.subscribe(editor, {
@@ -440,8 +486,15 @@ impl AssistantPanel {
InlineAssistantEvent::Confirmed {
prompt,
include_conversation,
+ retrieve_context,
} => {
- self.confirm_inline_assist(assist_id, prompt, *include_conversation, cx);
+ self.confirm_inline_assist(
+ assist_id,
+ prompt,
+ *include_conversation,
+ cx,
+ *retrieve_context,
+ );
}
InlineAssistantEvent::Canceled => {
self.finish_inline_assist(assist_id, true, cx);
@@ -454,6 +507,9 @@ impl AssistantPanel {
} => {
self.include_conversation_in_next_inline_assist = *include_conversation;
}
+ InlineAssistantEvent::RetrieveContextToggled { retrieve_context } => {
+ self.retrieve_context_in_next_inline_assist = *retrieve_context
+ }
}
}
@@ -532,6 +588,7 @@ impl AssistantPanel {
user_prompt: &str,
include_conversation: bool,
cx: &mut ViewContext<Self>,
+ retrieve_context: bool,
) {
let conversation = if include_conversation {
self.active_editor()
@@ -553,6 +610,20 @@ impl AssistantPanel {
return;
};
+ let project = pending_assist.project.clone();
+
+ let project_name = if let Some(project) = project.upgrade(cx) {
+ Some(
+ project
+ .read(cx)
+ .worktree_root_names(cx)
+ .collect::<Vec<&str>>()
+ .join("/"),
+ )
+ } else {
+ None
+ };
+
self.inline_prompt_history
.retain(|prompt| prompt != user_prompt);
self.inline_prompt_history.push_back(user_prompt.into());
@@ -590,13 +661,70 @@ impl AssistantPanel {
None
};
- let codegen_kind = codegen.read(cx).kind().clone();
+ // Higher Temperature increases the randomness of model outputs.
+ // 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.to_string() != "Markdown".to_string() {
+ 0.5
+ } else {
+ 1.0
+ }
+ } else {
+ 1.0
+ };
+
let user_prompt = user_prompt.to_string();
- let mut messages = Vec::new();
+ let snippets = if retrieve_context {
+ let Some(project) = project.upgrade(cx) else {
+ return;
+ };
+
+ let search_results = if let Some(semantic_index) = self.semantic_index.clone() {
+ let search_results = semantic_index.update(cx, |this, cx| {
+ this.search_project(project, user_prompt.to_string(), 10, vec![], vec![], cx)
+ });
+
+ cx.background()
+ .spawn(async move { search_results.await.unwrap_or_default() })
+ } else {
+ Task::ready(Vec::new())
+ };
+
+ let snippets = cx.spawn(|_, cx| async move {
+ let mut snippets = Vec::new();
+ for result in search_results.await {
+ snippets.push(PromptCodeSnippet::new(result.buffer, result.range, &cx));
+ }
+ snippets
+ });
+ snippets
+ } else {
+ Task::ready(Vec::new())
+ };
+
let mut model = settings::get::<AssistantSettings>(cx)
.default_open_ai_model
.clone();
+ let model_name = model.full_name();
+
+ let prompt = cx.background().spawn(async move {
+ let snippets = snippets.await;
+
+ let language_name = language_name.as_deref();
+ generate_content_prompt(
+ user_prompt,
+ language_name,
+ buffer,
+ range,
+ snippets,
+ model_name,
+ project_name,
+ )
+ });
+
+ let mut messages = Vec::new();
if let Some(conversation) = conversation {
let conversation = conversation.read(cx);
let buffer = conversation.buffer.read(cx);
@@ -608,24 +736,24 @@ impl AssistantPanel {
model = conversation.model.clone();
}
- let prompt = cx.background().spawn(async move {
- let language_name = language_name.as_deref();
- generate_content_prompt(user_prompt, language_name, &buffer, range, codegen_kind)
- });
-
cx.spawn(|_, mut cx| async move {
- let prompt = prompt.await;
+ // I Don't know if we want to return a ? here.
+ let prompt = prompt.await?;
messages.push(RequestMessage {
role: Role::User,
content: prompt,
});
+
let request = OpenAIRequest {
model: model.full_name().into(),
messages,
stream: true,
+ stop: vec!["|END|>".to_string()],
+ temperature,
};
codegen.update(&mut cx, |codegen, cx| codegen.start(request, cx));
+ anyhow::Ok(())
})
.detach();
}
@@ -1514,12 +1642,14 @@ impl Conversation {
Role::Assistant => "assistant".into(),
Role::System => "system".into(),
},
- content: self
- .buffer
- .read(cx)
- .text_for_range(message.offset_range)
- .collect(),
+ content: Some(
+ self.buffer
+ .read(cx)
+ .text_for_range(message.offset_range)
+ .collect(),
+ ),
name: None,
+ function_call: None,
})
})
.collect::<Vec<_>>();
@@ -1613,6 +1743,8 @@ impl Conversation {
.map(|message| message.to_open_ai_message(self.buffer.read(cx)))
.collect(),
stream: true,
+ stop: vec![],
+ temperature: 1.0,
};
let stream = stream_completion(api_key, cx.background().clone(), request);
@@ -1897,6 +2029,8 @@ impl Conversation {
model: self.model.full_name().to_string(),
messages: messages.collect(),
stream: true,
+ stop: vec![],
+ temperature: 1.0,
};
let stream = stream_completion(api_key, cx.background().clone(), request);
@@ -2638,12 +2772,16 @@ enum InlineAssistantEvent {
Confirmed {
prompt: String,
include_conversation: bool,
+ retrieve_context: bool,
},
Canceled,
Dismissed,
IncludeConversationToggled {
include_conversation: bool,
},
+ RetrieveContextToggled {
+ retrieve_context: bool,
+ },
}
struct InlineAssistant {
@@ -2659,6 +2797,11 @@ struct InlineAssistant {
pending_prompt: String,
codegen: ModelHandle<Codegen>,
_subscriptions: Vec<Subscription>,
+ retrieve_context: bool,
+ semantic_index: Option<ModelHandle<SemanticIndex>>,
+ semantic_permissioned: Option<bool>,
+ project: WeakModelHandle<Project>,
+ maintain_rate_limit: Option<Task<()>>,
}
impl Entity for InlineAssistant {
@@ -2675,51 +2818,65 @@ impl View for InlineAssistant {
let theme = theme::current(cx);
Flex::row()
- .with_child(
- Flex::row()
- .with_child(
- Button::action(ToggleIncludeConversation)
- .with_tooltip("Include Conversation", theme.tooltip.clone())
+ .with_children([Flex::row()
+ .with_child(
+ Button::action(ToggleIncludeConversation)
+ .with_tooltip("Include Conversation", theme.tooltip.clone())
+ .with_id(self.id)
+ .with_contents(theme::components::svg::Svg::new("icons/ai.svg"))
+ .toggleable(self.include_conversation)
+ .with_style(theme.assistant.inline.include_conversation.clone())
+ .element()
+ .aligned(),
+ )
+ .with_children(if SemanticIndex::enabled(cx) {
+ Some(
+ Button::action(ToggleRetrieveContext)
+ .with_tooltip("Retrieve Context", theme.tooltip.clone())
.with_id(self.id)
- .with_contents(theme::components::svg::Svg::new("icons/ai.svg"))
- .toggleable(self.include_conversation)
- .with_style(theme.assistant.inline.include_conversation.clone())
+ .with_contents(theme::components::svg::Svg::new(
+ "icons/magnifying_glass.svg",
+ ))
+ .toggleable(self.retrieve_context)
+ .with_style(theme.assistant.inline.retrieve_context.clone())
.element()
.aligned(),
)
- .with_children(if let Some(error) = self.codegen.read(cx).error() {
- Some(
- Svg::new("icons/error.svg")
- .with_color(theme.assistant.error_icon.color)
- .constrained()
- .with_width(theme.assistant.error_icon.width)
- .contained()
- .with_style(theme.assistant.error_icon.container)
- .with_tooltip::<ErrorIcon>(
- self.id,
- error.to_string(),
- None,
- theme.tooltip.clone(),
- cx,
- )
- .aligned(),
- )
- } else {
- None
- })
- .aligned()
- .constrained()
- .dynamically({
- let measurements = self.measurements.clone();
- move |constraint, _, _| {
- let measurements = measurements.get();
- SizeConstraint {
- min: vec2f(measurements.gutter_width, constraint.min.y()),
- max: vec2f(measurements.gutter_width, constraint.max.y()),
- }
+ } else {
+ None
+ })
+ .with_children(if let Some(error) = self.codegen.read(cx).error() {
+ Some(
+ Svg::new("icons/error.svg")
+ .with_color(theme.assistant.error_icon.color)
+ .constrained()
+ .with_width(theme.assistant.error_icon.width)
+ .contained()
+ .with_style(theme.assistant.error_icon.container)
+ .with_tooltip::<ErrorIcon>(
+ self.id,
+ error.to_string(),
+ None,
+ theme.tooltip.clone(),
+ cx,
+ )
+ .aligned(),
+ )
+ } else {
+ None
+ })
+ .aligned()
+ .constrained()
+ .dynamically({
+ let measurements = self.measurements.clone();
+ move |constraint, _, _| {
+ let measurements = measurements.get();
+ SizeConstraint {
+ min: vec2f(measurements.gutter_width, constraint.min.y()),
+ max: vec2f(measurements.gutter_width, constraint.max.y()),
}
- }),
- )
+ }
+ })])
.with_child(Empty::new().constrained().dynamically({
let measurements = self.measurements.clone();
move |constraint, _, _| {
@@ -2742,6 +2899,16 @@ impl View for InlineAssistant {
.left()
.flex(1., true),
)
+ .with_children(if self.retrieve_context {
+ Some(
+ Flex::row()
+ .with_children(self.retrieve_context_status(cx))
+ .flex(1., true)
+ .aligned(),
+ )
+ } else {
+ None
+ })
.contained()
.with_style(theme.assistant.inline.container)
.into_any()
@@ -2767,6 +2934,9 @@ impl InlineAssistant {
codegen: ModelHandle<Codegen>,
workspace: WeakViewHandle<Workspace>,
cx: &mut ViewContext<Self>,
+ retrieve_context: bool,
+ semantic_index: Option<ModelHandle<SemanticIndex>>,
+ project: ModelHandle<Project>,
) -> Self {
let prompt_editor = cx.add_view(|cx| {
let mut editor = Editor::single_line(
@@ -2780,11 +2950,16 @@ impl InlineAssistant {
editor.set_placeholder_text(placeholder, cx);
editor
});
- let subscriptions = vec![
+ let mut subscriptions = vec![
cx.observe(&codegen, Self::handle_codegen_changed),
cx.subscribe(&prompt_editor, Self::handle_prompt_editor_events),
];
- Self {
+
+ if let Some(semantic_index) = semantic_index.clone() {
+ subscriptions.push(cx.observe(&semantic_index, Self::semantic_index_changed));
+ }
+
+ let assistant = Self {
id,
prompt_editor,
workspace,
@@ -2797,7 +2972,33 @@ impl InlineAssistant {
pending_prompt: String::new(),
codegen,
_subscriptions: subscriptions,
+ retrieve_context,
+ semantic_permissioned: None,
+ semantic_index,
+ project: project.downgrade(),
+ maintain_rate_limit: None,
+ };
+
+ assistant.index_project(cx).log_err();
+
+ assistant
+ }
+
+ fn semantic_permissioned(&self, cx: &mut ViewContext<Self>) -> Task<Result<bool>> {
+ if let Some(value) = self.semantic_permissioned {
+ return Task::ready(Ok(value));
}
+
+ let Some(project) = self.project.upgrade(cx) else {
+ return Task::ready(Err(anyhow!("project was dropped")));
+ };
+
+ self.semantic_index
+ .as_ref()
+ .map(|semantic| {
+ semantic.update(cx, |this, cx| this.project_previously_indexed(&project, cx))
+ })
+ .unwrap_or(Task::ready(Ok(false)))
}
fn handle_prompt_editor_events(
@@ -2812,6 +3013,37 @@ impl InlineAssistant {
}
}
+ fn semantic_index_changed(
+ &mut self,
+ semantic_index: ModelHandle<SemanticIndex>,
+ cx: &mut ViewContext<Self>,
+ ) {
+ let Some(project) = self.project.upgrade(cx) else {
+ return;
+ };
+
+ let status = semantic_index.read(cx).status(&project);
+ match status {
+ SemanticIndexStatus::Indexing {
+ rate_limit_expiry: Some(_),
+ ..
+ } => {
+ if self.maintain_rate_limit.is_none() {
+ self.maintain_rate_limit = Some(cx.spawn(|this, mut cx| async move {
+ loop {
+ cx.background().timer(Duration::from_secs(1)).await;
+ this.update(&mut cx, |_, cx| cx.notify()).log_err();
+ }
+ }));
+ }
+ return;
+ }
+ _ => {
+ self.maintain_rate_limit = None;
+ }
+ }
+ }
+
fn handle_codegen_changed(&mut self, _: ModelHandle<Codegen>, cx: &mut ViewContext<Self>) {
let is_read_only = !self.codegen.read(cx).idle();
self.prompt_editor.update(cx, |editor, cx| {
@@ -2861,12 +3093,241 @@ impl InlineAssistant {
cx.emit(InlineAssistantEvent::Confirmed {
prompt,
include_conversation: self.include_conversation,
+ retrieve_context: self.retrieve_context,
});
self.confirmed = true;
cx.notify();
}
}
+ fn toggle_retrieve_context(&mut self, _: &ToggleRetrieveContext, cx: &mut ViewContext<Self>) {
+ let semantic_permissioned = self.semantic_permissioned(cx);
+
+ let Some(project) = self.project.upgrade(cx) else {
+ return;
+ };
+
+ let project_name = project
+ .read(cx)
+ .worktree_root_names(cx)
+ .collect::<Vec<&str>>()
+ .join("/");
+ let is_plural = project_name.chars().filter(|letter| *letter == '/').count() > 0;
+ let prompt_text = format!("Would you like to index the '{}' project{} for context retrieval? This requires sending code to the OpenAI API", project_name,
+ if is_plural {
+ "s"
+ } else {""});
+
+ cx.spawn(|this, mut cx| async move {
+ // If Necessary prompt user
+ if !semantic_permissioned.await.unwrap_or(false) {
+ let mut answer = this.update(&mut cx, |_, cx| {
+ cx.prompt(
+ PromptLevel::Info,
+ prompt_text.as_str(),
+ &["Continue", "Cancel"],
+ )
+ })?;
+
+ if answer.next().await == Some(0) {
+ this.update(&mut cx, |this, _| {
+ this.semantic_permissioned = Some(true);
+ })?;
+ } else {
+ return anyhow::Ok(());
+ }
+ }
+
+ // If permissioned, update context appropriately
+ this.update(&mut cx, |this, cx| {
+ this.retrieve_context = !this.retrieve_context;
+
+ cx.emit(InlineAssistantEvent::RetrieveContextToggled {
+ retrieve_context: this.retrieve_context,
+ });
+
+ if this.retrieve_context {
+ this.index_project(cx).log_err();
+ }
+
+ cx.notify();
+ })?;
+
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
+ fn index_project(&self, cx: &mut ViewContext<Self>) -> anyhow::Result<()> {
+ let Some(project) = self.project.upgrade(cx) else {
+ return Err(anyhow!("project was dropped!"));
+ };
+
+ let semantic_permissioned = self.semantic_permissioned(cx);
+ if let Some(semantic_index) = SemanticIndex::global(cx) {
+ cx.spawn(|_, mut cx| async move {
+ // This has to be updated to accomodate for semantic_permissions
+ if semantic_permissioned.await.unwrap_or(false) {
+ semantic_index
+ .update(&mut cx, |index, cx| index.index_project(project, cx))
+ .await
+ } else {
+ Err(anyhow!("project is not permissioned for semantic indexing"))
+ }
+ })
+ .detach_and_log_err(cx);
+ }
+
+ anyhow::Ok(())
+ }
+
+ fn retrieve_context_status(
+ &self,
+ cx: &mut ViewContext<Self>,
+ ) -> Option<AnyElement<InlineAssistant>> {
+ enum ContextStatusIcon {}
+
+ let Some(project) = self.project.upgrade(cx) else {
+ return None;
+ };
+
+ if let Some(semantic_index) = SemanticIndex::global(cx) {
+ let status = semantic_index.update(cx, |index, _| index.status(&project));
+ let theme = theme::current(cx);
+ match status {
+ SemanticIndexStatus::NotAuthenticated {} => Some(
+ Svg::new("icons/error.svg")
+ .with_color(theme.assistant.error_icon.color)
+ .constrained()
+ .with_width(theme.assistant.error_icon.width)
+ .contained()
+ .with_style(theme.assistant.error_icon.container)
+ .with_tooltip::<ContextStatusIcon>(
+ self.id,
+ "Not Authenticated. Please ensure you have a valid 'OPENAI_API_KEY' in your environment variables.",
+ None,
+ theme.tooltip.clone(),
+ cx,
+ )
+ .aligned()
+ .into_any(),
+ ),
+ SemanticIndexStatus::NotIndexed {} => Some(
+ Svg::new("icons/error.svg")
+ .with_color(theme.assistant.inline.context_status.error_icon.color)
+ .constrained()
+ .with_width(theme.assistant.inline.context_status.error_icon.width)
+ .contained()
+ .with_style(theme.assistant.inline.context_status.error_icon.container)
+ .with_tooltip::<ContextStatusIcon>(
+ self.id,
+ "Not Indexed",
+ None,
+ theme.tooltip.clone(),
+ cx,
+ )
+ .aligned()
+ .into_any(),
+ ),
+ SemanticIndexStatus::Indexing {
+ remaining_files,
+ rate_limit_expiry,
+ } => {
+
+ let mut status_text = if remaining_files == 0 {
+ "Indexing...".to_string()
+ } else {
+ format!("Remaining files to index: {remaining_files}")
+ };
+
+ if let Some(rate_limit_expiry) = rate_limit_expiry {
+ let remaining_seconds = rate_limit_expiry.duration_since(Instant::now());
+ if remaining_seconds > Duration::from_secs(0) && remaining_files > 0 {
+ write!(
+ status_text,
+ " (rate limit expires in {}s)",
+ remaining_seconds.as_secs()
+ )
+ .unwrap();
+ }
+ }
+ Some(
+ Svg::new("icons/update.svg")
+ .with_color(theme.assistant.inline.context_status.in_progress_icon.color)
+ .constrained()
+ .with_width(theme.assistant.inline.context_status.in_progress_icon.width)
+ .contained()
+ .with_style(theme.assistant.inline.context_status.in_progress_icon.container)
+ .with_tooltip::<ContextStatusIcon>(
+ self.id,
+ status_text,
+ None,
+ theme.tooltip.clone(),
+ cx,
+ )
+ .aligned()
+ .into_any(),
+ )
+ }
+ SemanticIndexStatus::Indexed {} => Some(
+ Svg::new("icons/check.svg")
+ .with_color(theme.assistant.inline.context_status.complete_icon.color)
+ .constrained()
+ .with_width(theme.assistant.inline.context_status.complete_icon.width)
+ .contained()
+ .with_style(theme.assistant.inline.context_status.complete_icon.container)
+ .with_tooltip::<ContextStatusIcon>(
+ self.id,
+ "Index up to date",
+ None,
+ theme.tooltip.clone(),
+ cx,
+ )
+ .aligned()
+ .into_any(),
+ ),
+ }
+ } else {
+ None
+ }
+ }
+
+ // fn retrieve_context_status(&self, cx: &mut ViewContext<Self>) -> String {
+ // let project = self.project.clone();
+ // if let Some(semantic_index) = self.semantic_index.clone() {
+ // let status = semantic_index.update(cx, |index, cx| index.status(&project));
+ // return match status {
+ // // This theoretically shouldnt be a valid code path
+ // // As the inline assistant cant be launched without an API key
+ // // We keep it here for safety
+ // semantic_index::SemanticIndexStatus::NotAuthenticated => {
+ // "Not Authenticated!\nPlease ensure you have an `OPENAI_API_KEY` in your environment variables.".to_string()
+ // }
+ // semantic_index::SemanticIndexStatus::Indexed => {
+ // "Indexing Complete!".to_string()
+ // }
+ // semantic_index::SemanticIndexStatus::Indexing { remaining_files, rate_limit_expiry } => {
+
+ // let mut status = format!("Remaining files to index for Context Retrieval: {remaining_files}");
+
+ // if let Some(rate_limit_expiry) = rate_limit_expiry {
+ // let remaining_seconds =
+ // rate_limit_expiry.duration_since(Instant::now());
+ // if remaining_seconds > Duration::from_secs(0) {
+ // write!(status, " (rate limit resets in {}s)", remaining_seconds.as_secs()).unwrap();
+ // }
+ // }
+ // status
+ // }
+ // semantic_index::SemanticIndexStatus::NotIndexed => {
+ // "Not Indexed for Context Retrieval".to_string()
+ // }
+ // };
+ // }
+
+ // "".to_string()
+ // }
+
fn toggle_include_conversation(
&mut self,
_: &ToggleIncludeConversation,
@@ -2929,6 +3390,7 @@ struct PendingInlineAssist {
inline_assistant: Option<(BlockId, ViewHandle<InlineAssistant>)>,
codegen: ModelHandle<Codegen>,
_subscriptions: Vec<Subscription>,
+ project: WeakModelHandle<Project>,
}
fn merge_ranges(ranges: &mut Vec<Range<Anchor>>, buffer: &MultiBufferSnapshot) {
@@ -1,10 +1,11 @@
use crate::streaming_diff::{Hunk, StreamingDiff};
use ai::completion::{CompletionProvider, OpenAIRequest};
use anyhow::Result;
-use editor::{multi_buffer, Anchor, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint};
+use editor::{Anchor, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint};
use futures::{channel::mpsc, SinkExt, Stream, StreamExt};
use gpui::{Entity, ModelContext, ModelHandle, Task};
use language::{Rope, TransactionId};
+use multi_buffer;
use std::{cmp, future, ops::Range, sync::Arc};
pub enum Event {
@@ -1,8 +1,13 @@
-use crate::codegen::CodegenKind;
+use ai::models::{LanguageModel, OpenAILanguageModel};
+use ai::templates::base::{PromptArguments, PromptChain, PromptPriority, PromptTemplate};
+use ai::templates::file_context::FileContext;
+use ai::templates::generate::GenerateInlineContent;
+use ai::templates::preamble::EngineerPreamble;
+use ai::templates::repository_context::{PromptCodeSnippet, RepositoryContext};
use language::{BufferSnapshot, OffsetRangeExt, ToOffset};
use std::cmp::{self, Reverse};
-use std::fmt::Write;
use std::ops::Range;
+use std::sync::Arc;
#[allow(dead_code)]
fn summarize(buffer: &BufferSnapshot, selected_range: Range<impl ToOffset>) -> String {
@@ -118,86 +123,50 @@ fn summarize(buffer: &BufferSnapshot, selected_range: Range<impl ToOffset>) -> S
pub fn generate_content_prompt(
user_prompt: String,
language_name: Option<&str>,
- buffer: &BufferSnapshot,
- range: Range<impl ToOffset>,
- kind: CodegenKind,
-) -> String {
- let range = range.to_offset(buffer);
- let mut prompt = String::new();
-
- // General Preamble
- if let Some(language_name) = language_name {
- writeln!(prompt, "You're an expert {language_name} engineer.\n").unwrap();
+ buffer: BufferSnapshot,
+ range: Range<usize>,
+ search_results: Vec<PromptCodeSnippet>,
+ model: &str,
+ project_name: Option<String>,
+) -> anyhow::Result<String> {
+ // Using new Prompt Templates
+ let openai_model: Arc<dyn LanguageModel> = Arc::new(OpenAILanguageModel::load(model));
+ let lang_name = if let Some(language_name) = language_name {
+ Some(language_name.to_string())
} else {
- writeln!(prompt, "You're an expert engineer.\n").unwrap();
- }
-
- let mut content = String::new();
- content.extend(buffer.text_for_range(0..range.start));
- if range.start == range.end {
- content.push_str("<|START|>");
- } else {
- content.push_str("<|START|");
- }
- content.extend(buffer.text_for_range(range.clone()));
- if range.start != range.end {
- content.push_str("|END|>");
- }
- content.extend(buffer.text_for_range(range.end..buffer.len()));
-
- writeln!(
- prompt,
- "The file you are currently working on has the following content:"
- )
- .unwrap();
- if let Some(language_name) = language_name {
- let language_name = language_name.to_lowercase();
- writeln!(prompt, "```{language_name}\n{content}\n```").unwrap();
- } else {
- writeln!(prompt, "```\n{content}\n```").unwrap();
- }
-
- match kind {
- CodegenKind::Generate { position: _ } => {
- writeln!(prompt, "In particular, the user's cursor is current on the '<|START|>' span in the above outline, with no text selected.").unwrap();
- writeln!(
- prompt,
- "Assume the cursor is located where the `<|START|` marker is."
- )
- .unwrap();
- writeln!(
- prompt,
- "Text can't be replaced, so assume your answer will be inserted at the cursor."
- )
- .unwrap();
- writeln!(
- prompt,
- "Generate text based on the users prompt: {user_prompt}"
- )
- .unwrap();
- }
- CodegenKind::Transform { range: _ } => {
- writeln!(prompt, "In particular, the user has selected a section of the text between the '<|START|' and '|END|>' spans.").unwrap();
- writeln!(
- prompt,
- "Modify the users code selected text based upon the users prompt: {user_prompt}"
- )
- .unwrap();
- writeln!(
- prompt,
- "You MUST reply with only the adjusted code (within the '<|START|' and '|END|>' spans), not the entire file."
- )
- .unwrap();
- }
- }
-
- if let Some(language_name) = language_name {
- writeln!(prompt, "Your answer MUST always be valid {language_name}").unwrap();
- }
- writeln!(prompt, "Always wrap your response in a Markdown codeblock").unwrap();
- writeln!(prompt, "Never make remarks about the output.").unwrap();
-
- prompt
+ None
+ };
+
+ let args = PromptArguments {
+ model: openai_model,
+ language_name: lang_name.clone(),
+ project_name,
+ snippets: search_results.clone(),
+ reserved_tokens: 1000,
+ buffer: Some(buffer),
+ selected_range: Some(range),
+ user_prompt: Some(user_prompt.clone()),
+ };
+
+ let templates: Vec<(PromptPriority, Box<dyn PromptTemplate>)> = vec![
+ (PromptPriority::Mandatory, Box::new(EngineerPreamble {})),
+ (
+ PromptPriority::Ordered { order: 1 },
+ Box::new(RepositoryContext {}),
+ ),
+ (
+ PromptPriority::Ordered { order: 0 },
+ Box::new(FileContext {}),
+ ),
+ (
+ PromptPriority::Mandatory,
+ Box::new(GenerateInlineContent {}),
+ ),
+ ];
+ let chain = PromptChain::new(args, templates);
+ let (prompt, _) = chain.generate(true)?;
+
+ anyhow::Ok(prompt)
}
#[cfg(test)]
@@ -10,7 +10,7 @@ use client::{
ZED_ALWAYS_ACTIVE,
};
use collections::HashSet;
-use futures::{future::Shared, FutureExt};
+use futures::{channel::oneshot, future::Shared, Future, FutureExt};
use gpui::{
AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Subscription, Task,
WeakModelHandle,
@@ -37,10 +37,42 @@ pub struct IncomingCall {
pub initial_project: Option<proto::ParticipantProject>,
}
+pub struct OneAtATime {
+ cancel: Option<oneshot::Sender<()>>,
+}
+
+impl OneAtATime {
+ /// spawn a task in the given context.
+ /// if another task is spawned before that resolves, or if the OneAtATime itself is dropped, the first task will be cancelled and return Ok(None)
+ /// otherwise you'll see the result of the task.
+ fn spawn<F, Fut, R>(&mut self, cx: &mut AppContext, f: F) -> Task<Result<Option<R>>>
+ where
+ F: 'static + FnOnce(AsyncAppContext) -> Fut,
+ Fut: Future<Output = Result<R>>,
+ R: 'static,
+ {
+ let (tx, rx) = oneshot::channel();
+ self.cancel.replace(tx);
+ cx.spawn(|cx| async move {
+ futures::select_biased! {
+ _ = rx.fuse() => Ok(None),
+ result = f(cx).fuse() => result.map(Some),
+ }
+ })
+ }
+
+ fn running(&self) -> bool {
+ self.cancel
+ .as_ref()
+ .is_some_and(|cancel| !cancel.is_canceled())
+ }
+}
+
/// Singleton global maintaining the user's participation in a room across workspaces.
pub struct ActiveCall {
room: Option<(ModelHandle<Room>, Vec<Subscription>)>,
pending_room_creation: Option<Shared<Task<Result<ModelHandle<Room>, Arc<anyhow::Error>>>>>,
+ _join_debouncer: OneAtATime,
location: Option<WeakModelHandle<Project>>,
pending_invites: HashSet<u64>,
incoming_call: (
@@ -69,6 +101,7 @@ impl ActiveCall {
pending_invites: Default::default(),
incoming_call: watch::channel(),
+ _join_debouncer: OneAtATime { cancel: None },
_subscriptions: vec![
client.add_request_handler(cx.handle(), Self::handle_incoming_call),
client.add_message_handler(cx.handle(), Self::handle_call_canceled),
@@ -143,6 +176,10 @@ impl ActiveCall {
}
cx.notify();
+ if self._join_debouncer.running() {
+ return Task::ready(Ok(()));
+ }
+
let room = if let Some(room) = self.room().cloned() {
Some(Task::ready(Ok(room)).shared())
} else {
@@ -259,11 +296,20 @@ impl ActiveCall {
return Task::ready(Err(anyhow!("no incoming call")));
};
- let join = Room::join(&call, self.client.clone(), self.user_store.clone(), cx);
+ if self.pending_room_creation.is_some() {
+ return Task::ready(Ok(()));
+ }
+
+ let room_id = call.room_id.clone();
+ let client = self.client.clone();
+ let user_store = self.user_store.clone();
+ let join = self
+ ._join_debouncer
+ .spawn(cx, move |cx| Room::join(room_id, client, user_store, cx));
cx.spawn(|this, mut cx| async move {
let room = join.await?;
- this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx))
+ this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))
.await?;
this.update(&mut cx, |this, cx| {
this.report_call_event("accept incoming", cx)
@@ -290,20 +336,28 @@ impl ActiveCall {
&mut self,
channel_id: u64,
cx: &mut ModelContext<Self>,
- ) -> Task<Result<ModelHandle<Room>>> {
+ ) -> Task<Result<Option<ModelHandle<Room>>>> {
if let Some(room) = self.room().cloned() {
if room.read(cx).channel_id() == Some(channel_id) {
- return Task::ready(Ok(room));
+ return Task::ready(Ok(Some(room)));
} else {
room.update(cx, |room, cx| room.clear_state(cx));
}
}
- let join = Room::join_channel(channel_id, self.client.clone(), self.user_store.clone(), cx);
+ if self.pending_room_creation.is_some() {
+ return Task::ready(Ok(None));
+ }
- cx.spawn(|this, mut cx| async move {
+ let client = self.client.clone();
+ let user_store = self.user_store.clone();
+ let join = self._join_debouncer.spawn(cx, move |cx| async move {
+ Room::join_channel(channel_id, client, user_store, cx).await
+ });
+
+ cx.spawn(move |this, mut cx| async move {
let room = join.await?;
- this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx))
+ this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))
.await?;
this.update(&mut cx, |this, cx| {
this.report_call_event("join channel", cx)
@@ -457,3 +511,40 @@ pub fn report_call_event_for_channel(
};
telemetry.report_clickhouse_event(event, telemetry_settings);
}
+
+#[cfg(test)]
+mod test {
+ use gpui::TestAppContext;
+
+ use crate::OneAtATime;
+
+ #[gpui::test]
+ async fn test_one_at_a_time(cx: &mut TestAppContext) {
+ let mut one_at_a_time = OneAtATime { cancel: None };
+
+ assert_eq!(
+ cx.update(|cx| one_at_a_time.spawn(cx, |_| async { Ok(1) }))
+ .await
+ .unwrap(),
+ Some(1)
+ );
+
+ let (a, b) = cx.update(|cx| {
+ (
+ one_at_a_time.spawn(cx, |_| async {
+ assert!(false);
+ Ok(2)
+ }),
+ one_at_a_time.spawn(cx, |_| async { Ok(3) }),
+ )
+ });
+
+ assert_eq!(a.await.unwrap(), None);
+ assert_eq!(b.await.unwrap(), Some(3));
+
+ let promise = cx.update(|cx| one_at_a_time.spawn(cx, |_| async { Ok(4) }));
+ drop(one_at_a_time);
+
+ assert_eq!(promise.await.unwrap(), None);
+ }
+}
@@ -1,7 +1,6 @@
use crate::{
call_settings::CallSettings,
participant::{LocalParticipant, ParticipantLocation, RemoteParticipant, RemoteVideoTrack},
- IncomingCall,
};
use anyhow::{anyhow, Result};
use audio::{Audio, Sound};
@@ -55,7 +54,7 @@ pub enum Event {
pub struct Room {
id: u64,
- channel_id: Option<u64>,
+ pub channel_id: Option<u64>,
live_kit: Option<LiveKitRoom>,
status: RoomStatus,
shared_projects: HashSet<WeakModelHandle<Project>>,
@@ -122,6 +121,10 @@ impl Room {
}
}
+ pub fn can_publish(&self) -> bool {
+ self.live_kit.as_ref().is_some_and(|room| room.can_publish)
+ }
+
fn new(
id: u64,
channel_id: Option<u64>,
@@ -181,20 +184,23 @@ impl Room {
});
let connect = room.connect(&connection_info.server_url, &connection_info.token);
- cx.spawn(|this, mut cx| async move {
- connect.await?;
+ if connection_info.can_publish {
+ cx.spawn(|this, mut cx| async move {
+ connect.await?;
- if !cx.read(Self::mute_on_join) {
- this.update(&mut cx, |this, cx| this.share_microphone(cx))
- .await?;
- }
+ if !cx.read(Self::mute_on_join) {
+ this.update(&mut cx, |this, cx| this.share_microphone(cx))
+ .await?;
+ }
- anyhow::Ok(())
- })
- .detach_and_log_err(cx);
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
Some(LiveKitRoom {
room,
+ can_publish: connection_info.can_publish,
screen_track: LocalTrack::None,
microphone_track: LocalTrack::None,
next_publish_id: 0,
@@ -284,37 +290,32 @@ impl Room {
})
}
- pub(crate) fn join_channel(
+ pub(crate) async fn join_channel(
channel_id: u64,
client: Arc<Client>,
user_store: ModelHandle<UserStore>,
- cx: &mut AppContext,
- ) -> Task<Result<ModelHandle<Self>>> {
- cx.spawn(|cx| async move {
- Self::from_join_response(
- client.request(proto::JoinChannel { channel_id }).await?,
- client,
- user_store,
- cx,
- )
- })
+ cx: AsyncAppContext,
+ ) -> Result<ModelHandle<Self>> {
+ Self::from_join_response(
+ client.request(proto::JoinChannel { channel_id }).await?,
+ client,
+ user_store,
+ cx,
+ )
}
- pub(crate) fn join(
- call: &IncomingCall,
+ pub(crate) async fn join(
+ room_id: u64,
client: Arc<Client>,
user_store: ModelHandle<UserStore>,
- cx: &mut AppContext,
- ) -> Task<Result<ModelHandle<Self>>> {
- let id = call.room_id;
- cx.spawn(|cx| async move {
- Self::from_join_response(
- client.request(proto::JoinRoom { id }).await?,
- client,
- user_store,
- cx,
- )
- })
+ cx: AsyncAppContext,
+ ) -> Result<ModelHandle<Self>> {
+ Self::from_join_response(
+ client.request(proto::JoinRoom { id: room_id }).await?,
+ client,
+ user_store,
+ cx,
+ )
}
pub fn mute_on_join(cx: &AppContext) -> bool {
@@ -1498,6 +1499,7 @@ struct LiveKitRoom {
deafened: bool,
speaking: bool,
next_publish_id: usize,
+ can_publish: bool,
_maintain_room: Task<()>,
_maintain_tracks: [Task<()>; 2],
}
@@ -7,10 +7,11 @@ use gpui::{AppContext, ModelHandle};
use std::sync::Arc;
pub use channel_buffer::{ChannelBuffer, ChannelBufferEvent, ACKNOWLEDGE_DEBOUNCE_INTERVAL};
-pub use channel_chat::{ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId};
-pub use channel_store::{
- Channel, ChannelData, ChannelEvent, ChannelId, ChannelMembership, ChannelPath, ChannelStore,
+pub use channel_chat::{
+ mentions_to_proto, ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId,
+ MessageParams,
};
+pub use channel_store::{Channel, ChannelEvent, ChannelId, ChannelMembership, ChannelStore};
#[cfg(test)]
mod channel_store_tests;
@@ -1,4 +1,4 @@
-use crate::Channel;
+use crate::{Channel, ChannelId, ChannelStore};
use anyhow::Result;
use client::{Client, Collaborator, UserStore};
use collections::HashMap;
@@ -19,10 +19,11 @@ pub(crate) fn init(client: &Arc<Client>) {
}
pub struct ChannelBuffer {
- pub(crate) channel: Arc<Channel>,
+ pub channel_id: ChannelId,
connected: bool,
collaborators: HashMap<PeerId, Collaborator>,
user_store: ModelHandle<UserStore>,
+ channel_store: ModelHandle<ChannelStore>,
buffer: ModelHandle<language::Buffer>,
buffer_epoch: u64,
client: Arc<Client>,
@@ -34,6 +35,7 @@ pub enum ChannelBufferEvent {
CollaboratorsChanged,
Disconnected,
BufferEdited,
+ ChannelChanged,
}
impl Entity for ChannelBuffer {
@@ -46,7 +48,7 @@ impl Entity for ChannelBuffer {
}
self.client
.send(proto::LeaveChannelBuffer {
- channel_id: self.channel.id,
+ channel_id: self.channel_id,
})
.log_err();
}
@@ -58,6 +60,7 @@ impl ChannelBuffer {
channel: Arc<Channel>,
client: Arc<Client>,
user_store: ModelHandle<UserStore>,
+ channel_store: ModelHandle<ChannelStore>,
mut cx: AsyncAppContext,
) -> Result<ModelHandle<Self>> {
let response = client
@@ -90,9 +93,10 @@ impl ChannelBuffer {
connected: true,
collaborators: Default::default(),
acknowledge_task: None,
- channel,
+ channel_id: channel.id,
subscription: Some(subscription.set_model(&cx.handle(), &mut cx.to_async())),
user_store,
+ channel_store,
};
this.replace_collaborators(response.collaborators, cx);
this
@@ -179,7 +183,7 @@ impl ChannelBuffer {
let operation = language::proto::serialize_operation(operation);
self.client
.send(proto::UpdateChannelBuffer {
- channel_id: self.channel.id,
+ channel_id: self.channel_id,
operations: vec![operation],
})
.log_err();
@@ -223,12 +227,15 @@ impl ChannelBuffer {
&self.collaborators
}
- pub fn channel(&self) -> Arc<Channel> {
- self.channel.clone()
+ pub fn channel(&self, cx: &AppContext) -> Option<Arc<Channel>> {
+ self.channel_store
+ .read(cx)
+ .channel_for_id(self.channel_id)
+ .cloned()
}
pub(crate) fn disconnect(&mut self, cx: &mut ModelContext<Self>) {
- log::info!("channel buffer {} disconnected", self.channel.id);
+ log::info!("channel buffer {} disconnected", self.channel_id);
if self.connected {
self.connected = false;
self.subscription.take();
@@ -237,6 +244,11 @@ impl ChannelBuffer {
}
}
+ pub(crate) fn channel_changed(&mut self, cx: &mut ModelContext<Self>) {
+ cx.emit(ChannelBufferEvent::ChannelChanged);
+ cx.notify()
+ }
+
pub fn is_connected(&self) -> bool {
self.connected
}
@@ -3,19 +3,25 @@ use anyhow::{anyhow, Result};
use client::{
proto,
user::{User, UserStore},
- Client, Subscription, TypedEnvelope,
+ Client, Subscription, TypedEnvelope, UserId,
};
use futures::lock::Mutex;
use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task};
use rand::prelude::*;
-use std::{collections::HashSet, mem, ops::Range, sync::Arc};
+use std::{
+ collections::HashSet,
+ mem,
+ ops::{ControlFlow, Range},
+ sync::Arc,
+};
use sum_tree::{Bias, SumTree};
use time::OffsetDateTime;
use util::{post_inc, ResultExt as _, TryFutureExt};
pub struct ChannelChat {
- channel: Arc<Channel>,
+ pub channel_id: ChannelId,
messages: SumTree<ChannelMessage>,
+ acknowledged_message_ids: HashSet<u64>,
channel_store: ModelHandle<ChannelStore>,
loaded_all_messages: bool,
last_acknowledged_id: Option<u64>,
@@ -27,6 +33,12 @@ pub struct ChannelChat {
_subscription: Subscription,
}
+#[derive(Debug, PartialEq, Eq)]
+pub struct MessageParams {
+ pub text: String,
+ pub mentions: Vec<(Range<usize>, UserId)>,
+}
+
#[derive(Clone, Debug)]
pub struct ChannelMessage {
pub id: ChannelMessageId,
@@ -34,6 +46,7 @@ pub struct ChannelMessage {
pub timestamp: OffsetDateTime,
pub sender: Arc<User>,
pub nonce: u128,
+ pub mentions: Vec<(Range<usize>, UserId)>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
@@ -74,7 +87,7 @@ impl Entity for ChannelChat {
fn release(&mut self, _: &mut AppContext) {
self.rpc
.send(proto::LeaveChannelChat {
- channel_id: self.channel.id,
+ channel_id: self.channel_id,
})
.log_err();
}
@@ -99,12 +112,13 @@ impl ChannelChat {
Ok(cx.add_model(|cx| {
let mut this = Self {
- channel,
+ channel_id: channel.id,
user_store,
channel_store,
rpc: client,
outgoing_messages_lock: Default::default(),
messages: Default::default(),
+ acknowledged_message_ids: Default::default(),
loaded_all_messages,
next_pending_message_id: 0,
last_acknowledged_id: None,
@@ -116,16 +130,23 @@ impl ChannelChat {
}))
}
- pub fn channel(&self) -> &Arc<Channel> {
- &self.channel
+ pub fn channel(&self, cx: &AppContext) -> Option<Arc<Channel>> {
+ self.channel_store
+ .read(cx)
+ .channel_for_id(self.channel_id)
+ .cloned()
+ }
+
+ pub fn client(&self) -> &Arc<Client> {
+ &self.rpc
}
pub fn send_message(
&mut self,
- body: String,
+ message: MessageParams,
cx: &mut ModelContext<Self>,
- ) -> Result<Task<Result<()>>> {
- if body.is_empty() {
+ ) -> Result<Task<Result<u64>>> {
+ if message.text.is_empty() {
Err(anyhow!("message body can't be empty"))?;
}
@@ -135,16 +156,17 @@ impl ChannelChat {
.current_user()
.ok_or_else(|| anyhow!("current_user is not present"))?;
- let channel_id = self.channel.id;
+ let channel_id = self.channel_id;
let pending_id = ChannelMessageId::Pending(post_inc(&mut self.next_pending_message_id));
let nonce = self.rng.gen();
self.insert_messages(
SumTree::from_item(
ChannelMessage {
id: pending_id,
- body: body.clone(),
+ body: message.text.clone(),
sender: current_user,
timestamp: OffsetDateTime::now_utc(),
+ mentions: message.mentions.clone(),
nonce,
},
&(),
@@ -158,27 +180,25 @@ impl ChannelChat {
let outgoing_message_guard = outgoing_messages_lock.lock().await;
let request = rpc.request(proto::SendChannelMessage {
channel_id,
- body,
+ body: message.text,
nonce: Some(nonce.into()),
+ mentions: mentions_to_proto(&message.mentions),
});
let response = request.await?;
drop(outgoing_message_guard);
- let message = ChannelMessage::from_proto(
- response.message.ok_or_else(|| anyhow!("invalid message"))?,
- &user_store,
- &mut cx,
- )
- .await?;
+ let response = response.message.ok_or_else(|| anyhow!("invalid message"))?;
+ let id = response.id;
+ let message = ChannelMessage::from_proto(response, &user_store, &mut cx).await?;
this.update(&mut cx, |this, cx| {
this.insert_messages(SumTree::from_item(message, &()), cx);
- Ok(())
+ Ok(id)
})
}))
}
pub fn remove_message(&mut self, id: u64, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
let response = self.rpc.request(proto::RemoveChannelMessage {
- channel_id: self.channel.id,
+ channel_id: self.channel_id,
message_id: id,
});
cx.spawn(|this, mut cx| async move {
@@ -191,41 +211,76 @@ impl ChannelChat {
})
}
- pub fn load_more_messages(&mut self, cx: &mut ModelContext<Self>) -> bool {
- if !self.loaded_all_messages {
- let rpc = self.rpc.clone();
- let user_store = self.user_store.clone();
- let channel_id = self.channel.id;
- if let Some(before_message_id) =
- self.messages.first().and_then(|message| match message.id {
- ChannelMessageId::Saved(id) => Some(id),
- ChannelMessageId::Pending(_) => None,
- })
- {
- cx.spawn(|this, mut cx| {
- async move {
- let response = rpc
- .request(proto::GetChannelMessages {
- channel_id,
- before_message_id,
- })
- .await?;
- let loaded_all_messages = response.done;
- let messages =
- messages_from_proto(response.messages, &user_store, &mut cx).await?;
- this.update(&mut cx, |this, cx| {
- this.loaded_all_messages = loaded_all_messages;
- this.insert_messages(messages, cx);
- });
- anyhow::Ok(())
+ pub fn load_more_messages(&mut self, cx: &mut ModelContext<Self>) -> Option<Task<Option<()>>> {
+ if self.loaded_all_messages {
+ return None;
+ }
+
+ let rpc = self.rpc.clone();
+ let user_store = self.user_store.clone();
+ let channel_id = self.channel_id;
+ let before_message_id = self.first_loaded_message_id()?;
+ Some(cx.spawn(|this, mut cx| {
+ async move {
+ let response = rpc
+ .request(proto::GetChannelMessages {
+ channel_id,
+ before_message_id,
+ })
+ .await?;
+ let loaded_all_messages = response.done;
+ let messages = messages_from_proto(response.messages, &user_store, &mut cx).await?;
+ this.update(&mut cx, |this, cx| {
+ this.loaded_all_messages = loaded_all_messages;
+ this.insert_messages(messages, cx);
+ });
+ anyhow::Ok(())
+ }
+ .log_err()
+ }))
+ }
+
+ pub fn first_loaded_message_id(&mut self) -> Option<u64> {
+ self.messages.first().and_then(|message| match message.id {
+ ChannelMessageId::Saved(id) => Some(id),
+ ChannelMessageId::Pending(_) => None,
+ })
+ }
+
+ /// Load all of the chat messages since a certain message id.
+ ///
+ /// For now, we always maintain a suffix of the channel's messages.
+ pub async fn load_history_since_message(
+ chat: ModelHandle<Self>,
+ message_id: u64,
+ mut cx: AsyncAppContext,
+ ) -> Option<usize> {
+ loop {
+ let step = chat.update(&mut cx, |chat, cx| {
+ if let Some(first_id) = chat.first_loaded_message_id() {
+ if first_id <= message_id {
+ let mut cursor = chat.messages.cursor::<(ChannelMessageId, Count)>();
+ let message_id = ChannelMessageId::Saved(message_id);
+ cursor.seek(&message_id, Bias::Left, &());
+ return ControlFlow::Break(
+ if cursor
+ .item()
+ .map_or(false, |message| message.id == message_id)
+ {
+ Some(cursor.start().1 .0)
+ } else {
+ None
+ },
+ );
}
- .log_err()
- })
- .detach();
- return true;
+ }
+ ControlFlow::Continue(chat.load_more_messages(cx))
+ });
+ match step {
+ ControlFlow::Break(ix) => return ix,
+ ControlFlow::Continue(task) => task?.await?,
}
}
- false
}
pub fn acknowledge_last_message(&mut self, cx: &mut ModelContext<Self>) {
@@ -236,13 +291,13 @@ impl ChannelChat {
{
self.rpc
.send(proto::AckChannelMessage {
- channel_id: self.channel.id,
+ channel_id: self.channel_id,
message_id: latest_message_id,
})
.ok();
self.last_acknowledged_id = Some(latest_message_id);
self.channel_store.update(cx, |store, cx| {
- store.acknowledge_message_id(self.channel.id, latest_message_id, cx);
+ store.acknowledge_message_id(self.channel_id, latest_message_id, cx);
});
}
}
@@ -251,7 +306,7 @@ impl ChannelChat {
pub fn rejoin(&mut self, cx: &mut ModelContext<Self>) {
let user_store = self.user_store.clone();
let rpc = self.rpc.clone();
- let channel_id = self.channel.id;
+ let channel_id = self.channel_id;
cx.spawn(|this, mut cx| {
async move {
let response = rpc.request(proto::JoinChannelChat { channel_id }).await?;
@@ -284,6 +339,7 @@ impl ChannelChat {
let request = rpc.request(proto::SendChannelMessage {
channel_id,
body: pending_message.body,
+ mentions: mentions_to_proto(&pending_message.mentions),
nonce: Some(pending_message.nonce.into()),
});
let response = request.await?;
@@ -319,6 +375,17 @@ impl ChannelChat {
cursor.item().unwrap()
}
+ pub fn acknowledge_message(&mut self, id: u64) {
+ if self.acknowledged_message_ids.insert(id) {
+ self.rpc
+ .send(proto::AckChannelMessage {
+ channel_id: self.channel_id,
+ message_id: id,
+ })
+ .ok();
+ }
+ }
+
pub fn messages_in_range(&self, range: Range<usize>) -> impl Iterator<Item = &ChannelMessage> {
let mut cursor = self.messages.cursor::<Count>();
cursor.seek(&Count(range.start), Bias::Right, &());
@@ -348,7 +415,7 @@ impl ChannelChat {
this.update(&mut cx, |this, cx| {
this.insert_messages(SumTree::from_item(message, &()), cx);
cx.emit(ChannelChatEvent::NewMessage {
- channel_id: this.channel.id,
+ channel_id: this.channel_id,
message_id,
})
});
@@ -451,22 +518,7 @@ async fn messages_from_proto(
user_store: &ModelHandle<UserStore>,
cx: &mut AsyncAppContext,
) -> Result<SumTree<ChannelMessage>> {
- let unique_user_ids = proto_messages
- .iter()
- .map(|m| m.sender_id)
- .collect::<HashSet<_>>()
- .into_iter()
- .collect();
- user_store
- .update(cx, |user_store, cx| {
- user_store.get_users(unique_user_ids, cx)
- })
- .await?;
-
- let mut messages = Vec::with_capacity(proto_messages.len());
- for message in proto_messages {
- messages.push(ChannelMessage::from_proto(message, user_store, cx).await?);
- }
+ let messages = ChannelMessage::from_proto_vec(proto_messages, user_store, cx).await?;
let mut result = SumTree::new();
result.extend(messages, &());
Ok(result)
@@ -486,6 +538,14 @@ impl ChannelMessage {
Ok(ChannelMessage {
id: ChannelMessageId::Saved(message.id),
body: message.body,
+ mentions: message
+ .mentions
+ .into_iter()
+ .filter_map(|mention| {
+ let range = mention.range?;
+ Some((range.start as usize..range.end as usize, mention.user_id))
+ })
+ .collect(),
timestamp: OffsetDateTime::from_unix_timestamp(message.timestamp as i64)?,
sender,
nonce: message
@@ -498,6 +558,43 @@ impl ChannelMessage {
pub fn is_pending(&self) -> bool {
matches!(self.id, ChannelMessageId::Pending(_))
}
+
+ pub async fn from_proto_vec(
+ proto_messages: Vec<proto::ChannelMessage>,
+ user_store: &ModelHandle<UserStore>,
+ cx: &mut AsyncAppContext,
+ ) -> Result<Vec<Self>> {
+ let unique_user_ids = proto_messages
+ .iter()
+ .map(|m| m.sender_id)
+ .collect::<HashSet<_>>()
+ .into_iter()
+ .collect();
+ user_store
+ .update(cx, |user_store, cx| {
+ user_store.get_users(unique_user_ids, cx)
+ })
+ .await?;
+
+ let mut messages = Vec::with_capacity(proto_messages.len());
+ for message in proto_messages {
+ messages.push(ChannelMessage::from_proto(message, user_store, cx).await?);
+ }
+ Ok(messages)
+ }
+}
+
+pub fn mentions_to_proto(mentions: &[(Range<usize>, UserId)]) -> Vec<proto::ChatMention> {
+ mentions
+ .iter()
+ .map(|(range, user_id)| proto::ChatMention {
+ range: Some(proto::Range {
+ start: range.start as u64,
+ end: range.end as u64,
+ }),
+ user_id: *user_id as u64,
+ })
+ .collect()
}
impl sum_tree::Item for ChannelMessage {
@@ -538,3 +635,12 @@ impl<'a> sum_tree::Dimension<'a, ChannelMessageSummary> for Count {
self.0 += summary.count;
}
}
+
+impl<'a> From<&'a str> for MessageParams {
+ fn from(value: &'a str) -> Self {
+ Self {
+ text: value.into(),
+ mentions: Vec::new(),
+ }
+ }
+}
@@ -1,6 +1,6 @@
mod channel_index;
-use crate::{channel_buffer::ChannelBuffer, channel_chat::ChannelChat};
+use crate::{channel_buffer::ChannelBuffer, channel_chat::ChannelChat, ChannelMessage};
use anyhow::{anyhow, Result};
use channel_index::ChannelIndex;
use client::{Client, Subscription, User, UserId, UserStore};
@@ -9,11 +9,10 @@ use db::RELEASE_CHANNEL;
use futures::{channel::mpsc, future::Shared, Future, FutureExt, StreamExt};
use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task, WeakModelHandle};
use rpc::{
- proto::{self, ChannelEdge, ChannelPermission},
+ proto::{self, ChannelVisibility},
TypedEnvelope,
};
-use serde_derive::{Deserialize, Serialize};
-use std::{borrow::Cow, hash::Hash, mem, ops::Deref, sync::Arc, time::Duration};
+use std::{mem, sync::Arc, time::Duration};
use util::ResultExt;
pub fn init(client: &Arc<Client>, user_store: ModelHandle<UserStore>, cx: &mut AppContext) {
@@ -27,10 +26,9 @@ pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30);
pub type ChannelId = u64;
pub struct ChannelStore {
- channel_index: ChannelIndex,
+ pub channel_index: ChannelIndex,
channel_invitations: Vec<Arc<Channel>>,
channel_participants: HashMap<ChannelId, Vec<Arc<User>>>,
- channels_with_admin_privileges: HashSet<ChannelId>,
outgoing_invites: HashSet<(ChannelId, UserId)>,
update_channels_tx: mpsc::UnboundedSender<proto::UpdateChannels>,
opened_buffers: HashMap<ChannelId, OpenedModelHandle<ChannelBuffer>>,
@@ -43,14 +41,15 @@ pub struct ChannelStore {
_update_channels: Task<()>,
}
-pub type ChannelData = (Channel, ChannelPath);
-
#[derive(Clone, Debug, PartialEq)]
pub struct Channel {
pub id: ChannelId,
pub name: String,
+ pub visibility: proto::ChannelVisibility,
+ pub role: proto::ChannelRole,
pub unseen_note_version: Option<(u64, clock::Global)>,
pub unseen_message_id: Option<u64>,
+ pub parent_path: Vec<u64>,
}
impl Channel {
@@ -71,15 +70,41 @@ impl Channel {
slug.trim_matches(|c| c == '-').to_string()
}
-}
-#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)]
-pub struct ChannelPath(Arc<[ChannelId]>);
+ pub fn can_edit_notes(&self) -> bool {
+ self.role == proto::ChannelRole::Member || self.role == proto::ChannelRole::Admin
+ }
+}
pub struct ChannelMembership {
pub user: Arc<User>,
pub kind: proto::channel_member::Kind,
- pub admin: bool,
+ pub role: proto::ChannelRole,
+}
+impl ChannelMembership {
+ pub fn sort_key(&self) -> MembershipSortKey {
+ MembershipSortKey {
+ role_order: match self.role {
+ proto::ChannelRole::Admin => 0,
+ proto::ChannelRole::Member => 1,
+ proto::ChannelRole::Banned => 2,
+ proto::ChannelRole::Guest => 3,
+ },
+ kind_order: match self.kind {
+ proto::channel_member::Kind::Member => 0,
+ proto::channel_member::Kind::AncestorMember => 1,
+ proto::channel_member::Kind::Invitee => 2,
+ },
+ username_order: self.user.github_login.as_str(),
+ }
+ }
+}
+
+#[derive(PartialOrd, Ord, PartialEq, Eq)]
+pub struct MembershipSortKey<'a> {
+ role_order: u8,
+ kind_order: u8,
+ username_order: &'a str,
}
pub enum ChannelEvent {
@@ -127,9 +152,6 @@ impl ChannelStore {
this.update(&mut cx, |this, cx| this.handle_disconnect(true, cx));
}
}
- if status.is_connected() {
- } else {
- }
}
Some(())
});
@@ -138,7 +160,6 @@ impl ChannelStore {
channel_invitations: Vec::default(),
channel_index: ChannelIndex::default(),
channel_participants: Default::default(),
- channels_with_admin_privileges: Default::default(),
outgoing_invites: Default::default(),
opened_buffers: Default::default(),
opened_chats: Default::default(),
@@ -167,16 +188,6 @@ impl ChannelStore {
self.client.clone()
}
- pub fn has_children(&self, channel_id: ChannelId) -> bool {
- self.channel_index.iter().any(|path| {
- if let Some(ix) = path.iter().position(|id| *id == channel_id) {
- path.len() > ix + 1
- } else {
- false
- }
- })
- }
-
/// Returns the number of unique channels in the store
pub fn channel_count(&self) -> usize {
self.channel_index.by_id().len()
@@ -196,26 +207,31 @@ impl ChannelStore {
}
/// Iterate over all entries in the channel DAG
- pub fn channel_dag_entries(&self) -> impl '_ + Iterator<Item = (usize, &Arc<Channel>)> {
- self.channel_index.iter().map(move |path| {
- let id = path.last().unwrap();
- let channel = self.channel_for_id(*id).unwrap();
- (path.len() - 1, channel)
- })
+ pub fn ordered_channels(&self) -> impl '_ + Iterator<Item = (usize, &Arc<Channel>)> {
+ self.channel_index
+ .ordered_channels()
+ .iter()
+ .filter_map(move |id| {
+ let channel = self.channel_index.by_id().get(id)?;
+ Some((channel.parent_path.len(), channel))
+ })
}
- pub fn channel_dag_entry_at(&self, ix: usize) -> Option<(&Arc<Channel>, &ChannelPath)> {
- let path = self.channel_index.get(ix)?;
- let id = path.last().unwrap();
- let channel = self.channel_for_id(*id).unwrap();
-
- Some((channel, path))
+ pub fn channel_at_index(&self, ix: usize) -> Option<&Arc<Channel>> {
+ let channel_id = self.channel_index.ordered_channels().get(ix)?;
+ self.channel_index.by_id().get(channel_id)
}
pub fn channel_at(&self, ix: usize) -> Option<&Arc<Channel>> {
self.channel_index.by_id().values().nth(ix)
}
+ pub fn has_channel_invitation(&self, channel_id: ChannelId) -> bool {
+ self.channel_invitations
+ .iter()
+ .any(|channel| channel.id == channel_id)
+ }
+
pub fn channel_invitations(&self) -> &[Arc<Channel>] {
&self.channel_invitations
}
@@ -240,14 +256,42 @@ impl ChannelStore {
) -> Task<Result<ModelHandle<ChannelBuffer>>> {
let client = self.client.clone();
let user_store = self.user_store.clone();
+ let channel_store = cx.handle();
self.open_channel_resource(
channel_id,
|this| &mut this.opened_buffers,
- |channel, cx| ChannelBuffer::new(channel, client, user_store, cx),
+ |channel, cx| ChannelBuffer::new(channel, client, user_store, channel_store, cx),
cx,
)
}
+ pub fn fetch_channel_messages(
+ &self,
+ message_ids: Vec<u64>,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<Vec<ChannelMessage>>> {
+ let request = if message_ids.is_empty() {
+ None
+ } else {
+ Some(
+ self.client
+ .request(proto::GetChannelMessagesById { message_ids }),
+ )
+ };
+ cx.spawn_weak(|this, mut cx| async move {
+ if let Some(request) = request {
+ let response = request.await?;
+ let this = this
+ .upgrade(&cx)
+ .ok_or_else(|| anyhow!("channel store dropped"))?;
+ let user_store = this.read_with(&cx, |this, _| this.user_store.clone());
+ ChannelMessage::from_proto_vec(response.messages, &user_store, &mut cx).await
+ } else {
+ Ok(Vec::new())
+ }
+ })
+ }
+
pub fn has_channel_buffer_changed(&self, channel_id: ChannelId) -> Option<bool> {
self.channel_index
.by_id()
@@ -393,16 +437,11 @@ impl ChannelStore {
.spawn(async move { task.await.map_err(|error| anyhow!("{}", error)) })
}
- pub fn is_user_admin(&self, channel_id: ChannelId) -> bool {
- self.channel_index.iter().any(|path| {
- if let Some(ix) = path.iter().position(|id| *id == channel_id) {
- path[..=ix]
- .iter()
- .any(|id| self.channels_with_admin_privileges.contains(id))
- } else {
- false
- }
- })
+ pub fn is_channel_admin(&self, channel_id: ChannelId) -> bool {
+ let Some(channel) = self.channel_for_id(channel_id) else {
+ return false;
+ };
+ channel.role == proto::ChannelRole::Admin
}
pub fn channel_participants(&self, channel_id: ChannelId) -> &[Arc<User>] {
@@ -429,24 +468,19 @@ impl ChannelStore {
.ok_or_else(|| anyhow!("missing channel in response"))?;
let channel_id = channel.id;
- let parent_edge = if let Some(parent_id) = parent_id {
- vec![ChannelEdge {
- channel_id: channel.id,
- parent_id,
- }]
- } else {
- vec![]
- };
+ // let parent_edge = if let Some(parent_id) = parent_id {
+ // vec![ChannelEdge {
+ // channel_id: channel.id,
+ // parent_id,
+ // }]
+ // } else {
+ // vec![]
+ // };
this.update(&mut cx, |this, cx| {
let task = this.update_channels(
proto::UpdateChannels {
channels: vec![channel],
- insert_edge: parent_edge,
- channel_permissions: vec![ChannelPermission {
- channel_id,
- is_admin: true,
- }],
..Default::default()
},
cx,
@@ -464,52 +498,34 @@ impl ChannelStore {
})
}
- pub fn link_channel(
- &mut self,
- channel_id: ChannelId,
- to: ChannelId,
- cx: &mut ModelContext<Self>,
- ) -> Task<Result<()>> {
- let client = self.client.clone();
- cx.spawn(|_, _| async move {
- let _ = client
- .request(proto::LinkChannel { channel_id, to })
- .await?;
-
- Ok(())
- })
- }
-
- pub fn unlink_channel(
+ pub fn move_channel(
&mut self,
channel_id: ChannelId,
- from: ChannelId,
+ to: Option<ChannelId>,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
let client = self.client.clone();
cx.spawn(|_, _| async move {
let _ = client
- .request(proto::UnlinkChannel { channel_id, from })
+ .request(proto::MoveChannel { channel_id, to })
.await?;
Ok(())
})
}
- pub fn move_channel(
+ pub fn set_channel_visibility(
&mut self,
channel_id: ChannelId,
- from: ChannelId,
- to: ChannelId,
+ visibility: ChannelVisibility,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
let client = self.client.clone();
cx.spawn(|_, _| async move {
let _ = client
- .request(proto::MoveChannel {
+ .request(proto::SetChannelVisibility {
channel_id,
- from,
- to,
+ visibility: visibility.into(),
})
.await?;
@@ -521,7 +537,7 @@ impl ChannelStore {
&mut self,
channel_id: ChannelId,
user_id: UserId,
- admin: bool,
+ role: proto::ChannelRole,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
if !self.outgoing_invites.insert((channel_id, user_id)) {
@@ -535,7 +551,7 @@ impl ChannelStore {
.request(proto::InviteChannelMember {
channel_id,
user_id,
- admin,
+ role: role.into(),
})
.await;
@@ -579,11 +595,11 @@ impl ChannelStore {
})
}
- pub fn set_member_admin(
+ pub fn set_member_role(
&mut self,
channel_id: ChannelId,
user_id: UserId,
- admin: bool,
+ role: proto::ChannelRole,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
if !self.outgoing_invites.insert((channel_id, user_id)) {
@@ -594,10 +610,10 @@ impl ChannelStore {
let client = self.client.clone();
cx.spawn(|this, mut cx| async move {
let result = client
- .request(proto::SetChannelMemberAdmin {
+ .request(proto::SetChannelMemberRole {
channel_id,
user_id,
- admin,
+ role: role.into(),
})
.await;
@@ -649,14 +665,15 @@ impl ChannelStore {
&mut self,
channel_id: ChannelId,
accept: bool,
- ) -> impl Future<Output = Result<()>> {
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
let client = self.client.clone();
- async move {
+ cx.background().spawn(async move {
client
.request(proto::RespondToChannelInvite { channel_id, accept })
.await?;
Ok(())
- }
+ })
}
pub fn get_channel_member_details(
@@ -685,8 +702,8 @@ impl ChannelStore {
.filter_map(|(user, member)| {
Some(ChannelMembership {
user,
- admin: member.admin,
- kind: proto::channel_member::Kind::from_i32(member.kind)?,
+ role: member.role(),
+ kind: member.kind(),
})
})
.collect())
@@ -724,6 +741,11 @@ impl ChannelStore {
}
fn handle_connect(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+ self.channel_index.clear();
+ self.channel_invitations.clear();
+ self.channel_participants.clear();
+ self.channel_index.clear();
+ self.outgoing_invites.clear();
self.disconnect_channel_buffers_task.take();
for chat in self.opened_chats.values() {
@@ -743,7 +765,7 @@ impl ChannelStore {
let channel_buffer = buffer.read(cx);
let buffer = channel_buffer.buffer().read(cx);
buffer_versions.push(proto::ChannelBufferVersion {
- channel_id: channel_buffer.channel().id,
+ channel_id: channel_buffer.channel_id,
epoch: channel_buffer.epoch(),
version: language::proto::serialize_version(&buffer.version()),
});
@@ -770,13 +792,13 @@ impl ChannelStore {
};
channel_buffer.update(cx, |channel_buffer, cx| {
- let channel_id = channel_buffer.channel().id;
+ let channel_id = channel_buffer.channel_id;
if let Some(remote_buffer) = response
.buffers
.iter_mut()
.find(|buffer| buffer.channel_id == channel_id)
{
- let channel_id = channel_buffer.channel().id;
+ let channel_id = channel_buffer.channel_id;
let remote_version =
language::proto::deserialize_version(&remote_buffer.version);
@@ -833,12 +855,6 @@ impl ChannelStore {
}
fn handle_disconnect(&mut self, wait_for_reconnect: bool, cx: &mut ModelContext<Self>) {
- self.channel_index.clear();
- self.channel_invitations.clear();
- self.channel_participants.clear();
- self.channels_with_admin_privileges.clear();
- self.channel_index.clear();
- self.outgoing_invites.clear();
cx.notify();
self.disconnect_channel_buffers_task.get_or_insert_with(|| {
@@ -881,9 +897,12 @@ impl ChannelStore {
ix,
Arc::new(Channel {
id: channel.id,
+ visibility: channel.visibility(),
+ role: channel.role(),
name: channel.name,
unseen_note_version: None,
unseen_message_id: None,
+ parent_path: channel.parent_path,
}),
),
}
@@ -891,8 +910,6 @@ impl ChannelStore {
let channels_changed = !payload.channels.is_empty()
|| !payload.delete_channels.is_empty()
- || !payload.insert_edge.is_empty()
- || !payload.delete_edge.is_empty()
|| !payload.unseen_channel_messages.is_empty()
|| !payload.unseen_channel_buffer_changes.is_empty();
@@ -900,12 +917,17 @@ impl ChannelStore {
if !payload.delete_channels.is_empty() {
self.channel_index.delete_channels(&payload.delete_channels);
self.channel_participants
- .retain(|channel_id, _| !payload.delete_channels.contains(channel_id));
- self.channels_with_admin_privileges
- .retain(|channel_id| !payload.delete_channels.contains(channel_id));
+ .retain(|channel_id, _| !&payload.delete_channels.contains(channel_id));
for channel_id in &payload.delete_channels {
let channel_id = *channel_id;
+ if payload
+ .channels
+ .iter()
+ .any(|channel| channel.id == channel_id)
+ {
+ continue;
+ }
if let Some(OpenedModelHandle::Open(buffer)) =
self.opened_buffers.remove(&channel_id)
{
@@ -918,7 +940,16 @@ impl ChannelStore {
let mut index = self.channel_index.bulk_insert();
for channel in payload.channels {
- index.insert(channel)
+ let id = channel.id;
+ let channel_changed = index.insert(channel);
+
+ if channel_changed {
+ if let Some(OpenedModelHandle::Open(buffer)) = self.opened_buffers.get(&id) {
+ if let Some(buffer) = buffer.upgrade(cx) {
+ buffer.update(cx, ChannelBuffer::channel_changed);
+ }
+ }
+ }
}
for unseen_buffer_change in payload.unseen_channel_buffer_changes {
@@ -936,24 +967,6 @@ impl ChannelStore {
unseen_channel_message.message_id,
);
}
-
- for edge in payload.insert_edge {
- index.insert_edge(edge.channel_id, edge.parent_id);
- }
-
- for edge in payload.delete_edge {
- index.delete_edge(edge.parent_id, edge.channel_id);
- }
- }
-
- for permission in payload.channel_permissions {
- if permission.is_admin {
- self.channels_with_admin_privileges
- .insert(permission.channel_id);
- } else {
- self.channels_with_admin_privileges
- .remove(&permission.channel_id);
- }
}
cx.notify();
@@ -1002,44 +1015,3 @@ impl ChannelStore {
}))
}
}
-
-impl Deref for ChannelPath {
- type Target = [ChannelId];
-
- fn deref(&self) -> &Self::Target {
- &self.0
- }
-}
-
-impl ChannelPath {
- pub fn new(path: Arc<[ChannelId]>) -> Self {
- debug_assert!(path.len() >= 1);
- Self(path)
- }
-
- pub fn parent_id(&self) -> Option<ChannelId> {
- self.0.len().checked_sub(2).map(|i| self.0[i])
- }
-
- pub fn channel_id(&self) -> ChannelId {
- self.0[self.0.len() - 1]
- }
-}
-
-impl From<ChannelPath> for Cow<'static, ChannelPath> {
- fn from(value: ChannelPath) -> Self {
- Cow::Owned(value)
- }
-}
-
-impl<'a> From<&'a ChannelPath> for Cow<'a, ChannelPath> {
- fn from(value: &'a ChannelPath) -> Self {
- Cow::Borrowed(value)
- }
-}
-
-impl Default for ChannelPath {
- fn default() -> Self {
- ChannelPath(Arc::from([]))
- }
-}
@@ -1,14 +1,11 @@
-use std::{ops::Deref, sync::Arc};
-
use crate::{Channel, ChannelId};
use collections::BTreeMap;
use rpc::proto;
-
-use super::ChannelPath;
+use std::sync::Arc;
#[derive(Default, Debug)]
pub struct ChannelIndex {
- paths: Vec<ChannelPath>,
+ channels_ordered: Vec<ChannelId>,
channels_by_id: BTreeMap<ChannelId, Arc<Channel>>,
}
@@ -17,8 +14,12 @@ impl ChannelIndex {
&self.channels_by_id
}
+ pub fn ordered_channels(&self) -> &[ChannelId] {
+ &self.channels_ordered
+ }
+
pub fn clear(&mut self) {
- self.paths.clear();
+ self.channels_ordered.clear();
self.channels_by_id.clear();
}
@@ -26,15 +27,13 @@ impl ChannelIndex {
pub fn delete_channels(&mut self, channels: &[ChannelId]) {
self.channels_by_id
.retain(|channel_id, _| !channels.contains(channel_id));
- self.paths.retain(|path| {
- path.iter()
- .all(|channel_id| self.channels_by_id.contains_key(channel_id))
- });
+ self.channels_ordered
+ .retain(|channel_id| !channels.contains(channel_id));
}
pub fn bulk_insert(&mut self) -> ChannelPathsInsertGuard {
ChannelPathsInsertGuard {
- paths: &mut self.paths,
+ channels_ordered: &mut self.channels_ordered,
channels_by_id: &mut self.channels_by_id,
}
}
@@ -77,42 +76,15 @@ impl ChannelIndex {
}
}
-impl Deref for ChannelIndex {
- type Target = [ChannelPath];
-
- fn deref(&self) -> &Self::Target {
- &self.paths
- }
-}
-
/// A guard for ensuring that the paths index maintains its sort and uniqueness
/// invariants after a series of insertions
#[derive(Debug)]
pub struct ChannelPathsInsertGuard<'a> {
- paths: &'a mut Vec<ChannelPath>,
+ channels_ordered: &'a mut Vec<ChannelId>,
channels_by_id: &'a mut BTreeMap<ChannelId, Arc<Channel>>,
}
impl<'a> ChannelPathsInsertGuard<'a> {
- /// Remove the given edge from this index. This will not remove the channel.
- /// If this operation would result in a dangling edge, re-insert it.
- pub fn delete_edge(&mut self, parent_id: ChannelId, channel_id: ChannelId) {
- self.paths.retain(|path| {
- !path
- .windows(2)
- .any(|window| window == [parent_id, channel_id])
- });
-
- // Ensure that there is at least one channel path in the index
- if !self
- .paths
- .iter()
- .any(|path| path.iter().any(|id| id == &channel_id))
- {
- self.insert_root(channel_id);
- }
- }
-
pub fn note_changed(&mut self, channel_id: ChannelId, epoch: u64, version: &clock::Global) {
insert_note_changed(&mut self.channels_by_id, channel_id, epoch, &version);
}
@@ -121,91 +93,65 @@ impl<'a> ChannelPathsInsertGuard<'a> {
insert_new_message(&mut self.channels_by_id, channel_id, message_id)
}
- pub fn insert(&mut self, channel_proto: proto::Channel) {
+ pub fn insert(&mut self, channel_proto: proto::Channel) -> bool {
+ let mut ret = false;
if let Some(existing_channel) = self.channels_by_id.get_mut(&channel_proto.id) {
- Arc::make_mut(existing_channel).name = channel_proto.name;
+ let existing_channel = Arc::make_mut(existing_channel);
+
+ ret = existing_channel.visibility != channel_proto.visibility()
+ || existing_channel.role != channel_proto.role()
+ || existing_channel.name != channel_proto.name;
+
+ existing_channel.visibility = channel_proto.visibility();
+ existing_channel.role = channel_proto.role();
+ existing_channel.name = channel_proto.name;
} else {
self.channels_by_id.insert(
channel_proto.id,
Arc::new(Channel {
id: channel_proto.id,
+ visibility: channel_proto.visibility(),
+ role: channel_proto.role(),
name: channel_proto.name,
unseen_note_version: None,
unseen_message_id: None,
+ parent_path: channel_proto.parent_path,
}),
);
self.insert_root(channel_proto.id);
}
- }
-
- pub fn insert_edge(&mut self, channel_id: ChannelId, parent_id: ChannelId) {
- let mut parents = Vec::new();
- let mut descendants = Vec::new();
- let mut ixs_to_remove = Vec::new();
-
- for (ix, path) in self.paths.iter().enumerate() {
- if path
- .windows(2)
- .any(|window| window[0] == parent_id && window[1] == channel_id)
- {
- // We already have this edge in the index
- return;
- }
- if path.ends_with(&[parent_id]) {
- parents.push(path);
- } else if let Some(position) = path.iter().position(|id| id == &channel_id) {
- if position == 0 {
- ixs_to_remove.push(ix);
- }
- descendants.push(path.split_at(position).1);
- }
- }
-
- let mut new_paths = Vec::new();
- for parent in parents.iter() {
- if descendants.is_empty() {
- let mut new_path = Vec::with_capacity(parent.len() + 1);
- new_path.extend_from_slice(parent);
- new_path.push(channel_id);
- new_paths.push(ChannelPath::new(new_path.into()));
- } else {
- for descendant in descendants.iter() {
- let mut new_path = Vec::with_capacity(parent.len() + descendant.len());
- new_path.extend_from_slice(parent);
- new_path.extend_from_slice(descendant);
- new_paths.push(ChannelPath::new(new_path.into()));
- }
- }
- }
-
- for ix in ixs_to_remove.into_iter().rev() {
- self.paths.swap_remove(ix);
- }
- self.paths.extend(new_paths)
+ ret
}
fn insert_root(&mut self, channel_id: ChannelId) {
- self.paths.push(ChannelPath::new(Arc::from([channel_id])));
+ self.channels_ordered.push(channel_id);
}
}
impl<'a> Drop for ChannelPathsInsertGuard<'a> {
fn drop(&mut self) {
- self.paths.sort_by(|a, b| {
- let a = channel_path_sorting_key(a, &self.channels_by_id);
- let b = channel_path_sorting_key(b, &self.channels_by_id);
+ self.channels_ordered.sort_by(|a, b| {
+ let a = channel_path_sorting_key(*a, &self.channels_by_id);
+ let b = channel_path_sorting_key(*b, &self.channels_by_id);
a.cmp(b)
});
- self.paths.dedup();
+ self.channels_ordered.dedup();
}
}
fn channel_path_sorting_key<'a>(
- path: &'a [ChannelId],
+ id: ChannelId,
channels_by_id: &'a BTreeMap<ChannelId, Arc<Channel>>,
-) -> impl 'a + Iterator<Item = Option<&'a str>> {
- path.iter()
- .map(|id| Some(channels_by_id.get(id)?.name.as_str()))
+) -> impl Iterator<Item = &str> {
+ let (parent_path, name) = channels_by_id
+ .get(&id)
+ .map_or((&[] as &[_], None), |channel| {
+ (channel.parent_path.as_slice(), Some(channel.name.as_str()))
+ });
+ parent_path
+ .iter()
+ .filter_map(|id| Some(channels_by_id.get(id)?.name.as_str()))
+ .chain(name)
}
fn insert_note_changed(
@@ -3,7 +3,7 @@ use crate::channel_chat::ChannelChatEvent;
use super::*;
use client::{test::FakeServer, Client, UserStore};
use gpui::{AppContext, ModelHandle, TestAppContext};
-use rpc::proto;
+use rpc::proto::{self};
use settings::SettingsStore;
use util::http::FakeHttpClient;
@@ -18,16 +18,18 @@ fn test_update_channels(cx: &mut AppContext) {
proto::Channel {
id: 1,
name: "b".to_string(),
+ visibility: proto::ChannelVisibility::Members as i32,
+ role: proto::ChannelRole::Admin.into(),
+ parent_path: Vec::new(),
},
proto::Channel {
id: 2,
name: "a".to_string(),
+ visibility: proto::ChannelVisibility::Members as i32,
+ role: proto::ChannelRole::Member.into(),
+ parent_path: Vec::new(),
},
],
- channel_permissions: vec![proto::ChannelPermission {
- channel_id: 1,
- is_admin: true,
- }],
..Default::default()
},
cx,
@@ -36,8 +38,8 @@ fn test_update_channels(cx: &mut AppContext) {
&channel_store,
&[
//
- (0, "a".to_string(), false),
- (0, "b".to_string(), true),
+ (0, "a".to_string(), proto::ChannelRole::Member),
+ (0, "b".to_string(), proto::ChannelRole::Admin),
],
cx,
);
@@ -49,20 +51,16 @@ fn test_update_channels(cx: &mut AppContext) {
proto::Channel {
id: 3,
name: "x".to_string(),
+ visibility: proto::ChannelVisibility::Members as i32,
+ role: proto::ChannelRole::Admin.into(),
+ parent_path: vec![1],
},
proto::Channel {
id: 4,
name: "y".to_string(),
- },
- ],
- insert_edge: vec![
- proto::ChannelEdge {
- parent_id: 1,
- channel_id: 3,
- },
- proto::ChannelEdge {
- parent_id: 2,
- channel_id: 4,
+ visibility: proto::ChannelVisibility::Members as i32,
+ role: proto::ChannelRole::Member.into(),
+ parent_path: vec![2],
},
],
..Default::default()
@@ -72,10 +70,10 @@ fn test_update_channels(cx: &mut AppContext) {
assert_channels(
&channel_store,
&[
- (0, "a".to_string(), false),
- (1, "y".to_string(), false),
- (0, "b".to_string(), true),
- (1, "x".to_string(), true),
+ (0, "a".to_string(), proto::ChannelRole::Member),
+ (1, "y".to_string(), proto::ChannelRole::Member),
+ (0, "b".to_string(), proto::ChannelRole::Admin),
+ (1, "x".to_string(), proto::ChannelRole::Admin),
],
cx,
);
@@ -92,30 +90,25 @@ fn test_dangling_channel_paths(cx: &mut AppContext) {
proto::Channel {
id: 0,
name: "a".to_string(),
+ visibility: proto::ChannelVisibility::Members as i32,
+ role: proto::ChannelRole::Admin.into(),
+ parent_path: vec![],
},
proto::Channel {
id: 1,
name: "b".to_string(),
+ visibility: proto::ChannelVisibility::Members as i32,
+ role: proto::ChannelRole::Admin.into(),
+ parent_path: vec![0],
},
proto::Channel {
id: 2,
name: "c".to_string(),
+ visibility: proto::ChannelVisibility::Members as i32,
+ role: proto::ChannelRole::Admin.into(),
+ parent_path: vec![0, 1],
},
],
- insert_edge: vec![
- proto::ChannelEdge {
- parent_id: 0,
- channel_id: 1,
- },
- proto::ChannelEdge {
- parent_id: 1,
- channel_id: 2,
- },
- ],
- channel_permissions: vec![proto::ChannelPermission {
- channel_id: 0,
- is_admin: true,
- }],
..Default::default()
},
cx,
@@ -125,9 +118,9 @@ fn test_dangling_channel_paths(cx: &mut AppContext) {
&channel_store,
&[
//
- (0, "a".to_string(), true),
- (1, "b".to_string(), true),
- (2, "c".to_string(), true),
+ (0, "a".to_string(), proto::ChannelRole::Admin),
+ (1, "b".to_string(), proto::ChannelRole::Admin),
+ (2, "c".to_string(), proto::ChannelRole::Admin),
],
cx,
);
@@ -142,7 +135,11 @@ fn test_dangling_channel_paths(cx: &mut AppContext) {
);
// Make sure that the 1/2/3 path is gone
- assert_channels(&channel_store, &[(0, "a".to_string(), true)], cx);
+ assert_channels(
+ &channel_store,
+ &[(0, "a".to_string(), proto::ChannelRole::Admin)],
+ cx,
+ );
}
#[gpui::test]
@@ -158,12 +155,19 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
channels: vec![proto::Channel {
id: channel_id,
name: "the-channel".to_string(),
+ visibility: proto::ChannelVisibility::Members as i32,
+ role: proto::ChannelRole::Member.into(),
+ parent_path: vec![],
}],
..Default::default()
});
cx.foreground().run_until_parked();
cx.read(|cx| {
- assert_channels(&channel_store, &[(0, "the-channel".to_string(), false)], cx);
+ assert_channels(
+ &channel_store,
+ &[(0, "the-channel".to_string(), proto::ChannelRole::Member)],
+ cx,
+ );
});
let get_users = server.receive::<proto::GetUsers>().await.unwrap();
@@ -181,7 +185,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
// Join a channel and populate its existing messages.
let channel = channel_store.update(cx, |store, cx| {
- let channel_id = store.channel_dag_entries().next().unwrap().1.id;
+ let channel_id = store.ordered_channels().next().unwrap().1.id;
store.open_channel_chat(channel_id, cx)
});
let join_channel = server.receive::<proto::JoinChannelChat>().await.unwrap();
@@ -194,6 +198,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
body: "a".into(),
timestamp: 1000,
sender_id: 5,
+ mentions: vec![],
nonce: Some(1.into()),
},
proto::ChannelMessage {
@@ -201,6 +206,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
body: "b".into(),
timestamp: 1001,
sender_id: 6,
+ mentions: vec![],
nonce: Some(2.into()),
},
],
@@ -247,6 +253,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
body: "c".into(),
timestamp: 1002,
sender_id: 7,
+ mentions: vec![],
nonce: Some(3.into()),
}),
});
@@ -284,7 +291,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
// Scroll up to view older messages.
channel.update(cx, |channel, cx| {
- assert!(channel.load_more_messages(cx));
+ channel.load_more_messages(cx).unwrap().detach();
});
let get_messages = server.receive::<proto::GetChannelMessages>().await.unwrap();
assert_eq!(get_messages.payload.channel_id, 5);
@@ -300,6 +307,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
timestamp: 998,
sender_id: 5,
nonce: Some(4.into()),
+ mentions: vec![],
},
proto::ChannelMessage {
id: 9,
@@ -307,6 +315,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
timestamp: 999,
sender_id: 6,
nonce: Some(5.into()),
+ mentions: vec![],
},
],
},
@@ -358,19 +367,13 @@ fn update_channels(
#[track_caller]
fn assert_channels(
channel_store: &ModelHandle<ChannelStore>,
- expected_channels: &[(usize, String, bool)],
+ expected_channels: &[(usize, String, proto::ChannelRole)],
cx: &AppContext,
) {
let actual = channel_store.read_with(cx, |store, _| {
store
- .channel_dag_entries()
- .map(|(depth, channel)| {
- (
- depth,
- channel.name.to_string(),
- store.is_user_admin(channel.id),
- )
- })
+ .ordered_channels()
+ .map(|(depth, channel)| (depth, channel.name.to_string(), channel.role))
.collect::<Vec<_>>()
});
assert_eq!(actual, expected_channels);
@@ -4,7 +4,9 @@ use lazy_static::lazy_static;
use parking_lot::Mutex;
use serde::Serialize;
use std::{env, io::Write, mem, path::PathBuf, sync::Arc, time::Duration};
-use sysinfo::{Pid, PidExt, ProcessExt, System, SystemExt};
+use sysinfo::{
+ CpuRefreshKind, Pid, PidExt, ProcessExt, ProcessRefreshKind, RefreshKind, System, SystemExt,
+};
use tempfile::NamedTempFile;
use util::http::HttpClient;
use util::{channel::ReleaseChannel, TryFutureExt};
@@ -166,8 +168,16 @@ impl Telemetry {
let this = self.clone();
cx.spawn(|mut cx| async move {
- let mut system = System::new_all();
- system.refresh_all();
+ // Avoiding calling `System::new_all()`, as there have been crashes related to it
+ let refresh_kind = RefreshKind::new()
+ .with_memory() // For memory usage
+ .with_processes(ProcessRefreshKind::everything()) // For process usage
+ .with_cpu(CpuRefreshKind::everything()); // For core count
+
+ let mut system = System::new_with_specifics(refresh_kind);
+
+ // Avoiding calling `refresh_all()`, just update what we need
+ system.refresh_specifics(refresh_kind);
loop {
// Waiting some amount of time before the first query is important to get a reasonable value
@@ -175,8 +185,7 @@ impl Telemetry {
const DURATION_BETWEEN_SYSTEM_EVENTS: Duration = Duration::from_secs(60);
smol::Timer::after(DURATION_BETWEEN_SYSTEM_EVENTS).await;
- system.refresh_memory();
- system.refresh_processes();
+ system.refresh_specifics(refresh_kind);
let current_process = Pid::from_u32(std::process::id());
let Some(process) = system.processes().get(¤t_process) else {
@@ -293,21 +293,19 @@ impl UserStore {
// No need to paralellize here
let mut updated_contacts = Vec::new();
for contact in message.contacts {
- let should_notify = contact.should_notify;
- updated_contacts.push((
- Arc::new(Contact::from_proto(contact, &this, &mut cx).await?),
- should_notify,
+ updated_contacts.push(Arc::new(
+ Contact::from_proto(contact, &this, &mut cx).await?,
));
}
let mut incoming_requests = Vec::new();
for request in message.incoming_requests {
- incoming_requests.push({
- let user = this
- .update(&mut cx, |this, cx| this.get_user(request.requester_id, cx))
- .await?;
- (user, request.should_notify)
- });
+ incoming_requests.push(
+ this.update(&mut cx, |this, cx| {
+ this.get_user(request.requester_id, cx)
+ })
+ .await?,
+ );
}
let mut outgoing_requests = Vec::new();
@@ -330,13 +328,7 @@ impl UserStore {
this.contacts
.retain(|contact| !removed_contacts.contains(&contact.user.id));
// Update existing contacts and insert new ones
- for (updated_contact, should_notify) in updated_contacts {
- if should_notify {
- cx.emit(Event::Contact {
- user: updated_contact.user.clone(),
- kind: ContactEventKind::Accepted,
- });
- }
+ for updated_contact in updated_contacts {
match this.contacts.binary_search_by_key(
&&updated_contact.user.github_login,
|contact| &contact.user.github_login,
@@ -359,14 +351,7 @@ impl UserStore {
}
});
// Update existing incoming requests and insert new ones
- for (user, should_notify) in incoming_requests {
- if should_notify {
- cx.emit(Event::Contact {
- user: user.clone(),
- kind: ContactEventKind::Requested,
- });
- }
-
+ for user in incoming_requests {
match this
.incoming_contact_requests
.binary_search_by_key(&&user.github_login, |contact| {
@@ -415,6 +400,12 @@ impl UserStore {
&self.incoming_contact_requests
}
+ pub fn has_incoming_contact_request(&self, user_id: u64) -> bool {
+ self.incoming_contact_requests
+ .iter()
+ .any(|user| user.id == user_id)
+ }
+
pub fn outgoing_contact_requests(&self) -> &[Arc<User>] {
&self.outgoing_contact_requests
}
@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathan@zed.dev>"]
default-run = "collab"
edition = "2021"
name = "collab"
-version = "0.24.0"
+version = "0.27.0"
publish = false
[[bin]]
@@ -73,6 +73,7 @@ git = { path = "../git", features = ["test-support"] }
live_kit_client = { path = "../live_kit_client", features = ["test-support"] }
lsp = { path = "../lsp", features = ["test-support"] }
node_runtime = { path = "../node_runtime" }
+notifications = { path = "../notifications", features = ["test-support"] }
project = { path = "../project", features = ["test-support"] }
rpc = { path = "../rpc", features = ["test-support"] }
settings = { path = "../settings", features = ["test-support"] }
@@ -44,7 +44,7 @@ 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) NOT NULL,
+ "room_id" INTEGER REFERENCES rooms (id) ON DELETE CASCADE NOT NULL,
"host_user_id" INTEGER REFERENCES users (id) NOT NULL,
"host_connection_id" INTEGER,
"host_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE,
@@ -192,9 +192,13 @@ CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id");
CREATE TABLE "channels" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"name" VARCHAR NOT NULL,
- "created_at" TIMESTAMP NOT NULL DEFAULT now
+ "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "visibility" VARCHAR NOT NULL,
+ "parent_path" TEXT
);
+CREATE INDEX "index_channels_on_parent_path" ON "channels" ("parent_path");
+
CREATE TABLE IF NOT EXISTS "channel_chat_participants" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER NOT NULL REFERENCES users (id),
@@ -213,19 +217,22 @@ CREATE TABLE IF NOT EXISTS "channel_messages" (
"nonce" BLOB NOT NULL
);
CREATE INDEX "index_channel_messages_on_channel_id" ON "channel_messages" ("channel_id");
-CREATE UNIQUE INDEX "index_channel_messages_on_nonce" ON "channel_messages" ("nonce");
+CREATE UNIQUE INDEX "index_channel_messages_on_sender_id_nonce" ON "channel_messages" ("sender_id", "nonce");
-CREATE TABLE "channel_paths" (
- "id_path" TEXT NOT NULL PRIMARY KEY,
- "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE
+CREATE TABLE "channel_message_mentions" (
+ "message_id" INTEGER NOT NULL REFERENCES channel_messages (id) ON DELETE CASCADE,
+ "start_offset" INTEGER NOT NULL,
+ "end_offset" INTEGER NOT NULL,
+ "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ PRIMARY KEY(message_id, start_offset)
);
-CREATE INDEX "index_channel_paths_on_channel_id" ON "channel_paths" ("channel_id");
CREATE TABLE "channel_members" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
"user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
"admin" BOOLEAN NOT NULL DEFAULT false,
+ "role" VARCHAR,
"accepted" BOOLEAN NOT NULL DEFAULT false,
"updated_at" TIMESTAMP NOT NULL DEFAULT now
);
@@ -312,3 +319,26 @@ CREATE TABLE IF NOT EXISTS "observed_channel_messages" (
);
CREATE UNIQUE INDEX "index_observed_channel_messages_user_and_channel_id" ON "observed_channel_messages" ("user_id", "channel_id");
+
+CREATE TABLE "notification_kinds" (
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT,
+ "name" VARCHAR NOT NULL
+);
+
+CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ("name");
+
+CREATE TABLE "notifications" (
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT,
+ "created_at" TIMESTAMP NOT NULL default CURRENT_TIMESTAMP,
+ "recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ "kind" INTEGER NOT NULL REFERENCES notification_kinds (id),
+ "entity_id" INTEGER,
+ "content" TEXT,
+ "is_read" BOOLEAN NOT NULL DEFAULT FALSE,
+ "response" BOOLEAN
+);
+
+CREATE INDEX
+ "index_notifications_on_recipient_id_is_read_kind_entity_id"
+ ON "notifications"
+ ("recipient_id", "is_read", "kind", "entity_id");
@@ -0,0 +1,22 @@
+CREATE TABLE "notification_kinds" (
+ "id" SERIAL PRIMARY KEY,
+ "name" VARCHAR NOT NULL
+);
+
+CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ("name");
+
+CREATE TABLE notifications (
+ "id" SERIAL PRIMARY KEY,
+ "created_at" TIMESTAMP NOT NULL DEFAULT now(),
+ "recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ "kind" INTEGER NOT NULL REFERENCES notification_kinds (id),
+ "entity_id" INTEGER,
+ "content" TEXT,
+ "is_read" BOOLEAN NOT NULL DEFAULT FALSE,
+ "response" BOOLEAN
+);
+
+CREATE INDEX
+ "index_notifications_on_recipient_id_is_read_kind_entity_id"
+ ON "notifications"
+ ("recipient_id", "is_read", "kind", "entity_id");
@@ -0,0 +1,4 @@
+ALTER TABLE channel_members ADD COLUMN role TEXT;
+UPDATE channel_members SET role = CASE WHEN admin THEN 'admin' ELSE 'member' END;
+
+ALTER TABLE channels ADD COLUMN visibility TEXT NOT NULL DEFAULT 'members';
@@ -0,0 +1,8 @@
+-- Add migration script here
+
+ALTER TABLE projects
+ DROP CONSTRAINT projects_room_id_fkey,
+ ADD CONSTRAINT projects_room_id_fkey
+ FOREIGN KEY (room_id)
+ REFERENCES rooms (id)
+ ON DELETE CASCADE;
@@ -0,0 +1,11 @@
+CREATE TABLE "channel_message_mentions" (
+ "message_id" INTEGER NOT NULL REFERENCES channel_messages (id) ON DELETE CASCADE,
+ "start_offset" INTEGER NOT NULL,
+ "end_offset" INTEGER NOT NULL,
+ "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ PRIMARY KEY(message_id, start_offset)
+);
+
+-- We use 'on conflict update' with this index, so it should be per-user.
+CREATE UNIQUE INDEX "index_channel_messages_on_sender_id_nonce" ON "channel_messages" ("sender_id", "nonce");
+DROP INDEX "index_channel_messages_on_nonce";
@@ -0,0 +1,12 @@
+ALTER TABLE channels ADD COLUMN parent_path TEXT;
+
+UPDATE channels
+SET parent_path = substr(
+ channel_paths.id_path,
+ 2,
+ length(channel_paths.id_path) - length('/' || channel_paths.channel_id::text || '/')
+)
+FROM channel_paths
+WHERE channel_paths.channel_id = channels.id;
+
+CREATE INDEX "index_channels_on_parent_path" ON "channels" ("parent_path");
@@ -71,7 +71,6 @@ async fn main() {
db::NewUserParams {
github_login: github_user.login,
github_user_id: github_user.id,
- invite_count: 5,
},
)
.await
@@ -20,7 +20,7 @@ use rpc::{
};
use sea_orm::{
entity::prelude::*,
- sea_query::{Alias, Expr, OnConflict, Query},
+ sea_query::{Alias, Expr, OnConflict},
ActiveValue, Condition, ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbErr,
FromQueryResult, IntoActiveModel, IsolationLevel, JoinType, QueryOrder, QuerySelect, Statement,
TransactionTrait,
@@ -47,14 +47,14 @@ pub use ids::*;
pub use sea_orm::ConnectOptions;
pub use tables::user::Model as User;
-use self::queries::channels::ChannelGraph;
-
pub struct Database {
options: ConnectOptions,
pool: DatabaseConnection,
rooms: DashMap<RoomId, Arc<Mutex<()>>>,
rng: Mutex<StdRng>,
executor: Executor,
+ notification_kinds_by_id: HashMap<NotificationKindId, &'static str>,
+ notification_kinds_by_name: HashMap<String, NotificationKindId>,
#[cfg(test)]
runtime: Option<tokio::runtime::Runtime>,
}
@@ -69,6 +69,8 @@ impl Database {
pool: sea_orm::Database::connect(options).await?,
rooms: DashMap::with_capacity(16384),
rng: Mutex::new(StdRng::seed_from_u64(0)),
+ notification_kinds_by_id: HashMap::default(),
+ notification_kinds_by_name: HashMap::default(),
executor,
#[cfg(test)]
runtime: None,
@@ -121,6 +123,11 @@ impl Database {
Ok(new_migrations)
}
+ pub async fn initialize_static_data(&mut self) -> Result<()> {
+ self.initialize_notification_kinds().await?;
+ Ok(())
+ }
+
pub async fn transaction<F, Fut, T>(&self, f: F) -> Result<T>
where
F: Send + Fn(TransactionHandle) -> Fut,
@@ -361,18 +368,9 @@ impl<T> RoomGuard<T> {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Contact {
- Accepted {
- user_id: UserId,
- should_notify: bool,
- busy: bool,
- },
- Outgoing {
- user_id: UserId,
- },
- Incoming {
- user_id: UserId,
- should_notify: bool,
- },
+ Accepted { user_id: UserId, busy: bool },
+ Outgoing { user_id: UserId },
+ Incoming { user_id: UserId },
}
impl Contact {
@@ -385,6 +383,15 @@ impl Contact {
}
}
+pub type NotificationBatch = Vec<(UserId, proto::Notification)>;
+
+pub struct CreatedChannelMessage {
+ pub message_id: MessageId,
+ pub participant_connection_ids: Vec<ConnectionId>,
+ pub channel_members: Vec<UserId>,
+ pub notifications: NotificationBatch,
+}
+
#[derive(Clone, Debug, PartialEq, Eq, FromQueryResult, Serialize, Deserialize)]
pub struct Invite {
pub email_address: String,
@@ -417,7 +424,6 @@ pub struct WaitlistSummary {
pub struct NewUserParams {
pub github_login: String,
pub github_user_id: i32,
- pub invite_count: i32,
}
#[derive(Debug)]
@@ -428,17 +434,115 @@ pub struct NewUserResult {
pub signup_device_id: Option<String>,
}
-#[derive(FromQueryResult, Debug, PartialEq, Eq, Hash)]
+#[derive(Debug)]
+pub struct MoveChannelResult {
+ pub participants_to_update: HashMap<UserId, ChannelsForUser>,
+ pub participants_to_remove: HashSet<UserId>,
+ pub moved_channels: HashSet<ChannelId>,
+}
+
+#[derive(Debug)]
+pub struct RenameChannelResult {
+ pub channel: Channel,
+ pub participants_to_update: HashMap<UserId, Channel>,
+}
+
+#[derive(Debug)]
+pub struct CreateChannelResult {
+ pub channel: Channel,
+ pub participants_to_update: Vec<(UserId, ChannelsForUser)>,
+}
+
+#[derive(Debug)]
+pub struct SetChannelVisibilityResult {
+ pub participants_to_update: HashMap<UserId, ChannelsForUser>,
+ pub participants_to_remove: HashSet<UserId>,
+ pub channels_to_remove: Vec<ChannelId>,
+}
+
+#[derive(Debug)]
+pub struct MembershipUpdated {
+ pub channel_id: ChannelId,
+ pub new_channels: ChannelsForUser,
+ pub removed_channels: Vec<ChannelId>,
+}
+
+#[derive(Debug)]
+pub enum SetMemberRoleResult {
+ InviteUpdated(Channel),
+ MembershipUpdated(MembershipUpdated),
+}
+
+#[derive(Debug)]
+pub struct InviteMemberResult {
+ pub channel: Channel,
+ pub notifications: NotificationBatch,
+}
+
+#[derive(Debug)]
+pub struct RespondToChannelInvite {
+ pub membership_update: Option<MembershipUpdated>,
+ pub notifications: NotificationBatch,
+}
+
+#[derive(Debug)]
+pub struct RemoveChannelMemberResult {
+ pub membership_update: MembershipUpdated,
+ pub notification_id: Option<NotificationId>,
+}
+
+#[derive(Debug, PartialEq, Eq, Hash)]
pub struct Channel {
pub id: ChannelId,
pub name: String,
+ pub visibility: ChannelVisibility,
+ pub role: ChannelRole,
+ pub parent_path: Vec<ChannelId>,
+}
+
+impl Channel {
+ fn from_model(value: channel::Model, role: ChannelRole) -> Self {
+ Channel {
+ id: value.id,
+ visibility: value.visibility,
+ name: value.clone().name,
+ role,
+ parent_path: value.ancestors().collect(),
+ }
+ }
+
+ pub fn to_proto(&self) -> proto::Channel {
+ proto::Channel {
+ id: self.id.to_proto(),
+ name: self.name.clone(),
+ visibility: self.visibility.into(),
+ role: self.role.into(),
+ parent_path: self.parent_path.iter().map(|c| c.to_proto()).collect(),
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Eq, Hash)]
+pub struct ChannelMember {
+ pub role: ChannelRole,
+ pub user_id: UserId,
+ pub kind: proto::channel_member::Kind,
+}
+
+impl ChannelMember {
+ pub fn to_proto(&self) -> proto::ChannelMember {
+ proto::ChannelMember {
+ role: self.role.into(),
+ user_id: self.user_id.to_proto(),
+ kind: self.kind.into(),
+ }
+ }
}
#[derive(Debug, PartialEq)]
pub struct ChannelsForUser {
- pub channels: ChannelGraph,
+ pub channels: Vec<Channel>,
pub channel_participants: HashMap<ChannelId, Vec<UserId>>,
- pub channels_with_admin_privileges: HashSet<ChannelId>,
pub unseen_buffer_changes: Vec<proto::UnseenChannelBufferChange>,
pub channel_messages: Vec<proto::UnseenChannelMessage>,
}
@@ -1,4 +1,5 @@
use crate::Result;
+use rpc::proto;
use sea_orm::{entity::prelude::*, DbErr};
use serde::{Deserialize, Serialize};
@@ -80,3 +81,119 @@ id_type!(SignupId);
id_type!(UserId);
id_type!(ChannelBufferCollaboratorId);
id_type!(FlagId);
+id_type!(NotificationId);
+id_type!(NotificationKindId);
+
+#[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash)]
+#[sea_orm(rs_type = "String", db_type = "String(None)")]
+pub enum ChannelRole {
+ #[sea_orm(string_value = "admin")]
+ Admin,
+ #[sea_orm(string_value = "member")]
+ #[default]
+ Member,
+ #[sea_orm(string_value = "guest")]
+ Guest,
+ #[sea_orm(string_value = "banned")]
+ Banned,
+}
+
+impl ChannelRole {
+ pub fn should_override(&self, other: Self) -> bool {
+ use ChannelRole::*;
+ match self {
+ Admin => matches!(other, Member | Banned | Guest),
+ Member => matches!(other, Banned | Guest),
+ Banned => matches!(other, Guest),
+ Guest => false,
+ }
+ }
+
+ pub fn max(&self, other: Self) -> Self {
+ if self.should_override(other) {
+ *self
+ } else {
+ other
+ }
+ }
+
+ pub fn can_see_all_descendants(&self) -> bool {
+ use ChannelRole::*;
+ match self {
+ Admin | Member => true,
+ Guest | Banned => false,
+ }
+ }
+
+ pub fn can_only_see_public_descendants(&self) -> bool {
+ use ChannelRole::*;
+ match self {
+ Guest => true,
+ Admin | Member | Banned => false,
+ }
+ }
+}
+
+impl From<proto::ChannelRole> for ChannelRole {
+ fn from(value: proto::ChannelRole) -> Self {
+ match value {
+ proto::ChannelRole::Admin => ChannelRole::Admin,
+ proto::ChannelRole::Member => ChannelRole::Member,
+ proto::ChannelRole::Guest => ChannelRole::Guest,
+ proto::ChannelRole::Banned => ChannelRole::Banned,
+ }
+ }
+}
+
+impl Into<proto::ChannelRole> for ChannelRole {
+ fn into(self) -> proto::ChannelRole {
+ match self {
+ ChannelRole::Admin => proto::ChannelRole::Admin,
+ ChannelRole::Member => proto::ChannelRole::Member,
+ ChannelRole::Guest => proto::ChannelRole::Guest,
+ ChannelRole::Banned => proto::ChannelRole::Banned,
+ }
+ }
+}
+
+impl Into<i32> for ChannelRole {
+ fn into(self) -> i32 {
+ let proto: proto::ChannelRole = self.into();
+ proto.into()
+ }
+}
+
+#[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash)]
+#[sea_orm(rs_type = "String", db_type = "String(None)")]
+pub enum ChannelVisibility {
+ #[sea_orm(string_value = "public")]
+ Public,
+ #[sea_orm(string_value = "members")]
+ #[default]
+ Members,
+}
+
+impl From<proto::ChannelVisibility> for ChannelVisibility {
+ fn from(value: proto::ChannelVisibility) -> Self {
+ match value {
+ proto::ChannelVisibility::Public => ChannelVisibility::Public,
+ proto::ChannelVisibility::Members => ChannelVisibility::Members,
+ }
+ }
+}
+
+impl Into<proto::ChannelVisibility> for ChannelVisibility {
+ fn into(self) -> proto::ChannelVisibility {
+ match self {
+ ChannelVisibility::Public => proto::ChannelVisibility::Public,
+ ChannelVisibility::Members => proto::ChannelVisibility::Members,
+ }
+ }
+}
+
+impl Into<i32> for ChannelVisibility {
+ fn into(self) -> i32 {
+ let proto: proto::ChannelVisibility = self.into();
+ proto.into()
+ }
+}
@@ -5,6 +5,7 @@ pub mod buffers;
pub mod channels;
pub mod contacts;
pub mod messages;
+pub mod notifications;
pub mod projects;
pub mod rooms;
pub mod servers;
@@ -1,4 +1,5 @@
use super::*;
+use sea_orm::sea_query::Query;
impl Database {
pub async fn create_access_token(
@@ -16,7 +16,8 @@ impl Database {
connection: ConnectionId,
) -> Result<proto::JoinChannelBufferResponse> {
self.transaction(|tx| async move {
- self.check_user_is_channel_member(channel_id, user_id, &tx)
+ let channel = self.get_channel_internal(channel_id, &*tx).await?;
+ self.check_user_is_channel_participant(&channel, user_id, &tx)
.await?;
let buffer = channel::Model {
@@ -129,9 +130,11 @@ impl Database {
self.transaction(|tx| async move {
let mut results = Vec::new();
for client_buffer in buffers {
- let channel_id = ChannelId::from_proto(client_buffer.channel_id);
+ let channel = self
+ .get_channel_internal(ChannelId::from_proto(client_buffer.channel_id), &*tx)
+ .await?;
if self
- .check_user_is_channel_member(channel_id, user_id, &*tx)
+ .check_user_is_channel_participant(&channel, user_id, &*tx)
.await
.is_err()
{
@@ -139,9 +142,9 @@ impl Database {
continue;
}
- let buffer = self.get_channel_buffer(channel_id, &*tx).await?;
+ let buffer = self.get_channel_buffer(channel.id, &*tx).await?;
let mut collaborators = channel_buffer_collaborator::Entity::find()
- .filter(channel_buffer_collaborator::Column::ChannelId.eq(channel_id))
+ .filter(channel_buffer_collaborator::Column::ChannelId.eq(channel.id))
.all(&*tx)
.await?;
@@ -439,7 +442,8 @@ impl Database {
Vec<proto::VectorClockEntry>,
)> {
self.transaction(move |tx| async move {
- self.check_user_is_channel_member(channel_id, user, &*tx)
+ let channel = self.get_channel_internal(channel_id, &*tx).await?;
+ self.check_user_is_channel_member(&channel, user, &*tx)
.await?;
let buffer = buffer::Entity::find()
@@ -482,7 +486,7 @@ impl Database {
)
.await?;
- channel_members = self.get_channel_members_internal(channel_id, &*tx).await?;
+ channel_members = self.get_channel_participants(&channel, &*tx).await?;
let collaborators = self
.get_channel_buffer_collaborators_internal(channel_id, &*tx)
.await?;
@@ -1,8 +1,6 @@
use super::*;
-use rpc::proto::ChannelEdge;
-use smallvec::SmallVec;
-
-type ChannelDescendants = HashMap<ChannelId, SmallSet<ChannelId>>;
+use rpc::proto::channel_member::Kind;
+use sea_orm::TryGetableMany;
impl Database {
#[cfg(test)]
@@ -19,112 +17,258 @@ impl Database {
.await
}
+ #[cfg(test)]
pub async fn create_root_channel(&self, name: &str, creator_id: UserId) -> Result<ChannelId> {
- self.create_channel(name, None, creator_id).await
+ Ok(self
+ .create_channel(name, None, creator_id)
+ .await?
+ .channel
+ .id)
}
- pub async fn create_channel(
+ #[cfg(test)]
+ pub async fn create_sub_channel(
&self,
name: &str,
- parent: Option<ChannelId>,
+ parent: ChannelId,
creator_id: UserId,
) -> Result<ChannelId> {
+ Ok(self
+ .create_channel(name, Some(parent), creator_id)
+ .await?
+ .channel
+ .id)
+ }
+
+ pub async fn create_channel(
+ &self,
+ name: &str,
+ parent_channel_id: Option<ChannelId>,
+ admin_id: UserId,
+ ) -> Result<CreateChannelResult> {
let name = Self::sanitize_channel_name(name)?;
self.transaction(move |tx| async move {
- if let Some(parent) = parent {
- self.check_user_is_channel_admin(parent, creator_id, &*tx)
+ let mut parent = None;
+
+ if let Some(parent_channel_id) = parent_channel_id {
+ let parent_channel = self.get_channel_internal(parent_channel_id, &*tx).await?;
+ self.check_user_is_channel_admin(&parent_channel, admin_id, &*tx)
.await?;
+ parent = Some(parent_channel);
}
let channel = channel::ActiveModel {
+ id: ActiveValue::NotSet,
name: ActiveValue::Set(name.to_string()),
- ..Default::default()
+ visibility: ActiveValue::Set(ChannelVisibility::Members),
+ parent_path: ActiveValue::Set(
+ parent
+ .as_ref()
+ .map_or(String::new(), |parent| parent.path()),
+ ),
}
.insert(&*tx)
.await?;
- if let Some(parent) = parent {
- let sql = r#"
- INSERT INTO channel_paths
- (id_path, channel_id)
- SELECT
- id_path || $1 || '/', $2
- FROM
- channel_paths
- WHERE
- channel_id = $3
- "#;
- let channel_paths_stmt = Statement::from_sql_and_values(
- self.pool.get_database_backend(),
- sql,
- [
- channel.id.to_proto().into(),
- channel.id.to_proto().into(),
- parent.to_proto().into(),
- ],
- );
- tx.execute(channel_paths_stmt).await?;
+ let participants_to_update;
+ if let Some(parent) = &parent {
+ participants_to_update = self
+ .participants_to_notify_for_channel_change(parent, &*tx)
+ .await?;
} else {
- channel_path::Entity::insert(channel_path::ActiveModel {
+ participants_to_update = vec![];
+
+ channel_member::ActiveModel {
+ id: ActiveValue::NotSet,
channel_id: ActiveValue::Set(channel.id),
- id_path: ActiveValue::Set(format!("/{}/", channel.id)),
+ user_id: ActiveValue::Set(admin_id),
+ accepted: ActiveValue::Set(true),
+ role: ActiveValue::Set(ChannelRole::Admin),
+ }
+ .insert(&*tx)
+ .await?;
+ };
+
+ Ok(CreateChannelResult {
+ channel: Channel::from_model(channel, ChannelRole::Admin),
+ participants_to_update,
+ })
+ })
+ .await
+ }
+
+ pub async fn join_channel(
+ &self,
+ channel_id: ChannelId,
+ user_id: UserId,
+ connection: ConnectionId,
+ environment: &str,
+ ) -> Result<(JoinRoom, Option<MembershipUpdated>, ChannelRole)> {
+ self.transaction(move |tx| async move {
+ let channel = self.get_channel_internal(channel_id, &*tx).await?;
+ let mut role = self.channel_role_for_user(&channel, user_id, &*tx).await?;
+
+ let mut accept_invite_result = None;
+
+ if role.is_none() {
+ if let Some(invitation) = self
+ .pending_invite_for_channel(&channel, user_id, &*tx)
+ .await?
+ {
+ // note, this may be a parent channel
+ role = Some(invitation.role);
+ channel_member::Entity::update(channel_member::ActiveModel {
+ accepted: ActiveValue::Set(true),
+ ..invitation.into_active_model()
+ })
+ .exec(&*tx)
+ .await?;
+
+ accept_invite_result = Some(
+ self.calculate_membership_updated(&channel, user_id, &*tx)
+ .await?,
+ );
+
+ debug_assert!(
+ self.channel_role_for_user(&channel, user_id, &*tx).await? == role
+ );
+ }
+ }
+
+ if channel.visibility == ChannelVisibility::Public {
+ role = Some(ChannelRole::Guest);
+ let channel_to_join = self
+ .public_ancestors_including_self(&channel, &*tx)
+ .await?
+ .first()
+ .cloned()
+ .unwrap_or(channel.clone());
+
+ channel_member::Entity::insert(channel_member::ActiveModel {
+ id: ActiveValue::NotSet,
+ channel_id: ActiveValue::Set(channel_to_join.id),
+ user_id: ActiveValue::Set(user_id),
+ accepted: ActiveValue::Set(true),
+ role: ActiveValue::Set(ChannelRole::Guest),
})
.exec(&*tx)
.await?;
+
+ accept_invite_result = Some(
+ self.calculate_membership_updated(&channel_to_join, user_id, &*tx)
+ .await?,
+ );
+
+ debug_assert!(self.channel_role_for_user(&channel, user_id, &*tx).await? == role);
}
- channel_member::ActiveModel {
- channel_id: ActiveValue::Set(channel.id),
- user_id: ActiveValue::Set(creator_id),
- accepted: ActiveValue::Set(true),
- admin: ActiveValue::Set(true),
- ..Default::default()
+ if role.is_none() || role == Some(ChannelRole::Banned) {
+ Err(anyhow!("not allowed"))?
}
- .insert(&*tx)
- .await?;
- Ok(channel.id)
+ let live_kit_room = format!("channel-{}", nanoid::nanoid!(30));
+ let room_id = self
+ .get_or_create_channel_room(channel_id, &live_kit_room, environment, &*tx)
+ .await?;
+
+ self.join_channel_room_internal(room_id, user_id, connection, &*tx)
+ .await
+ .map(|jr| (jr, accept_invite_result, role.unwrap()))
})
.await
}
- pub async fn delete_channel(
+ pub async fn set_channel_visibility(
&self,
channel_id: ChannelId,
- user_id: UserId,
- ) -> Result<(Vec<ChannelId>, Vec<UserId>)> {
+ visibility: ChannelVisibility,
+ admin_id: UserId,
+ ) -> Result<SetChannelVisibilityResult> {
self.transaction(move |tx| async move {
- self.check_user_is_channel_admin(channel_id, user_id, &*tx)
+ let channel = self.get_channel_internal(channel_id, &*tx).await?;
+
+ self.check_user_is_channel_admin(&channel, admin_id, &*tx)
.await?;
- // Don't remove descendant channels that have additional parents.
- let mut channels_to_remove = self.get_channel_descendants([channel_id], &*tx).await?;
- {
- let mut channels_to_keep = channel_path::Entity::find()
- .filter(
- channel_path::Column::ChannelId
- .is_in(
- channels_to_remove
- .keys()
- .copied()
- .filter(|&id| id != channel_id),
- )
- .and(
- channel_path::Column::IdPath
- .not_like(&format!("%/{}/%", channel_id)),
- ),
- )
- .stream(&*tx)
- .await?;
- while let Some(row) = channels_to_keep.next().await {
- let row = row?;
- channels_to_remove.remove(&row.channel_id);
+ let previous_members = self
+ .get_channel_participant_details_internal(&channel, &*tx)
+ .await?;
+
+ let mut model = channel.into_active_model();
+ model.visibility = ActiveValue::Set(visibility);
+ let channel = model.update(&*tx).await?;
+
+ let mut participants_to_update: HashMap<UserId, ChannelsForUser> = self
+ .participants_to_notify_for_channel_change(&channel, &*tx)
+ .await?
+ .into_iter()
+ .collect();
+
+ let mut channels_to_remove: Vec<ChannelId> = vec![];
+ let mut participants_to_remove: HashSet<UserId> = HashSet::default();
+ match visibility {
+ ChannelVisibility::Members => {
+ let all_descendents: Vec<ChannelId> = self
+ .get_channel_descendants_including_self(vec![channel_id], &*tx)
+ .await?
+ .into_iter()
+ .map(|channel| channel.id)
+ .collect();
+
+ channels_to_remove = channel::Entity::find()
+ .filter(
+ channel::Column::Id
+ .is_in(all_descendents)
+ .and(channel::Column::Visibility.eq(ChannelVisibility::Public)),
+ )
+ .all(&*tx)
+ .await?
+ .into_iter()
+ .map(|channel| channel.id)
+ .collect();
+
+ channels_to_remove.push(channel_id);
+
+ for member in previous_members {
+ if member.role.can_only_see_public_descendants() {
+ participants_to_remove.insert(member.user_id);
+ }
+ }
+ }
+ ChannelVisibility::Public => {
+ if let Some(public_parent) = self.public_parent_channel(&channel, &*tx).await? {
+ let parent_updates = self
+ .participants_to_notify_for_channel_change(&public_parent, &*tx)
+ .await?;
+
+ for (user_id, channels) in parent_updates {
+ participants_to_update.insert(user_id, channels);
+ }
+ }
}
}
- let channel_ancestors = self.get_channel_ancestors(channel_id, &*tx).await?;
+ Ok(SetChannelVisibilityResult {
+ participants_to_update,
+ participants_to_remove,
+ channels_to_remove,
+ })
+ })
+ .await
+ }
+
+ pub async fn delete_channel(
+ &self,
+ channel_id: ChannelId,
+ user_id: UserId,
+ ) -> Result<(Vec<ChannelId>, Vec<UserId>)> {
+ self.transaction(move |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 members_to_notify: Vec<UserId> = channel_member::Entity::find()
- .filter(channel_member::Column::ChannelId.is_in(channel_ancestors))
+ .filter(channel_member::Column::ChannelId.is_in(channel.ancestors_including_self()))
.select_only()
.column(channel_member::Column::UserId)
.distinct()
@@ -132,25 +276,19 @@ impl Database {
.all(&*tx)
.await?;
+ let channels_to_remove = self
+ .get_channel_descendants_including_self(vec![channel.id], &*tx)
+ .await?
+ .into_iter()
+ .map(|channel| channel.id)
+ .collect::<Vec<_>>();
+
channel::Entity::delete_many()
- .filter(channel::Column::Id.is_in(channels_to_remove.keys().copied()))
+ .filter(channel::Column::Id.is_in(channels_to_remove.iter().copied()))
.exec(&*tx)
.await?;
- // Delete any other paths that include this channel
- let sql = r#"
- DELETE FROM channel_paths
- WHERE
- id_path LIKE '%' || $1 || '%'
- "#;
- let channel_paths_stmt = Statement::from_sql_and_values(
- self.pool.get_database_backend(),
- sql,
- [channel_id.to_proto().into()],
- );
- tx.execute(channel_paths_stmt).await?;
-
- Ok((channels_to_remove.into_keys().collect(), members_to_notify))
+ Ok((channels_to_remove, members_to_notify))
})
.await
}
@@ -160,23 +298,44 @@ impl Database {
channel_id: ChannelId,
invitee_id: UserId,
inviter_id: UserId,
- is_admin: bool,
- ) -> Result<()> {
+ role: ChannelRole,
+ ) -> Result<InviteMemberResult> {
self.transaction(move |tx| async move {
- self.check_user_is_channel_admin(channel_id, inviter_id, &*tx)
+ let channel = self.get_channel_internal(channel_id, &*tx).await?;
+ self.check_user_is_channel_admin(&channel, inviter_id, &*tx)
.await?;
channel_member::ActiveModel {
+ id: ActiveValue::NotSet,
channel_id: ActiveValue::Set(channel_id),
user_id: ActiveValue::Set(invitee_id),
accepted: ActiveValue::Set(false),
- admin: ActiveValue::Set(is_admin),
- ..Default::default()
+ role: ActiveValue::Set(role),
}
.insert(&*tx)
.await?;
- Ok(())
+ let channel = Channel::from_model(channel, role);
+
+ let notifications = self
+ .create_notification(
+ invitee_id,
+ rpc::Notification::ChannelInvitation {
+ channel_id: channel_id.to_proto(),
+ channel_name: channel.name.clone(),
+ inviter_id: inviter_id.to_proto(),
+ },
+ true,
+ &*tx,
+ )
+ .await?
+ .into_iter()
+ .collect();
+
+ Ok(InviteMemberResult {
+ channel,
+ notifications,
+ })
})
.await
}
@@ -192,24 +351,37 @@ impl Database {
pub async fn rename_channel(
&self,
channel_id: ChannelId,
- user_id: UserId,
+ admin_id: UserId,
new_name: &str,
- ) -> Result<String> {
+ ) -> Result<RenameChannelResult> {
self.transaction(move |tx| async move {
let new_name = Self::sanitize_channel_name(new_name)?.to_string();
- self.check_user_is_channel_admin(channel_id, user_id, &*tx)
+ let channel = self.get_channel_internal(channel_id, &*tx).await?;
+ let role = self
+ .check_user_is_channel_admin(&channel, admin_id, &*tx)
.await?;
- channel::ActiveModel {
- id: ActiveValue::Unchanged(channel_id),
- name: ActiveValue::Set(new_name.clone()),
- ..Default::default()
- }
- .update(&*tx)
- .await?;
+ let mut model = channel.into_active_model();
+ model.name = ActiveValue::Set(new_name.clone());
+ let channel = model.update(&*tx).await?;
+
+ let participants = self
+ .get_channel_participant_details_internal(&channel, &*tx)
+ .await?;
- Ok(new_name)
+ Ok(RenameChannelResult {
+ channel: Channel::from_model(channel.clone(), role),
+ participants_to_update: participants
+ .iter()
+ .map(|participant| {
+ (
+ participant.user_id,
+ Channel::from_model(channel.clone(), participant.role),
+ )
+ })
+ .collect(),
+ })
})
.await
}
@@ -219,10 +391,12 @@ impl Database {
channel_id: ChannelId,
user_id: UserId,
accept: bool,
- ) -> Result<()> {
+ ) -> Result<RespondToChannelInvite> {
self.transaction(move |tx| async move {
- let rows_affected = if accept {
- channel_member::Entity::update_many()
+ let channel = self.get_channel_internal(channel_id, &*tx).await?;
+
+ let membership_update = if accept {
+ let rows_affected = channel_member::Entity::update_many()
.set(channel_member::ActiveModel {
accepted: ActiveValue::Set(accept),
..Default::default()
@@ -235,35 +409,91 @@ impl Database {
)
.exec(&*tx)
.await?
- .rows_affected
+ .rows_affected;
+
+ if rows_affected == 0 {
+ Err(anyhow!("no such invitation"))?;
+ }
+
+ Some(
+ self.calculate_membership_updated(&channel, user_id, &*tx)
+ .await?,
+ )
} else {
- channel_member::ActiveModel {
- channel_id: ActiveValue::Unchanged(channel_id),
- user_id: ActiveValue::Unchanged(user_id),
- ..Default::default()
+ let rows_affected = channel_member::Entity::delete_many()
+ .filter(
+ channel_member::Column::ChannelId
+ .eq(channel_id)
+ .and(channel_member::Column::UserId.eq(user_id))
+ .and(channel_member::Column::Accepted.eq(false)),
+ )
+ .exec(&*tx)
+ .await?
+ .rows_affected;
+ if rows_affected == 0 {
+ Err(anyhow!("no such invitation"))?;
}
- .delete(&*tx)
- .await?
- .rows_affected
- };
- if rows_affected == 0 {
- Err(anyhow!("no such invitation"))?;
- }
+ None
+ };
- Ok(())
+ Ok(RespondToChannelInvite {
+ membership_update,
+ notifications: self
+ .mark_notification_as_read_with_response(
+ user_id,
+ &rpc::Notification::ChannelInvitation {
+ channel_id: channel_id.to_proto(),
+ channel_name: Default::default(),
+ inviter_id: Default::default(),
+ },
+ accept,
+ &*tx,
+ )
+ .await?
+ .into_iter()
+ .collect(),
+ })
})
.await
}
+ async fn calculate_membership_updated(
+ &self,
+ channel: &channel::Model,
+ user_id: UserId,
+ tx: &DatabaseTransaction,
+ ) -> Result<MembershipUpdated> {
+ let new_channels = self.get_user_channels(user_id, Some(channel), &*tx).await?;
+ let removed_channels = self
+ .get_channel_descendants_including_self(vec![channel.id], &*tx)
+ .await?
+ .into_iter()
+ .filter_map(|channel| {
+ if !new_channels.channels.iter().any(|c| c.id == channel.id) {
+ Some(channel.id)
+ } else {
+ None
+ }
+ })
+ .collect::<Vec<_>>();
+
+ Ok(MembershipUpdated {
+ channel_id: channel.id,
+ new_channels,
+ removed_channels,
+ })
+ }
+
pub async fn remove_channel_member(
&self,
channel_id: ChannelId,
member_id: UserId,
- remover_id: UserId,
- ) -> Result<()> {
+ admin_id: UserId,
+ ) -> Result<RemoveChannelMemberResult> {
self.transaction(|tx| async move {
- self.check_user_is_channel_admin(channel_id, remover_id, &*tx)
+ let channel = self.get_channel_internal(channel_id, &*tx).await?;
+ self.check_user_is_channel_admin(&channel, admin_id, &*tx)
.await?;
let result = channel_member::Entity::delete_many()
@@ -279,13 +509,30 @@ impl Database {
Err(anyhow!("no such member"))?;
}
- Ok(())
+ Ok(RemoveChannelMemberResult {
+ membership_update: self
+ .calculate_membership_updated(&channel, member_id, &*tx)
+ .await?,
+ notification_id: self
+ .remove_notification(
+ member_id,
+ rpc::Notification::ChannelInvitation {
+ channel_id: channel_id.to_proto(),
+ channel_name: Default::default(),
+ inviter_id: Default::default(),
+ },
+ &*tx,
+ )
+ .await?,
+ })
})
.await
}
pub async fn get_channel_invites_for_user(&self, user_id: UserId) -> Result<Vec<Channel>> {
self.transaction(|tx| async move {
+ let mut role_for_channel: HashMap<ChannelId, ChannelRole> = HashMap::default();
+
let channel_invites = channel_member::Entity::find()
.filter(
channel_member::Column::UserId
@@ -295,22 +542,20 @@ impl Database {
.all(&*tx)
.await?;
+ for invite in channel_invites {
+ role_for_channel.insert(invite.channel_id, invite.role);
+ }
+
let channels = channel::Entity::find()
- .filter(
- channel::Column::Id.is_in(
- channel_invites
- .into_iter()
- .map(|channel_member| channel_member.channel_id),
- ),
- )
+ .filter(channel::Column::Id.is_in(role_for_channel.keys().copied()))
.all(&*tx)
.await?;
let channels = channels
.into_iter()
- .map(|channel| Channel {
- id: channel.id,
- name: channel.name,
+ .filter_map(|channel| {
+ let role = *role_for_channel.get(&channel.id)?;
+ Some(Channel::from_model(channel, role))
})
.collect();
@@ -319,88 +564,11 @@ impl Database {
.await
}
- async fn get_channel_graph(
- &self,
- parents_by_child_id: ChannelDescendants,
- trim_dangling_parents: bool,
- tx: &DatabaseTransaction,
- ) -> Result<ChannelGraph> {
- let mut channels = Vec::with_capacity(parents_by_child_id.len());
- {
- let mut rows = channel::Entity::find()
- .filter(channel::Column::Id.is_in(parents_by_child_id.keys().copied()))
- .stream(&*tx)
- .await?;
- while let Some(row) = rows.next().await {
- let row = row?;
- channels.push(Channel {
- id: row.id,
- name: row.name,
- })
- }
- }
-
- let mut edges = Vec::with_capacity(parents_by_child_id.len());
- for (channel, parents) in parents_by_child_id.iter() {
- for parent in parents.into_iter() {
- if trim_dangling_parents {
- if parents_by_child_id.contains_key(parent) {
- edges.push(ChannelEdge {
- channel_id: channel.to_proto(),
- parent_id: parent.to_proto(),
- });
- }
- } else {
- edges.push(ChannelEdge {
- channel_id: channel.to_proto(),
- parent_id: parent.to_proto(),
- });
- }
- }
- }
-
- Ok(ChannelGraph { channels, edges })
- }
-
pub async fn get_channels_for_user(&self, user_id: UserId) -> Result<ChannelsForUser> {
self.transaction(|tx| async move {
let tx = tx;
- let channel_memberships = channel_member::Entity::find()
- .filter(
- channel_member::Column::UserId
- .eq(user_id)
- .and(channel_member::Column::Accepted.eq(true)),
- )
- .all(&*tx)
- .await?;
-
- self.get_user_channels(user_id, channel_memberships, &tx)
- .await
- })
- .await
- }
-
- pub async fn get_channel_for_user(
- &self,
- channel_id: ChannelId,
- user_id: UserId,
- ) -> Result<ChannelsForUser> {
- self.transaction(|tx| async move {
- let tx = tx;
-
- let channel_membership = channel_member::Entity::find()
- .filter(
- channel_member::Column::UserId
- .eq(user_id)
- .and(channel_member::Column::ChannelId.eq(channel_id))
- .and(channel_member::Column::Accepted.eq(true)),
- )
- .all(&*tx)
- .await?;
-
- self.get_user_channels(user_id, channel_membership, &tx)
- .await
+ self.get_user_channels(user_id, None, &tx).await
})
.await
}
@@ -408,22 +576,78 @@ impl Database {
pub async fn get_user_channels(
&self,
user_id: UserId,
- channel_memberships: Vec<channel_member::Model>,
+ ancestor_channel: Option<&channel::Model>,
tx: &DatabaseTransaction,
) -> Result<ChannelsForUser> {
- let parents_by_child_id = self
- .get_channel_descendants(channel_memberships.iter().map(|m| m.channel_id), &*tx)
+ let channel_memberships = channel_member::Entity::find()
+ .filter(
+ channel_member::Column::UserId
+ .eq(user_id)
+ .and(channel_member::Column::Accepted.eq(true)),
+ )
+ .all(&*tx)
.await?;
- let channels_with_admin_privileges = channel_memberships
- .iter()
- .filter_map(|membership| membership.admin.then_some(membership.channel_id))
- .collect();
-
- let graph = self
- .get_channel_graph(parents_by_child_id, true, &tx)
+ let descendants = self
+ .get_channel_descendants_including_self(
+ channel_memberships.iter().map(|m| m.channel_id),
+ &*tx,
+ )
.await?;
+ let mut roles_by_channel_id: HashMap<ChannelId, ChannelRole> = HashMap::default();
+ for membership in channel_memberships.iter() {
+ roles_by_channel_id.insert(membership.channel_id, membership.role);
+ }
+
+ let mut visible_channel_ids: HashSet<ChannelId> = HashSet::default();
+
+ let channels: Vec<Channel> = descendants
+ .into_iter()
+ .filter_map(|channel| {
+ let parent_role = channel
+ .parent_id()
+ .and_then(|parent_id| roles_by_channel_id.get(&parent_id));
+
+ let role = if let Some(parent_role) = parent_role {
+ let role = if let Some(existing_role) = roles_by_channel_id.get(&channel.id) {
+ existing_role.max(*parent_role)
+ } else {
+ *parent_role
+ };
+ roles_by_channel_id.insert(channel.id, role);
+ role
+ } else {
+ *roles_by_channel_id.get(&channel.id)?
+ };
+
+ let can_see_parent_paths = role.can_see_all_descendants()
+ || role.can_only_see_public_descendants()
+ && channel.visibility == ChannelVisibility::Public;
+ if !can_see_parent_paths {
+ return None;
+ }
+
+ visible_channel_ids.insert(channel.id);
+
+ if let Some(ancestor) = ancestor_channel {
+ if !channel
+ .ancestors_including_self()
+ .any(|id| id == ancestor.id)
+ {
+ return None;
+ }
+ }
+
+ let mut channel = Channel::from_model(channel, role);
+ channel
+ .parent_path
+ .retain(|id| visible_channel_ids.contains(&id));
+
+ Some(channel)
+ })
+ .collect();
+
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryUserIdsAndChannelIds {
ChannelId,
@@ -434,7 +658,7 @@ impl Database {
{
let mut rows = room_participant::Entity::find()
.inner_join(room::Entity)
- .filter(room::Column::ChannelId.is_in(graph.channels.iter().map(|c| c.id)))
+ .filter(room::Column::ChannelId.is_in(channels.iter().map(|c| c.id)))
.select_only()
.column(room::Column::ChannelId)
.column(room_participant::Column::UserId)
@@ -447,7 +671,7 @@ impl Database {
}
}
- let channel_ids = graph.channels.iter().map(|c| c.id).collect::<Vec<_>>();
+ let channel_ids = channels.iter().map(|c| c.id).collect::<Vec<_>>();
let channel_buffer_changes = self
.unseen_channel_buffer_changes(user_id, &channel_ids, &*tx)
.await?;
@@ -457,634 +681,632 @@ impl Database {
.await?;
Ok(ChannelsForUser {
- channels: graph,
+ channels,
channel_participants,
- channels_with_admin_privileges,
unseen_buffer_changes: channel_buffer_changes,
channel_messages: unseen_messages,
})
}
- pub async fn get_channel_members(&self, id: ChannelId) -> Result<Vec<UserId>> {
- self.transaction(|tx| async move { self.get_channel_members_internal(id, &*tx).await })
- .await
+ async fn participants_to_notify_for_channel_change(
+ &self,
+ new_parent: &channel::Model,
+ tx: &DatabaseTransaction,
+ ) -> Result<Vec<(UserId, ChannelsForUser)>> {
+ let mut results: Vec<(UserId, ChannelsForUser)> = Vec::new();
+
+ let members = self
+ .get_channel_participant_details_internal(new_parent, &*tx)
+ .await?;
+
+ for member in members.iter() {
+ if !member.role.can_see_all_descendants() {
+ continue;
+ }
+ results.push((
+ member.user_id,
+ self.get_user_channels(member.user_id, Some(new_parent), &*tx)
+ .await?,
+ ))
+ }
+
+ let public_parents = self
+ .public_ancestors_including_self(new_parent, &*tx)
+ .await?;
+ let public_parent = public_parents.last();
+
+ let Some(public_parent) = public_parent else {
+ return Ok(results);
+ };
+
+ // could save some time in the common case by skipping this if the
+ // new channel is not public and has no public descendants.
+ let public_members = if public_parent == new_parent {
+ members
+ } else {
+ self.get_channel_participant_details_internal(public_parent, &*tx)
+ .await?
+ };
+
+ for member in public_members {
+ if !member.role.can_only_see_public_descendants() {
+ continue;
+ };
+ results.push((
+ member.user_id,
+ self.get_user_channels(member.user_id, Some(public_parent), &*tx)
+ .await?,
+ ))
+ }
+
+ Ok(results)
}
- pub async fn set_channel_member_admin(
+ pub async fn set_channel_member_role(
&self,
channel_id: ChannelId,
- from: UserId,
+ admin_id: UserId,
for_user: UserId,
- admin: bool,
- ) -> Result<()> {
+ role: ChannelRole,
+ ) -> Result<SetMemberRoleResult> {
self.transaction(|tx| async move {
- self.check_user_is_channel_admin(channel_id, from, &*tx)
+ let channel = self.get_channel_internal(channel_id, &*tx).await?;
+ self.check_user_is_channel_admin(&channel, admin_id, &*tx)
.await?;
- let result = channel_member::Entity::update_many()
+ let membership = channel_member::Entity::find()
.filter(
channel_member::Column::ChannelId
.eq(channel_id)
.and(channel_member::Column::UserId.eq(for_user)),
)
- .set(channel_member::ActiveModel {
- admin: ActiveValue::set(admin),
- ..Default::default()
- })
- .exec(&*tx)
+ .one(&*tx)
.await?;
- if result.rows_affected == 0 {
- Err(anyhow!("no such member"))?;
- }
+ let Some(membership) = membership else {
+ Err(anyhow!("no such member"))?
+ };
- Ok(())
+ let mut update = membership.into_active_model();
+ update.role = ActiveValue::Set(role);
+ let updated = channel_member::Entity::update(update).exec(&*tx).await?;
+
+ if updated.accepted {
+ Ok(SetMemberRoleResult::MembershipUpdated(
+ self.calculate_membership_updated(&channel, for_user, &*tx)
+ .await?,
+ ))
+ } else {
+ Ok(SetMemberRoleResult::InviteUpdated(Channel::from_model(
+ channel, role,
+ )))
+ }
})
.await
}
- pub async fn get_channel_member_details(
+ pub async fn get_channel_participant_details(
&self,
channel_id: ChannelId,
user_id: UserId,
) -> Result<Vec<proto::ChannelMember>> {
- self.transaction(|tx| async move {
- self.check_user_is_channel_admin(channel_id, user_id, &*tx)
- .await?;
-
- #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
- enum QueryMemberDetails {
- UserId,
- Admin,
- IsDirectMember,
- Accepted,
- }
-
- let tx = tx;
- let ancestor_ids = self.get_channel_ancestors(channel_id, &*tx).await?;
- let mut stream = channel_member::Entity::find()
- .distinct()
- .filter(channel_member::Column::ChannelId.is_in(ancestor_ids.iter().copied()))
- .select_only()
- .column(channel_member::Column::UserId)
- .column(channel_member::Column::Admin)
- .column_as(
- channel_member::Column::ChannelId.eq(channel_id),
- QueryMemberDetails::IsDirectMember,
- )
- .column(channel_member::Column::Accepted)
- .order_by_asc(channel_member::Column::UserId)
- .into_values::<_, QueryMemberDetails>()
- .stream(&*tx)
- .await?;
+ let (role, members) = self
+ .transaction(move |tx| async move {
+ let channel = self.get_channel_internal(channel_id, &*tx).await?;
+ let role = self
+ .check_user_is_channel_participant(&channel, user_id, &*tx)
+ .await?;
+ Ok((
+ role,
+ self.get_channel_participant_details_internal(&channel, &*tx)
+ .await?,
+ ))
+ })
+ .await?;
- let mut rows = Vec::<proto::ChannelMember>::new();
- while let Some(row) = stream.next().await {
- let (user_id, is_admin, is_direct_member, is_invite_accepted): (
- UserId,
- bool,
- bool,
- bool,
- ) = row?;
- let kind = match (is_direct_member, is_invite_accepted) {
- (true, true) => proto::channel_member::Kind::Member,
- (true, false) => proto::channel_member::Kind::Invitee,
- (false, true) => proto::channel_member::Kind::AncestorMember,
- (false, false) => continue,
- };
- let user_id = user_id.to_proto();
- let kind = kind.into();
- if let Some(last_row) = rows.last_mut() {
- if last_row.user_id == user_id {
- if is_direct_member {
- last_row.kind = kind;
- last_row.admin = is_admin;
- }
- continue;
+ if role == ChannelRole::Admin {
+ Ok(members
+ .into_iter()
+ .map(|channel_member| channel_member.to_proto())
+ .collect())
+ } else {
+ return Ok(members
+ .into_iter()
+ .filter_map(|member| {
+ if member.kind == proto::channel_member::Kind::Invitee {
+ return None;
}
- }
- rows.push(proto::ChannelMember {
- user_id,
- kind,
- admin: is_admin,
- });
- }
-
- Ok(rows)
- })
- .await
+ Some(ChannelMember {
+ role: member.role,
+ user_id: member.user_id,
+ kind: proto::channel_member::Kind::Member,
+ })
+ })
+ .map(|channel_member| channel_member.to_proto())
+ .collect());
+ }
}
- pub async fn get_channel_members_internal(
+ async fn get_channel_participant_details_internal(
&self,
- id: ChannelId,
+ channel: &channel::Model,
tx: &DatabaseTransaction,
- ) -> Result<Vec<UserId>> {
- let ancestor_ids = self.get_channel_ancestors(id, tx).await?;
- let user_ids = channel_member::Entity::find()
- .distinct()
- .filter(
- channel_member::Column::ChannelId
- .is_in(ancestor_ids.iter().copied())
- .and(channel_member::Column::Accepted.eq(true)),
- )
+ ) -> Result<Vec<ChannelMember>> {
+ #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+ enum QueryMemberDetails {
+ UserId,
+ Role,
+ IsDirectMember,
+ Accepted,
+ Visibility,
+ }
+
+ let mut stream = channel_member::Entity::find()
+ .left_join(channel::Entity)
+ .filter(channel_member::Column::ChannelId.is_in(channel.ancestors_including_self()))
.select_only()
.column(channel_member::Column::UserId)
- .into_values::<_, QueryUserIds>()
- .all(&*tx)
+ .column(channel_member::Column::Role)
+ .column_as(
+ channel_member::Column::ChannelId.eq(channel.id),
+ QueryMemberDetails::IsDirectMember,
+ )
+ .column(channel_member::Column::Accepted)
+ .column(channel::Column::Visibility)
+ .into_values::<_, QueryMemberDetails>()
+ .stream(&*tx)
.await?;
- Ok(user_ids)
+
+ let mut user_details: HashMap<UserId, ChannelMember> = HashMap::default();
+
+ while let Some(user_membership) = stream.next().await {
+ let (user_id, channel_role, is_direct_member, is_invite_accepted, visibility): (
+ UserId,
+ ChannelRole,
+ bool,
+ bool,
+ ChannelVisibility,
+ ) = user_membership?;
+ let kind = match (is_direct_member, is_invite_accepted) {
+ (true, true) => proto::channel_member::Kind::Member,
+ (true, false) => proto::channel_member::Kind::Invitee,
+ (false, true) => proto::channel_member::Kind::AncestorMember,
+ (false, false) => continue,
+ };
+
+ if channel_role == ChannelRole::Guest
+ && visibility != ChannelVisibility::Public
+ && channel.visibility != ChannelVisibility::Public
+ {
+ continue;
+ }
+
+ if let Some(details_mut) = user_details.get_mut(&user_id) {
+ if channel_role.should_override(details_mut.role) {
+ details_mut.role = channel_role;
+ }
+ if kind == Kind::Member {
+ details_mut.kind = kind;
+ // the UI is going to be a bit confusing if you already have permissions
+ // that are greater than or equal to the ones you're being invited to.
+ } else if kind == Kind::Invitee && details_mut.kind == Kind::AncestorMember {
+ details_mut.kind = kind;
+ }
+ } else {
+ user_details.insert(
+ user_id,
+ ChannelMember {
+ user_id,
+ kind,
+ role: channel_role,
+ },
+ );
+ }
+ }
+
+ Ok(user_details
+ .into_iter()
+ .map(|(_, details)| details)
+ .collect())
}
- pub async fn check_user_is_channel_member(
+ pub async fn get_channel_participants(
&self,
- channel_id: ChannelId,
- user_id: UserId,
+ channel: &channel::Model,
tx: &DatabaseTransaction,
- ) -> Result<()> {
- let channel_ids = self.get_channel_ancestors(channel_id, tx).await?;
- channel_member::Entity::find()
- .filter(
- channel_member::Column::ChannelId
- .is_in(channel_ids)
- .and(channel_member::Column::UserId.eq(user_id)),
- )
- .one(&*tx)
- .await?
- .ok_or_else(|| anyhow!("user is not a channel member or channel does not exist"))?;
- Ok(())
+ ) -> Result<Vec<UserId>> {
+ let participants = self
+ .get_channel_participant_details_internal(channel, &*tx)
+ .await?;
+ Ok(participants
+ .into_iter()
+ .map(|member| member.user_id)
+ .collect())
}
pub async fn check_user_is_channel_admin(
&self,
- channel_id: ChannelId,
+ channel: &channel::Model,
user_id: UserId,
tx: &DatabaseTransaction,
- ) -> Result<()> {
- let channel_ids = self.get_channel_ancestors(channel_id, tx).await?;
- channel_member::Entity::find()
- .filter(
- channel_member::Column::ChannelId
- .is_in(channel_ids)
- .and(channel_member::Column::UserId.eq(user_id))
- .and(channel_member::Column::Admin.eq(true)),
- )
- .one(&*tx)
- .await?
- .ok_or_else(|| anyhow!("user is not a channel admin or channel does not exist"))?;
- Ok(())
+ ) -> Result<ChannelRole> {
+ let role = self.channel_role_for_user(channel, user_id, tx).await?;
+ match role {
+ Some(ChannelRole::Admin) => Ok(role.unwrap()),
+ Some(ChannelRole::Member)
+ | Some(ChannelRole::Banned)
+ | Some(ChannelRole::Guest)
+ | None => Err(anyhow!(
+ "user is not a channel admin or channel does not exist"
+ ))?,
+ }
}
- /// Returns the channel ancestors, deepest first
- pub async fn get_channel_ancestors(
+ pub async fn check_user_is_channel_member(
&self,
- channel_id: ChannelId,
+ channel: &channel::Model,
+ user_id: UserId,
tx: &DatabaseTransaction,
- ) -> Result<Vec<ChannelId>> {
- let paths = channel_path::Entity::find()
- .filter(channel_path::Column::ChannelId.eq(channel_id))
- .order_by(channel_path::Column::IdPath, sea_orm::Order::Desc)
- .all(tx)
- .await?;
- let mut channel_ids = Vec::new();
- for path in paths {
- for id in path.id_path.trim_matches('/').split('/') {
- if let Ok(id) = id.parse() {
- let id = ChannelId::from_proto(id);
- if let Err(ix) = channel_ids.binary_search(&id) {
- channel_ids.insert(ix, id);
- }
- }
- }
+ ) -> Result<ChannelRole> {
+ let channel_role = self.channel_role_for_user(channel, user_id, tx).await?;
+ match channel_role {
+ Some(ChannelRole::Admin) | Some(ChannelRole::Member) => Ok(channel_role.unwrap()),
+ Some(ChannelRole::Banned) | Some(ChannelRole::Guest) | None => Err(anyhow!(
+ "user is not a channel member or channel does not exist"
+ ))?,
}
- Ok(channel_ids)
}
- /// Returns the channel descendants,
- /// Structured as a map from child ids to their parent ids
- /// For example, the descendants of 'a' in this DAG:
- ///
- /// /- b -\
- /// a -- c -- d
- ///
- /// would be:
- /// {
- /// a: [],
- /// b: [a],
- /// c: [a],
- /// d: [a, c],
- /// }
- async fn get_channel_descendants(
+ pub async fn check_user_is_channel_participant(
&self,
- channel_ids: impl IntoIterator<Item = ChannelId>,
+ channel: &channel::Model,
+ user_id: UserId,
tx: &DatabaseTransaction,
- ) -> Result<ChannelDescendants> {
- 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(HashMap::default());
- }
-
- let sql = format!(
- r#"
- SELECT
- descendant_paths.*
- FROM
- channel_paths parent_paths, channel_paths descendant_paths
- WHERE
- parent_paths.channel_id IN ({values}) AND
- descendant_paths.id_path LIKE (parent_paths.id_path || '%')
- "#
- );
-
- let stmt = Statement::from_string(self.pool.get_database_backend(), sql);
-
- let mut parents_by_child_id: ChannelDescendants = HashMap::default();
- let mut paths = channel_path::Entity::find()
- .from_raw_sql(stmt)
- .stream(tx)
- .await?;
-
- while let Some(path) = paths.next().await {
- let path = path?;
- let ids = path.id_path.trim_matches('/').split('/');
- let mut parent_id = None;
- for id in ids {
- if let Ok(id) = id.parse() {
- let id = ChannelId::from_proto(id);
- if id == path.channel_id {
- break;
- }
- parent_id = Some(id);
- }
- }
- let entry = parents_by_child_id.entry(path.channel_id).or_default();
- if let Some(parent_id) = parent_id {
- entry.insert(parent_id);
+ ) -> Result<ChannelRole> {
+ let role = self.channel_role_for_user(channel, user_id, tx).await?;
+ match role {
+ Some(ChannelRole::Admin) | Some(ChannelRole::Member) | Some(ChannelRole::Guest) => {
+ Ok(role.unwrap())
}
+ Some(ChannelRole::Banned) | None => Err(anyhow!(
+ "user is not a channel participant or channel does not exist"
+ ))?,
}
-
- Ok(parents_by_child_id)
}
- /// Returns the channel with the given ID and:
- /// - true if the user is a member
- /// - false if the user hasn't accepted the invitation yet
- pub async fn get_channel(
+ pub async fn pending_invite_for_channel(
&self,
- channel_id: ChannelId,
+ channel: &channel::Model,
user_id: UserId,
- ) -> Result<Option<(Channel, bool)>> {
- self.transaction(|tx| async move {
- let tx = tx;
-
- let channel = channel::Entity::find_by_id(channel_id).one(&*tx).await?;
-
- if let Some(channel) = channel {
- if self
- .check_user_is_channel_member(channel_id, user_id, &*tx)
- .await
- .is_err()
- {
- return Ok(None);
- }
-
- let channel_membership = channel_member::Entity::find()
- .filter(
- channel_member::Column::ChannelId
- .eq(channel_id)
- .and(channel_member::Column::UserId.eq(user_id)),
- )
- .one(&*tx)
- .await?;
-
- let is_accepted = channel_membership
- .map(|membership| membership.accepted)
- .unwrap_or(false);
+ tx: &DatabaseTransaction,
+ ) -> Result<Option<channel_member::Model>> {
+ let row = channel_member::Entity::find()
+ .filter(channel_member::Column::ChannelId.is_in(channel.ancestors_including_self()))
+ .filter(channel_member::Column::UserId.eq(user_id))
+ .filter(channel_member::Column::Accepted.eq(false))
+ .one(&*tx)
+ .await?;
- Ok(Some((
- Channel {
- id: channel.id,
- name: channel.name,
- },
- is_accepted,
- )))
- } else {
- Ok(None)
- }
- })
- .await
+ Ok(row)
}
- pub async fn get_or_create_channel_room(
+ pub async fn public_parent_channel(
&self,
- channel_id: ChannelId,
- live_kit_room: &str,
- enviroment: &str,
- ) -> Result<RoomId> {
- self.transaction(|tx| async move {
- let tx = tx;
-
- let room = room::Entity::find()
- .filter(room::Column::ChannelId.eq(channel_id))
- .one(&*tx)
- .await?;
-
- let room_id = if let Some(room) = room {
- room.id
- } else {
- let result = room::Entity::insert(room::ActiveModel {
- channel_id: ActiveValue::Set(Some(channel_id)),
- live_kit_room: ActiveValue::Set(live_kit_room.to_string()),
- enviroment: ActiveValue::Set(Some(enviroment.to_string())),
- ..Default::default()
- })
- .exec(&*tx)
- .await?;
-
- result.last_insert_id
- };
-
- Ok(room_id)
- })
- .await
+ channel: &channel::Model,
+ tx: &DatabaseTransaction,
+ ) -> Result<Option<channel::Model>> {
+ let mut path = self.public_ancestors_including_self(channel, &*tx).await?;
+ if path.last().unwrap().id == channel.id {
+ path.pop();
+ }
+ Ok(path.pop())
}
- // Insert an edge from the given channel to the given other channel.
- pub async fn link_channel(
+ pub async fn public_ancestors_including_self(
&self,
- user: UserId,
- channel: ChannelId,
- to: ChannelId,
- ) -> Result<ChannelGraph> {
- self.transaction(|tx| async move {
- // Note that even with these maxed permissions, this linking operation
- // is still insecure because you can't remove someone's permissions to a
- // channel if they've linked the channel to one where they're an admin.
- self.check_user_is_channel_admin(channel, user, &*tx)
- .await?;
+ channel: &channel::Model,
+ tx: &DatabaseTransaction,
+ ) -> Result<Vec<channel::Model>> {
+ let visible_channels = channel::Entity::find()
+ .filter(channel::Column::Id.is_in(channel.ancestors_including_self()))
+ .filter(channel::Column::Visibility.eq(ChannelVisibility::Public))
+ .order_by_asc(channel::Column::ParentPath)
+ .all(&*tx)
+ .await?;
- self.link_channel_internal(user, channel, to, &*tx).await
- })
- .await
+ Ok(visible_channels)
}
- pub async fn link_channel_internal(
+ pub async fn channel_role_for_user(
&self,
- user: UserId,
- channel: ChannelId,
- to: ChannelId,
+ channel: &channel::Model,
+ user_id: UserId,
tx: &DatabaseTransaction,
- ) -> Result<ChannelGraph> {
- self.check_user_is_channel_admin(to, user, &*tx).await?;
+ ) -> Result<Option<ChannelRole>> {
+ #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+ enum QueryChannelMembership {
+ ChannelId,
+ Role,
+ Visibility,
+ }
- let paths = channel_path::Entity::find()
- .filter(channel_path::Column::IdPath.like(&format!("%/{}/%", channel)))
- .all(tx)
+ let mut rows = channel_member::Entity::find()
+ .left_join(channel::Entity)
+ .filter(
+ channel_member::Column::ChannelId
+ .is_in(channel.ancestors_including_self())
+ .and(channel_member::Column::UserId.eq(user_id))
+ .and(channel_member::Column::Accepted.eq(true)),
+ )
+ .select_only()
+ .column(channel_member::Column::ChannelId)
+ .column(channel_member::Column::Role)
+ .column(channel::Column::Visibility)
+ .into_values::<_, QueryChannelMembership>()
+ .stream(&*tx)
.await?;
- let mut new_path_suffixes = HashSet::default();
- for path in paths {
- if let Some(start_offset) = path.id_path.find(&format!("/{}/", channel)) {
- new_path_suffixes.insert((
- path.channel_id,
- path.id_path[(start_offset + 1)..].to_string(),
- ));
+ let mut user_role: Option<ChannelRole> = None;
+
+ let mut is_participant = false;
+ let mut current_channel_visibility = None;
+
+ // note these channels are not iterated in any particular order,
+ // our current logic takes the highest permission available.
+ while let Some(row) = rows.next().await {
+ let (membership_channel, role, visibility): (
+ ChannelId,
+ ChannelRole,
+ ChannelVisibility,
+ ) = row?;
+
+ match role {
+ ChannelRole::Admin | ChannelRole::Member | ChannelRole::Banned => {
+ if let Some(users_role) = user_role {
+ user_role = Some(users_role.max(role));
+ } else {
+ user_role = Some(role)
+ }
+ }
+ ChannelRole::Guest if visibility == ChannelVisibility::Public => {
+ is_participant = true
+ }
+ ChannelRole::Guest => {}
+ }
+ if channel.id == membership_channel {
+ current_channel_visibility = Some(visibility);
}
}
+ // free up database connection
+ drop(rows);
- let paths_to_new_parent = channel_path::Entity::find()
- .filter(channel_path::Column::ChannelId.eq(to))
- .all(tx)
- .await?;
-
- let mut new_paths = Vec::new();
- for path in paths_to_new_parent {
- if path.id_path.contains(&format!("/{}/", channel)) {
- Err(anyhow!("cycle"))?;
+ if is_participant && user_role.is_none() {
+ if current_channel_visibility.is_none() {
+ current_channel_visibility = channel::Entity::find()
+ .filter(channel::Column::Id.eq(channel.id))
+ .one(&*tx)
+ .await?
+ .map(|channel| channel.visibility);
+ }
+ if current_channel_visibility == Some(ChannelVisibility::Public) {
+ user_role = Some(ChannelRole::Guest);
}
-
- new_paths.extend(new_path_suffixes.iter().map(|(channel_id, path_suffix)| {
- channel_path::ActiveModel {
- channel_id: ActiveValue::Set(*channel_id),
- id_path: ActiveValue::Set(format!("{}{}", &path.id_path, path_suffix)),
- }
- }));
}
- channel_path::Entity::insert_many(new_paths)
- .exec(&*tx)
- .await?;
+ Ok(user_role)
+ }
- // remove any root edges for the channel we just linked
- {
- channel_path::Entity::delete_many()
- .filter(channel_path::Column::IdPath.like(&format!("/{}/%", channel)))
- .exec(&*tx)
- .await?;
+ // Get the descendants of the given set if channels, ordered by their
+ // path.
+ async fn get_channel_descendants_including_self(
+ &self,
+ channel_ids: impl IntoIterator<Item = ChannelId>,
+ tx: &DatabaseTransaction,
+ ) -> Result<Vec<channel::Model>> {
+ let mut values = String::new();
+ for id in channel_ids {
+ if !values.is_empty() {
+ values.push_str(", ");
+ }
+ write!(&mut values, "({})", id).unwrap();
}
- let mut channel_descendants = self.get_channel_descendants([channel], &*tx).await?;
- if let Some(channel) = channel_descendants.get_mut(&channel) {
- // Remove the other parents
- channel.clear();
- channel.insert(to);
+ if values.is_empty() {
+ return Ok(vec![]);
}
- let channels = self
- .get_channel_graph(channel_descendants, false, &*tx)
- .await?;
+ let sql = format!(
+ r#"
+ SELECT DISTINCT
+ descendant_channels.*,
+ descendant_channels.parent_path || descendant_channels.id as full_path
+ FROM
+ channels parent_channels, channels descendant_channels
+ WHERE
+ descendant_channels.id IN ({values}) OR
+ (
+ parent_channels.id IN ({values}) AND
+ descendant_channels.parent_path LIKE (parent_channels.parent_path || parent_channels.id || '/%')
+ )
+ ORDER BY
+ full_path ASC
+ "#
+ );
- Ok(channels)
+ Ok(channel::Entity::find()
+ .from_raw_sql(Statement::from_string(
+ self.pool.get_database_backend(),
+ sql,
+ ))
+ .all(tx)
+ .await?)
}
- /// Unlink a channel from a given parent. This will add in a root edge if
- /// the channel has no other parents after this operation.
- pub async fn unlink_channel(
- &self,
- user: UserId,
- channel: ChannelId,
- from: ChannelId,
- ) -> Result<()> {
+ /// Returns the channel with the given ID
+ pub async fn get_channel(&self, channel_id: ChannelId, user_id: UserId) -> Result<Channel> {
self.transaction(|tx| async move {
- // Note that even with these maxed permissions, this linking operation
- // is still insecure because you can't remove someone's permissions to a
- // channel if they've linked the channel to one where they're an admin.
- self.check_user_is_channel_admin(channel, user, &*tx)
+ let channel = self.get_channel_internal(channel_id, &*tx).await?;
+ let role = self
+ .check_user_is_channel_participant(&channel, user_id, &*tx)
.await?;
- self.unlink_channel_internal(user, channel, from, &*tx)
- .await?;
-
- Ok(())
+ Ok(Channel::from_model(channel, role))
})
.await
}
- pub async fn unlink_channel_internal(
+ pub async fn get_channel_internal(
&self,
- user: UserId,
- channel: ChannelId,
- from: ChannelId,
+ channel_id: ChannelId,
tx: &DatabaseTransaction,
- ) -> Result<()> {
- self.check_user_is_channel_admin(from, user, &*tx).await?;
+ ) -> Result<channel::Model> {
+ Ok(channel::Entity::find_by_id(channel_id)
+ .one(&*tx)
+ .await?
+ .ok_or_else(|| anyhow!("no such channel"))?)
+ }
- let sql = r#"
- DELETE FROM channel_paths
- WHERE
- id_path LIKE '%/' || $1 || '/' || $2 || '/%'
- RETURNING id_path, channel_id
- "#;
+ pub(crate) async fn get_or_create_channel_room(
+ &self,
+ channel_id: ChannelId,
+ live_kit_room: &str,
+ environment: &str,
+ tx: &DatabaseTransaction,
+ ) -> Result<RoomId> {
+ let room = room::Entity::find()
+ .filter(room::Column::ChannelId.eq(channel_id))
+ .one(&*tx)
+ .await?;
- let paths = channel_path::Entity::find()
- .from_raw_sql(Statement::from_sql_and_values(
- self.pool.get_database_backend(),
- sql,
- [from.to_proto().into(), channel.to_proto().into()],
- ))
- .all(&*tx)
+ let room_id = if let Some(room) = room {
+ if let Some(env) = room.enviroment {
+ if &env != environment {
+ Err(anyhow!("must join using the {} release", env))?;
+ }
+ }
+ room.id
+ } else {
+ let result = room::Entity::insert(room::ActiveModel {
+ channel_id: ActiveValue::Set(Some(channel_id)),
+ live_kit_room: ActiveValue::Set(live_kit_room.to_string()),
+ enviroment: ActiveValue::Set(Some(environment.to_string())),
+ ..Default::default()
+ })
+ .exec(&*tx)
.await?;
- let is_stranded = channel_path::Entity::find()
- .filter(channel_path::Column::ChannelId.eq(channel))
- .count(&*tx)
- .await?
- == 0;
-
- // Make sure that there is always at least one path to the channel
- if is_stranded {
- let root_paths: Vec<_> = paths
- .iter()
- .map(|path| {
- let start_offset = path.id_path.find(&format!("/{}/", channel)).unwrap();
- channel_path::ActiveModel {
- channel_id: ActiveValue::Set(path.channel_id),
- id_path: ActiveValue::Set(path.id_path[start_offset..].to_string()),
- }
- })
- .collect();
- channel_path::Entity::insert_many(root_paths)
- .exec(&*tx)
- .await?;
- }
+ result.last_insert_id
+ };
- Ok(())
+ Ok(room_id)
}
- /// Move a channel from one parent to another, returns the
- /// Channels that were moved for notifying clients
+ /// Move a channel from one parent to another
pub async fn move_channel(
&self,
- user: UserId,
- channel: ChannelId,
- from: ChannelId,
- to: ChannelId,
- ) -> Result<ChannelGraph> {
- if from == to {
- return Ok(ChannelGraph {
- channels: vec![],
- edges: vec![],
- });
- }
-
+ channel_id: ChannelId,
+ new_parent_id: Option<ChannelId>,
+ admin_id: UserId,
+ ) -> Result<Option<MoveChannelResult>> {
self.transaction(|tx| async move {
- self.check_user_is_channel_admin(channel, user, &*tx)
+ let channel = self.get_channel_internal(channel_id, &*tx).await?;
+ self.check_user_is_channel_admin(&channel, admin_id, &*tx)
.await?;
- let moved_channels = self.link_channel_internal(user, channel, to, &*tx).await?;
+ let new_parent_path;
+ let new_parent_channel;
+ if let Some(new_parent_id) = new_parent_id {
+ let new_parent = self.get_channel_internal(new_parent_id, &*tx).await?;
+ self.check_user_is_channel_admin(&new_parent, admin_id, &*tx)
+ .await?;
- self.unlink_channel_internal(user, channel, from, &*tx)
+ new_parent_path = new_parent.path();
+ new_parent_channel = Some(new_parent);
+ } else {
+ new_parent_path = String::new();
+ new_parent_channel = None;
+ };
+
+ let previous_participants = self
+ .get_channel_participant_details_internal(&channel, &*tx)
.await?;
- Ok(moved_channels)
- })
- .await
- }
-}
+ let old_path = format!("{}{}/", channel.parent_path, channel.id);
+ let new_path = format!("{}{}/", new_parent_path, channel.id);
-#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
-enum QueryUserIds {
- UserId,
-}
+ if old_path == new_path {
+ return Ok(None);
+ }
-#[derive(Debug)]
-pub struct ChannelGraph {
- pub channels: Vec<Channel>,
- pub edges: Vec<ChannelEdge>,
-}
+ let mut model = channel.into_active_model();
+ model.parent_path = ActiveValue::Set(new_parent_path);
+ let channel = model.update(&*tx).await?;
-impl ChannelGraph {
- pub fn is_empty(&self) -> bool {
- self.channels.is_empty() && self.edges.is_empty()
- }
-}
+ if new_parent_channel.is_none() {
+ channel_member::ActiveModel {
+ id: ActiveValue::NotSet,
+ channel_id: ActiveValue::Set(channel_id),
+ user_id: ActiveValue::Set(admin_id),
+ accepted: ActiveValue::Set(true),
+ role: ActiveValue::Set(ChannelRole::Admin),
+ }
+ .insert(&*tx)
+ .await?;
+ }
-#[cfg(test)]
-impl PartialEq for ChannelGraph {
- fn eq(&self, other: &Self) -> bool {
- // Order independent comparison for tests
- let channels_set = self.channels.iter().collect::<HashSet<_>>();
- let other_channels_set = other.channels.iter().collect::<HashSet<_>>();
- let edges_set = self
- .edges
- .iter()
- .map(|edge| (edge.channel_id, edge.parent_id))
- .collect::<HashSet<_>>();
- let other_edges_set = other
- .edges
- .iter()
- .map(|edge| (edge.channel_id, edge.parent_id))
- .collect::<HashSet<_>>();
-
- channels_set == other_channels_set && edges_set == other_edges_set
- }
-}
+ let descendent_ids =
+ ChannelId::find_by_statement::<QueryIds>(Statement::from_sql_and_values(
+ self.pool.get_database_backend(),
+ "
+ UPDATE channels SET parent_path = REPLACE(parent_path, $1, $2)
+ WHERE parent_path LIKE $3 || '%'
+ RETURNING id
+ ",
+ [old_path.clone().into(), new_path.into(), old_path.into()],
+ ))
+ .all(&*tx)
+ .await?;
-#[cfg(not(test))]
-impl PartialEq for ChannelGraph {
- fn eq(&self, other: &Self) -> bool {
- self.channels == other.channels && self.edges == other.edges
- }
-}
+ let participants_to_update: HashMap<_, _> = self
+ .participants_to_notify_for_channel_change(
+ new_parent_channel.as_ref().unwrap_or(&channel),
+ &*tx,
+ )
+ .await?
+ .into_iter()
+ .collect();
-struct SmallSet<T>(SmallVec<[T; 1]>);
+ let mut moved_channels: HashSet<ChannelId> = HashSet::default();
+ for id in descendent_ids {
+ moved_channels.insert(id);
+ }
+ moved_channels.insert(channel_id);
-impl<T> Deref for SmallSet<T> {
- type Target = [T];
+ let mut participants_to_remove: HashSet<UserId> = HashSet::default();
+ for participant in previous_participants {
+ if participant.kind == proto::channel_member::Kind::AncestorMember {
+ if !participants_to_update.contains_key(&participant.user_id) {
+ participants_to_remove.insert(participant.user_id);
+ }
+ }
+ }
- fn deref(&self) -> &Self::Target {
- self.0.deref()
+ Ok(Some(MoveChannelResult {
+ participants_to_remove,
+ participants_to_update,
+ moved_channels,
+ }))
+ })
+ .await
}
}
-impl<T> Default for SmallSet<T> {
- fn default() -> Self {
- Self(SmallVec::new())
- }
+#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+enum QueryIds {
+ Id,
}
-impl<T> SmallSet<T> {
- fn insert(&mut self, value: T) -> bool
- where
- T: Ord,
- {
- match self.binary_search(&value) {
- Ok(_) => false,
- Err(ix) => {
- self.0.insert(ix, value);
- true
- }
- }
- }
-
- fn clear(&mut self) {
- self.0.clear();
- }
+#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+enum QueryUserIds {
+ UserId,
}
@@ -8,7 +8,6 @@ impl Database {
user_id_b: UserId,
a_to_b: bool,
accepted: bool,
- should_notify: bool,
user_a_busy: bool,
user_b_busy: bool,
}
@@ -53,7 +52,6 @@ impl Database {
if db_contact.accepted {
contacts.push(Contact::Accepted {
user_id: db_contact.user_id_b,
- should_notify: db_contact.should_notify && db_contact.a_to_b,
busy: db_contact.user_b_busy,
});
} else if db_contact.a_to_b {
@@ -63,19 +61,16 @@ impl Database {
} else {
contacts.push(Contact::Incoming {
user_id: db_contact.user_id_b,
- should_notify: db_contact.should_notify,
});
}
} else if db_contact.accepted {
contacts.push(Contact::Accepted {
user_id: db_contact.user_id_a,
- should_notify: db_contact.should_notify && !db_contact.a_to_b,
busy: db_contact.user_a_busy,
});
} else if db_contact.a_to_b {
contacts.push(Contact::Incoming {
user_id: db_contact.user_id_a,
- should_notify: db_contact.should_notify,
});
} else {
contacts.push(Contact::Outgoing {
@@ -124,7 +119,11 @@ impl Database {
.await
}
- pub async fn send_contact_request(&self, sender_id: UserId, receiver_id: UserId) -> Result<()> {
+ pub async fn send_contact_request(
+ &self,
+ sender_id: UserId,
+ receiver_id: UserId,
+ ) -> Result<NotificationBatch> {
self.transaction(|tx| async move {
let (id_a, id_b, a_to_b) = if sender_id < receiver_id {
(sender_id, receiver_id, true)
@@ -161,11 +160,22 @@ impl Database {
.exec_without_returning(&*tx)
.await?;
- if rows_affected == 1 {
- Ok(())
- } else {
- Err(anyhow!("contact already requested"))?
+ if rows_affected == 0 {
+ Err(anyhow!("contact already requested"))?;
}
+
+ Ok(self
+ .create_notification(
+ receiver_id,
+ rpc::Notification::ContactRequest {
+ sender_id: sender_id.to_proto(),
+ },
+ true,
+ &*tx,
+ )
+ .await?
+ .into_iter()
+ .collect())
})
.await
}
@@ -179,7 +189,11 @@ impl Database {
///
/// * `requester_id` - The user that initiates this request
/// * `responder_id` - The user that will be removed
- pub async fn remove_contact(&self, requester_id: UserId, responder_id: UserId) -> Result<bool> {
+ pub async fn remove_contact(
+ &self,
+ requester_id: UserId,
+ responder_id: UserId,
+ ) -> Result<(bool, Option<NotificationId>)> {
self.transaction(|tx| async move {
let (id_a, id_b) = if responder_id < requester_id {
(responder_id, requester_id)
@@ -198,7 +212,21 @@ impl Database {
.ok_or_else(|| anyhow!("no such contact"))?;
contact::Entity::delete_by_id(contact.id).exec(&*tx).await?;
- Ok(contact.accepted)
+
+ let mut deleted_notification_id = None;
+ if !contact.accepted {
+ deleted_notification_id = self
+ .remove_notification(
+ responder_id,
+ rpc::Notification::ContactRequest {
+ sender_id: requester_id.to_proto(),
+ },
+ &*tx,
+ )
+ .await?;
+ }
+
+ Ok((contact.accepted, deleted_notification_id))
})
.await
}
@@ -249,7 +277,7 @@ impl Database {
responder_id: UserId,
requester_id: UserId,
accept: bool,
- ) -> Result<()> {
+ ) -> Result<NotificationBatch> {
self.transaction(|tx| async move {
let (id_a, id_b, a_to_b) = if responder_id < requester_id {
(responder_id, requester_id, false)
@@ -287,11 +315,38 @@ impl Database {
result.rows_affected
};
- if rows_affected == 1 {
- Ok(())
- } else {
+ if rows_affected == 0 {
Err(anyhow!("no such contact request"))?
}
+
+ let mut notifications = Vec::new();
+ notifications.extend(
+ self.mark_notification_as_read_with_response(
+ responder_id,
+ &rpc::Notification::ContactRequest {
+ sender_id: requester_id.to_proto(),
+ },
+ accept,
+ &*tx,
+ )
+ .await?,
+ );
+
+ if accept {
+ notifications.extend(
+ self.create_notification(
+ requester_id,
+ rpc::Notification::ContactRequestAccepted {
+ responder_id: responder_id.to_proto(),
+ },
+ true,
+ &*tx,
+ )
+ .await?,
+ );
+ }
+
+ Ok(notifications)
})
.await
}
@@ -1,4 +1,6 @@
use super::*;
+use rpc::Notification;
+use sea_orm::TryInsertResult;
use time::OffsetDateTime;
impl Database {
@@ -9,7 +11,8 @@ impl Database {
user_id: UserId,
) -> Result<()> {
self.transaction(|tx| async move {
- self.check_user_is_channel_member(channel_id, user_id, &*tx)
+ let channel = self.get_channel_internal(channel_id, &*tx).await?;
+ self.check_user_is_channel_participant(&channel, user_id, &*tx)
.await?;
channel_chat_participant::ActiveModel {
id: ActiveValue::NotSet,
@@ -77,7 +80,8 @@ impl Database {
before_message_id: Option<MessageId>,
) -> Result<Vec<proto::ChannelMessage>> {
self.transaction(|tx| async move {
- self.check_user_is_channel_member(channel_id, user_id, &*tx)
+ let channel = self.get_channel_internal(channel_id, &*tx).await?;
+ self.check_user_is_channel_participant(&channel, user_id, &*tx)
.await?;
let mut condition =
@@ -87,33 +91,103 @@ impl Database {
condition = condition.add(channel_message::Column::Id.lt(before_message_id));
}
- let mut rows = channel_message::Entity::find()
+ let rows = channel_message::Entity::find()
.filter(condition)
.order_by_desc(channel_message::Column::Id)
.limit(count as u64)
- .stream(&*tx)
+ .all(&*tx)
.await?;
- let mut messages = Vec::new();
- while let Some(row) = rows.next().await {
- let row = row?;
+ self.load_channel_messages(rows, &*tx).await
+ })
+ .await
+ }
+
+ pub async fn get_channel_messages_by_id(
+ &self,
+ user_id: UserId,
+ message_ids: &[MessageId],
+ ) -> Result<Vec<proto::ChannelMessage>> {
+ self.transaction(|tx| async move {
+ let rows = channel_message::Entity::find()
+ .filter(channel_message::Column::Id.is_in(message_ids.iter().copied()))
+ .order_by_desc(channel_message::Column::Id)
+ .all(&*tx)
+ .await?;
+
+ let mut channels = HashMap::<ChannelId, channel::Model>::default();
+ for row in &rows {
+ channels.insert(
+ row.channel_id,
+ self.get_channel_internal(row.channel_id, &*tx).await?,
+ );
+ }
+
+ for (_, channel) in channels {
+ self.check_user_is_channel_participant(&channel, user_id, &*tx)
+ .await?;
+ }
+
+ let messages = self.load_channel_messages(rows, &*tx).await?;
+ Ok(messages)
+ })
+ .await
+ }
+
+ async fn load_channel_messages(
+ &self,
+ rows: Vec<channel_message::Model>,
+ tx: &DatabaseTransaction,
+ ) -> Result<Vec<proto::ChannelMessage>> {
+ let mut messages = rows
+ .into_iter()
+ .map(|row| {
let nonce = row.nonce.as_u64_pair();
- messages.push(proto::ChannelMessage {
+ proto::ChannelMessage {
id: row.id.to_proto(),
sender_id: row.sender_id.to_proto(),
body: row.body,
timestamp: row.sent_at.assume_utc().unix_timestamp() as u64,
+ mentions: vec![],
nonce: Some(proto::Nonce {
upper_half: nonce.0,
lower_half: nonce.1,
}),
- });
+ }
+ })
+ .collect::<Vec<_>>();
+ messages.reverse();
+
+ let mut mentions = channel_message_mention::Entity::find()
+ .filter(channel_message_mention::Column::MessageId.is_in(messages.iter().map(|m| m.id)))
+ .order_by_asc(channel_message_mention::Column::MessageId)
+ .order_by_asc(channel_message_mention::Column::StartOffset)
+ .stream(&*tx)
+ .await?;
+
+ let mut message_ix = 0;
+ while let Some(mention) = mentions.next().await {
+ let mention = mention?;
+ let message_id = mention.message_id.to_proto();
+ while let Some(message) = messages.get_mut(message_ix) {
+ if message.id < message_id {
+ message_ix += 1;
+ } else {
+ if message.id == message_id {
+ message.mentions.push(proto::ChatMention {
+ range: Some(proto::Range {
+ start: mention.start_offset as u64,
+ end: mention.end_offset as u64,
+ }),
+ user_id: mention.user_id.to_proto(),
+ });
+ }
+ break;
+ }
}
- drop(rows);
- messages.reverse();
- Ok(messages)
- })
- .await
+ }
+
+ Ok(messages)
}
pub async fn create_channel_message(
@@ -121,10 +195,15 @@ impl Database {
channel_id: ChannelId,
user_id: UserId,
body: &str,
+ mentions: &[proto::ChatMention],
timestamp: OffsetDateTime,
nonce: u128,
- ) -> Result<(MessageId, Vec<ConnectionId>, Vec<UserId>)> {
+ ) -> Result<CreatedChannelMessage> {
self.transaction(|tx| async move {
+ let channel = self.get_channel_internal(channel_id, &*tx).await?;
+ self.check_user_is_channel_participant(&channel, user_id, &*tx)
+ .await?;
+
let mut rows = channel_chat_participant::Entity::find()
.filter(channel_chat_participant::Column::ChannelId.eq(channel_id))
.stream(&*tx)
@@ -150,7 +229,7 @@ impl Database {
let timestamp = timestamp.to_offset(time::UtcOffset::UTC);
let timestamp = time::PrimitiveDateTime::new(timestamp.date(), timestamp.time());
- let message = channel_message::Entity::insert(channel_message::ActiveModel {
+ let result = channel_message::Entity::insert(channel_message::ActiveModel {
channel_id: ActiveValue::Set(channel_id),
sender_id: ActiveValue::Set(user_id),
body: ActiveValue::Set(body.to_string()),
@@ -159,35 +238,85 @@ impl Database {
id: ActiveValue::NotSet,
})
.on_conflict(
- OnConflict::column(channel_message::Column::Nonce)
- .update_column(channel_message::Column::Nonce)
- .to_owned(),
+ OnConflict::columns([
+ channel_message::Column::SenderId,
+ channel_message::Column::Nonce,
+ ])
+ .do_nothing()
+ .to_owned(),
)
+ .do_nothing()
.exec(&*tx)
.await?;
- #[derive(Debug, Clone, Copy, EnumIter, DeriveColumn)]
- enum QueryConnectionId {
- ConnectionId,
+ let message_id;
+ let mut notifications = Vec::new();
+ match result {
+ TryInsertResult::Inserted(result) => {
+ message_id = result.last_insert_id;
+ let mentioned_user_ids =
+ mentions.iter().map(|m| m.user_id).collect::<HashSet<_>>();
+ let mentions = mentions
+ .iter()
+ .filter_map(|mention| {
+ let range = mention.range.as_ref()?;
+ if !body.is_char_boundary(range.start as usize)
+ || !body.is_char_boundary(range.end as usize)
+ {
+ return None;
+ }
+ Some(channel_message_mention::ActiveModel {
+ message_id: ActiveValue::Set(message_id),
+ start_offset: ActiveValue::Set(range.start as i32),
+ end_offset: ActiveValue::Set(range.end as i32),
+ user_id: ActiveValue::Set(UserId::from_proto(mention.user_id)),
+ })
+ })
+ .collect::<Vec<_>>();
+ if !mentions.is_empty() {
+ channel_message_mention::Entity::insert_many(mentions)
+ .exec(&*tx)
+ .await?;
+ }
+
+ for mentioned_user in mentioned_user_ids {
+ notifications.extend(
+ self.create_notification(
+ UserId::from_proto(mentioned_user),
+ rpc::Notification::ChannelMessageMention {
+ message_id: message_id.to_proto(),
+ sender_id: user_id.to_proto(),
+ channel_id: channel_id.to_proto(),
+ },
+ false,
+ &*tx,
+ )
+ .await?,
+ );
+ }
+
+ self.observe_channel_message_internal(channel_id, user_id, message_id, &*tx)
+ .await?;
+ }
+ _ => {
+ message_id = channel_message::Entity::find()
+ .filter(channel_message::Column::Nonce.eq(Uuid::from_u128(nonce)))
+ .one(&*tx)
+ .await?
+ .ok_or_else(|| anyhow!("failed to insert message"))?
+ .id;
+ }
}
- // Observe this message for the sender
- self.observe_channel_message_internal(
- channel_id,
- user_id,
- message.last_insert_id,
- &*tx,
- )
- .await?;
-
- let mut channel_members = self.get_channel_members_internal(channel_id, &*tx).await?;
+ let mut channel_members = self.get_channel_participants(&channel, &*tx).await?;
channel_members.retain(|member| !participant_user_ids.contains(member));
- Ok((
- message.last_insert_id,
+ Ok(CreatedChannelMessage {
+ message_id,
participant_connection_ids,
channel_members,
- ))
+ notifications,
+ })
})
.await
}
@@ -197,11 +326,24 @@ impl Database {
channel_id: ChannelId,
user_id: UserId,
message_id: MessageId,
- ) -> Result<()> {
+ ) -> Result<NotificationBatch> {
self.transaction(|tx| async move {
self.observe_channel_message_internal(channel_id, user_id, message_id, &*tx)
.await?;
- Ok(())
+ let mut batch = NotificationBatch::default();
+ batch.extend(
+ self.mark_notification_as_read(
+ user_id,
+ &Notification::ChannelMessageMention {
+ message_id: message_id.to_proto(),
+ sender_id: Default::default(),
+ channel_id: Default::default(),
+ },
+ &*tx,
+ )
+ .await?,
+ );
+ Ok(batch)
})
.await
}
@@ -337,8 +479,23 @@ impl Database {
.filter(channel_message::Column::SenderId.eq(user_id))
.exec(&*tx)
.await?;
+
if result.rows_affected == 0 {
- Err(anyhow!("no such message"))?;
+ let channel = self.get_channel_internal(channel_id, &*tx).await?;
+ if self
+ .check_user_is_channel_admin(&channel, user_id, &*tx)
+ .await
+ .is_ok()
+ {
+ let result = channel_message::Entity::delete_by_id(message_id)
+ .exec(&*tx)
+ .await?;
+ if result.rows_affected == 0 {
+ Err(anyhow!("no such message"))?;
+ }
+ } else {
+ Err(anyhow!("operation could not be completed"))?;
+ }
}
Ok(participant_connection_ids)
@@ -0,0 +1,262 @@
+use super::*;
+use rpc::Notification;
+
+impl Database {
+ pub async fn initialize_notification_kinds(&mut self) -> Result<()> {
+ notification_kind::Entity::insert_many(Notification::all_variant_names().iter().map(
+ |kind| notification_kind::ActiveModel {
+ name: ActiveValue::Set(kind.to_string()),
+ ..Default::default()
+ },
+ ))
+ .on_conflict(OnConflict::new().do_nothing().to_owned())
+ .exec_without_returning(&self.pool)
+ .await?;
+
+ let mut rows = notification_kind::Entity::find().stream(&self.pool).await?;
+ while let Some(row) = rows.next().await {
+ let row = row?;
+ self.notification_kinds_by_name.insert(row.name, row.id);
+ }
+
+ for name in Notification::all_variant_names() {
+ if let Some(id) = self.notification_kinds_by_name.get(*name).copied() {
+ self.notification_kinds_by_id.insert(id, name);
+ }
+ }
+
+ Ok(())
+ }
+
+ pub async fn get_notifications(
+ &self,
+ recipient_id: UserId,
+ limit: usize,
+ before_id: Option<NotificationId>,
+ ) -> Result<Vec<proto::Notification>> {
+ self.transaction(|tx| async move {
+ let mut result = Vec::new();
+ let mut condition =
+ Condition::all().add(notification::Column::RecipientId.eq(recipient_id));
+
+ if let Some(before_id) = before_id {
+ condition = condition.add(notification::Column::Id.lt(before_id));
+ }
+
+ let mut rows = notification::Entity::find()
+ .filter(condition)
+ .order_by_desc(notification::Column::Id)
+ .limit(limit as u64)
+ .stream(&*tx)
+ .await?;
+ while let Some(row) = rows.next().await {
+ let row = row?;
+ let kind = row.kind;
+ if let Some(proto) = model_to_proto(self, row) {
+ result.push(proto);
+ } else {
+ log::warn!("unknown notification kind {:?}", kind);
+ }
+ }
+ result.reverse();
+ Ok(result)
+ })
+ .await
+ }
+
+ /// Create a notification. If `avoid_duplicates` is set to true, then avoid
+ /// creating a new notification if the given recipient already has an
+ /// unread notification with the given kind and entity id.
+ pub async fn create_notification(
+ &self,
+ recipient_id: UserId,
+ notification: Notification,
+ avoid_duplicates: bool,
+ tx: &DatabaseTransaction,
+ ) -> Result<Option<(UserId, proto::Notification)>> {
+ if avoid_duplicates {
+ if self
+ .find_notification(recipient_id, ¬ification, tx)
+ .await?
+ .is_some()
+ {
+ return Ok(None);
+ }
+ }
+
+ let proto = notification.to_proto();
+ let kind = notification_kind_from_proto(self, &proto)?;
+ let model = notification::ActiveModel {
+ recipient_id: ActiveValue::Set(recipient_id),
+ kind: ActiveValue::Set(kind),
+ entity_id: ActiveValue::Set(proto.entity_id.map(|id| id as i32)),
+ content: ActiveValue::Set(proto.content.clone()),
+ ..Default::default()
+ }
+ .save(&*tx)
+ .await?;
+
+ Ok(Some((
+ recipient_id,
+ proto::Notification {
+ id: model.id.as_ref().to_proto(),
+ kind: proto.kind,
+ timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64,
+ is_read: false,
+ response: None,
+ content: proto.content,
+ entity_id: proto.entity_id,
+ },
+ )))
+ }
+
+ /// Remove an unread notification with the given recipient, kind and
+ /// entity id.
+ pub async fn remove_notification(
+ &self,
+ recipient_id: UserId,
+ notification: Notification,
+ tx: &DatabaseTransaction,
+ ) -> Result<Option<NotificationId>> {
+ let id = self
+ .find_notification(recipient_id, ¬ification, tx)
+ .await?;
+ if let Some(id) = id {
+ notification::Entity::delete_by_id(id).exec(tx).await?;
+ }
+ Ok(id)
+ }
+
+ /// Populate the response for the notification with the given kind and
+ /// entity id.
+ pub async fn mark_notification_as_read_with_response(
+ &self,
+ recipient_id: UserId,
+ notification: &Notification,
+ response: bool,
+ tx: &DatabaseTransaction,
+ ) -> Result<Option<(UserId, proto::Notification)>> {
+ self.mark_notification_as_read_internal(recipient_id, notification, Some(response), tx)
+ .await
+ }
+
+ pub async fn mark_notification_as_read(
+ &self,
+ recipient_id: UserId,
+ notification: &Notification,
+ tx: &DatabaseTransaction,
+ ) -> Result<Option<(UserId, proto::Notification)>> {
+ self.mark_notification_as_read_internal(recipient_id, notification, None, tx)
+ .await
+ }
+
+ pub async fn mark_notification_as_read_by_id(
+ &self,
+ recipient_id: UserId,
+ notification_id: NotificationId,
+ ) -> Result<NotificationBatch> {
+ self.transaction(|tx| async move {
+ let row = notification::Entity::update(notification::ActiveModel {
+ id: ActiveValue::Unchanged(notification_id),
+ recipient_id: ActiveValue::Unchanged(recipient_id),
+ is_read: ActiveValue::Set(true),
+ ..Default::default()
+ })
+ .exec(&*tx)
+ .await?;
+ Ok(model_to_proto(self, row)
+ .map(|notification| (recipient_id, notification))
+ .into_iter()
+ .collect())
+ })
+ .await
+ }
+
+ async fn mark_notification_as_read_internal(
+ &self,
+ recipient_id: UserId,
+ notification: &Notification,
+ response: Option<bool>,
+ tx: &DatabaseTransaction,
+ ) -> Result<Option<(UserId, proto::Notification)>> {
+ if let Some(id) = self
+ .find_notification(recipient_id, notification, &*tx)
+ .await?
+ {
+ let row = notification::Entity::update(notification::ActiveModel {
+ id: ActiveValue::Unchanged(id),
+ recipient_id: ActiveValue::Unchanged(recipient_id),
+ is_read: ActiveValue::Set(true),
+ response: if let Some(response) = response {
+ ActiveValue::Set(Some(response))
+ } else {
+ ActiveValue::NotSet
+ },
+ ..Default::default()
+ })
+ .exec(tx)
+ .await?;
+ Ok(model_to_proto(self, row).map(|notification| (recipient_id, notification)))
+ } else {
+ Ok(None)
+ }
+ }
+
+ /// Find an unread notification by its recipient, kind and entity id.
+ async fn find_notification(
+ &self,
+ recipient_id: UserId,
+ notification: &Notification,
+ tx: &DatabaseTransaction,
+ ) -> Result<Option<NotificationId>> {
+ let proto = notification.to_proto();
+ let kind = notification_kind_from_proto(self, &proto)?;
+
+ #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+ enum QueryIds {
+ Id,
+ }
+
+ Ok(notification::Entity::find()
+ .select_only()
+ .column(notification::Column::Id)
+ .filter(
+ Condition::all()
+ .add(notification::Column::RecipientId.eq(recipient_id))
+ .add(notification::Column::IsRead.eq(false))
+ .add(notification::Column::Kind.eq(kind))
+ .add(if proto.entity_id.is_some() {
+ notification::Column::EntityId.eq(proto.entity_id)
+ } else {
+ notification::Column::EntityId.is_null()
+ }),
+ )
+ .into_values::<_, QueryIds>()
+ .one(&*tx)
+ .await?)
+ }
+}
+
+fn model_to_proto(this: &Database, row: notification::Model) -> Option<proto::Notification> {
+ let kind = this.notification_kinds_by_id.get(&row.kind)?;
+ Some(proto::Notification {
+ id: row.id.to_proto(),
+ kind: kind.to_string(),
+ timestamp: row.created_at.assume_utc().unix_timestamp() as u64,
+ is_read: row.is_read,
+ response: row.response,
+ content: row.content,
+ entity_id: row.entity_id.map(|id| id as u64),
+ })
+}
+
+fn notification_kind_from_proto(
+ this: &Database,
+ proto: &proto::Notification,
+) -> Result<NotificationKindId> {
+ Ok(this
+ .notification_kinds_by_name
+ .get(&proto.kind)
+ .copied()
+ .ok_or_else(|| anyhow!("invalid notification kind {:?}", proto.kind))?)
+}
@@ -50,10 +50,10 @@ impl Database {
.map(|participant| participant.user_id),
);
- let (channel_id, room) = self.get_channel_room(room_id, &tx).await?;
+ let (channel, room) = self.get_channel_room(room_id, &tx).await?;
let channel_members;
- if let Some(channel_id) = channel_id {
- channel_members = self.get_channel_members_internal(channel_id, &tx).await?;
+ if let Some(channel) = &channel {
+ channel_members = self.get_channel_participants(channel, &tx).await?;
} else {
channel_members = Vec::new();
@@ -69,7 +69,7 @@ impl Database {
Ok(RefreshedRoom {
room,
- channel_id,
+ channel_id: channel.map(|channel| channel.id),
channel_members,
stale_participant_user_ids,
canceled_calls_to_user_ids,
@@ -298,98 +298,137 @@ impl Database {
}
}
- #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
- enum QueryParticipantIndices {
- ParticipantIndex,
- }
- let existing_participant_indices: Vec<i32> = room_participant::Entity::find()
- .filter(
- room_participant::Column::RoomId
- .eq(room_id)
- .and(room_participant::Column::ParticipantIndex.is_not_null()),
- )
- .select_only()
- .column(room_participant::Column::ParticipantIndex)
- .into_values::<_, QueryParticipantIndices>()
- .all(&*tx)
- .await?;
-
- let mut participant_index = 0;
- while existing_participant_indices.contains(&participant_index) {
- participant_index += 1;
+ if channel_id.is_some() {
+ Err(anyhow!("tried to join channel call directly"))?
}
- if let Some(channel_id) = channel_id {
- self.check_user_is_channel_member(channel_id, user_id, &*tx)
- .await?;
+ let participant_index = self
+ .get_next_participant_index_internal(room_id, &*tx)
+ .await?;
- room_participant::Entity::insert_many([room_participant::ActiveModel {
- room_id: ActiveValue::set(room_id),
- user_id: ActiveValue::set(user_id),
+ let result = room_participant::Entity::update_many()
+ .filter(
+ Condition::all()
+ .add(room_participant::Column::RoomId.eq(room_id))
+ .add(room_participant::Column::UserId.eq(user_id))
+ .add(room_participant::Column::AnsweringConnectionId.is_null()),
+ )
+ .set(room_participant::ActiveModel {
+ participant_index: ActiveValue::Set(Some(participant_index)),
answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
answering_connection_server_id: ActiveValue::set(Some(ServerId(
connection.owner_id as i32,
))),
answering_connection_lost: ActiveValue::set(false),
- calling_user_id: ActiveValue::set(user_id),
- calling_connection_id: ActiveValue::set(connection.id as i32),
- calling_connection_server_id: ActiveValue::set(Some(ServerId(
- connection.owner_id as i32,
- ))),
- participant_index: ActiveValue::Set(Some(participant_index)),
..Default::default()
- }])
- .on_conflict(
- OnConflict::columns([room_participant::Column::UserId])
- .update_columns([
- room_participant::Column::AnsweringConnectionId,
- room_participant::Column::AnsweringConnectionServerId,
- room_participant::Column::AnsweringConnectionLost,
- room_participant::Column::ParticipantIndex,
- ])
- .to_owned(),
- )
+ })
.exec(&*tx)
.await?;
- } else {
- let result = room_participant::Entity::update_many()
- .filter(
- Condition::all()
- .add(room_participant::Column::RoomId.eq(room_id))
- .add(room_participant::Column::UserId.eq(user_id))
- .add(room_participant::Column::AnsweringConnectionId.is_null()),
- )
- .set(room_participant::ActiveModel {
- participant_index: ActiveValue::Set(Some(participant_index)),
- answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
- answering_connection_server_id: ActiveValue::set(Some(ServerId(
- connection.owner_id as i32,
- ))),
- answering_connection_lost: ActiveValue::set(false),
- ..Default::default()
- })
- .exec(&*tx)
- .await?;
- if result.rows_affected == 0 {
- Err(anyhow!("room does not exist or was already joined"))?;
- }
+ if result.rows_affected == 0 {
+ Err(anyhow!("room does not exist or was already joined"))?;
}
let room = self.get_room(room_id, &tx).await?;
- let channel_members = if let Some(channel_id) = channel_id {
- self.get_channel_members_internal(channel_id, &tx).await?
- } else {
- Vec::new()
- };
Ok(JoinRoom {
room,
- channel_id,
- channel_members,
+ channel_id: None,
+ channel_members: vec![],
})
})
.await
}
+ async fn get_next_participant_index_internal(
+ &self,
+ room_id: RoomId,
+ tx: &DatabaseTransaction,
+ ) -> Result<i32> {
+ #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+ enum QueryParticipantIndices {
+ ParticipantIndex,
+ }
+ let existing_participant_indices: Vec<i32> = room_participant::Entity::find()
+ .filter(
+ room_participant::Column::RoomId
+ .eq(room_id)
+ .and(room_participant::Column::ParticipantIndex.is_not_null()),
+ )
+ .select_only()
+ .column(room_participant::Column::ParticipantIndex)
+ .into_values::<_, QueryParticipantIndices>()
+ .all(&*tx)
+ .await?;
+
+ let mut participant_index = 0;
+ while existing_participant_indices.contains(&participant_index) {
+ participant_index += 1;
+ }
+
+ Ok(participant_index)
+ }
+
+ pub async fn channel_id_for_room(&self, room_id: RoomId) -> Result<Option<ChannelId>> {
+ self.transaction(|tx| async move {
+ let room: Option<room::Model> = room::Entity::find()
+ .filter(room::Column::Id.eq(room_id))
+ .one(&*tx)
+ .await?;
+
+ Ok(room.and_then(|room| room.channel_id))
+ })
+ .await
+ }
+
+ pub(crate) async fn join_channel_room_internal(
+ &self,
+ room_id: RoomId,
+ user_id: UserId,
+ connection: ConnectionId,
+ tx: &DatabaseTransaction,
+ ) -> Result<JoinRoom> {
+ let participant_index = self
+ .get_next_participant_index_internal(room_id, &*tx)
+ .await?;
+
+ room_participant::Entity::insert_many([room_participant::ActiveModel {
+ room_id: ActiveValue::set(room_id),
+ user_id: ActiveValue::set(user_id),
+ answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
+ answering_connection_server_id: ActiveValue::set(Some(ServerId(
+ connection.owner_id as i32,
+ ))),
+ answering_connection_lost: ActiveValue::set(false),
+ calling_user_id: ActiveValue::set(user_id),
+ calling_connection_id: ActiveValue::set(connection.id as i32),
+ calling_connection_server_id: ActiveValue::set(Some(ServerId(
+ connection.owner_id as i32,
+ ))),
+ participant_index: ActiveValue::Set(Some(participant_index)),
+ ..Default::default()
+ }])
+ .on_conflict(
+ OnConflict::columns([room_participant::Column::UserId])
+ .update_columns([
+ room_participant::Column::AnsweringConnectionId,
+ room_participant::Column::AnsweringConnectionServerId,
+ room_participant::Column::AnsweringConnectionLost,
+ room_participant::Column::ParticipantIndex,
+ ])
+ .to_owned(),
+ )
+ .exec(&*tx)
+ .await?;
+
+ let (channel, room) = self.get_channel_room(room_id, &tx).await?;
+ let channel = channel.ok_or_else(|| anyhow!("no channel for room"))?;
+ let channel_members = self.get_channel_participants(&channel, &*tx).await?;
+ Ok(JoinRoom {
+ room,
+ channel_id: Some(channel.id),
+ channel_members,
+ })
+ }
+
pub async fn rejoin_room(
&self,
rejoin_room: proto::RejoinRoom,
@@ -679,16 +718,16 @@ impl Database {
});
}
- let (channel_id, room) = self.get_channel_room(room_id, &tx).await?;
- let channel_members = if let Some(channel_id) = channel_id {
- self.get_channel_members_internal(channel_id, &tx).await?
+ let (channel, room) = self.get_channel_room(room_id, &tx).await?;
+ let channel_members = if let Some(channel) = &channel {
+ self.get_channel_participants(&channel, &tx).await?
} else {
Vec::new()
};
Ok(RejoinedRoom {
room,
- channel_id,
+ channel_id: channel.map(|channel| channel.id),
channel_members,
rejoined_projects,
reshared_projects,
@@ -830,7 +869,7 @@ impl Database {
.exec(&*tx)
.await?;
- let (channel_id, room) = self.get_channel_room(room_id, &tx).await?;
+ let (channel, room) = self.get_channel_room(room_id, &tx).await?;
let deleted = if room.participants.is_empty() {
let result = room::Entity::delete_by_id(room_id).exec(&*tx).await?;
result.rows_affected > 0
@@ -838,14 +877,14 @@ impl Database {
false
};
- let channel_members = if let Some(channel_id) = channel_id {
- self.get_channel_members_internal(channel_id, &tx).await?
+ let channel_members = if let Some(channel) = &channel {
+ self.get_channel_participants(channel, &tx).await?
} else {
Vec::new()
};
let left_room = LeftRoom {
room,
- channel_id,
+ channel_id: channel.map(|channel| channel.id),
channel_members,
left_projects,
canceled_calls_to_user_ids,
@@ -1033,7 +1072,7 @@ impl Database {
&self,
room_id: RoomId,
tx: &DatabaseTransaction,
- ) -> Result<(Option<ChannelId>, proto::Room)> {
+ ) -> Result<(Option<channel::Model>, proto::Room)> {
let db_room = room::Entity::find_by_id(room_id)
.one(tx)
.await?
@@ -1142,9 +1181,16 @@ impl Database {
project_id: db_follower.project_id.to_proto(),
});
}
+ drop(db_followers);
+
+ let channel = if let Some(channel_id) = db_room.channel_id {
+ Some(self.get_channel_internal(channel_id, &*tx).await?)
+ } else {
+ None
+ };
Ok((
- db_room.channel_id,
+ channel,
proto::Room {
id: db_room.id.to_proto(),
live_kit_room: db_room.live_kit_room,
@@ -7,11 +7,13 @@ pub mod channel_buffer_collaborator;
pub mod channel_chat_participant;
pub mod channel_member;
pub mod channel_message;
-pub mod channel_path;
+pub mod channel_message_mention;
pub mod contact;
pub mod feature_flag;
pub mod follower;
pub mod language_server;
+pub mod notification;
+pub mod notification_kind;
pub mod observed_buffer_edits;
pub mod observed_channel_messages;
pub mod project;
@@ -1,4 +1,4 @@
-use crate::db::ChannelId;
+use crate::db::{ChannelId, ChannelVisibility};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
@@ -7,6 +7,29 @@ pub struct Model {
#[sea_orm(primary_key)]
pub id: ChannelId,
pub name: String,
+ pub visibility: ChannelVisibility,
+ pub parent_path: String,
+}
+
+impl Model {
+ pub fn parent_id(&self) -> Option<ChannelId> {
+ self.ancestors().last()
+ }
+
+ pub fn ancestors(&self) -> impl Iterator<Item = ChannelId> + '_ {
+ self.parent_path
+ .trim_end_matches('/')
+ .split('/')
+ .filter_map(|id| Some(ChannelId::from_proto(id.parse().ok()?)))
+ }
+
+ pub fn ancestors_including_self(&self) -> impl Iterator<Item = ChannelId> + '_ {
+ self.ancestors().chain(Some(self.id))
+ }
+
+ pub fn path(&self) -> String {
+ format!("{}{}/", self.parent_path, self.id)
+ }
}
impl ActiveModelBehavior for ActiveModel {}
@@ -1,7 +1,7 @@
-use crate::db::{channel_member, ChannelId, ChannelMemberId, UserId};
+use crate::db::{channel_member, ChannelId, ChannelMemberId, ChannelRole, UserId};
use sea_orm::entity::prelude::*;
-#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
+#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "channel_members")]
pub struct Model {
#[sea_orm(primary_key)]
@@ -9,7 +9,7 @@ pub struct Model {
pub channel_id: ChannelId,
pub user_id: UserId,
pub accepted: bool,
- pub admin: bool,
+ pub role: ChannelRole,
}
impl ActiveModelBehavior for ActiveModel {}
@@ -0,0 +1,43 @@
+use crate::db::{MessageId, UserId};
+use sea_orm::entity::prelude::*;
+
+#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
+#[sea_orm(table_name = "channel_message_mentions")]
+pub struct Model {
+ #[sea_orm(primary_key)]
+ pub message_id: MessageId,
+ #[sea_orm(primary_key)]
+ pub start_offset: i32,
+ pub end_offset: i32,
+ pub user_id: UserId,
+}
+
+impl ActiveModelBehavior for ActiveModel {}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+ #[sea_orm(
+ belongs_to = "super::channel_message::Entity",
+ from = "Column::MessageId",
+ to = "super::channel_message::Column::Id"
+ )]
+ Message,
+ #[sea_orm(
+ belongs_to = "super::user::Entity",
+ from = "Column::UserId",
+ to = "super::user::Column::Id"
+ )]
+ MentionedUser,
+}
+
+impl Related<super::channel::Entity> for Entity {
+ fn to() -> RelationDef {
+ Relation::Message.def()
+ }
+}
+
+impl Related<super::user::Entity> for Entity {
+ fn to() -> RelationDef {
+ Relation::MentionedUser.def()
+ }
+}
@@ -0,0 +1,29 @@
+use crate::db::{NotificationId, NotificationKindId, UserId};
+use sea_orm::entity::prelude::*;
+use time::PrimitiveDateTime;
+
+#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
+#[sea_orm(table_name = "notifications")]
+pub struct Model {
+ #[sea_orm(primary_key)]
+ pub id: NotificationId,
+ pub created_at: PrimitiveDateTime,
+ pub recipient_id: UserId,
+ pub kind: NotificationKindId,
+ pub entity_id: Option<i32>,
+ pub content: String,
+ pub is_read: bool,
+ pub response: Option<bool>,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+ #[sea_orm(
+ belongs_to = "super::user::Entity",
+ from = "Column::RecipientId",
+ to = "super::user::Column::Id"
+ )]
+ Recipient,
+}
+
+impl ActiveModelBehavior for ActiveModel {}
@@ -1,15 +1,15 @@
-use crate::db::ChannelId;
+use crate::db::NotificationKindId;
use sea_orm::entity::prelude::*;
-#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
-#[sea_orm(table_name = "channel_paths")]
+#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
+#[sea_orm(table_name = "notification_kinds")]
pub struct Model {
#[sea_orm(primary_key)]
- pub id_path: String,
- pub channel_id: ChannelId,
+ pub id: NotificationKindId,
+ pub name: String,
}
-impl ActiveModelBehavior for ActiveModel {}
-
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
+
+impl ActiveModelBehavior for ActiveModel {}
@@ -7,10 +7,12 @@ mod message_tests;
use super::*;
use gpui::executor::Background;
use parking_lot::Mutex;
-use rpc::proto::ChannelEdge;
use sea_orm::ConnectionTrait;
use sqlx::migrate::MigrateDatabase;
-use std::sync::Arc;
+use std::sync::{
+ atomic::{AtomicI32, AtomicU32, Ordering::SeqCst},
+ Arc,
+};
const TEST_RELEASE_CHANNEL: &'static str = "test";
@@ -31,7 +33,7 @@ impl TestDb {
let mut db = runtime.block_on(async {
let mut options = ConnectOptions::new(url);
options.max_connections(5);
- let db = Database::new(options, Executor::Deterministic(background))
+ let mut db = Database::new(options, Executor::Deterministic(background))
.await
.unwrap();
let sql = include_str!(concat!(
@@ -45,6 +47,7 @@ impl TestDb {
))
.await
.unwrap();
+ db.initialize_notification_kinds().await.unwrap();
db
});
@@ -79,11 +82,12 @@ impl TestDb {
options
.max_connections(5)
.idle_timeout(Duration::from_secs(0));
- let db = Database::new(options, Executor::Deterministic(background))
+ let mut db = Database::new(options, Executor::Deterministic(background))
.await
.unwrap();
let migrations_path = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations");
db.migrate(Path::new(migrations_path), false).await.unwrap();
+ db.initialize_notification_kinds().await.unwrap();
db
});
@@ -148,26 +152,39 @@ impl Drop for TestDb {
}
}
-/// The second tuples are (channel_id, parent)
-fn graph(channels: &[(ChannelId, &'static str)], edges: &[(ChannelId, ChannelId)]) -> ChannelGraph {
- let mut graph = ChannelGraph {
- channels: vec![],
- edges: vec![],
- };
-
- for (id, name) in channels {
- graph.channels.push(Channel {
+fn channel_tree(channels: &[(ChannelId, &[ChannelId], &'static str, ChannelRole)]) -> Vec<Channel> {
+ channels
+ .iter()
+ .map(|(id, parent_path, name, role)| Channel {
id: *id,
name: name.to_string(),
+ visibility: ChannelVisibility::Members,
+ role: *role,
+ parent_path: parent_path.to_vec(),
})
- }
+ .collect()
+}
- for (channel, parent) in edges {
- graph.edges.push(ChannelEdge {
- channel_id: channel.to_proto(),
- parent_id: parent.to_proto(),
- })
- }
+static GITHUB_USER_ID: AtomicI32 = AtomicI32::new(5);
+
+async fn new_test_user(db: &Arc<Database>, email: &str) -> UserId {
+ db.create_user(
+ email,
+ false,
+ NewUserParams {
+ github_login: email[0..email.find("@").unwrap()].to_string(),
+ github_user_id: GITHUB_USER_ID.fetch_add(1, SeqCst),
+ },
+ )
+ .await
+ .unwrap()
+ .user_id
+}
- graph
+static TEST_CONNECTION_ID: AtomicU32 = AtomicU32::new(1);
+fn new_test_connection(server: ServerId) -> ConnectionId {
+ ConnectionId {
+ id: TEST_CONNECTION_ID.fetch_add(1, SeqCst),
+ owner_id: server.0 as u32,
+ }
}
@@ -17,7 +17,6 @@ async fn test_channel_buffers(db: &Arc<Database>) {
NewUserParams {
github_login: "user_a".into(),
github_user_id: 101,
- invite_count: 0,
},
)
.await
@@ -30,7 +29,6 @@ async fn test_channel_buffers(db: &Arc<Database>) {
NewUserParams {
github_login: "user_b".into(),
github_user_id: 102,
- invite_count: 0,
},
)
.await
@@ -45,7 +43,6 @@ async fn test_channel_buffers(db: &Arc<Database>) {
NewUserParams {
github_login: "user_c".into(),
github_user_id: 102,
- invite_count: 0,
},
)
.await
@@ -56,7 +53,7 @@ async fn test_channel_buffers(db: &Arc<Database>) {
let zed_id = db.create_root_channel("zed", a_id).await.unwrap();
- db.invite_channel_member(zed_id, b_id, a_id, false)
+ db.invite_channel_member(zed_id, b_id, a_id, ChannelRole::Member)
.await
.unwrap();
@@ -178,7 +175,6 @@ async fn test_channel_buffers_last_operations(db: &Database) {
NewUserParams {
github_login: "user_a".into(),
github_user_id: 101,
- invite_count: 0,
},
)
.await
@@ -191,7 +187,6 @@ async fn test_channel_buffers_last_operations(db: &Database) {
NewUserParams {
github_login: "user_b".into(),
github_user_id: 102,
- invite_count: 0,
},
)
.await
@@ -211,7 +206,7 @@ async fn test_channel_buffers_last_operations(db: &Database) {
.await
.unwrap();
- db.invite_channel_member(channel, observer_id, user_id, false)
+ db.invite_channel_member(channel, observer_id, user_id, ChannelRole::Member)
.await
.unwrap();
db.respond_to_channel_invite(channel, observer_id, true)
@@ -1,56 +1,28 @@
-use collections::{HashMap, HashSet};
-use rpc::{
- proto::{self},
- ConnectionId,
-};
-
use crate::{
db::{
- queries::channels::ChannelGraph,
- tests::{graph, TEST_RELEASE_CHANNEL},
- ChannelId, Database, NewUserParams,
+ tests::{channel_tree, new_test_connection, new_test_user, TEST_RELEASE_CHANNEL},
+ Channel, ChannelId, ChannelRole, Database, NewUserParams, RoomId,
},
test_both_dbs,
};
+use rpc::{
+ proto::{self},
+ ConnectionId,
+};
use std::sync::Arc;
test_both_dbs!(test_channels, test_channels_postgres, test_channels_sqlite);
async fn test_channels(db: &Arc<Database>) {
- let a_id = db
- .create_user(
- "user1@example.com",
- false,
- NewUserParams {
- github_login: "user1".into(),
- github_user_id: 5,
- invite_count: 0,
- },
- )
- .await
- .unwrap()
- .user_id;
-
- let b_id = db
- .create_user(
- "user2@example.com",
- false,
- NewUserParams {
- github_login: "user2".into(),
- github_user_id: 6,
- invite_count: 0,
- },
- )
- .await
- .unwrap()
- .user_id;
+ let a_id = new_test_user(db, "user1@example.com").await;
+ let b_id = new_test_user(db, "user2@example.com").await;
let zed_id = db.create_root_channel("zed", a_id).await.unwrap();
// Make sure that people cannot read channels they haven't been invited to
- assert!(db.get_channel(zed_id, b_id).await.unwrap().is_none());
+ assert!(db.get_channel(zed_id, b_id).await.is_err());
- db.invite_channel_member(zed_id, b_id, a_id, false)
+ db.invite_channel_member(zed_id, b_id, a_id, ChannelRole::Member)
.await
.unwrap();
@@ -58,99 +30,103 @@ async fn test_channels(db: &Arc<Database>) {
.await
.unwrap();
- let crdb_id = db.create_channel("crdb", Some(zed_id), a_id).await.unwrap();
+ let crdb_id = db.create_sub_channel("crdb", zed_id, a_id).await.unwrap();
let livestreaming_id = db
- .create_channel("livestreaming", Some(zed_id), a_id)
+ .create_sub_channel("livestreaming", zed_id, a_id)
.await
.unwrap();
let replace_id = db
- .create_channel("replace", Some(zed_id), a_id)
+ .create_sub_channel("replace", zed_id, a_id)
.await
.unwrap();
- let mut members = db.get_channel_members(replace_id).await.unwrap();
+ let mut members = db
+ .transaction(|tx| async move {
+ let channel = db.get_channel_internal(replace_id, &*tx).await?;
+ Ok(db.get_channel_participants(&channel, &*tx).await?)
+ })
+ .await
+ .unwrap();
members.sort();
assert_eq!(members, &[a_id, b_id]);
let rust_id = db.create_root_channel("rust", a_id).await.unwrap();
- let cargo_id = db
- .create_channel("cargo", Some(rust_id), a_id)
- .await
- .unwrap();
+ let cargo_id = db.create_sub_channel("cargo", rust_id, a_id).await.unwrap();
let cargo_ra_id = db
- .create_channel("cargo-ra", Some(cargo_id), a_id)
+ .create_sub_channel("cargo-ra", cargo_id, a_id)
.await
.unwrap();
let result = db.get_channels_for_user(a_id).await.unwrap();
assert_eq!(
result.channels,
- graph(
- &[
- (zed_id, "zed"),
- (crdb_id, "crdb"),
- (livestreaming_id, "livestreaming"),
- (replace_id, "replace"),
- (rust_id, "rust"),
- (cargo_id, "cargo"),
- (cargo_ra_id, "cargo-ra")
- ],
- &[
- (crdb_id, zed_id),
- (livestreaming_id, zed_id),
- (replace_id, zed_id),
- (cargo_id, rust_id),
- (cargo_ra_id, cargo_id),
- ]
- )
+ channel_tree(&[
+ (zed_id, &[], "zed", ChannelRole::Admin),
+ (crdb_id, &[zed_id], "crdb", ChannelRole::Admin),
+ (
+ livestreaming_id,
+ &[zed_id],
+ "livestreaming",
+ ChannelRole::Admin
+ ),
+ (replace_id, &[zed_id], "replace", ChannelRole::Admin),
+ (rust_id, &[], "rust", ChannelRole::Admin),
+ (cargo_id, &[rust_id], "cargo", ChannelRole::Admin),
+ (
+ cargo_ra_id,
+ &[rust_id, cargo_id],
+ "cargo-ra",
+ ChannelRole::Admin
+ )
+ ],)
);
let result = db.get_channels_for_user(b_id).await.unwrap();
assert_eq!(
result.channels,
- graph(
- &[
- (zed_id, "zed"),
- (crdb_id, "crdb"),
- (livestreaming_id, "livestreaming"),
- (replace_id, "replace")
- ],
- &[
- (crdb_id, zed_id),
- (livestreaming_id, zed_id),
- (replace_id, zed_id)
- ]
- )
+ channel_tree(&[
+ (zed_id, &[], "zed", ChannelRole::Member),
+ (crdb_id, &[zed_id], "crdb", ChannelRole::Member),
+ (
+ livestreaming_id,
+ &[zed_id],
+ "livestreaming",
+ ChannelRole::Member
+ ),
+ (replace_id, &[zed_id], "replace", ChannelRole::Member)
+ ],)
);
// Update member permissions
- let set_subchannel_admin = db.set_channel_member_admin(crdb_id, a_id, b_id, true).await;
+ let set_subchannel_admin = db
+ .set_channel_member_role(crdb_id, a_id, b_id, ChannelRole::Admin)
+ .await;
assert!(set_subchannel_admin.is_err());
- let set_channel_admin = db.set_channel_member_admin(zed_id, a_id, b_id, true).await;
+ let set_channel_admin = db
+ .set_channel_member_role(zed_id, a_id, b_id, ChannelRole::Admin)
+ .await;
assert!(set_channel_admin.is_ok());
let result = db.get_channels_for_user(b_id).await.unwrap();
assert_eq!(
result.channels,
- graph(
- &[
- (zed_id, "zed"),
- (crdb_id, "crdb"),
- (livestreaming_id, "livestreaming"),
- (replace_id, "replace")
- ],
- &[
- (crdb_id, zed_id),
- (livestreaming_id, zed_id),
- (replace_id, zed_id)
- ]
- )
+ channel_tree(&[
+ (zed_id, &[], "zed", ChannelRole::Admin),
+ (crdb_id, &[zed_id], "crdb", ChannelRole::Admin),
+ (
+ livestreaming_id,
+ &[zed_id],
+ "livestreaming",
+ ChannelRole::Admin
+ ),
+ (replace_id, &[zed_id], "replace", ChannelRole::Admin)
+ ],)
);
// Remove a single channel
db.delete_channel(crdb_id, a_id).await.unwrap();
- assert!(db.get_channel(crdb_id, a_id).await.unwrap().is_none());
+ assert!(db.get_channel(crdb_id, a_id).await.is_err());
// Remove a channel tree
let (mut channel_ids, user_ids) = db.delete_channel(rust_id, a_id).await.unwrap();
@@ -158,9 +134,9 @@ async fn test_channels(db: &Arc<Database>) {
assert_eq!(channel_ids, &[rust_id, cargo_id, cargo_ra_id]);
assert_eq!(user_ids, &[a_id]);
- assert!(db.get_channel(rust_id, a_id).await.unwrap().is_none());
- assert!(db.get_channel(cargo_id, a_id).await.unwrap().is_none());
- assert!(db.get_channel(cargo_ra_id, a_id).await.unwrap().is_none());
+ assert!(db.get_channel(rust_id, a_id).await.is_err());
+ assert!(db.get_channel(cargo_id, a_id).await.is_err());
+ assert!(db.get_channel(cargo_ra_id, a_id).await.is_err());
}
test_both_dbs!(
@@ -172,43 +148,15 @@ test_both_dbs!(
async fn test_joining_channels(db: &Arc<Database>) {
let owner_id = db.create_server("test").await.unwrap().0 as u32;
- let user_1 = db
- .create_user(
- "user1@example.com",
- false,
- NewUserParams {
- github_login: "user1".into(),
- github_user_id: 5,
- invite_count: 0,
- },
- )
- .await
- .unwrap()
- .user_id;
- let user_2 = db
- .create_user(
- "user2@example.com",
- false,
- NewUserParams {
- github_login: "user2".into(),
- github_user_id: 6,
- invite_count: 0,
- },
- )
- .await
- .unwrap()
- .user_id;
+ let user_1 = new_test_user(db, "user1@example.com").await;
+ let user_2 = new_test_user(db, "user2@example.com").await;
let channel_1 = db.create_root_channel("channel_1", user_1).await.unwrap();
- let room_1 = db
- .get_or_create_channel_room(channel_1, "1", TEST_RELEASE_CHANNEL)
- .await
- .unwrap();
// can join a room with membership to its channel
- let joined_room = db
- .join_room(
- room_1,
+ let (joined_room, _, _) = db
+ .join_channel(
+ channel_1,
user_1,
ConnectionId { owner_id, id: 1 },
TEST_RELEASE_CHANNEL,
@@ -217,11 +165,12 @@ async fn test_joining_channels(db: &Arc<Database>) {
.unwrap();
assert_eq!(joined_room.room.participants.len(), 1);
+ let room_id = RoomId::from_proto(joined_room.room.id);
drop(joined_room);
// cannot join a room without membership to its channel
assert!(db
.join_room(
- room_1,
+ room_id,
user_2,
ConnectionId { owner_id, id: 1 },
TEST_RELEASE_CHANNEL
@@ -239,58 +188,21 @@ test_both_dbs!(
async fn test_channel_invites(db: &Arc<Database>) {
db.create_server("test").await.unwrap();
- let user_1 = db
- .create_user(
- "user1@example.com",
- false,
- NewUserParams {
- github_login: "user1".into(),
- github_user_id: 5,
- invite_count: 0,
- },
- )
- .await
- .unwrap()
- .user_id;
- let user_2 = db
- .create_user(
- "user2@example.com",
- false,
- NewUserParams {
- github_login: "user2".into(),
- github_user_id: 6,
- invite_count: 0,
- },
- )
- .await
- .unwrap()
- .user_id;
-
- let user_3 = db
- .create_user(
- "user3@example.com",
- false,
- NewUserParams {
- github_login: "user3".into(),
- github_user_id: 7,
- invite_count: 0,
- },
- )
- .await
- .unwrap()
- .user_id;
+ let user_1 = new_test_user(db, "user1@example.com").await;
+ let user_2 = new_test_user(db, "user2@example.com").await;
+ let user_3 = new_test_user(db, "user3@example.com").await;
let channel_1_1 = db.create_root_channel("channel_1", user_1).await.unwrap();
let channel_1_2 = db.create_root_channel("channel_2", user_1).await.unwrap();
- db.invite_channel_member(channel_1_1, user_2, user_1, false)
+ db.invite_channel_member(channel_1_1, user_2, user_1, ChannelRole::Member)
.await
.unwrap();
- db.invite_channel_member(channel_1_2, user_2, user_1, false)
+ db.invite_channel_member(channel_1_2, user_2, user_1, ChannelRole::Member)
.await
.unwrap();
- db.invite_channel_member(channel_1_1, user_3, user_1, true)
+ db.invite_channel_member(channel_1_1, user_3, user_1, ChannelRole::Admin)
.await
.unwrap();
@@ -314,27 +226,29 @@ async fn test_channel_invites(db: &Arc<Database>) {
assert_eq!(user_3_invites, &[channel_1_1]);
- let members = db
- .get_channel_member_details(channel_1_1, user_1)
+ let mut members = db
+ .get_channel_participant_details(channel_1_1, user_1)
.await
.unwrap();
+
+ members.sort_by_key(|member| member.user_id);
assert_eq!(
members,
&[
proto::ChannelMember {
user_id: user_1.to_proto(),
kind: proto::channel_member::Kind::Member.into(),
- admin: true,
+ role: proto::ChannelRole::Admin.into(),
},
proto::ChannelMember {
user_id: user_2.to_proto(),
kind: proto::channel_member::Kind::Invitee.into(),
- admin: false,
+ role: proto::ChannelRole::Member.into(),
},
proto::ChannelMember {
user_id: user_3.to_proto(),
kind: proto::channel_member::Kind::Invitee.into(),
- admin: true,
+ role: proto::ChannelRole::Admin.into(),
},
]
);
@@ -344,12 +258,12 @@ async fn test_channel_invites(db: &Arc<Database>) {
.unwrap();
let channel_1_3 = db
- .create_channel("channel_3", Some(channel_1_1), user_1)
+ .create_sub_channel("channel_3", channel_1_1, user_1)
.await
.unwrap();
let members = db
- .get_channel_member_details(channel_1_3, user_1)
+ .get_channel_participant_details(channel_1_3, user_1)
.await
.unwrap();
assert_eq!(
@@ -357,13 +271,13 @@ async fn test_channel_invites(db: &Arc<Database>) {
&[
proto::ChannelMember {
user_id: user_1.to_proto(),
- kind: proto::channel_member::Kind::Member.into(),
- admin: true,
+ kind: proto::channel_member::Kind::AncestorMember.into(),
+ role: proto::ChannelRole::Admin.into(),
},
proto::ChannelMember {
user_id: user_2.to_proto(),
kind: proto::channel_member::Kind::AncestorMember.into(),
- admin: false,
+ role: proto::ChannelRole::Member.into(),
},
]
);
@@ -385,7 +299,6 @@ async fn test_channel_renames(db: &Arc<Database>) {
NewUserParams {
github_login: "user1".into(),
github_user_id: 5,
- invite_count: 0,
},
)
.await
@@ -399,7 +312,6 @@ async fn test_channel_renames(db: &Arc<Database>) {
NewUserParams {
github_login: "user2".into(),
github_user_id: 6,
- invite_count: 0,
},
)
.await
@@ -412,18 +324,10 @@ async fn test_channel_renames(db: &Arc<Database>) {
.await
.unwrap();
- let zed_archive_id = zed_id;
-
- let (channel, _) = db
- .get_channel(zed_archive_id, user_1)
- .await
- .unwrap()
- .unwrap();
+ let channel = db.get_channel(zed_id, user_1).await.unwrap();
assert_eq!(channel.name, "zed-archive");
- let non_permissioned_rename = db
- .rename_channel(zed_archive_id, user_2, "hacked-lol")
- .await;
+ let non_permissioned_rename = db.rename_channel(zed_id, user_2, "hacked-lol").await;
assert!(non_permissioned_rename.is_err());
let bad_name_rename = db.rename_channel(zed_id, user_1, "#").await;
@@ -444,7 +348,6 @@ async fn test_db_channel_moving(db: &Arc<Database>) {
NewUserParams {
github_login: "user1".into(),
github_user_id: 5,
- invite_count: 0,
},
)
.await
@@ -453,20 +356,17 @@ async fn test_db_channel_moving(db: &Arc<Database>) {
let zed_id = db.create_root_channel("zed", a_id).await.unwrap();
- let crdb_id = db.create_channel("crdb", Some(zed_id), a_id).await.unwrap();
+ let crdb_id = db.create_sub_channel("crdb", zed_id, a_id).await.unwrap();
- let gpui2_id = db
- .create_channel("gpui2", Some(zed_id), a_id)
- .await
- .unwrap();
+ let gpui2_id = db.create_sub_channel("gpui2", zed_id, a_id).await.unwrap();
let livestreaming_id = db
- .create_channel("livestreaming", Some(crdb_id), a_id)
+ .create_sub_channel("livestreaming", crdb_id, a_id)
.await
.unwrap();
let livestreaming_dag_id = db
- .create_channel("livestreaming_dag", Some(livestreaming_id), a_id)
+ .create_sub_channel("livestreaming_dag", livestreaming_id, a_id)
.await
.unwrap();
@@ -476,397 +376,444 @@ async fn test_db_channel_moving(db: &Arc<Database>) {
// /- gpui2
// zed -- crdb - livestreaming - livestreaming_dag
let result = db.get_channels_for_user(a_id).await.unwrap();
- assert_dag(
+ assert_channel_tree(
result.channels,
&[
- (zed_id, None),
- (crdb_id, Some(zed_id)),
- (gpui2_id, Some(zed_id)),
- (livestreaming_id, Some(crdb_id)),
- (livestreaming_dag_id, Some(livestreaming_id)),
+ (zed_id, &[]),
+ (crdb_id, &[zed_id]),
+ (livestreaming_id, &[zed_id, crdb_id]),
+ (livestreaming_dag_id, &[zed_id, crdb_id, livestreaming_id]),
+ (gpui2_id, &[zed_id]),
],
);
+}
- // Attempt to make a cycle
- assert!(db
- .link_channel(a_id, zed_id, livestreaming_id)
- .await
- .is_err());
+test_both_dbs!(
+ test_db_channel_moving_bugs,
+ test_db_channel_moving_bugs_postgres,
+ test_db_channel_moving_bugs_sqlite
+);
- // ========================================================================
- // Make a link
- db.link_channel(a_id, livestreaming_id, zed_id)
+async fn test_db_channel_moving_bugs(db: &Arc<Database>) {
+ let user_id = db
+ .create_user(
+ "user1@example.com",
+ false,
+ NewUserParams {
+ github_login: "user1".into(),
+ github_user_id: 5,
+ },
+ )
.await
- .unwrap();
+ .unwrap()
+ .user_id;
- // DAG is now:
- // /- gpui2
- // zed -- crdb - livestreaming - livestreaming_dag
- // \---------/
- let result = db.get_channels_for_user(a_id).await.unwrap();
- assert_dag(
- result.channels,
- &[
- (zed_id, None),
- (crdb_id, Some(zed_id)),
- (gpui2_id, Some(zed_id)),
- (livestreaming_id, Some(zed_id)),
- (livestreaming_id, Some(crdb_id)),
- (livestreaming_dag_id, Some(livestreaming_id)),
- ],
- );
+ let zed_id = db.create_root_channel("zed", user_id).await.unwrap();
- // ========================================================================
- // Create a new channel below a channel with multiple parents
- let livestreaming_dag_sub_id = db
- .create_channel("livestreaming_dag_sub", Some(livestreaming_dag_id), a_id)
+ let projects_id = db
+ .create_sub_channel("projects", zed_id, user_id)
.await
.unwrap();
- // DAG is now:
- // /- gpui2
- // zed -- crdb - livestreaming - livestreaming_dag - livestreaming_dag_sub_id
- // \---------/
- let result = db.get_channels_for_user(a_id).await.unwrap();
- assert_dag(
- result.channels,
- &[
- (zed_id, None),
- (crdb_id, Some(zed_id)),
- (gpui2_id, Some(zed_id)),
- (livestreaming_id, Some(zed_id)),
- (livestreaming_id, Some(crdb_id)),
- (livestreaming_dag_id, Some(livestreaming_id)),
- (livestreaming_dag_sub_id, Some(livestreaming_dag_id)),
- ],
- );
-
- // ========================================================================
- // Test a complex DAG by making another link
- let returned_channels = db
- .link_channel(a_id, livestreaming_dag_sub_id, livestreaming_id)
+ let livestreaming_id = db
+ .create_sub_channel("livestreaming", projects_id, user_id)
.await
.unwrap();
- // DAG is now:
- // /- gpui2 /---------------------\
- // zed - crdb - livestreaming - livestreaming_dag - livestreaming_dag_sub_id
- // \--------/
+ // Dag is: zed - projects - livestreaming
+
+ // Move to same parent should be a no-op
+ assert!(db
+ .move_channel(projects_id, Some(zed_id), user_id)
+ .await
+ .unwrap()
+ .is_none());
- // make sure we're getting just the new link
- // Not using the assert_dag helper because we want to make sure we're returning the full data
- pretty_assertions::assert_eq!(
- returned_channels,
- graph(
- &[(livestreaming_dag_sub_id, "livestreaming_dag_sub")],
- &[(livestreaming_dag_sub_id, livestreaming_id)]
- )
+ let result = db.get_channels_for_user(user_id).await.unwrap();
+ assert_channel_tree(
+ result.channels,
+ &[
+ (zed_id, &[]),
+ (projects_id, &[zed_id]),
+ (livestreaming_id, &[zed_id, projects_id]),
+ ],
);
- let result = db.get_channels_for_user(a_id).await.unwrap();
- assert_dag(
+ // Move the project channel to the root
+ db.move_channel(projects_id, None, user_id).await.unwrap();
+ let result = db.get_channels_for_user(user_id).await.unwrap();
+ assert_channel_tree(
result.channels,
&[
- (zed_id, None),
- (crdb_id, Some(zed_id)),
- (gpui2_id, Some(zed_id)),
- (livestreaming_id, Some(zed_id)),
- (livestreaming_id, Some(crdb_id)),
- (livestreaming_dag_id, Some(livestreaming_id)),
- (livestreaming_dag_sub_id, Some(livestreaming_id)),
- (livestreaming_dag_sub_id, Some(livestreaming_dag_id)),
+ (zed_id, &[]),
+ (projects_id, &[]),
+ (livestreaming_id, &[projects_id]),
],
);
+}
- // ========================================================================
- // Test a complex DAG by making another link
- let returned_channels = db
- .link_channel(a_id, livestreaming_id, gpui2_id)
+test_both_dbs!(
+ test_user_is_channel_participant,
+ test_user_is_channel_participant_postgres,
+ test_user_is_channel_participant_sqlite
+);
+
+async fn test_user_is_channel_participant(db: &Arc<Database>) {
+ let admin = new_test_user(db, "admin@example.com").await;
+ let member = new_test_user(db, "member@example.com").await;
+ let guest = new_test_user(db, "guest@example.com").await;
+
+ let zed_channel = db.create_root_channel("zed", admin).await.unwrap();
+ let active_channel_id = db
+ .create_sub_channel("active", zed_channel, admin)
+ .await
+ .unwrap();
+ let vim_channel_id = db
+ .create_sub_channel("vim", active_channel_id, admin)
+ .await
+ .unwrap();
+
+ db.set_channel_visibility(vim_channel_id, crate::db::ChannelVisibility::Public, admin)
+ .await
+ .unwrap();
+ db.invite_channel_member(active_channel_id, member, admin, ChannelRole::Member)
+ .await
+ .unwrap();
+ db.invite_channel_member(vim_channel_id, guest, admin, ChannelRole::Guest)
.await
.unwrap();
- // DAG is now:
- // /- gpui2 -\ /---------------------\
- // zed - crdb -- livestreaming - livestreaming_dag - livestreaming_dag_sub_id
- // \---------/
+ db.respond_to_channel_invite(active_channel_id, member, true)
+ .await
+ .unwrap();
- // Make sure that we're correctly getting the full sub-dag
- pretty_assertions::assert_eq!(
- returned_channels,
- graph(
- &[
- (livestreaming_id, "livestreaming"),
- (livestreaming_dag_id, "livestreaming_dag"),
- (livestreaming_dag_sub_id, "livestreaming_dag_sub"),
- ],
- &[
- (livestreaming_id, gpui2_id),
- (livestreaming_dag_id, livestreaming_id),
- (livestreaming_dag_sub_id, livestreaming_id),
- (livestreaming_dag_sub_id, livestreaming_dag_id),
- ]
+ db.transaction(|tx| async move {
+ db.check_user_is_channel_participant(
+ &db.get_channel_internal(vim_channel_id, &*tx).await?,
+ admin,
+ &*tx,
)
- );
-
- let result = db.get_channels_for_user(a_id).await.unwrap();
- assert_dag(
- result.channels,
- &[
- (zed_id, None),
- (crdb_id, Some(zed_id)),
- (gpui2_id, Some(zed_id)),
- (livestreaming_id, Some(zed_id)),
- (livestreaming_id, Some(crdb_id)),
- (livestreaming_id, Some(gpui2_id)),
- (livestreaming_dag_id, Some(livestreaming_id)),
- (livestreaming_dag_sub_id, Some(livestreaming_id)),
- (livestreaming_dag_sub_id, Some(livestreaming_dag_id)),
- ],
- );
+ .await
+ })
+ .await
+ .unwrap();
+ db.transaction(|tx| async move {
+ db.check_user_is_channel_participant(
+ &db.get_channel_internal(vim_channel_id, &*tx).await?,
+ member,
+ &*tx,
+ )
+ .await
+ })
+ .await
+ .unwrap();
- // ========================================================================
- // Test unlinking in a complex DAG by removing the inner link
- db.unlink_channel(a_id, livestreaming_dag_sub_id, livestreaming_id)
+ let mut members = db
+ .get_channel_participant_details(vim_channel_id, admin)
.await
.unwrap();
- // DAG is now:
- // /- gpui2 -\
- // zed - crdb -- livestreaming - livestreaming_dag - livestreaming_dag_sub
- // \---------/
+ members.sort_by_key(|member| member.user_id);
- let result = db.get_channels_for_user(a_id).await.unwrap();
- assert_dag(
- result.channels,
+ assert_eq!(
+ members,
&[
- (zed_id, None),
- (crdb_id, Some(zed_id)),
- (gpui2_id, Some(zed_id)),
- (livestreaming_id, Some(gpui2_id)),
- (livestreaming_id, Some(zed_id)),
- (livestreaming_id, Some(crdb_id)),
- (livestreaming_dag_id, Some(livestreaming_id)),
- (livestreaming_dag_sub_id, Some(livestreaming_dag_id)),
- ],
+ proto::ChannelMember {
+ user_id: admin.to_proto(),
+ kind: proto::channel_member::Kind::AncestorMember.into(),
+ role: proto::ChannelRole::Admin.into(),
+ },
+ proto::ChannelMember {
+ user_id: member.to_proto(),
+ kind: proto::channel_member::Kind::AncestorMember.into(),
+ role: proto::ChannelRole::Member.into(),
+ },
+ proto::ChannelMember {
+ user_id: guest.to_proto(),
+ kind: proto::channel_member::Kind::Invitee.into(),
+ role: proto::ChannelRole::Guest.into(),
+ },
+ ]
);
- // ========================================================================
- // Test unlinking in a complex DAG by removing the inner link
- db.unlink_channel(a_id, livestreaming_id, gpui2_id)
+ db.respond_to_channel_invite(vim_channel_id, guest, true)
.await
.unwrap();
- // DAG is now:
- // /- gpui2
- // zed - crdb -- livestreaming - livestreaming_dag - livestreaming_dag_sub
- // \---------/
- let result = db.get_channels_for_user(a_id).await.unwrap();
- assert_dag(
- result.channels,
+ db.transaction(|tx| async move {
+ db.check_user_is_channel_participant(
+ &db.get_channel_internal(vim_channel_id, &*tx).await?,
+ guest,
+ &*tx,
+ )
+ .await
+ })
+ .await
+ .unwrap();
+
+ let channels = db.get_channels_for_user(guest).await.unwrap().channels;
+ assert_channel_tree(channels, &[(vim_channel_id, &[])]);
+ let channels = db.get_channels_for_user(member).await.unwrap().channels;
+ assert_channel_tree(
+ channels,
&[
- (zed_id, None),
- (crdb_id, Some(zed_id)),
- (gpui2_id, Some(zed_id)),
- (livestreaming_id, Some(zed_id)),
- (livestreaming_id, Some(crdb_id)),
- (livestreaming_dag_id, Some(livestreaming_id)),
- (livestreaming_dag_sub_id, Some(livestreaming_dag_id)),
+ (active_channel_id, &[]),
+ (vim_channel_id, &[active_channel_id]),
],
);
- // ========================================================================
- // Test moving DAG nodes by moving livestreaming to be below gpui2
- db.move_channel(a_id, livestreaming_id, crdb_id, gpui2_id)
+ db.set_channel_member_role(vim_channel_id, admin, guest, ChannelRole::Banned)
.await
.unwrap();
+ assert!(db
+ .transaction(|tx| async move {
+ db.check_user_is_channel_participant(
+ &db.get_channel_internal(vim_channel_id, &*tx).await.unwrap(),
+ guest,
+ &*tx,
+ )
+ .await
+ })
+ .await
+ .is_err());
- // DAG is now:
- // /- gpui2 -- livestreaming - livestreaming_dag - livestreaming_dag_sub
- // zed - crdb /
- // \---------/
- let result = db.get_channels_for_user(a_id).await.unwrap();
- assert_dag(
- result.channels,
- &[
- (zed_id, None),
- (crdb_id, Some(zed_id)),
- (gpui2_id, Some(zed_id)),
- (livestreaming_id, Some(zed_id)),
- (livestreaming_id, Some(gpui2_id)),
- (livestreaming_dag_id, Some(livestreaming_id)),
- (livestreaming_dag_sub_id, Some(livestreaming_dag_id)),
- ],
- );
+ let mut members = db
+ .get_channel_participant_details(vim_channel_id, admin)
+ .await
+ .unwrap();
- // ========================================================================
- // Deleting a channel should not delete children that still have other parents
- db.delete_channel(gpui2_id, a_id).await.unwrap();
+ members.sort_by_key(|member| member.user_id);
- // DAG is now:
- // zed - crdb
- // \- livestreaming - livestreaming_dag - livestreaming_dag_sub
- let result = db.get_channels_for_user(a_id).await.unwrap();
- assert_dag(
- result.channels,
+ assert_eq!(
+ members,
&[
- (zed_id, None),
- (crdb_id, Some(zed_id)),
- (livestreaming_id, Some(zed_id)),
- (livestreaming_dag_id, Some(livestreaming_id)),
- (livestreaming_dag_sub_id, Some(livestreaming_dag_id)),
- ],
+ proto::ChannelMember {
+ user_id: admin.to_proto(),
+ kind: proto::channel_member::Kind::AncestorMember.into(),
+ role: proto::ChannelRole::Admin.into(),
+ },
+ proto::ChannelMember {
+ user_id: member.to_proto(),
+ kind: proto::channel_member::Kind::AncestorMember.into(),
+ role: proto::ChannelRole::Member.into(),
+ },
+ proto::ChannelMember {
+ user_id: guest.to_proto(),
+ kind: proto::channel_member::Kind::Member.into(),
+ role: proto::ChannelRole::Banned.into(),
+ },
+ ]
);
- // ========================================================================
- // Unlinking a channel from it's parent should automatically promote it to a root channel
- db.unlink_channel(a_id, crdb_id, zed_id).await.unwrap();
+ db.remove_channel_member(vim_channel_id, guest, admin)
+ .await
+ .unwrap();
- // DAG is now:
- // crdb
- // zed
- // \- livestreaming - livestreaming_dag - livestreaming_dag_sub
+ db.set_channel_visibility(zed_channel, crate::db::ChannelVisibility::Public, admin)
+ .await
+ .unwrap();
- let result = db.get_channels_for_user(a_id).await.unwrap();
- assert_dag(
- result.channels,
- &[
- (zed_id, None),
- (crdb_id, None),
- (livestreaming_id, Some(zed_id)),
- (livestreaming_dag_id, Some(livestreaming_id)),
- (livestreaming_dag_sub_id, Some(livestreaming_dag_id)),
- ],
- );
+ db.invite_channel_member(zed_channel, guest, admin, ChannelRole::Guest)
+ .await
+ .unwrap();
- // ========================================================================
- // You should be able to move a root channel into a non-root channel
- db.link_channel(a_id, crdb_id, zed_id).await.unwrap();
+ // currently people invited to parent channels are not shown here
+ let mut members = db
+ .get_channel_participant_details(vim_channel_id, admin)
+ .await
+ .unwrap();
- // DAG is now:
- // zed - crdb
- // \- livestreaming - livestreaming_dag - livestreaming_dag_sub
+ members.sort_by_key(|member| member.user_id);
- let result = db.get_channels_for_user(a_id).await.unwrap();
- assert_dag(
- result.channels,
+ assert_eq!(
+ members,
&[
- (zed_id, None),
- (crdb_id, Some(zed_id)),
- (livestreaming_id, Some(zed_id)),
- (livestreaming_dag_id, Some(livestreaming_id)),
- (livestreaming_dag_sub_id, Some(livestreaming_dag_id)),
- ],
+ proto::ChannelMember {
+ user_id: admin.to_proto(),
+ kind: proto::channel_member::Kind::AncestorMember.into(),
+ role: proto::ChannelRole::Admin.into(),
+ },
+ proto::ChannelMember {
+ user_id: member.to_proto(),
+ kind: proto::channel_member::Kind::AncestorMember.into(),
+ role: proto::ChannelRole::Member.into(),
+ },
+ ]
);
- // ========================================================================
- // Prep for DAG deletion test
- db.link_channel(a_id, livestreaming_id, crdb_id)
+ db.respond_to_channel_invite(zed_channel, guest, true)
.await
.unwrap();
- // DAG is now:
- // zed - crdb - livestreaming - livestreaming_dag - livestreaming_dag_sub
- // \--------/
+ db.transaction(|tx| async move {
+ db.check_user_is_channel_participant(
+ &db.get_channel_internal(zed_channel, &*tx).await.unwrap(),
+ guest,
+ &*tx,
+ )
+ .await
+ })
+ .await
+ .unwrap();
+ assert!(db
+ .transaction(|tx| async move {
+ db.check_user_is_channel_participant(
+ &db.get_channel_internal(active_channel_id, &*tx)
+ .await
+ .unwrap(),
+ guest,
+ &*tx,
+ )
+ .await
+ })
+ .await
+ .is_err(),);
+
+ db.transaction(|tx| async move {
+ db.check_user_is_channel_participant(
+ &db.get_channel_internal(vim_channel_id, &*tx).await.unwrap(),
+ guest,
+ &*tx,
+ )
+ .await
+ })
+ .await
+ .unwrap();
- let result = db.get_channels_for_user(a_id).await.unwrap();
- assert_dag(
- result.channels,
+ let mut members = db
+ .get_channel_participant_details(vim_channel_id, admin)
+ .await
+ .unwrap();
+
+ members.sort_by_key(|member| member.user_id);
+
+ assert_eq!(
+ members,
&[
- (zed_id, None),
- (crdb_id, Some(zed_id)),
- (livestreaming_id, Some(zed_id)),
- (livestreaming_id, Some(crdb_id)),
- (livestreaming_dag_id, Some(livestreaming_id)),
- (livestreaming_dag_sub_id, Some(livestreaming_dag_id)),
- ],
+ proto::ChannelMember {
+ user_id: admin.to_proto(),
+ kind: proto::channel_member::Kind::AncestorMember.into(),
+ role: proto::ChannelRole::Admin.into(),
+ },
+ proto::ChannelMember {
+ user_id: member.to_proto(),
+ kind: proto::channel_member::Kind::AncestorMember.into(),
+ role: proto::ChannelRole::Member.into(),
+ },
+ proto::ChannelMember {
+ user_id: guest.to_proto(),
+ kind: proto::channel_member::Kind::AncestorMember.into(),
+ role: proto::ChannelRole::Guest.into(),
+ },
+ ]
);
- // Deleting the parent of a DAG should delete the whole DAG:
- db.delete_channel(zed_id, a_id).await.unwrap();
- let result = db.get_channels_for_user(a_id).await.unwrap();
-
- assert!(result.channels.is_empty())
+ let channels = db.get_channels_for_user(guest).await.unwrap().channels;
+ assert_channel_tree(
+ channels,
+ &[(zed_channel, &[]), (vim_channel_id, &[zed_channel])],
+ )
}
test_both_dbs!(
- test_db_channel_moving_bugs,
- test_db_channel_moving_bugs_postgres,
- test_db_channel_moving_bugs_sqlite
+ test_user_joins_correct_channel,
+ test_user_joins_correct_channel_postgres,
+ test_user_joins_correct_channel_sqlite
);
-async fn test_db_channel_moving_bugs(db: &Arc<Database>) {
- let user_id = db
- .create_user(
- "user1@example.com",
- false,
- NewUserParams {
- github_login: "user1".into(),
- github_user_id: 5,
- invite_count: 0,
- },
- )
+async fn test_user_joins_correct_channel(db: &Arc<Database>) {
+ let admin = new_test_user(db, "admin@example.com").await;
+
+ let zed_channel = db.create_root_channel("zed", admin).await.unwrap();
+
+ let active_channel = db
+ .create_sub_channel("active", zed_channel, admin)
.await
- .unwrap()
- .user_id;
+ .unwrap();
- let zed_id = db.create_root_channel("zed", user_id).await.unwrap();
+ let vim_channel = db
+ .create_sub_channel("vim", active_channel, admin)
+ .await
+ .unwrap();
- let projects_id = db
- .create_channel("projects", Some(zed_id), user_id)
+ let vim2_channel = db
+ .create_sub_channel("vim2", vim_channel, admin)
.await
.unwrap();
- let livestreaming_id = db
- .create_channel("livestreaming", Some(projects_id), user_id)
+ db.set_channel_visibility(zed_channel, crate::db::ChannelVisibility::Public, admin)
.await
.unwrap();
- // Dag is: zed - projects - livestreaming
+ db.set_channel_visibility(vim_channel, crate::db::ChannelVisibility::Public, admin)
+ .await
+ .unwrap();
- // Move to same parent should be a no-op
- assert!(db
- .move_channel(user_id, projects_id, zed_id, zed_id)
+ db.set_channel_visibility(vim2_channel, crate::db::ChannelVisibility::Public, admin)
.await
+ .unwrap();
+
+ let most_public = db
+ .transaction(|tx| async move {
+ Ok(db
+ .public_ancestors_including_self(
+ &db.get_channel_internal(vim_channel, &*tx).await.unwrap(),
+ &tx,
+ )
+ .await?
+ .first()
+ .cloned())
+ })
+ .await
+ .unwrap()
.unwrap()
- .is_empty());
+ .id;
+
+ assert_eq!(most_public, zed_channel)
+}
- // Stranding a channel should retain it's sub channels
- db.unlink_channel(user_id, projects_id, zed_id)
+test_both_dbs!(
+ test_guest_access,
+ test_guest_access_postgres,
+ test_guest_access_sqlite
+);
+
+async fn test_guest_access(db: &Arc<Database>) {
+ let server = db.create_server("test").await.unwrap();
+
+ let admin = new_test_user(db, "admin@example.com").await;
+ let guest = new_test_user(db, "guest@example.com").await;
+ let guest_connection = new_test_connection(server);
+
+ let zed_channel = db.create_root_channel("zed", admin).await.unwrap();
+ db.set_channel_visibility(zed_channel, crate::db::ChannelVisibility::Public, admin)
.await
.unwrap();
- let result = db.get_channels_for_user(user_id).await.unwrap();
- assert_dag(
- result.channels,
- &[
- (zed_id, None),
- (projects_id, None),
- (livestreaming_id, Some(projects_id)),
- ],
- );
+ assert!(db
+ .join_channel_chat(zed_channel, guest_connection, guest)
+ .await
+ .is_err());
+
+ db.join_channel(zed_channel, guest, guest_connection, TEST_RELEASE_CHANNEL)
+ .await
+ .unwrap();
+
+ assert!(db
+ .join_channel_chat(zed_channel, guest_connection, guest)
+ .await
+ .is_ok())
}
#[track_caller]
-fn assert_dag(actual: ChannelGraph, expected: &[(ChannelId, Option<ChannelId>)]) {
- let mut actual_map: HashMap<ChannelId, HashSet<ChannelId>> = HashMap::default();
- for channel in actual.channels {
- actual_map.insert(channel.id, HashSet::default());
- }
- for edge in actual.edges {
- actual_map
- .get_mut(&ChannelId::from_proto(edge.channel_id))
- .unwrap()
- .insert(ChannelId::from_proto(edge.parent_id));
- }
-
- let mut expected_map: HashMap<ChannelId, HashSet<ChannelId>> = HashMap::default();
-
- for (child, parent) in expected {
- let entry = expected_map.entry(*child).or_default();
- if let Some(parent) = parent {
- entry.insert(*parent);
- }
- }
-
- pretty_assertions::assert_eq!(actual_map, expected_map)
+fn assert_channel_tree(actual: Vec<Channel>, expected: &[(ChannelId, &[ChannelId])]) {
+ let actual = actual
+ .iter()
+ .map(|channel| (channel.id, channel.parent_path.as_slice()))
+ .collect::<Vec<_>>();
+ pretty_assertions::assert_eq!(
+ actual,
+ expected.to_vec(),
+ "wrong channel ids and parent paths"
+ );
}
@@ -22,7 +22,6 @@ async fn test_get_users(db: &Arc<Database>) {
NewUserParams {
github_login: format!("user{i}"),
github_user_id: i,
- invite_count: 0,
},
)
.await
@@ -88,7 +87,6 @@ async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
NewUserParams {
github_login: "login1".into(),
github_user_id: 101,
- invite_count: 0,
},
)
.await
@@ -101,7 +99,6 @@ async fn test_get_or_create_user_by_github_account(db: &Arc<Database>) {
NewUserParams {
github_login: "login2".into(),
github_user_id: 102,
- invite_count: 0,
},
)
.await
@@ -156,7 +153,6 @@ async fn test_create_access_tokens(db: &Arc<Database>) {
NewUserParams {
github_login: "u1".into(),
github_user_id: 1,
- invite_count: 0,
},
)
.await
@@ -238,7 +234,6 @@ async fn test_add_contacts(db: &Arc<Database>) {
NewUserParams {
github_login: format!("user{i}"),
github_user_id: i,
- invite_count: 0,
},
)
.await
@@ -264,10 +259,7 @@ async fn test_add_contacts(db: &Arc<Database>) {
);
assert_eq!(
db.get_contacts(user_2).await.unwrap(),
- &[Contact::Incoming {
- user_id: user_1,
- should_notify: true
- }]
+ &[Contact::Incoming { user_id: user_1 }]
);
// User 2 dismisses the contact request notification without accepting or rejecting.
@@ -280,10 +272,7 @@ async fn test_add_contacts(db: &Arc<Database>) {
.unwrap();
assert_eq!(
db.get_contacts(user_2).await.unwrap(),
- &[Contact::Incoming {
- user_id: user_1,
- should_notify: false
- }]
+ &[Contact::Incoming { user_id: user_1 }]
);
// User can't accept their own contact request
@@ -299,7 +288,6 @@ async fn test_add_contacts(db: &Arc<Database>) {
db.get_contacts(user_1).await.unwrap(),
&[Contact::Accepted {
user_id: user_2,
- should_notify: true,
busy: false,
}],
);
@@ -309,7 +297,6 @@ async fn test_add_contacts(db: &Arc<Database>) {
db.get_contacts(user_2).await.unwrap(),
&[Contact::Accepted {
user_id: user_1,
- should_notify: false,
busy: false,
}]
);
@@ -326,7 +313,6 @@ async fn test_add_contacts(db: &Arc<Database>) {
db.get_contacts(user_1).await.unwrap(),
&[Contact::Accepted {
user_id: user_2,
- should_notify: true,
busy: false,
}]
);
@@ -339,7 +325,6 @@ async fn test_add_contacts(db: &Arc<Database>) {
db.get_contacts(user_1).await.unwrap(),
&[Contact::Accepted {
user_id: user_2,
- should_notify: false,
busy: false,
}]
);
@@ -353,12 +338,10 @@ async fn test_add_contacts(db: &Arc<Database>) {
&[
Contact::Accepted {
user_id: user_2,
- should_notify: false,
busy: false,
},
Contact::Accepted {
user_id: user_3,
- should_notify: false,
busy: false,
}
]
@@ -367,7 +350,6 @@ async fn test_add_contacts(db: &Arc<Database>) {
db.get_contacts(user_3).await.unwrap(),
&[Contact::Accepted {
user_id: user_1,
- should_notify: false,
busy: false,
}],
);
@@ -383,7 +365,6 @@ async fn test_add_contacts(db: &Arc<Database>) {
db.get_contacts(user_2).await.unwrap(),
&[Contact::Accepted {
user_id: user_1,
- should_notify: false,
busy: false,
}]
);
@@ -391,7 +372,6 @@ async fn test_add_contacts(db: &Arc<Database>) {
db.get_contacts(user_3).await.unwrap(),
&[Contact::Accepted {
user_id: user_1,
- should_notify: false,
busy: false,
}],
);
@@ -415,7 +395,6 @@ async fn test_metrics_id(db: &Arc<Database>) {
NewUserParams {
github_login: "person1".into(),
github_user_id: 101,
- invite_count: 5,
},
)
.await
@@ -431,7 +410,6 @@ async fn test_metrics_id(db: &Arc<Database>) {
NewUserParams {
github_login: "person2".into(),
github_user_id: 102,
- invite_count: 5,
},
)
.await
@@ -460,7 +438,6 @@ async fn test_project_count(db: &Arc<Database>) {
NewUserParams {
github_login: "admin".into(),
github_user_id: 0,
- invite_count: 0,
},
)
.await
@@ -472,7 +449,6 @@ async fn test_project_count(db: &Arc<Database>) {
NewUserParams {
github_login: "user".into(),
github_user_id: 1,
- invite_count: 0,
},
)
.await
@@ -554,7 +530,6 @@ async fn test_fuzzy_search_users() {
NewUserParams {
github_login: github_login.into(),
github_user_id: i as i32,
- invite_count: 0,
},
)
.await
@@ -596,7 +571,6 @@ async fn test_non_matching_release_channels(db: &Arc<Database>) {
NewUserParams {
github_login: "admin".into(),
github_user_id: 0,
- invite_count: 0,
},
)
.await
@@ -608,7 +582,6 @@ async fn test_non_matching_release_channels(db: &Arc<Database>) {
NewUserParams {
github_login: "user".into(),
github_user_id: 1,
- invite_count: 0,
},
)
.await
@@ -18,7 +18,6 @@ async fn test_get_user_flags(db: &Arc<Database>) {
NewUserParams {
github_login: format!("user1"),
github_user_id: 1,
- invite_count: 0,
},
)
.await
@@ -32,7 +31,6 @@ async fn test_get_user_flags(db: &Arc<Database>) {
NewUserParams {
github_login: format!("user2"),
github_user_id: 2,
- invite_count: 0,
},
)
.await
@@ -1,7 +1,9 @@
+use super::new_test_user;
use crate::{
- db::{Database, MessageId, NewUserParams},
+ db::{ChannelRole, Database, MessageId},
test_both_dbs,
};
+use channel::mentions_to_proto;
use std::sync::Arc;
use time::OffsetDateTime;
@@ -12,39 +14,38 @@ test_both_dbs!(
);
async fn test_channel_message_retrieval(db: &Arc<Database>) {
- let user = db
- .create_user(
- "user@example.com",
- false,
- NewUserParams {
- github_login: "user".into(),
- github_user_id: 1,
- invite_count: 0,
- },
- )
- .await
- .unwrap()
- .user_id;
- let channel = db.create_channel("channel", None, user).await.unwrap();
+ let user = new_test_user(db, "user@example.com").await;
+ let result = db.create_channel("channel", None, user).await.unwrap();
let owner_id = db.create_server("test").await.unwrap().0 as u32;
- db.join_channel_chat(channel, rpc::ConnectionId { owner_id, id: 0 }, user)
- .await
- .unwrap();
+ db.join_channel_chat(
+ result.channel.id,
+ rpc::ConnectionId { owner_id, id: 0 },
+ user,
+ )
+ .await
+ .unwrap();
let mut all_messages = Vec::new();
for i in 0..10 {
all_messages.push(
- db.create_channel_message(channel, user, &i.to_string(), OffsetDateTime::now_utc(), i)
- .await
- .unwrap()
- .0
- .to_proto(),
+ db.create_channel_message(
+ result.channel.id,
+ user,
+ &i.to_string(),
+ &[],
+ OffsetDateTime::now_utc(),
+ i,
+ )
+ .await
+ .unwrap()
+ .message_id
+ .to_proto(),
);
}
let messages = db
- .get_channel_messages(channel, user, 3, None)
+ .get_channel_messages(result.channel.id, user, 3, None)
.await
.unwrap()
.into_iter()
@@ -54,7 +55,7 @@ async fn test_channel_message_retrieval(db: &Arc<Database>) {
let messages = db
.get_channel_messages(
- channel,
+ result.channel.id,
user,
4,
Some(MessageId::from_proto(all_messages[6])),
@@ -74,99 +75,154 @@ test_both_dbs!(
);
async fn test_channel_message_nonces(db: &Arc<Database>) {
- let user = db
- .create_user(
- "user@example.com",
- false,
- NewUserParams {
- github_login: "user".into(),
- github_user_id: 1,
- invite_count: 0,
- },
- )
+ let user_a = new_test_user(db, "user_a@example.com").await;
+ let user_b = new_test_user(db, "user_b@example.com").await;
+ let user_c = new_test_user(db, "user_c@example.com").await;
+ let channel = db.create_root_channel("channel", user_a).await.unwrap();
+ db.invite_channel_member(channel, user_b, user_a, ChannelRole::Member)
.await
- .unwrap()
- .user_id;
- let channel = db.create_channel("channel", None, user).await.unwrap();
-
- let owner_id = db.create_server("test").await.unwrap().0 as u32;
-
- db.join_channel_chat(channel, rpc::ConnectionId { owner_id, id: 0 }, user)
+ .unwrap();
+ db.invite_channel_member(channel, user_c, user_a, ChannelRole::Member)
.await
.unwrap();
-
- let msg1_id = db
- .create_channel_message(channel, user, "1", OffsetDateTime::now_utc(), 1)
+ db.respond_to_channel_invite(channel, user_b, true)
.await
.unwrap();
- let msg2_id = db
- .create_channel_message(channel, user, "2", OffsetDateTime::now_utc(), 2)
+ db.respond_to_channel_invite(channel, user_c, true)
.await
.unwrap();
- let msg3_id = db
- .create_channel_message(channel, user, "3", OffsetDateTime::now_utc(), 1)
+
+ let owner_id = db.create_server("test").await.unwrap().0 as u32;
+ db.join_channel_chat(channel, rpc::ConnectionId { owner_id, id: 0 }, user_a)
.await
.unwrap();
- let msg4_id = db
- .create_channel_message(channel, user, "4", OffsetDateTime::now_utc(), 2)
+ db.join_channel_chat(channel, rpc::ConnectionId { owner_id, id: 1 }, user_b)
.await
.unwrap();
- assert_ne!(msg1_id, msg2_id);
- assert_eq!(msg1_id, msg3_id);
- assert_eq!(msg2_id, msg4_id);
-}
-
-test_both_dbs!(
- test_channel_message_new_notification,
- test_channel_message_new_notification_postgres,
- test_channel_message_new_notification_sqlite
-);
-
-async fn test_channel_message_new_notification(db: &Arc<Database>) {
- let user = db
- .create_user(
- "user_a@example.com",
- false,
- NewUserParams {
- github_login: "user_a".into(),
- github_user_id: 1,
- invite_count: 0,
- },
+ // As user A, create messages that re-use the same nonces. The requests
+ // succeed, but return the same ids.
+ let id1 = db
+ .create_channel_message(
+ channel,
+ user_a,
+ "hi @user_b",
+ &mentions_to_proto(&[(3..10, user_b.to_proto())]),
+ OffsetDateTime::now_utc(),
+ 100,
)
.await
.unwrap()
- .user_id;
- let observer = db
- .create_user(
- "user_b@example.com",
- false,
- NewUserParams {
- github_login: "user_b".into(),
- github_user_id: 1,
- invite_count: 0,
- },
+ .message_id;
+ let id2 = db
+ .create_channel_message(
+ channel,
+ user_a,
+ "hello, fellow users",
+ &mentions_to_proto(&[]),
+ OffsetDateTime::now_utc(),
+ 200,
+ )
+ .await
+ .unwrap()
+ .message_id;
+ let id3 = db
+ .create_channel_message(
+ channel,
+ user_a,
+ "bye @user_c (same nonce as first message)",
+ &mentions_to_proto(&[(4..11, user_c.to_proto())]),
+ OffsetDateTime::now_utc(),
+ 100,
+ )
+ .await
+ .unwrap()
+ .message_id;
+ let id4 = db
+ .create_channel_message(
+ channel,
+ user_a,
+ "omg (same nonce as second message)",
+ &mentions_to_proto(&[]),
+ OffsetDateTime::now_utc(),
+ 200,
)
.await
.unwrap()
- .user_id;
+ .message_id;
- let channel_1 = db.create_channel("channel", None, user).await.unwrap();
+ // As a different user, reuse one of the same nonces. This request succeeds
+ // and returns a different id.
+ let id5 = db
+ .create_channel_message(
+ channel,
+ user_b,
+ "omg @user_a (same nonce as user_a's first message)",
+ &mentions_to_proto(&[(4..11, user_a.to_proto())]),
+ OffsetDateTime::now_utc(),
+ 100,
+ )
+ .await
+ .unwrap()
+ .message_id;
- let channel_2 = db.create_channel("channel-2", None, user).await.unwrap();
+ assert_ne!(id1, id2);
+ assert_eq!(id1, id3);
+ assert_eq!(id2, id4);
+ assert_ne!(id5, id1);
- db.invite_channel_member(channel_1, observer, user, false)
+ let messages = db
+ .get_channel_messages(channel, user_a, 5, None)
.await
- .unwrap();
+ .unwrap()
+ .into_iter()
+ .map(|m| (m.id, m.body, m.mentions))
+ .collect::<Vec<_>>();
+ assert_eq!(
+ messages,
+ &[
+ (
+ id1.to_proto(),
+ "hi @user_b".into(),
+ mentions_to_proto(&[(3..10, user_b.to_proto())]),
+ ),
+ (
+ id2.to_proto(),
+ "hello, fellow users".into(),
+ mentions_to_proto(&[])
+ ),
+ (
+ id5.to_proto(),
+ "omg @user_a (same nonce as user_a's first message)".into(),
+ mentions_to_proto(&[(4..11, user_a.to_proto())]),
+ ),
+ ]
+ );
+}
- db.respond_to_channel_invite(channel_1, observer, true)
+test_both_dbs!(
+ test_unseen_channel_messages,
+ test_unseen_channel_messages_postgres,
+ test_unseen_channel_messages_sqlite
+);
+
+async fn test_unseen_channel_messages(db: &Arc<Database>) {
+ let user = new_test_user(db, "user_a@example.com").await;
+ let observer = new_test_user(db, "user_b@example.com").await;
+
+ let channel_1 = db.create_root_channel("channel", user).await.unwrap();
+ let channel_2 = db.create_root_channel("channel-2", user).await.unwrap();
+
+ db.invite_channel_member(channel_1, observer, user, ChannelRole::Member)
.await
.unwrap();
-
- db.invite_channel_member(channel_2, observer, user, false)
+ db.invite_channel_member(channel_2, observer, user, ChannelRole::Member)
.await
.unwrap();
+ db.respond_to_channel_invite(channel_1, observer, true)
+ .await
+ .unwrap();
db.respond_to_channel_invite(channel_2, observer, true)
.await
.unwrap();
@@ -179,28 +235,31 @@ async fn test_channel_message_new_notification(db: &Arc<Database>) {
.unwrap();
let _ = db
- .create_channel_message(channel_1, user, "1_1", OffsetDateTime::now_utc(), 1)
+ .create_channel_message(channel_1, user, "1_1", &[], OffsetDateTime::now_utc(), 1)
.await
.unwrap();
- let (second_message, _, _) = db
- .create_channel_message(channel_1, user, "1_2", OffsetDateTime::now_utc(), 2)
+ let second_message = db
+ .create_channel_message(channel_1, user, "1_2", &[], OffsetDateTime::now_utc(), 2)
.await
- .unwrap();
+ .unwrap()
+ .message_id;
- let (third_message, _, _) = db
- .create_channel_message(channel_1, user, "1_3", OffsetDateTime::now_utc(), 3)
+ let third_message = db
+ .create_channel_message(channel_1, user, "1_3", &[], OffsetDateTime::now_utc(), 3)
.await
- .unwrap();
+ .unwrap()
+ .message_id;
db.join_channel_chat(channel_2, user_connection_id, user)
.await
.unwrap();
- let (fourth_message, _, _) = db
- .create_channel_message(channel_2, user, "2_1", OffsetDateTime::now_utc(), 4)
+ let fourth_message = db
+ .create_channel_message(channel_2, user, "2_1", &[], OffsetDateTime::now_utc(), 4)
.await
- .unwrap();
+ .unwrap()
+ .message_id;
// Check that observer has new messages
let unseen_messages = db
@@ -295,3 +354,101 @@ async fn test_channel_message_new_notification(db: &Arc<Database>) {
}]
);
}
+
+test_both_dbs!(
+ test_channel_message_mentions,
+ test_channel_message_mentions_postgres,
+ test_channel_message_mentions_sqlite
+);
+
+async fn test_channel_message_mentions(db: &Arc<Database>) {
+ let user_a = new_test_user(db, "user_a@example.com").await;
+ let user_b = new_test_user(db, "user_b@example.com").await;
+ let user_c = new_test_user(db, "user_c@example.com").await;
+
+ let channel = db
+ .create_channel("channel", None, user_a)
+ .await
+ .unwrap()
+ .channel
+ .id;
+ db.invite_channel_member(channel, user_b, user_a, ChannelRole::Member)
+ .await
+ .unwrap();
+ db.respond_to_channel_invite(channel, user_b, true)
+ .await
+ .unwrap();
+
+ let owner_id = db.create_server("test").await.unwrap().0 as u32;
+ let connection_id = rpc::ConnectionId { owner_id, id: 0 };
+ db.join_channel_chat(channel, connection_id, user_a)
+ .await
+ .unwrap();
+
+ db.create_channel_message(
+ channel,
+ user_a,
+ "hi @user_b and @user_c",
+ &mentions_to_proto(&[(3..10, user_b.to_proto()), (15..22, user_c.to_proto())]),
+ OffsetDateTime::now_utc(),
+ 1,
+ )
+ .await
+ .unwrap();
+ db.create_channel_message(
+ channel,
+ user_a,
+ "bye @user_c",
+ &mentions_to_proto(&[(4..11, user_c.to_proto())]),
+ OffsetDateTime::now_utc(),
+ 2,
+ )
+ .await
+ .unwrap();
+ db.create_channel_message(
+ channel,
+ user_a,
+ "umm",
+ &mentions_to_proto(&[]),
+ OffsetDateTime::now_utc(),
+ 3,
+ )
+ .await
+ .unwrap();
+ db.create_channel_message(
+ channel,
+ user_a,
+ "@user_b, stop.",
+ &mentions_to_proto(&[(0..7, user_b.to_proto())]),
+ OffsetDateTime::now_utc(),
+ 4,
+ )
+ .await
+ .unwrap();
+
+ let messages = db
+ .get_channel_messages(channel, user_b, 5, None)
+ .await
+ .unwrap()
+ .into_iter()
+ .map(|m| (m.body, m.mentions))
+ .collect::<Vec<_>>();
+ assert_eq!(
+ &messages,
+ &[
+ (
+ "hi @user_b and @user_c".into(),
+ mentions_to_proto(&[(3..10, user_b.to_proto()), (15..22, user_c.to_proto())]),
+ ),
+ (
+ "bye @user_c".into(),
+ mentions_to_proto(&[(4..11, user_c.to_proto())]),
+ ),
+ ("umm".into(), mentions_to_proto(&[]),),
+ (
+ "@user_b, stop.".into(),
+ mentions_to_proto(&[(0..7, user_b.to_proto())]),
+ ),
+ ]
+ );
+}
@@ -119,7 +119,9 @@ impl AppState {
pub async fn new(config: Config) -> Result<Arc<Self>> {
let mut db_options = db::ConnectOptions::new(config.database_url.clone());
db_options.max_connections(config.database_max_connections);
- let db = Database::new(db_options, Executor::Production).await?;
+ let mut db = Database::new(db_options, Executor::Production).await?;
+ db.initialize_notification_kinds().await?;
+
let live_kit_client = if let Some(((server, key), secret)) = config
.live_kit_server
.as_ref()
@@ -3,8 +3,11 @@ mod connection_pool;
use crate::{
auth,
db::{
- self, BufferId, ChannelId, ChannelsForUser, Database, MessageId, ProjectId, RoomId,
- ServerId, User, UserId,
+ self, BufferId, ChannelId, ChannelRole, ChannelsForUser, CreateChannelResult,
+ CreatedChannelMessage, Database, InviteMemberResult, MembershipUpdated, MessageId,
+ MoveChannelResult, NotificationId, ProjectId, RemoveChannelMemberResult,
+ RenameChannelResult, RespondToChannelInvite, RoomId, ServerId, SetChannelVisibilityResult,
+ User, UserId,
},
executor::Executor,
AppState, Result,
@@ -38,8 +41,8 @@ use lazy_static::lazy_static;
use prometheus::{register_int_gauge, IntGauge};
use rpc::{
proto::{
- self, Ack, AnyTypedEnvelope, ChannelEdge, EntityMessage, EnvelopedMessage,
- LiveKitConnectionInfo, RequestMessage, UpdateChannelBufferCollaborators,
+ self, Ack, AnyTypedEnvelope, EntityMessage, EnvelopedMessage, LiveKitConnectionInfo,
+ RequestMessage, UpdateChannelBufferCollaborators,
},
Connection, ConnectionId, Peer, Receipt, TypedEnvelope,
};
@@ -70,6 +73,7 @@ pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10);
const MESSAGE_COUNT_PER_PAGE: usize = 100;
const MAX_MESSAGE_LEN: usize = 1024;
+const NOTIFICATION_COUNT_PER_PAGE: usize = 50;
lazy_static! {
static ref METRIC_CONNECTIONS: IntGauge =
@@ -225,6 +229,7 @@ impl Server {
.add_request_handler(forward_project_request::<proto::OpenBufferByPath>)
.add_request_handler(forward_project_request::<proto::GetCompletions>)
.add_request_handler(forward_project_request::<proto::ApplyCompletionAdditionalEdits>)
+ .add_request_handler(forward_project_request::<proto::ResolveCompletionDocumentation>)
.add_request_handler(forward_project_request::<proto::GetCodeActions>)
.add_request_handler(forward_project_request::<proto::ApplyCodeAction>)
.add_request_handler(forward_project_request::<proto::PrepareRename>)
@@ -254,7 +259,8 @@ impl Server {
.add_request_handler(delete_channel)
.add_request_handler(invite_channel_member)
.add_request_handler(remove_channel_member)
- .add_request_handler(set_channel_member_admin)
+ .add_request_handler(set_channel_member_role)
+ .add_request_handler(set_channel_visibility)
.add_request_handler(rename_channel)
.add_request_handler(join_channel_buffer)
.add_request_handler(leave_channel_buffer)
@@ -268,8 +274,9 @@ impl Server {
.add_request_handler(send_channel_message)
.add_request_handler(remove_channel_message)
.add_request_handler(get_channel_messages)
- .add_request_handler(link_channel)
- .add_request_handler(unlink_channel)
+ .add_request_handler(get_channel_messages_by_id)
+ .add_request_handler(get_notifications)
+ .add_request_handler(mark_notification_as_read)
.add_request_handler(move_channel)
.add_request_handler(follow)
.add_message_handler(unfollow)
@@ -387,7 +394,7 @@ impl Server {
let contacts = app_state.db.get_contacts(user_id).await.trace_err();
if let Some((busy, contacts)) = busy.zip(contacts) {
let pool = pool.lock();
- let updated_contact = contact_for_user(user_id, false, busy, &pool);
+ let updated_contact = contact_for_user(user_id, busy, &pool);
for contact in contacts {
if let db::Contact::Accepted {
user_id: contact_user_id,
@@ -581,14 +588,14 @@ impl Server {
let (contacts, channels_for_user, channel_invites) = future::try_join3(
this.app_state.db.get_contacts(user_id),
this.app_state.db.get_channels_for_user(user_id),
- this.app_state.db.get_channel_invites_for_user(user_id)
+ this.app_state.db.get_channel_invites_for_user(user_id),
).await?;
{
let mut pool = this.connection_pool.lock();
pool.add_connection(connection_id, user_id, user.admin);
this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?;
- this.peer.send(connection_id, build_initial_channels_update(
+ this.peer.send(connection_id, build_channels_update(
channels_for_user,
channel_invites
))?;
@@ -687,7 +694,7 @@ impl Server {
if let Some(user) = self.app_state.db.get_user_by_id(inviter_id).await? {
if let Some(code) = &user.invite_code {
let pool = self.connection_pool.lock();
- let invitee_contact = contact_for_user(invitee_id, true, false, &pool);
+ let invitee_contact = contact_for_user(invitee_id, false, &pool);
for connection_id in pool.user_connection_ids(inviter_id) {
self.peer.send(
connection_id,
@@ -935,7 +942,7 @@ async fn create_room(
let live_kit_room = live_kit_room.clone();
let live_kit = session.live_kit_client.as_ref();
- util::async_iife!({
+ util::async_maybe!({
let live_kit = live_kit?;
let token = live_kit
@@ -945,6 +952,7 @@ async fn create_room(
Some(proto::LiveKitConnectionInfo {
server_url: live_kit.url().into(),
token,
+ can_publish: true,
})
})
}
@@ -976,6 +984,13 @@ async fn join_room(
session: Session,
) -> Result<()> {
let room_id = RoomId::from_proto(request.id);
+
+ let channel_id = session.db().await.channel_id_for_room(room_id).await?;
+
+ if let Some(channel_id) = channel_id {
+ return join_channel_internal(channel_id, Box::new(response), session).await;
+ }
+
let joined_room = {
let room = session
.db()
@@ -991,16 +1006,6 @@ async fn join_room(
room.into_inner()
};
- if let Some(channel_id) = joined_room.channel_id {
- channel_updated(
- channel_id,
- &joined_room.room,
- &joined_room.channel_members,
- &session.peer,
- &*session.connection_pool().await,
- )
- }
-
for connection_id in session
.connection_pool()
.await
@@ -1028,6 +1033,7 @@ async fn join_room(
Some(proto::LiveKitConnectionInfo {
server_url: live_kit.url().into(),
token,
+ can_publish: true,
})
} else {
None
@@ -1038,7 +1044,7 @@ async fn join_room(
response.send(proto::JoinRoomResponse {
room: Some(joined_room.room),
- channel_id: joined_room.channel_id.map(|id| id.to_proto()),
+ channel_id: None,
live_kit_connection_info,
})?;
@@ -2064,7 +2070,7 @@ async fn request_contact(
return Err(anyhow!("cannot add yourself as a contact"))?;
}
- session
+ let notifications = session
.db()
.await
.send_contact_request(requester_id, responder_id)
@@ -2087,16 +2093,14 @@ async fn request_contact(
.incoming_requests
.push(proto::IncomingContactRequest {
requester_id: requester_id.to_proto(),
- should_notify: true,
});
- for connection_id in session
- .connection_pool()
- .await
- .user_connection_ids(responder_id)
- {
+ let connection_pool = session.connection_pool().await;
+ for connection_id in connection_pool.user_connection_ids(responder_id) {
session.peer.send(connection_id, update.clone())?;
}
+ send_notifications(&*connection_pool, &session.peer, notifications);
+
response.send(proto::Ack {})?;
Ok(())
}
@@ -2115,7 +2119,8 @@ async fn respond_to_contact_request(
} else {
let accept = request.response == proto::ContactRequestResponse::Accept as i32;
- db.respond_to_contact_request(responder_id, requester_id, accept)
+ let notifications = db
+ .respond_to_contact_request(responder_id, requester_id, accept)
.await?;
let requester_busy = db.is_user_busy(requester_id).await?;
let responder_busy = db.is_user_busy(responder_id).await?;
@@ -2126,7 +2131,7 @@ async fn respond_to_contact_request(
if accept {
update
.contacts
- .push(contact_for_user(requester_id, false, requester_busy, &pool));
+ .push(contact_for_user(requester_id, requester_busy, &pool));
}
update
.remove_incoming_requests
@@ -2140,14 +2145,17 @@ async fn respond_to_contact_request(
if accept {
update
.contacts
- .push(contact_for_user(responder_id, true, responder_busy, &pool));
+ .push(contact_for_user(responder_id, responder_busy, &pool));
}
update
.remove_outgoing_requests
.push(responder_id.to_proto());
+
for connection_id in pool.user_connection_ids(requester_id) {
session.peer.send(connection_id, update.clone())?;
}
+
+ send_notifications(&*pool, &session.peer, notifications);
}
response.send(proto::Ack {})?;
@@ -2162,7 +2170,8 @@ async fn remove_contact(
let requester_id = session.user_id;
let responder_id = UserId::from_proto(request.user_id);
let db = session.db().await;
- let contact_accepted = db.remove_contact(requester_id, responder_id).await?;
+ let (contact_accepted, deleted_notification_id) =
+ db.remove_contact(requester_id, responder_id).await?;
let pool = session.connection_pool().await;
// Update outgoing contact requests of requester
@@ -2189,6 +2198,14 @@ async fn remove_contact(
}
for connection_id in pool.user_connection_ids(responder_id) {
session.peer.send(connection_id, update.clone())?;
+ if let Some(notification_id) = deleted_notification_id {
+ session.peer.send(
+ connection_id,
+ proto::DeleteNotification {
+ notification_id: notification_id.to_proto(),
+ },
+ )?;
+ }
}
response.send(proto::Ack {})?;
@@ -2203,37 +2220,21 @@ async fn create_channel(
let db = session.db().await;
let parent_id = request.parent_id.map(|id| ChannelId::from_proto(id));
- let id = db
+ let CreateChannelResult {
+ channel,
+ participants_to_update,
+ } = db
.create_channel(&request.name, parent_id, session.user_id)
.await?;
- let channel = proto::Channel {
- id: id.to_proto(),
- name: request.name,
- };
-
response.send(proto::CreateChannelResponse {
- channel: Some(channel.clone()),
+ channel: Some(channel.to_proto()),
parent_id: request.parent_id,
})?;
- let Some(parent_id) = parent_id else {
- return Ok(());
- };
-
- let update = proto::UpdateChannels {
- channels: vec![channel],
- insert_edge: vec![ChannelEdge {
- parent_id: parent_id.to_proto(),
- channel_id: id.to_proto(),
- }],
- ..Default::default()
- };
-
- let user_ids_to_notify = db.get_channel_members(parent_id).await?;
-
let connection_pool = session.connection_pool().await;
- for user_id in user_ids_to_notify {
+ for (user_id, channels) in participants_to_update {
+ let update = build_channels_update(channels, vec![]);
for connection_id in connection_pool.user_connection_ids(user_id) {
if user_id == session.user_id {
continue;
@@ -2282,27 +2283,30 @@ async fn invite_channel_member(
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
let invitee_id = UserId::from_proto(request.user_id);
- db.invite_channel_member(channel_id, invitee_id, session.user_id, request.admin)
+ let InviteMemberResult {
+ channel,
+ notifications,
+ } = db
+ .invite_channel_member(
+ channel_id,
+ invitee_id,
+ session.user_id,
+ request.role().into(),
+ )
.await?;
- let (channel, _) = db
- .get_channel(channel_id, session.user_id)
- .await?
- .ok_or_else(|| anyhow!("channel not found"))?;
+ let update = proto::UpdateChannels {
+ channel_invitations: vec![channel.to_proto()],
+ ..Default::default()
+ };
- let mut update = proto::UpdateChannels::default();
- update.channel_invitations.push(proto::Channel {
- id: channel.id.to_proto(),
- name: channel.name,
- });
- for connection_id in session
- .connection_pool()
- .await
- .user_connection_ids(invitee_id)
- {
+ let connection_pool = session.connection_pool().await;
+ for connection_id in connection_pool.user_connection_ids(invitee_id) {
session.peer.send(connection_id, update.clone())?;
}
+ send_notifications(&*connection_pool, &session.peer, notifications);
+
response.send(proto::Ack {})?;
Ok(())
}
@@ -2316,157 +2320,153 @@ async fn remove_channel_member(
let channel_id = ChannelId::from_proto(request.channel_id);
let member_id = UserId::from_proto(request.user_id);
- db.remove_channel_member(channel_id, member_id, session.user_id)
+ let RemoveChannelMemberResult {
+ membership_update,
+ notification_id,
+ } = db
+ .remove_channel_member(channel_id, member_id, session.user_id)
.await?;
- let mut update = proto::UpdateChannels::default();
- update.delete_channels.push(channel_id.to_proto());
-
- for connection_id in session
- .connection_pool()
- .await
- .user_connection_ids(member_id)
- {
- session.peer.send(connection_id, update.clone())?;
+ let connection_pool = &session.connection_pool().await;
+ notify_membership_updated(
+ &connection_pool,
+ membership_update,
+ member_id,
+ &session.peer,
+ );
+ for connection_id in connection_pool.user_connection_ids(member_id) {
+ if let Some(notification_id) = notification_id {
+ session
+ .peer
+ .send(
+ connection_id,
+ proto::DeleteNotification {
+ notification_id: notification_id.to_proto(),
+ },
+ )
+ .trace_err();
+ }
}
response.send(proto::Ack {})?;
Ok(())
}
-async fn set_channel_member_admin(
- request: proto::SetChannelMemberAdmin,
- response: Response<proto::SetChannelMemberAdmin>,
+async fn set_channel_visibility(
+ request: proto::SetChannelVisibility,
+ response: Response<proto::SetChannelVisibility>,
session: Session,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
- let member_id = UserId::from_proto(request.user_id);
- db.set_channel_member_admin(channel_id, session.user_id, member_id, request.admin)
+ let visibility = request.visibility().into();
+
+ let SetChannelVisibilityResult {
+ participants_to_update,
+ participants_to_remove,
+ channels_to_remove,
+ } = db
+ .set_channel_visibility(channel_id, visibility, session.user_id)
.await?;
- let (channel, has_accepted) = db
- .get_channel(channel_id, member_id)
- .await?
- .ok_or_else(|| anyhow!("channel not found"))?;
-
- let mut update = proto::UpdateChannels::default();
- if has_accepted {
- update.channel_permissions.push(proto::ChannelPermission {
- channel_id: channel.id.to_proto(),
- is_admin: request.admin,
- });
+ let connection_pool = session.connection_pool().await;
+ for (user_id, channels) in participants_to_update {
+ let update = build_channels_update(channels, vec![]);
+ for connection_id in connection_pool.user_connection_ids(user_id) {
+ session.peer.send(connection_id, update.clone())?;
+ }
}
-
- for connection_id in session
- .connection_pool()
- .await
- .user_connection_ids(member_id)
- {
- session.peer.send(connection_id, update.clone())?;
+ for user_id in participants_to_remove {
+ let update = proto::UpdateChannels {
+ delete_channels: channels_to_remove.iter().map(|id| id.to_proto()).collect(),
+ ..Default::default()
+ };
+ for connection_id in connection_pool.user_connection_ids(user_id) {
+ session.peer.send(connection_id, update.clone())?;
+ }
}
response.send(proto::Ack {})?;
Ok(())
}
-async fn rename_channel(
- request: proto::RenameChannel,
- response: Response<proto::RenameChannel>,
+async fn set_channel_member_role(
+ request: proto::SetChannelMemberRole,
+ response: Response<proto::SetChannelMemberRole>,
session: Session,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
- let new_name = db
- .rename_channel(channel_id, session.user_id, &request.name)
+ let member_id = UserId::from_proto(request.user_id);
+ let result = db
+ .set_channel_member_role(
+ channel_id,
+ session.user_id,
+ member_id,
+ request.role().into(),
+ )
.await?;
- let channel = proto::Channel {
- id: request.channel_id,
- name: new_name,
- };
- response.send(proto::RenameChannelResponse {
- channel: Some(channel.clone()),
- })?;
- let mut update = proto::UpdateChannels::default();
- update.channels.push(channel);
-
- let member_ids = db.get_channel_members(channel_id).await?;
-
- let connection_pool = session.connection_pool().await;
- for member_id in member_ids {
- for connection_id in connection_pool.user_connection_ids(member_id) {
- session.peer.send(connection_id, update.clone())?;
+ match result {
+ db::SetMemberRoleResult::MembershipUpdated(membership_update) => {
+ let connection_pool = session.connection_pool().await;
+ notify_membership_updated(
+ &connection_pool,
+ membership_update,
+ member_id,
+ &session.peer,
+ )
}
- }
-
- Ok(())
-}
-
-async fn link_channel(
- request: proto::LinkChannel,
- response: Response<proto::LinkChannel>,
- session: Session,
-) -> Result<()> {
- let db = session.db().await;
- let channel_id = ChannelId::from_proto(request.channel_id);
- let to = ChannelId::from_proto(request.to);
- let channels_to_send = db.link_channel(session.user_id, channel_id, to).await?;
+ db::SetMemberRoleResult::InviteUpdated(channel) => {
+ let update = proto::UpdateChannels {
+ channel_invitations: vec![channel.to_proto()],
+ ..Default::default()
+ };
- let members = db.get_channel_members(to).await?;
- let connection_pool = session.connection_pool().await;
- let update = proto::UpdateChannels {
- channels: channels_to_send
- .channels
- .into_iter()
- .map(|channel| proto::Channel {
- id: channel.id.to_proto(),
- name: channel.name,
- })
- .collect(),
- insert_edge: channels_to_send.edges,
- ..Default::default()
- };
- for member_id in members {
- for connection_id in connection_pool.user_connection_ids(member_id) {
- session.peer.send(connection_id, update.clone())?;
+ for connection_id in session
+ .connection_pool()
+ .await
+ .user_connection_ids(member_id)
+ {
+ session.peer.send(connection_id, update.clone())?;
+ }
}
}
- response.send(Ack {})?;
-
+ response.send(proto::Ack {})?;
Ok(())
}
-async fn unlink_channel(
- request: proto::UnlinkChannel,
- response: Response<proto::UnlinkChannel>,
+async fn rename_channel(
+ request: proto::RenameChannel,
+ response: Response<proto::RenameChannel>,
session: Session,
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
- let from = ChannelId::from_proto(request.from);
-
- db.unlink_channel(session.user_id, channel_id, from).await?;
+ let RenameChannelResult {
+ channel,
+ participants_to_update,
+ } = db
+ .rename_channel(channel_id, session.user_id, &request.name)
+ .await?;
- let members = db.get_channel_members(from).await?;
+ response.send(proto::RenameChannelResponse {
+ channel: Some(channel.to_proto()),
+ })?;
- let update = proto::UpdateChannels {
- delete_edge: vec![proto::ChannelEdge {
- channel_id: channel_id.to_proto(),
- parent_id: from.to_proto(),
- }],
- ..Default::default()
- };
let connection_pool = session.connection_pool().await;
- for member_id in members {
- for connection_id in connection_pool.user_connection_ids(member_id) {
+ for (user_id, channel) in participants_to_update {
+ for connection_id in connection_pool.user_connection_ids(user_id) {
+ let update = proto::UpdateChannels {
+ channels: vec![channel.to_proto()],
+ ..Default::default()
+ };
+
session.peer.send(connection_id, update.clone())?;
}
}
- response.send(Ack {})?;
-
Ok(())
}
@@ -2475,57 +2475,50 @@ async fn move_channel(
response: Response<proto::MoveChannel>,
session: Session,
) -> Result<()> {
- let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
- let from_parent = ChannelId::from_proto(request.from);
- let to = ChannelId::from_proto(request.to);
+ let to = request.to.map(ChannelId::from_proto);
- let channels_to_send = db
- .move_channel(session.user_id, channel_id, from_parent, to)
+ let result = session
+ .db()
+ .await
+ .move_channel(channel_id, to, session.user_id)
.await?;
- if channels_to_send.is_empty() {
- response.send(Ack {})?;
- return Ok(());
- }
+ notify_channel_moved(result, session).await?;
- let members_from = db.get_channel_members(from_parent).await?;
- let members_to = db.get_channel_members(to).await?;
+ response.send(Ack {})?;
+ Ok(())
+}
- let update = proto::UpdateChannels {
- delete_edge: vec![proto::ChannelEdge {
- channel_id: channel_id.to_proto(),
- parent_id: from_parent.to_proto(),
- }],
- ..Default::default()
+async fn notify_channel_moved(result: Option<MoveChannelResult>, session: Session) -> Result<()> {
+ let Some(MoveChannelResult {
+ participants_to_remove,
+ participants_to_update,
+ moved_channels,
+ }) = result
+ else {
+ return Ok(());
};
+ let moved_channels: Vec<u64> = moved_channels.iter().map(|id| id.to_proto()).collect();
+
let connection_pool = session.connection_pool().await;
- for member_id in members_from {
- for connection_id in connection_pool.user_connection_ids(member_id) {
+ for (user_id, channels) in participants_to_update {
+ let mut update = build_channels_update(channels, vec![]);
+ update.delete_channels = moved_channels.clone();
+ for connection_id in connection_pool.user_connection_ids(user_id) {
session.peer.send(connection_id, update.clone())?;
}
}
- let update = proto::UpdateChannels {
- channels: channels_to_send
- .channels
- .into_iter()
- .map(|channel| proto::Channel {
- id: channel.id.to_proto(),
- name: channel.name,
- })
- .collect(),
- insert_edge: channels_to_send.edges,
- ..Default::default()
- };
- for member_id in members_to {
- for connection_id in connection_pool.user_connection_ids(member_id) {
+ for user_id in participants_to_remove {
+ let update = proto::UpdateChannels {
+ delete_channels: moved_channels.clone(),
+ ..Default::default()
+ };
+ for connection_id in connection_pool.user_connection_ids(user_id) {
session.peer.send(connection_id, update.clone())?;
}
}
-
- response.send(Ack {})?;
-
Ok(())
}
@@ -2537,7 +2530,7 @@ async fn get_channel_members(
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
let members = db
- .get_channel_member_details(channel_id, session.user_id)
+ .get_channel_participant_details(channel_id, session.user_id)
.await?;
response.send(proto::GetChannelMembersResponse { members })?;
Ok(())
@@ -2550,54 +2543,34 @@ async fn respond_to_channel_invite(
) -> Result<()> {
let db = session.db().await;
let channel_id = ChannelId::from_proto(request.channel_id);
- db.respond_to_channel_invite(channel_id, session.user_id, request.accept)
+ let RespondToChannelInvite {
+ membership_update,
+ notifications,
+ } = db
+ .respond_to_channel_invite(channel_id, session.user_id, request.accept)
.await?;
- let mut update = proto::UpdateChannels::default();
- update
- .remove_channel_invitations
- .push(channel_id.to_proto());
- if request.accept {
- let result = db.get_channel_for_user(channel_id, session.user_id).await?;
- update
- .channels
- .extend(
- result
- .channels
- .channels
- .into_iter()
- .map(|channel| proto::Channel {
- id: channel.id.to_proto(),
- name: channel.name,
- }),
- );
- update.unseen_channel_messages = result.channel_messages;
- update.unseen_channel_buffer_changes = result.unseen_buffer_changes;
- update.insert_edge = result.channels.edges;
- update
- .channel_participants
- .extend(
- result
- .channel_participants
- .into_iter()
- .map(|(channel_id, user_ids)| proto::ChannelParticipants {
- channel_id: channel_id.to_proto(),
- participant_user_ids: user_ids.into_iter().map(UserId::to_proto).collect(),
- }),
- );
- update
- .channel_permissions
- .extend(
- result
- .channels_with_admin_privileges
- .into_iter()
- .map(|channel_id| proto::ChannelPermission {
- channel_id: channel_id.to_proto(),
- is_admin: true,
- }),
- );
- }
- session.peer.send(session.connection_id, update)?;
+ let connection_pool = session.connection_pool().await;
+ if let Some(membership_update) = membership_update {
+ notify_membership_updated(
+ &connection_pool,
+ membership_update,
+ session.user_id,
+ &session.peer,
+ );
+ } else {
+ let update = proto::UpdateChannels {
+ remove_channel_invitations: vec![channel_id.to_proto()],
+ ..Default::default()
+ };
+
+ for connection_id in connection_pool.user_connection_ids(session.user_id) {
+ session.peer.send(connection_id, update.clone())?;
+ }
+ };
+
+ send_notifications(&*connection_pool, &session.peer, notifications);
+
response.send(proto::Ack {})?;
Ok(())
@@ -2609,19 +2582,35 @@ async fn join_channel(
session: Session,
) -> Result<()> {
let channel_id = ChannelId::from_proto(request.channel_id);
- let live_kit_room = format!("channel-{}", nanoid::nanoid!(30));
+ join_channel_internal(channel_id, Box::new(response), session).await
+}
+
+trait JoinChannelInternalResponse {
+ fn send(self, result: proto::JoinRoomResponse) -> Result<()>;
+}
+impl JoinChannelInternalResponse for Response<proto::JoinChannel> {
+ fn send(self, result: proto::JoinRoomResponse) -> Result<()> {
+ Response::<proto::JoinChannel>::send(self, result)
+ }
+}
+impl JoinChannelInternalResponse for Response<proto::JoinRoom> {
+ fn send(self, result: proto::JoinRoomResponse) -> Result<()> {
+ Response::<proto::JoinRoom>::send(self, result)
+ }
+}
+async fn join_channel_internal(
+ channel_id: ChannelId,
+ response: Box<impl JoinChannelInternalResponse>,
+ session: Session,
+) -> Result<()> {
let joined_room = {
leave_room_for_session(&session).await?;
let db = session.db().await;
- let room_id = db
- .get_or_create_channel_room(channel_id, &live_kit_room, &*RELEASE_CHANNEL_NAME)
- .await?;
-
- let joined_room = db
- .join_room(
- room_id,
+ let (joined_room, membership_updated, role) = db
+ .join_channel(
+ channel_id,
session.user_id,
session.connection_id,
RELEASE_CHANNEL_NAME.as_str(),
@@ -2629,16 +2618,32 @@ async fn join_channel(
.await?;
let live_kit_connection_info = session.live_kit_client.as_ref().and_then(|live_kit| {
- let token = live_kit
- .room_token(
- &joined_room.room.live_kit_room,
- &session.user_id.to_string(),
+ let (can_publish, token) = if role == ChannelRole::Guest {
+ (
+ false,
+ live_kit
+ .guest_token(
+ &joined_room.room.live_kit_room,
+ &session.user_id.to_string(),
+ )
+ .trace_err()?,
)
- .trace_err()?;
+ } else {
+ (
+ true,
+ live_kit
+ .room_token(
+ &joined_room.room.live_kit_room,
+ &session.user_id.to_string(),
+ )
+ .trace_err()?,
+ )
+ };
Some(LiveKitConnectionInfo {
server_url: live_kit.url().into(),
token,
+ can_publish,
})
});
@@ -2648,9 +2653,19 @@ async fn join_channel(
live_kit_connection_info,
})?;
+ let connection_pool = session.connection_pool().await;
+ if let Some(membership_updated) = membership_updated {
+ notify_membership_updated(
+ &connection_pool,
+ membership_updated,
+ session.user_id,
+ &session.peer,
+ );
+ }
+
room_updated(&joined_room.room, &session.peer);
- joined_room.into_inner()
+ joined_room
};
channel_updated(
@@ -2662,7 +2677,6 @@ async fn join_channel(
);
update_user_contacts(session.user_id, &session).await?;
-
Ok(())
}
@@ -2815,6 +2829,29 @@ fn channel_buffer_updated<T: EnvelopedMessage>(
});
}
+fn send_notifications(
+ connection_pool: &ConnectionPool,
+ peer: &Peer,
+ notifications: db::NotificationBatch,
+) {
+ for (user_id, notification) in notifications {
+ for connection_id in connection_pool.user_connection_ids(user_id) {
+ if let Err(error) = peer.send(
+ connection_id,
+ proto::AddNotification {
+ notification: Some(notification.clone()),
+ },
+ ) {
+ tracing::error!(
+ "failed to send notification to {:?} {}",
+ connection_id,
+ error
+ );
+ }
+ }
+ }
+}
+
async fn send_channel_message(
request: proto::SendChannelMessage,
response: Response<proto::SendChannelMessage>,
@@ -2829,19 +2866,27 @@ async fn send_channel_message(
return Err(anyhow!("message can't be blank"))?;
}
+ // TODO: adjust mentions if body is trimmed
+
let timestamp = OffsetDateTime::now_utc();
let nonce = request
.nonce
.ok_or_else(|| anyhow!("nonce can't be blank"))?;
let channel_id = ChannelId::from_proto(request.channel_id);
- let (message_id, connection_ids, non_participants) = session
+ let CreatedChannelMessage {
+ message_id,
+ participant_connection_ids,
+ channel_members,
+ notifications,
+ } = session
.db()
.await
.create_channel_message(
channel_id,
session.user_id,
&body,
+ &request.mentions,
timestamp,
nonce.clone().into(),
)
@@ -2850,18 +2895,23 @@ async fn send_channel_message(
sender_id: session.user_id.to_proto(),
id: message_id.to_proto(),
body,
+ mentions: request.mentions,
timestamp: timestamp.unix_timestamp() as u64,
nonce: Some(nonce),
};
- broadcast(Some(session.connection_id), connection_ids, |connection| {
- session.peer.send(
- connection,
- proto::ChannelMessageSent {
- channel_id: channel_id.to_proto(),
- message: Some(message.clone()),
- },
- )
- });
+ broadcast(
+ Some(session.connection_id),
+ participant_connection_ids,
+ |connection| {
+ session.peer.send(
+ connection,
+ proto::ChannelMessageSent {
+ channel_id: channel_id.to_proto(),
+ message: Some(message.clone()),
+ },
+ )
+ },
+ );
response.send(proto::SendChannelMessageResponse {
message: Some(message),
})?;
@@ -2869,7 +2919,7 @@ async fn send_channel_message(
let pool = &*session.connection_pool().await;
broadcast(
None,
- non_participants
+ channel_members
.iter()
.flat_map(|user_id| pool.user_connection_ids(*user_id)),
|peer_id| {
@@ -2885,6 +2935,7 @@ async fn send_channel_message(
)
},
);
+ send_notifications(pool, &session.peer, notifications);
Ok(())
}
@@ -6,6 +6,7 @@ mod channel_message_tests;
mod channel_tests;
mod following_tests;
mod integration_tests;
+mod notification_tests;
mod random_channel_buffer_tests;
mod random_project_collaboration_tests;
mod randomized_test_helpers;
@@ -39,3 +40,7 @@ fn room_participants(room: &ModelHandle<Room>, cx: &mut TestAppContext) -> RoomP
RoomParticipants { remote, pending }
})
}
+
+fn channel_id(room: &ModelHandle<Room>, cx: &mut TestAppContext) -> Option<u64> {
+ cx.read(|cx| room.read(cx).channel_id())
+}
@@ -3,7 +3,7 @@ use crate::{
tests::TestServer,
};
use call::ActiveCall;
-use channel::{Channel, ACKNOWLEDGE_DEBOUNCE_INTERVAL};
+use channel::ACKNOWLEDGE_DEBOUNCE_INTERVAL;
use client::ParticipantIndex;
use client::{Collaborator, UserId};
use collab_ui::channel_view::ChannelView;
@@ -407,11 +407,8 @@ async fn test_channel_buffer_disconnect(
server.disconnect_client(client_a.peer_id().unwrap());
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
- channel_buffer_a.update(cx_a, |buffer, _| {
- assert_eq!(
- buffer.channel().as_ref(),
- &channel(channel_id, "the-channel")
- );
+ channel_buffer_a.update(cx_a, |buffer, cx| {
+ assert_eq!(buffer.channel(cx).unwrap().name, "the-channel");
assert!(!buffer.is_connected());
});
@@ -432,24 +429,12 @@ async fn test_channel_buffer_disconnect(
deterministic.run_until_parked();
// Channel buffer observed the deletion
- channel_buffer_b.update(cx_b, |buffer, _| {
- assert_eq!(
- buffer.channel().as_ref(),
- &channel(channel_id, "the-channel")
- );
+ channel_buffer_b.update(cx_b, |buffer, cx| {
+ assert!(buffer.channel(cx).is_none());
assert!(!buffer.is_connected());
});
}
-fn channel(id: u64, name: &'static str) -> Channel {
- Channel {
- id,
- name: name.to_string(),
- unseen_note_version: None,
- unseen_message_id: None,
- }
-}
-
#[gpui::test]
async fn test_rejoin_channel_buffer(
deterministic: Arc<Deterministic>,
@@ -694,7 +679,7 @@ async fn test_following_to_channel_notes_without_a_shared_project(
.await
.unwrap();
channel_view_1_a.update(cx_a, |notes, cx| {
- assert_eq!(notes.channel(cx).name, "channel-1");
+ assert_eq!(notes.channel(cx).unwrap().name, "channel-1");
notes.editor.update(cx, |editor, cx| {
editor.insert("Hello from A.", cx);
editor.change_selections(None, cx, |selections| {
@@ -726,7 +711,7 @@ async fn test_following_to_channel_notes_without_a_shared_project(
.expect("active item is not a channel view")
});
channel_view_1_b.read_with(cx_b, |notes, cx| {
- assert_eq!(notes.channel(cx).name, "channel-1");
+ assert_eq!(notes.channel(cx).unwrap().name, "channel-1");
let editor = notes.editor.read(cx);
assert_eq!(editor.text(cx), "Hello from A.");
assert_eq!(editor.selections.ranges::<usize>(cx), &[3..4]);
@@ -738,7 +723,7 @@ async fn test_following_to_channel_notes_without_a_shared_project(
.await
.unwrap();
channel_view_2_a.read_with(cx_a, |notes, cx| {
- assert_eq!(notes.channel(cx).name, "channel-2");
+ assert_eq!(notes.channel(cx).unwrap().name, "channel-2");
});
// Client B is taken to the notes for channel 2.
@@ -755,7 +740,7 @@ async fn test_following_to_channel_notes_without_a_shared_project(
.expect("active item is not a channel view")
});
channel_view_2_b.read_with(cx_b, |notes, cx| {
- assert_eq!(notes.channel(cx).name, "channel-2");
+ assert_eq!(notes.channel(cx).unwrap().name, "channel-2");
});
}
@@ -1,27 +1,30 @@
use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer};
-use channel::{ChannelChat, ChannelMessageId};
+use channel::{ChannelChat, ChannelMessageId, MessageParams};
use collab_ui::chat_panel::ChatPanel;
use gpui::{executor::Deterministic, BorrowAppContext, ModelHandle, TestAppContext};
+use rpc::Notification;
use std::sync::Arc;
use workspace::dock::Panel;
#[gpui::test]
async fn test_basic_channel_messages(
deterministic: Arc<Deterministic>,
- cx_a: &mut TestAppContext,
- cx_b: &mut TestAppContext,
+ mut cx_a: &mut TestAppContext,
+ mut cx_b: &mut TestAppContext,
+ mut cx_c: &mut TestAppContext,
) {
deterministic.forbid_parking();
let mut server = TestServer::start(&deterministic).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
+ let client_c = server.create_client(cx_c, "user_c").await;
let channel_id = server
.make_channel(
"the-channel",
None,
(&client_a, cx_a),
- &mut [(&client_b, cx_b)],
+ &mut [(&client_b, cx_b), (&client_c, cx_c)],
)
.await;
@@ -36,8 +39,17 @@ async fn test_basic_channel_messages(
.await
.unwrap();
- channel_chat_a
- .update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap())
+ let message_id = channel_chat_a
+ .update(cx_a, |c, cx| {
+ c.send_message(
+ MessageParams {
+ text: "hi @user_c!".into(),
+ mentions: vec![(3..10, client_c.id())],
+ },
+ cx,
+ )
+ .unwrap()
+ })
.await
.unwrap();
channel_chat_a
@@ -52,15 +64,55 @@ async fn test_basic_channel_messages(
.unwrap();
deterministic.run_until_parked();
- channel_chat_a.update(cx_a, |c, _| {
+
+ let channel_chat_c = client_c
+ .channel_store()
+ .update(cx_c, |store, cx| store.open_channel_chat(channel_id, cx))
+ .await
+ .unwrap();
+
+ for (chat, cx) in [
+ (&channel_chat_a, &mut cx_a),
+ (&channel_chat_b, &mut cx_b),
+ (&channel_chat_c, &mut cx_c),
+ ] {
+ chat.update(*cx, |c, _| {
+ assert_eq!(
+ c.messages()
+ .iter()
+ .map(|m| (m.body.as_str(), m.mentions.as_slice()))
+ .collect::<Vec<_>>(),
+ vec![
+ ("hi @user_c!", [(3..10, client_c.id())].as_slice()),
+ ("two", &[]),
+ ("three", &[])
+ ],
+ "results for user {}",
+ c.client().id(),
+ );
+ });
+ }
+
+ client_c.notification_store().update(cx_c, |store, _| {
+ assert_eq!(store.notification_count(), 2);
+ assert_eq!(store.unread_notification_count(), 1);
assert_eq!(
- c.messages()
- .iter()
- .map(|m| m.body.as_str())
- .collect::<Vec<_>>(),
- vec!["one", "two", "three"]
+ store.notification_at(0).unwrap().notification,
+ Notification::ChannelMessageMention {
+ message_id,
+ sender_id: client_a.id(),
+ channel_id,
+ }
);
- })
+ assert_eq!(
+ store.notification_at(1).unwrap().notification,
+ Notification::ChannelInvitation {
+ channel_id,
+ channel_name: "the-channel".to_string(),
+ inviter_id: client_a.id()
+ }
+ );
+ });
}
#[gpui::test]
@@ -280,7 +332,7 @@ async fn test_channel_message_changes(
chat_panel_b
.update(cx_b, |chat_panel, cx| {
chat_panel.set_active(true, cx);
- chat_panel.select_channel(channel_id, cx)
+ chat_panel.select_channel(channel_id, None, cx)
})
.await
.unwrap();
@@ -1,12 +1,17 @@
use crate::{
+ db::{self, UserId},
rpc::RECONNECT_TIMEOUT,
tests::{room_participants, RoomParticipants, TestServer},
};
use call::ActiveCall;
use channel::{ChannelId, ChannelMembership, ChannelStore};
use client::User;
+use futures::future::try_join_all;
use gpui::{executor::Deterministic, ModelHandle, TestAppContext};
-use rpc::{proto, RECEIVE_TIMEOUT};
+use rpc::{
+ proto::{self, ChannelRole},
+ RECEIVE_TIMEOUT,
+};
use std::sync::Arc;
#[gpui::test]
@@ -44,22 +49,19 @@ async fn test_core_channels(
id: channel_a_id,
name: "channel-a".to_string(),
depth: 0,
- user_is_admin: true,
+ role: ChannelRole::Admin,
},
ExpectedChannel {
id: channel_b_id,
name: "channel-b".to_string(),
depth: 1,
- user_is_admin: true,
+ role: ChannelRole::Admin,
},
],
);
client_b.channel_store().read_with(cx_b, |channels, _| {
- assert!(channels
- .channel_dag_entries()
- .collect::<Vec<_>>()
- .is_empty())
+ assert!(channels.ordered_channels().collect::<Vec<_>>().is_empty())
});
// Invite client B to channel A as client A.
@@ -68,7 +70,12 @@ async fn test_core_channels(
.update(cx_a, |store, cx| {
assert!(!store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap()));
- let invite = store.invite_member(channel_a_id, client_b.user_id().unwrap(), false, cx);
+ let invite = store.invite_member(
+ channel_a_id,
+ client_b.user_id().unwrap(),
+ proto::ChannelRole::Member,
+ cx,
+ );
// Make sure we're synchronously storing the pending invite
assert!(store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap()));
@@ -86,7 +93,7 @@ async fn test_core_channels(
id: channel_a_id,
name: "channel-a".to_string(),
depth: 0,
- user_is_admin: false,
+ role: ChannelRole::Member,
}],
);
@@ -103,12 +110,12 @@ async fn test_core_channels(
&[
(
client_a.user_id().unwrap(),
- true,
+ proto::ChannelRole::Admin,
proto::channel_member::Kind::Member,
),
(
client_b.user_id().unwrap(),
- false,
+ proto::ChannelRole::Member,
proto::channel_member::Kind::Invitee,
),
],
@@ -117,8 +124,8 @@ async fn test_core_channels(
// Client B accepts the invitation.
client_b
.channel_store()
- .update(cx_b, |channels, _| {
- channels.respond_to_channel_invite(channel_a_id, true)
+ .update(cx_b, |channels, cx| {
+ channels.respond_to_channel_invite(channel_a_id, true, cx)
})
.await
.unwrap();
@@ -133,13 +140,13 @@ async fn test_core_channels(
ExpectedChannel {
id: channel_a_id,
name: "channel-a".to_string(),
- user_is_admin: false,
+ role: ChannelRole::Member,
depth: 0,
},
ExpectedChannel {
id: channel_b_id,
name: "channel-b".to_string(),
- user_is_admin: false,
+ role: ChannelRole::Member,
depth: 1,
},
],
@@ -161,19 +168,19 @@ async fn test_core_channels(
ExpectedChannel {
id: channel_a_id,
name: "channel-a".to_string(),
- user_is_admin: false,
+ role: ChannelRole::Member,
depth: 0,
},
ExpectedChannel {
id: channel_b_id,
name: "channel-b".to_string(),
- user_is_admin: false,
+ role: ChannelRole::Member,
depth: 1,
},
ExpectedChannel {
id: channel_c_id,
name: "channel-c".to_string(),
- user_is_admin: false,
+ role: ChannelRole::Member,
depth: 2,
},
],
@@ -183,7 +190,12 @@ async fn test_core_channels(
client_a
.channel_store()
.update(cx_a, |store, cx| {
- store.set_member_admin(channel_a_id, client_b.user_id().unwrap(), true, cx)
+ store.set_member_role(
+ channel_a_id,
+ client_b.user_id().unwrap(),
+ proto::ChannelRole::Admin,
+ cx,
+ )
})
.await
.unwrap();
@@ -200,19 +212,19 @@ async fn test_core_channels(
id: channel_a_id,
name: "channel-a".to_string(),
depth: 0,
- user_is_admin: true,
+ role: ChannelRole::Admin,
},
ExpectedChannel {
id: channel_b_id,
name: "channel-b".to_string(),
depth: 1,
- user_is_admin: true,
+ role: ChannelRole::Admin,
},
ExpectedChannel {
id: channel_c_id,
name: "channel-c".to_string(),
depth: 2,
- user_is_admin: true,
+ role: ChannelRole::Admin,
},
],
);
@@ -234,7 +246,7 @@ async fn test_core_channels(
id: channel_a_id,
name: "channel-a".to_string(),
depth: 0,
- user_is_admin: true,
+ role: ChannelRole::Admin,
}],
);
assert_channels(
@@ -244,7 +256,7 @@ async fn test_core_channels(
id: channel_a_id,
name: "channel-a".to_string(),
depth: 0,
- user_is_admin: true,
+ role: ChannelRole::Admin,
}],
);
@@ -267,18 +279,27 @@ async fn test_core_channels(
id: channel_a_id,
name: "channel-a".to_string(),
depth: 0,
- user_is_admin: true,
+ role: ChannelRole::Admin,
}],
);
// Client B no longer has access to the channel
assert_channels(client_b.channel_store(), cx_b, &[]);
- // When disconnected, client A sees no channels.
server.forbid_connections();
server.disconnect_client(client_a.peer_id().unwrap());
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
- assert_channels(client_a.channel_store(), cx_a, &[]);
+
+ server
+ .app_state
+ .db
+ .rename_channel(
+ db::ChannelId::from_proto(channel_a_id),
+ UserId::from_proto(client_a.id()),
+ "channel-a-renamed",
+ )
+ .await
+ .unwrap();
server.allow_connections();
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
@@ -287,9 +308,9 @@ async fn test_core_channels(
cx_a,
&[ExpectedChannel {
id: channel_a_id,
- name: "channel-a".to_string(),
+ name: "channel-a-renamed".to_string(),
depth: 0,
- user_is_admin: true,
+ role: ChannelRole::Admin,
}],
);
}
@@ -305,12 +326,12 @@ fn assert_participants_eq(participants: &[Arc<User>], expected_partitipants: &[u
#[track_caller]
fn assert_members_eq(
members: &[ChannelMembership],
- expected_members: &[(u64, bool, proto::channel_member::Kind)],
+ expected_members: &[(u64, proto::ChannelRole, proto::channel_member::Kind)],
) {
assert_eq!(
members
.iter()
- .map(|member| (member.user.id, member.admin, member.kind))
+ .map(|member| (member.user.id, member.role, member.kind))
.collect::<Vec<_>>(),
expected_members
);
@@ -397,7 +418,7 @@ async fn test_channel_room(
id: zed_id,
name: "zed".to_string(),
depth: 0,
- user_is_admin: false,
+ role: ChannelRole::Member,
}],
);
client_b.channel_store().read_with(cx_b, |channels, _| {
@@ -611,7 +632,12 @@ async fn test_permissions_update_while_invited(
client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
- channel_store.invite_member(rust_id, client_b.user_id().unwrap(), false, cx)
+ channel_store.invite_member(
+ rust_id,
+ client_b.user_id().unwrap(),
+ proto::ChannelRole::Member,
+ cx,
+ )
})
.await
.unwrap();
@@ -625,7 +651,7 @@ async fn test_permissions_update_while_invited(
depth: 0,
id: rust_id,
name: "rust".to_string(),
- user_is_admin: false,
+ role: ChannelRole::Member,
}],
);
assert_channels(client_b.channel_store(), cx_b, &[]);
@@ -634,7 +660,12 @@ async fn test_permissions_update_while_invited(
client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
- channel_store.set_member_admin(rust_id, client_b.user_id().unwrap(), true, cx)
+ channel_store.set_member_role(
+ rust_id,
+ client_b.user_id().unwrap(),
+ proto::ChannelRole::Admin,
+ cx,
+ )
})
.await
.unwrap();
@@ -648,7 +679,7 @@ async fn test_permissions_update_while_invited(
depth: 0,
id: rust_id,
name: "rust".to_string(),
- user_is_admin: false,
+ role: ChannelRole::Member,
}],
);
assert_channels(client_b.channel_store(), cx_b, &[]);
@@ -688,7 +719,7 @@ async fn test_channel_rename(
depth: 0,
id: rust_id,
name: "rust-archive".to_string(),
- user_is_admin: true,
+ role: ChannelRole::Admin,
}],
);
@@ -700,7 +731,7 @@ async fn test_channel_rename(
depth: 0,
id: rust_id,
name: "rust-archive".to_string(),
- user_is_admin: false,
+ role: ChannelRole::Member,
}],
);
}
@@ -803,7 +834,12 @@ async fn test_lost_channel_creation(
client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
- channel_store.invite_member(channel_id, client_b.user_id().unwrap(), false, cx)
+ channel_store.invite_member(
+ channel_id,
+ client_b.user_id().unwrap(),
+ proto::ChannelRole::Member,
+ cx,
+ )
})
.await
.unwrap();
@@ -818,7 +854,7 @@ async fn test_lost_channel_creation(
depth: 0,
id: channel_id,
name: "x".to_string(),
- user_is_admin: false,
+ role: ChannelRole::Member,
}],
);
@@ -842,13 +878,13 @@ async fn test_lost_channel_creation(
depth: 0,
id: channel_id,
name: "x".to_string(),
- user_is_admin: true,
+ role: ChannelRole::Admin,
},
ExpectedChannel {
depth: 1,
id: subchannel_id,
name: "subchannel".to_string(),
- user_is_admin: true,
+ role: ChannelRole::Admin,
},
],
);
@@ -856,8 +892,8 @@ async fn test_lost_channel_creation(
// Client B accepts the invite
client_b
.channel_store()
- .update(cx_b, |channel_store, _| {
- channel_store.respond_to_channel_invite(channel_id, true)
+ .update(cx_b, |channel_store, cx| {
+ channel_store.respond_to_channel_invite(channel_id, true, cx)
})
.await
.unwrap();
@@ -873,234 +909,507 @@ async fn test_lost_channel_creation(
depth: 0,
id: channel_id,
name: "x".to_string(),
- user_is_admin: false,
+ role: ChannelRole::Member,
},
ExpectedChannel {
depth: 1,
id: subchannel_id,
name: "subchannel".to_string(),
- user_is_admin: false,
+ role: ChannelRole::Member,
},
],
);
}
#[gpui::test]
-async fn test_channel_moving(
+async fn test_channel_link_notifications(
deterministic: Arc<Deterministic>,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
cx_c: &mut TestAppContext,
) {
deterministic.forbid_parking();
+
let mut server = TestServer::start(&deterministic).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let client_c = server.create_client(cx_c, "user_c").await;
+ let user_b = client_b.user_id().unwrap();
+ let user_c = client_c.user_id().unwrap();
+
let channels = server
- .make_channel_tree(
- &[
- ("channel-a", None),
- ("channel-b", Some("channel-a")),
- ("channel-c", Some("channel-b")),
- ("channel-d", Some("channel-c")),
- ],
- (&client_a, cx_a),
- )
+ .make_channel_tree(&[("zed", None)], (&client_a, cx_a))
.await;
- let channel_a_id = channels[0];
- let channel_b_id = channels[1];
- let channel_c_id = channels[2];
- let channel_d_id = channels[3];
+ let zed_channel = channels[0];
+
+ try_join_all(client_a.channel_store().update(cx_a, |channel_store, cx| {
+ [
+ channel_store.set_channel_visibility(zed_channel, proto::ChannelVisibility::Public, cx),
+ channel_store.invite_member(zed_channel, user_b, proto::ChannelRole::Member, cx),
+ channel_store.invite_member(zed_channel, user_c, proto::ChannelRole::Guest, cx),
+ ]
+ }))
+ .await
+ .unwrap();
- // Current shape:
- // a - b - c - d
+ deterministic.run_until_parked();
+
+ client_b
+ .channel_store()
+ .update(cx_b, |channel_store, cx| {
+ channel_store.respond_to_channel_invite(zed_channel, true, cx)
+ })
+ .await
+ .unwrap();
+
+ client_c
+ .channel_store()
+ .update(cx_c, |channel_store, cx| {
+ channel_store.respond_to_channel_invite(zed_channel, true, cx)
+ })
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ // we have an admin (a), member (b) and guest (c) all part of the zed channel.
+
+ // create a new private channel, make it public, and move it under the previous one, and verify it shows for b and not c
+ let active_channel = client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.create_channel("active", Some(zed_channel), cx)
+ })
+ .await
+ .unwrap();
+
+ // the new channel shows for b and not c
assert_channels_list_shape(
client_a.channel_store(),
cx_a,
- &[
- (channel_a_id, 0),
- (channel_b_id, 1),
- (channel_c_id, 2),
- (channel_d_id, 3),
- ],
+ &[(zed_channel, 0), (active_channel, 1)],
);
+ assert_channels_list_shape(
+ client_b.channel_store(),
+ cx_b,
+ &[(zed_channel, 0), (active_channel, 1)],
+ );
+ assert_channels_list_shape(client_c.channel_store(), cx_c, &[(zed_channel, 0)]);
+
+ let vim_channel = client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.create_channel("vim", None, cx)
+ })
+ .await
+ .unwrap();
client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
- channel_store.move_channel(channel_d_id, channel_c_id, channel_b_id, cx)
+ channel_store.set_channel_visibility(vim_channel, proto::ChannelVisibility::Public, cx)
})
.await
.unwrap();
- // Current shape:
- // /- d
- // a - b -- c
+ client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.move_channel(vim_channel, Some(active_channel), cx)
+ })
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ // the new channel shows for b and c
assert_channels_list_shape(
client_a.channel_store(),
cx_a,
- &[
- (channel_a_id, 0),
- (channel_b_id, 1),
- (channel_c_id, 2),
- (channel_d_id, 2),
- ],
+ &[(zed_channel, 0), (active_channel, 1), (vim_channel, 2)],
+ );
+ assert_channels_list_shape(
+ client_b.channel_store(),
+ cx_b,
+ &[(zed_channel, 0), (active_channel, 1), (vim_channel, 2)],
);
+ assert_channels_list_shape(
+ client_c.channel_store(),
+ cx_c,
+ &[(zed_channel, 0), (vim_channel, 1)],
+ );
+
+ let helix_channel = client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.create_channel("helix", None, cx)
+ })
+ .await
+ .unwrap();
+
+ client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.move_channel(helix_channel, Some(vim_channel), cx)
+ })
+ .await
+ .unwrap();
client_a
.channel_store()
.update(cx_a, |channel_store, cx| {
- channel_store.link_channel(channel_d_id, channel_c_id, cx)
+ channel_store.set_channel_visibility(
+ helix_channel,
+ proto::ChannelVisibility::Public,
+ cx,
+ )
})
.await
.unwrap();
- // Current shape for A:
- // /------\
- // a - b -- c -- d
+ // the new channel shows for b and c
assert_channels_list_shape(
- client_a.channel_store(),
- cx_a,
+ client_b.channel_store(),
+ cx_b,
&[
- (channel_a_id, 0),
- (channel_b_id, 1),
- (channel_c_id, 2),
- (channel_d_id, 3),
- (channel_d_id, 2),
+ (zed_channel, 0),
+ (active_channel, 1),
+ (vim_channel, 2),
+ (helix_channel, 3),
],
);
-
- let b_channels = server
- .make_channel_tree(
- &[
- ("channel-mu", None),
- ("channel-gamma", Some("channel-mu")),
- ("channel-epsilon", Some("channel-mu")),
- ],
- (&client_b, cx_b),
- )
- .await;
- let channel_mu_id = b_channels[0];
- let channel_ga_id = b_channels[1];
- let channel_ep_id = b_channels[2];
-
- // Current shape for B:
- // /- ep
- // mu -- ga
assert_channels_list_shape(
- client_b.channel_store(),
- cx_b,
- &[(channel_mu_id, 0), (channel_ep_id, 1), (channel_ga_id, 1)],
+ client_c.channel_store(),
+ cx_c,
+ &[(zed_channel, 0), (vim_channel, 1), (helix_channel, 2)],
);
client_a
- .add_admin_to_channel((&client_b, cx_b), channel_b_id, cx_a)
- .await;
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.set_channel_visibility(vim_channel, proto::ChannelVisibility::Members, cx)
+ })
+ .await
+ .unwrap();
- // Current shape for B:
- // /- ep
- // mu -- ga
- // /---------\
- // b -- c -- d
+ // the members-only channel is still shown for c, but hidden for b
assert_channels_list_shape(
client_b.channel_store(),
cx_b,
&[
- // New channels from a
- (channel_b_id, 0),
- (channel_c_id, 1),
- (channel_d_id, 2),
- (channel_d_id, 1),
- // B's old channels
- (channel_mu_id, 0),
- (channel_ep_id, 1),
- (channel_ga_id, 1),
+ (zed_channel, 0),
+ (active_channel, 1),
+ (vim_channel, 2),
+ (helix_channel, 3),
],
);
-
client_b
- .add_admin_to_channel((&client_c, cx_c), channel_ep_id, cx_b)
+ .channel_store()
+ .read_with(cx_b, |channel_store, _| {
+ assert_eq!(
+ channel_store
+ .channel_for_id(vim_channel)
+ .unwrap()
+ .visibility,
+ proto::ChannelVisibility::Members
+ )
+ });
+
+ assert_channels_list_shape(client_c.channel_store(), cx_c, &[(zed_channel, 0)]);
+}
+
+#[gpui::test]
+async fn test_channel_membership_notifications(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
+
+ deterministic.forbid_parking();
+
+ let mut server = TestServer::start(&deterministic).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_c").await;
+
+ let user_b = client_b.user_id().unwrap();
+
+ let channels = server
+ .make_channel_tree(
+ &[
+ ("zed", None),
+ ("active", Some("zed")),
+ ("vim", Some("active")),
+ ],
+ (&client_a, cx_a),
+ )
.await;
+ let zed_channel = channels[0];
+ let _active_channel = channels[1];
+ let vim_channel = channels[2];
+
+ try_join_all(client_a.channel_store().update(cx_a, |channel_store, cx| {
+ [
+ channel_store.set_channel_visibility(zed_channel, proto::ChannelVisibility::Public, cx),
+ channel_store.set_channel_visibility(vim_channel, proto::ChannelVisibility::Public, cx),
+ channel_store.invite_member(vim_channel, user_b, proto::ChannelRole::Member, cx),
+ channel_store.invite_member(zed_channel, user_b, proto::ChannelRole::Guest, cx),
+ ]
+ }))
+ .await
+ .unwrap();
- // Current shape for C:
- // - ep
- assert_channels_list_shape(client_c.channel_store(), cx_c, &[(channel_ep_id, 0)]);
+ deterministic.run_until_parked();
client_b
.channel_store()
.update(cx_b, |channel_store, cx| {
- channel_store.link_channel(channel_b_id, channel_ep_id, cx)
+ channel_store.respond_to_channel_invite(zed_channel, true, cx)
})
.await
.unwrap();
- // Current shape for B:
- // /---------\
- // /- ep -- b -- c -- d
- // mu -- ga
- assert_channels_list_shape(
+ client_b
+ .channel_store()
+ .update(cx_b, |channel_store, cx| {
+ channel_store.respond_to_channel_invite(vim_channel, true, cx)
+ })
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ // we have an admin (a), and a guest (b) with access to all of zed, and membership in vim.
+ assert_channels(
client_b.channel_store(),
cx_b,
&[
- (channel_mu_id, 0),
- (channel_ep_id, 1),
- (channel_b_id, 2),
- (channel_c_id, 3),
- (channel_d_id, 4),
- (channel_d_id, 3),
- (channel_ga_id, 1),
+ ExpectedChannel {
+ depth: 0,
+ id: zed_channel,
+ name: "zed".to_string(),
+ role: ChannelRole::Guest,
+ },
+ ExpectedChannel {
+ depth: 1,
+ id: vim_channel,
+ name: "vim".to_string(),
+ role: ChannelRole::Member,
+ },
],
);
- // Current shape for C:
- // /---------\
- // ep -- b -- c -- d
- assert_channels_list_shape(
- client_c.channel_store(),
- cx_c,
+ client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.remove_member(vim_channel, user_b, cx)
+ })
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ assert_channels(
+ client_b.channel_store(),
+ cx_b,
&[
- (channel_ep_id, 0),
- (channel_b_id, 1),
- (channel_c_id, 2),
- (channel_d_id, 3),
- (channel_d_id, 2),
+ ExpectedChannel {
+ depth: 0,
+ id: zed_channel,
+ name: "zed".to_string(),
+ role: ChannelRole::Guest,
+ },
+ ExpectedChannel {
+ depth: 1,
+ id: vim_channel,
+ name: "vim".to_string(),
+ role: ChannelRole::Guest,
+ },
],
- );
+ )
+}
- client_b
+#[gpui::test]
+async fn test_guest_access(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
+
+ let mut server = TestServer::start(&deterministic).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+
+ let channels = server
+ .make_channel_tree(
+ &[("channel-a", None), ("channel-b", Some("channel-a"))],
+ (&client_a, cx_a),
+ )
+ .await;
+ let channel_a = channels[0];
+ let channel_b = channels[1];
+
+ let active_call_b = cx_b.read(ActiveCall::global);
+
+ // Non-members should not be allowed to join
+ assert!(active_call_b
+ .update(cx_b, |call, cx| call.join_channel(channel_a, cx))
+ .await
+ .is_err());
+
+ // Make channels A and B public
+ client_a
.channel_store()
- .update(cx_b, |channel_store, cx| {
- channel_store.link_channel(channel_ga_id, channel_b_id, cx)
+ .update(cx_a, |channel_store, cx| {
+ channel_store.set_channel_visibility(channel_a, proto::ChannelVisibility::Public, cx)
})
.await
.unwrap();
+ client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.set_channel_visibility(channel_b, proto::ChannelVisibility::Public, cx)
+ })
+ .await
+ .unwrap();
+
+ // Client B joins channel A as a guest
+ active_call_b
+ .update(cx_b, |call, cx| call.join_channel(channel_a, cx))
+ .await
+ .unwrap();
- // Current shape for B:
- // /---------\
- // /- ep -- b -- c -- d
- // / \
- // mu ---------- ga
+ deterministic.run_until_parked();
+ assert_channels_list_shape(
+ client_a.channel_store(),
+ cx_a,
+ &[(channel_a, 0), (channel_b, 1)],
+ );
assert_channels_list_shape(
client_b.channel_store(),
cx_b,
- &[
- (channel_mu_id, 0),
- (channel_ep_id, 1),
- (channel_b_id, 2),
- (channel_c_id, 3),
- (channel_d_id, 4),
- (channel_d_id, 3),
- (channel_ga_id, 3),
- (channel_ga_id, 1),
- ],
+ &[(channel_a, 0), (channel_b, 1)],
);
- // Current shape for A:
- // /------\
- // a - b -- c -- d
- // \-- ga
+ client_a.channel_store().update(cx_a, |channel_store, _| {
+ let participants = channel_store.channel_participants(channel_a);
+ assert_eq!(participants.len(), 1);
+ assert_eq!(participants[0].id, client_b.user_id().unwrap());
+ });
+
+ client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.set_channel_visibility(channel_a, proto::ChannelVisibility::Members, cx)
+ })
+ .await
+ .unwrap();
+
+ assert_channels_list_shape(client_b.channel_store(), cx_b, &[]);
+
+ active_call_b
+ .update(cx_b, |call, cx| call.join_channel(channel_b, cx))
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+ assert_channels_list_shape(client_b.channel_store(), cx_b, &[(channel_b, 0)]);
+}
+
+#[gpui::test]
+async fn test_invite_access(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
+
+ let mut server = TestServer::start(&deterministic).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+
+ let channels = server
+ .make_channel_tree(
+ &[("channel-a", None), ("channel-b", Some("channel-a"))],
+ (&client_a, cx_a),
+ )
+ .await;
+ let channel_a_id = channels[0];
+ let channel_b_id = channels[0];
+
+ let active_call_b = cx_b.read(ActiveCall::global);
+
+ // should not be allowed to join
+ assert!(active_call_b
+ .update(cx_b, |call, cx| call.join_channel(channel_b_id, cx))
+ .await
+ .is_err());
+
+ client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.invite_member(
+ channel_a_id,
+ client_b.user_id().unwrap(),
+ ChannelRole::Member,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ active_call_b
+ .update(cx_b, |call, cx| call.join_channel(channel_b_id, cx))
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ client_b.channel_store().update(cx_b, |channel_store, _| {
+ assert!(channel_store.channel_for_id(channel_b_id).is_some());
+ assert!(channel_store.channel_for_id(channel_a_id).is_some());
+ });
+
+ client_a.channel_store().update(cx_a, |channel_store, _| {
+ let participants = channel_store.channel_participants(channel_b_id);
+ assert_eq!(participants.len(), 1);
+ assert_eq!(participants[0].id, client_b.user_id().unwrap());
+ })
+}
+
+#[gpui::test]
+async fn test_channel_moving(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ _cx_b: &mut TestAppContext,
+ _cx_c: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
+ let mut server = TestServer::start(&deterministic).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ // let client_b = server.create_client(cx_b, "user_b").await;
+ // let client_c = server.create_client(cx_c, "user_c").await;
+
+ let channels = server
+ .make_channel_tree(
+ &[
+ ("channel-a", None),
+ ("channel-b", Some("channel-a")),
+ ("channel-c", Some("channel-b")),
+ ("channel-d", Some("channel-c")),
+ ],
+ (&client_a, cx_a),
+ )
+ .await;
+ let channel_a_id = channels[0];
+ let channel_b_id = channels[1];
+ let channel_c_id = channels[2];
+ let channel_d_id = channels[3];
+
+ // Current shape:
+ // a - b - c - d
assert_channels_list_shape(
client_a.channel_store(),
cx_a,
@@ -1,6 +1,6 @@
use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer};
use call::ActiveCall;
-use collab_ui::project_shared_notification::ProjectSharedNotification;
+use collab_ui::notifications::project_shared_notification::ProjectSharedNotification;
use editor::{Editor, ExcerptRange, MultiBuffer};
use gpui::{executor::Deterministic, geometry::vector::vec2f, TestAppContext, ViewHandle};
use live_kit_client::MacOSDisplay;
@@ -1,6 +1,6 @@
use crate::{
rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT},
- tests::{room_participants, RoomParticipants, TestClient, TestServer},
+ tests::{channel_id, room_participants, RoomParticipants, TestClient, TestServer},
};
use call::{room, ActiveCall, ParticipantLocation, Room};
use client::{User, RECEIVE_TIMEOUT};
@@ -15,8 +15,8 @@ use gpui::{executor::Deterministic, test::EmptyView, AppContext, ModelHandle, Te
use indoc::indoc;
use language::{
language_settings::{AllLanguageSettings, Formatter, InlayHintSettings},
- tree_sitter_rust, Anchor, BundledFormatter, Diagnostic, DiagnosticEntry, FakeLspAdapter,
- Language, LanguageConfig, LineEnding, OffsetRangeExt, Point, Rope,
+ tree_sitter_rust, Anchor, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language,
+ LanguageConfig, LineEnding, OffsetRangeExt, Point, Rope,
};
use live_kit_client::MacOSDisplay;
use lsp::LanguageServerId;
@@ -469,6 +469,119 @@ async fn test_calling_multiple_users_simultaneously(
);
}
+#[gpui::test(iterations = 10)]
+async fn test_joining_channels_and_calling_multiple_users_simultaneously(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+ cx_c: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
+ let mut server = TestServer::start(&deterministic).await;
+
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+ let client_c = server.create_client(cx_c, "user_c").await;
+ server
+ .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
+ .await;
+
+ let channel_1 = server
+ .make_channel(
+ "channel1",
+ None,
+ (&client_a, cx_a),
+ &mut [(&client_b, cx_b), (&client_c, cx_c)],
+ )
+ .await;
+
+ let channel_2 = server
+ .make_channel(
+ "channel2",
+ None,
+ (&client_a, cx_a),
+ &mut [(&client_b, cx_b), (&client_c, cx_c)],
+ )
+ .await;
+
+ let active_call_a = cx_a.read(ActiveCall::global);
+
+ // Simultaneously join channel 1 and then channel 2
+ active_call_a
+ .update(cx_a, |call, cx| call.join_channel(channel_1, cx))
+ .detach();
+ let join_channel_2 = active_call_a.update(cx_a, |call, cx| call.join_channel(channel_2, cx));
+
+ join_channel_2.await.unwrap();
+
+ let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
+ deterministic.run_until_parked();
+
+ assert_eq!(channel_id(&room_a, cx_a), Some(channel_2));
+
+ // Leave the room
+ active_call_a
+ .update(cx_a, |call, cx| {
+ let hang_up = call.hang_up(cx);
+ hang_up
+ })
+ .await
+ .unwrap();
+
+ // Initiating invites and then joining a channel should fail gracefully
+ let b_invite = active_call_a.update(cx_a, |call, cx| {
+ call.invite(client_b.user_id().unwrap(), None, cx)
+ });
+ let c_invite = active_call_a.update(cx_a, |call, cx| {
+ call.invite(client_c.user_id().unwrap(), None, cx)
+ });
+
+ let join_channel = active_call_a.update(cx_a, |call, cx| call.join_channel(channel_1, cx));
+
+ b_invite.await.unwrap();
+ c_invite.await.unwrap();
+ join_channel.await.unwrap();
+
+ let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
+ deterministic.run_until_parked();
+
+ assert_eq!(
+ room_participants(&room_a, cx_a),
+ RoomParticipants {
+ remote: Default::default(),
+ pending: vec!["user_b".to_string(), "user_c".to_string()]
+ }
+ );
+
+ assert_eq!(channel_id(&room_a, cx_a), None);
+
+ // Leave the room
+ active_call_a
+ .update(cx_a, |call, cx| {
+ let hang_up = call.hang_up(cx);
+ hang_up
+ })
+ .await
+ .unwrap();
+
+ // Simultaneously join channel 1 and call user B and user C from client A.
+ let join_channel = active_call_a.update(cx_a, |call, cx| call.join_channel(channel_1, cx));
+
+ let b_invite = active_call_a.update(cx_a, |call, cx| {
+ call.invite(client_b.user_id().unwrap(), None, cx)
+ });
+ let c_invite = active_call_a.update(cx_a, |call, cx| {
+ call.invite(client_c.user_id().unwrap(), None, cx)
+ });
+
+ join_channel.await.unwrap();
+ b_invite.await.unwrap();
+ c_invite.await.unwrap();
+
+ active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
+ deterministic.run_until_parked();
+}
+
#[gpui::test(iterations = 10)]
async fn test_room_uniqueness(
deterministic: Arc<Deterministic>,
@@ -4530,6 +4643,7 @@ async fn test_prettier_formatting_buffer(
LanguageConfig {
name: "Rust".into(),
path_suffixes: vec!["rs".to_string()],
+ prettier_parser_name: Some("test_parser".to_string()),
..Default::default()
},
Some(tree_sitter_rust::language()),
@@ -4537,10 +4651,7 @@ async fn test_prettier_formatting_buffer(
let test_plugin = "test_plugin";
let mut fake_language_servers = language
.set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
- enabled_formatters: vec![BundledFormatter::Prettier {
- parser_name: Some("test_parser"),
- plugin_names: vec![test_plugin],
- }],
+ prettier_plugins: vec![test_plugin],
..Default::default()
}))
.await;
@@ -4557,11 +4668,7 @@ async fn test_prettier_formatting_buffer(
.insert_tree(&directory, json!({ "a.rs": buffer_text }))
.await;
let (project_a, worktree_id) = client_a.build_local_project(&directory, cx_a).await;
- let prettier_format_suffix = project_a.update(cx_a, |project, _| {
- let suffix = project.enable_test_prettier(&[test_plugin]);
- project.languages().add(language);
- suffix
- });
+ let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX;
let buffer_a = cx_a
.background()
.spawn(project_a.update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx)))
@@ -0,0 +1,159 @@
+use crate::tests::TestServer;
+use gpui::{executor::Deterministic, TestAppContext};
+use notifications::NotificationEvent;
+use parking_lot::Mutex;
+use rpc::{proto, Notification};
+use std::sync::Arc;
+
+#[gpui::test]
+async fn test_notifications(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
+ let mut server = TestServer::start(&deterministic).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+
+ let notification_events_a = Arc::new(Mutex::new(Vec::new()));
+ let notification_events_b = Arc::new(Mutex::new(Vec::new()));
+ client_a.notification_store().update(cx_a, |_, cx| {
+ let events = notification_events_a.clone();
+ cx.subscribe(&cx.handle(), move |_, _, event, _| {
+ events.lock().push(event.clone());
+ })
+ .detach()
+ });
+ client_b.notification_store().update(cx_b, |_, cx| {
+ let events = notification_events_b.clone();
+ cx.subscribe(&cx.handle(), move |_, _, event, _| {
+ events.lock().push(event.clone());
+ })
+ .detach()
+ });
+
+ // Client A sends a contact request to client B.
+ client_a
+ .user_store()
+ .update(cx_a, |store, cx| store.request_contact(client_b.id(), cx))
+ .await
+ .unwrap();
+
+ // Client B receives a contact request notification and responds to the
+ // request, accepting it.
+ deterministic.run_until_parked();
+ client_b.notification_store().update(cx_b, |store, cx| {
+ assert_eq!(store.notification_count(), 1);
+ assert_eq!(store.unread_notification_count(), 1);
+
+ let entry = store.notification_at(0).unwrap();
+ assert_eq!(
+ entry.notification,
+ Notification::ContactRequest {
+ sender_id: client_a.id()
+ }
+ );
+ assert!(!entry.is_read);
+ assert_eq!(
+ ¬ification_events_b.lock()[0..],
+ &[
+ NotificationEvent::NewNotification {
+ entry: entry.clone(),
+ },
+ NotificationEvent::NotificationsUpdated {
+ old_range: 0..0,
+ new_count: 1
+ }
+ ]
+ );
+
+ store.respond_to_notification(entry.notification.clone(), true, cx);
+ });
+
+ // Client B sees the notification is now read, and that they responded.
+ deterministic.run_until_parked();
+ client_b.notification_store().read_with(cx_b, |store, _| {
+ assert_eq!(store.notification_count(), 1);
+ assert_eq!(store.unread_notification_count(), 0);
+
+ let entry = store.notification_at(0).unwrap();
+ assert!(entry.is_read);
+ assert_eq!(entry.response, Some(true));
+ assert_eq!(
+ ¬ification_events_b.lock()[2..],
+ &[
+ NotificationEvent::NotificationRead {
+ entry: entry.clone(),
+ },
+ NotificationEvent::NotificationsUpdated {
+ old_range: 0..1,
+ new_count: 1
+ }
+ ]
+ );
+ });
+
+ // Client A receives a notification that client B accepted their request.
+ client_a.notification_store().read_with(cx_a, |store, _| {
+ assert_eq!(store.notification_count(), 1);
+ assert_eq!(store.unread_notification_count(), 1);
+
+ let entry = store.notification_at(0).unwrap();
+ assert_eq!(
+ entry.notification,
+ Notification::ContactRequestAccepted {
+ responder_id: client_b.id()
+ }
+ );
+ assert!(!entry.is_read);
+ });
+
+ // Client A creates a channel and invites client B to be a member.
+ let channel_id = client_a
+ .channel_store()
+ .update(cx_a, |store, cx| {
+ store.create_channel("the-channel", None, cx)
+ })
+ .await
+ .unwrap();
+ client_a
+ .channel_store()
+ .update(cx_a, |store, cx| {
+ store.invite_member(channel_id, client_b.id(), proto::ChannelRole::Member, cx)
+ })
+ .await
+ .unwrap();
+
+ // Client B receives a channel invitation notification and responds to the
+ // invitation, accepting it.
+ deterministic.run_until_parked();
+ client_b.notification_store().update(cx_b, |store, cx| {
+ assert_eq!(store.notification_count(), 2);
+ assert_eq!(store.unread_notification_count(), 1);
+
+ let entry = store.notification_at(0).unwrap();
+ assert_eq!(
+ entry.notification,
+ Notification::ChannelInvitation {
+ channel_id,
+ channel_name: "the-channel".to_string(),
+ inviter_id: client_a.id()
+ }
+ );
+ assert!(!entry.is_read);
+
+ store.respond_to_notification(entry.notification.clone(), true, cx);
+ });
+
+ // Client B sees the notification is now read, and that they responded.
+ deterministic.run_until_parked();
+ client_b.notification_store().read_with(cx_b, |store, _| {
+ assert_eq!(store.notification_count(), 2);
+ assert_eq!(store.unread_notification_count(), 0);
+
+ let entry = store.notification_at(0).unwrap();
+ assert!(entry.is_read);
+ assert_eq!(entry.response, Some(true));
+ });
+}
@@ -1,3 +1,5 @@
+use crate::db::ChannelRole;
+
use super::{run_randomized_test, RandomizedTest, TestClient, TestError, TestServer, UserTestPlan};
use anyhow::Result;
use async_trait::async_trait;
@@ -46,11 +48,11 @@ impl RandomizedTest for RandomChannelBufferTest {
let db = &server.app_state.db;
for ix in 0..CHANNEL_COUNT {
let id = db
- .create_channel(&format!("channel-{ix}"), None, users[0].user_id)
+ .create_root_channel(&format!("channel-{ix}"), users[0].user_id)
.await
.unwrap();
for user in &users[1..] {
- db.invite_channel_member(id, user.user_id, users[0].user_id, false)
+ db.invite_channel_member(id, user.user_id, users[0].user_id, ChannelRole::Member)
.await
.unwrap();
db.respond_to_channel_invite(id, user.user_id, true)
@@ -81,7 +83,7 @@ impl RandomizedTest for RandomChannelBufferTest {
match rng.gen_range(0..100_u32) {
0..=29 => {
let channel_name = client.channel_store().read_with(cx, |store, cx| {
- store.channel_dag_entries().find_map(|(_, channel)| {
+ store.ordered_channels().find_map(|(_, channel)| {
if store.has_open_channel_buffer(channel.id, cx) {
None
} else {
@@ -96,15 +98,16 @@ impl RandomizedTest for RandomChannelBufferTest {
30..=40 => {
if let Some(buffer) = channel_buffers.iter().choose(rng) {
- let channel_name = buffer.read_with(cx, |b, _| b.channel().name.clone());
+ let channel_name =
+ buffer.read_with(cx, |b, cx| b.channel(cx).unwrap().name.clone());
break ChannelBufferOperation::LeaveChannelNotes { channel_name };
}
}
_ => {
if let Some(buffer) = channel_buffers.iter().choose(rng) {
- break buffer.read_with(cx, |b, _| {
- let channel_name = b.channel().name.clone();
+ break buffer.read_with(cx, |b, cx| {
+ let channel_name = b.channel(cx).unwrap().name.clone();
let edits = b
.buffer()
.read_with(cx, |buffer, _| buffer.get_random_edits(rng, 3));
@@ -128,7 +131,7 @@ impl RandomizedTest for RandomChannelBufferTest {
ChannelBufferOperation::JoinChannelNotes { channel_name } => {
let buffer = client.channel_store().update(cx, |store, cx| {
let channel_id = store
- .channel_dag_entries()
+ .ordered_channels()
.find(|(_, c)| c.name == channel_name)
.unwrap()
.1
@@ -151,7 +154,7 @@ impl RandomizedTest for RandomChannelBufferTest {
let buffer = cx.update(|cx| {
let mut left_buffer = Err(TestError::Inapplicable);
client.channel_buffers().retain(|buffer| {
- if buffer.read(cx).channel().name == channel_name {
+ if buffer.read(cx).channel(cx).unwrap().name == channel_name {
left_buffer = Ok(buffer.clone());
false
} else {
@@ -177,7 +180,9 @@ impl RandomizedTest for RandomChannelBufferTest {
client
.channel_buffers()
.iter()
- .find(|buffer| buffer.read(cx).channel().name == channel_name)
+ .find(|buffer| {
+ buffer.read(cx).channel(cx).unwrap().name == channel_name
+ })
.cloned()
})
.ok_or_else(|| TestError::Inapplicable)?;
@@ -248,7 +253,7 @@ impl RandomizedTest for RandomChannelBufferTest {
if let Some(channel_buffer) = client
.channel_buffers()
.iter()
- .find(|b| b.read(cx).channel().id == channel_id.to_proto())
+ .find(|b| b.read(cx).channel_id == channel_id.to_proto())
{
let channel_buffer = channel_buffer.read(cx);
@@ -208,8 +208,7 @@ impl<T: RandomizedTest> TestPlan<T> {
false,
NewUserParams {
github_login: username.clone(),
- github_user_id: (ix + 1) as i32,
- invite_count: 0,
+ github_user_id: ix as i32,
},
)
.await
@@ -16,9 +16,10 @@ use futures::{channel::oneshot, StreamExt as _};
use gpui::{executor::Deterministic, ModelHandle, Task, TestAppContext, WindowHandle};
use language::LanguageRegistry;
use node_runtime::FakeNodeRuntime;
+use notifications::NotificationStore;
use parking_lot::Mutex;
use project::{Project, WorktreeId};
-use rpc::RECEIVE_TIMEOUT;
+use rpc::{proto::ChannelRole, RECEIVE_TIMEOUT};
use settings::SettingsStore;
use std::{
cell::{Ref, RefCell, RefMut},
@@ -46,6 +47,7 @@ pub struct TestClient {
pub username: String,
pub app_state: Arc<workspace::AppState>,
channel_store: ModelHandle<ChannelStore>,
+ notification_store: ModelHandle<NotificationStore>,
state: RefCell<TestClientState>,
}
@@ -138,7 +140,6 @@ impl TestServer {
NewUserParams {
github_login: name.into(),
github_user_id: 0,
- invite_count: 0,
},
)
.await
@@ -231,7 +232,8 @@ impl TestServer {
workspace::init(app_state.clone(), cx);
audio::init((), cx);
call::init(client.clone(), user_store.clone(), cx);
- channel::init(&client, user_store, cx);
+ channel::init(&client, user_store.clone(), cx);
+ notifications::init(client.clone(), user_store, cx);
});
client
@@ -243,6 +245,7 @@ impl TestServer {
app_state,
username: name.to_string(),
channel_store: cx.read(ChannelStore::global).clone(),
+ notification_store: cx.read(NotificationStore::global).clone(),
state: Default::default(),
};
client.wait_for_current_user(cx).await;
@@ -327,7 +330,7 @@ impl TestServer {
channel_store.invite_member(
channel_id,
member_client.user_id().unwrap(),
- false,
+ ChannelRole::Member,
cx,
)
})
@@ -338,8 +341,8 @@ impl TestServer {
member_cx
.read(ChannelStore::global)
- .update(*member_cx, |channels, _| {
- channels.respond_to_channel_invite(channel_id, true)
+ .update(*member_cx, |channels, cx| {
+ channels.respond_to_channel_invite(channel_id, true, cx)
})
.await
.unwrap();
@@ -448,6 +451,10 @@ impl TestClient {
&self.channel_store
}
+ pub fn notification_store(&self) -> &ModelHandle<NotificationStore> {
+ &self.notification_store
+ }
+
pub fn user_store(&self) -> &ModelHandle<UserStore> {
&self.app_state.user_store
}
@@ -604,33 +611,6 @@ impl TestClient {
) -> WindowHandle<Workspace> {
cx.add_window(|cx| Workspace::new(0, project.clone(), self.app_state.clone(), cx))
}
-
- pub async fn add_admin_to_channel(
- &self,
- user: (&TestClient, &mut TestAppContext),
- channel: u64,
- cx_self: &mut TestAppContext,
- ) {
- let (other_client, other_cx) = user;
-
- cx_self
- .read(ChannelStore::global)
- .update(cx_self, |channel_store, cx| {
- channel_store.invite_member(channel, other_client.user_id().unwrap(), true, cx)
- })
- .await
- .unwrap();
-
- cx_self.foreground().run_until_parked();
-
- other_cx
- .read(ChannelStore::global)
- .update(other_cx, |channel_store, _| {
- channel_store.respond_to_channel_invite(channel, true)
- })
- .await
- .unwrap();
- }
}
impl Drop for TestClient {
@@ -37,10 +37,12 @@ fuzzy = { path = "../fuzzy" }
gpui = { path = "../gpui" }
language = { path = "../language" }
menu = { path = "../menu" }
+notifications = { path = "../notifications" }
rich_text = { path = "../rich_text" }
picker = { path = "../picker" }
project = { path = "../project" }
-recent_projects = {path = "../recent_projects"}
+recent_projects = { path = "../recent_projects" }
+rpc = { path = "../rpc" }
settings = { path = "../settings" }
feature_flags = {path = "../feature_flags"}
theme = { path = "../theme" }
@@ -52,12 +54,14 @@ zed-actions = {path = "../zed-actions"}
anyhow.workspace = true
futures.workspace = true
+lazy_static.workspace = true
log.workspace = true
schemars.workspace = true
postage.workspace = true
serde.workspace = true
serde_derive.workspace = true
time.workspace = true
+smallvec.workspace = true
[dev-dependencies]
call = { path = "../call", features = ["test-support"] }
@@ -65,7 +69,12 @@ client = { path = "../client", features = ["test-support"] }
collections = { path = "../collections", features = ["test-support"] }
editor = { path = "../editor", features = ["test-support"] }
gpui = { path = "../gpui", features = ["test-support"] }
+notifications = { path = "../notifications", features = ["test-support"] }
project = { path = "../project", features = ["test-support"] }
+rpc = { path = "../rpc", features = ["test-support"] }
settings = { path = "../settings", features = ["test-support"] }
util = { path = "../util", features = ["test-support"] }
workspace = { path = "../workspace", features = ["test-support"] }
+
+pretty_assertions.workspace = true
+tree-sitter-markdown.workspace = true
@@ -15,13 +15,14 @@ use gpui::{
ViewContext, ViewHandle,
};
use project::Project;
+use smallvec::SmallVec;
use std::{
any::{Any, TypeId},
sync::Arc,
};
use util::ResultExt;
use workspace::{
- item::{FollowableItem, Item, ItemHandle},
+ item::{FollowableItem, Item, ItemEvent, ItemHandle},
register_followable_item,
searchable::SearchableItemHandle,
ItemNavHistory, Pane, SaveIntent, ViewId, Workspace, WorkspaceId,
@@ -140,6 +141,12 @@ impl ChannelView {
editor.set_collaboration_hub(Box::new(ChannelBufferCollaborationHub(
channel_buffer.clone(),
)));
+ editor.set_read_only(
+ !channel_buffer
+ .read(cx)
+ .channel(cx)
+ .is_some_and(|c| c.can_edit_notes()),
+ );
editor
});
let _editor_event_subscription = cx.subscribe(&editor, |_, _, e, cx| cx.emit(e.clone()));
@@ -157,8 +164,8 @@ impl ChannelView {
}
}
- pub fn channel(&self, cx: &AppContext) -> Arc<Channel> {
- self.channel_buffer.read(cx).channel()
+ pub fn channel(&self, cx: &AppContext) -> Option<Arc<Channel>> {
+ self.channel_buffer.read(cx).channel(cx)
}
fn handle_channel_buffer_event(
@@ -172,6 +179,13 @@ impl ChannelView {
editor.set_read_only(true);
cx.notify();
}),
+ ChannelBufferEvent::ChannelChanged => {
+ self.editor.update(cx, |editor, cx| {
+ editor.set_read_only(!self.channel(cx).is_some_and(|c| c.can_edit_notes()));
+ cx.emit(editor::Event::TitleChanged);
+ cx.notify()
+ });
+ }
ChannelBufferEvent::BufferEdited => {
if cx.is_self_focused() || self.editor.is_focused(cx) {
self.acknowledge_buffer_version(cx);
@@ -179,7 +193,7 @@ impl ChannelView {
self.channel_store.update(cx, |store, cx| {
let channel_buffer = self.channel_buffer.read(cx);
store.notes_changed(
- channel_buffer.channel().id,
+ channel_buffer.channel_id,
channel_buffer.epoch(),
&channel_buffer.buffer().read(cx).version(),
cx,
@@ -187,7 +201,7 @@ impl ChannelView {
});
}
}
- _ => {}
+ ChannelBufferEvent::CollaboratorsChanged => {}
}
}
@@ -195,7 +209,7 @@ impl ChannelView {
self.channel_store.update(cx, |store, cx| {
let channel_buffer = self.channel_buffer.read(cx);
store.acknowledge_notes_version(
- channel_buffer.channel().id,
+ channel_buffer.channel_id,
channel_buffer.epoch(),
&channel_buffer.buffer().read(cx).version(),
cx,
@@ -250,11 +264,17 @@ impl Item for ChannelView {
style: &theme::Tab,
cx: &gpui::AppContext,
) -> AnyElement<V> {
- let channel_name = &self.channel_buffer.read(cx).channel().name;
- let label = if self.channel_buffer.read(cx).is_connected() {
- format!("#{}", channel_name)
+ let label = if let Some(channel) = self.channel(cx) {
+ match (
+ channel.can_edit_notes(),
+ self.channel_buffer.read(cx).is_connected(),
+ ) {
+ (true, true) => format!("#{}", channel.name),
+ (false, true) => format!("#{} (read-only)", channel.name),
+ (_, false) => format!("#{} (disconnected)", channel.name),
+ }
} else {
- format!("#{} (disconnected)", channel_name)
+ format!("channel notes (disconnected)")
};
Label::new(label, style.label.to_owned()).into_any()
}
@@ -298,6 +318,10 @@ impl Item for ChannelView {
fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option<Vector2F> {
self.editor.read(cx).pixel_position_of_cursor(cx)
}
+
+ fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> {
+ editor::Editor::to_item_events(event)
+ }
}
impl FollowableItem for ChannelView {
@@ -313,7 +337,7 @@ impl FollowableItem for ChannelView {
Some(proto::view::Variant::ChannelView(
proto::view::ChannelView {
- channel_id: channel_buffer.channel().id,
+ channel_id: channel_buffer.channel_id,
editor: if let Some(proto::view::Variant::Editor(proto)) =
self.editor.read(cx).to_state_proto(cx)
{
@@ -1,4 +1,6 @@
-use crate::{channel_view::ChannelView, ChatPanelSettings};
+use crate::{
+ channel_view::ChannelView, is_channels_feature_enabled, render_avatar, ChatPanelSettings,
+};
use anyhow::Result;
use call::ActiveCall;
use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore};
@@ -6,18 +8,18 @@ use client::Client;
use collections::HashMap;
use db::kvp::KEY_VALUE_STORE;
use editor::Editor;
-use feature_flags::{ChannelsAlpha, FeatureFlagAppExt};
use gpui::{
actions,
elements::*,
platform::{CursorStyle, MouseButton},
serde_json,
views::{ItemType, Select, SelectStyle},
- AnyViewHandle, AppContext, AsyncAppContext, Entity, ImageData, ModelHandle, Subscription, Task,
- View, ViewContext, ViewHandle, WeakViewHandle,
+ AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View,
+ ViewContext, ViewHandle, WeakViewHandle,
};
-use language::{language_settings::SoftWrap, LanguageRegistry};
+use language::LanguageRegistry;
use menu::Confirm;
+use message_editor::MessageEditor;
use project::Fs;
use rich_text::RichText;
use serde::{Deserialize, Serialize};
@@ -31,6 +33,8 @@ use workspace::{
Workspace,
};
+mod message_editor;
+
const MESSAGE_LOADING_THRESHOLD: usize = 50;
const CHAT_PANEL_KEY: &'static str = "ChatPanel";
@@ -40,7 +44,7 @@ pub struct ChatPanel {
languages: Arc<LanguageRegistry>,
active_chat: Option<(ModelHandle<ChannelChat>, Subscription)>,
message_list: ListState<ChatPanel>,
- input_editor: ViewHandle<Editor>,
+ input_editor: ViewHandle<MessageEditor>,
channel_select: ViewHandle<Select>,
local_timezone: UtcOffset,
fs: Arc<dyn Fs>,
@@ -49,6 +53,7 @@ pub struct ChatPanel {
pending_serialization: Task<Option<()>>,
subscriptions: Vec<gpui::Subscription>,
workspace: WeakViewHandle<Workspace>,
+ is_scrolled_to_bottom: bool,
has_focus: bool,
markdown_data: HashMap<ChannelMessageId, RichText>,
}
@@ -85,13 +90,18 @@ impl ChatPanel {
let languages = workspace.app_state().languages.clone();
let input_editor = cx.add_view(|cx| {
- let mut editor = Editor::auto_height(
- 4,
- Some(Arc::new(|theme| theme.chat_panel.input_editor.clone())),
+ MessageEditor::new(
+ languages.clone(),
+ channel_store.clone(),
+ cx.add_view(|cx| {
+ Editor::auto_height(
+ 4,
+ Some(Arc::new(|theme| theme.chat_panel.input_editor.clone())),
+ cx,
+ )
+ }),
cx,
- );
- editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
- editor
+ )
});
let workspace_handle = workspace.weak_handle();
@@ -121,13 +131,14 @@ impl ChatPanel {
});
let mut message_list =
- ListState::<Self>::new(0, Orientation::Bottom, 1000., move |this, ix, cx| {
+ ListState::<Self>::new(0, Orientation::Bottom, 10., move |this, ix, cx| {
this.render_message(ix, cx)
});
- message_list.set_scroll_handler(|visible_range, this, cx| {
+ message_list.set_scroll_handler(|visible_range, count, this, cx| {
if visible_range.start < MESSAGE_LOADING_THRESHOLD {
this.load_more_messages(&LoadMoreMessages, cx);
}
+ this.is_scrolled_to_bottom = visible_range.end == count;
});
cx.add_view(|cx| {
@@ -136,7 +147,6 @@ impl ChatPanel {
client,
channel_store,
languages,
-
active_chat: Default::default(),
pending_serialization: Task::ready(None),
message_list,
@@ -146,6 +156,7 @@ impl ChatPanel {
has_focus: false,
subscriptions: Vec::new(),
workspace: workspace_handle,
+ is_scrolled_to_bottom: true,
active: false,
width: None,
markdown_data: Default::default(),
@@ -179,35 +190,20 @@ impl ChatPanel {
.channel_at(selected_ix)
.map(|e| e.id);
if let Some(selected_channel_id) = selected_channel_id {
- this.select_channel(selected_channel_id, cx)
+ this.select_channel(selected_channel_id, None, cx)
.detach_and_log_err(cx);
}
})
.detach();
- let markdown = this.languages.language_for_name("Markdown");
- cx.spawn(|this, mut cx| async move {
- let markdown = markdown.await?;
-
- this.update(&mut cx, |this, cx| {
- this.input_editor.update(cx, |editor, cx| {
- editor.buffer().update(cx, |multi_buffer, cx| {
- multi_buffer
- .as_singleton()
- .unwrap()
- .update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx))
- })
- })
- })?;
-
- anyhow::Ok(())
- })
- .detach_and_log_err(cx);
-
this
})
}
+ pub fn is_scrolled_to_bottom(&self) -> bool {
+ self.is_scrolled_to_bottom
+ }
+
pub fn active_chat(&self) -> Option<ModelHandle<ChannelChat>> {
self.active_chat.as_ref().map(|(chat, _)| chat.clone())
}
@@ -267,20 +263,22 @@ impl ChatPanel {
fn set_active_chat(&mut self, chat: ModelHandle<ChannelChat>, cx: &mut ViewContext<Self>) {
if self.active_chat.as_ref().map(|e| &e.0) != Some(&chat) {
- let id = chat.read(cx).channel().id;
+ let channel_id = chat.read(cx).channel_id;
{
+ self.markdown_data.clear();
let chat = chat.read(cx);
self.message_list.reset(chat.message_count());
- let placeholder = format!("Message #{}", chat.channel().name);
- self.input_editor.update(cx, move |editor, cx| {
- editor.set_placeholder_text(placeholder, cx);
+
+ let channel_name = chat.channel(cx).map(|channel| channel.name.clone());
+ self.input_editor.update(cx, |editor, cx| {
+ editor.set_channel(channel_id, channel_name, cx);
});
- }
+ };
let subscription = cx.subscribe(&chat, Self::channel_did_change);
self.active_chat = Some((chat, subscription));
self.acknowledge_last_message(cx);
self.channel_select.update(cx, |select, cx| {
- if let Some(ix) = self.channel_store.read(cx).index_of_channel(id) {
+ if let Some(ix) = self.channel_store.read(cx).index_of_channel(channel_id) {
select.set_selected_index(ix, cx);
}
});
@@ -319,7 +317,7 @@ impl ChatPanel {
}
fn acknowledge_last_message(&mut self, cx: &mut ViewContext<'_, '_, ChatPanel>) {
- if self.active {
+ if self.active && self.is_scrolled_to_bottom {
if let Some((chat, _)) = &self.active_chat {
chat.update(cx, |chat, cx| {
chat.acknowledge_last_message(cx);
@@ -355,28 +353,48 @@ impl ChatPanel {
}
fn render_message(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
- let (message, is_continuation, is_last) = {
- let active_chat = self.active_chat.as_ref().unwrap().0.read(cx);
- let last_message = active_chat.message(ix.saturating_sub(1));
- let this_message = active_chat.message(ix);
- let is_continuation = last_message.id != this_message.id
- && this_message.sender.id == last_message.sender.id;
-
- (
- active_chat.message(ix).clone(),
- is_continuation,
- active_chat.message_count() == ix + 1,
- )
- };
+ let (message, is_continuation, is_last, is_admin) = self
+ .active_chat
+ .as_ref()
+ .unwrap()
+ .0
+ .update(cx, |active_chat, cx| {
+ let is_admin = self
+ .channel_store
+ .read(cx)
+ .is_channel_admin(active_chat.channel_id);
+
+ let last_message = active_chat.message(ix.saturating_sub(1));
+ let this_message = active_chat.message(ix).clone();
+ let is_continuation = last_message.id != this_message.id
+ && this_message.sender.id == last_message.sender.id;
+
+ if let ChannelMessageId::Saved(id) = this_message.id {
+ if this_message
+ .mentions
+ .iter()
+ .any(|(_, user_id)| Some(*user_id) == self.client.user_id())
+ {
+ active_chat.acknowledge_message(id);
+ }
+ }
+
+ (
+ this_message,
+ is_continuation,
+ active_chat.message_count() == ix + 1,
+ is_admin,
+ )
+ });
let is_pending = message.is_pending();
- let text = self
- .markdown_data
- .entry(message.id)
- .or_insert_with(|| rich_text::render_markdown(message.body, &self.languages, None));
+ let theme = theme::current(cx);
+ let text = self.markdown_data.entry(message.id).or_insert_with(|| {
+ Self::render_markdown_with_mentions(&self.languages, self.client.id(), &message)
+ });
let now = OffsetDateTime::now_utc();
- let theme = theme::current(cx);
+
let style = if is_pending {
&theme.chat_panel.pending_message
} else if is_continuation {
@@ -386,23 +404,23 @@ impl ChatPanel {
};
let belongs_to_user = Some(message.sender.id) == self.client.user_id();
- let message_id_to_remove =
- if let (ChannelMessageId::Saved(id), true) = (message.id, belongs_to_user) {
- Some(id)
- } else {
- None
- };
+ let message_id_to_remove = if let (ChannelMessageId::Saved(id), true) =
+ (message.id, belongs_to_user || is_admin)
+ {
+ Some(id)
+ } else {
+ None
+ };
enum MessageBackgroundHighlight {}
MouseEventHandler::new::<MessageBackgroundHighlight, _>(ix, cx, |state, cx| {
- let container = style.container.style_for(state);
+ let container = style.style_for(state);
if is_continuation {
Flex::row()
.with_child(
text.element(
theme.editor.syntax.clone(),
- style.body.clone(),
- theme.editor.document_highlight_read_background,
+ theme.chat_panel.rich_text.clone(),
cx,
)
.flex(1., true),
@@ -424,15 +442,16 @@ impl ChatPanel {
Flex::row()
.with_child(render_avatar(
message.sender.avatar.clone(),
- &theme,
+ &theme.chat_panel.avatar,
+ theme.chat_panel.avatar_container,
))
.with_child(
Label::new(
message.sender.github_login.clone(),
- style.sender.text.clone(),
+ theme.chat_panel.message_sender.text.clone(),
)
.contained()
- .with_style(style.sender.container),
+ .with_style(theme.chat_panel.message_sender.container),
)
.with_child(
Label::new(
@@ -441,10 +460,10 @@ impl ChatPanel {
now,
self.local_timezone,
),
- style.timestamp.text.clone(),
+ theme.chat_panel.message_timestamp.text.clone(),
)
.contained()
- .with_style(style.timestamp.container),
+ .with_style(theme.chat_panel.message_timestamp.container),
)
.align_children_center()
.flex(1., true),
@@ -457,8 +476,7 @@ impl ChatPanel {
.with_child(
text.element(
theme.editor.syntax.clone(),
- style.body.clone(),
- theme.editor.document_highlight_read_background,
+ theme.chat_panel.rich_text.clone(),
cx,
)
.flex(1., true),
@@ -479,6 +497,23 @@ impl ChatPanel {
.into_any()
}
+ fn render_markdown_with_mentions(
+ language_registry: &Arc<LanguageRegistry>,
+ current_user_id: u64,
+ message: &channel::ChannelMessage,
+ ) -> RichText {
+ let mentions = message
+ .mentions
+ .iter()
+ .map(|(range, user_id)| rich_text::Mention {
+ range: range.clone(),
+ is_self_mention: *user_id == current_user_id,
+ })
+ .collect::<Vec<_>>();
+
+ rich_text::render_markdown(message.body.clone(), &mentions, language_registry, None)
+ }
+
fn render_input_box(&self, theme: &Arc<Theme>, cx: &AppContext) -> AnyElement<Self> {
ChildView::new(&self.input_editor, cx)
.contained()
@@ -604,14 +639,12 @@ impl ChatPanel {
fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
if let Some((chat, _)) = self.active_chat.as_ref() {
- let body = self.input_editor.update(cx, |editor, cx| {
- let body = editor.text(cx);
- editor.clear(cx);
- body
- });
+ let message = self
+ .input_editor
+ .update(cx, |editor, cx| editor.take_message(cx));
if let Some(task) = chat
- .update(cx, |chat, cx| chat.send_message(body, cx))
+ .update(cx, |chat, cx| chat.send_message(message, cx))
.log_err()
{
task.detach();
@@ -628,7 +661,9 @@ impl ChatPanel {
fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext<Self>) {
if let Some((chat, _)) = self.active_chat.as_ref() {
chat.update(cx, |channel, cx| {
- channel.load_more_messages(cx);
+ if let Some(task) = channel.load_more_messages(cx) {
+ task.detach();
+ }
})
}
}
@@ -636,29 +671,52 @@ impl ChatPanel {
pub fn select_channel(
&mut self,
selected_channel_id: u64,
+ scroll_to_message_id: Option<u64>,
cx: &mut ViewContext<ChatPanel>,
) -> Task<Result<()>> {
- if let Some((chat, _)) = &self.active_chat {
- if chat.read(cx).channel().id == selected_channel_id {
- return Task::ready(Ok(()));
- }
- }
+ let open_chat = self
+ .active_chat
+ .as_ref()
+ .and_then(|(chat, _)| {
+ (chat.read(cx).channel_id == selected_channel_id)
+ .then(|| Task::ready(anyhow::Ok(chat.clone())))
+ })
+ .unwrap_or_else(|| {
+ self.channel_store.update(cx, |store, cx| {
+ store.open_channel_chat(selected_channel_id, cx)
+ })
+ });
- let open_chat = self.channel_store.update(cx, |store, cx| {
- store.open_channel_chat(selected_channel_id, cx)
- });
cx.spawn(|this, mut cx| async move {
let chat = open_chat.await?;
this.update(&mut cx, |this, cx| {
- this.markdown_data = Default::default();
- this.set_active_chat(chat, cx);
- })
+ this.set_active_chat(chat.clone(), cx);
+ })?;
+
+ if let Some(message_id) = scroll_to_message_id {
+ if let Some(item_ix) =
+ ChannelChat::load_history_since_message(chat.clone(), message_id, cx.clone())
+ .await
+ {
+ this.update(&mut cx, |this, cx| {
+ if this.active_chat.as_ref().map_or(false, |(c, _)| *c == chat) {
+ this.message_list.scroll_to(ListOffset {
+ item_ix,
+ offset_in_item: 0.,
+ });
+ cx.notify();
+ }
+ })?;
+ }
+ }
+
+ Ok(())
})
}
fn open_notes(&mut self, _: &OpenChannelNotes, cx: &mut ViewContext<Self>) {
if let Some((chat, _)) = &self.active_chat {
- let channel_id = chat.read(cx).channel().id;
+ let channel_id = chat.read(cx).channel_id;
if let Some(workspace) = self.workspace.upgrade(cx) {
ChannelView::open(channel_id, workspace, cx).detach();
}
@@ -667,7 +725,7 @@ impl ChatPanel {
fn join_call(&mut self, _: &JoinCall, cx: &mut ViewContext<Self>) {
if let Some((chat, _)) = &self.active_chat {
- let channel_id = chat.read(cx).channel().id;
+ let channel_id = chat.read(cx).channel_id;
ActiveCall::global(cx)
.update(cx, |call, cx| call.join_channel(channel_id, cx))
.detach_and_log_err(cx);
@@ -675,32 +733,6 @@ impl ChatPanel {
}
}
-fn render_avatar(avatar: Option<Arc<ImageData>>, theme: &Arc<Theme>) -> AnyElement<ChatPanel> {
- let avatar_style = theme.chat_panel.avatar;
-
- avatar
- .map(|avatar| {
- Image::from_data(avatar)
- .with_style(avatar_style.image)
- .aligned()
- .contained()
- .with_corner_radius(avatar_style.outer_corner_radius)
- .constrained()
- .with_width(avatar_style.outer_width)
- .with_height(avatar_style.outer_width)
- .into_any()
- })
- .unwrap_or_else(|| {
- Empty::new()
- .constrained()
- .with_width(avatar_style.outer_width)
- .into_any()
- })
- .contained()
- .with_style(theme.chat_panel.avatar_container)
- .into_any()
-}
-
fn render_remove(
message_id_to_remove: Option<u64>,
cx: &mut ViewContext<'_, '_, ChatPanel>,
@@ -771,7 +803,8 @@ impl View for ChatPanel {
*self.client.status().borrow(),
client::Status::Connected { .. }
) {
- cx.focus(&self.input_editor);
+ let editor = self.input_editor.read(cx).editor.clone();
+ cx.focus(&editor);
}
}
@@ -810,14 +843,14 @@ impl Panel for ChatPanel {
self.active = active;
if active {
self.acknowledge_last_message(cx);
- if !is_chat_feature_enabled(cx) {
+ if !is_channels_feature_enabled(cx) {
cx.emit(Event::Dismissed);
}
}
}
fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
- (settings::get::<ChatPanelSettings>(cx).button && is_chat_feature_enabled(cx))
+ (settings::get::<ChatPanelSettings>(cx).button && is_channels_feature_enabled(cx))
.then(|| "icons/conversations.svg")
}
@@ -842,10 +875,6 @@ impl Panel for ChatPanel {
}
}
-fn is_chat_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool {
- cx.is_staff() || cx.has_flag::<ChannelsAlpha>()
-}
-
fn format_timestamp(
mut timestamp: OffsetDateTime,
mut now: OffsetDateTime,
@@ -883,3 +912,72 @@ fn render_icon_button<V: View>(style: &IconButton, svg_path: &'static str) -> im
.contained()
.with_style(style.container)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use gpui::fonts::HighlightStyle;
+ use pretty_assertions::assert_eq;
+ use rich_text::{BackgroundKind, Highlight, RenderedRegion};
+ use util::test::marked_text_ranges;
+
+ #[gpui::test]
+ fn test_render_markdown_with_mentions() {
+ let language_registry = Arc::new(LanguageRegistry::test());
+ let (body, ranges) = marked_text_ranges("*hi*, «@abc», let's **call** «@fgh»", false);
+ let message = channel::ChannelMessage {
+ id: ChannelMessageId::Saved(0),
+ body,
+ timestamp: OffsetDateTime::now_utc(),
+ sender: Arc::new(client::User {
+ github_login: "fgh".into(),
+ avatar: None,
+ id: 103,
+ }),
+ nonce: 5,
+ mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
+ };
+
+ let message = ChatPanel::render_markdown_with_mentions(&language_registry, 102, &message);
+
+ // Note that the "'" was replaced with ’ due to smart punctuation.
+ let (body, ranges) = marked_text_ranges("«hi», «@abc», let’s «call» «@fgh»", false);
+ assert_eq!(message.text, body);
+ assert_eq!(
+ message.highlights,
+ vec![
+ (
+ ranges[0].clone(),
+ HighlightStyle {
+ italic: Some(true),
+ ..Default::default()
+ }
+ .into()
+ ),
+ (ranges[1].clone(), Highlight::Mention),
+ (
+ ranges[2].clone(),
+ HighlightStyle {
+ weight: Some(gpui::fonts::Weight::BOLD),
+ ..Default::default()
+ }
+ .into()
+ ),
+ (ranges[3].clone(), Highlight::SelfMention)
+ ]
+ );
+ assert_eq!(
+ message.regions,
+ vec![
+ RenderedRegion {
+ background_kind: Some(BackgroundKind::Mention),
+ link_url: None
+ },
+ RenderedRegion {
+ background_kind: Some(BackgroundKind::SelfMention),
+ link_url: None
+ },
+ ]
+ );
+ }
+}
@@ -0,0 +1,313 @@
+use channel::{ChannelId, ChannelMembership, ChannelStore, MessageParams};
+use client::UserId;
+use collections::HashMap;
+use editor::{AnchorRangeExt, Editor};
+use gpui::{
+ elements::ChildView, AnyElement, AsyncAppContext, Element, Entity, ModelHandle, Task, View,
+ ViewContext, ViewHandle, WeakViewHandle,
+};
+use language::{language_settings::SoftWrap, Buffer, BufferSnapshot, LanguageRegistry};
+use lazy_static::lazy_static;
+use project::search::SearchQuery;
+use std::{sync::Arc, time::Duration};
+
+const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
+
+lazy_static! {
+ static ref MENTIONS_SEARCH: SearchQuery = SearchQuery::regex(
+ "@[-_\\w]+",
+ false,
+ false,
+ Default::default(),
+ Default::default()
+ )
+ .unwrap();
+}
+
+pub struct MessageEditor {
+ pub editor: ViewHandle<Editor>,
+ channel_store: ModelHandle<ChannelStore>,
+ users: HashMap<String, UserId>,
+ mentions: Vec<UserId>,
+ mentions_task: Option<Task<()>>,
+ channel_id: Option<ChannelId>,
+}
+
+impl MessageEditor {
+ pub fn new(
+ language_registry: Arc<LanguageRegistry>,
+ channel_store: ModelHandle<ChannelStore>,
+ editor: ViewHandle<Editor>,
+ cx: &mut ViewContext<Self>,
+ ) -> Self {
+ editor.update(cx, |editor, cx| {
+ editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
+ });
+
+ let buffer = editor
+ .read(cx)
+ .buffer()
+ .read(cx)
+ .as_singleton()
+ .expect("message editor must be singleton");
+
+ cx.subscribe(&buffer, Self::on_buffer_event).detach();
+
+ let markdown = language_registry.language_for_name("Markdown");
+ cx.app_context()
+ .spawn(|mut cx| async move {
+ let markdown = markdown.await?;
+ buffer.update(&mut cx, |buffer, cx| {
+ buffer.set_language(Some(markdown), cx)
+ });
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+
+ Self {
+ editor,
+ channel_store,
+ users: HashMap::default(),
+ channel_id: None,
+ mentions: Vec::new(),
+ mentions_task: None,
+ }
+ }
+
+ pub fn set_channel(
+ &mut self,
+ channel_id: u64,
+ channel_name: Option<String>,
+ cx: &mut ViewContext<Self>,
+ ) {
+ self.editor.update(cx, |editor, cx| {
+ if let Some(channel_name) = channel_name {
+ editor.set_placeholder_text(format!("Message #{}", channel_name), cx);
+ } else {
+ editor.set_placeholder_text(format!("Message Channel"), cx);
+ }
+ });
+ self.channel_id = Some(channel_id);
+ self.refresh_users(cx);
+ }
+
+ pub fn refresh_users(&mut self, cx: &mut ViewContext<Self>) {
+ if let Some(channel_id) = self.channel_id {
+ let members = self.channel_store.update(cx, |store, cx| {
+ store.get_channel_member_details(channel_id, cx)
+ });
+ cx.spawn(|this, mut cx| async move {
+ let members = members.await?;
+ this.update(&mut cx, |this, cx| this.set_members(members, cx))?;
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+ }
+
+ pub fn set_members(&mut self, members: Vec<ChannelMembership>, _: &mut ViewContext<Self>) {
+ self.users.clear();
+ self.users.extend(
+ members
+ .into_iter()
+ .map(|member| (member.user.github_login.clone(), member.user.id)),
+ );
+ }
+
+ pub fn take_message(&mut self, cx: &mut ViewContext<Self>) -> MessageParams {
+ self.editor.update(cx, |editor, cx| {
+ let highlights = editor.text_highlights::<Self>(cx);
+ let text = editor.text(cx);
+ let snapshot = editor.buffer().read(cx).snapshot(cx);
+ let mentions = if let Some((_, ranges)) = highlights {
+ ranges
+ .iter()
+ .map(|range| range.to_offset(&snapshot))
+ .zip(self.mentions.iter().copied())
+ .collect()
+ } else {
+ Vec::new()
+ };
+
+ editor.clear(cx);
+ self.mentions.clear();
+
+ MessageParams { text, mentions }
+ })
+ }
+
+ fn on_buffer_event(
+ &mut self,
+ buffer: ModelHandle<Buffer>,
+ event: &language::Event,
+ cx: &mut ViewContext<Self>,
+ ) {
+ if let language::Event::Reparsed | language::Event::Edited = event {
+ let buffer = buffer.read(cx).snapshot();
+ self.mentions_task = Some(cx.spawn(|this, cx| async move {
+ cx.background().timer(MENTIONS_DEBOUNCE_INTERVAL).await;
+ Self::find_mentions(this, buffer, cx).await;
+ }));
+ }
+ }
+
+ async fn find_mentions(
+ this: WeakViewHandle<MessageEditor>,
+ buffer: BufferSnapshot,
+ mut cx: AsyncAppContext,
+ ) {
+ let (buffer, ranges) = cx
+ .background()
+ .spawn(async move {
+ let ranges = MENTIONS_SEARCH.search(&buffer, None).await;
+ (buffer, ranges)
+ })
+ .await;
+
+ this.update(&mut cx, |this, cx| {
+ let mut anchor_ranges = Vec::new();
+ let mut mentioned_user_ids = Vec::new();
+ let mut text = String::new();
+
+ this.editor.update(cx, |editor, cx| {
+ let multi_buffer = editor.buffer().read(cx).snapshot(cx);
+ for range in ranges {
+ text.clear();
+ text.extend(buffer.text_for_range(range.clone()));
+ if let Some(username) = text.strip_prefix("@") {
+ if let Some(user_id) = this.users.get(username) {
+ let start = multi_buffer.anchor_after(range.start);
+ let end = multi_buffer.anchor_after(range.end);
+
+ mentioned_user_ids.push(*user_id);
+ anchor_ranges.push(start..end);
+ }
+ }
+ }
+
+ editor.clear_highlights::<Self>(cx);
+ editor.highlight_text::<Self>(
+ anchor_ranges,
+ theme::current(cx).chat_panel.rich_text.mention_highlight,
+ cx,
+ )
+ });
+
+ this.mentions = mentioned_user_ids;
+ this.mentions_task.take();
+ })
+ .ok();
+ }
+}
+
+impl Entity for MessageEditor {
+ type Event = ();
+}
+
+impl View for MessageEditor {
+ fn render(&mut self, cx: &mut ViewContext<'_, '_, Self>) -> AnyElement<Self> {
+ ChildView::new(&self.editor, cx).into_any()
+ }
+
+ fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+ if cx.is_self_focused() {
+ cx.focus(&self.editor);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use client::{Client, User, UserStore};
+ use gpui::{TestAppContext, WindowHandle};
+ use language::{Language, LanguageConfig};
+ use rpc::proto;
+ use settings::SettingsStore;
+ use util::{http::FakeHttpClient, test::marked_text_ranges};
+
+ #[gpui::test]
+ async fn test_message_editor(cx: &mut TestAppContext) {
+ let editor = init_test(cx);
+ let editor = editor.root(cx);
+
+ editor.update(cx, |editor, cx| {
+ editor.set_members(
+ vec![
+ ChannelMembership {
+ user: Arc::new(User {
+ github_login: "a-b".into(),
+ id: 101,
+ avatar: None,
+ }),
+ kind: proto::channel_member::Kind::Member,
+ role: proto::ChannelRole::Member,
+ },
+ ChannelMembership {
+ user: Arc::new(User {
+ github_login: "C_D".into(),
+ id: 102,
+ avatar: None,
+ }),
+ kind: proto::channel_member::Kind::Member,
+ role: proto::ChannelRole::Member,
+ },
+ ],
+ cx,
+ );
+
+ editor.editor.update(cx, |editor, cx| {
+ editor.set_text("Hello, @a-b! Have you met @C_D?", cx)
+ });
+ });
+
+ cx.foreground().advance_clock(MENTIONS_DEBOUNCE_INTERVAL);
+
+ editor.update(cx, |editor, cx| {
+ let (text, ranges) = marked_text_ranges("Hello, «@a-b»! Have you met «@C_D»?", false);
+ assert_eq!(
+ editor.take_message(cx),
+ MessageParams {
+ text,
+ mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
+ }
+ );
+ });
+ }
+
+ fn init_test(cx: &mut TestAppContext) -> WindowHandle<MessageEditor> {
+ cx.foreground().forbid_parking();
+
+ cx.update(|cx| {
+ let http = FakeHttpClient::with_404_response();
+ let client = Client::new(http.clone(), cx);
+ let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
+ cx.set_global(SettingsStore::test(cx));
+ theme::init((), cx);
+ language::init(cx);
+ editor::init(cx);
+ client::init(&client, cx);
+ channel::init(&client, user_store, cx);
+ });
+
+ let language_registry = Arc::new(LanguageRegistry::test());
+ language_registry.add(Arc::new(Language::new(
+ LanguageConfig {
+ name: "Markdown".into(),
+ ..Default::default()
+ },
+ Some(tree_sitter_markdown::language()),
+ )));
+
+ let editor = cx.add_window(|cx| {
+ MessageEditor::new(
+ language_registry,
+ ChannelStore::global(cx),
+ cx.add_view(|cx| Editor::auto_height(4, None, cx)),
+ cx,
+ )
+ });
+ cx.foreground().run_until_parked();
+ editor
+ }
+}
@@ -9,9 +9,12 @@ use crate::{
};
use anyhow::Result;
use call::ActiveCall;
-use channel::{Channel, ChannelData, ChannelEvent, ChannelId, ChannelPath, ChannelStore};
+use channel::{Channel, ChannelEvent, ChannelId, ChannelStore};
use channel_modal::ChannelModal;
-use client::{proto::PeerId, Client, Contact, User, UserStore};
+use client::{
+ proto::{self, PeerId},
+ Client, Contact, User, UserStore,
+};
use contact_finder::ContactFinder;
use context_menu::{ContextMenu, ContextMenuItem};
use db::kvp::KEY_VALUE_STORE;
@@ -43,7 +46,7 @@ use serde_derive::{Deserialize, Serialize};
use settings::SettingsStore;
use std::{borrow::Cow, hash::Hash, mem, sync::Arc};
use theme::{components::ComponentExt, IconButton, Interactive};
-use util::{iife, ResultExt, TryFutureExt};
+use util::{maybe, ResultExt, TryFutureExt};
use workspace::{
dock::{DockPosition, Panel},
item::ItemHandle,
@@ -52,17 +55,17 @@ use workspace::{
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
struct ToggleCollapse {
- location: ChannelPath,
+ location: ChannelId,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
struct NewChannel {
- location: ChannelPath,
+ location: ChannelId,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
struct RenameChannel {
- location: ChannelPath,
+ channel_id: ChannelId,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -108,18 +111,6 @@ pub struct CopyChannelLink {
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
struct StartMoveChannelFor {
channel_id: ChannelId,
- parent_id: Option<ChannelId>,
-}
-
-#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
-struct StartLinkChannelFor {
- channel_id: ChannelId,
- parent_id: Option<ChannelId>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
-struct LinkChannel {
- to: ChannelId,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -127,14 +118,6 @@ struct MoveChannel {
to: ChannelId,
}
-#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
-struct UnlinkChannel {
- channel_id: ChannelId,
- parent_id: ChannelId,
-}
-
-type DraggedChannel = (Channel, Option<ChannelId>);
-
actions!(
collab_panel,
[
@@ -144,8 +127,7 @@ actions!(
CollapseSelectedChannel,
ExpandSelectedChannel,
StartMoveChannel,
- StartLinkChannel,
- MoveOrLinkToSelected,
+ MoveSelected,
InsertSpace,
]
);
@@ -163,11 +145,8 @@ impl_actions!(
JoinChannelCall,
JoinChannelChat,
CopyChannelLink,
- LinkChannel,
StartMoveChannelFor,
- StartLinkChannelFor,
MoveChannel,
- UnlinkChannel,
ToggleSelectedIx
]
);
@@ -175,14 +154,6 @@ impl_actions!(
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
struct ChannelMoveClipboard {
channel_id: ChannelId,
- parent_id: Option<ChannelId>,
- intent: ClipboardIntent,
-}
-
-#[derive(Debug, Copy, Clone, PartialEq, Eq)]
-enum ClipboardIntent {
- Move,
- Link,
}
const COLLABORATION_PANEL_KEY: &'static str = "CollaborationPanel";
@@ -229,87 +200,35 @@ pub fn init(cx: &mut AppContext) {
_: &mut ViewContext<CollabPanel>| {
panel.channel_clipboard = Some(ChannelMoveClipboard {
channel_id: action.channel_id,
- parent_id: action.parent_id,
- intent: ClipboardIntent::Move,
});
},
);
- cx.add_action(
- |panel: &mut CollabPanel,
- action: &StartLinkChannelFor,
- _: &mut ViewContext<CollabPanel>| {
- panel.channel_clipboard = Some(ChannelMoveClipboard {
- channel_id: action.channel_id,
- parent_id: action.parent_id,
- intent: ClipboardIntent::Link,
- })
- },
- );
-
cx.add_action(
|panel: &mut CollabPanel, _: &StartMoveChannel, _: &mut ViewContext<CollabPanel>| {
- if let Some((_, path)) = panel.selected_channel() {
- panel.channel_clipboard = Some(ChannelMoveClipboard {
- channel_id: path.channel_id(),
- parent_id: path.parent_id(),
- intent: ClipboardIntent::Move,
- })
- }
- },
- );
-
- cx.add_action(
- |panel: &mut CollabPanel, _: &StartLinkChannel, _: &mut ViewContext<CollabPanel>| {
- if let Some((_, path)) = panel.selected_channel() {
+ if let Some(channel) = panel.selected_channel() {
panel.channel_clipboard = Some(ChannelMoveClipboard {
- channel_id: path.channel_id(),
- parent_id: path.parent_id(),
- intent: ClipboardIntent::Link,
+ channel_id: channel.id,
})
}
},
);
cx.add_action(
- |panel: &mut CollabPanel, _: &MoveOrLinkToSelected, cx: &mut ViewContext<CollabPanel>| {
- let clipboard = panel.channel_clipboard.take();
- if let Some(((selected_channel, _), clipboard)) =
- panel.selected_channel().zip(clipboard)
- {
- match clipboard.intent {
- ClipboardIntent::Move if clipboard.parent_id.is_some() => {
- let parent_id = clipboard.parent_id.unwrap();
- panel.channel_store.update(cx, |channel_store, cx| {
- channel_store
- .move_channel(
- clipboard.channel_id,
- parent_id,
- selected_channel.id,
- cx,
- )
- .detach_and_log_err(cx)
- })
- }
- _ => panel.channel_store.update(cx, |channel_store, cx| {
- channel_store
- .link_channel(clipboard.channel_id, selected_channel.id, cx)
- .detach_and_log_err(cx)
- }),
- }
- }
- },
- );
+ |panel: &mut CollabPanel, _: &MoveSelected, cx: &mut ViewContext<CollabPanel>| {
+ let Some(clipboard) = panel.channel_clipboard.take() else {
+ return;
+ };
+ let Some(selected_channel) = panel.selected_channel() else {
+ return;
+ };
- cx.add_action(
- |panel: &mut CollabPanel, action: &LinkChannel, cx: &mut ViewContext<CollabPanel>| {
- if let Some(clipboard) = panel.channel_clipboard.take() {
- panel.channel_store.update(cx, |channel_store, cx| {
- channel_store
- .link_channel(clipboard.channel_id, action.to, cx)
- .detach_and_log_err(cx)
+ panel
+ .channel_store
+ .update(cx, |channel_store, cx| {
+ channel_store.move_channel(clipboard.channel_id, Some(selected_channel.id), cx)
})
- }
+ .detach_and_log_err(cx)
},
);
@@ -317,39 +236,23 @@ pub fn init(cx: &mut AppContext) {
|panel: &mut CollabPanel, action: &MoveChannel, cx: &mut ViewContext<CollabPanel>| {
if let Some(clipboard) = panel.channel_clipboard.take() {
panel.channel_store.update(cx, |channel_store, cx| {
- if let Some(parent) = clipboard.parent_id {
- channel_store
- .move_channel(clipboard.channel_id, parent, action.to, cx)
- .detach_and_log_err(cx)
- } else {
- channel_store
- .link_channel(clipboard.channel_id, action.to, cx)
- .detach_and_log_err(cx)
- }
+ channel_store
+ .move_channel(clipboard.channel_id, Some(action.to), cx)
+ .detach_and_log_err(cx)
})
}
},
);
-
- cx.add_action(
- |panel: &mut CollabPanel, action: &UnlinkChannel, cx: &mut ViewContext<CollabPanel>| {
- panel.channel_store.update(cx, |channel_store, cx| {
- channel_store
- .unlink_channel(action.channel_id, action.parent_id, cx)
- .detach_and_log_err(cx)
- })
- },
- );
}
#[derive(Debug)]
pub enum ChannelEditingState {
Create {
- location: Option<ChannelPath>,
+ location: Option<ChannelId>,
pending_name: Option<String>,
},
Rename {
- location: ChannelPath,
+ location: ChannelId,
pending_name: Option<String>,
},
}
@@ -383,16 +286,23 @@ pub struct CollabPanel {
list_state: ListState<Self>,
subscriptions: Vec<Subscription>,
collapsed_sections: Vec<Section>,
- collapsed_channels: Vec<ChannelPath>,
- drag_target_channel: Option<ChannelData>,
+ collapsed_channels: Vec<ChannelId>,
+ drag_target_channel: ChannelDragTarget,
workspace: WeakViewHandle<Workspace>,
context_menu_on_selected: bool,
}
+#[derive(PartialEq, Eq)]
+enum ChannelDragTarget {
+ None,
+ Root,
+ Channel(ChannelId),
+}
+
#[derive(Serialize, Deserialize)]
struct SerializedCollabPanel {
width: Option<f32>,
- collapsed_channels: Option<Vec<ChannelPath>>,
+ collapsed_channels: Option<Vec<ChannelId>>,
}
#[derive(Debug)]
@@ -428,7 +338,7 @@ enum ListEntry {
is_last: bool,
},
ParticipantScreen {
- peer_id: PeerId,
+ peer_id: Option<PeerId>,
is_last: bool,
},
IncomingRequest(Arc<User>),
@@ -437,11 +347,14 @@ enum ListEntry {
Channel {
channel: Arc<Channel>,
depth: usize,
- path: ChannelPath,
+ has_children: bool,
},
ChannelNotes {
channel_id: ChannelId,
},
+ ChannelChat {
+ channel_id: ChannelId,
+ },
ChannelEditor {
depth: usize,
},
@@ -569,14 +482,14 @@ impl CollabPanel {
ListEntry::Channel {
channel,
depth,
- path,
+ has_children,
} => {
let channel_row = this.render_channel(
&*channel,
*depth,
- path.to_owned(),
&theme,
is_selected,
+ *has_children,
ix,
cx,
);
@@ -602,6 +515,13 @@ impl CollabPanel {
ix,
cx,
),
+ ListEntry::ChannelChat { channel_id } => this.render_channel_chat(
+ *channel_id,
+ &theme.collab_panel,
+ is_selected,
+ ix,
+ cx,
+ ),
ListEntry::ChannelInvite(channel) => Self::render_channel_invite(
channel.clone(),
this.channel_store.clone(),
@@ -664,7 +584,7 @@ impl CollabPanel {
workspace: workspace.weak_handle(),
client: workspace.app_state().client.clone(),
context_menu_on_selected: true,
- drag_target_channel: None,
+ drag_target_channel: ChannelDragTarget::None,
list_state,
};
@@ -804,7 +724,8 @@ impl CollabPanel {
let room = room.read(cx);
if let Some(channel_id) = room.channel_id() {
- self.entries.push(ListEntry::ChannelNotes { channel_id })
+ self.entries.push(ListEntry::ChannelNotes { channel_id });
+ self.entries.push(ListEntry::ChannelChat { channel_id })
}
// Populate the active user.
@@ -836,7 +757,13 @@ impl CollabPanel {
project_id: project.id,
worktree_root_names: project.worktree_root_names.clone(),
host_user_id: user_id,
- is_last: projects.peek().is_none(),
+ is_last: projects.peek().is_none() && !room.is_screen_sharing(),
+ });
+ }
+ if room.is_screen_sharing() {
+ self.entries.push(ListEntry::ParticipantScreen {
+ peer_id: None,
+ is_last: true,
});
}
}
@@ -880,7 +807,7 @@ impl CollabPanel {
}
if !participant.video_tracks.is_empty() {
self.entries.push(ListEntry::ParticipantScreen {
- peer_id: participant.peer_id,
+ peer_id: Some(participant.peer_id),
is_last: true,
});
}
@@ -921,7 +848,7 @@ impl CollabPanel {
if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
self.match_candidates.clear();
self.match_candidates
- .extend(channel_store.channel_dag_entries().enumerate().map(
+ .extend(channel_store.ordered_channels().enumerate().map(
|(ix, (_, channel))| StringMatchCandidate {
id: ix,
string: channel.name.clone(),
@@ -943,48 +870,52 @@ impl CollabPanel {
}
let mut collapse_depth = None;
for mat in matches {
- let (channel, path) = channel_store
- .channel_dag_entry_at(mat.candidate_id)
- .unwrap();
- let depth = path.len() - 1;
+ let channel = channel_store.channel_at_index(mat.candidate_id).unwrap();
+ let depth = channel.parent_path.len();
- if collapse_depth.is_none() && self.is_channel_collapsed(path) {
+ if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) {
collapse_depth = Some(depth);
} else if let Some(collapsed_depth) = collapse_depth {
if depth > collapsed_depth {
continue;
}
- if self.is_channel_collapsed(path) {
+ if self.is_channel_collapsed(channel.id) {
collapse_depth = Some(depth);
} else {
collapse_depth = None;
}
}
+ let has_children = channel_store
+ .channel_at_index(mat.candidate_id + 1)
+ .map_or(false, |next_channel| {
+ next_channel.parent_path.ends_with(&[channel.id])
+ });
+
match &self.channel_editing_state {
Some(ChannelEditingState::Create {
- location: parent_path,
+ location: parent_id,
..
- }) if parent_path.as_ref() == Some(path) => {
+ }) if *parent_id == Some(channel.id) => {
self.entries.push(ListEntry::Channel {
channel: channel.clone(),
depth,
- path: path.clone(),
+ has_children: false,
});
self.entries
.push(ListEntry::ChannelEditor { depth: depth + 1 });
}
Some(ChannelEditingState::Rename {
- location: parent_path,
+ location: parent_id,
..
- }) if parent_path == path => {
+ }) if parent_id == &channel.id => {
self.entries.push(ListEntry::ChannelEditor { depth });
}
_ => {
self.entries.push(ListEntry::Channel {
channel: channel.clone(),
depth,
- path: path.clone(),
+ has_children,
});
}
}
@@ -1225,14 +1156,18 @@ impl CollabPanel {
) -> AnyElement<Self> {
enum CallParticipant {}
enum CallParticipantTooltip {}
+ enum LeaveCallButton {}
+ enum LeaveCallTooltip {}
let collab_theme = &theme.collab_panel;
let is_current_user =
user_store.read(cx).current_user().map(|user| user.id) == Some(user.id);
- let content =
- MouseEventHandler::new::<CallParticipant, _>(user.id as usize, cx, |mouse_state, _| {
+ let content = MouseEventHandler::new::<CallParticipant, _>(
+ user.id as usize,
+ cx,
+ |mouse_state, cx| {
let style = if is_current_user {
*collab_theme
.contact_row
@@ -1268,14 +1203,32 @@ impl CollabPanel {
Label::new("Calling", collab_theme.calling_indicator.text.clone())
.contained()
.with_style(collab_theme.calling_indicator.container)
- .aligned(),
+ .aligned()
+ .into_any(),
)
} else if is_current_user {
Some(
- Label::new("You", collab_theme.calling_indicator.text.clone())
- .contained()
- .with_style(collab_theme.calling_indicator.container)
- .aligned(),
+ MouseEventHandler::new::<LeaveCallButton, _>(0, cx, |state, _| {
+ render_icon_button(
+ theme
+ .collab_panel
+ .leave_call_button
+ .style_for(is_selected, state),
+ "icons/exit.svg",
+ )
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, _, cx| {
+ Self::leave_call(cx);
+ })
+ .with_tooltip::<LeaveCallTooltip>(
+ 0,
+ "Leave call",
+ None,
+ theme.tooltip.clone(),
+ cx,
+ )
+ .into_any(),
)
} else {
None
@@ -1284,7 +1237,8 @@ impl CollabPanel {
.with_height(collab_theme.row_height)
.contained()
.with_style(style)
- });
+ },
+ );
if is_current_user || is_pending || peer_id.is_none() {
return content.into_any();
@@ -1406,7 +1360,7 @@ impl CollabPanel {
}
fn render_participant_screen(
- peer_id: PeerId,
+ peer_id: Option<PeerId>,
is_last: bool,
is_selected: bool,
theme: &theme::CollabPanel,
@@ -1421,8 +1375,8 @@ impl CollabPanel {
.unwrap_or(0.);
let tree_branch = theme.tree_branch;
- MouseEventHandler::new::<OpenSharedScreen, _>(
- peer_id.as_u64() as usize,
+ let handler = MouseEventHandler::new::<OpenSharedScreen, _>(
+ peer_id.map(|id| id.as_u64()).unwrap_or(0) as usize,
cx,
|mouse_state, cx| {
let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
@@ -1460,16 +1414,20 @@ impl CollabPanel {
.contained()
.with_style(row.container)
},
- )
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, this, cx| {
- if let Some(workspace) = this.workspace.upgrade(cx) {
- workspace.update(cx, |workspace, cx| {
- workspace.open_shared_screen(peer_id, cx)
- });
- }
- })
- .into_any()
+ );
+ if peer_id.is_none() {
+ return handler.into_any();
+ }
+ handler
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ if let Some(workspace) = this.workspace.upgrade(cx) {
+ workspace.update(cx, |workspace, cx| {
+ workspace.open_shared_screen(peer_id.unwrap(), cx)
+ });
+ }
+ })
+ .into_any()
}
fn take_editing_state(&mut self, cx: &mut ViewContext<Self>) -> bool {
@@ -1496,23 +1454,33 @@ impl CollabPanel {
enum AddChannel {}
let tooltip_style = &theme.tooltip;
+ let mut channel_link = None;
+ let mut channel_tooltip_text = None;
+ let mut channel_icon = None;
+ let mut is_dragged_over = false;
+
let text = match section {
Section::ActiveCall => {
- let channel_name = iife!({
+ let channel_name = maybe!({
let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
- let name = self
- .channel_store
- .read(cx)
- .channel_for_id(channel_id)?
- .name
- .as_str();
+ let channel = self.channel_store.read(cx).channel_for_id(channel_id)?;
+
+ channel_link = Some(channel.link());
+ (channel_icon, channel_tooltip_text) = match channel.visibility {
+ proto::ChannelVisibility::Public => {
+ (Some("icons/public.svg"), Some("Copy public channel link."))
+ }
+ proto::ChannelVisibility::Members => {
+ (Some("icons/hash.svg"), Some("Copy private channel link."))
+ }
+ };
- Some(name)
+ Some(channel.name.as_str())
});
if let Some(name) = channel_name {
- Cow::Owned(format!("#{}", name))
+ Cow::Owned(format!("{}", name))
} else {
Cow::Borrowed("Current Call")
}
@@ -1527,28 +1495,30 @@ impl CollabPanel {
enum AddContact {}
let button = match section {
- Section::ActiveCall => Some(
+ Section::ActiveCall => channel_link.map(|channel_link| {
+ let channel_link_copy = channel_link.clone();
MouseEventHandler::new::<AddContact, _>(0, cx, |state, _| {
render_icon_button(
theme
.collab_panel
.leave_call_button
.style_for(is_selected, state),
- "icons/exit.svg",
+ "icons/link.svg",
)
})
.with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, |_, _, cx| {
- Self::leave_call(cx);
+ .on_click(MouseButton::Left, move |_, _, cx| {
+ let item = ClipboardItem::new(channel_link_copy.clone());
+ cx.write_to_clipboard(item)
})
.with_tooltip::<AddContact>(
0,
- "Leave call",
+ channel_tooltip_text.unwrap(),
None,
tooltip_style.clone(),
cx,
- ),
- ),
+ )
+ }),
Section::Contacts => Some(
MouseEventHandler::new::<LeaveCallContactList, _>(0, cx, |state, _| {
render_icon_button(
@@ -1571,26 +1541,37 @@ impl CollabPanel {
cx,
),
),
- Section::Channels => Some(
- MouseEventHandler::new::<AddChannel, _>(0, cx, |state, _| {
- render_icon_button(
- theme
- .collab_panel
- .add_contact_button
- .style_for(is_selected, state),
- "icons/plus.svg",
- )
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, |_, this, cx| this.new_root_channel(cx))
- .with_tooltip::<AddChannel>(
- 0,
- "Create a channel",
- None,
- tooltip_style.clone(),
- cx,
- ),
- ),
+ Section::Channels => {
+ if cx
+ .global::<DragAndDrop<Workspace>>()
+ .currently_dragged::<Channel>(cx.window())
+ .is_some()
+ && self.drag_target_channel == ChannelDragTarget::Root
+ {
+ is_dragged_over = true;
+ }
+
+ Some(
+ MouseEventHandler::new::<AddChannel, _>(0, cx, |state, _| {
+ render_icon_button(
+ theme
+ .collab_panel
+ .add_contact_button
+ .style_for(is_selected, state),
+ "icons/plus.svg",
+ )
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, this, cx| this.new_root_channel(cx))
+ .with_tooltip::<AddChannel>(
+ 0,
+ "Create a channel",
+ None,
+ tooltip_style.clone(),
+ cx,
+ ),
+ )
+ }
_ => None,
};
@@ -1633,6 +1614,21 @@ impl CollabPanel {
theme.collab_panel.contact_username.container.margin.left,
),
)
+ } else if let Some(channel_icon) = channel_icon {
+ Some(
+ Svg::new(channel_icon)
+ .with_color(header_style.text.color)
+ .constrained()
+ .with_max_width(icon_size)
+ .with_max_height(icon_size)
+ .aligned()
+ .constrained()
+ .with_width(icon_size)
+ .contained()
+ .with_margin_right(
+ theme.collab_panel.contact_username.container.margin.left,
+ ),
+ )
} else {
None
})
@@ -1646,9 +1642,37 @@ impl CollabPanel {
.constrained()
.with_height(theme.collab_panel.row_height)
.contained()
- .with_style(header_style.container)
+ .with_style(if is_dragged_over {
+ theme.collab_panel.dragged_over_header
+ } else {
+ header_style.container
+ })
});
+ result = result
+ .on_move(move |_, this, cx| {
+ if cx
+ .global::<DragAndDrop<Workspace>>()
+ .currently_dragged::<Channel>(cx.window())
+ .is_some()
+ {
+ this.drag_target_channel = ChannelDragTarget::Root;
+ cx.notify()
+ }
+ })
+ .on_up(MouseButton::Left, move |_, this, cx| {
+ if let Some((_, dragged_channel)) = cx
+ .global::<DragAndDrop<Workspace>>()
+ .currently_dragged::<Channel>(cx.window())
+ {
+ this.channel_store
+ .update(cx, |channel_store, cx| {
+ channel_store.move_channel(dragged_channel.id, None, cx)
+ })
+ .detach_and_log_err(cx)
+ }
+ });
+
if can_collapse {
result = result
.with_cursor_style(CursorStyle::PointingHand)
@@ -1899,20 +1923,25 @@ impl CollabPanel {
&self,
channel: &Channel,
depth: usize,
- path: ChannelPath,
theme: &theme::Theme,
is_selected: bool,
+ has_children: bool,
ix: usize,
cx: &mut ViewContext<Self>,
) -> AnyElement<Self> {
let channel_id = channel.id;
let collab_theme = &theme.collab_panel;
- let has_children = self.channel_store.read(cx).has_children(channel_id);
- let other_selected =
- self.selected_channel().map(|channel| channel.0.id) == Some(channel.id);
- let disclosed = has_children.then(|| !self.collapsed_channels.binary_search(&path).is_ok());
-
- let is_active = iife!({
+ let is_public = self
+ .channel_store
+ .read(cx)
+ .channel_for_id(channel_id)
+ .map(|channel| channel.visibility)
+ == Some(proto::ChannelVisibility::Public);
+ let other_selected = self.selected_channel().map(|channel| channel.id) == Some(channel.id);
+ let disclosed =
+ has_children.then(|| !self.collapsed_channels.binary_search(&channel.id).is_ok());
+
+ let is_active = maybe!({
let call_channel = ActiveCall::global(cx)
.read(cx)
.room()?
@@ -1933,13 +1962,9 @@ impl CollabPanel {
let mut is_dragged_over = false;
if cx
.global::<DragAndDrop<Workspace>>()
- .currently_dragged::<DraggedChannel>(cx.window())
+ .currently_dragged::<Channel>(cx.window())
.is_some()
- && self
- .drag_target_channel
- .as_ref()
- .filter(|(_, dragged_path)| path.starts_with(dragged_path))
- .is_some()
+ && self.drag_target_channel == ChannelDragTarget::Channel(channel_id)
{
is_dragged_over = true;
}
@@ -1965,12 +1990,16 @@ impl CollabPanel {
Flex::<Self>::row()
.with_child(
- Svg::new("icons/hash.svg")
- .with_color(collab_theme.channel_hash.color)
- .constrained()
- .with_width(collab_theme.channel_hash.width)
- .aligned()
- .left(),
+ Svg::new(if is_public {
+ "icons/public.svg"
+ } else {
+ "icons/hash.svg"
+ })
+ .with_color(collab_theme.channel_hash.color)
+ .constrained()
+ .with_width(collab_theme.channel_hash.width)
+ .aligned()
+ .left(),
)
.with_child({
let style = collab_theme.channel_name.inactive_state();
@@ -2118,7 +2147,7 @@ impl CollabPanel {
.disclosable(
disclosed,
Box::new(ToggleCollapse {
- location: path.clone(),
+ location: channel.id.clone(),
}),
)
.with_id(ix)
@@ -2138,7 +2167,7 @@ impl CollabPanel {
)
})
.on_click(MouseButton::Left, move |_, this, cx| {
- if this.drag_target_channel.take().is_none() {
+ if this.drag_target_channel == ChannelDragTarget::None {
if is_active {
this.open_channel_notes(&OpenChannelNotes { channel_id }, cx)
} else {
@@ -2147,76 +2176,43 @@ impl CollabPanel {
}
})
.on_click(MouseButton::Right, {
- let path = path.clone();
+ let channel = channel.clone();
move |e, this, cx| {
- this.deploy_channel_context_menu(Some(e.position), &path, ix, cx);
+ this.deploy_channel_context_menu(Some(e.position), &channel, ix, cx);
}
})
- .on_up(MouseButton::Left, move |e, this, cx| {
+ .on_up(MouseButton::Left, move |_, this, cx| {
if let Some((_, dragged_channel)) = cx
.global::<DragAndDrop<Workspace>>()
- .currently_dragged::<DraggedChannel>(cx.window())
+ .currently_dragged::<Channel>(cx.window())
{
- if e.modifiers.alt {
- this.channel_store.update(cx, |channel_store, cx| {
- channel_store
- .link_channel(dragged_channel.0.id, channel_id, cx)
- .detach_and_log_err(cx)
+ this.channel_store
+ .update(cx, |channel_store, cx| {
+ channel_store.move_channel(dragged_channel.id, Some(channel_id), cx)
})
- } else {
- this.channel_store.update(cx, |channel_store, cx| {
- match dragged_channel.1 {
- Some(parent_id) => channel_store.move_channel(
- dragged_channel.0.id,
- parent_id,
- channel_id,
- cx,
- ),
- None => {
- channel_store.link_channel(dragged_channel.0.id, channel_id, cx)
- }
- }
- .detach_and_log_err(cx)
- })
- }
+ .detach_and_log_err(cx)
}
})
.on_move({
let channel = channel.clone();
- let path = path.clone();
move |_, this, cx| {
- if let Some((_, _dragged_channel)) =
- cx.global::<DragAndDrop<Workspace>>()
- .currently_dragged::<DraggedChannel>(cx.window())
+ if let Some((_, dragged_channel)) = cx
+ .global::<DragAndDrop<Workspace>>()
+ .currently_dragged::<Channel>(cx.window())
{
- match &this.drag_target_channel {
- Some(current_target)
- if current_target.0 == channel && current_target.1 == path =>
- {
- return
- }
- _ => {
- this.drag_target_channel = Some((channel.clone(), path.clone()));
- cx.notify();
- }
+ if channel.id != dragged_channel.id {
+ this.drag_target_channel = ChannelDragTarget::Channel(channel.id);
}
+ cx.notify()
}
}
})
- .as_draggable(
- (channel.clone(), path.parent_id()),
- move |modifiers, (channel, _), cx: &mut ViewContext<Workspace>| {
+ .as_draggable::<_, Channel>(
+ channel.clone(),
+ move |_, channel, cx: &mut ViewContext<Workspace>| {
let theme = &theme::current(cx).collab_panel;
Flex::<Workspace>::row()
- .with_children(modifiers.alt.then(|| {
- Svg::new("icons/plus.svg")
- .with_color(theme.channel_hash.color)
- .constrained()
- .with_width(theme.channel_hash.width)
- .aligned()
- .left()
- }))
.with_child(
Svg::new("icons/hash.svg")
.with_color(theme.channel_hash.color)
@@ -2275,7 +2271,7 @@ impl CollabPanel {
.with_child(render_tree_branch(
tree_branch,
&row.name.text,
- true,
+ false,
vec2f(host_avatar_width, theme.row_height),
cx.font_cache(),
))
@@ -2308,6 +2304,62 @@ impl CollabPanel {
.into_any()
}
+ fn render_channel_chat(
+ &self,
+ channel_id: ChannelId,
+ theme: &theme::CollabPanel,
+ is_selected: bool,
+ ix: usize,
+ cx: &mut ViewContext<Self>,
+ ) -> AnyElement<Self> {
+ enum ChannelChat {}
+ let host_avatar_width = theme
+ .contact_avatar
+ .width
+ .or(theme.contact_avatar.height)
+ .unwrap_or(0.);
+
+ MouseEventHandler::new::<ChannelChat, _>(ix as usize, cx, |state, cx| {
+ let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state);
+ let row = theme.project_row.in_state(is_selected).style_for(state);
+
+ Flex::<Self>::row()
+ .with_child(render_tree_branch(
+ tree_branch,
+ &row.name.text,
+ true,
+ vec2f(host_avatar_width, theme.row_height),
+ cx.font_cache(),
+ ))
+ .with_child(
+ Svg::new("icons/conversations.svg")
+ .with_color(theme.channel_hash.color)
+ .constrained()
+ .with_width(theme.channel_hash.width)
+ .aligned()
+ .left(),
+ )
+ .with_child(
+ Label::new("chat", theme.channel_name.text.clone())
+ .contained()
+ .with_style(theme.channel_name.container)
+ .aligned()
+ .left()
+ .flex(1., true),
+ )
+ .constrained()
+ .with_height(theme.row_height)
+ .contained()
+ .with_style(*theme.channel_row.style_for(is_selected, state))
+ .with_padding_left(theme.channel_row.default_style().padding.left)
+ })
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ this.join_channel_chat(&JoinChannelChat { channel_id }, cx);
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .into_any()
+ }
+
fn render_channel_invite(
channel: Arc<Channel>,
channel_store: ModelHandle<ChannelStore>,
@@ -1,12 +1,16 @@
use channel::{ChannelId, ChannelMembership, ChannelStore};
-use client::{proto, User, UserId, UserStore};
+use client::{
+ proto::{self, ChannelRole, ChannelVisibility},
+ User, UserId, UserStore,
+};
use context_menu::{ContextMenu, ContextMenuItem};
use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{
actions,
elements::*,
platform::{CursorStyle, MouseButton},
- AppContext, Entity, ModelHandle, MouseState, Task, View, ViewContext, ViewHandle,
+ AppContext, ClipboardItem, Entity, ModelHandle, MouseState, Task, View, ViewContext,
+ ViewHandle,
};
use picker::{Picker, PickerDelegate, PickerEvent};
use std::sync::Arc;
@@ -96,11 +100,14 @@ impl ChannelModal {
let channel_id = self.channel_id;
cx.spawn(|this, mut cx| async move {
if mode == Mode::ManageMembers {
- let members = channel_store
+ let mut members = channel_store
.update(&mut cx, |channel_store, cx| {
channel_store.get_channel_member_details(channel_id, cx)
})
.await?;
+
+ members.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
+
this.update(&mut cx, |this, cx| {
this.picker
.update(cx, |picker, _| picker.delegate_mut().members = members);
@@ -182,6 +189,81 @@ impl View for ChannelModal {
.into_any()
}
+ fn render_visibility(
+ channel_id: ChannelId,
+ visibility: ChannelVisibility,
+ theme: &theme::TabbedModal,
+ cx: &mut ViewContext<ChannelModal>,
+ ) -> AnyElement<ChannelModal> {
+ enum TogglePublic {}
+
+ if visibility == ChannelVisibility::Members {
+ return Flex::row()
+ .with_child(
+ MouseEventHandler::new::<TogglePublic, _>(0, cx, move |state, _| {
+ let style = theme.visibility_toggle.style_for(state);
+ Label::new(format!("{}", "Public access: OFF"), style.text.clone())
+ .contained()
+ .with_style(style.container.clone())
+ })
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ this.channel_store
+ .update(cx, |channel_store, cx| {
+ channel_store.set_channel_visibility(
+ channel_id,
+ ChannelVisibility::Public,
+ cx,
+ )
+ })
+ .detach_and_log_err(cx);
+ })
+ .with_cursor_style(CursorStyle::PointingHand),
+ )
+ .into_any();
+ }
+
+ Flex::row()
+ .with_child(
+ MouseEventHandler::new::<TogglePublic, _>(0, cx, move |state, _| {
+ let style = theme.visibility_toggle.style_for(state);
+ Label::new(format!("{}", "Public access: ON"), style.text.clone())
+ .contained()
+ .with_style(style.container.clone())
+ })
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ this.channel_store
+ .update(cx, |channel_store, cx| {
+ channel_store.set_channel_visibility(
+ channel_id,
+ ChannelVisibility::Members,
+ cx,
+ )
+ })
+ .detach_and_log_err(cx);
+ })
+ .with_cursor_style(CursorStyle::PointingHand),
+ )
+ .with_spacing(14.0)
+ .with_child(
+ MouseEventHandler::new::<TogglePublic, _>(1, cx, move |state, _| {
+ let style = theme.channel_link.style_for(state);
+ Label::new(format!("{}", "copy link"), style.text.clone())
+ .contained()
+ .with_style(style.container.clone())
+ })
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ if let Some(channel) =
+ this.channel_store.read(cx).channel_for_id(channel_id)
+ {
+ let item = ClipboardItem::new(channel.link());
+ cx.write_to_clipboard(item);
+ }
+ })
+ .with_cursor_style(CursorStyle::PointingHand),
+ )
+ .into_any()
+ }
+
Flex::column()
.with_child(
Flex::column()
@@ -190,6 +272,7 @@ impl View for ChannelModal {
.contained()
.with_style(theme.title.container.clone()),
)
+ .with_child(render_visibility(channel.id, channel.visibility, theme, cx))
.with_child(Flex::row().with_children([
render_mode_button::<InviteMembers>(
Mode::InviteMembers,
@@ -343,9 +426,11 @@ impl PickerDelegate for ChannelModalDelegate {
}
fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
- if let Some((selected_user, admin)) = self.user_at_index(self.selected_index) {
+ if let Some((selected_user, role)) = self.user_at_index(self.selected_index) {
match self.mode {
- Mode::ManageMembers => self.show_context_menu(admin.unwrap_or(false), cx),
+ Mode::ManageMembers => {
+ self.show_context_menu(role.unwrap_or(ChannelRole::Member), cx)
+ }
Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
Some(proto::channel_member::Kind::Invitee) => {
self.remove_selected_member(cx);
@@ -373,7 +458,7 @@ impl PickerDelegate for ChannelModalDelegate {
let full_theme = &theme::current(cx);
let theme = &full_theme.collab_panel.channel_modal;
let tabbed_modal = &full_theme.collab_panel.tabbed_modal;
- let (user, admin) = self.user_at_index(ix).unwrap();
+ let (user, role) = self.user_at_index(ix).unwrap();
let request_status = self.member_status(user.id, cx);
let style = tabbed_modal
@@ -409,15 +494,25 @@ impl PickerDelegate for ChannelModalDelegate {
},
)
})
- .with_children(admin.and_then(|admin| {
- (in_manage && admin).then(|| {
+ .with_children(if in_manage && role == Some(ChannelRole::Admin) {
+ Some(
Label::new("Admin", theme.member_tag.text.clone())
.contained()
.with_style(theme.member_tag.container)
.aligned()
- .left()
- })
- }))
+ .left(),
+ )
+ } else if in_manage && role == Some(ChannelRole::Guest) {
+ Some(
+ Label::new("Guest", theme.member_tag.text.clone())
+ .contained()
+ .with_style(theme.member_tag.container)
+ .aligned()
+ .left(),
+ )
+ } else {
+ None
+ })
.with_children({
let svg = match self.mode {
Mode::ManageMembers => Some(
@@ -502,13 +597,13 @@ impl ChannelModalDelegate {
})
}
- fn user_at_index(&self, ix: usize) -> Option<(Arc<User>, Option<bool>)> {
+ fn user_at_index(&self, ix: usize) -> Option<(Arc<User>, Option<ChannelRole>)> {
match self.mode {
Mode::ManageMembers => self.matching_member_indices.get(ix).and_then(|ix| {
let channel_membership = self.members.get(*ix)?;
Some((
channel_membership.user.clone(),
- Some(channel_membership.admin),
+ Some(channel_membership.role),
))
}),
Mode::InviteMembers => Some((self.matching_users.get(ix).cloned()?, None)),
@@ -516,17 +611,21 @@ impl ChannelModalDelegate {
}
fn toggle_selected_member_admin(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
- let (user, admin) = self.user_at_index(self.selected_index)?;
- let admin = !admin.unwrap_or(false);
+ let (user, role) = self.user_at_index(self.selected_index)?;
+ let new_role = if role == Some(ChannelRole::Admin) {
+ ChannelRole::Member
+ } else {
+ ChannelRole::Admin
+ };
let update = self.channel_store.update(cx, |store, cx| {
- store.set_member_admin(self.channel_id, user.id, admin, cx)
+ store.set_member_role(self.channel_id, user.id, new_role, cx)
});
cx.spawn(|picker, mut cx| async move {
update.await?;
picker.update(&mut cx, |picker, cx| {
let this = picker.delegate_mut();
if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user.id) {
- member.admin = admin;
+ member.role = new_role;
}
cx.focus_self();
cx.notify();
@@ -572,25 +671,30 @@ impl ChannelModalDelegate {
fn invite_member(&mut self, user: Arc<User>, cx: &mut ViewContext<Picker<Self>>) {
let invite_member = self.channel_store.update(cx, |store, cx| {
- store.invite_member(self.channel_id, user.id, false, cx)
+ store.invite_member(self.channel_id, user.id, ChannelRole::Member, cx)
});
cx.spawn(|this, mut cx| async move {
invite_member.await?;
this.update(&mut cx, |this, cx| {
- this.delegate_mut().members.push(ChannelMembership {
+ let new_member = ChannelMembership {
user,
kind: proto::channel_member::Kind::Invitee,
- admin: false,
- });
+ role: ChannelRole::Member,
+ };
+ let members = &mut this.delegate_mut().members;
+ match members.binary_search_by_key(&new_member.sort_key(), |k| k.sort_key()) {
+ Ok(ix) | Err(ix) => members.insert(ix, new_member),
+ }
+
cx.notify();
})
})
.detach_and_log_err(cx);
}
- fn show_context_menu(&mut self, user_is_admin: bool, cx: &mut ViewContext<Picker<Self>>) {
+ fn show_context_menu(&mut self, role: ChannelRole, cx: &mut ViewContext<Picker<Self>>) {
self.context_menu.update(cx, |context_menu, cx| {
context_menu.show(
Default::default(),
@@ -598,7 +702,7 @@ impl ChannelModalDelegate {
vec![
ContextMenuItem::action("Remove", RemoveMember),
ContextMenuItem::action(
- if user_is_admin {
+ if role == ChannelRole::Admin {
"Make non-admin"
} else {
"Make admin"
@@ -1,10 +1,10 @@
use crate::{
- contact_notification::ContactNotification, face_pile::FacePile, toggle_deafen, toggle_mute,
- toggle_screen_sharing, LeaveCall, ToggleDeafen, ToggleMute, ToggleScreenSharing,
+ face_pile::FacePile, toggle_deafen, toggle_mute, toggle_screen_sharing, LeaveCall,
+ ToggleDeafen, ToggleMute, ToggleScreenSharing,
};
use auto_update::AutoUpdateStatus;
use call::{ActiveCall, ParticipantLocation, Room};
-use client::{proto::PeerId, Client, ContactEventKind, SignIn, SignOut, User, UserStore};
+use client::{proto::PeerId, Client, SignIn, SignOut, User, UserStore};
use clock::ReplicaId;
use context_menu::{ContextMenu, ContextMenuItem};
use gpui::{
@@ -88,8 +88,10 @@ impl View for CollabTitlebarItem {
.zip(peer_id)
.zip(ActiveCall::global(cx).read(cx).room().cloned())
{
- right_container
- .add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx));
+ if room.read(cx).can_publish() {
+ right_container
+ .add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx));
+ }
right_container.add_child(self.render_leave_call(&theme, cx));
let muted = room.read(cx).is_muted(cx);
let speaking = room.read(cx).is_speaking();
@@ -97,9 +99,14 @@ impl View for CollabTitlebarItem {
self.render_current_user(&workspace, &theme, &user, peer_id, muted, speaking, cx),
);
left_container.add_children(self.render_collaborators(&workspace, &theme, &room, cx));
- right_container.add_child(self.render_toggle_mute(&theme, &room, cx));
+ if room.read(cx).can_publish() {
+ right_container.add_child(self.render_toggle_mute(&theme, &room, cx));
+ }
right_container.add_child(self.render_toggle_deafen(&theme, &room, cx));
- right_container.add_child(self.render_toggle_screen_sharing_button(&theme, &room, cx));
+ if room.read(cx).can_publish() {
+ right_container
+ .add_child(self.render_toggle_screen_sharing_button(&theme, &room, cx));
+ }
}
let status = workspace.read(cx).client().status();
@@ -151,28 +158,6 @@ impl CollabTitlebarItem {
this.window_activation_changed(active, cx)
}));
subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify()));
- subscriptions.push(
- cx.subscribe(&user_store, move |this, user_store, event, cx| {
- if let Some(workspace) = this.workspace.upgrade(cx) {
- workspace.update(cx, |workspace, cx| {
- if let client::Event::Contact { user, kind } = event {
- if let ContactEventKind::Requested | ContactEventKind::Accepted = kind {
- workspace.show_notification(user.id as usize, cx, |cx| {
- cx.add_view(|cx| {
- ContactNotification::new(
- user.clone(),
- *kind,
- user_store,
- cx,
- )
- })
- })
- }
- }
- });
- }
- }),
- );
Self {
workspace: workspace.weak_handle(),
@@ -488,7 +473,11 @@ impl CollabTitlebarItem {
pub fn toggle_vcs_menu(&mut self, _: &ToggleVcsMenu, cx: &mut ViewContext<Self>) {
if self.branch_popover.take().is_none() {
if let Some(workspace) = self.workspace.upgrade(cx) {
- let view = cx.add_view(|cx| build_branch_list(workspace, cx));
+ let Some(view) =
+ cx.add_option_view(|cx| build_branch_list(workspace, cx).log_err())
+ else {
+ return;
+ };
cx.subscribe(&view, |this, _, event, cx| {
match event {
PickerEvent::Dismiss => {
@@ -2,30 +2,32 @@ pub mod channel_view;
pub mod chat_panel;
pub mod collab_panel;
mod collab_titlebar_item;
-mod contact_notification;
mod face_pile;
-mod incoming_call_notification;
-mod notifications;
+pub mod notification_panel;
+pub mod notifications;
mod panel_settings;
-pub mod project_shared_notification;
-mod sharing_status_indicator;
use call::{report_call_event_for_room, ActiveCall, Room};
+use feature_flags::{ChannelsAlpha, FeatureFlagAppExt};
use gpui::{
actions,
+ elements::{ContainerStyle, Empty, Image},
geometry::{
rect::RectF,
vector::{vec2f, Vector2F},
},
platform::{Screen, WindowBounds, WindowKind, WindowOptions},
- AppContext, Task,
+ AnyElement, AppContext, Element, ImageData, Task,
};
use std::{rc::Rc, sync::Arc};
+use theme::AvatarStyle;
use util::ResultExt;
use workspace::AppState;
pub use collab_titlebar_item::CollabTitlebarItem;
-pub use panel_settings::{ChatPanelSettings, CollaborationPanelSettings};
+pub use panel_settings::{
+ ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings,
+};
actions!(
collab,
@@ -35,14 +37,13 @@ actions!(
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
settings::register::<CollaborationPanelSettings>(cx);
settings::register::<ChatPanelSettings>(cx);
+ settings::register::<NotificationPanelSettings>(cx);
vcs_menu::init(cx);
collab_titlebar_item::init(cx);
collab_panel::init(cx);
chat_panel::init(cx);
- incoming_call_notification::init(&app_state, cx);
- project_shared_notification::init(&app_state, cx);
- sharing_status_indicator::init(cx);
+ notifications::init(&app_state, cx);
cx.add_global_action(toggle_screen_sharing);
cx.add_global_action(toggle_mute);
@@ -130,3 +131,35 @@ fn notification_window_options(
screen: Some(screen),
}
}
+
+fn render_avatar<T: 'static>(
+ avatar: Option<Arc<ImageData>>,
+ avatar_style: &AvatarStyle,
+ container: ContainerStyle,
+) -> AnyElement<T> {
+ avatar
+ .map(|avatar| {
+ Image::from_data(avatar)
+ .with_style(avatar_style.image)
+ .aligned()
+ .contained()
+ .with_corner_radius(avatar_style.outer_corner_radius)
+ .constrained()
+ .with_width(avatar_style.outer_width)
+ .with_height(avatar_style.outer_width)
+ .into_any()
+ })
+ .unwrap_or_else(|| {
+ Empty::new()
+ .constrained()
+ .with_width(avatar_style.outer_width)
+ .into_any()
+ })
+ .contained()
+ .with_style(container)
+ .into_any()
+}
+
+fn is_channels_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool {
+ cx.is_staff() || cx.has_flag::<ChannelsAlpha>()
+}
@@ -1,121 +0,0 @@
-use std::sync::Arc;
-
-use crate::notifications::render_user_notification;
-use client::{ContactEventKind, User, UserStore};
-use gpui::{elements::*, Entity, ModelHandle, View, ViewContext};
-use workspace::notifications::Notification;
-
-pub struct ContactNotification {
- user_store: ModelHandle<UserStore>,
- user: Arc<User>,
- kind: client::ContactEventKind,
-}
-
-#[derive(Clone, PartialEq)]
-struct Dismiss(u64);
-
-#[derive(Clone, PartialEq)]
-pub struct RespondToContactRequest {
- pub user_id: u64,
- pub accept: bool,
-}
-
-pub enum Event {
- Dismiss,
-}
-
-impl Entity for ContactNotification {
- type Event = Event;
-}
-
-impl View for ContactNotification {
- fn ui_name() -> &'static str {
- "ContactNotification"
- }
-
- fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
- match self.kind {
- ContactEventKind::Requested => render_user_notification(
- self.user.clone(),
- "wants to add you as a contact",
- Some("They won't be alerted if you decline."),
- |notification, cx| notification.dismiss(cx),
- vec![
- (
- "Decline",
- Box::new(|notification, cx| {
- notification.respond_to_contact_request(false, cx)
- }),
- ),
- (
- "Accept",
- Box::new(|notification, cx| {
- notification.respond_to_contact_request(true, cx)
- }),
- ),
- ],
- cx,
- ),
- ContactEventKind::Accepted => render_user_notification(
- self.user.clone(),
- "accepted your contact request",
- None,
- |notification, cx| notification.dismiss(cx),
- vec![],
- cx,
- ),
- _ => unreachable!(),
- }
- }
-}
-
-impl Notification for ContactNotification {
- fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
- matches!(event, Event::Dismiss)
- }
-}
-
-impl ContactNotification {
- pub fn new(
- user: Arc<User>,
- kind: client::ContactEventKind,
- user_store: ModelHandle<UserStore>,
- cx: &mut ViewContext<Self>,
- ) -> Self {
- cx.subscribe(&user_store, move |this, _, event, cx| {
- if let client::Event::Contact {
- kind: ContactEventKind::Cancelled,
- user,
- } = event
- {
- if user.id == this.user.id {
- cx.emit(Event::Dismiss);
- }
- }
- })
- .detach();
-
- Self {
- user,
- kind,
- user_store,
- }
- }
-
- fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
- self.user_store.update(cx, |store, cx| {
- store
- .dismiss_contact_request(self.user.id, cx)
- .detach_and_log_err(cx);
- });
- cx.emit(Event::Dismiss);
- }
-
- fn respond_to_contact_request(&mut self, accept: bool, cx: &mut ViewContext<Self>) {
- self.user_store
- .update(cx, |store, cx| {
- store.respond_to_contact_request(self.user.id, accept, cx)
- })
- .detach();
- }
-}
@@ -0,0 +1,884 @@
+use crate::{chat_panel::ChatPanel, render_avatar, NotificationPanelSettings};
+use anyhow::Result;
+use channel::ChannelStore;
+use client::{Client, Notification, User, UserStore};
+use collections::HashMap;
+use db::kvp::KEY_VALUE_STORE;
+use futures::StreamExt;
+use gpui::{
+ actions,
+ elements::*,
+ platform::{CursorStyle, MouseButton},
+ serde_json, AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Task, View,
+ ViewContext, ViewHandle, WeakViewHandle, WindowContext,
+};
+use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
+use project::Fs;
+use rpc::proto;
+use serde::{Deserialize, Serialize};
+use settings::SettingsStore;
+use std::{sync::Arc, time::Duration};
+use theme::{ui, Theme};
+use time::{OffsetDateTime, UtcOffset};
+use util::{ResultExt, TryFutureExt};
+use workspace::{
+ dock::{DockPosition, Panel},
+ Workspace,
+};
+
+const LOADING_THRESHOLD: usize = 30;
+const MARK_AS_READ_DELAY: Duration = Duration::from_secs(1);
+const TOAST_DURATION: Duration = Duration::from_secs(5);
+const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel";
+
+pub struct NotificationPanel {
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ channel_store: ModelHandle<ChannelStore>,
+ notification_store: ModelHandle<NotificationStore>,
+ fs: Arc<dyn Fs>,
+ width: Option<f32>,
+ active: bool,
+ notification_list: ListState<Self>,
+ pending_serialization: Task<Option<()>>,
+ subscriptions: Vec<gpui::Subscription>,
+ workspace: WeakViewHandle<Workspace>,
+ current_notification_toast: Option<(u64, Task<()>)>,
+ local_timezone: UtcOffset,
+ has_focus: bool,
+ mark_as_read_tasks: HashMap<u64, Task<Result<()>>>,
+}
+
+#[derive(Serialize, Deserialize)]
+struct SerializedNotificationPanel {
+ width: Option<f32>,
+}
+
+#[derive(Debug)]
+pub enum Event {
+ DockPositionChanged,
+ Focus,
+ Dismissed,
+}
+
+pub struct NotificationPresenter {
+ pub actor: Option<Arc<client::User>>,
+ pub text: String,
+ pub icon: &'static str,
+ pub needs_response: bool,
+ pub can_navigate: bool,
+}
+
+actions!(notification_panel, [ToggleFocus]);
+
+pub fn init(_cx: &mut AppContext) {}
+
+impl NotificationPanel {
+ pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
+ let fs = workspace.app_state().fs.clone();
+ let client = workspace.app_state().client.clone();
+ let user_store = workspace.app_state().user_store.clone();
+ let workspace_handle = workspace.weak_handle();
+
+ cx.add_view(|cx| {
+ let mut status = client.status();
+ cx.spawn(|this, mut cx| async move {
+ while let Some(_) = status.next().await {
+ if this
+ .update(&mut cx, |_, cx| {
+ cx.notify();
+ })
+ .is_err()
+ {
+ break;
+ }
+ }
+ })
+ .detach();
+
+ let mut notification_list =
+ ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
+ this.render_notification(ix, cx)
+ .unwrap_or_else(|| Empty::new().into_any())
+ });
+ notification_list.set_scroll_handler(|visible_range, count, this, cx| {
+ if count.saturating_sub(visible_range.end) < LOADING_THRESHOLD {
+ if let Some(task) = this
+ .notification_store
+ .update(cx, |store, cx| store.load_more_notifications(false, cx))
+ {
+ task.detach();
+ }
+ }
+ });
+
+ let mut this = Self {
+ fs,
+ client,
+ user_store,
+ local_timezone: cx.platform().local_timezone(),
+ channel_store: ChannelStore::global(cx),
+ notification_store: NotificationStore::global(cx),
+ notification_list,
+ pending_serialization: Task::ready(None),
+ workspace: workspace_handle,
+ has_focus: false,
+ current_notification_toast: None,
+ subscriptions: Vec::new(),
+ active: false,
+ mark_as_read_tasks: HashMap::default(),
+ width: None,
+ };
+
+ let mut old_dock_position = this.position(cx);
+ this.subscriptions.extend([
+ cx.observe(&this.notification_store, |_, _, cx| cx.notify()),
+ cx.subscribe(&this.notification_store, Self::on_notification_event),
+ cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| {
+ let new_dock_position = this.position(cx);
+ if new_dock_position != old_dock_position {
+ old_dock_position = new_dock_position;
+ cx.emit(Event::DockPositionChanged);
+ }
+ cx.notify();
+ }),
+ ]);
+ this
+ })
+ }
+
+ pub fn load(
+ workspace: WeakViewHandle<Workspace>,
+ cx: AsyncAppContext,
+ ) -> Task<Result<ViewHandle<Self>>> {
+ cx.spawn(|mut cx| async move {
+ let serialized_panel = if let Some(panel) = cx
+ .background()
+ .spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) })
+ .await
+ .log_err()
+ .flatten()
+ {
+ Some(serde_json::from_str::<SerializedNotificationPanel>(&panel)?)
+ } else {
+ None
+ };
+
+ workspace.update(&mut cx, |workspace, cx| {
+ let panel = Self::new(workspace, cx);
+ if let Some(serialized_panel) = serialized_panel {
+ panel.update(cx, |panel, cx| {
+ panel.width = serialized_panel.width;
+ cx.notify();
+ });
+ }
+ panel
+ })
+ })
+ }
+
+ fn serialize(&mut self, cx: &mut ViewContext<Self>) {
+ let width = self.width;
+ self.pending_serialization = cx.background().spawn(
+ async move {
+ KEY_VALUE_STORE
+ .write_kvp(
+ NOTIFICATION_PANEL_KEY.into(),
+ serde_json::to_string(&SerializedNotificationPanel { width })?,
+ )
+ .await?;
+ anyhow::Ok(())
+ }
+ .log_err(),
+ );
+ }
+
+ fn render_notification(
+ &mut self,
+ ix: usize,
+ cx: &mut ViewContext<Self>,
+ ) -> Option<AnyElement<Self>> {
+ let entry = self.notification_store.read(cx).notification_at(ix)?;
+ let notification_id = entry.id;
+ let now = OffsetDateTime::now_utc();
+ let timestamp = entry.timestamp;
+ let NotificationPresenter {
+ actor,
+ text,
+ needs_response,
+ can_navigate,
+ ..
+ } = self.present_notification(entry, cx)?;
+
+ let theme = theme::current(cx);
+ let style = &theme.notification_panel;
+ let response = entry.response;
+ let notification = entry.notification.clone();
+
+ let message_style = if entry.is_read {
+ style.read_text.clone()
+ } else {
+ style.unread_text.clone()
+ };
+
+ if self.active && !entry.is_read {
+ self.did_render_notification(notification_id, ¬ification, cx);
+ }
+
+ enum Decline {}
+ enum Accept {}
+
+ Some(
+ MouseEventHandler::new::<NotificationEntry, _>(ix, cx, |_, cx| {
+ let container = message_style.container;
+
+ Flex::row()
+ .with_children(actor.map(|actor| {
+ render_avatar(actor.avatar.clone(), &style.avatar, style.avatar_container)
+ }))
+ .with_child(
+ Flex::column()
+ .with_child(Text::new(text, message_style.text.clone()))
+ .with_child(
+ Flex::row()
+ .with_child(
+ Label::new(
+ format_timestamp(timestamp, now, self.local_timezone),
+ style.timestamp.text.clone(),
+ )
+ .contained()
+ .with_style(style.timestamp.container),
+ )
+ .with_children(if let Some(is_accepted) = response {
+ Some(
+ Label::new(
+ if is_accepted {
+ "You accepted"
+ } else {
+ "You declined"
+ },
+ style.read_text.text.clone(),
+ )
+ .flex_float()
+ .into_any(),
+ )
+ } else if needs_response {
+ Some(
+ Flex::row()
+ .with_children([
+ MouseEventHandler::new::<Decline, _>(
+ ix,
+ cx,
+ |state, _| {
+ let button =
+ style.button.style_for(state);
+ Label::new(
+ "Decline",
+ button.text.clone(),
+ )
+ .contained()
+ .with_style(button.container)
+ },
+ )
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, {
+ let notification = notification.clone();
+ move |_, view, cx| {
+ view.respond_to_notification(
+ notification.clone(),
+ false,
+ cx,
+ );
+ }
+ }),
+ MouseEventHandler::new::<Accept, _>(
+ ix,
+ cx,
+ |state, _| {
+ let button =
+ style.button.style_for(state);
+ Label::new(
+ "Accept",
+ button.text.clone(),
+ )
+ .contained()
+ .with_style(button.container)
+ },
+ )
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, {
+ let notification = notification.clone();
+ move |_, view, cx| {
+ view.respond_to_notification(
+ notification.clone(),
+ true,
+ cx,
+ );
+ }
+ }),
+ ])
+ .flex_float()
+ .into_any(),
+ )
+ } else {
+ None
+ }),
+ )
+ .flex(1.0, true),
+ )
+ .contained()
+ .with_style(container)
+ .into_any()
+ })
+ .with_cursor_style(if can_navigate {
+ CursorStyle::PointingHand
+ } else {
+ CursorStyle::default()
+ })
+ .on_click(MouseButton::Left, {
+ let notification = notification.clone();
+ move |_, this, cx| this.did_click_notification(¬ification, cx)
+ })
+ .into_any(),
+ )
+ }
+
+ fn present_notification(
+ &self,
+ entry: &NotificationEntry,
+ cx: &AppContext,
+ ) -> Option<NotificationPresenter> {
+ let user_store = self.user_store.read(cx);
+ let channel_store = self.channel_store.read(cx);
+ match entry.notification {
+ Notification::ContactRequest { sender_id } => {
+ let requester = user_store.get_cached_user(sender_id)?;
+ Some(NotificationPresenter {
+ icon: "icons/plus.svg",
+ text: format!("{} wants to add you as a contact", requester.github_login),
+ needs_response: user_store.has_incoming_contact_request(requester.id),
+ actor: Some(requester),
+ can_navigate: false,
+ })
+ }
+ Notification::ContactRequestAccepted { responder_id } => {
+ let responder = user_store.get_cached_user(responder_id)?;
+ Some(NotificationPresenter {
+ icon: "icons/plus.svg",
+ text: format!("{} accepted your contact invite", responder.github_login),
+ needs_response: false,
+ actor: Some(responder),
+ can_navigate: false,
+ })
+ }
+ Notification::ChannelInvitation {
+ ref channel_name,
+ channel_id,
+ inviter_id,
+ } => {
+ let inviter = user_store.get_cached_user(inviter_id)?;
+ Some(NotificationPresenter {
+ icon: "icons/hash.svg",
+ text: format!(
+ "{} invited you to join the #{channel_name} channel",
+ inviter.github_login
+ ),
+ needs_response: channel_store.has_channel_invitation(channel_id),
+ actor: Some(inviter),
+ can_navigate: false,
+ })
+ }
+ Notification::ChannelMessageMention {
+ sender_id,
+ channel_id,
+ message_id,
+ } => {
+ let sender = user_store.get_cached_user(sender_id)?;
+ let channel = channel_store.channel_for_id(channel_id)?;
+ let message = self
+ .notification_store
+ .read(cx)
+ .channel_message_for_id(message_id)?;
+ Some(NotificationPresenter {
+ icon: "icons/conversations.svg",
+ text: format!(
+ "{} mentioned you in #{}:\n{}",
+ sender.github_login, channel.name, message.body,
+ ),
+ needs_response: false,
+ actor: Some(sender),
+ can_navigate: true,
+ })
+ }
+ }
+ }
+
+ fn did_render_notification(
+ &mut self,
+ notification_id: u64,
+ notification: &Notification,
+ cx: &mut ViewContext<Self>,
+ ) {
+ let should_mark_as_read = match notification {
+ Notification::ContactRequestAccepted { .. } => true,
+ Notification::ContactRequest { .. }
+ | Notification::ChannelInvitation { .. }
+ | Notification::ChannelMessageMention { .. } => false,
+ };
+
+ if should_mark_as_read {
+ self.mark_as_read_tasks
+ .entry(notification_id)
+ .or_insert_with(|| {
+ let client = self.client.clone();
+ cx.spawn(|this, mut cx| async move {
+ cx.background().timer(MARK_AS_READ_DELAY).await;
+ client
+ .request(proto::MarkNotificationRead { notification_id })
+ .await?;
+ this.update(&mut cx, |this, _| {
+ this.mark_as_read_tasks.remove(¬ification_id);
+ })?;
+ Ok(())
+ })
+ });
+ }
+ }
+
+ fn did_click_notification(&mut self, notification: &Notification, cx: &mut ViewContext<Self>) {
+ if let Notification::ChannelMessageMention {
+ message_id,
+ channel_id,
+ ..
+ } = notification.clone()
+ {
+ if let Some(workspace) = self.workspace.upgrade(cx) {
+ cx.app_context().defer(move |cx| {
+ workspace.update(cx, |workspace, cx| {
+ if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
+ panel.update(cx, |panel, cx| {
+ panel
+ .select_channel(channel_id, Some(message_id), cx)
+ .detach_and_log_err(cx);
+ });
+ }
+ });
+ });
+ }
+ }
+ }
+
+ fn is_showing_notification(&self, notification: &Notification, cx: &AppContext) -> bool {
+ if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification {
+ if let Some(workspace) = self.workspace.upgrade(cx) {
+ return workspace
+ .read_with(cx, |workspace, cx| {
+ if let Some(panel) = workspace.panel::<ChatPanel>(cx) {
+ return panel.read_with(cx, |panel, cx| {
+ panel.is_scrolled_to_bottom()
+ && panel.active_chat().map_or(false, |chat| {
+ chat.read(cx).channel_id == *channel_id
+ })
+ });
+ }
+ false
+ })
+ .unwrap_or_default();
+ }
+ }
+
+ false
+ }
+
+ fn render_sign_in_prompt(
+ &self,
+ theme: &Arc<Theme>,
+ cx: &mut ViewContext<Self>,
+ ) -> AnyElement<Self> {
+ enum SignInPromptLabel {}
+
+ MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
+ Label::new(
+ "Sign in to view your notifications".to_string(),
+ theme
+ .chat_panel
+ .sign_in_prompt
+ .style_for(mouse_state)
+ .clone(),
+ )
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ let client = this.client.clone();
+ cx.spawn(|_, cx| async move {
+ client.authenticate_and_connect(true, &cx).log_err().await;
+ })
+ .detach();
+ })
+ .aligned()
+ .into_any()
+ }
+
+ fn render_empty_state(
+ &self,
+ theme: &Arc<Theme>,
+ _cx: &mut ViewContext<Self>,
+ ) -> AnyElement<Self> {
+ Label::new(
+ "You have no notifications".to_string(),
+ theme.chat_panel.sign_in_prompt.default.clone(),
+ )
+ .aligned()
+ .into_any()
+ }
+
+ fn on_notification_event(
+ &mut self,
+ _: ModelHandle<NotificationStore>,
+ event: &NotificationEvent,
+ cx: &mut ViewContext<Self>,
+ ) {
+ match event {
+ NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx),
+ NotificationEvent::NotificationRemoved { entry }
+ | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry.id, cx),
+ NotificationEvent::NotificationsUpdated {
+ old_range,
+ new_count,
+ } => {
+ self.notification_list.splice(old_range.clone(), *new_count);
+ cx.notify();
+ }
+ }
+ }
+
+ fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
+ if self.is_showing_notification(&entry.notification, cx) {
+ return;
+ }
+
+ let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx)
+ else {
+ return;
+ };
+
+ let notification_id = entry.id;
+ self.current_notification_toast = Some((
+ notification_id,
+ cx.spawn(|this, mut cx| async move {
+ cx.background().timer(TOAST_DURATION).await;
+ this.update(&mut cx, |this, cx| this.remove_toast(notification_id, cx))
+ .ok();
+ }),
+ ));
+
+ self.workspace
+ .update(cx, |workspace, cx| {
+ workspace.dismiss_notification::<NotificationToast>(0, cx);
+ workspace.show_notification(0, cx, |cx| {
+ let workspace = cx.weak_handle();
+ cx.add_view(|_| NotificationToast {
+ notification_id,
+ actor,
+ text,
+ workspace,
+ })
+ })
+ })
+ .ok();
+ }
+
+ fn remove_toast(&mut self, notification_id: u64, cx: &mut ViewContext<Self>) {
+ if let Some((current_id, _)) = &self.current_notification_toast {
+ if *current_id == notification_id {
+ self.current_notification_toast.take();
+ self.workspace
+ .update(cx, |workspace, cx| {
+ workspace.dismiss_notification::<NotificationToast>(0, cx)
+ })
+ .ok();
+ }
+ }
+ }
+
+ fn respond_to_notification(
+ &mut self,
+ notification: Notification,
+ response: bool,
+ cx: &mut ViewContext<Self>,
+ ) {
+ self.notification_store.update(cx, |store, cx| {
+ store.respond_to_notification(notification, response, cx);
+ });
+ }
+}
+
+impl Entity for NotificationPanel {
+ type Event = Event;
+}
+
+impl View for NotificationPanel {
+ fn ui_name() -> &'static str {
+ "NotificationPanel"
+ }
+
+ fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+ let theme = theme::current(cx);
+ let style = &theme.notification_panel;
+ let element = if self.client.user_id().is_none() {
+ self.render_sign_in_prompt(&theme, cx)
+ } else if self.notification_list.item_count() == 0 {
+ self.render_empty_state(&theme, cx)
+ } else {
+ Flex::column()
+ .with_child(
+ Flex::row()
+ .with_child(Label::new("Notifications", style.title.text.clone()))
+ .with_child(ui::svg(&style.title_icon).flex_float())
+ .align_children_center()
+ .contained()
+ .with_style(style.title.container)
+ .constrained()
+ .with_height(style.title_height),
+ )
+ .with_child(
+ List::new(self.notification_list.clone())
+ .contained()
+ .with_style(style.list)
+ .flex(1., true),
+ )
+ .into_any()
+ };
+ element
+ .contained()
+ .with_style(style.container)
+ .constrained()
+ .with_min_width(150.)
+ .into_any()
+ }
+
+ fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
+ self.has_focus = true;
+ }
+
+ fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
+ self.has_focus = false;
+ }
+}
+
+impl Panel for NotificationPanel {
+ fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
+ settings::get::<NotificationPanelSettings>(cx).dock
+ }
+
+ fn position_is_valid(&self, position: DockPosition) -> bool {
+ matches!(position, DockPosition::Left | DockPosition::Right)
+ }
+
+ fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
+ settings::update_settings_file::<NotificationPanelSettings>(
+ self.fs.clone(),
+ cx,
+ move |settings| settings.dock = Some(position),
+ );
+ }
+
+ fn size(&self, cx: &gpui::WindowContext) -> f32 {
+ self.width
+ .unwrap_or_else(|| settings::get::<NotificationPanelSettings>(cx).default_width)
+ }
+
+ fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
+ self.width = size;
+ self.serialize(cx);
+ cx.notify();
+ }
+
+ fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
+ self.active = active;
+ if self.notification_store.read(cx).notification_count() == 0 {
+ cx.emit(Event::Dismissed);
+ }
+ }
+
+ fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
+ (settings::get::<NotificationPanelSettings>(cx).button
+ && self.notification_store.read(cx).notification_count() > 0)
+ .then(|| "icons/bell.svg")
+ }
+
+ fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
+ (
+ "Notification Panel".to_string(),
+ Some(Box::new(ToggleFocus)),
+ )
+ }
+
+ fn icon_label(&self, cx: &WindowContext) -> Option<String> {
+ let count = self.notification_store.read(cx).unread_notification_count();
+ if count == 0 {
+ None
+ } else {
+ Some(count.to_string())
+ }
+ }
+
+ fn should_change_position_on_event(event: &Self::Event) -> bool {
+ matches!(event, Event::DockPositionChanged)
+ }
+
+ fn should_close_on_event(event: &Self::Event) -> bool {
+ matches!(event, Event::Dismissed)
+ }
+
+ fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
+ self.has_focus
+ }
+
+ fn is_focus_event(event: &Self::Event) -> bool {
+ matches!(event, Event::Focus)
+ }
+}
+
+pub struct NotificationToast {
+ notification_id: u64,
+ actor: Option<Arc<User>>,
+ text: String,
+ workspace: WeakViewHandle<Workspace>,
+}
+
+pub enum ToastEvent {
+ Dismiss,
+}
+
+impl NotificationToast {
+ fn focus_notification_panel(&self, cx: &mut AppContext) {
+ let workspace = self.workspace.clone();
+ let notification_id = self.notification_id;
+ cx.defer(move |cx| {
+ workspace
+ .update(cx, |workspace, cx| {
+ if let Some(panel) = workspace.focus_panel::<NotificationPanel>(cx) {
+ panel.update(cx, |panel, cx| {
+ let store = panel.notification_store.read(cx);
+ if let Some(entry) = store.notification_for_id(notification_id) {
+ panel.did_click_notification(&entry.clone().notification, cx);
+ }
+ });
+ }
+ })
+ .ok();
+ })
+ }
+}
+
+impl Entity for NotificationToast {
+ type Event = ToastEvent;
+}
+
+impl View for NotificationToast {
+ fn ui_name() -> &'static str {
+ "ContactNotification"
+ }
+
+ fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+ let user = self.actor.clone();
+ let theme = theme::current(cx).clone();
+ let theme = &theme.contact_notification;
+
+ MouseEventHandler::new::<Self, _>(0, cx, |_, cx| {
+ Flex::row()
+ .with_children(user.and_then(|user| {
+ Some(
+ Image::from_data(user.avatar.clone()?)
+ .with_style(theme.header_avatar)
+ .aligned()
+ .constrained()
+ .with_height(
+ cx.font_cache()
+ .line_height(theme.header_message.text.font_size),
+ )
+ .aligned()
+ .top(),
+ )
+ }))
+ .with_child(
+ Text::new(self.text.clone(), theme.header_message.text.clone())
+ .contained()
+ .with_style(theme.header_message.container)
+ .aligned()
+ .top()
+ .left()
+ .flex(1., true),
+ )
+ .with_child(
+ MouseEventHandler::new::<ToastEvent, _>(0, cx, |state, _| {
+ let style = theme.dismiss_button.style_for(state);
+ Svg::new("icons/x.svg")
+ .with_color(style.color)
+ .constrained()
+ .with_width(style.icon_width)
+ .aligned()
+ .contained()
+ .with_style(style.container)
+ .constrained()
+ .with_width(style.button_width)
+ .with_height(style.button_width)
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .with_padding(Padding::uniform(5.))
+ .on_click(MouseButton::Left, move |_, _, cx| {
+ cx.emit(ToastEvent::Dismiss)
+ })
+ .aligned()
+ .constrained()
+ .with_height(
+ cx.font_cache()
+ .line_height(theme.header_message.text.font_size),
+ )
+ .aligned()
+ .top()
+ .flex_float(),
+ )
+ .contained()
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ this.focus_notification_panel(cx);
+ cx.emit(ToastEvent::Dismiss);
+ })
+ .into_any()
+ }
+}
+
+impl workspace::notifications::Notification for NotificationToast {
+ fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
+ matches!(event, ToastEvent::Dismiss)
+ }
+}
+
+fn format_timestamp(
+ mut timestamp: OffsetDateTime,
+ mut now: OffsetDateTime,
+ local_timezone: UtcOffset,
+) -> String {
+ timestamp = timestamp.to_offset(local_timezone);
+ now = now.to_offset(local_timezone);
+
+ let today = now.date();
+ let date = timestamp.date();
+ if date == today {
+ let difference = now - timestamp;
+ if difference >= Duration::from_secs(3600) {
+ format!("{}h", difference.whole_seconds() / 3600)
+ } else if difference >= Duration::from_secs(60) {
+ format!("{}m", difference.whole_seconds() / 60)
+ } else {
+ "just now".to_string()
+ }
+ } else if date.next_day() == Some(today) {
+ format!("yesterday")
+ } else {
+ format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
+ }
+}
@@ -1,110 +1,11 @@
-use client::User;
-use gpui::{
- elements::*,
- platform::{CursorStyle, MouseButton},
- AnyElement, Element, ViewContext,
-};
+use gpui::AppContext;
use std::sync::Arc;
+use workspace::AppState;
-enum Dismiss {}
-enum Button {}
+pub mod incoming_call_notification;
+pub mod project_shared_notification;
-pub fn render_user_notification<F, V: 'static>(
- user: Arc<User>,
- title: &'static str,
- body: Option<&'static str>,
- on_dismiss: F,
- buttons: Vec<(&'static str, Box<dyn Fn(&mut V, &mut ViewContext<V>)>)>,
- cx: &mut ViewContext<V>,
-) -> AnyElement<V>
-where
- F: 'static + Fn(&mut V, &mut ViewContext<V>),
-{
- let theme = theme::current(cx).clone();
- let theme = &theme.contact_notification;
-
- Flex::column()
- .with_child(
- Flex::row()
- .with_children(user.avatar.clone().map(|avatar| {
- Image::from_data(avatar)
- .with_style(theme.header_avatar)
- .aligned()
- .constrained()
- .with_height(
- cx.font_cache()
- .line_height(theme.header_message.text.font_size),
- )
- .aligned()
- .top()
- }))
- .with_child(
- Text::new(
- format!("{} {}", user.github_login, title),
- theme.header_message.text.clone(),
- )
- .contained()
- .with_style(theme.header_message.container)
- .aligned()
- .top()
- .left()
- .flex(1., true),
- )
- .with_child(
- MouseEventHandler::new::<Dismiss, _>(user.id as usize, cx, |state, _| {
- let style = theme.dismiss_button.style_for(state);
- Svg::new("icons/x.svg")
- .with_color(style.color)
- .constrained()
- .with_width(style.icon_width)
- .aligned()
- .contained()
- .with_style(style.container)
- .constrained()
- .with_width(style.button_width)
- .with_height(style.button_width)
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .with_padding(Padding::uniform(5.))
- .on_click(MouseButton::Left, move |_, view, cx| on_dismiss(view, cx))
- .aligned()
- .constrained()
- .with_height(
- cx.font_cache()
- .line_height(theme.header_message.text.font_size),
- )
- .aligned()
- .top()
- .flex_float(),
- )
- .into_any_named("contact notification header"),
- )
- .with_children(body.map(|body| {
- Label::new(body, theme.body_message.text.clone())
- .contained()
- .with_style(theme.body_message.container)
- }))
- .with_children(if buttons.is_empty() {
- None
- } else {
- Some(
- Flex::row()
- .with_children(buttons.into_iter().enumerate().map(
- |(ix, (message, handler))| {
- MouseEventHandler::new::<Button, _>(ix, cx, |state, _| {
- let button = theme.button.style_for(state);
- Label::new(message, button.text.clone())
- .contained()
- .with_style(button.container)
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, view, cx| handler(view, cx))
- },
- ))
- .aligned()
- .right(),
- )
- })
- .contained()
- .into_any()
+pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
+ incoming_call_notification::init(app_state, cx);
+ project_shared_notification::init(app_state, cx);
}
@@ -18,6 +18,13 @@ pub struct ChatPanelSettings {
pub default_width: f32,
}
+#[derive(Deserialize, Debug)]
+pub struct NotificationPanelSettings {
+ pub button: bool,
+ pub dock: DockPosition,
+ pub default_width: f32,
+}
+
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
pub struct PanelSettingsContent {
pub button: Option<bool>,
@@ -27,9 +34,7 @@ pub struct PanelSettingsContent {
impl Setting for CollaborationPanelSettings {
const KEY: Option<&'static str> = Some("collaboration_panel");
-
type FileContent = PanelSettingsContent;
-
fn load(
default_value: &Self::FileContent,
user_values: &[&Self::FileContent],
@@ -41,9 +46,19 @@ impl Setting for CollaborationPanelSettings {
impl Setting for ChatPanelSettings {
const KEY: Option<&'static str> = Some("chat_panel");
-
type FileContent = PanelSettingsContent;
+ fn load(
+ default_value: &Self::FileContent,
+ user_values: &[&Self::FileContent],
+ _: &gpui::AppContext,
+ ) -> anyhow::Result<Self> {
+ Self::load_via_json_merge(default_value, user_values)
+ }
+}
+impl Setting for NotificationPanelSettings {
+ const KEY: Option<&'static str> = Some("notification_panel");
+ type FileContent = PanelSettingsContent;
fn load(
default_value: &Self::FileContent,
user_values: &[&Self::FileContent],
@@ -1,62 +0,0 @@
-use crate::toggle_screen_sharing;
-use call::ActiveCall;
-use gpui::{
- color::Color,
- elements::{MouseEventHandler, Svg},
- platform::{Appearance, MouseButton},
- AnyElement, AppContext, Element, Entity, View, ViewContext,
-};
-use workspace::WorkspaceSettings;
-
-pub fn init(cx: &mut AppContext) {
- let active_call = ActiveCall::global(cx);
-
- let mut status_indicator = None;
- cx.observe(&active_call, move |call, cx| {
- if let Some(room) = call.read(cx).room() {
- if room.read(cx).is_screen_sharing() {
- if status_indicator.is_none()
- && settings::get::<WorkspaceSettings>(cx).show_call_status_icon
- {
- status_indicator = Some(cx.add_status_bar_item(|_| SharingStatusIndicator));
- }
- } else if let Some(window) = status_indicator.take() {
- window.update(cx, |cx| cx.remove_window());
- }
- } else if let Some(window) = status_indicator.take() {
- window.update(cx, |cx| cx.remove_window());
- }
- })
- .detach();
-}
-
-pub struct SharingStatusIndicator;
-
-impl Entity for SharingStatusIndicator {
- type Event = ();
-}
-
-impl View for SharingStatusIndicator {
- fn ui_name() -> &'static str {
- "SharingStatusIndicator"
- }
-
- fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
- let color = match cx.window_appearance() {
- Appearance::Light | Appearance::VibrantLight => Color::black(),
- Appearance::Dark | Appearance::VibrantDark => Color::white(),
- };
-
- MouseEventHandler::new::<Self, _>(0, cx, |_, _| {
- Svg::new("icons/desktop.svg")
- .with_color(color)
- .constrained()
- .with_width(18.)
- .aligned()
- })
- .on_click(MouseButton::Left, |_, _, cx| {
- toggle_screen_sharing(&Default::default(), cx)
- })
- .into_any()
- }
-}
@@ -19,6 +19,7 @@ settings = { path = "../settings" }
util = { path = "../util" }
theme = { path = "../theme" }
workspace = { path = "../workspace" }
+zed-actions = { path = "../zed-actions" }
[dev-dependencies]
gpui = { path = "../gpui", features = ["test-support"] }
@@ -6,8 +6,12 @@ use gpui::{
};
use picker::{Picker, PickerDelegate, PickerEvent};
use std::cmp::{self, Reverse};
-use util::ResultExt;
+use util::{
+ channel::{parse_zed_link, ReleaseChannel, RELEASE_CHANNEL},
+ ResultExt,
+};
use workspace::Workspace;
+use zed_actions::OpenZedURL;
pub fn init(cx: &mut AppContext) {
cx.add_action(toggle_command_palette);
@@ -167,13 +171,22 @@ impl PickerDelegate for CommandPaletteDelegate {
)
.await
};
- let intercept_result = cx.read(|cx| {
+ let mut intercept_result = cx.read(|cx| {
if cx.has_global::<CommandPaletteInterceptor>() {
cx.global::<CommandPaletteInterceptor>()(&query, cx)
} else {
None
}
});
+ if *RELEASE_CHANNEL == ReleaseChannel::Dev {
+ if parse_zed_link(&query).is_some() {
+ intercept_result = Some(CommandInterceptResult {
+ action: OpenZedURL { url: query.clone() }.boxed_clone(),
+ string: query.clone(),
+ positions: vec![],
+ })
+ }
+ }
if let Some(CommandInterceptResult {
action,
string,
@@ -36,6 +36,7 @@ serde.workspace = true
serde_derive.workspace = true
smol.workspace = true
futures.workspace = true
+parking_lot.workspace = true
[dev-dependencies]
clock = { path = "../clock" }
@@ -16,6 +16,7 @@ use language::{
};
use lsp::{LanguageServer, LanguageServerBinary, LanguageServerId};
use node_runtime::NodeRuntime;
+use parking_lot::Mutex;
use request::StatusNotification;
use settings::SettingsStore;
use smol::{fs, io::BufReader, stream::StreamExt};
@@ -387,8 +388,15 @@ impl Copilot {
path: node_path,
arguments,
};
- let server =
- LanguageServer::new(new_server_id, binary, Path::new("/"), None, cx.clone())?;
+
+ let server = LanguageServer::new(
+ Arc::new(Mutex::new(None)),
+ new_server_id,
+ binary,
+ Path::new("/"),
+ None,
+ cx.clone(),
+ )?;
server
.on_notification::<StatusNotification, _>(
@@ -20,7 +20,7 @@ use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use util::channel::ReleaseChannel;
-use util::{async_iife, ResultExt};
+use util::{async_maybe, ResultExt};
const CONNECTION_INITIALIZE_QUERY: &'static str = sql!(
PRAGMA foreign_keys=TRUE;
@@ -57,7 +57,7 @@ pub async fn open_db<M: Migrator + 'static>(
let release_channel_name = release_channel.dev_name();
let main_db_dir = db_dir.join(Path::new(&format!("0-{}", release_channel_name)));
- let connection = async_iife!({
+ let connection = async_maybe!({
smol::fs::create_dir_all(&main_db_dir)
.await
.context("Could not create db directory")
@@ -38,6 +38,10 @@ impl DiagnosticIndicator {
this.in_progress_checks.remove(language_server_id);
cx.notify();
}
+ project::Event::DiagnosticsUpdated { .. } => {
+ this.summary = project.read(cx).diagnostic_summary(cx);
+ cx.notify();
+ }
_ => {}
})
.detach();
@@ -14,6 +14,7 @@ test-support = [
"text/test-support",
"language/test-support",
"gpui/test-support",
+ "multi_buffer/test-support",
"project/test-support",
"util/test-support",
"workspace/test-support",
@@ -34,6 +35,7 @@ git = { path = "../git" }
gpui = { path = "../gpui" }
language = { path = "../language" }
lsp = { path = "../lsp" }
+multi_buffer = { path = "../multi_buffer" }
project = { path = "../project" }
rpc = { path = "../rpc" }
rich_text = { path = "../rich_text" }
@@ -57,7 +59,6 @@ log.workspace = true
ordered-float.workspace = true
parking_lot.workspace = true
postage.workspace = true
-pulldown-cmark = { version = "0.9.2", default-features = false }
rand.workspace = true
schemars.workspace = true
serde.workspace = true
@@ -5,22 +5,24 @@ mod tab_map;
mod wrap_map;
use crate::{
- link_go_to_definition::InlayHighlight, Anchor, AnchorRangeExt, InlayId, MultiBuffer,
- MultiBufferSnapshot, ToOffset, ToPoint,
+ link_go_to_definition::InlayHighlight, movement::TextLayoutDetails, Anchor, AnchorRangeExt,
+ EditorStyle, InlayId, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint,
};
pub use block_map::{BlockMap, BlockPoint};
use collections::{BTreeMap, HashMap, HashSet};
use fold_map::FoldMap;
use gpui::{
color::Color,
- fonts::{FontId, HighlightStyle},
+ fonts::{FontId, HighlightStyle, Underline},
+ text_layout::{Line, RunStyle},
Entity, ModelContext, ModelHandle,
};
use inlay_map::InlayMap;
use language::{
language_settings::language_settings, OffsetUtf16, Point, Subscription as BufferSubscription,
};
-use std::{any::TypeId, fmt::Debug, num::NonZeroU32, ops::Range, sync::Arc};
+use lsp::DiagnosticSeverity;
+use std::{any::TypeId, borrow::Cow, fmt::Debug, num::NonZeroU32, ops::Range, sync::Arc};
use sum_tree::{Bias, TreeMap};
use tab_map::TabMap;
use wrap_map::WrapMap;
@@ -316,6 +318,12 @@ pub struct Highlights<'a> {
pub suggestion_highlight_style: Option<HighlightStyle>,
}
+pub struct HighlightedChunk<'a> {
+ pub chunk: &'a str,
+ pub style: Option<HighlightStyle>,
+ pub is_tab: bool,
+}
+
pub struct DisplaySnapshot {
pub buffer_snapshot: MultiBufferSnapshot,
pub fold_snapshot: fold_map::FoldSnapshot,
@@ -485,7 +493,7 @@ impl DisplaySnapshot {
language_aware: bool,
inlay_highlight_style: Option<HighlightStyle>,
suggestion_highlight_style: Option<HighlightStyle>,
- ) -> DisplayChunks<'_> {
+ ) -> DisplayChunks<'a> {
self.block_snapshot.chunks(
display_rows,
language_aware,
@@ -498,6 +506,140 @@ impl DisplaySnapshot {
)
}
+ pub fn highlighted_chunks<'a>(
+ &'a self,
+ display_rows: Range<u32>,
+ language_aware: bool,
+ style: &'a EditorStyle,
+ ) -> impl Iterator<Item = HighlightedChunk<'a>> {
+ self.chunks(
+ display_rows,
+ language_aware,
+ Some(style.theme.hint),
+ Some(style.theme.suggestion),
+ )
+ .map(|chunk| {
+ let mut highlight_style = chunk
+ .syntax_highlight_id
+ .and_then(|id| id.style(&style.syntax));
+
+ if let Some(chunk_highlight) = chunk.highlight_style {
+ if let Some(highlight_style) = highlight_style.as_mut() {
+ highlight_style.highlight(chunk_highlight);
+ } else {
+ highlight_style = Some(chunk_highlight);
+ }
+ }
+
+ let mut diagnostic_highlight = HighlightStyle::default();
+
+ if chunk.is_unnecessary {
+ diagnostic_highlight.fade_out = Some(style.unnecessary_code_fade);
+ }
+
+ if let Some(severity) = chunk.diagnostic_severity {
+ // Omit underlines for HINT/INFO diagnostics on 'unnecessary' code.
+ if severity <= DiagnosticSeverity::WARNING || !chunk.is_unnecessary {
+ let diagnostic_style = super::diagnostic_style(severity, true, style);
+ diagnostic_highlight.underline = Some(Underline {
+ color: Some(diagnostic_style.message.text.color),
+ thickness: 1.0.into(),
+ squiggly: true,
+ });
+ }
+ }
+
+ if let Some(highlight_style) = highlight_style.as_mut() {
+ highlight_style.highlight(diagnostic_highlight);
+ } else {
+ highlight_style = Some(diagnostic_highlight);
+ }
+
+ HighlightedChunk {
+ chunk: chunk.text,
+ style: highlight_style,
+ is_tab: chunk.is_tab,
+ }
+ })
+ }
+
+ pub fn lay_out_line_for_row(
+ &self,
+ display_row: u32,
+ TextLayoutDetails {
+ font_cache,
+ text_layout_cache,
+ editor_style,
+ }: &TextLayoutDetails,
+ ) -> Line {
+ let mut styles = Vec::new();
+ let mut line = String::new();
+ let mut ended_in_newline = false;
+
+ let range = display_row..display_row + 1;
+ for chunk in self.highlighted_chunks(range, false, editor_style) {
+ line.push_str(chunk.chunk);
+
+ let text_style = if let Some(style) = chunk.style {
+ editor_style
+ .text
+ .clone()
+ .highlight(style, font_cache)
+ .map(Cow::Owned)
+ .unwrap_or_else(|_| Cow::Borrowed(&editor_style.text))
+ } else {
+ Cow::Borrowed(&editor_style.text)
+ };
+ ended_in_newline = chunk.chunk.ends_with("\n");
+
+ styles.push((
+ chunk.chunk.len(),
+ RunStyle {
+ font_id: text_style.font_id,
+ color: text_style.color,
+ underline: text_style.underline,
+ },
+ ));
+ }
+
+ // our pixel positioning logic assumes each line ends in \n,
+ // this is almost always true except for the last line which
+ // may have no trailing newline.
+ if !ended_in_newline && display_row == self.max_point().row() {
+ line.push_str("\n");
+
+ styles.push((
+ "\n".len(),
+ RunStyle {
+ font_id: editor_style.text.font_id,
+ color: editor_style.text_color,
+ underline: editor_style.text.underline,
+ },
+ ));
+ }
+
+ text_layout_cache.layout_str(&line, editor_style.text.font_size, &styles)
+ }
+
+ pub fn x_for_point(
+ &self,
+ display_point: DisplayPoint,
+ text_layout_details: &TextLayoutDetails,
+ ) -> f32 {
+ let layout_line = self.lay_out_line_for_row(display_point.row(), text_layout_details);
+ layout_line.x_for_index(display_point.column() as usize)
+ }
+
+ pub fn column_for_x(
+ &self,
+ display_row: u32,
+ x_coordinate: f32,
+ text_layout_details: &TextLayoutDetails,
+ ) -> u32 {
+ let layout_line = self.lay_out_line_for_row(display_row, text_layout_details);
+ layout_line.closest_index_for_x(x_coordinate) as u32
+ }
+
pub fn chars_at(
&self,
mut point: DisplayPoint,
@@ -869,12 +1011,16 @@ pub fn next_rows(display_row: u32, display_map: &DisplaySnapshot) -> impl Iterat
#[cfg(test)]
pub mod tests {
use super::*;
- use crate::{movement, test::marked_display_snapshot};
+ use crate::{
+ movement,
+ test::{editor_test_context::EditorTestContext, marked_display_snapshot},
+ };
use gpui::{color::Color, elements::*, test::observe, AppContext};
use language::{
language_settings::{AllLanguageSettings, AllLanguageSettingsContent},
Buffer, Language, LanguageConfig, SelectionGoal,
};
+ use project::Project;
use rand::{prelude::*, Rng};
use settings::SettingsStore;
use smol::stream::StreamExt;
@@ -1148,95 +1294,120 @@ pub mod tests {
}
#[gpui::test(retries = 5)]
- fn test_soft_wraps(cx: &mut AppContext) {
+ async fn test_soft_wraps(cx: &mut gpui::TestAppContext) {
cx.foreground().set_block_on_ticks(usize::MAX..=usize::MAX);
- init_test(cx, |_| {});
+ cx.update(|cx| {
+ init_test(cx, |_| {});
+ });
- let font_cache = cx.font_cache();
+ let mut cx = EditorTestContext::new(cx).await;
+ let editor = cx.editor.clone();
+ let window = cx.window.clone();
- let family_id = font_cache
- .load_family(&["Helvetica"], &Default::default())
- .unwrap();
- let font_id = font_cache
- .select_font(family_id, &Default::default())
- .unwrap();
- let font_size = 12.0;
- let wrap_width = Some(64.);
+ cx.update_window(window, |cx| {
+ let text_layout_details =
+ editor.read_with(cx, |editor, cx| editor.text_layout_details(cx));
- let text = "one two three four five\nsix seven eight";
- let buffer = MultiBuffer::build_simple(text, cx);
- let map = cx.add_model(|cx| {
- DisplayMap::new(buffer.clone(), font_id, font_size, wrap_width, 1, 1, cx)
- });
+ let font_cache = cx.font_cache().clone();
- let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
- assert_eq!(
- snapshot.text_chunks(0).collect::<String>(),
- "one two \nthree four \nfive\nsix seven \neight"
- );
- assert_eq!(
- snapshot.clip_point(DisplayPoint::new(0, 8), Bias::Left),
- DisplayPoint::new(0, 7)
- );
- assert_eq!(
- snapshot.clip_point(DisplayPoint::new(0, 8), Bias::Right),
- DisplayPoint::new(1, 0)
- );
- assert_eq!(
- movement::right(&snapshot, DisplayPoint::new(0, 7)),
- DisplayPoint::new(1, 0)
- );
- assert_eq!(
- movement::left(&snapshot, DisplayPoint::new(1, 0)),
- DisplayPoint::new(0, 7)
- );
- assert_eq!(
- movement::up(
- &snapshot,
- DisplayPoint::new(1, 10),
- SelectionGoal::None,
- false
- ),
- (DisplayPoint::new(0, 7), SelectionGoal::Column(10))
- );
- assert_eq!(
- movement::down(
- &snapshot,
- DisplayPoint::new(0, 7),
- SelectionGoal::Column(10),
- false
- ),
- (DisplayPoint::new(1, 10), SelectionGoal::Column(10))
- );
- assert_eq!(
- movement::down(
- &snapshot,
- DisplayPoint::new(1, 10),
- SelectionGoal::Column(10),
- false
- ),
- (DisplayPoint::new(2, 4), SelectionGoal::Column(10))
- );
+ let family_id = font_cache
+ .load_family(&["Helvetica"], &Default::default())
+ .unwrap();
+ let font_id = font_cache
+ .select_font(family_id, &Default::default())
+ .unwrap();
+ let font_size = 12.0;
+ let wrap_width = Some(64.);
- let ix = snapshot.buffer_snapshot.text().find("seven").unwrap();
- buffer.update(cx, |buffer, cx| {
- buffer.edit([(ix..ix, "and ")], None, cx);
- });
+ let text = "one two three four five\nsix seven eight";
+ let buffer = MultiBuffer::build_simple(text, cx);
+ let map = cx.add_model(|cx| {
+ DisplayMap::new(buffer.clone(), font_id, font_size, wrap_width, 1, 1, cx)
+ });
- let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
- assert_eq!(
- snapshot.text_chunks(1).collect::<String>(),
- "three four \nfive\nsix and \nseven eight"
- );
+ let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
+ assert_eq!(
+ snapshot.text_chunks(0).collect::<String>(),
+ "one two \nthree four \nfive\nsix seven \neight"
+ );
+ assert_eq!(
+ snapshot.clip_point(DisplayPoint::new(0, 8), Bias::Left),
+ DisplayPoint::new(0, 7)
+ );
+ assert_eq!(
+ snapshot.clip_point(DisplayPoint::new(0, 8), Bias::Right),
+ DisplayPoint::new(1, 0)
+ );
+ assert_eq!(
+ movement::right(&snapshot, DisplayPoint::new(0, 7)),
+ DisplayPoint::new(1, 0)
+ );
+ assert_eq!(
+ movement::left(&snapshot, DisplayPoint::new(1, 0)),
+ DisplayPoint::new(0, 7)
+ );
- // Re-wrap on font size changes
- map.update(cx, |map, cx| map.set_font(font_id, font_size + 3., cx));
+ let x = snapshot.x_for_point(DisplayPoint::new(1, 10), &text_layout_details);
+ assert_eq!(
+ movement::up(
+ &snapshot,
+ DisplayPoint::new(1, 10),
+ SelectionGoal::None,
+ false,
+ &text_layout_details,
+ ),
+ (
+ DisplayPoint::new(0, 7),
+ SelectionGoal::HorizontalPosition(x)
+ )
+ );
+ assert_eq!(
+ movement::down(
+ &snapshot,
+ DisplayPoint::new(0, 7),
+ SelectionGoal::HorizontalPosition(x),
+ false,
+ &text_layout_details
+ ),
+ (
+ DisplayPoint::new(1, 10),
+ SelectionGoal::HorizontalPosition(x)
+ )
+ );
+ assert_eq!(
+ movement::down(
+ &snapshot,
+ DisplayPoint::new(1, 10),
+ SelectionGoal::HorizontalPosition(x),
+ false,
+ &text_layout_details
+ ),
+ (
+ DisplayPoint::new(2, 4),
+ SelectionGoal::HorizontalPosition(x)
+ )
+ );
- let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
- assert_eq!(
- snapshot.text_chunks(1).collect::<String>(),
- "three \nfour five\nsix and \nseven \neight"
- )
+ let ix = snapshot.buffer_snapshot.text().find("seven").unwrap();
+ buffer.update(cx, |buffer, cx| {
+ buffer.edit([(ix..ix, "and ")], None, cx);
+ });
+
+ let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
+ assert_eq!(
+ snapshot.text_chunks(1).collect::<String>(),
+ "three four \nfive\nsix and \nseven eight"
+ );
+
+ // Re-wrap on font size changes
+ map.update(cx, |map, cx| map.set_font(font_id, font_size + 3., cx));
+
+ let snapshot = map.update(cx, |map, cx| map.snapshot(cx));
+ assert_eq!(
+ snapshot.text_chunks(1).collect::<String>(),
+ "three \nfour five\nsix and \nseven \neight"
+ )
+ });
}
#[gpui::test]
@@ -1731,6 +1902,9 @@ pub mod tests {
cx.foreground().forbid_parking();
cx.set_global(SettingsStore::test(cx));
language::init(cx);
+ crate::init(cx);
+ Project::init_settings(cx);
+ theme::init((), cx);
cx.update_global::<SettingsStore, _, _>(|store, cx| {
store.update_user_settings::<AllLanguageSettings>(cx, f);
});
@@ -993,8 +993,8 @@ mod tests {
use super::*;
use crate::display_map::inlay_map::InlayMap;
use crate::display_map::{fold_map::FoldMap, tab_map::TabMap, wrap_map::WrapMap};
- use crate::multi_buffer::MultiBuffer;
use gpui::{elements::Empty, Element};
+ use multi_buffer::MultiBuffer;
use rand::prelude::*;
use settings::SettingsStore;
use std::env;
@@ -91,7 +91,7 @@ impl<'a> FoldMapWriter<'a> {
// For now, ignore any ranges that span an excerpt boundary.
let fold = Fold(buffer.anchor_after(range.start)..buffer.anchor_before(range.end));
- if fold.0.start.excerpt_id() != fold.0.end.excerpt_id() {
+ if fold.0.start.excerpt_id != fold.0.end.excerpt_id {
continue;
}
@@ -1,10 +1,8 @@
-use crate::{
- multi_buffer::{MultiBufferChunks, MultiBufferRows},
- Anchor, InlayId, MultiBufferSnapshot, ToOffset,
-};
+use crate::{Anchor, InlayId, MultiBufferSnapshot, ToOffset};
use collections::{BTreeMap, BTreeSet};
use gpui::fonts::HighlightStyle;
use language::{Chunk, Edit, Point, TextSummary};
+use multi_buffer::{MultiBufferChunks, MultiBufferRows};
use std::{
any::TypeId,
cmp,
@@ -11,7 +11,6 @@ pub mod items;
mod link_go_to_definition;
mod mouse_context_menu;
pub mod movement;
-pub mod multi_buffer;
mod persistence;
pub mod scroll;
pub mod selections_collection;
@@ -25,7 +24,7 @@ use ::git::diff::DiffHunk;
use aho_corasick::AhoCorasick;
use anyhow::{anyhow, Context, Result};
use blink_manager::BlinkManager;
-use client::{ClickhouseEvent, Collaborator, ParticipantIndex, TelemetrySettings};
+use client::{ClickhouseEvent, Client, Collaborator, ParticipantIndex, TelemetrySettings};
use clock::{Global, ReplicaId};
use collections::{BTreeMap, Bound, HashMap, HashSet, VecDeque};
use convert_case::{Case, Casing};
@@ -48,9 +47,9 @@ use gpui::{
impl_actions,
keymap_matcher::KeymapContext,
platform::{CursorStyle, MouseButton},
- serde_json, AnyElement, AnyViewHandle, AppContext, AsyncAppContext, ClipboardItem, Element,
- Entity, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
- WindowContext,
+ serde_json, AnyElement, AnyViewHandle, AppContext, AsyncAppContext, ClipboardItem,
+ CursorRegion, Element, Entity, ModelHandle, MouseRegion, Subscription, Task, View, ViewContext,
+ ViewHandle, WeakViewHandle, WindowContext,
};
use highlight_matching_bracket::refresh_matching_bracket_highlights;
use hover_popover::{hide_hover, HoverState};
@@ -60,10 +59,10 @@ use itertools::Itertools;
pub use language::{char_kind, CharKind};
use language::{
language_settings::{self, all_language_settings, InlayHintSettings},
- point_from_lsp, AutoindentMode, BracketPair, Buffer, CodeAction, CodeLabel, Completion,
- CursorShape, Diagnostic, DiagnosticSeverity, File, IndentKind, IndentSize, Language,
- LanguageServerName, OffsetRangeExt, OffsetUtf16, Point, Selection, SelectionGoal,
- TransactionId,
+ markdown, point_from_lsp, AutoindentMode, BracketPair, Buffer, CodeAction, CodeLabel,
+ Completion, CursorShape, Diagnostic, DiagnosticSeverity, Documentation, File, IndentKind,
+ IndentSize, Language, LanguageRegistry, LanguageServerName, OffsetRangeExt, OffsetUtf16, Point,
+ Selection, SelectionGoal, TransactionId,
};
use link_go_to_definition::{
hide_link_definition, show_link_definition, GoToDefinitionLink, InlayHighlight,
@@ -71,15 +70,17 @@ use link_go_to_definition::{
};
use log::error;
use lsp::LanguageServerId;
+use movement::TextLayoutDetails;
use multi_buffer::ToOffsetUtf16;
pub use multi_buffer::{
Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, ToOffset,
ToPoint,
};
use ordered_float::OrderedFloat;
+use parking_lot::RwLock;
use project::{FormatTrigger, Location, Project, ProjectPath, ProjectTransaction};
use rand::{seq::SliceRandom, thread_rng};
-use rpc::proto::PeerId;
+use rpc::proto::{self, PeerId};
use scroll::{
autoscroll::Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager, ScrollbarAutoHide,
};
@@ -118,6 +119,67 @@ pub const DOCUMENT_HIGHLIGHTS_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis
pub const FORMAT_TIMEOUT: Duration = Duration::from_secs(2);
+pub fn render_parsed_markdown<Tag: 'static>(
+ parsed: &language::ParsedMarkdown,
+ editor_style: &EditorStyle,
+ workspace: Option<WeakViewHandle<Workspace>>,
+ cx: &mut ViewContext<Editor>,
+) -> Text {
+ enum RenderedMarkdown {}
+
+ let parsed = parsed.clone();
+ let view_id = cx.view_id();
+ let code_span_background_color = editor_style.document_highlight_read_background;
+
+ let mut region_id = 0;
+
+ Text::new(parsed.text, editor_style.text.clone())
+ .with_highlights(
+ parsed
+ .highlights
+ .iter()
+ .filter_map(|(range, highlight)| {
+ let highlight = highlight.to_highlight_style(&editor_style.syntax)?;
+ Some((range.clone(), highlight))
+ })
+ .collect::<Vec<_>>(),
+ )
+ .with_custom_runs(parsed.region_ranges, move |ix, bounds, cx| {
+ region_id += 1;
+ let region = parsed.regions[ix].clone();
+
+ if let Some(link) = region.link {
+ cx.scene().push_cursor_region(CursorRegion {
+ bounds,
+ style: CursorStyle::PointingHand,
+ });
+ cx.scene().push_mouse_region(
+ MouseRegion::new::<(RenderedMarkdown, Tag)>(view_id, region_id, bounds)
+ .on_down::<Editor, _>(MouseButton::Left, move |_, _, cx| match &link {
+ markdown::Link::Web { url } => cx.platform().open_url(url),
+ markdown::Link::Path { path } => {
+ if let Some(workspace) = &workspace {
+ _ = workspace.update(cx, |workspace, cx| {
+ workspace.open_abs_path(path.clone(), false, cx).detach();
+ });
+ }
+ }
+ }),
+ );
+ }
+
+ if region.code {
+ cx.scene().push_quad(gpui::Quad {
+ bounds,
+ background: Some(code_span_background_color),
+ border: Default::default(),
+ corner_radii: (2.0).into(),
+ });
+ }
+ })
+ .with_soft_wrap(true)
+}
+
#[derive(Clone, Deserialize, PartialEq, Default)]
pub struct SelectNext {
#[serde(default)]
@@ -594,7 +656,7 @@ pub struct Editor {
background_highlights: BTreeMap<TypeId, BackgroundHighlight>,
inlay_background_highlights: TreeMap<Option<TypeId>, InlayBackgroundHighlight>,
nav_history: Option<ItemNavHistory>,
- context_menu: Option<ContextMenu>,
+ context_menu: RwLock<Option<ContextMenu>>,
mouse_context_menu: ViewHandle<context_menu::ContextMenu>,
completion_tasks: Vec<(CompletionId, Task<Option<()>>)>,
next_completion_id: CompletionId,
@@ -787,10 +849,14 @@ enum ContextMenu {
}
impl ContextMenu {
- fn select_first(&mut self, cx: &mut ViewContext<Editor>) -> bool {
+ fn select_first(
+ &mut self,
+ project: Option<&ModelHandle<Project>>,
+ cx: &mut ViewContext<Editor>,
+ ) -> bool {
if self.visible() {
match self {
- ContextMenu::Completions(menu) => menu.select_first(cx),
+ ContextMenu::Completions(menu) => menu.select_first(project, cx),
ContextMenu::CodeActions(menu) => menu.select_first(cx),
}
true
@@ -799,10 +865,14 @@ impl ContextMenu {
}
}
- fn select_prev(&mut self, cx: &mut ViewContext<Editor>) -> bool {
+ fn select_prev(
+ &mut self,
+ project: Option<&ModelHandle<Project>>,
+ cx: &mut ViewContext<Editor>,
+ ) -> bool {
if self.visible() {
match self {
- ContextMenu::Completions(menu) => menu.select_prev(cx),
+ ContextMenu::Completions(menu) => menu.select_prev(project, cx),
ContextMenu::CodeActions(menu) => menu.select_prev(cx),
}
true
@@ -811,10 +881,14 @@ impl ContextMenu {
}
}
- fn select_next(&mut self, cx: &mut ViewContext<Editor>) -> bool {
+ fn select_next(
+ &mut self,
+ project: Option<&ModelHandle<Project>>,
+ cx: &mut ViewContext<Editor>,
+ ) -> bool {
if self.visible() {
match self {
- ContextMenu::Completions(menu) => menu.select_next(cx),
+ ContextMenu::Completions(menu) => menu.select_next(project, cx),
ContextMenu::CodeActions(menu) => menu.select_next(cx),
}
true
@@ -823,10 +897,14 @@ impl ContextMenu {
}
}
- fn select_last(&mut self, cx: &mut ViewContext<Editor>) -> bool {
+ fn select_last(
+ &mut self,
+ project: Option<&ModelHandle<Project>>,
+ cx: &mut ViewContext<Editor>,
+ ) -> bool {
if self.visible() {
match self {
- ContextMenu::Completions(menu) => menu.select_last(cx),
+ ContextMenu::Completions(menu) => menu.select_last(project, cx),
ContextMenu::CodeActions(menu) => menu.select_last(cx),
}
true
@@ -846,99 +924,355 @@ impl ContextMenu {
&self,
cursor_position: DisplayPoint,
style: EditorStyle,
+ workspace: Option<WeakViewHandle<Workspace>>,
cx: &mut ViewContext<Editor>,
) -> (DisplayPoint, AnyElement<Editor>) {
match self {
- ContextMenu::Completions(menu) => (cursor_position, menu.render(style, cx)),
+ ContextMenu::Completions(menu) => (cursor_position, menu.render(style, workspace, cx)),
ContextMenu::CodeActions(menu) => menu.render(cursor_position, style, cx),
}
}
}
+#[derive(Clone)]
struct CompletionsMenu {
id: CompletionId,
initial_position: Anchor,
buffer: ModelHandle<Buffer>,
- project: Option<ModelHandle<Project>>,
- completions: Arc<[Completion]>,
- match_candidates: Vec<StringMatchCandidate>,
+ completions: Arc<RwLock<Box<[Completion]>>>,
+ match_candidates: Arc<[StringMatchCandidate]>,
matches: Arc<[StringMatch]>,
selected_item: usize,
list: UniformListState,
}
impl CompletionsMenu {
- fn select_first(&mut self, cx: &mut ViewContext<Editor>) {
+ fn select_first(
+ &mut self,
+ project: Option<&ModelHandle<Project>>,
+ cx: &mut ViewContext<Editor>,
+ ) {
self.selected_item = 0;
self.list.scroll_to(ScrollTarget::Show(self.selected_item));
+ self.attempt_resolve_selected_completion_documentation(project, cx);
cx.notify();
}
- fn select_prev(&mut self, cx: &mut ViewContext<Editor>) {
+ fn select_prev(
+ &mut self,
+ project: Option<&ModelHandle<Project>>,
+ cx: &mut ViewContext<Editor>,
+ ) {
if self.selected_item > 0 {
self.selected_item -= 1;
+ } else {
+ self.selected_item = self.matches.len() - 1;
self.list.scroll_to(ScrollTarget::Show(self.selected_item));
}
+ self.list.scroll_to(ScrollTarget::Show(self.selected_item));
+ self.attempt_resolve_selected_completion_documentation(project, cx);
cx.notify();
}
- fn select_next(&mut self, cx: &mut ViewContext<Editor>) {
+ fn select_next(
+ &mut self,
+ project: Option<&ModelHandle<Project>>,
+ cx: &mut ViewContext<Editor>,
+ ) {
if self.selected_item + 1 < self.matches.len() {
self.selected_item += 1;
- self.list.scroll_to(ScrollTarget::Show(self.selected_item));
+ } else {
+ self.selected_item = 0;
}
+ self.list.scroll_to(ScrollTarget::Show(self.selected_item));
+ self.attempt_resolve_selected_completion_documentation(project, cx);
cx.notify();
}
- fn select_last(&mut self, cx: &mut ViewContext<Editor>) {
+ fn select_last(
+ &mut self,
+ project: Option<&ModelHandle<Project>>,
+ cx: &mut ViewContext<Editor>,
+ ) {
self.selected_item = self.matches.len() - 1;
self.list.scroll_to(ScrollTarget::Show(self.selected_item));
+ self.attempt_resolve_selected_completion_documentation(project, cx);
cx.notify();
}
+ fn pre_resolve_completion_documentation(
+ &self,
+ project: Option<ModelHandle<Project>>,
+ cx: &mut ViewContext<Editor>,
+ ) {
+ let settings = settings::get::<EditorSettings>(cx);
+ if !settings.show_completion_documentation {
+ return;
+ }
+
+ let Some(project) = project else {
+ return;
+ };
+ let client = project.read(cx).client();
+ let language_registry = project.read(cx).languages().clone();
+
+ let is_remote = project.read(cx).is_remote();
+ let project_id = project.read(cx).remote_id();
+
+ let completions = self.completions.clone();
+ let completion_indices: Vec<_> = self.matches.iter().map(|m| m.candidate_id).collect();
+
+ cx.spawn(move |this, mut cx| async move {
+ if is_remote {
+ let Some(project_id) = project_id else {
+ log::error!("Remote project without remote_id");
+ return;
+ };
+
+ for completion_index in completion_indices {
+ let completions_guard = completions.read();
+ let completion = &completions_guard[completion_index];
+ if completion.documentation.is_some() {
+ continue;
+ }
+
+ let server_id = completion.server_id;
+ let completion = completion.lsp_completion.clone();
+ drop(completions_guard);
+
+ Self::resolve_completion_documentation_remote(
+ project_id,
+ server_id,
+ completions.clone(),
+ completion_index,
+ completion,
+ client.clone(),
+ language_registry.clone(),
+ )
+ .await;
+
+ _ = this.update(&mut cx, |_, cx| cx.notify());
+ }
+ } else {
+ for completion_index in completion_indices {
+ let completions_guard = completions.read();
+ let completion = &completions_guard[completion_index];
+ if completion.documentation.is_some() {
+ continue;
+ }
+
+ let server_id = completion.server_id;
+ let completion = completion.lsp_completion.clone();
+ drop(completions_guard);
+
+ let server = project.read_with(&mut cx, |project, _| {
+ project.language_server_for_id(server_id)
+ });
+ let Some(server) = server else {
+ return;
+ };
+
+ Self::resolve_completion_documentation_local(
+ server,
+ completions.clone(),
+ completion_index,
+ completion,
+ language_registry.clone(),
+ )
+ .await;
+
+ _ = this.update(&mut cx, |_, cx| cx.notify());
+ }
+ }
+ })
+ .detach();
+ }
+
+ fn attempt_resolve_selected_completion_documentation(
+ &mut self,
+ project: Option<&ModelHandle<Project>>,
+ cx: &mut ViewContext<Editor>,
+ ) {
+ let settings = settings::get::<EditorSettings>(cx);
+ if !settings.show_completion_documentation {
+ return;
+ }
+
+ let completion_index = self.matches[self.selected_item].candidate_id;
+ let Some(project) = project else {
+ return;
+ };
+ let language_registry = project.read(cx).languages().clone();
+
+ let completions = self.completions.clone();
+ let completions_guard = completions.read();
+ let completion = &completions_guard[completion_index];
+ if completion.documentation.is_some() {
+ return;
+ }
+
+ let server_id = completion.server_id;
+ let completion = completion.lsp_completion.clone();
+ drop(completions_guard);
+
+ if project.read(cx).is_remote() {
+ let Some(project_id) = project.read(cx).remote_id() else {
+ log::error!("Remote project without remote_id");
+ return;
+ };
+
+ let client = project.read(cx).client();
+
+ cx.spawn(move |this, mut cx| async move {
+ Self::resolve_completion_documentation_remote(
+ project_id,
+ server_id,
+ completions.clone(),
+ completion_index,
+ completion,
+ client,
+ language_registry.clone(),
+ )
+ .await;
+
+ _ = this.update(&mut cx, |_, cx| cx.notify());
+ })
+ .detach();
+ } else {
+ let Some(server) = project.read(cx).language_server_for_id(server_id) else {
+ return;
+ };
+
+ cx.spawn(move |this, mut cx| async move {
+ Self::resolve_completion_documentation_local(
+ server,
+ completions,
+ completion_index,
+ completion,
+ language_registry,
+ )
+ .await;
+
+ _ = this.update(&mut cx, |_, cx| cx.notify());
+ })
+ .detach();
+ }
+ }
+
+ async fn resolve_completion_documentation_remote(
+ project_id: u64,
+ server_id: LanguageServerId,
+ completions: Arc<RwLock<Box<[Completion]>>>,
+ completion_index: usize,
+ completion: lsp::CompletionItem,
+ client: Arc<Client>,
+ language_registry: Arc<LanguageRegistry>,
+ ) {
+ let request = proto::ResolveCompletionDocumentation {
+ project_id,
+ language_server_id: server_id.0 as u64,
+ lsp_completion: serde_json::to_string(&completion).unwrap().into_bytes(),
+ };
+
+ let Some(response) = client
+ .request(request)
+ .await
+ .context("completion documentation resolve proto request")
+ .log_err()
+ else {
+ return;
+ };
+
+ if response.text.is_empty() {
+ let mut completions = completions.write();
+ let completion = &mut completions[completion_index];
+ completion.documentation = Some(Documentation::Undocumented);
+ }
+
+ let documentation = if response.is_markdown {
+ Documentation::MultiLineMarkdown(
+ markdown::parse_markdown(&response.text, &language_registry, None).await,
+ )
+ } else if response.text.lines().count() <= 1 {
+ Documentation::SingleLine(response.text)
+ } else {
+ Documentation::MultiLinePlainText(response.text)
+ };
+
+ let mut completions = completions.write();
+ let completion = &mut completions[completion_index];
+ completion.documentation = Some(documentation);
+ }
+
+ async fn resolve_completion_documentation_local(
+ server: Arc<lsp::LanguageServer>,
+ completions: Arc<RwLock<Box<[Completion]>>>,
+ completion_index: usize,
+ completion: lsp::CompletionItem,
+ language_registry: Arc<LanguageRegistry>,
+ ) {
+ let can_resolve = server
+ .capabilities()
+ .completion_provider
+ .as_ref()
+ .and_then(|options| options.resolve_provider)
+ .unwrap_or(false);
+ if !can_resolve {
+ return;
+ }
+
+ let request = server.request::<lsp::request::ResolveCompletionItem>(completion);
+ let Some(completion_item) = request.await.log_err() else {
+ return;
+ };
+
+ if let Some(lsp_documentation) = completion_item.documentation {
+ let documentation = language::prepare_completion_documentation(
+ &lsp_documentation,
+ &language_registry,
+ None, // TODO: Try to reasonably work out which language the completion is for
+ )
+ .await;
+
+ let mut completions = completions.write();
+ let completion = &mut completions[completion_index];
+ completion.documentation = Some(documentation);
+ } else {
+ let mut completions = completions.write();
+ let completion = &mut completions[completion_index];
+ completion.documentation = Some(Documentation::Undocumented);
+ }
+ }
+
fn visible(&self) -> bool {
!self.matches.is_empty()
}
- fn render(&self, style: EditorStyle, cx: &mut ViewContext<Editor>) -> AnyElement<Editor> {
+ fn render(
+ &self,
+ style: EditorStyle,
+ workspace: Option<WeakViewHandle<Workspace>>,
+ cx: &mut ViewContext<Editor>,
+ ) -> AnyElement<Editor> {
enum CompletionTag {}
- let language_servers = self.project.as_ref().map(|project| {
- project
- .read(cx)
- .language_servers_for_buffer(self.buffer.read(cx), cx)
- .filter(|(_, server)| server.capabilities().completion_provider.is_some())
- .map(|(adapter, server)| (server.server_id(), adapter.short_name))
- .collect::<Vec<_>>()
- });
- let needs_server_name = language_servers
- .as_ref()
- .map_or(false, |servers| servers.len() > 1);
-
- let get_server_name =
- move |lookup_server_id: lsp::LanguageServerId| -> Option<&'static str> {
- language_servers
- .iter()
- .flatten()
- .find_map(|(server_id, server_name)| {
- if *server_id == lookup_server_id {
- Some(*server_name)
- } else {
- None
- }
- })
- };
+ let settings = settings::get::<EditorSettings>(cx);
+ let show_completion_documentation = settings.show_completion_documentation;
let widest_completion_ix = self
.matches
.iter()
.enumerate()
.max_by_key(|(_, mat)| {
- let completion = &self.completions[mat.candidate_id];
- let mut len = completion.label.text.chars().count();
+ let completions = self.completions.read();
+ let completion = &completions[mat.candidate_id];
+ let documentation = &completion.documentation;
- if let Some(server_name) = get_server_name(completion.server_id) {
- len += server_name.chars().count();
+ let mut len = completion.label.text.chars().count();
+ if let Some(Documentation::SingleLine(text)) = documentation {
+ if show_completion_documentation {
+ len += text.chars().count();
+ }
}
len
@@ -948,16 +1282,24 @@ impl CompletionsMenu {
let completions = self.completions.clone();
let matches = self.matches.clone();
let selected_item = self.selected_item;
- let container_style = style.autocomplete.container;
- UniformList::new(
- self.list.clone(),
- matches.len(),
- cx,
+
+ let list = UniformList::new(self.list.clone(), matches.len(), cx, {
+ let style = style.clone();
move |_, range, items, cx| {
let start_ix = range.start;
+ let completions_guard = completions.read();
+
for (ix, mat) in matches[range].iter().enumerate() {
- let completion = &completions[mat.candidate_id];
let item_ix = start_ix + ix;
+ let candidate_id = mat.candidate_id;
+ let completion = &completions_guard[candidate_id];
+
+ let documentation = if show_completion_documentation {
+ &completion.documentation
+ } else {
+ &None
+ };
+
items.push(
MouseEventHandler::new::<CompletionTag, _>(
mat.candidate_id,
@@ -986,22 +1328,18 @@ impl CompletionsMenu {
),
);
- if let Some(server_name) = get_server_name(completion.server_id) {
+ if let Some(Documentation::SingleLine(text)) = documentation {
Flex::row()
.with_child(completion_label)
.with_children((|| {
- if !needs_server_name {
- return None;
- }
-
let text_style = TextStyle {
- color: style.autocomplete.server_name_color,
+ color: style.autocomplete.inline_docs_color,
font_size: style.text.font_size
- * style.autocomplete.server_name_size_percent,
+ * style.autocomplete.inline_docs_size_percent,
..style.text.clone()
};
- let label = Text::new(server_name, text_style)
+ let label = Text::new(text.clone(), text_style)
.aligned()
.constrained()
.dynamically(move |constraint, _, _| {
@@ -1021,7 +1359,7 @@ impl CompletionsMenu {
.with_style(
style
.autocomplete
- .server_name_container,
+ .inline_docs_container,
)
.into_any(),
)
@@ -1060,15 +1398,59 @@ impl CompletionsMenu {
)
.map(|task| task.detach());
})
+ .constrained()
+ .with_min_width(style.autocomplete.completion_min_width)
+ .with_max_width(style.autocomplete.completion_max_width)
.into_any(),
);
}
- },
- )
- .with_width_from_item(widest_completion_ix)
- .contained()
- .with_style(container_style)
- .into_any()
+ }
+ })
+ .with_width_from_item(widest_completion_ix);
+
+ enum MultiLineDocumentation {}
+
+ Flex::row()
+ .with_child(list.flex(1., false))
+ .with_children({
+ let mat = &self.matches[selected_item];
+ let completions = self.completions.read();
+ let completion = &completions[mat.candidate_id];
+ let documentation = &completion.documentation;
+
+ match documentation {
+ Some(Documentation::MultiLinePlainText(text)) => Some(
+ Flex::column()
+ .scrollable::<MultiLineDocumentation>(0, None, cx)
+ .with_child(
+ Text::new(text.clone(), style.text.clone()).with_soft_wrap(true),
+ )
+ .contained()
+ .with_style(style.autocomplete.alongside_docs_container)
+ .constrained()
+ .with_max_width(style.autocomplete.alongside_docs_max_width)
+ .flex(1., false),
+ ),
+
+ Some(Documentation::MultiLineMarkdown(parsed)) => Some(
+ Flex::column()
+ .scrollable::<MultiLineDocumentation>(0, None, cx)
+ .with_child(render_parsed_markdown::<MultiLineDocumentation>(
+ parsed, &style, workspace, cx,
+ ))
+ .contained()
+ .with_style(style.autocomplete.alongside_docs_container)
+ .constrained()
+ .with_max_width(style.autocomplete.alongside_docs_max_width)
+ .flex(1., false),
+ ),
+
+ _ => None,
+ }
+ })
+ .contained()
+ .with_style(style.autocomplete.container)
+ .into_any()
}
pub async fn filter(&mut self, query: Option<&str>, executor: Arc<executor::Background>) {
@@ -1095,13 +1477,13 @@ impl CompletionsMenu {
.collect()
};
- //Remove all candidates where the query's start does not match the start of any word in the candidate
+ // Remove all candidates where the query's start does not match the start of any word in the candidate
if let Some(query) = query {
if let Some(query_start) = query.chars().next() {
matches.retain(|string_match| {
split_words(&string_match.string).any(|word| {
- //Check that the first codepoint of the word as lowercase matches the first
- //codepoint of the query as lowercase
+ // Check that the first codepoint of the word as lowercase matches the first
+ // codepoint of the query as lowercase
word.chars()
.flat_map(|codepoint| codepoint.to_lowercase())
.zip(query_start.to_lowercase())
@@ -1111,23 +1493,27 @@ impl CompletionsMenu {
}
}
+ let completions = self.completions.read();
matches.sort_unstable_by_key(|mat| {
- let completion = &self.completions[mat.candidate_id];
+ let completion = &completions[mat.candidate_id];
(
completion.lsp_completion.sort_text.as_ref(),
Reverse(OrderedFloat(mat.score)),
completion.sort_key(),
)
});
+ drop(completions);
for mat in &mut matches {
- let filter_start = self.completions[mat.candidate_id].label.filter_range.start;
+ let completions = self.completions.read();
+ let filter_start = completions[mat.candidate_id].label.filter_range.start;
for position in &mut mat.positions {
*position += filter_start;
}
}
self.matches = matches.into();
+ self.selected_item = 0;
}
}
@@ -1150,17 +1536,23 @@ impl CodeActionsMenu {
fn select_prev(&mut self, cx: &mut ViewContext<Editor>) {
if self.selected_item > 0 {
self.selected_item -= 1;
+ } else {
+ self.selected_item = self.actions.len() - 1;
self.list.scroll_to(ScrollTarget::Show(self.selected_item));
- cx.notify()
}
+ self.list.scroll_to(ScrollTarget::Show(self.selected_item));
+ cx.notify();
}
fn select_next(&mut self, cx: &mut ViewContext<Editor>) {
if self.selected_item + 1 < self.actions.len() {
self.selected_item += 1;
self.list.scroll_to(ScrollTarget::Show(self.selected_item));
- cx.notify()
+ } else {
+ self.selected_item = 0;
+ self.list.scroll_to(ScrollTarget::Show(self.selected_item));
}
+ cx.notify();
}
fn select_last(&mut self, cx: &mut ViewContext<Editor>) {
@@ -1563,7 +1955,7 @@ impl Editor {
background_highlights: Default::default(),
inlay_background_highlights: Default::default(),
nav_history: None,
- context_menu: None,
+ context_menu: RwLock::new(None),
mouse_context_menu: cx
.add_view(|cx| context_menu::ContextMenu::new(editor_view_id, cx)),
completion_tasks: Default::default(),
@@ -1858,10 +2250,12 @@ impl Editor {
if local {
let new_cursor_position = self.selections.newest_anchor().head();
- let completion_menu = match self.context_menu.as_mut() {
+ let mut context_menu = self.context_menu.write();
+ let completion_menu = match context_menu.as_ref() {
Some(ContextMenu::Completions(menu)) => Some(menu),
+
_ => {
- self.context_menu.take();
+ *context_menu = None;
None
}
};
@@ -1873,13 +2267,39 @@ impl Editor {
if kind == Some(CharKind::Word)
&& word_range.to_inclusive().contains(&cursor_position)
{
+ let mut completion_menu = completion_menu.clone();
+ drop(context_menu);
+
let query = Self::completion_query(buffer, cursor_position);
- cx.background()
- .block(completion_menu.filter(query.as_deref(), cx.background().clone()));
+ cx.spawn(move |this, mut cx| async move {
+ completion_menu
+ .filter(query.as_deref(), cx.background().clone())
+ .await;
+
+ this.update(&mut cx, |this, cx| {
+ let mut context_menu = this.context_menu.write();
+ let Some(ContextMenu::Completions(menu)) = context_menu.as_ref() else {
+ return;
+ };
+
+ if menu.id > completion_menu.id {
+ return;
+ }
+
+ *context_menu = Some(ContextMenu::Completions(completion_menu));
+ drop(context_menu);
+ cx.notify();
+ })
+ })
+ .detach();
+
self.show_completions(&ShowCompletions, cx);
} else {
+ drop(context_menu);
self.hide_context_menu(cx);
}
+ } else {
+ drop(context_menu);
}
hide_hover(self, cx);
@@ -2877,8 +3297,10 @@ impl Editor {
i = 0;
} else if pair_state.range.start.to_offset(buffer) > range.end {
break;
- } else if pair_state.selection_id == selection.id {
- enclosing = Some(pair_state);
+ } else {
+ if pair_state.selection_id == selection.id {
+ enclosing = Some(pair_state);
+ }
i += 1;
}
}
@@ -2912,6 +3334,7 @@ impl Editor {
false
});
}
+
fn completion_query(buffer: &MultiBufferSnapshot, position: impl ToOffset) -> Option<String> {
let offset = position.to_offset(buffer);
let (word_range, kind) = buffer.surrounding_word(offset);
@@ -3064,6 +3487,14 @@ impl Editor {
.collect()
}
+ pub fn text_layout_details(&self, cx: &WindowContext) -> TextLayoutDetails {
+ TextLayoutDetails {
+ font_cache: cx.font_cache().clone(),
+ text_layout_cache: cx.text_layout_cache().clone(),
+ editor_style: self.style(cx),
+ }
+ }
+
fn splice_inlay_hints(
&self,
to_remove: Vec<InlayId>,
@@ -3150,7 +3581,6 @@ impl Editor {
});
let id = post_inc(&mut self.next_completion_id);
- let project = self.project.clone();
let task = cx.spawn(|this, mut cx| {
async move {
let menu = if let Some(completions) = completions.await.log_err() {
@@ -3169,8 +3599,7 @@ impl Editor {
})
.collect(),
buffer,
- project,
- completions: completions.into(),
+ completions: Arc::new(RwLock::new(completions.into())),
matches: Vec::new().into(),
selected_item: 0,
list: Default::default(),
@@ -3179,6 +3608,9 @@ impl Editor {
if menu.matches.is_empty() {
None
} else {
+ _ = this.update(&mut cx, |editor, cx| {
+ menu.pre_resolve_completion_documentation(editor.project.clone(), cx);
+ });
Some(menu)
}
} else {
@@ -3188,23 +3620,30 @@ impl Editor {
this.update(&mut cx, |this, cx| {
this.completion_tasks.retain(|(task_id, _)| *task_id > id);
- match this.context_menu.as_ref() {
+ let mut context_menu = this.context_menu.write();
+ match context_menu.as_ref() {
None => {}
+
Some(ContextMenu::Completions(prev_menu)) => {
if prev_menu.id > id {
return;
}
}
+
_ => return,
}
if this.focused && menu.is_some() {
let menu = menu.unwrap();
- this.show_context_menu(ContextMenu::Completions(menu), cx);
+ *context_menu = Some(ContextMenu::Completions(menu));
+ drop(context_menu);
+ this.discard_copilot_suggestion(cx);
+ cx.notify();
} else if this.completion_tasks.is_empty() {
// If there are no more completion tasks and the last menu was
// empty, we should hide it. If it was already hidden, we should
// also show the copilot suggestion when available.
+ drop(context_menu);
if this.hide_context_menu(cx).is_none() {
this.update_visible_copilot_suggestion(cx);
}
@@ -3235,7 +3674,8 @@ impl Editor {
.matches
.get(action.item_ix.unwrap_or(completions_menu.selected_item))?;
let buffer_handle = completions_menu.buffer;
- let completion = completions_menu.completions.get(mat.candidate_id)?;
+ let completions = completions_menu.completions.read();
+ let completion = completions.get(mat.candidate_id)?;
let snippet;
let text;
@@ -3348,14 +3788,13 @@ impl Editor {
}
pub fn toggle_code_actions(&mut self, action: &ToggleCodeActions, cx: &mut ViewContext<Self>) {
- if matches!(
- self.context_menu.as_ref(),
- Some(ContextMenu::CodeActions(_))
- ) {
- self.context_menu.take();
+ let mut context_menu = self.context_menu.write();
+ if matches!(context_menu.as_ref(), Some(ContextMenu::CodeActions(_))) {
+ *context_menu = None;
cx.notify();
return;
}
+ drop(context_menu);
let deployed_from_indicator = action.deployed_from_indicator;
let mut task = self.code_actions_task.take();
@@ -7,6 +7,7 @@ pub struct EditorSettings {
pub cursor_blink: bool,
pub hover_popover_enabled: bool,
pub show_completions_on_input: bool,
+ pub show_completion_documentation: bool,
pub use_on_type_format: bool,
pub scrollbar: Scrollbar,
pub relative_line_numbers: bool,
@@ -33,6 +34,7 @@ pub struct EditorSettingsContent {
pub cursor_blink: Option<bool>,
pub hover_popover_enabled: Option<bool>,
pub show_completions_on_input: Option<bool>,
+ pub show_completion_documentation: Option<bool>,
pub use_on_type_format: Option<bool>,
pub scrollbar: Option<ScrollbarContent>,
pub relative_line_numbers: Option<bool>,
@@ -19,8 +19,8 @@ use gpui::{
use indoc::indoc;
use language::{
language_settings::{AllLanguageSettings, AllLanguageSettingsContent, LanguageSettingsContent},
- BracketPairConfig, BundledFormatter, FakeLspAdapter, LanguageConfig, LanguageConfigOverride,
- LanguageRegistry, Override, Point,
+ BracketPairConfig, FakeLspAdapter, LanguageConfig, LanguageConfigOverride, LanguageRegistry,
+ Override, Point,
};
use parking_lot::Mutex;
use project::project_settings::{LspSettings, ProjectSettings};
@@ -851,7 +851,7 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) {
let view = cx
.add_window(|cx| {
- let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcde\nαβγδε\n", cx);
+ let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcde\nαβγδε", cx);
build_editor(buffer.clone(), cx)
})
.root(cx);
@@ -869,7 +869,7 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) {
true,
cx,
);
- assert_eq!(view.display_text(cx), "ⓐⓑ⋯ⓔ\nab⋯e\nαβ⋯ε\n");
+ assert_eq!(view.display_text(cx), "ⓐⓑ⋯ⓔ\nab⋯e\nαβ⋯ε");
view.move_right(&MoveRight, cx);
assert_eq!(
@@ -888,6 +888,11 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) {
);
view.move_down(&MoveDown, cx);
+ assert_eq!(
+ view.selections.display_ranges(cx),
+ &[empty_range(1, "ab⋯e".len())]
+ );
+ view.move_left(&MoveLeft, cx);
assert_eq!(
view.selections.display_ranges(cx),
&[empty_range(1, "ab⋯".len())]
@@ -929,17 +934,18 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) {
view.selections.display_ranges(cx),
&[empty_range(1, "ab⋯e".len())]
);
- view.move_up(&MoveUp, cx);
+ view.move_down(&MoveDown, cx);
assert_eq!(
view.selections.display_ranges(cx),
- &[empty_range(0, "ⓐⓑ⋯ⓔ".len())]
+ &[empty_range(2, "αβ⋯ε".len())]
);
- view.move_left(&MoveLeft, cx);
+ view.move_up(&MoveUp, cx);
assert_eq!(
view.selections.display_ranges(cx),
- &[empty_range(0, "ⓐⓑ⋯".len())]
+ &[empty_range(1, "ab⋯e".len())]
);
- view.move_left(&MoveLeft, cx);
+
+ view.move_up(&MoveUp, cx);
assert_eq!(
view.selections.display_ranges(cx),
&[empty_range(0, "ⓐⓑ".len())]
@@ -949,6 +955,11 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) {
view.selections.display_ranges(cx),
&[empty_range(0, "ⓐ".len())]
);
+ view.move_left(&MoveLeft, cx);
+ assert_eq!(
+ view.selections.display_ranges(cx),
+ &[empty_range(0, "".len())]
+ );
});
}
@@ -5084,6 +5095,9 @@ async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
LanguageConfig {
name: "Rust".into(),
path_suffixes: vec!["rs".to_string()],
+ // Enable Prettier formatting for the same buffer, and ensure
+ // LSP is called instead of Prettier.
+ prettier_parser_name: Some("test_parser".to_string()),
..Default::default()
},
Some(tree_sitter_rust::language()),
@@ -5094,12 +5108,6 @@ async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
document_formatting_provider: Some(lsp::OneOf::Left(true)),
..Default::default()
},
- // Enable Prettier formatting for the same buffer, and ensure
- // LSP is called instead of Prettier.
- enabled_formatters: vec![BundledFormatter::Prettier {
- parser_name: Some("test_parser"),
- plugin_names: Vec::new(),
- }],
..Default::default()
}))
.await;
@@ -5109,7 +5117,6 @@ async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
let project = Project::test(fs, ["/file.rs".as_ref()], cx).await;
project.update(cx, |project, _| {
- project.enable_test_prettier(&[]);
project.languages().add(Arc::new(language));
});
let buffer = project
@@ -5430,9 +5437,9 @@ async fn test_completion(cx: &mut gpui::TestAppContext) {
additional edit
"});
cx.simulate_keystroke(" ");
- assert!(cx.editor(|e, _| e.context_menu.is_none()));
+ assert!(cx.editor(|e, _| e.context_menu.read().is_none()));
cx.simulate_keystroke("s");
- assert!(cx.editor(|e, _| e.context_menu.is_none()));
+ assert!(cx.editor(|e, _| e.context_menu.read().is_none()));
cx.assert_editor_state(indoc! {"
one.second_completion
@@ -5494,12 +5501,12 @@ async fn test_completion(cx: &mut gpui::TestAppContext) {
});
cx.set_state("editorˇ");
cx.simulate_keystroke(".");
- assert!(cx.editor(|e, _| e.context_menu.is_none()));
+ assert!(cx.editor(|e, _| e.context_menu.read().is_none()));
cx.simulate_keystroke("c");
cx.simulate_keystroke("l");
cx.simulate_keystroke("o");
cx.assert_editor_state("editor.cloˇ");
- assert!(cx.editor(|e, _| e.context_menu.is_none()));
+ assert!(cx.editor(|e, _| e.context_menu.read().is_none()));
cx.update_editor(|editor, cx| {
editor.show_completions(&ShowCompletions, cx);
});
@@ -6710,6 +6717,102 @@ fn test_combine_syntax_and_fuzzy_match_highlights() {
);
}
+#[gpui::test]
+async fn go_to_prev_overlapping_diagnostic(
+ deterministic: Arc<Deterministic>,
+ cx: &mut gpui::TestAppContext,
+) {
+ init_test(cx, |_| {});
+
+ let mut cx = EditorTestContext::new(cx).await;
+ let project = cx.update_editor(|editor, _| editor.project.clone().unwrap());
+
+ cx.set_state(indoc! {"
+ ˇfn func(abc def: i32) -> u32 {
+ }
+ "});
+
+ cx.update(|cx| {
+ project.update(cx, |project, cx| {
+ project
+ .update_diagnostics(
+ LanguageServerId(0),
+ lsp::PublishDiagnosticsParams {
+ uri: lsp::Url::from_file_path("/root/file").unwrap(),
+ version: None,
+ diagnostics: vec![
+ lsp::Diagnostic {
+ range: lsp::Range::new(
+ lsp::Position::new(0, 11),
+ lsp::Position::new(0, 12),
+ ),
+ severity: Some(lsp::DiagnosticSeverity::ERROR),
+ ..Default::default()
+ },
+ lsp::Diagnostic {
+ range: lsp::Range::new(
+ lsp::Position::new(0, 12),
+ lsp::Position::new(0, 15),
+ ),
+ severity: Some(lsp::DiagnosticSeverity::ERROR),
+ ..Default::default()
+ },
+ lsp::Diagnostic {
+ range: lsp::Range::new(
+ lsp::Position::new(0, 25),
+ lsp::Position::new(0, 28),
+ ),
+ severity: Some(lsp::DiagnosticSeverity::ERROR),
+ ..Default::default()
+ },
+ ],
+ },
+ &[],
+ cx,
+ )
+ .unwrap()
+ });
+ });
+
+ deterministic.run_until_parked();
+
+ cx.update_editor(|editor, cx| {
+ editor.go_to_prev_diagnostic(&GoToPrevDiagnostic, cx);
+ });
+
+ cx.assert_editor_state(indoc! {"
+ fn func(abc def: i32) -> ˇu32 {
+ }
+ "});
+
+ cx.update_editor(|editor, cx| {
+ editor.go_to_prev_diagnostic(&GoToPrevDiagnostic, cx);
+ });
+
+ cx.assert_editor_state(indoc! {"
+ fn func(abc ˇdef: i32) -> u32 {
+ }
+ "});
+
+ cx.update_editor(|editor, cx| {
+ editor.go_to_prev_diagnostic(&GoToPrevDiagnostic, cx);
+ });
+
+ cx.assert_editor_state(indoc! {"
+ fn func(abcˇ def: i32) -> u32 {
+ }
+ "});
+
+ cx.update_editor(|editor, cx| {
+ editor.go_to_prev_diagnostic(&GoToPrevDiagnostic, cx);
+ });
+
+ cx.assert_editor_state(indoc! {"
+ fn func(abc def: i32) -> ˇu32 {
+ }
+ "});
+}
+
#[gpui::test]
async fn go_to_hunk(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
@@ -6792,6 +6895,46 @@ async fn go_to_hunk(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppCon
.unindent(),
);
+ cx.update_editor(|editor, cx| {
+ editor.go_to_prev_hunk(&GoToPrevHunk, cx);
+ });
+
+ cx.assert_editor_state(
+ &r#"
+ use some::modified;
+
+ ˇ
+ fn main() {
+ println!("hello there");
+
+ println!("around the");
+ println!("world");
+ }
+ "#
+ .unindent(),
+ );
+
+ cx.update_editor(|editor, cx| {
+ for _ in 0..3 {
+ editor.go_to_prev_hunk(&GoToPrevHunk, cx);
+ }
+ });
+
+ cx.assert_editor_state(
+ &r#"
+ use some::modified;
+
+
+ fn main() {
+ ˇ println!("hello there");
+
+ println!("around the");
+ println!("world");
+ }
+ "#
+ .unindent(),
+ );
+
cx.update_editor(|editor, cx| {
editor.fold(&Fold, cx);
@@ -7788,7 +7931,7 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut gpui:
cx.simulate_keystroke("-");
cx.foreground().run_until_parked();
cx.update_editor(|editor, _| {
- if let Some(ContextMenu::Completions(menu)) = &editor.context_menu {
+ if let Some(ContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
assert_eq!(
menu.matches.iter().map(|m| &m.string).collect::<Vec<_>>(),
&["bg-red", "bg-blue", "bg-yellow"]
@@ -7801,7 +7944,7 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut gpui:
cx.simulate_keystroke("l");
cx.foreground().run_until_parked();
cx.update_editor(|editor, _| {
- if let Some(ContextMenu::Completions(menu)) = &editor.context_menu {
+ if let Some(ContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
assert_eq!(
menu.matches.iter().map(|m| &m.string).collect::<Vec<_>>(),
&["bg-blue", "bg-yellow"]
@@ -7817,7 +7960,7 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut gpui:
cx.simulate_keystroke("l");
cx.foreground().run_until_parked();
cx.update_editor(|editor, _| {
- if let Some(ContextMenu::Completions(menu)) = &editor.context_menu {
+ if let Some(ContextMenu::Completions(menu)) = editor.context_menu.read().as_ref() {
assert_eq!(
menu.matches.iter().map(|m| &m.string).collect::<Vec<_>>(),
&["bg-yellow"]
@@ -7838,6 +7981,7 @@ async fn test_document_format_with_prettier(cx: &mut gpui::TestAppContext) {
LanguageConfig {
name: "Rust".into(),
path_suffixes: vec!["rs".to_string()],
+ prettier_parser_name: Some("test_parser".to_string()),
..Default::default()
},
Some(tree_sitter_rust::language()),
@@ -7846,10 +7990,7 @@ async fn test_document_format_with_prettier(cx: &mut gpui::TestAppContext) {
let test_plugin = "test_plugin";
let _ = language
.set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
- enabled_formatters: vec![BundledFormatter::Prettier {
- parser_name: Some("test_parser"),
- plugin_names: vec![test_plugin],
- }],
+ prettier_plugins: vec![test_plugin],
..Default::default()
}))
.await;
@@ -7858,10 +7999,9 @@ async fn test_document_format_with_prettier(cx: &mut gpui::TestAppContext) {
fs.insert_file("/file.rs", Default::default()).await;
let project = Project::test(fs, ["/file.rs".as_ref()], cx).await;
- let prettier_format_suffix = project.update(cx, |project, _| {
- let suffix = project.enable_test_prettier(&[test_plugin]);
+ let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX;
+ project.update(cx, |project, _| {
project.languages().add(Arc::new(language));
- suffix
});
let buffer = project
.update(cx, |project, cx| project.open_local_buffer("/file.rs", cx))
@@ -4,7 +4,7 @@ use super::{
MAX_LINE_LEN,
};
use crate::{
- display_map::{BlockStyle, DisplaySnapshot, FoldStatus, TransformBlock},
+ display_map::{BlockStyle, DisplaySnapshot, FoldStatus, HighlightedChunk, TransformBlock},
editor_settings::ShowScrollbar,
git::{diff_hunk_to_display, DisplayDiffHunk},
hover_popover::{
@@ -22,7 +22,7 @@ use git::diff::DiffHunkStatus;
use gpui::{
color::Color,
elements::*,
- fonts::{HighlightStyle, TextStyle, Underline},
+ fonts::TextStyle,
geometry::{
rect::RectF,
vector::{vec2f, Vector2F},
@@ -37,8 +37,7 @@ use gpui::{
use itertools::Itertools;
use json::json;
use language::{
- language_settings::ShowWhitespaceSetting, Bias, CursorShape, DiagnosticSeverity, OffsetUtf16,
- Selection,
+ language_settings::ShowWhitespaceSetting, Bias, CursorShape, OffsetUtf16, Selection,
};
use project::{
project_settings::{GitGutterSetting, ProjectSettings},
@@ -1584,56 +1583,7 @@ impl EditorElement {
.collect()
} else {
let style = &self.style;
- let chunks = snapshot
- .chunks(
- rows.clone(),
- true,
- Some(style.theme.hint),
- Some(style.theme.suggestion),
- )
- .map(|chunk| {
- let mut highlight_style = chunk
- .syntax_highlight_id
- .and_then(|id| id.style(&style.syntax));
-
- if let Some(chunk_highlight) = chunk.highlight_style {
- if let Some(highlight_style) = highlight_style.as_mut() {
- highlight_style.highlight(chunk_highlight);
- } else {
- highlight_style = Some(chunk_highlight);
- }
- }
-
- let mut diagnostic_highlight = HighlightStyle::default();
-
- if chunk.is_unnecessary {
- diagnostic_highlight.fade_out = Some(style.unnecessary_code_fade);
- }
-
- if let Some(severity) = chunk.diagnostic_severity {
- // Omit underlines for HINT/INFO diagnostics on 'unnecessary' code.
- if severity <= DiagnosticSeverity::WARNING || !chunk.is_unnecessary {
- let diagnostic_style = super::diagnostic_style(severity, true, style);
- diagnostic_highlight.underline = Some(Underline {
- color: Some(diagnostic_style.message.text.color),
- thickness: 1.0.into(),
- squiggly: true,
- });
- }
- }
-
- if let Some(highlight_style) = highlight_style.as_mut() {
- highlight_style.highlight(diagnostic_highlight);
- } else {
- highlight_style = Some(diagnostic_highlight);
- }
-
- HighlightedChunk {
- chunk: chunk.text,
- style: highlight_style,
- is_tab: chunk.is_tab,
- }
- });
+ let chunks = snapshot.highlighted_chunks(rows.clone(), true, style);
LineWithInvisibles::from_chunks(
chunks,
@@ -1870,12 +1820,6 @@ impl EditorElement {
}
}
-struct HighlightedChunk<'a> {
- chunk: &'a str,
- style: Option<HighlightStyle>,
- is_tab: bool,
-}
-
#[derive(Debug)]
pub struct LineWithInvisibles {
pub line: Line,
@@ -2428,7 +2372,7 @@ impl Element<Editor> for EditorElement {
}
let active = matches!(
- editor.context_menu,
+ editor.context_menu.read().as_ref(),
Some(crate::ContextMenu::CodeActions(_))
);
@@ -2439,9 +2383,13 @@ impl Element<Editor> for EditorElement {
}
let visible_rows = start_row..start_row + line_layouts.len() as u32;
- let mut hover = editor
- .hover_state
- .render(&snapshot, &style, visible_rows, cx);
+ let mut hover = editor.hover_state.render(
+ &snapshot,
+ &style,
+ visible_rows,
+ editor.workspace.as_ref().map(|(w, _)| w.clone()),
+ cx,
+ );
let mode = editor.mode;
let mut fold_indicators = editor.render_fold_indicators(
@@ -36,7 +36,7 @@ impl DisplayDiffHunk {
DisplayDiffHunk::Unfolded {
display_row_range, ..
- } => display_row_range.start..=display_row_range.end - 1,
+ } => display_row_range.start..=display_row_range.end,
};
range.contains(&display_row)
@@ -77,8 +77,8 @@ pub fn diff_hunk_to_display(hunk: DiffHunk<u32>, snapshot: &DisplaySnapshot) ->
} else {
let start = hunk_start_point.to_display_point(snapshot).row();
- let hunk_end_row_inclusive = hunk.buffer_range.end.max(hunk.buffer_range.start);
- let hunk_end_point = Point::new(hunk_end_row_inclusive, 0);
+ let hunk_end_row = hunk.buffer_range.end.max(hunk.buffer_range.start);
+ let hunk_end_point = Point::new(hunk_end_row, 0);
let end = hunk_end_point.to_display_point(snapshot).row();
DisplayDiffHunk::Unfolded {
@@ -87,3 +87,196 @@ pub fn diff_hunk_to_display(hunk: DiffHunk<u32>, snapshot: &DisplaySnapshot) ->
}
}
}
+
+#[cfg(any(test, feature = "test_support"))]
+mod tests {
+ use crate::editor_tests::init_test;
+ use crate::Point;
+ use gpui::TestAppContext;
+ use multi_buffer::{ExcerptRange, MultiBuffer};
+ use project::{FakeFs, Project};
+ use unindent::Unindent;
+ #[gpui::test]
+ async fn test_diff_hunks_in_range(cx: &mut TestAppContext) {
+ use git::diff::DiffHunkStatus;
+ init_test(cx, |_| {});
+
+ let fs = FakeFs::new(cx.background());
+ let project = Project::test(fs, [], cx).await;
+
+ // buffer has two modified hunks with two rows each
+ let buffer_1 = project
+ .update(cx, |project, cx| {
+ project.create_buffer(
+ "
+ 1.zero
+ 1.ONE
+ 1.TWO
+ 1.three
+ 1.FOUR
+ 1.FIVE
+ 1.six
+ "
+ .unindent()
+ .as_str(),
+ None,
+ cx,
+ )
+ })
+ .unwrap();
+ buffer_1.update(cx, |buffer, cx| {
+ buffer.set_diff_base(
+ Some(
+ "
+ 1.zero
+ 1.one
+ 1.two
+ 1.three
+ 1.four
+ 1.five
+ 1.six
+ "
+ .unindent(),
+ ),
+ cx,
+ );
+ });
+
+ // buffer has a deletion hunk and an insertion hunk
+ let buffer_2 = project
+ .update(cx, |project, cx| {
+ project.create_buffer(
+ "
+ 2.zero
+ 2.one
+ 2.two
+ 2.three
+ 2.four
+ 2.five
+ 2.six
+ "
+ .unindent()
+ .as_str(),
+ None,
+ cx,
+ )
+ })
+ .unwrap();
+ buffer_2.update(cx, |buffer, cx| {
+ buffer.set_diff_base(
+ Some(
+ "
+ 2.zero
+ 2.one
+ 2.one-and-a-half
+ 2.two
+ 2.three
+ 2.four
+ 2.six
+ "
+ .unindent(),
+ ),
+ cx,
+ );
+ });
+
+ cx.foreground().run_until_parked();
+
+ let multibuffer = cx.add_model(|cx| {
+ let mut multibuffer = MultiBuffer::new(0);
+ multibuffer.push_excerpts(
+ buffer_1.clone(),
+ [
+ // excerpt ends in the middle of a modified hunk
+ ExcerptRange {
+ context: Point::new(0, 0)..Point::new(1, 5),
+ primary: Default::default(),
+ },
+ // excerpt begins in the middle of a modified hunk
+ ExcerptRange {
+ context: Point::new(5, 0)..Point::new(6, 5),
+ primary: Default::default(),
+ },
+ ],
+ cx,
+ );
+ multibuffer.push_excerpts(
+ buffer_2.clone(),
+ [
+ // excerpt ends at a deletion
+ ExcerptRange {
+ context: Point::new(0, 0)..Point::new(1, 5),
+ primary: Default::default(),
+ },
+ // excerpt starts at a deletion
+ ExcerptRange {
+ context: Point::new(2, 0)..Point::new(2, 5),
+ primary: Default::default(),
+ },
+ // excerpt fully contains a deletion hunk
+ ExcerptRange {
+ context: Point::new(1, 0)..Point::new(2, 5),
+ primary: Default::default(),
+ },
+ // excerpt fully contains an insertion hunk
+ ExcerptRange {
+ context: Point::new(4, 0)..Point::new(6, 5),
+ primary: Default::default(),
+ },
+ ],
+ cx,
+ );
+ multibuffer
+ });
+
+ let snapshot = multibuffer.read_with(cx, |b, cx| b.snapshot(cx));
+
+ assert_eq!(
+ snapshot.text(),
+ "
+ 1.zero
+ 1.ONE
+ 1.FIVE
+ 1.six
+ 2.zero
+ 2.one
+ 2.two
+ 2.one
+ 2.two
+ 2.four
+ 2.five
+ 2.six"
+ .unindent()
+ );
+
+ let expected = [
+ (DiffHunkStatus::Modified, 1..2),
+ (DiffHunkStatus::Modified, 2..3),
+ //TODO: Define better when and where removed hunks show up at range extremities
+ (DiffHunkStatus::Removed, 6..6),
+ (DiffHunkStatus::Removed, 8..8),
+ (DiffHunkStatus::Added, 10..11),
+ ];
+
+ assert_eq!(
+ snapshot
+ .git_diff_hunks_in_range(0..12)
+ .map(|hunk| (hunk.status(), hunk.buffer_range))
+ .collect::<Vec<_>>(),
+ &expected,
+ );
+
+ assert_eq!(
+ snapshot
+ .git_diff_hunks_in_range_rev(0..12)
+ .map(|hunk| (hunk.status(), hunk.buffer_range))
+ .collect::<Vec<_>>(),
+ expected
+ .iter()
+ .rev()
+ .cloned()
+ .collect::<Vec<_>>()
+ .as_slice(),
+ );
+ }
+}
@@ -9,13 +9,15 @@ use gpui::{
actions,
elements::{Flex, MouseEventHandler, Padding, ParentElement, Text},
platform::{CursorStyle, MouseButton},
- AnyElement, AppContext, Element, ModelHandle, Task, ViewContext,
+ AnyElement, AppContext, Element, ModelHandle, Task, ViewContext, WeakViewHandle,
+};
+use language::{
+ markdown, Bias, DiagnosticEntry, DiagnosticSeverity, Language, LanguageRegistry, ParsedMarkdown,
};
-use language::{Bias, DiagnosticEntry, DiagnosticSeverity, Language, LanguageRegistry};
use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart, Project};
-use rich_text::{new_paragraph, render_code, render_markdown_mut, RichText};
use std::{ops::Range, sync::Arc, time::Duration};
use util::TryFutureExt;
+use workspace::Workspace;
pub const HOVER_DELAY_MILLIS: u64 = 350;
pub const HOVER_REQUEST_DELAY_MILLIS: u64 = 200;
@@ -105,12 +107,15 @@ pub fn hover_at_inlay(editor: &mut Editor, inlay_hover: InlayHover, cx: &mut Vie
this.hover_state.diagnostic_popover = None;
})?;
+ let language_registry = project.update(&mut cx, |p, _| p.languages().clone());
+ let blocks = vec![inlay_hover.tooltip];
+ let parsed_content = parse_blocks(&blocks, &language_registry, None).await;
+
let hover_popover = InfoPopover {
project: project.clone(),
symbol_range: RangeInEditor::Inlay(inlay_hover.range.clone()),
- blocks: vec![inlay_hover.tooltip],
- language: None,
- rendered_content: None,
+ blocks,
+ parsed_content,
};
this.update(&mut cx, |this, cx| {
@@ -288,35 +293,38 @@ fn show_hover(
});
})?;
- // Construct new hover popover from hover request
- let hover_popover = hover_request.await.ok().flatten().and_then(|hover_result| {
- if hover_result.is_empty() {
- return None;
+ let hover_result = hover_request.await.ok().flatten();
+ let hover_popover = match hover_result {
+ Some(hover_result) if !hover_result.is_empty() => {
+ // Create symbol range of anchors for highlighting and filtering of future requests.
+ let range = if let Some(range) = hover_result.range {
+ let start = snapshot
+ .buffer_snapshot
+ .anchor_in_excerpt(excerpt_id.clone(), range.start);
+ let end = snapshot
+ .buffer_snapshot
+ .anchor_in_excerpt(excerpt_id.clone(), range.end);
+
+ start..end
+ } else {
+ anchor..anchor
+ };
+
+ let language_registry = project.update(&mut cx, |p, _| p.languages().clone());
+ let blocks = hover_result.contents;
+ let language = hover_result.language;
+ let parsed_content = parse_blocks(&blocks, &language_registry, language).await;
+
+ Some(InfoPopover {
+ project: project.clone(),
+ symbol_range: RangeInEditor::Text(range),
+ blocks,
+ parsed_content,
+ })
}
- // Create symbol range of anchors for highlighting and filtering
- // of future requests.
- let range = if let Some(range) = hover_result.range {
- let start = snapshot
- .buffer_snapshot
- .anchor_in_excerpt(excerpt_id.clone(), range.start);
- let end = snapshot
- .buffer_snapshot
- .anchor_in_excerpt(excerpt_id.clone(), range.end);
-
- start..end
- } else {
- anchor..anchor
- };
-
- Some(InfoPopover {
- project: project.clone(),
- symbol_range: RangeInEditor::Text(range),
- blocks: hover_result.contents,
- language: hover_result.language,
- rendered_content: None,
- })
- });
+ _ => None,
+ };
this.update(&mut cx, |this, cx| {
if let Some(symbol_range) = hover_popover
@@ -345,44 +353,56 @@ fn show_hover(
editor.hover_state.info_task = Some(task);
}
-fn render_blocks(
+async fn parse_blocks(
blocks: &[HoverBlock],
language_registry: &Arc<LanguageRegistry>,
- language: Option<&Arc<Language>>,
-) -> RichText {
- let mut data = RichText {
- text: Default::default(),
- highlights: Default::default(),
- region_ranges: Default::default(),
- regions: Default::default(),
- };
+ language: Option<Arc<Language>>,
+) -> markdown::ParsedMarkdown {
+ let mut text = String::new();
+ let mut highlights = Vec::new();
+ let mut region_ranges = Vec::new();
+ let mut regions = Vec::new();
for block in blocks {
match &block.kind {
HoverBlockKind::PlainText => {
- new_paragraph(&mut data.text, &mut Vec::new());
- data.text.push_str(&block.text);
+ markdown::new_paragraph(&mut text, &mut Vec::new());
+ text.push_str(&block.text);
}
+
HoverBlockKind::Markdown => {
- render_markdown_mut(&block.text, language_registry, language, &mut data)
+ markdown::parse_markdown_block(
+ &block.text,
+ language_registry,
+ language.clone(),
+ &mut text,
+ &mut highlights,
+ &mut region_ranges,
+ &mut regions,
+ )
+ .await
}
+
HoverBlockKind::Code { language } => {
if let Some(language) = language_registry
.language_for_name(language)
.now_or_never()
.and_then(Result::ok)
{
- render_code(&mut data.text, &mut data.highlights, &block.text, &language);
+ markdown::highlight_code(&mut text, &mut highlights, &block.text, &language);
} else {
- data.text.push_str(&block.text);
+ text.push_str(&block.text);
}
}
}
}
- data.text = data.text.trim().to_string();
-
- data
+ ParsedMarkdown {
+ text: text.trim().to_string(),
+ highlights,
+ region_ranges,
+ regions,
+ }
}
#[derive(Default)]
@@ -403,6 +423,7 @@ impl HoverState {
snapshot: &EditorSnapshot,
style: &EditorStyle,
visible_rows: Range<u32>,
+ workspace: Option<WeakViewHandle<Workspace>>,
cx: &mut ViewContext<Editor>,
) -> Option<(DisplayPoint, Vec<AnyElement<Editor>>)> {
// If there is a diagnostic, position the popovers based on that.
@@ -432,7 +453,7 @@ impl HoverState {
elements.push(diagnostic_popover.render(style, cx));
}
if let Some(info_popover) = self.info_popover.as_mut() {
- elements.push(info_popover.render(style, cx));
+ elements.push(info_popover.render(style, workspace, cx));
}
Some((point, elements))
@@ -444,32 +465,23 @@ pub struct InfoPopover {
pub project: ModelHandle<Project>,
symbol_range: RangeInEditor,
pub blocks: Vec<HoverBlock>,
- language: Option<Arc<Language>>,
- rendered_content: Option<RichText>,
+ parsed_content: ParsedMarkdown,
}
impl InfoPopover {
pub fn render(
&mut self,
style: &EditorStyle,
+ workspace: Option<WeakViewHandle<Workspace>>,
cx: &mut ViewContext<Editor>,
) -> AnyElement<Editor> {
- let rendered_content = self.rendered_content.get_or_insert_with(|| {
- render_blocks(
- &self.blocks,
- self.project.read(cx).languages(),
- self.language.as_ref(),
- )
- });
-
- MouseEventHandler::new::<InfoPopover, _>(0, cx, move |_, cx| {
- let code_span_background_color = style.document_highlight_read_background;
+ MouseEventHandler::new::<InfoPopover, _>(0, cx, |_, cx| {
Flex::column()
- .scrollable::<HoverBlock>(1, None, cx)
- .with_child(rendered_content.element(
- style.syntax.clone(),
- style.text.clone(),
- code_span_background_color,
+ .scrollable::<HoverBlock>(0, None, cx)
+ .with_child(crate::render_parsed_markdown::<HoverBlock>(
+ &self.parsed_content,
+ style,
+ workspace,
cx,
))
.contained()
@@ -572,7 +584,6 @@ mod tests {
use language::{language_settings::InlayHintSettings, Diagnostic, DiagnosticSet};
use lsp::LanguageServerId;
use project::{HoverBlock, HoverBlockKind};
- use rich_text::Highlight;
use smol::stream::StreamExt;
use unindent::Unindent;
use util::test::marked_text_ranges;
@@ -793,7 +804,7 @@ mod tests {
}],
);
- let rendered = render_blocks(&blocks, &Default::default(), None);
+ let rendered = smol::block_on(parse_blocks(&blocks, &Default::default(), None));
assert_eq!(
rendered.text,
code_str.trim(),
@@ -900,7 +911,7 @@ mod tests {
// Links
Row {
blocks: vec three".to_string(),
+ text: "one [two](https://the-url) three".to_string(),
kind: HoverBlockKind::Markdown,
}],
expected_marked_text: "one «two» three".to_string(),
@@ -921,7 +932,7 @@ mod tests {
- a
- b
* two
- - [c](the-url)
+ - [c](https://the-url)
- d"
.unindent(),
kind: HoverBlockKind::Markdown,
@@ -985,7 +996,7 @@ mod tests {
expected_styles,
} in &rows[0..]
{
- let rendered = render_blocks(&blocks, &Default::default(), None);
+ let rendered = smol::block_on(parse_blocks(&blocks, &Default::default(), None));
let (expected_text, ranges) = marked_text_ranges(expected_marked_text, false);
let expected_highlights = ranges
@@ -1001,11 +1012,8 @@ mod tests {
.highlights
.iter()
.filter_map(|(range, highlight)| {
- let style = match highlight {
- Highlight::Id(id) => id.style(&style.syntax)?,
- Highlight::Highlight(style) => style.clone(),
- };
- Some((range.clone(), style))
+ let highlight = highlight.to_highlight_style(&style.syntax)?;
+ Some((range.clone(), highlight))
})
.collect();
@@ -1258,11 +1266,7 @@ mod tests {
"Popover range should match the new type label part"
);
assert_eq!(
- popover
- .rendered_content
- .as_ref()
- .expect("should have label text for new type hint")
- .text,
+ popover.parsed_content.text,
format!("A tooltip for `{new_type_label}`"),
"Rendered text should not anyhow alter backticks"
);
@@ -1316,11 +1320,7 @@ mod tests {
"Popover range should match the struct label part"
);
assert_eq!(
- popover
- .rendered_content
- .as_ref()
- .expect("should have label text for struct hint")
- .text,
+ popover.parsed_content.text,
format!("A tooltip for {struct_label}"),
"Rendered markdown element should remove backticks from text"
);
@@ -2138,7 +2138,7 @@ pub mod tests {
});
}
- #[gpui::test]
+ #[gpui::test(iterations = 10)]
async fn test_large_buffer_inlay_requests_split(cx: &mut gpui::TestAppContext) {
init_test(cx, |settings| {
settings.defaults.inlay_hints = Some(InlayHintSettings {
@@ -2400,11 +2400,13 @@ pub mod tests {
));
cx.foreground().run_until_parked();
editor.update(cx, |editor, cx| {
- let ranges = lsp_request_ranges.lock().drain(..).collect::<Vec<_>>();
+ let mut ranges = lsp_request_ranges.lock().drain(..).collect::<Vec<_>>();
+ ranges.sort_by_key(|r| r.start);
+
assert_eq!(ranges.len(), 3,
"On edit, should scroll to selection and query a range around it: visible + same range above and below. Instead, got query ranges {ranges:?}");
- let visible_query_range = &ranges[0];
- let above_query_range = &ranges[1];
+ let above_query_range = &ranges[0];
+ let visible_query_range = &ranges[1];
let below_query_range = &ranges[2];
assert!(above_query_range.end.character < visible_query_range.start.character || above_query_range.end.line + 1 == visible_query_range.start.line,
"Above range {above_query_range:?} should be before visible range {visible_query_range:?}");
@@ -1,7 +1,8 @@
use super::{Bias, DisplayPoint, DisplaySnapshot, SelectionGoal, ToDisplayPoint};
-use crate::{char_kind, CharKind, ToOffset, ToPoint};
+use crate::{char_kind, CharKind, EditorStyle, ToOffset, ToPoint};
+use gpui::{FontCache, TextLayoutCache};
use language::Point;
-use std::ops::Range;
+use std::{ops::Range, sync::Arc};
#[derive(Debug, PartialEq)]
pub enum FindRange {
@@ -9,6 +10,14 @@ pub enum FindRange {
MultiLine,
}
+/// TextLayoutDetails encompasses everything we need to move vertically
+/// taking into account variable width characters.
+pub struct TextLayoutDetails {
+ pub font_cache: Arc<FontCache>,
+ pub text_layout_cache: Arc<TextLayoutCache>,
+ pub editor_style: EditorStyle,
+}
+
pub fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
if point.column() > 0 {
*point.column_mut() -= 1;
@@ -47,8 +56,16 @@ pub fn up(
start: DisplayPoint,
goal: SelectionGoal,
preserve_column_at_start: bool,
+ text_layout_details: &TextLayoutDetails,
) -> (DisplayPoint, SelectionGoal) {
- up_by_rows(map, start, 1, goal, preserve_column_at_start)
+ up_by_rows(
+ map,
+ start,
+ 1,
+ goal,
+ preserve_column_at_start,
+ text_layout_details,
+ )
}
pub fn down(
@@ -56,8 +73,16 @@ pub fn down(
start: DisplayPoint,
goal: SelectionGoal,
preserve_column_at_end: bool,
+ text_layout_details: &TextLayoutDetails,
) -> (DisplayPoint, SelectionGoal) {
- down_by_rows(map, start, 1, goal, preserve_column_at_end)
+ down_by_rows(
+ map,
+ start,
+ 1,
+ goal,
+ preserve_column_at_end,
+ text_layout_details,
+ )
}
pub fn up_by_rows(
@@ -66,11 +91,13 @@ pub fn up_by_rows(
row_count: u32,
goal: SelectionGoal,
preserve_column_at_start: bool,
+ text_layout_details: &TextLayoutDetails,
) -> (DisplayPoint, SelectionGoal) {
- let mut goal_column = match goal {
- SelectionGoal::Column(column) => column,
- SelectionGoal::ColumnRange { end, .. } => end,
- _ => map.column_to_chars(start.row(), start.column()),
+ let mut goal_x = match goal {
+ SelectionGoal::HorizontalPosition(x) => x,
+ SelectionGoal::WrappedHorizontalPosition((_, x)) => x,
+ SelectionGoal::HorizontalRange { end, .. } => end,
+ _ => map.x_for_point(start, text_layout_details),
};
let prev_row = start.row().saturating_sub(row_count);
@@ -79,19 +106,19 @@ pub fn up_by_rows(
Bias::Left,
);
if point.row() < start.row() {
- *point.column_mut() = map.column_from_chars(point.row(), goal_column);
+ *point.column_mut() = map.column_for_x(point.row(), goal_x, text_layout_details)
} else if preserve_column_at_start {
return (start, goal);
} else {
point = DisplayPoint::new(0, 0);
- goal_column = 0;
+ goal_x = 0.0;
}
let mut clipped_point = map.clip_point(point, Bias::Left);
if clipped_point.row() < point.row() {
clipped_point = map.clip_point(point, Bias::Right);
}
- (clipped_point, SelectionGoal::Column(goal_column))
+ (clipped_point, SelectionGoal::HorizontalPosition(goal_x))
}
pub fn down_by_rows(
@@ -100,29 +127,31 @@ pub fn down_by_rows(
row_count: u32,
goal: SelectionGoal,
preserve_column_at_end: bool,
+ text_layout_details: &TextLayoutDetails,
) -> (DisplayPoint, SelectionGoal) {
- let mut goal_column = match goal {
- SelectionGoal::Column(column) => column,
- SelectionGoal::ColumnRange { end, .. } => end,
- _ => map.column_to_chars(start.row(), start.column()),
+ let mut goal_x = match goal {
+ SelectionGoal::HorizontalPosition(x) => x,
+ SelectionGoal::WrappedHorizontalPosition((_, x)) => x,
+ SelectionGoal::HorizontalRange { end, .. } => end,
+ _ => map.x_for_point(start, text_layout_details),
};
let new_row = start.row() + row_count;
let mut point = map.clip_point(DisplayPoint::new(new_row, 0), Bias::Right);
if point.row() > start.row() {
- *point.column_mut() = map.column_from_chars(point.row(), goal_column);
+ *point.column_mut() = map.column_for_x(point.row(), goal_x, text_layout_details)
} else if preserve_column_at_end {
return (start, goal);
} else {
point = map.max_point();
- goal_column = map.column_to_chars(point.row(), point.column())
+ goal_x = map.x_for_point(point, text_layout_details)
}
let mut clipped_point = map.clip_point(point, Bias::Right);
if clipped_point.row() > point.row() {
clipped_point = map.clip_point(point, Bias::Left);
}
- (clipped_point, SelectionGoal::Column(goal_column))
+ (clipped_point, SelectionGoal::HorizontalPosition(goal_x))
}
pub fn line_beginning(
@@ -340,6 +369,30 @@ pub fn find_boundary(
map.clip_point(offset.to_display_point(map), Bias::Right)
}
+pub fn chars_after(
+ map: &DisplaySnapshot,
+ mut offset: usize,
+) -> impl Iterator<Item = (char, Range<usize>)> + '_ {
+ map.buffer_snapshot.chars_at(offset).map(move |ch| {
+ let before = offset;
+ offset = offset + ch.len_utf8();
+ (ch, before..offset)
+ })
+}
+
+pub fn chars_before(
+ map: &DisplaySnapshot,
+ mut offset: usize,
+) -> impl Iterator<Item = (char, Range<usize>)> + '_ {
+ map.buffer_snapshot
+ .reversed_chars_at(offset)
+ .map(move |ch| {
+ let after = offset;
+ offset = offset - ch.len_utf8();
+ (ch, offset..after)
+ })
+}
+
pub fn is_inside_word(map: &DisplaySnapshot, point: DisplayPoint) -> bool {
let raw_point = point.to_point(map);
let scope = map.buffer_snapshot.language_scope_at(raw_point);
@@ -396,9 +449,11 @@ pub fn split_display_range_by_lines(
mod tests {
use super::*;
use crate::{
- display_map::Inlay, test::marked_display_snapshot, Buffer, DisplayMap, ExcerptRange,
- InlayId, MultiBuffer,
+ display_map::Inlay,
+ test::{editor_test_context::EditorTestContext, marked_display_snapshot},
+ Buffer, DisplayMap, ExcerptRange, InlayId, MultiBuffer,
};
+ use project::Project;
use settings::SettingsStore;
use util::post_inc;
@@ -676,7 +731,9 @@ mod tests {
let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
assert_eq!(
surrounding_word(&snapshot, display_points[1]),
- display_points[0]..display_points[2]
+ display_points[0]..display_points[2],
+ "{}",
+ marked_text.to_string()
);
}
@@ -686,128 +743,178 @@ mod tests {
assert("loremˇ ˇ ˇipsum", cx);
assert("lorem\nˇˇˇ\nipsum", cx);
assert("lorem\nˇˇipsumˇ", cx);
- assert("lorem,ˇˇ ˇipsum", cx);
+ assert("loremˇ,ˇˇ ipsum", cx);
assert("ˇloremˇˇ, ipsum", cx);
}
#[gpui::test]
- fn test_move_up_and_down_with_excerpts(cx: &mut gpui::AppContext) {
- init_test(cx);
-
- let family_id = cx
- .font_cache()
- .load_family(&["Helvetica"], &Default::default())
- .unwrap();
- let font_id = cx
- .font_cache()
- .select_font(family_id, &Default::default())
- .unwrap();
+ async fn test_move_up_and_down_with_excerpts(cx: &mut gpui::TestAppContext) {
+ cx.update(|cx| {
+ init_test(cx);
+ });
- let buffer =
- cx.add_model(|cx| Buffer::new(0, cx.model_id() as u64, "abc\ndefg\nhijkl\nmn"));
- let multibuffer = cx.add_model(|cx| {
- let mut multibuffer = MultiBuffer::new(0);
- multibuffer.push_excerpts(
- buffer.clone(),
- [
- ExcerptRange {
- context: Point::new(0, 0)..Point::new(1, 4),
- primary: None,
- },
- ExcerptRange {
- context: Point::new(2, 0)..Point::new(3, 2),
- primary: None,
- },
- ],
- cx,
+ let mut cx = EditorTestContext::new(cx).await;
+ let editor = cx.editor.clone();
+ let window = cx.window.clone();
+ cx.update_window(window, |cx| {
+ let text_layout_details =
+ editor.read_with(cx, |editor, cx| editor.text_layout_details(cx));
+
+ let family_id = cx
+ .font_cache()
+ .load_family(&["Helvetica"], &Default::default())
+ .unwrap();
+ let font_id = cx
+ .font_cache()
+ .select_font(family_id, &Default::default())
+ .unwrap();
+
+ let buffer =
+ cx.add_model(|cx| Buffer::new(0, cx.model_id() as u64, "abc\ndefg\nhijkl\nmn"));
+ let multibuffer = cx.add_model(|cx| {
+ let mut multibuffer = MultiBuffer::new(0);
+ multibuffer.push_excerpts(
+ buffer.clone(),
+ [
+ ExcerptRange {
+ context: Point::new(0, 0)..Point::new(1, 4),
+ primary: None,
+ },
+ ExcerptRange {
+ context: Point::new(2, 0)..Point::new(3, 2),
+ primary: None,
+ },
+ ],
+ cx,
+ );
+ multibuffer
+ });
+ let display_map =
+ cx.add_model(|cx| DisplayMap::new(multibuffer, font_id, 14.0, None, 2, 2, cx));
+ let snapshot = display_map.update(cx, |map, cx| map.snapshot(cx));
+
+ assert_eq!(snapshot.text(), "\n\nabc\ndefg\n\n\nhijkl\nmn");
+
+ let col_2_x = snapshot.x_for_point(DisplayPoint::new(2, 2), &text_layout_details);
+
+ // Can't move up into the first excerpt's header
+ assert_eq!(
+ up(
+ &snapshot,
+ DisplayPoint::new(2, 2),
+ SelectionGoal::HorizontalPosition(col_2_x),
+ false,
+ &text_layout_details
+ ),
+ (
+ DisplayPoint::new(2, 0),
+ SelectionGoal::HorizontalPosition(0.0)
+ ),
+ );
+ assert_eq!(
+ up(
+ &snapshot,
+ DisplayPoint::new(2, 0),
+ SelectionGoal::None,
+ false,
+ &text_layout_details
+ ),
+ (
+ DisplayPoint::new(2, 0),
+ SelectionGoal::HorizontalPosition(0.0)
+ ),
);
- multibuffer
- });
- let display_map =
- cx.add_model(|cx| DisplayMap::new(multibuffer, font_id, 14.0, None, 2, 2, cx));
- let snapshot = display_map.update(cx, |map, cx| map.snapshot(cx));
- assert_eq!(snapshot.text(), "\n\nabc\ndefg\n\n\nhijkl\nmn");
+ let col_4_x = snapshot.x_for_point(DisplayPoint::new(3, 4), &text_layout_details);
- // Can't move up into the first excerpt's header
- assert_eq!(
- up(
- &snapshot,
- DisplayPoint::new(2, 2),
- SelectionGoal::Column(2),
- false
- ),
- (DisplayPoint::new(2, 0), SelectionGoal::Column(0)),
- );
- assert_eq!(
- up(
- &snapshot,
- DisplayPoint::new(2, 0),
- SelectionGoal::None,
- false
- ),
- (DisplayPoint::new(2, 0), SelectionGoal::Column(0)),
- );
+ // Move up and down within first excerpt
+ assert_eq!(
+ up(
+ &snapshot,
+ DisplayPoint::new(3, 4),
+ SelectionGoal::HorizontalPosition(col_4_x),
+ false,
+ &text_layout_details
+ ),
+ (
+ DisplayPoint::new(2, 3),
+ SelectionGoal::HorizontalPosition(col_4_x)
+ ),
+ );
+ assert_eq!(
+ down(
+ &snapshot,
+ DisplayPoint::new(2, 3),
+ SelectionGoal::HorizontalPosition(col_4_x),
+ false,
+ &text_layout_details
+ ),
+ (
+ DisplayPoint::new(3, 4),
+ SelectionGoal::HorizontalPosition(col_4_x)
+ ),
+ );
- // Move up and down within first excerpt
- assert_eq!(
- up(
- &snapshot,
- DisplayPoint::new(3, 4),
- SelectionGoal::Column(4),
- false
- ),
- (DisplayPoint::new(2, 3), SelectionGoal::Column(4)),
- );
- assert_eq!(
- down(
- &snapshot,
- DisplayPoint::new(2, 3),
- SelectionGoal::Column(4),
- false
- ),
- (DisplayPoint::new(3, 4), SelectionGoal::Column(4)),
- );
+ let col_5_x = snapshot.x_for_point(DisplayPoint::new(6, 5), &text_layout_details);
- // Move up and down across second excerpt's header
- assert_eq!(
- up(
- &snapshot,
- DisplayPoint::new(6, 5),
- SelectionGoal::Column(5),
- false
- ),
- (DisplayPoint::new(3, 4), SelectionGoal::Column(5)),
- );
- assert_eq!(
- down(
- &snapshot,
- DisplayPoint::new(3, 4),
- SelectionGoal::Column(5),
- false
- ),
- (DisplayPoint::new(6, 5), SelectionGoal::Column(5)),
- );
+ // Move up and down across second excerpt's header
+ assert_eq!(
+ up(
+ &snapshot,
+ DisplayPoint::new(6, 5),
+ SelectionGoal::HorizontalPosition(col_5_x),
+ false,
+ &text_layout_details
+ ),
+ (
+ DisplayPoint::new(3, 4),
+ SelectionGoal::HorizontalPosition(col_5_x)
+ ),
+ );
+ assert_eq!(
+ down(
+ &snapshot,
+ DisplayPoint::new(3, 4),
+ SelectionGoal::HorizontalPosition(col_5_x),
+ false,
+ &text_layout_details
+ ),
+ (
+ DisplayPoint::new(6, 5),
+ SelectionGoal::HorizontalPosition(col_5_x)
+ ),
+ );
- // Can't move down off the end
- assert_eq!(
- down(
- &snapshot,
- DisplayPoint::new(7, 0),
- SelectionGoal::Column(0),
- false
- ),
- (DisplayPoint::new(7, 2), SelectionGoal::Column(2)),
- );
- assert_eq!(
- down(
- &snapshot,
- DisplayPoint::new(7, 2),
- SelectionGoal::Column(2),
- false
- ),
- (DisplayPoint::new(7, 2), SelectionGoal::Column(2)),
- );
+ let max_point_x = snapshot.x_for_point(DisplayPoint::new(7, 2), &text_layout_details);
+
+ // Can't move down off the end
+ assert_eq!(
+ down(
+ &snapshot,
+ DisplayPoint::new(7, 0),
+ SelectionGoal::HorizontalPosition(0.0),
+ false,
+ &text_layout_details
+ ),
+ (
+ DisplayPoint::new(7, 2),
+ SelectionGoal::HorizontalPosition(max_point_x)
+ ),
+ );
+ assert_eq!(
+ down(
+ &snapshot,
+ DisplayPoint::new(7, 2),
+ SelectionGoal::HorizontalPosition(max_point_x),
+ false,
+ &text_layout_details
+ ),
+ (
+ DisplayPoint::new(7, 2),
+ SelectionGoal::HorizontalPosition(max_point_x)
+ ),
+ );
+ });
}
fn init_test(cx: &mut gpui::AppContext) {
@@ -815,5 +922,6 @@ mod tests {
theme::init((), cx);
language::init(cx);
crate::init(cx);
+ Project::init_settings(cx);
}
}
@@ -1,6 +1,6 @@
use std::{
cell::Ref,
- cmp, iter, mem,
+ iter, mem,
ops::{Deref, DerefMut, Range, Sub},
sync::Arc,
};
@@ -13,6 +13,7 @@ use util::post_inc;
use crate::{
display_map::{DisplayMap, DisplaySnapshot, ToDisplayPoint},
+ movement::TextLayoutDetails,
Anchor, DisplayPoint, ExcerptId, MultiBuffer, MultiBufferSnapshot, SelectMode, ToOffset,
};
@@ -305,23 +306,29 @@ impl SelectionsCollection {
&mut self,
display_map: &DisplaySnapshot,
row: u32,
- columns: &Range<u32>,
+ positions: &Range<f32>,
reversed: bool,
+ text_layout_details: &TextLayoutDetails,
) -> Option<Selection<Point>> {
- let is_empty = columns.start == columns.end;
+ let is_empty = positions.start == positions.end;
let line_len = display_map.line_len(row);
- if columns.start < line_len || (is_empty && columns.start == line_len) {
- let start = DisplayPoint::new(row, columns.start);
- let end = DisplayPoint::new(row, cmp::min(columns.end, line_len));
+
+ let layed_out_line = display_map.lay_out_line_for_row(row, &text_layout_details);
+
+ let start_col = layed_out_line.closest_index_for_x(positions.start) as u32;
+ if start_col < line_len || (is_empty && positions.start == layed_out_line.width()) {
+ let start = DisplayPoint::new(row, start_col);
+ let end_col = layed_out_line.closest_index_for_x(positions.end) as u32;
+ let end = DisplayPoint::new(row, end_col);
Some(Selection {
id: post_inc(&mut self.next_selection_id),
start: start.to_point(display_map),
end: end.to_point(display_map),
reversed,
- goal: SelectionGoal::ColumnRange {
- start: columns.start,
- end: columns.end,
+ goal: SelectionGoal::HorizontalRange {
+ start: positions.start,
+ end: positions.end,
},
})
} else {
@@ -8,6 +8,7 @@ use crate::{
use gpui::{ModelHandle, ViewContext};
+use project::Project;
use util::test::{marked_text_offsets, marked_text_ranges};
#[cfg(test)]
@@ -63,9 +64,20 @@ pub fn assert_text_with_selections(
assert_eq!(editor.selections.ranges(cx), text_ranges);
}
+// RA thinks this is dead code even though it is used in a whole lot of tests
+#[allow(dead_code)]
+#[cfg(any(test, feature = "test-support"))]
pub(crate) fn build_editor(
buffer: ModelHandle<MultiBuffer>,
cx: &mut ViewContext<Editor>,
) -> Editor {
Editor::new(EditorMode::Full, buffer, None, None, cx)
}
+
+pub(crate) fn build_editor_with_project(
+ project: ModelHandle<Project>,
+ buffer: ModelHandle<MultiBuffer>,
+ cx: &mut ViewContext<Editor>,
+) -> Editor {
+ Editor::new(EditorMode::Full, buffer, Some(project), None, cx)
+}
@@ -6,18 +6,18 @@ use std::{
use anyhow::Result;
+use crate::{Editor, ToPoint};
use collections::HashSet;
use futures::Future;
use gpui::{json, ViewContext, ViewHandle};
use indoc::indoc;
use language::{point_to_lsp, FakeLspAdapter, Language, LanguageConfig, LanguageQueries};
use lsp::{notification, request};
+use multi_buffer::ToPointUtf16;
use project::Project;
use smol::stream::StreamExt;
use workspace::{AppState, Workspace, WorkspaceHandle};
-use crate::{multi_buffer::ToPointUtf16, Editor, ToPoint};
-
use super::editor_test_context::EditorTestContext;
pub struct EditorLspTestContext<'a> {
@@ -18,7 +18,7 @@ use util::{
test::{generate_marked_text, marked_text_ranges},
};
-use super::build_editor;
+use super::build_editor_with_project;
pub struct EditorTestContext<'a> {
pub cx: &'a mut gpui::TestAppContext,
@@ -29,13 +29,24 @@ pub struct EditorTestContext<'a> {
impl<'a> EditorTestContext<'a> {
pub async fn new(cx: &'a mut gpui::TestAppContext) -> EditorTestContext<'a> {
let fs = FakeFs::new(cx.background());
- let project = Project::test(fs, [], cx).await;
+ // fs.insert_file("/file", "".to_owned()).await;
+ fs.insert_tree(
+ "/root",
+ gpui::serde_json::json!({
+ "file": "",
+ }),
+ )
+ .await;
+ let project = Project::test(fs, ["/root".as_ref()], cx).await;
let buffer = project
- .update(cx, |project, cx| project.create_buffer("", None, cx))
+ .update(cx, |project, cx| {
+ project.open_local_buffer("/root/file", cx)
+ })
+ .await
.unwrap();
let window = cx.add_window(|cx| {
cx.focus_self();
- build_editor(MultiBuffer::build_from_buffer(buffer, cx), cx)
+ build_editor_with_project(project, MultiBuffer::build_from_buffer(buffer, cx), cx)
});
let editor = window.root(cx);
Self {
@@ -2,7 +2,8 @@ use std::{any::Any, cell::Cell, f32::INFINITY, ops::Range, rc::Rc};
use crate::{
json::{self, ToJson, Value},
- AnyElement, Axis, Element, ElementStateHandle, SizeConstraint, Vector2FExt, ViewContext,
+ AnyElement, Axis, Element, ElementStateHandle, SizeConstraint, TypeTag, Vector2FExt,
+ ViewContext,
};
use pathfinder_geometry::{
rect::RectF,
@@ -10,10 +11,10 @@ use pathfinder_geometry::{
};
use serde_json::json;
-#[derive(Default)]
struct ScrollState {
scroll_to: Cell<Option<usize>>,
scroll_position: Cell<f32>,
+ type_tag: TypeTag,
}
pub struct Flex<V> {
@@ -66,8 +67,14 @@ impl<V: 'static> Flex<V> {
where
Tag: 'static,
{
- let scroll_state = cx.default_element_state::<Tag, Rc<ScrollState>>(element_id);
- scroll_state.read(cx).scroll_to.set(scroll_to);
+ let scroll_state = cx.element_state::<Tag, Rc<ScrollState>>(
+ element_id,
+ Rc::new(ScrollState {
+ scroll_to: Cell::new(scroll_to),
+ scroll_position: Default::default(),
+ type_tag: TypeTag::new::<Tag>(),
+ }),
+ );
self.scroll_state = Some((scroll_state, cx.handle().id()));
self
}
@@ -276,38 +283,44 @@ impl<V: 'static> Element<V> for Flex<V> {
if let Some((scroll_state, id)) = &self.scroll_state {
let scroll_state = scroll_state.read(cx).clone();
cx.scene().push_mouse_region(
- crate::MouseRegion::new::<Self>(*id, 0, bounds)
- .on_scroll({
- let axis = self.axis;
- move |e, _: &mut V, cx| {
- if remaining_space < 0. {
- let scroll_delta = e.delta.raw();
-
- let mut delta = match axis {
- Axis::Horizontal => {
- if scroll_delta.x().abs() >= scroll_delta.y().abs() {
- scroll_delta.x()
- } else {
- scroll_delta.y()
- }
+ crate::MouseRegion::from_handlers(
+ scroll_state.type_tag,
+ *id,
+ 0,
+ bounds,
+ Default::default(),
+ )
+ .on_scroll({
+ let axis = self.axis;
+ move |e, _: &mut V, cx| {
+ if remaining_space < 0. {
+ let scroll_delta = e.delta.raw();
+
+ let mut delta = match axis {
+ Axis::Horizontal => {
+ if scroll_delta.x().abs() >= scroll_delta.y().abs() {
+ scroll_delta.x()
+ } else {
+ scroll_delta.y()
}
- Axis::Vertical => scroll_delta.y(),
- };
- if !e.delta.precise() {
- delta *= 20.;
}
+ Axis::Vertical => scroll_delta.y(),
+ };
+ if !e.delta.precise() {
+ delta *= 20.;
+ }
- scroll_state
- .scroll_position
- .set(scroll_state.scroll_position.get() - delta);
+ scroll_state
+ .scroll_position
+ .set(scroll_state.scroll_position.get() - delta);
- cx.notify();
- } else {
- cx.propagate_event();
- }
+ cx.notify();
+ } else {
+ cx.propagate_event();
}
- })
- .on_move(|_, _: &mut V, _| { /* Capture move events */ }),
+ }
+ })
+ .on_move(|_, _: &mut V, _| { /* Capture move events */ }),
)
}
@@ -30,7 +30,7 @@ struct StateInner<V> {
orientation: Orientation,
overdraw: f32,
#[allow(clippy::type_complexity)]
- scroll_handler: Option<Box<dyn FnMut(Range<usize>, &mut V, &mut ViewContext<V>)>>,
+ scroll_handler: Option<Box<dyn FnMut(Range<usize>, usize, &mut V, &mut ViewContext<V>)>>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
@@ -378,6 +378,10 @@ impl<V: 'static> ListState<V> {
.extend((0..element_count).map(|_| ListItem::Unrendered), &());
}
+ pub fn item_count(&self) -> usize {
+ self.0.borrow().items.summary().count
+ }
+
pub fn splice(&self, old_range: Range<usize>, count: usize) {
let state = &mut *self.0.borrow_mut();
@@ -416,7 +420,7 @@ impl<V: 'static> ListState<V> {
pub fn set_scroll_handler(
&mut self,
- handler: impl FnMut(Range<usize>, &mut V, &mut ViewContext<V>) + 'static,
+ handler: impl FnMut(Range<usize>, usize, &mut V, &mut ViewContext<V>) + 'static,
) {
self.0.borrow_mut().scroll_handler = Some(Box::new(handler))
}
@@ -529,7 +533,12 @@ impl<V: 'static> StateInner<V> {
if self.scroll_handler.is_some() {
let visible_range = self.visible_range(height, scroll_top);
- self.scroll_handler.as_mut().unwrap()(visible_range, view, cx);
+ self.scroll_handler.as_mut().unwrap()(
+ visible_range,
+ self.items.summary().count,
+ view,
+ cx,
+ );
}
cx.notify();
@@ -266,6 +266,8 @@ impl Line {
self.layout.len == 0
}
+ /// index_for_x returns the character containing the given x coordinate.
+ /// (e.g. to handle a mouse-click)
pub fn index_for_x(&self, x: f32) -> Option<usize> {
if x >= self.layout.width {
None
@@ -281,6 +283,28 @@ impl Line {
}
}
+ /// closest_index_for_x returns the character boundary closest to the given x coordinate
+ /// (e.g. to handle aligning up/down arrow keys)
+ pub fn closest_index_for_x(&self, x: f32) -> usize {
+ let mut prev_index = 0;
+ let mut prev_x = 0.0;
+
+ for run in self.layout.runs.iter() {
+ for glyph in run.glyphs.iter() {
+ if glyph.position.x() >= x {
+ if glyph.position.x() - x < x - prev_x {
+ return glyph.index;
+ } else {
+ return prev_index;
+ }
+ }
+ prev_index = glyph.index;
+ prev_x = glyph.position.x();
+ }
+ }
+ prev_index
+ }
+
pub fn paint(
&self,
origin: Vector2F,
@@ -45,6 +45,7 @@ lazy_static.workspace = true
log.workspace = true
parking_lot.workspace = true
postage.workspace = true
+pulldown-cmark = { version = "0.9.2", default-features = false }
regex.workspace = true
schemars.workspace = true
serde.workspace = true
@@ -1,11 +1,13 @@
pub use crate::{
diagnostic_set::DiagnosticSet,
highlight_map::{HighlightId, HighlightMap},
+ markdown::ParsedMarkdown,
proto, BracketPair, Grammar, Language, LanguageConfig, LanguageRegistry, PLAIN_TEXT,
};
use crate::{
diagnostic_set::{DiagnosticEntry, DiagnosticGroup},
language_settings::{language_settings, LanguageSettings},
+ markdown::parse_markdown,
outline::OutlineItem,
syntax_map::{
SyntaxLayerInfo, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxMapMatches,
@@ -143,11 +145,51 @@ pub struct Diagnostic {
pub is_unnecessary: bool,
}
+pub async fn prepare_completion_documentation(
+ documentation: &lsp::Documentation,
+ language_registry: &Arc<LanguageRegistry>,
+ language: Option<Arc<Language>>,
+) -> Documentation {
+ match documentation {
+ lsp::Documentation::String(text) => {
+ if text.lines().count() <= 1 {
+ Documentation::SingleLine(text.clone())
+ } else {
+ Documentation::MultiLinePlainText(text.clone())
+ }
+ }
+
+ lsp::Documentation::MarkupContent(lsp::MarkupContent { kind, value }) => match kind {
+ lsp::MarkupKind::PlainText => {
+ if value.lines().count() <= 1 {
+ Documentation::SingleLine(value.clone())
+ } else {
+ Documentation::MultiLinePlainText(value.clone())
+ }
+ }
+
+ lsp::MarkupKind::Markdown => {
+ let parsed = parse_markdown(value, language_registry, language).await;
+ Documentation::MultiLineMarkdown(parsed)
+ }
+ },
+ }
+}
+
+#[derive(Clone, Debug)]
+pub enum Documentation {
+ Undocumented,
+ SingleLine(String),
+ MultiLinePlainText(String),
+ MultiLineMarkdown(ParsedMarkdown),
+}
+
#[derive(Clone, Debug)]
pub struct Completion {
pub old_range: Range<Anchor>,
pub new_text: String,
pub label: CodeLabel,
+ pub documentation: Option<Documentation>,
pub server_id: LanguageServerId,
pub lsp_completion: lsp::CompletionItem,
}
@@ -159,7 +201,7 @@ pub struct CodeAction {
pub lsp_action: lsp::CodeAction,
}
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq)]
pub enum Operation {
Buffer(text::Operation),
@@ -182,7 +224,7 @@ pub enum Operation {
},
}
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq)]
pub enum Event {
Operation(Operation),
Edited,
@@ -331,8 +373,8 @@ pub(crate) struct DiagnosticEndpoint {
#[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Debug)]
pub enum CharKind {
- Punctuation,
Whitespace,
+ Punctuation,
Word,
}
@@ -1406,82 +1448,95 @@ impl Buffer {
return None;
}
- self.start_transaction();
- self.pending_autoindent.take();
- let autoindent_request = autoindent_mode
- .and_then(|mode| self.language.as_ref().map(|_| (self.snapshot(), mode)));
-
- let edit_operation = self.text.edit(edits.iter().cloned());
- let edit_id = edit_operation.timestamp();
+ // Non-generic part hoisted out to reduce LLVM IR size.
+ fn tail(
+ this: &mut Buffer,
+ edits: Vec<(Range<usize>, Arc<str>)>,
+ autoindent_mode: Option<AutoindentMode>,
+ cx: &mut ModelContext<Buffer>,
+ ) -> Option<clock::Lamport> {
+ this.start_transaction();
+ this.pending_autoindent.take();
+ let autoindent_request = autoindent_mode
+ .and_then(|mode| this.language.as_ref().map(|_| (this.snapshot(), mode)));
+
+ let edit_operation = this.text.edit(edits.iter().cloned());
+ let edit_id = edit_operation.timestamp();
+
+ if let Some((before_edit, mode)) = autoindent_request {
+ let mut delta = 0isize;
+ let entries = edits
+ .into_iter()
+ .enumerate()
+ .zip(&edit_operation.as_edit().unwrap().new_text)
+ .map(|((ix, (range, _)), new_text)| {
+ let new_text_length = new_text.len();
+ let old_start = range.start.to_point(&before_edit);
+ let new_start = (delta + range.start as isize) as usize;
+ delta +=
+ new_text_length as isize - (range.end as isize - range.start as isize);
+
+ let mut range_of_insertion_to_indent = 0..new_text_length;
+ let mut first_line_is_new = false;
+ let mut original_indent_column = None;
+
+ // When inserting an entire line at the beginning of an existing line,
+ // treat the insertion as new.
+ if new_text.contains('\n')
+ && old_start.column
+ <= before_edit.indent_size_for_line(old_start.row).len
+ {
+ first_line_is_new = true;
+ }
- if let Some((before_edit, mode)) = autoindent_request {
- let mut delta = 0isize;
- let entries = edits
- .into_iter()
- .enumerate()
- .zip(&edit_operation.as_edit().unwrap().new_text)
- .map(|((ix, (range, _)), new_text)| {
- let new_text_length = new_text.len();
- let old_start = range.start.to_point(&before_edit);
- let new_start = (delta + range.start as isize) as usize;
- delta += new_text_length as isize - (range.end as isize - range.start as isize);
-
- let mut range_of_insertion_to_indent = 0..new_text_length;
- let mut first_line_is_new = false;
- let mut original_indent_column = None;
-
- // When inserting an entire line at the beginning of an existing line,
- // treat the insertion as new.
- if new_text.contains('\n')
- && old_start.column <= before_edit.indent_size_for_line(old_start.row).len
- {
- first_line_is_new = true;
- }
+ // When inserting text starting with a newline, avoid auto-indenting the
+ // previous line.
+ if new_text.starts_with('\n') {
+ range_of_insertion_to_indent.start += 1;
+ first_line_is_new = true;
+ }
- // When inserting text starting with a newline, avoid auto-indenting the
- // previous line.
- if new_text.starts_with('\n') {
- range_of_insertion_to_indent.start += 1;
- first_line_is_new = true;
- }
+ // Avoid auto-indenting after the insertion.
+ if let AutoindentMode::Block {
+ original_indent_columns,
+ } = &mode
+ {
+ original_indent_column = Some(
+ original_indent_columns.get(ix).copied().unwrap_or_else(|| {
+ indent_size_for_text(
+ new_text[range_of_insertion_to_indent.clone()].chars(),
+ )
+ .len
+ }),
+ );
+ if new_text[range_of_insertion_to_indent.clone()].ends_with('\n') {
+ range_of_insertion_to_indent.end -= 1;
+ }
+ }
- // Avoid auto-indenting after the insertion.
- if let AutoindentMode::Block {
- original_indent_columns,
- } = &mode
- {
- original_indent_column =
- Some(original_indent_columns.get(ix).copied().unwrap_or_else(|| {
- indent_size_for_text(
- new_text[range_of_insertion_to_indent.clone()].chars(),
- )
- .len
- }));
- if new_text[range_of_insertion_to_indent.clone()].ends_with('\n') {
- range_of_insertion_to_indent.end -= 1;
+ AutoindentRequestEntry {
+ first_line_is_new,
+ original_indent_column,
+ indent_size: before_edit.language_indent_size_at(range.start, cx),
+ range: this
+ .anchor_before(new_start + range_of_insertion_to_indent.start)
+ ..this.anchor_after(new_start + range_of_insertion_to_indent.end),
}
- }
+ })
+ .collect();
- AutoindentRequestEntry {
- first_line_is_new,
- original_indent_column,
- indent_size: before_edit.language_indent_size_at(range.start, cx),
- range: self.anchor_before(new_start + range_of_insertion_to_indent.start)
- ..self.anchor_after(new_start + range_of_insertion_to_indent.end),
- }
- })
- .collect();
+ this.autoindent_requests.push(Arc::new(AutoindentRequest {
+ before_edit,
+ entries,
+ is_block_mode: matches!(mode, AutoindentMode::Block { .. }),
+ }));
+ }
- self.autoindent_requests.push(Arc::new(AutoindentRequest {
- before_edit,
- entries,
- is_block_mode: matches!(mode, AutoindentMode::Block { .. }),
- }));
+ this.end_transaction(cx);
+ this.send_operation(Operation::Buffer(edit_operation), cx);
+ Some(edit_id)
}
-
- self.end_transaction(cx);
- self.send_operation(Operation::Buffer(edit_operation), cx);
- Some(edit_id)
+ tail(self, edits, autoindent_mode, cx)
}
fn did_edit(
@@ -2,6 +2,7 @@ mod buffer;
mod diagnostic_set;
mod highlight_map;
pub mod language_settings;
+pub mod markdown;
mod outline;
pub mod proto;
mod syntax_map;
@@ -37,7 +38,7 @@ use std::{
path::{Path, PathBuf},
str,
sync::{
- atomic::{AtomicUsize, Ordering::SeqCst},
+ atomic::{AtomicU64, AtomicUsize, Ordering::SeqCst},
Arc,
},
};
@@ -110,18 +111,17 @@ pub struct LanguageServerName(pub Arc<str>);
pub struct CachedLspAdapter {
pub name: LanguageServerName,
pub short_name: &'static str,
- pub initialization_options: Option<Value>,
pub disk_based_diagnostic_sources: Vec<String>,
pub disk_based_diagnostics_progress_token: Option<String>,
pub language_ids: HashMap<String, String>,
pub adapter: Arc<dyn LspAdapter>,
+ pub reinstall_attempt_count: AtomicU64,
}
impl CachedLspAdapter {
pub async fn new(adapter: Arc<dyn LspAdapter>) -> Arc<Self> {
let name = adapter.name().await;
let short_name = adapter.short_name();
- let initialization_options = adapter.initialization_options().await;
let disk_based_diagnostic_sources = adapter.disk_based_diagnostic_sources().await;
let disk_based_diagnostics_progress_token =
adapter.disk_based_diagnostics_progress_token().await;
@@ -130,11 +130,11 @@ impl CachedLspAdapter {
Arc::new(CachedLspAdapter {
name,
short_name,
- initialization_options,
disk_based_diagnostic_sources,
disk_based_diagnostics_progress_token,
language_ids,
adapter,
+ reinstall_attempt_count: AtomicU64::new(0),
})
}
@@ -228,8 +228,8 @@ impl CachedLspAdapter {
self.adapter.label_for_symbol(name, kind, language).await
}
- pub fn enabled_formatters(&self) -> Vec<BundledFormatter> {
- self.adapter.enabled_formatters()
+ pub fn prettier_plugins(&self) -> &[&'static str] {
+ self.adapter.prettier_plugins()
}
}
@@ -338,31 +338,8 @@ pub trait LspAdapter: 'static + Send + Sync {
Default::default()
}
- fn enabled_formatters(&self) -> Vec<BundledFormatter> {
- Vec::new()
- }
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum BundledFormatter {
- Prettier {
- // See https://prettier.io/docs/en/options.html#parser for a list of valid values.
- // Usually, every language has a single parser (standard or plugin-provided), hence `Some("parser_name")` can be used.
- // There can not be multiple parsers for a single language, in case of a conflict, we would attempt to select the one with most plugins.
- //
- // But exceptions like Tailwind CSS exist, which uses standard parsers for CSS/JS/HTML/etc. but require an extra plugin to be installed.
- // For those cases, `None` will install the plugin but apply other, regular parser defined for the language, and this would not be a conflict.
- parser_name: Option<&'static str>,
- plugin_names: Vec<&'static str>,
- },
-}
-
-impl BundledFormatter {
- pub fn prettier(parser_name: &'static str) -> Self {
- Self::Prettier {
- parser_name: Some(parser_name),
- plugin_names: Vec::new(),
- }
+ fn prettier_plugins(&self) -> &[&'static str] {
+ &[]
}
}
@@ -400,6 +377,8 @@ pub struct LanguageConfig {
pub overrides: HashMap<String, LanguageConfigOverride>,
#[serde(default)]
pub word_characters: HashSet<char>,
+ #[serde(default)]
+ pub prettier_parser_name: Option<String>,
}
#[derive(Debug, Default)]
@@ -473,6 +452,7 @@ impl Default for LanguageConfig {
overrides: Default::default(),
collapsed_placeholder: Default::default(),
word_characters: Default::default(),
+ prettier_parser_name: None,
}
}
}
@@ -498,7 +478,7 @@ pub struct FakeLspAdapter {
pub initializer: Option<Box<dyn 'static + Send + Sync + Fn(&mut lsp::FakeLanguageServer)>>,
pub disk_based_diagnostics_progress_token: Option<String>,
pub disk_based_diagnostics_sources: Vec<String>,
- pub enabled_formatters: Vec<BundledFormatter>,
+ pub prettier_plugins: Vec<&'static str>,
}
#[derive(Clone, Debug, Default)]
@@ -667,7 +647,7 @@ struct LanguageRegistryState {
pub struct PendingLanguageServer {
pub server_id: LanguageServerId,
- pub task: Task<Result<Option<lsp::LanguageServer>>>,
+ pub task: Task<Result<lsp::LanguageServer>>,
pub container_dir: Option<Arc<Path>>,
}
@@ -906,6 +886,7 @@ impl LanguageRegistry {
pub fn create_pending_language_server(
self: &Arc<Self>,
+ stderr_capture: Arc<Mutex<Option<String>>>,
language: Arc<Language>,
adapter: Arc<CachedLspAdapter>,
root_path: Arc<Path>,
@@ -945,7 +926,7 @@ impl LanguageRegistry {
})
.detach();
- Ok(Some(server))
+ Ok(server)
});
return Some(PendingLanguageServer {
@@ -993,24 +974,23 @@ impl LanguageRegistry {
.clone();
drop(lock);
- let binary = match entry.clone().await.log_err() {
- Some(binary) => binary,
- None => return Ok(None),
+ let binary = match entry.clone().await {
+ Ok(binary) => binary,
+ Err(err) => anyhow::bail!("{err}"),
};
if let Some(task) = adapter.will_start_server(&delegate, &mut cx) {
- if task.await.log_err().is_none() {
- return Ok(None);
- }
+ task.await?;
}
- Ok(Some(lsp::LanguageServer::new(
+ lsp::LanguageServer::new(
+ stderr_capture,
server_id,
binary,
&root_path,
adapter.code_action_kinds(),
cx,
- )?))
+ )
})
};
@@ -1599,6 +1579,10 @@ impl Language {
override_id: None,
}
}
+
+ pub fn prettier_parser_name(&self) -> Option<&str> {
+ self.config.prettier_parser_name.as_deref()
+ }
}
impl LanguageScope {
@@ -1761,7 +1745,7 @@ impl Default for FakeLspAdapter {
disk_based_diagnostics_progress_token: None,
initialization_options: None,
disk_based_diagnostics_sources: Vec::new(),
- enabled_formatters: Vec::new(),
+ prettier_plugins: Vec::new(),
}
}
}
@@ -1819,8 +1803,8 @@ impl LspAdapter for Arc<FakeLspAdapter> {
self.initialization_options.clone()
}
- fn enabled_formatters(&self) -> Vec<BundledFormatter> {
- self.enabled_formatters.clone()
+ fn prettier_plugins(&self) -> &[&'static str] {
+ &self.prettier_plugins
}
}
@@ -0,0 +1,301 @@
+use std::sync::Arc;
+use std::{ops::Range, path::PathBuf};
+
+use crate::{HighlightId, Language, LanguageRegistry};
+use gpui::fonts::{self, HighlightStyle, Weight};
+use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag};
+
+#[derive(Debug, Clone)]
+pub struct ParsedMarkdown {
+ pub text: String,
+ pub highlights: Vec<(Range<usize>, MarkdownHighlight)>,
+ pub region_ranges: Vec<Range<usize>>,
+ pub regions: Vec<ParsedRegion>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum MarkdownHighlight {
+ Style(MarkdownHighlightStyle),
+ Code(HighlightId),
+}
+
+impl MarkdownHighlight {
+ pub fn to_highlight_style(&self, theme: &theme::SyntaxTheme) -> Option<HighlightStyle> {
+ match self {
+ MarkdownHighlight::Style(style) => {
+ let mut highlight = HighlightStyle::default();
+
+ if style.italic {
+ highlight.italic = Some(true);
+ }
+
+ if style.underline {
+ highlight.underline = Some(fonts::Underline {
+ thickness: 1.0.into(),
+ ..Default::default()
+ });
+ }
+
+ if style.weight != fonts::Weight::default() {
+ highlight.weight = Some(style.weight);
+ }
+
+ Some(highlight)
+ }
+
+ MarkdownHighlight::Code(id) => id.style(theme),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct MarkdownHighlightStyle {
+ pub italic: bool,
+ pub underline: bool,
+ pub weight: Weight,
+}
+
+#[derive(Debug, Clone)]
+pub struct ParsedRegion {
+ pub code: bool,
+ pub link: Option<Link>,
+}
+
+#[derive(Debug, Clone)]
+pub enum Link {
+ Web { url: String },
+ Path { path: PathBuf },
+}
+
+impl Link {
+ fn identify(text: String) -> Option<Link> {
+ if text.starts_with("http") {
+ return Some(Link::Web { url: text });
+ }
+
+ let path = PathBuf::from(text);
+ if path.is_absolute() {
+ return Some(Link::Path { path });
+ }
+
+ None
+ }
+}
+
+pub async fn parse_markdown(
+ markdown: &str,
+ language_registry: &Arc<LanguageRegistry>,
+ language: Option<Arc<Language>>,
+) -> ParsedMarkdown {
+ let mut text = String::new();
+ let mut highlights = Vec::new();
+ let mut region_ranges = Vec::new();
+ let mut regions = Vec::new();
+
+ parse_markdown_block(
+ markdown,
+ language_registry,
+ language,
+ &mut text,
+ &mut highlights,
+ &mut region_ranges,
+ &mut regions,
+ )
+ .await;
+
+ ParsedMarkdown {
+ text,
+ highlights,
+ region_ranges,
+ regions,
+ }
+}
+
+pub async fn parse_markdown_block(
+ markdown: &str,
+ language_registry: &Arc<LanguageRegistry>,
+ language: Option<Arc<Language>>,
+ text: &mut String,
+ highlights: &mut Vec<(Range<usize>, MarkdownHighlight)>,
+ region_ranges: &mut Vec<Range<usize>>,
+ regions: &mut Vec<ParsedRegion>,
+) {
+ let mut bold_depth = 0;
+ let mut italic_depth = 0;
+ let mut link_url = None;
+ let mut current_language = None;
+ let mut list_stack = Vec::new();
+
+ for event in Parser::new_ext(&markdown, Options::all()) {
+ let prev_len = text.len();
+ match event {
+ Event::Text(t) => {
+ if let Some(language) = ¤t_language {
+ highlight_code(text, highlights, t.as_ref(), language);
+ } else {
+ text.push_str(t.as_ref());
+
+ let mut style = MarkdownHighlightStyle::default();
+
+ if bold_depth > 0 {
+ style.weight = Weight::BOLD;
+ }
+
+ if italic_depth > 0 {
+ style.italic = true;
+ }
+
+ if let Some(link) = link_url.clone().and_then(|u| Link::identify(u)) {
+ region_ranges.push(prev_len..text.len());
+ regions.push(ParsedRegion {
+ code: false,
+ link: Some(link),
+ });
+ style.underline = true;
+ }
+
+ if style != MarkdownHighlightStyle::default() {
+ let mut new_highlight = true;
+ if let Some((last_range, MarkdownHighlight::Style(last_style))) =
+ highlights.last_mut()
+ {
+ if last_range.end == prev_len && last_style == &style {
+ last_range.end = text.len();
+ new_highlight = false;
+ }
+ }
+ if new_highlight {
+ let range = prev_len..text.len();
+ highlights.push((range, MarkdownHighlight::Style(style)));
+ }
+ }
+ }
+ }
+
+ Event::Code(t) => {
+ text.push_str(t.as_ref());
+ region_ranges.push(prev_len..text.len());
+
+ let link = link_url.clone().and_then(|u| Link::identify(u));
+ if link.is_some() {
+ highlights.push((
+ prev_len..text.len(),
+ MarkdownHighlight::Style(MarkdownHighlightStyle {
+ underline: true,
+ ..Default::default()
+ }),
+ ));
+ }
+ regions.push(ParsedRegion { code: true, link });
+ }
+
+ Event::Start(tag) => match tag {
+ Tag::Paragraph => new_paragraph(text, &mut list_stack),
+
+ Tag::Heading(_, _, _) => {
+ new_paragraph(text, &mut list_stack);
+ bold_depth += 1;
+ }
+
+ Tag::CodeBlock(kind) => {
+ new_paragraph(text, &mut list_stack);
+ current_language = if let CodeBlockKind::Fenced(language) = kind {
+ language_registry
+ .language_for_name(language.as_ref())
+ .await
+ .ok()
+ } else {
+ language.clone()
+ }
+ }
+
+ Tag::Emphasis => italic_depth += 1,
+
+ Tag::Strong => bold_depth += 1,
+
+ Tag::Link(_, url, _) => link_url = Some(url.to_string()),
+
+ Tag::List(number) => {
+ list_stack.push((number, false));
+ }
+
+ Tag::Item => {
+ let len = list_stack.len();
+ if let Some((list_number, has_content)) = list_stack.last_mut() {
+ *has_content = false;
+ if !text.is_empty() && !text.ends_with('\n') {
+ text.push('\n');
+ }
+ for _ in 0..len - 1 {
+ text.push_str(" ");
+ }
+ if let Some(number) = list_number {
+ text.push_str(&format!("{}. ", number));
+ *number += 1;
+ *has_content = false;
+ } else {
+ text.push_str("- ");
+ }
+ }
+ }
+
+ _ => {}
+ },
+
+ Event::End(tag) => match tag {
+ Tag::Heading(_, _, _) => bold_depth -= 1,
+ Tag::CodeBlock(_) => current_language = None,
+ Tag::Emphasis => italic_depth -= 1,
+ Tag::Strong => bold_depth -= 1,
+ Tag::Link(_, _, _) => link_url = None,
+ Tag::List(_) => drop(list_stack.pop()),
+ _ => {}
+ },
+
+ Event::HardBreak => text.push('\n'),
+
+ Event::SoftBreak => text.push(' '),
+
+ _ => {}
+ }
+ }
+}
+
+pub fn highlight_code(
+ text: &mut String,
+ highlights: &mut Vec<(Range<usize>, MarkdownHighlight)>,
+ content: &str,
+ language: &Arc<Language>,
+) {
+ let prev_len = text.len();
+ text.push_str(content);
+ for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) {
+ let highlight = MarkdownHighlight::Code(highlight_id);
+ highlights.push((prev_len + range.start..prev_len + range.end, highlight));
+ }
+}
+
+pub fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option<u64>, bool)>) {
+ let mut is_subsequent_paragraph_of_list = false;
+ if let Some((_, has_content)) = list_stack.last_mut() {
+ if *has_content {
+ is_subsequent_paragraph_of_list = true;
+ } else {
+ *has_content = true;
+ return;
+ }
+ }
+
+ if !text.is_empty() {
+ if !text.ends_with('\n') {
+ text.push('\n');
+ }
+ text.push('\n');
+ }
+ for _ in 0..list_stack.len().saturating_sub(1) {
+ text.push_str(" ");
+ }
+ if is_subsequent_paragraph_of_list {
+ text.push_str(" ");
+ }
+}
@@ -482,6 +482,7 @@ pub async fn deserialize_completion(
lsp_completion.filter_text.as_deref(),
)
}),
+ documentation: None,
server_id: LanguageServerId(completion.server_id as usize),
lsp_completion,
})
@@ -1,5 +1,5 @@
-use collections::HashMap;
-use editor::Editor;
+use collections::{HashMap, VecDeque};
+use editor::{Editor, MoveToEnd};
use futures::{channel::mpsc, StreamExt};
use gpui::{
actions,
@@ -11,7 +11,7 @@ use gpui::{
AnyElement, AppContext, Element, Entity, ModelContext, ModelHandle, Subscription, View,
ViewContext, ViewHandle, WeakModelHandle,
};
-use language::{Buffer, LanguageServerId, LanguageServerName};
+use language::{LanguageServerId, LanguageServerName};
use lsp::IoKind;
use project::{search::SearchQuery, Project};
use std::{borrow::Cow, sync::Arc};
@@ -22,8 +22,9 @@ use workspace::{
ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceCreated,
};
-const SEND_LINE: &str = "// Send:\n";
-const RECEIVE_LINE: &str = "// Receive:\n";
+const SEND_LINE: &str = "// Send:";
+const RECEIVE_LINE: &str = "// Receive:";
+const MAX_STORED_LOG_ENTRIES: usize = 2000;
pub struct LogStore {
projects: HashMap<WeakModelHandle<Project>, ProjectState>,
@@ -36,24 +37,25 @@ struct ProjectState {
}
struct LanguageServerState {
- log_buffer: ModelHandle<Buffer>,
+ log_messages: VecDeque<String>,
rpc_state: Option<LanguageServerRpcState>,
_io_logs_subscription: Option<lsp::Subscription>,
_lsp_logs_subscription: Option<lsp::Subscription>,
}
struct LanguageServerRpcState {
- buffer: ModelHandle<Buffer>,
+ rpc_messages: VecDeque<String>,
last_message_kind: Option<MessageKind>,
}
pub struct LspLogView {
pub(crate) editor: ViewHandle<Editor>,
+ editor_subscription: Subscription,
log_store: ModelHandle<LogStore>,
current_server_id: Option<LanguageServerId>,
is_showing_rpc_trace: bool,
project: ModelHandle<Project>,
- _log_store_subscription: Subscription,
+ _log_store_subscriptions: Vec<Subscription>,
}
pub struct LspLogToolbarItemView {
@@ -122,10 +124,9 @@ impl LogStore {
io_tx,
};
cx.spawn_weak(|this, mut cx| async move {
- while let Some((project, server_id, io_kind, mut message)) = io_rx.next().await {
+ while let Some((project, server_id, io_kind, message)) = io_rx.next().await {
if let Some(this) = this.upgrade(&cx) {
this.update(&mut cx, |this, cx| {
- message.push('\n');
this.on_io(project, server_id, io_kind, &message, cx);
});
}
@@ -168,15 +169,13 @@ impl LogStore {
project: &ModelHandle<Project>,
id: LanguageServerId,
cx: &mut ModelContext<Self>,
- ) -> Option<ModelHandle<Buffer>> {
+ ) -> Option<&mut LanguageServerState> {
let project_state = self.projects.get_mut(&project.downgrade())?;
let server_state = project_state.servers.entry(id).or_insert_with(|| {
cx.notify();
LanguageServerState {
rpc_state: None,
- log_buffer: cx
- .add_model(|cx| Buffer::new(0, cx.model_id() as u64, ""))
- .clone(),
+ log_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES),
_io_logs_subscription: None,
_lsp_logs_subscription: None,
}
@@ -186,7 +185,7 @@ impl LogStore {
if let Some(server) = server.as_deref() {
if server.has_notification_handler::<lsp::notification::LogMessage>() {
// Another event wants to re-add the server that was already added and subscribed to, avoid doing it again.
- return Some(server_state.log_buffer.clone());
+ return Some(server_state);
}
}
@@ -215,7 +214,7 @@ impl LogStore {
}
})
});
- Some(server_state.log_buffer.clone())
+ Some(server_state)
}
fn add_language_server_log(
@@ -225,24 +224,26 @@ impl LogStore {
message: &str,
cx: &mut ModelContext<Self>,
) -> Option<()> {
- let buffer = match self
+ let language_server_state = match self
.projects
.get_mut(&project.downgrade())?
.servers
- .get(&id)
- .map(|state| state.log_buffer.clone())
+ .get_mut(&id)
{
- Some(existing_buffer) => existing_buffer,
+ Some(existing_state) => existing_state,
None => self.add_language_server(&project, id, cx)?,
};
- buffer.update(cx, |buffer, cx| {
- let len = buffer.len();
- let has_newline = message.ends_with("\n");
- buffer.edit([(len..len, message)], None, cx);
- if !has_newline {
- let len = buffer.len();
- buffer.edit([(len..len, "\n")], None, cx);
- }
+
+ let log_lines = &mut language_server_state.log_messages;
+ while log_lines.len() >= MAX_STORED_LOG_ENTRIES {
+ log_lines.pop_front();
+ }
+ let message = message.trim();
+ log_lines.push_back(message.to_string());
+ cx.emit(Event::NewServerLogEntry {
+ id,
+ entry: message.to_string(),
+ is_rpc: false,
});
cx.notify();
Some(())
@@ -260,46 +261,32 @@ impl LogStore {
Some(())
}
- pub fn log_buffer_for_server(
+ fn server_logs(
&self,
project: &ModelHandle<Project>,
server_id: LanguageServerId,
- ) -> Option<ModelHandle<Buffer>> {
+ ) -> Option<&VecDeque<String>> {
let weak_project = project.downgrade();
let project_state = self.projects.get(&weak_project)?;
let server_state = project_state.servers.get(&server_id)?;
- Some(server_state.log_buffer.clone())
+ Some(&server_state.log_messages)
}
fn enable_rpc_trace_for_language_server(
&mut self,
project: &ModelHandle<Project>,
server_id: LanguageServerId,
- cx: &mut ModelContext<Self>,
- ) -> Option<ModelHandle<Buffer>> {
+ ) -> Option<&mut LanguageServerRpcState> {
let weak_project = project.downgrade();
let project_state = self.projects.get_mut(&weak_project)?;
let server_state = project_state.servers.get_mut(&server_id)?;
- let rpc_state = server_state.rpc_state.get_or_insert_with(|| {
- let language = project.read(cx).languages().language_for_name("JSON");
- let buffer = cx.add_model(|cx| Buffer::new(0, cx.model_id() as u64, ""));
- cx.spawn_weak({
- let buffer = buffer.clone();
- |_, mut cx| async move {
- let language = language.await.ok();
- buffer.update(&mut cx, |buffer, cx| {
- buffer.set_language(language, cx);
- });
- }
- })
- .detach();
-
- LanguageServerRpcState {
- buffer,
+ let rpc_state = server_state
+ .rpc_state
+ .get_or_insert_with(|| LanguageServerRpcState {
+ rpc_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES),
last_message_kind: None,
- }
- });
- Some(rpc_state.buffer.clone())
+ });
+ Some(rpc_state)
}
pub fn disable_rpc_trace_for_language_server(
@@ -328,7 +315,7 @@ impl LogStore {
IoKind::StdIn => false,
IoKind::StdErr => {
let project = project.upgrade(cx)?;
- let message = format!("stderr: {}\n", message.trim());
+ let message = format!("stderr: {}", message.trim());
self.add_language_server_log(&project, language_server_id, &message, cx);
return Some(());
}
@@ -341,24 +328,37 @@ impl LogStore {
.get_mut(&language_server_id)?
.rpc_state
.as_mut()?;
- state.buffer.update(cx, |buffer, cx| {
- let kind = if is_received {
- MessageKind::Receive
- } else {
- MessageKind::Send
+ let kind = if is_received {
+ MessageKind::Receive
+ } else {
+ MessageKind::Send
+ };
+
+ let rpc_log_lines = &mut state.rpc_messages;
+ if state.last_message_kind != Some(kind) {
+ let line_before_message = match kind {
+ MessageKind::Send => SEND_LINE,
+ MessageKind::Receive => RECEIVE_LINE,
};
- if state.last_message_kind != Some(kind) {
- let len = buffer.len();
- let line = match kind {
- MessageKind::Send => SEND_LINE,
- MessageKind::Receive => RECEIVE_LINE,
- };
- buffer.edit([(len..len, line)], None, cx);
- state.last_message_kind = Some(kind);
- }
- let len = buffer.len();
- buffer.edit([(len..len, message)], None, cx);
+ rpc_log_lines.push_back(line_before_message.to_string());
+ cx.emit(Event::NewServerLogEntry {
+ id: language_server_id,
+ entry: line_before_message.to_string(),
+ is_rpc: true,
+ });
+ }
+
+ while rpc_log_lines.len() >= MAX_STORED_LOG_ENTRIES {
+ rpc_log_lines.pop_front();
+ }
+ let message = message.trim();
+ rpc_log_lines.push_back(message.to_string());
+ cx.emit(Event::NewServerLogEntry {
+ id: language_server_id,
+ entry: message.to_string(),
+ is_rpc: true,
});
+ cx.notify();
Some(())
}
}
@@ -374,8 +374,7 @@ impl LspLogView {
.projects
.get(&project.downgrade())
.and_then(|project| project.servers.keys().copied().next());
- let buffer = cx.add_model(|cx| Buffer::new(0, cx.model_id() as u64, ""));
- let _log_store_subscription = cx.observe(&log_store, |this, store, cx| {
+ let model_changes_subscription = cx.observe(&log_store, |this, store, cx| {
(|| -> Option<()> {
let project_state = store.read(cx).projects.get(&this.project.downgrade())?;
if let Some(current_lsp) = this.current_server_id {
@@ -411,13 +410,31 @@ impl LspLogView {
cx.notify();
});
+ let events_subscriptions = cx.subscribe(&log_store, |log_view, _, e, cx| match e {
+ Event::NewServerLogEntry { id, entry, is_rpc } => {
+ if log_view.current_server_id == Some(*id) {
+ if (*is_rpc && log_view.is_showing_rpc_trace)
+ || (!*is_rpc && !log_view.is_showing_rpc_trace)
+ {
+ log_view.editor.update(cx, |editor, cx| {
+ editor.set_read_only(false);
+ editor.handle_input(entry.trim(), cx);
+ editor.handle_input("\n", cx);
+ editor.set_read_only(true);
+ });
+ }
+ }
+ }
+ });
+ let (editor, editor_subscription) = Self::editor_for_logs(String::new(), cx);
let mut this = Self {
- editor: Self::editor_for_buffer(project.clone(), buffer, cx),
+ editor,
+ editor_subscription,
project,
log_store,
current_server_id: None,
is_showing_rpc_trace: false,
- _log_store_subscription,
+ _log_store_subscriptions: vec![model_changes_subscription, events_subscriptions],
};
if let Some(server_id) = server_id {
this.show_logs_for_server(server_id, cx);
@@ -425,20 +442,19 @@ impl LspLogView {
this
}
- fn editor_for_buffer(
- project: ModelHandle<Project>,
- buffer: ModelHandle<Buffer>,
+ fn editor_for_logs(
+ log_contents: String,
cx: &mut ViewContext<Self>,
- ) -> ViewHandle<Editor> {
+ ) -> (ViewHandle<Editor>, Subscription) {
let editor = cx.add_view(|cx| {
- let mut editor = Editor::for_buffer(buffer, Some(project), cx);
+ let mut editor = Editor::multi_line(None, cx);
+ editor.set_text(log_contents, cx);
+ editor.move_to_end(&MoveToEnd, cx);
editor.set_read_only(true);
- editor.move_to_end(&Default::default(), cx);
editor
});
- cx.subscribe(&editor, |_, _, event, cx| cx.emit(event.clone()))
- .detach();
- editor
+ let editor_subscription = cx.subscribe(&editor, |_, _, event, cx| cx.emit(event.clone()));
+ (editor, editor_subscription)
}
pub(crate) fn menu_items<'a>(&'a self, cx: &'a AppContext) -> Option<Vec<LogMenuItem>> {
@@ -487,14 +503,17 @@ impl LspLogView {
}
fn show_logs_for_server(&mut self, server_id: LanguageServerId, cx: &mut ViewContext<Self>) {
- let buffer = self
+ let log_contents = self
.log_store
.read(cx)
- .log_buffer_for_server(&self.project, server_id);
- if let Some(buffer) = buffer {
+ .server_logs(&self.project, server_id)
+ .map(log_contents);
+ if let Some(log_contents) = log_contents {
self.current_server_id = Some(server_id);
self.is_showing_rpc_trace = false;
- self.editor = Self::editor_for_buffer(self.project.clone(), buffer, cx);
+ let (editor, editor_subscription) = Self::editor_for_logs(log_contents, cx);
+ self.editor = editor;
+ self.editor_subscription = editor_subscription;
cx.notify();
}
}
@@ -504,13 +523,37 @@ impl LspLogView {
server_id: LanguageServerId,
cx: &mut ViewContext<Self>,
) {
- let buffer = self.log_store.update(cx, |log_set, cx| {
- log_set.enable_rpc_trace_for_language_server(&self.project, server_id, cx)
+ let rpc_log = self.log_store.update(cx, |log_store, _| {
+ log_store
+ .enable_rpc_trace_for_language_server(&self.project, server_id)
+ .map(|state| log_contents(&state.rpc_messages))
});
- if let Some(buffer) = buffer {
+ if let Some(rpc_log) = rpc_log {
self.current_server_id = Some(server_id);
self.is_showing_rpc_trace = true;
- self.editor = Self::editor_for_buffer(self.project.clone(), buffer, cx);
+ let (editor, editor_subscription) = Self::editor_for_logs(rpc_log, cx);
+ let language = self.project.read(cx).languages().language_for_name("JSON");
+ editor
+ .read(cx)
+ .buffer()
+ .read(cx)
+ .as_singleton()
+ .expect("log buffer should be a singleton")
+ .update(cx, |_, cx| {
+ cx.spawn_weak({
+ let buffer = cx.handle();
+ |_, mut cx| async move {
+ let language = language.await.ok();
+ buffer.update(&mut cx, |buffer, cx| {
+ buffer.set_language(language, cx);
+ });
+ }
+ })
+ .detach();
+ });
+
+ self.editor = editor;
+ self.editor_subscription = editor_subscription;
cx.notify();
}
}
@@ -523,7 +566,7 @@ impl LspLogView {
) {
self.log_store.update(cx, |log_store, cx| {
if enabled {
- log_store.enable_rpc_trace_for_language_server(&self.project, server_id, cx);
+ log_store.enable_rpc_trace_for_language_server(&self.project, server_id);
} else {
log_store.disable_rpc_trace_for_language_server(&self.project, server_id, cx);
}
@@ -535,6 +578,16 @@ impl LspLogView {
}
}
+fn log_contents(lines: &VecDeque<String>) -> String {
+ let (a, b) = lines.as_slices();
+ let log_contents = a.join("\n");
+ if b.is_empty() {
+ log_contents
+ } else {
+ log_contents + "\n" + &b.join("\n")
+ }
+}
+
impl View for LspLogView {
fn ui_name() -> &'static str {
"LspLogView"
@@ -685,6 +738,7 @@ impl View for LspLogToolbarItemView {
});
let server_selected = current_server.is_some();
+ enum LspLogScroll {}
enum Menu {}
let lsp_menu = Stack::new()
.with_child(Self::render_language_server_menu_header(
@@ -697,7 +751,7 @@ impl View for LspLogToolbarItemView {
Overlay::new(
MouseEventHandler::new::<Menu, _>(0, cx, move |_, cx| {
Flex::column()
- .scrollable::<Self>(0, None, cx)
+ .scrollable::<LspLogScroll>(0, None, cx)
.with_children(menu_rows.into_iter().map(|row| {
Self::render_language_server_menu_item(
row.server_id,
@@ -876,6 +930,7 @@ impl LspLogToolbarItemView {
) -> impl Element<Self> {
enum ActivateLog {}
enum ActivateRpcTrace {}
+ enum LanguageServerCheckbox {}
Flex::column()
.with_child({
@@ -921,7 +976,7 @@ impl LspLogToolbarItemView {
.with_height(theme.toolbar_dropdown_menu.row_height),
)
.with_child(
- ui::checkbox_with_label::<Self, _, Self, _>(
+ ui::checkbox_with_label::<LanguageServerCheckbox, _, Self, _>(
Empty::new(),
&theme.welcome.checkbox,
rpc_trace_enabled,
@@ -947,8 +1002,16 @@ impl LspLogToolbarItemView {
}
}
+pub enum Event {
+ NewServerLogEntry {
+ id: LanguageServerId,
+ entry: String,
+ is_rpc: bool,
+ },
+}
+
impl Entity for LogStore {
- type Event = ();
+ type Event = Event;
}
impl Entity for LspLogView {
@@ -306,6 +306,16 @@ impl live_kit_server::api::Client for TestApiClient {
token::VideoGrant::to_join(room),
)
}
+
+ fn guest_token(&self, room: &str, identity: &str) -> Result<String> {
+ let server = TestServer::get(&self.url)?;
+ token::create(
+ &server.api_key,
+ &server.secret_key,
+ Some(identity),
+ token::VideoGrant::for_guest(room),
+ )
+ }
}
pub type Sid = String;
@@ -12,6 +12,7 @@ pub trait Client: Send + Sync {
async fn delete_room(&self, name: String) -> Result<()>;
async fn remove_participant(&self, room: String, identity: String) -> Result<()>;
fn room_token(&self, room: &str, identity: &str) -> Result<String>;
+ fn guest_token(&self, room: &str, identity: &str) -> Result<String>;
}
#[derive(Clone)]
@@ -138,4 +139,13 @@ impl Client for LiveKitClient {
token::VideoGrant::to_join(room),
)
}
+
+ fn guest_token(&self, room: &str, identity: &str) -> Result<String> {
+ token::create(
+ &self.key,
+ &self.secret,
+ Some(identity),
+ token::VideoGrant::for_guest(room),
+ )
+ }
}
@@ -57,6 +57,15 @@ impl<'a> VideoGrant<'a> {
..Default::default()
}
}
+
+ pub fn for_guest(room: &'a str) -> Self {
+ Self {
+ room: Some(Cow::Borrowed(room)),
+ room_join: Some(true),
+ can_subscribe: Some(true),
+ ..Default::default()
+ }
+ }
}
pub fn create(
@@ -136,6 +136,7 @@ struct Error {
impl LanguageServer {
pub fn new(
+ stderr_capture: Arc<Mutex<Option<String>>>,
server_id: LanguageServerId,
binary: LanguageServerBinary,
root_path: &Path,
@@ -165,6 +166,7 @@ impl LanguageServer {
stdin,
stdout,
Some(stderr),
+ stderr_capture,
Some(server),
root_path,
code_action_kinds,
@@ -197,6 +199,7 @@ impl LanguageServer {
stdin: Stdin,
stdout: Stdout,
stderr: Option<Stderr>,
+ stderr_capture: Arc<Mutex<Option<String>>>,
server: Option<Child>,
root_path: &Path,
code_action_kinds: Option<Vec<CodeActionKind>>,
@@ -218,20 +221,23 @@ impl LanguageServer {
let io_handlers = Arc::new(Mutex::new(HashMap::default()));
let stdout_input_task = cx.spawn(|cx| {
- {
- Self::handle_input(
- stdout,
- on_unhandled_notification.clone(),
- notification_handlers.clone(),
- response_handlers.clone(),
- io_handlers.clone(),
- cx,
- )
- }
+ Self::handle_input(
+ stdout,
+ on_unhandled_notification.clone(),
+ notification_handlers.clone(),
+ response_handlers.clone(),
+ io_handlers.clone(),
+ cx,
+ )
.log_err()
});
let stderr_input_task = stderr
- .map(|stderr| cx.spawn(|_| Self::handle_stderr(stderr, io_handlers.clone()).log_err()))
+ .map(|stderr| {
+ cx.spawn(|_| {
+ Self::handle_stderr(stderr, io_handlers.clone(), stderr_capture.clone())
+ .log_err()
+ })
+ })
.unwrap_or_else(|| Task::Ready(Some(None)));
let input_task = cx.spawn(|_| async move {
let (stdout, stderr) = futures::join!(stdout_input_task, stderr_input_task);
@@ -353,12 +359,14 @@ impl LanguageServer {
async fn handle_stderr<Stderr>(
stderr: Stderr,
io_handlers: Arc<Mutex<HashMap<usize, IoHandler>>>,
+ stderr_capture: Arc<Mutex<Option<String>>>,
) -> anyhow::Result<()>
where
Stderr: AsyncRead + Unpin + Send + 'static,
{
let mut stderr = BufReader::new(stderr);
let mut buffer = Vec::new();
+
loop {
buffer.clear();
stderr.read_until(b'\n', &mut buffer).await?;
@@ -367,6 +375,10 @@ impl LanguageServer {
for handler in io_handlers.lock().values_mut() {
handler(IoKind::StdErr, message);
}
+
+ if let Some(stderr) = stderr_capture.lock().as_mut() {
+ stderr.push_str(message);
+ }
}
// Don't starve the main thread when receiving lots of messages at once.
@@ -466,7 +478,10 @@ impl LanguageServer {
completion_item: Some(CompletionItemCapability {
snippet_support: Some(true),
resolve_support: Some(CompletionItemCapabilityResolveSupport {
- properties: vec!["additionalTextEdits".to_string()],
+ properties: vec![
+ "documentation".to_string(),
+ "additionalTextEdits".to_string(),
+ ],
}),
..Default::default()
}),
@@ -748,6 +763,15 @@ impl LanguageServer {
)
}
+ // some child of string literal (be it "" or ``) which is the child of an attribute
+
+ // <Foo className="bar" />
+ // <Foo className={`bar`} />
+ // <Foo className={something + "bar"} />
+ // <Foo className={something + "bar"} />
+ // const classes = "awesome ";
+ // <Foo className={classes} />
+
fn request_internal<T: request::Request>(
next_id: &AtomicUsize,
response_handlers: &Mutex<Option<HashMap<usize, ResponseHandler>>>,
@@ -926,6 +950,7 @@ impl LanguageServer {
stdin_writer,
stdout_reader,
None::<async_pipe::PipeReader>,
+ Arc::new(Mutex::new(None)),
None,
Path::new("/"),
None,
@@ -938,6 +963,7 @@ impl LanguageServer {
stdout_writer,
stdin_reader,
None::<async_pipe::PipeReader>,
+ Arc::new(Mutex::new(None)),
None,
Path::new("/"),
None,
@@ -0,0 +1,80 @@
+[package]
+name = "multi_buffer"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[lib]
+path = "src/multi_buffer.rs"
+doctest = false
+
+[features]
+test-support = [
+ "copilot/test-support",
+ "text/test-support",
+ "language/test-support",
+ "gpui/test-support",
+ "util/test-support",
+ "tree-sitter-rust",
+ "tree-sitter-typescript"
+]
+
+[dependencies]
+client = { path = "../client" }
+clock = { path = "../clock" }
+collections = { path = "../collections" }
+context_menu = { path = "../context_menu" }
+git = { path = "../git" }
+gpui = { path = "../gpui" }
+language = { path = "../language" }
+lsp = { path = "../lsp" }
+rich_text = { path = "../rich_text" }
+settings = { path = "../settings" }
+snippet = { path = "../snippet" }
+sum_tree = { path = "../sum_tree" }
+text = { path = "../text" }
+theme = { path = "../theme" }
+util = { path = "../util" }
+
+aho-corasick = "1.1"
+anyhow.workspace = true
+convert_case = "0.6.0"
+futures.workspace = true
+indoc = "1.0.4"
+itertools = "0.10"
+lazy_static.workspace = true
+log.workspace = true
+ordered-float.workspace = true
+parking_lot.workspace = true
+postage.workspace = true
+pulldown-cmark = { version = "0.9.2", default-features = false }
+rand.workspace = true
+schemars.workspace = true
+serde.workspace = true
+serde_derive.workspace = true
+smallvec.workspace = true
+smol.workspace = true
+
+tree-sitter-rust = { workspace = true, optional = true }
+tree-sitter-html = { workspace = true, optional = true }
+tree-sitter-typescript = { workspace = true, optional = true }
+
+[dev-dependencies]
+copilot = { path = "../copilot", features = ["test-support"] }
+text = { path = "../text", features = ["test-support"] }
+language = { path = "../language", features = ["test-support"] }
+lsp = { path = "../lsp", features = ["test-support"] }
+gpui = { path = "../gpui", features = ["test-support"] }
+util = { path = "../util", features = ["test-support"] }
+project = { path = "../project", features = ["test-support"] }
+settings = { path = "../settings", features = ["test-support"] }
+workspace = { path = "../workspace", features = ["test-support"] }
+
+ctor.workspace = true
+env_logger.workspace = true
+rand.workspace = true
+unindent.workspace = true
+tree-sitter.workspace = true
+tree-sitter-rust.workspace = true
+tree-sitter-html.workspace = true
+tree-sitter-typescript.workspace = true
@@ -8,9 +8,9 @@ use sum_tree::Bias;
#[derive(Clone, Copy, Eq, PartialEq, Debug, Hash)]
pub struct Anchor {
- pub(crate) buffer_id: Option<u64>,
- pub(crate) excerpt_id: ExcerptId,
- pub(crate) text_anchor: text::Anchor,
+ pub buffer_id: Option<u64>,
+ pub excerpt_id: ExcerptId,
+ pub text_anchor: text::Anchor,
}
impl Anchor {
@@ -30,10 +30,6 @@ impl Anchor {
}
}
- pub fn excerpt_id(&self) -> ExcerptId {
- self.excerpt_id
- }
-
pub fn cmp(&self, other: &Anchor, snapshot: &MultiBufferSnapshot) -> Ordering {
let excerpt_id_cmp = self.excerpt_id.cmp(&other.excerpt_id, snapshot);
if excerpt_id_cmp.is_eq() {
@@ -303,7 +303,7 @@ impl MultiBuffer {
self.snapshot.borrow().clone()
}
- pub(crate) fn read(&self, cx: &AppContext) -> Ref<MultiBufferSnapshot> {
+ pub fn read(&self, cx: &AppContext) -> Ref<MultiBufferSnapshot> {
self.sync(cx);
self.snapshot.borrow()
}
@@ -498,84 +498,98 @@ impl MultiBuffer {
}
}
- for (buffer_id, mut edits) in buffer_edits {
- edits.sort_unstable_by_key(|edit| edit.range.start);
- self.buffers.borrow()[&buffer_id]
- .buffer
- .update(cx, |buffer, cx| {
- let mut edits = edits.into_iter().peekable();
- let mut insertions = Vec::new();
- let mut original_indent_columns = Vec::new();
- let mut deletions = Vec::new();
- let empty_str: Arc<str> = "".into();
- while let Some(BufferEdit {
- mut range,
- new_text,
- mut is_insertion,
- original_indent_column,
- }) = edits.next()
- {
+ drop(cursor);
+ drop(snapshot);
+ // Non-generic part of edit, hoisted out to avoid blowing up LLVM IR.
+ fn tail(
+ this: &mut MultiBuffer,
+ buffer_edits: HashMap<u64, Vec<BufferEdit>>,
+ autoindent_mode: Option<AutoindentMode>,
+ edited_excerpt_ids: Vec<ExcerptId>,
+ cx: &mut ModelContext<MultiBuffer>,
+ ) {
+ for (buffer_id, mut edits) in buffer_edits {
+ edits.sort_unstable_by_key(|edit| edit.range.start);
+ this.buffers.borrow()[&buffer_id]
+ .buffer
+ .update(cx, |buffer, cx| {
+ let mut edits = edits.into_iter().peekable();
+ let mut insertions = Vec::new();
+ let mut original_indent_columns = Vec::new();
+ let mut deletions = Vec::new();
+ let empty_str: Arc<str> = "".into();
while let Some(BufferEdit {
- range: next_range,
- is_insertion: next_is_insertion,
- ..
- }) = edits.peek()
+ mut range,
+ new_text,
+ mut is_insertion,
+ original_indent_column,
+ }) = edits.next()
{
- if range.end >= next_range.start {
- range.end = cmp::max(next_range.end, range.end);
- is_insertion |= *next_is_insertion;
- edits.next();
- } else {
- break;
+ while let Some(BufferEdit {
+ range: next_range,
+ is_insertion: next_is_insertion,
+ ..
+ }) = edits.peek()
+ {
+ if range.end >= next_range.start {
+ range.end = cmp::max(next_range.end, range.end);
+ is_insertion |= *next_is_insertion;
+ edits.next();
+ } else {
+ break;
+ }
}
- }
- if is_insertion {
- original_indent_columns.push(original_indent_column);
- insertions.push((
- buffer.anchor_before(range.start)..buffer.anchor_before(range.end),
- new_text.clone(),
- ));
- } else if !range.is_empty() {
- deletions.push((
- buffer.anchor_before(range.start)..buffer.anchor_before(range.end),
- empty_str.clone(),
- ));
+ if is_insertion {
+ original_indent_columns.push(original_indent_column);
+ insertions.push((
+ buffer.anchor_before(range.start)
+ ..buffer.anchor_before(range.end),
+ new_text.clone(),
+ ));
+ } else if !range.is_empty() {
+ deletions.push((
+ buffer.anchor_before(range.start)
+ ..buffer.anchor_before(range.end),
+ empty_str.clone(),
+ ));
+ }
}
- }
- let deletion_autoindent_mode =
- if let Some(AutoindentMode::Block { .. }) = autoindent_mode {
- Some(AutoindentMode::Block {
- original_indent_columns: Default::default(),
- })
- } else {
- None
- };
- let insertion_autoindent_mode =
- if let Some(AutoindentMode::Block { .. }) = autoindent_mode {
- Some(AutoindentMode::Block {
- original_indent_columns,
- })
- } else {
- None
- };
+ let deletion_autoindent_mode =
+ if let Some(AutoindentMode::Block { .. }) = autoindent_mode {
+ Some(AutoindentMode::Block {
+ original_indent_columns: Default::default(),
+ })
+ } else {
+ None
+ };
+ let insertion_autoindent_mode =
+ if let Some(AutoindentMode::Block { .. }) = autoindent_mode {
+ Some(AutoindentMode::Block {
+ original_indent_columns,
+ })
+ } else {
+ None
+ };
- buffer.edit(deletions, deletion_autoindent_mode, cx);
- buffer.edit(insertions, insertion_autoindent_mode, cx);
- })
- }
+ buffer.edit(deletions, deletion_autoindent_mode, cx);
+ buffer.edit(insertions, insertion_autoindent_mode, cx);
+ })
+ }
- cx.emit(Event::ExcerptsEdited {
- ids: edited_excerpt_ids,
- });
+ cx.emit(Event::ExcerptsEdited {
+ ids: edited_excerpt_ids,
+ });
+ }
+ tail(self, buffer_edits, autoindent_mode, edited_excerpt_ids, cx);
}
pub fn start_transaction(&mut self, cx: &mut ModelContext<Self>) -> Option<TransactionId> {
self.start_transaction_at(Instant::now(), cx)
}
- pub(crate) fn start_transaction_at(
+ pub fn start_transaction_at(
&mut self,
now: Instant,
cx: &mut ModelContext<Self>,
@@ -594,7 +608,7 @@ impl MultiBuffer {
self.end_transaction_at(Instant::now(), cx)
}
- pub(crate) fn end_transaction_at(
+ pub fn end_transaction_at(
&mut self,
now: Instant,
cx: &mut ModelContext<Self>,
@@ -1494,7 +1508,7 @@ impl MultiBuffer {
"untitled".into()
}
- #[cfg(test)]
+ #[cfg(any(test, feature = "test-support"))]
pub fn is_parsing(&self, cx: &AppContext) -> bool {
self.as_singleton().unwrap().read(cx).is_parsing()
}
@@ -3184,7 +3198,7 @@ impl MultiBufferSnapshot {
theme: Option<&SyntaxTheme>,
) -> Option<(u64, Vec<OutlineItem<Anchor>>)> {
let anchor = self.anchor_before(offset);
- let excerpt_id = anchor.excerpt_id();
+ let excerpt_id = anchor.excerpt_id;
let excerpt = self.excerpt(excerpt_id)?;
Some((
excerpt.buffer_id,
@@ -4115,17 +4129,13 @@ where
#[cfg(test)]
mod tests {
- use crate::editor_tests::init_test;
-
use super::*;
use futures::StreamExt;
use gpui::{AppContext, TestAppContext};
use language::{Buffer, Rope};
- use project::{FakeFs, Project};
use rand::prelude::*;
use settings::SettingsStore;
use std::{env, rc::Rc};
- use unindent::Unindent;
use util::test::sample_text;
#[gpui::test]
@@ -4824,190 +4834,6 @@ mod tests {
);
}
- #[gpui::test]
- async fn test_diff_hunks_in_range(cx: &mut TestAppContext) {
- use git::diff::DiffHunkStatus;
- init_test(cx, |_| {});
-
- let fs = FakeFs::new(cx.background());
- let project = Project::test(fs, [], cx).await;
-
- // buffer has two modified hunks with two rows each
- let buffer_1 = project
- .update(cx, |project, cx| {
- project.create_buffer(
- "
- 1.zero
- 1.ONE
- 1.TWO
- 1.three
- 1.FOUR
- 1.FIVE
- 1.six
- "
- .unindent()
- .as_str(),
- None,
- cx,
- )
- })
- .unwrap();
- buffer_1.update(cx, |buffer, cx| {
- buffer.set_diff_base(
- Some(
- "
- 1.zero
- 1.one
- 1.two
- 1.three
- 1.four
- 1.five
- 1.six
- "
- .unindent(),
- ),
- cx,
- );
- });
-
- // buffer has a deletion hunk and an insertion hunk
- let buffer_2 = project
- .update(cx, |project, cx| {
- project.create_buffer(
- "
- 2.zero
- 2.one
- 2.two
- 2.three
- 2.four
- 2.five
- 2.six
- "
- .unindent()
- .as_str(),
- None,
- cx,
- )
- })
- .unwrap();
- buffer_2.update(cx, |buffer, cx| {
- buffer.set_diff_base(
- Some(
- "
- 2.zero
- 2.one
- 2.one-and-a-half
- 2.two
- 2.three
- 2.four
- 2.six
- "
- .unindent(),
- ),
- cx,
- );
- });
-
- cx.foreground().run_until_parked();
-
- let multibuffer = cx.add_model(|cx| {
- let mut multibuffer = MultiBuffer::new(0);
- multibuffer.push_excerpts(
- buffer_1.clone(),
- [
- // excerpt ends in the middle of a modified hunk
- ExcerptRange {
- context: Point::new(0, 0)..Point::new(1, 5),
- primary: Default::default(),
- },
- // excerpt begins in the middle of a modified hunk
- ExcerptRange {
- context: Point::new(5, 0)..Point::new(6, 5),
- primary: Default::default(),
- },
- ],
- cx,
- );
- multibuffer.push_excerpts(
- buffer_2.clone(),
- [
- // excerpt ends at a deletion
- ExcerptRange {
- context: Point::new(0, 0)..Point::new(1, 5),
- primary: Default::default(),
- },
- // excerpt starts at a deletion
- ExcerptRange {
- context: Point::new(2, 0)..Point::new(2, 5),
- primary: Default::default(),
- },
- // excerpt fully contains a deletion hunk
- ExcerptRange {
- context: Point::new(1, 0)..Point::new(2, 5),
- primary: Default::default(),
- },
- // excerpt fully contains an insertion hunk
- ExcerptRange {
- context: Point::new(4, 0)..Point::new(6, 5),
- primary: Default::default(),
- },
- ],
- cx,
- );
- multibuffer
- });
-
- let snapshot = multibuffer.read_with(cx, |b, cx| b.snapshot(cx));
-
- assert_eq!(
- snapshot.text(),
- "
- 1.zero
- 1.ONE
- 1.FIVE
- 1.six
- 2.zero
- 2.one
- 2.two
- 2.one
- 2.two
- 2.four
- 2.five
- 2.six"
- .unindent()
- );
-
- let expected = [
- (DiffHunkStatus::Modified, 1..2),
- (DiffHunkStatus::Modified, 2..3),
- //TODO: Define better when and where removed hunks show up at range extremities
- (DiffHunkStatus::Removed, 6..6),
- (DiffHunkStatus::Removed, 8..8),
- (DiffHunkStatus::Added, 10..11),
- ];
-
- assert_eq!(
- snapshot
- .git_diff_hunks_in_range(0..12)
- .map(|hunk| (hunk.status(), hunk.buffer_range))
- .collect::<Vec<_>>(),
- &expected,
- );
-
- assert_eq!(
- snapshot
- .git_diff_hunks_in_range_rev(0..12)
- .map(|hunk| (hunk.status(), hunk.buffer_range))
- .collect::<Vec<_>>(),
- expected
- .iter()
- .rev()
- .cloned()
- .collect::<Vec<_>>()
- .as_slice(),
- );
- }
-
#[gpui::test(iterations = 100)]
fn test_random_multibuffer(cx: &mut AppContext, mut rng: StdRng) {
let operations = env::var("OPERATIONS")
@@ -220,96 +220,31 @@ impl NodeRuntime for RealNodeRuntime {
}
}
-pub struct FakeNodeRuntime(Option<PrettierSupport>);
-
-struct PrettierSupport {
- plugins: Vec<&'static str>,
-}
+pub struct FakeNodeRuntime;
impl FakeNodeRuntime {
pub fn new() -> Arc<dyn NodeRuntime> {
- Arc::new(FakeNodeRuntime(None))
- }
-
- pub fn with_prettier_support(plugins: &[&'static str]) -> Arc<dyn NodeRuntime> {
- Arc::new(FakeNodeRuntime(Some(PrettierSupport::new(plugins))))
+ Arc::new(Self)
}
}
#[async_trait::async_trait]
impl NodeRuntime for FakeNodeRuntime {
async fn binary_path(&self) -> anyhow::Result<PathBuf> {
- if let Some(prettier_support) = &self.0 {
- prettier_support.binary_path().await
- } else {
- unreachable!()
- }
+ unreachable!()
}
async fn run_npm_subcommand(
&self,
- directory: Option<&Path>,
+ _: Option<&Path>,
subcommand: &str,
args: &[&str],
) -> anyhow::Result<Output> {
- if let Some(prettier_support) = &self.0 {
- prettier_support
- .run_npm_subcommand(directory, subcommand, args)
- .await
- } else {
- unreachable!()
- }
- }
-
- async fn npm_package_latest_version(&self, name: &str) -> anyhow::Result<String> {
- if let Some(prettier_support) = &self.0 {
- prettier_support.npm_package_latest_version(name).await
- } else {
- unreachable!()
- }
- }
-
- async fn npm_install_packages(
- &self,
- directory: &Path,
- packages: &[(&str, &str)],
- ) -> anyhow::Result<()> {
- if let Some(prettier_support) = &self.0 {
- prettier_support
- .npm_install_packages(directory, packages)
- .await
- } else {
- unreachable!()
- }
- }
-}
-
-impl PrettierSupport {
- const PACKAGE_VERSION: &str = "0.0.1";
-
- fn new(plugins: &[&'static str]) -> Self {
- Self {
- plugins: plugins.to_vec(),
- }
- }
-}
-
-#[async_trait::async_trait]
-impl NodeRuntime for PrettierSupport {
- async fn binary_path(&self) -> anyhow::Result<PathBuf> {
- Ok(PathBuf::from("prettier_fake_node"))
- }
-
- async fn run_npm_subcommand(&self, _: Option<&Path>, _: &str, _: &[&str]) -> Result<Output> {
- unreachable!()
+ unreachable!("Should not run npm subcommand '{subcommand}' with args {args:?}")
}
async fn npm_package_latest_version(&self, name: &str) -> anyhow::Result<String> {
- if name == "prettier" || self.plugins.contains(&name) {
- Ok(Self::PACKAGE_VERSION.to_string())
- } else {
- panic!("Unexpected package name: {name}")
- }
+ unreachable!("Should not query npm package '{name}' for latest version")
}
async fn npm_install_packages(
@@ -317,32 +252,6 @@ impl NodeRuntime for PrettierSupport {
_: &Path,
packages: &[(&str, &str)],
) -> anyhow::Result<()> {
- assert_eq!(
- packages.len(),
- self.plugins.len() + 1,
- "Unexpected packages length to install: {:?}, expected `prettier` + {:?}",
- packages,
- self.plugins
- );
- for (name, version) in packages {
- assert!(
- name == &"prettier" || self.plugins.contains(name),
- "Unexpected package `{}` to install in packages {:?}, expected {} for `prettier` + {:?}",
- name,
- packages,
- Self::PACKAGE_VERSION,
- self.plugins
- );
- assert_eq!(
- version,
- &Self::PACKAGE_VERSION,
- "Unexpected package version `{}` to install in packages {:?}, expected {} for `prettier` + {:?}",
- version,
- packages,
- Self::PACKAGE_VERSION,
- self.plugins
- );
- }
- Ok(())
+ unreachable!("Should not install packages {packages:?}")
}
}
@@ -0,0 +1,42 @@
+[package]
+name = "notifications"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[lib]
+path = "src/notification_store.rs"
+doctest = false
+
+[features]
+test-support = [
+ "channel/test-support",
+ "collections/test-support",
+ "gpui/test-support",
+ "rpc/test-support",
+]
+
+[dependencies]
+channel = { path = "../channel" }
+client = { path = "../client" }
+clock = { path = "../clock" }
+collections = { path = "../collections" }
+db = { path = "../db" }
+feature_flags = { path = "../feature_flags" }
+gpui = { path = "../gpui" }
+rpc = { path = "../rpc" }
+settings = { path = "../settings" }
+sum_tree = { path = "../sum_tree" }
+text = { path = "../text" }
+util = { path = "../util" }
+
+anyhow.workspace = true
+time.workspace = true
+
+[dev-dependencies]
+client = { path = "../client", features = ["test-support"] }
+collections = { path = "../collections", features = ["test-support"] }
+gpui = { path = "../gpui", features = ["test-support"] }
+rpc = { path = "../rpc", features = ["test-support"] }
+settings = { path = "../settings", features = ["test-support"] }
+util = { path = "../util", features = ["test-support"] }
@@ -0,0 +1,459 @@
+use anyhow::Result;
+use channel::{ChannelMessage, ChannelMessageId, ChannelStore};
+use client::{Client, UserStore};
+use collections::HashMap;
+use db::smol::stream::StreamExt;
+use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task};
+use rpc::{proto, Notification, TypedEnvelope};
+use std::{ops::Range, sync::Arc};
+use sum_tree::{Bias, SumTree};
+use time::OffsetDateTime;
+use util::ResultExt;
+
+pub fn init(client: Arc<Client>, user_store: ModelHandle<UserStore>, cx: &mut AppContext) {
+ let notification_store = cx.add_model(|cx| NotificationStore::new(client, user_store, cx));
+ cx.set_global(notification_store);
+}
+
+pub struct NotificationStore {
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ channel_messages: HashMap<u64, ChannelMessage>,
+ channel_store: ModelHandle<ChannelStore>,
+ notifications: SumTree<NotificationEntry>,
+ loaded_all_notifications: bool,
+ _watch_connection_status: Task<Option<()>>,
+ _subscriptions: Vec<client::Subscription>,
+}
+
+#[derive(Clone, PartialEq, Eq, Debug)]
+pub enum NotificationEvent {
+ NotificationsUpdated {
+ old_range: Range<usize>,
+ new_count: usize,
+ },
+ NewNotification {
+ entry: NotificationEntry,
+ },
+ NotificationRemoved {
+ entry: NotificationEntry,
+ },
+ NotificationRead {
+ entry: NotificationEntry,
+ },
+}
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct NotificationEntry {
+ pub id: u64,
+ pub notification: Notification,
+ pub timestamp: OffsetDateTime,
+ pub is_read: bool,
+ pub response: Option<bool>,
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct NotificationSummary {
+ max_id: u64,
+ count: usize,
+ unread_count: usize,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
+struct Count(usize);
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
+struct UnreadCount(usize);
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
+struct NotificationId(u64);
+
+impl NotificationStore {
+ pub fn global(cx: &AppContext) -> ModelHandle<Self> {
+ cx.global::<ModelHandle<Self>>().clone()
+ }
+
+ pub fn new(
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ cx: &mut ModelContext<Self>,
+ ) -> Self {
+ let mut connection_status = client.status();
+ let watch_connection_status = cx.spawn_weak(|this, mut cx| async move {
+ while let Some(status) = connection_status.next().await {
+ let this = this.upgrade(&cx)?;
+ match status {
+ client::Status::Connected { .. } => {
+ if let Some(task) = this.update(&mut cx, |this, cx| this.handle_connect(cx))
+ {
+ task.await.log_err()?;
+ }
+ }
+ _ => this.update(&mut cx, |this, cx| this.handle_disconnect(cx)),
+ }
+ }
+ Some(())
+ });
+
+ Self {
+ channel_store: ChannelStore::global(cx),
+ notifications: Default::default(),
+ loaded_all_notifications: false,
+ channel_messages: Default::default(),
+ _watch_connection_status: watch_connection_status,
+ _subscriptions: vec![
+ client.add_message_handler(cx.handle(), Self::handle_new_notification),
+ client.add_message_handler(cx.handle(), Self::handle_delete_notification),
+ ],
+ user_store,
+ client,
+ }
+ }
+
+ pub fn notification_count(&self) -> usize {
+ self.notifications.summary().count
+ }
+
+ pub fn unread_notification_count(&self) -> usize {
+ self.notifications.summary().unread_count
+ }
+
+ pub fn channel_message_for_id(&self, id: u64) -> Option<&ChannelMessage> {
+ self.channel_messages.get(&id)
+ }
+
+ // Get the nth newest notification.
+ pub fn notification_at(&self, ix: usize) -> Option<&NotificationEntry> {
+ let count = self.notifications.summary().count;
+ if ix >= count {
+ return None;
+ }
+ let ix = count - 1 - ix;
+ let mut cursor = self.notifications.cursor::<Count>();
+ cursor.seek(&Count(ix), Bias::Right, &());
+ cursor.item()
+ }
+
+ pub fn notification_for_id(&self, id: u64) -> Option<&NotificationEntry> {
+ let mut cursor = self.notifications.cursor::<NotificationId>();
+ cursor.seek(&NotificationId(id), Bias::Left, &());
+ if let Some(item) = cursor.item() {
+ if item.id == id {
+ return Some(item);
+ }
+ }
+ None
+ }
+
+ pub fn load_more_notifications(
+ &self,
+ clear_old: bool,
+ cx: &mut ModelContext<Self>,
+ ) -> Option<Task<Result<()>>> {
+ if self.loaded_all_notifications && !clear_old {
+ return None;
+ }
+
+ let before_id = if clear_old {
+ None
+ } else {
+ self.notifications.first().map(|entry| entry.id)
+ };
+ let request = self.client.request(proto::GetNotifications { before_id });
+ Some(cx.spawn(|this, mut cx| async move {
+ let response = request.await?;
+ this.update(&mut cx, |this, _| {
+ this.loaded_all_notifications = response.done
+ });
+ Self::add_notifications(
+ this,
+ response.notifications,
+ AddNotificationsOptions {
+ is_new: false,
+ clear_old,
+ includes_first: response.done,
+ },
+ cx,
+ )
+ .await?;
+ Ok(())
+ }))
+ }
+
+ fn handle_connect(&mut self, cx: &mut ModelContext<Self>) -> Option<Task<Result<()>>> {
+ self.notifications = Default::default();
+ self.channel_messages = Default::default();
+ cx.notify();
+ self.load_more_notifications(true, cx)
+ }
+
+ fn handle_disconnect(&mut self, cx: &mut ModelContext<Self>) {
+ cx.notify()
+ }
+
+ async fn handle_new_notification(
+ this: ModelHandle<Self>,
+ envelope: TypedEnvelope<proto::AddNotification>,
+ _: Arc<Client>,
+ cx: AsyncAppContext,
+ ) -> Result<()> {
+ Self::add_notifications(
+ this,
+ envelope.payload.notification.into_iter().collect(),
+ AddNotificationsOptions {
+ is_new: true,
+ clear_old: false,
+ includes_first: false,
+ },
+ cx,
+ )
+ .await
+ }
+
+ async fn handle_delete_notification(
+ this: ModelHandle<Self>,
+ envelope: TypedEnvelope<proto::DeleteNotification>,
+ _: Arc<Client>,
+ mut cx: AsyncAppContext,
+ ) -> Result<()> {
+ this.update(&mut cx, |this, cx| {
+ this.splice_notifications([(envelope.payload.notification_id, None)], false, cx);
+ Ok(())
+ })
+ }
+
+ async fn add_notifications(
+ this: ModelHandle<Self>,
+ notifications: Vec<proto::Notification>,
+ options: AddNotificationsOptions,
+ mut cx: AsyncAppContext,
+ ) -> Result<()> {
+ let mut user_ids = Vec::new();
+ let mut message_ids = Vec::new();
+
+ let notifications = notifications
+ .into_iter()
+ .filter_map(|message| {
+ Some(NotificationEntry {
+ id: message.id,
+ is_read: message.is_read,
+ timestamp: OffsetDateTime::from_unix_timestamp(message.timestamp as i64)
+ .ok()?,
+ notification: Notification::from_proto(&message)?,
+ response: message.response,
+ })
+ })
+ .collect::<Vec<_>>();
+ if notifications.is_empty() {
+ return Ok(());
+ }
+
+ for entry in ¬ifications {
+ match entry.notification {
+ Notification::ChannelInvitation { inviter_id, .. } => {
+ user_ids.push(inviter_id);
+ }
+ Notification::ContactRequest {
+ sender_id: requester_id,
+ } => {
+ user_ids.push(requester_id);
+ }
+ Notification::ContactRequestAccepted {
+ responder_id: contact_id,
+ } => {
+ user_ids.push(contact_id);
+ }
+ Notification::ChannelMessageMention {
+ sender_id,
+ message_id,
+ ..
+ } => {
+ user_ids.push(sender_id);
+ message_ids.push(message_id);
+ }
+ }
+ }
+
+ let (user_store, channel_store) = this.read_with(&cx, |this, _| {
+ (this.user_store.clone(), this.channel_store.clone())
+ });
+
+ user_store
+ .update(&mut cx, |store, cx| store.get_users(user_ids, cx))
+ .await?;
+ let messages = channel_store
+ .update(&mut cx, |store, cx| {
+ store.fetch_channel_messages(message_ids, cx)
+ })
+ .await?;
+ this.update(&mut cx, |this, cx| {
+ if options.clear_old {
+ cx.emit(NotificationEvent::NotificationsUpdated {
+ old_range: 0..this.notifications.summary().count,
+ new_count: 0,
+ });
+ this.notifications = SumTree::default();
+ this.channel_messages.clear();
+ this.loaded_all_notifications = false;
+ }
+
+ if options.includes_first {
+ this.loaded_all_notifications = true;
+ }
+
+ this.channel_messages
+ .extend(messages.into_iter().filter_map(|message| {
+ if let ChannelMessageId::Saved(id) = message.id {
+ Some((id, message))
+ } else {
+ None
+ }
+ }));
+
+ this.splice_notifications(
+ notifications
+ .into_iter()
+ .map(|notification| (notification.id, Some(notification))),
+ options.is_new,
+ cx,
+ );
+ });
+
+ Ok(())
+ }
+
+ fn splice_notifications(
+ &mut self,
+ notifications: impl IntoIterator<Item = (u64, Option<NotificationEntry>)>,
+ is_new: bool,
+ cx: &mut ModelContext<'_, NotificationStore>,
+ ) {
+ let mut cursor = self.notifications.cursor::<(NotificationId, Count)>();
+ let mut new_notifications = SumTree::new();
+ let mut old_range = 0..0;
+
+ for (i, (id, new_notification)) in notifications.into_iter().enumerate() {
+ new_notifications.append(cursor.slice(&NotificationId(id), Bias::Left, &()), &());
+
+ if i == 0 {
+ old_range.start = cursor.start().1 .0;
+ }
+
+ let old_notification = cursor.item();
+ if let Some(old_notification) = old_notification {
+ if old_notification.id == id {
+ cursor.next(&());
+
+ if let Some(new_notification) = &new_notification {
+ if new_notification.is_read {
+ cx.emit(NotificationEvent::NotificationRead {
+ entry: new_notification.clone(),
+ });
+ }
+ } else {
+ cx.emit(NotificationEvent::NotificationRemoved {
+ entry: old_notification.clone(),
+ });
+ }
+ }
+ } else if let Some(new_notification) = &new_notification {
+ if is_new {
+ cx.emit(NotificationEvent::NewNotification {
+ entry: new_notification.clone(),
+ });
+ }
+ }
+
+ if let Some(notification) = new_notification {
+ new_notifications.push(notification, &());
+ }
+ }
+
+ old_range.end = cursor.start().1 .0;
+ let new_count = new_notifications.summary().count - old_range.start;
+ new_notifications.append(cursor.suffix(&()), &());
+ drop(cursor);
+
+ self.notifications = new_notifications;
+ cx.emit(NotificationEvent::NotificationsUpdated {
+ old_range,
+ new_count,
+ });
+ }
+
+ pub fn respond_to_notification(
+ &mut self,
+ notification: Notification,
+ response: bool,
+ cx: &mut ModelContext<Self>,
+ ) {
+ match notification {
+ Notification::ContactRequest { sender_id } => {
+ self.user_store
+ .update(cx, |store, cx| {
+ store.respond_to_contact_request(sender_id, response, cx)
+ })
+ .detach();
+ }
+ Notification::ChannelInvitation { channel_id, .. } => {
+ self.channel_store
+ .update(cx, |store, cx| {
+ store.respond_to_channel_invite(channel_id, response, cx)
+ })
+ .detach();
+ }
+ _ => {}
+ }
+ }
+}
+
+impl Entity for NotificationStore {
+ type Event = NotificationEvent;
+}
+
+impl sum_tree::Item for NotificationEntry {
+ type Summary = NotificationSummary;
+
+ fn summary(&self) -> Self::Summary {
+ NotificationSummary {
+ max_id: self.id,
+ count: 1,
+ unread_count: if self.is_read { 0 } else { 1 },
+ }
+ }
+}
+
+impl sum_tree::Summary for NotificationSummary {
+ type Context = ();
+
+ fn add_summary(&mut self, summary: &Self, _: &()) {
+ self.max_id = self.max_id.max(summary.max_id);
+ self.count += summary.count;
+ self.unread_count += summary.unread_count;
+ }
+}
+
+impl<'a> sum_tree::Dimension<'a, NotificationSummary> for NotificationId {
+ fn add_summary(&mut self, summary: &NotificationSummary, _: &()) {
+ debug_assert!(summary.max_id > self.0);
+ self.0 = summary.max_id;
+ }
+}
+
+impl<'a> sum_tree::Dimension<'a, NotificationSummary> for Count {
+ fn add_summary(&mut self, summary: &NotificationSummary, _: &()) {
+ self.0 += summary.count;
+ }
+}
+
+impl<'a> sum_tree::Dimension<'a, NotificationSummary> for UnreadCount {
+ fn add_summary(&mut self, summary: &NotificationSummary, _: &()) {
+ self.0 += summary.unread_count;
+ }
+}
+
+struct AddNotificationsOptions {
+ is_new: bool,
+ clear_old: bool,
+ includes_first: bool,
+}
@@ -27,6 +27,7 @@ serde_derive.workspace = true
serde_json.workspace = true
anyhow.workspace = true
futures.workspace = true
+parking_lot.workspace = true
[dev-dependencies]
language = { path = "../language", features = ["test-support"] }
@@ -3,11 +3,11 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Context;
-use collections::{HashMap, HashSet};
+use collections::HashMap;
use fs::Fs;
use gpui::{AsyncAppContext, ModelHandle};
use language::language_settings::language_settings;
-use language::{Buffer, BundledFormatter, Diff};
+use language::{Buffer, Diff};
use lsp::{LanguageServer, LanguageServerId};
use node_runtime::NodeRuntime;
use serde::{Deserialize, Serialize};
@@ -44,6 +44,9 @@ pub const PRETTIER_SERVER_JS: &str = include_str!("./prettier_server.js");
const PRETTIER_PACKAGE_NAME: &str = "prettier";
const TAILWIND_PRETTIER_PLUGIN_PACKAGE_NAME: &str = "prettier-plugin-tailwindcss";
+#[cfg(any(test, feature = "test-support"))]
+pub const FORMAT_SUFFIX: &str = "\nformatted by test prettier";
+
impl Prettier {
pub const CONFIG_FILE_NAMES: &'static [&'static str] = &[
".prettierrc",
@@ -60,9 +63,6 @@ impl Prettier {
".editorconfig",
];
- #[cfg(any(test, feature = "test-support"))]
- pub const FORMAT_SUFFIX: &str = "\nformatted by test prettier";
-
pub async fn locate(
starting_path: Option<LocateStart>,
fs: Arc<dyn Fs>,
@@ -210,6 +210,7 @@ impl Prettier {
.spawn(async move { node.binary_path().await })
.await?;
let server = LanguageServer::new(
+ Arc::new(parking_lot::Mutex::new(None)),
server_id,
LanguageServerBinary {
path: node_path,
@@ -242,40 +243,16 @@ impl Prettier {
Self::Real(local) => {
let params = buffer.read_with(cx, |buffer, cx| {
let buffer_language = buffer.language();
- let parsers_with_plugins = buffer_language
- .into_iter()
- .flat_map(|language| {
- language
- .lsp_adapters()
- .iter()
- .flat_map(|adapter| adapter.enabled_formatters())
- .filter_map(|formatter| match formatter {
- BundledFormatter::Prettier {
- parser_name,
- plugin_names,
- } => Some((parser_name, plugin_names)),
- })
- })
- .fold(
- HashMap::default(),
- |mut parsers_with_plugins, (parser_name, plugins)| {
- match parser_name {
- Some(parser_name) => parsers_with_plugins
- .entry(parser_name)
- .or_insert_with(HashSet::default)
- .extend(plugins),
- None => parsers_with_plugins.values_mut().for_each(|existing_plugins| {
- existing_plugins.extend(plugins.iter());
- }),
- }
- parsers_with_plugins
- },
- );
-
- let selected_parser_with_plugins = parsers_with_plugins.iter().max_by_key(|(_, plugins)| plugins.len());
- if parsers_with_plugins.len() > 1 {
- log::warn!("Found multiple parsers with plugins {parsers_with_plugins:?}, will select only one: {selected_parser_with_plugins:?}");
- }
+ let parser_with_plugins = buffer_language.and_then(|l| {
+ let prettier_parser = l.prettier_parser_name()?;
+ let mut prettier_plugins = l
+ .lsp_adapters()
+ .iter()
+ .flat_map(|adapter| adapter.prettier_plugins())
+ .collect::<Vec<_>>();
+ prettier_plugins.dedup();
+ Some((prettier_parser, prettier_plugins))
+ });
let prettier_node_modules = self.prettier_dir().join("node_modules");
anyhow::ensure!(prettier_node_modules.is_dir(), "Prettier node_modules dir does not exist: {prettier_node_modules:?}");
@@ -296,7 +273,7 @@ impl Prettier {
}
None
};
- let (parser, located_plugins) = match selected_parser_with_plugins {
+ let (parser, located_plugins) = match parser_with_plugins {
Some((parser, plugins)) => {
// Tailwind plugin requires being added last
// https://github.com/tailwindlabs/prettier-plugin-tailwindcss#compatibility-with-other-prettier-plugins
@@ -373,7 +350,7 @@ impl Prettier {
#[cfg(any(test, feature = "test-support"))]
Self::Test(_) => Ok(buffer
.read_with(cx, |buffer, cx| {
- let formatted_text = buffer.text() + Self::FORMAT_SUFFIX;
+ let formatted_text = buffer.text() + FORMAT_SUFFIX;
buffer.diff(formatted_text, cx)
})
.await),
@@ -10,7 +10,7 @@ use futures::future;
use gpui::{AppContext, AsyncAppContext, ModelHandle};
use language::{
language_settings::{language_settings, InlayHintKind},
- point_from_lsp, point_to_lsp,
+ point_from_lsp, point_to_lsp, prepare_completion_documentation,
proto::{deserialize_anchor, deserialize_version, serialize_anchor, serialize_version},
range_from_lsp, range_to_lsp, Anchor, Bias, Buffer, BufferSnapshot, CachedLspAdapter, CharKind,
CodeAction, Completion, OffsetRangeExt, PointUtf16, ToOffset, ToPointUtf16, Transaction,
@@ -1341,7 +1341,7 @@ impl LspCommand for GetCompletions {
async fn response_from_lsp(
self,
completions: Option<lsp::CompletionResponse>,
- _: ModelHandle<Project>,
+ project: ModelHandle<Project>,
buffer: ModelHandle<Buffer>,
server_id: LanguageServerId,
cx: AsyncAppContext,
@@ -1358,10 +1358,11 @@ impl LspCommand for GetCompletions {
}
}
} else {
- Default::default()
+ Vec::new()
};
- let completions = buffer.read_with(&cx, |buffer, _| {
+ let completions = buffer.read_with(&cx, |buffer, cx| {
+ let language_registry = project.read(cx).languages().clone();
let language = buffer.language().cloned();
let snapshot = buffer.snapshot();
let clipped_position = buffer.clip_point_utf16(Unclipped(self.position), Bias::Left);
@@ -1370,6 +1371,14 @@ impl LspCommand for GetCompletions {
completions
.into_iter()
.filter_map(move |mut lsp_completion| {
+ if let Some(response_list) = &response_list {
+ if let Some(item_defaults) = &response_list.item_defaults {
+ if let Some(data) = &item_defaults.data {
+ lsp_completion.data = Some(data.clone());
+ }
+ }
+ }
+
let (old_range, mut new_text) = match lsp_completion.text_edit.as_ref() {
// If the language server provides a range to overwrite, then
// check that the range is valid.
@@ -1445,14 +1454,30 @@ impl LspCommand for GetCompletions {
}
};
- let language = language.clone();
LineEnding::normalize(&mut new_text);
+ let language_registry = language_registry.clone();
+ let language = language.clone();
+
Some(async move {
let mut label = None;
- if let Some(language) = language {
+ if let Some(language) = language.as_ref() {
language.process_completion(&mut lsp_completion).await;
label = language.label_for_completion(&lsp_completion).await;
}
+
+ let documentation = if let Some(lsp_docs) = &lsp_completion.documentation {
+ Some(
+ prepare_completion_documentation(
+ lsp_docs,
+ &language_registry,
+ language.clone(),
+ )
+ .await,
+ )
+ } else {
+ None
+ };
+
Completion {
old_range,
new_text,
@@ -1462,6 +1487,7 @@ impl LspCommand for GetCompletions {
lsp_completion.filter_text.as_deref(),
)
}),
+ documentation,
server_id,
lsp_completion,
}
@@ -39,11 +39,11 @@ use language::{
deserialize_anchor, deserialize_fingerprint, deserialize_line_ending, deserialize_version,
serialize_anchor, serialize_version, split_operations,
},
- range_from_lsp, range_to_lsp, Bias, Buffer, BufferSnapshot, BundledFormatter, CachedLspAdapter,
- CodeAction, CodeLabel, Completion, Diagnostic, DiagnosticEntry, DiagnosticSet, Diff,
- Event as BufferEvent, File as _, Language, LanguageRegistry, LanguageServerName, LocalFile,
- LspAdapterDelegate, OffsetRangeExt, Operation, Patch, PendingLanguageServer, PointUtf16,
- TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, Unclipped,
+ range_from_lsp, range_to_lsp, Bias, Buffer, BufferSnapshot, CachedLspAdapter, CodeAction,
+ CodeLabel, Completion, Diagnostic, DiagnosticEntry, DiagnosticSet, Diff, Event as BufferEvent,
+ File as _, Language, LanguageRegistry, LanguageServerName, LocalFile, LspAdapterDelegate,
+ OffsetRangeExt, Operation, Patch, PendingLanguageServer, PointUtf16, TextBufferSnapshot,
+ ToOffset, ToPointUtf16, Transaction, Unclipped,
};
use log::error;
use lsp::{
@@ -52,8 +52,9 @@ use lsp::{
};
use lsp_command::*;
use node_runtime::NodeRuntime;
+use parking_lot::Mutex;
use postage::watch;
-use prettier::{LocateStart, Prettier, PRETTIER_SERVER_FILE, PRETTIER_SERVER_JS};
+use prettier::{LocateStart, Prettier};
use project_settings::{LspSettings, ProjectSettings};
use rand::prelude::*;
use search::SearchQuery;
@@ -79,18 +80,19 @@ use std::{
time::{Duration, Instant},
};
use terminals::Terminals;
-use text::{Anchor, LineEnding, Rope};
+use text::Anchor;
use util::{
- debug_panic, defer,
- http::HttpClient,
- merge_json_value_into,
- paths::{DEFAULT_PRETTIER_DIR, LOCAL_SETTINGS_RELATIVE_PATH},
- post_inc, ResultExt, TryFutureExt as _,
+ debug_panic, defer, http::HttpClient, merge_json_value_into,
+ paths::LOCAL_SETTINGS_RELATIVE_PATH, post_inc, ResultExt, TryFutureExt as _,
};
pub use fs::*;
+#[cfg(any(test, feature = "test-support"))]
+pub use prettier::FORMAT_SUFFIX as TEST_PRETTIER_FORMAT_SUFFIX;
pub use worktree::*;
+const MAX_SERVER_REINSTALL_ATTEMPT_COUNT: u64 = 4;
+
pub trait Item {
fn entry_id(&self, cx: &AppContext) -> Option<ProjectEntryId>;
fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
@@ -592,6 +594,7 @@ impl Project {
client.add_model_request_handler(Self::handle_apply_code_action);
client.add_model_request_handler(Self::handle_on_type_formatting);
client.add_model_request_handler(Self::handle_inlay_hints);
+ client.add_model_request_handler(Self::handle_resolve_completion_documentation);
client.add_model_request_handler(Self::handle_resolve_inlay_hint);
client.add_model_request_handler(Self::handle_refresh_inlay_hints);
client.add_model_request_handler(Self::handle_reload_buffers);
@@ -835,16 +838,6 @@ impl Project {
project
}
- /// Enables a prettier mock that avoids interacting with node runtime, prettier LSP wrapper, or any real file changes.
- /// Instead, if appends the suffix to every input, this suffix is returned by this method.
- #[cfg(any(test, feature = "test-support"))]
- pub fn enable_test_prettier(&mut self, plugins: &[&'static str]) -> &'static str {
- self.node = Some(node_runtime::FakeNodeRuntime::with_prettier_support(
- plugins,
- ));
- Prettier::FORMAT_SUFFIX
- }
-
fn on_settings_changed(&mut self, cx: &mut ModelContext<Self>) {
let mut language_servers_to_start = Vec::new();
let mut language_formatters_to_check = Vec::new();
@@ -2731,12 +2724,18 @@ impl Project {
language: Arc<Language>,
cx: &mut ModelContext<Self>,
) {
+ if adapter.reinstall_attempt_count.load(SeqCst) > MAX_SERVER_REINSTALL_ATTEMPT_COUNT {
+ return;
+ }
+
let key = (worktree_id, adapter.name.clone());
if self.language_server_ids.contains_key(&key) {
return;
}
+ let stderr_capture = Arc::new(Mutex::new(Some(String::new())));
let pending_server = match self.languages.create_pending_language_server(
+ stderr_capture.clone(),
language.clone(),
adapter.clone(),
worktree_path,
@@ -2751,15 +2750,6 @@ impl Project {
let lsp = project_settings.lsp.get(&adapter.name.0);
let override_options = lsp.map(|s| s.initialization_options.clone()).flatten();
- let mut initialization_options = adapter.initialization_options.clone();
- match (&mut initialization_options, override_options) {
- (Some(initialization_options), Some(override_options)) => {
- merge_json_value_into(override_options, initialization_options);
- }
- (None, override_options) => initialization_options = override_options,
- _ => {}
- }
-
let server_id = pending_server.server_id;
let container_dir = pending_server.container_dir.clone();
let state = LanguageServerState::Starting({
@@ -2771,7 +2761,7 @@ impl Project {
cx.spawn_weak(|this, mut cx| async move {
let result = Self::setup_and_insert_language_server(
this,
- initialization_options,
+ override_options,
pending_server,
adapter.clone(),
language.clone(),
@@ -2782,29 +2772,41 @@ impl Project {
.await;
match result {
- Ok(server) => server,
+ Ok(server) => {
+ stderr_capture.lock().take();
+ Some(server)
+ }
Err(err) => {
- log::error!("failed to start language server {:?}: {}", server_name, err);
-
- if let Some(this) = this.upgrade(&cx) {
- if let Some(container_dir) = container_dir {
- let installation_test_binary = adapter
- .installation_test_binary(container_dir.to_path_buf())
- .await;
-
- this.update(&mut cx, |_, cx| {
- Self::check_errored_server(
- language,
- adapter,
- server_id,
- installation_test_binary,
- cx,
- )
- });
- }
+ log::error!("failed to start language server {server_name:?}: {err}");
+ log::error!("server stderr: {:?}", stderr_capture.lock().take());
+
+ let this = this.upgrade(&cx)?;
+ let container_dir = container_dir?;
+
+ let attempt_count = adapter.reinstall_attempt_count.fetch_add(1, SeqCst);
+ if attempt_count >= MAX_SERVER_REINSTALL_ATTEMPT_COUNT {
+ let max = MAX_SERVER_REINSTALL_ATTEMPT_COUNT;
+ log::error!(
+ "Hit {max} max reinstallation attempts for {server_name:?}"
+ );
+ return None;
}
+ let installation_test_binary = adapter
+ .installation_test_binary(container_dir.to_path_buf())
+ .await;
+
+ this.update(&mut cx, |_, cx| {
+ Self::check_errored_server(
+ language,
+ adapter,
+ server_id,
+ installation_test_binary,
+ cx,
+ )
+ });
+
None
}
}
@@ -2874,27 +2876,24 @@ impl Project {
async fn setup_and_insert_language_server(
this: WeakModelHandle<Self>,
- initialization_options: Option<serde_json::Value>,
+ override_initialization_options: Option<serde_json::Value>,
pending_server: PendingLanguageServer,
adapter: Arc<CachedLspAdapter>,
language: Arc<Language>,
server_id: LanguageServerId,
key: (WorktreeId, LanguageServerName),
cx: &mut AsyncAppContext,
- ) -> Result<Option<Arc<LanguageServer>>> {
- let setup = Self::setup_pending_language_server(
+ ) -> Result<Arc<LanguageServer>> {
+ let language_server = Self::setup_pending_language_server(
this,
- initialization_options,
+ override_initialization_options,
pending_server,
adapter.clone(),
server_id,
cx,
- );
+ )
+ .await?;
- let language_server = match setup.await? {
- Some(language_server) => language_server,
- None => return Ok(None),
- };
let this = match this.upgrade(cx) {
Some(this) => this,
None => return Err(anyhow!("failed to upgrade project handle")),
@@ -2911,22 +2910,19 @@ impl Project {
)
})?;
- Ok(Some(language_server))
+ Ok(language_server)
}
async fn setup_pending_language_server(
this: WeakModelHandle<Self>,
- initialization_options: Option<serde_json::Value>,
+ override_options: Option<serde_json::Value>,
pending_server: PendingLanguageServer,
adapter: Arc<CachedLspAdapter>,
server_id: LanguageServerId,
cx: &mut AsyncAppContext,
- ) -> Result<Option<Arc<LanguageServer>>> {
+ ) -> Result<Arc<LanguageServer>> {
let workspace_config = cx.update(|cx| adapter.workspace_configuration(cx)).await;
- let language_server = match pending_server.task.await? {
- Some(server) => server,
- None => return Ok(None),
- };
+ let language_server = pending_server.task.await?;
language_server
.on_notification::<lsp::notification::PublishDiagnostics, _>({
@@ -2934,8 +2930,8 @@ impl Project {
move |mut params, mut cx| {
let this = this;
let adapter = adapter.clone();
- adapter.process_diagnostics(&mut params);
if let Some(this) = this.upgrade(&cx) {
+ adapter.process_diagnostics(&mut params);
this.update(&mut cx, |this, cx| {
this.update_diagnostics(
server_id,
@@ -2997,6 +2993,7 @@ impl Project {
},
)
.detach();
+
language_server
.on_request::<lsp::request::RegisterCapability, _, _>({
move |params, mut cx| async move {
@@ -3063,6 +3060,15 @@ impl Project {
})
.detach();
+ let mut initialization_options = adapter.adapter.initialization_options().await;
+ match (&mut initialization_options, override_options) {
+ (Some(initialization_options), Some(override_options)) => {
+ merge_json_value_into(override_options, initialization_options);
+ }
+ (None, override_options) => initialization_options = override_options,
+ _ => {}
+ }
+
let language_server = language_server.initialize(initialization_options).await?;
language_server
@@ -3073,7 +3079,7 @@ impl Project {
)
.ok();
- Ok(Some(language_server))
+ Ok(language_server)
}
fn insert_newly_running_language_server(
@@ -7353,6 +7359,40 @@ impl Project {
})
}
+ async fn handle_resolve_completion_documentation(
+ this: ModelHandle<Self>,
+ envelope: TypedEnvelope<proto::ResolveCompletionDocumentation>,
+ _: Arc<Client>,
+ mut cx: AsyncAppContext,
+ ) -> Result<proto::ResolveCompletionDocumentationResponse> {
+ let lsp_completion = serde_json::from_slice(&envelope.payload.lsp_completion)?;
+
+ let completion = this
+ .read_with(&mut cx, |this, _| {
+ let id = LanguageServerId(envelope.payload.language_server_id as usize);
+ let Some(server) = this.language_server_for_id(id) else {
+ return Err(anyhow!("No language server {id}"));
+ };
+
+ Ok(server.request::<lsp::request::ResolveCompletionItem>(lsp_completion))
+ })?
+ .await?;
+
+ let mut is_markdown = false;
+ let text = match completion.documentation {
+ Some(lsp::Documentation::String(text)) => text,
+
+ Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { kind, value })) => {
+ is_markdown = kind == lsp::MarkupKind::Markdown;
+ value
+ }
+
+ _ => String::new(),
+ };
+
+ Ok(proto::ResolveCompletionDocumentationResponse { text, is_markdown })
+ }
+
async fn handle_apply_code_action(
this: ModelHandle<Self>,
envelope: TypedEnvelope<proto::ApplyCodeAction>,
@@ -8318,12 +8358,7 @@ impl Project {
let Some(buffer_language) = buffer.language() else {
return Task::ready(None);
};
- if !buffer_language
- .lsp_adapters()
- .iter()
- .flat_map(|adapter| adapter.enabled_formatters())
- .any(|formatter| matches!(formatter, BundledFormatter::Prettier { .. }))
- {
+ if buffer_language.prettier_parser_name().is_none() {
return Task::ready(None);
}
@@ -8460,6 +8495,18 @@ impl Project {
}
}
+ #[cfg(any(test, feature = "test-support"))]
+ fn install_default_formatters(
+ &self,
+ _worktree: Option<WorktreeId>,
+ _new_language: &Language,
+ _language_settings: &LanguageSettings,
+ _cx: &mut ModelContext<Self>,
+ ) -> Task<anyhow::Result<()>> {
+ return Task::ready(Ok(()));
+ }
+
+ #[cfg(not(any(test, feature = "test-support")))]
fn install_default_formatters(
&self,
worktree: Option<WorktreeId>,
@@ -8476,22 +8523,21 @@ impl Project {
};
let mut prettier_plugins = None;
- for formatter in new_language
- .lsp_adapters()
- .into_iter()
- .flat_map(|adapter| adapter.enabled_formatters())
- {
- match formatter {
- BundledFormatter::Prettier { plugin_names, .. } => prettier_plugins
- .get_or_insert_with(|| HashSet::default())
- .extend(plugin_names),
- }
+ if new_language.prettier_parser_name().is_some() {
+ prettier_plugins
+ .get_or_insert_with(|| HashSet::default())
+ .extend(
+ new_language
+ .lsp_adapters()
+ .iter()
+ .flat_map(|adapter| adapter.prettier_plugins()),
+ )
}
let Some(prettier_plugins) = prettier_plugins else {
return Task::ready(Ok(()));
};
- let default_prettier_dir = DEFAULT_PRETTIER_DIR.as_path();
+ let default_prettier_dir = util::paths::DEFAULT_PRETTIER_DIR.as_path();
let already_running_prettier = self
.prettier_instances
.get(&(worktree, default_prettier_dir.to_path_buf()))
@@ -8500,10 +8546,10 @@ impl Project {
let fs = Arc::clone(&self.fs);
cx.background()
.spawn(async move {
- let prettier_wrapper_path = default_prettier_dir.join(PRETTIER_SERVER_FILE);
+ let prettier_wrapper_path = default_prettier_dir.join(prettier::PRETTIER_SERVER_FILE);
// method creates parent directory if it doesn't exist
- fs.save(&prettier_wrapper_path, &Rope::from(PRETTIER_SERVER_JS), LineEnding::Unix).await
- .with_context(|| format!("writing {PRETTIER_SERVER_FILE} file at {prettier_wrapper_path:?}"))?;
+ fs.save(&prettier_wrapper_path, &text::Rope::from(prettier::PRETTIER_SERVER_JS), text::LineEnding::Unix).await
+ .with_context(|| format!("writing {} file at {prettier_wrapper_path:?}", prettier::PRETTIER_SERVER_FILE))?;
let packages_to_versions = future::try_join_all(
prettier_plugins
@@ -2027,11 +2027,16 @@ impl LocalSnapshot {
fn ignore_stack_for_abs_path(&self, abs_path: &Path, is_dir: bool) -> Arc<IgnoreStack> {
let mut new_ignores = Vec::new();
- for ancestor in abs_path.ancestors().skip(1) {
- if let Some((ignore, _)) = self.ignores_by_parent_abs_path.get(ancestor) {
- new_ignores.push((ancestor, Some(ignore.clone())));
- } else {
- new_ignores.push((ancestor, None));
+ for (index, ancestor) in abs_path.ancestors().enumerate() {
+ if index > 0 {
+ if let Some((ignore, _)) = self.ignores_by_parent_abs_path.get(ancestor) {
+ new_ignores.push((ancestor, Some(ignore.clone())));
+ } else {
+ new_ignores.push((ancestor, None));
+ }
+ }
+ if ancestor.join(&*DOT_GIT).is_dir() {
+ break;
}
}
@@ -2048,7 +2053,6 @@ impl LocalSnapshot {
if ignore_stack.is_abs_path_ignored(abs_path, is_dir) {
ignore_stack = IgnoreStack::all();
}
-
ignore_stack
}
@@ -3064,14 +3068,21 @@ impl BackgroundScanner {
// Populate ignores above the root.
let root_abs_path = self.state.lock().snapshot.abs_path.clone();
- for ancestor in root_abs_path.ancestors().skip(1) {
- if let Ok(ignore) = build_gitignore(&ancestor.join(&*GITIGNORE), self.fs.as_ref()).await
- {
- self.state
- .lock()
- .snapshot
- .ignores_by_parent_abs_path
- .insert(ancestor.into(), (ignore.into(), false));
+ for (index, ancestor) in root_abs_path.ancestors().enumerate() {
+ if index != 0 {
+ if let Ok(ignore) =
+ build_gitignore(&ancestor.join(&*GITIGNORE), self.fs.as_ref()).await
+ {
+ self.state
+ .lock()
+ .snapshot
+ .ignores_by_parent_abs_path
+ .insert(ancestor.into(), (ignore.into(), false));
+ }
+ }
+ if ancestor.join(&*DOT_GIT).is_dir() {
+ // Reached root of git repository.
+ break;
}
}
@@ -4,7 +4,7 @@ use collections::HashMap;
use gpui::{AppContext, AssetSource};
use serde_derive::Deserialize;
-use util::{iife, paths::PathExt};
+use util::{maybe, paths::PathExt};
#[derive(Deserialize, Debug)]
struct TypeConfig {
@@ -42,12 +42,12 @@ impl FileAssociations {
}
pub fn get_icon(path: &Path, cx: &AppContext) -> Arc<str> {
- iife!({
+ maybe!({
let this = cx.has_global::<Self>().then(|| cx.global::<Self>())?;
// FIXME: Associate a type with the languages and have the file's langauge
// override these associations
- iife!({
+ maybe!({
let suffix = path.icon_suffix()?;
this.suffixes
@@ -61,7 +61,7 @@ impl FileAssociations {
}
pub fn get_folder_icon(expanded: bool, cx: &AppContext) -> Arc<str> {
- iife!({
+ maybe!({
let this = cx.has_global::<Self>().then(|| cx.global::<Self>())?;
let key = if expanded {
@@ -78,7 +78,7 @@ impl FileAssociations {
}
pub fn get_chevron_icon(expanded: bool, cx: &AppContext) -> Arc<str> {
- iife!({
+ maybe!({
let this = cx.has_global::<Self>().then(|| cx.global::<Self>())?;
let key = if expanded {
@@ -1,20 +1,35 @@
use std::{ops::Range, sync::Arc};
+use anyhow::bail;
use futures::FutureExt;
use gpui::{
- color::Color,
elements::Text,
- fonts::{HighlightStyle, TextStyle, Underline, Weight},
+ fonts::{HighlightStyle, Underline, Weight},
platform::{CursorStyle, MouseButton},
AnyElement, CursorRegion, Element, MouseRegion, ViewContext,
};
use language::{HighlightId, Language, LanguageRegistry};
-use theme::SyntaxTheme;
+use theme::{RichTextStyle, SyntaxTheme};
+use util::RangeExt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight {
Id(HighlightId),
Highlight(HighlightStyle),
+ Mention,
+ SelfMention,
+}
+
+impl From<HighlightStyle> for Highlight {
+ fn from(style: HighlightStyle) -> Self {
+ Self::Highlight(style)
+ }
+}
+
+impl From<HighlightId> for Highlight {
+ fn from(style: HighlightId) -> Self {
+ Self::Id(style)
+ }
}
#[derive(Debug, Clone)]
@@ -25,18 +40,32 @@ pub struct RichText {
pub regions: Vec<RenderedRegion>,
}
-#[derive(Debug, Clone)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum BackgroundKind {
+ Code,
+ /// A mention background for non-self user.
+ Mention,
+ SelfMention,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedRegion {
- code: bool,
- link_url: Option<String>,
+ pub background_kind: Option<BackgroundKind>,
+ pub link_url: Option<String>,
+}
+
+/// Allows one to specify extra links to the rendered markdown, which can be used
+/// for e.g. mentions.
+pub struct Mention {
+ pub range: Range<usize>,
+ pub is_self_mention: bool,
}
impl RichText {
pub fn element<V: 'static>(
&self,
syntax: Arc<SyntaxTheme>,
- style: TextStyle,
- code_span_background_color: Color,
+ style: RichTextStyle,
cx: &mut ViewContext<V>,
) -> AnyElement<V> {
let mut region_id = 0;
@@ -45,7 +74,7 @@ impl RichText {
let regions = self.regions.clone();
enum Markdown {}
- Text::new(self.text.clone(), style.clone())
+ Text::new(self.text.clone(), style.text.clone())
.with_highlights(
self.highlights
.iter()
@@ -53,6 +82,8 @@ impl RichText {
let style = match highlight {
Highlight::Id(id) => id.style(&syntax)?,
Highlight::Highlight(style) => style.clone(),
+ Highlight::Mention => style.mention_highlight,
+ Highlight::SelfMention => style.self_mention_highlight,
};
Some((range.clone(), style))
})
@@ -73,22 +104,55 @@ impl RichText {
}),
);
}
- if region.code {
- cx.scene().push_quad(gpui::Quad {
- bounds,
- background: Some(code_span_background_color),
- border: Default::default(),
- corner_radii: (2.0).into(),
- });
+ if let Some(region_kind) = ®ion.background_kind {
+ let background = match region_kind {
+ BackgroundKind::Code => style.code_background,
+ BackgroundKind::Mention => style.mention_background,
+ BackgroundKind::SelfMention => style.self_mention_background,
+ };
+ if background.is_some() {
+ cx.scene().push_quad(gpui::Quad {
+ bounds,
+ background,
+ border: Default::default(),
+ corner_radii: (2.0).into(),
+ });
+ }
}
})
.with_soft_wrap(true)
.into_any()
}
+
+ pub fn add_mention(
+ &mut self,
+ range: Range<usize>,
+ is_current_user: bool,
+ mention_style: HighlightStyle,
+ ) -> anyhow::Result<()> {
+ if range.end > self.text.len() {
+ bail!(
+ "Mention in range {range:?} is outside of bounds for a message of length {}",
+ self.text.len()
+ );
+ }
+
+ if is_current_user {
+ self.region_ranges.push(range.clone());
+ self.regions.push(RenderedRegion {
+ background_kind: Some(BackgroundKind::Mention),
+ link_url: None,
+ });
+ }
+ self.highlights
+ .push((range, Highlight::Highlight(mention_style)));
+ Ok(())
+ }
}
pub fn render_markdown_mut(
block: &str,
+ mut mentions: &[Mention],
language_registry: &Arc<LanguageRegistry>,
language: Option<&Arc<Language>>,
data: &mut RichText,
@@ -101,15 +165,40 @@ pub fn render_markdown_mut(
let mut current_language = None;
let mut list_stack = Vec::new();
- for event in Parser::new_ext(&block, Options::all()) {
+ let options = Options::all();
+ for (event, source_range) in Parser::new_ext(&block, options).into_offset_iter() {
let prev_len = data.text.len();
match event {
Event::Text(t) => {
if let Some(language) = ¤t_language {
render_code(&mut data.text, &mut data.highlights, t.as_ref(), language);
} else {
- data.text.push_str(t.as_ref());
+ if let Some(mention) = mentions.first() {
+ if source_range.contains_inclusive(&mention.range) {
+ mentions = &mentions[1..];
+ let range = (prev_len + mention.range.start - source_range.start)
+ ..(prev_len + mention.range.end - source_range.start);
+ data.highlights.push((
+ range.clone(),
+ if mention.is_self_mention {
+ Highlight::SelfMention
+ } else {
+ Highlight::Mention
+ },
+ ));
+ data.region_ranges.push(range);
+ data.regions.push(RenderedRegion {
+ background_kind: Some(if mention.is_self_mention {
+ BackgroundKind::SelfMention
+ } else {
+ BackgroundKind::Mention
+ }),
+ link_url: None,
+ });
+ }
+ }
+ data.text.push_str(t.as_ref());
let mut style = HighlightStyle::default();
if bold_depth > 0 {
style.weight = Some(Weight::BOLD);
@@ -121,7 +210,7 @@ pub fn render_markdown_mut(
data.region_ranges.push(prev_len..data.text.len());
data.regions.push(RenderedRegion {
link_url: Some(link_url),
- code: false,
+ background_kind: None,
});
style.underline = Some(Underline {
thickness: 1.0.into(),
@@ -162,7 +251,7 @@ pub fn render_markdown_mut(
));
}
data.regions.push(RenderedRegion {
- code: true,
+ background_kind: Some(BackgroundKind::Code),
link_url: link_url.clone(),
});
}
@@ -228,6 +317,7 @@ pub fn render_markdown_mut(
pub fn render_markdown(
block: String,
+ mentions: &[Mention],
language_registry: &Arc<LanguageRegistry>,
language: Option<&Arc<Language>>,
) -> RichText {
@@ -238,7 +328,7 @@ pub fn render_markdown(
regions: Default::default(),
};
- render_markdown_mut(&block, language_registry, language, &mut data);
+ render_markdown_mut(&block, mentions, language_registry, language, &mut data);
data.text = data.text.trim().to_string();
@@ -17,6 +17,7 @@ clock = { path = "../clock" }
collections = { path = "../collections" }
gpui = { path = "../gpui", optional = true }
util = { path = "../util" }
+
anyhow.workspace = true
async-lock = "2.4"
async-tungstenite = "0.16"
@@ -27,8 +28,10 @@ prost.workspace = true
rand.workspace = true
rsa = "0.4"
serde.workspace = true
+serde_json.workspace = true
serde_derive.workspace = true
smol-timeout = "0.6"
+strum.workspace = true
tracing = { version = "0.1.34", features = ["log"] }
zstd = "0.11"
@@ -89,88 +89,96 @@ message Envelope {
FormatBuffersResponse format_buffers_response = 70;
GetCompletions get_completions = 71;
GetCompletionsResponse get_completions_response = 72;
- ApplyCompletionAdditionalEdits apply_completion_additional_edits = 73;
- ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 74;
- GetCodeActions get_code_actions = 75;
- GetCodeActionsResponse get_code_actions_response = 76;
- GetHover get_hover = 77;
- GetHoverResponse get_hover_response = 78;
- ApplyCodeAction apply_code_action = 79;
- ApplyCodeActionResponse apply_code_action_response = 80;
- PrepareRename prepare_rename = 81;
- PrepareRenameResponse prepare_rename_response = 82;
- PerformRename perform_rename = 83;
- PerformRenameResponse perform_rename_response = 84;
- SearchProject search_project = 85;
- SearchProjectResponse search_project_response = 86;
-
- UpdateContacts update_contacts = 87;
- UpdateInviteInfo update_invite_info = 88;
- ShowContacts show_contacts = 89;
-
- GetUsers get_users = 90;
- FuzzySearchUsers fuzzy_search_users = 91;
- UsersResponse users_response = 92;
- RequestContact request_contact = 93;
- RespondToContactRequest respond_to_contact_request = 94;
- RemoveContact remove_contact = 95;
-
- Follow follow = 96;
- FollowResponse follow_response = 97;
- UpdateFollowers update_followers = 98;
- Unfollow unfollow = 99;
- GetPrivateUserInfo get_private_user_info = 100;
- GetPrivateUserInfoResponse get_private_user_info_response = 101;
- UpdateDiffBase update_diff_base = 102;
-
- OnTypeFormatting on_type_formatting = 103;
- OnTypeFormattingResponse on_type_formatting_response = 104;
-
- UpdateWorktreeSettings update_worktree_settings = 105;
-
- InlayHints inlay_hints = 106;
- InlayHintsResponse inlay_hints_response = 107;
- ResolveInlayHint resolve_inlay_hint = 108;
- ResolveInlayHintResponse resolve_inlay_hint_response = 109;
- RefreshInlayHints refresh_inlay_hints = 110;
-
- CreateChannel create_channel = 111;
- CreateChannelResponse create_channel_response = 112;
- InviteChannelMember invite_channel_member = 113;
- RemoveChannelMember remove_channel_member = 114;
- RespondToChannelInvite respond_to_channel_invite = 115;
- UpdateChannels update_channels = 116;
- JoinChannel join_channel = 117;
- DeleteChannel delete_channel = 118;
- GetChannelMembers get_channel_members = 119;
- GetChannelMembersResponse get_channel_members_response = 120;
- SetChannelMemberAdmin set_channel_member_admin = 121;
- RenameChannel rename_channel = 122;
- RenameChannelResponse rename_channel_response = 123;
-
- JoinChannelBuffer join_channel_buffer = 124;
- JoinChannelBufferResponse join_channel_buffer_response = 125;
- UpdateChannelBuffer update_channel_buffer = 126;
- LeaveChannelBuffer leave_channel_buffer = 127;
- UpdateChannelBufferCollaborators update_channel_buffer_collaborators = 128;
- RejoinChannelBuffers rejoin_channel_buffers = 129;
- RejoinChannelBuffersResponse rejoin_channel_buffers_response = 130;
- AckBufferOperation ack_buffer_operation = 143;
-
- JoinChannelChat join_channel_chat = 131;
- JoinChannelChatResponse join_channel_chat_response = 132;
- LeaveChannelChat leave_channel_chat = 133;
- SendChannelMessage send_channel_message = 134;
- SendChannelMessageResponse send_channel_message_response = 135;
- ChannelMessageSent channel_message_sent = 136;
- GetChannelMessages get_channel_messages = 137;
- GetChannelMessagesResponse get_channel_messages_response = 138;
- RemoveChannelMessage remove_channel_message = 139;
- AckChannelMessage ack_channel_message = 144;
-
- LinkChannel link_channel = 140;
- UnlinkChannel unlink_channel = 141;
- MoveChannel move_channel = 142; // current max: 144
+ ResolveCompletionDocumentation resolve_completion_documentation = 73;
+ ResolveCompletionDocumentationResponse resolve_completion_documentation_response = 74;
+ ApplyCompletionAdditionalEdits apply_completion_additional_edits = 75;
+ ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 76;
+ GetCodeActions get_code_actions = 77;
+ GetCodeActionsResponse get_code_actions_response = 78;
+ GetHover get_hover = 79;
+ GetHoverResponse get_hover_response = 80;
+ ApplyCodeAction apply_code_action = 81;
+ ApplyCodeActionResponse apply_code_action_response = 82;
+ PrepareRename prepare_rename = 83;
+ PrepareRenameResponse prepare_rename_response = 84;
+ PerformRename perform_rename = 85;
+ PerformRenameResponse perform_rename_response = 86;
+ SearchProject search_project = 87;
+ SearchProjectResponse search_project_response = 88;
+
+ UpdateContacts update_contacts = 89;
+ UpdateInviteInfo update_invite_info = 90;
+ ShowContacts show_contacts = 91;
+
+ GetUsers get_users = 92;
+ FuzzySearchUsers fuzzy_search_users = 93;
+ UsersResponse users_response = 94;
+ RequestContact request_contact = 95;
+ RespondToContactRequest respond_to_contact_request = 96;
+ RemoveContact remove_contact = 97;
+
+ Follow follow = 98;
+ FollowResponse follow_response = 99;
+ UpdateFollowers update_followers = 100;
+ Unfollow unfollow = 101;
+ GetPrivateUserInfo get_private_user_info = 102;
+ GetPrivateUserInfoResponse get_private_user_info_response = 103;
+ UpdateDiffBase update_diff_base = 104;
+
+ OnTypeFormatting on_type_formatting = 105;
+ OnTypeFormattingResponse on_type_formatting_response = 106;
+
+ UpdateWorktreeSettings update_worktree_settings = 107;
+
+ InlayHints inlay_hints = 108;
+ InlayHintsResponse inlay_hints_response = 109;
+ ResolveInlayHint resolve_inlay_hint = 110;
+ ResolveInlayHintResponse resolve_inlay_hint_response = 111;
+ RefreshInlayHints refresh_inlay_hints = 112;
+
+ CreateChannel create_channel = 113;
+ CreateChannelResponse create_channel_response = 114;
+ InviteChannelMember invite_channel_member = 115;
+ RemoveChannelMember remove_channel_member = 116;
+ RespondToChannelInvite respond_to_channel_invite = 117;
+ UpdateChannels update_channels = 118;
+ JoinChannel join_channel = 119;
+ DeleteChannel delete_channel = 120;
+ GetChannelMembers get_channel_members = 121;
+ GetChannelMembersResponse get_channel_members_response = 122;
+ SetChannelMemberRole set_channel_member_role = 123;
+ RenameChannel rename_channel = 124;
+ RenameChannelResponse rename_channel_response = 125;
+
+ JoinChannelBuffer join_channel_buffer = 126;
+ JoinChannelBufferResponse join_channel_buffer_response = 127;
+ UpdateChannelBuffer update_channel_buffer = 128;
+ LeaveChannelBuffer leave_channel_buffer = 129;
+ UpdateChannelBufferCollaborators update_channel_buffer_collaborators = 130;
+ RejoinChannelBuffers rejoin_channel_buffers = 131;
+ RejoinChannelBuffersResponse rejoin_channel_buffers_response = 132;
+ AckBufferOperation ack_buffer_operation = 133;
+
+ JoinChannelChat join_channel_chat = 134;
+ JoinChannelChatResponse join_channel_chat_response = 135;
+ LeaveChannelChat leave_channel_chat = 136;
+ SendChannelMessage send_channel_message = 137;
+ SendChannelMessageResponse send_channel_message_response = 138;
+ ChannelMessageSent channel_message_sent = 139;
+ GetChannelMessages get_channel_messages = 140;
+ GetChannelMessagesResponse get_channel_messages_response = 141;
+ RemoveChannelMessage remove_channel_message = 142;
+ AckChannelMessage ack_channel_message = 143;
+ GetChannelMessagesById get_channel_messages_by_id = 144;
+
+ MoveChannel move_channel = 147;
+ SetChannelVisibility set_channel_visibility = 148;
+
+ AddNotification add_notification = 149;
+ GetNotifications get_notifications = 150;
+ GetNotificationsResponse get_notifications_response = 151;
+ DeleteNotification delete_notification = 152;
+ MarkNotificationRead mark_notification_read = 153; // Current max
}
}
@@ -332,6 +340,7 @@ message RoomUpdated {
message LiveKitConnectionInfo {
string server_url = 1;
string token = 2;
+ bool can_publish = 3;
}
message ShareProject {
@@ -832,6 +841,17 @@ message ResolveState {
}
}
+message ResolveCompletionDocumentation {
+ uint64 project_id = 1;
+ uint64 language_server_id = 2;
+ bytes lsp_completion = 3;
+}
+
+message ResolveCompletionDocumentationResponse {
+ string text = 1;
+ bool is_markdown = 2;
+}
+
message ResolveInlayHint {
uint64 project_id = 1;
uint64 buffer_id = 2;
@@ -950,13 +970,10 @@ message LspDiskBasedDiagnosticsUpdated {}
message UpdateChannels {
repeated Channel channels = 1;
- repeated ChannelEdge insert_edge = 2;
- repeated ChannelEdge delete_edge = 3;
repeated uint64 delete_channels = 4;
repeated Channel channel_invitations = 5;
repeated uint64 remove_channel_invitations = 6;
repeated ChannelParticipants channel_participants = 7;
- repeated ChannelPermission channel_permissions = 8;
repeated UnseenChannelMessage unseen_channel_messages = 9;
repeated UnseenChannelBufferChange unseen_channel_buffer_changes = 10;
}
@@ -972,14 +989,9 @@ message UnseenChannelBufferChange {
repeated VectorClockEntry version = 3;
}
-message ChannelEdge {
- uint64 channel_id = 1;
- uint64 parent_id = 2;
-}
-
message ChannelPermission {
uint64 channel_id = 1;
- bool is_admin = 2;
+ ChannelRole role = 3;
}
message ChannelParticipants {
@@ -1005,8 +1017,8 @@ message GetChannelMembersResponse {
message ChannelMember {
uint64 user_id = 1;
- bool admin = 2;
Kind kind = 3;
+ ChannelRole role = 4;
enum Kind {
Member = 0;
@@ -1028,7 +1040,7 @@ message CreateChannelResponse {
message InviteChannelMember {
uint64 channel_id = 1;
uint64 user_id = 2;
- bool admin = 3;
+ ChannelRole role = 4;
}
message RemoveChannelMember {
@@ -1036,10 +1048,22 @@ message RemoveChannelMember {
uint64 user_id = 2;
}
-message SetChannelMemberAdmin {
+enum ChannelRole {
+ Admin = 0;
+ Member = 1;
+ Guest = 2;
+ Banned = 3;
+}
+
+message SetChannelMemberRole {
uint64 channel_id = 1;
uint64 user_id = 2;
- bool admin = 3;
+ ChannelRole role = 3;
+}
+
+message SetChannelVisibility {
+ uint64 channel_id = 1;
+ ChannelVisibility visibility = 2;
}
message RenameChannel {
@@ -1068,6 +1092,7 @@ message SendChannelMessage {
uint64 channel_id = 1;
string body = 2;
Nonce nonce = 3;
+ repeated ChatMention mentions = 4;
}
message RemoveChannelMessage {
@@ -1099,20 +1124,13 @@ message GetChannelMessagesResponse {
bool done = 2;
}
-message LinkChannel {
- uint64 channel_id = 1;
- uint64 to = 2;
-}
-
-message UnlinkChannel {
- uint64 channel_id = 1;
- uint64 from = 2;
+message GetChannelMessagesById {
+ repeated uint64 message_ids = 1;
}
message MoveChannel {
uint64 channel_id = 1;
- uint64 from = 2;
- uint64 to = 3;
+ optional uint64 to = 2;
}
message JoinChannelBuffer {
@@ -1125,6 +1143,12 @@ message ChannelMessage {
uint64 timestamp = 3;
uint64 sender_id = 4;
Nonce nonce = 5;
+ repeated ChatMention mentions = 6;
+}
+
+message ChatMention {
+ Range range = 1;
+ uint64 user_id = 2;
}
message RejoinChannelBuffers {
@@ -1216,7 +1240,6 @@ message ShowContacts {}
message IncomingContactRequest {
uint64 requester_id = 1;
- bool should_notify = 2;
}
message UpdateDiagnostics {
@@ -1533,16 +1556,23 @@ message Nonce {
uint64 lower_half = 2;
}
+enum ChannelVisibility {
+ Public = 0;
+ Members = 1;
+}
+
message Channel {
uint64 id = 1;
string name = 2;
+ ChannelVisibility visibility = 3;
+ ChannelRole role = 4;
+ repeated uint64 parent_path = 5;
}
message Contact {
uint64 user_id = 1;
bool online = 2;
bool busy = 3;
- bool should_notify = 4;
}
message WorktreeMetadata {
@@ -1557,3 +1587,34 @@ message UpdateDiffBase {
uint64 buffer_id = 2;
optional string diff_base = 3;
}
+
+message GetNotifications {
+ optional uint64 before_id = 1;
+}
+
+message AddNotification {
+ Notification notification = 1;
+}
+
+message GetNotificationsResponse {
+ repeated Notification notifications = 1;
+ bool done = 2;
+}
+
+message DeleteNotification {
+ uint64 notification_id = 1;
+}
+
+message MarkNotificationRead {
+ uint64 notification_id = 1;
+}
+
+message Notification {
+ uint64 id = 1;
+ uint64 timestamp = 2;
+ string kind = 3;
+ optional uint64 entity_id = 4;
+ string content = 5;
+ bool is_read = 6;
+ optional bool response = 7;
+}
@@ -0,0 +1,105 @@
+use crate::proto;
+use serde::{Deserialize, Serialize};
+use serde_json::{map, Value};
+use strum::{EnumVariantNames, VariantNames as _};
+
+const KIND: &'static str = "kind";
+const ENTITY_ID: &'static str = "entity_id";
+
+/// A notification that can be stored, associated with a given recipient.
+///
+/// This struct is stored in the collab database as JSON, so it shouldn't be
+/// changed in a backward-incompatible way. For example, when renaming a
+/// variant, add a serde alias for the old name.
+///
+/// Most notification types have a special field which is aliased to
+/// `entity_id`. This field is stored in its own database column, and can
+/// be used to query the notification.
+#[derive(Debug, Clone, PartialEq, Eq, EnumVariantNames, Serialize, Deserialize)]
+#[serde(tag = "kind")]
+pub enum Notification {
+ ContactRequest {
+ #[serde(rename = "entity_id")]
+ sender_id: u64,
+ },
+ ContactRequestAccepted {
+ #[serde(rename = "entity_id")]
+ responder_id: u64,
+ },
+ ChannelInvitation {
+ #[serde(rename = "entity_id")]
+ channel_id: u64,
+ channel_name: String,
+ inviter_id: u64,
+ },
+ ChannelMessageMention {
+ #[serde(rename = "entity_id")]
+ message_id: u64,
+ sender_id: u64,
+ channel_id: u64,
+ },
+}
+
+impl Notification {
+ pub fn to_proto(&self) -> proto::Notification {
+ let mut value = serde_json::to_value(self).unwrap();
+ let mut entity_id = None;
+ let value = value.as_object_mut().unwrap();
+ let Some(Value::String(kind)) = value.remove(KIND) else {
+ unreachable!("kind is the enum tag")
+ };
+ if let map::Entry::Occupied(e) = value.entry(ENTITY_ID) {
+ if e.get().is_u64() {
+ entity_id = e.remove().as_u64();
+ }
+ }
+ proto::Notification {
+ kind,
+ entity_id,
+ content: serde_json::to_string(&value).unwrap(),
+ ..Default::default()
+ }
+ }
+
+ pub fn from_proto(notification: &proto::Notification) -> Option<Self> {
+ let mut value = serde_json::from_str::<Value>(¬ification.content).ok()?;
+ let object = value.as_object_mut()?;
+ object.insert(KIND.into(), notification.kind.to_string().into());
+ if let Some(entity_id) = notification.entity_id {
+ object.insert(ENTITY_ID.into(), entity_id.into());
+ }
+ serde_json::from_value(value).ok()
+ }
+
+ pub fn all_variant_names() -> &'static [&'static str] {
+ Self::VARIANTS
+ }
+}
+
+#[test]
+fn test_notification() {
+ // Notifications can be serialized and deserialized.
+ for notification in [
+ Notification::ContactRequest { sender_id: 1 },
+ Notification::ContactRequestAccepted { responder_id: 2 },
+ Notification::ChannelInvitation {
+ channel_id: 100,
+ channel_name: "the-channel".into(),
+ inviter_id: 50,
+ },
+ Notification::ChannelMessageMention {
+ sender_id: 200,
+ channel_id: 30,
+ message_id: 1,
+ },
+ ] {
+ let message = notification.to_proto();
+ let deserialized = Notification::from_proto(&message).unwrap();
+ assert_eq!(deserialized, notification);
+ }
+
+ // When notifications are serialized, the `kind` and `actor_id` fields are
+ // stored separately, and do not appear redundantly in the JSON.
+ let notification = Notification::ContactRequest { sender_id: 1 };
+ assert_eq!(notification.to_proto().content, "{}");
+}
@@ -133,6 +133,9 @@ impl fmt::Display for PeerId {
messages!(
(Ack, Foreground),
+ (AckBufferOperation, Background),
+ (AckChannelMessage, Background),
+ (AddNotification, Foreground),
(AddProjectCollaborator, Foreground),
(ApplyCodeAction, Background),
(ApplyCodeActionResponse, Background),
@@ -143,57 +146,74 @@ messages!(
(Call, Foreground),
(CallCanceled, Foreground),
(CancelCall, Foreground),
+ (ChannelMessageSent, Foreground),
(CopyProjectEntry, Foreground),
(CreateBufferForPeer, Foreground),
(CreateChannel, Foreground),
(CreateChannelResponse, Foreground),
- (ChannelMessageSent, Foreground),
(CreateProjectEntry, Foreground),
(CreateRoom, Foreground),
(CreateRoomResponse, Foreground),
(DeclineCall, Foreground),
+ (DeleteChannel, Foreground),
+ (DeleteNotification, Foreground),
(DeleteProjectEntry, Foreground),
(Error, Foreground),
(ExpandProjectEntry, Foreground),
+ (ExpandProjectEntryResponse, Foreground),
(Follow, Foreground),
(FollowResponse, Foreground),
(FormatBuffers, Foreground),
(FormatBuffersResponse, Foreground),
(FuzzySearchUsers, Foreground),
- (GetCodeActions, Background),
- (GetCodeActionsResponse, Background),
- (GetHover, Background),
- (GetHoverResponse, Background),
+ (GetChannelMembers, Foreground),
+ (GetChannelMembersResponse, Foreground),
(GetChannelMessages, Background),
+ (GetChannelMessagesById, Background),
(GetChannelMessagesResponse, Background),
- (SendChannelMessage, Background),
- (SendChannelMessageResponse, Background),
+ (GetCodeActions, Background),
+ (GetCodeActionsResponse, Background),
(GetCompletions, Background),
(GetCompletionsResponse, Background),
(GetDefinition, Background),
(GetDefinitionResponse, Background),
- (GetTypeDefinition, Background),
- (GetTypeDefinitionResponse, Background),
(GetDocumentHighlights, Background),
(GetDocumentHighlightsResponse, Background),
- (GetReferences, Background),
- (GetReferencesResponse, Background),
+ (GetHover, Background),
+ (GetHoverResponse, Background),
+ (GetNotifications, Foreground),
+ (GetNotificationsResponse, Foreground),
+ (GetPrivateUserInfo, Foreground),
+ (GetPrivateUserInfoResponse, Foreground),
(GetProjectSymbols, Background),
(GetProjectSymbolsResponse, Background),
+ (GetReferences, Background),
+ (GetReferencesResponse, Background),
+ (GetTypeDefinition, Background),
+ (GetTypeDefinitionResponse, Background),
(GetUsers, Foreground),
(Hello, Foreground),
(IncomingCall, Foreground),
+ (InlayHints, Background),
+ (InlayHintsResponse, Background),
(InviteChannelMember, Foreground),
- (UsersResponse, Foreground),
+ (JoinChannel, Foreground),
+ (JoinChannelBuffer, Foreground),
+ (JoinChannelBufferResponse, Foreground),
+ (JoinChannelChat, Foreground),
+ (JoinChannelChatResponse, Foreground),
(JoinProject, Foreground),
(JoinProjectResponse, Foreground),
(JoinRoom, Foreground),
(JoinRoomResponse, Foreground),
- (JoinChannelChat, Foreground),
- (JoinChannelChatResponse, Foreground),
+ (LeaveChannelBuffer, Background),
(LeaveChannelChat, Foreground),
(LeaveProject, Foreground),
(LeaveRoom, Foreground),
+ (MarkNotificationRead, Foreground),
+ (MoveChannel, Foreground),
+ (OnTypeFormatting, Background),
+ (OnTypeFormattingResponse, Background),
(OpenBufferById, Background),
(OpenBufferByPath, Background),
(OpenBufferForSymbol, Background),
@@ -201,58 +221,56 @@ messages!(
(OpenBufferResponse, Background),
(PerformRename, Background),
(PerformRenameResponse, Background),
- (OnTypeFormatting, Background),
- (OnTypeFormattingResponse, Background),
- (InlayHints, Background),
- (InlayHintsResponse, Background),
- (ResolveInlayHint, Background),
- (ResolveInlayHintResponse, Background),
- (RefreshInlayHints, Foreground),
(Ping, Foreground),
(PrepareRename, Background),
(PrepareRenameResponse, Background),
- (ExpandProjectEntryResponse, Foreground),
(ProjectEntryResponse, Foreground),
+ (RefreshInlayHints, Foreground),
+ (RejoinChannelBuffers, Foreground),
+ (RejoinChannelBuffersResponse, Foreground),
(RejoinRoom, Foreground),
(RejoinRoomResponse, Foreground),
- (RemoveContact, Foreground),
- (RemoveChannelMember, Foreground),
- (RemoveChannelMessage, Foreground),
(ReloadBuffers, Foreground),
(ReloadBuffersResponse, Foreground),
+ (RemoveChannelMember, Foreground),
+ (RemoveChannelMessage, Foreground),
+ (RemoveContact, Foreground),
(RemoveProjectCollaborator, Foreground),
+ (RenameChannel, Foreground),
+ (RenameChannelResponse, Foreground),
(RenameProjectEntry, Foreground),
(RequestContact, Foreground),
- (RespondToContactRequest, Foreground),
+ (ResolveCompletionDocumentation, Background),
+ (ResolveCompletionDocumentationResponse, Background),
+ (ResolveInlayHint, Background),
+ (ResolveInlayHintResponse, Background),
(RespondToChannelInvite, Foreground),
- (JoinChannel, Foreground),
+ (RespondToContactRequest, Foreground),
(RoomUpdated, Foreground),
(SaveBuffer, Foreground),
- (RenameChannel, Foreground),
- (RenameChannelResponse, Foreground),
- (SetChannelMemberAdmin, Foreground),
+ (SetChannelMemberRole, Foreground),
+ (SetChannelVisibility, Foreground),
(SearchProject, Background),
(SearchProjectResponse, Background),
+ (SendChannelMessage, Background),
+ (SendChannelMessageResponse, Background),
(ShareProject, Foreground),
(ShareProjectResponse, Foreground),
(ShowContacts, Foreground),
(StartLanguageServer, Foreground),
(SynchronizeBuffers, Foreground),
(SynchronizeBuffersResponse, Foreground),
- (RejoinChannelBuffers, Foreground),
- (RejoinChannelBuffersResponse, Foreground),
(Test, Foreground),
(Unfollow, Foreground),
(UnshareProject, Foreground),
(UpdateBuffer, Foreground),
(UpdateBufferFile, Foreground),
- (UpdateContacts, Foreground),
- (DeleteChannel, Foreground),
- (MoveChannel, Foreground),
- (LinkChannel, Foreground),
- (UnlinkChannel, Foreground),
+ (UpdateChannelBuffer, Foreground),
+ (UpdateChannelBufferCollaborators, Foreground),
(UpdateChannels, Foreground),
+ (UpdateContacts, Foreground),
(UpdateDiagnosticSummary, Foreground),
+ (UpdateDiffBase, Foreground),
(UpdateFollowers, Foreground),
(UpdateInviteInfo, Foreground),
(UpdateLanguageServer, Foreground),
@@ -261,18 +279,7 @@ messages!(
(UpdateProjectCollaborator, Foreground),
(UpdateWorktree, Foreground),
(UpdateWorktreeSettings, Foreground),
- (UpdateDiffBase, Foreground),
- (GetPrivateUserInfo, Foreground),
- (GetPrivateUserInfoResponse, Foreground),
- (GetChannelMembers, Foreground),
- (GetChannelMembersResponse, Foreground),
- (JoinChannelBuffer, Foreground),
- (JoinChannelBufferResponse, Foreground),
- (LeaveChannelBuffer, Background),
- (UpdateChannelBuffer, Foreground),
- (UpdateChannelBufferCollaborators, Foreground),
- (AckBufferOperation, Background),
- (AckChannelMessage, Background),
+ (UsersResponse, Foreground),
);
request_messages!(
@@ -284,72 +291,78 @@ request_messages!(
(Call, Ack),
(CancelCall, Ack),
(CopyProjectEntry, ProjectEntryResponse),
+ (CreateChannel, CreateChannelResponse),
(CreateProjectEntry, ProjectEntryResponse),
(CreateRoom, CreateRoomResponse),
- (CreateChannel, CreateChannelResponse),
(DeclineCall, Ack),
+ (DeleteChannel, Ack),
(DeleteProjectEntry, ProjectEntryResponse),
(ExpandProjectEntry, ExpandProjectEntryResponse),
(Follow, FollowResponse),
(FormatBuffers, FormatBuffersResponse),
+ (FuzzySearchUsers, UsersResponse),
+ (GetChannelMembers, GetChannelMembersResponse),
+ (GetChannelMessages, GetChannelMessagesResponse),
+ (GetChannelMessagesById, GetChannelMessagesResponse),
(GetCodeActions, GetCodeActionsResponse),
- (GetHover, GetHoverResponse),
(GetCompletions, GetCompletionsResponse),
(GetDefinition, GetDefinitionResponse),
- (GetTypeDefinition, GetTypeDefinitionResponse),
(GetDocumentHighlights, GetDocumentHighlightsResponse),
- (GetReferences, GetReferencesResponse),
+ (GetHover, GetHoverResponse),
+ (GetNotifications, GetNotificationsResponse),
(GetPrivateUserInfo, GetPrivateUserInfoResponse),
(GetProjectSymbols, GetProjectSymbolsResponse),
- (FuzzySearchUsers, UsersResponse),
+ (GetReferences, GetReferencesResponse),
+ (GetTypeDefinition, GetTypeDefinitionResponse),
(GetUsers, UsersResponse),
+ (IncomingCall, Ack),
+ (InlayHints, InlayHintsResponse),
(InviteChannelMember, Ack),
+ (JoinChannel, JoinRoomResponse),
+ (JoinChannelBuffer, JoinChannelBufferResponse),
+ (JoinChannelChat, JoinChannelChatResponse),
(JoinProject, JoinProjectResponse),
(JoinRoom, JoinRoomResponse),
- (JoinChannelChat, JoinChannelChatResponse),
+ (LeaveChannelBuffer, Ack),
(LeaveRoom, Ack),
- (RejoinRoom, RejoinRoomResponse),
- (IncomingCall, Ack),
+ (MarkNotificationRead, Ack),
+ (MoveChannel, Ack),
+ (OnTypeFormatting, OnTypeFormattingResponse),
(OpenBufferById, OpenBufferResponse),
(OpenBufferByPath, OpenBufferResponse),
(OpenBufferForSymbol, OpenBufferForSymbolResponse),
- (Ping, Ack),
(PerformRename, PerformRenameResponse),
+ (Ping, Ack),
(PrepareRename, PrepareRenameResponse),
- (OnTypeFormatting, OnTypeFormattingResponse),
- (InlayHints, InlayHintsResponse),
- (ResolveInlayHint, ResolveInlayHintResponse),
(RefreshInlayHints, Ack),
+ (RejoinChannelBuffers, RejoinChannelBuffersResponse),
+ (RejoinRoom, RejoinRoomResponse),
(ReloadBuffers, ReloadBuffersResponse),
- (RequestContact, Ack),
(RemoveChannelMember, Ack),
- (RemoveContact, Ack),
- (RespondToContactRequest, Ack),
- (RespondToChannelInvite, Ack),
- (SetChannelMemberAdmin, Ack),
- (SendChannelMessage, SendChannelMessageResponse),
- (GetChannelMessages, GetChannelMessagesResponse),
- (GetChannelMembers, GetChannelMembersResponse),
- (JoinChannel, JoinRoomResponse),
(RemoveChannelMessage, Ack),
- (DeleteChannel, Ack),
- (RenameProjectEntry, ProjectEntryResponse),
+ (RemoveContact, Ack),
(RenameChannel, RenameChannelResponse),
- (LinkChannel, Ack),
- (UnlinkChannel, Ack),
- (MoveChannel, Ack),
+ (RenameProjectEntry, ProjectEntryResponse),
+ (RequestContact, Ack),
+ (
+ ResolveCompletionDocumentation,
+ ResolveCompletionDocumentationResponse
+ ),
+ (ResolveInlayHint, ResolveInlayHintResponse),
+ (RespondToChannelInvite, Ack),
+ (RespondToContactRequest, Ack),
(SaveBuffer, BufferSaved),
(SearchProject, SearchProjectResponse),
+ (SendChannelMessage, SendChannelMessageResponse),
+ (SetChannelMemberRole, Ack),
+ (SetChannelVisibility, Ack),
(ShareProject, ShareProjectResponse),
(SynchronizeBuffers, SynchronizeBuffersResponse),
- (RejoinChannelBuffers, RejoinChannelBuffersResponse),
(Test, Test),
(UpdateBuffer, Ack),
(UpdateParticipantLocation, Ack),
(UpdateProject, Ack),
(UpdateWorktree, Ack),
- (JoinChannelBuffer, JoinChannelBufferResponse),
- (LeaveChannelBuffer, Ack)
);
entity_messages!(
@@ -368,25 +381,26 @@ entity_messages!(
GetCodeActions,
GetCompletions,
GetDefinition,
- GetTypeDefinition,
GetDocumentHighlights,
GetHover,
- GetReferences,
GetProjectSymbols,
+ GetReferences,
+ GetTypeDefinition,
+ InlayHints,
JoinProject,
LeaveProject,
+ OnTypeFormatting,
OpenBufferById,
OpenBufferByPath,
OpenBufferForSymbol,
PerformRename,
- OnTypeFormatting,
- InlayHints,
- ResolveInlayHint,
- RefreshInlayHints,
PrepareRename,
+ RefreshInlayHints,
ReloadBuffers,
RemoveProjectCollaborator,
RenameProjectEntry,
+ ResolveCompletionDocumentation,
+ ResolveInlayHint,
SaveBuffer,
SearchProject,
StartLanguageServer,
@@ -395,19 +409,19 @@ entity_messages!(
UpdateBuffer,
UpdateBufferFile,
UpdateDiagnosticSummary,
+ UpdateDiffBase,
UpdateLanguageServer,
UpdateProject,
UpdateProjectCollaborator,
UpdateWorktree,
UpdateWorktreeSettings,
- UpdateDiffBase
);
entity_messages!(
channel_id,
ChannelMessageSent,
- UpdateChannelBuffer,
RemoveChannelMessage,
+ UpdateChannelBuffer,
UpdateChannelBufferCollaborators,
);
@@ -1,9 +1,12 @@
pub mod auth;
mod conn;
+mod notification;
mod peer;
pub mod proto;
+
pub use conn::Connection;
+pub use notification::*;
pub use peer::*;
mod macros;
-pub const PROTOCOL_VERSION: u32 = 64;
+pub const PROTOCOL_VERSION: u32 = 66;
@@ -537,6 +537,7 @@ impl BufferSearchBar {
self.active_searchable_item
.as_ref()
.map(|searchable_item| searchable_item.query_suggestion(cx))
+ .filter(|suggestion| !suggestion.is_empty())
}
pub fn set_replacement(&mut self, replacement: Option<&str>, cx: &mut ViewContext<Self>) {
@@ -351,33 +351,32 @@ impl View for ProjectSearchView {
SemanticIndexStatus::NotAuthenticated => {
major_text = Cow::Borrowed("Not Authenticated");
show_minor_text = false;
- Some(
- "API Key Missing: Please set 'OPENAI_API_KEY' in Environment Variables"
- .to_string(),
- )
+ Some(vec![
+ "API Key Missing: Please set 'OPENAI_API_KEY' in Environment Variables."
+ .to_string(), "If you authenticated using the Assistant Panel, please restart Zed to Authenticate.".to_string()])
}
- SemanticIndexStatus::Indexed => Some("Indexing complete".to_string()),
+ SemanticIndexStatus::Indexed => Some(vec!["Indexing complete".to_string()]),
SemanticIndexStatus::Indexing {
remaining_files,
rate_limit_expiry,
} => {
if remaining_files == 0 {
- Some(format!("Indexing..."))
+ Some(vec![format!("Indexing...")])
} else {
if let Some(rate_limit_expiry) = rate_limit_expiry {
let remaining_seconds =
rate_limit_expiry.duration_since(Instant::now());
if remaining_seconds > Duration::from_secs(0) {
- Some(format!(
+ Some(vec![format!(
"Remaining files to index (rate limit resets in {}s): {}",
remaining_seconds.as_secs(),
remaining_files
- ))
+ )])
} else {
- Some(format!("Remaining files to index: {}", remaining_files))
+ Some(vec![format!("Remaining files to index: {}", remaining_files)])
}
} else {
- Some(format!("Remaining files to index: {}", remaining_files))
+ Some(vec![format!("Remaining files to index: {}", remaining_files)])
}
}
}
@@ -394,9 +393,11 @@ impl View for ProjectSearchView {
} else {
match current_mode {
SearchMode::Semantic => {
- let mut minor_text = Vec::new();
+ let mut minor_text: Vec<String> = Vec::new();
minor_text.push("".into());
- minor_text.extend(semantic_status);
+ if let Some(semantic_status) = semantic_status {
+ minor_text.extend(semantic_status);
+ }
if show_minor_text {
minor_text
.push("Simply explain the code you are looking to find.".into());
@@ -51,7 +51,6 @@ workspace = { path = "../workspace", features = ["test-support"] }
settings = { path = "../settings", features = ["test-support"]}
rust-embed = { version = "8.0", features = ["include-exclude"] }
client = { path = "../client" }
-zed = { path = "../zed"}
node_runtime = { path = "../node_runtime"}
pretty_assertions.workspace = true
@@ -70,6 +69,3 @@ tree-sitter-elixir.workspace = true
tree-sitter-lua.workspace = true
tree-sitter-ruby.workspace = true
tree-sitter-php.workspace = true
-
-[[example]]
-name = "eval"
@@ -41,6 +41,7 @@ pub struct EmbeddingQueue {
pending_batch_token_count: usize,
finished_files_tx: channel::Sender<FileToEmbed>,
finished_files_rx: channel::Receiver<FileToEmbed>,
+ api_key: Option<String>,
}
#[derive(Clone)]
@@ -50,7 +51,11 @@ pub struct FileFragmentToEmbed {
}
impl EmbeddingQueue {
- pub fn new(embedding_provider: Arc<dyn EmbeddingProvider>, executor: Arc<Background>) -> Self {
+ pub fn new(
+ embedding_provider: Arc<dyn EmbeddingProvider>,
+ executor: Arc<Background>,
+ api_key: Option<String>,
+ ) -> Self {
let (finished_files_tx, finished_files_rx) = channel::unbounded();
Self {
embedding_provider,
@@ -59,9 +64,14 @@ impl EmbeddingQueue {
pending_batch_token_count: 0,
finished_files_tx,
finished_files_rx,
+ api_key,
}
}
+ pub fn set_api_key(&mut self, api_key: Option<String>) {
+ self.api_key = api_key
+ }
+
pub fn push(&mut self, file: FileToEmbed) {
if file.spans.is_empty() {
self.finished_files_tx.try_send(file).unwrap();
@@ -108,6 +118,7 @@ impl EmbeddingQueue {
let finished_files_tx = self.finished_files_tx.clone();
let embedding_provider = self.embedding_provider.clone();
+ let api_key = self.api_key.clone();
self.executor
.spawn(async move {
@@ -132,7 +143,7 @@ impl EmbeddingQueue {
return;
};
- match embedding_provider.embed_batch(spans).await {
+ match embedding_provider.embed_batch(spans, api_key).await {
Ok(embeddings) => {
let mut embeddings = embeddings.into_iter();
for fragment in batch {
@@ -123,6 +123,8 @@ pub struct SemanticIndex {
_embedding_task: Task<()>,
_parsing_files_tasks: Vec<Task<()>>,
projects: HashMap<WeakModelHandle<Project>, ProjectState>,
+ api_key: Option<String>,
+ embedding_queue: Arc<Mutex<EmbeddingQueue>>,
}
struct ProjectState {
@@ -268,7 +270,7 @@ pub struct SearchResult {
}
impl SemanticIndex {
- pub fn global(cx: &AppContext) -> Option<ModelHandle<SemanticIndex>> {
+ pub fn global(cx: &mut AppContext) -> Option<ModelHandle<SemanticIndex>> {
if cx.has_global::<ModelHandle<Self>>() {
Some(cx.global::<ModelHandle<SemanticIndex>>().clone())
} else {
@@ -276,12 +278,26 @@ impl SemanticIndex {
}
}
+ pub fn authenticate(&mut self, cx: &AppContext) {
+ if self.api_key.is_none() {
+ self.api_key = self.embedding_provider.retrieve_credentials(cx);
+
+ self.embedding_queue
+ .lock()
+ .set_api_key(self.api_key.clone());
+ }
+ }
+
+ pub fn is_authenticated(&self) -> bool {
+ self.api_key.is_some()
+ }
+
pub fn enabled(cx: &AppContext) -> bool {
settings::get::<SemanticIndexSettings>(cx).enabled
}
pub fn status(&self, project: &ModelHandle<Project>) -> SemanticIndexStatus {
- if !self.embedding_provider.is_authenticated() {
+ if !self.is_authenticated() {
return SemanticIndexStatus::NotAuthenticated;
}
@@ -323,7 +339,7 @@ impl SemanticIndex {
Ok(cx.add_model(|cx| {
let t0 = Instant::now();
let embedding_queue =
- EmbeddingQueue::new(embedding_provider.clone(), cx.background().clone());
+ EmbeddingQueue::new(embedding_provider.clone(), cx.background().clone(), None);
let _embedding_task = cx.background().spawn({
let embedded_files = embedding_queue.finished_files();
let db = db.clone();
@@ -388,6 +404,8 @@ impl SemanticIndex {
_embedding_task,
_parsing_files_tasks,
projects: Default::default(),
+ api_key: None,
+ embedding_queue
}
}))
}
@@ -702,12 +720,13 @@ impl SemanticIndex {
let index = self.index_project(project.clone(), cx);
let embedding_provider = self.embedding_provider.clone();
+ let api_key = self.api_key.clone();
cx.spawn(|this, mut cx| async move {
index.await?;
let t0 = Instant::now();
let query = embedding_provider
- .embed_batch(vec![query])
+ .embed_batch(vec![query], api_key)
.await?
.pop()
.ok_or_else(|| anyhow!("could not embed query"))?;
@@ -925,6 +944,7 @@ impl SemanticIndex {
let fs = self.fs.clone();
let db_path = self.db.path().clone();
let background = cx.background().clone();
+ let api_key = self.api_key.clone();
cx.background().spawn(async move {
let db = VectorDatabase::new(fs, db_path.clone(), background).await?;
let mut results = Vec::<SearchResult>::new();
@@ -939,10 +959,15 @@ impl SemanticIndex {
.parse_file_with_template(None, &snapshot.text(), language)
.log_err()
.unwrap_or_default();
- if Self::embed_spans(&mut spans, embedding_provider.as_ref(), &db)
- .await
- .log_err()
- .is_some()
+ if Self::embed_spans(
+ &mut spans,
+ embedding_provider.as_ref(),
+ &db,
+ api_key.clone(),
+ )
+ .await
+ .log_err()
+ .is_some()
{
for span in spans {
let similarity = span.embedding.unwrap().similarity(&query);
@@ -982,8 +1007,11 @@ impl SemanticIndex {
project: ModelHandle<Project>,
cx: &mut ModelContext<Self>,
) -> Task<Result<()>> {
- if !self.embedding_provider.is_authenticated() {
- return Task::ready(Err(anyhow!("user is not authenticated")));
+ if self.api_key.is_none() {
+ self.authenticate(cx);
+ if self.api_key.is_none() {
+ return Task::ready(Err(anyhow!("user is not authenticated")));
+ }
}
if !self.projects.contains_key(&project.downgrade()) {
@@ -1164,6 +1192,7 @@ impl SemanticIndex {
spans: &mut [Span],
embedding_provider: &dyn EmbeddingProvider,
db: &VectorDatabase,
+ api_key: Option<String>,
) -> Result<()> {
let mut batch = Vec::new();
let mut batch_tokens = 0;
@@ -1186,7 +1215,7 @@ impl SemanticIndex {
if batch_tokens + span.token_count > embedding_provider.max_tokens_per_batch() {
let batch_embeddings = embedding_provider
- .embed_batch(mem::take(&mut batch))
+ .embed_batch(mem::take(&mut batch), api_key.clone())
.await?;
embeddings.extend(batch_embeddings);
batch_tokens = 0;
@@ -1198,7 +1227,7 @@ impl SemanticIndex {
if !batch.is_empty() {
let batch_embeddings = embedding_provider
- .embed_batch(mem::take(&mut batch))
+ .embed_batch(mem::take(&mut batch), api_key)
.await?;
embeddings.extend(batch_embeddings);
@@ -7,7 +7,7 @@ use crate::{
use ai::embedding::{DummyEmbeddings, Embedding, EmbeddingProvider};
use anyhow::Result;
use async_trait::async_trait;
-use gpui::{executor::Deterministic, Task, TestAppContext};
+use gpui::{executor::Deterministic, AppContext, Task, TestAppContext};
use language::{Language, LanguageConfig, LanguageRegistry, ToOffset};
use parking_lot::Mutex;
use pretty_assertions::assert_eq;
@@ -228,7 +228,7 @@ async fn test_embedding_batching(cx: &mut TestAppContext, mut rng: StdRng) {
let embedding_provider = Arc::new(FakeEmbeddingProvider::default());
- let mut queue = EmbeddingQueue::new(embedding_provider.clone(), cx.background());
+ let mut queue = EmbeddingQueue::new(embedding_provider.clone(), cx.background(), None);
for file in &files {
queue.push(file.clone());
}
@@ -1281,8 +1281,8 @@ impl FakeEmbeddingProvider {
#[async_trait]
impl EmbeddingProvider for FakeEmbeddingProvider {
- fn is_authenticated(&self) -> bool {
- true
+ fn retrieve_credentials(&self, _cx: &AppContext) -> Option<String> {
+ Some("Fake Credentials".to_string())
}
fn truncate(&self, span: &str) -> (String, usize) {
(span.to_string(), 1)
@@ -1296,7 +1296,11 @@ impl EmbeddingProvider for FakeEmbeddingProvider {
None
}
- async fn embed_batch(&self, spans: Vec<String>) -> Result<Vec<Embedding>> {
+ async fn embed_batch(
+ &self,
+ spans: Vec<String>,
+ _api_key: Option<String>,
+ ) -> Result<Vec<Embedding>> {
self.embedding_count
.fetch_add(spans.len(), atomic::Ordering::SeqCst);
Ok(spans.iter().map(|span| self.embed_sync(span)).collect())
@@ -150,11 +150,14 @@ impl TerminalView {
cx.notify();
cx.emit(Event::Wakeup);
}
+
Event::Bell => {
this.has_bell = true;
cx.emit(Event::Wakeup);
}
+
Event::BlinkChanged => this.blinking_on = !this.blinking_on,
+
Event::TitleChanged => {
if let Some(foreground_info) = &this.terminal().read(cx).foreground_process_info {
let cwd = foreground_info.cwd.clone();
@@ -171,6 +174,7 @@ impl TerminalView {
.detach();
}
}
+
Event::NewNavigationTarget(maybe_navigation_target) => {
this.can_navigate_to_selected_word = match maybe_navigation_target {
Some(MaybeNavigationTarget::Url(_)) => true,
@@ -180,8 +184,10 @@ impl TerminalView {
None => false,
}
}
+
Event::Open(maybe_navigation_target) => match maybe_navigation_target {
MaybeNavigationTarget::Url(url) => cx.platform().open_url(url),
+
MaybeNavigationTarget::PathLike(maybe_path) => {
if !this.can_navigate_to_selected_word {
return;
@@ -246,6 +252,7 @@ impl TerminalView {
}
}
},
+
_ => cx.emit(event.clone()),
})
.detach();
@@ -2,14 +2,15 @@ use crate::{Anchor, BufferSnapshot, TextDimension};
use std::cmp::Ordering;
use std::ops::Range;
-#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+#[derive(Copy, Clone, Debug, PartialEq)]
pub enum SelectionGoal {
None,
- Column(u32),
- ColumnRange { start: u32, end: u32 },
+ HorizontalPosition(f32),
+ HorizontalRange { start: f32, end: f32 },
+ WrappedHorizontalPosition((u32, f32)),
}
-#[derive(Clone, Debug, Eq, PartialEq)]
+#[derive(Clone, Debug, PartialEq)]
pub struct Selection<T> {
pub id: usize,
pub start: T,
@@ -53,6 +53,7 @@ pub struct Theme {
pub collab_panel: CollabPanel,
pub project_panel: ProjectPanel,
pub chat_panel: ChatPanel,
+ pub notification_panel: NotificationPanel,
pub command_palette: CommandPalette,
pub picker: Picker,
pub editor: Editor,
@@ -249,6 +250,7 @@ pub struct CollabPanel {
pub add_contact_button: Toggleable<Interactive<IconButton>>,
pub add_channel_button: Toggleable<Interactive<IconButton>>,
pub header_row: ContainedText,
+ pub dragged_over_header: ContainerStyle,
pub subheader_row: Toggleable<Interactive<ContainedText>>,
pub leave_call: Interactive<ContainedText>,
pub contact_row: Toggleable<Interactive<ContainerStyle>>,
@@ -286,6 +288,8 @@ pub struct TabbedModal {
pub header: ContainerStyle,
pub body: ContainerStyle,
pub title: ContainedText,
+ pub visibility_toggle: Interactive<ContainedText>,
+ pub channel_link: Interactive<ContainedText>,
pub picker: Picker,
pub max_height: f32,
pub max_width: f32,
@@ -636,21 +640,43 @@ pub struct ChatPanel {
pub input_editor: FieldEditor,
pub avatar: AvatarStyle,
pub avatar_container: ContainerStyle,
- pub message: ChatMessage,
- pub continuation_message: ChatMessage,
+ pub rich_text: RichTextStyle,
+ pub message_sender: ContainedText,
+ pub message_timestamp: ContainedText,
+ pub message: Interactive<ContainerStyle>,
+ pub continuation_message: Interactive<ContainerStyle>,
+ pub pending_message: Interactive<ContainerStyle>,
pub last_message_bottom_spacing: f32,
- pub pending_message: ChatMessage,
pub sign_in_prompt: Interactive<TextStyle>,
pub icon_button: Interactive<IconButton>,
}
+#[derive(Clone, Deserialize, Default, JsonSchema)]
+pub struct RichTextStyle {
+ pub text: TextStyle,
+ pub mention_highlight: HighlightStyle,
+ pub mention_background: Option<Color>,
+ pub self_mention_highlight: HighlightStyle,
+ pub self_mention_background: Option<Color>,
+ pub code_background: Option<Color>,
+}
+
#[derive(Deserialize, Default, JsonSchema)]
-pub struct ChatMessage {
+pub struct NotificationPanel {
#[serde(flatten)]
- pub container: Interactive<ContainerStyle>,
- pub body: TextStyle,
- pub sender: ContainedText,
+ pub container: ContainerStyle,
+ pub title: ContainedText,
+ pub title_icon: SvgStyle,
+ pub title_height: f32,
+ pub list: ContainerStyle,
+ pub avatar: AvatarStyle,
+ pub avatar_container: ContainerStyle,
+ pub sign_in_prompt: Interactive<TextStyle>,
+ pub icon_button: Interactive<IconButton>,
+ pub unread_text: ContainedText,
+ pub read_text: ContainedText,
pub timestamp: ContainedText,
+ pub button: Interactive<ContainedText>,
}
#[derive(Deserialize, Default, JsonSchema)]
@@ -867,9 +893,13 @@ pub struct AutocompleteStyle {
pub selected_item: ContainerStyle,
pub hovered_item: ContainerStyle,
pub match_highlight: HighlightStyle,
- pub server_name_container: ContainerStyle,
- pub server_name_color: Color,
- pub server_name_size_percent: f32,
+ pub completion_min_width: f32,
+ pub completion_max_width: f32,
+ pub inline_docs_container: ContainerStyle,
+ pub inline_docs_color: Color,
+ pub inline_docs_size_percent: f32,
+ pub alongside_docs_max_width: f32,
+ pub alongside_docs_container: ContainerStyle,
}
#[derive(Clone, Copy, Default, Deserialize, JsonSchema)]
@@ -1195,6 +1225,15 @@ pub struct InlineAssistantStyle {
pub disabled_editor: FieldEditor,
pub pending_edit_background: Color,
pub include_conversation: ToggleIconButtonStyle,
+ pub retrieve_context: ToggleIconButtonStyle,
+ pub context_status: ContextStatusStyle,
+}
+
+#[derive(Clone, Deserialize, Default, JsonSchema)]
+pub struct ContextStatusStyle {
+ pub error_icon: Icon,
+ pub in_progress_icon: Icon,
+ pub complete_icon: Icon,
}
#[derive(Clone, Deserialize, Default, JsonSchema)]
@@ -1,5 +1,5 @@
use crate::http::HttpClient;
-use anyhow::{anyhow, Context, Result};
+use anyhow::{anyhow, bail, Context, Result};
use futures::AsyncReadExt;
use serde::Deserialize;
use std::sync::Arc;
@@ -16,6 +16,7 @@ pub struct GithubRelease {
pub pre_release: bool,
pub assets: Vec<GithubReleaseAsset>,
pub tarball_url: String,
+ pub zipball_url: String,
}
#[derive(Deserialize, Debug)]
@@ -45,6 +46,14 @@ pub async fn latest_github_release(
.await
.context("error reading latest release")?;
+ if response.status().is_client_error() {
+ let text = String::from_utf8_lossy(body.as_slice());
+ bail!(
+ "status error {}, response: {text:?}",
+ response.status().as_u16()
+ );
+ }
+
let releases = match serde_json::from_slice::<Vec<GithubRelease>>(body.as_slice()) {
Ok(releases) => releases,
@@ -350,19 +350,19 @@ pub fn unzip_option<T, U>(option: Option<(T, U)>) -> (Option<T>, Option<U>) {
}
}
-/// Immediately invoked function expression. Good for using the ? operator
+/// Evaluates to an immediately invoked function expression. Good for using the ? operator
/// in functions which do not return an Option or Result
#[macro_export]
-macro_rules! iife {
+macro_rules! maybe {
($block:block) => {
(|| $block)()
};
}
-/// Async Immediately invoked function expression. Good for using the ? operator
-/// in functions which do not return an Option or Result. Async version of above
+/// Evaluates to an immediately invoked function expression. Good for using the ? operator
+/// in functions which do not return an Option or Result, but async.
#[macro_export]
-macro_rules! async_iife {
+macro_rules! async_maybe {
($block:block) => {
(|| async move { $block })()
};
@@ -449,7 +449,7 @@ mod tests {
None
}
- let foo = iife!({
+ let foo = maybe!({
option_returning_function()?;
Some(())
});
@@ -7,6 +7,7 @@ publish = false
[dependencies]
fuzzy = {path = "../fuzzy"}
+fs = {path = "../fs"}
gpui = {path = "../gpui"}
picker = {path = "../picker"}
util = {path = "../util"}
@@ -1,4 +1,5 @@
use anyhow::{anyhow, bail, Result};
+use fs::repository::Branch;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
actions,
@@ -15,59 +16,42 @@ actions!(branches, [OpenRecent]);
pub fn init(cx: &mut AppContext) {
Picker::<BranchListDelegate>::init(cx);
- cx.add_async_action(toggle);
+ cx.add_action(toggle);
}
pub type BranchList = Picker<BranchListDelegate>;
pub fn build_branch_list(
workspace: ViewHandle<Workspace>,
cx: &mut ViewContext<BranchList>,
-) -> BranchList {
- Picker::new(
- BranchListDelegate {
- matches: vec![],
- workspace,
- selected_index: 0,
- last_query: String::default(),
- branch_name_trailoff_after: 29,
- },
- cx,
- )
- .with_theme(|theme| theme.picker.clone())
+) -> Result<BranchList> {
+ let delegate = workspace.read_with(cx, |workspace, cx| {
+ BranchListDelegate::new(workspace, cx.handle(), 29, cx)
+ })?;
+
+ Ok(Picker::new(delegate, cx).with_theme(|theme| theme.picker.clone()))
}
fn toggle(
- _: &mut Workspace,
+ workspace: &mut Workspace,
_: &OpenRecent,
cx: &mut ViewContext<Workspace>,
-) -> Option<Task<Result<()>>> {
- Some(cx.spawn(|workspace, mut cx| async move {
- workspace.update(&mut cx, |workspace, cx| {
- workspace.toggle_modal(cx, |_, cx| {
- let workspace = cx.handle();
- cx.add_view(|cx| {
- Picker::new(
- BranchListDelegate {
- matches: vec![],
- workspace,
- selected_index: 0,
- last_query: String::default(),
- /// Modal branch picker has a longer trailoff than a popover one.
- branch_name_trailoff_after: 70,
- },
- cx,
- )
- .with_theme(|theme| theme.picker.clone())
- .with_max_size(800., 1200.)
- })
- });
- })?;
- Ok(())
- }))
+) -> Result<()> {
+ // Modal branch picker has a longer trailoff than a popover one.
+ let delegate = BranchListDelegate::new(workspace, cx.handle(), 70, cx)?;
+ workspace.toggle_modal(cx, |_, cx| {
+ cx.add_view(|cx| {
+ Picker::new(delegate, cx)
+ .with_theme(|theme| theme.picker.clone())
+ .with_max_size(800., 1200.)
+ })
+ });
+
+ Ok(())
}
pub struct BranchListDelegate {
matches: Vec<StringMatch>,
+ all_branches: Vec<Branch>,
workspace: ViewHandle<Workspace>,
selected_index: usize,
last_query: String,
@@ -76,6 +60,33 @@ pub struct BranchListDelegate {
}
impl BranchListDelegate {
+ fn new(
+ workspace: &Workspace,
+ handle: ViewHandle<Workspace>,
+ branch_name_trailoff_after: usize,
+ cx: &AppContext,
+ ) -> Result<Self> {
+ let project = workspace.project().read(&cx);
+ let Some(worktree) = project.visible_worktrees(cx).next() else {
+ bail!("Cannot update branch list as there are no visible worktrees")
+ };
+
+ let mut cwd = worktree.read(cx).abs_path().to_path_buf();
+ cwd.push(".git");
+ let Some(repo) = project.fs().open_repo(&cwd) else {
+ bail!("Project does not have associated git repository.")
+ };
+ let all_branches = repo.lock().branches()?;
+ Ok(Self {
+ matches: vec![],
+ workspace: handle,
+ all_branches,
+ selected_index: 0,
+ last_query: Default::default(),
+ branch_name_trailoff_after,
+ })
+ }
+
fn display_error_toast(&self, message: String, cx: &mut ViewContext<BranchList>) {
const GIT_CHECKOUT_FAILURE_ID: usize = 2048;
self.workspace.update(cx, |model, ctx| {
@@ -83,6 +94,7 @@ impl BranchListDelegate {
});
}
}
+
impl PickerDelegate for BranchListDelegate {
fn placeholder_text(&self) -> Arc<str> {
"Select branch...".into()
@@ -102,45 +114,28 @@ impl PickerDelegate for BranchListDelegate {
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
cx.spawn(move |picker, mut cx| async move {
- let Some(candidates) = picker
- .read_with(&mut cx, |view, cx| {
- let delegate = view.delegate();
- let project = delegate.workspace.read(cx).project().read(&cx);
-
- let Some(worktree) = project.visible_worktrees(cx).next() else {
- bail!("Cannot update branch list as there are no visible worktrees")
- };
- let mut cwd = worktree.read(cx).abs_path().to_path_buf();
- cwd.push(".git");
- let Some(repo) = project.fs().open_repo(&cwd) else {
- bail!("Project does not have associated git repository.")
- };
- let mut branches = repo.lock().branches()?;
- const RECENT_BRANCHES_COUNT: usize = 10;
- if query.is_empty() && branches.len() > RECENT_BRANCHES_COUNT {
- // Truncate list of recent branches
- // Do a partial sort to show recent-ish branches first.
- branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
- rhs.unix_timestamp.cmp(&lhs.unix_timestamp)
- });
- branches.truncate(RECENT_BRANCHES_COUNT);
- branches.sort_unstable_by(|lhs, rhs| lhs.name.cmp(&rhs.name));
- }
- Ok(branches
- .iter()
- .cloned()
- .enumerate()
- .map(|(ix, command)| StringMatchCandidate {
- id: ix,
- char_bag: command.name.chars().collect(),
- string: command.name.into(),
- })
- .collect::<Vec<_>>())
- })
- .log_err()
- else {
- return;
- };
+ let candidates = picker.read_with(&mut cx, |view, _| {
+ const RECENT_BRANCHES_COUNT: usize = 10;
+ let mut branches = view.delegate().all_branches.clone();
+ if query.is_empty() && branches.len() > RECENT_BRANCHES_COUNT {
+ // Truncate list of recent branches
+ // Do a partial sort to show recent-ish branches first.
+ branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
+ rhs.unix_timestamp.cmp(&lhs.unix_timestamp)
+ });
+ branches.truncate(RECENT_BRANCHES_COUNT);
+ branches.sort_unstable_by(|lhs, rhs| lhs.name.cmp(&rhs.name));
+ }
+ branches
+ .into_iter()
+ .enumerate()
+ .map(|(ix, command)| StringMatchCandidate {
+ id: ix,
+ char_bag: command.name.chars().collect(),
+ string: command.name.into(),
+ })
+ .collect::<Vec<StringMatchCandidate>>()
+ });
let Some(candidates) = candidates.log_err() else {
return;
};
@@ -1,9 +1,7 @@
-use std::cmp;
-
use editor::{
char_kind,
display_map::{DisplaySnapshot, FoldPoint, ToDisplayPoint},
- movement::{self, find_boundary, find_preceding_boundary, FindRange},
+ movement::{self, find_boundary, find_preceding_boundary, FindRange, TextLayoutDetails},
Bias, CharKind, DisplayPoint, ToOffset,
};
use gpui::{actions, impl_actions, AppContext, WindowContext};
@@ -42,6 +40,7 @@ pub enum Motion {
NextLineStart,
StartOfLineDownward,
EndOfLineDownward,
+ GoToColumn,
}
#[derive(Clone, Deserialize, PartialEq)]
@@ -121,6 +120,7 @@ actions!(
NextLineStart,
StartOfLineDownward,
EndOfLineDownward,
+ GoToColumn,
]
);
impl_actions!(
@@ -217,6 +217,7 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(|_: &mut Workspace, &EndOfLineDownward, cx: _| {
motion(Motion::EndOfLineDownward, cx)
});
+ cx.add_action(|_: &mut Workspace, &GoToColumn, cx: _| motion(Motion::GoToColumn, cx));
cx.add_action(|_: &mut Workspace, action: &RepeatFind, cx: _| {
repeat_motion(action.backwards, cx)
})
@@ -294,6 +295,7 @@ impl Motion {
| Right
| StartOfLine { .. }
| EndOfLineDownward
+ | GoToColumn
| NextWordStart { .. }
| PreviousWordStart { .. }
| FirstNonWhitespace { .. }
@@ -319,6 +321,7 @@ impl Motion {
| EndOfParagraph
| StartOfLineDownward
| EndOfLineDownward
+ | GoToColumn
| NextWordStart { .. }
| PreviousWordStart { .. }
| FirstNonWhitespace { .. }
@@ -348,6 +351,7 @@ impl Motion {
| StartOfLineDownward
| StartOfParagraph
| EndOfParagraph
+ | GoToColumn
| NextWordStart { .. }
| PreviousWordStart { .. }
| FirstNonWhitespace { .. }
@@ -361,6 +365,7 @@ impl Motion {
point: DisplayPoint,
goal: SelectionGoal,
maybe_times: Option<usize>,
+ text_layout_details: &TextLayoutDetails,
) -> Option<(DisplayPoint, SelectionGoal)> {
let times = maybe_times.unwrap_or(1);
use Motion::*;
@@ -370,16 +375,16 @@ impl Motion {
Backspace => (backspace(map, point, times), SelectionGoal::None),
Down {
display_lines: false,
- } => down(map, point, goal, times),
+ } => up_down_buffer_rows(map, point, goal, times as isize, &text_layout_details),
Down {
display_lines: true,
- } => down_display(map, point, goal, times),
+ } => down_display(map, point, goal, times, &text_layout_details),
Up {
display_lines: false,
- } => up(map, point, goal, times),
+ } => up_down_buffer_rows(map, point, goal, 0 - times as isize, &text_layout_details),
Up {
display_lines: true,
- } => up_display(map, point, goal, times),
+ } => up_display(map, point, goal, times, &text_layout_details),
Right => (right(map, point, times), SelectionGoal::None),
NextWordStart { ignore_punctuation } => (
next_word_start(map, point, *ignore_punctuation, times),
@@ -430,6 +435,7 @@ impl Motion {
NextLineStart => (next_line_start(map, point, times), SelectionGoal::None),
StartOfLineDownward => (next_line_start(map, point, times - 1), SelectionGoal::None),
EndOfLineDownward => (next_line_end(map, point, times), SelectionGoal::None),
+ GoToColumn => (go_to_column(map, point, times), SelectionGoal::None),
};
(new_point != point || infallible).then_some((new_point, goal))
@@ -442,10 +448,15 @@ impl Motion {
selection: &mut Selection<DisplayPoint>,
times: Option<usize>,
expand_to_surrounding_newline: bool,
+ text_layout_details: &TextLayoutDetails,
) -> bool {
- if let Some((new_head, goal)) =
- self.move_point(map, selection.head(), selection.goal, times)
- {
+ if let Some((new_head, goal)) = self.move_point(
+ map,
+ selection.head(),
+ selection.goal,
+ times,
+ &text_layout_details,
+ ) {
selection.set_head(new_head, goal);
if self.linewise() {
@@ -530,35 +541,85 @@ fn backspace(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> Di
point
}
-fn down(
+pub(crate) fn start_of_relative_buffer_row(
+ map: &DisplaySnapshot,
+ point: DisplayPoint,
+ times: isize,
+) -> DisplayPoint {
+ let start = map.display_point_to_fold_point(point, Bias::Left);
+ let target = start.row() as isize + times;
+ let new_row = (target.max(0) as u32).min(map.fold_snapshot.max_point().row());
+
+ map.clip_point(
+ map.fold_point_to_display_point(
+ map.fold_snapshot
+ .clip_point(FoldPoint::new(new_row, 0), Bias::Right),
+ ),
+ Bias::Right,
+ )
+}
+
+fn up_down_buffer_rows(
map: &DisplaySnapshot,
point: DisplayPoint,
mut goal: SelectionGoal,
- times: usize,
+ times: isize,
+ text_layout_details: &TextLayoutDetails,
) -> (DisplayPoint, SelectionGoal) {
let start = map.display_point_to_fold_point(point, Bias::Left);
+ let begin_folded_line = map.fold_point_to_display_point(
+ map.fold_snapshot
+ .clip_point(FoldPoint::new(start.row(), 0), Bias::Left),
+ );
+ let select_nth_wrapped_row = point.row() - begin_folded_line.row();
- let goal_column = match goal {
- SelectionGoal::Column(column) => column,
- SelectionGoal::ColumnRange { end, .. } => end,
+ let (goal_wrap, goal_x) = match goal {
+ SelectionGoal::WrappedHorizontalPosition((row, x)) => (row, x),
+ SelectionGoal::HorizontalRange { end, .. } => (select_nth_wrapped_row, end),
+ SelectionGoal::HorizontalPosition(x) => (select_nth_wrapped_row, x),
_ => {
- goal = SelectionGoal::Column(start.column());
- start.column()
+ let x = map.x_for_point(point, text_layout_details);
+ goal = SelectionGoal::WrappedHorizontalPosition((select_nth_wrapped_row, x));
+ (select_nth_wrapped_row, x)
}
};
- let new_row = cmp::min(
- start.row() + times as u32,
- map.fold_snapshot.max_point().row(),
- );
- let new_col = cmp::min(goal_column, map.fold_snapshot.line_len(new_row));
- let point = map.fold_point_to_display_point(
+ let target = start.row() as isize + times;
+ let new_row = (target.max(0) as u32).min(map.fold_snapshot.max_point().row());
+
+ let mut begin_folded_line = map.fold_point_to_display_point(
map.fold_snapshot
- .clip_point(FoldPoint::new(new_row, new_col), Bias::Left),
+ .clip_point(FoldPoint::new(new_row, 0), Bias::Left),
);
- // clip twice to "clip at end of line"
- (map.clip_point(point, Bias::Left), goal)
+ let mut i = 0;
+ while i < goal_wrap && begin_folded_line.row() < map.max_point().row() {
+ let next_folded_line = DisplayPoint::new(begin_folded_line.row() + 1, 0);
+ if map
+ .display_point_to_fold_point(next_folded_line, Bias::Right)
+ .row()
+ == new_row
+ {
+ i += 1;
+ begin_folded_line = next_folded_line;
+ } else {
+ break;
+ }
+ }
+
+ let new_col = if i == goal_wrap {
+ map.column_for_x(begin_folded_line.row(), goal_x, text_layout_details)
+ } else {
+ map.line_len(begin_folded_line.row())
+ };
+
+ (
+ map.clip_point(
+ DisplayPoint::new(begin_folded_line.row(), new_col),
+ Bias::Left,
+ ),
+ goal,
+ )
}
fn down_display(
@@ -566,49 +627,24 @@ fn down_display(
mut point: DisplayPoint,
mut goal: SelectionGoal,
times: usize,
+ text_layout_details: &TextLayoutDetails,
) -> (DisplayPoint, SelectionGoal) {
for _ in 0..times {
- (point, goal) = movement::down(map, point, goal, true);
+ (point, goal) = movement::down(map, point, goal, true, text_layout_details);
}
(point, goal)
}
-pub(crate) fn up(
- map: &DisplaySnapshot,
- point: DisplayPoint,
- mut goal: SelectionGoal,
- times: usize,
-) -> (DisplayPoint, SelectionGoal) {
- let start = map.display_point_to_fold_point(point, Bias::Left);
-
- let goal_column = match goal {
- SelectionGoal::Column(column) => column,
- SelectionGoal::ColumnRange { end, .. } => end,
- _ => {
- goal = SelectionGoal::Column(start.column());
- start.column()
- }
- };
-
- let new_row = start.row().saturating_sub(times as u32);
- let new_col = cmp::min(goal_column, map.fold_snapshot.line_len(new_row));
- let point = map.fold_point_to_display_point(
- map.fold_snapshot
- .clip_point(FoldPoint::new(new_row, new_col), Bias::Left),
- );
-
- (map.clip_point(point, Bias::Left), goal)
-}
-
fn up_display(
map: &DisplaySnapshot,
mut point: DisplayPoint,
mut goal: SelectionGoal,
times: usize,
+ text_layout_details: &TextLayoutDetails,
) -> (DisplayPoint, SelectionGoal) {
for _ in 0..times {
- (point, goal) = movement::up(map, point, goal, true);
+ (point, goal) = movement::up(map, point, goal, true, &text_layout_details);
}
(point, goal)
@@ -707,7 +743,7 @@ fn previous_word_start(
point
}
-fn first_non_whitespace(
+pub(crate) fn first_non_whitespace(
map: &DisplaySnapshot,
display_lines: bool,
from: DisplayPoint,
@@ -886,13 +922,22 @@ fn find_backward(
}
fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint {
- let correct_line = down(map, point, SelectionGoal::None, times).0;
+ let correct_line = start_of_relative_buffer_row(map, point, times as isize);
first_non_whitespace(map, false, correct_line)
}
-fn next_line_end(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint {
+fn go_to_column(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint {
+ let correct_line = start_of_relative_buffer_row(map, point, 0);
+ right(map, correct_line, times.saturating_sub(1))
+}
+
+pub(crate) fn next_line_end(
+ map: &DisplaySnapshot,
+ mut point: DisplayPoint,
+ times: usize,
+) -> DisplayPoint {
if times > 1 {
- point = down(map, point, SelectionGoal::None, times - 1).0;
+ point = start_of_relative_buffer_row(map, point, times as isize - 1);
}
end_of_line(map, false, point)
}
@@ -12,7 +12,7 @@ mod yank;
use std::sync::Arc;
use crate::{
- motion::{self, Motion},
+ motion::{self, first_non_whitespace, next_line_end, right, Motion},
object::Object,
state::{Mode, Operator},
Vim,
@@ -179,10 +179,11 @@ pub(crate) fn move_cursor(
cx: &mut WindowContext,
) {
vim.update_active_editor(cx, |editor, cx| {
+ let text_layout_details = editor.text_layout_details(cx);
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
s.move_cursors_with(|map, cursor, goal| {
motion
- .move_point(map, cursor, goal, times)
+ .move_point(map, cursor, goal, times, &text_layout_details)
.unwrap_or((cursor, goal))
})
})
@@ -195,9 +196,7 @@ fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext<Workspa
vim.switch_mode(Mode::Insert, false, cx);
vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
- s.maybe_move_cursors_with(|map, cursor, goal| {
- Motion::Right.move_point(map, cursor, goal, None)
- });
+ s.move_cursors_with(|map, cursor, _| (right(map, cursor, 1), SelectionGoal::None));
});
});
});
@@ -220,11 +219,11 @@ fn insert_first_non_whitespace(
vim.switch_mode(Mode::Insert, false, cx);
vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
- s.maybe_move_cursors_with(|map, cursor, goal| {
- Motion::FirstNonWhitespace {
- display_lines: false,
- }
- .move_point(map, cursor, goal, None)
+ s.move_cursors_with(|map, cursor, _| {
+ (
+ first_non_whitespace(map, false, cursor),
+ SelectionGoal::None,
+ )
});
});
});
@@ -237,8 +236,8 @@ fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewConte
vim.switch_mode(Mode::Insert, false, cx);
vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
- s.maybe_move_cursors_with(|map, cursor, goal| {
- Motion::CurrentLine.move_point(map, cursor, goal, None)
+ s.move_cursors_with(|map, cursor, _| {
+ (next_line_end(map, cursor, 1), SelectionGoal::None)
});
});
});
@@ -268,7 +267,7 @@ fn insert_line_above(_: &mut Workspace, _: &InsertLineAbove, cx: &mut ViewContex
editor.edit_with_autoindent(edits, cx);
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
s.move_cursors_with(|map, cursor, _| {
- let previous_line = motion::up(map, cursor, SelectionGoal::None, 1).0;
+ let previous_line = motion::start_of_relative_buffer_row(map, cursor, -1);
let insert_point = motion::end_of_line(map, false, previous_line);
(insert_point, SelectionGoal::None)
});
@@ -283,6 +282,7 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex
vim.start_recording(cx);
vim.switch_mode(Mode::Insert, false, cx);
vim.update_active_editor(cx, |editor, cx| {
+ let text_layout_details = editor.text_layout_details(cx);
editor.transact(cx, |editor, cx| {
let (map, old_selections) = editor.selections.all_display(cx);
@@ -301,7 +301,13 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex
});
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
s.maybe_move_cursors_with(|map, cursor, goal| {
- Motion::CurrentLine.move_point(map, cursor, goal, None)
+ Motion::CurrentLine.move_point(
+ map,
+ cursor,
+ goal,
+ None,
+ &text_layout_details,
+ )
});
});
editor.edit_with_autoindent(edits, cx);
@@ -399,12 +405,26 @@ mod test {
#[gpui::test]
async fn test_j(cx: &mut gpui::TestAppContext) {
- let mut cx = NeovimBackedTestContext::new(cx).await.binding(["j"]);
- cx.assert_all(indoc! {"
- ˇThe qˇuick broˇwn
- ˇfox jumps"
+ let mut cx = NeovimBackedTestContext::new(cx).await;
+
+ cx.set_shared_state(indoc! {"
+ aaˇaa
+ 😃😃"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["j"]).await;
+ cx.assert_shared_state(indoc! {"
+ aaaa
+ 😃ˇ😃"
})
.await;
+
+ for marked_position in cx.each_marked_position(indoc! {"
+ ˇThe qˇuick broˇwn
+ ˇfox jumps"
+ }) {
+ cx.assert_neovim_compatible(&marked_position, ["j"]).await;
+ }
}
#[gpui::test]
@@ -2,7 +2,7 @@ use crate::{motion::Motion, object::Object, state::Mode, utils::copy_selections_
use editor::{
char_kind,
display_map::DisplaySnapshot,
- movement::{self, FindRange},
+ movement::{self, FindRange, TextLayoutDetails},
scroll::autoscroll::Autoscroll,
CharKind, DisplayPoint,
};
@@ -20,6 +20,7 @@ pub fn change_motion(vim: &mut Vim, motion: Motion, times: Option<usize>, cx: &m
| Motion::StartOfLine { .. }
);
vim.update_active_editor(cx, |editor, cx| {
+ let text_layout_details = editor.text_layout_details(cx);
editor.transact(cx, |editor, cx| {
// We are swapping to insert mode anyway. Just set the line end clipping behavior now
editor.set_clip_at_line_ends(false, cx);
@@ -27,9 +28,15 @@ pub fn change_motion(vim: &mut Vim, motion: Motion, times: Option<usize>, cx: &m
s.move_with(|map, selection| {
motion_succeeded |= if let Motion::NextWordStart { ignore_punctuation } = motion
{
- expand_changed_word_selection(map, selection, times, ignore_punctuation)
+ expand_changed_word_selection(
+ map,
+ selection,
+ times,
+ ignore_punctuation,
+ &text_layout_details,
+ )
} else {
- motion.expand_selection(map, selection, times, false)
+ motion.expand_selection(map, selection, times, false, &text_layout_details)
};
});
});
@@ -81,6 +88,7 @@ fn expand_changed_word_selection(
selection: &mut Selection<DisplayPoint>,
times: Option<usize>,
ignore_punctuation: bool,
+ text_layout_details: &TextLayoutDetails,
) -> bool {
if times.is_none() || times.unwrap() == 1 {
let scope = map
@@ -103,11 +111,22 @@ fn expand_changed_word_selection(
});
true
} else {
- Motion::NextWordStart { ignore_punctuation }
- .expand_selection(map, selection, None, false)
+ Motion::NextWordStart { ignore_punctuation }.expand_selection(
+ map,
+ selection,
+ None,
+ false,
+ &text_layout_details,
+ )
}
} else {
- Motion::NextWordStart { ignore_punctuation }.expand_selection(map, selection, times, false)
+ Motion::NextWordStart { ignore_punctuation }.expand_selection(
+ map,
+ selection,
+ times,
+ false,
+ &text_layout_details,
+ )
}
}
@@ -7,6 +7,7 @@ use language::Point;
pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option<usize>, cx: &mut WindowContext) {
vim.stop_recording();
vim.update_active_editor(cx, |editor, cx| {
+ let text_layout_details = editor.text_layout_details(cx);
editor.transact(cx, |editor, cx| {
editor.set_clip_at_line_ends(false, cx);
let mut original_columns: HashMap<_, _> = Default::default();
@@ -14,7 +15,7 @@ pub fn delete_motion(vim: &mut Vim, motion: Motion, times: Option<usize>, cx: &m
s.move_with(|map, selection| {
let original_head = selection.head();
original_columns.insert(selection.id, original_head.column());
- motion.expand_selection(map, selection, times, true);
+ motion.expand_selection(map, selection, times, true, &text_layout_details);
// Motion::NextWordStart on an empty line should delete it.
if let Motion::NextWordStart {
@@ -192,10 +193,10 @@ mod test {
}
#[gpui::test]
- async fn test_delete_e(cx: &mut gpui::TestAppContext) {
+ async fn test_delete_next_word_end(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await.binding(["d", "e"]);
- cx.assert("Teˇst Test").await;
- cx.assert("Tˇest test").await;
+ // cx.assert("Teˇst Test").await;
+ // cx.assert("Tˇest test").await;
cx.assert(indoc! {"
Test teˇst
test"})
@@ -255,8 +255,18 @@ mod test {
4
5"})
.await;
- cx.simulate_shared_keystrokes(["shift-g", "ctrl-v", "g", "g", "g", "ctrl-x"])
+
+ cx.simulate_shared_keystrokes(["shift-g", "ctrl-v", "g", "g"])
+ .await;
+ cx.assert_shared_state(indoc! {"
+ «1ˇ»
+ «2ˇ»
+ «3ˇ» 2
+ «4ˇ»
+ «5ˇ»"})
.await;
+
+ cx.simulate_shared_keystrokes(["g", "ctrl-x"]).await;
cx.assert_shared_state(indoc! {"
ˇ0
0
@@ -30,6 +30,7 @@ fn paste(_: &mut Workspace, action: &Paste, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| {
vim.record_current_action(cx);
vim.update_active_editor(cx, |editor, cx| {
+ let text_layout_details = editor.text_layout_details(cx);
editor.transact(cx, |editor, cx| {
editor.set_clip_at_line_ends(false, cx);
@@ -168,8 +169,14 @@ fn paste(_: &mut Workspace, action: &Paste, cx: &mut ViewContext<Workspace>) {
let mut cursor = anchor.to_display_point(map);
if *line_mode {
if !before {
- cursor =
- movement::down(map, cursor, SelectionGoal::None, false).0;
+ cursor = movement::down(
+ map,
+ cursor,
+ SelectionGoal::None,
+ false,
+ &text_layout_details,
+ )
+ .0;
}
cursor = movement::indented_line_beginning(map, cursor, true);
} else if !is_multiline {
@@ -32,10 +32,17 @@ pub fn substitute(vim: &mut Vim, count: Option<usize>, line_mode: bool, cx: &mut
vim.update_active_editor(cx, |editor, cx| {
editor.set_clip_at_line_ends(false, cx);
editor.transact(cx, |editor, cx| {
+ let text_layout_details = editor.text_layout_details(cx);
editor.change_selections(None, cx, |s| {
s.move_with(|map, selection| {
if selection.start == selection.end {
- Motion::Right.expand_selection(map, selection, count, true);
+ Motion::Right.expand_selection(
+ map,
+ selection,
+ count,
+ true,
+ &text_layout_details,
+ );
}
if line_mode {
// in Visual mode when the selection contains the newline at the end
@@ -43,7 +50,13 @@ pub fn substitute(vim: &mut Vim, count: Option<usize>, line_mode: bool, cx: &mut
if !selection.is_empty() && selection.end.column() == 0 {
selection.end = movement::left(map, selection.end);
}
- Motion::CurrentLine.expand_selection(map, selection, None, false);
+ Motion::CurrentLine.expand_selection(
+ map,
+ selection,
+ None,
+ false,
+ &text_layout_details,
+ );
if let Some((point, _)) = (Motion::FirstNonWhitespace {
display_lines: false,
})
@@ -52,6 +65,7 @@ pub fn substitute(vim: &mut Vim, count: Option<usize>, line_mode: bool, cx: &mut
selection.start,
selection.goal,
None,
+ &text_layout_details,
) {
selection.start = point;
}
@@ -4,6 +4,7 @@ use gpui::WindowContext;
pub fn yank_motion(vim: &mut Vim, motion: Motion, times: Option<usize>, cx: &mut WindowContext) {
vim.update_active_editor(cx, |editor, cx| {
+ let text_layout_details = editor.text_layout_details(cx);
editor.transact(cx, |editor, cx| {
editor.set_clip_at_line_ends(false, cx);
let mut original_positions: HashMap<_, _> = Default::default();
@@ -11,7 +12,7 @@ pub fn yank_motion(vim: &mut Vim, motion: Motion, times: Option<usize>, cx: &mut
s.move_with(|map, selection| {
let original_position = (selection.head(), selection.goal);
original_positions.insert(selection.id, original_position);
- motion.expand_selection(map, selection, times, true);
+ motion.expand_selection(map, selection, times, true, &text_layout_details);
});
});
copy_selections_content(editor, motion.linewise(), cx);
@@ -2,7 +2,7 @@ use std::ops::Range;
use editor::{
char_kind,
- display_map::DisplaySnapshot,
+ display_map::{DisplaySnapshot, ToDisplayPoint},
movement::{self, FindRange},
Bias, CharKind, DisplayPoint,
};
@@ -20,6 +20,7 @@ pub enum Object {
Quotes,
BackQuotes,
DoubleQuotes,
+ VerticalBars,
Parentheses,
SquareBrackets,
CurlyBrackets,
@@ -40,6 +41,7 @@ actions!(
Quotes,
BackQuotes,
DoubleQuotes,
+ VerticalBars,
Parentheses,
SquareBrackets,
CurlyBrackets,
@@ -64,6 +66,7 @@ pub fn init(cx: &mut AppContext) {
});
cx.add_action(|_: &mut Workspace, _: &CurlyBrackets, cx: _| object(Object::CurlyBrackets, cx));
cx.add_action(|_: &mut Workspace, _: &AngleBrackets, cx: _| object(Object::AngleBrackets, cx));
+ cx.add_action(|_: &mut Workspace, _: &VerticalBars, cx: _| object(Object::VerticalBars, cx));
}
fn object(object: Object, cx: &mut WindowContext) {
@@ -79,9 +82,11 @@ fn object(object: Object, cx: &mut WindowContext) {
impl Object {
pub fn is_multiline(self) -> bool {
match self {
- Object::Word { .. } | Object::Quotes | Object::BackQuotes | Object::DoubleQuotes => {
- false
- }
+ Object::Word { .. }
+ | Object::Quotes
+ | Object::BackQuotes
+ | Object::VerticalBars
+ | Object::DoubleQuotes => false,
Object::Sentence
| Object::Parentheses
| Object::AngleBrackets
@@ -96,6 +101,7 @@ impl Object {
Object::Quotes
| Object::BackQuotes
| Object::DoubleQuotes
+ | Object::VerticalBars
| Object::Parentheses
| Object::SquareBrackets
| Object::CurlyBrackets
@@ -111,6 +117,7 @@ impl Object {
| Object::Quotes
| Object::BackQuotes
| Object::DoubleQuotes
+ | Object::VerticalBars
| Object::Parentheses
| Object::SquareBrackets
| Object::CurlyBrackets
@@ -142,6 +149,9 @@ impl Object {
Object::DoubleQuotes => {
surrounding_markers(map, relative_to, around, self.is_multiline(), '"', '"')
}
+ Object::VerticalBars => {
+ surrounding_markers(map, relative_to, around, self.is_multiline(), '|', '|')
+ }
Object::Parentheses => {
surrounding_markers(map, relative_to, around, self.is_multiline(), '(', ')')
}
@@ -427,110 +437,151 @@ fn surrounding_markers(
relative_to: DisplayPoint,
around: bool,
search_across_lines: bool,
- start_marker: char,
- end_marker: char,
+ open_marker: char,
+ close_marker: char,
) -> Option<Range<DisplayPoint>> {
- let mut matched_ends = 0;
- let mut start = None;
- for (char, mut point) in map.reverse_chars_at(relative_to) {
- if char == start_marker {
- if matched_ends > 0 {
- matched_ends -= 1;
- } else {
- if around {
- start = Some(point)
- } else {
- *point.column_mut() += char.len_utf8() as u32;
- start = Some(point)
+ let point = relative_to.to_offset(map, Bias::Left);
+
+ let mut matched_closes = 0;
+ let mut opening = None;
+
+ if let Some((ch, range)) = movement::chars_after(map, point).next() {
+ if ch == open_marker {
+ if open_marker == close_marker {
+ let mut total = 0;
+ for (ch, _) in movement::chars_before(map, point) {
+ if ch == '\n' {
+ break;
+ }
+ if ch == open_marker {
+ total += 1;
+ }
}
- break;
+ if total % 2 == 0 {
+ opening = Some(range)
+ }
+ } else {
+ opening = Some(range)
}
- } else if char == end_marker {
- matched_ends += 1;
- } else if char == '\n' && !search_across_lines {
- break;
}
}
- let mut matched_starts = 0;
- let mut end = None;
- for (char, mut point) in map.chars_at(relative_to) {
- if char == end_marker {
- if start.is_none() {
+ if opening.is_none() {
+ for (ch, range) in movement::chars_before(map, point) {
+ if ch == '\n' && !search_across_lines {
break;
}
- if matched_starts > 0 {
- matched_starts -= 1;
- } else {
- if around {
- *point.column_mut() += char.len_utf8() as u32;
- end = Some(point);
- } else {
- end = Some(point);
+ if ch == open_marker {
+ if matched_closes == 0 {
+ opening = Some(range);
+ break;
}
-
- break;
+ matched_closes -= 1;
+ } else if ch == close_marker {
+ matched_closes += 1
}
}
+ }
- if char == start_marker {
- if start.is_none() {
- if around {
- start = Some(point);
- } else {
- *point.column_mut() += char.len_utf8() as u32;
- start = Some(point);
- }
- } else {
- matched_starts += 1;
+ if opening.is_none() {
+ for (ch, range) in movement::chars_after(map, point) {
+ if ch == open_marker {
+ opening = Some(range);
+ break;
+ } else if ch == close_marker {
+ break;
}
}
+ }
+
+ let Some(mut opening) = opening else {
+ return None;
+ };
- if char == '\n' && !search_across_lines {
+ let mut matched_opens = 0;
+ let mut closing = None;
+
+ for (ch, range) in movement::chars_after(map, opening.end) {
+ if ch == '\n' && !search_across_lines {
break;
}
+
+ if ch == close_marker {
+ if matched_opens == 0 {
+ closing = Some(range);
+ break;
+ }
+ matched_opens -= 1;
+ } else if ch == open_marker {
+ matched_opens += 1;
+ }
}
- let (Some(mut start), Some(mut end)) = (start, end) else {
+ let Some(mut closing) = closing else {
return None;
};
- if !around {
- // if a block starts with a newline, move the start to after the newline.
- let mut was_newline = false;
- for (char, point) in map.chars_at(start) {
- if was_newline {
- start = point;
- } else if char == '\n' {
- was_newline = true;
- continue;
+ if around && !search_across_lines {
+ let mut found = false;
+
+ for (ch, range) in movement::chars_after(map, closing.end) {
+ if ch.is_whitespace() && ch != '\n' {
+ found = true;
+ closing.end = range.end;
+ } else {
+ break;
}
- break;
}
- // if a block ends with a newline, then whitespace, then the delimeter,
- // move the end to after the newline.
- let mut new_end = end;
- for (char, point) in map.reverse_chars_at(end) {
- if char == '\n' {
- end = new_end;
- break;
+
+ if !found {
+ for (ch, range) in movement::chars_before(map, opening.start) {
+ if ch.is_whitespace() && ch != '\n' {
+ opening.start = range.start
+ } else {
+ break;
+ }
}
- if !char.is_whitespace() {
+ }
+ }
+
+ if !around && search_across_lines {
+ if let Some((ch, range)) = movement::chars_after(map, opening.end).next() {
+ if ch == '\n' {
+ opening.end = range.end
+ }
+ }
+
+ for (ch, range) in movement::chars_before(map, closing.start) {
+ if !ch.is_whitespace() {
break;
}
- new_end = point
+ if ch != '\n' {
+ closing.start = range.start
+ }
}
}
- Some(start..end)
+ let result = if around {
+ opening.start..closing.end
+ } else {
+ opening.end..closing.start
+ };
+
+ Some(
+ map.clip_point(result.start.to_display_point(map), Bias::Left)
+ ..map.clip_point(result.end.to_display_point(map), Bias::Right),
+ )
}
#[cfg(test)]
mod test {
use indoc::indoc;
- use crate::test::{ExemptionFeatures, NeovimBackedTestContext};
+ use crate::{
+ state::Mode,
+ test::{ExemptionFeatures, NeovimBackedTestContext, VimTestContext},
+ };
const WORD_LOCATIONS: &'static str = indoc! {"
The quick ˇbrowˇnˇ•••
@@ -765,13 +816,6 @@ mod test {
let mut cx = NeovimBackedTestContext::new(cx).await;
for (start, end) in SURROUNDING_OBJECTS {
- if ((start == &'\'' || start == &'`' || start == &'"')
- && !ExemptionFeatures::QuotesSeekForward.supported())
- || (start == &'<' && !ExemptionFeatures::AngleBracketsFreezeNeovim.supported())
- {
- continue;
- }
-
let marked_string = SURROUNDING_MARKER_STRING
.replace('`', &start.to_string())
.replace('\'', &end.to_string());
@@ -786,6 +830,63 @@ mod test {
.await;
}
}
+ #[gpui::test]
+ async fn test_singleline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
+ let mut cx = NeovimBackedTestContext::new(cx).await;
+ cx.set_shared_wrap(12).await;
+
+ cx.set_shared_state(indoc! {
+ "helˇlo \"world\"!"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["v", "i", "\""]).await;
+ cx.assert_shared_state(indoc! {
+ "hello \"«worldˇ»\"!"
+ })
+ .await;
+
+ cx.set_shared_state(indoc! {
+ "hello \"wˇorld\"!"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["v", "i", "\""]).await;
+ cx.assert_shared_state(indoc! {
+ "hello \"«worldˇ»\"!"
+ })
+ .await;
+
+ cx.set_shared_state(indoc! {
+ "hello \"wˇorld\"!"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["v", "a", "\""]).await;
+ cx.assert_shared_state(indoc! {
+ "hello« \"world\"ˇ»!"
+ })
+ .await;
+
+ cx.set_shared_state(indoc! {
+ "hello \"wˇorld\" !"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["v", "a", "\""]).await;
+ cx.assert_shared_state(indoc! {
+ "hello «\"world\" ˇ»!"
+ })
+ .await;
+
+ cx.set_shared_state(indoc! {
+ "hello \"wˇorld\"•
+ goodbye"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["v", "a", "\""]).await;
+ cx.assert_shared_state(indoc! {
+ "hello «\"world\" ˇ»
+ goodbye"
+ })
+ .await;
+ }
#[gpui::test]
async fn test_multiline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
@@ -827,6 +928,66 @@ mod test {
return false
}"})
.await;
+
+ cx.set_shared_state(indoc! {
+ "func empty(a string) bool {
+ if a == \"\" ˇ{
+ return true
+ }
+ return false
+ }"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["v", "i", "{"]).await;
+ cx.assert_shared_state(indoc! {"
+ func empty(a string) bool {
+ if a == \"\" {
+ « return true
+ ˇ» }
+ return false
+ }"})
+ .await;
+ }
+
+ #[gpui::test]
+ async fn test_vertical_bars(cx: &mut gpui::TestAppContext) {
+ let mut cx = VimTestContext::new(cx, true).await;
+ cx.set_state(
+ indoc! {"
+ fn boop() {
+ baz(ˇ|a, b| { bar(|j, k| { })})
+ }"
+ },
+ Mode::Normal,
+ );
+ cx.simulate_keystrokes(["c", "i", "|"]);
+ cx.assert_state(
+ indoc! {"
+ fn boop() {
+ baz(|ˇ| { bar(|j, k| { })})
+ }"
+ },
+ Mode::Insert,
+ );
+ cx.simulate_keystrokes(["escape", "1", "8", "|"]);
+ cx.assert_state(
+ indoc! {"
+ fn boop() {
+ baz(|| { bar(ˇ|j, k| { })})
+ }"
+ },
+ Mode::Normal,
+ );
+
+ cx.simulate_keystrokes(["v", "a", "|"]);
+ cx.assert_state(
+ indoc! {"
+ fn boop() {
+ baz(|| { bar(«|j, k| ˇ»{ })})
+ }"
+ },
+ Mode::Visual,
+ );
}
#[gpui::test]
@@ -834,12 +995,6 @@ mod test {
let mut cx = NeovimBackedTestContext::new(cx).await;
for (start, end) in SURROUNDING_OBJECTS {
- if ((start == &'\'' || start == &'`' || start == &'"')
- && !ExemptionFeatures::QuotesSeekForward.supported())
- || (start == &'<' && !ExemptionFeatures::AngleBracketsFreezeNeovim.supported())
- {
- continue;
- }
let marked_string = SURROUNDING_MARKER_STRING
.replace('`', &start.to_string())
.replace('\'', &end.to_string());
@@ -653,6 +653,63 @@ async fn test_selection_goal(cx: &mut gpui::TestAppContext) {
.await;
}
+#[gpui::test]
+async fn test_wrapped_motions(cx: &mut gpui::TestAppContext) {
+ let mut cx = NeovimBackedTestContext::new(cx).await;
+
+ cx.set_shared_wrap(12).await;
+
+ cx.set_shared_state(indoc! {"
+ aaˇaa
+ 😃😃"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["j"]).await;
+ cx.assert_shared_state(indoc! {"
+ aaaa
+ 😃ˇ😃"
+ })
+ .await;
+
+ cx.set_shared_state(indoc! {"
+ 123456789012aaˇaa
+ 123456789012😃😃"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["j"]).await;
+ cx.assert_shared_state(indoc! {"
+ 123456789012aaaa
+ 123456789012😃ˇ😃"
+ })
+ .await;
+
+ cx.set_shared_state(indoc! {"
+ 123456789012aaˇaa
+ 123456789012😃😃"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["j"]).await;
+ cx.assert_shared_state(indoc! {"
+ 123456789012aaaa
+ 123456789012😃ˇ😃"
+ })
+ .await;
+
+ cx.set_shared_state(indoc! {"
+ 123456789012aaaaˇaaaaaaaa123456789012
+ wow
+ 123456789012😃😃😃😃😃😃123456789012"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["j", "j"]).await;
+ cx.assert_shared_state(indoc! {"
+ 123456789012aaaaaaaaaaaa123456789012
+ wow
+ 123456789012😃😃ˇ😃😃😃😃123456789012"
+ })
+ .await;
+}
+
#[gpui::test]
async fn test_paragraphs_dont_wrap(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
@@ -677,3 +734,26 @@ async fn test_paragraphs_dont_wrap(cx: &mut gpui::TestAppContext) {
two"})
.await;
}
+
+#[gpui::test]
+async fn test_select_all_issue_2170(cx: &mut gpui::TestAppContext) {
+ let mut cx = VimTestContext::new(cx, true).await;
+
+ cx.set_state(
+ indoc! {"
+ defmodule Test do
+ def test(a, ˇ[_, _] = b), do: IO.puts('hi')
+ end
+ "},
+ Mode::Normal,
+ );
+ cx.simulate_keystrokes(["g", "a"]);
+ cx.assert_state(
+ indoc! {"
+ defmodule Test do
+ def test(a, «[ˇ»_, _] = b), do: IO.puts('hi')
+ end
+ "},
+ Mode::Visual,
+ );
+}
@@ -1,15 +1,15 @@
use editor::scroll::VERTICAL_SCROLL_MARGIN;
use indoc::indoc;
use settings::SettingsStore;
-use std::ops::{Deref, DerefMut, Range};
+use std::{
+ ops::{Deref, DerefMut},
+ panic, thread,
+};
use collections::{HashMap, HashSet};
use gpui::{geometry::vector::vec2f, ContextHandle};
-use language::{
- language_settings::{AllLanguageSettings, SoftWrap},
- OffsetRangeExt,
-};
-use util::test::{generate_marked_text, marked_text_offsets};
+use language::language_settings::{AllLanguageSettings, SoftWrap};
+use util::test::marked_text_offsets;
use super::{neovim_connection::NeovimConnection, NeovimBackedBindingTestContext, VimTestContext};
use crate::state::Mode;
@@ -37,10 +37,6 @@ pub enum ExemptionFeatures {
AroundSentenceStartingBetweenIncludesWrongWhitespace,
// Non empty selection with text objects in visual mode
NonEmptyVisualTextObjects,
- // Quote style surrounding text objects don't seek forward properly
- QuotesSeekForward,
- // Neovim freezes up for some reason with angle brackets
- AngleBracketsFreezeNeovim,
// Sentence Doesn't backtrack when its at the end of the file
SentenceAfterPunctuationAtEndOfFile,
}
@@ -66,12 +62,22 @@ pub struct NeovimBackedTestContext<'a> {
impl<'a> NeovimBackedTestContext<'a> {
pub async fn new(cx: &'a mut gpui::TestAppContext) -> NeovimBackedTestContext<'a> {
- let function_name = cx.function_name.clone();
- let cx = VimTestContext::new(cx, true).await;
+ // rust stores the name of the test on the current thread.
+ // We use this to automatically name a file that will store
+ // the neovim connection's requests/responses so that we can
+ // run without neovim on CI.
+ let thread = thread::current();
+ let test_name = thread
+ .name()
+ .expect("thread is not named")
+ .split(":")
+ .last()
+ .unwrap()
+ .to_string();
Self {
- cx,
+ cx: VimTestContext::new(cx, true).await,
exemptions: Default::default(),
- neovim: NeovimConnection::new(function_name).await,
+ neovim: NeovimConnection::new(test_name).await,
last_set_state: None,
recent_keystrokes: Default::default(),
@@ -250,25 +256,13 @@ impl<'a> NeovimBackedTestContext<'a> {
}
pub async fn neovim_state(&mut self) -> String {
- generate_marked_text(
- self.neovim.text().await.as_str(),
- &self.neovim_selections().await[..],
- true,
- )
+ self.neovim.marked_text().await
}
pub async fn neovim_mode(&mut self) -> Mode {
self.neovim.mode().await.unwrap()
}
- async fn neovim_selections(&mut self) -> Vec<Range<usize>> {
- let neovim_selections = self.neovim.selections().await;
- neovim_selections
- .into_iter()
- .map(|selection| selection.to_offset(&self.buffer_snapshot()))
- .collect()
- }
-
pub async fn assert_state_matches(&mut self) {
self.is_dirty = false;
let neovim = self.neovim_state().await;
@@ -1,9 +1,9 @@
+use std::path::PathBuf;
#[cfg(feature = "neovim")]
use std::{
cmp,
- ops::{Deref, DerefMut},
+ ops::{Deref, DerefMut, Range},
};
-use std::{ops::Range, path::PathBuf};
#[cfg(feature = "neovim")]
use async_compat::Compat;
@@ -12,6 +12,7 @@ use async_trait::async_trait;
#[cfg(feature = "neovim")]
use gpui::keymap_matcher::Keystroke;
+#[cfg(feature = "neovim")]
use language::Point;
#[cfg(feature = "neovim")]
@@ -109,7 +110,12 @@ impl NeovimConnection {
// Sends a keystroke to the neovim process.
#[cfg(feature = "neovim")]
pub async fn send_keystroke(&mut self, keystroke_text: &str) {
- let keystroke = Keystroke::parse(keystroke_text).unwrap();
+ let mut keystroke = Keystroke::parse(keystroke_text).unwrap();
+
+ if keystroke.key == "<" {
+ keystroke.key = "lt".to_string()
+ }
+
let special = keystroke.shift
|| keystroke.ctrl
|| keystroke.alt
@@ -296,7 +302,7 @@ impl NeovimConnection {
}
#[cfg(feature = "neovim")]
- pub async fn state(&mut self) -> (Option<Mode>, String, Vec<Range<Point>>) {
+ pub async fn state(&mut self) -> (Option<Mode>, String) {
let nvim_buffer = self
.nvim
.get_current_buf()
@@ -405,37 +411,33 @@ impl NeovimConnection {
.push(Point::new(selection_row, selection_col)..Point::new(cursor_row, cursor_col)),
}
+ let ranges = encode_ranges(&text, &selections);
let state = NeovimData::Get {
mode,
- state: encode_ranges(&text, &selections),
+ state: ranges.clone(),
};
if self.data.back() != Some(&state) {
self.data.push_back(state.clone());
}
- (mode, text, selections)
+ (mode, ranges)
}
#[cfg(not(feature = "neovim"))]
- pub async fn state(&mut self) -> (Option<Mode>, String, Vec<Range<Point>>) {
- if let Some(NeovimData::Get { state: text, mode }) = self.data.front() {
- let (text, ranges) = parse_state(text);
- (*mode, text, ranges)
+ pub async fn state(&mut self) -> (Option<Mode>, String) {
+ if let Some(NeovimData::Get { state: raw, mode }) = self.data.front() {
+ (*mode, raw.to_string())
} else {
panic!("operation does not match recorded script. re-record with --features=neovim");
}
}
- pub async fn selections(&mut self) -> Vec<Range<Point>> {
- self.state().await.2
- }
-
pub async fn mode(&mut self) -> Option<Mode> {
self.state().await.0
}
- pub async fn text(&mut self) -> String {
+ pub async fn marked_text(&mut self) -> String {
self.state().await.1
}
@@ -527,6 +529,7 @@ impl Handler for NvimHandler {
}
}
+#[cfg(feature = "neovim")]
fn parse_state(marked_text: &str) -> (String, Vec<Range<Point>>) {
let (text, ranges) = util::test::marked_text_ranges(marked_text, true);
let point_ranges = ranges
@@ -25,7 +25,7 @@ pub use mode_indicator::ModeIndicator;
use motion::Motion;
use normal::normal_replace;
use serde::Deserialize;
-use settings::{Setting, SettingsStore};
+use settings::{update_settings_file, Setting, SettingsStore};
use state::{EditorState, Mode, Operator, RecordedSelection, WorkspaceState};
use std::{ops::Range, sync::Arc};
use visual::{visual_block_motion, visual_replace};
@@ -48,6 +48,7 @@ actions!(
vim,
[Tab, Enter, Object, InnerObject, FindForward, FindBackward]
);
+actions!(workspace, [ToggleVimMode]);
impl_actions!(vim, [Number, SwitchMode, PushOperator]);
#[derive(Copy, Clone, Debug)]
@@ -88,6 +89,14 @@ pub fn init(cx: &mut AppContext) {
Vim::active_editor_input_ignored("\n".into(), cx)
});
+ cx.add_action(|workspace: &mut Workspace, _: &ToggleVimMode, cx| {
+ let fs = workspace.app_state().fs.clone();
+ let currently_enabled = settings::get::<VimModeSetting>(cx).0;
+ update_settings_file::<VimModeSetting>(fs, cx, move |setting| {
+ *setting = Some(!currently_enabled)
+ })
+ });
+
// Any time settings change, update vim mode to match. The Vim struct
// will be initialized as disabled by default, so we filter its commands
// out when starting up.
@@ -581,7 +590,7 @@ impl Setting for VimModeSetting {
fn local_selections_changed(newest: Selection<usize>, cx: &mut WindowContext) {
Vim::update(cx, |vim, cx| {
if vim.enabled && vim.state().mode == Mode::Normal && !newest.is_empty() {
- if matches!(newest.goal, SelectionGoal::ColumnRange { .. }) {
+ if matches!(newest.goal, SelectionGoal::HorizontalRange { .. }) {
vim.switch_mode(Mode::VisualBlock, false, cx);
} else {
vim.switch_mode(Mode::Visual, false, cx)
@@ -1,5 +1,5 @@
use anyhow::Result;
-use std::{cmp, sync::Arc};
+use std::sync::Arc;
use collections::HashMap;
use editor::{
@@ -57,6 +57,7 @@ pub fn init(cx: &mut AppContext) {
pub fn visual_motion(motion: Motion, times: Option<usize>, cx: &mut WindowContext) {
Vim::update(cx, |vim, cx| {
vim.update_active_editor(cx, |editor, cx| {
+ let text_layout_details = editor.text_layout_details(cx);
if vim.state().mode == Mode::VisualBlock
&& !matches!(
motion,
@@ -67,7 +68,7 @@ pub fn visual_motion(motion: Motion, times: Option<usize>, cx: &mut WindowContex
{
let is_up_or_down = matches!(motion, Motion::Up { .. } | Motion::Down { .. });
visual_block_motion(is_up_or_down, editor, cx, |map, point, goal| {
- motion.move_point(map, point, goal, times)
+ motion.move_point(map, point, goal, times, &text_layout_details)
})
} else {
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
@@ -89,9 +90,13 @@ pub fn visual_motion(motion: Motion, times: Option<usize>, cx: &mut WindowContex
current_head = movement::left(map, selection.end)
}
- let Some((new_head, goal)) =
- motion.move_point(map, current_head, selection.goal, times)
- else {
+ let Some((new_head, goal)) = motion.move_point(
+ map,
+ current_head,
+ selection.goal,
+ times,
+ &text_layout_details,
+ ) else {
return;
};
@@ -135,19 +140,23 @@ pub fn visual_block_motion(
SelectionGoal,
) -> Option<(DisplayPoint, SelectionGoal)>,
) {
+ let text_layout_details = editor.text_layout_details(cx);
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
let map = &s.display_map();
let mut head = s.newest_anchor().head().to_display_point(map);
let mut tail = s.oldest_anchor().tail().to_display_point(map);
+ let mut head_x = map.x_for_point(head, &text_layout_details);
+ let mut tail_x = map.x_for_point(tail, &text_layout_details);
+
let (start, end) = match s.newest_anchor().goal {
- SelectionGoal::ColumnRange { start, end } if preserve_goal => (start, end),
- SelectionGoal::Column(start) if preserve_goal => (start, start + 1),
- _ => (tail.column(), head.column()),
+ SelectionGoal::HorizontalRange { start, end } if preserve_goal => (start, end),
+ SelectionGoal::HorizontalPosition(start) if preserve_goal => (start, start),
+ _ => (tail_x, head_x),
};
- let goal = SelectionGoal::ColumnRange { start, end };
+ let mut goal = SelectionGoal::HorizontalRange { start, end };
- let was_reversed = tail.column() > head.column();
+ let was_reversed = tail_x > head_x;
if !was_reversed && !preserve_goal {
head = movement::saturating_left(map, head);
}
@@ -156,32 +165,56 @@ pub fn visual_block_motion(
return;
};
head = new_head;
+ head_x = map.x_for_point(head, &text_layout_details);
- let is_reversed = tail.column() > head.column();
+ let is_reversed = tail_x > head_x;
if was_reversed && !is_reversed {
- tail = movement::left(map, tail)
+ tail = movement::saturating_left(map, tail);
+ tail_x = map.x_for_point(tail, &text_layout_details);
} else if !was_reversed && is_reversed {
- tail = movement::right(map, tail)
+ tail = movement::saturating_right(map, tail);
+ tail_x = map.x_for_point(tail, &text_layout_details);
}
if !is_reversed && !preserve_goal {
- head = movement::saturating_right(map, head)
+ head = movement::saturating_right(map, head);
+ head_x = map.x_for_point(head, &text_layout_details);
}
- let columns = if is_reversed {
- head.column()..tail.column()
- } else if head.column() == tail.column() {
- head.column()..(head.column() + 1)
+ let positions = if is_reversed {
+ head_x..tail_x
} else {
- tail.column()..head.column()
+ tail_x..head_x
};
+ if !preserve_goal {
+ goal = SelectionGoal::HorizontalRange {
+ start: positions.start,
+ end: positions.end,
+ };
+ }
+
let mut selections = Vec::new();
let mut row = tail.row();
loop {
- let start = map.clip_point(DisplayPoint::new(row, columns.start), Bias::Left);
- let end = map.clip_point(DisplayPoint::new(row, columns.end), Bias::Left);
- if columns.start <= map.line_len(row) {
+ let layed_out_line = map.lay_out_line_for_row(row, &text_layout_details);
+ let start = DisplayPoint::new(
+ row,
+ layed_out_line.closest_index_for_x(positions.start) as u32,
+ );
+ let mut end = DisplayPoint::new(
+ row,
+ layed_out_line.closest_index_for_x(positions.end) as u32,
+ );
+ if end <= start {
+ if start.column() == map.line_len(start.row()) {
+ end = start;
+ } else {
+ end = movement::saturating_right(map, start);
+ }
+ }
+
+ if positions.start <= layed_out_line.width() {
let selection = Selection {
id: s.new_selection_id(),
start: start.to_point(map),
@@ -230,21 +263,13 @@ pub fn visual_object(object: Object, cx: &mut WindowContext) {
if let Some(range) = object.range(map, head, around) {
if !range.is_empty() {
- let expand_both_ways =
- if object.always_expands_both_ways() || selection.is_empty() {
- true
- // contains only one character
- } else if let Some((_, start)) =
- map.reverse_chars_at(selection.end).next()
- {
- selection.start == start
- } else {
- false
- };
+ let expand_both_ways = object.always_expands_both_ways()
+ || selection.is_empty()
+ || movement::right(map, selection.start) == selection.end;
if expand_both_ways {
- selection.start = cmp::min(selection.start, range.start);
- selection.end = cmp::max(selection.end, range.end);
+ selection.start = range.start;
+ selection.end = range.end;
} else if selection.reversed {
selection.start = range.start;
} else {
@@ -888,6 +913,28 @@ mod test {
.await;
}
+ #[gpui::test]
+ async fn test_visual_block_issue_2123(cx: &mut gpui::TestAppContext) {
+ let mut cx = NeovimBackedTestContext::new(cx).await;
+
+ cx.set_shared_state(indoc! {
+ "The ˇquick brown
+ fox jumps over
+ the lazy dog
+ "
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["ctrl-v", "right", "down"])
+ .await;
+ cx.assert_shared_state(indoc! {
+ "The «quˇ»ick brown
+ fox «juˇ»mps over
+ the lazy dog
+ "
+ })
+ .await;
+ }
+
#[gpui::test]
async fn test_visual_block_insert(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
@@ -1 +0,0 @@
-[{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[3,5],"end":[3,5]}}]
@@ -1,3 +1,1023 @@
+{"Put":{"state":"ˇTh'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'ˇe ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e 'ˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''ˇ'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''ˇqui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''ˇ'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''quˇi'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''ˇ'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ˇ'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck broˇ'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'ˇ'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'ˇfox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'ˇ'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox juˇmps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'ˇ'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ovˇ'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'ˇ'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'ˇer\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'ˇer\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe ˇlazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇ'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇ'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇ'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇo'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇ'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'oˇ'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇ'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'ˇg"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'ˇe ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e 'ˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''ˇ'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''ˇqui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''ˇ'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''quˇi'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''ˇ'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ˇ'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck broˇ'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'ˇ'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'ˇfox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'ˇ'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox juˇmps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'ˇ'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ovˇ'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'ˇ'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'ˇer\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'ˇer\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe ˇlazy d'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇ'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇ'o'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇ'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇo'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇ'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'oˇ'g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇ'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'ˇg"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Thˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'ˇe ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Thˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Thˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e 'ˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e 'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''ˇqui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e 'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''quˇi'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e 'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''quiˇwn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck broˇ'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck broˇ\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'ˇfox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\nˇer\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox juˇmps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\nˇer\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ovˇ'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\nˇer\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'ˇer\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'ˇer\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe ˇlazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇ'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇo'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'oˇ'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'ˇg"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Thˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'ˇe ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Thˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Thˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e 'ˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e 'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''ˇqui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e 'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''quˇi'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e 'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''quiˇwn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck broˇ'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck broˇ\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'ˇfox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\nˇer\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox juˇmps ov'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\nˇer\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ovˇ'er\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\nˇer\nthe lazy d'o'g","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'ˇer\nthe lazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'ˇer\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe ˇlazy d'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇ'o'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇo'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'oˇ'g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'ˇg"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`ˇe ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e `ˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``ˇ`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``ˇqui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``ˇ`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``quˇi`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``ˇ`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ˇ`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck broˇ`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`ˇ`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`ˇfox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`ˇ`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox juˇmps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`ˇ`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ovˇ`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`ˇ`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`ˇer\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`ˇer\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe ˇlazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇ`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇ`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇ`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇo`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇ`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`oˇ`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇ`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`ˇg"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`ˇe ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e `ˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``ˇ`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``ˇqui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``ˇ`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``quˇi`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``ˇ`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ˇ`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck broˇ`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`ˇ`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`ˇfox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`ˇ`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox juˇmps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`ˇ`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ovˇ`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`ˇ`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`ˇer\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`ˇer\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe ˇlazy d`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇ`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇ`o`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇ`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇo`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇ`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`oˇ`g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇ`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`ˇg"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Thˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`ˇe ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Thˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Thˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e `ˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e `ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``ˇqui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e `ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``quˇi`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e `ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``quiˇwn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck broˇ`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck broˇ\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`ˇfox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\nˇer\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox juˇmps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\nˇer\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ovˇ`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\nˇer\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`ˇer\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`ˇer\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe ˇlazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇ`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇo`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`oˇ`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`ˇg"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Thˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`ˇe ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Thˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Thˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e `ˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e `ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``ˇqui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e `ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``quˇi`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e `ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``quiˇwn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck broˇ`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck broˇ\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`ˇfox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\nˇer\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox juˇmps ov`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\nˇer\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ovˇ`er\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\nˇer\nthe lazy d`o`g","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`ˇer\nthe lazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`ˇer\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe ˇlazy d`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇ`o`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇo`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`oˇ`g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`ˇg"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"ˇe \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"ˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"ˇ\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"ˇqui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"ˇ\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"quˇi\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"ˇ\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ˇ\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck broˇ\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"ˇ\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇfox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇ\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox juˇmps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇ\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ovˇ\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇ\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"ˇer\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"ˇer\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe ˇlazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇ\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇ\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇ\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇo\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇ\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"oˇ\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇ\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"ˇg"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"ˇe \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"ˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"ˇ\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"ˇqui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"ˇ\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"quˇi\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"ˇ\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ˇ\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck broˇ\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"ˇ\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇfox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇ\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox juˇmps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇ\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ovˇ\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇ\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"ˇer\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"ˇer\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe ˇlazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇ\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇ\"o\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇ\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇo\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇ\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"oˇ\"g"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇ\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"ˇg"}}
+{"Key":"c"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Thˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"ˇe \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Thˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Thˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"ˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"ˇqui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"quˇi\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"quiˇwn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck broˇ\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck broˇ\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇfox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\nˇer\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox juˇmps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\nˇer\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ovˇ\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\nˇer\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"ˇer\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"ˇer\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe ˇlazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇ\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇo\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"oˇ\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"ˇg"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Thˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"ˇe \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Thˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Thˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"ˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"ˇqui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"quˇi\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"quiˇwn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck broˇ\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck broˇ\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇfox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\nˇer\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox juˇmps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\nˇer\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ovˇ\"er\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\nˇer\nthe lazy d\"o\"g","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"ˇer\nthe lazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"ˇer\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe ˇlazy d\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇ\"o\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇo\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"oˇ\"g"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇg","mode":"Insert"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"ˇg"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"ˇg","mode":"Normal"}}
{"Put":{"state":"ˇTh)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"}}
{"Key":"c"}
{"Key":"i"}
@@ -1,11 +1,3 @@
-{"Put":{"state":"Teˇst Test"}}
-{"Key":"d"}
-{"Key":"e"}
-{"Get":{"state":"Teˇ Test","mode":"Normal"}}
-{"Put":{"state":"Tˇest test"}}
-{"Key":"d"}
-{"Key":"e"}
-{"Get":{"state":"Tˇ test","mode":"Normal"}}
{"Put":{"state":"Test teˇst\ntest"}}
{"Key":"d"}
{"Key":"e"}
@@ -1,3 +1,1023 @@
+{"Put":{"state":"ˇTh'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'ˇe ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e 'ˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''ˇ'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''ˇqui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''ˇ'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''quˇi'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''ˇ'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ˇ'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck broˇ'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'ˇ'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'ˇfox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'ˇ'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox juˇmps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'ˇ'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ovˇ'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'ˇ'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'ˇer\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'ˇer\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe ˇlazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇ'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇ'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇ'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇo'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇ'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'oˇ'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇ'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'ˇg"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'ˇe ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e 'ˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''ˇ'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''ˇqui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''ˇ'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''quˇi'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''ˇ'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ˇ'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck broˇ'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'ˇ'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'ˇfox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'ˇ'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox juˇmps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'ˇ'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ovˇ'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'ˇ'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'ˇer\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'ˇer\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe ˇlazy d'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇ'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇ'o'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇ'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇo'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇ'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'oˇ'g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇ'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'ˇg"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Thˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'ˇe ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Thˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Thˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e 'ˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e 'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''ˇqui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e 'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''quˇi'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e 'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''quiˇwn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck broˇ'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck brˇo\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'ˇfox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\nˇer\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox juˇmps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\nˇer\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ovˇ'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\nˇer\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'ˇer\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'ˇer\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe ˇlazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇ'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇo'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'oˇ'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'ˇg"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Thˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'ˇe ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Thˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ˇ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Thˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e 'ˇ'qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e 'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''ˇqui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e 'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''quˇi'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e 'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ˇck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''quiˇwn'\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck broˇ'wn'\n'fox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck brˇo\n'fox jumps ov'er\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'ˇfox jumps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\nˇer\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox juˇmps ov'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\nˇer\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ovˇ'er\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\nˇer\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'ˇer\nthe lazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'ˇer\nthe lazy d'o'g","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe ˇlazy d'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇ'o'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'ˇo'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'oˇ'g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'ˇg"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"'"}
+{"Get":{"state":"Th'e ''qui'ck bro'wn'\n'fox jumps ov'er\nthe lazy d'o'ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`ˇe ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e `ˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``ˇ`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``ˇqui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``ˇ`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``quˇi`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``ˇ`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ˇ`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck broˇ`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`ˇ`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`ˇfox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`ˇ`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox juˇmps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`ˇ`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ovˇ`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`ˇ`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`ˇer\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`ˇer\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe ˇlazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇ`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇ`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇ`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇo`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇ`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`oˇ`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇ`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`ˇg"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`ˇe ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e `ˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``ˇ`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``ˇqui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``ˇ`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``quˇi`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``ˇ`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ˇ`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck broˇ`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`ˇ`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`ˇfox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`ˇ`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox juˇmps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`ˇ`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ovˇ`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`ˇ`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`ˇer\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`ˇer\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe ˇlazy d`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇ`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇ`o`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇ`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇo`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇ`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`oˇ`g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇ`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`ˇg"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Thˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`ˇe ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Thˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Thˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e `ˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e `ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``ˇqui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e `ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``quˇi`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e `ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``quiˇwn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck broˇ`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck brˇo\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`ˇfox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\nˇer\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox juˇmps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\nˇer\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ovˇ`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\nˇer\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`ˇer\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`ˇer\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe ˇlazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇ`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇo`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`oˇ`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`ˇg"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Thˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`ˇe ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Thˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ˇ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Thˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e `ˇ`qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e `ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``ˇqui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e `ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``quˇi`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e `ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ˇck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``quiˇwn`\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck broˇ`wn`\n`fox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck brˇo\n`fox jumps ov`er\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`ˇfox jumps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\nˇer\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox juˇmps ov`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\nˇer\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ovˇ`er\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\nˇer\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`ˇer\nthe lazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`ˇer\nthe lazy d`o`g","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe ˇlazy d`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇ`o`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`ˇo`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`oˇ`g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`ˇg"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"`"}
+{"Get":{"state":"Th`e ``qui`ck bro`wn`\n`fox jumps ov`er\nthe lazy d`o`ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"ˇe \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"ˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"ˇ\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"ˇqui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"ˇ\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"quˇi\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"ˇ\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ˇ\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck broˇ\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"ˇ\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇfox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇ\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox juˇmps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇ\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ovˇ\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇ\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"ˇer\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"ˇer\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe ˇlazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇ\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇ\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇ\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇo\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇ\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"oˇ\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇ\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"ˇg"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"ˇe \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"ˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"ˇ\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"ˇqui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"ˇ\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"quˇi\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"ˇ\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ˇ\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck broˇ\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"ˇ\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇfox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇ\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox juˇmps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇ\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ovˇ\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇ\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"ˇer\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"ˇer\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe ˇlazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇ\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇ\"o\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇ\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇo\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇ\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"oˇ\"g"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇ\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"ˇg"}}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Thˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"ˇe \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Thˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Thˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"ˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"ˇqui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"quˇi\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"quiˇwn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck broˇ\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck brˇo\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇfox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\nˇer\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox juˇmps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\nˇer\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ovˇ\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\nˇer\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"ˇer\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"ˇer\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe ˇlazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇ\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇo\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"oˇ\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"ˇg"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"ˇg","mode":"Normal"}}
+{"Put":{"state":"ˇTh\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Thˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"ˇe \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Thˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e ˇ\"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Thˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"ˇ\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"ˇqui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"quˇi\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ˇck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"quiˇwn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck broˇ\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck brˇo\n\"fox jumps ov\"er\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"ˇfox jumps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\nˇer\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox juˇmps ov\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\nˇer\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ovˇ\"er\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\nˇer\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"ˇer\nthe lazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"ˇer\nthe lazy d\"o\"g","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe ˇlazy d\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇ\"o\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"ˇo\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"oˇ\"g"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy dˇg","mode":"Normal"}}
+{"Put":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"ˇg"}}
+{"Key":"d"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"Th\"e \"\"qui\"ck bro\"wn\"\n\"fox jumps ov\"er\nthe lazy d\"o\"ˇg","mode":"Normal"}}
{"Put":{"state":"ˇTh)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"}}
{"Key":"d"}
{"Key":"i"}
@@ -1,32 +0,0 @@
-{"Put":{"state":"Thˇe quick-brown\n\n\nfox_jumps over\nthe"}}
-{"Key":"e"}
-{"Get":{"state":"The quicˇk-brown\n\n\nfox_jumps over\nthe","mode":"Normal"}}
-{"Key":"e"}
-{"Get":{"state":"The quickˇ-brown\n\n\nfox_jumps over\nthe","mode":"Normal"}}
-{"Key":"e"}
-{"Get":{"state":"The quick-browˇn\n\n\nfox_jumps over\nthe","mode":"Normal"}}
-{"Key":"e"}
-{"Get":{"state":"The quick-brown\n\n\nfox_jumpˇs over\nthe","mode":"Normal"}}
-{"Key":"e"}
-{"Get":{"state":"The quick-brown\n\n\nfox_jumps oveˇr\nthe","mode":"Normal"}}
-{"Key":"e"}
-{"Get":{"state":"The quick-brown\n\n\nfox_jumps over\nthˇe","mode":"Normal"}}
-{"Key":"e"}
-{"Get":{"state":"The quick-brown\n\n\nfox_jumps over\nthˇe","mode":"Normal"}}
-{"Put":{"state":"Thˇe quick-brown\n\n\nfox_jumps over\nthe"}}
-{"Key":"shift-e"}
-{"Get":{"state":"The quick-browˇn\n\n\nfox_jumps over\nthe","mode":"Normal"}}
-{"Put":{"state":"The quicˇk-brown\n\n\nfox_jumps over\nthe"}}
-{"Key":"shift-e"}
-{"Get":{"state":"The quick-browˇn\n\n\nfox_jumps over\nthe","mode":"Normal"}}
-{"Put":{"state":"The quickˇ-brown\n\n\nfox_jumps over\nthe"}}
-{"Key":"shift-e"}
-{"Get":{"state":"The quick-browˇn\n\n\nfox_jumps over\nthe","mode":"Normal"}}
-{"Key":"shift-e"}
-{"Get":{"state":"The quick-brown\n\n\nfox_jumpˇs over\nthe","mode":"Normal"}}
-{"Key":"shift-e"}
-{"Get":{"state":"The quick-brown\n\n\nfox_jumps oveˇr\nthe","mode":"Normal"}}
-{"Key":"shift-e"}
-{"Get":{"state":"The quick-brown\n\n\nfox_jumps over\nthˇe","mode":"Normal"}}
-{"Key":"shift-e"}
-{"Get":{"state":"The quick-brown\n\n\nfox_jumps over\nthˇe","mode":"Normal"}}
@@ -9,6 +9,7 @@
{"Key":"ctrl-v"}
{"Key":"g"}
{"Key":"g"}
+{"Get":{"state":"«1ˇ»\n«2ˇ»\n«3ˇ» 2\n«4ˇ»\n«5ˇ»","mode":"VisualBlock"}}
{"Key":"g"}
{"Key":"ctrl-x"}
{"Get":{"state":"ˇ0\n0\n0 2\n0\n0","mode":"Normal"}}
@@ -1,3 +1,6 @@
+{"Put":{"state":"aaˇaa\n😃😃"}}
+{"Key":"j"}
+{"Get":{"state":"aaaa\n😃ˇ😃","mode":"Normal"}}
{"Put":{"state":"ˇThe quick brown\nfox jumps"}}
{"Key":"j"}
{"Get":{"state":"The quick brown\nˇfox jumps","mode":"Normal"}}
@@ -8,3 +8,8 @@
{"Key":"i"}
{"Key":"{"}
{"Get":{"state":"func empty(a string) bool {\n if a == \"\" {\n« return true\nˇ» }\n return false\n}","mode":"Visual"}}
+{"Put":{"state":"func empty(a string) bool {\n if a == \"\" ˇ{\n return true\n }\n return false\n}"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"{"}
+{"Get":{"state":"func empty(a string) bool {\n if a == \"\" {\n« return true\nˇ» }\n return false\n}","mode":"Visual"}}
@@ -0,0 +1,27 @@
+{"SetOption":{"value":"wrap"}}
+{"SetOption":{"value":"columns=12"}}
+{"Put":{"state":"helˇlo \"world\"!"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"hello \"«worldˇ»\"!","mode":"Visual"}}
+{"Put":{"state":"hello \"wˇorld\"!"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"\""}
+{"Get":{"state":"hello \"«worldˇ»\"!","mode":"Visual"}}
+{"Put":{"state":"hello \"wˇorld\"!"}}
+{"Key":"v"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"hello« \"world\"ˇ»!","mode":"Visual"}}
+{"Put":{"state":"hello \"wˇorld\" !"}}
+{"Key":"v"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"hello «\"world\" ˇ»!","mode":"Visual"}}
+{"Put":{"state":"hello \"wˇorld\"•\ngoodbye"}}
+{"Key":"v"}
+{"Key":"a"}
+{"Key":"\""}
+{"Get":{"state":"hello «\"world\" ˇ»\ngoodbye","mode":"Visual"}}
@@ -0,0 +1,5 @@
+{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog\n"}}
+{"Key":"ctrl-v"}
+{"Key":"right"}
+{"Key":"down"}
+{"Get":{"state":"The «quˇ»ick brown\nfox «juˇ»mps over\nthe lazy dog\n","mode":"VisualBlock"}}
@@ -1,26 +0,0 @@
-{"Put":{"state":"The quick brown\nfox jˇumps over\nthe lazy dog"}}
-{"Key":"v"}
-{"Key":"i"}
-{"Key":"w"}
-{"Key":"y"}
-{"Get":{"state":"The quick brown\nfox ˇjumps over\nthe lazy dog","mode":"Normal"}}
-{"Key":"p"}
-{"Get":{"state":"The quick brown\nfox jjumpˇsumps over\nthe lazy dog","mode":"Normal"}}
-{"Put":{"state":"The quick brown\nfox juˇmps over\nthe lazy dog"}}
-{"Key":"shift-v"}
-{"Key":"d"}
-{"Get":{"state":"The quick brown\nthe laˇzy dog","mode":"Normal"}}
-{"Key":"v"}
-{"Key":"i"}
-{"Key":"w"}
-{"Key":"p"}
-{"Get":{"state":"The quick brown\nthe \nˇfox jumps over\n dog","mode":"Normal"}}
-{"ReadRegister":{"name":"\"","value":"lazy"}}
-{"Put":{"state":"The quick brown\nfox juˇmps over\nthe lazy dog"}}
-{"Key":"shift-v"}
-{"Key":"d"}
-{"Get":{"state":"The quick brown\nthe laˇzy dog","mode":"Normal"}}
-{"Key":"k"}
-{"Key":"shift-v"}
-{"Key":"p"}
-{"Get":{"state":"ˇfox jumps over\nthe lazy dog","mode":"Normal"}}
@@ -0,0 +1,15 @@
+{"SetOption":{"value":"wrap"}}
+{"SetOption":{"value":"columns=12"}}
+{"Put":{"state":"aaˇaa\n😃😃"}}
+{"Key":"j"}
+{"Get":{"state":"aaaa\n😃ˇ😃","mode":"Normal"}}
+{"Put":{"state":"123456789012aaˇaa\n123456789012😃😃"}}
+{"Key":"j"}
+{"Get":{"state":"123456789012aaaa\n123456789012😃ˇ😃","mode":"Normal"}}
+{"Put":{"state":"123456789012aaˇaa\n123456789012😃😃"}}
+{"Key":"j"}
+{"Get":{"state":"123456789012aaaa\n123456789012😃ˇ😃","mode":"Normal"}}
+{"Put":{"state":"123456789012aaaaˇaaaaaaaa123456789012\nwow\n123456789012😃😃😃😃😃😃123456789012"}}
+{"Key":"j"}
+{"Key":"j"}
+{"Get":{"state":"123456789012aaaaaaaaaaaa123456789012\nwow\n123456789012😃😃ˇ😃😃😃😃123456789012","mode":"Normal"}}
@@ -35,9 +35,9 @@ use gpui::{
CursorStyle, ModifiersChangedEvent, MouseButton, PathPromptOptions, Platform, PromptLevel,
WindowBounds, WindowOptions,
},
- AnyModelHandle, AnyViewHandle, AnyWeakViewHandle, AnyWindowHandle, AppContext, AsyncAppContext,
- Entity, ModelContext, ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext,
- ViewHandle, WeakViewHandle, WindowContext, WindowHandle,
+ AnyModelHandle, AnyViewHandle, AnyWeakViewHandle, AppContext, AsyncAppContext, Entity,
+ ModelContext, ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext, ViewHandle,
+ WeakViewHandle, WindowContext, WindowHandle,
};
use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ProjectItem};
use itertools::Itertools;
@@ -289,6 +289,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {
cx.add_global_action(restart);
cx.add_async_action(Workspace::save_all);
cx.add_action(Workspace::add_folder_to_project);
+
cx.add_action(
|workspace: &mut Workspace, _: &Unfollow, cx: &mut ViewContext<Workspace>| {
let pane = workspace.active_pane().clone();
@@ -4237,6 +4238,10 @@ async fn join_channel_internal(
})
.await?;
+ let Some(room) = room else {
+ return anyhow::Ok(true);
+ };
+
room.update(cx, |room, _| room.room_update_completed())
.await;
@@ -4294,12 +4299,14 @@ pub fn join_channel(
}
if let Err(err) = result {
- let prompt = active_window.unwrap().prompt(
- PromptLevel::Critical,
- &format!("Failed to join channel: {}", err),
- &["Ok"],
- &mut cx,
- );
+ let prompt = active_window.unwrap().update(&mut cx, |_, cx| {
+ cx.prompt(
+ PromptLevel::Critical,
+ &format!("Failed to join channel: {}", err),
+ &["Ok"],
+ )
+ });
+
if let Some(mut prompt) = prompt {
prompt.next().await;
} else {
@@ -4312,17 +4319,39 @@ pub fn join_channel(
})
}
-pub fn activate_any_workspace_window(cx: &mut AsyncAppContext) -> Option<AnyWindowHandle> {
+pub async fn get_any_active_workspace(
+ app_state: Arc<AppState>,
+ mut cx: AsyncAppContext,
+) -> Result<ViewHandle<Workspace>> {
+ // find an existing workspace to focus and show call controls
+ let active_window = activate_any_workspace_window(&mut cx);
+ if active_window.is_none() {
+ cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), None, cx))
+ .await;
+ }
+
+ let Some(active_window) = activate_any_workspace_window(&mut cx) else {
+ return Err(anyhow!("could not open zed"))?;
+ };
+
+ Ok(active_window)
+}
+
+pub fn activate_any_workspace_window(cx: &mut AsyncAppContext) -> Option<ViewHandle<Workspace>> {
for window in cx.windows() {
- let found = window.update(cx, |cx| {
- let is_workspace = cx.root_view().clone().downcast::<Workspace>().is_some();
- if is_workspace {
- cx.activate_window();
- }
- is_workspace
- });
- if found == Some(true) {
- return Some(window);
+ if let Some(workspace) = window
+ .update(cx, |cx| {
+ cx.root_view()
+ .clone()
+ .downcast::<Workspace>()
+ .map(|workspace| {
+ cx.activate_window();
+ workspace
+ })
+ })
+ .flatten()
+ {
+ return Some(workspace);
}
}
None
@@ -8,3 +8,4 @@ publish = false
[dependencies]
gpui = { path = "../gpui" }
+serde.workspace = true
@@ -1,4 +1,7 @@
-use gpui::actions;
+use std::sync::Arc;
+
+use gpui::{actions, impl_actions};
+use serde::Deserialize;
actions!(
zed,
@@ -26,3 +29,13 @@ actions!(
ResetDatabase,
]
);
+
+#[derive(Deserialize, Clone, PartialEq)]
+pub struct OpenBrowser {
+ pub url: Arc<str>,
+}
+#[derive(Deserialize, Clone, PartialEq)]
+pub struct OpenZedURL {
+ pub url: String,
+}
+impl_actions!(zed, [OpenBrowser, OpenZedURL]);
@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathansobo@gmail.com>"]
description = "The fast, collaborative code editor."
edition = "2021"
name = "zed"
-version = "0.109.0"
+version = "0.111.0"
publish = false
[lib]
@@ -15,6 +15,9 @@ doctest = false
name = "Zed"
path = "src/main.rs"
+[[example]]
+name = "semantic_index_eval"
+
[dependencies]
audio = { path = "../audio" }
activity_indicator = { path = "../activity_indicator" }
@@ -50,6 +53,7 @@ language_selector = { path = "../language_selector" }
lsp = { path = "../lsp" }
language_tools = { path = "../language_tools" }
node_runtime = { path = "../node_runtime" }
+notifications = { path = "../notifications" }
assistant = { path = "../assistant" }
outline = { path = "../outline" }
plugin_runtime = { path = "../plugin_runtime",optional = true }
@@ -135,12 +139,14 @@ tree-sitter-yaml.workspace = true
tree-sitter-lua.workspace = true
tree-sitter-nix.workspace = true
tree-sitter-nu.workspace = true
+tree-sitter-vue.workspace = true
url = "2.2"
urlencoding = "2.1.2"
uuid.workspace = true
[dev-dependencies]
+ai = { path = "../ai" }
call = { path = "../call", features = ["test-support"] }
client = { path = "../client", features = ["test-support"] }
editor = { path = "../editor", features = ["test-support"] }
@@ -55,7 +55,7 @@ fn parse_eval() -> anyhow::Result<Vec<RepoEval>> {
.as_path()
.parent()
.unwrap()
- .join("crates/semantic_index/eval");
+ .join("zed/crates/semantic_index/eval");
let mut repo_evals: Vec<RepoEval> = Vec::new();
for entry in fs::read_dir(eval_folder)? {
@@ -469,6 +469,7 @@ fn main() {
.join("embeddings_db");
let languages = languages.clone();
+
let fs = fs.clone();
cx.spawn(|mut cx| async move {
let semantic_index = SemanticIndex::new(
@@ -24,6 +24,7 @@ mod rust;
mod svelte;
mod tailwind;
mod typescript;
+mod vue;
mod yaml;
// 1. Add tree-sitter-{language} parser to zed crate
@@ -75,7 +76,10 @@ pub fn init(
elixir::ElixirLspSetting::ElixirLs => language(
"elixir",
tree_sitter_elixir::language(),
- vec![Arc::new(elixir::ElixirLspAdapter)],
+ vec![
+ Arc::new(elixir::ElixirLspAdapter),
+ Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())),
+ ],
),
elixir::ElixirLspSetting::NextLs => language(
"elixir",
@@ -100,7 +104,10 @@ pub fn init(
language(
"heex",
tree_sitter_heex::language(),
- vec![Arc::new(elixir::ElixirLspAdapter)],
+ vec![
+ Arc::new(elixir::ElixirLspAdapter),
+ Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())),
+ ],
);
language(
"json",
@@ -166,7 +173,10 @@ pub fn init(
language(
"erb",
tree_sitter_embedded_template::language(),
- vec![Arc::new(ruby::RubyLanguageServer)],
+ vec![
+ Arc::new(ruby::RubyLanguageServer),
+ Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())),
+ ],
);
language("scheme", tree_sitter_scheme::language(), vec![]);
language("racket", tree_sitter_racket::language(), vec![]);
@@ -183,20 +193,29 @@ pub fn init(
language(
"svelte",
tree_sitter_svelte::language(),
- vec![Arc::new(svelte::SvelteLspAdapter::new(
- node_runtime.clone(),
- ))],
+ vec![
+ Arc::new(svelte::SvelteLspAdapter::new(node_runtime.clone())),
+ Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())),
+ ],
);
language(
"php",
tree_sitter_php::language(),
- vec![Arc::new(php::IntelephenseLspAdapter::new(node_runtime))],
+ vec![
+ Arc::new(php::IntelephenseLspAdapter::new(node_runtime.clone())),
+ Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())),
+ ],
);
language("elm", tree_sitter_elm::language(), vec![]);
language("glsl", tree_sitter_glsl::language(), vec![]);
language("nix", tree_sitter_nix::language(), vec![]);
language("nu", tree_sitter_nu::language(), vec![]);
+ language(
+ "vue",
+ tree_sitter_vue::language(),
+ vec![Arc::new(vue::VueLspAdapter::new(node_runtime))],
+ );
}
#[cfg(any(test, feature = "test-support"))]
@@ -3,6 +3,7 @@
(raw_string)
(heredoc_body)
(heredoc_start)
+ (ansi_c_string)
] @string
(command_name) @function
@@ -1,7 +1,7 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use futures::StreamExt;
-use language::{BundledFormatter, LanguageServerName, LspAdapter, LspAdapterDelegate};
+use language::{LanguageServerName, LspAdapter, LspAdapterDelegate};
use lsp::LanguageServerBinary;
use node_runtime::NodeRuntime;
use serde_json::json;
@@ -96,10 +96,6 @@ impl LspAdapter for CssLspAdapter {
"provideFormatter": true
}))
}
-
- fn enabled_formatters(&self) -> Vec<BundledFormatter> {
- vec![BundledFormatter::prettier("css")]
- }
}
async fn get_cached_server_binary(
@@ -10,3 +10,4 @@ brackets = [
]
word_characters = ["-"]
block_comment = ["/* ", " */"]
+prettier_parser_name = "css"
@@ -19,7 +19,7 @@ use std::{
},
};
use util::{
- async_iife,
+ async_maybe,
fs::remove_matching,
github::{latest_github_release, GitHubLspBinaryVersion},
ResultExt,
@@ -421,7 +421,7 @@ impl LspAdapter for NextLspAdapter {
}
async fn get_cached_server_binary_next(container_dir: PathBuf) -> Option<LanguageServerBinary> {
- async_iife!({
+ async_maybe!({
let mut last_binary_path = None;
let mut entries = fs::read_dir(&container_dir).await?;
while let Some(entry) = entries.next().await {
@@ -9,3 +9,8 @@ brackets = [
{ start = "\"", end = "\"", close = true, newline = false, not_in = ["string", "comment"] },
{ start = "'", end = "'", close = true, newline = false, not_in = ["string", "comment"] },
]
+scope_opt_in_language_servers = ["tailwindcss-language-server"]
+
+[overrides.string]
+word_characters = ["-"]
+opt_into_language_servers = ["tailwindcss-language-server"]
@@ -5,3 +5,4 @@ brackets = [
{ start = "<", end = ">", close = true, newline = true },
]
block_comment = ["<%#", "%>"]
+scope_opt_in_language_servers = ["tailwindcss-language-server"]
@@ -5,3 +5,8 @@ brackets = [
{ start = "<", end = ">", close = true, newline = true },
]
block_comment = ["<%!-- ", " --%>"]
+scope_opt_in_language_servers = ["tailwindcss-language-server"]
+
+[overrides.string]
+word_characters = ["-"]
+opt_into_language_servers = ["tailwindcss-language-server"]
@@ -0,0 +1,4 @@
+[
+ (attribute_value)
+ (quoted_attribute_value)
+] @string
@@ -1,7 +1,7 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use futures::StreamExt;
-use language::{BundledFormatter, LanguageServerName, LspAdapter, LspAdapterDelegate};
+use language::{LanguageServerName, LspAdapter, LspAdapterDelegate};
use lsp::LanguageServerBinary;
use node_runtime::NodeRuntime;
use serde_json::json;
@@ -96,10 +96,6 @@ impl LspAdapter for HtmlLspAdapter {
"provideFormatter": true
}))
}
-
- fn enabled_formatters(&self) -> Vec<BundledFormatter> {
- vec![BundledFormatter::prettier("html")]
- }
}
async fn get_cached_server_binary(
@@ -11,3 +11,4 @@ brackets = [
{ start = "!--", end = " --", close = true, newline = false, not_in = ["comment", "string"] },
]
word_characters = ["-"]
+prettier_parser_name = "html"
@@ -15,6 +15,7 @@ brackets = [
]
word_characters = ["$", "#"]
scope_opt_in_language_servers = ["tailwindcss-language-server"]
+prettier_parser_name = "babel"
[overrides.element]
line_comment = { remove = true }
@@ -4,9 +4,7 @@ use collections::HashMap;
use feature_flags::FeatureFlagAppExt;
use futures::{future::BoxFuture, FutureExt, StreamExt};
use gpui::AppContext;
-use language::{
- BundledFormatter, LanguageRegistry, LanguageServerName, LspAdapter, LspAdapterDelegate,
-};
+use language::{LanguageRegistry, LanguageServerName, LspAdapter, LspAdapterDelegate};
use lsp::LanguageServerBinary;
use node_runtime::NodeRuntime;
use serde_json::json;
@@ -146,10 +144,6 @@ impl LspAdapter for JsonLspAdapter {
async fn language_ids(&self) -> HashMap<String, String> {
[("JSON".into(), "jsonc".into())].into_iter().collect()
}
-
- fn enabled_formatters(&self) -> Vec<BundledFormatter> {
- vec![BundledFormatter::prettier("json")]
- }
}
async fn get_cached_server_binary(
@@ -7,3 +7,4 @@ brackets = [
{ start = "[", end = "]", close = true, newline = true },
{ start = "\"", end = "\"", close = true, newline = false, not_in = ["string"] },
]
+prettier_parser_name = "json"
@@ -8,7 +8,7 @@ use lsp::LanguageServerBinary;
use smol::fs;
use std::{any::Any, env::consts, path::PathBuf};
use util::{
- async_iife,
+ async_maybe,
github::{latest_github_release, GitHubLspBinaryVersion},
ResultExt,
};
@@ -106,7 +106,7 @@ impl super::LspAdapter for LuaLspAdapter {
}
async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServerBinary> {
- async_iife!({
+ async_maybe!({
let mut last_binary_path = None;
let mut entries = fs::read_dir(&container_dir).await?;
while let Some(entry) = entries.next().await {
@@ -11,3 +11,4 @@ brackets = [
]
collapsed_placeholder = "/* ... */"
word_characters = ["$"]
+scope_opt_in_language_servers = ["tailwindcss-language-server"]
@@ -1,7 +1,7 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use futures::StreamExt;
-use language::{BundledFormatter, LanguageServerName, LspAdapter, LspAdapterDelegate};
+use language::{LanguageServerName, LspAdapter, LspAdapterDelegate};
use lsp::LanguageServerBinary;
use node_runtime::NodeRuntime;
use serde_json::json;
@@ -96,11 +96,8 @@ impl LspAdapter for SvelteLspAdapter {
}))
}
- fn enabled_formatters(&self) -> Vec<BundledFormatter> {
- vec![BundledFormatter::Prettier {
- parser_name: Some("svelte"),
- plugin_names: vec!["prettier-plugin-svelte"],
- }]
+ fn prettier_plugins(&self) -> &[&'static str] {
+ &["prettier-plugin-svelte"]
}
}
@@ -12,7 +12,9 @@ brackets = [
{ start = "`", end = "`", close = true, newline = false, not_in = ["string"] },
{ start = "/*", end = " */", close = true, newline = false, not_in = ["string", "comment"] },
]
+scope_opt_in_language_servers = ["tailwindcss-language-server"]
+prettier_parser_name = "svelte"
-[overrides.element]
-line_comment = { remove = true }
-block_comment = ["{/* ", " */}"]
+[overrides.string]
+word_characters = ["-"]
+opt_into_language_servers = ["tailwindcss-language-server"]
@@ -0,0 +1,7 @@
+(comment) @comment
+
+[
+ (raw_text)
+ (attribute_value)
+ (quoted_attribute_value)
+] @string
@@ -6,7 +6,7 @@ use futures::{
FutureExt, StreamExt,
};
use gpui::AppContext;
-use language::{BundledFormatter, LanguageServerName, LspAdapter, LspAdapterDelegate};
+use language::{LanguageServerName, LspAdapter, LspAdapterDelegate};
use lsp::LanguageServerBinary;
use node_runtime::NodeRuntime;
use serde_json::{json, Value};
@@ -117,22 +117,21 @@ impl LspAdapter for TailwindLspAdapter {
}
async fn language_ids(&self) -> HashMap<String, String> {
- HashMap::from_iter(
- [
- ("HTML".to_string(), "html".to_string()),
- ("CSS".to_string(), "css".to_string()),
- ("JavaScript".to_string(), "javascript".to_string()),
- ("TSX".to_string(), "typescriptreact".to_string()),
- ]
- .into_iter(),
- )
+ HashMap::from_iter([
+ ("HTML".to_string(), "html".to_string()),
+ ("CSS".to_string(), "css".to_string()),
+ ("JavaScript".to_string(), "javascript".to_string()),
+ ("TSX".to_string(), "typescriptreact".to_string()),
+ ("Svelte".to_string(), "svelte".to_string()),
+ ("Elixir".to_string(), "phoenix-heex".to_string()),
+ ("HEEX".to_string(), "phoenix-heex".to_string()),
+ ("ERB".to_string(), "erb".to_string()),
+ ("PHP".to_string(), "php".to_string()),
+ ])
}
- fn enabled_formatters(&self) -> Vec<BundledFormatter> {
- vec![BundledFormatter::Prettier {
- parser_name: None,
- plugin_names: vec!["prettier-plugin-tailwindcss"],
- }]
+ fn prettier_plugins(&self) -> &[&'static str] {
+ &["prettier-plugin-tailwindcss"]
}
}
@@ -14,6 +14,7 @@ brackets = [
]
word_characters = ["#", "$"]
scope_opt_in_language_servers = ["tailwindcss-language-server"]
+prettier_parser_name = "typescript"
[overrides.element]
line_comment = { remove = true }
@@ -4,7 +4,7 @@ use async_tar::Archive;
use async_trait::async_trait;
use futures::{future::BoxFuture, FutureExt};
use gpui::AppContext;
-use language::{BundledFormatter, LanguageServerName, LspAdapter, LspAdapterDelegate};
+use language::{LanguageServerName, LspAdapter, LspAdapterDelegate};
use lsp::{CodeActionKind, LanguageServerBinary};
use node_runtime::NodeRuntime;
use serde_json::{json, Value};
@@ -161,10 +161,6 @@ impl LspAdapter for TypeScriptLspAdapter {
"provideFormatter": true
}))
}
-
- fn enabled_formatters(&self) -> Vec<BundledFormatter> {
- vec![BundledFormatter::prettier("typescript")]
- }
}
async fn get_cached_ts_server_binary(
@@ -313,10 +309,6 @@ impl LspAdapter for EsLintLspAdapter {
async fn initialization_options(&self) -> Option<serde_json::Value> {
None
}
-
- fn enabled_formatters(&self) -> Vec<BundledFormatter> {
- vec![BundledFormatter::prettier("babel")]
- }
}
async fn get_cached_eslint_server_binary(
@@ -13,3 +13,4 @@ brackets = [
{ start = "/*", end = " */", close = true, newline = false, not_in = ["string", "comment"] },
]
word_characters = ["#", "$"]
+prettier_parser_name = "typescript"
@@ -0,0 +1,220 @@
+use anyhow::{anyhow, ensure, Result};
+use async_trait::async_trait;
+use futures::StreamExt;
+pub use language::*;
+use lsp::{CodeActionKind, LanguageServerBinary};
+use node_runtime::NodeRuntime;
+use parking_lot::Mutex;
+use serde_json::Value;
+use smol::fs::{self};
+use std::{
+ any::Any,
+ ffi::OsString,
+ path::{Path, PathBuf},
+ sync::Arc,
+};
+use util::ResultExt;
+
+pub struct VueLspVersion {
+ vue_version: String,
+ ts_version: String,
+}
+
+pub struct VueLspAdapter {
+ node: Arc<dyn NodeRuntime>,
+ typescript_install_path: Mutex<Option<PathBuf>>,
+}
+
+impl VueLspAdapter {
+ const SERVER_PATH: &'static str =
+ "node_modules/@vue/language-server/bin/vue-language-server.js";
+ // TODO: this can't be hardcoded, yet we have to figure out how to pass it in initialization_options.
+ const TYPESCRIPT_PATH: &'static str = "node_modules/typescript/lib";
+ pub fn new(node: Arc<dyn NodeRuntime>) -> Self {
+ let typescript_install_path = Mutex::new(None);
+ Self {
+ node,
+ typescript_install_path,
+ }
+ }
+}
+#[async_trait]
+impl super::LspAdapter for VueLspAdapter {
+ async fn name(&self) -> LanguageServerName {
+ LanguageServerName("vue-language-server".into())
+ }
+
+ fn short_name(&self) -> &'static str {
+ "vue-language-server"
+ }
+
+ async fn fetch_latest_server_version(
+ &self,
+ _: &dyn LspAdapterDelegate,
+ ) -> Result<Box<dyn 'static + Send + Any>> {
+ Ok(Box::new(VueLspVersion {
+ vue_version: self
+ .node
+ .npm_package_latest_version("@vue/language-server")
+ .await?,
+ ts_version: self.node.npm_package_latest_version("typescript").await?,
+ }) as Box<_>)
+ }
+ async fn initialization_options(&self) -> Option<Value> {
+ let typescript_sdk_path = self.typescript_install_path.lock();
+ let typescript_sdk_path = typescript_sdk_path
+ .as_ref()
+ .expect("initialization_options called without a container_dir for typescript");
+
+ Some(serde_json::json!({
+ "typescript": {
+ "tsdk": typescript_sdk_path
+ }
+ }))
+ }
+ fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
+ // REFACTOR is explicitly disabled, as vue-lsp does not adhere to LSP protocol for code actions with these - it
+ // sends back a CodeAction with neither `command` nor `edits` fields set, which is against the spec.
+ Some(vec![
+ CodeActionKind::EMPTY,
+ CodeActionKind::QUICKFIX,
+ CodeActionKind::REFACTOR_REWRITE,
+ ])
+ }
+ async fn fetch_server_binary(
+ &self,
+ version: Box<dyn 'static + Send + Any>,
+ container_dir: PathBuf,
+ _: &dyn LspAdapterDelegate,
+ ) -> Result<LanguageServerBinary> {
+ let version = version.downcast::<VueLspVersion>().unwrap();
+ let server_path = container_dir.join(Self::SERVER_PATH);
+ let ts_path = container_dir.join(Self::TYPESCRIPT_PATH);
+ if fs::metadata(&server_path).await.is_err() {
+ self.node
+ .npm_install_packages(
+ &container_dir,
+ &[("@vue/language-server", version.vue_version.as_str())],
+ )
+ .await?;
+ }
+ ensure!(
+ fs::metadata(&server_path).await.is_ok(),
+ "@vue/language-server package installation failed"
+ );
+ if fs::metadata(&ts_path).await.is_err() {
+ self.node
+ .npm_install_packages(
+ &container_dir,
+ &[("typescript", version.ts_version.as_str())],
+ )
+ .await?;
+ }
+
+ ensure!(
+ fs::metadata(&ts_path).await.is_ok(),
+ "typescript for Vue package installation failed"
+ );
+ *self.typescript_install_path.lock() = Some(ts_path);
+ Ok(LanguageServerBinary {
+ path: self.node.binary_path().await?,
+ arguments: vue_server_binary_arguments(&server_path),
+ })
+ }
+
+ async fn cached_server_binary(
+ &self,
+ container_dir: PathBuf,
+ _: &dyn LspAdapterDelegate,
+ ) -> Option<LanguageServerBinary> {
+ let (server, ts_path) = get_cached_server_binary(container_dir, self.node.clone()).await?;
+ *self.typescript_install_path.lock() = Some(ts_path);
+ Some(server)
+ }
+
+ async fn installation_test_binary(
+ &self,
+ container_dir: PathBuf,
+ ) -> Option<LanguageServerBinary> {
+ let (server, ts_path) = get_cached_server_binary(container_dir, self.node.clone())
+ .await
+ .map(|(mut binary, ts_path)| {
+ binary.arguments = vec!["--help".into()];
+ (binary, ts_path)
+ })?;
+ *self.typescript_install_path.lock() = Some(ts_path);
+ Some(server)
+ }
+
+ async fn label_for_completion(
+ &self,
+ item: &lsp::CompletionItem,
+ language: &Arc<language::Language>,
+ ) -> Option<language::CodeLabel> {
+ use lsp::CompletionItemKind as Kind;
+ let len = item.label.len();
+ let grammar = language.grammar()?;
+ let highlight_id = match item.kind? {
+ Kind::CLASS | Kind::INTERFACE => grammar.highlight_id_for_name("type"),
+ Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"),
+ Kind::CONSTANT => grammar.highlight_id_for_name("constant"),
+ Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"),
+ Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("tag"),
+ Kind::VARIABLE => grammar.highlight_id_for_name("type"),
+ Kind::KEYWORD => grammar.highlight_id_for_name("keyword"),
+ Kind::VALUE => grammar.highlight_id_for_name("tag"),
+ _ => None,
+ }?;
+
+ let text = match &item.detail {
+ Some(detail) => format!("{} {}", item.label, detail),
+ None => item.label.clone(),
+ };
+
+ Some(language::CodeLabel {
+ text,
+ runs: vec![(0..len, highlight_id)],
+ filter_range: 0..len,
+ })
+ }
+}
+
+fn vue_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
+ vec![server_path.into(), "--stdio".into()]
+}
+
+type TypescriptPath = PathBuf;
+async fn get_cached_server_binary(
+ container_dir: PathBuf,
+ node: Arc<dyn NodeRuntime>,
+) -> Option<(LanguageServerBinary, TypescriptPath)> {
+ (|| async move {
+ let mut last_version_dir = None;
+ let mut entries = fs::read_dir(&container_dir).await?;
+ while let Some(entry) = entries.next().await {
+ let entry = entry?;
+ if entry.file_type().await?.is_dir() {
+ last_version_dir = Some(entry.path());
+ }
+ }
+ let last_version_dir = last_version_dir.ok_or_else(|| anyhow!("no cached binary"))?;
+ let server_path = last_version_dir.join(VueLspAdapter::SERVER_PATH);
+ let typescript_path = last_version_dir.join(VueLspAdapter::TYPESCRIPT_PATH);
+ if server_path.exists() && typescript_path.exists() {
+ Ok((
+ LanguageServerBinary {
+ path: node.binary_path().await?,
+ arguments: vue_server_binary_arguments(&server_path),
+ },
+ typescript_path,
+ ))
+ } else {
+ Err(anyhow!(
+ "missing executable in directory {:?}",
+ last_version_dir
+ ))
+ }
+ })()
+ .await
+ .log_err()
+}
@@ -0,0 +1,2 @@
+("<" @open ">" @close)
+("\"" @open "\"" @close)
@@ -0,0 +1,14 @@
+name = "Vue.js"
+path_suffixes = ["vue"]
+block_comment = ["<!-- ", " -->"]
+autoclose_before = ";:.,=}])>"
+brackets = [
+ { start = "{", end = "}", close = true, newline = true },
+ { start = "[", end = "]", close = true, newline = true },
+ { start = "(", end = ")", close = true, newline = true },
+ { start = "<", end = ">", close = true, newline = true, not_in = ["string", "comment"] },
+ { start = "\"", end = "\"", close = true, newline = false, not_in = ["string"] },
+ { start = "'", end = "'", close = true, newline = false, not_in = ["string", "comment"] },
+ { start = "`", end = "`", close = true, newline = false, not_in = ["string"] },
+]
+word_characters = ["-"]
@@ -0,0 +1,15 @@
+(attribute) @property
+(directive_attribute) @property
+(quoted_attribute_value) @string
+(interpolation) @punctuation.special
+(raw_text) @embedded
+
+((tag_name) @type
+ (#match? @type "^[A-Z]"))
+
+((directive_name) @keyword
+ (#match? @keyword "^v-"))
+
+(start_tag) @tag
+(end_tag) @tag
+(self_closing_tag) @tag
@@ -0,0 +1,7 @@
+(script_element
+ (raw_text) @content
+ (#set! "language" "javascript"))
+
+(style_element
+ (raw_text) @content
+ (#set! "language" "css"))
@@ -3,8 +3,7 @@ use async_trait::async_trait;
use futures::{future::BoxFuture, FutureExt, StreamExt};
use gpui::AppContext;
use language::{
- language_settings::all_language_settings, BundledFormatter, LanguageServerName, LspAdapter,
- LspAdapterDelegate,
+ language_settings::all_language_settings, LanguageServerName, LspAdapter, LspAdapterDelegate,
};
use lsp::LanguageServerBinary;
use node_runtime::NodeRuntime;
@@ -109,10 +108,6 @@ impl LspAdapter for YamlLspAdapter {
}))
.boxed()
}
-
- fn enabled_formatters(&self) -> Vec<BundledFormatter> {
- vec![BundledFormatter::prettier("yaml")]
- }
}
async fn get_cached_server_binary(
@@ -9,3 +9,4 @@ brackets = [
]
increase_indent_pattern = ":\\s*[|>]?\\s*$"
+prettier_parser_name = "yaml"
@@ -3,22 +3,17 @@
use anyhow::{anyhow, Context, Result};
use backtrace::Backtrace;
-use cli::{
- ipc::{self, IpcSender},
- CliRequest, CliResponse, IpcHandshake, FORCE_CLI_MODE_ENV_VAR_NAME,
-};
+use cli::FORCE_CLI_MODE_ENV_VAR_NAME;
use client::{
self, Client, TelemetrySettings, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN,
};
+use collab_ui::channel_view::ChannelView;
use db::kvp::KEY_VALUE_STORE;
-use editor::{scroll::autoscroll::Autoscroll, Editor};
-use futures::{
- channel::{mpsc, oneshot},
- FutureExt, SinkExt, StreamExt,
-};
+use editor::Editor;
+use futures::StreamExt;
use gpui::{Action, App, AppContext, AssetSource, AsyncAppContext, Task};
use isahc::{config::Configurable, Request};
-use language::{LanguageRegistry, Point};
+use language::LanguageRegistry;
use log::LevelFilter;
use node_runtime::RealNodeRuntime;
use parking_lot::Mutex;
@@ -28,7 +23,6 @@ use settings::{default_settings, handle_settings_file_changes, watch_config_file
use simplelog::ConfigBuilder;
use smol::process::Command;
use std::{
- collections::HashMap,
env,
ffi::OsStr,
fs::OpenOptions,
@@ -40,13 +34,11 @@ use std::{
Arc, Weak,
},
thread,
- time::{Duration, SystemTime, UNIX_EPOCH},
+ time::{SystemTime, UNIX_EPOCH},
};
-use sum_tree::Bias;
use util::{
channel::{parse_zed_link, ReleaseChannel},
http::{self, HttpClient},
- paths::PathLikeWithPosition,
};
use uuid::Uuid;
use welcome::{show_welcome_experience, FIRST_OPEN};
@@ -58,12 +50,9 @@ use zed::{
assets::Assets,
build_window_options, handle_keymap_file_changes, initialize_workspace, languages, menus,
only_instance::{ensure_only_instance, IsOnlyInstance},
+ open_listener::{handle_cli_connection, OpenListener, OpenRequest},
};
-use crate::open_listener::{OpenListener, OpenRequest};
-
-mod open_listener;
-
fn main() {
let http = http::client();
init_paths();
@@ -113,6 +102,7 @@ fn main() {
app.run(move |cx| {
cx.set_global(*RELEASE_CHANNEL);
+ cx.set_global(listener.clone());
let mut store = SettingsStore::default();
store
@@ -202,6 +192,7 @@ fn main() {
activity_indicator::init(cx);
language_tools::init(cx);
call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
+ notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx);
collab_ui::init(&app_state, cx);
feedback::init(cx);
welcome::init(cx);
@@ -250,6 +241,20 @@ fn main() {
})
.detach_and_log_err(cx)
}
+ Ok(Some(OpenRequest::OpenChannelNotes { channel_id })) => {
+ triggered_authentication = true;
+ let app_state = app_state.clone();
+ let client = client.clone();
+ cx.spawn(|mut cx| async move {
+ // ignore errors here, we'll show a generic "not signed in"
+ let _ = authenticate(client, &cx).await;
+ let workspace =
+ workspace::get_any_active_workspace(app_state, cx.clone()).await?;
+ cx.update(|cx| ChannelView::open(channel_id, workspace, cx))
+ .await
+ })
+ .detach_and_log_err(cx)
+ }
Ok(None) | Err(_) => cx
.spawn({
let app_state = app_state.clone();
@@ -264,8 +269,10 @@ fn main() {
while let Some(request) = open_rx.next().await {
match request {
OpenRequest::Paths { paths } => {
- cx.update(|cx| workspace::open_paths(&paths, &app_state, None, cx))
- .detach();
+ cx.update(|cx| {
+ workspace::open_paths(&paths, &app_state.clone(), None, cx)
+ })
+ .detach();
}
OpenRequest::CliConnection { connection } => {
cx.spawn(|cx| handle_cli_connection(connection, app_state.clone(), cx))
@@ -276,6 +283,16 @@ fn main() {
workspace::join_channel(channel_id, app_state.clone(), None, cx)
})
.detach(),
+ OpenRequest::OpenChannelNotes { channel_id } => {
+ let app_state = app_state.clone();
+ if let Ok(workspace) =
+ workspace::get_any_active_workspace(app_state, cx.clone()).await
+ {
+ cx.update(|cx| {
+ ChannelView::open(channel_id, workspace, cx).detach();
+ })
+ }
+ }
}
}
}
@@ -667,7 +684,7 @@ fn load_embedded_fonts(app: &App) {
#[cfg(debug_assertions)]
async fn watch_themes(fs: Arc<dyn Fs>, mut cx: AsyncAppContext) -> Option<()> {
let mut events = fs
- .watch("styles/src".as_ref(), Duration::from_millis(100))
+ .watch("styles/src".as_ref(), std::time::Duration::from_millis(100))
.await;
while (events.next().await).is_some() {
let output = Command::new("npm")
@@ -693,7 +710,7 @@ async fn watch_languages(fs: Arc<dyn Fs>, languages: Arc<LanguageRegistry>) -> O
let mut events = fs
.watch(
"crates/zed/src/languages".as_ref(),
- Duration::from_millis(100),
+ std::time::Duration::from_millis(100),
)
.await;
while (events.next().await).is_some() {
@@ -708,7 +725,7 @@ fn watch_file_types(fs: Arc<dyn Fs>, cx: &mut AppContext) {
let mut events = fs
.watch(
"assets/icons/file_icons/file_types.json".as_ref(),
- Duration::from_millis(100),
+ std::time::Duration::from_millis(100),
)
.await;
while (events.next().await).is_some() {
@@ -735,189 +752,6 @@ async fn watch_languages(_: Arc<dyn Fs>, _: Arc<LanguageRegistry>) -> Option<()>
#[cfg(not(debug_assertions))]
fn watch_file_types(_fs: Arc<dyn Fs>, _cx: &mut AppContext) {}
-fn connect_to_cli(
- server_name: &str,
-) -> Result<(mpsc::Receiver<CliRequest>, IpcSender<CliResponse>)> {
- let handshake_tx = cli::ipc::IpcSender::<IpcHandshake>::connect(server_name.to_string())
- .context("error connecting to cli")?;
- let (request_tx, request_rx) = ipc::channel::<CliRequest>()?;
- let (response_tx, response_rx) = ipc::channel::<CliResponse>()?;
-
- handshake_tx
- .send(IpcHandshake {
- requests: request_tx,
- responses: response_rx,
- })
- .context("error sending ipc handshake")?;
-
- let (mut async_request_tx, async_request_rx) =
- futures::channel::mpsc::channel::<CliRequest>(16);
- thread::spawn(move || {
- while let Ok(cli_request) = request_rx.recv() {
- if smol::block_on(async_request_tx.send(cli_request)).is_err() {
- break;
- }
- }
- Ok::<_, anyhow::Error>(())
- });
-
- Ok((async_request_rx, response_tx))
-}
-
-async fn handle_cli_connection(
- (mut requests, responses): (mpsc::Receiver<CliRequest>, IpcSender<CliResponse>),
- app_state: Arc<AppState>,
- mut cx: AsyncAppContext,
-) {
- if let Some(request) = requests.next().await {
- match request {
- CliRequest::Open { paths, wait } => {
- let mut caret_positions = HashMap::new();
-
- let paths = if paths.is_empty() {
- workspace::last_opened_workspace_paths()
- .await
- .map(|location| location.paths().to_vec())
- .unwrap_or_default()
- } else {
- paths
- .into_iter()
- .filter_map(|path_with_position_string| {
- let path_with_position = PathLikeWithPosition::parse_str(
- &path_with_position_string,
- |path_str| {
- Ok::<_, std::convert::Infallible>(
- Path::new(path_str).to_path_buf(),
- )
- },
- )
- .expect("Infallible");
- let path = path_with_position.path_like;
- if let Some(row) = path_with_position.row {
- if path.is_file() {
- let row = row.saturating_sub(1);
- let col =
- path_with_position.column.unwrap_or(0).saturating_sub(1);
- caret_positions.insert(path.clone(), Point::new(row, col));
- }
- }
- Some(path)
- })
- .collect()
- };
-
- let mut errored = false;
- match cx
- .update(|cx| workspace::open_paths(&paths, &app_state, None, cx))
- .await
- {
- Ok((workspace, items)) => {
- let mut item_release_futures = Vec::new();
-
- for (item, path) in items.into_iter().zip(&paths) {
- match item {
- Some(Ok(item)) => {
- if let Some(point) = caret_positions.remove(path) {
- if let Some(active_editor) = item.downcast::<Editor>() {
- active_editor
- .downgrade()
- .update(&mut cx, |editor, cx| {
- let snapshot =
- editor.snapshot(cx).display_snapshot;
- let point = snapshot
- .buffer_snapshot
- .clip_point(point, Bias::Left);
- editor.change_selections(
- Some(Autoscroll::center()),
- cx,
- |s| s.select_ranges([point..point]),
- );
- })
- .log_err();
- }
- }
-
- let released = oneshot::channel();
- cx.update(|cx| {
- item.on_release(
- cx,
- Box::new(move |_| {
- let _ = released.0.send(());
- }),
- )
- .detach();
- });
- item_release_futures.push(released.1);
- }
- Some(Err(err)) => {
- responses
- .send(CliResponse::Stderr {
- message: format!("error opening {:?}: {}", path, err),
- })
- .log_err();
- errored = true;
- }
- None => {}
- }
- }
-
- if wait {
- let background = cx.background();
- let wait = async move {
- if paths.is_empty() {
- let (done_tx, done_rx) = oneshot::channel();
- if let Some(workspace) = workspace.upgrade(&cx) {
- let _subscription = cx.update(|cx| {
- cx.observe_release(&workspace, move |_, _| {
- let _ = done_tx.send(());
- })
- });
- drop(workspace);
- let _ = done_rx.await;
- }
- } else {
- let _ =
- futures::future::try_join_all(item_release_futures).await;
- };
- }
- .fuse();
- futures::pin_mut!(wait);
-
- loop {
- // Repeatedly check if CLI is still open to avoid wasting resources
- // waiting for files or workspaces to close.
- let mut timer = background.timer(Duration::from_secs(1)).fuse();
- futures::select_biased! {
- _ = wait => break,
- _ = timer => {
- if responses.send(CliResponse::Ping).is_err() {
- break;
- }
- }
- }
- }
- }
- }
- Err(error) => {
- errored = true;
- responses
- .send(CliResponse::Stderr {
- message: format!("error opening {:?}: {}", paths, error),
- })
- .log_err();
- }
- }
-
- responses
- .send(CliResponse::Exit {
- status: i32::from(errored),
- })
- .log_err();
- }
- }
- }
-}
-
pub fn background_actions() -> &'static [(&'static str, &'static dyn Action)] {
&[
("Go to file", &file_finder::Toggle),
@@ -1,15 +1,26 @@
-use anyhow::anyhow;
+use anyhow::{anyhow, Context, Result};
+use cli::{ipc, IpcHandshake};
use cli::{ipc::IpcSender, CliRequest, CliResponse};
-use futures::channel::mpsc;
+use editor::scroll::autoscroll::Autoscroll;
+use editor::Editor;
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
+use futures::channel::{mpsc, oneshot};
+use futures::{FutureExt, SinkExt, StreamExt};
+use gpui::AsyncAppContext;
+use language::{Bias, Point};
+use std::collections::HashMap;
use std::ffi::OsStr;
use std::os::unix::prelude::OsStrExt;
+use std::path::Path;
use std::sync::atomic::Ordering;
+use std::sync::Arc;
+use std::thread;
+use std::time::Duration;
use std::{path::PathBuf, sync::atomic::AtomicBool};
use util::channel::parse_zed_link;
+use util::paths::PathLikeWithPosition;
use util::ResultExt;
-
-use crate::connect_to_cli;
+use workspace::AppState;
pub enum OpenRequest {
Paths {
@@ -21,6 +32,9 @@ pub enum OpenRequest {
JoinChannel {
channel_id: u64,
},
+ OpenChannelNotes {
+ channel_id: u64,
+ },
}
pub struct OpenListener {
@@ -74,7 +88,11 @@ impl OpenListener {
if let Some(slug) = parts.next() {
if let Some(id_str) = slug.split("-").last() {
if let Ok(channel_id) = id_str.parse::<u64>() {
- return Some(OpenRequest::JoinChannel { channel_id });
+ if Some("notes") == parts.next() {
+ return Some(OpenRequest::OpenChannelNotes { channel_id });
+ } else {
+ return Some(OpenRequest::JoinChannel { channel_id });
+ }
}
}
}
@@ -96,3 +114,186 @@ impl OpenListener {
Some(OpenRequest::Paths { paths })
}
}
+
+fn connect_to_cli(
+ server_name: &str,
+) -> Result<(mpsc::Receiver<CliRequest>, IpcSender<CliResponse>)> {
+ let handshake_tx = cli::ipc::IpcSender::<IpcHandshake>::connect(server_name.to_string())
+ .context("error connecting to cli")?;
+ let (request_tx, request_rx) = ipc::channel::<CliRequest>()?;
+ let (response_tx, response_rx) = ipc::channel::<CliResponse>()?;
+
+ handshake_tx
+ .send(IpcHandshake {
+ requests: request_tx,
+ responses: response_rx,
+ })
+ .context("error sending ipc handshake")?;
+
+ let (mut async_request_tx, async_request_rx) =
+ futures::channel::mpsc::channel::<CliRequest>(16);
+ thread::spawn(move || {
+ while let Ok(cli_request) = request_rx.recv() {
+ if smol::block_on(async_request_tx.send(cli_request)).is_err() {
+ break;
+ }
+ }
+ Ok::<_, anyhow::Error>(())
+ });
+
+ Ok((async_request_rx, response_tx))
+}
+
+pub async fn handle_cli_connection(
+ (mut requests, responses): (mpsc::Receiver<CliRequest>, IpcSender<CliResponse>),
+ app_state: Arc<AppState>,
+ mut cx: AsyncAppContext,
+) {
+ if let Some(request) = requests.next().await {
+ match request {
+ CliRequest::Open { paths, wait } => {
+ let mut caret_positions = HashMap::new();
+
+ let paths = if paths.is_empty() {
+ workspace::last_opened_workspace_paths()
+ .await
+ .map(|location| location.paths().to_vec())
+ .unwrap_or_default()
+ } else {
+ paths
+ .into_iter()
+ .filter_map(|path_with_position_string| {
+ let path_with_position = PathLikeWithPosition::parse_str(
+ &path_with_position_string,
+ |path_str| {
+ Ok::<_, std::convert::Infallible>(
+ Path::new(path_str).to_path_buf(),
+ )
+ },
+ )
+ .expect("Infallible");
+ let path = path_with_position.path_like;
+ if let Some(row) = path_with_position.row {
+ if path.is_file() {
+ let row = row.saturating_sub(1);
+ let col =
+ path_with_position.column.unwrap_or(0).saturating_sub(1);
+ caret_positions.insert(path.clone(), Point::new(row, col));
+ }
+ }
+ Some(path)
+ })
+ .collect()
+ };
+
+ let mut errored = false;
+ match cx
+ .update(|cx| workspace::open_paths(&paths, &app_state, None, cx))
+ .await
+ {
+ Ok((workspace, items)) => {
+ let mut item_release_futures = Vec::new();
+
+ for (item, path) in items.into_iter().zip(&paths) {
+ match item {
+ Some(Ok(item)) => {
+ if let Some(point) = caret_positions.remove(path) {
+ if let Some(active_editor) = item.downcast::<Editor>() {
+ active_editor
+ .downgrade()
+ .update(&mut cx, |editor, cx| {
+ let snapshot =
+ editor.snapshot(cx).display_snapshot;
+ let point = snapshot
+ .buffer_snapshot
+ .clip_point(point, Bias::Left);
+ editor.change_selections(
+ Some(Autoscroll::center()),
+ cx,
+ |s| s.select_ranges([point..point]),
+ );
+ })
+ .log_err();
+ }
+ }
+
+ let released = oneshot::channel();
+ cx.update(|cx| {
+ item.on_release(
+ cx,
+ Box::new(move |_| {
+ let _ = released.0.send(());
+ }),
+ )
+ .detach();
+ });
+ item_release_futures.push(released.1);
+ }
+ Some(Err(err)) => {
+ responses
+ .send(CliResponse::Stderr {
+ message: format!("error opening {:?}: {}", path, err),
+ })
+ .log_err();
+ errored = true;
+ }
+ None => {}
+ }
+ }
+
+ if wait {
+ let background = cx.background();
+ let wait = async move {
+ if paths.is_empty() {
+ let (done_tx, done_rx) = oneshot::channel();
+ if let Some(workspace) = workspace.upgrade(&cx) {
+ let _subscription = cx.update(|cx| {
+ cx.observe_release(&workspace, move |_, _| {
+ let _ = done_tx.send(());
+ })
+ });
+ drop(workspace);
+ let _ = done_rx.await;
+ }
+ } else {
+ let _ =
+ futures::future::try_join_all(item_release_futures).await;
+ };
+ }
+ .fuse();
+ futures::pin_mut!(wait);
+
+ loop {
+ // Repeatedly check if CLI is still open to avoid wasting resources
+ // waiting for files or workspaces to close.
+ let mut timer = background.timer(Duration::from_secs(1)).fuse();
+ futures::select_biased! {
+ _ = wait => break,
+ _ = timer => {
+ if responses.send(CliResponse::Ping).is_err() {
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+ Err(error) => {
+ errored = true;
+ responses
+ .send(CliResponse::Stderr {
+ message: format!("error opening {:?}: {}", paths, error),
+ })
+ .log_err();
+ }
+ }
+
+ responses
+ .send(CliResponse::Exit {
+ status: i32::from(errored),
+ })
+ .log_err();
+ }
+ }
+ }
+}
@@ -2,6 +2,7 @@ pub mod assets;
pub mod languages;
pub mod menus;
pub mod only_instance;
+pub mod open_listener;
#[cfg(any(test, feature = "test-support"))]
pub mod test;
@@ -28,6 +29,7 @@ use gpui::{
AppContext, AsyncAppContext, Task, ViewContext, WeakViewHandle,
};
pub use lsp;
+use open_listener::OpenListener;
pub use project;
use project_panel::ProjectPanel;
use quick_action_bar::QuickActionBar;
@@ -87,6 +89,10 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
},
);
cx.add_global_action(quit);
+ cx.add_global_action(move |action: &OpenZedURL, cx| {
+ cx.global::<Arc<OpenListener>>()
+ .open_urls(vec![action.url.clone()])
+ });
cx.add_global_action(move |action: &OpenBrowser, cx| cx.platform().open_url(&action.url));
cx.add_global_action(move |_: &IncreaseBufferFontSize, cx| {
theme::adjust_font_size(cx, |size| *size += 1.0)
@@ -221,6 +227,13 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
workspace.toggle_panel_focus::<collab_ui::chat_panel::ChatPanel>(cx);
},
);
+ cx.add_action(
+ |workspace: &mut Workspace,
+ _: &collab_ui::notification_panel::ToggleFocus,
+ cx: &mut ViewContext<Workspace>| {
+ workspace.toggle_panel_focus::<collab_ui::notification_panel::NotificationPanel>(cx);
+ },
+ );
cx.add_action(
|workspace: &mut Workspace,
_: &terminal_panel::ToggleFocus,
@@ -275,9 +288,8 @@ pub fn initialize_workspace(
QuickActionBar::new(buffer_search_bar, workspace)
});
toolbar.add_item(quick_action_bar, cx);
- let diagnostic_editor_controls = cx.add_view(|_| {
- diagnostics::ToolbarControls::new()
- });
+ let diagnostic_editor_controls =
+ cx.add_view(|_| diagnostics::ToolbarControls::new());
toolbar.add_item(diagnostic_editor_controls, cx);
let project_search_bar = cx.add_view(|_| ProjectSearchBar::new());
toolbar.add_item(project_search_bar, cx);
@@ -351,12 +363,24 @@ pub fn initialize_workspace(
collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone());
let chat_panel =
collab_ui::chat_panel::ChatPanel::load(workspace_handle.clone(), cx.clone());
- let (project_panel, terminal_panel, assistant_panel, channels_panel, chat_panel) = futures::try_join!(
+ let notification_panel = collab_ui::notification_panel::NotificationPanel::load(
+ workspace_handle.clone(),
+ cx.clone(),
+ );
+ let (
+ project_panel,
+ terminal_panel,
+ assistant_panel,
+ channels_panel,
+ chat_panel,
+ notification_panel,
+ ) = futures::try_join!(
project_panel,
terminal_panel,
assistant_panel,
channels_panel,
chat_panel,
+ notification_panel,
)?;
workspace_handle.update(&mut cx, |workspace, cx| {
let project_panel_position = project_panel.position(cx);
@@ -377,6 +401,7 @@ pub fn initialize_workspace(
workspace.add_panel(assistant_panel, cx);
workspace.add_panel(channels_panel, cx);
workspace.add_panel(chat_panel, cx);
+ workspace.add_panel(notification_panel, cx);
if !was_deserialized
&& workspace
@@ -2426,6 +2451,7 @@ mod tests {
audio::init((), cx);
channel::init(&app_state.client, app_state.user_store.clone(), cx);
call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
+ notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx);
workspace::init(app_state.clone(), cx);
Project::init_settings(cx);
language::init(cx);
@@ -134,6 +134,8 @@ else
cp -R target/${target_dir}/WebRTC.framework "${app_path}/Contents/Frameworks/"
fi
+cp crates/zed/contents/$channel/embedded.provisionprofile "${app_path}/Contents/"
+
if [[ -n $MACOS_CERTIFICATE && -n $MACOS_CERTIFICATE_PASSWORD && -n $APPLE_NOTARIZATION_USERNAME && -n $APPLE_NOTARIZATION_PASSWORD ]]; then
echo "Signing bundle with Apple-issued certificate"
security create-keychain -p "$MACOS_CERTIFICATE_PASSWORD" zed.keychain || echo ""
@@ -143,14 +145,33 @@ if [[ -n $MACOS_CERTIFICATE && -n $MACOS_CERTIFICATE_PASSWORD && -n $APPLE_NOTAR
security import /tmp/zed-certificate.p12 -k zed.keychain -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
rm /tmp/zed-certificate.p12
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CERTIFICATE_PASSWORD" zed.keychain
- /usr/bin/codesign --force --deep --timestamp --options runtime --entitlements crates/zed/resources/zed.entitlements --sign "Zed Industries, Inc." "${app_path}" -v
+
+ # sequence of codesign commands modeled after this example: https://developer.apple.com/forums/thread/701514
+ /usr/bin/codesign --deep --force --timestamp --sign "Zed Industries, Inc." "${app_path}/Contents/Frameworks/WebRTC.framework" -v
+ /usr/bin/codesign --deep --force --timestamp --options runtime --sign "Zed Industries, Inc." "${app_path}/Contents/MacOS/cli" -v
+ /usr/bin/codesign --deep --force --timestamp --options runtime --entitlements crates/zed/resources/zed.entitlements --sign "Zed Industries, Inc." "${app_path}/Contents/MacOS/zed" -v
+ /usr/bin/codesign --force --timestamp --options runtime --entitlements crates/zed/resources/zed.entitlements --sign "Zed Industries, Inc." "${app_path}" -v
+
security default-keychain -s login.keychain
else
echo "One or more of the following variables are missing: MACOS_CERTIFICATE, MACOS_CERTIFICATE_PASSWORD, APPLE_NOTARIZATION_USERNAME, APPLE_NOTARIZATION_PASSWORD"
- echo "Performing an ad-hoc signature, but this bundle should not be distributed"
- echo "If you see 'The application cannot be opened for an unexpected reason,' you likely don't have the necessary entitlements to run the application in your signing keychain"
- echo "You will need to download a new signing key from developer.apple.com, add it to keychain, and export MACOS_SIGNING_KEY=<email address of signing key>"
- codesign --force --deep --entitlements crates/zed/resources/zed.entitlements --sign ${MACOS_SIGNING_KEY:- -} "${app_path}" -v
+ if [[ "$local_only" = false ]]; then
+ echo "To create a self-signed local build use ./scripts/build.sh -ldf"
+ exit 1
+ fi
+
+ echo "====== WARNING ======"
+ echo "This bundle is being signed without all entitlements, some features (e.g. universal links) will not work"
+ echo "====== WARNING ======"
+
+ # NOTE: if you need to test universal links you have a few paths forward:
+ # - create a PR and tag it with the `run-build-dmg` label, and download the .dmg file from there.
+ # - get a signing key for the MQ55VZLNZQ team from Nathan.
+ # - create your own signing key, and update references to MQ55VZLNZQ to your own team ID
+ # then comment out this line.
+ cat crates/zed/resources/zed.entitlements | sed '/com.apple.developer.associated-domains/,+1d' > "${app_path}/Contents/Resources/zed.entitlements"
+
+ codesign --force --deep --entitlements "${app_path}/Contents/Resources/zed.entitlements" --sign ${MACOS_SIGNING_KEY:- -} "${app_path}" -v
fi
if [[ "$target_dir" = "debug" && "$local_only" = false ]]; then
@@ -1,3 +1,3 @@
#!/bin/bash
-RUST_LOG=semantic_index=trace cargo run -p semantic_index --example eval --release
+RUST_LOG=semantic_index=trace cargo run --example semantic_index_eval --release
@@ -55,6 +55,8 @@ let users = [
'iamnbutler'
]
+const RUST_LOG = process.env.RUST_LOG || 'info'
+
// If a user is specified, make sure it's first in the list
const user = process.env.ZED_IMPERSONATE
if (user) {
@@ -81,7 +83,9 @@ setTimeout(() => {
ZED_ALWAYS_ACTIVE: '1',
ZED_SERVER_URL: 'http://localhost:8080',
ZED_ADMIN_API_TOKEN: 'secret',
- ZED_WINDOW_SIZE: `${instanceWidth},${instanceHeight}`
+ ZED_WINDOW_SIZE: `${instanceWidth},${instanceHeight}`,
+ PATH: process.env.PATH,
+ RUST_LOG,
}
})
}
@@ -13,6 +13,7 @@ import project_shared_notification from "./project_shared_notification"
import tooltip from "./tooltip"
import terminal from "./terminal"
import chat_panel from "./chat_panel"
+import notification_panel from "./notification_panel"
import collab_panel from "./collab_panel"
import toolbar_dropdown_menu from "./toolbar_dropdown_menu"
import incoming_call_notification from "./incoming_call_notification"
@@ -57,6 +58,7 @@ export default function app(): any {
assistant: assistant(),
feedback: feedback(),
chat_panel: chat_panel(),
+ notification_panel: notification_panel(),
component_test: component_test(),
}
}
@@ -79,6 +79,80 @@ export default function assistant(): any {
},
},
pending_edit_background: background(theme.highest, "positive"),
+ context_status: {
+ error_icon: {
+ margin: { left: 8, right: 18 },
+ color: foreground(theme.highest, "negative"),
+ width: 12,
+ },
+ in_progress_icon: {
+ margin: { left: 8, right: 18 },
+ color: foreground(theme.highest, "positive"),
+ width: 12,
+ },
+ complete_icon: {
+ margin: { left: 8, right: 18 },
+ color: foreground(theme.highest, "positive"),
+ width: 12,
+ }
+ },
+ retrieve_context: toggleable({
+ base: interactive({
+ base: {
+ icon_size: 12,
+ color: foreground(theme.highest, "variant"),
+
+ button_width: 12,
+ background: background(theme.highest, "on"),
+ corner_radius: 2,
+ border: {
+ width: 1., color: background(theme.highest, "on")
+ },
+ margin: { left: 2 },
+ padding: {
+ left: 4,
+ right: 4,
+ top: 4,
+ bottom: 4,
+ },
+ },
+ state: {
+ hovered: {
+ ...text(theme.highest, "mono", "variant", "hovered"),
+ background: background(theme.highest, "on", "hovered"),
+ border: {
+ width: 1., color: background(theme.highest, "on", "hovered")
+ },
+ },
+ clicked: {
+ ...text(theme.highest, "mono", "variant", "pressed"),
+ background: background(theme.highest, "on", "pressed"),
+ border: {
+ width: 1., color: background(theme.highest, "on", "pressed")
+ },
+ },
+ },
+ }),
+ state: {
+ active: {
+ default: {
+ icon_size: 12,
+ button_width: 12,
+ color: foreground(theme.highest, "variant"),
+ background: background(theme.highest, "accent"),
+ border: border(theme.highest, "accent"),
+ },
+ hovered: {
+ background: background(theme.highest, "accent", "hovered"),
+ border: border(theme.highest, "accent", "hovered"),
+ },
+ clicked: {
+ background: background(theme.highest, "accent", "pressed"),
+ border: border(theme.highest, "accent", "pressed"),
+ },
+ },
+ },
+ }),
include_conversation: toggleable({
base: interactive({
base: {
@@ -1,10 +1,6 @@
-import {
- background,
- border,
- text,
-} from "./components"
+import { background, border, foreground, text } from "./components"
import { icon_button } from "../component/icon_button"
-import { useTheme } from "../theme"
+import { useTheme, with_opacity } from "../theme"
import { interactive } from "../element"
export default function chat_panel(): any {
@@ -41,15 +37,13 @@ export default function chat_panel(): any {
left: 2,
top: 2,
bottom: 2,
- }
- },
- list: {
-
+ },
},
+ list: {},
channel_select: {
header: {
...channel_name,
- border: border(layer, { bottom: true })
+ border: border(layer, { bottom: true }),
},
item: channel_name,
active_item: {
@@ -62,8 +56,8 @@ export default function chat_panel(): any {
},
menu: {
background: background(layer, "on"),
- border: border(layer, { bottom: true })
- }
+ border: border(layer, { bottom: true }),
+ },
},
icon_button: icon_button({
variant: "ghost",
@@ -91,6 +85,21 @@ export default function chat_panel(): any {
top: 4,
},
},
+
+ rich_text: {
+ text: text(layer, "sans", "base"),
+ code_background: with_opacity(foreground(layer, "accent"), 0.1),
+ mention_highlight: { weight: "bold" },
+ self_mention_highlight: { weight: "bold" },
+ self_mention_background: background(layer, "active"),
+ },
+ message_sender: {
+ margin: {
+ right: 8,
+ },
+ ...text(layer, "sans", "base", { weight: "bold" }),
+ },
+ message_timestamp: text(layer, "sans", "base", "disabled"),
message: {
...interactive({
base: {
@@ -100,7 +109,7 @@ export default function chat_panel(): any {
bottom: 4,
left: SPACING / 2,
right: SPACING / 3,
- }
+ },
},
state: {
hovered: {
@@ -108,25 +117,9 @@ export default function chat_panel(): any {
},
},
}),
- body: text(layer, "sans", "base"),
- sender: {
- margin: {
- right: 8,
- },
- ...text(layer, "sans", "base", { weight: "bold" }),
- },
- timestamp: text(layer, "sans", "base", "disabled"),
},
last_message_bottom_spacing: SPACING,
continuation_message: {
- body: text(layer, "sans", "base"),
- sender: {
- margin: {
- right: 8,
- },
- ...text(layer, "sans", "base", { weight: "bold" }),
- },
- timestamp: text(layer, "sans", "base", "disabled"),
...interactive({
base: {
padding: {
@@ -134,7 +127,7 @@ export default function chat_panel(): any {
bottom: 4,
left: SPACING / 2,
right: SPACING / 3,
- }
+ },
},
state: {
hovered: {
@@ -144,14 +137,6 @@ export default function chat_panel(): any {
}),
},
pending_message: {
- body: text(layer, "sans", "base"),
- sender: {
- margin: {
- right: 8,
- },
- ...text(layer, "sans", "base", "disabled"),
- },
- timestamp: text(layer, "sans", "base"),
...interactive({
base: {
padding: {
@@ -159,7 +144,7 @@ export default function chat_panel(): any {
bottom: 4,
left: SPACING / 2,
right: SPACING / 3,
- }
+ },
},
state: {
hovered: {
@@ -170,6 +155,6 @@ export default function chat_panel(): any {
},
sign_in_prompt: {
default: text(layer, "sans", "base"),
- }
+ },
}
}
@@ -1,10 +1,11 @@
-import { useTheme } from "../theme"
+import { StyleSets, useTheme } from "../theme"
import { background, border, foreground, text } from "./components"
import picker from "./picker"
import { input } from "../component/input"
import contact_finder from "./contact_finder"
import { tab } from "../component/tab"
import { icon_button } from "../component/icon_button"
+import { interactive } from "../element/interactive"
export default function channel_modal(): any {
const theme = useTheme()
@@ -27,6 +28,24 @@ export default function channel_modal(): any {
const picker_input = input()
+ const interactive_text = (styleset: StyleSets) =>
+ interactive({
+ base: {
+ padding: {
+ left: 8,
+ top: 8
+ },
+ ...text(theme.middle, "sans", styleset, "default"),
+ }, state: {
+ hovered: {
+ ...text(theme.middle, "sans", styleset, "hovered"),
+ },
+ clicked: {
+ ...text(theme.middle, "sans", styleset, "active"),
+ }
+ }
+ })
+
const member_icon_style = icon_button({
variant: "ghost",
size: "sm",
@@ -88,6 +107,8 @@ export default function channel_modal(): any {
left: BUTTON_OFFSET,
},
},
+ visibility_toggle: interactive_text("base"),
+ channel_link: interactive_text("accent"),
picker: {
empty_container: {},
item: {
@@ -210,6 +210,14 @@ export default function contacts_panel(): any {
right: SPACING,
},
},
+ dragged_over_header: {
+ margin: { top: SPACING },
+ padding: {
+ left: SPACING,
+ right: SPACING,
+ },
+ background: background(layer, "hovered"),
+ },
subheader_row,
leave_call: interactive({
base: {
@@ -279,7 +287,7 @@ export default function contacts_panel(): any {
margin: {
left: CHANNEL_SPACING,
},
- }
+ },
},
list_empty_label_container: {
margin: {
@@ -206,9 +206,13 @@ export default function editor(): any {
match_highlight: foreground(theme.middle, "accent", "active"),
background: background(theme.middle, "active"),
},
- server_name_container: { padding: { left: 40 } },
- server_name_color: text(theme.middle, "sans", "disabled", {}).color,
- server_name_size_percent: 0.75,
+ completion_min_width: 300,
+ completion_max_width: 700,
+ inline_docs_container: { padding: { left: 40 } },
+ inline_docs_color: text(theme.middle, "sans", "disabled", {}).color,
+ inline_docs_size_percent: 0.75,
+ alongside_docs_max_width: 700,
+ alongside_docs_container: { padding: autocomplete_item.padding }
},
diagnostic_header: {
background: background(theme.middle),
@@ -0,0 +1,75 @@
+import { background, border, text } from "./components"
+import { icon_button } from "../component/icon_button"
+import { useTheme, with_opacity } from "../theme"
+import { text_button } from "../component"
+
+export default function (): any {
+ const theme = useTheme()
+ const layer = theme.middle
+
+ const notification_text = {
+ padding: { top: 4, bottom: 4 },
+ ...text(layer, "sans", "base"),
+ }
+
+ const notification_read_text_color = with_opacity(
+ theme.middle.base.default.foreground,
+ 0.6
+ )
+
+ return {
+ background: background(layer),
+ avatar: {
+ icon_width: 24,
+ icon_height: 24,
+ corner_radius: 12,
+ outer_width: 24,
+ outer_corner_radius: 24,
+ },
+ title: {
+ ...text(layer, "sans", "default"),
+ padding: { left: 8, right: 8 },
+ border: border(layer, { bottom: true }),
+ },
+ title_height: 32,
+ title_icon: {
+ asset: "icons/feedback.svg",
+ color: text(theme.lowest, "sans", "default").color,
+ dimensions: {
+ width: 16,
+ height: 16,
+ },
+ },
+ read_text: {
+ ...notification_text,
+ color: notification_read_text_color,
+ },
+ unread_text: notification_text,
+ button: text_button({
+ variant: "ghost",
+ }),
+ timestamp: text(layer, "sans", "base", "disabled"),
+ avatar_container: {
+ padding: {
+ right: 8,
+ left: 2,
+ top: 4,
+ bottom: 2,
+ },
+ },
+ list: {
+ padding: {
+ left: 8,
+ right: 8,
+ },
+ },
+ icon_button: icon_button({
+ variant: "ghost",
+ color: "variant",
+ size: "sm",
+ }),
+ sign_in_prompt: {
+ default: text(layer, "sans", "base"),
+ },
+ }
+}
@@ -2,7 +2,6 @@ import { with_opacity } from "../theme/color"
import { background, border, foreground, text } from "./components"
import { interactive, toggleable } from "../element"
import { useTheme } from "../theme"
-import { text_button } from "../component/text_button"
const search_results = () => {
const theme = useTheme()
@@ -36,7 +35,7 @@ export default function search(): any {
left: 10,
right: 4,
},
- margin: { right: SEARCH_ROW_SPACING }
+ margin: { right: SEARCH_ROW_SPACING },
}
const include_exclude_editor = {
@@ -378,7 +377,7 @@ export default function search(): any {
modes_container: {
padding: {
right: SEARCH_ROW_SPACING,
- }
+ },
},
replace_icon: {
icon: {