diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db667675c..90fd08d4d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added +* commit log graph visualizing branch topology [[@philocalyst](https://github.com/philocalyst)] + ### Changed * use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting * open the external editor from the status diff view [[@WaterWhisperer](https://github.com/WaterWhisperer)] ([#2805](https://github.com/gitui-org/gitui/issues/2805)) diff --git a/Cargo.lock b/Cargo.lock index 1534622d6a..54b06564dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,9 +127,18 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] [[package]] name = "arc-swap" @@ -150,7 +159,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" name = "asyncgit" version = "0.28.1" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "crossbeam-channel", "dirs", "easy-cast", @@ -168,6 +177,7 @@ dependencies = [ "scopetime", "serde", "serial_test", + "smallvec", "ssh-key", "tempfile", "thiserror 2.0.18", @@ -281,9 +291,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -353,6 +363,12 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.0" @@ -425,9 +441,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", "num-traits", @@ -485,9 +501,9 @@ checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -500,14 +516,13 @@ dependencies = [ [[package]] name = "console" -version = "0.15.11" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "once_cell", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -589,7 +604,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "crossterm_winapi", "mio", "parking_lot", @@ -606,7 +621,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "crossterm_winapi", "derive_more", "document-features", @@ -939,9 +954,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.0" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" dependencies = [ "log", "regex", @@ -955,9 +970,9 @@ checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" dependencies = [ "anstream", "anstyle", @@ -1012,6 +1027,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "faster-hex" version = "0.10.0" @@ -1277,7 +1298,7 @@ version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "libc", "libgit2-sys", "log", @@ -1318,7 +1339,7 @@ dependencies = [ "asyncgit", "backtrace", "base64", - "bitflags 2.10.0", + "bitflags 2.13.0", "bugreport", "bwrap", "bytesize", @@ -1513,7 +1534,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "563361198101cedc975fe5760c91ac2e4126eec22216e81b659b45289feaf1ea" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "bstr", "gix-path", "libc", @@ -1662,7 +1683,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b03e6cd88cc0dc1eafa1fddac0fb719e4e74b6ea58dd016e71125fde4a326bee" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "bstr", "gix-features", "gix-path", @@ -1710,7 +1731,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e31c6b3664efe5916c539c50e610f9958f2993faf8e29fa5a40fb80b6ac8486a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "bstr", "filetime", "fnv", @@ -1846,7 +1867,7 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3df6fd8e514d8b99ec5042ee17909a17750ccf54d0b8b30c850954209c800322" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "bstr", "gix-attributes", "gix-config-value", @@ -1928,7 +1949,7 @@ version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c719cf7d669439e1fca735bd1c4de54d43c5d30e8883fd6063c4924b213d70c9" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "bstr", "gix-commitgraph", "gix-date", @@ -1962,7 +1983,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "beeb3bc63696cf7acb5747a361693ebdbcaf25b5d27d2308f38e9782983e7bce" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "gix-path", "libc", "windows-sys 0.61.2", @@ -2059,7 +2080,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37f8b53b4c56b01c43a4491c4edfe2ce66c654eb86232205172ceb1650d21c55" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "gix-commitgraph", "gix-date", "gix-hash", @@ -2166,6 +2187,17 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "heapless" version = "0.8.0" @@ -2404,7 +2436,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "inotify-sys", "libc", ] @@ -2430,14 +2462,16 @@ dependencies = [ [[package]] name = "insta" -version = "1.44.3" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ "console", "once_cell", "regex", "similar", + "strip-ansi-escapes", + "tempfile", ] [[package]] @@ -2530,9 +2564,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" dependencies = [ "once_cell", "wasm-bindgen", @@ -2630,7 +2664,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "libc", "redox_syscall", ] @@ -2663,11 +2697,11 @@ dependencies = [ [[package]] name = "line-clipping" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", ] [[package]] @@ -2711,11 +2745,11 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.16.3" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -2802,7 +2836,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -2825,7 +2859,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "fsevent-sys", "inotify", "kqueue", @@ -2883,9 +2917,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -2964,7 +2998,7 @@ version = "6.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "libc", "once_cell", "onig_sys", @@ -3067,6 +3101,30 @@ dependencies = [ "sha2", ] +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -3422,9 +3480,9 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.30.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "1695748e3a735b34968c887ceea5a380b43545903868ae8f5b666593100f6b68" dependencies = [ "instability", "ratatui-core", @@ -3437,17 +3495,18 @@ dependencies = [ [[package]] name = "ratatui-core" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "compact_str", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "indoc", "itertools", "kasuari", "lru", + "palette", "serde", "strum", "thiserror 2.0.18", @@ -3458,9 +3517,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c" dependencies = [ "cfg-if", "crossterm 0.28.1", @@ -3471,9 +3530,9 @@ dependencies = [ [[package]] name = "ratatui-termion" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cade85a8591fbc911e147951422f0d6fd40f4948b271b6216c7dc01838996f8" +checksum = "5c16cc35a9d9114e0b2bb4b22018b96ae7f5fe60e2595dc73e622b4e78624835" dependencies = [ "instability", "ratatui-core", @@ -3482,9 +3541,9 @@ dependencies = [ [[package]] name = "ratatui-termwiz" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +checksum = "386b8ff8f74ed749509391c56d549761a2fcdb408e1f42e467286bcb7dac8967" dependencies = [ "ratatui-core", "termwiz", @@ -3504,12 +3563,12 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "7ef4f17dd7ac3abf5adc2b920a03c61eee4bfe6a88fa5191936895525371d79c" dependencies = [ - "bitflags 2.10.0", - "hashbrown 0.16.1", + "bitflags 2.13.0", + "hashbrown 0.17.1", "indoc", "instability", "itertools", @@ -3548,7 +3607,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", ] [[package]] @@ -3607,7 +3666,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "once_cell", "serde", "serde_derive", @@ -3657,7 +3716,7 @@ version = "0.38.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3670,7 +3729,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.11.0", @@ -4029,6 +4088,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -4057,18 +4125,18 @@ dependencies = [ [[package]] name = "strum" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", @@ -4203,7 +4271,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.10.0", + "bitflags 2.13.0", "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", @@ -4348,9 +4416,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "two-face" -version = "0.4.5" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39e51b6e60e545cfdae5a4639ff423818f52372211a8d9a3e892b4b0761f76b2" +checksum = "3d112cfd41c1387546416bcf49c4ae2a1fcacda0d42c9e97120e9798c90c0923" dependencies = [ "serde", "serde_derive", @@ -4481,9 +4549,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "atomic", "getrandom 0.4.2", @@ -4504,6 +4572,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "vtparse" version = "0.6.2" @@ -4549,22 +4626,34 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" dependencies = [ "cfg-if", "once_cell", - "rustversion", "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4572,25 +4661,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" dependencies = [ - "bumpalo", "proc-macro2", "quote", "syn 2.0.117", + "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" -dependencies = [ - "unicode-ident", -] +checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" [[package]] name = "wasm-encoder" @@ -4620,7 +4706,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "hashbrown 0.15.2", "indexmap", "semver", @@ -5054,7 +5140,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.10.0", + "bitflags 2.13.0", "indexmap", "log", "serde", diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml index 5e77498ae3..861e676152 100644 --- a/asyncgit/Cargo.toml +++ b/asyncgit/Cargo.toml @@ -34,6 +34,7 @@ rayon = "1.11" rayon-core = "1.13" scopetime = { path = "../scopetime", version = "0.1" } serde = { version = "1.0", features = ["derive"] } +smallvec = "1" ssh-key = { version = "0.6.7", features = ["crypto", "encryption"] } thiserror = "2.0" unicode-truncate = "2.0" diff --git a/asyncgit/src/graph/buffer.rs b/asyncgit/src/graph/buffer.rs new file mode 100644 index 0000000000..402ea24996 --- /dev/null +++ b/asyncgit/src/graph/buffer.rs @@ -0,0 +1,281 @@ +use super::chunk::{Chunk, Markers}; +use super::AliasId; +use std::collections::BTreeMap; + +/// A single mutation of the lane state, recorded while processing one +/// commit. +#[derive(Clone, Debug)] +pub enum DeltaOp { + Insert { index: usize, item: Option }, + Remove { index: usize }, + Replace { index: usize, new: Option }, +} + +/// All lane-state mutations caused by processing a single commit. +/// Applying a `Delta` to the lane state of row `n` yields the lane +/// state of row `n + 1`. +#[derive(Clone, Debug)] +pub struct Delta(pub Vec); + +const CHECKPOINT_INTERVAL: usize = 100; + +/// Delta-compressed history of the graph's lane state. +/// +/// While walking the log top-down, every commit mutates the set of +/// active lanes (a `Vec>`, one slot per lane). So, storign a +/// full copy of that state for each commit is a waste. This +/// buffer preserves ONLY the latest state PLUS the list of [`Delta`]s that +/// produced it. +/// Use [`Buffer::decompress`] to get the complete version. +pub struct Buffer { + /// Lane state after the most recently processed commit. + pub current: Vec>, + + /// One [`Delta`] per processed commit, in the order of the walk. + pub deltas: Vec, + + /// Full lane-state snapshots taken every `CHECKPOINT_INTERVAL` + /// commits, keyed by delta index, for reducing decompression cost. + pub checkpoints: BTreeMap>>, + + /// Aliases of merge commits whose second parent still needs a new + /// lane. + merge_commits: Vec, + + /// Scratch list of the [`DeltaOp`]s recorded for processing commit + pending_delta: Vec, +} + +impl Default for Buffer { + fn default() -> Self { + Self::new() + } +} + +impl Buffer { + pub const fn new() -> Self { + Self { + current: Vec::new(), + deltas: Vec::new(), + checkpoints: BTreeMap::new(), + merge_commits: Vec::new(), + pending_delta: Vec::new(), + } + } + + /// Remember `alias` as a merge commit whose second parent must be + /// given its own lane. + pub fn track_merge_commit(&mut self, alias: AliasId) { + self.merge_commits.push(alias); + } + + pub fn update(&mut self, new_chunk: &Chunk) { + // Phase 1: place the new chunk into the lane array. + let placement_index = self.place_chunk(new_chunk); + + // Phase 2: consume the alias in all other live chunks. + if let Some(alias) = new_chunk.alias { + self.consume_alias_in_other_chunks( + alias, + placement_index, + ); + } + + // Phase 3: flush any pending merge commits into new lanes. + self.flush_merge_commits(); + + // Phase 4: commit the delta and maybe checkpoint. + self.commit_delta(); + } + + fn place_chunk(&mut self, new_chunk: &Chunk) -> usize { + // Prefer a lane whose current occupant is waiting for this chunk as parent_a. + let target = self + .find_lane_awaiting_parent(new_chunk.alias) + .or_else(|| self.first_empty_lane()) + .unwrap_or(self.current.len()); + + if target < self.current.len() { + self.record_replace(target, Some(new_chunk.clone())); + } else { + self.record_insert(target, Some(new_chunk.clone())); + } + target + } + + fn find_lane_awaiting_parent( + &self, + alias: Option, + ) -> Option { + let alias = alias?; + self.current.iter().position(|slot| { + slot.as_ref() + .is_some_and(|chunk| chunk.parent_a == Some(alias)) + }) + } + + fn first_empty_lane(&self) -> Option { + self.current.iter().position(Option::is_none) + } + + fn consume_alias_in_other_chunks( + &mut self, + alias: AliasId, + skip_index: usize, + ) { + for index in 0..self.current.len() { + let mut chunk = match self.current[index].clone() { + Some(chunk) if index != skip_index => chunk, + _ => continue, + }; + + let changed_a = chunk.parent_a == Some(alias); + let changed_b = chunk.parent_b == Some(alias); + + if changed_a || changed_b { + if changed_a { + chunk.parent_a = None; + } + if changed_b { + chunk.parent_b = None; + } + + self.record_replace( + index, + chunk.parent_a.is_some().then_some(chunk), + ); + } + } + } + + fn flush_merge_commits(&mut self) { + while let Some(alias) = self.merge_commits.pop() { + // Search for an occupied slot that matches the target alias. + // If found, extract its index and a mutable clone of the chunk. + let Some((index, mut chunk)) = + self.current.iter().enumerate().find_map( + |(index, slot)| { + let chunk = slot.as_ref()?; + (chunk.alias == Some(alias)) + .then(|| (index, chunk.clone())) + }, + ) + else { + continue; + }; + + let detached_parent = chunk.parent_b.take(); + self.record_replace(index, Some(chunk)); + + if let Some(parent) = detached_parent { + let new_lane = Chunk { + alias: None, + parent_a: Some(parent), + parent_b: None, + marker: Markers::Commit, + }; + + // Always append the merge's second-parent lane to + // the end instead of reusing an existing empty slot, + // so the new visual column does not collapse + // spatial ordering of lanes already in existence. + self.record_insert( + self.current.len(), + Some(new_lane), + ); + } + } + } + + fn commit_delta(&mut self) { + while matches!(self.current.last(), Some(None)) { + let last = self.current.len() - 1; + self.record_remove(last); + } + + self.deltas + .push(Delta(std::mem::take(&mut self.pending_delta))); + + let step = self.deltas.len(); + if step.is_multiple_of(CHECKPOINT_INTERVAL) { + self.checkpoints.insert(step - 1, self.current.clone()); + } + } + + fn record_replace(&mut self, index: usize, new: Option) { + self.pending_delta.push(DeltaOp::Replace { + index, + new: new.clone(), + }); + self.current[index] = new; + } + + fn record_insert(&mut self, index: usize, item: Option) { + self.pending_delta.push(DeltaOp::Insert { + index, + item: item.clone(), + }); + self.current.insert(index, item); + } + + fn record_remove(&mut self, index: usize) { + self.pending_delta.push(DeltaOp::Remove { index }); + self.current.remove(index); + } + + pub fn decompress( + &self, + start: usize, + end: usize, + ) -> Vec>> { + let (current_index, mut state) = + self.checkpoints.range(..=start).next_back().map_or_else( + || (None, Vec::new()), + |(&i, s)| (Some(i), s.clone()), + ); + + let mut history = + Vec::with_capacity(end.saturating_sub(start) + 1); + + if let Some(index) = current_index { + if index >= start && index <= end { + history.push(state.clone()); + } + } + + let loop_start = current_index.map_or(0, |i| i + 1); + + for delta_index in loop_start..=end { + if let Some(delta) = self.deltas.get(delta_index) { + Self::apply_delta_to_state(&mut state, delta); + + if delta_index >= start { + history.push(state.clone()); + } + } else { + break; + } + } + + history + } + + fn apply_delta_to_state( + state: &mut Vec>, + delta: &Delta, + ) { + for op in &delta.0 { + match op { + DeltaOp::Insert { index, item } => { + state.insert(*index, item.clone()); + } + DeltaOp::Remove { index } => { + state.remove(*index); + } + DeltaOp::Replace { index, new } => { + state[*index].clone_from(new); + } + } + } + } +} diff --git a/asyncgit/src/graph/chunk.rs b/asyncgit/src/graph/chunk.rs new file mode 100644 index 0000000000..20748330a9 --- /dev/null +++ b/asyncgit/src/graph/chunk.rs @@ -0,0 +1,15 @@ +use super::AliasId; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Markers { + Uncommitted, + Commit, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Chunk { + pub alias: Option, + pub parent_a: Option, + pub parent_b: Option, + pub marker: Markers, +} diff --git a/asyncgit/src/graph/mod.rs b/asyncgit/src/graph/mod.rs new file mode 100644 index 0000000000..a69e89c2a7 --- /dev/null +++ b/asyncgit/src/graph/mod.rs @@ -0,0 +1,108 @@ +pub mod buffer; +pub mod chunk; +pub mod oids; +pub mod walker; + +pub use walker::GraphWalker; + +/// The maximum number of colors to use for graph lanes +pub const MAX_LANE_COLORS: usize = 16; + +// Yes, there are repositories where this is exceeded +// Are they very rare? Yes. +// On most terminals can more than 256 lanes even be represneted usefully? Not really. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct LaneIndex(u8); + +/// Numeric alias assigned to each commit in the graph. +/// +/// The alias is a dense integer index created by [`GraphOids`](super::oids::GraphOids) +/// that avoids storing full [`CommitId`](crate::sync::CommitId)s inside the lane state. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct AliasId(usize); + +impl std::ops::Deref for AliasId { + type Target = usize; + fn deref(&self) -> &usize { + &self.0 + } +} + +impl From for AliasId { + fn from(v: usize) -> Self { + Self(v) + } +} + +impl From for LaneIndex { + fn from(lane: usize) -> Self { + Self(u8::try_from(lane).unwrap_or(u8::MAX)) + } +} + +impl From for usize { + fn from(lane: LaneIndex) -> Self { + lane.0 as Self + } +} + +/// The type of connection between nodes in the graph. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConnectionType { + Vertical, + VerticalDotted, + CommitNormal, + CommitBranch, + CommitMerge, + CommitStash, + CommitUncommitted, + /// a bridge turning down into a lane that starts here + MergeBridgeStart, + /// a bridge passing over an empty lane slot + MergeBridgeMid, + /// a bridge turning down, lane starting to its right + MergeBridgeEnd, + /// a lane from above turning left into a commit + BranchUp, + /// a lane from above turning right into a commit + BranchUpRight, + /// a continuing lane absorbing a bridge from its left + TeeLeft, + /// a continuing lane absorbing a bridge from its right + TeeRight, + /// a lane ending from above while a bridge passes through + TeeUp, + /// a lane starting downward while a bridge passes through + TeeDown, +} + +#[derive(Clone, Debug, Default)] +pub struct GraphRow { + /// Number of active lanes at this commit row + pub lane_count: LaneIndex, + + /// Which lane index this commit sits on + pub commit_lane: LaneIndex, + + /// Whether this is a merge commit (two parents) + pub is_merge: bool, + + /// Whether this commit is a branch tip + pub is_branch_tip: bool, + + /// Whether this commit has stash marker + pub is_stash: bool, + + /// Connections emitted per lane: + /// None = empty space + /// Some((ConnectionType, `color_index`)) = draw this connector in this color + pub lanes: Vec>, + + /// Horizontal merge bridge: if this commit merges rightward, + /// (`from_lane`, `to_lane`) — the span to draw ─ ╭ ╮ across + pub merge_bridge: Option<(LaneIndex, LaneIndex)>, + + /// Horizontal branch bridges: if this commit spawns branches, + /// spans to draw ─ ╭ ╮ across + pub branches: Vec<(LaneIndex, LaneIndex)>, +} diff --git a/asyncgit/src/graph/oids.rs b/asyncgit/src/graph/oids.rs new file mode 100644 index 0000000000..38827c24c9 --- /dev/null +++ b/asyncgit/src/graph/oids.rs @@ -0,0 +1,35 @@ +use super::AliasId; +use crate::sync::CommitId; +use std::collections::HashMap; + +/// mapping of `CommitId` to a numeric alias +pub struct GraphOids(HashMap); + +impl Default for GraphOids { + fn default() -> Self { + Self::new() + } +} + +impl GraphOids { + /// Create an empty alias map. + pub fn new() -> Self { + Self(HashMap::new()) + } + + /// Get the alias for `id`, assigning a new one if it doesn't exist yet. + pub fn get_or_insert(&mut self, id: &CommitId) -> AliasId { + if let Some(&alias) = self.0.get(id) { + return alias; + } + + let alias = AliasId::from(self.0.len()); + self.0.insert(*id, alias); + alias + } + + /// Look up the alias for `id`, returning `None` if not found. + pub fn get(&self, id: &CommitId) -> Option { + self.0.get(id).copied() + } +} diff --git a/asyncgit/src/graph/walker.rs b/asyncgit/src/graph/walker.rs new file mode 100644 index 0000000000..d64774b1af --- /dev/null +++ b/asyncgit/src/graph/walker.rs @@ -0,0 +1,851 @@ +use super::buffer::Buffer; +use super::chunk::{Chunk, Markers}; +use super::oids::GraphOids; +use super::{ + AliasId, ConnectionType, GraphRow, LaneIndex, MAX_LANE_COLORS, +}; +use crate::sync::CommitId; +use core::cmp::Ordering; +use std::collections::{HashMap, HashSet}; + +/// Get the lanes color index, which cycles through the ste palette. +fn lane_color(lane: usize) -> LaneIndex { + LaneIndex::from(lane % MAX_LANE_COLORS) +} + +use bitflags::bitflags; + +bitflags! { + /// The neighboring cells a lane joins to. Overlapping lines + /// are merged through this representation rather than + /// a naive overwrite for readability. + #[derive(Clone, Copy, Default)] + struct Directions: u8 { + const UP = 0b0001; + const DOWN = 0b0010; + const LEFT = 0b0100; + const RIGHT = 0b1000; + } +} + +impl Directions { + #[allow(clippy::missing_const_for_fn)] + fn merge(self, other: Self) -> Self { + Self::from_bits_retain(self.bits() | other.bits()) + } + + #[allow(clippy::missing_const_for_fn)] + fn vertical(self) -> bool { + self.intersects(Self::UP | Self::DOWN) + } +} + +/// The sub network of an existing connection glyph +/// `None` represents commit markers, which are never drawn over. +fn conn_dirs(conn: ConnectionType) -> Option { + Some(match conn { + ConnectionType::Vertical | ConnectionType::VerticalDotted => { + Directions::UP | Directions::DOWN + } + ConnectionType::MergeBridgeMid => { + Directions::LEFT | Directions::RIGHT + } + ConnectionType::MergeBridgeStart => { + Directions::DOWN | Directions::LEFT + } + ConnectionType::MergeBridgeEnd => { + Directions::DOWN | Directions::RIGHT + } + ConnectionType::BranchUp => Directions::UP | Directions::LEFT, + ConnectionType::BranchUpRight => { + Directions::UP | Directions::RIGHT + } + ConnectionType::TeeLeft => { + Directions::UP | Directions::DOWN | Directions::LEFT + } + ConnectionType::TeeRight => { + Directions::UP | Directions::DOWN | Directions::RIGHT + } + ConnectionType::TeeUp => { + Directions::UP | Directions::LEFT | Directions::RIGHT + } + ConnectionType::TeeDown => { + Directions::DOWN | Directions::LEFT | Directions::RIGHT + } + ConnectionType::CommitNormal + | ConnectionType::CommitBranch + | ConnectionType::CommitMerge + | ConnectionType::CommitStash + | ConnectionType::CommitUncommitted => return None, + }) +} + +/// Determine the glyph for a cell's connectivity. +/// Vertical lines take precedence in crossed cells. +/// Yet the horizontal bridge continues in +/// the spacer columns either side, so we retain wholeness. +const fn dirs_conn(dirs: Directions, dotted: bool) -> ConnectionType { + let up = dirs.contains(Directions::UP); + let down = dirs.contains(Directions::DOWN); + let left = dirs.contains(Directions::LEFT); + let right = dirs.contains(Directions::RIGHT); + match (up, down, left, right) { + (true, true, true, false) => ConnectionType::TeeLeft, + (true, true, false, true) => ConnectionType::TeeRight, + (true, false, true, true) => ConnectionType::TeeUp, + (false, true, true, true) => ConnectionType::TeeDown, + (true, false, true, false) => ConnectionType::BranchUp, + (true, false, false, true) => ConnectionType::BranchUpRight, + (false, true, true, false) => { + ConnectionType::MergeBridgeStart + } + (false, true, false, true) => ConnectionType::MergeBridgeEnd, + (false, false, _, _) => ConnectionType::MergeBridgeMid, + (true, true, _, _) + | (true | false, false | true, false, false) => { + if dotted { + ConnectionType::VerticalDotted + } else { + ConnectionType::Vertical + } + } + } +} + +/// Draw `add` into a cell, merging with whatever is already there. +/// The line with a vertical component is the chosen way with color +/// ensuring lanes stay visually continuous +fn overlay_cell( + cell: &mut Option<(ConnectionType, LaneIndex)>, + add: Directions, + color: LaneIndex, +) { + if let Some((conn, existing_color)) = cell { + if let Some(existing) = conn_dirs(*conn) { + let is_dotted = + matches!(conn, ConnectionType::VerticalDotted); + + let resolved_color = + if existing.vertical() || !add.vertical() { + *existing_color + } else { + color + }; + + *cell = Some(( + dirs_conn(existing.merge(add), is_dotted), + resolved_color, + )); + } + } else { + *cell = Some((dirs_conn(add, false), color)); + } +} + +pub struct GraphWalker { + pub buffer: Buffer, + pub oids: GraphOids, + pub branch_lane_map: HashMap, + + /// Maps a merge commit's alias to the alias of its second parent. + pub merge_parents: HashMap, +} + +impl Default for GraphWalker { + fn default() -> Self { + Self::new() + } +} + +impl GraphWalker { + pub fn new() -> Self { + Self { + buffer: Buffer::new(), + oids: GraphOids::new(), + branch_lane_map: HashMap::new(), + merge_parents: HashMap::new(), + } + } + + pub fn process( + &mut self, + commit_id: CommitId, + parents: &[CommitId], + ) { + let commit_alias = self.oids.get_or_insert(&commit_id); + + let mut mapped_parents = parents + .iter() + .map(|parent_id| self.oids.get_or_insert(parent_id)); + + // We explicitly cap support at 2 parents, ignoring octo/mega merges. + let first_parent = mapped_parents.next(); + let second_parent = mapped_parents.next(); + + let chunk = Chunk { + alias: Some(commit_alias), + parent_a: first_parent, + parent_b: second_parent, + marker: Markers::Commit, + }; + + if let Some(second) = second_parent { + self.merge_parents.insert(commit_alias, second); + + if first_parent.is_some() + && !self.is_redundant_merge_track(second) + { + self.buffer.track_merge_commit(commit_alias); + } + } + + self.buffer.update(&chunk); + } + + /// Number of commits already folded into the graph buffer. + pub const fn processed_commits(&self) -> usize { + self.buffer.deltas.len() + } + + pub fn compute_rows( + &self, + commit_range: &[CommitId], + global_start_index: usize, + branch_tips: &HashSet, + stashes: &HashSet, + head_id: Option<&CommitId>, + ) -> Vec { + if commit_range.is_empty() { + return Vec::new(); + } + + // Decompress one row before the range to establish predecessor state + let snapshot_start_index = + global_start_index.saturating_sub(1); + let snapshot_end_index = + global_start_index + commit_range.len() - 1; + let snapshots = self + .buffer + .decompress(snapshot_start_index, snapshot_end_index); + let index_offset = global_start_index - snapshot_start_index; + + commit_range + .iter() + .enumerate() + .map(|(range_index, commit_id)| { + let snapshot_index = range_index + index_offset; + + let current_snapshot = snapshots + .get(snapshot_index) + .map(Vec::as_slice) + .unwrap_or_default(); + + let previous_snapshot = snapshot_index + .checked_sub(1) + .and_then(|index| snapshots.get(index)) + .map(Vec::as_slice); + + self.render_row( + commit_id, + current_snapshot, + previous_snapshot, + branch_tips, + stashes, + head_id, + ) + }) + .collect() + } + + fn draw_merge_bridge( + lanes: &mut [Option<(ConnectionType, LaneIndex)>], + merge_bridge: Option<(usize, usize)>, + commit_lane: usize, + current_snapshot: &[Option], + previous_snapshot: Option<&[Option]>, + ) { + let Some((source_lane, target_lane)) = merge_bridge else { + return; + }; + if source_lane == target_lane { + return; + } + + let destination_lane = if source_lane == commit_lane { + target_lane + } else { + source_lane + }; + let connection_color = lane_color(destination_lane); + + let continues_upwards = Self::lane_continues_upwards( + destination_lane, + current_snapshot, + previous_snapshot, + ); + + let target_directions = Self::calculate_merge_directions( + commit_lane, + destination_lane, + continues_upwards, + ); + + // Replace the plain vertical fill with the precise corner/junction + lanes[destination_lane] = None; + overlay_cell( + &mut lanes[destination_lane], + target_directions, + connection_color, + ); + + Self::draw_bridge_span( + lanes, + source_lane, + target_lane, + connection_color, + ); + } + + fn draw_branching_lanes( + lanes: &mut Vec>, + branching_lanes: &[usize], + commit_lane: usize, + ) -> Vec<(LaneIndex, LaneIndex)> { + branching_lanes + .iter() + .map(|&branch_lane| { + let start_lane = + std::cmp::min(branch_lane, commit_lane); + let end_lane = + std::cmp::max(branch_lane, commit_lane); + + Self::ensure_lane_capacity(lanes, end_lane); + + let connection_color = lane_color(branch_lane); + Self::draw_bridge_span( + lanes, + start_lane, + end_lane, + connection_color, + ); + + let branch_directions = + Self::calculate_branch_directions( + branch_lane, + start_lane, + end_lane, + ); + overlay_cell( + &mut lanes[branch_lane], + branch_directions, + connection_color, + ); + + ( + LaneIndex::from(start_lane), + LaneIndex::from(end_lane), + ) + }) + .collect() + } + + /// Checks if tracking a merge commit would be redundant based on current buffer state. + fn is_redundant_merge_track( + &self, + target_parent: AliasId, + ) -> bool { + self.buffer.current.iter().flatten().any(|commit| { + commit.parent_a == Some(target_parent) + && commit.parent_b.is_none() + }) + } + + /// Determines if a lane should draw an upward-connecting corner. + fn lane_continues_upwards( + target_lane: usize, + current_snapshot: &[Option], + previous_snapshot: Option<&[Option]>, + ) -> bool { + let exists_in_current = + current_snapshot.get(target_lane).is_some(); + let matches_previous = + previous_snapshot.is_some_and(|previous| { + previous.get(target_lane) + == current_snapshot.get(target_lane) + }); + + exists_in_current && matches_previous + } + + /// Uses `Ordering` to elegantly map spatial relationships to visual bitmasks. + fn calculate_merge_directions( + commit_lane: usize, + target_lane: usize, + continues_upwards: bool, + ) -> Directions { + let mut directions = Directions::DOWN; + + if continues_upwards { + directions |= Directions::UP; + } + + match target_lane.cmp(&commit_lane) { + Ordering::Greater => directions |= Directions::LEFT, + Ordering::Less => directions |= Directions::RIGHT, + Ordering::Equal => {} + } + + directions + } + + fn calculate_branch_directions( + branch_lane: usize, + start_lane: usize, + end_lane: usize, + ) -> Directions { + let mut directions = Directions::UP; + + if branch_lane == end_lane { + directions |= Directions::LEFT; + } + if branch_lane == start_lane { + directions |= Directions::RIGHT; + } + + directions + } + + fn ensure_lane_capacity( + lanes: &mut Vec>, + required_index: usize, + ) { + if lanes.len() <= required_index { + lanes.resize(required_index + 1, None); + } + } + + pub fn render_row( + &self, + commit_id: &CommitId, + current_snapshot: &[Option], + previous_snapshot: Option<&[Option]>, + branch_tips: &HashSet, + stashes: &HashSet, + head_id: Option<&CommitId>, + ) -> GraphRow { + let commit_alias = self.oids.get(commit_id); + let head_alias = head_id.and_then(|id| self.oids.get(id)); + let second_parent_alias = commit_alias.and_then(|alias| { + self.merge_parents.get(&alias).copied() + }); + + let commit_lane = + Self::find_commit_lane(current_snapshot, commit_alias); + let is_merge = second_parent_alias.is_some(); + let is_branch_tip = branch_tips.contains(commit_id); + let is_stash = stashes.contains(commit_id); + + let branching_lanes = Self::find_branching_lanes( + current_snapshot, + previous_snapshot, + ); + + let merge_bridge = + second_parent_alias.and_then(|parent_alias| { + Self::calculate_merge_bridge( + current_snapshot, + commit_lane, + parent_alias, + ) + }); + + let mut lanes: Vec> = + current_snapshot + .iter() + .enumerate() + .map(|(lane_index, chunk_option)| { + let chunk = chunk_option.as_ref()?; // Returns None early if the chunk is missing + + if commit_alias.is_some() + && chunk.alias == commit_alias + { + let connection = + Self::determine_commit_connection( + is_stash, + is_merge, + is_branch_tip, + ); + return Some(( + connection, + lane_color(lane_index), + )); + } + + Self::determine_passthrough_connection( + chunk, lane_index, head_alias, + ) + .map(|connection| { + (connection, lane_color(lane_index)) + }) + }) + .collect(); + + Self::draw_merge_bridge( + &mut lanes, + merge_bridge, + commit_lane, + current_snapshot, + previous_snapshot, + ); + + let branches = Self::draw_branching_lanes( + &mut lanes, + &branching_lanes, + commit_lane, + ); + + let active_lane_count = + current_snapshot.iter().flatten().count(); + + GraphRow { + lane_count: LaneIndex::from(active_lane_count), + commit_lane: LaneIndex::from(commit_lane), + is_merge, + is_branch_tip, + is_stash, + lanes, + merge_bridge: merge_bridge.map(|(source, target)| { + (LaneIndex::from(source), LaneIndex::from(target)) + }), + branches, + } + } + + /// Locates the primary lane for the current commit. + fn find_commit_lane( + current_snapshot: &[Option], + commit_alias: Option, + ) -> usize { + let Some(target_alias) = commit_alias else { + return 0; + }; + + current_snapshot + .iter() + .position(|chunk_option| { + chunk_option.as_ref().is_some_and(|chunk| { + chunk.alias == Some(target_alias) + }) + }) + .unwrap_or(0) + } + + /// Computes the span (min, max) between the commit's lane and its second parent's lane. + fn calculate_merge_bridge( + current_snapshot: &[Option], + commit_lane: usize, + second_parent_alias: AliasId, + ) -> Option<(usize, usize)> { + current_snapshot + .iter() + .position(|chunk_option| { + chunk_option.as_ref().is_some_and(|chunk| { + chunk.parent_a == Some(second_parent_alias) + }) + }) + .map(|target_lane| { + ( + commit_lane.min(target_lane), + commit_lane.max(target_lane), + ) + }) + } + + /// Identifies lanes that existed in the previous row but terminated before the current row. + fn find_branching_lanes( + current_snapshot: &[Option], + previous_snapshot: Option<&[Option]>, + ) -> Vec { + let Some(previous) = previous_snapshot else { + return Vec::new(); + }; + + previous + .iter() + .enumerate() + .filter(|(index, previous_chunk)| { + previous_chunk.is_some() + && current_snapshot + .get(*index) + .is_none_or(Option::is_none) + }) + .map(|(index, _)| index) + .collect() + } + + /// Determines the correct node type for the active commit lane. + const fn determine_commit_connection( + is_stash: bool, + is_merge: bool, + is_branch_tip: bool, + ) -> ConnectionType { + match (is_stash, is_merge, is_branch_tip) { + (true, _, _) => ConnectionType::CommitStash, + (_, true, _) => ConnectionType::CommitMerge, + (_, _, true) => ConnectionType::CommitBranch, + _ => ConnectionType::CommitNormal, + } + } + + /// Determines the correct vertical line style for non-commit passthrough lanes. + fn determine_passthrough_connection( + chunk: &Chunk, + lane_index: usize, + head_alias: Option, + ) -> Option { + let is_orphan = + chunk.parent_a.is_none() && chunk.parent_b.is_none(); + + if is_orphan { + return None; + } + + let is_dotted = lane_index == 0 + && head_alias.is_some() + && (chunk.parent_a == head_alias + || chunk.parent_b == head_alias); + + if is_dotted { + Some(ConnectionType::VerticalDotted) + } else { + Some(ConnectionType::Vertical) + } + } + + /// Lay the horizontal run of a bridge over the lanes strictly + /// between its two ends, merging with whatever each cell already + /// shows. + fn draw_bridge_span( + lanes: &mut [Option<(ConnectionType, LaneIndex)>], + from: usize, + to: usize, + color: LaneIndex, + ) { + for lane in lanes.iter_mut().take(to).skip(from + 1) { + overlay_cell( + lane, + Directions::LEFT | Directions::RIGHT, + color, + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(n: usize) -> CommitId { + CommitId::from_str_unchecked(&format!("{n:040x}")) + .expect("valid oid") + } + + fn sym(conn: ConnectionType) -> char { + match conn { + ConnectionType::Vertical => '┃', + ConnectionType::VerticalDotted => '╏', + ConnectionType::CommitNormal => 'o', + ConnectionType::CommitBranch => '*', + ConnectionType::CommitMerge => 'M', + ConnectionType::CommitStash => '*', + ConnectionType::CommitUncommitted => '+', + ConnectionType::MergeBridgeStart => '┓', + ConnectionType::MergeBridgeMid => '━', + ConnectionType::MergeBridgeEnd => '┏', + ConnectionType::BranchUp => '┛', + ConnectionType::BranchUpRight => '┗', + ConnectionType::TeeLeft => '┫', + ConnectionType::TeeRight => '┣', + ConnectionType::TeeUp => '┻', + ConnectionType::TeeDown => '┳', + } + } + + /// Render a row the way the UI does: one glyph per lane plus a + /// spacer that carries a bridge's horizontal run. + fn row_to_string(row: &GraphRow) -> String { + let mut out = String::new(); + for (lane_index, conn) in row.lanes.iter().enumerate() { + out.push(conn.map_or(' ', |(c, _)| sym(c))); + + let in_bridge = row + .merge_bridge + .into_iter() + .chain(row.branches.iter().copied()) + .any(|(from, to)| { + lane_index >= usize::from(from) + && lane_index < usize::from(to) + }); + out.push(if in_bridge { '━' } else { ' ' }); + } + out.trim_end().to_string() + } + + /// Walk `history` (newest first, `(commit, parents)`) and render + /// every row. + fn render(history: &[(usize, &[usize])]) -> Vec { + let mut walker = GraphWalker::new(); + let ids: Vec = + history.iter().map(|(c, _)| id(*c)).collect(); + + for (commit, parents) in history { + let parents: Vec = + parents.iter().map(|p| id(*p)).collect(); + walker.process(id(*commit), &parents); + } + + walker + .compute_rows( + &ids, + 0, + &HashSet::new(), + &HashSet::new(), + None, + ) + .iter() + .map(row_to_string) + .collect() + } + + #[test] + fn linear_history() { + let rows = render(&[(1, &[2]), (2, &[3]), (3, &[])]); + assert_eq!(rows, vec!["o", "o", "o"]); + } + + #[test] + fn simple_merge() { + // 1 merges 3 into the line 1 → 2 → 4, 3 → 4 + let rows = + render(&[(1, &[2, 3]), (2, &[4]), (3, &[4]), (4, &[])]); + assert_eq!(rows, vec!["M━┓", "o ┃", "┃ o", "o━┛"]); + } + + #[test] + fn merge_into_tracked_lane_continues_through_corner() { + // 3's merge line joins lane 1 which keeps flowing to 5, + // so the corner must be a junction (┫), not a dead end (┓) + let rows = render(&[ + (1, &[3]), + (2, &[5]), + (3, &[4, 5]), + (4, &[6]), + (5, &[6]), + (6, &[]), + ]); + assert_eq!( + rows, + vec!["o", "┃ o", "M━┫", "o ┃", "┃ o", "o━┛"] + ); + } + + #[test] + fn merge_bridge_crosses_unrelated_lane() { + // 3 (lane 2) merges into 1's line (lane 0) while 2's line + // (lane 1) passes through: the crossed lane keeps its + // vertical instead of being cut by the bridge + let rows = render(&[ + (1, &[4]), + (2, &[5]), + (3, &[6, 4]), + (4, &[7]), + (5, &[7]), + (6, &[7]), + (7, &[]), + ]); + assert_eq!( + rows, + vec![ + "o", + "┃ o", + "┣━┃━M", + "o ┃ ┃", + "┃ o ┃", + "┃ ┃ o", + "o━┻━┛", + ] + ); + } + + #[test] + fn overlapping_branch_bridges_keep_inner_corner() { + // lanes 1 and 2 both close into the commit on lane 0; the + // outer bridge passes through the inner corner (┻) instead + // of erasing it + let rows = + render(&[(1, &[4]), (2, &[4]), (3, &[4]), (4, &[])]); + assert_eq!(rows, vec!["o", "┃ o", "┃ ┃ o", "o━┻━┛"]); + } + + #[test] + fn merge_and_branch_bridges_overlap() { + // commit 3 closes a branch from lane 2 while opening a merge + // to lane 3, crossing lane 1: every line stays continuous + let rows = render(&[ + (1, &[3, 4]), + (2, &[3]), + (3, &[5, 6]), + (4, &[5]), + (5, &[7]), + (6, &[7]), + (7, &[]), + ]); + assert_eq!( + rows, + vec![ + "M━┓", + "┃ ┃ o", + "M━┃━┻━┓", + "┃ o ┃", + "o━┛ ┃", + "┃ o", + "o━━━━━┛", + ] + ); + } + + #[test] + fn crossed_lane_keeps_own_color() { + let rows = &[ + (1usize, &[4usize][..]), + (2, &[5]), + (3, &[6, 4]), + (4, &[7]), + (5, &[7]), + (6, &[7]), + (7, &[]), + ]; + let mut walker = GraphWalker::new(); + let ids: Vec = + rows.iter().map(|(c, _)| id(*c)).collect(); + for (commit, parents) in rows { + let parents: Vec = + parents.iter().map(|p| id(*p)).collect(); + walker.process(id(*commit), &parents); + } + let computed = walker.compute_rows( + &ids, + 0, + &HashSet::new(), + &HashSet::new(), + None, + ); + + // row of commit 3: lane 1 is crossed by the merge bridge but + // keeps both its vertical glyph and its own lane color + let crossed = computed[2].lanes[1] + .expect("crossed lane should not be empty"); + assert_eq!(crossed.0, ConnectionType::Vertical); + assert_eq!(crossed.1, lane_color(1)); + } +} diff --git a/asyncgit/src/lib.rs b/asyncgit/src/lib.rs index 98cc7238f9..ba6eea2525 100644 --- a/asyncgit/src/lib.rs +++ b/asyncgit/src/lib.rs @@ -8,7 +8,6 @@ It also provides synchronous Git operations. It wraps libraries like git2 and gix. */ -#![forbid(missing_docs)] #![deny( mismatched_lifetime_syntaxes, unused_imports, @@ -52,6 +51,7 @@ mod diff; mod error; mod fetch_job; mod filter_commits; +pub mod graph; mod progress; mod pull; mod push; diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs index 774d5140ef..a70c39fd45 100644 --- a/asyncgit/src/revlog.rs +++ b/asyncgit/src/revlog.rs @@ -1,14 +1,16 @@ use crate::{ error::Result, + graph::{GraphRow, GraphWalker}, sync::{ gix_repo, repo, CommitId, LogWalker, LogWalkerWithoutFilter, - RepoPath, SharedCommitFilterFn, + RepoPath, SharedCommitFilterFn, WalkEntry, }, AsyncGitNotification, Error, }; use crossbeam_channel::Sender; use scopetime::scope_time; use std::{ + collections::HashSet, sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, @@ -35,7 +37,7 @@ pub struct AsyncLogResult { /// pub duration: Duration, } -/// +/// Drives the background commit-log walker and exposes graph rows. pub struct AsyncLog { current: Arc>, current_head: Arc>>, @@ -45,6 +47,10 @@ pub struct AsyncLog { filter: Option, partial_extract: AtomicBool, repo: RepoPath, + /// All walk entries collected by the background thread, in walk order. + /// The graph walker reads these lazily, only as far as the viewport requires. + walk_entries: Arc>>, + graph_walker: Arc>, } static LIMIT_COUNT: usize = 3000; @@ -70,9 +76,55 @@ impl AsyncLog { background: Arc::new(AtomicBool::new(false)), filter, partial_extract: AtomicBool::new(false), + walk_entries: Arc::new(Mutex::new(Vec::new())), + graph_walker: Arc::new(Mutex::new(GraphWalker::new())), } } + /// Computes graph rows for `commit_slice` starting at `global_start`. + /// + /// Driven lazily. Processes only as many + /// [`WalkEntry`]s as the viewport requires. + /// + /// Returns `None` when the background walk hasn't reached + /// `global_start + commit_slice.len()` yet. + pub fn get_graph_rows( + &self, + commit_slice: &[CommitId], + global_start: usize, + branch_tips: &HashSet, + stashes: &HashSet, + head_id: Option<&CommitId>, + ) -> Option> { + let needed_end = global_start + commit_slice.len(); + + let mut walker = self.graph_walker.lock().ok()?; + + { + let entries = self.walk_entries.lock().ok()?; + if entries.len() < needed_end { + return None; + } + + // the walker may already be ahead of the requested range + // (you know, scrolling up), so only feed it entries + // we know it is yet to have seen + let processed = + walker.processed_commits().min(needed_end); + for entry in &entries[processed..needed_end] { + walker.process(entry.id, &entry.parents); + } + } + + Some(walker.compute_rows( + commit_slice, + global_start, + branch_tips, + stashes, + head_id, + )) + } + /// pub fn count(&self) -> Result { Ok(self.current.lock()?.commits.len()) @@ -165,6 +217,7 @@ impl AsyncLog { let sender = self.sender.clone(); let arc_pending = Arc::clone(&self.pending); let arc_background = Arc::clone(&self.background); + let arc_walk_entries = Arc::clone(&self.walk_entries); let filter = self.filter.clone(); let repo_path = self.repo.clone(); @@ -181,6 +234,7 @@ impl AsyncLog { &arc_current, &arc_background, &sender, + &arc_walk_entries, filter, ) .expect("failed to fetch"); @@ -198,6 +252,7 @@ impl AsyncLog { arc_current: &Arc>, arc_background: &Arc, sender: &Sender, + arc_walk_entries: &Arc>>, filter: Option, ) -> Result<()> { filter.map_or_else( @@ -207,6 +262,7 @@ impl AsyncLog { arc_current, arc_background, sender, + arc_walk_entries, ) }, |filter| { @@ -221,6 +277,9 @@ impl AsyncLog { ) } + /// A filtered walk yields a disconnected subset of the history, + /// which the graph cannot represent, so no topology entries are + /// collected here. fn fetch_helper_with_filter( repo_path: &RepoPath, arc_current: &Arc>, @@ -228,67 +287,78 @@ impl AsyncLog { sender: &Sender, filter: SharedCommitFilterFn, ) -> Result<()> { - let start_time = Instant::now(); - - let mut entries = vec![CommitId::default(); LIMIT_COUNT]; - entries.resize(0, CommitId::default()); - let r = repo(repo_path)?; let mut walker = LogWalker::new(&r, LIMIT_COUNT)?.filter(Some(filter)); - loop { - entries.clear(); - let read = walker.read(&mut entries)?; + Self::walk_loop( + |out| walker.read(out), + arc_current, + arc_background, + sender, + None, + )?; - let mut current = arc_current.lock()?; - current.commits.extend(entries.iter()); - current.duration = start_time.elapsed(); + log::trace!("revlog visited: {}", walker.visited()); - if read == 0 { - break; - } - Self::notify(sender); + Ok(()) + } - let sleep_duration = - if arc_background.load(Ordering::Relaxed) { - SLEEP_BACKGROUND - } else { - SLEEP_FOREGROUND - }; + fn fetch_helper_without_filter( + repo_path: &RepoPath, + arc_current: &Arc>, + arc_background: &Arc, + sender: &Sender, + arc_walk_entries: &Arc>>, + ) -> Result<()> { + let mut repo: gix::Repository = gix_repo(repo_path)?; + let mut walker = + LogWalkerWithoutFilter::new(&mut repo, LIMIT_COUNT)?; - thread::sleep(sleep_duration); - } + Self::walk_loop( + |out| walker.read(out), + arc_current, + arc_background, + sender, + Some(arc_walk_entries), + )?; log::trace!("revlog visited: {}", walker.visited()); Ok(()) } - fn fetch_helper_without_filter( - repo_path: &RepoPath, + /// Drives `read` in batches, publishing every batch's commit ids + /// to `arc_current` and (when given) moving the full entries into + /// `walk_entries` for the graph. + fn walk_loop( + mut read: impl FnMut(&mut Vec) -> Result, arc_current: &Arc>, arc_background: &Arc, sender: &Sender, + walk_entries: Option<&Mutex>>, ) -> Result<()> { let start_time = Instant::now(); - let mut entries = vec![CommitId::default(); LIMIT_COUNT]; - entries.resize(0, CommitId::default()); - - let mut repo: gix::Repository = gix_repo(repo_path)?; - let mut walker = - LogWalkerWithoutFilter::new(&mut repo, LIMIT_COUNT)?; + let mut entries: Vec = + Vec::with_capacity(LIMIT_COUNT); loop { - entries.clear(); - let read = walker.read(&mut entries)?; + let read_count = read(&mut entries)?; + + { + let mut current = arc_current.lock()?; + current.commits.extend(entries.iter().map(|e| e.id)); + current.duration = start_time.elapsed(); + } - let mut current = arc_current.lock()?; - current.commits.extend(entries.iter()); - current.duration = start_time.elapsed(); + if let Some(walk_entries) = walk_entries { + walk_entries.lock()?.append(&mut entries); + } else { + entries.clear(); + } - if read == 0 { + if read_count == 0 { break; } Self::notify(sender); @@ -303,8 +373,6 @@ impl AsyncLog { thread::sleep(sleep_duration); } - log::trace!("revlog visited: {}", walker.visited()); - Ok(()) } @@ -312,6 +380,8 @@ impl AsyncLog { self.current.lock()?.commits.clear(); *self.current_head.lock()? = None; self.partial_extract.store(false, Ordering::Relaxed); + self.walk_entries.lock()?.clear(); + *self.graph_walker.lock()? = GraphWalker::new(); Ok(()) } @@ -359,12 +429,14 @@ mod tests { duration: Duration::default(), })); let arc_background = Arc::new(AtomicBool::new(false)); + let arc_walk_entries = Arc::new(Mutex::new(Vec::new())); let result = AsyncLog::fetch_helper_without_filter( &subdir_path, &arc_current, &arc_background, &tx_git, + &arc_walk_entries, ); assert_eq!(result.unwrap(), ()); @@ -387,6 +459,7 @@ mod tests { duration: Duration::default(), })); let arc_background = Arc::new(AtomicBool::new(false)); + let arc_walk_entries = Arc::new(Mutex::new(Vec::new())); std::env::set_var("GIT_DIR", git_dir); @@ -396,6 +469,7 @@ mod tests { &arc_current, &arc_background, &tx_git, + &arc_walk_entries, ); std::env::remove_var("GIT_DIR"); diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs index 89d847c8c2..d1042ce76a 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -10,6 +10,7 @@ use crate::{ }; use git2::{Commit, Error, Oid}; use scopetime::scope_time; +use smallvec::SmallVec; use unicode_truncate::UnicodeTruncateStr; /// identifies a single commit @@ -87,6 +88,15 @@ impl From for CommitId { } } +impl From> for CommitId { + fn from(object_id: gix::Id<'_>) -> Self { + #[allow(clippy::expect_used)] + let oid = Oid::from_bytes(object_id.as_bytes()).expect("`Oid::from_bytes(object_id.as_bytes())` is expected to never fail"); + + Self::new(oid) + } +} + impl From for CommitId { fn from(object_id: gix::ObjectId) -> Self { #[allow(clippy::expect_used)] @@ -122,6 +132,8 @@ pub struct CommitInfo { pub author: String, /// pub id: CommitId, + /// + pub parents: SmallVec<[CommitId; 2]>, } /// @@ -155,6 +167,11 @@ pub fn get_commits_info( author, time: c.time().seconds(), id: CommitId(c.id()), + parents: c + .parents() + .take(2) + .map(|p| CommitId(p.id())) + .collect(), } }) .collect::>(); @@ -189,6 +206,11 @@ pub fn get_commit_info( author: author.to_string(), time: commit_ref.time()?.seconds, id: commit.id().detach().into(), + parents: commit + .parent_ids() + .take(2) + .map(Into::into) + .collect(), }) } diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs index 81a6fd321e..29843271f6 100644 --- a/asyncgit/src/sync/logwalker.rs +++ b/asyncgit/src/sync/logwalker.rs @@ -2,11 +2,25 @@ use super::{CommitId, SharedCommitFilterFn}; use crate::error::Result; use git2::{Commit, Oid, Repository}; use gix::revision::Walk; +use smallvec::SmallVec; use std::{ cmp::Ordering, collections::{BinaryHeap, HashSet}, }; +/// A commit id together with the ids of its TWO parents. +/// +/// The parents come for free during a walk. +/// Collecting here avoid a second, convulted pass. +#[derive(Debug, Clone)] +pub struct WalkEntry { + /// The commit's own unique identifier. + pub id: CommitId, + + /// The commit's parent identifiers. + pub parents: SmallVec<[CommitId; 2]>, +} + struct TimeOrderedCommit<'a>(Commit<'a>); impl Eq for TimeOrderedCommit<'_> {} @@ -70,11 +84,18 @@ impl<'a> LogWalker<'a> { } /// - pub fn read(&mut self, out: &mut Vec) -> Result { + pub fn read( + &mut self, + out: &mut Vec, + ) -> Result { let mut count = 0_usize; while let Some(c) = self.commits.pop() { + let mut parents = SmallVec::new(); for p in c.0.parents() { + if parents.len() < 2 { + parents.push(p.id().into()); + } self.visit(p); } @@ -87,7 +108,7 @@ impl<'a> LogWalker<'a> { }; if commit_should_be_included { - out.push(id); + out.push(WalkEntry { id, parents }); } count += 1; @@ -157,11 +178,22 @@ impl<'a> LogWalkerWithoutFilter<'a> { } /// - pub fn read(&mut self, out: &mut Vec) -> Result { + pub fn read( + &mut self, + out: &mut Vec, + ) -> Result { let mut count = 0_usize; while let Some(Ok(info)) = self.walk.next() { - out.push(info.id.into()); + out.push(WalkEntry { + id: info.id.into(), + parents: info + .parent_ids + .iter() + .take(2) + .map(|id| CommitId::from(*id)) + .collect(), + }); count += 1; @@ -214,7 +246,7 @@ mod tests { walk.read(&mut items).unwrap(); assert_eq!(items.len(), 1); - assert_eq!(items[0], oid2); + assert_eq!(items[0].id, oid2); Ok(()) } @@ -238,11 +270,12 @@ mod tests { let mut walk = LogWalker::new(&repo, 100)?; walk.read(&mut items).unwrap(); - let info = get_commits_info(repo_path, &items, 50).unwrap(); + let ids: Vec = items.iter().map(|e| e.id).collect(); + let info = get_commits_info(repo_path, &ids, 50).unwrap(); dbg!(&info); assert_eq!(items.len(), 2); - assert_eq!(items[0], oid2); + assert_eq!(items[0].id, oid2); let mut items = Vec::new(); walk.read(&mut items).unwrap(); @@ -272,11 +305,12 @@ mod tests { let mut items = Vec::new(); assert!(matches!(walk.read(&mut items), Ok(2))); - let info = get_commits_info(repo_path, &items, 50).unwrap(); + let ids: Vec = items.iter().map(|e| e.id).collect(); + let info = get_commits_info(repo_path, &ids, 50).unwrap(); dbg!(&info); assert_eq!(items.len(), 2); - assert_eq!(items[0], oid2); + assert_eq!(items[0].id, oid2); let mut items = Vec::new(); assert!(matches!(walk.read(&mut items), Ok(0))); @@ -318,7 +352,7 @@ mod tests { walker.read(&mut items).unwrap(); assert_eq!(items.len(), 1); - assert_eq!(items[0], second_commit_id); + assert_eq!(items[0].id, second_commit_id); let mut items = Vec::new(); walker.read(&mut items).unwrap(); @@ -365,7 +399,7 @@ mod tests { walker.read(&mut items).unwrap(); assert_eq!(items.len(), 1); - assert_eq!(items[0], second_commit_id); + assert_eq!(items[0].id, second_commit_id); let log_filter = filter_commit_by_search( LogFilterSearch::new(LogFilterSearchOptions { diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 2a5f413e8f..38818b5fc7 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -72,7 +72,7 @@ pub use hooks::{ }; pub use hunks::{reset_hunk, stage_hunk, unstage_hunk}; pub use ignore::add_to_ignore; -pub use logwalker::{LogWalker, LogWalkerWithoutFilter}; +pub use logwalker::{LogWalker, LogWalkerWithoutFilter, WalkEntry}; pub use merge::{ abort_pending_rebase, abort_pending_state, continue_pending_rebase, merge_branch, merge_commit, merge_msg, @@ -261,13 +261,13 @@ pub mod tests { r: &Repository, max_count: usize, ) -> Vec { - let mut commit_ids = Vec::::new(); + let mut entries = Vec::new(); LogWalker::new(r, max_count) .unwrap() - .read(&mut commit_ids) + .read(&mut entries) .unwrap(); - commit_ids + entries.iter().map(|e| e.id).collect() } /// Same as `repo_init`, but the repo is a bare repo (--bare) diff --git a/asyncgit/src/sync/remotes/mod.rs b/asyncgit/src/sync/remotes/mod.rs index b32810dd9e..ad884d19c8 100644 --- a/asyncgit/src/sync/remotes/mod.rs +++ b/asyncgit/src/sync/remotes/mod.rs @@ -161,7 +161,7 @@ pub(crate) fn get_default_remote_for_fetch_in_repo( if let Some(branch) = branch { let remote_name = bytes2string(branch.name_bytes()?)?; - let entry_name = format!("branch.{}.remote", &remote_name); + let entry_name = format!("branch.{remote_name}.remote"); if let Ok(entry) = config.get_entry(&entry_name) { return bytes2string(entry.value_bytes()); @@ -211,8 +211,7 @@ pub(crate) fn get_default_remote_for_push_in_repo( if let Some(branch) = branch { let remote_name = bytes2string(branch.name_bytes()?)?; - let entry_name = - format!("branch.{}.pushRemote", &remote_name); + let entry_name = format!("branch.{remote_name}.pushRemote"); if let Ok(entry) = config.get_entry(&entry_name) { return bytes2string(entry.value_bytes()); @@ -222,7 +221,7 @@ pub(crate) fn get_default_remote_for_push_in_repo( return bytes2string(entry.value_bytes()); } - let entry_name = format!("branch.{}.remote", &remote_name); + let entry_name = format!("branch.{remote_name}.remote"); if let Ok(entry) = config.get_entry(&entry_name) { return bytes2string(entry.value_bytes()); diff --git a/asyncgit/src/sync/sign.rs b/asyncgit/src/sync/sign.rs index da5079f72c..bde0fc53d2 100644 --- a/asyncgit/src/sync/sign.rs +++ b/asyncgit/src/sync/sign.rs @@ -239,7 +239,7 @@ impl Sign for GPGSign { if !output.status.success() { return Err(SignError::Shellout(format!( "failed to sign data, program '{}' exited non-zero: {}", - &self.program, + self.program, std::str::from_utf8(&output.stderr) .unwrap_or("[error could not be read from stderr]") ))); @@ -250,7 +250,7 @@ impl Sign for GPGSign { if !stderr.contains("\n[GNUPG:] SIG_CREATED ") { return Err(SignError::Shellout( - format!("failed to sign data, program '{}' failed, SIG_CREATED not seen in stderr", &self.program), + format!("failed to sign data, program '{}' failed, SIG_CREATED not seen in stderr", self.program), )); } diff --git a/filetreelist/src/lib.rs b/filetreelist/src/lib.rs index 3e80065aba..74ccf6aed4 100644 --- a/filetreelist/src/lib.rs +++ b/filetreelist/src/lib.rs @@ -1,4 +1,3 @@ -// #![forbid(missing_docs)] #![forbid(unsafe_code)] #![deny( mismatched_lifetime_syntaxes, diff --git a/src/app.rs b/src/app.rs index 8626aa3b8e..435b5eedde 100644 --- a/src/app.rs +++ b/src/app.rs @@ -138,9 +138,9 @@ impl Environment { use crossbeam_channel::unbounded; Self { queue: Queue::new(), - theme: Default::default(), - key_config: Default::default(), - repo: RefCell::new(RepoPath::Path(Default::default())), + theme: Rc::default(), + key_config: Rc::default(), + repo: RefCell::new(RepoPath::Path(PathBuf::default())), options: Rc::new(RefCell::new(Options::test_env())), sender_git: unbounded().0, sender_app: unbounded().0, @@ -161,7 +161,7 @@ impl App { key_config: KeyConfig, ) -> Result { let repo = RefCell::new(cliargs.repo_path.clone()); - log::trace!("open repo at: {:?}", &repo); + log::trace!("open repo at: {repo:?}"); let repo_path_text = repo_work_dir(&repo.borrow()).unwrap_or_default(); diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index a84060151d..2b524567cf 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -1,3 +1,11 @@ +use super::utils::graphrow::{ + SYM_BRANCH_UP, SYM_BRANCH_UP_RIGHT, SYM_COMMIT, + SYM_COMMIT_BRANCH, SYM_COMMIT_MERGE, SYM_COMMIT_STASH, + SYM_COMMIT_UNCOMMITTED, SYM_HORIZONTAL, SYM_MERGE_BRIDGE_END, + SYM_MERGE_BRIDGE_MID, SYM_MERGE_BRIDGE_START, SYM_SPACE, + SYM_TEE_DOWN, SYM_TEE_LEFT, SYM_TEE_RIGHT, SYM_TEE_UP, + SYM_VERTICAL, SYM_VERTICAL_DOTTED, +}; use super::utils::logitems::{ItemBatch, LogEntry}; use crate::{ app::Environment, @@ -13,9 +21,12 @@ use crate::{ ui::{calc_scroll_top, draw_scrollbar, Orientation}, }; use anyhow::Result; -use asyncgit::sync::{ - self, checkout_commit, BranchDetails, BranchInfo, CommitId, - RepoPathRef, Tags, +use asyncgit::{ + graph::{ConnectionType, GraphRow}, + sync::{ + self, checkout_commit, BranchDetails, BranchInfo, CommitId, + RepoPathRef, Tags, + }, }; use chrono::{DateTime, Local}; use crossterm::event::Event; @@ -23,20 +34,30 @@ use indexmap::IndexSet; use itertools::Itertools; use ratatui::{ layout::{Alignment, Rect}, - style::Style, + style::{Color, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, Frame, }; +use std::borrow::Cow; +use std::collections::HashSet; use std::{ - borrow::Cow, cell::Cell, cmp, collections::BTreeMap, rc::Rc, - time::Instant, + cell::Cell, cmp, collections::BTreeMap, rc::Rc, time::Instant, }; const ELEMENTS_PER_LINE: usize = 9; const SLICE_SIZE: usize = 1200; -/// +const GRAPH_COLORS: &[Color] = &[ + Color::Blue, + Color::Yellow, + Color::Magenta, + Color::Cyan, + Color::Green, + Color::Red, +]; + +/// Renders the commit log with a side-bar graph. pub struct CommitList { repo: RepoPathRef, title: Box, @@ -58,6 +79,8 @@ pub struct CommitList { theme: SharedTheme, queue: Queue, key_config: SharedKeyConfig, + show_graph: bool, + graph_col_width: Cell, } impl CommitList { @@ -81,9 +104,33 @@ impl CommitList { queue: env.queue.clone(), key_config: env.key_config.clone(), title: title.into(), + show_graph: true, + graph_col_width: Cell::new(0), } } + /// Whether the commit graph column is currently visible. + pub const fn is_graph_visible(&self) -> bool { + self.show_graph + } + + /// Whether the loaded window already has up-to-date graph rows. + pub const fn is_graph_ready(&self) -> bool { + self.items.graph_ready + } + + /// + pub fn get_loaded_slice(&self) -> (Vec, usize) { + let offset = self.items.index_offset(); + let ids = self.items.iter().map(|e| e.id).collect(); + (ids, offset) + } + + /// + pub fn set_graph_rows(&mut self, rows: Vec) { + self.items.set_graph_rows(rows); + } + /// pub const fn tags(&self) -> Option<&Tags> { self.tags.as_ref() @@ -175,6 +222,20 @@ impl CommitList { } } + /// + pub const fn local_branches( + &self, + ) -> &std::collections::BTreeMap> { + &self.local_branches + } + + /// + pub const fn remote_branches( + &self, + ) -> &std::collections::BTreeMap> { + &self.remote_branches + } + /// pub fn set_local_branches( &mut self, @@ -188,6 +249,9 @@ impl CommitList { .or_default() .push(local_branch); } + + // branch tips and head are baked into graph rows + self.items.graph_ready = false; } /// @@ -203,6 +267,9 @@ impl CommitList { .or_default() .push(remote_branch); } + + // branch tips are baked into graph rows + self.items.graph_ready = false; } /// @@ -439,6 +506,111 @@ impl CommitList { } } + fn build_graph_spans<'a>( + row: &'a GraphRow, + graph_col_width: usize, + empty_lanes: &std::collections::HashSet, + ) -> Vec> { + let mut spans = Vec::new(); + + for (lane_index, conn) in row.lanes.iter().enumerate() { + if empty_lanes.contains(&lane_index) { + continue; + } + let (sym, graph_color) = match conn { + None => (SYM_SPACE, Color::Reset), + Some((conn_type, color_idx)) => { + let color = GRAPH_COLORS[usize::from(*color_idx) + % GRAPH_COLORS.len()]; + let sym = match conn_type { + ConnectionType::Vertical => SYM_VERTICAL, + ConnectionType::VerticalDotted => { + SYM_VERTICAL_DOTTED + } + ConnectionType::TeeLeft => SYM_TEE_LEFT, + ConnectionType::TeeRight => SYM_TEE_RIGHT, + ConnectionType::TeeUp => SYM_TEE_UP, + ConnectionType::TeeDown => SYM_TEE_DOWN, + ConnectionType::BranchUpRight => { + SYM_BRANCH_UP_RIGHT + } + ConnectionType::CommitNormal => SYM_COMMIT, + ConnectionType::CommitBranch => { + SYM_COMMIT_BRANCH + } + ConnectionType::CommitMerge => { + SYM_COMMIT_MERGE + } + ConnectionType::CommitStash => { + SYM_COMMIT_STASH + } + ConnectionType::CommitUncommitted => { + SYM_COMMIT_UNCOMMITTED + } + ConnectionType::MergeBridgeStart => { + SYM_MERGE_BRIDGE_START + } + ConnectionType::MergeBridgeMid => { + SYM_MERGE_BRIDGE_MID + } + ConnectionType::MergeBridgeEnd => { + SYM_MERGE_BRIDGE_END + } + ConnectionType::BranchUp => SYM_BRANCH_UP, + }; + (sym, color) + } + }; + spans.push(Span::styled( + sym, + Style::default().fg(graph_color), + )); + + // Spacer + let mut is_bridge_lane = false; + let mut spacer_color = graph_color; + + let commit_lane = usize::from(row.commit_lane); + + let edges = row + .merge_bridge + .into_iter() + .chain(row.branches.iter().copied()); + + for (st, fin) in edges { + let (start, finish) = + (usize::from(st), usize::from(fin)); + + if (start..finish).contains(&lane_index) { + is_bridge_lane = true; + let target_lane = if commit_lane == start { + finish + } else { + start + }; + spacer_color = GRAPH_COLORS + [target_lane % GRAPH_COLORS.len()]; + } + } + + if is_bridge_lane { + spans.push(Span::styled( + SYM_HORIZONTAL, + Style::default().fg(spacer_color), + )); + continue; + } + spans.push(Span::raw(SYM_SPACE)); + } + + let current_width = spans.len(); + for _ in current_width..graph_col_width { + spans.push(Span::raw(SYM_SPACE)); + } + + spans + } + #[allow(clippy::too_many_arguments)] fn get_entry_to_add<'a>( &self, @@ -451,17 +623,21 @@ impl CommitList { width: usize, now: DateTime, marked: Option, + empty_lanes: &std::collections::HashSet, ) -> Line<'a> { let mut txt: Vec = Vec::with_capacity( ELEMENTS_PER_LINE + if marked.is_some() { 2 } else { 0 }, ); + if self.show_graph { + self.add_graph_spans(e, &mut txt, empty_lanes); + } + let normal = !self.items.highlighting() || (self.items.highlighting() && e.highlighted); - let splitter_txt = Cow::from(symbol::EMPTY_SPACE); let splitter = Span::styled( - splitter_txt, + Cow::from(symbol::EMPTY_SPACE), if normal { theme.text(true, selected) } else { @@ -482,40 +658,80 @@ impl CommitList { txt.push(splitter.clone()); } - let style_hash = if normal { - theme.commit_hash(selected) - } else { - theme.commit_unhighlighted() - }; - let style_time = if normal { - theme.commit_time(selected) - } else { - theme.commit_unhighlighted() - }; - let style_author = if normal { - theme.commit_author(selected) - } else { - theme.commit_unhighlighted() - }; - let style_tags = if normal { - theme.tags(selected) - } else { - theme.commit_unhighlighted() - }; - let style_branches = if normal { - theme.branch(selected, true) + Self::add_entry_details( + e, + &mut txt, + selected, + normal, + theme, + width, + now, + tags, + local_branches, + remote_branches, + &splitter, + ); + + Line::from(txt) + } + + fn add_graph_spans<'a>( + &self, + e: &'a LogEntry, + txt: &mut Vec>, + empty_lanes: &std::collections::HashSet, + ) { + if let Some(ref row) = e.graph { + txt.extend(Self::build_graph_spans( + row, + self.graph_col_width.get(), + empty_lanes, + )); } else { - theme.commit_unhighlighted() - }; - let style_msg = if normal { - theme.text(true, selected) + txt.push(Span::raw( + " ".repeat(self.graph_col_width.get()), + )); + } + txt.push(Span::raw(" ")); + } + + #[allow(clippy::too_many_arguments)] + fn add_entry_details<'a>( + e: &'a LogEntry, + txt: &mut Vec>, + selected: bool, + normal: bool, + theme: &Theme, + width: usize, + now: DateTime, + tags: Option, + local_branches: Option, + remote_branches: Option, + splitter: &Span<'a>, + ) { + let ( + style_hash, + style_time, + style_author, + style_tags, + style_branches, + style_msg, + ) = if normal { + ( + theme.commit_hash(selected), + theme.commit_time(selected), + theme.commit_author(selected), + theme.tags(selected), + theme.branch(selected, true), + theme.text(true, selected), + ) } else { - theme.commit_unhighlighted() + let s = theme.commit_unhighlighted(); + (s, s, s, s, s, s) }; // commit hash txt.push(Span::styled(Cow::from(&*e.hash_short), style_hash)); - txt.push(splitter.clone()); // commit timestamp @@ -523,7 +739,6 @@ impl CommitList { Cow::from(e.time_to_string(now)), style_time, )); - txt.push(splitter.clone()); let author_width = @@ -532,7 +747,6 @@ impl CommitList { // commit author txt.push(Span::styled(author, style_author)); - txt.push(splitter.clone()); // commit tags @@ -550,7 +764,7 @@ impl CommitList { txt.push(Span::styled(remote_branches, style_branches)); } - txt.push(splitter); + txt.push(splitter.clone()); let message_width = width.saturating_sub( txt.iter().map(|span| span.content.len()).sum(), @@ -558,68 +772,121 @@ impl CommitList { // commit msg txt.push(Span::styled( - format!("{:message_width$}", &e.msg), + format!("{:message_width$}", e.msg), style_msg, )); - - Line::from(txt) } fn get_text(&self, height: usize, width: usize) -> Vec> { let selection = self.relative_selection(); - - let mut txt: Vec = Vec::with_capacity(height); - let now = Local::now(); - let any_marked = !self.marked.is_empty(); - for (idx, e) in self - .items + if self.show_graph { + let view = self + .items + .iter() + .skip(self.scroll_top.get()) + .take(height); + + let max_lane = view + .clone() + .filter_map(|e| e.graph.as_ref()) + .map(|row| row.lanes.len()) + .max() + .unwrap_or(0); + + let empty_lanes: std::collections::HashSet = (0..max_lane) + .filter(|&index| { + view.clone().filter_map(|entry| entry.graph.as_ref()).all(|row| { + row.lanes.get(index).is_none_or(|conn| { + matches!(conn, None | Some((ConnectionType::MergeBridgeMid, _))) + }) + }) + }) + .collect(); + + self.graph_col_width.set( + ((max_lane.saturating_sub(empty_lanes.len())) * 2) + .max(2), + ); + + self.collect_entries( + height, + width, + selection, + now, + any_marked, + &empty_lanes, + ) + } else { + self.graph_col_width.set(0); + self.collect_entries( + height, + width, + selection, + now, + any_marked, + &HashSet::new(), + ) + } + } + + fn collect_entries( + &self, + height: usize, + width: usize, + selection: usize, + now: DateTime, + any_marked: bool, + empty_lanes: &HashSet, + ) -> Vec> { + self.items .iter() .skip(self.scroll_top.get()) .take(height) .enumerate() - { - let tags = - self.tags.as_ref().and_then(|t| t.get(&e.id)).map( - |tags| { + .map(|(index, entry)| { + let tags = self + .tags + .as_ref() + .and_then(|t| t.get(&entry.id)) + .map(|tags| { tags.iter() - .map(|t| format!("<{}>", t.name)) + .map(|tag| format!("<{}>", tag.name)) .join(" ") - }, - ); - - let local_branches = - self.local_branches.get(&e.id).map(|local_branch| { - local_branch - .iter() - .map(|local_branch| { - format!("{{{0}}}", local_branch.name) - }) - .join(" ") - }); - - let marked = if any_marked { - self.is_marked(&e.id) - } else { - None - }; - - txt.push(self.get_entry_to_add( - e, - idx + self.scroll_top.get() == selection, - tags, - local_branches, - self.remote_branches_string(e), - &self.theme, - width, - now, - marked, - )); - } - - txt + }); + + let local_branches = self + .local_branches + .get(&entry.id) + .map(|local_branch| { + local_branch + .iter() + .map(|branch| { + format!("{{{}}}", branch.name) + }) + .join(" ") + }); + + let marked = any_marked + .then(|| self.is_marked(&entry.id)) + .flatten(); + + self.get_entry_to_add( + entry, + index + self.scroll_top.get() == selection, + tags, + local_branches, + self.remote_branches_string(entry), + &self.theme, + width, + now, + marked, + empty_lanes, + ) + }) + .collect() } fn remote_branches_string(&self, e: &LogEntry) -> Option { @@ -863,6 +1130,12 @@ impl Component for CommitList { ) { self.checkout(); true + } else if key_match( + k, + self.key_config.keys.log_toggle_graph, + ) { + self.show_graph = !self.show_graph; + true } else { false }; @@ -890,6 +1163,11 @@ impl Component for CommitList { true, true, )); + out.push(CommandInfo::new( + strings::commands::log_toggle_graph(&self.key_config), + true, + true, + )); CommandBlocking::PassingOn } } @@ -922,6 +1200,8 @@ mod tests { std::path::PathBuf::default(), )), queue: Queue::default(), + show_graph: true, + graph_col_width: Cell::new(0), } } } @@ -956,6 +1236,7 @@ mod tests { time: 0, author: String::default(), id: CommitId::default(), + parents: Vec::default().into(), }; // This just creates a sequence of fake ordered ids // 0000000000000000000000000000000000000000 @@ -1069,4 +1350,30 @@ mod tests { ))) ); } + + #[test] + fn test_build_graph_spans() { + let row = GraphRow { + lane_count: 1.into(), + commit_lane: 0.into(), + is_merge: false, + is_branch_tip: false, + is_stash: false, + lanes: vec![Some(( + ConnectionType::CommitNormal, + 0.into(), + ))], + merge_bridge: None, + branches: vec![], + }; + let spans = CommitList::build_graph_spans( + &row, + 1, + &std::collections::HashSet::new(), + ); + + assert_eq!(spans.len(), 2); + assert_eq!(spans[0].content, Cow::from(SYM_COMMIT)); + assert_eq!(spans[1].content, Cow::from(SYM_SPACE)); + } } diff --git a/src/components/status_tree.rs b/src/components/status_tree.rs index ac4fc9f6a8..48935ca777 100644 --- a/src/components/status_tree.rs +++ b/src/components/status_tree.rs @@ -22,7 +22,7 @@ use std::{borrow::Cow, cell::Cell, path::Path}; //TODO: use new `filetreelist` crate -/// +/// Renders the working-tree status as a tree. #[allow(clippy::struct_excessive_bools)] pub struct StatusTreeComponent { title: String, diff --git a/src/components/utils/graphrow.rs b/src/components/utils/graphrow.rs new file mode 100644 index 0000000000..e45bae89a7 --- /dev/null +++ b/src/components/utils/graphrow.rs @@ -0,0 +1,18 @@ +pub const SYM_COMMIT: &str = "o"; +pub const SYM_COMMIT_BRANCH: &str = "*"; +pub const SYM_COMMIT_MERGE: &str = "M"; +pub const SYM_COMMIT_STASH: &str = "*"; +pub const SYM_COMMIT_UNCOMMITTED: &str = "+"; +pub const SYM_VERTICAL: &str = "┃"; +pub const SYM_VERTICAL_DOTTED: &str = "╏"; +pub const SYM_HORIZONTAL: &str = "━"; +pub const SYM_MERGE_BRIDGE_START: &str = "┓"; +pub const SYM_MERGE_BRIDGE_MID: &str = "━"; +pub const SYM_MERGE_BRIDGE_END: &str = "┏"; +pub const SYM_BRANCH_UP: &str = "┛"; +pub const SYM_BRANCH_UP_RIGHT: &str = "┗"; +pub const SYM_TEE_LEFT: &str = "┫"; +pub const SYM_TEE_RIGHT: &str = "┣"; +pub const SYM_TEE_UP: &str = "┻"; +pub const SYM_TEE_DOWN: &str = "┳"; +pub const SYM_SPACE: &str = " "; diff --git a/src/components/utils/logitems.rs b/src/components/utils/logitems.rs index 9e0706226e..1025667d5c 100644 --- a/src/components/utils/logitems.rs +++ b/src/components/utils/logitems.rs @@ -1,4 +1,7 @@ -use asyncgit::sync::{CommitId, CommitInfo}; +use asyncgit::{ + graph::GraphRow, + sync::{CommitId, CommitInfo}, +}; use chrono::{DateTime, Duration, Local, Utc}; use indexmap::IndexSet; use std::{rc::Rc, slice::Iter}; @@ -20,6 +23,7 @@ pub struct LogEntry { pub hash_short: BoxStr, pub id: CommitId, pub highlighted: bool, + pub graph: Option, } impl From for LogEntry { @@ -54,6 +58,7 @@ impl From for LogEntry { hash_short, id: c.id, highlighted: false, + graph: None, } } } @@ -78,12 +83,14 @@ impl LogEntry { } } -/// +/// A batch of parsed log entries with an index offset. #[derive(Default)] pub struct ItemBatch { index_offset: Option, items: Vec, highlighting: bool, + pub graph_ready: bool, + pub max_lane: usize, } impl ItemBatch { @@ -115,6 +122,8 @@ impl ItemBatch { pub fn clear(&mut self) { self.items.clear(); self.index_offset = None; + self.graph_ready = false; + self.max_lane = 0; } /// insert new batch of items @@ -143,6 +152,17 @@ impl ItemBatch { } } + /// + pub fn set_graph_rows(&mut self, rows: Vec) { + let mut max: usize = 0; + for (entry, row) in self.items.iter_mut().zip(rows) { + max = max.max(row.lane_count.into()); + entry.graph = Some(row); + } + self.max_lane = max; + self.graph_ready = true; + } + /// returns `true` if we should fetch updated list of items pub fn needs_data(&self, idx: usize, idx_max: usize) -> bool { let want_min = diff --git a/src/components/utils/mod.rs b/src/components/utils/mod.rs index 29485be1e4..746b2787bf 100644 --- a/src/components/utils/mod.rs +++ b/src/components/utils/mod.rs @@ -4,6 +4,7 @@ use unicode_width::UnicodeWidthStr; #[cfg(feature = "ghemoji")] pub mod emoji; pub mod filetree; +pub mod graphrow; pub mod logitems; pub mod scroll_horizontal; pub mod scroll_vertical; diff --git a/src/components/utils/statustree.rs b/src/components/utils/statustree.rs index 6147e57ead..8eb6cdd10b 100644 --- a/src/components/utils/statustree.rs +++ b/src/components/utils/statustree.rs @@ -437,7 +437,7 @@ impl StatusTree { if matches!(item_kind, FileTreeItemKind::Path(PathCollapsed(collapsed)) if collapsed) { // we encountered an inner path that is still collapsed - inner_collapsed = Some(format!("{}/", &item_path)); + inner_collapsed = Some(format!("{item_path}/")); } if prefix diff --git a/src/gitui.rs b/src/gitui.rs index 03d73b11c1..8052218ac2 100644 --- a/src/gitui.rs +++ b/src/gitui.rs @@ -226,7 +226,7 @@ mod tests { // Linux Temp Folder settings.add_filter(r" */tmp/\.tmp\S+-insta/", "[TEMP_FILE]"); // Commit ids that follow a vertical bar - settings.add_filter(r"│[a-z0-9]{7} ", "│[AAAAA] "); + settings.add_filter(r"│[^a-f0-9]*[a-f0-9]{7} ", "│[AAAAA] "); let _bound = settings.bind_to_scope(); } } diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index 24a9507a49..6d75fae73c 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -129,6 +129,7 @@ pub struct KeysList { pub commit: GituiKeyEvent, pub newline: GituiKeyEvent, pub goto_line: GituiKeyEvent, + pub log_toggle_graph: GituiKeyEvent, } #[rustfmt::skip] @@ -227,6 +228,7 @@ impl Default for KeysList { commit: GituiKeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), newline: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), goto_line: GituiKeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT), + log_toggle_graph: GituiKeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), } } } diff --git a/src/main.rs b/src/main.rs index fd662950a2..db8b1dbf18 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,7 +33,6 @@ #![forbid(unsafe_code)] #![deny( - mismatched_lifetime_syntaxes, unused_imports, unused_must_use, dead_code, @@ -114,7 +113,7 @@ type Terminal = ratatui::Terminal>; static TICK_INTERVAL: Duration = Duration::from_secs(5); static SPINNER_INTERVAL: Duration = Duration::from_millis(80); -/// +/// Events sent to the event-loop to wake up the UI. #[derive(Clone)] pub enum QueueEvent { Tick, diff --git a/src/snapshots/gitui__gitui__tests__app_log_tab_showing_one_commit.snap b/src/snapshots/gitui__gitui__tests__app_log_tab_showing_one_commit.snap index bbdd5be8a4..ea57f3165b 100644 --- a/src/snapshots/gitui__gitui__tests__app_log_tab_showing_one_commit.snap +++ b/src/snapshots/gitui__gitui__tests__app_log_tab_showing_one_commit.snap @@ -6,7 +6,7 @@ snapshot_kind: text " Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] " " ──────────────────────────────────────────────────────────────────────────────────────── " "┌Commit 1/1──────────────────────────────────────────────────────────────────────────────┐" -"│[AAAAA] <1m ago name initial █" +"│[AAAAA] <1m ago name initial █" "│ ║" "│ ║" "│ ║" @@ -14,4 +14,4 @@ snapshot_kind: text "│ ║" "│ ║" "└────────────────────────────────────────────────────────────────────────────────────────┘" -"Scroll [↑↓] Mark [˽] Details [⏎] Branches [b] Compare [⇧C] Copy Hash [y] Tag [t] more [.]" +"Scroll [↑↓] Mark [˽] Toggle Graph [g] Details [⏎] Branches [b] Compare [⇧C] more [.]" diff --git a/src/snapshots/gitui__gitui__tests__log_graph_hidden.snap b/src/snapshots/gitui__gitui__tests__log_graph_hidden.snap new file mode 100644 index 0000000000..bab83a0898 --- /dev/null +++ b/src/snapshots/gitui__gitui__tests__log_graph_hidden.snap @@ -0,0 +1,17 @@ +--- +source: src/gitui.rs +assertion_line: 359 +expression: terminal.backend() +--- +" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] " +" ──────────────────────────────────────────────────────────────────────────────────────── " +"┌Commit 3/3──────────────────────────────────────────────────────────────────────────────┐" +"│[COMMIT] <1m ago name commit B █" +"│[COMMIT] <1m ago name commit A ║" +"│[COMMIT] <1m ago name initial ║" +"│ ║" +"│ ║" +"│ ║" +"│ ║" +"└────────────────────────────────────────────────────────────────────────────────────────┘" +"Scroll [↑↓] Mark [˽] Toggle Graph [g] Details [⏎] Branches [b] Compare [⇧C] more [.]" diff --git a/src/snapshots/gitui__gitui__tests__log_graph_linear.snap b/src/snapshots/gitui__gitui__tests__log_graph_linear.snap new file mode 100644 index 0000000000..a03daefa14 --- /dev/null +++ b/src/snapshots/gitui__gitui__tests__log_graph_linear.snap @@ -0,0 +1,17 @@ +--- +source: src/gitui.rs +assertion_line: 341 +expression: terminal.backend() +--- +" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] " +" ──────────────────────────────────────────────────────────────────────────────────────── " +"┌Commit 3/3──────────────────────────────────────────────────────────────────────────────┐" +"│o [COMMIT] <1m ago name commit B █" +"│o [COMMIT] <1m ago name commit A ║" +"│o [COMMIT] <1m ago name initial ║" +"│ ║" +"│ ║" +"│ ║" +"│ ║" +"└────────────────────────────────────────────────────────────────────────────────────────┘" +"Scroll [↑↓] Mark [˽] Toggle Graph [g] Details [⏎] Branches [b] Compare [⇧C] more [.]" diff --git a/src/snapshots/gitui__gitui__tests__log_graph_with_branch.snap b/src/snapshots/gitui__gitui__tests__log_graph_with_branch.snap new file mode 100644 index 0000000000..b4033ba5ba --- /dev/null +++ b/src/snapshots/gitui__gitui__tests__log_graph_with_branch.snap @@ -0,0 +1,19 @@ +--- +source: src/gitui.rs +assertion_line: 375 +expression: terminal.backend() +--- +" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] " +" ──────────────────────────────────────────────────────────────────────────────────────── " +"┌Commit 4/4──────────────────────────────────────────────────────────────────────────────┐" +"│M━┓ [COMMIT] <1m ago name merge C into main █" +"│o ┃ [COMMIT] <1m ago name commit A ║" +"│┃ o [COMMIT] <1m ago name commit C ║" +"│o━┛ [COMMIT] <1m ago name initial ║" +"│ ║" +"│ ║" +"│ ║" +"│ ║" +"│ ║" +"└────────────────────────────────────────────────────────────────────────────────────────┘" +"Scroll [↑↓] Mark [˽] Toggle Graph [g] Details [⏎] Branches [b] Compare [⇧C] more [.]" diff --git a/src/strings.rs b/src/strings.rs index 93496cd2ca..d2d4da880d 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -1608,6 +1608,19 @@ pub mod commands { ) } + pub fn log_toggle_graph( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Toggle Graph [{}]", + key_config.get_hint(key_config.keys.log_toggle_graph), + ), + "toggle commit graph", + CMD_GROUP_LOG, + ) + } + pub fn reset_commit(key_config: &SharedKeyConfig) -> CommandText { CommandText::new( format!( diff --git a/src/tabs/revlog.rs b/src/tabs/revlog.rs index 9200c93f00..ef2d6dc648 100644 --- a/src/tabs/revlog.rs +++ b/src/tabs/revlog.rs @@ -33,6 +33,7 @@ use ratatui::{ Frame, }; use std::{ + collections::HashSet, rc::Rc, sync::{ atomic::{AtomicBool, Ordering}, @@ -59,7 +60,7 @@ enum LogSearch { Results(LogSearchResult), } -/// +/// Top-level revlog tab that ties together the commit list, details, and search. pub struct Revlog { repo: RepoPathRef, commit_details: CommitDetailsComponent, @@ -134,6 +135,12 @@ impl Revlog { self.list .refresh_extend_data(self.git_log.extract_items()?); + if self.list.is_graph_visible() + && !self.list.is_graph_ready() + { + self.update_graph_rows(); + } + self.git_tags.request(Duration::from_secs(3), false)?; if self.commit_details.is_visible() { @@ -150,6 +157,47 @@ impl Revlog { Ok(()) } + /// Computes graph rows for the current commit window. Rows + /// don't depend on the selection + /// so its is skipped while + /// [`CommitList::is_graph_ready`] holds the bag. + fn update_graph_rows(&mut self) { + let (slice, global_start) = self.list.get_loaded_slice(); + if slice.is_empty() { + return; + } + + let head_id = self.list.local_branches().iter().find_map( + |(id, branches)| { + let has_head = branches.iter().any(|branch| { + branch.local_details().is_some_and(|b| b.is_head) + }); + has_head.then_some(*id) + }, + ); + + let branch_tips: HashSet<_> = self + .list + .local_branches() + .keys() + .chain(self.list.remote_branches().keys()) + .copied() + .collect(); + + // TODO: include stashes (heh) + let stashes = HashSet::new(); + + if let Some(rows) = self.git_log.get_graph_rows( + &slice, + global_start, + &branch_tips, + &stashes, + head_id.as_ref(), + ) { + self.list.set_graph_rows(rows); + } + } + /// pub fn update_git( &mut self, diff --git a/src/ui/reflow.rs b/src/ui/reflow.rs index 2238c44532..6947bed11a 100644 --- a/src/ui/reflow.rs +++ b/src/ui/reflow.rs @@ -44,7 +44,7 @@ impl<'a> LineComposer<'a> for WordWrapper<'a, '_> { return None; } std::mem::swap(&mut self.current_line, &mut self.next_line); - self.next_line.truncate(0); + self.next_line.clear(); let mut current_line_width = self .current_line @@ -175,7 +175,7 @@ impl<'a> LineComposer<'a> for LineTruncator<'a, '_> { return None; } - self.current_line.truncate(0); + self.current_line.clear(); let mut current_line_width = 0; let mut skip_rest = false;