Detailed changes
@@ -14,7 +14,7 @@ body:
### Description
<!-- Describe with sufficient detail to reproduce from a clean Zed install.
- Any code must be sufficient to reproduce (include context!)
- - Code must as text, not just as a screenshot.
+ - Include code as text, not just as a screenshot.
- Issues with insufficient detail may be summarily closed.
-->
@@ -191,9 +191,9 @@ dependencies = [
[[package]]
name = "agent-client-protocol"
-version = "0.0.31"
+version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "289eb34ee17213dadcca47eedadd386a5e7678094095414e475965d1bcca2860"
+checksum = "6b91e5ec3ce05e8effb2a7a3b7b1a587daa6699b9f98bbde6a35e44b8c6c773a"
dependencies = [
"anyhow",
"async-broadcast",
@@ -292,23 +292,21 @@ dependencies = [
"anyhow",
"client",
"collections",
- "context_server",
"env_logger 0.11.8",
"fs",
"futures 0.3.31",
"gpui",
"gpui_tokio",
"indoc",
- "itertools 0.14.0",
"language",
"language_model",
"language_models",
"libc",
"log",
"nix 0.29.0",
+ "node_runtime",
"paths",
"project",
- "rand 0.8.5",
"reqwest_client",
"schemars",
"semver",
@@ -316,12 +314,10 @@ dependencies = [
"serde_json",
"settings",
"smol",
- "strum 0.27.1",
"tempfile",
"thiserror 2.0.12",
"ui",
"util",
- "uuid",
"watch",
"which 6.0.3",
"workspace-hack",
@@ -9213,6 +9209,7 @@ dependencies = [
"language",
"lsp",
"project",
+ "proto",
"release_channel",
"serde_json",
"settings",
@@ -17185,8 +17182,7 @@ dependencies = [
[[package]]
name = "tree-sitter-cpp"
version = "0.23.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743"
+source = "git+https://github.com/tree-sitter/tree-sitter-cpp?rev=5cb9b693cfd7bfacab1d9ff4acac1a4150700609#5cb9b693cfd7bfacab1d9ff4acac1a4150700609"
dependencies = [
"cc",
"tree-sitter-language",
@@ -20396,7 +20392,7 @@ dependencies = [
[[package]]
name = "zed"
-version = "0.202.0"
+version = "0.203.0"
dependencies = [
"acp_tools",
"activity_indicator",
@@ -20590,7 +20586,7 @@ dependencies = [
[[package]]
name = "zed_html"
-version = "0.2.1"
+version = "0.2.2"
dependencies = [
"zed_extension_api 0.1.0",
]
@@ -426,7 +426,7 @@ zlog_settings = { path = "crates/zlog_settings" }
# External crates
#
-agent-client-protocol = "0.0.31"
+agent-client-protocol = "0.1"
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
any_vec = "0.14"
@@ -624,7 +624,7 @@ tower-http = "0.4.4"
tree-sitter = { version = "0.25.6", features = ["wasm"] }
tree-sitter-bash = "0.25.0"
tree-sitter-c = "0.23"
-tree-sitter-cpp = "0.23"
+tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" }
tree-sitter-css = "0.23"
tree-sitter-diff = "0.1.0"
tree-sitter-elixir = "0.3"
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-filter-icon lucide-list-filter"><path d="M3 6h18"/><path d="M7 12h10"/><path d="M10 18h4"/></svg>
@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 12.375H13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M3 11.125L6.75003 7.375L3 3.62497" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,1257 @@
+<svg width="515" height="126" viewBox="0 0 515 126" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_2906_6463)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 0.390625H0.390625V12.1094H12.1094V0.390625ZM0 0V12.5H12.5V0H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 0.390625H12.8906V12.1094H24.6094V0.390625ZM12.5 0V12.5H25V0H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 0.390625H25.3906V12.1094H37.1094V0.390625ZM25 0V12.5H37.5V0H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 0.390625H37.8906V12.1094H49.6094V0.390625ZM37.5 0V12.5H50V0H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 0.390625H50.3906V12.1094H62.1094V0.390625ZM50 0V12.5H62.5V0H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 0.390625H62.8906V12.1094H74.6094V0.390625ZM62.5 0V12.5H75V0H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 0.390625H75.3906V12.1094H87.1094V0.390625ZM75 0V12.5H87.5V0H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 0.390625H87.8906V12.1094H99.6094V0.390625ZM87.5 0V12.5H100V0H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 0.390625H100.391V12.1094H112.109V0.390625ZM100 0V12.5H112.5V0H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 0.390625H112.891V12.1094H124.609V0.390625ZM112.5 0V12.5H125V0H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 0.390625H125.391V12.1094H137.109V0.390625ZM125 0V12.5H137.5V0H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 0.390625H137.891V12.1094H149.609V0.390625ZM137.5 0V12.5H150V0H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 0.390625H150.391V12.1094H162.109V0.390625ZM150 0V12.5H162.5V0H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 0.390625H162.891V12.1094H174.609V0.390625ZM162.5 0V12.5H175V0H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 0.390625H175.391V12.1094H187.109V0.390625ZM175 0V12.5H187.5V0H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 0.390625H187.891V12.1094H199.609V0.390625ZM187.5 0V12.5H200V0H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 0.390625H200.391V12.1094H212.109V0.390625ZM200 0V12.5H212.5V0H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 0.390625H212.891V12.1094H224.609V0.390625ZM212.5 0V12.5H225V0H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 0.390625H225.391V12.1094H237.109V0.390625ZM225 0V12.5H237.5V0H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 0.390625H237.891V12.1094H249.609V0.390625ZM237.5 0V12.5H250V0H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 0.390625H250.391V12.1094H262.109V0.390625ZM250 0V12.5H262.5V0H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 0.390625H262.891V12.1094H274.609V0.390625ZM262.5 0V12.5H275V0H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 0.390625H275.391V12.1094H287.109V0.390625ZM275 0V12.5H287.5V0H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 0.390625H287.891V12.1094H299.609V0.390625ZM287.5 0V12.5H300V0H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 0.390625H300.391V12.1094H312.109V0.390625ZM300 0V12.5H312.5V0H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 0.390625H312.891V12.1094H324.609V0.390625ZM312.5 0V12.5H325V0H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 0.390625H325.391V12.1094H337.109V0.390625ZM325 0V12.5H337.5V0H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 0.390625H337.891V12.1094H349.609V0.390625ZM337.5 0V12.5H350V0H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 0.390625H350.391V12.1094H362.109V0.390625ZM350 0V12.5H362.5V0H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 0.390625H362.891V12.1094H374.609V0.390625ZM362.5 0V12.5H375V0H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 0.390625H375.391V12.1094H387.109V0.390625ZM375 0V12.5H387.5V0H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 0.390625H387.891V12.1094H399.609V0.390625ZM387.5 0V12.5H400V0H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 0.390625H400.391V12.1094H412.109V0.390625ZM400 0V12.5H412.5V0H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 0.390625H412.891V12.1094H424.609V0.390625ZM412.5 0V12.5H425V0H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 0.390625H425.391V12.1094H437.109V0.390625ZM425 0V12.5H437.5V0H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 0.390625H437.891V12.1094H449.609V0.390625ZM437.5 0V12.5H450V0H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 0.390625H450.391V12.1094H462.109V0.390625ZM450 0V12.5H462.5V0H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 0.390625H462.891V12.1094H474.609V0.390625ZM462.5 0V12.5H475V0H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 0.390625H475.391V12.1094H487.109V0.390625ZM475 0V12.5H487.5V0H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 0.390625H487.891V12.1094H499.609V0.390625ZM487.5 0V12.5H500V0H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 0.390625H500.391V12.1094H512.109V0.390625ZM500 0V12.5H512.5V0H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 0.390625H512.891V12.1094H524.609V0.390625ZM512.5 0V12.5H525V0H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 0.390625H525.391V12.1094H537.109V0.390625ZM525 0V12.5H537.5V0H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 0.390625H537.891V12.1094H549.609V0.390625ZM537.5 0V12.5H550V0H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 0.390625H550.391V12.1094H562.109V0.390625ZM550 0V12.5H562.5V0H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 0.390625H562.891V12.1094H574.609V0.390625ZM562.5 0V12.5H575V0H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 0.390625H575.391V12.1094H587.109V0.390625ZM575 0V12.5H587.5V0H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 0.390625H587.891V12.1094H599.609V0.390625ZM587.5 0V12.5H600V0H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 12.8906H0.390625V24.6094H12.1094V12.8906ZM0 12.5V25H12.5V12.5H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 12.8906H12.8906V24.6094H24.6094V12.8906ZM12.5 12.5V25H25V12.5H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 12.8906H25.3906V24.6094H37.1094V12.8906ZM25 12.5V25H37.5V12.5H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 12.8906H37.8906V24.6094H49.6094V12.8906ZM37.5 12.5V25H50V12.5H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 12.8906H50.3906V24.6094H62.1094V12.8906ZM50 12.5V25H62.5V12.5H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 12.8906H62.8906V24.6094H74.6094V12.8906ZM62.5 12.5V25H75V12.5H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 12.8906H75.3906V24.6094H87.1094V12.8906ZM75 12.5V25H87.5V12.5H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 12.8906H87.8906V24.6094H99.6094V12.8906ZM87.5 12.5V25H100V12.5H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 12.8906H100.391V24.6094H112.109V12.8906ZM100 12.5V25H112.5V12.5H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 12.8906H112.891V24.6094H124.609V12.8906ZM112.5 12.5V25H125V12.5H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 12.8906H125.391V24.6094H137.109V12.8906ZM125 12.5V25H137.5V12.5H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 12.8906H137.891V24.6094H149.609V12.8906ZM137.5 12.5V25H150V12.5H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 12.8906H150.391V24.6094H162.109V12.8906ZM150 12.5V25H162.5V12.5H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 12.8906H162.891V24.6094H174.609V12.8906ZM162.5 12.5V25H175V12.5H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 12.8906H175.391V24.6094H187.109V12.8906ZM175 12.5V25H187.5V12.5H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 12.8906H187.891V24.6094H199.609V12.8906ZM187.5 12.5V25H200V12.5H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 12.8906H200.391V24.6094H212.109V12.8906ZM200 12.5V25H212.5V12.5H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 12.8906H212.891V24.6094H224.609V12.8906ZM212.5 12.5V25H225V12.5H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 12.8906H225.391V24.6094H237.109V12.8906ZM225 12.5V25H237.5V12.5H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 12.8906H237.891V24.6094H249.609V12.8906ZM237.5 12.5V25H250V12.5H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 12.8906H250.391V24.6094H262.109V12.8906ZM250 12.5V25H262.5V12.5H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 12.8906H262.891V24.6094H274.609V12.8906ZM262.5 12.5V25H275V12.5H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 12.8906H275.391V24.6094H287.109V12.8906ZM275 12.5V25H287.5V12.5H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 12.8906H287.891V24.6094H299.609V12.8906ZM287.5 12.5V25H300V12.5H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 12.8906H300.391V24.6094H312.109V12.8906ZM300 12.5V25H312.5V12.5H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 12.8906H312.891V24.6094H324.609V12.8906ZM312.5 12.5V25H325V12.5H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 12.8906H325.391V24.6094H337.109V12.8906ZM325 12.5V25H337.5V12.5H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 12.8906H337.891V24.6094H349.609V12.8906ZM337.5 12.5V25H350V12.5H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 12.8906H350.391V24.6094H362.109V12.8906ZM350 12.5V25H362.5V12.5H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 12.8906H362.891V24.6094H374.609V12.8906ZM362.5 12.5V25H375V12.5H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 12.8906H375.391V24.6094H387.109V12.8906ZM375 12.5V25H387.5V12.5H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 12.8906H387.891V24.6094H399.609V12.8906ZM387.5 12.5V25H400V12.5H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 12.8906H400.391V24.6094H412.109V12.8906ZM400 12.5V25H412.5V12.5H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 12.8906H412.891V24.6094H424.609V12.8906ZM412.5 12.5V25H425V12.5H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 12.8906H425.391V24.6094H437.109V12.8906ZM425 12.5V25H437.5V12.5H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 12.8906H437.891V24.6094H449.609V12.8906ZM437.5 12.5V25H450V12.5H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 12.8906H450.391V24.6094H462.109V12.8906ZM450 12.5V25H462.5V12.5H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 12.8906H462.891V24.6094H474.609V12.8906ZM462.5 12.5V25H475V12.5H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 12.8906H475.391V24.6094H487.109V12.8906ZM475 12.5V25H487.5V12.5H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 12.8906H487.891V24.6094H499.609V12.8906ZM487.5 12.5V25H500V12.5H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 12.8906H500.391V24.6094H512.109V12.8906ZM500 12.5V25H512.5V12.5H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 12.8906H512.891V24.6094H524.609V12.8906ZM512.5 12.5V25H525V12.5H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 12.8906H525.391V24.6094H537.109V12.8906ZM525 12.5V25H537.5V12.5H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 12.8906H537.891V24.6094H549.609V12.8906ZM537.5 12.5V25H550V12.5H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 12.8906H550.391V24.6094H562.109V12.8906ZM550 12.5V25H562.5V12.5H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 12.8906H562.891V24.6094H574.609V12.8906ZM562.5 12.5V25H575V12.5H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 12.8906H575.391V24.6094H587.109V12.8906ZM575 12.5V25H587.5V12.5H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 12.8906H587.891V24.6094H599.609V12.8906ZM587.5 12.5V25H600V12.5H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 25.3906H0.390625V37.1094H12.1094V25.3906ZM0 25V37.5H12.5V25H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 25.3906H12.8906V37.1094H24.6094V25.3906ZM12.5 25V37.5H25V25H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 25.3906H25.3906V37.1094H37.1094V25.3906ZM25 25V37.5H37.5V25H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 25.3906H37.8906V37.1094H49.6094V25.3906ZM37.5 25V37.5H50V25H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 25.3906H50.3906V37.1094H62.1094V25.3906ZM50 25V37.5H62.5V25H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 25.3906H62.8906V37.1094H74.6094V25.3906ZM62.5 25V37.5H75V25H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 25.3906H75.3906V37.1094H87.1094V25.3906ZM75 25V37.5H87.5V25H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 25.3906H87.8906V37.1094H99.6094V25.3906ZM87.5 25V37.5H100V25H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 25.3906H100.391V37.1094H112.109V25.3906ZM100 25V37.5H112.5V25H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 25.3906H112.891V37.1094H124.609V25.3906ZM112.5 25V37.5H125V25H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 25.3906H125.391V37.1094H137.109V25.3906ZM125 25V37.5H137.5V25H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 25.3906H137.891V37.1094H149.609V25.3906ZM137.5 25V37.5H150V25H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 25.3906H150.391V37.1094H162.109V25.3906ZM150 25V37.5H162.5V25H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 25.3906H162.891V37.1094H174.609V25.3906ZM162.5 25V37.5H175V25H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 25.3906H175.391V37.1094H187.109V25.3906ZM175 25V37.5H187.5V25H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 25.3906H187.891V37.1094H199.609V25.3906ZM187.5 25V37.5H200V25H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 25.3906H200.391V37.1094H212.109V25.3906ZM200 25V37.5H212.5V25H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 25.3906H212.891V37.1094H224.609V25.3906ZM212.5 25V37.5H225V25H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 25.3906H225.391V37.1094H237.109V25.3906ZM225 25V37.5H237.5V25H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 25.3906H237.891V37.1094H249.609V25.3906ZM237.5 25V37.5H250V25H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 25.3906H250.391V37.1094H262.109V25.3906ZM250 25V37.5H262.5V25H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 25.3906H262.891V37.1094H274.609V25.3906ZM262.5 25V37.5H275V25H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 25.3906H275.391V37.1094H287.109V25.3906ZM275 25V37.5H287.5V25H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 25.3906H287.891V37.1094H299.609V25.3906ZM287.5 25V37.5H300V25H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 25.3906H300.391V37.1094H312.109V25.3906ZM300 25V37.5H312.5V25H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 25.3906H312.891V37.1094H324.609V25.3906ZM312.5 25V37.5H325V25H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 25.3906H325.391V37.1094H337.109V25.3906ZM325 25V37.5H337.5V25H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 25.3906H337.891V37.1094H349.609V25.3906ZM337.5 25V37.5H350V25H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 25.3906H350.391V37.1094H362.109V25.3906ZM350 25V37.5H362.5V25H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 25.3906H362.891V37.1094H374.609V25.3906ZM362.5 25V37.5H375V25H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 25.3906H375.391V37.1094H387.109V25.3906ZM375 25V37.5H387.5V25H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 25.3906H387.891V37.1094H399.609V25.3906ZM387.5 25V37.5H400V25H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 25.3906H400.391V37.1094H412.109V25.3906ZM400 25V37.5H412.5V25H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 25.3906H412.891V37.1094H424.609V25.3906ZM412.5 25V37.5H425V25H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 25.3906H425.391V37.1094H437.109V25.3906ZM425 25V37.5H437.5V25H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 25.3906H437.891V37.1094H449.609V25.3906ZM437.5 25V37.5H450V25H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 25.3906H450.391V37.1094H462.109V25.3906ZM450 25V37.5H462.5V25H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 25.3906H462.891V37.1094H474.609V25.3906ZM462.5 25V37.5H475V25H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 25.3906H475.391V37.1094H487.109V25.3906ZM475 25V37.5H487.5V25H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 25.3906H487.891V37.1094H499.609V25.3906ZM487.5 25V37.5H500V25H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 25.3906H500.391V37.1094H512.109V25.3906ZM500 25V37.5H512.5V25H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 25.3906H512.891V37.1094H524.609V25.3906ZM512.5 25V37.5H525V25H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 25.3906H525.391V37.1094H537.109V25.3906ZM525 25V37.5H537.5V25H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 25.3906H537.891V37.1094H549.609V25.3906ZM537.5 25V37.5H550V25H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 25.3906H550.391V37.1094H562.109V25.3906ZM550 25V37.5H562.5V25H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 25.3906H562.891V37.1094H574.609V25.3906ZM562.5 25V37.5H575V25H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 25.3906H575.391V37.1094H587.109V25.3906ZM575 25V37.5H587.5V25H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 25.3906H587.891V37.1094H599.609V25.3906ZM587.5 25V37.5H600V25H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 37.8906H0.390625V49.6094H12.1094V37.8906ZM0 37.5V50H12.5V37.5H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 37.8906H12.8906V49.6094H24.6094V37.8906ZM12.5 37.5V50H25V37.5H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 37.8906H25.3906V49.6094H37.1094V37.8906ZM25 37.5V50H37.5V37.5H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 37.8906H37.8906V49.6094H49.6094V37.8906ZM37.5 37.5V50H50V37.5H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 37.8906H50.3906V49.6094H62.1094V37.8906ZM50 37.5V50H62.5V37.5H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 37.8906H62.8906V49.6094H74.6094V37.8906ZM62.5 37.5V50H75V37.5H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 37.8906H75.3906V49.6094H87.1094V37.8906ZM75 37.5V50H87.5V37.5H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 37.8906H87.8906V49.6094H99.6094V37.8906ZM87.5 37.5V50H100V37.5H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 37.8906H100.391V49.6094H112.109V37.8906ZM100 37.5V50H112.5V37.5H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 37.8906H112.891V49.6094H124.609V37.8906ZM112.5 37.5V50H125V37.5H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 37.8906H125.391V49.6094H137.109V37.8906ZM125 37.5V50H137.5V37.5H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 37.8906H137.891V49.6094H149.609V37.8906ZM137.5 37.5V50H150V37.5H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 37.8906H150.391V49.6094H162.109V37.8906ZM150 37.5V50H162.5V37.5H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 37.8906H162.891V49.6094H174.609V37.8906ZM162.5 37.5V50H175V37.5H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 37.8906H175.391V49.6094H187.109V37.8906ZM175 37.5V50H187.5V37.5H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 37.8906H187.891V49.6094H199.609V37.8906ZM187.5 37.5V50H200V37.5H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 37.8906H200.391V49.6094H212.109V37.8906ZM200 37.5V50H212.5V37.5H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 37.8906H212.891V49.6094H224.609V37.8906ZM212.5 37.5V50H225V37.5H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 37.8906H225.391V49.6094H237.109V37.8906ZM225 37.5V50H237.5V37.5H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 37.8906H237.891V49.6094H249.609V37.8906ZM237.5 37.5V50H250V37.5H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 37.8906H250.391V49.6094H262.109V37.8906ZM250 37.5V50H262.5V37.5H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 37.8906H262.891V49.6094H274.609V37.8906ZM262.5 37.5V50H275V37.5H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 37.8906H275.391V49.6094H287.109V37.8906ZM275 37.5V50H287.5V37.5H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 37.8906H287.891V49.6094H299.609V37.8906ZM287.5 37.5V50H300V37.5H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 37.8906H300.391V49.6094H312.109V37.8906ZM300 37.5V50H312.5V37.5H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 37.8906H312.891V49.6094H324.609V37.8906ZM312.5 37.5V50H325V37.5H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 37.8906H325.391V49.6094H337.109V37.8906ZM325 37.5V50H337.5V37.5H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 37.8906H337.891V49.6094H349.609V37.8906ZM337.5 37.5V50H350V37.5H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 37.8906H350.391V49.6094H362.109V37.8906ZM350 37.5V50H362.5V37.5H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 37.8906H362.891V49.6094H374.609V37.8906ZM362.5 37.5V50H375V37.5H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 37.8906H375.391V49.6094H387.109V37.8906ZM375 37.5V50H387.5V37.5H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 37.8906H387.891V49.6094H399.609V37.8906ZM387.5 37.5V50H400V37.5H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 37.8906H400.391V49.6094H412.109V37.8906ZM400 37.5V50H412.5V37.5H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 37.8906H412.891V49.6094H424.609V37.8906ZM412.5 37.5V50H425V37.5H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 37.8906H425.391V49.6094H437.109V37.8906ZM425 37.5V50H437.5V37.5H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 37.8906H437.891V49.6094H449.609V37.8906ZM437.5 37.5V50H450V37.5H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 37.8906H450.391V49.6094H462.109V37.8906ZM450 37.5V50H462.5V37.5H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 37.8906H462.891V49.6094H474.609V37.8906ZM462.5 37.5V50H475V37.5H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 37.8906H475.391V49.6094H487.109V37.8906ZM475 37.5V50H487.5V37.5H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 37.8906H487.891V49.6094H499.609V37.8906ZM487.5 37.5V50H500V37.5H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 37.8906H500.391V49.6094H512.109V37.8906ZM500 37.5V50H512.5V37.5H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 37.8906H512.891V49.6094H524.609V37.8906ZM512.5 37.5V50H525V37.5H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 37.8906H525.391V49.6094H537.109V37.8906ZM525 37.5V50H537.5V37.5H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 37.8906H537.891V49.6094H549.609V37.8906ZM537.5 37.5V50H550V37.5H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 37.8906H550.391V49.6094H562.109V37.8906ZM550 37.5V50H562.5V37.5H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 37.8906H562.891V49.6094H574.609V37.8906ZM562.5 37.5V50H575V37.5H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 37.8906H575.391V49.6094H587.109V37.8906ZM575 37.5V50H587.5V37.5H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 37.8906H587.891V49.6094H599.609V37.8906ZM587.5 37.5V50H600V37.5H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 50.3906H0.390625V62.1094H12.1094V50.3906ZM0 50V62.5H12.5V50H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 50.3906H12.8906V62.1094H24.6094V50.3906ZM12.5 50V62.5H25V50H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 50.3906H25.3906V62.1094H37.1094V50.3906ZM25 50V62.5H37.5V50H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 50.3906H37.8906V62.1094H49.6094V50.3906ZM37.5 50V62.5H50V50H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 50.3906H50.3906V62.1094H62.1094V50.3906ZM50 50V62.5H62.5V50H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 50.3906H62.8906V62.1094H74.6094V50.3906ZM62.5 50V62.5H75V50H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 50.3906H75.3906V62.1094H87.1094V50.3906ZM75 50V62.5H87.5V50H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 50.3906H87.8906V62.1094H99.6094V50.3906ZM87.5 50V62.5H100V50H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 50.3906H100.391V62.1094H112.109V50.3906ZM100 50V62.5H112.5V50H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 50.3906H112.891V62.1094H124.609V50.3906ZM112.5 50V62.5H125V50H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 50.3906H125.391V62.1094H137.109V50.3906ZM125 50V62.5H137.5V50H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 50.3906H137.891V62.1094H149.609V50.3906ZM137.5 50V62.5H150V50H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 50.3906H150.391V62.1094H162.109V50.3906ZM150 50V62.5H162.5V50H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 50.3906H162.891V62.1094H174.609V50.3906ZM162.5 50V62.5H175V50H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 50.3906H175.391V62.1094H187.109V50.3906ZM175 50V62.5H187.5V50H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 50.3906H187.891V62.1094H199.609V50.3906ZM187.5 50V62.5H200V50H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 50.3906H200.391V62.1094H212.109V50.3906ZM200 50V62.5H212.5V50H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 50.3906H212.891V62.1094H224.609V50.3906ZM212.5 50V62.5H225V50H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 50.3906H225.391V62.1094H237.109V50.3906ZM225 50V62.5H237.5V50H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 50.3906H237.891V62.1094H249.609V50.3906ZM237.5 50V62.5H250V50H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 50.3906H250.391V62.1094H262.109V50.3906ZM250 50V62.5H262.5V50H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 50.3906H262.891V62.1094H274.609V50.3906ZM262.5 50V62.5H275V50H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 50.3906H275.391V62.1094H287.109V50.3906ZM275 50V62.5H287.5V50H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 50.3906H287.891V62.1094H299.609V50.3906ZM287.5 50V62.5H300V50H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 50.3906H300.391V62.1094H312.109V50.3906ZM300 50V62.5H312.5V50H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 50.3906H312.891V62.1094H324.609V50.3906ZM312.5 50V62.5H325V50H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 50.3906H325.391V62.1094H337.109V50.3906ZM325 50V62.5H337.5V50H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 50.3906H337.891V62.1094H349.609V50.3906ZM337.5 50V62.5H350V50H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 50.3906H350.391V62.1094H362.109V50.3906ZM350 50V62.5H362.5V50H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 50.3906H362.891V62.1094H374.609V50.3906ZM362.5 50V62.5H375V50H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 50.3906H375.391V62.1094H387.109V50.3906ZM375 50V62.5H387.5V50H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 50.3906H387.891V62.1094H399.609V50.3906ZM387.5 50V62.5H400V50H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 50.3906H400.391V62.1094H412.109V50.3906ZM400 50V62.5H412.5V50H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 50.3906H412.891V62.1094H424.609V50.3906ZM412.5 50V62.5H425V50H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 50.3906H425.391V62.1094H437.109V50.3906ZM425 50V62.5H437.5V50H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 50.3906H437.891V62.1094H449.609V50.3906ZM437.5 50V62.5H450V50H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 50.3906H450.391V62.1094H462.109V50.3906ZM450 50V62.5H462.5V50H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 50.3906H462.891V62.1094H474.609V50.3906ZM462.5 50V62.5H475V50H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 50.3906H475.391V62.1094H487.109V50.3906ZM475 50V62.5H487.5V50H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 50.3906H487.891V62.1094H499.609V50.3906ZM487.5 50V62.5H500V50H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 50.3906H500.391V62.1094H512.109V50.3906ZM500 50V62.5H512.5V50H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 50.3906H512.891V62.1094H524.609V50.3906ZM512.5 50V62.5H525V50H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 50.3906H525.391V62.1094H537.109V50.3906ZM525 50V62.5H537.5V50H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 50.3906H537.891V62.1094H549.609V50.3906ZM537.5 50V62.5H550V50H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 50.3906H550.391V62.1094H562.109V50.3906ZM550 50V62.5H562.5V50H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 50.3906H562.891V62.1094H574.609V50.3906ZM562.5 50V62.5H575V50H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 50.3906H575.391V62.1094H587.109V50.3906ZM575 50V62.5H587.5V50H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 50.3906H587.891V62.1094H599.609V50.3906ZM587.5 50V62.5H600V50H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 62.8906H0.390625V74.6094H12.1094V62.8906ZM0 62.5V75H12.5V62.5H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 62.8906H12.8906V74.6094H24.6094V62.8906ZM12.5 62.5V75H25V62.5H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 62.8906H25.3906V74.6094H37.1094V62.8906ZM25 62.5V75H37.5V62.5H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 62.8906H37.8906V74.6094H49.6094V62.8906ZM37.5 62.5V75H50V62.5H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 62.8906H50.3906V74.6094H62.1094V62.8906ZM50 62.5V75H62.5V62.5H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 62.8906H62.8906V74.6094H74.6094V62.8906ZM62.5 62.5V75H75V62.5H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 62.8906H75.3906V74.6094H87.1094V62.8906ZM75 62.5V75H87.5V62.5H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 62.8906H87.8906V74.6094H99.6094V62.8906ZM87.5 62.5V75H100V62.5H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 62.8906H100.391V74.6094H112.109V62.8906ZM100 62.5V75H112.5V62.5H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 62.8906H112.891V74.6094H124.609V62.8906ZM112.5 62.5V75H125V62.5H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 62.8906H125.391V74.6094H137.109V62.8906ZM125 62.5V75H137.5V62.5H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 62.8906H137.891V74.6094H149.609V62.8906ZM137.5 62.5V75H150V62.5H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 62.8906H150.391V74.6094H162.109V62.8906ZM150 62.5V75H162.5V62.5H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 62.8906H162.891V74.6094H174.609V62.8906ZM162.5 62.5V75H175V62.5H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 62.8906H175.391V74.6094H187.109V62.8906ZM175 62.5V75H187.5V62.5H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 62.8906H187.891V74.6094H199.609V62.8906ZM187.5 62.5V75H200V62.5H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 62.8906H200.391V74.6094H212.109V62.8906ZM200 62.5V75H212.5V62.5H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 62.8906H212.891V74.6094H224.609V62.8906ZM212.5 62.5V75H225V62.5H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 62.8906H225.391V74.6094H237.109V62.8906ZM225 62.5V75H237.5V62.5H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 62.8906H237.891V74.6094H249.609V62.8906ZM237.5 62.5V75H250V62.5H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 62.8906H250.391V74.6094H262.109V62.8906ZM250 62.5V75H262.5V62.5H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 62.8906H262.891V74.6094H274.609V62.8906ZM262.5 62.5V75H275V62.5H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 62.8906H275.391V74.6094H287.109V62.8906ZM275 62.5V75H287.5V62.5H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 62.8906H287.891V74.6094H299.609V62.8906ZM287.5 62.5V75H300V62.5H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 62.8906H300.391V74.6094H312.109V62.8906ZM300 62.5V75H312.5V62.5H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 62.8906H312.891V74.6094H324.609V62.8906ZM312.5 62.5V75H325V62.5H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 62.8906H325.391V74.6094H337.109V62.8906ZM325 62.5V75H337.5V62.5H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 62.8906H337.891V74.6094H349.609V62.8906ZM337.5 62.5V75H350V62.5H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 62.8906H350.391V74.6094H362.109V62.8906ZM350 62.5V75H362.5V62.5H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 62.8906H362.891V74.6094H374.609V62.8906ZM362.5 62.5V75H375V62.5H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 62.8906H375.391V74.6094H387.109V62.8906ZM375 62.5V75H387.5V62.5H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 62.8906H387.891V74.6094H399.609V62.8906ZM387.5 62.5V75H400V62.5H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 62.8906H400.391V74.6094H412.109V62.8906ZM400 62.5V75H412.5V62.5H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 62.8906H412.891V74.6094H424.609V62.8906ZM412.5 62.5V75H425V62.5H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 62.8906H425.391V74.6094H437.109V62.8906ZM425 62.5V75H437.5V62.5H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 62.8906H437.891V74.6094H449.609V62.8906ZM437.5 62.5V75H450V62.5H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 62.8906H450.391V74.6094H462.109V62.8906ZM450 62.5V75H462.5V62.5H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 62.8906H462.891V74.6094H474.609V62.8906ZM462.5 62.5V75H475V62.5H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 62.8906H475.391V74.6094H487.109V62.8906ZM475 62.5V75H487.5V62.5H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 62.8906H487.891V74.6094H499.609V62.8906ZM487.5 62.5V75H500V62.5H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 62.8906H500.391V74.6094H512.109V62.8906ZM500 62.5V75H512.5V62.5H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 62.8906H512.891V74.6094H524.609V62.8906ZM512.5 62.5V75H525V62.5H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 62.8906H525.391V74.6094H537.109V62.8906ZM525 62.5V75H537.5V62.5H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 62.8906H537.891V74.6094H549.609V62.8906ZM537.5 62.5V75H550V62.5H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 62.8906H550.391V74.6094H562.109V62.8906ZM550 62.5V75H562.5V62.5H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 62.8906H562.891V74.6094H574.609V62.8906ZM562.5 62.5V75H575V62.5H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 62.8906H575.391V74.6094H587.109V62.8906ZM575 62.5V75H587.5V62.5H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 62.8906H587.891V74.6094H599.609V62.8906ZM587.5 62.5V75H600V62.5H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 75.3906H0.390625V87.1094H12.1094V75.3906ZM0 75V87.5H12.5V75H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 75.3906H12.8906V87.1094H24.6094V75.3906ZM12.5 75V87.5H25V75H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 75.3906H25.3906V87.1094H37.1094V75.3906ZM25 75V87.5H37.5V75H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 75.3906H37.8906V87.1094H49.6094V75.3906ZM37.5 75V87.5H50V75H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 75.3906H50.3906V87.1094H62.1094V75.3906ZM50 75V87.5H62.5V75H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 75.3906H62.8906V87.1094H74.6094V75.3906ZM62.5 75V87.5H75V75H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 75.3906H75.3906V87.1094H87.1094V75.3906ZM75 75V87.5H87.5V75H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 75.3906H87.8906V87.1094H99.6094V75.3906ZM87.5 75V87.5H100V75H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 75.3906H100.391V87.1094H112.109V75.3906ZM100 75V87.5H112.5V75H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 75.3906H112.891V87.1094H124.609V75.3906ZM112.5 75V87.5H125V75H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 75.3906H125.391V87.1094H137.109V75.3906ZM125 75V87.5H137.5V75H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 75.3906H137.891V87.1094H149.609V75.3906ZM137.5 75V87.5H150V75H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 75.3906H150.391V87.1094H162.109V75.3906ZM150 75V87.5H162.5V75H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 75.3906H162.891V87.1094H174.609V75.3906ZM162.5 75V87.5H175V75H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 75.3906H175.391V87.1094H187.109V75.3906ZM175 75V87.5H187.5V75H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 75.3906H187.891V87.1094H199.609V75.3906ZM187.5 75V87.5H200V75H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 75.3906H200.391V87.1094H212.109V75.3906ZM200 75V87.5H212.5V75H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 75.3906H212.891V87.1094H224.609V75.3906ZM212.5 75V87.5H225V75H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 75.3906H225.391V87.1094H237.109V75.3906ZM225 75V87.5H237.5V75H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 75.3906H237.891V87.1094H249.609V75.3906ZM237.5 75V87.5H250V75H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 75.3906H250.391V87.1094H262.109V75.3906ZM250 75V87.5H262.5V75H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 75.3906H262.891V87.1094H274.609V75.3906ZM262.5 75V87.5H275V75H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 75.3906H275.391V87.1094H287.109V75.3906ZM275 75V87.5H287.5V75H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 75.3906H287.891V87.1094H299.609V75.3906ZM287.5 75V87.5H300V75H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 75.3906H300.391V87.1094H312.109V75.3906ZM300 75V87.5H312.5V75H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 75.3906H312.891V87.1094H324.609V75.3906ZM312.5 75V87.5H325V75H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 75.3906H325.391V87.1094H337.109V75.3906ZM325 75V87.5H337.5V75H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 75.3906H337.891V87.1094H349.609V75.3906ZM337.5 75V87.5H350V75H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 75.3906H350.391V87.1094H362.109V75.3906ZM350 75V87.5H362.5V75H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 75.3906H362.891V87.1094H374.609V75.3906ZM362.5 75V87.5H375V75H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 75.3906H375.391V87.1094H387.109V75.3906ZM375 75V87.5H387.5V75H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 75.3906H387.891V87.1094H399.609V75.3906ZM387.5 75V87.5H400V75H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 75.3906H400.391V87.1094H412.109V75.3906ZM400 75V87.5H412.5V75H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 75.3906H412.891V87.1094H424.609V75.3906ZM412.5 75V87.5H425V75H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 75.3906H425.391V87.1094H437.109V75.3906ZM425 75V87.5H437.5V75H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 75.3906H437.891V87.1094H449.609V75.3906ZM437.5 75V87.5H450V75H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 75.3906H450.391V87.1094H462.109V75.3906ZM450 75V87.5H462.5V75H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 75.3906H462.891V87.1094H474.609V75.3906ZM462.5 75V87.5H475V75H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 75.3906H475.391V87.1094H487.109V75.3906ZM475 75V87.5H487.5V75H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 75.3906H487.891V87.1094H499.609V75.3906ZM487.5 75V87.5H500V75H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 75.3906H500.391V87.1094H512.109V75.3906ZM500 75V87.5H512.5V75H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 75.3906H512.891V87.1094H524.609V75.3906ZM512.5 75V87.5H525V75H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 75.3906H525.391V87.1094H537.109V75.3906ZM525 75V87.5H537.5V75H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 75.3906H537.891V87.1094H549.609V75.3906ZM537.5 75V87.5H550V75H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 75.3906H550.391V87.1094H562.109V75.3906ZM550 75V87.5H562.5V75H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 75.3906H562.891V87.1094H574.609V75.3906ZM562.5 75V87.5H575V75H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 75.3906H575.391V87.1094H587.109V75.3906ZM575 75V87.5H587.5V75H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 75.3906H587.891V87.1094H599.609V75.3906ZM587.5 75V87.5H600V75H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 87.8906H0.390625V99.6094H12.1094V87.8906ZM0 87.5V100H12.5V87.5H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 87.8906H12.8906V99.6094H24.6094V87.8906ZM12.5 87.5V100H25V87.5H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 87.8906H25.3906V99.6094H37.1094V87.8906ZM25 87.5V100H37.5V87.5H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 87.8906H37.8906V99.6094H49.6094V87.8906ZM37.5 87.5V100H50V87.5H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 87.8906H50.3906V99.6094H62.1094V87.8906ZM50 87.5V100H62.5V87.5H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 87.8906H62.8906V99.6094H74.6094V87.8906ZM62.5 87.5V100H75V87.5H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 87.8906H75.3906V99.6094H87.1094V87.8906ZM75 87.5V100H87.5V87.5H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 87.8906H87.8906V99.6094H99.6094V87.8906ZM87.5 87.5V100H100V87.5H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 87.8906H100.391V99.6094H112.109V87.8906ZM100 87.5V100H112.5V87.5H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 87.8906H112.891V99.6094H124.609V87.8906ZM112.5 87.5V100H125V87.5H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 87.8906H125.391V99.6094H137.109V87.8906ZM125 87.5V100H137.5V87.5H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 87.8906H137.891V99.6094H149.609V87.8906ZM137.5 87.5V100H150V87.5H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 87.8906H150.391V99.6094H162.109V87.8906ZM150 87.5V100H162.5V87.5H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 87.8906H162.891V99.6094H174.609V87.8906ZM162.5 87.5V100H175V87.5H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 87.8906H175.391V99.6094H187.109V87.8906ZM175 87.5V100H187.5V87.5H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 87.8906H187.891V99.6094H199.609V87.8906ZM187.5 87.5V100H200V87.5H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 87.8906H200.391V99.6094H212.109V87.8906ZM200 87.5V100H212.5V87.5H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 87.8906H212.891V99.6094H224.609V87.8906ZM212.5 87.5V100H225V87.5H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 87.8906H225.391V99.6094H237.109V87.8906ZM225 87.5V100H237.5V87.5H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 87.8906H237.891V99.6094H249.609V87.8906ZM237.5 87.5V100H250V87.5H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 87.8906H250.391V99.6094H262.109V87.8906ZM250 87.5V100H262.5V87.5H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 87.8906H262.891V99.6094H274.609V87.8906ZM262.5 87.5V100H275V87.5H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 87.8906H275.391V99.6094H287.109V87.8906ZM275 87.5V100H287.5V87.5H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 87.8906H287.891V99.6094H299.609V87.8906ZM287.5 87.5V100H300V87.5H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 87.8906H300.391V99.6094H312.109V87.8906ZM300 87.5V100H312.5V87.5H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 87.8906H312.891V99.6094H324.609V87.8906ZM312.5 87.5V100H325V87.5H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 87.8906H325.391V99.6094H337.109V87.8906ZM325 87.5V100H337.5V87.5H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 87.8906H337.891V99.6094H349.609V87.8906ZM337.5 87.5V100H350V87.5H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 87.8906H350.391V99.6094H362.109V87.8906ZM350 87.5V100H362.5V87.5H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 87.8906H362.891V99.6094H374.609V87.8906ZM362.5 87.5V100H375V87.5H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 87.8906H375.391V99.6094H387.109V87.8906ZM375 87.5V100H387.5V87.5H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 87.8906H387.891V99.6094H399.609V87.8906ZM387.5 87.5V100H400V87.5H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 87.8906H400.391V99.6094H412.109V87.8906ZM400 87.5V100H412.5V87.5H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 87.8906H412.891V99.6094H424.609V87.8906ZM412.5 87.5V100H425V87.5H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 87.8906H425.391V99.6094H437.109V87.8906ZM425 87.5V100H437.5V87.5H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 87.8906H437.891V99.6094H449.609V87.8906ZM437.5 87.5V100H450V87.5H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 87.8906H450.391V99.6094H462.109V87.8906ZM450 87.5V100H462.5V87.5H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 87.8906H462.891V99.6094H474.609V87.8906ZM462.5 87.5V100H475V87.5H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 87.8906H475.391V99.6094H487.109V87.8906ZM475 87.5V100H487.5V87.5H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 87.8906H487.891V99.6094H499.609V87.8906ZM487.5 87.5V100H500V87.5H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 87.8906H500.391V99.6094H512.109V87.8906ZM500 87.5V100H512.5V87.5H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 87.8906H512.891V99.6094H524.609V87.8906ZM512.5 87.5V100H525V87.5H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 87.8906H525.391V99.6094H537.109V87.8906ZM525 87.5V100H537.5V87.5H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 87.8906H537.891V99.6094H549.609V87.8906ZM537.5 87.5V100H550V87.5H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 87.8906H550.391V99.6094H562.109V87.8906ZM550 87.5V100H562.5V87.5H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 87.8906H562.891V99.6094H574.609V87.8906ZM562.5 87.5V100H575V87.5H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 87.8906H575.391V99.6094H587.109V87.8906ZM575 87.5V100H587.5V87.5H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 87.8906H587.891V99.6094H599.609V87.8906ZM587.5 87.5V100H600V87.5H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 100.391H0.390625V112.109H12.1094V100.391ZM0 100V112.5H12.5V100H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 100.391H12.8906V112.109H24.6094V100.391ZM12.5 100V112.5H25V100H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 100.391H25.3906V112.109H37.1094V100.391ZM25 100V112.5H37.5V100H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 100.391H37.8906V112.109H49.6094V100.391ZM37.5 100V112.5H50V100H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 100.391H50.3906V112.109H62.1094V100.391ZM50 100V112.5H62.5V100H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 100.391H62.8906V112.109H74.6094V100.391ZM62.5 100V112.5H75V100H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 100.391H75.3906V112.109H87.1094V100.391ZM75 100V112.5H87.5V100H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 100.391H87.8906V112.109H99.6094V100.391ZM87.5 100V112.5H100V100H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 100.391H100.391V112.109H112.109V100.391ZM100 100V112.5H112.5V100H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 100.391H112.891V112.109H124.609V100.391ZM112.5 100V112.5H125V100H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 100.391H125.391V112.109H137.109V100.391ZM125 100V112.5H137.5V100H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 100.391H137.891V112.109H149.609V100.391ZM137.5 100V112.5H150V100H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 100.391H150.391V112.109H162.109V100.391ZM150 100V112.5H162.5V100H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 100.391H162.891V112.109H174.609V100.391ZM162.5 100V112.5H175V100H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 100.391H175.391V112.109H187.109V100.391ZM175 100V112.5H187.5V100H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 100.391H187.891V112.109H199.609V100.391ZM187.5 100V112.5H200V100H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 100.391H200.391V112.109H212.109V100.391ZM200 100V112.5H212.5V100H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 100.391H212.891V112.109H224.609V100.391ZM212.5 100V112.5H225V100H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 100.391H225.391V112.109H237.109V100.391ZM225 100V112.5H237.5V100H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 100.391H237.891V112.109H249.609V100.391ZM237.5 100V112.5H250V100H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 100.391H250.391V112.109H262.109V100.391ZM250 100V112.5H262.5V100H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 100.391H262.891V112.109H274.609V100.391ZM262.5 100V112.5H275V100H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 100.391H275.391V112.109H287.109V100.391ZM275 100V112.5H287.5V100H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 100.391H287.891V112.109H299.609V100.391ZM287.5 100V112.5H300V100H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 100.391H300.391V112.109H312.109V100.391ZM300 100V112.5H312.5V100H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 100.391H312.891V112.109H324.609V100.391ZM312.5 100V112.5H325V100H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 100.391H325.391V112.109H337.109V100.391ZM325 100V112.5H337.5V100H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 100.391H337.891V112.109H349.609V100.391ZM337.5 100V112.5H350V100H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 100.391H350.391V112.109H362.109V100.391ZM350 100V112.5H362.5V100H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 100.391H362.891V112.109H374.609V100.391ZM362.5 100V112.5H375V100H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 100.391H375.391V112.109H387.109V100.391ZM375 100V112.5H387.5V100H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 100.391H387.891V112.109H399.609V100.391ZM387.5 100V112.5H400V100H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 100.391H400.391V112.109H412.109V100.391ZM400 100V112.5H412.5V100H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 100.391H412.891V112.109H424.609V100.391ZM412.5 100V112.5H425V100H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 100.391H425.391V112.109H437.109V100.391ZM425 100V112.5H437.5V100H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 100.391H437.891V112.109H449.609V100.391ZM437.5 100V112.5H450V100H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 100.391H450.391V112.109H462.109V100.391ZM450 100V112.5H462.5V100H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 100.391H462.891V112.109H474.609V100.391ZM462.5 100V112.5H475V100H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 100.391H475.391V112.109H487.109V100.391ZM475 100V112.5H487.5V100H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 100.391H487.891V112.109H499.609V100.391ZM487.5 100V112.5H500V100H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 100.391H500.391V112.109H512.109V100.391ZM500 100V112.5H512.5V100H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 100.391H512.891V112.109H524.609V100.391ZM512.5 100V112.5H525V100H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 100.391H525.391V112.109H537.109V100.391ZM525 100V112.5H537.5V100H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 100.391H537.891V112.109H549.609V100.391ZM537.5 100V112.5H550V100H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 100.391H550.391V112.109H562.109V100.391ZM550 100V112.5H562.5V100H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 100.391H562.891V112.109H574.609V100.391ZM562.5 100V112.5H575V100H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 100.391H575.391V112.109H587.109V100.391ZM575 100V112.5H587.5V100H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 100.391H587.891V112.109H599.609V100.391ZM587.5 100V112.5H600V100H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 112.891H0.390625V124.609H12.1094V112.891ZM0 112.5V125H12.5V112.5H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 112.891H12.8906V124.609H24.6094V112.891ZM12.5 112.5V125H25V112.5H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 112.891H25.3906V124.609H37.1094V112.891ZM25 112.5V125H37.5V112.5H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 112.891H37.8906V124.609H49.6094V112.891ZM37.5 112.5V125H50V112.5H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 112.891H50.3906V124.609H62.1094V112.891ZM50 112.5V125H62.5V112.5H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 112.891H62.8906V124.609H74.6094V112.891ZM62.5 112.5V125H75V112.5H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 112.891H75.3906V124.609H87.1094V112.891ZM75 112.5V125H87.5V112.5H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 112.891H87.8906V124.609H99.6094V112.891ZM87.5 112.5V125H100V112.5H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 112.891H100.391V124.609H112.109V112.891ZM100 112.5V125H112.5V112.5H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 112.891H112.891V124.609H124.609V112.891ZM112.5 112.5V125H125V112.5H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 112.891H125.391V124.609H137.109V112.891ZM125 112.5V125H137.5V112.5H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 112.891H137.891V124.609H149.609V112.891ZM137.5 112.5V125H150V112.5H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 112.891H150.391V124.609H162.109V112.891ZM150 112.5V125H162.5V112.5H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 112.891H162.891V124.609H174.609V112.891ZM162.5 112.5V125H175V112.5H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 112.891H175.391V124.609H187.109V112.891ZM175 112.5V125H187.5V112.5H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 112.891H187.891V124.609H199.609V112.891ZM187.5 112.5V125H200V112.5H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 112.891H200.391V124.609H212.109V112.891ZM200 112.5V125H212.5V112.5H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 112.891H212.891V124.609H224.609V112.891ZM212.5 112.5V125H225V112.5H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 112.891H225.391V124.609H237.109V112.891ZM225 112.5V125H237.5V112.5H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 112.891H237.891V124.609H249.609V112.891ZM237.5 112.5V125H250V112.5H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 112.891H250.391V124.609H262.109V112.891ZM250 112.5V125H262.5V112.5H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 112.891H262.891V124.609H274.609V112.891ZM262.5 112.5V125H275V112.5H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 112.891H275.391V124.609H287.109V112.891ZM275 112.5V125H287.5V112.5H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 112.891H287.891V124.609H299.609V112.891ZM287.5 112.5V125H300V112.5H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 112.891H300.391V124.609H312.109V112.891ZM300 112.5V125H312.5V112.5H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 112.891H312.891V124.609H324.609V112.891ZM312.5 112.5V125H325V112.5H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 112.891H325.391V124.609H337.109V112.891ZM325 112.5V125H337.5V112.5H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 112.891H337.891V124.609H349.609V112.891ZM337.5 112.5V125H350V112.5H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 112.891H350.391V124.609H362.109V112.891ZM350 112.5V125H362.5V112.5H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 112.891H362.891V124.609H374.609V112.891ZM362.5 112.5V125H375V112.5H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 112.891H375.391V124.609H387.109V112.891ZM375 112.5V125H387.5V112.5H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 112.891H387.891V124.609H399.609V112.891ZM387.5 112.5V125H400V112.5H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 112.891H400.391V124.609H412.109V112.891ZM400 112.5V125H412.5V112.5H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 112.891H412.891V124.609H424.609V112.891ZM412.5 112.5V125H425V112.5H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 112.891H425.391V124.609H437.109V112.891ZM425 112.5V125H437.5V112.5H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 112.891H437.891V124.609H449.609V112.891ZM437.5 112.5V125H450V112.5H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 112.891H450.391V124.609H462.109V112.891ZM450 112.5V125H462.5V112.5H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 112.891H462.891V124.609H474.609V112.891ZM462.5 112.5V125H475V112.5H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 112.891H475.391V124.609H487.109V112.891ZM475 112.5V125H487.5V112.5H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 112.891H487.891V124.609H499.609V112.891ZM487.5 112.5V125H500V112.5H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 112.891H500.391V124.609H512.109V112.891ZM500 112.5V125H512.5V112.5H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 112.891H512.891V124.609H524.609V112.891ZM512.5 112.5V125H525V112.5H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 112.891H525.391V124.609H537.109V112.891ZM525 112.5V125H537.5V112.5H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 112.891H537.891V124.609H549.609V112.891ZM537.5 112.5V125H550V112.5H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 112.891H550.391V124.609H562.109V112.891ZM550 112.5V125H562.5V112.5H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 112.891H562.891V124.609H574.609V112.891ZM562.5 112.5V125H575V112.5H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 112.891H575.391V124.609H587.109V112.891ZM575 112.5V125H587.5V112.5H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 112.891H587.891V124.609H599.609V112.891ZM587.5 112.5V125H600V112.5H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 125.391H0.390625V137.109H12.1094V125.391ZM0 125V137.5H12.5V125H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 125.391H12.8906V137.109H24.6094V125.391ZM12.5 125V137.5H25V125H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 125.391H25.3906V137.109H37.1094V125.391ZM25 125V137.5H37.5V125H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 125.391H37.8906V137.109H49.6094V125.391ZM37.5 125V137.5H50V125H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 125.391H50.3906V137.109H62.1094V125.391ZM50 125V137.5H62.5V125H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 125.391H62.8906V137.109H74.6094V125.391ZM62.5 125V137.5H75V125H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 125.391H75.3906V137.109H87.1094V125.391ZM75 125V137.5H87.5V125H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 125.391H87.8906V137.109H99.6094V125.391ZM87.5 125V137.5H100V125H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 125.391H100.391V137.109H112.109V125.391ZM100 125V137.5H112.5V125H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 125.391H112.891V137.109H124.609V125.391ZM112.5 125V137.5H125V125H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 125.391H125.391V137.109H137.109V125.391ZM125 125V137.5H137.5V125H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 125.391H137.891V137.109H149.609V125.391ZM137.5 125V137.5H150V125H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 125.391H150.391V137.109H162.109V125.391ZM150 125V137.5H162.5V125H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 125.391H162.891V137.109H174.609V125.391ZM162.5 125V137.5H175V125H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 125.391H175.391V137.109H187.109V125.391ZM175 125V137.5H187.5V125H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 125.391H187.891V137.109H199.609V125.391ZM187.5 125V137.5H200V125H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 125.391H200.391V137.109H212.109V125.391ZM200 125V137.5H212.5V125H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 125.391H212.891V137.109H224.609V125.391ZM212.5 125V137.5H225V125H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 125.391H225.391V137.109H237.109V125.391ZM225 125V137.5H237.5V125H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 125.391H237.891V137.109H249.609V125.391ZM237.5 125V137.5H250V125H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 125.391H250.391V137.109H262.109V125.391ZM250 125V137.5H262.5V125H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 125.391H262.891V137.109H274.609V125.391ZM262.5 125V137.5H275V125H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 125.391H275.391V137.109H287.109V125.391ZM275 125V137.5H287.5V125H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 125.391H287.891V137.109H299.609V125.391ZM287.5 125V137.5H300V125H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 125.391H300.391V137.109H312.109V125.391ZM300 125V137.5H312.5V125H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 125.391H312.891V137.109H324.609V125.391ZM312.5 125V137.5H325V125H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 125.391H325.391V137.109H337.109V125.391ZM325 125V137.5H337.5V125H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 125.391H337.891V137.109H349.609V125.391ZM337.5 125V137.5H350V125H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 125.391H350.391V137.109H362.109V125.391ZM350 125V137.5H362.5V125H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 125.391H362.891V137.109H374.609V125.391ZM362.5 125V137.5H375V125H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 125.391H375.391V137.109H387.109V125.391ZM375 125V137.5H387.5V125H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 125.391H387.891V137.109H399.609V125.391ZM387.5 125V137.5H400V125H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 125.391H400.391V137.109H412.109V125.391ZM400 125V137.5H412.5V125H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 125.391H412.891V137.109H424.609V125.391ZM412.5 125V137.5H425V125H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 125.391H425.391V137.109H437.109V125.391ZM425 125V137.5H437.5V125H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 125.391H437.891V137.109H449.609V125.391ZM437.5 125V137.5H450V125H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 125.391H450.391V137.109H462.109V125.391ZM450 125V137.5H462.5V125H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 125.391H462.891V137.109H474.609V125.391ZM462.5 125V137.5H475V125H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 125.391H475.391V137.109H487.109V125.391ZM475 125V137.5H487.5V125H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 125.391H487.891V137.109H499.609V125.391ZM487.5 125V137.5H500V125H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 125.391H500.391V137.109H512.109V125.391ZM500 125V137.5H512.5V125H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 125.391H512.891V137.109H524.609V125.391ZM512.5 125V137.5H525V125H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 125.391H525.391V137.109H537.109V125.391ZM525 125V137.5H537.5V125H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 125.391H537.891V137.109H549.609V125.391ZM537.5 125V137.5H550V125H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 125.391H550.391V137.109H562.109V125.391ZM550 125V137.5H562.5V125H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 125.391H562.891V137.109H574.609V125.391ZM562.5 125V137.5H575V125H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 125.391H575.391V137.109H587.109V125.391ZM575 125V137.5H587.5V125H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 125.391H587.891V137.109H599.609V125.391ZM587.5 125V137.5H600V125H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 137.891H0.390625V149.609H12.1094V137.891ZM0 137.5V150H12.5V137.5H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 137.891H12.8906V149.609H24.6094V137.891ZM12.5 137.5V150H25V137.5H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 137.891H25.3906V149.609H37.1094V137.891ZM25 137.5V150H37.5V137.5H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 137.891H37.8906V149.609H49.6094V137.891ZM37.5 137.5V150H50V137.5H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 137.891H50.3906V149.609H62.1094V137.891ZM50 137.5V150H62.5V137.5H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 137.891H62.8906V149.609H74.6094V137.891ZM62.5 137.5V150H75V137.5H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 137.891H75.3906V149.609H87.1094V137.891ZM75 137.5V150H87.5V137.5H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 137.891H87.8906V149.609H99.6094V137.891ZM87.5 137.5V150H100V137.5H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 137.891H100.391V149.609H112.109V137.891ZM100 137.5V150H112.5V137.5H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 137.891H112.891V149.609H124.609V137.891ZM112.5 137.5V150H125V137.5H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 137.891H125.391V149.609H137.109V137.891ZM125 137.5V150H137.5V137.5H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 137.891H137.891V149.609H149.609V137.891ZM137.5 137.5V150H150V137.5H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 137.891H150.391V149.609H162.109V137.891ZM150 137.5V150H162.5V137.5H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 137.891H162.891V149.609H174.609V137.891ZM162.5 137.5V150H175V137.5H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 137.891H175.391V149.609H187.109V137.891ZM175 137.5V150H187.5V137.5H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 137.891H187.891V149.609H199.609V137.891ZM187.5 137.5V150H200V137.5H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 137.891H200.391V149.609H212.109V137.891ZM200 137.5V150H212.5V137.5H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 137.891H212.891V149.609H224.609V137.891ZM212.5 137.5V150H225V137.5H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 137.891H225.391V149.609H237.109V137.891ZM225 137.5V150H237.5V137.5H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 137.891H237.891V149.609H249.609V137.891ZM237.5 137.5V150H250V137.5H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 137.891H250.391V149.609H262.109V137.891ZM250 137.5V150H262.5V137.5H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 137.891H262.891V149.609H274.609V137.891ZM262.5 137.5V150H275V137.5H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 137.891H275.391V149.609H287.109V137.891ZM275 137.5V150H287.5V137.5H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 137.891H287.891V149.609H299.609V137.891ZM287.5 137.5V150H300V137.5H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 137.891H300.391V149.609H312.109V137.891ZM300 137.5V150H312.5V137.5H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 137.891H312.891V149.609H324.609V137.891ZM312.5 137.5V150H325V137.5H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 137.891H325.391V149.609H337.109V137.891ZM325 137.5V150H337.5V137.5H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 137.891H337.891V149.609H349.609V137.891ZM337.5 137.5V150H350V137.5H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 137.891H350.391V149.609H362.109V137.891ZM350 137.5V150H362.5V137.5H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 137.891H362.891V149.609H374.609V137.891ZM362.5 137.5V150H375V137.5H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 137.891H375.391V149.609H387.109V137.891ZM375 137.5V150H387.5V137.5H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 137.891H387.891V149.609H399.609V137.891ZM387.5 137.5V150H400V137.5H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 137.891H400.391V149.609H412.109V137.891ZM400 137.5V150H412.5V137.5H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 137.891H412.891V149.609H424.609V137.891ZM412.5 137.5V150H425V137.5H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 137.891H425.391V149.609H437.109V137.891ZM425 137.5V150H437.5V137.5H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 137.891H437.891V149.609H449.609V137.891ZM437.5 137.5V150H450V137.5H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 137.891H450.391V149.609H462.109V137.891ZM450 137.5V150H462.5V137.5H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 137.891H462.891V149.609H474.609V137.891ZM462.5 137.5V150H475V137.5H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 137.891H475.391V149.609H487.109V137.891ZM475 137.5V150H487.5V137.5H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 137.891H487.891V149.609H499.609V137.891ZM487.5 137.5V150H500V137.5H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 137.891H500.391V149.609H512.109V137.891ZM500 137.5V150H512.5V137.5H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 137.891H512.891V149.609H524.609V137.891ZM512.5 137.5V150H525V137.5H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 137.891H525.391V149.609H537.109V137.891ZM525 137.5V150H537.5V137.5H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 137.891H537.891V149.609H549.609V137.891ZM537.5 137.5V150H550V137.5H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 137.891H550.391V149.609H562.109V137.891ZM550 137.5V150H562.5V137.5H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 137.891H562.891V149.609H574.609V137.891ZM562.5 137.5V150H575V137.5H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 137.891H575.391V149.609H587.109V137.891ZM575 137.5V150H587.5V137.5H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 137.891H587.891V149.609H599.609V137.891ZM587.5 137.5V150H600V137.5H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 150.391H0.390625V162.109H12.1094V150.391ZM0 150V162.5H12.5V150H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 150.391H12.8906V162.109H24.6094V150.391ZM12.5 150V162.5H25V150H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 150.391H25.3906V162.109H37.1094V150.391ZM25 150V162.5H37.5V150H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 150.391H37.8906V162.109H49.6094V150.391ZM37.5 150V162.5H50V150H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 150.391H50.3906V162.109H62.1094V150.391ZM50 150V162.5H62.5V150H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 150.391H62.8906V162.109H74.6094V150.391ZM62.5 150V162.5H75V150H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 150.391H75.3906V162.109H87.1094V150.391ZM75 150V162.5H87.5V150H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 150.391H87.8906V162.109H99.6094V150.391ZM87.5 150V162.5H100V150H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 150.391H100.391V162.109H112.109V150.391ZM100 150V162.5H112.5V150H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 150.391H112.891V162.109H124.609V150.391ZM112.5 150V162.5H125V150H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 150.391H125.391V162.109H137.109V150.391ZM125 150V162.5H137.5V150H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 150.391H137.891V162.109H149.609V150.391ZM137.5 150V162.5H150V150H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 150.391H150.391V162.109H162.109V150.391ZM150 150V162.5H162.5V150H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 150.391H162.891V162.109H174.609V150.391ZM162.5 150V162.5H175V150H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 150.391H175.391V162.109H187.109V150.391ZM175 150V162.5H187.5V150H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 150.391H187.891V162.109H199.609V150.391ZM187.5 150V162.5H200V150H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 150.391H200.391V162.109H212.109V150.391ZM200 150V162.5H212.5V150H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 150.391H212.891V162.109H224.609V150.391ZM212.5 150V162.5H225V150H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 150.391H225.391V162.109H237.109V150.391ZM225 150V162.5H237.5V150H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 150.391H237.891V162.109H249.609V150.391ZM237.5 150V162.5H250V150H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 150.391H250.391V162.109H262.109V150.391ZM250 150V162.5H262.5V150H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 150.391H262.891V162.109H274.609V150.391ZM262.5 150V162.5H275V150H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 150.391H275.391V162.109H287.109V150.391ZM275 150V162.5H287.5V150H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 150.391H287.891V162.109H299.609V150.391ZM287.5 150V162.5H300V150H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 150.391H300.391V162.109H312.109V150.391ZM300 150V162.5H312.5V150H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 150.391H312.891V162.109H324.609V150.391ZM312.5 150V162.5H325V150H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 150.391H325.391V162.109H337.109V150.391ZM325 150V162.5H337.5V150H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 150.391H337.891V162.109H349.609V150.391ZM337.5 150V162.5H350V150H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 150.391H350.391V162.109H362.109V150.391ZM350 150V162.5H362.5V150H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 150.391H362.891V162.109H374.609V150.391ZM362.5 150V162.5H375V150H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 150.391H375.391V162.109H387.109V150.391ZM375 150V162.5H387.5V150H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 150.391H387.891V162.109H399.609V150.391ZM387.5 150V162.5H400V150H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 150.391H400.391V162.109H412.109V150.391ZM400 150V162.5H412.5V150H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 150.391H412.891V162.109H424.609V150.391ZM412.5 150V162.5H425V150H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 150.391H425.391V162.109H437.109V150.391ZM425 150V162.5H437.5V150H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 150.391H437.891V162.109H449.609V150.391ZM437.5 150V162.5H450V150H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 150.391H450.391V162.109H462.109V150.391ZM450 150V162.5H462.5V150H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 150.391H462.891V162.109H474.609V150.391ZM462.5 150V162.5H475V150H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 150.391H475.391V162.109H487.109V150.391ZM475 150V162.5H487.5V150H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 150.391H487.891V162.109H499.609V150.391ZM487.5 150V162.5H500V150H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 150.391H500.391V162.109H512.109V150.391ZM500 150V162.5H512.5V150H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 150.391H512.891V162.109H524.609V150.391ZM512.5 150V162.5H525V150H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 150.391H525.391V162.109H537.109V150.391ZM525 150V162.5H537.5V150H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 150.391H537.891V162.109H549.609V150.391ZM537.5 150V162.5H550V150H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 150.391H550.391V162.109H562.109V150.391ZM550 150V162.5H562.5V150H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 150.391H562.891V162.109H574.609V150.391ZM562.5 150V162.5H575V150H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 150.391H575.391V162.109H587.109V150.391ZM575 150V162.5H587.5V150H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 150.391H587.891V162.109H599.609V150.391ZM587.5 150V162.5H600V150H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 162.891H0.390625V174.609H12.1094V162.891ZM0 162.5V175H12.5V162.5H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 162.891H12.8906V174.609H24.6094V162.891ZM12.5 162.5V175H25V162.5H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 162.891H25.3906V174.609H37.1094V162.891ZM25 162.5V175H37.5V162.5H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 162.891H37.8906V174.609H49.6094V162.891ZM37.5 162.5V175H50V162.5H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 162.891H50.3906V174.609H62.1094V162.891ZM50 162.5V175H62.5V162.5H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 162.891H62.8906V174.609H74.6094V162.891ZM62.5 162.5V175H75V162.5H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 162.891H75.3906V174.609H87.1094V162.891ZM75 162.5V175H87.5V162.5H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 162.891H87.8906V174.609H99.6094V162.891ZM87.5 162.5V175H100V162.5H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 162.891H100.391V174.609H112.109V162.891ZM100 162.5V175H112.5V162.5H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 162.891H112.891V174.609H124.609V162.891ZM112.5 162.5V175H125V162.5H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 162.891H125.391V174.609H137.109V162.891ZM125 162.5V175H137.5V162.5H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 162.891H137.891V174.609H149.609V162.891ZM137.5 162.5V175H150V162.5H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 162.891H150.391V174.609H162.109V162.891ZM150 162.5V175H162.5V162.5H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 162.891H162.891V174.609H174.609V162.891ZM162.5 162.5V175H175V162.5H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 162.891H175.391V174.609H187.109V162.891ZM175 162.5V175H187.5V162.5H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 162.891H187.891V174.609H199.609V162.891ZM187.5 162.5V175H200V162.5H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 162.891H200.391V174.609H212.109V162.891ZM200 162.5V175H212.5V162.5H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 162.891H212.891V174.609H224.609V162.891ZM212.5 162.5V175H225V162.5H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 162.891H225.391V174.609H237.109V162.891ZM225 162.5V175H237.5V162.5H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 162.891H237.891V174.609H249.609V162.891ZM237.5 162.5V175H250V162.5H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 162.891H250.391V174.609H262.109V162.891ZM250 162.5V175H262.5V162.5H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 162.891H262.891V174.609H274.609V162.891ZM262.5 162.5V175H275V162.5H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 162.891H275.391V174.609H287.109V162.891ZM275 162.5V175H287.5V162.5H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 162.891H287.891V174.609H299.609V162.891ZM287.5 162.5V175H300V162.5H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 162.891H300.391V174.609H312.109V162.891ZM300 162.5V175H312.5V162.5H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 162.891H312.891V174.609H324.609V162.891ZM312.5 162.5V175H325V162.5H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 162.891H325.391V174.609H337.109V162.891ZM325 162.5V175H337.5V162.5H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 162.891H337.891V174.609H349.609V162.891ZM337.5 162.5V175H350V162.5H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 162.891H350.391V174.609H362.109V162.891ZM350 162.5V175H362.5V162.5H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 162.891H362.891V174.609H374.609V162.891ZM362.5 162.5V175H375V162.5H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 162.891H375.391V174.609H387.109V162.891ZM375 162.5V175H387.5V162.5H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 162.891H387.891V174.609H399.609V162.891ZM387.5 162.5V175H400V162.5H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 162.891H400.391V174.609H412.109V162.891ZM400 162.5V175H412.5V162.5H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 162.891H412.891V174.609H424.609V162.891ZM412.5 162.5V175H425V162.5H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 162.891H425.391V174.609H437.109V162.891ZM425 162.5V175H437.5V162.5H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 162.891H437.891V174.609H449.609V162.891ZM437.5 162.5V175H450V162.5H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 162.891H450.391V174.609H462.109V162.891ZM450 162.5V175H462.5V162.5H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 162.891H462.891V174.609H474.609V162.891ZM462.5 162.5V175H475V162.5H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 162.891H475.391V174.609H487.109V162.891ZM475 162.5V175H487.5V162.5H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 162.891H487.891V174.609H499.609V162.891ZM487.5 162.5V175H500V162.5H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 162.891H500.391V174.609H512.109V162.891ZM500 162.5V175H512.5V162.5H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 162.891H512.891V174.609H524.609V162.891ZM512.5 162.5V175H525V162.5H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 162.891H525.391V174.609H537.109V162.891ZM525 162.5V175H537.5V162.5H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 162.891H537.891V174.609H549.609V162.891ZM537.5 162.5V175H550V162.5H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 162.891H550.391V174.609H562.109V162.891ZM550 162.5V175H562.5V162.5H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 162.891H562.891V174.609H574.609V162.891ZM562.5 162.5V175H575V162.5H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 162.891H575.391V174.609H587.109V162.891ZM575 162.5V175H587.5V162.5H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 162.891H587.891V174.609H599.609V162.891ZM587.5 162.5V175H600V162.5H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 175.391H0.390625V187.109H12.1094V175.391ZM0 175V187.5H12.5V175H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 175.391H12.8906V187.109H24.6094V175.391ZM12.5 175V187.5H25V175H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 175.391H25.3906V187.109H37.1094V175.391ZM25 175V187.5H37.5V175H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 175.391H37.8906V187.109H49.6094V175.391ZM37.5 175V187.5H50V175H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 175.391H50.3906V187.109H62.1094V175.391ZM50 175V187.5H62.5V175H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 175.391H62.8906V187.109H74.6094V175.391ZM62.5 175V187.5H75V175H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 175.391H75.3906V187.109H87.1094V175.391ZM75 175V187.5H87.5V175H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 175.391H87.8906V187.109H99.6094V175.391ZM87.5 175V187.5H100V175H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 175.391H100.391V187.109H112.109V175.391ZM100 175V187.5H112.5V175H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 175.391H112.891V187.109H124.609V175.391ZM112.5 175V187.5H125V175H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 175.391H125.391V187.109H137.109V175.391ZM125 175V187.5H137.5V175H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 175.391H137.891V187.109H149.609V175.391ZM137.5 175V187.5H150V175H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 175.391H150.391V187.109H162.109V175.391ZM150 175V187.5H162.5V175H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 175.391H162.891V187.109H174.609V175.391ZM162.5 175V187.5H175V175H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 175.391H175.391V187.109H187.109V175.391ZM175 175V187.5H187.5V175H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 175.391H187.891V187.109H199.609V175.391ZM187.5 175V187.5H200V175H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 175.391H200.391V187.109H212.109V175.391ZM200 175V187.5H212.5V175H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 175.391H212.891V187.109H224.609V175.391ZM212.5 175V187.5H225V175H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 175.391H225.391V187.109H237.109V175.391ZM225 175V187.5H237.5V175H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 175.391H237.891V187.109H249.609V175.391ZM237.5 175V187.5H250V175H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 175.391H250.391V187.109H262.109V175.391ZM250 175V187.5H262.5V175H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 175.391H262.891V187.109H274.609V175.391ZM262.5 175V187.5H275V175H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 175.391H275.391V187.109H287.109V175.391ZM275 175V187.5H287.5V175H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 175.391H287.891V187.109H299.609V175.391ZM287.5 175V187.5H300V175H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 175.391H300.391V187.109H312.109V175.391ZM300 175V187.5H312.5V175H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 175.391H312.891V187.109H324.609V175.391ZM312.5 175V187.5H325V175H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 175.391H325.391V187.109H337.109V175.391ZM325 175V187.5H337.5V175H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 175.391H337.891V187.109H349.609V175.391ZM337.5 175V187.5H350V175H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 175.391H350.391V187.109H362.109V175.391ZM350 175V187.5H362.5V175H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 175.391H362.891V187.109H374.609V175.391ZM362.5 175V187.5H375V175H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 175.391H375.391V187.109H387.109V175.391ZM375 175V187.5H387.5V175H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 175.391H387.891V187.109H399.609V175.391ZM387.5 175V187.5H400V175H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 175.391H400.391V187.109H412.109V175.391ZM400 175V187.5H412.5V175H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 175.391H412.891V187.109H424.609V175.391ZM412.5 175V187.5H425V175H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 175.391H425.391V187.109H437.109V175.391ZM425 175V187.5H437.5V175H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 175.391H437.891V187.109H449.609V175.391ZM437.5 175V187.5H450V175H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 175.391H450.391V187.109H462.109V175.391ZM450 175V187.5H462.5V175H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 175.391H462.891V187.109H474.609V175.391ZM462.5 175V187.5H475V175H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 175.391H475.391V187.109H487.109V175.391ZM475 175V187.5H487.5V175H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 175.391H487.891V187.109H499.609V175.391ZM487.5 175V187.5H500V175H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 175.391H500.391V187.109H512.109V175.391ZM500 175V187.5H512.5V175H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 175.391H512.891V187.109H524.609V175.391ZM512.5 175V187.5H525V175H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 175.391H525.391V187.109H537.109V175.391ZM525 175V187.5H537.5V175H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 175.391H537.891V187.109H549.609V175.391ZM537.5 175V187.5H550V175H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 175.391H550.391V187.109H562.109V175.391ZM550 175V187.5H562.5V175H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 175.391H562.891V187.109H574.609V175.391ZM562.5 175V187.5H575V175H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 175.391H575.391V187.109H587.109V175.391ZM575 175V187.5H587.5V175H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 175.391H587.891V187.109H599.609V175.391ZM587.5 175V187.5H600V175H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 187.891H0.390625V199.609H12.1094V187.891ZM0 187.5V200H12.5V187.5H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 187.891H12.8906V199.609H24.6094V187.891ZM12.5 187.5V200H25V187.5H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 187.891H25.3906V199.609H37.1094V187.891ZM25 187.5V200H37.5V187.5H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 187.891H37.8906V199.609H49.6094V187.891ZM37.5 187.5V200H50V187.5H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 187.891H50.3906V199.609H62.1094V187.891ZM50 187.5V200H62.5V187.5H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 187.891H62.8906V199.609H74.6094V187.891ZM62.5 187.5V200H75V187.5H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 187.891H75.3906V199.609H87.1094V187.891ZM75 187.5V200H87.5V187.5H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 187.891H87.8906V199.609H99.6094V187.891ZM87.5 187.5V200H100V187.5H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 187.891H100.391V199.609H112.109V187.891ZM100 187.5V200H112.5V187.5H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 187.891H112.891V199.609H124.609V187.891ZM112.5 187.5V200H125V187.5H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 187.891H125.391V199.609H137.109V187.891ZM125 187.5V200H137.5V187.5H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 187.891H137.891V199.609H149.609V187.891ZM137.5 187.5V200H150V187.5H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 187.891H150.391V199.609H162.109V187.891ZM150 187.5V200H162.5V187.5H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 187.891H162.891V199.609H174.609V187.891ZM162.5 187.5V200H175V187.5H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 187.891H175.391V199.609H187.109V187.891ZM175 187.5V200H187.5V187.5H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 187.891H187.891V199.609H199.609V187.891ZM187.5 187.5V200H200V187.5H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 187.891H200.391V199.609H212.109V187.891ZM200 187.5V200H212.5V187.5H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 187.891H212.891V199.609H224.609V187.891ZM212.5 187.5V200H225V187.5H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 187.891H225.391V199.609H237.109V187.891ZM225 187.5V200H237.5V187.5H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 187.891H237.891V199.609H249.609V187.891ZM237.5 187.5V200H250V187.5H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 187.891H250.391V199.609H262.109V187.891ZM250 187.5V200H262.5V187.5H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 187.891H262.891V199.609H274.609V187.891ZM262.5 187.5V200H275V187.5H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 187.891H275.391V199.609H287.109V187.891ZM275 187.5V200H287.5V187.5H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 187.891H287.891V199.609H299.609V187.891ZM287.5 187.5V200H300V187.5H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 187.891H300.391V199.609H312.109V187.891ZM300 187.5V200H312.5V187.5H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 187.891H312.891V199.609H324.609V187.891ZM312.5 187.5V200H325V187.5H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 187.891H325.391V199.609H337.109V187.891ZM325 187.5V200H337.5V187.5H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 187.891H337.891V199.609H349.609V187.891ZM337.5 187.5V200H350V187.5H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 187.891H350.391V199.609H362.109V187.891ZM350 187.5V200H362.5V187.5H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 187.891H362.891V199.609H374.609V187.891ZM362.5 187.5V200H375V187.5H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 187.891H375.391V199.609H387.109V187.891ZM375 187.5V200H387.5V187.5H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 187.891H387.891V199.609H399.609V187.891ZM387.5 187.5V200H400V187.5H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 187.891H400.391V199.609H412.109V187.891ZM400 187.5V200H412.5V187.5H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 187.891H412.891V199.609H424.609V187.891ZM412.5 187.5V200H425V187.5H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 187.891H425.391V199.609H437.109V187.891ZM425 187.5V200H437.5V187.5H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 187.891H437.891V199.609H449.609V187.891ZM437.5 187.5V200H450V187.5H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 187.891H450.391V199.609H462.109V187.891ZM450 187.5V200H462.5V187.5H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 187.891H462.891V199.609H474.609V187.891ZM462.5 187.5V200H475V187.5H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 187.891H475.391V199.609H487.109V187.891ZM475 187.5V200H487.5V187.5H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 187.891H487.891V199.609H499.609V187.891ZM487.5 187.5V200H500V187.5H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 187.891H500.391V199.609H512.109V187.891ZM500 187.5V200H512.5V187.5H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 187.891H512.891V199.609H524.609V187.891ZM512.5 187.5V200H525V187.5H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 187.891H525.391V199.609H537.109V187.891ZM525 187.5V200H537.5V187.5H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 187.891H537.891V199.609H549.609V187.891ZM537.5 187.5V200H550V187.5H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 187.891H550.391V199.609H562.109V187.891ZM550 187.5V200H562.5V187.5H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 187.891H562.891V199.609H574.609V187.891ZM562.5 187.5V200H575V187.5H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 187.891H575.391V199.609H587.109V187.891ZM575 187.5V200H587.5V187.5H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 187.891H587.891V199.609H599.609V187.891ZM587.5 187.5V200H600V187.5H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 200.391H0.390625V212.109H12.1094V200.391ZM0 200V212.5H12.5V200H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 200.391H12.8906V212.109H24.6094V200.391ZM12.5 200V212.5H25V200H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 200.391H25.3906V212.109H37.1094V200.391ZM25 200V212.5H37.5V200H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 200.391H37.8906V212.109H49.6094V200.391ZM37.5 200V212.5H50V200H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 200.391H50.3906V212.109H62.1094V200.391ZM50 200V212.5H62.5V200H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 200.391H62.8906V212.109H74.6094V200.391ZM62.5 200V212.5H75V200H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 200.391H75.3906V212.109H87.1094V200.391ZM75 200V212.5H87.5V200H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 200.391H87.8906V212.109H99.6094V200.391ZM87.5 200V212.5H100V200H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 200.391H100.391V212.109H112.109V200.391ZM100 200V212.5H112.5V200H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 200.391H112.891V212.109H124.609V200.391ZM112.5 200V212.5H125V200H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 200.391H125.391V212.109H137.109V200.391ZM125 200V212.5H137.5V200H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 200.391H137.891V212.109H149.609V200.391ZM137.5 200V212.5H150V200H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 200.391H150.391V212.109H162.109V200.391ZM150 200V212.5H162.5V200H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 200.391H162.891V212.109H174.609V200.391ZM162.5 200V212.5H175V200H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 200.391H175.391V212.109H187.109V200.391ZM175 200V212.5H187.5V200H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 200.391H187.891V212.109H199.609V200.391ZM187.5 200V212.5H200V200H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 200.391H200.391V212.109H212.109V200.391ZM200 200V212.5H212.5V200H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 200.391H212.891V212.109H224.609V200.391ZM212.5 200V212.5H225V200H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 200.391H225.391V212.109H237.109V200.391ZM225 200V212.5H237.5V200H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 200.391H237.891V212.109H249.609V200.391ZM237.5 200V212.5H250V200H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 200.391H250.391V212.109H262.109V200.391ZM250 200V212.5H262.5V200H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 200.391H262.891V212.109H274.609V200.391ZM262.5 200V212.5H275V200H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 200.391H275.391V212.109H287.109V200.391ZM275 200V212.5H287.5V200H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 200.391H287.891V212.109H299.609V200.391ZM287.5 200V212.5H300V200H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 200.391H300.391V212.109H312.109V200.391ZM300 200V212.5H312.5V200H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 200.391H312.891V212.109H324.609V200.391ZM312.5 200V212.5H325V200H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 200.391H325.391V212.109H337.109V200.391ZM325 200V212.5H337.5V200H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 200.391H337.891V212.109H349.609V200.391ZM337.5 200V212.5H350V200H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 200.391H350.391V212.109H362.109V200.391ZM350 200V212.5H362.5V200H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 200.391H362.891V212.109H374.609V200.391ZM362.5 200V212.5H375V200H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 200.391H375.391V212.109H387.109V200.391ZM375 200V212.5H387.5V200H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 200.391H387.891V212.109H399.609V200.391ZM387.5 200V212.5H400V200H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 200.391H400.391V212.109H412.109V200.391ZM400 200V212.5H412.5V200H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 200.391H412.891V212.109H424.609V200.391ZM412.5 200V212.5H425V200H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 200.391H425.391V212.109H437.109V200.391ZM425 200V212.5H437.5V200H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 200.391H437.891V212.109H449.609V200.391ZM437.5 200V212.5H450V200H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 200.391H450.391V212.109H462.109V200.391ZM450 200V212.5H462.5V200H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 200.391H462.891V212.109H474.609V200.391ZM462.5 200V212.5H475V200H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 200.391H475.391V212.109H487.109V200.391ZM475 200V212.5H487.5V200H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 200.391H487.891V212.109H499.609V200.391ZM487.5 200V212.5H500V200H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 200.391H500.391V212.109H512.109V200.391ZM500 200V212.5H512.5V200H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 200.391H512.891V212.109H524.609V200.391ZM512.5 200V212.5H525V200H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 200.391H525.391V212.109H537.109V200.391ZM525 200V212.5H537.5V200H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 200.391H537.891V212.109H549.609V200.391ZM537.5 200V212.5H550V200H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 200.391H550.391V212.109H562.109V200.391ZM550 200V212.5H562.5V200H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 200.391H562.891V212.109H574.609V200.391ZM562.5 200V212.5H575V200H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 200.391H575.391V212.109H587.109V200.391ZM575 200V212.5H587.5V200H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 200.391H587.891V212.109H599.609V200.391ZM587.5 200V212.5H600V200H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 212.891H0.390625V224.609H12.1094V212.891ZM0 212.5V225H12.5V212.5H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 212.891H12.8906V224.609H24.6094V212.891ZM12.5 212.5V225H25V212.5H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 212.891H25.3906V224.609H37.1094V212.891ZM25 212.5V225H37.5V212.5H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 212.891H37.8906V224.609H49.6094V212.891ZM37.5 212.5V225H50V212.5H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 212.891H50.3906V224.609H62.1094V212.891ZM50 212.5V225H62.5V212.5H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 212.891H62.8906V224.609H74.6094V212.891ZM62.5 212.5V225H75V212.5H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 212.891H75.3906V224.609H87.1094V212.891ZM75 212.5V225H87.5V212.5H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 212.891H87.8906V224.609H99.6094V212.891ZM87.5 212.5V225H100V212.5H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 212.891H100.391V224.609H112.109V212.891ZM100 212.5V225H112.5V212.5H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 212.891H112.891V224.609H124.609V212.891ZM112.5 212.5V225H125V212.5H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 212.891H125.391V224.609H137.109V212.891ZM125 212.5V225H137.5V212.5H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 212.891H137.891V224.609H149.609V212.891ZM137.5 212.5V225H150V212.5H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 212.891H150.391V224.609H162.109V212.891ZM150 212.5V225H162.5V212.5H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 212.891H162.891V224.609H174.609V212.891ZM162.5 212.5V225H175V212.5H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 212.891H175.391V224.609H187.109V212.891ZM175 212.5V225H187.5V212.5H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 212.891H187.891V224.609H199.609V212.891ZM187.5 212.5V225H200V212.5H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 212.891H200.391V224.609H212.109V212.891ZM200 212.5V225H212.5V212.5H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 212.891H212.891V224.609H224.609V212.891ZM212.5 212.5V225H225V212.5H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 212.891H225.391V224.609H237.109V212.891ZM225 212.5V225H237.5V212.5H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 212.891H237.891V224.609H249.609V212.891ZM237.5 212.5V225H250V212.5H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 212.891H250.391V224.609H262.109V212.891ZM250 212.5V225H262.5V212.5H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 212.891H262.891V224.609H274.609V212.891ZM262.5 212.5V225H275V212.5H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 212.891H275.391V224.609H287.109V212.891ZM275 212.5V225H287.5V212.5H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 212.891H287.891V224.609H299.609V212.891ZM287.5 212.5V225H300V212.5H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 212.891H300.391V224.609H312.109V212.891ZM300 212.5V225H312.5V212.5H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 212.891H312.891V224.609H324.609V212.891ZM312.5 212.5V225H325V212.5H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 212.891H325.391V224.609H337.109V212.891ZM325 212.5V225H337.5V212.5H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 212.891H337.891V224.609H349.609V212.891ZM337.5 212.5V225H350V212.5H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 212.891H350.391V224.609H362.109V212.891ZM350 212.5V225H362.5V212.5H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 212.891H362.891V224.609H374.609V212.891ZM362.5 212.5V225H375V212.5H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 212.891H375.391V224.609H387.109V212.891ZM375 212.5V225H387.5V212.5H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 212.891H387.891V224.609H399.609V212.891ZM387.5 212.5V225H400V212.5H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 212.891H400.391V224.609H412.109V212.891ZM400 212.5V225H412.5V212.5H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 212.891H412.891V224.609H424.609V212.891ZM412.5 212.5V225H425V212.5H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 212.891H425.391V224.609H437.109V212.891ZM425 212.5V225H437.5V212.5H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 212.891H437.891V224.609H449.609V212.891ZM437.5 212.5V225H450V212.5H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 212.891H450.391V224.609H462.109V212.891ZM450 212.5V225H462.5V212.5H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 212.891H462.891V224.609H474.609V212.891ZM462.5 212.5V225H475V212.5H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 212.891H475.391V224.609H487.109V212.891ZM475 212.5V225H487.5V212.5H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 212.891H487.891V224.609H499.609V212.891ZM487.5 212.5V225H500V212.5H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 212.891H500.391V224.609H512.109V212.891ZM500 212.5V225H512.5V212.5H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 212.891H512.891V224.609H524.609V212.891ZM512.5 212.5V225H525V212.5H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 212.891H525.391V224.609H537.109V212.891ZM525 212.5V225H537.5V212.5H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 212.891H537.891V224.609H549.609V212.891ZM537.5 212.5V225H550V212.5H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 212.891H550.391V224.609H562.109V212.891ZM550 212.5V225H562.5V212.5H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 212.891H562.891V224.609H574.609V212.891ZM562.5 212.5V225H575V212.5H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 212.891H575.391V224.609H587.109V212.891ZM575 212.5V225H587.5V212.5H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 212.891H587.891V224.609H599.609V212.891ZM587.5 212.5V225H600V212.5H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 225.391H0.390625V237.109H12.1094V225.391ZM0 225V237.5H12.5V225H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 225.391H12.8906V237.109H24.6094V225.391ZM12.5 225V237.5H25V225H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 225.391H25.3906V237.109H37.1094V225.391ZM25 225V237.5H37.5V225H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 225.391H37.8906V237.109H49.6094V225.391ZM37.5 225V237.5H50V225H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 225.391H50.3906V237.109H62.1094V225.391ZM50 225V237.5H62.5V225H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 225.391H62.8906V237.109H74.6094V225.391ZM62.5 225V237.5H75V225H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 225.391H75.3906V237.109H87.1094V225.391ZM75 225V237.5H87.5V225H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 225.391H87.8906V237.109H99.6094V225.391ZM87.5 225V237.5H100V225H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 225.391H100.391V237.109H112.109V225.391ZM100 225V237.5H112.5V225H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 225.391H112.891V237.109H124.609V225.391ZM112.5 225V237.5H125V225H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 225.391H125.391V237.109H137.109V225.391ZM125 225V237.5H137.5V225H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 225.391H137.891V237.109H149.609V225.391ZM137.5 225V237.5H150V225H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 225.391H150.391V237.109H162.109V225.391ZM150 225V237.5H162.5V225H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 225.391H162.891V237.109H174.609V225.391ZM162.5 225V237.5H175V225H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 225.391H175.391V237.109H187.109V225.391ZM175 225V237.5H187.5V225H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 225.391H187.891V237.109H199.609V225.391ZM187.5 225V237.5H200V225H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 225.391H200.391V237.109H212.109V225.391ZM200 225V237.5H212.5V225H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 225.391H212.891V237.109H224.609V225.391ZM212.5 225V237.5H225V225H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 225.391H225.391V237.109H237.109V225.391ZM225 225V237.5H237.5V225H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 225.391H237.891V237.109H249.609V225.391ZM237.5 225V237.5H250V225H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 225.391H250.391V237.109H262.109V225.391ZM250 225V237.5H262.5V225H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 225.391H262.891V237.109H274.609V225.391ZM262.5 225V237.5H275V225H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 225.391H275.391V237.109H287.109V225.391ZM275 225V237.5H287.5V225H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 225.391H287.891V237.109H299.609V225.391ZM287.5 225V237.5H300V225H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 225.391H300.391V237.109H312.109V225.391ZM300 225V237.5H312.5V225H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 225.391H312.891V237.109H324.609V225.391ZM312.5 225V237.5H325V225H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 225.391H325.391V237.109H337.109V225.391ZM325 225V237.5H337.5V225H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 225.391H337.891V237.109H349.609V225.391ZM337.5 225V237.5H350V225H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 225.391H350.391V237.109H362.109V225.391ZM350 225V237.5H362.5V225H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 225.391H362.891V237.109H374.609V225.391ZM362.5 225V237.5H375V225H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 225.391H375.391V237.109H387.109V225.391ZM375 225V237.5H387.5V225H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 225.391H387.891V237.109H399.609V225.391ZM387.5 225V237.5H400V225H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 225.391H400.391V237.109H412.109V225.391ZM400 225V237.5H412.5V225H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 225.391H412.891V237.109H424.609V225.391ZM412.5 225V237.5H425V225H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 225.391H425.391V237.109H437.109V225.391ZM425 225V237.5H437.5V225H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 225.391H437.891V237.109H449.609V225.391ZM437.5 225V237.5H450V225H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 225.391H450.391V237.109H462.109V225.391ZM450 225V237.5H462.5V225H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 225.391H462.891V237.109H474.609V225.391ZM462.5 225V237.5H475V225H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 225.391H475.391V237.109H487.109V225.391ZM475 225V237.5H487.5V225H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 225.391H487.891V237.109H499.609V225.391ZM487.5 225V237.5H500V225H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 225.391H500.391V237.109H512.109V225.391ZM500 225V237.5H512.5V225H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 225.391H512.891V237.109H524.609V225.391ZM512.5 225V237.5H525V225H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 225.391H525.391V237.109H537.109V225.391ZM525 225V237.5H537.5V225H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 225.391H537.891V237.109H549.609V225.391ZM537.5 225V237.5H550V225H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 225.391H550.391V237.109H562.109V225.391ZM550 225V237.5H562.5V225H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 225.391H562.891V237.109H574.609V225.391ZM562.5 225V237.5H575V225H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 225.391H575.391V237.109H587.109V225.391ZM575 225V237.5H587.5V225H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 225.391H587.891V237.109H599.609V225.391ZM587.5 225V237.5H600V225H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 237.891H0.390625V249.609H12.1094V237.891ZM0 237.5V250H12.5V237.5H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 237.891H12.8906V249.609H24.6094V237.891ZM12.5 237.5V250H25V237.5H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 237.891H25.3906V249.609H37.1094V237.891ZM25 237.5V250H37.5V237.5H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 237.891H37.8906V249.609H49.6094V237.891ZM37.5 237.5V250H50V237.5H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 237.891H50.3906V249.609H62.1094V237.891ZM50 237.5V250H62.5V237.5H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 237.891H62.8906V249.609H74.6094V237.891ZM62.5 237.5V250H75V237.5H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 237.891H75.3906V249.609H87.1094V237.891ZM75 237.5V250H87.5V237.5H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 237.891H87.8906V249.609H99.6094V237.891ZM87.5 237.5V250H100V237.5H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 237.891H100.391V249.609H112.109V237.891ZM100 237.5V250H112.5V237.5H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 237.891H112.891V249.609H124.609V237.891ZM112.5 237.5V250H125V237.5H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 237.891H125.391V249.609H137.109V237.891ZM125 237.5V250H137.5V237.5H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 237.891H137.891V249.609H149.609V237.891ZM137.5 237.5V250H150V237.5H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 237.891H150.391V249.609H162.109V237.891ZM150 237.5V250H162.5V237.5H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 237.891H162.891V249.609H174.609V237.891ZM162.5 237.5V250H175V237.5H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 237.891H175.391V249.609H187.109V237.891ZM175 237.5V250H187.5V237.5H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 237.891H187.891V249.609H199.609V237.891ZM187.5 237.5V250H200V237.5H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 237.891H200.391V249.609H212.109V237.891ZM200 237.5V250H212.5V237.5H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 237.891H212.891V249.609H224.609V237.891ZM212.5 237.5V250H225V237.5H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 237.891H225.391V249.609H237.109V237.891ZM225 237.5V250H237.5V237.5H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 237.891H237.891V249.609H249.609V237.891ZM237.5 237.5V250H250V237.5H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 237.891H250.391V249.609H262.109V237.891ZM250 237.5V250H262.5V237.5H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 237.891H262.891V249.609H274.609V237.891ZM262.5 237.5V250H275V237.5H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 237.891H275.391V249.609H287.109V237.891ZM275 237.5V250H287.5V237.5H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 237.891H287.891V249.609H299.609V237.891ZM287.5 237.5V250H300V237.5H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 237.891H300.391V249.609H312.109V237.891ZM300 237.5V250H312.5V237.5H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 237.891H312.891V249.609H324.609V237.891ZM312.5 237.5V250H325V237.5H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 237.891H325.391V249.609H337.109V237.891ZM325 237.5V250H337.5V237.5H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 237.891H337.891V249.609H349.609V237.891ZM337.5 237.5V250H350V237.5H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 237.891H350.391V249.609H362.109V237.891ZM350 237.5V250H362.5V237.5H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 237.891H362.891V249.609H374.609V237.891ZM362.5 237.5V250H375V237.5H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 237.891H375.391V249.609H387.109V237.891ZM375 237.5V250H387.5V237.5H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 237.891H387.891V249.609H399.609V237.891ZM387.5 237.5V250H400V237.5H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 237.891H400.391V249.609H412.109V237.891ZM400 237.5V250H412.5V237.5H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 237.891H412.891V249.609H424.609V237.891ZM412.5 237.5V250H425V237.5H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 237.891H425.391V249.609H437.109V237.891ZM425 237.5V250H437.5V237.5H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 237.891H437.891V249.609H449.609V237.891ZM437.5 237.5V250H450V237.5H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 237.891H450.391V249.609H462.109V237.891ZM450 237.5V250H462.5V237.5H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 237.891H462.891V249.609H474.609V237.891ZM462.5 237.5V250H475V237.5H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 237.891H475.391V249.609H487.109V237.891ZM475 237.5V250H487.5V237.5H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 237.891H487.891V249.609H499.609V237.891ZM487.5 237.5V250H500V237.5H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 237.891H500.391V249.609H512.109V237.891ZM500 237.5V250H512.5V237.5H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 237.891H512.891V249.609H524.609V237.891ZM512.5 237.5V250H525V237.5H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 237.891H525.391V249.609H537.109V237.891ZM525 237.5V250H537.5V237.5H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 237.891H537.891V249.609H549.609V237.891ZM537.5 237.5V250H550V237.5H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 237.891H550.391V249.609H562.109V237.891ZM550 237.5V250H562.5V237.5H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 237.891H562.891V249.609H574.609V237.891ZM562.5 237.5V250H575V237.5H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 237.891H575.391V249.609H587.109V237.891ZM575 237.5V250H587.5V237.5H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 237.891H587.891V249.609H599.609V237.891ZM587.5 237.5V250H600V237.5H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 250.391H0.390625V262.109H12.1094V250.391ZM0 250V262.5H12.5V250H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 250.391H12.8906V262.109H24.6094V250.391ZM12.5 250V262.5H25V250H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 250.391H25.3906V262.109H37.1094V250.391ZM25 250V262.5H37.5V250H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 250.391H37.8906V262.109H49.6094V250.391ZM37.5 250V262.5H50V250H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 250.391H50.3906V262.109H62.1094V250.391ZM50 250V262.5H62.5V250H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 250.391H62.8906V262.109H74.6094V250.391ZM62.5 250V262.5H75V250H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 250.391H75.3906V262.109H87.1094V250.391ZM75 250V262.5H87.5V250H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 250.391H87.8906V262.109H99.6094V250.391ZM87.5 250V262.5H100V250H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 250.391H100.391V262.109H112.109V250.391ZM100 250V262.5H112.5V250H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 250.391H112.891V262.109H124.609V250.391ZM112.5 250V262.5H125V250H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 250.391H125.391V262.109H137.109V250.391ZM125 250V262.5H137.5V250H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 250.391H137.891V262.109H149.609V250.391ZM137.5 250V262.5H150V250H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 250.391H150.391V262.109H162.109V250.391ZM150 250V262.5H162.5V250H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 250.391H162.891V262.109H174.609V250.391ZM162.5 250V262.5H175V250H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 250.391H175.391V262.109H187.109V250.391ZM175 250V262.5H187.5V250H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 250.391H187.891V262.109H199.609V250.391ZM187.5 250V262.5H200V250H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 250.391H200.391V262.109H212.109V250.391ZM200 250V262.5H212.5V250H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 250.391H212.891V262.109H224.609V250.391ZM212.5 250V262.5H225V250H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 250.391H225.391V262.109H237.109V250.391ZM225 250V262.5H237.5V250H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 250.391H237.891V262.109H249.609V250.391ZM237.5 250V262.5H250V250H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 250.391H250.391V262.109H262.109V250.391ZM250 250V262.5H262.5V250H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 250.391H262.891V262.109H274.609V250.391ZM262.5 250V262.5H275V250H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 250.391H275.391V262.109H287.109V250.391ZM275 250V262.5H287.5V250H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 250.391H287.891V262.109H299.609V250.391ZM287.5 250V262.5H300V250H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 250.391H300.391V262.109H312.109V250.391ZM300 250V262.5H312.5V250H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 250.391H312.891V262.109H324.609V250.391ZM312.5 250V262.5H325V250H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 250.391H325.391V262.109H337.109V250.391ZM325 250V262.5H337.5V250H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 250.391H337.891V262.109H349.609V250.391ZM337.5 250V262.5H350V250H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 250.391H350.391V262.109H362.109V250.391ZM350 250V262.5H362.5V250H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 250.391H362.891V262.109H374.609V250.391ZM362.5 250V262.5H375V250H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 250.391H375.391V262.109H387.109V250.391ZM375 250V262.5H387.5V250H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 250.391H387.891V262.109H399.609V250.391ZM387.5 250V262.5H400V250H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 250.391H400.391V262.109H412.109V250.391ZM400 250V262.5H412.5V250H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 250.391H412.891V262.109H424.609V250.391ZM412.5 250V262.5H425V250H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 250.391H425.391V262.109H437.109V250.391ZM425 250V262.5H437.5V250H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 250.391H437.891V262.109H449.609V250.391ZM437.5 250V262.5H450V250H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 250.391H450.391V262.109H462.109V250.391ZM450 250V262.5H462.5V250H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 250.391H462.891V262.109H474.609V250.391ZM462.5 250V262.5H475V250H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 250.391H475.391V262.109H487.109V250.391ZM475 250V262.5H487.5V250H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 250.391H487.891V262.109H499.609V250.391ZM487.5 250V262.5H500V250H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 250.391H500.391V262.109H512.109V250.391ZM500 250V262.5H512.5V250H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 250.391H512.891V262.109H524.609V250.391ZM512.5 250V262.5H525V250H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 250.391H525.391V262.109H537.109V250.391ZM525 250V262.5H537.5V250H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 250.391H537.891V262.109H549.609V250.391ZM537.5 250V262.5H550V250H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 250.391H550.391V262.109H562.109V250.391ZM550 250V262.5H562.5V250H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 250.391H562.891V262.109H574.609V250.391ZM562.5 250V262.5H575V250H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 250.391H575.391V262.109H587.109V250.391ZM575 250V262.5H587.5V250H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 250.391H587.891V262.109H599.609V250.391ZM587.5 250V262.5H600V250H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 262.891H0.390625V274.609H12.1094V262.891ZM0 262.5V275H12.5V262.5H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 262.891H12.8906V274.609H24.6094V262.891ZM12.5 262.5V275H25V262.5H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 262.891H25.3906V274.609H37.1094V262.891ZM25 262.5V275H37.5V262.5H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 262.891H37.8906V274.609H49.6094V262.891ZM37.5 262.5V275H50V262.5H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 262.891H50.3906V274.609H62.1094V262.891ZM50 262.5V275H62.5V262.5H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 262.891H62.8906V274.609H74.6094V262.891ZM62.5 262.5V275H75V262.5H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 262.891H75.3906V274.609H87.1094V262.891ZM75 262.5V275H87.5V262.5H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 262.891H87.8906V274.609H99.6094V262.891ZM87.5 262.5V275H100V262.5H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 262.891H100.391V274.609H112.109V262.891ZM100 262.5V275H112.5V262.5H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 262.891H112.891V274.609H124.609V262.891ZM112.5 262.5V275H125V262.5H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 262.891H125.391V274.609H137.109V262.891ZM125 262.5V275H137.5V262.5H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 262.891H137.891V274.609H149.609V262.891ZM137.5 262.5V275H150V262.5H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 262.891H150.391V274.609H162.109V262.891ZM150 262.5V275H162.5V262.5H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 262.891H162.891V274.609H174.609V262.891ZM162.5 262.5V275H175V262.5H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 262.891H175.391V274.609H187.109V262.891ZM175 262.5V275H187.5V262.5H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 262.891H187.891V274.609H199.609V262.891ZM187.5 262.5V275H200V262.5H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 262.891H200.391V274.609H212.109V262.891ZM200 262.5V275H212.5V262.5H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 262.891H212.891V274.609H224.609V262.891ZM212.5 262.5V275H225V262.5H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 262.891H225.391V274.609H237.109V262.891ZM225 262.5V275H237.5V262.5H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 262.891H237.891V274.609H249.609V262.891ZM237.5 262.5V275H250V262.5H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 262.891H250.391V274.609H262.109V262.891ZM250 262.5V275H262.5V262.5H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 262.891H262.891V274.609H274.609V262.891ZM262.5 262.5V275H275V262.5H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 262.891H275.391V274.609H287.109V262.891ZM275 262.5V275H287.5V262.5H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 262.891H287.891V274.609H299.609V262.891ZM287.5 262.5V275H300V262.5H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 262.891H300.391V274.609H312.109V262.891ZM300 262.5V275H312.5V262.5H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 262.891H312.891V274.609H324.609V262.891ZM312.5 262.5V275H325V262.5H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 262.891H325.391V274.609H337.109V262.891ZM325 262.5V275H337.5V262.5H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 262.891H337.891V274.609H349.609V262.891ZM337.5 262.5V275H350V262.5H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 262.891H350.391V274.609H362.109V262.891ZM350 262.5V275H362.5V262.5H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 262.891H362.891V274.609H374.609V262.891ZM362.5 262.5V275H375V262.5H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 262.891H375.391V274.609H387.109V262.891ZM375 262.5V275H387.5V262.5H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 262.891H387.891V274.609H399.609V262.891ZM387.5 262.5V275H400V262.5H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 262.891H400.391V274.609H412.109V262.891ZM400 262.5V275H412.5V262.5H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 262.891H412.891V274.609H424.609V262.891ZM412.5 262.5V275H425V262.5H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 262.891H425.391V274.609H437.109V262.891ZM425 262.5V275H437.5V262.5H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 262.891H437.891V274.609H449.609V262.891ZM437.5 262.5V275H450V262.5H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 262.891H450.391V274.609H462.109V262.891ZM450 262.5V275H462.5V262.5H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 262.891H462.891V274.609H474.609V262.891ZM462.5 262.5V275H475V262.5H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 262.891H475.391V274.609H487.109V262.891ZM475 262.5V275H487.5V262.5H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 262.891H487.891V274.609H499.609V262.891ZM487.5 262.5V275H500V262.5H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 262.891H500.391V274.609H512.109V262.891ZM500 262.5V275H512.5V262.5H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 262.891H512.891V274.609H524.609V262.891ZM512.5 262.5V275H525V262.5H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 262.891H525.391V274.609H537.109V262.891ZM525 262.5V275H537.5V262.5H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 262.891H537.891V274.609H549.609V262.891ZM537.5 262.5V275H550V262.5H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 262.891H550.391V274.609H562.109V262.891ZM550 262.5V275H562.5V262.5H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 262.891H562.891V274.609H574.609V262.891ZM562.5 262.5V275H575V262.5H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 262.891H575.391V274.609H587.109V262.891ZM575 262.5V275H587.5V262.5H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 262.891H587.891V274.609H599.609V262.891ZM587.5 262.5V275H600V262.5H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 275.391H0.390625V287.109H12.1094V275.391ZM0 275V287.5H12.5V275H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 275.391H12.8906V287.109H24.6094V275.391ZM12.5 275V287.5H25V275H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 275.391H25.3906V287.109H37.1094V275.391ZM25 275V287.5H37.5V275H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 275.391H37.8906V287.109H49.6094V275.391ZM37.5 275V287.5H50V275H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 275.391H50.3906V287.109H62.1094V275.391ZM50 275V287.5H62.5V275H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 275.391H62.8906V287.109H74.6094V275.391ZM62.5 275V287.5H75V275H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 275.391H75.3906V287.109H87.1094V275.391ZM75 275V287.5H87.5V275H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 275.391H87.8906V287.109H99.6094V275.391ZM87.5 275V287.5H100V275H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 275.391H100.391V287.109H112.109V275.391ZM100 275V287.5H112.5V275H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 275.391H112.891V287.109H124.609V275.391ZM112.5 275V287.5H125V275H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 275.391H125.391V287.109H137.109V275.391ZM125 275V287.5H137.5V275H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 275.391H137.891V287.109H149.609V275.391ZM137.5 275V287.5H150V275H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 275.391H150.391V287.109H162.109V275.391ZM150 275V287.5H162.5V275H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 275.391H162.891V287.109H174.609V275.391ZM162.5 275V287.5H175V275H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 275.391H175.391V287.109H187.109V275.391ZM175 275V287.5H187.5V275H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 275.391H187.891V287.109H199.609V275.391ZM187.5 275V287.5H200V275H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 275.391H200.391V287.109H212.109V275.391ZM200 275V287.5H212.5V275H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 275.391H212.891V287.109H224.609V275.391ZM212.5 275V287.5H225V275H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 275.391H225.391V287.109H237.109V275.391ZM225 275V287.5H237.5V275H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 275.391H237.891V287.109H249.609V275.391ZM237.5 275V287.5H250V275H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 275.391H250.391V287.109H262.109V275.391ZM250 275V287.5H262.5V275H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 275.391H262.891V287.109H274.609V275.391ZM262.5 275V287.5H275V275H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 275.391H275.391V287.109H287.109V275.391ZM275 275V287.5H287.5V275H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 275.391H287.891V287.109H299.609V275.391ZM287.5 275V287.5H300V275H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 275.391H300.391V287.109H312.109V275.391ZM300 275V287.5H312.5V275H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 275.391H312.891V287.109H324.609V275.391ZM312.5 275V287.5H325V275H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 275.391H325.391V287.109H337.109V275.391ZM325 275V287.5H337.5V275H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 275.391H337.891V287.109H349.609V275.391ZM337.5 275V287.5H350V275H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 275.391H350.391V287.109H362.109V275.391ZM350 275V287.5H362.5V275H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 275.391H362.891V287.109H374.609V275.391ZM362.5 275V287.5H375V275H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 275.391H375.391V287.109H387.109V275.391ZM375 275V287.5H387.5V275H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 275.391H387.891V287.109H399.609V275.391ZM387.5 275V287.5H400V275H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 275.391H400.391V287.109H412.109V275.391ZM400 275V287.5H412.5V275H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 275.391H412.891V287.109H424.609V275.391ZM412.5 275V287.5H425V275H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 275.391H425.391V287.109H437.109V275.391ZM425 275V287.5H437.5V275H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 275.391H437.891V287.109H449.609V275.391ZM437.5 275V287.5H450V275H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 275.391H450.391V287.109H462.109V275.391ZM450 275V287.5H462.5V275H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 275.391H462.891V287.109H474.609V275.391ZM462.5 275V287.5H475V275H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 275.391H475.391V287.109H487.109V275.391ZM475 275V287.5H487.5V275H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 275.391H487.891V287.109H499.609V275.391ZM487.5 275V287.5H500V275H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 275.391H500.391V287.109H512.109V275.391ZM500 275V287.5H512.5V275H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 275.391H512.891V287.109H524.609V275.391ZM512.5 275V287.5H525V275H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 275.391H525.391V287.109H537.109V275.391ZM525 275V287.5H537.5V275H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 275.391H537.891V287.109H549.609V275.391ZM537.5 275V287.5H550V275H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 275.391H550.391V287.109H562.109V275.391ZM550 275V287.5H562.5V275H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 275.391H562.891V287.109H574.609V275.391ZM562.5 275V287.5H575V275H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 275.391H575.391V287.109H587.109V275.391ZM575 275V287.5H587.5V275H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 275.391H587.891V287.109H599.609V275.391ZM587.5 275V287.5H600V275H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 287.891H0.390625V299.609H12.1094V287.891ZM0 287.5V300H12.5V287.5H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 287.891H12.8906V299.609H24.6094V287.891ZM12.5 287.5V300H25V287.5H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 287.891H25.3906V299.609H37.1094V287.891ZM25 287.5V300H37.5V287.5H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 287.891H37.8906V299.609H49.6094V287.891ZM37.5 287.5V300H50V287.5H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 287.891H50.3906V299.609H62.1094V287.891ZM50 287.5V300H62.5V287.5H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 287.891H62.8906V299.609H74.6094V287.891ZM62.5 287.5V300H75V287.5H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 287.891H75.3906V299.609H87.1094V287.891ZM75 287.5V300H87.5V287.5H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 287.891H87.8906V299.609H99.6094V287.891ZM87.5 287.5V300H100V287.5H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 287.891H100.391V299.609H112.109V287.891ZM100 287.5V300H112.5V287.5H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 287.891H112.891V299.609H124.609V287.891ZM112.5 287.5V300H125V287.5H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 287.891H125.391V299.609H137.109V287.891ZM125 287.5V300H137.5V287.5H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 287.891H137.891V299.609H149.609V287.891ZM137.5 287.5V300H150V287.5H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 287.891H150.391V299.609H162.109V287.891ZM150 287.5V300H162.5V287.5H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 287.891H162.891V299.609H174.609V287.891ZM162.5 287.5V300H175V287.5H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 287.891H175.391V299.609H187.109V287.891ZM175 287.5V300H187.5V287.5H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 287.891H187.891V299.609H199.609V287.891ZM187.5 287.5V300H200V287.5H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 287.891H200.391V299.609H212.109V287.891ZM200 287.5V300H212.5V287.5H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 287.891H212.891V299.609H224.609V287.891ZM212.5 287.5V300H225V287.5H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 287.891H225.391V299.609H237.109V287.891ZM225 287.5V300H237.5V287.5H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 287.891H237.891V299.609H249.609V287.891ZM237.5 287.5V300H250V287.5H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 287.891H250.391V299.609H262.109V287.891ZM250 287.5V300H262.5V287.5H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 287.891H262.891V299.609H274.609V287.891ZM262.5 287.5V300H275V287.5H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 287.891H275.391V299.609H287.109V287.891ZM275 287.5V300H287.5V287.5H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 287.891H287.891V299.609H299.609V287.891ZM287.5 287.5V300H300V287.5H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 287.891H300.391V299.609H312.109V287.891ZM300 287.5V300H312.5V287.5H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 287.891H312.891V299.609H324.609V287.891ZM312.5 287.5V300H325V287.5H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 287.891H325.391V299.609H337.109V287.891ZM325 287.5V300H337.5V287.5H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 287.891H337.891V299.609H349.609V287.891ZM337.5 287.5V300H350V287.5H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 287.891H350.391V299.609H362.109V287.891ZM350 287.5V300H362.5V287.5H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 287.891H362.891V299.609H374.609V287.891ZM362.5 287.5V300H375V287.5H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 287.891H375.391V299.609H387.109V287.891ZM375 287.5V300H387.5V287.5H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 287.891H387.891V299.609H399.609V287.891ZM387.5 287.5V300H400V287.5H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 287.891H400.391V299.609H412.109V287.891ZM400 287.5V300H412.5V287.5H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 287.891H412.891V299.609H424.609V287.891ZM412.5 287.5V300H425V287.5H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 287.891H425.391V299.609H437.109V287.891ZM425 287.5V300H437.5V287.5H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 287.891H437.891V299.609H449.609V287.891ZM437.5 287.5V300H450V287.5H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 287.891H450.391V299.609H462.109V287.891ZM450 287.5V300H462.5V287.5H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 287.891H462.891V299.609H474.609V287.891ZM462.5 287.5V300H475V287.5H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 287.891H475.391V299.609H487.109V287.891ZM475 287.5V300H487.5V287.5H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 287.891H487.891V299.609H499.609V287.891ZM487.5 287.5V300H500V287.5H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 287.891H500.391V299.609H512.109V287.891ZM500 287.5V300H512.5V287.5H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 287.891H512.891V299.609H524.609V287.891ZM512.5 287.5V300H525V287.5H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 287.891H525.391V299.609H537.109V287.891ZM525 287.5V300H537.5V287.5H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 287.891H537.891V299.609H549.609V287.891ZM537.5 287.5V300H550V287.5H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 287.891H550.391V299.609H562.109V287.891ZM550 287.5V300H562.5V287.5H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 287.891H562.891V299.609H574.609V287.891ZM562.5 287.5V300H575V287.5H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 287.891H575.391V299.609H587.109V287.891ZM575 287.5V300H587.5V287.5H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 287.891H587.891V299.609H599.609V287.891ZM587.5 287.5V300H600V287.5H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 300.391H0.390625V312.109H12.1094V300.391ZM0 300V312.5H12.5V300H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 300.391H12.8906V312.109H24.6094V300.391ZM12.5 300V312.5H25V300H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 300.391H25.3906V312.109H37.1094V300.391ZM25 300V312.5H37.5V300H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 300.391H37.8906V312.109H49.6094V300.391ZM37.5 300V312.5H50V300H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 300.391H50.3906V312.109H62.1094V300.391ZM50 300V312.5H62.5V300H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 300.391H62.8906V312.109H74.6094V300.391ZM62.5 300V312.5H75V300H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 300.391H75.3906V312.109H87.1094V300.391ZM75 300V312.5H87.5V300H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 300.391H87.8906V312.109H99.6094V300.391ZM87.5 300V312.5H100V300H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 300.391H100.391V312.109H112.109V300.391ZM100 300V312.5H112.5V300H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 300.391H112.891V312.109H124.609V300.391ZM112.5 300V312.5H125V300H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 300.391H125.391V312.109H137.109V300.391ZM125 300V312.5H137.5V300H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 300.391H137.891V312.109H149.609V300.391ZM137.5 300V312.5H150V300H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 300.391H150.391V312.109H162.109V300.391ZM150 300V312.5H162.5V300H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 300.391H162.891V312.109H174.609V300.391ZM162.5 300V312.5H175V300H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 300.391H175.391V312.109H187.109V300.391ZM175 300V312.5H187.5V300H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 300.391H187.891V312.109H199.609V300.391ZM187.5 300V312.5H200V300H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 300.391H200.391V312.109H212.109V300.391ZM200 300V312.5H212.5V300H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 300.391H212.891V312.109H224.609V300.391ZM212.5 300V312.5H225V300H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 300.391H225.391V312.109H237.109V300.391ZM225 300V312.5H237.5V300H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 300.391H237.891V312.109H249.609V300.391ZM237.5 300V312.5H250V300H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 300.391H250.391V312.109H262.109V300.391ZM250 300V312.5H262.5V300H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 300.391H262.891V312.109H274.609V300.391ZM262.5 300V312.5H275V300H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 300.391H275.391V312.109H287.109V300.391ZM275 300V312.5H287.5V300H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 300.391H287.891V312.109H299.609V300.391ZM287.5 300V312.5H300V300H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 300.391H300.391V312.109H312.109V300.391ZM300 300V312.5H312.5V300H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 300.391H312.891V312.109H324.609V300.391ZM312.5 300V312.5H325V300H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 300.391H325.391V312.109H337.109V300.391ZM325 300V312.5H337.5V300H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 300.391H337.891V312.109H349.609V300.391ZM337.5 300V312.5H350V300H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 300.391H350.391V312.109H362.109V300.391ZM350 300V312.5H362.5V300H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 300.391H362.891V312.109H374.609V300.391ZM362.5 300V312.5H375V300H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 300.391H375.391V312.109H387.109V300.391ZM375 300V312.5H387.5V300H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 300.391H387.891V312.109H399.609V300.391ZM387.5 300V312.5H400V300H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 300.391H400.391V312.109H412.109V300.391ZM400 300V312.5H412.5V300H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 300.391H412.891V312.109H424.609V300.391ZM412.5 300V312.5H425V300H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 300.391H425.391V312.109H437.109V300.391ZM425 300V312.5H437.5V300H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 300.391H437.891V312.109H449.609V300.391ZM437.5 300V312.5H450V300H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 300.391H450.391V312.109H462.109V300.391ZM450 300V312.5H462.5V300H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 300.391H462.891V312.109H474.609V300.391ZM462.5 300V312.5H475V300H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 300.391H475.391V312.109H487.109V300.391ZM475 300V312.5H487.5V300H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 300.391H487.891V312.109H499.609V300.391ZM487.5 300V312.5H500V300H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 300.391H500.391V312.109H512.109V300.391ZM500 300V312.5H512.5V300H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 300.391H512.891V312.109H524.609V300.391ZM512.5 300V312.5H525V300H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 300.391H525.391V312.109H537.109V300.391ZM525 300V312.5H537.5V300H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 300.391H537.891V312.109H549.609V300.391ZM537.5 300V312.5H550V300H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 300.391H550.391V312.109H562.109V300.391ZM550 300V312.5H562.5V300H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 300.391H562.891V312.109H574.609V300.391ZM562.5 300V312.5H575V300H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 300.391H575.391V312.109H587.109V300.391ZM575 300V312.5H587.5V300H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 300.391H587.891V312.109H599.609V300.391ZM587.5 300V312.5H600V300H587.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M12.1094 312.891H0.390625V324.609H12.1094V312.891ZM0 312.5V325H12.5V312.5H0Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.6094 312.891H12.8906V324.609H24.6094V312.891ZM12.5 312.5V325H25V312.5H12.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.1094 312.891H25.3906V324.609H37.1094V312.891ZM25 312.5V325H37.5V312.5H25Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.6094 312.891H37.8906V324.609H49.6094V312.891ZM37.5 312.5V325H50V312.5H37.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M62.1094 312.891H50.3906V324.609H62.1094V312.891ZM50 312.5V325H62.5V312.5H50Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M74.6094 312.891H62.8906V324.609H74.6094V312.891ZM62.5 312.5V325H75V312.5H62.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M87.1094 312.891H75.3906V324.609H87.1094V312.891ZM75 312.5V325H87.5V312.5H75Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M99.6094 312.891H87.8906V324.609H99.6094V312.891ZM87.5 312.5V325H100V312.5H87.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M112.109 312.891H100.391V324.609H112.109V312.891ZM100 312.5V325H112.5V312.5H100Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M124.609 312.891H112.891V324.609H124.609V312.891ZM112.5 312.5V325H125V312.5H112.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M137.109 312.891H125.391V324.609H137.109V312.891ZM125 312.5V325H137.5V312.5H125Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M149.609 312.891H137.891V324.609H149.609V312.891ZM137.5 312.5V325H150V312.5H137.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M162.109 312.891H150.391V324.609H162.109V312.891ZM150 312.5V325H162.5V312.5H150Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M174.609 312.891H162.891V324.609H174.609V312.891ZM162.5 312.5V325H175V312.5H162.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M187.109 312.891H175.391V324.609H187.109V312.891ZM175 312.5V325H187.5V312.5H175Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M199.609 312.891H187.891V324.609H199.609V312.891ZM187.5 312.5V325H200V312.5H187.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M212.109 312.891H200.391V324.609H212.109V312.891ZM200 312.5V325H212.5V312.5H200Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M224.609 312.891H212.891V324.609H224.609V312.891ZM212.5 312.5V325H225V312.5H212.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M237.109 312.891H225.391V324.609H237.109V312.891ZM225 312.5V325H237.5V312.5H225Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M249.609 312.891H237.891V324.609H249.609V312.891ZM237.5 312.5V325H250V312.5H237.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M262.109 312.891H250.391V324.609H262.109V312.891ZM250 312.5V325H262.5V312.5H250Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M274.609 312.891H262.891V324.609H274.609V312.891ZM262.5 312.5V325H275V312.5H262.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M287.109 312.891H275.391V324.609H287.109V312.891ZM275 312.5V325H287.5V312.5H275Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M299.609 312.891H287.891V324.609H299.609V312.891ZM287.5 312.5V325H300V312.5H287.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M312.109 312.891H300.391V324.609H312.109V312.891ZM300 312.5V325H312.5V312.5H300Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M324.609 312.891H312.891V324.609H324.609V312.891ZM312.5 312.5V325H325V312.5H312.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M337.109 312.891H325.391V324.609H337.109V312.891ZM325 312.5V325H337.5V312.5H325Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M349.609 312.891H337.891V324.609H349.609V312.891ZM337.5 312.5V325H350V312.5H337.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M362.109 312.891H350.391V324.609H362.109V312.891ZM350 312.5V325H362.5V312.5H350Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M374.609 312.891H362.891V324.609H374.609V312.891ZM362.5 312.5V325H375V312.5H362.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M387.109 312.891H375.391V324.609H387.109V312.891ZM375 312.5V325H387.5V312.5H375Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M399.609 312.891H387.891V324.609H399.609V312.891ZM387.5 312.5V325H400V312.5H387.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M412.109 312.891H400.391V324.609H412.109V312.891ZM400 312.5V325H412.5V312.5H400Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M424.609 312.891H412.891V324.609H424.609V312.891ZM412.5 312.5V325H425V312.5H412.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M437.109 312.891H425.391V324.609H437.109V312.891ZM425 312.5V325H437.5V312.5H425Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M449.609 312.891H437.891V324.609H449.609V312.891ZM437.5 312.5V325H450V312.5H437.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M462.109 312.891H450.391V324.609H462.109V312.891ZM450 312.5V325H462.5V312.5H450Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M474.609 312.891H462.891V324.609H474.609V312.891ZM462.5 312.5V325H475V312.5H462.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M487.109 312.891H475.391V324.609H487.109V312.891ZM475 312.5V325H487.5V312.5H475Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M499.609 312.891H487.891V324.609H499.609V312.891ZM487.5 312.5V325H500V312.5H487.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M512.109 312.891H500.391V324.609H512.109V312.891ZM500 312.5V325H512.5V312.5H500Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M524.609 312.891H512.891V324.609H524.609V312.891ZM512.5 312.5V325H525V312.5H512.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M537.109 312.891H525.391V324.609H537.109V312.891ZM525 312.5V325H537.5V312.5H525Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.609 312.891H537.891V324.609H549.609V312.891ZM537.5 312.5V325H550V312.5H537.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M562.109 312.891H550.391V324.609H562.109V312.891ZM550 312.5V325H562.5V312.5H550Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M574.609 312.891H562.891V324.609H574.609V312.891ZM562.5 312.5V325H575V312.5H562.5Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M587.109 312.891H575.391V324.609H587.109V312.891ZM575 312.5V325H587.5V312.5H575Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M599.609 312.891H587.891V324.609H599.609V312.891ZM587.5 312.5V325H600V312.5H587.5Z" fill="black"/>
+</g>
+<defs>
+<clipPath id="clip0_2906_6463">
+<rect width="515" height="126" fill="white"/>
+</clipPath>
+</defs>
+</svg>
@@ -0,0 +1 @@
@@ -0,0 +1,2 @@
+
@@ -40,7 +40,7 @@
"shift-f11": "debugger::StepOut",
"f11": "zed::ToggleFullScreen",
"ctrl-alt-z": "edit_prediction::RateCompletions",
- "ctrl-shift-i": "edit_prediction::ToggleMenu",
+ "ctrl-alt-shift-i": "edit_prediction::ToggleMenu",
"ctrl-alt-l": "lsp_tool::ToggleMenu"
}
},
@@ -120,7 +120,7 @@
"alt-g m": "git::OpenModifiedFiles",
"menu": "editor::OpenContextMenu",
"shift-f10": "editor::OpenContextMenu",
- "ctrl-shift-e": "editor::ToggleEditPrediction",
+ "ctrl-alt-shift-e": "editor::ToggleEditPrediction",
"f9": "editor::ToggleBreakpoint",
"shift-f9": "editor::EditLogBreakpoint"
}
@@ -130,8 +130,8 @@
"bindings": {
"shift-enter": "editor::Newline",
"enter": "editor::Newline",
- "ctrl-enter": "editor::NewlineAbove",
- "ctrl-shift-enter": "editor::NewlineBelow",
+ "ctrl-enter": "editor::NewlineBelow",
+ "ctrl-shift-enter": "editor::NewlineAbove",
"ctrl-k ctrl-z": "editor::ToggleSoftWrap",
"ctrl-k z": "editor::ToggleSoftWrap",
"find": "buffer_search::Deploy",
@@ -0,0 +1,1260 @@
+[
+ // Standard Windows bindings
+ {
+ "use_key_equivalents": true,
+ "bindings": {
+ "home": "menu::SelectFirst",
+ "shift-pageup": "menu::SelectFirst",
+ "pageup": "menu::SelectFirst",
+ "end": "menu::SelectLast",
+ "shift-pagedown": "menu::SelectLast",
+ "pagedown": "menu::SelectLast",
+ "ctrl-n": "menu::SelectNext",
+ "tab": "menu::SelectNext",
+ "down": "menu::SelectNext",
+ "ctrl-p": "menu::SelectPrevious",
+ "shift-tab": "menu::SelectPrevious",
+ "up": "menu::SelectPrevious",
+ "enter": "menu::Confirm",
+ "ctrl-enter": "menu::SecondaryConfirm",
+ "ctrl-escape": "menu::Cancel",
+ "ctrl-c": "menu::Cancel",
+ "escape": "menu::Cancel",
+ "shift-alt-enter": "menu::Restart",
+ "alt-enter": ["picker::ConfirmInput", { "secondary": false }],
+ "ctrl-alt-enter": ["picker::ConfirmInput", { "secondary": true }],
+ "ctrl-shift-w": "workspace::CloseWindow",
+ "shift-escape": "workspace::ToggleZoom",
+ "open": "workspace::Open",
+ "ctrl-o": "workspace::Open",
+ "ctrl-=": ["zed::IncreaseBufferFontSize", { "persist": false }],
+ "ctrl-shift-=": ["zed::IncreaseBufferFontSize", { "persist": false }],
+ "ctrl--": ["zed::DecreaseBufferFontSize", { "persist": false }],
+ "ctrl-0": ["zed::ResetBufferFontSize", { "persist": false }],
+ "ctrl-,": "zed::OpenSettings",
+ "ctrl-q": "zed::Quit",
+ "f4": "debugger::Start",
+ "shift-f5": "debugger::Stop",
+ "ctrl-shift-f5": "debugger::RerunSession",
+ "f6": "debugger::Pause",
+ "f7": "debugger::StepOver",
+ "ctrl-f11": "debugger::StepInto",
+ "shift-f11": "debugger::StepOut",
+ "f11": "zed::ToggleFullScreen",
+ "ctrl-shift-i": "edit_prediction::ToggleMenu",
+ "shift-alt-l": "lsp_tool::ToggleMenu"
+ }
+ },
+ {
+ "context": "Picker || menu",
+ "use_key_equivalents": true,
+ "bindings": {
+ "up": "menu::SelectPrevious",
+ "down": "menu::SelectNext"
+ }
+ },
+ {
+ "context": "Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "escape": "editor::Cancel",
+ "shift-backspace": "editor::Backspace",
+ "backspace": "editor::Backspace",
+ "delete": "editor::Delete",
+ "tab": "editor::Tab",
+ "shift-tab": "editor::Backtab",
+ "ctrl-k": "editor::CutToEndOfLine",
+ "ctrl-k ctrl-q": "editor::Rewrap",
+ "ctrl-k q": "editor::Rewrap",
+ "ctrl-backspace": "editor::DeleteToPreviousWordStart",
+ "ctrl-delete": "editor::DeleteToNextWordEnd",
+ "cut": "editor::Cut",
+ "shift-delete": "editor::Cut",
+ "ctrl-x": "editor::Cut",
+ "copy": "editor::Copy",
+ "ctrl-insert": "editor::Copy",
+ "ctrl-c": "editor::Copy",
+ "paste": "editor::Paste",
+ "shift-insert": "editor::Paste",
+ "ctrl-v": "editor::Paste",
+ "undo": "editor::Undo",
+ "ctrl-z": "editor::Undo",
+ "redo": "editor::Redo",
+ "ctrl-y": "editor::Redo",
+ "ctrl-shift-z": "editor::Redo",
+ "up": "editor::MoveUp",
+ "ctrl-up": "editor::LineUp",
+ "ctrl-down": "editor::LineDown",
+ "pageup": "editor::MovePageUp",
+ "alt-pageup": "editor::PageUp",
+ "shift-pageup": "editor::SelectPageUp",
+ "home": ["editor::MoveToBeginningOfLine", { "stop_at_soft_wraps": true, "stop_at_indent": true }],
+ "down": "editor::MoveDown",
+ "pagedown": "editor::MovePageDown",
+ "alt-pagedown": "editor::PageDown",
+ "shift-pagedown": "editor::SelectPageDown",
+ "end": ["editor::MoveToEndOfLine", { "stop_at_soft_wraps": true }],
+ "left": "editor::MoveLeft",
+ "right": "editor::MoveRight",
+ "ctrl-left": "editor::MoveToPreviousWordStart",
+ "ctrl-right": "editor::MoveToNextWordEnd",
+ "ctrl-home": "editor::MoveToBeginning",
+ "ctrl-end": "editor::MoveToEnd",
+ "shift-up": "editor::SelectUp",
+ "shift-down": "editor::SelectDown",
+ "shift-left": "editor::SelectLeft",
+ "shift-right": "editor::SelectRight",
+ "ctrl-shift-left": "editor::SelectToPreviousWordStart",
+ "ctrl-shift-right": "editor::SelectToNextWordEnd",
+ "ctrl-shift-home": "editor::SelectToBeginning",
+ "ctrl-shift-end": "editor::SelectToEnd",
+ "ctrl-a": "editor::SelectAll",
+ "ctrl-l": "editor::SelectLine",
+ "shift-alt-f": "editor::Format",
+ "shift-alt-o": "editor::OrganizeImports",
+ "shift-home": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": true, "stop_at_indent": true }],
+ "shift-end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": true }],
+ "ctrl-alt-space": "editor::ShowCharacterPalette",
+ "ctrl-;": "editor::ToggleLineNumbers",
+ "ctrl-'": "editor::ToggleSelectedDiffHunks",
+ "ctrl-\"": "editor::ExpandAllDiffHunks",
+ "ctrl-i": "editor::ShowSignatureHelp",
+ "alt-g b": "git::Blame",
+ "alt-g m": "git::OpenModifiedFiles",
+ "menu": "editor::OpenContextMenu",
+ "shift-f10": "editor::OpenContextMenu",
+ "ctrl-shift-e": "editor::ToggleEditPrediction",
+ "f9": "editor::ToggleBreakpoint",
+ "shift-f9": "editor::EditLogBreakpoint"
+ }
+ },
+ {
+ "context": "Editor && mode == full",
+ "use_key_equivalents": true,
+ "bindings": {
+ "shift-enter": "editor::Newline",
+ "enter": "editor::Newline",
+ "ctrl-enter": "editor::NewlineBelow",
+ "ctrl-shift-enter": "editor::NewlineAbove",
+ "ctrl-k ctrl-z": "editor::ToggleSoftWrap",
+ "ctrl-k z": "editor::ToggleSoftWrap",
+ "find": "buffer_search::Deploy",
+ "ctrl-f": "buffer_search::Deploy",
+ "ctrl-h": "buffer_search::DeployReplace",
+ "ctrl-shift-.": "assistant::QuoteSelection",
+ "ctrl-shift-,": "assistant::InsertIntoEditor",
+ "shift-alt-e": "editor::SelectEnclosingSymbol",
+ "ctrl-shift-backspace": "editor::GoToPreviousChange",
+ "ctrl-shift-alt-backspace": "editor::GoToNextChange",
+ "alt-enter": "editor::OpenSelectionsInMultibuffer"
+ }
+ },
+ {
+ "context": "Editor && mode == full && edit_prediction",
+ "use_key_equivalents": true,
+ "bindings": {
+ "alt-]": "editor::NextEditPrediction",
+ "alt-[": "editor::PreviousEditPrediction"
+ }
+ },
+ {
+ "context": "Editor && !edit_prediction",
+ "use_key_equivalents": true,
+ "bindings": {
+ "alt-\\": "editor::ShowEditPrediction"
+ }
+ },
+ {
+ "context": "Editor && mode == auto_height",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-enter": "editor::Newline",
+ "shift-enter": "editor::Newline",
+ "ctrl-shift-enter": "editor::NewlineBelow"
+ }
+ },
+ {
+ "context": "Markdown",
+ "use_key_equivalents": true,
+ "bindings": {
+ "copy": "markdown::Copy",
+ "ctrl-c": "markdown::Copy"
+ }
+ },
+ {
+ "context": "Editor && jupyter && !ContextEditor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-shift-enter": "repl::Run",
+ "ctrl-alt-enter": "repl::RunInPlace"
+ }
+ },
+ {
+ "context": "Editor && !agent_diff",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-k ctrl-r": "git::Restore",
+ "alt-y": "git::StageAndNext",
+ "shift-alt-y": "git::UnstageAndNext"
+ }
+ },
+ {
+ "context": "Editor && editor_agent_diff",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-y": "agent::Keep",
+ "ctrl-n": "agent::Reject",
+ "ctrl-shift-y": "agent::KeepAll",
+ "ctrl-shift-n": "agent::RejectAll",
+ "ctrl-shift-r": "agent::OpenAgentDiff"
+ }
+ },
+ {
+ "context": "AgentDiff",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-y": "agent::Keep",
+ "ctrl-n": "agent::Reject",
+ "ctrl-shift-y": "agent::KeepAll",
+ "ctrl-shift-n": "agent::RejectAll"
+ }
+ },
+ {
+ "context": "ContextEditor > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-enter": "assistant::Assist",
+ "ctrl-s": "workspace::Save",
+ "save": "workspace::Save",
+ "ctrl-shift-,": "assistant::InsertIntoEditor",
+ "shift-enter": "assistant::Split",
+ "ctrl-r": "assistant::CycleMessageRole",
+ "enter": "assistant::ConfirmCommand",
+ "alt-enter": "editor::Newline",
+ "ctrl-k c": "assistant::CopyCode",
+ "ctrl-g": "search::SelectNextMatch",
+ "ctrl-shift-g": "search::SelectPreviousMatch",
+ "ctrl-k l": "agent::OpenRulesLibrary"
+ }
+ },
+ {
+ "context": "AgentPanel",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-n": "agent::NewThread",
+ "shift-alt-n": "agent::NewTextThread",
+ "ctrl-shift-h": "agent::OpenHistory",
+ "shift-alt-c": "agent::OpenSettings",
+ "shift-alt-p": "agent::OpenRulesLibrary",
+ "ctrl-i": "agent::ToggleProfileSelector",
+ "shift-alt-/": "agent::ToggleModelSelector",
+ "ctrl-shift-a": "agent::ToggleContextPicker",
+ "ctrl-shift-j": "agent::ToggleNavigationMenu",
+ "ctrl-shift-i": "agent::ToggleOptionsMenu",
+ // "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu",
+ "shift-alt-escape": "agent::ExpandMessageEditor",
+ "ctrl-shift-.": "assistant::QuoteSelection",
+ "shift-alt-e": "agent::RemoveAllContext",
+ "ctrl-shift-e": "project_panel::ToggleFocus",
+ "ctrl-shift-enter": "agent::ContinueThread",
+ "super-ctrl-b": "agent::ToggleBurnMode",
+ "alt-enter": "agent::ContinueWithBurnMode"
+ }
+ },
+ {
+ "context": "AgentPanel > NavigationMenu",
+ "use_key_equivalents": true,
+ "bindings": {
+ "shift-backspace": "agent::DeleteRecentlyOpenThread"
+ }
+ },
+ {
+ "context": "AgentPanel > Markdown",
+ "use_key_equivalents": true,
+ "bindings": {
+ "copy": "markdown::CopyAsMarkdown",
+ "ctrl-c": "markdown::CopyAsMarkdown"
+ }
+ },
+ {
+ "context": "AgentPanel && prompt_editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-n": "agent::NewTextThread",
+ "ctrl-alt-t": "agent::NewThread"
+ }
+ },
+ {
+ "context": "AgentPanel && external_agent_thread",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-n": "agent::NewExternalAgentThread",
+ "ctrl-alt-t": "agent::NewThread"
+ }
+ },
+ {
+ "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send",
+ "use_key_equivalents": true,
+ "bindings": {
+ "enter": "agent::Chat",
+ "ctrl-enter": "agent::ChatWithFollow",
+ "ctrl-i": "agent::ToggleProfileSelector",
+ "ctrl-shift-r": "agent::OpenAgentDiff",
+ "ctrl-shift-y": "agent::KeepAll",
+ "ctrl-shift-n": "agent::RejectAll"
+ }
+ },
+ {
+ "context": "MessageEditor && !Picker > Editor && use_modifier_to_send",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-enter": "agent::Chat",
+ "enter": "editor::Newline",
+ "ctrl-i": "agent::ToggleProfileSelector",
+ "ctrl-shift-r": "agent::OpenAgentDiff",
+ "ctrl-shift-y": "agent::KeepAll",
+ "ctrl-shift-n": "agent::RejectAll"
+ }
+ },
+ {
+ "context": "EditMessageEditor > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "escape": "menu::Cancel",
+ "enter": "menu::Confirm",
+ "alt-enter": "editor::Newline"
+ }
+ },
+ {
+ "context": "AgentFeedbackMessageEditor > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "escape": "menu::Cancel",
+ "enter": "menu::Confirm",
+ "alt-enter": "editor::Newline"
+ }
+ },
+ {
+ "context": "ContextStrip",
+ "use_key_equivalents": true,
+ "bindings": {
+ "up": "agent::FocusUp",
+ "right": "agent::FocusRight",
+ "left": "agent::FocusLeft",
+ "down": "agent::FocusDown",
+ "backspace": "agent::RemoveFocusedContext",
+ "enter": "agent::AcceptSuggestedContext"
+ }
+ },
+ {
+ "context": "AcpThread > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "enter": "agent::Chat",
+ "ctrl-shift-r": "agent::OpenAgentDiff",
+ "ctrl-shift-y": "agent::KeepAll",
+ "ctrl-shift-n": "agent::RejectAll"
+ }
+ },
+ {
+ "context": "ThreadHistory",
+ "use_key_equivalents": true,
+ "bindings": {
+ "backspace": "agent::RemoveSelectedThread"
+ }
+ },
+ {
+ "context": "PromptLibrary",
+ "use_key_equivalents": true,
+ "bindings": {
+ "new": "rules_library::NewRule",
+ "ctrl-n": "rules_library::NewRule",
+ "ctrl-shift-s": "rules_library::ToggleDefaultRule"
+ }
+ },
+ {
+ "context": "BufferSearchBar",
+ "use_key_equivalents": true,
+ "bindings": {
+ "escape": "buffer_search::Dismiss",
+ "tab": "buffer_search::FocusEditor",
+ "enter": "search::SelectNextMatch",
+ "shift-enter": "search::SelectPreviousMatch",
+ "alt-enter": "search::SelectAllMatches",
+ "find": "search::FocusSearch",
+ "ctrl-f": "search::FocusSearch",
+ "ctrl-h": "search::ToggleReplace",
+ "ctrl-l": "search::ToggleSelection"
+ }
+ },
+ {
+ "context": "BufferSearchBar && in_replace > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "enter": "search::ReplaceNext",
+ "ctrl-enter": "search::ReplaceAll"
+ }
+ },
+ {
+ "context": "BufferSearchBar && !in_replace > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "up": "search::PreviousHistoryQuery",
+ "down": "search::NextHistoryQuery"
+ }
+ },
+ {
+ "context": "ProjectSearchBar",
+ "use_key_equivalents": true,
+ "bindings": {
+ "escape": "project_search::ToggleFocus",
+ "shift-find": "search::FocusSearch",
+ "ctrl-shift-f": "search::FocusSearch",
+ "ctrl-shift-h": "search::ToggleReplace",
+ "alt-r": "search::ToggleRegex" // vscode
+ }
+ },
+ {
+ "context": "ProjectSearchBar > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "up": "search::PreviousHistoryQuery",
+ "down": "search::NextHistoryQuery"
+ }
+ },
+ {
+ "context": "ProjectSearchBar && in_replace > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "enter": "search::ReplaceNext",
+ "ctrl-alt-enter": "search::ReplaceAll"
+ }
+ },
+ {
+ "context": "ProjectSearchView",
+ "use_key_equivalents": true,
+ "bindings": {
+ "escape": "project_search::ToggleFocus",
+ "ctrl-shift-h": "search::ToggleReplace",
+ "alt-r": "search::ToggleRegex" // vscode
+ }
+ },
+ {
+ "context": "Pane",
+ "use_key_equivalents": true,
+ "bindings": {
+ "alt-1": ["pane::ActivateItem", 0],
+ "alt-2": ["pane::ActivateItem", 1],
+ "alt-3": ["pane::ActivateItem", 2],
+ "alt-4": ["pane::ActivateItem", 3],
+ "alt-5": ["pane::ActivateItem", 4],
+ "alt-6": ["pane::ActivateItem", 5],
+ "alt-7": ["pane::ActivateItem", 6],
+ "alt-8": ["pane::ActivateItem", 7],
+ "alt-9": ["pane::ActivateItem", 8],
+ "alt-0": "pane::ActivateLastItem",
+ "ctrl-pageup": "pane::ActivatePreviousItem",
+ "ctrl-pagedown": "pane::ActivateNextItem",
+ "ctrl-shift-pageup": "pane::SwapItemLeft",
+ "ctrl-shift-pagedown": "pane::SwapItemRight",
+ "ctrl-f4": ["pane::CloseActiveItem", { "close_pinned": false }],
+ "ctrl-w": ["pane::CloseActiveItem", { "close_pinned": false }],
+ "ctrl-shift-alt-t": ["pane::CloseOtherItems", { "close_pinned": false }],
+ "ctrl-shift-alt-w": "workspace::CloseInactiveTabsAndPanes",
+ "ctrl-k e": ["pane::CloseItemsToTheLeft", { "close_pinned": false }],
+ "ctrl-k t": ["pane::CloseItemsToTheRight", { "close_pinned": false }],
+ "ctrl-k u": ["pane::CloseCleanItems", { "close_pinned": false }],
+ "ctrl-k w": ["pane::CloseAllItems", { "close_pinned": false }],
+ "ctrl-k ctrl-w": "workspace::CloseAllItemsAndPanes",
+ "back": "pane::GoBack",
+ "alt--": "pane::GoBack",
+ "alt-=": "pane::GoForward",
+ "forward": "pane::GoForward",
+ "f3": "search::SelectNextMatch",
+ "shift-f3": "search::SelectPreviousMatch",
+ "shift-find": "project_search::ToggleFocus",
+ "ctrl-shift-f": "project_search::ToggleFocus",
+ "shift-alt-h": "search::ToggleReplace",
+ "alt-l": "search::ToggleSelection",
+ "alt-enter": "search::SelectAllMatches",
+ "alt-c": "search::ToggleCaseSensitive",
+ "alt-w": "search::ToggleWholeWord",
+ "alt-find": "project_search::ToggleFilters",
+ "alt-f": "project_search::ToggleFilters",
+ "alt-r": "search::ToggleRegex",
+ // "ctrl-shift-alt-x": "search::ToggleRegex",
+ "ctrl-k shift-enter": "pane::TogglePinTab"
+ }
+ },
+ // Bindings from VS Code
+ {
+ "context": "Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-[": "editor::Outdent",
+ "ctrl-]": "editor::Indent",
+ "ctrl-shift-alt-up": "editor::AddSelectionAbove", // Insert Cursor Above
+ "ctrl-shift-alt-down": "editor::AddSelectionBelow", // Insert Cursor Below
+ "ctrl-shift-k": "editor::DeleteLine",
+ "alt-up": "editor::MoveLineUp",
+ "alt-down": "editor::MoveLineDown",
+ "shift-alt-up": "editor::DuplicateLineUp",
+ "shift-alt-down": "editor::DuplicateLineDown",
+ "shift-alt-right": "editor::SelectLargerSyntaxNode", // Expand Selection
+ "shift-alt-left": "editor::SelectSmallerSyntaxNode", // Shrink Selection
+ "ctrl-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection
+ "ctrl-f2": "editor::SelectAllMatches", // Select all occurrences of current word
+ "ctrl-d": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand
+ "ctrl-shift-down": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch
+ "ctrl-shift-up": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToPreviousFindMatch
+ "ctrl-k ctrl-d": ["editor::SelectNext", { "replace_newest": true }], // editor.action.moveSelectionToNextFindMatch / find_under_expand_skip
+ "ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch
+ "ctrl-k ctrl-i": "editor::Hover",
+ "ctrl-k ctrl-b": "editor::BlameHover",
+ "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }],
+ "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
+ "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }],
+ "f2": "editor::Rename",
+ "f12": "editor::GoToDefinition",
+ "alt-f12": "editor::GoToDefinitionSplit",
+ "ctrl-shift-f10": "editor::GoToDefinitionSplit",
+ "ctrl-f12": "editor::GoToImplementation",
+ "shift-f12": "editor::GoToTypeDefinition",
+ "ctrl-alt-f12": "editor::GoToTypeDefinitionSplit",
+ "shift-alt-f12": "editor::FindAllReferences",
+ "ctrl-m": "editor::MoveToEnclosingBracket", // from jetbrains
+ "ctrl-shift-\\": "editor::MoveToEnclosingBracket",
+ "ctrl-shift-[": "editor::Fold",
+ "ctrl-shift-]": "editor::UnfoldLines",
+ "ctrl-k ctrl-l": "editor::ToggleFold",
+ "ctrl-k ctrl-[": "editor::FoldRecursive",
+ "ctrl-k ctrl-]": "editor::UnfoldRecursive",
+ "ctrl-k ctrl-1": ["editor::FoldAtLevel", 1],
+ "ctrl-k ctrl-2": ["editor::FoldAtLevel", 2],
+ "ctrl-k ctrl-3": ["editor::FoldAtLevel", 3],
+ "ctrl-k ctrl-4": ["editor::FoldAtLevel", 4],
+ "ctrl-k ctrl-5": ["editor::FoldAtLevel", 5],
+ "ctrl-k ctrl-6": ["editor::FoldAtLevel", 6],
+ "ctrl-k ctrl-7": ["editor::FoldAtLevel", 7],
+ "ctrl-k ctrl-8": ["editor::FoldAtLevel", 8],
+ "ctrl-k ctrl-9": ["editor::FoldAtLevel", 9],
+ "ctrl-k ctrl-0": "editor::FoldAll",
+ "ctrl-k ctrl-j": "editor::UnfoldAll",
+ "ctrl-space": "editor::ShowCompletions",
+ "ctrl-shift-space": "editor::ShowWordCompletions",
+ "ctrl-.": "editor::ToggleCodeActions",
+ "ctrl-k r": "editor::RevealInFileManager",
+ "ctrl-k p": "editor::CopyPath",
+ "ctrl-\\": "pane::SplitRight",
+ "ctrl-shift-alt-c": "editor::DisplayCursorNames",
+ "alt-.": "editor::GoToHunk",
+ "alt-,": "editor::GoToPreviousHunk"
+ }
+ },
+ {
+ "context": "Editor && extension == md",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-k v": "markdown::OpenPreviewToTheSide",
+ "ctrl-shift-v": "markdown::OpenPreview"
+ }
+ },
+ {
+ "context": "Editor && extension == svg",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-k v": "svg::OpenPreviewToTheSide",
+ "ctrl-shift-v": "svg::OpenPreview"
+ }
+ },
+ {
+ "context": "Editor && mode == full",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-shift-o": "outline::Toggle",
+ "ctrl-g": "go_to_line::Toggle"
+ }
+ },
+ {
+ "context": "Workspace",
+ "use_key_equivalents": true,
+ "bindings": {
+ "alt-open": ["projects::OpenRecent", { "create_new_window": false }],
+ // Change the default action on `menu::Confirm` by setting the parameter
+ // "ctrl-alt-o": ["projects::OpenRecent", { "create_new_window": true }],
+ "ctrl-r": ["projects::OpenRecent", { "create_new_window": false }],
+ "shift-alt-open": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
+ // Change to open path modal for existing remote connection by setting the parameter
+ // "ctrl-shift-alt-o": "["projects::OpenRemote", { "from_existing_connection": true }]",
+ "ctrl-shift-alt-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
+ "shift-alt-b": "branches::OpenRecent",
+ "shift-alt-enter": "toast::RunAction",
+ "ctrl-shift-`": "workspace::NewTerminal",
+ "save": "workspace::Save",
+ "ctrl-s": "workspace::Save",
+ "ctrl-k ctrl-shift-s": "workspace::SaveWithoutFormat",
+ "shift-save": "workspace::SaveAs",
+ "ctrl-shift-s": "workspace::SaveAs",
+ "new": "workspace::NewFile",
+ "ctrl-n": "workspace::NewFile",
+ "shift-new": "workspace::NewWindow",
+ "ctrl-shift-n": "workspace::NewWindow",
+ "ctrl-`": "terminal_panel::ToggleFocus",
+ "f10": ["app_menu::OpenApplicationMenu", "Zed"],
+ "alt-1": ["workspace::ActivatePane", 0],
+ "alt-2": ["workspace::ActivatePane", 1],
+ "alt-3": ["workspace::ActivatePane", 2],
+ "alt-4": ["workspace::ActivatePane", 3],
+ "alt-5": ["workspace::ActivatePane", 4],
+ "alt-6": ["workspace::ActivatePane", 5],
+ "alt-7": ["workspace::ActivatePane", 6],
+ "alt-8": ["workspace::ActivatePane", 7],
+ "alt-9": ["workspace::ActivatePane", 8],
+ "ctrl-alt-b": "workspace::ToggleRightDock",
+ "ctrl-b": "workspace::ToggleLeftDock",
+ "ctrl-j": "workspace::ToggleBottomDock",
+ "ctrl-shift-y": "workspace::CloseAllDocks",
+ "alt-r": "workspace::ResetActiveDockSize",
+ // For 0px parameter, uses UI font size value.
+ "shift-alt--": ["workspace::DecreaseActiveDockSize", { "px": 0 }],
+ "shift-alt-=": ["workspace::IncreaseActiveDockSize", { "px": 0 }],
+ "shift-alt-0": "workspace::ResetOpenDocksSize",
+ "ctrl-shift-alt--": ["workspace::DecreaseOpenDocksSize", { "px": 0 }],
+ "ctrl-shift-alt-=": ["workspace::IncreaseOpenDocksSize", { "px": 0 }],
+ "shift-find": "pane::DeploySearch",
+ "ctrl-shift-f": "pane::DeploySearch",
+ "ctrl-shift-h": ["pane::DeploySearch", { "replace_enabled": true }],
+ "ctrl-shift-t": "pane::ReopenClosedItem",
+ "ctrl-k ctrl-s": "zed::OpenKeymapEditor",
+ "ctrl-k ctrl-t": "theme_selector::Toggle",
+ "ctrl-alt-super-p": "settings_profile_selector::Toggle",
+ "ctrl-t": "project_symbols::Toggle",
+ "ctrl-p": "file_finder::Toggle",
+ "ctrl-tab": "tab_switcher::Toggle",
+ "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }],
+ "ctrl-e": "file_finder::Toggle",
+ "f1": "command_palette::Toggle",
+ "ctrl-shift-p": "command_palette::Toggle",
+ "ctrl-shift-m": "diagnostics::Deploy",
+ "ctrl-shift-e": "project_panel::ToggleFocus",
+ "ctrl-shift-b": "outline_panel::ToggleFocus",
+ "ctrl-shift-g": "git_panel::ToggleFocus",
+ "ctrl-shift-d": "debug_panel::ToggleFocus",
+ "ctrl-shift-/": "agent::ToggleFocus",
+ "alt-save": "workspace::SaveAll",
+ "ctrl-k s": "workspace::SaveAll",
+ "ctrl-k m": "language_selector::Toggle",
+ "escape": "workspace::Unfollow",
+ "ctrl-k ctrl-left": "workspace::ActivatePaneLeft",
+ "ctrl-k ctrl-right": "workspace::ActivatePaneRight",
+ "ctrl-k ctrl-up": "workspace::ActivatePaneUp",
+ "ctrl-k ctrl-down": "workspace::ActivatePaneDown",
+ "ctrl-k shift-left": "workspace::SwapPaneLeft",
+ "ctrl-k shift-right": "workspace::SwapPaneRight",
+ "ctrl-k shift-up": "workspace::SwapPaneUp",
+ "ctrl-k shift-down": "workspace::SwapPaneDown",
+ "ctrl-shift-x": "zed::Extensions",
+ "ctrl-shift-r": "task::Rerun",
+ "alt-t": "task::Rerun",
+ "shift-alt-t": "task::Spawn",
+ "shift-alt-r": ["task::Spawn", { "reveal_target": "center" }],
+ // also possible to spawn tasks by name:
+ // "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }]
+ // or by tag:
+ // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }],
+ "f5": "debugger::Rerun",
+ "ctrl-f4": "workspace::CloseActiveDock",
+ "ctrl-w": "workspace::CloseActiveDock"
+ }
+ },
+ {
+ "context": "Workspace && debugger_running",
+ "use_key_equivalents": true,
+ "bindings": {
+ "f5": "zed::NoAction"
+ }
+ },
+ {
+ "context": "Workspace && debugger_stopped",
+ "use_key_equivalents": true,
+ "bindings": {
+ "f5": "debugger::Continue"
+ }
+ },
+ {
+ "context": "ApplicationMenu",
+ "use_key_equivalents": true,
+ "bindings": {
+ "f10": "menu::Cancel",
+ "left": "app_menu::ActivateMenuLeft",
+ "right": "app_menu::ActivateMenuRight"
+ }
+ },
+ // Bindings from Sublime Text
+ {
+ "context": "Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-u": "editor::UndoSelection",
+ "ctrl-shift-u": "editor::RedoSelection",
+ "ctrl-shift-j": "editor::JoinLines",
+ "ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart",
+ "shift-alt-h": "editor::DeleteToPreviousSubwordStart",
+ "ctrl-alt-delete": "editor::DeleteToNextSubwordEnd",
+ "shift-alt-d": "editor::DeleteToNextSubwordEnd",
+ "ctrl-alt-left": "editor::MoveToPreviousSubwordStart",
+ "ctrl-alt-right": "editor::MoveToNextSubwordEnd",
+ "ctrl-shift-alt-left": "editor::SelectToPreviousSubwordStart",
+ "ctrl-shift-alt-right": "editor::SelectToNextSubwordEnd"
+ }
+ },
+ // Bindings from Atom
+ {
+ "context": "Pane",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-k up": "pane::SplitUp",
+ "ctrl-k down": "pane::SplitDown",
+ "ctrl-k left": "pane::SplitLeft",
+ "ctrl-k right": "pane::SplitRight"
+ }
+ },
+ // Bindings that should be unified with bindings for more general actions
+ {
+ "context": "Editor && renaming",
+ "use_key_equivalents": true,
+ "bindings": {
+ "enter": "editor::ConfirmRename"
+ }
+ },
+ {
+ "context": "Editor && showing_completions",
+ "use_key_equivalents": true,
+ "bindings": {
+ "enter": "editor::ConfirmCompletion",
+ "shift-enter": "editor::ConfirmCompletionReplace",
+ "tab": "editor::ComposeCompletion"
+ }
+ },
+ // Bindings for accepting edit predictions
+ //
+ // alt-l is provided as an alternative to tab/alt-tab. and will be displayed in the UI. This is
+ // because alt-tab may not be available, as it is often used for window switching.
+ {
+ "context": "Editor && edit_prediction",
+ "use_key_equivalents": true,
+ "bindings": {
+ "alt-tab": "editor::AcceptEditPrediction",
+ "alt-l": "editor::AcceptEditPrediction",
+ "tab": "editor::AcceptEditPrediction",
+ "alt-right": "editor::AcceptPartialEditPrediction"
+ }
+ },
+ {
+ "context": "Editor && edit_prediction_conflict",
+ "use_key_equivalents": true,
+ "bindings": {
+ "alt-tab": "editor::AcceptEditPrediction",
+ "alt-l": "editor::AcceptEditPrediction",
+ "alt-right": "editor::AcceptPartialEditPrediction"
+ }
+ },
+ {
+ "context": "Editor && showing_code_actions",
+ "use_key_equivalents": true,
+ "bindings": {
+ "enter": "editor::ConfirmCodeAction"
+ }
+ },
+ {
+ "context": "Editor && (showing_code_actions || showing_completions)",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-p": "editor::ContextMenuPrevious",
+ "up": "editor::ContextMenuPrevious",
+ "ctrl-n": "editor::ContextMenuNext",
+ "down": "editor::ContextMenuNext",
+ "pageup": "editor::ContextMenuFirst",
+ "pagedown": "editor::ContextMenuLast"
+ }
+ },
+ {
+ "context": "Editor && showing_signature_help && !showing_completions",
+ "use_key_equivalents": true,
+ "bindings": {
+ "up": "editor::SignatureHelpPrevious",
+ "down": "editor::SignatureHelpNext"
+ }
+ },
+ // Custom bindings
+ {
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-shift-alt-f": "workspace::FollowNextCollaborator",
+ // Only available in debug builds: opens an element inspector for development.
+ "shift-alt-i": "dev::ToggleInspector"
+ }
+ },
+ {
+ "context": "!Terminal",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-shift-c": "collab_panel::ToggleFocus"
+ }
+ },
+ {
+ "context": "!ContextEditor > Editor && mode == full",
+ "use_key_equivalents": true,
+ "bindings": {
+ "alt-enter": "editor::OpenExcerpts",
+ "shift-enter": "editor::ExpandExcerpts",
+ "ctrl-alt-enter": "editor::OpenExcerptsSplit",
+ "ctrl-shift-e": "pane::RevealInProjectPanel",
+ "ctrl-f8": "editor::GoToHunk",
+ "ctrl-shift-f8": "editor::GoToPreviousHunk",
+ "ctrl-enter": "assistant::InlineAssist",
+ "ctrl-shift-;": "editor::ToggleInlayHints"
+ }
+ },
+ {
+ "context": "PromptEditor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-[": "agent::CyclePreviousInlineAssist",
+ "ctrl-]": "agent::CycleNextInlineAssist",
+ "shift-alt-e": "agent::RemoveAllContext"
+ }
+ },
+ {
+ "context": "Prompt",
+ "use_key_equivalents": true,
+ "bindings": {
+ "left": "menu::SelectPrevious",
+ "right": "menu::SelectNext",
+ "h": "menu::SelectPrevious",
+ "l": "menu::SelectNext"
+ }
+ },
+ {
+ "context": "ProjectSearchBar && !in_replace",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-enter": "project_search::SearchInNew"
+ }
+ },
+ {
+ "context": "OutlinePanel && not_editing",
+ "use_key_equivalents": true,
+ "bindings": {
+ "left": "outline_panel::CollapseSelectedEntry",
+ "right": "outline_panel::ExpandSelectedEntry",
+ "alt-copy": "outline_panel::CopyPath",
+ "shift-alt-c": "outline_panel::CopyPath",
+ "shift-alt-copy": "workspace::CopyRelativePath",
+ "ctrl-shift-alt-c": "workspace::CopyRelativePath",
+ "ctrl-alt-r": "outline_panel::RevealInFileManager",
+ "space": "outline_panel::OpenSelectedEntry",
+ "shift-down": "menu::SelectNext",
+ "shift-up": "menu::SelectPrevious",
+ "alt-enter": "editor::OpenExcerpts",
+ "ctrl-alt-enter": "editor::OpenExcerptsSplit"
+ }
+ },
+ {
+ "context": "ProjectPanel",
+ "use_key_equivalents": true,
+ "bindings": {
+ "left": "project_panel::CollapseSelectedEntry",
+ "right": "project_panel::ExpandSelectedEntry",
+ "new": "project_panel::NewFile",
+ "ctrl-n": "project_panel::NewFile",
+ "alt-new": "project_panel::NewDirectory",
+ "alt-n": "project_panel::NewDirectory",
+ "cut": "project_panel::Cut",
+ "ctrl-x": "project_panel::Cut",
+ "copy": "project_panel::Copy",
+ "ctrl-insert": "project_panel::Copy",
+ "ctrl-c": "project_panel::Copy",
+ "paste": "project_panel::Paste",
+ "shift-insert": "project_panel::Paste",
+ "ctrl-v": "project_panel::Paste",
+ "alt-copy": "project_panel::CopyPath",
+ "shift-alt-c": "project_panel::CopyPath",
+ "shift-alt-copy": "workspace::CopyRelativePath",
+ "ctrl-k ctrl-shift-c": "workspace::CopyRelativePath",
+ "enter": "project_panel::Rename",
+ "f2": "project_panel::Rename",
+ "backspace": ["project_panel::Trash", { "skip_prompt": false }],
+ "delete": ["project_panel::Trash", { "skip_prompt": false }],
+ "shift-delete": ["project_panel::Delete", { "skip_prompt": false }],
+ "ctrl-backspace": ["project_panel::Delete", { "skip_prompt": false }],
+ "ctrl-delete": ["project_panel::Delete", { "skip_prompt": false }],
+ "ctrl-alt-r": "project_panel::RevealInFileManager",
+ "ctrl-shift-enter": "project_panel::OpenWithSystem",
+ "alt-d": "project_panel::CompareMarkedFiles",
+ "shift-find": "project_panel::NewSearchInDirectory",
+ "ctrl-k ctrl-shift-f": "project_panel::NewSearchInDirectory",
+ "shift-down": "menu::SelectNext",
+ "shift-up": "menu::SelectPrevious",
+ "escape": "menu::Cancel"
+ }
+ },
+ {
+ "context": "ProjectPanel && not_editing",
+ "use_key_equivalents": true,
+ "bindings": {
+ "space": "project_panel::Open"
+ }
+ },
+ {
+ "context": "GitPanel && ChangesList",
+ "use_key_equivalents": true,
+ "bindings": {
+ "up": "menu::SelectPrevious",
+ "down": "menu::SelectNext",
+ "enter": "menu::Confirm",
+ "alt-y": "git::StageFile",
+ "shift-alt-y": "git::UnstageFile",
+ "space": "git::ToggleStaged",
+ "shift-space": "git::StageRange",
+ "tab": "git_panel::FocusEditor",
+ "shift-tab": "git_panel::FocusEditor",
+ "escape": "git_panel::ToggleFocus",
+ "alt-enter": "menu::SecondaryConfirm",
+ "delete": ["git::RestoreFile", { "skip_prompt": false }],
+ "backspace": ["git::RestoreFile", { "skip_prompt": false }],
+ "shift-delete": ["git::RestoreFile", { "skip_prompt": false }],
+ "ctrl-backspace": ["git::RestoreFile", { "skip_prompt": false }],
+ "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }]
+ }
+ },
+ {
+ "context": "GitPanel && CommitEditor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "escape": "git::Cancel"
+ }
+ },
+ {
+ "context": "GitCommit > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "escape": "menu::Cancel",
+ "enter": "editor::Newline",
+ "ctrl-enter": "git::Commit",
+ "ctrl-shift-enter": "git::Amend",
+ "alt-l": "git::GenerateCommitMessage"
+ }
+ },
+ {
+ "context": "GitPanel",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-g ctrl-g": "git::Fetch",
+ "ctrl-g up": "git::Push",
+ "ctrl-g down": "git::Pull",
+ "ctrl-g shift-up": "git::ForcePush",
+ "ctrl-g d": "git::Diff",
+ "ctrl-g backspace": "git::RestoreTrackedFiles",
+ "ctrl-g shift-backspace": "git::TrashUntrackedFiles",
+ "ctrl-space": "git::StageAll",
+ "ctrl-shift-space": "git::UnstageAll",
+ "ctrl-enter": "git::Commit",
+ "ctrl-shift-enter": "git::Amend"
+ }
+ },
+ {
+ "context": "GitDiff > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-enter": "git::Commit",
+ "ctrl-shift-enter": "git::Amend",
+ "ctrl-space": "git::StageAll",
+ "ctrl-shift-space": "git::UnstageAll"
+ }
+ },
+ {
+ "context": "AskPass > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "enter": "menu::Confirm"
+ }
+ },
+ {
+ "context": "CommitEditor > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "escape": "git_panel::FocusChanges",
+ "tab": "git_panel::FocusChanges",
+ "shift-tab": "git_panel::FocusChanges",
+ "enter": "editor::Newline",
+ "ctrl-enter": "git::Commit",
+ "ctrl-shift-enter": "git::Amend",
+ "alt-up": "git_panel::FocusChanges",
+ "alt-l": "git::GenerateCommitMessage"
+ }
+ },
+ {
+ "context": "DebugPanel",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-t": "debugger::ToggleThreadPicker",
+ "ctrl-i": "debugger::ToggleSessionPicker",
+ "shift-alt-escape": "debugger::ToggleExpandItem"
+ }
+ },
+ {
+ "context": "VariableList",
+ "use_key_equivalents": true,
+ "bindings": {
+ "left": "variable_list::CollapseSelectedEntry",
+ "right": "variable_list::ExpandSelectedEntry",
+ "enter": "variable_list::EditVariable",
+ "ctrl-c": "variable_list::CopyVariableValue",
+ "ctrl-alt-c": "variable_list::CopyVariableName",
+ "delete": "variable_list::RemoveWatch",
+ "backspace": "variable_list::RemoveWatch",
+ "alt-enter": "variable_list::AddWatch"
+ }
+ },
+ {
+ "context": "BreakpointList",
+ "use_key_equivalents": true,
+ "bindings": {
+ "space": "debugger::ToggleEnableBreakpoint",
+ "backspace": "debugger::UnsetBreakpoint",
+ "left": "debugger::PreviousBreakpointProperty",
+ "right": "debugger::NextBreakpointProperty"
+ }
+ },
+ {
+ "context": "CollabPanel && not_editing",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-backspace": "collab_panel::Remove",
+ "space": "menu::Confirm"
+ }
+ },
+ {
+ "context": "CollabPanel",
+ "use_key_equivalents": true,
+ "bindings": {
+ "alt-up": "collab_panel::MoveChannelUp",
+ "alt-down": "collab_panel::MoveChannelDown"
+ }
+ },
+ {
+ "context": "(CollabPanel && editing) > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "space": "collab_panel::InsertSpace"
+ }
+ },
+ {
+ "context": "ChannelModal",
+ "use_key_equivalents": true,
+ "bindings": {
+ "tab": "channel_modal::ToggleMode"
+ }
+ },
+ {
+ "context": "Picker > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "escape": "menu::Cancel",
+ "up": "menu::SelectPrevious",
+ "down": "menu::SelectNext",
+ "tab": "picker::ConfirmCompletion",
+ "alt-enter": ["picker::ConfirmInput", { "secondary": false }]
+ }
+ },
+ {
+ "context": "ChannelModal > Picker > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "tab": "channel_modal::ToggleMode"
+ }
+ },
+ {
+ "context": "FileFinder || (FileFinder > Picker > Editor)",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-p": "file_finder::Toggle",
+ "ctrl-shift-a": "file_finder::ToggleSplitMenu",
+ "ctrl-shift-i": "file_finder::ToggleFilterMenu"
+ }
+ },
+ {
+ "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-shift-p": "file_finder::SelectPrevious",
+ "ctrl-j": "pane::SplitDown",
+ "ctrl-k": "pane::SplitUp",
+ "ctrl-h": "pane::SplitLeft",
+ "ctrl-l": "pane::SplitRight"
+ }
+ },
+ {
+ "context": "TabSwitcher",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-shift-tab": "menu::SelectPrevious",
+ "ctrl-up": "menu::SelectPrevious",
+ "ctrl-down": "menu::SelectNext",
+ "ctrl-backspace": "tab_switcher::CloseSelectedItem"
+ }
+ },
+ {
+ "context": "Terminal",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-alt-space": "terminal::ShowCharacterPalette",
+ "copy": "terminal::Copy",
+ "ctrl-insert": "terminal::Copy",
+ "ctrl-shift-c": "terminal::Copy",
+ "paste": "terminal::Paste",
+ "shift-insert": "terminal::Paste",
+ "ctrl-shift-v": "terminal::Paste",
+ "ctrl-enter": "assistant::InlineAssist",
+ "alt-b": ["terminal::SendText", "\u001bb"],
+ "alt-f": ["terminal::SendText", "\u001bf"],
+ "alt-.": ["terminal::SendText", "\u001b."],
+ "ctrl-delete": ["terminal::SendText", "\u001bd"],
+ // Overrides for conflicting keybindings
+ "ctrl-b": ["terminal::SendKeystroke", "ctrl-b"],
+ "ctrl-c": ["terminal::SendKeystroke", "ctrl-c"],
+ "ctrl-e": ["terminal::SendKeystroke", "ctrl-e"],
+ "ctrl-o": ["terminal::SendKeystroke", "ctrl-o"],
+ "ctrl-w": ["terminal::SendKeystroke", "ctrl-w"],
+ "ctrl-backspace": ["terminal::SendKeystroke", "ctrl-w"],
+ "ctrl-shift-a": "editor::SelectAll",
+ "find": "buffer_search::Deploy",
+ "ctrl-shift-f": "buffer_search::Deploy",
+ "ctrl-shift-l": "terminal::Clear",
+ "ctrl-shift-w": "pane::CloseActiveItem",
+ "up": ["terminal::SendKeystroke", "up"],
+ "pageup": ["terminal::SendKeystroke", "pageup"],
+ "down": ["terminal::SendKeystroke", "down"],
+ "pagedown": ["terminal::SendKeystroke", "pagedown"],
+ "escape": ["terminal::SendKeystroke", "escape"],
+ "enter": ["terminal::SendKeystroke", "enter"],
+ "shift-pageup": "terminal::ScrollPageUp",
+ "shift-pagedown": "terminal::ScrollPageDown",
+ "shift-up": "terminal::ScrollLineUp",
+ "shift-down": "terminal::ScrollLineDown",
+ "shift-home": "terminal::ScrollToTop",
+ "shift-end": "terminal::ScrollToBottom",
+ "ctrl-shift-space": "terminal::ToggleViMode",
+ "ctrl-shift-r": "terminal::RerunTask",
+ "ctrl-alt-r": "terminal::RerunTask",
+ "alt-t": "terminal::RerunTask"
+ }
+ },
+ {
+ "context": "ZedPredictModal",
+ "use_key_equivalents": true,
+ "bindings": {
+ "escape": "menu::Cancel"
+ }
+ },
+ {
+ "context": "ConfigureContextServerModal > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "escape": "menu::Cancel",
+ "enter": "editor::Newline",
+ "ctrl-enter": "menu::Confirm"
+ }
+ },
+ {
+ "context": "OnboardingAiConfigurationModal",
+ "use_key_equivalents": true,
+ "bindings": {
+ "escape": "menu::Cancel"
+ }
+ },
+ {
+ "context": "Diagnostics",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh"
+ }
+ },
+ {
+ "context": "DebugConsole > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "enter": "menu::Confirm",
+ "alt-enter": "console::WatchExpression"
+ }
+ },
+ {
+ "context": "RunModal",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-tab": "pane::ActivateNextItem",
+ "ctrl-shift-tab": "pane::ActivatePreviousItem"
+ }
+ },
+ {
+ "context": "MarkdownPreview",
+ "use_key_equivalents": true,
+ "bindings": {
+ "pageup": "markdown::MovePageUp",
+ "pagedown": "markdown::MovePageDown"
+ }
+ },
+ {
+ "context": "KeymapEditor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-f": "search::FocusSearch",
+ "alt-find": "keymap_editor::ToggleKeystrokeSearch",
+ "alt-f": "keymap_editor::ToggleKeystrokeSearch",
+ "alt-c": "keymap_editor::ToggleConflictFilter",
+ "enter": "keymap_editor::EditBinding",
+ "alt-enter": "keymap_editor::CreateBinding",
+ "ctrl-c": "keymap_editor::CopyAction",
+ "ctrl-shift-c": "keymap_editor::CopyContext",
+ "ctrl-t": "keymap_editor::ShowMatchingKeybinds"
+ }
+ },
+ {
+ "context": "KeystrokeInput",
+ "use_key_equivalents": true,
+ "bindings": {
+ "enter": "keystroke_input::StartRecording",
+ "escape escape escape": "keystroke_input::StopRecording",
+ "delete": "keystroke_input::ClearKeystrokes"
+ }
+ },
+ {
+ "context": "KeybindEditorModal",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-enter": "menu::Confirm",
+ "escape": "menu::Cancel"
+ }
+ },
+ {
+ "context": "KeybindEditorModal > Editor",
+ "use_key_equivalents": true,
+ "bindings": {
+ "up": "menu::SelectPrevious",
+ "down": "menu::SelectNext"
+ }
+ },
+ {
+ "context": "Onboarding",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-1": "onboarding::ActivateBasicsPage",
+ "ctrl-2": "onboarding::ActivateEditingPage",
+ "ctrl-3": "onboarding::ActivateAISetupPage",
+ "ctrl-escape": "onboarding::Finish",
+ "alt-tab": "onboarding::SignIn",
+ "shift-alt-a": "onboarding::OpenAccount"
+ }
+ }
+]
@@ -38,6 +38,7 @@
"alt-;": ["editor::ToggleComments", { "advance_downwards": false }],
"ctrl-x ctrl-;": "editor::ToggleComments",
"alt-.": "editor::GoToDefinition", // xref-find-definitions
+ "alt-?": "editor::FindAllReferences", // xref-find-references
"alt-,": "pane::GoBack", // xref-pop-marker-stack
"ctrl-x h": "editor::SelectAll", // mark-whole-buffer
"ctrl-d": "editor::Delete", // delete-char
@@ -38,6 +38,7 @@
"alt-;": ["editor::ToggleComments", { "advance_downwards": false }],
"ctrl-x ctrl-;": "editor::ToggleComments",
"alt-.": "editor::GoToDefinition", // xref-find-definitions
+ "alt-?": "editor::FindAllReferences", // xref-find-references
"alt-,": "pane::GoBack", // xref-pop-marker-stack
"ctrl-x h": "editor::SelectAll", // mark-whole-buffer
"ctrl-d": "editor::Delete", // delete-char
@@ -428,12 +428,14 @@
"g h": "vim::StartOfLine",
"g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s"
"g e": "vim::EndOfDocument",
+ "g .": "vim::HelixGotoLastModification", // go to last modification
"g r": "editor::FindAllReferences", // zed specific
"g t": "vim::WindowTop",
"g c": "vim::WindowMiddle",
"g b": "vim::WindowBottom",
- "x": "editor::SelectLine",
+ "shift-r": "editor::Paste",
+ "x": "vim::HelixSelectLine",
"shift-x": "editor::SelectLine",
"%": "editor::SelectAll",
// Window mode
@@ -363,6 +363,8 @@
// Whether to show code action buttons in the editor toolbar.
"code_actions": false
},
+ // Whether to allow windows to tab together based on the user’s tabbing preference (macOS only).
+ "use_system_window_tabs": false,
// Titlebar related settings
"title_bar": {
// Whether to show the branch icon beside branch switcher in the titlebar.
@@ -653,6 +655,8 @@
// "never"
"show": "always"
},
+ // Whether to enable drag-and-drop operations in the project panel.
+ "drag_and_drop": true,
// Whether to hide the root entry when only one folder is open in the window.
"hide_root": false
},
@@ -1581,7 +1585,7 @@
"ensure_final_newline_on_save": false
},
"Elixir": {
- "language_servers": ["elixir-ls", "!next-ls", "!lexical", "..."]
+ "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."]
},
"Elm": {
"tab_size": 4
@@ -1606,7 +1610,7 @@
}
},
"HEEX": {
- "language_servers": ["elixir-ls", "!next-ls", "!lexical", "..."]
+ "language_servers": ["elixir-ls", "!expert", "!next-ls", "!lexical", "..."]
},
"HTML": {
"prettier": {
@@ -43,8 +43,8 @@
// "args": ["--login"]
// }
// }
- "shell": "system",
+ "shell": "system"
// Represents the tags for inline runnable indicators, or spawning multiple tasks at once.
- "tags": []
+ // "tags": []
}
]
@@ -789,16 +789,12 @@ pub enum ThreadStatus {
#[derive(Debug, Clone)]
pub enum LoadError {
- NotInstalled {
- error_message: SharedString,
- install_message: SharedString,
- install_command: String,
- },
Unsupported {
- error_message: SharedString,
- upgrade_message: SharedString,
- upgrade_command: String,
+ command: SharedString,
+ current_version: SharedString,
+ minimum_version: SharedString,
},
+ FailedToInstall(SharedString),
Exited {
status: ExitStatus,
},
@@ -808,12 +804,19 @@ pub enum LoadError {
impl Display for LoadError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
- LoadError::NotInstalled { error_message, .. }
- | LoadError::Unsupported { error_message, .. } => {
- write!(f, "{error_message}")
+ LoadError::Unsupported {
+ command: path,
+ current_version,
+ minimum_version,
+ } => {
+ write!(
+ f,
+ "version {current_version} from {path} is not supported (need at least {minimum_version})"
+ )
}
+ LoadError::FailedToInstall(msg) => write!(f, "Failed to install: {msg}"),
LoadError::Exited { status } => write!(f, "Server exited with status {status}"),
- LoadError::Other(msg) => write!(f, "{}", msg),
+ LoadError::Other(msg) => write!(f, "{msg}"),
}
}
}
@@ -664,7 +664,7 @@ impl Thread {
}
pub fn get_or_init_configured_model(&mut self, cx: &App) -> Option<ConfiguredModel> {
- if self.configured_model.is_none() || self.messages.is_empty() {
+ if self.configured_model.is_none() {
self.configured_model = LanguageModelRegistry::read_global(cx).default_model();
}
self.configured_model.clone()
@@ -2097,7 +2097,7 @@ impl Thread {
}
pub fn summarize(&mut self, cx: &mut Context<Self>) {
- let Some(model) = LanguageModelRegistry::read_global(cx).thread_summary_model(cx) else {
+ let Some(model) = LanguageModelRegistry::read_global(cx).thread_summary_model() else {
println!("No thread summary model");
return;
};
@@ -2416,7 +2416,7 @@ impl Thread {
}
let Some(ConfiguredModel { model, provider }) =
- LanguageModelRegistry::read_global(cx).thread_summary_model(cx)
+ LanguageModelRegistry::read_global(cx).thread_summary_model()
else {
return;
};
@@ -5410,10 +5410,13 @@ fn main() {{
}),
cx,
);
- registry.set_thread_summary_model(Some(ConfiguredModel {
- provider,
- model: model.clone(),
- }));
+ registry.set_thread_summary_model(
+ Some(ConfiguredModel {
+ provider,
+ model: model.clone(),
+ }),
+ cx,
+ );
})
});
@@ -61,16 +61,19 @@ pub struct LanguageModels {
model_list: acp_thread::AgentModelList,
refresh_models_rx: watch::Receiver<()>,
refresh_models_tx: watch::Sender<()>,
+ _authenticate_all_providers_task: Task<()>,
}
impl LanguageModels {
- fn new(cx: &App) -> Self {
+ fn new(cx: &mut App) -> Self {
let (refresh_models_tx, refresh_models_rx) = watch::channel(());
+
let mut this = Self {
models: HashMap::default(),
model_list: acp_thread::AgentModelList::Grouped(IndexMap::default()),
refresh_models_rx,
refresh_models_tx,
+ _authenticate_all_providers_task: Self::authenticate_all_language_model_providers(cx),
};
this.refresh_list(cx);
this
@@ -90,7 +93,7 @@ impl LanguageModels {
let mut recommended = Vec::new();
for provider in &providers {
for model in provider.recommended_models(cx) {
- recommended_models.insert(model.id());
+ recommended_models.insert((model.provider_id(), model.id()));
recommended.push(Self::map_language_model_to_info(&model, provider));
}
}
@@ -107,7 +110,7 @@ impl LanguageModels {
for model in provider.provided_models(cx) {
let model_info = Self::map_language_model_to_info(&model, &provider);
let model_id = model_info.id.clone();
- if !recommended_models.contains(&model.id()) {
+ if !recommended_models.contains(&(model.provider_id(), model.id())) {
provider_models.push(model_info);
}
models.insert(model_id, model);
@@ -150,6 +153,52 @@ impl LanguageModels {
fn model_id(model: &Arc<dyn LanguageModel>) -> acp_thread::AgentModelId {
acp_thread::AgentModelId(format!("{}/{}", model.provider_id().0, model.id().0).into())
}
+
+ fn authenticate_all_language_model_providers(cx: &mut App) -> Task<()> {
+ let authenticate_all_providers = LanguageModelRegistry::global(cx)
+ .read(cx)
+ .providers()
+ .iter()
+ .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
+ .collect::<Vec<_>>();
+
+ cx.background_spawn(async move {
+ for (provider_id, provider_name, authenticate_task) in authenticate_all_providers {
+ if let Err(err) = authenticate_task.await {
+ if matches!(err, language_model::AuthenticateError::CredentialsNotFound) {
+ // Since we're authenticating these providers in the
+ // background for the purposes of populating the
+ // language selector, we don't care about providers
+ // where the credentials are not found.
+ } else {
+ // Some providers have noisy failure states that we
+ // don't want to spam the logs with every time the
+ // language model selector is initialized.
+ //
+ // Ideally these should have more clear failure modes
+ // that we know are safe to ignore here, like what we do
+ // with `CredentialsNotFound` above.
+ match provider_id.0.as_ref() {
+ "lmstudio" | "ollama" => {
+ // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated".
+ //
+ // These fail noisily, so we don't log them.
+ }
+ "copilot_chat" => {
+ // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors.
+ }
+ _ => {
+ log::error!(
+ "Failed to authenticate provider: {}: {err}",
+ provider_name.0
+ );
+ }
+ }
+ }
+ }
+ }
+ })
+ }
}
pub struct NativeAgent {
@@ -228,7 +277,7 @@ impl NativeAgent {
) -> Entity<AcpThread> {
let connection = Rc::new(NativeAgentConnection(cx.entity()));
let registry = LanguageModelRegistry::read_global(cx);
- let summarization_model = registry.thread_summary_model(cx).map(|c| c.model);
+ let summarization_model = registry.thread_summary_model().map(|c| c.model);
thread_handle.update(cx, |thread, cx| {
thread.set_summarization_model(summarization_model, cx);
@@ -524,7 +573,7 @@ impl NativeAgent {
let registry = LanguageModelRegistry::read_global(cx);
let default_model = registry.default_model().map(|m| m.model);
- let summarization_model = registry.thread_summary_model(cx).map(|m| m.model);
+ let summarization_model = registry.thread_summary_model().map(|m| m.model);
for session in self.sessions.values_mut() {
session.thread.update(cx, |thread, cx| {
@@ -1,10 +1,9 @@
use std::{any::Any, path::Path, rc::Rc, sync::Arc};
-use agent_servers::AgentServer;
+use agent_servers::{AgentServer, AgentServerDelegate};
use anyhow::Result;
use fs::Fs;
use gpui::{App, Entity, SharedString, Task};
-use project::Project;
use prompt_store::PromptStore;
use crate::{HistoryStore, NativeAgent, NativeAgentConnection, templates::Templates};
@@ -30,14 +29,6 @@ impl AgentServer for NativeAgentServer {
"Zed Agent".into()
}
- fn empty_state_headline(&self) -> SharedString {
- self.name()
- }
-
- fn empty_state_message(&self) -> SharedString {
- "".into()
- }
-
fn logo(&self) -> ui::IconName {
ui::IconName::ZedAgent
}
@@ -45,14 +36,14 @@ impl AgentServer for NativeAgentServer {
fn connect(
&self,
_root_dir: &Path,
- project: &Entity<Project>,
+ delegate: AgentServerDelegate,
cx: &mut App,
) -> Task<Result<Rc<dyn acp_thread::AgentConnection>>> {
log::debug!(
"NativeAgentServer::connect called for path: {:?}",
_root_dir
);
- let project = project.clone();
+ let project = delegate.project().clone();
let fs = self.fs.clone();
let history = self.history.clone();
let prompt_store = PromptStore::global(cx);
@@ -72,6 +72,7 @@ async fn test_echo(cx: &mut TestAppContext) {
}
#[gpui::test]
+#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows
async fn test_thinking(cx: &mut TestAppContext) {
let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await;
let fake_model = model.as_fake();
@@ -471,7 +472,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
tool_name: ToolRequiringPermission::name().into(),
is_error: true,
content: "Permission to run tool denied by user".into(),
- output: None
+ output: Some("Permission to run tool denied by user".into())
})
]
);
@@ -1821,11 +1822,11 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
let clock = Arc::new(clock::FakeSystemClock::new());
let client = Client::new(clock, http_client, cx);
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
- Project::init_settings(cx);
- agent_settings::init(cx);
language_model::init(client.clone(), cx);
language_models::init(user_store, client.clone(), cx);
+ Project::init_settings(cx);
LanguageModelRegistry::test(cx);
+ agent_settings::init(cx);
});
cx.executor().forbid_parking();
@@ -732,7 +732,17 @@ impl Thread {
stream.update_tool_call_fields(
&tool_use.id,
acp::ToolCallUpdateFields {
- status: Some(acp::ToolCallStatus::Completed),
+ status: Some(
+ tool_result
+ .as_ref()
+ .map_or(acp::ToolCallStatus::Failed, |result| {
+ if result.is_error {
+ acp::ToolCallStatus::Failed
+ } else {
+ acp::ToolCallStatus::Completed
+ }
+ }),
+ ),
raw_output: output,
..Default::default()
},
@@ -1557,7 +1567,7 @@ impl Thread {
tool_name: tool_use.name,
is_error: true,
content: LanguageModelToolResultContent::Text(Arc::from(error.to_string())),
- output: None,
+ output: Some(error.to_string().into()),
},
}
}))
@@ -2459,6 +2469,30 @@ impl ToolCallEventStreamReceiver {
}
}
+ pub async fn expect_update_fields(&mut self) -> acp::ToolCallUpdateFields {
+ let event = self.0.next().await;
+ if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(
+ update,
+ )))) = event
+ {
+ update.fields
+ } else {
+ panic!("Expected update fields but got: {:?}", event);
+ }
+ }
+
+ pub async fn expect_diff(&mut self) -> Entity<acp_thread::Diff> {
+ let event = self.0.next().await;
+ if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateDiff(
+ update,
+ )))) = event
+ {
+ update.diff
+ } else {
+ panic!("Expected diff but got: {:?}", event);
+ }
+ }
+
pub async fn expect_terminal(&mut self) -> Entity<acp_thread::Terminal> {
let event = self.0.next().await;
if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateTerminal(
@@ -273,6 +273,13 @@ impl AgentTool for EditFileTool {
let diff = cx.new(|cx| Diff::new(buffer.clone(), cx))?;
event_stream.update_diff(diff.clone());
+ let _finalize_diff = util::defer({
+ let diff = diff.downgrade();
+ let mut cx = cx.clone();
+ move || {
+ diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok();
+ }
+ });
let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
let old_text = cx
@@ -389,8 +396,6 @@ impl AgentTool for EditFileTool {
})
.await;
- diff.update(cx, |diff, cx| diff.finalize(cx)).ok();
-
let input_path = input.path.display();
if unified_diff.is_empty() {
anyhow::ensure!(
@@ -1545,6 +1550,100 @@ mod tests {
);
}
+ #[gpui::test]
+ async fn test_diff_finalization(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = project::FakeFs::new(cx.executor());
+ fs.insert_tree("/", json!({"main.rs": ""})).await;
+
+ let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await;
+ let languages = project.read_with(cx, |project, _cx| project.languages().clone());
+ let context_server_registry =
+ cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
+ let model = Arc::new(FakeLanguageModel::default());
+ let thread = cx.new(|cx| {
+ Thread::new(
+ project.clone(),
+ cx.new(|_cx| ProjectContext::default()),
+ context_server_registry.clone(),
+ Templates::new(),
+ Some(model.clone()),
+ cx,
+ )
+ });
+
+ // Ensure the diff is finalized after the edit completes.
+ {
+ let tool = Arc::new(EditFileTool::new(thread.downgrade(), languages.clone()));
+ let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
+ let edit = cx.update(|cx| {
+ tool.run(
+ EditFileToolInput {
+ display_description: "Edit file".into(),
+ path: path!("/main.rs").into(),
+ mode: EditFileMode::Edit,
+ },
+ stream_tx,
+ cx,
+ )
+ });
+ stream_rx.expect_update_fields().await;
+ let diff = stream_rx.expect_diff().await;
+ diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
+ cx.run_until_parked();
+ model.end_last_completion_stream();
+ edit.await.unwrap();
+ diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
+ }
+
+ // Ensure the diff is finalized if an error occurs while editing.
+ {
+ model.forbid_requests();
+ let tool = Arc::new(EditFileTool::new(thread.downgrade(), languages.clone()));
+ let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
+ let edit = cx.update(|cx| {
+ tool.run(
+ EditFileToolInput {
+ display_description: "Edit file".into(),
+ path: path!("/main.rs").into(),
+ mode: EditFileMode::Edit,
+ },
+ stream_tx,
+ cx,
+ )
+ });
+ stream_rx.expect_update_fields().await;
+ let diff = stream_rx.expect_diff().await;
+ diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
+ edit.await.unwrap_err();
+ diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
+ model.allow_requests();
+ }
+
+ // Ensure the diff is finalized if the tool call gets dropped.
+ {
+ let tool = Arc::new(EditFileTool::new(thread.downgrade(), languages.clone()));
+ let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
+ let edit = cx.update(|cx| {
+ tool.run(
+ EditFileToolInput {
+ display_description: "Edit file".into(),
+ path: path!("/main.rs").into(),
+ mode: EditFileMode::Edit,
+ },
+ stream_tx,
+ cx,
+ )
+ });
+ stream_rx.expect_update_fields().await;
+ let diff = stream_rx.expect_diff().await;
+ diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
+ drop(edit);
+ cx.run_until_parked();
+ diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
+ }
+ }
+
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
@@ -2,7 +2,7 @@ use agent_client_protocol as acp;
use anyhow::Result;
use futures::{FutureExt as _, future::Shared};
use gpui::{App, AppContext, Entity, SharedString, Task};
-use project::{Project, terminals::TerminalKind};
+use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{
@@ -144,14 +144,14 @@ impl AgentTool for TerminalTool {
let terminal = self
.project
.update(cx, |project, cx| {
- project.create_terminal(
- TerminalKind::Task(task::SpawnInTerminal {
+ project.create_terminal_task(
+ task::SpawnInTerminal {
command: Some(program),
args,
cwd: working_dir.clone(),
env,
..Default::default()
- }),
+ },
cx,
)
})?
@@ -6,7 +6,7 @@ publish.workspace = true
license = "GPL-3.0-or-later"
[features]
-test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support", "dep:env_logger", "fs", "client/test-support", "dep:gpui_tokio", "reqwest_client/test-support"]
+test-support = ["acp_thread/test-support", "gpui/test-support", "project/test-support", "dep:env_logger", "client/test-support", "dep:gpui_tokio", "reqwest_client/test-support"]
e2e = []
[lints]
@@ -25,21 +25,19 @@ agent_settings.workspace = true
anyhow.workspace = true
client = { workspace = true, optional = true }
collections.workspace = true
-context_server.workspace = true
env_logger = { workspace = true, optional = true }
-fs = { workspace = true, optional = true }
+fs.workspace = true
futures.workspace = true
gpui.workspace = true
gpui_tokio = { workspace = true, optional = true }
indoc.workspace = true
-itertools.workspace = true
language.workspace = true
language_model.workspace = true
language_models.workspace = true
log.workspace = true
+node_runtime.workspace = true
paths.workspace = true
project.workspace = true
-rand.workspace = true
reqwest_client = { workspace = true, optional = true }
schemars.workspace = true
semver.workspace = true
@@ -47,12 +45,10 @@ serde.workspace = true
serde_json.workspace = true
settings.workspace = true
smol.workspace = true
-strum.workspace = true
tempfile.workspace = true
thiserror.workspace = true
ui.workspace = true
util.workspace = true
-uuid.workspace = true
watch.workspace = true
which.workspace = true
workspace-hack.workspace = true
@@ -3,6 +3,7 @@ use acp_thread::AgentConnection;
use acp_tools::AcpConnectionRegistry;
use action_log::ActionLog;
use agent_client_protocol::{self as acp, Agent as _, ErrorCode};
+use agent_settings::AgentSettings;
use anyhow::anyhow;
use collections::HashMap;
use futures::AsyncBufReadExt as _;
@@ -10,6 +11,7 @@ use futures::channel::oneshot;
use futures::io::BufReader;
use project::Project;
use serde::Deserialize;
+use settings::Settings as _;
use std::{any::Any, cell::RefCell};
use std::{path::Path, rc::Rc};
use thiserror::Error;
@@ -30,6 +32,8 @@ pub struct AcpConnection {
auth_methods: Vec<acp::AuthMethod>,
prompt_capabilities: acp::PromptCapabilities,
_io_task: Task<Result<()>>,
+ _wait_task: Task<Result<()>>,
+ _stderr_task: Task<Result<()>>,
}
pub struct AcpSession {
@@ -56,7 +60,7 @@ impl AcpConnection {
root_dir: &Path,
cx: &mut AsyncApp,
) -> Result<Self> {
- let mut child = util::command::new_smol_command(&command.path)
+ let mut child = util::command::new_smol_command(command.path)
.args(command.args.iter().map(|arg| arg.as_str()))
.envs(command.env.iter().flatten())
.current_dir(root_dir)
@@ -86,7 +90,7 @@ impl AcpConnection {
let io_task = cx.background_spawn(io_task);
- cx.background_spawn(async move {
+ let stderr_task = cx.background_spawn(async move {
let mut stderr = BufReader::new(stderr);
let mut line = String::new();
while let Ok(n) = stderr.read_line(&mut line).await
@@ -95,10 +99,10 @@ impl AcpConnection {
log::warn!("agent stderr: {}", &line);
line.clear();
}
- })
- .detach();
+ Ok(())
+ });
- cx.spawn({
+ let wait_task = cx.spawn({
let sessions = sessions.clone();
async move |cx| {
let status = child.status().await?;
@@ -114,8 +118,7 @@ impl AcpConnection {
anyhow::Ok(())
}
- })
- .detach();
+ });
let connection = Rc::new(connection);
@@ -148,8 +151,14 @@ impl AcpConnection {
sessions,
prompt_capabilities: response.agent_capabilities.prompt_capabilities,
_io_task: io_task,
+ _wait_task: wait_task,
+ _stderr_task: stderr_task,
})
}
+
+ pub fn prompt_capabilities(&self) -> &acp::PromptCapabilities {
+ &self.prompt_capabilities
+ }
}
impl AgentConnection for AcpConnection {
@@ -162,12 +171,34 @@ impl AgentConnection for AcpConnection {
let conn = self.connection.clone();
let sessions = self.sessions.clone();
let cwd = cwd.to_path_buf();
+ let context_server_store = project.read(cx).context_server_store().read(cx);
+ let mcp_servers = context_server_store
+ .configured_server_ids()
+ .iter()
+ .filter_map(|id| {
+ let configuration = context_server_store.configuration_for_server(id)?;
+ let command = configuration.command();
+ Some(acp::McpServer {
+ name: id.0.to_string(),
+ command: command.path.clone(),
+ args: command.args.clone(),
+ env: if let Some(env) = command.env.as_ref() {
+ env.iter()
+ .map(|(name, value)| acp::EnvVariable {
+ name: name.clone(),
+ value: value.clone(),
+ })
+ .collect()
+ } else {
+ vec![]
+ },
+ })
+ })
+ .collect();
+
cx.spawn(async move |cx| {
let response = conn
- .new_session(acp::NewSessionRequest {
- mcp_servers: vec![],
- cwd,
- })
+ .new_session(acp::NewSessionRequest { mcp_servers, cwd })
.await
.map_err(|err| {
if err.code == acp::ErrorCode::AUTH_REQUIRED.code {
@@ -313,6 +344,28 @@ impl acp::Client for ClientDelegate {
arguments: acp::RequestPermissionRequest,
) -> Result<acp::RequestPermissionResponse, acp::Error> {
let cx = &mut self.cx.clone();
+
+ // If always_allow_tool_actions is enabled, then auto-choose the first "Allow" button
+ if AgentSettings::try_read_global(cx, |settings| settings.always_allow_tool_actions)
+ .unwrap_or(false)
+ {
+ // Don't use AllowAlways, because then if you were to turn off always_allow_tool_actions,
+ // some tools would (incorrectly) continue to auto-accept.
+ if let Some(allow_once_option) = arguments.options.iter().find_map(|option| {
+ if matches!(option.kind, acp::PermissionOptionKind::AllowOnce) {
+ Some(option.id.clone())
+ } else {
+ None
+ }
+ }) {
+ return Ok(acp::RequestPermissionResponse {
+ outcome: acp::RequestPermissionOutcome::Selected {
+ option_id: allow_once_option,
+ },
+ });
+ }
+ }
+
let rx = self
.sessions
.borrow()
@@ -13,12 +13,19 @@ pub use gemini::*;
pub use settings::*;
use acp_thread::AgentConnection;
+use acp_thread::LoadError;
use anyhow::Result;
+use anyhow::anyhow;
+use anyhow::bail;
use collections::HashMap;
+use gpui::AppContext as _;
use gpui::{App, AsyncApp, Entity, SharedString, Task};
+use node_runtime::VersionStrategy;
use project::Project;
use schemars::JsonSchema;
+use semver::Version;
use serde::{Deserialize, Serialize};
+use std::str::FromStr as _;
use std::{
any::Any,
path::{Path, PathBuf},
@@ -31,17 +38,108 @@ pub fn init(cx: &mut App) {
settings::init(cx);
}
+pub struct AgentServerDelegate {
+ project: Entity<Project>,
+ status_tx: watch::Sender<SharedString>,
+}
+
+impl AgentServerDelegate {
+ pub fn new(project: Entity<Project>, status_tx: watch::Sender<SharedString>) -> Self {
+ Self { project, status_tx }
+ }
+
+ pub fn project(&self) -> &Entity<Project> {
+ &self.project
+ }
+
+ fn get_or_npm_install_builtin_agent(
+ self,
+ binary_name: SharedString,
+ package_name: SharedString,
+ entrypoint_path: PathBuf,
+ ignore_system_version: bool,
+ minimum_version: Option<Version>,
+ cx: &mut App,
+ ) -> Task<Result<AgentServerCommand>> {
+ let project = self.project;
+ let fs = project.read(cx).fs().clone();
+ let Some(node_runtime) = project.read(cx).node_runtime().cloned() else {
+ return Task::ready(Err(anyhow!("Missing node runtime")));
+ };
+ let mut status_tx = self.status_tx;
+
+ cx.spawn(async move |cx| {
+ if !ignore_system_version {
+ if let Some(bin) = find_bin_in_path(binary_name.clone(), &project, cx).await {
+ return Ok(AgentServerCommand { path: bin, args: Vec::new(), env: Default::default() })
+ }
+ }
+
+ cx.background_spawn(async move {
+ let node_path = node_runtime.binary_path().await?;
+ let dir = paths::data_dir().join("external_agents").join(binary_name.as_str());
+ fs.create_dir(&dir).await?;
+ let local_executable_path = dir.join(entrypoint_path);
+ let command = AgentServerCommand {
+ path: node_path,
+ args: vec![local_executable_path.to_string_lossy().to_string()],
+ env: Default::default(),
+ };
+
+ let installed_version = node_runtime
+ .npm_package_installed_version(&dir, &package_name)
+ .await?
+ .filter(|version| {
+ Version::from_str(&version)
+ .is_ok_and(|version| Some(version) >= minimum_version)
+ });
+
+ status_tx.send("Checking for latest version…".into())?;
+ let latest_version = match node_runtime.npm_package_latest_version(&package_name).await
+ {
+ Ok(latest_version) => latest_version,
+ Err(e) => {
+ if let Some(installed_version) = installed_version {
+ log::error!("{e}");
+ log::warn!("failed to fetch latest version of {package_name}, falling back to cached version {installed_version}");
+ return Ok(command);
+ } else {
+ bail!(e);
+ }
+ }
+ };
+
+ let should_install = node_runtime
+ .should_install_npm_package(
+ &package_name,
+ &local_executable_path,
+ &dir,
+ VersionStrategy::Latest(&latest_version),
+ )
+ .await;
+
+ if should_install {
+ status_tx.send("Installing latest version…".into())?;
+ node_runtime
+ .npm_install_packages(&dir, &[(&package_name, &latest_version)])
+ .await?;
+ }
+
+ Ok(command)
+ }).await.map_err(|e| LoadError::FailedToInstall(e.to_string().into()).into())
+ })
+ }
+}
+
pub trait AgentServer: Send {
fn logo(&self) -> ui::IconName;
fn name(&self) -> SharedString;
- fn empty_state_headline(&self) -> SharedString;
- fn empty_state_message(&self) -> SharedString;
fn telemetry_id(&self) -> &'static str;
fn connect(
&self,
root_dir: &Path,
- project: &Entity<Project>,
+ delegate: AgentServerDelegate,
cx: &mut App,
) -> Task<Result<Rc<dyn AgentConnection>>>;
@@ -79,15 +177,6 @@ impl std::fmt::Debug for AgentServerCommand {
}
}
-pub enum AgentServerVersion {
- Supported,
- Unsupported {
- error_message: SharedString,
- upgrade_message: SharedString,
- upgrade_command: String,
- },
-}
-
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)]
pub struct AgentServerCommand {
#[serde(rename = "command")]
@@ -102,23 +191,16 @@ impl AgentServerCommand {
path_bin_name: &'static str,
extra_args: &[&'static str],
fallback_path: Option<&Path>,
- settings: Option<AgentServerSettings>,
+ settings: Option<BuiltinAgentServerSettings>,
project: &Entity<Project>,
cx: &mut AsyncApp,
) -> Option<Self> {
- if let Some(agent_settings) = settings {
- Some(Self {
- path: agent_settings.command.path,
- args: agent_settings
- .command
- .args
- .into_iter()
- .chain(extra_args.iter().map(|arg| arg.to_string()))
- .collect(),
- env: agent_settings.command.env,
- })
+ if let Some(settings) = settings
+ && let Some(command) = settings.custom_command()
+ {
+ Some(command)
} else {
- match find_bin_in_path(path_bin_name, project, cx).await {
+ match find_bin_in_path(path_bin_name.into(), project, cx).await {
Some(path) => Some(Self {
path,
args: extra_args.iter().map(|arg| arg.to_string()).collect(),
@@ -141,7 +223,7 @@ impl AgentServerCommand {
}
async fn find_bin_in_path(
- bin_name: &'static str,
+ bin_name: SharedString,
project: &Entity<Project>,
cx: &mut AsyncApp,
) -> Option<PathBuf> {
@@ -171,11 +253,11 @@ async fn find_bin_in_path(
cx.background_executor()
.spawn(async move {
let which_result = if cfg!(windows) {
- which::which(bin_name)
+ which::which(bin_name.as_str())
} else {
let env = env_task.await.unwrap_or_default();
let shell_path = env.get("PATH").cloned();
- which::which_in(bin_name, shell_path.as_ref(), root_dir.as_ref())
+ which::which_in(bin_name.as_str(), shell_path.as_ref(), root_dir.as_ref())
};
if let Err(which::Error::CannotFindBinaryPath) = which_result {
@@ -1,47 +1,23 @@
-mod edit_tool;
-mod mcp_server;
-mod permission_tool;
-mod read_tool;
-pub mod tools;
-mod write_tool;
-
-use action_log::ActionLog;
-use collections::HashMap;
-use context_server::listener::McpServerTool;
use language_models::provider::anthropic::AnthropicLanguageModelProvider;
-use project::Project;
use settings::SettingsStore;
-use smol::process::Child;
use std::any::Any;
-use std::cell::RefCell;
-use std::fmt::Display;
-use std::path::{Path, PathBuf};
+use std::path::Path;
use std::rc::Rc;
-use util::command::new_smol_command;
-use uuid::Uuid;
-use agent_client_protocol as acp;
-use anyhow::{Context as _, Result, anyhow};
-use futures::channel::oneshot;
-use futures::{AsyncBufReadExt, AsyncWriteExt};
-use futures::{
- AsyncRead, AsyncWrite, FutureExt, StreamExt,
- channel::mpsc::{self, UnboundedReceiver, UnboundedSender},
- io::BufReader,
- select_biased,
-};
-use gpui::{App, AppContext, AsyncApp, Entity, SharedString, Task, WeakEntity};
-use serde::{Deserialize, Serialize};
-use util::{ResultExt, debug_panic};
+use anyhow::Result;
+use gpui::{App, AppContext as _, SharedString, Task};
-use crate::claude::mcp_server::{ClaudeZedMcpServer, McpConfig};
-use crate::claude::tools::ClaudeTool;
-use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings};
-use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError, MentionUri};
+use crate::{AgentServer, AgentServerDelegate, AllAgentServersSettings};
+use acp_thread::AgentConnection;
#[derive(Clone)]
pub struct ClaudeCode;
+impl ClaudeCode {
+ const BINARY_NAME: &'static str = "claude-code-acp";
+ const PACKAGE_NAME: &'static str = "@zed-industries/claude-code-acp";
+}
+
impl AgentServer for ClaudeCode {
fn telemetry_id(&self) -> &'static str {
"claude-code"
@@ -51,1327 +27,55 @@ impl AgentServer for ClaudeCode {
"Claude Code".into()
}
- fn empty_state_headline(&self) -> SharedString {
- self.name()
- }
-
- fn empty_state_message(&self) -> SharedString {
- "How can I help you today?".into()
- }
-
fn logo(&self) -> ui::IconName {
ui::IconName::AiClaude
}
fn connect(
&self,
- _root_dir: &Path,
- _project: &Entity<Project>,
- _cx: &mut App,
+ root_dir: &Path,
+ delegate: AgentServerDelegate,
+ cx: &mut App,
) -> Task<Result<Rc<dyn AgentConnection>>> {
- let connection = ClaudeAgentConnection {
- sessions: Default::default(),
- };
-
- Task::ready(Ok(Rc::new(connection) as _))
- }
-
- fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
- self
- }
-}
-
-struct ClaudeAgentConnection {
- sessions: Rc<RefCell<HashMap<acp::SessionId, ClaudeAgentSession>>>,
-}
+ let root_dir = root_dir.to_path_buf();
+ let server_name = self.name();
+ let settings = cx.read_global(|settings: &SettingsStore, _| {
+ settings.get::<AllAgentServersSettings>(None).claude.clone()
+ });
-impl AgentConnection for ClaudeAgentConnection {
- fn new_thread(
- self: Rc<Self>,
- project: Entity<Project>,
- cwd: &Path,
- cx: &mut App,
- ) -> Task<Result<Entity<AcpThread>>> {
- let cwd = cwd.to_owned();
cx.spawn(async move |cx| {
- let settings = cx.read_global(|settings: &SettingsStore, _| {
- settings.get::<AllAgentServersSettings>(None).claude.clone()
- })?;
-
- let Some(command) = AgentServerCommand::resolve(
- "claude",
- &[],
- Some(&util::paths::home_dir().join(".claude/local/claude")),
- settings,
- &project,
- cx,
- )
- .await
- else {
- return Err(LoadError::NotInstalled {
- error_message: "Failed to find Claude Code binary".into(),
- install_message: "Install Claude Code".into(),
- install_command: "npm install -g @anthropic-ai/claude-code@latest".into(),
- }.into());
- };
-
- let api_key =
- cx.update(AnthropicLanguageModelProvider::api_key)?
- .await
- .map_err(|err| {
- if err.is::<language_model::AuthenticateError>() {
- anyhow!(AuthRequired::new().with_language_model_provider(
- language_model::ANTHROPIC_PROVIDER_ID
- ))
- } else {
- anyhow!(err)
- }
- })?;
-
- let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid());
- let fs = project.read_with(cx, |project, _cx| project.fs().clone())?;
- let permission_mcp_server = ClaudeZedMcpServer::new(thread_rx.clone(), fs, cx).await?;
-
- let mut mcp_servers = HashMap::default();
- mcp_servers.insert(
- mcp_server::SERVER_NAME.to_string(),
- permission_mcp_server.server_config()?,
- );
- let mcp_config = McpConfig { mcp_servers };
-
- let mcp_config_file = tempfile::NamedTempFile::new()?;
- let (mcp_config_file, mcp_config_path) = mcp_config_file.into_parts();
-
- let mut mcp_config_file = smol::fs::File::from(mcp_config_file);
- mcp_config_file
- .write_all(serde_json::to_string(&mcp_config)?.as_bytes())
- .await?;
- mcp_config_file.flush().await?;
-
- let (incoming_message_tx, mut incoming_message_rx) = mpsc::unbounded();
- let (outgoing_tx, outgoing_rx) = mpsc::unbounded();
-
- let session_id = acp::SessionId(Uuid::new_v4().to_string().into());
-
- log::trace!("Starting session with id: {}", session_id);
-
- let mut child = spawn_claude(
- &command,
- ClaudeSessionMode::Start,
- session_id.clone(),
- api_key,
- &mcp_config_path,
- &cwd,
- )?;
-
- let stdout = child.stdout.take().context("Failed to take stdout")?;
- let stdin = child.stdin.take().context("Failed to take stdin")?;
- let stderr = child.stderr.take().context("Failed to take stderr")?;
-
- let pid = child.id();
- log::trace!("Spawned (pid: {})", pid);
-
- cx.background_spawn(async move {
- let mut stderr = BufReader::new(stderr);
- let mut line = String::new();
- while let Ok(n) = stderr.read_line(&mut line).await
- && n > 0
- {
- log::warn!("agent stderr: {}", &line);
- line.clear();
- }
- })
- .detach();
-
- cx.background_spawn(async move {
- let mut outgoing_rx = Some(outgoing_rx);
-
- ClaudeAgentSession::handle_io(
- outgoing_rx.take().unwrap(),
- incoming_message_tx.clone(),
- stdin,
- stdout,
- )
- .await?;
-
- log::trace!("Stopped (pid: {})", pid);
-
- drop(mcp_config_path);
- anyhow::Ok(())
- })
- .detach();
-
- let turn_state = Rc::new(RefCell::new(TurnState::None));
-
- let handler_task = cx.spawn({
- let turn_state = turn_state.clone();
- let mut thread_rx = thread_rx.clone();
- async move |cx| {
- while let Some(message) = incoming_message_rx.next().await {
- ClaudeAgentSession::handle_message(
- thread_rx.clone(),
- message,
- turn_state.clone(),
- cx,
- )
- .await
- }
-
- if let Some(status) = child.status().await.log_err()
- && let Some(thread) = thread_rx.recv().await.ok()
- {
- let version = claude_version(command.path.clone(), cx).await.log_err();
- let help = claude_help(command.path.clone(), cx).await.log_err();
- thread
- .update(cx, |thread, cx| {
- let error = if let Some(version) = version
- && let Some(help) = help
- && (!help.contains("--input-format")
- || !help.contains("--session-id"))
- {
- LoadError::Unsupported {
- error_message: format!(
- "Your installed version of Claude Code ({}, version {}) does not have required features for use with Zed.",
- command.path.to_string_lossy(),
- version,
- )
- .into(),
- upgrade_message: "Upgrade Claude Code to latest".into(),
- upgrade_command: format!(
- "{} update",
- command.path.to_string_lossy()
- ),
- }
- } else {
- LoadError::Exited { status }
- };
- thread.emit_load_error(error, cx);
- })
- .ok();
- }
- }
- });
-
- let action_log = cx.new(|_| ActionLog::new(project.clone()))?;
- let thread = cx.new(|cx| {
- AcpThread::new(
- "Claude Code",
- self.clone(),
- project,
- action_log,
- session_id.clone(),
- watch::Receiver::constant(acp::PromptCapabilities {
- image: true,
- audio: false,
- embedded_context: true,
- }),
- cx,
- )
- })?;
-
- thread_tx.send(thread.downgrade())?;
-
- let session = ClaudeAgentSession {
- outgoing_tx,
- turn_state,
- _handler_task: handler_task,
- _mcp_server: Some(permission_mcp_server),
+ let mut command = if let Some(settings) = settings {
+ settings.command
+ } else {
+ cx.update(|cx| {
+ delegate.get_or_npm_install_builtin_agent(
+ Self::BINARY_NAME.into(),
+ Self::PACKAGE_NAME.into(),
+ format!("node_modules/{}/dist/index.js", Self::PACKAGE_NAME).into(),
+ true,
+ None,
+ cx,
+ )
+ })?
+ .await?
};
- self.sessions.borrow_mut().insert(session_id, session);
+ if let Some(api_key) = cx
+ .update(AnthropicLanguageModelProvider::api_key)?
+ .await
+ .ok()
+ {
+ command
+ .env
+ .get_or_insert_default()
+ .insert("ANTHROPIC_API_KEY".to_owned(), api_key.key);
+ }
- Ok(thread)
+ crate::acp::connect(server_name, command.clone(), &root_dir, cx).await
})
}
- fn auth_methods(&self) -> &[acp::AuthMethod] {
- &[]
- }
-
- fn authenticate(&self, _: acp::AuthMethodId, _cx: &mut App) -> Task<Result<()>> {
- Task::ready(Err(anyhow!("Authentication not supported")))
- }
-
- fn prompt(
- &self,
- _id: Option<acp_thread::UserMessageId>,
- params: acp::PromptRequest,
- cx: &mut App,
- ) -> Task<Result<acp::PromptResponse>> {
- let sessions = self.sessions.borrow();
- let Some(session) = sessions.get(¶ms.session_id) else {
- return Task::ready(Err(anyhow!(
- "Attempted to send message to nonexistent session {}",
- params.session_id
- )));
- };
-
- let (end_tx, end_rx) = oneshot::channel();
- session.turn_state.replace(TurnState::InProgress { end_tx });
-
- let content = acp_content_to_claude(params.prompt);
-
- if let Err(err) = session.outgoing_tx.unbounded_send(SdkMessage::User {
- message: Message {
- role: Role::User,
- content: Content::Chunks(content),
- id: None,
- model: None,
- stop_reason: None,
- stop_sequence: None,
- usage: None,
- },
- session_id: Some(params.session_id.to_string()),
- }) {
- return Task::ready(Err(anyhow!(err)));
- }
-
- cx.foreground_executor().spawn(async move { end_rx.await? })
- }
-
- fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) {
- let sessions = self.sessions.borrow();
- let Some(session) = sessions.get(session_id) else {
- log::warn!("Attempted to cancel nonexistent session {}", session_id);
- return;
- };
-
- let request_id = new_request_id();
-
- let turn_state = session.turn_state.take();
- let TurnState::InProgress { end_tx } = turn_state else {
- // Already canceled or idle, put it back
- session.turn_state.replace(turn_state);
- return;
- };
-
- session.turn_state.replace(TurnState::CancelRequested {
- end_tx,
- request_id: request_id.clone(),
- });
-
- session
- .outgoing_tx
- .unbounded_send(SdkMessage::ControlRequest {
- request_id,
- request: ControlRequest::Interrupt,
- })
- .log_err();
- }
-
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
self
}
}
-
-#[derive(Clone, Copy)]
-enum ClaudeSessionMode {
- Start,
- #[expect(dead_code)]
- Resume,
-}
-
-fn spawn_claude(
- command: &AgentServerCommand,
- mode: ClaudeSessionMode,
- session_id: acp::SessionId,
- api_key: language_models::provider::anthropic::ApiKey,
- mcp_config_path: &Path,
- root_dir: &Path,
-) -> Result<Child> {
- let child = util::command::new_smol_command(&command.path)
- .args([
- "--input-format",
- "stream-json",
- "--output-format",
- "stream-json",
- "--print",
- "--verbose",
- "--mcp-config",
- mcp_config_path.to_string_lossy().as_ref(),
- "--permission-prompt-tool",
- &format!(
- "mcp__{}__{}",
- mcp_server::SERVER_NAME,
- permission_tool::PermissionTool::NAME,
- ),
- "--allowedTools",
- &format!(
- "mcp__{}__{}",
- mcp_server::SERVER_NAME,
- read_tool::ReadTool::NAME
- ),
- "--disallowedTools",
- "Read,Write,Edit,MultiEdit",
- ])
- .args(match mode {
- ClaudeSessionMode::Start => ["--session-id".to_string(), session_id.to_string()],
- ClaudeSessionMode::Resume => ["--resume".to_string(), session_id.to_string()],
- })
- .args(command.args.iter().map(|arg| arg.as_str()))
- .envs(command.env.iter().flatten())
- .env("ANTHROPIC_API_KEY", api_key.key)
- .current_dir(root_dir)
- .stdin(std::process::Stdio::piped())
- .stdout(std::process::Stdio::piped())
- .stderr(std::process::Stdio::piped())
- .kill_on_drop(true)
- .spawn()?;
-
- Ok(child)
-}
-
-fn claude_version(path: PathBuf, cx: &mut AsyncApp) -> Task<Result<semver::Version>> {
- cx.background_spawn(async move {
- let output = new_smol_command(path).arg("--version").output().await?;
- let output = String::from_utf8(output.stdout)?;
- let version = output
- .trim()
- .strip_suffix(" (Claude Code)")
- .context("parsing Claude version")?;
- let version = semver::Version::parse(version)?;
- anyhow::Ok(version)
- })
-}
-
-fn claude_help(path: PathBuf, cx: &mut AsyncApp) -> Task<Result<String>> {
- cx.background_spawn(async move {
- let output = new_smol_command(path).arg("--help").output().await?;
- let output = String::from_utf8(output.stdout)?;
- anyhow::Ok(output)
- })
-}
-
-struct ClaudeAgentSession {
- outgoing_tx: UnboundedSender<SdkMessage>,
- turn_state: Rc<RefCell<TurnState>>,
- _mcp_server: Option<ClaudeZedMcpServer>,
- _handler_task: Task<()>,
-}
-
-#[derive(Debug, Default)]
-enum TurnState {
- #[default]
- None,
- InProgress {
- end_tx: oneshot::Sender<Result<acp::PromptResponse>>,
- },
- CancelRequested {
- end_tx: oneshot::Sender<Result<acp::PromptResponse>>,
- request_id: String,
- },
- CancelConfirmed {
- end_tx: oneshot::Sender<Result<acp::PromptResponse>>,
- },
-}
-
-impl TurnState {
- fn is_canceled(&self) -> bool {
- matches!(self, TurnState::CancelConfirmed { .. })
- }
-
- fn end_tx(self) -> Option<oneshot::Sender<Result<acp::PromptResponse>>> {
- match self {
- TurnState::None => None,
- TurnState::InProgress { end_tx, .. } => Some(end_tx),
- TurnState::CancelRequested { end_tx, .. } => Some(end_tx),
- TurnState::CancelConfirmed { end_tx } => Some(end_tx),
- }
- }
-
- fn confirm_cancellation(self, id: &str) -> Self {
- match self {
- TurnState::CancelRequested { request_id, end_tx } if request_id == id => {
- TurnState::CancelConfirmed { end_tx }
- }
- _ => self,
- }
- }
-}
-
-impl ClaudeAgentSession {
- async fn handle_message(
- mut thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
- message: SdkMessage,
- turn_state: Rc<RefCell<TurnState>>,
- cx: &mut AsyncApp,
- ) {
- match message {
- // we should only be sending these out, they don't need to be in the thread
- SdkMessage::ControlRequest { .. } => {}
- SdkMessage::User {
- message,
- session_id: _,
- } => {
- let Some(thread) = thread_rx
- .recv()
- .await
- .log_err()
- .and_then(|entity| entity.upgrade())
- else {
- log::error!("Received an SDK message but thread is gone");
- return;
- };
-
- for chunk in message.content.chunks() {
- match chunk {
- ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => {
- if !turn_state.borrow().is_canceled() {
- thread
- .update(cx, |thread, cx| {
- thread.push_user_content_block(None, text.into(), cx)
- })
- .log_err();
- }
- }
- ContentChunk::ToolResult {
- content,
- tool_use_id,
- } => {
- let content = content.to_string();
- thread
- .update(cx, |thread, cx| {
- let id = acp::ToolCallId(tool_use_id.into());
- let set_new_content = !content.is_empty()
- && thread.tool_call(&id).is_none_or(|(_, tool_call)| {
- // preserve rich diff if we have one
- tool_call.diffs().next().is_none()
- });
-
- thread.update_tool_call(
- acp::ToolCallUpdate {
- id,
- fields: acp::ToolCallUpdateFields {
- status: if turn_state.borrow().is_canceled() {
- // Do not set to completed if turn was canceled
- None
- } else {
- Some(acp::ToolCallStatus::Completed)
- },
- content: set_new_content
- .then(|| vec![content.into()]),
- ..Default::default()
- },
- },
- cx,
- )
- })
- .log_err();
- }
- ContentChunk::Thinking { .. }
- | ContentChunk::RedactedThinking
- | ContentChunk::ToolUse { .. } => {
- debug_panic!(
- "Should not get {:?} with role: assistant. should we handle this?",
- chunk
- );
- }
- ContentChunk::Image { source } => {
- if !turn_state.borrow().is_canceled() {
- thread
- .update(cx, |thread, cx| {
- thread.push_user_content_block(None, source.into(), cx)
- })
- .log_err();
- }
- }
-
- ContentChunk::Document | ContentChunk::WebSearchToolResult => {
- thread
- .update(cx, |thread, cx| {
- thread.push_assistant_content_block(
- format!("Unsupported content: {:?}", chunk).into(),
- false,
- cx,
- )
- })
- .log_err();
- }
- }
- }
- }
- SdkMessage::Assistant {
- message,
- session_id: _,
- } => {
- let Some(thread) = thread_rx
- .recv()
- .await
- .log_err()
- .and_then(|entity| entity.upgrade())
- else {
- log::error!("Received an SDK message but thread is gone");
- return;
- };
-
- for chunk in message.content.chunks() {
- match chunk {
- ContentChunk::Text { text } | ContentChunk::UntaggedText(text) => {
- thread
- .update(cx, |thread, cx| {
- thread.push_assistant_content_block(text.into(), false, cx)
- })
- .log_err();
- }
- ContentChunk::Thinking { thinking } => {
- thread
- .update(cx, |thread, cx| {
- thread.push_assistant_content_block(thinking.into(), true, cx)
- })
- .log_err();
- }
- ContentChunk::RedactedThinking => {
- thread
- .update(cx, |thread, cx| {
- thread.push_assistant_content_block(
- "[REDACTED]".into(),
- true,
- cx,
- )
- })
- .log_err();
- }
- ContentChunk::ToolUse { id, name, input } => {
- let claude_tool = ClaudeTool::infer(&name, input);
-
- thread
- .update(cx, |thread, cx| {
- if let ClaudeTool::TodoWrite(Some(params)) = claude_tool {
- thread.update_plan(
- acp::Plan {
- entries: params
- .todos
- .into_iter()
- .map(Into::into)
- .collect(),
- },
- cx,
- )
- } else {
- thread.upsert_tool_call(
- claude_tool.as_acp(acp::ToolCallId(id.into())),
- cx,
- )?;
- }
- anyhow::Ok(())
- })
- .log_err();
- }
- ContentChunk::ToolResult { .. } | ContentChunk::WebSearchToolResult => {
- debug_panic!(
- "Should not get tool results with role: assistant. should we handle this?"
- );
- }
- ContentChunk::Image { source } => {
- thread
- .update(cx, |thread, cx| {
- thread.push_assistant_content_block(source.into(), false, cx)
- })
- .log_err();
- }
- ContentChunk::Document => {
- thread
- .update(cx, |thread, cx| {
- thread.push_assistant_content_block(
- format!("Unsupported content: {:?}", chunk).into(),
- false,
- cx,
- )
- })
- .log_err();
- }
- }
- }
- }
- SdkMessage::Result {
- is_error,
- subtype,
- result,
- ..
- } => {
- let turn_state = turn_state.take();
- let was_canceled = turn_state.is_canceled();
- let Some(end_turn_tx) = turn_state.end_tx() else {
- debug_panic!("Received `SdkMessage::Result` but there wasn't an active turn");
- return;
- };
-
- if is_error || (!was_canceled && subtype == ResultErrorType::ErrorDuringExecution) {
- end_turn_tx
- .send(Err(anyhow!(
- "Error: {}",
- result.unwrap_or_else(|| subtype.to_string())
- )))
- .ok();
- } else {
- let stop_reason = match subtype {
- ResultErrorType::Success => acp::StopReason::EndTurn,
- ResultErrorType::ErrorMaxTurns => acp::StopReason::MaxTurnRequests,
- ResultErrorType::ErrorDuringExecution => acp::StopReason::Cancelled,
- };
- end_turn_tx
- .send(Ok(acp::PromptResponse { stop_reason }))
- .ok();
- }
- }
- SdkMessage::ControlResponse { response } => {
- if matches!(response.subtype, ResultErrorType::Success) {
- let new_state = turn_state.take().confirm_cancellation(&response.request_id);
- turn_state.replace(new_state);
- } else {
- log::error!("Control response error: {:?}", response);
- }
- }
- SdkMessage::System { .. } => {}
- }
- }
-
- async fn handle_io(
- mut outgoing_rx: UnboundedReceiver<SdkMessage>,
- incoming_tx: UnboundedSender<SdkMessage>,
- mut outgoing_bytes: impl Unpin + AsyncWrite,
- incoming_bytes: impl Unpin + AsyncRead,
- ) -> Result<UnboundedReceiver<SdkMessage>> {
- let mut output_reader = BufReader::new(incoming_bytes);
- let mut outgoing_line = Vec::new();
- let mut incoming_line = String::new();
- loop {
- select_biased! {
- message = outgoing_rx.next() => {
- if let Some(message) = message {
- outgoing_line.clear();
- serde_json::to_writer(&mut outgoing_line, &message)?;
- log::trace!("send: {}", String::from_utf8_lossy(&outgoing_line));
- outgoing_line.push(b'\n');
- outgoing_bytes.write_all(&outgoing_line).await.ok();
- } else {
- break;
- }
- }
- bytes_read = output_reader.read_line(&mut incoming_line).fuse() => {
- if bytes_read? == 0 {
- break
- }
- log::trace!("recv: {}", &incoming_line);
- match serde_json::from_str::<SdkMessage>(&incoming_line) {
- Ok(message) => {
- incoming_tx.unbounded_send(message).log_err();
- }
- Err(error) => {
- log::error!("failed to parse incoming message: {error}. Raw: {incoming_line}");
- }
- }
- incoming_line.clear();
- }
- }
- }
-
- Ok(outgoing_rx)
- }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-struct Message {
- role: Role,
- content: Content,
- #[serde(skip_serializing_if = "Option::is_none")]
- id: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- model: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- stop_reason: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- stop_sequence: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- usage: Option<Usage>,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(untagged)]
-enum Content {
- UntaggedText(String),
- Chunks(Vec<ContentChunk>),
-}
-
-impl Content {
- pub fn chunks(self) -> impl Iterator<Item = ContentChunk> {
- match self {
- Self::Chunks(chunks) => chunks.into_iter(),
- Self::UntaggedText(text) => vec![ContentChunk::Text { text }].into_iter(),
- }
- }
-}
-
-impl Display for Content {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- Content::UntaggedText(txt) => write!(f, "{}", txt),
- Content::Chunks(chunks) => {
- for chunk in chunks {
- write!(f, "{}", chunk)?;
- }
- Ok(())
- }
- }
- }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(tag = "type", rename_all = "snake_case")]
-enum ContentChunk {
- Text {
- text: String,
- },
- ToolUse {
- id: String,
- name: String,
- input: serde_json::Value,
- },
- ToolResult {
- content: Content,
- tool_use_id: String,
- },
- Thinking {
- thinking: String,
- },
- RedactedThinking,
- Image {
- source: ImageSource,
- },
- // TODO
- Document,
- WebSearchToolResult,
- #[serde(untagged)]
- UntaggedText(String),
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(tag = "type", rename_all = "snake_case")]
-enum ImageSource {
- Base64 { data: String, media_type: String },
- Url { url: String },
-}
-
-impl Into<acp::ContentBlock> for ImageSource {
- fn into(self) -> acp::ContentBlock {
- match self {
- ImageSource::Base64 { data, media_type } => {
- acp::ContentBlock::Image(acp::ImageContent {
- annotations: None,
- data,
- mime_type: media_type,
- uri: None,
- })
- }
- ImageSource::Url { url } => acp::ContentBlock::Image(acp::ImageContent {
- annotations: None,
- data: "".to_string(),
- mime_type: "".to_string(),
- uri: Some(url),
- }),
- }
- }
-}
-
-impl Display for ContentChunk {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- ContentChunk::Text { text } => write!(f, "{}", text),
- ContentChunk::Thinking { thinking } => write!(f, "Thinking: {}", thinking),
- ContentChunk::RedactedThinking => write!(f, "Thinking: [REDACTED]"),
- ContentChunk::UntaggedText(text) => write!(f, "{}", text),
- ContentChunk::ToolResult { content, .. } => write!(f, "{}", content),
- ContentChunk::Image { .. }
- | ContentChunk::Document
- | ContentChunk::ToolUse { .. }
- | ContentChunk::WebSearchToolResult => {
- write!(f, "\n{:?}\n", &self)
- }
- }
- }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-struct Usage {
- input_tokens: u32,
- cache_creation_input_tokens: u32,
- cache_read_input_tokens: u32,
- output_tokens: u32,
- service_tier: String,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-enum Role {
- System,
- Assistant,
- User,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-struct MessageParam {
- role: Role,
- content: String,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(tag = "type", rename_all = "snake_case")]
-enum SdkMessage {
- // An assistant message
- Assistant {
- message: Message, // from Anthropic SDK
- #[serde(skip_serializing_if = "Option::is_none")]
- session_id: Option<String>,
- },
- // A user message
- User {
- message: Message, // from Anthropic SDK
- #[serde(skip_serializing_if = "Option::is_none")]
- session_id: Option<String>,
- },
- // Emitted as the last message in a conversation
- Result {
- subtype: ResultErrorType,
- duration_ms: f64,
- duration_api_ms: f64,
- is_error: bool,
- num_turns: i32,
- #[serde(skip_serializing_if = "Option::is_none")]
- result: Option<String>,
- session_id: String,
- total_cost_usd: f64,
- },
- // Emitted as the first message at the start of a conversation
- System {
- cwd: String,
- session_id: String,
- tools: Vec<String>,
- model: String,
- mcp_servers: Vec<McpServer>,
- #[serde(rename = "apiKeySource")]
- api_key_source: String,
- #[serde(rename = "permissionMode")]
- permission_mode: PermissionMode,
- },
- /// Messages used to control the conversation, outside of chat messages to the model
- ControlRequest {
- request_id: String,
- request: ControlRequest,
- },
- /// Response to a control request
- ControlResponse { response: ControlResponse },
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(tag = "subtype", rename_all = "snake_case")]
-enum ControlRequest {
- /// Cancel the current conversation
- Interrupt,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-struct ControlResponse {
- request_id: String,
- subtype: ResultErrorType,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
-#[serde(rename_all = "snake_case")]
-enum ResultErrorType {
- Success,
- ErrorMaxTurns,
- ErrorDuringExecution,
-}
-
-impl Display for ResultErrorType {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- ResultErrorType::Success => write!(f, "success"),
- ResultErrorType::ErrorMaxTurns => write!(f, "error_max_turns"),
- ResultErrorType::ErrorDuringExecution => write!(f, "error_during_execution"),
- }
- }
-}
-
-fn acp_content_to_claude(prompt: Vec<acp::ContentBlock>) -> Vec<ContentChunk> {
- let mut content = Vec::with_capacity(prompt.len());
- let mut context = Vec::with_capacity(prompt.len());
-
- for chunk in prompt {
- match chunk {
- acp::ContentBlock::Text(text_content) => {
- content.push(ContentChunk::Text {
- text: text_content.text,
- });
- }
- acp::ContentBlock::ResourceLink(resource_link) => {
- match MentionUri::parse(&resource_link.uri) {
- Ok(uri) => {
- content.push(ContentChunk::Text {
- text: format!("{}", uri.as_link()),
- });
- }
- Err(_) => {
- content.push(ContentChunk::Text {
- text: resource_link.uri,
- });
- }
- }
- }
- acp::ContentBlock::Resource(resource) => match resource.resource {
- acp::EmbeddedResourceResource::TextResourceContents(resource) => {
- match MentionUri::parse(&resource.uri) {
- Ok(uri) => {
- content.push(ContentChunk::Text {
- text: format!("{}", uri.as_link()),
- });
- }
- Err(_) => {
- content.push(ContentChunk::Text {
- text: resource.uri.clone(),
- });
- }
- }
-
- context.push(ContentChunk::Text {
- text: format!(
- "\n<context ref=\"{}\">\n{}\n</context>",
- resource.uri, resource.text
- ),
- });
- }
- acp::EmbeddedResourceResource::BlobResourceContents(_) => {
- // Unsupported by SDK
- }
- },
- acp::ContentBlock::Image(acp::ImageContent {
- data, mime_type, ..
- }) => content.push(ContentChunk::Image {
- source: ImageSource::Base64 {
- data,
- media_type: mime_type,
- },
- }),
- acp::ContentBlock::Audio(_) => {
- // Unsupported by SDK
- }
- }
- }
-
- content.extend(context);
- content
-}
-
-fn new_request_id() -> String {
- use rand::Rng;
- // In the Claude Code TS SDK they just generate a random 12 character string,
- // `Math.random().toString(36).substring(2, 15)`
- rand::thread_rng()
- .sample_iter(&rand::distributions::Alphanumeric)
- .take(12)
- .map(char::from)
- .collect()
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-struct McpServer {
- name: String,
- status: String,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-enum PermissionMode {
- Default,
- AcceptEdits,
- BypassPermissions,
- Plan,
-}
-
-#[cfg(test)]
-pub(crate) mod tests {
- use super::*;
- use crate::e2e_tests;
- use gpui::TestAppContext;
- use serde_json::json;
-
- crate::common_e2e_tests!(async |_, _, _| ClaudeCode, allow_option_id = "allow");
-
- pub fn local_command() -> AgentServerCommand {
- AgentServerCommand {
- path: "claude".into(),
- args: vec![],
- env: None,
- }
- }
-
- #[gpui::test]
- #[cfg_attr(not(feature = "e2e"), ignore)]
- async fn test_todo_plan(cx: &mut TestAppContext) {
- let fs = e2e_tests::init_test(cx).await;
- let project = Project::test(fs, [], cx).await;
- let thread =
- e2e_tests::new_test_thread(ClaudeCode, project.clone(), "/private/tmp", cx).await;
-
- thread
- .update(cx, |thread, cx| {
- thread.send_raw(
- "Create a todo plan for initializing a new React app. I'll follow it myself, do not execute on it.",
- cx,
- )
- })
- .await
- .unwrap();
-
- let mut entries_len = 0;
-
- thread.read_with(cx, |thread, _| {
- entries_len = thread.plan().entries.len();
- assert!(!thread.plan().entries.is_empty(), "Empty plan");
- });
-
- thread
- .update(cx, |thread, cx| {
- thread.send_raw(
- "Mark the first entry status as in progress without acting on it.",
- cx,
- )
- })
- .await
- .unwrap();
-
- thread.read_with(cx, |thread, _| {
- assert!(matches!(
- thread.plan().entries[0].status,
- acp::PlanEntryStatus::InProgress
- ));
- assert_eq!(thread.plan().entries.len(), entries_len);
- });
-
- thread
- .update(cx, |thread, cx| {
- thread.send_raw(
- "Now mark the first entry as completed without acting on it.",
- cx,
- )
- })
- .await
- .unwrap();
-
- thread.read_with(cx, |thread, _| {
- assert!(matches!(
- thread.plan().entries[0].status,
- acp::PlanEntryStatus::Completed
- ));
- assert_eq!(thread.plan().entries.len(), entries_len);
- });
- }
-
- #[test]
- fn test_deserialize_content_untagged_text() {
- let json = json!("Hello, world!");
- let content: Content = serde_json::from_value(json).unwrap();
- match content {
- Content::UntaggedText(text) => assert_eq!(text, "Hello, world!"),
- _ => panic!("Expected UntaggedText variant"),
- }
- }
-
- #[test]
- fn test_deserialize_content_chunks() {
- let json = json!([
- {
- "type": "text",
- "text": "Hello"
- },
- {
- "type": "tool_use",
- "id": "tool_123",
- "name": "calculator",
- "input": {"operation": "add", "a": 1, "b": 2}
- }
- ]);
- let content: Content = serde_json::from_value(json).unwrap();
- match content {
- Content::Chunks(chunks) => {
- assert_eq!(chunks.len(), 2);
- match &chunks[0] {
- ContentChunk::Text { text } => assert_eq!(text, "Hello"),
- _ => panic!("Expected Text chunk"),
- }
- match &chunks[1] {
- ContentChunk::ToolUse { id, name, input } => {
- assert_eq!(id, "tool_123");
- assert_eq!(name, "calculator");
- assert_eq!(input["operation"], "add");
- assert_eq!(input["a"], 1);
- assert_eq!(input["b"], 2);
- }
- _ => panic!("Expected ToolUse chunk"),
- }
- }
- _ => panic!("Expected Chunks variant"),
- }
- }
-
- #[test]
- fn test_deserialize_tool_result_untagged_text() {
- let json = json!({
- "type": "tool_result",
- "content": "Result content",
- "tool_use_id": "tool_456"
- });
- let chunk: ContentChunk = serde_json::from_value(json).unwrap();
- match chunk {
- ContentChunk::ToolResult {
- content,
- tool_use_id,
- } => {
- match content {
- Content::UntaggedText(text) => assert_eq!(text, "Result content"),
- _ => panic!("Expected UntaggedText content"),
- }
- assert_eq!(tool_use_id, "tool_456");
- }
- _ => panic!("Expected ToolResult variant"),
- }
- }
-
- #[test]
- fn test_deserialize_tool_result_chunks() {
- let json = json!({
- "type": "tool_result",
- "content": [
- {
- "type": "text",
- "text": "Processing complete"
- },
- {
- "type": "text",
- "text": "Result: 42"
- }
- ],
- "tool_use_id": "tool_789"
- });
- let chunk: ContentChunk = serde_json::from_value(json).unwrap();
- match chunk {
- ContentChunk::ToolResult {
- content,
- tool_use_id,
- } => {
- match content {
- Content::Chunks(chunks) => {
- assert_eq!(chunks.len(), 2);
- match &chunks[0] {
- ContentChunk::Text { text } => assert_eq!(text, "Processing complete"),
- _ => panic!("Expected Text chunk"),
- }
- match &chunks[1] {
- ContentChunk::Text { text } => assert_eq!(text, "Result: 42"),
- _ => panic!("Expected Text chunk"),
- }
- }
- _ => panic!("Expected Chunks content"),
- }
- assert_eq!(tool_use_id, "tool_789");
- }
- _ => panic!("Expected ToolResult variant"),
- }
- }
-
- #[test]
- fn test_acp_content_to_claude() {
- let acp_content = vec![
- acp::ContentBlock::Text(acp::TextContent {
- text: "Hello world".to_string(),
- annotations: None,
- }),
- acp::ContentBlock::Image(acp::ImageContent {
- data: "base64data".to_string(),
- mime_type: "image/png".to_string(),
- annotations: None,
- uri: None,
- }),
- acp::ContentBlock::ResourceLink(acp::ResourceLink {
- uri: "file:///path/to/example.rs".to_string(),
- name: "example.rs".to_string(),
- annotations: None,
- description: None,
- mime_type: None,
- size: None,
- title: None,
- }),
- acp::ContentBlock::Resource(acp::EmbeddedResource {
- annotations: None,
- resource: acp::EmbeddedResourceResource::TextResourceContents(
- acp::TextResourceContents {
- mime_type: None,
- text: "fn main() { println!(\"Hello!\"); }".to_string(),
- uri: "file:///path/to/code.rs".to_string(),
- },
- ),
- }),
- acp::ContentBlock::ResourceLink(acp::ResourceLink {
- uri: "invalid_uri_format".to_string(),
- name: "invalid.txt".to_string(),
- annotations: None,
- description: None,
- mime_type: None,
- size: None,
- title: None,
- }),
- ];
-
- let claude_content = acp_content_to_claude(acp_content);
-
- assert_eq!(claude_content.len(), 6);
-
- match &claude_content[0] {
- ContentChunk::Text { text } => assert_eq!(text, "Hello world"),
- _ => panic!("Expected Text chunk"),
- }
-
- match &claude_content[1] {
- ContentChunk::Image { source } => match source {
- ImageSource::Base64 { data, media_type } => {
- assert_eq!(data, "base64data");
- assert_eq!(media_type, "image/png");
- }
- _ => panic!("Expected Base64 image source"),
- },
- _ => panic!("Expected Image chunk"),
- }
-
- match &claude_content[2] {
- ContentChunk::Text { text } => {
- assert!(text.contains("example.rs"));
- assert!(text.contains("file:///path/to/example.rs"));
- }
- _ => panic!("Expected Text chunk for ResourceLink"),
- }
-
- match &claude_content[3] {
- ContentChunk::Text { text } => {
- assert!(text.contains("code.rs"));
- assert!(text.contains("file:///path/to/code.rs"));
- }
- _ => panic!("Expected Text chunk for Resource"),
- }
-
- match &claude_content[4] {
- ContentChunk::Text { text } => {
- assert_eq!(text, "invalid_uri_format");
- }
- _ => panic!("Expected Text chunk for invalid URI"),
- }
-
- match &claude_content[5] {
- ContentChunk::Text { text } => {
- assert!(text.contains("<context ref=\"file:///path/to/code.rs\">"));
- assert!(text.contains("fn main() { println!(\"Hello!\"); }"));
- assert!(text.contains("</context>"));
- }
- _ => panic!("Expected Text chunk for context"),
- }
- }
-}
@@ -1,178 +0,0 @@
-use acp_thread::AcpThread;
-use anyhow::Result;
-use context_server::{
- listener::{McpServerTool, ToolResponse},
- types::{ToolAnnotations, ToolResponseContent},
-};
-use gpui::{AsyncApp, WeakEntity};
-use language::unified_diff;
-use util::markdown::MarkdownCodeBlock;
-
-use crate::tools::EditToolParams;
-
-#[derive(Clone)]
-pub struct EditTool {
- thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
-}
-
-impl EditTool {
- pub fn new(thread_rx: watch::Receiver<WeakEntity<AcpThread>>) -> Self {
- Self { thread_rx }
- }
-}
-
-impl McpServerTool for EditTool {
- type Input = EditToolParams;
- type Output = ();
-
- const NAME: &'static str = "Edit";
-
- fn annotations(&self) -> ToolAnnotations {
- ToolAnnotations {
- title: Some("Edit file".to_string()),
- read_only_hint: Some(false),
- destructive_hint: Some(false),
- open_world_hint: Some(false),
- idempotent_hint: Some(false),
- }
- }
-
- async fn run(
- &self,
- input: Self::Input,
- cx: &mut AsyncApp,
- ) -> Result<ToolResponse<Self::Output>> {
- let mut thread_rx = self.thread_rx.clone();
- let Some(thread) = thread_rx.recv().await?.upgrade() else {
- anyhow::bail!("Thread closed");
- };
-
- let content = thread
- .update(cx, |thread, cx| {
- thread.read_text_file(input.abs_path.clone(), None, None, true, cx)
- })?
- .await?;
-
- let (new_content, diff) = cx
- .background_executor()
- .spawn(async move {
- let new_content = content.replace(&input.old_text, &input.new_text);
- if new_content == content {
- return Err(anyhow::anyhow!("Failed to find `old_text`",));
- }
- let diff = unified_diff(&content, &new_content);
-
- Ok((new_content, diff))
- })
- .await?;
-
- thread
- .update(cx, |thread, cx| {
- thread.write_text_file(input.abs_path, new_content, cx)
- })?
- .await?;
-
- Ok(ToolResponse {
- content: vec![ToolResponseContent::Text {
- text: MarkdownCodeBlock {
- tag: "diff",
- text: diff.as_str().trim_end_matches('\n'),
- }
- .to_string(),
- }],
- structured_content: (),
- })
- }
-}
-
-#[cfg(test)]
-mod tests {
- use std::rc::Rc;
-
- use acp_thread::{AgentConnection, StubAgentConnection};
- use gpui::{Entity, TestAppContext};
- use indoc::indoc;
- use project::{FakeFs, Project};
- use serde_json::json;
- use settings::SettingsStore;
- use util::path;
-
- use super::*;
-
- #[gpui::test]
- async fn old_text_not_found(cx: &mut TestAppContext) {
- let (_thread, tool) = init_test(cx).await;
-
- let result = tool
- .run(
- EditToolParams {
- abs_path: path!("/root/file.txt").into(),
- old_text: "hi".into(),
- new_text: "bye".into(),
- },
- &mut cx.to_async(),
- )
- .await;
-
- assert_eq!(result.unwrap_err().to_string(), "Failed to find `old_text`");
- }
-
- #[gpui::test]
- async fn found_and_replaced(cx: &mut TestAppContext) {
- let (_thread, tool) = init_test(cx).await;
-
- let result = tool
- .run(
- EditToolParams {
- abs_path: path!("/root/file.txt").into(),
- old_text: "hello".into(),
- new_text: "hi".into(),
- },
- &mut cx.to_async(),
- )
- .await;
-
- assert_eq!(
- result.unwrap().content[0].text().unwrap(),
- indoc! {
- r"
- ```diff
- @@ -1,1 +1,1 @@
- -hello
- +hi
- ```
- "
- }
- );
- }
-
- async fn init_test(cx: &mut TestAppContext) -> (Entity<AcpThread>, EditTool) {
- cx.update(|cx| {
- let settings_store = SettingsStore::test(cx);
- cx.set_global(settings_store);
- language::init(cx);
- Project::init_settings(cx);
- });
-
- let connection = Rc::new(StubAgentConnection::new());
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree(
- path!("/root"),
- json!({
- "file.txt": "hello"
- }),
- )
- .await;
- let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
- let (mut thread_tx, thread_rx) = watch::channel(WeakEntity::new_invalid());
-
- let thread = cx
- .update(|cx| connection.new_thread(project, path!("/test").as_ref(), cx))
- .await
- .unwrap();
-
- thread_tx.send(thread.downgrade()).unwrap();
-
- (thread, EditTool::new(thread_rx))
- }
-}
@@ -1,99 +0,0 @@
-use std::path::PathBuf;
-use std::sync::Arc;
-
-use crate::claude::edit_tool::EditTool;
-use crate::claude::permission_tool::PermissionTool;
-use crate::claude::read_tool::ReadTool;
-use crate::claude::write_tool::WriteTool;
-use acp_thread::AcpThread;
-#[cfg(not(test))]
-use anyhow::Context as _;
-use anyhow::Result;
-use collections::HashMap;
-use context_server::types::{
- Implementation, InitializeParams, InitializeResponse, ProtocolVersion, ServerCapabilities,
- ToolsCapabilities, requests,
-};
-use gpui::{App, AsyncApp, Task, WeakEntity};
-use project::Fs;
-use serde::Serialize;
-
-pub struct ClaudeZedMcpServer {
- server: context_server::listener::McpServer,
-}
-
-pub const SERVER_NAME: &str = "zed";
-
-impl ClaudeZedMcpServer {
- pub async fn new(
- thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
- fs: Arc<dyn Fs>,
- cx: &AsyncApp,
- ) -> Result<Self> {
- let mut mcp_server = context_server::listener::McpServer::new(cx).await?;
- mcp_server.handle_request::<requests::Initialize>(Self::handle_initialize);
-
- mcp_server.add_tool(PermissionTool::new(fs.clone(), thread_rx.clone()));
- mcp_server.add_tool(ReadTool::new(thread_rx.clone()));
- mcp_server.add_tool(EditTool::new(thread_rx.clone()));
- mcp_server.add_tool(WriteTool::new(thread_rx.clone()));
-
- Ok(Self { server: mcp_server })
- }
-
- pub fn server_config(&self) -> Result<McpServerConfig> {
- #[cfg(not(test))]
- let zed_path = std::env::current_exe()
- .context("finding current executable path for use in mcp_server")?;
-
- #[cfg(test)]
- let zed_path = crate::e2e_tests::get_zed_path();
-
- Ok(McpServerConfig {
- command: zed_path,
- args: vec![
- "--nc".into(),
- self.server.socket_path().display().to_string(),
- ],
- env: None,
- })
- }
-
- fn handle_initialize(_: InitializeParams, cx: &App) -> Task<Result<InitializeResponse>> {
- cx.foreground_executor().spawn(async move {
- Ok(InitializeResponse {
- protocol_version: ProtocolVersion("2025-06-18".into()),
- capabilities: ServerCapabilities {
- experimental: None,
- logging: None,
- completions: None,
- prompts: None,
- resources: None,
- tools: Some(ToolsCapabilities {
- list_changed: Some(false),
- }),
- },
- server_info: Implementation {
- name: SERVER_NAME.into(),
- version: "0.1.0".into(),
- },
- meta: None,
- })
- })
- }
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-pub struct McpConfig {
- pub mcp_servers: HashMap<String, McpServerConfig>,
-}
-
-#[derive(Serialize, Clone)]
-#[serde(rename_all = "camelCase")]
-pub struct McpServerConfig {
- pub command: PathBuf,
- pub args: Vec<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub env: Option<HashMap<String, String>>,
-}
@@ -1,158 +0,0 @@
-use std::sync::Arc;
-
-use acp_thread::AcpThread;
-use agent_client_protocol as acp;
-use agent_settings::AgentSettings;
-use anyhow::{Context as _, Result};
-use context_server::{
- listener::{McpServerTool, ToolResponse},
- types::ToolResponseContent,
-};
-use gpui::{AsyncApp, WeakEntity};
-use project::Fs;
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-use settings::{Settings as _, update_settings_file};
-use util::debug_panic;
-
-use crate::tools::ClaudeTool;
-
-#[derive(Clone)]
-pub struct PermissionTool {
- fs: Arc<dyn Fs>,
- thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
-}
-
-/// Request permission for tool calls
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct PermissionToolParams {
- tool_name: String,
- input: serde_json::Value,
- tool_use_id: Option<String>,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-pub struct PermissionToolResponse {
- behavior: PermissionToolBehavior,
- updated_input: serde_json::Value,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "snake_case")]
-enum PermissionToolBehavior {
- Allow,
- Deny,
-}
-
-impl PermissionTool {
- pub fn new(fs: Arc<dyn Fs>, thread_rx: watch::Receiver<WeakEntity<AcpThread>>) -> Self {
- Self { fs, thread_rx }
- }
-}
-
-impl McpServerTool for PermissionTool {
- type Input = PermissionToolParams;
- type Output = ();
-
- const NAME: &'static str = "Confirmation";
-
- async fn run(
- &self,
- input: Self::Input,
- cx: &mut AsyncApp,
- ) -> Result<ToolResponse<Self::Output>> {
- if agent_settings::AgentSettings::try_read_global(cx, |settings| {
- settings.always_allow_tool_actions
- })
- .unwrap_or(false)
- {
- let response = PermissionToolResponse {
- behavior: PermissionToolBehavior::Allow,
- updated_input: input.input,
- };
-
- return Ok(ToolResponse {
- content: vec![ToolResponseContent::Text {
- text: serde_json::to_string(&response)?,
- }],
- structured_content: (),
- });
- }
-
- let mut thread_rx = self.thread_rx.clone();
- let Some(thread) = thread_rx.recv().await?.upgrade() else {
- anyhow::bail!("Thread closed");
- };
-
- let claude_tool = ClaudeTool::infer(&input.tool_name, input.input.clone());
- let tool_call_id = acp::ToolCallId(input.tool_use_id.context("Tool ID required")?.into());
-
- const ALWAYS_ALLOW: &str = "always_allow";
- const ALLOW: &str = "allow";
- const REJECT: &str = "reject";
-
- let chosen_option = thread
- .update(cx, |thread, cx| {
- thread.request_tool_call_authorization(
- claude_tool.as_acp(tool_call_id).into(),
- vec![
- acp::PermissionOption {
- id: acp::PermissionOptionId(ALWAYS_ALLOW.into()),
- name: "Always Allow".into(),
- kind: acp::PermissionOptionKind::AllowAlways,
- },
- acp::PermissionOption {
- id: acp::PermissionOptionId(ALLOW.into()),
- name: "Allow".into(),
- kind: acp::PermissionOptionKind::AllowOnce,
- },
- acp::PermissionOption {
- id: acp::PermissionOptionId(REJECT.into()),
- name: "Reject".into(),
- kind: acp::PermissionOptionKind::RejectOnce,
- },
- ],
- cx,
- )
- })??
- .await?;
-
- let response = match chosen_option.0.as_ref() {
- ALWAYS_ALLOW => {
- cx.update(|cx| {
- update_settings_file::<AgentSettings>(self.fs.clone(), cx, |settings, _| {
- settings.set_always_allow_tool_actions(true);
- });
- })?;
-
- PermissionToolResponse {
- behavior: PermissionToolBehavior::Allow,
- updated_input: input.input,
- }
- }
- ALLOW => PermissionToolResponse {
- behavior: PermissionToolBehavior::Allow,
- updated_input: input.input,
- },
- REJECT => PermissionToolResponse {
- behavior: PermissionToolBehavior::Deny,
- updated_input: input.input,
- },
- opt => {
- debug_panic!("Unexpected option: {}", opt);
- PermissionToolResponse {
- behavior: PermissionToolBehavior::Deny,
- updated_input: input.input,
- }
- }
- };
-
- Ok(ToolResponse {
- content: vec![ToolResponseContent::Text {
- text: serde_json::to_string(&response)?,
- }],
- structured_content: (),
- })
- }
-}
@@ -1,59 +0,0 @@
-use acp_thread::AcpThread;
-use anyhow::Result;
-use context_server::{
- listener::{McpServerTool, ToolResponse},
- types::{ToolAnnotations, ToolResponseContent},
-};
-use gpui::{AsyncApp, WeakEntity};
-
-use crate::tools::ReadToolParams;
-
-#[derive(Clone)]
-pub struct ReadTool {
- thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
-}
-
-impl ReadTool {
- pub fn new(thread_rx: watch::Receiver<WeakEntity<AcpThread>>) -> Self {
- Self { thread_rx }
- }
-}
-
-impl McpServerTool for ReadTool {
- type Input = ReadToolParams;
- type Output = ();
-
- const NAME: &'static str = "Read";
-
- fn annotations(&self) -> ToolAnnotations {
- ToolAnnotations {
- title: Some("Read file".to_string()),
- read_only_hint: Some(true),
- destructive_hint: Some(false),
- open_world_hint: Some(false),
- idempotent_hint: None,
- }
- }
-
- async fn run(
- &self,
- input: Self::Input,
- cx: &mut AsyncApp,
- ) -> Result<ToolResponse<Self::Output>> {
- let mut thread_rx = self.thread_rx.clone();
- let Some(thread) = thread_rx.recv().await?.upgrade() else {
- anyhow::bail!("Thread closed");
- };
-
- let content = thread
- .update(cx, |thread, cx| {
- thread.read_text_file(input.abs_path, input.offset, input.limit, false, cx)
- })?
- .await?;
-
- Ok(ToolResponse {
- content: vec![ToolResponseContent::Text { text: content }],
- structured_content: (),
- })
- }
-}
@@ -1,688 +0,0 @@
-use std::path::PathBuf;
-
-use agent_client_protocol as acp;
-use itertools::Itertools;
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-use util::ResultExt;
-
-pub enum ClaudeTool {
- Task(Option<TaskToolParams>),
- NotebookRead(Option<NotebookReadToolParams>),
- NotebookEdit(Option<NotebookEditToolParams>),
- Edit(Option<EditToolParams>),
- MultiEdit(Option<MultiEditToolParams>),
- ReadFile(Option<ReadToolParams>),
- Write(Option<WriteToolParams>),
- Ls(Option<LsToolParams>),
- Glob(Option<GlobToolParams>),
- Grep(Option<GrepToolParams>),
- Terminal(Option<BashToolParams>),
- WebFetch(Option<WebFetchToolParams>),
- WebSearch(Option<WebSearchToolParams>),
- TodoWrite(Option<TodoWriteToolParams>),
- ExitPlanMode(Option<ExitPlanModeToolParams>),
- Other {
- name: String,
- input: serde_json::Value,
- },
-}
-
-impl ClaudeTool {
- pub fn infer(tool_name: &str, input: serde_json::Value) -> Self {
- match tool_name {
- // Known tools
- "mcp__zed__Read" => Self::ReadFile(serde_json::from_value(input).log_err()),
- "mcp__zed__Edit" => Self::Edit(serde_json::from_value(input).log_err()),
- "mcp__zed__Write" => Self::Write(serde_json::from_value(input).log_err()),
- "MultiEdit" => Self::MultiEdit(serde_json::from_value(input).log_err()),
- "Write" => Self::Write(serde_json::from_value(input).log_err()),
- "LS" => Self::Ls(serde_json::from_value(input).log_err()),
- "Glob" => Self::Glob(serde_json::from_value(input).log_err()),
- "Grep" => Self::Grep(serde_json::from_value(input).log_err()),
- "Bash" => Self::Terminal(serde_json::from_value(input).log_err()),
- "WebFetch" => Self::WebFetch(serde_json::from_value(input).log_err()),
- "WebSearch" => Self::WebSearch(serde_json::from_value(input).log_err()),
- "TodoWrite" => Self::TodoWrite(serde_json::from_value(input).log_err()),
- "exit_plan_mode" => Self::ExitPlanMode(serde_json::from_value(input).log_err()),
- "Task" => Self::Task(serde_json::from_value(input).log_err()),
- "NotebookRead" => Self::NotebookRead(serde_json::from_value(input).log_err()),
- "NotebookEdit" => Self::NotebookEdit(serde_json::from_value(input).log_err()),
- // Inferred from name
- _ => {
- let tool_name = tool_name.to_lowercase();
-
- if tool_name.contains("edit") || tool_name.contains("write") {
- Self::Edit(None)
- } else if tool_name.contains("terminal") {
- Self::Terminal(None)
- } else {
- Self::Other {
- name: tool_name,
- input,
- }
- }
- }
- }
- }
-
- pub fn label(&self) -> String {
- match &self {
- Self::Task(Some(params)) => params.description.clone(),
- Self::Task(None) => "Task".into(),
- Self::NotebookRead(Some(params)) => {
- format!("Read Notebook {}", params.notebook_path.display())
- }
- Self::NotebookRead(None) => "Read Notebook".into(),
- Self::NotebookEdit(Some(params)) => {
- format!("Edit Notebook {}", params.notebook_path.display())
- }
- Self::NotebookEdit(None) => "Edit Notebook".into(),
- Self::Terminal(Some(params)) => format!("`{}`", params.command),
- Self::Terminal(None) => "Terminal".into(),
- Self::ReadFile(_) => "Read File".into(),
- Self::Ls(Some(params)) => {
- format!("List Directory {}", params.path.display())
- }
- Self::Ls(None) => "List Directory".into(),
- Self::Edit(Some(params)) => {
- format!("Edit {}", params.abs_path.display())
- }
- Self::Edit(None) => "Edit".into(),
- Self::MultiEdit(Some(params)) => {
- format!("Multi Edit {}", params.file_path.display())
- }
- Self::MultiEdit(None) => "Multi Edit".into(),
- Self::Write(Some(params)) => {
- format!("Write {}", params.abs_path.display())
- }
- Self::Write(None) => "Write".into(),
- Self::Glob(Some(params)) => {
- format!("Glob `{params}`")
- }
- Self::Glob(None) => "Glob".into(),
- Self::Grep(Some(params)) => format!("`{params}`"),
- Self::Grep(None) => "Grep".into(),
- Self::WebFetch(Some(params)) => format!("Fetch {}", params.url),
- Self::WebFetch(None) => "Fetch".into(),
- Self::WebSearch(Some(params)) => format!("Web Search: {}", params),
- Self::WebSearch(None) => "Web Search".into(),
- Self::TodoWrite(Some(params)) => format!(
- "Update TODOs: {}",
- params.todos.iter().map(|todo| &todo.content).join(", ")
- ),
- Self::TodoWrite(None) => "Update TODOs".into(),
- Self::ExitPlanMode(_) => "Exit Plan Mode".into(),
- Self::Other { name, .. } => name.clone(),
- }
- }
- pub fn content(&self) -> Vec<acp::ToolCallContent> {
- match &self {
- Self::Other { input, .. } => vec![
- format!(
- "```json\n{}```",
- serde_json::to_string_pretty(&input).unwrap_or("{}".to_string())
- )
- .into(),
- ],
- Self::Task(Some(params)) => vec![params.prompt.clone().into()],
- Self::NotebookRead(Some(params)) => {
- vec![params.notebook_path.display().to_string().into()]
- }
- Self::NotebookEdit(Some(params)) => vec![params.new_source.clone().into()],
- Self::Terminal(Some(params)) => vec![
- format!(
- "`{}`\n\n{}",
- params.command,
- params.description.as_deref().unwrap_or_default()
- )
- .into(),
- ],
- Self::ReadFile(Some(params)) => vec![params.abs_path.display().to_string().into()],
- Self::Ls(Some(params)) => vec![params.path.display().to_string().into()],
- Self::Glob(Some(params)) => vec![params.to_string().into()],
- Self::Grep(Some(params)) => vec![format!("`{params}`").into()],
- Self::WebFetch(Some(params)) => vec![params.prompt.clone().into()],
- Self::WebSearch(Some(params)) => vec![params.to_string().into()],
- Self::ExitPlanMode(Some(params)) => vec![params.plan.clone().into()],
- Self::Edit(Some(params)) => vec![acp::ToolCallContent::Diff {
- diff: acp::Diff {
- path: params.abs_path.clone(),
- old_text: Some(params.old_text.clone()),
- new_text: params.new_text.clone(),
- },
- }],
- Self::Write(Some(params)) => vec![acp::ToolCallContent::Diff {
- diff: acp::Diff {
- path: params.abs_path.clone(),
- old_text: None,
- new_text: params.content.clone(),
- },
- }],
- Self::MultiEdit(Some(params)) => {
- // todo: show multiple edits in a multibuffer?
- params
- .edits
- .first()
- .map(|edit| {
- vec![acp::ToolCallContent::Diff {
- diff: acp::Diff {
- path: params.file_path.clone(),
- old_text: Some(edit.old_string.clone()),
- new_text: edit.new_string.clone(),
- },
- }]
- })
- .unwrap_or_default()
- }
- Self::TodoWrite(Some(_)) => {
- // These are mapped to plan updates later
- vec![]
- }
- Self::Task(None)
- | Self::NotebookRead(None)
- | Self::NotebookEdit(None)
- | Self::Terminal(None)
- | Self::ReadFile(None)
- | Self::Ls(None)
- | Self::Glob(None)
- | Self::Grep(None)
- | Self::WebFetch(None)
- | Self::WebSearch(None)
- | Self::TodoWrite(None)
- | Self::ExitPlanMode(None)
- | Self::Edit(None)
- | Self::Write(None)
- | Self::MultiEdit(None) => vec![],
- }
- }
-
- pub fn kind(&self) -> acp::ToolKind {
- match self {
- Self::Task(_) => acp::ToolKind::Think,
- Self::NotebookRead(_) => acp::ToolKind::Read,
- Self::NotebookEdit(_) => acp::ToolKind::Edit,
- Self::Edit(_) => acp::ToolKind::Edit,
- Self::MultiEdit(_) => acp::ToolKind::Edit,
- Self::Write(_) => acp::ToolKind::Edit,
- Self::ReadFile(_) => acp::ToolKind::Read,
- Self::Ls(_) => acp::ToolKind::Search,
- Self::Glob(_) => acp::ToolKind::Search,
- Self::Grep(_) => acp::ToolKind::Search,
- Self::Terminal(_) => acp::ToolKind::Execute,
- Self::WebSearch(_) => acp::ToolKind::Search,
- Self::WebFetch(_) => acp::ToolKind::Fetch,
- Self::TodoWrite(_) => acp::ToolKind::Think,
- Self::ExitPlanMode(_) => acp::ToolKind::Think,
- Self::Other { .. } => acp::ToolKind::Other,
- }
- }
-
- pub fn locations(&self) -> Vec<acp::ToolCallLocation> {
- match &self {
- Self::Edit(Some(EditToolParams { abs_path, .. })) => vec![acp::ToolCallLocation {
- path: abs_path.clone(),
- line: None,
- }],
- Self::MultiEdit(Some(MultiEditToolParams { file_path, .. })) => {
- vec![acp::ToolCallLocation {
- path: file_path.clone(),
- line: None,
- }]
- }
- Self::Write(Some(WriteToolParams {
- abs_path: file_path,
- ..
- })) => {
- vec![acp::ToolCallLocation {
- path: file_path.clone(),
- line: None,
- }]
- }
- Self::ReadFile(Some(ReadToolParams {
- abs_path, offset, ..
- })) => vec![acp::ToolCallLocation {
- path: abs_path.clone(),
- line: *offset,
- }],
- Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => {
- vec![acp::ToolCallLocation {
- path: notebook_path.clone(),
- line: None,
- }]
- }
- Self::NotebookEdit(Some(NotebookEditToolParams { notebook_path, .. })) => {
- vec![acp::ToolCallLocation {
- path: notebook_path.clone(),
- line: None,
- }]
- }
- Self::Glob(Some(GlobToolParams {
- path: Some(path), ..
- })) => vec![acp::ToolCallLocation {
- path: path.clone(),
- line: None,
- }],
- Self::Ls(Some(LsToolParams { path, .. })) => vec![acp::ToolCallLocation {
- path: path.clone(),
- line: None,
- }],
- Self::Grep(Some(GrepToolParams {
- path: Some(path), ..
- })) => vec![acp::ToolCallLocation {
- path: PathBuf::from(path),
- line: None,
- }],
- Self::Task(_)
- | Self::NotebookRead(None)
- | Self::NotebookEdit(None)
- | Self::Edit(None)
- | Self::MultiEdit(None)
- | Self::Write(None)
- | Self::ReadFile(None)
- | Self::Ls(None)
- | Self::Glob(_)
- | Self::Grep(_)
- | Self::Terminal(_)
- | Self::WebFetch(_)
- | Self::WebSearch(_)
- | Self::TodoWrite(_)
- | Self::ExitPlanMode(_)
- | Self::Other { .. } => vec![],
- }
- }
-
- pub fn as_acp(&self, id: acp::ToolCallId) -> acp::ToolCall {
- acp::ToolCall {
- id,
- kind: self.kind(),
- status: acp::ToolCallStatus::InProgress,
- title: self.label(),
- content: self.content(),
- locations: self.locations(),
- raw_input: None,
- raw_output: None,
- }
- }
-}
-
-/// Edit a file.
-///
-/// In sessions with mcp__zed__Edit always use it instead of Edit as it will
-/// allow the user to conveniently review changes.
-///
-/// File editing instructions:
-/// - The `old_text` param must match existing file content, including indentation.
-/// - The `old_text` param must come from the actual file, not an outline.
-/// - The `old_text` section must not be empty.
-/// - Be minimal with replacements:
-/// - For unique lines, include only those lines.
-/// - For non-unique lines, include enough context to identify them.
-/// - Do not escape quotes, newlines, or other characters.
-/// - Only edit the specified file.
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct EditToolParams {
- /// The absolute path to the file to read.
- pub abs_path: PathBuf,
- /// The old text to replace (must be unique in the file)
- pub old_text: String,
- /// The new text.
- pub new_text: String,
-}
-
-/// Reads the content of the given file in the project.
-///
-/// Never attempt to read a path that hasn't been previously mentioned.
-///
-/// In sessions with mcp__zed__Read always use it instead of Read as it contains the most up-to-date contents.
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct ReadToolParams {
- /// The absolute path to the file to read.
- pub abs_path: PathBuf,
- /// Which line to start reading from. Omit to start from the beginning.
- #[serde(skip_serializing_if = "Option::is_none")]
- pub offset: Option<u32>,
- /// How many lines to read. Omit for the whole file.
- #[serde(skip_serializing_if = "Option::is_none")]
- pub limit: Option<u32>,
-}
-
-/// Writes content to the specified file in the project.
-///
-/// In sessions with mcp__zed__Write always use it instead of Write as it will
-/// allow the user to conveniently review changes.
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct WriteToolParams {
- /// The absolute path of the file to write.
- pub abs_path: PathBuf,
- /// The full content to write.
- pub content: String,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct BashToolParams {
- /// Shell command to execute
- pub command: String,
- /// 5-10 word description of what command does
- #[serde(skip_serializing_if = "Option::is_none")]
- pub description: Option<String>,
- /// Timeout in ms (max 600000ms/10min, default 120000ms)
- #[serde(skip_serializing_if = "Option::is_none")]
- pub timeout: Option<u32>,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct GlobToolParams {
- /// Glob pattern like **/*.js or src/**/*.ts
- pub pattern: String,
- /// Directory to search in (omit for current directory)
- #[serde(skip_serializing_if = "Option::is_none")]
- pub path: Option<PathBuf>,
-}
-
-impl std::fmt::Display for GlobToolParams {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- if let Some(path) = &self.path {
- write!(f, "{}", path.display())?;
- }
- write!(f, "{}", self.pattern)
- }
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct LsToolParams {
- /// Absolute path to directory
- pub path: PathBuf,
- /// Array of glob patterns to ignore
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
- pub ignore: Vec<String>,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct GrepToolParams {
- /// Regex pattern to search for
- pub pattern: String,
- /// File/directory to search (defaults to current directory)
- #[serde(skip_serializing_if = "Option::is_none")]
- pub path: Option<String>,
- /// "content" (shows lines), "files_with_matches" (default), "count"
- #[serde(skip_serializing_if = "Option::is_none")]
- pub output_mode: Option<GrepOutputMode>,
- /// Filter files with glob pattern like "*.js"
- #[serde(skip_serializing_if = "Option::is_none")]
- pub glob: Option<String>,
- /// File type filter like "js", "py", "rust"
- #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
- pub file_type: Option<String>,
- /// Case insensitive search
- #[serde(rename = "-i", default, skip_serializing_if = "is_false")]
- pub case_insensitive: bool,
- /// Show line numbers (content mode only)
- #[serde(rename = "-n", default, skip_serializing_if = "is_false")]
- pub line_numbers: bool,
- /// Lines after match (content mode only)
- #[serde(rename = "-A", skip_serializing_if = "Option::is_none")]
- pub after_context: Option<u32>,
- /// Lines before match (content mode only)
- #[serde(rename = "-B", skip_serializing_if = "Option::is_none")]
- pub before_context: Option<u32>,
- /// Lines before and after match (content mode only)
- #[serde(rename = "-C", skip_serializing_if = "Option::is_none")]
- pub context: Option<u32>,
- /// Enable multiline/cross-line matching
- #[serde(default, skip_serializing_if = "is_false")]
- pub multiline: bool,
- /// Limit output to first N results
- #[serde(skip_serializing_if = "Option::is_none")]
- pub head_limit: Option<u32>,
-}
-
-impl std::fmt::Display for GrepToolParams {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "grep")?;
-
- // Boolean flags
- if self.case_insensitive {
- write!(f, " -i")?;
- }
- if self.line_numbers {
- write!(f, " -n")?;
- }
-
- // Context options
- if let Some(after) = self.after_context {
- write!(f, " -A {}", after)?;
- }
- if let Some(before) = self.before_context {
- write!(f, " -B {}", before)?;
- }
- if let Some(context) = self.context {
- write!(f, " -C {}", context)?;
- }
-
- // Output mode
- if let Some(mode) = &self.output_mode {
- match mode {
- GrepOutputMode::FilesWithMatches => write!(f, " -l")?,
- GrepOutputMode::Count => write!(f, " -c")?,
- GrepOutputMode::Content => {} // Default mode
- }
- }
-
- // Head limit
- if let Some(limit) = self.head_limit {
- write!(f, " | head -{}", limit)?;
- }
-
- // Glob pattern
- if let Some(glob) = &self.glob {
- write!(f, " --include=\"{}\"", glob)?;
- }
-
- // File type
- if let Some(file_type) = &self.file_type {
- write!(f, " --type={}", file_type)?;
- }
-
- // Multiline
- if self.multiline {
- write!(f, " -P")?; // Perl-compatible regex for multiline
- }
-
- // Pattern (escaped if contains special characters)
- write!(f, " \"{}\"", self.pattern)?;
-
- // Path
- if let Some(path) = &self.path {
- write!(f, " {}", path)?;
- }
-
- Ok(())
- }
-}
-
-#[derive(Default, Deserialize, Serialize, JsonSchema, strum::Display, Debug)]
-#[serde(rename_all = "snake_case")]
-pub enum TodoPriority {
- High,
- #[default]
- Medium,
- Low,
-}
-
-impl Into<acp::PlanEntryPriority> for TodoPriority {
- fn into(self) -> acp::PlanEntryPriority {
- match self {
- TodoPriority::High => acp::PlanEntryPriority::High,
- TodoPriority::Medium => acp::PlanEntryPriority::Medium,
- TodoPriority::Low => acp::PlanEntryPriority::Low,
- }
- }
-}
-
-#[derive(Deserialize, Serialize, JsonSchema, Debug)]
-#[serde(rename_all = "snake_case")]
-pub enum TodoStatus {
- Pending,
- InProgress,
- Completed,
-}
-
-impl Into<acp::PlanEntryStatus> for TodoStatus {
- fn into(self) -> acp::PlanEntryStatus {
- match self {
- TodoStatus::Pending => acp::PlanEntryStatus::Pending,
- TodoStatus::InProgress => acp::PlanEntryStatus::InProgress,
- TodoStatus::Completed => acp::PlanEntryStatus::Completed,
- }
- }
-}
-
-#[derive(Deserialize, Serialize, JsonSchema, Debug)]
-pub struct Todo {
- /// Task description
- pub content: String,
- /// Current status of the todo
- pub status: TodoStatus,
- /// Priority level of the todo
- #[serde(default)]
- pub priority: TodoPriority,
-}
-
-impl Into<acp::PlanEntry> for Todo {
- fn into(self) -> acp::PlanEntry {
- acp::PlanEntry {
- content: self.content,
- priority: self.priority.into(),
- status: self.status.into(),
- }
- }
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct TodoWriteToolParams {
- pub todos: Vec<Todo>,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct ExitPlanModeToolParams {
- /// Implementation plan in markdown format
- pub plan: String,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct TaskToolParams {
- /// Short 3-5 word description of task
- pub description: String,
- /// Detailed task for agent to perform
- pub prompt: String,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct NotebookReadToolParams {
- /// Absolute path to .ipynb file
- pub notebook_path: PathBuf,
- /// Specific cell ID to read
- #[serde(skip_serializing_if = "Option::is_none")]
- pub cell_id: Option<String>,
-}
-
-#[derive(Deserialize, Serialize, JsonSchema, Debug)]
-#[serde(rename_all = "snake_case")]
-pub enum CellType {
- Code,
- Markdown,
-}
-
-#[derive(Deserialize, Serialize, JsonSchema, Debug)]
-#[serde(rename_all = "snake_case")]
-pub enum EditMode {
- Replace,
- Insert,
- Delete,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct NotebookEditToolParams {
- /// Absolute path to .ipynb file
- pub notebook_path: PathBuf,
- /// New cell content
- pub new_source: String,
- /// Cell ID to edit
- #[serde(skip_serializing_if = "Option::is_none")]
- pub cell_id: Option<String>,
- /// Type of cell (code or markdown)
- #[serde(skip_serializing_if = "Option::is_none")]
- pub cell_type: Option<CellType>,
- /// Edit operation mode
- #[serde(skip_serializing_if = "Option::is_none")]
- pub edit_mode: Option<EditMode>,
-}
-
-#[derive(Deserialize, Serialize, JsonSchema, Debug)]
-pub struct MultiEditItem {
- /// The text to search for and replace
- pub old_string: String,
- /// The replacement text
- pub new_string: String,
- /// Whether to replace all occurrences or just the first
- #[serde(default, skip_serializing_if = "is_false")]
- pub replace_all: bool,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct MultiEditToolParams {
- /// Absolute path to file
- pub file_path: PathBuf,
- /// List of edits to apply
- pub edits: Vec<MultiEditItem>,
-}
-
-fn is_false(v: &bool) -> bool {
- !*v
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-#[serde(rename_all = "snake_case")]
-pub enum GrepOutputMode {
- Content,
- FilesWithMatches,
- Count,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct WebFetchToolParams {
- /// Valid URL to fetch
- #[serde(rename = "url")]
- pub url: String,
- /// What to extract from content
- pub prompt: String,
-}
-
-#[derive(Deserialize, JsonSchema, Debug)]
-pub struct WebSearchToolParams {
- /// Search query (min 2 chars)
- pub query: String,
- /// Only include these domains
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
- pub allowed_domains: Vec<String>,
- /// Exclude these domains
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
- pub blocked_domains: Vec<String>,
-}
-
-impl std::fmt::Display for WebSearchToolParams {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "\"{}\"", self.query)?;
-
- if !self.allowed_domains.is_empty() {
- write!(f, " (allowed: {})", self.allowed_domains.join(", "))?;
- }
-
- if !self.blocked_domains.is_empty() {
- write!(f, " (blocked: {})", self.blocked_domains.join(", "))?;
- }
-
- Ok(())
- }
-}
@@ -1,59 +0,0 @@
-use acp_thread::AcpThread;
-use anyhow::Result;
-use context_server::{
- listener::{McpServerTool, ToolResponse},
- types::ToolAnnotations,
-};
-use gpui::{AsyncApp, WeakEntity};
-
-use crate::tools::WriteToolParams;
-
-#[derive(Clone)]
-pub struct WriteTool {
- thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
-}
-
-impl WriteTool {
- pub fn new(thread_rx: watch::Receiver<WeakEntity<AcpThread>>) -> Self {
- Self { thread_rx }
- }
-}
-
-impl McpServerTool for WriteTool {
- type Input = WriteToolParams;
- type Output = ();
-
- const NAME: &'static str = "Write";
-
- fn annotations(&self) -> ToolAnnotations {
- ToolAnnotations {
- title: Some("Write file".to_string()),
- read_only_hint: Some(false),
- destructive_hint: Some(false),
- open_world_hint: Some(false),
- idempotent_hint: Some(false),
- }
- }
-
- async fn run(
- &self,
- input: Self::Input,
- cx: &mut AsyncApp,
- ) -> Result<ToolResponse<Self::Output>> {
- let mut thread_rx = self.thread_rx.clone();
- let Some(thread) = thread_rx.recv().await?.upgrade() else {
- anyhow::bail!("Thread closed");
- };
-
- thread
- .update(cx, |thread, cx| {
- thread.write_text_file(input.abs_path, input.content, cx)
- })?
- .await?;
-
- Ok(ToolResponse {
- content: vec![],
- structured_content: (),
- })
- }
-}
@@ -1,8 +1,7 @@
-use crate::{AgentServerCommand, AgentServerSettings};
+use crate::{AgentServerCommand, AgentServerDelegate};
use acp_thread::AgentConnection;
use anyhow::Result;
-use gpui::{App, Entity, SharedString, Task};
-use project::Project;
+use gpui::{App, SharedString, Task};
use std::{path::Path, rc::Rc};
use ui::IconName;
@@ -13,11 +12,8 @@ pub struct CustomAgentServer {
}
impl CustomAgentServer {
- pub fn new(name: SharedString, settings: &AgentServerSettings) -> Self {
- Self {
- name,
- command: settings.command.clone(),
- }
+ pub fn new(name: SharedString, command: AgentServerCommand) -> Self {
+ Self { name, command }
}
}
@@ -34,27 +30,16 @@ impl crate::AgentServer for CustomAgentServer {
IconName::Terminal
}
- fn empty_state_headline(&self) -> SharedString {
- "No conversations yet".into()
- }
-
- fn empty_state_message(&self) -> SharedString {
- format!("Start a conversation with {}", self.name).into()
- }
-
fn connect(
&self,
root_dir: &Path,
- _project: &Entity<Project>,
+ _delegate: AgentServerDelegate,
cx: &mut App,
) -> Task<Result<Rc<dyn AgentConnection>>> {
let server_name = self.name();
let command = self.command.clone();
let root_dir = root_dir.to_path_buf();
-
- cx.spawn(async move |mut cx| {
- crate::acp::connect(server_name, command, &root_dir, &mut cx).await
- })
+ cx.spawn(async move |cx| crate::acp::connect(server_name, command, &root_dir, cx).await)
}
fn into_any(self: Rc<Self>) -> Rc<dyn std::any::Any> {
@@ -1,4 +1,6 @@
-use crate::AgentServer;
+use crate::{AgentServer, AgentServerDelegate};
+#[cfg(test)]
+use crate::{AgentServerCommand, CustomAgentServerSettings};
use acp_thread::{AcpThread, AgentThreadEntry, ToolCall, ToolCallStatus};
use agent_client_protocol as acp;
use futures::{FutureExt, StreamExt, channel::mpsc, select};
@@ -471,12 +473,14 @@ pub async fn init_test(cx: &mut TestAppContext) -> Arc<FakeFs> {
#[cfg(test)]
crate::AllAgentServersSettings::override_global(
crate::AllAgentServersSettings {
- claude: Some(crate::AgentServerSettings {
- command: crate::claude::tests::local_command(),
- }),
- gemini: Some(crate::AgentServerSettings {
- command: crate::gemini::tests::local_command(),
+ claude: Some(CustomAgentServerSettings {
+ command: AgentServerCommand {
+ path: "claude-code-acp".into(),
+ args: vec![],
+ env: None,
+ },
}),
+ gemini: Some(crate::gemini::tests::local_command().into()),
custom: collections::HashMap::default(),
},
cx,
@@ -494,8 +498,10 @@ pub async fn new_test_thread(
current_dir: impl AsRef<Path>,
cx: &mut TestAppContext,
) -> Entity<AcpThread> {
+ let delegate = AgentServerDelegate::new(project.clone(), watch::channel("".into()).0);
+
let connection = cx
- .update(|cx| server.connect(current_dir.as_ref(), &project, cx))
+ .update(|cx| server.connect(current_dir.as_ref(), delegate, cx))
.await
.unwrap();
@@ -1,12 +1,12 @@
use std::rc::Rc;
use std::{any::Any, path::Path};
-use crate::{AgentServer, AgentServerCommand};
+use crate::acp::AcpConnection;
+use crate::{AgentServer, AgentServerDelegate};
use acp_thread::{AgentConnection, LoadError};
use anyhow::Result;
-use gpui::{App, Entity, SharedString, Task};
+use gpui::{App, AppContext as _, SharedString, Task};
use language_models::provider::google::GoogleLanguageModelProvider;
-use project::Project;
use settings::SettingsStore;
use crate::AllAgentServersSettings;
@@ -25,14 +25,6 @@ impl AgentServer for Gemini {
"Gemini CLI".into()
}
- fn empty_state_headline(&self) -> SharedString {
- self.name()
- }
-
- fn empty_state_message(&self) -> SharedString {
- "Ask questions, edit files, run commands".into()
- }
-
fn logo(&self) -> ui::IconName {
ui::IconName::AiGemini
}
@@ -40,60 +32,99 @@ impl AgentServer for Gemini {
fn connect(
&self,
root_dir: &Path,
- project: &Entity<Project>,
+ delegate: AgentServerDelegate,
cx: &mut App,
) -> Task<Result<Rc<dyn AgentConnection>>> {
- let project = project.clone();
let root_dir = root_dir.to_path_buf();
let server_name = self.name();
+ let settings = cx.read_global(|settings: &SettingsStore, _| {
+ settings.get::<AllAgentServersSettings>(None).gemini.clone()
+ });
+
cx.spawn(async move |cx| {
- let settings = cx.read_global(|settings: &SettingsStore, _| {
- settings.get::<AllAgentServersSettings>(None).gemini.clone()
- })?;
-
- let Some(mut command) =
- AgentServerCommand::resolve("gemini", &[ACP_ARG], None, settings, &project, cx).await
- else {
- return Err(LoadError::NotInstalled {
- error_message: "Failed to find Gemini CLI binary".into(),
- install_message: "Install Gemini CLI".into(),
- install_command: Self::install_command().into(),
- }.into());
+ let ignore_system_version = settings
+ .as_ref()
+ .and_then(|settings| settings.ignore_system_version)
+ .unwrap_or(true);
+ let mut command = if let Some(settings) = settings
+ && let Some(command) = settings.custom_command()
+ {
+ command
+ } else {
+ cx.update(|cx| {
+ delegate.get_or_npm_install_builtin_agent(
+ Self::BINARY_NAME.into(),
+ Self::PACKAGE_NAME.into(),
+ format!("node_modules/{}/dist/index.js", Self::PACKAGE_NAME).into(),
+ ignore_system_version,
+ Some(Self::MINIMUM_VERSION.parse().unwrap()),
+ cx,
+ )
+ })?
+ .await?
};
+ command.args.push("--experimental-acp".into());
- if let Some(api_key)= cx.update(GoogleLanguageModelProvider::api_key)?.await.ok() {
- command.env.get_or_insert_default().insert("GEMINI_API_KEY".to_owned(), api_key.key);
+ if let Some(api_key) = cx.update(GoogleLanguageModelProvider::api_key)?.await.ok() {
+ command
+ .env
+ .get_or_insert_default()
+ .insert("GEMINI_API_KEY".to_owned(), api_key.key);
}
let result = crate::acp::connect(server_name, command.clone(), &root_dir, cx).await;
- if result.is_err() {
- let version_fut = util::command::new_smol_command(&command.path)
- .args(command.args.iter())
- .arg("--version")
- .kill_on_drop(true)
- .output();
-
- let help_fut = util::command::new_smol_command(&command.path)
- .args(command.args.iter())
- .arg("--help")
- .kill_on_drop(true)
- .output();
-
- let (version_output, help_output) = futures::future::join(version_fut, help_fut).await;
-
- let current_version = String::from_utf8(version_output?.stdout)?;
- let supported = String::from_utf8(help_output?.stdout)?.contains(ACP_ARG);
-
- if !supported {
- return Err(LoadError::Unsupported {
- error_message: format!(
- "Your installed version of Gemini CLI ({}, version {}) doesn't support the Agentic Coding Protocol (ACP).",
- command.path.to_string_lossy(),
- current_version
- ).into(),
- upgrade_message: "Upgrade Gemini CLI to latest".into(),
- upgrade_command: Self::upgrade_command().into(),
- }.into())
+ match &result {
+ Ok(connection) => {
+ if let Some(connection) = connection.clone().downcast::<AcpConnection>()
+ && !connection.prompt_capabilities().image
+ {
+ let version_output = util::command::new_smol_command(&command.path)
+ .args(command.args.iter())
+ .arg("--version")
+ .kill_on_drop(true)
+ .output()
+ .await;
+ let current_version =
+ String::from_utf8(version_output?.stdout)?.trim().to_owned();
+ if !connection.prompt_capabilities().image {
+ return Err(LoadError::Unsupported {
+ current_version: current_version.into(),
+ command: command.path.to_string_lossy().to_string().into(),
+ minimum_version: Self::MINIMUM_VERSION.into(),
+ }
+ .into());
+ }
+ }
+ }
+ Err(_) => {
+ let version_fut = util::command::new_smol_command(&command.path)
+ .args(command.args.iter())
+ .arg("--version")
+ .kill_on_drop(true)
+ .output();
+
+ let help_fut = util::command::new_smol_command(&command.path)
+ .args(command.args.iter())
+ .arg("--help")
+ .kill_on_drop(true)
+ .output();
+
+ let (version_output, help_output) =
+ futures::future::join(version_fut, help_fut).await;
+
+ let current_version = std::str::from_utf8(&version_output?.stdout)?
+ .trim()
+ .to_string();
+ let supported = String::from_utf8(help_output?.stdout)?.contains(ACP_ARG);
+
+ if !supported {
+ return Err(LoadError::Unsupported {
+ current_version: current_version.into(),
+ command: command.path.to_string_lossy().to_string().into(),
+ minimum_version: Self::MINIMUM_VERSION.into(),
+ }
+ .into());
+ }
}
}
result
@@ -106,17 +137,11 @@ impl AgentServer for Gemini {
}
impl Gemini {
- pub fn binary_name() -> &'static str {
- "gemini"
- }
+ const PACKAGE_NAME: &str = "@google/gemini-cli";
- pub fn install_command() -> &'static str {
- "npm install -g @google/gemini-cli@preview"
- }
+ const MINIMUM_VERSION: &str = "0.2.1";
- pub fn upgrade_command() -> &'static str {
- "npm install -g @google/gemini-cli@preview"
- }
+ const BINARY_NAME: &str = "gemini";
}
#[cfg(test)]
@@ -1,3 +1,5 @@
+use std::path::PathBuf;
+
use crate::AgentServerCommand;
use anyhow::Result;
use collections::HashMap;
@@ -12,16 +14,62 @@ pub fn init(cx: &mut App) {
#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)]
pub struct AllAgentServersSettings {
- pub gemini: Option<AgentServerSettings>,
- pub claude: Option<AgentServerSettings>,
+ pub gemini: Option<BuiltinAgentServerSettings>,
+ pub claude: Option<CustomAgentServerSettings>,
/// Custom agent servers configured by the user
#[serde(flatten)]
- pub custom: HashMap<SharedString, AgentServerSettings>,
+ pub custom: HashMap<SharedString, CustomAgentServerSettings>,
+}
+
+#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
+pub struct BuiltinAgentServerSettings {
+ /// Absolute path to a binary to be used when launching this agent.
+ ///
+ /// This can be used to run a specific binary without automatic downloads or searching `$PATH`.
+ #[serde(rename = "command")]
+ pub path: Option<PathBuf>,
+ /// If a binary is specified in `command`, it will be passed these arguments.
+ pub args: Option<Vec<String>>,
+ /// If a binary is specified in `command`, it will be passed these environment variables.
+ pub env: Option<HashMap<String, String>>,
+ /// Whether to skip searching `$PATH` for an agent server binary when
+ /// launching this agent.
+ ///
+ /// This has no effect if a `command` is specified. Otherwise, when this is
+ /// `false`, Zed will search `$PATH` for an agent server binary and, if one
+ /// is found, use it for threads with this agent. If no agent binary is
+ /// found on `$PATH`, Zed will automatically install and use its own binary.
+ /// When this is `true`, Zed will not search `$PATH`, and will always use
+ /// its own binary.
+ ///
+ /// Default: true
+ pub ignore_system_version: Option<bool>,
+}
+
+impl BuiltinAgentServerSettings {
+ pub(crate) fn custom_command(self) -> Option<AgentServerCommand> {
+ self.path.map(|path| AgentServerCommand {
+ path,
+ args: self.args.unwrap_or_default(),
+ env: self.env,
+ })
+ }
+}
+
+impl From<AgentServerCommand> for BuiltinAgentServerSettings {
+ fn from(value: AgentServerCommand) -> Self {
+ BuiltinAgentServerSettings {
+ path: Some(value.path),
+ args: Some(value.args),
+ env: value.env,
+ ..Default::default()
+ }
+ }
}
#[derive(Deserialize, Serialize, Clone, JsonSchema, Debug, PartialEq)]
-pub struct AgentServerSettings {
+pub struct CustomAgentServerSettings {
#[serde(flatten)]
pub command: AgentServerCommand,
}
@@ -6,8 +6,8 @@ use agent2::HistoryStore;
use collections::HashMap;
use editor::{Editor, EditorMode, MinimapVisibility};
use gpui::{
- AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, Focusable,
- TextStyleRefinement, WeakEntity, Window,
+ AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
+ ScrollHandle, TextStyleRefinement, WeakEntity, Window,
};
use language::language_settings::SoftWrap;
use project::Project;
@@ -154,10 +154,22 @@ impl EntryViewState {
});
}
}
- AgentThreadEntry::AssistantMessage(_) => {
- if index == self.entries.len() {
- self.entries.push(Entry::empty())
- }
+ AgentThreadEntry::AssistantMessage(message) => {
+ let entry = if let Some(Entry::AssistantMessage(entry)) =
+ self.entries.get_mut(index)
+ {
+ entry
+ } else {
+ self.set_entry(
+ index,
+ Entry::AssistantMessage(AssistantMessageEntry::default()),
+ );
+ let Some(Entry::AssistantMessage(entry)) = self.entries.get_mut(index) else {
+ unreachable!()
+ };
+ entry
+ };
+ entry.sync(message);
}
};
}
@@ -177,7 +189,7 @@ impl EntryViewState {
pub fn settings_changed(&mut self, cx: &mut App) {
for entry in self.entries.iter() {
match entry {
- Entry::UserMessage { .. } => {}
+ Entry::UserMessage { .. } | Entry::AssistantMessage { .. } => {}
Entry::Content(response_views) => {
for view in response_views.values() {
if let Ok(diff_editor) = view.clone().downcast::<Editor>() {
@@ -208,17 +220,44 @@ pub enum ViewEvent {
MessageEditorEvent(Entity<MessageEditor>, MessageEditorEvent),
}
+#[derive(Default, Debug)]
+pub struct AssistantMessageEntry {
+ scroll_handles_by_chunk_index: HashMap<usize, ScrollHandle>,
+}
+
+impl AssistantMessageEntry {
+ pub fn scroll_handle_for_chunk(&self, ix: usize) -> Option<ScrollHandle> {
+ self.scroll_handles_by_chunk_index.get(&ix).cloned()
+ }
+
+ pub fn sync(&mut self, message: &acp_thread::AssistantMessage) {
+ if let Some(acp_thread::AssistantMessageChunk::Thought { .. }) = message.chunks.last() {
+ let ix = message.chunks.len() - 1;
+ let handle = self.scroll_handles_by_chunk_index.entry(ix).or_default();
+ handle.scroll_to_bottom();
+ }
+ }
+}
+
#[derive(Debug)]
pub enum Entry {
UserMessage(Entity<MessageEditor>),
+ AssistantMessage(AssistantMessageEntry),
Content(HashMap<EntityId, AnyEntity>),
}
impl Entry {
+ pub fn focus_handle(&self, cx: &App) -> Option<FocusHandle> {
+ match self {
+ Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)),
+ Self::AssistantMessage(_) | Self::Content(_) => None,
+ }
+ }
+
pub fn message_editor(&self) -> Option<&Entity<MessageEditor>> {
match self {
Self::UserMessage(editor) => Some(editor),
- Entry::Content(_) => None,
+ Self::AssistantMessage(_) | Self::Content(_) => None,
}
}
@@ -239,6 +278,16 @@ impl Entry {
.map(|entity| entity.downcast::<TerminalView>().unwrap())
}
+ pub fn scroll_handle_for_assistant_message_chunk(
+ &self,
+ chunk_ix: usize,
+ ) -> Option<ScrollHandle> {
+ match self {
+ Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix),
+ Self::UserMessage(_) | Self::Content(_) => None,
+ }
+ }
+
fn content_map(&self) -> Option<&HashMap<EntityId, AnyEntity>> {
match self {
Self::Content(map) => Some(map),
@@ -254,7 +303,7 @@ impl Entry {
pub fn has_content(&self) -> bool {
match self {
Self::Content(map) => !map.is_empty(),
- Self::UserMessage(_) => false,
+ Self::UserMessage(_) | Self::AssistantMessage(_) => false,
}
}
}
@@ -4,7 +4,7 @@ use crate::{
};
use acp_thread::{MentionUri, selection_name};
use agent_client_protocol as acp;
-use agent_servers::AgentServer;
+use agent_servers::{AgentServer, AgentServerDelegate};
use agent2::HistoryStore;
use anyhow::{Result, anyhow};
use assistant_slash_commands::codeblock_fence_for_path;
@@ -648,7 +648,8 @@ impl MessageEditor {
self.project.read(cx).fs().clone(),
self.history_store.clone(),
));
- let connection = server.connect(Path::new(""), &self.project, cx);
+ let delegate = AgentServerDelegate::new(self.project.clone(), watch::channel("".into()).0);
+ let connection = server.connect(Path::new(""), delegate, cx);
cx.spawn(async move |_, cx| {
let agent = connection.await?;
let agent = agent.downcast::<agent2::NativeAgentConnection>().unwrap();
@@ -73,11 +73,8 @@ impl AcpModelPickerDelegate {
this.update_in(cx, |this, window, cx| {
this.delegate.models = models.ok();
this.delegate.selected_model = selected_model.ok();
- this.delegate.update_matches(this.query(cx), window, cx)
- })?
- .await;
-
- Ok(())
+ this.refresh(window, cx)
+ })
}
refresh(&this, &session_id, cx).await.log_err();
@@ -6,10 +6,10 @@ use acp_thread::{
use acp_thread::{AgentConnection, Plan};
use action_log::ActionLog;
use agent_client_protocol::{self as acp, PromptCapabilities};
-use agent_servers::{AgentServer, ClaudeCode};
+use agent_servers::{AgentServer, AgentServerDelegate, ClaudeCode};
use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
use agent2::{DbThreadMetadata, HistoryEntry, HistoryEntryId, HistoryStore};
-use anyhow::bail;
+use anyhow::{Result, anyhow, bail};
use audio::{Audio, Sound};
use buffer_diff::BufferDiff;
use client::zed_urls;
@@ -18,13 +18,14 @@ use editor::scroll::Autoscroll;
use editor::{Editor, EditorEvent, EditorMode, MultiBuffer, PathKey, SelectionEffects};
use file_icons::FileIcons;
use fs::Fs;
+use futures::FutureExt as _;
use gpui::{
Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem,
- EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset,
- ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement, Subscription,
- Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window,
- WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage, point,
- prelude::*, pulsating_between,
+ CursorStyle, EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length,
+ ListOffset, ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement,
+ Subscription, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity,
+ Window, WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage,
+ point, prelude::*, pulsating_between,
};
use language::Buffer;
@@ -39,6 +40,8 @@ use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
use std::{collections::BTreeMap, rc::Rc, time::Duration};
+use task::SpawnInTerminal;
+use terminal_view::terminal_panel::TerminalPanel;
use text::Anchor;
use theme::ThemeSettings;
use ui::{
@@ -66,7 +69,6 @@ use crate::{
KeepAll, OpenAgentDiff, OpenHistory, RejectAll, ToggleBurnMode, ToggleProfileSelector,
};
-const RESPONSE_PADDING_X: Pixels = px(19.);
pub const MIN_EDITOR_LINES: usize = 4;
pub const MAX_EDITOR_LINES: usize = 8;
@@ -94,6 +96,10 @@ impl ThreadError {
error.downcast_ref::<language_model::ModelRequestLimitReachedError>()
{
Self::ModelRequestLimitReached(error.plan)
+ } else if let Some(acp_error) = error.downcast_ref::<acp::Error>()
+ && acp_error.code == acp::ErrorCode::AUTH_REQUIRED.code
+ {
+ Self::AuthenticationRequired(acp_error.message.clone().into())
} else {
let string = error.to_string();
// TODO: we should have Gemini return better errors here.
@@ -284,9 +290,7 @@ pub struct AcpThreadView {
}
enum ThreadState {
- Loading {
- _task: Task<()>,
- },
+ Loading(Entity<LoadingView>),
Ready {
thread: Entity<AcpThread>,
title_editor: Option<Entity<Editor>>,
@@ -302,6 +306,12 @@ enum ThreadState {
},
}
+struct LoadingView {
+ title: SharedString,
+ _load_task: Task<()>,
+ _update_title_task: Task<anyhow::Result<()>>,
+}
+
impl AcpThreadView {
pub fn new(
agent: Rc<dyn AgentServer>,
@@ -412,8 +422,10 @@ impl AcpThreadView {
.next()
.map(|worktree| worktree.read(cx).abs_path())
.unwrap_or_else(|| paths::home_dir().as_path().into());
+ let (tx, mut rx) = watch::channel("Loading…".into());
+ let delegate = AgentServerDelegate::new(project.clone(), tx);
- let connect_task = agent.connect(&root_dir, &project, cx);
+ let connect_task = agent.connect(&root_dir, delegate, cx);
let load_task = cx.spawn_in(window, async move |this, cx| {
let connection = match connect_task.await {
Ok(connection) => connection,
@@ -478,11 +490,14 @@ impl AcpThreadView {
.set(thread.read(cx).prompt_capabilities());
let count = thread.read(cx).entries().len();
- this.list_state.splice(0..0, count);
this.entry_view_state.update(cx, |view_state, cx| {
for ix in 0..count {
view_state.sync_entry(ix, &thread, window, cx);
}
+ this.list_state.splice_focusable(
+ 0..0,
+ (0..count).map(|ix| view_state.entry(ix)?.focus_handle(cx)),
+ );
});
if let Some(resume) = resume_thread {
@@ -563,7 +578,25 @@ impl AcpThreadView {
.log_err();
});
- ThreadState::Loading { _task: load_task }
+ let loading_view = cx.new(|cx| {
+ let update_title_task = cx.spawn(async move |this, cx| {
+ loop {
+ let status = rx.recv().await?;
+ this.update(cx, |this: &mut LoadingView, cx| {
+ this.title = status;
+ cx.notify();
+ })?;
+ }
+ });
+
+ LoadingView {
+ title: "Loading…".into(),
+ _load_task: load_task,
+ _update_title_task: update_title_task,
+ }
+ });
+
+ ThreadState::Loading(loading_view)
}
fn handle_auth_required(
@@ -663,11 +696,18 @@ impl AcpThreadView {
}
}
- pub fn title(&self) -> SharedString {
+ pub fn title(&self, cx: &App) -> SharedString {
match &self.thread_state {
ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(),
- ThreadState::Loading { .. } => "Loading…".into(),
- ThreadState::LoadError(_) => "Failed to load".into(),
+ ThreadState::Loading(loading_view) => loading_view.read(cx).title.clone(),
+ ThreadState::LoadError(error) => match error {
+ LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(),
+ LoadError::FailedToInstall(_) => {
+ format!("Failed to Install {}", self.agent.name()).into()
+ }
+ LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(),
+ LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(),
+ },
}
}
@@ -889,7 +929,7 @@ impl AcpThreadView {
fn send_impl(
&mut self,
- contents: Task<anyhow::Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>>,
+ contents: Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -899,9 +939,10 @@ impl AcpThreadView {
self.editing_message.take();
self.thread_feedback.clear();
- let Some(thread) = self.thread().cloned() else {
+ let Some(thread) = self.thread() else {
return;
};
+ let thread = thread.downgrade();
if self.should_be_following {
self.workspace
.update(cx, |workspace, cx| {
@@ -1109,9 +1150,14 @@ impl AcpThreadView {
let len = thread.read(cx).entries().len();
let index = len - 1;
self.entry_view_state.update(cx, |view_state, cx| {
- view_state.sync_entry(index, thread, window, cx)
+ view_state.sync_entry(index, thread, window, cx);
+ self.list_state.splice_focusable(
+ index..index,
+ [view_state
+ .entry(index)
+ .and_then(|entry| entry.focus_handle(cx))],
+ );
});
- self.list_state.splice(index..index, 1);
}
AcpThreadEvent::EntryUpdated(index) => {
self.entry_view_state.update(cx, |view_state, cx| {
@@ -1219,6 +1265,31 @@ impl AcpThreadView {
});
return;
}
+ } else if method.0.as_ref() == "anthropic-api-key" {
+ let registry = LanguageModelRegistry::global(cx);
+ let provider = registry
+ .read(cx)
+ .provider(&language_model::ANTHROPIC_PROVIDER_ID)
+ .unwrap();
+ if !provider.is_authenticated(cx) {
+ let this = cx.weak_entity();
+ let agent = self.agent.clone();
+ let connection = connection.clone();
+ window.defer(cx, |window, cx| {
+ Self::handle_auth_required(
+ this,
+ AuthRequired {
+ description: Some("ANTHROPIC_API_KEY must be set".to_owned()),
+ provider_id: Some(language_model::ANTHROPIC_PROVIDER_ID),
+ },
+ agent,
+ connection,
+ window,
+ cx,
+ );
+ });
+ return;
+ }
} else if method.0.as_ref() == "vertex-ai"
&& std::env::var("GOOGLE_API_KEY").is_err()
&& (std::env::var("GOOGLE_CLOUD_PROJECT").is_err()
@@ -1250,7 +1321,15 @@ impl AcpThreadView {
self.thread_error.take();
configuration_view.take();
pending_auth_method.replace(method.clone());
- let authenticate = connection.authenticate(method, cx);
+ let authenticate = if method.0.as_ref() == "claude-login" {
+ if let Some(workspace) = self.workspace.upgrade() {
+ Self::spawn_claude_login(&workspace, window, cx)
+ } else {
+ Task::ready(Ok(()))
+ }
+ } else {
+ connection.authenticate(method, cx)
+ };
cx.notify();
self.auth_task =
Some(cx.spawn_in(window, {
@@ -1274,6 +1353,13 @@ impl AcpThreadView {
this.update_in(cx, |this, window, cx| {
if let Err(err) = result {
+ if let ThreadState::Unauthenticated {
+ pending_auth_method,
+ ..
+ } = &mut this.thread_state
+ {
+ pending_auth_method.take();
+ }
this.handle_thread_error(err, cx);
} else {
this.thread_state = Self::initial_state(
@@ -1292,6 +1378,76 @@ impl AcpThreadView {
}));
}
+ fn spawn_claude_login(
+ workspace: &Entity<Workspace>,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> Task<Result<()>> {
+ let Some(terminal_panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
+ return Task::ready(Ok(()));
+ };
+ let project = workspace.read(cx).project().read(cx);
+ let cwd = project.first_project_directory(cx);
+ let shell = project.terminal_settings(&cwd, cx).shell.clone();
+
+ let terminal = terminal_panel.update(cx, |terminal_panel, cx| {
+ terminal_panel.spawn_task(
+ &SpawnInTerminal {
+ id: task::TaskId("claude-login".into()),
+ full_label: "claude /login".to_owned(),
+ label: "claude /login".to_owned(),
+ command: Some("claude".to_owned()),
+ args: vec!["/login".to_owned()],
+ command_label: "claude /login".to_owned(),
+ cwd,
+ use_new_terminal: true,
+ allow_concurrent_runs: true,
+ hide: task::HideStrategy::Always,
+ shell,
+ ..Default::default()
+ },
+ window,
+ cx,
+ )
+ });
+ cx.spawn(async move |cx| {
+ let terminal = terminal.await?;
+ let mut exit_status = terminal
+ .read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
+ .fuse();
+
+ let logged_in = cx
+ .spawn({
+ let terminal = terminal.clone();
+ async move |cx| {
+ loop {
+ cx.background_executor().timer(Duration::from_secs(1)).await;
+ let content =
+ terminal.update(cx, |terminal, _cx| terminal.get_content())?;
+ if content.contains("Login successful") {
+ return anyhow::Ok(());
+ }
+ }
+ }
+ })
+ .fuse();
+ futures::pin_mut!(logged_in);
+ futures::select_biased! {
+ result = logged_in => {
+ if let Err(e) = result {
+ log::error!("{e}");
+ return Err(anyhow!("exited before logging in"));
+ }
+ }
+ _ = exit_status => {
+ return Err(anyhow!("exited before logging in"));
+ }
+ }
+ terminal.update(cx, |terminal, _| terminal.kill_active_task())?;
+ Ok(())
+ })
+ }
+
fn authorize_tool_call(
&mut self,
tool_call_id: acp::ToolCallId,
@@ -1367,14 +1523,14 @@ impl AcpThreadView {
.id(("user_message", entry_ix))
.map(|this| {
if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() {
- this.pt_4()
+ this.pt(rems_from_px(18.))
} else if rules_item.is_some() {
this.pt_3()
} else {
this.pt_2()
}
})
- .pb_4()
+ .pb_3()
.px_2()
.gap_1p5()
.w_full()
@@ -1493,10 +1649,12 @@ impl AcpThreadView {
.into_any()
}
AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
+ let is_last = entry_ix + 1 == total_entries;
+
let style = default_markdown_style(false, false, window, cx);
let message_body = v_flex()
.w_full()
- .gap_2p5()
+ .gap_3()
.children(chunks.iter().enumerate().filter_map(
|(chunk_ix, chunk)| match chunk {
AssistantMessageChunk::Message { block } => {
@@ -1523,8 +1681,8 @@ impl AcpThreadView {
v_flex()
.px_5()
- .py_1()
- .when(entry_ix + 1 == total_entries, |this| this.pb_4())
+ .py_1p5()
+ .when(is_last, |this| this.pb_4())
.w_full()
.text_ui(cx)
.child(message_body)
@@ -1533,7 +1691,7 @@ impl AcpThreadView {
AgentThreadEntry::ToolCall(tool_call) => {
let has_terminals = tool_call.terminals().next().is_some();
- div().w_full().py_1().px_5().map(|this| {
+ div().w_full().map(|this| {
if has_terminals {
this.children(tool_call.terminals().map(|terminal| {
self.render_terminal_tool_call(
@@ -1614,59 +1772,72 @@ impl AcpThreadView {
) -> AnyElement {
let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix));
let card_header_id = SharedString::from("inner-card-header");
+
let key = (entry_ix, chunk_ix);
+
let is_open = self.expanded_thinking_blocks.contains(&key);
+ let scroll_handle = self
+ .entry_view_state
+ .read(cx)
+ .entry(entry_ix)
+ .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix));
+
+ let thinking_content = {
+ div()
+ .id(("thinking-content", chunk_ix))
+ .when_some(scroll_handle, |this, scroll_handle| {
+ this.track_scroll(&scroll_handle)
+ })
+ .text_ui_sm(cx)
+ .overflow_hidden()
+ .child(
+ self.render_markdown(chunk, default_markdown_style(false, false, window, cx)),
+ )
+ };
+
v_flex()
+ .gap_1()
.child(
h_flex()
.id(header_id)
.group(&card_header_id)
.relative()
.w_full()
- .gap_1p5()
+ .pr_1()
+ .justify_between()
.child(
h_flex()
- .size_4()
- .justify_center()
+ .h(window.line_height() - px(2.))
+ .gap_1p5()
+ .overflow_hidden()
.child(
- div()
- .group_hover(&card_header_id, |s| s.invisible().w_0())
- .child(
- Icon::new(IconName::ToolThink)
- .size(IconSize::Small)
- .color(Color::Muted),
- ),
+ Icon::new(IconName::ToolThink)
+ .size(IconSize::Small)
+ .color(Color::Muted),
)
.child(
- h_flex()
- .absolute()
- .inset_0()
- .invisible()
- .justify_center()
- .group_hover(&card_header_id, |s| s.visible())
- .child(
- Disclosure::new(("expand", entry_ix), is_open)
- .opened_icon(IconName::ChevronUp)
- .closed_icon(IconName::ChevronRight)
- .on_click(cx.listener({
- move |this, _event, _window, cx| {
- if is_open {
- this.expanded_thinking_blocks.remove(&key);
- } else {
- this.expanded_thinking_blocks.insert(key);
- }
- cx.notify();
- }
- })),
- ),
+ div()
+ .text_size(self.tool_name_font_size())
+ .text_color(cx.theme().colors().text_muted)
+ .child("Thinking"),
),
)
.child(
- div()
- .text_size(self.tool_name_font_size())
- .text_color(cx.theme().colors().text_muted)
- .child("Thinking"),
+ Disclosure::new(("expand", entry_ix), is_open)
+ .opened_icon(IconName::ChevronUp)
+ .closed_icon(IconName::ChevronDown)
+ .visible_on_hover(&card_header_id)
+ .on_click(cx.listener({
+ move |this, _event, _window, cx| {
+ if is_open {
+ this.expanded_thinking_blocks.remove(&key);
+ } else {
+ this.expanded_thinking_blocks.insert(key);
+ }
+ cx.notify();
+ }
+ })),
)
.on_click(cx.listener({
move |this, _event, _window, cx| {
@@ -1682,17 +1853,11 @@ impl AcpThreadView {
.when(is_open, |this| {
this.child(
div()
- .relative()
- .mt_1p5()
- .ml(px(7.))
- .pl_4()
+ .ml_1p5()
+ .pl_3p5()
.border_l_1()
.border_color(self.tool_card_border_color(cx))
- .text_ui_sm(cx)
- .child(self.render_markdown(
- chunk,
- default_markdown_style(false, false, window, cx),
- )),
+ .child(thinking_content),
)
})
.into_any_element()
@@ -1705,7 +1870,6 @@ impl AcpThreadView {
window: &Window,
cx: &Context<Self>,
) -> Div {
- let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix));
let card_header_id = SharedString::from("inner-tool-call-header");
let tool_icon =
@@ -1734,11 +1898,7 @@ impl AcpThreadView {
_ => false,
};
- let failed_tool_call = matches!(
- tool_call.status,
- ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
- );
-
+ let has_location = tool_call.locations.len() == 1;
let needs_confirmation = matches!(
tool_call.status,
ToolCallStatus::WaitingForConfirmation { .. }
@@ -1751,46 +1911,56 @@ impl AcpThreadView {
let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id);
- let gradient_overlay = |color: Hsla| {
+ let gradient_overlay = {
div()
.absolute()
.top_0()
.right_0()
.w_12()
.h_full()
- .bg(linear_gradient(
- 90.,
- linear_color_stop(color, 1.),
- linear_color_stop(color.opacity(0.2), 0.),
- ))
- };
- let gradient_color = if use_card_layout {
- self.tool_card_header_bg(cx)
- } else {
- cx.theme().colors().panel_background
+ .map(|this| {
+ if use_card_layout {
+ this.bg(linear_gradient(
+ 90.,
+ linear_color_stop(self.tool_card_header_bg(cx), 1.),
+ linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.),
+ ))
+ } else {
+ this.bg(linear_gradient(
+ 90.,
+ linear_color_stop(cx.theme().colors().panel_background, 1.),
+ linear_color_stop(
+ cx.theme().colors().panel_background.opacity(0.2),
+ 0.,
+ ),
+ ))
+ }
+ })
};
let tool_output_display = if is_open {
match &tool_call.status {
- ToolCallStatus::WaitingForConfirmation { options, .. } => {
- v_flex()
- .w_full()
- .children(tool_call.content.iter().map(|content| {
- div()
- .child(self.render_tool_call_content(
- entry_ix, content, tool_call, window, cx,
- ))
- .into_any_element()
- }))
- .child(self.render_permission_buttons(
- options,
- entry_ix,
- tool_call.id.clone(),
- tool_call.content.is_empty(),
- cx,
- ))
- .into_any()
- }
+ ToolCallStatus::WaitingForConfirmation { options, .. } => v_flex()
+ .w_full()
+ .children(tool_call.content.iter().map(|content| {
+ div()
+ .child(self.render_tool_call_content(
+ entry_ix,
+ content,
+ tool_call,
+ use_card_layout,
+ window,
+ cx,
+ ))
+ .into_any_element()
+ }))
+ .child(self.render_permission_buttons(
+ options,
+ entry_ix,
+ tool_call.id.clone(),
+ cx,
+ ))
+ .into_any(),
ToolCallStatus::Pending | ToolCallStatus::InProgress
if is_edit
&& tool_call.content.is_empty()
@@ -1805,9 +1975,14 @@ impl AcpThreadView {
| ToolCallStatus::Canceled => v_flex()
.w_full()
.children(tool_call.content.iter().map(|content| {
- div().child(
- self.render_tool_call_content(entry_ix, content, tool_call, window, cx),
- )
+ div().child(self.render_tool_call_content(
+ entry_ix,
+ content,
+ tool_call,
+ use_card_layout,
+ window,
+ cx,
+ ))
}))
.into_any(),
ToolCallStatus::Rejected => Empty.into_any(),
@@ -1818,30 +1993,36 @@ impl AcpThreadView {
};
v_flex()
- .when(use_card_layout, |this| {
- this.rounded_md()
- .border_1()
- .border_color(self.tool_card_border_color(cx))
- .bg(cx.theme().colors().editor_background)
- .overflow_hidden()
+ .map(|this| {
+ if use_card_layout {
+ this.my_1p5()
+ .rounded_md()
+ .border_1()
+ .border_color(self.tool_card_border_color(cx))
+ .bg(cx.theme().colors().editor_background)
+ .overflow_hidden()
+ } else {
+ this.my_1()
+ }
+ })
+ .map(|this| {
+ if has_location && !use_card_layout {
+ this.ml_4()
+ } else {
+ this.ml_5()
+ }
})
+ .mr_5()
.child(
h_flex()
- .id(header_id)
.group(&card_header_id)
.relative()
.w_full()
- .max_w_full()
.gap_1()
+ .justify_between()
.when(use_card_layout, |this| {
- this.pl_1p5()
- .pr_1()
- .py_0p5()
- .rounded_t_md()
- .when(is_open && !failed_tool_call, |this| {
- this.border_b_1()
- .border_color(self.tool_card_border_color(cx))
- })
+ this.p_0p5()
+ .rounded_t(rems_from_px(5.))
.bg(self.tool_card_header_bg(cx))
})
.child(
@@ -1850,8 +2031,16 @@ impl AcpThreadView {
.w_full()
.h(window.line_height() - px(2.))
.text_size(self.tool_name_font_size())
+ .gap_1p5()
+ .when(has_location || use_card_layout, |this| this.px_1())
+ .when(has_location, |this| {
+ this.cursor(CursorStyle::PointingHand)
+ .rounded(rems_from_px(3.)) // Concentric border radius
+ .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5)))
+ })
+ .overflow_hidden()
.child(tool_icon)
- .child(if tool_call.locations.len() == 1 {
+ .child(if has_location {
let name = tool_call.locations[0]
.path
.file_name()
@@ -1862,13 +2051,6 @@ impl AcpThreadView {
h_flex()
.id(("open-tool-call-location", entry_ix))
.w_full()
- .max_w_full()
- .px_1p5()
- .rounded_sm()
- .overflow_x_scroll()
- .hover(|label| {
- label.bg(cx.theme().colors().element_hover.opacity(0.5))
- })
.map(|this| {
if use_card_layout {
this.text_color(cx.theme().colors().text)
@@ -1878,31 +2060,28 @@ impl AcpThreadView {
})
.child(name)
.tooltip(Tooltip::text("Jump to File"))
- .cursor(gpui::CursorStyle::PointingHand)
.on_click(cx.listener(move |this, _, window, cx| {
this.open_tool_call_location(entry_ix, 0, window, cx);
}))
.into_any_element()
} else {
h_flex()
- .relative()
.w_full()
- .max_w_full()
- .ml_1p5()
- .overflow_hidden()
- .child(h_flex().pr_8().child(self.render_markdown(
+ .child(self.render_markdown(
tool_call.label.clone(),
default_markdown_style(false, true, window, cx),
- )))
- .child(gradient_overlay(gradient_color))
+ ))
.into_any()
- }),
+ })
+ .when(!has_location, |this| this.child(gradient_overlay)),
)
- .child(
- h_flex()
- .gap_px()
- .when(is_collapsible, |this| {
- this.child(
+ .when(is_collapsible || failed_or_canceled, |this| {
+ this.child(
+ h_flex()
+ .px_1()
+ .gap_px()
+ .when(is_collapsible, |this| {
+ this.child(
Disclosure::new(("expand", entry_ix), is_open)
.opened_icon(IconName::ChevronUp)
.closed_icon(IconName::ChevronDown)
@@ -1919,15 +2098,16 @@ impl AcpThreadView {
}
})),
)
- })
- .when(failed_or_canceled, |this| {
- this.child(
- Icon::new(IconName::Close)
- .color(Color::Error)
- .size(IconSize::Small),
- )
- }),
- ),
+ })
+ .when(failed_or_canceled, |this| {
+ this.child(
+ Icon::new(IconName::Close)
+ .color(Color::Error)
+ .size(IconSize::Small),
+ )
+ }),
+ )
+ }),
)
.children(tool_output_display)
}
@@ -1937,6 +2117,7 @@ impl AcpThreadView {
entry_ix: usize,
content: &ToolCallContent,
tool_call: &ToolCall,
+ card_layout: bool,
window: &Window,
cx: &Context<Self>,
) -> AnyElement {
@@ -1945,7 +2126,13 @@ impl AcpThreadView {
if let Some(resource_link) = content.resource_link() {
self.render_resource_link(resource_link, cx)
} else if let Some(markdown) = content.markdown() {
- self.render_markdown_output(markdown.clone(), tool_call.id.clone(), window, cx)
+ self.render_markdown_output(
+ markdown.clone(),
+ tool_call.id.clone(),
+ card_layout,
+ window,
+ cx,
+ )
} else {
Empty.into_any_element()
}
@@ -1961,6 +2148,7 @@ impl AcpThreadView {
&self,
markdown: Entity<Markdown>,
tool_call_id: acp::ToolCallId,
+ card_layout: bool,
window: &Window,
cx: &Context<Self>,
) -> AnyElement {
@@ -1968,26 +2156,35 @@ impl AcpThreadView {
v_flex()
.mt_1p5()
- .ml(px(7.))
- .px_3p5()
.gap_2()
- .border_l_1()
- .border_color(self.tool_card_border_color(cx))
+ .when(!card_layout, |this| {
+ this.ml(rems(0.4))
+ .px_3p5()
+ .border_l_1()
+ .border_color(self.tool_card_border_color(cx))
+ })
+ .when(card_layout, |this| {
+ this.p_2()
+ .border_t_1()
+ .border_color(self.tool_card_border_color(cx))
+ })
.text_sm()
.text_color(cx.theme().colors().text_muted)
.child(self.render_markdown(markdown, default_markdown_style(false, false, window, cx)))
- .child(
- IconButton::new(button_id, IconName::ChevronUp)
- .full_width()
- .style(ButtonStyle::Outlined)
- .icon_color(Color::Muted)
- .on_click(cx.listener({
- move |this: &mut Self, _, _, cx: &mut Context<Self>| {
- this.expanded_tool_calls.remove(&tool_call_id);
- cx.notify();
- }
- })),
- )
+ .when(!card_layout, |this| {
+ this.child(
+ IconButton::new(button_id, IconName::ChevronUp)
+ .full_width()
+ .style(ButtonStyle::Outlined)
+ .icon_color(Color::Muted)
+ .on_click(cx.listener({
+ move |this: &mut Self, _, _, cx: &mut Context<Self>| {
+ this.expanded_tool_calls.remove(&tool_call_id);
+ cx.notify();
+ }
+ })),
+ )
+ })
.into_any_element()
}
@@ -2025,7 +2222,7 @@ impl AcpThreadView {
let button_id = SharedString::from(format!("item-{}", uri));
div()
- .ml(px(7.))
+ .ml(rems(0.4))
.pl_2p5()
.border_l_1()
.border_color(self.tool_card_border_color(cx))
@@ -2055,7 +2252,6 @@ impl AcpThreadView {
options: &[acp::PermissionOption],
entry_ix: usize,
tool_call_id: acp::ToolCallId,
- empty_content: bool,
cx: &Context<Self>,
) -> Div {
h_flex()
@@ -2065,10 +2261,8 @@ impl AcpThreadView {
.gap_1()
.justify_between()
.flex_wrap()
- .when(!empty_content, |this| {
- this.border_t_1()
- .border_color(self.tool_card_border_color(cx))
- })
+ .border_t_1()
+ .border_color(self.tool_card_border_color(cx))
.child(
div()
.min_w(rems_from_px(145.))
@@ -2166,6 +2360,8 @@ impl AcpThreadView {
v_flex()
.h_full()
+ .border_t_1()
+ .border_color(self.tool_card_border_color(cx))
.child(
if let Some(entry) = self.entry_view_state.read(cx).entry(entry_ix)
&& let Some(editor) = entry.editor_for_diff(diff)
@@ -2213,6 +2409,12 @@ impl AcpThreadView {
started_at.elapsed()
};
+ let header_id =
+ SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id()));
+ let header_group = SharedString::from(format!(
+ "terminal-tool-header-group-{}",
+ terminal.entity_id()
+ ));
let header_bg = cx
.theme()
.colors()
@@ -2228,10 +2430,7 @@ impl AcpThreadView {
let is_expanded = self.expanded_tool_calls.contains(&tool_call.id);
let header = h_flex()
- .id(SharedString::from(format!(
- "terminal-tool-header-{}",
- terminal.entity_id()
- )))
+ .id(header_id)
.flex_none()
.gap_1()
.justify_between()
@@ -2295,23 +2494,28 @@ impl AcpThreadView {
),
)
})
- .when(tool_failed || command_failed, |header| {
- header.child(
- div()
- .id(("terminal-tool-error-code-indicator", terminal.entity_id()))
- .child(
- Icon::new(IconName::Close)
- .size(IconSize::Small)
- .color(Color::Error),
- )
- .when_some(output.and_then(|o| o.exit_status), |this, status| {
- this.tooltip(Tooltip::text(format!(
- "Exited with code {}",
- status.code().unwrap_or(-1),
- )))
- }),
+ .child(
+ Disclosure::new(
+ SharedString::from(format!(
+ "terminal-tool-disclosure-{}",
+ terminal.entity_id()
+ )),
+ is_expanded,
)
- })
+ .opened_icon(IconName::ChevronUp)
+ .closed_icon(IconName::ChevronDown)
+ .visible_on_hover(&header_group)
+ .on_click(cx.listener({
+ let id = tool_call.id.clone();
+ move |this, _event, _window, _cx| {
+ if is_expanded {
+ this.expanded_tool_calls.remove(&id);
+ } else {
+ this.expanded_tool_calls.insert(id.clone());
+ }
+ }
+ })),
+ )
.when(truncated_output, |header| {
let tooltip = if let Some(output) = output {
if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES {
@@ -2354,26 +2558,23 @@ impl AcpThreadView {
.size(LabelSize::XSmall),
)
})
- .child(
- Disclosure::new(
- SharedString::from(format!(
- "terminal-tool-disclosure-{}",
- terminal.entity_id()
- )),
- is_expanded,
+ .when(tool_failed || command_failed, |header| {
+ header.child(
+ div()
+ .id(("terminal-tool-error-code-indicator", terminal.entity_id()))
+ .child(
+ Icon::new(IconName::Close)
+ .size(IconSize::Small)
+ .color(Color::Error),
+ )
+ .when_some(output.and_then(|o| o.exit_status), |this, status| {
+ this.tooltip(Tooltip::text(format!(
+ "Exited with code {}",
+ status.code().unwrap_or(-1),
+ )))
+ }),
)
- .opened_icon(IconName::ChevronUp)
- .closed_icon(IconName::ChevronDown)
- .on_click(cx.listener({
- let id = tool_call.id.clone();
- move |this, _event, _window, _cx| {
- if is_expanded {
- this.expanded_tool_calls.remove(&id);
- } else {
- this.expanded_tool_calls.insert(id.clone());
- }
- }})),
- );
+ });
let terminal_view = self
.entry_view_state
@@ -3,20 +3,23 @@ mod configure_context_server_modal;
mod manage_profiles_modal;
mod tool_picker;
-use std::{sync::Arc, time::Duration};
+use std::{ops::Range, sync::Arc, time::Duration};
-use agent_servers::{AgentServerCommand, AllAgentServersSettings, Gemini};
+use agent_servers::{AgentServerCommand, AllAgentServersSettings, CustomAgentServerSettings};
use agent_settings::AgentSettings;
+use anyhow::Result;
use assistant_tool::{ToolSource, ToolWorkingSet};
use cloud_llm_client::Plan;
use collections::HashMap;
use context_server::ContextServerId;
+use editor::{Editor, SelectionEffects, scroll::Autoscroll};
use extension::ExtensionManifest;
use extension_host::ExtensionStore;
use fs::Fs;
use gpui::{
- Action, Animation, AnimationExt as _, AnyView, App, Corner, Entity, EventEmitter, FocusHandle,
- Focusable, Hsla, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage,
+ Action, Animation, AnimationExt as _, AnyView, App, AsyncWindowContext, Corner, Entity,
+ EventEmitter, FocusHandle, Focusable, Hsla, ScrollHandle, Subscription, Task, Transformation,
+ WeakEntity, percentage,
};
use language::LanguageRegistry;
use language_model::{
@@ -24,7 +27,6 @@ use language_model::{
};
use notifications::status_toast::{StatusToast, ToastIcon};
use project::{
- Project,
context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
project_settings::{ContextServerSettings, ProjectSettings},
};
@@ -34,7 +36,7 @@ use ui::{
Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*,
};
use util::ResultExt as _;
-use workspace::Workspace;
+use workspace::{Workspace, create_and_open_local_file};
use zed_actions::ExtensionCategoryFilter;
pub(crate) use configure_context_server_modal::ConfigureContextServerModal;
@@ -49,7 +51,6 @@ pub struct AgentConfiguration {
fs: Arc<dyn Fs>,
language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>,
- project: WeakEntity<Project>,
focus_handle: FocusHandle,
configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>,
context_server_store: Entity<ContextServerStore>,
@@ -59,7 +60,6 @@ pub struct AgentConfiguration {
_registry_subscription: Subscription,
scroll_handle: ScrollHandle,
scrollbar_state: ScrollbarState,
- gemini_is_installed: bool,
_check_for_gemini: Task<()>,
}
@@ -70,7 +70,6 @@ impl AgentConfiguration {
tools: Entity<ToolWorkingSet>,
language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>,
- project: WeakEntity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -95,11 +94,6 @@ impl AgentConfiguration {
cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify())
.detach();
- cx.observe_global_in::<SettingsStore>(window, |this, _, cx| {
- this.check_for_gemini(cx);
- cx.notify();
- })
- .detach();
let scroll_handle = ScrollHandle::new();
let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
@@ -108,7 +102,6 @@ impl AgentConfiguration {
fs,
language_registry,
workspace,
- project,
focus_handle,
configuration_views_by_provider: HashMap::default(),
context_server_store,
@@ -118,11 +111,9 @@ impl AgentConfiguration {
_registry_subscription: registry_subscription,
scroll_handle,
scrollbar_state,
- gemini_is_installed: false,
_check_for_gemini: Task::ready(()),
};
this.build_provider_configuration_views(window, cx);
- this.check_for_gemini(cx);
this
}
@@ -152,34 +143,6 @@ impl AgentConfiguration {
self.configuration_views_by_provider
.insert(provider.id(), configuration_view);
}
-
- fn check_for_gemini(&mut self, cx: &mut Context<Self>) {
- let project = self.project.clone();
- let settings = AllAgentServersSettings::get_global(cx).clone();
- self._check_for_gemini = cx.spawn({
- async move |this, cx| {
- let Some(project) = project.upgrade() else {
- return;
- };
- let gemini_is_installed = AgentServerCommand::resolve(
- Gemini::binary_name(),
- &[],
- // TODO expose fallback path from the Gemini/CC types so we don't have to hardcode it again here
- None,
- settings.gemini,
- &project,
- cx,
- )
- .await
- .is_some();
- this.update(cx, |this, cx| {
- this.gemini_is_installed = gemini_is_installed;
- cx.notify();
- })
- .ok();
- }
- });
- }
}
impl Focusable for AgentConfiguration {
@@ -1038,9 +1001,8 @@ impl AgentConfiguration {
name.clone(),
ExternalAgent::Custom {
name: name.clone(),
- settings: settings.clone(),
+ command: settings.command.clone(),
},
- None,
cx,
)
.into_any_element()
@@ -1058,10 +1020,39 @@ impl AgentConfiguration {
.child(
v_flex()
.gap_0p5()
- .child(Headline::new("External Agents"))
+ .child(
+ h_flex()
+ .w_full()
+ .gap_2()
+ .justify_between()
+ .child(Headline::new("External Agents"))
+ .child(
+ Button::new("add-agent", "Add Agent")
+ .icon_position(IconPosition::Start)
+ .icon(IconName::Plus)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .label_size(LabelSize::Small)
+ .on_click(
+ move |_, window, cx| {
+ if let Some(workspace) = window.root().flatten() {
+ let workspace = workspace.downgrade();
+ window
+ .spawn(cx, async |cx| {
+ open_new_agent_servers_entry_in_settings_editor(
+ workspace,
+ cx,
+ ).await
+ })
+ .detach_and_log_err(cx);
+ }
+ }
+ ),
+ )
+ )
.child(
Label::new(
- "Use the full power of Zed's UI with your favorite agent, connected via the Agent Client Protocol.",
+ "Bring the agent of your choice to Zed via our new Agent Client Protocol.",
)
.color(Color::Muted),
),
@@ -1070,7 +1061,6 @@ impl AgentConfiguration {
IconName::AiGemini,
"Gemini CLI",
ExternalAgent::Gemini,
- (!self.gemini_is_installed).then_some(Gemini::install_command().into()),
cx,
))
// TODO add CC
@@ -1083,7 +1073,6 @@ impl AgentConfiguration {
icon: IconName,
name: impl Into<SharedString>,
agent: ExternalAgent,
- install_command: Option<SharedString>,
cx: &mut Context<Self>,
) -> impl IntoElement {
let name = name.into();
@@ -1103,88 +1092,28 @@ impl AgentConfiguration {
.child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
.child(Label::new(name.clone())),
)
- .map(|this| {
- if let Some(install_command) = install_command {
- this.child(
- Button::new(
- SharedString::from(format!("install_external_agent-{name}")),
- "Install Agent",
- )
- .label_size(LabelSize::Small)
- .icon(IconName::Plus)
- .icon_position(IconPosition::Start)
- .icon_size(IconSize::XSmall)
- .icon_color(Color::Muted)
- .tooltip(Tooltip::text(install_command.clone()))
- .on_click(cx.listener(
- move |this, _, window, cx| {
- let Some(project) = this.project.upgrade() else {
- return;
- };
- let Some(workspace) = this.workspace.upgrade() else {
- return;
- };
- let cwd = project.read(cx).first_project_directory(cx);
- let shell =
- project.read(cx).terminal_settings(&cwd, cx).shell.clone();
- let spawn_in_terminal = task::SpawnInTerminal {
- id: task::TaskId(install_command.to_string()),
- full_label: install_command.to_string(),
- label: install_command.to_string(),
- command: Some(install_command.to_string()),
- args: Vec::new(),
- command_label: install_command.to_string(),
- cwd,
- env: Default::default(),
- use_new_terminal: true,
- allow_concurrent_runs: true,
- reveal: Default::default(),
- reveal_target: Default::default(),
- hide: Default::default(),
- shell,
- show_summary: true,
- show_command: true,
- show_rerun: false,
- };
- let task = workspace.update(cx, |workspace, cx| {
- workspace.spawn_in_terminal(spawn_in_terminal, window, cx)
- });
- cx.spawn(async move |this, cx| {
- task.await;
- this.update(cx, |this, cx| {
- this.check_for_gemini(cx);
- })
- .ok();
- })
- .detach();
- },
- )),
- )
- } else {
- this.child(
- h_flex().gap_1().child(
- Button::new(
- SharedString::from(format!("start_acp_thread-{name}")),
- "Start New Thread",
- )
- .label_size(LabelSize::Small)
- .icon(IconName::Thread)
- .icon_position(IconPosition::Start)
- .icon_size(IconSize::XSmall)
- .icon_color(Color::Muted)
- .on_click(move |_, window, cx| {
- window.dispatch_action(
- NewExternalAgentThread {
- agent: Some(agent.clone()),
- }
- .boxed_clone(),
- cx,
- );
- }),
- ),
+ .child(
+ h_flex().gap_1().child(
+ Button::new(
+ SharedString::from(format!("start_acp_thread-{name}")),
+ "Start New Thread",
)
- }
- })
+ .label_size(LabelSize::Small)
+ .icon(IconName::Thread)
+ .icon_position(IconPosition::Start)
+ .icon_size(IconSize::XSmall)
+ .icon_color(Color::Muted)
+ .on_click(move |_, window, cx| {
+ window.dispatch_action(
+ NewExternalAgentThread {
+ agent: Some(agent.clone()),
+ }
+ .boxed_clone(),
+ cx,
+ );
+ }),
+ ),
+ )
}
}
@@ -1324,3 +1253,109 @@ fn show_unable_to_uninstall_extension_with_context_server(
workspace.toggle_status_toast(status_toast, cx);
}
+
+async fn open_new_agent_servers_entry_in_settings_editor(
+ workspace: WeakEntity<Workspace>,
+ cx: &mut AsyncWindowContext,
+) -> Result<()> {
+ let settings_editor = workspace
+ .update_in(cx, |_, window, cx| {
+ create_and_open_local_file(paths::settings_file(), window, cx, || {
+ settings::initial_user_settings_content().as_ref().into()
+ })
+ })?
+ .await?
+ .downcast::<Editor>()
+ .unwrap();
+
+ settings_editor
+ .downgrade()
+ .update_in(cx, |item, window, cx| {
+ let text = item.buffer().read(cx).snapshot(cx).text();
+
+ let settings = cx.global::<SettingsStore>();
+
+ let mut unique_server_name = None;
+ let edits = settings.edits_for_update::<AllAgentServersSettings>(&text, |file| {
+ let server_name: Option<SharedString> = (0..u8::MAX)
+ .map(|i| {
+ if i == 0 {
+ "your_agent".into()
+ } else {
+ format!("your_agent_{}", i).into()
+ }
+ })
+ .find(|name| !file.custom.contains_key(name));
+ if let Some(server_name) = server_name {
+ unique_server_name = Some(server_name.clone());
+ file.custom.insert(
+ server_name,
+ CustomAgentServerSettings {
+ command: AgentServerCommand {
+ path: "path_to_executable".into(),
+ args: vec![],
+ env: Some(HashMap::default()),
+ },
+ },
+ );
+ }
+ });
+
+ if edits.is_empty() {
+ return;
+ }
+
+ let ranges = edits
+ .iter()
+ .map(|(range, _)| range.clone())
+ .collect::<Vec<_>>();
+
+ item.edit(edits, cx);
+ if let Some((unique_server_name, buffer)) =
+ unique_server_name.zip(item.buffer().read(cx).as_singleton())
+ {
+ let snapshot = buffer.read(cx).snapshot();
+ if let Some(range) =
+ find_text_in_buffer(&unique_server_name, ranges[0].start, &snapshot)
+ {
+ item.change_selections(
+ SelectionEffects::scroll(Autoscroll::newest()),
+ window,
+ cx,
+ |selections| {
+ selections.select_ranges(vec![range]);
+ },
+ );
+ }
+ }
+ })
+}
+
+fn find_text_in_buffer(
+ text: &str,
+ start: usize,
+ snapshot: &language::BufferSnapshot,
+) -> Option<Range<usize>> {
+ let chars = text.chars().collect::<Vec<char>>();
+
+ let mut offset = start;
+ let mut char_offset = 0;
+ for c in snapshot.chars_at(start) {
+ if char_offset >= chars.len() {
+ break;
+ }
+ offset += 1;
+
+ if c == chars[char_offset] {
+ char_offset += 1;
+ } else {
+ char_offset = 0;
+ }
+ }
+
+ if char_offset == chars.len() {
+ Some(offset.saturating_sub(chars.len())..offset)
+ } else {
+ None
+ }
+}
@@ -5,7 +5,7 @@ use std::sync::Arc;
use std::time::Duration;
use acp_thread::AcpThread;
-use agent_servers::AgentServerSettings;
+use agent_servers::AgentServerCommand;
use agent2::{DbThreadMetadata, HistoryEntry};
use db::kvp::{Dismissable, KEY_VALUE_STORE};
use serde::{Deserialize, Serialize};
@@ -14,6 +14,7 @@ use zed_actions::agent::ReauthenticateAgent;
use crate::acp::{AcpThreadHistory, ThreadHistoryEvent};
use crate::agent_diff::AgentDiffThread;
+use crate::ui::AcpOnboardingModal;
use crate::{
AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode,
DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread,
@@ -28,7 +29,6 @@ use crate::{
slash_command::SlashCommandCompletionProvider,
text_thread_editor::{
AgentPanelDelegate, TextThreadEditor, humanize_token_count, make_lsp_adapter_delegate,
- render_remaining_tokens,
},
thread_history::{HistoryEntryElement, ThreadHistory},
ui::{AgentOnboardingModal, EndTrialUpsell},
@@ -77,7 +77,10 @@ use workspace::{
};
use zed_actions::{
DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
- agent::{OpenOnboardingModal, OpenSettings, ResetOnboarding, ToggleModelSelector},
+ agent::{
+ OpenAcpOnboardingModal, OpenOnboardingModal, OpenSettings, ResetOnboarding,
+ ToggleModelSelector,
+ },
assistant::{OpenRulesLibrary, ToggleFocus},
};
@@ -201,6 +204,9 @@ pub fn init(cx: &mut App) {
.register_action(|workspace, _: &OpenOnboardingModal, window, cx| {
AgentOnboardingModal::toggle(workspace, window, cx)
})
+ .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
+ AcpOnboardingModal::toggle(workspace, window, cx)
+ })
.register_action(|_workspace, _: &ResetOnboarding, window, cx| {
window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
window.refresh();
@@ -253,7 +259,7 @@ pub enum AgentType {
NativeAgent,
Custom {
name: SharedString,
- settings: AgentServerSettings,
+ command: AgentServerCommand,
},
}
@@ -591,17 +597,6 @@ impl AgentPanel {
None
};
- // Wait for the Gemini/Native feature flag to be available.
- let client = workspace.read_with(cx, |workspace, _| workspace.client().clone())?;
- if !client.status().borrow().is_signed_out() {
- cx.update(|_, cx| {
- cx.wait_for_flag_or_timeout::<feature_flags::GeminiAndNativeFeatureFlag>(
- Duration::from_secs(2),
- )
- })?
- .await;
- }
-
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| {
Self::new(
@@ -622,6 +617,10 @@ impl AgentPanel {
}
cx.notify();
});
+ } else {
+ panel.update(cx, |panel, cx| {
+ panel.new_agent_thread(AgentType::NativeAgent, window, cx);
+ });
}
panel
})?;
@@ -1480,7 +1479,6 @@ impl AgentPanel {
tools,
self.language_registry.clone(),
self.workspace.clone(),
- self.project.downgrade(),
window,
cx,
)
@@ -1852,19 +1850,6 @@ impl AgentPanel {
menu
}
- pub fn set_selected_agent(
- &mut self,
- agent: AgentType,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- if self.selected_agent != agent {
- self.selected_agent = agent.clone();
- self.serialize(cx);
- }
- self.new_agent_thread(agent, window, cx);
- }
-
pub fn selected_agent(&self) -> AgentType {
self.selected_agent.clone()
}
@@ -1875,6 +1860,11 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
+ if self.selected_agent != agent {
+ self.selected_agent = agent.clone();
+ self.serialize(cx);
+ }
+
match agent {
AgentType::Zed => {
window.dispatch_action(
@@ -1905,8 +1895,8 @@ impl AgentPanel {
window,
cx,
),
- AgentType::Custom { name, settings } => self.external_thread(
- Some(crate::ExternalAgent::Custom { name, settings }),
+ AgentType::Custom { name, command } => self.external_thread(
+ Some(crate::ExternalAgent::Custom { name, command }),
None,
None,
window,
@@ -2124,7 +2114,7 @@ impl AgentPanel {
.child(title_editor)
.into_any_element()
} else {
- Label::new(thread_view.read(cx).title())
+ Label::new(thread_view.read(cx).title(cx))
.color(Color::Muted)
.truncate()
.into_any_element()
@@ -2555,7 +2545,7 @@ impl AgentPanel {
workspace.panel::<AgentPanel>(cx)
{
panel.update(cx, |panel, cx| {
- panel.set_selected_agent(
+ panel.new_agent_thread(
AgentType::NativeAgent,
window,
cx,
@@ -2581,7 +2571,7 @@ impl AgentPanel {
workspace.panel::<AgentPanel>(cx)
{
panel.update(cx, |panel, cx| {
- panel.set_selected_agent(
+ panel.new_agent_thread(
AgentType::TextThread,
window,
cx,
@@ -2609,7 +2599,7 @@ impl AgentPanel {
workspace.panel::<AgentPanel>(cx)
{
panel.update(cx, |panel, cx| {
- panel.set_selected_agent(
+ panel.new_agent_thread(
AgentType::Gemini,
window,
cx,
@@ -2636,7 +2626,7 @@ impl AgentPanel {
workspace.panel::<AgentPanel>(cx)
{
panel.update(cx, |panel, cx| {
- panel.set_selected_agent(
+ panel.new_agent_thread(
AgentType::ClaudeCode,
window,
cx,
@@ -2669,13 +2659,13 @@ impl AgentPanel {
workspace.panel::<AgentPanel>(cx)
{
panel.update(cx, |panel, cx| {
- panel.set_selected_agent(
+ panel.new_agent_thread(
AgentType::Custom {
name: agent_name
.clone(),
- settings:
- agent_settings
- .clone(),
+ command: agent_settings
+ .command
+ .clone(),
},
window,
cx,
@@ -2693,9 +2683,9 @@ impl AgentPanel {
})
.when(cx.has_flag::<GeminiAndNativeFeatureFlag>(), |menu| {
menu.separator().link(
- "Add Your Own Agent",
+ "Add Other Agents",
OpenBrowser {
- url: "https://agentclientprotocol.com/".into(),
+ url: zed_urls::external_agents_docs(cx),
}
.boxed_clone(),
)
@@ -2883,12 +2873,8 @@ impl AgentPanel {
Some(token_count)
}
- ActiveView::TextThread { context_editor, .. } => {
- let element = render_remaining_tokens(context_editor, cx)?;
-
- Some(element.into_any_element())
- }
ActiveView::ExternalAgentThread { .. }
+ | ActiveView::TextThread { .. }
| ActiveView::History
| ActiveView::Configuration => None,
}
@@ -28,7 +28,7 @@ use std::rc::Rc;
use std::sync::Arc;
use agent::{Thread, ThreadId};
-use agent_servers::AgentServerSettings;
+use agent_servers::AgentServerCommand;
use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection};
use assistant_slash_command::SlashCommandRegistry;
use client::Client;
@@ -170,7 +170,7 @@ enum ExternalAgent {
NativeAgent,
Custom {
name: SharedString,
- settings: AgentServerSettings,
+ command: AgentServerCommand,
},
}
@@ -193,9 +193,9 @@ impl ExternalAgent {
Self::Gemini => Rc::new(agent_servers::Gemini),
Self::ClaudeCode => Rc::new(agent_servers::ClaudeCode),
Self::NativeAgent => Rc::new(agent2::NativeAgentServer::new(fs, history)),
- Self::Custom { name, settings } => Rc::new(agent_servers::CustomAgentServer::new(
+ Self::Custom { name, command } => Rc::new(agent_servers::CustomAgentServer::new(
name.clone(),
- settings,
+ command.clone(),
)),
}
}
@@ -334,7 +334,7 @@ impl<T: 'static> PromptEditor<T> {
EditorEvent::Edited { .. } => {
if let Some(workspace) = window.root::<Workspace>().flatten() {
workspace.update(cx, |workspace, cx| {
- let is_via_ssh = workspace.project().read(cx).is_via_ssh();
+ let is_via_ssh = workspace.project().read(cx).is_via_remote_server();
workspace
.client()
@@ -6,7 +6,8 @@ use feature_flags::ZedProFeatureFlag;
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task};
use language_model::{
- ConfiguredModel, LanguageModel, LanguageModelProviderId, LanguageModelRegistry,
+ AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId,
+ LanguageModelRegistry,
};
use ordered_float::OrderedFloat;
use picker::{Picker, PickerDelegate};
@@ -76,6 +77,7 @@ pub struct LanguageModelPickerDelegate {
all_models: Arc<GroupedModels>,
filtered_entries: Vec<LanguageModelPickerEntry>,
selected_index: usize,
+ _authenticate_all_providers_task: Task<()>,
_subscriptions: Vec<Subscription>,
}
@@ -96,6 +98,7 @@ impl LanguageModelPickerDelegate {
selected_index: Self::get_active_model_index(&entries, get_active_model(cx)),
filtered_entries: entries,
get_active_model: Arc::new(get_active_model),
+ _authenticate_all_providers_task: Self::authenticate_all_providers(cx),
_subscriptions: vec![cx.subscribe_in(
&LanguageModelRegistry::global(cx),
window,
@@ -139,6 +142,56 @@ impl LanguageModelPickerDelegate {
.unwrap_or(0)
}
+ /// Authenticates all providers in the [`LanguageModelRegistry`].
+ ///
+ /// We do this so that we can populate the language selector with all of the
+ /// models from the configured providers.
+ fn authenticate_all_providers(cx: &mut App) -> Task<()> {
+ let authenticate_all_providers = LanguageModelRegistry::global(cx)
+ .read(cx)
+ .providers()
+ .iter()
+ .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
+ .collect::<Vec<_>>();
+
+ cx.spawn(async move |_cx| {
+ for (provider_id, provider_name, authenticate_task) in authenticate_all_providers {
+ if let Err(err) = authenticate_task.await {
+ if matches!(err, AuthenticateError::CredentialsNotFound) {
+ // Since we're authenticating these providers in the
+ // background for the purposes of populating the
+ // language selector, we don't care about providers
+ // where the credentials are not found.
+ } else {
+ // Some providers have noisy failure states that we
+ // don't want to spam the logs with every time the
+ // language model selector is initialized.
+ //
+ // Ideally these should have more clear failure modes
+ // that we know are safe to ignore here, like what we do
+ // with `CredentialsNotFound` above.
+ match provider_id.0.as_ref() {
+ "lmstudio" | "ollama" => {
+ // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated".
+ //
+ // These fail noisily, so we don't log them.
+ }
+ "copilot_chat" => {
+ // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors.
+ }
+ _ => {
+ log::error!(
+ "Failed to authenticate provider: {}: {err}",
+ provider_name.0
+ );
+ }
+ }
+ }
+ }
+ }
+ })
+ }
+
pub fn active_model(&self, cx: &App) -> Option<ConfiguredModel> {
(self.get_active_model)(cx)
}
@@ -1857,6 +1857,53 @@ impl TextThreadEditor {
.update(cx, |context, cx| context.summarize(true, cx));
}
+ fn render_remaining_tokens(&self, cx: &App) -> Option<impl IntoElement + use<>> {
+ let (token_count_color, token_count, max_token_count, tooltip) =
+ match token_state(&self.context, cx)? {
+ TokenState::NoTokensLeft {
+ max_token_count,
+ token_count,
+ } => (
+ Color::Error,
+ token_count,
+ max_token_count,
+ Some("Token Limit Reached"),
+ ),
+ TokenState::HasMoreTokens {
+ max_token_count,
+ token_count,
+ over_warn_threshold,
+ } => {
+ let (color, tooltip) = if over_warn_threshold {
+ (Color::Warning, Some("Token Limit is Close to Exhaustion"))
+ } else {
+ (Color::Muted, None)
+ };
+ (color, token_count, max_token_count, tooltip)
+ }
+ };
+
+ Some(
+ h_flex()
+ .id("token-count")
+ .gap_0p5()
+ .child(
+ Label::new(humanize_token_count(token_count))
+ .size(LabelSize::Small)
+ .color(token_count_color),
+ )
+ .child(Label::new("/").size(LabelSize::Small).color(Color::Muted))
+ .child(
+ Label::new(humanize_token_count(max_token_count))
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .when_some(tooltip, |element, tooltip| {
+ element.tooltip(Tooltip::text(tooltip))
+ }),
+ )
+ }
+
fn render_send_button(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let focus_handle = self.focus_handle(cx);
@@ -2420,9 +2467,14 @@ impl Render for TextThreadEditor {
)
.child(
h_flex()
- .gap_1()
- .child(self.render_language_model_selector(window, cx))
- .child(self.render_send_button(window, cx)),
+ .gap_2p5()
+ .children(self.render_remaining_tokens(cx))
+ .child(
+ h_flex()
+ .gap_1()
+ .child(self.render_language_model_selector(window, cx))
+ .child(self.render_send_button(window, cx)),
+ ),
),
)
}
@@ -2710,58 +2762,6 @@ impl FollowableItem for TextThreadEditor {
}
}
-pub fn render_remaining_tokens(
- context_editor: &Entity<TextThreadEditor>,
- cx: &App,
-) -> Option<impl IntoElement + use<>> {
- let context = &context_editor.read(cx).context;
-
- let (token_count_color, token_count, max_token_count, tooltip) = match token_state(context, cx)?
- {
- TokenState::NoTokensLeft {
- max_token_count,
- token_count,
- } => (
- Color::Error,
- token_count,
- max_token_count,
- Some("Token Limit Reached"),
- ),
- TokenState::HasMoreTokens {
- max_token_count,
- token_count,
- over_warn_threshold,
- } => {
- let (color, tooltip) = if over_warn_threshold {
- (Color::Warning, Some("Token Limit is Close to Exhaustion"))
- } else {
- (Color::Muted, None)
- };
- (color, token_count, max_token_count, tooltip)
- }
- };
-
- Some(
- h_flex()
- .id("token-count")
- .gap_0p5()
- .child(
- Label::new(humanize_token_count(token_count))
- .size(LabelSize::Small)
- .color(token_count_color),
- )
- .child(Label::new("/").size(LabelSize::Small).color(Color::Muted))
- .child(
- Label::new(humanize_token_count(max_token_count))
- .size(LabelSize::Small)
- .color(Color::Muted),
- )
- .when_some(tooltip, |element, tooltip| {
- element.tooltip(Tooltip::text(tooltip))
- }),
- )
-}
-
enum PendingSlashCommand {}
fn invoked_slash_command_fold_placeholder(
@@ -1,3 +1,4 @@
+mod acp_onboarding_modal;
mod agent_notification;
mod burn_mode_tooltip;
mod context_pill;
@@ -6,6 +7,7 @@ mod onboarding_modal;
pub mod preview;
mod unavailable_editing_tooltip;
+pub use acp_onboarding_modal::*;
pub use agent_notification::*;
pub use burn_mode_tooltip::*;
pub use context_pill::*;
@@ -0,0 +1,254 @@
+use client::zed_urls;
+use gpui::{
+ ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render,
+ linear_color_stop, linear_gradient,
+};
+use ui::{TintColor, Vector, VectorName, prelude::*};
+use workspace::{ModalView, Workspace};
+
+use crate::agent_panel::{AgentPanel, AgentType};
+
+macro_rules! acp_onboarding_event {
+ ($name:expr) => {
+ telemetry::event!($name, source = "ACP Onboarding");
+ };
+ ($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => {
+ telemetry::event!($name, source = "ACP Onboarding", $($key $(= $value)?),+);
+ };
+}
+
+pub struct AcpOnboardingModal {
+ focus_handle: FocusHandle,
+ workspace: Entity<Workspace>,
+}
+
+impl AcpOnboardingModal {
+ pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
+ let workspace_entity = cx.entity();
+ workspace.toggle_modal(window, cx, |_window, cx| Self {
+ workspace: workspace_entity,
+ focus_handle: cx.focus_handle(),
+ });
+ }
+
+ fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
+ self.workspace.update(cx, |workspace, cx| {
+ workspace.focus_panel::<AgentPanel>(window, cx);
+
+ if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+ panel.update(cx, |panel, cx| {
+ panel.new_agent_thread(AgentType::Gemini, window, cx);
+ });
+ }
+ });
+
+ cx.emit(DismissEvent);
+
+ acp_onboarding_event!("Open Panel Clicked");
+ }
+
+ fn view_docs(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
+ cx.open_url(&zed_urls::external_agents_docs(cx));
+ cx.notify();
+
+ acp_onboarding_event!("Documentation Link Clicked");
+ }
+
+ fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
+ cx.emit(DismissEvent);
+ }
+}
+
+impl EventEmitter<DismissEvent> for AcpOnboardingModal {}
+
+impl Focusable for AcpOnboardingModal {
+ fn focus_handle(&self, _cx: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl ModalView for AcpOnboardingModal {}
+
+impl Render for AcpOnboardingModal {
+ fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let illustration_element = |label: bool, opacity: f32| {
+ h_flex()
+ .px_1()
+ .py_0p5()
+ .gap_1()
+ .rounded_sm()
+ .bg(cx.theme().colors().element_active.opacity(0.05))
+ .border_1()
+ .border_color(cx.theme().colors().border)
+ .border_dashed()
+ .child(
+ Icon::new(IconName::Stop)
+ .size(IconSize::Small)
+ .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.15))),
+ )
+ .map(|this| {
+ if label {
+ this.child(
+ Label::new("Your Agent Here")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ } else {
+ this.child(
+ div().w_16().h_1().rounded_full().bg(cx
+ .theme()
+ .colors()
+ .element_active
+ .opacity(0.6)),
+ )
+ }
+ })
+ .opacity(opacity)
+ };
+
+ let illustration = h_flex()
+ .relative()
+ .h(rems_from_px(126.))
+ .bg(cx.theme().colors().editor_background)
+ .border_b_1()
+ .border_color(cx.theme().colors().border_variant)
+ .justify_center()
+ .gap_8()
+ .rounded_t_md()
+ .overflow_hidden()
+ .child(
+ div().absolute().inset_0().w(px(515.)).h(px(126.)).child(
+ Vector::new(VectorName::AcpGrid, rems_from_px(515.), rems_from_px(126.))
+ .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.02))),
+ ),
+ )
+ .child(div().absolute().inset_0().size_full().bg(linear_gradient(
+ 0.,
+ linear_color_stop(
+ cx.theme().colors().elevated_surface_background.opacity(0.1),
+ 0.9,
+ ),
+ linear_color_stop(
+ cx.theme().colors().elevated_surface_background.opacity(0.),
+ 0.,
+ ),
+ )))
+ .child(
+ div()
+ .absolute()
+ .inset_0()
+ .size_full()
+ .bg(gpui::black().opacity(0.15)),
+ )
+ .child(
+ h_flex()
+ .gap_4()
+ .child(
+ Vector::new(VectorName::AcpLogo, rems_from_px(106.), rems_from_px(40.))
+ .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
+ )
+ .child(
+ Vector::new(
+ VectorName::AcpLogoSerif,
+ rems_from_px(111.),
+ rems_from_px(41.),
+ )
+ .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
+ ),
+ )
+ .child(
+ v_flex()
+ .gap_1p5()
+ .child(illustration_element(false, 0.15))
+ .child(illustration_element(true, 0.3))
+ .child(
+ h_flex()
+ .pl_1()
+ .pr_2()
+ .py_0p5()
+ .gap_1()
+ .rounded_sm()
+ .bg(cx.theme().colors().element_active.opacity(0.2))
+ .border_1()
+ .border_color(cx.theme().colors().border)
+ .child(
+ Icon::new(IconName::AiGemini)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .child(Label::new("New Gemini CLI Thread").size(LabelSize::Small)),
+ )
+ .child(illustration_element(true, 0.3))
+ .child(illustration_element(false, 0.15)),
+ );
+
+ let heading = v_flex()
+ .w_full()
+ .gap_1()
+ .child(
+ Label::new("Now Available")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .child(Headline::new("Bring Your Own Agent to Zed").size(HeadlineSize::Large));
+
+ let copy = "Bring the agent of your choice to Zed via our new Agent Client Protocol (ACP), starting with Google's Gemini CLI integration.";
+
+ let open_panel_button = Button::new("open-panel", "Start with Gemini CLI")
+ .icon_size(IconSize::Indicator)
+ .style(ButtonStyle::Tinted(TintColor::Accent))
+ .full_width()
+ .on_click(cx.listener(Self::open_panel));
+
+ let docs_button = Button::new("add-other-agents", "Add Other Agents")
+ .icon(IconName::ArrowUpRight)
+ .icon_size(IconSize::Indicator)
+ .icon_color(Color::Muted)
+ .full_width()
+ .on_click(cx.listener(Self::view_docs));
+
+ let close_button = h_flex().absolute().top_2().right_2().child(
+ IconButton::new("cancel", IconName::Close).on_click(cx.listener(
+ |_, _: &ClickEvent, _window, cx| {
+ acp_onboarding_event!("Canceled", trigger = "X click");
+ cx.emit(DismissEvent);
+ },
+ )),
+ );
+
+ v_flex()
+ .id("acp-onboarding")
+ .key_context("AcpOnboardingModal")
+ .relative()
+ .w(rems(34.))
+ .h_full()
+ .elevation_3(cx)
+ .track_focus(&self.focus_handle(cx))
+ .overflow_hidden()
+ .on_action(cx.listener(Self::cancel))
+ .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| {
+ acp_onboarding_event!("Canceled", trigger = "Action");
+ cx.emit(DismissEvent);
+ }))
+ .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| {
+ this.focus_handle.focus(window);
+ }))
+ .child(illustration)
+ .child(
+ v_flex()
+ .p_4()
+ .gap_2()
+ .child(heading)
+ .child(Label::new(copy).color(Color::Muted))
+ .child(
+ v_flex()
+ .w_full()
+ .mt_2()
+ .gap_1()
+ .child(open_panel_button)
+ .child(docs_button),
+ ),
+ )
+ .child(close_button)
+ }
+}
@@ -62,6 +62,8 @@ impl AgentNotification {
app_id: Some(app_id.to_owned()),
window_min_size: None,
window_decorations: Some(WindowDecorations::Client),
+ tabbing_identifier: None,
+ ..Default::default()
}
}
}
@@ -373,7 +373,7 @@ pub async fn complete(
.uri(uri)
.header("Anthropic-Version", "2023-06-01")
.header("Anthropic-Beta", beta_headers)
- .header("X-Api-Key", api_key)
+ .header("X-Api-Key", api_key.trim())
.header("Content-Type", "application/json");
let serialized_request =
@@ -526,7 +526,7 @@ pub async fn stream_completion_with_rate_limit_info(
.uri(uri)
.header("Anthropic-Version", "2023-06-01")
.header("Anthropic-Beta", beta_headers)
- .header("X-Api-Key", api_key)
+ .header("X-Api-Key", api_key.trim())
.header("Content-Type", "application/json");
let serialized_request =
serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?;
@@ -492,7 +492,7 @@ mod custom_path_matcher {
pub fn new(globs: &[String]) -> Result<Self, globset::Error> {
let globs = globs
.iter()
- .map(|glob| Glob::new(&SanitizedPath::from(glob).to_glob_string()))
+ .map(|glob| Glob::new(&SanitizedPath::new(glob).to_glob_string()))
.collect::<Result<Vec<_>, _>>()?;
let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect();
let sources_with_trailing_slash = globs
@@ -15,7 +15,7 @@ use language::LineEnding;
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
-use project::{Project, terminals::TerminalKind};
+use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
@@ -213,17 +213,16 @@ impl Tool for TerminalTool {
async move |cx| {
let program = program.await;
let env = env.await;
-
project
.update(cx, |project, cx| {
- project.create_terminal(
- TerminalKind::Task(task::SpawnInTerminal {
+ project.create_terminal_task(
+ task::SpawnInTerminal {
command: Some(program),
args,
cwd,
env,
..Default::default()
- }),
+ },
cx,
)
})?
@@ -1161,7 +1161,7 @@ impl Room {
let request = self.client.request(proto::ShareProject {
room_id: self.id(),
worktrees: project.read(cx).worktree_metadata_protos(cx),
- is_ssh_project: project.read(cx).is_via_ssh(),
+ is_ssh_project: project.read(cx).is_via_remote_server(),
});
cx.spawn(async move |this, cx| {
@@ -287,6 +287,7 @@ pub enum Status {
},
ConnectionLost,
Reauthenticating,
+ Reauthenticated,
Reconnecting,
ReconnectionError {
next_reconnection: Instant,
@@ -298,6 +299,21 @@ impl Status {
matches!(self, Self::Connected { .. })
}
+ pub fn was_connected(&self) -> bool {
+ matches!(
+ self,
+ Self::ConnectionLost
+ | Self::Reauthenticating
+ | Self::Reauthenticated
+ | Self::Reconnecting
+ )
+ }
+
+ /// Returns whether the client is currently connected or was connected at some point.
+ pub fn is_or_was_connected(&self) -> bool {
+ self.is_connected() || self.was_connected()
+ }
+
pub fn is_signing_in(&self) -> bool {
matches!(
self,
@@ -857,11 +873,13 @@ impl Client {
try_provider: bool,
cx: &AsyncApp,
) -> Result<Credentials> {
- if self.status().borrow().is_signed_out() {
+ let is_reauthenticating = if self.status().borrow().is_signed_out() {
self.set_status(Status::Authenticating, cx);
+ false
} else {
self.set_status(Status::Reauthenticating, cx);
- }
+ true
+ };
let mut credentials = None;
@@ -919,7 +937,14 @@ impl Client {
self.cloud_client
.set_credentials(credentials.user_id as u32, credentials.access_token.clone());
self.state.write().credentials = Some(credentials.clone());
- self.set_status(Status::Authenticated, cx);
+ self.set_status(
+ if is_reauthenticating {
+ Status::Reauthenticated
+ } else {
+ Status::Authenticated
+ },
+ cx,
+ );
Ok(credentials)
}
@@ -1034,6 +1059,7 @@ impl Client {
| Status::Authenticating
| Status::AuthenticationError
| Status::Reauthenticating
+ | Status::Reauthenticated
| Status::ReconnectionError { .. } => false,
Status::Connected { .. } | Status::Connecting | Status::Reconnecting => {
return ConnectionResult::Result(Ok(()));
@@ -216,7 +216,9 @@ impl UserStore {
return Ok(());
};
match status {
- Status::Authenticated | Status::Connected { .. } => {
+ Status::Authenticated
+ | Status::Reauthenticated
+ | Status::Connected { .. } => {
if let Some(user_id) = client.user_id() {
let response = client
.cloud_client()
@@ -43,3 +43,11 @@ pub fn ai_privacy_and_security(cx: &App) -> String {
server_url = server_url(cx)
)
}
+
+/// Returns the URL to Zed AI's external agents documentation.
+pub fn external_agents_docs(cx: &App) -> String {
+ format!(
+ "{server_url}/docs/ai/external-agents",
+ server_url = server_url(cx)
+ )
+}
@@ -175,6 +175,7 @@ CREATE TABLE "language_servers" (
"project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
"name" VARCHAR NOT NULL,
"capabilities" TEXT NOT NULL,
+ "worktree_id" BIGINT,
PRIMARY KEY (project_id, id)
);
@@ -0,0 +1,2 @@
+ALTER TABLE language_servers
+ ADD COLUMN worktree_id BIGINT;
@@ -694,6 +694,7 @@ impl Database {
project_id: ActiveValue::set(project_id),
id: ActiveValue::set(server.id as i64),
name: ActiveValue::set(server.name.clone()),
+ worktree_id: ActiveValue::set(server.worktree_id.map(|id| id as i64)),
capabilities: ActiveValue::set(update.capabilities.clone()),
})
.on_conflict(
@@ -704,6 +705,7 @@ impl Database {
.update_columns([
language_server::Column::Name,
language_server::Column::Capabilities,
+ language_server::Column::WorktreeId,
])
.to_owned(),
)
@@ -1065,7 +1067,7 @@ impl Database {
server: proto::LanguageServer {
id: language_server.id as u64,
name: language_server.name,
- worktree_id: None,
+ worktree_id: language_server.worktree_id.map(|id| id as u64),
},
capabilities: language_server.capabilities,
})
@@ -809,7 +809,7 @@ impl Database {
server: proto::LanguageServer {
id: language_server.id as u64,
name: language_server.name,
- worktree_id: None,
+ worktree_id: language_server.worktree_id.map(|id| id as u64),
},
capabilities: language_server.capabilities,
})
@@ -10,6 +10,7 @@ pub struct Model {
pub id: i64,
pub name: String,
pub capabilities: String,
+ pub worktree_id: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -476,7 +476,9 @@ impl Server {
.add_request_handler(forward_mutating_project_request::<proto::GitChangeBranch>)
.add_request_handler(forward_mutating_project_request::<proto::CheckForPushedCommits>)
.add_message_handler(broadcast_project_message_from_host::<proto::AdvertiseContexts>)
- .add_message_handler(update_context);
+ .add_message_handler(update_context)
+ .add_request_handler(forward_mutating_project_request::<proto::ToggleLspLogs>)
+ .add_message_handler(broadcast_project_message_from_host::<proto::LanguageServerLog>);
Arc::new(server)
}
@@ -26,7 +26,7 @@ use project::{
debugger::session::ThreadId,
lsp_store::{FormatTrigger, LspFormatTarget},
};
-use remote::SshRemoteClient;
+use remote::RemoteClient;
use remote_server::{HeadlessAppState, HeadlessProject};
use rpc::proto;
use serde_json::json;
@@ -59,7 +59,7 @@ async fn test_sharing_an_ssh_remote_project(
.await;
// Set up project on remote FS
- let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
+ let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx);
let remote_fs = FakeFs::new(server_cx.executor());
remote_fs
.insert_tree(
@@ -101,7 +101,7 @@ async fn test_sharing_an_ssh_remote_project(
)
});
- let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await;
+ let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
let (project_a, worktree_id) = client_a
.build_ssh_project(path!("/code/project1"), client_ssh, cx_a)
.await;
@@ -235,7 +235,7 @@ async fn test_ssh_collaboration_git_branches(
.await;
// Set up project on remote FS
- let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
+ let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx);
let remote_fs = FakeFs::new(server_cx.executor());
remote_fs
.insert_tree("/project", serde_json::json!({ ".git":{} }))
@@ -268,7 +268,7 @@ async fn test_ssh_collaboration_git_branches(
)
});
- let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await;
+ let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
let (project_a, _) = client_a
.build_ssh_project("/project", client_ssh, cx_a)
.await;
@@ -420,7 +420,7 @@ async fn test_ssh_collaboration_formatting_with_prettier(
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
- let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
+ let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx);
let remote_fs = FakeFs::new(server_cx.executor());
let buffer_text = "let one = \"two\"";
let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX;
@@ -473,7 +473,7 @@ async fn test_ssh_collaboration_formatting_with_prettier(
)
});
- let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await;
+ let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
let (project_a, worktree_id) = client_a
.build_ssh_project(path!("/project"), client_ssh, cx_a)
.await;
@@ -602,7 +602,7 @@ async fn test_remote_server_debugger(
release_channel::init(SemanticVersion::default(), cx);
dap_adapters::init(cx);
});
- let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
+ let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx);
let remote_fs = FakeFs::new(server_cx.executor());
remote_fs
.insert_tree(
@@ -633,7 +633,7 @@ async fn test_remote_server_debugger(
)
});
- let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await;
+ let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
let mut server = TestServer::start(server_cx.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
cx_a.update(|cx| {
@@ -711,7 +711,7 @@ async fn test_slow_adapter_startup_retries(
release_channel::init(SemanticVersion::default(), cx);
dap_adapters::init(cx);
});
- let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
+ let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx);
let remote_fs = FakeFs::new(server_cx.executor());
remote_fs
.insert_tree(
@@ -742,7 +742,7 @@ async fn test_slow_adapter_startup_retries(
)
});
- let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await;
+ let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
let mut server = TestServer::start(server_cx.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
cx_a.update(|cx| {
@@ -26,7 +26,7 @@ use node_runtime::NodeRuntime;
use notifications::NotificationStore;
use parking_lot::Mutex;
use project::{Project, WorktreeId};
-use remote::SshRemoteClient;
+use remote::RemoteClient;
use rpc::{
RECEIVE_TIMEOUT,
proto::{self, ChannelRole},
@@ -765,11 +765,11 @@ impl TestClient {
pub async fn build_ssh_project(
&self,
root_path: impl AsRef<Path>,
- ssh: Entity<SshRemoteClient>,
+ ssh: Entity<RemoteClient>,
cx: &mut TestAppContext,
) -> (Entity<Project>, WorktreeId) {
let project = cx.update(|cx| {
- Project::ssh(
+ Project::remote(
ssh,
self.client().clone(),
self.app_state.node_runtime.clone(),
@@ -3047,7 +3047,7 @@ impl Render for CollabPanel {
.on_action(cx.listener(CollabPanel::move_channel_down))
.track_focus(&self.focus_handle)
.size_full()
- .child(if !self.client.status().borrow().is_connected() {
+ .child(if !self.client.status().borrow().is_or_was_connected() {
self.render_signed_out(cx)
} else {
self.render_signed_in(window, cx)
@@ -66,5 +66,7 @@ fn notification_window_options(
app_id: Some(app_id.to_owned()),
window_min_size: None,
window_decorations: Some(WindowDecorations::Client),
+ tabbing_identifier: None,
+ ..Default::default()
}
}
@@ -1,7 +1,10 @@
use anyhow::Result;
use db::{
- define_connection, query,
- sqlez::{bindable::Column, statement::Statement},
+ query,
+ sqlez::{
+ bindable::Column, domain::Domain, statement::Statement,
+ thread_safe_connection::ThreadSafeConnection,
+ },
sqlez_macros::sql,
};
use serde::{Deserialize, Serialize};
@@ -50,8 +53,11 @@ impl Column for SerializedCommandInvocation {
}
}
-define_connection!(pub static ref COMMAND_PALETTE_HISTORY: CommandPaletteDB<()> =
- &[sql!(
+pub struct CommandPaletteDB(ThreadSafeConnection);
+
+impl Domain for CommandPaletteDB {
+ const NAME: &str = stringify!(CommandPaletteDB);
+ const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE IF NOT EXISTS command_invocations(
id INTEGER PRIMARY KEY AUTOINCREMENT,
command_name TEXT NOT NULL,
@@ -59,7 +65,9 @@ define_connection!(pub static ref COMMAND_PALETTE_HISTORY: CommandPaletteDB<()>
last_invoked INTEGER DEFAULT (unixepoch()) NOT NULL
) STRICT;
)];
-);
+}
+
+db::static_connection!(COMMAND_PALETTE_HISTORY, CommandPaletteDB, []);
impl CommandPaletteDB {
pub async fn write_command_invocation(
@@ -62,12 +62,6 @@ impl CopilotChatConfiguration {
}
}
-// Copilot's base model; defined by Microsoft in premium requests table
-// This will be moved to the front of the Copilot model list, and will be used for
-// 'fast' requests (e.g. title generation)
-// https://docs.github.com/en/copilot/managing-copilot/monitoring-usage-and-entitlements/about-premium-requests
-const DEFAULT_MODEL_ID: &str = "gpt-4.1";
-
#[derive(Clone, Copy, Serialize, Deserialize, Debug, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
@@ -101,22 +95,39 @@ where
Ok(models)
}
-#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
+#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub struct Model {
+ billing: ModelBilling,
capabilities: ModelCapabilities,
id: String,
name: String,
policy: Option<ModelPolicy>,
vendor: ModelVendor,
+ is_chat_default: bool,
+ // The model with this value true is selected by VSCode copilot if a premium request limit is
+ // reached. Zed does not currently implement this behaviour
+ is_chat_fallback: bool,
model_picker_enabled: bool,
}
+#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
+struct ModelBilling {
+ is_premium: bool,
+ multiplier: f64,
+ // List of plans a model is restricted to
+ // Field is not present if a model is available for all plans
+ #[serde(default)]
+ restricted_to: Option<Vec<String>>,
+}
+
#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
struct ModelCapabilities {
family: String,
#[serde(default)]
limits: ModelLimits,
supports: ModelSupportedFeatures,
+ #[serde(rename = "type")]
+ model_type: String,
}
#[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)]
@@ -602,6 +613,7 @@ async fn get_models(
.into_iter()
.filter(|model| {
model.model_picker_enabled
+ && model.capabilities.model_type.as_str() == "chat"
&& model
.policy
.as_ref()
@@ -610,9 +622,7 @@ async fn get_models(
.dedup_by(|a, b| a.capabilities.family == b.capabilities.family)
.collect();
- if let Some(default_model_position) =
- models.iter().position(|model| model.id == DEFAULT_MODEL_ID)
- {
+ if let Some(default_model_position) = models.iter().position(|model| model.is_chat_default) {
let default_model = models.remove(default_model_position);
models.insert(0, default_model);
}
@@ -630,7 +640,9 @@ async fn request_models(
.uri(models_url.as_ref())
.header("Authorization", format!("Bearer {}", api_token))
.header("Content-Type", "application/json")
- .header("Copilot-Integration-Id", "vscode-chat");
+ .header("Copilot-Integration-Id", "vscode-chat")
+ .header("Editor-Version", "vscode/1.103.2")
+ .header("x-github-api-version", "2025-05-01");
let request = request_builder.body(AsyncBody::empty())?;
@@ -801,6 +813,10 @@ mod tests {
let json = r#"{
"data": [
{
+ "billing": {
+ "is_premium": false,
+ "multiplier": 0
+ },
"capabilities": {
"family": "gpt-4",
"limits": {
@@ -814,6 +830,8 @@ mod tests {
"type": "chat"
},
"id": "gpt-4",
+ "is_chat_default": false,
+ "is_chat_fallback": false,
"model_picker_enabled": false,
"name": "GPT 4",
"object": "model",
@@ -825,6 +843,16 @@ mod tests {
"some-unknown-field": 123
},
{
+ "billing": {
+ "is_premium": true,
+ "multiplier": 1,
+ "restricted_to": [
+ "pro",
+ "pro_plus",
+ "business",
+ "enterprise"
+ ]
+ },
"capabilities": {
"family": "claude-3.7-sonnet",
"limits": {
@@ -848,6 +876,8 @@ mod tests {
"type": "chat"
},
"id": "claude-3.7-sonnet",
+ "is_chat_default": false,
+ "is_chat_fallback": false,
"model_picker_enabled": true,
"name": "Claude 3.7 Sonnet",
"object": "model",
@@ -234,6 +234,7 @@ impl PythonDebugAdapter {
.await
.map_err(|e| format!("{e:#?}"))?
.success();
+
if !did_succeed {
return Err("Failed to create base virtual environment".into());
}
@@ -110,11 +110,14 @@ pub async fn open_test_db<M: Migrator>(db_name: &str) -> ThreadSafeConnection {
}
/// Implements a basic DB wrapper for a given domain
+///
+/// Arguments:
+/// - static variable name for connection
+/// - type of connection wrapper
+/// - dependencies, whose migrations should be run prior to this domain's migrations
#[macro_export]
-macro_rules! define_connection {
- (pub static ref $id:ident: $t:ident<()> = $migrations:expr; $($global:ident)?) => {
- pub struct $t($crate::sqlez::thread_safe_connection::ThreadSafeConnection);
-
+macro_rules! static_connection {
+ ($id:ident, $t:ident, [ $($d:ty),* ] $(, $global:ident)?) => {
impl ::std::ops::Deref for $t {
type Target = $crate::sqlez::thread_safe_connection::ThreadSafeConnection;
@@ -123,16 +126,6 @@ macro_rules! define_connection {
}
}
- impl $crate::sqlez::domain::Domain for $t {
- fn name() -> &'static str {
- stringify!($t)
- }
-
- fn migrations() -> &'static [&'static str] {
- $migrations
- }
- }
-
impl $t {
#[cfg(any(test, feature = "test-support"))]
pub async fn open_test_db(name: &'static str) -> Self {
@@ -142,44 +135,8 @@ macro_rules! define_connection {
#[cfg(any(test, feature = "test-support"))]
pub static $id: std::sync::LazyLock<$t> = std::sync::LazyLock::new(|| {
- $t($crate::smol::block_on($crate::open_test_db::<$t>(stringify!($id))))
- });
-
- #[cfg(not(any(test, feature = "test-support")))]
- pub static $id: std::sync::LazyLock<$t> = std::sync::LazyLock::new(|| {
- let db_dir = $crate::database_dir();
- let scope = if false $(|| stringify!($global) == "global")? {
- "global"
- } else {
- $crate::RELEASE_CHANNEL.dev_name()
- };
- $t($crate::smol::block_on($crate::open_db::<$t>(db_dir, scope)))
- });
- };
- (pub static ref $id:ident: $t:ident<$($d:ty),+> = $migrations:expr; $($global:ident)?) => {
- pub struct $t($crate::sqlez::thread_safe_connection::ThreadSafeConnection);
-
- impl ::std::ops::Deref for $t {
- type Target = $crate::sqlez::thread_safe_connection::ThreadSafeConnection;
-
- fn deref(&self) -> &Self::Target {
- &self.0
- }
- }
-
- impl $crate::sqlez::domain::Domain for $t {
- fn name() -> &'static str {
- stringify!($t)
- }
-
- fn migrations() -> &'static [&'static str] {
- $migrations
- }
- }
-
- #[cfg(any(test, feature = "test-support"))]
- pub static $id: std::sync::LazyLock<$t> = std::sync::LazyLock::new(|| {
- $t($crate::smol::block_on($crate::open_test_db::<($($d),+, $t)>(stringify!($id))))
+ #[allow(unused_parens)]
+ $t($crate::smol::block_on($crate::open_test_db::<($($d,)* $t)>(stringify!($id))))
});
#[cfg(not(any(test, feature = "test-support")))]
@@ -190,9 +147,10 @@ macro_rules! define_connection {
} else {
$crate::RELEASE_CHANNEL.dev_name()
};
- $t($crate::smol::block_on($crate::open_db::<($($d),+, $t)>(db_dir, scope)))
+ #[allow(unused_parens)]
+ $t($crate::smol::block_on($crate::open_db::<($($d,)* $t)>(db_dir, scope)))
});
- };
+ }
}
pub fn write_and_log<F>(cx: &App, db_write: impl FnOnce() -> F + Send + 'static)
@@ -219,17 +177,12 @@ mod tests {
enum BadDB {}
impl Domain for BadDB {
- fn name() -> &'static str {
- "db_tests"
- }
-
- fn migrations() -> &'static [&'static str] {
- &[
- sql!(CREATE TABLE test(value);),
- // failure because test already exists
- sql!(CREATE TABLE test(value);),
- ]
- }
+ const NAME: &str = "db_tests";
+ const MIGRATIONS: &[&str] = &[
+ sql!(CREATE TABLE test(value);),
+ // failure because test already exists
+ sql!(CREATE TABLE test(value);),
+ ];
}
let tempdir = tempfile::Builder::new()
@@ -251,25 +204,15 @@ mod tests {
enum CorruptedDB {}
impl Domain for CorruptedDB {
- fn name() -> &'static str {
- "db_tests"
- }
-
- fn migrations() -> &'static [&'static str] {
- &[sql!(CREATE TABLE test(value);)]
- }
+ const NAME: &str = "db_tests";
+ const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test(value);)];
}
enum GoodDB {}
impl Domain for GoodDB {
- fn name() -> &'static str {
- "db_tests" //Notice same name
- }
-
- fn migrations() -> &'static [&'static str] {
- &[sql!(CREATE TABLE test2(value);)] //But different migration
- }
+ const NAME: &str = "db_tests"; //Notice same name
+ const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test2(value);)];
}
let tempdir = tempfile::Builder::new()
@@ -305,25 +248,16 @@ mod tests {
enum CorruptedDB {}
impl Domain for CorruptedDB {
- fn name() -> &'static str {
- "db_tests"
- }
+ const NAME: &str = "db_tests";
- fn migrations() -> &'static [&'static str] {
- &[sql!(CREATE TABLE test(value);)]
- }
+ const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test(value);)];
}
enum GoodDB {}
impl Domain for GoodDB {
- fn name() -> &'static str {
- "db_tests" //Notice same name
- }
-
- fn migrations() -> &'static [&'static str] {
- &[sql!(CREATE TABLE test2(value);)] //But different migration
- }
+ const NAME: &str = "db_tests"; //Notice same name
+ const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test2(value);)]; // But different migration
}
let tempdir = tempfile::Builder::new()
@@ -2,16 +2,26 @@ use gpui::App;
use sqlez_macros::sql;
use util::ResultExt as _;
-use crate::{define_connection, query, write_and_log};
+use crate::{
+ query,
+ sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
+ write_and_log,
+};
-define_connection!(pub static ref KEY_VALUE_STORE: KeyValueStore<()> =
- &[sql!(
+pub struct KeyValueStore(crate::sqlez::thread_safe_connection::ThreadSafeConnection);
+
+impl Domain for KeyValueStore {
+ const NAME: &str = stringify!(KeyValueStore);
+
+ const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE IF NOT EXISTS kv_store(
key TEXT PRIMARY KEY,
value TEXT NOT NULL
) STRICT;
)];
-);
+}
+
+crate::static_connection!(KEY_VALUE_STORE, KeyValueStore, []);
pub trait Dismissable {
const KEY: &'static str;
@@ -91,15 +101,19 @@ mod tests {
}
}
-define_connection!(pub static ref GLOBAL_KEY_VALUE_STORE: GlobalKeyValueStore<()> =
- &[sql!(
+pub struct GlobalKeyValueStore(ThreadSafeConnection);
+
+impl Domain for GlobalKeyValueStore {
+ const NAME: &str = stringify!(GlobalKeyValueStore);
+ const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE IF NOT EXISTS kv_store(
key TEXT PRIMARY KEY,
value TEXT NOT NULL
) STRICT;
)];
- global
-);
+}
+
+crate::static_connection!(GLOBAL_KEY_VALUE_STORE, GlobalKeyValueStore, [], global);
impl GlobalKeyValueStore {
query! {
@@ -85,6 +85,10 @@ actions!(
Rerun,
/// Toggles expansion of the selected item in the debugger UI.
ToggleExpandItem,
+ /// Toggle the user frame filter in the stack frame list
+ /// When toggled on, only frames from the user's code are shown
+ /// When toggled off, all frames are shown
+ ToggleUserFrames,
]
);
@@ -272,12 +276,25 @@ pub fn init(cx: &mut App) {
}
})
.on_action({
+ let active_item = active_item.clone();
move |_: &ToggleIgnoreBreakpoints, _, cx| {
active_item
.update(cx, |item, cx| item.toggle_ignore_breakpoints(cx))
.ok();
}
})
+ .on_action(move |_: &ToggleUserFrames, _, cx| {
+ if let Some((thread_status, stack_frame_list)) = active_item
+ .read_with(cx, |item, cx| {
+ (item.thread_status(cx), item.stack_frame_list().clone())
+ })
+ .ok()
+ {
+ stack_frame_list.update(cx, |stack_frame_list, cx| {
+ stack_frame_list.toggle_frame_filter(thread_status, cx);
+ })
+ }
+ })
});
})
.detach();
@@ -1383,14 +1383,28 @@ impl PickerDelegate for DebugDelegate {
.border_color(cx.theme().colors().border_variant)
.children({
let action = menu::SecondaryConfirm.boxed_clone();
- KeyBinding::for_action(&*action, window, cx).map(|keybind| {
- Button::new("edit-debug-task", "Edit in debug.json")
- .label_size(LabelSize::Small)
- .key_binding(keybind)
- .on_click(move |_, window, cx| {
- window.dispatch_action(action.boxed_clone(), cx)
- })
- })
+ if self.matches.is_empty() {
+ Some(
+ Button::new("edit-debug-json", "Edit debug.json")
+ .label_size(LabelSize::Small)
+ .on_click(cx.listener(|_picker, _, window, cx| {
+ window.dispatch_action(
+ zed_actions::OpenProjectDebugTasks.boxed_clone(),
+ cx,
+ );
+ cx.emit(DismissEvent);
+ })),
+ )
+ } else {
+ KeyBinding::for_action(&*action, window, cx).map(|keybind| {
+ Button::new("edit-debug-task", "Edit in debug.json")
+ .label_size(LabelSize::Small)
+ .key_binding(keybind)
+ .on_click(move |_, window, cx| {
+ window.dispatch_action(action.boxed_clone(), cx)
+ })
+ })
+ }
})
.map(|this| {
if (current_modifiers.alt || self.matches.is_empty()) && !self.prompt.is_empty() {
@@ -270,12 +270,9 @@ pub(crate) fn deserialize_pane_layout(
.children
.iter()
.map(|child| match child {
- DebuggerPaneItem::Frames => Box::new(SubView::new(
- stack_frame_list.focus_handle(cx),
- stack_frame_list.clone().into(),
- DebuggerPaneItem::Frames,
- cx,
- )),
+ DebuggerPaneItem::Frames => {
+ Box::new(SubView::stack_frame_list(stack_frame_list.clone(), cx))
+ }
DebuggerPaneItem::Variables => Box::new(SubView::new(
variable_list.focus_handle(cx),
variable_list.clone().into(),
@@ -36,7 +36,6 @@ use module_list::ModuleList;
use project::{
DebugScenarioContext, Project, WorktreeId,
debugger::session::{self, Session, SessionEvent, SessionStateEvent, ThreadId, ThreadStatus},
- terminals::TerminalKind,
};
use rpc::proto::ViewId;
use serde_json::Value;
@@ -158,6 +157,29 @@ impl SubView {
})
}
+ pub(crate) fn stack_frame_list(
+ stack_frame_list: Entity<StackFrameList>,
+ cx: &mut App,
+ ) -> Entity<Self> {
+ let weak_list = stack_frame_list.downgrade();
+ let this = Self::new(
+ stack_frame_list.focus_handle(cx),
+ stack_frame_list.into(),
+ DebuggerPaneItem::Frames,
+ cx,
+ );
+
+ this.update(cx, |this, _| {
+ this.with_actions(Box::new(move |_, cx| {
+ weak_list
+ .update(cx, |this, _| this.render_control_strip())
+ .unwrap_or_else(|_| div().into_any_element())
+ }));
+ });
+
+ this
+ }
+
pub(crate) fn console(console: Entity<Console>, cx: &mut App) -> Entity<Self> {
let weak_console = console.downgrade();
let this = Self::new(
@@ -916,10 +938,11 @@ impl RunningState {
let task_store = project.read(cx).task_store().downgrade();
let weak_project = project.downgrade();
let weak_workspace = workspace.downgrade();
- let ssh_info = project
+ let remote_shell = project
.read(cx)
- .ssh_client()
- .and_then(|it| it.read(cx).ssh_info());
+ .remote_client()
+ .as_ref()
+ .and_then(|remote| remote.read(cx).shell());
cx.spawn_in(window, async move |this, cx| {
let DebugScenario {
@@ -1003,7 +1026,7 @@ impl RunningState {
None
};
- let builder = ShellBuilder::new(ssh_info.as_ref().map(|info| &*info.shell), &task.resolved.shell);
+ let builder = ShellBuilder::new(remote_shell.as_deref(), &task.resolved.shell);
let command_label = builder.command_label(&task.resolved.command_label);
let (command, args) =
builder.build(task.resolved.command.clone(), &task.resolved.args);
@@ -1016,12 +1039,11 @@ impl RunningState {
};
let terminal = project
.update(cx, |project, cx| {
- project.create_terminal(
- TerminalKind::Task(task_with_shell.clone()),
+ project.create_terminal_task(
+ task_with_shell.clone(),
cx,
)
- })?
- .await?;
+ })?.await?;
let terminal_view = cx.new_window_entity(|window, cx| {
TerminalView::new(
@@ -1165,7 +1187,7 @@ impl RunningState {
.filter(|title| !title.is_empty())
.or_else(|| command.clone())
.unwrap_or_else(|| "Debug terminal".to_string());
- let kind = TerminalKind::Task(task::SpawnInTerminal {
+ let kind = task::SpawnInTerminal {
id: task::TaskId("debug".to_string()),
full_label: title.clone(),
label: title.clone(),
@@ -1183,12 +1205,13 @@ impl RunningState {
show_summary: false,
show_command: false,
show_rerun: false,
- });
+ };
let workspace = self.workspace.clone();
let weak_project = project.downgrade();
- let terminal_task = project.update(cx, |project, cx| project.create_terminal(kind, cx));
+ let terminal_task =
+ project.update(cx, |project, cx| project.create_terminal_task(kind, cx));
let terminal_task = cx.spawn_in(window, async move |_, cx| {
let terminal = terminal_task.await?;
@@ -4,16 +4,17 @@ use std::time::Duration;
use anyhow::{Context as _, Result, anyhow};
use dap::StackFrameId;
+use db::kvp::KEY_VALUE_STORE;
use gpui::{
- AnyElement, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, ListState, MouseButton,
- Stateful, Subscription, Task, WeakEntity, list,
+ Action, AnyElement, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, ListState,
+ MouseButton, Stateful, Subscription, Task, WeakEntity, list,
};
use util::debug_panic;
-use crate::StackTraceView;
+use crate::{StackTraceView, ToggleUserFrames};
use language::PointUtf16;
use project::debugger::breakpoint_store::ActiveStackFrame;
-use project::debugger::session::{Session, SessionEvent, StackFrame};
+use project::debugger::session::{Session, SessionEvent, StackFrame, ThreadStatus};
use project::{ProjectItem, ProjectPath};
use ui::{Scrollbar, ScrollbarState, Tooltip, prelude::*};
use workspace::{ItemHandle, Workspace};
@@ -26,6 +27,34 @@ pub enum StackFrameListEvent {
BuiltEntries,
}
+/// Represents the filter applied to the stack frame list
+#[derive(PartialEq, Eq, Copy, Clone)]
+enum StackFrameFilter {
+ /// Show all frames
+ All,
+ /// Show only frames from the user's code
+ OnlyUserFrames,
+}
+
+impl StackFrameFilter {
+ fn from_str_or_default(s: impl AsRef<str>) -> Self {
+ match s.as_ref() {
+ "user" => StackFrameFilter::OnlyUserFrames,
+ "all" => StackFrameFilter::All,
+ _ => StackFrameFilter::All,
+ }
+ }
+}
+
+impl From<StackFrameFilter> for String {
+ fn from(filter: StackFrameFilter) -> Self {
+ match filter {
+ StackFrameFilter::All => "all".to_string(),
+ StackFrameFilter::OnlyUserFrames => "user".to_string(),
+ }
+ }
+}
+
pub struct StackFrameList {
focus_handle: FocusHandle,
_subscription: Subscription,
@@ -37,6 +66,8 @@ pub struct StackFrameList {
opened_stack_frame_id: Option<StackFrameId>,
scrollbar_state: ScrollbarState,
list_state: ListState,
+ list_filter: StackFrameFilter,
+ filter_entries_indices: Vec<usize>,
error: Option<SharedString>,
_refresh_task: Task<()>,
}
@@ -73,6 +104,16 @@ impl StackFrameList {
let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.));
let scrollbar_state = ScrollbarState::new(list_state.clone());
+ let list_filter = KEY_VALUE_STORE
+ .read_kvp(&format!(
+ "stack-frame-list-filter-{}",
+ session.read(cx).adapter().0
+ ))
+ .ok()
+ .flatten()
+ .map(StackFrameFilter::from_str_or_default)
+ .unwrap_or(StackFrameFilter::All);
+
let mut this = Self {
session,
workspace,
@@ -80,9 +121,11 @@ impl StackFrameList {
state,
_subscription,
entries: Default::default(),
+ filter_entries_indices: Vec::default(),
error: None,
selected_ix: None,
opened_stack_frame_id: None,
+ list_filter,
list_state,
scrollbar_state,
_refresh_task: Task::ready(()),
@@ -103,7 +146,15 @@ impl StackFrameList {
) -> Vec<dap::StackFrame> {
self.entries
.iter()
- .flat_map(|frame| match frame {
+ .enumerate()
+ .filter(|(ix, _)| {
+ self.list_filter == StackFrameFilter::All
+ || self
+ .filter_entries_indices
+ .binary_search_by_key(&ix, |ix| ix)
+ .is_ok()
+ })
+ .flat_map(|(_, frame)| match frame {
StackFrameEntry::Normal(frame) => vec![frame.clone()],
StackFrameEntry::Label(frame) if show_labels => vec![frame.clone()],
StackFrameEntry::Collapsed(frames) if show_collapsed => frames.clone(),
@@ -126,7 +177,15 @@ impl StackFrameList {
self.stack_frames(cx)
.unwrap_or_default()
.into_iter()
- .map(|stack_frame| stack_frame.dap)
+ .enumerate()
+ .filter(|(ix, _)| {
+ self.list_filter == StackFrameFilter::All
+ || self
+ .filter_entries_indices
+ .binary_search_by_key(&ix, |ix| ix)
+ .is_ok()
+ })
+ .map(|(_, stack_frame)| stack_frame.dap)
.collect()
}
@@ -192,7 +251,32 @@ impl StackFrameList {
return;
}
};
- for stack_frame in &stack_frames {
+
+ let worktree_prefixes: Vec<_> = self
+ .workspace
+ .read_with(cx, |workspace, cx| {
+ workspace
+ .visible_worktrees(cx)
+ .map(|tree| tree.read(cx).abs_path())
+ .collect()
+ })
+ .unwrap_or_default();
+
+ let mut filter_entries_indices = Vec::default();
+ for (ix, stack_frame) in stack_frames.iter().enumerate() {
+ let frame_in_visible_worktree = stack_frame.dap.source.as_ref().is_some_and(|source| {
+ source.path.as_ref().is_some_and(|path| {
+ worktree_prefixes
+ .iter()
+ .filter_map(|tree| tree.to_str())
+ .any(|tree| path.starts_with(tree))
+ })
+ });
+
+ if frame_in_visible_worktree {
+ filter_entries_indices.push(ix);
+ }
+
match stack_frame.dap.presentation_hint {
Some(dap::StackFramePresentationHint::Deemphasize)
| Some(dap::StackFramePresentationHint::Subtle) => {
@@ -225,8 +309,10 @@ impl StackFrameList {
let collapsed_entries = std::mem::take(&mut collapsed_entries);
if !collapsed_entries.is_empty() {
entries.push(StackFrameEntry::Collapsed(collapsed_entries));
+ self.filter_entries_indices.push(entries.len() - 1);
}
self.entries = entries;
+ self.filter_entries_indices = filter_entries_indices;
if let Some(ix) = first_stack_frame_with_path
.or(first_stack_frame)
@@ -242,7 +328,14 @@ impl StackFrameList {
self.selected_ix = ix;
}
- self.list_state.reset(self.entries.len());
+ match self.list_filter {
+ StackFrameFilter::All => {
+ self.list_state.reset(self.entries.len());
+ }
+ StackFrameFilter::OnlyUserFrames => {
+ self.list_state.reset(self.filter_entries_indices.len());
+ }
+ }
cx.emit(StackFrameListEvent::BuiltEntries);
cx.notify();
}
@@ -572,6 +665,11 @@ impl StackFrameList {
}
fn render_entry(&self, ix: usize, cx: &mut Context<Self>) -> AnyElement {
+ let ix = match self.list_filter {
+ StackFrameFilter::All => ix,
+ StackFrameFilter::OnlyUserFrames => self.filter_entries_indices[ix],
+ };
+
match &self.entries[ix] {
StackFrameEntry::Label(stack_frame) => self.render_label_entry(stack_frame, cx),
StackFrameEntry::Normal(stack_frame) => self.render_normal_entry(ix, stack_frame, cx),
@@ -702,6 +800,67 @@ impl StackFrameList {
self.activate_selected_entry(window, cx);
}
+ pub(crate) fn toggle_frame_filter(
+ &mut self,
+ thread_status: Option<ThreadStatus>,
+ cx: &mut Context<Self>,
+ ) {
+ self.list_filter = match self.list_filter {
+ StackFrameFilter::All => StackFrameFilter::OnlyUserFrames,
+ StackFrameFilter::OnlyUserFrames => StackFrameFilter::All,
+ };
+
+ if let Some(database_id) = self
+ .workspace
+ .read_with(cx, |workspace, _| workspace.database_id())
+ .ok()
+ .flatten()
+ {
+ let database_id: i64 = database_id.into();
+ let save_task = KEY_VALUE_STORE.write_kvp(
+ format!(
+ "stack-frame-list-filter-{}-{}",
+ self.session.read(cx).adapter().0,
+ database_id,
+ ),
+ self.list_filter.into(),
+ );
+ cx.background_spawn(save_task).detach();
+ }
+
+ if let Some(ThreadStatus::Stopped) = thread_status {
+ match self.list_filter {
+ StackFrameFilter::All => {
+ self.list_state.reset(self.entries.len());
+ }
+ StackFrameFilter::OnlyUserFrames => {
+ self.list_state.reset(self.filter_entries_indices.len());
+ if !self
+ .selected_ix
+ .map(|ix| self.filter_entries_indices.contains(&ix))
+ .unwrap_or_default()
+ {
+ self.selected_ix = None;
+ }
+ }
+ }
+
+ if let Some(ix) = self.selected_ix {
+ let scroll_to = match self.list_filter {
+ StackFrameFilter::All => ix,
+ StackFrameFilter::OnlyUserFrames => self
+ .filter_entries_indices
+ .binary_search_by_key(&ix, |ix| *ix)
+ .expect("This index will always exist"),
+ };
+ self.list_state.scroll_to_reveal_item(scroll_to);
+ }
+
+ cx.emit(StackFrameListEvent::BuiltEntries);
+ cx.notify();
+ }
+ }
+
fn render_list(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().p_1().size_full().child(
list(
@@ -711,6 +870,30 @@ impl StackFrameList {
.size_full(),
)
}
+
+ pub(crate) fn render_control_strip(&self) -> AnyElement {
+ let tooltip_title = match self.list_filter {
+ StackFrameFilter::All => "Show stack frames from your project",
+ StackFrameFilter::OnlyUserFrames => "Show all stack frames",
+ };
+
+ h_flex()
+ .child(
+ IconButton::new(
+ "filter-by-visible-worktree-stack-frame-list",
+ IconName::ListFilter,
+ )
+ .tooltip(move |window, cx| {
+ Tooltip::for_action(tooltip_title, &ToggleUserFrames, window, cx)
+ })
+ .toggle_state(self.list_filter == StackFrameFilter::OnlyUserFrames)
+ .icon_size(IconSize::Small)
+ .on_click(|_, window, cx| {
+ window.dispatch_action(ToggleUserFrames.boxed_clone(), cx)
+ }),
+ )
+ .into_any_element()
+ }
}
impl Render for StackFrameList {
@@ -752,3 +752,288 @@ async fn test_collapsed_entries(executor: BackgroundExecutor, cx: &mut TestAppCo
});
});
}
+
+#[gpui::test]
+async fn test_stack_frame_filter(executor: BackgroundExecutor, cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(executor.clone());
+
+ let test_file_content = r#"
+ function main() {
+ doSomething();
+ }
+
+ function doSomething() {
+ console.log('doing something');
+ }
+ "#
+ .unindent();
+
+ fs.insert_tree(
+ path!("/project"),
+ json!({
+ "src": {
+ "test.js": test_file_content,
+ }
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+ let workspace = init_test_workspace(&project, cx).await;
+ let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+ let session = start_debug_session(&workspace, cx, |_| {}).unwrap();
+ let client = session.update(cx, |session, _| session.adapter_client().unwrap());
+
+ client.on_request::<Threads, _>(move |_, _| {
+ Ok(dap::ThreadsResponse {
+ threads: vec![dap::Thread {
+ id: 1,
+ name: "Thread 1".into(),
+ }],
+ })
+ });
+
+ client.on_request::<Scopes, _>(move |_, _| Ok(dap::ScopesResponse { scopes: vec![] }));
+
+ let stack_frames = vec![
+ StackFrame {
+ id: 1,
+ name: "main".into(),
+ source: Some(dap::Source {
+ name: Some("test.js".into()),
+ path: Some(path!("/project/src/test.js").into()),
+ source_reference: None,
+ presentation_hint: None,
+ origin: None,
+ sources: None,
+ adapter_data: None,
+ checksums: None,
+ }),
+ line: 2,
+ column: 1,
+ end_line: None,
+ end_column: None,
+ can_restart: None,
+ instruction_pointer_reference: None,
+ module_id: None,
+ presentation_hint: None,
+ },
+ StackFrame {
+ id: 2,
+ name: "node:internal/modules/cjs/loader".into(),
+ source: Some(dap::Source {
+ name: Some("loader.js".into()),
+ path: Some(path!("/usr/lib/node/internal/modules/cjs/loader.js").into()),
+ source_reference: None,
+ presentation_hint: None,
+ origin: None,
+ sources: None,
+ adapter_data: None,
+ checksums: None,
+ }),
+ line: 100,
+ column: 1,
+ end_line: None,
+ end_column: None,
+ can_restart: None,
+ instruction_pointer_reference: None,
+ module_id: None,
+ presentation_hint: Some(dap::StackFramePresentationHint::Deemphasize),
+ },
+ StackFrame {
+ id: 3,
+ name: "node:internal/modules/run_main".into(),
+ source: Some(dap::Source {
+ name: Some("run_main.js".into()),
+ path: Some(path!("/usr/lib/node/internal/modules/run_main.js").into()),
+ source_reference: None,
+ presentation_hint: None,
+ origin: None,
+ sources: None,
+ adapter_data: None,
+ checksums: None,
+ }),
+ line: 50,
+ column: 1,
+ end_line: None,
+ end_column: None,
+ can_restart: None,
+ instruction_pointer_reference: None,
+ module_id: None,
+ presentation_hint: Some(dap::StackFramePresentationHint::Deemphasize),
+ },
+ StackFrame {
+ id: 4,
+ name: "doSomething".into(),
+ source: Some(dap::Source {
+ name: Some("test.js".into()),
+ path: Some(path!("/project/src/test.js").into()),
+ source_reference: None,
+ presentation_hint: None,
+ origin: None,
+ sources: None,
+ adapter_data: None,
+ checksums: None,
+ }),
+ line: 3,
+ column: 1,
+ end_line: None,
+ end_column: None,
+ can_restart: None,
+ instruction_pointer_reference: None,
+ module_id: None,
+ presentation_hint: None,
+ },
+ ];
+
+ // Store a copy for assertions
+ let stack_frames_for_assertions = stack_frames.clone();
+
+ client.on_request::<StackTrace, _>({
+ let stack_frames = Arc::new(stack_frames.clone());
+ move |_, args| {
+ assert_eq!(1, args.thread_id);
+
+ Ok(dap::StackTraceResponse {
+ stack_frames: (*stack_frames).clone(),
+ total_frames: None,
+ })
+ }
+ });
+
+ client
+ .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
+ reason: dap::StoppedEventReason::Pause,
+ description: None,
+ thread_id: Some(1),
+ preserve_focus_hint: None,
+ text: None,
+ all_threads_stopped: None,
+ hit_breakpoint_ids: None,
+ }))
+ .await;
+
+ cx.run_until_parked();
+
+ // trigger threads to load
+ active_debug_session_panel(workspace, cx).update(cx, |session, cx| {
+ session.running_state().update(cx, |running_state, cx| {
+ running_state
+ .session()
+ .update(cx, |session, cx| session.threads(cx));
+ });
+ });
+
+ cx.run_until_parked();
+
+ // select first thread
+ active_debug_session_panel(workspace, cx).update_in(cx, |session, window, cx| {
+ session.running_state().update(cx, |running_state, cx| {
+ running_state.select_current_thread(
+ &running_state
+ .session()
+ .update(cx, |session, cx| session.threads(cx)),
+ window,
+ cx,
+ );
+ });
+ });
+
+ cx.run_until_parked();
+
+ // trigger stack frames to load
+ active_debug_session_panel(workspace, cx).update(cx, |debug_panel_item, cx| {
+ let stack_frame_list = debug_panel_item
+ .running_state()
+ .update(cx, |state, _| state.stack_frame_list().clone());
+
+ stack_frame_list.update(cx, |stack_frame_list, cx| {
+ stack_frame_list.dap_stack_frames(cx);
+ });
+ });
+
+ cx.run_until_parked();
+
+ active_debug_session_panel(workspace, cx).update_in(cx, |debug_panel_item, window, cx| {
+ let stack_frame_list = debug_panel_item
+ .running_state()
+ .update(cx, |state, _| state.stack_frame_list().clone());
+
+ stack_frame_list.update(cx, |stack_frame_list, cx| {
+ stack_frame_list.build_entries(true, window, cx);
+
+ // Verify we have the expected collapsed structure
+ assert_eq!(
+ stack_frame_list.entries(),
+ &vec![
+ StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()),
+ StackFrameEntry::Collapsed(vec![
+ stack_frames_for_assertions[1].clone(),
+ stack_frames_for_assertions[2].clone()
+ ]),
+ StackFrameEntry::Normal(stack_frames_for_assertions[3].clone()),
+ ]
+ );
+
+ // Test 1: Verify filtering works
+ let all_frames = stack_frame_list.flatten_entries(true, false);
+ assert_eq!(all_frames.len(), 4, "Should see all 4 frames initially");
+
+ // Toggle to user frames only
+ stack_frame_list
+ .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
+
+ let user_frames = stack_frame_list.dap_stack_frames(cx);
+ assert_eq!(user_frames.len(), 2, "Should only see 2 user frames");
+ assert_eq!(user_frames[0].name, "main");
+ assert_eq!(user_frames[1].name, "doSomething");
+
+ // Test 2: Verify filtering toggles correctly
+ // Check we can toggle back and see all frames again
+
+ // Toggle back to all frames
+ stack_frame_list
+ .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
+
+ let all_frames_again = stack_frame_list.flatten_entries(true, false);
+ assert_eq!(
+ all_frames_again.len(),
+ 4,
+ "Should see all 4 frames after toggling back"
+ );
+
+ // Test 3: Verify collapsed entries stay expanded
+ stack_frame_list.expand_collapsed_entry(1, cx);
+ assert_eq!(
+ stack_frame_list.entries(),
+ &vec![
+ StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()),
+ StackFrameEntry::Normal(stack_frames_for_assertions[1].clone()),
+ StackFrameEntry::Normal(stack_frames_for_assertions[2].clone()),
+ StackFrameEntry::Normal(stack_frames_for_assertions[3].clone()),
+ ]
+ );
+
+ // Toggle filter twice
+ stack_frame_list
+ .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
+ stack_frame_list
+ .toggle_frame_filter(Some(project::debugger::session::ThreadStatus::Stopped), cx);
+
+ // Verify entries remain expanded
+ assert_eq!(
+ stack_frame_list.entries(),
+ &vec![
+ StackFrameEntry::Normal(stack_frames_for_assertions[0].clone()),
+ StackFrameEntry::Normal(stack_frames_for_assertions[1].clone()),
+ StackFrameEntry::Normal(stack_frames_for_assertions[2].clone()),
+ StackFrameEntry::Normal(stack_frames_for_assertions[3].clone()),
+ ],
+ "Expanded entries should remain expanded after toggling filter"
+ );
+ });
+ });
+}
@@ -268,7 +268,7 @@ pub async fn stream_completion(
.method(Method::POST)
.uri(uri)
.header("Content-Type", "application/json")
- .header("Authorization", format!("Bearer {}", api_key));
+ .header("Authorization", format!("Bearer {}", api_key.trim()));
let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?;
let mut response = client.send(request).await?;
@@ -19,6 +19,10 @@ static KEYMAP_LINUX: LazyLock<KeymapFile> = LazyLock::new(|| {
load_keymap("keymaps/default-linux.json").expect("Failed to load Linux keymap")
});
+static KEYMAP_WINDOWS: LazyLock<KeymapFile> = LazyLock::new(|| {
+ load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap")
+});
+
static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(dump_all_gpui_actions);
const FRONT_MATTER_COMMENT: &str = "<!-- ZED_META {} -->";
@@ -216,6 +220,7 @@ fn find_binding(os: &str, action: &str) -> Option<String> {
let keymap = match os {
"macos" => &KEYMAP_MACOS,
"linux" | "freebsd" => &KEYMAP_LINUX,
+ "windows" => &KEYMAP_WINDOWS,
_ => unreachable!("Not a valid OS: {}", os),
};
@@ -753,6 +753,8 @@ actions!(
UniqueLinesCaseInsensitive,
/// Removes duplicate lines (case-sensitive).
UniqueLinesCaseSensitive,
- UnwrapSyntaxNode
+ UnwrapSyntaxNode,
+ /// Wraps selections in tag specified by language.
+ WrapSelectionsInTag
]
);
@@ -2588,7 +2588,7 @@ impl Editor {
|| binding
.keystrokes()
.first()
- .is_some_and(|keystroke| keystroke.modifiers.modified())
+ .is_some_and(|keystroke| keystroke.modifiers().modified())
}))
}
@@ -7686,16 +7686,16 @@ impl Editor {
.keystroke()
{
modifiers_held = modifiers_held
- || (&accept_keystroke.modifiers == modifiers
- && accept_keystroke.modifiers.modified());
+ || (accept_keystroke.modifiers() == modifiers
+ && accept_keystroke.modifiers().modified());
};
if let Some(accept_partial_keystroke) = self
.accept_edit_prediction_keybind(true, window, cx)
.keystroke()
{
modifiers_held = modifiers_held
- || (&accept_partial_keystroke.modifiers == modifiers
- && accept_partial_keystroke.modifiers.modified());
+ || (accept_partial_keystroke.modifiers() == modifiers
+ && accept_partial_keystroke.modifiers().modified());
}
if modifiers_held {
@@ -9044,7 +9044,7 @@ impl Editor {
let is_platform_style_mac = PlatformStyle::platform() == PlatformStyle::Mac;
- let modifiers_color = if accept_keystroke.modifiers == window.modifiers() {
+ let modifiers_color = if *accept_keystroke.modifiers() == window.modifiers() {
Color::Accent
} else {
Color::Muted
@@ -9056,19 +9056,19 @@ impl Editor {
.font(theme::ThemeSettings::get_global(cx).buffer_font.clone())
.text_size(TextSize::XSmall.rems(cx))
.child(h_flex().children(ui::render_modifiers(
- &accept_keystroke.modifiers,
+ accept_keystroke.modifiers(),
PlatformStyle::platform(),
Some(modifiers_color),
Some(IconSize::XSmall.rems().into()),
true,
)))
.when(is_platform_style_mac, |parent| {
- parent.child(accept_keystroke.key.clone())
+ parent.child(accept_keystroke.key().to_string())
})
.when(!is_platform_style_mac, |parent| {
parent.child(
Key::new(
- util::capitalize(&accept_keystroke.key),
+ util::capitalize(accept_keystroke.key()),
Some(Color::Default),
)
.size(Some(IconSize::XSmall.rems().into())),
@@ -9171,7 +9171,7 @@ impl Editor {
max_width: Pixels,
cursor_point: Point,
style: &EditorStyle,
- accept_keystroke: Option<&gpui::Keystroke>,
+ accept_keystroke: Option<&gpui::KeybindingKeystroke>,
_window: &Window,
cx: &mut Context<Editor>,
) -> Option<AnyElement> {
@@ -9249,7 +9249,7 @@ impl Editor {
accept_keystroke.as_ref(),
|el, accept_keystroke| {
el.child(h_flex().children(ui::render_modifiers(
- &accept_keystroke.modifiers,
+ accept_keystroke.modifiers(),
PlatformStyle::platform(),
Some(Color::Default),
Some(IconSize::XSmall.rems().into()),
@@ -9319,7 +9319,7 @@ impl Editor {
.child(completion),
)
.when_some(accept_keystroke, |el, accept_keystroke| {
- if !accept_keystroke.modifiers.modified() {
+ if !accept_keystroke.modifiers().modified() {
return el;
}
@@ -9338,7 +9338,7 @@ impl Editor {
.font(theme::ThemeSettings::get_global(cx).buffer_font.clone())
.when(is_platform_style_mac, |parent| parent.gap_1())
.child(h_flex().children(ui::render_modifiers(
- &accept_keystroke.modifiers,
+ accept_keystroke.modifiers(),
PlatformStyle::platform(),
Some(if !has_completion {
Color::Muted
@@ -10447,6 +10447,86 @@ impl Editor {
})
}
+ fn enable_wrap_selections_in_tag(&self, cx: &App) -> bool {
+ let snapshot = self.buffer.read(cx).snapshot(cx);
+ for selection in self.selections.disjoint_anchors().iter() {
+ if snapshot
+ .language_at(selection.start)
+ .and_then(|lang| lang.config().wrap_characters.as_ref())
+ .is_some()
+ {
+ return true;
+ }
+ }
+ false
+ }
+
+ fn wrap_selections_in_tag(
+ &mut self,
+ _: &WrapSelectionsInTag,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx);
+
+ let snapshot = self.buffer.read(cx).snapshot(cx);
+
+ let mut edits = Vec::new();
+ let mut boundaries = Vec::new();
+
+ for selection in self.selections.all::<Point>(cx).iter() {
+ let Some(wrap_config) = snapshot
+ .language_at(selection.start)
+ .and_then(|lang| lang.config().wrap_characters.clone())
+ else {
+ continue;
+ };
+
+ let open_tag = format!("{}{}", wrap_config.start_prefix, wrap_config.start_suffix);
+ let close_tag = format!("{}{}", wrap_config.end_prefix, wrap_config.end_suffix);
+
+ let start_before = snapshot.anchor_before(selection.start);
+ let end_after = snapshot.anchor_after(selection.end);
+
+ edits.push((start_before..start_before, open_tag));
+ edits.push((end_after..end_after, close_tag));
+
+ boundaries.push((
+ start_before,
+ end_after,
+ wrap_config.start_prefix.len(),
+ wrap_config.end_suffix.len(),
+ ));
+ }
+
+ if edits.is_empty() {
+ return;
+ }
+
+ self.transact(window, cx, |this, window, cx| {
+ let buffer = this.buffer.update(cx, |buffer, cx| {
+ buffer.edit(edits, None, cx);
+ buffer.snapshot(cx)
+ });
+
+ let mut new_selections = Vec::with_capacity(boundaries.len() * 2);
+ for (start_before, end_after, start_prefix_len, end_suffix_len) in
+ boundaries.into_iter()
+ {
+ let open_offset = start_before.to_offset(&buffer) + start_prefix_len;
+ let close_offset = end_after.to_offset(&buffer).saturating_sub(end_suffix_len);
+ new_selections.push(open_offset..open_offset);
+ new_selections.push(close_offset..close_offset);
+ }
+
+ this.change_selections(Default::default(), window, cx, |s| {
+ s.select_ranges(new_selections);
+ });
+
+ this.request_autoscroll(Autoscroll::fit(), cx);
+ });
+ }
+
pub fn reload_file(&mut self, _: &ReloadFile, window: &mut Window, cx: &mut Context<Self>) {
let Some(project) = self.project.clone() else {
return;
@@ -20074,7 +20154,7 @@ impl Editor {
let (telemetry, is_via_ssh) = {
let project = project.read(cx);
let telemetry = project.client().telemetry().clone();
- let is_via_ssh = project.is_via_ssh();
+ let is_via_ssh = project.is_via_remote_server();
(telemetry, is_via_ssh)
};
refresh_linked_ranges(self, window, cx);
@@ -20642,7 +20722,7 @@ impl Editor {
copilot_enabled,
copilot_enabled_for_language,
edit_predictions_provider,
- is_via_ssh = project.is_via_ssh(),
+ is_via_ssh = project.is_via_remote_server(),
);
} else {
telemetry::event!(
@@ -20652,7 +20732,7 @@ impl Editor {
copilot_enabled,
copilot_enabled_for_language,
edit_predictions_provider,
- is_via_ssh = project.is_via_ssh(),
+ is_via_ssh = project.is_via_remote_server(),
);
};
}
@@ -4403,6 +4403,129 @@ async fn test_unique_lines_single_selection(cx: &mut TestAppContext) {
"});
}
+#[gpui::test]
+async fn test_wrap_in_tag_single_selection(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ let mut cx = EditorTestContext::new(cx).await;
+
+ let js_language = Arc::new(Language::new(
+ LanguageConfig {
+ name: "JavaScript".into(),
+ wrap_characters: Some(language::WrapCharactersConfig {
+ start_prefix: "<".into(),
+ start_suffix: ">".into(),
+ end_prefix: "</".into(),
+ end_suffix: ">".into(),
+ }),
+ ..LanguageConfig::default()
+ },
+ None,
+ ));
+
+ cx.update_buffer(|buffer, cx| buffer.set_language(Some(js_language), cx));
+
+ cx.set_state(indoc! {"
+ «testˇ»
+ "});
+ cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx));
+ cx.assert_editor_state(indoc! {"
+ <«ˇ»>test</«ˇ»>
+ "});
+
+ cx.set_state(indoc! {"
+ «test
+ testˇ»
+ "});
+ cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx));
+ cx.assert_editor_state(indoc! {"
+ <«ˇ»>test
+ test</«ˇ»>
+ "});
+
+ cx.set_state(indoc! {"
+ teˇst
+ "});
+ cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx));
+ cx.assert_editor_state(indoc! {"
+ te<«ˇ»></«ˇ»>st
+ "});
+}
+
+#[gpui::test]
+async fn test_wrap_in_tag_multi_selection(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ let mut cx = EditorTestContext::new(cx).await;
+
+ let js_language = Arc::new(Language::new(
+ LanguageConfig {
+ name: "JavaScript".into(),
+ wrap_characters: Some(language::WrapCharactersConfig {
+ start_prefix: "<".into(),
+ start_suffix: ">".into(),
+ end_prefix: "</".into(),
+ end_suffix: ">".into(),
+ }),
+ ..LanguageConfig::default()
+ },
+ None,
+ ));
+
+ cx.update_buffer(|buffer, cx| buffer.set_language(Some(js_language), cx));
+
+ cx.set_state(indoc! {"
+ «testˇ»
+ «testˇ» «testˇ»
+ «testˇ»
+ "});
+ cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx));
+ cx.assert_editor_state(indoc! {"
+ <«ˇ»>test</«ˇ»>
+ <«ˇ»>test</«ˇ»> <«ˇ»>test</«ˇ»>
+ <«ˇ»>test</«ˇ»>
+ "});
+
+ cx.set_state(indoc! {"
+ «test
+ testˇ»
+ «test
+ testˇ»
+ "});
+ cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx));
+ cx.assert_editor_state(indoc! {"
+ <«ˇ»>test
+ test</«ˇ»>
+ <«ˇ»>test
+ test</«ˇ»>
+ "});
+}
+
+#[gpui::test]
+async fn test_wrap_in_tag_does_nothing_in_unsupported_languages(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ let mut cx = EditorTestContext::new(cx).await;
+
+ let plaintext_language = Arc::new(Language::new(
+ LanguageConfig {
+ name: "Plain Text".into(),
+ ..LanguageConfig::default()
+ },
+ None,
+ ));
+
+ cx.update_buffer(|buffer, cx| buffer.set_language(Some(plaintext_language), cx));
+
+ cx.set_state(indoc! {"
+ «testˇ»
+ "});
+ cx.update_editor(|e, window, cx| e.wrap_selections_in_tag(&WrapSelectionsInTag, window, cx));
+ cx.assert_editor_state(indoc! {"
+ «testˇ»
+ "});
+}
+
#[gpui::test]
async fn test_manipulate_immutable_lines_with_multi_selection(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -43,10 +43,10 @@ use gpui::{
Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle,
DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId,
GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero,
- Keystroke, Length, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent,
- MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, ScrollHandle,
- ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled,
- TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill,
+ KeybindingKeystroke, Length, ModifiersChangedEvent, MouseButton, MouseClickEvent,
+ MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta,
+ ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement,
+ Style, Styled, TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill,
linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background,
transparent_black,
};
@@ -585,6 +585,9 @@ impl EditorElement {
register_action(editor, window, Editor::edit_log_breakpoint);
register_action(editor, window, Editor::enable_breakpoint);
register_action(editor, window, Editor::disable_breakpoint);
+ if editor.read(cx).enable_wrap_selections_in_tag(cx) {
+ register_action(editor, window, Editor::wrap_selections_in_tag);
+ }
}
fn register_key_listeners(&self, window: &mut Window, _: &mut App, layout: &EditorLayout) {
@@ -7150,7 +7153,7 @@ fn header_jump_data(
pub struct AcceptEditPredictionBinding(pub(crate) Option<gpui::KeyBinding>);
impl AcceptEditPredictionBinding {
- pub fn keystroke(&self) -> Option<&Keystroke> {
+ pub fn keystroke(&self) -> Option<&KeybindingKeystroke> {
if let Some(binding) = self.0.as_ref() {
match &binding.keystrokes() {
[keystroke, ..] => Some(keystroke),
@@ -1,13 +1,17 @@
use anyhow::Result;
-use db::sqlez::bindable::{Bind, Column, StaticColumnCount};
-use db::sqlez::statement::Statement;
+use db::{
+ query,
+ sqlez::{
+ bindable::{Bind, Column, StaticColumnCount},
+ domain::Domain,
+ statement::Statement,
+ },
+ sqlez_macros::sql,
+};
use fs::MTime;
use itertools::Itertools as _;
use std::path::PathBuf;
-use db::sqlez_macros::sql;
-use db::{define_connection, query};
-
use workspace::{ItemId, WorkspaceDb, WorkspaceId};
#[derive(Clone, Debug, PartialEq, Default)]
@@ -83,7 +87,11 @@ impl Column for SerializedEditor {
}
}
-define_connection!(
+pub struct EditorDb(db::sqlez::thread_safe_connection::ThreadSafeConnection);
+
+impl Domain for EditorDb {
+ const NAME: &str = stringify!(EditorDb);
+
// Current schema shape using pseudo-rust syntax:
// editors(
// item_id: usize,
@@ -113,7 +121,8 @@ define_connection!(
// start: usize,
// end: usize,
// )
- pub static ref DB: EditorDb<WorkspaceDb> = &[
+
+ const MIGRATIONS: &[&str] = &[
sql! (
CREATE TABLE editors(
item_id INTEGER NOT NULL,
@@ -189,7 +198,9 @@ define_connection!(
) STRICT;
),
];
-);
+}
+
+db::static_connection!(DB, EditorDb, [WorkspaceDb]);
// https://www.sqlite.org/limits.html
// > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER,
@@ -43,7 +43,7 @@ use language::{
use node_runtime::NodeRuntime;
use project::ContextProviderWithTasks;
use release_channel::ReleaseChannel;
-use remote::SshRemoteClient;
+use remote::RemoteClient;
use semantic_version::SemanticVersion;
use serde::{Deserialize, Serialize};
use settings::Settings;
@@ -117,7 +117,7 @@ pub struct ExtensionStore {
pub wasm_host: Arc<WasmHost>,
pub wasm_extensions: Vec<(Arc<ExtensionManifest>, WasmExtension)>,
pub tasks: Vec<Task<()>>,
- pub ssh_clients: HashMap<String, WeakEntity<SshRemoteClient>>,
+ pub remote_clients: HashMap<String, WeakEntity<RemoteClient>>,
pub ssh_registered_tx: UnboundedSender<()>,
}
@@ -270,7 +270,7 @@ impl ExtensionStore {
reload_tx,
tasks: Vec::new(),
- ssh_clients: HashMap::default(),
+ remote_clients: HashMap::default(),
ssh_registered_tx: connection_registered_tx,
};
@@ -1693,7 +1693,7 @@ impl ExtensionStore {
async fn sync_extensions_over_ssh(
this: &WeakEntity<Self>,
- client: WeakEntity<SshRemoteClient>,
+ client: WeakEntity<RemoteClient>,
cx: &mut AsyncApp,
) -> Result<()> {
let extensions = this.update(cx, |this, _cx| {
@@ -1765,8 +1765,8 @@ impl ExtensionStore {
pub async fn update_ssh_clients(this: &WeakEntity<Self>, cx: &mut AsyncApp) -> Result<()> {
let clients = this.update(cx, |this, _cx| {
- this.ssh_clients.retain(|_k, v| v.upgrade().is_some());
- this.ssh_clients.values().cloned().collect::<Vec<_>>()
+ this.remote_clients.retain(|_k, v| v.upgrade().is_some());
+ this.remote_clients.values().cloned().collect::<Vec<_>>()
})?;
for client in clients {
@@ -1778,17 +1778,17 @@ impl ExtensionStore {
anyhow::Ok(())
}
- pub fn register_ssh_client(&mut self, client: Entity<SshRemoteClient>, cx: &mut Context<Self>) {
+ pub fn register_remote_client(&mut self, client: Entity<RemoteClient>, cx: &mut Context<Self>) {
let connection_options = client.read(cx).connection_options();
let ssh_url = connection_options.ssh_url();
- if let Some(existing_client) = self.ssh_clients.get(&ssh_url)
+ if let Some(existing_client) = self.remote_clients.get(&ssh_url)
&& existing_client.upgrade().is_some()
{
return;
}
- self.ssh_clients.insert(ssh_url, client.downgrade());
+ self.remote_clients.insert(ssh_url, client.downgrade());
self.ssh_registered_tx.unbounded_send(()).ok();
}
}
@@ -98,6 +98,10 @@ impl FeatureFlag for GeminiAndNativeFeatureFlag {
// integration too, and we'd like to turn Gemini/Native on in new builds
// without enabling Claude Code in old builds.
const NAME: &'static str = "gemini-and-native";
+
+ fn enabled_for_all() -> bool {
+ true
+ }
}
pub struct ClaudeCodeFeatureFlag;
@@ -201,7 +205,7 @@ impl FeatureFlagAppExt for App {
fn has_flag<T: FeatureFlag>(&self) -> bool {
self.try_global::<FeatureFlags>()
.map(|flags| flags.has_flag::<T>())
- .unwrap_or(false)
+ .unwrap_or(T::enabled_for_all())
}
fn is_staff(&self) -> bool {
@@ -1381,7 +1381,7 @@ impl PickerDelegate for FileFinderDelegate {
project
.worktree_for_id(history_item.project.worktree_id, cx)
.is_some()
- || ((project.is_local() || project.is_via_ssh())
+ || ((project.is_local() || project.is_via_remote_server())
&& history_item.absolute.is_some())
}),
self.currently_opened_path.as_ref(),
@@ -495,7 +495,8 @@ impl Fs for RealFs {
};
// todo(windows)
// When new version of `windows-rs` release, make this operation `async`
- let path = SanitizedPath::from(path.canonicalize()?);
+ let path = path.canonicalize()?;
+ let path = SanitizedPath::new(&path);
let path_string = path.to_string();
let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(path_string))?.get()?;
file.DeleteAsync(StorageDeleteOption::Default)?.get()?;
@@ -522,7 +523,8 @@ impl Fs for RealFs {
// todo(windows)
// When new version of `windows-rs` release, make this operation `async`
- let path = SanitizedPath::from(path.canonicalize()?);
+ let path = path.canonicalize()?;
+ let path = SanitizedPath::new(&path);
let path_string = path.to_string();
let folder = StorageFolder::GetFolderFromPathAsync(&HSTRING::from(path_string))?.get()?;
folder.DeleteAsync(StorageDeleteOption::Default)?.get()?;
@@ -783,7 +785,7 @@ impl Fs for RealFs {
{
target = parent.join(target);
if let Ok(canonical) = self.canonicalize(&target).await {
- target = SanitizedPath::from(canonical).as_path().to_path_buf();
+ target = SanitizedPath::new(&canonical).as_path().to_path_buf();
}
}
watcher.add(&target).ok();
@@ -42,7 +42,7 @@ impl Drop for FsWatcher {
impl Watcher for FsWatcher {
fn add(&self, path: &std::path::Path) -> anyhow::Result<()> {
- let root_path = SanitizedPath::from(path);
+ let root_path = SanitizedPath::new_arc(path);
let tx = self.tx.clone();
let pending_paths = self.pending_path_events.clone();
@@ -70,7 +70,7 @@ impl Watcher for FsWatcher {
.paths
.iter()
.filter_map(|event_path| {
- let event_path = SanitizedPath::from(event_path);
+ let event_path = SanitizedPath::new(event_path);
event_path.starts_with(&root_path).then(|| PathEvent {
path: event_path.as_path().to_path_buf(),
kind,
@@ -4466,7 +4466,7 @@ fn current_language_model(cx: &Context<'_, GitPanel>) -> Option<Arc<dyn Language
is_enabled
.then(|| {
let ConfiguredModel { provider, model } =
- LanguageModelRegistry::read_global(cx).commit_message_model(cx)?;
+ LanguageModelRegistry::read_global(cx).commit_message_model()?;
provider.is_authenticated(cx).then(|| model)
})
@@ -13,6 +13,7 @@ pub async fn stream_generate_content(
api_key: &str,
mut request: GenerateContentRequest,
) -> Result<BoxStream<'static, Result<GenerateContentResponse>>> {
+ let api_key = api_key.trim();
validate_generate_content_request(&request)?;
// The `model` field is emptied as it is provided as a path parameter.
@@ -152,6 +152,36 @@ impl Render for WindowDemo {
)
.unwrap();
}))
+ .child(button("Unresizable", move |_, cx| {
+ cx.open_window(
+ WindowOptions {
+ is_resizable: false,
+ window_bounds: Some(window_bounds),
+ ..Default::default()
+ },
+ |_, cx| {
+ cx.new(|_| SubWindow {
+ custom_titlebar: false,
+ })
+ },
+ )
+ .unwrap();
+ }))
+ .child(button("Unminimizable", move |_, cx| {
+ cx.open_window(
+ WindowOptions {
+ is_minimizable: false,
+ window_bounds: Some(window_bounds),
+ ..Default::default()
+ },
+ |_, cx| {
+ cx.new(|_| SubWindow {
+ custom_titlebar: false,
+ })
+ },
+ )
+ .unwrap();
+ }))
.child(button("Hide Application", |window, cx| {
cx.hide();
@@ -62,6 +62,8 @@ fn build_window_options(display_id: DisplayId, bounds: Bounds<Pixels>) -> Window
app_id: None,
window_min_size: None,
window_decorations: None,
+ tabbing_identifier: None,
+ ..Default::default()
}
}
@@ -7,7 +7,7 @@ use std::{
path::{Path, PathBuf},
rc::{Rc, Weak},
sync::{Arc, atomic::Ordering::SeqCst},
- time::Duration,
+ time::{Duration, Instant},
};
use anyhow::{Context as _, Result, anyhow};
@@ -17,6 +17,7 @@ use futures::{
channel::oneshot,
future::{LocalBoxFuture, Shared},
};
+use itertools::Itertools;
use parking_lot::RwLock;
use slotmap::SlotMap;
@@ -37,10 +38,10 @@ use crate::{
AssetSource, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, DispatchPhase, DisplayId,
EventEmitter, FocusHandle, FocusMap, ForegroundExecutor, Global, KeyBinding, KeyContext,
Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform,
- PlatformDisplay, PlatformKeyboardLayout, Point, PromptBuilder, PromptButton, PromptHandle,
- PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource,
- SubscriberSet, Subscription, SvgRenderer, Task, TextSystem, Window, WindowAppearance,
- WindowHandle, WindowId, WindowInvalidator,
+ PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, PromptBuilder,
+ PromptButton, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle,
+ Reservation, ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, Task,
+ TextSystem, Window, WindowAppearance, WindowHandle, WindowId, WindowInvalidator,
colors::{Colors, GlobalColors},
current_platform, hash, init_app_menus,
};
@@ -237,6 +238,303 @@ type WindowClosedHandler = Box<dyn FnMut(&mut App)>;
type ReleaseListener = Box<dyn FnOnce(&mut dyn Any, &mut App) + 'static>;
type NewEntityListener = Box<dyn FnMut(AnyEntity, &mut Option<&mut Window>, &mut App) + 'static>;
+#[doc(hidden)]
+#[derive(Clone, PartialEq, Eq)]
+pub struct SystemWindowTab {
+ pub id: WindowId,
+ pub title: SharedString,
+ pub handle: AnyWindowHandle,
+ pub last_active_at: Instant,
+}
+
+impl SystemWindowTab {
+ /// Create a new instance of the window tab.
+ pub fn new(title: SharedString, handle: AnyWindowHandle) -> Self {
+ Self {
+ id: handle.id,
+ title,
+ handle,
+ last_active_at: Instant::now(),
+ }
+ }
+}
+
+/// A controller for managing window tabs.
+#[derive(Default)]
+pub struct SystemWindowTabController {
+ visible: Option<bool>,
+ tab_groups: FxHashMap<usize, Vec<SystemWindowTab>>,
+}
+
+impl Global for SystemWindowTabController {}
+
+impl SystemWindowTabController {
+ /// Create a new instance of the window tab controller.
+ pub fn new() -> Self {
+ Self {
+ visible: None,
+ tab_groups: FxHashMap::default(),
+ }
+ }
+
+ /// Initialize the global window tab controller.
+ pub fn init(cx: &mut App) {
+ cx.set_global(SystemWindowTabController::new());
+ }
+
+ /// Get all tab groups.
+ pub fn tab_groups(&self) -> &FxHashMap<usize, Vec<SystemWindowTab>> {
+ &self.tab_groups
+ }
+
+ /// Get the next tab group window handle.
+ pub fn get_next_tab_group_window(cx: &mut App, id: WindowId) -> Option<&AnyWindowHandle> {
+ let controller = cx.global::<SystemWindowTabController>();
+ let current_group = controller
+ .tab_groups
+ .iter()
+ .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group));
+
+ let current_group = current_group?;
+ let mut group_ids: Vec<_> = controller.tab_groups.keys().collect();
+ let idx = group_ids.iter().position(|g| *g == current_group)?;
+ let next_idx = (idx + 1) % group_ids.len();
+
+ controller
+ .tab_groups
+ .get(group_ids[next_idx])
+ .and_then(|tabs| {
+ tabs.iter()
+ .max_by_key(|tab| tab.last_active_at)
+ .or_else(|| tabs.first())
+ .map(|tab| &tab.handle)
+ })
+ }
+
+ /// Get the previous tab group window handle.
+ pub fn get_prev_tab_group_window(cx: &mut App, id: WindowId) -> Option<&AnyWindowHandle> {
+ let controller = cx.global::<SystemWindowTabController>();
+ let current_group = controller
+ .tab_groups
+ .iter()
+ .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| group));
+
+ let current_group = current_group?;
+ let mut group_ids: Vec<_> = controller.tab_groups.keys().collect();
+ let idx = group_ids.iter().position(|g| *g == current_group)?;
+ let prev_idx = if idx == 0 {
+ group_ids.len() - 1
+ } else {
+ idx - 1
+ };
+
+ controller
+ .tab_groups
+ .get(group_ids[prev_idx])
+ .and_then(|tabs| {
+ tabs.iter()
+ .max_by_key(|tab| tab.last_active_at)
+ .or_else(|| tabs.first())
+ .map(|tab| &tab.handle)
+ })
+ }
+
+ /// Get all tabs in the same window.
+ pub fn tabs(&self, id: WindowId) -> Option<&Vec<SystemWindowTab>> {
+ let tab_group = self
+ .tab_groups
+ .iter()
+ .find_map(|(group, tabs)| tabs.iter().find(|tab| tab.id == id).map(|_| *group));
+
+ if let Some(tab_group) = tab_group {
+ self.tab_groups.get(&tab_group)
+ } else {
+ None
+ }
+ }
+
+ /// Initialize the visibility of the system window tab controller.
+ pub fn init_visible(cx: &mut App, visible: bool) {
+ let mut controller = cx.global_mut::<SystemWindowTabController>();
+ if controller.visible.is_none() {
+ controller.visible = Some(visible);
+ }
+ }
+
+ /// Get the visibility of the system window tab controller.
+ pub fn is_visible(&self) -> bool {
+ self.visible.unwrap_or(false)
+ }
+
+ /// Set the visibility of the system window tab controller.
+ pub fn set_visible(cx: &mut App, visible: bool) {
+ let mut controller = cx.global_mut::<SystemWindowTabController>();
+ controller.visible = Some(visible);
+ }
+
+ /// Update the last active of a window.
+ pub fn update_last_active(cx: &mut App, id: WindowId) {
+ let mut controller = cx.global_mut::<SystemWindowTabController>();
+ for windows in controller.tab_groups.values_mut() {
+ for tab in windows.iter_mut() {
+ if tab.id == id {
+ tab.last_active_at = Instant::now();
+ }
+ }
+ }
+ }
+
+ /// Update the position of a tab within its group.
+ pub fn update_tab_position(cx: &mut App, id: WindowId, ix: usize) {
+ let mut controller = cx.global_mut::<SystemWindowTabController>();
+ for (_, windows) in controller.tab_groups.iter_mut() {
+ if let Some(current_pos) = windows.iter().position(|tab| tab.id == id) {
+ if ix < windows.len() && current_pos != ix {
+ let window_tab = windows.remove(current_pos);
+ windows.insert(ix, window_tab);
+ }
+ break;
+ }
+ }
+ }
+
+ /// Update the title of a tab.
+ pub fn update_tab_title(cx: &mut App, id: WindowId, title: SharedString) {
+ let controller = cx.global::<SystemWindowTabController>();
+ let tab = controller
+ .tab_groups
+ .values()
+ .flat_map(|windows| windows.iter())
+ .find(|tab| tab.id == id);
+
+ if tab.map_or(true, |t| t.title == title) {
+ return;
+ }
+
+ let mut controller = cx.global_mut::<SystemWindowTabController>();
+ for windows in controller.tab_groups.values_mut() {
+ for tab in windows.iter_mut() {
+ if tab.id == id {
+ tab.title = title.clone();
+ }
+ }
+ }
+ }
+
+ /// Insert a tab into a tab group.
+ pub fn add_tab(cx: &mut App, id: WindowId, tabs: Vec<SystemWindowTab>) {
+ let mut controller = cx.global_mut::<SystemWindowTabController>();
+ let Some(tab) = tabs.clone().into_iter().find(|tab| tab.id == id) else {
+ return;
+ };
+
+ let mut expected_tab_ids: Vec<_> = tabs
+ .iter()
+ .filter(|tab| tab.id != id)
+ .map(|tab| tab.id)
+ .sorted()
+ .collect();
+
+ let mut tab_group_id = None;
+ for (group_id, group_tabs) in &controller.tab_groups {
+ let tab_ids: Vec<_> = group_tabs.iter().map(|tab| tab.id).sorted().collect();
+ if tab_ids == expected_tab_ids {
+ tab_group_id = Some(*group_id);
+ break;
+ }
+ }
+
+ if let Some(tab_group_id) = tab_group_id {
+ if let Some(tabs) = controller.tab_groups.get_mut(&tab_group_id) {
+ tabs.push(tab);
+ }
+ } else {
+ let new_group_id = controller.tab_groups.len();
+ controller.tab_groups.insert(new_group_id, tabs);
+ }
+ }
+
+ /// Remove a tab from a tab group.
+ pub fn remove_tab(cx: &mut App, id: WindowId) -> Option<SystemWindowTab> {
+ let mut controller = cx.global_mut::<SystemWindowTabController>();
+ let mut removed_tab = None;
+
+ controller.tab_groups.retain(|_, tabs| {
+ if let Some(pos) = tabs.iter().position(|tab| tab.id == id) {
+ removed_tab = Some(tabs.remove(pos));
+ }
+ !tabs.is_empty()
+ });
+
+ removed_tab
+ }
+
+ /// Move a tab to a new tab group.
+ pub fn move_tab_to_new_window(cx: &mut App, id: WindowId) {
+ let mut removed_tab = Self::remove_tab(cx, id);
+ let mut controller = cx.global_mut::<SystemWindowTabController>();
+
+ if let Some(tab) = removed_tab {
+ let new_group_id = controller.tab_groups.keys().max().map_or(0, |k| k + 1);
+ controller.tab_groups.insert(new_group_id, vec![tab]);
+ }
+ }
+
+ /// Merge all tab groups into a single group.
+ pub fn merge_all_windows(cx: &mut App, id: WindowId) {
+ let mut controller = cx.global_mut::<SystemWindowTabController>();
+ let Some(initial_tabs) = controller.tabs(id) else {
+ return;
+ };
+
+ let mut all_tabs = initial_tabs.clone();
+ for tabs in controller.tab_groups.values() {
+ all_tabs.extend(
+ tabs.iter()
+ .filter(|tab| !initial_tabs.contains(tab))
+ .cloned(),
+ );
+ }
+
+ controller.tab_groups.clear();
+ controller.tab_groups.insert(0, all_tabs);
+ }
+
+ /// Selects the next tab in the tab group in the trailing direction.
+ pub fn select_next_tab(cx: &mut App, id: WindowId) {
+ let mut controller = cx.global_mut::<SystemWindowTabController>();
+ let Some(tabs) = controller.tabs(id) else {
+ return;
+ };
+
+ let current_index = tabs.iter().position(|tab| tab.id == id).unwrap();
+ let next_index = (current_index + 1) % tabs.len();
+
+ let _ = &tabs[next_index].handle.update(cx, |_, window, _| {
+ window.activate_window();
+ });
+ }
+
+ /// Selects the previous tab in the tab group in the leading direction.
+ pub fn select_previous_tab(cx: &mut App, id: WindowId) {
+ let mut controller = cx.global_mut::<SystemWindowTabController>();
+ let Some(tabs) = controller.tabs(id) else {
+ return;
+ };
+
+ let current_index = tabs.iter().position(|tab| tab.id == id).unwrap();
+ let previous_index = if current_index == 0 {
+ tabs.len() - 1
+ } else {
+ current_index - 1
+ };
+
+ let _ = &tabs[previous_index].handle.update(cx, |_, window, _| {
+ window.activate_window();
+ });
+ }
+}
+
/// Contains the state of the full application, and passed as a reference to a variety of callbacks.
/// Other [Context] derefs to this type.
/// You need a reference to an `App` to access the state of a [Entity].
@@ -263,6 +561,7 @@ pub struct App {
pub(crate) focus_handles: Arc<FocusMap>,
pub(crate) keymap: Rc<RefCell<Keymap>>,
pub(crate) keyboard_layout: Box<dyn PlatformKeyboardLayout>,
+ pub(crate) keyboard_mapper: Rc<dyn PlatformKeyboardMapper>,
pub(crate) global_action_listeners:
FxHashMap<TypeId, Vec<Rc<dyn Fn(&dyn Any, DispatchPhase, &mut Self)>>>,
pending_effects: VecDeque<Effect>,
@@ -312,6 +611,7 @@ impl App {
let text_system = Arc::new(TextSystem::new(platform.text_system()));
let entities = EntityMap::new();
let keyboard_layout = platform.keyboard_layout();
+ let keyboard_mapper = platform.keyboard_mapper();
let app = Rc::new_cyclic(|this| AppCell {
app: RefCell::new(App {
@@ -337,6 +637,7 @@ impl App {
focus_handles: Arc::new(RwLock::new(SlotMap::with_key())),
keymap: Rc::new(RefCell::new(Keymap::default())),
keyboard_layout,
+ keyboard_mapper,
global_action_listeners: FxHashMap::default(),
pending_effects: VecDeque::new(),
pending_notifications: FxHashSet::default(),
@@ -369,6 +670,7 @@ impl App {
});
init_app_menus(platform.as_ref(), &app.borrow());
+ SystemWindowTabController::init(&mut app.borrow_mut());
platform.on_keyboard_layout_change(Box::new({
let app = Rc::downgrade(&app);
@@ -376,6 +678,7 @@ impl App {
if let Some(app) = app.upgrade() {
let cx = &mut app.borrow_mut();
cx.keyboard_layout = cx.platform.keyboard_layout();
+ cx.keyboard_mapper = cx.platform.keyboard_mapper();
cx.keyboard_layout_observers
.clone()
.retain(&(), move |callback| (callback)(cx));
@@ -424,6 +727,11 @@ impl App {
self.keyboard_layout.as_ref()
}
+ /// Get the current keyboard mapper.
+ pub fn keyboard_mapper(&self) -> &Rc<dyn PlatformKeyboardMapper> {
+ &self.keyboard_mapper
+ }
+
/// Invokes a handler when the current keyboard layout changes
pub fn on_keyboard_layout_change<F>(&self, mut callback: F) -> Subscription
where
@@ -4,7 +4,7 @@ mod context;
pub use binding::*;
pub use context::*;
-use crate::{Action, Keystroke, is_no_action};
+use crate::{Action, AsKeystroke, Keystroke, is_no_action};
use collections::{HashMap, HashSet};
use smallvec::SmallVec;
use std::any::TypeId;
@@ -141,7 +141,7 @@ impl Keymap {
/// only.
pub fn bindings_for_input(
&self,
- input: &[Keystroke],
+ input: &[impl AsKeystroke],
context_stack: &[KeyContext],
) -> (SmallVec<[KeyBinding; 1]>, bool) {
let mut matched_bindings = SmallVec::<[(usize, BindingIndex, &KeyBinding); 1]>::new();
@@ -192,7 +192,6 @@ impl Keymap {
(bindings, !pending.is_empty())
}
-
/// Check if the given binding is enabled, given a certain key context.
/// Returns the deepest depth at which the binding matches, or None if it doesn't match.
fn binding_enabled(&self, binding: &KeyBinding, contexts: &[KeyContext]) -> Option<usize> {
@@ -639,7 +638,7 @@ mod tests {
fn assert_bindings(keymap: &Keymap, action: &dyn Action, expected: &[&str]) {
let actual = keymap
.bindings_for_action(action)
- .map(|binding| binding.keystrokes[0].unparse())
+ .map(|binding| binding.keystrokes[0].inner().unparse())
.collect::<Vec<_>>();
assert_eq!(actual, expected, "{:?}", action);
}
@@ -1,14 +1,15 @@
use std::rc::Rc;
-use collections::HashMap;
-
-use crate::{Action, InvalidKeystrokeError, KeyBindingContextPredicate, Keystroke, SharedString};
+use crate::{
+ Action, AsKeystroke, DummyKeyboardMapper, InvalidKeystrokeError, KeyBindingContextPredicate,
+ KeybindingKeystroke, Keystroke, PlatformKeyboardMapper, SharedString,
+};
use smallvec::SmallVec;
/// A keybinding and its associated metadata, from the keymap.
pub struct KeyBinding {
pub(crate) action: Box<dyn Action>,
- pub(crate) keystrokes: SmallVec<[Keystroke; 2]>,
+ pub(crate) keystrokes: SmallVec<[KeybindingKeystroke; 2]>,
pub(crate) context_predicate: Option<Rc<KeyBindingContextPredicate>>,
pub(crate) meta: Option<KeyBindingMetaIndex>,
/// The json input string used when building the keybinding, if any
@@ -32,7 +33,15 @@ impl KeyBinding {
pub fn new<A: Action>(keystrokes: &str, action: A, context: Option<&str>) -> Self {
let context_predicate =
context.map(|context| KeyBindingContextPredicate::parse(context).unwrap().into());
- Self::load(keystrokes, Box::new(action), context_predicate, None, None).unwrap()
+ Self::load(
+ keystrokes,
+ Box::new(action),
+ context_predicate,
+ false,
+ None,
+ &DummyKeyboardMapper,
+ )
+ .unwrap()
}
/// Load a keybinding from the given raw data.
@@ -40,24 +49,22 @@ impl KeyBinding {
keystrokes: &str,
action: Box<dyn Action>,
context_predicate: Option<Rc<KeyBindingContextPredicate>>,
- key_equivalents: Option<&HashMap<char, char>>,
+ use_key_equivalents: bool,
action_input: Option<SharedString>,
+ keyboard_mapper: &dyn PlatformKeyboardMapper,
) -> std::result::Result<Self, InvalidKeystrokeError> {
- let mut keystrokes: SmallVec<[Keystroke; 2]> = keystrokes
+ let keystrokes: SmallVec<[KeybindingKeystroke; 2]> = keystrokes
.split_whitespace()
- .map(Keystroke::parse)
+ .map(|source| {
+ let keystroke = Keystroke::parse(source)?;
+ Ok(KeybindingKeystroke::new_with_mapper(
+ keystroke,
+ use_key_equivalents,
+ keyboard_mapper,
+ ))
+ })
.collect::<std::result::Result<_, _>>()?;
- if let Some(equivalents) = key_equivalents {
- for keystroke in keystrokes.iter_mut() {
- if keystroke.key.chars().count() == 1
- && let Some(key) = equivalents.get(&keystroke.key.chars().next().unwrap())
- {
- keystroke.key = key.to_string();
- }
- }
- }
-
Ok(Self {
keystrokes,
action,
@@ -79,13 +86,13 @@ impl KeyBinding {
}
/// Check if the given keystrokes match this binding.
- pub fn match_keystrokes(&self, typed: &[Keystroke]) -> Option<bool> {
+ pub fn match_keystrokes(&self, typed: &[impl AsKeystroke]) -> Option<bool> {
if self.keystrokes.len() < typed.len() {
return None;
}
for (target, typed) in self.keystrokes.iter().zip(typed.iter()) {
- if !typed.should_match(target) {
+ if !typed.as_keystroke().should_match(target) {
return None;
}
}
@@ -94,7 +101,7 @@ impl KeyBinding {
}
/// Get the keystrokes associated with this binding
- pub fn keystrokes(&self) -> &[Keystroke] {
+ pub fn keystrokes(&self) -> &[KeybindingKeystroke] {
self.keystrokes.as_slice()
}
@@ -40,8 +40,8 @@ use crate::{
DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun,
ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput,
Point, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, ScaledPixels, Scene,
- ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, SvgSize, Task, TaskLabel, Window,
- WindowControlArea, hash, point, px, size,
+ ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, SvgSize, SystemWindowTab, Task,
+ TaskLabel, Window, WindowControlArea, hash, point, px, size,
};
use anyhow::Result;
use async_task::Runnable;
@@ -231,7 +231,6 @@ pub(crate) trait Platform: 'static {
fn on_quit(&self, callback: Box<dyn FnMut()>);
fn on_reopen(&self, callback: Box<dyn FnMut()>);
- fn on_keyboard_layout_change(&self, callback: Box<dyn FnMut()>);
fn set_menus(&self, menus: Vec<Menu>, keymap: &Keymap);
fn get_menus(&self) -> Option<Vec<OwnedMenu>> {
@@ -251,7 +250,6 @@ pub(crate) trait Platform: 'static {
fn on_app_menu_action(&self, callback: Box<dyn FnMut(&dyn Action)>);
fn on_will_open_app_menu(&self, callback: Box<dyn FnMut()>);
fn on_validate_app_menu_command(&self, callback: Box<dyn FnMut(&dyn Action) -> bool>);
- fn keyboard_layout(&self) -> Box<dyn PlatformKeyboardLayout>;
fn compositor_name(&self) -> &'static str {
""
@@ -272,6 +270,10 @@ pub(crate) trait Platform: 'static {
fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task<Result<()>>;
fn read_credentials(&self, url: &str) -> Task<Result<Option<(String, Vec<u8>)>>>;
fn delete_credentials(&self, url: &str) -> Task<Result<()>>;
+
+ fn keyboard_layout(&self) -> Box<dyn PlatformKeyboardLayout>;
+ fn keyboard_mapper(&self) -> Rc<dyn PlatformKeyboardMapper>;
+ fn on_keyboard_layout_change(&self, callback: Box<dyn FnMut()>);
}
/// A handle to a platform's display, e.g. a monitor or laptop screen.
@@ -500,9 +502,26 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
fn sprite_atlas(&self) -> Arc<dyn PlatformAtlas>;
// macOS specific methods
+ fn get_title(&self) -> String {
+ String::new()
+ }
+ fn tabbed_windows(&self) -> Option<Vec<SystemWindowTab>> {
+ None
+ }
+ fn tab_bar_visible(&self) -> bool {
+ false
+ }
fn set_edited(&mut self, _edited: bool) {}
fn show_character_palette(&self) {}
fn titlebar_double_click(&self) {}
+ fn on_move_tab_to_new_window(&self, _callback: Box<dyn FnMut()>) {}
+ fn on_merge_all_windows(&self, _callback: Box<dyn FnMut()>) {}
+ fn on_select_previous_tab(&self, _callback: Box<dyn FnMut()>) {}
+ fn on_select_next_tab(&self, _callback: Box<dyn FnMut()>) {}
+ fn on_toggle_tab_bar(&self, _callback: Box<dyn FnMut()>) {}
+ fn merge_all_windows(&self) {}
+ fn move_tab_to_new_window(&self) {}
+ fn toggle_window_tab_overview(&self) {}
#[cfg(target_os = "windows")]
fn get_raw_handle(&self) -> windows::HWND;
@@ -1089,6 +1108,12 @@ pub struct WindowOptions {
/// Whether the window should be movable by the user
pub is_movable: bool,
+ /// Whether the window should be resizable by the user
+ pub is_resizable: bool,
+
+ /// Whether the window should be minimized by the user
+ pub is_minimizable: bool,
+
/// The display to create the window on, if this is None,
/// the window will be created on the main display
pub display_id: Option<DisplayId>,
@@ -1105,6 +1130,9 @@ pub struct WindowOptions {
/// Whether to use client or server side decorations. Wayland only
/// Note that this may be ignored.
pub window_decorations: Option<WindowDecorations>,
+
+ /// Tab group name, allows opening the window as a native tab on macOS 10.12+. Windows with the same tabbing identifier will be grouped together.
+ pub tabbing_identifier: Option<String>,
}
/// The variables that can be configured when creating a new window
@@ -1131,6 +1159,14 @@ pub(crate) struct WindowParams {
#[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))]
pub is_movable: bool,
+ /// Whether the window should be resizable by the user
+ #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))]
+ pub is_resizable: bool,
+
+ /// Whether the window should be minimized by the user
+ #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))]
+ pub is_minimizable: bool,
+
#[cfg_attr(
any(target_os = "linux", target_os = "freebsd", target_os = "windows"),
allow(dead_code)
@@ -1144,6 +1180,8 @@ pub(crate) struct WindowParams {
pub display_id: Option<DisplayId>,
pub window_min_size: Option<Size<Pixels>>,
+ #[cfg(target_os = "macos")]
+ pub tabbing_identifier: Option<String>,
}
/// Represents the status of how a window should be opened.
@@ -1189,11 +1227,14 @@ impl Default for WindowOptions {
show: true,
kind: WindowKind::Normal,
is_movable: true,
+ is_resizable: true,
+ is_minimizable: true,
display_id: None,
window_background: WindowBackgroundAppearance::default(),
app_id: None,
window_min_size: None,
window_decorations: None,
+ tabbing_identifier: None,
}
}
}
@@ -1,3 +1,7 @@
+use collections::HashMap;
+
+use crate::{KeybindingKeystroke, Keystroke};
+
/// A trait for platform-specific keyboard layouts
pub trait PlatformKeyboardLayout {
/// Get the keyboard layout ID, which should be unique to the layout
@@ -5,3 +9,33 @@ pub trait PlatformKeyboardLayout {
/// Get the keyboard layout display name
fn name(&self) -> &str;
}
+
+/// A trait for platform-specific keyboard mappings
+pub trait PlatformKeyboardMapper {
+ /// Map a key equivalent to its platform-specific representation
+ fn map_key_equivalent(
+ &self,
+ keystroke: Keystroke,
+ use_key_equivalents: bool,
+ ) -> KeybindingKeystroke;
+ /// Get the key equivalents for the current keyboard layout,
+ /// only used on macOS
+ fn get_key_equivalents(&self) -> Option<&HashMap<char, char>>;
+}
+
+/// A dummy implementation of the platform keyboard mapper
+pub struct DummyKeyboardMapper;
+
+impl PlatformKeyboardMapper for DummyKeyboardMapper {
+ fn map_key_equivalent(
+ &self,
+ keystroke: Keystroke,
+ _use_key_equivalents: bool,
+ ) -> KeybindingKeystroke {
+ KeybindingKeystroke::from_keystroke(keystroke)
+ }
+
+ fn get_key_equivalents(&self) -> Option<&HashMap<char, char>> {
+ None
+ }
+}
@@ -5,6 +5,14 @@ use std::{
fmt::{Display, Write},
};
+use crate::PlatformKeyboardMapper;
+
+/// This is a helper trait so that we can simplify the implementation of some functions
+pub trait AsKeystroke {
+ /// Returns the GPUI representation of the keystroke.
+ fn as_keystroke(&self) -> &Keystroke;
+}
+
/// A keystroke and associated metadata generated by the platform
#[derive(Clone, Debug, Eq, PartialEq, Default, Deserialize, Hash)]
pub struct Keystroke {
@@ -24,6 +32,19 @@ pub struct Keystroke {
pub key_char: Option<String>,
}
+/// Represents a keystroke that can be used in keybindings and displayed to the user.
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct KeybindingKeystroke {
+ /// The GPUI representation of the keystroke.
+ inner: Keystroke,
+ /// The modifiers to display.
+ #[cfg(target_os = "windows")]
+ display_modifiers: Modifiers,
+ /// The key to display.
+ #[cfg(target_os = "windows")]
+ display_key: String,
+}
+
/// Error type for `Keystroke::parse`. This is used instead of `anyhow::Error` so that Zed can use
/// markdown to display it.
#[derive(Debug)]
@@ -58,7 +79,7 @@ impl Keystroke {
///
/// This method assumes that `self` was typed and `target' is in the keymap, and checks
/// both possibilities for self against the target.
- pub fn should_match(&self, target: &Keystroke) -> bool {
+ pub fn should_match(&self, target: &KeybindingKeystroke) -> bool {
#[cfg(not(target_os = "windows"))]
if let Some(key_char) = self
.key_char
@@ -71,7 +92,7 @@ impl Keystroke {
..Default::default()
};
- if &target.key == key_char && target.modifiers == ime_modifiers {
+ if &target.inner.key == key_char && target.inner.modifiers == ime_modifiers {
return true;
}
}
@@ -83,12 +104,12 @@ impl Keystroke {
.filter(|key_char| key_char != &&self.key)
{
// On Windows, if key_char is set, then the typed keystroke produced the key_char
- if &target.key == key_char && target.modifiers == Modifiers::none() {
+ if &target.inner.key == key_char && target.inner.modifiers == Modifiers::none() {
return true;
}
}
- target.modifiers == self.modifiers && target.key == self.key
+ target.inner.modifiers == self.modifiers && target.inner.key == self.key
}
/// key syntax is:
@@ -200,31 +221,7 @@ impl Keystroke {
/// Produces a representation of this key that Parse can understand.
pub fn unparse(&self) -> String {
- let mut str = String::new();
- if self.modifiers.function {
- str.push_str("fn-");
- }
- if self.modifiers.control {
- str.push_str("ctrl-");
- }
- if self.modifiers.alt {
- str.push_str("alt-");
- }
- if self.modifiers.platform {
- #[cfg(target_os = "macos")]
- str.push_str("cmd-");
-
- #[cfg(any(target_os = "linux", target_os = "freebsd"))]
- str.push_str("super-");
-
- #[cfg(target_os = "windows")]
- str.push_str("win-");
- }
- if self.modifiers.shift {
- str.push_str("shift-");
- }
- str.push_str(&self.key);
- str
+ unparse(&self.modifiers, &self.key)
}
/// Returns true if this keystroke left
@@ -266,6 +263,117 @@ impl Keystroke {
}
}
+impl KeybindingKeystroke {
+ #[cfg(target_os = "windows")]
+ pub(crate) fn new(inner: Keystroke, display_modifiers: Modifiers, display_key: String) -> Self {
+ KeybindingKeystroke {
+ inner,
+ display_modifiers,
+ display_key,
+ }
+ }
+
+ /// Create a new keybinding keystroke from the given keystroke using the given keyboard mapper.
+ pub fn new_with_mapper(
+ inner: Keystroke,
+ use_key_equivalents: bool,
+ keyboard_mapper: &dyn PlatformKeyboardMapper,
+ ) -> Self {
+ keyboard_mapper.map_key_equivalent(inner, use_key_equivalents)
+ }
+
+ /// Create a new keybinding keystroke from the given keystroke, without any platform-specific mapping.
+ pub fn from_keystroke(keystroke: Keystroke) -> Self {
+ #[cfg(target_os = "windows")]
+ {
+ let key = keystroke.key.clone();
+ let modifiers = keystroke.modifiers;
+ KeybindingKeystroke {
+ inner: keystroke,
+ display_modifiers: modifiers,
+ display_key: key,
+ }
+ }
+ #[cfg(not(target_os = "windows"))]
+ {
+ KeybindingKeystroke { inner: keystroke }
+ }
+ }
+
+ /// Returns the GPUI representation of the keystroke.
+ pub fn inner(&self) -> &Keystroke {
+ &self.inner
+ }
+
+ /// Returns the modifiers.
+ ///
+ /// Platform-specific behavior:
+ /// - On macOS and Linux, this modifiers is the same as `inner.modifiers`, which is the GPUI representation of the keystroke.
+ /// - On Windows, this modifiers is the display modifiers, for example, a `ctrl-@` keystroke will have `inner.modifiers` as
+ /// `Modifiers::control()` and `display_modifiers` as `Modifiers::control_shift()`.
+ pub fn modifiers(&self) -> &Modifiers {
+ #[cfg(target_os = "windows")]
+ {
+ &self.display_modifiers
+ }
+ #[cfg(not(target_os = "windows"))]
+ {
+ &self.inner.modifiers
+ }
+ }
+
+ /// Returns the key.
+ ///
+ /// Platform-specific behavior:
+ /// - On macOS and Linux, this key is the same as `inner.key`, which is the GPUI representation of the keystroke.
+ /// - On Windows, this key is the display key, for example, a `ctrl-@` keystroke will have `inner.key` as `@` and `display_key` as `2`.
+ pub fn key(&self) -> &str {
+ #[cfg(target_os = "windows")]
+ {
+ &self.display_key
+ }
+ #[cfg(not(target_os = "windows"))]
+ {
+ &self.inner.key
+ }
+ }
+
+ /// Sets the modifiers. On Windows this modifies both `inner.modifiers` and `display_modifiers`.
+ pub fn set_modifiers(&mut self, modifiers: Modifiers) {
+ self.inner.modifiers = modifiers;
+ #[cfg(target_os = "windows")]
+ {
+ self.display_modifiers = modifiers;
+ }
+ }
+
+ /// Sets the key. On Windows this modifies both `inner.key` and `display_key`.
+ pub fn set_key(&mut self, key: String) {
+ #[cfg(target_os = "windows")]
+ {
+ self.display_key = key.clone();
+ }
+ self.inner.key = key;
+ }
+
+ /// Produces a representation of this key that Parse can understand.
+ pub fn unparse(&self) -> String {
+ #[cfg(target_os = "windows")]
+ {
+ unparse(&self.display_modifiers, &self.display_key)
+ }
+ #[cfg(not(target_os = "windows"))]
+ {
+ unparse(&self.inner.modifiers, &self.inner.key)
+ }
+ }
+
+ /// Removes the key_char
+ pub fn remove_key_char(&mut self) {
+ self.inner.key_char = None;
+ }
+}
+
fn is_printable_key(key: &str) -> bool {
!matches!(
key,
@@ -322,65 +430,15 @@ fn is_printable_key(key: &str) -> bool {
impl std::fmt::Display for Keystroke {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- if self.modifiers.control {
- #[cfg(target_os = "macos")]
- f.write_char('^')?;
-
- #[cfg(not(target_os = "macos"))]
- write!(f, "ctrl-")?;
- }
- if self.modifiers.alt {
- #[cfg(target_os = "macos")]
- f.write_char('⌥')?;
-
- #[cfg(not(target_os = "macos"))]
- write!(f, "alt-")?;
- }
- if self.modifiers.platform {
- #[cfg(target_os = "macos")]
- f.write_char('⌘')?;
-
- #[cfg(any(target_os = "linux", target_os = "freebsd"))]
- f.write_char('❖')?;
-
- #[cfg(target_os = "windows")]
- f.write_char('⊞')?;
- }
- if self.modifiers.shift {
- #[cfg(target_os = "macos")]
- f.write_char('⇧')?;
+ display_modifiers(&self.modifiers, f)?;
+ display_key(&self.key, f)
+ }
+}
- #[cfg(not(target_os = "macos"))]
- write!(f, "shift-")?;
- }
- let key = match self.key.as_str() {
- #[cfg(target_os = "macos")]
- "backspace" => '⌫',
- #[cfg(target_os = "macos")]
- "up" => '↑',
- #[cfg(target_os = "macos")]
- "down" => '↓',
- #[cfg(target_os = "macos")]
- "left" => '←',
- #[cfg(target_os = "macos")]
- "right" => '→',
- #[cfg(target_os = "macos")]
- "tab" => '⇥',
- #[cfg(target_os = "macos")]
- "escape" => '⎋',
- #[cfg(target_os = "macos")]
- "shift" => '⇧',
- #[cfg(target_os = "macos")]
- "control" => '⌃',
- #[cfg(target_os = "macos")]
- "alt" => '⌥',
- #[cfg(target_os = "macos")]
- "platform" => '⌘',
-
- key if key.len() == 1 => key.chars().next().unwrap().to_ascii_uppercase(),
- key => return f.write_str(key),
- };
- f.write_char(key)
+impl std::fmt::Display for KeybindingKeystroke {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ display_modifiers(self.modifiers(), f)?;
+ display_key(self.key(), f)
}
}
@@ -600,3 +658,110 @@ pub struct Capslock {
#[serde(default)]
pub on: bool,
}
+
+impl AsKeystroke for Keystroke {
+ fn as_keystroke(&self) -> &Keystroke {
+ self
+ }
+}
+
+impl AsKeystroke for KeybindingKeystroke {
+ fn as_keystroke(&self) -> &Keystroke {
+ &self.inner
+ }
+}
+
+fn display_modifiers(modifiers: &Modifiers, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ if modifiers.control {
+ #[cfg(target_os = "macos")]
+ f.write_char('^')?;
+
+ #[cfg(not(target_os = "macos"))]
+ write!(f, "ctrl-")?;
+ }
+ if modifiers.alt {
+ #[cfg(target_os = "macos")]
+ f.write_char('⌥')?;
+
+ #[cfg(not(target_os = "macos"))]
+ write!(f, "alt-")?;
+ }
+ if modifiers.platform {
+ #[cfg(target_os = "macos")]
+ f.write_char('⌘')?;
+
+ #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+ f.write_char('❖')?;
+
+ #[cfg(target_os = "windows")]
+ f.write_char('⊞')?;
+ }
+ if modifiers.shift {
+ #[cfg(target_os = "macos")]
+ f.write_char('⇧')?;
+
+ #[cfg(not(target_os = "macos"))]
+ write!(f, "shift-")?;
+ }
+ Ok(())
+}
+
+fn display_key(key: &str, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let key = match key {
+ #[cfg(target_os = "macos")]
+ "backspace" => '⌫',
+ #[cfg(target_os = "macos")]
+ "up" => '↑',
+ #[cfg(target_os = "macos")]
+ "down" => '↓',
+ #[cfg(target_os = "macos")]
+ "left" => '←',
+ #[cfg(target_os = "macos")]
+ "right" => '→',
+ #[cfg(target_os = "macos")]
+ "tab" => '⇥',
+ #[cfg(target_os = "macos")]
+ "escape" => '⎋',
+ #[cfg(target_os = "macos")]
+ "shift" => '⇧',
+ #[cfg(target_os = "macos")]
+ "control" => '⌃',
+ #[cfg(target_os = "macos")]
+ "alt" => '⌥',
+ #[cfg(target_os = "macos")]
+ "platform" => '⌘',
+
+ key if key.len() == 1 => key.chars().next().unwrap().to_ascii_uppercase(),
+ key => return f.write_str(key),
+ };
+ f.write_char(key)
+}
+
+#[inline]
+fn unparse(modifiers: &Modifiers, key: &str) -> String {
+ let mut result = String::new();
+ if modifiers.function {
+ result.push_str("fn-");
+ }
+ if modifiers.control {
+ result.push_str("ctrl-");
+ }
+ if modifiers.alt {
+ result.push_str("alt-");
+ }
+ if modifiers.platform {
+ #[cfg(target_os = "macos")]
+ result.push_str("cmd-");
+
+ #[cfg(any(target_os = "linux", target_os = "freebsd"))]
+ result.push_str("super-");
+
+ #[cfg(target_os = "windows")]
+ result.push_str("win-");
+ }
+ if modifiers.shift {
+ result.push_str("shift-");
+ }
+ result.push_str(&key);
+ result
+}
@@ -25,8 +25,8 @@ use xkbcommon::xkb::{self, Keycode, Keysym, State};
use crate::{
Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId,
ForegroundExecutor, Keymap, LinuxDispatcher, Menu, MenuItem, OwnedMenu, PathPromptOptions,
- Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformTextSystem, PlatformWindow,
- Point, Result, Task, WindowAppearance, WindowParams, px,
+ Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper,
+ PlatformTextSystem, PlatformWindow, Point, Result, Task, WindowAppearance, WindowParams, px,
};
#[cfg(any(feature = "wayland", feature = "x11"))]
@@ -144,6 +144,10 @@ impl<P: LinuxClient + 'static> Platform for P {
self.keyboard_layout()
}
+ fn keyboard_mapper(&self) -> Rc<dyn PlatformKeyboardMapper> {
+ Rc::new(crate::DummyKeyboardMapper)
+ }
+
fn on_keyboard_layout_change(&self, callback: Box<dyn FnMut()>) {
self.with_common(|common| common.callbacks.keyboard_layout_change = Some(callback));
}
@@ -1,8 +1,9 @@
+use collections::HashMap;
use std::ffi::{CStr, c_void};
use objc::{msg_send, runtime::Object, sel, sel_impl};
-use crate::PlatformKeyboardLayout;
+use crate::{KeybindingKeystroke, Keystroke, PlatformKeyboardLayout, PlatformKeyboardMapper};
use super::{
TISCopyCurrentKeyboardLayoutInputSource, TISGetInputSourceProperty, kTISPropertyInputSourceID,
@@ -14,6 +15,10 @@ pub(crate) struct MacKeyboardLayout {
name: String,
}
+pub(crate) struct MacKeyboardMapper {
+ key_equivalents: Option<HashMap<char, char>>,
+}
+
impl PlatformKeyboardLayout for MacKeyboardLayout {
fn id(&self) -> &str {
&self.id
@@ -24,6 +29,27 @@ impl PlatformKeyboardLayout for MacKeyboardLayout {
}
}
+impl PlatformKeyboardMapper for MacKeyboardMapper {
+ fn map_key_equivalent(
+ &self,
+ mut keystroke: Keystroke,
+ use_key_equivalents: bool,
+ ) -> KeybindingKeystroke {
+ if use_key_equivalents && let Some(key_equivalents) = &self.key_equivalents {
+ if keystroke.key.chars().count() == 1
+ && let Some(key) = key_equivalents.get(&keystroke.key.chars().next().unwrap())
+ {
+ keystroke.key = key.to_string();
+ }
+ }
+ KeybindingKeystroke::from_keystroke(keystroke)
+ }
+
+ fn get_key_equivalents(&self) -> Option<&HashMap<char, char>> {
+ self.key_equivalents.as_ref()
+ }
+}
+
impl MacKeyboardLayout {
pub(crate) fn new() -> Self {
unsafe {
@@ -47,3 +73,1428 @@ impl MacKeyboardLayout {
}
}
}
+
+impl MacKeyboardMapper {
+ pub(crate) fn new(layout_id: &str) -> Self {
+ let key_equivalents = get_key_equivalents(layout_id);
+
+ Self { key_equivalents }
+ }
+}
+
+// On some keyboards (e.g. German QWERTZ) it is not possible to type the full ASCII range
+// without using option. This means that some of our built in keyboard shortcuts do not work
+// for those users.
+//
+// The way macOS solves this problem is to move shortcuts around so that they are all reachable,
+// even if the mnemonic changes. https://developer.apple.com/documentation/swiftui/keyboardshortcut/localization-swift.struct
+//
+// For example, cmd-> is the "switch window" shortcut because the > key is right above tab.
+// To ensure this doesn't cause problems for shortcuts defined for a QWERTY layout, apple moves
+// any shortcuts defined as cmd-> to cmd-:. Coincidentally this s also the same keyboard position
+// as cmd-> on a QWERTY layout.
+//
+// Another example is cmd-[ and cmd-], as they cannot be typed without option, those keys are remapped to cmd-ö
+// and cmd-ä. These shortcuts are not in the same position as a QWERTY keyboard, because on a QWERTZ keyboard
+// the + key is in the way; and shortcuts bound to cmd-+ are still typed as cmd-+ on either keyboard (though the
+// specific key moves)
+//
+// As far as I can tell, there's no way to query the mappings Apple uses except by rendering a menu with every
+// possible key combination, and inspecting the UI to see what it rendered. So that's what we did...
+//
+// These mappings were generated by running https://github.com/ConradIrwin/keyboard-inspector, tidying up the
+// output to remove languages with no mappings and other oddities, and converting it to a less verbose representation with:
+// jq -s 'map(to_entries | map({key: .key, value: [(.value | to_entries | map(.key) | join("")), (.value | to_entries | map(.value) | join(""))]}) | from_entries) | add'
+// From there I used multi-cursor to produce this match statement.
+fn get_key_equivalents(layout_id: &str) -> Option<HashMap<char, char>> {
+ let mappings: &[(char, char)] = match layout_id {
+ "com.apple.keylayout.ABC-AZERTY" => &[
+ ('!', '1'),
+ ('"', '%'),
+ ('#', '3'),
+ ('$', '4'),
+ ('%', '5'),
+ ('&', '7'),
+ ('(', '9'),
+ (')', '0'),
+ ('*', '8'),
+ ('.', ';'),
+ ('/', ':'),
+ ('0', 'à'),
+ ('1', '&'),
+ ('2', 'é'),
+ ('3', '"'),
+ ('4', '\''),
+ ('5', '('),
+ ('6', '§'),
+ ('7', 'è'),
+ ('8', '!'),
+ ('9', 'ç'),
+ (':', '°'),
+ (';', ')'),
+ ('<', '.'),
+ ('>', '/'),
+ ('@', '2'),
+ ('[', '^'),
+ ('\'', 'ù'),
+ ('\\', '`'),
+ (']', '$'),
+ ('^', '6'),
+ ('`', '<'),
+ ('{', '¨'),
+ ('|', '£'),
+ ('}', '*'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.ABC-QWERTZ" => &[
+ ('"', '`'),
+ ('#', '§'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', 'ß'),
+ (':', 'Ü'),
+ (';', 'ü'),
+ ('<', ';'),
+ ('=', '*'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ö'),
+ ('\'', '´'),
+ ('\\', '#'),
+ (']', 'ä'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Ö'),
+ ('|', '\''),
+ ('}', 'Ä'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Albanian" => &[
+ ('"', '\''),
+ (':', 'Ç'),
+ (';', 'ç'),
+ ('<', ';'),
+ ('>', ':'),
+ ('@', '"'),
+ ('\'', '@'),
+ ('\\', 'ë'),
+ ('`', '<'),
+ ('|', 'Ë'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Austrian" => &[
+ ('"', '`'),
+ ('#', '§'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', 'ß'),
+ (':', 'Ü'),
+ (';', 'ü'),
+ ('<', ';'),
+ ('=', '*'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ö'),
+ ('\'', '´'),
+ ('\\', '#'),
+ (']', 'ä'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Ö'),
+ ('|', '\''),
+ ('}', 'Ä'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Azeri" => &[
+ ('"', 'Ə'),
+ (',', 'ç'),
+ ('.', 'ş'),
+ ('/', '.'),
+ (':', 'I'),
+ (';', 'ı'),
+ ('<', 'Ç'),
+ ('>', 'Ş'),
+ ('?', ','),
+ ('W', 'Ü'),
+ ('[', 'ö'),
+ ('\'', 'ə'),
+ (']', 'ğ'),
+ ('w', 'ü'),
+ ('{', 'Ö'),
+ ('|', '/'),
+ ('}', 'Ğ'),
+ ],
+ "com.apple.keylayout.Belgian" => &[
+ ('!', '1'),
+ ('"', '%'),
+ ('#', '3'),
+ ('$', '4'),
+ ('%', '5'),
+ ('&', '7'),
+ ('(', '9'),
+ (')', '0'),
+ ('*', '8'),
+ ('.', ';'),
+ ('/', ':'),
+ ('0', 'à'),
+ ('1', '&'),
+ ('2', 'é'),
+ ('3', '"'),
+ ('4', '\''),
+ ('5', '('),
+ ('6', '§'),
+ ('7', 'è'),
+ ('8', '!'),
+ ('9', 'ç'),
+ (':', '°'),
+ (';', ')'),
+ ('<', '.'),
+ ('>', '/'),
+ ('@', '2'),
+ ('[', '^'),
+ ('\'', 'ù'),
+ ('\\', '`'),
+ (']', '$'),
+ ('^', '6'),
+ ('`', '<'),
+ ('{', '¨'),
+ ('|', '£'),
+ ('}', '*'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Brazilian-ABNT2" => &[
+ ('"', '`'),
+ ('/', 'ç'),
+ ('?', 'Ç'),
+ ('\'', '´'),
+ ('\\', '~'),
+ ('^', '¨'),
+ ('`', '\''),
+ ('|', '^'),
+ ('~', '"'),
+ ],
+ "com.apple.keylayout.Brazilian-Pro" => &[('^', 'ˆ'), ('~', '˜')],
+ "com.apple.keylayout.British" => &[('#', '£')],
+ "com.apple.keylayout.Canadian-CSA" => &[
+ ('"', 'È'),
+ ('/', 'é'),
+ ('<', '\''),
+ ('>', '"'),
+ ('?', 'É'),
+ ('[', '^'),
+ ('\'', 'è'),
+ ('\\', 'à'),
+ (']', 'ç'),
+ ('`', 'ù'),
+ ('{', '¨'),
+ ('|', 'À'),
+ ('}', 'Ç'),
+ ('~', 'Ù'),
+ ],
+ "com.apple.keylayout.Croatian" => &[
+ ('"', 'Ć'),
+ ('&', '\''),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ (':', 'Č'),
+ (';', 'č'),
+ ('<', ';'),
+ ('=', '*'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'š'),
+ ('\'', 'ć'),
+ ('\\', 'ž'),
+ (']', 'đ'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Š'),
+ ('|', 'Ž'),
+ ('}', 'Đ'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Croatian-PC" => &[
+ ('"', 'Ć'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '\''),
+ (':', 'Č'),
+ (';', 'č'),
+ ('<', ';'),
+ ('=', '*'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'š'),
+ ('\'', 'ć'),
+ ('\\', 'ž'),
+ (']', 'đ'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Š'),
+ ('|', 'Ž'),
+ ('}', 'Đ'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Czech" => &[
+ ('!', '1'),
+ ('"', '!'),
+ ('#', '3'),
+ ('$', '4'),
+ ('%', '5'),
+ ('&', '7'),
+ ('(', '9'),
+ (')', '0'),
+ ('*', '8'),
+ ('+', '%'),
+ ('/', '\''),
+ ('0', 'é'),
+ ('1', '+'),
+ ('2', 'ě'),
+ ('3', 'š'),
+ ('4', 'č'),
+ ('5', 'ř'),
+ ('6', 'ž'),
+ ('7', 'ý'),
+ ('8', 'á'),
+ ('9', 'í'),
+ (':', '"'),
+ (';', 'ů'),
+ ('<', '?'),
+ ('>', ':'),
+ ('?', 'ˇ'),
+ ('@', '2'),
+ ('[', 'ú'),
+ ('\'', '§'),
+ (']', ')'),
+ ('^', '6'),
+ ('`', '¨'),
+ ('{', 'Ú'),
+ ('}', '('),
+ ('~', '`'),
+ ],
+ "com.apple.keylayout.Czech-QWERTY" => &[
+ ('!', '1'),
+ ('"', '!'),
+ ('#', '3'),
+ ('$', '4'),
+ ('%', '5'),
+ ('&', '7'),
+ ('(', '9'),
+ (')', '0'),
+ ('*', '8'),
+ ('+', '%'),
+ ('/', '\''),
+ ('0', 'é'),
+ ('1', '+'),
+ ('2', 'ě'),
+ ('3', 'š'),
+ ('4', 'č'),
+ ('5', 'ř'),
+ ('6', 'ž'),
+ ('7', 'ý'),
+ ('8', 'á'),
+ ('9', 'í'),
+ (':', '"'),
+ (';', 'ů'),
+ ('<', '?'),
+ ('>', ':'),
+ ('?', 'ˇ'),
+ ('@', '2'),
+ ('[', 'ú'),
+ ('\'', '§'),
+ (']', ')'),
+ ('^', '6'),
+ ('`', '¨'),
+ ('{', 'Ú'),
+ ('}', '('),
+ ('~', '`'),
+ ],
+ "com.apple.keylayout.Danish" => &[
+ ('"', '^'),
+ ('$', '€'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '´'),
+ (':', 'Å'),
+ (';', 'å'),
+ ('<', ';'),
+ ('=', '`'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'æ'),
+ ('\'', '¨'),
+ ('\\', '\''),
+ (']', 'ø'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Æ'),
+ ('|', '*'),
+ ('}', 'Ø'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Faroese" => &[
+ ('"', 'Ø'),
+ ('$', '€'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '´'),
+ (':', 'Æ'),
+ (';', 'æ'),
+ ('<', ';'),
+ ('=', '`'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'å'),
+ ('\'', 'ø'),
+ ('\\', '\''),
+ (']', 'ð'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Å'),
+ ('|', '*'),
+ ('}', 'Ð'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Finnish" => &[
+ ('"', '^'),
+ ('$', '€'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '´'),
+ (':', 'Å'),
+ (';', 'å'),
+ ('<', ';'),
+ ('=', '`'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ö'),
+ ('\'', '¨'),
+ ('\\', '\''),
+ (']', 'ä'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Ö'),
+ ('|', '*'),
+ ('}', 'Ä'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.FinnishExtended" => &[
+ ('"', 'ˆ'),
+ ('$', '€'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '´'),
+ (':', 'Å'),
+ (';', 'å'),
+ ('<', ';'),
+ ('=', '`'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ö'),
+ ('\'', '¨'),
+ ('\\', '\''),
+ (']', 'ä'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Ö'),
+ ('|', '*'),
+ ('}', 'Ä'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.FinnishSami-PC" => &[
+ ('"', 'ˆ'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '´'),
+ (':', 'Å'),
+ (';', 'å'),
+ ('<', ';'),
+ ('=', '`'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ö'),
+ ('\'', '¨'),
+ ('\\', '@'),
+ (']', 'ä'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Ö'),
+ ('|', '*'),
+ ('}', 'Ä'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.French" => &[
+ ('!', '1'),
+ ('"', '%'),
+ ('#', '3'),
+ ('$', '4'),
+ ('%', '5'),
+ ('&', '7'),
+ ('(', '9'),
+ (')', '0'),
+ ('*', '8'),
+ ('.', ';'),
+ ('/', ':'),
+ ('0', 'à'),
+ ('1', '&'),
+ ('2', 'é'),
+ ('3', '"'),
+ ('4', '\''),
+ ('5', '('),
+ ('6', '§'),
+ ('7', 'è'),
+ ('8', '!'),
+ ('9', 'ç'),
+ (':', '°'),
+ (';', ')'),
+ ('<', '.'),
+ ('>', '/'),
+ ('@', '2'),
+ ('[', '^'),
+ ('\'', 'ù'),
+ ('\\', '`'),
+ (']', '$'),
+ ('^', '6'),
+ ('`', '<'),
+ ('{', '¨'),
+ ('|', '£'),
+ ('}', '*'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.French-PC" => &[
+ ('!', '1'),
+ ('"', '%'),
+ ('#', '3'),
+ ('$', '4'),
+ ('%', '5'),
+ ('&', '7'),
+ ('(', '9'),
+ (')', '0'),
+ ('*', '8'),
+ ('-', ')'),
+ ('.', ';'),
+ ('/', ':'),
+ ('0', 'à'),
+ ('1', '&'),
+ ('2', 'é'),
+ ('3', '"'),
+ ('4', '\''),
+ ('5', '('),
+ ('6', '-'),
+ ('7', 'è'),
+ ('8', '_'),
+ ('9', 'ç'),
+ (':', '§'),
+ (';', '!'),
+ ('<', '.'),
+ ('>', '/'),
+ ('@', '2'),
+ ('[', '^'),
+ ('\'', 'ù'),
+ ('\\', '*'),
+ (']', '$'),
+ ('^', '6'),
+ ('_', '°'),
+ ('`', '<'),
+ ('{', '¨'),
+ ('|', 'μ'),
+ ('}', '£'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.French-numerical" => &[
+ ('!', '1'),
+ ('"', '%'),
+ ('#', '3'),
+ ('$', '4'),
+ ('%', '5'),
+ ('&', '7'),
+ ('(', '9'),
+ (')', '0'),
+ ('*', '8'),
+ ('.', ';'),
+ ('/', ':'),
+ ('0', 'à'),
+ ('1', '&'),
+ ('2', 'é'),
+ ('3', '"'),
+ ('4', '\''),
+ ('5', '('),
+ ('6', '§'),
+ ('7', 'è'),
+ ('8', '!'),
+ ('9', 'ç'),
+ (':', '°'),
+ (';', ')'),
+ ('<', '.'),
+ ('>', '/'),
+ ('@', '2'),
+ ('[', '^'),
+ ('\'', 'ù'),
+ ('\\', '`'),
+ (']', '$'),
+ ('^', '6'),
+ ('`', '<'),
+ ('{', '¨'),
+ ('|', '£'),
+ ('}', '*'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.German" => &[
+ ('"', '`'),
+ ('#', '§'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', 'ß'),
+ (':', 'Ü'),
+ (';', 'ü'),
+ ('<', ';'),
+ ('=', '*'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ö'),
+ ('\'', '´'),
+ ('\\', '#'),
+ (']', 'ä'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Ö'),
+ ('|', '\''),
+ ('}', 'Ä'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.German-DIN-2137" => &[
+ ('"', '`'),
+ ('#', '§'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', 'ß'),
+ (':', 'Ü'),
+ (';', 'ü'),
+ ('<', ';'),
+ ('=', '*'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ö'),
+ ('\'', '´'),
+ ('\\', '#'),
+ (']', 'ä'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Ö'),
+ ('|', '\''),
+ ('}', 'Ä'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Hawaiian" => &[('\'', 'ʻ')],
+ "com.apple.keylayout.Hungarian" => &[
+ ('!', '\''),
+ ('"', 'Á'),
+ ('#', '+'),
+ ('$', '!'),
+ ('&', '='),
+ ('(', ')'),
+ (')', 'Ö'),
+ ('*', '('),
+ ('+', 'Ó'),
+ ('/', 'ü'),
+ ('0', 'ö'),
+ (':', 'É'),
+ (';', 'é'),
+ ('<', 'Ü'),
+ ('=', 'ó'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ő'),
+ ('\'', 'á'),
+ ('\\', 'ű'),
+ (']', 'ú'),
+ ('^', '/'),
+ ('`', 'í'),
+ ('{', 'Ő'),
+ ('|', 'Ű'),
+ ('}', 'Ú'),
+ ('~', 'Í'),
+ ],
+ "com.apple.keylayout.Hungarian-QWERTY" => &[
+ ('!', '\''),
+ ('"', 'Á'),
+ ('#', '+'),
+ ('$', '!'),
+ ('&', '='),
+ ('(', ')'),
+ (')', 'Ö'),
+ ('*', '('),
+ ('+', 'Ó'),
+ ('/', 'ü'),
+ ('0', 'ö'),
+ (':', 'É'),
+ (';', 'é'),
+ ('<', 'Ü'),
+ ('=', 'ó'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ő'),
+ ('\'', 'á'),
+ ('\\', 'ű'),
+ (']', 'ú'),
+ ('^', '/'),
+ ('`', 'í'),
+ ('{', 'Ő'),
+ ('|', 'Ű'),
+ ('}', 'Ú'),
+ ('~', 'Í'),
+ ],
+ "com.apple.keylayout.Icelandic" => &[
+ ('"', 'Ö'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '\''),
+ (':', 'Ð'),
+ (';', 'ð'),
+ ('<', ';'),
+ ('=', '*'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'æ'),
+ ('\'', 'ö'),
+ ('\\', 'þ'),
+ (']', '´'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Æ'),
+ ('|', 'Þ'),
+ ('}', '´'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Irish" => &[('#', '£')],
+ "com.apple.keylayout.IrishExtended" => &[('#', '£')],
+ "com.apple.keylayout.Italian" => &[
+ ('!', '1'),
+ ('"', '%'),
+ ('#', '3'),
+ ('$', '4'),
+ ('%', '5'),
+ ('&', '7'),
+ ('(', '9'),
+ (')', '0'),
+ ('*', '8'),
+ (',', ';'),
+ ('.', ':'),
+ ('/', ','),
+ ('0', 'é'),
+ ('1', '&'),
+ ('2', '"'),
+ ('3', '\''),
+ ('4', '('),
+ ('5', 'ç'),
+ ('6', 'è'),
+ ('7', ')'),
+ ('8', '£'),
+ ('9', 'à'),
+ (':', '!'),
+ (';', 'ò'),
+ ('<', '.'),
+ ('>', '/'),
+ ('@', '2'),
+ ('[', 'ì'),
+ ('\'', 'ù'),
+ ('\\', '§'),
+ (']', '$'),
+ ('^', '6'),
+ ('`', '<'),
+ ('{', '^'),
+ ('|', '°'),
+ ('}', '*'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Italian-Pro" => &[
+ ('"', '^'),
+ ('#', '£'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '\''),
+ (':', 'é'),
+ (';', 'è'),
+ ('<', ';'),
+ ('=', '*'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ò'),
+ ('\'', 'ì'),
+ ('\\', 'ù'),
+ (']', 'à'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'ç'),
+ ('|', '§'),
+ ('}', '°'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.LatinAmerican" => &[
+ ('"', '¨'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '\''),
+ (':', 'Ñ'),
+ (';', 'ñ'),
+ ('<', ';'),
+ ('=', '*'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', '{'),
+ ('\'', '´'),
+ ('\\', '¿'),
+ (']', '}'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', '['),
+ ('|', '¡'),
+ ('}', ']'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Lithuanian" => &[
+ ('!', 'Ą'),
+ ('#', 'Ę'),
+ ('$', 'Ė'),
+ ('%', 'Į'),
+ ('&', 'Ų'),
+ ('*', 'Ū'),
+ ('+', 'Ž'),
+ ('1', 'ą'),
+ ('2', 'č'),
+ ('3', 'ę'),
+ ('4', 'ė'),
+ ('5', 'į'),
+ ('6', 'š'),
+ ('7', 'ų'),
+ ('8', 'ū'),
+ ('=', 'ž'),
+ ('@', 'Č'),
+ ('^', 'Š'),
+ ],
+ "com.apple.keylayout.Maltese" => &[
+ ('#', '£'),
+ ('[', 'ġ'),
+ (']', 'ħ'),
+ ('`', 'ż'),
+ ('{', 'Ġ'),
+ ('}', 'Ħ'),
+ ('~', 'Ż'),
+ ],
+ "com.apple.keylayout.NorthernSami" => &[
+ ('"', 'Ŋ'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '´'),
+ (':', 'Å'),
+ (';', 'å'),
+ ('<', ';'),
+ ('=', '`'),
+ ('>', ':'),
+ ('@', '"'),
+ ('Q', 'Á'),
+ ('W', 'Š'),
+ ('X', 'Č'),
+ ('[', 'ø'),
+ ('\'', 'ŋ'),
+ ('\\', 'đ'),
+ (']', 'æ'),
+ ('^', '&'),
+ ('`', 'ž'),
+ ('q', 'á'),
+ ('w', 'š'),
+ ('x', 'č'),
+ ('{', 'Ø'),
+ ('|', 'Đ'),
+ ('}', 'Æ'),
+ ('~', 'Ž'),
+ ],
+ "com.apple.keylayout.Norwegian" => &[
+ ('"', '^'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '´'),
+ (':', 'Å'),
+ (';', 'å'),
+ ('<', ';'),
+ ('=', '`'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ø'),
+ ('\'', '¨'),
+ ('\\', '@'),
+ (']', 'æ'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Ø'),
+ ('|', '*'),
+ ('}', 'Æ'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.NorwegianExtended" => &[
+ ('"', 'ˆ'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '´'),
+ (':', 'Å'),
+ (';', 'å'),
+ ('<', ';'),
+ ('=', '`'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ø'),
+ ('\\', '@'),
+ (']', 'æ'),
+ ('`', '<'),
+ ('}', 'Æ'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.NorwegianSami-PC" => &[
+ ('"', 'ˆ'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '´'),
+ (':', 'Å'),
+ (';', 'å'),
+ ('<', ';'),
+ ('=', '`'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ø'),
+ ('\'', '¨'),
+ ('\\', '@'),
+ (']', 'æ'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Ø'),
+ ('|', '*'),
+ ('}', 'Æ'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Polish" => &[
+ ('!', '§'),
+ ('"', 'ę'),
+ ('#', '!'),
+ ('$', '?'),
+ ('%', '+'),
+ ('&', ':'),
+ ('(', '/'),
+ (')', '"'),
+ ('*', '_'),
+ ('+', ']'),
+ (',', '.'),
+ ('.', ','),
+ ('/', 'ż'),
+ (':', 'Ł'),
+ (';', 'ł'),
+ ('<', 'ś'),
+ ('=', '['),
+ ('>', 'ń'),
+ ('?', 'Ż'),
+ ('@', '%'),
+ ('[', 'ó'),
+ ('\'', 'ą'),
+ ('\\', ';'),
+ (']', '('),
+ ('^', '='),
+ ('_', 'ć'),
+ ('`', '<'),
+ ('{', 'ź'),
+ ('|', '$'),
+ ('}', ')'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Portuguese" => &[
+ ('"', '`'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '\''),
+ (':', 'ª'),
+ (';', 'º'),
+ ('<', ';'),
+ ('=', '*'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ç'),
+ ('\'', '´'),
+ (']', '~'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Ç'),
+ ('}', '^'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Sami-PC" => &[
+ ('"', 'Ŋ'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '´'),
+ (':', 'Å'),
+ (';', 'å'),
+ ('<', ';'),
+ ('=', '`'),
+ ('>', ':'),
+ ('@', '"'),
+ ('Q', 'Á'),
+ ('W', 'Š'),
+ ('X', 'Č'),
+ ('[', 'ø'),
+ ('\'', 'ŋ'),
+ ('\\', 'đ'),
+ (']', 'æ'),
+ ('^', '&'),
+ ('`', 'ž'),
+ ('q', 'á'),
+ ('w', 'š'),
+ ('x', 'č'),
+ ('{', 'Ø'),
+ ('|', 'Đ'),
+ ('}', 'Æ'),
+ ('~', 'Ž'),
+ ],
+ "com.apple.keylayout.Serbian-Latin" => &[
+ ('"', 'Ć'),
+ ('&', '\''),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ (':', 'Č'),
+ (';', 'č'),
+ ('<', ';'),
+ ('=', '*'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'š'),
+ ('\'', 'ć'),
+ ('\\', 'ž'),
+ (']', 'đ'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Š'),
+ ('|', 'Ž'),
+ ('}', 'Đ'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Slovak" => &[
+ ('!', '1'),
+ ('"', '!'),
+ ('#', '3'),
+ ('$', '4'),
+ ('%', '5'),
+ ('&', '7'),
+ ('(', '9'),
+ (')', '0'),
+ ('*', '8'),
+ ('+', '%'),
+ ('/', '\''),
+ ('0', 'é'),
+ ('1', '+'),
+ ('2', 'ľ'),
+ ('3', 'š'),
+ ('4', 'č'),
+ ('5', 'ť'),
+ ('6', 'ž'),
+ ('7', 'ý'),
+ ('8', 'á'),
+ ('9', 'í'),
+ (':', '"'),
+ (';', 'ô'),
+ ('<', '?'),
+ ('>', ':'),
+ ('?', 'ˇ'),
+ ('@', '2'),
+ ('[', 'ú'),
+ ('\'', '§'),
+ (']', 'ä'),
+ ('^', '6'),
+ ('`', 'ň'),
+ ('{', 'Ú'),
+ ('}', 'Ä'),
+ ('~', 'Ň'),
+ ],
+ "com.apple.keylayout.Slovak-QWERTY" => &[
+ ('!', '1'),
+ ('"', '!'),
+ ('#', '3'),
+ ('$', '4'),
+ ('%', '5'),
+ ('&', '7'),
+ ('(', '9'),
+ (')', '0'),
+ ('*', '8'),
+ ('+', '%'),
+ ('/', '\''),
+ ('0', 'é'),
+ ('1', '+'),
+ ('2', 'ľ'),
+ ('3', 'š'),
+ ('4', 'č'),
+ ('5', 'ť'),
+ ('6', 'ž'),
+ ('7', 'ý'),
+ ('8', 'á'),
+ ('9', 'í'),
+ (':', '"'),
+ (';', 'ô'),
+ ('<', '?'),
+ ('>', ':'),
+ ('?', 'ˇ'),
+ ('@', '2'),
+ ('[', 'ú'),
+ ('\'', '§'),
+ (']', 'ä'),
+ ('^', '6'),
+ ('`', 'ň'),
+ ('{', 'Ú'),
+ ('}', 'Ä'),
+ ('~', 'Ň'),
+ ],
+ "com.apple.keylayout.Slovenian" => &[
+ ('"', 'Ć'),
+ ('&', '\''),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ (':', 'Č'),
+ (';', 'č'),
+ ('<', ';'),
+ ('=', '*'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'š'),
+ ('\'', 'ć'),
+ ('\\', 'ž'),
+ (']', 'đ'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Š'),
+ ('|', 'Ž'),
+ ('}', 'Đ'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Spanish" => &[
+ ('!', '¡'),
+ ('"', '¨'),
+ ('.', 'ç'),
+ ('/', '.'),
+ (':', 'º'),
+ (';', '´'),
+ ('<', '¿'),
+ ('>', 'Ç'),
+ ('@', '!'),
+ ('[', 'ñ'),
+ ('\'', '`'),
+ ('\\', '\''),
+ (']', ';'),
+ ('^', '/'),
+ ('`', '<'),
+ ('{', 'Ñ'),
+ ('|', '"'),
+ ('}', ':'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Spanish-ISO" => &[
+ ('"', '¨'),
+ ('#', '·'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('.', 'ç'),
+ ('/', '.'),
+ (':', 'º'),
+ (';', '´'),
+ ('<', '¿'),
+ ('>', 'Ç'),
+ ('@', '"'),
+ ('[', 'ñ'),
+ ('\'', '`'),
+ ('\\', '\''),
+ (']', ';'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Ñ'),
+ ('|', '"'),
+ ('}', '`'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Swedish" => &[
+ ('"', '^'),
+ ('$', '€'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '´'),
+ (':', 'Å'),
+ (';', 'å'),
+ ('<', ';'),
+ ('=', '`'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ö'),
+ ('\'', '¨'),
+ ('\\', '\''),
+ (']', 'ä'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Ö'),
+ ('|', '*'),
+ ('}', 'Ä'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Swedish-Pro" => &[
+ ('"', '^'),
+ ('$', '€'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '´'),
+ (':', 'Å'),
+ (';', 'å'),
+ ('<', ';'),
+ ('=', '`'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ö'),
+ ('\'', '¨'),
+ ('\\', '\''),
+ (']', 'ä'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Ö'),
+ ('|', '*'),
+ ('}', 'Ä'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.SwedishSami-PC" => &[
+ ('"', 'ˆ'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('/', '´'),
+ (':', 'Å'),
+ (';', 'å'),
+ ('<', ';'),
+ ('=', '`'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ö'),
+ ('\'', '¨'),
+ ('\\', '@'),
+ (']', 'ä'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Ö'),
+ ('|', '*'),
+ ('}', 'Ä'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.SwissFrench" => &[
+ ('!', '+'),
+ ('"', '`'),
+ ('#', '*'),
+ ('$', 'ç'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('+', '!'),
+ ('/', '\''),
+ (':', 'ü'),
+ (';', 'è'),
+ ('<', ';'),
+ ('=', '¨'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'é'),
+ ('\'', '^'),
+ ('\\', '$'),
+ (']', 'à'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'ö'),
+ ('|', '£'),
+ ('}', 'ä'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.SwissGerman" => &[
+ ('!', '+'),
+ ('"', '`'),
+ ('#', '*'),
+ ('$', 'ç'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('+', '!'),
+ ('/', '\''),
+ (':', 'è'),
+ (';', 'ü'),
+ ('<', ';'),
+ ('=', '¨'),
+ ('>', ':'),
+ ('@', '"'),
+ ('[', 'ö'),
+ ('\'', '^'),
+ ('\\', '$'),
+ (']', 'ä'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'é'),
+ ('|', '£'),
+ ('}', 'à'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Turkish" => &[
+ ('"', '-'),
+ ('#', '"'),
+ ('$', '\''),
+ ('%', '('),
+ ('&', ')'),
+ ('(', '%'),
+ (')', ':'),
+ ('*', '_'),
+ (',', 'ö'),
+ ('-', 'ş'),
+ ('.', 'ç'),
+ ('/', '.'),
+ (':', '$'),
+ ('<', 'Ö'),
+ ('>', 'Ç'),
+ ('@', '*'),
+ ('[', 'ğ'),
+ ('\'', ','),
+ ('\\', 'ü'),
+ (']', 'ı'),
+ ('^', '/'),
+ ('_', 'Ş'),
+ ('`', '<'),
+ ('{', 'Ğ'),
+ ('|', 'Ü'),
+ ('}', 'I'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Turkish-QWERTY-PC" => &[
+ ('"', 'I'),
+ ('#', '^'),
+ ('$', '+'),
+ ('&', '/'),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ ('+', ':'),
+ (',', 'ö'),
+ ('.', 'ç'),
+ ('/', '*'),
+ (':', 'Ş'),
+ (';', 'ş'),
+ ('<', 'Ö'),
+ ('=', '.'),
+ ('>', 'Ç'),
+ ('@', '\''),
+ ('[', 'ğ'),
+ ('\'', 'ı'),
+ ('\\', ','),
+ (']', 'ü'),
+ ('^', '&'),
+ ('`', '<'),
+ ('{', 'Ğ'),
+ ('|', ';'),
+ ('}', 'Ü'),
+ ('~', '>'),
+ ],
+ "com.apple.keylayout.Turkish-Standard" => &[
+ ('"', 'Ş'),
+ ('#', '^'),
+ ('&', '\''),
+ ('(', ')'),
+ (')', '='),
+ ('*', '('),
+ (',', '.'),
+ ('.', ','),
+ (':', 'Ç'),
+ (';', 'ç'),
+ ('<', ':'),
+ ('=', '*'),
+ ('>', ';'),
+ ('@', '"'),
+ ('[', 'ğ'),
+ ('\'', 'ş'),
+ ('\\', 'ü'),
+ (']', 'ı'),
+ ('^', '&'),
+ ('`', 'ö'),
+ ('{', 'Ğ'),
+ ('|', 'Ü'),
+ ('}', 'I'),
+ ('~', 'Ö'),
+ ],
+ "com.apple.keylayout.Turkmen" => &[
+ ('C', 'Ç'),
+ ('Q', 'Ä'),
+ ('V', 'Ý'),
+ ('X', 'Ü'),
+ ('[', 'ň'),
+ ('\\', 'ş'),
+ (']', 'ö'),
+ ('^', '№'),
+ ('`', 'ž'),
+ ('c', 'ç'),
+ ('q', 'ä'),
+ ('v', 'ý'),
+ ('x', 'ü'),
+ ('{', 'Ň'),
+ ('|', 'Ş'),
+ ('}', 'Ö'),
+ ('~', 'Ž'),
+ ],
+ "com.apple.keylayout.USInternational-PC" => &[('^', 'ˆ'), ('~', '˜')],
+ "com.apple.keylayout.Welsh" => &[('#', '£')],
+
+ _ => return None,
+ };
+
+ Some(HashMap::from_iter(mappings.iter().cloned()))
+}
@@ -1,5 +1,5 @@
use super::{
- BoolExt, MacKeyboardLayout,
+ BoolExt, MacKeyboardLayout, MacKeyboardMapper,
attributed_string::{NSAttributedString, NSMutableAttributedString},
events::key_to_native,
renderer,
@@ -8,8 +8,9 @@ use crate::{
Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString,
CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher,
MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform,
- PlatformDisplay, PlatformKeyboardLayout, PlatformTextSystem, PlatformWindow, Result,
- SemanticVersion, SystemMenuType, Task, WindowAppearance, WindowParams, hash,
+ PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem,
+ PlatformWindow, Result, SemanticVersion, SystemMenuType, Task, WindowAppearance, WindowParams,
+ hash,
};
use anyhow::{Context as _, anyhow};
use block::ConcreteBlock;
@@ -171,6 +172,7 @@ pub(crate) struct MacPlatformState {
finish_launching: Option<Box<dyn FnOnce()>>,
dock_menu: Option<id>,
menus: Option<Vec<OwnedMenu>>,
+ keyboard_mapper: Rc<MacKeyboardMapper>,
}
impl Default for MacPlatform {
@@ -189,6 +191,9 @@ impl MacPlatform {
#[cfg(not(feature = "font-kit"))]
let text_system = Arc::new(crate::NoopTextSystem::new());
+ let keyboard_layout = MacKeyboardLayout::new();
+ let keyboard_mapper = Rc::new(MacKeyboardMapper::new(keyboard_layout.id()));
+
Self(Mutex::new(MacPlatformState {
headless,
text_system,
@@ -209,6 +214,7 @@ impl MacPlatform {
dock_menu: None,
on_keyboard_layout_change: None,
menus: None,
+ keyboard_mapper,
}))
}
@@ -348,19 +354,19 @@ impl MacPlatform {
let mut mask = NSEventModifierFlags::empty();
for (modifier, flag) in &[
(
- keystroke.modifiers.platform,
+ keystroke.modifiers().platform,
NSEventModifierFlags::NSCommandKeyMask,
),
(
- keystroke.modifiers.control,
+ keystroke.modifiers().control,
NSEventModifierFlags::NSControlKeyMask,
),
(
- keystroke.modifiers.alt,
+ keystroke.modifiers().alt,
NSEventModifierFlags::NSAlternateKeyMask,
),
(
- keystroke.modifiers.shift,
+ keystroke.modifiers().shift,
NSEventModifierFlags::NSShiftKeyMask,
),
] {
@@ -373,7 +379,7 @@ impl MacPlatform {
.initWithTitle_action_keyEquivalent_(
ns_string(name),
selector,
- ns_string(key_to_native(&keystroke.key).as_ref()),
+ ns_string(key_to_native(keystroke.key()).as_ref()),
)
.autorelease();
if Self::os_version() >= SemanticVersion::new(12, 0, 0) {
@@ -882,6 +888,10 @@ impl Platform for MacPlatform {
Box::new(MacKeyboardLayout::new())
}
+ fn keyboard_mapper(&self) -> Rc<dyn PlatformKeyboardMapper> {
+ self.0.lock().keyboard_mapper.clone()
+ }
+
fn app_path(&self) -> Result<PathBuf> {
unsafe {
let bundle: id = NSBundle::mainBundle();
@@ -1393,6 +1403,8 @@ extern "C" fn will_terminate(this: &mut Object, _: Sel, _: id) {
extern "C" fn on_keyboard_layout_change(this: &mut Object, _: Sel, _: id) {
let platform = unsafe { get_mac_platform(this) };
let mut lock = platform.0.lock();
+ let keyboard_layout = MacKeyboardLayout::new();
+ lock.keyboard_mapper = Rc::new(MacKeyboardMapper::new(keyboard_layout.id()));
if let Some(mut callback) = lock.on_keyboard_layout_change.take() {
drop(lock);
callback();
@@ -4,8 +4,10 @@ use crate::{
ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay,
PlatformInput, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions,
- ScaledPixels, Size, Timer, WindowAppearance, WindowBackgroundAppearance, WindowBounds,
- WindowControlArea, WindowKind, WindowParams, platform::PlatformInputHandler, point, px, size,
+ ScaledPixels, SharedString, Size, SystemWindowTab, Timer, WindowAppearance,
+ WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, WindowParams,
+ dispatch_get_main_queue, dispatch_sys::dispatch_async_f, platform::PlatformInputHandler, point,
+ px, size,
};
use block::ConcreteBlock;
use cocoa::{
@@ -24,6 +26,7 @@ use cocoa::{
NSUserDefaults,
},
};
+
use core_graphics::display::{CGDirectDisplayID, CGPoint, CGRect};
use ctor::ctor;
use futures::channel::oneshot;
@@ -82,6 +85,12 @@ type NSDragOperation = NSUInteger;
const NSDragOperationNone: NSDragOperation = 0;
#[allow(non_upper_case_globals)]
const NSDragOperationCopy: NSDragOperation = 1;
+#[derive(PartialEq)]
+pub enum UserTabbingPreference {
+ Never,
+ Always,
+ InFullScreen,
+}
#[link(name = "CoreGraphics", kind = "framework")]
unsafe extern "C" {
@@ -343,6 +352,36 @@ unsafe fn build_window_class(name: &'static str, superclass: &Class) -> *const C
conclude_drag_operation as extern "C" fn(&Object, Sel, id),
);
+ decl.add_method(
+ sel!(addTitlebarAccessoryViewController:),
+ add_titlebar_accessory_view_controller as extern "C" fn(&Object, Sel, id),
+ );
+
+ decl.add_method(
+ sel!(moveTabToNewWindow:),
+ move_tab_to_new_window as extern "C" fn(&Object, Sel, id),
+ );
+
+ decl.add_method(
+ sel!(mergeAllWindows:),
+ merge_all_windows as extern "C" fn(&Object, Sel, id),
+ );
+
+ decl.add_method(
+ sel!(selectNextTab:),
+ select_next_tab as extern "C" fn(&Object, Sel, id),
+ );
+
+ decl.add_method(
+ sel!(selectPreviousTab:),
+ select_previous_tab as extern "C" fn(&Object, Sel, id),
+ );
+
+ decl.add_method(
+ sel!(toggleTabBar:),
+ toggle_tab_bar as extern "C" fn(&Object, Sel, id),
+ );
+
decl.register()
}
}
@@ -375,6 +414,11 @@ struct MacWindowState {
// Whether the next left-mouse click is also the focusing click.
first_mouse: bool,
fullscreen_restore_bounds: Bounds<Pixels>,
+ move_tab_to_new_window_callback: Option<Box<dyn FnMut()>>,
+ merge_all_windows_callback: Option<Box<dyn FnMut()>>,
+ select_next_tab_callback: Option<Box<dyn FnMut()>>,
+ select_previous_tab_callback: Option<Box<dyn FnMut()>>,
+ toggle_tab_bar_callback: Option<Box<dyn FnMut()>>,
}
impl MacWindowState {
@@ -530,10 +574,13 @@ impl MacWindow {
titlebar,
kind,
is_movable,
+ is_resizable,
+ is_minimizable,
focus,
show,
display_id,
window_min_size,
+ tabbing_identifier,
}: WindowParams,
executor: ForegroundExecutor,
renderer_context: renderer::Context,
@@ -541,14 +588,25 @@ impl MacWindow {
unsafe {
let pool = NSAutoreleasePool::new(nil);
- let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: NO];
+ let allows_automatic_window_tabbing = tabbing_identifier.is_some();
+ if allows_automatic_window_tabbing {
+ let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: YES];
+ } else {
+ let () = msg_send![class!(NSWindow), setAllowsAutomaticWindowTabbing: NO];
+ }
let mut style_mask;
if let Some(titlebar) = titlebar.as_ref() {
- style_mask = NSWindowStyleMask::NSClosableWindowMask
- | NSWindowStyleMask::NSMiniaturizableWindowMask
- | NSWindowStyleMask::NSResizableWindowMask
- | NSWindowStyleMask::NSTitledWindowMask;
+ style_mask =
+ NSWindowStyleMask::NSClosableWindowMask | NSWindowStyleMask::NSTitledWindowMask;
+
+ if is_resizable {
+ style_mask |= NSWindowStyleMask::NSResizableWindowMask;
+ }
+
+ if is_minimizable {
+ style_mask |= NSWindowStyleMask::NSMiniaturizableWindowMask;
+ }
if titlebar.appears_transparent {
style_mask |= NSWindowStyleMask::NSFullSizeContentViewWindowMask;
@@ -660,6 +718,11 @@ impl MacWindow {
external_files_dragged: false,
first_mouse: false,
fullscreen_restore_bounds: Bounds::default(),
+ move_tab_to_new_window_callback: None,
+ merge_all_windows_callback: None,
+ select_next_tab_callback: None,
+ select_previous_tab_callback: None,
+ toggle_tab_bar_callback: None,
})));
(*native_window).set_ivar(
@@ -714,6 +777,11 @@ impl MacWindow {
WindowKind::Normal => {
native_window.setLevel_(NSNormalWindowLevel);
native_window.setAcceptsMouseMovedEvents_(YES);
+
+ if let Some(tabbing_identifier) = tabbing_identifier {
+ let tabbing_id = NSString::alloc(nil).init_str(tabbing_identifier.as_str());
+ let _: () = msg_send![native_window, setTabbingIdentifier: tabbing_id];
+ }
}
WindowKind::PopUp => {
// Use a tracking area to allow receiving MouseMoved events even when
@@ -742,6 +810,38 @@ impl MacWindow {
}
}
+ let app = NSApplication::sharedApplication(nil);
+ let main_window: id = msg_send![app, mainWindow];
+ if allows_automatic_window_tabbing
+ && !main_window.is_null()
+ && main_window != native_window
+ {
+ let main_window_is_fullscreen = main_window
+ .styleMask()
+ .contains(NSWindowStyleMask::NSFullScreenWindowMask);
+ let user_tabbing_preference = Self::get_user_tabbing_preference()
+ .unwrap_or(UserTabbingPreference::InFullScreen);
+ let should_add_as_tab = user_tabbing_preference == UserTabbingPreference::Always
+ || user_tabbing_preference == UserTabbingPreference::InFullScreen
+ && main_window_is_fullscreen;
+
+ if should_add_as_tab {
+ let main_window_can_tab: BOOL =
+ msg_send![main_window, respondsToSelector: sel!(addTabbedWindow:ordered:)];
+ let main_window_visible: BOOL = msg_send![main_window, isVisible];
+
+ if main_window_can_tab == YES && main_window_visible == YES {
+ let _: () = msg_send![main_window, addTabbedWindow: native_window ordered: NSWindowOrderingMode::NSWindowAbove];
+
+ // Ensure the window is visible immediately after adding the tab, since the tab bar is updated with a new entry at this point.
+ // Note: Calling orderFront here can break fullscreen mode (makes fullscreen windows exit fullscreen), so only do this if the main window is not fullscreen.
+ if !main_window_is_fullscreen {
+ let _: () = msg_send![native_window, orderFront: nil];
+ }
+ }
+ }
+ }
+
if focus && show {
native_window.makeKeyAndOrderFront_(nil);
} else if show {
@@ -796,6 +896,33 @@ impl MacWindow {
window_handles
}
}
+
+ pub fn get_user_tabbing_preference() -> Option<UserTabbingPreference> {
+ unsafe {
+ let defaults: id = NSUserDefaults::standardUserDefaults();
+ let domain = NSString::alloc(nil).init_str("NSGlobalDomain");
+ let key = NSString::alloc(nil).init_str("AppleWindowTabbingMode");
+
+ let dict: id = msg_send![defaults, persistentDomainForName: domain];
+ let value: id = if !dict.is_null() {
+ msg_send![dict, objectForKey: key]
+ } else {
+ nil
+ };
+
+ let value_str = if !value.is_null() {
+ CStr::from_ptr(NSString::UTF8String(value)).to_string_lossy()
+ } else {
+ "".into()
+ };
+
+ match value_str.as_ref() {
+ "manual" => Some(UserTabbingPreference::Never),
+ "always" => Some(UserTabbingPreference::Always),
+ _ => Some(UserTabbingPreference::InFullScreen),
+ }
+ }
+ }
}
impl Drop for MacWindow {
@@ -851,6 +978,46 @@ impl PlatformWindow for MacWindow {
.detach();
}
+ fn merge_all_windows(&self) {
+ let native_window = self.0.lock().native_window;
+ unsafe extern "C" fn merge_windows_async(context: *mut std::ffi::c_void) {
+ let native_window = context as id;
+ let _: () = msg_send![native_window, mergeAllWindows:nil];
+ }
+
+ unsafe {
+ dispatch_async_f(
+ dispatch_get_main_queue(),
+ native_window as *mut std::ffi::c_void,
+ Some(merge_windows_async),
+ );
+ }
+ }
+
+ fn move_tab_to_new_window(&self) {
+ let native_window = self.0.lock().native_window;
+ unsafe extern "C" fn move_tab_async(context: *mut std::ffi::c_void) {
+ let native_window = context as id;
+ let _: () = msg_send![native_window, moveTabToNewWindow:nil];
+ let _: () = msg_send![native_window, makeKeyAndOrderFront: nil];
+ }
+
+ unsafe {
+ dispatch_async_f(
+ dispatch_get_main_queue(),
+ native_window as *mut std::ffi::c_void,
+ Some(move_tab_async),
+ );
+ }
+ }
+
+ fn toggle_window_tab_overview(&self) {
+ let native_window = self.0.lock().native_window;
+ unsafe {
+ let _: () = msg_send![native_window, toggleTabOverview:nil];
+ }
+ }
+
fn scale_factor(&self) -> f32 {
self.0.as_ref().lock().scale_factor()
}
@@ -1051,6 +1218,17 @@ impl PlatformWindow for MacWindow {
}
}
+ fn get_title(&self) -> String {
+ unsafe {
+ let title: id = msg_send![self.0.lock().native_window, title];
+ if title.is_null() {
+ "".to_string()
+ } else {
+ title.to_str().to_string()
+ }
+ }
+ }
+
fn set_app_id(&mut self, _app_id: &str) {}
fn set_background_appearance(&self, background_appearance: WindowBackgroundAppearance) {
@@ -1212,6 +1390,62 @@ impl PlatformWindow for MacWindow {
self.0.lock().appearance_changed_callback = Some(callback);
}
+ fn tabbed_windows(&self) -> Option<Vec<SystemWindowTab>> {
+ unsafe {
+ let windows: id = msg_send![self.0.lock().native_window, tabbedWindows];
+ if windows.is_null() {
+ return None;
+ }
+
+ let count: NSUInteger = msg_send![windows, count];
+ let mut result = Vec::new();
+ for i in 0..count {
+ let window: id = msg_send![windows, objectAtIndex:i];
+ if msg_send![window, isKindOfClass: WINDOW_CLASS] {
+ let handle = get_window_state(&*window).lock().handle;
+ let title: id = msg_send![window, title];
+ let title = SharedString::from(title.to_str().to_string());
+
+ result.push(SystemWindowTab::new(title, handle));
+ }
+ }
+
+ Some(result)
+ }
+ }
+
+ fn tab_bar_visible(&self) -> bool {
+ unsafe {
+ let tab_group: id = msg_send![self.0.lock().native_window, tabGroup];
+ if tab_group.is_null() {
+ false
+ } else {
+ let tab_bar_visible: BOOL = msg_send![tab_group, isTabBarVisible];
+ tab_bar_visible == YES
+ }
+ }
+ }
+
+ fn on_move_tab_to_new_window(&self, callback: Box<dyn FnMut()>) {
+ self.0.as_ref().lock().move_tab_to_new_window_callback = Some(callback);
+ }
+
+ fn on_merge_all_windows(&self, callback: Box<dyn FnMut()>) {
+ self.0.as_ref().lock().merge_all_windows_callback = Some(callback);
+ }
+
+ fn on_select_next_tab(&self, callback: Box<dyn FnMut()>) {
+ self.0.as_ref().lock().select_next_tab_callback = Some(callback);
+ }
+
+ fn on_select_previous_tab(&self, callback: Box<dyn FnMut()>) {
+ self.0.as_ref().lock().select_previous_tab_callback = Some(callback);
+ }
+
+ fn on_toggle_tab_bar(&self, callback: Box<dyn FnMut()>) {
+ self.0.as_ref().lock().toggle_tab_bar_callback = Some(callback);
+ }
+
fn draw(&self, scene: &crate::Scene) {
let mut this = self.0.lock();
this.renderer.draw(scene);
@@ -1653,6 +1887,7 @@ extern "C" fn window_did_change_occlusion_state(this: &Object, _: Sel, _: id) {
.occlusionState()
.contains(NSWindowOcclusionState::NSWindowOcclusionStateVisible)
{
+ lock.move_traffic_light();
lock.start_display_link();
} else {
lock.stop_display_link();
@@ -1714,7 +1949,7 @@ extern "C" fn window_did_change_screen(this: &Object, _: Sel, _: id) {
extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) {
let window_state = unsafe { get_window_state(this) };
- let lock = window_state.lock();
+ let mut lock = window_state.lock();
let is_active = unsafe { lock.native_window.isKeyWindow() == YES };
// When opening a pop-up while the application isn't active, Cocoa sends a spurious
@@ -1735,9 +1970,34 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id)
let executor = lock.executor.clone();
drop(lock);
+
+ // If window is becoming active, trigger immediate synchronous frame request.
+ if selector == sel!(windowDidBecomeKey:) && is_active {
+ let window_state = unsafe { get_window_state(this) };
+ let mut lock = window_state.lock();
+
+ if let Some(mut callback) = lock.request_frame_callback.take() {
+ #[cfg(not(feature = "macos-blade"))]
+ lock.renderer.set_presents_with_transaction(true);
+ lock.stop_display_link();
+ drop(lock);
+ callback(Default::default());
+
+ let mut lock = window_state.lock();
+ lock.request_frame_callback = Some(callback);
+ #[cfg(not(feature = "macos-blade"))]
+ lock.renderer.set_presents_with_transaction(false);
+ lock.start_display_link();
+ }
+ }
+
executor
.spawn(async move {
let mut lock = window_state.as_ref().lock();
+ if is_active {
+ lock.move_traffic_light();
+ }
+
if let Some(mut callback) = lock.activate_callback.take() {
drop(lock);
callback(is_active);
@@ -2273,3 +2533,80 @@ unsafe fn remove_layer_background(layer: id) {
}
}
}
+
+extern "C" fn add_titlebar_accessory_view_controller(this: &Object, _: Sel, view_controller: id) {
+ unsafe {
+ let _: () = msg_send![super(this, class!(NSWindow)), addTitlebarAccessoryViewController: view_controller];
+
+ // Hide the native tab bar and set its height to 0, since we render our own.
+ let accessory_view: id = msg_send![view_controller, view];
+ let _: () = msg_send![accessory_view, setHidden: YES];
+ let mut frame: NSRect = msg_send![accessory_view, frame];
+ frame.size.height = 0.0;
+ let _: () = msg_send![accessory_view, setFrame: frame];
+ }
+}
+
+extern "C" fn move_tab_to_new_window(this: &Object, _: Sel, _: id) {
+ unsafe {
+ let _: () = msg_send![super(this, class!(NSWindow)), moveTabToNewWindow:nil];
+
+ let window_state = get_window_state(this);
+ let mut lock = window_state.as_ref().lock();
+ if let Some(mut callback) = lock.move_tab_to_new_window_callback.take() {
+ drop(lock);
+ callback();
+ window_state.lock().move_tab_to_new_window_callback = Some(callback);
+ }
+ }
+}
+
+extern "C" fn merge_all_windows(this: &Object, _: Sel, _: id) {
+ unsafe {
+ let _: () = msg_send![super(this, class!(NSWindow)), mergeAllWindows:nil];
+
+ let window_state = get_window_state(this);
+ let mut lock = window_state.as_ref().lock();
+ if let Some(mut callback) = lock.merge_all_windows_callback.take() {
+ drop(lock);
+ callback();
+ window_state.lock().merge_all_windows_callback = Some(callback);
+ }
+ }
+}
+
+extern "C" fn select_next_tab(this: &Object, _sel: Sel, _id: id) {
+ let window_state = unsafe { get_window_state(this) };
+ let mut lock = window_state.as_ref().lock();
+ if let Some(mut callback) = lock.select_next_tab_callback.take() {
+ drop(lock);
+ callback();
+ window_state.lock().select_next_tab_callback = Some(callback);
+ }
+}
+
+extern "C" fn select_previous_tab(this: &Object, _sel: Sel, _id: id) {
+ let window_state = unsafe { get_window_state(this) };
+ let mut lock = window_state.as_ref().lock();
+ if let Some(mut callback) = lock.select_previous_tab_callback.take() {
+ drop(lock);
+ callback();
+ window_state.lock().select_previous_tab_callback = Some(callback);
+ }
+}
+
+extern "C" fn toggle_tab_bar(this: &Object, _sel: Sel, _id: id) {
+ unsafe {
+ let _: () = msg_send![super(this, class!(NSWindow)), toggleTabBar:nil];
+
+ let window_state = get_window_state(this);
+ let mut lock = window_state.as_ref().lock();
+ lock.move_traffic_light();
+
+ if let Some(mut callback) = lock.toggle_tab_bar_callback.take() {
+ drop(lock);
+ callback();
+ window_state.lock().toggle_tab_bar_callback = Some(callback);
+ }
+ }
+}
@@ -1,8 +1,9 @@
use crate::{
AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels,
- ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay, PlatformKeyboardLayout,
- PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream,
- SourceMetadata, Task, TestDisplay, TestWindow, WindowAppearance, WindowParams, size,
+ DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay,
+ PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PromptButton,
+ ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, Task,
+ TestDisplay, TestWindow, WindowAppearance, WindowParams, size,
};
use anyhow::Result;
use collections::VecDeque;
@@ -237,6 +238,10 @@ impl Platform for TestPlatform {
Box::new(TestKeyboardLayout)
}
+ fn keyboard_mapper(&self) -> Rc<dyn PlatformKeyboardMapper> {
+ Rc::new(DummyKeyboardMapper)
+ }
+
fn on_keyboard_layout_change(&self, _: Box<dyn FnMut()>) {}
fn run(&self, _on_finish_launching: Box<dyn FnOnce()>) {
@@ -1,22 +1,31 @@
use anyhow::Result;
+use collections::HashMap;
use windows::Win32::UI::{
Input::KeyboardAndMouse::{
- GetKeyboardLayoutNameW, MAPVK_VK_TO_CHAR, MapVirtualKeyW, ToUnicode, VIRTUAL_KEY, VK_0,
- VK_1, VK_2, VK_3, VK_4, VK_5, VK_6, VK_7, VK_8, VK_9, VK_ABNT_C1, VK_CONTROL, VK_MENU,
- VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, VK_OEM_7, VK_OEM_8, VK_OEM_102,
- VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_PLUS, VK_SHIFT,
+ GetKeyboardLayoutNameW, MAPVK_VK_TO_CHAR, MAPVK_VK_TO_VSC, MapVirtualKeyW, ToUnicode,
+ VIRTUAL_KEY, VK_0, VK_1, VK_2, VK_3, VK_4, VK_5, VK_6, VK_7, VK_8, VK_9, VK_ABNT_C1,
+ VK_CONTROL, VK_MENU, VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, VK_OEM_7,
+ VK_OEM_8, VK_OEM_102, VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_PLUS, VK_SHIFT,
},
WindowsAndMessaging::KL_NAMELENGTH,
};
use windows_core::HSTRING;
-use crate::{Modifiers, PlatformKeyboardLayout};
+use crate::{
+ KeybindingKeystroke, Keystroke, Modifiers, PlatformKeyboardLayout, PlatformKeyboardMapper,
+};
pub(crate) struct WindowsKeyboardLayout {
id: String,
name: String,
}
+pub(crate) struct WindowsKeyboardMapper {
+ key_to_vkey: HashMap<String, (u16, bool)>,
+ vkey_to_key: HashMap<u16, String>,
+ vkey_to_shifted: HashMap<u16, String>,
+}
+
impl PlatformKeyboardLayout for WindowsKeyboardLayout {
fn id(&self) -> &str {
&self.id
@@ -27,6 +36,61 @@ impl PlatformKeyboardLayout for WindowsKeyboardLayout {
}
}
+impl PlatformKeyboardMapper for WindowsKeyboardMapper {
+ fn map_key_equivalent(
+ &self,
+ mut keystroke: Keystroke,
+ use_key_equivalents: bool,
+ ) -> KeybindingKeystroke {
+ let Some((vkey, shifted_key)) = self.get_vkey_from_key(&keystroke.key, use_key_equivalents)
+ else {
+ return KeybindingKeystroke::from_keystroke(keystroke);
+ };
+ if shifted_key && keystroke.modifiers.shift {
+ log::warn!(
+ "Keystroke '{}' has both shift and a shifted key, this is likely a bug",
+ keystroke.key
+ );
+ }
+
+ let shift = shifted_key || keystroke.modifiers.shift;
+ keystroke.modifiers.shift = false;
+
+ let Some(key) = self.vkey_to_key.get(&vkey).cloned() else {
+ log::error!(
+ "Failed to map key equivalent '{:?}' to a valid key",
+ keystroke
+ );
+ return KeybindingKeystroke::from_keystroke(keystroke);
+ };
+
+ keystroke.key = if shift {
+ let Some(shifted_key) = self.vkey_to_shifted.get(&vkey).cloned() else {
+ log::error!(
+ "Failed to map keystroke {:?} with virtual key '{:?}' to a shifted key",
+ keystroke,
+ vkey
+ );
+ return KeybindingKeystroke::from_keystroke(keystroke);
+ };
+ shifted_key
+ } else {
+ key.clone()
+ };
+
+ let modifiers = Modifiers {
+ shift,
+ ..keystroke.modifiers
+ };
+
+ KeybindingKeystroke::new(keystroke, modifiers, key)
+ }
+
+ fn get_key_equivalents(&self) -> Option<&HashMap<char, char>> {
+ None
+ }
+}
+
impl WindowsKeyboardLayout {
pub(crate) fn new() -> Result<Self> {
let mut buffer = [0u16; KL_NAMELENGTH as usize];
@@ -48,6 +112,41 @@ impl WindowsKeyboardLayout {
}
}
+impl WindowsKeyboardMapper {
+ pub(crate) fn new() -> Self {
+ let mut key_to_vkey = HashMap::default();
+ let mut vkey_to_key = HashMap::default();
+ let mut vkey_to_shifted = HashMap::default();
+ for vkey in CANDIDATE_VKEYS {
+ if let Some(key) = get_key_from_vkey(*vkey) {
+ key_to_vkey.insert(key.clone(), (vkey.0, false));
+ vkey_to_key.insert(vkey.0, key);
+ }
+ let scan_code = unsafe { MapVirtualKeyW(vkey.0 as u32, MAPVK_VK_TO_VSC) };
+ if scan_code == 0 {
+ continue;
+ }
+ if let Some(shifted_key) = get_shifted_key(*vkey, scan_code) {
+ key_to_vkey.insert(shifted_key.clone(), (vkey.0, true));
+ vkey_to_shifted.insert(vkey.0, shifted_key);
+ }
+ }
+ Self {
+ key_to_vkey,
+ vkey_to_key,
+ vkey_to_shifted,
+ }
+ }
+
+ fn get_vkey_from_key(&self, key: &str, use_key_equivalents: bool) -> Option<(u16, bool)> {
+ if use_key_equivalents {
+ get_vkey_from_key_with_us_layout(key)
+ } else {
+ self.key_to_vkey.get(key).cloned()
+ }
+ }
+}
+
pub(crate) fn get_keystroke_key(
vkey: VIRTUAL_KEY,
scan_code: u32,
@@ -140,3 +239,134 @@ pub(crate) fn generate_key_char(
_ => None,
}
}
+
+fn get_vkey_from_key_with_us_layout(key: &str) -> Option<(u16, bool)> {
+ match key {
+ // ` => VK_OEM_3
+ "`" => Some((VK_OEM_3.0, false)),
+ "~" => Some((VK_OEM_3.0, true)),
+ "1" => Some((VK_1.0, false)),
+ "!" => Some((VK_1.0, true)),
+ "2" => Some((VK_2.0, false)),
+ "@" => Some((VK_2.0, true)),
+ "3" => Some((VK_3.0, false)),
+ "#" => Some((VK_3.0, true)),
+ "4" => Some((VK_4.0, false)),
+ "$" => Some((VK_4.0, true)),
+ "5" => Some((VK_5.0, false)),
+ "%" => Some((VK_5.0, true)),
+ "6" => Some((VK_6.0, false)),
+ "^" => Some((VK_6.0, true)),
+ "7" => Some((VK_7.0, false)),
+ "&" => Some((VK_7.0, true)),
+ "8" => Some((VK_8.0, false)),
+ "*" => Some((VK_8.0, true)),
+ "9" => Some((VK_9.0, false)),
+ "(" => Some((VK_9.0, true)),
+ "0" => Some((VK_0.0, false)),
+ ")" => Some((VK_0.0, true)),
+ "-" => Some((VK_OEM_MINUS.0, false)),
+ "_" => Some((VK_OEM_MINUS.0, true)),
+ "=" => Some((VK_OEM_PLUS.0, false)),
+ "+" => Some((VK_OEM_PLUS.0, true)),
+ "[" => Some((VK_OEM_4.0, false)),
+ "{" => Some((VK_OEM_4.0, true)),
+ "]" => Some((VK_OEM_6.0, false)),
+ "}" => Some((VK_OEM_6.0, true)),
+ "\\" => Some((VK_OEM_5.0, false)),
+ "|" => Some((VK_OEM_5.0, true)),
+ ";" => Some((VK_OEM_1.0, false)),
+ ":" => Some((VK_OEM_1.0, true)),
+ "'" => Some((VK_OEM_7.0, false)),
+ "\"" => Some((VK_OEM_7.0, true)),
+ "," => Some((VK_OEM_COMMA.0, false)),
+ "<" => Some((VK_OEM_COMMA.0, true)),
+ "." => Some((VK_OEM_PERIOD.0, false)),
+ ">" => Some((VK_OEM_PERIOD.0, true)),
+ "/" => Some((VK_OEM_2.0, false)),
+ "?" => Some((VK_OEM_2.0, true)),
+ _ => None,
+ }
+}
+
+const CANDIDATE_VKEYS: &[VIRTUAL_KEY] = &[
+ VK_OEM_3,
+ VK_OEM_MINUS,
+ VK_OEM_PLUS,
+ VK_OEM_4,
+ VK_OEM_5,
+ VK_OEM_6,
+ VK_OEM_1,
+ VK_OEM_7,
+ VK_OEM_COMMA,
+ VK_OEM_PERIOD,
+ VK_OEM_2,
+ VK_OEM_102,
+ VK_OEM_8,
+ VK_ABNT_C1,
+ VK_0,
+ VK_1,
+ VK_2,
+ VK_3,
+ VK_4,
+ VK_5,
+ VK_6,
+ VK_7,
+ VK_8,
+ VK_9,
+];
+
+#[cfg(test)]
+mod tests {
+ use crate::{Keystroke, Modifiers, PlatformKeyboardMapper, WindowsKeyboardMapper};
+
+ #[test]
+ fn test_keyboard_mapper() {
+ let mapper = WindowsKeyboardMapper::new();
+
+ // Normal case
+ let keystroke = Keystroke {
+ modifiers: Modifiers::control(),
+ key: "a".to_string(),
+ key_char: None,
+ };
+ let mapped = mapper.map_key_equivalent(keystroke.clone(), true);
+ assert_eq!(*mapped.inner(), keystroke);
+ assert_eq!(mapped.key(), "a");
+ assert_eq!(*mapped.modifiers(), Modifiers::control());
+
+ // Shifted case, ctrl-$
+ let keystroke = Keystroke {
+ modifiers: Modifiers::control(),
+ key: "$".to_string(),
+ key_char: None,
+ };
+ let mapped = mapper.map_key_equivalent(keystroke.clone(), true);
+ assert_eq!(*mapped.inner(), keystroke);
+ assert_eq!(mapped.key(), "4");
+ assert_eq!(*mapped.modifiers(), Modifiers::control_shift());
+
+ // Shifted case, but shift is true
+ let keystroke = Keystroke {
+ modifiers: Modifiers::control_shift(),
+ key: "$".to_string(),
+ key_char: None,
+ };
+ let mapped = mapper.map_key_equivalent(keystroke, true);
+ assert_eq!(mapped.inner().modifiers, Modifiers::control());
+ assert_eq!(mapped.key(), "4");
+ assert_eq!(*mapped.modifiers(), Modifiers::control_shift());
+
+ // Windows style
+ let keystroke = Keystroke {
+ modifiers: Modifiers::control_shift(),
+ key: "4".to_string(),
+ key_char: None,
+ };
+ let mapped = mapper.map_key_equivalent(keystroke, true);
+ assert_eq!(mapped.inner().modifiers, Modifiers::control());
+ assert_eq!(mapped.inner().key, "$");
+ assert_eq!(mapped.key(), "4");
+ assert_eq!(*mapped.modifiers(), Modifiers::control_shift());
+ }
+}
@@ -351,6 +351,10 @@ impl Platform for WindowsPlatform {
)
}
+ fn keyboard_mapper(&self) -> Rc<dyn PlatformKeyboardMapper> {
+ Rc::new(WindowsKeyboardMapper::new())
+ }
+
fn on_keyboard_layout_change(&self, callback: Box<dyn FnMut()>) {
self.state.borrow_mut().callbacks.keyboard_layout_change = Some(callback);
}
@@ -847,7 +851,7 @@ fn file_save_dialog(
if !directory.to_string_lossy().is_empty()
&& let Some(full_path) = directory.canonicalize().log_err()
{
- let full_path = SanitizedPath::from(full_path);
+ let full_path = SanitizedPath::new(&full_path);
let full_path_string = full_path.to_string();
let path_item: IShellItem =
unsafe { SHCreateItemFromParsingName(&HSTRING::from(full_path_string), None)? };
@@ -382,10 +382,17 @@ impl WindowsWindow {
let (mut dwexstyle, dwstyle) = if params.kind == WindowKind::PopUp {
(WS_EX_TOOLWINDOW, WINDOW_STYLE(0x0))
} else {
- (
- WS_EX_APPWINDOW,
- WS_THICKFRAME | WS_SYSMENU | WS_MAXIMIZEBOX | WS_MINIMIZEBOX,
- )
+ let mut dwstyle = WS_SYSMENU;
+
+ if params.is_resizable {
+ dwstyle |= WS_THICKFRAME | WS_MAXIMIZEBOX;
+ }
+
+ if params.is_minimizable {
+ dwstyle |= WS_MINIMIZEBOX;
+ }
+
+ (WS_EX_APPWINDOW, dwstyle)
};
if !disable_direct_composition {
dwexstyle |= WS_EX_NOREDIRECTIONBITMAP;
@@ -181,7 +181,7 @@ impl LineWrapper {
matches!(c, '\u{0400}'..='\u{04FF}') ||
// Some other known special characters that should be treated as word characters,
// e.g. `a-b`, `var_name`, `I'm`, '@mention`, `#hashtag`, `100%`, `3.1415`, `2^3`, `a~b`, etc.
- matches!(c, '-' | '_' | '.' | '\'' | '$' | '%' | '@' | '#' | '^' | '~' | ',') ||
+ matches!(c, '-' | '_' | '.' | '\'' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '!' | ';' | '*') ||
// Characters that used in URL, e.g. `https://github.com/zed-industries/zed?a=1&b=2` for better wrapping a long URL.
matches!(c, '/' | ':' | '?' | '&' | '=') ||
// `⋯` character is special used in Zed, to keep this at the end of the line.
@@ -12,11 +12,11 @@ use crate::{
PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, PromptButton, PromptLevel, Quad,
Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge,
SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS, ScaledPixels, Scene, Shadow, SharedString, Size,
- StrikethroughStyle, Style, SubscriberSet, Subscription, TabHandles, TaffyLayoutEngine, Task,
- TextStyle, TextStyleRefinement, TransformationMatrix, Underline, UnderlineStyle,
- WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations,
- WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size,
- transparent_black,
+ StrikethroughStyle, Style, SubscriberSet, Subscription, SystemWindowTab,
+ SystemWindowTabController, TabHandles, TaffyLayoutEngine, Task, TextStyle, TextStyleRefinement,
+ TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance,
+ WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem,
+ point, prelude::*, px, rems, size, transparent_black,
};
use anyhow::{Context as _, Result, anyhow};
use collections::{FxHashMap, FxHashSet};
@@ -939,11 +939,15 @@ impl Window {
show,
kind,
is_movable,
+ is_resizable,
+ is_minimizable,
display_id,
window_background,
app_id,
window_min_size,
window_decorations,
+ #[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
+ tabbing_identifier,
} = options;
let bounds = window_bounds
@@ -956,12 +960,23 @@ impl Window {
titlebar,
kind,
is_movable,
+ is_resizable,
+ is_minimizable,
focus,
show,
display_id,
window_min_size,
+ #[cfg(target_os = "macos")]
+ tabbing_identifier,
},
)?;
+
+ let tab_bar_visible = platform_window.tab_bar_visible();
+ SystemWindowTabController::init_visible(cx, tab_bar_visible);
+ if let Some(tabs) = platform_window.tabbed_windows() {
+ SystemWindowTabController::add_tab(cx, handle.window_id(), tabs);
+ }
+
let display_id = platform_window.display().map(|display| display.id());
let sprite_atlas = platform_window.sprite_atlas();
let mouse_position = platform_window.mouse_position();
@@ -991,9 +1006,13 @@ impl Window {
}
platform_window.on_close(Box::new({
+ let window_id = handle.window_id();
let mut cx = cx.to_async();
move || {
let _ = handle.update(&mut cx, |_, window, _| window.remove_window());
+ let _ = cx.update(|cx| {
+ SystemWindowTabController::remove_tab(cx, window_id);
+ });
}
}));
platform_window.on_request_frame(Box::new({
@@ -1082,7 +1101,11 @@ impl Window {
.activation_observers
.clone()
.retain(&(), |callback| callback(window, cx));
+
+ window.bounds_changed(cx);
window.refresh();
+
+ SystemWindowTabController::update_last_active(cx, window.handle.id);
})
.log_err();
}
@@ -1123,6 +1146,57 @@ impl Window {
.unwrap_or(None)
})
});
+ platform_window.on_move_tab_to_new_window({
+ let mut cx = cx.to_async();
+ Box::new(move || {
+ handle
+ .update(&mut cx, |_, _window, cx| {
+ SystemWindowTabController::move_tab_to_new_window(cx, handle.window_id());
+ })
+ .log_err();
+ })
+ });
+ platform_window.on_merge_all_windows({
+ let mut cx = cx.to_async();
+ Box::new(move || {
+ handle
+ .update(&mut cx, |_, _window, cx| {
+ SystemWindowTabController::merge_all_windows(cx, handle.window_id());
+ })
+ .log_err();
+ })
+ });
+ platform_window.on_select_next_tab({
+ let mut cx = cx.to_async();
+ Box::new(move || {
+ handle
+ .update(&mut cx, |_, _window, cx| {
+ SystemWindowTabController::select_next_tab(cx, handle.window_id());
+ })
+ .log_err();
+ })
+ });
+ platform_window.on_select_previous_tab({
+ let mut cx = cx.to_async();
+ Box::new(move || {
+ handle
+ .update(&mut cx, |_, _window, cx| {
+ SystemWindowTabController::select_previous_tab(cx, handle.window_id())
+ })
+ .log_err();
+ })
+ });
+ platform_window.on_toggle_tab_bar({
+ let mut cx = cx.to_async();
+ Box::new(move || {
+ handle
+ .update(&mut cx, |_, window, cx| {
+ let tab_bar_visible = window.platform_window.tab_bar_visible();
+ SystemWindowTabController::set_visible(cx, tab_bar_visible);
+ })
+ .log_err();
+ })
+ });
if let Some(app_id) = app_id {
platform_window.set_app_id(&app_id);
@@ -4275,11 +4349,47 @@ impl Window {
}
/// Perform titlebar double-click action.
- /// This is MacOS specific.
+ /// This is macOS specific.
pub fn titlebar_double_click(&self) {
self.platform_window.titlebar_double_click();
}
+ /// Gets the window's title at the platform level.
+ /// This is macOS specific.
+ pub fn window_title(&self) -> String {
+ self.platform_window.get_title()
+ }
+
+ /// Returns a list of all tabbed windows and their titles.
+ /// This is macOS specific.
+ pub fn tabbed_windows(&self) -> Option<Vec<SystemWindowTab>> {
+ self.platform_window.tabbed_windows()
+ }
+
+ /// Returns the tab bar visibility.
+ /// This is macOS specific.
+ pub fn tab_bar_visible(&self) -> bool {
+ self.platform_window.tab_bar_visible()
+ }
+
+ /// Merges all open windows into a single tabbed window.
+ /// This is macOS specific.
+ pub fn merge_all_windows(&self) {
+ self.platform_window.merge_all_windows()
+ }
+
+ /// Moves the tab to a new containing window.
+ /// This is macOS specific.
+ pub fn move_tab_to_new_window(&self) {
+ self.platform_window.move_tab_to_new_window()
+ }
+
+ /// Shows or hides the window tab overview.
+ /// This is macOS specific.
+ pub fn toggle_window_tab_overview(&self) {
+ self.platform_window.toggle_window_tab_overview()
+ }
+
/// Toggles the inspector mode on this window.
#[cfg(any(feature = "inspector", debug_assertions))]
pub fn toggle_inspector(&mut self, cx: &mut App) {
@@ -4468,6 +4578,13 @@ impl Window {
}
None
}
+
+ /// For testing: set the current modifier keys state.
+ /// This does not generate any events.
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn set_modifiers(&mut self, modifiers: Modifiers) {
+ self.modifiers = modifiers;
+ }
}
// #[derive(Clone, Copy, Eq, PartialEq, Hash)]
@@ -146,6 +146,7 @@ pub enum IconName {
Library,
LineHeight,
ListCollapse,
+ ListFilter,
ListTodo,
ListTree,
ListX,
@@ -215,6 +216,7 @@ pub enum IconName {
Tab,
Terminal,
TerminalAlt,
+ TerminalGhost,
TextSnippet,
TextThread,
Thread,
@@ -401,12 +401,19 @@ pub fn init(cx: &mut App) {
mod persistence {
use std::path::PathBuf;
- use db::{define_connection, query, sqlez_macros::sql};
+ use db::{
+ query,
+ sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
+ sqlez_macros::sql,
+ };
use workspace::{ItemId, WorkspaceDb, WorkspaceId};
- define_connection! {
- pub static ref IMAGE_VIEWER: ImageViewerDb<WorkspaceDb> =
- &[sql!(
+ pub struct ImageViewerDb(ThreadSafeConnection);
+
+ impl Domain for ImageViewerDb {
+ const NAME: &str = stringify!(ImageViewerDb);
+
+ const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE image_viewers (
workspace_id INTEGER,
item_id INTEGER UNIQUE,
@@ -417,9 +424,11 @@ mod persistence {
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;
- )];
+ )];
}
+ db::static_connection!(IMAGE_VIEWER, ImageViewerDb, [WorkspaceDb]);
+
impl ImageViewerDb {
query! {
pub async fn save_image_path(
@@ -720,6 +720,9 @@ pub struct LanguageConfig {
/// How to soft-wrap long lines of text.
#[serde(default)]
pub soft_wrap: Option<SoftWrap>,
+ /// When set, selections can be wrapped using prefix/suffix pairs on both sides.
+ #[serde(default)]
+ pub wrap_characters: Option<WrapCharactersConfig>,
/// The name of a Prettier parser that will be used for this language when no file path is available.
/// If there's a parser name in the language settings, that will be used instead.
#[serde(default)]
@@ -923,6 +926,7 @@ impl Default for LanguageConfig {
hard_tabs: None,
tab_size: None,
soft_wrap: None,
+ wrap_characters: None,
prettier_parser_name: None,
hidden: false,
jsx_tag_auto_close: None,
@@ -932,6 +936,18 @@ impl Default for LanguageConfig {
}
}
+#[derive(Clone, Debug, Deserialize, JsonSchema)]
+pub struct WrapCharactersConfig {
+ /// Opening token split into a prefix and suffix. The first caret goes
+ /// after the prefix (i.e., between prefix and suffix).
+ pub start_prefix: String,
+ pub start_suffix: String,
+ /// Closing token split into a prefix and suffix. The second caret goes
+ /// after the prefix (i.e., between prefix and suffix).
+ pub end_prefix: String,
+ pub end_suffix: String,
+}
+
fn auto_indent_using_last_non_empty_line_default() -> bool {
true
}
@@ -11,13 +11,14 @@ use std::{
use async_trait::async_trait;
use collections::HashMap;
+use fs::Fs;
use gpui::{AsyncApp, SharedString};
use settings::WorktreeId;
use crate::{LanguageName, ManifestName};
/// Represents a single toolchain.
-#[derive(Clone, Debug, Eq)]
+#[derive(Clone, Eq, Debug)]
pub struct Toolchain {
/// User-facing label
pub name: SharedString,
@@ -29,21 +30,29 @@ pub struct Toolchain {
impl std::hash::Hash for Toolchain {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
- self.name.hash(state);
- self.path.hash(state);
- self.language_name.hash(state);
+ let Self {
+ name,
+ path,
+ language_name,
+ as_json: _,
+ } = self;
+ name.hash(state);
+ path.hash(state);
+ language_name.hash(state);
}
}
impl PartialEq for Toolchain {
fn eq(&self, other: &Self) -> bool {
+ let Self {
+ name,
+ path,
+ language_name,
+ as_json: _,
+ } = self;
// Do not use as_json for comparisons; it shouldn't impact equality, as it's not user-surfaced.
// Thus, there could be multiple entries that look the same in the UI.
- (&self.name, &self.path, &self.language_name).eq(&(
- &other.name,
- &other.path,
- &other.language_name,
- ))
+ (name, path, language_name).eq(&(&other.name, &other.path, &other.language_name))
}
}
@@ -52,13 +61,14 @@ pub trait ToolchainLister: Send + Sync {
async fn list(
&self,
worktree_root: PathBuf,
- subroot_relative_path: Option<Arc<Path>>,
+ subroot_relative_path: Arc<Path>,
project_env: Option<HashMap<String, String>>,
) -> ToolchainList;
// Returns a term which we should use in UI to refer to a toolchain.
fn term(&self) -> SharedString;
/// Returns the name of the manifest file for this toolchain.
fn manifest_name(&self) -> ManifestName;
+ async fn activation_script(&self, toolchain: &Toolchain, fs: &dyn Fs) -> Option<String>;
}
#[async_trait(?Send)]
@@ -82,7 +92,7 @@ pub trait LocalLanguageToolchainStore: Send + Sync + 'static {
) -> Option<Toolchain>;
}
-#[async_trait(?Send )]
+#[async_trait(?Send)]
impl<T: LocalLanguageToolchainStore> LanguageToolchainStore for T {
async fn active_toolchain(
self: Arc<Self>,
@@ -4,12 +4,16 @@ use crate::{
LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
LanguageModelRequest, LanguageModelToolChoice,
};
+use anyhow::anyhow;
use futures::{FutureExt, channel::mpsc, future::BoxFuture, stream::BoxStream};
use gpui::{AnyView, App, AsyncApp, Entity, Task, Window};
use http_client::Result;
use parking_lot::Mutex;
use smol::stream::StreamExt;
-use std::sync::Arc;
+use std::sync::{
+ Arc,
+ atomic::{AtomicBool, Ordering::SeqCst},
+};
#[derive(Clone)]
pub struct FakeLanguageModelProvider {
@@ -106,6 +110,7 @@ pub struct FakeLanguageModel {
>,
)>,
>,
+ forbid_requests: AtomicBool,
}
impl Default for FakeLanguageModel {
@@ -114,11 +119,20 @@ impl Default for FakeLanguageModel {
provider_id: LanguageModelProviderId::from("fake".to_string()),
provider_name: LanguageModelProviderName::from("Fake".to_string()),
current_completion_txs: Mutex::new(Vec::new()),
+ forbid_requests: AtomicBool::new(false),
}
}
}
impl FakeLanguageModel {
+ pub fn allow_requests(&self) {
+ self.forbid_requests.store(false, SeqCst);
+ }
+
+ pub fn forbid_requests(&self) {
+ self.forbid_requests.store(true, SeqCst);
+ }
+
pub fn pending_completions(&self) -> Vec<LanguageModelRequest> {
self.current_completion_txs
.lock()
@@ -251,9 +265,18 @@ impl LanguageModel for FakeLanguageModel {
LanguageModelCompletionError,
>,
> {
- let (tx, rx) = mpsc::unbounded();
- self.current_completion_txs.lock().push((request, tx));
- async move { Ok(rx.boxed()) }.boxed()
+ if self.forbid_requests.load(SeqCst) {
+ async move {
+ Err(LanguageModelCompletionError::Other(anyhow!(
+ "requests are forbidden"
+ )))
+ }
+ .boxed()
+ } else {
+ let (tx, rx) = mpsc::unbounded();
+ self.current_completion_txs.lock().push((request, tx));
+ async move { Ok(rx.boxed()) }.boxed()
+ }
}
fn as_fake(&self) -> &Self {
@@ -6,6 +6,7 @@ use collections::BTreeMap;
use gpui::{App, Context, Entity, EventEmitter, Global, prelude::*};
use std::{str::FromStr, sync::Arc};
use thiserror::Error;
+use util::maybe;
pub fn init(cx: &mut App) {
let registry = cx.new(|_cx| LanguageModelRegistry::default());
@@ -41,9 +42,7 @@ impl std::fmt::Debug for ConfigurationError {
#[derive(Default)]
pub struct LanguageModelRegistry {
default_model: Option<ConfiguredModel>,
- /// This model is automatically configured by a user's environment after
- /// authenticating all providers. It's only used when default_model is not available.
- environment_fallback_model: Option<ConfiguredModel>,
+ default_fast_model: Option<ConfiguredModel>,
inline_assistant_model: Option<ConfiguredModel>,
commit_message_model: Option<ConfiguredModel>,
thread_summary_model: Option<ConfiguredModel>,
@@ -99,6 +98,9 @@ impl ConfiguredModel {
pub enum Event {
DefaultModelChanged,
+ InlineAssistantModelChanged,
+ CommitMessageModelChanged,
+ ThreadSummaryModelChanged,
ProviderStateChanged(LanguageModelProviderId),
AddedProvider(LanguageModelProviderId),
RemovedProvider(LanguageModelProviderId),
@@ -206,6 +208,7 @@ impl LanguageModelRegistry {
) -> impl Iterator<Item = Arc<dyn LanguageModel>> + 'a {
self.providers
.values()
+ .filter(|provider| provider.is_authenticated(cx))
.flat_map(|provider| provider.provided_models(cx))
}
@@ -224,7 +227,7 @@ impl LanguageModelRegistry {
cx: &mut Context<Self>,
) {
let configured_model = model.and_then(|model| self.select_model(model, cx));
- self.set_inline_assistant_model(configured_model);
+ self.set_inline_assistant_model(configured_model, cx);
}
pub fn select_commit_message_model(
@@ -233,7 +236,7 @@ impl LanguageModelRegistry {
cx: &mut Context<Self>,
) {
let configured_model = model.and_then(|model| self.select_model(model, cx));
- self.set_commit_message_model(configured_model);
+ self.set_commit_message_model(configured_model, cx);
}
pub fn select_thread_summary_model(
@@ -242,7 +245,7 @@ impl LanguageModelRegistry {
cx: &mut Context<Self>,
) {
let configured_model = model.and_then(|model| self.select_model(model, cx));
- self.set_thread_summary_model(configured_model);
+ self.set_thread_summary_model(configured_model, cx);
}
/// Selects and sets the inline alternatives for language models based on
@@ -276,60 +279,68 @@ impl LanguageModelRegistry {
}
pub fn set_default_model(&mut self, model: Option<ConfiguredModel>, cx: &mut Context<Self>) {
- match (self.default_model(), model.as_ref()) {
+ match (self.default_model.as_ref(), model.as_ref()) {
(Some(old), Some(new)) if old.is_same_as(new) => {}
(None, None) => {}
_ => cx.emit(Event::DefaultModelChanged),
}
+ self.default_fast_model = maybe!({
+ let provider = &model.as_ref()?.provider;
+ let fast_model = provider.default_fast_model(cx)?;
+ Some(ConfiguredModel {
+ provider: provider.clone(),
+ model: fast_model,
+ })
+ });
self.default_model = model;
}
- pub fn set_environment_fallback_model(
+ pub fn set_inline_assistant_model(
&mut self,
model: Option<ConfiguredModel>,
cx: &mut Context<Self>,
) {
- if self.default_model.is_none() {
- match (self.environment_fallback_model.as_ref(), model.as_ref()) {
- (Some(old), Some(new)) if old.is_same_as(new) => {}
- (None, None) => {}
- _ => cx.emit(Event::DefaultModelChanged),
- }
+ match (self.inline_assistant_model.as_ref(), model.as_ref()) {
+ (Some(old), Some(new)) if old.is_same_as(new) => {}
+ (None, None) => {}
+ _ => cx.emit(Event::InlineAssistantModelChanged),
}
- self.environment_fallback_model = model;
- }
-
- pub fn set_inline_assistant_model(&mut self, model: Option<ConfiguredModel>) {
self.inline_assistant_model = model;
}
- pub fn set_commit_message_model(&mut self, model: Option<ConfiguredModel>) {
+ pub fn set_commit_message_model(
+ &mut self,
+ model: Option<ConfiguredModel>,
+ cx: &mut Context<Self>,
+ ) {
+ match (self.commit_message_model.as_ref(), model.as_ref()) {
+ (Some(old), Some(new)) if old.is_same_as(new) => {}
+ (None, None) => {}
+ _ => cx.emit(Event::CommitMessageModelChanged),
+ }
self.commit_message_model = model;
}
- pub fn set_thread_summary_model(&mut self, model: Option<ConfiguredModel>) {
+ pub fn set_thread_summary_model(
+ &mut self,
+ model: Option<ConfiguredModel>,
+ cx: &mut Context<Self>,
+ ) {
+ match (self.thread_summary_model.as_ref(), model.as_ref()) {
+ (Some(old), Some(new)) if old.is_same_as(new) => {}
+ (None, None) => {}
+ _ => cx.emit(Event::ThreadSummaryModelChanged),
+ }
self.thread_summary_model = model;
}
- #[track_caller]
pub fn default_model(&self) -> Option<ConfiguredModel> {
#[cfg(debug_assertions)]
if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() {
return None;
}
- self.default_model
- .clone()
- .or_else(|| self.environment_fallback_model.clone())
- }
-
- pub fn default_fast_model(&self, cx: &App) -> Option<ConfiguredModel> {
- let provider = self.default_model()?.provider;
- let fast_model = provider.default_fast_model(cx)?;
- Some(ConfiguredModel {
- provider,
- model: fast_model,
- })
+ self.default_model.clone()
}
pub fn inline_assistant_model(&self) -> Option<ConfiguredModel> {
@@ -343,7 +354,7 @@ impl LanguageModelRegistry {
.or_else(|| self.default_model.clone())
}
- pub fn commit_message_model(&self, cx: &App) -> Option<ConfiguredModel> {
+ pub fn commit_message_model(&self) -> Option<ConfiguredModel> {
#[cfg(debug_assertions)]
if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() {
return None;
@@ -351,11 +362,11 @@ impl LanguageModelRegistry {
self.commit_message_model
.clone()
- .or_else(|| self.default_fast_model(cx))
+ .or_else(|| self.default_fast_model.clone())
.or_else(|| self.default_model.clone())
}
- pub fn thread_summary_model(&self, cx: &App) -> Option<ConfiguredModel> {
+ pub fn thread_summary_model(&self) -> Option<ConfiguredModel> {
#[cfg(debug_assertions)]
if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() {
return None;
@@ -363,7 +374,7 @@ impl LanguageModelRegistry {
self.thread_summary_model
.clone()
- .or_else(|| self.default_fast_model(cx))
+ .or_else(|| self.default_fast_model.clone())
.or_else(|| self.default_model.clone())
}
@@ -400,34 +411,4 @@ mod tests {
let providers = registry.read(cx).providers();
assert!(providers.is_empty());
}
-
- #[gpui::test]
- async fn test_configure_environment_fallback_model(cx: &mut gpui::TestAppContext) {
- let registry = cx.new(|_| LanguageModelRegistry::default());
-
- let provider = FakeLanguageModelProvider::default();
- registry.update(cx, |registry, cx| {
- registry.register_provider(provider.clone(), cx);
- });
-
- cx.update(|cx| provider.authenticate(cx)).await.unwrap();
-
- registry.update(cx, |registry, cx| {
- let provider = registry.provider(&provider.id()).unwrap();
-
- registry.set_environment_fallback_model(
- Some(ConfiguredModel {
- provider: provider.clone(),
- model: provider.default_model(cx).unwrap(),
- }),
- cx,
- );
-
- let default_model = registry.default_model().unwrap();
- let fallback_model = registry.environment_fallback_model.clone().unwrap();
-
- assert_eq!(default_model.model.id(), fallback_model.model.id());
- assert_eq!(default_model.provider.id(), fallback_model.provider.id());
- });
- }
}
@@ -44,7 +44,6 @@ ollama = { workspace = true, features = ["schemars"] }
open_ai = { workspace = true, features = ["schemars"] }
open_router = { workspace = true, features = ["schemars"] }
partial-json-fixer.workspace = true
-project.workspace = true
release_channel.workspace = true
schemars.workspace = true
serde.workspace = true
@@ -3,12 +3,8 @@ use std::sync::Arc;
use ::settings::{Settings, SettingsStore};
use client::{Client, UserStore};
use collections::HashSet;
-use futures::future;
-use gpui::{App, AppContext as _, Context, Entity};
-use language_model::{
- AuthenticateError, ConfiguredModel, LanguageModelProviderId, LanguageModelRegistry,
-};
-use project::DisableAiSettings;
+use gpui::{App, Context, Entity};
+use language_model::{LanguageModelProviderId, LanguageModelRegistry};
use provider::deepseek::DeepSeekLanguageModelProvider;
pub mod provider;
@@ -17,7 +13,7 @@ pub mod ui;
use crate::provider::anthropic::AnthropicLanguageModelProvider;
use crate::provider::bedrock::BedrockLanguageModelProvider;
-use crate::provider::cloud::{self, CloudLanguageModelProvider};
+use crate::provider::cloud::CloudLanguageModelProvider;
use crate::provider::copilot_chat::CopilotChatLanguageModelProvider;
use crate::provider::google::GoogleLanguageModelProvider;
use crate::provider::lmstudio::LmStudioLanguageModelProvider;
@@ -52,13 +48,6 @@ pub fn init(user_store: Entity<UserStore>, client: Arc<Client>, cx: &mut App) {
cx,
);
});
-
- let mut already_authenticated = false;
- if !DisableAiSettings::get_global(cx).disable_ai {
- authenticate_all_providers(registry.clone(), cx);
- already_authenticated = true;
- }
-
cx.observe_global::<SettingsStore>(move |cx| {
let openai_compatible_providers_new = AllLanguageModelSettings::get_global(cx)
.openai_compatible
@@ -76,12 +65,6 @@ pub fn init(user_store: Entity<UserStore>, client: Arc<Client>, cx: &mut App) {
);
});
openai_compatible_providers = openai_compatible_providers_new;
- already_authenticated = false;
- }
-
- if !DisableAiSettings::get_global(cx).disable_ai && !already_authenticated {
- authenticate_all_providers(registry.clone(), cx);
- already_authenticated = true;
}
})
.detach();
@@ -168,83 +151,3 @@ fn register_language_model_providers(
registry.register_provider(XAiLanguageModelProvider::new(client.http_client(), cx), cx);
registry.register_provider(CopilotChatLanguageModelProvider::new(cx), cx);
}
-
-/// Authenticates all providers in the [`LanguageModelRegistry`].
-///
-/// We do this so that we can populate the language selector with all of the
-/// models from the configured providers.
-///
-/// This function won't do anything if AI is disabled.
-fn authenticate_all_providers(registry: Entity<LanguageModelRegistry>, cx: &mut App) {
- let providers_to_authenticate = registry
- .read(cx)
- .providers()
- .iter()
- .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx)))
- .collect::<Vec<_>>();
-
- let mut tasks = Vec::with_capacity(providers_to_authenticate.len());
-
- for (provider_id, provider_name, authenticate_task) in providers_to_authenticate {
- tasks.push(cx.background_spawn(async move {
- if let Err(err) = authenticate_task.await {
- if matches!(err, AuthenticateError::CredentialsNotFound) {
- // Since we're authenticating these providers in the
- // background for the purposes of populating the
- // language selector, we don't care about providers
- // where the credentials are not found.
- } else {
- // Some providers have noisy failure states that we
- // don't want to spam the logs with every time the
- // language model selector is initialized.
- //
- // Ideally these should have more clear failure modes
- // that we know are safe to ignore here, like what we do
- // with `CredentialsNotFound` above.
- match provider_id.0.as_ref() {
- "lmstudio" | "ollama" => {
- // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated".
- //
- // These fail noisily, so we don't log them.
- }
- "copilot_chat" => {
- // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors.
- }
- _ => {
- log::error!(
- "Failed to authenticate provider: {}: {err}",
- provider_name.0
- );
- }
- }
- }
- }
- }));
- }
-
- let all_authenticated_future = future::join_all(tasks);
-
- cx.spawn(async move |cx| {
- all_authenticated_future.await;
-
- registry
- .update(cx, |registry, cx| {
- let cloud_provider = registry.provider(&cloud::PROVIDER_ID);
- let fallback_model = cloud_provider
- .iter()
- .chain(registry.providers().iter())
- .find(|provider| provider.is_authenticated(cx))
- .and_then(|provider| {
- Some(ConfiguredModel {
- provider: provider.clone(),
- model: provider
- .default_model(cx)
- .or_else(|| provider.recommended_models(cx).first().cloned())?,
- })
- });
- registry.set_environment_fallback_model(fallback_model, cx);
- })
- .ok();
- })
- .detach();
-}
@@ -44,8 +44,8 @@ use crate::provider::anthropic::{AnthropicEventMapper, count_anthropic_tokens, i
use crate::provider::google::{GoogleEventMapper, into_google};
use crate::provider::open_ai::{OpenAiEventMapper, count_open_ai_tokens, into_open_ai};
-pub const PROVIDER_ID: LanguageModelProviderId = language_model::ZED_CLOUD_PROVIDER_ID;
-pub const PROVIDER_NAME: LanguageModelProviderName = language_model::ZED_CLOUD_PROVIDER_NAME;
+const PROVIDER_ID: LanguageModelProviderId = language_model::ZED_CLOUD_PROVIDER_ID;
+const PROVIDER_NAME: LanguageModelProviderName = language_model::ZED_CLOUD_PROVIDER_NAME;
#[derive(Default, Clone, Debug, PartialEq)]
pub struct ZedDotDevSettings {
@@ -146,7 +146,7 @@ impl State {
default_fast_model: None,
recommended_models: Vec::new(),
_fetch_models_task: cx.spawn(async move |this, cx| {
- maybe!(async {
+ maybe!(async move {
let (client, llm_api_token) = this
.read_with(cx, |this, _cx| (client.clone(), this.llm_api_token.clone()))?;
@@ -381,7 +381,7 @@ impl LanguageModel for OpenRouterLanguageModel {
fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
let model_id = self.model.id().trim().to_lowercase();
- if model_id.contains("gemini") || model_id.contains("grok-4") {
+ if model_id.contains("gemini") || model_id.contains("grok") {
LanguageModelToolSchemaFormat::JsonSchemaSubset
} else {
LanguageModelToolSchemaFormat::JsonSchema
@@ -319,7 +319,7 @@ impl LanguageModel for XAiLanguageModel {
}
fn tool_input_format(&self) -> LanguageModelToolSchemaFormat {
let model_id = self.model.id().trim().to_lowercase();
- if model_id.eq(x_ai::Model::Grok4.id()) {
+ if model_id.eq(x_ai::Model::Grok4.id()) || model_id.eq(x_ai::Model::GrokCodeFast1.id()) {
LanguageModelToolSchemaFormat::JsonSchemaSubset
} else {
LanguageModelToolSchemaFormat::JsonSchema
@@ -24,6 +24,7 @@ itertools.workspace = true
language.workspace = true
lsp.workspace = true
project.workspace = true
+proto.workspace = true
serde_json.workspace = true
settings.workspace = true
theme.workspace = true
@@ -4,7 +4,6 @@ use gpui::{
};
use itertools::Itertools;
use serde_json::json;
-use settings::get_key_equivalents;
use ui::{Button, ButtonStyle};
use ui::{
ButtonCommon, Clickable, Context, FluentBuilder, InteractiveElement, Label, LabelCommon,
@@ -169,7 +168,8 @@ impl Item for KeyContextView {
impl Render for KeyContextView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
use itertools::Itertools;
- let key_equivalents = get_key_equivalents(cx.keyboard_layout().id());
+
+ let key_equivalents = cx.keyboard_mapper().get_key_equivalents();
v_flex()
.id("key-context-view")
.overflow_scroll()
@@ -1,20 +1,20 @@
mod key_context_view;
-mod lsp_log;
-pub mod lsp_tool;
+pub mod lsp_button;
+pub mod lsp_log_view;
mod syntax_tree_view;
#[cfg(test)]
-mod lsp_log_tests;
+mod lsp_log_view_tests;
use gpui::{App, AppContext, Entity};
-pub use lsp_log::{LogStore, LspLogToolbarItemView, LspLogView};
+pub use lsp_log_view::LspLogView;
pub use syntax_tree_view::{SyntaxTreeToolbarItemView, SyntaxTreeView};
use ui::{Context, Window};
use workspace::{Item, ItemHandle, SplitDirection, Workspace};
pub fn init(cx: &mut App) {
- lsp_log::init(cx);
+ lsp_log_view::init(true, cx);
syntax_tree_view::init(cx);
key_context_view::init(cx);
}
@@ -11,7 +11,10 @@ use editor::{Editor, EditorEvent};
use gpui::{Corner, Entity, Subscription, Task, WeakEntity, actions};
use language::{BinaryStatus, BufferId, ServerHealth};
use lsp::{LanguageServerId, LanguageServerName, LanguageServerSelector};
-use project::{LspStore, LspStoreEvent, Worktree, project_settings::ProjectSettings};
+use project::{
+ LspStore, LspStoreEvent, Worktree, lsp_store::log_store::GlobalLogStore,
+ project_settings::ProjectSettings,
+};
use settings::{Settings as _, SettingsStore};
use ui::{
Context, ContextMenu, ContextMenuEntry, ContextMenuItem, DocumentationAside, DocumentationSide,
@@ -20,7 +23,7 @@ use ui::{
use workspace::{StatusItemView, Workspace};
-use crate::lsp_log::GlobalLogStore;
+use crate::lsp_log_view;
actions!(
lsp_tool,
@@ -30,7 +33,7 @@ actions!(
]
);
-pub struct LspTool {
+pub struct LspButton {
server_state: Entity<LanguageServerState>,
popover_menu_handle: PopoverMenuHandle<ContextMenu>,
lsp_menu: Option<Entity<ContextMenu>>,
@@ -121,9 +124,8 @@ impl LanguageServerState {
menu = menu.align_popover_bottom();
let lsp_logs = cx
.try_global::<GlobalLogStore>()
- .and_then(|lsp_logs| lsp_logs.0.upgrade());
- let lsp_store = self.lsp_store.upgrade();
- let Some((lsp_logs, lsp_store)) = lsp_logs.zip(lsp_store) else {
+ .map(|lsp_logs| lsp_logs.0.clone());
+ let Some(lsp_logs) = lsp_logs else {
return menu;
};
@@ -210,10 +212,11 @@ impl LanguageServerState {
};
let server_selector = server_info.server_selector();
- // TODO currently, Zed remote does not work well with the LSP logs
- // https://github.com/zed-industries/zed/issues/28557
- let has_logs = lsp_store.read(cx).as_local().is_some()
- && lsp_logs.read(cx).has_server_logs(&server_selector);
+ let is_remote = self
+ .lsp_store
+ .update(cx, |lsp_store, _| lsp_store.as_remote().is_some())
+ .unwrap_or(false);
+ let has_logs = is_remote || lsp_logs.read(cx).has_server_logs(&server_selector);
let status_color = server_info
.binary_status
@@ -241,10 +244,10 @@ impl LanguageServerState {
.as_ref()
.or_else(|| server_info.binary_status.as_ref()?.message.as_ref())
.cloned();
- let hover_label = if has_logs {
- Some("View Logs")
- } else if message.is_some() {
+ let hover_label = if message.is_some() {
Some("View Message")
+ } else if has_logs {
+ Some("View Logs")
} else {
None
};
@@ -288,16 +291,7 @@ impl LanguageServerState {
let server_name = server_info.name.clone();
let workspace = self.workspace.clone();
move |window, cx| {
- if has_logs {
- lsp_logs.update(cx, |lsp_logs, cx| {
- lsp_logs.open_server_trace(
- workspace.clone(),
- server_selector.clone(),
- window,
- cx,
- );
- });
- } else if let Some(message) = &message {
+ if let Some(message) = &message {
let Some(create_buffer) = workspace
.update(cx, |workspace, cx| {
workspace
@@ -347,6 +341,14 @@ impl LanguageServerState {
anyhow::Ok(())
})
.detach();
+ } else if has_logs {
+ lsp_log_view::open_server_trace(
+ &lsp_logs,
+ workspace.clone(),
+ server_selector.clone(),
+ window,
+ cx,
+ );
} else {
cx.propagate();
}
@@ -510,7 +512,7 @@ impl ServerData<'_> {
}
}
-impl LspTool {
+impl LspButton {
pub fn new(
workspace: &Workspace,
popover_menu_handle: PopoverMenuHandle<ContextMenu>,
@@ -518,37 +520,59 @@ impl LspTool {
cx: &mut Context<Self>,
) -> Self {
let settings_subscription =
- cx.observe_global_in::<SettingsStore>(window, move |lsp_tool, window, cx| {
+ cx.observe_global_in::<SettingsStore>(window, move |lsp_button, window, cx| {
if ProjectSettings::get_global(cx).global_lsp_settings.button {
- if lsp_tool.lsp_menu.is_none() {
- lsp_tool.refresh_lsp_menu(true, window, cx);
+ if lsp_button.lsp_menu.is_none() {
+ lsp_button.refresh_lsp_menu(true, window, cx);
}
- } else if lsp_tool.lsp_menu.take().is_some() {
+ } else if lsp_button.lsp_menu.take().is_some() {
cx.notify();
}
});
let lsp_store = workspace.project().read(cx).lsp_store();
+ let mut language_servers = LanguageServers::default();
+ for (_, status) in lsp_store.read(cx).language_server_statuses() {
+ language_servers.binary_statuses.insert(
+ status.name.clone(),
+ LanguageServerBinaryStatus {
+ status: BinaryStatus::None,
+ message: None,
+ },
+ );
+ }
+
let lsp_store_subscription =
- cx.subscribe_in(&lsp_store, window, |lsp_tool, _, e, window, cx| {
- lsp_tool.on_lsp_store_event(e, window, cx)
+ cx.subscribe_in(&lsp_store, window, |lsp_button, _, e, window, cx| {
+ lsp_button.on_lsp_store_event(e, window, cx)
});
- let state = cx.new(|_| LanguageServerState {
+ let server_state = cx.new(|_| LanguageServerState {
workspace: workspace.weak_handle(),
items: Vec::new(),
lsp_store: lsp_store.downgrade(),
active_editor: None,
- language_servers: LanguageServers::default(),
+ language_servers,
});
- Self {
- server_state: state,
+ let mut lsp_button = Self {
+ server_state,
popover_menu_handle,
lsp_menu: None,
lsp_menu_refresh: Task::ready(()),
_subscriptions: vec![settings_subscription, lsp_store_subscription],
+ };
+ if !lsp_button
+ .server_state
+ .read(cx)
+ .language_servers
+ .binary_statuses
+ .is_empty()
+ {
+ lsp_button.refresh_lsp_menu(true, window, cx);
}
+
+ lsp_button
}
fn on_lsp_store_event(
@@ -708,6 +732,25 @@ impl LspTool {
}
}
}
+ state
+ .lsp_store
+ .update(cx, |lsp_store, cx| {
+ for (server_id, status) in lsp_store.language_server_statuses() {
+ if let Some(worktree) = status.worktree.and_then(|worktree_id| {
+ lsp_store
+ .worktree_store()
+ .read(cx)
+ .worktree_for_id(worktree_id, cx)
+ }) {
+ server_ids_to_worktrees.insert(server_id, worktree.clone());
+ server_names_to_worktrees
+ .entry(status.name.clone())
+ .or_default()
+ .insert((worktree, server_id));
+ }
+ }
+ })
+ .ok();
let mut servers_per_worktree = BTreeMap::<SharedString, Vec<ServerData>>::new();
let mut servers_without_worktree = Vec::<ServerData>::new();
@@ -852,18 +895,18 @@ impl LspTool {
) {
if create_if_empty || self.lsp_menu.is_some() {
let state = self.server_state.clone();
- self.lsp_menu_refresh = cx.spawn_in(window, async move |lsp_tool, cx| {
+ self.lsp_menu_refresh = cx.spawn_in(window, async move |lsp_button, cx| {
cx.background_executor()
.timer(Duration::from_millis(30))
.await;
- lsp_tool
- .update_in(cx, |lsp_tool, window, cx| {
- lsp_tool.regenerate_items(cx);
+ lsp_button
+ .update_in(cx, |lsp_button, window, cx| {
+ lsp_button.regenerate_items(cx);
let menu = ContextMenu::build(window, cx, |menu, _, cx| {
state.update(cx, |state, cx| state.fill_menu(menu, cx))
});
- lsp_tool.lsp_menu = Some(menu.clone());
- lsp_tool.popover_menu_handle.refresh_menu(
+ lsp_button.lsp_menu = Some(menu.clone());
+ lsp_button.popover_menu_handle.refresh_menu(
window,
cx,
Rc::new(move |_, _| Some(menu.clone())),
@@ -876,7 +919,7 @@ impl LspTool {
}
}
-impl StatusItemView for LspTool {
+impl StatusItemView for LspButton {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn workspace::ItemHandle>,
@@ -899,9 +942,9 @@ impl StatusItemView for LspTool {
let _editor_subscription = cx.subscribe_in(
&editor,
window,
- |lsp_tool, _, e: &EditorEvent, window, cx| match e {
+ |lsp_button, _, e: &EditorEvent, window, cx| match e {
EditorEvent::ExcerptsAdded { buffer, .. } => {
- let updated = lsp_tool.server_state.update(cx, |state, cx| {
+ let updated = lsp_button.server_state.update(cx, |state, cx| {
if let Some(active_editor) = state.active_editor.as_mut() {
let buffer_id = buffer.read(cx).remote_id();
active_editor.editor_buffers.insert(buffer_id)
@@ -910,13 +953,13 @@ impl StatusItemView for LspTool {
}
});
if updated {
- lsp_tool.refresh_lsp_menu(false, window, cx);
+ lsp_button.refresh_lsp_menu(false, window, cx);
}
}
EditorEvent::ExcerptsRemoved {
removed_buffer_ids, ..
} => {
- let removed = lsp_tool.server_state.update(cx, |state, _| {
+ let removed = lsp_button.server_state.update(cx, |state, _| {
let mut removed = false;
if let Some(active_editor) = state.active_editor.as_mut() {
for id in removed_buffer_ids {
@@ -930,7 +973,7 @@ impl StatusItemView for LspTool {
removed
});
if removed {
- lsp_tool.refresh_lsp_menu(false, window, cx);
+ lsp_button.refresh_lsp_menu(false, window, cx);
}
}
_ => {}
@@ -960,7 +1003,7 @@ impl StatusItemView for LspTool {
}
}
-impl Render for LspTool {
+impl Render for LspButton {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
if self.server_state.read(cx).language_servers.is_empty() || self.lsp_menu.is_none() {
return div();
@@ -1005,11 +1048,11 @@ impl Render for LspTool {
(None, "All Servers Operational")
};
- let lsp_tool = cx.entity();
+ let lsp_button = cx.entity();
div().child(
PopoverMenu::new("lsp-tool")
- .menu(move |_, cx| lsp_tool.read(cx).lsp_menu.clone())
+ .menu(move |_, cx| lsp_button.read(cx).lsp_menu.clone())
.anchor(Corner::BottomLeft)
.with_handle(self.popover_menu_handle.clone())
.trigger_with_tooltip(
@@ -1,20 +1,24 @@
-use collections::{HashMap, VecDeque};
+use collections::VecDeque;
use copilot::Copilot;
use editor::{Editor, EditorEvent, actions::MoveToEnd, scroll::Autoscroll};
-use futures::{StreamExt, channel::mpsc};
use gpui::{
- AnyView, App, Context, Corner, Entity, EventEmitter, FocusHandle, Focusable, Global,
- IntoElement, ParentElement, Render, Styled, Subscription, WeakEntity, Window, actions, div,
+ AnyView, App, Context, Corner, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
+ ParentElement, Render, Styled, Subscription, WeakEntity, Window, actions, div,
};
use itertools::Itertools;
use language::{LanguageServerId, language_settings::SoftWrap};
use lsp::{
- IoKind, LanguageServer, LanguageServerName, LanguageServerSelector, MessageType,
+ LanguageServer, LanguageServerBinary, LanguageServerName, LanguageServerSelector, MessageType,
SetTraceParams, TraceValue, notification::SetTrace,
};
-use project::{Project, WorktreeId, search::SearchQuery};
+use project::{
+ Project,
+ lsp_store::log_store::{self, Event, LanguageServerKind, LogKind, LogStore, Message},
+ search::SearchQuery,
+};
use std::{any::TypeId, borrow::Cow, sync::Arc};
use ui::{Button, Checkbox, ContextMenu, Label, PopoverMenu, ToggleState, prelude::*};
+use util::ResultExt as _;
use workspace::{
SplitDirection, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId,
item::{Item, ItemHandle},
@@ -23,132 +27,53 @@ use workspace::{
use crate::get_or_create_tool;
-const SEND_LINE: &str = "\n// Send:";
-const RECEIVE_LINE: &str = "\n// Receive:";
-const MAX_STORED_LOG_ENTRIES: usize = 2000;
-
-pub struct LogStore {
- projects: HashMap<WeakEntity<Project>, ProjectState>,
- language_servers: HashMap<LanguageServerId, LanguageServerState>,
- copilot_log_subscription: Option<lsp::Subscription>,
- _copilot_subscription: Option<gpui::Subscription>,
- io_tx: mpsc::UnboundedSender<(LanguageServerId, IoKind, String)>,
-}
-
-struct ProjectState {
- _subscriptions: [gpui::Subscription; 2],
-}
-
-trait Message: AsRef<str> {
- type Level: Copy + std::fmt::Debug;
- fn should_include(&self, _: Self::Level) -> bool {
- true
- }
-}
-
-pub(super) struct LogMessage {
- message: String,
- typ: MessageType,
-}
-
-impl AsRef<str> for LogMessage {
- fn as_ref(&self) -> &str {
- &self.message
- }
-}
-
-impl Message for LogMessage {
- type Level = MessageType;
-
- fn should_include(&self, level: Self::Level) -> bool {
- match (self.typ, level) {
- (MessageType::ERROR, _) => true,
- (_, MessageType::ERROR) => false,
- (MessageType::WARNING, _) => true,
- (_, MessageType::WARNING) => false,
- (MessageType::INFO, _) => true,
- (_, MessageType::INFO) => false,
- _ => true,
- }
- }
-}
-
-pub(super) struct TraceMessage {
- message: String,
-}
-
-impl AsRef<str> for TraceMessage {
- fn as_ref(&self) -> &str {
- &self.message
- }
-}
-
-impl Message for TraceMessage {
- type Level = ();
-}
-
-struct RpcMessage {
- message: String,
-}
-
-impl AsRef<str> for RpcMessage {
- fn as_ref(&self) -> &str {
- &self.message
- }
-}
-
-impl Message for RpcMessage {
- type Level = ();
-}
-
-pub(super) struct LanguageServerState {
- name: Option<LanguageServerName>,
- worktree_id: Option<WorktreeId>,
- kind: LanguageServerKind,
- log_messages: VecDeque<LogMessage>,
- trace_messages: VecDeque<TraceMessage>,
- rpc_state: Option<LanguageServerRpcState>,
- trace_level: TraceValue,
- log_level: MessageType,
- io_logs_subscription: Option<lsp::Subscription>,
-}
-
-#[derive(PartialEq, Clone)]
-pub enum LanguageServerKind {
- Local { project: WeakEntity<Project> },
- Remote { project: WeakEntity<Project> },
- Global,
-}
-
-impl LanguageServerKind {
- fn is_remote(&self) -> bool {
- matches!(self, LanguageServerKind::Remote { .. })
- }
-}
-
-impl std::fmt::Debug for LanguageServerKind {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- LanguageServerKind::Local { .. } => write!(f, "LanguageServerKind::Local"),
- LanguageServerKind::Remote { .. } => write!(f, "LanguageServerKind::Remote"),
- LanguageServerKind::Global => write!(f, "LanguageServerKind::Global"),
- }
- }
-}
-
-impl LanguageServerKind {
- fn project(&self) -> Option<&WeakEntity<Project>> {
- match self {
- Self::Local { project } => Some(project),
- Self::Remote { project } => Some(project),
- Self::Global { .. } => None,
- }
- }
-}
-
-struct LanguageServerRpcState {
- rpc_messages: VecDeque<RpcMessage>,
- last_message_kind: Option<MessageKind>,
+pub fn open_server_trace(
+ log_store: &Entity<LogStore>,
+ workspace: WeakEntity<Workspace>,
+ server: LanguageServerSelector,
+ window: &mut Window,
+ cx: &mut App,
+) {
+ log_store.update(cx, |_, cx| {
+ cx.spawn_in(window, async move |log_store, cx| {
+ let Some(log_store) = log_store.upgrade() else {
+ return;
+ };
+ workspace
+ .update_in(cx, |workspace, window, cx| {
+ let project = workspace.project().clone();
+ let tool_log_store = log_store.clone();
+ let log_view = get_or_create_tool(
+ workspace,
+ SplitDirection::Right,
+ window,
+ cx,
+ move |window, cx| LspLogView::new(project, tool_log_store, window, cx),
+ );
+ log_view.update(cx, |log_view, cx| {
+ let server_id = match server {
+ LanguageServerSelector::Id(id) => Some(id),
+ LanguageServerSelector::Name(name) => {
+ log_store.read(cx).language_servers.iter().find_map(
+ |(id, state)| {
+ if state.name.as_ref() == Some(&name) {
+ Some(*id)
+ } else {
+ None
+ }
+ },
+ )
+ }
+ };
+ if let Some(server_id) = server_id {
+ log_view.show_rpc_trace_for_server(server_id, window, cx);
+ }
+ });
+ })
+ .ok();
+ })
+ .detach();
+ })
}
pub struct LspLogView {
@@ -167,32 +92,6 @@ pub struct LspLogToolbarItemView {
_log_view_subscription: Option<Subscription>,
}
-#[derive(Copy, Clone, PartialEq, Eq)]
-enum MessageKind {
- Send,
- Receive,
-}
-
-#[derive(Clone, Copy, Debug, Default, PartialEq)]
-pub enum LogKind {
- Rpc,
- Trace,
- #[default]
- Logs,
- ServerInfo,
-}
-
-impl LogKind {
- fn label(&self) -> &'static str {
- match self {
- LogKind::Rpc => RPC_MESSAGES,
- LogKind::Trace => SERVER_TRACE,
- LogKind::Logs => SERVER_LOGS,
- LogKind::ServerInfo => SERVER_INFO,
- }
- }
-}
-
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct LogMenuItem {
pub server_id: LanguageServerId,
@@ -212,59 +111,24 @@ actions!(
]
);
-pub(super) struct GlobalLogStore(pub WeakEntity<LogStore>);
-
-impl Global for GlobalLogStore {}
-
-pub fn init(cx: &mut App) {
- let log_store = cx.new(LogStore::new);
- cx.set_global(GlobalLogStore(log_store.downgrade()));
-
- cx.observe_new(move |workspace: &mut Workspace, _, cx| {
- let project = workspace.project();
- if project.read(cx).is_local() || project.read(cx).is_via_ssh() {
- log_store.update(cx, |store, cx| {
- store.add_project(project, cx);
- });
- }
-
- let log_store = log_store.clone();
- workspace.register_action(move |workspace, _: &OpenLanguageServerLogs, window, cx| {
- let project = workspace.project().read(cx);
- if project.is_local() || project.is_via_ssh() {
- let project = workspace.project().clone();
- let log_store = log_store.clone();
- get_or_create_tool(
- workspace,
- SplitDirection::Right,
- window,
- cx,
- move |window, cx| LspLogView::new(project, log_store, window, cx),
- );
- }
- });
- })
- .detach();
-}
-
-impl LogStore {
- pub fn new(cx: &mut Context<Self>) -> Self {
- let (io_tx, mut io_rx) = mpsc::unbounded();
+pub fn init(store_logs: bool, cx: &mut App) {
+ let log_store = log_store::init(store_logs, cx);
- let copilot_subscription = Copilot::global(cx).map(|copilot| {
+ log_store.update(cx, |_, cx| {
+ Copilot::global(cx).map(|copilot| {
let copilot = &copilot;
- cx.subscribe(copilot, |this, copilot, edit_prediction_event, cx| {
+ cx.subscribe(copilot, |log_store, copilot, edit_prediction_event, cx| {
if let copilot::Event::CopilotLanguageServerStarted = edit_prediction_event
&& let Some(server) = copilot.read(cx).language_server()
{
let server_id = server.server_id();
- let weak_this = cx.weak_entity();
- this.copilot_log_subscription =
+ let weak_lsp_store = cx.weak_entity();
+ log_store.copilot_log_subscription =
Some(server.on_notification::<copilot::request::LogMessage, _>(
move |params, cx| {
- weak_this
- .update(cx, |this, cx| {
- this.add_language_server_log(
+ weak_lsp_store
+ .update(cx, |lsp_store, cx| {
+ lsp_store.add_language_server_log(
server_id,
MessageType::LOG,
¶ms.message,
@@ -274,8 +138,9 @@ impl LogStore {
.ok();
},
));
+
let name = LanguageServerName::new_static("copilot");
- this.add_language_server(
+ log_store.add_language_server(
LanguageServerKind::Global,
server.server_id(),
Some(name),
@@ -285,429 +150,29 @@ impl LogStore {
);
}
})
- });
-
- let this = Self {
- copilot_log_subscription: None,
- _copilot_subscription: copilot_subscription,
- projects: HashMap::default(),
- language_servers: HashMap::default(),
- io_tx,
- };
-
- cx.spawn(async move |this, cx| {
- while let Some((server_id, io_kind, message)) = io_rx.next().await {
- if let Some(this) = this.upgrade() {
- this.update(cx, |this, cx| {
- this.on_io(server_id, io_kind, &message, cx);
- })?;
- }
- }
- anyhow::Ok(())
+ .detach();
})
- .detach_and_log_err(cx);
- this
- }
-
- pub fn add_project(&mut self, project: &Entity<Project>, cx: &mut Context<Self>) {
- let weak_project = project.downgrade();
- self.projects.insert(
- project.downgrade(),
- ProjectState {
- _subscriptions: [
- cx.observe_release(project, move |this, _, _| {
- this.projects.remove(&weak_project);
- this.language_servers
- .retain(|_, state| state.kind.project() != Some(&weak_project));
- }),
- cx.subscribe(project, |this, project, event, cx| {
- let server_kind = if project.read(cx).is_via_ssh() {
- LanguageServerKind::Remote {
- project: project.downgrade(),
- }
- } else {
- LanguageServerKind::Local {
- project: project.downgrade(),
- }
- };
+ });
- match event {
- project::Event::LanguageServerAdded(id, name, worktree_id) => {
- this.add_language_server(
- server_kind,
- *id,
- Some(name.clone()),
- *worktree_id,
- project
- .read(cx)
- .lsp_store()
- .read(cx)
- .language_server_for_id(*id),
- cx,
- );
- }
- project::Event::LanguageServerRemoved(id) => {
- this.remove_language_server(*id, cx);
- }
- project::Event::LanguageServerLog(id, typ, message) => {
- this.add_language_server(server_kind, *id, None, None, None, cx);
- match typ {
- project::LanguageServerLogType::Log(typ) => {
- this.add_language_server_log(*id, *typ, message, cx);
- }
- project::LanguageServerLogType::Trace(_) => {
- this.add_language_server_trace(*id, message, cx);
- }
- }
- }
- _ => {}
- }
- }),
- ],
- },
- );
- }
-
- pub(super) fn get_language_server_state(
- &mut self,
- id: LanguageServerId,
- ) -> Option<&mut LanguageServerState> {
- self.language_servers.get_mut(&id)
- }
-
- fn add_language_server(
- &mut self,
- kind: LanguageServerKind,
- server_id: LanguageServerId,
- name: Option<LanguageServerName>,
- worktree_id: Option<WorktreeId>,
- server: Option<Arc<LanguageServer>>,
- cx: &mut Context<Self>,
- ) -> Option<&mut LanguageServerState> {
- let server_state = self.language_servers.entry(server_id).or_insert_with(|| {
- cx.notify();
- LanguageServerState {
- name: None,
- worktree_id: None,
- kind,
- rpc_state: None,
- log_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES),
- trace_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES),
- trace_level: TraceValue::Off,
- log_level: MessageType::LOG,
- io_logs_subscription: None,
- }
+ cx.observe_new(move |workspace: &mut Workspace, _, cx| {
+ log_store.update(cx, |store, cx| {
+ store.add_project(workspace.project(), cx);
});
- if let Some(name) = name {
- server_state.name = Some(name);
- }
- if let Some(worktree_id) = worktree_id {
- server_state.worktree_id = Some(worktree_id);
- }
-
- if let Some(server) = server.filter(|_| server_state.io_logs_subscription.is_none()) {
- let io_tx = self.io_tx.clone();
- let server_id = server.server_id();
- server_state.io_logs_subscription = Some(server.on_io(move |io_kind, message| {
- io_tx
- .unbounded_send((server_id, io_kind, message.to_string()))
- .ok();
- }));
- }
-
- Some(server_state)
- }
-
- fn add_language_server_log(
- &mut self,
- id: LanguageServerId,
- typ: MessageType,
- message: &str,
- cx: &mut Context<Self>,
- ) -> Option<()> {
- let language_server_state = self.get_language_server_state(id)?;
-
- let log_lines = &mut language_server_state.log_messages;
- Self::add_language_server_message(
- log_lines,
- id,
- LogMessage {
- message: message.trim_end().to_string(),
- typ,
- },
- language_server_state.log_level,
- LogKind::Logs,
- cx,
- );
- Some(())
- }
-
- fn add_language_server_trace(
- &mut self,
- id: LanguageServerId,
- message: &str,
- cx: &mut Context<Self>,
- ) -> Option<()> {
- let language_server_state = self.get_language_server_state(id)?;
-
- let log_lines = &mut language_server_state.trace_messages;
- Self::add_language_server_message(
- log_lines,
- id,
- TraceMessage {
- message: message.trim().to_string(),
- },
- (),
- LogKind::Trace,
- cx,
- );
- Some(())
- }
-
- fn add_language_server_message<T: Message>(
- log_lines: &mut VecDeque<T>,
- id: LanguageServerId,
- message: T,
- current_severity: <T as Message>::Level,
- kind: LogKind,
- cx: &mut Context<Self>,
- ) {
- while log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES {
- log_lines.pop_front();
- }
- let text = message.as_ref().to_string();
- let visible = message.should_include(current_severity);
- log_lines.push_back(message);
-
- if visible {
- cx.emit(Event::NewServerLogEntry { id, kind, text });
- cx.notify();
- }
- }
-
- fn remove_language_server(&mut self, id: LanguageServerId, cx: &mut Context<Self>) {
- self.language_servers.remove(&id);
- cx.notify();
- }
-
- pub(super) fn server_logs(&self, server_id: LanguageServerId) -> Option<&VecDeque<LogMessage>> {
- Some(&self.language_servers.get(&server_id)?.log_messages)
- }
-
- pub(super) fn server_trace(
- &self,
- server_id: LanguageServerId,
- ) -> Option<&VecDeque<TraceMessage>> {
- Some(&self.language_servers.get(&server_id)?.trace_messages)
- }
-
- fn server_ids_for_project<'a>(
- &'a self,
- lookup_project: &'a WeakEntity<Project>,
- ) -> impl Iterator<Item = LanguageServerId> + 'a {
- self.language_servers
- .iter()
- .filter_map(move |(id, state)| match &state.kind {
- LanguageServerKind::Local { project } | LanguageServerKind::Remote { project } => {
- if project == lookup_project {
- Some(*id)
- } else {
- None
- }
- }
- LanguageServerKind::Global => Some(*id),
- })
- }
-
- fn enable_rpc_trace_for_language_server(
- &mut self,
- server_id: LanguageServerId,
- ) -> Option<&mut LanguageServerRpcState> {
- let rpc_state = self
- .language_servers
- .get_mut(&server_id)?
- .rpc_state
- .get_or_insert_with(|| LanguageServerRpcState {
- rpc_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES),
- last_message_kind: None,
- });
- Some(rpc_state)
- }
-
- pub fn disable_rpc_trace_for_language_server(
- &mut self,
- server_id: LanguageServerId,
- ) -> Option<()> {
- self.language_servers.get_mut(&server_id)?.rpc_state.take();
- Some(())
- }
-
- pub fn has_server_logs(&self, server: &LanguageServerSelector) -> bool {
- match server {
- LanguageServerSelector::Id(id) => self.language_servers.contains_key(id),
- LanguageServerSelector::Name(name) => self
- .language_servers
- .iter()
- .any(|(_, state)| state.name.as_ref() == Some(name)),
- }
- }
-
- pub fn open_server_log(
- &mut self,
- workspace: WeakEntity<Workspace>,
- server: LanguageServerSelector,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- cx.spawn_in(window, async move |log_store, cx| {
- let Some(log_store) = log_store.upgrade() else {
- return;
- };
- workspace
- .update_in(cx, |workspace, window, cx| {
- let project = workspace.project().clone();
- let tool_log_store = log_store.clone();
- let log_view = get_or_create_tool(
- workspace,
- SplitDirection::Right,
- window,
- cx,
- move |window, cx| LspLogView::new(project, tool_log_store, window, cx),
- );
- log_view.update(cx, |log_view, cx| {
- let server_id = match server {
- LanguageServerSelector::Id(id) => Some(id),
- LanguageServerSelector::Name(name) => {
- log_store.read(cx).language_servers.iter().find_map(
- |(id, state)| {
- if state.name.as_ref() == Some(&name) {
- Some(*id)
- } else {
- None
- }
- },
- )
- }
- };
- if let Some(server_id) = server_id {
- log_view.show_logs_for_server(server_id, window, cx);
- }
- });
- })
- .ok();
- })
- .detach();
- }
-
- pub fn open_server_trace(
- &mut self,
- workspace: WeakEntity<Workspace>,
- server: LanguageServerSelector,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- cx.spawn_in(window, async move |log_store, cx| {
- let Some(log_store) = log_store.upgrade() else {
- return;
- };
- workspace
- .update_in(cx, |workspace, window, cx| {
- let project = workspace.project().clone();
- let tool_log_store = log_store.clone();
- let log_view = get_or_create_tool(
- workspace,
- SplitDirection::Right,
- window,
- cx,
- move |window, cx| LspLogView::new(project, tool_log_store, window, cx),
- );
- log_view.update(cx, |log_view, cx| {
- let server_id = match server {
- LanguageServerSelector::Id(id) => Some(id),
- LanguageServerSelector::Name(name) => {
- log_store.read(cx).language_servers.iter().find_map(
- |(id, state)| {
- if state.name.as_ref() == Some(&name) {
- Some(*id)
- } else {
- None
- }
- },
- )
- }
- };
- if let Some(server_id) = server_id {
- log_view.show_rpc_trace_for_server(server_id, window, cx);
- }
- });
- })
- .ok();
- })
- .detach();
- }
-
- fn on_io(
- &mut self,
- language_server_id: LanguageServerId,
- io_kind: IoKind,
- message: &str,
- cx: &mut Context<Self>,
- ) -> Option<()> {
- let is_received = match io_kind {
- IoKind::StdOut => true,
- IoKind::StdIn => false,
- IoKind::StdErr => {
- self.add_language_server_log(language_server_id, MessageType::LOG, message, cx);
- return Some(());
- }
- };
-
- let state = self
- .get_language_server_state(language_server_id)?
- .rpc_state
- .as_mut()?;
- 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) {
- while rpc_log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES {
- rpc_log_lines.pop_front();
- }
- let line_before_message = match kind {
- MessageKind::Send => SEND_LINE,
- MessageKind::Receive => RECEIVE_LINE,
- };
- rpc_log_lines.push_back(RpcMessage {
- message: line_before_message.to_string(),
- });
- cx.emit(Event::NewServerLogEntry {
- id: language_server_id,
- kind: LogKind::Rpc,
- text: line_before_message.to_string(),
- });
- }
-
- while rpc_log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES {
- rpc_log_lines.pop_front();
- }
-
- let message = message.trim();
- rpc_log_lines.push_back(RpcMessage {
- message: message.to_string(),
- });
- cx.emit(Event::NewServerLogEntry {
- id: language_server_id,
- kind: LogKind::Rpc,
- text: message.to_string(),
+ let log_store = log_store.clone();
+ workspace.register_action(move |workspace, _: &OpenLanguageServerLogs, window, cx| {
+ let log_store = log_store.clone();
+ let project = workspace.project().clone();
+ get_or_create_tool(
+ workspace,
+ SplitDirection::Right,
+ window,
+ cx,
+ move |window, cx| LspLogView::new(project, log_store, window, cx),
+ );
});
- cx.notify();
- Some(())
- }
+ })
+ .detach();
}
impl LspLogView {
@@ -751,13 +216,14 @@ impl LspLogView {
cx.notify();
});
+
let events_subscriptions = cx.subscribe_in(
&log_store,
window,
move |log_view, _, e, window, cx| match e {
Event::NewServerLogEntry { id, kind, text } => {
if log_view.current_server_id == Some(*id)
- && *kind == log_view.active_entry_kind
+ && LogKind::from_server_log_type(kind) == log_view.active_entry_kind
{
log_view.editor.update(cx, |editor, cx| {
editor.set_read_only(false);
@@ -800,7 +266,7 @@ impl LspLogView {
window.focus(&log_view.editor.focus_handle(cx));
});
- let mut this = Self {
+ let mut lsp_log_view = Self {
focus_handle,
editor,
editor_subscriptions,
@@ -815,9 +281,9 @@ impl LspLogView {
],
};
if let Some(server_id) = server_id {
- this.show_logs_for_server(server_id, window, cx);
+ lsp_log_view.show_logs_for_server(server_id, window, cx);
}
- this
+ lsp_log_view
}
fn editor_for_logs(
@@ -838,7 +304,7 @@ impl LspLogView {
}
fn editor_for_server_info(
- server: &LanguageServer,
+ info: ServerInfo,
window: &mut Window,
cx: &mut Context<Self>,
) -> (Entity<Editor>, Vec<Subscription>) {
@@ -853,22 +319,21 @@ impl LspLogView {
* Capabilities: {CAPABILITIES}
* Configuration: {CONFIGURATION}",
- NAME = server.name(),
- ID = server.server_id(),
- BINARY = server.binary(),
- WORKSPACE_FOLDERS = server
- .workspace_folders()
- .into_iter()
- .filter_map(|path| path
- .to_file_path()
- .ok()
- .map(|path| path.to_string_lossy().into_owned()))
- .collect::<Vec<_>>()
- .join(", "),
- CAPABILITIES = serde_json::to_string_pretty(&server.capabilities())
+ NAME = info.name,
+ ID = info.id,
+ BINARY = info.binary.as_ref().map_or_else(
+ || "Unknown".to_string(),
+ |bin| bin.path.as_path().to_string_lossy().to_string()
+ ),
+ WORKSPACE_FOLDERS = info.workspace_folders.join(", "),
+ CAPABILITIES = serde_json::to_string_pretty(&info.capabilities)
.unwrap_or_else(|e| format!("Failed to serialize capabilities: {e}")),
- CONFIGURATION = serde_json::to_string_pretty(server.configuration())
- .unwrap_or_else(|e| format!("Failed to serialize configuration: {e}")),
+ CONFIGURATION = info
+ .configuration
+ .map(|configuration| serde_json::to_string_pretty(&configuration))
+ .transpose()
+ .unwrap_or_else(|e| Some(format!("Failed to serialize configuration: {e}")))
+ .unwrap_or_else(|| "Unknown".to_string()),
);
let editor = initialize_new_editor(server_info, false, window, cx);
let editor_subscription = cx.subscribe(
@@ -891,7 +356,9 @@ impl LspLogView {
.language_servers
.iter()
.map(|(server_id, state)| match &state.kind {
- LanguageServerKind::Local { .. } | LanguageServerKind::Remote { .. } => {
+ LanguageServerKind::Local { .. }
+ | LanguageServerKind::Remote { .. }
+ | LanguageServerKind::LocalSsh { .. } => {
let worktree_root_name = state
.worktree_id
.and_then(|id| self.project.read(cx).worktree_for_id(id, cx))
@@ -1003,11 +470,17 @@ impl LspLogView {
window: &mut Window,
cx: &mut Context<Self>,
) {
+ let trace_level = self
+ .log_store
+ .update(cx, |this, _| {
+ Some(this.get_language_server_state(server_id)?.trace_level)
+ })
+ .unwrap_or(TraceValue::Messages);
let log_contents = self
.log_store
.read(cx)
.server_trace(server_id)
- .map(|v| log_contents(v, ()));
+ .map(|v| log_contents(v, trace_level));
if let Some(log_contents) = log_contents {
self.current_server_id = Some(server_id);
self.active_entry_kind = LogKind::Trace;
@@ -1025,6 +498,7 @@ impl LspLogView {
window: &mut Window,
cx: &mut Context<Self>,
) {
+ self.toggle_rpc_trace_for_server(server_id, true, window, cx);
let rpc_log = self.log_store.update(cx, |log_store, _| {
log_store
.enable_rpc_trace_for_language_server(server_id)
@@ -1069,12 +543,33 @@ impl LspLogView {
window: &mut Window,
cx: &mut Context<Self>,
) {
- self.log_store.update(cx, |log_store, _| {
+ self.log_store.update(cx, |log_store, cx| {
if enabled {
log_store.enable_rpc_trace_for_language_server(server_id);
} else {
log_store.disable_rpc_trace_for_language_server(server_id);
}
+
+ if let Some(server_state) = log_store.language_servers.get(&server_id) {
+ if let LanguageServerKind::Remote { project } = &server_state.kind {
+ project
+ .update(cx, |project, cx| {
+ if let Some((client, project_id)) =
+ project.lsp_store().read(cx).upstream_client()
+ {
+ client
+ .send(proto::ToggleLspLogs {
+ project_id,
+ log_type: proto::toggle_lsp_logs::LogType::Rpc as i32,
+ server_id: server_id.to_proto(),
+ enabled,
+ })
+ .log_err();
+ }
+ })
+ .ok();
+ }
+ };
});
if !enabled && Some(server_id) == self.current_server_id {
self.show_logs_for_server(server_id, window, cx);
@@ -1113,13 +608,38 @@ impl LspLogView {
window: &mut Window,
cx: &mut Context<Self>,
) {
- let lsp_store = self.project.read(cx).lsp_store();
- let Some(server) = lsp_store.read(cx).language_server_for_id(server_id) else {
+ let Some(server_info) = self
+ .project
+ .read(cx)
+ .lsp_store()
+ .update(cx, |lsp_store, _| {
+ lsp_store
+ .language_server_for_id(server_id)
+ .as_ref()
+ .map(|language_server| ServerInfo::new(language_server))
+ .or_else(move || {
+ let capabilities =
+ lsp_store.lsp_server_capabilities.get(&server_id)?.clone();
+ let name = lsp_store
+ .language_server_statuses
+ .get(&server_id)
+ .map(|status| status.name.clone())?;
+ Some(ServerInfo {
+ id: server_id,
+ capabilities,
+ binary: None,
+ name,
+ workspace_folders: Vec::new(),
+ configuration: None,
+ })
+ })
+ })
+ else {
return;
};
self.current_server_id = Some(server_id);
self.active_entry_kind = LogKind::ServerInfo;
- let (editor, editor_subscriptions) = Self::editor_for_server_info(&server, window, cx);
+ let (editor, editor_subscriptions) = Self::editor_for_server_info(server_info, window, cx);
self.editor = editor;
self.editor_subscriptions = editor_subscriptions;
cx.notify();
@@ -1416,7 +936,6 @@ impl Render for LspLogToolbarItemView {
let view_selector = current_server.map(|server| {
let server_id = server.server_id;
- let is_remote = server.server_kind.is_remote();
let rpc_trace_enabled = server.rpc_trace_enabled;
let log_view = log_view.clone();
PopoverMenu::new("LspViewSelector")
@@ -1438,55 +957,53 @@ impl Render for LspLogToolbarItemView {
view.show_logs_for_server(server_id, window, cx);
}),
)
- .when(!is_remote, |this| {
- this.entry(
- SERVER_TRACE,
- None,
- window.handler_for(&log_view, move |view, window, cx| {
- view.show_trace_for_server(server_id, window, cx);
- }),
- )
- .custom_entry(
- {
- let log_toolbar_view = log_toolbar_view.clone();
- move |window, _| {
- h_flex()
- .w_full()
- .justify_between()
- .child(Label::new(RPC_MESSAGES))
- .child(
- div().child(
- Checkbox::new(
- "LspLogEnableRpcTrace",
- if rpc_trace_enabled {
+ .entry(
+ SERVER_TRACE,
+ None,
+ window.handler_for(&log_view, move |view, window, cx| {
+ view.show_trace_for_server(server_id, window, cx);
+ }),
+ )
+ .custom_entry(
+ {
+ let log_toolbar_view = log_toolbar_view.clone();
+ move |window, _| {
+ h_flex()
+ .w_full()
+ .justify_between()
+ .child(Label::new(RPC_MESSAGES))
+ .child(
+ div().child(
+ Checkbox::new(
+ "LspLogEnableRpcTrace",
+ if rpc_trace_enabled {
+ ToggleState::Selected
+ } else {
+ ToggleState::Unselected
+ },
+ )
+ .on_click(window.listener_for(
+ &log_toolbar_view,
+ move |view, selection, window, cx| {
+ let enabled = matches!(
+ selection,
ToggleState::Selected
- } else {
- ToggleState::Unselected
- },
- )
- .on_click(window.listener_for(
- &log_toolbar_view,
- move |view, selection, window, cx| {
- let enabled = matches!(
- selection,
- ToggleState::Selected
- );
- view.toggle_rpc_logging_for_server(
- server_id, enabled, window, cx,
- );
- cx.stop_propagation();
- },
- )),
- ),
- )
- .into_any_element()
- }
- },
- window.handler_for(&log_view, move |view, window, cx| {
- view.show_rpc_trace_for_server(server_id, window, cx);
- }),
- )
- })
+ );
+ view.toggle_rpc_logging_for_server(
+ server_id, enabled, window, cx,
+ );
+ cx.stop_propagation();
+ },
+ )),
+ ),
+ )
+ .into_any_element()
+ }
+ },
+ window.handler_for(&log_view, move |view, window, cx| {
+ view.show_rpc_trace_for_server(server_id, window, cx);
+ }),
+ )
.entry(
SERVER_INFO,
None,
@@ -1,20 +1,22 @@
use std::sync::Arc;
-use crate::lsp_log::LogMenuItem;
+use crate::lsp_log_view::LogMenuItem;
use super::*;
use futures::StreamExt;
use gpui::{AppContext as _, SemanticVersion, TestAppContext, VisualTestContext};
use language::{FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
use lsp::LanguageServerName;
-use lsp_log::LogKind;
-use project::{FakeFs, Project};
+use project::{
+ FakeFs, Project,
+ lsp_store::log_store::{LanguageServerKind, LogKind, LogStore},
+};
use serde_json::json;
use settings::SettingsStore;
use util::path;
#[gpui::test]
-async fn test_lsp_logs(cx: &mut TestAppContext) {
+async fn test_lsp_log_view(cx: &mut TestAppContext) {
zlog::init_test();
init_test(cx);
@@ -51,7 +53,7 @@ async fn test_lsp_logs(cx: &mut TestAppContext) {
},
);
- let log_store = cx.new(LogStore::new);
+ let log_store = cx.new(|cx| LogStore::new(true, cx));
log_store.update(cx, |store, cx| store.add_project(&project, cx));
let _rust_buffer = project
@@ -94,7 +96,7 @@ async fn test_lsp_logs(cx: &mut TestAppContext) {
rpc_trace_enabled: false,
selected_entry: LogKind::Logs,
trace_level: lsp::TraceValue::Off,
- server_kind: lsp_log::LanguageServerKind::Local {
+ server_kind: LanguageServerKind::Local {
project: project.downgrade()
}
}]
@@ -3,8 +3,27 @@
(namespace_identifier) @namespace
(concept_definition
- (identifier) @concept)
+ name: (identifier) @concept)
+(requires_clause
+ constraint: (template_type
+ name: (type_identifier) @concept))
+
+(module_name
+ (identifier) @module)
+
+(module_declaration
+ name: (module_name
+ (identifier) @module))
+
+(import_declaration
+ name: (module_name
+ (identifier) @module))
+
+(import_declaration
+ partition: (module_partition
+ (module_name
+ (identifier) @module)))
(call_expression
function: (qualified_identifier
@@ -61,6 +80,9 @@
(operator_name
(identifier)? @operator) @function
+(operator_name
+ "<=>" @operator.spaceship)
+
(destructor_name (identifier) @function)
((namespace_identifier) @type
@@ -68,21 +90,17 @@
(auto) @type
(type_identifier) @type
-type :(primitive_type) @type.primitive
-(sized_type_specifier) @type.primitive
-
-(requires_clause
- constraint: (template_type
- name: (type_identifier) @concept))
+type: (primitive_type) @type.builtin
+(sized_type_specifier) @type.builtin
(attribute
- name: (identifier) @keyword)
+ name: (identifier) @attribute)
-((identifier) @constant
- (#match? @constant "^_*[A-Z][A-Z\\d_]*$"))
+((identifier) @constant.builtin
+ (#match? @constant.builtin "^_*[A-Z][A-Z\\d_]*$"))
(statement_identifier) @label
-(this) @variable.special
+(this) @variable.builtin
("static_assert") @function.builtin
[
@@ -96,7 +114,9 @@ type :(primitive_type) @type.primitive
"co_return"
"co_yield"
"concept"
+ "consteval"
"constexpr"
+ "constinit"
"continue"
"decltype"
"default"
@@ -105,15 +125,20 @@ type :(primitive_type) @type.primitive
"else"
"enum"
"explicit"
+ "export"
"extern"
"final"
"for"
"friend"
+ "goto"
"if"
+ "import"
"inline"
+ "module"
"namespace"
"new"
"noexcept"
+ "operator"
"override"
"private"
"protected"
@@ -124,6 +149,7 @@ type :(primitive_type) @type.primitive
"struct"
"switch"
"template"
+ "thread_local"
"throw"
"try"
"typedef"
@@ -146,7 +172,7 @@ type :(primitive_type) @type.primitive
"#ifndef"
"#include"
(preproc_directive)
-] @keyword
+] @keyword.directive
(comment) @comment
@@ -224,10 +250,24 @@ type :(primitive_type) @type.primitive
">"
"<="
">="
- "<=>"
- "||"
"?"
+ "and"
+ "and_eq"
+ "bitand"
+ "bitor"
+ "compl"
+ "not"
+ "not_eq"
+ "or"
+ "or_eq"
+ "xor"
+ "xor_eq"
] @operator
+"<=>" @operator.spaceship
+
+(binary_expression
+ operator: "<=>" @operator.spaceship)
+
(conditional_expression ":" @operator)
(user_defined_literal (literal_suffix) @operator)
@@ -764,6 +764,7 @@ mod tests {
let highlight_type = grammar.highlight_id_for_name("type").unwrap();
let highlight_keyword = grammar.highlight_id_for_name("keyword").unwrap();
let highlight_number = grammar.highlight_id_for_name("number").unwrap();
+ let highlight_field = grammar.highlight_id_for_name("property").unwrap();
assert_eq!(
adapter
@@ -828,7 +829,7 @@ mod tests {
Some(CodeLabel {
text: "two.Three a.Bcd".to_string(),
filter_range: 0..9,
- runs: vec![(12..15, highlight_type)],
+ runs: vec![(4..9, highlight_field), (12..15, highlight_type)],
})
);
}
@@ -1,13 +1,13 @@
(identifier) @variable
(type_identifier) @type
-(field_identifier) @variable.member
+(field_identifier) @property
(package_identifier) @namespace
(keyed_element
.
(literal_element
- (identifier) @variable.member))
+ (identifier) @property))
(call_expression
function: (identifier) @function)
@@ -6,6 +6,7 @@ first_line_pattern = '^#!.*\b(?:[/ ]node|deno run.*--ext[= ]js)\b'
line_comments = ["// "]
block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }
documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 }
+wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "</", end_suffix = ">" }
autoclose_before = ";:.,=}])>"
brackets = [
{ start = "{", end = "}", close = true, newline = true },
@@ -2,6 +2,7 @@ use anyhow::{Context as _, ensure};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use collections::HashMap;
+use futures::AsyncBufReadExt;
use gpui::{App, Task};
use gpui::{AsyncApp, SharedString};
use language::Toolchain;
@@ -30,8 +31,6 @@ use std::{
borrow::Cow,
ffi::OsString,
fmt::Write,
- fs,
- io::{self, BufRead},
path::{Path, PathBuf},
sync::Arc,
};
@@ -741,14 +740,16 @@ fn env_priority(kind: Option<PythonEnvironmentKind>) -> usize {
/// Return the name of environment declared in <worktree-root/.venv.
///
/// https://virtualfish.readthedocs.io/en/latest/plugins.html#auto-activation-auto-activation
-fn get_worktree_venv_declaration(worktree_root: &Path) -> Option<String> {
- fs::File::open(worktree_root.join(".venv"))
- .and_then(|file| {
- let mut venv_name = String::new();
- io::BufReader::new(file).read_line(&mut venv_name)?;
- Ok(venv_name.trim().to_string())
- })
- .ok()
+async fn get_worktree_venv_declaration(worktree_root: &Path) -> Option<String> {
+ let file = async_fs::File::open(worktree_root.join(".venv"))
+ .await
+ .ok()?;
+ let mut venv_name = String::new();
+ smol::io::BufReader::new(file)
+ .read_line(&mut venv_name)
+ .await
+ .ok()?;
+ Some(venv_name.trim().to_string())
}
#[async_trait]
@@ -759,7 +760,7 @@ impl ToolchainLister for PythonToolchainProvider {
async fn list(
&self,
worktree_root: PathBuf,
- subroot_relative_path: Option<Arc<Path>>,
+ subroot_relative_path: Arc<Path>,
project_env: Option<HashMap<String, String>>,
) -> ToolchainList {
let env = project_env.unwrap_or_default();
@@ -771,13 +772,15 @@ impl ToolchainLister for PythonToolchainProvider {
);
let mut config = Configuration::default();
- let mut directories = vec![worktree_root.clone()];
- if let Some(subroot_relative_path) = subroot_relative_path {
- debug_assert!(subroot_relative_path.is_relative());
- directories.push(worktree_root.join(subroot_relative_path));
- }
-
- config.workspace_directories = Some(directories);
+ debug_assert!(subroot_relative_path.is_relative());
+ // `.ancestors()` will yield at least one path, so in case of empty `subroot_relative_path`, we'll just use
+ // worktree root as the workspace directory.
+ config.workspace_directories = Some(
+ subroot_relative_path
+ .ancestors()
+ .map(|ancestor| worktree_root.join(ancestor))
+ .collect(),
+ );
for locator in locators.iter() {
locator.configure(&config);
}
@@ -791,7 +794,7 @@ impl ToolchainLister for PythonToolchainProvider {
.map_or(Vec::new(), |mut guard| std::mem::take(&mut guard));
let wr = worktree_root;
- let wr_venv = get_worktree_venv_declaration(&wr);
+ let wr_venv = get_worktree_venv_declaration(&wr).await;
// Sort detected environments by:
// environment name matching activation file (<workdir>/.venv)
// environment project dir matching worktree_root
@@ -856,7 +859,7 @@ impl ToolchainLister for PythonToolchainProvider {
.into_iter()
.filter_map(|toolchain| {
let mut name = String::from("Python");
- if let Some(ref version) = toolchain.version {
+ if let Some(version) = &toolchain.version {
_ = write!(name, " {version}");
}
@@ -877,7 +880,7 @@ impl ToolchainLister for PythonToolchainProvider {
name: name.into(),
path: toolchain.executable.as_ref()?.to_str()?.to_owned().into(),
language_name: LanguageName::new("Python"),
- as_json: serde_json::to_value(toolchain).ok()?,
+ as_json: serde_json::to_value(toolchain.clone()).ok()?,
})
})
.collect();
@@ -891,6 +894,23 @@ impl ToolchainLister for PythonToolchainProvider {
fn term(&self) -> SharedString {
self.term.clone()
}
+ async fn activation_script(&self, toolchain: &Toolchain, fs: &dyn Fs) -> Option<String> {
+ let toolchain = serde_json::from_value::<pet_core::python_environment::PythonEnvironment>(
+ toolchain.as_json.clone(),
+ )
+ .ok()?;
+ let mut activation_script = None;
+ if let Some(prefix) = &toolchain.prefix {
+ #[cfg(not(target_os = "windows"))]
+ let path = prefix.join(BINARY_DIR).join("activate");
+ #[cfg(target_os = "windows")]
+ let path = prefix.join(BINARY_DIR).join("activate.ps1");
+ if fs.is_file(&path).await {
+ activation_script = Some(format!(". {}", path.display()));
+ }
+ }
+ activation_script
+ }
}
pub struct EnvironmentApi<'a> {
@@ -195,12 +195,13 @@ operator: "/" @operator
(attribute_item (attribute [
(identifier) @attribute
(scoped_identifier name: (identifier) @attribute)
+ (token_tree (identifier) @attribute (#match? @attribute "^[a-z\\d_]*$"))
+ (token_tree (identifier) @variable "::" (identifier) @type (#match? @type "^[A-Z]"))
]))
+
(inner_attribute_item (attribute [
(identifier) @attribute
(scoped_identifier name: (identifier) @attribute)
+ (token_tree (identifier) @attribute (#match? @attribute "^[a-z\\d_]*$"))
+ (token_tree (identifier) @variable "::" (identifier) @type (#match? @type "^[A-Z]"))
]))
-; Match nested snake case identifiers in attribute items.
-(token_tree (identifier) @attribute (#match? @attribute "^[a-z\\d_]*$"))
-; Override the attribute match for paths in scoped type/enum identifiers.
-(token_tree (identifier) @variable "::" (identifier) @type (#match? @type "^[A-Z]"))
@@ -4,6 +4,7 @@ path_suffixes = ["tsx"]
line_comments = ["// "]
block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }
documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 }
+wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "</", end_suffix = ">" }
autoclose_before = ";:.,=}])>"
brackets = [
{ start = "{", end = "}", close = true, newline = true },
@@ -5,6 +5,7 @@ first_line_pattern = '^#!.*\b(?:deno run|ts-node|bun|tsx|[/ ]node)\b'
line_comments = ["// "]
block_comment = { start = "/*", prefix = "* ", end = "*/", tab_size = 1 }
documentation_comment = { start = "/**", prefix = "* ", end = "*/", tab_size = 1 }
+wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "</", end_suffix = ">" }
autoclose_before = ";:.,=}])>"
brackets = [
{ start = "{", end = "}", close = true, newline = true },
@@ -1383,7 +1383,8 @@ impl LanguageServer {
self.notify::<DidChangeWorkspaceFolders>(¶ms).ok();
}
}
- /// Add new workspace folder to the list.
+
+ /// Remove existing workspace folder from the list.
pub fn remove_workspace_folder(&self, uri: Url) {
if self
.capabilities()
@@ -1323,7 +1323,7 @@ fn render_copy_code_block_button(
.icon_size(IconSize::Small)
.style(ButtonStyle::Filled)
.shape(ui::IconButtonShape::Square)
- .tooltip(Tooltip::text("Copy Code"))
+ .tooltip(Tooltip::text("Copy"))
.on_click({
let markdown = markdown;
move |_event, _window, cx| {
@@ -482,7 +482,7 @@ pub async fn stream_completion(
.method(Method::POST)
.uri(uri)
.header("Content-Type", "application/json")
- .header("Authorization", format!("Bearer {}", api_key));
+ .header("Authorization", format!("Bearer {}", api_key.trim()));
let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?;
let mut response = client.send(request).await?;
@@ -850,13 +850,19 @@ impl workspace::SerializableItem for Onboarding {
}
mod persistence {
- use db::{define_connection, query, sqlez_macros::sql};
+ use db::{
+ query,
+ sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
+ sqlez_macros::sql,
+ };
use workspace::WorkspaceDb;
- define_connection! {
- pub static ref ONBOARDING_PAGES: OnboardingPagesDb<WorkspaceDb> =
- &[
- sql!(
+ pub struct OnboardingPagesDb(ThreadSafeConnection);
+
+ impl Domain for OnboardingPagesDb {
+ const NAME: &str = stringify!(OnboardingPagesDb);
+
+ const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE onboarding_pages (
workspace_id INTEGER,
item_id INTEGER UNIQUE,
@@ -866,10 +872,11 @@ mod persistence {
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;
- ),
- ];
+ )];
}
+ db::static_connection!(ONBOARDING_PAGES, OnboardingPagesDb, [WorkspaceDb]);
+
impl OnboardingPagesDb {
query! {
pub async fn save_onboarding_page(
@@ -414,13 +414,19 @@ impl workspace::SerializableItem for WelcomePage {
}
mod persistence {
- use db::{define_connection, query, sqlez_macros::sql};
+ use db::{
+ query,
+ sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
+ sqlez_macros::sql,
+ };
use workspace::WorkspaceDb;
- define_connection! {
- pub static ref WELCOME_PAGES: WelcomePagesDb<WorkspaceDb> =
- &[
- sql!(
+ pub struct WelcomePagesDb(ThreadSafeConnection);
+
+ impl Domain for WelcomePagesDb {
+ const NAME: &str = stringify!(WelcomePagesDb);
+
+ const MIGRATIONS: &[&str] = (&[sql!(
CREATE TABLE welcome_pages (
workspace_id INTEGER,
item_id INTEGER UNIQUE,
@@ -430,10 +436,11 @@ mod persistence {
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;
- ),
- ];
+ )]);
}
+ db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]);
+
impl WelcomePagesDb {
query! {
pub async fn save_welcome_page(
@@ -461,7 +461,7 @@ pub async fn stream_completion(
.method(Method::POST)
.uri(uri)
.header("Content-Type", "application/json")
- .header("Authorization", format!("Bearer {}", api_key));
+ .header("Authorization", format!("Bearer {}", api_key.trim()));
let request = request_builder.body(AsyncBody::from(serde_json::to_string(&request)?))?;
let mut response = client.send(request).await?;
@@ -565,7 +565,7 @@ pub fn embed<'a>(
.method(Method::POST)
.uri(uri)
.header("Content-Type", "application/json")
- .header("Authorization", format!("Bearer {}", api_key))
+ .header("Authorization", format!("Bearer {}", api_key.trim()))
.body(body)
.map(|request| client.send(request));
@@ -424,7 +424,7 @@ pub async fn complete(
.method(Method::POST)
.uri(uri)
.header("Content-Type", "application/json")
- .header("Authorization", format!("Bearer {}", api_key))
+ .header("Authorization", format!("Bearer {}", api_key.trim()))
.header("HTTP-Referer", "https://zed.dev")
.header("X-Title", "Zed Editor");
@@ -5100,9 +5100,9 @@ impl EventEmitter<PanelEvent> for OutlinePanel {}
impl Render for OutlinePanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- let (is_local, is_via_ssh) = self
- .project
- .read_with(cx, |project, _| (project.is_local(), project.is_via_ssh()));
+ let (is_local, is_via_ssh) = self.project.read_with(cx, |project, _| {
+ (project.is_local(), project.is_via_remote_server())
+ });
let query = self.query(cx);
let pinned = self.pinned;
let settings = OutlinePanelSettings::get_global(cx);
@@ -63,7 +63,7 @@ pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf {
let abs_path = path
.canonicalize()
.expect("failed to canonicalize custom data directory's path to an absolute path");
- path = PathBuf::from(util::paths::SanitizedPath::from(abs_path))
+ path = util::paths::SanitizedPath::new(&abs_path).into()
}
std::fs::create_dir_all(&path).expect("failed to create custom data directory");
path
@@ -5,11 +5,8 @@ use super::{
session::{self, Session, SessionStateEvent},
};
use crate::{
- InlayHint, InlayHintLabel, ProjectEnvironment, ResolveState,
- debugger::session::SessionQuirks,
- project_settings::ProjectSettings,
- terminals::{SshCommand, wrap_for_ssh},
- worktree_store::WorktreeStore,
+ InlayHint, InlayHintLabel, ProjectEnvironment, ResolveState, debugger::session::SessionQuirks,
+ project_settings::ProjectSettings, worktree_store::WorktreeStore,
};
use anyhow::{Context as _, Result, anyhow};
use async_trait::async_trait;
@@ -34,7 +31,7 @@ use http_client::HttpClient;
use language::{Buffer, LanguageToolchainStore, language_settings::InlayHintKind};
use node_runtime::NodeRuntime;
-use remote::{SshInfo, SshRemoteClient, ssh_session::SshArgs};
+use remote::RemoteClient;
use rpc::{
AnyProtoClient, TypedEnvelope,
proto::{self},
@@ -68,7 +65,7 @@ pub enum DapStoreEvent {
enum DapStoreMode {
Local(LocalDapStore),
- Ssh(SshDapStore),
+ Remote(RemoteDapStore),
Collab,
}
@@ -80,8 +77,8 @@ pub struct LocalDapStore {
toolchain_store: Arc<dyn LanguageToolchainStore>,
}
-pub struct SshDapStore {
- ssh_client: Entity<SshRemoteClient>,
+pub struct RemoteDapStore {
+ remote_client: Entity<RemoteClient>,
upstream_client: AnyProtoClient,
upstream_project_id: u64,
}
@@ -147,16 +144,16 @@ impl DapStore {
Self::new(mode, breakpoint_store, worktree_store, cx)
}
- pub fn new_ssh(
+ pub fn new_remote(
project_id: u64,
- ssh_client: Entity<SshRemoteClient>,
+ remote_client: Entity<RemoteClient>,
breakpoint_store: Entity<BreakpointStore>,
worktree_store: Entity<WorktreeStore>,
cx: &mut Context<Self>,
) -> Self {
- let mode = DapStoreMode::Ssh(SshDapStore {
- upstream_client: ssh_client.read(cx).proto_client(),
- ssh_client,
+ let mode = DapStoreMode::Remote(RemoteDapStore {
+ upstream_client: remote_client.read(cx).proto_client(),
+ remote_client,
upstream_project_id: project_id,
});
@@ -242,64 +239,52 @@ impl DapStore {
Ok(binary)
})
}
- DapStoreMode::Ssh(ssh) => {
- let request = ssh.upstream_client.request(proto::GetDebugAdapterBinary {
- session_id: session_id.to_proto(),
- project_id: ssh.upstream_project_id,
- worktree_id: worktree.read(cx).id().to_proto(),
- definition: Some(definition.to_proto()),
- });
- let ssh_client = ssh.ssh_client.clone();
+ DapStoreMode::Remote(remote) => {
+ let request = remote
+ .upstream_client
+ .request(proto::GetDebugAdapterBinary {
+ session_id: session_id.to_proto(),
+ project_id: remote.upstream_project_id,
+ worktree_id: worktree.read(cx).id().to_proto(),
+ definition: Some(definition.to_proto()),
+ });
+ let remote = remote.remote_client.clone();
cx.spawn(async move |_, cx| {
let response = request.await?;
let binary = DebugAdapterBinary::from_proto(response)?;
- let (mut ssh_command, envs, path_style, ssh_shell) =
- ssh_client.read_with(cx, |ssh, _| {
- let SshInfo {
- args: SshArgs { arguments, envs },
- path_style,
- shell,
- } = ssh.ssh_info().context("SSH arguments not found")?;
- anyhow::Ok((
- SshCommand { arguments },
- envs.unwrap_or_default(),
- path_style,
- shell,
- ))
- })??;
-
- let mut connection = None;
- if let Some(c) = binary.connection {
- let local_bind_addr = Ipv4Addr::LOCALHOST;
- let port =
- dap::transport::TcpTransport::unused_port(local_bind_addr).await?;
- ssh_command.add_port_forwarding(port, c.host.to_string(), c.port);
+ let port_forwarding;
+ let connection;
+ if let Some(c) = binary.connection {
+ let host = Ipv4Addr::LOCALHOST;
+ let port = dap::transport::TcpTransport::unused_port(host).await?;
+ port_forwarding = Some((port, c.host.to_string(), c.port));
connection = Some(TcpArguments {
port,
- host: local_bind_addr,
+ host,
timeout: c.timeout,
})
+ } else {
+ port_forwarding = None;
+ connection = None;
}
- let (program, args) = wrap_for_ssh(
- &ssh_shell,
- &ssh_command,
- binary
- .command
- .as_ref()
- .map(|command| (command, &binary.arguments)),
- binary.cwd.as_deref(),
- binary.envs,
- None,
- path_style,
- );
+ let command = remote.read_with(cx, |remote, _cx| {
+ remote.build_command(
+ binary.command,
+ &binary.arguments,
+ &binary.envs,
+ binary.cwd.map(|path| path.display().to_string()),
+ None,
+ port_forwarding,
+ )
+ })??;
Ok(DebugAdapterBinary {
- command: Some(program),
- arguments: args,
- envs,
+ command: Some(command.program),
+ arguments: command.args,
+ envs: command.env,
cwd: None,
connection,
request_args: binary.request_args,
@@ -365,9 +350,9 @@ impl DapStore {
)))
}
}
- DapStoreMode::Ssh(ssh) => {
- let request = ssh.upstream_client.request(proto::RunDebugLocators {
- project_id: ssh.upstream_project_id,
+ DapStoreMode::Remote(remote) => {
+ let request = remote.upstream_client.request(proto::RunDebugLocators {
+ project_id: remote.upstream_project_id,
build_command: Some(build_command.to_proto()),
locator: locator_name.to_owned(),
});
@@ -44,7 +44,7 @@ use parking_lot::Mutex;
use postage::stream::Stream as _;
use rpc::{
AnyProtoClient, TypedEnvelope,
- proto::{self, FromProto, SSH_PROJECT_ID, ToProto, git_reset, split_repository_update},
+ proto::{self, FromProto, ToProto, git_reset, split_repository_update},
};
use serde::Deserialize;
use std::{
@@ -62,7 +62,7 @@ use std::{
};
use sum_tree::{Edit, SumTree, TreeSet};
use text::{Bias, BufferId};
-use util::{ResultExt, debug_panic, post_inc};
+use util::{ResultExt, debug_panic, paths::SanitizedPath, post_inc};
use worktree::{
File, PathChange, PathKey, PathProgress, PathSummary, PathTarget, ProjectEntryId,
UpdatedGitRepositoriesSet, UpdatedGitRepository, Worktree,
@@ -141,14 +141,10 @@ enum GitStoreState {
project_environment: Entity<ProjectEnvironment>,
fs: Arc<dyn Fs>,
},
- Ssh {
- upstream_client: AnyProtoClient,
- upstream_project_id: ProjectId,
- downstream: Option<(AnyProtoClient, ProjectId)>,
- },
Remote {
upstream_client: AnyProtoClient,
- upstream_project_id: ProjectId,
+ upstream_project_id: u64,
+ downstream: Option<(AnyProtoClient, ProjectId)>,
},
}
@@ -355,7 +351,7 @@ impl GitStore {
worktree_store: &Entity<WorktreeStore>,
buffer_store: Entity<BufferStore>,
upstream_client: AnyProtoClient,
- project_id: ProjectId,
+ project_id: u64,
cx: &mut Context<Self>,
) -> Self {
Self::new(
@@ -364,23 +360,6 @@ impl GitStore {
GitStoreState::Remote {
upstream_client,
upstream_project_id: project_id,
- },
- cx,
- )
- }
-
- pub fn ssh(
- worktree_store: &Entity<WorktreeStore>,
- buffer_store: Entity<BufferStore>,
- upstream_client: AnyProtoClient,
- cx: &mut Context<Self>,
- ) -> Self {
- Self::new(
- worktree_store.clone(),
- buffer_store,
- GitStoreState::Ssh {
- upstream_client,
- upstream_project_id: ProjectId(SSH_PROJECT_ID),
downstream: None,
},
cx,
@@ -451,7 +430,7 @@ impl GitStore {
pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
match &mut self.state {
- GitStoreState::Ssh {
+ GitStoreState::Remote {
downstream: downstream_client,
..
} => {
@@ -527,9 +506,6 @@ impl GitStore {
}),
});
}
- GitStoreState::Remote { .. } => {
- debug_panic!("shared called on remote store");
- }
}
}
@@ -541,15 +517,12 @@ impl GitStore {
} => {
downstream_client.take();
}
- GitStoreState::Ssh {
+ GitStoreState::Remote {
downstream: downstream_client,
..
} => {
downstream_client.take();
}
- GitStoreState::Remote { .. } => {
- debug_panic!("unshared called on remote store");
- }
}
self.shared_diffs.clear();
}
@@ -1047,21 +1020,17 @@ impl GitStore {
} => downstream_client
.as_ref()
.map(|state| (state.client.clone(), state.project_id)),
- GitStoreState::Ssh {
+ GitStoreState::Remote {
downstream: downstream_client,
..
} => downstream_client.clone(),
- GitStoreState::Remote { .. } => None,
}
}
fn upstream_client(&self) -> Option<AnyProtoClient> {
match &self.state {
GitStoreState::Local { .. } => None,
- GitStoreState::Ssh {
- upstream_client, ..
- }
- | GitStoreState::Remote {
+ GitStoreState::Remote {
upstream_client, ..
} => Some(upstream_client.clone()),
}
@@ -1431,12 +1400,7 @@ impl GitStore {
cx.background_executor()
.spawn(async move { fs.git_init(&path, fallback_branch_name) })
}
- GitStoreState::Ssh {
- upstream_client,
- upstream_project_id: project_id,
- ..
- }
- | GitStoreState::Remote {
+ GitStoreState::Remote {
upstream_client,
upstream_project_id: project_id,
..
@@ -1446,7 +1410,7 @@ impl GitStore {
cx.background_executor().spawn(async move {
client
.request(proto::GitInit {
- project_id: project_id.0,
+ project_id: project_id,
abs_path: path.to_string_lossy().to_string(),
fallback_branch_name,
})
@@ -1470,13 +1434,18 @@ impl GitStore {
cx.background_executor()
.spawn(async move { fs.git_clone(&repo, &path).await })
}
- GitStoreState::Ssh {
+ GitStoreState::Remote {
upstream_client,
upstream_project_id,
..
} => {
+ if upstream_client.is_via_collab() {
+ return Task::ready(Err(anyhow!(
+ "Git Clone isn't supported for project guests"
+ )));
+ }
let request = upstream_client.request(proto::GitClone {
- project_id: upstream_project_id.0,
+ project_id: *upstream_project_id,
abs_path: path.to_string_lossy().to_string(),
remote_repo: repo,
});
@@ -1490,9 +1459,6 @@ impl GitStore {
}
})
}
- GitStoreState::Remote { .. } => {
- Task::ready(Err(anyhow!("Git Clone isn't supported for remote users")))
- }
}
}
@@ -3267,6 +3233,7 @@ impl Repository {
let git_store = self.git_store.upgrade()?;
let worktree_store = git_store.read(cx).worktree_store.read(cx);
let abs_path = self.snapshot.work_directory_abs_path.join(&path.0);
+ let abs_path = SanitizedPath::new(&abs_path);
let (worktree, relative_path) = worktree_store.find_worktree(abs_path, cx)?;
Some(ProjectPath {
worktree_id: worktree.read(cx).id(),
@@ -11,18 +11,22 @@
//! Most of the interesting work happens at the local layer, as bulk of the complexity is with managing the lifecycle of language servers. The actual implementation of the LSP protocol is handled by [`lsp`] crate.
pub mod clangd_ext;
pub mod json_language_server_ext;
+pub mod log_store;
pub mod lsp_ext_command;
pub mod rust_analyzer_ext;
use crate::{
CodeAction, ColorPresentation, Completion, CompletionResponse, CompletionSource,
CoreCompletion, DocumentColor, Hover, InlayHint, LocationLink, LspAction, LspPullDiagnostics,
- ManifestProvidersStore, ProjectItem, ProjectPath, ProjectTransaction, PulledDiagnostics,
- ResolveState, Symbol,
+ ManifestProvidersStore, Project, ProjectItem, ProjectPath, ProjectTransaction,
+ PulledDiagnostics, ResolveState, Symbol,
buffer_store::{BufferStore, BufferStoreEvent},
environment::ProjectEnvironment,
lsp_command::{self, *},
- lsp_store,
+ lsp_store::{
+ self,
+ log_store::{GlobalLogStore, LanguageServerKind},
+ },
manifest_tree::{
LanguageServerTree, LanguageServerTreeNode, LaunchDisposition, ManifestQueryDelegate,
ManifestTree,
@@ -977,7 +981,9 @@ impl LocalLspStore {
this.update(&mut cx, |_, cx| {
cx.emit(LspStoreEvent::LanguageServerLog(
server_id,
- LanguageServerLogType::Trace(params.verbose),
+ LanguageServerLogType::Trace {
+ verbose_info: params.verbose,
+ },
params.message,
));
})
@@ -3180,7 +3186,7 @@ impl LocalLspStore {
} else {
let (path, pattern) = match &watcher.glob_pattern {
lsp::GlobPattern::String(s) => {
- let watcher_path = SanitizedPath::from(s);
+ let watcher_path = SanitizedPath::new(s);
let path = glob_literal_prefix(watcher_path.as_path());
let pattern = watcher_path
.as_path()
@@ -3272,7 +3278,7 @@ impl LocalLspStore {
let worktree_root_path = tree.abs_path();
match &watcher.glob_pattern {
lsp::GlobPattern::String(s) => {
- let watcher_path = SanitizedPath::from(s);
+ let watcher_path = SanitizedPath::new(s);
let relative = watcher_path
.as_path()
.strip_prefix(&worktree_root_path)
@@ -3482,13 +3488,13 @@ pub struct LspStore {
buffer_store: Entity<BufferStore>,
worktree_store: Entity<WorktreeStore>,
pub languages: Arc<LanguageRegistry>,
- language_server_statuses: BTreeMap<LanguageServerId, LanguageServerStatus>,
+ pub language_server_statuses: BTreeMap<LanguageServerId, LanguageServerStatus>,
active_entry: Option<ProjectEntryId>,
_maintain_workspace_config: (Task<Result<()>>, watch::Sender<()>),
_maintain_buffer_languages: Task<()>,
diagnostic_summaries:
HashMap<WorktreeId, HashMap<Arc<Path>, HashMap<LanguageServerId, DiagnosticSummary>>>,
- pub(super) lsp_server_capabilities: HashMap<LanguageServerId, lsp::ServerCapabilities>,
+ pub lsp_server_capabilities: HashMap<LanguageServerId, lsp::ServerCapabilities>,
lsp_document_colors: HashMap<BufferId, DocumentColorData>,
lsp_code_lens: HashMap<BufferId, CodeLensData>,
running_lsp_requests: HashMap<TypeId, (Global, HashMap<LspRequestId, Task<()>>)>,
@@ -3565,6 +3571,7 @@ pub struct LanguageServerStatus {
pub pending_work: BTreeMap<String, LanguageServerProgress>,
pub has_pending_diagnostic_updates: bool,
progress_tokens: HashSet<String>,
+ pub worktree: Option<WorktreeId>,
}
#[derive(Clone, Debug)]
@@ -7483,7 +7490,7 @@ impl LspStore {
server: Some(proto::LanguageServer {
id: server_id.to_proto(),
name: status.name.to_string(),
- worktree_id: None,
+ worktree_id: status.worktree.map(|id| id.to_proto()),
}),
capabilities: serde_json::to_string(&server.capabilities())
.expect("serializing server LSP capabilities"),
@@ -7508,9 +7515,15 @@ impl LspStore {
pub(crate) fn set_language_server_statuses_from_proto(
&mut self,
+ project: WeakEntity<Project>,
language_servers: Vec<proto::LanguageServer>,
server_capabilities: Vec<String>,
+ cx: &mut Context<Self>,
) {
+ let lsp_logs = cx
+ .try_global::<GlobalLogStore>()
+ .map(|lsp_store| lsp_store.0.clone());
+
self.language_server_statuses = language_servers
.into_iter()
.zip(server_capabilities)
@@ -7520,13 +7533,34 @@ impl LspStore {
self.lsp_server_capabilities
.insert(server_id, server_capabilities);
}
+
+ let name = LanguageServerName::from_proto(server.name);
+ let worktree = server.worktree_id.map(WorktreeId::from_proto);
+
+ if let Some(lsp_logs) = &lsp_logs {
+ lsp_logs.update(cx, |lsp_logs, cx| {
+ lsp_logs.add_language_server(
+ // Only remote clients get their language servers set from proto
+ LanguageServerKind::Remote {
+ project: project.clone(),
+ },
+ server_id,
+ Some(name.clone()),
+ worktree,
+ None,
+ cx,
+ );
+ });
+ }
+
(
server_id,
LanguageServerStatus {
- name: LanguageServerName::from_proto(server.name),
+ name,
pending_work: Default::default(),
has_pending_diagnostic_updates: false,
progress_tokens: Default::default(),
+ worktree,
},
)
})
@@ -8892,6 +8926,7 @@ impl LspStore {
pending_work: Default::default(),
has_pending_diagnostic_updates: false,
progress_tokens: Default::default(),
+ worktree: server.worktree_id.map(WorktreeId::from_proto),
},
);
cx.emit(LspStoreEvent::LanguageServerAdded(
@@ -10905,6 +10940,7 @@ impl LspStore {
pending_work: Default::default(),
has_pending_diagnostic_updates: false,
progress_tokens: Default::default(),
+ worktree: Some(key.worktree_id),
},
);
@@ -11702,6 +11738,20 @@ impl LspStore {
"workspace/didChangeConfiguration" => {
// Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings.
}
+ "workspace/didChangeWorkspaceFolders" => {
+ // In this case register options is an empty object, we can ignore it
+ let caps = lsp::WorkspaceFoldersServerCapabilities {
+ supported: Some(true),
+ change_notifications: Some(OneOf::Right(reg.id)),
+ };
+ server.update_capabilities(|capabilities| {
+ capabilities
+ .workspace
+ .get_or_insert_default()
+ .workspace_folders = Some(caps);
+ });
+ notify_server_capabilities_updated(&server, cx);
+ }
"workspace/symbol" => {
let options = parse_register_capabilities(reg)?;
server.update_capabilities(|capabilities| {
@@ -11778,17 +11828,15 @@ impl LspStore {
notify_server_capabilities_updated(&server, cx);
}
"textDocument/codeAction" => {
- if let Some(options) = reg
- .register_options
- .map(serde_json::from_value)
- .transpose()?
- {
- server.update_capabilities(|capabilities| {
- capabilities.code_action_provider =
- Some(lsp::CodeActionProviderCapability::Options(options));
- });
- notify_server_capabilities_updated(&server, cx);
- }
+ let options = parse_register_capabilities(reg)?;
+ let provider = match options {
+ OneOf::Left(value) => lsp::CodeActionProviderCapability::Simple(value),
+ OneOf::Right(caps) => caps,
+ };
+ server.update_capabilities(|capabilities| {
+ capabilities.code_action_provider = Some(provider);
+ });
+ notify_server_capabilities_updated(&server, cx);
}
"textDocument/definition" => {
let options = parse_register_capabilities(reg)?;
@@ -11810,16 +11858,15 @@ impl LspStore {
}
}
"textDocument/hover" => {
- if let Some(caps) = reg
- .register_options
- .map(serde_json::from_value)
- .transpose()?
- {
- server.update_capabilities(|capabilities| {
- capabilities.hover_provider = Some(caps);
- });
- notify_server_capabilities_updated(&server, cx);
- }
+ let options = parse_register_capabilities(reg)?;
+ let provider = match options {
+ OneOf::Left(value) => lsp::HoverProviderCapability::Simple(value),
+ OneOf::Right(caps) => caps,
+ };
+ server.update_capabilities(|capabilities| {
+ capabilities.hover_provider = Some(provider);
+ });
+ notify_server_capabilities_updated(&server, cx);
}
"textDocument/signatureHelp" => {
if let Some(caps) = reg
@@ -11904,16 +11951,15 @@ impl LspStore {
}
}
"textDocument/documentColor" => {
- if let Some(caps) = reg
- .register_options
- .map(serde_json::from_value)
- .transpose()?
- {
- server.update_capabilities(|capabilities| {
- capabilities.color_provider = Some(caps);
- });
- notify_server_capabilities_updated(&server, cx);
- }
+ let options = parse_register_capabilities(reg)?;
+ let provider = match options {
+ OneOf::Left(value) => lsp::ColorProviderCapability::Simple(value),
+ OneOf::Right(caps) => caps,
+ };
+ server.update_capabilities(|capabilities| {
+ capabilities.color_provider = Some(provider);
+ });
+ notify_server_capabilities_updated(&server, cx);
}
_ => log::warn!("unhandled capability registration: {reg:?}"),
}
@@ -11948,6 +11994,18 @@ impl LspStore {
"workspace/didChangeConfiguration" => {
// Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings.
}
+ "workspace/didChangeWorkspaceFolders" => {
+ server.update_capabilities(|capabilities| {
+ capabilities
+ .workspace
+ .get_or_insert_with(|| lsp::WorkspaceServerCapabilities {
+ workspace_folders: None,
+ file_operations: None,
+ })
+ .workspace_folders = None;
+ });
+ notify_server_capabilities_updated(&server, cx);
+ }
"workspace/symbol" => {
server.update_capabilities(|capabilities| {
capabilities.workspace_symbol_provider = None
@@ -12168,6 +12226,14 @@ impl LspStore {
let data = self.lsp_code_lens.get_mut(&buffer_id)?;
Some(data.update.take()?.1)
}
+
+ pub fn downstream_client(&self) -> Option<(AnyProtoClient, u64)> {
+ self.downstream_client.clone()
+ }
+
+ pub fn worktree_store(&self) -> Entity<WorktreeStore> {
+ self.worktree_store.clone()
+ }
}
// Registration with registerOptions as null, should fallback to true.
@@ -12677,45 +12743,69 @@ impl PartialEq for LanguageServerPromptRequest {
#[derive(Clone, Debug, PartialEq)]
pub enum LanguageServerLogType {
Log(MessageType),
- Trace(Option<String>),
+ Trace { verbose_info: Option<String> },
+ Rpc { received: bool },
}
impl LanguageServerLogType {
pub fn to_proto(&self) -> proto::language_server_log::LogType {
match self {
Self::Log(log_type) => {
- let message_type = match *log_type {
- MessageType::ERROR => 1,
- MessageType::WARNING => 2,
- MessageType::INFO => 3,
- MessageType::LOG => 4,
+ use proto::log_message::LogLevel;
+ let level = match *log_type {
+ MessageType::ERROR => LogLevel::Error,
+ MessageType::WARNING => LogLevel::Warning,
+ MessageType::INFO => LogLevel::Info,
+ MessageType::LOG => LogLevel::Log,
other => {
- log::warn!("Unknown lsp log message type: {:?}", other);
- 4
+ log::warn!("Unknown lsp log message type: {other:?}");
+ LogLevel::Log
}
};
- proto::language_server_log::LogType::LogMessageType(message_type)
+ proto::language_server_log::LogType::Log(proto::LogMessage {
+ level: level as i32,
+ })
}
- Self::Trace(message) => {
- proto::language_server_log::LogType::LogTrace(proto::LspLogTrace {
- message: message.clone(),
+ Self::Trace { verbose_info } => {
+ proto::language_server_log::LogType::Trace(proto::TraceMessage {
+ verbose_info: verbose_info.to_owned(),
})
}
+ Self::Rpc { received } => {
+ let kind = if *received {
+ proto::rpc_message::Kind::Received
+ } else {
+ proto::rpc_message::Kind::Sent
+ };
+ let kind = kind as i32;
+ proto::language_server_log::LogType::Rpc(proto::RpcMessage { kind })
+ }
}
}
pub fn from_proto(log_type: proto::language_server_log::LogType) -> Self {
+ use proto::log_message::LogLevel;
+ use proto::rpc_message;
match log_type {
- proto::language_server_log::LogType::LogMessageType(message_type) => {
- Self::Log(match message_type {
- 1 => MessageType::ERROR,
- 2 => MessageType::WARNING,
- 3 => MessageType::INFO,
- 4 => MessageType::LOG,
- _ => MessageType::LOG,
- })
- }
- proto::language_server_log::LogType::LogTrace(trace) => Self::Trace(trace.message),
+ proto::language_server_log::LogType::Log(message_type) => Self::Log(
+ match LogLevel::from_i32(message_type.level).unwrap_or(LogLevel::Log) {
+ LogLevel::Error => MessageType::ERROR,
+ LogLevel::Warning => MessageType::WARNING,
+ LogLevel::Info => MessageType::INFO,
+ LogLevel::Log => MessageType::LOG,
+ },
+ ),
+ proto::language_server_log::LogType::Trace(trace_message) => Self::Trace {
+ verbose_info: trace_message.verbose_info,
+ },
+ proto::language_server_log::LogType::Rpc(message) => Self::Rpc {
+ received: match rpc_message::Kind::from_i32(message.kind)
+ .unwrap_or(rpc_message::Kind::Received)
+ {
+ rpc_message::Kind::Received => true,
+ rpc_message::Kind::Sent => false,
+ },
+ },
}
}
}
@@ -0,0 +1,704 @@
+use std::{collections::VecDeque, sync::Arc};
+
+use collections::HashMap;
+use futures::{StreamExt, channel::mpsc};
+use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Global, Subscription, WeakEntity};
+use lsp::{
+ IoKind, LanguageServer, LanguageServerId, LanguageServerName, LanguageServerSelector,
+ MessageType, TraceValue,
+};
+use rpc::proto;
+use settings::WorktreeId;
+
+use crate::{LanguageServerLogType, LspStore, Project, ProjectItem as _};
+
+const SEND_LINE: &str = "\n// Send:";
+const RECEIVE_LINE: &str = "\n// Receive:";
+const MAX_STORED_LOG_ENTRIES: usize = 2000;
+
+const RPC_MESSAGES: &str = "RPC Messages";
+const SERVER_LOGS: &str = "Server Logs";
+const SERVER_TRACE: &str = "Server Trace";
+const SERVER_INFO: &str = "Server Info";
+
+pub fn init(store_logs: bool, cx: &mut App) -> Entity<LogStore> {
+ let log_store = cx.new(|cx| LogStore::new(store_logs, cx));
+ cx.set_global(GlobalLogStore(log_store.clone()));
+ log_store
+}
+
+pub struct GlobalLogStore(pub Entity<LogStore>);
+
+impl Global for GlobalLogStore {}
+
+#[derive(Debug)]
+pub enum Event {
+ NewServerLogEntry {
+ id: LanguageServerId,
+ kind: LanguageServerLogType,
+ text: String,
+ },
+}
+
+impl EventEmitter<Event> for LogStore {}
+
+pub struct LogStore {
+ store_logs: bool,
+ projects: HashMap<WeakEntity<Project>, ProjectState>,
+ pub copilot_log_subscription: Option<lsp::Subscription>,
+ pub language_servers: HashMap<LanguageServerId, LanguageServerState>,
+ io_tx: mpsc::UnboundedSender<(LanguageServerId, IoKind, String)>,
+}
+
+struct ProjectState {
+ _subscriptions: [Subscription; 2],
+}
+
+pub trait Message: AsRef<str> {
+ type Level: Copy + std::fmt::Debug;
+ fn should_include(&self, _: Self::Level) -> bool {
+ true
+ }
+}
+
+#[derive(Debug)]
+pub struct LogMessage {
+ message: String,
+ typ: MessageType,
+}
+
+impl AsRef<str> for LogMessage {
+ fn as_ref(&self) -> &str {
+ &self.message
+ }
+}
+
+impl Message for LogMessage {
+ type Level = MessageType;
+
+ fn should_include(&self, level: Self::Level) -> bool {
+ match (self.typ, level) {
+ (MessageType::ERROR, _) => true,
+ (_, MessageType::ERROR) => false,
+ (MessageType::WARNING, _) => true,
+ (_, MessageType::WARNING) => false,
+ (MessageType::INFO, _) => true,
+ (_, MessageType::INFO) => false,
+ _ => true,
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct TraceMessage {
+ message: String,
+ is_verbose: bool,
+}
+
+impl AsRef<str> for TraceMessage {
+ fn as_ref(&self) -> &str {
+ &self.message
+ }
+}
+
+impl Message for TraceMessage {
+ type Level = TraceValue;
+
+ fn should_include(&self, level: Self::Level) -> bool {
+ match level {
+ TraceValue::Off => false,
+ TraceValue::Messages => !self.is_verbose,
+ TraceValue::Verbose => true,
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct RpcMessage {
+ message: String,
+}
+
+impl AsRef<str> for RpcMessage {
+ fn as_ref(&self) -> &str {
+ &self.message
+ }
+}
+
+impl Message for RpcMessage {
+ type Level = ();
+}
+
+pub struct LanguageServerState {
+ pub name: Option<LanguageServerName>,
+ pub worktree_id: Option<WorktreeId>,
+ pub kind: LanguageServerKind,
+ log_messages: VecDeque<LogMessage>,
+ trace_messages: VecDeque<TraceMessage>,
+ pub rpc_state: Option<LanguageServerRpcState>,
+ pub trace_level: TraceValue,
+ pub log_level: MessageType,
+ io_logs_subscription: Option<lsp::Subscription>,
+}
+
+impl std::fmt::Debug for LanguageServerState {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("LanguageServerState")
+ .field("name", &self.name)
+ .field("worktree_id", &self.worktree_id)
+ .field("kind", &self.kind)
+ .field("log_messages", &self.log_messages)
+ .field("trace_messages", &self.trace_messages)
+ .field("rpc_state", &self.rpc_state)
+ .field("trace_level", &self.trace_level)
+ .field("log_level", &self.log_level)
+ .finish_non_exhaustive()
+ }
+}
+
+#[derive(PartialEq, Clone)]
+pub enum LanguageServerKind {
+ Local { project: WeakEntity<Project> },
+ Remote { project: WeakEntity<Project> },
+ LocalSsh { lsp_store: WeakEntity<LspStore> },
+ Global,
+}
+
+impl std::fmt::Debug for LanguageServerKind {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ LanguageServerKind::Local { .. } => write!(f, "LanguageServerKind::Local"),
+ LanguageServerKind::Remote { .. } => write!(f, "LanguageServerKind::Remote"),
+ LanguageServerKind::LocalSsh { .. } => write!(f, "LanguageServerKind::LocalSsh"),
+ LanguageServerKind::Global => write!(f, "LanguageServerKind::Global"),
+ }
+ }
+}
+
+impl LanguageServerKind {
+ pub fn project(&self) -> Option<&WeakEntity<Project>> {
+ match self {
+ Self::Local { project } => Some(project),
+ Self::Remote { project } => Some(project),
+ Self::LocalSsh { .. } => None,
+ Self::Global { .. } => None,
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct LanguageServerRpcState {
+ pub rpc_messages: VecDeque<RpcMessage>,
+ last_message_kind: Option<MessageKind>,
+}
+
+#[derive(Debug, Copy, Clone, PartialEq, Eq)]
+enum MessageKind {
+ Send,
+ Receive,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub enum LogKind {
+ Rpc,
+ Trace,
+ #[default]
+ Logs,
+ ServerInfo,
+}
+
+impl LogKind {
+ pub fn from_server_log_type(log_type: &LanguageServerLogType) -> Self {
+ match log_type {
+ LanguageServerLogType::Log(_) => Self::Logs,
+ LanguageServerLogType::Trace { .. } => Self::Trace,
+ LanguageServerLogType::Rpc { .. } => Self::Rpc,
+ }
+ }
+
+ pub fn label(&self) -> &'static str {
+ match self {
+ LogKind::Rpc => RPC_MESSAGES,
+ LogKind::Trace => SERVER_TRACE,
+ LogKind::Logs => SERVER_LOGS,
+ LogKind::ServerInfo => SERVER_INFO,
+ }
+ }
+}
+
+impl LogStore {
+ pub fn new(store_logs: bool, cx: &mut Context<Self>) -> Self {
+ let (io_tx, mut io_rx) = mpsc::unbounded();
+
+ let log_store = Self {
+ projects: HashMap::default(),
+ language_servers: HashMap::default(),
+ copilot_log_subscription: None,
+ store_logs,
+ io_tx,
+ };
+ cx.spawn(async move |log_store, cx| {
+ while let Some((server_id, io_kind, message)) = io_rx.next().await {
+ if let Some(log_store) = log_store.upgrade() {
+ log_store.update(cx, |log_store, cx| {
+ log_store.on_io(server_id, io_kind, &message, cx);
+ })?;
+ }
+ }
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+
+ log_store
+ }
+
+ pub fn add_project(&mut self, project: &Entity<Project>, cx: &mut Context<Self>) {
+ let weak_project = project.downgrade();
+ self.projects.insert(
+ project.downgrade(),
+ ProjectState {
+ _subscriptions: [
+ cx.observe_release(project, move |this, _, _| {
+ this.projects.remove(&weak_project);
+ this.language_servers
+ .retain(|_, state| state.kind.project() != Some(&weak_project));
+ }),
+ cx.subscribe(project, move |log_store, project, event, cx| {
+ let server_kind = if project.read(cx).is_local() {
+ LanguageServerKind::Local {
+ project: project.downgrade(),
+ }
+ } else {
+ LanguageServerKind::Remote {
+ project: project.downgrade(),
+ }
+ };
+ match event {
+ crate::Event::LanguageServerAdded(id, name, worktree_id) => {
+ log_store.add_language_server(
+ server_kind,
+ *id,
+ Some(name.clone()),
+ *worktree_id,
+ project
+ .read(cx)
+ .lsp_store()
+ .read(cx)
+ .language_server_for_id(*id),
+ cx,
+ );
+ }
+ crate::Event::LanguageServerBufferRegistered {
+ server_id,
+ buffer_id,
+ name,
+ ..
+ } => {
+ let worktree_id = project
+ .read(cx)
+ .buffer_for_id(*buffer_id, cx)
+ .and_then(|buffer| {
+ Some(buffer.read(cx).project_path(cx)?.worktree_id)
+ });
+ let name = name.clone().or_else(|| {
+ project
+ .read(cx)
+ .lsp_store()
+ .read(cx)
+ .language_server_statuses
+ .get(server_id)
+ .map(|status| status.name.clone())
+ });
+ log_store.add_language_server(
+ server_kind,
+ *server_id,
+ name,
+ worktree_id,
+ None,
+ cx,
+ );
+ }
+ crate::Event::LanguageServerRemoved(id) => {
+ log_store.remove_language_server(*id, cx);
+ }
+ crate::Event::LanguageServerLog(id, typ, message) => {
+ log_store.add_language_server(
+ server_kind,
+ *id,
+ None,
+ None,
+ None,
+ cx,
+ );
+ match typ {
+ crate::LanguageServerLogType::Log(typ) => {
+ log_store.add_language_server_log(*id, *typ, message, cx);
+ }
+ crate::LanguageServerLogType::Trace { verbose_info } => {
+ log_store.add_language_server_trace(
+ *id,
+ message,
+ verbose_info.clone(),
+ cx,
+ );
+ }
+ crate::LanguageServerLogType::Rpc { received } => {
+ let kind = if *received {
+ MessageKind::Receive
+ } else {
+ MessageKind::Send
+ };
+ log_store.add_language_server_rpc(*id, kind, message, cx);
+ }
+ }
+ }
+ crate::Event::ToggleLspLogs { server_id, enabled } => {
+ // we do not support any other log toggling yet
+ if *enabled {
+ log_store.enable_rpc_trace_for_language_server(*server_id);
+ } else {
+ log_store.disable_rpc_trace_for_language_server(*server_id);
+ }
+ }
+ _ => {}
+ }
+ }),
+ ],
+ },
+ );
+ }
+
+ pub fn get_language_server_state(
+ &mut self,
+ id: LanguageServerId,
+ ) -> Option<&mut LanguageServerState> {
+ self.language_servers.get_mut(&id)
+ }
+
+ pub fn add_language_server(
+ &mut self,
+ kind: LanguageServerKind,
+ server_id: LanguageServerId,
+ name: Option<LanguageServerName>,
+ worktree_id: Option<WorktreeId>,
+ server: Option<Arc<LanguageServer>>,
+ cx: &mut Context<Self>,
+ ) -> Option<&mut LanguageServerState> {
+ let server_state = self.language_servers.entry(server_id).or_insert_with(|| {
+ cx.notify();
+ LanguageServerState {
+ name: None,
+ worktree_id: None,
+ kind,
+ rpc_state: None,
+ log_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES),
+ trace_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES),
+ trace_level: TraceValue::Off,
+ log_level: MessageType::LOG,
+ io_logs_subscription: None,
+ }
+ });
+
+ if let Some(name) = name {
+ server_state.name = Some(name);
+ }
+ if let Some(worktree_id) = worktree_id {
+ server_state.worktree_id = Some(worktree_id);
+ }
+
+ if let Some(server) = server.filter(|_| server_state.io_logs_subscription.is_none()) {
+ let io_tx = self.io_tx.clone();
+ let server_id = server.server_id();
+ server_state.io_logs_subscription = Some(server.on_io(move |io_kind, message| {
+ io_tx
+ .unbounded_send((server_id, io_kind, message.to_string()))
+ .ok();
+ }));
+ }
+
+ Some(server_state)
+ }
+
+ pub fn add_language_server_log(
+ &mut self,
+ id: LanguageServerId,
+ typ: MessageType,
+ message: &str,
+ cx: &mut Context<Self>,
+ ) -> Option<()> {
+ let store_logs = self.store_logs;
+ let language_server_state = self.get_language_server_state(id)?;
+
+ let log_lines = &mut language_server_state.log_messages;
+ let message = message.trim_end().to_string();
+ if !store_logs {
+ // Send all messages regardless of the visibility in case of not storing, to notify the receiver anyway
+ self.emit_event(
+ Event::NewServerLogEntry {
+ id,
+ kind: LanguageServerLogType::Log(typ),
+ text: message,
+ },
+ cx,
+ );
+ } else if let Some(new_message) = Self::push_new_message(
+ log_lines,
+ LogMessage { message, typ },
+ language_server_state.log_level,
+ ) {
+ self.emit_event(
+ Event::NewServerLogEntry {
+ id,
+ kind: LanguageServerLogType::Log(typ),
+ text: new_message,
+ },
+ cx,
+ );
+ }
+ Some(())
+ }
+
+ fn add_language_server_trace(
+ &mut self,
+ id: LanguageServerId,
+ message: &str,
+ verbose_info: Option<String>,
+ cx: &mut Context<Self>,
+ ) -> Option<()> {
+ let store_logs = self.store_logs;
+ let language_server_state = self.get_language_server_state(id)?;
+
+ let log_lines = &mut language_server_state.trace_messages;
+ if !store_logs {
+ // Send all messages regardless of the visibility in case of not storing, to notify the receiver anyway
+ self.emit_event(
+ Event::NewServerLogEntry {
+ id,
+ kind: LanguageServerLogType::Trace { verbose_info },
+ text: message.trim().to_string(),
+ },
+ cx,
+ );
+ } else if let Some(new_message) = Self::push_new_message(
+ log_lines,
+ TraceMessage {
+ message: message.trim().to_string(),
+ is_verbose: false,
+ },
+ TraceValue::Messages,
+ ) {
+ if let Some(verbose_message) = verbose_info.as_ref() {
+ Self::push_new_message(
+ log_lines,
+ TraceMessage {
+ message: verbose_message.clone(),
+ is_verbose: true,
+ },
+ TraceValue::Verbose,
+ );
+ }
+ self.emit_event(
+ Event::NewServerLogEntry {
+ id,
+ kind: LanguageServerLogType::Trace { verbose_info },
+ text: new_message,
+ },
+ cx,
+ );
+ }
+ Some(())
+ }
+
+ fn push_new_message<T: Message>(
+ log_lines: &mut VecDeque<T>,
+ message: T,
+ current_severity: <T as Message>::Level,
+ ) -> Option<String> {
+ while log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES {
+ log_lines.pop_front();
+ }
+ let visible = message.should_include(current_severity);
+
+ let visible_message = visible.then(|| message.as_ref().to_string());
+ log_lines.push_back(message);
+ visible_message
+ }
+
+ fn add_language_server_rpc(
+ &mut self,
+ language_server_id: LanguageServerId,
+ kind: MessageKind,
+ message: &str,
+ cx: &mut Context<'_, Self>,
+ ) {
+ let store_logs = self.store_logs;
+ let Some(state) = self
+ .get_language_server_state(language_server_id)
+ .and_then(|state| state.rpc_state.as_mut())
+ else {
+ return;
+ };
+
+ let received = kind == MessageKind::Receive;
+ let rpc_log_lines = &mut state.rpc_messages;
+ if state.last_message_kind != Some(kind) {
+ while rpc_log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES {
+ rpc_log_lines.pop_front();
+ }
+ let line_before_message = match kind {
+ MessageKind::Send => SEND_LINE,
+ MessageKind::Receive => RECEIVE_LINE,
+ };
+ if store_logs {
+ rpc_log_lines.push_back(RpcMessage {
+ message: line_before_message.to_string(),
+ });
+ }
+ // Do not send a synthetic message over the wire, it will be derived from the actual RPC message
+ cx.emit(Event::NewServerLogEntry {
+ id: language_server_id,
+ kind: LanguageServerLogType::Rpc { received },
+ text: line_before_message.to_string(),
+ });
+ }
+
+ while rpc_log_lines.len() + 1 >= MAX_STORED_LOG_ENTRIES {
+ rpc_log_lines.pop_front();
+ }
+
+ if store_logs {
+ rpc_log_lines.push_back(RpcMessage {
+ message: message.trim().to_owned(),
+ });
+ }
+
+ self.emit_event(
+ Event::NewServerLogEntry {
+ id: language_server_id,
+ kind: LanguageServerLogType::Rpc { received },
+ text: message.to_owned(),
+ },
+ cx,
+ );
+ }
+
+ pub fn remove_language_server(&mut self, id: LanguageServerId, cx: &mut Context<Self>) {
+ self.language_servers.remove(&id);
+ cx.notify();
+ }
+
+ pub fn server_logs(&self, server_id: LanguageServerId) -> Option<&VecDeque<LogMessage>> {
+ Some(&self.language_servers.get(&server_id)?.log_messages)
+ }
+
+ pub fn server_trace(&self, server_id: LanguageServerId) -> Option<&VecDeque<TraceMessage>> {
+ Some(&self.language_servers.get(&server_id)?.trace_messages)
+ }
+
+ pub fn server_ids_for_project<'a>(
+ &'a self,
+ lookup_project: &'a WeakEntity<Project>,
+ ) -> impl Iterator<Item = LanguageServerId> + 'a {
+ self.language_servers
+ .iter()
+ .filter_map(move |(id, state)| match &state.kind {
+ LanguageServerKind::Local { project } | LanguageServerKind::Remote { project } => {
+ if project == lookup_project {
+ Some(*id)
+ } else {
+ None
+ }
+ }
+ LanguageServerKind::Global | LanguageServerKind::LocalSsh { .. } => Some(*id),
+ })
+ }
+
+ pub fn enable_rpc_trace_for_language_server(
+ &mut self,
+ server_id: LanguageServerId,
+ ) -> Option<&mut LanguageServerRpcState> {
+ let rpc_state = self
+ .language_servers
+ .get_mut(&server_id)?
+ .rpc_state
+ .get_or_insert_with(|| LanguageServerRpcState {
+ rpc_messages: VecDeque::with_capacity(MAX_STORED_LOG_ENTRIES),
+ last_message_kind: None,
+ });
+ Some(rpc_state)
+ }
+
+ pub fn disable_rpc_trace_for_language_server(
+ &mut self,
+ server_id: LanguageServerId,
+ ) -> Option<()> {
+ self.language_servers.get_mut(&server_id)?.rpc_state.take();
+ Some(())
+ }
+
+ pub fn has_server_logs(&self, server: &LanguageServerSelector) -> bool {
+ match server {
+ LanguageServerSelector::Id(id) => self.language_servers.contains_key(id),
+ LanguageServerSelector::Name(name) => self
+ .language_servers
+ .iter()
+ .any(|(_, state)| state.name.as_ref() == Some(name)),
+ }
+ }
+
+ fn on_io(
+ &mut self,
+ language_server_id: LanguageServerId,
+ io_kind: IoKind,
+ message: &str,
+ cx: &mut Context<Self>,
+ ) -> Option<()> {
+ let is_received = match io_kind {
+ IoKind::StdOut => true,
+ IoKind::StdIn => false,
+ IoKind::StdErr => {
+ self.add_language_server_log(language_server_id, MessageType::LOG, message, cx);
+ return Some(());
+ }
+ };
+
+ let kind = if is_received {
+ MessageKind::Receive
+ } else {
+ MessageKind::Send
+ };
+
+ self.add_language_server_rpc(language_server_id, kind, message, cx);
+ cx.notify();
+ Some(())
+ }
+
+ fn emit_event(&mut self, e: Event, cx: &mut Context<Self>) {
+ match &e {
+ Event::NewServerLogEntry { id, kind, text } => {
+ if let Some(state) = self.get_language_server_state(*id) {
+ let downstream_client = match &state.kind {
+ LanguageServerKind::Remote { project }
+ | LanguageServerKind::Local { project } => project
+ .upgrade()
+ .map(|project| project.read(cx).lsp_store()),
+ LanguageServerKind::LocalSsh { lsp_store } => lsp_store.upgrade(),
+ LanguageServerKind::Global => None,
+ }
+ .and_then(|lsp_store| lsp_store.read(cx).downstream_client());
+ if let Some((client, project_id)) = downstream_client {
+ client
+ .send(proto::LanguageServerLog {
+ project_id,
+ language_server_id: id.to_proto(),
+ message: text.clone(),
+ log_type: Some(kind.to_proto()),
+ })
+ .ok();
+ }
+ }
+ }
+ }
+
+ cx.emit(e);
+ }
+}
@@ -42,9 +42,7 @@ pub use manifest_tree::ManifestTree;
use anyhow::{Context as _, Result, anyhow};
use buffer_store::{BufferStore, BufferStoreEvent};
-use client::{
- Client, Collaborator, PendingEntitySubscription, ProjectId, TypedEnvelope, UserStore, proto,
-};
+use client::{Client, Collaborator, PendingEntitySubscription, TypedEnvelope, UserStore, proto};
use clock::ReplicaId;
use dap::client::DebugAdapterClient;
@@ -89,10 +87,10 @@ use node_runtime::NodeRuntime;
use parking_lot::Mutex;
pub use prettier_store::PrettierStore;
use project_settings::{ProjectSettings, SettingsObserver, SettingsObserverEvent};
-use remote::{SshConnectionOptions, SshRemoteClient};
+use remote::{RemoteClient, SshConnectionOptions};
use rpc::{
AnyProtoClient, ErrorCode,
- proto::{FromProto, LanguageServerPromptResponse, SSH_PROJECT_ID, ToProto},
+ proto::{FromProto, LanguageServerPromptResponse, REMOTE_SERVER_PROJECT_ID, ToProto},
};
use search::{SearchInputKind, SearchQuery, SearchResult};
use search_history::SearchHistory;
@@ -177,12 +175,12 @@ pub struct Project {
dap_store: Entity<DapStore>,
breakpoint_store: Entity<BreakpointStore>,
- client: Arc<client::Client>,
+ collab_client: Arc<client::Client>,
join_project_response_message_id: u32,
task_store: Entity<TaskStore>,
user_store: Entity<UserStore>,
fs: Arc<dyn Fs>,
- ssh_client: Option<Entity<SshRemoteClient>>,
+ remote_client: Option<Entity<RemoteClient>>,
client_state: ProjectClientState,
git_store: Entity<GitStore>,
collaborators: HashMap<proto::PeerId, Collaborator>,
@@ -282,6 +280,11 @@ pub enum Event {
server_id: LanguageServerId,
buffer_id: BufferId,
buffer_abs_path: PathBuf,
+ name: Option<LanguageServerName>,
+ },
+ ToggleLspLogs {
+ server_id: LanguageServerId,
+ enabled: bool,
},
Toast {
notification_id: SharedString,
@@ -1003,6 +1006,7 @@ impl Project {
client.add_entity_request_handler(Self::handle_open_buffer_by_path);
client.add_entity_request_handler(Self::handle_open_new_buffer);
client.add_entity_message_handler(Self::handle_create_buffer_for_peer);
+ client.add_entity_message_handler(Self::handle_toggle_lsp_logs);
WorktreeStore::init(&client);
BufferStore::init(&client);
@@ -1154,12 +1158,12 @@ impl Project {
active_entry: None,
snippets,
languages,
- client,
+ collab_client: client,
task_store,
user_store,
settings_observer,
fs,
- ssh_client: None,
+ remote_client: None,
breakpoint_store,
dap_store,
@@ -1183,8 +1187,8 @@ impl Project {
})
}
- pub fn ssh(
- ssh: Entity<SshRemoteClient>,
+ pub fn remote(
+ remote: Entity<RemoteClient>,
client: Arc<Client>,
node: NodeRuntime,
user_store: Entity<UserStore>,
@@ -1200,10 +1204,15 @@ impl Project {
let snippets =
SnippetProvider::new(fs.clone(), BTreeSet::from_iter([global_snippets_dir]), cx);
- let (ssh_proto, path_style) =
- ssh.read_with(cx, |ssh, _| (ssh.proto_client(), ssh.path_style()));
+ let (remote_proto, path_style) =
+ remote.read_with(cx, |remote, _| (remote.proto_client(), remote.path_style()));
let worktree_store = cx.new(|_| {
- WorktreeStore::remote(false, ssh_proto.clone(), SSH_PROJECT_ID, path_style)
+ WorktreeStore::remote(
+ false,
+ remote_proto.clone(),
+ REMOTE_SERVER_PROJECT_ID,
+ path_style,
+ )
});
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
.detach();
@@ -1215,31 +1224,32 @@ impl Project {
let buffer_store = cx.new(|cx| {
BufferStore::remote(
worktree_store.clone(),
- ssh.read(cx).proto_client(),
- SSH_PROJECT_ID,
+ remote.read(cx).proto_client(),
+ REMOTE_SERVER_PROJECT_ID,
cx,
)
});
let image_store = cx.new(|cx| {
ImageStore::remote(
worktree_store.clone(),
- ssh.read(cx).proto_client(),
- SSH_PROJECT_ID,
+ remote.read(cx).proto_client(),
+ REMOTE_SERVER_PROJECT_ID,
cx,
)
});
cx.subscribe(&buffer_store, Self::on_buffer_store_event)
.detach();
- let toolchain_store = cx
- .new(|cx| ToolchainStore::remote(SSH_PROJECT_ID, ssh.read(cx).proto_client(), cx));
+ let toolchain_store = cx.new(|cx| {
+ ToolchainStore::remote(REMOTE_SERVER_PROJECT_ID, remote.read(cx).proto_client(), cx)
+ });
let task_store = cx.new(|cx| {
TaskStore::remote(
fs.clone(),
buffer_store.downgrade(),
worktree_store.clone(),
toolchain_store.read(cx).as_language_toolchain_store(),
- ssh.read(cx).proto_client(),
- SSH_PROJECT_ID,
+ remote.read(cx).proto_client(),
+ REMOTE_SERVER_PROJECT_ID,
cx,
)
});
@@ -1262,8 +1272,8 @@ impl Project {
buffer_store.clone(),
worktree_store.clone(),
languages.clone(),
- ssh_proto.clone(),
- SSH_PROJECT_ID,
+ remote_proto.clone(),
+ REMOTE_SERVER_PROJECT_ID,
fs.clone(),
cx,
)
@@ -1271,12 +1281,12 @@ impl Project {
cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
let breakpoint_store =
- cx.new(|_| BreakpointStore::remote(SSH_PROJECT_ID, ssh_proto.clone()));
+ cx.new(|_| BreakpointStore::remote(REMOTE_SERVER_PROJECT_ID, remote_proto.clone()));
let dap_store = cx.new(|cx| {
- DapStore::new_ssh(
- SSH_PROJECT_ID,
- ssh.clone(),
+ DapStore::new_remote(
+ REMOTE_SERVER_PROJECT_ID,
+ remote.clone(),
breakpoint_store.clone(),
worktree_store.clone(),
cx,
@@ -1284,10 +1294,16 @@ impl Project {
});
let git_store = cx.new(|cx| {
- GitStore::ssh(&worktree_store, buffer_store.clone(), ssh_proto.clone(), cx)
+ GitStore::remote(
+ &worktree_store,
+ buffer_store.clone(),
+ remote_proto.clone(),
+ REMOTE_SERVER_PROJECT_ID,
+ cx,
+ )
});
- cx.subscribe(&ssh, Self::on_ssh_event).detach();
+ cx.subscribe(&remote, Self::on_remote_client_event).detach();
let this = Self {
buffer_ordered_messages_tx: tx,
@@ -1306,11 +1322,13 @@ impl Project {
_subscriptions: vec![
cx.on_release(Self::release),
cx.on_app_quit(|this, cx| {
- let shutdown = this.ssh_client.take().and_then(|client| {
- client.read(cx).shutdown_processes(
- Some(proto::ShutdownRemoteServer {}),
- cx.background_executor().clone(),
- )
+ let shutdown = this.remote_client.take().and_then(|client| {
+ client.update(cx, |client, cx| {
+ client.shutdown_processes(
+ Some(proto::ShutdownRemoteServer {}),
+ cx.background_executor().clone(),
+ )
+ })
});
cx.background_executor().spawn(async move {
@@ -1323,12 +1341,12 @@ impl Project {
active_entry: None,
snippets,
languages,
- client,
+ collab_client: client,
task_store,
user_store,
settings_observer,
fs,
- ssh_client: Some(ssh.clone()),
+ remote_client: Some(remote.clone()),
buffers_needing_diff: Default::default(),
git_diff_debouncer: DebouncedDelay::new(),
terminals: Terminals {
@@ -1346,52 +1364,34 @@ impl Project {
agent_location: None,
};
- // ssh -> local machine handlers
- ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &cx.entity());
- ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.buffer_store);
- ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.worktree_store);
- ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.lsp_store);
- ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.dap_store);
- ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.settings_observer);
- ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.git_store);
-
- ssh_proto.add_entity_message_handler(Self::handle_create_buffer_for_peer);
- ssh_proto.add_entity_message_handler(Self::handle_update_worktree);
- ssh_proto.add_entity_message_handler(Self::handle_update_project);
- ssh_proto.add_entity_message_handler(Self::handle_toast);
- ssh_proto.add_entity_request_handler(Self::handle_language_server_prompt_request);
- ssh_proto.add_entity_message_handler(Self::handle_hide_toast);
- ssh_proto.add_entity_request_handler(Self::handle_update_buffer_from_ssh);
- BufferStore::init(&ssh_proto);
- LspStore::init(&ssh_proto);
- SettingsObserver::init(&ssh_proto);
- TaskStore::init(Some(&ssh_proto));
- ToolchainStore::init(&ssh_proto);
- DapStore::init(&ssh_proto, cx);
- GitStore::init(&ssh_proto);
+ // remote server -> local machine handlers
+ remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &cx.entity());
+ remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.buffer_store);
+ remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.worktree_store);
+ remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.lsp_store);
+ remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.dap_store);
+ remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.settings_observer);
+ remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.git_store);
+
+ remote_proto.add_entity_message_handler(Self::handle_create_buffer_for_peer);
+ remote_proto.add_entity_message_handler(Self::handle_update_worktree);
+ remote_proto.add_entity_message_handler(Self::handle_update_project);
+ remote_proto.add_entity_message_handler(Self::handle_toast);
+ remote_proto.add_entity_request_handler(Self::handle_language_server_prompt_request);
+ remote_proto.add_entity_message_handler(Self::handle_hide_toast);
+ remote_proto.add_entity_request_handler(Self::handle_update_buffer_from_remote_server);
+ BufferStore::init(&remote_proto);
+ LspStore::init(&remote_proto);
+ SettingsObserver::init(&remote_proto);
+ TaskStore::init(Some(&remote_proto));
+ ToolchainStore::init(&remote_proto);
+ DapStore::init(&remote_proto, cx);
+ GitStore::init(&remote_proto);
this
})
}
- pub async fn remote(
- remote_id: u64,
- client: Arc<Client>,
- user_store: Entity<UserStore>,
- languages: Arc<LanguageRegistry>,
- fs: Arc<dyn Fs>,
- cx: AsyncApp,
- ) -> Result<Entity<Self>> {
- let project =
- Self::in_room(remote_id, client, user_store, languages, fs, cx.clone()).await?;
- cx.update(|cx| {
- connection_manager::Manager::global(cx).update(cx, |manager, cx| {
- manager.maintain_project_connection(&project, cx)
- })
- })?;
- Ok(project)
- }
-
pub async fn in_room(
remote_id: u64,
client: Arc<Client>,
@@ -1481,7 +1481,7 @@ impl Project {
})?;
let lsp_store = cx.new(|cx| {
- let mut lsp_store = LspStore::new_remote(
+ LspStore::new_remote(
buffer_store.clone(),
worktree_store.clone(),
languages.clone(),
@@ -1489,12 +1489,7 @@ impl Project {
remote_id,
fs.clone(),
cx,
- );
- lsp_store.set_language_server_statuses_from_proto(
- response.payload.language_servers,
- response.payload.language_server_capabilities,
- );
- lsp_store
+ )
})?;
let task_store = cx.new(|cx| {
@@ -1523,12 +1518,12 @@ impl Project {
&worktree_store,
buffer_store.clone(),
client.clone().into(),
- ProjectId(remote_id),
+ remote_id,
cx,
)
})?;
- let this = cx.new(|cx| {
+ let project = cx.new(|cx| {
let replica_id = response.payload.replica_id as ReplicaId;
let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([]), cx);
@@ -1559,7 +1554,7 @@ impl Project {
cx.subscribe(&dap_store, Self::on_dap_store_event).detach();
- let mut this = Self {
+ let mut project = Self {
buffer_ordered_messages_tx: tx,
buffer_store: buffer_store.clone(),
image_store,
@@ -1574,11 +1569,11 @@ impl Project {
task_store,
snippets,
fs,
- ssh_client: None,
+ remote_client: None,
settings_observer: settings_observer.clone(),
client_subscriptions: Default::default(),
_subscriptions: vec![cx.on_release(Self::release)],
- client: client.clone(),
+ collab_client: client.clone(),
client_state: ProjectClientState::Remote {
sharing_has_stopped: false,
capability: Capability::ReadWrite,
@@ -1602,13 +1597,25 @@ impl Project {
toolchain_store: None,
agent_location: None,
};
- this.set_role(role, cx);
+ project.set_role(role, cx);
for worktree in worktrees {
- this.add_worktree(&worktree, cx);
+ project.add_worktree(&worktree, cx);
}
- this
+ project
})?;
+ let weak_project = project.downgrade();
+ lsp_store
+ .update(&mut cx, |lsp_store, cx| {
+ lsp_store.set_language_server_statuses_from_proto(
+ weak_project,
+ response.payload.language_servers,
+ response.payload.language_server_capabilities,
+ cx,
+ );
+ })
+ .ok();
+
let subscriptions = subscriptions
.into_iter()
.map(|s| match s {
@@ -1624,7 +1631,7 @@ impl Project {
EntitySubscription::SettingsObserver(subscription) => {
subscription.set_entity(&settings_observer, &cx)
}
- EntitySubscription::Project(subscription) => subscription.set_entity(&this, &cx),
+ EntitySubscription::Project(subscription) => subscription.set_entity(&project, &cx),
EntitySubscription::LspStore(subscription) => {
subscription.set_entity(&lsp_store, &cx)
}
@@ -1644,13 +1651,13 @@ impl Project {
.update(&mut cx, |user_store, cx| user_store.get_users(user_ids, cx))?
.await?;
- this.update(&mut cx, |this, cx| {
+ project.update(&mut cx, |this, cx| {
this.set_collaborators_from_proto(response.payload.collaborators, cx)?;
this.client_subscriptions.extend(subscriptions);
anyhow::Ok(())
})??;
- Ok(this)
+ Ok(project)
}
fn new_search_history() -> SearchHistory {
@@ -1661,11 +1668,13 @@ impl Project {
}
fn release(&mut self, cx: &mut App) {
- if let Some(client) = self.ssh_client.take() {
- let shutdown = client.read(cx).shutdown_processes(
- Some(proto::ShutdownRemoteServer {}),
- cx.background_executor().clone(),
- );
+ if let Some(client) = self.remote_client.take() {
+ let shutdown = client.update(cx, |client, cx| {
+ client.shutdown_processes(
+ Some(proto::ShutdownRemoteServer {}),
+ cx.background_executor().clone(),
+ )
+ });
cx.background_spawn(async move {
if let Some(shutdown) = shutdown {
@@ -1681,7 +1690,7 @@ impl Project {
let _ = self.unshare_internal(cx);
}
ProjectClientState::Remote { remote_id, .. } => {
- let _ = self.client.send(proto::LeaveProject {
+ let _ = self.collab_client.send(proto::LeaveProject {
project_id: *remote_id,
});
self.disconnected_from_host_internal(cx);
@@ -1808,11 +1817,11 @@ impl Project {
}
pub fn client(&self) -> Arc<Client> {
- self.client.clone()
+ self.collab_client.clone()
}
- pub fn ssh_client(&self) -> Option<Entity<SshRemoteClient>> {
- self.ssh_client.clone()
+ pub fn remote_client(&self) -> Option<Entity<RemoteClient>> {
+ self.remote_client.clone()
}
pub fn user_store(&self) -> Entity<UserStore> {
@@ -1893,30 +1902,30 @@ impl Project {
if self.is_local() {
return true;
}
- if self.is_via_ssh() {
+ if self.is_via_remote_server() {
return true;
}
false
}
- pub fn ssh_connection_state(&self, cx: &App) -> Option<remote::ConnectionState> {
- self.ssh_client
+ pub fn remote_connection_state(&self, cx: &App) -> Option<remote::ConnectionState> {
+ self.remote_client
.as_ref()
- .map(|ssh| ssh.read(cx).connection_state())
+ .map(|remote| remote.read(cx).connection_state())
}
- pub fn ssh_connection_options(&self, cx: &App) -> Option<SshConnectionOptions> {
- self.ssh_client
+ pub fn remote_connection_options(&self, cx: &App) -> Option<SshConnectionOptions> {
+ self.remote_client
.as_ref()
- .map(|ssh| ssh.read(cx).connection_options())
+ .map(|remote| remote.read(cx).connection_options())
}
pub fn replica_id(&self) -> ReplicaId {
match self.client_state {
ProjectClientState::Remote { replica_id, .. } => replica_id,
_ => {
- if self.ssh_client.is_some() {
+ if self.remote_client.is_some() {
1
} else {
0
@@ -2052,13 +2061,12 @@ impl Project {
exclude_sub_dirs: bool,
cx: &App,
) -> Option<bool> {
- let sanitized_path = SanitizedPath::from(path);
- let path = sanitized_path.as_path();
+ let path = SanitizedPath::new(path).as_path();
self.worktrees(cx)
.filter_map(|worktree| {
let worktree = worktree.read(cx);
let abs_path = worktree.as_local()?.abs_path();
- let contains = path == abs_path
+ let contains = path == abs_path.as_ref()
|| (path.starts_with(abs_path) && (!exclude_sub_dirs || !metadata.is_dir));
contains.then(|| worktree.is_visible())
})
@@ -2220,55 +2228,55 @@ impl Project {
);
self.client_subscriptions.extend([
- self.client
+ self.collab_client
.subscribe_to_entity(project_id)?
.set_entity(&cx.entity(), &cx.to_async()),
- self.client
+ self.collab_client
.subscribe_to_entity(project_id)?
.set_entity(&self.worktree_store, &cx.to_async()),
- self.client
+ self.collab_client
.subscribe_to_entity(project_id)?
.set_entity(&self.buffer_store, &cx.to_async()),
- self.client
+ self.collab_client
.subscribe_to_entity(project_id)?
.set_entity(&self.lsp_store, &cx.to_async()),
- self.client
+ self.collab_client
.subscribe_to_entity(project_id)?
.set_entity(&self.settings_observer, &cx.to_async()),
- self.client
+ self.collab_client
.subscribe_to_entity(project_id)?
.set_entity(&self.dap_store, &cx.to_async()),
- self.client
+ self.collab_client
.subscribe_to_entity(project_id)?
.set_entity(&self.breakpoint_store, &cx.to_async()),
- self.client
+ self.collab_client
.subscribe_to_entity(project_id)?
.set_entity(&self.git_store, &cx.to_async()),
]);
self.buffer_store.update(cx, |buffer_store, cx| {
- buffer_store.shared(project_id, self.client.clone().into(), cx)
+ buffer_store.shared(project_id, self.collab_client.clone().into(), cx)
});
self.worktree_store.update(cx, |worktree_store, cx| {
- worktree_store.shared(project_id, self.client.clone().into(), cx);
+ worktree_store.shared(project_id, self.collab_client.clone().into(), cx);
});
self.lsp_store.update(cx, |lsp_store, cx| {
- lsp_store.shared(project_id, self.client.clone().into(), cx)
+ lsp_store.shared(project_id, self.collab_client.clone().into(), cx)
});
self.breakpoint_store.update(cx, |breakpoint_store, _| {
- breakpoint_store.shared(project_id, self.client.clone().into())
+ breakpoint_store.shared(project_id, self.collab_client.clone().into())
});
self.dap_store.update(cx, |dap_store, cx| {
- dap_store.shared(project_id, self.client.clone().into(), cx);
+ dap_store.shared(project_id, self.collab_client.clone().into(), cx);
});
self.task_store.update(cx, |task_store, cx| {
- task_store.shared(project_id, self.client.clone().into(), cx);
+ task_store.shared(project_id, self.collab_client.clone().into(), cx);
});
self.settings_observer.update(cx, |settings_observer, cx| {
- settings_observer.shared(project_id, self.client.clone().into(), cx)
+ settings_observer.shared(project_id, self.collab_client.clone().into(), cx)
});
self.git_store.update(cx, |git_store, cx| {
- git_store.shared(project_id, self.client.clone().into(), cx)
+ git_store.shared(project_id, self.collab_client.clone().into(), cx)
});
self.client_state = ProjectClientState::Shared {
@@ -2293,7 +2301,7 @@ impl Project {
});
if let Some(remote_id) = self.remote_id() {
self.git_store.update(cx, |git_store, cx| {
- git_store.shared(remote_id, self.client.clone().into(), cx)
+ git_store.shared(remote_id, self.collab_client.clone().into(), cx)
});
}
cx.emit(Event::Reshared);
@@ -2319,10 +2327,14 @@ impl Project {
self.join_project_response_message_id = message_id;
self.set_worktrees_from_proto(message.worktrees, cx)?;
self.set_collaborators_from_proto(message.collaborators, cx)?;
- self.lsp_store.update(cx, |lsp_store, _| {
+
+ let project = cx.weak_entity();
+ self.lsp_store.update(cx, |lsp_store, cx| {
lsp_store.set_language_server_statuses_from_proto(
+ project,
message.language_servers,
message.language_server_capabilities,
+ cx,
)
});
self.enqueue_buffer_ordered_message(BufferOrderedMessage::Resync)
@@ -2370,7 +2382,7 @@ impl Project {
git_store.unshared(cx);
});
- self.client
+ self.collab_client
.send(proto::UnshareProject {
project_id: remote_id,
})
@@ -2437,15 +2449,17 @@ impl Project {
sharing_has_stopped,
..
} => *sharing_has_stopped,
- ProjectClientState::Local if self.is_via_ssh() => self.ssh_is_disconnected(cx),
+ ProjectClientState::Local if self.is_via_remote_server() => {
+ self.remote_client_is_disconnected(cx)
+ }
_ => false,
}
}
- fn ssh_is_disconnected(&self, cx: &App) -> bool {
- self.ssh_client
+ fn remote_client_is_disconnected(&self, cx: &App) -> bool {
+ self.remote_client
.as_ref()
- .map(|ssh| ssh.read(cx).is_disconnected())
+ .map(|remote| remote.read(cx).is_disconnected())
.unwrap_or(false)
}
@@ -2463,16 +2477,16 @@ impl Project {
pub fn is_local(&self) -> bool {
match &self.client_state {
ProjectClientState::Local | ProjectClientState::Shared { .. } => {
- self.ssh_client.is_none()
+ self.remote_client.is_none()
}
ProjectClientState::Remote { .. } => false,
}
}
- pub fn is_via_ssh(&self) -> bool {
+ pub fn is_via_remote_server(&self) -> bool {
match &self.client_state {
ProjectClientState::Local | ProjectClientState::Shared { .. } => {
- self.ssh_client.is_some()
+ self.remote_client.is_some()
}
ProjectClientState::Remote { .. } => false,
}
@@ -2496,7 +2510,7 @@ impl Project {
language: Option<Arc<Language>>,
cx: &mut Context<Self>,
) -> Entity<Buffer> {
- if self.is_via_collab() || self.is_via_ssh() {
+ if self.is_via_collab() || self.is_via_remote_server() {
panic!("called create_local_buffer on a remote project")
}
self.buffer_store.update(cx, |buffer_store, cx| {
@@ -2620,10 +2634,10 @@ impl Project {
) -> Task<Result<Entity<Buffer>>> {
if let Some(buffer) = self.buffer_for_id(id, cx) {
Task::ready(Ok(buffer))
- } else if self.is_local() || self.is_via_ssh() {
+ } else if self.is_local() || self.is_via_remote_server() {
Task::ready(Err(anyhow!("buffer {id} does not exist")))
} else if let Some(project_id) = self.remote_id() {
- let request = self.client.request(proto::OpenBufferById {
+ let request = self.collab_client.request(proto::OpenBufferById {
project_id,
id: id.into(),
});
@@ -2741,7 +2755,7 @@ impl Project {
for (buffer_id, operations) in operations_by_buffer_id.drain() {
let request = this.read_with(cx, |this, _| {
let project_id = this.remote_id()?;
- Some(this.client.request(proto::UpdateBuffer {
+ Some(this.collab_client.request(proto::UpdateBuffer {
buffer_id: buffer_id.into(),
project_id,
operations,
@@ -2808,7 +2822,7 @@ impl Project {
project.read_with(cx, |project, _| {
if let Some(project_id) = project.remote_id() {
project
- .client
+ .collab_client
.send(proto::UpdateLanguageServer {
project_id,
server_name: name.map(|name| String::from(name.0)),
@@ -2846,8 +2860,8 @@ impl Project {
self.register_buffer(buffer, cx).log_err();
}
BufferStoreEvent::BufferDropped(buffer_id) => {
- if let Some(ref ssh_client) = self.ssh_client {
- ssh_client
+ if let Some(ref remote_client) = self.remote_client {
+ remote_client
.read(cx)
.proto_client()
.send(proto::CloseBuffer {
@@ -2973,6 +2987,7 @@ impl Project {
buffer_id,
server_id: *language_server_id,
buffer_abs_path: PathBuf::from(&update.buffer_abs_path),
+ name: name.clone(),
});
}
}
@@ -2995,16 +3010,14 @@ impl Project {
}
}
- fn on_ssh_event(
+ fn on_remote_client_event(
&mut self,
- _: Entity<SshRemoteClient>,
- event: &remote::SshRemoteEvent,
+ _: Entity<RemoteClient>,
+ event: &remote::RemoteClientEvent,
cx: &mut Context<Self>,
) {
match event {
- remote::SshRemoteEvent::Disconnected => {
- // if self.is_via_ssh() {
- // self.collaborators.clear();
+ remote::RemoteClientEvent::Disconnected => {
self.worktree_store.update(cx, |store, cx| {
store.disconnected_from_host(cx);
});
@@ -3110,8 +3123,9 @@ impl Project {
}
fn on_worktree_released(&mut self, id_to_remove: WorktreeId, cx: &mut Context<Self>) {
- if let Some(ssh) = &self.ssh_client {
- ssh.read(cx)
+ if let Some(remote) = &self.remote_client {
+ remote
+ .read(cx)
.proto_client()
.send(proto::RemoveWorktree {
worktree_id: id_to_remove.to_proto(),
@@ -3144,8 +3158,9 @@ impl Project {
} => {
let operation = language::proto::serialize_operation(operation);
- if let Some(ssh) = &self.ssh_client {
- ssh.read(cx)
+ if let Some(remote) = &self.remote_client {
+ remote
+ .read(cx)
.proto_client()
.send(proto::UpdateBuffer {
project_id: 0,
@@ -3552,16 +3567,16 @@ impl Project {
pub fn open_server_settings(&mut self, cx: &mut Context<Self>) -> Task<Result<Entity<Buffer>>> {
let guard = self.retain_remotely_created_models(cx);
- let Some(ssh_client) = self.ssh_client.as_ref() else {
+ let Some(remote) = self.remote_client.as_ref() else {
return Task::ready(Err(anyhow!("not an ssh project")));
};
- let proto_client = ssh_client.read(cx).proto_client();
+ let proto_client = remote.read(cx).proto_client();
cx.spawn(async move |project, cx| {
let buffer = proto_client
.request(proto::OpenServerSettings {
- project_id: SSH_PROJECT_ID,
+ project_id: REMOTE_SERVER_PROJECT_ID,
})
.await?;
@@ -3948,10 +3963,11 @@ impl Project {
) -> Receiver<Entity<Buffer>> {
let (tx, rx) = smol::channel::unbounded();
- let (client, remote_id): (AnyProtoClient, _) = if let Some(ssh_client) = &self.ssh_client {
+ let (client, remote_id): (AnyProtoClient, _) = if let Some(ssh_client) = &self.remote_client
+ {
(ssh_client.read(cx).proto_client(), 0)
} else if let Some(remote_id) = self.remote_id() {
- (self.client.clone().into(), remote_id)
+ (self.collab_client.clone().into(), remote_id)
} else {
return rx;
};
@@ -4095,14 +4111,14 @@ impl Project {
is_dir: metadata.is_dir,
})
})
- } else if let Some(ssh_client) = self.ssh_client.as_ref() {
+ } else if let Some(ssh_client) = self.remote_client.as_ref() {
let path_style = ssh_client.read(cx).path_style();
let request_path = RemotePathBuf::from_str(path, path_style);
let request = ssh_client
.read(cx)
.proto_client()
.request(proto::GetPathMetadata {
- project_id: SSH_PROJECT_ID,
+ project_id: REMOTE_SERVER_PROJECT_ID,
path: request_path.to_proto(),
});
cx.background_spawn(async move {
@@ -4202,10 +4218,10 @@ impl Project {
) -> Task<Result<Vec<DirectoryItem>>> {
if self.is_local() {
DirectoryLister::Local(cx.entity(), self.fs.clone()).list_directory(query, cx)
- } else if let Some(session) = self.ssh_client.as_ref() {
+ } else if let Some(session) = self.remote_client.as_ref() {
let path_buf = PathBuf::from(query);
let request = proto::ListRemoteDirectory {
- dev_server_id: SSH_PROJECT_ID,
+ dev_server_id: REMOTE_SERVER_PROJECT_ID,
path: path_buf.to_proto(),
config: Some(proto::ListRemoteDirectoryConfig { is_dir: true }),
};
@@ -4420,7 +4436,7 @@ impl Project {
mut cx: AsyncApp,
) -> Result<()> {
this.update(&mut cx, |this, cx| {
- if this.is_local() || this.is_via_ssh() {
+ if this.is_local() || this.is_via_remote_server() {
this.unshare(cx)?;
} else {
this.disconnected_from_host(cx);
@@ -4629,7 +4645,7 @@ impl Project {
})?
}
- async fn handle_update_buffer_from_ssh(
+ async fn handle_update_buffer_from_remote_server(
this: Entity<Self>,
envelope: TypedEnvelope<proto::UpdateBuffer>,
cx: AsyncApp,
@@ -4638,7 +4654,7 @@ impl Project {
if let Some(remote_id) = this.remote_id() {
let mut payload = envelope.payload.clone();
payload.project_id = remote_id;
- cx.background_spawn(this.client.request(payload))
+ cx.background_spawn(this.collab_client.request(payload))
.detach_and_log_err(cx);
}
this.buffer_store.clone()
@@ -4652,9 +4668,9 @@ impl Project {
cx: AsyncApp,
) -> Result<proto::Ack> {
let buffer_store = this.read_with(&cx, |this, cx| {
- if let Some(ssh) = &this.ssh_client {
+ if let Some(ssh) = &this.remote_client {
let mut payload = envelope.payload.clone();
- payload.project_id = SSH_PROJECT_ID;
+ payload.project_id = REMOTE_SERVER_PROJECT_ID;
cx.background_spawn(ssh.read(cx).proto_client().request(payload))
.detach_and_log_err(cx);
}
@@ -4698,13 +4714,27 @@ impl Project {
})?
}
+ async fn handle_toggle_lsp_logs(
+ project: Entity<Self>,
+ envelope: TypedEnvelope<proto::ToggleLspLogs>,
+ mut cx: AsyncApp,
+ ) -> Result<()> {
+ project.update(&mut cx, |_, cx| {
+ cx.emit(Event::ToggleLspLogs {
+ server_id: LanguageServerId::from_proto(envelope.payload.server_id),
+ enabled: envelope.payload.enabled,
+ })
+ })?;
+ Ok(())
+ }
+
async fn handle_synchronize_buffers(
this: Entity<Self>,
envelope: TypedEnvelope<proto::SynchronizeBuffers>,
mut cx: AsyncApp,
) -> Result<proto::SynchronizeBuffersResponse> {
let response = this.update(&mut cx, |this, cx| {
- let client = this.client.clone();
+ let client = this.collab_client.clone();
this.buffer_store.update(cx, |this, cx| {
this.handle_synchronize_buffers(envelope, cx, client)
})
@@ -4841,7 +4871,7 @@ impl Project {
}
};
- let client = self.client.clone();
+ let client = self.collab_client.clone();
cx.spawn(async move |this, cx| {
let (buffers, incomplete_buffer_ids) = this.update(cx, |this, cx| {
this.buffer_store.read(cx).buffer_version_info(cx)
@@ -1951,6 +1951,7 @@ async fn test_restarting_server_with_diagnostics_running(cx: &mut gpui::TestAppC
server_id: LanguageServerId(1),
buffer_id,
buffer_abs_path: PathBuf::from(path!("/dir/a.rs")),
+ name: Some(fake_server.server.name())
}
);
assert_eq!(
@@ -9182,13 +9183,14 @@ fn python_lang(fs: Arc<FakeFs>) -> Arc<Language> {
async fn list(
&self,
worktree_root: PathBuf,
- subroot_relative_path: Option<Arc<Path>>,
+ subroot_relative_path: Arc<Path>,
_: Option<HashMap<String, String>>,
) -> ToolchainList {
// This lister will always return a path .venv directories within ancestors
let ancestors = subroot_relative_path
- .into_iter()
- .flat_map(|path| path.ancestors().map(ToOwned::to_owned).collect::<Vec<_>>());
+ .ancestors()
+ .map(ToOwned::to_owned)
+ .collect::<Vec<_>>();
let mut toolchains = vec![];
for ancestor in ancestors {
let venv_path = worktree_root.join(ancestor).join(".venv");
@@ -9214,6 +9216,9 @@ fn python_lang(fs: Arc<FakeFs>) -> Arc<Language> {
fn manifest_name(&self) -> ManifestName {
SharedString::new_static("pyproject.toml").into()
}
+ async fn activation_script(&self, _: &Toolchain, _: &dyn Fs) -> Option<String> {
+ None
+ }
}
Arc::new(
Language::new(
@@ -143,7 +143,7 @@ impl SearchQuery {
pub fn regex(
query: impl ToString,
whole_word: bool,
- case_sensitive: bool,
+ mut case_sensitive: bool,
include_ignored: bool,
one_match_per_line: bool,
files_to_include: PathMatcher,
@@ -153,6 +153,14 @@ impl SearchQuery {
) -> Result<Self> {
let mut query = query.to_string();
let initial_query = Arc::from(query.as_str());
+
+ if let Some((case_sensitive_from_pattern, new_query)) =
+ Self::case_sensitive_from_pattern(&query)
+ {
+ case_sensitive = case_sensitive_from_pattern;
+ query = new_query
+ }
+
if whole_word {
let mut word_query = String::new();
if let Some(first) = query.get(0..1)
@@ -192,6 +200,45 @@ impl SearchQuery {
})
}
+ /// Extracts case sensitivity settings from pattern items in the provided
+ /// query and returns the same query, with the pattern items removed.
+ ///
+ /// The following pattern modifiers are supported:
+ ///
+ /// - `\c` (case_sensitive: false)
+ /// - `\C` (case_sensitive: true)
+ ///
+ /// If no pattern item were found, `None` will be returned.
+ fn case_sensitive_from_pattern(query: &str) -> Option<(bool, String)> {
+ if !(query.contains("\\c") || query.contains("\\C")) {
+ return None;
+ }
+
+ let mut was_escaped = false;
+ let mut new_query = String::new();
+ let mut is_case_sensitive = None;
+
+ for c in query.chars() {
+ if was_escaped {
+ if c == 'c' {
+ is_case_sensitive = Some(false);
+ } else if c == 'C' {
+ is_case_sensitive = Some(true);
+ } else {
+ new_query.push('\\');
+ new_query.push(c);
+ }
+ was_escaped = false
+ } else if c == '\\' {
+ was_escaped = true
+ } else {
+ new_query.push(c);
+ }
+ }
+
+ is_case_sensitive.map(|c| (c, new_query))
+ }
+
pub fn from_proto(message: proto::SearchQuery) -> Result<Self> {
let files_to_include = if message.files_to_include.is_empty() {
message
@@ -596,4 +643,87 @@ mod tests {
}
}
}
+
+ #[test]
+ fn test_case_sensitive_pattern_items() {
+ let case_sensitive = false;
+ let search_query = SearchQuery::regex(
+ "test\\C",
+ false,
+ case_sensitive,
+ false,
+ false,
+ Default::default(),
+ Default::default(),
+ false,
+ None,
+ )
+ .expect("Should be able to create a regex SearchQuery");
+
+ assert_eq!(
+ search_query.case_sensitive(),
+ true,
+ "Case sensitivity should be enabled when \\C pattern item is present in the query."
+ );
+
+ let case_sensitive = true;
+ let search_query = SearchQuery::regex(
+ "test\\c",
+ true,
+ case_sensitive,
+ false,
+ false,
+ Default::default(),
+ Default::default(),
+ false,
+ None,
+ )
+ .expect("Should be able to create a regex SearchQuery");
+
+ assert_eq!(
+ search_query.case_sensitive(),
+ false,
+ "Case sensitivity should be disabled when \\c pattern item is present, even if initially set to true."
+ );
+
+ let case_sensitive = false;
+ let search_query = SearchQuery::regex(
+ "test\\c\\C",
+ false,
+ case_sensitive,
+ false,
+ false,
+ Default::default(),
+ Default::default(),
+ false,
+ None,
+ )
+ .expect("Should be able to create a regex SearchQuery");
+
+ assert_eq!(
+ search_query.case_sensitive(),
+ true,
+ "Case sensitivity should be enabled when \\C is the last pattern item, even after a \\c."
+ );
+
+ let case_sensitive = false;
+ let search_query = SearchQuery::regex(
+ "tests\\\\C",
+ false,
+ case_sensitive,
+ false,
+ false,
+ Default::default(),
+ Default::default(),
+ false,
+ None,
+ )
+ .expect("Should be able to create a regex SearchQuery");
+
+ assert_eq!(
+ search_query.case_sensitive(),
+ false,
+ "Case sensitivity should not be enabled when \\C pattern item is preceded by a backslash."
+ );
+ }
}
@@ -1,72 +1,28 @@
-use crate::{Project, ProjectPath};
-use anyhow::{Context as _, Result};
+use anyhow::Result;
use collections::HashMap;
use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity};
use itertools::Itertools;
use language::LanguageName;
-use remote::{SshInfo, ssh_session::SshArgs};
+use remote::RemoteClient;
use settings::{Settings, SettingsLocation};
use smol::channel::bounded;
use std::{
borrow::Cow,
- env::{self},
path::{Path, PathBuf},
sync::Arc,
};
use task::{Shell, ShellBuilder, SpawnInTerminal};
use terminal::{
- TaskState, TaskStatus, Terminal, TerminalBuilder,
- terminal_settings::{self, ActivateScript, TerminalSettings, VenvSettings},
-};
-use util::{
- ResultExt,
- paths::{PathStyle, RemotePathBuf},
+ TaskState, TaskStatus, Terminal, TerminalBuilder, terminal_settings::TerminalSettings,
};
+use util::{get_default_system_shell, get_system_shell, maybe};
-/// The directory inside a Python virtual environment that contains executables
-const PYTHON_VENV_BIN_DIR: &str = if cfg!(target_os = "windows") {
- "Scripts"
-} else {
- "bin"
-};
+use crate::{Project, ProjectPath};
pub struct Terminals {
pub(crate) local_handles: Vec<WeakEntity<terminal::Terminal>>,
}
-/// Terminals are opened either for the users shell, or to run a task.
-
-#[derive(Debug)]
-pub enum TerminalKind {
- /// Run a shell at the given path (or $HOME if None)
- Shell(Option<PathBuf>),
- /// Run a task.
- Task(SpawnInTerminal),
-}
-
-/// SshCommand describes how to connect to a remote server
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SshCommand {
- pub arguments: Vec<String>,
-}
-
-impl SshCommand {
- pub fn add_port_forwarding(&mut self, local_port: u16, host: String, remote_port: u16) {
- self.arguments.push("-L".to_string());
- self.arguments
- .push(format!("{}:{}:{}", local_port, host, remote_port));
- }
-}
-
-#[derive(Debug)]
-pub struct SshDetails {
- pub host: String,
- pub ssh_command: SshCommand,
- pub envs: Option<HashMap<String, String>>,
- pub path_style: PathStyle,
- pub shell: String,
-}
-
impl Project {
pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> {
self.active_entry()
@@ -86,76 +42,33 @@ impl Project {
}
}
- pub fn ssh_details(&self, cx: &App) -> Option<SshDetails> {
- if let Some(ssh_client) = &self.ssh_client {
- let ssh_client = ssh_client.read(cx);
- if let Some(SshInfo {
- args: SshArgs { arguments, envs },
- path_style,
- shell,
- }) = ssh_client.ssh_info()
- {
- return Some(SshDetails {
- host: ssh_client.connection_options().host,
- ssh_command: SshCommand { arguments },
- envs,
- path_style,
- shell,
- });
- }
- }
-
- None
- }
-
- pub fn create_terminal(
+ pub fn create_terminal_task(
&mut self,
- kind: TerminalKind,
+ spawn_task: SpawnInTerminal,
cx: &mut Context<Self>,
) -> Task<Result<Entity<Terminal>>> {
- let path: Option<Arc<Path>> = match &kind {
- TerminalKind::Shell(path) => path.as_ref().map(|path| Arc::from(path.as_ref())),
- TerminalKind::Task(spawn_task) => {
- if let Some(cwd) = &spawn_task.cwd {
- Some(Arc::from(cwd.as_ref()))
- } else {
- self.active_project_directory(cx)
- }
- }
- };
-
- let mut settings_location = None;
- if let Some(path) = path.as_ref()
- && let Some((worktree, _)) = self.find_worktree(path, cx)
- {
- settings_location = Some(SettingsLocation {
- worktree_id: worktree.read(cx).id(),
- path,
+ let is_via_remote = self.remote_client.is_some();
+ let project_path_context = self
+ .active_entry()
+ .and_then(|entry_id| self.worktree_id_for_entry(entry_id, cx))
+ .or_else(|| self.visible_worktrees(cx).next().map(|wt| wt.read(cx).id()))
+ .map(|worktree_id| ProjectPath {
+ worktree_id,
+ path: Arc::from(Path::new("")),
});
- }
- let venv = TerminalSettings::get(settings_location, cx)
- .detect_venv
- .clone();
- cx.spawn(async move |project, cx| {
- let python_venv_directory = if let Some(path) = path {
- project
- .update(cx, |this, cx| this.python_venv_directory(path, venv, cx))?
- .await
+ let path: Option<Arc<Path>> = if let Some(cwd) = &spawn_task.cwd {
+ if is_via_remote {
+ Some(Arc::from(cwd.as_ref()))
} else {
- None
- };
- project.update(cx, |project, cx| {
- project.create_terminal_with_venv(kind, python_venv_directory, cx)
- })?
- })
- }
+ let cwd = cwd.to_string_lossy();
+ let tilde_substituted = shellexpand::tilde(&cwd);
+ Some(Arc::from(Path::new(tilde_substituted.as_ref())))
+ }
+ } else {
+ self.active_project_directory(cx)
+ };
- pub fn terminal_settings<'a>(
- &'a self,
- path: &'a Option<PathBuf>,
- cx: &'a App,
- ) -> &'a TerminalSettings {
let mut settings_location = None;
if let Some(path) = path.as_ref()
&& let Some((worktree, _)) = self.find_worktree(path, cx)
@@ -165,92 +78,179 @@ impl Project {
path,
});
}
- TerminalSettings::get(settings_location, cx)
- }
-
- pub fn exec_in_shell(&self, command: String, cx: &App) -> std::process::Command {
- let path = self.first_project_directory(cx);
- let ssh_details = self.ssh_details(cx);
- let settings = self.terminal_settings(&path, cx).clone();
+ let settings = TerminalSettings::get(settings_location, cx).clone();
+ let detect_venv = settings.detect_venv.as_option().is_some();
- let builder =
- ShellBuilder::new(ssh_details.as_ref().map(|ssh| &*ssh.shell), &settings.shell)
- .non_interactive();
- let (command, args) = builder.build(Some(command), &Vec::new());
+ let (completion_tx, completion_rx) = bounded(1);
+ // Start with the environment that we might have inherited from the Zed CLI.
let mut env = self
.environment
.read(cx)
.get_cli_environment()
.unwrap_or_default();
+ // Then extend it with the explicit env variables from the settings, so they take
+ // precedence.
env.extend(settings.env);
- match self.ssh_details(cx) {
- Some(SshDetails {
- ssh_command,
- envs,
- path_style,
- shell,
- ..
- }) => {
- let (command, args) = wrap_for_ssh(
- &shell,
- &ssh_command,
- Some((&command, &args)),
- path.as_deref(),
+ let local_path = if is_via_remote { None } else { path.clone() };
+ let task_state = Some(TaskState {
+ id: spawn_task.id,
+ full_label: spawn_task.full_label,
+ label: spawn_task.label,
+ command_label: spawn_task.command_label,
+ hide: spawn_task.hide,
+ status: TaskStatus::Running,
+ show_summary: spawn_task.show_summary,
+ show_command: spawn_task.show_command,
+ show_rerun: spawn_task.show_rerun,
+ completion_rx,
+ });
+ let remote_client = self.remote_client.clone();
+ let shell = match &remote_client {
+ Some(remote_client) => remote_client
+ .read(cx)
+ .shell()
+ .unwrap_or_else(get_default_system_shell),
+ None => match &settings.shell {
+ Shell::Program(program) => program.clone(),
+ Shell::WithArguments {
+ program,
+ args: _,
+ title_override: _,
+ } => program.clone(),
+ Shell::System => get_system_shell(),
+ },
+ };
+
+ let toolchain = project_path_context
+ .filter(|_| detect_venv)
+ .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx));
+ let lang_registry = self.languages.clone();
+ let fs = self.fs.clone();
+ cx.spawn(async move |project, cx| {
+ let activation_script = maybe!(async {
+ let toolchain = toolchain?.await?;
+ lang_registry
+ .language_for_name(&toolchain.language_name.0)
+ .await
+ .ok()?
+ .toolchain_lister()?
+ .activation_script(&toolchain, fs.as_ref())
+ .await
+ })
+ .await;
+
+ project.update(cx, move |this, cx| {
+ let shell = {
+ env.extend(spawn_task.env);
+ match remote_client {
+ Some(remote_client) => create_remote_shell(
+ spawn_task
+ .command
+ .as_ref()
+ .map(|command| (command, &spawn_task.args)),
+ &mut env,
+ path,
+ remote_client,
+ activation_script.clone(),
+ cx,
+ )?,
+ None => match activation_script.clone() {
+ Some(activation_script) => {
+ let to_run = if let Some(command) = spawn_task.command {
+ let command: Option<Cow<str>> = shlex::try_quote(&command).ok();
+ let args = spawn_task
+ .args
+ .iter()
+ .filter_map(|arg| shlex::try_quote(arg).ok());
+ command.into_iter().chain(args).join(" ")
+ } else {
+ format!("exec {shell} -l")
+ };
+ Shell::WithArguments {
+ program: get_default_system_shell(),
+ args: vec![
+ "-c".to_owned(),
+ format!("{activation_script}; {to_run}",),
+ ],
+ title_override: None,
+ }
+ }
+ None => {
+ if let Some(program) = spawn_task.command {
+ Shell::WithArguments {
+ program,
+ args: spawn_task.args,
+ title_override: None,
+ }
+ } else {
+ Shell::System
+ }
+ }
+ },
+ }
+ };
+ TerminalBuilder::new(
+ local_path.map(|path| path.to_path_buf()),
+ task_state,
+ shell,
env,
- None,
- path_style,
- );
- let mut command = std::process::Command::new(command);
- command.args(args);
- if let Some(envs) = envs {
- command.envs(envs);
- }
- command
- }
- None => {
- let mut command = std::process::Command::new(command);
- command.args(args);
- command.envs(env);
- if let Some(path) = path {
- command.current_dir(path);
- }
- command
- }
- }
+ settings.cursor_shape.unwrap_or_default(),
+ settings.alternate_scroll,
+ settings.max_scroll_history_lines,
+ is_via_remote,
+ cx.entity_id().as_u64(),
+ Some(completion_tx),
+ cx,
+ activation_script,
+ )
+ .map(|builder| {
+ let terminal_handle = cx.new(|cx| builder.subscribe(cx));
+
+ this.terminals
+ .local_handles
+ .push(terminal_handle.downgrade());
+
+ let id = terminal_handle.entity_id();
+ cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
+ let handles = &mut project.terminals.local_handles;
+
+ if let Some(index) = handles
+ .iter()
+ .position(|terminal| terminal.entity_id() == id)
+ {
+ handles.remove(index);
+ cx.notify();
+ }
+ })
+ .detach();
+
+ terminal_handle
+ })
+ })?
+ })
}
- pub fn create_terminal_with_venv(
+ pub fn create_terminal_shell(
&mut self,
- kind: TerminalKind,
- python_venv_directory: Option<PathBuf>,
+ cwd: Option<PathBuf>,
cx: &mut Context<Self>,
- ) -> Result<Entity<Terminal>> {
- let this = &mut *self;
- let ssh_details = this.ssh_details(cx);
- let path: Option<Arc<Path>> = match &kind {
- TerminalKind::Shell(path) => path.as_ref().map(|path| Arc::from(path.as_ref())),
- TerminalKind::Task(spawn_task) => {
- if let Some(cwd) = &spawn_task.cwd {
- if ssh_details.is_some() {
- Some(Arc::from(cwd.as_ref()))
- } else {
- let cwd = cwd.to_string_lossy();
- let tilde_substituted = shellexpand::tilde(&cwd);
- Some(Arc::from(Path::new(tilde_substituted.as_ref())))
- }
- } else {
- this.active_project_directory(cx)
- }
- }
- };
-
- let is_ssh_terminal = ssh_details.is_some();
+ ) -> Task<Result<Entity<Terminal>>> {
+ let project_path_context = self
+ .active_entry()
+ .and_then(|entry_id| self.worktree_id_for_entry(entry_id, cx))
+ .or_else(|| self.visible_worktrees(cx).next().map(|wt| wt.read(cx).id()))
+ .map(|worktree_id| ProjectPath {
+ worktree_id,
+ path: Arc::from(Path::new("")),
+ });
+ let path = cwd.map(|p| Arc::from(&*p));
+ let is_via_remote = self.remote_client.is_some();
let mut settings_location = None;
if let Some(path) = path.as_ref()
- && let Some((worktree, _)) = this.find_worktree(path, cx)
+ && let Some((worktree, _)) = self.find_worktree(path, cx)
{
settings_location = Some(SettingsLocation {
worktree_id: worktree.read(cx).id(),
@@ -258,11 +258,10 @@ impl Project {
});
}
let settings = TerminalSettings::get(settings_location, cx).clone();
-
- let (completion_tx, completion_rx) = bounded(1);
+ let detect_venv = settings.detect_venv.as_option().is_some();
// Start with the environment that we might have inherited from the Zed CLI.
- let mut env = this
+ let mut env = self
.environment
.read(cx)
.get_cli_environment()
@@ -271,160 +270,116 @@ impl Project {
// precedence.
env.extend(settings.env);
- let local_path = if is_ssh_terminal { None } else { path.clone() };
-
- let mut python_venv_activate_command = Task::ready(None);
-
- let (spawn_task, shell) = match kind {
- TerminalKind::Shell(_) => {
- if let Some(python_venv_directory) = &python_venv_directory {
- python_venv_activate_command = this.python_activate_command(
- python_venv_directory,
- &settings.detect_venv,
- &settings.shell,
- cx,
- );
- }
+ let local_path = if is_via_remote { None } else { path.clone() };
+
+ let toolchain = project_path_context
+ .filter(|_| detect_venv)
+ .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx));
+ let remote_client = self.remote_client.clone();
+ let shell = match &remote_client {
+ Some(remote_client) => remote_client
+ .read(cx)
+ .shell()
+ .unwrap_or_else(get_default_system_shell),
+ None => match &settings.shell {
+ Shell::Program(program) => program.clone(),
+ Shell::WithArguments {
+ program,
+ args: _,
+ title_override: _,
+ } => program.clone(),
+ Shell::System => get_system_shell(),
+ },
+ };
- match ssh_details {
- Some(SshDetails {
- host,
- ssh_command,
- envs,
- path_style,
- shell,
- }) => {
- log::debug!("Connecting to a remote server: {ssh_command:?}");
-
- // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
- // to properly display colors.
- // We do not have the luxury of assuming the host has it installed,
- // so we set it to a default that does not break the highlighting via ssh.
- env.entry("TERM".to_string())
- .or_insert_with(|| "xterm-256color".to_string());
-
- let (program, args) = wrap_for_ssh(
- &shell,
- &ssh_command,
- None,
- path.as_deref(),
- env,
+ let lang_registry = self.languages.clone();
+ let fs = self.fs.clone();
+ cx.spawn(async move |project, cx| {
+ let activation_script = maybe!(async {
+ let toolchain = toolchain?.await?;
+ let language = lang_registry
+ .language_for_name(&toolchain.language_name.0)
+ .await
+ .ok();
+ let lister = language?.toolchain_lister();
+ lister?.activation_script(&toolchain, fs.as_ref()).await
+ })
+ .await;
+ project.update(cx, move |this, cx| {
+ let shell = {
+ match remote_client {
+ Some(remote_client) => create_remote_shell(
None,
- path_style,
- );
- env = HashMap::default();
- if let Some(envs) = envs {
- env.extend(envs);
- }
- (
- Option::<TaskState>::None,
- Shell::WithArguments {
- program,
- args,
- title_override: Some(format!("{} — Terminal", host).into()),
- },
- )
- }
- None => (None, settings.shell),
- }
- }
- TerminalKind::Task(spawn_task) => {
- let task_state = Some(TaskState {
- id: spawn_task.id,
- full_label: spawn_task.full_label,
- label: spawn_task.label,
- command_label: spawn_task.command_label,
- hide: spawn_task.hide,
- status: TaskStatus::Running,
- show_summary: spawn_task.show_summary,
- show_command: spawn_task.show_command,
- show_rerun: spawn_task.show_rerun,
- completion_rx,
- });
-
- env.extend(spawn_task.env);
-
- if let Some(venv_path) = &python_venv_directory {
- env.insert(
- "VIRTUAL_ENV".to_string(),
- venv_path.to_string_lossy().to_string(),
- );
- }
-
- match ssh_details {
- Some(SshDetails {
- host,
- ssh_command,
- envs,
- path_style,
- shell,
- }) => {
- log::debug!("Connecting to a remote server: {ssh_command:?}");
- env.entry("TERM".to_string())
- .or_insert_with(|| "xterm-256color".to_string());
- let (program, args) = wrap_for_ssh(
- &shell,
- &ssh_command,
- spawn_task
- .command
- .as_ref()
- .map(|command| (command, &spawn_task.args)),
- path.as_deref(),
- env,
- python_venv_directory.as_deref(),
- path_style,
- );
- env = HashMap::default();
- if let Some(envs) = envs {
- env.extend(envs);
- }
- (
- task_state,
- Shell::WithArguments {
- program,
- args,
- title_override: Some(format!("{} — Terminal", host).into()),
+ &mut env,
+ path,
+ remote_client,
+ activation_script.clone(),
+ cx,
+ )?,
+ None => match activation_script.clone() {
+ Some(activation_script) => Shell::WithArguments {
+ program: get_default_system_shell(),
+ args: vec![
+ "-c".to_owned(),
+ format!("{activation_script}; exec {shell} -l",),
+ ],
+ title_override: Some(shell.into()),
},
- )
+ None => settings.shell,
+ },
}
- None => {
- if let Some(venv_path) = &python_venv_directory {
- add_environment_path(&mut env, &venv_path.join(PYTHON_VENV_BIN_DIR))
- .log_err();
+ };
+ TerminalBuilder::new(
+ local_path.map(|path| path.to_path_buf()),
+ None,
+ shell,
+ env,
+ settings.cursor_shape.unwrap_or_default(),
+ settings.alternate_scroll,
+ settings.max_scroll_history_lines,
+ is_via_remote,
+ cx.entity_id().as_u64(),
+ None,
+ cx,
+ activation_script,
+ )
+ .map(|builder| {
+ let terminal_handle = cx.new(|cx| builder.subscribe(cx));
+
+ this.terminals
+ .local_handles
+ .push(terminal_handle.downgrade());
+
+ let id = terminal_handle.entity_id();
+ cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
+ let handles = &mut project.terminals.local_handles;
+
+ if let Some(index) = handles
+ .iter()
+ .position(|terminal| terminal.entity_id() == id)
+ {
+ handles.remove(index);
+ cx.notify();
}
+ })
+ .detach();
- let shell = if let Some(program) = spawn_task.command {
- Shell::WithArguments {
- program,
- args: spawn_task.args,
- title_override: None,
- }
- } else {
- Shell::System
- };
- (task_state, shell)
- }
- }
- }
- };
- TerminalBuilder::new(
- local_path.map(|path| path.to_path_buf()),
- python_venv_directory,
- spawn_task,
- shell,
- env,
- settings.cursor_shape.unwrap_or_default(),
- settings.alternate_scroll,
- settings.max_scroll_history_lines,
- is_ssh_terminal,
- cx.entity_id().as_u64(),
- completion_tx,
- cx,
- )
- .map(|builder| {
+ terminal_handle
+ })
+ })?
+ })
+ }
+
+ pub fn clone_terminal(
+ &mut self,
+ terminal: &Entity<Terminal>,
+ cx: &mut Context<'_, Project>,
+ cwd: impl FnOnce() -> Option<PathBuf>,
+ ) -> Result<Entity<Terminal>> {
+ terminal.read(cx).clone_builder(cx, cwd).map(|builder| {
let terminal_handle = cx.new(|cx| builder.subscribe(cx));
- this.terminals
+ self.terminals
.local_handles
.push(terminal_handle.downgrade());
@@ -442,324 +397,113 @@ impl Project {
})
.detach();
- this.activate_python_virtual_environment(
- python_venv_activate_command,
- &terminal_handle,
- cx,
- );
-
terminal_handle
})
}
- fn python_venv_directory(
- &self,
- abs_path: Arc<Path>,
- venv_settings: VenvSettings,
- cx: &Context<Project>,
- ) -> Task<Option<PathBuf>> {
- cx.spawn(async move |this, cx| {
- if let Some((worktree, relative_path)) = this
- .update(cx, |this, cx| this.find_worktree(&abs_path, cx))
- .ok()?
- {
- let toolchain = this
- .update(cx, |this, cx| {
- this.active_toolchain(
- ProjectPath {
- worktree_id: worktree.read(cx).id(),
- path: relative_path.into(),
- },
- LanguageName::new("Python"),
- cx,
- )
- })
- .ok()?
- .await;
-
- if let Some(toolchain) = toolchain {
- let toolchain_path = Path::new(toolchain.path.as_ref());
- return Some(toolchain_path.parent()?.parent()?.to_path_buf());
- }
- }
- let venv_settings = venv_settings.as_option()?;
- this.update(cx, move |this, cx| {
- if let Some(path) = this.find_venv_in_worktree(&abs_path, &venv_settings, cx) {
- return Some(path);
- }
- this.find_venv_on_filesystem(&abs_path, &venv_settings, cx)
- })
- .ok()
- .flatten()
- })
- }
-
- fn find_venv_in_worktree(
- &self,
- abs_path: &Path,
- venv_settings: &terminal_settings::VenvSettingsContent,
- cx: &App,
- ) -> Option<PathBuf> {
- venv_settings
- .directories
- .iter()
- .map(|name| abs_path.join(name))
- .find(|venv_path| {
- let bin_path = venv_path.join(PYTHON_VENV_BIN_DIR);
- self.find_worktree(&bin_path, cx)
- .and_then(|(worktree, relative_path)| {
- worktree.read(cx).entry_for_path(&relative_path)
- })
- .is_some_and(|entry| entry.is_dir())
- })
- }
-
- fn find_venv_on_filesystem(
- &self,
- abs_path: &Path,
- venv_settings: &terminal_settings::VenvSettingsContent,
- cx: &App,
- ) -> Option<PathBuf> {
- let (worktree, _) = self.find_worktree(abs_path, cx)?;
- let fs = worktree.read(cx).as_local()?.fs();
- venv_settings
- .directories
- .iter()
- .map(|name| abs_path.join(name))
- .find(|venv_path| {
- let bin_path = venv_path.join(PYTHON_VENV_BIN_DIR);
- // One-time synchronous check is acceptable for terminal/task initialization
- smol::block_on(fs.metadata(&bin_path))
- .ok()
- .flatten()
- .is_some_and(|meta| meta.is_dir)
- })
- }
-
- fn activate_script_kind(shell: Option<&str>) -> ActivateScript {
- let shell_env = std::env::var("SHELL").ok();
- let shell_path = shell.or_else(|| shell_env.as_deref());
- let shell = std::path::Path::new(shell_path.unwrap_or(""))
- .file_name()
- .and_then(|name| name.to_str())
- .unwrap_or("");
- match shell {
- "fish" => ActivateScript::Fish,
- "tcsh" => ActivateScript::Csh,
- "nu" => ActivateScript::Nushell,
- "powershell" | "pwsh" => ActivateScript::PowerShell,
- _ => ActivateScript::Default,
+ pub fn terminal_settings<'a>(
+ &'a self,
+ path: &'a Option<PathBuf>,
+ cx: &'a App,
+ ) -> &'a TerminalSettings {
+ let mut settings_location = None;
+ if let Some(path) = path.as_ref()
+ && let Some((worktree, _)) = self.find_worktree(path, cx)
+ {
+ settings_location = Some(SettingsLocation {
+ worktree_id: worktree.read(cx).id(),
+ path,
+ });
}
+ TerminalSettings::get(settings_location, cx)
}
- fn python_activate_command(
- &self,
- venv_base_directory: &Path,
- venv_settings: &VenvSettings,
- shell: &Shell,
- cx: &mut App,
- ) -> Task<Option<String>> {
- let Some(venv_settings) = venv_settings.as_option() else {
- return Task::ready(None);
- };
- let activate_keyword = match venv_settings.activate_script {
- terminal_settings::ActivateScript::Default => match std::env::consts::OS {
- "windows" => ".",
- _ => ".",
- },
- terminal_settings::ActivateScript::Nushell => "overlay use",
- terminal_settings::ActivateScript::PowerShell => ".",
- terminal_settings::ActivateScript::Pyenv => "pyenv",
- _ => "source",
- };
- let script_kind =
- if venv_settings.activate_script == terminal_settings::ActivateScript::Default {
- match shell {
- Shell::Program(program) => Self::activate_script_kind(Some(program)),
- Shell::WithArguments {
- program,
- args: _,
- title_override: _,
- } => Self::activate_script_kind(Some(program)),
- Shell::System => Self::activate_script_kind(None),
- }
- } else {
- venv_settings.activate_script
- };
-
- let activate_script_name = match script_kind {
- terminal_settings::ActivateScript::Default
- | terminal_settings::ActivateScript::Pyenv => "activate",
- terminal_settings::ActivateScript::Csh => "activate.csh",
- terminal_settings::ActivateScript::Fish => "activate.fish",
- terminal_settings::ActivateScript::Nushell => "activate.nu",
- terminal_settings::ActivateScript::PowerShell => "activate.ps1",
- };
+ pub fn exec_in_shell(&self, command: String, cx: &App) -> Result<std::process::Command> {
+ let path = self.first_project_directory(cx);
+ let remote_client = self.remote_client.as_ref();
+ let settings = self.terminal_settings(&path, cx).clone();
+ let remote_shell = remote_client
+ .as_ref()
+ .and_then(|remote_client| remote_client.read(cx).shell());
+ let builder = ShellBuilder::new(remote_shell.as_deref(), &settings.shell).non_interactive();
+ let (command, args) = builder.build(Some(command), &Vec::new());
- let line_ending = match std::env::consts::OS {
- "windows" => "\r",
- _ => "\n",
- };
+ let mut env = self
+ .environment
+ .read(cx)
+ .get_cli_environment()
+ .unwrap_or_default();
+ env.extend(settings.env);
- if venv_settings.venv_name.is_empty() {
- let path = venv_base_directory
- .join(PYTHON_VENV_BIN_DIR)
- .join(activate_script_name)
- .to_string_lossy()
- .to_string();
-
- let is_valid_path = self.resolve_abs_path(path.as_ref(), cx);
- cx.background_spawn(async move {
- let quoted = shlex::try_quote(&path).ok()?;
- if is_valid_path.await.is_some_and(|meta| meta.is_file()) {
- Some(format!(
- "{} {} ; clear{}",
- activate_keyword, quoted, line_ending
- ))
- } else {
- None
+ match remote_client {
+ Some(remote_client) => {
+ let command_template = remote_client.read(cx).build_command(
+ Some(command),
+ &args,
+ &env,
+ None,
+ // todo
+ None,
+ None,
+ )?;
+ let mut command = std::process::Command::new(command_template.program);
+ command.args(command_template.args);
+ command.envs(command_template.env);
+ Ok(command)
+ }
+ None => {
+ let mut command = std::process::Command::new(command);
+ command.args(args);
+ command.envs(env);
+ if let Some(path) = path {
+ command.current_dir(path);
}
- })
- } else {
- Task::ready(Some(format!(
- "{activate_keyword} {activate_script_name} {name}; clear{line_ending}",
- name = venv_settings.venv_name
- )))
+ Ok(command)
+ }
}
}
- fn activate_python_virtual_environment(
- &self,
- command: Task<Option<String>>,
- terminal_handle: &Entity<Terminal>,
- cx: &mut App,
- ) {
- terminal_handle.update(cx, |_, cx| {
- cx.spawn(async move |this, cx| {
- if let Some(command) = command.await {
- this.update(cx, |this, _| {
- this.input(command.into_bytes());
- })
- .ok();
- }
- })
- .detach()
- });
- }
-
pub fn local_terminal_handles(&self) -> &Vec<WeakEntity<terminal::Terminal>> {
&self.terminals.local_handles
}
}
-pub fn wrap_for_ssh(
- shell: &str,
- ssh_command: &SshCommand,
- command: Option<(&String, &Vec<String>)>,
- path: Option<&Path>,
- env: HashMap<String, String>,
- venv_directory: Option<&Path>,
- path_style: PathStyle,
-) -> (String, Vec<String>) {
- let to_run = if let Some((command, args)) = command {
- let command: Option<Cow<str>> = shlex::try_quote(command).ok();
- let args = args.iter().filter_map(|arg| shlex::try_quote(arg).ok());
- command.into_iter().chain(args).join(" ")
- } else {
- format!("exec {shell} -l")
+fn create_remote_shell(
+ spawn_command: Option<(&String, &Vec<String>)>,
+ env: &mut HashMap<String, String>,
+ working_directory: Option<Arc<Path>>,
+ remote_client: Entity<RemoteClient>,
+ activation_script: Option<String>,
+ cx: &mut App,
+) -> Result<Shell> {
+ // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
+ // to properly display colors.
+ // We do not have the luxury of assuming the host has it installed,
+ // so we set it to a default that does not break the highlighting via ssh.
+ env.entry("TERM".to_string())
+ .or_insert_with(|| "xterm-256color".to_string());
+
+ let (program, args) = match spawn_command {
+ Some((program, args)) => (Some(program.clone()), args),
+ None => (None, &Vec::new()),
};
- let mut env_changes = String::new();
- for (k, v) in env.iter() {
- if let Some((k, v)) = shlex::try_quote(k).ok().zip(shlex::try_quote(v).ok()) {
- env_changes.push_str(&format!("{}={} ", k, v));
- }
- }
- if let Some(venv_directory) = venv_directory
- && let Ok(str) = shlex::try_quote(venv_directory.to_string_lossy().as_ref())
- {
- let path = RemotePathBuf::new(PathBuf::from(str.to_string()), path_style).to_string();
- env_changes.push_str(&format!("PATH={}:$PATH ", path));
- }
-
- let commands = if let Some(path) = path {
- let path = RemotePathBuf::new(path.to_path_buf(), path_style).to_string();
- // shlex will wrap the command in single quotes (''), disabling ~ expansion,
- // replace ith with something that works
- let tilde_prefix = "~/";
- if path.starts_with(tilde_prefix) {
- let trimmed_path = path
- .trim_start_matches("/")
- .trim_start_matches("~")
- .trim_start_matches("/");
-
- format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}")
- } else {
- format!("cd \"{path}\"; {env_changes} {to_run}")
- }
- } else {
- format!("cd; {env_changes} {to_run}")
- };
- let shell_invocation = format!("{shell} -c {}", shlex::try_quote(&commands).unwrap());
-
- let program = "ssh".to_string();
- let mut args = ssh_command.arguments.clone();
-
- args.push("-t".to_string());
- args.push(shell_invocation);
- (program, args)
-}
-
-fn add_environment_path(env: &mut HashMap<String, String>, new_path: &Path) -> Result<()> {
- let mut env_paths = vec![new_path.to_path_buf()];
- if let Some(path) = env.get("PATH").or(env::var("PATH").ok().as_ref()) {
- let mut paths = std::env::split_paths(&path).collect::<Vec<_>>();
- env_paths.append(&mut paths);
- }
-
- let paths = std::env::join_paths(env_paths).context("failed to create PATH env variable")?;
- env.insert("PATH".to_string(), paths.to_string_lossy().to_string());
-
- Ok(())
-}
-
-#[cfg(test)]
-mod tests {
- use collections::HashMap;
-
- #[test]
- fn test_add_environment_path_with_existing_path() {
- let tmp_path = std::path::PathBuf::from("/tmp/new");
- let mut env = HashMap::default();
- let old_path = if cfg!(windows) {
- "/usr/bin;/usr/local/bin"
- } else {
- "/usr/bin:/usr/local/bin"
- };
- env.insert("PATH".to_string(), old_path.to_string());
- env.insert("OTHER".to_string(), "aaa".to_string());
-
- super::add_environment_path(&mut env, &tmp_path).unwrap();
- if cfg!(windows) {
- assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", old_path));
- } else {
- assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", old_path));
- }
- assert_eq!(env.get("OTHER").unwrap(), "aaa");
- }
-
- #[test]
- fn test_add_environment_path_with_empty_path() {
- let tmp_path = std::path::PathBuf::from("/tmp/new");
- let mut env = HashMap::default();
- env.insert("OTHER".to_string(), "aaa".to_string());
- let os_path = std::env::var("PATH").unwrap();
- super::add_environment_path(&mut env, &tmp_path).unwrap();
- if cfg!(windows) {
- assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", os_path));
- } else {
- assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", os_path));
- }
- assert_eq!(env.get("OTHER").unwrap(), "aaa");
- }
+ let command = remote_client.read(cx).build_command(
+ program,
+ args.as_slice(),
+ env,
+ working_directory.map(|path| path.display().to_string()),
+ activation_script,
+ None,
+ )?;
+ *env = command.env;
+
+ log::debug!("Connecting to a remote server: {:?}", command.program);
+ let host = remote_client.read(cx).connection_options().host;
+
+ Ok(Shell::WithArguments {
+ program: command.program,
+ args: command.args,
+ title_override: Some(format!("{} — Terminal", host).into()),
+ })
}
@@ -389,12 +389,7 @@ impl LocalToolchainStore {
cx.background_spawn(async move {
Some((
toolchains
- .list(
- worktree_root,
- Some(relative_path.path.clone())
- .filter(|_| *relative_path.path != *Path::new("")),
- project_env,
- )
+ .list(worktree_root, relative_path.path.clone(), project_env)
.await,
relative_path.path,
))
@@ -18,7 +18,7 @@ use gpui::{
use postage::oneshot;
use rpc::{
AnyProtoClient, ErrorExt, TypedEnvelope,
- proto::{self, FromProto, SSH_PROJECT_ID, ToProto},
+ proto::{self, FromProto, REMOTE_SERVER_PROJECT_ID, ToProto},
};
use smol::{
channel::{Receiver, Sender},
@@ -61,7 +61,7 @@ pub struct WorktreeStore {
worktrees_reordered: bool,
#[allow(clippy::type_complexity)]
loading_worktrees:
- HashMap<SanitizedPath, Shared<Task<Result<Entity<Worktree>, Arc<anyhow::Error>>>>>,
+ HashMap<Arc<SanitizedPath>, Shared<Task<Result<Entity<Worktree>, Arc<anyhow::Error>>>>>,
state: WorktreeStoreState,
}
@@ -153,10 +153,10 @@ impl WorktreeStore {
pub fn find_worktree(
&self,
- abs_path: impl Into<SanitizedPath>,
+ abs_path: impl AsRef<Path>,
cx: &App,
) -> Option<(Entity<Worktree>, PathBuf)> {
- let abs_path: SanitizedPath = abs_path.into();
+ let abs_path = SanitizedPath::new(&abs_path);
for tree in self.worktrees() {
if let Ok(relative_path) = abs_path.as_path().strip_prefix(tree.read(cx).abs_path()) {
return Some((tree.clone(), relative_path.into()));
@@ -211,11 +211,11 @@ impl WorktreeStore {
pub fn create_worktree(
&mut self,
- abs_path: impl Into<SanitizedPath>,
+ abs_path: impl AsRef<Path>,
visible: bool,
cx: &mut Context<Self>,
) -> Task<Result<Entity<Worktree>>> {
- let abs_path: SanitizedPath = abs_path.into();
+ let abs_path: Arc<SanitizedPath> = SanitizedPath::new_arc(&abs_path);
if !self.loading_worktrees.contains_key(&abs_path) {
let task = match &self.state {
WorktreeStoreState::Remote {
@@ -226,8 +226,7 @@ impl WorktreeStore {
if upstream_client.is_via_collab() {
Task::ready(Err(Arc::new(anyhow!("cannot create worktrees via collab"))))
} else {
- let abs_path =
- RemotePathBuf::new(abs_path.as_path().to_path_buf(), *path_style);
+ let abs_path = RemotePathBuf::new(abs_path.to_path_buf(), *path_style);
self.create_ssh_worktree(upstream_client.clone(), abs_path, visible, cx)
}
}
@@ -277,7 +276,7 @@ impl WorktreeStore {
let path = RemotePathBuf::new(abs_path.into(), path_style);
let response = client
.request(proto::AddWorktree {
- project_id: SSH_PROJECT_ID,
+ project_id: REMOTE_SERVER_PROJECT_ID,
path: path.to_proto(),
visible,
})
@@ -297,7 +296,7 @@ impl WorktreeStore {
let worktree = cx.update(|cx| {
Worktree::remote(
- SSH_PROJECT_ID,
+ REMOTE_SERVER_PROJECT_ID,
0,
proto::WorktreeMetadata {
id: response.worktree_id,
@@ -320,15 +319,21 @@ impl WorktreeStore {
fn create_local_worktree(
&mut self,
fs: Arc<dyn Fs>,
- abs_path: impl Into<SanitizedPath>,
+ abs_path: Arc<SanitizedPath>,
visible: bool,
cx: &mut Context<Self>,
) -> Task<Result<Entity<Worktree>, Arc<anyhow::Error>>> {
let next_entry_id = self.next_entry_id.clone();
- let path: SanitizedPath = abs_path.into();
cx.spawn(async move |this, cx| {
- let worktree = Worktree::local(path.clone(), visible, fs, next_entry_id, cx).await;
+ let worktree = Worktree::local(
+ SanitizedPath::cast_arc(abs_path.clone()),
+ visible,
+ fs,
+ next_entry_id,
+ cx,
+ )
+ .await;
let worktree = worktree?;
@@ -336,7 +341,7 @@ impl WorktreeStore {
if visible {
cx.update(|cx| {
- cx.add_recent_document(path.as_path());
+ cx.add_recent_document(abs_path.as_path());
})
.log_err();
}
@@ -653,7 +653,7 @@ impl ProjectPanel {
let file_path = entry.path.clone();
let worktree_id = worktree.read(cx).id();
let entry_id = entry.id;
- let is_via_ssh = project.read(cx).is_via_ssh();
+ let is_via_ssh = project.read(cx).is_via_remote_server();
workspace
.open_path_preview(
@@ -4089,6 +4089,7 @@ impl ProjectPanel {
.when(!is_sticky, |this| {
this
.when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over))
+ .when(settings.drag_and_drop, |this| this
.on_drag_move::<ExternalPaths>(cx.listener(
move |this, event: &DragMoveEvent<ExternalPaths>, _, cx| {
let is_current_target = this.drag_target_entry.as_ref()
@@ -4222,7 +4223,7 @@ impl ProjectPanel {
}
this.drag_onto(selections, entry_id, kind.is_file(), window, cx);
}),
- )
+ ))
})
.on_mouse_down(
MouseButton::Left,
@@ -4433,6 +4434,7 @@ impl ProjectPanel {
div()
.when(!is_sticky, |div| {
div
+ .when(settings.drag_and_drop, |div| div
.on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
this.hover_scroll_task.take();
this.drag_target_entry = None;
@@ -4464,7 +4466,7 @@ impl ProjectPanel {
}
},
- ))
+ )))
})
.child(
Label::new(DELIMITER.clone())
@@ -4484,6 +4486,7 @@ impl ProjectPanel {
.when(index != components_len - 1, |div|{
let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned();
div
+ .when(settings.drag_and_drop, |div| div
.on_drag_move(cx.listener(
move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
if event.bounds.contains(&event.event.position) {
@@ -4521,7 +4524,7 @@ impl ProjectPanel {
target.index == index
), |this| {
this.bg(item_colors.drag_over)
- })
+ }))
})
})
.on_click(cx.listener(move |this, _, _, cx| {
@@ -5029,7 +5032,8 @@ impl ProjectPanel {
sticky_parents.reverse();
- let git_status_enabled = ProjectPanelSettings::get_global(cx).git_status;
+ let panel_settings = ProjectPanelSettings::get_global(cx);
+ let git_status_enabled = panel_settings.git_status;
let root_name = OsStr::new(worktree.root_name());
let git_summaries_by_id = if git_status_enabled {
@@ -5113,11 +5117,11 @@ impl Render for ProjectPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let has_worktree = !self.visible_entries.is_empty();
let project = self.project.read(cx);
- let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
- let show_indent_guides =
- ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
+ let panel_settings = ProjectPanelSettings::get_global(cx);
+ let indent_size = panel_settings.indent_size;
+ let show_indent_guides = panel_settings.indent_guides.show == ShowIndentGuides::Always;
let show_sticky_entries = {
- if ProjectPanelSettings::get_global(cx).sticky_scroll {
+ if panel_settings.sticky_scroll {
let is_scrollable = self.scroll_handle.is_scrollable();
let is_scrolled = self.scroll_handle.offset().y < px(0.);
is_scrollable && is_scrolled
@@ -5205,8 +5209,10 @@ impl Render for ProjectPanel {
h_flex()
.id("project-panel")
.group("project-panel")
- .on_drag_move(cx.listener(handle_drag_move::<ExternalPaths>))
- .on_drag_move(cx.listener(handle_drag_move::<DraggedSelection>))
+ .when(panel_settings.drag_and_drop, |this| {
+ this.on_drag_move(cx.listener(handle_drag_move::<ExternalPaths>))
+ .on_drag_move(cx.listener(handle_drag_move::<DraggedSelection>))
+ })
.size_full()
.relative()
.on_modifiers_changed(cx.listener(
@@ -5295,7 +5301,7 @@ impl Render for ProjectPanel {
.on_action(cx.listener(Self::open_system))
.on_action(cx.listener(Self::open_in_terminal))
})
- .when(project.is_via_ssh(), |el| {
+ .when(project.is_via_remote_server(), |el| {
el.on_action(cx.listener(Self::open_in_terminal))
})
.on_mouse_down(
@@ -5544,30 +5550,32 @@ impl Render for ProjectPanel {
})),
)
.when(is_local, |div| {
- div.drag_over::<ExternalPaths>(|style, _, _, cx| {
- style.bg(cx.theme().colors().drop_target_background)
+ div.when(panel_settings.drag_and_drop, |div| {
+ div.drag_over::<ExternalPaths>(|style, _, _, cx| {
+ style.bg(cx.theme().colors().drop_target_background)
+ })
+ .on_drop(cx.listener(
+ move |this, external_paths: &ExternalPaths, window, cx| {
+ this.drag_target_entry = None;
+ this.hover_scroll_task.take();
+ if let Some(task) = this
+ .workspace
+ .update(cx, |workspace, cx| {
+ workspace.open_workspace_for_paths(
+ true,
+ external_paths.paths().to_owned(),
+ window,
+ cx,
+ )
+ })
+ .log_err()
+ {
+ task.detach_and_log_err(cx);
+ }
+ cx.stop_propagation();
+ },
+ ))
})
- .on_drop(cx.listener(
- move |this, external_paths: &ExternalPaths, window, cx| {
- this.drag_target_entry = None;
- this.hover_scroll_task.take();
- if let Some(task) = this
- .workspace
- .update(cx, |workspace, cx| {
- workspace.open_workspace_for_paths(
- true,
- external_paths.paths().to_owned(),
- window,
- cx,
- )
- })
- .log_err()
- {
- task.detach_and_log_err(cx);
- }
- cx.stop_propagation();
- },
- ))
})
}
}
@@ -47,6 +47,7 @@ pub struct ProjectPanelSettings {
pub scrollbar: ScrollbarSettings,
pub show_diagnostics: ShowDiagnostics,
pub hide_root: bool,
+ pub drag_and_drop: bool,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -160,6 +161,10 @@ pub struct ProjectPanelSettingsContent {
///
/// Default: true
pub sticky_scroll: Option<bool>,
+ /// Whether to enable drag-and-drop operations in the project panel.
+ ///
+ /// Default: true
+ pub drag_and_drop: Option<bool>,
}
impl Settings for ProjectPanelSettings {
@@ -1,4 +1,5 @@
fn main() {
+ println!("cargo:rerun-if-changed=proto");
let mut build = prost_build::Config::new();
build
.type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]")
@@ -610,11 +610,36 @@ message ServerMetadataUpdated {
message LanguageServerLog {
uint64 project_id = 1;
uint64 language_server_id = 2;
+ string message = 3;
oneof log_type {
- uint32 log_message_type = 3;
- LspLogTrace log_trace = 4;
+ LogMessage log = 4;
+ TraceMessage trace = 5;
+ RpcMessage rpc = 6;
+ }
+}
+
+message LogMessage {
+ LogLevel level = 1;
+
+ enum LogLevel {
+ LOG = 0;
+ INFO = 1;
+ WARNING = 2;
+ ERROR = 3;
+ }
+}
+
+message TraceMessage {
+ optional string verbose_info = 1;
+}
+
+message RpcMessage {
+ Kind kind = 1;
+
+ enum Kind {
+ RECEIVED = 0;
+ SENT = 1;
}
- string message = 5;
}
message LspLogTrace {
@@ -932,3 +957,16 @@ message MultiLspQuery {
message MultiLspQueryResponse {
repeated LspResponse responses = 1;
}
+
+message ToggleLspLogs {
+ uint64 project_id = 1;
+ LogType log_type = 2;
+ uint64 server_id = 3;
+ bool enabled = 4;
+
+ enum LogType {
+ LOG = 0;
+ TRACE = 1;
+ RPC = 2;
+ }
+}
@@ -396,7 +396,8 @@ message Envelope {
GitCloneResponse git_clone_response = 364;
LspQuery lsp_query = 365;
- LspQueryResponse lsp_query_response = 366; // current max
+ LspQueryResponse lsp_query_response = 366;
+ ToggleLspLogs toggle_lsp_logs = 367; // current max
}
reserved 87 to 88;
@@ -16,8 +16,8 @@ pub use typed_envelope::*;
include!(concat!(env!("OUT_DIR"), "/zed.messages.rs"));
-pub const SSH_PEER_ID: PeerId = PeerId { owner_id: 0, id: 0 };
-pub const SSH_PROJECT_ID: u64 = 0;
+pub const REMOTE_SERVER_PEER_ID: PeerId = PeerId { owner_id: 0, id: 0 };
+pub const REMOTE_SERVER_PROJECT_ID: u64 = 0;
messages!(
(Ack, Foreground),
@@ -312,7 +312,8 @@ messages!(
(GetDefaultBranch, Background),
(GetDefaultBranchResponse, Background),
(GitClone, Background),
- (GitCloneResponse, Background)
+ (GitCloneResponse, Background),
+ (ToggleLspLogs, Background),
);
request_messages!(
@@ -481,7 +482,8 @@ request_messages!(
(GetDocumentDiagnostics, GetDocumentDiagnosticsResponse),
(PullWorkspaceDiagnostics, Ack),
(GetDefaultBranch, GetDefaultBranchResponse),
- (GitClone, GitCloneResponse)
+ (GitClone, GitCloneResponse),
+ (ToggleLspLogs, Ack),
);
lsp_messages!(
@@ -612,6 +614,7 @@ entity_messages!(
GitReset,
GitCheckoutFiles,
SetIndexText,
+ ToggleLspLogs,
Push,
Fetch,
@@ -64,8 +64,8 @@ impl DisconnectedOverlay {
}
let handle = cx.entity().downgrade();
- let ssh_connection_options = project.read(cx).ssh_connection_options(cx);
- let host = if let Some(ssh_connection_options) = ssh_connection_options {
+ let remote_connection_options = project.read(cx).remote_connection_options(cx);
+ let host = if let Some(ssh_connection_options) = remote_connection_options {
Host::SshRemoteProject(ssh_connection_options)
} else {
Host::RemoteProject
@@ -28,8 +28,8 @@ use paths::user_ssh_config_file;
use picker::Picker;
use project::Fs;
use project::Project;
-use remote::ssh_session::ConnectionIdentifier;
-use remote::{SshConnectionOptions, SshRemoteClient};
+use remote::remote_client::ConnectionIdentifier;
+use remote::{RemoteClient, SshConnectionOptions};
use settings::Settings;
use settings::SettingsStore;
use settings::update_settings_file;
@@ -69,7 +69,7 @@ pub struct RemoteServerProjects {
mode: Mode,
focus_handle: FocusHandle,
workspace: WeakEntity<Workspace>,
- retained_connections: Vec<Entity<SshRemoteClient>>,
+ retained_connections: Vec<Entity<RemoteClient>>,
ssh_config_updates: Task<()>,
ssh_config_servers: BTreeSet<SharedString>,
create_new_window: bool,
@@ -597,7 +597,7 @@ impl RemoteServerProjects {
let (path_style, project) = cx.update(|_, cx| {
(
session.read(cx).path_style(),
- project::Project::ssh(
+ project::Project::remote(
session,
app_state.client.clone(),
app_state.node_runtime.clone(),
@@ -15,8 +15,9 @@ use gpui::{
use language::CursorShape;
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use release_channel::ReleaseChannel;
-use remote::ssh_session::{ConnectionIdentifier, SshPortForwardOption};
-use remote::{SshConnectionOptions, SshPlatform, SshRemoteClient};
+use remote::{
+ ConnectionIdentifier, RemoteClient, RemotePlatform, SshConnectionOptions, SshPortForwardOption,
+};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsSources};
@@ -451,7 +452,7 @@ pub struct SshClientDelegate {
known_password: Option<String>,
}
-impl remote::SshClientDelegate for SshClientDelegate {
+impl remote::RemoteClientDelegate for SshClientDelegate {
fn ask_password(&self, prompt: String, tx: oneshot::Sender<String>, cx: &mut AsyncApp) {
let mut known_password = self.known_password.clone();
if let Some(password) = known_password.take() {
@@ -473,7 +474,7 @@ impl remote::SshClientDelegate for SshClientDelegate {
fn download_server_binary_locally(
&self,
- platform: SshPlatform,
+ platform: RemotePlatform,
release_channel: ReleaseChannel,
version: Option<SemanticVersion>,
cx: &mut AsyncApp,
@@ -503,7 +504,7 @@ impl remote::SshClientDelegate for SshClientDelegate {
fn get_download_params(
&self,
- platform: SshPlatform,
+ platform: RemotePlatform,
release_channel: ReleaseChannel,
version: Option<SemanticVersion>,
cx: &mut AsyncApp,
@@ -543,13 +544,13 @@ pub fn connect_over_ssh(
ui: Entity<SshPrompt>,
window: &mut Window,
cx: &mut App,
-) -> Task<Result<Option<Entity<SshRemoteClient>>>> {
+) -> Task<Result<Option<Entity<RemoteClient>>>> {
let window = window.window_handle();
let known_password = connection_options.password.clone();
let (tx, rx) = oneshot::channel();
ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
- remote::SshRemoteClient::new(
+ remote::RemoteClient::ssh(
unique_identifier,
connection_options,
rx,
@@ -681,9 +682,9 @@ pub async fn open_ssh_project(
window
.update(cx, |workspace, _, cx| {
- if let Some(client) = workspace.project().read(cx).ssh_client() {
+ if let Some(client) = workspace.project().read(cx).remote_client() {
ExtensionStore::global(cx)
- .update(cx, |store, cx| store.register_ssh_client(client, cx));
+ .update(cx, |store, cx| store.register_remote_client(client, cx));
}
})
.ok();
@@ -51,6 +51,16 @@ pub async fn write_message<S: AsyncWrite + Unpin>(
Ok(())
}
+pub async fn write_size_prefixed_buffer<S: AsyncWrite + Unpin>(
+ stream: &mut S,
+ buffer: &mut Vec<u8>,
+) -> Result<()> {
+ let len = buffer.len() as u32;
+ stream.write_all(len.to_le_bytes().as_slice()).await?;
+ stream.write_all(buffer).await?;
+ Ok(())
+}
+
pub async fn read_message_raw<S: AsyncRead + Unpin>(
stream: &mut S,
buffer: &mut Vec<u8>,
@@ -1,9 +1,11 @@
pub mod json_log;
pub mod protocol;
pub mod proxy;
-pub mod ssh_session;
+pub mod remote_client;
+mod transport;
-pub use ssh_session::{
- ConnectionState, SshClientDelegate, SshConnectionOptions, SshInfo, SshPlatform,
- SshRemoteClient, SshRemoteEvent,
+pub use remote_client::{
+ ConnectionIdentifier, ConnectionState, RemoteClient, RemoteClientDelegate, RemoteClientEvent,
+ RemotePlatform,
};
+pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption};
@@ -0,0 +1,1488 @@
+use crate::{
+ SshConnectionOptions, protocol::MessageId, proxy::ProxyLaunchError,
+ transport::ssh::SshRemoteConnection,
+};
+use anyhow::{Context as _, Result, anyhow};
+use async_trait::async_trait;
+use collections::HashMap;
+use futures::{
+ Future, FutureExt as _, StreamExt as _,
+ channel::{
+ mpsc::{self, Sender, UnboundedReceiver, UnboundedSender},
+ oneshot,
+ },
+ future::{BoxFuture, Shared},
+ select, select_biased,
+};
+use gpui::{
+ App, AppContext as _, AsyncApp, BackgroundExecutor, BorrowAppContext, Context, Entity,
+ EventEmitter, Global, SemanticVersion, Task, WeakEntity,
+};
+use parking_lot::Mutex;
+
+use release_channel::ReleaseChannel;
+use rpc::{
+ AnyProtoClient, ErrorExt, ProtoClient, ProtoMessageHandlerSet, RpcError,
+ proto::{self, Envelope, EnvelopedMessage, PeerId, RequestMessage, build_typed_envelope},
+};
+use std::{
+ collections::VecDeque,
+ fmt,
+ ops::ControlFlow,
+ path::PathBuf,
+ sync::{
+ Arc, Weak,
+ atomic::{AtomicU32, AtomicU64, Ordering::SeqCst},
+ },
+ time::{Duration, Instant},
+};
+use util::{
+ ResultExt,
+ paths::{PathStyle, RemotePathBuf},
+};
+
+#[derive(Copy, Clone, Debug)]
+pub struct RemotePlatform {
+ pub os: &'static str,
+ pub arch: &'static str,
+}
+
+#[derive(Clone, Debug)]
+pub struct CommandTemplate {
+ pub program: String,
+ pub args: Vec<String>,
+ pub env: HashMap<String, String>,
+}
+
+pub trait RemoteClientDelegate: Send + Sync {
+ fn ask_password(&self, prompt: String, tx: oneshot::Sender<String>, cx: &mut AsyncApp);
+ fn get_download_params(
+ &self,
+ platform: RemotePlatform,
+ release_channel: ReleaseChannel,
+ version: Option<SemanticVersion>,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<Option<(String, String)>>>;
+ fn download_server_binary_locally(
+ &self,
+ platform: RemotePlatform,
+ release_channel: ReleaseChannel,
+ version: Option<SemanticVersion>,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<PathBuf>>;
+ fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp);
+}
+
+const MAX_MISSED_HEARTBEATS: usize = 5;
+const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
+const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(5);
+
+const MAX_RECONNECT_ATTEMPTS: usize = 3;
+
+enum State {
+ Connecting,
+ Connected {
+ ssh_connection: Arc<dyn RemoteConnection>,
+ delegate: Arc<dyn RemoteClientDelegate>,
+
+ multiplex_task: Task<Result<()>>,
+ heartbeat_task: Task<Result<()>>,
+ },
+ HeartbeatMissed {
+ missed_heartbeats: usize,
+
+ ssh_connection: Arc<dyn RemoteConnection>,
+ delegate: Arc<dyn RemoteClientDelegate>,
+
+ multiplex_task: Task<Result<()>>,
+ heartbeat_task: Task<Result<()>>,
+ },
+ Reconnecting,
+ ReconnectFailed {
+ ssh_connection: Arc<dyn RemoteConnection>,
+ delegate: Arc<dyn RemoteClientDelegate>,
+
+ error: anyhow::Error,
+ attempts: usize,
+ },
+ ReconnectExhausted,
+ ServerNotRunning,
+}
+
+impl fmt::Display for State {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Connecting => write!(f, "connecting"),
+ Self::Connected { .. } => write!(f, "connected"),
+ Self::Reconnecting => write!(f, "reconnecting"),
+ Self::ReconnectFailed { .. } => write!(f, "reconnect failed"),
+ Self::ReconnectExhausted => write!(f, "reconnect exhausted"),
+ Self::HeartbeatMissed { .. } => write!(f, "heartbeat missed"),
+ Self::ServerNotRunning { .. } => write!(f, "server not running"),
+ }
+ }
+}
+
+impl State {
+ fn remote_connection(&self) -> Option<Arc<dyn RemoteConnection>> {
+ match self {
+ Self::Connected { ssh_connection, .. } => Some(ssh_connection.clone()),
+ Self::HeartbeatMissed { ssh_connection, .. } => Some(ssh_connection.clone()),
+ Self::ReconnectFailed { ssh_connection, .. } => Some(ssh_connection.clone()),
+ _ => None,
+ }
+ }
+
+ fn can_reconnect(&self) -> bool {
+ match self {
+ Self::Connected { .. }
+ | Self::HeartbeatMissed { .. }
+ | Self::ReconnectFailed { .. } => true,
+ State::Connecting
+ | State::Reconnecting
+ | State::ReconnectExhausted
+ | State::ServerNotRunning => false,
+ }
+ }
+
+ fn is_reconnect_failed(&self) -> bool {
+ matches!(self, Self::ReconnectFailed { .. })
+ }
+
+ fn is_reconnect_exhausted(&self) -> bool {
+ matches!(self, Self::ReconnectExhausted { .. })
+ }
+
+ fn is_server_not_running(&self) -> bool {
+ matches!(self, Self::ServerNotRunning)
+ }
+
+ fn is_reconnecting(&self) -> bool {
+ matches!(self, Self::Reconnecting { .. })
+ }
+
+ fn heartbeat_recovered(self) -> Self {
+ match self {
+ Self::HeartbeatMissed {
+ ssh_connection,
+ delegate,
+ multiplex_task,
+ heartbeat_task,
+ ..
+ } => Self::Connected {
+ ssh_connection,
+ delegate,
+ multiplex_task,
+ heartbeat_task,
+ },
+ _ => self,
+ }
+ }
+
+ fn heartbeat_missed(self) -> Self {
+ match self {
+ Self::Connected {
+ ssh_connection,
+ delegate,
+ multiplex_task,
+ heartbeat_task,
+ } => Self::HeartbeatMissed {
+ missed_heartbeats: 1,
+ ssh_connection,
+ delegate,
+ multiplex_task,
+ heartbeat_task,
+ },
+ Self::HeartbeatMissed {
+ missed_heartbeats,
+ ssh_connection,
+ delegate,
+ multiplex_task,
+ heartbeat_task,
+ } => Self::HeartbeatMissed {
+ missed_heartbeats: missed_heartbeats + 1,
+ ssh_connection,
+ delegate,
+ multiplex_task,
+ heartbeat_task,
+ },
+ _ => self,
+ }
+ }
+}
+
+/// The state of the ssh connection.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ConnectionState {
+ Connecting,
+ Connected,
+ HeartbeatMissed,
+ Reconnecting,
+ Disconnected,
+}
+
+impl From<&State> for ConnectionState {
+ fn from(value: &State) -> Self {
+ match value {
+ State::Connecting => Self::Connecting,
+ State::Connected { .. } => Self::Connected,
+ State::Reconnecting | State::ReconnectFailed { .. } => Self::Reconnecting,
+ State::HeartbeatMissed { .. } => Self::HeartbeatMissed,
+ State::ReconnectExhausted => Self::Disconnected,
+ State::ServerNotRunning => Self::Disconnected,
+ }
+ }
+}
+
+pub struct RemoteClient {
+ client: Arc<ChannelClient>,
+ unique_identifier: String,
+ connection_options: SshConnectionOptions,
+ path_style: PathStyle,
+ state: Option<State>,
+}
+
+#[derive(Debug)]
+pub enum RemoteClientEvent {
+ Disconnected,
+}
+
+impl EventEmitter<RemoteClientEvent> for RemoteClient {}
+
+// Identifies the socket on the remote server so that reconnects
+// can re-join the same project.
+pub enum ConnectionIdentifier {
+ Setup(u64),
+ Workspace(i64),
+}
+
+static NEXT_ID: AtomicU64 = AtomicU64::new(1);
+
+impl ConnectionIdentifier {
+ pub fn setup() -> Self {
+ Self::Setup(NEXT_ID.fetch_add(1, SeqCst))
+ }
+
+ // This string gets used in a socket name, and so must be relatively short.
+ // The total length of:
+ // /home/{username}/.local/share/zed/server_state/{name}/stdout.sock
+ // Must be less than about 100 characters
+ // https://unix.stackexchange.com/questions/367008/why-is-socket-path-length-limited-to-a-hundred-chars
+ // So our strings should be at most 20 characters or so.
+ fn to_string(&self, cx: &App) -> String {
+ let identifier_prefix = match ReleaseChannel::global(cx) {
+ ReleaseChannel::Stable => "".to_string(),
+ release_channel => format!("{}-", release_channel.dev_name()),
+ };
+ match self {
+ Self::Setup(setup_id) => format!("{identifier_prefix}setup-{setup_id}"),
+ Self::Workspace(workspace_id) => {
+ format!("{identifier_prefix}workspace-{workspace_id}",)
+ }
+ }
+ }
+}
+
+impl RemoteClient {
+ pub fn ssh(
+ unique_identifier: ConnectionIdentifier,
+ connection_options: SshConnectionOptions,
+ cancellation: oneshot::Receiver<()>,
+ delegate: Arc<dyn RemoteClientDelegate>,
+ cx: &mut App,
+ ) -> Task<Result<Option<Entity<Self>>>> {
+ let unique_identifier = unique_identifier.to_string(cx);
+ cx.spawn(async move |cx| {
+ let success = Box::pin(async move {
+ let (outgoing_tx, outgoing_rx) = mpsc::unbounded::<Envelope>();
+ let (incoming_tx, incoming_rx) = mpsc::unbounded::<Envelope>();
+ let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1);
+
+ let client =
+ cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "client"))?;
+
+ let ssh_connection = cx
+ .update(|cx| {
+ cx.update_default_global(|pool: &mut ConnectionPool, cx| {
+ pool.connect(connection_options.clone(), &delegate, cx)
+ })
+ })?
+ .await
+ .map_err(|e| e.cloned())?;
+
+ let path_style = ssh_connection.path_style();
+ let this = cx.new(|_| Self {
+ client: client.clone(),
+ unique_identifier: unique_identifier.clone(),
+ connection_options,
+ path_style,
+ state: Some(State::Connecting),
+ })?;
+
+ let io_task = ssh_connection.start_proxy(
+ unique_identifier,
+ false,
+ incoming_tx,
+ outgoing_rx,
+ connection_activity_tx,
+ delegate.clone(),
+ cx,
+ );
+
+ let multiplex_task = Self::monitor(this.downgrade(), io_task, cx);
+
+ if let Err(error) = client.ping(HEARTBEAT_TIMEOUT).await {
+ log::error!("failed to establish connection: {}", error);
+ return Err(error);
+ }
+
+ let heartbeat_task = Self::heartbeat(this.downgrade(), connection_activity_rx, cx);
+
+ this.update(cx, |this, _| {
+ this.state = Some(State::Connected {
+ ssh_connection,
+ delegate,
+ multiplex_task,
+ heartbeat_task,
+ });
+ })?;
+
+ Ok(Some(this))
+ });
+
+ select! {
+ _ = cancellation.fuse() => {
+ Ok(None)
+ }
+ result = success.fuse() => result
+ }
+ })
+ }
+
+ pub fn proto_client_from_channels(
+ incoming_rx: mpsc::UnboundedReceiver<Envelope>,
+ outgoing_tx: mpsc::UnboundedSender<Envelope>,
+ cx: &App,
+ name: &'static str,
+ ) -> AnyProtoClient {
+ ChannelClient::new(incoming_rx, outgoing_tx, cx, name).into()
+ }
+
+ pub fn shutdown_processes<T: RequestMessage>(
+ &mut self,
+ shutdown_request: Option<T>,
+ executor: BackgroundExecutor,
+ ) -> Option<impl Future<Output = ()> + use<T>> {
+ let state = self.state.take()?;
+ log::info!("shutting down ssh processes");
+
+ let State::Connected {
+ multiplex_task,
+ heartbeat_task,
+ ssh_connection,
+ delegate,
+ } = state
+ else {
+ return None;
+ };
+
+ let client = self.client.clone();
+
+ Some(async move {
+ if let Some(shutdown_request) = shutdown_request {
+ client.send(shutdown_request).log_err();
+ // We wait 50ms instead of waiting for a response, because
+ // waiting for a response would require us to wait on the main thread
+ // which we want to avoid in an `on_app_quit` callback.
+ executor.timer(Duration::from_millis(50)).await;
+ }
+
+ // Drop `multiplex_task` because it owns our ssh_proxy_process, which is a
+ // child of master_process.
+ drop(multiplex_task);
+ // Now drop the rest of state, which kills master process.
+ drop(heartbeat_task);
+ drop(ssh_connection);
+ drop(delegate);
+ })
+ }
+
+ fn reconnect(&mut self, cx: &mut Context<Self>) -> Result<()> {
+ let can_reconnect = self
+ .state
+ .as_ref()
+ .map(|state| state.can_reconnect())
+ .unwrap_or(false);
+ if !can_reconnect {
+ log::info!("aborting reconnect, because not in state that allows reconnecting");
+ let error = if let Some(state) = self.state.as_ref() {
+ format!("invalid state, cannot reconnect while in state {state}")
+ } else {
+ "no state set".to_string()
+ };
+ anyhow::bail!(error);
+ }
+
+ let state = self.state.take().unwrap();
+ let (attempts, ssh_connection, delegate) = match state {
+ State::Connected {
+ ssh_connection,
+ delegate,
+ multiplex_task,
+ heartbeat_task,
+ }
+ | State::HeartbeatMissed {
+ ssh_connection,
+ delegate,
+ multiplex_task,
+ heartbeat_task,
+ ..
+ } => {
+ drop(multiplex_task);
+ drop(heartbeat_task);
+ (0, ssh_connection, delegate)
+ }
+ State::ReconnectFailed {
+ attempts,
+ ssh_connection,
+ delegate,
+ ..
+ } => (attempts, ssh_connection, delegate),
+ State::Connecting
+ | State::Reconnecting
+ | State::ReconnectExhausted
+ | State::ServerNotRunning => unreachable!(),
+ };
+
+ let attempts = attempts + 1;
+ if attempts > MAX_RECONNECT_ATTEMPTS {
+ log::error!(
+ "Failed to reconnect to after {} attempts, giving up",
+ MAX_RECONNECT_ATTEMPTS
+ );
+ self.set_state(State::ReconnectExhausted, cx);
+ return Ok(());
+ }
+
+ self.set_state(State::Reconnecting, cx);
+
+ log::info!("Trying to reconnect to ssh server... Attempt {}", attempts);
+
+ let unique_identifier = self.unique_identifier.clone();
+ let client = self.client.clone();
+ let reconnect_task = cx.spawn(async move |this, cx| {
+ macro_rules! failed {
+ ($error:expr, $attempts:expr, $ssh_connection:expr, $delegate:expr) => {
+ return State::ReconnectFailed {
+ error: anyhow!($error),
+ attempts: $attempts,
+ ssh_connection: $ssh_connection,
+ delegate: $delegate,
+ };
+ };
+ }
+
+ if let Err(error) = ssh_connection
+ .kill()
+ .await
+ .context("Failed to kill ssh process")
+ {
+ failed!(error, attempts, ssh_connection, delegate);
+ };
+
+ let connection_options = ssh_connection.connection_options();
+
+ let (outgoing_tx, outgoing_rx) = mpsc::unbounded::<Envelope>();
+ let (incoming_tx, incoming_rx) = mpsc::unbounded::<Envelope>();
+ let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1);
+
+ let (ssh_connection, io_task) = match async {
+ let ssh_connection = cx
+ .update_global(|pool: &mut ConnectionPool, cx| {
+ pool.connect(connection_options, &delegate, cx)
+ })?
+ .await
+ .map_err(|error| error.cloned())?;
+
+ let io_task = ssh_connection.start_proxy(
+ unique_identifier,
+ true,
+ incoming_tx,
+ outgoing_rx,
+ connection_activity_tx,
+ delegate.clone(),
+ cx,
+ );
+ anyhow::Ok((ssh_connection, io_task))
+ }
+ .await
+ {
+ Ok((ssh_connection, io_task)) => (ssh_connection, io_task),
+ Err(error) => {
+ failed!(error, attempts, ssh_connection, delegate);
+ }
+ };
+
+ let multiplex_task = Self::monitor(this.clone(), io_task, cx);
+ client.reconnect(incoming_rx, outgoing_tx, cx);
+
+ if let Err(error) = client.resync(HEARTBEAT_TIMEOUT).await {
+ failed!(error, attempts, ssh_connection, delegate);
+ };
+
+ State::Connected {
+ ssh_connection,
+ delegate,
+ multiplex_task,
+ heartbeat_task: Self::heartbeat(this.clone(), connection_activity_rx, cx),
+ }
+ });
+
+ cx.spawn(async move |this, cx| {
+ let new_state = reconnect_task.await;
+ this.update(cx, |this, cx| {
+ this.try_set_state(cx, |old_state| {
+ if old_state.is_reconnecting() {
+ match &new_state {
+ State::Connecting
+ | State::Reconnecting
+ | State::HeartbeatMissed { .. }
+ | State::ServerNotRunning => {}
+ State::Connected { .. } => {
+ log::info!("Successfully reconnected");
+ }
+ State::ReconnectFailed {
+ error, attempts, ..
+ } => {
+ log::error!(
+ "Reconnect attempt {} failed: {:?}. Starting new attempt...",
+ attempts,
+ error
+ );
+ }
+ State::ReconnectExhausted => {
+ log::error!("Reconnect attempt failed and all attempts exhausted");
+ }
+ }
+ Some(new_state)
+ } else {
+ None
+ }
+ });
+
+ if this.state_is(State::is_reconnect_failed) {
+ this.reconnect(cx)
+ } else if this.state_is(State::is_reconnect_exhausted) {
+ Ok(())
+ } else {
+ log::debug!("State has transition from Reconnecting into new state while attempting reconnect.");
+ Ok(())
+ }
+ })
+ })
+ .detach_and_log_err(cx);
+
+ Ok(())
+ }
+
+ fn heartbeat(
+ this: WeakEntity<Self>,
+ mut connection_activity_rx: mpsc::Receiver<()>,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<()>> {
+ let Ok(client) = this.read_with(cx, |this, _| this.client.clone()) else {
+ return Task::ready(Err(anyhow!("SshRemoteClient lost")));
+ };
+
+ cx.spawn(async move |cx| {
+ let mut missed_heartbeats = 0;
+
+ let keepalive_timer = cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse();
+ futures::pin_mut!(keepalive_timer);
+
+ loop {
+ select_biased! {
+ result = connection_activity_rx.next().fuse() => {
+ if result.is_none() {
+ log::warn!("ssh heartbeat: connection activity channel has been dropped. stopping.");
+ return Ok(());
+ }
+
+ if missed_heartbeats != 0 {
+ missed_heartbeats = 0;
+ let _ =this.update(cx, |this, cx| {
+ this.handle_heartbeat_result(missed_heartbeats, cx)
+ })?;
+ }
+ }
+ _ = keepalive_timer => {
+ log::debug!("Sending heartbeat to server...");
+
+ let result = select_biased! {
+ _ = connection_activity_rx.next().fuse() => {
+ Ok(())
+ }
+ ping_result = client.ping(HEARTBEAT_TIMEOUT).fuse() => {
+ ping_result
+ }
+ };
+
+ if result.is_err() {
+ missed_heartbeats += 1;
+ log::warn!(
+ "No heartbeat from server after {:?}. Missed heartbeat {} out of {}.",
+ HEARTBEAT_TIMEOUT,
+ missed_heartbeats,
+ MAX_MISSED_HEARTBEATS
+ );
+ } else if missed_heartbeats != 0 {
+ missed_heartbeats = 0;
+ } else {
+ continue;
+ }
+
+ let result = this.update(cx, |this, cx| {
+ this.handle_heartbeat_result(missed_heartbeats, cx)
+ })?;
+ if result.is_break() {
+ return Ok(());
+ }
+ }
+ }
+
+ keepalive_timer.set(cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse());
+ }
+ })
+ }
+
+ fn handle_heartbeat_result(
+ &mut self,
+ missed_heartbeats: usize,
+ cx: &mut Context<Self>,
+ ) -> ControlFlow<()> {
+ let state = self.state.take().unwrap();
+ let next_state = if missed_heartbeats > 0 {
+ state.heartbeat_missed()
+ } else {
+ state.heartbeat_recovered()
+ };
+
+ self.set_state(next_state, cx);
+
+ if missed_heartbeats >= MAX_MISSED_HEARTBEATS {
+ log::error!(
+ "Missed last {} heartbeats. Reconnecting...",
+ missed_heartbeats
+ );
+
+ self.reconnect(cx)
+ .context("failed to start reconnect process after missing heartbeats")
+ .log_err();
+ ControlFlow::Break(())
+ } else {
+ ControlFlow::Continue(())
+ }
+ }
+
+ fn monitor(
+ this: WeakEntity<Self>,
+ io_task: Task<Result<i32>>,
+ cx: &AsyncApp,
+ ) -> Task<Result<()>> {
+ cx.spawn(async move |cx| {
+ let result = io_task.await;
+
+ match result {
+ Ok(exit_code) => {
+ if let Some(error) = ProxyLaunchError::from_exit_code(exit_code) {
+ match error {
+ ProxyLaunchError::ServerNotRunning => {
+ log::error!("failed to reconnect because server is not running");
+ this.update(cx, |this, cx| {
+ this.set_state(State::ServerNotRunning, cx);
+ })?;
+ }
+ }
+ } else if exit_code > 0 {
+ log::error!("proxy process terminated unexpectedly");
+ this.update(cx, |this, cx| {
+ this.reconnect(cx).ok();
+ })?;
+ }
+ }
+ Err(error) => {
+ log::warn!("ssh io task died with error: {:?}. reconnecting...", error);
+ this.update(cx, |this, cx| {
+ this.reconnect(cx).ok();
+ })?;
+ }
+ }
+
+ Ok(())
+ })
+ }
+
+ fn state_is(&self, check: impl FnOnce(&State) -> bool) -> bool {
+ self.state.as_ref().is_some_and(check)
+ }
+
+ fn try_set_state(&mut self, cx: &mut Context<Self>, map: impl FnOnce(&State) -> Option<State>) {
+ let new_state = self.state.as_ref().and_then(map);
+ if let Some(new_state) = new_state {
+ self.state.replace(new_state);
+ cx.notify();
+ }
+ }
+
+ fn set_state(&mut self, state: State, cx: &mut Context<Self>) {
+ log::info!("setting state to '{}'", &state);
+
+ let is_reconnect_exhausted = state.is_reconnect_exhausted();
+ let is_server_not_running = state.is_server_not_running();
+ self.state.replace(state);
+
+ if is_reconnect_exhausted || is_server_not_running {
+ cx.emit(RemoteClientEvent::Disconnected);
+ }
+ cx.notify();
+ }
+
+ pub fn shell(&self) -> Option<String> {
+ Some(self.state.as_ref()?.remote_connection()?.shell())
+ }
+
+ pub fn build_command(
+ &self,
+ program: Option<String>,
+ args: &[String],
+ env: &HashMap<String, String>,
+ working_dir: Option<String>,
+ activation_script: Option<String>,
+ port_forward: Option<(u16, String, u16)>,
+ ) -> Result<CommandTemplate> {
+ let Some(connection) = self
+ .state
+ .as_ref()
+ .and_then(|state| state.remote_connection())
+ else {
+ return Err(anyhow!("no connection"));
+ };
+ connection.build_command(
+ program,
+ args,
+ env,
+ working_dir,
+ activation_script,
+ port_forward,
+ )
+ }
+
+ pub fn upload_directory(
+ &self,
+ src_path: PathBuf,
+ dest_path: RemotePathBuf,
+ cx: &App,
+ ) -> Task<Result<()>> {
+ let Some(connection) = self
+ .state
+ .as_ref()
+ .and_then(|state| state.remote_connection())
+ else {
+ return Task::ready(Err(anyhow!("no ssh connection")));
+ };
+ connection.upload_directory(src_path, dest_path, cx)
+ }
+
+ pub fn proto_client(&self) -> AnyProtoClient {
+ self.client.clone().into()
+ }
+
+ pub fn host(&self) -> String {
+ self.connection_options.host.clone()
+ }
+
+ pub fn connection_options(&self) -> SshConnectionOptions {
+ self.connection_options.clone()
+ }
+
+ pub fn connection_state(&self) -> ConnectionState {
+ self.state
+ .as_ref()
+ .map(ConnectionState::from)
+ .unwrap_or(ConnectionState::Disconnected)
+ }
+
+ pub fn is_disconnected(&self) -> bool {
+ self.connection_state() == ConnectionState::Disconnected
+ }
+
+ pub fn path_style(&self) -> PathStyle {
+ self.path_style
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn simulate_disconnect(&self, client_cx: &mut App) -> Task<()> {
+ let opts = self.connection_options();
+ client_cx.spawn(async move |cx| {
+ let connection = cx
+ .update_global(|c: &mut ConnectionPool, _| {
+ if let Some(ConnectionPoolEntry::Connecting(c)) = c.connections.get(&opts) {
+ c.clone()
+ } else {
+ panic!("missing test connection")
+ }
+ })
+ .unwrap()
+ .await
+ .unwrap();
+
+ connection.simulate_disconnect(cx);
+ })
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn fake_server(
+ client_cx: &mut gpui::TestAppContext,
+ server_cx: &mut gpui::TestAppContext,
+ ) -> (SshConnectionOptions, AnyProtoClient) {
+ let port = client_cx
+ .update(|cx| cx.default_global::<ConnectionPool>().connections.len() as u16 + 1);
+ let opts = SshConnectionOptions {
+ host: "<fake>".to_string(),
+ port: Some(port),
+ ..Default::default()
+ };
+ let (outgoing_tx, _) = mpsc::unbounded::<Envelope>();
+ let (_, incoming_rx) = mpsc::unbounded::<Envelope>();
+ let server_client =
+ server_cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "fake-server"));
+ let connection: Arc<dyn RemoteConnection> = Arc::new(fake::FakeRemoteConnection {
+ connection_options: opts.clone(),
+ server_cx: fake::SendableCx::new(server_cx),
+ server_channel: server_client.clone(),
+ });
+
+ client_cx.update(|cx| {
+ cx.update_default_global(|c: &mut ConnectionPool, cx| {
+ c.connections.insert(
+ opts.clone(),
+ ConnectionPoolEntry::Connecting(
+ cx.background_spawn({
+ let connection = connection.clone();
+ async move { Ok(connection.clone()) }
+ })
+ .shared(),
+ ),
+ );
+ })
+ });
+
+ (opts, server_client.into())
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub async fn fake_client(
+ opts: SshConnectionOptions,
+ client_cx: &mut gpui::TestAppContext,
+ ) -> Entity<Self> {
+ let (_tx, rx) = oneshot::channel();
+ client_cx
+ .update(|cx| {
+ Self::ssh(
+ ConnectionIdentifier::setup(),
+ opts,
+ rx,
+ Arc::new(fake::Delegate),
+ cx,
+ )
+ })
+ .await
+ .unwrap()
+ .unwrap()
+ }
+}
+
+enum ConnectionPoolEntry {
+ Connecting(Shared<Task<Result<Arc<dyn RemoteConnection>, Arc<anyhow::Error>>>>),
+ Connected(Weak<dyn RemoteConnection>),
+}
+
+#[derive(Default)]
+struct ConnectionPool {
+ connections: HashMap<SshConnectionOptions, ConnectionPoolEntry>,
+}
+
+impl Global for ConnectionPool {}
+
+impl ConnectionPool {
+ pub fn connect(
+ &mut self,
+ opts: SshConnectionOptions,
+ delegate: &Arc<dyn RemoteClientDelegate>,
+ cx: &mut App,
+ ) -> Shared<Task<Result<Arc<dyn RemoteConnection>, Arc<anyhow::Error>>>> {
+ let connection = self.connections.get(&opts);
+ match connection {
+ Some(ConnectionPoolEntry::Connecting(task)) => {
+ let delegate = delegate.clone();
+ cx.spawn(async move |cx| {
+ delegate.set_status(Some("Waiting for existing connection attempt"), cx);
+ })
+ .detach();
+ return task.clone();
+ }
+ Some(ConnectionPoolEntry::Connected(ssh)) => {
+ if let Some(ssh) = ssh.upgrade()
+ && !ssh.has_been_killed()
+ {
+ return Task::ready(Ok(ssh)).shared();
+ }
+ self.connections.remove(&opts);
+ }
+ None => {}
+ }
+
+ let task = cx
+ .spawn({
+ let opts = opts.clone();
+ let delegate = delegate.clone();
+ async move |cx| {
+ let connection = SshRemoteConnection::new(opts.clone(), delegate, cx)
+ .await
+ .map(|connection| Arc::new(connection) as Arc<dyn RemoteConnection>);
+
+ cx.update_global(|pool: &mut Self, _| {
+ debug_assert!(matches!(
+ pool.connections.get(&opts),
+ Some(ConnectionPoolEntry::Connecting(_))
+ ));
+ match connection {
+ Ok(connection) => {
+ pool.connections.insert(
+ opts.clone(),
+ ConnectionPoolEntry::Connected(Arc::downgrade(&connection)),
+ );
+ Ok(connection)
+ }
+ Err(error) => {
+ pool.connections.remove(&opts);
+ Err(Arc::new(error))
+ }
+ }
+ })?
+ }
+ })
+ .shared();
+
+ self.connections
+ .insert(opts.clone(), ConnectionPoolEntry::Connecting(task.clone()));
+ task
+ }
+}
+
+#[async_trait(?Send)]
+pub(crate) trait RemoteConnection: Send + Sync {
+ fn start_proxy(
+ &self,
+ unique_identifier: String,
+ reconnect: bool,
+ incoming_tx: UnboundedSender<Envelope>,
+ outgoing_rx: UnboundedReceiver<Envelope>,
+ connection_activity_tx: Sender<()>,
+ delegate: Arc<dyn RemoteClientDelegate>,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<i32>>;
+ fn upload_directory(
+ &self,
+ src_path: PathBuf,
+ dest_path: RemotePathBuf,
+ cx: &App,
+ ) -> Task<Result<()>>;
+ async fn kill(&self) -> Result<()>;
+ fn has_been_killed(&self) -> bool;
+ fn build_command(
+ &self,
+ program: Option<String>,
+ args: &[String],
+ env: &HashMap<String, String>,
+ working_dir: Option<String>,
+ activation_script: Option<String>,
+ port_forward: Option<(u16, String, u16)>,
+ ) -> Result<CommandTemplate>;
+ fn connection_options(&self) -> SshConnectionOptions;
+ fn path_style(&self) -> PathStyle;
+ fn shell(&self) -> String;
+
+ #[cfg(any(test, feature = "test-support"))]
+ fn simulate_disconnect(&self, _: &AsyncApp) {}
+}
+
+type ResponseChannels = Mutex<HashMap<MessageId, oneshot::Sender<(Envelope, oneshot::Sender<()>)>>>;
+
+struct ChannelClient {
+ next_message_id: AtomicU32,
+ outgoing_tx: Mutex<mpsc::UnboundedSender<Envelope>>,
+ buffer: Mutex<VecDeque<Envelope>>,
+ response_channels: ResponseChannels,
+ message_handlers: Mutex<ProtoMessageHandlerSet>,
+ max_received: AtomicU32,
+ name: &'static str,
+ task: Mutex<Task<Result<()>>>,
+}
+
+impl ChannelClient {
+ fn new(
+ incoming_rx: mpsc::UnboundedReceiver<Envelope>,
+ outgoing_tx: mpsc::UnboundedSender<Envelope>,
+ cx: &App,
+ name: &'static str,
+ ) -> Arc<Self> {
+ Arc::new_cyclic(|this| Self {
+ outgoing_tx: Mutex::new(outgoing_tx),
+ next_message_id: AtomicU32::new(0),
+ max_received: AtomicU32::new(0),
+ response_channels: ResponseChannels::default(),
+ message_handlers: Default::default(),
+ buffer: Mutex::new(VecDeque::new()),
+ name,
+ task: Mutex::new(Self::start_handling_messages(
+ this.clone(),
+ incoming_rx,
+ &cx.to_async(),
+ )),
+ })
+ }
+
+ fn start_handling_messages(
+ this: Weak<Self>,
+ mut incoming_rx: mpsc::UnboundedReceiver<Envelope>,
+ cx: &AsyncApp,
+ ) -> Task<Result<()>> {
+ cx.spawn(async move |cx| {
+ let peer_id = PeerId { owner_id: 0, id: 0 };
+ while let Some(incoming) = incoming_rx.next().await {
+ let Some(this) = this.upgrade() else {
+ return anyhow::Ok(());
+ };
+ if let Some(ack_id) = incoming.ack_id {
+ let mut buffer = this.buffer.lock();
+ while buffer.front().is_some_and(|msg| msg.id <= ack_id) {
+ buffer.pop_front();
+ }
+ }
+ if let Some(proto::envelope::Payload::FlushBufferedMessages(_)) = &incoming.payload
+ {
+ log::debug!(
+ "{}:ssh message received. name:FlushBufferedMessages",
+ this.name
+ );
+ {
+ let buffer = this.buffer.lock();
+ for envelope in buffer.iter() {
+ this.outgoing_tx
+ .lock()
+ .unbounded_send(envelope.clone())
+ .ok();
+ }
+ }
+ let mut envelope = proto::Ack {}.into_envelope(0, Some(incoming.id), None);
+ envelope.id = this.next_message_id.fetch_add(1, SeqCst);
+ this.outgoing_tx.lock().unbounded_send(envelope).ok();
+ continue;
+ }
+
+ this.max_received.store(incoming.id, SeqCst);
+
+ if let Some(request_id) = incoming.responding_to {
+ let request_id = MessageId(request_id);
+ let sender = this.response_channels.lock().remove(&request_id);
+ if let Some(sender) = sender {
+ let (tx, rx) = oneshot::channel();
+ if incoming.payload.is_some() {
+ sender.send((incoming, tx)).ok();
+ }
+ rx.await.ok();
+ }
+ } else if let Some(envelope) =
+ build_typed_envelope(peer_id, Instant::now(), incoming)
+ {
+ let type_name = envelope.payload_type_name();
+ let message_id = envelope.message_id();
+ if let Some(future) = ProtoMessageHandlerSet::handle_message(
+ &this.message_handlers,
+ envelope,
+ this.clone().into(),
+ cx.clone(),
+ ) {
+ log::debug!("{}:ssh message received. name:{type_name}", this.name);
+ cx.foreground_executor()
+ .spawn(async move {
+ match future.await {
+ Ok(_) => {
+ log::debug!(
+ "{}:ssh message handled. name:{type_name}",
+ this.name
+ );
+ }
+ Err(error) => {
+ log::error!(
+ "{}:error handling message. type:{}, error:{}",
+ this.name,
+ type_name,
+ format!("{error:#}").lines().fold(
+ String::new(),
+ |mut message, line| {
+ if !message.is_empty() {
+ message.push(' ');
+ }
+ message.push_str(line);
+ message
+ }
+ )
+ );
+ }
+ }
+ })
+ .detach()
+ } else {
+ log::error!("{}:unhandled ssh message name:{type_name}", this.name);
+ if let Err(e) = AnyProtoClient::from(this.clone()).send_response(
+ message_id,
+ anyhow::anyhow!("no handler registered for {type_name}").to_proto(),
+ ) {
+ log::error!(
+ "{}:error sending error response for {type_name}:{e:#}",
+ this.name
+ );
+ }
+ }
+ }
+ }
+ anyhow::Ok(())
+ })
+ }
+
+ fn reconnect(
+ self: &Arc<Self>,
+ incoming_rx: UnboundedReceiver<Envelope>,
+ outgoing_tx: UnboundedSender<Envelope>,
+ cx: &AsyncApp,
+ ) {
+ *self.outgoing_tx.lock() = outgoing_tx;
+ *self.task.lock() = Self::start_handling_messages(Arc::downgrade(self), incoming_rx, cx);
+ }
+
+ fn request<T: RequestMessage>(
+ &self,
+ payload: T,
+ ) -> impl 'static + Future<Output = Result<T::Response>> {
+ self.request_internal(payload, true)
+ }
+
+ fn request_internal<T: RequestMessage>(
+ &self,
+ payload: T,
+ use_buffer: bool,
+ ) -> impl 'static + Future<Output = Result<T::Response>> {
+ log::debug!("ssh request start. name:{}", T::NAME);
+ let response =
+ self.request_dynamic(payload.into_envelope(0, None, None), T::NAME, use_buffer);
+ async move {
+ let response = response.await?;
+ log::debug!("ssh request finish. name:{}", T::NAME);
+ T::Response::from_envelope(response).context("received a response of the wrong type")
+ }
+ }
+
+ async fn resync(&self, timeout: Duration) -> Result<()> {
+ smol::future::or(
+ async {
+ self.request_internal(proto::FlushBufferedMessages {}, false)
+ .await?;
+
+ for envelope in self.buffer.lock().iter() {
+ self.outgoing_tx
+ .lock()
+ .unbounded_send(envelope.clone())
+ .ok();
+ }
+ Ok(())
+ },
+ async {
+ smol::Timer::after(timeout).await;
+ anyhow::bail!("Timed out resyncing remote client")
+ },
+ )
+ .await
+ }
+
+ async fn ping(&self, timeout: Duration) -> Result<()> {
+ smol::future::or(
+ async {
+ self.request(proto::Ping {}).await?;
+ Ok(())
+ },
+ async {
+ smol::Timer::after(timeout).await;
+ anyhow::bail!("Timed out pinging remote client")
+ },
+ )
+ .await
+ }
+
+ fn send<T: EnvelopedMessage>(&self, payload: T) -> Result<()> {
+ log::debug!("ssh send name:{}", T::NAME);
+ self.send_dynamic(payload.into_envelope(0, None, None))
+ }
+
+ fn request_dynamic(
+ &self,
+ mut envelope: proto::Envelope,
+ type_name: &'static str,
+ use_buffer: bool,
+ ) -> impl 'static + Future<Output = Result<proto::Envelope>> {
+ envelope.id = self.next_message_id.fetch_add(1, SeqCst);
+ let (tx, rx) = oneshot::channel();
+ let mut response_channels_lock = self.response_channels.lock();
+ response_channels_lock.insert(MessageId(envelope.id), tx);
+ drop(response_channels_lock);
+
+ let result = if use_buffer {
+ self.send_buffered(envelope)
+ } else {
+ self.send_unbuffered(envelope)
+ };
+ async move {
+ if let Err(error) = &result {
+ log::error!("failed to send message: {error}");
+ anyhow::bail!("failed to send message: {error}");
+ }
+
+ let response = rx.await.context("connection lost")?.0;
+ if let Some(proto::envelope::Payload::Error(error)) = &response.payload {
+ return Err(RpcError::from_proto(error, type_name));
+ }
+ Ok(response)
+ }
+ }
+
+ pub fn send_dynamic(&self, mut envelope: proto::Envelope) -> Result<()> {
+ envelope.id = self.next_message_id.fetch_add(1, SeqCst);
+ self.send_buffered(envelope)
+ }
+
+ fn send_buffered(&self, mut envelope: proto::Envelope) -> Result<()> {
+ envelope.ack_id = Some(self.max_received.load(SeqCst));
+ self.buffer.lock().push_back(envelope.clone());
+ // ignore errors on send (happen while we're reconnecting)
+ // assume that the global "disconnected" overlay is sufficient.
+ self.outgoing_tx.lock().unbounded_send(envelope).ok();
+ Ok(())
+ }
+
+ fn send_unbuffered(&self, mut envelope: proto::Envelope) -> Result<()> {
+ envelope.ack_id = Some(self.max_received.load(SeqCst));
+ self.outgoing_tx.lock().unbounded_send(envelope).ok();
+ Ok(())
+ }
+}
+
+impl ProtoClient for ChannelClient {
+ fn request(
+ &self,
+ envelope: proto::Envelope,
+ request_type: &'static str,
+ ) -> BoxFuture<'static, Result<proto::Envelope>> {
+ self.request_dynamic(envelope, request_type, true).boxed()
+ }
+
+ fn send(&self, envelope: proto::Envelope, _message_type: &'static str) -> Result<()> {
+ self.send_dynamic(envelope)
+ }
+
+ fn send_response(&self, envelope: Envelope, _message_type: &'static str) -> anyhow::Result<()> {
+ self.send_dynamic(envelope)
+ }
+
+ fn message_handler_set(&self) -> &Mutex<ProtoMessageHandlerSet> {
+ &self.message_handlers
+ }
+
+ fn is_via_collab(&self) -> bool {
+ false
+ }
+}
+
+#[cfg(any(test, feature = "test-support"))]
+mod fake {
+ use super::{ChannelClient, RemoteClientDelegate, RemoteConnection, RemotePlatform};
+ use crate::{SshConnectionOptions, remote_client::CommandTemplate};
+ use anyhow::Result;
+ use async_trait::async_trait;
+ use collections::HashMap;
+ use futures::{
+ FutureExt, SinkExt, StreamExt,
+ channel::{
+ mpsc::{self, Sender},
+ oneshot,
+ },
+ select_biased,
+ };
+ use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task, TestAppContext};
+ use release_channel::ReleaseChannel;
+ use rpc::proto::Envelope;
+ use std::{path::PathBuf, sync::Arc};
+ use util::paths::{PathStyle, RemotePathBuf};
+
+ pub(super) struct FakeRemoteConnection {
+ pub(super) connection_options: SshConnectionOptions,
+ pub(super) server_channel: Arc<ChannelClient>,
+ pub(super) server_cx: SendableCx,
+ }
+
+ pub(super) struct SendableCx(AsyncApp);
+ impl SendableCx {
+ // SAFETY: When run in test mode, GPUI is always single threaded.
+ pub(super) fn new(cx: &TestAppContext) -> Self {
+ Self(cx.to_async())
+ }
+
+ // SAFETY: Enforce that we're on the main thread by requiring a valid AsyncApp
+ fn get(&self, _: &AsyncApp) -> AsyncApp {
+ self.0.clone()
+ }
+ }
+
+ // SAFETY: There is no way to access a SendableCx from a different thread, see [`SendableCx::new`] and [`SendableCx::get`]
+ unsafe impl Send for SendableCx {}
+ unsafe impl Sync for SendableCx {}
+
+ #[async_trait(?Send)]
+ impl RemoteConnection for FakeRemoteConnection {
+ async fn kill(&self) -> Result<()> {
+ Ok(())
+ }
+
+ fn has_been_killed(&self) -> bool {
+ false
+ }
+
+ fn build_command(
+ &self,
+ program: Option<String>,
+ args: &[String],
+ env: &HashMap<String, String>,
+ _: Option<String>,
+ _: Option<String>,
+ _: Option<(u16, String, u16)>,
+ ) -> Result<CommandTemplate> {
+ let ssh_program = program.unwrap_or_else(|| "sh".to_string());
+ let mut ssh_args = Vec::new();
+ ssh_args.push(ssh_program);
+ ssh_args.extend(args.iter().cloned());
+ Ok(CommandTemplate {
+ program: "ssh".into(),
+ args: ssh_args,
+ env: env.clone(),
+ })
+ }
+
+ fn upload_directory(
+ &self,
+ _src_path: PathBuf,
+ _dest_path: RemotePathBuf,
+ _cx: &App,
+ ) -> Task<Result<()>> {
+ unreachable!()
+ }
+
+ fn connection_options(&self) -> SshConnectionOptions {
+ self.connection_options.clone()
+ }
+
+ fn simulate_disconnect(&self, cx: &AsyncApp) {
+ let (outgoing_tx, _) = mpsc::unbounded::<Envelope>();
+ let (_, incoming_rx) = mpsc::unbounded::<Envelope>();
+ self.server_channel
+ .reconnect(incoming_rx, outgoing_tx, &self.server_cx.get(cx));
+ }
+
+ fn start_proxy(
+ &self,
+ _unique_identifier: String,
+ _reconnect: bool,
+ mut client_incoming_tx: mpsc::UnboundedSender<Envelope>,
+ mut client_outgoing_rx: mpsc::UnboundedReceiver<Envelope>,
+ mut connection_activity_tx: Sender<()>,
+ _delegate: Arc<dyn RemoteClientDelegate>,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<i32>> {
+ let (mut server_incoming_tx, server_incoming_rx) = mpsc::unbounded::<Envelope>();
+ let (server_outgoing_tx, mut server_outgoing_rx) = mpsc::unbounded::<Envelope>();
+
+ self.server_channel.reconnect(
+ server_incoming_rx,
+ server_outgoing_tx,
+ &self.server_cx.get(cx),
+ );
+
+ cx.background_spawn(async move {
+ loop {
+ select_biased! {
+ server_to_client = server_outgoing_rx.next().fuse() => {
+ let Some(server_to_client) = server_to_client else {
+ return Ok(1)
+ };
+ connection_activity_tx.try_send(()).ok();
+ client_incoming_tx.send(server_to_client).await.ok();
+ }
+ client_to_server = client_outgoing_rx.next().fuse() => {
+ let Some(client_to_server) = client_to_server else {
+ return Ok(1)
+ };
+ server_incoming_tx.send(client_to_server).await.ok();
+ }
+ }
+ }
+ })
+ }
+
+ fn path_style(&self) -> PathStyle {
+ PathStyle::current()
+ }
+
+ fn shell(&self) -> String {
+ "sh".to_owned()
+ }
+ }
+
+ pub(super) struct Delegate;
+
+ impl RemoteClientDelegate for Delegate {
+ fn ask_password(&self, _: String, _: oneshot::Sender<String>, _: &mut AsyncApp) {
+ unreachable!()
+ }
+
+ fn download_server_binary_locally(
+ &self,
+ _: RemotePlatform,
+ _: ReleaseChannel,
+ _: Option<SemanticVersion>,
+ _: &mut AsyncApp,
+ ) -> Task<Result<PathBuf>> {
+ unreachable!()
+ }
+
+ fn get_download_params(
+ &self,
+ _platform: RemotePlatform,
+ _release_channel: ReleaseChannel,
+ _version: Option<SemanticVersion>,
+ _cx: &mut AsyncApp,
+ ) -> Task<Result<Option<(String, String)>>> {
+ unreachable!()
+ }
+
+ fn set_status(&self, _: Option<&str>, _: &mut AsyncApp) {}
+ }
+}
@@ -1,2749 +0,0 @@
-use crate::{
- json_log::LogRecord,
- protocol::{
- MESSAGE_LEN_SIZE, MessageId, message_len_from_buffer, read_message_with_len, write_message,
- },
- proxy::ProxyLaunchError,
-};
-use anyhow::{Context as _, Result, anyhow};
-use async_trait::async_trait;
-use collections::HashMap;
-use futures::{
- AsyncReadExt as _, Future, FutureExt as _, StreamExt as _,
- channel::{
- mpsc::{self, Sender, UnboundedReceiver, UnboundedSender},
- oneshot,
- },
- future::{BoxFuture, Shared},
- select, select_biased,
-};
-use gpui::{
- App, AppContext as _, AsyncApp, BackgroundExecutor, BorrowAppContext, Context, Entity,
- EventEmitter, Global, SemanticVersion, Task, WeakEntity,
-};
-use itertools::Itertools;
-use parking_lot::Mutex;
-
-use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
-use rpc::{
- AnyProtoClient, ErrorExt, ProtoClient, ProtoMessageHandlerSet, RpcError,
- proto::{self, Envelope, EnvelopedMessage, PeerId, RequestMessage, build_typed_envelope},
-};
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-use smol::{
- fs,
- process::{self, Child, Stdio},
-};
-use std::{
- collections::VecDeque,
- fmt, iter,
- ops::ControlFlow,
- path::{Path, PathBuf},
- sync::{
- Arc, Weak,
- atomic::{AtomicU32, AtomicU64, Ordering::SeqCst},
- },
- time::{Duration, Instant},
-};
-use tempfile::TempDir;
-use util::{
- ResultExt,
- paths::{PathStyle, RemotePathBuf},
-};
-
-#[derive(Clone)]
-pub struct SshSocket {
- connection_options: SshConnectionOptions,
- #[cfg(not(target_os = "windows"))]
- socket_path: PathBuf,
- #[cfg(target_os = "windows")]
- envs: HashMap<String, String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)]
-pub struct SshPortForwardOption {
- #[serde(skip_serializing_if = "Option::is_none")]
- pub local_host: Option<String>,
- pub local_port: u16,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub remote_host: Option<String>,
- pub remote_port: u16,
-}
-
-#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
-pub struct SshConnectionOptions {
- pub host: String,
- pub username: Option<String>,
- pub port: Option<u16>,
- pub password: Option<String>,
- pub args: Option<Vec<String>>,
- pub port_forwards: Option<Vec<SshPortForwardOption>>,
-
- pub nickname: Option<String>,
- pub upload_binary_over_ssh: bool,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SshArgs {
- pub arguments: Vec<String>,
- pub envs: Option<HashMap<String, String>>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SshInfo {
- pub args: SshArgs,
- pub path_style: PathStyle,
- pub shell: String,
-}
-
-#[macro_export]
-macro_rules! shell_script {
- ($fmt:expr, $($name:ident = $arg:expr),+ $(,)?) => {{
- format!(
- $fmt,
- $(
- $name = shlex::try_quote($arg).unwrap()
- ),+
- )
- }};
-}
-
-fn parse_port_number(port_str: &str) -> Result<u16> {
- port_str
- .parse()
- .with_context(|| format!("parsing port number: {port_str}"))
-}
-
-fn parse_port_forward_spec(spec: &str) -> Result<SshPortForwardOption> {
- let parts: Vec<&str> = spec.split(':').collect();
-
- match parts.len() {
- 4 => {
- let local_port = parse_port_number(parts[1])?;
- let remote_port = parse_port_number(parts[3])?;
-
- Ok(SshPortForwardOption {
- local_host: Some(parts[0].to_string()),
- local_port,
- remote_host: Some(parts[2].to_string()),
- remote_port,
- })
- }
- 3 => {
- let local_port = parse_port_number(parts[0])?;
- let remote_port = parse_port_number(parts[2])?;
-
- Ok(SshPortForwardOption {
- local_host: None,
- local_port,
- remote_host: Some(parts[1].to_string()),
- remote_port,
- })
- }
- _ => anyhow::bail!("Invalid port forward format"),
- }
-}
-
-impl SshConnectionOptions {
- pub fn parse_command_line(input: &str) -> Result<Self> {
- let input = input.trim_start_matches("ssh ");
- let mut hostname: Option<String> = None;
- let mut username: Option<String> = None;
- let mut port: Option<u16> = None;
- let mut args = Vec::new();
- let mut port_forwards: Vec<SshPortForwardOption> = Vec::new();
-
- // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W
- const ALLOWED_OPTS: &[&str] = &[
- "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
- ];
- const ALLOWED_ARGS: &[&str] = &[
- "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R",
- "-w",
- ];
-
- let mut tokens = shlex::split(input).context("invalid input")?.into_iter();
-
- 'outer: while let Some(arg) = tokens.next() {
- if ALLOWED_OPTS.contains(&(&arg as &str)) {
- args.push(arg.to_string());
- continue;
- }
- if arg == "-p" {
- port = tokens.next().and_then(|arg| arg.parse().ok());
- continue;
- } else if let Some(p) = arg.strip_prefix("-p") {
- port = p.parse().ok();
- continue;
- }
- if arg == "-l" {
- username = tokens.next();
- continue;
- } else if let Some(l) = arg.strip_prefix("-l") {
- username = Some(l.to_string());
- continue;
- }
- if arg == "-L" || arg.starts_with("-L") {
- let forward_spec = if arg == "-L" {
- tokens.next()
- } else {
- Some(arg.strip_prefix("-L").unwrap().to_string())
- };
-
- if let Some(spec) = forward_spec {
- port_forwards.push(parse_port_forward_spec(&spec)?);
- } else {
- anyhow::bail!("Missing port forward format");
- }
- }
-
- for a in ALLOWED_ARGS {
- if arg == *a {
- args.push(arg);
- if let Some(next) = tokens.next() {
- args.push(next);
- }
- continue 'outer;
- } else if arg.starts_with(a) {
- args.push(arg);
- continue 'outer;
- }
- }
- if arg.starts_with("-") || hostname.is_some() {
- anyhow::bail!("unsupported argument: {:?}", arg);
- }
- let mut input = &arg as &str;
- // Destination might be: username1@username2@ip2@ip1
- if let Some((u, rest)) = input.rsplit_once('@') {
- input = rest;
- username = Some(u.to_string());
- }
- if let Some((rest, p)) = input.split_once(':') {
- input = rest;
- port = p.parse().ok()
- }
- hostname = Some(input.to_string())
- }
-
- let Some(hostname) = hostname else {
- anyhow::bail!("missing hostname");
- };
-
- let port_forwards = match port_forwards.len() {
- 0 => None,
- _ => Some(port_forwards),
- };
-
- Ok(Self {
- host: hostname,
- username,
- port,
- port_forwards,
- args: Some(args),
- password: None,
- nickname: None,
- upload_binary_over_ssh: false,
- })
- }
-
- pub fn ssh_url(&self) -> String {
- let mut result = String::from("ssh://");
- if let Some(username) = &self.username {
- // Username might be: username1@username2@ip2
- let username = urlencoding::encode(username);
- result.push_str(&username);
- result.push('@');
- }
- result.push_str(&self.host);
- if let Some(port) = self.port {
- result.push(':');
- result.push_str(&port.to_string());
- }
- result
- }
-
- pub fn additional_args(&self) -> Vec<String> {
- let mut args = self.args.iter().flatten().cloned().collect::<Vec<String>>();
-
- if let Some(forwards) = &self.port_forwards {
- args.extend(forwards.iter().map(|pf| {
- let local_host = match &pf.local_host {
- Some(host) => host,
- None => "localhost",
- };
- let remote_host = match &pf.remote_host {
- Some(host) => host,
- None => "localhost",
- };
-
- format!(
- "-L{}:{}:{}:{}",
- local_host, pf.local_port, remote_host, pf.remote_port
- )
- }));
- }
-
- args
- }
-
- fn scp_url(&self) -> String {
- if let Some(username) = &self.username {
- format!("{}@{}", username, self.host)
- } else {
- self.host.clone()
- }
- }
-
- pub fn connection_string(&self) -> String {
- let host = if let Some(username) = &self.username {
- format!("{}@{}", username, self.host)
- } else {
- self.host.clone()
- };
- if let Some(port) = &self.port {
- format!("{}:{}", host, port)
- } else {
- host
- }
- }
-}
-
-#[derive(Copy, Clone, Debug)]
-pub struct SshPlatform {
- pub os: &'static str,
- pub arch: &'static str,
-}
-
-pub trait SshClientDelegate: Send + Sync {
- fn ask_password(&self, prompt: String, tx: oneshot::Sender<String>, cx: &mut AsyncApp);
- fn get_download_params(
- &self,
- platform: SshPlatform,
- release_channel: ReleaseChannel,
- version: Option<SemanticVersion>,
- cx: &mut AsyncApp,
- ) -> Task<Result<Option<(String, String)>>>;
-
- fn download_server_binary_locally(
- &self,
- platform: SshPlatform,
- release_channel: ReleaseChannel,
- version: Option<SemanticVersion>,
- cx: &mut AsyncApp,
- ) -> Task<Result<PathBuf>>;
- fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp);
-}
-
-impl SshSocket {
- #[cfg(not(target_os = "windows"))]
- fn new(options: SshConnectionOptions, socket_path: PathBuf) -> Result<Self> {
- Ok(Self {
- connection_options: options,
- socket_path,
- })
- }
-
- #[cfg(target_os = "windows")]
- fn new(options: SshConnectionOptions, temp_dir: &TempDir, secret: String) -> Result<Self> {
- let askpass_script = temp_dir.path().join("askpass.bat");
- std::fs::write(&askpass_script, "@ECHO OFF\necho %ZED_SSH_ASKPASS%")?;
- let mut envs = HashMap::default();
- envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into());
- envs.insert("SSH_ASKPASS".into(), askpass_script.display().to_string());
- envs.insert("ZED_SSH_ASKPASS".into(), secret);
- Ok(Self {
- connection_options: options,
- envs,
- })
- }
-
- // :WARNING: ssh unquotes arguments when executing on the remote :WARNING:
- // e.g. $ ssh host sh -c 'ls -l' is equivalent to $ ssh host sh -c ls -l
- // and passes -l as an argument to sh, not to ls.
- // Furthermore, some setups (e.g. Coder) will change directory when SSH'ing
- // into a machine. You must use `cd` to get back to $HOME.
- // You need to do it like this: $ ssh host "cd; sh -c 'ls -l /tmp'"
- fn ssh_command(&self, program: &str, args: &[&str]) -> process::Command {
- let mut command = util::command::new_smol_command("ssh");
- let to_run = iter::once(&program)
- .chain(args.iter())
- .map(|token| {
- // We're trying to work with: sh, bash, zsh, fish, tcsh, ...?
- debug_assert!(
- !token.contains('\n'),
- "multiline arguments do not work in all shells"
- );
- shlex::try_quote(token).unwrap()
- })
- .join(" ");
- let to_run = format!("cd; {to_run}");
- log::debug!("ssh {} {:?}", self.connection_options.ssh_url(), to_run);
- self.ssh_options(&mut command)
- .arg(self.connection_options.ssh_url())
- .arg(to_run);
- command
- }
-
- async fn run_command(&self, program: &str, args: &[&str]) -> Result<String> {
- let output = self.ssh_command(program, args).output().await?;
- anyhow::ensure!(
- output.status.success(),
- "failed to run command: {}",
- String::from_utf8_lossy(&output.stderr)
- );
- Ok(String::from_utf8_lossy(&output.stdout).to_string())
- }
-
- #[cfg(not(target_os = "windows"))]
- fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command {
- command
- .stdin(Stdio::piped())
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .args(self.connection_options.additional_args())
- .args(["-o", "ControlMaster=no", "-o"])
- .arg(format!("ControlPath={}", self.socket_path.display()))
- }
-
- #[cfg(target_os = "windows")]
- fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command {
- command
- .stdin(Stdio::piped())
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .args(self.connection_options.additional_args())
- .envs(self.envs.clone())
- }
-
- // On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh.
- // On Linux, we use the `ControlPath` option to create a socket file that ssh can use to
- #[cfg(not(target_os = "windows"))]
- fn ssh_args(&self) -> SshArgs {
- let mut arguments = self.connection_options.additional_args();
- arguments.extend(vec![
- "-o".to_string(),
- "ControlMaster=no".to_string(),
- "-o".to_string(),
- format!("ControlPath={}", self.socket_path.display()),
- self.connection_options.ssh_url(),
- ]);
- SshArgs {
- arguments,
- envs: None,
- }
- }
-
- #[cfg(target_os = "windows")]
- fn ssh_args(&self) -> SshArgs {
- let mut arguments = self.connection_options.additional_args();
- arguments.push(self.connection_options.ssh_url());
- SshArgs {
- arguments,
- envs: Some(self.envs.clone()),
- }
- }
-
- async fn platform(&self) -> Result<SshPlatform> {
- let uname = self.run_command("sh", &["-c", "uname -sm"]).await?;
- let Some((os, arch)) = uname.split_once(" ") else {
- anyhow::bail!("unknown uname: {uname:?}")
- };
-
- let os = match os.trim() {
- "Darwin" => "macos",
- "Linux" => "linux",
- _ => anyhow::bail!(
- "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
- ),
- };
- // exclude armv5,6,7 as they are 32-bit.
- let arch = if arch.starts_with("armv8")
- || arch.starts_with("armv9")
- || arch.starts_with("arm64")
- || arch.starts_with("aarch64")
- {
- "aarch64"
- } else if arch.starts_with("x86") {
- "x86_64"
- } else {
- anyhow::bail!(
- "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
- )
- };
-
- Ok(SshPlatform { os, arch })
- }
-
- async fn shell(&self) -> String {
- match self.run_command("sh", &["-c", "echo $SHELL"]).await {
- Ok(shell) => shell.trim().to_owned(),
- Err(e) => {
- log::error!("Failed to get shell: {e}");
- "sh".to_owned()
- }
- }
- }
-}
-
-const MAX_MISSED_HEARTBEATS: usize = 5;
-const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
-const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(5);
-
-const MAX_RECONNECT_ATTEMPTS: usize = 3;
-
-enum State {
- Connecting,
- Connected {
- ssh_connection: Arc<dyn RemoteConnection>,
- delegate: Arc<dyn SshClientDelegate>,
-
- multiplex_task: Task<Result<()>>,
- heartbeat_task: Task<Result<()>>,
- },
- HeartbeatMissed {
- missed_heartbeats: usize,
-
- ssh_connection: Arc<dyn RemoteConnection>,
- delegate: Arc<dyn SshClientDelegate>,
-
- multiplex_task: Task<Result<()>>,
- heartbeat_task: Task<Result<()>>,
- },
- Reconnecting,
- ReconnectFailed {
- ssh_connection: Arc<dyn RemoteConnection>,
- delegate: Arc<dyn SshClientDelegate>,
-
- error: anyhow::Error,
- attempts: usize,
- },
- ReconnectExhausted,
- ServerNotRunning,
-}
-
-impl fmt::Display for State {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::Connecting => write!(f, "connecting"),
- Self::Connected { .. } => write!(f, "connected"),
- Self::Reconnecting => write!(f, "reconnecting"),
- Self::ReconnectFailed { .. } => write!(f, "reconnect failed"),
- Self::ReconnectExhausted => write!(f, "reconnect exhausted"),
- Self::HeartbeatMissed { .. } => write!(f, "heartbeat missed"),
- Self::ServerNotRunning { .. } => write!(f, "server not running"),
- }
- }
-}
-
-impl State {
- fn ssh_connection(&self) -> Option<&dyn RemoteConnection> {
- match self {
- Self::Connected { ssh_connection, .. } => Some(ssh_connection.as_ref()),
- Self::HeartbeatMissed { ssh_connection, .. } => Some(ssh_connection.as_ref()),
- Self::ReconnectFailed { ssh_connection, .. } => Some(ssh_connection.as_ref()),
- _ => None,
- }
- }
-
- fn can_reconnect(&self) -> bool {
- match self {
- Self::Connected { .. }
- | Self::HeartbeatMissed { .. }
- | Self::ReconnectFailed { .. } => true,
- State::Connecting
- | State::Reconnecting
- | State::ReconnectExhausted
- | State::ServerNotRunning => false,
- }
- }
-
- fn is_reconnect_failed(&self) -> bool {
- matches!(self, Self::ReconnectFailed { .. })
- }
-
- fn is_reconnect_exhausted(&self) -> bool {
- matches!(self, Self::ReconnectExhausted { .. })
- }
-
- fn is_server_not_running(&self) -> bool {
- matches!(self, Self::ServerNotRunning)
- }
-
- fn is_reconnecting(&self) -> bool {
- matches!(self, Self::Reconnecting { .. })
- }
-
- fn heartbeat_recovered(self) -> Self {
- match self {
- Self::HeartbeatMissed {
- ssh_connection,
- delegate,
- multiplex_task,
- heartbeat_task,
- ..
- } => Self::Connected {
- ssh_connection,
- delegate,
- multiplex_task,
- heartbeat_task,
- },
- _ => self,
- }
- }
-
- fn heartbeat_missed(self) -> Self {
- match self {
- Self::Connected {
- ssh_connection,
- delegate,
- multiplex_task,
- heartbeat_task,
- } => Self::HeartbeatMissed {
- missed_heartbeats: 1,
- ssh_connection,
- delegate,
- multiplex_task,
- heartbeat_task,
- },
- Self::HeartbeatMissed {
- missed_heartbeats,
- ssh_connection,
- delegate,
- multiplex_task,
- heartbeat_task,
- } => Self::HeartbeatMissed {
- missed_heartbeats: missed_heartbeats + 1,
- ssh_connection,
- delegate,
- multiplex_task,
- heartbeat_task,
- },
- _ => self,
- }
- }
-}
-
-/// The state of the ssh connection.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum ConnectionState {
- Connecting,
- Connected,
- HeartbeatMissed,
- Reconnecting,
- Disconnected,
-}
-
-impl From<&State> for ConnectionState {
- fn from(value: &State) -> Self {
- match value {
- State::Connecting => Self::Connecting,
- State::Connected { .. } => Self::Connected,
- State::Reconnecting | State::ReconnectFailed { .. } => Self::Reconnecting,
- State::HeartbeatMissed { .. } => Self::HeartbeatMissed,
- State::ReconnectExhausted => Self::Disconnected,
- State::ServerNotRunning => Self::Disconnected,
- }
- }
-}
-
-pub struct SshRemoteClient {
- client: Arc<ChannelClient>,
- unique_identifier: String,
- connection_options: SshConnectionOptions,
- path_style: PathStyle,
- state: Arc<Mutex<Option<State>>>,
-}
-
-#[derive(Debug)]
-pub enum SshRemoteEvent {
- Disconnected,
-}
-
-impl EventEmitter<SshRemoteEvent> for SshRemoteClient {}
-
-// Identifies the socket on the remote server so that reconnects
-// can re-join the same project.
-pub enum ConnectionIdentifier {
- Setup(u64),
- Workspace(i64),
-}
-
-static NEXT_ID: AtomicU64 = AtomicU64::new(1);
-
-impl ConnectionIdentifier {
- pub fn setup() -> Self {
- Self::Setup(NEXT_ID.fetch_add(1, SeqCst))
- }
-
- // This string gets used in a socket name, and so must be relatively short.
- // The total length of:
- // /home/{username}/.local/share/zed/server_state/{name}/stdout.sock
- // Must be less than about 100 characters
- // https://unix.stackexchange.com/questions/367008/why-is-socket-path-length-limited-to-a-hundred-chars
- // So our strings should be at most 20 characters or so.
- fn to_string(&self, cx: &App) -> String {
- let identifier_prefix = match ReleaseChannel::global(cx) {
- ReleaseChannel::Stable => "".to_string(),
- release_channel => format!("{}-", release_channel.dev_name()),
- };
- match self {
- Self::Setup(setup_id) => format!("{identifier_prefix}setup-{setup_id}"),
- Self::Workspace(workspace_id) => {
- format!("{identifier_prefix}workspace-{workspace_id}",)
- }
- }
- }
-}
-
-impl SshRemoteClient {
- pub fn new(
- unique_identifier: ConnectionIdentifier,
- connection_options: SshConnectionOptions,
- cancellation: oneshot::Receiver<()>,
- delegate: Arc<dyn SshClientDelegate>,
- cx: &mut App,
- ) -> Task<Result<Option<Entity<Self>>>> {
- let unique_identifier = unique_identifier.to_string(cx);
- cx.spawn(async move |cx| {
- let success = Box::pin(async move {
- let (outgoing_tx, outgoing_rx) = mpsc::unbounded::<Envelope>();
- let (incoming_tx, incoming_rx) = mpsc::unbounded::<Envelope>();
- let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1);
-
- let client =
- cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "client"))?;
-
- let ssh_connection = cx
- .update(|cx| {
- cx.update_default_global(|pool: &mut ConnectionPool, cx| {
- pool.connect(connection_options.clone(), &delegate, cx)
- })
- })?
- .await
- .map_err(|e| e.cloned())?;
-
- let path_style = ssh_connection.path_style();
- let this = cx.new(|_| Self {
- client: client.clone(),
- unique_identifier: unique_identifier.clone(),
- connection_options,
- path_style,
- state: Arc::new(Mutex::new(Some(State::Connecting))),
- })?;
-
- let io_task = ssh_connection.start_proxy(
- unique_identifier,
- false,
- incoming_tx,
- outgoing_rx,
- connection_activity_tx,
- delegate.clone(),
- cx,
- );
-
- let multiplex_task = Self::monitor(this.downgrade(), io_task, cx);
-
- if let Err(error) = client.ping(HEARTBEAT_TIMEOUT).await {
- log::error!("failed to establish connection: {}", error);
- return Err(error);
- }
-
- let heartbeat_task = Self::heartbeat(this.downgrade(), connection_activity_rx, cx);
-
- this.update(cx, |this, _| {
- *this.state.lock() = Some(State::Connected {
- ssh_connection,
- delegate,
- multiplex_task,
- heartbeat_task,
- });
- })?;
-
- Ok(Some(this))
- });
-
- select! {
- _ = cancellation.fuse() => {
- Ok(None)
- }
- result = success.fuse() => result
- }
- })
- }
-
- pub fn proto_client_from_channels(
- incoming_rx: mpsc::UnboundedReceiver<Envelope>,
- outgoing_tx: mpsc::UnboundedSender<Envelope>,
- cx: &App,
- name: &'static str,
- ) -> AnyProtoClient {
- ChannelClient::new(incoming_rx, outgoing_tx, cx, name).into()
- }
-
- pub fn shutdown_processes<T: RequestMessage>(
- &self,
- shutdown_request: Option<T>,
- executor: BackgroundExecutor,
- ) -> Option<impl Future<Output = ()> + use<T>> {
- let state = self.state.lock().take()?;
- log::info!("shutting down ssh processes");
-
- let State::Connected {
- multiplex_task,
- heartbeat_task,
- ssh_connection,
- delegate,
- } = state
- else {
- return None;
- };
-
- let client = self.client.clone();
-
- Some(async move {
- if let Some(shutdown_request) = shutdown_request {
- client.send(shutdown_request).log_err();
- // We wait 50ms instead of waiting for a response, because
- // waiting for a response would require us to wait on the main thread
- // which we want to avoid in an `on_app_quit` callback.
- executor.timer(Duration::from_millis(50)).await;
- }
-
- // Drop `multiplex_task` because it owns our ssh_proxy_process, which is a
- // child of master_process.
- drop(multiplex_task);
- // Now drop the rest of state, which kills master process.
- drop(heartbeat_task);
- drop(ssh_connection);
- drop(delegate);
- })
- }
-
- fn reconnect(&mut self, cx: &mut Context<Self>) -> Result<()> {
- let mut lock = self.state.lock();
-
- let can_reconnect = lock
- .as_ref()
- .map(|state| state.can_reconnect())
- .unwrap_or(false);
- if !can_reconnect {
- log::info!("aborting reconnect, because not in state that allows reconnecting");
- let error = if let Some(state) = lock.as_ref() {
- format!("invalid state, cannot reconnect while in state {state}")
- } else {
- "no state set".to_string()
- };
- anyhow::bail!(error);
- }
-
- let state = lock.take().unwrap();
- let (attempts, ssh_connection, delegate) = match state {
- State::Connected {
- ssh_connection,
- delegate,
- multiplex_task,
- heartbeat_task,
- }
- | State::HeartbeatMissed {
- ssh_connection,
- delegate,
- multiplex_task,
- heartbeat_task,
- ..
- } => {
- drop(multiplex_task);
- drop(heartbeat_task);
- (0, ssh_connection, delegate)
- }
- State::ReconnectFailed {
- attempts,
- ssh_connection,
- delegate,
- ..
- } => (attempts, ssh_connection, delegate),
- State::Connecting
- | State::Reconnecting
- | State::ReconnectExhausted
- | State::ServerNotRunning => unreachable!(),
- };
-
- let attempts = attempts + 1;
- if attempts > MAX_RECONNECT_ATTEMPTS {
- log::error!(
- "Failed to reconnect to after {} attempts, giving up",
- MAX_RECONNECT_ATTEMPTS
- );
- drop(lock);
- self.set_state(State::ReconnectExhausted, cx);
- return Ok(());
- }
- drop(lock);
-
- self.set_state(State::Reconnecting, cx);
-
- log::info!("Trying to reconnect to ssh server... Attempt {}", attempts);
-
- let unique_identifier = self.unique_identifier.clone();
- let client = self.client.clone();
- let reconnect_task = cx.spawn(async move |this, cx| {
- macro_rules! failed {
- ($error:expr, $attempts:expr, $ssh_connection:expr, $delegate:expr) => {
- return State::ReconnectFailed {
- error: anyhow!($error),
- attempts: $attempts,
- ssh_connection: $ssh_connection,
- delegate: $delegate,
- };
- };
- }
-
- if let Err(error) = ssh_connection
- .kill()
- .await
- .context("Failed to kill ssh process")
- {
- failed!(error, attempts, ssh_connection, delegate);
- };
-
- let connection_options = ssh_connection.connection_options();
-
- let (outgoing_tx, outgoing_rx) = mpsc::unbounded::<Envelope>();
- let (incoming_tx, incoming_rx) = mpsc::unbounded::<Envelope>();
- let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1);
-
- let (ssh_connection, io_task) = match async {
- let ssh_connection = cx
- .update_global(|pool: &mut ConnectionPool, cx| {
- pool.connect(connection_options, &delegate, cx)
- })?
- .await
- .map_err(|error| error.cloned())?;
-
- let io_task = ssh_connection.start_proxy(
- unique_identifier,
- true,
- incoming_tx,
- outgoing_rx,
- connection_activity_tx,
- delegate.clone(),
- cx,
- );
- anyhow::Ok((ssh_connection, io_task))
- }
- .await
- {
- Ok((ssh_connection, io_task)) => (ssh_connection, io_task),
- Err(error) => {
- failed!(error, attempts, ssh_connection, delegate);
- }
- };
-
- let multiplex_task = Self::monitor(this.clone(), io_task, cx);
- client.reconnect(incoming_rx, outgoing_tx, cx);
-
- if let Err(error) = client.resync(HEARTBEAT_TIMEOUT).await {
- failed!(error, attempts, ssh_connection, delegate);
- };
-
- State::Connected {
- ssh_connection,
- delegate,
- multiplex_task,
- heartbeat_task: Self::heartbeat(this.clone(), connection_activity_rx, cx),
- }
- });
-
- cx.spawn(async move |this, cx| {
- let new_state = reconnect_task.await;
- this.update(cx, |this, cx| {
- this.try_set_state(cx, |old_state| {
- if old_state.is_reconnecting() {
- match &new_state {
- State::Connecting
- | State::Reconnecting
- | State::HeartbeatMissed { .. }
- | State::ServerNotRunning => {}
- State::Connected { .. } => {
- log::info!("Successfully reconnected");
- }
- State::ReconnectFailed {
- error, attempts, ..
- } => {
- log::error!(
- "Reconnect attempt {} failed: {:?}. Starting new attempt...",
- attempts,
- error
- );
- }
- State::ReconnectExhausted => {
- log::error!("Reconnect attempt failed and all attempts exhausted");
- }
- }
- Some(new_state)
- } else {
- None
- }
- });
-
- if this.state_is(State::is_reconnect_failed) {
- this.reconnect(cx)
- } else if this.state_is(State::is_reconnect_exhausted) {
- Ok(())
- } else {
- log::debug!("State has transition from Reconnecting into new state while attempting reconnect.");
- Ok(())
- }
- })
- })
- .detach_and_log_err(cx);
-
- Ok(())
- }
-
- fn heartbeat(
- this: WeakEntity<Self>,
- mut connection_activity_rx: mpsc::Receiver<()>,
- cx: &mut AsyncApp,
- ) -> Task<Result<()>> {
- let Ok(client) = this.read_with(cx, |this, _| this.client.clone()) else {
- return Task::ready(Err(anyhow!("SshRemoteClient lost")));
- };
-
- cx.spawn(async move |cx| {
- let mut missed_heartbeats = 0;
-
- let keepalive_timer = cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse();
- futures::pin_mut!(keepalive_timer);
-
- loop {
- select_biased! {
- result = connection_activity_rx.next().fuse() => {
- if result.is_none() {
- log::warn!("ssh heartbeat: connection activity channel has been dropped. stopping.");
- return Ok(());
- }
-
- if missed_heartbeats != 0 {
- missed_heartbeats = 0;
- let _ =this.update(cx, |this, cx| {
- this.handle_heartbeat_result(missed_heartbeats, cx)
- })?;
- }
- }
- _ = keepalive_timer => {
- log::debug!("Sending heartbeat to server...");
-
- let result = select_biased! {
- _ = connection_activity_rx.next().fuse() => {
- Ok(())
- }
- ping_result = client.ping(HEARTBEAT_TIMEOUT).fuse() => {
- ping_result
- }
- };
-
- if result.is_err() {
- missed_heartbeats += 1;
- log::warn!(
- "No heartbeat from server after {:?}. Missed heartbeat {} out of {}.",
- HEARTBEAT_TIMEOUT,
- missed_heartbeats,
- MAX_MISSED_HEARTBEATS
- );
- } else if missed_heartbeats != 0 {
- missed_heartbeats = 0;
- } else {
- continue;
- }
-
- let result = this.update(cx, |this, cx| {
- this.handle_heartbeat_result(missed_heartbeats, cx)
- })?;
- if result.is_break() {
- return Ok(());
- }
- }
- }
-
- keepalive_timer.set(cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse());
- }
- })
- }
-
- fn handle_heartbeat_result(
- &mut self,
- missed_heartbeats: usize,
- cx: &mut Context<Self>,
- ) -> ControlFlow<()> {
- let state = self.state.lock().take().unwrap();
- let next_state = if missed_heartbeats > 0 {
- state.heartbeat_missed()
- } else {
- state.heartbeat_recovered()
- };
-
- self.set_state(next_state, cx);
-
- if missed_heartbeats >= MAX_MISSED_HEARTBEATS {
- log::error!(
- "Missed last {} heartbeats. Reconnecting...",
- missed_heartbeats
- );
-
- self.reconnect(cx)
- .context("failed to start reconnect process after missing heartbeats")
- .log_err();
- ControlFlow::Break(())
- } else {
- ControlFlow::Continue(())
- }
- }
-
- fn monitor(
- this: WeakEntity<Self>,
- io_task: Task<Result<i32>>,
- cx: &AsyncApp,
- ) -> Task<Result<()>> {
- cx.spawn(async move |cx| {
- let result = io_task.await;
-
- match result {
- Ok(exit_code) => {
- if let Some(error) = ProxyLaunchError::from_exit_code(exit_code) {
- match error {
- ProxyLaunchError::ServerNotRunning => {
- log::error!("failed to reconnect because server is not running");
- this.update(cx, |this, cx| {
- this.set_state(State::ServerNotRunning, cx);
- })?;
- }
- }
- } else if exit_code > 0 {
- log::error!("proxy process terminated unexpectedly");
- this.update(cx, |this, cx| {
- this.reconnect(cx).ok();
- })?;
- }
- }
- Err(error) => {
- log::warn!("ssh io task died with error: {:?}. reconnecting...", error);
- this.update(cx, |this, cx| {
- this.reconnect(cx).ok();
- })?;
- }
- }
-
- Ok(())
- })
- }
-
- fn state_is(&self, check: impl FnOnce(&State) -> bool) -> bool {
- self.state.lock().as_ref().is_some_and(check)
- }
-
- fn try_set_state(&self, cx: &mut Context<Self>, map: impl FnOnce(&State) -> Option<State>) {
- let mut lock = self.state.lock();
- let new_state = lock.as_ref().and_then(map);
-
- if let Some(new_state) = new_state {
- lock.replace(new_state);
- cx.notify();
- }
- }
-
- fn set_state(&self, state: State, cx: &mut Context<Self>) {
- log::info!("setting state to '{}'", &state);
-
- let is_reconnect_exhausted = state.is_reconnect_exhausted();
- let is_server_not_running = state.is_server_not_running();
- self.state.lock().replace(state);
-
- if is_reconnect_exhausted || is_server_not_running {
- cx.emit(SshRemoteEvent::Disconnected);
- }
- cx.notify();
- }
-
- pub fn ssh_info(&self) -> Option<SshInfo> {
- self.state
- .lock()
- .as_ref()
- .and_then(|state| state.ssh_connection())
- .map(|ssh_connection| SshInfo {
- args: ssh_connection.ssh_args(),
- path_style: ssh_connection.path_style(),
- shell: ssh_connection.shell(),
- })
- }
-
- pub fn upload_directory(
- &self,
- src_path: PathBuf,
- dest_path: RemotePathBuf,
- cx: &App,
- ) -> Task<Result<()>> {
- let state = self.state.lock();
- let Some(connection) = state.as_ref().and_then(|state| state.ssh_connection()) else {
- return Task::ready(Err(anyhow!("no ssh connection")));
- };
- connection.upload_directory(src_path, dest_path, cx)
- }
-
- pub fn proto_client(&self) -> AnyProtoClient {
- self.client.clone().into()
- }
-
- pub fn connection_string(&self) -> String {
- self.connection_options.connection_string()
- }
-
- pub fn connection_options(&self) -> SshConnectionOptions {
- self.connection_options.clone()
- }
-
- pub fn connection_state(&self) -> ConnectionState {
- self.state
- .lock()
- .as_ref()
- .map(ConnectionState::from)
- .unwrap_or(ConnectionState::Disconnected)
- }
-
- pub fn is_disconnected(&self) -> bool {
- self.connection_state() == ConnectionState::Disconnected
- }
-
- pub fn path_style(&self) -> PathStyle {
- self.path_style
- }
-
- #[cfg(any(test, feature = "test-support"))]
- pub fn simulate_disconnect(&self, client_cx: &mut App) -> Task<()> {
- let opts = self.connection_options();
- client_cx.spawn(async move |cx| {
- let connection = cx
- .update_global(|c: &mut ConnectionPool, _| {
- if let Some(ConnectionPoolEntry::Connecting(c)) = c.connections.get(&opts) {
- c.clone()
- } else {
- panic!("missing test connection")
- }
- })
- .unwrap()
- .await
- .unwrap();
-
- connection.simulate_disconnect(cx);
- })
- }
-
- #[cfg(any(test, feature = "test-support"))]
- pub fn fake_server(
- client_cx: &mut gpui::TestAppContext,
- server_cx: &mut gpui::TestAppContext,
- ) -> (SshConnectionOptions, AnyProtoClient) {
- let port = client_cx
- .update(|cx| cx.default_global::<ConnectionPool>().connections.len() as u16 + 1);
- let opts = SshConnectionOptions {
- host: "<fake>".to_string(),
- port: Some(port),
- ..Default::default()
- };
- let (outgoing_tx, _) = mpsc::unbounded::<Envelope>();
- let (_, incoming_rx) = mpsc::unbounded::<Envelope>();
- let server_client =
- server_cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "fake-server"));
- let connection: Arc<dyn RemoteConnection> = Arc::new(fake::FakeRemoteConnection {
- connection_options: opts.clone(),
- server_cx: fake::SendableCx::new(server_cx),
- server_channel: server_client.clone(),
- });
-
- client_cx.update(|cx| {
- cx.update_default_global(|c: &mut ConnectionPool, cx| {
- c.connections.insert(
- opts.clone(),
- ConnectionPoolEntry::Connecting(
- cx.background_spawn({
- let connection = connection.clone();
- async move { Ok(connection.clone()) }
- })
- .shared(),
- ),
- );
- })
- });
-
- (opts, server_client.into())
- }
-
- #[cfg(any(test, feature = "test-support"))]
- pub async fn fake_client(
- opts: SshConnectionOptions,
- client_cx: &mut gpui::TestAppContext,
- ) -> Entity<Self> {
- let (_tx, rx) = oneshot::channel();
- client_cx
- .update(|cx| {
- Self::new(
- ConnectionIdentifier::setup(),
- opts,
- rx,
- Arc::new(fake::Delegate),
- cx,
- )
- })
- .await
- .unwrap()
- .unwrap()
- }
-}
-
-enum ConnectionPoolEntry {
- Connecting(Shared<Task<Result<Arc<dyn RemoteConnection>, Arc<anyhow::Error>>>>),
- Connected(Weak<dyn RemoteConnection>),
-}
-
-#[derive(Default)]
-struct ConnectionPool {
- connections: HashMap<SshConnectionOptions, ConnectionPoolEntry>,
-}
-
-impl Global for ConnectionPool {}
-
-impl ConnectionPool {
- pub fn connect(
- &mut self,
- opts: SshConnectionOptions,
- delegate: &Arc<dyn SshClientDelegate>,
- cx: &mut App,
- ) -> Shared<Task<Result<Arc<dyn RemoteConnection>, Arc<anyhow::Error>>>> {
- let connection = self.connections.get(&opts);
- match connection {
- Some(ConnectionPoolEntry::Connecting(task)) => {
- let delegate = delegate.clone();
- cx.spawn(async move |cx| {
- delegate.set_status(Some("Waiting for existing connection attempt"), cx);
- })
- .detach();
- return task.clone();
- }
- Some(ConnectionPoolEntry::Connected(ssh)) => {
- if let Some(ssh) = ssh.upgrade()
- && !ssh.has_been_killed()
- {
- return Task::ready(Ok(ssh)).shared();
- }
- self.connections.remove(&opts);
- }
- None => {}
- }
-
- let task = cx
- .spawn({
- let opts = opts.clone();
- let delegate = delegate.clone();
- async move |cx| {
- let connection = SshRemoteConnection::new(opts.clone(), delegate, cx)
- .await
- .map(|connection| Arc::new(connection) as Arc<dyn RemoteConnection>);
-
- cx.update_global(|pool: &mut Self, _| {
- debug_assert!(matches!(
- pool.connections.get(&opts),
- Some(ConnectionPoolEntry::Connecting(_))
- ));
- match connection {
- Ok(connection) => {
- pool.connections.insert(
- opts.clone(),
- ConnectionPoolEntry::Connected(Arc::downgrade(&connection)),
- );
- Ok(connection)
- }
- Err(error) => {
- pool.connections.remove(&opts);
- Err(Arc::new(error))
- }
- }
- })?
- }
- })
- .shared();
-
- self.connections
- .insert(opts.clone(), ConnectionPoolEntry::Connecting(task.clone()));
- task
- }
-}
-
-impl From<SshRemoteClient> for AnyProtoClient {
- fn from(client: SshRemoteClient) -> Self {
- AnyProtoClient::new(client.client)
- }
-}
-
-#[async_trait(?Send)]
-trait RemoteConnection: Send + Sync {
- fn start_proxy(
- &self,
- unique_identifier: String,
- reconnect: bool,
- incoming_tx: UnboundedSender<Envelope>,
- outgoing_rx: UnboundedReceiver<Envelope>,
- connection_activity_tx: Sender<()>,
- delegate: Arc<dyn SshClientDelegate>,
- cx: &mut AsyncApp,
- ) -> Task<Result<i32>>;
- fn upload_directory(
- &self,
- src_path: PathBuf,
- dest_path: RemotePathBuf,
- cx: &App,
- ) -> Task<Result<()>>;
- async fn kill(&self) -> Result<()>;
- fn has_been_killed(&self) -> bool;
- /// On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh.
- /// On Linux, we use the `ControlPath` option to create a socket file that ssh can use to
- fn ssh_args(&self) -> SshArgs;
- fn connection_options(&self) -> SshConnectionOptions;
- fn path_style(&self) -> PathStyle;
- fn shell(&self) -> String;
-
- #[cfg(any(test, feature = "test-support"))]
- fn simulate_disconnect(&self, _: &AsyncApp) {}
-}
-
-struct SshRemoteConnection {
- socket: SshSocket,
- master_process: Mutex<Option<Child>>,
- remote_binary_path: Option<RemotePathBuf>,
- ssh_platform: SshPlatform,
- ssh_path_style: PathStyle,
- ssh_shell: String,
- _temp_dir: TempDir,
-}
-
-#[async_trait(?Send)]
-impl RemoteConnection for SshRemoteConnection {
- async fn kill(&self) -> Result<()> {
- let Some(mut process) = self.master_process.lock().take() else {
- return Ok(());
- };
- process.kill().ok();
- process.status().await?;
- Ok(())
- }
-
- fn has_been_killed(&self) -> bool {
- self.master_process.lock().is_none()
- }
-
- fn ssh_args(&self) -> SshArgs {
- self.socket.ssh_args()
- }
-
- fn connection_options(&self) -> SshConnectionOptions {
- self.socket.connection_options.clone()
- }
-
- fn shell(&self) -> String {
- self.ssh_shell.clone()
- }
-
- fn upload_directory(
- &self,
- src_path: PathBuf,
- dest_path: RemotePathBuf,
- cx: &App,
- ) -> Task<Result<()>> {
- let mut command = util::command::new_smol_command("scp");
- let output = self
- .socket
- .ssh_options(&mut command)
- .args(
- self.socket
- .connection_options
- .port
- .map(|port| vec!["-P".to_string(), port.to_string()])
- .unwrap_or_default(),
- )
- .arg("-C")
- .arg("-r")
- .arg(&src_path)
- .arg(format!(
- "{}:{}",
- self.socket.connection_options.scp_url(),
- dest_path
- ))
- .output();
-
- cx.background_spawn(async move {
- let output = output.await?;
-
- anyhow::ensure!(
- output.status.success(),
- "failed to upload directory {} -> {}: {}",
- src_path.display(),
- dest_path.to_string(),
- String::from_utf8_lossy(&output.stderr)
- );
-
- Ok(())
- })
- }
-
- fn start_proxy(
- &self,
- unique_identifier: String,
- reconnect: bool,
- incoming_tx: UnboundedSender<Envelope>,
- outgoing_rx: UnboundedReceiver<Envelope>,
- connection_activity_tx: Sender<()>,
- delegate: Arc<dyn SshClientDelegate>,
- cx: &mut AsyncApp,
- ) -> Task<Result<i32>> {
- delegate.set_status(Some("Starting proxy"), cx);
-
- let Some(remote_binary_path) = self.remote_binary_path.clone() else {
- return Task::ready(Err(anyhow!("Remote binary path not set")));
- };
-
- let mut start_proxy_command = shell_script!(
- "exec {binary_path} proxy --identifier {identifier}",
- binary_path = &remote_binary_path.to_string(),
- identifier = &unique_identifier,
- );
-
- for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
- if let Some(value) = std::env::var(env_var).ok() {
- start_proxy_command = format!(
- "{}={} {} ",
- env_var,
- shlex::try_quote(&value).unwrap(),
- start_proxy_command,
- );
- }
- }
-
- if reconnect {
- start_proxy_command.push_str(" --reconnect");
- }
-
- let ssh_proxy_process = match self
- .socket
- .ssh_command("sh", &["-c", &start_proxy_command])
- // IMPORTANT: we kill this process when we drop the task that uses it.
- .kill_on_drop(true)
- .spawn()
- {
- Ok(process) => process,
- Err(error) => {
- return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error)));
- }
- };
-
- Self::multiplex(
- ssh_proxy_process,
- incoming_tx,
- outgoing_rx,
- connection_activity_tx,
- cx,
- )
- }
-
- fn path_style(&self) -> PathStyle {
- self.ssh_path_style
- }
-}
-
-impl SshRemoteConnection {
- async fn new(
- connection_options: SshConnectionOptions,
- delegate: Arc<dyn SshClientDelegate>,
- cx: &mut AsyncApp,
- ) -> Result<Self> {
- use askpass::AskPassResult;
-
- delegate.set_status(Some("Connecting"), cx);
-
- let url = connection_options.ssh_url();
-
- let temp_dir = tempfile::Builder::new()
- .prefix("zed-ssh-session")
- .tempdir()?;
- let askpass_delegate = askpass::AskPassDelegate::new(cx, {
- let delegate = delegate.clone();
- move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx)
- });
-
- let mut askpass =
- askpass::AskPassSession::new(cx.background_executor(), askpass_delegate).await?;
-
- // Start the master SSH process, which does not do anything except for establish
- // the connection and keep it open, allowing other ssh commands to reuse it
- // via a control socket.
- #[cfg(not(target_os = "windows"))]
- let socket_path = temp_dir.path().join("ssh.sock");
-
- let mut master_process = {
- #[cfg(not(target_os = "windows"))]
- let args = [
- "-N",
- "-o",
- "ControlPersist=no",
- "-o",
- "ControlMaster=yes",
- "-o",
- ];
- // On Windows, `ControlMaster` and `ControlPath` are not supported:
- // https://github.com/PowerShell/Win32-OpenSSH/issues/405
- // https://github.com/PowerShell/Win32-OpenSSH/wiki/Project-Scope
- #[cfg(target_os = "windows")]
- let args = ["-N"];
- let mut master_process = util::command::new_smol_command("ssh");
- master_process
- .kill_on_drop(true)
- .stdin(Stdio::null())
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .env("SSH_ASKPASS_REQUIRE", "force")
- .env("SSH_ASKPASS", askpass.script_path())
- .args(connection_options.additional_args())
- .args(args);
- #[cfg(not(target_os = "windows"))]
- master_process.arg(format!("ControlPath={}", socket_path.display()));
- master_process.arg(&url).spawn()?
- };
- // Wait for this ssh process to close its stdout, indicating that authentication
- // has completed.
- let mut stdout = master_process.stdout.take().unwrap();
- let mut output = Vec::new();
-
- let result = select_biased! {
- result = askpass.run().fuse() => {
- match result {
- AskPassResult::CancelledByUser => {
- master_process.kill().ok();
- anyhow::bail!("SSH connection canceled")
- }
- AskPassResult::Timedout => {
- anyhow::bail!("connecting to host timed out")
- }
- }
- }
- _ = stdout.read_to_end(&mut output).fuse() => {
- anyhow::Ok(())
- }
- };
-
- if let Err(e) = result {
- return Err(e.context("Failed to connect to host"));
- }
-
- if master_process.try_status()?.is_some() {
- output.clear();
- let mut stderr = master_process.stderr.take().unwrap();
- stderr.read_to_end(&mut output).await?;
-
- let error_message = format!(
- "failed to connect: {}",
- String::from_utf8_lossy(&output).trim()
- );
- anyhow::bail!(error_message);
- }
-
- #[cfg(not(target_os = "windows"))]
- let socket = SshSocket::new(connection_options, socket_path)?;
- #[cfg(target_os = "windows")]
- let socket = SshSocket::new(connection_options, &temp_dir, askpass.get_password())?;
- drop(askpass);
-
- let ssh_platform = socket.platform().await?;
- let ssh_path_style = match ssh_platform.os {
- "windows" => PathStyle::Windows,
- _ => PathStyle::Posix,
- };
- let ssh_shell = socket.shell().await;
-
- let mut this = Self {
- socket,
- master_process: Mutex::new(Some(master_process)),
- _temp_dir: temp_dir,
- remote_binary_path: None,
- ssh_path_style,
- ssh_platform,
- ssh_shell,
- };
-
- let (release_channel, version, commit) = cx.update(|cx| {
- (
- ReleaseChannel::global(cx),
- AppVersion::global(cx),
- AppCommitSha::try_global(cx),
- )
- })?;
- this.remote_binary_path = Some(
- this.ensure_server_binary(&delegate, release_channel, version, commit, cx)
- .await?,
- );
-
- Ok(this)
- }
-
- fn multiplex(
- mut ssh_proxy_process: Child,
- incoming_tx: UnboundedSender<Envelope>,
- mut outgoing_rx: UnboundedReceiver<Envelope>,
- mut connection_activity_tx: Sender<()>,
- cx: &AsyncApp,
- ) -> Task<Result<i32>> {
- let mut child_stderr = ssh_proxy_process.stderr.take().unwrap();
- let mut child_stdout = ssh_proxy_process.stdout.take().unwrap();
- let mut child_stdin = ssh_proxy_process.stdin.take().unwrap();
-
- let mut stdin_buffer = Vec::new();
- let mut stdout_buffer = Vec::new();
- let mut stderr_buffer = Vec::new();
- let mut stderr_offset = 0;
-
- let stdin_task = cx.background_spawn(async move {
- while let Some(outgoing) = outgoing_rx.next().await {
- write_message(&mut child_stdin, &mut stdin_buffer, outgoing).await?;
- }
- anyhow::Ok(())
- });
-
- let stdout_task = cx.background_spawn({
- let mut connection_activity_tx = connection_activity_tx.clone();
- async move {
- loop {
- stdout_buffer.resize(MESSAGE_LEN_SIZE, 0);
- let len = child_stdout.read(&mut stdout_buffer).await?;
-
- if len == 0 {
- return anyhow::Ok(());
- }
-
- if len < MESSAGE_LEN_SIZE {
- child_stdout.read_exact(&mut stdout_buffer[len..]).await?;
- }
-
- let message_len = message_len_from_buffer(&stdout_buffer);
- let envelope =
- read_message_with_len(&mut child_stdout, &mut stdout_buffer, message_len)
- .await?;
- connection_activity_tx.try_send(()).ok();
- incoming_tx.unbounded_send(envelope).ok();
- }
- }
- });
-
- let stderr_task: Task<anyhow::Result<()>> = cx.background_spawn(async move {
- loop {
- stderr_buffer.resize(stderr_offset + 1024, 0);
-
- let len = child_stderr
- .read(&mut stderr_buffer[stderr_offset..])
- .await?;
- if len == 0 {
- return anyhow::Ok(());
- }
-
- stderr_offset += len;
- let mut start_ix = 0;
- while let Some(ix) = stderr_buffer[start_ix..stderr_offset]
- .iter()
- .position(|b| b == &b'\n')
- {
- let line_ix = start_ix + ix;
- let content = &stderr_buffer[start_ix..line_ix];
- start_ix = line_ix + 1;
- if let Ok(record) = serde_json::from_slice::<LogRecord>(content) {
- record.log(log::logger())
- } else {
- eprintln!("(remote) {}", String::from_utf8_lossy(content));
- }
- }
- stderr_buffer.drain(0..start_ix);
- stderr_offset -= start_ix;
-
- connection_activity_tx.try_send(()).ok();
- }
- });
-
- cx.background_spawn(async move {
- let result = futures::select! {
- result = stdin_task.fuse() => {
- result.context("stdin")
- }
- result = stdout_task.fuse() => {
- result.context("stdout")
- }
- result = stderr_task.fuse() => {
- result.context("stderr")
- }
- };
-
- let status = ssh_proxy_process.status().await?.code().unwrap_or(1);
- match result {
- Ok(_) => Ok(status),
- Err(error) => Err(error),
- }
- })
- }
-
- #[allow(unused)]
- async fn ensure_server_binary(
- &self,
- delegate: &Arc<dyn SshClientDelegate>,
- release_channel: ReleaseChannel,
- version: SemanticVersion,
- commit: Option<AppCommitSha>,
- cx: &mut AsyncApp,
- ) -> Result<RemotePathBuf> {
- let version_str = match release_channel {
- ReleaseChannel::Nightly => {
- let commit = commit.map(|s| s.full()).unwrap_or_default();
- format!("{}-{}", version, commit)
- }
- ReleaseChannel::Dev => "build".to_string(),
- _ => version.to_string(),
- };
- let binary_name = format!(
- "zed-remote-server-{}-{}",
- release_channel.dev_name(),
- version_str
- );
- let dst_path = RemotePathBuf::new(
- paths::remote_server_dir_relative().join(binary_name),
- self.ssh_path_style,
- );
-
- let build_remote_server = std::env::var("ZED_BUILD_REMOTE_SERVER").ok();
- #[cfg(debug_assertions)]
- if let Some(build_remote_server) = build_remote_server {
- let src_path = self.build_local(build_remote_server, delegate, cx).await?;
- let tmp_path = RemotePathBuf::new(
- paths::remote_server_dir_relative().join(format!(
- "download-{}-{}",
- std::process::id(),
- src_path.file_name().unwrap().to_string_lossy()
- )),
- self.ssh_path_style,
- );
- self.upload_local_server_binary(&src_path, &tmp_path, delegate, cx)
- .await?;
- self.extract_server_binary(&dst_path, &tmp_path, delegate, cx)
- .await?;
- return Ok(dst_path);
- }
-
- if self
- .socket
- .run_command(&dst_path.to_string(), &["version"])
- .await
- .is_ok()
- {
- return Ok(dst_path);
- }
-
- let wanted_version = cx.update(|cx| match release_channel {
- ReleaseChannel::Nightly => Ok(None),
- ReleaseChannel::Dev => {
- anyhow::bail!(
- "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})",
- dst_path
- )
- }
- _ => Ok(Some(AppVersion::global(cx))),
- })??;
-
- let tmp_path_gz = RemotePathBuf::new(
- PathBuf::from(format!("{}-download-{}.gz", dst_path, std::process::id())),
- self.ssh_path_style,
- );
- if !self.socket.connection_options.upload_binary_over_ssh
- && let Some((url, body)) = delegate
- .get_download_params(self.ssh_platform, release_channel, wanted_version, cx)
- .await?
- {
- match self
- .download_binary_on_server(&url, &body, &tmp_path_gz, delegate, cx)
- .await
- {
- Ok(_) => {
- self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx)
- .await?;
- return Ok(dst_path);
- }
- Err(e) => {
- log::error!(
- "Failed to download binary on server, attempting to upload server: {}",
- e
- )
- }
- }
- }
-
- let src_path = delegate
- .download_server_binary_locally(self.ssh_platform, release_channel, wanted_version, cx)
- .await?;
- self.upload_local_server_binary(&src_path, &tmp_path_gz, delegate, cx)
- .await?;
- self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx)
- .await?;
- Ok(dst_path)
- }
-
- async fn download_binary_on_server(
- &self,
- url: &str,
- body: &str,
- tmp_path_gz: &RemotePathBuf,
- delegate: &Arc<dyn SshClientDelegate>,
- cx: &mut AsyncApp,
- ) -> Result<()> {
- if let Some(parent) = tmp_path_gz.parent() {
- self.socket
- .run_command(
- "sh",
- &[
- "-c",
- &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()),
- ],
- )
- .await?;
- }
-
- delegate.set_status(Some("Downloading remote development server on host"), cx);
-
- match self
- .socket
- .run_command(
- "curl",
- &[
- "-f",
- "-L",
- "-X",
- "GET",
- "-H",
- "Content-Type: application/json",
- "-d",
- body,
- url,
- "-o",
- &tmp_path_gz.to_string(),
- ],
- )
- .await
- {
- Ok(_) => {}
- Err(e) => {
- if self.socket.run_command("which", &["curl"]).await.is_ok() {
- return Err(e);
- }
-
- match self
- .socket
- .run_command(
- "wget",
- &[
- "--method=GET",
- "--header=Content-Type: application/json",
- "--body-data",
- body,
- url,
- "-O",
- &tmp_path_gz.to_string(),
- ],
- )
- .await
- {
- Ok(_) => {}
- Err(e) => {
- if self.socket.run_command("which", &["wget"]).await.is_ok() {
- return Err(e);
- } else {
- anyhow::bail!("Neither curl nor wget is available");
- }
- }
- }
- }
- }
-
- Ok(())
- }
-
- async fn upload_local_server_binary(
- &self,
- src_path: &Path,
- tmp_path_gz: &RemotePathBuf,
- delegate: &Arc<dyn SshClientDelegate>,
- cx: &mut AsyncApp,
- ) -> Result<()> {
- if let Some(parent) = tmp_path_gz.parent() {
- self.socket
- .run_command(
- "sh",
- &[
- "-c",
- &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()),
- ],
- )
- .await?;
- }
-
- let src_stat = fs::metadata(&src_path).await?;
- let size = src_stat.len();
-
- let t0 = Instant::now();
- delegate.set_status(Some("Uploading remote development server"), cx);
- log::info!(
- "uploading remote development server to {:?} ({}kb)",
- tmp_path_gz,
- size / 1024
- );
- self.upload_file(src_path, tmp_path_gz)
- .await
- .context("failed to upload server binary")?;
- log::info!("uploaded remote development server in {:?}", t0.elapsed());
- Ok(())
- }
-
- async fn extract_server_binary(
- &self,
- dst_path: &RemotePathBuf,
- tmp_path: &RemotePathBuf,
- delegate: &Arc<dyn SshClientDelegate>,
- cx: &mut AsyncApp,
- ) -> Result<()> {
- delegate.set_status(Some("Extracting remote development server"), cx);
- let server_mode = 0o755;
-
- let orig_tmp_path = tmp_path.to_string();
- let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
- shell_script!(
- "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
- server_mode = &format!("{:o}", server_mode),
- dst_path = &dst_path.to_string(),
- )
- } else {
- shell_script!(
- "chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",
- server_mode = &format!("{:o}", server_mode),
- dst_path = &dst_path.to_string()
- )
- };
- self.socket.run_command("sh", &["-c", &script]).await?;
- Ok(())
- }
-
- async fn upload_file(&self, src_path: &Path, dest_path: &RemotePathBuf) -> Result<()> {
- log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
- let mut command = util::command::new_smol_command("scp");
- let output = self
- .socket
- .ssh_options(&mut command)
- .args(
- self.socket
- .connection_options
- .port
- .map(|port| vec!["-P".to_string(), port.to_string()])
- .unwrap_or_default(),
- )
- .arg(src_path)
- .arg(format!(
- "{}:{}",
- self.socket.connection_options.scp_url(),
- dest_path
- ))
- .output()
- .await?;
-
- anyhow::ensure!(
- output.status.success(),
- "failed to upload file {} -> {}: {}",
- src_path.display(),
- dest_path.to_string(),
- String::from_utf8_lossy(&output.stderr)
- );
- Ok(())
- }
-
- #[cfg(debug_assertions)]
- async fn build_local(
- &self,
- build_remote_server: String,
- delegate: &Arc<dyn SshClientDelegate>,
- cx: &mut AsyncApp,
- ) -> Result<PathBuf> {
- use smol::process::{Command, Stdio};
- use std::env::VarError;
-
- async fn run_cmd(command: &mut Command) -> Result<()> {
- let output = command
- .kill_on_drop(true)
- .stderr(Stdio::inherit())
- .output()
- .await?;
- anyhow::ensure!(
- output.status.success(),
- "Failed to run command: {command:?}"
- );
- Ok(())
- }
-
- let use_musl = !build_remote_server.contains("nomusl");
- let triple = format!(
- "{}-{}",
- self.ssh_platform.arch,
- match self.ssh_platform.os {
- "linux" =>
- if use_musl {
- "unknown-linux-musl"
- } else {
- "unknown-linux-gnu"
- },
- "macos" => "apple-darwin",
- _ => anyhow::bail!("can't cross compile for: {:?}", self.ssh_platform),
- }
- );
- let mut rust_flags = match std::env::var("RUSTFLAGS") {
- Ok(val) => val,
- Err(VarError::NotPresent) => String::new(),
- Err(e) => {
- log::error!("Failed to get env var `RUSTFLAGS` value: {e}");
- String::new()
- }
- };
- if self.ssh_platform.os == "linux" && use_musl {
- rust_flags.push_str(" -C target-feature=+crt-static");
- }
- if build_remote_server.contains("mold") {
- rust_flags.push_str(" -C link-arg=-fuse-ld=mold");
- }
-
- if self.ssh_platform.arch == std::env::consts::ARCH
- && self.ssh_platform.os == std::env::consts::OS
- {
- delegate.set_status(Some("Building remote server binary from source"), cx);
- log::info!("building remote server binary from source");
- run_cmd(
- Command::new("cargo")
- .args([
- "build",
- "--package",
- "remote_server",
- "--features",
- "debug-embed",
- "--target-dir",
- "target/remote_server",
- "--target",
- &triple,
- ])
- .env("RUSTFLAGS", &rust_flags),
- )
- .await?;
- } else if build_remote_server.contains("cross") {
- #[cfg(target_os = "windows")]
- use util::paths::SanitizedPath;
-
- delegate.set_status(Some("Installing cross.rs for cross-compilation"), cx);
- log::info!("installing cross");
- run_cmd(Command::new("cargo").args([
- "install",
- "cross",
- "--git",
- "https://github.com/cross-rs/cross",
- ]))
- .await?;
-
- delegate.set_status(
- Some(&format!(
- "Building remote server binary from source for {} with Docker",
- &triple
- )),
- cx,
- );
- log::info!("building remote server binary from source for {}", &triple);
-
- // On Windows, the binding needs to be set to the canonical path
- #[cfg(target_os = "windows")]
- let src =
- SanitizedPath::from(smol::fs::canonicalize("./target").await?).to_glob_string();
- #[cfg(not(target_os = "windows"))]
- let src = "./target";
- run_cmd(
- Command::new("cross")
- .args([
- "build",
- "--package",
- "remote_server",
- "--features",
- "debug-embed",
- "--target-dir",
- "target/remote_server",
- "--target",
- &triple,
- ])
- .env(
- "CROSS_CONTAINER_OPTS",
- format!("--mount type=bind,src={src},dst=/app/target"),
- )
- .env("RUSTFLAGS", &rust_flags),
- )
- .await?;
- } else {
- let which = cx
- .background_spawn(async move { which::which("zig") })
- .await;
-
- if which.is_err() {
- #[cfg(not(target_os = "windows"))]
- {
- anyhow::bail!(
- "zig not found on $PATH, install zig (see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross"
- )
- }
- #[cfg(target_os = "windows")]
- {
- anyhow::bail!(
- "zig not found on $PATH, install zig (use `winget install -e --id zig.zig` or see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross"
- )
- }
- }
-
- delegate.set_status(Some("Adding rustup target for cross-compilation"), cx);
- log::info!("adding rustup target");
- run_cmd(Command::new("rustup").args(["target", "add"]).arg(&triple)).await?;
-
- delegate.set_status(Some("Installing cargo-zigbuild for cross-compilation"), cx);
- log::info!("installing cargo-zigbuild");
- run_cmd(Command::new("cargo").args(["install", "--locked", "cargo-zigbuild"])).await?;
-
- delegate.set_status(
- Some(&format!(
- "Building remote binary from source for {triple} with Zig"
- )),
- cx,
- );
- log::info!("building remote binary from source for {triple} with Zig");
- run_cmd(
- Command::new("cargo")
- .args([
- "zigbuild",
- "--package",
- "remote_server",
- "--features",
- "debug-embed",
- "--target-dir",
- "target/remote_server",
- "--target",
- &triple,
- ])
- .env("RUSTFLAGS", &rust_flags),
- )
- .await?;
- };
- let bin_path = Path::new("target")
- .join("remote_server")
- .join(&triple)
- .join("debug")
- .join("remote_server");
-
- let path = if !build_remote_server.contains("nocompress") {
- delegate.set_status(Some("Compressing binary"), cx);
-
- #[cfg(not(target_os = "windows"))]
- {
- run_cmd(Command::new("gzip").args(["-f", &bin_path.to_string_lossy()])).await?;
- }
- #[cfg(target_os = "windows")]
- {
- // On Windows, we use 7z to compress the binary
- let seven_zip = which::which("7z.exe").context("7z.exe not found on $PATH, install it (e.g. with `winget install -e --id 7zip.7zip`) or, if you don't want this behaviour, set $env:ZED_BUILD_REMOTE_SERVER=\"nocompress\"")?;
- let gz_path = format!("target/remote_server/{}/debug/remote_server.gz", triple);
- if smol::fs::metadata(&gz_path).await.is_ok() {
- smol::fs::remove_file(&gz_path).await?;
- }
- run_cmd(Command::new(seven_zip).args([
- "a",
- "-tgzip",
- &gz_path,
- &bin_path.to_string_lossy(),
- ]))
- .await?;
- }
-
- let mut archive_path = bin_path;
- archive_path.set_extension("gz");
- std::env::current_dir()?.join(archive_path)
- } else {
- bin_path
- };
-
- Ok(path)
- }
-}
-
-type ResponseChannels = Mutex<HashMap<MessageId, oneshot::Sender<(Envelope, oneshot::Sender<()>)>>>;
-
-struct ChannelClient {
- next_message_id: AtomicU32,
- outgoing_tx: Mutex<mpsc::UnboundedSender<Envelope>>,
- buffer: Mutex<VecDeque<Envelope>>,
- response_channels: ResponseChannels,
- message_handlers: Mutex<ProtoMessageHandlerSet>,
- max_received: AtomicU32,
- name: &'static str,
- task: Mutex<Task<Result<()>>>,
-}
-
-impl ChannelClient {
- fn new(
- incoming_rx: mpsc::UnboundedReceiver<Envelope>,
- outgoing_tx: mpsc::UnboundedSender<Envelope>,
- cx: &App,
- name: &'static str,
- ) -> Arc<Self> {
- Arc::new_cyclic(|this| Self {
- outgoing_tx: Mutex::new(outgoing_tx),
- next_message_id: AtomicU32::new(0),
- max_received: AtomicU32::new(0),
- response_channels: ResponseChannels::default(),
- message_handlers: Default::default(),
- buffer: Mutex::new(VecDeque::new()),
- name,
- task: Mutex::new(Self::start_handling_messages(
- this.clone(),
- incoming_rx,
- &cx.to_async(),
- )),
- })
- }
-
- fn start_handling_messages(
- this: Weak<Self>,
- mut incoming_rx: mpsc::UnboundedReceiver<Envelope>,
- cx: &AsyncApp,
- ) -> Task<Result<()>> {
- cx.spawn(async move |cx| {
- let peer_id = PeerId { owner_id: 0, id: 0 };
- while let Some(incoming) = incoming_rx.next().await {
- let Some(this) = this.upgrade() else {
- return anyhow::Ok(());
- };
- if let Some(ack_id) = incoming.ack_id {
- let mut buffer = this.buffer.lock();
- while buffer.front().is_some_and(|msg| msg.id <= ack_id) {
- buffer.pop_front();
- }
- }
- if let Some(proto::envelope::Payload::FlushBufferedMessages(_)) = &incoming.payload
- {
- log::debug!(
- "{}:ssh message received. name:FlushBufferedMessages",
- this.name
- );
- {
- let buffer = this.buffer.lock();
- for envelope in buffer.iter() {
- this.outgoing_tx
- .lock()
- .unbounded_send(envelope.clone())
- .ok();
- }
- }
- let mut envelope = proto::Ack {}.into_envelope(0, Some(incoming.id), None);
- envelope.id = this.next_message_id.fetch_add(1, SeqCst);
- this.outgoing_tx.lock().unbounded_send(envelope).ok();
- continue;
- }
-
- this.max_received.store(incoming.id, SeqCst);
-
- if let Some(request_id) = incoming.responding_to {
- let request_id = MessageId(request_id);
- let sender = this.response_channels.lock().remove(&request_id);
- if let Some(sender) = sender {
- let (tx, rx) = oneshot::channel();
- if incoming.payload.is_some() {
- sender.send((incoming, tx)).ok();
- }
- rx.await.ok();
- }
- } else if let Some(envelope) =
- build_typed_envelope(peer_id, Instant::now(), incoming)
- {
- let type_name = envelope.payload_type_name();
- let message_id = envelope.message_id();
- if let Some(future) = ProtoMessageHandlerSet::handle_message(
- &this.message_handlers,
- envelope,
- this.clone().into(),
- cx.clone(),
- ) {
- log::debug!("{}:ssh message received. name:{type_name}", this.name);
- cx.foreground_executor()
- .spawn(async move {
- match future.await {
- Ok(_) => {
- log::debug!(
- "{}:ssh message handled. name:{type_name}",
- this.name
- );
- }
- Err(error) => {
- log::error!(
- "{}:error handling message. type:{}, error:{}",
- this.name,
- type_name,
- format!("{error:#}").lines().fold(
- String::new(),
- |mut message, line| {
- if !message.is_empty() {
- message.push(' ');
- }
- message.push_str(line);
- message
- }
- )
- );
- }
- }
- })
- .detach()
- } else {
- log::error!("{}:unhandled ssh message name:{type_name}", this.name);
- if let Err(e) = AnyProtoClient::from(this.clone()).send_response(
- message_id,
- anyhow::anyhow!("no handler registered for {type_name}").to_proto(),
- ) {
- log::error!(
- "{}:error sending error response for {type_name}:{e:#}",
- this.name
- );
- }
- }
- }
- }
- anyhow::Ok(())
- })
- }
-
- fn reconnect(
- self: &Arc<Self>,
- incoming_rx: UnboundedReceiver<Envelope>,
- outgoing_tx: UnboundedSender<Envelope>,
- cx: &AsyncApp,
- ) {
- *self.outgoing_tx.lock() = outgoing_tx;
- *self.task.lock() = Self::start_handling_messages(Arc::downgrade(self), incoming_rx, cx);
- }
-
- fn request<T: RequestMessage>(
- &self,
- payload: T,
- ) -> impl 'static + Future<Output = Result<T::Response>> {
- self.request_internal(payload, true)
- }
-
- fn request_internal<T: RequestMessage>(
- &self,
- payload: T,
- use_buffer: bool,
- ) -> impl 'static + Future<Output = Result<T::Response>> {
- log::debug!("ssh request start. name:{}", T::NAME);
- let response =
- self.request_dynamic(payload.into_envelope(0, None, None), T::NAME, use_buffer);
- async move {
- let response = response.await?;
- log::debug!("ssh request finish. name:{}", T::NAME);
- T::Response::from_envelope(response).context("received a response of the wrong type")
- }
- }
-
- async fn resync(&self, timeout: Duration) -> Result<()> {
- smol::future::or(
- async {
- self.request_internal(proto::FlushBufferedMessages {}, false)
- .await?;
-
- for envelope in self.buffer.lock().iter() {
- self.outgoing_tx
- .lock()
- .unbounded_send(envelope.clone())
- .ok();
- }
- Ok(())
- },
- async {
- smol::Timer::after(timeout).await;
- anyhow::bail!("Timed out resyncing remote client")
- },
- )
- .await
- }
-
- async fn ping(&self, timeout: Duration) -> Result<()> {
- smol::future::or(
- async {
- self.request(proto::Ping {}).await?;
- Ok(())
- },
- async {
- smol::Timer::after(timeout).await;
- anyhow::bail!("Timed out pinging remote client")
- },
- )
- .await
- }
-
- pub fn send<T: EnvelopedMessage>(&self, payload: T) -> Result<()> {
- log::debug!("ssh send name:{}", T::NAME);
- self.send_dynamic(payload.into_envelope(0, None, None))
- }
-
- fn request_dynamic(
- &self,
- mut envelope: proto::Envelope,
- type_name: &'static str,
- use_buffer: bool,
- ) -> impl 'static + Future<Output = Result<proto::Envelope>> {
- envelope.id = self.next_message_id.fetch_add(1, SeqCst);
- let (tx, rx) = oneshot::channel();
- let mut response_channels_lock = self.response_channels.lock();
- response_channels_lock.insert(MessageId(envelope.id), tx);
- drop(response_channels_lock);
-
- let result = if use_buffer {
- self.send_buffered(envelope)
- } else {
- self.send_unbuffered(envelope)
- };
- async move {
- if let Err(error) = &result {
- log::error!("failed to send message: {error}");
- anyhow::bail!("failed to send message: {error}");
- }
-
- let response = rx.await.context("connection lost")?.0;
- if let Some(proto::envelope::Payload::Error(error)) = &response.payload {
- return Err(RpcError::from_proto(error, type_name));
- }
- Ok(response)
- }
- }
-
- pub fn send_dynamic(&self, mut envelope: proto::Envelope) -> Result<()> {
- envelope.id = self.next_message_id.fetch_add(1, SeqCst);
- self.send_buffered(envelope)
- }
-
- fn send_buffered(&self, mut envelope: proto::Envelope) -> Result<()> {
- envelope.ack_id = Some(self.max_received.load(SeqCst));
- self.buffer.lock().push_back(envelope.clone());
- // ignore errors on send (happen while we're reconnecting)
- // assume that the global "disconnected" overlay is sufficient.
- self.outgoing_tx.lock().unbounded_send(envelope).ok();
- Ok(())
- }
-
- fn send_unbuffered(&self, mut envelope: proto::Envelope) -> Result<()> {
- envelope.ack_id = Some(self.max_received.load(SeqCst));
- self.outgoing_tx.lock().unbounded_send(envelope).ok();
- Ok(())
- }
-}
-
-impl ProtoClient for ChannelClient {
- fn request(
- &self,
- envelope: proto::Envelope,
- request_type: &'static str,
- ) -> BoxFuture<'static, Result<proto::Envelope>> {
- self.request_dynamic(envelope, request_type, true).boxed()
- }
-
- fn send(&self, envelope: proto::Envelope, _message_type: &'static str) -> Result<()> {
- self.send_dynamic(envelope)
- }
-
- fn send_response(&self, envelope: Envelope, _message_type: &'static str) -> anyhow::Result<()> {
- self.send_dynamic(envelope)
- }
-
- fn message_handler_set(&self) -> &Mutex<ProtoMessageHandlerSet> {
- &self.message_handlers
- }
-
- fn is_via_collab(&self) -> bool {
- false
- }
-}
-
-#[cfg(any(test, feature = "test-support"))]
-mod fake {
- use std::{path::PathBuf, sync::Arc};
-
- use anyhow::Result;
- use async_trait::async_trait;
- use futures::{
- FutureExt, SinkExt, StreamExt,
- channel::{
- mpsc::{self, Sender},
- oneshot,
- },
- select_biased,
- };
- use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task, TestAppContext};
- use release_channel::ReleaseChannel;
- use rpc::proto::Envelope;
- use util::paths::{PathStyle, RemotePathBuf};
-
- use super::{
- ChannelClient, RemoteConnection, SshArgs, SshClientDelegate, SshConnectionOptions,
- SshPlatform,
- };
-
- pub(super) struct FakeRemoteConnection {
- pub(super) connection_options: SshConnectionOptions,
- pub(super) server_channel: Arc<ChannelClient>,
- pub(super) server_cx: SendableCx,
- }
-
- pub(super) struct SendableCx(AsyncApp);
- impl SendableCx {
- // SAFETY: When run in test mode, GPUI is always single threaded.
- pub(super) fn new(cx: &TestAppContext) -> Self {
- Self(cx.to_async())
- }
-
- // SAFETY: Enforce that we're on the main thread by requiring a valid AsyncApp
- fn get(&self, _: &AsyncApp) -> AsyncApp {
- self.0.clone()
- }
- }
-
- // SAFETY: There is no way to access a SendableCx from a different thread, see [`SendableCx::new`] and [`SendableCx::get`]
- unsafe impl Send for SendableCx {}
- unsafe impl Sync for SendableCx {}
-
- #[async_trait(?Send)]
- impl RemoteConnection for FakeRemoteConnection {
- async fn kill(&self) -> Result<()> {
- Ok(())
- }
-
- fn has_been_killed(&self) -> bool {
- false
- }
-
- fn ssh_args(&self) -> SshArgs {
- SshArgs {
- arguments: Vec::new(),
- envs: None,
- }
- }
-
- fn upload_directory(
- &self,
- _src_path: PathBuf,
- _dest_path: RemotePathBuf,
- _cx: &App,
- ) -> Task<Result<()>> {
- unreachable!()
- }
-
- fn connection_options(&self) -> SshConnectionOptions {
- self.connection_options.clone()
- }
-
- fn simulate_disconnect(&self, cx: &AsyncApp) {
- let (outgoing_tx, _) = mpsc::unbounded::<Envelope>();
- let (_, incoming_rx) = mpsc::unbounded::<Envelope>();
- self.server_channel
- .reconnect(incoming_rx, outgoing_tx, &self.server_cx.get(cx));
- }
-
- fn start_proxy(
- &self,
- _unique_identifier: String,
- _reconnect: bool,
- mut client_incoming_tx: mpsc::UnboundedSender<Envelope>,
- mut client_outgoing_rx: mpsc::UnboundedReceiver<Envelope>,
- mut connection_activity_tx: Sender<()>,
- _delegate: Arc<dyn SshClientDelegate>,
- cx: &mut AsyncApp,
- ) -> Task<Result<i32>> {
- let (mut server_incoming_tx, server_incoming_rx) = mpsc::unbounded::<Envelope>();
- let (server_outgoing_tx, mut server_outgoing_rx) = mpsc::unbounded::<Envelope>();
-
- self.server_channel.reconnect(
- server_incoming_rx,
- server_outgoing_tx,
- &self.server_cx.get(cx),
- );
-
- cx.background_spawn(async move {
- loop {
- select_biased! {
- server_to_client = server_outgoing_rx.next().fuse() => {
- let Some(server_to_client) = server_to_client else {
- return Ok(1)
- };
- connection_activity_tx.try_send(()).ok();
- client_incoming_tx.send(server_to_client).await.ok();
- }
- client_to_server = client_outgoing_rx.next().fuse() => {
- let Some(client_to_server) = client_to_server else {
- return Ok(1)
- };
- server_incoming_tx.send(client_to_server).await.ok();
- }
- }
- }
- })
- }
-
- fn path_style(&self) -> PathStyle {
- PathStyle::current()
- }
-
- fn shell(&self) -> String {
- "sh".to_owned()
- }
- }
-
- pub(super) struct Delegate;
-
- impl SshClientDelegate for Delegate {
- fn ask_password(&self, _: String, _: oneshot::Sender<String>, _: &mut AsyncApp) {
- unreachable!()
- }
-
- fn download_server_binary_locally(
- &self,
- _: SshPlatform,
- _: ReleaseChannel,
- _: Option<SemanticVersion>,
- _: &mut AsyncApp,
- ) -> Task<Result<PathBuf>> {
- unreachable!()
- }
-
- fn get_download_params(
- &self,
- _platform: SshPlatform,
- _release_channel: ReleaseChannel,
- _version: Option<SemanticVersion>,
- _cx: &mut AsyncApp,
- ) -> Task<Result<Option<(String, String)>>> {
- unreachable!()
- }
-
- fn set_status(&self, _: Option<&str>, _: &mut AsyncApp) {}
- }
-}
@@ -0,0 +1 @@
+pub mod ssh;
@@ -0,0 +1,1365 @@
+use crate::{
+ RemoteClientDelegate, RemotePlatform,
+ json_log::LogRecord,
+ protocol::{MESSAGE_LEN_SIZE, message_len_from_buffer, read_message_with_len, write_message},
+ remote_client::{CommandTemplate, RemoteConnection},
+};
+use anyhow::{Context as _, Result, anyhow};
+use async_trait::async_trait;
+use collections::HashMap;
+use futures::{
+ AsyncReadExt as _, FutureExt as _, StreamExt as _,
+ channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender},
+ select_biased,
+};
+use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task};
+use itertools::Itertools;
+use parking_lot::Mutex;
+use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
+use rpc::proto::Envelope;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use smol::{
+ fs,
+ process::{self, Child, Stdio},
+};
+use std::{
+ iter,
+ path::{Path, PathBuf},
+ sync::Arc,
+ time::Instant,
+};
+use tempfile::TempDir;
+use util::{
+ get_default_system_shell,
+ paths::{PathStyle, RemotePathBuf},
+};
+
+pub(crate) struct SshRemoteConnection {
+ socket: SshSocket,
+ master_process: Mutex<Option<Child>>,
+ remote_binary_path: Option<RemotePathBuf>,
+ ssh_platform: RemotePlatform,
+ ssh_path_style: PathStyle,
+ ssh_shell: String,
+ _temp_dir: TempDir,
+}
+
+#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
+pub struct SshConnectionOptions {
+ pub host: String,
+ pub username: Option<String>,
+ pub port: Option<u16>,
+ pub password: Option<String>,
+ pub args: Option<Vec<String>>,
+ pub port_forwards: Option<Vec<SshPortForwardOption>>,
+
+ pub nickname: Option<String>,
+ pub upload_binary_over_ssh: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)]
+pub struct SshPortForwardOption {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub local_host: Option<String>,
+ pub local_port: u16,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub remote_host: Option<String>,
+ pub remote_port: u16,
+}
+
+#[derive(Clone)]
+struct SshSocket {
+ connection_options: SshConnectionOptions,
+ #[cfg(not(target_os = "windows"))]
+ socket_path: PathBuf,
+ envs: HashMap<String, String>,
+}
+
+macro_rules! shell_script {
+ ($fmt:expr, $($name:ident = $arg:expr),+ $(,)?) => {{
+ format!(
+ $fmt,
+ $(
+ $name = shlex::try_quote($arg).unwrap()
+ ),+
+ )
+ }};
+}
+
+#[async_trait(?Send)]
+impl RemoteConnection for SshRemoteConnection {
+ async fn kill(&self) -> Result<()> {
+ let Some(mut process) = self.master_process.lock().take() else {
+ return Ok(());
+ };
+ process.kill().ok();
+ process.status().await?;
+ Ok(())
+ }
+
+ fn has_been_killed(&self) -> bool {
+ self.master_process.lock().is_none()
+ }
+
+ fn connection_options(&self) -> SshConnectionOptions {
+ self.socket.connection_options.clone()
+ }
+
+ fn shell(&self) -> String {
+ self.ssh_shell.clone()
+ }
+
+ fn build_command(
+ &self,
+ input_program: Option<String>,
+ input_args: &[String],
+ input_env: &HashMap<String, String>,
+ working_dir: Option<String>,
+ activation_script: Option<String>,
+ port_forward: Option<(u16, String, u16)>,
+ ) -> Result<CommandTemplate> {
+ use std::fmt::Write as _;
+
+ let mut script = String::new();
+ if let Some(working_dir) = working_dir {
+ let working_dir =
+ RemotePathBuf::new(working_dir.into(), self.ssh_path_style).to_string();
+
+ // shlex will wrap the command in single quotes (''), disabling ~ expansion,
+ // replace ith with something that works
+ const TILDE_PREFIX: &'static str = "~/";
+ if working_dir.starts_with(TILDE_PREFIX) {
+ let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/");
+ write!(&mut script, "cd \"$HOME/{working_dir}\"; ").unwrap();
+ } else {
+ write!(&mut script, "cd \"{working_dir}\"; ").unwrap();
+ }
+ } else {
+ write!(&mut script, "cd; ").unwrap();
+ };
+ if let Some(activation_script) = activation_script {
+ write!(&mut script, " {activation_script};").unwrap();
+ }
+
+ for (k, v) in input_env.iter() {
+ if let Some((k, v)) = shlex::try_quote(k).ok().zip(shlex::try_quote(v).ok()) {
+ write!(&mut script, "{}={} ", k, v).unwrap();
+ }
+ }
+
+ let shell = &self.ssh_shell;
+
+ if let Some(input_program) = input_program {
+ let command = shlex::try_quote(&input_program)?;
+ script.push_str(&command);
+ for arg in input_args {
+ let arg = shlex::try_quote(&arg)?;
+ script.push_str(" ");
+ script.push_str(&arg);
+ }
+ } else {
+ write!(&mut script, "exec {shell} -l").unwrap();
+ };
+
+ let sys_shell = get_default_system_shell();
+ let shell_invocation = format!("{sys_shell} -c {}", shlex::try_quote(&script).unwrap());
+
+ let mut args = Vec::new();
+ args.extend(self.socket.ssh_args());
+
+ if let Some((local_port, host, remote_port)) = port_forward {
+ args.push("-L".into());
+ args.push(format!("{local_port}:{host}:{remote_port}"));
+ }
+
+ args.push("-t".into());
+ args.push(shell_invocation);
+ Ok(CommandTemplate {
+ program: "ssh".into(),
+ args,
+ env: self.socket.envs.clone(),
+ })
+ }
+
+ fn upload_directory(
+ &self,
+ src_path: PathBuf,
+ dest_path: RemotePathBuf,
+ cx: &App,
+ ) -> Task<Result<()>> {
+ let mut command = util::command::new_smol_command("scp");
+ let output = self
+ .socket
+ .ssh_options(&mut command)
+ .args(
+ self.socket
+ .connection_options
+ .port
+ .map(|port| vec!["-P".to_string(), port.to_string()])
+ .unwrap_or_default(),
+ )
+ .arg("-C")
+ .arg("-r")
+ .arg(&src_path)
+ .arg(format!(
+ "{}:{}",
+ self.socket.connection_options.scp_url(),
+ dest_path
+ ))
+ .output();
+
+ cx.background_spawn(async move {
+ let output = output.await?;
+
+ anyhow::ensure!(
+ output.status.success(),
+ "failed to upload directory {} -> {}: {}",
+ src_path.display(),
+ dest_path.to_string(),
+ String::from_utf8_lossy(&output.stderr)
+ );
+
+ Ok(())
+ })
+ }
+
+ fn start_proxy(
+ &self,
+ unique_identifier: String,
+ reconnect: bool,
+ incoming_tx: UnboundedSender<Envelope>,
+ outgoing_rx: UnboundedReceiver<Envelope>,
+ connection_activity_tx: Sender<()>,
+ delegate: Arc<dyn RemoteClientDelegate>,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<i32>> {
+ delegate.set_status(Some("Starting proxy"), cx);
+
+ let Some(remote_binary_path) = self.remote_binary_path.clone() else {
+ return Task::ready(Err(anyhow!("Remote binary path not set")));
+ };
+
+ let mut start_proxy_command = shell_script!(
+ "exec {binary_path} proxy --identifier {identifier}",
+ binary_path = &remote_binary_path.to_string(),
+ identifier = &unique_identifier,
+ );
+
+ for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
+ if let Some(value) = std::env::var(env_var).ok() {
+ start_proxy_command = format!(
+ "{}={} {} ",
+ env_var,
+ shlex::try_quote(&value).unwrap(),
+ start_proxy_command,
+ );
+ }
+ }
+
+ if reconnect {
+ start_proxy_command.push_str(" --reconnect");
+ }
+
+ let ssh_proxy_process = match self
+ .socket
+ .ssh_command("sh", &["-lc", &start_proxy_command])
+ // IMPORTANT: we kill this process when we drop the task that uses it.
+ .kill_on_drop(true)
+ .spawn()
+ {
+ Ok(process) => process,
+ Err(error) => {
+ return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error)));
+ }
+ };
+
+ Self::multiplex(
+ ssh_proxy_process,
+ incoming_tx,
+ outgoing_rx,
+ connection_activity_tx,
+ cx,
+ )
+ }
+
+ fn path_style(&self) -> PathStyle {
+ self.ssh_path_style
+ }
+}
+
+impl SshRemoteConnection {
+ pub(crate) async fn new(
+ connection_options: SshConnectionOptions,
+ delegate: Arc<dyn RemoteClientDelegate>,
+ cx: &mut AsyncApp,
+ ) -> Result<Self> {
+ use askpass::AskPassResult;
+
+ delegate.set_status(Some("Connecting"), cx);
+
+ let url = connection_options.ssh_url();
+
+ let temp_dir = tempfile::Builder::new()
+ .prefix("zed-ssh-session")
+ .tempdir()?;
+ let askpass_delegate = askpass::AskPassDelegate::new(cx, {
+ let delegate = delegate.clone();
+ move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx)
+ });
+
+ let mut askpass =
+ askpass::AskPassSession::new(cx.background_executor(), askpass_delegate).await?;
+
+ // Start the master SSH process, which does not do anything except for establish
+ // the connection and keep it open, allowing other ssh commands to reuse it
+ // via a control socket.
+ #[cfg(not(target_os = "windows"))]
+ let socket_path = temp_dir.path().join("ssh.sock");
+
+ let mut master_process = {
+ #[cfg(not(target_os = "windows"))]
+ let args = [
+ "-N",
+ "-o",
+ "ControlPersist=no",
+ "-o",
+ "ControlMaster=yes",
+ "-o",
+ ];
+ // On Windows, `ControlMaster` and `ControlPath` are not supported:
+ // https://github.com/PowerShell/Win32-OpenSSH/issues/405
+ // https://github.com/PowerShell/Win32-OpenSSH/wiki/Project-Scope
+ #[cfg(target_os = "windows")]
+ let args = ["-N"];
+ let mut master_process = util::command::new_smol_command("ssh");
+ master_process
+ .kill_on_drop(true)
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .env("SSH_ASKPASS_REQUIRE", "force")
+ .env("SSH_ASKPASS", askpass.script_path())
+ .args(connection_options.additional_args())
+ .args(args);
+ #[cfg(not(target_os = "windows"))]
+ master_process.arg(format!("ControlPath={}", socket_path.display()));
+ master_process.arg(&url).spawn()?
+ };
+ // Wait for this ssh process to close its stdout, indicating that authentication
+ // has completed.
+ let mut stdout = master_process.stdout.take().unwrap();
+ let mut output = Vec::new();
+
+ let result = select_biased! {
+ result = askpass.run().fuse() => {
+ match result {
+ AskPassResult::CancelledByUser => {
+ master_process.kill().ok();
+ anyhow::bail!("SSH connection canceled")
+ }
+ AskPassResult::Timedout => {
+ anyhow::bail!("connecting to host timed out")
+ }
+ }
+ }
+ _ = stdout.read_to_end(&mut output).fuse() => {
+ anyhow::Ok(())
+ }
+ };
+
+ if let Err(e) = result {
+ return Err(e.context("Failed to connect to host"));
+ }
+
+ if master_process.try_status()?.is_some() {
+ output.clear();
+ let mut stderr = master_process.stderr.take().unwrap();
+ stderr.read_to_end(&mut output).await?;
+
+ let error_message = format!(
+ "failed to connect: {}",
+ String::from_utf8_lossy(&output).trim()
+ );
+ anyhow::bail!(error_message);
+ }
+
+ #[cfg(not(target_os = "windows"))]
+ let socket = SshSocket::new(connection_options, socket_path)?;
+ #[cfg(target_os = "windows")]
+ let socket = SshSocket::new(connection_options, &temp_dir, askpass.get_password())?;
+ drop(askpass);
+
+ let ssh_platform = socket.platform().await?;
+ let ssh_path_style = match ssh_platform.os {
+ "windows" => PathStyle::Windows,
+ _ => PathStyle::Posix,
+ };
+ let ssh_shell = socket.shell().await;
+
+ let mut this = Self {
+ socket,
+ master_process: Mutex::new(Some(master_process)),
+ _temp_dir: temp_dir,
+ remote_binary_path: None,
+ ssh_path_style,
+ ssh_platform,
+ ssh_shell,
+ };
+
+ let (release_channel, version, commit) = cx.update(|cx| {
+ (
+ ReleaseChannel::global(cx),
+ AppVersion::global(cx),
+ AppCommitSha::try_global(cx),
+ )
+ })?;
+ this.remote_binary_path = Some(
+ this.ensure_server_binary(&delegate, release_channel, version, commit, cx)
+ .await?,
+ );
+
+ Ok(this)
+ }
+
+ fn multiplex(
+ mut ssh_proxy_process: Child,
+ incoming_tx: UnboundedSender<Envelope>,
+ mut outgoing_rx: UnboundedReceiver<Envelope>,
+ mut connection_activity_tx: Sender<()>,
+ cx: &AsyncApp,
+ ) -> Task<Result<i32>> {
+ let mut child_stderr = ssh_proxy_process.stderr.take().unwrap();
+ let mut child_stdout = ssh_proxy_process.stdout.take().unwrap();
+ let mut child_stdin = ssh_proxy_process.stdin.take().unwrap();
+
+ let mut stdin_buffer = Vec::new();
+ let mut stdout_buffer = Vec::new();
+ let mut stderr_buffer = Vec::new();
+ let mut stderr_offset = 0;
+
+ let stdin_task = cx.background_spawn(async move {
+ while let Some(outgoing) = outgoing_rx.next().await {
+ write_message(&mut child_stdin, &mut stdin_buffer, outgoing).await?;
+ }
+ anyhow::Ok(())
+ });
+
+ let stdout_task = cx.background_spawn({
+ let mut connection_activity_tx = connection_activity_tx.clone();
+ async move {
+ loop {
+ stdout_buffer.resize(MESSAGE_LEN_SIZE, 0);
+ let len = child_stdout.read(&mut stdout_buffer).await?;
+
+ if len == 0 {
+ return anyhow::Ok(());
+ }
+
+ if len < MESSAGE_LEN_SIZE {
+ child_stdout.read_exact(&mut stdout_buffer[len..]).await?;
+ }
+
+ let message_len = message_len_from_buffer(&stdout_buffer);
+ let envelope =
+ read_message_with_len(&mut child_stdout, &mut stdout_buffer, message_len)
+ .await?;
+ connection_activity_tx.try_send(()).ok();
+ incoming_tx.unbounded_send(envelope).ok();
+ }
+ }
+ });
+
+ let stderr_task: Task<anyhow::Result<()>> = cx.background_spawn(async move {
+ loop {
+ stderr_buffer.resize(stderr_offset + 1024, 0);
+
+ let len = child_stderr
+ .read(&mut stderr_buffer[stderr_offset..])
+ .await?;
+ if len == 0 {
+ return anyhow::Ok(());
+ }
+
+ stderr_offset += len;
+ let mut start_ix = 0;
+ while let Some(ix) = stderr_buffer[start_ix..stderr_offset]
+ .iter()
+ .position(|b| b == &b'\n')
+ {
+ let line_ix = start_ix + ix;
+ let content = &stderr_buffer[start_ix..line_ix];
+ start_ix = line_ix + 1;
+ if let Ok(record) = serde_json::from_slice::<LogRecord>(content) {
+ record.log(log::logger())
+ } else {
+ eprintln!("(remote) {}", String::from_utf8_lossy(content));
+ }
+ }
+ stderr_buffer.drain(0..start_ix);
+ stderr_offset -= start_ix;
+
+ connection_activity_tx.try_send(()).ok();
+ }
+ });
+
+ cx.background_spawn(async move {
+ let result = futures::select! {
+ result = stdin_task.fuse() => {
+ result.context("stdin")
+ }
+ result = stdout_task.fuse() => {
+ result.context("stdout")
+ }
+ result = stderr_task.fuse() => {
+ result.context("stderr")
+ }
+ };
+
+ let status = ssh_proxy_process.status().await?.code().unwrap_or(1);
+ match result {
+ Ok(_) => Ok(status),
+ Err(error) => Err(error),
+ }
+ })
+ }
+
+ #[allow(unused)]
+ async fn ensure_server_binary(
+ &self,
+ delegate: &Arc<dyn RemoteClientDelegate>,
+ release_channel: ReleaseChannel,
+ version: SemanticVersion,
+ commit: Option<AppCommitSha>,
+ cx: &mut AsyncApp,
+ ) -> Result<RemotePathBuf> {
+ let version_str = match release_channel {
+ ReleaseChannel::Nightly => {
+ let commit = commit.map(|s| s.full()).unwrap_or_default();
+ format!("{}-{}", version, commit)
+ }
+ ReleaseChannel::Dev => "build".to_string(),
+ _ => version.to_string(),
+ };
+ let binary_name = format!(
+ "zed-remote-server-{}-{}",
+ release_channel.dev_name(),
+ version_str
+ );
+ let dst_path = RemotePathBuf::new(
+ paths::remote_server_dir_relative().join(binary_name),
+ self.ssh_path_style,
+ );
+
+ let build_remote_server = std::env::var("ZED_BUILD_REMOTE_SERVER").ok();
+ #[cfg(debug_assertions)]
+ if let Some(build_remote_server) = build_remote_server {
+ let src_path = self.build_local(build_remote_server, delegate, cx).await?;
+ let tmp_path = RemotePathBuf::new(
+ paths::remote_server_dir_relative().join(format!(
+ "download-{}-{}",
+ std::process::id(),
+ src_path.file_name().unwrap().to_string_lossy()
+ )),
+ self.ssh_path_style,
+ );
+ self.upload_local_server_binary(&src_path, &tmp_path, delegate, cx)
+ .await?;
+ self.extract_server_binary(&dst_path, &tmp_path, delegate, cx)
+ .await?;
+ return Ok(dst_path);
+ }
+
+ if self
+ .socket
+ .run_command(&dst_path.to_string(), &["version"])
+ .await
+ .is_ok()
+ {
+ return Ok(dst_path);
+ }
+
+ let wanted_version = cx.update(|cx| match release_channel {
+ ReleaseChannel::Nightly => Ok(None),
+ ReleaseChannel::Dev => {
+ anyhow::bail!(
+ "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})",
+ dst_path
+ )
+ }
+ _ => Ok(Some(AppVersion::global(cx))),
+ })??;
+
+ let tmp_path_gz = RemotePathBuf::new(
+ PathBuf::from(format!("{}-download-{}.gz", dst_path, std::process::id())),
+ self.ssh_path_style,
+ );
+ if !self.socket.connection_options.upload_binary_over_ssh
+ && let Some((url, body)) = delegate
+ .get_download_params(self.ssh_platform, release_channel, wanted_version, cx)
+ .await?
+ {
+ match self
+ .download_binary_on_server(&url, &body, &tmp_path_gz, delegate, cx)
+ .await
+ {
+ Ok(_) => {
+ self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx)
+ .await?;
+ return Ok(dst_path);
+ }
+ Err(e) => {
+ log::error!(
+ "Failed to download binary on server, attempting to upload server: {}",
+ e
+ )
+ }
+ }
+ }
+
+ let src_path = delegate
+ .download_server_binary_locally(self.ssh_platform, release_channel, wanted_version, cx)
+ .await?;
+ self.upload_local_server_binary(&src_path, &tmp_path_gz, delegate, cx)
+ .await?;
+ self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx)
+ .await?;
+ Ok(dst_path)
+ }
+
+ async fn download_binary_on_server(
+ &self,
+ url: &str,
+ body: &str,
+ tmp_path_gz: &RemotePathBuf,
+ delegate: &Arc<dyn RemoteClientDelegate>,
+ cx: &mut AsyncApp,
+ ) -> Result<()> {
+ if let Some(parent) = tmp_path_gz.parent() {
+ self.socket
+ .run_command(
+ "sh",
+ &[
+ "-lc",
+ &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()),
+ ],
+ )
+ .await?;
+ }
+
+ delegate.set_status(Some("Downloading remote development server on host"), cx);
+
+ match self
+ .socket
+ .run_command(
+ "curl",
+ &[
+ "-f",
+ "-L",
+ "-X",
+ "GET",
+ "-H",
+ "Content-Type: application/json",
+ "-d",
+ body,
+ url,
+ "-o",
+ &tmp_path_gz.to_string(),
+ ],
+ )
+ .await
+ {
+ Ok(_) => {}
+ Err(e) => {
+ if self.socket.run_command("which", &["curl"]).await.is_ok() {
+ return Err(e);
+ }
+
+ match self
+ .socket
+ .run_command(
+ "wget",
+ &[
+ "--method=GET",
+ "--header=Content-Type: application/json",
+ "--body-data",
+ body,
+ url,
+ "-O",
+ &tmp_path_gz.to_string(),
+ ],
+ )
+ .await
+ {
+ Ok(_) => {}
+ Err(e) => {
+ if self.socket.run_command("which", &["wget"]).await.is_ok() {
+ return Err(e);
+ } else {
+ anyhow::bail!("Neither curl nor wget is available");
+ }
+ }
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ async fn upload_local_server_binary(
+ &self,
+ src_path: &Path,
+ tmp_path_gz: &RemotePathBuf,
+ delegate: &Arc<dyn RemoteClientDelegate>,
+ cx: &mut AsyncApp,
+ ) -> Result<()> {
+ if let Some(parent) = tmp_path_gz.parent() {
+ self.socket
+ .run_command(
+ "sh",
+ &[
+ "-lc",
+ &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()),
+ ],
+ )
+ .await?;
+ }
+
+ let src_stat = fs::metadata(&src_path).await?;
+ let size = src_stat.len();
+
+ let t0 = Instant::now();
+ delegate.set_status(Some("Uploading remote development server"), cx);
+ log::info!(
+ "uploading remote development server to {:?} ({}kb)",
+ tmp_path_gz,
+ size / 1024
+ );
+ self.upload_file(src_path, tmp_path_gz)
+ .await
+ .context("failed to upload server binary")?;
+ log::info!("uploaded remote development server in {:?}", t0.elapsed());
+ Ok(())
+ }
+
+ async fn extract_server_binary(
+ &self,
+ dst_path: &RemotePathBuf,
+ tmp_path: &RemotePathBuf,
+ delegate: &Arc<dyn RemoteClientDelegate>,
+ cx: &mut AsyncApp,
+ ) -> Result<()> {
+ delegate.set_status(Some("Extracting remote development server"), cx);
+ let server_mode = 0o755;
+
+ let orig_tmp_path = tmp_path.to_string();
+ let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
+ shell_script!(
+ "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
+ server_mode = &format!("{:o}", server_mode),
+ dst_path = &dst_path.to_string(),
+ )
+ } else {
+ shell_script!(
+ "chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",
+ server_mode = &format!("{:o}", server_mode),
+ dst_path = &dst_path.to_string()
+ )
+ };
+ self.socket.run_command("sh", &["-lc", &script]).await?;
+ Ok(())
+ }
+
+ async fn upload_file(&self, src_path: &Path, dest_path: &RemotePathBuf) -> Result<()> {
+ log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
+ let mut command = util::command::new_smol_command("scp");
+ let output = self
+ .socket
+ .ssh_options(&mut command)
+ .args(
+ self.socket
+ .connection_options
+ .port
+ .map(|port| vec!["-P".to_string(), port.to_string()])
+ .unwrap_or_default(),
+ )
+ .arg(src_path)
+ .arg(format!(
+ "{}:{}",
+ self.socket.connection_options.scp_url(),
+ dest_path
+ ))
+ .output()
+ .await?;
+
+ anyhow::ensure!(
+ output.status.success(),
+ "failed to upload file {} -> {}: {}",
+ src_path.display(),
+ dest_path.to_string(),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ Ok(())
+ }
+
+ #[cfg(debug_assertions)]
+ async fn build_local(
+ &self,
+ build_remote_server: String,
+ delegate: &Arc<dyn RemoteClientDelegate>,
+ cx: &mut AsyncApp,
+ ) -> Result<PathBuf> {
+ use smol::process::{Command, Stdio};
+ use std::env::VarError;
+
+ async fn run_cmd(command: &mut Command) -> Result<()> {
+ let output = command
+ .kill_on_drop(true)
+ .stderr(Stdio::inherit())
+ .output()
+ .await?;
+ anyhow::ensure!(
+ output.status.success(),
+ "Failed to run command: {command:?}"
+ );
+ Ok(())
+ }
+
+ let use_musl = !build_remote_server.contains("nomusl");
+ let triple = format!(
+ "{}-{}",
+ self.ssh_platform.arch,
+ match self.ssh_platform.os {
+ "linux" =>
+ if use_musl {
+ "unknown-linux-musl"
+ } else {
+ "unknown-linux-gnu"
+ },
+ "macos" => "apple-darwin",
+ _ => anyhow::bail!("can't cross compile for: {:?}", self.ssh_platform),
+ }
+ );
+ let mut rust_flags = match std::env::var("RUSTFLAGS") {
+ Ok(val) => val,
+ Err(VarError::NotPresent) => String::new(),
+ Err(e) => {
+ log::error!("Failed to get env var `RUSTFLAGS` value: {e}");
+ String::new()
+ }
+ };
+ if self.ssh_platform.os == "linux" && use_musl {
+ rust_flags.push_str(" -C target-feature=+crt-static");
+ }
+ if build_remote_server.contains("mold") {
+ rust_flags.push_str(" -C link-arg=-fuse-ld=mold");
+ }
+
+ if self.ssh_platform.arch == std::env::consts::ARCH
+ && self.ssh_platform.os == std::env::consts::OS
+ {
+ delegate.set_status(Some("Building remote server binary from source"), cx);
+ log::info!("building remote server binary from source");
+ run_cmd(
+ Command::new("cargo")
+ .args([
+ "build",
+ "--package",
+ "remote_server",
+ "--features",
+ "debug-embed",
+ "--target-dir",
+ "target/remote_server",
+ "--target",
+ &triple,
+ ])
+ .env("RUSTFLAGS", &rust_flags),
+ )
+ .await?;
+ } else if build_remote_server.contains("cross") {
+ #[cfg(target_os = "windows")]
+ use util::paths::SanitizedPath;
+
+ delegate.set_status(Some("Installing cross.rs for cross-compilation"), cx);
+ log::info!("installing cross");
+ run_cmd(Command::new("cargo").args([
+ "install",
+ "cross",
+ "--git",
+ "https://github.com/cross-rs/cross",
+ ]))
+ .await?;
+
+ delegate.set_status(
+ Some(&format!(
+ "Building remote server binary from source for {} with Docker",
+ &triple
+ )),
+ cx,
+ );
+ log::info!("building remote server binary from source for {}", &triple);
+
+ // On Windows, the binding needs to be set to the canonical path
+ #[cfg(target_os = "windows")]
+ let src =
+ SanitizedPath::new(&smol::fs::canonicalize("./target").await?).to_glob_string();
+ #[cfg(not(target_os = "windows"))]
+ let src = "./target";
+ run_cmd(
+ Command::new("cross")
+ .args([
+ "build",
+ "--package",
+ "remote_server",
+ "--features",
+ "debug-embed",
+ "--target-dir",
+ "target/remote_server",
+ "--target",
+ &triple,
+ ])
+ .env(
+ "CROSS_CONTAINER_OPTS",
+ format!("--mount type=bind,src={src},dst=/app/target"),
+ )
+ .env("RUSTFLAGS", &rust_flags),
+ )
+ .await?;
+ } else {
+ let which = cx
+ .background_spawn(async move { which::which("zig") })
+ .await;
+
+ if which.is_err() {
+ #[cfg(not(target_os = "windows"))]
+ {
+ anyhow::bail!(
+ "zig not found on $PATH, install zig (see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross"
+ )
+ }
+ #[cfg(target_os = "windows")]
+ {
+ anyhow::bail!(
+ "zig not found on $PATH, install zig (use `winget install -e --id zig.zig` or see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross"
+ )
+ }
+ }
+
+ delegate.set_status(Some("Adding rustup target for cross-compilation"), cx);
+ log::info!("adding rustup target");
+ run_cmd(Command::new("rustup").args(["target", "add"]).arg(&triple)).await?;
+
+ delegate.set_status(Some("Installing cargo-zigbuild for cross-compilation"), cx);
+ log::info!("installing cargo-zigbuild");
+ run_cmd(Command::new("cargo").args(["install", "--locked", "cargo-zigbuild"])).await?;
+
+ delegate.set_status(
+ Some(&format!(
+ "Building remote binary from source for {triple} with Zig"
+ )),
+ cx,
+ );
+ log::info!("building remote binary from source for {triple} with Zig");
+ run_cmd(
+ Command::new("cargo")
+ .args([
+ "zigbuild",
+ "--package",
+ "remote_server",
+ "--features",
+ "debug-embed",
+ "--target-dir",
+ "target/remote_server",
+ "--target",
+ &triple,
+ ])
+ .env("RUSTFLAGS", &rust_flags),
+ )
+ .await?;
+ };
+ let bin_path = Path::new("target")
+ .join("remote_server")
+ .join(&triple)
+ .join("debug")
+ .join("remote_server");
+
+ let path = if !build_remote_server.contains("nocompress") {
+ delegate.set_status(Some("Compressing binary"), cx);
+
+ #[cfg(not(target_os = "windows"))]
+ {
+ run_cmd(Command::new("gzip").args(["-f", &bin_path.to_string_lossy()])).await?;
+ }
+ #[cfg(target_os = "windows")]
+ {
+ // On Windows, we use 7z to compress the binary
+ let seven_zip = which::which("7z.exe").context("7z.exe not found on $PATH, install it (e.g. with `winget install -e --id 7zip.7zip`) or, if you don't want this behaviour, set $env:ZED_BUILD_REMOTE_SERVER=\"nocompress\"")?;
+ let gz_path = format!("target/remote_server/{}/debug/remote_server.gz", triple);
+ if smol::fs::metadata(&gz_path).await.is_ok() {
+ smol::fs::remove_file(&gz_path).await?;
+ }
+ run_cmd(Command::new(seven_zip).args([
+ "a",
+ "-tgzip",
+ &gz_path,
+ &bin_path.to_string_lossy(),
+ ]))
+ .await?;
+ }
+
+ let mut archive_path = bin_path;
+ archive_path.set_extension("gz");
+ std::env::current_dir()?.join(archive_path)
+ } else {
+ bin_path
+ };
+
+ Ok(path)
+ }
+}
+
+impl SshSocket {
+ #[cfg(not(target_os = "windows"))]
+ fn new(options: SshConnectionOptions, socket_path: PathBuf) -> Result<Self> {
+ Ok(Self {
+ connection_options: options,
+ envs: HashMap::default(),
+ socket_path,
+ })
+ }
+
+ #[cfg(target_os = "windows")]
+ fn new(options: SshConnectionOptions, temp_dir: &TempDir, secret: String) -> Result<Self> {
+ let askpass_script = temp_dir.path().join("askpass.bat");
+ std::fs::write(&askpass_script, "@ECHO OFF\necho %ZED_SSH_ASKPASS%")?;
+ let mut envs = HashMap::default();
+ envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into());
+ envs.insert("SSH_ASKPASS".into(), askpass_script.display().to_string());
+ envs.insert("ZED_SSH_ASKPASS".into(), secret);
+ Ok(Self {
+ connection_options: options,
+ envs,
+ })
+ }
+
+ // :WARNING: ssh unquotes arguments when executing on the remote :WARNING:
+ // e.g. $ ssh host sh -c 'ls -l' is equivalent to $ ssh host sh -c ls -l
+ // and passes -l as an argument to sh, not to ls.
+ // Furthermore, some setups (e.g. Coder) will change directory when SSH'ing
+ // into a machine. You must use `cd` to get back to $HOME.
+ // You need to do it like this: $ ssh host "cd; sh -c 'ls -l /tmp'"
+ fn ssh_command(&self, program: &str, args: &[&str]) -> process::Command {
+ let mut command = util::command::new_smol_command("ssh");
+ let to_run = iter::once(&program)
+ .chain(args.iter())
+ .map(|token| {
+ // We're trying to work with: sh, bash, zsh, fish, tcsh, ...?
+ debug_assert!(
+ !token.contains('\n'),
+ "multiline arguments do not work in all shells"
+ );
+ shlex::try_quote(token).unwrap()
+ })
+ .join(" ");
+ let to_run = format!("cd; {to_run}");
+ log::debug!("ssh {} {:?}", self.connection_options.ssh_url(), to_run);
+ self.ssh_options(&mut command)
+ .arg(self.connection_options.ssh_url())
+ .arg(to_run);
+ command
+ }
+
+ async fn run_command(&self, program: &str, args: &[&str]) -> Result<String> {
+ let output = self.ssh_command(program, args).output().await?;
+ anyhow::ensure!(
+ output.status.success(),
+ "failed to run command: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ Ok(String::from_utf8_lossy(&output.stdout).to_string())
+ }
+
+ #[cfg(not(target_os = "windows"))]
+ fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command {
+ command
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .args(self.connection_options.additional_args())
+ .args(["-o", "ControlMaster=no", "-o"])
+ .arg(format!("ControlPath={}", self.socket_path.display()))
+ }
+
+ #[cfg(target_os = "windows")]
+ fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command {
+ command
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .args(self.connection_options.additional_args())
+ .envs(self.envs.clone())
+ }
+
+ // On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh.
+ // On Linux, we use the `ControlPath` option to create a socket file that ssh can use to
+ #[cfg(not(target_os = "windows"))]
+ fn ssh_args(&self) -> Vec<String> {
+ let mut arguments = self.connection_options.additional_args();
+ arguments.extend(vec![
+ "-o".to_string(),
+ "ControlMaster=no".to_string(),
+ "-o".to_string(),
+ format!("ControlPath={}", self.socket_path.display()),
+ self.connection_options.ssh_url(),
+ ]);
+ arguments
+ }
+
+ #[cfg(target_os = "windows")]
+ fn ssh_args(&self) -> Vec<String> {
+ let mut arguments = self.connection_options.additional_args();
+ arguments.push(self.connection_options.ssh_url());
+ arguments
+ }
+
+ async fn platform(&self) -> Result<RemotePlatform> {
+ let uname = self.run_command("sh", &["-lc", "uname -sm"]).await?;
+ let Some((os, arch)) = uname.split_once(" ") else {
+ anyhow::bail!("unknown uname: {uname:?}")
+ };
+
+ let os = match os.trim() {
+ "Darwin" => "macos",
+ "Linux" => "linux",
+ _ => anyhow::bail!(
+ "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
+ ),
+ };
+ // exclude armv5,6,7 as they are 32-bit.
+ let arch = if arch.starts_with("armv8")
+ || arch.starts_with("armv9")
+ || arch.starts_with("arm64")
+ || arch.starts_with("aarch64")
+ {
+ "aarch64"
+ } else if arch.starts_with("x86") {
+ "x86_64"
+ } else {
+ anyhow::bail!(
+ "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
+ )
+ };
+
+ Ok(RemotePlatform { os, arch })
+ }
+
+ async fn shell(&self) -> String {
+ match self.run_command("sh", &["-lc", "echo $SHELL"]).await {
+ Ok(shell) => shell.trim().to_owned(),
+ Err(e) => {
+ log::error!("Failed to get shell: {e}");
+ "sh".to_owned()
+ }
+ }
+ }
+}
+
+fn parse_port_number(port_str: &str) -> Result<u16> {
+ port_str
+ .parse()
+ .with_context(|| format!("parsing port number: {port_str}"))
+}
+
+fn parse_port_forward_spec(spec: &str) -> Result<SshPortForwardOption> {
+ let parts: Vec<&str> = spec.split(':').collect();
+
+ match parts.len() {
+ 4 => {
+ let local_port = parse_port_number(parts[1])?;
+ let remote_port = parse_port_number(parts[3])?;
+
+ Ok(SshPortForwardOption {
+ local_host: Some(parts[0].to_string()),
+ local_port,
+ remote_host: Some(parts[2].to_string()),
+ remote_port,
+ })
+ }
+ 3 => {
+ let local_port = parse_port_number(parts[0])?;
+ let remote_port = parse_port_number(parts[2])?;
+
+ Ok(SshPortForwardOption {
+ local_host: None,
+ local_port,
+ remote_host: Some(parts[1].to_string()),
+ remote_port,
+ })
+ }
+ _ => anyhow::bail!("Invalid port forward format"),
+ }
+}
+
+impl SshConnectionOptions {
+ pub fn parse_command_line(input: &str) -> Result<Self> {
+ let input = input.trim_start_matches("ssh ");
+ let mut hostname: Option<String> = None;
+ let mut username: Option<String> = None;
+ let mut port: Option<u16> = None;
+ let mut args = Vec::new();
+ let mut port_forwards: Vec<SshPortForwardOption> = Vec::new();
+
+ // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W
+ const ALLOWED_OPTS: &[&str] = &[
+ "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
+ ];
+ const ALLOWED_ARGS: &[&str] = &[
+ "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R",
+ "-w",
+ ];
+
+ let mut tokens = shlex::split(input).context("invalid input")?.into_iter();
+
+ 'outer: while let Some(arg) = tokens.next() {
+ if ALLOWED_OPTS.contains(&(&arg as &str)) {
+ args.push(arg.to_string());
+ continue;
+ }
+ if arg == "-p" {
+ port = tokens.next().and_then(|arg| arg.parse().ok());
+ continue;
+ } else if let Some(p) = arg.strip_prefix("-p") {
+ port = p.parse().ok();
+ continue;
+ }
+ if arg == "-l" {
+ username = tokens.next();
+ continue;
+ } else if let Some(l) = arg.strip_prefix("-l") {
+ username = Some(l.to_string());
+ continue;
+ }
+ if arg == "-L" || arg.starts_with("-L") {
+ let forward_spec = if arg == "-L" {
+ tokens.next()
+ } else {
+ Some(arg.strip_prefix("-L").unwrap().to_string())
+ };
+
+ if let Some(spec) = forward_spec {
+ port_forwards.push(parse_port_forward_spec(&spec)?);
+ } else {
+ anyhow::bail!("Missing port forward format");
+ }
+ }
+
+ for a in ALLOWED_ARGS {
+ if arg == *a {
+ args.push(arg);
+ if let Some(next) = tokens.next() {
+ args.push(next);
+ }
+ continue 'outer;
+ } else if arg.starts_with(a) {
+ args.push(arg);
+ continue 'outer;
+ }
+ }
+ if arg.starts_with("-") || hostname.is_some() {
+ anyhow::bail!("unsupported argument: {:?}", arg);
+ }
+ let mut input = &arg as &str;
+ // Destination might be: username1@username2@ip2@ip1
+ if let Some((u, rest)) = input.rsplit_once('@') {
+ input = rest;
+ username = Some(u.to_string());
+ }
+ if let Some((rest, p)) = input.split_once(':') {
+ input = rest;
+ port = p.parse().ok()
+ }
+ hostname = Some(input.to_string())
+ }
+
+ let Some(hostname) = hostname else {
+ anyhow::bail!("missing hostname");
+ };
+
+ let port_forwards = match port_forwards.len() {
+ 0 => None,
+ _ => Some(port_forwards),
+ };
+
+ Ok(Self {
+ host: hostname,
+ username,
+ port,
+ port_forwards,
+ args: Some(args),
+ password: None,
+ nickname: None,
+ upload_binary_over_ssh: false,
+ })
+ }
+
+ pub fn ssh_url(&self) -> String {
+ let mut result = String::from("ssh://");
+ if let Some(username) = &self.username {
+ // Username might be: username1@username2@ip2
+ let username = urlencoding::encode(username);
+ result.push_str(&username);
+ result.push('@');
+ }
+ result.push_str(&self.host);
+ if let Some(port) = self.port {
+ result.push(':');
+ result.push_str(&port.to_string());
+ }
+ result
+ }
+
+ pub fn additional_args(&self) -> Vec<String> {
+ let mut args = self.args.iter().flatten().cloned().collect::<Vec<String>>();
+
+ if let Some(forwards) = &self.port_forwards {
+ args.extend(forwards.iter().map(|pf| {
+ let local_host = match &pf.local_host {
+ Some(host) => host,
+ None => "localhost",
+ };
+ let remote_host = match &pf.remote_host {
+ Some(host) => host,
+ None => "localhost",
+ };
+
+ format!(
+ "-L{}:{}:{}:{}",
+ local_host, pf.local_port, remote_host, pf.remote_port
+ )
+ }));
+ }
+
+ args
+ }
+
+ fn scp_url(&self) -> String {
+ if let Some(username) = &self.username {
+ format!("{}@{}", username, self.host)
+ } else {
+ self.host.clone()
+ }
+ }
+
+ pub fn connection_string(&self) -> String {
+ let host = if let Some(username) = &self.username {
+ format!("{}@{}", username, self.host)
+ } else {
+ self.host.clone()
+ };
+ if let Some(port) = &self.port {
+ format!("{}:{}", host, port)
+ } else {
+ host
+ }
+ }
+}
@@ -1,5 +1,6 @@
use ::proto::{FromProto, ToProto};
use anyhow::{Context as _, Result, anyhow};
+use lsp::LanguageServerId;
use extension::ExtensionHostProxy;
use extension_host::headless_host::HeadlessExtensionStore;
@@ -14,6 +15,7 @@ use project::{
buffer_store::{BufferStore, BufferStoreEvent},
debugger::{breakpoint_store::BreakpointStore, dap_store::DapStore},
git_store::GitStore,
+ lsp_store::log_store::{self, GlobalLogStore, LanguageServerKind},
project_settings::SettingsObserver,
search::SearchQuery,
task_store::TaskStore,
@@ -21,7 +23,7 @@ use project::{
};
use rpc::{
AnyProtoClient, TypedEnvelope,
- proto::{self, SSH_PEER_ID, SSH_PROJECT_ID},
+ proto::{self, REMOTE_SERVER_PEER_ID, REMOTE_SERVER_PROJECT_ID},
};
use settings::initial_server_settings_content;
@@ -65,6 +67,7 @@ impl HeadlessProject {
settings::init(cx);
language::init(cx);
project::Project::init_settings(cx);
+ log_store::init(false, cx);
}
pub fn new(
@@ -83,7 +86,7 @@ impl HeadlessProject {
let worktree_store = cx.new(|cx| {
let mut store = WorktreeStore::local(true, fs.clone());
- store.shared(SSH_PROJECT_ID, session.clone(), cx);
+ store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);
store
});
@@ -101,7 +104,7 @@ impl HeadlessProject {
let buffer_store = cx.new(|cx| {
let mut buffer_store = BufferStore::local(worktree_store.clone(), cx);
- buffer_store.shared(SSH_PROJECT_ID, session.clone(), cx);
+ buffer_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);
buffer_store
});
@@ -119,7 +122,7 @@ impl HeadlessProject {
breakpoint_store.clone(),
cx,
);
- dap_store.shared(SSH_PROJECT_ID, session.clone(), cx);
+ dap_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);
dap_store
});
@@ -131,7 +134,7 @@ impl HeadlessProject {
fs.clone(),
cx,
);
- store.shared(SSH_PROJECT_ID, session.clone(), cx);
+ store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);
store
});
@@ -154,7 +157,7 @@ impl HeadlessProject {
environment.clone(),
cx,
);
- task_store.shared(SSH_PROJECT_ID, session.clone(), cx);
+ task_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);
task_store
});
let settings_observer = cx.new(|cx| {
@@ -164,7 +167,7 @@ impl HeadlessProject {
task_store.clone(),
cx,
);
- observer.shared(SSH_PROJECT_ID, session.clone(), cx);
+ observer.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);
observer
});
@@ -185,7 +188,7 @@ impl HeadlessProject {
fs.clone(),
cx,
);
- lsp_store.shared(SSH_PROJECT_ID, session.clone(), cx);
+ lsp_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);
lsp_store
});
@@ -213,15 +216,15 @@ impl HeadlessProject {
);
// local_machine -> ssh handlers
- session.subscribe_to_entity(SSH_PROJECT_ID, &worktree_store);
- session.subscribe_to_entity(SSH_PROJECT_ID, &buffer_store);
- session.subscribe_to_entity(SSH_PROJECT_ID, &cx.entity());
- session.subscribe_to_entity(SSH_PROJECT_ID, &lsp_store);
- session.subscribe_to_entity(SSH_PROJECT_ID, &task_store);
- session.subscribe_to_entity(SSH_PROJECT_ID, &toolchain_store);
- session.subscribe_to_entity(SSH_PROJECT_ID, &dap_store);
- session.subscribe_to_entity(SSH_PROJECT_ID, &settings_observer);
- session.subscribe_to_entity(SSH_PROJECT_ID, &git_store);
+ session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &worktree_store);
+ session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &buffer_store);
+ session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &cx.entity());
+ session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &lsp_store);
+ session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &task_store);
+ session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &toolchain_store);
+ session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &dap_store);
+ session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &settings_observer);
+ session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &git_store);
session.add_request_handler(cx.weak_entity(), Self::handle_list_remote_directory);
session.add_request_handler(cx.weak_entity(), Self::handle_get_path_metadata);
@@ -235,6 +238,7 @@ impl HeadlessProject {
session.add_entity_request_handler(Self::handle_open_new_buffer);
session.add_entity_request_handler(Self::handle_find_search_candidates);
session.add_entity_request_handler(Self::handle_open_server_settings);
+ session.add_entity_message_handler(Self::handle_toggle_lsp_logs);
session.add_entity_request_handler(BufferStore::handle_update_buffer);
session.add_entity_message_handler(BufferStore::handle_close_buffer);
@@ -288,7 +292,7 @@ impl HeadlessProject {
} = event
{
cx.background_spawn(self.session.request(proto::UpdateBuffer {
- project_id: SSH_PROJECT_ID,
+ project_id: REMOTE_SERVER_PROJECT_ID,
buffer_id: buffer.read(cx).remote_id().to_proto(),
operations: vec![serialize_operation(operation)],
}))
@@ -298,11 +302,40 @@ impl HeadlessProject {
fn on_lsp_store_event(
&mut self,
- _lsp_store: Entity<LspStore>,
+ lsp_store: Entity<LspStore>,
event: &LspStoreEvent,
cx: &mut Context<Self>,
) {
match event {
+ LspStoreEvent::LanguageServerAdded(id, name, worktree_id) => {
+ let log_store = cx
+ .try_global::<GlobalLogStore>()
+ .map(|lsp_logs| lsp_logs.0.clone());
+ if let Some(log_store) = log_store {
+ log_store.update(cx, |log_store, cx| {
+ log_store.add_language_server(
+ LanguageServerKind::LocalSsh {
+ lsp_store: self.lsp_store.downgrade(),
+ },
+ *id,
+ Some(name.clone()),
+ *worktree_id,
+ lsp_store.read(cx).language_server_for_id(*id),
+ cx,
+ );
+ });
+ }
+ }
+ LspStoreEvent::LanguageServerRemoved(id) => {
+ let log_store = cx
+ .try_global::<GlobalLogStore>()
+ .map(|lsp_logs| lsp_logs.0.clone());
+ if let Some(log_store) = log_store {
+ log_store.update(cx, |log_store, cx| {
+ log_store.remove_language_server(*id, cx);
+ });
+ }
+ }
LspStoreEvent::LanguageServerUpdate {
language_server_id,
name,
@@ -310,7 +343,7 @@ impl HeadlessProject {
} => {
self.session
.send(proto::UpdateLanguageServer {
- project_id: SSH_PROJECT_ID,
+ project_id: REMOTE_SERVER_PROJECT_ID,
server_name: name.as_ref().map(|name| name.to_string()),
language_server_id: language_server_id.to_proto(),
variant: Some(message.clone()),
@@ -320,25 +353,15 @@ impl HeadlessProject {
LspStoreEvent::Notification(message) => {
self.session
.send(proto::Toast {
- project_id: SSH_PROJECT_ID,
+ project_id: REMOTE_SERVER_PROJECT_ID,
notification_id: "lsp".to_string(),
message: message.clone(),
})
.log_err();
}
- LspStoreEvent::LanguageServerLog(language_server_id, log_type, message) => {
- self.session
- .send(proto::LanguageServerLog {
- project_id: SSH_PROJECT_ID,
- language_server_id: language_server_id.to_proto(),
- message: message.clone(),
- log_type: Some(log_type.to_proto()),
- })
- .log_err();
- }
LspStoreEvent::LanguageServerPrompt(prompt) => {
let request = self.session.request(proto::LanguageServerPromptRequest {
- project_id: SSH_PROJECT_ID,
+ project_id: REMOTE_SERVER_PROJECT_ID,
actions: prompt
.actions
.iter()
@@ -474,7 +497,7 @@ impl HeadlessProject {
let buffer_id = buffer.read_with(&cx, |b, _| b.remote_id())?;
buffer_store.update(&mut cx, |buffer_store, cx| {
buffer_store
- .create_buffer_for_peer(&buffer, SSH_PEER_ID, cx)
+ .create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx)
.detach_and_log_err(cx);
})?;
@@ -500,7 +523,7 @@ impl HeadlessProject {
let buffer_id = buffer.read_with(&cx, |b, _| b.remote_id())?;
buffer_store.update(&mut cx, |buffer_store, cx| {
buffer_store
- .create_buffer_for_peer(&buffer, SSH_PEER_ID, cx)
+ .create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx)
.detach_and_log_err(cx);
})?;
@@ -509,7 +532,31 @@ impl HeadlessProject {
})
}
- pub async fn handle_open_server_settings(
+ async fn handle_toggle_lsp_logs(
+ _: Entity<Self>,
+ envelope: TypedEnvelope<proto::ToggleLspLogs>,
+ mut cx: AsyncApp,
+ ) -> Result<()> {
+ let server_id = LanguageServerId::from_proto(envelope.payload.server_id);
+ let lsp_logs = cx
+ .update(|cx| {
+ cx.try_global::<GlobalLogStore>()
+ .map(|lsp_logs| lsp_logs.0.clone())
+ })?
+ .context("lsp logs store is missing")?;
+
+ lsp_logs.update(&mut cx, |lsp_logs, _| {
+ // we do not support any other log toggling yet
+ if envelope.payload.enabled {
+ lsp_logs.enable_rpc_trace_for_language_server(server_id);
+ } else {
+ lsp_logs.disable_rpc_trace_for_language_server(server_id);
+ }
+ })?;
+ Ok(())
+ }
+
+ async fn handle_open_server_settings(
this: Entity<Self>,
_: TypedEnvelope<proto::OpenServerSettings>,
mut cx: AsyncApp,
@@ -550,7 +597,7 @@ impl HeadlessProject {
buffer_store.update(cx, |buffer_store, cx| {
buffer_store
- .create_buffer_for_peer(&buffer, SSH_PEER_ID, cx)
+ .create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx)
.detach_and_log_err(cx);
});
@@ -562,7 +609,7 @@ impl HeadlessProject {
})
}
- pub async fn handle_find_search_candidates(
+ async fn handle_find_search_candidates(
this: Entity<Self>,
envelope: TypedEnvelope<proto::FindSearchCandidates>,
mut cx: AsyncApp,
@@ -586,7 +633,7 @@ impl HeadlessProject {
response.buffer_ids.push(buffer_id.to_proto());
buffer_store
.update(&mut cx, |buffer_store, cx| {
- buffer_store.create_buffer_for_peer(&buffer, SSH_PEER_ID, cx)
+ buffer_store.create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx)
})?
.await?;
}
@@ -594,7 +641,7 @@ impl HeadlessProject {
Ok(response)
}
- pub async fn handle_list_remote_directory(
+ async fn handle_list_remote_directory(
this: Entity<Self>,
envelope: TypedEnvelope<proto::ListRemoteDirectory>,
cx: AsyncApp,
@@ -626,7 +673,7 @@ impl HeadlessProject {
})
}
- pub async fn handle_get_path_metadata(
+ async fn handle_get_path_metadata(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GetPathMetadata>,
cx: AsyncApp,
@@ -644,7 +691,7 @@ impl HeadlessProject {
})
}
- pub async fn handle_shutdown_remote_server(
+ async fn handle_shutdown_remote_server(
_this: Entity<Self>,
_envelope: TypedEnvelope<proto::ShutdownRemoteServer>,
cx: AsyncApp,
@@ -22,7 +22,7 @@ use project::{
Project, ProjectPath,
search::{SearchQuery, SearchResult},
};
-use remote::SshRemoteClient;
+use remote::RemoteClient;
use serde_json::json;
use settings::{Settings, SettingsLocation, SettingsStore, initial_server_settings_content};
use smol::stream::StreamExt;
@@ -1119,7 +1119,7 @@ async fn test_reconnect(cx: &mut TestAppContext, server_cx: &mut TestAppContext)
buffer.edit([(ix..ix + 1, "100")], None, cx);
});
- let client = cx.read(|cx| project.read(cx).ssh_client().unwrap());
+ let client = cx.read(|cx| project.read(cx).remote_client().unwrap());
client
.update(cx, |client, cx| client.simulate_disconnect(cx))
.detach();
@@ -1782,7 +1782,7 @@ pub async fn init_test(
});
init_logger();
- let (opts, ssh_server_client) = SshRemoteClient::fake_server(cx, server_cx);
+ let (opts, ssh_server_client) = RemoteClient::fake_server(cx, server_cx);
let http_client = Arc::new(BlockedHttpClient);
let node_runtime = NodeRuntime::unavailable();
let languages = Arc::new(LanguageRegistry::new(cx.executor()));
@@ -1804,7 +1804,7 @@ pub async fn init_test(
)
});
- let ssh = SshRemoteClient::fake_client(opts, cx).await;
+ let ssh = RemoteClient::fake_client(opts, cx).await;
let project = build_project(ssh, cx);
project
.update(cx, {
@@ -1819,7 +1819,7 @@ fn init_logger() {
zlog::init_test();
}
-fn build_project(ssh: Entity<SshRemoteClient>, cx: &mut TestAppContext) -> Entity<Project> {
+fn build_project(ssh: Entity<RemoteClient>, cx: &mut TestAppContext) -> Entity<Project> {
cx.update(|cx| {
if !cx.has_global::<SettingsStore>() {
let settings_store = SettingsStore::test(cx);
@@ -1845,5 +1845,5 @@ fn build_project(ssh: Entity<SshRemoteClient>, cx: &mut TestAppContext) -> Entit
language::init(cx);
});
- cx.update(|cx| Project::ssh(ssh, client, node, user_store, languages, fs, cx))
+ cx.update(|cx| Project::remote(ssh, client, node, user_store, languages, fs, cx))
}
@@ -19,14 +19,14 @@ use project::project_settings::ProjectSettings;
use proto::CrashReport;
use release_channel::{AppVersion, RELEASE_CHANNEL, ReleaseChannel};
-use remote::SshRemoteClient;
+use remote::RemoteClient;
use remote::{
json_log::LogRecord,
protocol::{read_message, write_message},
proxy::ProxyLaunchError,
};
use reqwest_client::ReqwestClient;
-use rpc::proto::{self, Envelope, SSH_PROJECT_ID};
+use rpc::proto::{self, Envelope, REMOTE_SERVER_PROJECT_ID};
use rpc::{AnyProtoClient, TypedEnvelope};
use settings::{Settings, SettingsStore, watch_config_file};
use smol::channel::{Receiver, Sender};
@@ -396,7 +396,7 @@ fn start_server(
})
.detach();
- SshRemoteClient::proto_client_from_channels(incoming_rx, outgoing_tx, cx, "server")
+ RemoteClient::proto_client_from_channels(incoming_rx, outgoing_tx, cx, "server")
}
fn init_paths() -> anyhow::Result<()> {
@@ -867,34 +867,21 @@ where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
- use remote::protocol::read_message_raw;
+ use remote::protocol::{read_message_raw, write_size_prefixed_buffer};
let mut buffer = Vec::new();
loop {
read_message_raw(&mut reader, &mut buffer)
.await
.with_context(|| format!("failed to read message from {}", socket_name))?;
-
write_size_prefixed_buffer(&mut writer, &mut buffer)
.await
.with_context(|| format!("failed to write message to {}", socket_name))?;
-
writer.flush().await?;
-
buffer.clear();
}
}
-async fn write_size_prefixed_buffer<S: AsyncWrite + Unpin>(
- stream: &mut S,
- buffer: &mut Vec<u8>,
-) -> Result<()> {
- let len = buffer.len() as u32;
- stream.write_all(len.to_le_bytes().as_slice()).await?;
- stream.write_all(buffer).await?;
- Ok(())
-}
-
fn initialize_settings(
session: AnyProtoClient,
fs: Arc<dyn Fs>,
@@ -910,7 +897,7 @@ fn initialize_settings(
session
.send(proto::Toast {
- project_id: SSH_PROJECT_ID,
+ project_id: REMOTE_SERVER_PROJECT_ID,
notification_id: "server-settings-failed".to_string(),
message: format!(
"Error in settings on remote host {:?}: {}",
@@ -922,7 +909,7 @@ fn initialize_settings(
} else {
session
.send(proto::HideToast {
- project_id: SSH_PROJECT_ID,
+ project_id: REMOTE_SERVER_PROJECT_ID,
notification_id: "server-settings-failed".to_string(),
})
.log_err();
@@ -414,7 +414,7 @@ impl RulesLibrary {
});
Self {
title_bar: if !cfg!(target_os = "macos") {
- Some(cx.new(|_| PlatformTitleBar::new("rules-library-title-bar")))
+ Some(cx.new(|cx| PlatformTitleBar::new("rules-library-title-bar", cx)))
} else {
None
},
@@ -749,14 +749,16 @@ impl BufferSearchBar {
return false;
};
- self.configured_options =
+ let configured_options =
SearchOptions::from_settings(&EditorSettings::get_global(cx).search);
- if self.dismissed
- && (self.configured_options != self.default_options
- || self.configured_options != self.search_options)
- {
- self.search_options = self.configured_options;
- self.default_options = self.configured_options;
+ let settings_changed = configured_options != self.configured_options;
+
+ if self.dismissed && settings_changed {
+ // Only update configuration options when search bar is dismissed,
+ // so we don't miss updates even after calling show twice
+ self.configured_options = configured_options;
+ self.search_options = configured_options;
+ self.default_options = configured_options;
}
self.dismissed = false;
@@ -1514,18 +1516,25 @@ mod tests {
cx,
)
});
- let cx = cx.add_empty_window();
- let editor =
- cx.new_window_entity(|window, cx| Editor::for_buffer(buffer.clone(), None, window, cx));
-
- let search_bar = cx.new_window_entity(|window, cx| {
+ let mut editor = None;
+ let window = cx.add_window(|window, cx| {
+ let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
+ "keymaps/default-macos.json",
+ cx,
+ )
+ .unwrap();
+ cx.bind_keys(default_key_bindings);
+ editor = Some(cx.new(|cx| Editor::for_buffer(buffer.clone(), None, window, cx)));
let mut search_bar = BufferSearchBar::new(None, window, cx);
- search_bar.set_active_pane_item(Some(&editor), window, cx);
+ search_bar.set_active_pane_item(Some(&editor.clone().unwrap()), window, cx);
search_bar.show(window, cx);
search_bar
});
+ let search_bar = window.root(cx).unwrap();
+
+ let cx = VisualTestContext::from_window(*window, cx).into_mut();
- (editor, search_bar, cx)
+ (editor.unwrap(), search_bar, cx)
}
#[gpui::test]
@@ -2750,11 +2759,6 @@ mod tests {
"Search bar should be present and visible"
);
search_bar.deploy(&deploy, window, cx);
- assert_eq!(
- search_bar.configured_options,
- SearchOptions::NONE,
- "Should have configured search options matching the settings"
- );
assert_eq!(
search_bar.search_options,
SearchOptions::WHOLE_WORD,
@@ -2765,21 +2769,22 @@ mod tests {
search_bar.deploy(&deploy, window, cx);
assert_eq!(
search_bar.search_options,
- SearchOptions::NONE,
- "After hiding and showing the search bar, default options should be used"
+ SearchOptions::WHOLE_WORD,
+ "After hiding and showing the search bar, search options should be preserved"
);
search_bar.toggle_search_option(SearchOptions::REGEX, window, cx);
search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
assert_eq!(
search_bar.search_options,
- SearchOptions::REGEX | SearchOptions::WHOLE_WORD,
+ SearchOptions::REGEX,
"Should enable the options toggled"
);
assert!(
!search_bar.dismissed,
"Search bar should be present and visible"
);
+ search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx);
});
update_search_settings(
@@ -2800,11 +2805,6 @@ mod tests {
);
search_bar.deploy(&deploy, window, cx);
- assert_eq!(
- search_bar.configured_options,
- SearchOptions::CASE_SENSITIVE,
- "Should have configured search options matching the settings"
- );
assert_eq!(
search_bar.search_options,
SearchOptions::REGEX | SearchOptions::WHOLE_WORD,
@@ -2812,10 +2812,37 @@ mod tests {
);
search_bar.dismiss(&Dismiss, window, cx);
search_bar.deploy(&deploy, window, cx);
+ assert_eq!(
+ search_bar.configured_options,
+ SearchOptions::CASE_SENSITIVE,
+ "After a settings update and toggling the search bar, configured options should be updated"
+ );
assert_eq!(
search_bar.search_options,
SearchOptions::CASE_SENSITIVE,
- "After hiding and showing the search bar, default options should be used"
+ "After a settings update and toggling the search bar, configured options should be used"
+ );
+ });
+
+ update_search_settings(
+ SearchSettings {
+ button: true,
+ whole_word: true,
+ case_sensitive: true,
+ include_ignored: false,
+ regex: false,
+ },
+ cx,
+ );
+
+ search_bar.update_in(cx, |search_bar, window, cx| {
+ search_bar.deploy(&deploy, window, cx);
+ search_bar.dismiss(&Dismiss, window, cx);
+ search_bar.show(window, cx);
+ assert_eq!(
+ search_bar.search_options,
+ SearchOptions::CASE_SENSITIVE | SearchOptions::WHOLE_WORD,
+ "Calling deploy on an already deployed search bar should not prevent settings updates from being detected"
);
});
}
@@ -1139,7 +1139,7 @@ impl ProjectSearchView {
fn build_search_query(&mut self, cx: &mut Context<Self>) -> Option<SearchQuery> {
// Do not bail early in this function, as we want to fill out `self.panels_with_errors`.
- let text = self.query_editor.read(cx).text(cx);
+ let text = self.search_query_text(cx);
let open_buffers = if self.included_opened_only {
Some(self.open_buffers(cx))
} else {
@@ -1,1424 +0,0 @@
-use collections::HashMap;
-
-// On some keyboards (e.g. German QWERTZ) it is not possible to type the full ASCII range
-// without using option. This means that some of our built in keyboard shortcuts do not work
-// for those users.
-//
-// The way macOS solves this problem is to move shortcuts around so that they are all reachable,
-// even if the mnemonic changes. https://developer.apple.com/documentation/swiftui/keyboardshortcut/localization-swift.struct
-//
-// For example, cmd-> is the "switch window" shortcut because the > key is right above tab.
-// To ensure this doesn't cause problems for shortcuts defined for a QWERTY layout, apple moves
-// any shortcuts defined as cmd-> to cmd-:. Coincidentally this s also the same keyboard position
-// as cmd-> on a QWERTY layout.
-//
-// Another example is cmd-[ and cmd-], as they cannot be typed without option, those keys are remapped to cmd-ö
-// and cmd-ä. These shortcuts are not in the same position as a QWERTY keyboard, because on a QWERTZ keyboard
-// the + key is in the way; and shortcuts bound to cmd-+ are still typed as cmd-+ on either keyboard (though the
-// specific key moves)
-//
-// As far as I can tell, there's no way to query the mappings Apple uses except by rendering a menu with every
-// possible key combination, and inspecting the UI to see what it rendered. So that's what we did...
-//
-// These mappings were generated by running https://github.com/ConradIrwin/keyboard-inspector, tidying up the
-// output to remove languages with no mappings and other oddities, and converting it to a less verbose representation with:
-// jq -s 'map(to_entries | map({key: .key, value: [(.value | to_entries | map(.key) | join("")), (.value | to_entries | map(.value) | join(""))]}) | from_entries) | add'
-// From there I used multi-cursor to produce this match statement.
-#[cfg(target_os = "macos")]
-pub fn get_key_equivalents(layout: &str) -> Option<HashMap<char, char>> {
- let mappings: &[(char, char)] = match layout {
- "com.apple.keylayout.ABC-AZERTY" => &[
- ('!', '1'),
- ('"', '%'),
- ('#', '3'),
- ('$', '4'),
- ('%', '5'),
- ('&', '7'),
- ('(', '9'),
- (')', '0'),
- ('*', '8'),
- ('.', ';'),
- ('/', ':'),
- ('0', 'à'),
- ('1', '&'),
- ('2', 'é'),
- ('3', '"'),
- ('4', '\''),
- ('5', '('),
- ('6', '§'),
- ('7', 'è'),
- ('8', '!'),
- ('9', 'ç'),
- (':', '°'),
- (';', ')'),
- ('<', '.'),
- ('>', '/'),
- ('@', '2'),
- ('[', '^'),
- ('\'', 'ù'),
- ('\\', '`'),
- (']', '$'),
- ('^', '6'),
- ('`', '<'),
- ('{', '¨'),
- ('|', '£'),
- ('}', '*'),
- ('~', '>'),
- ],
- "com.apple.keylayout.ABC-QWERTZ" => &[
- ('"', '`'),
- ('#', '§'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', 'ß'),
- (':', 'Ü'),
- (';', 'ü'),
- ('<', ';'),
- ('=', '*'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ö'),
- ('\'', '´'),
- ('\\', '#'),
- (']', 'ä'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Ö'),
- ('|', '\''),
- ('}', 'Ä'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Albanian" => &[
- ('"', '\''),
- (':', 'Ç'),
- (';', 'ç'),
- ('<', ';'),
- ('>', ':'),
- ('@', '"'),
- ('\'', '@'),
- ('\\', 'ë'),
- ('`', '<'),
- ('|', 'Ë'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Austrian" => &[
- ('"', '`'),
- ('#', '§'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', 'ß'),
- (':', 'Ü'),
- (';', 'ü'),
- ('<', ';'),
- ('=', '*'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ö'),
- ('\'', '´'),
- ('\\', '#'),
- (']', 'ä'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Ö'),
- ('|', '\''),
- ('}', 'Ä'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Azeri" => &[
- ('"', 'Ə'),
- (',', 'ç'),
- ('.', 'ş'),
- ('/', '.'),
- (':', 'I'),
- (';', 'ı'),
- ('<', 'Ç'),
- ('>', 'Ş'),
- ('?', ','),
- ('W', 'Ü'),
- ('[', 'ö'),
- ('\'', 'ə'),
- (']', 'ğ'),
- ('w', 'ü'),
- ('{', 'Ö'),
- ('|', '/'),
- ('}', 'Ğ'),
- ],
- "com.apple.keylayout.Belgian" => &[
- ('!', '1'),
- ('"', '%'),
- ('#', '3'),
- ('$', '4'),
- ('%', '5'),
- ('&', '7'),
- ('(', '9'),
- (')', '0'),
- ('*', '8'),
- ('.', ';'),
- ('/', ':'),
- ('0', 'à'),
- ('1', '&'),
- ('2', 'é'),
- ('3', '"'),
- ('4', '\''),
- ('5', '('),
- ('6', '§'),
- ('7', 'è'),
- ('8', '!'),
- ('9', 'ç'),
- (':', '°'),
- (';', ')'),
- ('<', '.'),
- ('>', '/'),
- ('@', '2'),
- ('[', '^'),
- ('\'', 'ù'),
- ('\\', '`'),
- (']', '$'),
- ('^', '6'),
- ('`', '<'),
- ('{', '¨'),
- ('|', '£'),
- ('}', '*'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Brazilian-ABNT2" => &[
- ('"', '`'),
- ('/', 'ç'),
- ('?', 'Ç'),
- ('\'', '´'),
- ('\\', '~'),
- ('^', '¨'),
- ('`', '\''),
- ('|', '^'),
- ('~', '"'),
- ],
- "com.apple.keylayout.Brazilian-Pro" => &[('^', 'ˆ'), ('~', '˜')],
- "com.apple.keylayout.British" => &[('#', '£')],
- "com.apple.keylayout.Canadian-CSA" => &[
- ('"', 'È'),
- ('/', 'é'),
- ('<', '\''),
- ('>', '"'),
- ('?', 'É'),
- ('[', '^'),
- ('\'', 'è'),
- ('\\', 'à'),
- (']', 'ç'),
- ('`', 'ù'),
- ('{', '¨'),
- ('|', 'À'),
- ('}', 'Ç'),
- ('~', 'Ù'),
- ],
- "com.apple.keylayout.Croatian" => &[
- ('"', 'Ć'),
- ('&', '\''),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- (':', 'Č'),
- (';', 'č'),
- ('<', ';'),
- ('=', '*'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'š'),
- ('\'', 'ć'),
- ('\\', 'ž'),
- (']', 'đ'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Š'),
- ('|', 'Ž'),
- ('}', 'Đ'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Croatian-PC" => &[
- ('"', 'Ć'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '\''),
- (':', 'Č'),
- (';', 'č'),
- ('<', ';'),
- ('=', '*'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'š'),
- ('\'', 'ć'),
- ('\\', 'ž'),
- (']', 'đ'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Š'),
- ('|', 'Ž'),
- ('}', 'Đ'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Czech" => &[
- ('!', '1'),
- ('"', '!'),
- ('#', '3'),
- ('$', '4'),
- ('%', '5'),
- ('&', '7'),
- ('(', '9'),
- (')', '0'),
- ('*', '8'),
- ('+', '%'),
- ('/', '\''),
- ('0', 'é'),
- ('1', '+'),
- ('2', 'ě'),
- ('3', 'š'),
- ('4', 'č'),
- ('5', 'ř'),
- ('6', 'ž'),
- ('7', 'ý'),
- ('8', 'á'),
- ('9', 'í'),
- (':', '"'),
- (';', 'ů'),
- ('<', '?'),
- ('>', ':'),
- ('?', 'ˇ'),
- ('@', '2'),
- ('[', 'ú'),
- ('\'', '§'),
- (']', ')'),
- ('^', '6'),
- ('`', '¨'),
- ('{', 'Ú'),
- ('}', '('),
- ('~', '`'),
- ],
- "com.apple.keylayout.Czech-QWERTY" => &[
- ('!', '1'),
- ('"', '!'),
- ('#', '3'),
- ('$', '4'),
- ('%', '5'),
- ('&', '7'),
- ('(', '9'),
- (')', '0'),
- ('*', '8'),
- ('+', '%'),
- ('/', '\''),
- ('0', 'é'),
- ('1', '+'),
- ('2', 'ě'),
- ('3', 'š'),
- ('4', 'č'),
- ('5', 'ř'),
- ('6', 'ž'),
- ('7', 'ý'),
- ('8', 'á'),
- ('9', 'í'),
- (':', '"'),
- (';', 'ů'),
- ('<', '?'),
- ('>', ':'),
- ('?', 'ˇ'),
- ('@', '2'),
- ('[', 'ú'),
- ('\'', '§'),
- (']', ')'),
- ('^', '6'),
- ('`', '¨'),
- ('{', 'Ú'),
- ('}', '('),
- ('~', '`'),
- ],
- "com.apple.keylayout.Danish" => &[
- ('"', '^'),
- ('$', '€'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '´'),
- (':', 'Å'),
- (';', 'å'),
- ('<', ';'),
- ('=', '`'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'æ'),
- ('\'', '¨'),
- ('\\', '\''),
- (']', 'ø'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Æ'),
- ('|', '*'),
- ('}', 'Ø'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Faroese" => &[
- ('"', 'Ø'),
- ('$', '€'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '´'),
- (':', 'Æ'),
- (';', 'æ'),
- ('<', ';'),
- ('=', '`'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'å'),
- ('\'', 'ø'),
- ('\\', '\''),
- (']', 'ð'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Å'),
- ('|', '*'),
- ('}', 'Ð'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Finnish" => &[
- ('"', '^'),
- ('$', '€'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '´'),
- (':', 'Å'),
- (';', 'å'),
- ('<', ';'),
- ('=', '`'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ö'),
- ('\'', '¨'),
- ('\\', '\''),
- (']', 'ä'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Ö'),
- ('|', '*'),
- ('}', 'Ä'),
- ('~', '>'),
- ],
- "com.apple.keylayout.FinnishExtended" => &[
- ('"', 'ˆ'),
- ('$', '€'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '´'),
- (':', 'Å'),
- (';', 'å'),
- ('<', ';'),
- ('=', '`'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ö'),
- ('\'', '¨'),
- ('\\', '\''),
- (']', 'ä'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Ö'),
- ('|', '*'),
- ('}', 'Ä'),
- ('~', '>'),
- ],
- "com.apple.keylayout.FinnishSami-PC" => &[
- ('"', 'ˆ'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '´'),
- (':', 'Å'),
- (';', 'å'),
- ('<', ';'),
- ('=', '`'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ö'),
- ('\'', '¨'),
- ('\\', '@'),
- (']', 'ä'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Ö'),
- ('|', '*'),
- ('}', 'Ä'),
- ('~', '>'),
- ],
- "com.apple.keylayout.French" => &[
- ('!', '1'),
- ('"', '%'),
- ('#', '3'),
- ('$', '4'),
- ('%', '5'),
- ('&', '7'),
- ('(', '9'),
- (')', '0'),
- ('*', '8'),
- ('.', ';'),
- ('/', ':'),
- ('0', 'à'),
- ('1', '&'),
- ('2', 'é'),
- ('3', '"'),
- ('4', '\''),
- ('5', '('),
- ('6', '§'),
- ('7', 'è'),
- ('8', '!'),
- ('9', 'ç'),
- (':', '°'),
- (';', ')'),
- ('<', '.'),
- ('>', '/'),
- ('@', '2'),
- ('[', '^'),
- ('\'', 'ù'),
- ('\\', '`'),
- (']', '$'),
- ('^', '6'),
- ('`', '<'),
- ('{', '¨'),
- ('|', '£'),
- ('}', '*'),
- ('~', '>'),
- ],
- "com.apple.keylayout.French-PC" => &[
- ('!', '1'),
- ('"', '%'),
- ('#', '3'),
- ('$', '4'),
- ('%', '5'),
- ('&', '7'),
- ('(', '9'),
- (')', '0'),
- ('*', '8'),
- ('-', ')'),
- ('.', ';'),
- ('/', ':'),
- ('0', 'à'),
- ('1', '&'),
- ('2', 'é'),
- ('3', '"'),
- ('4', '\''),
- ('5', '('),
- ('6', '-'),
- ('7', 'è'),
- ('8', '_'),
- ('9', 'ç'),
- (':', '§'),
- (';', '!'),
- ('<', '.'),
- ('>', '/'),
- ('@', '2'),
- ('[', '^'),
- ('\'', 'ù'),
- ('\\', '*'),
- (']', '$'),
- ('^', '6'),
- ('_', '°'),
- ('`', '<'),
- ('{', '¨'),
- ('|', 'μ'),
- ('}', '£'),
- ('~', '>'),
- ],
- "com.apple.keylayout.French-numerical" => &[
- ('!', '1'),
- ('"', '%'),
- ('#', '3'),
- ('$', '4'),
- ('%', '5'),
- ('&', '7'),
- ('(', '9'),
- (')', '0'),
- ('*', '8'),
- ('.', ';'),
- ('/', ':'),
- ('0', 'à'),
- ('1', '&'),
- ('2', 'é'),
- ('3', '"'),
- ('4', '\''),
- ('5', '('),
- ('6', '§'),
- ('7', 'è'),
- ('8', '!'),
- ('9', 'ç'),
- (':', '°'),
- (';', ')'),
- ('<', '.'),
- ('>', '/'),
- ('@', '2'),
- ('[', '^'),
- ('\'', 'ù'),
- ('\\', '`'),
- (']', '$'),
- ('^', '6'),
- ('`', '<'),
- ('{', '¨'),
- ('|', '£'),
- ('}', '*'),
- ('~', '>'),
- ],
- "com.apple.keylayout.German" => &[
- ('"', '`'),
- ('#', '§'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', 'ß'),
- (':', 'Ü'),
- (';', 'ü'),
- ('<', ';'),
- ('=', '*'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ö'),
- ('\'', '´'),
- ('\\', '#'),
- (']', 'ä'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Ö'),
- ('|', '\''),
- ('}', 'Ä'),
- ('~', '>'),
- ],
- "com.apple.keylayout.German-DIN-2137" => &[
- ('"', '`'),
- ('#', '§'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', 'ß'),
- (':', 'Ü'),
- (';', 'ü'),
- ('<', ';'),
- ('=', '*'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ö'),
- ('\'', '´'),
- ('\\', '#'),
- (']', 'ä'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Ö'),
- ('|', '\''),
- ('}', 'Ä'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Hawaiian" => &[('\'', 'ʻ')],
- "com.apple.keylayout.Hungarian" => &[
- ('!', '\''),
- ('"', 'Á'),
- ('#', '+'),
- ('$', '!'),
- ('&', '='),
- ('(', ')'),
- (')', 'Ö'),
- ('*', '('),
- ('+', 'Ó'),
- ('/', 'ü'),
- ('0', 'ö'),
- (':', 'É'),
- (';', 'é'),
- ('<', 'Ü'),
- ('=', 'ó'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ő'),
- ('\'', 'á'),
- ('\\', 'ű'),
- (']', 'ú'),
- ('^', '/'),
- ('`', 'í'),
- ('{', 'Ő'),
- ('|', 'Ű'),
- ('}', 'Ú'),
- ('~', 'Í'),
- ],
- "com.apple.keylayout.Hungarian-QWERTY" => &[
- ('!', '\''),
- ('"', 'Á'),
- ('#', '+'),
- ('$', '!'),
- ('&', '='),
- ('(', ')'),
- (')', 'Ö'),
- ('*', '('),
- ('+', 'Ó'),
- ('/', 'ü'),
- ('0', 'ö'),
- (':', 'É'),
- (';', 'é'),
- ('<', 'Ü'),
- ('=', 'ó'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ő'),
- ('\'', 'á'),
- ('\\', 'ű'),
- (']', 'ú'),
- ('^', '/'),
- ('`', 'í'),
- ('{', 'Ő'),
- ('|', 'Ű'),
- ('}', 'Ú'),
- ('~', 'Í'),
- ],
- "com.apple.keylayout.Icelandic" => &[
- ('"', 'Ö'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '\''),
- (':', 'Ð'),
- (';', 'ð'),
- ('<', ';'),
- ('=', '*'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'æ'),
- ('\'', 'ö'),
- ('\\', 'þ'),
- (']', '´'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Æ'),
- ('|', 'Þ'),
- ('}', '´'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Irish" => &[('#', '£')],
- "com.apple.keylayout.IrishExtended" => &[('#', '£')],
- "com.apple.keylayout.Italian" => &[
- ('!', '1'),
- ('"', '%'),
- ('#', '3'),
- ('$', '4'),
- ('%', '5'),
- ('&', '7'),
- ('(', '9'),
- (')', '0'),
- ('*', '8'),
- (',', ';'),
- ('.', ':'),
- ('/', ','),
- ('0', 'é'),
- ('1', '&'),
- ('2', '"'),
- ('3', '\''),
- ('4', '('),
- ('5', 'ç'),
- ('6', 'è'),
- ('7', ')'),
- ('8', '£'),
- ('9', 'à'),
- (':', '!'),
- (';', 'ò'),
- ('<', '.'),
- ('>', '/'),
- ('@', '2'),
- ('[', 'ì'),
- ('\'', 'ù'),
- ('\\', '§'),
- (']', '$'),
- ('^', '6'),
- ('`', '<'),
- ('{', '^'),
- ('|', '°'),
- ('}', '*'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Italian-Pro" => &[
- ('"', '^'),
- ('#', '£'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '\''),
- (':', 'é'),
- (';', 'è'),
- ('<', ';'),
- ('=', '*'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ò'),
- ('\'', 'ì'),
- ('\\', 'ù'),
- (']', 'à'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'ç'),
- ('|', '§'),
- ('}', '°'),
- ('~', '>'),
- ],
- "com.apple.keylayout.LatinAmerican" => &[
- ('"', '¨'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '\''),
- (':', 'Ñ'),
- (';', 'ñ'),
- ('<', ';'),
- ('=', '*'),
- ('>', ':'),
- ('@', '"'),
- ('[', '{'),
- ('\'', '´'),
- ('\\', '¿'),
- (']', '}'),
- ('^', '&'),
- ('`', '<'),
- ('{', '['),
- ('|', '¡'),
- ('}', ']'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Lithuanian" => &[
- ('!', 'Ą'),
- ('#', 'Ę'),
- ('$', 'Ė'),
- ('%', 'Į'),
- ('&', 'Ų'),
- ('*', 'Ū'),
- ('+', 'Ž'),
- ('1', 'ą'),
- ('2', 'č'),
- ('3', 'ę'),
- ('4', 'ė'),
- ('5', 'į'),
- ('6', 'š'),
- ('7', 'ų'),
- ('8', 'ū'),
- ('=', 'ž'),
- ('@', 'Č'),
- ('^', 'Š'),
- ],
- "com.apple.keylayout.Maltese" => &[
- ('#', '£'),
- ('[', 'ġ'),
- (']', 'ħ'),
- ('`', 'ż'),
- ('{', 'Ġ'),
- ('}', 'Ħ'),
- ('~', 'Ż'),
- ],
- "com.apple.keylayout.NorthernSami" => &[
- ('"', 'Ŋ'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '´'),
- (':', 'Å'),
- (';', 'å'),
- ('<', ';'),
- ('=', '`'),
- ('>', ':'),
- ('@', '"'),
- ('Q', 'Á'),
- ('W', 'Š'),
- ('X', 'Č'),
- ('[', 'ø'),
- ('\'', 'ŋ'),
- ('\\', 'đ'),
- (']', 'æ'),
- ('^', '&'),
- ('`', 'ž'),
- ('q', 'á'),
- ('w', 'š'),
- ('x', 'č'),
- ('{', 'Ø'),
- ('|', 'Đ'),
- ('}', 'Æ'),
- ('~', 'Ž'),
- ],
- "com.apple.keylayout.Norwegian" => &[
- ('"', '^'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '´'),
- (':', 'Å'),
- (';', 'å'),
- ('<', ';'),
- ('=', '`'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ø'),
- ('\'', '¨'),
- ('\\', '@'),
- (']', 'æ'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Ø'),
- ('|', '*'),
- ('}', 'Æ'),
- ('~', '>'),
- ],
- "com.apple.keylayout.NorwegianExtended" => &[
- ('"', 'ˆ'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '´'),
- (':', 'Å'),
- (';', 'å'),
- ('<', ';'),
- ('=', '`'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ø'),
- ('\\', '@'),
- (']', 'æ'),
- ('`', '<'),
- ('}', 'Æ'),
- ('~', '>'),
- ],
- "com.apple.keylayout.NorwegianSami-PC" => &[
- ('"', 'ˆ'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '´'),
- (':', 'Å'),
- (';', 'å'),
- ('<', ';'),
- ('=', '`'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ø'),
- ('\'', '¨'),
- ('\\', '@'),
- (']', 'æ'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Ø'),
- ('|', '*'),
- ('}', 'Æ'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Polish" => &[
- ('!', '§'),
- ('"', 'ę'),
- ('#', '!'),
- ('$', '?'),
- ('%', '+'),
- ('&', ':'),
- ('(', '/'),
- (')', '"'),
- ('*', '_'),
- ('+', ']'),
- (',', '.'),
- ('.', ','),
- ('/', 'ż'),
- (':', 'Ł'),
- (';', 'ł'),
- ('<', 'ś'),
- ('=', '['),
- ('>', 'ń'),
- ('?', 'Ż'),
- ('@', '%'),
- ('[', 'ó'),
- ('\'', 'ą'),
- ('\\', ';'),
- (']', '('),
- ('^', '='),
- ('_', 'ć'),
- ('`', '<'),
- ('{', 'ź'),
- ('|', '$'),
- ('}', ')'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Portuguese" => &[
- ('"', '`'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '\''),
- (':', 'ª'),
- (';', 'º'),
- ('<', ';'),
- ('=', '*'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ç'),
- ('\'', '´'),
- (']', '~'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Ç'),
- ('}', '^'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Sami-PC" => &[
- ('"', 'Ŋ'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '´'),
- (':', 'Å'),
- (';', 'å'),
- ('<', ';'),
- ('=', '`'),
- ('>', ':'),
- ('@', '"'),
- ('Q', 'Á'),
- ('W', 'Š'),
- ('X', 'Č'),
- ('[', 'ø'),
- ('\'', 'ŋ'),
- ('\\', 'đ'),
- (']', 'æ'),
- ('^', '&'),
- ('`', 'ž'),
- ('q', 'á'),
- ('w', 'š'),
- ('x', 'č'),
- ('{', 'Ø'),
- ('|', 'Đ'),
- ('}', 'Æ'),
- ('~', 'Ž'),
- ],
- "com.apple.keylayout.Serbian-Latin" => &[
- ('"', 'Ć'),
- ('&', '\''),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- (':', 'Č'),
- (';', 'č'),
- ('<', ';'),
- ('=', '*'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'š'),
- ('\'', 'ć'),
- ('\\', 'ž'),
- (']', 'đ'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Š'),
- ('|', 'Ž'),
- ('}', 'Đ'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Slovak" => &[
- ('!', '1'),
- ('"', '!'),
- ('#', '3'),
- ('$', '4'),
- ('%', '5'),
- ('&', '7'),
- ('(', '9'),
- (')', '0'),
- ('*', '8'),
- ('+', '%'),
- ('/', '\''),
- ('0', 'é'),
- ('1', '+'),
- ('2', 'ľ'),
- ('3', 'š'),
- ('4', 'č'),
- ('5', 'ť'),
- ('6', 'ž'),
- ('7', 'ý'),
- ('8', 'á'),
- ('9', 'í'),
- (':', '"'),
- (';', 'ô'),
- ('<', '?'),
- ('>', ':'),
- ('?', 'ˇ'),
- ('@', '2'),
- ('[', 'ú'),
- ('\'', '§'),
- (']', 'ä'),
- ('^', '6'),
- ('`', 'ň'),
- ('{', 'Ú'),
- ('}', 'Ä'),
- ('~', 'Ň'),
- ],
- "com.apple.keylayout.Slovak-QWERTY" => &[
- ('!', '1'),
- ('"', '!'),
- ('#', '3'),
- ('$', '4'),
- ('%', '5'),
- ('&', '7'),
- ('(', '9'),
- (')', '0'),
- ('*', '8'),
- ('+', '%'),
- ('/', '\''),
- ('0', 'é'),
- ('1', '+'),
- ('2', 'ľ'),
- ('3', 'š'),
- ('4', 'č'),
- ('5', 'ť'),
- ('6', 'ž'),
- ('7', 'ý'),
- ('8', 'á'),
- ('9', 'í'),
- (':', '"'),
- (';', 'ô'),
- ('<', '?'),
- ('>', ':'),
- ('?', 'ˇ'),
- ('@', '2'),
- ('[', 'ú'),
- ('\'', '§'),
- (']', 'ä'),
- ('^', '6'),
- ('`', 'ň'),
- ('{', 'Ú'),
- ('}', 'Ä'),
- ('~', 'Ň'),
- ],
- "com.apple.keylayout.Slovenian" => &[
- ('"', 'Ć'),
- ('&', '\''),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- (':', 'Č'),
- (';', 'č'),
- ('<', ';'),
- ('=', '*'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'š'),
- ('\'', 'ć'),
- ('\\', 'ž'),
- (']', 'đ'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Š'),
- ('|', 'Ž'),
- ('}', 'Đ'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Spanish" => &[
- ('!', '¡'),
- ('"', '¨'),
- ('.', 'ç'),
- ('/', '.'),
- (':', 'º'),
- (';', '´'),
- ('<', '¿'),
- ('>', 'Ç'),
- ('@', '!'),
- ('[', 'ñ'),
- ('\'', '`'),
- ('\\', '\''),
- (']', ';'),
- ('^', '/'),
- ('`', '<'),
- ('{', 'Ñ'),
- ('|', '"'),
- ('}', ':'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Spanish-ISO" => &[
- ('"', '¨'),
- ('#', '·'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('.', 'ç'),
- ('/', '.'),
- (':', 'º'),
- (';', '´'),
- ('<', '¿'),
- ('>', 'Ç'),
- ('@', '"'),
- ('[', 'ñ'),
- ('\'', '`'),
- ('\\', '\''),
- (']', ';'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Ñ'),
- ('|', '"'),
- ('}', '`'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Swedish" => &[
- ('"', '^'),
- ('$', '€'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '´'),
- (':', 'Å'),
- (';', 'å'),
- ('<', ';'),
- ('=', '`'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ö'),
- ('\'', '¨'),
- ('\\', '\''),
- (']', 'ä'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Ö'),
- ('|', '*'),
- ('}', 'Ä'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Swedish-Pro" => &[
- ('"', '^'),
- ('$', '€'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '´'),
- (':', 'Å'),
- (';', 'å'),
- ('<', ';'),
- ('=', '`'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ö'),
- ('\'', '¨'),
- ('\\', '\''),
- (']', 'ä'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Ö'),
- ('|', '*'),
- ('}', 'Ä'),
- ('~', '>'),
- ],
- "com.apple.keylayout.SwedishSami-PC" => &[
- ('"', 'ˆ'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('/', '´'),
- (':', 'Å'),
- (';', 'å'),
- ('<', ';'),
- ('=', '`'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ö'),
- ('\'', '¨'),
- ('\\', '@'),
- (']', 'ä'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Ö'),
- ('|', '*'),
- ('}', 'Ä'),
- ('~', '>'),
- ],
- "com.apple.keylayout.SwissFrench" => &[
- ('!', '+'),
- ('"', '`'),
- ('#', '*'),
- ('$', 'ç'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('+', '!'),
- ('/', '\''),
- (':', 'ü'),
- (';', 'è'),
- ('<', ';'),
- ('=', '¨'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'é'),
- ('\'', '^'),
- ('\\', '$'),
- (']', 'à'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'ö'),
- ('|', '£'),
- ('}', 'ä'),
- ('~', '>'),
- ],
- "com.apple.keylayout.SwissGerman" => &[
- ('!', '+'),
- ('"', '`'),
- ('#', '*'),
- ('$', 'ç'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('+', '!'),
- ('/', '\''),
- (':', 'è'),
- (';', 'ü'),
- ('<', ';'),
- ('=', '¨'),
- ('>', ':'),
- ('@', '"'),
- ('[', 'ö'),
- ('\'', '^'),
- ('\\', '$'),
- (']', 'ä'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'é'),
- ('|', '£'),
- ('}', 'à'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Turkish" => &[
- ('"', '-'),
- ('#', '"'),
- ('$', '\''),
- ('%', '('),
- ('&', ')'),
- ('(', '%'),
- (')', ':'),
- ('*', '_'),
- (',', 'ö'),
- ('-', 'ş'),
- ('.', 'ç'),
- ('/', '.'),
- (':', '$'),
- ('<', 'Ö'),
- ('>', 'Ç'),
- ('@', '*'),
- ('[', 'ğ'),
- ('\'', ','),
- ('\\', 'ü'),
- (']', 'ı'),
- ('^', '/'),
- ('_', 'Ş'),
- ('`', '<'),
- ('{', 'Ğ'),
- ('|', 'Ü'),
- ('}', 'I'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Turkish-QWERTY-PC" => &[
- ('"', 'I'),
- ('#', '^'),
- ('$', '+'),
- ('&', '/'),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- ('+', ':'),
- (',', 'ö'),
- ('.', 'ç'),
- ('/', '*'),
- (':', 'Ş'),
- (';', 'ş'),
- ('<', 'Ö'),
- ('=', '.'),
- ('>', 'Ç'),
- ('@', '\''),
- ('[', 'ğ'),
- ('\'', 'ı'),
- ('\\', ','),
- (']', 'ü'),
- ('^', '&'),
- ('`', '<'),
- ('{', 'Ğ'),
- ('|', ';'),
- ('}', 'Ü'),
- ('~', '>'),
- ],
- "com.apple.keylayout.Turkish-Standard" => &[
- ('"', 'Ş'),
- ('#', '^'),
- ('&', '\''),
- ('(', ')'),
- (')', '='),
- ('*', '('),
- (',', '.'),
- ('.', ','),
- (':', 'Ç'),
- (';', 'ç'),
- ('<', ':'),
- ('=', '*'),
- ('>', ';'),
- ('@', '"'),
- ('[', 'ğ'),
- ('\'', 'ş'),
- ('\\', 'ü'),
- (']', 'ı'),
- ('^', '&'),
- ('`', 'ö'),
- ('{', 'Ğ'),
- ('|', 'Ü'),
- ('}', 'I'),
- ('~', 'Ö'),
- ],
- "com.apple.keylayout.Turkmen" => &[
- ('C', 'Ç'),
- ('Q', 'Ä'),
- ('V', 'Ý'),
- ('X', 'Ü'),
- ('[', 'ň'),
- ('\\', 'ş'),
- (']', 'ö'),
- ('^', '№'),
- ('`', 'ž'),
- ('c', 'ç'),
- ('q', 'ä'),
- ('v', 'ý'),
- ('x', 'ü'),
- ('{', 'Ň'),
- ('|', 'Ş'),
- ('}', 'Ö'),
- ('~', 'Ž'),
- ],
- "com.apple.keylayout.USInternational-PC" => &[('^', 'ˆ'), ('~', '˜')],
- "com.apple.keylayout.Welsh" => &[('#', '£')],
-
- _ => return None,
- };
-
- Some(HashMap::from_iter(mappings.iter().cloned()))
-}
-
-#[cfg(not(target_os = "macos"))]
-pub fn get_key_equivalents(_layout: &str) -> Option<HashMap<char, char>> {
- None
-}
@@ -3,7 +3,8 @@ use collections::{BTreeMap, HashMap, IndexMap};
use fs::Fs;
use gpui::{
Action, ActionBuildError, App, InvalidKeystrokeError, KEYSTROKE_PARSE_EXPECTED_MESSAGE,
- KeyBinding, KeyBindingContextPredicate, KeyBindingMetaIndex, Keystroke, NoAction, SharedString,
+ KeyBinding, KeyBindingContextPredicate, KeyBindingMetaIndex, KeybindingKeystroke, Keystroke,
+ NoAction, SharedString,
};
use schemars::{JsonSchema, json_schema};
use serde::Deserialize;
@@ -211,9 +212,6 @@ impl KeymapFile {
}
pub fn load(content: &str, cx: &App) -> KeymapFileLoadResult {
- let key_equivalents =
- crate::key_equivalents::get_key_equivalents(cx.keyboard_layout().id());
-
if content.is_empty() {
return KeymapFileLoadResult::Success {
key_bindings: Vec::new(),
@@ -255,12 +253,6 @@ impl KeymapFile {
}
};
- let key_equivalents = if *use_key_equivalents {
- key_equivalents.as_ref()
- } else {
- None
- };
-
let mut section_errors = String::new();
if !unrecognized_fields.is_empty() {
@@ -278,7 +270,7 @@ impl KeymapFile {
keystrokes,
action,
context_predicate.clone(),
- key_equivalents,
+ *use_key_equivalents,
cx,
);
match result {
@@ -336,7 +328,7 @@ impl KeymapFile {
keystrokes: &str,
action: &KeymapAction,
context: Option<Rc<KeyBindingContextPredicate>>,
- key_equivalents: Option<&HashMap<char, char>>,
+ use_key_equivalents: bool,
cx: &App,
) -> std::result::Result<KeyBinding, String> {
let (build_result, action_input_string) = match &action.0 {
@@ -404,8 +396,9 @@ impl KeymapFile {
keystrokes,
action,
context,
- key_equivalents,
+ use_key_equivalents,
action_input_string.map(SharedString::from),
+ cx.keyboard_mapper().as_ref(),
) {
Ok(key_binding) => key_binding,
Err(InvalidKeystrokeError { keystroke }) => {
@@ -607,6 +600,7 @@ impl KeymapFile {
mut operation: KeybindUpdateOperation<'a>,
mut keymap_contents: String,
tab_size: usize,
+ keyboard_mapper: &dyn gpui::PlatformKeyboardMapper,
) -> Result<String> {
match operation {
// if trying to replace a keybinding that is not user-defined, treat it as an add operation
@@ -646,7 +640,7 @@ impl KeymapFile {
.action_value()
.context("Failed to generate target action JSON value")?;
let Some((index, keystrokes_str)) =
- find_binding(&keymap, &target, &target_action_value)
+ find_binding(&keymap, &target, &target_action_value, keyboard_mapper)
else {
anyhow::bail!("Failed to find keybinding to remove");
};
@@ -681,7 +675,7 @@ impl KeymapFile {
.context("Failed to generate source action JSON value")?;
if let Some((index, keystrokes_str)) =
- find_binding(&keymap, &target, &target_action_value)
+ find_binding(&keymap, &target, &target_action_value, keyboard_mapper)
{
if target.context == source.context {
// if we are only changing the keybinding (common case)
@@ -781,7 +775,7 @@ impl KeymapFile {
}
let use_key_equivalents = from.and_then(|from| {
let action_value = from.action_value().context("Failed to serialize action value. `use_key_equivalents` on new keybinding may be incorrect.").log_err()?;
- let (index, _) = find_binding(&keymap, &from, &action_value)?;
+ let (index, _) = find_binding(&keymap, &from, &action_value, keyboard_mapper)?;
Some(keymap.0[index].use_key_equivalents)
}).unwrap_or(false);
if use_key_equivalents {
@@ -808,6 +802,7 @@ impl KeymapFile {
keymap: &'b KeymapFile,
target: &KeybindUpdateTarget<'a>,
target_action_value: &Value,
+ keyboard_mapper: &dyn gpui::PlatformKeyboardMapper,
) -> Option<(usize, &'b str)> {
let target_context_parsed =
KeyBindingContextPredicate::parse(target.context.unwrap_or("")).ok();
@@ -823,8 +818,15 @@ impl KeymapFile {
for (keystrokes_str, action) in bindings {
let Ok(keystrokes) = keystrokes_str
.split_whitespace()
- .map(Keystroke::parse)
- .collect::<Result<Vec<_>, _>>()
+ .map(|source| {
+ let keystroke = Keystroke::parse(source)?;
+ Ok(KeybindingKeystroke::new_with_mapper(
+ keystroke,
+ false,
+ keyboard_mapper,
+ ))
+ })
+ .collect::<Result<Vec<_>, InvalidKeystrokeError>>()
else {
continue;
};
@@ -832,7 +834,7 @@ impl KeymapFile {
|| !keystrokes
.iter()
.zip(target.keystrokes)
- .all(|(a, b)| a.should_match(b))
+ .all(|(a, b)| a.inner().should_match(b))
{
continue;
}
@@ -847,7 +849,7 @@ impl KeymapFile {
}
}
-#[derive(Clone)]
+#[derive(Clone, Debug)]
pub enum KeybindUpdateOperation<'a> {
Replace {
/// Describes the keybind to create
@@ -916,7 +918,7 @@ impl<'a> KeybindUpdateOperation<'a> {
#[derive(Debug, Clone)]
pub struct KeybindUpdateTarget<'a> {
pub context: Option<&'a str>,
- pub keystrokes: &'a [Keystroke],
+ pub keystrokes: &'a [KeybindingKeystroke],
pub action_name: &'a str,
pub action_arguments: Option<&'a str>,
}
@@ -941,6 +943,9 @@ impl<'a> KeybindUpdateTarget<'a> {
fn keystrokes_unparsed(&self) -> String {
let mut keystrokes = String::with_capacity(self.keystrokes.len() * 8);
for keystroke in self.keystrokes {
+ // The reason use `keystroke.unparse()` instead of `keystroke.inner.unparse()`
+ // here is that, we want the user to use `ctrl-shift-4` instead of `ctrl-$`
+ // by default on Windows.
keystrokes.push_str(&keystroke.unparse());
keystrokes.push(' ');
}
@@ -959,7 +964,7 @@ impl<'a> KeybindUpdateTarget<'a> {
}
}
-#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum KeybindSource {
User,
Vim,
@@ -1020,7 +1025,7 @@ impl From<KeybindSource> for KeyBindingMetaIndex {
#[cfg(test)]
mod tests {
- use gpui::Keystroke;
+ use gpui::{DummyKeyboardMapper, KeybindingKeystroke, Keystroke};
use unindent::Unindent;
use crate::{
@@ -1049,16 +1054,27 @@ mod tests {
operation: KeybindUpdateOperation,
expected: impl ToString,
) {
- let result = KeymapFile::update_keybinding(operation, input.to_string(), 4)
- .expect("Update succeeded");
+ let result = KeymapFile::update_keybinding(
+ operation,
+ input.to_string(),
+ 4,
+ &gpui::DummyKeyboardMapper,
+ )
+ .expect("Update succeeded");
pretty_assertions::assert_eq!(expected.to_string(), result);
}
#[track_caller]
- fn parse_keystrokes(keystrokes: &str) -> Vec<Keystroke> {
+ fn parse_keystrokes(keystrokes: &str) -> Vec<KeybindingKeystroke> {
keystrokes
.split(' ')
- .map(|s| Keystroke::parse(s).expect("Keystrokes valid"))
+ .map(|s| {
+ KeybindingKeystroke::new_with_mapper(
+ Keystroke::parse(s).expect("Keystrokes valid"),
+ false,
+ &DummyKeyboardMapper,
+ )
+ })
.collect()
}
@@ -1,6 +1,5 @@
mod base_keymap_setting;
mod editable_setting_control;
-mod key_equivalents;
mod keymap_file;
mod settings_file;
mod settings_json;
@@ -14,7 +13,6 @@ use util::asset_str;
pub use base_keymap_setting::*;
pub use editable_setting_control::*;
-pub use key_equivalents::*;
pub use keymap_file::{
KeyBindingValidator, KeyBindingValidatorRegistration, KeybindSource, KeybindUpdateOperation,
KeybindUpdateTarget, KeymapFile, KeymapFileLoadResult,
@@ -32,7 +30,7 @@ pub struct ActiveSettingsProfileName(pub String);
impl Global for ActiveSettingsProfileName {}
-#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
+#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord, serde::Serialize)]
pub struct WorktreeId(usize);
impl From<WorktreeId> for usize {
@@ -89,7 +87,10 @@ pub fn default_settings() -> Cow<'static, str> {
#[cfg(target_os = "macos")]
pub const DEFAULT_KEYMAP_PATH: &str = "keymaps/default-macos.json";
-#[cfg(not(target_os = "macos"))]
+#[cfg(target_os = "windows")]
+pub const DEFAULT_KEYMAP_PATH: &str = "keymaps/default-windows.json";
+
+#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub const DEFAULT_KEYMAP_PATH: &str = "keymaps/default-linux.json";
pub fn default_keymap() -> Cow<'static, str> {
@@ -14,9 +14,9 @@ use gpui::{
Action, AppContext as _, AsyncApp, Axis, ClickEvent, Context, DismissEvent, Entity,
EventEmitter, FocusHandle, Focusable, Global, IsZero,
KeyBindingContextPredicate::{And, Descendant, Equal, Identifier, Not, NotEqual, Or},
- KeyContext, Keystroke, MouseButton, Point, ScrollStrategy, ScrollWheelEvent, Stateful,
- StyledText, Subscription, Task, TextStyleRefinement, WeakEntity, actions, anchored, deferred,
- div,
+ KeyContext, KeybindingKeystroke, MouseButton, PlatformKeyboardMapper, Point, ScrollStrategy,
+ ScrollWheelEvent, Stateful, StyledText, Subscription, Task, TextStyleRefinement, WeakEntity,
+ actions, anchored, deferred, div,
};
use language::{Language, LanguageConfig, ToOffset as _};
use notifications::status_toast::{StatusToast, ToastIcon};
@@ -174,7 +174,7 @@ impl FilterState {
#[derive(Debug, Default, PartialEq, Eq, Clone, Hash)]
struct ActionMapping {
- keystrokes: Vec<Keystroke>,
+ keystrokes: Vec<KeybindingKeystroke>,
context: Option<SharedString>,
}
@@ -236,7 +236,7 @@ struct ConflictState {
}
type ConflictKeybindMapping = HashMap<
- Vec<Keystroke>,
+ Vec<KeybindingKeystroke>,
Vec<(
Option<gpui::KeyBindingContextPredicate>,
Vec<ConflictOrigin>,
@@ -414,12 +414,14 @@ impl Focusable for KeymapEditor {
}
}
/// Helper function to check if two keystroke sequences match exactly
-fn keystrokes_match_exactly(keystrokes1: &[Keystroke], keystrokes2: &[Keystroke]) -> bool {
+fn keystrokes_match_exactly(
+ keystrokes1: &[KeybindingKeystroke],
+ keystrokes2: &[KeybindingKeystroke],
+) -> bool {
keystrokes1.len() == keystrokes2.len()
- && keystrokes1
- .iter()
- .zip(keystrokes2)
- .all(|(k1, k2)| k1.key == k2.key && k1.modifiers == k2.modifiers)
+ && keystrokes1.iter().zip(keystrokes2).all(|(k1, k2)| {
+ k1.inner().key == k2.inner().key && k1.inner().modifiers == k2.inner().modifiers
+ })
}
impl KeymapEditor {
@@ -509,7 +511,7 @@ impl KeymapEditor {
self.filter_editor.read(cx).text(cx)
}
- fn current_keystroke_query(&self, cx: &App) -> Vec<Keystroke> {
+ fn current_keystroke_query(&self, cx: &App) -> Vec<KeybindingKeystroke> {
match self.search_mode {
SearchMode::KeyStroke { .. } => self.keystroke_editor.read(cx).keystrokes().to_vec(),
SearchMode::Normal => Default::default(),
@@ -530,7 +532,7 @@ impl KeymapEditor {
let keystroke_query = keystroke_query
.into_iter()
- .map(|keystroke| keystroke.unparse())
+ .map(|keystroke| keystroke.inner().unparse())
.collect::<Vec<String>>()
.join(" ");
@@ -554,7 +556,7 @@ impl KeymapEditor {
async fn update_matches(
this: WeakEntity<Self>,
action_query: String,
- keystroke_query: Vec<Keystroke>,
+ keystroke_query: Vec<KeybindingKeystroke>,
cx: &mut AsyncApp,
) -> anyhow::Result<()> {
let action_query = command_palette::normalize_action_query(&action_query);
@@ -603,13 +605,15 @@ impl KeymapEditor {
{
let query = &keystroke_query[query_cursor];
let keystroke = &keystrokes[keystroke_cursor];
- let matches =
- query.modifiers.is_subset_of(&keystroke.modifiers)
- && ((query.key.is_empty()
- || query.key == keystroke.key)
- && query.key_char.as_ref().is_none_or(
- |q_kc| q_kc == &keystroke.key,
- ));
+ let matches = query
+ .inner()
+ .modifiers
+ .is_subset_of(&keystroke.inner().modifiers)
+ && ((query.inner().key.is_empty()
+ || query.inner().key == keystroke.inner().key)
+ && query.inner().key_char.as_ref().is_none_or(
+ |q_kc| q_kc == &keystroke.inner().key,
+ ));
if matches {
found_count += 1;
query_cursor += 1;
@@ -678,7 +682,7 @@ impl KeymapEditor {
.map(KeybindSource::from_meta)
.unwrap_or(KeybindSource::Unknown);
- let keystroke_text = ui::text_for_keystrokes(key_binding.keystrokes(), cx);
+ let keystroke_text = ui::text_for_keybinding_keystrokes(key_binding.keystrokes(), cx);
let ui_key_binding = ui::KeyBinding::new_from_gpui(key_binding.clone(), cx)
.vim_mode(source == KeybindSource::Vim);
@@ -1202,8 +1206,11 @@ impl KeymapEditor {
.read(cx)
.get_scrollbar_offset(Axis::Vertical),
));
- cx.spawn(async move |_, _| remove_keybinding(to_remove, &fs, tab_size).await)
- .detach_and_notify_err(window, cx);
+ let keyboard_mapper = cx.keyboard_mapper().clone();
+ cx.spawn(async move |_, _| {
+ remove_keybinding(to_remove, &fs, tab_size, keyboard_mapper.as_ref()).await
+ })
+ .detach_and_notify_err(window, cx);
}
fn copy_context_to_clipboard(
@@ -1422,7 +1429,7 @@ impl ProcessedBinding {
.map(|keybind| keybind.get_action_mapping())
}
- fn keystrokes(&self) -> Option<&[Keystroke]> {
+ fn keystrokes(&self) -> Option<&[KeybindingKeystroke]> {
self.ui_key_binding()
.map(|binding| binding.keystrokes.as_slice())
}
@@ -2220,7 +2227,7 @@ impl KeybindingEditorModal {
Ok(action_arguments)
}
- fn validate_keystrokes(&self, cx: &App) -> anyhow::Result<Vec<Keystroke>> {
+ fn validate_keystrokes(&self, cx: &App) -> anyhow::Result<Vec<KeybindingKeystroke>> {
let new_keystrokes = self
.keybind_editor
.read_with(cx, |editor, _| editor.keystrokes().to_vec());
@@ -2249,12 +2256,10 @@ impl KeybindingEditorModal {
let fs = self.fs.clone();
let tab_size = cx.global::<settings::SettingsStore>().json_tab_size();
- let new_keystrokes = self
- .validate_keystrokes(cx)
- .map_err(InputError::error)?
- .into_iter()
- .map(remove_key_char)
- .collect::<Vec<_>>();
+ let mut new_keystrokes = self.validate_keystrokes(cx).map_err(InputError::error)?;
+ new_keystrokes
+ .iter_mut()
+ .for_each(|ks| ks.remove_key_char());
let new_context = self.validate_context(cx).map_err(InputError::error)?;
let new_action_args = self
@@ -2316,6 +2321,7 @@ impl KeybindingEditorModal {
}).unwrap_or(Ok(()))?;
let create = self.creating;
+ let keyboard_mapper = cx.keyboard_mapper().clone();
cx.spawn(async move |this, cx| {
let action_name = existing_keybind.action().name;
@@ -2328,6 +2334,7 @@ impl KeybindingEditorModal {
new_action_args.as_deref(),
&fs,
tab_size,
+ keyboard_mapper.as_ref(),
)
.await
{
@@ -2445,14 +2452,6 @@ impl KeybindingEditorModal {
}
}
-fn remove_key_char(Keystroke { modifiers, key, .. }: Keystroke) -> Keystroke {
- Keystroke {
- modifiers,
- key,
- ..Default::default()
- }
-}
-
impl Render for KeybindingEditorModal {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme().colors();
@@ -2992,6 +2991,7 @@ async fn save_keybinding_update(
new_args: Option<&str>,
fs: &Arc<dyn Fs>,
tab_size: usize,
+ keyboard_mapper: &dyn PlatformKeyboardMapper,
) -> anyhow::Result<()> {
let keymap_contents = settings::KeymapFile::load_keymap_file(fs)
.await
@@ -3034,9 +3034,13 @@ async fn save_keybinding_update(
let (new_keybinding, removed_keybinding, source) = operation.generate_telemetry();
- let updated_keymap_contents =
- settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size)
- .map_err(|err| anyhow::anyhow!("Could not save updated keybinding: {}", err))?;
+ let updated_keymap_contents = settings::KeymapFile::update_keybinding(
+ operation,
+ keymap_contents,
+ tab_size,
+ keyboard_mapper,
+ )
+ .map_err(|err| anyhow::anyhow!("Could not save updated keybinding: {}", err))?;
fs.write(
paths::keymap_file().as_path(),
updated_keymap_contents.as_bytes(),
@@ -3057,6 +3061,7 @@ async fn remove_keybinding(
existing: ProcessedBinding,
fs: &Arc<dyn Fs>,
tab_size: usize,
+ keyboard_mapper: &dyn PlatformKeyboardMapper,
) -> anyhow::Result<()> {
let Some(keystrokes) = existing.keystrokes() else {
anyhow::bail!("Cannot remove a keybinding that does not exist");
@@ -3080,9 +3085,13 @@ async fn remove_keybinding(
};
let (new_keybinding, removed_keybinding, source) = operation.generate_telemetry();
- let updated_keymap_contents =
- settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size)
- .context("Failed to update keybinding")?;
+ let updated_keymap_contents = settings::KeymapFile::update_keybinding(
+ operation,
+ keymap_contents,
+ tab_size,
+ keyboard_mapper,
+ )
+ .context("Failed to update keybinding")?;
fs.write(
paths::keymap_file().as_path(),
updated_keymap_contents.as_bytes(),
@@ -3348,12 +3357,15 @@ impl SerializableItem for KeymapEditor {
}
mod persistence {
- use db::{define_connection, query, sqlez_macros::sql};
+ use db::{query, sqlez::domain::Domain, sqlez_macros::sql};
use workspace::WorkspaceDb;
- define_connection! {
- pub static ref KEYBINDING_EDITORS: KeybindingEditorDb<WorkspaceDb> =
- &[sql!(
+ pub struct KeybindingEditorDb(db::sqlez::thread_safe_connection::ThreadSafeConnection);
+
+ impl Domain for KeybindingEditorDb {
+ const NAME: &str = stringify!(KeybindingEditorDb);
+
+ const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE keybinding_editors (
workspace_id INTEGER,
item_id INTEGER UNIQUE,
@@ -3362,9 +3374,11 @@ mod persistence {
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;
- )];
+ )];
}
+ db::static_connection!(KEYBINDING_EDITORS, KeybindingEditorDb, [WorkspaceDb]);
+
impl KeybindingEditorDb {
query! {
pub async fn save_keybinding_editor(
@@ -1,6 +1,6 @@
use gpui::{
Animation, AnimationExt, Context, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext,
- Keystroke, Modifiers, ModifiersChangedEvent, Subscription, Task, actions,
+ KeybindingKeystroke, Keystroke, Modifiers, ModifiersChangedEvent, Subscription, Task, actions,
};
use ui::{
ActiveTheme as _, Color, IconButton, IconButtonShape, IconName, IconSize, Label, LabelSize,
@@ -42,8 +42,8 @@ impl PartialEq for CloseKeystrokeResult {
}
pub struct KeystrokeInput {
- keystrokes: Vec<Keystroke>,
- placeholder_keystrokes: Option<Vec<Keystroke>>,
+ keystrokes: Vec<KeybindingKeystroke>,
+ placeholder_keystrokes: Option<Vec<KeybindingKeystroke>>,
outer_focus_handle: FocusHandle,
inner_focus_handle: FocusHandle,
intercept_subscription: Option<Subscription>,
@@ -70,7 +70,7 @@ impl KeystrokeInput {
const KEYSTROKE_COUNT_MAX: usize = 3;
pub fn new(
- placeholder_keystrokes: Option<Vec<Keystroke>>,
+ placeholder_keystrokes: Option<Vec<KeybindingKeystroke>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -97,7 +97,7 @@ impl KeystrokeInput {
}
}
- pub fn set_keystrokes(&mut self, keystrokes: Vec<Keystroke>, cx: &mut Context<Self>) {
+ pub fn set_keystrokes(&mut self, keystrokes: Vec<KeybindingKeystroke>, cx: &mut Context<Self>) {
self.keystrokes = keystrokes;
self.keystrokes_changed(cx);
}
@@ -106,7 +106,7 @@ impl KeystrokeInput {
self.search = search;
}
- pub fn keystrokes(&self) -> &[Keystroke] {
+ pub fn keystrokes(&self) -> &[KeybindingKeystroke] {
if let Some(placeholders) = self.placeholder_keystrokes.as_ref()
&& self.keystrokes.is_empty()
{
@@ -116,19 +116,19 @@ impl KeystrokeInput {
&& self
.keystrokes
.last()
- .is_some_and(|last| last.key.is_empty())
+ .is_some_and(|last| last.key().is_empty())
{
return &self.keystrokes[..self.keystrokes.len() - 1];
}
&self.keystrokes
}
- fn dummy(modifiers: Modifiers) -> Keystroke {
- Keystroke {
+ fn dummy(modifiers: Modifiers) -> KeybindingKeystroke {
+ KeybindingKeystroke::from_keystroke(Keystroke {
modifiers,
key: "".to_string(),
key_char: None,
- }
+ })
}
fn keystrokes_changed(&self, cx: &mut Context<Self>) {
@@ -254,7 +254,7 @@ impl KeystrokeInput {
self.keystrokes_changed(cx);
if let Some(last) = self.keystrokes.last_mut()
- && last.key.is_empty()
+ && last.key().is_empty()
&& keystrokes_len <= Self::KEYSTROKE_COUNT_MAX
{
if !self.search && !event.modifiers.modified() {
@@ -263,13 +263,14 @@ impl KeystrokeInput {
}
if self.search {
if self.previous_modifiers.modified() {
- last.modifiers |= event.modifiers;
+ let modifiers = *last.modifiers() | event.modifiers;
+ last.set_modifiers(modifiers);
} else {
self.keystrokes.push(Self::dummy(event.modifiers));
}
self.previous_modifiers |= event.modifiers;
} else {
- last.modifiers = event.modifiers;
+ last.set_modifiers(event.modifiers);
return;
}
} else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX {
@@ -297,14 +298,15 @@ impl KeystrokeInput {
return;
}
- let mut keystroke = keystroke.clone();
+ let keystroke = KeybindingKeystroke::new_with_mapper(
+ keystroke.clone(),
+ false,
+ cx.keyboard_mapper().as_ref(),
+ );
if let Some(last) = self.keystrokes.last()
- && last.key.is_empty()
+ && last.key().is_empty()
&& (!self.search || self.previous_modifiers.modified())
{
- let key = keystroke.key.clone();
- keystroke = last.clone();
- keystroke.key = key;
self.keystrokes.pop();
}
@@ -320,15 +322,19 @@ impl KeystrokeInput {
return;
}
- self.keystrokes.push(keystroke.clone());
+ self.keystrokes.push(keystroke);
self.keystrokes_changed(cx);
+ // The reason we use the real modifiers from the window instead of the keystroke's modifiers
+ // is that for keystrokes like `ctrl-$` the modifiers reported by keystroke is `ctrl` which
+ // is wrong, it should be `ctrl-shift`. The window's modifiers are always correct.
+ let real_modifiers = window.modifiers();
if self.search {
- self.previous_modifiers = keystroke.modifiers;
+ self.previous_modifiers = real_modifiers;
return;
}
- if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX && keystroke.modifiers.modified() {
- self.keystrokes.push(Self::dummy(keystroke.modifiers));
+ if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX && real_modifiers.modified() {
+ self.keystrokes.push(Self::dummy(real_modifiers));
}
}
@@ -364,7 +370,7 @@ impl KeystrokeInput {
&self.keystrokes
};
keystrokes.iter().map(move |keystroke| {
- h_flex().children(ui::render_keystroke(
+ h_flex().children(ui::render_keybinding_keystroke(
keystroke,
Some(Color::Default),
Some(rems(0.875).into()),
@@ -706,8 +712,11 @@ mod tests {
// Combine current modifiers with keystroke modifiers
keystroke.modifiers |= self.current_modifiers;
+ let real_modifiers = keystroke.modifiers;
+ keystroke = to_gpui_keystroke(keystroke);
self.update_input(|input, window, cx| {
+ window.set_modifiers(real_modifiers);
input.handle_keystroke(&keystroke, window, cx);
});
@@ -735,6 +744,7 @@ mod tests {
};
self.update_input(|input, window, cx| {
+ window.set_modifiers(new_modifiers);
input.on_modifiers_changed(&event, window, cx);
});
@@ -809,9 +819,13 @@ mod tests {
/// Verifies that the keystrokes match the expected strings
#[track_caller]
pub fn expect_keystrokes(&mut self, expected: &[&str]) -> &mut Self {
- let actual = self
- .input
- .read_with(&self.cx, |input, _| input.keystrokes.clone());
+ let actual: Vec<Keystroke> = self.input.read_with(&self.cx, |input, _| {
+ input
+ .keystrokes
+ .iter()
+ .map(|keystroke| keystroke.inner().clone())
+ .collect()
+ });
Self::expect_keystrokes_equal(&actual, expected);
self
}
@@ -938,8 +952,102 @@ mod tests {
}
}
+ /// For GPUI, when you press `ctrl-shift-2`, it produces `ctrl-@` without the shift modifier.
+ fn to_gpui_keystroke(mut keystroke: Keystroke) -> Keystroke {
+ if keystroke.modifiers.shift {
+ match keystroke.key.as_str() {
+ "`" => {
+ keystroke.key = "~".into();
+ keystroke.modifiers.shift = false;
+ }
+ "1" => {
+ keystroke.key = "!".into();
+ keystroke.modifiers.shift = false;
+ }
+ "2" => {
+ keystroke.key = "@".into();
+ keystroke.modifiers.shift = false;
+ }
+ "3" => {
+ keystroke.key = "#".into();
+ keystroke.modifiers.shift = false;
+ }
+ "4" => {
+ keystroke.key = "$".into();
+ keystroke.modifiers.shift = false;
+ }
+ "5" => {
+ keystroke.key = "%".into();
+ keystroke.modifiers.shift = false;
+ }
+ "6" => {
+ keystroke.key = "^".into();
+ keystroke.modifiers.shift = false;
+ }
+ "7" => {
+ keystroke.key = "&".into();
+ keystroke.modifiers.shift = false;
+ }
+ "8" => {
+ keystroke.key = "*".into();
+ keystroke.modifiers.shift = false;
+ }
+ "9" => {
+ keystroke.key = "(".into();
+ keystroke.modifiers.shift = false;
+ }
+ "0" => {
+ keystroke.key = ")".into();
+ keystroke.modifiers.shift = false;
+ }
+ "-" => {
+ keystroke.key = "_".into();
+ keystroke.modifiers.shift = false;
+ }
+ "=" => {
+ keystroke.key = "+".into();
+ keystroke.modifiers.shift = false;
+ }
+ "[" => {
+ keystroke.key = "{".into();
+ keystroke.modifiers.shift = false;
+ }
+ "]" => {
+ keystroke.key = "}".into();
+ keystroke.modifiers.shift = false;
+ }
+ "\\" => {
+ keystroke.key = "|".into();
+ keystroke.modifiers.shift = false;
+ }
+ ";" => {
+ keystroke.key = ":".into();
+ keystroke.modifiers.shift = false;
+ }
+ "'" => {
+ keystroke.key = "\"".into();
+ keystroke.modifiers.shift = false;
+ }
+ "," => {
+ keystroke.key = "<".into();
+ keystroke.modifiers.shift = false;
+ }
+ "." => {
+ keystroke.key = ">".into();
+ keystroke.modifiers.shift = false;
+ }
+ "/" => {
+ keystroke.key = "?".into();
+ keystroke.modifiers.shift = false;
+ }
+ _ => {}
+ }
+ }
+ keystroke
+ }
+
struct KeystrokeUpdateTracker {
- initial_keystrokes: Vec<Keystroke>,
+ initial_keystrokes: Vec<KeybindingKeystroke>,
_subscription: Subscription,
input: Entity<KeystrokeInput>,
received_keystrokes_updated: bool,
@@ -983,8 +1091,8 @@ mod tests {
);
}
- fn keystrokes_str(ks: &[Keystroke]) -> String {
- ks.iter().map(|ks| ks.unparse()).join(" ")
+ fn keystrokes_str(ks: &[KeybindingKeystroke]) -> String {
+ ks.iter().map(|ks| ks.inner().unparse()).join(" ")
}
}
}
@@ -1041,7 +1149,15 @@ mod tests {
.send_events(&["+cmd", "shift-f", "-cmd"])
// In search mode, when completing a modifier-only keystroke with a key,
// only the original modifiers are preserved, not the keystroke's modifiers
- .expect_keystrokes(&["cmd-f"]);
+ //
+ // Update:
+ // This behavior was changed to preserve all modifiers in search mode, this is now reflected in the expected keystrokes.
+ // Specifically, considering the sequence: `+cmd +shift -shift 2`, we expect it to produce the same result as `+cmd +shift 2`
+ // which is `cmd-@`. But in the case of `+cmd +shift -shift 2`, the keystroke we receive is `cmd-2`, which means that
+ // we need to dynamically map the key from `2` to `@` when the shift modifier is not present, which is not possible.
+ // Therefore, we now preserve all modifiers in search mode to ensure consistent behavior.
+ // And also, VSCode seems to preserve all modifiers in search mode as well.
+ .expect_keystrokes(&["cmd-shift-f"]);
}
#[gpui::test]
@@ -1218,7 +1334,7 @@ mod tests {
.await
.with_search_mode(true)
.send_events(&["+ctrl", "+shift", "-shift", "a", "-ctrl"])
- .expect_keystrokes(&["ctrl-shift-a"]);
+ .expect_keystrokes(&["ctrl-a"]);
}
#[gpui::test]
@@ -1326,7 +1442,7 @@ mod tests {
.await
.with_search_mode(true)
.send_events(&["+ctrl+alt", "-ctrl", "j"])
- .expect_keystrokes(&["ctrl-alt-j"]);
+ .expect_keystrokes(&["alt-j"]);
}
#[gpui::test]
@@ -1348,11 +1464,11 @@ mod tests {
.send_events(&["+ctrl+alt", "-ctrl", "+shift"])
.expect_keystrokes(&["ctrl-shift-alt-"])
.send_keystroke("j")
- .expect_keystrokes(&["ctrl-shift-alt-j"])
+ .expect_keystrokes(&["shift-alt-j"])
.send_keystroke("i")
- .expect_keystrokes(&["ctrl-shift-alt-j", "shift-alt-i"])
+ .expect_keystrokes(&["shift-alt-j", "shift-alt-i"])
.send_events(&["-shift-alt", "+cmd"])
- .expect_keystrokes(&["ctrl-shift-alt-j", "shift-alt-i", "cmd-"]);
+ .expect_keystrokes(&["shift-alt-j", "shift-alt-i", "cmd-"]);
}
#[gpui::test]
@@ -1385,4 +1501,13 @@ mod tests {
.send_events(&["+ctrl", "-ctrl", "+alt", "-alt", "+shift", "-shift"])
.expect_empty();
}
+
+ #[gpui::test]
+ async fn test_not_search_shifted_keys(cx: &mut TestAppContext) {
+ init_test(cx)
+ .await
+ .with_search_mode(false)
+ .send_events(&["+ctrl", "+shift", "4", "-all"])
+ .expect_keystrokes(&["ctrl-$"]);
+ }
}
@@ -1,8 +1,12 @@
use crate::connection::Connection;
pub trait Domain: 'static {
- fn name() -> &'static str;
- fn migrations() -> &'static [&'static str];
+ const NAME: &str;
+ const MIGRATIONS: &[&str];
+
+ fn should_allow_migration_change(_index: usize, _old: &str, _new: &str) -> bool {
+ false
+ }
}
pub trait Migrator: 'static {
@@ -17,7 +21,11 @@ impl Migrator for () {
impl<D: Domain> Migrator for D {
fn migrate(connection: &Connection) -> anyhow::Result<()> {
- connection.migrate(Self::name(), Self::migrations())
+ connection.migrate(
+ Self::NAME,
+ Self::MIGRATIONS,
+ Self::should_allow_migration_change,
+ )
}
}
@@ -34,7 +34,12 @@ impl Connection {
/// Note: Unlike everything else in SQLez, migrations are run eagerly, without first
/// preparing the SQL statements. This makes it possible to do multi-statement schema
/// updates in a single string without running into prepare errors.
- pub fn migrate(&self, domain: &'static str, migrations: &[&'static str]) -> Result<()> {
+ pub fn migrate(
+ &self,
+ domain: &'static str,
+ migrations: &[&'static str],
+ mut should_allow_migration_change: impl FnMut(usize, &str, &str) -> bool,
+ ) -> Result<()> {
self.with_savepoint("migrating", || {
// Setup the migrations table unconditionally
self.exec(indoc! {"
@@ -65,9 +70,14 @@ impl Connection {
&sqlformat::QueryParams::None,
Default::default(),
);
- if completed_migration == migration {
+ if completed_migration == migration
+ || migration.trim().starts_with("-- ALLOW_MIGRATION_CHANGE")
+ {
// Migration already run. Continue
continue;
+ } else if should_allow_migration_change(index, &completed_migration, &migration)
+ {
+ continue;
} else {
anyhow::bail!(formatdoc! {"
Migration changed for {domain} at step {index}
@@ -108,6 +118,7 @@ mod test {
a TEXT,
b TEXT
)"}],
+ disallow_migration_change,
)
.unwrap();
@@ -136,6 +147,7 @@ mod test {
d TEXT
)"},
],
+ disallow_migration_change,
)
.unwrap();
@@ -214,7 +226,11 @@ mod test {
// Run the migration verifying that the row got dropped
connection
- .migrate("test", &["DELETE FROM test_table"])
+ .migrate(
+ "test",
+ &["DELETE FROM test_table"],
+ disallow_migration_change,
+ )
.unwrap();
assert_eq!(
connection
@@ -232,7 +248,11 @@ mod test {
// Run the same migration again and verify that the table was left unchanged
connection
- .migrate("test", &["DELETE FROM test_table"])
+ .migrate(
+ "test",
+ &["DELETE FROM test_table"],
+ disallow_migration_change,
+ )
.unwrap();
assert_eq!(
connection
@@ -252,27 +272,28 @@ mod test {
.migrate(
"test migration",
&[
- indoc! {"
- CREATE TABLE test (
- col INTEGER
- )"},
- indoc! {"
- INSERT INTO test (col) VALUES (1)"},
+ "CREATE TABLE test (col INTEGER)",
+ "INSERT INTO test (col) VALUES (1)",
],
+ disallow_migration_change,
)
.unwrap();
+ let mut migration_changed = false;
+
// Create another migration with the same domain but different steps
let second_migration_result = connection.migrate(
"test migration",
&[
- indoc! {"
- CREATE TABLE test (
- color INTEGER
- )"},
- indoc! {"
- INSERT INTO test (color) VALUES (1)"},
+ "CREATE TABLE test (color INTEGER )",
+ "INSERT INTO test (color) VALUES (1)",
],
+ |_, old, new| {
+ assert_eq!(old, "CREATE TABLE test (col INTEGER)");
+ assert_eq!(new, "CREATE TABLE test (color INTEGER)");
+ migration_changed = true;
+ false
+ },
);
// Verify new migration returns error when run
@@ -284,7 +305,11 @@ mod test {
let connection = Connection::open_memory(Some("test_create_alter_drop"));
connection
- .migrate("first_migration", &["CREATE TABLE table1(a TEXT) STRICT;"])
+ .migrate(
+ "first_migration",
+ &["CREATE TABLE table1(a TEXT) STRICT;"],
+ disallow_migration_change,
+ )
.unwrap();
connection
@@ -305,6 +330,7 @@ mod test {
ALTER TABLE table2 RENAME TO table1;
"}],
+ disallow_migration_change,
)
.unwrap();
@@ -312,4 +338,8 @@ mod test {
assert_eq!(res, "test text");
}
+
+ fn disallow_migration_change(_: usize, _: &str, _: &str) -> bool {
+ false
+ }
}
@@ -278,12 +278,8 @@ mod test {
enum TestDomain {}
impl Domain for TestDomain {
- fn name() -> &'static str {
- "test"
- }
- fn migrations() -> &'static [&'static str] {
- &["CREATE TABLE test(col1 TEXT, col2 TEXT) STRICT;"]
- }
+ const NAME: &str = "test";
+ const MIGRATIONS: &[&str] = &["CREATE TABLE test(col1 TEXT, col2 TEXT) STRICT;"];
}
for _ in 0..100 {
@@ -312,12 +308,9 @@ mod test {
fn wild_zed_lost_failure() {
enum TestWorkspace {}
impl Domain for TestWorkspace {
- fn name() -> &'static str {
- "workspace"
- }
+ const NAME: &str = "workspace";
- fn migrations() -> &'static [&'static str] {
- &["
+ const MIGRATIONS: &[&str] = &["
CREATE TABLE workspaces(
workspace_id INTEGER PRIMARY KEY,
dock_visible INTEGER, -- Boolean
@@ -336,8 +329,7 @@ mod test {
ON DELETE CASCADE
ON UPDATE CASCADE
) STRICT;
- "]
- }
+ "];
}
let builder =
@@ -268,7 +268,7 @@ impl TabMatch {
.flatten();
let colored_icon = icon.color(git_status_color.unwrap_or_default());
- let most_sever_diagostic_level = if show_diagnostics == ShowDiagnostics::Off {
+ let most_severe_diagnostic_level = if show_diagnostics == ShowDiagnostics::Off {
None
} else {
let buffer_store = project.read(cx).buffer_store().read(cx);
@@ -287,7 +287,7 @@ impl TabMatch {
};
let decorations =
- entry_diagnostic_aware_icon_decoration_and_color(most_sever_diagostic_level)
+ entry_diagnostic_aware_icon_decoration_and_color(most_severe_diagnostic_level)
.filter(|(d, _)| {
*d != IconDecorationKind::Triangle
|| show_diagnostics != ShowDiagnostics::Errors
@@ -360,7 +360,12 @@ impl TabSwitcherDelegate {
.detach();
}
- fn update_all_pane_matches(&mut self, query: String, window: &mut Window, cx: &mut App) {
+ fn update_all_pane_matches(
+ &mut self,
+ query: String,
+ window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
@@ -418,7 +423,7 @@ impl TabSwitcherDelegate {
let selected_item_id = self.selected_item_id();
self.matches = matches;
- self.selected_index = self.compute_selected_index(selected_item_id);
+ self.selected_index = self.compute_selected_index(selected_item_id, window, cx);
}
fn update_matches(
@@ -477,7 +482,7 @@ impl TabSwitcherDelegate {
a_score.cmp(&b_score)
});
- self.selected_index = self.compute_selected_index(selected_item_id);
+ self.selected_index = self.compute_selected_index(selected_item_id, window, cx);
}
fn selected_item_id(&self) -> Option<EntityId> {
@@ -486,7 +491,12 @@ impl TabSwitcherDelegate {
.map(|tab_match| tab_match.item.item_id())
}
- fn compute_selected_index(&mut self, prev_selected_item_id: Option<EntityId>) -> usize {
+ fn compute_selected_index(
+ &mut self,
+ prev_selected_item_id: Option<EntityId>,
+ window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> usize {
if self.matches.is_empty() {
return 0;
}
@@ -508,8 +518,10 @@ impl TabSwitcherDelegate {
return self.matches.len() - 1;
}
+ // This only runs when initially opening the picker
+ // Index 0 is already active, so don't preselect it for switching.
if self.matches.len() > 1 {
- // Index 0 is active, so don't preselect it for switching.
+ self.set_selected_index(1, window, cx);
return 1;
}
@@ -1,3 +1,7 @@
+use std::fmt;
+
+use util::get_system_shell;
+
use crate::Shell;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
@@ -11,9 +15,22 @@ pub enum ShellKind {
Cmd,
}
+impl fmt::Display for ShellKind {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ ShellKind::Posix => write!(f, "sh"),
+ ShellKind::Csh => write!(f, "csh"),
+ ShellKind::Fish => write!(f, "fish"),
+ ShellKind::Powershell => write!(f, "powershell"),
+ ShellKind::Nushell => write!(f, "nu"),
+ ShellKind::Cmd => write!(f, "cmd"),
+ }
+ }
+}
+
impl ShellKind {
pub fn system() -> Self {
- Self::new(&system_shell())
+ Self::new(&get_system_shell())
}
pub fn new(program: &str) -> Self {
@@ -22,12 +39,12 @@ impl ShellKind {
#[cfg(not(windows))]
let (_, program) = program.rsplit_once('/').unwrap_or(("", program));
if program == "powershell"
- || program == "powershell.exe"
+ || program.ends_with("powershell.exe")
|| program == "pwsh"
- || program == "pwsh.exe"
+ || program.ends_with("pwsh.exe")
{
ShellKind::Powershell
- } else if program == "cmd" || program == "cmd.exe" {
+ } else if program == "cmd" || program.ends_with("cmd.exe") {
ShellKind::Cmd
} else if program == "nu" {
ShellKind::Nushell
@@ -36,7 +53,7 @@ impl ShellKind {
} else if program == "csh" {
ShellKind::Csh
} else {
- // Someother shell detected, the user might install and use a
+ // Some other shell detected, the user might install and use a
// unix-like shell.
ShellKind::Posix
}
@@ -178,18 +195,6 @@ impl ShellKind {
}
}
-fn system_shell() -> String {
- if cfg!(target_os = "windows") {
- // `alacritty_terminal` uses this as default on Windows. See:
- // https://github.com/alacritty/alacritty/blob/0d4ab7bca43213d96ddfe40048fc0f922543c6f8/alacritty_terminal/src/tty/windows/mod.rs#L130
- // We could use `util::get_windows_system_shell()` here, but we are running tasks here, so leave it to `powershell.exe`
- // should be okay.
- "powershell.exe".to_string()
- } else {
- std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
- }
-}
-
/// ShellBuilder is used to turn a user-requested task into a
/// program that can be executed by the shell.
pub struct ShellBuilder {
@@ -203,14 +208,15 @@ pub struct ShellBuilder {
impl ShellBuilder {
/// Create a new ShellBuilder as configured.
pub fn new(remote_system_shell: Option<&str>, shell: &Shell) -> Self {
- let (program, args) = match shell {
- Shell::System => match remote_system_shell {
- Some(remote_shell) => (remote_shell.to_string(), Vec::new()),
- None => (system_shell(), Vec::new()),
+ let (program, args) = match remote_system_shell {
+ Some(program) => (program.to_string(), Vec::new()),
+ None => match shell {
+ Shell::System => (get_system_shell(), Vec::new()),
+ Shell::Program(shell) => (shell.clone(), Vec::new()),
+ Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()),
},
- Shell::Program(shell) => (shell.clone(), Vec::new()),
- Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()),
};
+
let kind = ShellKind::new(&program);
Self {
program,
@@ -344,7 +344,6 @@ pub struct TerminalBuilder {
impl TerminalBuilder {
pub fn new(
working_directory: Option<PathBuf>,
- python_venv_directory: Option<PathBuf>,
task: Option<TaskState>,
shell: Shell,
mut env: HashMap<String, String>,
@@ -353,8 +352,9 @@ impl TerminalBuilder {
max_scroll_history_lines: Option<usize>,
is_ssh_terminal: bool,
window_id: u64,
- completion_tx: Sender<Option<ExitStatus>>,
+ completion_tx: Option<Sender<Option<ExitStatus>>>,
cx: &App,
+ activation_script: Option<String>,
) -> Result<TerminalBuilder> {
// If the parent environment doesn't have a locale set
// (As is the case when launched from a .app on MacOS),
@@ -428,13 +428,10 @@ impl TerminalBuilder {
.clone()
.or_else(|| Some(home_dir().to_path_buf())),
drain_on_exit: true,
- env: env.into_iter().collect(),
+ env: env.clone().into_iter().collect(),
}
};
- // Setup Alacritty's env, which modifies the current process's environment
- alacritty_terminal::tty::setup_env();
-
let default_cursor_style = AlacCursorStyle::from(cursor_shape);
let scrolling_history = if task.is_some() {
// Tasks like `cargo build --all` may produce a lot of output, ergo allow maximum scrolling.
@@ -517,11 +514,19 @@ impl TerminalBuilder {
hyperlink_regex_searches: RegexSearches::new(),
vi_mode_enabled: false,
is_ssh_terminal,
- python_venv_directory,
last_mouse_move_time: Instant::now(),
last_hyperlink_search_position: None,
#[cfg(windows)]
shell_program,
+ activation_script,
+ template: CopyTemplate {
+ shell,
+ env,
+ cursor_shape,
+ alternate_scroll,
+ max_scroll_history_lines,
+ window_id,
+ },
};
Ok(TerminalBuilder {
@@ -683,7 +688,7 @@ pub enum SelectionPhase {
pub struct Terminal {
pty_tx: Notifier,
- completion_tx: Sender<Option<ExitStatus>>,
+ completion_tx: Option<Sender<Option<ExitStatus>>>,
term: Arc<FairMutex<Term<ZedListener>>>,
term_config: Config,
events: VecDeque<InternalEvent>,
@@ -695,7 +700,6 @@ pub struct Terminal {
pub breadcrumb_text: String,
pub pty_info: PtyProcessInfo,
title_override: Option<SharedString>,
- pub python_venv_directory: Option<PathBuf>,
scroll_px: Pixels,
next_link_id: usize,
selection_phase: SelectionPhase,
@@ -707,6 +711,17 @@ pub struct Terminal {
last_hyperlink_search_position: Option<Point<Pixels>>,
#[cfg(windows)]
shell_program: Option<String>,
+ template: CopyTemplate,
+ activation_script: Option<String>,
+}
+
+struct CopyTemplate {
+ shell: Shell,
+ env: HashMap<String, String>,
+ cursor_shape: CursorShape,
+ alternate_scroll: AlternateScroll,
+ max_scroll_history_lines: Option<usize>,
+ window_id: u64,
}
pub struct TaskState {
@@ -1895,7 +1910,9 @@ impl Terminal {
}
});
- self.completion_tx.try_send(e).ok();
+ if let Some(tx) = &self.completion_tx {
+ tx.try_send(e).ok();
+ }
let task = match &mut self.task {
Some(task) => task,
None => {
@@ -1950,6 +1967,28 @@ impl Terminal {
pub fn vi_mode_enabled(&self) -> bool {
self.vi_mode_enabled
}
+
+ pub fn clone_builder(
+ &self,
+ cx: &App,
+ cwd: impl FnOnce() -> Option<PathBuf>,
+ ) -> Result<TerminalBuilder> {
+ let working_directory = self.working_directory().or_else(cwd);
+ TerminalBuilder::new(
+ working_directory,
+ None,
+ self.template.shell.clone(),
+ self.template.env.clone(),
+ self.template.cursor_shape,
+ self.template.alternate_scroll,
+ self.template.max_scroll_history_lines,
+ self.is_ssh_terminal,
+ self.template.window_id,
+ None,
+ cx,
+ self.activation_script.clone(),
+ )
+ }
}
// Helper function to convert a grid row to a string
@@ -2164,7 +2203,6 @@ mod tests {
let (completion_tx, completion_rx) = smol::channel::unbounded();
let terminal = cx.new(|cx| {
TerminalBuilder::new(
- None,
None,
None,
task::Shell::WithArguments {
@@ -2178,8 +2216,9 @@ mod tests {
None,
false,
0,
- completion_tx,
+ Some(completion_tx),
cx,
+ None,
)
.unwrap()
.subscribe(cx)
@@ -3,13 +3,17 @@ use async_recursion::async_recursion;
use collections::HashSet;
use futures::{StreamExt as _, stream::FuturesUnordered};
use gpui::{AppContext as _, AsyncWindowContext, Axis, Entity, Task, WeakEntity};
-use project::{Project, terminals::TerminalKind};
+use project::Project;
use serde::{Deserialize, Serialize};
-use std::path::{Path, PathBuf};
+use std::path::PathBuf;
use ui::{App, Context, Pixels, Window};
use util::ResultExt as _;
-use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql};
+use db::{
+ query,
+ sqlez::{domain::Domain, statement::Statement, thread_safe_connection::ThreadSafeConnection},
+ sqlez_macros::sql,
+};
use workspace::{
ItemHandle, ItemId, Member, Pane, PaneAxis, PaneGroup, SerializableItem as _, Workspace,
WorkspaceDb, WorkspaceId,
@@ -242,11 +246,9 @@ async fn deserialize_pane_group(
.update(cx, |workspace, cx| default_working_directory(workspace, cx))
.ok()
.flatten();
- let kind = TerminalKind::Shell(
- working_directory.as_deref().map(Path::to_path_buf),
- );
- let terminal =
- project.update(cx, |project, cx| project.create_terminal(kind, cx));
+ let terminal = project.update(cx, |project, cx| {
+ project.create_terminal_shell(working_directory, cx)
+ });
Some(Some(terminal))
} else {
Some(None)
@@ -375,9 +377,13 @@ impl<'de> Deserialize<'de> for SerializedAxis {
}
}
-define_connection! {
- pub static ref TERMINAL_DB: TerminalDb<WorkspaceDb> =
- &[sql!(
+pub struct TerminalDb(ThreadSafeConnection);
+
+impl Domain for TerminalDb {
+ const NAME: &str = stringify!(TerminalDb);
+
+ const MIGRATIONS: &[&str] = &[
+ sql!(
CREATE TABLE terminals (
workspace_id INTEGER,
item_id INTEGER UNIQUE,
@@ -414,6 +420,8 @@ define_connection! {
];
}
+db::static_connection!(TERMINAL_DB, TerminalDb, [WorkspaceDb]);
+
impl TerminalDb {
query! {
pub async fn update_workspace_id(
@@ -1403,7 +1403,7 @@ impl InputHandler for TerminalInputHandler {
window.invalidate_character_coordinates();
let project = this.project().read(cx);
let telemetry = project.client().telemetry().clone();
- telemetry.log_edit_event("terminal", project.is_via_ssh());
+ telemetry.log_edit_event("terminal", project.is_via_remote_server());
})
.ok();
}
@@ -16,7 +16,7 @@ use gpui::{
Task, WeakEntity, Window, actions,
};
use itertools::Itertools;
-use project::{Fs, Project, ProjectEntryId, terminals::TerminalKind};
+use project::{Fs, Project, ProjectEntryId};
use search::{BufferSearchBar, buffer_search::DivRegistrar};
use settings::Settings;
use task::{RevealStrategy, RevealTarget, ShellBuilder, SpawnInTerminal, TaskId};
@@ -376,14 +376,19 @@ impl TerminalPanel {
}
self.serialize(cx);
}
- pane::Event::Split(direction) => {
- let Some(new_pane) = self.new_pane_with_cloned_active_terminal(window, cx) else {
- return;
- };
+ &pane::Event::Split(direction) => {
+ let fut = self.new_pane_with_cloned_active_terminal(window, cx);
let pane = pane.clone();
- let direction = *direction;
- self.center.split(&pane, &new_pane, direction).log_err();
- window.focus(&new_pane.focus_handle(cx));
+ cx.spawn_in(window, async move |panel, cx| {
+ let Some(new_pane) = fut.await else {
+ return;
+ };
+ _ = panel.update_in(cx, |panel, window, cx| {
+ panel.center.split(&pane, &new_pane, direction).log_err();
+ window.focus(&new_pane.focus_handle(cx));
+ });
+ })
+ .detach();
}
pane::Event::Focus => {
self.active_pane = pane.clone();
@@ -400,57 +405,62 @@ impl TerminalPanel {
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
- ) -> Option<Entity<Pane>> {
- let workspace = self.workspace.upgrade()?;
+ ) -> Task<Option<Entity<Pane>>> {
+ let Some(workspace) = self.workspace.upgrade() else {
+ return Task::ready(None);
+ };
let workspace = workspace.read(cx);
let database_id = workspace.database_id();
let weak_workspace = self.workspace.clone();
let project = workspace.project().clone();
- let (working_directory, python_venv_directory) = self
- .active_pane
+ let active_pane = &self.active_pane;
+ let terminal_view = active_pane
.read(cx)
.active_item()
- .and_then(|item| item.downcast::<TerminalView>())
- .map(|terminal_view| {
- let terminal = terminal_view.read(cx).terminal().read(cx);
- (
- terminal
- .working_directory()
- .or_else(|| default_working_directory(workspace, cx)),
- terminal.python_venv_directory.clone(),
- )
- })
- .unwrap_or((None, None));
- let kind = TerminalKind::Shell(working_directory);
- let terminal = project
- .update(cx, |project, cx| {
- project.create_terminal_with_venv(kind, python_venv_directory, cx)
- })
- .ok()?;
-
- let terminal_view = Box::new(cx.new(|cx| {
- TerminalView::new(
- terminal.clone(),
- weak_workspace.clone(),
- database_id,
- project.downgrade(),
- window,
- cx,
- )
- }));
- let pane = new_terminal_pane(
- weak_workspace,
- project,
- self.active_pane.read(cx).is_zoomed(),
- window,
- cx,
- );
- self.apply_tab_bar_buttons(&pane, cx);
- pane.update(cx, |pane, cx| {
- pane.add_item(terminal_view, true, true, None, window, cx);
+ .and_then(|item| item.downcast::<TerminalView>());
+ let working_directory = terminal_view.as_ref().and_then(|terminal_view| {
+ let terminal = terminal_view.read(cx).terminal().read(cx);
+ terminal
+ .working_directory()
+ .or_else(|| default_working_directory(workspace, cx))
});
+ let is_zoomed = active_pane.read(cx).is_zoomed();
+ cx.spawn_in(window, async move |panel, cx| {
+ let terminal = project
+ .update(cx, |project, cx| match terminal_view {
+ Some(view) => Task::ready(project.clone_terminal(
+ &view.read(cx).terminal.clone(),
+ cx,
+ || working_directory,
+ )),
+ None => project.create_terminal_shell(working_directory, cx),
+ })
+ .ok()?
+ .await
+ .ok()?;
- Some(pane)
+ panel
+ .update_in(cx, move |terminal_panel, window, cx| {
+ let terminal_view = Box::new(cx.new(|cx| {
+ TerminalView::new(
+ terminal.clone(),
+ weak_workspace.clone(),
+ database_id,
+ project.downgrade(),
+ window,
+ cx,
+ )
+ }));
+ let pane = new_terminal_pane(weak_workspace, project, is_zoomed, window, cx);
+ terminal_panel.apply_tab_bar_buttons(&pane, cx);
+ pane.update(cx, |pane, cx| {
+ pane.add_item(terminal_view, true, true, None, window, cx);
+ });
+ Some(pane)
+ })
+ .ok()
+ .flatten()
+ })
}
pub fn open_terminal(
@@ -465,8 +475,8 @@ impl TerminalPanel {
terminal_panel
.update(cx, |panel, cx| {
- panel.add_terminal(
- TerminalKind::Shell(Some(action.working_directory.clone())),
+ panel.add_terminal_shell(
+ Some(action.working_directory.clone()),
RevealStrategy::Always,
window,
cx,
@@ -475,23 +485,34 @@ impl TerminalPanel {
.detach_and_log_err(cx);
}
- fn spawn_task(
+ pub fn spawn_task(
&mut self,
task: &SpawnInTerminal,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<WeakEntity<Terminal>>> {
- let Ok((ssh_client, false)) = self.workspace.update(cx, |workspace, cx| {
- let project = workspace.project().read(cx);
- (
- project.ssh_client().and_then(|it| it.read(cx).ssh_info()),
- project.is_via_collab(),
- )
- }) else {
- return Task::ready(Err(anyhow!("Project is not local")));
+ let remote_client = self
+ .workspace
+ .update(cx, |workspace, cx| {
+ let project = workspace.project().read(cx);
+ if project.is_via_collab() {
+ Err(anyhow!("cannot spawn tasks as a guest"))
+ } else {
+ Ok(project.remote_client())
+ }
+ })
+ .flatten();
+
+ let remote_client = match remote_client {
+ Ok(remote_client) => remote_client,
+ Err(e) => return Task::ready(Err(e)),
};
- let builder = ShellBuilder::new(ssh_client.as_ref().map(|info| &*info.shell), &task.shell);
+ let remote_shell = remote_client
+ .as_ref()
+ .and_then(|remote_client| remote_client.read(cx).shell());
+
+ let builder = ShellBuilder::new(remote_shell.as_deref(), &task.shell);
let command_label = builder.command_label(&task.command_label);
let (command, args) = builder.build(task.command.clone(), &task.args);
@@ -560,15 +581,16 @@ impl TerminalPanel {
) -> Task<Result<WeakEntity<Terminal>>> {
let reveal = spawn_task.reveal;
let reveal_target = spawn_task.reveal_target;
- let kind = TerminalKind::Task(spawn_task);
match reveal_target {
RevealTarget::Center => self
.workspace
.update(cx, |workspace, cx| {
- Self::add_center_terminal(workspace, kind, window, cx)
+ Self::add_center_terminal(workspace, window, cx, |project, cx| {
+ project.create_terminal_task(spawn_task, cx)
+ })
})
.unwrap_or_else(|e| Task::ready(Err(e))),
- RevealTarget::Dock => self.add_terminal(kind, reveal, window, cx),
+ RevealTarget::Dock => self.add_terminal_task(spawn_task, reveal, window, cx),
}
}
@@ -583,11 +605,14 @@ impl TerminalPanel {
return;
};
- let kind = TerminalKind::Shell(default_working_directory(workspace, cx));
-
terminal_panel
.update(cx, |this, cx| {
- this.add_terminal(kind, RevealStrategy::Always, window, cx)
+ this.add_terminal_shell(
+ default_working_directory(workspace, cx),
+ RevealStrategy::Always,
+ window,
+ cx,
+ )
})
.detach_and_log_err(cx);
}
@@ -649,9 +674,13 @@ impl TerminalPanel {
pub fn add_center_terminal(
workspace: &mut Workspace,
- kind: TerminalKind,
window: &mut Window,
cx: &mut Context<Workspace>,
+ create_terminal: impl FnOnce(
+ &mut Project,
+ &mut Context<Project>,
+ ) -> Task<Result<Entity<Terminal>>>
+ + 'static,
) -> Task<Result<WeakEntity<Terminal>>> {
if !is_enabled_in_workspace(workspace, cx) {
return Task::ready(Err(anyhow!(
@@ -660,9 +689,7 @@ impl TerminalPanel {
}
let project = workspace.project().downgrade();
cx.spawn_in(window, async move |workspace, cx| {
- let terminal = project
- .update(cx, |project, cx| project.create_terminal(kind, cx))?
- .await?;
+ let terminal = project.update(cx, create_terminal)?.await?;
workspace.update_in(cx, |workspace, window, cx| {
let terminal_view = cx.new(|cx| {
@@ -681,9 +708,9 @@ impl TerminalPanel {
})
}
- pub fn add_terminal(
+ pub fn add_terminal_task(
&mut self,
- kind: TerminalKind,
+ task: SpawnInTerminal,
reveal_strategy: RevealStrategy,
window: &mut Window,
cx: &mut Context<Self>,
@@ -699,7 +726,66 @@ impl TerminalPanel {
})?;
let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?;
let terminal = project
- .update(cx, |project, cx| project.create_terminal(kind, cx))?
+ .update(cx, |project, cx| project.create_terminal_task(task, cx))?
+ .await?;
+ let result = workspace.update_in(cx, |workspace, window, cx| {
+ let terminal_view = Box::new(cx.new(|cx| {
+ TerminalView::new(
+ terminal.clone(),
+ workspace.weak_handle(),
+ workspace.database_id(),
+ workspace.project().downgrade(),
+ window,
+ cx,
+ )
+ }));
+
+ match reveal_strategy {
+ RevealStrategy::Always => {
+ workspace.focus_panel::<Self>(window, cx);
+ }
+ RevealStrategy::NoFocus => {
+ workspace.open_panel::<Self>(window, cx);
+ }
+ RevealStrategy::Never => {}
+ }
+
+ pane.update(cx, |pane, cx| {
+ let focus = pane.has_focus(window, cx)
+ || matches!(reveal_strategy, RevealStrategy::Always);
+ pane.add_item(terminal_view, true, focus, None, window, cx);
+ });
+
+ Ok(terminal.downgrade())
+ })?;
+ terminal_panel.update(cx, |terminal_panel, cx| {
+ terminal_panel.pending_terminals_to_add =
+ terminal_panel.pending_terminals_to_add.saturating_sub(1);
+ terminal_panel.serialize(cx)
+ })?;
+ result
+ })
+ }
+
+ pub fn add_terminal_shell(
+ &mut self,
+ cwd: Option<PathBuf>,
+ reveal_strategy: RevealStrategy,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<WeakEntity<Terminal>>> {
+ let workspace = self.workspace.clone();
+ cx.spawn_in(window, async move |terminal_panel, cx| {
+ if workspace.update(cx, |workspace, cx| !is_enabled_in_workspace(workspace, cx))? {
+ anyhow::bail!("terminal not yet supported for remote projects");
+ }
+ let pane = terminal_panel.update(cx, |terminal_panel, _| {
+ terminal_panel.pending_terminals_to_add += 1;
+ terminal_panel.active_pane.clone()
+ })?;
+ let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?;
+ let terminal = project
+ .update(cx, |project, cx| project.create_terminal_shell(cwd, cx))?
.await?;
let result = workspace.update_in(cx, |workspace, window, cx| {
let terminal_view = Box::new(cx.new(|cx| {
@@ -808,7 +894,7 @@ impl TerminalPanel {
})??;
let new_terminal = project
.update(cx, |project, cx| {
- project.create_terminal(TerminalKind::Task(spawn_task), cx)
+ project.create_terminal_task(spawn_task, cx)
})?
.await?;
terminal_to_replace.update_in(cx, |terminal_to_replace, window, cx| {
@@ -1237,18 +1323,29 @@ impl Render for TerminalPanel {
let panes = terminal_panel.center.panes();
if let Some(&pane) = panes.get(action.0) {
window.focus(&pane.read(cx).focus_handle(cx));
- } else if let Some(new_pane) =
- terminal_panel.new_pane_with_cloned_active_terminal(window, cx)
- {
- terminal_panel
- .center
- .split(
- &terminal_panel.active_pane,
- &new_pane,
- SplitDirection::Right,
- )
- .log_err();
- window.focus(&new_pane.focus_handle(cx));
+ } else {
+ let future =
+ terminal_panel.new_pane_with_cloned_active_terminal(window, cx);
+ cx.spawn_in(window, async move |terminal_panel, cx| {
+ if let Some(new_pane) = future.await {
+ _ = terminal_panel.update_in(
+ cx,
+ |terminal_panel, window, cx| {
+ terminal_panel
+ .center
+ .split(
+ &terminal_panel.active_pane,
+ &new_pane,
+ SplitDirection::Right,
+ )
+ .log_err();
+ let new_pane = new_pane.read(cx);
+ window.focus(&new_pane.focus_handle(cx));
+ },
+ );
+ }
+ })
+ .detach();
}
}),
)
@@ -1384,13 +1481,14 @@ impl Panel for TerminalPanel {
return;
}
cx.defer_in(window, |this, window, cx| {
- let Ok(kind) = this.workspace.update(cx, |workspace, cx| {
- TerminalKind::Shell(default_working_directory(workspace, cx))
- }) else {
+ let Ok(kind) = this
+ .workspace
+ .update(cx, |workspace, cx| default_working_directory(workspace, cx))
+ else {
return;
};
- this.add_terminal(kind, RevealStrategy::Always, window, cx)
+ this.add_terminal_shell(kind, RevealStrategy::Always, window, cx)
.detach_and_log_err(cx)
})
}
@@ -364,7 +364,7 @@ fn possibly_open_target(
mod tests {
use super::*;
use gpui::TestAppContext;
- use project::{Project, terminals::TerminalKind};
+ use project::Project;
use serde_json::json;
use std::path::{Path, PathBuf};
use terminal::{HoveredWord, alacritty_terminal::index::Point as AlacPoint};
@@ -405,8 +405,8 @@ mod tests {
app_cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let terminal = project
- .update(cx, |project, cx| {
- project.create_terminal(TerminalKind::Shell(None), cx)
+ .update(cx, |project: &mut Project, cx| {
+ project.create_terminal_shell(None, cx)
})
.await
.expect("Failed to create a terminal");
@@ -15,7 +15,7 @@ use gpui::{
deferred, div,
};
use persistence::TERMINAL_DB;
-use project::{Project, search::SearchQuery, terminals::TerminalKind};
+use project::{Project, search::SearchQuery};
use schemars::JsonSchema;
use task::TaskId;
use terminal::{
@@ -204,12 +204,9 @@ impl TerminalView {
cx: &mut Context<Workspace>,
) {
let working_directory = default_working_directory(workspace, cx);
- TerminalPanel::add_center_terminal(
- workspace,
- TerminalKind::Shell(working_directory),
- window,
- cx,
- )
+ TerminalPanel::add_center_terminal(workspace, window, cx, |project, cx| {
+ project.create_terminal_shell(working_directory, cx)
+ })
.detach_and_log_err(cx);
}
@@ -1333,16 +1330,10 @@ impl Item for TerminalView {
let terminal = self
.project
.update(cx, |project, cx| {
- let terminal = self.terminal().read(cx);
- let working_directory = terminal
- .working_directory()
- .or_else(|| Some(project.active_project_directory(cx)?.to_path_buf()));
- let python_venv_directory = terminal.python_venv_directory.clone();
- project.create_terminal_with_venv(
- TerminalKind::Shell(working_directory),
- python_venv_directory,
- cx,
- )
+ let cwd = project
+ .active_project_directory(cx)
+ .map(|it| it.to_path_buf());
+ project.clone_terminal(self.terminal(), cx, || cwd)
})
.ok()?
.log_err()?;
@@ -1498,9 +1489,7 @@ impl SerializableItem for TerminalView {
.flatten();
let terminal = project
- .update(cx, |project, cx| {
- project.create_terminal(TerminalKind::Shell(cwd), cx)
- })?
+ .update(cx, |project, cx| project.create_terminal_shell(cwd, cx))?
.await?;
cx.update(|window, cx| {
cx.new(|cx| {
@@ -337,7 +337,7 @@ impl TitleBar {
let room = room.read(cx);
let project = self.project.read(cx);
- let is_local = project.is_local() || project.is_via_ssh();
+ let is_local = project.is_local() || project.is_via_remote_server();
let is_shared = is_local && project.is_shared();
let is_muted = room.is_muted();
let muted_by_user = room.muted_by_user();
@@ -119,7 +119,7 @@ impl Render for OnboardingBanner {
h_flex()
.h_full()
.gap_1()
- .child(Icon::new(self.details.icon_name).size(IconSize::Small))
+ .child(Icon::new(self.details.icon_name).size(IconSize::XSmall))
.child(
h_flex()
.gap_0p5()
@@ -1,28 +1,35 @@
use gpui::{
- AnyElement, Context, Decorations, Hsla, InteractiveElement, IntoElement, MouseButton,
+ AnyElement, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, WindowControlArea, div, px,
};
use smallvec::SmallVec;
use std::mem;
use ui::prelude::*;
-use crate::platforms::{platform_linux, platform_mac, platform_windows};
+use crate::{
+ platforms::{platform_linux, platform_mac, platform_windows},
+ system_window_tabs::SystemWindowTabs,
+};
pub struct PlatformTitleBar {
id: ElementId,
platform_style: PlatformStyle,
children: SmallVec<[AnyElement; 2]>,
should_move: bool,
+ system_window_tabs: Entity<SystemWindowTabs>,
}
impl PlatformTitleBar {
- pub fn new(id: impl Into<ElementId>) -> Self {
+ pub fn new(id: impl Into<ElementId>, cx: &mut Context<Self>) -> Self {
let platform_style = PlatformStyle::platform();
+ let system_window_tabs = cx.new(|_cx| SystemWindowTabs::new());
+
Self {
id: id.into(),
platform_style,
children: SmallVec::new(),
should_move: false,
+ system_window_tabs,
}
}
@@ -66,7 +73,7 @@ impl Render for PlatformTitleBar {
let close_action = Box::new(workspace::CloseWindow);
let children = mem::take(&mut self.children);
- h_flex()
+ let title_bar = h_flex()
.window_control_area(WindowControlArea::Drag)
.w_full()
.h(height)
@@ -162,7 +169,12 @@ impl Render for PlatformTitleBar {
title_bar.child(platform_windows::WindowsWindowControls::new(height))
}
}
- })
+ });
+
+ v_flex()
+ .w_full()
+ .child(title_bar)
+ .child(self.system_window_tabs.clone().into_any_element())
}
}
@@ -0,0 +1,477 @@
+use settings::Settings;
+
+use gpui::{
+ AnyWindowHandle, Context, Hsla, InteractiveElement, MouseButton, ParentElement, ScrollHandle,
+ Styled, SystemWindowTab, SystemWindowTabController, Window, WindowId, actions, canvas, div,
+};
+
+use theme::ThemeSettings;
+use ui::{
+ Color, ContextMenu, DynamicSpacing, IconButton, IconButtonShape, IconName, IconSize, Label,
+ LabelSize, Tab, h_flex, prelude::*, right_click_menu,
+};
+use workspace::{
+ CloseWindow, ItemSettings, Workspace,
+ item::{ClosePosition, ShowCloseButton},
+};
+
+actions!(
+ window,
+ [
+ ShowNextWindowTab,
+ ShowPreviousWindowTab,
+ MergeAllWindows,
+ MoveTabToNewWindow
+ ]
+);
+
+#[derive(Clone)]
+pub struct DraggedWindowTab {
+ pub id: WindowId,
+ pub ix: usize,
+ pub handle: AnyWindowHandle,
+ pub title: String,
+ pub width: Pixels,
+ pub is_active: bool,
+ pub active_background_color: Hsla,
+ pub inactive_background_color: Hsla,
+}
+
+pub struct SystemWindowTabs {
+ tab_bar_scroll_handle: ScrollHandle,
+ measured_tab_width: Pixels,
+ last_dragged_tab: Option<DraggedWindowTab>,
+}
+
+impl SystemWindowTabs {
+ pub fn new() -> Self {
+ Self {
+ tab_bar_scroll_handle: ScrollHandle::new(),
+ measured_tab_width: px(0.),
+ last_dragged_tab: None,
+ }
+ }
+
+ pub fn init(cx: &mut App) {
+ cx.observe_new(|workspace: &mut Workspace, _, _| {
+ workspace.register_action_renderer(|div, _, window, cx| {
+ let window_id = window.window_handle().window_id();
+ let controller = cx.global::<SystemWindowTabController>();
+
+ let tab_groups = controller.tab_groups();
+ let tabs = controller.tabs(window_id);
+ let Some(tabs) = tabs else {
+ return div;
+ };
+
+ div.when(tabs.len() > 1, |div| {
+ div.on_action(move |_: &ShowNextWindowTab, window, cx| {
+ SystemWindowTabController::select_next_tab(
+ cx,
+ window.window_handle().window_id(),
+ );
+ })
+ .on_action(move |_: &ShowPreviousWindowTab, window, cx| {
+ SystemWindowTabController::select_previous_tab(
+ cx,
+ window.window_handle().window_id(),
+ );
+ })
+ .on_action(move |_: &MoveTabToNewWindow, window, cx| {
+ SystemWindowTabController::move_tab_to_new_window(
+ cx,
+ window.window_handle().window_id(),
+ );
+ window.move_tab_to_new_window();
+ })
+ })
+ .when(tab_groups.len() > 1, |div| {
+ div.on_action(move |_: &MergeAllWindows, window, cx| {
+ SystemWindowTabController::merge_all_windows(
+ cx,
+ window.window_handle().window_id(),
+ );
+ window.merge_all_windows();
+ })
+ })
+ });
+ })
+ .detach();
+ }
+
+ fn render_tab(
+ &self,
+ ix: usize,
+ item: SystemWindowTab,
+ tabs: Vec<SystemWindowTab>,
+ active_background_color: Hsla,
+ inactive_background_color: Hsla,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> impl IntoElement + use<> {
+ let entity = cx.entity();
+ let settings = ItemSettings::get_global(cx);
+ let close_side = &settings.close_position;
+ let show_close_button = &settings.show_close_button;
+
+ let rem_size = window.rem_size();
+ let width = self.measured_tab_width.max(rem_size * 10);
+ let is_active = window.window_handle().window_id() == item.id;
+ let title = item.title.to_string();
+
+ let label = Label::new(&title)
+ .size(LabelSize::Small)
+ .truncate()
+ .color(if is_active {
+ Color::Default
+ } else {
+ Color::Muted
+ });
+
+ let tab = h_flex()
+ .id(ix)
+ .group("tab")
+ .w_full()
+ .overflow_hidden()
+ .h(Tab::content_height(cx))
+ .relative()
+ .px(DynamicSpacing::Base16.px(cx))
+ .justify_center()
+ .border_l_1()
+ .border_color(cx.theme().colors().border)
+ .cursor_pointer()
+ .on_drag(
+ DraggedWindowTab {
+ id: item.id,
+ ix,
+ handle: item.handle,
+ title: item.title.to_string(),
+ width,
+ is_active,
+ active_background_color,
+ inactive_background_color,
+ },
+ move |tab, _, _, cx| {
+ entity.update(cx, |this, _cx| {
+ this.last_dragged_tab = Some(tab.clone());
+ });
+ cx.new(|_| tab.clone())
+ },
+ )
+ .drag_over::<DraggedWindowTab>({
+ let tab_ix = ix;
+ move |element, dragged_tab: &DraggedWindowTab, _, cx| {
+ let mut styled_tab = element
+ .bg(cx.theme().colors().drop_target_background)
+ .border_color(cx.theme().colors().drop_target_border)
+ .border_0();
+
+ if tab_ix < dragged_tab.ix {
+ styled_tab = styled_tab.border_l_2();
+ } else if tab_ix > dragged_tab.ix {
+ styled_tab = styled_tab.border_r_2();
+ }
+
+ styled_tab
+ }
+ })
+ .on_drop({
+ let tab_ix = ix;
+ cx.listener(move |this, dragged_tab: &DraggedWindowTab, _window, cx| {
+ this.last_dragged_tab = None;
+ Self::handle_tab_drop(dragged_tab, tab_ix, cx);
+ })
+ })
+ .on_click(move |_, _, cx| {
+ let _ = item.handle.update(cx, |_, window, _| {
+ window.activate_window();
+ });
+ })
+ .child(label)
+ .map(|this| match show_close_button {
+ ShowCloseButton::Hidden => this,
+ _ => this.child(
+ div()
+ .absolute()
+ .top_2()
+ .w_4()
+ .h_4()
+ .map(|this| match close_side {
+ ClosePosition::Left => this.left_1(),
+ ClosePosition::Right => this.right_1(),
+ })
+ .child(
+ IconButton::new("close", IconName::Close)
+ .shape(IconButtonShape::Square)
+ .icon_color(Color::Muted)
+ .icon_size(IconSize::XSmall)
+ .on_click({
+ move |_, window, cx| {
+ if item.handle.window_id()
+ == window.window_handle().window_id()
+ {
+ window.dispatch_action(Box::new(CloseWindow), cx);
+ } else {
+ let _ = item.handle.update(cx, |_, window, cx| {
+ window.dispatch_action(Box::new(CloseWindow), cx);
+ });
+ }
+ }
+ })
+ .map(|this| match show_close_button {
+ ShowCloseButton::Hover => this.visible_on_hover("tab"),
+ _ => this,
+ }),
+ ),
+ ),
+ })
+ .into_any();
+
+ let menu = right_click_menu(ix)
+ .trigger(|_, _, _| tab)
+ .menu(move |window, cx| {
+ let focus_handle = cx.focus_handle();
+ let tabs = tabs.clone();
+ let other_tabs = tabs.clone();
+ let move_tabs = tabs.clone();
+ let merge_tabs = tabs.clone();
+
+ ContextMenu::build(window, cx, move |mut menu, _window_, _cx| {
+ menu = menu.entry("Close Tab", None, move |window, cx| {
+ Self::handle_right_click_action(
+ cx,
+ window,
+ &tabs,
+ |tab| tab.id == item.id,
+ |window, cx| {
+ window.dispatch_action(Box::new(CloseWindow), cx);
+ },
+ );
+ });
+
+ menu = menu.entry("Close Other Tabs", None, move |window, cx| {
+ Self::handle_right_click_action(
+ cx,
+ window,
+ &other_tabs,
+ |tab| tab.id != item.id,
+ |window, cx| {
+ window.dispatch_action(Box::new(CloseWindow), cx);
+ },
+ );
+ });
+
+ menu = menu.entry("Move Tab to New Window", None, move |window, cx| {
+ Self::handle_right_click_action(
+ cx,
+ window,
+ &move_tabs,
+ |tab| tab.id == item.id,
+ |window, cx| {
+ SystemWindowTabController::move_tab_to_new_window(
+ cx,
+ window.window_handle().window_id(),
+ );
+ window.move_tab_to_new_window();
+ },
+ );
+ });
+
+ menu = menu.entry("Show All Tabs", None, move |window, cx| {
+ Self::handle_right_click_action(
+ cx,
+ window,
+ &merge_tabs,
+ |tab| tab.id == item.id,
+ |window, _cx| {
+ window.toggle_window_tab_overview();
+ },
+ );
+ });
+
+ menu.context(focus_handle)
+ })
+ });
+
+ div()
+ .flex_1()
+ .min_w(rem_size * 10)
+ .when(is_active, |this| this.bg(active_background_color))
+ .border_t_1()
+ .border_color(if is_active {
+ active_background_color
+ } else {
+ cx.theme().colors().border
+ })
+ .child(menu)
+ }
+
+ fn handle_tab_drop(dragged_tab: &DraggedWindowTab, ix: usize, cx: &mut Context<Self>) {
+ SystemWindowTabController::update_tab_position(cx, dragged_tab.id, ix);
+ }
+
+ fn handle_right_click_action<F, P>(
+ cx: &mut App,
+ window: &mut Window,
+ tabs: &Vec<SystemWindowTab>,
+ predicate: P,
+ mut action: F,
+ ) where
+ P: Fn(&SystemWindowTab) -> bool,
+ F: FnMut(&mut Window, &mut App),
+ {
+ for tab in tabs {
+ if predicate(tab) {
+ if tab.id == window.window_handle().window_id() {
+ action(window, cx);
+ } else {
+ let _ = tab.handle.update(cx, |_view, window, cx| {
+ action(window, cx);
+ });
+ }
+ }
+ }
+ }
+}
+
+impl Render for SystemWindowTabs {
+ fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let active_background_color = cx.theme().colors().title_bar_background;
+ let inactive_background_color = cx.theme().colors().tab_bar_background;
+ let entity = cx.entity();
+
+ let controller = cx.global::<SystemWindowTabController>();
+ let visible = controller.is_visible();
+ let current_window_tab = vec![SystemWindowTab::new(
+ SharedString::from(window.window_title()),
+ window.window_handle(),
+ )];
+ let tabs = controller
+ .tabs(window.window_handle().window_id())
+ .unwrap_or(¤t_window_tab)
+ .clone();
+
+ let tab_items = tabs
+ .iter()
+ .enumerate()
+ .map(|(ix, item)| {
+ self.render_tab(
+ ix,
+ item.clone(),
+ tabs.clone(),
+ active_background_color,
+ inactive_background_color,
+ window,
+ cx,
+ )
+ })
+ .collect::<Vec<_>>();
+
+ let number_of_tabs = tab_items.len().max(1);
+ if !window.tab_bar_visible() && !visible {
+ return h_flex().into_any_element();
+ }
+
+ h_flex()
+ .w_full()
+ .h(Tab::container_height(cx))
+ .bg(inactive_background_color)
+ .on_mouse_up_out(
+ MouseButton::Left,
+ cx.listener(|this, _event, window, cx| {
+ if let Some(tab) = this.last_dragged_tab.take() {
+ SystemWindowTabController::move_tab_to_new_window(cx, tab.id);
+ if tab.id == window.window_handle().window_id() {
+ window.move_tab_to_new_window();
+ } else {
+ let _ = tab.handle.update(cx, |_, window, _cx| {
+ window.move_tab_to_new_window();
+ });
+ }
+ }
+ }),
+ )
+ .child(
+ h_flex()
+ .id("window tabs")
+ .w_full()
+ .h(Tab::container_height(cx))
+ .bg(inactive_background_color)
+ .overflow_x_scroll()
+ .track_scroll(&self.tab_bar_scroll_handle)
+ .children(tab_items)
+ .child(
+ canvas(
+ |_, _, _| (),
+ move |bounds, _, _, cx| {
+ let entity = entity.clone();
+ entity.update(cx, |this, cx| {
+ let width = bounds.size.width / number_of_tabs as f32;
+ if width != this.measured_tab_width {
+ this.measured_tab_width = width;
+ cx.notify();
+ }
+ });
+ },
+ )
+ .absolute()
+ .size_full(),
+ ),
+ )
+ .child(
+ h_flex()
+ .h_full()
+ .px(DynamicSpacing::Base06.rems(cx))
+ .border_t_1()
+ .border_l_1()
+ .border_color(cx.theme().colors().border)
+ .child(
+ IconButton::new("plus", IconName::Plus)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted)
+ .on_click(|_event, window, cx| {
+ window.dispatch_action(
+ Box::new(zed_actions::OpenRecent {
+ create_new_window: true,
+ }),
+ cx,
+ );
+ }),
+ ),
+ )
+ .into_any_element()
+ }
+}
+
+impl Render for DraggedWindowTab {
+ fn render(
+ &mut self,
+ _window: &mut gpui::Window,
+ cx: &mut gpui::Context<Self>,
+ ) -> impl gpui::IntoElement {
+ let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
+ let label = Label::new(self.title.clone())
+ .size(LabelSize::Small)
+ .truncate()
+ .color(if self.is_active {
+ Color::Default
+ } else {
+ Color::Muted
+ });
+
+ h_flex()
+ .h(Tab::container_height(cx))
+ .w(self.width)
+ .px(DynamicSpacing::Base16.px(cx))
+ .justify_center()
+ .bg(if self.is_active {
+ self.active_background_color
+ } else {
+ self.inactive_background_color
+ })
+ .border_1()
+ .border_color(cx.theme().colors().border)
+ .font(ui_font)
+ .child(label)
+ }
+}
@@ -3,6 +3,7 @@ mod collab;
mod onboarding_banner;
pub mod platform_title_bar;
mod platforms;
+mod system_window_tabs;
mod title_bar_settings;
#[cfg(feature = "stories")]
@@ -11,6 +12,7 @@ mod stories;
use crate::{
application_menu::{ApplicationMenu, show_menus},
platform_title_bar::PlatformTitleBar,
+ system_window_tabs::SystemWindowTabs,
};
#[cfg(not(target_os = "macos"))]
@@ -65,6 +67,7 @@ actions!(
pub fn init(cx: &mut App) {
TitleBarSettings::register(cx);
+ SystemWindowTabs::init(cx);
cx.observe_new(|workspace: &mut Workspace, window, cx| {
let Some(window) = window else {
@@ -275,16 +278,16 @@ impl TitleBar {
let banner = cx.new(|cx| {
OnboardingBanner::new(
- "Debugger Onboarding",
- IconName::Debug,
- "The Debugger",
- None,
- zed_actions::debugger::OpenOnboardingModal.boxed_clone(),
+ "ACP Onboarding",
+ IconName::Sparkle,
+ "Bring Your Own Agent",
+ Some("Introducing:".into()),
+ zed_actions::agent::OpenAcpOnboardingModal.boxed_clone(),
cx,
)
});
- let platform_titlebar = cx.new(|_| PlatformTitleBar::new(id));
+ let platform_titlebar = cx.new(|cx| PlatformTitleBar::new(id, cx));
Self {
platform_titlebar,
@@ -299,8 +302,8 @@ impl TitleBar {
}
}
- fn render_ssh_project_host(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
- let options = self.project.read(cx).ssh_connection_options(cx)?;
+ fn render_remote_project_connection(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
+ let options = self.project.read(cx).remote_connection_options(cx)?;
let host: SharedString = options.connection_string().into();
let nickname = options
@@ -308,7 +311,7 @@ impl TitleBar {
.map(|nick| nick.into())
.unwrap_or_else(|| host.clone());
- let (indicator_color, meta) = match self.project.read(cx).ssh_connection_state(cx)? {
+ let (indicator_color, meta) = match self.project.read(cx).remote_connection_state(cx)? {
remote::ConnectionState::Connecting => (Color::Info, format!("Connecting to: {host}")),
remote::ConnectionState::Connected => (Color::Success, format!("Connected to: {host}")),
remote::ConnectionState::HeartbeatMissed => (
@@ -324,7 +327,7 @@ impl TitleBar {
}
};
- let icon_color = match self.project.read(cx).ssh_connection_state(cx)? {
+ let icon_color = match self.project.read(cx).remote_connection_state(cx)? {
remote::ConnectionState::Connecting => Color::Info,
remote::ConnectionState::Connected => Color::Default,
remote::ConnectionState::HeartbeatMissed => Color::Warning,
@@ -379,8 +382,8 @@ impl TitleBar {
}
pub fn render_project_host(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
- if self.project.read(cx).is_via_ssh() {
- return self.render_ssh_project_host(cx);
+ if self.project.read(cx).is_via_remote_server() {
+ return self.render_remote_project_connection(cx);
}
if self.project.read(cx).is_disconnected(cx) {
@@ -13,6 +13,9 @@ use crate::prelude::*;
)]
#[strum(serialize_all = "snake_case")]
pub enum VectorName {
+ AcpGrid,
+ AcpLogo,
+ AcpLogoSerif,
AiGrid,
DebuggerGrid,
Grid,
@@ -1,8 +1,8 @@
use crate::PlatformStyle;
use crate::{Icon, IconName, IconSize, h_flex, prelude::*};
use gpui::{
- Action, AnyElement, App, FocusHandle, Global, IntoElement, Keystroke, Modifiers, Window,
- relative,
+ Action, AnyElement, App, FocusHandle, Global, IntoElement, KeybindingKeystroke, Keystroke,
+ Modifiers, Window, relative,
};
use itertools::Itertools;
@@ -13,7 +13,7 @@ pub struct KeyBinding {
/// More than one keystroke produces a chord.
///
/// This should always contain at least one keystroke.
- pub keystrokes: Vec<Keystroke>,
+ pub keystrokes: Vec<KeybindingKeystroke>,
/// The [`PlatformStyle`] to use when displaying this keybinding.
platform_style: PlatformStyle,
@@ -59,7 +59,7 @@ impl KeyBinding {
cx.try_global::<VimStyle>().is_some_and(|g| g.0)
}
- pub fn new(keystrokes: Vec<Keystroke>, cx: &App) -> Self {
+ pub fn new(keystrokes: Vec<KeybindingKeystroke>, cx: &App) -> Self {
Self {
keystrokes,
platform_style: PlatformStyle::platform(),
@@ -99,16 +99,16 @@ impl KeyBinding {
}
fn render_key(
- keystroke: &Keystroke,
+ key: &str,
color: Option<Color>,
platform_style: PlatformStyle,
size: impl Into<Option<AbsoluteLength>>,
) -> AnyElement {
- let key_icon = icon_for_key(keystroke, platform_style);
+ let key_icon = icon_for_key(key, platform_style);
match key_icon {
Some(icon) => KeyIcon::new(icon, color).size(size).into_any_element(),
None => {
- let key = util::capitalize(&keystroke.key);
+ let key = util::capitalize(key);
Key::new(&key, color).size(size).into_any_element()
}
}
@@ -124,7 +124,7 @@ impl RenderOnce for KeyBinding {
"KEY_BINDING-{}",
self.keystrokes
.iter()
- .map(|k| k.key.to_string())
+ .map(|k| k.key().to_string())
.collect::<Vec<_>>()
.join(" ")
)
@@ -137,7 +137,7 @@ impl RenderOnce for KeyBinding {
.py_0p5()
.rounded_xs()
.text_color(cx.theme().colors().text_muted)
- .children(render_keystroke(
+ .children(render_keybinding_keystroke(
keystroke,
color,
self.size,
@@ -148,8 +148,8 @@ impl RenderOnce for KeyBinding {
}
}
-pub fn render_keystroke(
- keystroke: &Keystroke,
+pub fn render_keybinding_keystroke(
+ keystroke: &KeybindingKeystroke,
color: Option<Color>,
size: impl Into<Option<AbsoluteLength>>,
platform_style: PlatformStyle,
@@ -163,26 +163,34 @@ pub fn render_keystroke(
let size = size.into();
if use_text {
- let element = Key::new(keystroke_text(keystroke, platform_style, vim_mode), color)
- .size(size)
- .into_any_element();
+ let element = Key::new(
+ keystroke_text(
+ keystroke.modifiers(),
+ keystroke.key(),
+ platform_style,
+ vim_mode,
+ ),
+ color,
+ )
+ .size(size)
+ .into_any_element();
vec![element]
} else {
let mut elements = Vec::new();
elements.extend(render_modifiers(
- &keystroke.modifiers,
+ keystroke.modifiers(),
platform_style,
color,
size,
true,
));
- elements.push(render_key(keystroke, color, platform_style, size));
+ elements.push(render_key(keystroke.key(), color, platform_style, size));
elements
}
}
-fn icon_for_key(keystroke: &Keystroke, platform_style: PlatformStyle) -> Option<IconName> {
- match keystroke.key.as_str() {
+fn icon_for_key(key: &str, platform_style: PlatformStyle) -> Option<IconName> {
+ match key {
"left" => Some(IconName::ArrowLeft),
"right" => Some(IconName::ArrowRight),
"up" => Some(IconName::ArrowUp),
@@ -379,7 +387,7 @@ impl KeyIcon {
/// Returns a textual representation of the key binding for the given [`Action`].
pub fn text_for_action(action: &dyn Action, window: &Window, cx: &App) -> Option<String> {
let key_binding = window.highest_precedence_binding_for_action(action)?;
- Some(text_for_keystrokes(key_binding.keystrokes(), cx))
+ Some(text_for_keybinding_keystrokes(key_binding.keystrokes(), cx))
}
pub fn text_for_keystrokes(keystrokes: &[Keystroke], cx: &App) -> String {
@@ -387,22 +395,50 @@ pub fn text_for_keystrokes(keystrokes: &[Keystroke], cx: &App) -> String {
let vim_enabled = cx.try_global::<VimStyle>().is_some();
keystrokes
.iter()
- .map(|keystroke| keystroke_text(keystroke, platform_style, vim_enabled))
+ .map(|keystroke| {
+ keystroke_text(
+ &keystroke.modifiers,
+ &keystroke.key,
+ platform_style,
+ vim_enabled,
+ )
+ })
+ .join(" ")
+}
+
+pub fn text_for_keybinding_keystrokes(keystrokes: &[KeybindingKeystroke], cx: &App) -> String {
+ let platform_style = PlatformStyle::platform();
+ let vim_enabled = cx.try_global::<VimStyle>().is_some();
+ keystrokes
+ .iter()
+ .map(|keystroke| {
+ keystroke_text(
+ keystroke.modifiers(),
+ keystroke.key(),
+ platform_style,
+ vim_enabled,
+ )
+ })
.join(" ")
}
-pub fn text_for_keystroke(keystroke: &Keystroke, cx: &App) -> String {
+pub fn text_for_keystroke(modifiers: &Modifiers, key: &str, cx: &App) -> String {
let platform_style = PlatformStyle::platform();
let vim_enabled = cx.try_global::<VimStyle>().is_some();
- keystroke_text(keystroke, platform_style, vim_enabled)
+ keystroke_text(modifiers, key, platform_style, vim_enabled)
}
/// Returns a textual representation of the given [`Keystroke`].
-fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode: bool) -> String {
+fn keystroke_text(
+ modifiers: &Modifiers,
+ key: &str,
+ platform_style: PlatformStyle,
+ vim_mode: bool,
+) -> String {
let mut text = String::new();
let delimiter = '-';
- if keystroke.modifiers.function {
+ if modifiers.function {
match vim_mode {
false => text.push_str("Fn"),
true => text.push_str("fn"),
@@ -411,7 +447,7 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode
text.push(delimiter);
}
- if keystroke.modifiers.control {
+ if modifiers.control {
match (platform_style, vim_mode) {
(PlatformStyle::Mac, false) => text.push_str("Control"),
(PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Ctrl"),
@@ -421,7 +457,7 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode
text.push(delimiter);
}
- if keystroke.modifiers.platform {
+ if modifiers.platform {
match (platform_style, vim_mode) {
(PlatformStyle::Mac, false) => text.push_str("Command"),
(PlatformStyle::Mac, true) => text.push_str("cmd"),
@@ -434,7 +470,7 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode
text.push(delimiter);
}
- if keystroke.modifiers.alt {
+ if modifiers.alt {
match (platform_style, vim_mode) {
(PlatformStyle::Mac, false) => text.push_str("Option"),
(PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Alt"),
@@ -444,7 +480,7 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode
text.push(delimiter);
}
- if keystroke.modifiers.shift {
+ if modifiers.shift {
match (platform_style, vim_mode) {
(_, false) => text.push_str("Shift"),
(_, true) => text.push_str("shift"),
@@ -453,9 +489,9 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode
}
if vim_mode {
- text.push_str(&keystroke.key)
+ text.push_str(key)
} else {
- let key = match keystroke.key.as_str() {
+ let key = match key {
"pageup" => "PageUp",
"pagedown" => "PageDown",
key => &util::capitalize(key),
@@ -562,9 +598,11 @@ mod tests {
#[test]
fn test_text_for_keystroke() {
+ let keystroke = Keystroke::parse("cmd-c").unwrap();
assert_eq!(
keystroke_text(
- &Keystroke::parse("cmd-c").unwrap(),
+ &keystroke.modifiers,
+ &keystroke.key,
PlatformStyle::Mac,
false
),
@@ -572,7 +610,8 @@ mod tests {
);
assert_eq!(
keystroke_text(
- &Keystroke::parse("cmd-c").unwrap(),
+ &keystroke.modifiers,
+ &keystroke.key,
PlatformStyle::Linux,
false
),
@@ -580,16 +619,19 @@ mod tests {
);
assert_eq!(
keystroke_text(
- &Keystroke::parse("cmd-c").unwrap(),
+ &keystroke.modifiers,
+ &keystroke.key,
PlatformStyle::Windows,
false
),
"Win-C".to_string()
);
+ let keystroke = Keystroke::parse("ctrl-alt-delete").unwrap();
assert_eq!(
keystroke_text(
- &Keystroke::parse("ctrl-alt-delete").unwrap(),
+ &keystroke.modifiers,
+ &keystroke.key,
PlatformStyle::Mac,
false
),
@@ -597,7 +639,8 @@ mod tests {
);
assert_eq!(
keystroke_text(
- &Keystroke::parse("ctrl-alt-delete").unwrap(),
+ &keystroke.modifiers,
+ &keystroke.key,
PlatformStyle::Linux,
false
),
@@ -605,16 +648,19 @@ mod tests {
);
assert_eq!(
keystroke_text(
- &Keystroke::parse("ctrl-alt-delete").unwrap(),
+ &keystroke.modifiers,
+ &keystroke.key,
PlatformStyle::Windows,
false
),
"Ctrl-Alt-Delete".to_string()
);
+ let keystroke = Keystroke::parse("shift-pageup").unwrap();
assert_eq!(
keystroke_text(
- &Keystroke::parse("shift-pageup").unwrap(),
+ &keystroke.modifiers,
+ &keystroke.key,
PlatformStyle::Mac,
false
),
@@ -622,7 +668,8 @@ mod tests {
);
assert_eq!(
keystroke_text(
- &Keystroke::parse("shift-pageup").unwrap(),
+ &keystroke.modifiers,
+ &keystroke.key,
PlatformStyle::Linux,
false,
),
@@ -630,7 +677,8 @@ mod tests {
);
assert_eq!(
keystroke_text(
- &Keystroke::parse("shift-pageup").unwrap(),
+ &keystroke.modifiers,
+ &keystroke.key,
PlatformStyle::Windows,
false
),
@@ -3,6 +3,7 @@ use regex::Regex;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::fmt::{Display, Formatter};
+use std::mem;
use std::path::StripPrefixError;
use std::sync::{Arc, OnceLock};
use std::{
@@ -99,21 +100,86 @@ impl<T: AsRef<Path>> PathExt for T {
}
}
-/// Due to the issue of UNC paths on Windows, which can cause bugs in various parts of Zed, introducing this `SanitizedPath`
-/// leverages Rust's type system to ensure that all paths entering Zed are always "sanitized" by removing the `\\\\?\\` prefix.
-/// On non-Windows operating systems, this struct is effectively a no-op.
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
-pub struct SanitizedPath(pub Arc<Path>);
+/// In memory, this is identical to `Path`. On non-Windows conversions to this type are no-ops. On
+/// windows, these conversions sanitize UNC paths by removing the `\\\\?\\` prefix.
+#[derive(Eq, PartialEq, Hash, Ord, PartialOrd)]
+#[repr(transparent)]
+pub struct SanitizedPath(Path);
impl SanitizedPath {
- pub fn starts_with(&self, prefix: &SanitizedPath) -> bool {
+ pub fn new<T: AsRef<Path> + ?Sized>(path: &T) -> &Self {
+ #[cfg(not(target_os = "windows"))]
+ return Self::unchecked_new(path.as_ref());
+
+ #[cfg(target_os = "windows")]
+ return Self::unchecked_new(dunce::simplified(path.as_ref()));
+ }
+
+ pub fn unchecked_new<T: AsRef<Path> + ?Sized>(path: &T) -> &Self {
+ // safe because `Path` and `SanitizedPath` have the same repr and Drop impl
+ unsafe { mem::transmute::<&Path, &Self>(path.as_ref()) }
+ }
+
+ pub fn from_arc(path: Arc<Path>) -> Arc<Self> {
+ // safe because `Path` and `SanitizedPath` have the same repr and Drop impl
+ #[cfg(not(target_os = "windows"))]
+ return unsafe { mem::transmute::<Arc<Path>, Arc<Self>>(path) };
+
+ // TODO: could avoid allocating here if dunce::simplified results in the same path
+ #[cfg(target_os = "windows")]
+ return Self::new(&path).into();
+ }
+
+ pub fn new_arc<T: AsRef<Path> + ?Sized>(path: &T) -> Arc<Self> {
+ Self::new(path).into()
+ }
+
+ pub fn cast_arc(path: Arc<Self>) -> Arc<Path> {
+ // safe because `Path` and `SanitizedPath` have the same repr and Drop impl
+ unsafe { mem::transmute::<Arc<Self>, Arc<Path>>(path) }
+ }
+
+ pub fn cast_arc_ref(path: &Arc<Self>) -> &Arc<Path> {
+ // safe because `Path` and `SanitizedPath` have the same repr and Drop impl
+ unsafe { mem::transmute::<&Arc<Self>, &Arc<Path>>(path) }
+ }
+
+ pub fn starts_with(&self, prefix: &Self) -> bool {
self.0.starts_with(&prefix.0)
}
- pub fn as_path(&self) -> &Arc<Path> {
+ pub fn as_path(&self) -> &Path {
&self.0
}
+ pub fn file_name(&self) -> Option<&std::ffi::OsStr> {
+ self.0.file_name()
+ }
+
+ pub fn extension(&self) -> Option<&std::ffi::OsStr> {
+ self.0.extension()
+ }
+
+ pub fn join<P: AsRef<Path>>(&self, path: P) -> PathBuf {
+ self.0.join(path)
+ }
+
+ pub fn parent(&self) -> Option<&Self> {
+ self.0.parent().map(Self::unchecked_new)
+ }
+
+ pub fn strip_prefix(&self, base: &Self) -> Result<&Path, StripPrefixError> {
+ self.0.strip_prefix(base.as_path())
+ }
+
+ pub fn to_str(&self) -> Option<&str> {
+ self.0.to_str()
+ }
+
+ pub fn to_path_buf(&self) -> PathBuf {
+ self.0.to_path_buf()
+ }
+
pub fn to_glob_string(&self) -> String {
#[cfg(target_os = "windows")]
{
@@ -124,13 +190,11 @@ impl SanitizedPath {
self.0.to_string_lossy().to_string()
}
}
+}
- pub fn join(&self, path: &Self) -> Self {
- self.0.join(&path.0).into()
- }
-
- pub fn strip_prefix(&self, base: &Self) -> Result<&Path, StripPrefixError> {
- self.0.strip_prefix(base.as_path())
+impl std::fmt::Debug for SanitizedPath {
+ fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
+ std::fmt::Debug::fmt(&self.0, formatter)
}
}
@@ -140,29 +204,23 @@ impl Display for SanitizedPath {
}
}
-impl From<SanitizedPath> for Arc<Path> {
- fn from(sanitized_path: SanitizedPath) -> Self {
- sanitized_path.0
+impl From<&SanitizedPath> for Arc<SanitizedPath> {
+ fn from(sanitized_path: &SanitizedPath) -> Self {
+ let path: Arc<Path> = sanitized_path.0.into();
+ // safe because `Path` and `SanitizedPath` have the same repr and Drop impl
+ unsafe { mem::transmute(path) }
}
}
-impl From<SanitizedPath> for PathBuf {
- fn from(sanitized_path: SanitizedPath) -> Self {
- sanitized_path.0.as_ref().into()
+impl From<&SanitizedPath> for PathBuf {
+ fn from(sanitized_path: &SanitizedPath) -> Self {
+ sanitized_path.as_path().into()
}
}
-impl<T: AsRef<Path>> From<T> for SanitizedPath {
- #[cfg(not(target_os = "windows"))]
- fn from(path: T) -> Self {
- let path = path.as_ref();
- SanitizedPath(path.into())
- }
-
- #[cfg(target_os = "windows")]
- fn from(path: T) -> Self {
- let path = path.as_ref();
- SanitizedPath(dunce::simplified(path).into())
+impl AsRef<Path> for SanitizedPath {
+ fn as_ref(&self) -> &Path {
+ &self.0
}
}
@@ -1195,14 +1253,14 @@ mod tests {
#[cfg(target_os = "windows")]
fn test_sanitized_path() {
let path = Path::new("C:\\Users\\someone\\test_file.rs");
- let sanitized_path = SanitizedPath::from(path);
+ let sanitized_path = SanitizedPath::new(path);
assert_eq!(
sanitized_path.to_string(),
"C:\\Users\\someone\\test_file.rs"
);
let path = Path::new("\\\\?\\C:\\Users\\someone\\test_file.rs");
- let sanitized_path = SanitizedPath::from(path);
+ let sanitized_path = SanitizedPath::new(path);
assert_eq!(
sanitized_path.to_string(),
"C:\\Users\\someone\\test_file.rs"
@@ -1057,6 +1057,18 @@ pub fn get_system_shell() -> String {
}
}
+pub fn get_default_system_shell() -> String {
+ #[cfg(target_os = "windows")]
+ {
+ get_windows_system_shell()
+ }
+
+ #[cfg(not(target_os = "windows"))]
+ {
+ "/bin/sh".to_string()
+ }
+}
+
#[derive(Debug)]
pub enum ConnectionResult<O> {
Timeout,
@@ -1924,7 +1924,9 @@ impl ShellExec {
let Some(range) = input_range else { return };
- let mut process = project.read(cx).exec_in_shell(command, cx);
+ let Some(mut process) = project.read(cx).exec_in_shell(command, cx).log_err() else {
+ return;
+ };
process.stdout(Stdio::piped());
process.stderr(Stdio::piped());
@@ -1,8 +1,10 @@
use editor::display_map::DisplaySnapshot;
-use editor::{DisplayPoint, Editor, SelectionEffects, ToOffset, ToPoint, movement};
+use editor::{
+ DisplayPoint, Editor, HideMouseCursorOrigin, SelectionEffects, ToOffset, ToPoint, movement,
+};
use gpui::{Action, actions};
use gpui::{Context, Window};
-use language::{CharClassifier, CharKind};
+use language::{CharClassifier, CharKind, Point};
use text::{Bias, SelectionGoal};
use crate::motion;
@@ -23,14 +25,20 @@ actions!(
HelixInsert,
/// Appends at the end of the selection.
HelixAppend,
+ /// Goes to the location of the last modification.
+ HelixGotoLastModification,
+ /// Select entire line or multiple lines, extending downwards.
+ HelixSelectLine,
]
);
pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
Vim::action(editor, cx, Vim::helix_normal_after);
+ Vim::action(editor, cx, Vim::helix_select_lines);
Vim::action(editor, cx, Vim::helix_insert);
Vim::action(editor, cx, Vim::helix_append);
Vim::action(editor, cx, Vim::helix_yank);
+ Vim::action(editor, cx, Vim::helix_goto_last_modification);
}
impl Vim {
@@ -430,6 +438,56 @@ impl Vim {
});
self.switch_mode(Mode::HelixNormal, true, window, cx);
}
+
+ pub fn helix_goto_last_modification(
+ &mut self,
+ _: &HelixGotoLastModification,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.jump(".".into(), false, false, window, cx);
+ }
+
+ pub fn helix_select_lines(
+ &mut self,
+ _: &HelixSelectLine,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let count = Vim::take_count(cx).unwrap_or(1);
+ self.update_editor(cx, |_, editor, cx| {
+ editor.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
+ let display_map = editor.display_map.update(cx, |map, cx| map.snapshot(cx));
+ let mut selections = editor.selections.all::<Point>(cx);
+ let max_point = display_map.buffer_snapshot.max_point();
+ let buffer_snapshot = &display_map.buffer_snapshot;
+
+ for selection in &mut selections {
+ // Start always goes to column 0 of the first selected line
+ let start_row = selection.start.row;
+ let current_end_row = selection.end.row;
+
+ // Check if cursor is on empty line by checking first character
+ let line_start_offset = buffer_snapshot.point_to_offset(Point::new(start_row, 0));
+ let first_char = buffer_snapshot.chars_at(line_start_offset).next();
+ let extra_line = if first_char == Some('\n') { 1 } else { 0 };
+
+ let end_row = current_end_row + count as u32 + extra_line;
+
+ selection.start = Point::new(start_row, 0);
+ selection.end = if end_row > max_point.row {
+ max_point
+ } else {
+ Point::new(end_row, 0)
+ };
+ selection.reversed = false;
+ }
+
+ editor.change_selections(Default::default(), window, cx, |s| {
+ s.select(selections);
+ });
+ });
+ }
}
#[cfg(test)]
@@ -441,6 +499,7 @@ mod test {
#[gpui::test]
async fn test_word_motions(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
+ cx.enable_helix();
// «
// ˇ
// »
@@ -502,6 +561,7 @@ mod test {
#[gpui::test]
async fn test_delete(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
+ cx.enable_helix();
// test delete a selection
cx.set_state(
@@ -582,6 +642,7 @@ mod test {
#[gpui::test]
async fn test_f_and_t(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
+ cx.enable_helix();
cx.set_state(
indoc! {"
@@ -635,6 +696,7 @@ mod test {
#[gpui::test]
async fn test_newline_char(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
+ cx.enable_helix();
cx.set_state("aa«\nˇ»bb cc", Mode::HelixNormal);
@@ -652,6 +714,7 @@ mod test {
#[gpui::test]
async fn test_insert_selected(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
+ cx.enable_helix();
cx.set_state(
indoc! {"
«The ˇ»quick brown
@@ -674,6 +737,7 @@ mod test {
#[gpui::test]
async fn test_append(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
+ cx.enable_helix();
// test from the end of the selection
cx.set_state(
indoc! {"
@@ -716,6 +780,7 @@ mod test {
#[gpui::test]
async fn test_replace(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
+ cx.enable_helix();
// No selection (single character)
cx.set_state("ˇaa", Mode::HelixNormal);
@@ -763,4 +828,210 @@ mod test {
cx.shared_clipboard().assert_eq("worl");
cx.assert_state("hello «worlˇ»d", Mode::HelixNormal);
}
+ #[gpui::test]
+ async fn test_shift_r_paste(cx: &mut gpui::TestAppContext) {
+ let mut cx = VimTestContext::new(cx, true).await;
+ cx.enable_helix();
+
+ // First copy some text to clipboard
+ cx.set_state("«hello worldˇ»", Mode::HelixNormal);
+ cx.simulate_keystrokes("y");
+
+ // Test paste with shift-r on single cursor
+ cx.set_state("foo ˇbar", Mode::HelixNormal);
+ cx.simulate_keystrokes("shift-r");
+
+ cx.assert_state("foo hello worldˇbar", Mode::HelixNormal);
+
+ // Test paste with shift-r on selection
+ cx.set_state("foo «barˇ» baz", Mode::HelixNormal);
+ cx.simulate_keystrokes("shift-r");
+
+ cx.assert_state("foo hello worldˇ baz", Mode::HelixNormal);
+ }
+
+ #[gpui::test]
+ async fn test_insert_mode_stickiness(cx: &mut gpui::TestAppContext) {
+ let mut cx = VimTestContext::new(cx, true).await;
+ cx.enable_helix();
+
+ // Make a modification at a specific location
+ cx.set_state("ˇhello", Mode::HelixNormal);
+ assert_eq!(cx.mode(), Mode::HelixNormal);
+ cx.simulate_keystrokes("i");
+ assert_eq!(cx.mode(), Mode::Insert);
+ cx.simulate_keystrokes("escape");
+ assert_eq!(cx.mode(), Mode::HelixNormal);
+ }
+
+ #[gpui::test]
+ async fn test_goto_last_modification(cx: &mut gpui::TestAppContext) {
+ let mut cx = VimTestContext::new(cx, true).await;
+ cx.enable_helix();
+
+ // Make a modification at a specific location
+ cx.set_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
+ cx.assert_state("line one\nline ˇtwo\nline three", Mode::HelixNormal);
+ cx.simulate_keystrokes("i");
+ cx.simulate_keystrokes("escape");
+ cx.simulate_keystrokes("i");
+ cx.simulate_keystrokes("m o d i f i e d space");
+ cx.simulate_keystrokes("escape");
+
+ // TODO: this fails, because state is no longer helix
+ cx.assert_state(
+ "line one\nline modified ˇtwo\nline three",
+ Mode::HelixNormal,
+ );
+
+ // Move cursor away from the modification
+ cx.simulate_keystrokes("up");
+
+ // Use "g ." to go back to last modification
+ cx.simulate_keystrokes("g .");
+
+ // Verify we're back at the modification location and still in HelixNormal mode
+ cx.assert_state(
+ "line one\nline modifiedˇ two\nline three",
+ Mode::HelixNormal,
+ );
+ }
+
+ #[gpui::test]
+ async fn test_helix_select_lines(cx: &mut gpui::TestAppContext) {
+ let mut cx = VimTestContext::new(cx, true).await;
+ cx.set_state(
+ "line one\nline ˇtwo\nline three\nline four",
+ Mode::HelixNormal,
+ );
+ cx.simulate_keystrokes("2 x");
+ cx.assert_state(
+ "line one\n«line two\nline three\nˇ»line four",
+ Mode::HelixNormal,
+ );
+
+ // Test extending existing line selection
+ cx.set_state(
+ indoc! {"
+ li«ˇne one
+ li»ne two
+ line three
+ line four"},
+ Mode::HelixNormal,
+ );
+ cx.simulate_keystrokes("x");
+ cx.assert_state(
+ indoc! {"
+ «line one
+ line two
+ ˇ»line three
+ line four"},
+ Mode::HelixNormal,
+ );
+
+ // Pressing x in empty line, select next line (because helix considers cursor a selection)
+ cx.set_state(
+ indoc! {"
+ line one
+ ˇ
+ line three
+ line four"},
+ Mode::HelixNormal,
+ );
+ cx.simulate_keystrokes("x");
+ cx.assert_state(
+ indoc! {"
+ line one
+ «
+ line three
+ ˇ»line four"},
+ Mode::HelixNormal,
+ );
+
+ // Empty line with count selects extra + count lines
+ cx.set_state(
+ indoc! {"
+ line one
+ ˇ
+ line three
+ line four
+ line five"},
+ Mode::HelixNormal,
+ );
+ cx.simulate_keystrokes("2 x");
+ cx.assert_state(
+ indoc! {"
+ line one
+ «
+ line three
+ line four
+ ˇ»line five"},
+ Mode::HelixNormal,
+ );
+
+ // Compare empty vs non-empty line behavior
+ cx.set_state(
+ indoc! {"
+ ˇnon-empty line
+ line two
+ line three"},
+ Mode::HelixNormal,
+ );
+ cx.simulate_keystrokes("x");
+ cx.assert_state(
+ indoc! {"
+ «non-empty line
+ ˇ»line two
+ line three"},
+ Mode::HelixNormal,
+ );
+
+ // Same test but with empty line - should select one extra
+ cx.set_state(
+ indoc! {"
+ ˇ
+ line two
+ line three"},
+ Mode::HelixNormal,
+ );
+ cx.simulate_keystrokes("x");
+ cx.assert_state(
+ indoc! {"
+ «
+ line two
+ ˇ»line three"},
+ Mode::HelixNormal,
+ );
+
+ // Test selecting multiple lines with count
+ cx.set_state(
+ indoc! {"
+ ˇline one
+ line two
+ line threeˇ
+ line four
+ line five"},
+ Mode::HelixNormal,
+ );
+ cx.simulate_keystrokes("x");
+ cx.assert_state(
+ indoc! {"
+ «line one
+ ˇ»line two
+ «line three
+ ˇ»line four
+ line five"},
+ Mode::HelixNormal,
+ );
+ cx.simulate_keystrokes("x");
+ cx.assert_state(
+ indoc! {"
+ «line one
+ line two
+ line three
+ line four
+ ˇ»line five"},
+ Mode::HelixNormal,
+ );
+ }
}
@@ -203,7 +203,10 @@ impl Vim {
// hook into the existing to clear out any vim search state on cmd+f or edit -> find.
fn search_deploy(&mut self, _: &buffer_search::Deploy, _: &mut Window, cx: &mut Context<Self>) {
+ // Preserve the current mode when resetting search state
+ let current_mode = self.mode;
self.search = Default::default();
+ self.search.prior_mode = current_mode;
cx.propagate();
}
@@ -7,8 +7,10 @@ use crate::{motion::Motion, object::Object};
use anyhow::Result;
use collections::HashMap;
use command_palette_hooks::{CommandPaletteFilter, CommandPaletteInterceptor};
-use db::define_connection;
-use db::sqlez_macros::sql;
+use db::{
+ sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
+ sqlez_macros::sql,
+};
use editor::display_map::{is_invisible, replacement};
use editor::{Anchor, ClipboardSelection, Editor, MultiBuffer, ToPoint as EditorToPoint};
use gpui::{
@@ -1668,8 +1670,12 @@ impl MarksView {
}
}
-define_connection! (
- pub static ref DB: VimDb<WorkspaceDb> = &[
+pub struct VimDb(ThreadSafeConnection);
+
+impl Domain for VimDb {
+ const NAME: &str = stringify!(VimDb);
+
+ const MIGRATIONS: &[&str] = &[
sql! (
CREATE TABLE vim_marks (
workspace_id INTEGER,
@@ -1689,7 +1695,9 @@ define_connection! (
ON vim_global_marks_paths(workspace_id, mark_name);
),
];
-);
+}
+
+db::static_connection!(DB, VimDb, [WorkspaceDb]);
struct SerializedMark {
path: Arc<Path>,
@@ -26,7 +26,7 @@ impl PathList {
let mut indexed_paths: Vec<(usize, PathBuf)> = paths
.iter()
.enumerate()
- .map(|(ix, path)| (ix, SanitizedPath::from(path).into()))
+ .map(|(ix, path)| (ix, SanitizedPath::new(path).into()))
.collect();
indexed_paths.sort_by(|(_, a), (_, b)| a.cmp(b));
let order = indexed_paths.iter().map(|e| e.0).collect::<Vec<_>>().into();
@@ -58,11 +58,7 @@ impl PathList {
let mut paths: Vec<PathBuf> = if serialized.paths.is_empty() {
Vec::new()
} else {
- serde_json::from_str::<Vec<PathBuf>>(&serialized.paths)
- .unwrap_or(Vec::new())
- .into_iter()
- .map(|s| SanitizedPath::from(s).into())
- .collect()
+ serialized.paths.split('\n').map(PathBuf::from).collect()
};
let mut order: Vec<usize> = serialized
@@ -85,7 +81,13 @@ impl PathList {
pub fn serialize(&self) -> SerializedPathList {
use std::fmt::Write as _;
- let paths = serde_json::to_string(&self.paths).unwrap_or_default();
+ let mut paths = String::new();
+ for path in self.paths.iter() {
+ if !paths.is_empty() {
+ paths.push('\n');
+ }
+ paths.push_str(&path.to_string_lossy());
+ }
let mut order = String::new();
for ix in self.order.iter() {
@@ -10,7 +10,11 @@ use std::{
use anyhow::{Context as _, Result, bail};
use collections::HashMap;
-use db::{define_connection, query, sqlez::connection::Connection, sqlez_macros::sql};
+use db::{
+ query,
+ sqlez::{connection::Connection, domain::Domain},
+ sqlez_macros::sql,
+};
use gpui::{Axis, Bounds, Task, WindowBounds, WindowId, point, size};
use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint};
@@ -275,186 +279,189 @@ impl sqlez::bindable::Bind for SerializedPixels {
}
}
-define_connection! {
- pub static ref DB: WorkspaceDb<()> =
- &[
- sql!(
- CREATE TABLE workspaces(
- workspace_id INTEGER PRIMARY KEY,
- workspace_location BLOB UNIQUE,
- dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed.
- dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed.
- dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed.
- left_sidebar_open INTEGER, // Boolean
- timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
- FOREIGN KEY(dock_pane) REFERENCES panes(pane_id)
- ) STRICT;
-
- CREATE TABLE pane_groups(
- group_id INTEGER PRIMARY KEY,
- workspace_id INTEGER NOT NULL,
- parent_group_id INTEGER, // NULL indicates that this is a root node
- position INTEGER, // NULL indicates that this is a root node
- axis TEXT NOT NULL, // Enum: 'Vertical' / 'Horizontal'
- FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
- ON DELETE CASCADE
- ON UPDATE CASCADE,
- FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
- ) STRICT;
-
- CREATE TABLE panes(
- pane_id INTEGER PRIMARY KEY,
- workspace_id INTEGER NOT NULL,
- active INTEGER NOT NULL, // Boolean
- FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
- ON DELETE CASCADE
- ON UPDATE CASCADE
- ) STRICT;
-
- CREATE TABLE center_panes(
- pane_id INTEGER PRIMARY KEY,
- parent_group_id INTEGER, // NULL means that this is a root pane
- position INTEGER, // NULL means that this is a root pane
- FOREIGN KEY(pane_id) REFERENCES panes(pane_id)
- ON DELETE CASCADE,
- FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
- ) STRICT;
-
- CREATE TABLE items(
- item_id INTEGER NOT NULL, // This is the item's view id, so this is not unique
- workspace_id INTEGER NOT NULL,
- pane_id INTEGER NOT NULL,
- kind TEXT NOT NULL,
- position INTEGER NOT NULL,
- active INTEGER NOT NULL,
- FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
- ON DELETE CASCADE
- ON UPDATE CASCADE,
- FOREIGN KEY(pane_id) REFERENCES panes(pane_id)
- ON DELETE CASCADE,
- PRIMARY KEY(item_id, workspace_id)
- ) STRICT;
- ),
- sql!(
- ALTER TABLE workspaces ADD COLUMN window_state TEXT;
- ALTER TABLE workspaces ADD COLUMN window_x REAL;
- ALTER TABLE workspaces ADD COLUMN window_y REAL;
- ALTER TABLE workspaces ADD COLUMN window_width REAL;
- ALTER TABLE workspaces ADD COLUMN window_height REAL;
- ALTER TABLE workspaces ADD COLUMN display BLOB;
- ),
- // Drop foreign key constraint from workspaces.dock_pane to panes table.
- sql!(
- CREATE TABLE workspaces_2(
- workspace_id INTEGER PRIMARY KEY,
- workspace_location BLOB UNIQUE,
- dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed.
- dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed.
- dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed.
- left_sidebar_open INTEGER, // Boolean
- timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
- window_state TEXT,
- window_x REAL,
- window_y REAL,
- window_width REAL,
- window_height REAL,
- display BLOB
- ) STRICT;
- INSERT INTO workspaces_2 SELECT * FROM workspaces;
- DROP TABLE workspaces;
- ALTER TABLE workspaces_2 RENAME TO workspaces;
- ),
- // Add panels related information
- sql!(
- ALTER TABLE workspaces ADD COLUMN left_dock_visible INTEGER; //bool
- ALTER TABLE workspaces ADD COLUMN left_dock_active_panel TEXT;
- ALTER TABLE workspaces ADD COLUMN right_dock_visible INTEGER; //bool
- ALTER TABLE workspaces ADD COLUMN right_dock_active_panel TEXT;
- ALTER TABLE workspaces ADD COLUMN bottom_dock_visible INTEGER; //bool
- ALTER TABLE workspaces ADD COLUMN bottom_dock_active_panel TEXT;
- ),
- // Add panel zoom persistence
- sql!(
- ALTER TABLE workspaces ADD COLUMN left_dock_zoom INTEGER; //bool
- ALTER TABLE workspaces ADD COLUMN right_dock_zoom INTEGER; //bool
- ALTER TABLE workspaces ADD COLUMN bottom_dock_zoom INTEGER; //bool
- ),
- // Add pane group flex data
- sql!(
- ALTER TABLE pane_groups ADD COLUMN flexes TEXT;
- ),
- // Add fullscreen field to workspace
- // Deprecated, `WindowBounds` holds the fullscreen state now.
- // Preserving so users can downgrade Zed.
- sql!(
- ALTER TABLE workspaces ADD COLUMN fullscreen INTEGER; //bool
- ),
- // Add preview field to items
- sql!(
- ALTER TABLE items ADD COLUMN preview INTEGER; //bool
- ),
- // Add centered_layout field to workspace
- sql!(
- ALTER TABLE workspaces ADD COLUMN centered_layout INTEGER; //bool
- ),
- sql!(
- CREATE TABLE remote_projects (
- remote_project_id INTEGER NOT NULL UNIQUE,
- path TEXT,
- dev_server_name TEXT
- );
- ALTER TABLE workspaces ADD COLUMN remote_project_id INTEGER;
- ALTER TABLE workspaces RENAME COLUMN workspace_location TO local_paths;
- ),
- sql!(
- DROP TABLE remote_projects;
- CREATE TABLE dev_server_projects (
- id INTEGER NOT NULL UNIQUE,
- path TEXT,
- dev_server_name TEXT
- );
- ALTER TABLE workspaces DROP COLUMN remote_project_id;
- ALTER TABLE workspaces ADD COLUMN dev_server_project_id INTEGER;
- ),
- sql!(
- ALTER TABLE workspaces ADD COLUMN local_paths_order BLOB;
- ),
- sql!(
- ALTER TABLE workspaces ADD COLUMN session_id TEXT DEFAULT NULL;
- ),
- sql!(
- ALTER TABLE workspaces ADD COLUMN window_id INTEGER DEFAULT NULL;
- ),
- sql!(
- ALTER TABLE panes ADD COLUMN pinned_count INTEGER DEFAULT 0;
- ),
- sql!(
- CREATE TABLE ssh_projects (
- id INTEGER PRIMARY KEY,
- host TEXT NOT NULL,
- port INTEGER,
- path TEXT NOT NULL,
- user TEXT
- );
- ALTER TABLE workspaces ADD COLUMN ssh_project_id INTEGER REFERENCES ssh_projects(id) ON DELETE CASCADE;
- ),
- sql!(
- ALTER TABLE ssh_projects RENAME COLUMN path TO paths;
- ),
- sql!(
- CREATE TABLE toolchains (
- workspace_id INTEGER,
- worktree_id INTEGER,
- language_name TEXT NOT NULL,
- name TEXT NOT NULL,
- path TEXT NOT NULL,
- PRIMARY KEY (workspace_id, worktree_id, language_name)
- );
- ),
- sql!(
- ALTER TABLE toolchains ADD COLUMN raw_json TEXT DEFAULT "{}";
- ),
- sql!(
+pub struct WorkspaceDb(ThreadSafeConnection);
+
+impl Domain for WorkspaceDb {
+ const NAME: &str = stringify!(WorkspaceDb);
+
+ const MIGRATIONS: &[&str] = &[
+ sql!(
+ CREATE TABLE workspaces(
+ workspace_id INTEGER PRIMARY KEY,
+ workspace_location BLOB UNIQUE,
+ dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed.
+ dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed.
+ dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed.
+ left_sidebar_open INTEGER, // Boolean
+ timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ FOREIGN KEY(dock_pane) REFERENCES panes(pane_id)
+ ) STRICT;
+
+ CREATE TABLE pane_groups(
+ group_id INTEGER PRIMARY KEY,
+ workspace_id INTEGER NOT NULL,
+ parent_group_id INTEGER, // NULL indicates that this is a root node
+ position INTEGER, // NULL indicates that this is a root node
+ axis TEXT NOT NULL, // Enum: 'Vertical' / 'Horizontal'
+ FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE,
+ FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
+ ) STRICT;
+
+ CREATE TABLE panes(
+ pane_id INTEGER PRIMARY KEY,
+ workspace_id INTEGER NOT NULL,
+ active INTEGER NOT NULL, // Boolean
+ FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE
+ ) STRICT;
+
+ CREATE TABLE center_panes(
+ pane_id INTEGER PRIMARY KEY,
+ parent_group_id INTEGER, // NULL means that this is a root pane
+ position INTEGER, // NULL means that this is a root pane
+ FOREIGN KEY(pane_id) REFERENCES panes(pane_id)
+ ON DELETE CASCADE,
+ FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE
+ ) STRICT;
+
+ CREATE TABLE items(
+ item_id INTEGER NOT NULL, // This is the item's view id, so this is not unique
+ workspace_id INTEGER NOT NULL,
+ pane_id INTEGER NOT NULL,
+ kind TEXT NOT NULL,
+ position INTEGER NOT NULL,
+ active INTEGER NOT NULL,
+ FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE,
+ FOREIGN KEY(pane_id) REFERENCES panes(pane_id)
+ ON DELETE CASCADE,
+ PRIMARY KEY(item_id, workspace_id)
+ ) STRICT;
+ ),
+ sql!(
+ ALTER TABLE workspaces ADD COLUMN window_state TEXT;
+ ALTER TABLE workspaces ADD COLUMN window_x REAL;
+ ALTER TABLE workspaces ADD COLUMN window_y REAL;
+ ALTER TABLE workspaces ADD COLUMN window_width REAL;
+ ALTER TABLE workspaces ADD COLUMN window_height REAL;
+ ALTER TABLE workspaces ADD COLUMN display BLOB;
+ ),
+ // Drop foreign key constraint from workspaces.dock_pane to panes table.
+ sql!(
+ CREATE TABLE workspaces_2(
+ workspace_id INTEGER PRIMARY KEY,
+ workspace_location BLOB UNIQUE,
+ dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed.
+ dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed.
+ dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed.
+ left_sidebar_open INTEGER, // Boolean
+ timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ window_state TEXT,
+ window_x REAL,
+ window_y REAL,
+ window_width REAL,
+ window_height REAL,
+ display BLOB
+ ) STRICT;
+ INSERT INTO workspaces_2 SELECT * FROM workspaces;
+ DROP TABLE workspaces;
+ ALTER TABLE workspaces_2 RENAME TO workspaces;
+ ),
+ // Add panels related information
+ sql!(
+ ALTER TABLE workspaces ADD COLUMN left_dock_visible INTEGER; //bool
+ ALTER TABLE workspaces ADD COLUMN left_dock_active_panel TEXT;
+ ALTER TABLE workspaces ADD COLUMN right_dock_visible INTEGER; //bool
+ ALTER TABLE workspaces ADD COLUMN right_dock_active_panel TEXT;
+ ALTER TABLE workspaces ADD COLUMN bottom_dock_visible INTEGER; //bool
+ ALTER TABLE workspaces ADD COLUMN bottom_dock_active_panel TEXT;
+ ),
+ // Add panel zoom persistence
+ sql!(
+ ALTER TABLE workspaces ADD COLUMN left_dock_zoom INTEGER; //bool
+ ALTER TABLE workspaces ADD COLUMN right_dock_zoom INTEGER; //bool
+ ALTER TABLE workspaces ADD COLUMN bottom_dock_zoom INTEGER; //bool
+ ),
+ // Add pane group flex data
+ sql!(
+ ALTER TABLE pane_groups ADD COLUMN flexes TEXT;
+ ),
+ // Add fullscreen field to workspace
+ // Deprecated, `WindowBounds` holds the fullscreen state now.
+ // Preserving so users can downgrade Zed.
+ sql!(
+ ALTER TABLE workspaces ADD COLUMN fullscreen INTEGER; //bool
+ ),
+ // Add preview field to items
+ sql!(
+ ALTER TABLE items ADD COLUMN preview INTEGER; //bool
+ ),
+ // Add centered_layout field to workspace
+ sql!(
+ ALTER TABLE workspaces ADD COLUMN centered_layout INTEGER; //bool
+ ),
+ sql!(
+ CREATE TABLE remote_projects (
+ remote_project_id INTEGER NOT NULL UNIQUE,
+ path TEXT,
+ dev_server_name TEXT
+ );
+ ALTER TABLE workspaces ADD COLUMN remote_project_id INTEGER;
+ ALTER TABLE workspaces RENAME COLUMN workspace_location TO local_paths;
+ ),
+ sql!(
+ DROP TABLE remote_projects;
+ CREATE TABLE dev_server_projects (
+ id INTEGER NOT NULL UNIQUE,
+ path TEXT,
+ dev_server_name TEXT
+ );
+ ALTER TABLE workspaces DROP COLUMN remote_project_id;
+ ALTER TABLE workspaces ADD COLUMN dev_server_project_id INTEGER;
+ ),
+ sql!(
+ ALTER TABLE workspaces ADD COLUMN local_paths_order BLOB;
+ ),
+ sql!(
+ ALTER TABLE workspaces ADD COLUMN session_id TEXT DEFAULT NULL;
+ ),
+ sql!(
+ ALTER TABLE workspaces ADD COLUMN window_id INTEGER DEFAULT NULL;
+ ),
+ sql!(
+ ALTER TABLE panes ADD COLUMN pinned_count INTEGER DEFAULT 0;
+ ),
+ sql!(
+ CREATE TABLE ssh_projects (
+ id INTEGER PRIMARY KEY,
+ host TEXT NOT NULL,
+ port INTEGER,
+ path TEXT NOT NULL,
+ user TEXT
+ );
+ ALTER TABLE workspaces ADD COLUMN ssh_project_id INTEGER REFERENCES ssh_projects(id) ON DELETE CASCADE;
+ ),
+ sql!(
+ ALTER TABLE ssh_projects RENAME COLUMN path TO paths;
+ ),
+ sql!(
+ CREATE TABLE toolchains (
+ workspace_id INTEGER,
+ worktree_id INTEGER,
+ language_name TEXT NOT NULL,
+ name TEXT NOT NULL,
+ path TEXT NOT NULL,
+ PRIMARY KEY (workspace_id, worktree_id, language_name)
+ );
+ ),
+ sql!(
+ ALTER TABLE toolchains ADD COLUMN raw_json TEXT DEFAULT "{}";
+ ),
+ sql!(
CREATE TABLE breakpoints (
workspace_id INTEGER NOT NULL,
path TEXT NOT NULL,
@@ -466,141 +473,172 @@ define_connection! {
ON UPDATE CASCADE
);
),
- sql!(
- ALTER TABLE workspaces ADD COLUMN local_paths_array TEXT;
- CREATE UNIQUE INDEX local_paths_array_uq ON workspaces(local_paths_array);
- ALTER TABLE workspaces ADD COLUMN local_paths_order_array TEXT;
- ),
- sql!(
- ALTER TABLE breakpoints ADD COLUMN state INTEGER DEFAULT(0) NOT NULL
- ),
- sql!(
- ALTER TABLE breakpoints DROP COLUMN kind
- ),
- sql!(ALTER TABLE toolchains ADD COLUMN relative_worktree_path TEXT DEFAULT "" NOT NULL),
- sql!(
- ALTER TABLE breakpoints ADD COLUMN condition TEXT;
- ALTER TABLE breakpoints ADD COLUMN hit_condition TEXT;
- ),
- sql!(CREATE TABLE toolchains2 (
- workspace_id INTEGER,
- worktree_id INTEGER,
- language_name TEXT NOT NULL,
- name TEXT NOT NULL,
- path TEXT NOT NULL,
- raw_json TEXT NOT NULL,
- relative_worktree_path TEXT NOT NULL,
- PRIMARY KEY (workspace_id, worktree_id, language_name, relative_worktree_path)) STRICT;
- INSERT INTO toolchains2
- SELECT * FROM toolchains;
- DROP TABLE toolchains;
- ALTER TABLE toolchains2 RENAME TO toolchains;
- ),
- sql!(
- CREATE TABLE ssh_connections (
- id INTEGER PRIMARY KEY,
- host TEXT NOT NULL,
- port INTEGER,
- user TEXT
- );
+ sql!(
+ ALTER TABLE workspaces ADD COLUMN local_paths_array TEXT;
+ CREATE UNIQUE INDEX local_paths_array_uq ON workspaces(local_paths_array);
+ ALTER TABLE workspaces ADD COLUMN local_paths_order_array TEXT;
+ ),
+ sql!(
+ ALTER TABLE breakpoints ADD COLUMN state INTEGER DEFAULT(0) NOT NULL
+ ),
+ sql!(
+ ALTER TABLE breakpoints DROP COLUMN kind
+ ),
+ sql!(ALTER TABLE toolchains ADD COLUMN relative_worktree_path TEXT DEFAULT "" NOT NULL),
+ sql!(
+ ALTER TABLE breakpoints ADD COLUMN condition TEXT;
+ ALTER TABLE breakpoints ADD COLUMN hit_condition TEXT;
+ ),
+ sql!(CREATE TABLE toolchains2 (
+ workspace_id INTEGER,
+ worktree_id INTEGER,
+ language_name TEXT NOT NULL,
+ name TEXT NOT NULL,
+ path TEXT NOT NULL,
+ raw_json TEXT NOT NULL,
+ relative_worktree_path TEXT NOT NULL,
+ PRIMARY KEY (workspace_id, worktree_id, language_name, relative_worktree_path)) STRICT;
+ INSERT INTO toolchains2
+ SELECT * FROM toolchains;
+ DROP TABLE toolchains;
+ ALTER TABLE toolchains2 RENAME TO toolchains;
+ ),
+ sql!(
+ CREATE TABLE ssh_connections (
+ id INTEGER PRIMARY KEY,
+ host TEXT NOT NULL,
+ port INTEGER,
+ user TEXT
+ );
+
+ INSERT INTO ssh_connections (host, port, user)
+ SELECT DISTINCT host, port, user
+ FROM ssh_projects;
+
+ CREATE TABLE workspaces_2(
+ workspace_id INTEGER PRIMARY KEY,
+ paths TEXT,
+ paths_order TEXT,
+ ssh_connection_id INTEGER REFERENCES ssh_connections(id),
+ timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ window_state TEXT,
+ window_x REAL,
+ window_y REAL,
+ window_width REAL,
+ window_height REAL,
+ display BLOB,
+ left_dock_visible INTEGER,
+ left_dock_active_panel TEXT,
+ right_dock_visible INTEGER,
+ right_dock_active_panel TEXT,
+ bottom_dock_visible INTEGER,
+ bottom_dock_active_panel TEXT,
+ left_dock_zoom INTEGER,
+ right_dock_zoom INTEGER,
+ bottom_dock_zoom INTEGER,
+ fullscreen INTEGER,
+ centered_layout INTEGER,
+ session_id TEXT,
+ window_id INTEGER
+ ) STRICT;
+
+ INSERT
+ INTO workspaces_2
+ SELECT
+ workspaces.workspace_id,
+ CASE
+ WHEN ssh_projects.id IS NOT NULL THEN ssh_projects.paths
+ ELSE
+ CASE
+ WHEN workspaces.local_paths_array IS NULL OR workspaces.local_paths_array = "" THEN
+ NULL
+ ELSE
+ replace(workspaces.local_paths_array, ',', CHAR(10))
+ END
+ END as paths,
+
+ CASE
+ WHEN ssh_projects.id IS NOT NULL THEN ""
+ ELSE workspaces.local_paths_order_array
+ END as paths_order,
+
+ CASE
+ WHEN ssh_projects.id IS NOT NULL THEN (
+ SELECT ssh_connections.id
+ FROM ssh_connections
+ WHERE
+ ssh_connections.host IS ssh_projects.host AND
+ ssh_connections.port IS ssh_projects.port AND
+ ssh_connections.user IS ssh_projects.user
+ )
+ ELSE NULL
+ END as ssh_connection_id,
+
+ workspaces.timestamp,
+ workspaces.window_state,
+ workspaces.window_x,
+ workspaces.window_y,
+ workspaces.window_width,
+ workspaces.window_height,
+ workspaces.display,
+ workspaces.left_dock_visible,
+ workspaces.left_dock_active_panel,
+ workspaces.right_dock_visible,
+ workspaces.right_dock_active_panel,
+ workspaces.bottom_dock_visible,
+ workspaces.bottom_dock_active_panel,
+ workspaces.left_dock_zoom,
+ workspaces.right_dock_zoom,
+ workspaces.bottom_dock_zoom,
+ workspaces.fullscreen,
+ workspaces.centered_layout,
+ workspaces.session_id,
+ workspaces.window_id
+ FROM
+ workspaces LEFT JOIN
+ ssh_projects ON
+ workspaces.ssh_project_id = ssh_projects.id;
+
+ DELETE FROM workspaces_2
+ WHERE workspace_id NOT IN (
+ SELECT MAX(workspace_id)
+ FROM workspaces_2
+ GROUP BY ssh_connection_id, paths
+ );
- INSERT INTO ssh_connections (host, port, user)
- SELECT DISTINCT host, port, user
- FROM ssh_projects;
-
- CREATE TABLE workspaces_2(
- workspace_id INTEGER PRIMARY KEY,
- paths TEXT,
- paths_order TEXT,
- ssh_connection_id INTEGER REFERENCES ssh_connections(id),
- timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
- window_state TEXT,
- window_x REAL,
- window_y REAL,
- window_width REAL,
- window_height REAL,
- display BLOB,
- left_dock_visible INTEGER,
- left_dock_active_panel TEXT,
- right_dock_visible INTEGER,
- right_dock_active_panel TEXT,
- bottom_dock_visible INTEGER,
- bottom_dock_active_panel TEXT,
- left_dock_zoom INTEGER,
- right_dock_zoom INTEGER,
- bottom_dock_zoom INTEGER,
- fullscreen INTEGER,
- centered_layout INTEGER,
- session_id TEXT,
- window_id INTEGER
- ) STRICT;
-
- INSERT
- INTO workspaces_2
- SELECT
- workspaces.workspace_id,
- CASE
- WHEN ssh_projects.id IS NOT NULL THEN ssh_projects.paths
+ DROP TABLE ssh_projects;
+ DROP TABLE workspaces;
+ ALTER TABLE workspaces_2 RENAME TO workspaces;
+
+ CREATE UNIQUE INDEX ix_workspaces_location ON workspaces(ssh_connection_id, paths);
+ ),
+ // Fix any data from when workspaces.paths were briefly encoded as JSON arrays
+ sql!(
+ UPDATE workspaces
+ SET paths = CASE
+ WHEN substr(paths, 1, 2) = '[' || '"' AND substr(paths, -2, 2) = '"' || ']' THEN
+ replace(
+ substr(paths, 3, length(paths) - 4),
+ '"' || ',' || '"',
+ CHAR(10)
+ )
ELSE
- CASE
- WHEN workspaces.local_paths_array IS NULL OR workspaces.local_paths_array = "" THEN
- NULL
- ELSE
- json('[' || '"' || replace(workspaces.local_paths_array, ',', '"' || "," || '"') || '"' || ']')
- END
- END as paths,
-
- CASE
- WHEN ssh_projects.id IS NOT NULL THEN ""
- ELSE workspaces.local_paths_order_array
- END as paths_order,
-
- CASE
- WHEN ssh_projects.id IS NOT NULL THEN (
- SELECT ssh_connections.id
- FROM ssh_connections
- WHERE
- ssh_connections.host IS ssh_projects.host AND
- ssh_connections.port IS ssh_projects.port AND
- ssh_connections.user IS ssh_projects.user
- )
- ELSE NULL
- END as ssh_connection_id,
-
- workspaces.timestamp,
- workspaces.window_state,
- workspaces.window_x,
- workspaces.window_y,
- workspaces.window_width,
- workspaces.window_height,
- workspaces.display,
- workspaces.left_dock_visible,
- workspaces.left_dock_active_panel,
- workspaces.right_dock_visible,
- workspaces.right_dock_active_panel,
- workspaces.bottom_dock_visible,
- workspaces.bottom_dock_active_panel,
- workspaces.left_dock_zoom,
- workspaces.right_dock_zoom,
- workspaces.bottom_dock_zoom,
- workspaces.fullscreen,
- workspaces.centered_layout,
- workspaces.session_id,
- workspaces.window_id
- FROM
- workspaces LEFT JOIN
- ssh_projects ON
- workspaces.ssh_project_id = ssh_projects.id;
-
- DROP TABLE ssh_projects;
- DROP TABLE workspaces;
- ALTER TABLE workspaces_2 RENAME TO workspaces;
-
- CREATE UNIQUE INDEX ix_workspaces_location ON workspaces(ssh_connection_id, paths);
- ),
+ replace(paths, ',', CHAR(10))
+ END
+ WHERE paths IS NOT NULL
+ ),
];
+
+ // Allow recovering from bad migration that was initially shipped to nightly
+ // when introducing the ssh_connections table.
+ fn should_allow_migration_change(_index: usize, old: &str, new: &str) -> bool {
+ old.starts_with("CREATE TABLE ssh_connections")
+ && new.starts_with("CREATE TABLE ssh_connections")
+ }
}
+db::static_connection!(DB, WorkspaceDb, []);
+
impl WorkspaceDb {
/// Returns a serialized workspace for the given worktree_roots. If the passed array
/// is empty, the most recent workspace is returned instead. If no workspace for the
@@ -786,7 +824,6 @@ impl WorkspaceDb {
conn.exec_bound(
sql!(
DELETE FROM breakpoints WHERE workspace_id = ?1;
- DELETE FROM toolchains WHERE workspace_id = ?1;
)
)?(workspace.id).context("Clearing old breakpoints")?;
@@ -1059,7 +1096,6 @@ impl WorkspaceDb {
query! {
pub async fn delete_workspace_by_id(id: WorkspaceId) -> Result<()> {
- DELETE FROM toolchains WHERE workspace_id = ?1;
DELETE FROM workspaces
WHERE workspace_id IS ?
}
@@ -1386,24 +1422,24 @@ impl WorkspaceDb {
&self,
workspace_id: WorkspaceId,
worktree_id: WorktreeId,
- relative_path: String,
+ relative_worktree_path: String,
language_name: LanguageName,
) -> Result<Option<Toolchain>> {
self.write(move |this| {
let mut select = this
.select_bound(sql!(
- SELECT name, path, raw_json FROM toolchains WHERE workspace_id = ? AND language_name = ? AND worktree_id = ? AND relative_path = ?
+ SELECT name, path, raw_json FROM toolchains WHERE workspace_id = ? AND language_name = ? AND worktree_id = ? AND relative_worktree_path = ?
))
- .context("Preparing insertion")?;
+ .context("select toolchain")?;
let toolchain: Vec<(String, String, String)> =
- select((workspace_id, language_name.as_ref().to_string(), worktree_id.to_usize(), relative_path))?;
+ select((workspace_id, language_name.as_ref().to_string(), worktree_id.to_usize(), relative_worktree_path))?;
Ok(toolchain.into_iter().next().and_then(|(name, path, raw_json)| Some(Toolchain {
name: name.into(),
path: path.into(),
language_name,
- as_json: serde_json::Value::from_str(&raw_json).ok()?
+ as_json: serde_json::Value::from_str(&raw_json).ok()?,
})))
})
.await
@@ -1418,7 +1454,7 @@ impl WorkspaceDb {
.select_bound(sql!(
SELECT name, path, worktree_id, relative_worktree_path, language_name, raw_json FROM toolchains WHERE workspace_id = ?
))
- .context("Preparing insertion")?;
+ .context("select toolchains")?;
let toolchain: Vec<(String, String, u64, String, String, String)> =
select(workspace_id)?;
@@ -1427,7 +1463,7 @@ impl WorkspaceDb {
name: name.into(),
path: path.into(),
language_name: LanguageName::new(&language_name),
- as_json: serde_json::Value::from_str(&raw_json).ok()?
+ as_json: serde_json::Value::from_str(&raw_json).ok()?,
}, WorktreeId::from_proto(worktree_id), Arc::from(relative_worktree_path.as_ref())))).collect())
})
.await
@@ -1803,6 +1839,7 @@ mod tests {
ON DELETE CASCADE
) STRICT;
)],
+ |_, _, _| false,
)
.unwrap();
})
@@ -1851,6 +1888,7 @@ mod tests {
REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;)],
+ |_, _, _| false,
)
})
.await
@@ -20,7 +20,7 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) {
- match self.project.read(cx).ssh_connection_state(cx) {
+ match self.project.read(cx).remote_connection_state(cx) {
None | Some(ConnectionState::Connected) => {}
Some(
ConnectionState::Connecting
@@ -42,9 +42,9 @@ use gpui::{
Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context,
CursorStyle, Decorations, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle,
Focusable, Global, HitboxBehavior, Hsla, KeyContext, Keystroke, ManagedView, MouseButton,
- PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size, Stateful, Subscription, Task,
- Tiling, WeakEntity, WindowBounds, WindowHandle, WindowId, WindowOptions, actions, canvas,
- point, relative, size, transparent_black,
+ PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size, Stateful, Subscription,
+ SystemWindowTabController, Task, Tiling, WeakEntity, WindowBounds, WindowHandle, WindowId,
+ WindowOptions, actions, canvas, point, relative, size, transparent_black,
};
pub use history_manager::*;
pub use item::{
@@ -74,7 +74,7 @@ use project::{
DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId,
debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus},
};
-use remote::{SshClientDelegate, SshConnectionOptions, ssh_session::ConnectionIdentifier};
+use remote::{RemoteClientDelegate, SshConnectionOptions, remote_client::ConnectionIdentifier};
use schemars::JsonSchema;
use serde::Deserialize;
use session::AppSession;
@@ -2084,7 +2084,7 @@ impl Workspace {
cx: &mut Context<Self>,
) -> oneshot::Receiver<Option<Vec<PathBuf>>> {
if self.project.read(cx).is_via_collab()
- || self.project.read(cx).is_via_ssh()
+ || self.project.read(cx).is_via_remote_server()
|| !WorkspaceSettings::get_global(cx).use_system_path_prompts
{
let prompt = self.on_prompt_for_new_path.take().unwrap();
@@ -2576,7 +2576,7 @@ impl Workspace {
};
let this = this.clone();
- let abs_path: Arc<Path> = SanitizedPath::from(abs_path.clone()).into();
+ let abs_path: Arc<Path> = SanitizedPath::new(&abs_path).as_path().into();
let fs = fs.clone();
let pane = pane.clone();
let task = cx.spawn(async move |cx| {
@@ -4375,6 +4375,11 @@ impl Workspace {
return;
}
window.set_window_title(&title);
+ SystemWindowTabController::update_tab_title(
+ cx,
+ window.window_handle().window_id(),
+ SharedString::from(&title),
+ );
self.last_window_title = Some(title);
}
@@ -5249,7 +5254,7 @@ impl Workspace {
fn serialize_workspace_location(&self, cx: &App) -> WorkspaceLocation {
let paths = PathList::new(&self.root_paths(cx));
- if let Some(connection) = self.project.read(cx).ssh_connection_options(cx) {
+ if let Some(connection) = self.project.read(cx).remote_connection_options(cx) {
WorkspaceLocation::Location(
SerializedWorkspaceLocation::Ssh(SerializedSshConnection {
host: connection.host,
@@ -5797,17 +5802,22 @@ impl Workspace {
return;
};
let windows = cx.windows();
- let Some(next_window) = windows
- .iter()
- .cycle()
- .skip_while(|window| window.window_id() != current_window_id)
- .nth(1)
- else {
- return;
- };
- next_window
- .update(cx, |_, window, _| window.activate_window())
- .ok();
+ let next_window =
+ SystemWindowTabController::get_next_tab_group_window(cx, current_window_id).or_else(
+ || {
+ windows
+ .iter()
+ .cycle()
+ .skip_while(|window| window.window_id() != current_window_id)
+ .nth(1)
+ },
+ );
+
+ if let Some(window) = next_window {
+ window
+ .update(cx, |_, window, _| window.activate_window())
+ .ok();
+ }
}
pub fn activate_previous_window(&mut self, cx: &mut Context<Self>) {
@@ -5815,18 +5825,23 @@ impl Workspace {
return;
};
let windows = cx.windows();
- let Some(prev_window) = windows
- .iter()
- .rev()
- .cycle()
- .skip_while(|window| window.window_id() != current_window_id)
- .nth(1)
- else {
- return;
- };
- prev_window
- .update(cx, |_, window, _| window.activate_window())
- .ok();
+ let prev_window =
+ SystemWindowTabController::get_prev_tab_group_window(cx, current_window_id).or_else(
+ || {
+ windows
+ .iter()
+ .rev()
+ .cycle()
+ .skip_while(|window| window.window_id() != current_window_id)
+ .nth(1)
+ },
+ );
+
+ if let Some(window) = prev_window {
+ window
+ .update(cx, |_, window, _| window.activate_window())
+ .ok();
+ }
}
pub fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
@@ -6875,7 +6890,8 @@ async fn join_channel_internal(
| Status::Authenticating
| Status::Authenticated
| Status::Reconnecting
- | Status::Reauthenticating => continue,
+ | Status::Reauthenticating
+ | Status::Reauthenticated => continue,
Status::Connected { .. } => break 'outer,
Status::SignedOut | Status::AuthenticationError => {
return Err(ErrorCode::SignedOut.into());
@@ -6917,7 +6933,7 @@ async fn join_channel_internal(
return None;
}
- if (project.is_local() || project.is_via_ssh())
+ if (project.is_local() || project.is_via_remote_server())
&& project.visible_worktrees(cx).any(|tree| {
tree.read(cx)
.root_entry()
@@ -7263,7 +7279,7 @@ pub fn open_ssh_project_with_new_connection(
window: WindowHandle<Workspace>,
connection_options: SshConnectionOptions,
cancel_rx: oneshot::Receiver<()>,
- delegate: Arc<dyn SshClientDelegate>,
+ delegate: Arc<dyn RemoteClientDelegate>,
app_state: Arc<AppState>,
paths: Vec<PathBuf>,
cx: &mut App,
@@ -7274,7 +7290,7 @@ pub fn open_ssh_project_with_new_connection(
let session = match cx
.update(|cx| {
- remote::SshRemoteClient::new(
+ remote::RemoteClient::ssh(
ConnectionIdentifier::Workspace(workspace_id.0),
connection_options,
cancel_rx,
@@ -7289,7 +7305,7 @@ pub fn open_ssh_project_with_new_connection(
};
let project = cx.update(|cx| {
- project::Project::ssh(
+ project::Project::remote(
session,
app_state.client.clone(),
app_state.node_runtime.clone(),
@@ -29,6 +29,7 @@ pub struct WorkspaceSettings {
pub on_last_window_closed: OnLastWindowClosed,
pub resize_all_panels_in_dock: Vec<DockPosition>,
pub close_on_file_delete: bool,
+ pub use_system_window_tabs: bool,
pub zoomed_padding: bool,
}
@@ -203,6 +204,10 @@ pub struct WorkspaceSettingsContent {
///
/// Default: false
pub close_on_file_delete: Option<bool>,
+ /// Whether to allow windows to tab together based on the user’s tabbing preference (macOS only).
+ ///
+ /// Default: false
+ pub use_system_window_tabs: Option<bool>,
/// Whether to show padding for zoomed panels.
/// When enabled, zoomed bottom panels will have some top padding,
/// while zoomed left/right panels will have padding to the right/left (respectively).
@@ -357,6 +362,8 @@ impl Settings for WorkspaceSettings {
current.max_tabs = Some(n)
}
+ vscode.bool_setting("window.nativeTabs", &mut current.use_system_window_tabs);
+
// some combination of "window.restoreWindows" and "workbench.startupEditor" might
// map to our "restore_on_startup"
@@ -158,7 +158,7 @@ pub struct RemoteWorktree {
#[derive(Clone)]
pub struct Snapshot {
id: WorktreeId,
- abs_path: SanitizedPath,
+ abs_path: Arc<SanitizedPath>,
root_name: String,
root_char_bag: CharBag,
entries_by_path: SumTree<Entry>,
@@ -457,7 +457,7 @@ enum ScanState {
scanning: bool,
},
RootUpdated {
- new_path: Option<SanitizedPath>,
+ new_path: Option<Arc<SanitizedPath>>,
},
}
@@ -763,8 +763,8 @@ impl Worktree {
pub fn abs_path(&self) -> Arc<Path> {
match self {
- Worktree::Local(worktree) => worktree.abs_path.clone().into(),
- Worktree::Remote(worktree) => worktree.abs_path.clone().into(),
+ Worktree::Local(worktree) => SanitizedPath::cast_arc(worktree.abs_path.clone()),
+ Worktree::Remote(worktree) => SanitizedPath::cast_arc(worktree.abs_path.clone()),
}
}
@@ -1813,7 +1813,7 @@ impl LocalWorktree {
// Otherwise, the FS watcher would do it on the `RootUpdated` event,
// but with a noticeable delay, so we handle it proactively.
local.update_abs_path_and_refresh(
- Some(SanitizedPath::from(abs_path.clone())),
+ Some(SanitizedPath::new_arc(&abs_path)),
cx,
);
Task::ready(Ok(this.root_entry().cloned()))
@@ -2090,7 +2090,7 @@ impl LocalWorktree {
fn update_abs_path_and_refresh(
&mut self,
- new_path: Option<SanitizedPath>,
+ new_path: Option<Arc<SanitizedPath>>,
cx: &Context<Worktree>,
) {
if let Some(new_path) = new_path {
@@ -2340,7 +2340,7 @@ impl Snapshot {
pub fn new(id: u64, root_name: String, abs_path: Arc<Path>) -> Self {
Snapshot {
id: WorktreeId::from_usize(id as usize),
- abs_path: abs_path.into(),
+ abs_path: SanitizedPath::from_arc(abs_path),
root_char_bag: root_name.chars().map(|c| c.to_ascii_lowercase()).collect(),
root_name,
always_included_entries: Default::default(),
@@ -2368,7 +2368,7 @@ impl Snapshot {
//
// This is definitely a bug, but it's not clear if we should handle it here or not.
pub fn abs_path(&self) -> &Arc<Path> {
- self.abs_path.as_path()
+ SanitizedPath::cast_arc_ref(&self.abs_path)
}
fn build_initial_update(&self, project_id: u64, worktree_id: u64) -> proto::UpdateWorktree {
@@ -2464,7 +2464,7 @@ impl Snapshot {
Some(removed_entry.path)
}
- fn update_abs_path(&mut self, abs_path: SanitizedPath, root_name: String) {
+ fn update_abs_path(&mut self, abs_path: Arc<SanitizedPath>, root_name: String) {
self.abs_path = abs_path;
if root_name != self.root_name {
self.root_char_bag = root_name.chars().map(|c| c.to_ascii_lowercase()).collect();
@@ -2483,7 +2483,7 @@ impl Snapshot {
update.removed_entries.len()
);
self.update_abs_path(
- SanitizedPath::from(PathBuf::from_proto(update.abs_path)),
+ SanitizedPath::new_arc(&PathBuf::from_proto(update.abs_path)),
update.root_name,
);
@@ -3151,16 +3151,6 @@ impl BackgroundScannerState {
.work_directory_abs_path(&work_directory)
.log_err()?;
- if self
- .snapshot
- .git_repositories
- .get(&work_dir_entry.id)
- .is_some()
- {
- log::trace!("existing git repository for {work_directory:?}");
- return None;
- }
-
let dot_git_abs_path: Arc<Path> = self
.snapshot
.abs_path
@@ -3859,7 +3849,11 @@ impl BackgroundScanner {
root_entry.is_ignored = true;
state.insert_entry(root_entry.clone(), self.fs.as_ref(), self.watcher.as_ref());
}
- state.enqueue_scan_dir(root_abs_path.into(), &root_entry, &scan_job_tx);
+ state.enqueue_scan_dir(
+ SanitizedPath::cast_arc(root_abs_path),
+ &root_entry,
+ &scan_job_tx,
+ );
}
};
@@ -3940,8 +3934,9 @@ impl BackgroundScanner {
self.forcibly_load_paths(&request.relative_paths).await;
let root_path = self.state.lock().snapshot.abs_path.clone();
- let root_canonical_path = match self.fs.canonicalize(root_path.as_path()).await {
- Ok(path) => SanitizedPath::from(path),
+ let root_canonical_path = self.fs.canonicalize(root_path.as_path()).await;
+ let root_canonical_path = match &root_canonical_path {
+ Ok(path) => SanitizedPath::new(path),
Err(err) => {
log::error!("failed to canonicalize root path {root_path:?}: {err}");
return true;
@@ -3969,8 +3964,8 @@ impl BackgroundScanner {
}
self.reload_entries_for_paths(
- root_path,
- root_canonical_path,
+ &root_path,
+ &root_canonical_path,
&request.relative_paths,
abs_paths,
None,
@@ -3982,8 +3977,9 @@ impl BackgroundScanner {
async fn process_events(&self, mut abs_paths: Vec<PathBuf>) {
let root_path = self.state.lock().snapshot.abs_path.clone();
- let root_canonical_path = match self.fs.canonicalize(root_path.as_path()).await {
- Ok(path) => SanitizedPath::from(path),
+ let root_canonical_path = self.fs.canonicalize(root_path.as_path()).await;
+ let root_canonical_path = match &root_canonical_path {
+ Ok(path) => SanitizedPath::new(path),
Err(err) => {
let new_path = self
.state
@@ -3992,7 +3988,7 @@ impl BackgroundScanner {
.root_file_handle
.clone()
.and_then(|handle| handle.current_path(&self.fs).log_err())
- .map(SanitizedPath::from)
+ .map(|path| SanitizedPath::new_arc(&path))
.filter(|new_path| *new_path != root_path);
if let Some(new_path) = new_path.as_ref() {
@@ -4021,7 +4017,7 @@ impl BackgroundScanner {
abs_paths.sort_unstable();
abs_paths.dedup_by(|a, b| a.starts_with(b));
abs_paths.retain(|abs_path| {
- let abs_path = SanitizedPath::from(abs_path);
+ let abs_path = &SanitizedPath::new(abs_path);
let snapshot = &self.state.lock().snapshot;
{
@@ -4064,7 +4060,7 @@ impl BackgroundScanner {
return false;
};
- if abs_path.0.file_name() == Some(*GITIGNORE) {
+ if abs_path.file_name() == Some(*GITIGNORE) {
for (_, repo) in snapshot.git_repositories.iter().filter(|(_, repo)| repo.directory_contains(&relative_path)) {
if !dot_git_abs_paths.iter().any(|dot_git_abs_path| dot_git_abs_path == repo.common_dir_abs_path.as_ref()) {
dot_git_abs_paths.push(repo.common_dir_abs_path.to_path_buf());
@@ -4103,8 +4099,8 @@ impl BackgroundScanner {
let (scan_job_tx, scan_job_rx) = channel::unbounded();
log::debug!("received fs events {:?}", relative_paths);
self.reload_entries_for_paths(
- root_path,
- root_canonical_path,
+ &root_path,
+ &root_canonical_path,
&relative_paths,
abs_paths,
Some(scan_job_tx.clone()),
@@ -4451,8 +4447,8 @@ impl BackgroundScanner {
/// All list arguments should be sorted before calling this function
async fn reload_entries_for_paths(
&self,
- root_abs_path: SanitizedPath,
- root_canonical_path: SanitizedPath,
+ root_abs_path: &SanitizedPath,
+ root_canonical_path: &SanitizedPath,
relative_paths: &[Arc<Path>],
abs_paths: Vec<PathBuf>,
scan_queue_tx: Option<Sender<ScanJob>>,
@@ -4480,7 +4476,7 @@ impl BackgroundScanner {
}
}
- anyhow::Ok(Some((metadata, SanitizedPath::from(canonical_path))))
+ anyhow::Ok(Some((metadata, SanitizedPath::new_arc(&canonical_path))))
} else {
Ok(None)
}
@@ -20,6 +20,8 @@ pub enum Model {
Grok3MiniFast,
#[serde(rename = "grok-4-latest")]
Grok4,
+ #[serde(rename = "grok-code-fast-1")]
+ GrokCodeFast1,
#[serde(rename = "custom")]
Custom {
name: String,
@@ -43,6 +45,7 @@ impl Model {
"grok-3-mini" => Ok(Self::Grok3Mini),
"grok-3-fast" => Ok(Self::Grok3Fast),
"grok-3-mini-fast" => Ok(Self::Grok3MiniFast),
+ "grok-code-fast-1" => Ok(Self::GrokCodeFast1),
_ => anyhow::bail!("invalid model id '{id}'"),
}
}
@@ -55,6 +58,7 @@ impl Model {
Self::Grok3Fast => "grok-3-fast",
Self::Grok3MiniFast => "grok-3-mini-fast",
Self::Grok4 => "grok-4",
+ Self::GrokCodeFast1 => "grok-code-fast-1",
Self::Custom { name, .. } => name,
}
}
@@ -67,6 +71,7 @@ impl Model {
Self::Grok3Fast => "Grok 3 Fast",
Self::Grok3MiniFast => "Grok 3 Mini Fast",
Self::Grok4 => "Grok 4",
+ Self::GrokCodeFast1 => "Grok Code Fast 1",
Self::Custom {
name, display_name, ..
} => display_name.as_ref().unwrap_or(name),
@@ -76,7 +81,7 @@ impl Model {
pub fn max_token_count(&self) -> u64 {
match self {
Self::Grok3 | Self::Grok3Mini | Self::Grok3Fast | Self::Grok3MiniFast => 131_072,
- Self::Grok4 => 256_000,
+ Self::Grok4 | Self::GrokCodeFast1 => 256_000,
Self::Grok2Vision => 8_192,
Self::Custom { max_tokens, .. } => *max_tokens,
}
@@ -85,7 +90,7 @@ impl Model {
pub fn max_output_tokens(&self) -> Option<u64> {
match self {
Self::Grok3 | Self::Grok3Mini | Self::Grok3Fast | Self::Grok3MiniFast => Some(8_192),
- Self::Grok4 => Some(64_000),
+ Self::Grok4 | Self::GrokCodeFast1 => Some(64_000),
Self::Grok2Vision => Some(4_096),
Self::Custom {
max_output_tokens, ..
@@ -101,7 +106,7 @@ impl Model {
| Self::Grok3Fast
| Self::Grok3MiniFast
| Self::Grok4 => true,
- Model::Custom { .. } => false,
+ Self::GrokCodeFast1 | Model::Custom { .. } => false,
}
}
@@ -116,7 +121,8 @@ impl Model {
| Self::Grok3Mini
| Self::Grok3Fast
| Self::Grok3MiniFast
- | Self::Grok4 => true,
+ | Self::Grok4
+ | Self::GrokCodeFast1 => true,
Model::Custom { .. } => false,
}
}
@@ -2,7 +2,7 @@
description = "The fast, collaborative code editor."
edition.workspace = true
name = "zed"
-version = "0.202.0"
+version = "0.203.0"
publish.workspace = true
license = "GPL-3.0-or-later"
authors = ["Zed Team <hi@zed.dev>"]
@@ -2,7 +2,7 @@ mod reliability;
mod zed;
use agent_ui::AgentPanel;
-use anyhow::{Context as _, Result};
+use anyhow::{Context as _, Error, Result};
use clap::{Parser, command};
use cli::FORCE_CLI_MODE_ENV_VAR_NAME;
use client::{Client, ProxySettings, UserStore, parse_zed_link};
@@ -947,9 +947,13 @@ async fn installation_id() -> Result<IdType> {
async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp) -> Result<()> {
if let Some(locations) = restorable_workspace_locations(cx, &app_state).await {
+ let use_system_window_tabs = cx
+ .update(|cx| WorkspaceSettings::get(None, cx).use_system_window_tabs)
+ .unwrap_or(false);
+ let mut results: Vec<Result<(), Error>> = Vec::new();
let mut tasks = Vec::new();
- for (location, paths) in locations {
+ for (index, (location, paths)) in locations.into_iter().enumerate() {
match location {
SerializedWorkspaceLocation::Local => {
let app_state = app_state.clone();
@@ -964,7 +968,14 @@ async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp
})?;
open_task.await.map(|_| ())
});
- tasks.push(task);
+
+ // If we're using system window tabs and this is the first workspace,
+ // wait for it to finish so that the other windows can be added as tabs.
+ if use_system_window_tabs && index == 0 {
+ results.push(task.await);
+ } else {
+ tasks.push(task);
+ }
}
SerializedWorkspaceLocation::Ssh(ssh) => {
let app_state = app_state.clone();
@@ -998,7 +1009,7 @@ async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp
}
// Wait for all workspaces to open concurrently
- let results = future::join_all(tasks).await;
+ results.extend(future::join_all(tasks).await);
// Show notifications for any errors that occurred
let mut error_count = 0;
@@ -220,10 +220,10 @@ pub fn init(
let installation_id = installation_id.clone();
let system_id = system_id.clone();
- let Some(ssh_client) = project.ssh_client() else {
+ let Some(remote_client) = project.remote_client() else {
return;
};
- ssh_client.update(cx, |client, cx| {
+ remote_client.update(cx, |client, cx| {
if !TelemetrySettings::get_global(cx).diagnostics {
return;
}
@@ -32,7 +32,8 @@ use gpui::{
};
use image_viewer::ImageInfo;
use language::Capability;
-use language_tools::lsp_tool::{self, LspTool};
+use language_tools::lsp_button::{self, LspButton};
+use language_tools::lsp_log_view::LspLogToolbarItemView;
use migrate::{MigrationBanner, MigrationEvent, MigrationNotification, MigrationType};
use migrator::{migrate_keymap, migrate_settings};
use onboarding::DOCS_URL;
@@ -282,6 +283,8 @@ pub fn build_window_options(display_uuid: Option<Uuid>, cx: &mut App) -> WindowO
_ => gpui::WindowDecorations::Client,
};
+ let use_system_window_tabs = WorkspaceSettings::get_global(cx).use_system_window_tabs;
+
WindowOptions {
titlebar: Some(TitlebarOptions {
title: None,
@@ -301,6 +304,12 @@ pub fn build_window_options(display_uuid: Option<Uuid>, cx: &mut App) -> WindowO
width: px(360.0),
height: px(240.0),
}),
+ tabbing_identifier: if use_system_window_tabs {
+ Some(String::from("zed"))
+ } else {
+ None
+ },
+ ..Default::default()
}
}
@@ -388,12 +397,12 @@ pub fn initialize_workspace(
let vim_mode_indicator = cx.new(|cx| vim::ModeIndicator::new(window, cx));
let image_info = cx.new(|_cx| ImageInfo::new(workspace));
- let lsp_tool_menu_handle = PopoverMenuHandle::default();
- let lsp_tool =
- cx.new(|cx| LspTool::new(workspace, lsp_tool_menu_handle.clone(), window, cx));
+ let lsp_button_menu_handle = PopoverMenuHandle::default();
+ let lsp_button =
+ cx.new(|cx| LspButton::new(workspace, lsp_button_menu_handle.clone(), window, cx));
workspace.register_action({
- move |_, _: &lsp_tool::ToggleMenu, window, cx| {
- lsp_tool_menu_handle.toggle(window, cx);
+ move |_, _: &lsp_button::ToggleMenu, window, cx| {
+ lsp_button_menu_handle.toggle(window, cx);
}
});
@@ -401,7 +410,7 @@ pub fn initialize_workspace(
cx.new(|_| go_to_line::cursor_position::CursorPosition::new(workspace));
workspace.status_bar().update(cx, |status_bar, cx| {
status_bar.add_left_item(search_button, window, cx);
- status_bar.add_left_item(lsp_tool, window, cx);
+ status_bar.add_left_item(lsp_button, window, cx);
status_bar.add_left_item(diagnostic_summary, window, cx);
status_bar.add_left_item(activity_indicator, window, cx);
status_bar.add_right_item(edit_prediction_button, window, cx);
@@ -918,7 +927,7 @@ fn register_actions(
capture_audio(workspace, window, cx);
});
- if workspace.project().read(cx).is_via_ssh() {
+ if workspace.project().read(cx).is_via_remote_server() {
workspace.register_action({
move |workspace, _: &OpenServerSettings, window, cx| {
let open_server_settings = workspace
@@ -980,7 +989,7 @@ fn initialize_pane(
toolbar.add_item(diagnostic_editor_controls, window, cx);
let project_search_bar = cx.new(|_| ProjectSearchBar::new());
toolbar.add_item(project_search_bar, window, cx);
- let lsp_log_item = cx.new(|_| language_tools::LspLogToolbarItemView::new());
+ let lsp_log_item = cx.new(|_| LspLogToolbarItemView::new());
toolbar.add_item(lsp_log_item, window, cx);
let dap_log_item = cx.new(|_| debugger_tools::DapLogToolbarItemView::new());
toolbar.add_item(dap_log_item, window, cx);
@@ -1308,11 +1317,11 @@ pub fn handle_keymap_file_changes(
})
.detach();
- let mut current_mapping = settings::get_key_equivalents(cx.keyboard_layout().id());
+ let mut current_layout_id = cx.keyboard_layout().id().to_string();
cx.on_keyboard_layout_change(move |cx| {
- let next_mapping = settings::get_key_equivalents(cx.keyboard_layout().id());
- if next_mapping != current_mapping {
- current_mapping = next_mapping;
+ let next_layout_id = cx.keyboard_layout().id();
+ if next_layout_id != current_layout_id {
+ current_layout_id = next_layout_id.to_string();
keyboard_layout_tx.unbounded_send(()).ok();
}
})
@@ -1543,7 +1552,7 @@ pub fn open_new_ssh_project_from_project(
cx: &mut Context<Workspace>,
) -> Task<anyhow::Result<()>> {
let app_state = workspace.app_state().clone();
- let Some(ssh_client) = workspace.project().read(cx).ssh_client() else {
+ let Some(ssh_client) = workspace.project().read(cx).remote_client() else {
return Task::ready(Err(anyhow::anyhow!("Not an ssh project")));
};
let connection_options = ssh_client.read(cx).connection_options();
@@ -4506,6 +4515,7 @@ mod tests {
"zed",
"zed_predict_onboarding",
"zeta",
+ "window",
];
assert_eq!(
all_namespaces,
@@ -4729,7 +4739,7 @@ mod tests {
// and key strokes contain the given key
bindings
.into_iter()
- .any(|binding| binding.keystrokes().iter().any(|k| k.key == key)),
+ .any(|binding| binding.keystrokes().iter().any(|k| k.key() == key)),
"On {} Failed to find {} with key binding {}",
line,
action.name(),
@@ -1,10 +1,17 @@
use anyhow::Result;
-use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql};
+use db::{
+ query,
+ sqlez::{domain::Domain, statement::Statement, thread_safe_connection::ThreadSafeConnection},
+ sqlez_macros::sql,
+};
use workspace::{ItemId, WorkspaceDb, WorkspaceId};
-define_connection! {
- pub static ref COMPONENT_PREVIEW_DB: ComponentPreviewDb<WorkspaceDb> =
- &[sql!(
+pub struct ComponentPreviewDb(ThreadSafeConnection);
+
+impl Domain for ComponentPreviewDb {
+ const NAME: &str = stringify!(ComponentPreviewDb);
+
+ const MIGRATIONS: &[&str] = &[sql!(
CREATE TABLE component_previews (
workspace_id INTEGER,
item_id INTEGER UNIQUE,
@@ -13,9 +20,11 @@ define_connection! {
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;
- )];
+ )];
}
+db::static_connection!(COMPONENT_PREVIEW_DB, ComponentPreviewDb, [WorkspaceDb]);
+
impl ComponentPreviewDb {
pub async fn save_active_page(
&self,
@@ -8,7 +8,6 @@ use settings::SettingsStore;
use std::{cell::RefCell, rc::Rc, sync::Arc};
use supermaven::{Supermaven, SupermavenCompletionProvider};
use ui::Window;
-use workspace::Workspace;
use zeta::{ProviderDataCollection, ZetaEditPredictionProvider};
pub fn init(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut App) {
@@ -204,10 +203,14 @@ fn assign_edit_prediction_provider(
}
}
+<<<<<<< HEAD
let workspace = window.root::<Workspace>().flatten();
let zeta =
zeta::Zeta::register(workspace, worktree, client.clone(), user_store, cx);
+=======
+ let zeta = zeta::Zeta::register(worktree, client.clone(), user_store, cx);
+>>>>>>> main
if let Some(buffer) = &singleton_buffer
&& buffer.read(cx).file().is_some()
@@ -72,7 +72,10 @@ impl QuickActionBar {
Tooltip::with_meta(
tooltip_text,
Some(open_action_for_tooltip),
- format!("{} to open in a split", text_for_keystroke(&alt_click, cx)),
+ format!(
+ "{} to open in a split",
+ text_for_keystroke(&alt_click.modifiers, &alt_click.key, cx)
+ ),
window,
cx,
)
@@ -284,6 +284,8 @@ pub mod agent {
OpenSettings,
/// Opens the agent onboarding modal.
OpenOnboardingModal,
+ /// Opens the ACP onboarding modal.
+ OpenAcpOnboardingModal,
/// Resets the agent onboarding state.
ResetOnboarding,
/// Starts a chat conversation with the agent.
@@ -27,7 +27,7 @@ use collections::{HashMap, HashSet, VecDeque};
use futures::AsyncReadExt;
use gpui::{
App, AppContext as _, AsyncApp, Context, Entity, EntityId, Global, SemanticVersion,
- Subscription, Task, WeakEntity, actions,
+ SharedString, Subscription, Task, actions,
};
use http_client::{AsyncBody, HttpClient, Method, Request, Response};
use input_excerpt::excerpt_for_cursor_position;
@@ -55,8 +55,7 @@ use telemetry_events::EditPredictionRating;
use thiserror::Error;
use util::{ResultExt, maybe};
use uuid::Uuid;
-use workspace::Workspace;
-use workspace::notifications::{ErrorMessagePrompt, NotificationId};
+use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification};
use worktree::Worktree;
const CURSOR_MARKER: &str = "<|user_cursor_is_here|>";
@@ -235,7 +234,10 @@ impl std::fmt::Debug for EditPrediction {
}
pub struct Zeta {
+<<<<<<< HEAD
workspace: WeakEntity<Workspace>,
+=======
+>>>>>>> main
client: Arc<Client>,
events: VecDeque<Event>,
registered_buffers: HashMap<gpui::EntityId, RegisteredBuffer>,
@@ -274,14 +276,17 @@ impl Zeta {
}
pub fn register(
+<<<<<<< HEAD
workspace: Option<Entity<Workspace>>,
+=======
+>>>>>>> main
worktree: Option<Entity<Worktree>>,
client: Arc<Client>,
user_store: Entity<UserStore>,
cx: &mut App,
) -> Entity<Self> {
let this = Self::global(cx).unwrap_or_else(|| {
- let entity = cx.new(|cx| Self::new(workspace, client, user_store, cx));
+ let entity = cx.new(|cx| Self::new(client, user_store, cx));
cx.set_global(ZetaGlobal(entity.clone()));
entity
});
@@ -306,12 +311,16 @@ impl Zeta {
self.user_store.read(cx).edit_prediction_usage()
}
+<<<<<<< HEAD
fn new(
workspace: Option<Entity<Workspace>>,
client: Arc<Client>,
user_store: Entity<UserStore>,
cx: &mut Context<Self>,
) -> Self {
+=======
+ fn new(client: Arc<Client>, user_store: Entity<UserStore>, cx: &mut Context<Self>) -> Self {
+>>>>>>> main
let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx);
let data_collection_choice = Self::load_data_collection_choices();
@@ -354,10 +363,13 @@ impl Zeta {
}
Self {
+<<<<<<< HEAD
workspace: workspace.map_or_else(
|| WeakEntity::new_invalid(),
|workspace| workspace.downgrade(),
),
+=======
+>>>>>>> main
client,
events: VecDeque::with_capacity(MAX_EVENT_COUNT),
shown_completions: VecDeque::with_capacity(MAX_SHOWN_COMPLETION_COUNT),
@@ -454,8 +466,12 @@ impl Zeta {
fn request_completion_impl<F, R>(
&mut self,
+<<<<<<< HEAD
workspace: Option<Entity<Workspace>>,
project: Option<Entity<Project>>,
+=======
+ project: Option<&Entity<Project>>,
+>>>>>>> main
buffer: &Entity<Buffer>,
cursor: language::Anchor,
can_collect_data: CanCollectData,
@@ -554,23 +570,20 @@ impl Zeta {
zeta.update_required = true;
});
- if let Some(workspace) = workspace {
- workspace.update(cx, |workspace, cx| {
- workspace.show_notification(
- NotificationId::unique::<ZedUpdateRequiredError>(),
- cx,
- |cx| {
- cx.new(|cx| {
- ErrorMessagePrompt::new(err.to_string(), cx)
- .with_link_button(
- "Update Zed",
- "https://zed.dev/releases",
- )
- })
- },
- );
- });
- }
+ let error_message: SharedString = err.to_string().into();
+ show_app_notification(
+ NotificationId::unique::<ZedUpdateRequiredError>(),
+ cx,
+ move |cx| {
+ cx.new(|cx| {
+ ErrorMessagePrompt::new(error_message.clone(), cx)
+ .with_link_button(
+ "Update Zed",
+ "https://zed.dev/releases",
+ )
+ })
+ },
+ );
})
.ok();
}
@@ -802,6 +815,7 @@ and then another
) -> Task<Result<Option<EditPrediction>>> {
use std::future::ready;
+<<<<<<< HEAD
self.request_completion_impl(
None,
project,
@@ -811,6 +825,11 @@ and then another
cx,
|_params| ready(Ok((response, None))),
)
+=======
+ self.request_completion_impl(project, buffer, position, false, cx, |_params| {
+ ready(Ok((response, None)))
+ })
+>>>>>>> main
}
pub fn request_completion(
@@ -822,7 +841,10 @@ and then another
cx: &mut Context<Self>,
) -> Task<Result<Option<EditPrediction>>> {
self.request_completion_impl(
+<<<<<<< HEAD
self.workspace.upgrade(),
+=======
+>>>>>>> main
project,
buffer,
position,
@@ -2408,7 +2430,7 @@ mod tests {
// Construct the fake server to authenticate.
let _server = FakeServer::for_client(42, &client, cx).await;
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
- let zeta = cx.new(|cx| Zeta::new(None, client, user_store.clone(), cx));
+ let zeta = cx.new(|cx| Zeta::new(client, user_store.clone(), cx));
let buffer = cx.new(|cx| Buffer::local(buffer_content, cx));
let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 0)));
@@ -2472,7 +2494,7 @@ mod tests {
// Construct the fake server to authenticate.
let _server = FakeServer::for_client(42, &client, cx).await;
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
- let zeta = cx.new(|cx| Zeta::new(None, client, user_store.clone(), cx));
+ let zeta = cx.new(|cx| Zeta::new(client, user_store.clone(), cx));
let buffer = cx.new(|cx| Buffer::local(buffer_content, cx));
let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
@@ -47,6 +47,7 @@
- [Overview](./ai/overview.md)
- [Agent Panel](./ai/agent-panel.md)
- [Tools](./ai/tools.md)
+ - [External Agents](./ai/external-agents.md)
- [Inline Assistant](./ai/inline-assistant.md)
- [Edit Prediction](./ai/edit-prediction.md)
- [Text Threads](./ai/text-threads.md)
@@ -1,14 +1,15 @@
# Agent Panel
-The Agent Panel provides you with a surface to interact with LLMs, enabling various types of tasks, such as generating code, asking questions about your codebase, and general inquiries like emails, documentation, and more.
+The Agent Panel allows you to interact with many LLMs and coding agents that can support you in various types of tasks, such as generating code, codebase understanding, and other general inquiries like writing emails, documentation, and more.
To open it, use the `agent: new thread` action in [the Command Palette](../getting-started.md#command-palette) or click the ✨ (sparkles) icon in the status bar.
-If you're using the Agent Panel for the first time, you need to have at least one LLM provider configured.
+If you're using the Agent Panel for the first time, you need to have at least one LLM or agent provider configured.
You can do that by:
1. [subscribing to our Pro plan](https://zed.dev/pricing), so you have access to our hosted models
-2. or by [bringing your own API keys](./llm-providers.md#use-your-own-keys) for your desired provider
+2. [bringing your own API keys](./llm-providers.md#use-your-own-keys) for your desired provider
+3. using an external agent like [Gemini CLI](./external-agents.md#gemini-cli)
## Overview {#overview}
@@ -17,6 +18,15 @@ If you need extra room to type, you can expand the message editor with {#kb agen
You should start to see the responses stream in with indications of [which tools](./tools.md) the model is using to fulfill your prompt.
+> Note that, at the moment, not all features outlined below work for external agents, like [Gemini CLI](./external-agents.md#gemini-cli)—features like _restoring threads from history_, _checkpoints_, _token usage display_, _model selection_, and others may be supported in the future.
+
+### Creating New Threads
+
+By default, the Agent Panel uses Zed's first-party agent.
+
+To change that, go to the plus button in the top-right of the Agent Panel and choose another option.
+You choose to create a new [Text Thread](./text-threads.md) or, if you have [external agents](./external-agents.md) connected, you can create new threads with them.
+
### Editing Messages {#editing-messages}
Any message that you send to the AI is editable.
@@ -30,7 +40,7 @@ The checkpoint button appears even if you interrupt the thread midway through an
### Navigating History {#navigating-history}
-To quickly navigate through recently opened threads, use the {#kb agent::ToggleNavigationMenu} binding, when focused on the panel's editor, or click the menu icon button at the top left of the panel to open the dropdown that shows you the six most recent threads.
+To quickly navigate through recently opened threads, use the {#kb agent::ToggleNavigationMenu} binding, when focused on the panel's editor, or click the menu icon button at the top right of the panel to open the dropdown that shows you the six most recent threads.
The items in this menu function similarly to tabs, and closing them doesn’t delete the thread; instead, it simply removes them from the recent list.
@@ -70,16 +80,13 @@ So, if your active tab had edits made by the AI, you'll see diffs with the same
Although Zed's agent is very efficient at reading through your code base to autonomously pick up relevant files, directories, and other context, manually adding context is still encouraged as a way to speed up and improve the AI's response quality.
-If you have a tab open while using the Agent Panel, that tab appears as a suggested context in form of a dashed button.
-You can also add other forms of context by either mentioning them with `@` or hitting the `+` icon button.
-
-You can even add previous threads as context by mentioning them with `@thread`, or by selecting the "New From Summary" option from the `+` menu to continue a longer conversation, keeping it within the context window.
+To add any file, directory, symbol, previous threads, rules files, or even web pages as context, type `@` to mention them in the editor.
Pasting images as context is also supported by the Agent Panel.
### Token Usage {#token-usage}
-Zed surfaces how many tokens you are consuming for your currently active thread in the panel's toolbar.
+Zed surfaces how many tokens you are consuming for your currently active thread nearby the profile selector in the panel's message editor.
Depending on how many pieces of context you add, your token consumption can grow rapidly.
With that in mind, once you get close to the model's context window, a banner appears below the message editor suggesting to start a new thread with the current one summarized and added as context.
@@ -145,7 +152,7 @@ Zed's UI will inform about this via a warning icon that appears close to the mod
## Text Threads {#text-threads}
-["Text threads"](./text-threads.md) present your conversation with the LLM in a different format—as raw text.
+["Text Threads"](./text-threads.md) present your conversation with the LLM in a different format—as raw text.
With text threads, you have full control over the conversation data.
You can remove and edit responses from the LLM, swap roles, and include more context earlier in the conversation.
@@ -131,7 +131,7 @@ The default value is `false`.
```json
{
"agent": {
- "always_allow_tool_actions": "true"
+ "always_allow_tool_actions": true
}
}
```
@@ -146,7 +146,7 @@ The default value is `false`.
```json
{
"agent": {
- "single_file_review": "true"
+ "single_file_review": true
}
}
```
@@ -163,7 +163,7 @@ The default value is `false`.
```json
{
"agent": {
- "play_sound_when_agent_done": "true"
+ "play_sound_when_agent_done": true
}
}
```
@@ -179,7 +179,7 @@ The default value is `false`.
```json
{
"agent": {
- "use_modifier_to_send": "true"
+ "use_modifier_to_send": true
}
}
```
@@ -194,7 +194,7 @@ It is set to `true` by default, but if set to false, the card's height is capped
```json
{
"agent": {
- "expand_edit_card": "false"
+ "expand_edit_card": false
}
}
```
@@ -207,7 +207,7 @@ It is set to `true` by default, but if set to false, the card will be fully coll
```json
{
"agent": {
- "expand_terminal_card": "false"
+ "expand_terminal_card": false
}
}
```
@@ -220,7 +220,7 @@ The default value is `true`.
```json
{
"agent": {
- "enable_feedback": "false"
+ "enable_feedback": false
}
}
```
@@ -0,0 +1,82 @@
+# External Agents
+
+Zed supports terminal-based agentic coding tools through the [Agent Client Protocol (ACP)](https://agentclientprotocol.com).
+
+Currently, [Gemini CLI](https://github.com/google-gemini/gemini-cli) serves as the reference implementation, and you can [add custom ACP-compatible agents](#add-custom-agents) as well.
+
+## Gemini CLI {#gemini-cli}
+
+Zed provides the ability to run [Gemini CLI](https://github.com/google-gemini/gemini-cli) directly in the [agent panel](./agent-panel.md).
+
+Under the hood we run Gemini CLI in the background, and talk to it over ACP.
+This means that you're running the real Gemini CLI, with all of the advantages of that, but you can see and interact with files in your editor.
+
+### Getting Started
+
+As of Zed Stable v0.201.5 you should be able to use Gemini CLI directly from Zed. First open the agent panel with {#kb agent::ToggleFocus}, and then use the `+` button in the top right to start a New Gemini CLI thread.
+
+If you'd like to bind this to a keyboard shortcut, you can do so by editing your keybindings file to include:
+
+```json
+[
+ {
+ "bindings": {
+ "cmd-alt-g": ["agent::NewExternalAgentThread", { "agent": "gemini" }]
+ }
+ }
+]
+```
+
+#### Installation
+
+If you don't yet have Gemini CLI installed, then Zed will install a version for you. If you do, then we will use the version of Gemini CLI on your path.
+
+You need to be running at least Gemini version `0.2.0`, and if your version of Gemini is too old you will see an
+error message.
+
+The instructions to upgrade Gemini depend on how you originally installed it, but typically, running `npm install -g @google/gemini-cli@latest` should work.
+
+#### Authentication
+
+After you have Gemini CLI running, you'll be prompted to choose your authentication method.
+
+Most users should click the "Log in with Google". This will cause a browser window to pop-up and auth directly with Gemini CLI. Zed does not see your oauth or access tokens in this case.
+
+You can also use the "Gemini API Key". If you select this, and have the `GEMINI_API_KEY` set, then we will use that. Otherwise Zed will prompt you for an API key which will be stored securely in your keychain, and used to start Gemini CLI from within Zed.
+
+The "Vertex AI" option is for those who are using Vertex AI, and have already configured their environment correctly.
+
+For more information, see the [Gemini CLI docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/index.md).
+
+### Usage
+
+Similar to Zed's first-party agent, you can use Gemini CLI to do anything that you need.
+
+You can @-mention files, recent threads, symbols, or fetch the web.
+
+> Note that some first-party agent features don't yet work with Gemini CLI: editing past messages, resuming threads from history, checkpointing, and using the agent in SSH projects.
+> We hope to add these features in the near future.
+
+## Add Custom Agents {#add-custom-agents}
+
+You can run any agent speaking ACP in Zed by changing your settings as follows:
+
+```json
+{
+ "agent_servers": {
+ "Custom Agent": {
+ "command": "node",
+ "args": ["~/projects/agent/index.js", "--acp"],
+ "env": {}
+ }
+ }
+}
+```
+
+This can also be useful if you're in the middle of developing a new agent that speaks the protocol and you want to debug it.
+
+## Debugging Agents
+
+When using external agents in Zed, you can access the debug view via with `dev: open acp logs` from the Command Palette. This lets you see the messages being sent and received between Zed and the agent.
+
+
@@ -6,6 +6,8 @@ Learn how to get started using AI with Zed and all its capabilities.
- [Configuration](./configuration.md): Learn how to set up different language model providers like Anthropic, OpenAI, Ollama, Google AI, and more.
+- [External Agents](./external-agents.md): Learn how to plug in your favorite agent into Zed.
+
- [Subscription](./subscription.md): Learn about Zed's hosted model service and other billing-related information.
- [Privacy and Security](./privacy-and-security.md): Understand how Zed handles privacy and security with AI features.
@@ -104,6 +104,70 @@ Non-negative `float` values
}
```
+## Agent Font Size
+
+- Description: The font size for text in the agent panel. Inherits the UI font size if unset.
+- Setting: `agent_font_size`
+- Default: `null`
+
+**Options**
+
+`integer` values from `6` to `100` pixels (inclusive)
+
+## Allow Rewrap
+
+- Description: Controls where the `editor::Rewrap` action is allowed in the current language scope
+- Setting: `allow_rewrap`
+- Default: `"in_comments"`
+
+**Options**
+
+1. Allow rewrap in comments only:
+
+```json
+{
+ "allow_rewrap": "in_comments"
+}
+```
+
+2. Allow rewrap everywhere:
+
+```json
+{
+ "allow_rewrap": "everywhere"
+}
+```
+
+3. Never allow rewrap:
+
+```json
+{
+ "allow_rewrap": "never"
+}
+```
+
+Note: This setting has no effect in Vim mode, as rewrap is already allowed everywhere.
+
+## Auto Indent
+
+- Description: Whether indentation should be adjusted based on the context whilst typing. This can be specified on a per-language basis.
+- Setting: `auto_indent`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
+## Auto Indent On Paste
+
+- Description: Whether indentation of pasted content should be adjusted based on the context
+- Setting: `auto_indent_on_paste`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
## Auto Install extensions
- Description: Define extensions to be autoinstalled or never be installed.
@@ -182,42 +246,30 @@ Define extensions which should be installed (`true`) or never installed (`false`
}
```
-## Restore on Startup
+## Autoscroll on Clicks
-- Description: Controls session restoration on startup.
-- Setting: `restore_on_startup`
-- Default: `last_session`
+- Description: Whether to scroll when clicking near the edge of the visible text area.
+- Setting: `autoscroll_on_clicks`
+- Default: `false`
**Options**
-1. Restore all workspaces that were open when quitting Zed:
-
-```json
-{
- "restore_on_startup": "last_session"
-}
-```
+`boolean` values
-2. Restore the workspace that was closed last:
+## Auto Signature Help
-```json
-{
- "restore_on_startup": "last_workspace"
-}
-```
+- Description: Show method signatures in the editor, when inside parentheses
+- Setting: `auto_signature_help`
+- Default: `false`
-3. Always start with an empty editor:
+**Options**
-```json
-{
- "restore_on_startup": "none"
-}
-```
+`boolean` values
-## Autoscroll on Clicks
+### Show Signature Help After Edits
-- Description: Whether to scroll when clicking near the edge of the visible text area.
-- Setting: `autoscroll_on_clicks`
+- Description: Whether to show the signature help after completion or a bracket pair inserted. If `auto_signature_help` is enabled, this setting will be treated as enabled also.
+- Setting: `show_signature_help_after_edits`
- Default: `false`
**Options**
@@ -378,6 +430,24 @@ For example, to use `Nerd Font` as a fallback, add the following to your setting
`"standard"`, `"comfortable"` or `{ "custom": float }` (`1` is compact, `2` is loose)
+## Centered Layout
+
+- Description: Configuration for the centered layout mode.
+- Setting: `centered_layout`
+- Default:
+
+```json
+"centered_layout": {
+ "left_padding": 0.2,
+ "right_padding": 0.2,
+}
+```
+
+**Options**
+
+The `left_padding` and `right_padding` options define the relative width of the
+left and right padding of the central pane from the workspace when the centered layout mode is activated. Valid values range is from `0` to `0.4`.
+
## Close on File Delete
- Description: Whether to automatically close editor tabs when their corresponding files are deleted from disk.
@@ -402,23 +472,63 @@ Note: Dirty files (files with unsaved changes) will not be automatically closed
`boolean` values
-## Centered Layout
+## Diagnostics Max Severity
-- Description: Configuration for the centered layout mode.
-- Setting: `centered_layout`
-- Default:
+- Description: Which level to use to filter out diagnostics displayed in the editor
+- Setting: `diagnostics_max_severity`
+- Default: `null`
+
+**Options**
+
+1. Allow all diagnostics (default):
```json
-"centered_layout": {
- "left_padding": 0.2,
- "right_padding": 0.2,
+{
+ "diagnostics_max_severity": null
+}
+```
+
+2. Show only errors:
+
+```json
+{
+ "diagnostics_max_severity": "error"
+}
+```
+
+3. Show errors and warnings:
+
+```json
+{
+ "diagnostics_max_severity": "warning"
+}
+```
+
+4. Show errors, warnings, and information:
+
+```json
+{
+ "diagnostics_max_severity": "information"
+}
+```
+
+5. Show all including hints:
+
+```json
+{
+ "diagnostics_max_severity": "hint"
}
```
+## Disable AI
+
+- Description: Whether to disable all AI features in Zed
+- Setting: `disable_ai`
+- Default: `false`
+
**Options**
-The `left_padding` and `right_padding` options define the relative width of the
-left and right padding of the central pane from the workspace when the centered layout mode is activated. Valid values range is from `0` to `0.4`.
+`boolean` values
## Direnv Integration
@@ -435,6 +545,42 @@ There are two options to choose from:
1. `shell_hook`: Use the shell hook to load direnv. This relies on direnv to activate upon entering the directory. Supports POSIX shells and fish.
2. `direct`: Use `direnv export json` to load direnv. This will load direnv directly without relying on the shell hook and might cause some inconsistencies. This allows direnv to work with any shell.
+## Double Click In Multibuffer
+
+- Description: What to do when multibuffer is double clicked in some of its excerpts (parts of singleton buffers)
+- Setting: `double_click_in_multibuffer`
+- Default: `"select"`
+
+**Options**
+
+1. Behave as a regular buffer and select the whole word (default):
+
+```json
+{
+ "double_click_in_multibuffer": "select"
+}
+```
+
+2. Open the excerpt clicked as a new buffer in the new tab:
+
+```json
+{
+ "double_click_in_multibuffer": "open"
+}
+```
+
+For the case of "open", regular selection behavior can be achieved by holding `alt` when double clicking.
+
+## Drop Target Size
+
+- Description: Relative size of the drop target in the editor that will open dropped file as a split pane (0-0.5). For example, 0.25 means if you drop onto the top/bottom quarter of the pane a new vertical split will be used, if you drop onto the left/right quarter of the pane a new horizontal split will be used.
+- Setting: `drop_target_size`
+- Default: `0.2`
+
+**Options**
+
+`float` values between `0` and `0.5`
+
## Edit Predictions
- Description: Settings for edit predictions.
@@ -581,6 +727,32 @@ List of `string` values
"cursor_shape": "hollow"
```
+## Gutter
+
+- Description: Settings for the editor gutter
+- Setting: `gutter`
+- Default:
+
+```json
+{
+ "gutter": {
+ "line_numbers": true,
+ "runnables": true,
+ "breakpoints": true,
+ "folds": true,
+ "min_line_number_digits": 4
+ }
+}
+```
+
+**Options**
+
+- `line_numbers`: Whether to show line numbers in the gutter
+- `runnables`: Whether to show runnable buttons in the gutter
+- `breakpoints`: Whether to show breakpoints in the gutter
+- `folds`: Whether to show fold buttons in the gutter
+- `min_line_number_digits`: Minimum number of characters to reserve space for in the gutter
+
## Hide Mouse
- Description: Determines when the mouse cursor should be hidden in an editor or input box.
@@ -1249,6 +1421,16 @@ or
Each option controls displaying of a particular toolbar element. If all elements are hidden, the editor toolbar is not displayed.
+## Use System Tabs
+
+- Description: Whether to allow windows to tab together based on the user’s tabbing preference (macOS only).
+- Setting: `use_system_window_tabs`
+- Default: `false`
+
+**Options**
+
+This setting enables integration with macOS’s native window tabbing feature. When set to `true`, Zed windows can be grouped together as tabs in a single macOS window, following the system-wide tabbing preferences set by the user (such as "Always", "In Full Screen", or "Never"). This setting is only available on macOS.
+
## Enable Language Server
- Description: Whether or not to use language servers to provide code intelligence.
@@ -1269,6 +1451,26 @@ Each option controls displaying of a particular toolbar element. If all elements
`boolean` values
+## Expand Excerpt Lines
+
+- Description: The default number of lines to expand excerpts in the multibuffer by
+- Setting: `expand_excerpt_lines`
+- Default: `5`
+
+**Options**
+
+Positive `integer` values
+
+## Extend Comment On Newline
+
+- Description: Whether to start a new line with a comment when a previous line is a comment as well.
+- Setting: `extend_comment_on_newline`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
## Status Bar
- Description: Control various elements in the status bar. Note that some items in the status bar have their own settings set elsewhere.
@@ -1327,6 +1529,24 @@ While other options may be changed at a runtime and should be placed under `sett
}
```
+## Global LSP Settings
+
+- Description: Configuration for global LSP settings that apply to all language servers
+- Setting: `global_lsp_settings`
+- Default:
+
+```json
+{
+ "global_lsp_settings": {
+ "button": true
+ }
+}
+```
+
+**Options**
+
+- `button`: Whether to show the LSP status button in the status bar
+
## LSP Highlight Debounce
- Description: The debounce delay in milliseconds before querying highlights from the language server based on the current cursor location.
@@ -1349,6 +1569,68 @@ While other options may be changed at a runtime and should be placed under `sett
`integer` values representing milliseconds
+## Features
+
+- Description: Features that can be globally enabled or disabled
+- Setting: `features`
+- Default:
+
+```json
+{
+ "features": {
+ "edit_prediction_provider": "zed"
+ }
+}
+```
+
+### Edit Prediction Provider
+
+- Description: Which edit prediction provider to use
+- Setting: `edit_prediction_provider`
+- Default: `"zed"`
+
+**Options**
+
+1. Use Zeta as the edit prediction provider:
+
+```json
+{
+ "features": {
+ "edit_prediction_provider": "zed"
+ }
+}
+```
+
+2. Use Copilot as the edit prediction provider:
+
+```json
+{
+ "features": {
+ "edit_prediction_provider": "copilot"
+ }
+}
+```
+
+3. Use Supermaven as the edit prediction provider:
+
+```json
+{
+ "features": {
+ "edit_prediction_provider": "supermaven"
+ }
+}
+```
+
+4. Turn off edit predictions across all providers
+
+```json
+{
+ "features": {
+ "edit_prediction_provider": "none"
+ }
+}
+```
+
## Format On Save
- Description: Whether or not to perform a buffer format before saving.
@@ -1892,18 +2174,62 @@ Example:
}
```
-## Indent Guides
+## Go to Definition Fallback
-- Description: Configuration related to indent guides. Indent guides can be configured separately for each language.
-- Setting: `indent_guides`
-- Default:
+- Description: What to do when the "go to definition" action fails to find a definition
+- Setting: `go_to_definition_fallback`
+- Default: `"find_all_references"`
+
+**Options**
+
+1. Do nothing:
```json
{
- "indent_guides": {
- "enabled": true,
- "line_width": 1,
- "active_line_width": 1,
+ "go_to_definition_fallback": "none"
+}
+```
+
+2. Find references for the same symbol (default):
+
+```json
+{
+ "go_to_definition_fallback": "find_all_references"
+}
+```
+
+## Hard Tabs
+
+- Description: Whether to indent lines using tab characters or multiple spaces.
+- Setting: `hard_tabs`
+- Default: `false`
+
+**Options**
+
+`boolean` values
+
+## Helix Mode
+
+- Description: Whether or not to enable Helix mode. Enabling `helix_mode` also enables `vim_mode`. See the [Helix documentation](./helix.md) for more details.
+- Setting: `helix_mode`
+- Default: `false`
+
+**Options**
+
+`boolean` values
+
+## Indent Guides
+
+- Description: Configuration related to indent guides. Indent guides can be configured separately for each language.
+- Setting: `indent_guides`
+- Default:
+
+```json
+{
+ "indent_guides": {
+ "enabled": true,
+ "line_width": 1,
+ "active_line_width": 1,
"coloring": "fixed",
"background_coloring": "disabled"
}
@@ -1961,40 +2287,6 @@ Example:
}
```
-## Hard Tabs
-
-- Description: Whether to indent lines using tab characters or multiple spaces.
-- Setting: `hard_tabs`
-- Default: `false`
-
-**Options**
-
-`boolean` values
-
-## Multi Cursor Modifier
-
-- Description: Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier.
-- Setting: `multi_cursor_modifier`
-- Default: `alt`
-
-**Options**
-
-1. Maps to `Alt` on Linux and Windows and to `Option` on MacOS:
-
-```json
-{
- "multi_cursor_modifier": "alt"
-}
-```
-
-2. Maps `Control` on Linux and Windows and to `Command` on MacOS:
-
-```json
-{
- "multi_cursor_modifier": "cmd_or_ctrl" // alias: "cmd", "ctrl"
-}
-```
-
## Hover Popover Enabled
- Description: Whether or not to show the informational hover box when moving the mouse over symbols in the editor.
@@ -2087,6 +2379,50 @@ Run the `icon theme selector: toggle` action in the command palette to see a cur
Run the `icon theme selector: toggle` action in the command palette to see a current list of valid icon themes names.
+## Image Viewer
+
+- Description: Settings for image viewer functionality
+- Setting: `image_viewer`
+- Default:
+
+```json
+{
+ "image_viewer": {
+ "unit": "binary"
+ }
+}
+```
+
+**Options**
+
+### Unit
+
+- Description: The unit for image file sizes
+- Setting: `unit`
+- Default: `"binary"`
+
+**Options**
+
+1. Use binary units (KiB, MiB):
+
+```json
+{
+ "image_viewer": {
+ "unit": "binary"
+ }
+}
+```
+
+2. Use decimal units (KB, MB):
+
+```json
+{
+ "image_viewer": {
+ "unit": "decimal"
+ }
+}
+```
+
## Inlay hints
- Description: Configuration for displaying extra text with hints in the editor.
@@ -2187,6 +2523,24 @@ Unspecified values have a `false` value, hints won't be toggled if all the modif
}
```
+## JSX Tag Auto Close
+
+- Description: Whether to automatically close JSX tags
+- Setting: `jsx_tag_auto_close`
+- Default:
+
+```json
+{
+ "jsx_tag_auto_close": {
+ "enabled": true
+ }
+}
+```
+
+**Options**
+
+- `enabled`: Whether to enable automatic JSX tag closing
+
## Languages
- Description: Configuration for specific languages.
@@ -2226,141 +2580,546 @@ The following settings can be overridden for each specific language:
- [`use_autoclose`](#use-autoclose)
- [`always_treat_brackets_as_autoclosed`](#always-treat-brackets-as-autoclosed)
-These values take in the same options as the root-level settings with the same name.
+These values take in the same options as the root-level settings with the same name.
+
+## Language Models
+
+- Description: Configuration for language model providers
+- Setting: `language_models`
+- Default:
+
+```json
+{
+ "language_models": {
+ "anthropic": {
+ "api_url": "https://api.anthropic.com"
+ },
+ "google": {
+ "api_url": "https://generativelanguage.googleapis.com"
+ },
+ "ollama": {
+ "api_url": "http://localhost:11434"
+ },
+ "openai": {
+ "api_url": "https://api.openai.com/v1"
+ }
+ }
+}
+```
+
+**Options**
+
+Configuration for various AI model providers including API URLs and authentication settings.
+
+## Line Indicator Format
+
+- Description: Format for line indicator in the status bar
+- Setting: `line_indicator_format`
+- Default: `"short"`
+
+**Options**
+
+1. Short format:
+
+```json
+{
+ "line_indicator_format": "short"
+}
+```
+
+2. Long format:
+
+```json
+{
+ "line_indicator_format": "long"
+}
+```
+
+## Linked Edits
+
+- Description: Whether to perform linked edits of associated ranges, if the language server supports it. For example, when editing opening `<html>` tag, the contents of the closing `</html>` tag will be edited as well.
+- Setting: `linked_edits`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
+## LSP Document Colors
+
+- Description: Whether to show document color information from the language server
+- Setting: `lsp_document_colors`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
+## Max Tabs
+
+- Description: Maximum number of tabs to show in the tab bar
+- Setting: `max_tabs`
+- Default: `null`
+
+**Options**
+
+Positive `integer` values or `null` for unlimited tabs
+
+## Middle Click Paste (Linux only)
+
+- Description: Enable middle-click paste on Linux
+- Setting: `middle_click_paste`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
+## Multi Cursor Modifier
+
+- Description: Determines the modifier to be used to add multiple cursors with the mouse. The open hover link mouse gestures will adapt such that it do not conflict with the multicursor modifier.
+- Setting: `multi_cursor_modifier`
+- Default: `alt`
+
+**Options**
+
+1. Maps to `Alt` on Linux and Windows and to `Option` on MacOS:
+
+```json
+{
+ "multi_cursor_modifier": "alt"
+}
+```
+
+2. Maps `Control` on Linux and Windows and to `Command` on MacOS:
+
+```json
+{
+ "multi_cursor_modifier": "cmd_or_ctrl" // alias: "cmd", "ctrl"
+}
+```
+
+## Node
+
+- Description: Configuration for Node.js integration
+- Setting: `node`
+- Default:
+
+```json
+{
+ "node": {
+ "ignore_system_version": false,
+ "path": null,
+ "npm_path": null
+ }
+}
+```
+
+**Options**
+
+- `ignore_system_version`: Whether to ignore the system Node.js version
+- `path`: Custom path to Node.js binary
+- `npm_path`: Custom path to npm binary
+
+## Network Proxy
+
+- Description: Configure a network proxy for Zed.
+- Setting: `proxy`
+- Default: `null`
+
+**Options**
+
+The proxy setting must contain a URL to the proxy.
+
+The following URI schemes are supported:
+
+- `http`
+- `https`
+- `socks4` - SOCKS4 proxy with local DNS
+- `socks4a` - SOCKS4 proxy with remote DNS
+- `socks5` - SOCKS5 proxy with local DNS
+- `socks5h` - SOCKS5 proxy with remote DNS
+
+`http` will be used when no scheme is specified.
+
+By default no proxy will be used, or Zed will attempt to retrieve proxy settings from environment variables, such as `http_proxy`, `HTTP_PROXY`, `https_proxy`, `HTTPS_PROXY`, `all_proxy`, `ALL_PROXY`, `no_proxy` and `NO_PROXY`.
+
+For example, to set an `http` proxy, add the following to your settings:
+
+```json
+{
+ "proxy": "http://127.0.0.1:10809"
+}
+```
+
+Or to set a `socks5` proxy:
+
+```json
+{
+ "proxy": "socks5h://localhost:10808"
+}
+```
+
+If you wish to exclude certain hosts from using the proxy, set the `NO_PROXY` environment variable. This accepts a comma-separated list of hostnames, host suffixes, IPv4/IPv6 addresses or blocks that should not use the proxy. For example if your environment included `NO_PROXY="google.com, 192.168.1.0/24"` all hosts in `192.168.1.*`, `google.com` and `*.google.com` would bypass the proxy. See [reqwest NoProxy docs](https://docs.rs/reqwest/latest/reqwest/struct.NoProxy.html#method.from_string) for more.
+
+## On Last Window Closed
+
+- Description: What to do when the last window is closed
+- Setting: `on_last_window_closed`
+- Default: `"platform_default"`
+
+**Options**
+
+1. Use platform default behavior:
+
+```json
+{
+ "on_last_window_closed": "platform_default"
+}
+```
+
+2. Always quit the application:
+
+```json
+{
+ "on_last_window_closed": "quit_app"
+}
+```
+
+## Profiles
+
+- Description: Configuration profiles that can be applied on top of existing settings
+- Setting: `profiles`
+- Default: `{}`
+
+**Options**
+
+Configuration object for defining settings profiles. Example:
+
+```json
+{
+ "profiles": {
+ "presentation": {
+ "buffer_font_size": 20,
+ "ui_font_size": 18,
+ "theme": "One Light"
+ }
+ }
+}
+```
+
+## Preview tabs
+
+- Description:
+ Preview tabs allow you to open files in preview mode, where they close automatically when you switch to another file unless you explicitly pin them. This is useful for quickly viewing files without cluttering your workspace. Preview tabs display their file names in italics. \
+ There are several ways to convert a preview tab into a regular tab:
+
+ - Double-clicking on the file
+ - Double-clicking on the tab header
+ - Using the `project_panel::OpenPermanent` action
+ - Editing the file
+ - Dragging the file to a different pane
+
+- Setting: `preview_tabs`
+- Default:
+
+```json
+"preview_tabs": {
+ "enabled": true,
+ "enable_preview_from_file_finder": false,
+ "enable_preview_from_code_navigation": false,
+}
+```
+
+### Enable preview from file finder
+
+- Description: Determines whether to open files in preview mode when selected from the file finder.
+- Setting: `enable_preview_from_file_finder`
+- Default: `false`
+
+**Options**
+
+`boolean` values
+
+### Enable preview from code navigation
+
+- Description: Determines whether a preview tab gets replaced when code navigation is used to navigate away from the tab.
+- Setting: `enable_preview_from_code_navigation`
+- Default: `false`
+
+**Options**
+
+`boolean` values
+
+## File Finder
+
+### File Icons
+
+- Description: Whether to show file icons in the file finder.
+- Setting: `file_icons`
+- Default: `true`
+
+### Modal Max Width
+
+- Description: Max-width of the file finder modal. It can take one of these values: `small`, `medium`, `large`, `xlarge`, and `full`.
+- Setting: `modal_max_width`
+- Default: `small`
+
+### Skip Focus For Active In Search
+
+- Description: Determines whether the file finder should skip focus for the active file in search results.
+- Setting: `skip_focus_for_active_in_search`
+- Default: `true`
+
+## Pane Split Direction Horizontal
+
+- Description: The direction that you want to split panes horizontally
+- Setting: `pane_split_direction_horizontal`
+- Default: `"up"`
+
+**Options**
+
+1. Split upward:
+
+```json
+{
+ "pane_split_direction_horizontal": "up"
+}
+```
+
+2. Split downward:
+
+```json
+{
+ "pane_split_direction_horizontal": "down"
+}
+```
+
+## Pane Split Direction Vertical
+
+- Description: The direction that you want to split panes vertically
+- Setting: `pane_split_direction_vertical`
+- Default: `"left"`
+
+**Options**
+
+1. Split to the left:
+
+```json
+{
+ "pane_split_direction_vertical": "left"
+}
+```
+
+2. Split to the right:
+
+```json
+{
+ "pane_split_direction_vertical": "right"
+}
+```
+
+## Preferred Line Length
+
+- Description: The column at which to soft-wrap lines, for buffers where soft-wrap is enabled.
+- Setting: `preferred_line_length`
+- Default: `80`
+
+**Options**
+
+`integer` values
+
+## Private Files
+
+- Description: Globs to match against file paths to determine if a file is private
+- Setting: `private_files`
+- Default: `["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/secrets.yml"]`
+
+**Options**
+
+List of `string` glob patterns
+
+## Projects Online By Default
+
+- Description: Whether or not to show the online projects view by default.
+- Setting: `projects_online_by_default`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
+## Read SSH Config
+
+- Description: Whether to read SSH configuration files
+- Setting: `read_ssh_config`
+- Default: `true`
+
+**Options**
+
+`boolean` values
+
+## Redact Private Values
+
+- Description: Hide the values of variables from visual display in private files
+- Setting: `redact_private_values`
+- Default: `false`
+
+**Options**
+
+`boolean` values
+
+## Relative Line Numbers
+
+- Description: Whether to show relative line numbers in the gutter
+- Setting: `relative_line_numbers`
+- Default: `false`
+
+**Options**
+
+`boolean` values
+
+## Remove Trailing Whitespace On Save
+
+- Description: Whether or not to remove any trailing whitespace from lines of a buffer before saving it.
+- Setting: `remove_trailing_whitespace_on_save`
+- Default: `true`
+
+**Options**
-## Network Proxy
+`boolean` values
-- Description: Configure a network proxy for Zed.
-- Setting: `proxy`
-- Default: `null`
+## Resize All Panels In Dock
+
+- Description: Whether to resize all the panels in a dock when resizing the dock. Can be a combination of "left", "right" and "bottom".
+- Setting: `resize_all_panels_in_dock`
+- Default: `["left"]`
**Options**
-The proxy setting must contain a URL to the proxy.
+List of strings containing any combination of:
-The following URI schemes are supported:
+- `"left"`: Resize left dock panels together
+- `"right"`: Resize right dock panels together
+- `"bottom"`: Resize bottom dock panels together
-- `http`
-- `https`
-- `socks4` - SOCKS4 proxy with local DNS
-- `socks4a` - SOCKS4 proxy with remote DNS
-- `socks5` - SOCKS5 proxy with local DNS
-- `socks5h` - SOCKS5 proxy with remote DNS
+## Restore on File Reopen
-`http` will be used when no scheme is specified.
+- Description: Whether to attempt to restore previous file's state when opening it again. The state is stored per pane.
+- Setting: `restore_on_file_reopen`
+- Default: `true`
-By default no proxy will be used, or Zed will attempt to retrieve proxy settings from environment variables, such as `http_proxy`, `HTTP_PROXY`, `https_proxy`, `HTTPS_PROXY`, `all_proxy`, `ALL_PROXY`, `no_proxy` and `NO_PROXY`.
+**Options**
-For example, to set an `http` proxy, add the following to your settings:
+`boolean` values
+
+## Restore on Startup
+
+- Description: Controls session restoration on startup.
+- Setting: `restore_on_startup`
+- Default: `last_session`
+
+**Options**
+
+1. Restore all workspaces that were open when quitting Zed:
```json
{
- "proxy": "http://127.0.0.1:10809"
+ "restore_on_startup": "last_session"
}
```
-Or to set a `socks5` proxy:
+2. Restore the workspace that was closed last:
```json
{
- "proxy": "socks5h://localhost:10808"
+ "restore_on_startup": "last_workspace"
}
```
-If you wish to exclude certain hosts from using the proxy, set the `NO_PROXY` environment variable. This accepts a comma-separated list of hostnames, host suffixes, IPv4/IPv6 addresses or blocks that should not use the proxy. For example if your environment included `NO_PROXY="google.com, 192.168.1.0/24"` all hosts in `192.168.1.*`, `google.com` and `*.google.com` would bypass the proxy. See [reqwest NoProxy docs](https://docs.rs/reqwest/latest/reqwest/struct.NoProxy.html#method.from_string) for more.
-
-## Preview tabs
-
-- Description:
- Preview tabs allow you to open files in preview mode, where they close automatically when you switch to another file unless you explicitly pin them. This is useful for quickly viewing files without cluttering your workspace. Preview tabs display their file names in italics. \
- There are several ways to convert a preview tab into a regular tab:
-
- - Double-clicking on the file
- - Double-clicking on the tab header
- - Using the `project_panel::OpenPermanent` action
- - Editing the file
- - Dragging the file to a different pane
-
-- Setting: `preview_tabs`
-- Default:
+3. Always start with an empty editor:
```json
-"preview_tabs": {
- "enabled": true,
- "enable_preview_from_file_finder": false,
- "enable_preview_from_code_navigation": false,
+{
+ "restore_on_startup": "none"
}
```
-### Enable preview from file finder
+## Scroll Beyond Last Line
-- Description: Determines whether to open files in preview mode when selected from the file finder.
-- Setting: `enable_preview_from_file_finder`
-- Default: `false`
+- Description: Whether the editor will scroll beyond the last line
+- Setting: `scroll_beyond_last_line`
+- Default: `"one_page"`
**Options**
-`boolean` values
+1. Scroll one page beyond the last line by one page:
-### Enable preview from code navigation
+```json
+{
+ "scroll_beyond_last_line": "one_page"
+}
+```
-- Description: Determines whether a preview tab gets replaced when code navigation is used to navigate away from the tab.
-- Setting: `enable_preview_from_code_navigation`
-- Default: `false`
+2. The editor will scroll beyond the last line by the same amount of lines as `vertical_scroll_margin`:
-**Options**
+```json
+{
+ "scroll_beyond_last_line": "vertical_scroll_margin"
+}
+```
-`boolean` values
+3. The editor will not scroll beyond the last line:
-## File Finder
+```json
+{
+ "scroll_beyond_last_line": "off"
+}
+```
-### File Icons
+**Options**
-- Description: Whether to show file icons in the file finder.
-- Setting: `file_icons`
-- Default: `true`
+`boolean` values
-### Modal Max Width
+## Scroll Sensitivity
-- Description: Max-width of the file finder modal. It can take one of these values: `small`, `medium`, `large`, `xlarge`, and `full`.
-- Setting: `modal_max_width`
-- Default: `small`
+- Description: Scroll sensitivity multiplier. This multiplier is applied to both the horizontal and vertical delta values while scrolling.
+- Setting: `scroll_sensitivity`
+- Default: `1.0`
-### Skip Focus For Active In Search
+**Options**
-- Description: Determines whether the file finder should skip focus for the active file in search results.
-- Setting: `skip_focus_for_active_in_search`
-- Default: `true`
+Positive `float` values
-## Preferred Line Length
+### Fast Scroll Sensitivity
-- Description: The column at which to soft-wrap lines, for buffers where soft-wrap is enabled.
-- Setting: `preferred_line_length`
-- Default: `80`
+- Description: Scroll sensitivity multiplier for fast scrolling. This multiplier is applied to both the horizontal and vertical delta values while scrolling. Fast scrolling happens when a user holds the alt or option key while scrolling.
+- Setting: `fast_scroll_sensitivity`
+- Default: `4.0`
**Options**
-`integer` values
+Positive `float` values
-## Projects Online By Default
+### Horizontal Scroll Margin
-- Description: Whether or not to show the online projects view by default.
-- Setting: `projects_online_by_default`
-- Default: `true`
+- Description: The number of characters to keep on either side when scrolling with the mouse
+- Setting: `horizontal_scroll_margin`
+- Default: `5`
**Options**
-`boolean` values
+Non-negative `integer` values
-## Remove Trailing Whitespace On Save
+### Vertical Scroll Margin
-- Description: Whether or not to remove any trailing whitespace from lines of a buffer before saving it.
-- Setting: `remove_trailing_whitespace_on_save`
-- Default: `true`
+- Description: The number of lines to keep above/below the cursor when scrolling with the keyboard
+- Setting: `vertical_scroll_margin`
+- Default: `3`
**Options**
-`boolean` values
+Non-negative `integer` values
## Search
@@ -1,49 +1,100 @@
# Zed Releases
-Zed currently maintains two public releases for macOS:
+Read about Zed's release channels [here](https://zed.dev/faq#what-are-the-release-channels).
-- [Stable](https://zed.dev/download): This is the primary version that people download and use.
-- [Preview](https://zed.dev/releases/preview): which receives updates a week ahead of Stable for early adopters.
+## Wednesday release process
-Typically we cut a new minor release every Wednesday. The current Preview becomes Stable, and the new Preview contains everything on main up until that point.
+You will need write access to the Zed repository to do this.
-If bugs are found and fixed during the week, they may be cherry-picked into the release branches and so new patch versions for preview and stable can become available throughout the week.
+Credentials for various services used in this process can be found in 1Password.
-## Wednesday release process
+---
-You will need write access to the Zed repository to do this:
+1. Checkout `main` and ensure your working copy is clean.
+
+1. Run `git fetch && git pull` to ensure you have the latest commits locally.
+
+1. Run `git fetch --tags --force` to forcibly ensure your local tags are in sync with the remote.
+
+1. Run `./script/get-stable-channel-release-notes`.
+
+ - Follow the instructions at the end of the script and aggregate the release notes into one structure.
+
+1. Run `./script/bump-zed-minor-versions`.
+
+ - Push the tags and branches as instructed.
+
+1. Run `./script/get-preview-channel-changes`.
+
+ - Take the script's output and build release notes by organizing each release note line into a category.
+ - Use a prior release for the initial outline.
+ - Make sure to append the `Credit` line, if present, to the end of the release note line.
+
+1. Once release drafts are up on [GitHub Releases](https://github.com/zed-industries/zed/releases), paste both preview and stable release notes into each and **save**.
+
+ - **Do not publish the drafts, yet.**
+
+1. Check the release assets.
+
+ - Ensure the stable and preview release jobs have finished without error.
+ - Ensure each draft has the proper number of assets—releases currently have 10 assets each.
+ - Download the artifacts for each release draft and test that you can run them locally.
+
+1. Publish stable / preview drafts, one at a time.
-- Checkout `main` and ensure your working copy is clean.
-- Run `./script/bump-zed-minor-versions` and push the tags
- and branches as instructed.
-- Wait for the builds to appear on [the Releases tab on GitHub](https://github.com/zed-industries/zed/releases) (typically takes around 30 minutes)
-- While you're waiting:
- - Start creating the new release notes for preview. You can start with the output of `./script/get-preview-channel-changes`.
- - Start drafting the release tweets.
-- Once the builds are ready:
- - Copy the release notes from the previous Preview release(s) to the current Stable release.
- - Download the artifacts for each release and test that you can run them locally.
- - Publish the releases on GitHub.
- - Tweet the tweets (Credentials are in 1Password).
+ - Use [Vercel](https://vercel.com/zed-industries/zed-dev) to check the progress of the website rebuild.
+ The release will be public once the rebuild has completed.
+
+1. Publish the release email that has been sent to [Kit](https://kit.com).
+
+ - Make sure to double-check that the email is correct before publishing.
+ - We sometimes correct things here and there that didn't translate from GitHub's renderer to Kit's.
+
+1. Build social media posts based on the popular items in stable.
+
+ - You can use the [prior week's post chain](https://zed.dev/channel/tweets-23331) as your outline.
+ - Stage the copy and assets using [Buffer](https://buffer.com), for both X and BlueSky.
+ - Publish both, one at a time, ensuring both are posted to each respective platform.
## Patch release process
-If your PR fixes a panic or a crash, you should cherry-pick it to the current stable and preview branches. If your PR fixes a regression in recently released code, you should cherry-pick it to preview.
+If your PR fixes a panic or a crash, you should cherry-pick it to the current stable and preview branches.
+If your PR fixes a regression in recently released code, you should cherry-pick it to preview.
You will need write access to the Zed repository to do this:
-- Send a PR containing your change to `main` as normal.
-- Leave a comment on the PR `/cherry-pick v0.XXX.x`. Once your PR is merged, the GitHub bot will send a PR to the branch.
- - In case of a merge conflict, you will have to cherry-pick manually and push the change to the `v0.XXX.x` branch.
-- After the commits are cherry-picked onto the branch, run `./script/trigger-release {preview|stable}`. This will bump the version numbers, create a new release tag, and kick off a release build.
- - This can also be run from the [GitHub Actions UI](https://github.com/zed-industries/zed/actions/workflows/bump_patch_version.yml):
- 
-- Wait for the builds to appear on [the Releases tab on GitHub](https://github.com/zed-industries/zed/releases) (typically takes around 30 minutes)
-- Proof-read and edit the release notes as needed.
-- Download the artifacts for each release and test that you can run them locally.
-- Publish the release.
+---
+
+1. Send a PR containing your change to `main` as normal.
+
+1. Once it is merged, cherry-pick the commit locally to either of the release branches (`v0.XXX.x`).
+
+ - In some cases, you may have to handle a merge conflict.
+ More often than not, this will happen when cherry-picking to stable, as the stable branch is more "stale" than the preview branch.
+
+1. After the commit is cherry-picked, run `./script/trigger-release {preview|stable}`.
+ This will bump the version numbers, create a new release tag, and kick off a release build.
+
+ - This can also be run from the [GitHub Actions UI](https://github.com/zed-industries/zed/actions/workflows/bump_patch_version.yml):
+ 
+
+1. Once release drafts are up on [GitHub Releases](https://github.com/zed-industries/zed/releases), proofread and edit the release notes as needed and **save**.
+
+ - **Do not publish the drafts, yet.**
+
+1. Check the release assets.
+
+ - Ensure the stable / preview release jobs have finished without error.
+ - Ensure each draft has the proper number of assets—releases currently have 10 assets each.
+ - Download the artifacts for each release draft and test that you can run them locally.
+
+1. Publish stable / preview drafts, one at a time.
+
+ - Use [Vercel](https://vercel.com/zed-industries/zed-dev) to check the progress of the website rebuild.
+ The release will be public once the rebuild has completed.
## Nightly release process
In addition to the public releases, we also have a nightly build that we encourage employees to use.
-Nightly is released by cron once a day, and can be shipped as often as you'd like. There are no release notes or announcements, so you can just merge your changes to main and run `./script/trigger-release nightly`.
+Nightly is released by cron once a day, and can be shipped as often as you'd like.
+There are no release notes or announcements, so you can just merge your changes to main and run `./script/trigger-release nightly`.
@@ -6,35 +6,72 @@ Elixir support is available through the [Elixir extension](https://github.com/ze
- [elixir-lang/tree-sitter-elixir](https://github.com/elixir-lang/tree-sitter-elixir)
- [phoenixframework/tree-sitter-heex](https://github.com/phoenixframework/tree-sitter-heex)
- Language servers:
+ - [elixir-lang/expert](https://github.com/elixir-lang/expert)
- [elixir-lsp/elixir-ls](https://github.com/elixir-lsp/elixir-ls)
- [elixir-tools/next-ls](https://github.com/elixir-tools/next-ls)
- [lexical-lsp/lexical](https://github.com/lexical-lsp/lexical)
## Choosing a language server
-The Elixir extension offers language server support for `elixir-ls`, `next-ls`, and `lexical`.
+The Elixir extension offers language server support for `expert`, `elixir-ls`, `next-ls`, and `lexical`.
`elixir-ls` is enabled by default.
+### Expert
+
+To switch to `expert`, add the following to your `settings.json`:
+
+```json
+{
+ "languages": {
+ "Elixir": {
+ "language_servers": [
+ "expert",
+ "!elixir-ls",
+ "!next-ls",
+ "!lexical",
+ "..."
+ ]
+ }
+ }
+}
+```
+
+### Next LS
+
To switch to `next-ls`, add the following to your `settings.json`:
```json
{
"languages": {
"Elixir": {
- "language_servers": ["next-ls", "!elixir-ls", "..."]
+ "language_servers": [
+ "next-ls",
+ "!expert",
+ "!elixir-ls",
+ "!lexical",
+ "..."
+ ]
}
}
}
```
+### Lexical
+
To switch to `lexical`, add the following to your `settings.json`:
```json
{
"languages": {
"Elixir": {
- "language_servers": ["lexical", "!elixir-ls", "..."]
+ "language_servers": [
+ "lexical",
+ "!expert",
+ "!elixir-ls",
+ "!next-ls",
+ "..."
+ ]
}
}
}
@@ -45,9 +45,9 @@ Zed supports ways to spawn (and rerun) commands using its integrated terminal to
// Whether to show the task line in the output of the spawned task, defaults to `true`.
"show_summary": true,
// Whether to show the command line in the output of the spawned task, defaults to `true`.
- "show_output": true,
+ "show_output": true
// Represents the tags for inline runnable indicators, or spawning multiple tasks at once.
- "tags": []
+ // "tags": []
}
]
```
@@ -431,6 +431,7 @@ Project panel can be shown/hidden with {#action project_panel::ToggleFocus} ({#k
"auto_reveal_entries": true, // Show file in panel when activating its buffer
"auto_fold_dirs": true, // Fold dirs with single subdir
"sticky_scroll": true, // Stick parent directories at top of the project panel.
+ "drag_and_drop": true, // Whether drag and drop is enabled
"scrollbar": { // Project panel scrollbar settings
"show": null // Show/hide: (auto, system, always, never)
},
@@ -1,6 +1,6 @@
[package]
name = "zed_html"
-version = "0.2.1"
+version = "0.2.2"
edition.workspace = true
publish.workspace = true
license = "Apache-2.0"
@@ -1,7 +1,7 @@
id = "html"
name = "HTML"
description = "HTML support."
-version = "0.2.1"
+version = "0.2.2"
schema_version = 1
authors = ["Isaac Clayton <slightknack@gmail.com>"]
repository = "https://github.com/zed-industries/zed"
@@ -3,6 +3,7 @@ grammar = "html"
path_suffixes = ["html", "htm", "shtml"]
autoclose_before = ">})"
block_comment = { start = "<!--", prefix = "", end = "-->", tab_size = 0 }
+wrap_characters = { start_prefix = "<", start_suffix = ">", end_prefix = "</", end_suffix = ">" }
brackets = [
{ start = "{", end = "}", close = true, newline = true },
{ start = "[", end = "]", close = true, newline = true },
@@ -1 +1,5 @@
(comment) @annotation
+
+(element
+ (start_tag
+ (tag_name) @name)) @item
@@ -1,12 +1,11 @@
#!/usr/bin/env node --redirect-warnings=/dev/null
const { execFileSync } = require("child_process");
-let { GITHUB_ACCESS_TOKEN } = process.env;
+const { GITHUB_ACCESS_TOKEN } = process.env;
const GITHUB_URL = "https://github.com";
const SKIPPABLE_NOTE_REGEX = /^\s*-?\s*n\/?a\s*/ims;
const PULL_REQUEST_WEB_URL = "https://github.com/zed-industries/zed/pull";
-const PULL_REQUEST_API_URL =
- "https://api.github.com/repos/zed-industries/zed/pulls";
+const PULL_REQUEST_API_URL = "https://api.github.com/repos/zed-industries/zed/pulls";
const DIVIDER = "-".repeat(80);
main();
@@ -25,15 +24,12 @@ async function main() {
const STAFF_MEMBERS = new Set(
(
await (
- await fetch(
- "https://api.github.com/orgs/zed-industries/teams/staff/members",
- {
- headers: {
- Authorization: `token ${GITHUB_ACCESS_TOKEN}`,
- Accept: "application/vnd.github+json",
- },
+ await fetch("https://api.github.com/orgs/zed-industries/teams/staff/members", {
+ headers: {
+ Authorization: `token ${GITHUB_ACCESS_TOKEN}`,
+ Accept: "application/vnd.github+json",
},
- )
+ })
).json()
).map(({ login }) => login.toLowerCase()),
);
@@ -44,11 +40,7 @@ async function main() {
};
// Get the last two preview tags
- const [newTag, oldTag] = execFileSync(
- "git",
- ["tag", "--sort", "-committerdate"],
- { encoding: "utf8" },
- )
+ const [newTag, oldTag] = execFileSync("git", ["tag", "--sort", "-committerdate"], { encoding: "utf8" })
.split("\n")
.filter((t) => t.startsWith("v") && t.endsWith("-pre"));
@@ -59,14 +51,10 @@ async function main() {
const pullRequestNumbers = getPullRequestNumbers(oldTag, newTag);
// Get the PRs that were cherry-picked between main and the old tag.
- const existingPullRequestNumbers = new Set(
- getPullRequestNumbers("main", oldTag),
- );
+ const existingPullRequestNumbers = new Set(getPullRequestNumbers("main", oldTag));
// Filter out those existing PRs from the set of new PRs.
- const newPullRequestNumbers = pullRequestNumbers.filter(
- (number) => !existingPullRequestNumbers.has(number),
- );
+ const newPullRequestNumbers = pullRequestNumbers.filter((number) => !existingPullRequestNumbers.has(number));
// Fetch the pull requests from the GitHub API.
console.log("Merged Pull requests:");
@@ -84,8 +72,7 @@ async function main() {
const releaseNotesHeader = /^\s*Release Notes:(.+)/ims;
const releaseNotes = pullRequest.body || "";
- let contributor =
- pullRequest.user?.login ?? "Unable to identify contributor";
+ let contributor = pullRequest.user?.login ?? "Unable to identify contributor";
const captures = releaseNotesHeader.exec(releaseNotes);
let notes = captures ? captures[1] : "MISSING";
notes = notes.trim();
@@ -127,11 +114,7 @@ function getCreditString(pullRequestNumber, contributor, isStaff) {
}
function getPullRequestNumbers(oldTag, newTag) {
- const pullRequestNumbers = execFileSync(
- "git",
- ["log", `${oldTag}..${newTag}`, "--oneline"],
- { encoding: "utf8" },
- )
+ const pullRequestNumbers = execFileSync("git", ["log", `${oldTag}..${newTag}`, "--oneline"], { encoding: "utf8" })
.split("\n")
.filter((line) => line.length > 0)
.map((line) => {
@@ -0,0 +1,101 @@
+#!/usr/bin/env node --redirect-warnings=/dev/null
+
+// This script should be ran before `bump-zed-minor-versions`
+
+// Prints the changelogs for all preview releases associated with the most
+// recent preview minor version.
+
+// Future TODO: Have the script perform deduplication of lines that were
+// included in both past stable and preview patches that shouldn't be mentioned
+// again in this week's stable minor release.
+
+// Future TODO: Get changelogs for latest cherry-picked commits on preview and
+// stable that didn't make it into a release, as they were cherry picked
+
+const { execFileSync } = require("child_process");
+const { GITHUB_ACCESS_TOKEN } = process.env;
+const GITHUB_TAGS_API_URL = "https://api.github.com/repos/zed-industries/zed/releases/tags";
+const DIVIDER = "-".repeat(80);
+
+main();
+
+async function main() {
+ if (!GITHUB_ACCESS_TOKEN) {
+ try {
+ GITHUB_ACCESS_TOKEN = execFileSync("gh", ["auth", "token"]).toString();
+ } catch (error) {
+ console.log(error);
+ console.log("No GITHUB_ACCESS_TOKEN and no `gh auth token`");
+ process.exit(1);
+ }
+ }
+
+ const allTags = execFileSync("git", ["tag", "--sort", "-committerdate"], { encoding: "utf8" })
+ .split("\n")
+ .filter((t) => t.length > 0);
+ const latestPreviewTag = allTags.filter((t) => t.startsWith("v") && t.endsWith("-pre"))[0];
+ const latestPreviewMinorVersion = latestPreviewTag.split(".")[1];
+ const latestPreviewTagRegex = new RegExp(`^v(\\d+)\\.(${latestPreviewMinorVersion})\\.(\\d+)-pre$`);
+
+ const parsedPreviewTags = allTags
+ .map((tag) => {
+ const match = tag.match(latestPreviewTagRegex);
+ if (match) {
+ return {
+ tag,
+ version: {
+ major: parseInt(match[1]),
+ minor: parseInt(match[2]),
+ patch: parseInt(match[3]),
+ },
+ };
+ }
+ return null;
+ })
+ .filter((item) => item !== null)
+ .sort((a, b) => a.version.patch - b.version.patch);
+
+ const matchingPreviewTags = parsedPreviewTags.map((item) => item.tag);
+
+ console.log("Fetching release information for preview tags:");
+ console.log(DIVIDER);
+
+ for (const tag of matchingPreviewTags) {
+ const releaseApiUrl = `${GITHUB_TAGS_API_URL}/${tag}`;
+
+ try {
+ const response = await fetch(releaseApiUrl, {
+ headers: {
+ Authorization: `token ${GITHUB_ACCESS_TOKEN}`,
+ },
+ });
+
+ if (!response.ok) {
+ console.log(`Failed to fetch release for ${tag}: ${response.status}`);
+ continue;
+ }
+
+ const release = await response.json();
+
+ console.log(`\nRelease: ${release.name || tag}`);
+ console.log(`Tag: ${tag}`);
+ console.log(`Published: ${release.published_at}`);
+ console.log(`URL: ${release.html_url}`);
+ console.log("\nRelease Notes:");
+ console.log(release.body || "No release notes");
+ console.log(DIVIDER);
+ } catch (error) {
+ console.log(`Error fetching release for ${tag}:`, error.message);
+ }
+ }
+
+ const patchUpdateTags = parsedPreviewTags.filter((tag) => tag.version.patch != 0).map((tag) => tag.tag);
+
+ console.log();
+ console.log("Please review the release notes associated with the following patch versions:");
+ for (const tag of patchUpdateTags) {
+ console.log(`- ${tag}`);
+ }
+ console.log("Remove items that have already been mentioned in the current published stable versions.");
+ console.log("https://github.com/zed-industries/zed/releases?q=prerelease%3Afalse&expanded=true");
+}