Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0af57f1818 | ||
|
|
8e6811b786 | ||
|
|
de8832a919 | ||
|
|
25b98d7c4d | ||
|
|
7ef1a2dac8 | ||
|
|
44a5b776d7 | ||
|
|
55b4c9fc50 | ||
|
|
35d9fc969c | ||
|
|
710c6d4a53 | ||
|
|
0877d5a568 | ||
|
|
f79f3dc1ec | ||
|
|
cf2773b6f4 | ||
|
|
0ba7169efd | ||
|
|
c087171735 | ||
|
|
2d3c30b553 | ||
|
|
8409556158 | ||
|
|
db60f57544 | ||
|
|
cb5865094f | ||
|
|
c74db60daf | ||
|
|
812027abcf | ||
|
|
c8d5553214 | ||
|
|
8d9dedf93c | ||
|
|
2c9b8adb9e | ||
|
|
e6d9e60b8f | ||
|
|
8627012d93 | ||
|
|
a21fa9e54c | ||
|
|
adfb5f4158 | ||
|
|
6f3f438f15 | ||
|
|
b495313a51 | ||
|
|
eed7325719 | ||
|
|
51482c497f |
@@ -96,7 +96,11 @@
|
|||||||
"Bash(identify src-tauri/icons/GitCat.ico)",
|
"Bash(identify src-tauri/icons/GitCat.ico)",
|
||||||
"Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")",
|
"Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")",
|
||||||
"Bash(python3 *)",
|
"Bash(python3 *)",
|
||||||
"Bash(xxd -l 16 src-tauri/icons/GitCat.ico)"
|
"Bash(xxd -l 16 src-tauri/icons/GitCat.ico)",
|
||||||
|
"Bash(pkg-config --exists openssl)",
|
||||||
|
"Bash(sudo apt-get install -y libssl-dev pkg-config)",
|
||||||
|
"Bash(dpkg -L libssl3t64)",
|
||||||
|
"Bash(grep *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,51 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="public/splash-icon.png" alt="Gitty Logo" width="360">
|
||||||
|
|
||||||
|
# Gitty
|
||||||
|
|
||||||
|
**A lightweight, modern Git client built with Tauri.**
|
||||||
|
|
||||||
|
Fast, simple, and designed for developers who want a clean Git experience without unnecessary complexity.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- 🚀 Fast native performance
|
||||||
|
- 🌿 Branch management
|
||||||
|
- 📝 Commit history
|
||||||
|
- 🔄 Pull, Push & Fetch
|
||||||
|
- 🔀 Merge & Rebase
|
||||||
|
- 📦 Repository management
|
||||||
|
- 🎨 Modern and intuitive UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📸 Preview
|
||||||
|
|
||||||
|
> Screenshots coming soon.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Built With
|
||||||
|
|
||||||
|
- **Tauri**
|
||||||
|
- **Rust**
|
||||||
|
- **Svelte**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 Status
|
||||||
|
|
||||||
|
Gitty is currently under active development.
|
||||||
|
|
||||||
|
Contributions, feedback and feature requests are always welcome!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT License
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "0.200.2",
|
"version": "2026.7.17",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "0.200.2",
|
"version": "2026.7.17",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/svelte": "^1.21.0",
|
"@lucide/svelte": "^1.21.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "0.200.2",
|
"version": "2026.7.17",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+126
-9
@@ -2543,6 +2543,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-aptabase",
|
"tauri-plugin-aptabase",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
@@ -3420,7 +3421,7 @@ dependencies = [
|
|||||||
"security-framework 2.11.1",
|
"security-framework 2.11.1",
|
||||||
"security-framework 3.7.0",
|
"security-framework 3.7.0",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
"zbus",
|
"zbus 4.4.0",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5895,7 +5896,7 @@ dependencies = [
|
|||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2",
|
"sha2",
|
||||||
"zbus",
|
"zbus 4.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7066,6 +7067,21 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-single-instance"
|
||||||
|
version = "2.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
"zbus 5.17.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-updater"
|
name = "tauri-plugin-updater"
|
||||||
version = "2.10.1"
|
version = "2.10.1"
|
||||||
@@ -9126,9 +9142,44 @@ dependencies = [
|
|||||||
"uds_windows",
|
"uds_windows",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
"xdg-home",
|
"xdg-home",
|
||||||
"zbus_macros",
|
"zbus_macros 4.4.0",
|
||||||
"zbus_names",
|
"zbus_names 3.0.0",
|
||||||
"zvariant",
|
"zvariant 4.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus"
|
||||||
|
version = "5.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e"
|
||||||
|
dependencies = [
|
||||||
|
"async-broadcast",
|
||||||
|
"async-executor",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"async-process",
|
||||||
|
"async-recursion",
|
||||||
|
"async-task",
|
||||||
|
"async-trait",
|
||||||
|
"blocking",
|
||||||
|
"enumflags2",
|
||||||
|
"event-listener",
|
||||||
|
"futures-core",
|
||||||
|
"futures-lite",
|
||||||
|
"hex",
|
||||||
|
"libc",
|
||||||
|
"ordered-stream",
|
||||||
|
"rustix",
|
||||||
|
"serde",
|
||||||
|
"serde_repr",
|
||||||
|
"tracing",
|
||||||
|
"uds_windows",
|
||||||
|
"uuid 1.23.4",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
"zbus_macros 5.17.0",
|
||||||
|
"zbus_names 4.3.3",
|
||||||
|
"zvariant 5.13.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9141,7 +9192,22 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
"zvariant_utils",
|
"zvariant_utils 2.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus_macros"
|
||||||
|
version = "5.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate 3.5.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
"zbus_names 4.3.3",
|
||||||
|
"zvariant 5.13.0",
|
||||||
|
"zvariant_utils 3.5.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9152,7 +9218,18 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"zvariant",
|
"zvariant 4.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus_names"
|
||||||
|
version = "4.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
"zvariant 5.13.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9313,7 +9390,21 @@ dependencies = [
|
|||||||
"enumflags2",
|
"enumflags2",
|
||||||
"serde",
|
"serde",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"zvariant_derive",
|
"zvariant_derive 4.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant"
|
||||||
|
version = "5.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7"
|
||||||
|
dependencies = [
|
||||||
|
"endi",
|
||||||
|
"enumflags2",
|
||||||
|
"serde",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
"zvariant_derive 5.13.0",
|
||||||
|
"zvariant_utils 3.5.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9326,7 +9417,20 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
"zvariant_utils",
|
"zvariant_utils 2.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant_derive"
|
||||||
|
version = "5.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate 3.5.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
"zvariant_utils 3.5.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9339,3 +9443,16 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant_utils"
|
||||||
|
version = "3.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"serde",
|
||||||
|
"syn 2.0.118",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ tokio = "1.52.3"
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
|
|
||||||
# ── Build profiles ───────────────────────────────────────────────────────────
|
# ── Build profiles ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
!macro GITTY_MIGRATE_LEGACY_DATA_DIR oldDir newDir
|
!macro GITTY_MIGRATE_LEGACY_DATA_DIR oldDir newDir
|
||||||
${IfNot} ${FileExists} "${newDir}\*.*"
|
${IfNot} ${FileExists} "${newDir}\*.*"
|
||||||
${AndIf} ${FileExists} "${oldDir}\*.*"
|
${AndIf} ${FileExists} "${oldDir}\*.*"
|
||||||
CreateDirectory "${newDir}"
|
|
||||||
Rename "${oldDir}" "${newDir}"
|
Rename "${oldDir}" "${newDir}"
|
||||||
${EndIf}
|
${EndIf}
|
||||||
!macroend
|
!macroend
|
||||||
@@ -34,6 +33,27 @@
|
|||||||
RMDir /r "$SMPROGRAMS\GitLite"
|
RMDir /r "$SMPROGRAMS\GitLite"
|
||||||
!macroend
|
!macroend
|
||||||
|
|
||||||
|
!macro GITTY_REGISTER_INSTALL
|
||||||
|
WriteRegStr HKCU "${MANUPRODUCTKEY}" "" "$INSTDIR"
|
||||||
|
WriteRegStr HKCU "${UNINSTKEY}" "MainBinaryName" "${MAINBINARYNAME}.exe"
|
||||||
|
WriteRegStr HKCU "${UNINSTKEY}" "DisplayName" "${PRODUCTNAME}"
|
||||||
|
WriteRegStr HKCU "${UNINSTKEY}" "DisplayIcon" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\""
|
||||||
|
WriteRegStr HKCU "${UNINSTKEY}" "DisplayVersion" "${VERSION}"
|
||||||
|
WriteRegStr HKCU "${UNINSTKEY}" "Publisher" "${MANUFACTURER}"
|
||||||
|
WriteRegStr HKCU "${UNINSTKEY}" "InstallLocation" "$\"$INSTDIR$\""
|
||||||
|
WriteRegStr HKCU "${UNINSTKEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||||
|
WriteRegStr HKCU "${UNINSTKEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
|
||||||
|
WriteRegDWORD HKCU "${UNINSTKEY}" "NoModify" "1"
|
||||||
|
WriteRegDWORD HKCU "${UNINSTKEY}" "NoRepair" "1"
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro GITTY_CREATE_UPDATE_SHORTCUTS
|
||||||
|
CreateShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
|
||||||
|
!insertmacro SetLnkAppUserModelId "$SMPROGRAMS\${PRODUCTNAME}.lnk"
|
||||||
|
CreateShortcut "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe"
|
||||||
|
!insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk"
|
||||||
|
!macroend
|
||||||
|
|
||||||
!macro NSIS_HOOK_PREINSTALL
|
!macro NSIS_HOOK_PREINSTALL
|
||||||
!insertmacro GITTY_MIGRATE_LEGACY_DATA_DIR "$APPDATA\com.git-lite" "$APPDATA\com.gitty"
|
!insertmacro GITTY_MIGRATE_LEGACY_DATA_DIR "$APPDATA\com.git-lite" "$APPDATA\com.gitty"
|
||||||
!insertmacro GITTY_MIGRATE_LEGACY_DATA_DIR "$LOCALAPPDATA\com.git-lite" "$LOCALAPPDATA\com.gitty"
|
!insertmacro GITTY_MIGRATE_LEGACY_DATA_DIR "$LOCALAPPDATA\com.git-lite" "$LOCALAPPDATA\com.gitty"
|
||||||
@@ -43,6 +63,8 @@
|
|||||||
|
|
||||||
!macro NSIS_HOOK_POSTINSTALL
|
!macro NSIS_HOOK_POSTINSTALL
|
||||||
!insertmacro GITTY_CLEANUP_LEGACY_SHORTCUTS
|
!insertmacro GITTY_CLEANUP_LEGACY_SHORTCUTS
|
||||||
|
!insertmacro GITTY_REGISTER_INSTALL
|
||||||
|
!insertmacro GITTY_CREATE_UPDATE_SHORTCUTS
|
||||||
!macroend
|
!macroend
|
||||||
|
|
||||||
!macro NSIS_HOOK_POSTUNINSTALL
|
!macro NSIS_HOOK_POSTUNINSTALL
|
||||||
|
|||||||
+245
-184
@@ -681,7 +681,14 @@ pub fn create_tag(
|
|||||||
Some(message) => {
|
Some(message) => {
|
||||||
run_git(
|
run_git(
|
||||||
&repo,
|
&repo,
|
||||||
["tag", "-a", name.as_str(), "-m", message.as_str(), target.as_str()],
|
[
|
||||||
|
"tag",
|
||||||
|
"-a",
|
||||||
|
name.as_str(),
|
||||||
|
"-m",
|
||||||
|
message.as_str(),
|
||||||
|
target.as_str(),
|
||||||
|
],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -755,7 +762,11 @@ fn validate_existing_tag_name(repo: &Path, name: &str) -> Result<String, String>
|
|||||||
|
|
||||||
fn validate_tag_ref_name(name: &str) -> Result<String, String> {
|
fn validate_tag_ref_name(name: &str) -> Result<String, String> {
|
||||||
let output = git_command()
|
let output = git_command()
|
||||||
.args(["check-ref-format", "--allow-onelevel", &format!("refs/tags/{name}")])
|
.args([
|
||||||
|
"check-ref-format",
|
||||||
|
"--allow-onelevel",
|
||||||
|
&format!("refs/tags/{name}"),
|
||||||
|
])
|
||||||
.output()
|
.output()
|
||||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||||
|
|
||||||
@@ -778,63 +789,75 @@ fn validate_tag_ref_name(name: &str) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
pub async fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||||
validate_files(&files)?;
|
let repo = resolve_repo(&path)?;
|
||||||
|
validate_files(&files)?;
|
||||||
|
|
||||||
if !files.is_empty() {
|
if !files.is_empty() {
|
||||||
// A file we've detected as an unstaged rename (see `detect_worktree_renames`) has
|
// A file we've detected as an unstaged rename (see `detect_worktree_renames`) has
|
||||||
// to be staged with both its old and new path so `git add` records it as a rename
|
// to be staged with both its old and new path so `git add` records it as a rename
|
||||||
// instead of leaving the old path's deletion unstaged.
|
// instead of leaving the old path's deletion unstaged.
|
||||||
let current_status = status_for_repo(&repo)?;
|
let current_status = status_for_repo(&repo)?;
|
||||||
let mut add_paths: Vec<String> = Vec::new();
|
let mut add_paths: Vec<String> = Vec::new();
|
||||||
for file in &files {
|
for file in &files {
|
||||||
match find_status(¤t_status.files, file) {
|
match find_status(¤t_status.files, file) {
|
||||||
Some(entry) => {
|
Some(entry) => {
|
||||||
if let Some(old_path) = &entry.old_path {
|
if let Some(old_path) = &entry.old_path {
|
||||||
add_paths.push(old_path.clone());
|
add_paths.push(old_path.clone());
|
||||||
|
}
|
||||||
|
add_paths.push(entry.path.clone());
|
||||||
}
|
}
|
||||||
add_paths.push(entry.path.clone());
|
None => add_paths.push(file.clone()),
|
||||||
}
|
}
|
||||||
None => add_paths.push(file.clone()),
|
|
||||||
}
|
}
|
||||||
|
run_git_with_paths(&repo, &["add"], &add_paths)?;
|
||||||
}
|
}
|
||||||
run_git_with_paths(&repo, &["add"], &add_paths)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not stage files: {err}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
pub async fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||||
validate_files(&files)?;
|
let repo = resolve_repo(&path)?;
|
||||||
|
validate_files(&files)?;
|
||||||
|
|
||||||
|
if !files.is_empty() {
|
||||||
|
let current_status = status_for_repo(&repo)?;
|
||||||
|
unstage_selected_files(&repo, ¤t_status.files, &files)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
status_for_repo(&repo)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not unstage files: {err}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<GitStatus, String> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
validate_files(&files)?;
|
||||||
|
|
||||||
|
if files.is_empty() {
|
||||||
|
return status_for_repo(&repo);
|
||||||
|
}
|
||||||
|
|
||||||
if !files.is_empty() {
|
|
||||||
let current_status = status_for_repo(&repo)?;
|
let current_status = status_for_repo(&repo)?;
|
||||||
unstage_selected_files(&repo, ¤t_status.files, &files)?;
|
if staged {
|
||||||
}
|
restore_staged_files(&repo, ¤t_status.files, &files)?;
|
||||||
|
} else {
|
||||||
|
restore_worktree_files(&repo, ¤t_status.files, &files)?;
|
||||||
|
}
|
||||||
|
|
||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
}
|
})
|
||||||
|
.await
|
||||||
#[tauri::command]
|
.map_err(|err| format!("Could not restore files: {err}"))?
|
||||||
pub fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<GitStatus, String> {
|
|
||||||
let repo = resolve_repo(&path)?;
|
|
||||||
validate_files(&files)?;
|
|
||||||
|
|
||||||
if files.is_empty() {
|
|
||||||
return status_for_repo(&repo);
|
|
||||||
}
|
|
||||||
|
|
||||||
let current_status = status_for_repo(&repo)?;
|
|
||||||
if staged {
|
|
||||||
restore_staged_files(&repo, ¤t_status.files, &files)?;
|
|
||||||
} else {
|
|
||||||
restore_worktree_files(&repo, ¤t_status.files, &files)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
status_for_repo(&repo)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1044,47 +1067,55 @@ pub fn apply_file_patch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
|
pub async fn commit(path: String, message: String) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||||
if message.trim().is_empty() {
|
let repo = resolve_repo(&path)?;
|
||||||
return Err("Commit message must not be empty.".to_string());
|
if message.trim().is_empty() {
|
||||||
}
|
return Err("Commit message must not be empty.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let current_status = status_for_repo(&repo)?;
|
let current_status = status_for_repo(&repo)?;
|
||||||
if has_unresolved_conflicts(¤t_status) {
|
if has_unresolved_conflicts(¤t_status) {
|
||||||
return Err("Merge conflicts must be resolved before you can commit.".to_string());
|
return Err("Merge conflicts must be resolved before you can commit.".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
run_git(&repo, ["commit", "-m", message.as_str()])?;
|
run_git(&repo, ["commit", "-m", message.as_str()])?;
|
||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not commit: {err}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> {
|
pub async fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||||
if verify_commit(&repo, "HEAD").is_err() {
|
let repo = resolve_repo(&path)?;
|
||||||
return Err("There is no commit to amend.".to_string());
|
if verify_commit(&repo, "HEAD").is_err() {
|
||||||
}
|
return Err("There is no commit to amend.".to_string());
|
||||||
|
|
||||||
let current_status = status_for_repo(&repo)?;
|
|
||||||
if has_unresolved_conflicts(¤t_status) {
|
|
||||||
return Err("Merge conflicts must be resolved before you can commit.".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let message = message
|
|
||||||
.map(|message| message.trim().to_string())
|
|
||||||
.filter(|message| !message.is_empty());
|
|
||||||
|
|
||||||
match message {
|
|
||||||
Some(message) => {
|
|
||||||
run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?;
|
|
||||||
}
|
}
|
||||||
None => {
|
|
||||||
run_git(&repo, ["commit", "--amend", "--no-edit"])?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
status_for_repo(&repo)
|
let current_status = status_for_repo(&repo)?;
|
||||||
|
if has_unresolved_conflicts(¤t_status) {
|
||||||
|
return Err("Merge conflicts must be resolved before you can commit.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = message
|
||||||
|
.map(|message| message.trim().to_string())
|
||||||
|
.filter(|message| !message.is_empty());
|
||||||
|
|
||||||
|
match message {
|
||||||
|
Some(message) => {
|
||||||
|
run_git(&repo, ["commit", "--amend", "-m", message.as_str()])?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
run_git(&repo, ["commit", "--amend", "--no-edit"])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status_for_repo(&repo)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not amend commit: {err}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1096,7 +1127,11 @@ pub fn last_commit_message(path: String) -> Result<Option<String>, String> {
|
|||||||
|
|
||||||
let output = run_git(&repo, ["log", "-1", "--format=%B", "HEAD"])?;
|
let output = run_git(&repo, ["log", "-1", "--format=%B", "HEAD"])?;
|
||||||
let message = String::from_utf8_lossy(&output).trim_end().to_string();
|
let message = String::from_utf8_lossy(&output).trim_end().to_string();
|
||||||
Ok(if message.is_empty() { None } else { Some(message) })
|
Ok(if message.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(message)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1117,39 +1152,43 @@ pub fn undo_last_commit(path: String) -> Result<GitStatus, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn pull(
|
pub async fn pull(
|
||||||
path: String,
|
path: String,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
) -> Result<GitStatus, String> {
|
) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||||
let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"];
|
let repo = resolve_repo(&path)?;
|
||||||
let output = match (username.as_deref(), password.as_deref()) {
|
let pull_args = ["pull", "--no-rebase", "--ff", "--no-edit"];
|
||||||
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
let output = match (username.as_deref(), password.as_deref()) {
|
||||||
run_git_authenticated_output(&repo, pull_args, u, p)?
|
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||||
|
run_git_authenticated_output(&repo, pull_args, u, p)?
|
||||||
|
}
|
||||||
|
_ => git_command()
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&repo)
|
||||||
|
.args(pull_args)
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
|
||||||
|
};
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
return status_for_repo(&repo);
|
||||||
}
|
}
|
||||||
_ => git_command()
|
|
||||||
.arg("-C")
|
|
||||||
.arg(&repo)
|
|
||||||
.args(pull_args)
|
|
||||||
.output()
|
|
||||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?,
|
|
||||||
};
|
|
||||||
|
|
||||||
if output.status.success() {
|
let status = status_for_repo(&repo)?;
|
||||||
return status_for_repo(&repo);
|
if has_unresolved_conflicts(&status) {
|
||||||
}
|
return Ok(status);
|
||||||
|
}
|
||||||
|
|
||||||
let status = status_for_repo(&repo)?;
|
let details = command_output_details(&output);
|
||||||
if has_unresolved_conflicts(&status) {
|
if is_auth_error(&details) {
|
||||||
return Ok(status);
|
return Err(format!("AUTH_FAILED:{details}"));
|
||||||
}
|
}
|
||||||
|
Err(format!("Git command failed: {details}"))
|
||||||
let details = command_output_details(&output);
|
})
|
||||||
if is_auth_error(&details) {
|
.await
|
||||||
return Err(format!("AUTH_FAILED:{details}"));
|
.map_err(|err| format!("Could not pull: {err}"))?
|
||||||
}
|
|
||||||
Err(format!("Git command failed: {details}"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1188,22 +1227,26 @@ pub async fn fetch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn push(
|
pub async fn push(
|
||||||
path: String,
|
path: String,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
) -> Result<GitStatus, String> {
|
) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||||
let push_args = push_args_for_repo(&repo)?;
|
let repo = resolve_repo(&path)?;
|
||||||
match (username.as_deref(), password.as_deref()) {
|
let push_args = push_args_for_repo(&repo)?;
|
||||||
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
match (username.as_deref(), password.as_deref()) {
|
||||||
run_git_authenticated(&repo, push_args, u, p)?;
|
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||||
|
run_git_authenticated(&repo, push_args, u, p)?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
run_git(&repo, push_args)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
status_for_repo(&repo)
|
||||||
run_git(&repo, push_args)?;
|
})
|
||||||
}
|
.await
|
||||||
}
|
.map_err(|err| format!("Could not push: {err}"))?
|
||||||
status_for_repo(&repo)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Credential storage (OS keychain) ────────────────────────────────────────
|
// ── Credential storage (OS keychain) ────────────────────────────────────────
|
||||||
@@ -1373,61 +1416,69 @@ pub fn cred_delete(key: String) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
pub async fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||||
let branch = branch.trim();
|
let repo = resolve_repo(&path)?;
|
||||||
if branch.is_empty() {
|
let branch = branch.trim();
|
||||||
return Err("Branch name must not be empty.".to_string());
|
if branch.is_empty() {
|
||||||
}
|
return Err("Branch name must not be empty.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let output = git_command()
|
let output = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(&repo)
|
.arg(&repo)
|
||||||
.args(["merge", "--no-edit", branch])
|
.args(["merge", "--no-edit", branch])
|
||||||
.output()
|
.output()
|
||||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||||
|
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
return status_for_repo(&repo);
|
return status_for_repo(&repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A merge that stops on conflicts leaves unmerged paths in the work tree.
|
// A merge that stops on conflicts leaves unmerged paths in the work tree.
|
||||||
// Surface those through the status so the UI can offer conflict resolution
|
// Surface those through the status so the UI can offer conflict resolution
|
||||||
// instead of treating the conflict as a hard error.
|
// instead of treating the conflict as a hard error.
|
||||||
let status = status_for_repo(&repo)?;
|
let status = status_for_repo(&repo)?;
|
||||||
if has_unresolved_conflicts(&status) {
|
if has_unresolved_conflicts(&status) {
|
||||||
return Ok(status);
|
return Ok(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
let details = if !stderr.trim().is_empty() {
|
let details = if !stderr.trim().is_empty() {
|
||||||
stderr.trim()
|
stderr.trim()
|
||||||
} else if !stdout.trim().is_empty() {
|
} else if !stdout.trim().is_empty() {
|
||||||
stdout.trim()
|
stdout.trim()
|
||||||
} else {
|
} else {
|
||||||
"unknown error"
|
"unknown error"
|
||||||
};
|
};
|
||||||
|
|
||||||
Err(format!("Merge failed: {details}"))
|
Err(format!("Merge failed: {details}"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not merge: {err}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||||
let branch = branch.trim();
|
let repo = resolve_repo(&path)?;
|
||||||
if branch.is_empty() {
|
let branch = branch.trim();
|
||||||
return Err("Branch name must not be empty.".to_string());
|
if branch.is_empty() {
|
||||||
}
|
return Err("Branch name must not be empty.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let output = git_command()
|
let output = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(&repo)
|
.arg(&repo)
|
||||||
.args(["rebase", branch])
|
.args(["rebase", branch])
|
||||||
.output()
|
.output()
|
||||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||||
|
|
||||||
rebase_status_or_error(&repo, output, "Rebase failed", true)
|
rebase_status_or_error(&repo, output, "Rebase failed", true)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not rebase: {err}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1479,19 +1530,23 @@ fn rebase_status_or_error(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> {
|
pub async fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||||
let commit_hash = verify_commit(&repo, &commit)?;
|
let repo = resolve_repo(&path)?;
|
||||||
|
let commit_hash = verify_commit(&repo, &commit)?;
|
||||||
|
|
||||||
let output = git_command()
|
let output = git_command()
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(&repo)
|
.arg(&repo)
|
||||||
.args(["cherry-pick", commit_hash.as_str()])
|
.args(["cherry-pick", commit_hash.as_str()])
|
||||||
.env("GIT_EDITOR", "true")
|
.env("GIT_EDITOR", "true")
|
||||||
.output()
|
.output()
|
||||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||||
|
|
||||||
cherry_pick_status_or_error(&repo, output, "Cherry-pick failed")
|
cherry_pick_status_or_error(&repo, output, "Cherry-pick failed")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not cherry-pick: {err}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -4826,12 +4881,12 @@ mod tests {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
#[cfg_attr(
|
#[cfg_attr(
|
||||||
windows,
|
windows,
|
||||||
ignore = "Git for Windows can fail local pull tests with a sh signal pipe error"
|
ignore = "Git for Windows can fail local pull tests with a sh signal pipe error"
|
||||||
)]
|
)]
|
||||||
fn pull_merges_diverging_branch_instead_of_requiring_fast_forward() {
|
async fn pull_merges_diverging_branch_instead_of_requiring_fast_forward() {
|
||||||
let repo = init_temp_repo("pull_diverged");
|
let repo = init_temp_repo("pull_diverged");
|
||||||
commit_initial_file(&repo.path);
|
commit_initial_file(&repo.path);
|
||||||
let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
let base_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||||
@@ -4861,7 +4916,9 @@ mod tests {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
let status = pull(repo.path.to_string_lossy().to_string(), None, None).unwrap();
|
let status = pull(repo.path.to_string_lossy().to_string(), None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(status.clean, "{:?}", status.files);
|
assert!(status.clean, "{:?}", status.files);
|
||||||
assert!(repo.path.join("remote.txt").exists());
|
assert!(repo.path.join("remote.txt").exists());
|
||||||
@@ -4910,12 +4967,12 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
#[cfg_attr(
|
#[cfg_attr(
|
||||||
windows,
|
windows,
|
||||||
ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
|
ignore = "Git for Windows can fail local push tests with a sh signal pipe error"
|
||||||
)]
|
)]
|
||||||
fn push_sets_upstream_for_branch_without_tracking_remote() {
|
async fn push_sets_upstream_for_branch_without_tracking_remote() {
|
||||||
let repo = init_temp_repo("push_sets_upstream_integration");
|
let repo = init_temp_repo("push_sets_upstream_integration");
|
||||||
let remote = init_bare_temp_repo("push_sets_upstream_integration_remote");
|
let remote = init_bare_temp_repo("push_sets_upstream_integration_remote");
|
||||||
commit_initial_file(&repo.path);
|
commit_initial_file(&repo.path);
|
||||||
@@ -4932,7 +4989,9 @@ mod tests {
|
|||||||
["remote", "add", "origin", remote.path.to_str().unwrap()],
|
["remote", "add", "origin", remote.path.to_str().unwrap()],
|
||||||
);
|
);
|
||||||
|
|
||||||
let status = push(repo.path.to_string_lossy().to_string(), None, None).unwrap();
|
let status = push(repo.path.to_string_lossy().to_string(), None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
status.upstream.as_deref(),
|
status.upstream.as_deref(),
|
||||||
@@ -5000,8 +5059,8 @@ mod tests {
|
|||||||
assert_eq!(contents.replace("\r\n", "\n"), "resolved\n");
|
assert_eq!(contents.replace("\r\n", "\n"), "resolved\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn commit_rejects_unresolved_merge_conflicts() {
|
async fn commit_rejects_unresolved_merge_conflicts() {
|
||||||
let repo = init_temp_repo("commit_rejects_conflicts");
|
let repo = init_temp_repo("commit_rejects_conflicts");
|
||||||
fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written");
|
fs::write(repo.path.join("file.txt"), "base\n").expect("base file should be written");
|
||||||
run_git_test(&repo.path, ["add", "file.txt"]);
|
run_git_test(&repo.path, ["add", "file.txt"]);
|
||||||
@@ -5027,6 +5086,7 @@ mod tests {
|
|||||||
repo.path.to_string_lossy().to_string(),
|
repo.path.to_string_lossy().to_string(),
|
||||||
"should not commit".to_string(),
|
"should not commit".to_string(),
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(err.contains("Merge conflicts"));
|
assert!(err.contains("Merge conflicts"));
|
||||||
@@ -5544,7 +5604,8 @@ mod tests {
|
|||||||
fn get_file_blame_attributes_lines_to_the_commits_that_introduced_them() {
|
fn get_file_blame_attributes_lines_to_the_commits_that_introduced_them() {
|
||||||
let repo = init_temp_repo("file_blame");
|
let repo = init_temp_repo("file_blame");
|
||||||
commit_initial_file(&repo.path);
|
commit_initial_file(&repo.path);
|
||||||
fs::write(repo.path.join("old.txt"), "original\ntwo\n").expect("tracked file should change");
|
fs::write(repo.path.join("old.txt"), "original\ntwo\n")
|
||||||
|
.expect("tracked file should change");
|
||||||
run_git_test(&repo.path, ["add", "old.txt"]);
|
run_git_test(&repo.path, ["add", "old.txt"]);
|
||||||
run_git_test(&repo.path, ["commit", "-q", "-m", "add second line"]);
|
run_git_test(&repo.path, ["commit", "-q", "-m", "add second line"]);
|
||||||
|
|
||||||
|
|||||||
+15
-7
@@ -19,7 +19,7 @@ use git::{
|
|||||||
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop,
|
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop,
|
||||||
stash_push, undo_last_commit, unstage_files,
|
stash_push, undo_last_commit, unstage_files,
|
||||||
};
|
};
|
||||||
use tauri::Manager;
|
use tauri::{Manager, AppHandle};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
@@ -43,13 +43,21 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
||||||
|
#[cfg(desktop)]
|
||||||
|
let _ = app.get_webview_window("main")
|
||||||
|
.expect("no main window")
|
||||||
|
.set_focus();
|
||||||
|
}))
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.plugin(tauri_plugin_aptabase::Builder::new("A-SH-1344793789").with_options(
|
.plugin(
|
||||||
tauri_plugin_aptabase::InitOptions {
|
tauri_plugin_aptabase::Builder::new("A-SH-1344793789")
|
||||||
host: Some("https://aptabase.cbsk-tech.de".to_string()),
|
.with_options(tauri_plugin_aptabase::InitOptions {
|
||||||
flush_interval:None
|
host: Some("https://aptabase.cbsk-tech.de".to_string()),
|
||||||
}
|
flush_interval: None,
|
||||||
).build())
|
})
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
.manage(SearchCancellationState::default())
|
.manage(SearchCancellationState::default())
|
||||||
.manage(commit_ai::CommitAiEngine::new())
|
.manage(commit_ai::CommitAiEngine::new())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Gitty",
|
"productName": "Gitty",
|
||||||
"version": "0.200.2",
|
"version": "2026.7.17",
|
||||||
"identifier": "com.gitty",
|
"identifier": "com.gitty",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+614
-68
@@ -98,6 +98,7 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
AiSettings,
|
AiSettings,
|
||||||
|
AppTheme,
|
||||||
AnalyticsSettings,
|
AnalyticsSettings,
|
||||||
CommitAiPhase,
|
CommitAiPhase,
|
||||||
ConflictFile,
|
ConflictFile,
|
||||||
@@ -133,7 +134,8 @@
|
|||||||
type AppView = "management" | "repository";
|
type AppView = "management" | "repository";
|
||||||
type CredentialAction = "push" | "pull" | "fetch" | "clone";
|
type CredentialAction = "push" | "pull" | "fetch" | "clone";
|
||||||
type PendingDiscard =
|
type PendingDiscard =
|
||||||
| { kind: "file"; file: GitFileStatus; staged: boolean }
|
| { kind: "file"; files: GitFileStatus[]; staged: boolean }
|
||||||
|
| { kind: "all-changes"; files: GitFileStatus[] }
|
||||||
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
|
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
|
||||||
|
|
||||||
interface RepoTab {
|
interface RepoTab {
|
||||||
@@ -164,14 +166,35 @@
|
|||||||
const REPO_STATUS_CACHE_KEY = "gitlite.repoStatusCache.v1";
|
const REPO_STATUS_CACHE_KEY = "gitlite.repoStatusCache.v1";
|
||||||
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
|
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
|
||||||
const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
|
const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
|
||||||
|
const APP_THEME_KEY = "gitlite.theme.v1";
|
||||||
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
|
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
|
||||||
|
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
|
||||||
|
const LEFT_BRANCH_PANEL_HEIGHT_KEY = "gitlite.leftBranchPanelHeight.v1";
|
||||||
|
const LEFT_STASH_PANEL_HEIGHT_KEY = "gitlite.leftStashPanelHeight.v1";
|
||||||
|
const BRANCH_PANEL_COLLAPSED_KEY = "gitlite.branchPanelCollapsed.v1";
|
||||||
|
const STASH_PANEL_COLLAPSED_KEY = "gitlite.stashPanelCollapsed.v2";
|
||||||
|
const EXPLORER_PANEL_COLLAPSED_KEY = "gitlite.explorerPanelCollapsed.v1";
|
||||||
const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1";
|
const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1";
|
||||||
|
const FILE_HISTORY_WIDTH_KEY = "gitlite.fileHistoryWidth.v1";
|
||||||
const COMMIT_PANEL_DEFAULT_HEIGHT = 220;
|
const COMMIT_PANEL_DEFAULT_HEIGHT = 220;
|
||||||
const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT;
|
const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT;
|
||||||
const COMMIT_PANEL_MAX_HEIGHT = 640;
|
const COMMIT_PANEL_MAX_HEIGHT = 640;
|
||||||
|
const LEFT_SIDEBAR_DEFAULT_WIDTH = 280;
|
||||||
|
const LEFT_SIDEBAR_MIN_WIDTH = 220;
|
||||||
|
const LEFT_SIDEBAR_MAX_WIDTH = 420;
|
||||||
|
const LEFT_BRANCH_PANEL_DEFAULT_HEIGHT = 260;
|
||||||
|
const LEFT_BRANCH_PANEL_MIN_HEIGHT = 180;
|
||||||
|
const LEFT_BRANCH_PANEL_MAX_HEIGHT = 560;
|
||||||
|
const LEFT_STASH_PANEL_DEFAULT_HEIGHT = 190;
|
||||||
|
const LEFT_STASH_PANEL_MIN_HEIGHT = 150;
|
||||||
|
const LEFT_STASH_PANEL_MAX_HEIGHT = 420;
|
||||||
|
const LEFT_EXPLORER_PANEL_MIN_HEIGHT = 220;
|
||||||
const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
|
const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
|
||||||
const HISTORY_ASIDE_MIN_WIDTH = 560;
|
const HISTORY_ASIDE_MIN_WIDTH = 560;
|
||||||
const HISTORY_ASIDE_MAX_WIDTH = 920;
|
const HISTORY_ASIDE_MAX_WIDTH = 920;
|
||||||
|
const FILE_HISTORY_DEFAULT_WIDTH = 300;
|
||||||
|
const FILE_HISTORY_MIN_WIDTH = 240;
|
||||||
|
const FILE_HISTORY_MAX_WIDTH = 520;
|
||||||
const ERROR_AUTO_HIDE_MS = 6000;
|
const ERROR_AUTO_HIDE_MS = 6000;
|
||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
@@ -218,6 +241,7 @@
|
|||||||
let appSettingsOpen = false;
|
let appSettingsOpen = false;
|
||||||
let analyticsNoticeOpen = false;
|
let analyticsNoticeOpen = false;
|
||||||
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
|
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
|
||||||
|
let appTheme: AppTheme = loadThemePreference();
|
||||||
let localModelOptions: LocalModelOption[] = [];
|
let localModelOptions: LocalModelOption[] = [];
|
||||||
let errorMessage = "";
|
let errorMessage = "";
|
||||||
let operation = "";
|
let operation = "";
|
||||||
@@ -292,10 +316,31 @@
|
|||||||
let resizingCommitPanel = false;
|
let resizingCommitPanel = false;
|
||||||
let resizeStartY = 0;
|
let resizeStartY = 0;
|
||||||
let resizeStartHeight = 0;
|
let resizeStartHeight = 0;
|
||||||
|
let leftSidebarWidth = loadLeftSidebarWidth();
|
||||||
|
let resizingLeftSidebar = false;
|
||||||
|
let leftSidebarResizeStartX = 0;
|
||||||
|
let leftSidebarResizeStartWidth = 0;
|
||||||
|
let leftBranchPanelHeight = loadLeftBranchPanelHeight();
|
||||||
|
let resizingLeftBranchPanel = false;
|
||||||
|
let leftBranchResizeStartY = 0;
|
||||||
|
let leftBranchResizeStartHeight = 0;
|
||||||
|
let leftStashPanelHeight = loadLeftStashPanelHeight();
|
||||||
|
let resizingLeftStashPanel = false;
|
||||||
|
let leftStashResizeStartY = 0;
|
||||||
|
let leftStashResizeStartHeight = 0;
|
||||||
|
let branchPanelCollapsed = loadStoredBoolean(BRANCH_PANEL_COLLAPSED_KEY, false);
|
||||||
|
let stashPanelCollapsed = loadStoredBoolean(STASH_PANEL_COLLAPSED_KEY, false);
|
||||||
|
let explorerPanelCollapsed = loadStoredBoolean(EXPLORER_PANEL_COLLAPSED_KEY, false);
|
||||||
let historyAsideWidth = loadHistoryAsideWidth();
|
let historyAsideWidth = loadHistoryAsideWidth();
|
||||||
let resizingHistoryAside = false;
|
let resizingHistoryAside = false;
|
||||||
let historyResizeStartX = 0;
|
let historyResizeStartX = 0;
|
||||||
let historyResizeStartWidth = 0;
|
let historyResizeStartWidth = 0;
|
||||||
|
let fileHistoryWidth = loadFileHistoryWidth();
|
||||||
|
let resizingFileHistory = false;
|
||||||
|
let fileHistoryResizeStartX = 0;
|
||||||
|
let fileHistoryResizeStartWidth = 0;
|
||||||
|
let fileHistoryCollapsed = true;
|
||||||
|
let themeMediaQuery: MediaQueryList | undefined;
|
||||||
|
|
||||||
// ── Derived ────────────────────────────────────────────────────────────────
|
// ── Derived ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -337,22 +382,29 @@
|
|||||||
$: localBranchNames = localBranches.map((b) => b.name);
|
$: localBranchNames = localBranches.map((b) => b.name);
|
||||||
$: remoteBranches = branches.filter((b) => b.remote);
|
$: remoteBranches = branches.filter((b) => b.remote);
|
||||||
$: repoSearchTerm = repoSearch.trim().toLowerCase();
|
$: repoSearchTerm = repoSearch.trim().toLowerCase();
|
||||||
$: openRepoRows = repoTabs.filter(repoMatchesSearch);
|
$: openRepoRows = repoTabs.filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
|
||||||
$: recentRepoRows = recentRepoPaths
|
$: recentRepoRows = recentRepoPaths
|
||||||
.filter((path) => !repoTabs.some((tab) => sameRepoPath(tab.path, path)))
|
.filter((path) => !repoTabs.some((tab) => sameRepoPath(tab.path, path)))
|
||||||
.map(repoRowFromPath)
|
.map((path) => repoRowFromPath(path, repoTabs, repoStatusCache))
|
||||||
.filter(repoMatchesSearch);
|
.filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
|
||||||
$: favoriteRepoRows = favoriteRepoPaths
|
$: favoriteRepoRows = favoriteRepoPaths
|
||||||
.map(repoRowFromPath)
|
.map((path) => repoRowFromPath(path, repoTabs, repoStatusCache))
|
||||||
.filter(repoMatchesSearch);
|
.filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
|
||||||
|
$: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed);
|
||||||
|
$: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
|
||||||
|
|
||||||
|
$: applyThemePreference(appTheme);
|
||||||
|
|
||||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
|
||||||
|
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
|
||||||
void runStartupSequence();
|
void runStartupSequence();
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
themeMediaQuery?.removeEventListener("change", handleSystemThemeChange);
|
||||||
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
||||||
if (backgroundRepoStatusTimer) clearInterval(backgroundRepoStatusTimer);
|
if (backgroundRepoStatusTimer) clearInterval(backgroundRepoStatusTimer);
|
||||||
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
|
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
|
||||||
@@ -497,6 +549,7 @@
|
|||||||
try {
|
try {
|
||||||
await fetchRemote(activeRepoPath);
|
await fetchRemote(activeRepoPath);
|
||||||
applyStatus(await getStatus(activeRepoPath));
|
applyStatus(await getStatus(activeRepoPath));
|
||||||
|
await refreshRefsAndCommitGraph(activeRepoPath);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — see comment above
|
// ignore — see comment above
|
||||||
} finally {
|
} finally {
|
||||||
@@ -514,8 +567,10 @@
|
|||||||
try {
|
try {
|
||||||
await fetchRemote(path);
|
await fetchRemote(path);
|
||||||
const nextStatus = await getStatus(path);
|
const nextStatus = await getStatus(path);
|
||||||
if (sameRepoPath(path, activeRepoPath)) applyStatus(nextStatus);
|
if (sameRepoPath(path, activeRepoPath)) {
|
||||||
else updateRepoManagementStatus(path, nextStatus);
|
applyStatus(nextStatus);
|
||||||
|
await refreshRefsAndCommitGraph(path);
|
||||||
|
} else updateRepoManagementStatus(path, nextStatus);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore; manual Fetch/Pull surfaces auth or network problems
|
// ignore; manual Fetch/Pull surfaces auth or network problems
|
||||||
} finally {
|
} finally {
|
||||||
@@ -586,26 +641,32 @@
|
|||||||
|
|
||||||
async function autoRefreshTick() {
|
async function autoRefreshTick() {
|
||||||
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
|
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
|
||||||
|
const path = activeRepoPath;
|
||||||
autoRefreshInFlight = true;
|
autoRefreshInFlight = true;
|
||||||
try {
|
try {
|
||||||
// Cheap fast path: only fetch status; skip the heavy reload if nothing changed.
|
// Cheap fast path: only fetch status; skip the heavy reload if nothing changed.
|
||||||
const nextStatus = await getStatus(activeRepoPath);
|
const nextStatus = await getStatus(path);
|
||||||
|
// The user may have switched repos (or closed this one) while the status
|
||||||
|
// call was in flight — applying a stale result would flash/overwrite the
|
||||||
|
// now-active repo's name and data with this one's.
|
||||||
|
if (!sameRepoPath(path, activeRepoPath)) return;
|
||||||
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
|
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
|
||||||
applyStatus(nextStatus);
|
applyStatus(nextStatus);
|
||||||
// Something changed — reload branches, commits and files in one bundled call.
|
// Something changed — reload branches, commits and files in one bundled call.
|
||||||
const bundle = await openRepositoryBundle(activeRepoPath, 100);
|
const bundle = await openRepositoryBundle(path, 100);
|
||||||
|
if (!sameRepoPath(path, activeRepoPath)) return;
|
||||||
const previousHeadHash = lastFileHistoryHeadHash;
|
const previousHeadHash = lastFileHistoryHeadHash;
|
||||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
await refreshBranchList(path, bundle.branches);
|
||||||
await refreshTags(activeRepoPath, bundle.tags);
|
await refreshTags(path, bundle.tags);
|
||||||
await refreshStashes(activeRepoPath, bundle.stashes);
|
await refreshStashes(path, bundle.stashes);
|
||||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
await refreshCommitHistory(path, bundle.commits);
|
||||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
await refreshExplorerFiles(path, bundle.files);
|
||||||
// File history reflects `git log`, which only changes when HEAD actually moves
|
// File history reflects `git log`, which only changes when HEAD actually moves
|
||||||
// (new commit, checkout, merge, ...) — skip the reload otherwise so a plain
|
// (new commit, checkout, merge, ...) — skip the reload otherwise so a plain
|
||||||
// working-tree/status change (staging, edits) doesn't keep re-fetching and
|
// working-tree/status change (staging, edits) doesn't keep re-fetching and
|
||||||
// flickering the currently viewed file's history.
|
// flickering the currently viewed file's history.
|
||||||
if (lastFileHistoryHeadHash !== previousHeadHash) {
|
if (lastFileHistoryHeadHash !== previousHeadHash && sameRepoPath(path, activeRepoPath)) {
|
||||||
await refreshFileHistory(activeRepoPath);
|
await refreshFileHistory(path);
|
||||||
}
|
}
|
||||||
} catch { /* ignore transient errors */ } finally {
|
} catch { /* ignore transient errors */ } finally {
|
||||||
autoRefreshInFlight = false;
|
autoRefreshInFlight = false;
|
||||||
@@ -702,11 +763,46 @@
|
|||||||
trackEvent("app_started", { first_run: 1 });
|
trackEvent("app_started", { first_run: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveAppSettings(next: AnalyticsSettings) {
|
function loadThemePreference(): AppTheme {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(APP_THEME_KEY);
|
||||||
|
if (stored === "system" || stored === "light" || stored === "dark") return stored;
|
||||||
|
} catch {
|
||||||
|
// Local storage is optional; system theme is a sane default.
|
||||||
|
}
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistThemePreference(next: AppTheme) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(APP_THEME_KEY, next);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage quota/private-mode errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThemePreference(next: AppTheme) {
|
||||||
|
const prefersLight = themeMediaQuery?.matches ?? window.matchMedia("(prefers-color-scheme: light)").matches;
|
||||||
|
const resolved = next === "system"
|
||||||
|
? prefersLight
|
||||||
|
? "light"
|
||||||
|
: "dark"
|
||||||
|
: next;
|
||||||
|
document.documentElement.dataset.themePreference = next;
|
||||||
|
document.documentElement.dataset.theme = resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSystemThemeChange() {
|
||||||
|
if (appTheme === "system") applyThemePreference(appTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme) {
|
||||||
analyticsSettings = next;
|
analyticsSettings = next;
|
||||||
|
appTheme = nextTheme;
|
||||||
persistAnalyticsSettings(next);
|
persistAnalyticsSettings(next);
|
||||||
|
persistThemePreference(nextTheme);
|
||||||
appSettingsOpen = false;
|
appSettingsOpen = false;
|
||||||
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1 });
|
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1, theme: nextTheme });
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCommitMessage(message: string) {
|
function updateCommitMessage(message: string) {
|
||||||
@@ -893,6 +989,10 @@
|
|||||||
return repoKey(left) === repoKey(right);
|
return repoKey(left) === repoKey(right);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function baseName(path: string): string {
|
||||||
|
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
|
||||||
|
}
|
||||||
|
|
||||||
function uniqueRepoPaths(paths: string[]): string[] {
|
function uniqueRepoPaths(paths: string[]): string[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
@@ -907,11 +1007,11 @@
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function repoRowFromPath(path: string): RepoTab {
|
function repoRowFromPath(path: string, tabs = repoTabs, statusCache = repoStatusCache): RepoTab {
|
||||||
const openTab = repoTabs.find((tab) => sameRepoPath(tab.path, path));
|
const openTab = tabs.find((tab) => sameRepoPath(tab.path, path));
|
||||||
if (openTab) return openTab;
|
if (openTab) return openTab;
|
||||||
|
|
||||||
const cached = repoStatusCache[repoKey(path)];
|
const cached = statusCache[repoKey(path)];
|
||||||
if (cached) return { ...cached, path, name: repoNameFromPath(path) };
|
if (cached) return { ...cached, path, name: repoNameFromPath(path) };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -925,11 +1025,11 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function repoMatchesSearch(repo: RepoTab): boolean {
|
function repoMatchesSearch(repo: RepoTab, searchTerm = repoSearchTerm): boolean {
|
||||||
if (!repoSearchTerm) return true;
|
if (!searchTerm) return true;
|
||||||
return repo.name.toLowerCase().includes(repoSearchTerm)
|
return repo.name.toLowerCase().includes(searchTerm)
|
||||||
|| repo.path.toLowerCase().includes(repoSearchTerm)
|
|| repo.path.toLowerCase().includes(searchTerm)
|
||||||
|| (repo.branch ?? "").toLowerCase().includes(repoSearchTerm);
|
|| (repo.branch ?? "").toLowerCase().includes(searchTerm);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadRepoLists() {
|
function loadRepoLists() {
|
||||||
@@ -1024,6 +1124,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadStoredBoolean(key: string, fallback: boolean): boolean {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(key);
|
||||||
|
if (stored === "true") return true;
|
||||||
|
if (stored === "false") return false;
|
||||||
|
} catch {
|
||||||
|
// Local storage is best-effort only; panel defaults are enough.
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistStoredBoolean(key: string, value: boolean) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, String(value));
|
||||||
|
} catch {
|
||||||
|
// Local storage is best-effort only; toggles must keep working without it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clampCommitPanelHeight(value: number): number {
|
function clampCommitPanelHeight(value: number): number {
|
||||||
return Math.min(COMMIT_PANEL_MAX_HEIGHT, Math.max(COMMIT_PANEL_MIN_HEIGHT, Math.round(value)));
|
return Math.min(COMMIT_PANEL_MAX_HEIGHT, Math.max(COMMIT_PANEL_MIN_HEIGHT, Math.round(value)));
|
||||||
}
|
}
|
||||||
@@ -1046,6 +1165,72 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampLeftSidebarWidth(value: number): number {
|
||||||
|
return Math.min(LEFT_SIDEBAR_MAX_WIDTH, Math.max(LEFT_SIDEBAR_MIN_WIDTH, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLeftSidebarWidth(): number {
|
||||||
|
try {
|
||||||
|
const stored = Number(localStorage.getItem(LEFT_SIDEBAR_WIDTH_KEY));
|
||||||
|
if (Number.isFinite(stored) && stored > 0) return clampLeftSidebarWidth(stored);
|
||||||
|
} catch {
|
||||||
|
// Fall through to the default below.
|
||||||
|
}
|
||||||
|
return LEFT_SIDEBAR_DEFAULT_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistLeftSidebarWidth(value: number) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LEFT_SIDEBAR_WIDTH_KEY, String(value));
|
||||||
|
} catch {
|
||||||
|
// Local storage is best-effort only; resizing must keep working without it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampLeftBranchPanelHeight(value: number): number {
|
||||||
|
return Math.min(LEFT_BRANCH_PANEL_MAX_HEIGHT, Math.max(LEFT_BRANCH_PANEL_MIN_HEIGHT, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLeftBranchPanelHeight(): number {
|
||||||
|
try {
|
||||||
|
const stored = Number(localStorage.getItem(LEFT_BRANCH_PANEL_HEIGHT_KEY));
|
||||||
|
if (Number.isFinite(stored) && stored > 0) return clampLeftBranchPanelHeight(stored);
|
||||||
|
} catch {
|
||||||
|
// Fall through to the default below.
|
||||||
|
}
|
||||||
|
return LEFT_BRANCH_PANEL_DEFAULT_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistLeftBranchPanelHeight(value: number) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LEFT_BRANCH_PANEL_HEIGHT_KEY, String(value));
|
||||||
|
} catch {
|
||||||
|
// Local storage is best-effort only; resizing must keep working without it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampLeftStashPanelHeight(value: number): number {
|
||||||
|
return Math.min(LEFT_STASH_PANEL_MAX_HEIGHT, Math.max(LEFT_STASH_PANEL_MIN_HEIGHT, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLeftStashPanelHeight(): number {
|
||||||
|
try {
|
||||||
|
const stored = Number(localStorage.getItem(LEFT_STASH_PANEL_HEIGHT_KEY));
|
||||||
|
if (Number.isFinite(stored) && stored > 0) return clampLeftStashPanelHeight(stored);
|
||||||
|
} catch {
|
||||||
|
// Fall through to the default below.
|
||||||
|
}
|
||||||
|
return LEFT_STASH_PANEL_DEFAULT_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistLeftStashPanelHeight(value: number) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LEFT_STASH_PANEL_HEIGHT_KEY, String(value));
|
||||||
|
} catch {
|
||||||
|
// Local storage is best-effort only; resizing must keep working without it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clampHistoryAsideWidth(value: number): number {
|
function clampHistoryAsideWidth(value: number): number {
|
||||||
return Math.min(HISTORY_ASIDE_MAX_WIDTH, Math.max(HISTORY_ASIDE_MIN_WIDTH, Math.round(value)));
|
return Math.min(HISTORY_ASIDE_MAX_WIDTH, Math.max(HISTORY_ASIDE_MIN_WIDTH, Math.round(value)));
|
||||||
}
|
}
|
||||||
@@ -1068,6 +1253,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampFileHistoryWidth(value: number): number {
|
||||||
|
return Math.min(FILE_HISTORY_MAX_WIDTH, Math.max(FILE_HISTORY_MIN_WIDTH, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFileHistoryWidth(): number {
|
||||||
|
try {
|
||||||
|
const stored = Number(localStorage.getItem(FILE_HISTORY_WIDTH_KEY));
|
||||||
|
if (Number.isFinite(stored) && stored > 0) return clampFileHistoryWidth(stored);
|
||||||
|
} catch {
|
||||||
|
// Fall through to the default below.
|
||||||
|
}
|
||||||
|
return FILE_HISTORY_DEFAULT_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistFileHistoryWidth(value: number) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(FILE_HISTORY_WIDTH_KEY, String(value));
|
||||||
|
} catch {
|
||||||
|
// Local storage is best-effort only; resizing must keep working without it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startCommitPanelResize(event: PointerEvent) {
|
function startCommitPanelResize(event: PointerEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
resizingCommitPanel = true;
|
resizingCommitPanel = true;
|
||||||
@@ -1096,6 +1303,131 @@
|
|||||||
persistCommitPanelHeight(commitPanelHeight);
|
persistCommitPanelHeight(commitPanelHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startLeftSidebarResize(event: PointerEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
resizingLeftSidebar = true;
|
||||||
|
leftSidebarResizeStartX = event.clientX;
|
||||||
|
leftSidebarResizeStartWidth = leftSidebarWidth;
|
||||||
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeftSidebarResizeMove(event: PointerEvent) {
|
||||||
|
if (!resizingLeftSidebar) return;
|
||||||
|
leftSidebarWidth = clampLeftSidebarWidth(leftSidebarResizeStartWidth + (event.clientX - leftSidebarResizeStartX));
|
||||||
|
}
|
||||||
|
|
||||||
|
function endLeftSidebarResize(event: PointerEvent) {
|
||||||
|
if (!resizingLeftSidebar) return;
|
||||||
|
resizingLeftSidebar = false;
|
||||||
|
persistLeftSidebarWidth(leftSidebarWidth);
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeftSidebarResizeKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||||
|
event.preventDefault();
|
||||||
|
leftSidebarWidth = clampLeftSidebarWidth(leftSidebarWidth + (event.key === "ArrowRight" ? 20 : -20));
|
||||||
|
persistLeftSidebarWidth(leftSidebarWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLeftSidebarRows(branchCollapsed: boolean, stashCollapsed: boolean, explorerCollapsed: boolean): string {
|
||||||
|
const branchRow = branchCollapsed
|
||||||
|
? "auto"
|
||||||
|
: `minmax(${LEFT_BRANCH_PANEL_MIN_HEIGHT}px, var(--branch-panel-height, ${LEFT_BRANCH_PANEL_DEFAULT_HEIGHT}px))`;
|
||||||
|
const branchHandleRow = branchCollapsed ? "0" : "14px";
|
||||||
|
const stashRow = stashCollapsed
|
||||||
|
? "auto"
|
||||||
|
: `minmax(${LEFT_STASH_PANEL_MIN_HEIGHT}px, var(--stash-panel-height, ${LEFT_STASH_PANEL_DEFAULT_HEIGHT}px))`;
|
||||||
|
const stashHandleRow = explorerCollapsed || (stashCollapsed && branchCollapsed) ? "0" : "14px";
|
||||||
|
const explorerRow = explorerCollapsed ? "auto" : `minmax(${LEFT_EXPLORER_PANEL_MIN_HEIGHT}px, 1fr)`;
|
||||||
|
|
||||||
|
return `${branchRow} ${branchHandleRow} ${stashRow} ${stashHandleRow} ${explorerRow}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLeftBranchPanelResize(event: PointerEvent) {
|
||||||
|
if (branchPanelCollapsed) return;
|
||||||
|
event.preventDefault();
|
||||||
|
resizingLeftBranchPanel = true;
|
||||||
|
leftBranchResizeStartY = event.clientY;
|
||||||
|
leftBranchResizeStartHeight = leftBranchPanelHeight;
|
||||||
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeftBranchPanelResizeMove(event: PointerEvent) {
|
||||||
|
if (!resizingLeftBranchPanel) return;
|
||||||
|
leftBranchPanelHeight = clampLeftBranchPanelHeight(leftBranchResizeStartHeight + (event.clientY - leftBranchResizeStartY));
|
||||||
|
}
|
||||||
|
|
||||||
|
function endLeftBranchPanelResize(event: PointerEvent) {
|
||||||
|
if (!resizingLeftBranchPanel) return;
|
||||||
|
resizingLeftBranchPanel = false;
|
||||||
|
persistLeftBranchPanelHeight(leftBranchPanelHeight);
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeftBranchPanelResizeKeydown(event: KeyboardEvent) {
|
||||||
|
if (branchPanelCollapsed || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
|
||||||
|
event.preventDefault();
|
||||||
|
leftBranchPanelHeight = clampLeftBranchPanelHeight(leftBranchPanelHeight + (event.key === "ArrowDown" ? 20 : -20));
|
||||||
|
persistLeftBranchPanelHeight(leftBranchPanelHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLeftStashPanelResize(event: PointerEvent) {
|
||||||
|
if (stashPanelCollapsed && branchPanelCollapsed) return;
|
||||||
|
event.preventDefault();
|
||||||
|
resizingLeftStashPanel = true;
|
||||||
|
leftStashResizeStartY = event.clientY;
|
||||||
|
leftStashResizeStartHeight = stashPanelCollapsed ? leftBranchPanelHeight : leftStashPanelHeight;
|
||||||
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeftStashPanelResizeMove(event: PointerEvent) {
|
||||||
|
if (!resizingLeftStashPanel) return;
|
||||||
|
if (stashPanelCollapsed) {
|
||||||
|
leftBranchPanelHeight = clampLeftBranchPanelHeight(leftStashResizeStartHeight + (event.clientY - leftStashResizeStartY));
|
||||||
|
} else {
|
||||||
|
leftStashPanelHeight = clampLeftStashPanelHeight(leftStashResizeStartHeight + (event.clientY - leftStashResizeStartY));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endLeftStashPanelResize(event: PointerEvent) {
|
||||||
|
if (!resizingLeftStashPanel) return;
|
||||||
|
resizingLeftStashPanel = false;
|
||||||
|
if (stashPanelCollapsed) persistLeftBranchPanelHeight(leftBranchPanelHeight);
|
||||||
|
else persistLeftStashPanelHeight(leftStashPanelHeight);
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeftStashPanelResizeKeydown(event: KeyboardEvent) {
|
||||||
|
if ((stashPanelCollapsed && branchPanelCollapsed) || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (stashPanelCollapsed) {
|
||||||
|
leftBranchPanelHeight = clampLeftBranchPanelHeight(leftBranchPanelHeight + (event.key === "ArrowDown" ? 20 : -20));
|
||||||
|
persistLeftBranchPanelHeight(leftBranchPanelHeight);
|
||||||
|
} else {
|
||||||
|
leftStashPanelHeight = clampLeftStashPanelHeight(leftStashPanelHeight + (event.key === "ArrowDown" ? 20 : -20));
|
||||||
|
persistLeftStashPanelHeight(leftStashPanelHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBranchPanelCollapsed() {
|
||||||
|
branchPanelCollapsed = !branchPanelCollapsed;
|
||||||
|
persistStoredBoolean(BRANCH_PANEL_COLLAPSED_KEY, branchPanelCollapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStashPanelCollapsed() {
|
||||||
|
stashPanelCollapsed = !stashPanelCollapsed;
|
||||||
|
persistStoredBoolean(STASH_PANEL_COLLAPSED_KEY, stashPanelCollapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExplorerPanelCollapsed() {
|
||||||
|
explorerPanelCollapsed = !explorerPanelCollapsed;
|
||||||
|
persistStoredBoolean(EXPLORER_PANEL_COLLAPSED_KEY, explorerPanelCollapsed);
|
||||||
|
}
|
||||||
|
|
||||||
function startHistoryAsideResize(event: PointerEvent) {
|
function startHistoryAsideResize(event: PointerEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
resizingHistoryAside = true;
|
resizingHistoryAside = true;
|
||||||
@@ -1124,6 +1456,43 @@
|
|||||||
persistHistoryAsideWidth(historyAsideWidth);
|
persistHistoryAsideWidth(historyAsideWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startFileHistoryResize(event: PointerEvent) {
|
||||||
|
if (fileHistoryCollapsed) return;
|
||||||
|
event.preventDefault();
|
||||||
|
resizingFileHistory = true;
|
||||||
|
fileHistoryResizeStartX = event.clientX;
|
||||||
|
fileHistoryResizeStartWidth = fileHistoryWidth;
|
||||||
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileHistoryResizeMove(event: PointerEvent) {
|
||||||
|
if (!resizingFileHistory) return;
|
||||||
|
fileHistoryWidth = clampFileHistoryWidth(fileHistoryResizeStartWidth + (fileHistoryResizeStartX - event.clientX));
|
||||||
|
}
|
||||||
|
|
||||||
|
function endFileHistoryResize(event: PointerEvent) {
|
||||||
|
if (!resizingFileHistory) return;
|
||||||
|
resizingFileHistory = false;
|
||||||
|
persistFileHistoryWidth(fileHistoryWidth);
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileHistoryResizeKeydown(event: KeyboardEvent) {
|
||||||
|
if (fileHistoryCollapsed || (event.key !== "ArrowLeft" && event.key !== "ArrowRight")) return;
|
||||||
|
event.preventDefault();
|
||||||
|
fileHistoryWidth = clampFileHistoryWidth(fileHistoryWidth + (event.key === "ArrowLeft" ? 20 : -20));
|
||||||
|
persistFileHistoryWidth(fileHistoryWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFileHistoryCollapsed() {
|
||||||
|
fileHistoryCollapsed = !fileHistoryCollapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealFileHistory() {
|
||||||
|
fileHistoryCollapsed = false;
|
||||||
|
}
|
||||||
|
|
||||||
function rememberRecentRepo(path: string) {
|
function rememberRecentRepo(path: string) {
|
||||||
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
|
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
|
||||||
persistRepoLists();
|
persistRepoLists();
|
||||||
@@ -1318,6 +1687,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshRefsAndCommitGraph(path = activeRepoPath) {
|
||||||
|
const previousHeadHash = lastFileHistoryHeadHash;
|
||||||
|
await refreshBranchList(path);
|
||||||
|
await refreshTags(path);
|
||||||
|
await refreshCommitHistory(path);
|
||||||
|
if (lastFileHistoryHeadHash !== previousHeadHash) {
|
||||||
|
await refreshFileHistory(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isCancellationMessage(message: string): boolean {
|
function isCancellationMessage(message: string): boolean {
|
||||||
return message.toLowerCase().includes("cancelled");
|
return message.toLowerCase().includes("cancelled");
|
||||||
}
|
}
|
||||||
@@ -1615,6 +1994,21 @@
|
|||||||
if (!activeStillOpen) await openRepo(path);
|
if (!activeStillOpen) await openRepo(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeRepoFromRecent(path: string, event?: MouseEvent) {
|
||||||
|
event?.stopPropagation();
|
||||||
|
if (isBusy) return;
|
||||||
|
recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path));
|
||||||
|
persistRepoLists();
|
||||||
|
trackEvent("repository_removed_from_recent", {
|
||||||
|
recent_repositories: recentRepoPaths.length,
|
||||||
|
});
|
||||||
|
if (!repoTabs.some((tab) => sameRepoPath(tab.path, path)) && !isFavoriteRepo(path) && repoStatusCache[repoKey(path)]) {
|
||||||
|
const { [repoKey(path)]: _removed, ...rest } = repoStatusCache;
|
||||||
|
repoStatusCache = rest;
|
||||||
|
persistRepoStatusCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeRepoFromManagement(path: string, event?: MouseEvent) {
|
async function removeRepoFromManagement(path: string, event?: MouseEvent) {
|
||||||
event?.stopPropagation();
|
event?.stopPropagation();
|
||||||
if (isBusy) return;
|
if (isBusy) return;
|
||||||
@@ -2018,6 +2412,7 @@
|
|||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
await runOperation("Fetching", async () => {
|
await runOperation("Fetching", async () => {
|
||||||
applyStatus(await fetchRemote(activeRepoPath, username, password));
|
applyStatus(await fetchRemote(activeRepoPath, username, password));
|
||||||
|
await refreshRefsAndCommitGraph(activeRepoPath);
|
||||||
trackEvent("repository_fetched", {
|
trackEvent("repository_fetched", {
|
||||||
from_stored_credential: fromStore ? 1 : 0,
|
from_stored_credential: fromStore ? 1 : 0,
|
||||||
ahead: status?.ahead ?? 0,
|
ahead: status?.ahead ?? 0,
|
||||||
@@ -2210,46 +2605,87 @@
|
|||||||
|
|
||||||
// ── File staging / restore ─────────────────────────────────────────────────
|
// ── File staging / restore ─────────────────────────────────────────────────
|
||||||
|
|
||||||
async function stageFile(file: GitFileStatus) {
|
async function stageFile(files: GitFileStatus[]) {
|
||||||
await runOperation(`Staging ${file.path}`, async () => {
|
const targets = files.filter((file) => file.unstaged !== null);
|
||||||
applyStatus(await stageFiles(activeRepoPath, [file.path]));
|
if (targets.length === 0) return;
|
||||||
|
const paths = targets.map((file) => file.path);
|
||||||
|
await runOperation(targets.length === 1 ? `Staging ${baseName(targets[0].path)}` : `Staging ${targets.length} files`, async () => {
|
||||||
|
applyStatus(await stageFiles(activeRepoPath, paths));
|
||||||
await refreshExplorerFiles(activeRepoPath);
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
trackEvent("file_staged", {
|
trackEvent("file_staged", {
|
||||||
status: file.unstaged ?? file.staged ?? "unknown",
|
files: targets.length,
|
||||||
|
status: targets.length === 1 ? (targets[0].unstaged ?? targets[0].staged ?? "unknown") : "multiple",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unstageFile(file: GitFileStatus) {
|
async function unstageFile(files: GitFileStatus[]) {
|
||||||
await runOperation(`Unstaging ${file.path}`, async () => {
|
const targets = files.filter((file) => file.staged !== null);
|
||||||
applyStatus(await unstageFiles(activeRepoPath, [file.path]));
|
if (targets.length === 0) return;
|
||||||
|
const paths = targets.map((file) => file.path);
|
||||||
|
await runOperation(targets.length === 1 ? `Unstaging ${baseName(targets[0].path)}` : `Unstaging ${targets.length} files`, async () => {
|
||||||
|
applyStatus(await unstageFiles(activeRepoPath, paths));
|
||||||
await refreshExplorerFiles(activeRepoPath);
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
trackEvent("file_unstaged", {
|
trackEvent("file_unstaged", {
|
||||||
status: file.staged ?? file.unstaged ?? "unknown",
|
files: targets.length,
|
||||||
|
status: targets.length === 1 ? (targets[0].staged ?? targets[0].unstaged ?? "unknown") : "multiple",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function discardFile(file: GitFileStatus, staged: boolean) {
|
function discardFiles(files: GitFileStatus[], staged: boolean) {
|
||||||
if (!activeRepoPath || isBusy) return;
|
if (!activeRepoPath || isBusy || files.length === 0) return;
|
||||||
pendingDiscard = { kind: "file", file, staged };
|
pendingDiscard = { kind: "file", files, staged };
|
||||||
trackEvent("discard_confirm_opened", {
|
trackEvent("discard_confirm_opened", {
|
||||||
kind: "file",
|
kind: "file",
|
||||||
staged: staged ? 1 : 0,
|
staged: staged ? 1 : 0,
|
||||||
|
files: files.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runDiscardFile(file: GitFileStatus, staged: boolean) {
|
function discardChanges(files: GitFileStatus[]) {
|
||||||
await runOperation(`Discarding ${file.path}`, async () => {
|
if (!activeRepoPath || isBusy || files.length === 0) return;
|
||||||
applyStatus(await restoreFiles(activeRepoPath, [file.path], staged));
|
pendingDiscard = { kind: "all-changes", files };
|
||||||
|
trackEvent("discard_confirm_opened", {
|
||||||
|
kind: "all",
|
||||||
|
files: files.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDiscardFiles(files: GitFileStatus[], staged: boolean) {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
const paths = files.map((file) => file.path);
|
||||||
|
await runOperation(files.length === 1 ? `Discarding ${baseName(files[0].path)}` : `Discarding ${files.length} files`, async () => {
|
||||||
|
applyStatus(await restoreFiles(activeRepoPath, paths, staged));
|
||||||
await refreshExplorerFiles(activeRepoPath);
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
await refreshFileHistory(activeRepoPath);
|
await refreshFileHistory(activeRepoPath);
|
||||||
trackEvent("file_discarded", {
|
trackEvent("file_discarded", {
|
||||||
|
files: files.length,
|
||||||
staged: staged ? 1 : 0,
|
staged: staged ? 1 : 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discards both the staged and unstaged changes for each given file (used
|
||||||
|
// by "Discard all" and "Discard selected", which don't distinguish lanes).
|
||||||
|
async function runDiscardAllChanges(files: GitFileStatus[]) {
|
||||||
|
const stagedPaths = files.filter((file) => file.staged !== null).map((file) => file.path);
|
||||||
|
const unstagedPaths = files.filter((file) => file.unstaged !== null).map((file) => file.path);
|
||||||
|
if (stagedPaths.length === 0 && unstagedPaths.length === 0) return;
|
||||||
|
await runOperation(files.length === 1 ? `Discarding ${baseName(files[0].path)}` : `Discarding ${files.length} files`, async () => {
|
||||||
|
let nextStatus: GitStatus | null = null;
|
||||||
|
if (stagedPaths.length > 0) nextStatus = await restoreFiles(activeRepoPath, stagedPaths, true);
|
||||||
|
if (unstagedPaths.length > 0) nextStatus = await restoreFiles(activeRepoPath, unstagedPaths, false);
|
||||||
|
if (nextStatus) applyStatus(nextStatus);
|
||||||
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
|
await refreshFileHistory(activeRepoPath);
|
||||||
|
trackEvent("file_discarded", {
|
||||||
|
files: files.length,
|
||||||
|
staged: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function openLinePatch(file: GitFileStatus, staged: boolean) {
|
async function openLinePatch(file: GitFileStatus, staged: boolean) {
|
||||||
if (!activeRepoPath) return;
|
if (!activeRepoPath) return;
|
||||||
linePatchOpen = true;
|
linePatchOpen = true;
|
||||||
@@ -2385,7 +2821,9 @@
|
|||||||
if (!discard || !activeRepoPath || isBusy) return;
|
if (!discard || !activeRepoPath || isBusy) return;
|
||||||
|
|
||||||
if (discard.kind === "file") {
|
if (discard.kind === "file") {
|
||||||
await runDiscardFile(discard.file, discard.staged);
|
await runDiscardFiles(discard.files, discard.staged);
|
||||||
|
} else if (discard.kind === "all-changes") {
|
||||||
|
await runDiscardAllChanges(discard.files);
|
||||||
} else {
|
} else {
|
||||||
await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged);
|
await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged);
|
||||||
}
|
}
|
||||||
@@ -2608,6 +3046,7 @@
|
|||||||
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
|
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
|
||||||
selectedExplorerPath = node.path;
|
selectedExplorerPath = node.path;
|
||||||
selectedExplorerKind = node.kind;
|
selectedExplorerKind = node.kind;
|
||||||
|
revealFileHistory();
|
||||||
void loadSelectedFileHistory(node.path);
|
void loadSelectedFileHistory(node.path);
|
||||||
trackEvent("explorer_node_selected", {
|
trackEvent("explorer_node_selected", {
|
||||||
kind: node.kind,
|
kind: node.kind,
|
||||||
@@ -2620,6 +3059,7 @@
|
|||||||
selectedExplorerPath = file.path;
|
selectedExplorerPath = file.path;
|
||||||
selectedExplorerKind = "file";
|
selectedExplorerKind = "file";
|
||||||
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
||||||
|
revealFileHistory();
|
||||||
|
|
||||||
void loadSelectedFileHistory(file.path);
|
void loadSelectedFileHistory(file.path);
|
||||||
trackEvent("explorer_file_selected", {
|
trackEvent("explorer_file_selected", {
|
||||||
@@ -2633,6 +3073,7 @@
|
|||||||
selectedExplorerPath = file.path;
|
selectedExplorerPath = file.path;
|
||||||
selectedExplorerKind = "file";
|
selectedExplorerKind = "file";
|
||||||
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
||||||
|
revealFileHistory();
|
||||||
|
|
||||||
void loadSelectedFileHistory(file.path);
|
void loadSelectedFileHistory(file.path);
|
||||||
trackEvent("explorer_file_selected", {
|
trackEvent("explorer_file_selected", {
|
||||||
@@ -2645,6 +3086,7 @@
|
|||||||
if (!activeRepoPath || node.kind !== "file") return;
|
if (!activeRepoPath || node.kind !== "file") return;
|
||||||
selectedExplorerPath = node.path;
|
selectedExplorerPath = node.path;
|
||||||
selectedExplorerKind = "file";
|
selectedExplorerKind = "file";
|
||||||
|
revealFileHistory();
|
||||||
void loadSelectedFileHistory(node.path);
|
void loadSelectedFileHistory(node.path);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3020,7 +3462,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if operation && operation !== "Opening repository"}
|
{#if operation && operation !== "Opening repository" && !hasRepository}
|
||||||
<section class="notice busy" aria-live="polite">
|
<section class="notice busy" aria-live="polite">
|
||||||
<LoaderCircle class="spin" size={17} aria-hidden="true" />
|
<LoaderCircle class="spin" size={17} aria-hidden="true" />
|
||||||
<span>{operation}</span>
|
<span>{operation}</span>
|
||||||
@@ -3179,7 +3621,7 @@
|
|||||||
>
|
>
|
||||||
<Star size={14} aria-hidden="true" />
|
<Star size={14} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}>
|
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromRecent(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}>
|
||||||
<X size={14} aria-hidden="true" />
|
<X size={14} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3231,10 +3673,22 @@
|
|||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Workspace -->
|
<!-- Workspace -->
|
||||||
<section class="workspace" aria-label="Git workspace" style="--history-aside-width: {historyAsideWidth}px;">
|
<section
|
||||||
|
class="workspace"
|
||||||
|
aria-label="Git workspace"
|
||||||
|
style="--left-sidebar-width: {leftSidebarWidth}px; --history-aside-width: {historyAsideWidth}px; --file-history-width: {fileHistoryWidth}px;"
|
||||||
|
>
|
||||||
|
|
||||||
<!-- Left sidebar: branches + explorer -->
|
<!-- Left sidebar: branches + explorer -->
|
||||||
<aside class="left-sidebar" aria-label="Repository navigation">
|
<aside
|
||||||
|
class="left-sidebar"
|
||||||
|
class:branch-collapsed={branchPanelCollapsed}
|
||||||
|
class:stash-collapsed={stashPanelCollapsed}
|
||||||
|
class:explorer-collapsed={explorerPanelCollapsed}
|
||||||
|
class:all-collapsed={allLeftPanelsCollapsed}
|
||||||
|
aria-label="Repository navigation"
|
||||||
|
style="--branch-panel-height: {leftBranchPanelHeight}px; --stash-panel-height: {leftStashPanelHeight}px; grid-template-rows: {leftSidebarRows};"
|
||||||
|
>
|
||||||
<BranchPanel
|
<BranchPanel
|
||||||
{branches}
|
{branches}
|
||||||
{localBranches}
|
{localBranches}
|
||||||
@@ -3251,7 +3705,31 @@
|
|||||||
onCreateTag={createNewTag}
|
onCreateTag={createNewTag}
|
||||||
onDeleteTag={deleteLocalTag}
|
onDeleteTag={deleteLocalTag}
|
||||||
onPushTag={pushLocalTag}
|
onPushTag={pushLocalTag}
|
||||||
|
collapsed={branchPanelCollapsed}
|
||||||
|
onToggleCollapsed={toggleBranchPanelCollapsed}
|
||||||
/>
|
/>
|
||||||
|
{#if !branchPanelCollapsed}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="panel-resize-handle left-panel-resize-handle branch-panel-resize-handle"
|
||||||
|
class:resizing={resizingLeftBranchPanel}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-label="Resize branches panel height"
|
||||||
|
aria-valuenow={leftBranchPanelHeight}
|
||||||
|
aria-valuemin={LEFT_BRANCH_PANEL_MIN_HEIGHT}
|
||||||
|
aria-valuemax={LEFT_BRANCH_PANEL_MAX_HEIGHT}
|
||||||
|
tabindex="0"
|
||||||
|
onpointerdown={startLeftBranchPanelResize}
|
||||||
|
onpointermove={onLeftBranchPanelResizeMove}
|
||||||
|
onpointerup={endLeftBranchPanelResize}
|
||||||
|
onpointercancel={endLeftBranchPanelResize}
|
||||||
|
onkeydown={onLeftBranchPanelResizeKeydown}
|
||||||
|
></div>
|
||||||
|
{:else}
|
||||||
|
<span class="left-panel-resize-placeholder" aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
<StashPanel
|
<StashPanel
|
||||||
{stashes}
|
{stashes}
|
||||||
changedCount={changedFiles.length}
|
changedCount={changedFiles.length}
|
||||||
@@ -3261,7 +3739,31 @@
|
|||||||
onApply={applyStashEntry}
|
onApply={applyStashEntry}
|
||||||
onPop={popStashEntry}
|
onPop={popStashEntry}
|
||||||
onDrop={dropStashEntry}
|
onDrop={dropStashEntry}
|
||||||
|
collapsed={stashPanelCollapsed}
|
||||||
|
onToggleCollapsed={toggleStashPanelCollapsed}
|
||||||
/>
|
/>
|
||||||
|
{#if !explorerPanelCollapsed && (!stashPanelCollapsed || !branchPanelCollapsed)}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="panel-resize-handle left-panel-resize-handle stash-panel-resize-handle"
|
||||||
|
class:resizing={resizingLeftStashPanel}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-label={stashPanelCollapsed ? "Resize files panel space" : "Resize stash and explorer panels"}
|
||||||
|
aria-valuenow={stashPanelCollapsed ? leftBranchPanelHeight : leftStashPanelHeight}
|
||||||
|
aria-valuemin={stashPanelCollapsed ? LEFT_BRANCH_PANEL_MIN_HEIGHT : LEFT_STASH_PANEL_MIN_HEIGHT}
|
||||||
|
aria-valuemax={stashPanelCollapsed ? LEFT_BRANCH_PANEL_MAX_HEIGHT : LEFT_STASH_PANEL_MAX_HEIGHT}
|
||||||
|
tabindex="0"
|
||||||
|
onpointerdown={startLeftStashPanelResize}
|
||||||
|
onpointermove={onLeftStashPanelResizeMove}
|
||||||
|
onpointerup={endLeftStashPanelResize}
|
||||||
|
onpointercancel={endLeftStashPanelResize}
|
||||||
|
onkeydown={onLeftStashPanelResizeKeydown}
|
||||||
|
></div>
|
||||||
|
{:else}
|
||||||
|
<span class="left-panel-resize-placeholder" aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
<ExplorerPanel
|
<ExplorerPanel
|
||||||
{repoFiles}
|
{repoFiles}
|
||||||
{expandedExplorerPaths}
|
{expandedExplorerPaths}
|
||||||
@@ -3275,8 +3777,28 @@
|
|||||||
onSelectNode={selectExplorerNode}
|
onSelectNode={selectExplorerNode}
|
||||||
onOpenFile={openFileFromExplorer}
|
onOpenFile={openFileFromExplorer}
|
||||||
onBlame={openBlame}
|
onBlame={openBlame}
|
||||||
|
collapsed={explorerPanelCollapsed}
|
||||||
|
onToggleCollapsed={toggleExplorerPanelCollapsed}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="left-sidebar-resize-handle"
|
||||||
|
class:resizing={resizingLeftSidebar}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize navigation sidebar width"
|
||||||
|
aria-valuenow={leftSidebarWidth}
|
||||||
|
aria-valuemin={LEFT_SIDEBAR_MIN_WIDTH}
|
||||||
|
aria-valuemax={LEFT_SIDEBAR_MAX_WIDTH}
|
||||||
|
tabindex="0"
|
||||||
|
onpointerdown={startLeftSidebarResize}
|
||||||
|
onpointermove={onLeftSidebarResizeMove}
|
||||||
|
onpointerup={endLeftSidebarResize}
|
||||||
|
onpointercancel={endLeftSidebarResize}
|
||||||
|
onkeydown={onLeftSidebarResizeKeydown}
|
||||||
|
></div>
|
||||||
|
|
||||||
<!-- Center: summary + status + commit -->
|
<!-- Center: summary + status + commit -->
|
||||||
<section class="main-panel" aria-label="Repository status">
|
<section class="main-panel" aria-label="Repository status">
|
||||||
@@ -3302,12 +3824,14 @@
|
|||||||
{unstagedCount}
|
{unstagedCount}
|
||||||
{hasRepository}
|
{hasRepository}
|
||||||
{isBusy}
|
{isBusy}
|
||||||
|
{operation}
|
||||||
{status}
|
{status}
|
||||||
selectedFilePath={selectedExplorerPath}
|
selectedFilePath={selectedExplorerPath}
|
||||||
onSelectFile={selectFileFromStatus}
|
onSelectFile={selectFileFromStatus}
|
||||||
onStage={stageFile}
|
onStage={stageFile}
|
||||||
onUnstage={unstageFile}
|
onUnstage={unstageFile}
|
||||||
onDiscard={discardFile}
|
onDiscard={discardFiles}
|
||||||
|
onDiscardMany={discardChanges}
|
||||||
onPatch={openLinePatch}
|
onPatch={openLinePatch}
|
||||||
onStageAll={stageAllFiles}
|
onStageAll={stageAllFiles}
|
||||||
onUnstageAll={unstageAllFiles}
|
onUnstageAll={unstageAllFiles}
|
||||||
@@ -3353,26 +3877,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="history-resize-handle"
|
||||||
|
class:resizing={resizingHistoryAside}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize commit history width"
|
||||||
|
aria-valuenow={historyAsideWidth}
|
||||||
|
aria-valuemin={HISTORY_ASIDE_MIN_WIDTH}
|
||||||
|
aria-valuemax={HISTORY_ASIDE_MAX_WIDTH}
|
||||||
|
tabindex="0"
|
||||||
|
onpointerdown={startHistoryAsideResize}
|
||||||
|
onpointermove={onHistoryAsideResizeMove}
|
||||||
|
onpointerup={endHistoryAsideResize}
|
||||||
|
onpointercancel={endHistoryAsideResize}
|
||||||
|
onkeydown={onHistoryAsideResizeKeydown}
|
||||||
|
></div>
|
||||||
|
|
||||||
<!-- Right sidebar: commit graph + file history -->
|
<!-- Right sidebar: commit graph + file history -->
|
||||||
<aside class="history-aside" aria-label="Commit history">
|
<aside class="history-aside" class:file-history-collapsed={fileHistoryCollapsed} aria-label="Commit history">
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
||||||
<div
|
|
||||||
class="history-resize-handle"
|
|
||||||
class:resizing={resizingHistoryAside}
|
|
||||||
role="separator"
|
|
||||||
aria-orientation="vertical"
|
|
||||||
aria-label="Resize history panel width"
|
|
||||||
aria-valuenow={historyAsideWidth}
|
|
||||||
aria-valuemin={HISTORY_ASIDE_MIN_WIDTH}
|
|
||||||
aria-valuemax={HISTORY_ASIDE_MAX_WIDTH}
|
|
||||||
tabindex="0"
|
|
||||||
onpointerdown={startHistoryAsideResize}
|
|
||||||
onpointermove={onHistoryAsideResizeMove}
|
|
||||||
onpointerup={endHistoryAsideResize}
|
|
||||||
onpointercancel={endHistoryAsideResize}
|
|
||||||
onkeydown={onHistoryAsideResizeKeydown}
|
|
||||||
></div>
|
|
||||||
<HistoryPanel
|
<HistoryPanel
|
||||||
{commits}
|
{commits}
|
||||||
{localBranchNames}
|
{localBranchNames}
|
||||||
@@ -3392,6 +3917,24 @@
|
|||||||
expandedCommitHashes = next;
|
expandedCommitHashes = next;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="file-history-resize-handle"
|
||||||
|
class:resizing={resizingFileHistory}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize file history width"
|
||||||
|
aria-valuenow={fileHistoryWidth}
|
||||||
|
aria-valuemin={FILE_HISTORY_MIN_WIDTH}
|
||||||
|
aria-valuemax={FILE_HISTORY_MAX_WIDTH}
|
||||||
|
tabindex={fileHistoryCollapsed ? -1 : 0}
|
||||||
|
onpointerdown={startFileHistoryResize}
|
||||||
|
onpointermove={onFileHistoryResizeMove}
|
||||||
|
onpointerup={endFileHistoryResize}
|
||||||
|
onpointercancel={endFileHistoryResize}
|
||||||
|
onkeydown={onFileHistoryResizeKeydown}
|
||||||
|
></div>
|
||||||
<FileHistoryPanel
|
<FileHistoryPanel
|
||||||
{fileHistory}
|
{fileHistory}
|
||||||
{selectedExplorerPath}
|
{selectedExplorerPath}
|
||||||
@@ -3401,6 +3944,8 @@
|
|||||||
isLoading={fileHistoryLoading}
|
isLoading={fileHistoryLoading}
|
||||||
onDiff={diffSelectedFileFromCommit}
|
onDiff={diffSelectedFileFromCommit}
|
||||||
onRestore={restoreSelectedFileFromCommit}
|
onRestore={restoreSelectedFileFromCommit}
|
||||||
|
collapsed={fileHistoryCollapsed}
|
||||||
|
onToggleCollapsed={toggleFileHistoryCollapsed}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
@@ -3431,6 +3976,7 @@
|
|||||||
{#if appSettingsOpen}
|
{#if appSettingsOpen}
|
||||||
<AppSettingsDialog
|
<AppSettingsDialog
|
||||||
analytics={analyticsSettings}
|
analytics={analyticsSettings}
|
||||||
|
theme={appTheme}
|
||||||
onSave={saveAppSettings}
|
onSave={saveAppSettings}
|
||||||
onClose={() => { appSettingsOpen = false; }}
|
onClose={() => { appSettingsOpen = false; }}
|
||||||
/>
|
/>
|
||||||
@@ -3463,8 +4009,8 @@
|
|||||||
|
|
||||||
{#if pendingDiscard}
|
{#if pendingDiscard}
|
||||||
<DiscardConfirmDialog
|
<DiscardConfirmDialog
|
||||||
file={pendingDiscard.file}
|
files={pendingDiscard.kind === "hunk" ? [pendingDiscard.file] : pendingDiscard.files}
|
||||||
staged={pendingDiscard.staged}
|
staged={pendingDiscard.kind === "all-changes" ? null : pendingDiscard.staged}
|
||||||
scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"}
|
scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"}
|
||||||
{isBusy}
|
{isBusy}
|
||||||
onConfirm={confirmDiscard}
|
onConfirm={confirmDiscard}
|
||||||
|
|||||||
+823
-128
File diff suppressed because it is too large
Load Diff
+30
-8
@@ -24,22 +24,44 @@
|
|||||||
export let onToggleAutoRefresh: () => void = () => {};
|
export let onToggleAutoRefresh: () => void = () => {};
|
||||||
export let onOpenSettings: () => void = () => {};
|
export let onOpenSettings: () => void = () => {};
|
||||||
|
|
||||||
const win = getCurrentWindow();
|
let win: ReturnType<typeof getCurrentWindow> | null = null;
|
||||||
let isMaximized = false;
|
let isMaximized = false;
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
let appVersion = "";
|
let appVersion = "";
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
isMaximized = await win.isMaximized();
|
try {
|
||||||
unlisten = await win.onResized(async () => {
|
win = getCurrentWindow();
|
||||||
isMaximized = await win.isMaximized();
|
isMaximized = await win.isMaximized();
|
||||||
});
|
unlisten = await win.onResized(async () => {
|
||||||
appVersion = await getVersion();
|
isMaximized = await win?.isMaximized() ?? false;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
win = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
appVersion = await getVersion();
|
||||||
|
} catch {
|
||||||
|
appVersion = "";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
unlisten?.();
|
unlisten?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function minimizeWindow() {
|
||||||
|
void win?.minimize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMaximizeWindow() {
|
||||||
|
void win?.toggleMaximize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWindow() {
|
||||||
|
void win?.close();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="titlebar" data-tauri-drag-region>
|
<header class="titlebar" data-tauri-drag-region>
|
||||||
@@ -200,12 +222,12 @@
|
|||||||
<div class="titlebar-divider" aria-hidden="true"></div>
|
<div class="titlebar-divider" aria-hidden="true"></div>
|
||||||
|
|
||||||
<div class="titlebar-controls">
|
<div class="titlebar-controls">
|
||||||
<button class="tb-btn" onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
<button class="tb-btn" onclick={minimizeWindow} title="Minimize" aria-label="Minimize">
|
||||||
<Minus size={12} aria-hidden="true" />
|
<Minus size={12} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="tb-btn"
|
class="tb-btn"
|
||||||
onclick={() => win.toggleMaximize()}
|
onclick={toggleMaximizeWindow}
|
||||||
title={isMaximized ? "Restore" : "Maximize"}
|
title={isMaximized ? "Restore" : "Maximize"}
|
||||||
aria-label={isMaximized ? "Restore" : "Maximize"}
|
aria-label={isMaximized ? "Restore" : "Maximize"}
|
||||||
>
|
>
|
||||||
@@ -220,7 +242,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="tb-btn close" onclick={() => win.close()} title="Close" aria-label="Close">
|
<button class="tb-btn close" onclick={closeWindow} title="Close" aria-label="Close">
|
||||||
<X size={12} aria-hidden="true" />
|
<X size={12} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, Settings, X } from "@lucide/svelte";
|
import { Check, Settings, X } from "@lucide/svelte";
|
||||||
import type { AnalyticsSettings } from "../types";
|
import type { AnalyticsSettings, AppTheme } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
analytics: AnalyticsSettings;
|
analytics: AnalyticsSettings;
|
||||||
onSave: (settings: AnalyticsSettings) => void;
|
theme: AppTheme;
|
||||||
|
onSave: (settings: AnalyticsSettings, theme: AppTheme) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { analytics, onSave = () => {}, onClose = () => {} }: Props = $props();
|
let { analytics, theme = "system", onSave = () => {}, onClose = () => {} }: Props = $props();
|
||||||
|
|
||||||
let analyticsEnabled = $state(true);
|
let analyticsEnabled = $state(true);
|
||||||
|
let selectedTheme = $state<AppTheme>("system");
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
analyticsEnabled = analytics.enabled;
|
analyticsEnabled = analytics.enabled;
|
||||||
|
selectedTheme = theme;
|
||||||
});
|
});
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
@@ -21,7 +24,7 @@
|
|||||||
...analytics,
|
...analytics,
|
||||||
enabled: analyticsEnabled,
|
enabled: analyticsEnabled,
|
||||||
noticeSeen: true,
|
noticeSeen: true,
|
||||||
});
|
}, selectedTheme);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -38,6 +41,31 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
<form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}>
|
||||||
|
<section class="settings-section">
|
||||||
|
<header>
|
||||||
|
<Settings size={16} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Appearance</span>
|
||||||
|
<h3>Theme</h3>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="settings-segmented" role="radiogroup" aria-label="Theme">
|
||||||
|
<label class:active={selectedTheme === "system"}>
|
||||||
|
<input type="radio" bind:group={selectedTheme} value="system" />
|
||||||
|
<span>System</span>
|
||||||
|
</label>
|
||||||
|
<label class:active={selectedTheme === "light"}>
|
||||||
|
<input type="radio" bind:group={selectedTheme} value="light" />
|
||||||
|
<span>Light</span>
|
||||||
|
</label>
|
||||||
|
<label class:active={selectedTheme === "dark"}>
|
||||||
|
<input type="radio" bind:group={selectedTheme} value="dark" />
|
||||||
|
<span>Dark</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<header>
|
<header>
|
||||||
<Settings size={16} aria-hidden="true" />
|
<Settings size={16} aria-hidden="true" />
|
||||||
|
|||||||
@@ -57,6 +57,8 @@
|
|||||||
onCreateTag: (name: string, message: string) => void | Promise<void>;
|
onCreateTag: (name: string, message: string) => void | Promise<void>;
|
||||||
onDeleteTag: (tag: GitTag) => void | Promise<void>;
|
onDeleteTag: (tag: GitTag) => void | Promise<void>;
|
||||||
onPushTag: (tag: GitTag) => void | Promise<void>;
|
onPushTag: (tag: GitTag) => void | Promise<void>;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapsed?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -75,6 +77,8 @@
|
|||||||
onCreateTag = () => {},
|
onCreateTag = () => {},
|
||||||
onDeleteTag = () => {},
|
onDeleteTag = () => {},
|
||||||
onPushTag = () => {},
|
onPushTag = () => {},
|
||||||
|
collapsed = false,
|
||||||
|
onToggleCollapsed = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let localOpen = $state(true);
|
let localOpen = $state(true);
|
||||||
@@ -355,7 +359,7 @@
|
|||||||
|
|
||||||
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
|
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
|
||||||
|
|
||||||
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Branches</span>
|
<span class="eyebrow">Branches</span>
|
||||||
@@ -373,10 +377,26 @@
|
|||||||
<Plus size={14} aria-hidden="true" />
|
<Plus size={14} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<span class="pill pill-count">{branches.length}</span>
|
<span class="pill pill-count">{branches.length}</span>
|
||||||
|
<button
|
||||||
|
class="branch-create-toggle panel-collapse-toggle"
|
||||||
|
type="button"
|
||||||
|
onclick={onToggleCollapsed}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
title={collapsed ? "Expand branches" : "Collapse branches"}
|
||||||
|
aria-label={collapsed ? "Expand branches panel" : "Collapse branches panel"}
|
||||||
|
>
|
||||||
|
{#if collapsed}
|
||||||
|
<ChevronRight size={14} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<ChevronDown size={14} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !hasRepository}
|
{#if collapsed}
|
||||||
|
<!-- collapsed -->
|
||||||
|
{:else if !hasRepository}
|
||||||
<p class="blank-state">Open a repository to list branches.</p>
|
<p class="blank-state">Open a repository to list branches.</p>
|
||||||
{:else if branches.length === 0 && tags.length === 0}
|
{:else if branches.length === 0 && tags.length === 0}
|
||||||
<p class="blank-state">No branches returned.</p>
|
<p class="blank-state">No branches returned.</p>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import type { GitFileStatus } from "../types";
|
import type { GitFileStatus } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
file: GitFileStatus;
|
files: GitFileStatus[];
|
||||||
staged: boolean;
|
staged: boolean | null;
|
||||||
scope: "file" | "hunk";
|
scope: "file" | "hunk";
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onConfirm: () => void | Promise<void>;
|
onConfirm: () => void | Promise<void>;
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
file,
|
files,
|
||||||
staged = false,
|
staged = false,
|
||||||
scope = "file",
|
scope = "file",
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
@@ -20,10 +20,16 @@
|
|||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let targetPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
function targetPath(file: GitFileStatus): string {
|
||||||
let title = $derived(scope === "hunk" ? "Discard hunk?" : "Discard file changes?");
|
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
||||||
let scopeLabel = $derived(scope === "hunk" ? "Selected hunk" : "File changes");
|
}
|
||||||
let sourceLabel = $derived(staged ? "staged changes" : "unstaged changes");
|
|
||||||
|
let count = $derived(files.length);
|
||||||
|
let title = $derived(
|
||||||
|
scope === "hunk" ? "Discard hunk?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?"
|
||||||
|
);
|
||||||
|
let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : count > 1 ? `${count} files` : "file");
|
||||||
|
let sourceLabel = $derived(staged === null ? "staged and unstaged changes" : staged ? "staged changes" : "unstaged changes");
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -46,11 +52,22 @@
|
|||||||
|
|
||||||
<div class="discard-confirm-copy">
|
<div class="discard-confirm-copy">
|
||||||
<p>
|
<p>
|
||||||
This will reset the {sourceLabel} for the {scopeLabel.toLowerCase()} below.
|
This will reset the {sourceLabel} for the {scopeLabel} below.
|
||||||
</p>
|
</p>
|
||||||
<code class="discard-target" title={targetPath}>{targetPath}</code>
|
{#if count > 1}
|
||||||
|
<ul class="discard-target-list">
|
||||||
|
{#each files.slice(0, 8) as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||||
|
<li><code class="discard-target" title={targetPath(file)}>{targetPath(file)}</code></li>
|
||||||
|
{/each}
|
||||||
|
{#if files.length > 8}
|
||||||
|
<li class="discard-target-more">+{files.length - 8} more</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{:else if count === 1}
|
||||||
|
<code class="discard-target" title={targetPath(files[0])}>{targetPath(files[0])}</code>
|
||||||
|
{/if}
|
||||||
<p class="discard-warning-text">
|
<p class="discard-warning-text">
|
||||||
This cannot be undone. If the file only exists in your working tree, it can be deleted entirely.
|
This cannot be undone. If a file only exists in your working tree, it can be deleted entirely.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
onSelectNode: (node: ExplorerNode) => void;
|
onSelectNode: (node: ExplorerNode) => void;
|
||||||
onOpenFile: (node: ExplorerNode) => void;
|
onOpenFile: (node: ExplorerNode) => void;
|
||||||
onBlame: (node: ExplorerNode) => void;
|
onBlame: (node: ExplorerNode) => void;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapsed?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -53,6 +55,8 @@
|
|||||||
onSelectNode = () => {},
|
onSelectNode = () => {},
|
||||||
onOpenFile = () => {},
|
onOpenFile = () => {},
|
||||||
onBlame = () => {},
|
onBlame = () => {},
|
||||||
|
collapsed = false,
|
||||||
|
onToggleCollapsed = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let contextNode = $state<ExplorerNode | null>(null);
|
let contextNode = $state<ExplorerNode | null>(null);
|
||||||
@@ -203,7 +207,7 @@
|
|||||||
|
|
||||||
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
|
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
|
||||||
|
|
||||||
<section class="panel explorer-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="File explorer">
|
<section class="panel explorer-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="File explorer">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Explorer</span>
|
<span class="eyebrow">Explorer</span>
|
||||||
@@ -231,10 +235,26 @@
|
|||||||
<Folder size={14} aria-hidden="true" />
|
<Folder size={14} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<span class="pill pill-count">{repoFiles.length}</span>
|
<span class="pill pill-count">{repoFiles.length}</span>
|
||||||
|
<button
|
||||||
|
class="explorer-bulk-button panel-collapse-toggle"
|
||||||
|
type="button"
|
||||||
|
onclick={onToggleCollapsed}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
title={collapsed ? "Expand file explorer" : "Collapse file explorer"}
|
||||||
|
aria-label={collapsed ? "Expand file explorer panel" : "Collapse file explorer panel"}
|
||||||
|
>
|
||||||
|
{#if collapsed}
|
||||||
|
<ChevronRight size={14} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<ChevronDown size={14} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !hasRepository}
|
{#if collapsed}
|
||||||
|
<!-- collapsed -->
|
||||||
|
{:else if !hasRepository}
|
||||||
<p class="blank-state">Open a repository to browse files.</p>
|
<p class="blank-state">Open a repository to browse files.</p>
|
||||||
{:else if repoFiles.length === 0}
|
{:else if repoFiles.length === 0}
|
||||||
<p class="blank-state">No files returned.</p>
|
<p class="blank-state">No files returned.</p>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { GitCompare, History, RotateCcw } from "@lucide/svelte";
|
import { ChevronLeft, ChevronRight, GitCompare, History, RotateCcw } from "@lucide/svelte";
|
||||||
import iconUrl from "../../../src-tauri/icons/icon.png";
|
import iconUrl from "../../../src-tauri/icons/icon.png";
|
||||||
import type { GitCommit } from "../types";
|
import type { GitCommit } from "../types";
|
||||||
|
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onDiff: (commit: GitCommit) => void;
|
onDiff: (commit: GitCommit) => void;
|
||||||
onRestore: (commit: GitCommit) => void;
|
onRestore: (commit: GitCommit) => void;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapsed?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -23,6 +25,8 @@
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
onDiff = () => {},
|
onDiff = () => {},
|
||||||
onRestore = () => {},
|
onRestore = () => {},
|
||||||
|
collapsed = false,
|
||||||
|
onToggleCollapsed = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function formatCommitDate(value: string): string {
|
function formatCommitDate(value: string): string {
|
||||||
@@ -76,7 +80,23 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Selected file history">
|
<section class="panel file-history-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Selected file history">
|
||||||
|
{#if collapsed}
|
||||||
|
<button
|
||||||
|
class="file-history-rail-toggle"
|
||||||
|
type="button"
|
||||||
|
onclick={onToggleCollapsed}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
title="Expand file history"
|
||||||
|
aria-label="Expand file history panel"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} aria-hidden="true" />
|
||||||
|
<span>File history</span>
|
||||||
|
{#if selectedExplorerPath}
|
||||||
|
<small>{fileHistory.length}</small>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
<div class="section-head file-history-head">
|
<div class="section-head file-history-head">
|
||||||
<div class="file-history-heading">
|
<div class="file-history-heading">
|
||||||
<span class="eyebrow">{selectedExplorerLabel}</span>
|
<span class="eyebrow">{selectedExplorerLabel}</span>
|
||||||
@@ -85,7 +105,19 @@
|
|||||||
use:pathTooltip={selectedExplorerPath}
|
use:pathTooltip={selectedExplorerPath}
|
||||||
>{selectedExplorerPath ? fileName(selectedExplorerPath) : "No file"}</h2>
|
>{selectedExplorerPath ? fileName(selectedExplorerPath) : "No file"}</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="pill pill-count file-history-count">{fileHistory.length}</span>
|
<div class="file-history-head-actions">
|
||||||
|
<span class="pill pill-count file-history-count">{fileHistory.length}</span>
|
||||||
|
<button
|
||||||
|
class="file-history-toggle panel-collapse-toggle"
|
||||||
|
type="button"
|
||||||
|
onclick={onToggleCollapsed}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
title="Collapse file history"
|
||||||
|
aria-label="Collapse file history panel"
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !hasRepository}
|
{#if !hasRepository}
|
||||||
@@ -136,4 +168,5 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -41,9 +41,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background:
|
background: linear-gradient(135deg, #070a10 0%, #0c111a 48%, #090e16 100%);
|
||||||
radial-gradient(circle at 50% 42%, rgba(137, 92, 255, 0.18), transparent 34%),
|
|
||||||
linear-gradient(135deg, #050712 0%, #080b16 46%, #120b18 100%);
|
|
||||||
animation: overlay-in 180ms ease;
|
animation: overlay-in 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +50,8 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
opacity: 0.28;
|
opacity: 0.28;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(151, 118, 255, 0.12) 1px, transparent 1px),
|
linear-gradient(rgba(111, 140, 255, 0.09) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, rgba(255, 109, 38, 0.08) 1px, transparent 1px);
|
linear-gradient(90deg, rgba(77, 182, 214, 0.07) 1px, transparent 1px);
|
||||||
background-size: 42px 42px;
|
background-size: 42px 42px;
|
||||||
mask-image: radial-gradient(circle at center, black 0%, transparent 68%);
|
mask-image: radial-gradient(circle at center, black 0%, transparent 68%);
|
||||||
animation: grid-drift 10s linear infinite;
|
animation: grid-drift 10s linear infinite;
|
||||||
@@ -67,14 +65,14 @@
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
width: min(420px, calc(100vw - 42px));
|
width: min(420px, calc(100vw - 42px));
|
||||||
padding: 30px 34px 28px;
|
padding: 30px 34px 28px;
|
||||||
border: 1px solid rgba(160, 124, 255, 0.24);
|
border: 1px solid rgba(90, 111, 154, 0.28);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(23, 26, 43, 0.9), rgba(12, 15, 27, 0.92)),
|
linear-gradient(180deg, rgba(23, 29, 43, 0.92), rgba(12, 17, 27, 0.94)),
|
||||||
var(--color-surface-raised);
|
var(--color-surface-raised);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 26px 90px rgba(0, 0, 0, 0.62),
|
0 26px 78px rgba(0, 0, 0, 0.54),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-loading-mark {
|
.repo-loading-mark {
|
||||||
@@ -89,7 +87,7 @@
|
|||||||
.repo-loading-halo {
|
.repo-loading-halo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 10px;
|
inset: 10px;
|
||||||
border: 1px solid rgba(151, 118, 255, 0.24);
|
border: 1px solid rgba(111, 140, 255, 0.22);
|
||||||
border-radius: 34px;
|
border-radius: 34px;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
@@ -98,7 +96,7 @@
|
|||||||
}
|
}
|
||||||
.repo-loading-halo.halo-two {
|
.repo-loading-halo.halo-two {
|
||||||
inset: 24px;
|
inset: 24px;
|
||||||
border-color: rgba(255, 109, 38, 0.26);
|
border-color: rgba(77, 182, 214, 0.24);
|
||||||
animation: halo-breathe 2.4s ease-in-out infinite reverse;
|
animation: halo-breathe 2.4s ease-in-out infinite reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,18 +114,18 @@
|
|||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-dasharray: 165;
|
stroke-dasharray: 165;
|
||||||
stroke-dashoffset: 165;
|
stroke-dashoffset: 165;
|
||||||
filter: drop-shadow(0 0 8px rgba(151, 118, 255, 0.55));
|
filter: drop-shadow(0 0 8px rgba(77, 182, 214, 0.32));
|
||||||
animation: trace-draw 2.6s ease-in-out infinite;
|
animation: trace-draw 2.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.repo-loading-traces .trace-main {
|
.repo-loading-traces .trace-main {
|
||||||
stroke: #9b70ff;
|
stroke: #6f8cff;
|
||||||
}
|
}
|
||||||
.repo-loading-traces .trace-branch {
|
.repo-loading-traces .trace-branch {
|
||||||
stroke: #ff6d26;
|
stroke: #4db6d6;
|
||||||
animation-delay: 0.28s;
|
animation-delay: 0.28s;
|
||||||
}
|
}
|
||||||
.repo-loading-traces .trace-cut {
|
.repo-loading-traces .trace-cut {
|
||||||
stroke: rgba(255, 255, 255, 0.42);
|
stroke: rgba(177, 186, 208, 0.42);
|
||||||
stroke-dasharray: 128;
|
stroke-dasharray: 128;
|
||||||
stroke-dashoffset: 128;
|
stroke-dashoffset: 128;
|
||||||
animation-delay: 0.55s;
|
animation-delay: 0.55s;
|
||||||
@@ -141,7 +139,7 @@
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
filter:
|
filter:
|
||||||
drop-shadow(0 18px 24px rgba(0, 0, 0, 0.55))
|
drop-shadow(0 18px 24px rgba(0, 0, 0, 0.55))
|
||||||
drop-shadow(0 0 18px rgba(151, 118, 255, 0.28));
|
drop-shadow(0 0 18px rgba(77, 182, 214, 0.2));
|
||||||
animation: icon-float 2.4s ease-in-out infinite;
|
animation: icon-float 2.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,18 +173,59 @@
|
|||||||
height: 4px;
|
height: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(151, 118, 255, 0.14);
|
background: rgba(111, 140, 255, 0.14);
|
||||||
}
|
}
|
||||||
.repo-loading-bar span {
|
.repo-loading-bar span {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 46%;
|
width: 46%;
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
background: linear-gradient(90deg, transparent, #9b70ff 40%, #ff6d26 74%, transparent);
|
background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent);
|
||||||
box-shadow: 0 0 16px rgba(255, 109, 38, 0.32);
|
box-shadow: 0 0 16px rgba(77, 182, 214, 0.26);
|
||||||
animation: bar-slide 1.35s ease-in-out infinite;
|
animation: bar-slide 1.35s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .repo-loading {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 42%, rgba(49, 95, 214, 0.12), transparent 34%),
|
||||||
|
linear-gradient(135deg, #f7f9fd 0%, #eef3f9 48%, #f8fafc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .repo-loading-grid {
|
||||||
|
opacity: 0.38;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(49, 95, 214, 0.1) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(15, 143, 181, 0.08) 1px, transparent 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .repo-loading-card {
|
||||||
|
border-color: rgba(61, 89, 142, 0.2);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,0.96), rgba(244,247,252,0.94)),
|
||||||
|
var(--color-surface-raised);
|
||||||
|
box-shadow:
|
||||||
|
0 26px 72px rgba(28,44,74,0.18),
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .repo-loading-traces .trace {
|
||||||
|
filter: drop-shadow(0 0 8px rgba(49,95,214,0.22));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .repo-loading-traces .trace-cut {
|
||||||
|
stroke: rgba(49,95,214,0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .repo-loading-icon {
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 18px 24px rgba(28,44,74,0.2))
|
||||||
|
drop-shadow(0 0 18px rgba(49,95,214,0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(:root[data-theme="light"]) .repo-loading-bar {
|
||||||
|
background: rgba(49,95,214,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
||||||
@keyframes grid-drift { to { transform: translate3d(42px, 42px, 0); } }
|
@keyframes grid-drift { to { transform: translate3d(42px, 42px, 0); } }
|
||||||
@keyframes icon-float {
|
@keyframes icon-float {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
onApply: (stash: GitStash) => void;
|
onApply: (stash: GitStash) => void;
|
||||||
onPop: (stash: GitStash) => void;
|
onPop: (stash: GitStash) => void;
|
||||||
onDrop: (stash: GitStash) => void;
|
onDrop: (stash: GitStash) => void;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapsed?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -22,11 +24,12 @@
|
|||||||
onApply = () => {},
|
onApply = () => {},
|
||||||
onPop = () => {},
|
onPop = () => {},
|
||||||
onDrop = () => {},
|
onDrop = () => {},
|
||||||
|
collapsed = false,
|
||||||
|
onToggleCollapsed = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let message = $state("");
|
let message = $state("");
|
||||||
let includeUntracked = $state(true);
|
let includeUntracked = $state(true);
|
||||||
let open = $state(false);
|
|
||||||
|
|
||||||
function submitPush() {
|
function submitPush() {
|
||||||
onPush(message, includeUntracked);
|
onPush(message, includeUntracked);
|
||||||
@@ -38,31 +41,32 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel stash-panel overflow-hidden" class:collapsed={!open} aria-label="Git stash">
|
<section class="panel stash-panel overflow-hidden" class:collapsed aria-label="Git stash">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Stash</span>
|
<span class="eyebrow">Stash</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Shelved changes</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Shelved changes</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="stash-head-actions">
|
<div class="stash-head-actions">
|
||||||
|
<span class="pill pill-count">{stashes.length}</span>
|
||||||
<button
|
<button
|
||||||
class="stash-toggle"
|
class="stash-toggle panel-collapse-toggle"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => { open = !open; }}
|
onclick={onToggleCollapsed}
|
||||||
aria-expanded={open}
|
aria-expanded={!collapsed}
|
||||||
title={open ? "Collapse stash panel" : "Expand stash panel"}
|
title={collapsed ? "Expand stash panel" : "Collapse stash panel"}
|
||||||
|
aria-label={collapsed ? "Expand stash panel" : "Collapse stash panel"}
|
||||||
>
|
>
|
||||||
{#if open}
|
{#if collapsed}
|
||||||
<ChevronDown size={14} aria-hidden="true" />
|
|
||||||
{:else}
|
|
||||||
<ChevronRight size={14} aria-hidden="true" />
|
<ChevronRight size={14} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<ChevronDown size={14} aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<span class="pill pill-count">{stashes.length}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !open}
|
{#if collapsed}
|
||||||
<!-- collapsed -->
|
<!-- collapsed -->
|
||||||
{:else if !hasRepository}
|
{:else if !hasRepository}
|
||||||
<div class="blank-state">No repository loaded.</div>
|
<div class="blank-state">No repository loaded.</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
|
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
|
||||||
|
import iconUrl from "../../../src-tauri/icons/icon.png";
|
||||||
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
|
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -8,12 +9,14 @@
|
|||||||
unstagedCount: number;
|
unstagedCount: number;
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
operation: string;
|
||||||
status: GitStatus | null;
|
status: GitStatus | null;
|
||||||
selectedFilePath: string;
|
selectedFilePath: string;
|
||||||
onSelectFile: (file: GitFileStatus) => void;
|
onSelectFile: (file: GitFileStatus) => void;
|
||||||
onStage: (file: GitFileStatus) => void;
|
onStage: (files: GitFileStatus[]) => void;
|
||||||
onUnstage: (file: GitFileStatus) => void;
|
onUnstage: (files: GitFileStatus[]) => void;
|
||||||
onDiscard: (file: GitFileStatus, staged: boolean) => void;
|
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
|
||||||
|
onDiscardMany: (files: GitFileStatus[]) => void;
|
||||||
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
||||||
onStageAll: () => void;
|
onStageAll: () => void;
|
||||||
onUnstageAll: () => void;
|
onUnstageAll: () => void;
|
||||||
@@ -25,12 +28,14 @@
|
|||||||
unstagedCount = 0,
|
unstagedCount = 0,
|
||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
operation = "",
|
||||||
status = null,
|
status = null,
|
||||||
selectedFilePath = "",
|
selectedFilePath = "",
|
||||||
onSelectFile = () => {},
|
onSelectFile = () => {},
|
||||||
onStage = () => {},
|
onStage = () => {},
|
||||||
onUnstage = () => {},
|
onUnstage = () => {},
|
||||||
onDiscard = () => {},
|
onDiscard = () => {},
|
||||||
|
onDiscardMany = () => {},
|
||||||
onPatch = () => {},
|
onPatch = () => {},
|
||||||
onStageAll = () => {},
|
onStageAll = () => {},
|
||||||
onUnstageAll = () => {},
|
onUnstageAll = () => {},
|
||||||
@@ -56,11 +61,102 @@
|
|||||||
return kind === "modified";
|
return kind === "modified";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileKey(file: GitFileStatus): string {
|
||||||
|
return `${file.old_path ?? ""}:${file.path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedStatusPaths = $state<Set<string>>(new Set());
|
||||||
|
let selectionAnchorKey = $state("");
|
||||||
|
|
||||||
|
function isStatusSelected(file: GitFileStatus): boolean {
|
||||||
|
return selectedStatusPaths.has(fileKey(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedFiles(): GitFileStatus[] {
|
||||||
|
return changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedStageTargets(file: GitFileStatus): GitFileStatus[] {
|
||||||
|
const files = isStatusSelected(file) ? selectedFiles() : [file];
|
||||||
|
return files.filter((item) => item.unstaged !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedUnstageTargets(file: GitFileStatus): GitFileStatus[] {
|
||||||
|
const files = isStatusSelected(file) ? selectedFiles() : [file];
|
||||||
|
return files.filter((item) => item.staged !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(event: MouseEvent, file: GitFileStatus) {
|
||||||
|
const key = fileKey(file);
|
||||||
|
const allKeys = changedFiles.map(fileKey);
|
||||||
|
const next = new Set(selectedStatusPaths);
|
||||||
|
|
||||||
|
if (event.shiftKey && selectionAnchorKey) {
|
||||||
|
const anchorIndex = allKeys.indexOf(selectionAnchorKey);
|
||||||
|
const currentIndex = allKeys.indexOf(key);
|
||||||
|
if (anchorIndex >= 0 && currentIndex >= 0) {
|
||||||
|
const start = Math.min(anchorIndex, currentIndex);
|
||||||
|
const end = Math.max(anchorIndex, currentIndex);
|
||||||
|
for (const itemKey of allKeys.slice(start, end + 1)) next.add(itemKey);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
} else if (event.ctrlKey || event.metaKey) {
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
selectionAnchorKey = key;
|
||||||
|
} else {
|
||||||
|
next.clear();
|
||||||
|
next.add(key);
|
||||||
|
selectionAnchorKey = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedStatusPaths = next;
|
||||||
|
onSelectFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageFromFile(file: GitFileStatus) {
|
||||||
|
const targets = selectedStageTargets(file);
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
onStage(targets);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unstageFromFile(file: GitFileStatus) {
|
||||||
|
const targets = selectedUnstageTargets(file);
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
onUnstage(targets);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard mirrors the stage/unstage target selection: if the clicked row is
|
||||||
|
// part of the current multi-selection, the whole selection (filtered to the
|
||||||
|
// relevant lane) is discarded; otherwise just that one file.
|
||||||
|
function discardStagedFromFile(file: GitFileStatus) {
|
||||||
|
const targets = selectedUnstageTargets(file);
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
onDiscard(targets, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardUnstagedFromFile(file: GitFileStatus) {
|
||||||
|
const targets = selectedStageTargets(file);
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
onDiscard(targets, false);
|
||||||
|
}
|
||||||
|
|
||||||
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
||||||
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
||||||
|
let selectedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f))).length);
|
||||||
|
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
|
||||||
|
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const validKeys = new Set(changedFiles.map(fileKey));
|
||||||
|
const next = new Set([...selectedStatusPaths].filter((key) => validKeys.has(key)));
|
||||||
|
if (next.size !== selectedStatusPaths.size) selectedStatusPaths = next;
|
||||||
|
if (selectionAnchorKey && !validKeys.has(selectionAnchorKey)) selectionAnchorKey = "";
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel grid grid-rows-[auto_auto_1fr] overflow-hidden" aria-label="Working tree status">
|
<section class="panel relative grid grid-rows-[auto_auto_1fr] overflow-hidden" aria-label="Working tree status">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Working tree</span>
|
<span class="eyebrow">Working tree</span>
|
||||||
@@ -94,6 +190,49 @@
|
|||||||
<Undo2 size={14} aria-hidden="true" />
|
<Undo2 size={14} aria-hidden="true" />
|
||||||
Unstage all
|
Unstage all
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-sm danger"
|
||||||
|
type="button"
|
||||||
|
onclick={() => onDiscardMany(changedFiles)}
|
||||||
|
disabled={isBusy || changedFiles.length === 0}
|
||||||
|
title="Discard all changes"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
|
Discard all
|
||||||
|
</button>
|
||||||
|
{#if selectedCount > 1}
|
||||||
|
<span class="status-selection-count">{selectedCount} selected</span>
|
||||||
|
<button
|
||||||
|
class="btn-sm"
|
||||||
|
type="button"
|
||||||
|
onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))}
|
||||||
|
disabled={isBusy || selectedUnstagedCount === 0}
|
||||||
|
title="Stage selected unstaged files"
|
||||||
|
>
|
||||||
|
<Check size={14} aria-hidden="true" />
|
||||||
|
Stage selected
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-sm"
|
||||||
|
type="button"
|
||||||
|
onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))}
|
||||||
|
disabled={isBusy || selectedStagedCount === 0}
|
||||||
|
title="Unstage selected staged files"
|
||||||
|
>
|
||||||
|
<Undo2 size={14} aria-hidden="true" />
|
||||||
|
Unstage selected
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-sm danger"
|
||||||
|
type="button"
|
||||||
|
onclick={() => onDiscardMany(selectedFiles())}
|
||||||
|
disabled={isBusy || selectedCount === 0}
|
||||||
|
title="Discard changes in selected files"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
|
Discard selected
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -108,13 +247,14 @@
|
|||||||
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||||
<article
|
<article
|
||||||
class="file-row"
|
class="file-row"
|
||||||
class:selected={selectedFilePath === file.path}
|
class:selected={isStatusSelected(file)}
|
||||||
|
class:active={selectedFilePath === file.path}
|
||||||
>
|
>
|
||||||
<div class="file-title">
|
<div class="file-title">
|
||||||
<button
|
<button
|
||||||
class="file-title-button"
|
class="file-title-button"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => onSelectFile(file)}
|
onclick={(event) => handleFileSelect(event, file)}
|
||||||
title={`Select ${displayPath(file)} in Explorer`}
|
title={`Select ${displayPath(file)} in Explorer`}
|
||||||
>
|
>
|
||||||
<strong>{fileName(file)}</strong>
|
<strong>{fileName(file)}</strong>
|
||||||
@@ -129,17 +269,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="lane-actions">
|
<div class="lane-actions">
|
||||||
{#if file.staged}
|
{#if file.staged}
|
||||||
<button class="btn-sm" type="button" onclick={() => onUnstage(file)} disabled={isBusy} title="Unstage file">
|
{@const unstageTargets = selectedUnstageTargets(file)}
|
||||||
|
<button class="btn-sm" type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={unstageTargets.length > 1 ? `Unstage ${unstageTargets.length} selected files` : "Unstage file"}>
|
||||||
<Undo2 size={14} aria-hidden="true" />
|
<Undo2 size={14} aria-hidden="true" />
|
||||||
Unstage
|
{unstageTargets.length > 1 ? `Unstage ${unstageTargets.length}` : "Unstage"}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
|
<button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
|
||||||
<FileDiff size={14} aria-hidden="true" />
|
<FileDiff size={14} aria-hidden="true" />
|
||||||
Details
|
Details
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes">
|
<button class="btn-sm" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={unstageTargets.length > 1 ? `Discard staged changes in ${unstageTargets.length} selected files` : "Discard staged changes"}>
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
Discard
|
{unstageTargets.length > 1 ? `Discard ${unstageTargets.length}` : "Discard"}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="quiet">No staged change</span>
|
<span class="quiet">No staged change</span>
|
||||||
@@ -154,17 +295,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="lane-actions">
|
<div class="lane-actions">
|
||||||
{#if file.unstaged}
|
{#if file.unstaged}
|
||||||
<button class="btn-sm" type="button" onclick={() => onStage(file)} disabled={isBusy} title="Stage file">
|
{@const stageTargets = selectedStageTargets(file)}
|
||||||
|
<button class="btn-sm" type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={stageTargets.length > 1 ? `Stage ${stageTargets.length} selected files` : "Stage file"}>
|
||||||
<Check size={14} aria-hidden="true" />
|
<Check size={14} aria-hidden="true" />
|
||||||
Stage
|
{stageTargets.length > 1 ? `Stage ${stageTargets.length}` : "Stage"}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines">
|
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines">
|
||||||
<FileDiff size={14} aria-hidden="true" />
|
<FileDiff size={14} aria-hidden="true" />
|
||||||
Details
|
Details
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
|
<button class="btn-sm" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={stageTargets.length > 1 ? `Discard unstaged changes in ${stageTargets.length} selected files` : "Discard unstaged changes"}>
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
Discard
|
{stageTargets.length > 1 ? `Discard ${stageTargets.length}` : "Discard"}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="quiet">No unstaged change</span>
|
<span class="quiet">No unstaged change</span>
|
||||||
@@ -176,4 +318,177 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if isBusy && hasRepository}
|
||||||
|
<div class="status-panel-overlay" role="status" aria-live="polite">
|
||||||
|
<div class="status-panel-overlay-card">
|
||||||
|
<div class="status-panel-overlay-mark">
|
||||||
|
<span class="status-panel-overlay-halo halo-one"></span>
|
||||||
|
<span class="status-panel-overlay-halo halo-two"></span>
|
||||||
|
<svg class="status-panel-overlay-traces" viewBox="0 0 220 220" aria-hidden="true">
|
||||||
|
<path class="trace trace-main" d="M28 154 C72 114, 88 108, 110 110 S156 116, 192 68" />
|
||||||
|
<path class="trace trace-branch" d="M62 74 C100 88, 126 124, 158 166" />
|
||||||
|
<path class="trace trace-cut" d="M46 180 L174 180" />
|
||||||
|
</svg>
|
||||||
|
<img src={iconUrl} alt="" class="status-panel-overlay-icon" />
|
||||||
|
</div>
|
||||||
|
<span class="status-panel-overlay-label">{operation || "Working"}…</span>
|
||||||
|
<div class="status-panel-overlay-bar"><span></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.status-panel-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 38%, rgba(111, 140, 255, 0.1), transparent 55%),
|
||||||
|
rgba(7, 10, 16, 0.62);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
animation: status-panel-overlay-in 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-panel-overlay-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: min(300px, calc(100% - 32px));
|
||||||
|
padding: 26px 28px 26px;
|
||||||
|
border: 1px solid rgba(90, 111, 154, 0.28);
|
||||||
|
border-radius: 16px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(23, 29, 43, 0.92), rgba(12, 17, 27, 0.94)),
|
||||||
|
var(--color-surface-raised);
|
||||||
|
color: var(--color-ink);
|
||||||
|
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-panel-overlay-mark {
|
||||||
|
position: relative;
|
||||||
|
width: 104px;
|
||||||
|
height: 104px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-panel-overlay-halo {
|
||||||
|
position: absolute;
|
||||||
|
inset: 6px;
|
||||||
|
border: 1px solid rgba(111, 140, 255, 0.22);
|
||||||
|
border-radius: 22px;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.status-panel-overlay-halo.halo-one {
|
||||||
|
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.status-panel-overlay-halo.halo-two {
|
||||||
|
inset: 16px;
|
||||||
|
border-color: rgba(77, 182, 214, 0.24);
|
||||||
|
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-panel-overlay-traces {
|
||||||
|
position: absolute;
|
||||||
|
inset: -12px;
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
overflow: visible;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.status-panel-overlay-traces .trace {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-dasharray: 165;
|
||||||
|
stroke-dashoffset: 165;
|
||||||
|
filter: drop-shadow(0 0 6px rgba(77, 182, 214, 0.32));
|
||||||
|
animation: status-panel-overlay-trace-draw 2.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.status-panel-overlay-traces .trace-main { stroke: #6f8cff; }
|
||||||
|
.status-panel-overlay-traces .trace-branch {
|
||||||
|
stroke: #4db6d6;
|
||||||
|
animation-delay: 0.28s;
|
||||||
|
}
|
||||||
|
.status-panel-overlay-traces .trace-cut {
|
||||||
|
stroke: rgba(177, 186, 208, 0.42);
|
||||||
|
stroke-dasharray: 128;
|
||||||
|
stroke-dashoffset: 128;
|
||||||
|
animation-delay: 0.55s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-panel-overlay-icon {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: contain;
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 8px 12px rgba(0, 0, 0, 0.5))
|
||||||
|
drop-shadow(0 0 10px rgba(77, 182, 214, 0.2));
|
||||||
|
animation: status-panel-overlay-icon-float 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-panel-overlay-label {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-panel-overlay-bar {
|
||||||
|
position: relative;
|
||||||
|
width: min(190px, 100%);
|
||||||
|
height: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(111, 140, 255, 0.14);
|
||||||
|
}
|
||||||
|
.status-panel-overlay-bar span {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 46%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent);
|
||||||
|
box-shadow: 0 0 12px rgba(77, 182, 214, 0.26);
|
||||||
|
animation: status-panel-overlay-bar-slide 1.35s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes status-panel-overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
@keyframes status-panel-overlay-icon-float {
|
||||||
|
0%, 100% { transform: translateY(0) scale(1); }
|
||||||
|
50% { transform: translateY(-3px) scale(1.015); }
|
||||||
|
}
|
||||||
|
@keyframes status-panel-overlay-halo-breathe {
|
||||||
|
0%, 100% { opacity: 0.35; transform: rotate(45deg) scale(0.95); }
|
||||||
|
50% { opacity: 0.8; transform: rotate(45deg) scale(1.04); }
|
||||||
|
}
|
||||||
|
@keyframes status-panel-overlay-bar-slide {
|
||||||
|
0% { transform: translateX(-120%); }
|
||||||
|
100% { transform: translateX(320%); }
|
||||||
|
}
|
||||||
|
@keyframes status-panel-overlay-trace-draw {
|
||||||
|
0% { stroke-dashoffset: 165; opacity: 0; }
|
||||||
|
36% { opacity: 1; }
|
||||||
|
64%, 100% { stroke-dashoffset: 0; opacity: 0.72; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.status-panel-overlay,
|
||||||
|
.status-panel-overlay-halo,
|
||||||
|
.status-panel-overlay-traces .trace,
|
||||||
|
.status-panel-overlay-icon,
|
||||||
|
.status-panel-overlay-bar span { animation: none; }
|
||||||
|
.status-panel-overlay-traces .trace { stroke-dashoffset: 0; opacity: 0.72; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type FileStatusKind =
|
|||||||
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
||||||
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
||||||
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
|
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
|
||||||
|
export type AppTheme = "system" | "light" | "dark";
|
||||||
|
|
||||||
export interface CommitAiStatus {
|
export interface CommitAiStatus {
|
||||||
phase: CommitAiPhase;
|
phase: CommitAiPhase;
|
||||||
|
|||||||
+17
@@ -3,12 +3,29 @@ import { mount } from "svelte";
|
|||||||
import App from "./App.svelte";
|
import App from "./App.svelte";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
|
||||||
|
const THEME_KEY = "gitlite.theme.v1";
|
||||||
const target = document.getElementById("app");
|
const target = document.getElementById("app");
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
throw new Error("App target element was not found.");
|
throw new Error("App target element was not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedTheme = localStorage.getItem(THEME_KEY);
|
||||||
|
const preference = storedTheme === "light" || storedTheme === "dark" || storedTheme === "system"
|
||||||
|
? storedTheme
|
||||||
|
: "system";
|
||||||
|
const resolved = preference === "system" && window.matchMedia("(prefers-color-scheme: light)").matches
|
||||||
|
? "light"
|
||||||
|
: preference === "system"
|
||||||
|
? "dark"
|
||||||
|
: preference;
|
||||||
|
document.documentElement.dataset.themePreference = preference;
|
||||||
|
document.documentElement.dataset.theme = resolved;
|
||||||
|
} catch {
|
||||||
|
// Storage is best-effort; the CSS system theme fallback still applies.
|
||||||
|
}
|
||||||
|
|
||||||
const app = mount(App, { target });
|
const app = mount(App, { target });
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
Reference in New Issue
Block a user