From 6d4aa7c634507df3177afb095275a6c082bbf5db Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Mon, 23 Feb 2026 14:16:48 -0800 Subject: [PATCH 1/8] feat(staged): implement standalone diff viewer app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the first working version of the Staged app — a focused, standalone diff viewer that opens on any git repository. Backend (Rust/Tauri): - Add git-diff crate dependency for shared diff computation - Implement Tauri commands: get_repo_info, list_recent_commits, list_diff_files, get_file_diff, get_file_at_ref, get_launch_args, open_repo_dialog - CLI arg parsing: repo path as positional arg, --staged/-s, --branch/-b, --commit/-c , --all/-a flags - Folder picker dialog for switching repositories Frontend (Svelte): - HomePage: repo info bar, mode picker cards (All Changes, Full Branch, Commit), commit history picker - DiffPage: full-page diff view using shared @builderbot/diff-viewer components with file tree sidebar and inline commenting - commands.ts: typed invoke wrappers with DiffSpec builders (specUncommitted, specBranch, specCommit, specRange) - App.svelte: simple view router (home <-> diff) with CLI arg auto-navigation - Dark theme CSS variables matching Mark's design system The app uses DiffSpec directly (base/head GitRef pairs) rather than Mark's branch-id-based abstraction, making it work on any repo without a database. Co-Authored-By: Claude Opus 4.6 --- apps/staged/package.json | 9 +- apps/staged/src-tauri/Cargo.lock | 449 ++++------ apps/staged/src-tauri/Cargo.toml | 7 +- .../src-tauri/capabilities/default.json | 6 +- apps/staged/src-tauri/src/lib.rs | 287 +++++- apps/staged/src-tauri/tauri.conf.json | 10 +- apps/staged/src/App.svelte | 91 +- apps/staged/src/app.css | 99 ++- apps/staged/src/lib/DiffPage.svelte | 820 ++++++++++++++++++ apps/staged/src/lib/HomePage.svelte | 499 +++++++++++ apps/staged/src/lib/commands.ts | 115 +++ apps/staged/tsconfig.app.json | 3 +- pnpm-lock.yaml | 36 +- 13 files changed, 2131 insertions(+), 300 deletions(-) create mode 100644 apps/staged/src/lib/DiffPage.svelte create mode 100644 apps/staged/src/lib/HomePage.svelte create mode 100644 apps/staged/src/lib/commands.ts diff --git a/apps/staged/package.json b/apps/staged/package.json index 9b8ffbac..4ef7b307 100644 --- a/apps/staged/package.json +++ b/apps/staged/package.json @@ -17,6 +17,7 @@ "@tauri-apps/cli": "^2.10.0", "@tsconfig/svelte": "^5.0.6", "@types/node": "^24.10.1", + "@types/sanitize-html": "^2.13.0", "prettier": "^3.7.4", "prettier-plugin-svelte": "^3.4.1", "svelte": "^5.46.4", @@ -25,6 +26,12 @@ "vite": "^7.2.4" }, "dependencies": { - "@tauri-apps/api": "^2.10.0" + "@builderbot/diff-viewer": "workspace:*", + "@tauri-apps/api": "^2.10.0", + "@tauri-apps/plugin-dialog": "^2.2.0", + "lucide-svelte": "^0.575.0", + "marked": "^17.0.1", + "sanitize-html": "^2.17.0", + "shiki": "^3.20.0" } } diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index 557404c8..7a8dd2a9 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -2,58 +2,12 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "acp-client" -version = "0.1.0" -dependencies = [ - "agent-client-protocol", - "anyhow", - "async-trait", - "blox-cli", - "log", - "serde", - "serde_json", - "tokio", - "tokio-util", -] - [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "agent-client-protocol" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2659b1089101b15db31137710159421cb44785ecdb5ba784be3b4a6f8cb8a475" -dependencies = [ - "agent-client-protocol-schema", - "anyhow", - "async-broadcast", - "async-trait", - "derive_more 2.1.1", - "futures", - "log", - "serde", - "serde_json", -] - -[[package]] -name = "agent-client-protocol-schema" -version = "0.10.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44bc1fef9c32f03bce2ab44af35b6f483bfd169bf55cc59beeb2e3b1a00ae4d1" -dependencies = [ - "anyhow", - "derive_more 2.1.1", - "schemars 1.2.1", - "serde", - "serde_json", - "strum", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -93,29 +47,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "atk" version = "0.18.2" @@ -196,16 +127,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "blox-cli" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", - "thiserror 2.0.18", - "wait-timeout", -] - [[package]] name = "brotli" version = "8.0.2" @@ -227,20 +148,6 @@ dependencies = [ "alloc-stdlib", ] -[[package]] -name = "builderbot-actions" -version = "0.1.0" -dependencies = [ - "acp-client", - "anyhow", - "async-trait", - "libc", - "serde", - "serde_json", - "tokio", - "uuid", -] - [[package]] name = "bumpalo" version = "3.20.2" @@ -342,6 +249,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -400,30 +309,12 @@ dependencies = [ "memchr", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "convert_case" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "cookie" version = "0.18.1" @@ -605,36 +496,13 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case 0.4.0", + "convert_case", "proc-macro2", "quote", "rustc_version", "syn 2.0.117", ] -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case 0.10.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", - "unicode-xid", -] - [[package]] name = "digest" version = "0.10.7" @@ -679,6 +547,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.11.0", + "block2", + "libc", "objc2", ] @@ -789,37 +659,6 @@ dependencies = [ "typeid", ] -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - [[package]] name = "fdeflate" version = "0.3.7" @@ -913,21 +752,6 @@ dependencies = [ "new_debug_unreachable", ] -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -935,7 +759,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -990,7 +813,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1198,6 +1020,30 @@ dependencies = [ "winapi", ] +[[package]] +name = "git-diff" +version = "0.1.0" +dependencies = [ + "git2", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.11.0", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + [[package]] name = "glib" version = "0.18.5" @@ -1695,6 +1541,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.88" @@ -1792,6 +1648,20 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libgit2-sys" +version = "0.18.3+1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + [[package]] name = "libloading" version = "0.7.4" @@ -1812,6 +1682,32 @@ dependencies = [ "libc", ] +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "litemap" version = "0.8.1" @@ -2230,6 +2126,24 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2261,12 +2175,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - [[package]] name = "parking_lot" version = "0.12.5" @@ -2796,6 +2704,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -2828,7 +2760,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap 1.9.3", - "schemars_derive 0.8.22", + "schemars_derive", "serde", "serde_json", "url", @@ -2855,7 +2787,6 @@ checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", - "schemars_derive 1.2.1", "serde", "serde_json", ] @@ -2872,18 +2803,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "schemars_derive" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.117", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -2898,7 +2817,7 @@ checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" dependencies = [ "bitflags 1.3.2", "cssparser", - "derive_more 0.99.20", + "derive_more", "fxhash", "log", "phf 0.8.0", @@ -3093,16 +3012,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "simd-adler32" version = "0.3.8" @@ -3201,13 +3110,12 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "staged" version = "0.1.0" dependencies = [ - "acp-client", - "blox-cli", - "builderbot-actions", + "git-diff", "serde", "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", ] [[package]] @@ -3241,27 +3149,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "swift-rs" version = "1.0.7" @@ -3499,6 +3386,63 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-plugin" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692a77abd8b8773e107a42ec0e05b767b8d2b7ece76ab36c6c3947e34df9f53f" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", +] + [[package]] name = "tauri-runtime" version = "2.10.0" @@ -3702,23 +3646,10 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "signal-hook-registry", "socket2", - "tokio-macros", "windows-sys 0.61.2", ] -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -3727,7 +3658,6 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", - "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -4041,6 +3971,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4073,15 +4009,6 @@ dependencies = [ "libc", ] -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - [[package]] name = "walkdir" version = "2.5.0" diff --git a/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index dabb7351..680cf91f 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -19,8 +19,7 @@ tauri-build = { version = "2.5.5", features = [] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } tauri = { version = "2.10.2", features = [] } +tauri-plugin-dialog = "2" -# Shared crates (proves the split works) -builderbot-actions = { path = "../../../crates/builderbot-actions" } -acp-client = { path = "../../../crates/acp-client" } -blox-cli = { path = "../../../crates/blox-cli" } +# Shared crate for diff computation +git-diff = { path = "../../../crates/git-diff" } diff --git a/apps/staged/src-tauri/capabilities/default.json b/apps/staged/src-tauri/capabilities/default.json index 8e906f70..b1ba88a7 100644 --- a/apps/staged/src-tauri/capabilities/default.json +++ b/apps/staged/src-tauri/capabilities/default.json @@ -3,5 +3,9 @@ "identifier": "default", "description": "enables the default permissions", "windows": ["main"], - "permissions": ["core:default"] + "permissions": [ + "core:default", + "dialog:default", + "dialog:allow-open" + ] } diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index da7f6a5a..76f8f7a6 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -1,14 +1,293 @@ //! Staged — standalone diff viewer. +//! +//! A focused diff viewer that opens a git repository and shows diffs +//! using the shared git-diff crate and @builderbot/diff-viewer package. -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! Welcome to Staged.", name) +use serde::Serialize; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +// ============================================================================= +// App state +// ============================================================================= + +struct AppState { + repo_path: PathBuf, +} + +// ============================================================================= +// Types +// ============================================================================= + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct RepoInfo { + path: String, + branch: String, + default_branch: String, + commits_ahead: u32, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct CommitInfo { + sha: String, + short_sha: String, + message: String, + author: String, + timestamp: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct DiffFilesResponse { + files: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct LaunchArgs { + repo_path: String, + mode: Option, + commit: Option, +} + +// ============================================================================= +// Commands: Git info +// ============================================================================= + +#[tauri::command(rename_all = "camelCase")] +fn get_repo_info(state: tauri::State<'_, Mutex>) -> Result { + let state = state.lock().unwrap(); + let repo = &state.repo_path; + + let branch = run_git(repo, &["rev-parse", "--abbrev-ref", "HEAD"]) + .unwrap_or_else(|_| "HEAD".to_string()); + + let default_branch = + git_diff::detect_default_branch(repo).unwrap_or_else(|_| "origin/main".to_string()); + + let commits_ahead = run_git( + repo, + &["rev-list", "--count", &format!("{default_branch}..HEAD")], + ) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + + Ok(RepoInfo { + path: repo.display().to_string(), + branch: branch.trim().to_string(), + default_branch, + commits_ahead, + }) +} + +#[tauri::command(rename_all = "camelCase")] +fn list_recent_commits( + state: tauri::State<'_, Mutex>, + count: Option, +) -> Result, String> { + let state = state.lock().unwrap(); + let repo = &state.repo_path; + let count = count.unwrap_or(20); + + let output = run_git( + repo, + &[ + "log", + &format!("-{count}"), + "--format=%H%n%h%n%s%n%an%n%at", + "--no-merges", + ], + ) + .map_err(|e| e.to_string())?; + + let mut commits = Vec::new(); + let lines: Vec<&str> = output.lines().collect(); + + for chunk in lines.chunks(5) { + if chunk.len() == 5 { + commits.push(CommitInfo { + sha: chunk[0].to_string(), + short_sha: chunk[1].to_string(), + message: chunk[2].to_string(), + author: chunk[3].to_string(), + timestamp: chunk[4].parse().unwrap_or(0), + }); + } + } + + Ok(commits) +} + +// ============================================================================= +// Commands: Diff operations +// ============================================================================= + +#[tauri::command(rename_all = "camelCase")] +fn list_diff_files( + state: tauri::State<'_, Mutex>, + spec: git_diff::DiffSpec, +) -> Result { + let state = state.lock().unwrap(); + let repo = &state.repo_path; + + let files = git_diff::list_diff_files(repo, &spec).map_err(|e| e.to_string())?; + Ok(DiffFilesResponse { files }) } +#[tauri::command(rename_all = "camelCase")] +fn get_file_diff( + state: tauri::State<'_, Mutex>, + spec: git_diff::DiffSpec, + path: String, +) -> Result { + let state = state.lock().unwrap(); + let repo = &state.repo_path; + let file_path = Path::new(&path); + + git_diff::get_file_diff(repo, &spec, file_path).map_err(|e| e.to_string()) +} + +#[tauri::command(rename_all = "camelCase")] +fn get_file_at_ref( + state: tauri::State<'_, Mutex>, + ref_name: String, + path: String, +) -> Result { + let state = state.lock().unwrap(); + let repo = &state.repo_path; + + git_diff::get_file_at_ref(repo, &ref_name, &path).map_err(|e| e.to_string()) +} + +// ============================================================================= +// Commands: Launch args +// ============================================================================= + +#[tauri::command(rename_all = "camelCase")] +fn get_launch_args(state: tauri::State<'_, Mutex>) -> LaunchArgs { + let state = state.lock().unwrap(); + + let args: Vec = std::env::args().collect(); + let mut mode: Option = None; + let mut commit: Option = None; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--staged" | "-s" => mode = Some("staged".to_string()), + "--branch" | "-b" => mode = Some("branch".to_string()), + "--commit" | "-c" => { + mode = Some("commit".to_string()); + if i + 1 < args.len() && !args[i + 1].starts_with('-') { + i += 1; + commit = Some(args[i].clone()); + } + } + "--all" | "-a" => mode = Some("all".to_string()), + _ => {} + } + i += 1; + } + + LaunchArgs { + repo_path: state.repo_path.display().to_string(), + mode, + commit, + } +} + +// ============================================================================= +// Commands: Dialog +// ============================================================================= + +#[tauri::command(rename_all = "camelCase")] +async fn open_repo_dialog( + app: tauri::AppHandle, + state: tauri::State<'_, Mutex>, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + let path = app.dialog().file().blocking_pick_folder(); + + match path { + Some(folder) => { + let folder_path = folder.to_string(); + let path = PathBuf::from(&folder_path); + if !path.join(".git").exists() && run_git(&path, &["rev-parse", "--git-dir"]).is_err() { + return Err(format!("{folder_path} is not a git repository")); + } + let mut s = state.lock().unwrap(); + s.repo_path = path; + Ok(Some(folder_path)) + } + None => Ok(None), + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +fn run_git(repo: &Path, args: &[&str]) -> Result { + let output = std::process::Command::new("git") + .args(["-C", &repo.display().to_string()]) + .args(args) + .output() + .map_err(|e| e.to_string())?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.into_owned()); + } + + String::from_utf8(output.stdout).map_err(|e| e.to_string()) +} + +fn resolve_repo_path() -> PathBuf { + let args: Vec = std::env::args().collect(); + let mut iter = args.iter().skip(1); + + while let Some(arg) = iter.next() { + match arg.as_str() { + "--commit" | "-c" => { + iter.next(); // skip the commit value + } + s if s.starts_with('-') => {} + path => { + let p = PathBuf::from(path); + if p.exists() { + return std::fs::canonicalize(&p).unwrap_or(p); + } + } + } + } + + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +// ============================================================================= +// App entry point +// ============================================================================= + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let repo_path = resolve_repo_path(); + tauri::Builder::default() - .invoke_handler(tauri::generate_handler![greet]) + .plugin(tauri_plugin_dialog::init()) + .manage(Mutex::new(AppState { repo_path })) + .invoke_handler(tauri::generate_handler![ + get_repo_info, + list_recent_commits, + list_diff_files, + get_file_diff, + get_file_at_ref, + get_launch_args, + open_repo_dialog, + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/apps/staged/src-tauri/tauri.conf.json b/apps/staged/src-tauri/tauri.conf.json index b0247abe..981f2941 100644 --- a/apps/staged/src-tauri/tauri.conf.json +++ b/apps/staged/src-tauri/tauri.conf.json @@ -12,9 +12,13 @@ "app": { "windows": [ { - "title": "Staged — Diff Viewer", - "width": 1200, - "height": 800 + "title": "Staged", + "width": 1400, + "height": 900, + "minWidth": 900, + "minHeight": 600, + "titleBarStyle": "Overlay", + "hiddenTitle": true } ], "security": { diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 7754f586..a26f2144 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -1,26 +1,75 @@ -
-

Staged

-

Diff Viewer — placeholder application.

-
- - + + +{#if initialized} + {#if view.kind === 'home'} + + {:else} + + {/if} +{/if} diff --git a/apps/staged/src/app.css b/apps/staged/src/app.css index 19fc4a69..927517b0 100644 --- a/apps/staged/src/app.css +++ b/apps/staged/src/app.css @@ -1,10 +1,103 @@ +* { + box-sizing: border-box; +} + :root { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - color: #e0e0e0; - background-color: #1a1a2e; + --bg-primary: #27212e; + --bg-chrome: #18151d; + --bg-deepest: #020203; + --bg-elevated: #38333f; + --bg-hover: #342e3b; + + --border-subtle: #38333f; + --border-muted: #47424d; + --border-emphasis: #5d5962; + + --text-primary: #ffffff; + --text-muted: #91889b; + --text-faint: #5c5565; + --text-accent: #58a6ff; + + --status-modified: #d29922; + --status-added: #3fb950; + --status-deleted: #f85149; + --status-renamed: #58a6ff; + --status-untracked: #91889b; + + --diff-added-bg: rgba(63, 185, 80, 0.08); + --diff-removed-bg: rgba(248, 81, 73, 0.08); + --diff-changed-bg: rgba(255, 255, 255, 0.04); + --diff-range-border: #524d58; + --diff-comment-highlight: rgba(88, 166, 255, 0.5); + + --search-match-bg: rgba(250, 200, 50, 0.35); + --search-current-match-bg: rgba(255, 150, 50, 0.5); + + --annotation-overlay-bg: rgba(0, 0, 0, 0.5); + --annotation-border: rgba(255, 255, 255, 0.1); + + --ui-accent: #3fb950; + --ui-accent-hover: #2ea043; + --ui-success: #3fb950; + --ui-danger: #f85149; + --ui-danger-bg: rgba(248, 81, 73, 0.1); + --ui-selection: rgba(255, 255, 255, 0.08); + + --scrollbar-thumb: #47424d; + --scrollbar-thumb-hover: #5d5962; + --scrollbar-thumb-transparent: rgba(255, 255, 255, 0.15); + --scrollbar-thumb-hover-transparent: rgba(255, 255, 255, 0.25); + + --shadow-overlay: rgba(0, 0, 0, 0.6); + --shadow-elevated: 0 8px 24px rgba(0, 0, 0, 0.4); + + --size-base: 13px; + --size-xs: calc(var(--size-base) * 0.846); + --size-sm: calc(var(--size-base) * 0.923); + --size-md: var(--size-base); + --size-lg: calc(var(--size-base) * 1.077); + --size-xl: calc(var(--size-base) * 1.231); + + --accent-primary: #58a6ff; + + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + font-size: var(--size-md); + line-height: 1.5; + font-weight: 400; + + color: var(--text-primary); + background-color: var(--bg-primary); + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } body { margin: 0; padding: 0; + min-height: 100vh; +} + +#app { + min-height: 100vh; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); } diff --git a/apps/staged/src/lib/DiffPage.svelte b/apps/staged/src/lib/DiffPage.svelte new file mode 100644 index 00000000..4602be77 --- /dev/null +++ b/apps/staged/src/lib/DiffPage.svelte @@ -0,0 +1,820 @@ + + + + + +
+ +
+ +
+ {label} + {#if files.length > 0} + {files.length} file{files.length === 1 ? '' : 's'} + {/if} +
+
+
+ + +
+ +
+ {#if loading} +
+ + Loading diff... +
+ {:else if error} +
+ {error} +
+ {:else if files.length === 0} +
+ No changes +
+ {:else} + c.path === selectedFile)} + loading={loadingFile !== null} + beforeLabel="before" + afterLabel="after" + onAddComment={handleAddComment} + onUpdateComment={handleUpdateComment} + onDeleteComment={handleDeleteComment} + /> + {/if} +
+ + + {#if !sidebarCollapsed} +
+ {#if loading} + + {:else if files.length > 0} + + {/if} +
+ {/if} +
+
+ + diff --git a/apps/staged/src/lib/HomePage.svelte b/apps/staged/src/lib/HomePage.svelte new file mode 100644 index 00000000..3ecc539c --- /dev/null +++ b/apps/staged/src/lib/HomePage.svelte @@ -0,0 +1,499 @@ + + + +
+
+ + {#if loadingRepo} +
+ + Loading repository... +
+ {:else if repoError} +
+

{repoError}

+ +
+ {:else if repoInfo} +
+ {shortenPath(repoInfo.path)} + · + + + {repoInfo.branch} + + {#if repoInfo.commitsAhead > 0} + · + {repoInfo.commitsAhead} ahead of {repoInfo.defaultBranch.replace('origin/', '')} + {/if} + +
+ + +
+ + + + + +
+ + + {#if showCommitPicker} +
+ {#if loadingCommits} +
+ + Loading commits... +
+ {:else if commits.length === 0} +
No commits found
+ {:else} + {#each commits as commit (commit.sha)} + + {/each} + {/if} +
+ {/if} + {/if} +
+
+ + diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts new file mode 100644 index 00000000..9f6e6285 --- /dev/null +++ b/apps/staged/src/lib/commands.ts @@ -0,0 +1,115 @@ +/** + * Typed invoke wrappers for Staged's Tauri commands. + */ + +import { invoke } from '@tauri-apps/api/core'; +import type { FileDiffSummary, FileDiff, File } from '@builderbot/diff-viewer/types'; + +// ============================================================================= +// Types (matching Rust backend) +// ============================================================================= + +export interface RepoInfo { + path: string; + branch: string; + defaultBranch: string; + commitsAhead: number; +} + +export interface CommitInfo { + sha: string; + shortSha: string; + message: string; + author: string; + timestamp: number; +} + +export interface DiffFilesResponse { + files: FileDiffSummary[]; +} + +export interface LaunchArgs { + repoPath: string; + mode: string | null; + commit: string | null; +} + +/** Matches the git-diff crate's GitRef enum (tagged union). */ +export type GitRef = + | { type: 'WorkingTree' } + | { type: 'Rev'; value: string } + | { type: 'MergeBase' } + | { type: 'MergeBaseOf'; value: [string, string] }; + +export interface DiffSpec { + base: GitRef; + head: GitRef; +} + +// ============================================================================= +// Diff spec builders +// ============================================================================= + +/** All uncommitted changes: HEAD -> working tree */ +export function specUncommitted(): DiffSpec { + return { + base: { type: 'Rev', value: 'HEAD' }, + head: { type: 'WorkingTree' }, + }; +} + +/** Full branch diff: merge-base -> HEAD */ +export function specBranch(): DiffSpec { + return { + base: { type: 'MergeBase' }, + head: { type: 'Rev', value: 'HEAD' }, + }; +} + +/** Single commit: parent -> commit */ +export function specCommit(sha: string): DiffSpec { + return { + base: { type: 'Rev', value: `${sha}~1` }, + head: { type: 'Rev', value: sha }, + }; +} + +/** Range from a commit to HEAD */ +export function specRange(fromSha: string): DiffSpec { + return { + base: { type: 'Rev', value: fromSha }, + head: { type: 'Rev', value: 'HEAD' }, + }; +} + +// ============================================================================= +// Commands +// ============================================================================= + +export function getRepoInfo(): Promise { + return invoke('get_repo_info'); +} + +export function listRecentCommits(count?: number): Promise { + return invoke('list_recent_commits', { count: count ?? null }); +} + +export function listDiffFiles(spec: DiffSpec): Promise { + return invoke('list_diff_files', { spec }); +} + +export function getFileDiff(spec: DiffSpec, path: string): Promise { + return invoke('get_file_diff', { spec, path }); +} + +export function getFileAtRef(refName: string, path: string): Promise { + return invoke('get_file_at_ref', { refName, path }); +} + +export function getLaunchArgs(): Promise { + return invoke('get_launch_args'); +} + +export function openRepoDialog(): Promise { + return invoke('open_repo_dialog'); +} diff --git a/apps/staged/tsconfig.app.json b/apps/staged/tsconfig.app.json index 90ed3c78..6e2c2f19 100644 --- a/apps/staged/tsconfig.app.json +++ b/apps/staged/tsconfig.app.json @@ -9,7 +9,8 @@ "noEmit": true, "allowJs": true, "checkJs": true, - "moduleDetection": "force" + "moduleDetection": "force", + "skipLibCheck": true }, "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7eed3cdf..23586651 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,9 +71,27 @@ importers: apps/staged: dependencies: + '@builderbot/diff-viewer': + specifier: workspace:* + version: link:../../packages/diff-viewer '@tauri-apps/api': specifier: ^2.10.0 version: 2.10.1 + '@tauri-apps/plugin-dialog': + specifier: ^2.2.0 + version: 2.6.0 + lucide-svelte: + specifier: ^0.575.0 + version: 0.575.0(svelte@5.53.2) + marked: + specifier: ^17.0.1 + version: 17.0.3 + sanitize-html: + specifier: ^2.17.0 + version: 2.17.1 + shiki: + specifier: ^3.20.0 + version: 3.22.0 devDependencies: '@sveltejs/vite-plugin-svelte': specifier: ^6.2.1 @@ -87,6 +105,9 @@ importers: '@types/node': specifier: ^24.10.1 version: 24.10.13 + '@types/sanitize-html': + specifier: ^2.13.0 + version: 2.16.0 prettier: specifier: ^3.7.4 version: 3.8.1 @@ -384,7 +405,6 @@ packages: resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} @@ -563,6 +583,9 @@ packages: '@tauri-apps/plugin-clipboard-manager@2.3.2': resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==} + '@tauri-apps/plugin-dialog@2.6.0': + resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==} + '@tauri-apps/plugin-store@2.4.2': resolution: {integrity: sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==} @@ -581,6 +604,9 @@ packages: '@types/node@24.10.13': resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} + '@types/sanitize-html@2.16.0': + resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -1216,6 +1242,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-dialog@2.6.0': + dependencies: + '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-store@2.4.2': dependencies: '@tauri-apps/api': 2.10.1 @@ -1236,6 +1266,10 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/sanitize-html@2.16.0': + dependencies: + htmlparser2: 8.0.2 + '@types/trusted-types@2.0.7': {} '@types/unist@3.0.3': {} From 893654eb4a20c4b52ac9ddb446457d3b7025f24a Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Mon, 23 Feb 2026 16:23:04 -0800 Subject: [PATCH 2/8] feat(staged): single-page layout with custom folder picker and theme selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two-page home→diff flow with a single-page layout that shows the diff viewer immediately. Add a spotlight-style folder picker with filesystem browsing and recent repo discovery (using macOS mdfind), and a searchable theme picker with adaptive theming derived from syntax colors. - Add Rust commands: set_repo_path, list_directory, search_directories, get_home_dir, find_recent_repos (with Spotlight integration) - New FolderPickerModal with breadcrumb nav, search, keyboard shortcuts - New ThemePicker dropdown with light/dark indicators and search - New preferences store with Tauri persistent storage and adaptive themes - Titlebar: macOS traffic lights (left), diff mode selector (center), repo/theme controls (right) - Remove HomePage.svelte and DiffPage.svelte (merged into App.svelte) Co-Authored-By: Claude Opus 4.6 --- apps/staged/package.json | 1 + apps/staged/src-tauri/Cargo.toml | 2 + .../src-tauri/capabilities/default.json | 3 +- apps/staged/src-tauri/src/lib.rs | 403 +++++- apps/staged/src/App.svelte | 1246 ++++++++++++++++- apps/staged/src/lib/DiffPage.svelte | 820 ----------- apps/staged/src/lib/FolderPickerModal.svelte | 734 ++++++++++ apps/staged/src/lib/HomePage.svelte | 499 ------- apps/staged/src/lib/ThemePicker.svelte | 247 ++++ apps/staged/src/lib/commands.ts | 46 +- apps/staged/src/lib/preferences.svelte.ts | 118 ++ pnpm-lock.yaml | 3 + 12 files changed, 2748 insertions(+), 1374 deletions(-) delete mode 100644 apps/staged/src/lib/DiffPage.svelte create mode 100644 apps/staged/src/lib/FolderPickerModal.svelte delete mode 100644 apps/staged/src/lib/HomePage.svelte create mode 100644 apps/staged/src/lib/ThemePicker.svelte create mode 100644 apps/staged/src/lib/preferences.svelte.ts diff --git a/apps/staged/package.json b/apps/staged/package.json index 4ef7b307..9d7e0e50 100644 --- a/apps/staged/package.json +++ b/apps/staged/package.json @@ -29,6 +29,7 @@ "@builderbot/diff-viewer": "workspace:*", "@tauri-apps/api": "^2.10.0", "@tauri-apps/plugin-dialog": "^2.2.0", + "@tauri-apps/plugin-store": "^2.2.0", "lucide-svelte": "^0.575.0", "marked": "^17.0.1", "sanitize-html": "^2.17.0", diff --git a/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index 680cf91f..0e9519a6 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -20,6 +20,8 @@ serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } tauri = { version = "2.10.2", features = [] } tauri-plugin-dialog = "2" +tauri-plugin-store = "2" +dirs = "5.0" # Shared crate for diff computation git-diff = { path = "../../../crates/git-diff" } diff --git a/apps/staged/src-tauri/capabilities/default.json b/apps/staged/src-tauri/capabilities/default.json index b1ba88a7..a79cb8d3 100644 --- a/apps/staged/src-tauri/capabilities/default.json +++ b/apps/staged/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "permissions": [ "core:default", "dialog:default", - "dialog:allow-open" + "dialog:allow-open", + "store:default" ] } diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 76f8f7a6..37100434 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -4,7 +4,9 @@ //! using the shared git-diff crate and @builderbot/diff-viewer package. use serde::Serialize; +use std::collections::HashSet; use std::path::{Path, PathBuf}; +use std::process::Command; use std::sync::Mutex; // ============================================================================= @@ -52,6 +54,22 @@ struct LaunchArgs { commit: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct DirEntry { + name: String, + path: String, + is_dir: bool, + is_repo: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct RecentRepo { + name: String, + path: String, +} + // ============================================================================= // Commands: Git info // ============================================================================= @@ -200,31 +218,373 @@ fn get_launch_args(state: tauri::State<'_, Mutex>) -> LaunchArgs { } // ============================================================================= -// Commands: Dialog +// Commands: Repo selection // ============================================================================= #[tauri::command(rename_all = "camelCase")] -async fn open_repo_dialog( - app: tauri::AppHandle, - state: tauri::State<'_, Mutex>, -) -> Result, String> { - use tauri_plugin_dialog::DialogExt; +fn set_repo_path(state: tauri::State<'_, Mutex>, path: String) -> Result<(), String> { + let p = PathBuf::from(&path); + if !p.join(".git").exists() && run_git(&p, &["rev-parse", "--git-dir"]).is_err() { + return Err(format!("{path} is not a git repository")); + } + let mut s = state.lock().unwrap(); + s.repo_path = p; + Ok(()) +} + +// ============================================================================= +// Commands: Directory browsing +// ============================================================================= + +/// List contents of a directory. +/// Returns directories first (sorted), then files (sorted). +/// Hidden files (starting with .) are excluded. +#[tauri::command(rename_all = "camelCase")] +fn list_directory(path: String) -> Result, String> { + let dir = Path::new(&path); + + if !dir.exists() { + return Err(format!("Directory does not exist: {path}")); + } + if !dir.is_dir() { + return Err(format!("Not a directory: {path}")); + } + + let mut dirs = Vec::new(); + let mut files = Vec::new(); + + let entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read directory: {e}"))?; - let path = app.dialog().file().blocking_pick_folder(); + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') { + continue; + } + + let entry_path = entry.path(); + let is_dir = entry_path.is_dir(); + let is_repo = is_dir && entry_path.join(".git").exists(); + + let item = DirEntry { + name, + path: entry_path.to_string_lossy().to_string(), + is_dir, + is_repo, + }; + + if is_dir { + dirs.push(item); + } else { + files.push(item); + } + } + + dirs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + dirs.extend(files); + Ok(dirs) +} - match path { - Some(folder) => { - let folder_path = folder.to_string(); - let path = PathBuf::from(&folder_path); - if !path.join(".git").exists() && run_git(&path, &["rev-parse", "--git-dir"]).is_err() { - return Err(format!("{folder_path} is not a git repository")); +/// Folders to skip during search. +const SKIP_FOLDERS: &[&str] = &[ + "Library", + "Applications", + "System", + "Volumes", + "cores", + "private", + "node_modules", + "target", + "build", + "dist", + "vendor", + ".git", + "__pycache__", + "venv", + ".venv", + "env", + ".cargo", + ".rustup", + ".npm", + ".cache", + "Caches", + "Movies", + "Music", + "Pictures", + "Photos Library.photoslibrary", +]; + +/// Common development folder names. +const DEV_FOLDERS: &[&str] = &[ + "dev", + "projects", + "code", + "repos", + "src", + "workspace", + "work", + "github", + "gitlab", + "Development", + "Documents", + "Desktop", +]; + +/// Search for git repositories matching a query. +#[tauri::command(rename_all = "camelCase")] +fn search_directories( + path: String, + query: String, + max_depth: Option, + limit: Option, +) -> Result, String> { + let dir = Path::new(&path); + let max_depth = max_depth.unwrap_or(6); + let limit = limit.unwrap_or(20); + let query_lower = query.to_lowercase(); + + if !dir.exists() || !dir.is_dir() { + return Err(format!("Invalid directory: {path}")); + } + + let mut results = Vec::new(); + let collect_limit = limit * 3; + + let home_dir = dirs::home_dir(); + let is_home = home_dir.as_ref().is_some_and(|h| h == dir); + + if is_home { + for dev_folder in DEV_FOLDERS { + let dev_path = dir.join(dev_folder); + if dev_path.exists() && dev_path.is_dir() { + search_repos_recursive( + &dev_path, + &query_lower, + 0, + max_depth, + &mut results, + collect_limit, + ); + if results.len() >= collect_limit { + break; + } } - let mut s = state.lock().unwrap(); - s.repo_path = path; - Ok(Some(folder_path)) } - None => Ok(None), + } else { + search_repos_recursive(dir, &query_lower, 0, max_depth, &mut results, collect_limit); } + + results.sort_by(|a, b| { + let a_exact = a.name.to_lowercase() == query_lower; + let b_exact = b.name.to_lowercase() == query_lower; + if a_exact != b_exact { + return b_exact.cmp(&a_exact); + } + let a_depth = a.path.matches('/').count(); + let b_depth = b.path.matches('/').count(); + a_depth.cmp(&b_depth) + }); + results.truncate(limit); + + Ok(results) +} + +fn search_repos_recursive( + dir: &Path, + query: &str, + depth: u32, + max_depth: u32, + results: &mut Vec, + limit: usize, +) -> bool { + if depth > max_depth || results.len() >= limit { + return results.len() >= limit; + } + + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return false, + }; + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') { + continue; + } + if SKIP_FOLDERS.contains(&name.as_str()) { + continue; + } + + let entry_path = entry.path(); + if !entry_path.is_dir() { + continue; + } + + let is_repo = entry_path.join(".git").exists(); + + if is_repo { + let name_lower = name.to_lowercase(); + if query.is_empty() || name_lower.starts_with(query) || name_lower.contains(query) { + results.push(DirEntry { + name: name.clone(), + path: entry_path.to_string_lossy().to_string(), + is_dir: true, + is_repo: true, + }); + if results.len() >= limit { + return true; + } + } + } else if search_repos_recursive(&entry_path, query, depth + 1, max_depth, results, limit) { + return true; + } + } + + false +} + +/// Get the user's home directory. +#[tauri::command] +fn get_home_dir() -> Result { + dirs::home_dir() + .map(|p| p.to_string_lossy().to_string()) + .ok_or_else(|| "Could not determine home directory".to_string()) +} + +// ============================================================================= +// Commands: Recent repos (Spotlight) +// ============================================================================= + +/// Directories to scan for recent activity. +const SCAN_DIRS: &[&str] = &[ + "Documents", + "Downloads", + "Desktop", + "Development", + "dev", + "projects", + "code", + "repos", + "src", + "workspace", + "work", + "github", + "gitlab", +]; + +/// Paths to exclude from results. +const EXCLUDE_PATTERNS: &[&str] = &[ + "node_modules", + "/target/", + "/.git/", + "/.cargo/", + "/.rustup/", + "/Library/", + "/.Trash/", + "/__pycache__/", + "/venv/", + "/.venv/", +]; + +#[tauri::command(rename_all = "camelCase")] +fn find_recent_repos(hours_ago: Option, limit: Option) -> Vec { + let hours_ago = hours_ago.unwrap_or(24); + let limit = limit.unwrap_or(10); + + let home = match dirs::home_dir() { + Some(h) => h, + None => return Vec::new(), + }; + + let scan_dirs: Vec = SCAN_DIRS + .iter() + .map(|d| home.join(d)) + .filter(|p| p.exists()) + .collect(); + + if scan_dirs.is_empty() { + return Vec::new(); + } + + let files = match find_recent_files_mdfind(&scan_dirs, hours_ago) { + Some(f) => f, + None => return Vec::new(), + }; + + let mut seen_repos: HashSet = HashSet::new(); + let mut repos: Vec = Vec::new(); + + for file in files { + if EXCLUDE_PATTERNS.iter().any(|p| file.contains(p)) { + continue; + } + + if let Some(repo_path) = find_git_root(Path::new(&file), &home) { + if seen_repos.insert(repo_path.clone()) { + let name = repo_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "Repository".to_string()); + + repos.push(RecentRepo { + name, + path: repo_path.to_string_lossy().to_string(), + }); + + if repos.len() >= limit { + break; + } + } + } + } + + repos +} + +fn find_recent_files_mdfind(scan_dirs: &[PathBuf], hours_ago: u32) -> Option> { + let seconds = hours_ago * 3600; + + let mut args: Vec = Vec::new(); + for dir in scan_dirs { + args.push("-onlyin".to_string()); + args.push(dir.to_string_lossy().to_string()); + } + + args.push(format!( + "kMDItemFSContentChangeDate >= $time.now(-{seconds})" + )); + + let output = Command::new("mdfind").args(&args).output().ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let files: Vec = stdout + .lines() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + + Some(files) +} + +fn find_git_root(path: &Path, home: &Path) -> Option { + let mut current = if path.is_file() { + path.parent()?.to_path_buf() + } else { + path.to_path_buf() + }; + + while current.starts_with(home) && current != *home { + if current.join(".git").exists() { + return Some(current); + } + current = current.parent()?.to_path_buf(); + } + + None } // ============================================================================= @@ -253,7 +613,7 @@ fn resolve_repo_path() -> PathBuf { while let Some(arg) = iter.next() { match arg.as_str() { "--commit" | "-c" => { - iter.next(); // skip the commit value + iter.next(); } s if s.starts_with('-') => {} path => { @@ -278,6 +638,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_store::Builder::default().build()) .manage(Mutex::new(AppState { repo_path })) .invoke_handler(tauri::generate_handler![ get_repo_info, @@ -286,7 +647,11 @@ pub fn run() { get_file_diff, get_file_at_ref, get_launch_args, - open_repo_dialog, + set_repo_path, + list_directory, + search_directories, + get_home_dir, + find_recent_repos, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index a26f2144..06504684 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -1,55 +1,154 @@ {#if initialized} - {#if view.kind === 'home'} - - {:else} - +
+ +
+
+ +
+ + + + +
+ + + {#if showCommitPicker} +
+ {#if loadingCommits} +
+ + Loading commits... +
+ {:else if commits.length === 0} +
No commits found
+ {:else} + {#each commits as commit (commit.sha)} + + {/each} + {/if} +
+ {/if} +
+ + {#if files.length > 0} + {files.length} file{files.length === 1 ? '' : 's'} + {/if} +
+ +
+ {#if repoInfo} + + + {repoInfo.branch} + + + {/if} + +
+
+ + +
+
+ {#if loading} +
+ + Loading diff... +
+ {:else if error} +
+ {error} +
+ {:else if repoError} +
+ {repoError} + +
+ {:else if files.length === 0} +
+ No changes +
+ {:else} + c.path === selectedFile)} + loading={loadingFile !== null} + beforeLabel="before" + afterLabel="after" + onAddComment={handleAddComment} + onUpdateComment={handleUpdateComment} + onDeleteComment={handleDeleteComment} + /> + {/if} +
+ + {#if files.length > 0} +
+ +
+ {/if} +
+
+ + + {#if showFolderPicker} + (showFolderPicker = false)} + /> + {/if} + + {#if showThemePicker} + (showThemePicker = false)} /> {/if} {/if} + + diff --git a/apps/staged/src/lib/DiffPage.svelte b/apps/staged/src/lib/DiffPage.svelte deleted file mode 100644 index 4602be77..00000000 --- a/apps/staged/src/lib/DiffPage.svelte +++ /dev/null @@ -1,820 +0,0 @@ - - - - - -
- -
- -
- {label} - {#if files.length > 0} - {files.length} file{files.length === 1 ? '' : 's'} - {/if} -
-
-
- - -
- -
- {#if loading} -
- - Loading diff... -
- {:else if error} -
- {error} -
- {:else if files.length === 0} -
- No changes -
- {:else} - c.path === selectedFile)} - loading={loadingFile !== null} - beforeLabel="before" - afterLabel="after" - onAddComment={handleAddComment} - onUpdateComment={handleUpdateComment} - onDeleteComment={handleDeleteComment} - /> - {/if} -
- - - {#if !sidebarCollapsed} -
- {#if loading} - - {:else if files.length > 0} - - {/if} -
- {/if} -
-
- - diff --git a/apps/staged/src/lib/FolderPickerModal.svelte b/apps/staged/src/lib/FolderPickerModal.svelte new file mode 100644 index 00000000..3d061f86 --- /dev/null +++ b/apps/staged/src/lib/FolderPickerModal.svelte @@ -0,0 +1,734 @@ + + + + + + + + + diff --git a/apps/staged/src/lib/HomePage.svelte b/apps/staged/src/lib/HomePage.svelte deleted file mode 100644 index 3ecc539c..00000000 --- a/apps/staged/src/lib/HomePage.svelte +++ /dev/null @@ -1,499 +0,0 @@ - - - -
-
- - {#if loadingRepo} -
- - Loading repository... -
- {:else if repoError} -
-

{repoError}

- -
- {:else if repoInfo} -
- {shortenPath(repoInfo.path)} - · - - - {repoInfo.branch} - - {#if repoInfo.commitsAhead > 0} - · - {repoInfo.commitsAhead} ahead of {repoInfo.defaultBranch.replace('origin/', '')} - {/if} - -
- - -
- - - - - -
- - - {#if showCommitPicker} -
- {#if loadingCommits} -
- - Loading commits... -
- {:else if commits.length === 0} -
No commits found
- {:else} - {#each commits as commit (commit.sha)} - - {/each} - {/if} -
- {/if} - {/if} -
-
- - diff --git a/apps/staged/src/lib/ThemePicker.svelte b/apps/staged/src/lib/ThemePicker.svelte new file mode 100644 index 00000000..6f7f2529 --- /dev/null +++ b/apps/staged/src/lib/ThemePicker.svelte @@ -0,0 +1,247 @@ + + + + + +
+
+ + +
+ +
+ {#each filteredThemes as theme, i (theme.name)} + + {:else} +
No themes match "{searchQuery}"
+ {/each} +
+
+ + diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 9f6e6285..2f2ee1f1 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -34,6 +34,18 @@ export interface LaunchArgs { commit: string | null; } +export interface DirEntry { + name: string; + path: string; + isDir: boolean; + isRepo: boolean; +} + +export interface RecentRepo { + name: string; + path: string; +} + /** Matches the git-diff crate's GitRef enum (tagged union). */ export type GitRef = | { type: 'WorkingTree' } @@ -110,6 +122,36 @@ export function getLaunchArgs(): Promise { return invoke('get_launch_args'); } -export function openRepoDialog(): Promise { - return invoke('open_repo_dialog'); +export function setRepoPath(path: string): Promise { + return invoke('set_repo_path', { path }); +} + +// ============================================================================= +// Directory browsing +// ============================================================================= + +export function listDirectory(path: string): Promise { + return invoke('list_directory', { path }); +} + +export function searchDirectories( + path: string, + query: string, + maxDepth?: number, + limit?: number +): Promise { + return invoke('search_directories', { + path, + query, + maxDepth: maxDepth ?? 3, + limit: limit ?? 20, + }); +} + +export function getHomeDir(): Promise { + return invoke('get_home_dir'); +} + +export function findRecentRepos(hoursAgo?: number, limit?: number): Promise { + return invoke('find_recent_repos', { hoursAgo, limit }); } diff --git a/apps/staged/src/lib/preferences.svelte.ts b/apps/staged/src/lib/preferences.svelte.ts new file mode 100644 index 00000000..c069a10c --- /dev/null +++ b/apps/staged/src/lib/preferences.svelte.ts @@ -0,0 +1,118 @@ +/** + * User Preferences Store for Staged + * + * Manages persistent user preferences (Tauri store-backed). + * Handles syntax theme selection with adaptive UI theming. + */ + +import { + SYNTAX_THEMES, + setSyntaxTheme, + getTheme, + isLightTheme, + initHighlighter, + type SyntaxThemeName, +} from '@builderbot/diff-viewer/utils'; +import { load, type Store } from '@tauri-apps/plugin-store'; +import { createAdaptiveTheme, themeToVarMap } from '../../../mark/src/lib/theme'; + +// Re-export for convenience +export { isLightTheme }; + +// ============================================================================= +// Constants +// ============================================================================= + +const SYNTAX_THEME_STORE_KEY = 'syntax-theme'; +const DEFAULT_SYNTAX_THEME: SyntaxThemeName = 'laserwave'; + +// ============================================================================= +// Store +// ============================================================================= + +let store: Store | null = null; + +async function initStore(): Promise { + if (store) return; + store = await load('preferences.json', { + defaults: {}, + autoSave: true, + overrideDefaults: true, + }); +} + +async function getStoreValue(key: string): Promise { + if (!store) return undefined; + return store.get(key); +} + +async function setStoreValue(key: string, value: T): Promise { + if (!store) return; + await store.set(key, value); +} + +// ============================================================================= +// Reactive State +// ============================================================================= + +export interface ThemeEntry { + name: string; +} + +export const preferences = $state({ + syntaxTheme: DEFAULT_SYNTAX_THEME as string, + loaded: false, +}); + +// ============================================================================= +// CSS Application +// ============================================================================= + +function applyAdaptiveTheme() { + const themeInfo = getTheme(); + if (themeInfo) { + const adaptiveTheme = createAdaptiveTheme(themeInfo.bg, themeInfo.fg, themeInfo.comment, { + added: themeInfo.added, + deleted: themeInfo.deleted, + modified: themeInfo.modified, + }); + const varMap = themeToVarMap(adaptiveTheme); + const style = document.documentElement.style; + for (const [prop, value] of Object.entries(varMap)) { + style.setProperty(prop, value); + } + } +} + +// ============================================================================= +// Initialization +// ============================================================================= + +export async function initPreferences(): Promise { + await initStore(); + + const savedTheme = await getStoreValue(SYNTAX_THEME_STORE_KEY); + if (savedTheme && SYNTAX_THEMES.includes(savedTheme as SyntaxThemeName)) { + preferences.syntaxTheme = savedTheme; + } + + await initHighlighter(preferences.syntaxTheme as SyntaxThemeName); + applyAdaptiveTheme(); + + preferences.loaded = true; +} + +// ============================================================================= +// Theme Actions +// ============================================================================= + +export function getAvailableSyntaxThemes(): ThemeEntry[] { + return SYNTAX_THEMES.map((name) => ({ name })); +} + +export async function selectSyntaxTheme(name: string): Promise { + await setSyntaxTheme(name as SyntaxThemeName); + preferences.syntaxTheme = name; + await setStoreValue(SYNTAX_THEME_STORE_KEY, name); + applyAdaptiveTheme(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23586651..f2b4ae94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: '@tauri-apps/plugin-dialog': specifier: ^2.2.0 version: 2.6.0 + '@tauri-apps/plugin-store': + specifier: ^2.2.0 + version: 2.4.2 lucide-svelte: specifier: ^0.575.0 version: 0.575.0(svelte@5.53.2) From 7c0ce8070cb6b4eb8d0c0d23110d1ae61e667838 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Mon, 23 Feb 2026 17:05:02 -0800 Subject: [PATCH 3/8] feat(staged): add staged-changes mode, segmented control, and draggable titlebar Three UX improvements to the Staged diff viewer: 1. Draggable titlebar: The center section of the titlebar now participates in the drag region, so you can move the window by dragging anywhere on the titlebar outside of interactive controls. 2. Staged changes mode: Add a new "Staged" diff mode that shows only git-staged (indexed) changes using `git diff --cached`. This adds an `Index` variant to the GitRef enum, with full support in the git-diff crate (list_diff_files via CLI, get_file_diff via libgit2's diff_tree_to_index, and file content loading from the index). 3. Segmented control: Replace the separate mode buttons with a connected segmented control (pill-shaped group with shared background). The active segment gets an elevated background with a subtle shadow, making the mutual exclusivity of the options immediately obvious. Co-Authored-By: Claude Opus 4.6 --- apps/staged/src-tauri/Cargo.lock | 152 +++++++++++++++++++++++++-- apps/staged/src/App.svelte | 175 ++++++++++++++++++------------- apps/staged/src/lib/commands.ts | 9 ++ crates/git-diff/src/diff.rs | 59 +++++++++-- crates/git-diff/src/types.rs | 4 + 5 files changed, 314 insertions(+), 85 deletions(-) diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index 7a8dd2a9..26e2afb1 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -513,13 +513,34 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -530,7 +551,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -2610,6 +2631,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3110,12 +3142,14 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "staged" version = "0.1.0" dependencies = [ + "dirs 5.0.1", "git-diff", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-store", ] [[package]] @@ -3281,7 +3315,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3331,7 +3365,7 @@ checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3443,6 +3477,22 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-store" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "tauri-runtime" version = "2.10.0" @@ -3647,9 +3697,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3811,9 +3873,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3830,7 +3904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -4448,6 +4522,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4490,6 +4573,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4547,6 +4645,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4565,6 +4669,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4583,6 +4693,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4613,6 +4729,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4631,6 +4753,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4649,6 +4777,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4667,6 +4801,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -4811,7 +4951,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dpi", "dunce", "gdkx11", diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 06504684..bf4a7cb2 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -25,6 +25,7 @@ Copy, Check, Trash2, + ListChecks, } from 'lucide-svelte'; import { DiffViewer } from '@builderbot/diff-viewer/components'; import { @@ -60,7 +61,7 @@ // State: Diff mode // ========================================================================== - type DiffMode = 'all' | 'branch' | 'commit'; + type DiffMode = 'all' | 'staged' | 'branch' | 'commit'; let diffMode = $state('all'); let diffSpec = $state(commands.specUncommitted()); @@ -131,6 +132,9 @@ case 'all': setMode('all'); break; + case 'staged': + setMode('staged'); + break; case 'branch': setMode('branch'); break; @@ -185,6 +189,11 @@ diffLabel = 'All Changes'; selectedCommit = null; break; + case 'staged': + diffSpec = commands.specStaged(); + diffLabel = 'Staged'; + selectedCommit = null; + break; case 'branch': diffSpec = commands.specBranch(); diffLabel = repoInfo @@ -388,63 +397,73 @@
-
- - - - -
+
+
+ + - {#if showCommitPicker} -
- {#if loadingCommits} -
- - Loading commits... -
- {:else if commits.length === 0} -
No commits found
- {:else} - {#each commits as commit (commit.sha)} - - {/each} - {/if} -
- {/if} +
+ + + {#if showCommitPicker} +
+ {#if loadingCommits} +
+ + Loading commits... +
+ {:else if commits.length === 0} +
No commits found
+ {:else} + {#each commits as commit (commit.sha)} + + {/each} + {/if} +
+ {/if} +
{#if files.length > 0} @@ -691,8 +710,7 @@ display: flex; align-items: center; justify-content: center; - gap: 2px; - -webkit-app-region: no-drag; + gap: 8px; } .titlebar-right { @@ -703,38 +721,50 @@ -webkit-app-region: no-drag; } - /* Mode buttons */ - .mode-btn { + /* Segmented control */ + .mode-segmented { display: flex; align-items: center; - gap: 5px; - padding: 4px 10px; + background-color: var(--bg-deepest); + border: 1px solid var(--border-subtle); + border-radius: 7px; + padding: 2px; + gap: 1px; + -webkit-app-region: no-drag; + } + + .mode-seg { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 10px; background: none; border: 1px solid transparent; - border-radius: 6px; - color: var(--text-muted); + border-radius: 5px; + color: var(--text-faint); cursor: pointer; font-size: var(--size-xs); font-family: inherit; transition: - color 0.1s, - background-color 0.1s, - border-color 0.1s; + color 0.15s, + background-color 0.15s, + border-color 0.15s, + box-shadow 0.15s; white-space: nowrap; } - .mode-btn:hover { - color: var(--text-primary); - background-color: var(--bg-hover); + .mode-seg:hover { + color: var(--text-muted); } - .mode-btn.active { + .mode-seg.active { color: var(--text-primary); - background-color: var(--bg-primary); + background-color: var(--bg-elevated); border-color: var(--border-muted); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); } - .mode-btn :global(.chevron) { + .mode-seg :global(.chevron) { color: var(--text-faint); margin-left: -2px; } @@ -742,12 +772,13 @@ .file-count { color: var(--text-faint); font-size: var(--size-xs); - margin-left: 4px; + -webkit-app-region: no-drag; } /* Commit picker */ .commit-picker-wrap { position: relative; + display: flex; } .commit-dropdown { diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 2f2ee1f1..c8252a19 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -49,6 +49,7 @@ export interface RecentRepo { /** Matches the git-diff crate's GitRef enum (tagged union). */ export type GitRef = | { type: 'WorkingTree' } + | { type: 'Index' } | { type: 'Rev'; value: string } | { type: 'MergeBase' } | { type: 'MergeBaseOf'; value: [string, string] }; @@ -70,6 +71,14 @@ export function specUncommitted(): DiffSpec { }; } +/** Staged changes only: HEAD -> index */ +export function specStaged(): DiffSpec { + return { + base: { type: 'Rev', value: 'HEAD' }, + head: { type: 'Index' }, + }; +} + /** Full branch diff: merge-base -> HEAD */ export function specBranch(): DiffSpec { return { diff --git a/crates/git-diff/src/diff.rs b/crates/git-diff/src/diff.rs index 5bf335b1..5f192ad0 100644 --- a/crates/git-diff/src/diff.rs +++ b/crates/git-diff/src/diff.rs @@ -46,6 +46,10 @@ pub fn get_unified_diff(repo: &Path, spec: &DiffSpec, path: &Path) -> Result { + // Diff from commit to staging area + cli::run(repo, &["diff", "--cached", base.as_str(), "--", path_str]) + } (GitRef::Rev(base), GitRef::Rev(head)) => { // Diff between two commits cli::run( @@ -53,8 +57,8 @@ pub fn get_unified_diff(repo: &Path, spec: &DiffSpec, path: &Path) -> Result Err(GitError::CommandFailed( - "Cannot use working tree as base".to_string(), + (GitRef::WorkingTree, _) | (GitRef::Index, _) => Err(GitError::CommandFailed( + "Cannot use working tree or index as base".to_string(), )), (GitRef::MergeBase | GitRef::MergeBaseOf(_), _) | (_, GitRef::MergeBase | GitRef::MergeBaseOf(_)) => { @@ -92,14 +96,20 @@ pub fn list_diff_files(repo: &Path, spec: &DiffSpec) -> Result { + // Staged changes: diff between a rev and the index + let args = ["diff", "--cached", "--name-status", "-z", base.as_str()]; + let output = cli::run(repo, &args)?; + parse_name_status(&output) + } (GitRef::Rev(base), GitRef::Rev(head)) => { // Commit range - use git diff let args = ["diff", "--name-status", "-z", base.as_str(), head.as_str()]; let output = cli::run(repo, &args)?; parse_name_status(&output) } - (GitRef::WorkingTree, _) => Err(GitError::CommandFailed( - "Cannot use working tree as base".to_string(), + (GitRef::WorkingTree, _) | (GitRef::Index, _) => Err(GitError::CommandFailed( + "Cannot use working tree or index as base".to_string(), )), (GitRef::MergeBase | GitRef::MergeBaseOf(_), _) | (_, GitRef::MergeBase | GitRef::MergeBaseOf(_)) => { @@ -322,13 +332,20 @@ pub fn get_file_diff(repo_path: &Path, spec: &DiffSpec, path: &Path) -> Result Result( git_ref: &GitRef, ) -> Result>, GitError> { match git_ref { - GitRef::WorkingTree => Ok(None), + GitRef::WorkingTree | GitRef::Index => Ok(None), GitRef::Rev(rev) => { let obj = repo .revparse_single(rev) @@ -410,6 +428,29 @@ fn load_file_from_tree( })) } +/// Load file content from the git index (staging area) +fn load_file_from_index(repo: &Repository, path: &Path) -> Result, GitError> { + let index = repo + .index() + .map_err(|e| GitError::CommandFailed(format!("Cannot read index: {e}")))?; + + let entry = match index.get_path(path, 0) { + Some(e) => e, + None => return Ok(None), + }; + + let blob = repo + .find_blob(entry.id) + .map_err(|e| GitError::CommandFailed(format!("Cannot load blob from index: {e}")))?; + + let content = bytes_to_content(blob.content()); + + Ok(Some(File { + path: path.to_string_lossy().to_string(), + content, + })) +} + /// Load file content from the working directory fn load_file_from_workdir(repo: &Repository, path: &Path) -> Result, GitError> { let workdir = repo @@ -455,13 +496,17 @@ fn get_hunks_libgit2( base_tree: Option<&git2::Tree>, head_tree: Option<&git2::Tree>, is_working_tree: bool, + is_index: bool, path: &Path, ) -> Result, GitError> { let mut opts = DiffOptions::new(); opts.context_lines(0); // No context, just the changes opts.pathspec(path); - let diff = if is_working_tree { + let diff = if is_index { + // Staged changes: tree → index + repo.diff_tree_to_index(base_tree, None, Some(&mut opts)) + } else if is_working_tree { repo.diff_tree_to_workdir_with_index(base_tree, Some(&mut opts)) } else { repo.diff_tree_to_tree(base_tree, head_tree, Some(&mut opts)) diff --git a/crates/git-diff/src/types.rs b/crates/git-diff/src/types.rs index 9de54726..bcff90ae 100644 --- a/crates/git-diff/src/types.rs +++ b/crates/git-diff/src/types.rs @@ -10,6 +10,8 @@ pub const WORKDIR: &str = "WORKDIR"; pub enum GitRef { /// The working tree (uncommitted changes) WorkingTree, + /// The staging area / index (what has been `git add`'d) + Index, /// Anything that resolves to a commit: SHA, branch, tag, origin/main, HEAD~3, etc. Rev(String), /// Merge-base between the default branch and HEAD. @@ -27,6 +29,7 @@ impl GitRef { pub fn as_git_arg(&self) -> Option<&str> { match self { GitRef::WorkingTree => None, + GitRef::Index => None, GitRef::Rev(s) => Some(s), GitRef::MergeBase => panic!("MergeBase must be resolved before use"), GitRef::MergeBaseOf(_) => panic!("MergeBaseOf must be resolved before use"), @@ -37,6 +40,7 @@ impl GitRef { pub fn display(&self) -> &str { match self { GitRef::WorkingTree => "@", + GitRef::Index => "index", GitRef::Rev(s) => s, GitRef::MergeBase => "merge-base", GitRef::MergeBaseOf(_) => "merge-base", From 4358551a01a7c949eb6006df1ef17f21073acff0 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Mon, 23 Feb 2026 17:34:05 -0800 Subject: [PATCH 4/8] feat(staged): upgrade empty state with icon, message, and git tree animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the plain "No changes" text with a polished empty state inspired by Mark's splash screen. The new empty state features: - Framed icon (CircleDashed) in an elevated card matching Mark's icon-frame - Heading "No changes found" with a hint to use the toolbar - Animated git tree visualization as a subtle background (20% opacity) - Fade-in entrance animation Copies GitTreeAnimation.svelte from Mark into the staged app — a canvas- based procedural git tree that continuously generates commits, branches, and merges with color-coded event pulses. Co-Authored-By: Claude Opus 4.6 --- apps/staged/src/App.svelte | 89 +++- apps/staged/src/lib/GitTreeAnimation.svelte | 554 ++++++++++++++++++++ 2 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 apps/staged/src/lib/GitTreeAnimation.svelte diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index bf4a7cb2..8f54f7b4 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -21,6 +21,7 @@ CirclePlus, CircleMinus, CircleArrowUp, + CircleDashed, MessageSquare, Copy, Check, @@ -42,6 +43,7 @@ import type { DiffSpec, RepoInfo, CommitInfo, RecentRepo } from './lib/commands'; import FolderPickerModal from './lib/FolderPickerModal.svelte'; import ThemePicker from './lib/ThemePicker.svelte'; + import GitTreeAnimation from './lib/GitTreeAnimation.svelte'; import { initPreferences } from './lib/preferences.svelte'; // ========================================================================== @@ -516,8 +518,17 @@
{:else if files.length === 0} -
- No changes +
+
+
+ +
+

No changes found

+

Choose a change set from the toolbar above

+
+
+ +
{:else} + + +
+ +
+ + From 1b714bae1944fa3f67f1bb0366b508f0273913c3 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Mon, 23 Feb 2026 17:42:02 -0800 Subject: [PATCH 5/8] feat(staged): use Mark's icon for empty state instead of CircleDashed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the generic CircleDashed lucide icon with the actual Mark app icon (branch path in a rounded rect) for the empty state. This matches Mark's splash screen rendering — the MarkIcon SVG component at 52px inside a 104x104 icon frame with 26px border radius and deeper shadow. Co-Authored-By: Claude Opus 4.6 --- apps/staged/src/App.svelte | 13 ++++----- apps/staged/src/lib/MarkIcon.svelte | 45 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 apps/staged/src/lib/MarkIcon.svelte diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 8f54f7b4..cc1b811d 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -21,7 +21,6 @@ CirclePlus, CircleMinus, CircleArrowUp, - CircleDashed, MessageSquare, Copy, Check, @@ -44,6 +43,7 @@ import FolderPickerModal from './lib/FolderPickerModal.svelte'; import ThemePicker from './lib/ThemePicker.svelte'; import GitTreeAnimation from './lib/GitTreeAnimation.svelte'; + import MarkIcon from './lib/MarkIcon.svelte'; import { initPreferences } from './lib/preferences.svelte'; // ========================================================================== @@ -521,7 +521,7 @@
- +

No changes found

Choose a change set from the toolbar above

@@ -988,15 +988,14 @@ display: flex; align-items: center; justify-content: center; - width: 64px; - height: 64px; - border-radius: 18px; + width: 104px; + height: 104px; + border-radius: 26px; background: var(--bg-elevated); border: 1px solid var(--border-subtle); box-shadow: - 0 4px 16px rgba(0, 0, 0, 0.15), + 0 8px 32px rgba(0, 0, 0, 0.2), 0 0 0 1px var(--border-subtle); - color: var(--text-muted); } .empty-heading { diff --git a/apps/staged/src/lib/MarkIcon.svelte b/apps/staged/src/lib/MarkIcon.svelte new file mode 100644 index 00000000..71c61269 --- /dev/null +++ b/apps/staged/src/lib/MarkIcon.svelte @@ -0,0 +1,45 @@ + + + + + + From ad0ddfd9f079d130c94ebee29dd9113e8035de5b Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Mon, 23 Feb 2026 19:10:35 -0800 Subject: [PATCH 6/8] fix(staged): replace CSS drag regions with programmatic window dragging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The titlebar was nearly impossible to drag because -webkit-app-region: drag was being overridden by no-drag on almost every child element (segmented control, file count, repo/theme buttons), leaving only the 72px traffic- light spacer as a grab target. Switch to Mark's approach: a single pointerdown handler on the titlebar that calls getCurrentWindow().startDragging() when the click target isn't an interactive element (button, a, input, [role="button"]). This makes every pixel of empty space in the titlebar draggable — the gaps between buttons, padding, and flex spacers all participate in the drag. Co-Authored-By: Claude Opus 4.6 --- apps/staged/src/App.svelte | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index cc1b811d..4d245322 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -38,6 +38,7 @@ type TreeNode, } from '@builderbot/diff-viewer/utils'; import type { FileDiff, FileDiffSummary, Comment, Span } from '@builderbot/diff-viewer/types'; + import { getCurrentWindow } from '@tauri-apps/api/window'; import * as commands from './lib/commands'; import type { DiffSpec, RepoInfo, CommitInfo, RecentRepo } from './lib/commands'; import FolderPickerModal from './lib/FolderPickerModal.svelte'; @@ -383,6 +384,16 @@ // Helpers // ========================================================================== + function startDrag(e: PointerEvent) { + if (e.button !== 0) return; + const target = e.target as HTMLElement; + const isInteractive = target.closest('button, a, input, [role="button"]'); + if (!isInteractive) { + e.preventDefault(); + getCurrentWindow().startDragging(); + } + } + function timeAgo(timestamp: number): string { const now = Date.now() / 1000; const diff = now - timestamp; @@ -396,10 +407,11 @@ {#if initialized}
-
-
+ +
+
-
+