From b9933ae076738cd37c312bb81e6a3e62da944ee8 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Mon, 27 Jul 2026 19:44:12 +0200 Subject: [PATCH] Add Git worktree pane --- Cargo.lock | 1136 ++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + docs/USER_GUIDE.md | 8 + src/app.rs | 85 +++ src/app/generation.rs | 5 + src/app/git.rs | 930 +++++++++++++++++++++++++++++++++ src/app/projects.rs | 22 +- src/app/view.rs | 19 + src/app/view/git.rs | 429 ++++++++++++++++ 9 files changed, 2630 insertions(+), 5 deletions(-) create mode 100644 src/app/git.rs create mode 100644 src/app/view/git.rs diff --git a/Cargo.lock b/Cargo.lock index 079b4eb..d2e3f03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-activity" version = "0.6.1" @@ -89,6 +95,15 @@ dependencies = [ "libc", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -341,6 +356,21 @@ dependencies = [ "serde", ] +[[package]] +name = "bisync" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5020822f6d6f23196ccaf55e228db36f9de1cf788052b37992e17cbc96ec41a7" +dependencies = [ + "bisync_macros", +] + +[[package]] +name = "bisync_macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d21f40d350a700f6aa107e45fb26448cf489d34794b2ba4522181dc9f1173af6" + [[package]] name = "bit-set" version = "0.8.0" @@ -377,6 +407,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.1" @@ -417,6 +456,17 @@ dependencies = [ "piper", ] +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -443,6 +493,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -604,6 +660,15 @@ dependencies = [ "x11rb", ] +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -740,6 +805,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -792,6 +866,16 @@ dependencies = [ "wgpu", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -851,12 +935,57 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-url" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.19", +] + [[package]] name = "deranged" version = "0.5.8" @@ -909,15 +1038,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.1", "const-oid", - "crypto-common", + "crypto-common 0.2.2", ] [[package]] @@ -992,6 +1131,7 @@ dependencies = [ "cc", "diesel", "diesel_migrations", + "gix", "iced", "image", "memmap2", @@ -1028,12 +1168,27 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1134,6 +1289,16 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1159,6 +1324,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1435,6 +1610,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1540,6 +1725,792 @@ dependencies = [ "winapi", ] +[[package]] +name = "gix" +version = "0.86.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb3790fd8981cba7949f1ba924ef865d902df731627bc5998d14164063892fce" +dependencies = [ + "gix-actor", + "gix-attributes", + "gix-command", + "gix-commitgraph", + "gix-config", + "gix-date", + "gix-diff", + "gix-dir", + "gix-discover", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-ignore", + "gix-index", + "gix-lock", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-pathspec", + "gix-protocol", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-status", + "gix-submodule", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree", + "gix-worktree-stream", + "gix-zlib", + "nonempty", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-actor" +version = "0.41.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-attributes" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31c593692ebdc1e38858d9a2b56f6a594c501e24a38971fe6685571f5a07be0" +dependencies = [ + "bstr", + "gix-features", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "smallvec", + "thiserror 2.0.19", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd1d118d0f5d88b96e6f6e13b566475fef4797ead4a02c26fed36c1375066f7" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-chunk" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00706d4fef135ef4b01680d5218c6ee40cda8baf697b864296cbc887d19118f6" +dependencies = [ + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-commitgraph" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2cd7f054ae2727223fe46dd39c012f066b12f532962d336d29ee193261787da" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-config" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "103d11bef95c467577ecfa8b7b86a22e65af3507b2c9bfa3809a4afbae7df301" +dependencies = [ + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "gix-utils", + "smallvec", + "thiserror 2.0.19", + "unicode-bom", +] + +[[package]] +name = "gix-config-value" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f813e312a3f7f327187823cd4c754a3e4d948c98907d11e8f37554a2b6b8059" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-path", + "libc", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-date" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e47b9e8cdc688296609b706428de570f88b1e0eed7156dde7b4a89d26fa4567" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-diff" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc" +dependencies = [ + "bstr", + "gix-attributes", + "gix-command", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-imara-diff", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-worktree", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-dir" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f24bc78f283946ac64757c8a61ffa71f0230aa1e8d98cbb0771db479871dd5b6" +dependencies = [ + "bstr", + "gix-discover", + "gix-fs", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-trace", + "gix-utils", + "gix-worktree", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-discover" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f517766fa1101dfe2606c1a19a8ffa699099030995a9194445446dfe261bdf" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-error" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9292309fd944e71b2a3c96d3c03a6feb8852db646febdde7cbb9f79cb5f329" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-features" +version = "0.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20aa09e83a48dc02c5f5f08578aa79d3ab1bab4618b8c362f88684645a02bdcc" +dependencies = [ + "bytes", + "crc32fast", + "crossbeam-channel", + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "parking_lot", + "prodash", + "walkdir", +] + +[[package]] +name = "gix-filter" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7b5dbf524d97e839f642930c76d7f011c0791e7d11d8148989ac5af7c76aa8" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-fs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865cf13fcaf5455220546cb9607c416bd1be9a6caafd143655a362fdeab64e80" +dependencies = [ + "bstr", + "gix-features", + "gix-path", + "gix-utils", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-glob" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421e92a711554fa5827d1b0599d3389acdd0f6729e97a8c5a57d79af1e50bf36" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-features", + "gix-path", +] + +[[package]] +name = "gix-hash" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13adaa73415fd6c902310923f68d0b98e8cecf14b33ea58c02cc387cee56f54e" +dependencies = [ + "faster-hex", + "gix-features", + "sha1-checked", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-hashtable" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78fccd6fea3bcf0b39c076bae60ae49b08daaf538b950202101a981f9d3c01d3" +dependencies = [ + "gix-hash", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "gix-ignore" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cff8e8aa125e39377456073e63df3334d9e5741372ddcc226198015076dda2" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-trace", + "unicode-bom", +] + +[[package]] +name = "gix-imara-diff" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1" +dependencies = [ + "bstr", + "hashbrown 0.17.1", +] + +[[package]] +name = "gix-index" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5009c4e7e9f9b4cfaaab1153e49133eb04d79c015b5702d6c3d2ab94271a89c6" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.17.1", + "itoa", + "libc", + "memmap2", + "rustix 1.1.4", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-lock" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c69157820343bf1c6e4b88b9808e920900de02e18aaf5862b30ada43814848" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-object" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48c235e7f886eb819fc878af75be889333dd3c38bee02ed7af48ae2cf596c4" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-odb" +version = "0.83.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dd494ffb5037e62b8220109e894d2861ff2150a2cacbfccdba57ae1ebab2b96" +dependencies = [ + "arc-swap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "gix-zlib", + "memmap2", + "parking_lot", + "tempfile", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-pack" +version = "0.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d5446127b269706e85998065267ddd2ccc3550179da6780b22fe496175ccb20" +dependencies = [ + "clru", + "gix-chunk", + "gix-error", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "gix-zlib", + "memmap2", + "smallvec", + "thiserror 2.0.19", + "uluru", +] + +[[package]] +name = "gix-packetline" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3766025c72319c4accdd854a18e6f0dd176c8eb0f3bc8a60a7765be2b50cabf2" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ed3e8d7a82e886e17a72e03d4ba0c13db6f2219b6cd4e2900b4cae426ec20c9" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-pathspec" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f6fa5f8007f008187c3f60b4373209ca83d1cc947f35ede03e16cd15a4d137" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-attributes", + "gix-config-value", + "gix-glob", + "gix-path", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-protocol" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dede40e89c1e90f548415f50636bb051f6d9c60f68b8b710bc07825722d19588" +dependencies = [ + "bisync", + "bstr", + "gix-date", + "gix-features", + "gix-hash", + "gix-ref", + "gix-shallow", + "gix-transport", + "gix-utils", + "nonempty", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-quote" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb0c90a8f6202ceaaa22996cbf837c943ccb2d8af9ff3490f0758305e6b7883" +dependencies = [ + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-refspec" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7406282cc0259b51f6aee299ca3d31279a020530363152a2e6c96e8a7f7bbc83" +dependencies = [ + "bstr", + "gix-error", + "gix-glob", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-revision" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e55e09d4a1ecf2beecc8c09cafcad37979e805b31f588b0e957e191df5783681" +dependencies = [ + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "nonempty", +] + +[[package]] +name = "gix-revwalk" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c113c0a53294dc6280ffc06cbcc4f50f820397e97d6a00b429a44b8db26e29" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-sec" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f" +dependencies = [ + "bitflags 2.13.1", + "gix-path", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-shallow" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecc9f4b40537043e4bbd7d3d1760e74fb8e7b07a546166b558acaa73ad97f4a" +dependencies = [ + "bstr", + "gix-hash", + "gix-lock", + "nonempty", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-status" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f83b1c74e69b90411fbfe89ccebc47aa49457ce4eb9b942b1311258fc863d22" +dependencies = [ + "bstr", + "filetime", + "gix-diff", + "gix-dir", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-index", + "gix-object", + "gix-path", + "gix-pathspec", + "gix-worktree", + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-submodule" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd98077a56d08886112e6b08dc94076d03539f4bc0b9d7880e4be2b8a640d8c" +dependencies = [ + "bstr", + "gix-config", + "gix-path", + "gix-pathspec", + "gix-refspec", + "gix-url", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-tempfile" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b675b920bd5a61d17ad542772f03ec34c60feb8ff683e1560c03ae967363731e" +dependencies = [ + "dashmap", + "gix-fs", + "libc", + "parking_lot", + "tempfile", +] + +[[package]] +name = "gix-trace" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" + +[[package]] +name = "gix-transport" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c1bcf30081eb8ab04540a5795c67a2fcb22cb2e976e8b1a4ea657b1ed61469" +dependencies = [ + "bstr", + "gix-command", + "gix-features", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-sec", + "gix-url", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-traverse" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008c5cd879e46e86b5c2469e633611978b18775d53d05668d691bc13088bd409" +dependencies = [ + "bitflags 2.13.1", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-url" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d10e53b8eae21ee601687f47bbbd6cb2ed7162cb4c1cafdd422fb7ec64cbee" +dependencies = [ + "bstr", + "gix-path", + "gix-utils", + "percent-encoding", + "thiserror 2.0.19", +] + +[[package]] +name = "gix-utils" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1795bd2a970ca8b2185318c2abb97d955c71992f1cf28de73ad3b593a9f3ce8" +dependencies = [ + "bstr", + "fastrand", + "getrandom 0.4.3", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a034e84d1e04e1b1f20f51f12491da230b6ac8b925d0c8e1b89bcd87a7c5ccc" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-worktree" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31eb8e675122e83585e461fe28f68ff8c5ed55b49017b697e7e76423ff973424" +dependencies = [ + "bstr", + "gix-attributes", + "gix-features", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-ignore", + "gix-index", + "gix-object", + "gix-path", + "gix-validate", +] + +[[package]] +name = "gix-worktree-stream" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b088c8724e7be120c4798dd86925cf05332c9d356a463542578600c50c7a549" +dependencies = [ + "gix-attributes", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-traverse", + "parking_lot", +] + +[[package]] +name = "gix-zlib" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e8813f5579b3075ff9c90f7c59cd2b62b4ebb639361f0911648b22d7446cc7c" +dependencies = [ + "thiserror 2.0.19", + "zlib-rs", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -1774,6 +2745,21 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1789,6 +2775,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -1797,6 +2785,21 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] [[package]] name = "heck" @@ -2215,6 +3218,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -2677,6 +3733,12 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + [[package]] name = "num-conv" version = "0.2.2" @@ -3411,6 +4473,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prodash" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" +dependencies = [ + "parking_lot", +] + [[package]] name = "profiling" version = "1.0.18" @@ -3942,6 +5013,27 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest 0.10.7", + "sha1", +] + [[package]] name = "sha2" version = "0.11.0" @@ -3949,10 +5041,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -4654,6 +5752,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "uluru" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c8a2469e56e6e5095c82ccd3afb98dad95f7af7929aab6d8ba8d6e0f73657da" +dependencies = [ + "arrayvec", +] + [[package]] name = "unicase" version = "2.9.0" @@ -4672,6 +5779,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + [[package]] name = "unicode-ccc" version = "0.4.0" @@ -4690,6 +5803,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -5924,6 +7046,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zlib-rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index bf366fd..908d050 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ cc = "1.3.0" [dependencies] diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "64-column-tables"] } diesel_migrations = "2.3.2" +gix = { version = "0.86.0", default-features = false, features = ["max-performance-safe", "sha1", "status"] } iced = { version = "0.14.0", features = ["advanced", "highlighter", "image-without-codecs", "markdown", "svg", "tokio"] } image = { version = "0.25.10", default-features = false, features = ["gif", "jpeg", "png", "webp"] } memmap2 = "0.9.11" diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 69d71bd..f475526 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -20,6 +20,14 @@ the current local branch. An unsaved draft can be moved with the project menu; saved chats keep their original project. Use the branch menu to switch local branches when that project's chats are idle. +The **Git** pane shows added, changed, and deleted files from the current +worktree, including separate staged and worktree markers. Select files to stage, +unstage, or commit them, or commit the existing staged set. Committing with no +selection or staged files asks before staging and committing every change. File +names open a colored diff that can switch between unified and side-by-side +layouts. The fetch, pull, and push actions use the `origin` remote and report +authentication, hook, conflict, and other Git errors without hiding them. + Quitting with active chats asks for confirmation. Confirming stops their model and tool work; canceling leaves every chat running. diff --git a/src/app.rs b/src/app.rs index 77edefe..5c74cde 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ mod generation; +mod git; mod model_manager; mod preferences; mod projects; @@ -7,6 +8,7 @@ mod view; pub(crate) use view::app_theme; use generation::ChatMessage; +use git::{ActiveGitOperation, GitDiff, GitDiffMode, GitWorktree}; use model_manager::{ActiveDownload, ModelDownload, ModelOperation}; use preferences::PreferenceDraft; #[cfg(test)] @@ -41,6 +43,7 @@ const APP_ID: &str = "de.rfc1437.ds4server"; const METRICS_SAMPLE_INTERVAL: Duration = Duration::from_millis(200); /// Disc scans are cheap but not free; the explorer does not need 5 Hz. const CACHE_SCAN_INTERVAL: Duration = Duration::from_secs(2); +const GIT_SCAN_INTERVAL: Duration = Duration::from_secs(2); pub(super) const MIN_SIDEBAR_WIDTH: i32 = 180; pub(super) const MAX_SIDEBAR_WIDTH: i32 = 520; @@ -67,6 +70,15 @@ pub(crate) struct App { /// when its first chat turn is stored, so empty ones vanish on restart. drafts: HashMap, git_states: HashMap, + git_worktrees: HashMap, + git_selected_project: Option, + git_selected_files: HashSet, + git_commit_message: String, + git_diff: Option, + git_diff_mode: GitDiffMode, + git_commit_all_confirmation: bool, + git_operation: Option, + last_git_scan: Instant, #[cfg(target_os = "macos")] background_chats: HashMap, /// Session whose quick-actions menu is open. @@ -227,6 +239,7 @@ pub(super) enum DetailTab { #[default] Chat, A2ui, + Git, Stats, } @@ -401,6 +414,18 @@ pub(crate) enum Message { CreateSession(i32), DraftProjectChanged(i32), SwitchGitBranch(String), + ToggleGitFile(PathBuf), + OpenGitDiff(PathBuf), + SetGitDiffMode(GitDiffMode), + GitCommitMessageChanged(String), + GitStageSelected, + GitUnstageSelected, + GitCommit, + ConfirmGitCommitAll, + GitFetch, + GitPull, + GitPush, + GitOperationTick, DiscardSession(i32), SelectSession(i32, i32), RequestDeleteSession(i32), @@ -416,6 +441,7 @@ pub(crate) enum Message { ToggleArchivedSessions(i32), ShowChat, ShowA2ui, + ShowGit, ShowStats, ToggleSidebar, StartSidebarDrag, @@ -479,6 +505,15 @@ impl App { selected_session: None, drafts, git_states: HashMap::new(), + git_worktrees: HashMap::new(), + git_selected_project: None, + git_selected_files: HashSet::new(), + git_commit_message: String::new(), + git_diff: None, + git_diff_mode: GitDiffMode::default(), + git_commit_all_confirmation: false, + git_operation: None, + last_git_scan: Instant::now() - GIT_SCAN_INTERVAL, #[cfg(target_os = "macos")] background_chats: HashMap::new(), session_menu: None, @@ -612,6 +647,15 @@ impl App { selected_session: None, drafts: HashMap::new(), git_states: HashMap::new(), + git_worktrees: HashMap::new(), + git_selected_project: None, + git_selected_files: HashSet::new(), + git_commit_message: String::new(), + git_diff: None, + git_diff_mode: GitDiffMode::default(), + git_commit_all_confirmation: false, + git_operation: None, + last_git_scan: Instant::now() - GIT_SCAN_INTERVAL, #[cfg(target_os = "macos")] background_chats: HashMap::new(), session_menu: None, @@ -987,6 +1031,10 @@ impl App { Message::DismissPanel => { if self.quit_confirmation { self.quit_confirmation = false; + } else if self.git_diff.is_some() { + self.git_diff = None; + } else if self.git_commit_all_confirmation { + self.git_commit_all_confirmation = false; } else if self.pending_session_delete.is_some() { self.pending_session_delete = None; } else if self.pending_a2ui_dismissal.is_some() { @@ -1012,6 +1060,13 @@ impl App { return scroll_chat_to_end(); } Message::ShowA2ui => self.detail_tab = DetailTab::A2ui, + Message::ShowGit => { + self.detail_tab = DetailTab::Git; + if let Some(project_id) = self.selected_project { + self.refresh_git_state(); + self.refresh_git_worktree(project_id); + } + } Message::ShowStats => { self.detail_tab = DetailTab::Stats; self.scan_kv_cache(); @@ -1625,6 +1680,11 @@ impl App { } self.drafts.remove(&project_id); self.git_states.remove(&project_id); + self.git_worktrees.remove(&project_id); + if self.git_selected_project == Some(project_id) { + self.git_selected_project = None; + self.git_selected_files.clear(); + } if self.config.interface.last_project_id == Some(project_id) { self.config.interface.last_project_id = None; let _ = self.config.save(&config_path()); @@ -1656,6 +1716,18 @@ impl App { } Message::DraftProjectChanged(project_id) => self.move_draft_to_project(project_id), Message::SwitchGitBranch(branch) => self.switch_git_branch(&branch), + Message::ToggleGitFile(path) => self.toggle_git_file(path), + Message::OpenGitDiff(path) => self.open_git_diff(&path), + Message::SetGitDiffMode(mode) => self.git_diff_mode = mode, + Message::GitCommitMessageChanged(message) => self.git_commit_message = message, + Message::GitStageSelected => self.stage_selected_git_files(), + Message::GitUnstageSelected => self.unstage_selected_git_files(), + Message::GitCommit => self.commit_git(), + Message::ConfirmGitCommitAll => self.confirm_commit_all_git(), + Message::GitFetch => self.start_git_remote("fetch"), + Message::GitPull => self.start_git_remote("pull"), + Message::GitPush => self.start_git_remote("push"), + Message::GitOperationTick => self.poll_git_operation(), Message::DiscardSession(project_id) => self.discard_session(project_id), Message::OpenSessionMenu(session_id) => { self.session_rename = None; @@ -1961,6 +2033,11 @@ impl App { iced::time::every(Duration::from_secs(1)).map(|_| Message::DownloadProgressTick), ); } + if self.git_operation.is_some() { + subscriptions.push( + iced::time::every(Duration::from_millis(100)).map(|_| Message::GitOperationTick), + ); + } #[cfg(target_os = "macos")] let titling = self.active_titling.is_some(); #[cfg(not(target_os = "macos"))] @@ -2054,6 +2131,14 @@ impl App { { self.scan_kv_cache(); } + if self.detail_tab == DetailTab::Git + && self.git_operation.is_none() + && self.last_git_scan.elapsed() >= GIT_SCAN_INTERVAL + && let Some(project_id) = self.selected_project + { + self.refresh_git_state(); + self.refresh_git_worktree(project_id); + } } /// Removes one cache file. Checkpoints are disposable: the next turn on that diff --git a/src/app/generation.rs b/src/app/generation.rs index 1965e8f..02a5582 100644 --- a/src/app/generation.rs +++ b/src/app/generation.rs @@ -689,6 +689,11 @@ impl App { } if was_generating && !self.generating { self.refresh_git_state(); + if self.detail_tab == DetailTab::Git + && let Some(project_id) = self.selected_project + { + self.refresh_git_worktree(project_id); + } } changed } diff --git a/src/app/git.rs b/src/app/git.rs new file mode 100644 index 0000000..173994b --- /dev/null +++ b/src/app/git.rs @@ -0,0 +1,930 @@ +use super::*; +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::process::{Command, Output}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum GitChangeKind { + Added, + Modified, + Deleted, +} + +impl GitChangeKind { + pub(super) fn marker(self) -> &'static str { + match self { + Self::Added => "A", + Self::Modified => "M", + Self::Deleted => "D", + } + } +} + +#[derive(Clone, Debug)] +pub(super) struct GitFile { + pub(super) path: PathBuf, + pub(super) display_path: String, + pub(super) staged_kind: Option, + pub(super) worktree_kind: Option, +} + +impl GitFile { + pub(super) fn kind(&self) -> GitChangeKind { + self.worktree_kind.or(self.staged_kind).unwrap() + } +} + +#[derive(Clone, Debug)] +pub(super) struct GitWorktree { + pub(super) root: PathBuf, + pub(super) files: Vec, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum GitDiffMode { + #[default] + Unified, + Split, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum GitDiffLineKind { + Section, + Header, + Hunk, + Context, + Addition, + Deletion, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct GitDiffLine { + pub(super) kind: GitDiffLineKind, + pub(super) old_number: Option, + pub(super) new_number: Option, + pub(super) text: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum GitDiffRow { + Header(GitDiffLine), + Pair { + old: Option, + new: Option, + }, +} + +#[derive(Clone, Debug)] +pub(super) struct GitDiff { + pub(super) path: String, + pub(super) lines: Vec, +} + +impl GitDiff { + pub(super) fn split_rows(&self) -> Vec { + let mut rows = Vec::new(); + let mut index = 0; + while index < self.lines.len() { + let line = &self.lines[index]; + match line.kind { + GitDiffLineKind::Section | GitDiffLineKind::Header | GitDiffLineKind::Hunk => { + rows.push(GitDiffRow::Header(line.clone())); + index += 1; + } + GitDiffLineKind::Context => { + rows.push(GitDiffRow::Pair { + old: Some(line.clone()), + new: Some(line.clone()), + }); + index += 1; + } + GitDiffLineKind::Deletion => { + let deletion_start = index; + while index < self.lines.len() + && self.lines[index].kind == GitDiffLineKind::Deletion + { + index += 1; + } + let addition_start = index; + while index < self.lines.len() + && self.lines[index].kind == GitDiffLineKind::Addition + { + index += 1; + } + let deletions = &self.lines[deletion_start..addition_start]; + let additions = &self.lines[addition_start..index]; + for offset in 0..deletions.len().max(additions.len()) { + rows.push(GitDiffRow::Pair { + old: deletions.get(offset).cloned(), + new: additions.get(offset).cloned(), + }); + } + } + GitDiffLineKind::Addition => { + rows.push(GitDiffRow::Pair { + old: None, + new: Some(line.clone()), + }); + index += 1; + } + } + } + rows + } +} + +pub(super) struct ActiveGitOperation { + pub(super) project_id: i32, + pub(super) label: String, + receiver: mpsc::Receiver>, + clear_commit_on_success: bool, +} + +impl App { + pub(super) fn refresh_git_worktree(&mut self, project_id: i32) { + let Some(path) = self + .projects + .iter() + .find(|item| item.project.id == project_id) + .map(|item| PathBuf::from(&item.project.path)) + else { + self.git_worktrees.remove(&project_id); + return; + }; + match read_worktree(&path) { + Ok(worktree) => { + self.git_worktrees.insert(project_id, worktree); + if self.git_selected_project == Some(project_id) { + let files = &self.git_worktrees[&project_id].files; + self.git_selected_files + .retain(|path| files.iter().any(|file| file.path == *path)); + } + } + Err(error) => { + self.git_worktrees.remove(&project_id); + if self.git_states.contains_key(&project_id) { + self.error = Some(format!("Could not read Git status: {error}")); + } + } + } + if self.selected_project == Some(project_id) { + self.last_git_scan = Instant::now(); + } + } + + pub(super) fn open_git_diff(&mut self, path: &Path) { + let Some(project_id) = self.selected_project else { + return; + }; + let Some(worktree) = self.git_worktrees.get(&project_id) else { + return; + }; + let Some(file) = worktree.files.iter().find(|file| file.path == path) else { + self.error = Some("The selected Git file is no longer available.".into()); + return; + }; + match load_diff(&worktree.root, file) { + Ok(diff) => { + self.git_diff = Some(diff); + self.error = None; + } + Err(error) => self.error = Some(error), + } + } + + pub(super) fn toggle_git_file(&mut self, path: PathBuf) { + let Some(project_id) = self.selected_project else { + return; + }; + if !self + .git_worktrees + .get(&project_id) + .is_some_and(|worktree| worktree.files.iter().any(|file| file.path == path)) + { + return; + } + if self.git_selected_project != Some(project_id) { + self.git_selected_files.clear(); + self.git_selected_project = Some(project_id); + } + if !self.git_selected_files.remove(&path) { + self.git_selected_files.insert(path); + } + } + + pub(super) fn stage_selected_git_files(&mut self) { + let Some((project_id, root, paths)) = + self.git_selection(|file| file.worktree_kind.is_some()) + else { + self.error = Some("Select at least one file to stage.".into()); + return; + }; + self.start_git_operation(project_id, "Staging files", false, move || { + stage_paths(&root, &paths) + }); + } + + pub(super) fn unstage_selected_git_files(&mut self) { + let Some((project_id, root, paths)) = self.git_selection(|file| file.staged_kind.is_some()) + else { + self.error = Some("Select at least one file to unstage.".into()); + return; + }; + self.start_git_operation(project_id, "Unstaging files", false, move || { + unstage_paths(&root, &paths) + }); + } + + pub(super) fn commit_git(&mut self) { + let message = self.git_commit_message.trim().to_owned(); + if message.is_empty() { + self.error = Some("Enter a commit message.".into()); + return; + } + let Some(project_id) = self.selected_project else { + return; + }; + let Some(worktree) = self.git_worktrees.get(&project_id) else { + self.error = Some("The selected project is not a Git worktree.".into()); + return; + }; + if self.project_has_active_chat(project_id) { + self.error = Some("Stop active chats before committing worktree changes.".into()); + return; + } + let root = worktree.root.clone(); + let selected = self.selected_git_paths(project_id); + if !selected.is_empty() { + self.start_git_operation(project_id, "Committing selected files", true, move || { + run_git_paths(&root, &["add", "--"], &selected)?; + run_git_commit_paths(&root, &message, &selected) + }); + } else if worktree.files.iter().any(|file| file.staged_kind.is_some()) { + self.start_git_operation(project_id, "Committing staged changes", true, move || { + run_git_args(&root, &["commit", "-m", &message]).map(|_| ()) + }); + } else if worktree.files.is_empty() { + self.error = Some("There are no changes to commit.".into()); + } else { + self.git_commit_all_confirmation = true; + } + } + + pub(super) fn confirm_commit_all_git(&mut self) { + let message = self.git_commit_message.trim().to_owned(); + let Some(project_id) = self.selected_project else { + return; + }; + let Some(worktree) = self.git_worktrees.get(&project_id) else { + return; + }; + let root = worktree.root.clone(); + self.git_commit_all_confirmation = false; + self.start_git_operation(project_id, "Committing all changes", true, move || { + run_git_args(&root, &["add", "--all"])?; + run_git_args(&root, &["commit", "-m", &message]).map(|_| ()) + }); + } + + pub(super) fn start_git_remote(&mut self, action: &'static str) { + let Some(project_id) = self.selected_project else { + return; + }; + let Some(worktree) = self.git_worktrees.get(&project_id) else { + self.error = Some("The selected project is not a Git worktree.".into()); + return; + }; + if action == "pull" && self.project_has_active_chat(project_id) { + self.error = Some("Stop active chats before pulling worktree changes.".into()); + return; + } + let root = worktree.root.clone(); + let label = match action { + "fetch" => "Fetching from origin", + "pull" => "Pulling from origin", + "push" => "Pushing to origin", + _ => return, + }; + let branch = self + .git_states + .get(&project_id) + .and_then(|state| state.current.clone()); + if action != "fetch" && branch.is_none() { + self.error = Some("Attach HEAD to a local branch before pulling or pushing.".into()); + return; + } + self.start_git_operation(project_id, label, false, move || { + run_git_remote(&root, action, branch.as_deref()) + }); + } + + pub(super) fn poll_git_operation(&mut self) { + let result = + self.git_operation + .as_ref() + .and_then(|operation| match operation.receiver.try_recv() { + Ok(result) => Some(result), + Err(TryRecvError::Empty) => None, + Err(TryRecvError::Disconnected) => { + Some(Err("The Git operation stopped unexpectedly.".into())) + } + }); + let Some(result) = result else { + return; + }; + let operation = self.git_operation.take().unwrap(); + match result { + Ok(()) => { + if operation.clear_commit_on_success { + self.git_commit_message.clear(); + self.git_selected_files.clear(); + } + self.error = None; + } + Err(error) => self.error = Some(error), + } + self.refresh_git_worktree(operation.project_id); + if self.selected_project == Some(operation.project_id) { + self.refresh_git_state(); + } + } + + fn git_selection( + &self, + include: impl Fn(&GitFile) -> bool, + ) -> Option<(i32, PathBuf, Vec)> { + let project_id = self.selected_project?; + let worktree = self.git_worktrees.get(&project_id)?; + let paths = self.selected_git_paths_matching(project_id, include); + (!paths.is_empty()).then(|| (project_id, worktree.root.clone(), paths)) + } + + fn selected_git_paths(&self, project_id: i32) -> Vec { + self.selected_git_paths_matching(project_id, |_| true) + } + + fn selected_git_paths_matching( + &self, + project_id: i32, + include: impl Fn(&GitFile) -> bool, + ) -> Vec { + if self.git_selected_project != Some(project_id) { + return Vec::new(); + } + self.git_worktrees + .get(&project_id) + .map(|worktree| { + worktree + .files + .iter() + .filter(|file| self.git_selected_files.contains(&file.path) && include(file)) + .map(|file| file.path.clone()) + .collect() + }) + .unwrap_or_default() + } + + fn start_git_operation( + &mut self, + project_id: i32, + label: &str, + clear_commit_on_success: bool, + operation: impl FnOnce() -> Result<(), String> + Send + 'static, + ) { + if self.git_operation.is_some() { + self.error = Some("Another Git operation is still running.".into()); + return; + } + if self.project_has_active_chat(project_id) + && !matches!(label, "Fetching from origin" | "Pushing to origin") + { + self.error = Some("Stop active chats before changing Git state.".into()); + return; + } + let (sender, receiver) = mpsc::channel(); + if let Err(error) = thread::Builder::new() + .name("ds4-git".into()) + .spawn(move || { + let _ = sender.send(operation()); + }) + { + self.error = Some(format!("Could not start the Git operation: {error}")); + return; + } + self.git_operation = Some(ActiveGitOperation { + project_id, + label: label.into(), + receiver, + clear_commit_on_success, + }); + self.error = None; + } +} + +fn read_worktree(project_path: &Path) -> Result { + let repository = gix::discover(project_path).map_err(|error| error.to_string())?; + let root = repository + .workdir() + .ok_or_else(|| "bare repositories do not have a worktree".to_owned())? + .to_path_buf(); + let dirwalk = repository + .dirwalk_options() + .map_err(|error| error.to_string())?; + let mut files = BTreeMap::::new(); + let changes = repository + .status(gix::progress::Discard) + .map_err(|error| error.to_string())? + .index_worktree_options_mut(|options| options.dirwalk_options = Some(dirwalk)) + .untracked_files(gix::status::UntrackedFiles::Files) + .into_iter(Vec::::new()) + .map_err(|error| error.to_string())?; + for change in changes { + let change = change.map_err(|error| error.to_string())?; + let path = gix::path::from_bstr(change.location()).into_owned(); + let display_path = path.to_string_lossy().into_owned(); + let file = files.entry(path.clone()).or_insert_with(|| GitFile { + path, + display_path, + staged_kind: None, + worktree_kind: None, + }); + match change { + gix::status::Item::IndexWorktree(change) => { + file.worktree_kind = change.summary().map(worktree_kind); + } + gix::status::Item::TreeIndex(change) => { + file.staged_kind = Some(match change { + gix::diff::index::Change::Addition { .. } => GitChangeKind::Added, + gix::diff::index::Change::Deletion { .. } => GitChangeKind::Deleted, + gix::diff::index::Change::Modification { .. } + | gix::diff::index::Change::Rewrite { .. } => GitChangeKind::Modified, + }); + } + } + } + files.retain(|_, file| file.staged_kind.is_some() || file.worktree_kind.is_some()); + Ok(GitWorktree { + root, + files: files.into_values().collect(), + }) +} + +fn worktree_kind(summary: gix::status::index_worktree::iter::Summary) -> GitChangeKind { + use gix::status::index_worktree::iter::Summary; + match summary { + Summary::Added | Summary::IntentToAdd => GitChangeKind::Added, + Summary::Removed => GitChangeKind::Deleted, + Summary::Modified + | Summary::TypeChange + | Summary::Renamed + | Summary::Copied + | Summary::Conflict => GitChangeKind::Modified, + } +} + +fn load_diff(root: &Path, file: &GitFile) -> Result { + let mut sections = Vec::new(); + if file.staged_kind.is_some() { + let output = run_git_path( + root, + &["diff", "--cached", "--no-ext-diff", "--no-color", "--"], + &file.path, + )?; + sections.push(("Staged changes", output)); + } + if file.worktree_kind.is_some() { + let mut output = run_git_path( + root, + &["diff", "--no-ext-diff", "--no-color", "--"], + &file.path, + )?; + if output.is_empty() && file.worktree_kind == Some(GitChangeKind::Added) { + output = run_no_index_diff(root, &file.path)?; + } + sections.push(("Worktree changes", output)); + } + let mut lines = Vec::new(); + for (label, content) in sections { + lines.push(GitDiffLine { + kind: GitDiffLineKind::Section, + old_number: None, + new_number: None, + text: label.into(), + }); + lines.extend(parse_unified_diff(&content)); + } + if lines.is_empty() { + lines.push(GitDiffLine { + kind: GitDiffLineKind::Header, + old_number: None, + new_number: None, + text: "No textual diff is available.".into(), + }); + } + Ok(GitDiff { + path: file.display_path.clone(), + lines, + }) +} + +fn parse_unified_diff(diff: &str) -> Vec { + let mut old_number = None; + let mut new_number = None; + diff.lines() + .map(|line| { + let (kind, old, new) = if line.starts_with("@@") { + let mut parts = line.split_whitespace(); + let _ = parts.next(); + old_number = parts.next().and_then(diff_range_start); + new_number = parts.next().and_then(diff_range_start); + (GitDiffLineKind::Hunk, None, None) + } else if line.starts_with("---") || line.starts_with("+++") { + (GitDiffLineKind::Header, None, None) + } else if line.starts_with('-') { + let current = old_number; + old_number = old_number.map(|number| number.saturating_add(1)); + (GitDiffLineKind::Deletion, current, None) + } else if line.starts_with('+') { + let current = new_number; + new_number = new_number.map(|number| number.saturating_add(1)); + (GitDiffLineKind::Addition, None, current) + } else if line.starts_with(' ') { + let current_old = old_number; + let current_new = new_number; + old_number = old_number.map(|number| number.saturating_add(1)); + new_number = new_number.map(|number| number.saturating_add(1)); + (GitDiffLineKind::Context, current_old, current_new) + } else { + (GitDiffLineKind::Header, None, None) + }; + GitDiffLine { + kind, + old_number: old, + new_number: new, + text: line.to_owned(), + } + }) + .collect() +} + +fn diff_range_start(range: &str) -> Option { + range + .get(1..)? + .split_once(',') + .map_or(range.get(1..), |(start, _)| Some(start))? + .parse() + .ok() +} + +fn git_has_head(root: &Path) -> bool { + Command::new("git") + .arg("-C") + .arg(root) + .args(["rev-parse", "--verify", "HEAD"]) + .output() + .is_ok_and(|output| output.status.success()) +} + +fn stage_paths(root: &Path, paths: &[PathBuf]) -> Result<(), String> { + run_git_paths(root, &["add", "--"], paths).map(|_| ()) +} + +fn unstage_paths(root: &Path, paths: &[PathBuf]) -> Result<(), String> { + if git_has_head(root) { + run_git_paths(root, &["restore", "--staged", "--"], paths)?; + } else { + run_git_paths( + root, + &["rm", "--cached", "--quiet", "--ignore-unmatch", "--"], + paths, + )?; + } + Ok(()) +} + +fn run_git_commit_paths(root: &Path, message: &str, paths: &[PathBuf]) -> Result<(), String> { + let mut command = git_command(root); + command.args(["commit", "-m", message, "--"]); + command.args(paths); + finish_git(command.output(), "commit selected files").map(|_| ()) +} + +fn run_git_paths(root: &Path, arguments: &[&str], paths: &[PathBuf]) -> Result { + let mut command = git_command(root); + command.args(arguments); + command.args(paths); + finish_git( + command.output(), + arguments.first().copied().unwrap_or("run Git"), + ) +} + +fn run_git_path(root: &Path, arguments: &[&str], path: &Path) -> Result { + run_git_paths(root, arguments, &[path.to_path_buf()]) +} + +fn run_git_args(root: &Path, arguments: &[&str]) -> Result { + let mut command = git_command(root); + command.args(arguments); + finish_git( + command.output(), + arguments.first().copied().unwrap_or("run Git"), + ) +} + +fn run_git_remote(root: &Path, action: &str, branch: Option<&str>) -> Result<(), String> { + let mut command = git_command(root); + match action { + "fetch" => { + command.args(["fetch", "origin"]); + } + "pull" => { + command.args([ + "pull", + "--no-rebase", + "origin", + branch.ok_or_else(|| "The current branch is unavailable.".to_owned())?, + ]); + } + "push" => { + command.args([ + "push", + "origin", + &format!( + "HEAD:{}", + branch.ok_or_else(|| "The current branch is unavailable.".to_owned())? + ), + ]); + } + _ => return Err("The Git remote action is unavailable.".into()), + } + finish_git(command.output(), action).map(|_| ()) +} + +fn run_no_index_diff(root: &Path, path: &Path) -> Result { + let mut command = git_command(root); + command.args(["diff", "--no-index", "--no-color", "--"]); + command.arg(OsStr::new("/dev/null")); + command.arg(root.join(path)); + let output = command + .output() + .map_err(|error| format!("Could not run Git diff: {error}"))?; + if output.status.success() || output.status.code() == Some(1) { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + finish_git(Ok(output), "diff") + } +} + +fn git_command(root: &Path) -> Command { + let mut command = Command::new("git"); + command.arg("-C").arg(root).env("GIT_TERMINAL_PROMPT", "0"); + command +} + +fn finish_git(output: std::io::Result, action: &str) -> Result { + let output = output.map_err(|error| format!("Could not {action}: {error}"))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + Err(if detail.is_empty() { + format!("Could not {action}.") + } else { + format!("Could not {action}: {detail}") + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); + + #[test] + fn gitoxide_status_merges_staged_and_worktree_changes() { + let directory = repository_fixture(); + std::fs::write(directory.join("added.txt"), "added\n").unwrap(); + run_git_args(&directory, &["config", "status.showUntrackedFiles", "no"]).unwrap(); + std::fs::write(directory.join("changed.txt"), "changed\n").unwrap(); + run_git_args(&directory, &["add", "changed.txt"]).unwrap(); + std::fs::write(directory.join("changed.txt"), "changed again\n").unwrap(); + std::fs::remove_file(directory.join("deleted.txt")).unwrap(); + + let worktree = read_worktree(&directory).unwrap(); + let added = worktree + .files + .iter() + .find(|file| file.display_path == "added.txt") + .unwrap(); + assert_eq!(added.worktree_kind, Some(GitChangeKind::Added)); + let changed = worktree + .files + .iter() + .find(|file| file.display_path == "changed.txt") + .unwrap(); + assert_eq!(changed.staged_kind, Some(GitChangeKind::Modified)); + assert_eq!(changed.worktree_kind, Some(GitChangeKind::Modified)); + let diff = load_diff(&worktree.root, changed).unwrap(); + assert_eq!( + diff.lines + .iter() + .filter(|line| line.kind == GitDiffLineKind::Section) + .count(), + 2 + ); + assert!( + diff.lines + .iter() + .any(|line| line.kind == GitDiffLineKind::Addition) + ); + assert!( + diff.lines + .iter() + .any(|line| line.kind == GitDiffLineKind::Deletion) + ); + let deleted = worktree + .files + .iter() + .find(|file| file.display_path == "deleted.txt") + .unwrap(); + assert_eq!(deleted.worktree_kind, Some(GitChangeKind::Deleted)); + + let added_path = PathBuf::from("added.txt"); + stage_paths(&directory, std::slice::from_ref(&added_path)).unwrap(); + let worktree = read_worktree(&directory).unwrap(); + let added = worktree + .files + .iter() + .find(|file| file.path == added_path) + .unwrap(); + assert_eq!(added.staged_kind, Some(GitChangeKind::Added)); + + unstage_paths(&directory, std::slice::from_ref(&added_path)).unwrap(); + let worktree = read_worktree(&directory).unwrap(); + let added = worktree + .files + .iter() + .find(|file| file.path == added_path) + .unwrap(); + assert_eq!(added.staged_kind, None); + assert_eq!(added.worktree_kind, Some(GitChangeKind::Added)); + + stage_paths(&directory, std::slice::from_ref(&added_path)).unwrap(); + run_git_commit_paths( + &directory, + "Commit selected file", + std::slice::from_ref(&added_path), + ) + .unwrap(); + let worktree = read_worktree(&directory).unwrap(); + assert!(!worktree.files.iter().any(|file| file.path == added_path)); + assert_eq!( + worktree + .files + .iter() + .find(|file| file.display_path == "changed.txt") + .unwrap() + .staged_kind, + Some(GitChangeKind::Modified) + ); + + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn unstage_works_before_the_first_commit() { + let directory = empty_repository_fixture(); + std::fs::write(directory.join("new.txt"), "new\n").unwrap(); + let path = PathBuf::from("new.txt"); + stage_paths(&directory, std::slice::from_ref(&path)).unwrap(); + unstage_paths(&directory, std::slice::from_ref(&path)).unwrap(); + + let worktree = read_worktree(&directory).unwrap(); + let file = worktree + .files + .iter() + .find(|file| file.path == path) + .unwrap(); + assert_eq!(file.staged_kind, None); + assert_eq!(file.worktree_kind, Some(GitChangeKind::Added)); + + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn origin_fetch_pull_and_push_use_the_current_branch() { + let local = repository_fixture(); + let remote = local.with_extension("remote.git"); + let collaborator = local.with_extension("collaborator"); + let output = Command::new("git") + .args(["init", "--bare"]) + .arg(&remote) + .output() + .unwrap(); + assert!(output.status.success()); + run_git_args( + &local, + &["remote", "add", "origin", remote.to_str().unwrap()], + ) + .unwrap(); + run_git_remote(&local, "push", Some("main")).unwrap(); + run_git_args(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]).unwrap(); + + let output = Command::new("git") + .arg("clone") + .arg(&remote) + .arg(&collaborator) + .output() + .unwrap(); + assert!(output.status.success()); + run_git_args(&collaborator, &["config", "user.name", "DS4Server Test"]).unwrap(); + run_git_args( + &collaborator, + &["config", "user.email", "test@ds4server.invalid"], + ) + .unwrap(); + std::fs::write(collaborator.join("remote.txt"), "from origin\n").unwrap(); + run_git_args(&collaborator, &["add", "remote.txt"]).unwrap(); + run_git_args(&collaborator, &["commit", "-m", "Remote change"]).unwrap(); + run_git_remote(&collaborator, "push", Some("main")).unwrap(); + + run_git_remote(&local, "fetch", None).unwrap(); + run_git_remote(&local, "pull", Some("main")).unwrap(); + assert_eq!( + std::fs::read_to_string(local.join("remote.txt")).unwrap(), + "from origin\n" + ); + std::fs::write(local.join("local.txt"), "to origin\n").unwrap(); + run_git_args(&local, &["add", "local.txt"]).unwrap(); + run_git_args(&local, &["commit", "-m", "Local change"]).unwrap(); + run_git_remote(&local, "push", Some("main")).unwrap(); + assert_eq!( + run_git_args(&remote, &["show", "main:local.txt"]) + .unwrap() + .trim(), + "to origin" + ); + + std::fs::remove_dir_all(local).unwrap(); + std::fs::remove_dir_all(remote).unwrap(); + std::fs::remove_dir_all(collaborator).unwrap(); + } + + #[test] + fn unified_diff_parser_aligns_replacements_for_split_view() { + let lines = parse_unified_diff( + "diff --git a/file b/file\n--- a/file\n+++ b/file\n@@ -2,2 +2,2 @@\n same\n-old\n+new\n", + ); + assert_eq!(lines[4].old_number, Some(2)); + assert_eq!(lines[4].new_number, Some(2)); + assert_eq!(lines[5].old_number, Some(3)); + assert_eq!(lines[6].new_number, Some(3)); + let rows = GitDiff { + path: "file".into(), + lines, + } + .split_rows(); + assert!(rows.iter().any(|row| matches!( + row, + GitDiffRow::Pair { + old: Some(old), + new: Some(new), + } if old.kind == GitDiffLineKind::Deletion + && new.kind == GitDiffLineKind::Addition + ))); + } + + fn repository_fixture() -> PathBuf { + let directory = empty_repository_fixture(); + std::fs::write(directory.join("changed.txt"), "original\n").unwrap(); + std::fs::write(directory.join("deleted.txt"), "original\n").unwrap(); + run_git_args(&directory, &["add", "changed.txt", "deleted.txt"]).unwrap(); + run_git_args(&directory, &["commit", "-m", "Initial"]).unwrap(); + directory + } + + fn empty_repository_fixture() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "ds4-server-git-pane-{}-{nonce}-{}", + std::process::id(), + NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&directory).unwrap(); + run_git_args(&directory, &["init", "-b", "main"]).unwrap(); + run_git_args(&directory, &["config", "user.name", "DS4Server Test"]).unwrap(); + run_git_args( + &directory, + &["config", "user.email", "test@ds4server.invalid"], + ) + .unwrap(); + directory + } +} diff --git a/src/app/projects.rs b/src/app/projects.rs index b7b9319..0ca2369 100644 --- a/src/app/projects.rs +++ b/src/app/projects.rs @@ -220,8 +220,17 @@ impl App { /// Selects a project and stores it as the one to reopen on the next launch. pub(super) fn remember_project(&mut self, project_id: i32) { + if self.selected_project != Some(project_id) { + self.git_selected_project = None; + self.git_selected_files.clear(); + self.git_diff = None; + self.git_commit_all_confirmation = false; + } self.selected_project = Some(project_id); self.refresh_git_state(); + if self.detail_tab == DetailTab::Git { + self.refresh_git_worktree(project_id); + } if self.config.interface.last_project_id == Some(project_id) { return; } @@ -273,6 +282,7 @@ impl App { } None => { self.git_states.remove(&project_id); + self.git_worktrees.remove(&project_id); } } } @@ -281,6 +291,15 @@ impl App { let Some(project_id) = self.selected_project else { return; }; + if self + .git_operation + .as_ref() + .is_some_and(|operation| operation.project_id == project_id) + { + self.error = + Some("Wait for the current Git operation before switching branches.".into()); + return; + } if self.project_has_active_chat(project_id) { self.error = Some("Stop active chats before switching branches.".into()); return; @@ -304,8 +323,9 @@ impl App { }; match git_switch(&path, branch) { Ok(()) => { - self.refresh_git_state(); self.error = None; + self.refresh_git_state(); + self.refresh_git_worktree(project_id); } Err(error) => self.error = Some(error), } diff --git a/src/app/view.rs b/src/app/view.rs index be201ca..a4f962e 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -1,5 +1,6 @@ mod a2ui; mod chat; +mod git; mod model_manager; mod preferences; mod stats; @@ -102,6 +103,8 @@ impl App { /// tree, so the layers below have to stay out of the dialog's field order. pub(super) fn modal_open(&self) -> bool { self.quit_confirmation + || self.git_diff.is_some() + || self.git_commit_all_confirmation || self.pending_project_path.is_some() || self.pending_session_delete.is_some() || self.session_rename.is_some() @@ -176,6 +179,10 @@ impl App { layers.push(self.session_menu_panel(session)); } else if let Some(surface_id) = &self.pending_a2ui_dismissal { layers.push(self.a2ui_dismiss_panel(surface_id)); + } else if self.git_commit_all_confirmation { + layers.push(self.git_commit_all_panel()); + } else if self.git_diff.is_some() { + layers.push(self.git_diff_panel()); } else if let Some(panel) = self.a2ui_modal_panel() { layers.push(panel); } @@ -192,6 +199,10 @@ impl App { layers.push(self.session_menu_panel(session)); } else if let Some(surface_id) = &self.pending_a2ui_dismissal { layers.push(self.a2ui_dismiss_panel(surface_id)); + } else if self.git_commit_all_confirmation { + layers.push(self.git_commit_all_panel()); + } else if self.git_diff.is_some() { + layers.push(self.git_diff_panel()); } else if let Some(panel) = self.a2ui_modal_panel() { layers.push(panel); } @@ -505,6 +516,7 @@ impl App { fn detail_tabs(&self) -> Element<'_, Message> { let chat_active = self.detail_tab == DetailTab::Chat; let a2ui_active = self.detail_tab == DetailTab::A2ui; + let git_active = self.detail_tab == DetailTab::Git; let stats_active = self.detail_tab == DetailTab::Stats; let tabs = container( row![ @@ -520,6 +532,12 @@ impl App { .padding([0, 16]) .on_press(Message::ShowA2ui) .style(move |theme, status| segmented_button_style(theme, status, a2ui_active)), + button(text("Git").size(13)) + .width(88) + .height(TITLE_BAR_CONTROL - 4.0) + .padding([0, 16]) + .on_press(Message::ShowGit) + .style(move |theme, status| segmented_button_style(theme, status, git_active)), button(text("Stats").size(13)) .width(88) .height(TITLE_BAR_CONTROL - 4.0) @@ -542,6 +560,7 @@ impl App { match self.detail_tab { DetailTab::Chat => self.chat_detail(), DetailTab::A2ui => self.a2ui_detail(), + DetailTab::Git => self.git_detail(), DetailTab::Stats => self.stats_dashboard(), } } diff --git a/src/app/view/git.rs b/src/app/view/git.rs new file mode 100644 index 0000000..df71db9 --- /dev/null +++ b/src/app/view/git.rs @@ -0,0 +1,429 @@ +use super::*; +use crate::app::git::{GitChangeKind, GitDiffLine, GitDiffLineKind, GitDiffMode, GitDiffRow}; +use iced::widget::column; + +impl App { + pub(super) fn git_detail(&self) -> Element<'_, Message> { + let Some(project) = self.selected_project() else { + return git_empty( + "No project selected", + "Choose a project to inspect its worktree.", + ); + }; + let Some(state) = self.git_states.get(&project.project.id) else { + return git_empty( + "Not a Git worktree", + "The selected project is not inside a Git repository.", + ); + }; + let Some(worktree) = self.git_worktrees.get(&project.project.id) else { + return git_empty("Reading worktree", "Git status will appear here shortly."); + }; + + let counts = [ + (GitChangeKind::Added, "Added"), + (GitChangeKind::Modified, "Changed"), + (GitChangeKind::Deleted, "Deleted"), + ]; + let mut file_list = column![].spacing(0); + for (kind, label) in counts { + let files = worktree + .files + .iter() + .filter(|file| file.kind() == kind) + .collect::>(); + if files.is_empty() { + continue; + } + file_list = file_list.push( + container( + row![ + text(label).size(11).color(muted_text()), + Space::new().width(Length::Fill), + text(files.len()).size(11).color(muted_text()), + ] + .align_y(Alignment::Center), + ) + .padding([8, 12]) + .width(Length::Fill), + ); + for file in files { + let path = file.path.clone(); + let selected = self.git_selected_project == Some(project.project.id) + && self.git_selected_files.contains(&file.path); + let staged = file.staged_kind.map(|kind| format!("S {}", kind.marker())); + let worktree = file + .worktree_kind + .map(|kind| format!("W {}", kind.marker())); + file_list = file_list.push(rule::horizontal(1)).push( + container( + row![ + checkbox(selected).on_toggle({ + let path = path.clone(); + move |_| Message::ToggleGitFile(path.clone()) + }), + text(kind.marker()).size(12).color(git_change_color(kind)), + button(text(&file.display_path).size(13)) + .padding(0) + .style(button::text) + .on_press(Message::OpenGitDiff(path)), + Space::new().width(Length::Fill), + staged.map(|label| text(label).size(11).color(staged_color())), + worktree.map(|label| text(label).size(11).color(muted_text())), + ] + .spacing(8) + .align_y(Alignment::Center), + ) + .padding([9, 12]) + .width(Length::Fill), + ); + } + } + if worktree.files.is_empty() { + file_list = file_list.push( + container( + column![ + text("Worktree clean").size(18), + text("There are no staged or unstaged changes.") + .size(13) + .color(muted_text()), + ] + .spacing(6) + .align_x(Alignment::Center), + ) + .padding(36) + .center_x(Length::Fill) + .width(Length::Fill), + ); + } + + let selected = self.git_selected_project == Some(project.project.id); + let can_stage = selected + && worktree.files.iter().any(|file| { + self.git_selected_files.contains(&file.path) && file.worktree_kind.is_some() + }); + let can_unstage = selected + && worktree.files.iter().any(|file| { + self.git_selected_files.contains(&file.path) && file.staged_kind.is_some() + }); + let idle = self.git_operation.is_none(); + let commit_ready = + idle && !worktree.files.is_empty() && !self.git_commit_message.trim().is_empty(); + let commit_input: Element<'_, Message> = if self.modal_open() { + container( + text(if self.git_commit_message.is_empty() { + "Commit message" + } else { + &self.git_commit_message + }) + .size(13) + .color(muted_text()), + ) + .padding(9) + .width(Length::Fill) + .style(preference_group_style) + .into() + } else { + text_input("Commit message", &self.git_commit_message) + .on_input(Message::GitCommitMessageChanged) + .on_submit(Message::GitCommit) + .padding(9) + .size(13) + .into() + }; + let controls = column![ + row![ + git_icon_action( + "+", + "Stage selected files", + (idle && can_stage).then_some(Message::GitStageSelected) + ), + git_icon_action( + "−", + "Unstage selected files", + (idle && can_unstage).then_some(Message::GitUnstageSelected) + ), + commit_input, + git_icon_action("●", "Commit", commit_ready.then_some(Message::GitCommit)), + rule::vertical(22), + git_icon_action("↻", "Fetch from origin", idle.then_some(Message::GitFetch)), + git_icon_action("↓", "Pull from origin", idle.then_some(Message::GitPull)), + git_icon_action("↑", "Push to origin", idle.then_some(Message::GitPush)), + ] + .spacing(7) + .align_y(Alignment::Center), + self.git_operation + .as_ref() + .map(|operation| text(format!("{}…", operation.label)) + .size(12) + .color(muted_text())), + ] + .spacing(6); + + let summary = format!( + "{} change{}", + worktree.files.len(), + if worktree.files.len() == 1 { "" } else { "s" } + ); + container( + column![ + row![ + column![ + text(&project.project.name).size(20), + text(worktree.root.display().to_string()) + .size(11) + .color(muted_text()), + ] + .spacing(4), + Space::new().width(Length::Fill), + column![ + text(&state.label).size(13), + text(summary).size(11).color(muted_text()), + ] + .spacing(4) + .align_x(Alignment::End), + ] + .align_y(Alignment::Center), + container(scrollable(file_list).height(Length::Fill)) + .height(Length::Fill) + .width(Length::Fill) + .style(overview_style), + container(controls) + .padding(12) + .width(Length::Fill) + .style(overview_style), + ] + .spacing(12), + ) + .padding(Padding::new(28.0).top(22.0)) + .height(Length::Fill) + .width(Length::Fill) + .into() + } + + pub(super) fn git_commit_all_panel(&self) -> Element<'_, Message> { + let dialog = container( + column![ + text("Commit all changes?").size(24), + text("No files are selected and no changes are staged. All worktree changes will be staged and committed.") + .size(14), + row![ + Space::new().width(Length::Fill), + action_button("Cancel").on_press(Message::DismissPanel), + action_button("Commit all changes").on_press(Message::ConfirmGitCommitAll), + ] + .spacing(8), + ] + .spacing(12), + ) + .padding(22) + .width(500) + .style(overview_style); + opaque( + container(dialog) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(modal_backdrop_style), + ) + } + + pub(super) fn git_diff_panel(&self) -> Element<'_, Message> { + let diff = self.git_diff.as_ref().unwrap(); + let unified_active = self.git_diff_mode == GitDiffMode::Unified; + let split_active = self.git_diff_mode == GitDiffMode::Split; + let mode = container( + row![ + button(text("1 column").size(12)) + .padding([4, 10]) + .on_press(Message::SetGitDiffMode(GitDiffMode::Unified)) + .style(move |theme, status| segmented_button_style( + theme, + status, + unified_active + )), + button(text("2 columns").size(12)) + .padding([4, 10]) + .on_press(Message::SetGitDiffMode(GitDiffMode::Split)) + .style(move |theme, status| segmented_button_style( + theme, + status, + split_active + )), + ] + .spacing(2), + ) + .padding(2) + .style(segmented_control_style); + let content = if unified_active { + unified_diff(&diff.lines) + } else { + split_diff(diff.split_rows()) + }; + let diff_scroll = scrollable(content) + .direction(scrollable::Direction::Both { + vertical: scrollable::Scrollbar::new(), + horizontal: scrollable::Scrollbar::new(), + }) + .height(Length::Fill) + .width(Length::Fill); + let dialog = container( + column![ + row![ + column![text("File diff").size(22), text(&diff.path).size(13)].spacing(4), + Space::new().width(Length::Fill), + mode, + action_button("Close").on_press(Message::DismissPanel), + ] + .spacing(10) + .align_y(Alignment::Center), + container(diff_scroll) + .height(Length::Fill) + .width(Length::Fill) + .style(overview_style), + ] + .spacing(12), + ) + .padding(18) + .max_width(1_240) + .max_height(840) + .width(Length::Fill) + .height(Length::Fill) + .style(overview_style); + opaque( + container(dialog) + .padding([42, 32]) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(modal_backdrop_style), + ) + } +} + +fn git_empty<'a>(title: &'a str, description: &'a str) -> Element<'a, Message> { + container( + column![ + text(title).size(24), + text(description).size(14).color(muted_text()), + ] + .spacing(8) + .align_x(Alignment::Center), + ) + .center_x(Length::Fill) + .center_y(Length::Fill) + .into() +} + +fn git_icon_action<'a>( + symbol: &'a str, + label: &'a str, + message: Option, +) -> Element<'a, Message> { + let mut action = action_button(text(symbol).size(17)).padding([5, 9]); + if let Some(message) = message { + action = action.on_press(message); + } + tooltip( + action, + container(text(label).size(12)) + .padding(8) + .style(preference_group_style), + tooltip::Position::Top, + ) + .gap(5) + .into() +} + +fn unified_diff(lines: &[GitDiffLine]) -> Element<'_, Message> { + let mut content = column![].spacing(0).width(Length::Shrink); + for line in lines { + content = content.push(diff_line(line, false)); + } + content.into() +} + +fn split_diff(rows: Vec) -> Element<'static, Message> { + let mut content = column![].spacing(0).width(Length::Fill); + for row in rows { + content = content.push(match row { + GitDiffRow::Header(line) => diff_line(&line, false), + GitDiffRow::Pair { old, new } => row![ + diff_half(old.as_ref(), true), + rule::vertical(1), + diff_half(new.as_ref(), false), + ] + .height(24) + .into(), + }); + } + content.into() +} + +fn diff_half(line: Option<&GitDiffLine>, old: bool) -> Element<'static, Message> { + line.map_or_else( + || { + container(Space::new().width(Length::Fill)) + .width(Length::Fill) + .into() + }, + |line| diff_line(line, old), + ) +} + +fn diff_line(line: &GitDiffLine, old: bool) -> Element<'static, Message> { + let kind = line.kind; + let number = if old { + line.old_number + } else { + line.new_number.or(line.old_number) + }; + container( + row![ + text(number.map_or_else(String::new, |number| number.to_string())) + .font(iced::Font::MONOSPACE) + .size(11) + .color(muted_text()) + .width(42), + text(line.text.clone()) + .font(iced::Font::MONOSPACE) + .size(12) + .wrapping(iced::widget::text::Wrapping::None), + ] + .spacing(8) + .align_y(Alignment::Center), + ) + .padding([3, 8]) + .height(24) + .width(Length::Fill) + .style(move |_| diff_line_style(kind)) + .into() +} + +fn diff_line_style(kind: GitDiffLineKind) -> container::Style { + let background = match kind { + GitDiffLineKind::Addition => Some(Color::from_rgba8(45, 120, 72, 0.24)), + GitDiffLineKind::Deletion => Some(Color::from_rgba8(170, 55, 64, 0.24)), + GitDiffLineKind::Hunk => Some(Color::from_rgba8(55, 92, 150, 0.22)), + GitDiffLineKind::Section => Some(Color::from_rgba8(255, 255, 255, 0.08)), + GitDiffLineKind::Header | GitDiffLineKind::Context => None, + }; + match background { + Some(background) => container::Style::default().background(background), + None => container::Style::default(), + } +} + +fn git_change_color(kind: GitChangeKind) -> Color { + match kind { + GitChangeKind::Added => Color::from_rgb8(93, 186, 112), + GitChangeKind::Modified => Color::from_rgb8(224, 174, 82), + GitChangeKind::Deleted => Color::from_rgb8(224, 96, 104), + } +} + +fn staged_color() -> Color { + Color::from_rgb8(93, 186, 112) +} + +fn modal_backdrop_style(_: &Theme) -> container::Style { + container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.72)) +}