From 8d4065533387b312074b14159b9246ecde03d60b Mon Sep 17 00:00:00 2001 From: HMasataka Date: Thu, 5 Mar 2026 22:33:03 +0900 Subject: [PATCH 1/6] update roadmap.md --- docs/roadmap.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 7cfe41f..cb784ec 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -448,15 +448,15 @@ **ゴール**: コミット署名と Git 設定の GUI 管理ができる -- [ ] 署名付きコミット - - [ ] GPG 署名付きコミット - - [ ] SSH 署名付きコミット - - [ ] 署名鍵の設定 UI - - [ ] 署名状態の表示(履歴ビューで検証済み/未検証バッジ) -- [ ] Gitconfig GUI 編集 - - [ ] ユーザー設定(name, email)の編集 - - [ ] リポジトリ固有設定の編集 - - [ ] よく使う設定項目の GUI 化 +- [x] 署名付きコミット + - [x] GPG 署名付きコミット + - [x] SSH 署名付きコミット + - [x] 署名鍵の設定 UI + - [x] 署名状態の表示(履歴ビューで検証済み/未検証バッジ) +- [x] Gitconfig GUI 編集 + - [x] ユーザー設定(name, email)の編集 + - [x] リポジトリ固有設定の編集 + - [x] よく使う設定項目の GUI 化 **完動品としての価値**: セキュリティ意識の高い開発フローと、ターミナル不要の Git 設定管理 From 03323a02f32b19a20dc115979a42d34fc8e54640 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 12:16:54 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(git):=20=E3=83=AA=E3=83=9D=E3=82=B8?= =?UTF-8?q?=E3=83=88=E3=83=AAOpen/Init/Clone=E3=83=BB.gitignore=E3=83=86?= =?UTF-8?q?=E3=83=B3=E3=83=97=E3=83=AC=E3=83=BC=E3=83=88=E3=81=AE=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=82=A8=E3=83=B3=E3=83=89=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src-tauri/Cargo.lock | 432 +++++++++++++++++++++++++++- src-tauri/Cargo.toml | 3 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/commands/gitignore.rs | 116 ++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/repo.rs | 113 ++++++++ src-tauri/src/config/mod.rs | 105 +++++++ src-tauri/src/git/dispatcher.rs | 62 +++- src-tauri/src/git/error.rs | 23 ++ src-tauri/src/lib.rs | 12 +- 10 files changed, 860 insertions(+), 11 deletions(-) create mode 100644 src-tauri/src/commands/gitignore.rs create mode 100644 src-tauri/src/commands/repo.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dc34b0a..b6c0c5d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -79,15 +79,18 @@ checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" name = "app" version = "0.1.0" dependencies = [ + "chrono", "dirs", "git2", "log", "notify", "notify-debouncer-mini", + "reqwest 0.12.28", "serde", "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-log", "tauri-plugin-opener", "tempfile", @@ -568,8 +571,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -608,6 +613,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -631,9 +646,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -644,7 +659,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -830,6 +845,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.11.0", + "block2", + "libc", "objc2", ] @@ -929,6 +946,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[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" @@ -1093,6 +1119,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1100,7 +1135,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1114,6 +1149,12 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1161,6 +1202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1445,7 +1487,7 @@ dependencies = [ "libc", "libgit2-sys", "log", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "url", ] @@ -1566,6 +1608,25 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1675,6 +1736,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1686,6 +1748,38 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1704,9 +1798,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2323,6 +2419,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2681,12 +2794,44 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-sys" version = "0.9.111" @@ -3316,6 +3461,48 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -3350,6 +3537,44 @@ 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 = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rkyv" version = "0.7.46" @@ -3417,12 +3642,51 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3432,6 +3696,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3495,6 +3768,29 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -3618,6 +3914,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.16.1" @@ -3839,6 +4147,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3892,6 +4206,27 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3913,7 +4248,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.11.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -3998,7 +4333,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -4099,6 +4434,46 @@ dependencies = [ "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-plugin-log" version = "2.8.0" @@ -4380,6 +4755,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4675,6 +5070,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -5180,6 +5581,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5793,6 +6205,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3f075d8..849d9af 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,6 +31,9 @@ toml = "0.8" notify = "7" notify-debouncer-mini = "0.5" which = "7" +chrono = { version = "0.4", features = ["serde"] } +reqwest = { version = "0.12", features = ["blocking", "json"] } +tauri-plugin-dialog = "2" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 5167782..349a560 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,6 +11,7 @@ "core:window:allow-minimize", "core:window:allow-toggle-maximize", "core:window:allow-start-dragging", - "opener:default" + "opener:default", + "dialog:default" ] } diff --git a/src-tauri/src/commands/gitignore.rs b/src-tauri/src/commands/gitignore.rs new file mode 100644 index 0000000..f9a4813 --- /dev/null +++ b/src-tauri/src/commands/gitignore.rs @@ -0,0 +1,116 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, SystemTime}; + +fn cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(GitignoreCache::default())) +} + +const CACHE_TTL: Duration = Duration::from_secs(3600); +const GITHUB_API_BASE: &str = "https://api.github.com/gitignore/templates"; + +#[derive(Default)] +struct GitignoreCache { + templates: Option<(Vec, SystemTime)>, + contents: HashMap, +} + +fn cache_dir() -> Option { + dirs::cache_dir().map(|d| d.join("rocket").join("gitignore")) +} + +#[tauri::command] +pub fn list_gitignore_templates() -> Result, String> { + { + let c = cache().lock().map_err(|e| format!("Lock poisoned: {e}"))?; + if let Some((ref list, fetched_at)) = c.templates { + if fetched_at.elapsed().unwrap_or(Duration::MAX) < CACHE_TTL { + return Ok(list.clone()); + } + } + } + + let client = reqwest::blocking::Client::new(); + let response = client + .get(GITHUB_API_BASE) + .header("User-Agent", "Rocket-Git-GUI") + .send() + .map_err(|e| format!("Failed to fetch templates: {e}"))?; + + let templates: Vec = response + .json() + .map_err(|e| format!("Failed to parse response: {e}"))?; + + { + let mut c = cache().lock().map_err(|e| format!("Lock poisoned: {e}"))?; + c.templates = Some((templates.clone(), SystemTime::now())); + } + + Ok(templates) +} + +#[tauri::command] +pub fn get_gitignore_template(name: String) -> Result { + { + let c = cache().lock().map_err(|e| format!("Lock poisoned: {e}"))?; + if let Some((ref content, fetched_at)) = c.contents.get(&name) { + if fetched_at.elapsed().unwrap_or(Duration::MAX) < CACHE_TTL { + return Ok(content.clone()); + } + } + } + + // Check local file cache + if let Some(dir) = cache_dir() { + let file_path = dir.join(format!("{name}.gitignore")); + if file_path.exists() { + if let Ok(metadata) = file_path.metadata() { + if let Ok(modified) = metadata.modified() { + if modified.elapsed().unwrap_or(Duration::MAX) < CACHE_TTL { + if let Ok(content) = fs::read_to_string(&file_path) { + let mut c = + cache().lock().map_err(|e| format!("Lock poisoned: {e}"))?; + c.contents + .insert(name.clone(), (content.clone(), SystemTime::now())); + return Ok(content); + } + } + } + } + } + } + + let url = format!("{GITHUB_API_BASE}/{name}"); + let client = reqwest::blocking::Client::new(); + let response = client + .get(&url) + .header("User-Agent", "Rocket-Git-GUI") + .send() + .map_err(|e| format!("Failed to fetch template '{name}': {e}"))?; + + let body: serde_json::Value = response + .json() + .map_err(|e| format!("Failed to parse template response: {e}"))?; + + let source = body["source"] + .as_str() + .ok_or_else(|| format!("Template '{name}' not found"))? + .to_string(); + + // Save to local file cache + if let Some(dir) = cache_dir() { + let _ = fs::create_dir_all(&dir); + let file_path = dir.join(format!("{name}.gitignore")); + let _ = fs::write(&file_path, &source); + } + + { + let mut c = cache().lock().map_err(|e| format!("Lock poisoned: {e}"))?; + c.contents.insert(name, (source.clone(), SystemTime::now())); + } + + Ok(source) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e20a4a7..45ea82c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -5,10 +5,12 @@ pub mod config; pub mod conflict; pub mod git; pub mod gitconfig; +pub mod gitignore; pub mod history; pub mod hosting; pub mod rebase; pub mod remote; +pub mod repo; pub mod reset; pub mod revert; pub mod search; diff --git a/src-tauri/src/commands/repo.rs b/src-tauri/src/commands/repo.rs new file mode 100644 index 0000000..90d7ce2 --- /dev/null +++ b/src-tauri/src/commands/repo.rs @@ -0,0 +1,113 @@ +use tauri::{AppHandle, Emitter, State}; + +use crate::config::{self, RecentRepo}; +use crate::git::backend::GitBackend; +use crate::git::dispatcher::GitDispatcher; +use crate::state::AppState; +use crate::watcher; + +fn repo_name_from_path(path: &str) -> String { + std::path::Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string()) +} + +fn setup_repo_after_open( + backend: Box, + app_handle: &AppHandle, + state: &State<'_, AppState>, + path: &str, +) -> Result<(), String> { + let watch_path = backend.workdir().to_path_buf(); + + { + let mut repo_lock = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + *repo_lock = Some(backend); + } + + { + let mut watcher_lock = state + .watcher + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + *watcher_lock = None; + + match watcher::start_watcher(app_handle.clone(), &watch_path) { + Ok(w) => { + *watcher_lock = Some(w); + } + Err(e) => { + log::error!("Failed to start watcher: {}", e); + } + } + } + + let mut cfg = config::load_config().map_err(|e| e.to_string())?; + cfg.last_opened_repo = Some(path.to_string()); + config::add_recent_repo(&mut cfg, path, &repo_name_from_path(path)); + config::save_config(&cfg).map_err(|e| e.to_string())?; + + let _ = app_handle.emit("repo:changed", ()); + + Ok(()) +} + +#[tauri::command] +pub fn open_repository( + path: String, + app_handle: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let backend = GitDispatcher::open_default(&path).map_err(|e| e.to_string())?; + setup_repo_after_open(backend, &app_handle, &state, &path) +} + +#[tauri::command] +pub fn init_repository( + path: String, + gitignore_template: Option, + app_handle: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let backend = GitDispatcher::init(&path).map_err(|e| e.to_string())?; + + if let Some(template_name) = gitignore_template { + if !template_name.is_empty() { + let content = crate::commands::gitignore::get_gitignore_template(template_name)?; + let gitignore_path = std::path::Path::new(&path).join(".gitignore"); + std::fs::write(&gitignore_path, content) + .map_err(|e| format!("Failed to write .gitignore: {e}"))?; + } + } + + setup_repo_after_open(backend, &app_handle, &state, &path) +} + +#[tauri::command] +pub fn get_recent_repos() -> Result, String> { + let cfg = config::load_config().map_err(|e| e.to_string())?; + Ok(cfg.recent_repos) +} + +#[tauri::command] +pub fn remove_recent_repo(path: String) -> Result<(), String> { + let mut cfg = config::load_config().map_err(|e| e.to_string())?; + config::remove_recent_repo(&mut cfg, &path); + config::save_config(&cfg).map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub fn clone_repository( + url: String, + path: String, + app_handle: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let backend = GitDispatcher::clone_repo(&url, &path).map_err(|e| e.to_string())?; + setup_repo_after_open(backend, &app_handle, &state, &path) +} diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index 0a3598e..83c22ee 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -6,10 +6,21 @@ use serde::{Deserialize, Serialize}; use crate::ai::types::AiConfig; use crate::git::error::{GitError, GitResult}; +const MAX_RECENT_REPOS: usize = 20; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecentRepo { + pub path: String, + pub name: String, + pub last_opened: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct AppConfig { pub last_opened_repo: Option, #[serde(default)] + pub recent_repos: Vec, + #[serde(default)] pub ai: AiConfig, #[serde(default)] pub appearance: AppearanceConfig, @@ -138,6 +149,26 @@ pub fn save_config(config: &AppConfig) -> GitResult<()> { Ok(()) } +pub fn add_recent_repo(config: &mut AppConfig, path: &str, name: &str) { + let now = chrono::Utc::now().to_rfc3339(); + config + .recent_repos + .retain(|r| r.path != path); + config.recent_repos.insert( + 0, + RecentRepo { + path: path.to_string(), + name: name.to_string(), + last_opened: now, + }, + ); + config.recent_repos.truncate(MAX_RECENT_REPOS); +} + +pub fn remove_recent_repo(config: &mut AppConfig, path: &str) { + config.recent_repos.retain(|r| r.path != path); +} + #[cfg(test)] mod tests { use super::*; @@ -152,6 +183,7 @@ mod tests { fn config_serialization_roundtrip() { let config = AppConfig { last_opened_repo: Some("/tmp/test-repo".to_string()), + recent_repos: vec![], ai: AiConfig::default(), appearance: AppearanceConfig::default(), editor: EditorConfig::default(), @@ -244,6 +276,7 @@ tab_style = "compact" fn config_full_roundtrip_with_all_sections() { let config = AppConfig { last_opened_repo: Some("/tmp/test".to_string()), + recent_repos: vec![], ai: AiConfig::default(), appearance: AppearanceConfig { theme: "light".to_string(), @@ -287,4 +320,76 @@ tab_style = "compact" assert_eq!(deserialized.tools.diff_tool, "vscode"); assert!(!deserialized.tools.auto_fetch_on_open); } + + #[test] + fn default_config_has_empty_recent_repos() { + let config = AppConfig::default(); + assert!(config.recent_repos.is_empty()); + } + + #[test] + fn add_recent_repo_inserts_at_front() { + let mut config = AppConfig::default(); + add_recent_repo(&mut config, "/tmp/repo1", "repo1"); + add_recent_repo(&mut config, "/tmp/repo2", "repo2"); + + assert_eq!(config.recent_repos.len(), 2); + assert_eq!(config.recent_repos[0].path, "/tmp/repo2"); + assert_eq!(config.recent_repos[1].path, "/tmp/repo1"); + } + + #[test] + fn add_recent_repo_deduplicates() { + let mut config = AppConfig::default(); + add_recent_repo(&mut config, "/tmp/repo1", "repo1"); + add_recent_repo(&mut config, "/tmp/repo2", "repo2"); + add_recent_repo(&mut config, "/tmp/repo1", "repo1"); + + assert_eq!(config.recent_repos.len(), 2); + assert_eq!(config.recent_repos[0].path, "/tmp/repo1"); + assert_eq!(config.recent_repos[1].path, "/tmp/repo2"); + } + + #[test] + fn add_recent_repo_truncates_at_max() { + let mut config = AppConfig::default(); + for i in 0..25 { + add_recent_repo(&mut config, &format!("/tmp/repo{i}"), &format!("repo{i}")); + } + + assert_eq!(config.recent_repos.len(), MAX_RECENT_REPOS); + assert_eq!(config.recent_repos[0].path, "/tmp/repo24"); + } + + #[test] + fn remove_recent_repo_removes_matching() { + let mut config = AppConfig::default(); + add_recent_repo(&mut config, "/tmp/repo1", "repo1"); + add_recent_repo(&mut config, "/tmp/repo2", "repo2"); + remove_recent_repo(&mut config, "/tmp/repo1"); + + assert_eq!(config.recent_repos.len(), 1); + assert_eq!(config.recent_repos[0].path, "/tmp/repo2"); + } + + #[test] + fn remove_recent_repo_noop_for_missing() { + let mut config = AppConfig::default(); + add_recent_repo(&mut config, "/tmp/repo1", "repo1"); + remove_recent_repo(&mut config, "/tmp/nonexistent"); + + assert_eq!(config.recent_repos.len(), 1); + } + + #[test] + fn recent_repo_serialization_roundtrip() { + let mut config = AppConfig::default(); + add_recent_repo(&mut config, "/tmp/repo1", "repo1"); + + let serialized = toml::to_string(&config).unwrap(); + let deserialized: AppConfig = toml::from_str(&serialized).unwrap(); + assert_eq!(deserialized.recent_repos.len(), 1); + assert_eq!(deserialized.recent_repos[0].path, "/tmp/repo1"); + assert_eq!(deserialized.recent_repos[0].name, "repo1"); + } } diff --git a/src-tauri/src/git/dispatcher.rs b/src-tauri/src/git/dispatcher.rs index c68223c..e87d97a 100644 --- a/src-tauri/src/git/dispatcher.rs +++ b/src-tauri/src/git/dispatcher.rs @@ -1,7 +1,7 @@ use std::path::Path; use crate::git::backend::GitBackend; -use crate::git::error::GitResult; +use crate::git::error::{GitError, GitResult}; use crate::git::git2_backend::Git2Backend; #[derive(Debug, Clone, Copy, Default)] @@ -22,4 +22,64 @@ impl GitDispatcher { pub fn open_default(path: impl AsRef) -> GitResult> { Self::open(path, BackendKind::default()) } + + pub fn init(path: impl AsRef) -> GitResult> { + let path = path.as_ref(); + git2::Repository::init(path) + .map_err(|e| GitError::InitFailed(Box::new(e)))?; + Self::open_default(path) + } + + pub fn clone_repo(url: &str, path: impl AsRef) -> GitResult> { + let path = path.as_ref(); + let output = std::process::Command::new("git") + .args(["clone", url, &path.to_string_lossy()]) + .output() + .map_err(|e| GitError::CloneFailed(Box::new(e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + return Err(GitError::CloneFailed(stderr.into())); + } + + Self::open_default(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn init_creates_repository() { + let dir = tempfile::tempdir().unwrap(); + let backend = GitDispatcher::init(dir.path()).unwrap(); + assert!(backend.workdir().exists()); + assert!(dir.path().join(".git").exists()); + } + + #[test] + fn init_returns_error_for_invalid_path() { + let result = GitDispatcher::init("/nonexistent/deeply/nested/path/that/should/fail"); + assert!(result.is_err()); + } + + #[test] + fn open_default_on_initialized_repo() { + let dir = tempfile::tempdir().unwrap(); + let canonical = std::fs::canonicalize(dir.path()).unwrap(); + git2::Repository::init(&canonical).unwrap(); + let backend = GitDispatcher::open_default(&canonical).unwrap(); + // Canonicalize both to handle macOS /private symlink + let expected = std::fs::canonicalize(canonical).unwrap(); + let actual = std::fs::canonicalize(backend.workdir()).unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn open_default_fails_for_non_repo() { + let dir = tempfile::tempdir().unwrap(); + let result = GitDispatcher::open_default(dir.path()); + assert!(result.is_err()); + } } diff --git a/src-tauri/src/git/error.rs b/src-tauri/src/git/error.rs index 8013219..6710794 100644 --- a/src-tauri/src/git/error.rs +++ b/src-tauri/src/git/error.rs @@ -118,6 +118,29 @@ pub enum GitError { #[error("signing failed: {0}")] SigningFailed(#[source] Box), + + #[error("clone failed: {0}")] + CloneFailed(#[source] Box), + + #[error("init failed: {0}")] + InitFailed(#[source] Box), } pub type GitResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clone_failed_display() { + let err = GitError::CloneFailed("connection refused".into()); + assert!(err.to_string().contains("clone failed")); + } + + #[test] + fn init_failed_display() { + let err = GitError::InitFailed("permission denied".into()); + assert!(err.to_string().contains("init failed")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9a21229..ed4a980 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,10 @@ pub mod ai; pub mod commands; -mod config; +pub mod config; pub mod git; pub mod hosting; pub mod state; -mod watcher; +pub mod watcher; use std::sync::Mutex; @@ -56,6 +56,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) .manage(AppState { repo: Mutex::new(repo), watcher: Mutex::new(None), @@ -186,6 +187,13 @@ pub fn run() { commands::gitconfig::set_gitconfig_value, commands::gitconfig::unset_gitconfig_value, commands::gitconfig::get_gitconfig_path, + commands::repo::open_repository, + commands::repo::init_repository, + commands::repo::get_recent_repos, + commands::repo::remove_recent_repo, + commands::repo::clone_repository, + commands::gitignore::list_gitignore_templates, + commands::gitignore::get_gitignore_template, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); From 3b1b2235b518eb3051327b4a523ea62960f384d8 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 12:17:05 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(frontend):=20=E3=83=AA=E3=83=9D?= =?UTF-8?q?=E3=82=B8=E3=83=88=E3=83=AA=E7=AE=A1=E7=90=86=E3=81=AEIPC?= =?UTF-8?q?=E3=82=B5=E3=83=BC=E3=83=93=E3=82=B9=E3=83=BB=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=A2=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- package.json | 1 + pnpm-lock.yaml | 10 ++ src/services/__tests__/gitignore.test.ts | 42 ++++++ src/services/__tests__/repo.test.ts | 101 +++++++++++++ src/services/gitignore.ts | 9 ++ src/services/repo.ts | 33 +++++ src/stores/__tests__/repoStore.test.ts | 173 +++++++++++++++++++++++ src/stores/repoStore.ts | 84 +++++++++++ src/stores/uiStore.ts | 3 +- 9 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 src/services/__tests__/gitignore.test.ts create mode 100644 src/services/__tests__/repo.test.ts create mode 100644 src/services/gitignore.ts create mode 100644 src/services/repo.ts create mode 100644 src/stores/__tests__/repoStore.test.ts create mode 100644 src/stores/repoStore.ts diff --git a/package.json b/package.json index e1864aa..25ea301 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-dialog": "^2", "react": "^19.2.4", "react-dom": "^19.2.4", "zustand": "^5.0.11" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2f320d..d075133 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@tauri-apps/api': specifier: ^2.10.1 version: 2.10.1 + '@tauri-apps/plugin-dialog': + specifier: ^2 + version: 2.6.0 react: specifier: ^19.2.4 version: 19.2.4 @@ -561,6 +564,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-dialog@2.6.0': + resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1285,6 +1291,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.10.0 '@tauri-apps/cli-win32-x64-msvc': 2.10.0 + '@tauri-apps/plugin-dialog@2.6.0': + dependencies: + '@tauri-apps/api': 2.10.1 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 diff --git a/src/services/__tests__/gitignore.test.ts b/src/services/__tests__/gitignore.test.ts new file mode 100644 index 0000000..97335cb --- /dev/null +++ b/src/services/__tests__/gitignore.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +import { invoke } from "@tauri-apps/api/core"; +import { getGitignoreTemplate, listGitignoreTemplates } from "../gitignore"; + +const mockedInvoke = vi.mocked(invoke); + +describe("gitignore service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("listGitignoreTemplates", () => { + it("returns template names", async () => { + const mockTemplates = ["Node", "Rust", "Python"]; + mockedInvoke.mockResolvedValueOnce(mockTemplates); + + const result = await listGitignoreTemplates(); + + expect(mockedInvoke).toHaveBeenCalledWith("list_gitignore_templates"); + expect(result).toEqual(mockTemplates); + }); + }); + + describe("getGitignoreTemplate", () => { + it("returns template content", async () => { + const mockContent = "node_modules/\n.env\n"; + mockedInvoke.mockResolvedValueOnce(mockContent); + + const result = await getGitignoreTemplate("Node"); + + expect(mockedInvoke).toHaveBeenCalledWith("get_gitignore_template", { + name: "Node", + }); + expect(result).toBe(mockContent); + }); + }); +}); diff --git a/src/services/__tests__/repo.test.ts b/src/services/__tests__/repo.test.ts new file mode 100644 index 0000000..9b1a9e4 --- /dev/null +++ b/src/services/__tests__/repo.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +import { invoke } from "@tauri-apps/api/core"; +import { + cloneRepository, + getRecentRepos, + initRepository, + openRepository, + removeRecentRepo, +} from "../repo"; + +const mockedInvoke = vi.mocked(invoke); + +describe("repo service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("openRepository", () => { + it("invokes with path", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await openRepository("/home/user/project"); + + expect(mockedInvoke).toHaveBeenCalledWith("open_repository", { + path: "/home/user/project", + }); + }); + }); + + describe("initRepository", () => { + it("invokes with path and null template", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await initRepository("/home/user/new-repo"); + + expect(mockedInvoke).toHaveBeenCalledWith("init_repository", { + path: "/home/user/new-repo", + gitignoreTemplate: null, + }); + }); + + it("invokes with path and template name", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await initRepository("/home/user/new-repo", "Rust"); + + expect(mockedInvoke).toHaveBeenCalledWith("init_repository", { + path: "/home/user/new-repo", + gitignoreTemplate: "Rust", + }); + }); + }); + + describe("cloneRepository", () => { + it("invokes with url and path", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await cloneRepository("https://example.com/repo.git", "/home/user/repo"); + + expect(mockedInvoke).toHaveBeenCalledWith("clone_repository", { + url: "https://example.com/repo.git", + path: "/home/user/repo", + }); + }); + }); + + describe("getRecentRepos", () => { + it("returns recent repos list", async () => { + const mockRepos = [ + { + path: "/home/user/project", + name: "project", + last_opened: "2026-01-01", + }, + ]; + mockedInvoke.mockResolvedValueOnce(mockRepos); + + const result = await getRecentRepos(); + + expect(mockedInvoke).toHaveBeenCalledWith("get_recent_repos"); + expect(result).toEqual(mockRepos); + }); + }); + + describe("removeRecentRepo", () => { + it("invokes with path", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await removeRecentRepo("/home/user/project"); + + expect(mockedInvoke).toHaveBeenCalledWith("remove_recent_repo", { + path: "/home/user/project", + }); + }); + }); +}); diff --git a/src/services/gitignore.ts b/src/services/gitignore.ts new file mode 100644 index 0000000..5b6a01f --- /dev/null +++ b/src/services/gitignore.ts @@ -0,0 +1,9 @@ +import { invoke } from "@tauri-apps/api/core"; + +export function listGitignoreTemplates(): Promise { + return invoke("list_gitignore_templates"); +} + +export function getGitignoreTemplate(name: string): Promise { + return invoke("get_gitignore_template", { name }); +} diff --git a/src/services/repo.ts b/src/services/repo.ts new file mode 100644 index 0000000..5c81f0f --- /dev/null +++ b/src/services/repo.ts @@ -0,0 +1,33 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface RecentRepo { + path: string; + name: string; + last_opened: string; +} + +export function openRepository(path: string): Promise { + return invoke("open_repository", { path }); +} + +export function initRepository( + path: string, + gitignoreTemplate?: string, +): Promise { + return invoke("init_repository", { + path, + gitignoreTemplate: gitignoreTemplate || null, + }); +} + +export function cloneRepository(url: string, path: string): Promise { + return invoke("clone_repository", { url, path }); +} + +export function getRecentRepos(): Promise { + return invoke("get_recent_repos"); +} + +export function removeRecentRepo(path: string): Promise { + return invoke("remove_recent_repo", { path }); +} diff --git a/src/stores/__tests__/repoStore.test.ts b/src/stores/__tests__/repoStore.test.ts new file mode 100644 index 0000000..c0853b3 --- /dev/null +++ b/src/stores/__tests__/repoStore.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +import { invoke } from "@tauri-apps/api/core"; +import { useRepoStore } from "../repoStore"; + +const mockedInvoke = vi.mocked(invoke); + +describe("repoStore", () => { + beforeEach(() => { + vi.clearAllMocks(); + useRepoStore.setState({ + recentRepos: [], + loading: false, + error: null, + }); + }); + + describe("fetchRecentRepos", () => { + it("sets recentRepos on success", async () => { + const mockRepos = [ + { + path: "/home/user/project", + name: "project", + last_opened: "2026-01-01", + }, + ]; + mockedInvoke.mockResolvedValueOnce(mockRepos); + + await useRepoStore.getState().fetchRecentRepos(); + + expect(useRepoStore.getState().recentRepos).toEqual(mockRepos); + expect(mockedInvoke).toHaveBeenCalledWith("get_recent_repos"); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("fetch repos error")); + + await expect( + useRepoStore.getState().fetchRecentRepos(), + ).rejects.toThrow(); + + expect(useRepoStore.getState().error).toContain("fetch repos error"); + }); + }); + + describe("openRepository", () => { + it("sets loading during operation", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await useRepoStore.getState().openRepository("/home/user/project"); + + expect(useRepoStore.getState().loading).toBe(false); + expect(useRepoStore.getState().error).toBeNull(); + expect(mockedInvoke).toHaveBeenCalledWith("open_repository", { + path: "/home/user/project", + }); + }); + + it("sets error and resets loading on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("open error")); + + await expect( + useRepoStore.getState().openRepository("/bad/path"), + ).rejects.toThrow(); + + const state = useRepoStore.getState(); + expect(state.error).toContain("open error"); + expect(state.loading).toBe(false); + }); + }); + + describe("initRepository", () => { + it("calls invoke on success", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await useRepoStore.getState().initRepository("/home/user/new-repo"); + + expect(useRepoStore.getState().loading).toBe(false); + expect(mockedInvoke).toHaveBeenCalledWith("init_repository", { + path: "/home/user/new-repo", + gitignoreTemplate: null, + }); + }); + + it("passes gitignore template", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await useRepoStore + .getState() + .initRepository("/home/user/new-repo", "Node"); + + expect(mockedInvoke).toHaveBeenCalledWith("init_repository", { + path: "/home/user/new-repo", + gitignoreTemplate: "Node", + }); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("init error")); + + await expect( + useRepoStore.getState().initRepository("/bad/path"), + ).rejects.toThrow(); + + const state = useRepoStore.getState(); + expect(state.error).toContain("init error"); + expect(state.loading).toBe(false); + }); + }); + + describe("cloneRepository", () => { + it("calls invoke on success", async () => { + mockedInvoke.mockResolvedValueOnce(undefined); + + await useRepoStore + .getState() + .cloneRepository("https://example.com/repo.git", "/home/user/repo"); + + expect(useRepoStore.getState().loading).toBe(false); + expect(mockedInvoke).toHaveBeenCalledWith("clone_repository", { + url: "https://example.com/repo.git", + path: "/home/user/repo", + }); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("clone error")); + + await expect( + useRepoStore.getState().cloneRepository("bad-url", "/path"), + ).rejects.toThrow(); + + const state = useRepoStore.getState(); + expect(state.error).toContain("clone error"); + expect(state.loading).toBe(false); + }); + }); + + describe("removeRecentRepo", () => { + it("removes repo from list on success", async () => { + useRepoStore.setState({ + recentRepos: [ + { path: "/home/user/a", name: "a", last_opened: "2026-01-01" }, + { path: "/home/user/b", name: "b", last_opened: "2026-01-02" }, + ], + }); + mockedInvoke.mockResolvedValueOnce(undefined); + + await useRepoStore.getState().removeRecentRepo("/home/user/a"); + + expect(useRepoStore.getState().recentRepos).toEqual([ + { path: "/home/user/b", name: "b", last_opened: "2026-01-02" }, + ]); + expect(mockedInvoke).toHaveBeenCalledWith("remove_recent_repo", { + path: "/home/user/a", + }); + }); + + it("sets error on failure", async () => { + mockedInvoke.mockRejectedValueOnce(new Error("remove error")); + + await expect( + useRepoStore.getState().removeRecentRepo("/bad/path"), + ).rejects.toThrow(); + + expect(useRepoStore.getState().error).toContain("remove error"); + }); + }); +}); diff --git a/src/stores/repoStore.ts b/src/stores/repoStore.ts new file mode 100644 index 0000000..063df8d --- /dev/null +++ b/src/stores/repoStore.ts @@ -0,0 +1,84 @@ +import { create } from "zustand"; +import type { RecentRepo } from "../services/repo"; +import { + cloneRepository as cloneRepositoryService, + getRecentRepos, + initRepository as initRepositoryService, + openRepository as openRepositoryService, + removeRecentRepo as removeRecentRepoService, +} from "../services/repo"; + +interface RepoState { + recentRepos: RecentRepo[]; + loading: boolean; + error: string | null; +} + +interface RepoActions { + fetchRecentRepos: () => Promise; + openRepository: (path: string) => Promise; + initRepository: (path: string, gitignoreTemplate?: string) => Promise; + cloneRepository: (url: string, path: string) => Promise; + removeRecentRepo: (path: string) => Promise; +} + +export const useRepoStore = create((set) => ({ + recentRepos: [], + loading: false, + error: null, + + fetchRecentRepos: async () => { + try { + const recentRepos = await getRecentRepos(); + set({ recentRepos }); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, + + openRepository: async (path: string) => { + set({ loading: true, error: null }); + try { + await openRepositoryService(path); + set({ loading: false }); + } catch (e) { + set({ error: String(e), loading: false }); + throw e; + } + }, + + initRepository: async (path: string, gitignoreTemplate?: string) => { + set({ loading: true, error: null }); + try { + await initRepositoryService(path, gitignoreTemplate); + set({ loading: false }); + } catch (e) { + set({ error: String(e), loading: false }); + throw e; + } + }, + + cloneRepository: async (url: string, path: string) => { + set({ loading: true, error: null }); + try { + await cloneRepositoryService(url, path); + set({ loading: false }); + } catch (e) { + set({ error: String(e), loading: false }); + throw e; + } + }, + + removeRecentRepo: async (path: string) => { + try { + await removeRecentRepoService(path); + set((state) => ({ + recentRepos: state.recentRepos.filter((r) => r.path !== path), + })); + } catch (e) { + set({ error: String(e) }); + throw e; + } + }, +})); diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index 781c132..b6024b1 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -15,7 +15,8 @@ export type PageId = | "reflog" | "hosting" | "submodules" - | "worktrees"; + | "worktrees" + | "open-repository"; interface BlameTarget { path: string; From 8bc8fc49d601685b71eba27cb4611d46da595755 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 12:17:12 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(frontend):=20Open=20Repository?= =?UTF-8?q?=E3=83=9A=E3=83=BC=E3=82=B8=E3=83=BBClone/Init=E3=83=80?= =?UTF-8?q?=E3=82=A4=E3=82=A2=E3=83=AD=E3=82=B0=E3=81=AEUI=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 6 + src/components/templates/AppShell.tsx | 5 +- src/main.tsx | 2 + src/pages/open-repository/index.tsx | 100 ++++++++++++ .../open-repository/molecules/RepoRow.tsx | 52 ++++++ .../open-repository/organisms/CloneDialog.tsx | 111 +++++++++++++ .../open-repository/organisms/InitDialog.tsx | 148 ++++++++++++++++++ .../organisms/RecentRepoList.tsx | 27 ++++ src/styles/init.css | 114 ++++++++++++++ src/styles/open-repository.css | 134 ++++++++++++++++ 10 files changed, 698 insertions(+), 1 deletion(-) create mode 100644 src/pages/open-repository/index.tsx create mode 100644 src/pages/open-repository/molecules/RepoRow.tsx create mode 100644 src/pages/open-repository/organisms/CloneDialog.tsx create mode 100644 src/pages/open-repository/organisms/InitDialog.tsx create mode 100644 src/pages/open-repository/organisms/RecentRepoList.tsx create mode 100644 src/styles/init.css create mode 100644 src/styles/open-repository.css diff --git a/src/App.tsx b/src/App.tsx index de6b8b3..79b480f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,9 @@ import { ConflictModal } from "./pages/conflict"; import { FileHistoryPage } from "./pages/file-history"; import { HistoryPage } from "./pages/history"; import { HostingPage } from "./pages/hosting"; +import { OpenRepositoryPage } from "./pages/open-repository"; +import { CloneDialog } from "./pages/open-repository/organisms/CloneDialog"; +import { InitDialog } from "./pages/open-repository/organisms/InitDialog"; import { RebasePage } from "./pages/rebase"; import { ReflogPage } from "./pages/reflog"; import { ResetPage } from "./pages/reset"; @@ -195,6 +198,7 @@ export function App() { {activePage === "submodules" && } {activePage === "worktrees" && } {activePage === "hosting" && } + {activePage === "open-repository" && } {activeModal === "remotes" && } @@ -209,6 +213,8 @@ export function App() { {activeModal === "conflict" && } {activeModal === "settings" && } {activeModal === "search" && } + {activeModal === "clone" && } + {activeModal === "init" && } ); } diff --git a/src/components/templates/AppShell.tsx b/src/components/templates/AppShell.tsx index af8edd8..1297b48 100644 --- a/src/components/templates/AppShell.tsx +++ b/src/components/templates/AppShell.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import type { RemoteInfo } from "../../services/git"; +import { useUIStore } from "../../stores/uiStore"; import { Sidebar } from "../organisms/Sidebar"; import { Statusbar } from "../organisms/Statusbar"; import { Titlebar } from "../organisms/Titlebar"; @@ -35,6 +36,8 @@ export function AppShell({ children, }: AppShellProps) { const defaultRemoteName = remotes[0]?.name ?? null; + const activePage = useUIStore((s) => s.activePage); + const hideSidebar = activePage === "open-repository"; return (
@@ -50,7 +53,7 @@ export function AppShell({ disabled={!hasRemotes} />
- + {!hideSidebar && }
{children}
diff --git a/src/main.tsx b/src/main.tsx index e3797e8..b4b039c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -26,6 +26,8 @@ import "./styles/reflog.css"; import "./styles/search.css"; import "./styles/submodules.css"; import "./styles/worktrees.css"; +import "./styles/open-repository.css"; +import "./styles/init.css"; const root = document.getElementById("root"); if (!root) throw new Error("Root element not found"); diff --git a/src/pages/open-repository/index.tsx b/src/pages/open-repository/index.tsx new file mode 100644 index 0000000..23f3c06 --- /dev/null +++ b/src/pages/open-repository/index.tsx @@ -0,0 +1,100 @@ +import { open } from "@tauri-apps/plugin-dialog"; +import { useCallback, useEffect } from "react"; +import { useRepoStore } from "../../stores/repoStore"; +import { useUIStore } from "../../stores/uiStore"; +import { RecentRepoList } from "./organisms/RecentRepoList"; + +export function OpenRepositoryPage() { + const recentRepos = useRepoStore((s) => s.recentRepos); + const fetchRecentRepos = useRepoStore((s) => s.fetchRecentRepos); + const openRepo = useRepoStore((s) => s.openRepository); + const addToast = useUIStore((s) => s.addToast); + const setActivePage = useUIStore((s) => s.setActivePage); + const openModal = useUIStore((s) => s.openModal); + + useEffect(() => { + fetchRecentRepos().catch((e: unknown) => { + addToast(String(e), "error"); + }); + }, [fetchRecentRepos, addToast]); + + const handleOpenFolder = useCallback(async () => { + const selected = await open({ directory: true, multiple: false }); + if (!selected) return; + try { + await openRepo(selected); + setActivePage("changes"); + } catch (e: unknown) { + addToast(`Failed to open repository: ${String(e)}`, "error"); + } + }, [openRepo, setActivePage, addToast]); + + const handleSelectRepo = useCallback( + async (path: string) => { + try { + await openRepo(path); + setActivePage("changes"); + } catch (e: unknown) { + addToast(`Failed to open repository: ${String(e)}`, "error"); + } + }, + [openRepo, setActivePage, addToast], + ); + + const handleClone = useCallback(() => { + openModal("clone"); + }, [openModal]); + + const handleInit = useCallback(() => { + openModal("init"); + }, [openModal]); + + return ( +
+
+
+

Open Repository

+

+ Select a recent repository or open a new one +

+
+ +
+ + + +
+ + +
+
+ ); +} diff --git a/src/pages/open-repository/molecules/RepoRow.tsx b/src/pages/open-repository/molecules/RepoRow.tsx new file mode 100644 index 0000000..3c54c2b --- /dev/null +++ b/src/pages/open-repository/molecules/RepoRow.tsx @@ -0,0 +1,52 @@ +import type { RecentRepo } from "../../../services/repo"; + +interface RepoRowProps { + repo: RecentRepo; + onClick: (path: string) => void; +} + +function repoIcon() { + return ( + + ); +} + +function formatRelativeTime(isoString: string): string { + const date = new Date(isoString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + + if (diffMin < 1) return "just now"; + if (diffMin < 60) return `${diffMin} min ago`; + if (diffHour < 24) return `${diffHour} hours ago`; + if (diffDay === 1) return "yesterday"; + if (diffDay < 7) return `${diffDay} days ago`; + return `${Math.floor(diffDay / 7)} weeks ago`; +} + +export function RepoRow({ repo, onClick }: RepoRowProps) { + return ( + + ); +} diff --git a/src/pages/open-repository/organisms/CloneDialog.tsx b/src/pages/open-repository/organisms/CloneDialog.tsx new file mode 100644 index 0000000..f2cf09f --- /dev/null +++ b/src/pages/open-repository/organisms/CloneDialog.tsx @@ -0,0 +1,111 @@ +import { open } from "@tauri-apps/plugin-dialog"; +import { useCallback, useState } from "react"; +import { Modal } from "../../../components/organisms/Modal"; +import { useRepoStore } from "../../../stores/repoStore"; +import { useUIStore } from "../../../stores/uiStore"; + +interface CloneDialogProps { + onClose: () => void; +} + +export function CloneDialog({ onClose }: CloneDialogProps) { + const [url, setUrl] = useState(""); + const [path, setPath] = useState(""); + const [loading, setLoading] = useState(false); + const cloneRepo = useRepoStore((s) => s.cloneRepository); + const addToast = useUIStore((s) => s.addToast); + const setActivePage = useUIStore((s) => s.setActivePage); + + const handleBrowse = useCallback(async () => { + const selected = await open({ directory: true, multiple: false }); + if (selected) { + setPath(selected); + } + }, []); + + const handleClone = useCallback(async () => { + if (!url.trim() || !path.trim()) return; + setLoading(true); + try { + await cloneRepo(url.trim(), path.trim()); + addToast("Repository cloned successfully", "success"); + setActivePage("changes"); + onClose(); + } catch (e: unknown) { + addToast(`Clone failed: ${String(e)}`, "error"); + } finally { + setLoading(false); + } + }, [url, path, cloneRepo, addToast, setActivePage, onClose]); + + const isValid = url.trim().length > 0 && path.trim().length > 0; + + return ( + + + + + } + > +
+
+ + setUrl(e.target.value)} + /> +
+
+ +
+ + setPath(e.target.value)} + /> + +
+
+
+
+ ); +} diff --git a/src/pages/open-repository/organisms/InitDialog.tsx b/src/pages/open-repository/organisms/InitDialog.tsx new file mode 100644 index 0000000..cddde10 --- /dev/null +++ b/src/pages/open-repository/organisms/InitDialog.tsx @@ -0,0 +1,148 @@ +import { open } from "@tauri-apps/plugin-dialog"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Modal } from "../../../components/organisms/Modal"; +import { listGitignoreTemplates } from "../../../services/gitignore"; +import { useRepoStore } from "../../../stores/repoStore"; +import { useUIStore } from "../../../stores/uiStore"; + +interface InitDialogProps { + onClose: () => void; +} + +export function InitDialog({ onClose }: InitDialogProps) { + const [path, setPath] = useState(""); + const [gitignoreTemplate, setGitignoreTemplate] = useState(""); + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(false); + const initRepo = useRepoStore((s) => s.initRepository); + const addToast = useUIStore((s) => s.addToast); + const setActivePage = useUIStore((s) => s.setActivePage); + + useEffect(() => { + listGitignoreTemplates() + .then(setTemplates) + .catch(() => { + // Non-critical; template list is optional + }); + }, []); + + const repoName = useMemo(() => { + if (!path) return ""; + const parts = path.replace(/\/+$/, "").split("/"); + return parts[parts.length - 1] || ""; + }, [path]); + + const handleBrowse = useCallback(async () => { + const selected = await open({ directory: true, multiple: false }); + if (selected) { + setPath(selected); + } + }, []); + + const handleInit = useCallback(async () => { + if (!path.trim()) return; + setLoading(true); + try { + await initRepo(path.trim(), gitignoreTemplate || undefined); + addToast("Repository initialized successfully", "success"); + setActivePage("changes"); + onClose(); + } catch (e: unknown) { + addToast(`Init failed: ${String(e)}`, "error"); + } finally { + setLoading(false); + } + }, [path, gitignoreTemplate, initRepo, addToast, setActivePage, onClose]); + + return ( + + + + + } + > +
+
+ +
+ + setPath(e.target.value)} + /> + +
+
+ +
+ + + Derived from directory path +
+ +
+ + +
+
+
+ ); +} diff --git a/src/pages/open-repository/organisms/RecentRepoList.tsx b/src/pages/open-repository/organisms/RecentRepoList.tsx new file mode 100644 index 0000000..6d25c3d --- /dev/null +++ b/src/pages/open-repository/organisms/RecentRepoList.tsx @@ -0,0 +1,27 @@ +import type { RecentRepo } from "../../../services/repo"; +import { RepoRow } from "../molecules/RepoRow"; + +interface RecentRepoListProps { + repos: RecentRepo[]; + onSelect: (path: string) => void; +} + +export function RecentRepoList({ repos, onSelect }: RecentRepoListProps) { + if (repos.length === 0) { + return null; + } + + return ( +
+
+ Recent Repositories + {repos.length} +
+
+ {repos.map((repo) => ( + + ))} +
+
+ ); +} diff --git a/src/styles/init.css b/src/styles/init.css new file mode 100644 index 0000000..659e2bc --- /dev/null +++ b/src/styles/init.css @@ -0,0 +1,114 @@ +/* ===== Init / Clone Form ===== */ +.init-form { + display: flex; + flex-direction: column; + gap: 18px; +} + +.init-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.init-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.init-input { + width: 100%; + padding: 10px 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-family: "JetBrains Mono", monospace; + font-size: 13px; + transition: border-color 0.15s ease; + box-sizing: border-box; +} + +.init-input:focus { + outline: none; + border-color: var(--accent); +} + +.init-input::placeholder { + color: var(--text-muted); +} + +.init-input.readonly { + opacity: 0.6; + cursor: default; + background: var(--bg-secondary); +} + +/* ===== Path Group (input + browse button) ===== */ +.init-path-group { + display: flex; + align-items: center; + gap: 0; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + transition: border-color 0.15s ease; +} + +.init-path-group:focus-within { + border-color: var(--accent); +} + +.init-path-icon { + width: 16px; + height: 16px; + color: var(--text-muted); + margin-left: 12px; + flex-shrink: 0; +} + +.init-path-group .init-input { + border: none; + border-radius: 0; + background: transparent; + flex: 1; + min-width: 0; +} + +.init-path-group .init-input:focus { + border-color: transparent; +} + +.init-browse-btn { + padding: 10px 16px; + background: var(--bg-secondary); + border: none; + border-left: 1px solid var(--border); + color: var(--text-secondary); + font-family: inherit; + font-size: 12px; + font-weight: 500; + cursor: pointer; + flex-shrink: 0; + transition: all 0.15s ease; +} + +.init-browse-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* ===== Derived Text ===== */ +.init-derived { + font-size: 11px; + color: var(--text-muted); +} + +/* ===== Select Override ===== */ +.init-field .modal-select { + width: 100%; +} diff --git a/src/styles/open-repository.css b/src/styles/open-repository.css new file mode 100644 index 0000000..1138909 --- /dev/null +++ b/src/styles/open-repository.css @@ -0,0 +1,134 @@ +/* ===== New Tab / Open Repository Page ===== */ +.newtab-page { + display: flex; + align-items: flex-start; + justify-content: center; + width: 100%; + height: 100%; + overflow-y: auto; + padding: 60px 20px; + box-sizing: border-box; +} + +.newtab-content { + width: 100%; + max-width: 640px; +} + +.newtab-header { + margin-bottom: 32px; +} +.newtab-title { + font-size: 24px; + font-weight: 700; + margin: 0 0 8px; +} +.newtab-desc { + font-size: 14px; + color: var(--text-muted); + margin: 0; +} + +.newtab-actions { + display: flex; + gap: 12px; + margin-bottom: 40px; +} + +.newtab-section { + margin-bottom: 24px; +} +.newtab-section-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} +.newtab-section-title { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} +.newtab-section-count { + background: var(--bg-tertiary); + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + color: var(--text-muted); +} + +.newtab-repo-list { + border-top: 1px solid var(--border); +} +.newtab-repo-row { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background-color 0.15s ease; + width: 100%; + background: none; + border-top: none; + border-left: none; + border-right: none; + color: inherit; + font: inherit; + text-align: left; +} +.newtab-repo-row:hover { + background: var(--bg-hover); +} +.newtab-repo-icon { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-tertiary); + border-radius: 8px; + color: var(--text-muted); + flex-shrink: 0; +} +.newtab-repo-icon svg { + width: 18px; + height: 18px; +} +.newtab-repo-info { + flex: 1; + min-width: 0; +} +.newtab-repo-name { + font-size: 14px; + font-weight: 600; + margin-bottom: 2px; +} +.newtab-repo-path { + font-size: 11px; + color: var(--text-muted); + font-family: "JetBrains Mono", monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.newtab-repo-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + flex-shrink: 0; +} +.newtab-repo-date { + font-size: 11px; + color: var(--text-muted); +} + +/* ===== Responsive Design ===== */ +@media (max-width: 767px) { + .newtab-page { + padding: 40px 16px; + } +} From a40f376205721dc1e7ac9530345046593b87e0d9 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 12:17:16 +0900 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20roadmap.md=20v1.6=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/roadmap.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index cb784ec..cb9b0db 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -466,13 +466,13 @@ **ゴール**: リポジトリの開き方・作り方の選択肢を広げる -- [ ] リポジトリ管理 - - [ ] クローン(HTTPS/SSH 対応、GUI ダイアログ) - - [ ] 新規リポジトリ初期化(git init) - - [ ] 最近開いたリポジトリ一覧 -- [ ] .gitignore テンプレート - - [ ] github/gitignore からテンプレート取得 - - [ ] 言語/フレームワーク選択 +- [x] リポジトリ管理 + - [x] クローン(HTTPS/SSH 対応、GUI ダイアログ) + - [x] 新規リポジトリ初期化(git init) + - [x] 最近開いたリポジトリ一覧 +- [x] .gitignore テンプレート + - [x] github/gitignore からテンプレート取得 + - [x] 言語/フレームワーク選択 - [ ] 複数テンプレート組み合わせ - [ ] 既存 .gitignore とのマージ From 4828c24ebc574f3bfd39f9ec09c23730ddfb6dbc Mon Sep 17 00:00:00 2001 From: HMasataka Date: Sat, 7 Mar 2026 13:32:31 +0900 Subject: [PATCH 6/6] format --- src-tauri/src/config/mod.rs | 4 +--- src-tauri/src/git/dispatcher.rs | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index 83c22ee..9674ca0 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -151,9 +151,7 @@ pub fn save_config(config: &AppConfig) -> GitResult<()> { pub fn add_recent_repo(config: &mut AppConfig, path: &str, name: &str) { let now = chrono::Utc::now().to_rfc3339(); - config - .recent_repos - .retain(|r| r.path != path); + config.recent_repos.retain(|r| r.path != path); config.recent_repos.insert( 0, RecentRepo { diff --git a/src-tauri/src/git/dispatcher.rs b/src-tauri/src/git/dispatcher.rs index e87d97a..71a9aa1 100644 --- a/src-tauri/src/git/dispatcher.rs +++ b/src-tauri/src/git/dispatcher.rs @@ -25,8 +25,7 @@ impl GitDispatcher { pub fn init(path: impl AsRef) -> GitResult> { let path = path.as_ref(); - git2::Repository::init(path) - .map_err(|e| GitError::InitFailed(Box::new(e)))?; + git2::Repository::init(path).map_err(|e| GitError::InitFailed(Box::new(e)))?; Self::open_default(path) }