diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..3550a30f --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.zed/tasks.json b/.zed/tasks.json index 77c05b36..d3895146 100644 --- a/.zed/tasks.json +++ b/.zed/tasks.json @@ -1,7 +1,8 @@ // Project tasks — Alt+T opens the picker (see keymap: task::Spawn) // // The check/clippy/test tasks set CARGO_TARGET_DIR so they don't invalidate what -// `cargo run` and rust-analyzer built. See CLAUDE.md ▸ Commands. +// `cargo run` and rust-analyzer built. The "(nix)" tasks supply GPUI's Linux libraries through +// `nix/devshell.nix`; with direnv and nix-direnv, the plain tasks work on Linux too. [ { "label": "cargo run (app)", @@ -31,6 +32,27 @@ "label": "CI (fmt + clippy + test)", "command": "./scripts/ci.sh" }, + { + "label": "cargo run (nix)", + "command": "nix-shell --run 'cargo run -p app'", + "reveal": "always", + "use_new_terminal": false + }, + { + "label": "cargo test (nix)", + "command": "nix-shell --run 'CARGO_TARGET_DIR=target/ci cargo test --workspace'" + }, + { + "label": "CI checks (nix)", + "command": "nix-shell --run './scripts/ci.sh'", + "reveal": "always" + }, + { + "label": "nix dev shell", + "command": "nix-shell", + "reveal": "always", + "use_new_terminal": true + }, { "label": "lazygit", "command": "lazygit" diff --git a/CLAUDE.md b/CLAUDE.md index 6cc5dbd8..647cb02d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,6 +135,8 @@ executable** first and then on the system. A checkout without them builds, launc ./scripts/fetch-binaries.sh target/release # before building an installer ``` +On Windows, use `./scripts/fetch-binaries.ps1` from PowerShell. + Consequence for tests: `preview`'s PDF and media tests assert *behaviour that depends on what is installed*. With the binary present they require a real render; without it they require a clean decline. Both directions are real assertions — if a test says "skipping", that is the no-binary diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a8b8041..eeb934cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,16 @@ cargo run The first build downloads Rust dependencies. The [`sample/`](sample) directory contains a sample collection and images for local testing. qrate builds and runs without optional preview binaries; PDF and video preview coverage needs the tools described in [the development setup guide](docs/dev/SETUP.md). -Linux contributors also need the system libraries listed in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Google Sheets sign-in is optional for local development and needs local credentials; setup details are in [the development setup guide](docs/dev/SETUP.md). +Linux contributors also need the system libraries listed in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). GPUI links `fontconfig`, `freetype`, `alsa-lib` and `openssl` through `pkg-config` at build time, and `dlopen`s `libxkbcommon`, `wayland`, `vulkan-loader`, `libGL` and the X11 client libraries at run time — a build that links cleanly still fails to start without the second group. Without `pkg-config` on `PATH` the build stops in `yeslogic-fontconfig-sys`. + +On NixOS, or with Nix installed anywhere, the shell in this repository supplies all of that and sets `LD_LIBRARY_PATH` so `cargo run` works: + +```sh +nix develop # flake, pins nixpkgs through flake.lock +nix-shell # same shell without flakes, using your own channel +``` + +Both read [`nix/devshell.nix`](nix/devshell.nix), so they cannot drift apart. Google Sheets sign-in is optional for local development and needs local credentials; setup details are in [the development setup guide](docs/dev/SETUP.md). ## Make a contribution diff --git a/Cargo.lock b/Cargo.lock index 8b125db7..4a58a934 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,12 +14,12 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer 0.38.0", "atspi-common", "phf 0.13.1", "serde", @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb" dependencies = [ "accesskit", "hashbrown 0.16.1", @@ -38,9 +38,9 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" dependencies = [ "accesskit", "hashbrown 0.16.1", @@ -62,9 +62,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -80,12 +80,12 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.33.1" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" dependencies = [ "accesskit", - "accesskit_consumer 0.37.0", + "accesskit_consumer 0.38.0", "hashbrown 0.16.1", "static_assertions", "windows 0.62.2", @@ -124,7 +124,7 @@ name = "agent-runtime" version = "0.4.0-beta.2" dependencies = [ "alacritty_terminal", - "gpui", + "gpui-pre", "log", "settings", "windows 0.62.2", @@ -262,11 +262,11 @@ dependencies = [ "checks", "data-exchange", "diagnostics", - "dirs", - "gpui", + "dirs 6.0.0", "gpui-component", - "gpui-component-assets", - "gpui_platform", + "gpui-kit-assets", + "gpui-pre", + "gpui-pre-platform", "keyring", "log", "os_info", @@ -677,11 +677,20 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" -version = "0.71.1" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "bitflags 2.11.0", "cexpr", @@ -1049,7 +1058,7 @@ version = "0.4.0-beta.2" dependencies = [ "diagnostics", "edtf", - "gpui", + "gpui-pre", "log", "reqwest", "serde_json", @@ -1172,16 +1181,6 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "collections" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" -dependencies = [ - "gpui_util", - "indexmap", - "rustc-hash 2.1.1", -] - [[package]] name = "color_quant" version = "1.1.0" @@ -1776,22 +1775,12 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "derive_refineable" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "diagnostics" version = "0.4.0-beta.2" dependencies = [ - "gpui", "gpui-component", + "gpui-pre", "log", "settings", ] @@ -1807,13 +1796,34 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -1824,7 +1834,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -2146,6 +2156,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-float2" version = "0.2.4" @@ -2717,99 +2738,63 @@ dependencies = [ ] [[package]] -name = "gpui" -version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" +name = "gpui-base" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2caaf00ebe0482774dd370a82a936edf4bf19a18a0d1a39353d20ecf61f70330" dependencies = [ - "accesskit", + "aho-corasick", "anyhow", "async-channel", - "async-task", - "backtrace", - "bindgen", - "bitflags 2.11.0", "chrono", - "collections", - "core-video", - "ctor", - "derive_more", - "embed-resource", - "etagere", "futures", - "futures-concurrency", - "getrandom 0.3.4", - "gpui_macros", - "gpui_shared_string", - "gpui_util", - "heapless", - "http_client", - "image", - "inventory", - "itertools 0.14.0", - "log", - "lyon", - "num_cpus", - "parking", - "parking_lot", - "pin-project", - "pollster 0.4.0", - "postage", - "profiling", - "proptest", - "rand 0.9.2", + "gpui-pre", + "gpui-pre-macros", + "gpui-pre-platform", + "gpui-pre-sum-tree", + "html5ever", + "instant", + "lsp-types", + "markdown", + "markup5ever_rcdom", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "raw-window-handle", - "refineable", "regex", - "resvg 0.46.0", - "scheduler", + "ropey", "schemars", - "seahash", "serde", "serde_json", - "slotmap", "smallvec", - "spin 0.10.0", - "strum", - "sum_tree", - "taffy", - "thiserror 2.0.18", + "smol", + "syntect", "tracing", - "ttf-parser", - "url", - "usvg 0.46.0", - "util_macros", - "uuid", - "waker-fn", + "unicode-segmentation", "web-time", - "windows 0.61.3", - "zed-font-kit", - "zed-scap", - "ztracing", ] [[package]] name = "gpui-component" -version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#98af8912ab0b3fe08df519dff7acd96a77b19586" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd7938c395be32cea6127b92f7206501ad1fe7e121a609de1aafdedcb4d98be" dependencies = [ - "aho-corasick", "anyhow", - "async-channel", "chrono", "core-text", "enum-iterator", - "futures", - "gpui", - "gpui-component-assets", + "gpui-base", "gpui-component-macros", - "gpui_macros", - "html5ever", + "gpui-kit-assets", + "gpui-pre", + "gpui-pre-macros", + "gpui-pre-sum-tree", "instant", "itertools 0.13.0", "log", "lsp-types", "markdown", - "markup5ever_rcdom", "notify", "num-traits", "objc2 0.6.4", @@ -2818,7 +2803,6 @@ dependencies = [ "once_cell", "paste", "raw-window-handle", - "regex", "resvg 0.45.1", "ropey", "rust-i18n", @@ -2829,52 +2813,126 @@ dependencies = [ "smallvec", "smol", "tracing", - "unicode-segmentation", "uuid", "windows 0.58.0", - "zed-sum-tree", ] [[package]] -name = "gpui-component-assets" -version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#98af8912ab0b3fe08df519dff7acd96a77b19586" +name = "gpui-component-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dacef57cadf1ce9b05b1f1ba6b3f3fed2521fd814f548eea0e4d8e43c5857795" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gpui-kit-assets" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb6fed67d6488fa9333b6534d17f5eaef32b83e3d7148b83a3d444a1884bd1c" dependencies = [ "anyhow", - "gpui", + "gpui-pre", + "gpui-pre-reqwest", "log", "rust-embed", "wasm-bindgen", "wasm-bindgen-futures", - "zed-reqwest", ] [[package]] -name = "gpui-component-macros" -version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#98af8912ab0b3fe08df519dff7acd96a77b19586" +name = "gpui-pre" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b0f6f593153b6eb84c336ea7ccf12a1fca186f0db99af628e72f85048c1d7b" dependencies = [ - "proc-macro2", - "quote", - "syn", + "accesskit", + "anyhow", + "async-channel", + "async-task", + "backtrace", + "bindgen", + "bitflags 2.11.0", + "chrono", + "core-video", + "ctor", + "derive_more", + "embed-resource", + "etagere", + "futures", + "futures-concurrency", + "getrandom 0.3.4", + "gpui-pre-collections", + "gpui-pre-http-client", + "gpui-pre-macros", + "gpui-pre-refineable", + "gpui-pre-scheduler", + "gpui-pre-shared-string", + "gpui-pre-sum-tree", + "gpui-pre-util", + "gpui-pre-util-macros", + "gpui-pre-ztracing", + "heapless", + "image", + "inventory", + "itertools 0.14.0", + "log", + "lyon", + "num_cpus", + "parking", + "parking_lot", + "pin-project", + "pollster 0.4.0", + "postage", + "profiling", + "proptest", + "rand 0.9.2", + "raw-window-handle", + "regex", + "resvg 0.46.0", + "schemars", + "seahash", + "serde", + "serde_json", + "slotmap", + "smallvec", + "spin 0.10.0", + "strum", + "taffy", + "thiserror 2.0.18", + "tracing", + "ttf-parser", + "url", + "usvg 0.46.0", + "uuid", + "waker-fn", + "web-time", + "windows 0.62.2", + "zed-font-kit", + "zed-scap", ] [[package]] -name = "gpui_apple" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" +name = "gpui-pre-apple" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e027ea436bb876a8da6962862ffdbf9708c6b8352415325647c4fc2a48467d" dependencies = [ "anyhow", "block", "cbindgen", "cocoa 0.26.0", - "collections", "core-foundation 0.10.0", "core-video", "derive_more", "etagere", "foreign-types 0.5.0", - "gpui", + "gpui-pre", + "gpui-pre-collections", "image", "log", "metal", @@ -2883,9 +2941,53 @@ dependencies = [ ] [[package]] -name = "gpui_linux" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" +name = "gpui-pre-collections" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89c8efa2e51e368c8538a7be1ea9a12127ca03abe6cc01d9d3474e9ac4f53016" +dependencies = [ + "gpui-pre-util", + "indexmap", + "rustc-hash 2.1.1", +] + +[[package]] +name = "gpui-pre-derive-refineable" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a098d319acc9f84bf159944f96c5ea43a4f4cd7ed759f984acbd15719495aa0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gpui-pre-http-client" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3495a45a28cb800c8626d2053406114bcaca26d390a4d4b183365b5bb8fdaf02" +dependencies = [ + "anyhow", + "async-compression", + "bytes", + "derive_more", + "futures", + "http", + "http-body", + "log", + "parking_lot", + "serde", + "serde_json", + "serde_urlencoded", + "url", +] + +[[package]] +name = "gpui-pre-linux" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9025a2a9f7e9241651bb5f9df6b69233ca4111b233e1fb3a4544d69ecc52bc3" dependencies = [ "accesskit", "accesskit_unix", @@ -2896,13 +2998,13 @@ dependencies = [ "bytemuck", "calloop", "calloop-wayland-source", - "collections", "filedescriptor", "futures", - "gpui", - "gpui_util", - "gpui_wgpu", - "http_client", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-http-client", + "gpui-pre-util", + "gpui-pre-wgpu", "libc", "log", "notify-rust", @@ -2929,9 +3031,10 @@ dependencies = [ ] [[package]] -name = "gpui_macos" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" +name = "gpui-pre-macos" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54678bfe6be71aa8e3fd31c3ae51af5d81e367344762540fd7cfdb778621abb1" dependencies = [ "accesskit", "accesskit_macos", @@ -2940,7 +3043,6 @@ dependencies = [ "block", "block2 0.6.2", "cocoa 0.26.0", - "collections", "core-foundation 0.10.0", "core-foundation-sys", "core-graphics 0.24.0", @@ -2949,15 +3051,16 @@ dependencies = [ "dispatch2", "foreign-types 0.5.0", "futures", - "gpui", - "gpui_apple", - "gpui_util", + "gpui-pre", + "gpui-pre-apple", + "gpui-pre-collections", + "gpui-pre-media", + "gpui-pre-util", "image", "itertools 0.14.0", "libc", "log", "mach2", - "media", "metal", "objc", "objc2 0.6.4", @@ -2975,33 +3078,139 @@ dependencies = [ ] [[package]] -name = "gpui_macros" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" +name = "gpui-pre-macros" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be2db7b5097b4d523b2bce933604bcc5acdaf679bb9a150e8299e6c07efc29c" dependencies = [ "heck 0.5.0", + "proc-macro-crate", "proc-macro2", "quote", "syn", ] [[package]] -name = "gpui_platform" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" +name = "gpui-pre-media" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a35102d2a165f2fbf21521d56e37d6f0ab906372d13b3e1fa94d68b299b22b74" +dependencies = [ + "anyhow", + "bindgen", + "core-foundation 0.10.0", + "core-video", + "foreign-types 0.5.0", + "metal", + "objc", +] + +[[package]] +name = "gpui-pre-perf" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1db0c046b93c2a29120f8ee4c04bc80a4d7d6117d1164ef349faface8943491" +dependencies = [ + "gpui-pre-collections", + "serde", + "serde_json", +] + +[[package]] +name = "gpui-pre-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35131851444188e573d21d3e8020a51af16bf08b177e25b1120815edbd448c80" dependencies = [ "console_error_panic_hook", - "gpui", - "gpui_linux", - "gpui_macos", - "gpui_web", - "gpui_windows", + "gpui-pre", + "gpui-pre-linux", + "gpui-pre-macos", + "gpui-pre-web", + "gpui-pre-windows", ] [[package]] -name = "gpui_shared_string" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" +name = "gpui-pre-refineable" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864e2e54a3029481dae5b6aae3ba2905f2fb1dfe66ced913b68c9ff527f8e327" +dependencies = [ + "gpui-pre-derive-refineable", +] + +[[package]] +name = "gpui-pre-reqwest" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05be23908e707966824f8c51a609904b7f30739e8d915e120a53c1b995ba964c" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration 0.6.1", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-util", + "tower", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "windows-registry 0.4.0", +] + +[[package]] +name = "gpui-pre-scheduler" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b58a78c4e0c032900704ea49922641c534cf8bbf1bf22eda6ba00a76e88c6c3" +dependencies = [ + "async-task", + "backtrace", + "chrono", + "flume", + "futures", + "parking_lot", + "rand 0.9.2", + "wasm_thread", + "web-time", +] + +[[package]] +name = "gpui-pre-shared-string" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82fc99fe88e44173758a500522d3f4059a6541da4b471af720e3f60cf34a2bc3" dependencies = [ "schemars", "serde", @@ -3009,9 +3218,23 @@ dependencies = [ ] [[package]] -name = "gpui_util" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" +name = "gpui-pre-sum-tree" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "002baed852f20cef1188d3d5e0f749025fc718e4d6cea026b9077a9a6c10d042" +dependencies = [ + "gpui-pre-ztracing", + "heapless", + "log", + "rayon", + "tracing", +] + +[[package]] +name = "gpui-pre-util" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fe779c4cb00929aafcb2b307cd1240588aebb5d1eb328c59b80f77c05a41fad" dependencies = [ "anyhow", "log", @@ -3019,21 +3242,33 @@ dependencies = [ ] [[package]] -name = "gpui_web" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" +name = "gpui-pre-util-macros" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f0ccc4bccb6a31786095d15fc9f40d6a4c6a295522e3460331dd7e929ff2f79" +dependencies = [ + "gpui-pre-perf", + "quote", + "syn", +] + +[[package]] +name = "gpui-pre-web" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27728bd9180d1a10516d719fed446adf85872cedf0e262ad0738f4edc9deaf33" dependencies = [ "anyhow", "console_error_panic_hook", "futures", - "gpui", - "gpui_wgpu", - "http_client", + "gpui-pre", + "gpui-pre-http-client", + "gpui-pre-scheduler", + "gpui-pre-wgpu", "js-sys", "log", "parking_lot", "raw-window-handle", - "scheduler", "uuid", "wasm-bindgen", "wasm-bindgen-futures", @@ -3043,17 +3278,18 @@ dependencies = [ ] [[package]] -name = "gpui_wgpu" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" +name = "gpui-pre-wgpu" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695c613099863bbf434ff896d196e3f55d4d5f59769dac99596e79dbb18a8aff" dependencies = [ "anyhow", "bytemuck", - "collections", "cosmic-text", "etagere", - "gpui", - "gpui_util", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-util", "itertools 0.14.0", "log", "parking_lot", @@ -3069,32 +3305,63 @@ dependencies = [ ] [[package]] -name = "gpui_windows" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" -dependencies = [ - "accesskit", - "accesskit_windows", - "anyhow", - "collections", - "dunce", - "etagere", - "futures", - "gpui", - "gpui_util", - "image", - "itertools 0.14.0", - "log", - "parking_lot", - "rand 0.9.2", - "raw-window-handle", - "smallvec", - "uuid", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-numerics 0.2.0", - "windows-registry 0.5.3", -] +name = "gpui-pre-windows" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a4b60579aa2cb4ef7b6f22133a7b9fe12458062961343bdcf36e259047bc7ad" +dependencies = [ + "accesskit", + "accesskit_windows", + "anyhow", + "dunce", + "etagere", + "futures", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-util", + "image", + "itertools 0.14.0", + "log", + "parking_lot", + "rand 0.9.2", + "raw-window-handle", + "smallvec", + "uuid", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-numerics 0.3.1", + "windows-registry 0.6.1", +] + +[[package]] +name = "gpui-pre-zlog" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1416ea5f018e3a8a1c8be266332c443583f28f8f87379a84ab1e77622173ac0" +dependencies = [ + "anyhow", + "chrono", + "gpui-pre-collections", + "log", +] + +[[package]] +name = "gpui-pre-ztracing" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cc64a25a3cc4e4f1d8acb00074d3925339dae657c020083735738ba8af96bf7" +dependencies = [ + "gpui-pre-zlog", + "gpui-pre-ztracing-macro", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "gpui-pre-ztracing-macro" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f3ea75672348f37d94472e579f5979ee19b744d0fa5aaadc2fe129ccafa80b" [[package]] name = "h2" @@ -3298,26 +3565,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http_client" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" -dependencies = [ - "anyhow", - "async-compression", - "bytes", - "derive_more", - "futures", - "http", - "http-body", - "log", - "parking_lot", - "serde", - "serde_json", - "serde_urlencoded", - "url", -] - [[package]] name = "httparse" version = "1.10.1" @@ -3401,7 +3648,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry 0.5.3", + "windows-registry 0.6.1", ] [[package]] @@ -4287,20 +4534,6 @@ dependencies = [ "digest", ] -[[package]] -name = "media" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" -dependencies = [ - "anyhow", - "bindgen", - "core-foundation 0.10.0", - "core-video", - "foreign-types 0.5.0", - "metal", - "objc", -] - [[package]] name = "memchr" version = "2.8.0" @@ -5459,16 +5692,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "perf" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" -dependencies = [ - "collections", - "serde", - "serde_json", -] - [[package]] name = "phf" version = "0.11.3" @@ -5631,7 +5854,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" name = "plugin-api" version = "0.4.0-beta.2" dependencies = [ - "gpui", + "gpui-pre", "serde_json", "settings", ] @@ -5641,7 +5864,7 @@ name = "plugin-host" version = "0.4.0-beta.2" dependencies = [ "diagnostics", - "gpui", + "gpui-pre", "log", "mlua", "plugin-api", @@ -5796,9 +6019,9 @@ version = "0.4.0-beta.2" dependencies = [ "anyhow", "block2 0.6.2", - "dirs", - "gpui", + "dirs 6.0.0", "gpui-component", + "gpui-pre", "image", "log", "mime_guess", @@ -5861,9 +6084,9 @@ dependencies = [ "csv", "data-exchange", "diagnostics", - "dirs", - "gpui", + "dirs 6.0.0", "gpui-component", + "gpui-pre", "log", "plugin-api", "serde", @@ -5874,8 +6097,9 @@ dependencies = [ [[package]] name = "proptest" -version = "1.10.0" -source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set 0.8.0", "bit-vec 0.8.0", @@ -5894,7 +6118,8 @@ dependencies = [ [[package]] name = "proptest-macro" version = "0.5.0" -source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efaa288b896cb2b345da7b7f2110ab19e51565b83495b56fcec98a62f8b1f33e" dependencies = [ "convert_case 0.11.0", "proc-macro2", @@ -6260,6 +6485,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -6291,14 +6527,6 @@ dependencies = [ "syn", ] -[[package]] -name = "refineable" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" -dependencies = [ - "derive_refineable", -] - [[package]] name = "regex" version = "1.12.3" @@ -6735,22 +6963,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scheduler" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" -dependencies = [ - "async-task", - "backtrace", - "chrono", - "flume", - "futures", - "parking_lot", - "rand 0.9.2", - "wasm_thread", - "web-time", -] - [[package]] name = "schemars" version = "1.2.1" @@ -7032,9 +7244,9 @@ name = "settings" version = "0.4.0-beta.2" dependencies = [ "anyhow", - "dirs", - "gpui", + "dirs 6.0.0", "gpui-component", + "gpui-pre", "log", "rusqlite", "serde", @@ -7084,7 +7296,7 @@ version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ - "dirs", + "dirs 6.0.0", ] [[package]] @@ -7256,7 +7468,7 @@ name = "spellcheck" version = "0.4.0-beta.2" dependencies = [ "diagnostics", - "gpui", + "gpui-pre", "log", "reqwest", "settings", @@ -7379,18 +7591,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "sum_tree" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" -dependencies = [ - "heapless", - "log", - "rayon", - "tracing", - "ztracing", -] - [[package]] name = "sval" version = "2.17.0" @@ -7695,6 +7895,24 @@ dependencies = [ "syn", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "regex-syntax", + "serde", + "serde_derive", + "thiserror 2.0.18", + "walkdir", +] + [[package]] name = "sys-locale" version = "0.3.2" @@ -7771,8 +7989,8 @@ dependencies = [ "ai", "base64", "diagnostics", - "gpui", "gpui-component", + "gpui-pre", "log", "plugin-api", "plugin-host", @@ -8562,16 +8780,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "util_macros" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" -dependencies = [ - "perf", - "quote", - "syn", -] - [[package]] name = "uuid" version = "1.21.0" @@ -8844,7 +9052,8 @@ dependencies = [ [[package]] name = "wasm_thread" version = "0.3.3" -source = "git+https://github.com/zed-industries/wasm_thread?rev=0cf96c7708dfb97ccf3da50347e25edcf75d6937#0cf96c7708dfb97ccf3da50347e25edcf75d6937" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7516db7f32decdadb1c3b8deb1b7d78b9df7606c5cc2f6241737c2ab3a0258e" dependencies = [ "futures", "js-sys", @@ -8872,6 +9081,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", + "log", "rustix", "scoped-tls", "smallvec", @@ -9210,8 +9420,8 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" name = "window-wrapper" version = "0.4.0-beta.2" dependencies = [ - "gpui", "gpui-component", + "gpui-pre", "schemars", "serde", "serde_json", @@ -9477,13 +9687,13 @@ dependencies = [ [[package]] name = "windows-registry" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -9568,6 +9778,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -9619,6 +9838,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -9685,6 +9919,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -9703,6 +9943,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -9721,6 +9967,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -9751,6 +10003,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -9769,6 +10027,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -9787,6 +10051,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -9805,6 +10075,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -9949,8 +10225,8 @@ version = "0.4.0-beta.2" dependencies = [ "agent-runtime", "diagnostics", - "gpui", "gpui-component", + "gpui-pre", "log", "preview", "schemars", @@ -9960,12 +10236,6 @@ dependencies = [ "table", ] -[[package]] -name = "workspace-hack" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beffa227304dbaea3ad6a06ac674f9bc83a3dec3b7f63eeb442de37e7cb6bb01" - [[package]] name = "writeable" version = "0.6.2" @@ -10053,15 +10323,17 @@ dependencies = [ [[package]] name = "xim-ctext" version = "0.3.0" -source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac61a7062c40f3c37b6e82eeeef835d5cc7824b632a72784a89b3963c33284c" dependencies = [ "encoding_rs", ] [[package]] name = "xim-parser" -version = "0.2.1" -source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dcee45f89572d5a65180af3a84e7ddb24f5ea690a6d3aa9de231281544dd7b7" dependencies = [ "bitflags 2.11.0", ] @@ -10324,14 +10596,15 @@ dependencies = [ [[package]] name = "zed-font-kit" version = "0.14.1-zed" -source = "git+https://github.com/zed-industries/font-kit?rev=94b0f28166665e8fd2f53ff6d268a14955c82269#94b0f28166665e8fd2f53ff6d268a14955c82269" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3898e450f36f852edda72e3f985c34426042c4951790b23b107f93394f9bff5" dependencies = [ "bitflags 2.11.0", "byteorder", "core-foundation 0.10.0", "core-graphics 0.24.0", "core-text", - "dirs", + "dirs 5.0.1", "dwrote", "float-ord", "freetype-sys", @@ -10345,59 +10618,11 @@ dependencies = [ "yeslogic-fontconfig-sys", ] -[[package]] -name = "zed-reqwest" -version = "0.12.15-zed" -source = "git+https://github.com/zed-industries/reqwest.git?rev=c15662463bda39148ba154100dd44d3fba5873a4#c15662463bda39148ba154100dd44d3fba5873a4" -dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "ipnet", - "js-sys", - "log", - "mime", - "mime_guess", - "once_cell", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-native-certs", - "rustls-pemfile", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "system-configuration 0.6.1", - "tokio", - "tokio-rustls", - "tokio-socks", - "tokio-util", - "tower", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "windows-registry 0.4.0", -] - [[package]] name = "zed-scap" version = "0.0.8-zed" -source = "git+https://github.com/zed-industries/scap?rev=4afea48c3b002197176fb19cd0f9b180dd36eaac#4afea48c3b002197176fb19cd0f9b180dd36eaac" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b338d705ae33a43ca00287c11129303a7a0aa57b101b72a1c08c863f698ac8" dependencies = [ "anyhow", "cocoa 0.25.0", @@ -10415,22 +10640,11 @@ dependencies = [ "xcb", ] -[[package]] -name = "zed-sum-tree" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d490156d0d7311855564d6e1d6dccab992405a0c0e15e1c8ef18920c02177e35" -dependencies = [ - "arrayvec", - "log", - "rayon", - "workspace-hack", -] - [[package]] name = "zed-xim" version = "0.4.0-zed" -source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0b46ed118eba34d9ba53d94ddc0b665e0e06a2cf874cfa2dd5dec278148642" dependencies = [ "ahash", "hashbrown 0.14.5", @@ -10590,17 +10804,6 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" -[[package]] -name = "zlog" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" -dependencies = [ - "anyhow", - "chrono", - "collections", - "log", -] - [[package]] name = "zmij" version = "1.0.21" @@ -10647,22 +10850,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "ztracing" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" -dependencies = [ - "tracing", - "tracing-subscriber", - "zlog", - "ztracing_macro", -] - -[[package]] -name = "ztracing_macro" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#fc9258b4632544c0819953c59e2c31b9e92ea362" - [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 2ce56c95..bece5297 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,10 +41,14 @@ anyhow = "1" # spreadsheet format for the price of one extension match. calamine = { version = "0.31", features = ["dates"] } csv = "1.3" -gpui = { git = "https://github.com/zed-industries/zed" } -gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "wayland", "x11"] } -gpui-component = { git = "https://github.com/longbridge/gpui-component" } -gpui-component-assets = { git = "https://github.com/longbridge/gpui-component" } +# SPIKE: gpui-kit 0.6. Upstream moved off zed's git repo onto the crates.io `gpui-pre*` +# snapshot, so these are renamed packages rather than new names in our source — every +# `use gpui::*` / `use gpui_component::*` in the workspace keeps resolving unchanged, which +# makes every remaining compile error genuine API drift. +gpui = { package = "gpui-pre", version = "0.3" } +gpui_platform = { package = "gpui-pre-platform", version = "0.3", features = ["font-kit", "wayland", "x11"] } +gpui-component = { version = "0.6" } +gpui-component-assets = { package = "gpui-kit-assets", version = "0.6" } dirs = "6" log = "0.4" # cpal-plus-symphonia glue. `preview` already decodes audio with symphonia for cover art, and the diff --git a/assets/themes/qrate.json b/assets/themes/qrate.json index 7f14945a..40293e44 100644 --- a/assets/themes/qrate.json +++ b/assets/themes/qrate.json @@ -25,7 +25,6 @@ "accent.background": "#f1f3f4", "accent.foreground": "#202124", "accordion.background": "#ffffff", - "accordion.hover.background": "#f8f9fa", "muted.background": "#f1f3f4", "muted.foreground": "#5f6368", @@ -158,7 +157,6 @@ "accent.background": "#404040", "accent.foreground": "#bbbbbb", "accordion.background": "#333333", - "accordion.hover.background": "#383838", "muted.background": "#404040", "muted.foreground": "#777777", diff --git a/crates/app/src/actions.rs b/crates/app/src/actions.rs index a1cc9fb5..29e9dadc 100644 --- a/crates/app/src/actions.rs +++ b/crates/app/src/actions.rs @@ -6,6 +6,7 @@ //! (e.g. the dock toggles, handled on the `App` root in `main.rs`). use gpui::*; +use gpui_component::dock::ToggleZoom; use crate::app_menus::OpenSettings; @@ -36,6 +37,12 @@ pub fn key_bindings() -> Vec { // Save the open project's data to its `.qrate` file. Global: saving shouldn't depend on // where focus sits (a focused cell editor's `Input` context doesn't bind Ctrl+S). KeyBinding::new("secondary-s", Save, None), + // Zoom the focused panel to fill the window, Zed's Shift+Esc. Global and unhandled by us: + // the library mounts the `ToggleZoom` handler on every tab panel's own frame, so the + // action dispatches from wherever focus sits up to the panel containing it — which is + // what makes one binding mean "zoom *this* panel". A panel opts out with + // `BasePanel::zoomable`; zooming back out is never refused, so this cannot strand anyone. + KeyBinding::new("shift-escape", ToggleZoom, None), KeyBinding::new("secondary-b", ToggleLeftDock, None), KeyBinding::new("secondary-`", ToggleBottomDock, None), KeyBinding::new("secondary-alt-b", ToggleRightDock, None), @@ -188,4 +195,37 @@ mod tests { ); } } + + /// Shift+Esc zooms the focused panel, and does it in every context Escape means something + /// else in. The two keys share a base key and differ only by the modifier, so a binding that + /// captured plain Escape here would take the selection and the viewer down with it — and the + /// zoom itself is asserted on the real workspace in `workspace`'s own tests. + #[test] + fn shift_escape_zooms_without_disturbing_plain_escape() { + let keymap = Keymap::new(key_bindings()); + let shift_escape = Keystroke::parse("shift-escape").expect("shift-escape parses"); + for contexts in [ + vec!["DataTable"], + vec!["DetailsPanel"], + vec!["ViewsPanel", "Viewer"], + ] { + let stack: Vec = contexts + .iter() + .map(|context| KeyContext::parse(context).expect("test context parses")) + .collect(); + let (bindings, _) = + keymap.bindings_for_input(std::slice::from_ref(&shift_escape), &stack); + assert_eq!( + bindings.first().map(|binding| binding.action().name()), + Some("dock::ToggleZoom"), + "{contexts:?} zooms on shift-escape" + ); + // The modifier is the whole difference, so plain Escape has to be untouched. + assert_ne!( + escape_action(&contexts), + Some("dock::ToggleZoom"), + "{contexts:?} keeps its own meaning for a bare Escape" + ); + } + } } diff --git a/crates/app/src/logging.rs b/crates/app/src/logging.rs index 901c5f1a..4aa2c77f 100644 --- a/crates/app/src/logging.rs +++ b/crates/app/src/logging.rs @@ -79,7 +79,7 @@ fn version() -> &'static str { env!("CARGO_PKG_VERSION") } -/// Removes GPUI's unactionable focus-node notice and demotes window-teardown errors to debug. +/// Removes GPUI's unactionable focus notice and demotes expected platform errors to debug. /// /// GPUI reports the missing focus node at info level every time focus reaches an internal element /// without an accessibility role, and narrates every accessibility tree update — one line per @@ -91,8 +91,18 @@ fn version() -> &'static str { /// a level that makes every pasted bug report open with three errors nobody can act on. struct QuietGpuiNoise(Box); +/// Whether `target` is one gpui logs under. +/// +/// The empty string is one of them. gpui's error helper builds the target from the caller's file +/// path by splitting on a `crates/` segment — which exists in zed's own checkout and not in the +/// published `gpui-pre`, unpacked as `…/gpui-pre-0.3.3/src/…`, so every error it logs arrives +/// with no target at all. Our own modules always have one, so nothing of ours is caught here. +fn logged_by_gpui(target: &str) -> bool { + target.starts_with("gpui") || target.is_empty() +} + fn is_accessibility_focus_noise(record: &Record) -> bool { - if !record.target().starts_with("gpui::window") { + if !logged_by_gpui(record.target()) { return false; } let message = record.args().to_string(); @@ -100,13 +110,21 @@ fn is_accessibility_focus_noise(record: &Record) -> bool { } fn is_window_teardown(record: &Record) -> bool { - if record.level() != Level::Error || !record.target().starts_with("gpui") { + if record.level() != Level::Error || !logged_by_gpui(record.target()) { return false; } let message = record.args().to_string(); message == "window not found" || message.starts_with("Invalid window handle") } +fn is_missing_dxgi_debug_layer(record: &Record) -> bool { + record.level() == Level::Error + && logged_by_gpui(record.target()) + && record.args().to_string().starts_with( + "The application requested an operation that depends on an SDK component that is missing or mismatched.", + ) +} + impl Log for QuietGpuiNoise { fn enabled(&self, metadata: &Metadata) -> bool { self.0.enabled(metadata) @@ -120,7 +138,7 @@ impl Log for QuietGpuiNoise { if is_accessibility_focus_noise(record) { return; } - if is_window_teardown(record) { + if is_window_teardown(record) || is_missing_dxgi_debug_layer(record) { self.0.log( &Record::builder() .level(Level::Debug) @@ -297,12 +315,18 @@ mod tests { error, "Invalid window handle (0x80040102)" )); + // The published `gpui-pre` logs with no target at all: gpui's error helper derives one + // from a `crates/` path segment that only exists in zed's own checkout. Missing this case + // put 162 of these in one session's log the first time the app ran on 0.6. + assert!(teardown("", error, "window not found")); assert!(!teardown( "gpui_windows::platform", error, "DirectX device lost" )); + // An untargeted error still has to *say* it is a teardown to be demoted. + assert!(!teardown("", error, "DirectX device lost")); assert!(!teardown("app::logging", error, "window not found")); assert!(!teardown( "gpui::window", @@ -311,6 +335,41 @@ mod tests { )); } + #[test] + fn only_the_optional_dxgi_debug_layer_error_is_demoted() { + let dxgi = |target: &str, level: log::Level, message: &str| { + super::is_missing_dxgi_debug_layer( + &log::Record::builder() + .level(level) + .target(target) + .args(format_args!("{message}")) + .build(), + ) + }; + let error = log::Level::Error; + + assert!(dxgi( + "", + error, + "The application requested an operation that depends on an SDK component that is missing or mismatched. (0x887A002D)" + )); + assert!(!dxgi( + "gpui_windows::directx_devices", + error, + "Failed to create Direct3D device" + )); + assert!(!dxgi( + "app::logging", + error, + "The application requested an operation that depends on an SDK component that is missing or mismatched. (0x887A002D)" + )); + assert!(!dxgi( + "", + log::Level::Warn, + "The application requested an operation that depends on an SDK component that is missing or mismatched. (0x887A002D)" + )); + } + #[test] fn only_gpui_missing_focus_nodes_are_suppressed() { let noisy = |target: &str, message: &str| { @@ -335,6 +394,11 @@ mod tests { "app::window::a11y", "a11y: focused element (FocusId(6v1)) has no accessibility node" )); + // Untargeted, for the same reason the teardown filter accepts it. + assert!(noisy( + "", + "a11y: focused element (FocusId(6v1)) has no accessibility node" + )); } /// The dump is only useful if every section is actually in it — a report missing the version diff --git a/crates/app/src/update_check.rs b/crates/app/src/update_check.rs index a4bb7e7c..d3b597fc 100644 --- a/crates/app/src/update_check.rs +++ b/crates/app/src/update_check.rs @@ -73,6 +73,21 @@ fn no_update_status(manual: bool) -> UpdateStatus { } } +fn initial_status(development_build: bool, kind: Option) -> UpdateStatus { + match (development_build, kind) { + (true, _) => { + UpdateStatus::Disabled("Development builds do not update automatically".into()) + } + (false, Some(InstallKind::WindowsMsi)) => { + UpdateStatus::Disabled("Updates are managed by your administrator".into()) + } + (false, Some(_)) => UpdateStatus::Idle, + (false, None) => { + UpdateStatus::Disabled("This source or unmarked build updates manually".into()) + } + } +} + pub struct AutoUpdater { status: UpdateStatus, installation: Option, @@ -95,13 +110,12 @@ enum DownloadEvent { impl AutoUpdater { fn new(cx: &mut Context) -> Self { let installation = updater::detect_installation().ok(); - let status = match &installation { - Some(installation) if installation.marker.kind == InstallKind::WindowsMsi => { - UpdateStatus::Disabled("Updates are managed by your administrator".into()) - } - Some(_) => UpdateStatus::Idle, - None => UpdateStatus::Disabled("This source or unmarked build updates manually".into()), - }; + let status = initial_status( + cfg!(debug_assertions), + installation + .as_ref() + .map(|installation| installation.marker.kind), + ); let _settings_sub = cx.observe_global::(|this, cx| { if automatic_updates(cx) && matches!(this.status, UpdateStatus::Idle) { this.poll(false, cx); @@ -138,7 +152,8 @@ impl AutoUpdater { } pub fn poll(&mut self, manual: bool, cx: &mut Context) { - if self.pending + if matches!(self.status, UpdateStatus::Disabled(_)) + || self.pending || matches!( self.status, UpdateStatus::Ready { .. } | UpdateStatus::Restarting @@ -255,6 +270,9 @@ impl AutoUpdater { } fn start_polling(&mut self, cx: &mut Context) { + if matches!(self.status, UpdateStatus::Disabled(_)) { + return; + } if automatic_updates(cx) { self.poll(false, cx); } @@ -331,13 +349,17 @@ fn helper_path(installation: &Installation) -> PathBuf { } pub fn init(cx: &mut App) { - let current = Version::parse(env!("CARGO_PKG_VERSION")).expect("package version is SemVer"); - match updater::mark_healthy(¤t) { - Ok(Some(receipt)) if receipt.status == updater::ReceiptStatus::Failed => { - log::error!("previous update failed: {}", receipt.message) + if cfg!(debug_assertions) { + log::info!("automatic updates are disabled in development builds"); + } else { + let current = Version::parse(env!("CARGO_PKG_VERSION")).expect("package version is SemVer"); + match updater::mark_healthy(¤t) { + Ok(Some(receipt)) if receipt.status == updater::ReceiptStatus::Failed => { + log::error!("previous update failed: {}", receipt.message) + } + Err(error) => log::warn!("could not finalize previous update: {error:#}"), + _ => {} } - Err(error) => log::warn!("could not finalize previous update: {error:#}"), - _ => {} } let updater = cx.new(AutoUpdater::new); cx.set_global(GlobalUpdater(updater.clone())); @@ -353,8 +375,18 @@ pub fn check_now(cx: &mut App) { #[cfg(test)] mod tests { use semver::Version; + use updater::InstallKind; + + use super::{UpdateStatus, initial_status, no_update_status}; - use super::{UpdateStatus, no_update_status}; + #[test] + fn development_builds_ignore_packaged_install_markers() { + assert!(matches!( + initial_status(true, Some(InstallKind::WindowsNsis)), + UpdateStatus::Disabled(reason) + if reason.as_ref() == "Development builds do not update automatically" + )); + } #[test] fn no_update_is_only_visible_after_a_manual_check() { diff --git a/crates/diagnostics/src/panel.rs b/crates/diagnostics/src/panel.rs index 4a8c4bd5..84829eff 100644 --- a/crates/diagnostics/src/panel.rs +++ b/crates/diagnostics/src/panel.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::*; use gpui_component::button::{Button, ButtonVariants as _}; -use gpui_component::dock::{Panel, PanelControl, PanelEvent}; +use gpui_component::dock::{BasePanel, Panel, PanelEvent}; use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem}; use gpui_component::tab::{Tab, TabBar}; use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; @@ -213,22 +213,24 @@ impl Focusable for ProblemsPanel { impl EventEmitter for ProblemsPanel {} -impl Panel for ProblemsPanel { +impl BasePanel for ProblemsPanel { fn panel_name(&self) -> &'static str { "ProblemsPanel" } - fn title(&mut self, _w: &mut Window, _cx: &mut Context) -> impl IntoElement { - SharedString::from("Problems") - } - - // The library always renders the ⋯ menu button; these just empty it of Close + Zoom. + // The library always renders the ⋯ menu button; this just empties it of Close. fn closable(&self, _cx: &App) -> bool { false } - fn zoomable(&self, _cx: &App) -> Option { - None + fn zoomable(&self, _cx: &App) -> bool { + true + } +} + +impl Panel for ProblemsPanel { + fn title(&mut self, _w: &mut Window, _cx: &mut Context) -> impl IntoElement { + SharedString::from("Problems") } } diff --git a/crates/project-wizard/src/data.rs b/crates/project-wizard/src/data.rs index 86b5bd94..7e772697 100644 --- a/crates/project-wizard/src/data.rs +++ b/crates/project-wizard/src/data.rs @@ -229,6 +229,16 @@ pub fn list_files(folder: &str, recursive: bool) -> Result, FolderEr Ok(files) } +/// Every file in a folder-backed blank project is new collection material, so all of them become +/// otherwise-empty rows rather than being matched against spreadsheet rows that do not exist. +pub fn inventory_folder(folder: &str, recursive: bool) -> Result { + Ok(FolderMatch { + matched_rows: 0, + total_rows: 0, + extra_files: list_files(folder, recursive)?, + }) +} + /// Matches spreadsheet rows against files in `folder` by looking for any cell whose value names a /// file there — by filename, filename stem, or the id a multi-part item's files are built from /// ([`settings::filenames::keys`], the same rule `table::photos` resolves rows with once the @@ -533,6 +543,20 @@ mod tests { assert_eq!(deep, ["nested.jpg", "top.jpg"]); } + #[test] + fn blank_project_inventory_turns_every_file_into_an_extra() { + let dir = tempdir("qrate-blank-folder"); + fs::create_dir_all(dir.join("batch")).unwrap(); + fs::write(dir.join("one.jpg"), "x").unwrap(); + fs::write(dir.join("batch").join("two.png"), "x").unwrap(); + + let mut inventory = inventory_folder(dir.to_str().unwrap(), true).unwrap(); + inventory.extra_files.sort(); + assert_eq!(inventory.matched_rows, 0); + assert_eq!(inventory.total_rows, 0); + assert_eq!(inventory.extra_files, ["one.jpg", "two.png"]); + } + #[test] fn folder_errors() { let csv = sample_dir().join("aderman_collection.csv"); diff --git a/crates/project-wizard/src/steps/files.rs b/crates/project-wizard/src/steps/files.rs index b8e6b5e1..bd6c2147 100644 --- a/crates/project-wizard/src/steps/files.rs +++ b/crates/project-wizard/src/steps/files.rs @@ -108,7 +108,10 @@ impl ProjectWizard { data::match_folder(preview, &self.folder_path, self.recurse_subfolders) }) } - EntryKind::Blank => None, + EntryKind::Blank => Some(data::inventory_folder( + &self.folder_path, + self.recurse_subfolders, + )), }; match result { Some(Ok(m)) => { @@ -263,7 +266,7 @@ impl ProjectWizard { .text_sm() .text_color(cx.theme().muted_foreground), ) - .child(self.folder_field("browse-folder-blank", false, cx)) + .child(self.folder_field("browse-folder-blank", true, cx)) } fn render_local_files( @@ -389,6 +392,16 @@ impl ProjectWizard { fn render_folder_status(&self) -> AnyElement { match (&self.folder_match, &self.folder_error) { + (Some(m), _) if self.entry_kind == EntryKind::Blank => inline_message( + "folder-status", + format!( + "{} file{} will become table rows", + m.extra_files.len(), + if m.extra_files.len() == 1 { "" } else { "s" }, + ), + MsgKind::Success, + ) + .into_any_element(), (Some(m), _) if m.matched_rows == m.total_rows => inline_message( "folder-status", format!("{} of {} files matched", m.matched_rows, m.total_rows), diff --git a/crates/project-wizard/src/steps/review.rs b/crates/project-wizard/src/steps/review.rs index b598c9b2..840ab17b 100644 --- a/crates/project-wizard/src/steps/review.rs +++ b/crates/project-wizard/src/steps/review.rs @@ -139,6 +139,25 @@ fn project_columns( columns } +fn append_extra_file_rows( + headers: &[String], + rows: &mut Vec>, + file_column: Option<&str>, + folder_match: Option<&crate::data::FolderMatch>, +) { + let Some((files, file_col)) = folder_match + .map(|matched| matched.extra_files.as_slice()) + .zip(file_column.and_then(|name| headers.iter().position(|header| header == name))) + else { + return; + }; + rows.extend(files.iter().map(|file| { + let mut row = vec![String::new(); headers.len()]; + row[file_col] = file.clone(); + row + })); +} + impl ProjectWizard { pub(crate) fn create_project(&mut self, window: &mut Window, cx: &mut Context) { let name = self.project_name(cx); @@ -170,18 +189,13 @@ impl ProjectWizard { // A file in the folder that no row names is still part of the collection. It arrives as // its own row, empty but for the filename, so it can be catalogued in qrate instead of // being noticed only when someone counts the folder. - if !self.skip_files - && let Some(m) = &self.folder_match - && let Some(file_col) = self - .file_column - .as_deref() - .and_then(|name| headers.iter().position(|h| h == name)) - { - rows.extend(m.extra_files.iter().map(|file| { - let mut row = vec![String::new(); headers.len()]; - row[file_col] = file.clone(); - row - })); + if !self.skip_files { + append_extra_file_rows( + &headers, + &mut rows, + self.file_column.as_deref(), + self.folder_match.as_ref(), + ); } // A project with no rows is a grid with nothing to type into — the first row has to be // created before anything else can be, so create it here rather than making the archivist @@ -293,6 +307,13 @@ impl ProjectWizard { let files_line = (!self.skip_files) .then(|| { self.folder_match.as_ref().map(|m| { + if self.entry_kind == EntryKind::Blank { + return format!( + "{} file{} · each becomes a table row", + m.extra_files.len(), + if m.extra_files.len() == 1 { "" } else { "s" }, + ); + } let method = match self.link_method { LinkMethod::ExactFilename => "exact filename", LinkMethod::CustomPattern => "custom pattern", @@ -360,9 +381,9 @@ impl ProjectWizard { mod tests { use settings::columns::ColumnType; - use crate::data::{ColumnConfigEntry, ColumnConfigPreview}; + use crate::data::{ColumnConfigEntry, ColumnConfigPreview, FolderMatch}; - use super::project_columns; + use super::{append_extra_file_rows, project_columns}; fn roles(columns: &[crate::project::ProjectColumn]) -> Vec<(&str, ColumnType)> { columns @@ -421,4 +442,25 @@ mod tests { ] ); } + + #[test] + fn blank_folder_files_become_rows_in_the_file_column() { + let headers = vec!["Title".into(), "File".into()]; + let mut rows = Vec::new(); + let folder = FolderMatch { + matched_rows: 0, + total_rows: 0, + extra_files: vec!["one.jpg".into(), "two.png".into()], + }; + + append_extra_file_rows(&headers, &mut rows, Some("File"), Some(&folder)); + + assert_eq!( + rows, + [ + vec![String::new(), "one.jpg".into()], + vec![String::new(), "two.png".into()], + ] + ); + } } diff --git a/crates/project-wizard/src/wizard.rs b/crates/project-wizard/src/wizard.rs index 723af6a7..23df8599 100644 --- a/crates/project-wizard/src/wizard.rs +++ b/crates/project-wizard/src/wizard.rs @@ -326,7 +326,12 @@ impl ProjectWizard { } Ok(()) } - EntryKind::Blank => Ok(()), + EntryKind::Blank => { + if !self.skip_files && self.folder_match.is_none() { + return Err("Choose a files folder, or say you'll add one later".into()); + } + Ok(()) + } }, WizardStep::Link => { if self.link_method == LinkMethod::CustomPattern diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 17a1cb7f..f5f73bb4 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -403,8 +403,8 @@ pub fn path_picker_item( }); let write = Arc::clone(&write); PathPickerApp { - field_size: options.size, - button_size: Some(options.size), + field_size: options.size(), + button_size: Some(options.size()), button_id: SharedString::from(format!("browse-{key}")), files, directories, diff --git a/crates/table/src/delegate.rs b/crates/table/src/delegate.rs index 9883d972..c8a528f3 100644 --- a/crates/table/src/delegate.rs +++ b/crates/table/src/delegate.rs @@ -7,7 +7,7 @@ use gpui::{ Window, div, px, }; use gpui_component::{ - input::InputState, + input::TextareaState, table::{Column, TableDelegate, TableState}, }; use serde::{Deserialize, Serialize}; @@ -63,12 +63,14 @@ pub struct QrateTableDelegate { /// common case: one selected row lives in `selection` alone and never reaches this set. pub(crate) selected_rows: BTreeSet, pub(crate) editing: EditState, - /// Shared single-line editor, reused across whichever cell is being edited. - pub(crate) editor: Entity, + /// Shared cell editor, reused across whichever cell is being edited. Multi-line so a long + /// value wraps inside the editor box instead of running off its right edge; Enter still + /// commits it, and Shift+Enter is what inserts a newline. + pub(crate) editor: Entity, /// Which cell/row/column the note editor is open on, if any. pub(crate) note_edit: Option, /// Shared note editor, the same one-per-table arrangement as [`Self::editor`]. - pub(crate) note_editor: Entity, + pub(crate) note_editor: Entity, /// Each row's resolved image path, parallel to `rows`. `None` until `TablePanel` resolves it. image_paths: Vec>, /// View→source row mapping: `visible_rows[view] == source`. The library only ever sees this @@ -96,7 +98,7 @@ pub struct QrateTableDelegate { } impl QrateTableDelegate { - pub(crate) fn new(editor: Entity, note_editor: Entity) -> Self { + pub(crate) fn new(editor: Entity, note_editor: Entity) -> Self { Self { columns: Vec::new(), rows: Vec::new(), diff --git a/crates/table/src/note.rs b/crates/table/src/note.rs index fb652d22..722f1ed8 100644 --- a/crates/table/src/note.rs +++ b/crates/table/src/note.rs @@ -13,7 +13,7 @@ use gpui::{ IntoElement, ParentElement as _, Path, SharedString, Styled as _, Window, canvas, deferred, div, point, px, }; -use gpui_component::input::Input; +use gpui_component::input::Textarea; use gpui_component::menu::{PopupMenu, PopupMenuItem}; use gpui_component::table::TableState; use gpui_component::{ActiveTheme as _, h_flex}; @@ -724,7 +724,7 @@ pub(crate) fn editor( .text_color(cx.theme().muted_foreground) .child(scope), ) - .child(Input::new(&delegate.note_editor).h_full()), + .child(Textarea::new(&delegate.note_editor).h_full()), )) .into_any_element(), ) diff --git a/crates/table/src/panel.rs b/crates/table/src/panel.rs index f50f45cc..1e6102a5 100644 --- a/crates/table/src/panel.rs +++ b/crates/table/src/panel.rs @@ -6,7 +6,7 @@ use gpui_component::{ ActiveTheme, Disableable as _, IconName, Selectable as _, Sizable as _, button::{Button, ButtonVariants as _}, h_flex, - input::{Escape, Input, InputEvent, InputState}, + input::{Escape, Input, InputEvent, InputState, Textarea, TextareaState}, table::{DataTable, TableEvent, TableState}, v_flex, }; @@ -105,14 +105,9 @@ pub struct TablePanel { impl TablePanel { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let editor = cx.new(|cx| { - InputState::new(window, cx) - .multi_line(true) - .submit_on_enter(true) - }); + let editor = cx.new(|cx| TextareaState::new(window, cx).submit_on_enter(true)); let note_editor = cx.new(|cx| { - InputState::new(window, cx) - .multi_line(true) + TextareaState::new(window, cx) .submit_on_enter(true) .placeholder("Note") }); @@ -182,12 +177,14 @@ impl TablePanel { // Notes are not `PROJECT_DATA`: `set_note` writes `__notes` itself, so this must not also // trip the table autosave (which would rewrite the whole dataset for a typed comment). let note_state = state.clone(); - let _note_sub = cx.subscribe( + let _note_sub = cx.subscribe_in( ¬e_editor, - move |_this, editor, event: &InputEvent, cx| { + window, + move |this, editor, event: &InputEvent, window, cx| { if !matches!(event, InputEvent::PressEnter { .. } | InputEvent::Blur) { return; } + let by_enter = matches!(event, InputEvent::PressEnter { .. }); let message = editor.read(cx).value(); note_state.update(cx, |state, cx| { let Some(location) = state.delegate_mut().note_edit.take() else { @@ -196,6 +193,9 @@ impl TablePanel { diagnostics::Diagnostics::set_note(location, message, cx); cx.notify(); }); + if by_enter { + this.focus_table(window, cx); + } }, ); @@ -1205,7 +1205,7 @@ pub(crate) fn paste_cells( /// suggestion list) needs a height it can't compute itself. Place the result with /// `deferred(float_at(anchor.origin, within, box))`. pub fn editor_box( - editor: &Entity, + editor: &Entity, anchor: Bounds, within: Bounds, window: &mut Window, @@ -1256,7 +1256,7 @@ pub fn editor_box( .rounded(cx.theme().radius) .shadow_lg() .child( - Input::new(editor) + Textarea::new(editor) .appearance(false) .h_full() .px(px(cell::CELL_PAD_X)) diff --git a/crates/workspace/src/dock_button.rs b/crates/workspace/src/dock_button.rs index 2a6c4cb1..a4359b6a 100644 --- a/crates/workspace/src/dock_button.rs +++ b/crates/workspace/src/dock_button.rs @@ -131,7 +131,7 @@ impl Render for DockToggleButton { // points at one panel, which can sit in an open dock and still be hidden behind a sibling // tab — lighting up for that aims the reader at something not on screen. let lit = self.dock.upgrade().is_some_and(|area| match self.toggles { - Toggles::Dock(placement) => area.read(cx).is_dock_open(placement, cx), + Toggles::Dock(placement) => area.read(cx).is_dock_open(placement), Toggles::Panel(meta) => PanelRegistry::visible(meta.name, &area, cx), }); diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index d8dbbe2e..5056e7da 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -22,7 +22,8 @@ use std::sync::Arc; use gpui::*; use gpui_component::dock::{ - DockArea, DockAreaState, DockEvent, DockItem, DockPlacement, PanelView, register_panel, + BasePanelView, DockArea, DockAreaState, DockEvent, DockLayout, DockPlacement, DockSkin, + panel_handle, register_panel, }; use settings::AppSettings; @@ -65,27 +66,18 @@ impl Global for BottomDockCrop {} /// Every caller gets the same grid redraw and persistence behavior because the state change owns /// its event. pub(crate) fn toggle_dock_immediately( - area: &DockArea, + area: &mut DockArea, placement: DockPlacement, window: &mut Window, cx: &mut Context, ) -> bool { - let dock = match placement { - DockPlacement::Left => area.left_dock(), - DockPlacement::Bottom => area.bottom_dock(), - DockPlacement::Right => area.right_dock(), - DockPlacement::Center => None, - } - .cloned(); - let Some(dock) = dock else { + // The centre is not a dock, and a dock we never built cannot be toggled. + if placement == DockPlacement::Center || !area.has_dock(placement) { return false; - }; - dock.update(cx, |dock, cx| { - let open = !dock.is_open(); - dock.panel().clone().set_collapsed(!open, window, cx); - dock.set_open(open, window, cx); - }); - cx.emit(DockEvent::LayoutChanged); + } + // Collapsing the panel tree and emitting `LayoutChanged` are both `toggle_dock`'s job now — + // it used to take driving the `Dock` entity by hand. + area.toggle_dock(placement, window, cx); true } @@ -101,21 +93,27 @@ impl Workspace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { // Register panel constructors so a persisted layout can be reconstructed by name. // The centre is the view host, not the grid — the grid is one view it mounts. - register_panel(cx, "ViewsPanel", |weak, _state, _info, window, cx| { - Box::new(cx.new(|cx| ViewsPanel::new(weak, window, cx))) + // `panel_handle` rather than a bare entity: base stores what it is given, and a skin can + // only recover presentation — our titles, the view switcher — from the wrapper. A bare + // entity still docks and persists, it just draws its `panel_name` where the title goes. + register_panel(cx, "ViewsPanel", |ctx, window, cx| { + let weak = ctx.dock_area(); + panel_handle(cx.new(|cx| ViewsPanel::new(weak, window, cx))) }); - register_panel(cx, "DetailsPanel", |_weak, _state, _info, window, cx| { - Box::new(cx.new(|cx| DetailsPanel::new(window, cx))) + register_panel(cx, "DetailsPanel", |_ctx, window, cx| { + panel_handle(cx.new(|cx| DetailsPanel::new(window, cx))) }); - register_panel(cx, "AgentPanel", |_weak, _state, _info, window, cx| { - Box::new(cx.new(|cx| AgentPanel::new(window, cx))) + register_panel(cx, "AgentPanel", |_ctx, window, cx| { + panel_handle(cx.new(|cx| AgentPanel::new(window, cx))) }); - register_panel(cx, "ProblemsPanel", |_weak, _state, _info, window, cx| { - Box::new(cx.new(|cx| ProblemsPanel::new(window, cx))) + register_panel(cx, "ProblemsPanel", |_ctx, window, cx| { + panel_handle(cx.new(|cx| ProblemsPanel::new(window, cx))) }); - let dock_area = - cx.new(|cx| DockArea::new("qrate-main", Some(DOCK_LAYOUT_VERSION), window, cx)); + // The appearance is a separate object in 0.6, installed as the area's renderer. The + // handle it hands back is how its settings are reached afterwards. + let (dock_area, skin) = + DockSkin::dock_area("qrate-main", Some(DOCK_LAYOUT_VERSION), window, cx); // Restore if saved, else build default; building first then loading would orphan a throwaway table. if !Self::restore_layout(&dock_area, window, cx) { @@ -123,69 +121,82 @@ impl Workspace { let centre = cx.new(|cx| ViewsPanel::new(weak.clone(), window, cx)); // Which dock each panel starts in is the panel's own declaration, so they're built // as a flat list and grouped by it rather than one `set_*_dock` call apiece. - let panels: [(&PanelMeta, Arc); 3] = [ + let panels: [(&PanelMeta, Arc); 3] = [ ( &DETAILS_META, - Arc::new(cx.new(|cx| DetailsPanel::new(window, cx))), + panel_handle(cx.new(|cx| DetailsPanel::new(window, cx))), ), ( &PROBLEMS_META, - Arc::new(cx.new(|cx| ProblemsPanel::new(window, cx))), + panel_handle(cx.new(|cx| ProblemsPanel::new(window, cx))), ), ( &AGENT_META, - Arc::new(cx.new(|cx| AgentPanel::new(window, cx))), + panel_handle(cx.new(|cx| AgentPanel::new(window, cx))), ), ]; dock_area.update(cx, |area, cx| { - // `tab`, not `panel`: `DockItem::panel` embeds the centre bare, with no title bar - // at all — and the title bar is where the view switcher lives. Restoring a saved - // layout always produces tabs anyway, so this only fixes the first launch, which - // would otherwise come up with no way to leave the grid. - area.set_center(DockItem::tab(centre, &weak, window, cx), window, cx); - for placement in [ - DockPlacement::Left, - DockPlacement::Right, - DockPlacement::Bottom, + // `tabs`, not a bare panel: a bare centre has no title bar at all — and the title + // bar is where the view switcher lives. Restoring a saved layout always produces + // tabs anyway, so this only fixes the first launch, which would otherwise come up + // with no way to leave the grid. + area.set_center( + DockLayout::tabs().panel_view(panel_handle(centre), cx), + window, + cx, + ); + for (placement, size) in [ + (DockPlacement::Left, px(300.)), + (DockPlacement::Right, px(340.)), + (DockPlacement::Bottom, px(200.)), ] { - let items: Vec> = panels + let mut layout = DockLayout::tabs(); + let mut filled = false; + for (_, view) in panels .iter() .filter(|(meta, _)| meta.default_placement == placement) - .map(|(_, view)| view.clone()) - .collect(); - if items.is_empty() { - continue; + { + layout = layout.panel_view(view.clone(), cx); + filled = true; } - let item = DockItem::tabs(items, &weak, window, cx); - match placement { - DockPlacement::Left => { - area.set_left_dock(item, Some(px(300.)), true, window, cx) - } - DockPlacement::Right => { - area.set_right_dock(item, Some(px(340.)), true, window, cx) - } - _ => area.set_bottom_dock(item, Some(px(200.)), true, window, cx), + if !filled { + continue; } + // Size is a property of the dock now, not an argument to filling it — and + // `set_dock` deliberately preserves the size of whatever it replaces. + area.set_dock(placement, layout, window, cx); + area.set_dock_size(placement, size, window, cx); } }); } // We drive dock open/close from our own title/status-bar buttons, so hide the built-in - // toggle arrows. Done in both paths — `load` doesn't carry this runtime-only flag. - dock_area.update(cx, |area, cx| area.set_toggle_button_visible(false, cx)); + // toggle arrows. A skin setting in 0.6, so it survives `load` without being reapplied. + skin.set_toggle_button_visible(false, cx); + // Saved layouts from older builds may contain arrangements qrate no longer permits. + PanelRegistry::enforce_edge_tabs(&dock_area, window, cx); // Whichever path ran above built the panels; this is what learns where they landed. PanelRegistry::sync(&dock_area, cx); - - // Every dock mutation emits `LayoutChanged`, which persists it and repaints the workspace. - let _layout_sub = cx.subscribe(&dock_area, |_this, area, event: &DockEvent, cx| { - if matches!(event, DockEvent::LayoutChanged) { - Self::persist_layout(&area, cx); - // Re-render the workspace so the bottom-strip crop tracks the dock's open state. - cx.notify(); - } - }); + // A new main window starts in its document, not on the outer action-routing shell. + PanelRegistry::focus_frontmost(DockPlacement::Center, &dock_area, window, cx); + + // Every dock mutation emits `LayoutChanged`, which normalizes user drags, refreshes the + // status-bar placement cache, persists the result and repaints the workspace. + let _layout_sub = cx.subscribe_in( + &dock_area, + window, + |_this, area, event: &DockEvent, window, cx| { + if matches!(event, DockEvent::LayoutChanged) { + PanelRegistry::enforce_edge_tabs(area, window, cx); + PanelRegistry::sync(area, cx); + Self::persist_layout(area, cx); + // Re-render the workspace so the bottom-strip crop tracks the dock's open state. + cx.notify(); + } + }, + ); let _viewer_sub = cx.observe_global::(|_this, cx| cx.notify()); @@ -208,9 +219,19 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { + let was_open = self.dock_area.read(cx).is_dock_open(placement); + let closing_focus = + PanelRegistry::placement_contains_focus(placement, &self.dock_area, window, cx); self.dock_area.update(cx, |area, cx| { toggle_dock_immediately(area, placement, window, cx); }); + if was_open { + if closing_focus { + PanelRegistry::focus_frontmost(DockPlacement::Center, &self.dock_area, window, cx); + } + } else { + PanelRegistry::focus_frontmost(placement, &self.dock_area, window, cx); + } } /// Serializes the dock state into the open project's `.qrate` (debounced, @@ -240,6 +261,7 @@ impl Workspace { if Self::restore_layout(&self.dock_area, window, cx) { // `load` rebuilt every panel from its saved name, so the old entries are orphans. PanelRegistry::sync(&self.dock_area, cx); + PanelRegistry::focus_frontmost(DockPlacement::Center, &self.dock_area, window, cx); } } @@ -351,10 +373,7 @@ fn prune(node: &mut serde_json::Value) { impl Render for Workspace { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { // Bottom dock closed: overhang 29px and clip to hide the library's residual strip; open: sit flush. - let bottom_open = self - .dock_area - .read(cx) - .is_dock_open(DockPlacement::Bottom, cx); + let bottom_open = self.dock_area.read(cx).is_dock_open(DockPlacement::Bottom); let overshoot = if bottom_open { px(0.) } else { @@ -401,10 +420,15 @@ mod tests { ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, actions, div, point, px, }; - use gpui_component::dock::{DockEvent, DockPlacement}; use gpui_component::menu::ContextMenuExt as _; + use gpui_component::{ + Placement, + dock::{DockEvent, DockPlacement, InsertTarget, PaneRef}, + }; - use crate::{BottomDockCrop, Workspace, prune, toggle_dock_immediately}; + use crate::{ + BarSide, BottomDockCrop, PanelRegistry, Workspace, bar_side, prune, toggle_dock_immediately, + }; actions!(workspace_context_menu_test, [CloseMenu]); @@ -513,6 +537,253 @@ mod tests { cx.update(|_, cx| assert_eq!(cx.global::().0, px(29.))); } + #[gpui::test] + fn opening_a_dock_focuses_it_and_closing_it_returns_to_the_document(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(settings::AppSettings::default()); + cx.set_global(settings::SettingsPersistence::default()); + }); + let (workspace, cx) = cx.add_window_view(Workspace::new); + let dock_area = cx.update(|_, cx| workspace.read(cx).dock_area.clone()); + cx.run_until_parked(); + + cx.update(|window, cx| { + PanelRegistry::focus_frontmost(DockPlacement::Center, &dock_area, window, cx); + workspace.update(cx, |workspace, cx| { + workspace.toggle_dock(DockPlacement::Left, window, cx) + }); + assert!( + PanelRegistry::placement_contains_focus( + DockPlacement::Center, + &dock_area, + window, + cx, + ), + "closing an unfocused dock leaves document focus alone" + ); + workspace.update(cx, |workspace, cx| { + workspace.toggle_dock(DockPlacement::Left, window, cx) + }); + assert!( + PanelRegistry::placement_contains_focus( + DockPlacement::Left, + &dock_area, + window, + cx, + ), + "opening a dock focuses its active panel" + ); + }); + + cx.dispatch_action(gpui_component::dock::ToggleZoom); + cx.run_until_parked(); + assert!( + cx.read(|cx| dock_area.read(cx).is_zoomed()), + "Shift+Esc zooms the dock opened by shortcut" + ); + cx.dispatch_action(gpui_component::dock::ToggleZoom); + + cx.update(|window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.toggle_dock(DockPlacement::Left, window, cx) + }); + assert!( + PanelRegistry::placement_contains_focus( + DockPlacement::Center, + &dock_area, + window, + cx, + ), + "closing the focused dock returns focus to the document" + ); + }); + } + + #[gpui::test] + fn dragged_tools_become_edge_tabs_and_move_their_status_item(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(settings::AppSettings::default()); + cx.set_global(settings::SettingsPersistence::default()); + }); + let (workspace, cx) = cx.add_window_view(Workspace::new); + let dock_area = cx.update(|_, cx| workspace.read(cx).dock_area.clone()); + cx.run_until_parked(); + + // Model a drop on the lower half of Details: gpui-kit first creates a split. + cx.update(|window, cx| { + let agent = PanelRegistry::entries(cx) + .iter() + .find(|entry| entry.meta.name == "AgentPanel") + .map(|entry| entry.view.panel_id(cx)) + .expect("Agent is docked"); + let details_group = dock_area + .read(cx) + .layout(DockPlacement::Left) + .expect("left dock") + .root() + .id(); + dock_area.update(cx, |area, cx| { + area.move_panel( + agent, + InsertTarget::Split { + node: details_group, + placement: Placement::Bottom, + size: None, + }, + window, + cx, + ); + }); + }); + cx.run_until_parked(); + + cx.update(|_, cx| { + let area = dock_area.read(cx); + assert!( + matches!( + area.layout(DockPlacement::Left) + .expect("left dock") + .root() + .kind(), + PaneRef::Tabs { .. } + ), + "the split drop is folded into the edge's tab group" + ); + assert_eq!( + PanelRegistry::placement("AgentPanel", cx), + Some(DockPlacement::Left) + ); + }); + + // A normal tab drop into another edge stays there and refreshes the status-bar cache. + cx.update(|window, cx| { + let agent = PanelRegistry::entries(cx) + .iter() + .find(|entry| entry.meta.name == "AgentPanel") + .map(|entry| entry.view.panel_id(cx)) + .expect("Agent is docked"); + let bottom_group = dock_area + .read(cx) + .layout(DockPlacement::Bottom) + .expect("bottom dock") + .root() + .id(); + dock_area.update(cx, |area, cx| { + area.move_panel( + agent, + InsertTarget::Tabs { + node: bottom_group, + ix: None, + activate: true, + }, + window, + cx, + ); + }); + }); + cx.run_until_parked(); + + cx.update(|_, cx| { + let placement = + PanelRegistry::placement("AgentPanel", cx).expect("Agent stays registered"); + assert_eq!(placement, DockPlacement::Bottom); + assert!(matches!(bar_side(placement), BarSide::Centre)); + }); + + // The document centre is not a tool dock; a drop there returns to the previous edge. + cx.update(|window, cx| { + let agent = PanelRegistry::entries(cx) + .iter() + .find(|entry| entry.meta.name == "AgentPanel") + .map(|entry| entry.view.panel_id(cx)) + .expect("Agent is docked"); + let centre_group = dock_area + .read(cx) + .layout(DockPlacement::Center) + .expect("centre layout") + .root() + .id(); + dock_area.update(cx, |area, cx| { + area.move_panel( + agent, + InsertTarget::Split { + node: centre_group, + placement: Placement::Right, + size: None, + }, + window, + cx, + ); + }); + }); + cx.run_until_parked(); + + cx.update(|_, cx| { + assert_eq!( + PanelRegistry::placement("AgentPanel", cx), + Some(DockPlacement::Bottom) + ); + let area = dock_area.read(cx); + let centre_names = area + .layout(DockPlacement::Center) + .expect("centre layout") + .panels() + .filter_map(|id| area.panel(id).map(|panel| panel.panel_name(cx))) + .collect::>(); + assert_eq!(centre_names, ["ViewsPanel"]); + }); + } + + /// Shift+Esc, end to end on the real workspace. `app` binds the key to `dock::ToggleZoom` + /// globally and handles it nowhere: the skin's frame carries the only handler, so the action + /// has to travel from the focused panel up to the group holding it. + /// + /// Both halves of the zoom gate are load-bearing here. Every panel in this crate answered + /// `zoomable() -> None` before the 0.6 port, and a panel that still refuses is skipped in + /// silence — no error, no zoom — which is exactly what this asserts against. + #[gpui::test] + fn shift_escape_zooms_the_focused_panel(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(settings::AppSettings::default()); + cx.set_global(settings::SettingsPersistence::default()); + }); + let (workspace, cx) = cx.add_window_view(Workspace::new); + let dock_area = cx.update(|_, cx| workspace.read(cx).dock_area.clone()); + cx.run_until_parked(); + + // Focus a docked panel, so the action starts from inside a tab group rather than at the + // window root, where nothing would answer it. + cx.update(|window, cx| { + let panel = crate::PanelRegistry::entries(cx) + .iter() + .find(|entry| entry.meta.name == "DetailsPanel") + .map(|entry| entry.view.clone()) + .expect("the default layout docks Details"); + panel.focus_handle(cx).focus(window, cx); + }); + cx.run_until_parked(); + + assert!( + !cx.read(|cx| dock_area.read(cx).is_zoomed()), + "nothing is zoomed to begin with" + ); + + cx.dispatch_action(gpui_component::dock::ToggleZoom); + cx.run_until_parked(); + assert!( + cx.read(|cx| dock_area.read(cx).is_zoomed()), + "the focused panel's group fills the window" + ); + + // Zooming out is never refused, so the same key always gets the docks back. + cx.dispatch_action(gpui_component::dock::ToggleZoom); + cx.run_until_parked(); + assert!(!cx.read(|cx| dock_area.read(cx).is_zoomed())); + } + /// The real thing, lifted out of a project whose left dock came back with an "Unnamed" tab /// beside Details on every launch: an empty `TabPanel` sitting in a tab slot, and an /// `active_index` of 1 that points past the end once it is gone. diff --git a/crates/workspace/src/panel_registry.rs b/crates/workspace/src/panel_registry.rs index b3f811ac..f808ab86 100644 --- a/crates/workspace/src/panel_registry.rs +++ b/crates/workspace/src/panel_registry.rs @@ -1,17 +1,22 @@ //! Which dock each panel is in, and what it puts in the status bar. //! -//! gpui_component owns the docking; what it has no notion of is a panel that *moves*, or a bar -//! button that follows one. Both need an answer to "where is DetailsPanel right now?", and this -//! is the only place that has one: `DockItem::remove_panel` takes `&self` and never pops from -//! the `DockItem::Tabs` snapshot it removes from, so the library's own view of a dock's contents -//! is wrong from the first move onwards. Never re-derive a placement from `DockItem`. +//! The dock owns the arrangement; what it has no notion of is a bar button that follows a panel +//! around. That needs an answer to "where is DetailsPanel right now?", and this is where it +//! lives — alongside each panel's icon, label and bar side. +//! +//! Unlike the `DockItem` model this replaced, the placement here is a *cache* of something the +//! dock already knows: a `PaneTree` is the single source of truth for what a dock holds, and +//! [`PanelRegistry::sync`] re-reads it rather than tracking moves by hand. -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use gpui::*; use gpui_component::{ IconName, - dock::{DockArea, DockEvent, DockItem, DockPlacement, PanelView}, + dock::{ + BasePanelView, DockArea, DockEvent, DockLayout, DockPlacement, InsertTarget, NodeId, + PaneRef, + }, }; use crate::Workspace; @@ -62,7 +67,7 @@ pub static PANELS: [&PanelMeta; 3] = [&DETAILS_META, &PROBLEMS_META, &AGENT_META pub struct PanelEntry { pub meta: &'static PanelMeta, - pub view: Arc, + pub view: Arc, pub placement: DockPlacement, } @@ -85,38 +90,61 @@ impl PanelRegistry { .map(|entry| entry.placement) } + pub fn focus_frontmost( + placement: DockPlacement, + dock_area: &Entity, + window: &mut Window, + cx: &mut App, + ) -> bool { + let panel = { + let area = dock_area.read(cx); + frontmost_panel(area, placement) + }; + if let Some(panel) = panel { + panel.focus_handle(cx).focus(window, cx); + true + } else { + false + } + } + + pub fn placement_contains_focus( + placement: DockPlacement, + dock_area: &Entity, + window: &Window, + cx: &App, + ) -> bool { + let area = dock_area.read(cx); + area.layout(placement).is_some_and(|tree| { + tree.panels().any(|id| { + area.panel(id) + .is_some_and(|panel| panel.focus_handle(cx).contains_focused(window, cx)) + }) + }) + } + /// Whether this panel is actually on screen — its dock is open *and* it is the tab in front. /// "Dock is open" alone lights a button up for a panel hidden behind its neighbour, which /// points the reader at something they cannot see. /// - /// Read off the live `TabPanel`, not the `DockItem` membership snapshot this module warns - /// about: which tab is active is state the library keeps current. pub fn visible(name: &str, dock_area: &Entity, cx: &App) -> bool { let Some(placement) = Self::placement(name, cx) else { return false; }; let area = dock_area.read(cx); - if !area.is_dock_open(placement, cx) { + if !area.is_dock_open(placement) { return false; } - let dock = match placement { - DockPlacement::Left => area.left_dock(), - DockPlacement::Right => area.right_dock(), - _ => area.bottom_dock(), - }; let mut front = Vec::new(); - if let Some(dock) = dock { - frontmost(dock.read(cx).panel(), cx, &mut front); - } + frontmost(area, placement, cx, &mut front); front.contains(&name) } /// Rebuild from what the dock area actually holds. Called after every layout construction: /// `DockArea::load` builds fresh panel entities from the saved names, so any entry made - /// before it would point at an orphan. Only correct immediately after a load or a default - /// build, while the `DockItem` snapshots still match reality. + /// before it would point at an orphan. pub fn sync(dock_area: &Entity, cx: &mut App) { - let mut found: Vec<(DockPlacement, Arc)> = Vec::new(); + let mut found: Vec<(DockPlacement, Arc)> = Vec::new(); { let area = dock_area.read(cx); for placement in [ @@ -124,14 +152,7 @@ impl PanelRegistry { DockPlacement::Right, DockPlacement::Bottom, ] { - let dock = match placement { - DockPlacement::Left => area.left_dock(), - DockPlacement::Right => area.right_dock(), - _ => area.bottom_dock(), - }; - if let Some(dock) = dock { - collect(dock.read(cx).panel(), placement, &mut found); - } + collect(area, placement, &mut found); } } @@ -152,6 +173,86 @@ impl PanelRegistry { cx.set_global(Self(entries)); } + /// Keep qrate's tool panels in one tab group per edge dock. + /// + /// gpui-kit exposes only a whole-layout drag lock. qrate keeps dragging enabled, then folds a + /// split drop back into tabs and returns a centre drop to the panel's previous edge. + pub fn enforce_edge_tabs(dock_area: &Entity, window: &mut Window, cx: &mut App) { + let previous: HashMap<&str, DockPlacement> = Self::entries(cx) + .iter() + .map(|entry| (entry.meta.name, entry.placement)) + .collect(); + let centre_moves = { + let area = dock_area.read(cx); + area.layout(DockPlacement::Center) + .into_iter() + .flat_map(|tree| tree.panels()) + .filter_map(|id| { + let name = area.panel(id)?.panel_name(cx); + let meta = PANELS.iter().find(|meta| meta.name == name)?; + let destination = previous + .get(name) + .copied() + .filter(|placement| *placement != DockPlacement::Center) + .unwrap_or(meta.default_placement); + Some((id, destination)) + }) + .collect::>() + }; + + dock_area.update(cx, |area, cx| { + for (panel, destination) in centre_moves { + if tabs_node(area, destination).is_none() { + area.set_dock(destination, DockLayout::tabs(), window, cx); + } + if let Some(node) = tabs_node(area, destination) { + area.move_panel( + panel, + InsertTarget::Tabs { + node, + ix: None, + activate: true, + }, + window, + cx, + ); + } + } + + for placement in [ + DockPlacement::Left, + DockPlacement::Right, + DockPlacement::Bottom, + ] { + let Some(tree) = area.layout(placement) else { + continue; + }; + if matches!(tree.root().kind(), PaneRef::Tabs { .. }) { + continue; + } + let Some(target) = tabs_node(area, placement) else { + continue; + }; + let panels = tree + .panels() + .filter(|panel| tree.find_panel_node(*panel) != Some(target)) + .collect::>(); + for panel in panels { + area.move_panel( + panel, + InsertTarget::Tabs { + node: target, + ix: None, + activate: true, + }, + window, + cx, + ); + } + } + }); + } + /// Show a panel, or put it away if it is already the one in front. /// /// "In front", not "its dock is open": where two panels share a dock, clicking the hidden @@ -161,12 +262,18 @@ impl PanelRegistry { let Some(placement) = Self::placement(name, cx) else { return; }; - let toggled = if Self::visible(name, dock_area, cx) { + let was_visible = Self::visible(name, dock_area, cx); + let closing_focus = was_visible + && Self::entries(cx) + .iter() + .find(|entry| entry.meta.name == name) + .is_some_and(|entry| entry.view.focus_handle(cx).contains_focused(window, cx)); + let toggled = if was_visible { dock_area.update(cx, |area, cx| { crate::toggle_dock_immediately(area, placement, window, cx) }) } else { - let toggled = if !dock_area.read(cx).is_dock_open(placement, cx) { + let toggled = if !dock_area.read(cx).is_dock_open(placement) { dock_area.update(cx, |area, cx| { crate::toggle_dock_immediately(area, placement, window, cx) }) @@ -180,14 +287,24 @@ impl PanelRegistry { if !toggled { dock_area.update(cx, |_, cx| cx.emit(DockEvent::LayoutChanged)); } + if was_visible { + if closing_focus { + Self::focus_frontmost(DockPlacement::Center, dock_area, window, cx); + } + } else if let Some(entry) = Self::entries(cx) + .iter() + .find(|entry| entry.meta.name == name) + { + entry.view.focus_handle(cx).focus(window, cx); + } } /// Make `name` the tab in front of its dock. /// - /// The library exposes no "activate this tab": `TabPanel::set_active_ix` is private and - /// `add_panel` returns early for a panel already in the strip. `DockItem::active_index` is - /// what is public, and it wants an index into the *live* tab order — which `dump` reports and - /// the `DockItem::Tabs` snapshot beside it does not, for the reason this module opens with. + /// A move onto the panel's own group at its own index: the tree edit is a no-op, so the tab + /// order is untouched and `activate` is the whole point. There is still no public "select + /// this tab" on `DockArea` — `TabGroup::select_tab` needs the group entity, which the area + /// keeps to itself. fn bring_to_front( name: &str, placement: DockPlacement, @@ -195,34 +312,47 @@ impl PanelRegistry { window: &mut Window, cx: &mut App, ) { - let area = dock_area.read(cx); - let dock = match placement { - DockPlacement::Left => area.left_dock(), - DockPlacement::Right => area.right_dock(), - _ => area.bottom_dock(), - }; - let Some(dock) = dock.cloned() else { - return; - }; - let item = dock.read(cx).panel().clone(); - if !matches!(item, DockItem::Tabs { .. }) { - return; - } - let Some(index) = item - .view() - .dump(cx) - .children - .iter() - .position(|child| child.panel_name == name) - else { + let Some((node, ix, panel)) = ({ + let area = dock_area.read(cx); + area.layout(placement).and_then(|tree| { + let mut found = None; + tree.root().walk(&mut |node| { + let PaneRef::Tabs { panels, .. } = node.kind() else { + return; + }; + if found.is_some() { + return; + } + found = panels + .iter() + .position(|id| { + area.panel(*id) + .is_some_and(|panel| panel.panel_name(cx) == name) + }) + .map(|ix| (node.id(), ix, panels[ix])); + }); + found + }) + }) else { return; }; - let item = item.active_index(index, cx); - dock.update(cx, |dock, cx| dock.set_panel(item, window, cx)); + dock_area.update(cx, |area, cx| { + area.move_panel( + panel, + InsertTarget::Tabs { + node, + ix: Some(ix), + activate: true, + }, + window, + cx, + ); + }); } - /// Move a panel to another dock, keeping the panel entity — and so its state — alive. The - /// library has no `move_panel`; `remove_panel` + `add_panel` hand the same `Arc` across. + /// Move a panel to another dock, keeping the panel entity — and so its state — alive. + /// `DockArea::move_panel` is built for exactly this: the panel never leaves the dock, so it + /// is never told it was removed, and its active state carries across. pub fn move_panel( name: &str, to: DockPlacement, @@ -241,9 +371,27 @@ impl PanelRegistry { return; } + let panel = view.panel_id(cx); dock_area.update(cx, |area, cx| { - area.remove_panel(view.clone(), from, window, cx); - area.add_panel(view, to, None, window, cx); + // A dock that was never built, or that a previous move emptied, has no group to + // merge into. Give it one first: `move_panel` wants a node to aim at, and unlike + // `add_panel_view` it detaches the panel from wherever it currently sits. + if tabs_node(area, to).is_none() { + area.set_dock(to, DockLayout::tabs(), window, cx); + } + let Some(node) = tabs_node(area, to) else { + return; + }; + area.move_panel( + panel, + InsertTarget::Tabs { + node, + ix: None, + activate: true, + }, + window, + cx, + ); }); cx.update_global::(|this, _| { if let Some(entry) = this.0.iter_mut().find(|entry| entry.meta.name == name) { @@ -257,10 +405,10 @@ impl PanelRegistry { dock_area.update(cx, |area, cx| { // An emptied dock keeps rendering as a bare tab strip, and a closed target dock would // swallow the panel we just moved into it. - if source_empty && area.is_dock_open(from, cx) { + if source_empty && area.is_dock_open(from) { crate::toggle_dock_immediately(area, from, window, cx); } - if !area.is_dock_open(to, cx) { + if !area.is_dock_open(to) { crate::toggle_dock_immediately(area, to, window, cx); } }); @@ -269,42 +417,67 @@ impl PanelRegistry { } /// The panels a dock is actually showing — one per tab group, since a split shows several at once. -fn frontmost<'a>(item: &DockItem, cx: &'a App, out: &mut Vec<&'a str>) { - match item { - DockItem::Tabs { view, .. } => out.extend( - view.read(cx) - .active_panel(cx) +fn frontmost(area: &DockArea, placement: DockPlacement, cx: &App, out: &mut Vec<&'static str>) { + let Some(tree) = area.layout(placement) else { + return; + }; + tree.root().walk(&mut |node| { + let PaneRef::Tabs { panels, active_ix } = node.kind() else { + return; + }; + out.extend( + panels + .get(active_ix) + .and_then(|id| area.panel(*id)) .map(|panel| panel.panel_name(cx)), - ), - DockItem::Panel { view, .. } => out.push(view.panel_name(cx)), - DockItem::Split { items, .. } => { - for item in items { - frontmost(item, cx, out); - } + ); + }); +} + +fn frontmost_panel(area: &DockArea, placement: DockPlacement) -> Option> { + let tree = area.layout(placement)?; + let mut found = None; + tree.root().walk(&mut |node| { + if found.is_some() { + return; } - DockItem::Tiles { .. } => {} - } + let PaneRef::Tabs { panels, active_ix } = node.kind() else { + return; + }; + found = panels + .get(active_ix) + .and_then(|id| area.panel(*id)) + .cloned(); + }); + found } -/// Flatten a dock's item tree into the panels it holds. Recursive because a restored layout can -/// come back as a `Split` even though nothing here builds one. +/// The panels a dock holds, in tree order. A restored layout can come back as a split even +/// though nothing here builds one, so this asks the tree rather than assuming one group. fn collect( - item: &DockItem, + area: &DockArea, placement: DockPlacement, - out: &mut Vec<(DockPlacement, Arc)>, + out: &mut Vec<(DockPlacement, Arc)>, ) { - match item { - DockItem::Tabs { items, .. } => { - out.extend(items.iter().map(|view| (placement, view.clone()))) - } - DockItem::Panel { view, .. } => out.push((placement, view.clone())), - DockItem::Split { items, .. } => { - for item in items { - collect(item, placement, out); - } + let Some(tree) = area.layout(placement) else { + return; + }; + out.extend( + tree.panels() + .filter_map(|id| area.panel(id).map(|panel| (placement, panel.clone()))), + ); +} + +/// The first tab group in a dock, which is where a panel moved there belongs. +fn tabs_node(area: &DockArea, placement: DockPlacement) -> Option { + let tree = area.layout(placement)?; + let mut found = None; + tree.root().walk(&mut |node| { + if found.is_none() && matches!(node.kind(), PaneRef::Tabs { .. }) { + found = Some(node.id()); } - DockItem::Tiles { .. } => {} - } + }); + found } #[cfg(test)] @@ -313,15 +486,14 @@ mod tests { use std::sync::Arc; use gpui::{AppContext as _, TestAppContext}; - use gpui_component::dock::{DockArea, DockItem, DockPlacement, PanelView}; + use gpui_component::dock::{BasePanelView, DockArea, DockLayout, DockPlacement, panel_handle}; use crate::panel_registry::PanelRegistry; use crate::panels::{AgentPanel, DetailsPanel}; use diagnostics::ProblemsPanel; - /// The registry is the only record of where a panel is: the library's `DockItem` snapshot - /// goes stale the moment anything moves. So this covers both halves — that `sync` reads the - /// real arrangement, and that a move updates it and takes the emptied dock down with it. + /// Covers both halves of the registry — that `sync` reads the real arrangement out of the + /// pane tree, and that a move updates it and takes the emptied dock down with it. #[gpui::test] fn a_move_updates_the_placement_and_closes_the_dock_it_emptied(cx: &mut TestAppContext) { cx.update(|cx| { @@ -335,21 +507,24 @@ mod tests { cx.update(|window, cx| { dock_area.update(cx, |area, cx| { - let weak = cx.entity().downgrade(); - let details: Arc = - Arc::new(cx.new(|cx| DetailsPanel::new(window, cx))); - let agent: Arc = Arc::new(cx.new(|cx| AgentPanel::new(window, cx))); - let problems: Arc = - Arc::new(cx.new(|cx| ProblemsPanel::new(window, cx))); - let tabs = |panel, cx: &mut gpui::App, window: &mut gpui::Window| { - DockItem::tabs(vec![panel], &weak, window, cx) - }; - let left = tabs(details, cx, window); - area.set_left_dock(left, None, true, window, cx); - let right = tabs(agent, cx, window); - area.set_right_dock(right, None, true, window, cx); - let bottom = tabs(problems, cx, window); - area.set_bottom_dock(bottom, None, true, window, cx); + let details: Arc = + panel_handle(cx.new(|cx| DetailsPanel::new(window, cx))); + let agent: Arc = + panel_handle(cx.new(|cx| AgentPanel::new(window, cx))); + let problems: Arc = + panel_handle(cx.new(|cx| ProblemsPanel::new(window, cx))); + for (placement, panel) in [ + (DockPlacement::Left, details), + (DockPlacement::Right, agent), + (DockPlacement::Bottom, problems), + ] { + area.set_dock( + placement, + DockLayout::tabs().panel_view(panel, cx), + window, + cx, + ); + } }); PanelRegistry::sync(&dock_area, cx); }); @@ -375,8 +550,8 @@ mod tests { Some(DockPlacement::Right) ); let area = dock_area.read(cx); - assert!(!area.is_dock_open(DockPlacement::Left, cx), "left emptied"); - assert!(area.is_dock_open(DockPlacement::Right, cx), "right open"); + assert!(!area.is_dock_open(DockPlacement::Left), "left emptied"); + assert!(area.is_dock_open(DockPlacement::Right), "right open"); }); } } diff --git a/crates/workspace/src/panels/agent.rs b/crates/workspace/src/panels/agent.rs index e365a131..5532f40b 100644 --- a/crates/workspace/src/panels/agent.rs +++ b/crates/workspace/src/panels/agent.rs @@ -5,7 +5,7 @@ use std::time::{Duration, Instant}; use gpui::prelude::FluentBuilder as _; use gpui::*; -use gpui_component::dock::{DockPlacement, Panel, PanelControl, PanelEvent}; +use gpui_component::dock::{BasePanel, DockPlacement, Panel, PanelEvent}; use gpui_component::menu::{ContextMenuExt as _, PopupMenuItem}; use gpui_component::scroll::ScrollableElement as _; use gpui_component::tab::{Tab, TabBar}; @@ -364,11 +364,22 @@ impl Focusable for AgentPanel { impl EventEmitter for AgentPanel {} -impl Panel for AgentPanel { +impl BasePanel for AgentPanel { fn panel_name(&self) -> &'static str { "AgentPanel" } + // The library always renders the ⋯ menu button; this just empties it of Close. + fn closable(&self, _cx: &App) -> bool { + false + } + + fn zoomable(&self, _cx: &App) -> bool { + true + } +} + +impl Panel for AgentPanel { fn title(&mut self, _w: &mut Window, _cx: &mut Context) -> impl IntoElement { SharedString::from("Agent") } @@ -451,15 +462,6 @@ impl Panel for AgentPanel { ), ) } - - // The library always renders the ⋯ menu button; these just empty it of Close + Zoom. - fn closable(&self, _cx: &App) -> bool { - false - } - - fn zoomable(&self, _cx: &App) -> Option { - None - } } impl Render for AgentPanel { diff --git a/crates/workspace/src/panels/details.rs b/crates/workspace/src/panels/details.rs index 52bfda1e..a0e07c78 100644 --- a/crates/workspace/src/panels/details.rs +++ b/crates/workspace/src/panels/details.rs @@ -8,9 +8,9 @@ use gpui::*; use gpui_component::{ ActiveTheme, IconName, Sizable, StyledExt as _, button::{Button, ButtonVariants}, - dock::{DockPlacement, Panel, PanelControl, PanelEvent}, + dock::{BasePanel, DockPlacement, Panel, PanelEvent}, h_flex, - input::{Escape, InputEvent, InputState}, + input::{Escape, InputEvent, TextareaState}, resizable::{resizable_panel, v_resizable}, scroll::ScrollableElement, table::TableState, @@ -64,7 +64,7 @@ pub struct DetailsPanel { _crop_sub: Subscription, /// The field editor, shared across whichever field is open — the same one-per-panel /// arrangement the grid uses for its cell editor. - editor: Entity, + editor: Entity, /// `(source_rows, data_col)` of the field being edited, in the grid's own coordinates so a /// filter change between opening and committing can't redirect the write. Several rows when /// the field belongs to a bundle: one edit box writing the same value down the selection. @@ -104,16 +104,21 @@ impl DetailsPanel { // `multi_line`, like the grid's cell editor: `editor_box` sizes the box to the value's // *wrapped* height, and a single-line input runs the text off the right edge instead. // `submit_on_enter` keeps Enter committing the field (Shift+Enter inserts a newline). - let editor = cx.new(|cx| { - InputState::new(window, cx) - .multi_line(true) - .submit_on_enter(true) - }); - let _editor_sub = cx.subscribe(&editor, |this, _input, event: &InputEvent, cx| { - if matches!(event, InputEvent::PressEnter { .. } | InputEvent::Blur) { + let editor = cx.new(|cx| TextareaState::new(window, cx).submit_on_enter(true)); + let _editor_sub = cx.subscribe_in( + &editor, + window, + |this, _input, event: &InputEvent, window, cx| { + if !matches!(event, InputEvent::PressEnter { .. } | InputEvent::Blur) { + return; + } + let by_enter = matches!(event, InputEvent::PressEnter { .. }); this.commit(cx); - } - }); + if by_enter { + this.focus_handle.focus(window, cx); + } + }, + ); let mut this = Self { focus_handle: cx.focus_handle(), state: None, @@ -462,22 +467,24 @@ impl Focusable for DetailsPanel { impl EventEmitter for DetailsPanel {} -impl Panel for DetailsPanel { +impl BasePanel for DetailsPanel { fn panel_name(&self) -> &'static str { "DetailsPanel" } - fn title(&mut self, _w: &mut Window, _cx: &mut Context) -> impl IntoElement { - SharedString::from("Details") - } - - // The library always renders the ⋯ menu button; these just empty it of Close + Zoom. + // The library always renders the ⋯ menu button; this just empties it of Close. fn closable(&self, _cx: &App) -> bool { false } - fn zoomable(&self, _cx: &App) -> Option { - None + fn zoomable(&self, _cx: &App) -> bool { + true + } +} + +impl Panel for DetailsPanel { + fn title(&mut self, _w: &mut Window, _cx: &mut Context) -> impl IntoElement { + SharedString::from("Details") } } @@ -551,10 +558,11 @@ fn render_image_frame( .ghost() .small() .tooltip("View fullscreen") - .on_click(move |_, _, cx| { + .on_click(move |_, window, cx| { crate::open_viewer( path.clone(), crate::ViewerScope::Workspace, + window, cx, ) }), diff --git a/crates/workspace/src/viewer/mod.rs b/crates/workspace/src/viewer/mod.rs index d25465da..c4ce9aee 100644 --- a/crates/workspace/src/viewer/mod.rs +++ b/crates/workspace/src/viewer/mod.rs @@ -61,25 +61,32 @@ pub enum Scope { Centre, } -/// The currently-open viewer, if any. Both mount slots observe this. +/// The currently-open viewer and the focus to restore. Both mount slots observe this. #[derive(Default)] -pub struct ActiveViewer(pub Option>); +pub struct ActiveViewer { + viewer: Option>, + return_focus: Option, +} impl Global for ActiveViewer {} /// The open viewer, if it belongs in `scope`. `None` tells that slot to mount nothing — which is /// how one global feeds two slots without ever painting itself twice. pub fn viewer_in(scope: Scope, cx: &App) -> Option> { - let viewer = cx.try_global::()?.0.clone()?; + let viewer = cx.try_global::()?.viewer.clone()?; (viewer.read(cx).scope == scope).then_some(viewer) } /// Opens `path` in the shared viewer overlay, replacing any viewer already open. -pub fn open_viewer(path: PathBuf, scope: Scope, cx: &mut App) { +pub fn open_viewer(path: PathBuf, scope: Scope, window: &mut Window, cx: &mut App) { let document = preview::has_text(&path); let video = preview::has_video(&path); let details = preview::describe(&path); let probe_path = path.clone(); + let return_focus = cx + .try_global::() + .and_then(|active| active.return_focus.clone()) + .or_else(|| window.focused(cx)); let viewer = cx.new(|cx| Viewer { transport: Transport::new(path.clone(), cx), path, @@ -118,13 +125,22 @@ pub fn open_viewer(path: PathBuf, scope: Scope, cx: &mut App) { }); }) .detach(); - cx.set_global(ActiveViewer(Some(viewer))); + cx.set_global(ActiveViewer { + viewer: Some(viewer), + return_focus, + }); } -pub fn close_viewer(cx: &mut App) { +pub fn close_viewer(window: &mut Window, cx: &mut App) { // Without this the recording plays on over an empty screen, with nothing left to stop it. preview::playback::stop(cx); - cx.set_global(ActiveViewer(None)); + let return_focus = cx + .try_global::() + .and_then(|active| active.return_focus.clone()); + cx.set_global(ActiveViewer::default()); + if let Some(return_focus) = return_focus { + return_focus.focus(window, cx); + } } pub struct Viewer { @@ -387,7 +403,7 @@ impl Render for Viewer { window.focus(&this.focus_handle, cx); cx.notify(); } else { - close_viewer(cx); + close_viewer(window, cx); } })) // Fills whichever overlay slot mounted it; `absolute` so it stacks over that slot's @@ -751,7 +767,7 @@ impl Render for Viewer { .ghost() .small() .tooltip("Close (Esc)") - .on_click(cx.listener(|_, _, _, cx| close_viewer(cx))), + .on_click(cx.listener(|_, _, window, cx| close_viewer(window, cx))), ), ) } @@ -760,42 +776,101 @@ impl Render for Viewer { #[cfg(test)] mod tests { // Never `use super::*` here — the parent's `use gpui::*` would shadow `#[test]`. - use gpui::TestAppContext; + use gpui::{ + Context, FocusHandle, InteractiveElement as _, IntoElement, Render, TestAppContext, + VisualTestContext, Window, div, + }; use crate::viewer::{Scope, close_viewer, open_viewer, viewer_in}; + struct FocusProbe(FocusHandle); + + impl Render for FocusProbe { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().track_focus(&self.0) + } + } + + fn with_window(cx: &mut TestAppContext) -> &mut VisualTestContext { + let (_, cx) = cx.add_window_view(|_, cx| FocusProbe(cx.focus_handle())); + cx + } + /// One global feeds two mount slots — the workspace overlay and the centre panel. Exactly one /// may claim it: drop the scope check and the viewer paints twice, once in each slot, with two /// sets of live controls over each other. #[gpui::test] fn only_the_slot_it_was_opened_for_mounts_the_viewer(cx: &mut TestAppContext) { + let cx = with_window(cx); let path = std::path::PathBuf::from("/nonexistent/qrate-scope-test.jpg"); - cx.update(|cx| { - open_viewer(path.clone(), Scope::Centre, cx); + cx.update(|window, cx| { + open_viewer(path.clone(), Scope::Centre, window, cx); assert!(viewer_in(Scope::Centre, cx).is_some()); assert!(viewer_in(Scope::Workspace, cx).is_none()); // Opening in the other scope replaces rather than stacks. - open_viewer(path, Scope::Workspace, cx); + open_viewer(path, Scope::Workspace, window, cx); assert!(viewer_in(Scope::Workspace, cx).is_some()); assert!(viewer_in(Scope::Centre, cx).is_none()); - close_viewer(cx); + close_viewer(window, cx); assert!(viewer_in(Scope::Workspace, cx).is_none()); assert!(viewer_in(Scope::Centre, cx).is_none()); }); } + #[gpui::test] + fn closing_the_viewer_restores_its_callers_focus(cx: &mut TestAppContext) { + let cx = with_window(cx); + cx.update(|window, cx| { + let caller = cx.focus_handle(); + caller.focus(window, cx); + open_viewer( + "/nonexistent/qrate-focus-test.jpg".into(), + Scope::Workspace, + window, + cx, + ); + let viewer = viewer_in(Scope::Workspace, cx).expect("just opened"); + let viewer_focus = viewer.read(cx).focus_handle.clone(); + viewer_focus.focus(window, cx); + assert!(viewer.read(cx).focus_handle.is_focused(window)); + + close_viewer(window, cx); + + assert!(caller.is_focused(window)); + }); + } + + #[gpui::test] + fn viewer_can_close_from_its_own_listener(cx: &mut TestAppContext) { + let cx = with_window(cx); + cx.update(|window, cx| { + open_viewer( + "/nonexistent/qrate-reentrant-close-test.jpg".into(), + Scope::Workspace, + window, + cx, + ); + let viewer = viewer_in(Scope::Workspace, cx).expect("just opened"); + + viewer.update(cx, |_, cx| close_viewer(window, cx)); + + assert!(viewer_in(Scope::Workspace, cx).is_none()); + }); + } + #[gpui::test] fn file_details_are_kept_without_restatting_during_render(cx: &mut TestAppContext) { + let cx = with_window(cx); let path = std::env::temp_dir().join("qrate-viewer-details.jpg"); std::fs::write(&path, b"abc").unwrap(); - cx.update(|cx| { - open_viewer(path.clone(), Scope::Workspace, cx); + cx.update(|window, cx| { + open_viewer(path.clone(), Scope::Workspace, window, cx); std::fs::remove_file(&path).unwrap(); let viewer = viewer_in(Scope::Workspace, cx).expect("just opened"); assert!(viewer.read(cx).details.is_some()); - close_viewer(cx); + close_viewer(window, cx); }); } @@ -803,9 +878,10 @@ mod tests { /// an underflow on page zero would panic on a `usize` subtraction. #[gpui::test] fn paging_stops_at_both_ends_and_resets_the_view(cx: &mut TestAppContext) { + let cx = with_window(cx); let path = std::path::PathBuf::from("/nonexistent/qrate-paging-test.pdf"); - cx.update(|cx| { - open_viewer(path, Scope::Workspace, cx); + cx.update(|window, cx| { + open_viewer(path, Scope::Workspace, window, cx); let viewer = viewer_in(Scope::Workspace, cx).expect("just opened"); viewer.update(cx, |viewer, _| { @@ -833,16 +909,17 @@ mod tests { assert_eq!(viewer.page, 2, "cannot go past the last page"); }); - close_viewer(cx); + close_viewer(window, cx); }); } /// A photo is a one-page document, so the controls stay hidden and the arrow keys do nothing. #[gpui::test] fn a_single_page_file_never_moves(cx: &mut TestAppContext) { + let cx = with_window(cx); let path = std::path::PathBuf::from("/nonexistent/qrate-single-page.jpg"); - cx.update(|cx| { - open_viewer(path, Scope::Centre, cx); + cx.update(|window, cx| { + open_viewer(path, Scope::Centre, window, cx); let viewer = viewer_in(Scope::Centre, cx).expect("just opened"); viewer.update(cx, |viewer, _| { assert_eq!( @@ -854,7 +931,7 @@ mod tests { viewer.turn_page(-1); assert_eq!(viewer.page, 0); }); - close_viewer(cx); + close_viewer(window, cx); }); } @@ -862,19 +939,20 @@ mod tests { /// a document" — a silent tape and a tape with no readable header both still need playing. #[gpui::test] fn only_a_recording_gets_a_transport(cx: &mut TestAppContext) { - cx.update(|cx| { - open_viewer("/nonexistent/take.wav".into(), Scope::Workspace, cx); + let cx = with_window(cx); + cx.update(|window, cx| { + open_viewer("/nonexistent/take.wav".into(), Scope::Workspace, window, cx); let recording = viewer_in(Scope::Workspace, cx).expect("just opened"); recording.update(cx, |viewer, _| { assert!(viewer.transport.is_some(), "a WAV is a recording"); assert!(!viewer.document); }); - open_viewer("/nonexistent/scan.pdf".into(), Scope::Workspace, cx); + open_viewer("/nonexistent/scan.pdf".into(), Scope::Workspace, window, cx); let document = viewer_in(Scope::Workspace, cx).expect("just opened"); document.update(cx, |viewer, _| assert!(viewer.transport.is_none())); - close_viewer(cx); + close_viewer(window, cx); }); } @@ -886,6 +964,7 @@ mod tests { /// it is the real path. #[gpui::test] fn closing_the_viewer_leaves_nothing_playing(cx: &mut TestAppContext) { + let cx = with_window(cx); // 44-byte canonical WAV header, then a second of 8 kHz 16-bit mono silence. let data = 8000usize * 2; let mut wav = Vec::new(); @@ -906,10 +985,10 @@ mod tests { let path = std::env::temp_dir().join("qrate-viewer-close-stops.wav"); std::fs::write(&path, &wav).unwrap(); - cx.update(|cx| { - open_viewer(path.clone(), Scope::Workspace, cx); + cx.update(|window, cx| { + open_viewer(path.clone(), Scope::Workspace, window, cx); preview::playback::play(&path, cx); - close_viewer(cx); + close_viewer(window, cx); assert!( !preview::playback::position(cx).is_some_and(|(_, playing)| playing), "the recording keeps going after the viewer is gone" @@ -928,6 +1007,7 @@ mod tests { fn a_video_scrubs_on_release_and_not_during_the_drag(cx: &mut TestAppContext) { use gpui_component::slider::{SliderEvent, SliderValue}; + let cx = with_window(cx); let path = std::env::temp_dir().join("qrate-viewer-scrub.mp4"); let made = std::process::Command::new("ffmpeg") .args(["-v", "error", "-y", "-f", "lavfi", "-i"]) @@ -940,12 +1020,12 @@ mod tests { } // An emitted event is delivered when the update flushes, so each half is its own update. - let viewer = cx.update(|cx| { - open_viewer(path.clone(), Scope::Workspace, cx); + let viewer = cx.update(|window, cx| { + open_viewer(path.clone(), Scope::Workspace, window, cx); viewer_in(Scope::Workspace, cx).expect("just opened") }); cx.run_until_parked(); - let scrubber = cx.update(|cx| { + let scrubber = cx.update(|_, cx| { let scrubber = viewer .read(cx) .scrubber @@ -955,21 +1035,21 @@ mod tests { scrubber }); - cx.update(|cx| { + cx.update(|_, cx| { scrubber.update(cx, |_, cx| { cx.emit(SliderEvent::Change(SliderValue::Single(4.))); }); }); - cx.update(|cx| { + cx.update(|_, cx| { assert_eq!(viewer.read(cx).scrub, 4, "the readout follows the thumb"); assert_eq!(viewer.read(cx).page, 0, "but nothing has been rendered yet"); scrubber.update(cx, |_, cx| { cx.emit(SliderEvent::Release(SliderValue::Single(4.))); }); }); - cx.update(|cx| { + cx.update(|window, cx| { assert_eq!(viewer.read(cx).page, 4, "letting go lands on that second"); - close_viewer(cx); + close_viewer(window, cx); }); let _ = std::fs::remove_file(&path); @@ -980,19 +1060,25 @@ mod tests { /// "has more than one page". #[gpui::test] fn a_one_page_document_still_says_so(cx: &mut TestAppContext) { - cx.update(|cx| { - open_viewer("/nonexistent/one-page.pdf".into(), Scope::Workspace, cx); + let cx = with_window(cx); + cx.update(|window, cx| { + open_viewer( + "/nonexistent/one-page.pdf".into(), + Scope::Workspace, + window, + cx, + ); let viewer = viewer_in(Scope::Workspace, cx).expect("just opened"); viewer.update(cx, |viewer, _| { assert_eq!(viewer.pages, 1); assert!(viewer.document, "a PDF is a document at any length"); }); - open_viewer("/nonexistent/scan.jpg".into(), Scope::Workspace, cx); + open_viewer("/nonexistent/scan.jpg".into(), Scope::Workspace, window, cx); let photo = viewer_in(Scope::Workspace, cx).expect("just opened"); photo.update(cx, |viewer, _| assert!(!viewer.document)); - close_viewer(cx); + close_viewer(window, cx); }); } } diff --git a/crates/workspace/src/views/gallery.rs b/crates/workspace/src/views/gallery.rs index 042c01fc..851f08e8 100644 --- a/crates/workspace/src/views/gallery.rs +++ b/crates/workspace/src/views/gallery.rs @@ -198,7 +198,7 @@ fn card( return; } if let Some(path) = viewable.clone() { - crate::viewer::open_viewer(path, crate::ViewerScope::Centre, cx); + crate::viewer::open_viewer(path, crate::ViewerScope::Centre, window, cx); } } }) diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index d2a8f130..ea37c618 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -17,7 +17,7 @@ use gpui_component::table::TableState; use gpui_component::{ ActiveTheme as _, Icon, IconName, Sizable as _, button::Button, - dock::{DockArea, Panel, PanelControl, PanelEvent}, + dock::{BasePanel, DockArea, Panel, PanelEvent}, h_flex, slider::{Slider, SliderEvent, SliderState}, tab::{Tab, TabBar}, @@ -241,6 +241,7 @@ impl ViewsPanel { /// inside a Windows window proc: a non-unwinding abort, not a catchable error. So the panel's own /// state changes under one short lease that is released before anything touches the dock. fn switch(panel: &Entity, mode: ViewMode, window: &mut Window, cx: &mut App) { + let had_focus = panel.read(cx).focus_handle.contains_focused(window, cx); let Some((leaving, dock_area)) = panel.update(cx, |this, cx| { (this.view != mode).then(|| { let leaving = this.view; @@ -253,7 +254,7 @@ fn switch(panel: &Entity, mode: ViewMode, window: &mut Window, cx: & }; if crate::viewer::viewer_in(ViewerScope::Centre, cx).is_some() { - crate::viewer::close_viewer(cx); + crate::viewer::close_viewer(window, cx); } let text = SharedString::from(mode.as_str()); if cx.has_global::() { @@ -265,6 +266,10 @@ fn switch(panel: &Entity, mode: ViewMode, window: &mut Window, cx: & layout::capture(leaving, &area, cx); layout::apply(mode, &area, window, cx); } + if had_focus { + let focus = panel.read(cx).focus_handle.clone(); + focus.focus(window, cx); + } // The switcher is drawn by the parent `TabPanel` (via `Panel::title`), which does not observe // this panel — `cx.notify()` above repaints the body but leaves the highlight on the old tab. // Switching views is a rare, deliberate click, so a full redraw is the cheap honest fix. @@ -279,11 +284,24 @@ impl Focusable for ViewsPanel { impl EventEmitter for ViewsPanel {} -impl Panel for ViewsPanel { +impl BasePanel for ViewsPanel { fn panel_name(&self) -> &'static str { "ViewsPanel" } + /// Centre panel: not closable, so the main view always keeps its body. + fn closable(&self, _cx: &App) -> bool { + false + } + + /// Zooming the centre is the point of Shift+Esc — it drops the surrounding docks and gives + /// the grid or the gallery the whole window. + fn zoomable(&self, _cx: &App) -> bool { + true + } +} + +impl Panel for ViewsPanel { /// The centre has no name worth showing, so the title cell carries the view switcher instead — /// which puts it at the far left of the same row as the Find button and the ⋯ menu. /// @@ -318,18 +336,6 @@ impl Panel for ViewsPanel { ) } - /// Centre panel: not closable, so the main view always keeps its body. - fn closable(&self, _cx: &App) -> bool { - false - } - - /// A zoom control makes no sense for the main body. Note this *does* render: `zoomable: None` - /// only greys the ⋯ menu's "Zoom In" entry and drops the zoom toolbar button — the ⋯ itself - /// is unconditional in `TabPanel::render_toolbar`. - fn zoomable(&self, _cx: &App) -> Option { - None - } - /// Rendered by `TabPanel::render_toolbar` immediately left of the ⋯ menu, forced to /// `.xsmall().ghost()` by the library. Only the grid has anything to find in. fn toolbar_buttons(&mut self, _w: &mut Window, _cx: &mut Context) -> Option> { @@ -497,7 +503,7 @@ mod layout { Some(PanelSlot { name: meta.name.to_string(), placement: placement_name(placement).to_string(), - open: area.is_dock_open(placement, cx), + open: area.is_dock_open(placement), }) }) .collect(); @@ -531,7 +537,7 @@ mod layout { }; let to = parse_placement(&slot.placement, meta.default_placement); PanelRegistry::move_panel(meta.name, to, dock_area, window, cx); - let open = dock_area.read(cx).is_dock_open(to, cx); + let open = dock_area.read(cx).is_dock_open(to); if open != slot.open { dock_area.update(cx, |area, cx| { crate::toggle_dock_immediately(area, to, window, cx) @@ -547,7 +553,7 @@ mod tests { use std::sync::Arc; use gpui::{AppContext as _, TestAppContext}; - use gpui_component::dock::{DockArea, DockItem, DockPlacement, PanelView}; + use gpui_component::dock::{BasePanelView, DockArea, DockLayout, DockPlacement, panel_handle}; use crate::panel_registry::PanelRegistry; use crate::views::{ViewMode, ViewsPanel}; @@ -587,12 +593,16 @@ mod tests { let weak = dock_area.downgrade(); let panel = cx.new(|cx| ViewsPanel::new(weak.clone(), window, cx)); dock_area.update(cx, |area, cx| { - let centre: Arc = Arc::new(panel.clone()); - area.set_center(DockItem::panel(centre), window, cx); - let details: Arc = - Arc::new(cx.new(|cx| crate::panels::DetailsPanel::new(window, cx))); - let left = DockItem::tabs(vec![details], &weak, window, cx); - area.set_left_dock(left, None, true, window, cx); + let centre: Arc = panel_handle(panel.clone()); + area.set_center(DockLayout::tabs().panel_view(centre, cx), window, cx); + let details: Arc = + panel_handle(cx.new(|cx| crate::panels::DetailsPanel::new(window, cx))); + area.set_dock( + DockPlacement::Left, + DockLayout::tabs().panel_view(details, cx), + window, + cx, + ); }); PanelRegistry::sync(&dock_area, cx); panel diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..b9a635fa --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1788614874, + "narHash": "sha256-7QYjT2vHLuX9Z1pdxHXDKCbh1CR3D/2rywB9Tx0MPRg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c043004d1c6985732bcc1cbc5a9c9aecbbb4e0f0", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..59f31f7b --- /dev/null +++ b/flake.nix @@ -0,0 +1,20 @@ +{ + description = "qrate — a spreadsheet for archival cataloguing, built on GPUI"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = + { self, nixpkgs }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + in + { + devShells = forAllSystems (system: { + default = import ./nix/devshell.nix { pkgs = nixpkgs.legacyPackages.${system}; }; + }); + }; +} diff --git a/nix/devshell.nix b/nix/devshell.nix new file mode 100644 index 00000000..488d28b0 --- /dev/null +++ b/nix/devshell.nix @@ -0,0 +1,50 @@ +# The dev shell, shared by flake.nix (`nix develop`) and shell.nix (`nix-shell`) +# so the two entry points cannot drift apart. +{ pkgs }: + +let + # Linked at build time, found through pkg-config. + buildDeps = with pkgs; [ + fontconfig + freetype + alsa-lib + openssl + ]; + + # dlopen'd at run time, so they must also be on the loader path — a build that + # links fine still dies at startup without them. + runtimeDeps = with pkgs; [ + libxkbcommon + wayland + vulkan-loader + libGL + libx11 + libxcb + libxcursor + libxrandr + libxi + ]; +in +pkgs.mkShell { + packages = + (with pkgs; [ + cargo + rustc + rustfmt + clippy + rust-analyzer + pkg-config + ]) + ++ buildDeps + ++ runtimeDeps; + + # GPUI resolves its Vulkan driver, Wayland and xkb through dlopen, and `cargo + # run` inherits this shell rather than a Nix wrapper, so the loader path has to + # be set here. + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeDeps; + + shellHook = '' + echo "qrate dev shell — cargo $(cargo --version | cut -d' ' -f2)" + echo "PDF and video preview need ./scripts/fetch-binaries.sh" + ''; +} diff --git a/scripts/fetch-binaries.ps1 b/scripts/fetch-binaries.ps1 new file mode 100644 index 00000000..86a465ee --- /dev/null +++ b/scripts/fetch-binaries.ps1 @@ -0,0 +1,78 @@ +param( + [string] $Destination = "target/debug" +) + +$ErrorActionPreference = "Stop" +$destinationPath = [System.IO.Path]::GetFullPath($Destination) +$temp = Join-Path ([System.IO.Path]::GetTempPath()) ("qrate-binaries-" + [guid]::NewGuid()) +$strict = -not [string]::IsNullOrEmpty($env:QRATE_STRICT_BINARIES) +$skipFfmpeg = -not [string]::IsNullOrEmpty($env:QRATE_SKIP_FFMPEG) +$architecture = switch ($env:PROCESSOR_ARCHITECTURE) { + "AMD64" { "x64" } + "ARM64" { "arm64" } + default { throw "Unsupported Windows architecture: $env:PROCESSOR_ARCHITECTURE" } +} + +New-Item -ItemType Directory -Force -Path $destinationPath, $temp | Out-Null +Write-Host "==> platform: win-$architecture, destination: $destinationPath" + +try { + $pdfiumUrl = "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-win-$architecture.tgz" + $pdfiumArchive = Join-Path $temp "pdfium.tgz" + Write-Host "==> pdfium: $pdfiumUrl" + try { + Invoke-WebRequest -UseBasicParsing $pdfiumUrl -OutFile $pdfiumArchive + tar -xzf $pdfiumArchive -C $temp + if ($LASTEXITCODE -ne 0) { + throw "tar exited with code $LASTEXITCODE" + } + $pdfium = Get-ChildItem $temp -Filter "pdfium.dll" -File -Recurse | + Select-Object -First 1 + if ($null -eq $pdfium) { + throw "No PDFium library was found in $pdfiumUrl; the archive layout may have changed" + } + Copy-Item $pdfium.FullName $destinationPath -Force + Write-Host " installed pdfium.dll" + } + catch { + if ($strict) { + throw + } + Write-Warning "PDFium download failed; PDF previews will fall back to an icon: $_" + } + + if ($skipFfmpeg) { + Write-Host "==> ffmpeg: skipped (QRATE_SKIP_FFMPEG set)" + } + else { + $ffmpegUrl = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" + $ffmpegArchive = Join-Path $temp "ffmpeg.zip" + Write-Host "==> ffmpeg: $ffmpegUrl" + try { + Invoke-WebRequest -UseBasicParsing $ffmpegUrl -OutFile $ffmpegArchive + $ffmpegDirectory = Join-Path $temp "ffmpeg" + Expand-Archive $ffmpegArchive -DestinationPath $ffmpegDirectory -Force + $ffmpeg = Get-ChildItem $ffmpegDirectory -Filter "ffmpeg.exe" -File -Recurse | + Select-Object -First 1 + if ($null -eq $ffmpeg) { + throw "No ffmpeg.exe was found in $ffmpegUrl; the archive layout may have changed" + } + Copy-Item $ffmpeg.FullName $destinationPath -Force + Write-Host " installed ffmpeg.exe" + } + catch { + if ($strict) { + throw + } + Write-Warning "ffmpeg download failed; video previews will fall back to an icon: $_" + } + } + + Write-Host "==> done. Installed preview binaries:" + Get-ChildItem $destinationPath -File | + Where-Object Name -Match "^(pdfium\.dll|ffmpeg\.exe)$" | + ForEach-Object { Write-Host " $($_.Name)" } +} +finally { + Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 00000000..fcaf2845 --- /dev/null +++ b/shell.nix @@ -0,0 +1,5 @@ +# Non-flake entry point. `nix develop` is preferred — it pins nixpkgs through +# flake.lock, where this takes whatever channel the caller has. +{ pkgs ? import { } }: + +import ./nix/devshell.nix { inherit pkgs; }