diff --git a/AGENTS.md b/AGENTS.md index 815e02f5..88dfc41a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ Keep Cargo output isolated per JJ workspace; never point concurrent workspace bu ## Code Organization -- Keep files under 300 lines. Split by responsibility when a file grows past that. +- Keep files focused on a single responsibility. Split them into cohesive modules when responsibilities diverge; use structure and ownership rather than a hard line-count limit. - One primary type per file, named after the type. Small private helpers used only by that type stay with it; deliberately-cohesive model clusters (a type plus its request/result vocabulary) may share a file. - Group related files into responsibility folders; don't create folders for singletons. - Rust: prefer folder modules over long single-file modules. Keep `mod.rs` and `lib.rs` thin: module declarations and `pub use` re-exports only. Put implementation in sibling modules named for the responsibility they own, such as `wrap/cols.rs`, `wrap/unified.rs`, and `wrap/side_by_side.rs`. diff --git a/Cargo.lock b/Cargo.lock index aa440098..275553ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,9 +143,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -176,9 +176,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -241,11 +241,11 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "ar_archive_writer" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" dependencies = [ - "object", + "object 0.39.1", ] [[package]] @@ -407,9 +407,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -537,9 +537,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -616,7 +616,7 @@ dependencies = [ "num-traits", "pastey", "rayon", - "thiserror 2.0.19", + "thiserror 2.0.20", "v_frame", "y4m", ] @@ -654,7 +654,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.37.3", "rustc-demangle", "windows-link 0.2.1", ] @@ -665,6 +665,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "basic-toml" version = "0.1.10" @@ -842,9 +848,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", @@ -874,13 +880,13 @@ dependencies = [ [[package]] name = "bytemuck_derive" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -937,9 +943,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ "serde_core", ] @@ -965,7 +971,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1075,9 +1081,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", @@ -1086,9 +1092,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.3" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fb99565819980999fb7b4a1796046a5c949e6d4ff132cf5fadf5a641e20d776" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -1096,9 +1102,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -1108,14 +1114,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.3" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f2392eae7f16557a3d727ef3a12e57b2b2ca6f98566a5f4fb41ffe305df077" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1596,9 +1602,9 @@ dependencies = [ [[package]] name = "ctor" -version = "1.0.9" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a394189d59f9befacce833f337f7b1eca5e9a91221bcdd4d28e0114d96e597b3" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" dependencies = [ "link-section", "linktime-proc-macro", @@ -1652,7 +1658,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1766,13 +1772,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1825,9 +1831,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "embed-resource" @@ -1838,7 +1844,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "vswhom", "winreg 0.55.0", ] @@ -1957,11 +1963,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -2050,9 +2055,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fixedbitset" @@ -2166,9 +2171,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.1" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad67eced03f5504d9cbd3a879b5958b5c54d4e5fd794361c6eb21b05fb703411" +checksum = "75382bc7392ef10aad10935f92fc3db36d2d4dad0e5d96d8d65e04f89a07ec39" dependencies = [ "bytemuck", ] @@ -2208,13 +2213,13 @@ dependencies = [ [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -2263,9 +2268,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -2278,9 +2283,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -2301,15 +2306,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -2318,9 +2323,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -2337,32 +2342,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -2499,14 +2504,14 @@ dependencies = [ "gix-worktree-stream", "nonempty", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "gix-actor" -version = "0.41.1" +version = "0.41.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bc998b8f746dda8565450d08a63b792ced9165d8c27a1ed3f02799ec6a7820f" +checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b" dependencies = [ "bstr", "gix-date", @@ -2526,33 +2531,33 @@ dependencies = [ "gix-trace", "kstring", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-bom", ] [[package]] name = "gix-bitmap" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ebef0c26ad305747649e727bbcd56a7b7910754eb7cea88f6dff6f93c51283" +checksum = "7cd1d118d0f5d88b96e6f6e13b566475fef4797ead4a02c26fed36c1375066f7" dependencies = [ "gix-error", ] [[package]] name = "gix-chunk" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9faee47943b638e58ddd5e275a4906ad3e4b6c8584f1d41bd18ab9032ec52afb" +checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc" dependencies = [ "gix-error", ] [[package]] name = "gix-command" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00706d4fef135ef4b01680d5218c6ee40cda8baf697b864296cbc887d19118f6" +checksum = "cf4363accdf6ef7ba861871d2d521ab7418a04aaaed919fadb022af71d379b12" dependencies = [ "bstr", "gix-path", @@ -2589,7 +2594,7 @@ dependencies = [ "gix-ref", "gix-sec", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-bom", ] @@ -2603,7 +2608,7 @@ dependencies = [ "bstr", "gix-path", "libc", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2636,7 +2641,7 @@ dependencies = [ "gix-trace", "gix-traverse", "gix-worktree", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2651,7 +2656,7 @@ dependencies = [ "gix-path", "gix-ref", "gix-sec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2679,7 +2684,7 @@ dependencies = [ "once_cell", "parking_lot", "prodash", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "zlib-rs", ] @@ -2702,7 +2707,7 @@ dependencies = [ "gix-trace", "gix-utils", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2716,7 +2721,7 @@ dependencies = [ "gix-features", "gix-path", "gix-utils", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2741,7 +2746,7 @@ dependencies = [ "gix-features", "sha1-checked", "sha2 0.11.0", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2770,9 +2775,9 @@ dependencies = [ [[package]] name = "gix-imara-diff" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b" +checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1" dependencies = [ "bstr", "hashbrown 0.17.1", @@ -2803,7 +2808,7 @@ dependencies = [ "memmap2", "rustix 1.1.4", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2814,7 +2819,7 @@ checksum = "65c9dedd9e90b0d47624d2ed241d394e09294118364e87b9b7e5f1fe755f3c2c" dependencies = [ "gix-tempfile", "gix-utils", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2833,7 +2838,7 @@ dependencies = [ "gix-validate", "itoa", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2854,7 +2859,7 @@ dependencies = [ "memmap2", "parking_lot", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2873,7 +2878,7 @@ dependencies = [ "gix-path", "memmap2", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "uluru", ] @@ -2886,19 +2891,19 @@ dependencies = [ "bstr", "faster-hex", "gix-trace", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "gix-path" -version = "0.12.2" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbbecb0f8dc5cdf6cbde69133f7072064dfc9da4cf0046913afb6857b07300fa" +checksum = "751d6bd162106f8c1e7e9aaccb5bbdd605267e91a930a17a4560c46e33a9100c" dependencies = [ "bstr", "gix-trace", "gix-validate", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2913,7 +2918,7 @@ dependencies = [ "gix-config-value", "gix-glob", "gix-path", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2932,7 +2937,7 @@ dependencies = [ "gix-utils", "maybe-async", "nonempty", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2963,7 +2968,7 @@ dependencies = [ "gix-utils", "gix-validate", "memmap2", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2979,7 +2984,7 @@ dependencies = [ "gix-revision", "gix-validate", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3011,14 +3016,14 @@ dependencies = [ "gix-hashtable", "gix-object", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "gix-sec" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8519976e4c7e486270740a5400369f37940779b80bd1377d94cfa1125d01b3" +checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f" dependencies = [ "bitflags 2.13.1", "gix-path", @@ -3036,7 +3041,7 @@ dependencies = [ "gix-hash", "gix-lock", "nonempty", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3051,7 +3056,7 @@ dependencies = [ "gix-pathspec", "gix-refspec", "gix-url", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3069,9 +3074,9 @@ dependencies = [ [[package]] name = "gix-trace" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44dc45eae785c0eb14173e0f152e6e224dcf4d45b6a6999a3aed22af541ad678" +checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" [[package]] name = "gix-transport" @@ -3086,7 +3091,7 @@ dependencies = [ "gix-quote", "gix-sec", "gix-url", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3103,7 +3108,7 @@ dependencies = [ "gix-object", "gix-revwalk", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3115,14 +3120,14 @@ dependencies = [ "bstr", "gix-path", "percent-encoding", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "gix-utils" -version = "0.3.4" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d773a906e39472c2b00aaf1993cd120d40198c1ff6db07c0ee9a44d4431b66c1" +checksum = "b1795bd2a970ca8b2185318c2abb97d955c71992f1cf28de73ad3b593a9f3ce8" dependencies = [ "fastrand", "getrandom 0.4.3", @@ -3131,9 +3136,9 @@ dependencies = [ [[package]] name = "gix-validate" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bc6fc771c4063ba7cd2f47b91fb6076251c6a823b64b7fe7b8874b0fe4afae3" +checksum = "9a034e84d1e04e1b1f20f51f12491da230b6ac8b925d0c8e1b89bcd87a7c5ccc" dependencies = [ "bstr", ] @@ -3187,15 +3192,15 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "globset" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", @@ -3246,7 +3251,7 @@ dependencies = [ "hashbrown 0.16.1", "log", "presser", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows 0.62.2", ] @@ -3341,7 +3346,7 @@ dependencies = [ "strum", "sum_tree", "taffy", - "thiserror 2.0.19", + "thiserror 2.0.20", "ttf-parser", "url", "usvg", @@ -3733,9 +3738,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -3779,9 +3784,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] @@ -3812,9 +3817,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -3826,9 +3831,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -3839,9 +3844,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3853,16 +3858,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -3873,15 +3879,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -3973,9 +3979,9 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ "bitflags 2.13.1", "inotify-sys", @@ -4117,6 +4123,7 @@ dependencies = [ "chrono", "csv", "directories", + "dunce", "futures", "gix", "gix-url", @@ -4134,7 +4141,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", ] [[package]] @@ -4161,7 +4168,7 @@ dependencies = [ "sha2 0.11.0", "sysinfo 0.39.6", "tempfile", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "unicode-segmentation", "unicode-width", "uuid", @@ -4188,7 +4195,7 @@ dependencies = [ "hex", "serde", "sha2 0.11.0", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -4212,15 +4219,15 @@ dependencies = [ "jayjay-markdown", "jayjay-primitives", "jayjay-review", - "thiserror 2.0.19", + "thiserror 2.0.20", "uniffi", ] [[package]] name = "jiff" -version = "0.2.34" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e184d09547b80eb7e20d141ba2fb1fbac843ca53f4cf1b31210adc4c1adc6e16" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ "defmt", "jiff-core", @@ -4244,9 +4251,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.34" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323da076b7a6faf914dc677cb05a4b907742ff7375c8322c9e7f5061e5e0e9de" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" dependencies = [ "jiff-core", "proc-macro2", @@ -4348,7 +4355,7 @@ dependencies = [ "smallvec", "strsim", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "toml_edit 0.25.13+spec-1.1.0", "tracing", "winreg 0.56.0", @@ -4414,9 +4421,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -4442,9 +4449,9 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kqueue" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -4516,9 +4523,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.187" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7743783ea728ef5c31194c6590797eed286449b4a4e87d626d8a51f0a94e732" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libfuzzer-sys" @@ -4548,9 +4555,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "libc", ] @@ -4563,15 +4570,15 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" [[package]] name = "link-section" -version = "0.19.0" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e333fe507b738576d6da5bb3f1a7d7a1c80307ed9ef31624c057d844c19c93e9" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" [[package]] name = "linktime-proc-macro" -version = "0.2.0" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" [[package]] name = "linux-raw-sys" @@ -4587,9 +4594,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -4890,7 +4897,7 @@ dependencies = [ "once_cell", "rustc-hash 1.1.0", "spirv", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-ident", ] @@ -5083,9 +5090,9 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -5367,6 +5374,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -5416,9 +5432,9 @@ dependencies = [ [[package]] name = "open" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408" dependencies = [ "is-wsl", "libc", @@ -5537,9 +5553,9 @@ dependencies = [ [[package]] name = "pest" -version = "2.8.7" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" dependencies = [ "memchr", "ucd-trie", @@ -5547,9 +5563,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.7" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" dependencies = [ "pest", "pest_generator", @@ -5557,9 +5573,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.7" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" dependencies = [ "pest", "pest_meta", @@ -5570,9 +5586,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.7" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" dependencies = [ "pest", ] @@ -5665,9 +5681,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plain" @@ -5681,9 +5697,9 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ - "base64", + "base64 0.22.1", "indexmap", - "quick-xml 0.41.0", + "quick-xml", "serde", "time", ] @@ -5757,9 +5773,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" @@ -5789,9 +5805,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -5950,9 +5966,9 @@ dependencies = [ [[package]] name = "psm" -version = "0.1.31" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" dependencies = [ "ar_archive_writer", "cc", @@ -6019,24 +6035,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" -dependencies = [ - "memchr", -] - -[[package]] -name = "quick-xml" -version = "0.39.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -6170,9 +6168,9 @@ checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" [[package]] name = "rangemap" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" [[package]] name = "rav1e" @@ -6204,7 +6202,7 @@ dependencies = [ "rand 0.9.5", "rand_chacha 0.9.0", "simd_helpers", - "thiserror 2.0.19", + "thiserror 2.0.20", "v_frame", "wasm-bindgen", ] @@ -6289,7 +6287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.1", + "font-types 0.12.3", "once_cell", ] @@ -6316,7 +6314,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -6361,9 +6359,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -6492,9 +6490,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "log", "once_cell", @@ -6507,18 +6505,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "ring", "rustls-pki-types", @@ -6593,9 +6591,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "indexmap", @@ -6607,14 +6605,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -6736,13 +6734,13 @@ dependencies = [ [[package]] name = "serde_derive_internals" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -6912,9 +6910,9 @@ dependencies = [ [[package]] name = "similar" -version = "3.1.1" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6505efef05804732ed8a3f2d4f279429eb485bd69d5b0cc6b19cc02005cda16" +checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987" dependencies = [ "bstr", ] @@ -7046,9 +7044,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" dependencies = [ "cc", "cfg-if", @@ -7146,34 +7144,35 @@ dependencies = [ [[package]] name = "sval" -version = "2.20.0" +version = "2.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fb9efbae90f97301f4d25f3be63dfd99d6b7af9d088228a52ec960d649b2e7d" +checksum = "ec4a2a7d92fa86fcc6222e4c3845f8486cff899d9db32480b26c91a5dbf2e22d" [[package]] name = "sval_buffer" -version = "2.20.0" +version = "2.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff5c0280ea0af40b3a1fd0b532680b5067482ffb81412ee66ec04b1d9952b49a" +checksum = "f4324db9ac500c609d659b752edf9c8abbf2233f8afd61a503fd6f88ed625032" dependencies = [ "sval", "sval_ref", + "zerocopy", ] [[package]] name = "sval_dynamic" -version = "2.20.0" +version = "2.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59b9067f2e68f58e110cf8019a268057e3791cf74b0fdb1b3c7c6e49104f44e6" +checksum = "4046add0eecf55e680b9e207edf5fc7737b18a1d950db363d97e7f1b2d7c629c" dependencies = [ "sval", ] [[package]] name = "sval_fmt" -version = "2.20.0" +version = "2.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96ebdbf0e4b175884aa587fcf551c16dabe245ea04235abdd8cbbd160b27ed5a" +checksum = "911a3486b5984a0a4f25edefcf2c2dba23654c29f63e75493b671d338bf24243" dependencies = [ "itoa", "ryu", @@ -7182,9 +7181,9 @@ dependencies = [ [[package]] name = "sval_json" -version = "2.20.0" +version = "2.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e448d9fa216a6c16670b28d624fbbcf5c04e41eb187bd7c52e01222ffa72a12" +checksum = "da53aae7c737b5b5f1be4bcb0ff20e057bf6b2ee4e9d025560075c5830d09f95" dependencies = [ "itoa", "ryu", @@ -7193,9 +7192,9 @@ dependencies = [ [[package]] name = "sval_nested" -version = "2.20.0" +version = "2.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021de5b5c26efd544c694cef9b8a9abe8633481bf3be1ea145a18a740818b291" +checksum = "df24df43cbdc4bb8c9f5ed19d0d57dc8f60a1a4259cdce52d597fe774ad3a71f" dependencies = [ "sval", "sval_buffer", @@ -7204,18 +7203,18 @@ dependencies = [ [[package]] name = "sval_ref" -version = "2.20.0" +version = "2.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54ef5ffec8bc52ded04ee424ab8d959e25e64fd40a48a16d21f8991ce824c1bb" +checksum = "2bebc17f0f1fad060e57b778728d41ef87627e9111a6365d7463472cb58fc1b3" dependencies = [ "sval", ] [[package]] name = "sval_serde" -version = "2.20.0" +version = "2.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b983833a8a2390f89ebcf9b9acd06883017014b4ffd72ee28e0c9de6852039f5" +checksum = "9f26fe3f6a68b40e6c8d654ea48c00e4316272fddf68c80493714c1b034ae70b" dependencies = [ "serde_core", "sval", @@ -7386,11 +7385,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -7406,9 +7405,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -7440,9 +7439,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.54" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", @@ -7505,9 +7504,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -7542,9 +7541,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", @@ -7604,9 +7603,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.4", ] @@ -7683,9 +7682,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.26.11" +version = "0.26.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1c71c1c4cc0920b20d6b0f6572e7682cd07a6a2faec71067a31fa394c586df" +checksum = "83c567a8e18ae93f20982c90370b16fd24023aeaf52f6052b96957ab253a0fec" dependencies = [ "cc", "regex", @@ -7757,13 +7756,13 @@ dependencies = [ [[package]] name = "tree-sitter-highlight" -version = "0.26.11" +version = "0.26.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd7a0c48d503cf4e0a57a2453424eaef2fce4b4269f13e3579e52f0d0c9e5cc8" +checksum = "12bf7c298789885e63e7c1c9c86976875136064d23f541007bae3a02a23a74f9" dependencies = [ "regex", "streaming-iterator", - "thiserror 2.0.19", + "thiserror 2.0.20", "tree-sitter", ] @@ -8148,7 +8147,7 @@ dependencies = [ "serde", "tempfile", "textwrap", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "uniffi_internal_macros", "uniffi_meta", "uniffi_pipeline", @@ -8193,7 +8192,7 @@ dependencies = [ "quote", "serde", "syn 2.0.119", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.4+spec-1.1.0", "uniffi_meta", ] @@ -8242,11 +8241,11 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" dependencies = [ - "base64", + "base64 0.23.1", "flate2", "log", "percent-encoding", @@ -8259,11 +8258,11 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" dependencies = [ - "base64", + "base64 0.23.1", "http", "httparse", "log", @@ -8293,7 +8292,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -8344,9 +8343,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -8374,9 +8373,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-bag" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef73bfbaf3216cb59c205d7176bee1194e0d84348979da31f4a71fefe3c2054e" +checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" dependencies = [ "value-bag-serde1", "value-bag-sval2", @@ -8384,9 +8383,9 @@ dependencies = [ [[package]] name = "value-bag-serde1" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b92170db3db8a6354f12a5b7f13a5453928433e08fc46aee51eedfa8f7a28a1" +checksum = "417d6197dd0ee696783d6be4276ac6ea74b985e00024c85ccfb37aff4f2bed82" dependencies = [ "erased-serde", "serde_core", @@ -8395,9 +8394,9 @@ dependencies = [ [[package]] name = "value-bag-sval2" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf9832097ca044466ae3f1aa43a943d4e450b7c3b8cdf65363875df07da79a0" +checksum = "c61f7251ecde2c9ed431bbe0659853e7991753447447bbf1ae59d8b31c578d4e" dependencies = [ "sval", "sval_buffer", @@ -8476,9 +8475,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -8489,9 +8488,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -8499,9 +8498,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8509,9 +8508,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -8522,9 +8521,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -8543,9 +8542,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", @@ -8557,9 +8556,9 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.14" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.13.1", "rustix 1.1.4", @@ -8618,12 +8617,12 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.39.4", + "quick-xml", "quote", ] @@ -8641,9 +8640,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -8737,7 +8736,7 @@ dependencies = [ "raw-window-handle", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", "wgpu-core-deps-windows-linux-android", @@ -8816,7 +8815,7 @@ dependencies = [ "raw-window-metal", "renderdoc-sys", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "wasm-bindgen", "wayland-sys", "web-sys", @@ -8937,7 +8936,7 @@ checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" dependencies = [ "parking_lot", "rayon", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows 0.61.3", "windows-future 0.2.1", ] @@ -9396,9 +9395,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "x11" @@ -9442,21 +9441,21 @@ checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "xcb" -version = "1.7.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6" +checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.1", "libc", - "quick-xml 0.30.0", + "quick-xml", "x11", ] [[package]] name = "xcursor" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xim-ctext" @@ -9494,9 +9493,9 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xmlwriter" @@ -9552,9 +9551,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -9611,14 +9610,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "zbus_names", "zvariant", "zvariant_utils", @@ -9647,6 +9646,15 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zed-font-kit" version = "0.14.1-zed" @@ -9713,18 +9721,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", @@ -9774,9 +9782,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -9785,9 +9793,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -9796,20 +9804,20 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "zlib-rs" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zlog" @@ -9846,9 +9854,9 @@ source = "git+https://github.com/hewigovens/zed?rev=2db32707ecac6e8e36977fc2d4d0 [[package]] name = "zune-core" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-inflate" @@ -9870,41 +9878,42 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.13.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" dependencies = [ "endi", "enumflags2", "serde", "serde_bytes", "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.13.1" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.5.0" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +checksum = "6b84ebb462416c27cdb97f2e7f5f0ccc844da1fe2ecc7121e1b690b41318bf42" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.119", + "syn 3.0.3", "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index dfaf0c8c..17ba4898 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,9 +12,9 @@ repository = "https://github.com/hewigovens/jayjay" [workspace.dependencies] jj-lib = "0.44.0" gix = { version = "0.85.0", default-features = false } -gix-url = "0.36.1" +gix-url = "0.36.2" percent-encoding = "2.3.2" -ureq = "3" +ureq = "3.4.0" uniffi = "0.32.0" clap = { version = "4", features = ["derive"] } thiserror = "2" @@ -31,13 +31,14 @@ sha2 = "0.11.0" hex = "0.4" uuid = { version = "1", features = ["v4"] } sysinfo = "0.39.6" -similar = "3.1.1" +similar = "3.2.0" unicode-width = "0.2" unicode-segmentation = "1" nucleo-matcher = "0.3" toml = "1.1" directories = "6" pulldown-cmark = { version = "0.13.4", default-features = false } +dunce = "1" tree-sitter = "0.26" tree-sitter-highlight = "0.26" diff --git a/UserGuide.md b/UserGuide.md index 7fa70349..df3b2569 100644 --- a/UserGuide.md +++ b/UserGuide.md @@ -7,6 +7,7 @@ This guide covers JayJay's user-facing features. The released macOS app uses the - Open a repository with `Cmd+O`, the app menu, the Dock recent-repositories menu, or the CLI launcher: `jayjay /path/to/repo`. - Open the current terminal directory with `jayjay .` after installing the bundled CLI launcher. - If you open a folder that is not a jj repository, JayJay shows an onboarding view with a `jj git init` path. +- In the Repository List, recent entries that are jj workspaces of a listed repository nest under that repository's row; pinning a workspace keeps it top-level. - JayJay watches the repository and working tree, then refreshes when jj operations or file edits change the repo. ## Main Window @@ -15,7 +16,8 @@ This guide covers JayJay's user-facing features. The released macOS app uses the - The detail header shows the selected change, description, author, status, bookmarks, PR state, and available actions. The change-id and commit-id are shown with their shortest unique prefix in bold. - The file column lists changed files in flat or tree form and shows review status, conflicts, renames, and file-level actions. - The diff pane shows the selected file with unified or side-by-side layout, syntax highlighting, word-level changes, and collapsed context. -- The status bar surfaces repository state, selected bookmark PR links/checks, and useful workspace context. +- The status bar surfaces repository state and selected bookmark PR links/checks. +- When a repository has more than one jj workspace, the repository title dropdown lists each workspace with its `@` description, changed-file count, recency, and conflict state, above the repository list. Type to filter, click a row to switch to that workspace's window, and use a row's context menu to copy a workspace's path, forget it, or delete it from disk. The bookmark picker shares the same filterable dropdown. ## Navigate History diff --git a/agents/shell-parity.md b/agents/shell-parity.md index 7617aae6..eecc0674 100644 --- a/agents/shell-parity.md +++ b/agents/shell-parity.md @@ -19,7 +19,7 @@ Update this matrix when the user guide adds a feature, a shell closes a gap, or | User Guide Feature | SwiftUI | GPUI (Linux) | Notes | | --- | --- | --- | --- | -| Open a Repository | Yes | Yes | Repository-list history stays shell-local, while pins share the Rust-backed `repositories.json`. Both shells keep Pinned above Recent, preserve pins when Recent is cleared, expose live windows plus closed pins from the repository title, activate an existing window without duplication, open closed pins in a new window, and return to the repository list after the last repo window closes. | +| Open a Repository | Yes | Partial — grouped workspace entries | Repository-list history stays shell-local, while pins share the Rust-backed `repositories.json`. Both shells keep Pinned above Recent, preserve pins when Recent is cleared, expose live windows plus closed pins from the repository title, activate an existing window without duplication, open closed pins in a new window, and return to the repository list after the last repo window closes. SwiftUI additionally nests recent jj-workspace entries under their primary repo's row (core `workspace_primary_root`). Exact gap: GPUI's repository list does not group workspace entries yet. | | Main Window | Yes | Yes | DAG, detail header, file column, diff pane, status bar, bookmark/tag/conflict markers, sibling-workspace name@ chips, and working-copy state should describe the same jj data. | | Navigate History | Yes | Yes | Selection, keyboard navigation, toolbar revset filtering (presets and custom expressions), load-more behavior, context actions, drag/drop outcomes, and divergent-change handling should stay aligned. | | Review Diffs | Yes | Partial — added/deleted side-by-side; rich previews | Text diff, unified/side-by-side modes, expandable collapsed context (Show 10 / Show all), find, image diff, file review, and flat/tree file lists are GPUI-covered. Keyboard access to expansion differs by mechanism: GPUI tab-focuses the per-region controls (Enter/Space), SwiftUI offers the palette action Expand All Unmodified Lines. SwiftUI briefly tints small reveals when Reduce Motion is off; GPUI uses an atomic, animation-free swap. Exact gaps: added and deleted files fall back to unified instead of rendering side-by-side; rich-preview gaps are enumerated in the next row. | @@ -29,7 +29,7 @@ Update this matrix when the user guide adds a feature, a shell closes a gap, or | Edit Diffs & Split Work | Yes | Yes | Both shells offer Diff Edit for the working copy and other mutable changes, while respecting jj's immutable revset. They cover line-granularity discard from the normal diff gutter and a dedicated multi-file view with per-file cards, line/hunk/file selection, select-all, keep-only-selected Done, Move to Working Copy, New Child, and New Parallel. Both show per-file +/− line stats in card headers, per-file collapse/expand, Expand All / Collapse All buttons with `mod+alt+e` / `mod+alt+c`, auto-collapse for large diffs via the shared `diff_edit_auto_collapsed_paths` policy, and a keyboard focus model: `j`/`k` or `↑`/`↓` move between file cards, `←`/`→` collapse/expand, `Return` toggles, and `Space` selects the focused card. | | Change Operations | Yes | Partial — DAG rewrites; @ description prefill | GPUI covers new-change-on-top, abandon, operation-log restore, editing descriptions (including `jj describe` on @), committing, codex/claude AI messages, file multi-select split/commit and batch actions, and Diff Edit destinations including Move to Working Copy, New Child, and New Parallel. Exact gaps: no DAG actions for `jj edit`, squash into parent/selected, rebase selected, merge selected, duplicate, absorb, or revert/backout; the commit box also does not initially prefill from @'s existing description. | | Bookmarks, Git & Pull Requests | Yes | Partial — bookmark-manager mutations; post-drag Push | GPUI covers bookmark creation, DAG drag/move, tracking, push/delete, Git fetch/push, remote URL opening, GitHub/GitLab/Codeberg/Cursor Origin PR/MR opening, and selected-bookmark PR/MR plus CI status. Exact gaps: the Bookmark Manager does not expose rename, push, delete, PR/MR open, conflict resolution, per-remote ahead/behind/diverged details, remote choice when tracking, or the hide-by-default **Show deleted** plus per-row **Forget** flow; dragging a tracked bookmark also does not show SwiftUI's one-click sidebar **Push** affordance. | -| Workspaces | Yes | Yes | Both shells create a workspace by name into a sibling directory, open it in its own window, show workspace context, and forget workspaces. GPUI entry points: Repository menu, status-bar workspace picker, and palette; switching lives in the status-bar picker where SwiftUI also offers palette "Switch to" entries — a presentation difference, not a workflow gap. | +| Workspaces | Yes | Partial — rich workspace dropdown | Both shells create a workspace by name into a sibling directory, open it in its own window, and forget workspaces. SwiftUI switches from the repository title dropdown, whose workspace rows show each workspace's @ change, description, changed-file count, recency, and conflict state (core `workspace_list` supplies the status); GPUI still switches from its status-bar workspace picker with names only. Exact gap: GPUI has no rich workspace dropdown yet. | | Stacked Pull Requests | Yes | Yes | Both shells preview the detected stack, validate edited bookmark names, and submit create/update operations with dependent bases. Successful multi-PR GitHub submissions also link a native stack through `gh api` when GitHub Stacked PRs is enabled, with a non-destructive dependent-chain fallback; GitLab detects the dependent MR stack automatically; Cursor Origin submits dependent PRs through the `origin` CLI. Done opens the top PR for a linked GitHub stack, the highest available GitLab MR, and every submitted PR for a GitHub dependent-chain fallback or Cursor Origin stack. | | Conflict Resolution | Yes | Yes | Conflicted changes/files, conflict diff styling, Use Ours/Theirs, resolve-in-editor, and refresh-after-resolution should stay behaviorally equivalent. | | Inspection Tools | Yes | Yes | File Annotate, File History, and Change Evolution should use the same source revs, copy values, and compare targets. | diff --git a/crates/jayjay-core/Cargo.toml b/crates/jayjay-core/Cargo.toml index 9ee4f85b..0b7c4fa6 100644 --- a/crates/jayjay-core/Cargo.toml +++ b/crates/jayjay-core/Cargo.toml @@ -27,6 +27,7 @@ directories = { workspace = true } toml = { workspace = true } plist = { workspace = true } csv = { workspace = true } +dunce = { workspace = true } [dev-dependencies] jj-test = { path = "../jj-test" } diff --git a/crates/jayjay-core/src/lib.rs b/crates/jayjay-core/src/lib.rs index e9228406..75da3fa9 100644 --- a/crates/jayjay-core/src/lib.rs +++ b/crates/jayjay-core/src/lib.rs @@ -28,6 +28,7 @@ pub use repo::{ find_existing_binary, generate_branch_name_cli, generate_commit_message_cli, home_dir, init_jj_git_repo, is_executable_file, is_valid_bookmark_name, is_valid_workspace_name, login_shell, login_shell_path, resolve_review_note, review_notes_output, revset_presets, + workspace_primary_root, }; pub use theme::{DiffThemeColors, change_id_prefix_color, diff_theme_colors}; pub use tools::{ diff --git a/crates/jayjay-core/src/repo/mod.rs b/crates/jayjay-core/src/repo/mod.rs index c6350491..0bdd539c 100644 --- a/crates/jayjay-core/src/repo/mod.rs +++ b/crates/jayjay-core/src/repo/mod.rs @@ -28,6 +28,8 @@ mod undo; mod working_copy; mod working_copy_ignore; mod workspace; +mod workspace_path; +mod workspace_removal; pub use commit_ai::COMMIT_MESSAGE_PROMPT; pub use commit_ai::detect_ai_provider; @@ -52,7 +54,7 @@ pub use revsets::{ DEFAULT_REVSET, DEFAULT_REVSET_DEPTH, RevsetPreset, build_default_revset, revset_presets, }; pub use stacked_pr::is_valid_bookmark_name; -pub use workspace::is_valid_workspace_name; +pub use workspace_path::{is_valid_workspace_name, workspace_primary_root}; pub const JJ_CONFIG_USER_NAME: &str = "user.name"; pub const JJ_CONFIG_USER_EMAIL: &str = "user.email"; @@ -68,6 +70,7 @@ use jj_lib::workspace::Workspace; use config::{default_settings, working_copy_factories}; use support::{block_on_result, load_repo_at_head, load_workspace_internal, op_is_ancestor_of}; +use workspace::WorkspacePathCache; use crate::types::*; @@ -75,6 +78,7 @@ pub struct Repo { path: PathBuf, workspace_name: jj_lib::ref_name::WorkspaceNameBuf, repo: RwLock>, + workspace_paths: RwLock, } impl Repo { @@ -96,6 +100,7 @@ impl Repo { path: workspace.workspace_root().to_owned(), workspace_name: workspace.workspace_name().to_owned(), repo: RwLock::new(repo), + workspace_paths: RwLock::new(WorkspacePathCache::default()), }) } diff --git a/crates/jayjay-core/src/repo/workspace.rs b/crates/jayjay-core/src/repo/workspace.rs index a7a1f1da..04a7a510 100644 --- a/crates/jayjay-core/src/repo/workspace.rs +++ b/crates/jayjay-core/src/repo/workspace.rs @@ -1,39 +1,240 @@ +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use futures::StreamExt as _; +use jj_lib::hex_util::encode_reverse_hex; +use jj_lib::matchers::EverythingMatcher; +use jj_lib::object_id::ObjectId; +use jj_lib::op_store::OperationId; +use jj_lib::repo::{ReadonlyRepo, Repo as _}; +use jj_lib::workspace::Workspace; +use jj_lib::workspace_store::{SimpleWorkspaceStore, WorkspaceStore as _}; +use pollster::FutureExt as _; + use super::Repo; +use super::support::{load_repo_at_head, load_workspace_internal, op_is_ancestor_of}; +use super::workspace_path::is_valid_workspace_name; +use super::workspace_removal::normalized_absolute_path; use crate::types::*; const IGNORE_WORKING_COPY_ARG: &str = "--ignore-working-copy"; const WORKSPACE_COMMAND: &str = "workspace"; +const WORKSPACE_LIST_MAX_ATTEMPTS: usize = 3; + +/// Workspace roots cost a jj subprocess each, so they are cached, but only for the operation they were resolved at: every add and forget, including one made by another process, lands as a new operation, so a name recreated at a different root cannot outlive its cached path. +#[derive(Default)] +pub(super) struct WorkspacePathCache { + op_id: Option, + paths: HashMap, +} + +impl WorkspacePathCache { + /// Advances the cache only when `repo` is at least as new as its current generation. An older overlapping list call must not roll a newer generation backwards. + fn sync_to(&mut self, repo: &Arc) -> CoreResult { + if self.op_id.as_ref() == Some(repo.op_id()) { + return Ok(true); + } + if let Some(op_id) = self.op_id.as_ref() + && !op_is_ancestor_of(repo, op_id)? + { + return Ok(false); + } + self.op_id = Some(repo.op_id().clone()); + self.paths.clear(); + Ok(true) + } + + fn path(&self, op_id: &OperationId, name: &str) -> Option<&String> { + (self.op_id.as_ref() == Some(op_id)) + .then(|| self.paths.get(name)) + .flatten() + } + + /// A resolver can finish after another call advances the cache, so insertion is conditional on the operation captured before resolution began. + fn insert_if_current(&mut self, op_id: &OperationId, name: String, path: String) -> bool { + if self.op_id.as_ref() != Some(op_id) { + return false; + } + self.paths.insert(name, path); + true + } +} impl Repo { - /// List all workspaces for this repo. + /// List all workspaces with the status of each one's committed `@`, retrying from a newer operation when roots and the repository head change concurrently; loading repository views never snapshots another working copy. pub fn workspace_list(&self) -> CoreResult> { - let output = self.run_jj(&[IGNORE_WORKING_COPY_ARG, WORKSPACE_COMMAND, "list"])?; - let current_name = self.workspace_name.as_str(); + let mut repo = self.get_repo(); + for _ in 0..WORKSPACE_LIST_MAX_ATTEMPTS { + let (workspaces, newer_repo) = self.workspace_list_at(repo)?; + let Some(newer_repo) = newer_repo else { + return Ok(workspaces); + }; + repo = newer_repo; + } + Err(CoreError::Internal { + message: + "workspace list kept changing while roots were resolving; refresh and try again" + .to_owned(), + }) + } + + fn workspace_list_at( + &self, + repo: Arc, + ) -> CoreResult<(Vec, Option>)> { + let operation_id = repo.op_id().hex(); + let cache_op_id = self + .workspace_paths + .write() + .unwrap() + .sync_to(&repo)? + .then(|| repo.op_id().clone()); let mut workspaces = Vec::new(); - for line in output.lines() { - let name = line.split(':').next().unwrap_or("").trim().to_owned(); - if name.is_empty() { + for (name, commit_id) in repo.view().wc_commit_ids() { + let name = name.as_str().to_owned(); + let Ok(commit) = repo.store().get_commit(commit_id) else { continue; - } - let path = self - .run_jj(&[ - IGNORE_WORKING_COPY_ARG, - WORKSPACE_COMMAND, - "root", - "--name", - &name, - ]) - .unwrap_or_default(); - let is_current = name == current_name; + }; + let change_id = encode_reverse_hex(commit.change_id().as_bytes()); + let change_id_short_len = repo + .shortest_unique_change_id_prefix_len(commit.change_id()) + .unwrap_or(change_id.len()) as u32; + let parent_tree = self.load_parent_tree(&repo, &commit, "load parent tree")?; + let files_changed = parent_tree + .diff_stream(&commit.tree(), &EverythingMatcher) + .count() + .block_on() as u32; + let is_current = name == self.workspace_name.as_str(); + let (path, is_path_resolved) = + match self.workspace_path(&name, is_current, cache_op_id.as_ref()) { + Ok(path) => (path, true), + Err(_) => ( + self.recorded_workspace_path(&name).unwrap_or_default(), + false, + ), + }; workspaces.push(WorkspaceInfo { - name, path, + is_path_resolved, is_current, + operation_id: operation_id.clone(), + change_id: ShortId::new(change_id, change_id_short_len), + description: commit.description().lines().next().unwrap_or("").to_owned(), + timestamp: commit.committer().timestamp.timestamp.0, + has_conflict: commit.has_conflict(), + files_changed, + name, }); } - Ok(workspaces) + let context = "validate workspace list"; + let workspace = load_workspace_internal(&self.path, context)?; + let current_repo = load_repo_at_head(&workspace, context)?; + if current_repo.op_id() != repo.op_id() { + return Ok((workspaces, Some(current_repo))); + } + Ok((workspaces, None)) + } + + /// Resolve a workspace root via the CLI, reusing the root already resolved for the current operation. + pub(super) fn workspace_path( + &self, + name: &str, + is_current: bool, + cache_op_id: Option<&OperationId>, + ) -> CoreResult { + if is_current { + return Ok(self.path.to_string_lossy().into_owned()); + } + if let Some(op_id) = cache_op_id + && let Some(path) = self.workspace_paths.read().unwrap().path(op_id, name) + && Path::new(path).is_dir() + { + return Ok(path.clone()); + } + let name_arg = format!("--name={name}"); + let path = self.run_jj(&[ + IGNORE_WORKING_COPY_ARG, + WORKSPACE_COMMAND, + "root", + &name_arg, + ])?; + if path.is_empty() || !Path::new(&path).is_absolute() { + return Err(CoreError::Internal { + message: format!("workspace root for {name} did not resolve to an absolute path"), + }); + } + if let Some(op_id) = cache_op_id { + self.workspace_paths.write().unwrap().insert_if_current( + op_id, + name.to_owned(), + path.clone(), + ); + } + Ok(path) + } + + /// Preserve a stable identity for an unresolved row when jj's root command cannot reach the checkout; shells may use it only to quiesce a matching window before name-based recovery. + fn recorded_workspace_path(&self, name: &str) -> Option { + let workspace = load_workspace_internal(&self.path, "read recorded workspace path").ok()?; + let workspace_name = jj_lib::ref_name::WorkspaceName::new(name); + let path = SimpleWorkspaceStore::load(workspace.repo_path()) + .and_then(|store| store.get_workspace_path(workspace_name)) + .ok()??; + normalized_absolute_path(&workspace.repo_path().join(path))? + .into_os_string() + .into_string() + .ok() + } + + /// Whether this checkout still owns its workspace name. Read from a freshly loaded head, never the cached repo, because the case worth detecting is another process forgetting this workspace, which the in-memory view still shows. + pub fn workspace_presence(&self) -> WorkspacePresence { + let context = "check workspace presence"; + let loaded = load_workspace_internal(&self.path, context).and_then(|workspace| { + let repo = load_repo_at_head(&workspace, context)?; + Ok((workspace, repo)) + }); + let Ok((workspace, repo)) = loaded else { + // A failed load only proves removal once the checkout itself is gone; otherwise it can be an op head mid-write or transient IO. + return if self.path.join(".jj").exists() { + WorkspacePresence::Unknown + } else { + WorkspacePresence::Gone + }; + }; + if repo + .view() + .get_wc_commit_id(self.workspace_name.as_ref()) + .is_none() + { + return WorkspacePresence::Gone; + } + self.recorded_root_presence(&workspace) + } + + /// The view maps a name to a commit, not to a checkout, so a name another process forgot and re-added elsewhere still resolves here; only the recorded root says whether this checkout still owns the name. + fn recorded_root_presence(&self, workspace: &Workspace) -> WorkspacePresence { + let recorded = SimpleWorkspaceStore::load(workspace.repo_path()) + .and_then(|store| store.get_workspace_path(self.workspace_name.as_ref())); + let recorded = match recorded { + // Repos predating recorded roots have no entry, which is no evidence of a move. + Ok(None) => return WorkspacePresence::Exists, + Ok(Some(path)) => workspace.repo_path().join(path), + Err(_) => return WorkspacePresence::Unknown, + }; + let (Ok(recorded), Ok(own)) = ( + std::fs::canonicalize(recorded), + std::fs::canonicalize(&self.path), + ) else { + return WorkspacePresence::Unknown; + }; + if recorded == own { + WorkspacePresence::Exists + } else { + WorkspacePresence::Gone + } } /// Create a new workspace at the given path, optionally on a specific revision. @@ -61,66 +262,42 @@ impl Repo { self.reload()?; Ok(output) } - - /// Remove a workspace. - pub fn workspace_forget(&self, name: &str) -> CoreResult<()> { - // `--` so an option-shaped workspace name is read as an operand, never as a jj flag. - self.run_jj_reload(&[WORKSPACE_COMMAND, "forget", "--", name]) - } -} - -/// True when `name` is safe both as a jj workspace name and as the sibling directory both shells create for it: no path separators or traversal, no option shape, no characters that are invalid in directory names on any supported platform. -pub fn is_valid_workspace_name(name: &str) -> bool { - if name.is_empty() || name.len() > 255 || name == "." || name == ".." { - return false; - } - if name.starts_with('-') || name.ends_with('.') { - return false; - } - !name.chars().any(|ch| { - ch.is_control() - || ch.is_whitespace() - || matches!( - ch, - '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*' | '@' - ) - }) } #[cfg(test)] mod tests { - use super::is_valid_workspace_name; + use jj_test::init_jj_repo; - #[test] - fn workspace_names_accept_simple_directory_safe_names() { - for ok in ["feature", "feature-2", "a_b", "ws.1", "Über"] { - assert!(is_valid_workspace_name(ok), "{ok} should be valid"); - } - } + use super::WorkspacePathCache; + use crate::Repo; #[test] - fn workspace_names_reject_path_option_and_revset_shapes() { - let bad = [ - "", - ".", - "..", - "-x", - "--name", - "a/b", - "a\\b", - "../up", - "a b", - "a\tb", - "a\nb", - "a@b", - "@", - "a:b", - "a*b", - "a?b", - "trailing.", - ]; - for name in bad { - assert!(!is_valid_workspace_name(name), "{name:?} should be invalid"); - } + fn workspace_path_cache_rejects_stale_generation_writes_and_rollbacks() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo = Repo::open(&repo_path).expect("open repo"); + let old_repo = repo.get_repo(); + + let dest = temp_dir.path().join("feature-ws"); + repo.workspace_add(dest.to_str().expect("utf8 dest"), "feature", "") + .expect("add workspace"); + let new_repo = repo.get_repo(); + + let mut cache = WorkspacePathCache::default(); + assert!(cache.sync_to(&old_repo).expect("set old generation")); + assert!(cache.insert_if_current(old_repo.op_id(), "feature".to_owned(), "old".to_owned())); + assert!(cache.sync_to(&new_repo).expect("advance generation")); + + assert!(!cache.insert_if_current( + old_repo.op_id(), + "feature".to_owned(), + "stale".to_owned() + )); + assert!( + !cache.sync_to(&old_repo).expect("reject old generation"), + "an older overlapping list call must not roll the cache back" + ); + assert_eq!(cache.op_id.as_ref(), Some(new_repo.op_id())); + assert!(cache.paths.is_empty()); } } diff --git a/crates/jayjay-core/src/repo/workspace_path.rs b/crates/jayjay-core/src/repo/workspace_path.rs new file mode 100644 index 00000000..7906eef6 --- /dev/null +++ b/crates/jayjay-core/src/repo/workspace_path.rs @@ -0,0 +1,69 @@ +/// The primary workspace root backing the jj workspace at `path`, or None when `path` is not a jj workspace. Secondary workspaces store the primary's repo location in a `.jj/repo` file (relative to `.jj/`), primaries have a `.jj/repo` directory. +pub fn workspace_primary_root(path: &str) -> Option { + let jj_dir = std::path::Path::new(path).join(".jj"); + let marker = jj_dir.join("repo"); + let metadata = std::fs::metadata(&marker).ok()?; + if metadata.is_dir() { + return Some(path.to_owned()); + } + let contents = std::fs::read_to_string(&marker).ok()?; + let store = jj_dir.join(contents.trim()); + let root = std::fs::canonicalize(store.parent()?.parent()?).ok()?; + Some(root.to_string_lossy().into_owned()) +} + +/// True when `name` is safe both as a jj workspace name and as the sibling directory both shells create for it: no path separators or traversal, no option shape, no characters that are invalid in directory names on any supported platform. +pub fn is_valid_workspace_name(name: &str) -> bool { + if name.is_empty() || name.len() > 255 || name == "." || name == ".." { + return false; + } + if name.starts_with('-') || name.ends_with('.') { + return false; + } + !name.chars().any(|ch| { + ch.is_control() + || ch.is_whitespace() + || matches!( + ch, + '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*' | '@' + ) + }) +} + +#[cfg(test)] +mod tests { + use super::is_valid_workspace_name; + + #[test] + fn workspace_names_accept_simple_directory_safe_names() { + for ok in ["feature", "feature-2", "a_b", "ws.1", "Über"] { + assert!(is_valid_workspace_name(ok), "{ok} should be valid"); + } + } + + #[test] + fn workspace_names_reject_path_option_and_revset_shapes() { + let bad = [ + "", + ".", + "..", + "-x", + "--name", + "a/b", + "a\\b", + "../up", + "a b", + "a\tb", + "a\nb", + "a@b", + "@", + "a:b", + "a*b", + "a?b", + "trailing.", + ]; + for name in bad { + assert!(!is_valid_workspace_name(name), "{name:?} should be invalid"); + } + } +} diff --git a/crates/jayjay-core/src/repo/workspace_removal.rs b/crates/jayjay-core/src/repo/workspace_removal.rs new file mode 100644 index 00000000..265de55b --- /dev/null +++ b/crates/jayjay-core/src/repo/workspace_removal.rs @@ -0,0 +1,306 @@ +use std::path::{Component, Path, PathBuf}; + +use jj_lib::object_id::ObjectId; +use jj_lib::workspace::Workspace; +use jj_lib::workspace_store::{SimpleWorkspaceStore, WorkspaceStore as _}; +use pollster::FutureExt as _; + +use super::Repo; +use super::support::{load_repo_at_head, load_workspace_internal}; +use crate::types::{CoreError, CoreResult}; + +impl Repo { + /// Validate that `name` still identifies the checkout at `expected_root`, returning the exact operation the destructive flow must consume. + pub fn workspace_removal_guard( + &self, + name: &str, + expected_root: &str, + expected_operation: &str, + ) -> CoreResult { + if name == self.workspace_name.as_str() { + return Err(CoreError::Internal { + message: "cannot remove the current workspace".to_owned(), + }); + } + let context = "prepare workspace removal"; + let workspace = load_workspace_internal(&self.path, context)?; + let repo = load_repo_at_head(&workspace, context)?; + if repo.op_id().hex() != expected_operation { + return Err(CoreError::Internal { + message: format!( + "workspace {name} changed after it was listed; refresh and try again" + ), + }); + } + let workspace_name = repo + .view() + .wc_commit_ids() + .keys() + .find(|workspace_name| workspace_name.as_str() == name) + .ok_or_else(|| CoreError::Internal { + message: format!("workspace {name} no longer exists"), + })?; + let expected_root = + self.verified_workspace_root(&workspace, workspace_name, expected_root)?; + let target = load_workspace_internal(&expected_root, context)?; + let target_repo_path = + std::fs::canonicalize(target.repo_path()).map_err(|error| CoreError::Internal { + message: format!("canonicalize target workspace repository: {error}"), + })?; + let source_repo_path = + std::fs::canonicalize(workspace.repo_path()).map_err(|error| CoreError::Internal { + message: format!("canonicalize source workspace repository: {error}"), + })?; + if target.workspace_name() != workspace_name || target_repo_path != source_repo_path { + return Err(CoreError::Internal { + message: format!( + "workspace {name} at {} no longer belongs to this repository", + expected_root.display() + ), + }); + } + Ok(repo.op_id().hex()) + } + + /// Remove exactly the workspace generation previously returned by `workspace_removal_guard`; operation-head locking makes the identity check and publication atomic with other jj commands. + pub fn workspace_forget( + &self, + name: &str, + expected_root: &str, + expected_operation: &str, + ) -> CoreResult> { + self.workspace_forget_internal(name, Some(expected_root), expected_operation) + } + + /// Forget a listed workspace whose checkout root could not be resolved. This recovery is name-only and never authorizes deleting files; the exact operation still locks the workspace generation being removed. + pub fn workspace_forget_unresolved( + &self, + name: &str, + expected_operation: &str, + ) -> CoreResult> { + if name == self.workspace_name.as_str() { + return Err(CoreError::Internal { + message: "cannot remove the current workspace".to_owned(), + }); + } + if self.workspace_path(name, false, None).is_ok() { + return Err(CoreError::Internal { + message: format!("workspace {name} root became available; refresh and try again"), + }); + } + self.workspace_forget_internal(name, None, expected_operation) + } + + fn workspace_forget_internal( + &self, + name: &str, + expected_root: Option<&str>, + expected_operation: &str, + ) -> CoreResult> { + let context = "forget workspace"; + let workspace = load_workspace_internal(&self.path, context)?; + let repo = load_repo_at_head(&workspace, context)?; + let workspace_name = repo + .view() + .wc_commit_ids() + .keys() + .find(|workspace_name| workspace_name.as_str() == name) + .map(ToOwned::to_owned) + .ok_or_else(|| CoreError::Internal { + message: format!("workspace {name} no longer exists"), + })?; + let op_heads_store = repo.loader().op_heads_store().clone(); + let op_heads_lock = + op_heads_store + .lock() + .block_on() + .map_err(|error| CoreError::Internal { + message: format!("lock operation heads before forgetting workspace: {error}"), + })?; + let head_ids = + op_heads_store + .get_op_heads() + .block_on() + .map_err(|error| CoreError::Internal { + message: format!("read operation heads before forgetting workspace: {error}"), + })?; + if repo.op_id().hex() != expected_operation + || head_ids.as_slice() != std::slice::from_ref(repo.op_id()) + { + return Err(CoreError::Internal { + message: format!( + "workspace {name} changed after confirmation; refresh and try again" + ), + }); + } + if let Some(expected_root) = expected_root + && !self.recorded_workspace_root_matches(&workspace, &workspace_name, expected_root)? + { + return Err(CoreError::Internal { + message: format!( + "workspace {name} moved after confirmation; refresh and try again" + ), + }); + } + + let old_working_copy_commit_id = self.current_wc_commit_id(); + let mut transaction = repo.start_transaction(); + transaction.set_workspace_name(self.workspace_name.as_ref()); + transaction + .repo_mut() + .remove_wc_commit(&workspace_name) + .block_on() + .map_err(|error| CoreError::Internal { + message: format!("remove workspace {name}: {error}"), + })?; + transaction + .repo_mut() + .rebase_descendants() + .block_on() + .map_err(|error| CoreError::Internal { + message: format!("rebase after forgetting workspace {name}: {error}"), + })?; + let unpublished = transaction + .write(format!("forget workspace {name}")) + .block_on() + .map_err(|error| CoreError::Internal { + message: format!("write forget workspace operation: {error}"), + })?; + let operation = unpublished.operation().clone(); + let updated_repo = unpublished.leave_unpublished(); + let workspace_store = + SimpleWorkspaceStore::load(workspace.repo_path()).map_err(|error| { + CoreError::Internal { + message: format!("load workspace store before forgetting: {error}"), + } + })?; + op_heads_store + .update_op_heads(operation.parent_ids(), operation.id()) + .block_on() + .map_err(|error| CoreError::Internal { + message: format!("publish forget workspace operation: {error}"), + })?; + let workspace_store_warning = workspace_store + .forget(&[workspace_name.as_ref()]) + .err() + .map(|error| { + format!( + "Workspace {name} was forgotten, but its saved checkout path could not be removed: {error}" + ) + }); + drop(op_heads_lock); + self.set_repo(updated_repo); + if self.current_wc_commit_id() != old_working_copy_commit_id + && let Err(error) = + self.check_out_current_working_copy("sync working copy after forgetting workspace") + { + let checkout_warning = format!( + "Workspace {name} was forgotten, but the current working copy could not be synchronized: {error}" + ); + return Ok(Some(match workspace_store_warning { + Some(workspace_store_warning) => { + format!("{workspace_store_warning}\n{checkout_warning}") + } + None => checkout_warning, + })); + } + Ok(workspace_store_warning) + } + + fn verified_workspace_root( + &self, + workspace: &Workspace, + workspace_name: &jj_lib::ref_name::WorkspaceName, + expected_root: &str, + ) -> CoreResult { + let expected_root = + std::fs::canonicalize(expected_root).map_err(|error| CoreError::Internal { + message: format!("canonicalize expected workspace root: {error}"), + })?; + let recorded_root = SimpleWorkspaceStore::load(workspace.repo_path()) + .and_then(|store| store.get_workspace_path(workspace_name)) + .map_err(|error| CoreError::Internal { + message: format!("read workspace root: {error}"), + })?; + // Older repositories have no saved roots; the caller still proves ownership by loading expected_root and matching both its workspace name and repository below. + let Some(recorded_root) = recorded_root else { + return Ok(expected_root); + }; + let recorded_root = std::fs::canonicalize(workspace.repo_path().join(recorded_root)) + .map_err(|error| CoreError::Internal { + message: format!("canonicalize recorded workspace root: {error}"), + })?; + if recorded_root != expected_root { + return Err(CoreError::Internal { + message: format!( + "workspace {} moved from {} to {}", + workspace_name.as_str(), + expected_root.display(), + recorded_root.display() + ), + }); + } + Ok(expected_root) + } + + fn recorded_workspace_root_matches( + &self, + workspace: &Workspace, + workspace_name: &jj_lib::ref_name::WorkspaceName, + expected_root: &str, + ) -> CoreResult { + let Some(expected_root) = normalized_absolute_path(Path::new(expected_root)) else { + return Ok(false); + }; + let recorded_root = SimpleWorkspaceStore::load(workspace.repo_path()) + .and_then(|store| store.get_workspace_path(workspace_name)) + .map_err(|error| CoreError::Internal { + message: format!("read workspace root before forgetting: {error}"), + })?; + // A missing legacy entry was already validated against the live checkout by workspace_removal_guard; the operation-head check above proves that validation is still current after quarantine. + let Some(recorded_root) = recorded_root else { + return Ok(true); + }; + Ok( + normalized_absolute_path(&workspace.repo_path().join(recorded_root)) + .is_some_and(|path| path == expected_root), + ) + } +} + +pub(super) fn normalized_absolute_path(path: &Path) -> Option { + // Windows canonicalization adds a verbatim prefix that JJ's workspace store does not preserve. + let path = dunce::simplified(path); + if !path.is_absolute() { + return None; + } + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + if !normalized.pop() { + return None; + } + } + Component::Normal(part) => normalized.push(part), + } + } + Some(normalized) +} + +#[cfg(all(test, windows))] +mod tests { + use super::normalized_absolute_path; + use std::path::Path; + + #[test] + fn windows_verbatim_and_standard_paths_have_the_same_identity() { + assert_eq!( + normalized_absolute_path(Path::new(r"\\?\C:\repo\feature")), + normalized_absolute_path(Path::new(r"C:\repo\feature")) + ); + } +} diff --git a/crates/jayjay-core/tests/workspaces.rs b/crates/jayjay-core/tests/workspaces.rs index 4544191e..c3cab57c 100644 --- a/crates/jayjay-core/tests/workspaces.rs +++ b/crates/jayjay-core/tests/workspaces.rs @@ -1,6 +1,8 @@ //! Workspace add/list/forget behavior over jj, including operand safety for option-shaped destinations and workspace names. -use jayjay_core::Repo; +use jayjay_core::{Repo, WorkspacePresence}; +use jj_lib::ref_name::WorkspaceName; +use jj_lib::workspace_store::{SimpleWorkspaceStore, WorkspaceStore as _}; use jj_test::{init_jj_repo, run_jj_in}; fn workspace_names(repo: &Repo) -> Vec { @@ -11,6 +13,34 @@ fn workspace_names(repo: &Repo) -> Vec { .collect() } +fn workspace_path(repo: &Repo, name: &str) -> std::path::PathBuf { + let workspaces = repo.workspace_list().expect("workspace list"); + let workspace = workspaces + .iter() + .find(|ws| ws.name == name) + .unwrap_or_else(|| panic!("{name} row")); + std::fs::canonicalize(&workspace.path).expect("canonical workspace path") +} + +fn forget_workspace(repo: &Repo, name: &str, path: &std::path::Path) { + let path = std::fs::canonicalize(path).expect("canonical workspace path"); + let path = path.to_str().expect("utf8 workspace path"); + let expected_operation = repo + .workspace_list() + .expect("workspace list") + .into_iter() + .find(|workspace| workspace.name == name) + .expect("workspace row") + .operation_id; + let operation = repo + .workspace_removal_guard(name, path, &expected_operation) + .expect("workspace removal guard"); + let warning = repo + .workspace_forget(name, path, &operation) + .expect("workspace forget"); + assert!(warning.is_none(), "{warning:?}"); +} + fn current_op_id_ignoring_working_copy(repo_path: &std::path::Path) -> String { let output = run_jj_in( repo_path, @@ -46,11 +76,42 @@ fn workspace_add_and_forget_roundtrip() { let current = workspaces.iter().find(|ws| ws.is_current).expect("current"); assert_eq!(current.name, "default", "adding must not switch workspaces"); - repo.workspace_forget("feature").expect("workspace forget"); + forget_workspace(&repo, "feature", &dest); let names = workspace_names(&repo); assert!(!names.contains(&"feature".to_owned()), "{names:?}"); } +#[test] +fn workspace_forget_supports_legacy_repositories_without_saved_roots() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo = Repo::open(&repo_path).expect("open repo"); + let dest = temp_dir.path().join("repo-feature"); + repo.workspace_add(dest.to_str().expect("utf8 dest"), "feature", "") + .expect("workspace add"); + let row = repo + .workspace_list() + .expect("workspace list") + .into_iter() + .find(|workspace| workspace.name == "feature") + .expect("workspace row"); + SimpleWorkspaceStore::load(&repo_path.join(".jj").join("repo")) + .expect("workspace store") + .forget(&[WorkspaceName::new("feature")]) + .expect("remove saved root to simulate a legacy repository"); + + let operation = repo + .workspace_removal_guard("feature", &row.path, &row.operation_id) + .expect("legacy workspace removal guard"); + let warning = repo + .workspace_forget("feature", &row.path, &operation) + .expect("legacy workspace forget"); + + assert!(warning.is_none(), "{warning:?}"); + repo.refresh_working_copy().expect("reload after forget"); + assert_eq!(workspace_names(&repo), ["default"]); +} + #[test] fn workspace_list_does_not_snapshot_the_working_copy() { let temp_dir = init_jj_repo(); @@ -112,8 +173,7 @@ fn workspace_add_treats_option_shaped_destination_as_literal_path() { let names = workspace_names(&repo); assert!(names.contains(&"hostile".to_owned()), "{names:?}"); - repo.workspace_forget("hostile") - .expect("forget with operand separator"); + forget_workspace(&repo, "hostile", &repo_path.join("-hostile")); let names = workspace_names(&repo); assert!(!names.contains(&"hostile".to_owned()), "{names:?}"); } @@ -150,6 +210,438 @@ fn workspace_add_rejects_option_shaped_revision() { assert!(!dest.exists()); } +#[test] +fn workspace_list_reports_sibling_working_copy_status() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo = Repo::open(&repo_path).expect("open repo"); + + let dest = temp_dir.path().join("feature-ws"); + repo.workspace_add(dest.to_str().expect("utf8 dest"), "feature", "") + .expect("add workspace"); + + std::fs::write(dest.join("new-file"), "content\n").expect("write file in sibling"); + run_jj_in(&dest, &["describe", "-m", "sibling work\n\nbody"]); + repo.refresh_working_copy() + .expect("reload after sibling activity"); + + let workspaces = repo.workspace_list().expect("workspace list"); + let feature = workspaces + .iter() + .find(|ws| ws.name == "feature") + .expect("feature row"); + assert!(!feature.is_current); + assert_eq!(feature.description, "sibling work"); + assert_eq!(feature.files_changed, 1); + assert!(!feature.has_conflict); + assert!(feature.timestamp > 0); + assert!(feature.change_id.short_len > 0); + assert_eq!( + std::fs::canonicalize(&feature.path).expect("canonical sibling path"), + std::fs::canonicalize(&dest).expect("canonical dest") + ); +} + +/// One missing checkout must not hide valid siblings; the unresolved row remains non-actionable except for operation-locked Forget recovery. +#[test] +fn workspace_list_preserves_valid_rows_and_forgets_an_unresolved_root() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo = Repo::open(&repo_path).expect("open repo"); + + let dest = temp_dir.path().join("feature-ws"); + repo.workspace_add(dest.to_str().expect("utf8 dest"), "feature", "") + .expect("add workspace"); + let feature = repo + .workspace_list() + .expect("prime resolved workspace path cache") + .into_iter() + .find(|workspace| workspace.name == "feature") + .expect("resolved feature row"); + assert!(feature.is_path_resolved); + std::fs::remove_dir_all(&dest).expect("remove workspace directory"); + + let workspaces = repo.workspace_list().expect("list with unresolved sibling"); + let current = workspaces + .iter() + .find(|workspace| workspace.is_current) + .expect("current row"); + let feature = workspaces + .iter() + .find(|workspace| workspace.name == "feature") + .expect("unresolved feature row"); + assert!(current.is_path_resolved); + assert!(!feature.is_path_resolved); + let canonical_temp = std::fs::canonicalize(temp_dir.path()).expect("canonical temp root"); + assert_eq!( + std::path::PathBuf::from(&feature.path), + dunce::simplified(&canonical_temp).join("feature-ws") + ); + + let warning = repo + .workspace_forget_unresolved("feature", &feature.operation_id) + .expect("forget unresolved workspace"); + assert!(warning.is_none(), "{warning:?}"); + repo.refresh_working_copy().expect("reload after forget"); + assert_eq!(workspace_names(&repo), ["default"]); +} + +#[test] +fn unresolved_workspace_forget_rejects_a_root_that_became_available() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo = Repo::open(&repo_path).expect("open repo"); + let dest = temp_dir.path().join("feature-ws"); + let held = temp_dir.path().join("feature-held"); + repo.workspace_add(dest.to_str().expect("utf8 dest"), "feature", "") + .expect("add workspace"); + std::fs::rename(&dest, &held).expect("hide workspace directory"); + let feature = repo + .workspace_list() + .expect("list unresolved workspace") + .into_iter() + .find(|workspace| workspace.name == "feature") + .expect("feature row"); + assert!(!feature.is_path_resolved); + std::fs::rename(&held, &dest).expect("restore workspace directory"); + + let error = repo + .workspace_forget_unresolved("feature", &feature.operation_id) + .expect_err("a recovered root must require normal guarded removal"); + + assert!( + error.to_string().contains("root became available"), + "{error}" + ); + assert!(workspace_names(&repo).contains(&"feature".to_owned())); +} + +#[test] +fn workspace_list_retries_from_an_external_operation_generation() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo = Repo::open(&repo_path).expect("open repo before external operation"); + let dest = temp_dir.path().join("feature-ws"); + repo.workspace_add(dest.to_str().expect("utf8 dest"), "feature", "") + .expect("add workspace"); + + run_jj_in(&repo_path, &["describe", "-m", "externally updated"]); + + let workspaces = repo + .workspace_list() + .expect("retry workspace list from the newer operation"); + let current = workspaces + .iter() + .find(|workspace| workspace.is_current) + .expect("current workspace row"); + assert_eq!(current.description, "externally updated"); + assert!( + workspaces + .iter() + .any(|workspace| workspace.name == "feature") + ); +} + +/// Another process can forget a name and recreate it at a different root without the name ever leaving the listing, so cached roots must not survive the operations that moved it. +#[test] +fn workspace_list_reresolves_a_name_recreated_at_another_root() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo = Repo::open(&repo_path).expect("open repo"); + + let first = temp_dir.path().join("feature-first"); + repo.workspace_add(first.to_str().expect("utf8 dest"), "feature", "") + .expect("add workspace"); + assert_eq!( + workspace_path(&repo, "feature"), + std::fs::canonicalize(&first).expect("canonical first") + ); + + let second = temp_dir.path().join("feature-second"); + run_jj_in(&repo_path, &["workspace", "forget", "feature"]); + run_jj_in( + &repo_path, + &[ + "workspace", + "add", + "--name", + "feature", + second.to_str().expect("utf8 dest"), + ], + ); + repo.refresh_working_copy() + .expect("reload after external recreate"); + + assert_eq!( + workspace_path(&repo, "feature"), + std::fs::canonicalize(&second).expect("canonical second"), + "the recreated root must replace the cached one" + ); +} + +#[test] +fn stale_workspace_removal_guard_does_not_forget_a_recreated_name() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo = Repo::open(&repo_path).expect("open repo"); + + let first = temp_dir.path().join("feature-first"); + repo.workspace_add(first.to_str().expect("utf8 dest"), "feature", "") + .expect("add workspace"); + let first = std::fs::canonicalize(first).expect("canonical first root"); + let expected_operation = repo + .workspace_list() + .expect("workspace list") + .into_iter() + .find(|workspace| workspace.name == "feature") + .expect("workspace row") + .operation_id; + let operation = repo + .workspace_removal_guard( + "feature", + first.to_str().expect("utf8 root"), + &expected_operation, + ) + .expect("workspace removal guard"); + + let second = temp_dir.path().join("feature-second"); + run_jj_in(&repo_path, &["workspace", "forget", "feature"]); + run_jj_in( + &repo_path, + &[ + "workspace", + "add", + "--name", + "feature", + second.to_str().expect("utf8 dest"), + ], + ); + + let error = repo + .workspace_forget("feature", first.to_str().expect("utf8 root"), &operation) + .expect_err("a stale removal guard must not forget the recreated workspace"); + assert!( + error.to_string().contains("changed after confirmation"), + "{error}" + ); + repo.refresh_working_copy() + .expect("reload after external recreate"); + assert_eq!( + workspace_path(&repo, "feature"), + std::fs::canonicalize(second).expect("canonical second root") + ); +} + +#[test] +fn stale_workspace_row_does_not_validate_a_recreated_name_at_the_same_root() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo = Repo::open(&repo_path).expect("open repo"); + + let dest = temp_dir.path().join("feature-ws"); + repo.workspace_add(dest.to_str().expect("utf8 dest"), "feature", "") + .expect("add workspace"); + let listed_operation = repo + .workspace_list() + .expect("workspace list") + .into_iter() + .find(|workspace| workspace.name == "feature") + .expect("workspace row") + .operation_id; + + run_jj_in(&repo_path, &["workspace", "forget", "feature"]); + std::fs::remove_dir_all(&dest).expect("remove forgotten checkout"); + run_jj_in( + &repo_path, + &[ + "workspace", + "add", + "--name", + "feature", + dest.to_str().expect("utf8 dest"), + ], + ); + + let error = repo + .workspace_removal_guard( + "feature", + dest.to_str().expect("utf8 dest"), + &listed_operation, + ) + .expect_err("a stale row must not validate its same-name, same-root replacement"); + assert!( + error.to_string().contains("changed after it was listed"), + "{error}" + ); +} + +#[test] +fn workspace_removal_guard_rejects_a_replaced_checkout_directory() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo = Repo::open(&repo_path).expect("open repo"); + + let dest = temp_dir.path().join("feature-ws"); + repo.workspace_add(dest.to_str().expect("utf8 dest"), "feature", "") + .expect("add workspace"); + let expected_operation = repo + .workspace_list() + .expect("workspace list") + .into_iter() + .find(|workspace| workspace.name == "feature") + .expect("workspace row") + .operation_id; + std::fs::rename(&dest, temp_dir.path().join("feature-original")) + .expect("move original workspace"); + std::fs::create_dir(&dest).expect("create unrelated replacement"); + + let error = repo + .workspace_removal_guard( + "feature", + dest.to_str().expect("utf8 dest"), + &expected_operation, + ) + .expect_err("an unrelated replacement must not receive a removal guard"); + assert!( + error.to_string().contains("prepare workspace removal"), + "{error}" + ); +} + +/// A workspace forgotten by another process keeps its checkout directory, `.jj` and all, so presence has to come from the view rather than the filesystem. +#[test] +fn forgotten_workspace_is_gone_while_its_directory_remains() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo = Repo::open(&repo_path).expect("open repo"); + + let dest = temp_dir.path().join("feature-ws"); + repo.workspace_add(dest.to_str().expect("utf8 dest"), "feature", "") + .expect("add workspace"); + let secondary = Repo::open(&dest).expect("open secondary"); + assert_eq!(secondary.workspace_presence(), WorkspacePresence::Exists); + + run_jj_in(&repo_path, &["workspace", "forget", "feature"]); + + assert!( + dest.join(".jj").exists(), + "forget leaves the checkout behind" + ); + assert_eq!( + secondary.workspace_presence(), + WorkspacePresence::Gone, + "a forgotten workspace must not report itself as existing" + ); + assert_eq!( + repo.workspace_presence(), + WorkspacePresence::Exists, + "the primary is untouched" + ); +} + +/// The recreated workspace keeps the name in the view, so only the recorded root tells the old checkout that the name is no longer its own. +#[test] +fn workspace_recreated_at_another_root_leaves_the_old_checkout_gone() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo = Repo::open(&repo_path).expect("open repo"); + + let first = temp_dir.path().join("feature-first"); + repo.workspace_add(first.to_str().expect("utf8 dest"), "feature", "") + .expect("add workspace"); + let secondary = Repo::open(&first).expect("open secondary"); + assert_eq!(secondary.workspace_presence(), WorkspacePresence::Exists); + + let second = temp_dir.path().join("feature-second"); + run_jj_in(&repo_path, &["workspace", "forget", "feature"]); + run_jj_in( + &repo_path, + &[ + "workspace", + "add", + "--name", + "feature", + second.to_str().expect("utf8 dest"), + ], + ); + + assert_eq!( + secondary.workspace_presence(), + WorkspacePresence::Gone, + "the name now belongs to another checkout" + ); + assert_eq!( + Repo::open(&second) + .expect("open recreated workspace") + .workspace_presence(), + WorkspacePresence::Exists, + "the checkout that owns the name is unaffected" + ); +} + +/// Forget & Delete from Disk removes the checkout outright, and the refresh failure it triggers must still close the window. +#[test] +fn deleted_workspace_directory_is_gone() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo = Repo::open(&repo_path).expect("open repo"); + + let dest = temp_dir.path().join("feature-ws"); + repo.workspace_add(dest.to_str().expect("utf8 dest"), "feature", "") + .expect("add workspace"); + let secondary = Repo::open(&dest).expect("open secondary"); + + run_jj_in(&repo_path, &["workspace", "forget", "feature"]); + std::fs::remove_dir_all(&dest).expect("delete checkout"); + + assert_eq!(secondary.workspace_presence(), WorkspacePresence::Gone); +} + +/// An unreadable repo is not proof of removal: the window must keep showing the real refresh error instead of closing. +#[test] +fn unreadable_repo_leaves_presence_unknown() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo = Repo::open(&repo_path).expect("open repo"); + assert_eq!(repo.workspace_presence(), WorkspacePresence::Exists); + + std::fs::write(repo_path.join(".jj").join("working_copy").join("type"), "x") + .expect("scramble working copy type"); + + assert_eq!( + repo.workspace_presence(), + WorkspacePresence::Unknown, + "a load failure with the checkout still on disk stays undecided" + ); +} + +#[test] +fn workspace_primary_root_resolves_secondaries_to_the_primary() { + let temp_dir = init_jj_repo(); + let repo_path = temp_dir.path().join("repo"); + let repo = Repo::open(&repo_path).expect("open repo"); + + let dest = temp_dir.path().join("feature-ws"); + repo.workspace_add(dest.to_str().expect("utf8 dest"), "feature", "") + .expect("add workspace"); + + let canonical_repo = std::fs::canonicalize(&repo_path).expect("canonical repo"); + let primary = jayjay_core::workspace_primary_root(repo_path.to_str().expect("utf8")) + .expect("primary resolves"); + assert_eq!( + std::fs::canonicalize(&primary).expect("canonical primary"), + canonical_repo + ); + let from_secondary = jayjay_core::workspace_primary_root(dest.to_str().expect("utf8")) + .expect("secondary resolves"); + assert_eq!(std::path::PathBuf::from(from_secondary), canonical_repo); + assert_eq!( + jayjay_core::workspace_primary_root(temp_dir.path().to_str().expect("utf8")), + None, + "non-jj directories resolve to nothing" + ); +} + #[test] fn sibling_workspace_working_copies_carry_their_name() { let temp_dir = init_jj_repo(); diff --git a/crates/jayjay-primitives/src/ops.rs b/crates/jayjay-primitives/src/ops.rs index 5c9ae34f..7f002763 100644 --- a/crates/jayjay-primitives/src/ops.rs +++ b/crates/jayjay-primitives/src/ops.rs @@ -12,8 +12,27 @@ pub struct OpLogEntry { #[derive(Debug, Clone)] pub struct WorkspaceInfo { pub name: String, + /// Last known checkout path. It is actionable only when `is_path_resolved`; an unresolved row keeps its name and operation so the stale registration can still be forgotten safely. pub path: String, + pub is_path_resolved: bool, pub is_current: bool, + /// Operation generation whose workspace name, root, and status produced this row. + pub operation_id: String, + /// Status of the workspace's committed `@`, read from the in-memory view without snapshotting its working copy. + pub change_id: ShortId, + pub description: String, + pub timestamp: i64, + pub has_conflict: bool, + pub files_changed: u32, +} + +/// Whether a workspace still exists. Not a bool: a repo that momentarily fails to load is undecided, not forgotten. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkspacePresence { + Exists, + /// Proven absent: a loaded view has no working-copy commit for it, or its checkout is gone from disk. + Gone, + Unknown, } #[derive(Debug, Clone)] diff --git a/crates/jayjay-uniffi/src/repo.rs b/crates/jayjay-uniffi/src/repo.rs index cac74e76..64a2216b 100644 --- a/crates/jayjay-uniffi/src/repo.rs +++ b/crates/jayjay-uniffi/src/repo.rs @@ -6,7 +6,7 @@ use jayjay_core::{ DiffEditFileSelection, DiffHunk, DiffStats, EvologEntry, FetchResult, FileDiffStats, FileTreeEntry, GitSubmoduleStatus, GraphEntry, JjCommand, JjCommandResult, OpLogEntry, PrInfo, Repo, ReviewNoteOutputFormat, RevsetPreset, Stack, StackedPrResult, SubmitStackLayer, - ToolsConfig, WorkspaceInfo, + ToolsConfig, WorkspaceInfo, WorkspacePresence, diff::{self, CollapsedDiff, FileDiff}, }; use jayjay_primitives::{NoteAnchor, NoteEntry, NoteSide, ReviewNoteStatus}; @@ -128,6 +128,11 @@ fn is_valid_workspace_name(name: String) -> bool { jayjay_core::is_valid_workspace_name(&name) } +#[uniffi::export] +fn workspace_primary_root(path: String) -> Option { + jayjay_core::workspace_primary_root(&path) +} + #[uniffi::export] fn jj_command_body(query: String) -> Option { JjCommand::from_palette_query(&query).map(JjCommand::into_raw) @@ -529,8 +534,40 @@ impl JayJayRepo { Ok(self.inner.workspace_add(&dest, &name, &rev)?) } - fn workspace_forget(&self, name: String) -> Result<(), JayJayError> { - Ok(self.inner.workspace_forget(&name)?) + fn workspace_removal_guard( + &self, + name: String, + expected_root: String, + expected_operation: String, + ) -> Result { + Ok(self + .inner + .workspace_removal_guard(&name, &expected_root, &expected_operation)?) + } + + fn workspace_forget( + &self, + name: String, + expected_root: String, + expected_operation: String, + ) -> Result, JayJayError> { + Ok(self + .inner + .workspace_forget(&name, &expected_root, &expected_operation)?) + } + + fn workspace_forget_unresolved( + &self, + name: String, + expected_operation: String, + ) -> Result, JayJayError> { + Ok(self + .inner + .workspace_forget_unresolved(&name, &expected_operation)?) + } + + fn workspace_presence(&self) -> WorkspacePresence { + self.inner.workspace_presence() } fn pull_request_info(&self, bookmark: String) -> Option { diff --git a/crates/jayjay-uniffi/src/types/repo.rs b/crates/jayjay-uniffi/src/types/repo.rs index a6034d5f..8ae51bf0 100644 --- a/crates/jayjay-uniffi/src/types/repo.rs +++ b/crates/jayjay-uniffi/src/types/repo.rs @@ -2,7 +2,7 @@ use jayjay_core as core; use jayjay_core::{ AnnotationLine, BookmarkInfo, ChecksStatus, CliStatus, FetchResult, FileTreeEntry, GitSubmoduleStatus, JjCommandResult, PrInfo, PrState, RemoteBookmarkTarget, RemoteSyncStatus, - RevsetPreset, WorkspaceInfo, + RevsetPreset, ShortId, WorkspaceInfo, WorkspacePresence, }; #[uniffi::remote(Record)] @@ -61,7 +61,21 @@ pub struct CliStatus { pub struct WorkspaceInfo { pub name: String, pub path: String, + pub is_path_resolved: bool, pub is_current: bool, + pub operation_id: String, + pub change_id: ShortId, + pub description: String, + pub timestamp: i64, + pub has_conflict: bool, + pub files_changed: u32, +} + +#[uniffi::remote(Enum)] +pub enum WorkspacePresence { + Exists, + Gone, + Unknown, } #[uniffi::remote(Record)] diff --git a/docs/guide.html b/docs/guide.html index 6cc76764..bcc1a8fd 100644 --- a/docs/guide.html +++ b/docs/guide.html @@ -92,6 +92,7 @@

Open a Repository #

  • Open a repository with Cmd+O, the app menu, the Dock recent-repositories menu, or the CLI launcher: jayjay /path/to/repo.
  • Open the current terminal directory with jayjay . after installing the bundled CLI launcher.
  • The Repository List separates persistent Pinned repositories from shell-local Recent Repositories. Use the pin button to keep a repository in the list; clearing Recent does not remove pins. Pins are shared between the SwiftUI and GPUI shells.
  • +
  • Recent entries that are jj workspaces of a listed repository nest under that repository's row instead of appearing as unrelated repos; pinning a workspace keeps it top-level.
  • Click the repository title in an open window to switch repositories. The menu activates an already-open repository without duplicating its window, opens a closed pinned repository in a new window, or returns to the full Repository List.
  • Closing the last repository window returns to the Repository List automatically.
  • If you open a folder that is not a jj repository, JayJay shows an onboarding view with a jj git init path.
  • @@ -238,8 +239,9 @@

    Bookmarks, Git & Pull Requests #

    Workspaces #

    • Create a new jj workspace from the Repository menu (New Workspace…) or the command palette: pick a destination folder and name, and JayJay runs jj workspace add so you get a second working copy backed by the same repository.
    • -
    • Switch between workspaces from the command palette — each one opens in its own window pointed at that working copy.
    • -
    • The sidebar lists the repository's workspaces and marks the current one; forget a stale workspace from there once its working copy is gone.
    • +
    • Switch between workspaces from the repository title dropdown's Workspaces section (shown when the repository has more than one): each row lists that workspace's @ change, description, changed-file count, recency, and conflict state, and clicking focuses its window or opens one. The command palette offers the same switch.
    • +
    • The dropdown has a filter field and a New Workspace button; a row's context menu can copy a workspace's path, forget it, or delete its directory from disk.
    • +
    • The sidebar lists the repository's workspaces and marks the current one.
    diff --git a/docs/llms.txt b/docs/llms.txt index 51794d1b..bf9ea9f5 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -24,6 +24,7 @@ Beta updates are opt-in via the Beta update channel in Settings. Anonymous build - Repository list: persistent pinned repositories shared between SwiftUI and GPUI, shell-local recent history, and a repository title menu that activates open windows or opens closed pins without duplication - DAG graph: lane-based fork/merge rendering, bookmark/conflict/divergent markers, sibling-workspace name@ chips, revset filtering +- Workspaces: create and forget jj workspaces, plus an instant native repository-title dropdown listing each workspace's @ description, changed files, recency, and conflicts — click to switch windows; recent workspaces nest under their repository in the repository list - Diffs: unified and side-by-side, expandable collapsed context, tree-sitter syntax highlighting, word-level changes, interdiff (compare any two revisions), rich previews for Markdown, SVG, notebooks, CSV/TSV tables, SARIF reports, and binary property lists - Diff edit: extract files, hunks, or individual lines from the working copy or another mutable change; per-file +/− line stats, collapsible file cards with auto-collapse for large diffs; review files and split with one click - Review notes: line-anchored notes on the working-copy diff, readable and resolvable via the `jayjay` CLI for AI-agent feedback loops diff --git a/shell/gpui/src/repo/view_model/loaders/mod.rs b/shell/gpui/src/repo/view_model/loaders/mod.rs index a8f6cfb4..7cead255 100644 --- a/shell/gpui/src/repo/view_model/loaders/mod.rs +++ b/shell/gpui/src/repo/view_model/loaders/mod.rs @@ -160,7 +160,9 @@ impl RepoViewModel { self.can_load_more = self.revset_is_default() && entries.len() >= self.revset_depth as usize; self.graph.bookmarks = Arc::new(data.bookmarks); - self.graph.workspaces = Arc::new(data.workspaces); + if let Some(workspaces) = data.workspaces { + self.graph.workspaces = Arc::new(workspaces); + } self.pr_host_name = data.pr_host_name.map(SharedString::from); self.working_copy_stats = data.working_copy_stats; self.current_operation_description = data.current_operation_description; @@ -244,7 +246,7 @@ impl RepoViewModel { struct RefreshData { entries: Vec, bookmarks: Vec, - workspaces: Vec, + workspaces: Option>, pr_host_name: Option, working_copy_stats: Option, current_operation_description: String, @@ -254,7 +256,7 @@ fn refresh_graph_blocking(repo: &Repo, revset: &str) -> CoreResult repo.refresh_working_copy()?; let entries = repo.log_graph(revset)?; let bookmarks = repo.list_bookmarks().unwrap_or_default(); - let workspaces = repo.workspace_list().unwrap_or_default(); + let workspaces = repo.workspace_list().ok(); let pr_host_name = repo.pr_host_name(); let working_copy_stats = repo.diff_stats("@").ok(); let current_operation_description = repo.current_operation_description(); diff --git a/shell/gpui/src/repo/view_model/mutations.rs b/shell/gpui/src/repo/view_model/mutations.rs index a6fd4104..e1262517 100644 --- a/shell/gpui/src/repo/view_model/mutations.rs +++ b/shell/gpui/src/repo/view_model/mutations.rs @@ -252,12 +252,31 @@ impl RepoViewModel { pub(crate) fn workspace_forget( &mut self, name: String, + expected_root: String, + expected_operation: String, cx: &mut Context, - ) -> gpui::Task> { - self.repo_write_task( + ) -> gpui::Task>> { + self.repo_result_task( cx, - move |repo| repo.workspace_forget(&name), - |vm, cx| vm.refresh(false, cx), + move |repo| { + let operation = + repo.workspace_removal_guard(&name, &expected_root, &expected_operation)?; + repo.workspace_forget(&name, &expected_root, &operation) + }, + |vm, _, cx| vm.refresh(false, cx), + ) + } + + pub(crate) fn workspace_forget_unresolved( + &mut self, + name: String, + expected_operation: String, + cx: &mut Context, + ) -> gpui::Task>> { + self.repo_result_task( + cx, + move |repo| repo.workspace_forget_unresolved(&name, &expected_operation), + |vm, _, cx| vm.refresh(false, cx), ) } diff --git a/shell/gpui/src/repo/window/menu.rs b/shell/gpui/src/repo/window/menu.rs index c3b02f88..34b619e6 100644 --- a/shell/gpui/src/repo/window/menu.rs +++ b/shell/gpui/src/repo/window/menu.rs @@ -177,8 +177,20 @@ impl RepoWindow { }) .detach(); } - ContextAction::ForgetWorkspace(name) => { - self.forget_workspace(name.to_string(), cx); + ContextAction::ForgetWorkspace { + name, + path, + operation_id, + } => { + self.forget_workspace( + name.to_string(), + path.to_string(), + operation_id.to_string(), + cx, + ); + } + ContextAction::ForgetUnresolvedWorkspace { name, operation_id } => { + self.forget_unresolved_workspace(name.to_string(), operation_id.to_string(), cx); } ContextAction::CreateWorkspace => { self.open_create_workspace(cx); @@ -355,16 +367,35 @@ fn workspace_menu_items(workspaces: &[WorkspaceInfo]) -> Vec { )); continue; } - items.push(ContextMenuItem::new( - format!("Open {}", ws.name), - glyph::COLUMNS, - ContextAction::OpenWorkspaceAt(ws.path.clone().into()), - )); + if ws.is_path_resolved { + items.push(ContextMenuItem::new( + format!("Open {}", ws.name), + glyph::COLUMNS, + ContextAction::OpenWorkspaceAt(ws.path.clone().into()), + )); + } else { + items.push(ContextMenuItem::new( + format!("{} (path unavailable)", ws.name), + glyph::WARNING, + ContextAction::Noop, + )); + } if ws.name != "default" { items.push(ContextMenuItem::new( format!("Forget {}", ws.name), glyph::X_CIRCLE, - ContextAction::ForgetWorkspace(ws.name.clone().into()), + if ws.is_path_resolved { + ContextAction::ForgetWorkspace { + name: ws.name.clone().into(), + path: ws.path.clone().into(), + operation_id: ws.operation_id.clone().into(), + } + } else { + ContextAction::ForgetUnresolvedWorkspace { + name: ws.name.clone().into(), + operation_id: ws.operation_id.clone().into(), + } + }, )); } } @@ -381,21 +412,28 @@ mod tests { use super::workspace_menu_items; use crate::app::config::{AppConfig, AppConfigStore}; use crate::ui::context_menu::ContextAction; - use jayjay_core::WorkspaceInfo; + use jayjay_core::{ShortId, WorkspaceInfo}; + + fn workspace(name: &str, path: &str, is_current: bool) -> WorkspaceInfo { + WorkspaceInfo { + name: name.to_owned(), + path: path.to_owned(), + is_path_resolved: true, + is_current, + operation_id: "operation".to_owned(), + change_id: ShortId::new("zzzzzzzz".to_owned(), 2), + description: String::new(), + timestamp: 0, + has_conflict: false, + files_changed: 0, + } + } #[test] fn workspace_menu_opens_and_forgets_non_default_workspaces() { let items = workspace_menu_items(&[ - WorkspaceInfo { - name: "default".to_owned(), - path: "/repo".to_owned(), - is_current: true, - }, - WorkspaceInfo { - name: "feature".to_owned(), - path: "/repo-feature".to_owned(), - is_current: false, - }, + workspace("default", "/repo", true), + workspace("feature", "/repo-feature", false), ]); let labels: Vec<_> = items.iter().map(|item| item.label.as_ref()).collect(); @@ -415,11 +453,41 @@ mod tests { )); assert!(matches!( &items[2].action, - ContextAction::ForgetWorkspace(name) if name.as_ref() == "feature" + ContextAction::ForgetWorkspace { + name, + path, + operation_id, + } if name.as_ref() == "feature" + && path.as_ref() == "/repo-feature" + && operation_id.as_ref() == "operation" )); assert!(matches!(&items[3].action, ContextAction::CreateWorkspace)); } + #[test] + fn unresolved_workspace_menu_disables_open_and_keeps_forget_recovery() { + let mut feature = workspace("feature", "/missing-feature", false); + feature.is_path_resolved = false; + let items = workspace_menu_items(&[workspace("default", "/repo", true), feature]); + + let labels: Vec<_> = items.iter().map(|item| item.label.as_ref()).collect(); + assert_eq!( + labels, + vec![ + "default", + "feature (path unavailable)", + "Forget feature", + "New Workspace…" + ] + ); + assert!(matches!(items[1].action, ContextAction::Noop)); + assert!(matches!( + &items[2].action, + ContextAction::ForgetUnresolvedWorkspace { name, operation_id } + if name.as_ref() == "feature" && operation_id.as_ref() == "operation" + )); + } + #[gpui::test] fn file_menu_uses_configured_editor_name(cx: &mut gpui::TestAppContext) { cx.update(|cx| { diff --git a/shell/gpui/src/repo/window/sync.rs b/shell/gpui/src/repo/window/sync.rs index cd6c755d..ab97ef65 100644 --- a/shell/gpui/src/repo/window/sync.rs +++ b/shell/gpui/src/repo/window/sync.rs @@ -46,12 +46,42 @@ impl RepoWindow { }); } - pub(crate) fn forget_workspace(&mut self, name: String, cx: &mut Context) { - let task = self - .vm - .update(cx, |vm, cx| vm.workspace_forget(name.clone(), cx)); - Self::spawn_ok(cx, task, move |view, _, cx| { - view.show_toast(format!("Forgot workspace {name}"), cx); + pub(crate) fn forget_workspace( + &mut self, + name: String, + path: String, + operation_id: String, + cx: &mut Context, + ) { + let task = self.vm.update(cx, |vm, cx| { + vm.workspace_forget(name.clone(), path, operation_id, cx) + }); + Self::spawn_ok(cx, task, move |view, warning, cx| { + view.show_toast( + warning + .clone() + .unwrap_or_else(|| format!("Forgot workspace {name}")), + cx, + ); + }); + } + + pub(crate) fn forget_unresolved_workspace( + &mut self, + name: String, + operation_id: String, + cx: &mut Context, + ) { + let task = self.vm.update(cx, |vm, cx| { + vm.workspace_forget_unresolved(name.clone(), operation_id, cx) + }); + Self::spawn_ok(cx, task, move |view, warning, cx| { + view.show_toast( + warning + .clone() + .unwrap_or_else(|| format!("Forgot workspace {name}")), + cx, + ); }); } diff --git a/shell/gpui/src/ui/context_menu.rs b/shell/gpui/src/ui/context_menu.rs index b0ec09aa..168907bb 100644 --- a/shell/gpui/src/ui/context_menu.rs +++ b/shell/gpui/src/ui/context_menu.rs @@ -43,7 +43,15 @@ pub enum ContextAction { #[allow(unused)] OpenInTerminal, OpenWorkspaceAt(SharedString), - ForgetWorkspace(SharedString), + ForgetWorkspace { + name: SharedString, + path: SharedString, + operation_id: SharedString, + }, + ForgetUnresolvedWorkspace { + name: SharedString, + operation_id: SharedString, + }, CreateWorkspace, OpenDiffEdit, AbandonSelectedLines(Arc), diff --git a/shell/mac/Resources/JayJayHelpBook/Contents/Resources/English.lproj/topics/navigate.html b/shell/mac/Resources/JayJayHelpBook/Contents/Resources/English.lproj/topics/navigate.html index 2d071394..4076d0d5 100644 --- a/shell/mac/Resources/JayJayHelpBook/Contents/Resources/English.lproj/topics/navigate.html +++ b/shell/mac/Resources/JayJayHelpBook/Contents/Resources/English.lproj/topics/navigate.html @@ -38,6 +38,7 @@

    Navigate history

  • Drag a change row to preview and confirm a rebase.
  • Drag a bookmark chip to move the bookmark, or drag the working-copy @ chip to edit another change. Dropping a resolved bookmark on the same change does nothing; dropping a conflicted chip on one of its rows resolves it to that commit.
  • Rows show name@ chips for other workspaces' working copies, so sibling checkouts are identifiable in the graph.
  • +
  • With more than one workspace, the repository title dropdown lists each one with its @ description, changed files, and recency — click a row to switch windows.
  • JayJay change graph with lanes, bookmarks, and selected change diff --git a/shell/mac/Resources/JayJayHelpBook/Contents/Resources/English.lproj/topics/open.html b/shell/mac/Resources/JayJayHelpBook/Contents/Resources/English.lproj/topics/open.html index 8b897f10..6ee9abc1 100644 --- a/shell/mac/Resources/JayJayHelpBook/Contents/Resources/English.lproj/topics/open.html +++ b/shell/mac/Resources/JayJayHelpBook/Contents/Resources/English.lproj/topics/open.html @@ -35,6 +35,7 @@

    Open a repository

    • After installing the bundled CLI, run jayjay /path/to/repo or jayjay . from a terminal.
    • The Repository List separates persistent Pinned repositories from Recent Repositories. Use the pin button to keep a repository in the list; clearing Recent does not remove pins.
    • +
    • Recent entries that are jj workspaces of a listed repository nest under that repository's row; pinning a workspace keeps it top-level.
    • Click the repository title in an open window to activate another open repository, open a closed pinned repository in a new window, or return to the full Repository List.
    • Closing the last repository window returns to the Repository List automatically.
    • Recent repositories also appear in the Dock menu. Pinned repositories are shared with JayJay's GPUI shell, while each shell keeps its own recent history.
    • diff --git a/shell/mac/Sources/JayJay/App/Window/RepoWindowManager.swift b/shell/mac/Sources/JayJay/App/Window/RepoWindowManager.swift index df9e8046..c513e879 100644 --- a/shell/mac/Sources/JayJay/App/Window/RepoWindowManager.swift +++ b/shell/mac/Sources/JayJay/App/Window/RepoWindowManager.swift @@ -5,11 +5,32 @@ import SwiftUI @MainActor @Observable final class RepoWindowManager { + private struct WeakRepoViewModel { + weak var value: RepoViewModel? + } + + private struct OpenedRepo: Sendable { + let repo: JayJayRepo + let workingCopyIsLarge: Bool + let configWarning: String? + } + + private enum RepoOpenResult: Sendable { + case success(OpenedRepo) + case failure(String) + } + private(set) var openRepoPaths: [String] = [] private let settings: AppSettings private var openRepoAction: ((String) -> Void)? private var showRepoListAction: ((Bool) -> Void)? private var isRepoListRequested = false + /// Weakly held per-path view models, so destructive flows can quiesce every window's repo tasks before its checkout is moved or deleted. + private var repoViewModels: [String: [WeakRepoViewModel]] = [:] + /// Repository opens begin before a view model exists, so removal tracks and awaits them separately. + private var repoOpenTasks: [String: [UUID: Task]] = [:] + /// Counts active destructive flows per repository, so one completion cannot release another flow's open barrier. + private var removalCountsByRepoPath: [String: Int] = [:] init(settings: AppSettings) { self.settings = settings @@ -98,6 +119,116 @@ final class RepoWindowManager { return normalizedPath != standardizedPath && activateRepoWindow(matching: normalizedPath) } + /// Close every window showing `path`. Callers forgetting or deleting a workspace must close its windows first, or a window can keep snapshotting a working copy that no longer exists. + func closeRepoWindow(at path: String) { + let normalizedPath = normalizedRepositoryPath(path: path) + NSApp.windows.filter { + $0.representedURL?.standardizedFileURL.path == normalizedPath + }.forEach { $0.close() } + refreshOpenRepoPaths() + } + + private func registerRepoViewModel(_ viewModel: RepoViewModel) { + repoViewModels = repoViewModels.compactMapValues { models in + let liveModels = models.filter { $0.value != nil } + return liveModels.isEmpty ? nil : liveModels + } + let path = normalizedRepositoryPath(path: viewModel.repoPath) + repoViewModels[path, default: []].append(WeakRepoViewModel(value: viewModel)) + } + + func loadRepoViewModel( + at path: String, + includeSubmoduleStatuses: Bool + ) async -> (viewModel: RepoViewModel?, error: String?) { + let normalizedPath = normalizedRepositoryPath(path: path) + guard !isRemovingRepo(at: normalizedPath) else { return (nil, nil) } + let taskId = UUID() + let task = Task.detached { + do { + let repo = try JayJayRepo.open(path: path) + return RepoOpenResult.success( + OpenedRepo( + repo: repo, + workingCopyIsLarge: repo.workingCopyIsLarge(), + configWarning: repo.checkUserConfig() + ) + ) + } catch { + return RepoOpenResult.failure(error.friendlyDescription) + } + } + repoOpenTasks[normalizedPath, default: [:]][taskId] = task + let result = await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + guard repoOpenTasks[normalizedPath]?[taskId] != nil else { return (nil, nil) } + repoOpenTasks[normalizedPath]?[taskId] = nil + if repoOpenTasks[normalizedPath]?.isEmpty == true { + repoOpenTasks[normalizedPath] = nil + } + guard !Task.isCancelled, !isRemovingRepo(at: normalizedPath) else { + return (nil, nil) + } + switch result { + case let .success(opened): + let viewModel = RepoViewModel( + path: path, + repo: opened.repo, + workingCopyIsLarge: opened.workingCopyIsLarge, + configWarning: opened.configWarning, + includeSubmoduleStatuses: includeSubmoduleStatuses + ) + registerRepoViewModel(viewModel) + return (viewModel, nil) + case let .failure(error): + return (nil, error) + } + } + + /// Quiesces the window's repo tasks and only then closes it, so a caller about to forget, move, or delete the checkout cannot race an in-flight snapshot or mutation. Quiescing must precede the close: closing releases the view model, after which its tasks can no longer be awaited. + func closeRepoWindowForWorkspaceRemoval(at path: String) async { + let normalizedPath = normalizedRepositoryPath(path: path) + removalCountsByRepoPath[normalizedPath, default: 0] += 1 + if let openTasks = repoOpenTasks[normalizedPath] { + for task in openTasks.values { + task.cancel() + } + for task in openTasks.values { + _ = await task.value + } + repoOpenTasks[normalizedPath] = nil + } + let viewModels = repoViewModels[normalizedPath]?.compactMap(\.value) ?? [] + for viewModel in viewModels { + await viewModel.prepareForRemoval() + } + repoViewModels[normalizedPath] = nil + closeRepoWindow(at: path) + } + + /// A workspace forgotten outside this window still needs the same shutdown barrier, but there is no local removal flow to release the open exclusion afterward. + func closeRepoWindowAfterWorkspaceVanished(at path: String) async { + await closeRepoWindowForWorkspaceRemoval(at: path) + finishWorkspaceRemoval(at: path) + } + + func finishWorkspaceRemoval(at path: String) { + let normalizedPath = normalizedRepositoryPath(path: path) + guard let count = removalCountsByRepoPath[normalizedPath] else { return } + if count == 1 { + removalCountsByRepoPath[normalizedPath] = nil + } else { + removalCountsByRepoPath[normalizedPath] = count - 1 + } + } + + private func isRemovingRepo(at normalizedPath: String) -> Bool { + removalCountsByRepoPath[normalizedPath] != nil + } + private func activateRepoWindow(matching path: String) -> Bool { guard let window = NSApp.windows.first(where: { $0.representedURL?.standardizedFileURL.path == path @@ -115,6 +246,7 @@ final class RepoWindowManager { func openRepo(_ path: String) { let normalizedPath = normalizedRepositoryPath(path: path) + guard !isRemovingRepo(at: normalizedPath) else { return } settings.recordOpenedRepo(normalizedPath) if activateRepoWindow(matching: normalizedPath) { diff --git a/shell/mac/Sources/JayJay/Onboarding/RepoListGrouping.swift b/shell/mac/Sources/JayJay/Onboarding/RepoListGrouping.swift new file mode 100644 index 00000000..71346834 --- /dev/null +++ b/shell/mac/Sources/JayJay/Onboarding/RepoListGrouping.swift @@ -0,0 +1,81 @@ +import Foundation +import JayJayCore + +struct RepoGroup: Identifiable { + let path: String + let workspaces: [String] + var id: String { + path + } +} + +/// A list entry's filesystem identity: where its path really points and, for secondary jj workspaces, the primary repo root, both canonical so entries match across symlinks. +struct RepoPathResolution: Equatable { + let canonicalPath: String + let primaryRoot: String? +} + +/// Nests recent entries that are secondary jj workspaces under their primary repo's row. Pinned entries always stay top-level, so pinning a workspace promotes it out of its group. Grouping itself is pure; the filesystem lookups it depends on arrive as `resolutions`, so building the list never blocks on a slow volume. +enum RepoListGrouping { + static func groups( + pinned: [String], + recents: [String], + resolutions: [String: RepoPathResolution] + ) -> (pinned: [RepoGroup], recent: [RepoGroup]) { + /// Paths without a resolution fall back to themselves, so entries render flat until their lookups complete. + func canonical(_ path: String) -> String { + resolutions[path]?.canonicalPath ?? path + } + + var ownerByCanonical: [String: String] = [:] + for path in recents + pinned { + ownerByCanonical[canonical(path)] = path + } + + var workspacesByOwner: [String: [String]] = [:] + var nested: Set = [] + for path in recents { + guard let root = resolutions[path]?.primaryRoot, + root != canonical(path), + let owner = ownerByCanonical[root], + owner != path + else { continue } + workspacesByOwner[owner, default: []].append(path) + nested.insert(path) + } + + func group(_ path: String) -> RepoGroup { + let workspaces = (workspacesByOwner[path] ?? []).sorted { + displayName($0).localizedStandardCompare(displayName($1)) == .orderedAscending + } + return RepoGroup(path: path, workspaces: workspaces) + } + + return ( + pinned.map(group), + recents.filter { !nested.contains($0) }.map(group) + ) + } + + /// Primary-root and symlink lookups hit the filesystem and can each block for a volume timeout on an unreachable path, so they run off the main actor and callers publish the results back into view state. + static func resolve(paths: [String]) async -> [String: RepoPathResolution] { + await Task.detached { + var resolutions: [String: RepoPathResolution] = [:] + for path in paths { + resolutions[path] = RepoPathResolution( + canonicalPath: canonical(path), + primaryRoot: workspacePrimaryRoot(path: path).map(canonical) + ) + } + return resolutions + }.value + } + + private static func canonical(_ path: String) -> String { + URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path + } + + private static func displayName(_ path: String) -> String { + URL(fileURLWithPath: path).lastPathComponent + } +} diff --git a/shell/mac/Sources/JayJay/Onboarding/WelcomeView.swift b/shell/mac/Sources/JayJay/Onboarding/WelcomeView.swift index 275ba6e7..29ee76cd 100644 --- a/shell/mac/Sources/JayJay/Onboarding/WelcomeView.swift +++ b/shell/mac/Sources/JayJay/Onboarding/WelcomeView.swift @@ -7,6 +7,14 @@ struct WelcomeView: View { @Environment(AppSettings.self) private var settings @Environment(RepositoryStore.self) private var repositoryStore + /// Cached per-path lookups, resolved off the main actor so entries on a slow volume render flat instead of stalling the list. Re-resolved on app activation: a path can be replaced externally while the list stays open. + @State private var resolutions: [String: RepoPathResolution] = [:] + @State private var resolutionEpoch = 0 + + private struct ResolutionRequest: Equatable { + let epoch: Int + let paths: [String] + } var body: some View { let pinnedRepositories = repositoryStore.paths @@ -40,9 +48,20 @@ struct WelcomeView: View { .onAppear { repositoryStore.reload() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in repositoryStore.reload() + resolutionEpoch += 1 + } + .task(id: ResolutionRequest(epoch: resolutionEpoch, paths: pinnedRepositories + recentRepositories)) { + await resolveRepoPaths(pinnedRepositories + recentRepositories) } } + private func resolveRepoPaths(_ paths: [String]) async { + guard !paths.isEmpty else { return } + let resolved = await RepoListGrouping.resolve(paths: paths) + guard !Task.isCancelled, resolved != resolutions else { return } + resolutions = resolved + } + private var header: some View { VStack(spacing: 12) { Image(nsImage: NSApplication.shared.applicationIconImage) @@ -71,20 +90,25 @@ struct WelcomeView: View { pinnedRepositories: [String], recentRepositories: [String] ) -> some View { - ScrollView { + let groups = RepoListGrouping.groups( + pinned: pinnedRepositories, + recents: recentRepositories, + resolutions: resolutions + ) + return ScrollView { VStack(alignment: .leading, spacing: 18) { - if !pinnedRepositories.isEmpty { + if !groups.pinned.isEmpty { repositorySection(title: "Pinned") { - ForEach(pinnedRepositories, id: \.self) { path in - repoRow(path: path, pinned: true) + ForEach(groups.pinned) { group in + repoGroupRows(group, pinned: true) } } } - if !recentRepositories.isEmpty { + if !groups.recent.isEmpty { repositorySection(title: "Recent Repositories", showsClear: true) { - ForEach(recentRepositories, id: \.self) { path in - repoRow(path: path, pinned: false) + ForEach(groups.recent) { group in + repoGroupRows(group, pinned: false) } } } @@ -95,6 +119,33 @@ struct WelcomeView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } + @ViewBuilder + private func repoGroupRows(_ group: RepoGroup, pinned: Bool) -> some View { + if group.workspaces.isEmpty { + repoRow(path: group.path, pinned: pinned) + } else { + groupedRepoCard(group, pinned: pinned) + } + } + + /// One card per repo with workspaces: the repo name as header, then the default workspace and every listed sibling as open targets. + private func groupedRepoCard(_ group: RepoGroup, pinned: Bool) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 8) { + Text(URL(fileURLWithPath: group.path).repositoryDisplayName) + .jayjayFont(12, weight: .semibold) + Spacer() + pinAndRemoveButtons(path: group.path, pinned: pinned) + } + workspaceEntryRow(name: "default", path: group.path, nested: false) + ForEach(group.workspaces, id: \.self) { path in + workspaceEntryRow(name: URL(fileURLWithPath: path).lastPathComponent, path: path, nested: true) + } + } + .padding(8) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + private func repositorySection( title: String, showsClear: Bool = false, @@ -134,23 +185,60 @@ struct WelcomeView: View { } .buttonStyle(.plain) - Button { repositoryStore.setPinned(!pinned, path: path) } label: { - Image(systemName: pinned ? "pin.slash.fill" : "pin.fill") - .foregroundStyle(.tertiary) + pinAndRemoveButtons(path: path, pinned: pinned) + } + .padding(8) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + + @ViewBuilder + private func pinAndRemoveButtons(path: String, pinned: Bool) -> some View { + Button { repositoryStore.setPinned(!pinned, path: path) } label: { + Image(systemName: pinned ? "pin.slash.fill" : "pin.fill") + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + .help(pinned ? "Unpin Repository" : "Pin Repository") + + if !pinned { + removeRecentButton(path: path) + } + } + + private func removeRecentButton(path: String) -> some View { + Button { settings.removeRecentRepo(path) } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + .help("Remove from Recent") + } + + /// The card's own row (`nested: false`) is the primary repo, whose pin and remove live in the card header; nested rows are always unpinned recents, so pinning one promotes it top-level. + private func workspaceEntryRow(name: String, path: String, nested: Bool) -> some View { + HStack(spacing: 8) { + Button { onOpen(path) } label: { + HStack(spacing: 6) { + Image(systemName: "folder") + .jayjayFont(10) + .foregroundStyle(.secondary) + Text(name) + .jayjayFont(12, weight: .medium) + Text(path) + .jayjayFont(10) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } .buttonStyle(.plain) - .help(pinned ? "Unpin Repository" : "Pin Repository") - if !pinned { - Button { settings.removeRecentRepo(path) } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.tertiary) - } - .buttonStyle(.plain) - .help("Remove from Recent") + if nested { + pinAndRemoveButtons(path: path, pinned: false) } } - .padding(8) - .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .padding(.leading, 6) } } diff --git a/shell/mac/Sources/JayJay/Repo/Bookmarks/BookmarkPicker.swift b/shell/mac/Sources/JayJay/Repo/Bookmarks/BookmarkPicker.swift index 78dcffa9..3f880932 100644 --- a/shell/mac/Sources/JayJay/Repo/Bookmarks/BookmarkPicker.swift +++ b/shell/mac/Sources/JayJay/Repo/Bookmarks/BookmarkPicker.swift @@ -1,6 +1,7 @@ import JayJayCore import SwiftUI +/// Toolbar bookmark switcher backed by the shared PickerPanel: filterable Tracked and Local Only sections, click filters the graph to that bookmark, right-click offers the bookmark actions. struct BookmarkPicker: View { let bookmarks: [BookmarkInfo] let actions: (any BookmarkActions)? @@ -34,35 +35,15 @@ struct BookmarkPicker: View { .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } } + @State private var anchor = PickerAnchor() + @State private var panel = PickerPanel() @State private var showingCreate = false @State private var newBookmarkName = "" @State private var renamingBookmark: String? @State private var renameNewName = "" var body: some View { - Menu { - if !trackedBookmarks.isEmpty { - Section("Tracked") { - ForEach(trackedBookmarks, id: \.name) { bookmark in - bookmarkMenu(bookmark) - } - } - } - - if !localOnlyBookmarks.isEmpty { - Section("Local Only") { - ForEach(localOnlyBookmarks, id: \.name) { bookmark in - bookmarkMenu(bookmark) - } - } - } - - Button("New Bookmark...") { - newBookmarkName = "" - showingCreate = true - } - - } label: { + Button(action: togglePanel) { HStack(spacing: 4) { Image(systemName: "arrow.triangle.branch") .imageScale(.small) @@ -70,143 +51,222 @@ struct BookmarkPicker: View { .jayjayFont(12, weight: .medium) .lineLimit(1) } + .contentShape(Rectangle()) } - .menuStyle(.borderlessButton) + .buttonStyle(.plain) + .fixedSize() + .background(PickerAnchorView(anchor: anchor)) + .help("Filter the graph by a bookmark, or manage bookmarks") .popover(isPresented: $showingCreate) { - VStack(alignment: .leading, spacing: 10) { - Text("New Bookmark") - .jayjayFont(13, weight: .semibold) - TextField("Bookmark name", text: $newBookmarkName) - .textFieldStyle(.roundedBorder) - .jayjayFont(13, design: .monospaced) - .frame(width: 220) - .onSubmit { submitCreate() } - HStack { - Spacer() - Button("Cancel") { showingCreate = false } - .keyboardShortcut(.cancelAction) - Button("Create") { submitCreate() } - .keyboardShortcut(.defaultAction) - .disabled(newBookmarkName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - .padding(14) + createPopover } .popover(isPresented: .init( get: { renamingBookmark != nil }, - set: { if !$0 { renamingBookmark = nil } } + set: { + if !$0 { + renamingBookmark = nil + } + } )) { - VStack(alignment: .leading, spacing: 10) { - Text("Rename Bookmark") - .jayjayFont(13, weight: .semibold) - Text("From: \(renamingBookmark ?? "")") - .jayjayFont(11, design: .monospaced) - .foregroundStyle(.secondary) - TextField("New name", text: $renameNewName) - .textFieldStyle(.roundedBorder) - .jayjayFont(13, design: .monospaced) - .frame(width: 220) - .onSubmit { submitRename() } - HStack { - Spacer() - Button("Cancel") { renamingBookmark = nil } - .keyboardShortcut(.cancelAction) - Button("Rename") { submitRename() } - .keyboardShortcut(.defaultAction) - .disabled({ - let n = renameNewName.trimmingCharacters(in: .whitespacesAndNewlines) - return n.isEmpty || n == renamingBookmark - }()) + renamePopover + } + } + + private func togglePanel() { + guard !panel.isVisible, !panel.wasJustDismissed else { + panel.dismiss() + return + } + guard let anchorView = anchor.view else { return } + var sections: [PickerSection] = [] + if !trackedBookmarks.isEmpty { + sections.append(PickerSection(id: "tracked", title: "Tracked", rows: trackedBookmarks.map(bookmarkRow))) + } + if !localOnlyBookmarks.isEmpty { + sections.append(PickerSection(id: "local", title: "Local Only", rows: localOnlyBookmarks.map(bookmarkRow))) + } + let root = PickerPanelRoot( + placeholder: "Filter", + actionLabel: "New Bookmark", + onAction: { + newBookmarkName = "" + showingCreate = true + }, + sections: sections, + emptyText: "No bookmarks yet", + onDismiss: { [weak panel] in panel?.dismiss() } + ) + panel.show(under: anchorView, size: PickerPanelRoot.idealSize(sections: sections, width: 280), content: root) + } + + private func bookmarkRow(_ bookmark: BookmarkInfo) -> PickerRow { + let caption = remoteCaption(bookmark) + return PickerRow( + id: "bookmark-\(bookmark.name)", + searchText: ([bookmark.name] + bookmark.trackedRemotes + bookmark.availableRemotes).joined(separator: " "), + height: caption == nil ? 28 : 38, + action: { onSelect(bookmark.name) }, + content: { _ in + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + Text(bookmark.name) + .font(.system(size: 13)) + .lineLimit(1) + Image(systemName: bookmark.isTrackingRemote ? "cloud.fill" : "cloud.slash") + .imageScale(.small) + .foregroundStyle(bookmark.isTrackingRemote ? .secondary : .tertiary) + Spacer(minLength: 8) + } + if let caption { + Text(caption) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(1) + } } + .padding(.horizontal, 14) } - .padding(14) + ) + .withContextMenu { bookmarkContextMenu(bookmark) } + } + + private func remoteCaption(_ bookmark: BookmarkInfo) -> String? { + if !bookmark.trackedRemotes.isEmpty { + return bookmark.trackedRemotes.map { "@\($0)" }.joined(separator: ", ") } + if !bookmark.availableRemotes.isEmpty { + return "Remote available: \(bookmark.availableRemotes.map { "@\($0)" }.joined(separator: ", "))" + } + return nil } @ViewBuilder - private func bookmarkMenu(_ bookmark: BookmarkInfo) -> some View { + private func bookmarkContextMenu(_ bookmark: BookmarkInfo) -> some View { let untrackedRemotes = bookmark.availableRemotes.filter { !bookmark.trackedRemotes.contains($0) } - Menu { - Button("Filter by this bookmark") { - onSelect(bookmark.name) - } - if bookmark.isTrackingRemote { - Button { - actions?.gitPullBookmark(name: bookmark.name) - } label: { - Label("Pull", systemImage: "arrow.down.circle") - } - } - Button { - actions?.gitPush(bookmark: bookmark.name) - } label: { - Label("Push", systemImage: "arrow.up.circle") - } - Button { - actions?.moveBookmarkForward(name: bookmark.name) - } label: { - Label("Move to @-", systemImage: "arrow.right.circle") - } + Button("Filter by this bookmark") { + panel.dismiss() + onSelect(bookmark.name) + } + if bookmark.isTrackingRemote { Button { - renameNewName = bookmark.name - renamingBookmark = bookmark.name + panel.dismiss() + actions?.gitPullBookmark(name: bookmark.name) } label: { - Label("Rename...", systemImage: "pencil") + Label("Pull", systemImage: "arrow.down.circle") } + } + Button { + panel.dismiss() + actions?.gitPush(bookmark: bookmark.name) + } label: { + Label("Push", systemImage: "arrow.up.circle") + } + Button { + panel.dismiss() + actions?.moveBookmarkForward(name: bookmark.name) + } label: { + Label("Move to @-", systemImage: "arrow.right.circle") + } + Button { + panel.dismiss() + renameNewName = bookmark.name + renamingBookmark = bookmark.name + } label: { + Label("Rename...", systemImage: "pencil") + } - if !bookmark.trackedRemotes.isEmpty { - Text("Tracking \(bookmark.trackedRemotes.joined(separator: ", "))") - } + if !bookmark.trackedRemotes.isEmpty { + Text("Tracking \(bookmark.trackedRemotes.joined(separator: ", "))") + } - if !untrackedRemotes.isEmpty { - Menu("Track Remote") { - ForEach(untrackedRemotes, id: \.self) { remote in - Button(remote) { - actions?.trackBookmark(name: bookmark.name, remote: remote) - } + if !untrackedRemotes.isEmpty { + Menu("Track Remote") { + ForEach(untrackedRemotes, id: \.self) { remote in + Button(remote) { + panel.dismiss() + actions?.trackBookmark(name: bookmark.name, remote: remote) } } - } else if bookmark.availableRemotes.isEmpty { - Text("No remote bookmark available") } + } else if bookmark.availableRemotes.isEmpty { + Text("No remote bookmark available") + } - Divider() - Button(role: .destructive) { - actions?.deleteBookmark(name: bookmark.name) - } label: { - Label("Delete", systemImage: "trash") - } + Divider() + Button(role: .destructive) { + panel.dismiss() + actions?.deleteBookmark(name: bookmark.name) } label: { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(bookmark.name) - Image(systemName: bookmark.isTrackingRemote ? "cloud.fill" : "cloud.slash") - .foregroundStyle(bookmark.isTrackingRemote ? .secondary : .tertiary) - } - if !bookmark.trackedRemotes.isEmpty { - Text(bookmark.trackedRemotes.map { "@\($0)" }.joined(separator: ", ")) - .font(.caption) - .foregroundStyle(.secondary) - } else if !bookmark.availableRemotes.isEmpty { - Text("Remote available: \(bookmark.availableRemotes.map { "@\($0)" }.joined(separator: ", "))") - .font(.caption) - .foregroundStyle(.secondary) - } + Label("Delete", systemImage: "trash") + } + } + + private var createPopover: some View { + VStack(alignment: .leading, spacing: 10) { + Text("New Bookmark") + .jayjayFont(13, weight: .semibold) + TextField("Bookmark name", text: $newBookmarkName) + .textFieldStyle(.roundedBorder) + .jayjayFont(13, design: .monospaced) + .frame(width: 220) + .onSubmit { submitCreate() } + HStack { + Spacer() + Button("Cancel") { showingCreate = false } + .keyboardShortcut(.cancelAction) + Button("Create") { submitCreate() } + .keyboardShortcut(.defaultAction) + .disabled(trimmedNewBookmarkName.isEmpty) } } + .padding(14) + } + + private var renamePopover: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Rename Bookmark") + .jayjayFont(13, weight: .semibold) + Text("From: \(renamingBookmark ?? "")") + .jayjayFont(11, design: .monospaced) + .foregroundStyle(.secondary) + TextField("New name", text: $renameNewName) + .textFieldStyle(.roundedBorder) + .jayjayFont(13, design: .monospaced) + .frame(width: 220) + .onSubmit { submitRename() } + HStack { + Spacer() + Button("Cancel") { renamingBookmark = nil } + .keyboardShortcut(.cancelAction) + Button("Rename") { submitRename() } + .keyboardShortcut(.defaultAction) + .disabled(!canSubmitRename) + } + } + .padding(14) + } + + private var trimmedNewBookmarkName: String { + newBookmarkName.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var trimmedRenameName: String { + renameNewName.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSubmitRename: Bool { + !trimmedRenameName.isEmpty && trimmedRenameName != renamingBookmark } private func submitCreate() { - let name = newBookmarkName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { return } - actions?.createBookmark(name: name, rev: "@") + guard !trimmedNewBookmarkName.isEmpty else { return } + actions?.createBookmark(name: trimmedNewBookmarkName, rev: "@") showingCreate = false } private func submitRename() { - let newName = renameNewName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !newName.isEmpty, let oldName = renamingBookmark, newName != oldName else { return } - actions?.renameBookmark(oldName: oldName, newName: newName) + guard canSubmitRename, let oldName = renamingBookmark else { return } + actions?.renameBookmark(oldName: oldName, newName: trimmedRenameName) renamingBookmark = nil } } diff --git a/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView+CommandPalette.swift b/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView+CommandPalette.swift index ebbaed6d..93114452 100644 --- a/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView+CommandPalette.swift +++ b/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView+CommandPalette.swift @@ -122,10 +122,10 @@ extension RepoContentView { items.append(CommandPaletteItem( title: "New Workspace", - icon: "square.on.square", + icon: "folder.badge.plus", category: "Workspace" ) { modal = .workspaceCreate }) - for workspace in viewModel.workspaceList() where !workspace.isCurrent { + for workspace in viewModel.workspaceList() where !workspace.isCurrent && workspace.isPathResolved { items.append(CommandPaletteItem( title: "Switch to \(workspace.name)", icon: "arrow.right.square", @@ -245,7 +245,9 @@ extension RepoContentView { commandPanel.show( items: items, - repoPath: viewModel.repoPath, + runJjCommand: { command in + try await viewModel.runJjCommand(command) + }, onJjCommandFinished: { result in guard result.exitCode == 0 else { return } viewModel.refresh() diff --git a/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView+Sheets.swift b/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView+Sheets.swift index 704f2378..b0952b86 100644 --- a/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView+Sheets.swift +++ b/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView+Sheets.swift @@ -40,6 +40,8 @@ extension RepoContentView { ) case .workspaceCreate: workspaceCreateSheet + case let .confirmWorkspaceDelete(name, path, operationId): + workspaceDeleteSheet(name: name, path: path, operationId: operationId) case .sponsorPrompt: SponsorPromptView( onDismiss: { self.modal = nil }, @@ -70,37 +72,20 @@ extension RepoContentView { } private func abandonSheet(rev: String) -> some View { - VStack(spacing: 16) { - Image(systemName: "trash.circle.fill") - .font(.system(size: 36)) - .foregroundStyle(.red) - Text("Abandon Change?") - .jayjayFont(16, weight: .semibold) - Text("This will remove the change and reparent its children.\nYou can undo this with jj op restore.") - .jayjayFont(13) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - Toggle("Don't ask again", isOn: Binding( + DestructiveConfirmSheet( + title: "Abandon Change?", + message: "This will remove the change and reparent its children.\nYou can undo this with jj op restore.", + confirmLabel: "Abandon", + dontAskAgain: Binding( get: { settings.skipAbandonConfirmation }, set: { settings.skipAbandonConfirmation = $0 } - )) - .jayjayFont(12) - - HStack(spacing: 12) { - Button("Cancel") { modal = nil } - .keyboardShortcut(.cancelAction) - Button("Abandon") { - viewModel.abandon(rev: rev) - modal = nil - } - .keyboardShortcut(.defaultAction) - .buttonStyle(.borderedProminent) - .tint(.red) + ), + onCancel: { modal = nil }, + onConfirm: { + viewModel.abandon(rev: rev) + modal = nil } - } - .padding(24) - .frame(width: 340) + ) } private func rebaseConfirmationSheet(request: DAGRebaseRequest) -> some View { @@ -195,6 +180,75 @@ extension RepoContentView { ) } + private func workspaceDeleteSheet(name: String, path: String, operationId: String) -> some View { + DestructiveConfirmSheet( + title: "Delete Workspace \(name)?", + message: "This closes its window, forgets the workspace, and deletes its directory from disk:\n\(path)", + confirmLabel: "Delete", + width: 400, + onCancel: { modal = nil }, + onConfirm: { + modal = nil + let settings = settings + let viewModel = viewModel + let windowManager = windowManager + Task { @MainActor in + // Wait out the target window's in-flight repo tasks before touching its checkout, or the move below could race a snapshot or mutation still running there. + await windowManager.closeRepoWindowForWorkspaceRemoval(at: path) + defer { windowManager.finishWorkspaceRemoval(at: path) } + // Capture the directory identity before core validation, then verify that exact object after the move so replacement at any point fails closed. + let quarantined: QuarantinedDirectory + let warning: String? + do { + let identity = try await Task.detached { + try QuarantinedDirectory.identity(path: path) + }.value + let operation = try await viewModel.workspaceRemovalGuard( + name: name, + expectedRoot: path, + expectedOperation: operationId + ) + quarantined = try await Task.detached { + try QuarantinedDirectory.capture( + path: path, + expectedIdentity: identity + ) + }.value + do { + warning = try await viewModel.workspaceForget( + name: name, + expectedRoot: path, + expectedOperation: operation + ) + } catch { + let forgetError = error + do { + try await Task.detached { try quarantined.restore() }.value + viewModel.present(error: forgetError) + } catch { + viewModel.error = "The forget failed and the workspace directory could not be moved back. It is preserved at:\n\(quarantined.quarantineURL.path)" + } + return + } + } catch { + viewModel.present(error: error) + return + } + settings.removeRecentRepo(path) + do { + try await Task.detached { try quarantined.delete() }.value + } catch { + viewModel.error = "The workspace was forgotten, but its quarantined directory could not be deleted. It is preserved at:\n\(quarantined.quarantineURL.path)" + return + } + if let warning { + viewModel.error = warning + } + } + } + ) + } + private var submoduleAttentionSheet: some View { SubmoduleAttentionSheet( repoPath: viewModel.repoPath, diff --git a/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView+StatusBar.swift b/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView+StatusBar.swift index d5921168..63f6913d 100644 --- a/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView+StatusBar.swift +++ b/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView+StatusBar.swift @@ -11,16 +11,6 @@ extension RepoContentView { private var statusBarLeadingItems: [StatusBarItem] { var items: [StatusBarItem] = [] - if viewModel.workspaces.count > 1, - let current = viewModel.workspaces.first(where: \.isCurrent) - { - items.append(.picker( - id: "workspace", - icon: "square.on.square", - label: current.name, - options: workspacePickerOptions - )) - } items.append(.text(id: "path", icon: "folder", text: viewModel.repoPath)) if let bookmark = activeBookmarkSyncItem { items.append(bookmark) @@ -128,34 +118,6 @@ extension RepoContentView { private func primaryRemoteTarget(_ bookmark: BookmarkInfo) -> RemoteBookmarkTarget? { bookmark.remoteTargets.first(where: { $0.remote == "origin" }) ?? bookmark.remoteTargets.first } - - private var workspacePickerOptions: [StatusBarPickerOption] { - viewModel.workspaces.map { ws in - if ws.isCurrent { - return StatusBarPickerOption(id: ws.name, label: ws.name, icon: "checkmark", disabled: true) - } - var children: [StatusBarPickerOption] = [ - StatusBarPickerOption(id: "\(ws.name)-open", label: "Open") { - windowManager.openRepo(ws.path) - } - ] - if ws.name != "default" { - children.append(StatusBarPickerOption(id: "\(ws.name)-forget", label: "Forget") { - viewModel.workspaceForget(name: ws.name) - settings.removeRecentRepo(ws.path) - }) - children.append(StatusBarPickerOption( - id: "\(ws.name)-delete", label: "Forget & Delete from Disk", - destructive: true - ) { - viewModel.workspaceForget(name: ws.name) - settings.removeRecentRepo(ws.path) - try? FileManager.default.removeItem(atPath: ws.path) - }) - } - return StatusBarPickerOption(id: ws.name, label: ws.name, children: children) - } - } } extension PrInfo { diff --git a/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView+Toolbar.swift b/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView+Toolbar.swift index 3d7a7e52..a71b1e41 100644 --- a/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView+Toolbar.swift +++ b/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView+Toolbar.swift @@ -56,18 +56,71 @@ extension RepoContentView { } } - @ToolbarContentBuilder private var repositoryTitle: some ToolbarContent { - if #available(macOS 26.0, *) { - ToolbarItem(placement: .navigation) { - RepoTitleMenu(repoPath: viewModel.repoPath) - } - .sharedBackgroundVisibility(.hidden) - } else { - ToolbarItem(placement: .navigation) { - RepoTitleMenu(repoPath: viewModel.repoPath) - } + ToolbarItem(placement: .navigation) { + RepoTitlePicker( + repoPath: viewModel.repoPath, + workspaces: viewModel.workspaces, + onOpenWorkspace: { workspace in + guard workspace.isPathResolved else { return } + windowManager.openRepo(workspace.path) + }, + onForget: { workspace in + let settings = settings + let viewModel = viewModel + let windowManager = windowManager + Task { @MainActor in + let hasRecordedPath = !workspace.path.isEmpty + if hasRecordedPath { + await windowManager.closeRepoWindowForWorkspaceRemoval(at: workspace.path) + } + defer { + if hasRecordedPath { + windowManager.finishWorkspaceRemoval(at: workspace.path) + } + } + do { + let warning: String? + if workspace.isPathResolved { + let operation = try await viewModel.workspaceRemovalGuard( + name: workspace.name, + expectedRoot: workspace.path, + expectedOperation: workspace.operationId + ) + warning = try await viewModel.workspaceForget( + name: workspace.name, + expectedRoot: workspace.path, + expectedOperation: operation + ) + } else { + warning = try await viewModel.workspaceForgetUnresolved( + name: workspace.name, + expectedOperation: workspace.operationId + ) + } + if hasRecordedPath { + settings.removeRecentRepo(workspace.path) + } + if let warning { + viewModel.error = warning + } + } catch { + viewModel.present(error: error) + } + } + }, + onForgetDelete: { workspace in + guard workspace.isPathResolved else { return } + modal = .confirmWorkspaceDelete( + name: workspace.name, + path: workspace.path, + operationId: workspace.operationId + ) + }, + onCreateWorkspace: { modal = .workspaceCreate } + ) } + .sharedBackgroundVisibility(.hidden) } } @@ -81,7 +134,13 @@ struct SidebarDivider: View { .fill(Color.primary.opacity(0.08)) .frame(width: 1) .contentShape(Rectangle().inset(by: -3)) - .onHover { if $0 { NSCursor.resizeLeftRight.push() } else { NSCursor.pop() } } + .onHover { + if $0 { + NSCursor.resizeLeftRight.push() + } else { + NSCursor.pop() + } + } .gesture( DragGesture(minimumDistance: 1) .onChanged { position = min(max(position + $0.translation.width, range.lowerBound), range.upperBound) } diff --git a/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView.swift b/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView.swift index fcd095be..1f437bee 100644 --- a/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView.swift +++ b/shell/mac/Sources/JayJay/Repo/ContentView/RepoContentView.swift @@ -56,6 +56,13 @@ struct RepoContentView: View { .onChange(of: viewModel.revset) { revsetDraft = viewModel.revset } + .onChange(of: viewModel.workspaceVanished) { _, vanished in + guard vanished else { return } + let repoPath = viewModel.repoPath + Task { @MainActor in + await windowManager.closeRepoWindowAfterWorkspaceVanished(at: repoPath) + } + } .toolbar { toolbarContent } .overlay { presentationOverlay } .animation(.easeOut(duration: 0.3), value: toast?.id) diff --git a/shell/mac/Sources/JayJay/Repo/DAG/DAGRow.swift b/shell/mac/Sources/JayJay/Repo/DAG/DAGRow.swift index bb481edd..2830e94e 100644 --- a/shell/mac/Sources/JayJay/Repo/DAG/DAGRow.swift +++ b/shell/mac/Sources/JayJay/Repo/DAG/DAGRow.swift @@ -49,7 +49,7 @@ struct DAGRow: View { HStack(spacing: 6) { CommitAvatar(email: change.author.email, size: 14) Text(change.author.name) - Text(relativeDate(change.author.timestampMillis)).foregroundStyle(.secondary) + Text(Date.relativeLabel(millis: change.author.timestampMillis)).foregroundStyle(.secondary) } .jayjayFont(10).lineLimit(1).truncationMode(.tail).foregroundStyle(.secondary) } @@ -94,18 +94,6 @@ struct DAGRow: View { } } - private static let relativeFormatter = RelativeDateTimeFormatter() - - /// Relative time the change last moved; the change id shown above is the stable identifier. - private func relativeDate(_ millis: Int64) -> String { - let date = Date(timeIntervalSince1970: Double(millis) / 1000) - let now = Date() - // Floor to whole minutes: a per-second count ("19s, 20s, …") on fresh changes is - // distracting, and a clock-skewed future timestamp then reads as "1 minute ago". - let reference = min(date, now.addingTimeInterval(-60)) - return Self.relativeFormatter.localizedString(for: reference, relativeTo: now) - } - private func dragTargetBubble(_ text: String) -> some View { HStack(spacing: 6) { Text(text) diff --git a/shell/mac/Sources/JayJay/Repo/RepoPresentation.swift b/shell/mac/Sources/JayJay/Repo/RepoPresentation.swift index 96aa6967..0173456d 100644 --- a/shell/mac/Sources/JayJay/Repo/RepoPresentation.swift +++ b/shell/mac/Sources/JayJay/Repo/RepoPresentation.swift @@ -9,6 +9,7 @@ enum RepoModalState: Identifiable { case undoLog case bookmarkManager case workspaceCreate + case confirmWorkspaceDelete(name: String, path: String, operationId: String) case sponsorPrompt var id: String { @@ -22,6 +23,8 @@ enum RepoModalState: Identifiable { case .undoLog: "undo-log" case .bookmarkManager: "bookmark-manager" case .workspaceCreate: "workspace-create" + case let .confirmWorkspaceDelete(name, _, operationId): + "workspace-delete-\(name)-\(operationId)" case .sponsorPrompt: "sponsor-prompt" } } diff --git a/shell/mac/Sources/JayJay/Repo/RepoTitleMenu.swift b/shell/mac/Sources/JayJay/Repo/RepoTitleMenu.swift deleted file mode 100644 index 245e67aa..00000000 --- a/shell/mac/Sources/JayJay/Repo/RepoTitleMenu.swift +++ /dev/null @@ -1,96 +0,0 @@ -import AppKit -import SwiftUI - -struct RepoTitleMenu: View { - let repoPath: String - - @Environment(RepositoryStore.self) private var repositoryStore - @Environment(RepoWindowManager.self) private var windowManager - - private var standardizedRepoPath: String { - URL(fileURLWithPath: repoPath).standardizedFileURL.path - } - - private var closedPinnedRepositories: [String] { - let open = Set(windowManager.openRepoPaths) - return repositoryStore.paths.filter { !open.contains($0) } - } - - var body: some View { - Menu { - Section("Open Windows") { - ForEach(windowManager.openRepoPaths, id: \.self) { path in - Button { - afterMenuDismiss { windowManager.activateRepo(path) } - } label: { - Label( - URL(fileURLWithPath: path).repositoryDisplayName, - systemImage: path == standardizedRepoPath ? "checkmark" : "macwindow" - ) - } - } - } - - if !closedPinnedRepositories.isEmpty { - Section("Pinned") { - ForEach(closedPinnedRepositories, id: \.self) { path in - Button { - afterMenuDismiss { windowManager.openRepo(path) } - } label: { - Label( - URL(fileURLWithPath: path).repositoryDisplayName, - systemImage: "pin.fill" - ) - } - } - } - } - - Divider() - - Button { - afterMenuDismiss { windowManager.showRepoList() } - } label: { - Label("Repository List...", systemImage: "list.bullet") - } - - Button { - afterMenuDismiss { windowManager.openRepositoryPicker() } - } label: { - Label("Open Repository...", systemImage: "folder") - } - } label: { - HStack(spacing: 4) { - Text(URL(fileURLWithPath: repoPath).repositoryDisplayName) - .fontWeight(.semibold) - Image(systemName: "chevron.down") - .font(.caption2) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 8) - .frame(minHeight: 30) - .contentShape(Rectangle()) - } - .menuIndicator(.hidden) - .menuStyle(.button) - .buttonStyle(.plain) - .fixedSize() - .onAppear(perform: refresh) - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - refresh() - } - .accessibilityLabel("Switch Repository") - } - - private func refresh() { - repositoryStore.reload() - windowManager.refreshOpenRepoPaths() - } - - private func afterMenuDismiss(_ action: @escaping @MainActor @Sendable () -> Void) { - // Default-mode work cannot run inside AppKit's event-tracking loop, so window changes wait until the menu is gone. - RunLoop.main.perform(inModes: [.default]) { - MainActor.assumeIsolated { action() } - } - } -} diff --git a/shell/mac/Sources/JayJay/Repo/RepoTitlePicker.swift b/shell/mac/Sources/JayJay/Repo/RepoTitlePicker.swift new file mode 100644 index 00000000..66fc2dcf --- /dev/null +++ b/shell/mac/Sources/JayJay/Repo/RepoTitlePicker.swift @@ -0,0 +1,230 @@ +import AppKit +import JayJayCore +import SwiftUI + +/// The toolbar's combined repo and workspace switcher: a borderless "repo / workspace" button opening a filterable PickerPanel with the current repo's workspaces on top and repositories plus global actions below. Sections are rebuilt on every click, so the panel is always current. +struct RepoTitlePicker: View { + let repoPath: String + let workspaces: [WorkspaceInfo] + let onOpenWorkspace: (WorkspaceInfo) -> Void + let onForget: (WorkspaceInfo) -> Void + let onForgetDelete: (WorkspaceInfo) -> Void + let onCreateWorkspace: () -> Void + + @Environment(RepositoryStore.self) private var repositoryStore + @Environment(RepoWindowManager.self) private var windowManager + @State private var anchor = PickerAnchor() + @State private var panel = PickerPanel() + @State private var rootRepoName: String? + @State private var isResolvingRootName = false + + private var standardizedRepoPath: String { + URL(fileURLWithPath: repoPath).standardizedFileURL.path + } + + private var currentWorkspaceName: String? { + workspaces.count > 1 ? workspaces.first(where: \.isCurrent)?.name : nil + } + + var body: some View { + Button(action: togglePanel) { + HStack(spacing: 4) { + Text(rootRepoName ?? URL(fileURLWithPath: repoPath).repositoryDisplayName) + .fontWeight(.semibold) + if let currentWorkspaceName { + Text("/") + .foregroundStyle(.tertiary) + Text(currentWorkspaceName) + .fontWeight(.semibold) + } + Image(systemName: "chevron.down") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .frame(minHeight: 30) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .fixedSize() + .background(PickerAnchorView(anchor: anchor)) + .help("Switch repository or workspace") + .accessibilityLabel("Switch Repository or Workspace") + .onAppear(perform: refresh) + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + refresh() + } + } + + private func refresh() { + repositoryStore.reload() + windowManager.refreshOpenRepoPaths() + resolveRootRepoName() + } + + /// A window on a secondary workspace is named after its checkout directory; the button's repo half must show the primary repo instead. The lookup hits the filesystem, so it runs off the main actor; `repoPath` never changes for a window, so a single guarded task cannot be superseded. + private func resolveRootRepoName() { + guard rootRepoName == nil, !isResolvingRootName else { return } + isResolvingRootName = true + let repoPath = repoPath + Task { @MainActor in + let root = await Task.detached { workspacePrimaryRoot(path: repoPath) }.value + isResolvingRootName = false + guard let root else { return } + rootRepoName = URL(fileURLWithPath: root).repositoryDisplayName + } + } + + private func togglePanel() { + guard !panel.isVisible, !panel.wasJustDismissed else { + panel.dismiss() + return + } + guard let anchorView = anchor.view else { return } + let sections = [workspaceSection, repositorySection, globalSection].compactMap(\.self) + let root = PickerPanelRoot( + placeholder: "Filter", + actionLabel: "New Workspace", + onAction: { deferred { onCreateWorkspace() } }, + sections: sections, + onDismiss: { [weak panel] in panel?.dismiss() } + ) + panel.show(under: anchorView, size: PickerPanelRoot.idealSize(sections: sections), content: root) + } + + /// Default-mode work cannot run inside AppKit's event-tracking loop, so window changes wait until the panel is gone. + private func deferred(_ action: @escaping @MainActor @Sendable () -> Void) { + RunLoop.main.perform(inModes: [.default]) { + MainActor.assumeIsolated { action() } + } + } + + private var sortedWorkspaces: [WorkspaceInfo] { + workspaces.sorted { + if $0.isCurrent != $1.isCurrent { + return $0.isCurrent + } + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + } + + private var workspaceSection: PickerSection? { + guard workspaces.count > 1 else { return nil } + let rows = sortedWorkspaces.map { workspace in + PickerRow( + id: "ws-\(workspace.name)", + searchText: "\(workspace.name) \(workspace.description) \(workspace.isPathResolved ? "" : "path unavailable")", + height: workspace.description.count > 52 ? 60 : 46, + action: workspace.isCurrent || !workspace.isPathResolved ? nil : { + deferred { onOpenWorkspace(workspace) } + } + ) { _ in + WorkspaceRowView(workspace: workspace) + } + .withContextMenu { workspaceContextMenu(workspace) } + } + return PickerSection(id: "workspaces", title: "Workspaces", rows: rows) + } + + @ViewBuilder + private func workspaceContextMenu(_ workspace: WorkspaceInfo) -> some View { + if !workspace.isCurrent, workspace.isPathResolved { + Button("Open in New Window") { + panel.dismiss() + deferred { onOpenWorkspace(workspace) } + } + } + if workspace.isPathResolved { + Button("Copy Path") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(workspace.path, forType: .string) + } + } + if !workspace.isCurrent, workspace.name != "default" { + Divider() + Button("Forget") { + panel.dismiss() + onForget(workspace) + } + if workspace.isPathResolved { + Button("Forget & Delete from Disk", role: .destructive) { + panel.dismiss() + onForgetDelete(workspace) + } + } + } + } + + private var repositorySection: PickerSection? { + let open = windowManager.openRepoPaths + let pinned = repositoryStore.paths.filter { !Set(open).contains($0) } + guard !open.isEmpty || !pinned.isEmpty else { return nil } + let openRows = open.map { path in + let isCurrent = path == standardizedRepoPath + return repoRow( + path: path, + icon: isCurrent ? "checkmark" : "macwindow", + iconTint: isCurrent ? Color.accentColor : .secondary, + action: isCurrent ? nil : { deferred { windowManager.activateRepo(path) } } + ) + } + let pinnedRows = pinned.map { path in + repoRow(path: path, icon: "pin.fill", iconTint: .secondary) { + deferred { windowManager.openRepo(path) } + } + } + return PickerSection(id: "repositories", title: "Repositories", rows: openRows + pinnedRows) + } + + private func repoRow(path: String, icon: String, iconTint: Color, action: (() -> Void)? = nil) -> PickerRow { + let name = URL(fileURLWithPath: path).repositoryDisplayName + return PickerRow( + id: "repo-\(path)", + searchText: "\(name) \(path)", + action: action + ) { _ in + HStack(spacing: 5) { + Image(systemName: icon) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(iconTint) + .frame(width: 14) + Text(name) + .font(.system(size: 13)) + .lineLimit(1) + Spacer(minLength: 8) + Text(path) + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.head) + .frame(maxWidth: 130, alignment: .trailing) + } + .padding(.horizontal, 14) + } + } + + private var globalSection: PickerSection? { + PickerSection(id: "global", title: nil, rows: [ + actionRow(id: "repo-list", title: "Repository List…", icon: "list.bullet") { + deferred { windowManager.showRepoList() } + }, + actionRow(id: "open-repo", title: "Open Repository…", icon: "folder") { + deferred { windowManager.openRepositoryPicker() } + } + ]) + } + + private func actionRow(id: String, title: String, icon: String, action: @escaping () -> Void) -> PickerRow { + PickerRow(id: id, searchText: title, height: 28, action: action) { _ in + HStack(spacing: 5) { + Image(systemName: icon) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 14) + Text(title) + .font(.system(size: 13)) + } + .padding(.horizontal, 14) + } + } +} diff --git a/shell/mac/Sources/JayJay/Repo/RepoWindow.swift b/shell/mac/Sources/JayJay/Repo/RepoWindow.swift index 1cd475d6..5335dad7 100644 --- a/shell/mac/Sources/JayJay/Repo/RepoWindow.swift +++ b/shell/mac/Sources/JayJay/Repo/RepoWindow.swift @@ -6,6 +6,7 @@ struct RepoWindow: View { @State private var viewModel: RepoViewModel? @State private var initError: String? @Environment(AppSettings.self) private var settings + @Environment(RepoWindowManager.self) private var windowManager var body: some View { Group { @@ -26,31 +27,20 @@ struct RepoWindow: View { private func openRepo() async { let path = repoPath let includeSubmodules = settings.enableGitSubmoduleSupport - // Off the main thread so the app stays responsive while loading large checkouts. - let result = await Task.detached { - Result { - let repo = try JayJayRepo.open(path: path) - return ( - repo: repo, - workingCopyIsLarge: repo.workingCopyIsLarge(), - configWarning: repo.checkUserConfig() - ) - } - }.value - switch result { - case let .success(opened): - let model = RepoViewModel( - path: path, - repo: opened.repo, - workingCopyIsLarge: opened.workingCopyIsLarge, - configWarning: opened.configWarning, - includeSubmoduleStatuses: includeSubmodules - ) - viewModel = model - // Huge checkouts skip the snapshot on open (it's the slow part); small repos refresh eagerly. - model.refresh(selecting: "@", snapshotWorkingCopy: !model.workingCopyIsLarge) - case let .failure(error): - initError = error.friendlyDescription + let result = await windowManager.loadRepoViewModel( + at: path, + includeSubmoduleStatuses: includeSubmodules + ) + guard !Task.isCancelled else { return } + if let model = result.viewModel { + viewModel = model + // Huge checkouts skip the snapshot on open (it's the slow part); small repos refresh eagerly. + model.refresh(selecting: "@", snapshotWorkingCopy: !model.workingCopyIsLarge) + } else if let error = result.error { + initError = error + } else { + // Removal can begin after the scene was requested but before its repo open was registered. + windowManager.closeRepoWindow(at: path) } } diff --git a/shell/mac/Sources/JayJay/Repo/StackedPr/StackedPrPanel.swift b/shell/mac/Sources/JayJay/Repo/StackedPr/StackedPrPanel.swift index 89cd62d8..96b6b03a 100644 --- a/shell/mac/Sources/JayJay/Repo/StackedPr/StackedPrPanel.swift +++ b/shell/mac/Sources/JayJay/Repo/StackedPr/StackedPrPanel.swift @@ -79,7 +79,9 @@ struct StackedPrPanel: View { } // While submitting, freeze the stack — no name edits or regenerate. layerList(stack).disabled(isWorking) - if let errorMessage { errorBanner(errorMessage) } + if let errorMessage { + errorBanner(errorMessage) + } actionRow(confirm: "Submit", disabled: !allNamesValid(stack)) { submit() } } } else if let errorMessage { @@ -116,7 +118,9 @@ struct StackedPrPanel: View { HStack(spacing: 5) { Image(systemName: "arrow.right").jayjayFont(8).foregroundStyle(.tertiary) Text(displayedBase(stack, index)).jayjayFont(10, design: .monospaced).foregroundStyle(.tertiary) - if !layer.bookmarkExisted { newBadge } + if !layer.bookmarkExisted { + newBadge + } } } Spacer() @@ -239,7 +243,7 @@ struct StackedPrPanel: View { private func loadStack() async { guard stack == nil, results == nil else { return } - viewModel.load { + viewModel.runRepoTask { try $0.detectStack(baseRev: "trunk()", tipRev: tipRev) } onSuccess: { _, detected in stack = detected @@ -271,7 +275,7 @@ struct StackedPrPanel: View { errorMessage = nil // Capture Sendable primitives; rebuild the records inside the operation. let payload = stack.layers.map { ($0.changeId, trimmedName(for: $0), $0.title, $0.body) } - viewModel.load { repo in + viewModel.runRepoTask { repo in let layers = payload.map { SubmitStackLayer(changeId: $0.0, bookmark: $0.1, title: $0.2, body: $0.3) } diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+ChangeActions.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+ChangeActions.swift index 1b6196e0..804d31c9 100644 --- a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+ChangeActions.swift +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+ChangeActions.swift @@ -19,9 +19,7 @@ extension RepoViewModel { func commit(message: String, manageSubmodules: Bool) async -> Bool { if manageSubmodules { do { - let blockedSubmodules = try await Task.detached { [repo] in - try repo.submoduleStatuses() - }.value + let blockedSubmodules = try await awaitRepoTask { try $0.submoduleStatuses() } if !blockedSubmodules.isEmpty { pendingCommitMessage = message submoduleAttentionItems = blockedSubmodules @@ -62,12 +60,12 @@ extension RepoViewModel { isLoading = true do { - let infoMessage = try await Task.detached { [repo] in - try repo.commitSafeSubmoduleUpdates( + let infoMessage = try await awaitRepoTask { + try $0.commitSafeSubmoduleUpdates( message: "\(message) (submodule)", paths: safePaths ) - }.value + } if let committedChangeId { reviewStore.clearChange(changeId: committedChangeId) @@ -138,7 +136,7 @@ extension RepoViewModel { } func opLog() { - load { + runRepoTask { try $0.opLog() } onSuccess: { viewModel, entries in viewModel.opLogEntries = entries diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+CommitMessage.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+CommitMessage.swift index c9772607..6bc05e43 100644 --- a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+CommitMessage.swift +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+CommitMessage.swift @@ -5,34 +5,33 @@ import JayJayCore #endif extension RepoViewModel { + @MainActor func generateCommitMessage() async -> String? { do { - let summary = try repo.diffSummary() + let summary = try await awaitRepoTask { try $0.diffSummary() } if summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return nil } let cliProvider = detectAiProvider() if !cliProvider.isEmpty { - let cliResult: String? = await Task.detached { [repo] in - repo.generateCommitMessage(diffSummary: summary) - }.value + let cliResult: String? = try await awaitRepoTask { + $0.generateCommitMessage(diffSummary: summary) + } if let message = cliResult, !message.isEmpty { - await MainActor.run { [weak self] in self?.aiProvider = cliProvider } + aiProvider = cliProvider return message } } if let message = await Self.generateWithLocalLLM(diffSummary: summary) { - await MainActor.run { [weak self] in self?.aiProvider = "Apple Intelligence" } + aiProvider = "Apple Intelligence" return message } return nil } catch { - await MainActor.run { [weak self] in - self?.present(error: error) - } + present(error: error) return nil } } diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Evolog.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Evolog.swift index 17535cf4..1fc79ede 100644 --- a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Evolog.swift +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+Evolog.swift @@ -10,7 +10,7 @@ extension RepoViewModel { } evologRev = rev evologEntries = nil - load { repo in + runRepoTask { repo in try repo.evolog(rev: rev) } onSuccess: { vm, entries in guard vm.evologRev == rev else { return } // user moved on while loading diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+FileActions.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+FileActions.swift index f9ab4753..fa51787c 100644 --- a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+FileActions.swift +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+FileActions.swift @@ -62,7 +62,7 @@ extension RepoViewModel { ) { lastInternalMutationAt = Date() let includeSubmoduleStatuses = includeSubmoduleStatuses - load { + runRepoTask { try $0.applyDiffSelection( rev: rev, destination: .removeFromSource, diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+GitActions.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+GitActions.swift index 8a364c2d..e3fd9dc2 100644 --- a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+GitActions.swift +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+GitActions.swift @@ -40,20 +40,13 @@ extension RepoViewModel { func openPR(bookmark: String) { guard !bookmark.isEmpty else { return } - Task.detached { [repo] in - let result = Result { try repo.pullRequestOpenUrl(bookmark: bookmark) } - await MainActor.run { [weak self] in - guard let self else { return } - switch result { - case let .success(urlString): - if let url = URL(string: urlString) { - NSWorkspace.shared.open(url) - } else { - info = urlString - } - case let .failure(error): - info = error.friendlyDescription - } + runRepoTask { + try $0.pullRequestOpenUrl(bookmark: bookmark) + } onSuccess: { viewModel, urlString in + if let url = URL(string: urlString) { + NSWorkspace.shared.open(url) + } else { + viewModel.info = urlString } } } diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+WorkspaceActions.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+WorkspaceActions.swift index df06ff21..d84b86d4 100644 --- a/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+WorkspaceActions.swift +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Actions/RepoViewModel+WorkspaceActions.swift @@ -1,8 +1,9 @@ +import Foundation import JayJayCore extension RepoViewModel { func workspaceList() -> [WorkspaceInfo] { - (try? repo.workspaceList()) ?? [] + workspaces } func workspaceAdd( @@ -27,7 +28,54 @@ extension RepoViewModel { ) } - func workspaceForget(name: String) { - perform { try $0.workspaceForget(name: name) } + @MainActor + func workspaceRemovalGuard( + name: String, + expectedRoot: String, + expectedOperation: String + ) async throws -> String { + try await awaitRepoTask { + try $0.workspaceRemovalGuard( + name: name, + expectedRoot: expectedRoot, + expectedOperation: expectedOperation + ) + } + } + + @MainActor + func workspaceForget( + name: String, + expectedRoot: String, + expectedOperation: String + ) async throws -> String? { + lastInternalMutationAt = Date() + let warning = try await awaitRepoTask { + try $0.workspaceForget( + name: name, + expectedRoot: expectedRoot, + expectedOperation: expectedOperation + ) + } + successActionSignal += 1 + refresh() + return warning + } + + @MainActor + func workspaceForgetUnresolved( + name: String, + expectedOperation: String + ) async throws -> String? { + lastInternalMutationAt = Date() + let warning = try await awaitRepoTask { + try $0.workspaceForgetUnresolved( + name: name, + expectedOperation: expectedOperation + ) + } + successActionSignal += 1 + refresh() + return warning } } diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+AsyncSupport.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+AsyncSupport.swift index 54a93932..5db07be7 100644 --- a/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+AsyncSupport.swift +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+AsyncSupport.swift @@ -9,11 +9,56 @@ struct RepoActionGate { extension RepoViewModel { typealias RepoOperation = @Sendable (JayJayRepo) throws -> Result + /// Retains lifecycle work independently of the latest-task handles, so superseded synchronous FFI remains visible to the workspace-removal barrier. + @discardableResult + func startLifecycleRepoTask( + _ operation: @escaping @Sendable () async -> Void + ) -> Task { + guard !isShuttingDown else { + return Task {} + } + let taskId = UUID() + let task = Task.detached { [self] in + await operation() + await MainActor.run { + lifecycleRepoTasks[taskId] = nil + } + } + lifecycleRepoTasks[taskId] = task + return task + } + @MainActor func present(error: any Error) { self.error = error.friendlyDescription } + /// Refresh failures from a workspace that vanished underneath the window (forgotten or deleted elsewhere) close the window instead of alerting; an undecided presence means the repo was merely unreadable, so the real error still belongs on screen. The presence probe is passed in because it repeats the filesystem access that just failed and must not run on the main actor. + @MainActor + func presentRefreshFailure(_ error: any Error, presence: WorkspacePresence) { + guard presence == .gone else { + present(error: error) + return + } + workspaceVanished = true + } + + /// The presence probe repeats the filesystem access that just failed and can block for a volume timeout, so cancellation must be checked on both sides of it and after the main-actor hop. + func handleRefreshFailure( + _ error: any Error, + workspacePresence: @Sendable () -> WorkspacePresence + ) async { + guard !Task.isCancelled else { return } + let presence = workspacePresence() + guard !Task.isCancelled else { return } + await MainActor.run { + guard !Task.isCancelled, !isShuttingDown else { return } + isLoading = false + isRefreshingInFlight = false + presentRefreshFailure(error, presence: presence) + } + } + func perform( selecting rev: String? = "@", beforeRefresh: @escaping @MainActor (RepoViewModel) -> Void = { _ in }, @@ -58,28 +103,22 @@ extension RepoViewModel { } lastInternalMutationAt = Date() runRepoTask(action) { viewModel, result in - if let gate { viewModel[keyPath: gate.state] = false } + if let gate { + viewModel[keyPath: gate.state] = false + } viewModel.successActionSignal += 1 beforeRefresh(viewModel) onSuccess(viewModel, result) viewModel.refresh(selecting: rev) } onFailure: { viewModel, error in - if let gate { viewModel[keyPath: gate.state] = false } + if let gate { + viewModel[keyPath: gate.state] = false + } onFailure(viewModel, error) } return true } - func load( - _ operation: @escaping RepoOperation, - onSuccess: @escaping @MainActor (RepoViewModel, Result) -> Void, - onFailure: @escaping @MainActor (RepoViewModel, any Error) -> Void = { viewModel, error in - viewModel.present(error: error) - } - ) { - runRepoTask(operation, onSuccess: onSuccess, onFailure: onFailure) - } - func runRepoTask( _ operation: @escaping RepoOperation, onSuccess: @escaping @MainActor (RepoViewModel, Result) -> Void, @@ -87,19 +126,45 @@ extension RepoViewModel { viewModel.present(error: error) } ) { - Task.detached { [repo] in + guard !isShuttingDown else { return } + let taskId = UUID() + inFlightRepoTasks[taskId] = Task.detached { [self, repo] in do { let result = try operation(repo) - await MainActor.run { [weak self] in - guard let self else { return } + await MainActor.run { onSuccess(self, result) + inFlightRepoTasks[taskId] = nil } } catch { - await MainActor.run { [weak self] in - guard let self else { return } + await MainActor.run { onFailure(self, error) + inFlightRepoTasks[taskId] = nil + } + } + } + } + + /// Runs an awaited repo operation through the same registry as callback-based actions, so workspace removal cannot outrun direct async reads or mutations. + @MainActor + func awaitRepoTask(_ operation: @escaping RepoOperation) async throws -> Result { + guard !isShuttingDown else { throw CancellationError() } + let taskId = UUID() + return try await withCheckedThrowingContinuation { continuation in + inFlightRepoTasks[taskId] = Task.detached { [self, repo] in + let outcome = Swift.Result { try operation(repo) } + await MainActor.run { + inFlightRepoTasks[taskId] = nil + continuation.resume(with: outcome) } } } } + + @MainActor + func runJjCommand(_ command: String) async throws -> JjCommandResult { + let path = repoPath + return try await awaitRepoTask { _ in + try runJjCommandInRepoPath(repoPath: path, command: command) + } + } } diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+Refresh.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+Refresh.swift index 959ea4f8..37dc6987 100644 --- a/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+Refresh.swift +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+Refresh.swift @@ -4,7 +4,7 @@ import JayJayCore private struct RepoRefreshContent { let graph: [GraphEntry] let bookmarks: [BookmarkInfo] - let workspaces: [WorkspaceInfo] + let workspaces: [WorkspaceInfo]? let prHostName: String? let selectedChange: ChangeDetail? let workingCopyChangeId: String @@ -30,16 +30,21 @@ extension RepoViewModel { func fetchPrInfo(bookmarks: [String]) { prFetchTask?.cancel() + guard !isShuttingDown else { + prFetchTask = nil + return + } guard let bookmark = bookmarks.first else { prInfo = nil return } prInfo = nil - prFetchTask = Task.detached { [repo] in + prFetchTask = startLifecycleRepoTask { [self, repo] in let info = repo.pullRequestInfo(bookmark: bookmark) guard !Task.isCancelled else { return } - await MainActor.run { [weak self] in - self?.prInfo = info + await MainActor.run { + guard !isShuttingDown else { return } + prInfo = info } } } @@ -55,8 +60,11 @@ extension RepoViewModel { isAutoTriggered: Bool = false, snapshotWorkingCopy: Bool = true ) { + guard !isShuttingDown else { return } // Don't pile FS-triggered refreshes on an in-flight one — our own refreshWorkingCopy re-fires the watcher. - if isAutoTriggered, isRefreshingInFlight { return } + if isAutoTriggered, isRefreshingInFlight { + return + } refreshTask?.cancel() isRefreshingInFlight = true isLoading = graphEntries.isEmpty @@ -66,7 +74,7 @@ extension RepoViewModel { let requestedRevset = revset let includeSubmoduleStatuses = includeSubmoduleStatuses let shouldLoadBeforeSnapshot = graphEntries.isEmpty && snapshotWorkingCopy - refreshTask = Task.detached { [repo] in + refreshTask = startLifecycleRepoTask { [self, repo] in do { if shouldLoadBeforeSnapshot { let content = try Self.loadRefreshContent( @@ -76,8 +84,8 @@ extension RepoViewModel { includeSubmoduleStatuses: includeSubmoduleStatuses ) guard !Task.isCancelled else { return } - await MainActor.run { [weak self] in - self?.applyRefreshContent( + await MainActor.run { + applyRefreshContent( content, revset: requestedRevset, isRefreshComplete: false @@ -97,19 +105,16 @@ extension RepoViewModel { includeSubmoduleStatuses: includeSubmoduleStatuses ) guard !Task.isCancelled else { return } - await MainActor.run { [weak self] in - self?.applyRefreshContent( + await MainActor.run { + applyRefreshContent( content, revset: requestedRevset, isRefreshComplete: true ) } } catch { - guard !Task.isCancelled else { return } - await MainActor.run { [weak self] in - self?.isLoading = false - self?.isRefreshingInFlight = false - self?.present(error: error) + await handleRefreshFailure(error) { + repo.workspacePresence() } } } @@ -121,9 +126,12 @@ extension RepoViewModel { revset: String, isRefreshComplete: Bool ) { + guard !isShuttingDown else { return } graphEntries = content.graph bookmarks = content.bookmarks - workspaces = content.workspaces + if let workspaces = content.workspaces { + self.workspaces = workspaces + } prHostName = content.prHostName selectedChange = content.selectedChange selectedChangeId = content.selectedChange?.info.selectionRevision @@ -146,7 +154,7 @@ extension RepoViewModel { } func loadMore() { - guard canLoadMore, let currentDepth = Self.defaultRevsetDepth(for: revset) else { return } + guard !isShuttingDown, canLoadMore, let currentDepth = Self.defaultRevsetDepth(for: revset) else { return } let nextDepth = currentDepth + Self.defaultRevsetPageSize let nextRevset = Self.buildDefaultRevset(depth: nextDepth) @@ -158,7 +166,7 @@ extension RepoViewModel { isRefreshingInFlight = true error = nil - refreshTask = Task.detached { [repo, includeSubmoduleStatuses] in + refreshTask = startLifecycleRepoTask { [self, repo, includeSubmoduleStatuses] in do { let content = try Self.loadRefreshContent( repo: repo, @@ -175,33 +183,33 @@ extension RepoViewModel { ) guard !Task.isCancelled else { return } - await MainActor.run { [weak self] in - self?.graphEntries = content.graph - self?.bookmarks = content.bookmarks - self?.workspaces = content.workspaces - self?.prHostName = content.prHostName - self?.selectedChange = content.selectedChange - self?.selectedChangeId = content.selectedChange?.info.selectionRevision - self?.applyWorkingCopy( + await MainActor.run { + guard !isShuttingDown else { return } + graphEntries = content.graph + bookmarks = content.bookmarks + if let workspaces = content.workspaces { + self.workspaces = workspaces + } + prHostName = content.prHostName + selectedChange = content.selectedChange + selectedChangeId = content.selectedChange?.info.selectionRevision + applyWorkingCopy( changeId: content.workingCopyChangeId, isDivergent: content.workingCopyIsDivergent, description: content.workingCopyDescription ) - self?.apply(content.statusBar) - self?.isLoading = false - self?.isRefreshingInFlight = false - self?.hasWorkingCopyChanges = false - self?.canLoadMore = canLoadMore + apply(content.statusBar) + isLoading = false + isRefreshingInFlight = false + hasWorkingCopyChanges = false + self.canLoadMore = canLoadMore if didGrow { - self?.revset = nextRevset + revset = nextRevset } } } catch { - guard !Task.isCancelled else { return } - await MainActor.run { [weak self] in - self?.isLoading = false - self?.isRefreshingInFlight = false - self?.present(error: error) + await handleRefreshFailure(error) { + repo.workspacePresence() } } } @@ -226,7 +234,7 @@ extension RepoViewModel { return try RepoRefreshContent( graph: graph, bookmarks: repo.listBookmarks(), - workspaces: (try? repo.workspaceList()) ?? [], + workspaces: try? repo.workspaceList(), prHostName: repo.prHostName(), selectedChange: selectedChange, workingCopyChangeId: workingCopy?.changeId.id ?? "", @@ -248,7 +256,9 @@ extension RepoViewModel { guard identityChanged || (isDivergent && description != previousDescription) else { return } workingCopyChangeId = changeId let hasDraft = !commitSummaryDraft.isEmpty || !commitDescriptionDraft.isEmpty - if hasDraft, description.isEmpty { return } + if hasDraft, description.isEmpty { + return + } commitSummaryDraft = commitSummary(message: description) commitDescriptionDraft = commitBody(message: description) } diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+Selection.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+Selection.swift index 6a153184..27e74dfb 100644 --- a/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+Selection.swift +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel+Selection.swift @@ -19,7 +19,7 @@ extension RepoViewModel { selectedChange = nil let includeSubmoduleStatuses = includeSubmoduleStatuses - load { + runRepoTask { try Self.loadSummaryWithConflicts( repo: $0, rev: requestedRev, @@ -62,7 +62,7 @@ extension RepoViewModel { compareToId = to compareDisplay = display selectedChangeId = to - load { + runRepoTask { let detail = try $0.interdiffSummary(fromRev: from, toRev: to) // Resolve the compare source to its immutable commit id so the diff // cache key is content-addressed on both sides; otherwise amending a diff --git a/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel.swift b/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel.swift index 7b404026..266be052 100644 --- a/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel.swift +++ b/shell/mac/Sources/JayJay/Repo/ViewModel/Core/RepoViewModel.swift @@ -33,6 +33,7 @@ final class RepoViewModel: ChangeActions, DAGActions, BookmarkActions { var submoduleAttentionItems: [GitSubmoduleStatus] = [] var pendingCommitMessage: String? var error: String? + var workspaceVanished = false var info: String? /// A tracked bookmark just moved by drag, awaiting an optional one-click push. var pendingPushBookmark: String? @@ -55,6 +56,12 @@ final class RepoViewModel: ChangeActions, DAGActions, BookmarkActions { var configWarning: String? private var fsWatcher: RepoFSWatcher? var refreshTask: Task? + /// Detached repo tasks retained so `prepareForRemoval()` can await them; keyed for self-removal on completion. + var inFlightRepoTasks: [UUID: Task] = [:] + /// Refresh and pull-request tasks remain registered after a newer task supersedes their latest-task handle, because cancellation cannot interrupt synchronous FFI already in progress. + var lifecycleRepoTasks: [UUID: Task] = [:] + /// Set once this repo is about to be moved or deleted; refuses new repo work so the quiesce in `prepareForRemoval()` cannot be outrun. + var isShuttingDown = false /// Stamp set by `perform()` so handleWorkingCopyChange can suppress its own FS echo. var lastInternalMutationAt: Date? /// True while a refresh task is running — gates FS-triggered re-entry. @@ -105,6 +112,37 @@ final class RepoViewModel: ChangeActions, DAGActions, BookmarkActions { ) } + /// Stops the file watcher, cancels refresh work, and waits for every in-flight repo task, so the caller can move or delete this checkout without racing a snapshot or mutation. Call before closing the window: the close releases this model, and unretained tasks could no longer be awaited. + @MainActor + func prepareForRemoval() async { + isShuttingDown = true + fsWatcher = nil + refreshTask?.cancel() + prFetchTask?.cancel() + let lifecycleTasks = Array(lifecycleRepoTasks.values) + for task in lifecycleTasks { + task.cancel() + } + refreshTask = nil + prFetchTask = nil + for task in lifecycleTasks { + await task.value + } + while !lifecycleRepoTasks.isEmpty { + let tasks = Array(lifecycleRepoTasks.values) + for task in tasks { + task.cancel() + await task.value + } + } + while !inFlightRepoTasks.isEmpty { + let tasks = Array(inFlightRepoTasks.values) + for task in tasks { + await task.value + } + } + } + private static func detectAIProvider() -> String { let cli = detectAiProvider() // from Rust via uniffi if !cli.isEmpty { diff --git a/shell/mac/Sources/JayJay/Repo/Workspaces/WorkspaceRowView.swift b/shell/mac/Sources/JayJay/Repo/Workspaces/WorkspaceRowView.swift new file mode 100644 index 00000000..8ffd4973 --- /dev/null +++ b/shell/mac/Sources/JayJay/Repo/Workspaces/WorkspaceRowView.swift @@ -0,0 +1,65 @@ +import JayJayCore +import SwiftUI + +/// Two-line picker row for a workspace: name, conflict badge, and recency up top; change id, description, and changed-file count below. +struct WorkspaceRowView: View { + let workspace: WorkspaceInfo + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 5) { + Image(systemName: workspace.isCurrent ? "checkmark" : "folder") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(workspace.isCurrent ? Color.accentColor : .secondary) + .frame(width: 14) + Text("\(workspace.name):") + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + Text(workspace.changeId.highlighted(scheme: colorScheme, maxChars: 8)) + .font(.system(size: 11, design: .monospaced)) + if workspace.hasConflict { + Text("conflict") + .font(.system(size: 9, weight: .semibold)) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(.red.opacity(0.15), in: Capsule()) + } + Spacer(minLength: 8) + Text(Date.relativeLabel(millis: workspace.timestamp)) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + HStack(alignment: .top, spacing: 6) { + descriptionText + .font(.system(size: 11)) + .lineLimit(2) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + Spacer(minLength: 8) + if workspace.filesChanged > 0 { + Text("\(workspace.filesChanged) file\(workspace.filesChanged == 1 ? "" : "s")") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .layoutPriority(1) + } + } + .padding(.leading, 19) + } + .padding(.horizontal, 14) + } + + @ViewBuilder + private var descriptionText: some View { + if !workspace.isPathResolved { + Text("Path unavailable — Forget to clean up") + .foregroundStyle(.orange) + } else if workspace.description.isEmpty { + Text("(no description)") + .foregroundStyle(.tertiary) + } else { + Text(workspace.description) + .foregroundStyle(.secondary) + } + } +} diff --git a/shell/mac/Sources/JayJay/Shared/AccessibilityIdentifiers.swift b/shell/mac/Sources/JayJay/Shared/AccessibilityIdentifiers.swift index aaa24cd9..070e4d1b 100644 --- a/shell/mac/Sources/JayJay/Shared/AccessibilityIdentifiers.swift +++ b/shell/mac/Sources/JayJay/Shared/AccessibilityIdentifiers.swift @@ -9,6 +9,12 @@ enum AID { } } + enum Picker { + static func row(_ id: String) -> String { + "picker.row.\(id)" + } + } + enum DAG { static func row(_ changeIdPrefix: String) -> String { "dag.row.\(changeIdPrefix)" diff --git a/shell/mac/Sources/JayJay/Shared/CommandPalette/CommandPalettePanel.swift b/shell/mac/Sources/JayJay/Shared/CommandPalette/CommandPalettePanel.swift index 44e76cd2..daac2937 100644 --- a/shell/mac/Sources/JayJay/Shared/CommandPalette/CommandPalettePanel.swift +++ b/shell/mac/Sources/JayJay/Shared/CommandPalette/CommandPalettePanel.swift @@ -34,12 +34,12 @@ final class CommandPalettePanel: NSPanel { func show( items: [CommandPaletteItem], - repoPath: String, + runJjCommand: @escaping (String) async throws -> JjCommandResult, onJjCommandFinished: @escaping (JjCommandResult) -> Void = { _ in } ) { let vc = NSHostingController(rootView: PaletteRoot( items: items, - repoPath: repoPath, + runJjCommand: runJjCommand, onJjCommandFinished: onJjCommandFinished, onDismiss: { [weak self] in self?.dismiss() } )) diff --git a/shell/mac/Sources/JayJay/Shared/CommandPalette/PaletteRoot+RawJJ.swift b/shell/mac/Sources/JayJay/Shared/CommandPalette/PaletteRoot+RawJJ.swift index 932512ec..ae3fe6f4 100644 --- a/shell/mac/Sources/JayJay/Shared/CommandPalette/PaletteRoot+RawJJ.swift +++ b/shell/mac/Sources/JayJay/Shared/CommandPalette/PaletteRoot+RawJJ.swift @@ -137,20 +137,21 @@ extension PaletteRoot { isRunning = true jjResult = nil jjError = nil - let path = repoPath let command = jjCmd - MainActorTask.detached { - try runJjCommandInRepoPath(repoPath: path, command: command) - } completion: { result in - switch result { - case let .success(commandResult): - jjResult = commandResult - history = CommandPaletteHistory.record(command, in: history) - if commandResult.exitCode == 0 { - onJjCommandFinished(commandResult) - } - case let .failure(error): + Task { @MainActor in + do { + let commandResult = try await runJjCommand(command) + jjResult = commandResult + history = CommandPaletteHistory.record(command, in: history) + if commandResult.exitCode == 0 { + onJjCommandFinished(commandResult) + } + } catch { + if !(error is CancellationError) { jjError = error.localizedDescription + } else { + jjError = "Repository operation was cancelled." + } } isRunning = false } diff --git a/shell/mac/Sources/JayJay/Shared/CommandPalette/PaletteRoot.swift b/shell/mac/Sources/JayJay/Shared/CommandPalette/PaletteRoot.swift index b6b14cd6..bcf08de7 100644 --- a/shell/mac/Sources/JayJay/Shared/CommandPalette/PaletteRoot.swift +++ b/shell/mac/Sources/JayJay/Shared/CommandPalette/PaletteRoot.swift @@ -4,7 +4,7 @@ import SwiftUI struct PaletteRoot: View { let items: [CommandPaletteItem] - let repoPath: String + let runJjCommand: (String) async throws -> JjCommandResult let onJjCommandFinished: (JjCommandResult) -> Void let onDismiss: () -> Void @@ -64,10 +64,12 @@ struct PaletteRoot: View { } .onKeyPress { press in if press.modifiers.contains(.control) { - if press.characters == "p" { move(-1) + if press.characters == "p" { + move(-1) return .handled } - if press.characters == "n" { move(1) + if press.characters == "n" { + move(1) return .handled } } diff --git a/shell/mac/Sources/JayJay/Shared/Date+Relative.swift b/shell/mac/Sources/JayJay/Shared/Date+Relative.swift new file mode 100644 index 00000000..426c8e52 --- /dev/null +++ b/shell/mac/Sources/JayJay/Shared/Date+Relative.swift @@ -0,0 +1,12 @@ +import Foundation + +extension Date { + private static let relativeFormatter = RelativeDateTimeFormatter() + + /// Floored to whole minutes: a per-second count on fresh changes is distracting, and a clock-skewed future timestamp then reads as "1 minute ago". + static func relativeLabel(millis: Int64) -> String { + let date = Date(timeIntervalSince1970: Double(millis) / 1000) + let now = Date() + return relativeFormatter.localizedString(for: min(date, now.addingTimeInterval(-60)), relativeTo: now) + } +} diff --git a/shell/mac/Sources/JayJay/Shared/DestructiveConfirmSheet.swift b/shell/mac/Sources/JayJay/Shared/DestructiveConfirmSheet.swift new file mode 100644 index 00000000..6f429ebf --- /dev/null +++ b/shell/mac/Sources/JayJay/Shared/DestructiveConfirmSheet.swift @@ -0,0 +1,42 @@ +import SwiftUI + +/// Confirmation sheet for a destructive action: red icon, title, centered explanation, optional "Don't ask again" toggle, Cancel plus a red confirm button. +struct DestructiveConfirmSheet: View { + let title: String + let message: String + let confirmLabel: String + var width: CGFloat = 340 + var dontAskAgain: Binding? + let onCancel: () -> Void + let onConfirm: () -> Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "trash.circle.fill") + .font(.system(size: 36)) + .foregroundStyle(.red) + Text(title) + .jayjayFont(16, weight: .semibold) + Text(message) + .jayjayFont(13) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + if let dontAskAgain { + Toggle("Don't ask again", isOn: dontAskAgain) + .jayjayFont(12) + } + + HStack(spacing: 12) { + Button("Cancel", action: onCancel) + .keyboardShortcut(.cancelAction) + Button(confirmLabel, action: onConfirm) + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .tint(.red) + } + } + .padding(24) + .frame(width: width) + } +} diff --git a/shell/mac/Sources/JayJay/Shared/Picker/PickerPanel.swift b/shell/mac/Sources/JayJay/Shared/Picker/PickerPanel.swift new file mode 100644 index 00000000..f347c804 --- /dev/null +++ b/shell/mac/Sources/JayJay/Shared/Picker/PickerPanel.swift @@ -0,0 +1,114 @@ +import AppKit +import SwiftUI + +/// Anchored dropdown panel shared by toolbar pickers: opens instantly under its anchor with no animation, dismisses on Escape or focus loss, and hosts arbitrary SwiftUI content such as a filterable list. +final class PickerPanel: NSPanel { + private weak var hostWindow: NSWindow? + private var hostWindowCloseObserver: NSObjectProtocol? + + init() { + super.init( + contentRect: .zero, + styleMask: [.nonactivatingPanel, .fullSizeContentView], + backing: .buffered, + defer: true + ) + titleVisibility = .hidden + titlebarAppearsTransparent = true + level = .floating + isOpaque = false + backgroundColor = .clear + hidesOnDeactivate = true + } + + func show(under anchor: NSView, size: NSSize, content: some View) { + contentViewController = NSHostingController(rootView: content) + appearance = anchor.window?.appearance ?? NSApp.effectiveAppearance + setContentSize(size) + if let window = anchor.window { + attach(to: window) + let anchorRect = window.convertToScreen(anchor.convert(anchor.bounds, to: nil)) + var origin = NSPoint(x: anchorRect.minX, y: anchorRect.minY - size.height - 4) + if let screen = window.screen ?? NSScreen.main { + origin.x = min(origin.x, screen.visibleFrame.maxX - size.width - 8) + origin.x = max(origin.x, screen.visibleFrame.minX + 8) + origin.y = max(origin.y, screen.visibleFrame.minY + 8) + } + setFrameOrigin(origin) + } + makeKeyAndOrderFront(nil) + } + + func dismiss() { + dismissedAt = Date() + orderOut(nil) + contentViewController = nil + detachFromHostWindow() + } + + private func attach(to window: NSWindow) { + detachFromHostWindow() + hostWindow = window + window.addChildWindow(self, ordered: .above) + hostWindowCloseObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] _ in + self?.dismiss() + } + } + + private func detachFromHostWindow() { + if let hostWindowCloseObserver { + NotificationCenter.default.removeObserver(hostWindowCloseObserver) + self.hostWindowCloseObserver = nil + } + hostWindow?.removeChildWindow(self) + hostWindow = nil + } + + private var dismissedAt: Date? + + /// True right after a dismissal. A click on the anchor button closes the panel via resignKey before the button's action runs, so the action must treat that click as a toggle-close instead of reopening. + var wasJustDismissed: Bool { + dismissedAt.map { Date().timeIntervalSince($0) < 0.3 } ?? false + } + + override func cancelOperation(_ sender: Any?) { + dismiss() + } + + override var canBecomeKey: Bool { + true + } + + override func resignKey() { + super.resignKey() + // Dismiss on focus loss (e.g. a click outside), unless it immediately regains key. + DispatchQueue.main.async { [weak self] in + guard let self, !self.isKeyWindow else { return } + dismiss() + } + } +} + +/// Stable NSView anchor placed behind a toolbar button so the panel opens from the button's frame. +@MainActor +final class PickerAnchor { + weak var view: NSView? +} + +struct PickerAnchorView: NSViewRepresentable { + let anchor: PickerAnchor + + func makeNSView(context: Context) -> NSView { + let view = NSView() + anchor.view = view + return view + } + + func updateNSView(_ view: NSView, context: Context) { + anchor.view = view + } +} diff --git a/shell/mac/Sources/JayJay/Shared/Picker/PickerPanelRoot.swift b/shell/mac/Sources/JayJay/Shared/Picker/PickerPanelRoot.swift new file mode 100644 index 00000000..744a3787 --- /dev/null +++ b/shell/mac/Sources/JayJay/Shared/Picker/PickerPanelRoot.swift @@ -0,0 +1,245 @@ +import SwiftUI + +struct PickerRow: Identifiable { + let id: String + let searchText: String + let height: CGFloat + let action: (() -> Void)? + let content: (_ highlighted: Bool) -> AnyView + private(set) var contextMenu: (() -> AnyView)? + + init( + id: String, + searchText: String, + height: CGFloat = 30, + action: (() -> Void)? = nil, + @ViewBuilder content: @escaping (_ highlighted: Bool) -> some View + ) { + self.id = id + self.searchText = searchText + self.height = height + self.action = action + self.content = { AnyView(content($0)) } + } + + func withContextMenu(@ViewBuilder _ items: @escaping () -> some View) -> PickerRow { + var row = self + row.contextMenu = { AnyView(items()) } + return row + } +} + +struct PickerSection: Identifiable { + let id: String + let title: String? + let rows: [PickerRow] +} + +/// GitHub Desktop-style picker scaffold: filter field plus a primary action up top, sectioned rows below, palette-style keyboard navigation (arrows move, Return activates, Escape closes). +struct PickerPanelRoot: View { + static let width: CGFloat = 360 + private static let headerHeight: CGFloat = 45 + private static let sectionTitleHeight: CGFloat = 25 + + let placeholder: String + let actionLabel: String? + let onAction: (() -> Void)? + let sections: [PickerSection] + var emptyText = "No matches" + let onDismiss: () -> Void + + @State private var query = "" + @State private var selectedIndex: Int? + @State private var hoveredID: String? + @FocusState private var isSearchFocused: Bool + + /// The panel is sized before SwiftUI lays out, so height comes from the declared row heights. + static func idealSize(sections: [PickerSection], width: CGFloat = Self.width) -> NSSize { + let rows = sections.flatMap(\.rows) + let content = rows.reduce(0) { $0 + $1.height } + + CGFloat(sections.filter { $0.title != nil }.count) * sectionTitleHeight + + headerHeight + 14 + return NSSize(width: width, height: min(max(content, 120), 480)) + } + + private var filteredSections: [PickerSection] { + let trimmed = query.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return sections } + return sections.compactMap { section in + let rows = section.rows.filter { $0.searchText.localizedCaseInsensitiveContains(trimmed) } + return rows.isEmpty ? nil : PickerSection(id: section.id, title: section.title, rows: rows) + } + } + + private var activatableRows: [PickerRow] { + filteredSections.flatMap(\.rows).filter { $0.action != nil } + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + rowList + } + .glassEffect(in: RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .onKeyPress(.upArrow) { + move(-1) + return .handled + } + .onKeyPress(.downArrow) { + move(1) + return .handled + } + .onKeyPress { press in + guard press.modifiers.contains(.control) else { return .ignored } + if press.characters == "p" { + move(-1) + return .handled + } + if press.characters == "n" { + move(1) + return .handled + } + return .ignored + } + .onKeyPress(.escape) { + onDismiss() + return .handled + } + .onAppear { isSearchFocused = true } + .onChange(of: query) { + // Highlight the first hit while filtering so Return activates it; no preselection when browsing. + selectedIndex = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : 0 + hoveredID = nil + } + } + + private var header: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + .frame(width: 14) + TextField(placeholder, text: $query) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .focused($isSearchFocused) + .onSubmit(activateSelection) + if let actionLabel, let onAction { + Button(actionLabel) { + onDismiss() + onAction() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(.horizontal, 12) + .frame(height: Self.headerHeight) + } + + private var rowList: some View { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: true) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(filteredSections) { section in + if let title = section.title { + Text(title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 14) + .padding(.top, 8) + .padding(.bottom, 3) + } + ForEach(section.rows) { row in + rowView(row) + } + } + if filteredSections.isEmpty { + Text(sections.isEmpty ? emptyText : "No matches") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + } + } + .padding(.vertical, 4) + } + .onChange(of: selectedIndex) { _, index in + guard let index, activatableRows.indices.contains(index) else { return } + proxy.scrollTo(activatableRows[index].id, anchor: .center) + } + } + } + + private func rowView(_ row: PickerRow) -> some View { + let highlighted = isHighlighted(row) + let identifier = AID.Picker.row(row.id) + return Group { + if let action = row.action { + Button { + onDismiss() + action() + } label: { + row.content(highlighted) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(identifier) + } else { + row.content(highlighted) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier(identifier) + } + } + .frame(height: row.height) + .onHover { hovering in + if hovering { + hoveredID = row.id + } else if hoveredID == row.id { + hoveredID = nil + } + } + .background( + RoundedRectangle(cornerRadius: 6) + .fill(highlighted ? Color.primary.opacity(0.07) : .clear) + .padding(.horizontal, 6) + ) + .modifier(RowContextMenu(builder: row.contextMenu)) + .id(row.id) + } + + private func isHighlighted(_ row: PickerRow) -> Bool { + if hoveredID == row.id { + return true + } + guard let selectedIndex, activatableRows.indices.contains(selectedIndex) else { return false } + return activatableRows[selectedIndex].id == row.id + } + + private func move(_ delta: Int) { + guard !activatableRows.isEmpty else { return } + let current = selectedIndex ?? (delta > 0 ? -1 : 0) + selectedIndex = max(0, min(activatableRows.count - 1, current + delta)) + hoveredID = nil + } + + private func activateSelection() { + guard let index = selectedIndex, activatableRows.indices.contains(index), let action = activatableRows[index].action else { return } + onDismiss() + action() + } +} + +private struct RowContextMenu: ViewModifier { + let builder: (() -> AnyView)? + + func body(content: Content) -> some View { + if let builder { + content.contextMenu { builder() } + } else { + content + } + } +} diff --git a/shell/mac/Sources/JayJay/Shared/QuarantinedDirectory.swift b/shell/mac/Sources/JayJay/Shared/QuarantinedDirectory.swift new file mode 100644 index 00000000..1b18ac35 --- /dev/null +++ b/shell/mac/Sources/JayJay/Shared/QuarantinedDirectory.swift @@ -0,0 +1,98 @@ +import Foundation + +/// A directory moved aside with a same-volume atomic rename before a destructive async operation, so the eventual delete can only touch the captured directory — never whatever occupies the original path by the time the operation completes. +struct QuarantinedDirectory: Sendable { + struct Identity: Equatable, Sendable { + let systemNumber: UInt64 + let fileNumber: UInt64 + } + + let originalURL: URL + let quarantineURL: URL + + static func identity(path: String) throws -> Identity { + let url = try validatedURL(path: path) + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + guard attributes[.type] as? FileAttributeType == .typeDirectory, + let systemNumber = attributes[.systemNumber] as? NSNumber, + let fileNumber = attributes[.systemFileNumber] as? NSNumber + else { + throw QuarantinedDirectoryError.notDirectory(path) + } + return Identity( + systemNumber: systemNumber.uint64Value, + fileNumber: fileNumber.uint64Value + ) + } + + /// Atomically moves the directory at `path` into a fresh temporary directory on the same volume, then verifies that the moved object is the one identified before core validation. + static func capture(path: String, expectedIdentity: Identity) throws -> QuarantinedDirectory { + let original = try validatedURL(path: path) + let container = try FileManager.default.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: original, + create: true + ) + let quarantine = container.appendingPathComponent(original.lastPathComponent, isDirectory: true) + try FileManager.default.moveItem(at: original, to: quarantine) + let captured = QuarantinedDirectory(originalURL: original, quarantineURL: quarantine) + do { + guard try identity(path: quarantine.path) == expectedIdentity else { + throw QuarantinedDirectoryError.identityChanged(path) + } + } catch { + do { + try captured.restore() + } catch { + throw QuarantinedDirectoryError.identityChangedAndPreserved(quarantine.path) + } + throw error + } + return captured + } + + private static func validatedURL(path: String) throws -> URL { + guard !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + NSString(string: path).isAbsolutePath + else { + throw QuarantinedDirectoryError.unsafePath(path) + } + let original = URL(fileURLWithPath: path).standardizedFileURL + guard original.path != "/" else { + throw QuarantinedDirectoryError.unsafePath(path) + } + return original + } + + /// Deletes the captured directory and its temporary container; the original path is left untouched. + func delete() throws { + try FileManager.default.removeItem(at: quarantineURL.deletingLastPathComponent()) + } + + /// Moves the captured directory back to its original path, for when the operation it was captured for fails. + func restore() throws { + try FileManager.default.moveItem(at: quarantineURL, to: originalURL) + try? FileManager.default.removeItem(at: quarantineURL.deletingLastPathComponent()) + } +} + +private enum QuarantinedDirectoryError: LocalizedError { + case identityChanged(String) + case identityChangedAndPreserved(String) + case notDirectory(String) + case unsafePath(String) + + var errorDescription: String? { + switch self { + case let .identityChanged(path): + "The workspace directory at \(path) changed after confirmation, so it was not deleted." + case let .identityChangedAndPreserved(path): + "The workspace directory changed after confirmation and could not be restored. It is preserved at:\n\(path)" + case let .notDirectory(path): + "The workspace path is not a directory: \(path)" + case let .unsafePath(path): + "Refusing to delete a workspace with an unsafe path: \(path.isEmpty ? "(empty)" : path)" + } + } +} diff --git a/shell/mac/Sources/JayJay/StatusBar/StatusBarItem.swift b/shell/mac/Sources/JayJay/StatusBar/StatusBarItem.swift index 3936b786..884a5c4d 100644 --- a/shell/mac/Sources/JayJay/StatusBar/StatusBarItem.swift +++ b/shell/mac/Sources/JayJay/StatusBar/StatusBarItem.swift @@ -4,24 +4,12 @@ enum StatusBarItem: Identifiable { case text(id: String, icon: String? = nil, text: String, tooltip: String? = nil) case link(id: String, icon: String, text: String, url: URL, tooltip: String? = nil) case action(id: String, icon: String, text: String, perform: () -> Void) - case picker(id: String, icon: String, label: String, options: [StatusBarPickerOption]) var id: String { switch self { case let .text(id, _, _, _): id case let .link(id, _, _, _, _): id case let .action(id, _, _, _): id - case let .picker(id, _, _, _): id } } } - -struct StatusBarPickerOption: Identifiable { - let id: String - let label: String - var icon: String? - var disabled: Bool = false - var destructive: Bool = false - var action: (() -> Void)? - var children: [StatusBarPickerOption]? -} diff --git a/shell/mac/Sources/JayJay/StatusBar/StatusBarItemView.swift b/shell/mac/Sources/JayJay/StatusBar/StatusBarItemView.swift index f4fecacc..d6b1a984 100644 --- a/shell/mac/Sources/JayJay/StatusBar/StatusBarItemView.swift +++ b/shell/mac/Sources/JayJay/StatusBar/StatusBarItemView.swift @@ -8,7 +8,9 @@ struct StatusBarItemView: View { switch item { case let .text(_, icon, text, tooltip): HStack(spacing: 3) { - if let icon { Image(systemName: icon).jayjayFont(10) } + if let icon { + Image(systemName: icon).jayjayFont(10) + } Text(text) .lineLimit(1) .truncationMode(.middle) @@ -27,7 +29,11 @@ struct StatusBarItemView: View { .buttonStyle(.plain) .help(tooltip ?? url.absoluteString) .onHover { inside in - if inside { NSCursor.pointingHand.push() } else { NSCursor.pop() } + if inside { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } } case let .action(_, icon, text, perform): @@ -38,66 +44,6 @@ struct StatusBarItemView: View { } } .buttonStyle(.plain) - - case let .picker(_, icon, label, options): - Image(systemName: icon).jayjayFont(10) - Menu { - ForEach(options) { option in - StatusBarPickerOptionView(option: option) - } - } label: { - Text(label) - .jayjayFont(11, weight: .medium, design: .monospaced) - .lineLimit(1) - } - .menuStyle(.borderlessButton) - .fixedSize() - } - } -} - -private struct StatusBarPickerOptionView: View { - let option: StatusBarPickerOption - - var body: some View { - if let children = option.children, !children.isEmpty { - Menu(option.label) { - ForEach(children) { child in - StatusBarPickerLeafView(option: child) - } - } - } else { - StatusBarPickerLeafView(option: option) - } - } -} - -private struct StatusBarPickerLeafView: View { - let option: StatusBarPickerOption - - var body: some View { - if let action = option.action { - if option.destructive { - Button(option.label, role: .destructive, action: action) - } else { - Button(action: action) { - if let icon = option.icon { - Label(option.label, systemImage: icon) - } else { - Text(option.label) - } - } - .disabled(option.disabled) - } - } else { - Button {} label: { - if let icon = option.icon { - Label(option.label, systemImage: icon) - } else { - Text(option.label) - } - } - .disabled(true) } } } diff --git a/shell/mac/Tests/JayJayTests/PickerPanelTests.swift b/shell/mac/Tests/JayJayTests/PickerPanelTests.swift new file mode 100644 index 00000000..4e9be031 --- /dev/null +++ b/shell/mac/Tests/JayJayTests/PickerPanelTests.swift @@ -0,0 +1,37 @@ +import AppKit +@testable import JayJay +import SwiftUI +import XCTest + +@MainActor +final class PickerPanelTests: XCTestCase { + func testHostWindowCloseDismissesPanelAndReleasesContent() { + _ = NSApplication.shared + let host = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let anchor = NSView(frame: NSRect(x: 20, y: 250, width: 120, height: 30)) + host.contentView = anchor + let panel = PickerPanel() + panel.animationBehavior = .none + panel.show( + under: anchor, + size: NSSize(width: 280, height: 200), + content: Text("Workspace picker") + ) + + XCTAssertTrue(panel.isVisible) + XCTAssertNotNil(panel.contentViewController) + XCTAssertTrue(host.childWindows?.contains(panel) == true) + + // Posting the notification exercises the production close observer without invoking AppKit window-transform animation inside the app-hosted XCTest process. + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: host) + + XCTAssertFalse(panel.isVisible) + XCTAssertNil(panel.contentViewController) + XCTAssertFalse(host.childWindows?.contains(panel) == true) + } +} diff --git a/shell/mac/Tests/JayJayTests/QuarantinedDirectoryTests.swift b/shell/mac/Tests/JayJayTests/QuarantinedDirectoryTests.swift new file mode 100644 index 00000000..cac12c4f --- /dev/null +++ b/shell/mac/Tests/JayJayTests/QuarantinedDirectoryTests.swift @@ -0,0 +1,69 @@ +@testable import JayJay +import XCTest + +final class QuarantinedDirectoryTests: XCTestCase { + private var workspace: URL! + + override func setUpWithError() throws { + workspace = FileManager.default.temporaryDirectory + .appendingPathComponent("quarantine-\(UUID().uuidString)/feature", isDirectory: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + try Data("contents".utf8).write(to: workspace.appendingPathComponent("file.txt")) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: workspace.deletingLastPathComponent()) + } + + func testDeleteOnlySeesTheCapturedDirectoryNotAReplacement() throws { + let identity = try QuarantinedDirectory.identity(path: workspace.path) + let quarantined = try QuarantinedDirectory.capture( + path: workspace.path, + expectedIdentity: identity + ) + XCTAssertFalse(FileManager.default.fileExists(atPath: workspace.path)) + + // Another process reuses the path while the async operation runs; the delete must not touch it. + let replacement = workspace.appendingPathComponent("precious.txt") + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: false) + try Data("keep me".utf8).write(to: replacement) + + try quarantined.delete() + + XCTAssertTrue(FileManager.default.fileExists(atPath: replacement.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: quarantined.quarantineURL.path)) + } + + func testRestorePutsTheDirectoryBack() throws { + let identity = try QuarantinedDirectory.identity(path: workspace.path) + let quarantined = try QuarantinedDirectory.capture( + path: workspace.path, + expectedIdentity: identity + ) + try quarantined.restore() + + let contents = try String(contentsOf: workspace.appendingPathComponent("file.txt"), encoding: .utf8) + XCTAssertEqual(contents, "contents") + XCTAssertFalse(FileManager.default.fileExists(atPath: quarantined.quarantineURL.path)) + } + + func testCaptureRejectsEmptyRelativeAndRootPaths() throws { + for path in ["", "relative-workspace", "/"] { + XCTAssertThrowsError(try QuarantinedDirectory.identity(path: path), path) + } + } + + func testCaptureRestoresAReplacementWhenItsIdentityChanged() throws { + let identity = try QuarantinedDirectory.identity(path: workspace.path) + try FileManager.default.removeItem(at: workspace) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: false) + let replacement = workspace.appendingPathComponent("precious.txt") + try Data("keep me".utf8).write(to: replacement) + + XCTAssertThrowsError( + try QuarantinedDirectory.capture(path: workspace.path, expectedIdentity: identity) + ) + + XCTAssertEqual(try String(contentsOf: replacement, encoding: .utf8), "keep me") + } +} diff --git a/shell/mac/Tests/JayJayTests/RepoListGroupingTests.swift b/shell/mac/Tests/JayJayTests/RepoListGroupingTests.swift new file mode 100644 index 00000000..3e564a7d --- /dev/null +++ b/shell/mac/Tests/JayJayTests/RepoListGroupingTests.swift @@ -0,0 +1,56 @@ +@testable import JayJay +import XCTest + +final class RepoListGroupingTests: XCTestCase { + private let resolutions: [String: RepoPathResolution] = [ + "/work/main": .init(canonicalPath: "/work/main", primaryRoot: "/work/main"), + "/work/agent-b": .init(canonicalPath: "/work/agent-b", primaryRoot: "/work/main"), + "/work/agent-a": .init(canonicalPath: "/work/agent-a", primaryRoot: "/work/main"), + "/work/orphan-ws": .init(canonicalPath: "/work/orphan-ws", primaryRoot: "/gone/main"), + "/work/other": .init(canonicalPath: "/work/other", primaryRoot: "/work/other") + ] + + func testRecentWorkspacesNestUnderTheirListedRootSortedByName() { + let result = RepoListGrouping.groups( + pinned: ["/work/main"], + recents: ["/work/agent-b", "/work/other", "/work/agent-a"], + resolutions: resolutions + ) + XCTAssertEqual(result.pinned.map(\.path), ["/work/main"]) + XCTAssertEqual(result.pinned[0].workspaces, ["/work/agent-a", "/work/agent-b"]) + XCTAssertEqual(result.recent.map(\.path), ["/work/other"]) + } + + func testWorkspacesWithoutAListedRootStayFlat() { + let result = RepoListGrouping.groups( + pinned: [], + recents: ["/work/orphan-ws", "/not-a-repo"], + resolutions: resolutions + ) + XCTAssertEqual(result.recent.map(\.path), ["/work/orphan-ws", "/not-a-repo"]) + XCTAssertTrue(result.recent.allSatisfy(\.workspaces.isEmpty)) + } + + func testPinnedWorkspaceStaysTopLevel() { + let result = RepoListGrouping.groups( + pinned: ["/work/agent-a"], + recents: ["/work/main", "/work/agent-b"], + resolutions: resolutions + ) + XCTAssertEqual(result.pinned.map(\.path), ["/work/agent-a"]) + XCTAssertEqual(result.recent.map(\.path), ["/work/main"]) + XCTAssertEqual(result.recent[0].workspaces, ["/work/agent-b"]) + } + + /// Lookups resolve asynchronously, so entries render flat until their resolutions arrive. + func testUnresolvedPathsRenderFlat() { + let result = RepoListGrouping.groups( + pinned: ["/work/main"], + recents: ["/work/agent-a"], + resolutions: [:] + ) + XCTAssertEqual(result.pinned.map(\.path), ["/work/main"]) + XCTAssertEqual(result.recent.map(\.path), ["/work/agent-a"]) + XCTAssertTrue(result.pinned[0].workspaces.isEmpty) + } +} diff --git a/shell/mac/Tests/JayJayTests/RepoViewModelRefreshTests.swift b/shell/mac/Tests/JayJayTests/RepoViewModelRefreshTests.swift new file mode 100644 index 00000000..5720ca2f --- /dev/null +++ b/shell/mac/Tests/JayJayTests/RepoViewModelRefreshTests.swift @@ -0,0 +1,60 @@ +@testable import JayJay +import JayJayCore +import XCTest + +@MainActor +final class RepoViewModelRefreshTests: RepoViewModelTestCase { + func testCancelledFailureProbeCannotOverwriteNewerRefreshState() async throws { + let viewModel = try XCTUnwrap(viewModel) + let probe = BlockingWorkspacePresenceProbe() + viewModel.isLoading = true + viewModel.isRefreshingInFlight = true + + let staleRefresh = viewModel.startLifecycleRepoTask { [viewModel] in + await viewModel.handleRefreshFailure(TestRefreshError.failed) { + probe.run() + } + } + while !probe.hasStarted { + await Task.yield() + } + + staleRefresh.cancel() + viewModel.isLoading = false + viewModel.isRefreshingInFlight = false + viewModel.error = "newer refresh" + probe.finish() + await staleRefresh.value + + XCTAssertFalse(viewModel.workspaceVanished) + XCTAssertEqual(viewModel.error, "newer refresh") + } +} + +private enum TestRefreshError: Error { + case failed +} + +private final class BlockingWorkspacePresenceProbe: @unchecked Sendable { + private let lock = NSLock() + private let release = DispatchSemaphore(value: 0) + private var started = false + + var hasStarted: Bool { + lock.lock() + defer { lock.unlock() } + return started + } + + func run() -> WorkspacePresence { + lock.lock() + started = true + lock.unlock() + release.wait() + return .gone + } + + func finish() { + release.signal() + } +} diff --git a/shell/mac/Tests/JayJayTests/RepoViewModelShutdownTests.swift b/shell/mac/Tests/JayJayTests/RepoViewModelShutdownTests.swift new file mode 100644 index 00000000..d44338b6 --- /dev/null +++ b/shell/mac/Tests/JayJayTests/RepoViewModelShutdownTests.swift @@ -0,0 +1,123 @@ +@testable import JayJay +import XCTest + +@MainActor +final class RepoViewModelShutdownTests: RepoViewModelTestCase { + /// Deleting a workspace moves its directory; the barrier must outwait any repo task that could still be reading or mutating the checkout. + func testPrepareForRemovalWaitsForInFlightRepoTasks() async throws { + let viewModel = try XCTUnwrap(viewModel) + let completed = LockedFlag() + viewModel.runRepoTask { _ in + Thread.sleep(forTimeInterval: 0.2) + completed.set() + } onSuccess: { + _, _ in + } + + await viewModel.prepareForRemoval() + + XCTAssertTrue(completed.isSet, "prepareForRemoval returned before an in-flight repo task finished") + XCTAssertTrue(viewModel.inFlightRepoTasks.isEmpty) + } + + func testPrepareForRemovalWaitsForAwaitedRepoTasks() async throws { + let viewModel = try XCTUnwrap(viewModel) + let started = LockedFlag() + let completed = LockedFlag() + let operation = Task { @MainActor in + try await viewModel.awaitRepoTask { _ in + started.set() + Thread.sleep(forTimeInterval: 0.2) + completed.set() + } + } + while !started.isSet { + await Task.yield() + } + + await viewModel.prepareForRemoval() + + try await operation.value + XCTAssertTrue(completed.isSet, "prepareForRemoval returned before an awaited repo task finished") + XCTAssertTrue(viewModel.inFlightRepoTasks.isEmpty) + } + + func testPrepareForRemovalWaitsForSupersededLifecycleTasks() async throws { + let viewModel = try XCTUnwrap(viewModel) + let firstCompleted = LockedFlag() + let secondCompleted = LockedFlag() + let first = viewModel.startLifecycleRepoTask { + Thread.sleep(forTimeInterval: 0.2) + firstCompleted.set() + } + viewModel.refreshTask = first + first.cancel() + viewModel.refreshTask = viewModel.startLifecycleRepoTask { + Thread.sleep(forTimeInterval: 0.1) + secondCompleted.set() + } + + await viewModel.prepareForRemoval() + + XCTAssertTrue(firstCompleted.isSet, "prepareForRemoval returned before superseded lifecycle work finished") + XCTAssertTrue(secondCompleted.isSet, "prepareForRemoval returned before the latest lifecycle work finished") + XCTAssertTrue(viewModel.lifecycleRepoTasks.isEmpty) + } + + func testShutdownRefusesNewRepoWork() async throws { + let viewModel = try XCTUnwrap(viewModel) + await viewModel.prepareForRemoval() + + let ran = LockedFlag() + viewModel.runRepoTask { + _ in ran.set() + } onSuccess: { + _, _ in + } + viewModel.refresh() + + try await Task.sleep(for: .milliseconds(100)) + XCTAssertFalse(ran.isSet, "a repo task started after the shutdown barrier") + XCTAssertTrue(viewModel.inFlightRepoTasks.isEmpty) + XCTAssertNil(viewModel.refreshTask) + } + + func testShutdownRefusesNewPullRequestFetches() async throws { + let viewModel = try XCTUnwrap(viewModel) + await viewModel.prepareForRemoval() + + viewModel.fetchPrInfo(bookmarks: ["feature"]) + + XCTAssertNil(viewModel.prFetchTask) + } + + func testShutdownRefusesNewAwaitedRepoWork() async throws { + let viewModel = try XCTUnwrap(viewModel) + await viewModel.prepareForRemoval() + + do { + _ = try await viewModel.awaitRepoTask { _ in () } + XCTFail("an awaited repo task started after the shutdown barrier") + } catch is CancellationError { + XCTAssertTrue(viewModel.inFlightRepoTasks.isEmpty) + } + } +} + +/// The operation runs off the main actor, so the completion signal needs its own lock. +private final class LockedFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + lock.lock() + value = true + lock.unlock() + } + + var isSet: Bool { + lock.lock() + defer { lock.unlock() } + return value + } +} diff --git a/shell/mac/Tests/JayJayTests/RepoWindowManagerTests.swift b/shell/mac/Tests/JayJayTests/RepoWindowManagerTests.swift new file mode 100644 index 00000000..9a31c4e0 --- /dev/null +++ b/shell/mac/Tests/JayJayTests/RepoWindowManagerTests.swift @@ -0,0 +1,87 @@ +import AppKit +@testable import JayJay +import JayJayCore +import XCTest + +@MainActor +final class RepoWindowManagerTests: XCTestCase { + func testOverlappingRemovalsKeepRepositoryClosedUntilLastCompletion() async throws { + _ = NSApplication.shared + let suiteName = "RepoWindowManagerTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let manager = RepoWindowManager(settings: AppSettings(defaults: defaults)) + var openedPaths: [String] = [] + manager.setWindowActions( + openRepo: { openedPaths.append($0) }, + showRepoList: { _ in } + ) + let path = FileManager.default.temporaryDirectory + .appending(path: "jayjay-overlapping-removals-\(UUID().uuidString)") + .path + + await manager.closeRepoWindowForWorkspaceRemoval(at: path) + await manager.closeRepoWindowForWorkspaceRemoval(at: path) + manager.finishWorkspaceRemoval(at: path) + manager.openRepo(path) + + XCTAssertTrue(openedPaths.isEmpty) + + manager.finishWorkspaceRemoval(at: path) + manager.openRepo(path) + + XCTAssertEqual(openedPaths.count, 1) + } + + func testVanishedWorkspaceCloseWaitsForRepoTasksAndReleasesOpenBarrier() async throws { + _ = NSApplication.shared + let suiteName = "RepoWindowManagerTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let manager = RepoWindowManager(settings: AppSettings(defaults: defaults)) + var openedPaths: [String] = [] + manager.setWindowActions( + openRepo: { openedPaths.append($0) }, + showRepoList: { _ in } + ) + let directory = FileManager.default.temporaryDirectory + .appending(path: "jayjay-vanished-workspace-close-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + try initJjGitRepo(path: directory.path) + let result = await manager.loadRepoViewModel( + at: directory.path, + includeSubmoduleStatuses: false + ) + let viewModel = try XCTUnwrap(result.viewModel, result.error ?? "load repo view model") + let completed = RepoWindowManagerLockedFlag() + viewModel.runRepoTask { _ in + Thread.sleep(forTimeInterval: 0.2) + completed.set() + } onSuccess: { _, _ in } + + await manager.closeRepoWindowAfterWorkspaceVanished(at: directory.path) + + XCTAssertTrue(completed.isSet, "the vanished-workspace close returned before repo work finished") + XCTAssertTrue(viewModel.isShuttingDown) + manager.openRepo(directory.path) + XCTAssertEqual(openedPaths.count, 1) + } +} + +private final class RepoWindowManagerLockedFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + lock.lock() + value = true + lock.unlock() + } + + var isSet: Bool { + lock.lock() + defer { lock.unlock() } + return value + } +} diff --git a/shell/mac/Tests/JayJayUITests/Scenes/RepoListInitialRepositoryScene.swift b/shell/mac/Tests/JayJayUITests/Scenes/RepoListInitialRepositoryScene.swift index 61f09d53..fedd73bc 100644 --- a/shell/mac/Tests/JayJayUITests/Scenes/RepoListInitialRepositoryScene.swift +++ b/shell/mac/Tests/JayJayUITests/Scenes/RepoListInitialRepositoryScene.swift @@ -7,7 +7,7 @@ final class RepoListInitialRepositoryScene: SceneBase { func testPinnedRepositoryOpensInNewWindow() throws { let app = try XCTUnwrap(app) - _ = try openPinnedRepository(in: app) + _ = openPinnedRepository(in: app) } func testClosingSoleInitialRepositoryWindowShowsRepoList() throws { @@ -36,14 +36,14 @@ final class RepoListInitialRepositoryScene: SceneBase { let reopened = app.windows["simple"] XCTAssertTrue(reopened.waitForExistence(timeout: 10), "Reopening from the list did not open a repository window") XCTAssertTrue( - reopened.toolbars.menuButtons["Switch Repository"].waitForExistence(timeout: 10), + repositoryTitleButton(in: reopened).waitForExistence(timeout: 10), "Reopened window is not a functional repository window" ) } func testRepositoryListWorksAfterMainWindowCloses() throws { let app = try XCTUnwrap(app) - let (mainWindow, pinnedWindow) = try openPinnedRepository(in: app) + let (mainWindow, pinnedWindow) = openPinnedRepository(in: app) app.menuBars.menuBarItems["Window"].click() let mainWindowMenuItems = app.menuItems.matching(identifier: "simple") @@ -79,22 +79,25 @@ final class RepoListInitialRepositoryScene: SceneBase { XCTAssertEqual(app.windows.count, 1, "Opening the repository list duplicated the initial window") } - private func openPinnedRepository(in app: XCUIApplication) throws -> (XCUIElement, XCUIElement) { + private func openPinnedRepository(in app: XCUIApplication) -> (XCUIElement, XCUIElement) { let repoWindow = app.windows["simple"] XCTAssertTrue(repoWindow.waitForExistence(timeout: 5), "Initial repository window missing") - openRepositoryTitleMenu(in: repoWindow) - - let pinnedRepos = app.menuItems.matching(identifier: "formats") - XCTAssertTrue(pinnedRepos.firstMatch.waitForExistence(timeout: 3), "Pinned repository menu item missing") - let pinnedRepo = try XCTUnwrap( - pinnedRepos.allElementsBoundByIndex.first(where: \.isHittable), - "Pinned repository menu item was not actionable" - ) + openRepositoryTitlePicker(in: repoWindow) + + // The row id carries the pinned repository's absolute path, which differs per fixture root. + let pinnedRepo = app.buttons.matching( + NSPredicate( + format: "identifier BEGINSWITH %@ AND identifier ENDSWITH %@", + AID.Picker.row("repo-"), + "/formats" + ) + ).firstMatch + XCTAssertTrue(pinnedRepo.waitForExistence(timeout: 3), "Pinned repository row missing") pinnedRepo.click() XCTAssertTrue( app.windows["formats"].waitForExistence(timeout: 10), - "Pinned repository did not open after the title menu closed" + "Pinned repository did not open after the picker closed" ) return (repoWindow, app.windows["formats"]) } diff --git a/shell/mac/Tests/JayJayUITests/Support/SceneBase.swift b/shell/mac/Tests/JayJayUITests/Support/SceneBase.swift index 5e259d04..56085869 100644 --- a/shell/mac/Tests/JayJayUITests/Support/SceneBase.swift +++ b/shell/mac/Tests/JayJayUITests/Support/SceneBase.swift @@ -87,21 +87,28 @@ class SceneBase: XCTestCase { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).rightClick() } - func openRepositoryTitleMenu(in window: XCUIElement) { - let titleMenu = window.toolbars.menuButtons["Switch Repository"].firstMatch - XCTAssertTrue(titleMenu.waitForExistence(timeout: 10), "Repository title menu missing") - titleMenu.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 1.2)).click() + /// Matches RepoTitlePicker's accessibility label; SwiftUI toolbar items expose labels, not identifiers. + static let repositoryTitleLabel = "Switch Repository or Workspace" + + func repositoryTitleButton(in window: XCUIElement) -> XCUIElement { + window.toolbars.buttons[Self.repositoryTitleLabel].firstMatch + } + + func openRepositoryTitlePicker(in window: XCUIElement) { + let titleButton = repositoryTitleButton(in: window) + XCTAssertTrue(titleButton.waitForExistence(timeout: 10), "Repository title button missing") + titleButton.click() } func chooseRepositoryList(in app: XCUIApplication, from window: XCUIElement) { - let repoList = app.menuItems["Repository List..."] - openRepositoryTitleMenu(in: window) + let repoList = app.buttons[AID.Picker.row("repo-list")].firstMatch + openRepositoryTitlePicker(in: window) if !repoList.waitForExistence(timeout: 3) { - // AppKit can swallow the first toolbar-menu click while another window is finishing its close transition. + // AppKit can swallow the first toolbar click while another window is finishing its close transition. keyStroke(.escape) - openRepositoryTitleMenu(in: window) + openRepositoryTitlePicker(in: window) } - XCTAssertTrue(repoList.waitForExistence(timeout: 3), "Repository List menu item missing") + XCTAssertTrue(repoList.waitForExistence(timeout: 3), "Repository List row missing") repoList.click() }