diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cae8fe..ca31918 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -191,42 +191,26 @@ jobs: VERSION: ${{ needs.channel.outputs.version }} run: | set -euo pipefail - APP_BUNDLE="$APP_NAME.app" - mkdir -p "$APP_BUNDLE/Contents/MacOS" "$APP_BUNDLE/Contents/Resources" - cp "target/$TARGET/$PROFILE/$BIN_NAME" "$APP_BUNDLE/Contents/MacOS/$BIN_NAME" - chmod +x "$APP_BUNDLE/Contents/MacOS/$BIN_NAME" - cat > "$APP_BUNDLE/Contents/Info.plist" < - - - - CFBundleName$APP_NAME - CFBundleDisplayName$APP_NAME - CFBundleIdentifierio.chmonitor.desktop - CFBundleVersion$VERSION - CFBundleShortVersionString$VERSION - CFBundleExecutable$BIN_NAME - CFBundlePackageTypeAPPL - NSHighResolutionCapable - - - PLIST - printf 'chmonitor %s (%s)\n' "$TAG" "$TARGET" > "$APP_BUNDLE/Contents/Resources/README.txt" - zip -qry "chmonitor-$TAG-$TARGET.zip" "$APP_BUNDLE" - rm -rf "$APP_BUNDLE" + chmod +x scripts/build-macos.sh + APP="$(scripts/build-macos.sh --bin "target/$TARGET/$PROFILE/$BIN_NAME" --version "$VERSION" --out "$PWD/dist/macos")" + ditto -c -k --sequesterRsrc --keepParent "$APP" "chmonitor-$TAG-$TARGET.zip" - # SHA256 of the raw packaged binary feeds the update manifests; - # PKG is the archive attached to the release. - - name: Binary sha256 + package name + # SHA256 of the downloadable archive (zip/tar.gz) feeds the update + # manifests so the in-app downloader can verify what it fetched. + - name: Archive sha256 + package name id: meta env: TAG: ${{ needs.channel.outputs.tag }} run: | set -euo pipefail - SHA="$(sha256sum "target/$TARGET/$PROFILE/$BIN_NAME" | cut -d' ' -f1)" if [ "${{ runner.os }}" = "Linux" ]; then EXT=tar.gz; else EXT=zip; fi PKG="chmonitor-$TAG-$TARGET.$EXT" test -f "$PKG" + if command -v sha256sum >/dev/null; then + SHA="$(sha256sum "$PKG" | cut -d' ' -f1)" + else + SHA="$(shasum -a 256 "$PKG" | cut -d' ' -f1)" + fi echo "sha256=$SHA" >> "$GITHUB_OUTPUT" echo "package=$PKG" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index 26e7c26..36c857e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/dist Cargo.lock.orig shots/ *.log diff --git a/Cargo.lock b/Cargo.lock index f248a78..b1aea3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ "accesskit", "accesskit_consumer 0.36.0", "atspi-common", - "phf", + "phf 0.13.1", "serde", "zvariant", ] @@ -129,16 +129,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "agent" -version = "0.0.2" -source = "git+https://github.com/crabtalk/bezel?rev=f86dbbfc84569cc69f3cba6ebb3d99d858dcb259#f86dbbfc84569cc69f3cba6ebb3d99d858dcb259" -dependencies = [ - "gpui", - "theme", - "web-time", -] - [[package]] name = "ahash" version = "0.8.12" @@ -147,6 +137,7 @@ checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "const-random", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -194,6 +185,23 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.104" @@ -215,6 +223,15 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -226,6 +243,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayref" version = "0.3.9" @@ -269,10 +292,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb8421aaa9644a5faf26735f258b669b15f063313ef8f8e2bdb28912a1a6f111" dependencies = [ "enumflags2", + "futures-channel", "futures-util", "getrandom 0.4.3", "serde", "serde_repr", + "wayland-backend", + "wayland-client", + "wayland-protocols", "zbus", ] @@ -550,29 +577,6 @@ dependencies = [ "arrayvec", ] -[[package]] -name = "aws-lc-rs" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - [[package]] name = "backtrace" version = "0.3.76" @@ -588,6 +592,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "base62" +version = "2.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" + [[package]] name = "base64" version = "0.22.1" @@ -595,15 +605,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "bezel" -version = "0.0.2" -source = "git+https://github.com/crabtalk/bezel?rev=f86dbbfc84569cc69f3cba6ebb3d99d858dcb259#f86dbbfc84569cc69f3cba6ebb3d99d858dcb259" +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" dependencies = [ - "agent", - "gpui", - "motion", - "theme", - "ui", + "serde", ] [[package]] @@ -626,15 +633,30 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + [[package]] name = "bit-set" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", ] +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -683,6 +705,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -733,6 +764,16 @@ dependencies = [ "cfg_aliases", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "built" version = "0.8.1" @@ -805,6 +846,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cbc" version = "0.1.2" @@ -844,12 +897,6 @@ dependencies = [ "shlex 2.0.1", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cexpr" version = "0.6.0" @@ -893,18 +940,21 @@ dependencies = [ [[package]] name = "chm-app" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", - "bezel", "chm-clickhouse", "chm-cloud-api", "chm-core", + "chm-postgres", "chm-telemetry", "chm-update", "chrono", "dirs", "gpui", + "gpui-base", + "gpui-component", + "gpui-component-assets", "gpui_platform", "semver", "serde", @@ -957,15 +1007,31 @@ dependencies = [ "tokio", ] +[[package]] +name = "chm-postgres" +version = "0.1.0" +dependencies = [ + "async-trait", + "chm-core", + "chrono", + "native-tls", + "postgres-native-tls", + "tokio", + "tokio-postgres", + "tracing", +] + [[package]] name = "chm-telemetry" version = "0.1.0" dependencies = [ "chm-update", "chrono", + "dirs", "reqwest", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.20", "tokio", "tracing", @@ -980,6 +1046,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.20", "tokio", "tracing", @@ -1006,7 +1073,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] @@ -1023,13 +1090,10 @@ dependencies = [ ] [[package]] -name = "cmake" -version = "0.1.58" +name = "cmov" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "cocoa" @@ -1042,7 +1106,7 @@ dependencies = [ "cocoa-foundation 0.1.2", "core-foundation 0.9.4", "core-graphics 0.23.2", - "foreign-types", + "foreign-types 0.5.0", "libc", "objc", ] @@ -1058,7 +1122,7 @@ dependencies = [ "cocoa-foundation 0.2.0", "core-foundation 0.10.1", "core-graphics 0.24.0", - "foreign-types", + "foreign-types 0.5.0", "libc", "objc", ] @@ -1105,7 +1169,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "gpui_util", "indexmap", @@ -1118,16 +1182,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - [[package]] name = "compression-codecs" version = "0.4.38" @@ -1165,6 +1219,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -1229,7 +1289,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1242,7 +1302,7 @@ dependencies = [ "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types 0.2.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1255,7 +1315,7 @@ dependencies = [ "bitflags 2.13.1", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1302,7 +1362,7 @@ checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1380,6 +1440,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -1430,6 +1499,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctor" version = "1.0.13" @@ -1440,6 +1518,15 @@ dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "data-url" version = "0.3.2" @@ -1496,7 +1583,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "proc-macro2", "quote", @@ -1509,11 +1596,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1582,6 +1681,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dunce" version = "1.0.5" @@ -1635,12 +1740,41 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + [[package]] name = "endi" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum-iterator" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -1776,6 +1910,23 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[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 = "fastrand" version = "2.5.0" @@ -1811,6 +1962,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -1851,6 +2012,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "flume" version = "0.12.0" @@ -1922,6 +2092,15 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1929,7 +2108,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1943,6 +2122,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1970,10 +2155,23 @@ dependencies = [ ] [[package]] -name = "fs_extra" -version = "1.3.0" +name = "fsevent-sys" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] [[package]] name = "futures" @@ -2118,7 +2316,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -2150,6 +2348,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gif" version = "0.14.2" @@ -2183,6 +2391,30 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "glow" version = "0.17.0" @@ -2241,7 +2473,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "accesskit", "anyhow", @@ -2249,28 +2481,20 @@ dependencies = [ "async-task", "bindgen", "bitflags 2.13.1", - "block", - "cbindgen", "chrono", - "cocoa 0.26.0", - "cocoa-foundation 0.2.0", "collections", - "core-foundation 0.10.1", - "core-foundation-sys", - "core-graphics 0.24.0", - "core-text", "core-video", "ctor", "derive_more", "embed-resource", "etagere", - "foreign-types", "futures", "futures-concurrency", "getrandom 0.3.4", "gpui_macros", "gpui_shared_string", "gpui_util", + "hdrhistogram", "heapless", "http_client", "image", @@ -2278,14 +2502,9 @@ dependencies = [ "itertools 0.14.0", "log", "lyon", - "mach2", - "media", - "metal", "num_cpus", - "objc", "parking", "parking_lot", - "pathfinder_geometry", "pin-project", "pollster 0.4.0", "postage", @@ -2294,7 +2513,7 @@ dependencies = [ "raw-window-handle", "refineable", "regex", - "resvg", + "resvg 0.46.0", "scheduler", "schemars", "seahash", @@ -2311,7 +2530,7 @@ dependencies = [ "tracing", "ttf-parser", "url", - "usvg", + "usvg 0.46.0", "util_macros", "uuid", "waker-fn", @@ -2322,10 +2541,111 @@ dependencies = [ "ztracing", ] +[[package]] +name = "gpui-base" +version = "0.5.2" +source = "git+https://github.com/longbridge/gpui-component#c27f5d5c8f70d534978c2f0739ad9e10d4e41eb4" +dependencies = [ + "aho-corasick", + "anyhow", + "async-channel", + "chrono", + "gpui", + "gpui_macros", + "gpui_platform", + "instant", + "lsp-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "raw-window-handle", + "regex", + "ropey", + "schemars", + "serde", + "serde_json", + "smallvec", + "smol", + "syntect", + "tracing", + "unicode-segmentation", + "web-time", + "zed-sum-tree", +] + +[[package]] +name = "gpui-component" +version = "0.5.2" +source = "git+https://github.com/longbridge/gpui-component#c27f5d5c8f70d534978c2f0739ad9e10d4e41eb4" +dependencies = [ + "anyhow", + "chrono", + "core-text", + "enum-iterator", + "futures", + "gpui", + "gpui-base", + "gpui-component-assets", + "gpui-component-macros", + "gpui_macros", + "html5ever", + "instant", + "itertools 0.13.0", + "log", + "lsp-types", + "markdown", + "markup5ever_rcdom", + "notify", + "num-traits", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "once_cell", + "paste", + "raw-window-handle", + "resvg 0.45.1", + "ropey", + "rust-i18n", + "schemars", + "serde", + "serde_json", + "serde_repr", + "smallvec", + "smol", + "tracing", + "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#c27f5d5c8f70d534978c2f0739ad9e10d4e41eb4" +dependencies = [ + "anyhow", + "gpui", + "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#c27f5d5c8f70d534978c2f0739ad9e10d4e41eb4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "gpui_apple" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "anyhow", "block", @@ -2336,7 +2656,7 @@ dependencies = [ "core-video", "derive_more", "etagere", - "foreign-types", + "foreign-types 0.5.0", "gpui", "image", "log", @@ -2348,15 +2668,17 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "accesskit", "accesskit_unix", "anyhow", "as-raw-xcb-connection", "ashpd", + "bitflags 2.13.1", "bytemuck", "calloop", + "calloop-wayland-source", "collections", "filedescriptor", "futures", @@ -2364,24 +2686,24 @@ dependencies = [ "gpui_util", "gpui_wgpu", "http_client", - "image", - "itertools 0.14.0", "libc", "log", "notify-rust", "oo7", "open", "parking_lot", - "pathfinder_geometry", - "pollster 0.4.0", - "profiling", "raw-window-handle", "smallvec", "smol", "strum", - "swash", "url", "uuid", + "wayland-backend", + "wayland-client", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-plasma", + "wayland-protocols-wlr", "x11-clipboard", "x11rb", "xkbcommon", @@ -2392,7 +2714,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "accesskit", "accesskit_macos", @@ -2408,7 +2730,7 @@ dependencies = [ "core-text", "ctor", "dispatch2", - "foreign-types", + "foreign-types 0.5.0", "futures", "gpui", "gpui_apple", @@ -2438,7 +2760,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2449,7 +2771,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "console_error_panic_hook", "gpui", @@ -2457,13 +2779,12 @@ dependencies = [ "gpui_macos", "gpui_web", "gpui_windows", - "reqwest_client", ] [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "schemars", "serde", @@ -2473,7 +2794,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "anyhow", "log", @@ -2483,7 +2804,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "anyhow", "console_error_panic_hook", @@ -2495,6 +2816,7 @@ dependencies = [ "log", "parking_lot", "raw-window-handle", + "scheduler", "uuid", "wasm-bindgen", "wasm-bindgen-futures", @@ -2506,7 +2828,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "anyhow", "bytemuck", @@ -2516,18 +2838,14 @@ dependencies = [ "gpui", "gpui_util", "itertools 0.14.0", - "js-sys", "log", "parking_lot", - "pollster 0.4.0", "profiling", "raw-window-handle", "smallvec", "swash", "unicode-bidi", "unicode-segmentation", - "wasm-bindgen", - "wasm-bindgen-futures", "web-sys", "wgpu", "zed-font-kit", @@ -2536,7 +2854,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "accesskit", "accesskit_windows", @@ -2561,6 +2879,16 @@ dependencies = [ "windows-registry 0.5.3", ] +[[package]] +name = "granit-parser" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d03f81ad4732830d85cfd417a9f62cde6dadda4354d37d078a6084a19560aa2d" +dependencies = [ + "arraydeque", + "smallvec", +] + [[package]] name = "h2" version = "0.4.18" @@ -2646,6 +2974,20 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hdrhistogram" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d1053f4708f0af3cf9fc5bffc7e68a914a3c45becb231c80068c9c3f78bea" +dependencies = [ + "base64", + "byteorder", + "crossbeam-channel", + "flate2", + "nom 8.0.0", + "num-traits", +] + [[package]] name = "heapless" version = "0.9.3" @@ -2692,7 +3034,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", ] [[package]] @@ -2701,7 +3043,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -2713,6 +3064,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "http" version = "1.5.0" @@ -2749,7 +3114,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "anyhow", "async-compression", @@ -2766,15 +3131,6 @@ dependencies = [ "url", ] -[[package]] -name = "http_client_tls" -version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" -dependencies = [ - "rustls", - "rustls-platform-verifier", -] - [[package]] name = "httparse" version = "1.10.1" @@ -2787,6 +3143,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -2977,6 +3342,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.10" @@ -2987,7 +3368,7 @@ dependencies = [ "byteorder-lite", "color_quant", "exr", - "gif", + "gif 0.14.2", "image-webp", "moxcms", "num-traits", @@ -2996,8 +3377,8 @@ dependencies = [ "ravif", "rayon", "tiff", - "zune-core", - "zune-jpeg", + "zune-core 0.5.3", + "zune-jpeg 0.5.15", ] [[package]] @@ -3010,6 +3391,12 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "imagesize" version = "0.14.0" @@ -3034,6 +3421,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -3044,6 +3451,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -3101,6 +3520,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -3125,22 +3553,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - [[package]] name = "jni-sys" version = "0.3.1" @@ -3207,6 +3619,37 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -3369,6 +3812,19 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "lyon" version = "1.0.19" @@ -3421,6 +3877,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + [[package]] name = "mac-notification-sys" version = "0.6.15" @@ -3453,6 +3915,42 @@ dependencies = [ "libc", ] +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "serde", + "unicode-id", +] + +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] + [[package]] name = "matchers" version = "0.2.0" @@ -3479,20 +3977,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", ] [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "anyhow", "bindgen", "core-foundation 0.10.1", "core-video", - "ctor", - "foreign-types", + "foreign-types 0.5.0", "metal", "objc", ] @@ -3530,7 +4037,7 @@ dependencies = [ "bitflags 2.13.1", "block", "core-graphics-types 0.2.0", - "foreign-types", + "foreign-types 0.5.0", "log", "objc", "paste", @@ -3575,19 +4082,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", - "wasi", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] -[[package]] -name = "motion" -version = "0.0.2" -source = "git+https://github.com/crabtalk/bezel?rev=f86dbbfc84569cc69f3cba6ebb3d99d858dcb259#f86dbbfc84569cc69f3cba6ebb3d99d858dcb259" -dependencies = [ - "gpui", - "web-time", -] - [[package]] name = "moxcms" version = "0.8.1" @@ -3605,7 +4104,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.9.1", "bitflags 2.13.1", "cfg-if", "cfg_aliases", @@ -3624,6 +4123,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -3648,6 +4164,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -3673,6 +4195,34 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "normpath" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.13.1", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + [[package]] name = "notify-rust" version = "4.18.0" @@ -3687,6 +4237,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -3879,8 +4438,8 @@ dependencies = [ "block2 0.5.1", "libc", "objc2 0.5.2", - "objc2-core-data", - "objc2-core-image", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", "objc2-foundation 0.2.2", "objc2-quartz-core 0.2.2", ] @@ -3892,8 +4451,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.1", + "block2 0.6.2", + "libc", "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data 0.3.2", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -3909,6 +4488,17 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -3920,6 +4510,19 @@ dependencies = [ "objc2 0.6.4", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + [[package]] name = "objc2-core-image" version = "0.2.2" @@ -3932,6 +4535,16 @@ dependencies = [ "objc2-metal 0.2.2", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-location" version = "0.3.2" @@ -3942,6 +4555,31 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -3973,6 +4611,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + [[package]] name = "objc2-metal" version = "0.2.2" @@ -4023,6 +4672,15 @@ dependencies = [ "objc2-metal 0.3.2", ] +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "objc2-user-notifications" version = "0.3.2" @@ -4092,20 +4750,20 @@ dependencies = [ "blocking", "cbc", "cipher", - "digest", + "digest 0.10.7", "endi", "futures-lite", "futures-util", "getrandom 0.4.3", "hkdf", - "hmac", - "md-5", + "hmac 0.12.1", + "md-5 0.10.6", "num", "num-bigint-dig", "pbkdf2", "serde", "serde_bytes", - "sha2", + "sha2 0.10.9", "subtle", "zbus", "zbus_macros", @@ -4123,12 +4781,49 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -4220,8 +4915,8 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", - "hmac", + "digest 0.10.7", + "hmac 0.12.1", ] [[package]] @@ -4233,22 +4928,51 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "collections", "serde", "serde_json", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_macros", - "phf_shared", - "serde", + "phf_shared 0.11.3", + "rand 0.8.7", ] [[package]] @@ -4258,7 +4982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -4267,13 +4991,22 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -4425,6 +5158,48 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "postgres-native-tls" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef4de47bb81477e0c3deaf153a1b10ae176484713ff1640969f4cb96b653ebc" +dependencies = [ + "native-tls", + "tokio", + "tokio-native-tls", + "tokio-postgres", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator", + "hmac 0.13.0", + "md-5 0.11.0", + "memchr", + "rand 0.10.2", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "chrono", + "fallible-iterator", + "postgres-protocol", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -4449,6 +5224,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "presser" version = "0.3.1" @@ -4907,7 +5688,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "derive_refineable", ] @@ -4986,21 +5767,20 @@ dependencies = [ ] [[package]] -name = "reqwest_client" -version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" dependencies = [ - "anyhow", - "bytes", - "futures", - "gpui_util", - "http_client", - "http_client_tls", + "gif 0.13.3", + "image-webp", "log", - "regex", - "serde", - "tokio", - "zed-reqwest", + "pico-args", + "rgb", + "svgtypes 0.15.3", + "tiny-skia", + "usvg 0.45.1", + "zune-jpeg 0.4.21", ] [[package]] @@ -5009,15 +5789,15 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" dependencies = [ - "gif", + "gif 0.14.2", "image-webp", "log", "pico-args", "rgb", - "svgtypes", + "svgtypes 0.16.1", "tiny-skia", - "usvg", - "zune-jpeg", + "usvg 0.46.0", + "zune-jpeg 0.5.15", ] [[package]] @@ -5043,6 +5823,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ropey" +version = "2.0.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4045a00dc327d084a2bbf126976e14125b54f23bd30511d45b842eba76c52d74" +dependencies = [ + "str_indices", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -5058,6 +5847,90 @@ dependencies = [ "memchr", ] +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "shellexpand", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "globset", + "sha2 0.11.0", + "walkdir", +] + +[[package]] +name = "rust-i18n" +version = "4.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f10cee36dd3b1f7929ea12b759de9eea9eff83bfccbc71f387ef4d41a57c64a4" +dependencies = [ + "globwalk", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "4.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0bb1ed4e04fe26c2a2652cad1c6595efaf7196f4445c0d6e13c67154347fb7e" +dependencies = [ + "glob", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "syn 2.0.119", +] + +[[package]] +name = "rust-i18n-support" +version = "4.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1c083408a2733180ae0acf2897612f8ceec7b0c0dcd065a0a87103bfb3b1d9" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "normpath", + "serde", + "serde-saphyr", + "serde_json", + "siphasher", + "toml 0.8.23", + "triomphe", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -5117,8 +5990,6 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ - "aws-lc-rs", - "log", "once_cell", "ring", "rustls-pki-types", @@ -5158,40 +6029,12 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs 0.26.11", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - [[package]] name = "rustls-webpki" version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -5248,7 +6091,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "async-task", "backtrace", @@ -5257,6 +6100,7 @@ dependencies = [ "futures", "parking_lot", "rand 0.9.5", + "wasm_thread", "web-time", ] @@ -5286,6 +6130,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -5370,6 +6220,25 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-saphyr" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" +dependencies = [ + "ahash", + "annotate-snippets", + "base64", + "encoding_rs_io", + "getrandom 0.3.4", + "granit-parser", + "nohash-hasher", + "num-traits", + "serde_core", + "smallvec", + "zmij", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -5489,7 +6358,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -5501,6 +6381,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + [[package]] name = "shlex" version = "1.3.0" @@ -5704,6 +6593,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + [[package]] name = "strict-num" version = "0.1.1" @@ -5713,6 +6608,42 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strum" version = "0.27.2" @@ -5743,7 +6674,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "heapless", "log", @@ -5837,13 +6768,23 @@ version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + [[package]] name = "svgtypes" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ - "kurbo", + "kurbo 0.13.1", "siphasher", ] @@ -5900,6 +6841,24 @@ dependencies = [ "syn 2.0.119", ] +[[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.20", + "walkdir", +] + [[package]] name = "sys-locale" version = "0.3.2" @@ -5993,23 +6952,23 @@ dependencies = [ ] [[package]] -name = "termcolor" -version = "1.4.1" +name = "tendril" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" dependencies = [ - "winapi-util", + "futf", + "mac", + "utf-8", ] [[package]] -name = "theme" -version = "0.0.2" -source = "git+https://github.com/crabtalk/bezel?rev=f86dbbfc84569cc69f3cba6ebb3d99d858dcb259#f86dbbfc84569cc69f3cba6ebb3d99d858dcb259" +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" dependencies = [ - "gpui", - "objc", - "serde", - "tracing", + "winapi-util", ] [[package]] @@ -6072,7 +7031,7 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -6180,6 +7139,42 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf 0.13.1", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.2", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -6444,6 +7439,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6482,18 +7488,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "ui" -version = "0.0.2" -source = "git+https://github.com/crabtalk/bezel?rev=f86dbbfc84569cc69f3cba6ebb3d99d858dcb259#f86dbbfc84569cc69f3cba6ebb3d99d858dcb259" -dependencies = [ - "gpui", - "motion", - "theme", - "unicode-segmentation", - "web-time", -] - [[package]] name = "unicase" version = "2.9.0" @@ -6518,6 +7512,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-id" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -6530,6 +7530,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -6584,6 +7593,33 @@ dependencies = [ "serde", ] +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize 0.13.0", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree 0.20.0", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes 0.15.3", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "usvg" version = "0.46.0" @@ -6594,8 +7630,8 @@ dependencies = [ "data-url", "flate2", "fontdb", - "imagesize", - "kurbo", + "imagesize 0.14.0", + "kurbo 0.13.1", "log", "pico-args", "roxmltree 0.21.1", @@ -6603,7 +7639,7 @@ dependencies = [ "simplecss", "siphasher", "strict-num", - "svgtypes", + "svgtypes 0.16.1", "tiny-skia-path", "unicode-bidi", "unicode-script", @@ -6611,6 +7647,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6620,7 +7662,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "perf", "quote", @@ -6693,6 +7735,12 @@ dependencies = [ "sval_serde", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -6750,6 +7798,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -6759,6 +7816,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -6838,6 +7904,92 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + [[package]] name = "wayland-sys" version = "0.31.11" @@ -6870,24 +8022,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-root-certs" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" -dependencies = [ - "webpki-root-certs 1.0.9", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webpki-roots" version = "1.0.9" @@ -6940,8 +8074,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.9.1", + "bit-vec 0.9.1", "bitflags 2.13.1", "bytemuck", "cfg_aliases", @@ -7012,7 +8146,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", + "bit-set 0.9.1", "bitflags 2.13.1", "block2 0.6.2", "bytemuck", @@ -7093,6 +8227,19 @@ dependencies = [ "winsafe", ] +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -7134,6 +8281,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -7202,6 +8359,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -7261,6 +8431,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -7283,6 +8464,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -7357,6 +8549,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -7375,6 +8576,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.3.1" @@ -7402,15 +8613,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -7438,21 +8640,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -7513,12 +8700,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -7531,12 +8712,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -7549,12 +8724,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -7579,12 +8748,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -7597,12 +8760,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -7615,12 +8772,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -7633,12 +8784,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -7723,6 +8868,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[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.4" @@ -7811,6 +8962,7 @@ checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" dependencies = [ "as-raw-xcb-connection", "libc", + "memmap2", "xkeysym", ] @@ -7826,6 +8978,17 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "xml5ever" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" +dependencies = [ + "log", + "mac", + "markup5ever", +] + [[package]] name = "xmlwriter" version = "0.1.0" @@ -8078,6 +9241,18 @@ 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" @@ -8194,7 +9369,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "anyhow", "chrono", @@ -8211,7 +9386,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "tracing", "tracing-subscriber", @@ -8222,7 +9397,13 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" [[package]] name = "zune-core" @@ -8239,13 +9420,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core", + "zune-core 0.5.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index bab1136..bf65205 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/chm-core", "crates/chm-cloud-api", "crates/chm-clickhouse", + "crates/chm-postgres", "crates/chm-update", "crates/chm-telemetry", "app", @@ -19,13 +20,19 @@ license = "MIT" chm-core = { path = "crates/chm-core" } chm-cloud-api = { path = "crates/chm-cloud-api" } chm-clickhouse = { path = "crates/chm-clickhouse" } +chm-postgres = { path = "crates/chm-postgres" } chm-update = { path = "crates/chm-update" } chm-telemetry = { path = "crates/chm-telemetry" } -# bezel stack — pinned to exact revs; all gpui types flow through bezel::gpui. -bezel = { git = "https://github.com/crabtalk/bezel", rev = "f86dbbfc84569cc69f3cba6ebb3d99d858dcb259" } -gpui = { git = "https://github.com/crabtalk/zed", rev = "cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423", version = "0.2.2" } -gpui_platform = { git = "https://github.com/crabtalk/zed", rev = "cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423", features = ["font-kit", "x11"] } +# gpui-component stack — official Zed GPUI + Longbridge widgets. +# runtime_shaders compiles Metal shaders at launch so `cargo build` works on a +# Mac that only has Command Line Tools (no Xcode `metal` compiler). Prefer the +# precompiled path when Xcode is installed — drop this feature then. +gpui = { git = "https://github.com/zed-industries/zed" } +gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "runtime_shaders"] } +gpui-base = { git = "https://github.com/longbridge/gpui-component" } +gpui-component = { git = "https://github.com/longbridge/gpui-component" } +gpui-component-assets = { git = "https://github.com/longbridge/gpui-component" } # crates-io anyhow = "1" diff --git a/README.md b/README.md index 8c5c315..4472f93 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,64 @@ # chmonitor Desktop -GPUI + bezel desktop client for [chmonitor](https://chmonitor.dev) — ClickHouse +GPUI desktop client for [chmonitor](https://chmonitor.dev) — ClickHouse monitoring for macOS and Linux, with two connection modes: 1. **Cloud / dashboard endpoint** — talks to `dash.chmonitor.dev` or any self-hosted chmonitor worker (`chm-cloud-api`). 2. **Direct ClickHouse** — speaks to your ClickHouse instance over HTTP (`chm-clickhouse`), SQL ported from the web dashboard. +3. **Postgres** — read-only `pg_stat_*` monitoring (`chm-postgres`), same + host switcher as ClickHouse. Merges/Traffic pages hide on a PG host. ## Build ```sh cargo build -p chm-app # debug cargo build --release -p chm-app # release (LTO, stripped) +scripts/build-macos.sh # dist/macos/chmonitor.app (icon + Info.plist) ``` +### macOS + +GPUI paints with Metal. Full Xcode ships the `metal` compiler used to +precompile shaders at build time. This workspace enables +`gpui_platform/runtime_shaders` so a Mac with only Command Line Tools +(`xcode-select -p` → `/Library/Developer/CommandLineTools`) can still +`cargo build -p chm-app`; shaders compile on first launch instead. + +To precompile shaders (faster startup) once Xcode is installed: + +```sh +sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer +xcodebuild -downloadComponent MetalToolchain # Xcode 26+ +``` + +Then drop `runtime_shaders` from the `gpui_platform` features in the +workspace `Cargo.toml`. + ## Run ```sh cargo run -p chm-app -- --connect # open Connect screen +cargo run -p chm-app -- --help CHM_SMOKE=1 cargo run -p chm-app # built-in fixture data, no network CHM_PROFILE=work cargo run -p chm-app # named saved profile +CHM_CONFIG=/tmp/chmonitor.toml cargo run -p chm-app ``` +Named profiles live under `[profiles.]` in `config.toml`; the +default connection is `[profile]`. `r` refreshes the current page; +keys `1`–`8` switch sidebar destinations; `cmd-b` toggles the sidebar; +drag the sidebar edge to resize it (saved in `[ui].sidebar_width`); +`cmd-,` opens Settings. The native title bar holds the host switcher, +time range, light/dark, and Settings. Overview defaults to the four +[dash.chmonitor.dev](https://dash.chmonitor.dev) KPI cards (active queries, +schema, storage, uptime); Settings can restore a roomier layout or pick +which tiles show. Pages restore from a local cache so they paint immediately, +then refresh; skeletons show when nothing is cached yet. Hosts are +`[profile]` plus `[profiles.]`; Connect's optional Name field +saves a named host. + ## Layout | Path | Purpose | @@ -30,9 +66,10 @@ CHM_PROFILE=work cargo run -p chm-app # named saved profile | `crates/chm-core` | domain types + `DataSource` trait + mock data | | `crates/chm-cloud-api` | mode 1: dashboard REST client | | `crates/chm-clickhouse` | mode 2: direct ClickHouse HTTP client | +| `crates/chm-postgres` | mode 3: direct Postgres (`pg_stat_*`) | | `crates/chm-update` | channel-aware update checker (stable/beta) | -| `crates/chm-telemetry` | opt-in telemetry + perf metrics | -| `app/` | GPUI + bezel UI | +| `crates/chm-telemetry` | opt-in install ping + page events, local fetch/RSS metrics | +| `app/` | GPUI UI: [gpui-base](https://longbridge.github.io/gpui-component/base/getting-started.md) primitives (buttons, radios, tables) + [gpui-component](https://longbridge.github.io/gpui-component/) for sidebar, charts, theme | | `.github/workflows/` | CI: lint, test, build matrix, releases | ## Testing @@ -40,6 +77,7 @@ CHM_PROFILE=work cargo run -p chm-app # named saved profile ```sh cargo test --workspace # unit + wiremock + SQL snapshots scripts/smoke.sh # GUI smoke on Linux desktop (display :1) +scripts/smoke-mac.sh # GUI smoke on macOS (CHM_SMOKE=1 + screenshot) ``` CI runs lint (`fmt` + `clippy -D warnings`), the workspace tests, a @@ -54,6 +92,8 @@ are rejected by the release pipeline. - `stable` — tagged releases via release-please. - `beta` — pre-release builds (tag suffix `-beta.N`); the in-app update checker follows the channel baked into the profile. -- Auto-update: in-app check + download prompt (chm-update); update manifests - are emitted per release and attached alongside signed archives. +- Auto-update: `[update]` in `config.toml` (check on launch by default; + `auto_download` fetches the archive). The status bar shows the version and + installs over `chmonitor.app` when you are running from the bundle. + Manifests are `{base}/{channel}.json` (`CHM_UPDATE_URL` overrides the host). diff --git a/app/Cargo.toml b/app/Cargo.toml index 3df67e1..f28c171 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -8,11 +8,14 @@ license.workspace = true chm-core.workspace = true chm-cloud-api.workspace = true chm-clickhouse.workspace = true +chm-postgres.workspace = true chm-update.workspace = true chm-telemetry.workspace = true -bezel.workspace = true gpui.workspace = true gpui_platform.workspace = true +gpui-base.workspace = true +gpui-component.workspace = true +gpui-component-assets.workspace = true anyhow.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/app/src/cache.rs b/app/src/cache.rs new file mode 100644 index 0000000..f070aff --- /dev/null +++ b/app/src/cache.rs @@ -0,0 +1,103 @@ +//! On-disk page cache so a host switch or relaunch paints last-known data +//! immediately while a background refresh runs. + +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +use chm_core::{ + Health, MergeRow, Overview, QueryRow, ReplicaRow, TableStat, TimeRange, TrafficSeries, +}; +use serde::{Deserialize, Serialize}; + +use crate::pages::Page; + +/// Skip a network round-trip when the file is newer than this. +pub const FRESH_SECS: u64 = 20; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum CachedPage { + Overview { + overview: Overview, + traffic: TrafficSeries, + }, + Queries { + running: Vec, + slow: Vec, + failed: Vec, + }, + Merges(Vec), + Replicas(Vec), + Health(Health), + Tables(Vec), + Traffic(TrafficSeries), +} + +fn dir() -> Option { + dirs::cache_dir().map(|d| d.join("chmonitor").join("pages")) +} + +fn file(host: &str, page: Page, range: TimeRange) -> Option { + let safe_host: String = host + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + dir().map(|d| { + d.join(format!( + "{safe_host}_{}_{}.json", + page.title().to_ascii_lowercase(), + range.label() + )) + }) +} + +pub fn load(host: &str, page: Page, range: TimeRange) -> Option<(CachedPage, SystemTime)> { + let path = file(host, page, range)?; + let meta = std::fs::metadata(&path).ok()?; + let mtime = meta.modified().ok()?; + let text = std::fs::read_to_string(&path).ok()?; + let page: CachedPage = serde_json::from_str(&text).ok()?; + Some((page, mtime)) +} + +pub fn save(host: &str, page: Page, range: TimeRange, data: &CachedPage) { + let Some(path) = file(host, page, range) else { + return; + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(text) = serde_json::to_string(data) { + let _ = std::fs::write(path, text); + } +} + +pub fn is_fresh(mtime: SystemTime) -> bool { + mtime + .elapsed() + .map(|d| d < Duration::from_secs(FRESH_SECS)) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_health_cache() { + let host = format!("test-{}", std::process::id()); + let data = CachedPage::Health(Health { + ok: true, + readonly_tables: 2, + ..Health::default() + }); + save(&host, Page::Health, TimeRange::TwentyFourHours, &data); + let (loaded, _) = load(&host, Page::Health, TimeRange::TwentyFourHours).expect("cached"); + match loaded { + CachedPage::Health(h) => { + assert!(h.ok); + assert_eq!(h.readonly_tables, 2); + } + _ => panic!("wrong page"), + } + } +} diff --git a/app/src/config.rs b/app/src/config.rs new file mode 100644 index 0000000..16ed038 --- /dev/null +++ b/app/src/config.rs @@ -0,0 +1,677 @@ +//! Connection profile, config.toml, CLI flags. +//! +//! Kept out of the GPUI shell so the parse/load path can be unit-tested +//! without opening a window. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use chm_clickhouse::ClickHouseClient; +use chm_cloud_api::CloudClient; +use chm_core::DataSource; +use chm_postgres::PostgresClient; + +/// Saved connection profile (`[profile]` table, or `[profiles.]`). +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct ProfileConfig { + /// "cloud" | "clickhouse" + pub mode: Option, + /// Cloud mode: API base URL. + #[serde(default)] + pub base_url: Option, + /// Cloud mode: API key. + #[serde(default)] + pub api_key: Option, + /// Direct mode: ClickHouse HTTP endpoint. + #[serde(default)] + pub url: Option, + /// Direct mode: user name. + #[serde(default)] + pub user: Option, + /// Direct mode: password. + #[serde(default)] + pub password: Option, + /// Postgres: database name (default `postgres`). + #[serde(default)] + pub database: Option, + /// Postgres: libpq sslmode (`disable` / `prefer` / `require`). + #[serde(default)] + pub sslmode: Option, + /// Release channel for the update check: "stable" | "beta". + #[serde(default)] + pub channel: Option, +} + +/// `[telemetry]` table — opt-in, default disabled. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct TelemetrySection { + #[serde(default)] + pub enabled: bool, +} + +/// `[ui]` table — appearance, density, visible Overview metrics, host. +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct UiSection { + #[serde(default)] + pub appearance: Option, + /// Active host: `"default"` for `[profile]`, or a `[profiles.]` key. + #[serde(default)] + pub host: Option, + /// `"compact"` (default) or `"comfortable"`. + #[serde(default)] + pub density: Option, + /// Overview tile ids (`qps`, `running`, `slow`, `failed`, `replicas`, + /// `disk`, …). Empty means the default six. + #[serde(default)] + pub overview_metrics: Vec, + /// Show the queries/sec sparkline on Overview. Default true. + #[serde(default = "default_true")] + pub show_chart: bool, + /// Start with the sidebar collapsed to an icon strip. Default false. + #[serde(default)] + pub compact_sidebar: bool, + /// Expanded sidebar width in pixels. Missing uses the app default. + #[serde(default)] + pub sidebar_width: Option, + /// Show fetch latency and RSS in the status bar. Default true. + #[serde(default = "default_true")] + pub show_perf: bool, +} + +impl Default for UiSection { + fn default() -> Self { + Self { + appearance: None, + host: None, + density: None, + overview_metrics: Vec::new(), + show_chart: true, + compact_sidebar: false, + sidebar_width: None, + show_perf: true, + } + } +} + +fn default_true() -> bool { + true +} + +/// `[update]` table — launch check and optional auto-download. +/// +/// Channel still lives on `[profile].channel` (`stable` / `beta`). +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct UpdateSection { + /// Check for a newer build on launch. Default true. + #[serde(default = "default_true")] + pub enabled: bool, + /// Download the archive when a newer build is found. Default false + /// (status bar shows the version; click to fetch/install). + #[serde(default)] + pub auto_download: bool, +} + +impl Default for UpdateSection { + fn default() -> Self { + Self { + enabled: true, + auto_download: false, + } + } +} + +/// Whole `config.toml`. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct ConfigFile { + #[serde(default)] + pub profile: ProfileConfig, + /// Named profiles selected with `CHM_PROFILE=`. + #[serde(default)] + pub profiles: BTreeMap, + #[serde(default)] + pub telemetry: TelemetrySection, + #[serde(default)] + pub ui: UiSection, + #[serde(default)] + pub update: UpdateSection, +} + +/// `/chmonitor/config.toml`, or `CHM_CONFIG` when set. +pub fn config_path() -> Option { + if let Some(path) = std::env::var_os("CHM_CONFIG").filter(|p| !p.is_empty()) { + return Some(PathBuf::from(path)); + } + dirs::config_dir().map(|d| d.join("chmonitor").join("config.toml")) +} + +/// `CHM_PROFILE` value when non-empty. +pub fn profile_name_from_env() -> Option { + std::env::var("CHM_PROFILE") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Read the saved profile, if a well-formed file exists. Any failure means +/// "no profile" and the app shows the Connect screen. +pub fn load_profile() -> Option { + load_profile_from(config_path()?.as_path(), profile_name_from_env().as_deref()) +} + +/// Read `config.toml`, or an empty default when the file is missing/invalid. +pub fn load_config() -> ConfigFile { + config_path() + .as_deref() + .map(load_config_from) + .unwrap_or_default() +} + +pub fn load_config_from(path: &Path) -> ConfigFile { + std::fs::read_to_string(path) + .ok() + .and_then(|text| toml::from_str(&text).ok()) + .unwrap_or_default() +} + +/// Write `config.toml`, creating the parent directory when needed. +pub fn save_config(cfg: &ConfigFile) -> Result<(), String> { + let path = config_path().ok_or_else(|| "no config directory on this platform".to_string())?; + save_config_to(&path, cfg) +} + +pub fn save_config_to(path: &Path, cfg: &ConfigFile) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("mkdir failed: {e}"))?; + } + let out = toml::to_string_pretty(cfg).map_err(|e| format!("serialize failed: {e}"))?; + std::fs::write(path, out).map_err(|e| format!("write failed: {e}")) +} + +/// Load `[profile]` or `[profiles.]` from an explicit path. +pub fn load_profile_from(path: &Path, named: Option<&str>) -> Option { + let text = std::fs::read_to_string(path).ok()?; + let cfg: ConfigFile = toml::from_str(&text).ok()?; + match named.filter(|n| !n.is_empty()) { + Some(name) => cfg.profiles.get(name).cloned().filter(|p| p.mode.is_some()), + None => { + cfg.profile.mode.as_ref()?; + Some(cfg.profile) + } + } +} + +/// Id used for the unnamed `[profile]` host in the switcher. +pub const DEFAULT_HOST_ID: &str = "default"; + +/// One saved connection, for the host switcher. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Host { + pub id: String, + pub label: String, + pub profile: ProfileConfig, +} + +/// Hostname (or fallback) shown in the switcher. +pub fn host_display(p: &ProfileConfig) -> String { + match p.mode.as_deref() { + Some("cloud") => host_from_url(p.base_url.as_deref()).unwrap_or_else(|| "cloud".into()), + Some("clickhouse") => { + host_from_url(p.url.as_deref()).unwrap_or_else(|| "clickhouse".into()) + } + Some("postgres") => host_from_url(p.url.as_deref()).unwrap_or_else(|| "postgres".into()), + _ => "host".into(), + } +} + +pub fn host_label(id: &str, p: &ProfileConfig) -> String { + if id != DEFAULT_HOST_ID { + return id.to_string(); + } + host_display(p) +} + +/// Best-effort host[:port] from an HTTP(S) URL. `None` when empty/unusable. +pub fn host_from_url(raw: Option<&str>) -> Option { + let raw = raw?.trim(); + if raw.is_empty() { + return None; + } + let rest = raw.split_once("://").map(|(_, r)| r).unwrap_or(raw); + let hostport = rest.split('/').next().unwrap_or(rest); + let host = hostport.split('@').next_back()?.split('?').next()?; + let host = host.trim(); + if host.is_empty() { + None + } else { + Some(host.to_string()) + } +} + +/// Turn a Connect "Name" field into a host id. Empty/`default` → `[profile]`. +pub fn host_id_from_name(name: &str) -> String { + let cleaned: String = name + .trim() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect(); + if cleaned.is_empty() || cleaned == DEFAULT_HOST_ID { + DEFAULT_HOST_ID.into() + } else { + cleaned + } +} + +/// `[profile]` first, then named `[profiles.*]` in key order. +pub fn list_hosts(cfg: &ConfigFile) -> Vec { + let mut out = Vec::new(); + if cfg.profile.mode.is_some() { + out.push(Host { + id: DEFAULT_HOST_ID.into(), + label: host_label(DEFAULT_HOST_ID, &cfg.profile), + profile: cfg.profile.clone(), + }); + } + for (id, p) in &cfg.profiles { + if p.mode.is_none() || id.is_empty() || id == DEFAULT_HOST_ID { + continue; + } + out.push(Host { + id: id.clone(), + label: host_label(id, p), + profile: p.clone(), + }); + } + out +} + +pub fn profile_for_host(cfg: &ConfigFile, id: &str) -> Option { + list_hosts(cfg) + .into_iter() + .find(|h| h.id == id) + .map(|h| h.profile) +} + +/// Env `CHM_PROFILE`, then `[ui].host`, then the first listed host. +pub fn active_host_id(cfg: &ConfigFile) -> Option { + let hosts = list_hosts(cfg); + if hosts.is_empty() { + return None; + } + let listed = |id: &str| hosts.iter().any(|h| h.id == id); + if let Some(name) = profile_name_from_env() + && listed(&name) + { + return Some(name); + } + if let Some(name) = cfg.ui.host.as_deref() + && listed(name) + { + return Some(name.to_string()); + } + Some(hosts[0].id.clone()) +} + +/// Build the boxed data source behind [`chm_core::DataSource`] for a saved +/// profile. `None` when required fields are missing. +pub fn source_from_profile(p: &ProfileConfig) -> Option> { + match p.mode.as_deref()? { + "cloud" => Some(Box::new(CloudClient::new( + p.base_url.clone()?, + p.api_key.clone(), + ))), + "clickhouse" => Some(Box::new(ClickHouseClient::new( + p.url.clone()?, + p.user.clone().unwrap_or_else(|| "default".into()), + p.password.clone(), + ))), + "postgres" => PostgresClient::new( + p.url.clone()?, + p.user.clone(), + p.password.clone(), + p.database.clone(), + p.sslmode.clone(), + ) + .ok() + .map(|c| Box::new(c) as Box), + _ => None, + } +} + +/// Flags parsed from argv. Stored process-wide so `Shell::new` can read them +/// without threading gpui constructors. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Cli { + /// Force the Connect page even when a profile exists. + pub connect: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CliError { + Help, + Version, + Unknown(String), +} + +pub const HELP: &str = "\ +chmonitor desktop — ClickHouse monitoring + +Usage: + chm-app [--connect] + +Options: + --connect Open the Connect screen + -h, --help Show this help + -V, --version Print version + +Environment: + CHM_SMOKE=1 Use fixture data (no network) + CHM_PROFILE= Load [profiles.] from config.toml + CHM_CONFIG= Override config.toml path + CHM_UPDATE_URL= Override update manifest base + +Config (`config.toml`): + [update] + enabled = true # check on launch (default) + auto_download = false # fetch the archive without a click + [ui] + density = \"compact\" # or comfortable + overview_metrics = [] # empty = running, schema, disk, uptime +"; + +impl Cli { + pub fn parse(args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let mut cli = Cli::default(); + for arg in args { + match arg.as_ref() { + "--connect" => cli.connect = true, + "-h" | "--help" => return Err(CliError::Help), + "-V" | "--version" => return Err(CliError::Version), + other => return Err(CliError::Unknown(other.to_string())), + } + } + Ok(cli) + } +} + +static CLI: OnceLock = OnceLock::new(); + +pub fn install_cli(cli: Cli) { + let _ = CLI.set(cli); +} + +pub fn cli() -> Cli { + CLI.get().cloned().unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_cfg(body: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "chm-config-tests-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&dir).unwrap(); + static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = dir.join(format!("{n}.toml")); + std::fs::write(&path, body).unwrap(); + path + } + + #[test] + fn cli_connect_and_help() { + assert_eq!(Cli::parse(["--connect"]).unwrap(), Cli { connect: true }); + assert_eq!(Cli::parse(Vec::<&str>::new()).unwrap(), Cli::default()); + assert_eq!(Cli::parse(["--help"]).unwrap_err(), CliError::Help); + assert_eq!(Cli::parse(["-V"]).unwrap_err(), CliError::Version); + assert!(matches!( + Cli::parse(["--nope"]), + Err(CliError::Unknown(s)) if s == "--nope" + )); + } + + #[test] + fn default_profile_requires_mode() { + let path = write_cfg("[profile]\nbase_url = \"https://x\"\n"); + assert!(load_profile_from(&path, None).is_none()); + } + + #[test] + fn default_profile_loads_cloud() { + let path = write_cfg( + "[profile]\nmode = \"cloud\"\nbase_url = \"https://acme.dash.chmonitor.dev\"\napi_key = \"k\"\n", + ); + let p = load_profile_from(&path, None).unwrap(); + assert_eq!(p.mode.as_deref(), Some("cloud")); + assert_eq!( + p.base_url.as_deref(), + Some("https://acme.dash.chmonitor.dev") + ); + let src = source_from_profile(&p).unwrap(); + assert_eq!(src.label(), "cloud: https://acme.dash.chmonitor.dev"); + } + + #[test] + fn named_profile_selected_over_default() { + let path = write_cfg( + r#" +[profile] +mode = "cloud" +base_url = "https://default.example" + +[profiles.work] +mode = "clickhouse" +url = "http://localhost:8123" +user = "alice" +"#, + ); + let def = load_profile_from(&path, None).unwrap(); + assert_eq!(def.mode.as_deref(), Some("cloud")); + let work = load_profile_from(&path, Some("work")).unwrap(); + assert_eq!(work.mode.as_deref(), Some("clickhouse")); + assert_eq!(work.user.as_deref(), Some("alice")); + let src = source_from_profile(&work).unwrap(); + assert_eq!(src.label(), "clickhouse: http://localhost:8123"); + assert!(load_profile_from(&path, Some("missing")).is_none()); + } + + #[test] + fn source_rejects_incomplete_and_unknown_modes() { + assert!(source_from_profile(&ProfileConfig::default()).is_none()); + assert!( + source_from_profile(&ProfileConfig { + mode: Some("cloud".into()), + ..Default::default() + }) + .is_none() + ); + assert!( + source_from_profile(&ProfileConfig { + mode: Some("clickhouse".into()), + ..Default::default() + }) + .is_none() + ); + assert!( + source_from_profile(&ProfileConfig { + mode: Some("ftp".into()), + url: Some("http://x".into()), + ..Default::default() + }) + .is_none() + ); + } + + #[test] + fn clickhouse_defaults_user_to_default() { + let src = source_from_profile(&ProfileConfig { + mode: Some("clickhouse".into()), + url: Some("http://ch:8123".into()), + ..Default::default() + }) + .unwrap(); + assert_eq!(src.label(), "clickhouse: http://ch:8123"); + } + + #[test] + fn postgres_source_from_profile() { + let src = source_from_profile(&ProfileConfig { + mode: Some("postgres".into()), + url: Some("postgres://localhost:5432/app".into()), + user: Some("alice".into()), + ..Default::default() + }) + .unwrap(); + assert!(src.label().starts_with("postgres:")); + assert_eq!(src.engine(), chm_core::SourceEngine::Postgres); + } + + #[test] + fn save_roundtrip_keeps_named_profiles_and_telemetry() { + let mut cfg = ConfigFile::default(); + cfg.profile.mode = Some("cloud".into()); + cfg.profile.base_url = Some("https://a".into()); + cfg.profiles.insert( + "work".into(), + ProfileConfig { + mode: Some("clickhouse".into()), + url: Some("http://localhost:8123".into()), + ..Default::default() + }, + ); + cfg.telemetry.enabled = true; + cfg.ui.appearance = Some("dark".into()); + let text = toml::to_string_pretty(&cfg).unwrap(); + let back: ConfigFile = toml::from_str(&text).unwrap(); + assert_eq!(back, cfg); + } + + #[test] + fn save_config_to_roundtrips_ui_and_channel() { + let path = write_cfg(""); + let mut cfg = ConfigFile::default(); + cfg.ui.appearance = Some("light".into()); + cfg.ui.density = Some("comfortable".into()); + cfg.ui.overview_metrics = vec!["qps".into(), "replicas".into()]; + cfg.ui.show_chart = false; + cfg.profile.channel = Some("beta".into()); + cfg.telemetry.enabled = true; + cfg.update.auto_download = true; + save_config_to(&path, &cfg).unwrap(); + let back = load_config_from(&path); + assert_eq!(back.ui.appearance.as_deref(), Some("light")); + assert_eq!(back.ui.density.as_deref(), Some("comfortable")); + assert_eq!(back.ui.overview_metrics, vec!["qps", "replicas"]); + assert!(!back.ui.show_chart); + assert_eq!(back.profile.channel.as_deref(), Some("beta")); + assert!(back.telemetry.enabled); + assert!(back.update.enabled); + assert!(back.update.auto_download); + } + + #[test] + fn ui_section_defaults_compact_and_chart_on() { + let cfg: ConfigFile = toml::from_str("").unwrap(); + assert!(cfg.ui.show_chart); + assert!(cfg.ui.show_perf); + assert!(!cfg.ui.compact_sidebar); + assert!(cfg.ui.sidebar_width.is_none()); + assert!(cfg.ui.overview_metrics.is_empty()); + assert!(cfg.ui.density.is_none()); + let cfg: ConfigFile = toml::from_str( + r#" +[ui] +density = "comfortable" +overview_metrics = ["qps", "disk"] +show_chart = false +compact_sidebar = true +show_perf = false +sidebar_width = 220 +"#, + ) + .unwrap(); + assert_eq!(cfg.ui.density.as_deref(), Some("comfortable")); + assert_eq!(cfg.ui.overview_metrics, vec!["qps", "disk"]); + assert!(!cfg.ui.show_chart); + assert!(cfg.ui.compact_sidebar); + assert_eq!(cfg.ui.sidebar_width, Some(220)); + assert!(!cfg.ui.show_perf); + } + + #[test] + fn update_section_defaults_to_enabled() { + let cfg: ConfigFile = toml::from_str("").unwrap(); + assert!(cfg.update.enabled); + assert!(!cfg.update.auto_download); + let cfg: ConfigFile = toml::from_str("[update]\nauto_download = true\n").unwrap(); + assert!(cfg.update.enabled); + assert!(cfg.update.auto_download); + let cfg: ConfigFile = toml::from_str("[update]\nenabled = false\n").unwrap(); + assert!(!cfg.update.enabled); + } + + #[test] + fn host_from_url_strips_scheme_and_path() { + assert_eq!( + host_from_url(Some("https://acme.dash.chmonitor.dev/api")), + Some("acme.dash.chmonitor.dev".into()) + ); + assert_eq!( + host_from_url(Some("http://localhost:8123")), + Some("localhost:8123".into()) + ); + assert_eq!(host_from_url(Some(" ")), None); + assert_eq!(host_from_url(None), None); + } + + #[test] + fn host_id_from_name_cleans_and_reserves_default() { + assert_eq!(host_id_from_name(""), DEFAULT_HOST_ID); + assert_eq!(host_id_from_name("default"), DEFAULT_HOST_ID); + assert_eq!(host_id_from_name(" prod / eu "), "prod---eu"); + assert_eq!(host_id_from_name("work"), "work"); + } + + #[test] + fn list_hosts_and_active_id() { + let mut cfg = ConfigFile::default(); + cfg.profile.mode = Some("cloud".into()); + cfg.profile.base_url = Some("https://acme.dash.chmonitor.dev".into()); + cfg.profiles.insert( + "work".into(), + ProfileConfig { + mode: Some("clickhouse".into()), + url: Some("http://localhost:8123".into()), + ..Default::default() + }, + ); + let hosts = list_hosts(&cfg); + assert_eq!(hosts.len(), 2); + assert_eq!(hosts[0].id, DEFAULT_HOST_ID); + assert_eq!(hosts[0].label, "acme.dash.chmonitor.dev"); + assert_eq!(hosts[1].id, "work"); + assert_eq!(active_host_id(&cfg).as_deref(), Some(DEFAULT_HOST_ID)); + cfg.ui.host = Some("work".into()); + assert_eq!(active_host_id(&cfg).as_deref(), Some("work")); + cfg.ui.host = Some("missing".into()); + assert_eq!(active_host_id(&cfg).as_deref(), Some(DEFAULT_HOST_ID)); + assert_eq!( + profile_for_host(&cfg, "work").unwrap().url.as_deref(), + Some("http://localhost:8123") + ); + } +} diff --git a/app/src/connect.rs b/app/src/connect.rs index aa97cc0..82664fc 100644 --- a/app/src/connect.rs +++ b/app/src/connect.rs @@ -1,32 +1,36 @@ -//! Connect screen — two-mode connection form (Cloud API vs direct -//! ClickHouse), Test (ping) and Save (writes config.toml). -//! AGENT D OWNS THIS FILE. -//! -//! Flow: pick mode → fill the relevant fields → Test runs `DataSource::ping` -//! → Save persists `[profile]` to `/chmonitor/config.toml` and -//! emits [`ConnectEvent::SavedProfile`], which shell.rs turns into a live -//! data source. +//! Connect screen — Cloud API vs ClickHouse vs Postgres, Test (ping) and Save. -use bezel::gpui::{ +use gpui::{ AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, - div, prelude::*, px, + Window, div, prelude::*, px, }; -use bezel::theme::Theme; -use bezel::ui::input::TextField; -use bezel::ui::widgets::{ButtonStyle, Buttons}; +use gpui_component::{ + ActiveTheme as _, h_flex, + input::{Input, InputState}, + v_flex, +}; + +use crate::widgets::controls::{choice_radio, ghost_button, primary_button, radio_group}; -use crate::shell::{ProfileConfig, config_path, source_from_profile}; +use crate::config::{ + DEFAULT_HOST_ID, ProfileConfig, host_id_from_name, load_config, save_config, + source_from_profile, +}; /// Fired after Save successfully writes config.toml. #[derive(Debug, Clone)] pub enum ConnectEvent { - SavedProfile(ProfileConfig), + SavedProfile { + profile: ProfileConfig, + host_id: String, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { Cloud, - Direct, + ClickHouse, + Postgres, } /// Outcome of the last Test press. @@ -41,53 +45,101 @@ enum TestState { pub struct ConnectFlow { focus: FocusHandle, mode: Mode, - base_url: Entity, - api_key: Entity, - url: Entity, - user: Entity, - password: Entity, + base_url: Entity, + api_key: Entity, + url: Entity, + user: Entity, + password: Entity, + database: Entity, + name: Entity, test: TestState, } impl EventEmitter for ConnectFlow {} impl Focusable for ConnectFlow { - fn focus_handle(&self, _: &bezel::gpui::App) -> FocusHandle { + fn focus_handle(&self, _: &gpui::App) -> FocusHandle { self.focus.clone() } } impl ConnectFlow { /// `initial` prefills the form from a saved profile, if any. - pub fn new(initial: Option, cx: &mut Context) -> Self { - let field = |text: Option, placeholder: &'static str, cx: &mut Context| { + pub fn new( + initial: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let field = |text: Option, + placeholder: &'static str, + masked: bool, + window: &mut Window, + cx: &mut Context| { cx.new(|cx| { - let mut f = TextField::new(cx).with_placeholder(placeholder); + let mut f = InputState::new(window, cx).placeholder(placeholder); if let Some(t) = text.filter(|t| !t.is_empty()) { - f.set_content(t, cx); + f = f.default_value(t); + } + if masked { + f = f.masked(true); } f }) }; let initial = initial.unwrap_or_default(); let mode = match initial.mode.as_deref() { - Some("clickhouse") => Mode::Direct, + Some("clickhouse") => Mode::ClickHouse, + Some("postgres") => Mode::Postgres, _ => Mode::Cloud, }; + let default_user = match mode { + Mode::Postgres => "postgres", + _ => "default", + }; Self { focus: cx.focus_handle(), mode, - base_url: field(initial.base_url, "https://acme.dash.chmonitor.dev", cx), - api_key: field(initial.api_key, "API key", cx), - url: field(initial.url, "http://localhost:8123", cx), - user: field(initial.user.or(Some("default".into())), "user", cx), - password: field(initial.password, "password", cx), + base_url: field( + initial.base_url, + "https://acme.dash.chmonitor.dev", + false, + window, + cx, + ), + api_key: field(initial.api_key, "API key", true, window, cx), + url: field( + initial.url, + if matches!(mode, Mode::Postgres) { + "postgres://localhost:5432/postgres" + } else { + "http://localhost:8123" + }, + false, + window, + cx, + ), + user: field( + initial.user.or(Some(default_user.into())), + "user", + false, + window, + cx, + ), + password: field(initial.password, "password", true, window, cx), + database: field( + initial.database.or(Some("postgres".into())), + "database", + false, + window, + cx, + ), + name: field(None, "work (optional)", false, window, cx), test: TestState::Idle, } } - fn read(e: &Entity, cx: &Context) -> String { - e.read(cx).content().trim().to_string() + fn read(e: &Entity, cx: &Context) -> String { + e.read(cx).value().trim().to_string() } /// Collect the form into a profile. Returns None when fields required by @@ -106,7 +158,7 @@ impl ConnectFlow { ..ProfileConfig::default() }) } - Mode::Direct => { + Mode::ClickHouse => { let url = nonempty(Self::read(&self.url, cx))?; Some(ProfileConfig { mode: Some("clickhouse".into()), @@ -116,6 +168,17 @@ impl ConnectFlow { ..ProfileConfig::default() }) } + Mode::Postgres => { + let url = nonempty(Self::read(&self.url, cx))?; + Some(ProfileConfig { + mode: Some("postgres".into()), + url: Some(url), + user: nonempty(Self::read(&self.user, cx)), + password: nonempty(Self::read(&self.password, cx)), + database: nonempty(Self::read(&self.database, cx)), + ..ProfileConfig::default() + }) + } } } @@ -151,225 +214,144 @@ impl ConnectFlow { cx.notify(); return; }; - // Preserve anything outside [profile] (e.g. [telemetry]) if a config - // already exists; otherwise start from defaults. - let mut cfg = config_path() - .and_then(|p| std::fs::read_to_string(p).ok()) - .and_then(|text| toml::from_str::(&text).ok()) - .unwrap_or_default(); - cfg.profile = profile.clone(); - let out = match toml::to_string_pretty(&cfg) { - Ok(out) => out, - Err(e) => { - self.test = TestState::Failed(format!("serialize failed: {e}")); - cx.notify(); - return; - } - }; - let Some(path) = config_path() else { - self.test = TestState::Failed("no config directory on this platform".into()); - cx.notify(); - return; - }; - if let Some(parent) = path.parent() - && let Err(e) = std::fs::create_dir_all(parent) - { - self.test = TestState::Failed(format!("mkdir failed: {e}")); - cx.notify(); - return; + let host_id = host_id_from_name(&Self::read(&self.name, cx)); + let mut cfg = load_config(); + if host_id == DEFAULT_HOST_ID { + cfg.profile = profile.clone(); + } else { + cfg.profiles.insert(host_id.clone(), profile.clone()); } - match std::fs::write(&path, out) { + cfg.ui.host = Some(host_id.clone()); + match save_config(&cfg) { Ok(()) => { self.test = TestState::Ok; - cx.emit(ConnectEvent::SavedProfile(profile)); + cx.emit(ConnectEvent::SavedProfile { profile, host_id }); cx.notify(); } Err(e) => { - self.test = TestState::Failed(format!("write failed: {e}")); + self.test = TestState::Failed(e); cx.notify(); } } } - // -- rendering ---------------------------------------------------------- - fn mode_row( &self, - theme: &Theme, label: &'static str, hint: &'static str, mode: Mode, cx: &mut Context, - ) -> bezel::gpui::Stateful { + ) -> impl IntoElement { let selected = self.mode == mode; - div() - .id(SharedString::from(format!("mode-{label}"))) - .flex() - .flex_row() - .items_center() - .gap(px(10.0)) - .px(px(12.0)) - .py(px(10.0)) - .rounded(px(8.0)) - .border_1() - .border_color(if selected { - theme.border_strong - } else { - theme.border - }) - .bg(if selected { - theme.element_active - } else { - theme.input_bg - }) - .cursor_pointer() - .hover(|s| s.bg(theme.element_hover)) - .on_click(cx.listener(move |this, _, _, cx| { - this.mode = mode; - this.test = TestState::Idle; - cx.notify(); - })) - .child( - div() - .size(px(14.0)) - .rounded_full() - .border_1() - .border_color(theme.border_strong) - .when(selected, |dot| dot.bg(theme.accent)), - ) - .child( - div() - .flex() - .flex_col() - .gap(px(2.0)) - .child(div().text_size(px(13.0)).child(label)) - .child( - div() - .text_size(px(11.0)) - .text_color(theme.text_muted) - .child(hint), - ), - ) + let entity = cx.entity().downgrade(); + choice_radio(format!("mode-{label}"), selected, label, hint, cx).on_change( + move |next, _, _, cx| { + if next { + let _ = entity.update(cx, |this, cx| { + this.mode = mode; + this.test = TestState::Idle; + cx.notify(); + }); + } + }, + ) } - fn field_row(label: &'static str, field: &Entity) -> bezel::gpui::Div { - div() - .flex() - .flex_col() - .gap(px(4.0)) - .child( - div() - .text_size(px(11.5)) - .text_color(bezel::theme::ink(0.55)) - .child(label), - ) - .child(field.clone()) + fn field_row(label: &'static str, field: &Entity) -> impl IntoElement { + v_flex() + .gap_1() + .child(div().text_sm().child(label)) + .child(Input::new(field)) } - fn status_line(&self, cx: &Context) -> bezel::gpui::AnyElement { - let theme = Theme::of(cx).clone(); - let (text, color): (&str, bezel::gpui::Hsla) = match &self.test { - TestState::Idle => ("", theme.text_faint), - TestState::Testing => ("testing…", theme.warning), - TestState::Ok => ("connection ok", theme.success), - TestState::Failed(e) => (e.as_str(), theme.danger), + fn status_line(&self, cx: &Context) -> impl IntoElement { + let (text, color) = match &self.test { + TestState::Idle => ("", cx.theme().muted_foreground), + TestState::Testing => ("testing…", cx.theme().warning), + TestState::Ok => ("connection ok", cx.theme().green), + TestState::Failed(e) => (e.as_str(), cx.theme().danger), }; div() - .min_h(px(18.0)) - .text_size(px(12.0)) + .min_h(px(18.)) + .text_sm() .text_color(color) .child(SharedString::from(text.to_string())) - .into_any_element() } } impl Render for ConnectFlow { - fn render( - &mut self, - _window: &mut bezel::gpui::Window, - cx: &mut Context, - ) -> impl bezel::gpui::IntoElement { - let theme = Theme::of(cx).clone(); - let (cloud_fields, direct_fields) = match self.mode { - Mode::Cloud => ( - div() - .flex() - .flex_col() - .gap(px(10.0)) - .child(Self::field_row("Base URL", &self.base_url)) - .child(Self::field_row("API key", &self.api_key)), - div(), - ), - Mode::Direct => ( - div(), - div() - .flex() - .flex_col() - .gap(px(10.0)) - .child(Self::field_row("URL", &self.url)) - .child(Self::field_row("User", &self.user)) - .child(Self::field_row("Password", &self.password)), - ), + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let fields = match self.mode { + Mode::Cloud => v_flex() + .gap_3() + .child(Self::field_row("Base URL", &self.base_url)) + .child(Self::field_row("API key", &self.api_key)), + Mode::ClickHouse => v_flex() + .gap_3() + .child(Self::field_row("URL", &self.url)) + .child(Self::field_row("User", &self.user)) + .child(Self::field_row("Password", &self.password)), + Mode::Postgres => v_flex() + .gap_3() + .child(Self::field_row("URL", &self.url)) + .child(Self::field_row("User", &self.user)) + .child(Self::field_row("Password", &self.password)) + .child(Self::field_row("Database", &self.database)), }; - div() - .flex() - .flex_col() - .gap(px(14.0)) - .max_w(px(460.0)) + v_flex() + .gap_4() + .max_w(px(460.)) .child( - div() - .flex() - .flex_col() - .gap(px(2.0)) - .child(div().text_size(px(16.0)).child("Connect to ClickHouse")) + v_flex() + .gap_1() + .child(div().text_lg().child("Add a host")) .child( div() - .text_size(px(12.0)) - .text_color(theme.text_muted) - .child("Use the chmonitor cloud API or talk to your server directly."), + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("ClickHouse, Postgres, or the chmonitor cloud API."), ), ) .child( - div() - .flex() - .flex_col() - .gap(px(8.0)) + radio_group("connect-mode") .child(self.mode_row( - &theme, "Cloud", "chmonitor-hosted dashboard API · base URL + API key", Mode::Cloud, cx, )) .child(self.mode_row( - &theme, - "Direct", - "ClickHouse HTTP endpoint · url + user + password", - Mode::Direct, + "ClickHouse", + "HTTP endpoint · url + user + password", + Mode::ClickHouse, + cx, + )) + .child(self.mode_row( + "Postgres", + "libpq endpoint · url + user + password + database", + Mode::Postgres, cx, )), ) - .child(cloud_fields) - .child(direct_fields) + .child(fields) + .child(Self::field_row("Name", &self.name)) .child(self.status_line(cx)) - .child( - div() - .flex() - .flex_row() - .gap(px(8.0)) + .child({ + let entity = cx.entity().downgrade(); + h_flex() + .gap_2() + .child(ghost_button("test", "Test", cx).on_click({ + let entity = entity.clone(); + move |_, _, cx| { + let _ = entity.update(cx, |this, cx| this.run_test(cx)); + } + })) .child( - theme - .button("Test", ButtonStyle::Ghost, None) - .id("test") - .on_click(cx.listener(|this, _, _, cx| this.run_test(cx))), + primary_button("save", "Save", cx).on_click(move |_, _, cx| { + let _ = entity.update(cx, |this, cx| this.save(cx)); + }), ) - .child( - theme - .button("Save", ButtonStyle::Prominent, None) - .id("save") - .on_click(cx.listener(|this, _, _, cx| this.save(cx))), - ), - ) + }) } } diff --git a/app/src/density.rs b/app/src/density.rs new file mode 100644 index 0000000..ada7231 --- /dev/null +++ b/app/src/density.rs @@ -0,0 +1,272 @@ +//! Layout density and which Overview metrics to show. +//! +//! Compact is the default: tighter chrome, the four dash.chmonitor.dev +//! KPI cards (active queries, schema, storage, uptime), optional sparkline. +//! Comfortable restores the roomier dashboard. Both are `[ui]` keys. + +use crate::config::load_config; + +/// Card / chrome spacing. Unknown or missing config → Compact. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Density { + Compact, + Comfortable, +} + +impl Density { + pub const ALL: [Density; 2] = [Density::Compact, Density::Comfortable]; + + pub fn from_cfg(s: Option<&str>) -> Self { + match s.map(|s| s.to_ascii_lowercase()).as_deref() { + Some("comfortable") => Self::Comfortable, + _ => Self::Compact, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Compact => "compact", + Self::Comfortable => "comfortable", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Compact => "Compact", + Self::Comfortable => "Comfortable", + } + } + + pub fn hint(self) -> &'static str { + match self { + Self::Compact => "tighter cards, key metrics first", + Self::Comfortable => "roomier dashboard", + } + } + + pub fn current() -> Self { + Self::from_cfg(load_config().ui.density.as_deref()) + } + + pub fn font_size(self) -> f32 { + match self { + Self::Compact => 13.0, + Self::Comfortable => 14.0, + } + } + + pub fn mono_font_size(self) -> f32 { + match self { + Self::Compact => 11.0, + Self::Comfortable => 12.0, + } + } + + pub fn radius(self) -> f32 { + match self { + Self::Compact => 6.0, + Self::Comfortable => 8.0, + } + } + + pub fn radius_lg(self) -> f32 { + self.radius() + 2.0 + } + + pub fn card_pad(self) -> f32 { + match self { + Self::Compact => 8.0, + Self::Comfortable => 14.0, + } + } + + pub fn card_gap(self) -> f32 { + match self { + Self::Compact => 6.0, + Self::Comfortable => 10.0, + } + } + + pub fn card_value(self) -> f32 { + match self { + Self::Compact => 18.0, + Self::Comfortable => 24.0, + } + } + + pub fn card_min_w(self) -> f32 { + match self { + Self::Compact => 108.0, + Self::Comfortable => 140.0, + } + } + + pub fn metrics_per_row(self) -> usize { + match self { + Self::Compact | Self::Comfortable => 4, + } + } + + pub fn chart_h(self) -> f32 { + match self { + Self::Compact => 128.0, + Self::Comfortable => 200.0, + } + } + + pub fn content_pad(self) -> f32 { + match self { + Self::Compact => 12.0, + Self::Comfortable => 16.0, + } + } + + pub fn table_px(self) -> f32 { + match self { + Self::Compact => 8.0, + Self::Comfortable => 12.0, + } + } + + pub fn table_py(self) -> f32 { + match self { + Self::Compact => 4.0, + Self::Comfortable => 8.0, + } + } +} + +/// One Overview tile. Ids are the `[ui].overview_metrics` strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverviewMetric { + Running, + Schema, + Disk, + Uptime, + Qps, + Slow, + Failed, + Merges, + Replicas, + Tables, + Parts, + Version, +} + +impl OverviewMetric { + pub const ALL: [OverviewMetric; 12] = [ + Self::Running, + Self::Schema, + Self::Disk, + Self::Uptime, + Self::Qps, + Self::Slow, + Self::Failed, + Self::Merges, + Self::Replicas, + Self::Tables, + Self::Parts, + Self::Version, + ]; + + /// Default visible set: the four dash.chmonitor.dev overview KPIs. + pub const DEFAULT: [OverviewMetric; 4] = + [Self::Running, Self::Schema, Self::Disk, Self::Uptime]; + + pub fn id(self) -> &'static str { + match self { + Self::Running => "running", + Self::Schema => "schema", + Self::Disk => "disk", + Self::Uptime => "uptime", + Self::Qps => "qps", + Self::Slow => "slow", + Self::Failed => "failed", + Self::Merges => "merges", + Self::Replicas => "replicas", + Self::Tables => "tables", + Self::Parts => "parts", + Self::Version => "version", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Running => "Active Queries", + Self::Schema => "Schema", + Self::Disk => "Storage", + Self::Uptime => "Uptime", + Self::Qps => "queries / sec", + Self::Slow => "slow · 24h", + Self::Failed => "failed · 24h", + Self::Merges => "active merges", + Self::Replicas => "replicas", + Self::Tables => "tables", + Self::Parts => "parts", + Self::Version => "version", + } + } + + pub fn from_id(s: &str) -> Option { + Self::ALL.iter().copied().find(|m| m.id() == s) + } +} + +/// Resolve the Overview tile list. Empty or all-unknown → the default six. +pub fn visible_metrics(ids: &[String]) -> Vec { + let parsed: Vec = ids + .iter() + .filter_map(|s| OverviewMetric::from_id(s)) + .collect(); + if parsed.is_empty() { + OverviewMetric::DEFAULT.to_vec() + } else { + parsed + } +} + +pub fn default_metric_ids() -> Vec { + OverviewMetric::DEFAULT + .iter() + .map(|m| m.id().to_string()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn density_parses_with_compact_default() { + assert_eq!(Density::from_cfg(None), Density::Compact); + assert_eq!(Density::from_cfg(Some("compact")), Density::Compact); + assert_eq!(Density::from_cfg(Some("COMFORTABLE")), Density::Comfortable); + assert_eq!(Density::from_cfg(Some("nope")), Density::Compact); + assert_eq!(Density::Compact.as_str(), "compact"); + assert_eq!(Density::Compact.metrics_per_row(), 4); + assert!(Density::Compact.card_pad() < Density::Comfortable.card_pad()); + assert!(Density::Compact.chart_h() < Density::Comfortable.chart_h()); + } + + #[test] + fn visible_metrics_falls_back_to_dashboard_kpis() { + assert_eq!(visible_metrics(&[]), OverviewMetric::DEFAULT); + assert_eq!( + visible_metrics(&["nope".into(), "also-nope".into()]), + OverviewMetric::DEFAULT + ); + assert_eq!( + visible_metrics(&["qps".into(), "disk".into()]), + vec![OverviewMetric::Qps, OverviewMetric::Disk] + ); + assert_eq!( + OverviewMetric::from_id("schema"), + Some(OverviewMetric::Schema) + ); + assert!(OverviewMetric::from_id("nope").is_none()); + assert_eq!( + default_metric_ids(), + ["running", "schema", "disk", "uptime"] + ); + } +} diff --git a/app/src/lib.rs b/app/src/lib.rs index c2826ab..5ed419b 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -1,10 +1,15 @@ -//! chm-app — GPUI + bezel desktop client for chmonitor. +//! chm-app — GPUI desktop client for chmonitor. //! -//! AGENT D OWNS shell.rs / main.rs / connect.rs / pages/ wiring. -//! AGENT E OWNS widgets/ (chart, table, metric card). -//! AGENT F/G/H fill pages/. Keep all gpui types via `bezel::gpui`. +//! Presentation for buttons, radios, toggles, and tables is owned here on +//! top of `gpui-base`. Sidebar, charts, inputs, and theme come from +//! `gpui-component`. +pub mod cache; +pub mod config; pub mod connect; +pub mod density; pub mod pages; pub mod shell; +pub mod theme; +pub mod updater; pub mod widgets; diff --git a/app/src/main.rs b/app/src/main.rs index b830535..24e80a2 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -1,23 +1,33 @@ -//! Entry point — bezel bootstrap (mirrors bezel `apps/hello` exactly). -//! -//! Bootstrap quirks worth remembering: -//! * This gpui fork has no `Application::new()`; the platform facade -//! `gpui_platform::application()` is the only entry. -//! * A gpui app gets no menu bar for free (no nib), so without a Quit -//! menu item `cmd-q` does nothing. -//! * Fonts must be registered before the first window paints. +//! Entry point — gpui-component bootstrap. -use bezel::gpui::{ - App, AppContext as _, Bounds, Focusable as _, Menu, MenuItem, SharedString, TitlebarOptions, - WindowBounds, WindowOptions, actions, px, size, +use chm_app::config::{Cli, CliError, load_config}; +use chm_app::pages::settings::{appearance_from_cfg, apply_appearance}; +use chm_app::shell::{OpenSettings, Refresh, Shell, ToggleSidebar}; +use gpui::{ + App, AppContext as _, Bounds, Focusable as _, KeyBinding, Menu, MenuItem, WindowBounds, + WindowOptions, actions, px, size, }; -use bezel::theme; -use bezel::ui; -use chm_app::shell::Shell; +use gpui_component::{Root, TitleBar}; actions!(chm_app, [Quit]); fn main() { + match Cli::parse(std::env::args().skip(1)) { + Ok(cli) => chm_app::config::install_cli(cli), + Err(CliError::Help) => { + print!("{}", chm_app::config::HELP); + return; + } + Err(CliError::Version) => { + println!("chm-app {}", env!("CARGO_PKG_VERSION")); + return; + } + Err(CliError::Unknown(arg)) => { + eprintln!("unknown argument: {arg}\n{}", chm_app::config::HELP); + std::process::exit(2); + } + } + // Smoke gate: prove init runs to completion headless before any window is // opened. CHM_SMOKE also selects MockDataSource inside Shell (see shell.rs). let smoke = std::env::var("CHM_SMOKE").is_ok(); @@ -25,41 +35,51 @@ fn main() { println!("shell ready"); } - gpui_platform::application().run(|cx: &mut App| { - if let Err(err) = ui::register_fonts(cx) { - eprintln!("FONT REGISTRATION FAILED: {err:?}"); - } - theme::appearance::init(theme::appearance::AppearanceMode::System, cx); - // TextField keybindings are opt-in and scoped to the field's key context. - ui::input::init(cx); - // Without a menu item cmd-q does nothing — no nib ships with a gpui app. - cx.on_action(|_: &Quit, cx: &mut App| cx.quit()); - cx.set_menus(vec![ - Menu::new("chmonitor").items([MenuItem::action("Quit", Quit)]), - ]); + gpui_platform::application() + .with_assets(gpui_component_assets::Assets) + .run(|cx: &mut App| { + // gpui_component::init includes gpui_base::init (theme tokens, + // scrollbars, focus). Application-owned controls are gpui-base + // primitives styled from the theme; charts/sidebar stay on the + // styled façade. + gpui_component::init(cx); + cx.bind_keys([ + KeyBinding::new("cmd-,", OpenSettings, None), + KeyBinding::new("cmd-b", ToggleSidebar, None), + KeyBinding::new("cmd-r", Refresh, None), + KeyBinding::new("cmd-q", Quit, None), + ]); + // Without a menu item cmd-q does nothing — no nib ships with a gpui app. + cx.on_action(|_: &Quit, cx: &mut App| cx.quit()); + cx.set_menus(vec![ + Menu::new("chmonitor").items(vec![ + MenuItem::action("Settings…", OpenSettings), + MenuItem::separator(), + MenuItem::action("Quit", Quit), + ]), + Menu::new("View").items(vec![ + MenuItem::action("Toggle Sidebar", ToggleSidebar), + MenuItem::action("Refresh", Refresh), + ]), + ]); - let bounds = Bounds::centered(None, size(px(1280.0), px(800.0)), cx); - cx.open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - titlebar: Some(TitlebarOptions { - title: Some(SharedString::from("chmonitor")), - ..Default::default() - }), - ..Default::default() - }, - |window, cx| { - // Follow system light/dark for this window (bezel README step). - theme::appearance::observe_window(window, cx).detach(); - let shell = cx.new(Shell::new); - // Root takes focus so 1-8 / r keys land in the shell's subtree - // from the first frame (gallery's pattern). - let focus = shell.read(cx).focus_handle(cx); - window.focus(&focus, cx); - shell - }, - ) - .unwrap(); // unwrap allowed here: same bootstrap shape as bezel examples - cx.activate(true); - }); + let appearance = appearance_from_cfg(load_config().ui.appearance.as_deref()); + let bounds = Bounds::centered(None, size(px(1100.0), px(720.0)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(size(px(720.0), px(480.0))), + ..TitleBar::window_options() + }, + move |window, cx| { + apply_appearance(appearance, window, cx); + let shell = cx.new(|cx| Shell::new(window, cx)); + let focus = shell.read(cx).focus_handle(cx); + window.focus(&focus, cx); + cx.new(|cx| Root::new(shell, window, cx)) + }, + ) + .unwrap(); + cx.activate(true); + }); } diff --git a/app/src/pages/health.rs b/app/src/pages/health.rs new file mode 100644 index 0000000..b7b17f6 --- /dev/null +++ b/app/src/pages/health.rs @@ -0,0 +1,115 @@ +//! Cluster health summary. + +use chm_core::Health; + +use gpui::{Context, Render, Window, div, prelude::*, px}; + +use crate::pages::status; +use crate::widgets::geometry::format_duration_ms; +use crate::widgets::metric_card; + +pub struct HealthPage { + data: Option, + error: Option, +} + +impl Default for HealthPage { + fn default() -> Self { + Self::new() + } +} + +impl HealthPage { + pub fn new() -> Self { + Self { + data: None, + error: None, + } + } + + pub fn set(&mut self, data: Result, cx: &mut Context) { + match data { + Ok(v) => { + self.data = Some(v); + self.error = None; + } + Err(e) => self.error = Some(e), + } + cx.notify(); + } +} + +impl Render for HealthPage { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if let Some(err) = &self.error { + return status(format!("health unavailable: {err}"), cx).into_any_element(); + } + let Some(h) = &self.data else { + return crate::widgets::skeleton::metric_grid(cx).into_any_element(); + }; + let pool_pct = format!( + "{:.0}%", + (h.background_pool_utilization * 100.0).clamp(0.0, 999.0) + ); + let gap = px(crate::density::Density::current().card_gap()); + div() + .flex() + .flex_col() + .gap(gap) + .w_full() + .child( + div() + .flex() + .flex_row() + .gap(gap) + .child(metric_card( + "status", + if h.ok { "ok" } else { "not ok" }, + None, + cx, + )) + .child(metric_card( + "readonly tables", + &h.readonly_tables.to_string(), + None, + cx, + )) + .child(metric_card( + "replication lag", + &format_duration_ms(h.replication_lag_max_sec * 1000.0), + None, + cx, + )) + .child(metric_card( + "zookeeper", + if h.zookeeper_available { + "available" + } else { + "unavailable" + }, + None, + cx, + )), + ) + .child( + div() + .flex() + .flex_row() + .gap(gap) + .child(metric_card( + "delayed inserts", + &h.delayed_inserts.to_string(), + None, + cx, + )) + .child(metric_card( + "distributed files", + &h.distributed_files_to_insert.to_string(), + None, + cx, + )) + .child(metric_card("background pool", &pool_pct, None, cx)), + ) + .into_any_element() + } +} diff --git a/app/src/pages/merges.rs b/app/src/pages/merges.rs new file mode 100644 index 0000000..db709a6 --- /dev/null +++ b/app/src/pages/merges.rs @@ -0,0 +1,102 @@ +//! In-flight merges and mutations. + +use chm_core::MergeRow; + +use gpui::{Context, Render, Window, prelude::*}; + +use crate::pages::status; +use crate::widgets::{CellVal, Column, data_table}; + +pub struct MergesPage { + data: Option>, + error: Option, +} + +impl Default for MergesPage { + fn default() -> Self { + Self::new() + } +} + +impl MergesPage { + pub fn new() -> Self { + Self { + data: None, + error: None, + } + } + + pub fn set(&mut self, data: Result, String>, cx: &mut Context) { + match data { + Ok(v) => { + self.data = Some(v); + self.error = None; + } + Err(e) => self.error = Some(e), + } + cx.notify(); + } +} + +impl Render for MergesPage { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if let Some(err) = &self.error { + return status(format!("merges unavailable: {err}"), cx).into_any_element(); + } + let Some(rows) = &self.data else { + return crate::widgets::skeleton::table_block(cx).into_any_element(); + }; + if rows.is_empty() { + return status("no merges or mutations in flight", cx).into_any_element(); + } + let columns = vec![ + Column { + name: "database".into(), + width: Some(110.0), + }, + Column { + name: "table".into(), + width: Some(140.0), + }, + Column { + name: "type".into(), + width: Some(80.0), + }, + Column { + name: "progress".into(), + width: Some(80.0), + }, + Column { + name: "parts".into(), + width: Some(72.0), + }, + Column { + name: "memory".into(), + width: Some(88.0), + }, + Column { + name: "elapsed".into(), + width: None, + }, + ]; + let body = rows + .iter() + .map(|r| { + vec![ + CellVal::Text(r.database.clone()), + CellVal::Text(r.table.clone()), + CellVal::Text(if r.is_mutation { + "mutation".into() + } else { + "merge".into() + }), + CellVal::Text(format!("{:.0}%", (r.progress * 100.0).clamp(0.0, 100.0))), + CellVal::Num(r.num_parts as f64), + CellVal::Bytes(r.total_memory_bytes), + CellVal::DurMs(r.elapsed_sec * 1000.0), + ] + }) + .collect(); + data_table("merges", columns, body, cx).into_any_element() + } +} diff --git a/app/src/pages/mod.rs b/app/src/pages/mod.rs index 706f01b..8afd11b 100644 --- a/app/src/pages/mod.rs +++ b/app/src/pages/mod.rs @@ -1,9 +1,16 @@ -//! Page routing. AGENT D owns mod.rs; Agents F/G/H own the individual page -//! files and will replace the placeholder bodies in shell.rs's `content`. +//! Page routing. -use bezel::gpui::{AnyElement, SharedString, div, prelude::*, px}; +use gpui::{App, FontWeight, SharedString, div, prelude::*}; +use gpui_component::{ActiveTheme as _, IconName}; +pub mod health; +pub mod merges; pub mod overview; +pub mod queries; +pub mod replicas; +pub mod settings; +pub mod tables; +pub mod traffic; /// Every sidebar destination, in keyboard-shortcut order (1-8). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -16,6 +23,7 @@ pub enum Page { Tables, Traffic, Connect, + Settings, } impl Page { @@ -45,27 +53,80 @@ impl Page { Page::Tables => "Tables", Page::Traffic => "Traffic", Page::Connect => "Connect", + Page::Settings => "Settings", } } - /// Sidebar glyph. Text markers until Agent E's icon widget lands; the - /// sidebar renders whatever this returns, so swapping in real icons is a - /// one-file change. - pub fn icon(self) -> AnyElement { - let glyph: &str = match self { - Page::Overview => "◧", - Page::Queries => "⌕", - Page::Merges => "⇄", - Page::Replicas => "⑃", - Page::Health => "♥", - Page::Tables => "▤", - Page::Traffic => "↕", - Page::Connect => "⌁", - }; - div() - .w(px(16.0)) - .text_size(px(13.0)) - .child(SharedString::from(glyph.to_string())) - .into_any_element() + /// Pages whose fetch takes the selected time range. + pub fn uses_range(self) -> bool { + matches!(self, Page::Overview | Page::Queries | Page::Traffic) + } + + /// ClickHouse-only pages are hidden on a Postgres host. + pub fn available(self, engine: chm_core::SourceEngine) -> bool { + match engine { + chm_core::SourceEngine::Postgres => !matches!(self, Page::Merges | Page::Traffic), + _ => true, + } + } + + pub fn icon(self) -> IconName { + match self { + Page::Overview => IconName::LayoutDashboard, + Page::Queries => IconName::Search, + Page::Merges => IconName::Replace, + Page::Replicas => IconName::Copy, + Page::Health => IconName::Heart, + Page::Tables => IconName::File, + Page::Traffic => IconName::ChartPie, + Page::Connect => IconName::Globe, + Page::Settings => IconName::Settings, + } + } +} + +pub(crate) fn status(text: impl Into, cx: &App) -> gpui::Div { + div() + .flex() + .flex_1() + .items_center() + .justify_center() + .text_color(cx.theme().muted_foreground) + .text_sm() + .child(text.into()) +} + +pub(crate) fn heading(title: &str, cx: &App) -> gpui::Div { + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(title.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_pages_have_stable_indexes_and_titles() { + assert_eq!(Page::ALL.len(), 8); + for (i, page) in Page::ALL.iter().enumerate() { + assert_eq!(page.index(), i); + assert!(!page.title().is_empty()); + } + assert_eq!(Page::Overview.title(), "Overview"); + assert_eq!(Page::Connect.title(), "Connect"); + assert_eq!(Page::Connect.index(), 7); + assert!(Page::Overview.uses_range()); + assert!(Page::Queries.uses_range()); + assert!(Page::Traffic.uses_range()); + assert!(!Page::Merges.uses_range()); + assert!(!Page::Connect.uses_range()); + assert_eq!(Page::Settings.title(), "Settings"); + assert!(!Page::ALL.contains(&Page::Settings)); + assert!(!Page::Merges.available(chm_core::SourceEngine::Postgres)); + assert!(Page::Queries.available(chm_core::SourceEngine::Postgres)); + assert!(Page::Merges.available(chm_core::SourceEngine::ClickHouse)); } } diff --git a/app/src/pages/overview.rs b/app/src/pages/overview.rs index a4c7fef..414ead0 100644 --- a/app/src/pages/overview.rs +++ b/app/src/pages/overview.rs @@ -1,14 +1,20 @@ -//! Overview page — placeholder-level metric card grid. -//! AGENT D owns this stub so smoke shots have content; Agents F/G/H replace -//! pages/ wholesale later. Renders whatever `Shell` fetched (mock data under -//! CHM_SMOKE=1). +//! Overview page — the handful of metrics that answer "is this cluster +//! healthy?", plus an optional queries/sec sparkline. Visible tiles and +//! density come from `[ui]` (Settings). -use chm_core::Overview; +use chm_core::{Overview, TrafficSeries}; -use bezel::gpui::{Context, Render, div, prelude::*, px}; +use gpui::{Context, Render, Window, div, prelude::*, px}; + +use crate::config::load_config; +use crate::density::{Density, OverviewMetric, visible_metrics}; +use crate::pages::status; +use crate::widgets::geometry::{format_bytes, format_count}; +use crate::widgets::{NamedSeries, kpi_card, line_chart}; pub struct OverviewPage { data: Option, + traffic: Option, error: Option, } @@ -22,11 +28,17 @@ impl OverviewPage { pub fn new() -> Self { Self { data: None, + traffic: None, error: None, } } - pub fn set_overview(&mut self, data: Result, cx: &mut Context) { + pub fn set_overview( + &mut self, + data: Result, + traffic: Result, + cx: &mut Context, + ) { match data { Ok(o) => { self.data = Some(o); @@ -34,136 +46,195 @@ impl OverviewPage { } Err(e) => self.error = Some(e), } + if let Ok(t) = traffic { + self.traffic = Some(t); + } cx.notify(); } - - fn card(label: &'static str, value: String) -> bezel::gpui::Div { - div() - .w(px(180.0)) - .flex() - .flex_col() - .gap(px(2.0)) - .p(px(12.0)) - .rounded(px(8.0)) - .border_1() - .border_color(bezel::theme::ink(0.10)) - .bg(bezel::theme::ink(0.03)) - .child( - div() - .text_size(px(11.0)) - .text_color(bezel::theme::ink(0.55)) - .child(label), - ) - .child(div().text_size(px(17.0)).child(value)) - } } impl Render for OverviewPage { - fn render( - &mut self, - _window: &mut bezel::gpui::Window, - cx: &mut Context, - ) -> impl bezel::gpui::IntoElement { - let _ = cx; + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { if let Some(err) = &self.error { - return div() - .flex() - .flex_1() - .items_center() - .justify_center() - .text_color(bezel::theme::ink(0.55)) - .text_size(px(13.0)) - .child(format!("overview unavailable: {err}")); + return status(format!("overview unavailable: {err}"), cx).into_any_element(); } let Some(o) = &self.data else { - return div() - .flex() - .flex_1() - .items_center() - .justify_center() - .text_color(bezel::theme::ink(0.45)) - .text_size(px(13.0)) - .child("loading overview…"); + return crate::widgets::skeleton::metric_grid(cx).into_any_element(); }; - let rows: [[(&'static str, String); 4]; 3] = [ - [ - ("queries / sec", format!("{:.1}", o.qps)), - ("running", o.running_queries.to_string()), - ("slow · 24h", o.slow_queries_24h.to_string()), - ("failed · 24h", o.failed_queries_24h.to_string()), - ], - [ - ("active merges", o.active_merges.to_string()), - ( - "replicas", - format!("{} / {}", o.replicas_ok, o.replicas_total), - ), - ("tables", o.tables_total.to_string()), - ("parts", fmt_u64(o.parts_total)), - ], - [ - ("disk used", fmt_bytes(o.disk_used_bytes)), - ( - "disk total", - format!( - "{} ({:.0}% used)", - fmt_bytes(o.disk_total_bytes), - 100.0 * o.disk_used_bytes as f64 / o.disk_total_bytes.max(1) as f64 - ), - ), - ("uptime", fmt_duration(o.uptime_seconds)), - ("version", o.clickhouse_version.clone()), - ], - ]; + let ui = load_config().ui; + let density = Density::from_cfg(ui.density.as_deref()); + let metrics = visible_metrics(&ui.overview_metrics); + let gap = px(density.card_gap()); + let per_row = density.metrics_per_row(); - let mut grid = div().flex().flex_col().gap(px(10.0)); - for row in rows { - let mut line = div().flex().flex_row().gap(px(10.0)); - for (label, value) in row { - line = line.child(Self::card(label, value)); + let mut grid = div().flex().flex_col().gap(gap).w_full(); + for chunk in metrics.chunks(per_row) { + let mut row = div().flex().flex_row().gap(gap); + for metric in chunk { + row = row.child(tile(*metric, o, cx)); } - grid = grid.child(line); + grid = grid.child(row); } - grid - } -} -/// Thousands separators, no external crate. -fn fmt_u64(n: u64) -> String { - let s = n.to_string(); - let bytes = s.as_bytes(); - let mut out = String::with_capacity(s.len() + s.len() / 3); - for (i, b) in bytes.iter().enumerate() { - if i > 0 && (bytes.len() - i).is_multiple_of(3) { - out.push('\''); + if ui.show_chart + && let Some(t) = &self.traffic + && !t.queries_per_sec.is_empty() + { + grid = grid.child( + div() + .flex() + .flex_col() + .w_full() + .h(px(density.chart_h())) + .child(line_chart( + "queries / sec", + "qps", + vec![NamedSeries { + name: "qps".into(), + points: t.queries_per_sec.clone(), + accent: true, + }], + cx, + )), + ); } - out.push(*b as char); + grid.into_any_element() } - out } -fn fmt_bytes(n: u64) -> String { - const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; - let mut v = n as f64; - let mut u = 0; - while v >= 1024.0 && u < UNITS.len() - 1 { - v /= 1024.0; - u += 1; - } - if u == 0 { - format!("{n} B") - } else { - format!("{v:.1} {}", UNITS[u]) +fn tile(metric: OverviewMetric, o: &Overview, cx: &gpui::App) -> impl gpui::IntoElement { + match metric { + OverviewMetric::Running => kpi_card( + "Active Queries", + o.running_queries.to_string(), + Some("running"), + Some(format!( + "{} queries today", + format_count(o.queries_today as f64) + )), + None, + cx, + ), + OverviewMetric::Schema => { + let unit = if o.databases_total == 1 { + "database" + } else { + "databases" + }; + let tables = if o.tables_total == 1 { + "1 table".into() + } else { + format!("{} tables", format_count(o.tables_total as f64)) + }; + kpi_card( + "Schema", + o.databases_total.to_string(), + Some(unit), + Some(tables), + None, + cx, + ) + } + OverviewMetric::Disk => { + let used_pct = 100.0 * o.disk_used_bytes as f64 / o.disk_total_bytes.max(1) as f64; + let free = o.disk_total_bytes.saturating_sub(o.disk_used_bytes); + kpi_card( + "Storage", + format_bytes(o.disk_used_bytes), + None, + Some(format!( + "{:.0}% of {} · {} free", + used_pct, + format_bytes(o.disk_total_bytes), + format_bytes(free) + )), + Some(used_pct as f32), + cx, + ) + } + OverviewMetric::Uptime => kpi_card( + "Uptime", + fmt_uptime(o.uptime_seconds), + None, + Some(o.clickhouse_version.clone()), + None, + cx, + ), + OverviewMetric::Qps => kpi_card( + "queries / sec", + format!("{:.1}", o.qps), + None, + None, + None, + cx, + ), + OverviewMetric::Slow => kpi_card( + "slow · 24h", + o.slow_queries_24h.to_string(), + None, + None, + None, + cx, + ), + OverviewMetric::Failed => kpi_card( + "failed · 24h", + o.failed_queries_24h.to_string(), + None, + None, + None, + cx, + ), + OverviewMetric::Merges => kpi_card( + "active merges", + o.active_merges.to_string(), + None, + None, + None, + cx, + ), + OverviewMetric::Replicas => kpi_card( + "replicas", + format!("{} / {}", o.replicas_ok, o.replicas_total), + None, + None, + None, + cx, + ), + OverviewMetric::Tables => kpi_card( + "tables", + format_count(o.tables_total as f64), + None, + None, + None, + cx, + ), + OverviewMetric::Parts => kpi_card( + "parts", + format_count(o.parts_total as f64), + None, + None, + None, + cx, + ), + OverviewMetric::Version => kpi_card( + "version", + o.clickhouse_version.clone(), + None, + None, + None, + cx, + ), } } -fn fmt_duration(secs: u64) -> String { +fn fmt_uptime(secs: u64) -> String { let d = secs / 86_400; let h = (secs % 86_400) / 3_600; match (d, h) { (0, 0) => format!("{}m", secs / 60), (0, h) => format!("{h}h"), + (d, 0) => format!("{d}d"), (d, h) => format!("{d}d {h}h"), } } diff --git a/app/src/pages/queries.rs b/app/src/pages/queries.rs new file mode 100644 index 0000000..9d4b0c1 --- /dev/null +++ b/app/src/pages/queries.rs @@ -0,0 +1,163 @@ +//! Running / slow / failed query lists. + +use chm_core::QueryRow; + +use gpui::{AnyElement, App, Context, Render, Window, div, prelude::*, px}; +use gpui_component::ActiveTheme as _; + +use crate::pages::{heading, status}; +use crate::widgets::{CellVal, Column, data_table}; + +pub struct QueriesPage { + running: Option>, + slow: Option>, + failed: Option>, + error: Option, +} + +impl Default for QueriesPage { + fn default() -> Self { + Self::new() + } +} + +impl QueriesPage { + pub fn new() -> Self { + Self { + running: None, + slow: None, + failed: None, + error: None, + } + } + + pub fn set( + &mut self, + running: Result, String>, + slow: Result, String>, + failed: Result, String>, + cx: &mut Context, + ) { + let mut errors = Vec::new(); + match running { + Ok(v) => self.running = Some(v), + Err(e) => errors.push(format!("running: {e}")), + } + match slow { + Ok(v) => self.slow = Some(v), + Err(e) => errors.push(format!("slow: {e}")), + } + match failed { + Ok(v) => self.failed = Some(v), + Err(e) => errors.push(format!("failed: {e}")), + } + self.error = if errors.is_empty() { + None + } else { + Some(errors.join(" · ")) + }; + cx.notify(); + } +} + +fn query_columns(with_exception: bool) -> Vec { + let mut cols = vec![ + Column { + name: "user".into(), + width: Some(96.0), + }, + Column { + name: "elapsed".into(), + width: Some(80.0), + }, + Column { + name: "memory".into(), + width: Some(80.0), + }, + Column { + name: "rows".into(), + width: Some(72.0), + }, + ]; + if with_exception { + cols.push(Column { + name: "exception".into(), + width: Some(220.0), + }); + } + cols.push(Column { + name: "query".into(), + width: None, + }); + cols +} + +fn query_rows(rows: &[QueryRow], with_exception: bool) -> Vec> { + rows.iter() + .map(|r| { + let mut cells = vec![ + CellVal::Text(r.user.clone()), + CellVal::DurMs(r.elapsed_ms), + CellVal::Bytes(r.memory_bytes), + CellVal::Num(r.read_rows as f64), + ]; + if with_exception { + cells.push(CellVal::Text( + r.exception.clone().unwrap_or_else(|| "—".into()), + )); + } + cells.push(CellVal::Text(r.normalized_sql.clone())); + cells + }) + .collect() +} + +fn section( + title: &str, + rows: Option<&Vec>, + with_exception: bool, + cx: &App, +) -> AnyElement { + let body: AnyElement = match rows { + None => status("loading…", cx).into_any_element(), + Some(rows) if rows.is_empty() => status("none", cx).into_any_element(), + Some(rows) => data_table( + format!("queries-{title}"), + query_columns(with_exception), + query_rows(rows, with_exception), + cx, + ) + .into_any_element(), + }; + div() + .flex() + .flex_col() + .gap(px(6.)) + .child(heading(title, cx)) + .child(body) + .into_any_element() +} + +impl Render for QueriesPage { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.running.is_none() && self.slow.is_none() && self.failed.is_none() { + if let Some(err) = &self.error { + return status(format!("queries unavailable: {err}"), cx).into_any_element(); + } + return crate::widgets::skeleton::table_block(cx).into_any_element(); + } + let mut col = div().flex().flex_col().gap(px(16.)).w_full(); + if let Some(err) = &self.error { + col = col.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("partial: {err}")), + ); + } + col.child(section("Running", self.running.as_ref(), false, cx)) + .child(section("Slow", self.slow.as_ref(), false, cx)) + .child(section("Failed", self.failed.as_ref(), true, cx)) + .into_any_element() + } +} diff --git a/app/src/pages/replicas.rs b/app/src/pages/replicas.rs new file mode 100644 index 0000000..de86c2f --- /dev/null +++ b/app/src/pages/replicas.rs @@ -0,0 +1,114 @@ +//! Replica health table. + +use chm_core::ReplicaRow; + +use gpui::{Context, Render, Window, prelude::*}; + +use crate::pages::status; +use crate::widgets::{CellVal, Column, data_table}; + +pub struct ReplicasPage { + data: Option>, + error: Option, +} + +impl Default for ReplicasPage { + fn default() -> Self { + Self::new() + } +} + +impl ReplicasPage { + pub fn new() -> Self { + Self { + data: None, + error: None, + } + } + + pub fn set(&mut self, data: Result, String>, cx: &mut Context) { + match data { + Ok(v) => { + self.data = Some(v); + self.error = None; + } + Err(e) => self.error = Some(e), + } + cx.notify(); + } +} + +impl Render for ReplicasPage { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if let Some(err) = &self.error { + return status(format!("replicas unavailable: {err}"), cx).into_any_element(); + } + let Some(rows) = &self.data else { + return crate::widgets::skeleton::table_block(cx).into_any_element(); + }; + if rows.is_empty() { + return status("no replicas", cx).into_any_element(); + } + let columns = vec![ + Column { + name: "replica".into(), + width: Some(110.0), + }, + Column { + name: "database".into(), + width: Some(110.0), + }, + Column { + name: "table".into(), + width: Some(140.0), + }, + Column { + name: "state".into(), + width: Some(90.0), + }, + Column { + name: "delay".into(), + width: Some(80.0), + }, + Column { + name: "queue".into(), + width: Some(64.0), + }, + Column { + name: "inserts".into(), + width: Some(72.0), + }, + Column { + name: "merges".into(), + width: Some(72.0), + }, + ]; + let body = rows + .iter() + .map(|r| { + let state = if r.is_session_expired { + "expired" + } else if r.is_readonly { + "readonly" + } else { + "ok" + }; + vec![ + CellVal::Text(r.replica_name.clone()), + CellVal::Text(r.database.clone()), + CellVal::Text(r.table.clone()), + CellVal::Text(state.into()), + CellVal::Text(if r.absolute_delay_sec < 0.001 { + "0s".into() + } else { + crate::widgets::geometry::format_duration_ms(r.absolute_delay_sec * 1000.0) + }), + CellVal::Num(r.queue_size as f64), + CellVal::Num(r.inserts_in_queue as f64), + CellVal::Num(r.merges_in_queue as f64), + ] + }) + .collect(); + data_table("replicas", columns, body, cx).into_any_element() + } +} diff --git a/app/src/pages/settings.rs b/app/src/pages/settings.rs new file mode 100644 index 0000000..1fd5e4e --- /dev/null +++ b/app/src/pages/settings.rs @@ -0,0 +1,542 @@ +//! Settings page — appearance, update channel, telemetry. Opened from the +//! app menu (`cmd-,`) or the sidebar footer. Writes `[ui]` / `[telemetry]` +//! / `profile.channel` in config.toml. + +use chm_update::Channel; +use gpui::{App, Context, Render, Window, div, prelude::*, px}; +use gpui_component::{ + ActiveTheme as _, Sizable as _, Theme, ThemeMode, checkbox::Checkbox, h_flex, v_flex, +}; + +use crate::config::{config_path, load_config, save_config}; +use crate::density::{Density, OverviewMetric, default_metric_ids}; +use crate::pages::heading; +use crate::widgets::controls::{choice_radio, radio_group, theme_switch}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Appearance { + System, + Light, + Dark, +} + +impl Appearance { + const ALL: [Appearance; 3] = [Appearance::System, Appearance::Light, Appearance::Dark]; + + fn label(self) -> &'static str { + match self { + Self::System => "System", + Self::Light => "Light", + Self::Dark => "Dark", + } + } + + fn hint(self) -> &'static str { + match self { + Self::System => "follow macOS light/dark", + Self::Light => "always light", + Self::Dark => "always dark", + } + } +} + +pub struct SettingsPage { + appearance: Appearance, + density: Density, + overview_metrics: Vec, + show_chart: bool, + compact_sidebar: bool, + show_perf: bool, + channel: Channel, + telemetry: bool, + update_enabled: bool, + auto_download: bool, + status: Option, +} + +impl Default for SettingsPage { + fn default() -> Self { + Self::new() + } +} + +impl SettingsPage { + pub fn new() -> Self { + let cfg = load_config(); + let overview_metrics = if cfg.ui.overview_metrics.is_empty() { + default_metric_ids() + } else { + cfg.ui.overview_metrics.clone() + }; + Self { + appearance: appearance_from_cfg(cfg.ui.appearance.as_deref()), + density: Density::from_cfg(cfg.ui.density.as_deref()), + overview_metrics, + show_chart: cfg.ui.show_chart, + compact_sidebar: cfg.ui.compact_sidebar, + show_perf: cfg.ui.show_perf, + channel: channel_from_cfg(cfg.profile.channel.as_deref()), + telemetry: cfg.telemetry.enabled, + update_enabled: cfg.update.enabled, + auto_download: cfg.update.auto_download, + status: None, + } + } + + fn persist(&mut self) { + let mut cfg = load_config(); + cfg.ui.appearance = Some(appearance_to_cfg(self.appearance).into()); + cfg.ui.density = Some(self.density.as_str().into()); + cfg.ui.overview_metrics = self.overview_metrics.clone(); + cfg.ui.show_chart = self.show_chart; + cfg.ui.compact_sidebar = self.compact_sidebar; + cfg.ui.show_perf = self.show_perf; + cfg.profile.channel = Some(self.channel.as_str().into()); + cfg.telemetry.enabled = self.telemetry; + cfg.update.enabled = self.update_enabled; + cfg.update.auto_download = self.auto_download; + self.status = save_config(&cfg).err(); + } + + fn set_appearance(&mut self, mode: Appearance, window: &mut Window, cx: &mut Context) { + self.appearance = mode; + apply_appearance(mode, window, cx); + self.persist(); + cx.notify(); + } + + fn set_channel(&mut self, channel: Channel, cx: &mut Context) { + self.channel = channel; + self.persist(); + cx.notify(); + } + + fn set_telemetry(&mut self, enabled: bool, cx: &mut Context) { + self.telemetry = enabled; + self.persist(); + cx.notify(); + } + + fn set_update_enabled(&mut self, enabled: bool, cx: &mut Context) { + self.update_enabled = enabled; + self.persist(); + cx.notify(); + } + + fn set_auto_download(&mut self, enabled: bool, cx: &mut Context) { + self.auto_download = enabled; + self.persist(); + cx.notify(); + } + + fn set_density(&mut self, density: Density, window: &mut Window, cx: &mut Context) { + self.density = density; + self.persist(); + crate::theme::apply_brand(cx); + window.refresh(); + cx.notify(); + } + + fn set_metric(&mut self, metric: OverviewMetric, on: bool, cx: &mut Context) { + let id = metric.id(); + if on { + if !self.overview_metrics.iter().any(|s| s == id) { + self.overview_metrics.push(id.into()); + } + } else { + self.overview_metrics.retain(|s| s != id); + if self.overview_metrics.is_empty() { + self.overview_metrics = default_metric_ids(); + } + } + self.persist(); + cx.notify(); + } + + fn set_show_chart(&mut self, enabled: bool, cx: &mut Context) { + self.show_chart = enabled; + self.persist(); + cx.notify(); + } + + fn set_compact_sidebar(&mut self, enabled: bool, cx: &mut Context) { + self.compact_sidebar = enabled; + self.persist(); + cx.notify(); + } + + fn set_show_perf(&mut self, enabled: bool, cx: &mut Context) { + self.show_perf = enabled; + self.persist(); + cx.notify(); + } + + fn metric_on(&self, metric: OverviewMetric) -> bool { + self.overview_metrics.iter().any(|s| s == metric.id()) + } +} + +impl Render for SettingsPage { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let path = config_path() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "(no config directory)".into()); + let appearance = self.appearance; + let density = self.density; + let show_chart = self.show_chart; + let compact_sidebar = self.compact_sidebar; + let show_perf = self.show_perf; + let channel = self.channel; + let telemetry = self.telemetry; + let update_enabled = self.update_enabled; + let auto_download = self.auto_download; + + v_flex() + .gap_5() + .max_w(px(520.)) + .child(heading("Appearance", cx)) + .child({ + let entity = cx.entity().downgrade(); + let mut group = radio_group("appearance"); + for &mode in &Appearance::ALL { + let entity = entity.clone(); + group = group.child( + choice_radio( + format!("app-{}", appearance_to_cfg(mode)), + appearance == mode, + mode.label(), + mode.hint(), + cx, + ) + .on_change(move |next, _, window, cx| { + if next { + let _ = entity.update(cx, |this, cx| { + this.set_appearance(mode, window, cx); + }); + } + }), + ); + } + group + }) + .child(heading("Density", cx)) + .child({ + let entity = cx.entity().downgrade(); + let mut group = radio_group("density"); + for &mode in &Density::ALL { + let entity = entity.clone(); + group = group.child( + choice_radio( + format!("den-{}", mode.as_str()), + density == mode, + mode.label(), + mode.hint(), + cx, + ) + .on_change(move |next, _, window, cx| { + if next { + let _ = entity.update(cx, |this, cx| { + this.set_density(mode, window, cx); + }); + } + }), + ); + } + group + }) + .child(heading("Overview metrics", cx)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("tiles on Overview — defaults to Active Queries, Schema, Storage, Uptime"), + ) + .child({ + let entity = cx.entity().downgrade(); + let mut cols = h_flex().gap_4().items_start(); + for column in OverviewMetric::ALL.chunks(6) { + let mut col = v_flex().gap_2().flex_1(); + for &metric in column { + let on = self.metric_on(metric); + let entity = entity.clone(); + col = col.child( + Checkbox::new(format!("m-{}", metric.id())) + .label(metric.label()) + .checked(on) + .small() + .on_click(move |next, _, cx| { + let on = *next; + let _ = entity.update(cx, |this, cx| { + this.set_metric(metric, on, cx); + }); + }), + ); + } + cols = cols.child(col); + } + cols + }) + .child({ + let entity = cx.entity().downgrade(); + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child(div().text_sm().child("Queries / sec chart")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("sparkline under the metric tiles"), + ), + ) + .child(theme_switch("show-chart", show_chart, cx).on_click( + move |next, _, cx| { + let on = *next; + let _ = entity.update(cx, |this, cx| this.set_show_chart(on, cx)); + }, + )) + }) + .child({ + let entity = cx.entity().downgrade(); + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child(div().text_sm().child("Compact sidebar")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("start with the icon strip (⌘B still toggles)"), + ), + ) + .child(theme_switch("compact-sidebar", compact_sidebar, cx).on_click( + move |next, _, cx| { + let on = *next; + let _ = + entity.update(cx, |this, cx| this.set_compact_sidebar(on, cx)); + }, + )) + }) + .child({ + let entity = cx.entity().downgrade(); + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child(div().text_sm().child("Status bar timing")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("fetch latency and memory in the status bar"), + ), + ) + .child(theme_switch("show-perf", show_perf, cx).on_click( + move |next, _, cx| { + let on = *next; + let _ = entity.update(cx, |this, cx| this.set_show_perf(on, cx)); + }, + )) + }) + .child(heading("Updates", cx)) + .child({ + let entity = cx.entity().downgrade(); + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child(div().text_sm().child("Check on launch")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("fetch the channel manifest from updates.chmonitor.dev"), + ), + ) + .child(theme_switch("upd-enabled", update_enabled, cx).on_click( + move |next, _, cx| { + let on = *next; + let _ = entity.update(cx, |this, cx| this.set_update_enabled(on, cx)); + }, + )) + }) + .child({ + let entity = cx.entity().downgrade(); + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child(div().text_sm().child("Download automatically")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("save the archive when a newer build is found"), + ), + ) + .child(theme_switch("upd-auto", auto_download, cx).on_click( + move |next, _, cx| { + let on = *next; + let _ = entity.update(cx, |this, cx| this.set_auto_download(on, cx)); + }, + )) + }) + .child({ + let entity = cx.entity().downgrade(); + radio_group("channel") + .child( + choice_radio( + "ch-stable", + channel == Channel::Stable, + "Stable", + "tagged releases", + cx, + ) + .on_change({ + let entity = entity.clone(); + move |next, _, _, cx| { + if next { + let _ = entity.update(cx, |this, cx| { + this.set_channel(Channel::Stable, cx); + }); + } + } + }), + ) + .child( + choice_radio( + "ch-beta", + channel == Channel::Beta, + "Beta", + "pre-release builds", + cx, + ) + .on_change(move |next, _, _, cx| { + if next { + let _ = entity.update(cx, |this, cx| { + this.set_channel(Channel::Beta, cx); + }); + } + }), + ) + }) + .child(heading("Telemetry", cx)) + .child({ + let entity = cx.entity().downgrade(); + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child(div().text_sm().child("Anonymous usage")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child( + "install ping + page views to telemetry.chmonitor.dev; no SQL or hostnames", + ), + ), + ) + .child(theme_switch("telemetry", telemetry, cx).on_click( + move |next, _, cx| { + let on = *next; + let _ = entity.update(cx, |this, cx| this.set_telemetry(on, cx)); + }, + )) + }) + .child(heading("Shortcuts", cx)) + .child( + v_flex() + .gap_1() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("1–8 switch page") + .child("r refresh") + .child("⌘B toggle sidebar") + .child("⌘, settings") + .child("⌘Q quit"), + ) + .child(heading("Config file", cx)) + .child( + v_flex() + .gap_1() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(format!("chmonitor {}", env!("CARGO_PKG_VERSION"))) + .child(path), + ) + .children(self.status.as_ref().map(|e| { + div() + .text_sm() + .text_color(cx.theme().danger) + .child(e.clone()) + })) + } +} + +pub fn apply_appearance(mode: Appearance, window: &mut Window, cx: &mut App) { + match mode { + Appearance::Light => Theme::change(ThemeMode::Light, Some(window), cx), + Appearance::Dark => Theme::change(ThemeMode::Dark, Some(window), cx), + Appearance::System => Theme::sync_system_appearance(Some(window), cx), + } + crate::theme::apply_brand(cx); +} + +pub fn appearance_from_cfg(s: Option<&str>) -> Appearance { + match s.map(|s| s.to_ascii_lowercase()).as_deref() { + Some("light") => Appearance::Light, + Some("dark") => Appearance::Dark, + _ => Appearance::System, + } +} + +pub(crate) fn appearance_to_cfg(mode: Appearance) -> &'static str { + match mode { + Appearance::System => "system", + Appearance::Light => "light", + Appearance::Dark => "dark", + } +} + +fn channel_from_cfg(s: Option<&str>) -> Channel { + match s.map(|s| s.to_ascii_lowercase()).as_deref() { + Some("beta") => Channel::Beta, + _ => Channel::Stable, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn appearance_and_channel_parse() { + assert_eq!(appearance_from_cfg(None), Appearance::System); + assert_eq!(appearance_from_cfg(Some("DARK")), Appearance::Dark); + assert_eq!(appearance_from_cfg(Some("light")), Appearance::Light); + assert_eq!(channel_from_cfg(Some("beta")), Channel::Beta); + assert_eq!(channel_from_cfg(None), Channel::Stable); + assert_eq!(appearance_to_cfg(Appearance::Dark), "dark"); + } +} diff --git a/app/src/pages/tables.rs b/app/src/pages/tables.rs new file mode 100644 index 0000000..f21d164 --- /dev/null +++ b/app/src/pages/tables.rs @@ -0,0 +1,107 @@ +//! Table stats. + +use chm_core::TableStat; + +use gpui::{Context, Render, Window, prelude::*}; + +use crate::pages::status; +use crate::widgets::{CellVal, Column, data_table}; + +pub struct TablesPage { + data: Option>, + error: Option, +} + +impl Default for TablesPage { + fn default() -> Self { + Self::new() + } +} + +impl TablesPage { + pub fn new() -> Self { + Self { + data: None, + error: None, + } + } + + pub fn set(&mut self, data: Result, String>, cx: &mut Context) { + match data { + Ok(v) => { + self.data = Some(v); + self.error = None; + } + Err(e) => self.error = Some(e), + } + cx.notify(); + } +} + +impl Render for TablesPage { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if let Some(err) = &self.error { + return status(format!("tables unavailable: {err}"), cx).into_any_element(); + } + let Some(rows) = &self.data else { + return crate::widgets::skeleton::table_block(cx).into_any_element(); + }; + if rows.is_empty() { + return status("no tables", cx).into_any_element(); + } + let columns = vec![ + Column { + name: "database".into(), + width: Some(110.0), + }, + Column { + name: "table".into(), + width: Some(160.0), + }, + Column { + name: "engine".into(), + width: Some(160.0), + }, + Column { + name: "parts".into(), + width: Some(72.0), + }, + Column { + name: "rows".into(), + width: Some(88.0), + }, + Column { + name: "size".into(), + width: Some(88.0), + }, + Column { + name: "ratio".into(), + width: Some(64.0), + }, + Column { + name: "modified".into(), + width: None, + }, + ]; + let body = rows + .iter() + .map(|r| { + let modified = r + .last_modified + .map(|t| t.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "—".into()); + vec![ + CellVal::Text(r.database.clone()), + CellVal::Text(r.name.clone()), + CellVal::Text(r.engine.clone()), + CellVal::Num(r.parts as f64), + CellVal::Num(r.rows as f64), + CellVal::Bytes(r.bytes_on_disk), + CellVal::Text(format!("{:.1}×", r.compressed_ratio)), + CellVal::Text(modified), + ] + }) + .collect(); + data_table("tables", columns, body, cx).into_any_element() + } +} diff --git a/app/src/pages/traffic.rs b/app/src/pages/traffic.rs new file mode 100644 index 0000000..87a2fbb --- /dev/null +++ b/app/src/pages/traffic.rs @@ -0,0 +1,98 @@ +//! Traffic charts (qps, rows, network). + +use chm_core::TrafficSeries; + +use gpui::{App, Context, Render, Window, div, prelude::*, px}; + +use crate::pages::status; +use crate::widgets::{NamedSeries, line_chart}; + +pub struct TrafficPage { + data: Option, + error: Option, +} + +impl Default for TrafficPage { + fn default() -> Self { + Self::new() + } +} + +impl TrafficPage { + pub fn new() -> Self { + Self { + data: None, + error: None, + } + } + + pub fn set(&mut self, data: Result, cx: &mut Context) { + match data { + Ok(v) => { + self.data = Some(v); + self.error = None; + } + Err(e) => self.error = Some(e), + } + cx.notify(); + } +} + +fn chart(title: &str, unit: &str, points: &[chm_core::SeriesPoint], cx: &App) -> gpui::Div { + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .h(px(200.)) + .child(line_chart( + title, + unit, + vec![NamedSeries { + name: title.into(), + points: points.to_vec(), + accent: true, + }], + cx, + )) +} + +impl Render for TrafficPage { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if let Some(err) = &self.error { + return status(format!("traffic unavailable: {err}"), cx).into_any_element(); + } + let Some(t) = &self.data else { + return crate::widgets::skeleton::chart_block(cx).into_any_element(); + }; + if t.queries_per_sec.is_empty() + && t.rows_read_per_sec.is_empty() + && t.network_rx_bps.is_empty() + && t.network_tx_bps.is_empty() + { + return status("no traffic in this range", cx).into_any_element(); + } + div() + .flex() + .flex_col() + .gap(px(10.)) + .w_full() + .child( + div() + .flex() + .flex_row() + .gap(px(10.)) + .child(chart("queries / sec", "qps", &t.queries_per_sec, cx)) + .child(chart("rows read / sec", "rows/s", &t.rows_read_per_sec, cx)), + ) + .child( + div() + .flex() + .flex_row() + .gap(px(10.)) + .child(chart("network in", "bit/s", &t.network_rx_bps, cx)) + .child(chart("network out", "bit/s", &t.network_tx_bps, cx)), + ) + .into_any_element() + } +} diff --git a/app/src/shell.rs b/app/src/shell.rs index becb65f..0596f36 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -1,120 +1,85 @@ //! App shell — window layout, sidebar nav, page routing, status bar, //! 30-second poll loop and the startup update-check hook. -//! AGENT D OWNS THIS FILE. -//! -//! All gpui types come through `bezel::gpui`; widgets come from the bezel -//! facade (`bezel::theme`, `bezel::ui`). -use std::sync::{Arc, OnceLock}; +use std::sync::{Arc, LazyLock, OnceLock}; use std::time::{Duration, Instant}; -use chm_clickhouse::ClickHouseClient; -use chm_cloud_api::CloudClient; -use chm_core::{DataSource, MockDataSource}; +use chm_core::{ + DataSource, Health, MergeRow, MockDataSource, Overview, QueryRow, ReplicaRow, SourceEngine, + TableStat, TimeRange, TrafficSeries, +}; -use bezel::gpui::{ - App, AppContext as _, AsyncApp, Context, Entity, FocusHandle, Focusable, Hsla, KeyBinding, - KeyDownEvent, Render, SharedString, WeakEntity, Window, actions, div, prelude::*, px, +use gpui::{ + App, AppContext as _, AsyncApp, Context, Entity, FocusHandle, Focusable, FontWeight, Hsla, + Image, ImageFormat, ImageSource, KeyBinding, KeyDownEvent, MouseButton, Render, SharedString, + WeakEntity, Window, actions, div, img, prelude::*, px, relative, +}; +use gpui_component::{ + ActiveTheme as _, Icon, IconName, Root, Selectable as _, Sizable as _, TitleBar, + button::{Button, ButtonVariants as _}, + h_flex, h_resizable, + menu::{DropdownMenu as _, PopupMenuItem}, + resizable_panel, + separator::Separator, + sidebar::{ + Sidebar, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarToggleButton, + }, + spinner::Spinner, + status_bar::StatusBar, + v_flex, }; -use bezel::theme::Theme; -use bezel::ui::widgets::status_dot; +use crate::cache; +use crate::config::{ + ConfigFile, Host, ProfileConfig, active_host_id, cli, config_path, list_hosts, load_config, + load_profile, profile_for_host, save_config, source_from_profile, +}; use crate::connect::{ConnectEvent, ConnectFlow}; use crate::pages::Page; +use crate::pages::health::HealthPage; +use crate::pages::merges::MergesPage; use crate::pages::overview::OverviewPage; +use crate::pages::queries::QueriesPage; +use crate::pages::replicas::ReplicasPage; +use crate::pages::settings::SettingsPage; +use crate::pages::tables::TablesPage; +use crate::pages::traffic::TrafficPage; +use crate::updater; +use crate::widgets::controls::ghost_button; -actions!(chm_shell, [Refresh]); +actions!(chm_shell, [Refresh, ToggleSidebar, OpenSettings]); /// Seconds between automatic background refreshes. const POLL_SECS: u64 = 30; /// Viewport width below which the sidebar collapses to an icon strip. const COMPACT_BELOW: f32 = 900.0; /// Sidebar width expanded / collapsed. -const SIDEBAR_W: f32 = 190.0; -const SIDEBAR_W_COMPACT: f32 = 48.0; +const SIDEBAR_W: f32 = 176.0; +const SIDEBAR_W_MIN: f32 = 140.0; +const SIDEBAR_W_MAX: f32 = 360.0; -/// Perf metrics live for the whole process; recording is gated by -/// `[telemetry] enabled=true` in config.toml (never on by default). -fn perf() -> &'static chm_telemetry::PerfMetrics { - static PERF: OnceLock = OnceLock::new(); - PERF.get_or_init(chm_telemetry::PerfMetrics::new) -} +static APP_ICON: LazyLock> = LazyLock::new(|| { + Arc::new(Image::from_bytes( + ImageFormat::Png, + include_bytes!("../../assets/icon/icon-1024.png").to_vec(), + )) +}); -// --------------------------------------------------------------------------- -// config.toml schema -// --------------------------------------------------------------------------- - -/// Saved connection profile (`[profile]` table). Written by the Connect -/// screen's Save button, read at startup. -#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)] -pub struct ProfileConfig { - /// "cloud" | "clickhouse" - pub mode: Option, - /// Cloud mode: API base URL. - #[serde(default)] - pub base_url: Option, - /// Cloud mode: API key. - #[serde(default)] - pub api_key: Option, - /// Direct mode: ClickHouse HTTP endpoint. - #[serde(default)] - pub url: Option, - /// Direct mode: user name. - #[serde(default)] - pub user: Option, - /// Direct mode: password. - #[serde(default)] - pub password: Option, - /// Release channel for the update check: "stable" | "beta". - #[serde(default)] - pub channel: Option, +fn clamp_sidebar_width(width: f32) -> f32 { + width.clamp(SIDEBAR_W_MIN, SIDEBAR_W_MAX) } -/// `[telemetry]` table — opt-in, default disabled. -#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)] -pub struct TelemetrySection { - #[serde(default)] - pub enabled: bool, +fn sidebar_width_from_cfg(width: Option) -> f32 { + width + .map(|w| clamp_sidebar_width(w as f32)) + .unwrap_or(SIDEBAR_W) } -/// Whole `config.toml`. -#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)] -pub struct ConfigFile { - #[serde(default)] - pub profile: ProfileConfig, - #[serde(default)] - pub telemetry: TelemetrySection, -} - -/// `/chmonitor/config.toml`. -pub fn config_path() -> Option { - dirs::config_dir().map(|d| d.join("chmonitor").join("config.toml")) -} - -/// Read the saved profile, if a well-formed file exists. Any failure means -/// "no profile" and the app shows the Connect screen. -pub fn load_profile() -> Option { - let text = std::fs::read_to_string(config_path()?).ok()?; - let cfg: ConfigFile = toml::from_str(&text).ok()?; - cfg.profile.mode.as_ref()?; - Some(cfg.profile) -} - -/// Build the boxed data source behind [`chm_core::DataSource`] for a saved -/// profile. `None` when required fields are missing. -pub fn source_from_profile(p: &ProfileConfig) -> Option> { - match p.mode.as_deref()? { - "cloud" => Some(Box::new(CloudClient::new( - p.base_url.clone()?, - p.api_key.clone(), - ))), - "clickhouse" => Some(Box::new(ClickHouseClient::new( - p.url.clone()?, - p.user.clone().unwrap_or_else(|| "default".into()), - p.password.clone(), - ))), - _ => None, - } +/// Perf metrics live for the whole process; recording is gated by +/// `[telemetry] enabled=true` in config.toml (never on by default). +fn perf() -> &'static chm_telemetry::PerfMetrics { + static PERF: OnceLock = OnceLock::new(); + PERF.get_or_init(chm_telemetry::PerfMetrics::new) } // --------------------------------------------------------------------------- @@ -130,29 +95,68 @@ pub enum ConnState { } impl ConnState { - fn dot(self, theme: &Theme) -> Hsla { + fn color(self, cx: &App) -> Hsla { match self { - Self::Connected => theme.success, - Self::Connecting => theme.warning, - Self::Error => theme.danger, + Self::Connected => cx.theme().green, + Self::Connecting => cx.theme().warning, + Self::Error => cx.theme().danger, } } } -/// Result of the one-shot startup update check. +/// Result of the one-shot startup update check / download. #[derive(Debug, Clone)] -struct UpdateNote(SharedString); +enum UpdateUi { + Disabled, + Checking, + Idle, + Silent, + Available(chm_update::ReleaseInfo), + Downloading(String), + Ready { + version: String, + archive: std::path::PathBuf, + }, + Failed(String), +} + +/// Glanceable facts about the active host (status bar). +#[derive(Debug, Clone, Default)] +struct HostStatus { + version: Option, + replicas_ok: u64, + replicas_total: u64, + health_ok: Option, + fetch_ms: Option, + rss_bytes: Option, +} /// The root view: owns routing, the active data source and the poll task. pub struct Shell { focus: FocusHandle, page: Page, + range: TimeRange, source: Option>>, conn: ConnState, last_refresh: Option>, - overview: Option>, + last_error: Option, + overview: Entity, + queries: Entity, + merges: Entity, + replicas: Entity, + health: Entity, + tables: Entity, + traffic: Entity, connect: Entity, - update_note: Option, + settings: Entity, + update: UpdateUi, + /// `None` follows the viewport; `Some` is a click/`cmd-b` override. + sidebar_collapsed: Option, + /// Expanded sidebar width in px (drag-handle, persisted as `[ui].sidebar_width`). + sidebar_width: f32, + active_host: Option, + host_status: HostStatus, + fetching: bool, } impl Focusable for Shell { @@ -166,26 +170,31 @@ impl Shell { /// * `CHM_SMOKE=1` forces [`MockDataSource`]; /// * else the saved profile builds a cloud/direct client; /// * else no source — Connect becomes the content pane. - fn pick_source() -> (Option>>, ConnState) { + #[allow(clippy::type_complexity)] + fn pick_source() -> (Option>>, ConnState, Option) { if std::env::var("CHM_SMOKE").is_ok() { return ( Some(Arc::new( Box::new(MockDataSource::new("mock (CHM_SMOKE)")) as Box )), ConnState::Connected, + Some("smoke".into()), ); } - match load_profile() { - Some(profile) => match source_from_profile(&profile) { - Some(src) => (Some(Arc::new(src)), ConnState::Connecting), - None => (None, ConnState::Error), - }, - None => (None, ConnState::Error), + let cfg = load_config(); + let id = active_host_id(&cfg); + match id + .as_deref() + .and_then(|id| profile_for_host(&cfg, id)) + .and_then(|p| source_from_profile(&p)) + { + Some(src) => (Some(Arc::new(src)), ConnState::Connecting, id), + None => (None, ConnState::Error, id), } } - pub fn new(cx: &mut Context) -> Self { - let (source, conn) = Self::pick_source(); + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let (source, conn, active_host) = Self::pick_source(); // Telemetry hook: opt-in only. Recording stays off unless the user // explicitly set `[telemetry] enabled = true`; nothing else enables it. @@ -195,68 +204,241 @@ impl Shell { .map(|cfg| cfg.telemetry.enabled) .unwrap_or(false); if telemetry_enabled { - // Config built but nothing transmitted: PerfMetrics only records - // local latency numbers, and recording itself stays gated here. - let _cfg = chm_telemetry::TelemetryConfig::default().set_enabled(true); + let channel = match load_profile().and_then(|p| p.channel).as_deref() { + Some("beta") => chm_update::Channel::Beta, + _ => chm_update::Channel::Stable, + }; + let cfg = chm_telemetry::TelemetryConfig::opt_in( + chm_telemetry::TELEMETRY_PING_URL, + env!("CARGO_PKG_VERSION"), + channel, + ); + cx.spawn(async move |_, _| { + let http = chm_telemetry::http_client(); + let _ = chm_core::tokio_block_on(chm_telemetry::ping(http, &cfg)); + let _ = chm_core::tokio_block_on(chm_telemetry::track(http, &cfg, "app_loaded")); + }) + .detach(); } - // One-shot update check, background thread. Failures are silent unless - // CHM_UPDATE_URL overrides the manifest base (chm_update semantics). - let channel = match load_profile().and_then(|p| p.channel).as_deref() { - Some("beta") => chm_update::Channel::Beta, - _ => chm_update::Channel::Stable, + let update_cfg = load_config().update; + if update_cfg.enabled { + Self::spawn_update_check(update_cfg.auto_download, cx); + } + + let force_connect = cli().connect; + let page = if force_connect || source.is_none() { + Page::Connect + } else { + Page::Overview }; - cx.spawn(async move |this, cx| { - let checker = chm_update::UpdateChecker::production(); - let current = semver::Version::new(0, 1, 0); - let note = match chm_core::tokio_block_on(checker.check(channel, ¤t)) { - Ok(Some(release)) => { - UpdateNote(format!("update available: v{}", release.version()).into()) - } - Ok(None) => UpdateNote("up to date".into()), - Err(_) => return, - }; - let _ = this.update(cx, |shell, cx| { - shell.update_note = Some(note); - cx.notify(); - }); - }) - .detach(); let mut shell = Self { focus: cx.focus_handle(), - page: Page::Overview, + page, + range: TimeRange::TwentyFourHours, source, conn, last_refresh: None, - overview: None, - connect: cx.new(|cx| ConnectFlow::new(load_profile(), cx)), - update_note: None, + last_error: None, + overview: cx.new(|_| OverviewPage::new()), + queries: cx.new(|_| QueriesPage::new()), + merges: cx.new(|_| MergesPage::new()), + replicas: cx.new(|_| ReplicasPage::new()), + health: cx.new(|_| HealthPage::new()), + tables: cx.new(|_| TablesPage::new()), + traffic: cx.new(|_| TrafficPage::new()), + connect: cx.new(|cx| ConnectFlow::new(load_profile(), window, cx)), + settings: cx.new(|_| SettingsPage::new()), + update: if load_config().update.enabled { + UpdateUi::Checking + } else { + UpdateUi::Disabled + }, + sidebar_collapsed: if load_config().ui.compact_sidebar { + Some(true) + } else { + None + }, + sidebar_width: sidebar_width_from_cfg(load_config().ui.sidebar_width), + active_host, + host_status: HostStatus::default(), + fetching: false, }; // Digits 1-8 switch pages; handled in render's on_key_down so it works // wherever focus sits in this view's subtree. `r` is an action. - cx.bind_keys([KeyBinding::new("r", Refresh, None)]); + cx.bind_keys([ + KeyBinding::new("r", Refresh, None), + KeyBinding::new("cmd-b", ToggleSidebar, None), + KeyBinding::new("cmd-,", OpenSettings, None), + ]); // Rebuild the source after the Connect screen writes a new profile. cx.subscribe(&shell.connect, |this, _, event: &ConnectEvent, cx| { - let ConnectEvent::SavedProfile(profile) = event; - this.source = source_from_profile(profile).map(Arc::new); - this.conn = if this.source.is_some() { - ConnState::Connecting - } else { - ConnState::Error - }; + let ConnectEvent::SavedProfile { profile, host_id } = event; + this.apply_host(host_id.clone(), profile.clone()); + this.page = Page::Overview; this.refresh_now(cx); cx.notify(); }) .detach(); shell.start_poll(cx); - shell.refresh_now(cx); + shell.refresh(false, cx); shell } + fn goto(&mut self, page: Page, cx: &mut Context) { + if self.page == page { + return; + } + self.page = page; + self.emit_page(page); + self.refresh(false, cx); + cx.notify(); + } + + fn emit_page(&self, page: Page) { + if !load_config().telemetry.enabled { + return; + } + let event = match page { + Page::Health => "health_viewed", + Page::Queries => "queries_viewed", + Page::Overview => "app_loaded", + _ => return, + }; + let cfg = chm_telemetry::TelemetryConfig::opt_in( + chm_telemetry::TELEMETRY_PING_URL, + env!("CARGO_PKG_VERSION"), + chm_update::Channel::Stable, + ); + std::thread::spawn(move || { + let http = chm_telemetry::http_client(); + let _ = chm_core::tokio_block_on(chm_telemetry::track(http, &cfg, event)); + }); + } + + fn toggle_sidebar(&mut self, narrow: bool, cx: &mut Context) { + let compact = sidebar_is_compact(self.sidebar_collapsed, narrow); + self.sidebar_collapsed = Some(!compact); + cx.notify(); + } + + fn set_sidebar_width(&mut self, width: f32, cx: &mut Context) { + let width = clamp_sidebar_width(width); + if (self.sidebar_width - width).abs() < 0.5 { + return; + } + self.sidebar_width = width; + let mut cfg = load_config(); + cfg.ui.sidebar_width = Some(width.round() as u32); + let _ = save_config(&cfg); + cx.notify(); + } + + fn apply_host(&mut self, host_id: String, profile: ProfileConfig) { + self.active_host = Some(host_id); + self.source = source_from_profile(&profile).map(Arc::new); + self.conn = if self.source.is_some() { + ConnState::Connecting + } else { + ConnState::Error + }; + self.host_status = HostStatus::default(); + self.last_error = None; + } + + fn switch_host(&mut self, host_id: String, cx: &mut Context) { + if self.active_host.as_deref() == Some(host_id.as_str()) { + cx.notify(); + return; + } + if std::env::var("CHM_SMOKE").is_ok() { + cx.notify(); + return; + } + let cfg = load_config(); + let Some(profile) = profile_for_host(&cfg, &host_id) else { + return; + }; + let mut cfg = load_config(); + cfg.ui.host = Some(host_id.clone()); + let _ = save_config(&cfg); + self.apply_host(host_id, profile); + if matches!(self.page, Page::Connect | Page::Settings) + || !self.page.available(self.source_engine()) + { + self.page = Page::Overview; + } + self.refresh_now(cx); + cx.notify(); + } + + fn source_engine(&self) -> SourceEngine { + self.source + .as_ref() + .map(|s| s.engine()) + .unwrap_or(SourceEngine::ClickHouse) + } + + fn hosts(&self) -> Vec { + if std::env::var("CHM_SMOKE").is_ok() { + return vec![Host { + id: "smoke".into(), + label: "smoke".into(), + profile: ProfileConfig { + mode: Some("mock".into()), + ..Default::default() + }, + }]; + } + list_hosts(&load_config()) + } + + fn active_host_label(&self) -> String { + let hosts = self.hosts(); + if let Some(id) = &self.active_host + && let Some(h) = hosts.iter().find(|h| &h.id == id) + { + return h.label.clone(); + } + self.source + .as_ref() + .map(|s| s.label()) + .unwrap_or_else(|| "no host".into()) + } + + fn host_status_text(&self) -> String { + match self.conn { + ConnState::Connecting => "connecting".into(), + ConnState::Error => self.last_error.clone().unwrap_or_else(|| "error".into()), + ConnState::Connected => { + let mut parts = Vec::new(); + match self.host_status.health_ok { + Some(false) => parts.push("not ok".into()), + _ => parts.push("ok".into()), + } + if let Some(v) = &self.host_status.version + && !v.is_empty() + { + parts.push(v.clone()); + } + if self.host_status.replicas_total > 0 { + parts.push(format!( + "{}/{} replicas", + self.host_status.replicas_ok, self.host_status.replicas_total + )); + } + if let Some(ms) = self.host_status.fetch_ms { + parts.push(format!("{ms:.0}ms")); + } + parts.join(" · ") + } + } + } + /// Recurring refresh: each tick re-spawns itself, so a slow fetch can /// never overlap the next one. Exits once the shell is dropped. fn start_poll(&self, cx: &mut Context) { @@ -279,183 +461,732 @@ impl Shell { /// Snapshot what a background fetch needs. Cheap: one Arc clone + a copy. fn poll_job(&self) -> Option { + if matches!(self.page, Page::Connect | Page::Settings) { + return None; + } self.source.as_ref().map(|src| PollJob { src: src.clone(), page: self.page, + range: self.range, }) } /// Manual refresh action + initial fill. fn refresh_now(&mut self, cx: &mut Context) { + self.refresh(true, cx); + } + + fn refresh(&mut self, force: bool, cx: &mut Context) { + self.hydrate_cache(cx); + if !force && self.cache_is_fresh() { + self.fetching = false; + cx.notify(); + return; + } let Some(job) = self.poll_job() else { return }; + if self.conn != ConnState::Error && !self.has_cached_page() { + self.conn = ConnState::Connecting; + } + self.fetching = true; + cx.notify(); cx.spawn(async move |this, cx| apply_poll(job, &this, cx).await) .detach(); } - /// Land fetched data back on the view (called from the async context). - fn set_overview_data( + fn cache_host(&self) -> Option<&str> { + self.active_host.as_deref() + } + + fn cache_is_fresh(&self) -> bool { + let Some(host) = self.cache_host() else { + return false; + }; + cache::load(host, self.page, self.range) + .map(|(_, t)| cache::is_fresh(t)) + .unwrap_or(false) + } + + fn has_cached_page(&self) -> bool { + let Some(host) = self.cache_host() else { + return false; + }; + cache::load(host, self.page, self.range).is_some() + } + + fn hydrate_cache(&mut self, cx: &mut Context) { + let Some(host) = self.active_host.clone() else { + return; + }; + let Some((data, _)) = cache::load(&host, self.page, self.range) else { + return; + }; + self.apply_cached(data, cx); + if self.conn != ConnState::Error { + self.conn = ConnState::Connected; + } + } + + fn apply_cached(&mut self, data: crate::cache::CachedPage, cx: &mut Context) { + use crate::cache::CachedPage; + match data { + CachedPage::Overview { overview, traffic } => { + self.host_status.version = Some(overview.clickhouse_version.clone()); + self.host_status.replicas_ok = overview.replicas_ok; + self.host_status.replicas_total = overview.replicas_total; + self.overview + .update(cx, |p, cx| p.set_overview(Ok(overview), Ok(traffic), cx)); + } + CachedPage::Queries { + running, + slow, + failed, + } => { + self.queries + .update(cx, |p, cx| p.set(Ok(running), Ok(slow), Ok(failed), cx)); + } + CachedPage::Merges(rows) => { + self.merges.update(cx, |p, cx| p.set(Ok(rows), cx)); + } + CachedPage::Replicas(rows) => { + self.replicas.update(cx, |p, cx| p.set(Ok(rows), cx)); + } + CachedPage::Health(h) => { + self.host_status.health_ok = Some(h.ok); + self.health.update(cx, |p, cx| p.set(Ok(h), cx)); + } + CachedPage::Tables(rows) => { + self.tables.update(cx, |p, cx| p.set(Ok(rows), cx)); + } + CachedPage::Traffic(t) => { + self.traffic.update(cx, |p, cx| p.set(Ok(t), cx)); + } + } + } + + fn toggle_dark(&mut self, window: &mut Window, cx: &mut Context) { + use crate::pages::settings::{Appearance, appearance_to_cfg, apply_appearance}; + let next = if crate::theme::current_mode(cx) == gpui_component::ThemeMode::Dark { + Appearance::Light + } else { + Appearance::Dark + }; + apply_appearance(next, window, cx); + let mut cfg = load_config(); + cfg.ui.appearance = Some(appearance_to_cfg(next).into()); + let _ = save_config(&cfg); + cx.notify(); + } + + fn apply_outcome( &mut self, - data: Result, + outcome: PollOutcome, at: chrono::DateTime, + fetch_ms: f64, cx: &mut Context, ) { self.last_refresh = Some(at); - self.conn = if data.is_ok() { - ConnState::Connected + self.fetching = false; + self.host_status.fetch_ms = Some(fetch_ms); + if let Some(rss) = chm_telemetry::rss_bytes() { + self.host_status.rss_bytes = Some(rss); + perf().record_rss(rss); + } + let _ = perf().record_fetch(fetch_ms); + if let Some(host) = self.active_host.clone() { + self.persist_cache(&host, &outcome); + } + match outcome { + PollOutcome::Overview { overview, traffic } => { + if let Ok(o) = &overview { + self.host_status.version = Some(o.clickhouse_version.clone()); + self.host_status.replicas_ok = o.replicas_ok; + self.host_status.replicas_total = o.replicas_total; + } + self.set_conn(overview.is_ok(), overview.as_ref().err().cloned()); + self.overview + .update(cx, |p, cx| p.set_overview(overview, traffic, cx)); + } + PollOutcome::Queries { + running, + slow, + failed, + } => { + let ok = running.is_ok() || slow.is_ok() || failed.is_ok(); + let err = running + .as_ref() + .err() + .or(slow.as_ref().err()) + .or(failed.as_ref().err()) + .cloned(); + self.set_conn(ok, err.filter(|_| !ok)); + self.queries + .update(cx, |p, cx| p.set(running, slow, failed, cx)); + } + PollOutcome::Merges(data) => { + self.set_conn(data.is_ok(), data.as_ref().err().cloned()); + self.merges.update(cx, |p, cx| p.set(data, cx)); + } + PollOutcome::Replicas(data) => { + self.set_conn(data.is_ok(), data.as_ref().err().cloned()); + self.replicas.update(cx, |p, cx| p.set(data, cx)); + } + PollOutcome::Health(data) => { + if let Ok(h) = &data { + self.host_status.health_ok = Some(h.ok); + } + self.set_conn(data.is_ok(), data.as_ref().err().cloned()); + self.health.update(cx, |p, cx| p.set(data, cx)); + } + PollOutcome::Tables(data) => { + self.set_conn(data.is_ok(), data.as_ref().err().cloned()); + self.tables.update(cx, |p, cx| p.set(data, cx)); + } + PollOutcome::Traffic(data) => { + self.set_conn(data.is_ok(), data.as_ref().err().cloned()); + self.traffic.update(cx, |p, cx| p.set(data, cx)); + } + } + cx.notify(); + } + + fn persist_cache(&self, host: &str, outcome: &PollOutcome) { + use crate::cache::CachedPage; + let page = match outcome { + PollOutcome::Overview { overview, traffic } => { + let (Ok(o), Ok(t)) = (overview, traffic) else { + return; + }; + CachedPage::Overview { + overview: o.clone(), + traffic: t.clone(), + } + } + PollOutcome::Queries { + running, + slow, + failed, + } => { + let (Ok(r), Ok(s), Ok(f)) = (running, slow, failed) else { + return; + }; + CachedPage::Queries { + running: r.clone(), + slow: s.clone(), + failed: f.clone(), + } + } + PollOutcome::Merges(Ok(rows)) => CachedPage::Merges(rows.clone()), + PollOutcome::Replicas(Ok(rows)) => CachedPage::Replicas(rows.clone()), + PollOutcome::Health(Ok(h)) => CachedPage::Health(h.clone()), + PollOutcome::Tables(Ok(rows)) => CachedPage::Tables(rows.clone()), + PollOutcome::Traffic(Ok(t)) => CachedPage::Traffic(t.clone()), + _ => return, + }; + crate::cache::save(host, self.page, self.range, &page); + } + + fn set_conn(&mut self, ok: bool, err: Option) { + if ok { + self.conn = ConnState::Connected; + self.last_error = None; } else { - ConnState::Error + self.conn = ConnState::Error; + self.last_error = err; + } + } + + fn spawn_update_check(auto_download: bool, cx: &mut Context) { + let channel = match load_profile().and_then(|p| p.channel).as_deref() { + Some("beta") => chm_update::Channel::Beta, + _ => chm_update::Channel::Stable, + }; + cx.spawn(async move |this, cx| { + let checker = chm_update::UpdateChecker::production(); + let current = semver::Version::parse(env!("CARGO_PKG_VERSION")) + .unwrap_or_else(|_| semver::Version::new(0, 1, 1)); + let found = chm_core::tokio_block_on(checker.check(channel, ¤t)); + let _ = this.update(cx, |shell, cx| { + match found { + Ok(Some(release)) => { + shell.update = UpdateUi::Available(release.clone()); + if auto_download { + shell.start_download(release, cx); + } + } + Ok(None) => shell.update = UpdateUi::Idle, + Err(_) => shell.update = UpdateUi::Silent, + } + cx.notify(); + }); + }) + .detach(); + } + + fn start_download(&mut self, release: chm_update::ReleaseInfo, cx: &mut Context) { + let Some(dest) = updater::archive_path(&release) else { + self.update = UpdateUi::Failed("no cache directory".into()); + cx.notify(); + return; + }; + self.update = UpdateUi::Downloading(release.version().to_string()); + cx.notify(); + cx.spawn(async move |this, cx| { + let checker = chm_update::UpdateChecker::production(); + let result = chm_core::tokio_block_on(checker.download(&release, &dest)); + let _ = this.update(cx, |shell, cx| { + shell.update = match result { + Ok(()) => UpdateUi::Ready { + version: release.version().to_string(), + archive: dest, + }, + Err(e) => UpdateUi::Failed(e.to_string()), + }; + cx.notify(); + }); + }) + .detach(); + } + + fn apply_downloaded(&mut self, cx: &mut Context) { + let UpdateUi::Ready { archive, .. } = &self.update else { + return; }; - if self.overview.is_none() { - self.overview = Some(cx.new(|_| OverviewPage::new())); + let archive = archive.clone(); + #[cfg(target_os = "macos")] + { + match updater::install_macos_zip(&archive) { + Ok(app) => { + updater::relaunch(&app); + cx.quit(); + } + Err(_) => { + let _ = std::process::Command::new("/usr/bin/open") + .args(["-R"]) + .arg(&archive) + .spawn(); + self.update = UpdateUi::Failed( + "saved the archive — drop it over /Applications/chmonitor.app".into(), + ); + cx.notify(); + } + } } - if let Some(page) = &self.overview { - page.update(cx, |p, cx| p.set_overview(data, cx)); + #[cfg(not(target_os = "macos"))] + { + let _ = archive; + self.update = UpdateUi::Failed("install the archive from the release page".into()); + cx.notify(); } - cx.notify(); + } + + fn host_icon(mode: Option<&str>) -> IconName { + match mode { + Some("postgres") => IconName::HardDrive, + Some("cloud") => IconName::Globe, + _ => IconName::Cpu, + } + } + + fn host_engine_label(&self) -> &'static str { + match self.source_engine() { + SourceEngine::Postgres => "PostgreSQL", + SourceEngine::ClickHouse => "ClickHouse", + SourceEngine::Cloud => "Cloud API", + SourceEngine::Mock => "Mock", + } + } + + fn app_icon(&self, compact: bool) -> impl IntoElement { + let size = if compact { px(16.) } else { px(24.) }; + img(ImageSource::Image(APP_ICON.clone())) + .size(size) + .rounded(px(6.)) + .flex_shrink_0() + .object_fit(gpui::ObjectFit::Cover) } // -- rendering ---------------------------------------------------------- - fn sidebar(&self, theme: &Theme, compact: bool, cx: &mut Context) -> bezel::gpui::Div { - let items: Vec = Page::ALL + fn render_sidebar(&self, compact: bool, cx: &mut Context) -> impl IntoElement { + let engine = self.source_engine(); + let entity = cx.entity().downgrade(); + let hosts = self.hosts(); + let host_label = self.active_host_label(); + let engine_label = self.host_engine_label(); + let active = self.active_host.clone(); + let muted = cx.theme().muted_foreground; + + let mut nav = SidebarMenu::new(); + for (i, page) in Page::ALL .iter() - .map(|&page| { - let active = page == self.page; - let hotkey = format!("{}", page.index() + 1); - let label = if compact { - div().child(page.icon()) - } else { - div() - .flex() - .flex_row() - .items_center() - .gap(px(8.0)) - .child(page.icon()) - .child(div().child(page.title())) - .child( + .copied() + .filter(|page| page.available(engine)) + .enumerate() + { + let active_page = page == self.page; + let hotkey = format!("{}", i + 1); + nav = nav.child( + SidebarMenuItem::new(page.title()) + .icon(page.icon()) + .active(active_page) + .suffix({ + let hotkey = hotkey.clone(); + move |_, _| div().text_xs().text_color(muted).child(hotkey.clone()) + }) + .on_click(cx.listener(move |this, _, _, cx| this.goto(page, cx))), + ); + } + + Sidebar::new("nav") + .collapsed(compact) + .collapsible(true) + .when(compact, |sb| sb.w(px(self.sidebar_width))) + .when(!compact, |sb| sb.w(relative(1.))) + .header( + SidebarHeader::new() + .child(self.app_icon(compact)) + .when(!compact, |header| { + header.child( + v_flex() + .flex_1() + .min_w_0() + .overflow_hidden() + .line_height(relative(1.25)) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_ellipsis() + .child(host_label.clone()), + ) + .child( + div() + .text_xs() + .text_color(muted) + .text_ellipsis() + .child(engine_label), + ), + ) + }) + .when(!compact, |header| { + header.child(Icon::new(IconName::ChevronsUpDown).size_4().flex_shrink_0()) + }) + .dropdown_menu(move |menu, _, _| { + let mut menu = menu; + for host in &hosts { + let id = host.id.clone(); + let entity = entity.clone(); + let selected = active.as_deref() == Some(id.as_str()); + menu = menu.item( + PopupMenuItem::new(host.label.clone()) + .icon(Self::host_icon(host.profile.mode.as_deref())) + .checked(selected) + .on_click(move |_, _, cx| { + let _ = entity.update(cx, |this, cx| { + this.switch_host(id.clone(), cx) + }); + }), + ); + } + let entity = entity.clone(); + menu.separator().item( + PopupMenuItem::new("Add host") + .icon(IconName::Plus) + .on_click(move |_, _, cx| { + let _ = entity.update(cx, |this, cx| { + this.page = Page::Connect; + cx.notify(); + }); + }), + ) + }), + ) + .child(nav) + .footer( + SidebarFooter::new() + .justify_between() + .child( + Button::new("sidebar-settings") + .ghost() + .compact() + .icon(Icon::new(IconName::Settings)) + .when(!compact, |btn| btn.label("Settings")) + .selected(self.page == Page::Settings) + .tooltip("Settings") + .on_click(cx.listener(|this, _, _, cx| { + this.page = Page::Settings; + cx.notify(); + })), + ) + .when(!compact, |footer| { + footer.child( div() - .ml(px(2.0)) - .text_size(px(10.0)) - .text_color(theme.text_faint) - .child(hotkey), + .text_xs() + .text_color(muted) + .child(format!("v{}", env!("CARGO_PKG_VERSION"))), ) - }; - div() - .id(SharedString::from(format!("nav-{}", page.title()))) - .w_full() - .px(px(if compact { 0.0 } else { 12.0 })) - .py(px(6.0)) - .rounded(px(6.0)) - .cursor_pointer() - .when(active, |el| el.bg(theme.element_active)) - .hover(|s| s.bg(theme.element_hover)) - .text_size(px(13.0)) - .text_color(if active { theme.text } else { theme.text_muted }) - .on_click( - cx.listener(move |this, _: &bezel::gpui::ClickEvent, _, cx| { - this.page = page; - cx.notify(); - }), + }), + ) + } + + fn render_title_bar( + &self, + compact: bool, + show_range: bool, + cx: &mut Context, + ) -> impl IntoElement { + let fetching = self.fetching; + let muted = cx.theme().muted_foreground; + TitleBar::new() + .child( + h_flex() + .items_center() + .gap_2() + .child( + SidebarToggleButton::new() + .collapsed(compact) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_sidebar( + window.viewport_size().width < px(COMPACT_BELOW), + cx, + ); + })), ) - .child(label) - }) - .map(bezel::gpui::IntoElement::into_any_element) - .collect(); + .child(Separator::vertical().h_4()) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(SharedString::from(self.page.title())), + ) + .when(fetching, |row| { + row.child(Spinner::new().xsmall().color(muted)) + }), + ) + .child( + h_flex() + .items_center() + .justify_end() + .gap_2() + .px_2() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .when(show_range, |row| row.child(self.range_bar(cx))) + .child({ + let dark = + crate::theme::current_mode(cx) == gpui_component::ThemeMode::Dark; + Button::new("theme-toggle") + .ghost() + .compact() + .xsmall() + .icon(if dark { + Icon::new(IconName::Sun) + } else { + Icon::new(IconName::Moon) + }) + .tooltip(if dark { "Light mode" } else { "Dark mode" }) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_dark(window, cx); + })) + }), + ) + } - div() - .flex() - .flex_col() - .gap(px(2.0)) - .when(compact, |col| col.items_center()) - .children(items) + fn range_bar(&self, cx: &mut Context) -> impl IntoElement { + let entity = cx.entity().downgrade(); + let mut group = crate::widgets::controls::range_group("time-range", cx); + for range in TimeRange::ALL { + let entity = entity.clone(); + let pressed = self.range == range; + group = group.child( + crate::widgets::controls::range_toggle( + format!("range-{}", range.label()), + pressed, + range.label(), + cx, + ) + .on_change(move |next, _, _, cx| { + if next { + let _ = entity.update(cx, |this, cx| { + if this.range != range { + this.range = range; + this.refresh_now(cx); + cx.notify(); + } + }); + } + }), + ); + } + group } - fn status_bar(&self, theme: &Theme) -> bezel::gpui::Div { - let label = self - .source - .as_ref() - .map(|s| SharedString::from(s.label())) - .unwrap_or_else(|| "no source configured".into()); + fn status_bar(&self, cx: &Context) -> impl IntoElement { + let host = self.active_host_label(); + let status = self.host_status_text(); let refreshed = match self.last_refresh { Some(at) => format!("updated {}", at.format("%H:%M:%S")), None => "not refreshed yet".to_string(), }; - let note = self - .update_note - .as_ref() - .map(|UpdateNote(t)| t.clone()) - .unwrap_or_else(|| "update check…".into()); + let muted = cx.theme().muted_foreground; + let update_el = match &self.update { + UpdateUi::Disabled | UpdateUi::Silent => None, + UpdateUi::Checking => Some( + div() + .text_color(muted) + .child("update check…") + .into_any_element(), + ), + UpdateUi::Idle => Some( + div() + .text_color(muted) + .child("up to date") + .into_any_element(), + ), + UpdateUi::Available(release) => { + let release = release.clone(); + let label = format!("update v{}", release.version()); + Some( + ghost_button("apply-update", label, cx) + .on_click(cx.listener(move |this, _, _, cx| { + this.start_download(release.clone(), cx); + })) + .into_any_element(), + ) + } + UpdateUi::Downloading(v) => Some( + div() + .text_color(muted) + .child(format!("downloading v{v}…")) + .into_any_element(), + ), + UpdateUi::Ready { version, .. } => Some( + ghost_button("install-update", format!("install v{version}"), cx) + .on_click(cx.listener(|this, _, _, cx| this.apply_downloaded(cx))) + .into_any_element(), + ), + UpdateUi::Failed(e) => Some( + div() + .text_color(cx.theme().danger) + .child(e.clone()) + .into_any_element(), + ), + }; + StatusBar::new() + .left( + h_flex() + .items_center() + .gap_2() + .child(div().size_2().rounded_full().bg(self.conn.color(cx))) + .child(host), + ) + .child(div().text_color(self.conn.color(cx)).child(status)) + .right({ + let mut bits = vec![refreshed]; + if load_config().ui.show_perf { + if let Some(ms) = self.host_status.fetch_ms { + bits.push(format!("{ms:.0}ms")); + } + if let Some(rss) = self.host_status.rss_bytes { + bits.push(crate::widgets::geometry::format_bytes(rss)); + } + } + bits.join(" · ") + }) + .children(update_el) + } - div() - .flex() - .flex_row() - .items_center() - .gap(px(12.0)) - .px(px(12.0)) - .py(px(6.0)) - .border_t_1() - .border_color(theme.border) - .bg(theme.surface) - .text_size(px(11.5)) - .text_color(theme.text_muted) - .child(status_dot(self.conn.dot(theme))) - .child(div().min_w_0().truncate().child(label)) - .child(div().child(refreshed)) - .child(div().flex_1()) - .child(div().text_color(theme.text_faint).child(note)) - } - - fn content(&mut self, _cx: &mut Context) -> bezel::gpui::AnyElement { - // No source yet: Connect owns the pane whatever the route points at. - if self.source.is_none() && self.page != Page::Connect { + fn content(&mut self, cx: &mut Context) -> gpui::AnyElement { + if self.source.is_none() && !matches!(self.page, Page::Connect | Page::Settings) { return div() .flex() .flex_1() .items_center() .justify_center() - .text_color(bezel::theme::ink(0.55)) - .text_size(px(13.0)) + .text_color(cx.theme().muted_foreground) + .text_sm() .child("no connection configured — pick a mode in Connect") .into_any_element(); } match self.page { - Page::Overview => match &self.overview { - Some(page) => page.clone().into_any_element(), - None => placeholder("loading overview…").into_any_element(), - }, - // Placeholder routes until Agents F/G/H replace pages/. - Page::Queries => placeholder("Queries — owned by Agent F").into_any_element(), - Page::Merges => placeholder("Merges — owned by Agent F").into_any_element(), - Page::Replicas => placeholder("Replicas — owned by Agent G").into_any_element(), - Page::Health => placeholder("Health — owned by Agent G").into_any_element(), - Page::Tables => placeholder("Tables — owned by Agent H").into_any_element(), - Page::Traffic => placeholder("Traffic — owned by Agent H").into_any_element(), + Page::Overview => self.overview.clone().into_any_element(), + Page::Queries => self.queries.clone().into_any_element(), + Page::Merges => self.merges.clone().into_any_element(), + Page::Replicas => self.replicas.clone().into_any_element(), + Page::Health => self.health.clone().into_any_element(), + Page::Tables => self.tables.clone().into_any_element(), + Page::Traffic => self.traffic.clone().into_any_element(), Page::Connect => self.connect.clone().into_any_element(), + Page::Settings => self.settings.clone().into_any_element(), } } } impl Render for Shell { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - // Cloned so helpers can take &mut cx while holding theme tokens - // (Theme::of borrows cx for the whole expression otherwise). - let theme = Theme::of(cx).clone(); - // Hover fades paint once and stick unless frames are requested. - if bezel::motion::hover_fades_active() { - window.request_animation_frame(); - } - let viewport = window.viewport_size(); - let compact = viewport.width < px(COMPACT_BELOW); + let narrow = viewport.width < px(COMPACT_BELOW); + let compact = sidebar_is_compact(self.sidebar_collapsed, narrow); + let show_range = self.page.uses_range() && self.source.is_some(); - div() + let pad = crate::density::Density::current().content_pad(); + let content = div() + .id("content-scroll") + .flex() + .flex_col() + .flex_1() + .min_w_0() + .min_h_0() + .p(px(pad)) + .overflow_y_scroll() + .child(self.content(cx)); + let split = if compact { + h_flex() + .flex_1() + .min_h_0() + .child(self.render_sidebar(true, cx)) + .child(content) + .into_any_element() + } else { + let entity = cx.entity().downgrade(); + div() + .flex_1() + .min_h_0() + .h_full() + .child( + h_resizable("shell-split") + .on_resize(move |state, _, cx| { + let width = state + .read(cx) + .sizes() + .first() + .copied() + .map(f32::from) + .unwrap_or(SIDEBAR_W); + let _ = entity.update(cx, |this, cx| this.set_sidebar_width(width, cx)); + }) + .child( + resizable_panel() + .size(px(self.sidebar_width)) + .size_range(px(SIDEBAR_W_MIN)..px(SIDEBAR_W_MAX)) + .child(self.render_sidebar(false, cx)), + ) + .child(resizable_panel().child(content)), + ) + .into_any_element() + }; + v_flex() .id("shell") .key_context("Shell") .track_focus(&self.focus) .on_action(cx.listener(|this, _: &Refresh, _, cx| this.refresh_now(cx))) + .on_action(cx.listener(|this, _: &ToggleSidebar, window, cx| { + this.toggle_sidebar(window.viewport_size().width < px(COMPACT_BELOW), cx); + })) + .on_action(cx.listener(|this, _: &OpenSettings, _, cx| { + this.page = Page::Settings; + cx.notify(); + })) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { - // Only when the shell itself holds focus — digits typed into - // a Connect text field must not switch pages. if !this.focus.is_focused(window) { return; } @@ -469,56 +1200,25 @@ impl Render for Shell { .parse::() .ok() .and_then(|n| n.checked_sub(1)) - && let Some(&page) = Page::ALL.get(idx) { - this.page = page; - cx.notify(); + let engine = this.source_engine(); + let page = Page::ALL + .iter() + .copied() + .filter(|p| p.available(engine)) + .nth(idx); + if let Some(page) = page { + this.goto(page, cx); + } } })) - .flex() - .flex_row() - .flex_1() - .bg(theme.bg) - .text_color(theme.text) .size_full() - .child( - // Sidebar surface; collapses to an icon strip below 900px. - div() - .flex() - .flex_col() - .w(px(if compact { - SIDEBAR_W_COMPACT - } else { - SIDEBAR_W - })) - .h_full() - .flex_none() - .p(px(8.0)) - .gap(px(8.0)) - .border_r_1() - .border_color(theme.border) - .bg(theme.surface) - .child(self.sidebar(&theme, compact, cx)), - ) - .child( - div() - .flex() - .flex_col() - .flex_1() - .min_w_0() - .child( - div() - .id("content-scroll") - .flex() - .flex_col() - .flex_1() - .min_h_0() - .p(px(16.0)) - .overflow_y_scroll() - .child(self.content(cx)), - ) - .child(self.status_bar(&theme)), - ) + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .child(self.render_title_bar(compact, show_range, cx)) + .child(split) + .child(self.status_bar(cx)) + .children(Root::render_notification_layer(window, cx)) } } @@ -532,33 +1232,104 @@ impl Render for Shell { struct PollJob { src: Arc>, page: Page, + range: TimeRange, +} + +enum PollOutcome { + Overview { + overview: Result, + traffic: Result, + }, + Queries { + running: Result, String>, + slow: Result, String>, + failed: Result, String>, + }, + Merges(Result, String>), + Replicas(Result, String>), + Health(Result), + Tables(Result, String>), + Traffic(Result), +} + +fn map_err(r: chm_core::Result) -> Result { + r.map_err(|e| e.to_string()) } async fn apply_poll(job: PollJob, this: &WeakEntity, cx: &mut AsyncApp) { let started = Instant::now(); - let result = match job.page { - Page::Overview => fetch_overview(&job.src).await, - _ => return, + let src = job.src; + let range = job.range; + let outcome = match job.page { + Page::Connect | Page::Settings => return, + Page::Overview => { + let (overview, traffic) = chm_core::tokio_block_on(async { + tokio::join!(src.overview(range), src.traffic(range)) + }); + PollOutcome::Overview { + overview: map_err(overview), + traffic: map_err(traffic), + } + } + Page::Queries => { + let (running, slow, failed) = chm_core::tokio_block_on(async { + tokio::join!( + src.running_queries(), + src.slow_queries(range), + src.failed_queries(range) + ) + }); + PollOutcome::Queries { + running: map_err(running), + slow: map_err(slow), + failed: map_err(failed), + } + } + Page::Merges => PollOutcome::Merges(map_err(chm_core::tokio_block_on(src.merges()))), + Page::Replicas => PollOutcome::Replicas(map_err(chm_core::tokio_block_on(src.replicas()))), + Page::Health => PollOutcome::Health(map_err(chm_core::tokio_block_on(src.health()))), + Page::Tables => PollOutcome::Tables(map_err(chm_core::tokio_block_on(src.tables()))), + Page::Traffic => { + PollOutcome::Traffic(map_err(chm_core::tokio_block_on(src.traffic(range)))) + } }; // Telemetry hook: fetch latency lands in PerfMetrics whenever the process // global exists; recording itself is opt-in via config.toml at startup. - let _ = perf().record_fetch(started.elapsed().as_secs_f64() * 1000.0); + let fetch_ms = started.elapsed().as_secs_f64() * 1000.0; + let _ = perf().record_fetch(fetch_ms); let at = chrono::Utc::now(); - let _ = this.update(cx, |shell, cx| shell.set_overview_data(result, at, cx)); + let _ = this.update(cx, |shell, cx| { + shell.apply_outcome(outcome, at, fetch_ms, cx) + }); } -async fn fetch_overview(src: &Arc>) -> Result { - chm_core::tokio_block_on(src.overview(chm_core::TimeRange::TwentyFourHours)) - .map_err(|e| e.to_string()) +/// Compact (icon strip) when the user collapsed it, otherwise when the +/// window is narrower than [`COMPACT_BELOW`]. +fn sidebar_is_compact(user: Option, narrow: bool) -> bool { + user.unwrap_or(narrow) } -fn placeholder(text: &'static str) -> bezel::gpui::Div { - div() - .flex() - .flex_1() - .items_center() - .justify_center() - .text_color(bezel::theme::ink(0.45)) - .text_size(px(13.0)) - .child(text) +#[cfg(test)] +mod tests { + use super::{SIDEBAR_W, clamp_sidebar_width, sidebar_is_compact, sidebar_width_from_cfg}; + + #[test] + fn sidebar_follows_viewport_until_toggled() { + assert!(!sidebar_is_compact(None, false)); + assert!(sidebar_is_compact(None, true)); + assert!(sidebar_is_compact(Some(true), false)); + assert!(!sidebar_is_compact(Some(false), true)); + assert!(sidebar_is_compact(Some(true), true)); + assert!(!sidebar_is_compact(Some(false), false)); + } + + #[test] + fn sidebar_width_clamps_and_defaults() { + assert_eq!(clamp_sidebar_width(80.0), 140.0); + assert_eq!(clamp_sidebar_width(500.0), 360.0); + assert_eq!(clamp_sidebar_width(200.0), 200.0); + assert_eq!(sidebar_width_from_cfg(None), SIDEBAR_W); + assert_eq!(sidebar_width_from_cfg(Some(220)), 220.0); + assert_eq!(sidebar_width_from_cfg(Some(10)), 140.0); + } } diff --git a/app/src/theme.rs b/app/src/theme.rs new file mode 100644 index 0000000..973baa9 --- /dev/null +++ b/app/src/theme.rs @@ -0,0 +1,195 @@ +//! macOS-native chrome: system UI font, SF/Menlo mono, HIG colors. +//! +//! Accent is system blue (`#007AFF` / `#0A84FF`). Surfaces follow window / +//! sidebar / separator labels from Apple's Human Interface palette so the +//! desktop client reads like Activity Monitor rather than the web dashboard. + +use gpui::{App, Hsla, rgb}; +use gpui_component::{Theme, ThemeMode}; + +use crate::density::Density; + +fn hx(v: u32) -> Hsla { + rgb(v).into() +} + +/// Apply system type and macOS semantic colors onto the active theme. +pub fn apply_brand(cx: &mut App) { + let dark = Theme::global(cx).is_dark(); + let density = Density::current(); + let theme = Theme::global_mut(cx); + theme.font_family = ".SystemUIFont".into(); + theme.font_size = gpui::px(density.font_size()); + theme.mono_font_family = if cfg!(target_os = "macos") { + "Menlo".into() + } else { + "ui-monospace".into() + }; + theme.mono_font_size = gpui::px(density.mono_font_size()); + theme.radius = gpui::px(density.radius()); + theme.radius_lg = gpui::px(density.radius_lg()); + if dark { + paint_dark(theme); + } else { + paint_light(theme); + } + Theme::sync_base(cx); +} + +fn paint_light(theme: &mut Theme) { + let blue = hx(0x007AFF); + let label = hx(0x1D1D1F); + let fill = hx(0xF2F2F7); + let hairline = hx(0xD1D1D6); + + theme.background = hx(0xFFFFFF); + theme.foreground = label; + theme.secondary = fill; + theme.secondary_foreground = label; + theme.secondary_hover = hx(0xE5E5EA); + theme.secondary_active = hx(0xD1D1D6); + theme.muted = fill; + theme.muted_foreground = hx(0x6E6E73); + theme.accent = hx(0xE5E5EA); + theme.accent_foreground = label; + theme.primary = blue; + theme.primary_foreground = hx(0xFFFFFF); + theme.primary_hover = hx(0x0066D6); + theme.primary_active = hx(0x0055C4); + theme.button_primary = theme.primary; + theme.button_primary_foreground = theme.primary_foreground; + theme.button_primary_hover = theme.primary_hover; + theme.border = hairline; + theme.input = hairline; + theme.ring = blue; + theme.selection = blue.opacity(0.28); + theme.danger = hx(0xFF3B30); + theme.danger_foreground = hx(0xFFFFFF); + theme.warning = hx(0xFF9F0A); + theme.warning_foreground = hx(0x1D1D1F); + theme.success = hx(0x34C759); + theme.success_foreground = hx(0xFFFFFF); + theme.green = hx(0x34C759); + theme.red = hx(0xFF3B30); + theme.blue = blue; + theme.sidebar = hx(0xF5F5F7); + theme.sidebar_foreground = label; + theme.sidebar_accent = hx(0xE5E5EA); + theme.sidebar_accent_foreground = label; + theme.sidebar_border = hairline; + theme.sidebar_primary = blue; + theme.sidebar_primary_foreground = hx(0xFFFFFF); + theme.chart_1 = blue; + theme.chart_2 = hx(0x5AC8FA); + theme.chart_3 = hx(0x64D2FF); + theme.chart_4 = hx(0x0A84FF); + theme.chart_5 = hx(0x0055C4); + theme.skeleton = hx(0xE5E5EA); + theme.popover = hx(0xFFFFFF); + theme.popover_foreground = label; + theme.title_bar = hx(0xF5F5F7); + theme.title_bar_border = hairline; + theme.status_bar = hx(0xF5F5F7); + theme.status_bar_border = hairline; + theme.switch = hx(0xD1D1D6); + theme.switch_thumb = hx(0xFFFFFF); + theme.tab_bar = fill; + theme.tab_bar_segmented = fill; + theme.tab = Hsla::transparent_black(); + theme.tab_active = hx(0xFFFFFF); + theme.tab_active_foreground = label; + theme.tab_foreground = hx(0x6E6E73); + theme.list_hover = hx(0xE5E5EA); + theme.list_active = blue.opacity(0.12); + theme.list_active_border = blue; + theme.table_head = fill; + theme.table_head_foreground = hx(0x6E6E73); + theme.table_row_border = hairline; + theme.table_hover = hx(0xF2F2F7); + theme.progress_bar = blue; + theme.scrollbar_thumb = hx(0xC7C7CC); + theme.overlay = hx(0x000000).opacity(0.18); +} + +fn paint_dark(theme: &mut Theme) { + let blue = hx(0x0A84FF); + let label = hx(0xF5F5F7); + let fill = hx(0x2C2C2E); + let hairline = hx(0x3A3A3C); + + theme.background = hx(0x1C1C1E); + theme.foreground = label; + theme.secondary = fill; + theme.secondary_foreground = label; + theme.secondary_hover = hx(0x3A3A3C); + theme.secondary_active = hx(0x48484A); + theme.muted = fill; + theme.muted_foreground = hx(0x8E8E93); + theme.accent = hx(0x3A3A3C); + theme.accent_foreground = label; + theme.primary = blue; + theme.primary_foreground = hx(0xFFFFFF); + theme.primary_hover = hx(0x409CFF); + theme.primary_active = hx(0x0070E0); + theme.button_primary = theme.primary; + theme.button_primary_foreground = theme.primary_foreground; + theme.button_primary_hover = theme.primary_hover; + theme.border = hairline; + theme.input = hairline; + theme.ring = blue; + theme.selection = blue.opacity(0.40); + theme.danger = hx(0xFF453A); + theme.danger_foreground = hx(0xFFFFFF); + theme.warning = hx(0xFF9F0A); + theme.warning_foreground = hx(0x1C1C1E); + theme.success = hx(0x30D158); + theme.success_foreground = hx(0x1C1C1E); + theme.green = hx(0x30D158); + theme.red = hx(0xFF453A); + theme.blue = blue; + theme.sidebar = hx(0x2C2C2E); + theme.sidebar_foreground = label; + theme.sidebar_accent = hx(0x3A3A3C); + theme.sidebar_accent_foreground = label; + theme.sidebar_border = hairline; + theme.sidebar_primary = blue; + theme.sidebar_primary_foreground = hx(0xFFFFFF); + theme.chart_1 = blue; + theme.chart_2 = hx(0x64D2FF); + theme.chart_3 = hx(0x5AC8FA); + theme.chart_4 = hx(0x007AFF); + theme.chart_5 = hx(0x409CFF); + theme.skeleton = hx(0x3A3A3C); + theme.popover = hx(0x2C2C2E); + theme.popover_foreground = label; + theme.title_bar = hx(0x2C2C2E); + theme.title_bar_border = hairline; + theme.status_bar = hx(0x2C2C2E); + theme.status_bar_border = hairline; + theme.switch = hx(0x39393D); + theme.switch_thumb = hx(0xFFFFFF); + theme.tab_bar = fill; + theme.tab_bar_segmented = fill; + theme.tab = Hsla::transparent_black(); + theme.tab_active = hx(0x3A3A3C); + theme.tab_active_foreground = label; + theme.tab_foreground = hx(0x8E8E93); + theme.list_hover = hx(0x3A3A3C); + theme.list_active = blue.opacity(0.22); + theme.list_active_border = blue; + theme.table_head = fill; + theme.table_head_foreground = hx(0x8E8E93); + theme.table_row_border = hairline; + theme.table_hover = hx(0x3A3A3C); + theme.progress_bar = blue; + theme.scrollbar_thumb = hx(0x636366); + theme.overlay = hx(0x000000).opacity(0.45); +} + +pub fn current_mode(cx: &App) -> ThemeMode { + if Theme::global(cx).is_dark() { + ThemeMode::Dark + } else { + ThemeMode::Light + } +} diff --git a/app/src/updater.rs b/app/src/updater.rs new file mode 100644 index 0000000..6f3cef9 --- /dev/null +++ b/app/src/updater.rs @@ -0,0 +1,127 @@ +//! Download cache path and macOS `.app` install from a release zip. + +use std::path::PathBuf; + +#[cfg(target_os = "macos")] +use std::path::Path; + +use chm_update::ReleaseInfo; + +/// `~/Library/Caches/chmonitor/updates` (macOS) or the platform cache dir. +pub fn cache_dir() -> Option { + dirs::cache_dir().map(|d| d.join("chmonitor").join("updates")) +} + +pub fn archive_path(release: &ReleaseInfo) -> Option { + let name = release + .url() + .rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("chmonitor-update.bin"); + cache_dir().map(|d| { + d.join(format!("v{name}", name = release.version())) + .join(name) + }) +} + +/// If this process is running from `chmonitor.app/Contents/MacOS/…`, +/// return the `.app` bundle path. +pub fn macos_bundle_path() -> Option { + let exe = std::env::current_exe().ok()?; + let macos = exe.parent()?; + if macos.file_name()?.to_str()? != "MacOS" { + return None; + } + let contents = macos.parent()?; + if contents.file_name()?.to_str()? != "Contents" { + return None; + } + let bundle = contents.parent()?; + if bundle.extension()?.to_str()? != "app" { + return None; + } + Some(bundle.to_path_buf()) +} + +/// Unpack a macOS zip (`.app` inside) and swap it over the running bundle. +/// Returns the installed `.app` path. Caller should relaunch and quit. +#[cfg(target_os = "macos")] +pub fn install_macos_zip(zip: &Path) -> Result { + let bundle = macos_bundle_path().ok_or_else(|| { + "not running from chmonitor.app — open the zip from Downloads".to_string() + })?; + let parent = bundle + .parent() + .ok_or_else(|| "app bundle has no parent directory".to_string())?; + let stage = parent.join(format!(".chmonitor-update-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&stage); + std::fs::create_dir_all(&stage).map_err(|e| format!("stage mkdir: {e}"))?; + let status = std::process::Command::new("/usr/bin/ditto") + .args(["-xk", "--"]) + .arg(zip) + .arg(&stage) + .status() + .map_err(|e| format!("ditto: {e}"))?; + if !status.success() { + let _ = std::fs::remove_dir_all(&stage); + return Err(format!("ditto exited {status}")); + } + let fresh = find_app(&stage).ok_or_else(|| { + let _ = std::fs::remove_dir_all(&stage); + "zip did not contain a .app bundle".to_string() + })?; + let backup = parent.join(format!("chmonitor.app.bak-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&backup); + std::fs::rename(&bundle, &backup).map_err(|e| { + let _ = std::fs::remove_dir_all(&stage); + format!("move running app aside: {e}") + })?; + let dest = parent.join("chmonitor.app"); + if let Err(e) = std::fs::rename(&fresh, &dest) { + let _ = std::fs::rename(&backup, &bundle); + let _ = std::fs::remove_dir_all(&stage); + return Err(format!("install new app: {e}")); + } + let _ = std::fs::remove_dir_all(&backup); + let _ = std::fs::remove_dir_all(&stage); + Ok(dest) +} + +#[cfg(target_os = "macos")] +fn find_app(root: &Path) -> Option { + let mut found = None; + let walker = std::fs::read_dir(root).ok()?; + for entry in walker.flatten() { + let p = entry.path(); + if p.extension().and_then(|e| e.to_str()) == Some("app") { + return Some(p); + } + if p.is_dir() + && let Some(inner) = find_app(&p) + { + found = Some(inner); + } + } + found +} + +#[cfg(target_os = "macos")] +pub fn relaunch(app: &Path) { + let _ = std::process::Command::new("/usr/bin/open").arg(app).spawn(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn archive_path_nests_under_version() { + let release = serde_json::from_str::( + r#"{"version":"1.2.3","url":"https://x/chmonitor-v1.2.3.zip","notes":""}"#, + ) + .unwrap(); + let path = archive_path(&release).expect("cache dir"); + assert!(path.ends_with("v1.2.3/chmonitor-v1.2.3.zip")); + } +} diff --git a/app/src/widgets/cards.rs b/app/src/widgets/cards.rs index 1e47a04..7aec189 100644 --- a/app/src/widgets/cards.rs +++ b/app/src/widgets/cards.rs @@ -1,44 +1,100 @@ -//! Metric card: a labeled headline number with an optional sub-line, painted -//! from the theme tokens without needing an `App` in scope. +//! Metric card: a labeled headline number with an optional sub-line. +//! KPI variant matches dash.chmonitor.dev overview tiles (uppercase label, +//! unit, optional storage bar). -use bezel::gpui::{div, prelude::*, px}; -use bezel::theme::{Theme, current_appearance}; +use gpui::{App, FontWeight, div, prelude::*, px, relative}; +use gpui_component::ActiveTheme as _; -/// Resolve the theme without a context: the process-wide appearance mirror -/// plus the palette builder give exactly what `Theme::of(cx)` would return. -pub(crate) fn theme_now() -> Theme { - Theme::for_appearance(current_appearance()) -} +use crate::density::Density; /// A stat tile: small muted label, large value, optional muted sub-line. -pub fn metric_card(label: &str, value: &str, sub: Option<&str>) -> impl IntoElement { - let t = theme_now(); - let label = label.to_string(); - let value = value.to_string(); - let sub = sub.map(str::to_string); +pub fn metric_card( + label: impl Into, + value: impl Into, + sub: Option, + cx: &App, +) -> impl IntoElement { + kpi_card(label, value, None, sub, None, cx) +} + +/// Dashboard-style KPI: `LABEL` → `value unit` → optional bar → sub-line. +pub fn kpi_card( + label: impl Into, + value: impl Into, + unit: Option<&str>, + sub: Option, + progress: Option, + cx: &App, +) -> impl IntoElement { + let label = label.into(); + let value = value.into(); + let unit = unit.map(str::to_string); + let d = Density::current(); + let pct = progress.map(|p| p.clamp(0.0, 100.0)); + let bar_color = match pct { + Some(p) if p > 90.0 => cx.theme().danger, + Some(p) if p > 75.0 => cx.theme().warning, + _ => cx.theme().primary, + }; div() .flex() .flex_col() - .gap(px(4.0)) - .p(px(12.0)) - .min_w(px(140.0)) + .gap(px(6.)) + .p(px(d.card_pad())) + .min_w(px(d.card_min_w())) .flex_1() - .bg(t.surface_card) + .bg(cx.theme().background) .border_1() - .border_color(t.border) - .rounded(px(Theme::PANEL_RADIUS)) + .border_color(cx.theme().border) + .rounded(cx.theme().radius) .child( div() - .text_size(px(11.0)) - .text_color(t.text_muted) + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().muted_foreground) .child(label), ) - .child(div().text_size(px(20.0)).text_color(t.text).child(value)) + .child( + div() + .flex() + .flex_row() + .items_baseline() + .gap(px(6.)) + .child( + div() + .text_size(px(d.card_value())) + .font_weight(FontWeight::SEMIBOLD) + .font_family(cx.theme().mono_font_family.clone()) + .text_color(cx.theme().foreground) + .child(value), + ) + .children(unit.map(|unit| { + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(cx.theme().muted_foreground) + .child(unit) + })), + ) + .children(pct.map(|p| { + div() + .w_full() + .h(px(4.)) + .rounded_full() + .bg(cx.theme().muted) + .child( + div() + .h_full() + .w(relative(p / 100.0)) + .rounded_full() + .bg(bar_color), + ) + })) .children(sub.map(|sub| { div() - .text_size(px(11.0)) - .text_color(t.text_faint) + .text_xs() + .text_color(cx.theme().muted_foreground) .child(sub) })) } diff --git a/app/src/widgets/chart.rs b/app/src/widgets/chart.rs index 7ea1a18..4e40eb8 100644 --- a/app/src/widgets/chart.rs +++ b/app/src/widgets/chart.rs @@ -1,17 +1,18 @@ -//! Time-series line chart: axes, tick labels and one polyline per series, -//! painted on a gpui [`canvas`](bezel::gpui::canvas). The math lives in -//! [`geometry`](super::geometry); this file only turns pixels into paint. +//! Time-series chart via gpui-component [`AreaChart`] / [`LineChart`]. -use super::geometry::{Bounds, format_count, nice_scale, points_to_px}; -use bezel::gpui::{ - App, Bounds as GBounds, Font, FontFeatures, FontWeight, Hsla, IntoElement, PathBuilder, Pixels, - TextAlign, TextRun, Window, canvas, div, fill, font, point, prelude::*, px, size, -}; -use bezel::theme::{Theme, current_appearance, hairline}; use chm_core::SeriesPoint; +use gpui::{ + App, FontWeight, SharedString, div, linear_color_stop, linear_gradient, prelude::*, px, +}; +use gpui_component::{ + ActiveTheme as _, + chart::{AreaChart, LineChart}, + h_flex, v_flex, +}; -/// One named line on a chart. `accent` picks the theme's accent color instead -/// of the muted foreground, so a primary series can out-shine its peers. +use super::geometry::format_time_of_day; + +/// One named line on a chart. `accent` picks the primary chart color. #[derive(Debug, Clone, Default, PartialEq)] pub struct NamedSeries { pub name: String, @@ -19,269 +20,115 @@ pub struct NamedSeries { pub accent: bool, } -/// Layout metrics for one chart, resolved against the actual element size. -struct ChartLayout { - plot: Bounds, -} - -const PAD_LEFT: f64 = 46.0; -const PAD_RIGHT: f64 = 12.0; -const PAD_TOP: f64 = 30.0; -const PAD_BOTTOM: f64 = 22.0; -const MIN_PLOT_W: f64 = 40.0; -const MIN_PLOT_H: f64 = 40.0; -const STROKE_WIDTH: f32 = 1.5; -const AXIS_FONT_SIZE: f32 = 10.0; - -fn mono_font(t: &Theme) -> Font { - let mut f = font(t.font_mono.clone()); - f.features = FontFeatures::default(); - f +#[derive(Clone)] +struct ChartPt { + x: String, + y: f64, } -/// Tick label text: compact counts, except bytes-style units which keep one -/// decimal (`2.5G` vs `1.0 GiB` both read fine on an axis). -fn tick_label(v: f64) -> String { - format_count(v) +fn to_pts(points: &[SeriesPoint]) -> Vec { + points + .iter() + .map(|p| ChartPt { + x: format_time_of_day(p.t_ms), + y: p.v, + }) + .collect() } -/// The full chart element: title, plot area with grid lines, tick labels on -/// both axes, and one polyline per series. Empty series render the empty -/// frame; nothing panics on degenerate data. -pub fn line_chart(title: &str, unit: &str, series: Vec) -> impl IntoElement { - let t = Theme::for_appearance(current_appearance()); - let title = title.to_string(); - let unit = unit.to_string(); - - let title_el = div() - .text_size(px(12.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(t.text) - .child(title); - - let unit_el = div() - .text_size(px(11.0)) - .text_color(t.text_faint) - .child(unit); - - let header = div() - .flex() - .flex_row() - .items_baseline() - .gap(px(8.0)) - .child(title_el) - .child(unit_el); - let plot_bg_color = t.surface; - let grid_color = hairline(0.07); - let axis_text_color = t.text_muted; - let line_color_muted = t.text_muted; - let line_color_accent = t.accent; - let mono = mono_font(&t); - let font_size = px(AXIS_FONT_SIZE); - - div() - .flex_1() - .min_w_0() - .min_h_0() - .flex() - .flex_col() - .gap(px(6.0)) - .child(header) - .child(div().flex_1().min_h_0().w_full().child(canvas( - move |bounds: GBounds, _window: &mut Window, _cx: &mut App| { - let w = - f64::from(bounds.size.width.as_f32()).max(PAD_LEFT + PAD_RIGHT + MIN_PLOT_W); - let h = - f64::from(bounds.size.height.as_f32()).max(PAD_TOP + PAD_BOTTOM + MIN_PLOT_H); - ChartLayout { - plot: Bounds { - x: PAD_LEFT, - y: PAD_TOP, - w: w - PAD_LEFT - PAD_RIGHT, - h: h - PAD_TOP - PAD_BOTTOM, - }, - } - }, - move |bounds: GBounds, - layout: ChartLayout, - window: &mut Window, - _cx: &mut App| { - let origin = bounds.origin; - let plot = layout.plot; - - window.paint_quad(fill( - GBounds:: { - origin: origin + point(px(plot.x as f32), px(plot.y as f32)), - size: size(px(plot.w as f32), px(plot.h as f32)), - }, - plot_bg_color, - )); - - let all_values: Vec = series - .iter() - .flat_map(|s| s.points.iter().map(|p| p.v)) - .collect(); - let (data_min, data_max) = if all_values.is_empty() { - (0.0, 1.0) - } else { - let mut lo = f64::INFINITY; - let mut hi = f64::NEG_INFINITY; - for v in &all_values { - if v.is_finite() { - lo = lo.min(*v); - hi = hi.max(*v); - } - } - if !lo.is_finite() || !hi.is_finite() { - (0.0, 1.0) - } else { - (lo, hi) - } - }; - - let (y_min, y_max, y_ticks) = nice_scale(data_min, data_max, 4); - let y_span = if y_max > y_min { y_max - y_min } else { 1.0 }; - - for tick in &y_ticks { - let frac = (tick - y_min) / y_span; - let y = plot.y + plot.h - frac * plot.h; - if y < plot.y - 0.5 || y > plot.y + plot.h + 0.5 { - continue; - } - let y = y.clamp(plot.y, plot.y + plot.h); - window.paint_quad(fill( - GBounds:: { - origin: origin + point(px(plot.x as f32), px(y as f32 - 0.5)), - size: size(px(plot.w as f32), px(1.0)), - }, - grid_color, - )); - let label = tick_label(*tick); - let label_len = label.len(); - let shaped = window - .text_system() - .shape_text( - label.into(), - font_size, - &[TextRun { - len: label_len, - font: mono.clone(), - color: axis_text_color, - background_color: None, - underline: None, - strikethrough: None, - }], - None, - None, - ) - .ok(); - if let Some(mut lines) = shaped - && let Some(line) = lines.first_mut() - { - let line_w = line.unwrapped_layout.width; - let lx = origin.x + px(plot.x as f32) - line_w - px(6.0); - let ly = origin.y + px(y as f32) - px(AXIS_FONT_SIZE * 0.62); - let _ = line.paint( - point(lx, ly), - px(AXIS_FONT_SIZE * 1.25), - TextAlign::Right, - None, - window, - _cx, - ); - } - } - - let x_ticks = super::geometry::x_time_ticks( - series.first().map(|s| s.points.as_slice()).unwrap_or(&[]), - plot.w, - 5, - ); - for (t_ms, frac) in x_ticks { - if !(0.0..=1.0).contains(&frac) { - continue; - } - let x = plot.x + frac * plot.w; - let label = super::geometry::format_time_of_day(t_ms); - let shaped = window - .text_system() - .shape_text( - label.clone().into(), - font_size, - &[TextRun { - len: label.len(), - font: mono.clone(), - color: axis_text_color, - background_color: None, - underline: None, - strikethrough: None, - }], - None, - None, - ) - .ok(); - if let Some(mut lines) = shaped - && let Some(line) = lines.first_mut() - { - let lx = origin.x + px(x as f32) - line.unwrapped_layout.width / 2.0; - let ly = origin.y + px((plot.y + plot.h) as f32) + px(5.0); - let _ = line.paint( - point(lx, ly), - px(AXIS_FONT_SIZE * 1.25), - TextAlign::Left, - None, - window, - _cx, - ); - } - } - - let px_points: Vec> = series - .iter() - .map(|s| points_to_px(&s.points, plot, y_min, y_max)) - .collect(); - for (s, pts) in series.iter().zip(px_points) { - if pts.len() < 2 { - continue; - } - let color: Hsla = if s.accent { - line_color_accent - } else { - line_color_muted - }; - let mut builder = PathBuilder::stroke(px(STROKE_WIDTH)); - let first = pts[0]; - builder.move_to(point( - origin.x + px(first.0 as f32), - origin.y + px(first.1 as f32), - )); - for p in &pts[1..] { - builder - .line_to(point(origin.x + px(p.0 as f32), origin.y + px(p.1 as f32))); - } - if let Ok(stroke_path) = builder.build() { - window.paint_path(stroke_path, color); - } - } - }, - ))) +/// Title + unit + a filled area (accent) or a line (muted). Empty series +/// still render the frame so layout does not jump. +pub fn line_chart(title: &str, unit: &str, series: Vec, cx: &App) -> impl IntoElement { + let title = SharedString::from(title.to_string()); + let unit = SharedString::from(unit.to_string()); + let primary = series.iter().find(|s| s.accent).or(series.first()); + let pts = primary.map(|s| to_pts(&s.points)).unwrap_or_default(); + let accent = primary.map(|s| s.accent).unwrap_or(true); + let color = if accent { + cx.theme().chart_1 + } else { + cx.theme().chart_2 + }; + let tick_margin = (pts.len() / 6).max(1); + let fill = linear_gradient( + 0., + linear_color_stop(color.opacity(0.4), 1.), + linear_color_stop(cx.theme().background.opacity(0.1), 0.), + ); + + v_flex() + .gap_1() + .w_full() + .h_full() + .min_h(px(120.)) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .p_3() + .child( + h_flex() + .items_baseline() + .gap_2() + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().foreground) + .child(title), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(unit), + ), + ) + .child(div().flex_1().min_h(px(80.)).w_full().map(|el| { + if pts.is_empty() { + el.child( + div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("no data"), + ) + } else if accent { + el.child( + AreaChart::new(pts) + .x(|d| d.x.clone()) + .y(|d| d.y) + .stroke(color) + .fill(fill) + .linear() + .tick_margin(tick_margin), + ) + } else { + el.child( + LineChart::new(pts) + .x(|d| d.x.clone()) + .y(|d| d.y) + .stroke(color) + .linear() + .tick_margin(tick_margin), + ) + } + })) } -/// Baseline helper so callers can sanity-check a series against the axis the -/// chart would choose, without painting. -pub fn chart_axis_for(series: &[NamedSeries]) -> (f64, f64, Vec) { - let mut lo = f64::INFINITY; - let mut hi = f64::NEG_INFINITY; - for s in series { - for p in &s.points { - if p.v.is_finite() { - lo = lo.min(p.v); - hi = hi.max(p.v); - } - } - } - if !lo.is_finite() || !hi.is_finite() { - return nice_scale(0.0, 1.0, 4); +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn to_pts_uses_utc_clock_labels() { + let pts = to_pts(&[SeriesPoint { + t_ms: 3_600_000, + v: 1.5, + }]); + assert_eq!(pts[0].x, "01:00"); + assert_eq!(pts[0].y, 1.5); } - nice_scale(lo, hi, 4) } diff --git a/app/src/widgets/controls.rs b/app/src/widgets/controls.rs new file mode 100644 index 0000000..cccd46d --- /dev/null +++ b/app/src/widgets/controls.rs @@ -0,0 +1,141 @@ +//! Application-owned presentation on gpui-base primitives. +//! +//! Base owns focus, keyboard, and accessibility. Theme tokens and layout +//! stay here so the product is not locked to gpui-component's default look. + +use gpui::{App, ElementId, FontWeight, SharedString, div, prelude::*, px, relative}; +use gpui_base::{Button, Radio, RadioGroup, Toggle, ToggleGroup}; +use gpui_component::{ActiveTheme as _, Sizable as _, switch::Switch}; + +pub fn primary_button( + id: impl Into, + label: impl Into, + cx: &App, +) -> Button { + Button::new(id) + .px_3() + .h_8() + .flex() + .items_center() + .rounded(cx.theme().radius) + .bg(cx.theme().primary) + .text_color(cx.theme().primary_foreground) + .hover(|s| s.opacity(0.9)) + .child(label.into()) +} + +pub fn ghost_button(id: impl Into, label: impl Into, cx: &App) -> Button { + Button::new(id) + .px_3() + .h_8() + .flex() + .items_center() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .hover(|s| s.bg(cx.theme().accent)) + .child(label.into()) +} + +/// A labeled radio option with an optional hint line. +pub fn choice_radio( + id: impl Into, + checked: bool, + label: impl Into, + hint: impl Into, + cx: &App, +) -> Radio { + let primary = cx.theme().primary; + let muted = cx.theme().muted_foreground; + Radio::new(id) + .checked(checked) + .flex() + .items_start() + .gap_2() + .px_3() + .py_2() + .rounded(cx.theme().radius) + .border_1() + .border_color(if checked { + cx.theme().primary + } else { + cx.theme().border + }) + .bg(if checked { + cx.theme().accent + } else { + cx.theme().background + }) + .child( + div() + .mt(px(2.)) + .flex() + .items_center() + .justify_center() + .size(px(14.)) + .rounded_full() + .border_1() + .border_color(primary) + .when(checked, |dot| { + dot.child(div().size(px(6.)).rounded_full().bg(primary)) + }), + ) + .child( + div() + .flex() + .flex_col() + .gap_1() + .child(div().text_sm().child(label.into())) + .child(div().text_xs().text_color(muted).child(hint.into())), + ) +} + +pub fn radio_group(id: impl Into) -> RadioGroup { + RadioGroup::new(id).flex().flex_col().gap_2() +} + +pub fn range_toggle( + id: impl Into, + pressed: bool, + label: impl Into, + cx: &App, +) -> Toggle { + Toggle::new(id) + .pressed(pressed) + .px_2() + .h(px(22.)) + .flex() + .items_center() + .justify_center() + .text_xs() + .font_weight(if pressed { + FontWeight::SEMIBOLD + } else { + FontWeight::NORMAL + }) + .line_height(relative(1.)) + .rounded(px(5.)) + .when(pressed, |t| { + t.bg(cx.theme().background) + .text_color(cx.theme().foreground) + }) + .when(!pressed, |t| t.text_color(cx.theme().muted_foreground)) + .hover(|s| s.bg(cx.theme().background.opacity(0.7))) + .child(label.into()) +} + +/// macOS segmented control track. +pub fn range_group(id: impl Into, cx: &App) -> ToggleGroup { + ToggleGroup::new(id) + .flex() + .items_center() + .gap(px(1.)) + .p(px(2.)) + .rounded(px(7.)) + .bg(cx.theme().muted) +} + +/// Native gpui-component switch (system-blue when on). +pub fn theme_switch(id: impl Into, checked: bool, _cx: &App) -> Switch { + Switch::new(id).checked(checked).small() +} diff --git a/app/src/widgets/mod.rs b/app/src/widgets/mod.rs index 86e26fd..3c1551c 100644 --- a/app/src/widgets/mod.rs +++ b/app/src/widgets/mod.rs @@ -1,12 +1,13 @@ //! Reusable dashboard widgets: metric cards, line charts, data tables and -//! their shared geometry math. Owned by Agent E; all gpui types flow through -//! `bezel::gpui`. +//! their shared geometry math. pub mod cards; pub mod chart; +pub mod controls; pub mod geometry; +pub mod skeleton; pub mod table; -pub use cards::metric_card; +pub use cards::{kpi_card, metric_card}; pub use chart::{NamedSeries, line_chart}; pub use table::{CellVal, Column, data_table}; diff --git a/app/src/widgets/skeleton.rs b/app/src/widgets/skeleton.rs new file mode 100644 index 0000000..e1275d2 --- /dev/null +++ b/app/src/widgets/skeleton.rs @@ -0,0 +1,83 @@ +//! Dashboard-style skeleton placeholders (gpui-component Skeleton). + +use gpui::{App, div, prelude::*, px}; +use gpui_component::{ActiveTheme as _, skeleton::Skeleton}; + +use crate::density::Density; + +fn bone(w: f32, h: f32) -> Skeleton { + Skeleton::new().w(px(w)).h(px(h)).rounded_md() +} + +pub fn metric_grid(cx: &App) -> impl IntoElement { + let border = cx.theme().border; + let radius = cx.theme().radius; + let d = Density::current(); + let per_row = d.metrics_per_row(); + let rows = 4usize.div_ceil(per_row); + div() + .flex() + .flex_col() + .gap(px(d.card_gap())) + .w_full() + .children((0..rows).map(move |_| { + div() + .flex() + .flex_row() + .gap(px(d.card_gap())) + .children((0..per_row).map(move |_| { + div() + .flex() + .flex_col() + .gap_2() + .p(px(d.card_pad())) + .flex_1() + .min_w(px(d.card_min_w())) + .border_1() + .border_color(border) + .rounded(radius) + .child(bone(64., 8.)) + .child(bone(80., 16.)) + })) + })) +} + +pub fn chart_block(cx: &App) -> impl IntoElement { + let d = Density::current(); + div() + .flex() + .flex_col() + .gap_2() + .w_full() + .h(px(d.chart_h())) + .p(px(d.card_pad())) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .child(bone(96., 10.)) + .child(div().flex_1().w_full().child(bone(360., d.chart_h() - 36.))) +} + +pub fn table_block(cx: &App) -> impl IntoElement { + let border = cx.theme().border; + div() + .flex() + .flex_col() + .w_full() + .border_1() + .border_color(border) + .rounded(cx.theme().radius) + .children((0..8).map(|i| { + div() + .flex() + .flex_row() + .gap_3() + .px_3() + .py_2() + .when(i > 0, |r| r.border_t_1().border_color(border)) + .child(bone(80., 10.)) + .child(bone(140., 10.)) + .child(bone(60., 10.)) + .child(div().flex_1().child(bone(180., 10.))) + })) +} diff --git a/app/src/widgets/table.rs b/app/src/widgets/table.rs index 8402259..cbbb713 100644 --- a/app/src/widgets/table.rs +++ b/app/src/widgets/table.rs @@ -1,10 +1,13 @@ -//! Data table: header row, zebra-striped body rows, monospace right-aligned -//! numerics. Cell values render through the [`geometry`](super::geometry) -//! formatters so units stay consistent across the app. +//! Data table: header row, body rows, right-aligned numerics. +//! Semantic structure comes from gpui-base; colors and density from the theme. + +use gpui::{App, ElementId, div, prelude::*, px}; +use gpui_base::{Table, TableBody, TableCell, TableHead, TableHeader, TableRow}; +use gpui_component::ActiveTheme as _; + +use crate::density::Density; use super::geometry::{format_bytes, format_count, format_duration_ms}; -use bezel::gpui::{Div, Hsla, div, prelude::*, px}; -use bezel::theme::{Theme, current_appearance, hairline}; /// One cell's value; the variant picks the formatter and the alignment. #[derive(Debug, Clone, PartialEq)] @@ -19,123 +22,103 @@ pub enum CellVal { DurMs(f64), } -/// A column: header label plus an optional fixed width in logical pixels. -/// `None` lets the column flex with the container. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct Column { - pub name: String, - pub width: Option, -} - -/// Render one cell to a styled div. Public within the crate so pages can -/// reuse the exact cell styling inside custom rows. impl CellVal { - pub fn render(self) -> Div { - let t = Theme::for_appearance(current_appearance()); + fn display(&self) -> String { match self { - CellVal::Text(text) => div() - .text_size(px(11.0)) - .text_color(t.text) - .truncate() - .child(text), - CellVal::Num(n) => numeric_cell(format_count(n), t.text), - CellVal::Bytes(b) => numeric_cell(format_bytes(b), t.text), - CellVal::DurMs(ms) => numeric_cell(format_duration_ms(ms), t.text), + CellVal::Text(text) => text.clone(), + CellVal::Num(n) => format_count(*n), + CellVal::Bytes(b) => format_bytes(*b), + CellVal::DurMs(ms) => format_duration_ms(*ms), } } + + fn numeric(&self) -> bool { + !matches!(self, CellVal::Text(_)) + } } -fn numeric_cell(text: String, color: Hsla) -> Div { - let t = Theme::for_appearance(current_appearance()); - div() - .font_family(t.font_mono.clone()) - .text_size(px(11.0)) - .text_color(color) - .flex() - .justify_end() - .child(text) +/// A column: header label plus an optional fixed width in logical pixels. +/// `None` lets the column flex with the container. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Column { + pub name: String, + pub width: Option, } -/// Header + striped rows. Rows may be ragged: short rows simply leave the +/// Header + rows. Rows may be ragged: short rows simply leave the /// trailing columns empty. -pub fn data_table(columns: Vec, rows: Vec>) -> impl IntoElement { - let t = Theme::for_appearance(current_appearance()); - let border = t.border; - let stripe = hairline(0.03); - let muted = t.text_muted; +pub fn data_table( + id: impl Into, + columns: Vec, + rows: Vec>, + cx: &App, +) -> impl IntoElement { + let id = id.into(); + let border = cx.theme().border; + let muted = cx.theme().muted_foreground; + let header_bg = cx.theme().secondary; + let n_cols = columns.len(); + let n_rows = rows.len(); + let d = Density::current(); + let px_cell = d.table_px(); + let py_cell = d.table_py(); - let mut header_cells = Vec::with_capacity(columns.len()); - for col in &columns { - let mut cell = div().child( - div() - .text_size(px(10.0)) + let header = TableHeader::new("header").child(TableRow::new("header-row", 1).flex().children( + columns.iter().enumerate().map(|(i, col)| { + let mut head = TableHead::new(("head", i), i + 1) + .px(px(px_cell)) + .py(px(py_cell)) + .text_xs() .text_color(muted) - .truncate() - .child(col.name.clone()), - ); - match col.width { - Some(w) => cell = cell.w(px(w)).flex_none(), - None => cell = cell.flex_1(), - } - header_cells.push(cell); - } - - let mut row_els = Vec::with_capacity(rows.len()); - for (ix, cells) in rows.iter().enumerate() { - let mut row = div() - .flex() - .flex_row() - .items_center() - .gap(px(12.0)) - .px(px(12.0)) - .py(px(6.0)); - if ix % 2 == 1 { - row = row.bg(stripe); - } - for (col_ix, value) in cells.iter().enumerate() { - let align_right = !matches!(value, CellVal::Text(_)); - let mut cell = value.clone().render(); - if align_right - && let Some(col) = columns.get(col_ix) - && col.width.is_none() - { - cell = cell.flex_1(); - } else if columns.get(col_ix).is_some_and(|c| c.width.is_some()) { - cell = cell.flex_none(); - if let Some(w) = columns.get(col_ix).and_then(|c| c.width) { - cell = cell.w(px(w)); - } + .child(col.name.clone()); + if let Some(w) = col.width { + head = head.w(px(w)).flex_none(); } else { - cell = cell.flex_1(); + head = head.flex_1(); } - row = row.child(cell); - } - for _ in cells.len()..columns.len() { - row = row.child(div().flex_1()); - } - row_els.push(row); - } + head + }), + )); - div() + let body = + TableBody::new("body").children(rows.into_iter().enumerate().map(|(row_ix, cells)| { + let cols = &columns; + TableRow::new(("row", row_ix), row_ix + 2) + .flex() + .border_t_1() + .border_color(border) + .children((0..cols.len()).map(|i| { + let mut cell = match cells.get(i) { + Some(value) => { + let mut c = TableCell::new(format!("cell-{row_ix}-{i}"), i + 1) + .px(px(px_cell)) + .py(px(py_cell)) + .text_xs() + .child(value.display()); + if value.numeric() { + c = c.text_right(); + } + c + } + None => TableCell::new(format!("empty-{row_ix}-{i}"), i + 1).child(div()), + }; + if let Some(w) = cols.get(i).and_then(|c| c.width) { + cell = cell.w(px(w)).flex_none(); + } else { + cell = cell.flex_1(); + } + cell + })) + })); + + Table::new(id) .w_full() .overflow_hidden() .border_1() .border_color(border) - .rounded(px(Theme::PANEL_RADIUS)) - .flex() - .flex_col() - .child( - div() - .flex() - .flex_row() - .items_center() - .gap(px(12.0)) - .px(px(12.0)) - .py(px(8.0)) - .bg(hairline(0.04)) - .border_b_1() - .border_color(border) - .children(header_cells), - ) - .children(row_els) + .rounded(cx.theme().radius) + .row_count(n_rows + 1) + .column_count(n_cols) + .child(header.bg(header_bg)) + .child(body) } diff --git a/assets/icon/icon-1024.png b/assets/icon/icon-1024.png new file mode 100644 index 0000000..5683d49 Binary files /dev/null and b/assets/icon/icon-1024.png differ diff --git a/assets/icon/logo.svg b/assets/icon/logo.svg new file mode 100644 index 0000000..a8b3cd8 --- /dev/null +++ b/assets/icon/logo.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/crates/chm-clickhouse/src/lib.rs b/crates/chm-clickhouse/src/lib.rs index 3f63e30..b1782db 100644 --- a/crates/chm-clickhouse/src/lib.rs +++ b/crates/chm-clickhouse/src/lib.rs @@ -83,16 +83,26 @@ ORDER BY elapsed DESC LIMIT 100"#; /// chmonitor dashboard overview headline block. +/// Running / schema counts match `apps/dashboard/src/lib/api/charts/overview-charts.ts`. pub const Q_OVERVIEW_MAIN: &str = r#" SELECT - (SELECT count() FROM system.processes) AS running_queries, + (SELECT count() FROM system.processes WHERE is_cancelled = 0) AS running_queries, (SELECT count() FROM system.merges) + (SELECT countIf(NOT is_done) FROM system.mutations) AS active_merges, - (SELECT count() FROM system.tables) AS tables_total, + (SELECT countDistinct(database) FROM system.tables + WHERE lower(database) NOT IN ('system', 'information_schema')) AS databases_total, + (SELECT countDistinct(format('{}.{}', database, name)) FROM system.tables + WHERE lower(database) NOT IN ('system', 'information_schema')) AS tables_total, (SELECT count() FROM system.parts WHERE active) AS parts_total, (SELECT coalesce(sum(bytes_on_disk), 0) FROM system.parts WHERE active) AS disk_used_bytes, uptime() AS uptime_seconds, version() AS clickhouse_version"#; +/// Dashboard `query-count-today`: QueryFinish rows since local midnight. +pub const Q_OVERVIEW_TODAY: &str = r#" +SELECT count() AS v +FROM system.query_log +WHERE type = 'QueryFinish' AND toDate(event_time) = today()"#; + /// qps denominator comes from the caller-selected range window. pub const Q_OVERVIEW_QPS: &str = r#" SELECT countIf(type = 'QueryStart') AS started_queries @@ -298,6 +308,7 @@ impl DataSource for ClickHouseClient { .await?; let reps: Vec = self.query_rows(Q_OVERVIEW_REPLICAS).await?; let disk_total_bytes = self.scalar_u64(Q_OVERVIEW_DISKS).await?; + let queries_today = self.scalar_u64(Q_OVERVIEW_TODAY).await.unwrap_or(0); let main = main.into_iter().next().unwrap_or_default(); let qps_row = qps_row.into_iter().next().unwrap_or_default(); @@ -319,6 +330,8 @@ impl DataSource for ClickHouseClient { disk_total_bytes, uptime_seconds: main.uptime_seconds, clickhouse_version: main.clickhouse_version, + databases_total: main.databases_total, + queries_today, }) }) } @@ -596,6 +609,8 @@ mod raw { #[serde(default)] pub active_merges: u64, #[serde(default)] + pub databases_total: u64, + #[serde(default)] pub tables_total: u64, #[serde(default)] pub parts_total: u64, @@ -783,6 +798,15 @@ mod sql_snapshots { assert!(Q_RUNNING.contains("LIMIT 100")); } + #[test] + fn overview_main_matches_dashboard_kpis() { + assert!(Q_OVERVIEW_MAIN.contains("FROM system.processes WHERE is_cancelled = 0")); + assert!(Q_OVERVIEW_MAIN.contains("NOT IN ('system', 'information_schema')")); + assert!(Q_OVERVIEW_MAIN.contains("AS databases_total")); + assert!(Q_OVERVIEW_TODAY.contains("toDate(event_time) = today()")); + assert!(Q_OVERVIEW_TODAY.contains("type = 'QueryFinish'")); + } + #[test] fn slow_queries_shape() { assert!(Q_SLOW_QUERIES.contains("FROM system.query_log")); @@ -1165,11 +1189,12 @@ mod wiremock_tests { fn overview_routes() -> Vec<(&'static str, String)> { vec![ - ("uptime()", r#"{"running_queries":12,"active_merges":5,"tables_total":142,"parts_total":8931,"disk_used_bytes":549755813888,"uptime_seconds":1036800,"clickhouse_version":"25.3.1.1"}"#.to_string()), + ("uptime()", r#"{"running_queries":12,"active_merges":5,"databases_total":8,"tables_total":142,"parts_total":8931,"disk_used_bytes":549755813888,"uptime_seconds":1036800,"clickhouse_version":"25.3.1.1"}"#.to_string()), ("countIf(type = 'QueryStart')", r#"{"started_queries":462000}"#.to_string()), ("countIf(exception != '')", r#"{"slow_queries_24h":37,"failed_queries_24h":3}"#.to_string()), ("FROM system.replicas", r#"{"replicas_ok":3,"replicas_total":3}"#.to_string()), ("FROM system.disks", r#"{"v":1099511627776}"#.to_string()), + ("toDate(event_time) = today()", r#"{"v":48210}"#.to_string()), ] } @@ -1179,6 +1204,8 @@ mod wiremock_tests { let o = c.overview(TimeRange::SixHours).await.expect("overview"); assert_eq!(o.running_queries, 12); assert_eq!(o.active_merges, 5); + assert_eq!(o.databases_total, 8); + assert_eq!(o.queries_today, 48_210); assert_eq!(o.tables_total, 142); assert_eq!(o.parts_total, 8931); assert_eq!(o.disk_used_bytes, 512 << 30); diff --git a/crates/chm-cloud-api/src/lib.rs b/crates/chm-cloud-api/src/lib.rs index dfc5304..9500aac 100644 --- a/crates/chm-cloud-api/src/lib.rs +++ b/crates/chm-cloud-api/src/lib.rs @@ -303,6 +303,8 @@ fn overview_of(v: &Value) -> Overview { disk_total_bytes: first_u64(v, &["disk_total_bytes", "disk_total", "total_bytes"]), uptime_seconds: first_u64(v, &["uptime_seconds", "uptime"]), clickhouse_version: first_str(v, &["clickhouse_version", "version", "ch_version"]), + databases_total: first_u64(v, &["databases_total", "databases"]), + queries_today: first_u64(v, &["queries_today", "today_queries"]), } } @@ -401,6 +403,10 @@ impl DataSource for CloudClient { format!("cloud: {}", self.base_url) } + fn engine(&self) -> chm_core::SourceEngine { + chm_core::SourceEngine::Cloud + } + /// GET /api/healthz (the one known-good route); any 2xx = reachable+authed. async fn ping(&self) -> Result<()> { let resp = self.request("healthz").await?; diff --git a/crates/chm-core/src/lib.rs b/crates/chm-core/src/lib.rs index d410aff..6b54358 100644 --- a/crates/chm-core/src/lib.rs +++ b/crates/chm-core/src/lib.rs @@ -41,6 +41,26 @@ pub enum DataSourceError { pub type Result = std::result::Result; +/// What kind of database a host speaks. Orthogonal to *where* credentials live. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceEngine { + ClickHouse, + Cloud, + Postgres, + Mock, +} + +impl SourceEngine { + pub fn tag(self) -> &'static str { + match self { + Self::ClickHouse => "ch", + Self::Cloud => "cloud", + Self::Postgres => "pg", + Self::Mock => "mock", + } + } +} + /// Dashboard time ranges (matches web UI: 1h/6h/24h/7d/30d). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum TimeRange { @@ -109,6 +129,9 @@ pub struct TrafficSeries { } /// Headline numbers for the overview page. +/// +/// The four dashboard KPIs on dash.chmonitor.dev are running queries +/// (with queries today), schema (databases + tables), storage, and uptime. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct Overview { pub qps: f64, @@ -124,6 +147,12 @@ pub struct Overview { pub disk_total_bytes: u64, pub uptime_seconds: u64, pub clickhouse_version: String, + /// User databases, excluding `system` / `information_schema`. + #[serde(default)] + pub databases_total: u64, + /// QueryFinish rows on `today()` (dashboard `query-count-today`). + #[serde(default)] + pub queries_today: u64, } /// One query row (list views for running/slow/failed). @@ -199,6 +228,12 @@ pub trait DataSource: Send + Sync { /// Human-readable label for status bar, e.g. "cloud: acme.dash.chmonitor.dev". fn label(&self) -> String; + /// Engine for this source. Defaults to ClickHouse so existing clients + /// stay fail-closed. + fn engine(&self) -> SourceEngine { + SourceEngine::ClickHouse + } + /// Cheap connectivity/auth probe used by Connect screen and reconnects. async fn ping(&self) -> Result<()>; @@ -233,6 +268,10 @@ impl DataSource for MockDataSource { self.label.clone() } + fn engine(&self) -> SourceEngine { + SourceEngine::Mock + } + async fn ping(&self) -> Result<()> { Ok(()) } @@ -252,6 +291,8 @@ impl DataSource for MockDataSource { disk_total_bytes: 1024_u64 * 1024 * 1024 * 1024, uptime_seconds: 86_400 * 12, clickhouse_version: "25.3.1.1 (smoke)".into(), + databases_total: 8, + queries_today: 48_210, }; // Vary by range so charts differ across selections in smoke shots. let mut o = base; @@ -424,6 +465,8 @@ fn row_query(id: &str, user: &str, elapsed_ms: f64, mem: u64, sql: &str) -> Quer fn rep(name: &str, ro: bool, delay: f64) -> ReplicaRow { ReplicaRow { + database: "events".into(), + table: "clicks".into(), replica_name: name.into(), is_readonly: ro, absolute_delay_sec: delay, @@ -539,6 +582,8 @@ mod tests { assert_eq!(o.replicas_ok, 3); assert_eq!(o.replicas_total, 3); assert_eq!(o.tables_total, 142); + assert_eq!(o.databases_total, 8); + assert_eq!(o.queries_today, 48_210); assert_eq!(o.parts_total, 8931); assert_eq!(o.disk_used_bytes, 512 * 1024 * 1024 * 1024); assert_eq!(o.disk_total_bytes, 1024_u64 * 1024 * 1024 * 1024); diff --git a/crates/chm-core/tests/serde_roundtrip.rs b/crates/chm-core/tests/serde_roundtrip.rs index 42389de..8fe4266 100644 --- a/crates/chm-core/tests/serde_roundtrip.rs +++ b/crates/chm-core/tests/serde_roundtrip.rs @@ -727,6 +727,8 @@ fn overview_round_trips_with_all_fields_populated() { disk_total_bytes: 1024_u64 * 1024 * 1024 * 1024, uptime_seconds: 86_400 * 12, clickhouse_version: "25.3.1.1 (smoke)".into(), + databases_total: 8, + queries_today: 48_210, }; assert_eq!(rt(&overview), overview); } diff --git a/crates/chm-postgres/Cargo.toml b/crates/chm-postgres/Cargo.toml new file mode 100644 index 0000000..3202044 --- /dev/null +++ b/crates/chm-postgres/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "chm-postgres" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +async-trait.workspace = true +chm-core.workspace = true +chrono.workspace = true +native-tls = "0.2" +postgres-native-tls = "0.5" +tokio.workspace = true +tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] } +tracing.workspace = true + +[dev-dependencies] +tokio.workspace = true diff --git a/crates/chm-postgres/src/lib.rs b/crates/chm-postgres/src/lib.rs new file mode 100644 index 0000000..e064374 --- /dev/null +++ b/crates/chm-postgres/src/lib.rs @@ -0,0 +1,469 @@ +//! Direct Postgres client for monitored sources (`pg_stat_activity` / +//! `pg_stat_statements` / `pg_stat_user_tables`). Read-only: every session +//! pins `default_transaction_read_only=on`. SQL follows chmonitor's +//! postgres-source collectors. + +use async_trait::async_trait; +use chm_core::{ + DataSource, DataSourceError, Health, MergeRow, Overview, QueryRow, ReplicaRow, Result, + SourceEngine, TableStat, TimeRange, TrafficSeries, +}; +use chrono::{TimeZone, Utc}; +use tokio_postgres::{Client, Config, NoTls, Row}; + +/// Direct Postgres source; implements [`chm_core::DataSource`]. +#[derive(Debug, Clone)] +pub struct PostgresClient { + config: Config, + sslmode: SslMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SslMode { + Disable, + Prefer, + Require, +} + +impl PostgresClient { + pub fn new( + url: impl AsRef, + user: Option, + password: Option, + database: Option, + sslmode: Option, + ) -> Result { + let (mut config, inferred_ssl) = parse_endpoint(url.as_ref())?; + if let Some(u) = user.filter(|u| !u.is_empty()) { + config.user(&u); + } + if let Some(p) = password.filter(|p| !p.is_empty()) { + config.password(p); + } + if let Some(d) = database.filter(|d| !d.is_empty()) { + config.dbname(&d); + } + if config.get_user().is_none() { + config.user("postgres"); + } + if config.get_dbname().is_none() { + config.dbname("postgres"); + } + let sslmode = sslmode + .as_deref() + .map(parse_sslmode) + .or(inferred_ssl) + .unwrap_or(SslMode::Prefer); + Ok(Self { config, sslmode }) + } + + async fn with_client(&self, f: F) -> Result + where + F: FnOnce(Client) -> Fut, + Fut: std::future::Future>, + { + match self.sslmode { + SslMode::Disable | SslMode::Prefer => self.connect_plain(f).await, + SslMode::Require => self.connect_tls(f).await, + } + } + + async fn connect_plain(&self, f: F) -> Result + where + F: FnOnce(Client) -> Fut, + Fut: std::future::Future>, + { + let (client, conn) = self.config.connect(NoTls).await.map_err(map_pg_err)?; + let handle = tokio::spawn(async move { + let _ = conn.await; + }); + pin_read_only(&client).await?; + let out = f(client).await; + handle.abort(); + out + } + + async fn connect_tls(&self, f: F) -> Result + where + F: FnOnce(Client) -> Fut, + Fut: std::future::Future>, + { + let connector = native_tls::TlsConnector::builder().build().map_err(|e| { + DataSourceError::Connection { + message: format!("tls connector: {e}"), + } + })?; + let connector = postgres_native_tls::MakeTlsConnector::new(connector); + let (client, conn) = self.config.connect(connector).await.map_err(map_pg_err)?; + let handle = tokio::spawn(async move { + let _ = conn.await; + }); + pin_read_only(&client).await?; + let out = f(client).await; + handle.abort(); + out + } +} + +async fn pin_read_only(client: &Client) -> Result<()> { + client + .batch_execute("SET default_transaction_read_only = on") + .await + .map_err(map_pg_err) +} + +fn map_pg_err(e: tokio_postgres::Error) -> DataSourceError { + let message = e.to_string(); + let code = e.code().map(|c| c.code()); + match code { + Some("28P01" | "28000") => DataSourceError::Auth { message }, + Some("3D000") => DataSourceError::Query { message }, + _ if e.is_closed() => DataSourceError::Connection { message }, + _ => DataSourceError::Query { message }, + } +} + +fn parse_sslmode(s: &str) -> SslMode { + match s.to_ascii_lowercase().as_str() { + "disable" | "allow" => SslMode::Disable, + "require" | "verify-ca" | "verify-full" => SslMode::Require, + _ => SslMode::Prefer, + } +} + +/// `postgres://…` URLs, or `host:port` / `host`. +fn parse_endpoint(raw: &str) -> Result<(Config, Option)> { + let raw = raw.trim(); + if raw.is_empty() { + return Err(DataSourceError::Connection { + message: "empty postgres url".into(), + }); + } + if raw.contains("://") { + let ssl = raw + .split(['?', '&']) + .find_map(|p| p.strip_prefix("sslmode=")) + .map(parse_sslmode); + let cfg: Config = + raw.parse() + .map_err(|e: tokio_postgres::Error| DataSourceError::Connection { + message: e.to_string(), + })?; + return Ok((cfg, ssl)); + } + let mut cfg = Config::new(); + match raw.rsplit_once(':') { + Some((host, port)) if port.parse::().is_ok() => { + cfg.host(host); + cfg.port(port.parse().unwrap()); + } + _ => { + cfg.host(raw); + cfg.port(5432); + } + } + Ok((cfg, None)) +} + +#[async_trait] +impl DataSource for PostgresClient { + fn label(&self) -> String { + use tokio_postgres::config::Host; + let host = match self.config.get_hosts().first() { + Some(Host::Tcp(h)) => h.clone(), + _ => "postgres".into(), + }; + format!("postgres: {host}") + } + + fn engine(&self) -> SourceEngine { + SourceEngine::Postgres + } + + async fn ping(&self) -> Result<()> { + self.with_client(|client| async move { + client + .query_one("SELECT 1", &[]) + .await + .map_err(map_pg_err)?; + Ok(()) + }) + .await + } + + async fn overview(&self, _range: TimeRange) -> Result { + self.with_client(|client| async move { + let row = client + .query_one( + r#" +SELECT + (SELECT count(*)::bigint FROM pg_stat_activity + WHERE state = 'active' AND pid <> pg_backend_pid()) AS running_queries, + (SELECT count(*)::bigint FROM pg_database WHERE datallowconn) AS databases_total, + (SELECT count(*)::bigint FROM pg_stat_user_tables) AS tables_total, + (SELECT coalesce(sum(pg_database_size(oid)), 0)::bigint FROM pg_database) AS disk_used_bytes, + extract(epoch FROM (now() - pg_postmaster_start_time()))::bigint AS uptime_seconds, + version() AS server_version, + (SELECT count(*)::bigint FROM pg_stat_replication) AS replicas_total, + (SELECT count(*)::bigint FROM pg_stat_replication WHERE state = 'streaming') AS replicas_ok, + coalesce(( + SELECT sum(xact_commit + xact_rollback) + / greatest(extract(epoch FROM (now() - stats_reset)), 1) + FROM pg_stat_database + ), 0)::float8 AS qps +"#, + &[], + ) + .await + .map_err(map_pg_err)?; + let running = get_i64(&row, "running_queries").max(0) as u64; + let databases = get_i64(&row, "databases_total").max(0) as u64; + let tables = get_i64(&row, "tables_total").max(0) as u64; + let disk = get_i64(&row, "disk_used_bytes").max(0) as u64; + let uptime = get_i64(&row, "uptime_seconds").max(0) as u64; + let version: String = row.try_get("server_version").unwrap_or_default(); + let replicas_total = get_i64(&row, "replicas_total").max(0) as u64; + let replicas_ok = get_i64(&row, "replicas_ok").max(0) as u64; + let qps: f64 = row.try_get("qps").unwrap_or(0.0); + Ok(Overview { + qps, + running_queries: running, + tables_total: tables, + disk_used_bytes: disk, + disk_total_bytes: disk, + uptime_seconds: uptime, + clickhouse_version: version, + replicas_total, + replicas_ok, + databases_total: databases, + ..Overview::default() + }) + }) + .await + } + + async fn traffic(&self, _range: TimeRange) -> Result { + Ok(TrafficSeries::default()) + } + + async fn running_queries(&self) -> Result> { + self.with_client(|client| async move { + let rows = client + .query( + r#" +SELECT pid::text AS id, + coalesce(usename, '') AS user_name, + coalesce(extract(epoch FROM (now() - query_start)), 0) * 1000 AS elapsed_ms, + left(coalesce(query, ''), 240) AS sql, + query_start +FROM pg_stat_activity +WHERE state = 'active' AND pid <> pg_backend_pid() +ORDER BY query_start NULLS LAST +LIMIT 100 +"#, + &[], + ) + .await + .map_err(map_pg_err)?; + Ok(rows.iter().map(activity_row).collect()) + }) + .await + } + + async fn slow_queries(&self, _range: TimeRange) -> Result> { + self.with_client(|client| async move { + let rows = match client + .query( + r#" +SELECT queryid::text AS id, + '' AS user_name, + mean_exec_time AS elapsed_ms, + left(query, 240) AS sql +FROM pg_stat_statements +ORDER BY mean_exec_time DESC +LIMIT 100 +"#, + &[], + ) + .await + { + Ok(rows) => rows, + Err(_) => return Ok(Vec::new()), + }; + Ok(rows + .iter() + .map(|r| QueryRow { + id: r.try_get("id").unwrap_or_default(), + user: r.try_get("user_name").unwrap_or_default(), + elapsed_ms: r.try_get("elapsed_ms").unwrap_or(0.0), + normalized_sql: r.try_get("sql").unwrap_or_default(), + ..QueryRow::default() + }) + .collect()) + }) + .await + } + + async fn failed_queries(&self, _range: TimeRange) -> Result> { + Ok(Vec::new()) + } + + async fn merges(&self) -> Result> { + Ok(Vec::new()) + } + + async fn replicas(&self) -> Result> { + self.with_client(|client| async move { + let rows = client + .query( + r#" +SELECT coalesce(nullif(application_name, ''), client_addr::text, pid::text) AS replica_name, + coalesce(state, '') AS state, + coalesce(extract(epoch FROM replay_lag), 0)::float8 AS delay +FROM pg_stat_replication +"#, + &[], + ) + .await + .map_err(map_pg_err)?; + Ok(rows + .iter() + .map(|r| { + let state: String = r.try_get("state").unwrap_or_default(); + ReplicaRow { + replica_name: r.try_get("replica_name").unwrap_or_default(), + absolute_delay_sec: r.try_get("delay").unwrap_or(0.0), + is_readonly: state != "streaming", + ..ReplicaRow::default() + } + }) + .collect()) + }) + .await + } + + async fn health(&self) -> Result { + self.with_client(|client| async move { + let row = client + .query_one( + r#" +SELECT + (SELECT count(*)::bigint FROM pg_stat_activity) AS conns, + (SELECT setting::bigint FROM pg_settings WHERE name = 'max_connections') AS max_conns, + pg_is_in_recovery() AS in_recovery, + (SELECT count(*)::bigint FROM pg_stat_activity WHERE state = 'idle in transaction') AS idle_txn +"#, + &[], + ) + .await + .map_err(map_pg_err)?; + let conns = get_i64(&row, "conns").max(0) as f32; + let max = get_i64(&row, "max_conns").max(1) as f32; + let in_recovery: bool = row.try_get("in_recovery").unwrap_or(false); + let idle: u64 = get_i64(&row, "idle_txn").max(0) as u64; + let util = (conns / max).clamp(0.0, 1.0); + Ok(Health { + ok: !in_recovery && util < 0.9, + zookeeper_available: true, + delayed_inserts: idle, + background_pool_utilization: util, + ..Health::default() + }) + }) + .await + } + + async fn tables(&self) -> Result> { + self.with_client(|client| async move { + let rows = client + .query( + r#" +SELECT schemaname AS database, + relname AS name, + 'heap' AS engine, + n_live_tup::bigint AS rows, + pg_total_relation_size(relid)::bigint AS bytes_on_disk +FROM pg_stat_user_tables +ORDER BY pg_total_relation_size(relid) DESC +LIMIT 200 +"#, + &[], + ) + .await + .map_err(map_pg_err)?; + Ok(rows + .iter() + .map(|r| TableStat { + database: r.try_get("database").unwrap_or_default(), + name: r.try_get("name").unwrap_or_default(), + engine: r.try_get("engine").unwrap_or_default(), + rows: get_i64(r, "rows").max(0) as u64, + bytes_on_disk: get_i64(r, "bytes_on_disk").max(0) as u64, + ..TableStat::default() + }) + .collect()) + }) + .await + } +} + +fn activity_row(r: &Row) -> QueryRow { + let started_at = r + .try_get::<_, Option>>("query_start") + .ok() + .flatten() + .or_else(|| { + r.try_get::<_, Option>("query_start") + .ok() + .flatten() + .map(|n| Utc.from_utc_datetime(&n)) + }); + QueryRow { + id: r.try_get("id").unwrap_or_default(), + user: r.try_get("user_name").unwrap_or_default(), + elapsed_ms: r.try_get("elapsed_ms").unwrap_or(0.0), + normalized_sql: r.try_get("sql").unwrap_or_default(), + started_at, + ..QueryRow::default() + } +} + +fn get_i64(row: &Row, col: &str) -> i64 { + row.try_get::<_, i64>(col) + .or_else(|_| row.try_get::<_, i32>(col).map(|v| v as i64)) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_host_port_and_url() { + let (cfg, ssl) = parse_endpoint("localhost:5432").unwrap(); + assert!(ssl.is_none()); + assert_eq!(cfg.get_ports(), [5432]); + + let (cfg, ssl) = parse_endpoint("postgres://alice@db.example:5433/app").unwrap(); + assert_eq!(cfg.get_user(), Some("alice")); + assert_eq!(cfg.get_dbname(), Some("app")); + assert_eq!(cfg.get_ports(), [5433]); + assert!(ssl.is_none()); + + let (_, ssl) = parse_endpoint("postgres://h/db?sslmode=disable").unwrap(); + assert_eq!(ssl, Some(SslMode::Disable)); + } + + #[test] + fn parse_empty_fails() { + assert!(parse_endpoint("").is_err()); + assert!(parse_endpoint(" ").is_err()); + } + + #[test] + fn sslmode_mapping() { + assert_eq!(parse_sslmode("disable"), SslMode::Disable); + assert_eq!(parse_sslmode("require"), SslMode::Require); + assert_eq!(parse_sslmode("prefer"), SslMode::Prefer); + } +} diff --git a/crates/chm-telemetry/Cargo.toml b/crates/chm-telemetry/Cargo.toml index 05a2c29..88f9158 100644 --- a/crates/chm-telemetry/Cargo.toml +++ b/crates/chm-telemetry/Cargo.toml @@ -12,6 +12,8 @@ chrono.workspace = true reqwest.workspace = true tracing.workspace = true chm-update.workspace = true +sha2 = "0.10" +dirs.workspace = true [dev-dependencies] tokio.workspace = true diff --git a/crates/chm-telemetry/src/lib.rs b/crates/chm-telemetry/src/lib.rs index b93f20b..ff33f6e 100644 --- a/crates/chm-telemetry/src/lib.rs +++ b/crates/chm-telemetry/src/lib.rs @@ -10,7 +10,7 @@ //! [`TelemetryConfig::endpoint`]. use std::collections::VecDeque; -use std::sync::Mutex; +use std::sync::{Mutex, OnceLock}; use chm_update::Channel; use serde::{Deserialize, Serialize}; @@ -126,6 +126,7 @@ impl RingBuffer { pub struct PerfMetrics { frame_ms: Mutex, fetch_ms: Mutex, + rss_bytes: Mutex>, } impl Default for PerfMetrics { @@ -139,6 +140,7 @@ impl PerfMetrics { Self { frame_ms: Mutex::new(RingBuffer::new()), fetch_ms: Mutex::new(RingBuffer::new()), + rss_bytes: Mutex::new(None), } } @@ -170,6 +172,18 @@ impl PerfMetrics { self.fetch_ms.lock().unwrap().samples.len() } + pub fn record_rss(&self, bytes: u64) { + *self.rss_bytes.lock().unwrap() = Some(bytes); + } + + pub fn last_rss(&self) -> Option { + *self.rss_bytes.lock().unwrap() + } + + pub fn last_fetch_ms(&self) -> Option { + self.fetch_ms.lock().unwrap().samples.back().copied() + } + pub fn reset(&mut self) { *self.frame_ms.lock().unwrap() = RingBuffer::new(); *self.fetch_ms.lock().unwrap() = RingBuffer::new(); @@ -234,10 +248,144 @@ impl Event { } /// Allowlist of event names. Anything else is rejected at construction. -const ALLOWED_EVENT_NAMES: &[&str] = &["app_launch", "app_quit", "page_view", "query_executed"]; +const ALLOWED_EVENT_NAMES: &[&str] = &[ + "app_launch", + "app_quit", + "app_loaded", + "page_view", + "cluster_connected", + "health_viewed", + "queries_viewed", + "query_executed", +]; /// Page-name allowlist for `page_view.props.page`. -pub const ALLOWED_PAGE_NAMES: &[&str] = &["overview", "queries", "settings"]; +pub const ALLOWED_PAGE_NAMES: &[&str] = &[ + "overview", "queries", "merges", "replicas", "health", "tables", "traffic", "connect", + "settings", +]; + +pub fn http_client() -> &'static reqwest::Client { + static HTTP: OnceLock = OnceLock::new(); + HTTP.get_or_init(reqwest::Client::new) +} + +/// Production ingest (opt-in only). +pub const TELEMETRY_PING_URL: &str = "https://telemetry.chmonitor.dev/v1/ping"; +pub const TELEMETRY_EVENT_URL: &str = "https://telemetry.chmonitor.dev/v1/event"; + +/// Opaque 64-char hex install id, persisted under the user config dir. +pub fn install_id() -> String { + let path = dirs::config_dir().map(|d| d.join("chmonitor").join("install_id")); + if let Some(path) = &path + && let Ok(existing) = std::fs::read_to_string(path) + { + let trimmed = existing.trim().to_ascii_lowercase(); + if trimmed.len() == 64 && trimmed.bytes().all(|b| b.is_ascii_hexdigit()) { + return trimmed; + } + } + let mut raw = [0u8; 32]; + #[cfg(unix)] + { + if let Ok(mut f) = std::fs::File::open("/dev/urandom") { + use std::io::Read; + let _ = f.read_exact(&mut raw); + } + } + if raw.iter().all(|b| *b == 0) { + let seed = format!( + "{}:{}:{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0), + std::env::consts::OS + ); + raw = sha256_bytes(seed.as_bytes()); + } + let hex = hex64(&raw); + if let Some(path) = path { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(path, &hex); + } + hex +} + +fn sha256_bytes(input: &[u8]) -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(input); + h.finalize().into() +} + +fn hex64(bytes: &[u8; 32]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Current process RSS in bytes (best-effort; `ps` on Unix). +pub fn rss_bytes() -> Option { + let pid = std::process::id().to_string(); + let out = std::process::Command::new("ps") + .args(["-o", "rss=", "-p", &pid]) + .output() + .ok()?; + let kb: u64 = String::from_utf8_lossy(&out.stdout).trim().parse().ok()?; + Some(kb.saturating_mul(1024)) +} + +/// POST `/v1/ping` so this install is counted. No-op when `enabled` is false. +pub async fn ping(http: &reqwest::Client, cfg: &TelemetryConfig) -> Result<()> { + if !cfg.enabled { + return Ok(()); + } + let body = json!({ + "instance_hash": install_id(), + "deploy_target": "unknown", + "platform": match std::env::consts::OS { + "macos" => "macos", + "linux" => "linux", + "windows" => "windows", + _ => "unknown", + }, + "chm_version": cfg.app_version, + }); + http.post(TELEMETRY_PING_URL) + .json(&body) + .send() + .await? + .error_for_status()?; + Ok(()) +} + +/// POST `/v1/event` (worker allowlist). No-op when disabled. +pub async fn track(http: &reqwest::Client, cfg: &TelemetryConfig, event: &str) -> Result<()> { + if !cfg.enabled { + return Ok(()); + } + if !matches!( + event, + "app_loaded" | "cluster_connected" | "health_viewed" | "queries_viewed" | "ai_query_sent" + ) { + return Err(Error::DisallowedEvent(event.into())); + } + let body = json!({ + "event": event, + "props": { + "deploy_target": "unknown", + "ch_flavor": "unknown", + } + }); + http.post(TELEMETRY_EVENT_URL) + .json(&body) + .send() + .await? + .error_for_status()?; + Ok(()) +} /// Queues events and flushes them to the configured endpoint. /// @@ -486,6 +634,15 @@ mod tests { assert_eq!(frames.p99, 594.0); } + #[test] + fn install_id_is_64_hex_and_stable() { + let a = install_id(); + let b = install_id(); + assert_eq!(a.len(), 64); + assert!(a.bytes().all(|c| c.is_ascii_hexdigit())); + assert_eq!(a, b); + } + #[test] fn perf_rejects_non_finite_and_negative_samples() { let metrics = PerfMetrics::new(); diff --git a/crates/chm-update/Cargo.toml b/crates/chm-update/Cargo.toml index 420a057..14e296e 100644 --- a/crates/chm-update/Cargo.toml +++ b/crates/chm-update/Cargo.toml @@ -11,6 +11,7 @@ thiserror.workspace = true semver.workspace = true reqwest.workspace = true tracing.workspace = true +sha2 = "0.10" [dev-dependencies] tokio.workspace = true diff --git a/crates/chm-update/src/lib.rs b/crates/chm-update/src/lib.rs index ce9b2eb..4e138fa 100644 --- a/crates/chm-update/src/lib.rs +++ b/crates/chm-update/src/lib.rs @@ -48,14 +48,15 @@ pub enum Error { BadManifest(String), #[error("invalid version in update manifest: {0}")] VersionParse(#[from] semver::Error), + #[error("update download checksum mismatch: expected {expected}, got {got}")] + Checksum { expected: String, got: String }, + #[error("update download io failed: {0}")] + Io(#[from] std::io::Error), } pub type Result = std::result::Result; /// A release advertised on a channel manifest. -/// -/// `sha256` is meant to be verified by the download/install step; this crate -/// never fetches the artifact itself. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct ReleaseInfo { version: semver::Version, @@ -63,6 +64,7 @@ pub struct ReleaseInfo { notes: String, sha256: Option, date: Option, + target: Option, } impl ReleaseInfo { @@ -85,6 +87,22 @@ impl ReleaseInfo { pub fn date(&self) -> Option<&str> { self.date.as_deref() } + + pub fn target(&self) -> Option<&str> { + self.target.as_deref() + } +} + +/// Rustc target triple for this binary, used to pick a row from a +/// multi-target channel manifest. +pub fn current_target() -> &'static str { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => "aarch64-apple-darwin", + ("macos", "x86_64") => "x86_64-apple-darwin", + ("linux", "x86_64") => "x86_64-unknown-linux-gnu", + ("linux", "aarch64") => "aarch64-unknown-linux-gnu", + _ => "unknown", + } } #[derive(Debug, Deserialize)] @@ -96,6 +114,15 @@ struct RawManifest { sha256: Option, #[serde(default)] date: Option, + #[serde(default)] + target: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum ManifestBody { + One(RawManifest), + Many(Vec), } #[derive(Debug, Clone)] @@ -150,8 +177,10 @@ impl UpdateChecker { .text() .await?; - let raw: RawManifest = + let body: ManifestBody = serde_json::from_str(&body).map_err(|e| Error::BadManifest(e.to_string()))?; + let raw = pick_manifest(body, current_target()) + .ok_or_else(|| Error::BadManifest("empty update manifest".into()))?; let version: semver::Version = raw.version.parse()?; if version <= *current { @@ -164,8 +193,59 @@ impl UpdateChecker { notes: raw.notes, sha256: raw.sha256, date: raw.date, + target: raw.target, })) } + + /// Downloads `release.url` to `dest` and verifies `sha256` when present. + pub async fn download(&self, release: &ReleaseInfo, dest: &std::path::Path) -> Result<()> { + let bytes = self + .client + .get(release.url()) + .send() + .await? + .error_for_status()? + .bytes() + .await?; + if let Some(expected) = release.sha256() { + let got = sha256_hex(&bytes); + if !got.eq_ignore_ascii_case(expected) { + return Err(Error::Checksum { + expected: expected.to_string(), + got, + }); + } + } + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(dest, bytes)?; + Ok(()) + } +} + +fn pick_manifest(body: ManifestBody, target: &str) -> Option { + match body { + ManifestBody::One(one) => Some(one), + ManifestBody::Many(rows) if rows.is_empty() => None, + ManifestBody::Many(mut rows) => { + if let Some(ix) = rows + .iter() + .position(|r| r.target.as_deref() == Some(target)) + { + Some(rows.swap_remove(ix)) + } else { + Some(rows.swap_remove(0)) + } + } + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) } fn normalize_base(base: String) -> String { @@ -314,6 +394,96 @@ mod tests { assert!(matches!(err, Error::Http(_)), "{err:?}"); } + #[tokio::test] + async fn multi_target_manifest_picks_matching_row() { + let body = serde_json::json!([ + { + "version": "1.2.3", + "url": "https://dl.example/linux.tar.gz", + "notes": "linux", + "target": "x86_64-unknown-linux-gnu" + }, + { + "version": "1.2.3", + "url": "https://dl.example/mac.zip", + "notes": "mac", + "sha256": "abc", + "target": current_target() + } + ]) + .to_string(); + let server = serve("/stable.json", 200, body).await; + let checker = UpdateChecker::new(server.uri()); + let release = checker + .check(Channel::Stable, &semver::Version::new(1, 0, 0)) + .await + .unwrap() + .expect("newer"); + let target = current_target(); + let (expected_url, expected_sha) = if target == "x86_64-unknown-linux-gnu" { + ("https://dl.example/linux.tar.gz", None) + } else { + ("https://dl.example/mac.zip", Some("abc")) + }; + assert_eq!(release.url(), expected_url); + assert_eq!(release.target(), Some(target)); + assert_eq!(release.sha256(), expected_sha); + } + + #[tokio::test] + async fn download_writes_file_and_checks_sha256() { + let payload = b"chmonitor-update-bytes"; + let digest = sha256_hex(payload); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/pkg.zip")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(payload.as_slice())) + .mount(&server) + .await; + let checker = UpdateChecker::new(server.uri()); + let release = ReleaseInfo { + version: semver::Version::new(1, 2, 3), + url: format!("{}/pkg.zip", server.uri()), + notes: String::new(), + sha256: Some(digest), + date: None, + target: None, + }; + let dest = std::env::temp_dir().join(format!( + "chm-update-dl-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + checker.download(&release, &dest).await.unwrap(); + assert_eq!(std::fs::read(&dest).unwrap(), payload); + let _ = std::fs::remove_file(&dest); + } + + #[tokio::test] + async fn download_rejects_bad_checksum() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/pkg.zip")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"nope")) + .mount(&server) + .await; + let checker = UpdateChecker::new(server.uri()); + let release = ReleaseInfo { + version: semver::Version::new(1, 2, 3), + url: format!("{}/pkg.zip", server.uri()), + notes: String::new(), + sha256: Some("deadbeef".into()), + date: None, + target: None, + }; + let dest = std::env::temp_dir().join("chm-update-bad-sha"); + let err = checker.download(&release, &dest).await.unwrap_err(); + assert!(matches!(err, Error::Checksum { .. }), "{err:?}"); + } + #[test] fn manifest_base_normalizes_trailing_slashes() { let checker = UpdateChecker::new("https://updates.example.com/"); diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh new file mode 100755 index 0000000..09d6e0f --- /dev/null +++ b/scripts/build-macos.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Build chmonitor.app for macOS: icon, Info.plist, signed-ready bundle. +# +# Usage: +# scripts/build-macos.sh # release profile → dist/macos/chmonitor.app +# scripts/build-macos.sh --debug +# scripts/build-macos.sh --beta +# scripts/build-macos.sh --bin PATH --version 0.1.1 --out DIR +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +PROFILE="release" +BIN="" +VERSION="${CHM_VERSION:-}" +OUT="$ROOT/dist/macos" +SKIP_BUILD=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --debug) PROFILE=dev; shift ;; + --release) PROFILE=release; shift ;; + --beta) PROFILE=beta; shift ;; + --bin) BIN="$2"; SKIP_BUILD=1; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --out) OUT="$2"; shift 2 ;; + -h|--help) + sed -n '2,12p' "$0" + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +APP_NAME="chmonitor" +BIN_NAME="chm-app" +BUNDLE_ID="io.chmonitor.desktop" +if [[ -z "$VERSION" ]]; then + VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' app/Cargo.toml | head -1)" +fi + +log() { printf '[build-macos] %s\n' "$*" >&2; } + +# --- icon --- +ICON_SRC="$ROOT/assets/icon/icon-1024.png" +[[ -f "$ICON_SRC" ]] || { echo "missing $ICON_SRC" >&2; exit 1; } + +ICONSET="$(mktemp -d /tmp/chm-iconset.XXXXXX)" +trap 'rm -rf "$ICONSET"' EXIT +mkdir -p "$ICONSET/AppIcon.iconset" + +# iconutil wants both 1x and @2x names. +copy_size() { + local px="$1" name="$2" + sips -z "$px" "$px" "$ICON_SRC" --out "$ICONSET/AppIcon.iconset/$name" >/dev/null +} +copy_size 16 icon_16x16.png +copy_size 32 icon_16x16@2x.png +copy_size 32 icon_32x32.png +copy_size 64 icon_32x32@2x.png +copy_size 128 icon_128x128.png +copy_size 256 icon_128x128@2x.png +copy_size 256 icon_256x256.png +copy_size 512 icon_256x256@2x.png +copy_size 512 icon_512x512.png +copy_size 1024 icon_512x512@2x.png + +ICNS="$ICONSET/AppIcon.icns" +iconutil -c icns "$ICONSET/AppIcon.iconset" -o "$ICNS" +log "icon $ICNS ($(wc -c <"$ICNS") bytes)" + +# --- binary --- +if [[ "$SKIP_BUILD" -eq 0 ]]; then + log "cargo build --profile $PROFILE -p chm-app" + cargo build --profile "$PROFILE" -p chm-app + if [[ "$PROFILE" = "dev" ]]; then + BIN="$ROOT/target/debug/$BIN_NAME" + else + BIN="$ROOT/target/$PROFILE/$BIN_NAME" + fi +fi +[[ -x "$BIN" ]] || { echo "binary not executable: $BIN" >&2; exit 1; } + +# --- bundle --- +mkdir -p "$OUT" +APP="$OUT/$APP_NAME.app" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN" "$APP/Contents/MacOS/$BIN_NAME" +chmod +x "$APP/Contents/MacOS/$BIN_NAME" +cp "$ICNS" "$APP/Contents/Resources/AppIcon.icns" +printf 'APPL????' > "$APP/Contents/PkgInfo" + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleName + $APP_NAME + CFBundleDisplayName + $APP_NAME + CFBundleIdentifier + $BUNDLE_ID + CFBundleVersion + $VERSION + CFBundleShortVersionString + $VERSION + CFBundleExecutable + $BIN_NAME + CFBundlePackageType + APPL + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon + LSApplicationCategoryType + public.app-category.developer-tools + LSMinimumSystemVersion + 13.0 + NSHighResolutionCapable + + NSSupportsAutomaticTermination + + + +PLIST + +log "app $APP" +log "version $VERSION ($(file -b "$APP/Contents/MacOS/$BIN_NAME"))" +echo "$APP" diff --git a/scripts/smoke-mac.sh b/scripts/smoke-mac.sh new file mode 100755 index 0000000..567092e --- /dev/null +++ b/scripts/smoke-mac.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# GUI smoke test for chmonitor desktop app on macOS. +# Launches with fixture data (CHM_SMOKE=1), verifies the process stays up, +# captures a screenshot of the app window, and fails on an early exit or panic. +# +# Usage: scripts/smoke-mac.sh +set -euo pipefail + +SHOTS_DIR="${SHOTS_DIR:-shots}" +WAIT_SECS="${WAIT_SECS:-6}" + +log() { printf '[smoke-mac] %s\n' "$*"; } +fail() { printf '[smoke-mac] FAIL: %s\n' "$*" >&2; exit 1; } + +command -v cargo >/dev/null || fail "cargo not on PATH" +[[ "$(uname -s)" == Darwin ]] || fail "this script is macOS-only (see scripts/smoke.sh for Linux)" + +mkdir -p "$SHOTS_DIR" + +log "building debug binary…" +cargo build -p chm-app + +BIN="target/debug/chm-app" +[[ -x "$BIN" ]] || fail "binary missing at $BIN" + +LOG="$(mktemp /tmp/chm-smoke.XXXXXX)" +env CHM_SMOKE=1 RUST_LOG=info "$BIN" >"$LOG" 2>&1 & +APP_PID=$! +trap 'kill $APP_PID 2>/dev/null || true' EXIT + +for _ in $(seq "$WAIT_SECS"); do + if grep -q "shell ready" "$LOG" 2>/dev/null; then + break + fi + kill -0 "$APP_PID" 2>/dev/null || { tail -30 "$LOG"; fail "app exited before shell ready"; } + sleep 1 +done +kill -0 "$APP_PID" 2>/dev/null || { tail -30 "$LOG"; fail "app exited early"; } +log "app alive after ${WAIT_SECS}s (pid $APP_PID)" + +# Give the first frame a moment to paint, then capture the app window +# (full-screen -x is a fallback if we cannot resolve the CGWindow id). +sleep 2 +OUT="$SHOTS_DIR/01-macos-overview.png" +if command -v screencapture >/dev/null; then + WID="" + if command -v swift >/dev/null; then + WID="$(swift -e ' +import CoreGraphics +let opts = CGWindowListOption.optionOnScreenOnly.union(.excludeDesktopElements) +guard let info = CGWindowListCopyWindowInfo(opts, kCGNullWindowID) as? [[String: Any]] else { fatalError("no windows") } +for w in info { + let owner = w[kCGWindowOwnerName as String] as? String ?? "" + let num = w[kCGWindowNumber as String] as? Int ?? 0 + if owner == "chm-app" || owner == "chmonitor" { + print(num) + break + } +} +' 2>/dev/null || true)" + fi + if [[ -n "$WID" ]]; then + screencapture -l"$WID" "$OUT" || log "WARN: screencapture -l$WID failed" + else + log "WARN: no chm-app window id — capturing full screen" + screencapture -x "$OUT" || log "WARN: screencapture failed" + fi + [[ -s "$OUT" ]] && log "shot: $OUT ($(wc -c <"$OUT") bytes)" +else + log "WARN: screencapture not on PATH — skipping screenshot" +fi + +kill -0 "$APP_PID" 2>/dev/null || fail "app died during capture" +if grep -qE "panicked at|RUST_BACKTRACE" "$LOG"; then + tail -20 "$LOG"; fail "panic found in app log" +fi +if ! grep -q "shell ready" "$LOG"; then + tail -20 "$LOG"; fail "never printed 'shell ready'" +fi + +log "PASS — log $LOG" diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 7a0d8ba..d7cd49e 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -9,6 +9,7 @@ set -euo pipefail DISPLAY_NUM="${1:-${DISPLAY:-:1}}" SHOTS_DIR="${SHOTS_DIR:-shots}" WAIT_SECS=6 +XVFB_PID="" log() { printf '[smoke] %s\n' "$*"; } fail() { printf '[smoke] FAIL: %s\n' "$*" >&2; exit 1; } @@ -16,8 +17,14 @@ fail() { printf '[smoke] FAIL: %s\n' "$*" >&2; exit 1; } command -v cargo >/dev/null || fail "cargo not on PATH" # --- preflight --------------------------------------------------------------- -if [ -z "${DISPLAY:-}" ] && ! command -v xvfb-run >/dev/null; then - fail "no DISPLAY and no xvfb-run" +if [ -z "${DISPLAY:-}" ]; then + command -v Xvfb >/dev/null || fail "no DISPLAY and no Xvfb" + DISPLAY_NUM=":99" + Xvfb "$DISPLAY_NUM" -screen 0 1440x900x24 >/dev/null 2>&1 & + XVFB_PID=$! + export DISPLAY="$DISPLAY_NUM" + sleep 1 + log "started Xvfb on $DISPLAY (pid $XVFB_PID)" fi shot_tool="" @@ -31,16 +38,8 @@ for t in wmctrl xdotool; do if command -v "$t" >/dev/null; then wm_tool="$t"; break; fi done -mkdir -p "$SHOTS_DIR" - # --- launch ------------------------------------------------------------------ -RUNNER=(env CHM_SMOKE=1) -if [ -n "${DISPLAY:-}" ]; then - RUNNER+=(DISPLAY="$DISPLAY_NUM") -else - log "no DISPLAY — using xvfb-run" - RUNNER=(xvfb-run -a -s "-screen 0 1440x900x24" env CHM_SMOKE=1) -fi +mkdir -p "$SHOTS_DIR" log "building debug binary…" cargo build -p chm-app @@ -49,9 +48,9 @@ BIN="target/debug/chm-app" [ -x "$BIN" ] || fail "binary missing at $BIN" LOG="$(mktemp /tmp/chm-smoke.XXXXXX.log)" -"${RUNNER[@]}" RUST_LOG=info "$BIN" >"$LOG" 2>&1 & +env CHM_SMOKE=1 RUST_LOG=info "$BIN" >"$LOG" 2>&1 & APP_PID=$! -trap 'kill $APP_PID 2>/dev/null || true' EXIT +trap 'kill $APP_PID $XVFB_PID 2>/dev/null || true' EXIT sleep "$WAIT_SECS" kill -0 "$APP_PID" 2>/dev/null || { tail -30 "$LOG"; fail "app exited early"; } @@ -81,13 +80,13 @@ esac shot() { local name="$1" out="$SHOTS_DIR/$1.png" case "$shot_tool" in - import) DISPLAY="$DISPLAY_NUM" import -window root "$out" 2>/dev/null ;; - scrot) DISPLAY="$DISPLAY_NUM" scrot -o "$out" 2>/dev/null ;; - gnome-screenshot) DISPLAY="$DISPLAY_NUM" gnome-screenshot -f "$out" 2>/dev/null ;; + import) import -window root "$out" || return 1 ;; + scrot) scrot -o "$out" || return 1 ;; + gnome-screenshot) gnome-screenshot -f "$out" || return 1 ;; esac } -shot "01-connect-or-overview" +shot "01-connect-or-overview" || fail "screenshot failed on $DISPLAY" PAGES=("02-overview" "03-queries" "04-merges" "05-replicas" "06-health" "07-tables" "08-traffic") # Page switching is keyboard-driven when supported (keys 1..8 bound in shell);