Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d0b9de142 | ||
|
|
e503786211 | ||
|
|
d4ac449074 | ||
|
|
a67663f3c5 | ||
|
|
9e48727fce | ||
|
|
a09335b729 | ||
|
|
da05a8461f | ||
|
|
21366cb26b | ||
|
|
a6312af6d7 | ||
|
|
899f024a4f | ||
|
|
aa61199b43 | ||
|
|
98f3902839 | ||
|
|
d1d3604a6d | ||
|
|
ce0cbc0786 | ||
|
|
0bad722c7a |
@@ -79,7 +79,20 @@
|
||||
"Bash(rustfmt --edition 2024 --check src/git.rs)",
|
||||
"Bash(rustfmt --edition 2024 --check src/badge.rs)",
|
||||
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)",
|
||||
"Bash(pkg-config --list-all)"
|
||||
"Bash(pkg-config --list-all)",
|
||||
"Bash(rustc --edition 2021 --crate-type lib -o /dev/null --emit=metadata src/git.rs)",
|
||||
"Bash(rustc --edition 2021 --crate-type lib -o /dev/null --emit=metadata /mnt/d/Development/GitLite/src-tauri/src/git.rs)",
|
||||
"Bash(rustc --edition 2021 --crate-type bin -o /dev/null --emit=metadata src/main.rs)",
|
||||
"Bash(grep -B1 \"^error\\\\[E0432\\\\]\\\\|^error$\")",
|
||||
"Bash(grep -v \"^--$\")",
|
||||
"Bash(grep \"^error$\" -A2)",
|
||||
"Bash(cargo run *)",
|
||||
"Bash(node_modules/.bin/svelte-check --version)",
|
||||
"Bash(rustfmt --check --edition 2021 src/git.rs src/main.rs)",
|
||||
"Bash(rustfmt --check --edition 2021 src/git.rs)",
|
||||
"Bash(awk *)",
|
||||
"Bash(rustfmt --edition 2021 /tmp/blame_chunk.rs)",
|
||||
"Read(//tmp/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "git-lite",
|
||||
"version": "2026.7.13",
|
||||
"version": "2026.7.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "git-lite",
|
||||
"version": "2026.7.13",
|
||||
"version": "2026.7.14",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.21.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "git-lite",
|
||||
"version": "2026.7.13",
|
||||
"version": "2026.7.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+166
-3
@@ -1128,7 +1128,7 @@ dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -2027,6 +2027,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -2034,7 +2043,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2048,6 +2057,12 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -2526,8 +2541,10 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-aptabase",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2895,6 +2912,22 @@ dependencies = [
|
||||
"webpki-roots 1.0.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -4007,6 +4040,23 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -4059,6 +4109,18 @@ dependencies = [
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no_std_io2"
|
||||
version = "0.9.4"
|
||||
@@ -4523,12 +4585,49 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.81"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -4554,6 +4653,22 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_info"
|
||||
version = "3.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"log",
|
||||
"nix 0.31.3",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-ui-kit",
|
||||
"serde",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
@@ -5342,11 +5457,13 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -5357,6 +5474,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
@@ -6618,6 +6736,15 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysctl"
|
||||
version = "0.6.0"
|
||||
@@ -6877,6 +7004,26 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-aptabase"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "075ebe083b5f311bdfbf436f8383d316dfb12267be5fd977b57d7701bdb99987"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"log",
|
||||
"os_info",
|
||||
"rand 0.9.4",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"time",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.0"
|
||||
@@ -7308,6 +7455,16 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rayon"
|
||||
version = "2.1.0"
|
||||
@@ -7942,6 +8099,12 @@ dependencies = [
|
||||
"uuid 0.8.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
@@ -8952,7 +9115,7 @@ dependencies = [
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"nix",
|
||||
"nix 0.29.0",
|
||||
"ordered-stream",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
|
||||
@@ -17,8 +17,10 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "=2.7.0"
|
||||
tauri-plugin-aptabase = "1.0"
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
||||
commit_ai = { path = "crates/commit_ai" }
|
||||
tokio = "1.52.3"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=APTABASE_KEY");
|
||||
tauri_build::build();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging",
|
||||
"dialog:allow-open"
|
||||
"dialog:allow-open",
|
||||
"aptabase:allow-track-event"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ pub struct GitStatus {
|
||||
pub files: Vec<GitFileStatus>,
|
||||
pub clean: bool,
|
||||
pub rebase_in_progress: bool,
|
||||
pub cherry_pick_in_progress: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
@@ -57,6 +58,16 @@ pub struct GitBranch {
|
||||
pub remote: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GitTag {
|
||||
pub name: String,
|
||||
pub hash: String,
|
||||
pub short_hash: String,
|
||||
pub message: Option<String>,
|
||||
pub date: String,
|
||||
pub annotated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GitStash {
|
||||
pub selector: String,
|
||||
@@ -106,6 +117,25 @@ pub struct GitCommitComparison {
|
||||
pub patch: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GitBlameLine {
|
||||
pub line_number: u32,
|
||||
pub content: String,
|
||||
pub commit_hash: String,
|
||||
pub short_hash: String,
|
||||
pub author_name: String,
|
||||
pub author_email: String,
|
||||
pub author_time: i64,
|
||||
pub summary: String,
|
||||
pub is_uncommitted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GitBlameResult {
|
||||
pub path: String,
|
||||
pub lines: Vec<GitBlameLine>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ConflictFile {
|
||||
pub path: String,
|
||||
@@ -245,6 +275,7 @@ pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
|
||||
pub struct RepositoryBundle {
|
||||
pub status: GitStatus,
|
||||
pub branches: Vec<GitBranch>,
|
||||
pub tags: Vec<GitTag>,
|
||||
pub stashes: Vec<GitStash>,
|
||||
pub commits: Vec<GitCommit>,
|
||||
pub files: Vec<GitRepositoryFile>,
|
||||
@@ -287,12 +318,14 @@ pub async fn open_repository_bundle(
|
||||
let repo = resolve_repo(&path)?;
|
||||
let status = status_for_repo(&repo)?;
|
||||
let branches = branches_for_repo(&repo)?;
|
||||
let tags = tags_for_repo(&repo)?;
|
||||
let stashes = stashes_for_repo(&repo)?;
|
||||
let commits = commits_for_repo(&repo, commit_limit)?;
|
||||
let files = repository_files_with_status(&repo, &status)?;
|
||||
Ok(RepositoryBundle {
|
||||
status,
|
||||
branches,
|
||||
tags,
|
||||
stashes,
|
||||
commits,
|
||||
files,
|
||||
@@ -320,6 +353,63 @@ pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
|
||||
stashes_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_tags(path: String) -> Result<Vec<GitTag>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
tags_for_repo(&repo)
|
||||
}
|
||||
|
||||
fn tags_for_repo(repo: &Path) -> Result<Vec<GitTag>, String> {
|
||||
let output = run_git(
|
||||
repo,
|
||||
[
|
||||
"for-each-ref",
|
||||
"--sort=-creatordate",
|
||||
"refs/tags",
|
||||
"--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(*objectname)%00%(*objectname:short)%00%(contents:subject)%00%(creatordate:iso-strict)",
|
||||
],
|
||||
)?;
|
||||
let text = String::from_utf8_lossy(&output);
|
||||
let mut tags = Vec::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let mut parts = line.splitn(7, '\0');
|
||||
let name = parts.next().unwrap_or_default().trim();
|
||||
let object_hash = parts.next().unwrap_or_default().trim();
|
||||
let object_short = parts.next().unwrap_or_default().trim();
|
||||
let deref_hash = parts.next().unwrap_or_default().trim();
|
||||
let deref_short = parts.next().unwrap_or_default().trim();
|
||||
let subject = parts.next().unwrap_or_default().trim();
|
||||
let date = parts.next().unwrap_or_default().trim();
|
||||
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Annotated tags are their own object with a `taggerdate`/subject and
|
||||
// dereference (`*...`) to the commit they point at; lightweight tags
|
||||
// point straight at the commit, so the dereferenced fields are empty.
|
||||
let annotated = !deref_hash.is_empty();
|
||||
let hash = if annotated { deref_hash } else { object_hash };
|
||||
let short_hash = if annotated { deref_short } else { object_short };
|
||||
|
||||
tags.push(GitTag {
|
||||
name: name.to_string(),
|
||||
hash: hash.to_string(),
|
||||
short_hash: short_hash.to_string(),
|
||||
message: if annotated && !subject.is_empty() {
|
||||
Some(subject.to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
date: date.to_string(),
|
||||
annotated,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
||||
let output = run_git(
|
||||
repo,
|
||||
@@ -570,6 +660,123 @@ pub fn delete_branch(
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_tag(
|
||||
path: String,
|
||||
name: String,
|
||||
target: Option<String>,
|
||||
message: Option<String>,
|
||||
) -> Result<Vec<GitTag>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let name = validate_new_tag_name(&repo, &name)?;
|
||||
let target = match target {
|
||||
Some(target) if !target.trim().is_empty() => verify_commit(&repo, &target)?,
|
||||
_ => verify_commit(&repo, "HEAD")?,
|
||||
};
|
||||
let message = message
|
||||
.map(|message| message.trim().to_string())
|
||||
.filter(|message| !message.is_empty());
|
||||
|
||||
match message {
|
||||
Some(message) => {
|
||||
run_git(
|
||||
&repo,
|
||||
["tag", "-a", name.as_str(), "-m", message.as_str(), target.as_str()],
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
run_git(&repo, ["tag", name.as_str(), target.as_str()])?;
|
||||
}
|
||||
}
|
||||
|
||||
tags_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_tag(path: String, name: String) -> Result<Vec<GitTag>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let name = validate_existing_tag_name(&repo, &name)?;
|
||||
run_git(&repo, ["tag", "-d", name.as_str()])?;
|
||||
tags_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn push_tag(
|
||||
path: String,
|
||||
name: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let name = validate_existing_tag_name(&repo, &name)?;
|
||||
let remote = initial_push_remote_name(&repo)?;
|
||||
let tag_ref = format!("refs/tags/{name}");
|
||||
let push_args = ["push", remote.as_str(), tag_ref.as_str()];
|
||||
|
||||
match (username.as_deref(), password.as_deref()) {
|
||||
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||
run_git_authenticated(&repo, push_args, u, p)?;
|
||||
}
|
||||
_ => {
|
||||
run_git(&repo, push_args)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_new_tag_name(repo: &Path, name: &str) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Tag name must not be empty.".to_string());
|
||||
}
|
||||
|
||||
let normalized = validate_tag_ref_name(name)?;
|
||||
if ref_exists(repo, &format!("refs/tags/{normalized}"))? {
|
||||
return Err(format!("Tag '{normalized}' already exists."));
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn validate_existing_tag_name(repo: &Path, name: &str) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("Tag name must not be empty.".to_string());
|
||||
}
|
||||
|
||||
let normalized = validate_tag_ref_name(name)?;
|
||||
if !ref_exists(repo, &format!("refs/tags/{normalized}"))? {
|
||||
return Err(format!("Tag '{normalized}' was not found."));
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn validate_tag_ref_name(name: &str) -> Result<String, String> {
|
||||
let output = git_command()
|
||||
.args(["check-ref-format", "--allow-onelevel", &format!("refs/tags/{name}")])
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let details = command_output_details(&output);
|
||||
return Err(format!("Invalid tag name: {details}"));
|
||||
}
|
||||
|
||||
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let normalized = normalized
|
||||
.strip_prefix("refs/tags/")
|
||||
.map(str::to_string)
|
||||
.unwrap_or(normalized);
|
||||
|
||||
Ok(if normalized.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
normalized
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
@@ -852,6 +1059,63 @@ pub fn commit(path: String, message: String) -> Result<GitStatus, String> {
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn amend_commit(path: String, message: Option<String>) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
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)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn last_commit_message(path: String) -> Result<Option<String>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if verify_commit(&repo, "HEAD").is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let output = run_git(&repo, ["log", "-1", "--format=%B", "HEAD"])?;
|
||||
let message = String::from_utf8_lossy(&output).trim_end().to_string();
|
||||
Ok(if message.is_empty() { None } else { Some(message) })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn undo_last_commit(path: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if verify_commit(&repo, "HEAD").is_err() {
|
||||
return Err("There is no commit to undo.".to_string());
|
||||
}
|
||||
if verify_commit(&repo, "HEAD~1").is_err() {
|
||||
return Err("This is the first commit; there is nothing to undo to.".to_string());
|
||||
}
|
||||
|
||||
// Mixed reset: moves HEAD back one commit and unstages the difference, but
|
||||
// leaves the working tree files untouched, so the undone commit's changes
|
||||
// reappear as ordinary uncommitted changes instead of being discarded.
|
||||
run_git(&repo, ["reset", "HEAD~1"])?;
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pull(
|
||||
path: String,
|
||||
@@ -1214,6 +1478,68 @@ fn rebase_status_or_error(
|
||||
Err(format!("{context}: {}", command_output_details(&output)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let commit_hash = verify_commit(&repo, &commit)?;
|
||||
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["cherry-pick", commit_hash.as_str()])
|
||||
.env("GIT_EDITOR", "true")
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||
|
||||
cherry_pick_status_or_error(&repo, output, "Cherry-pick failed")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cherry_pick_continue(path: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if !cherry_pick_in_progress(&repo) {
|
||||
return Err("No cherry-pick is currently in progress.".to_string());
|
||||
}
|
||||
|
||||
let output = git_command()
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["cherry-pick", "--continue"])
|
||||
.env("GIT_EDITOR", "true")
|
||||
.output()
|
||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||
|
||||
cherry_pick_status_or_error(&repo, output, "Cherry-pick continue failed")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cherry_pick_abort(path: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if !cherry_pick_in_progress(&repo) {
|
||||
return Err("No cherry-pick is currently in progress.".to_string());
|
||||
}
|
||||
|
||||
run_git(&repo, ["cherry-pick", "--abort"])?;
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
fn cherry_pick_status_or_error(
|
||||
repo: &Path,
|
||||
output: Output,
|
||||
context: &str,
|
||||
) -> Result<GitStatus, String> {
|
||||
if output.status.success() {
|
||||
return status_for_repo(repo);
|
||||
}
|
||||
|
||||
let status = status_for_repo(repo)?;
|
||||
if has_unresolved_conflicts(&status) || status.cherry_pick_in_progress {
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
Err(format!("{context}: {}", command_output_details(&output)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
@@ -1300,6 +1626,107 @@ pub fn cancel_file_history(
|
||||
state.cancel(request_id)
|
||||
}
|
||||
|
||||
const UNCOMMITTED_BLAME_HASH: &str = "0000000000000000000000000000000000000000";
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_file_blame(path: String, file: String) -> Result<GitBlameResult, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(std::slice::from_ref(&file))?;
|
||||
|
||||
if verify_commit(&repo, "HEAD").is_err() {
|
||||
return Err("Repository has no commits yet.".to_string());
|
||||
}
|
||||
|
||||
let args = vec![
|
||||
OsString::from("blame"),
|
||||
OsString::from("--line-porcelain"),
|
||||
OsString::from("--"),
|
||||
OsString::from(file.clone()),
|
||||
];
|
||||
let output =
|
||||
run_git(&repo, args).map_err(|err| format!("Could not load blame for '{file}': {err}"))?;
|
||||
|
||||
Ok(GitBlameResult {
|
||||
path: file,
|
||||
lines: parse_blame_porcelain(&output),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_blame_porcelain(output: &[u8]) -> Vec<GitBlameLine> {
|
||||
#[derive(Default, Clone)]
|
||||
struct BlameMeta {
|
||||
author_name: String,
|
||||
author_email: String,
|
||||
author_time: i64,
|
||||
summary: String,
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(output);
|
||||
let mut lines_out: Vec<GitBlameLine> = Vec::new();
|
||||
let mut commit_meta: BTreeMap<String, BlameMeta> = BTreeMap::new();
|
||||
let mut current_hash = String::new();
|
||||
let mut current_final_line: u32 = 0;
|
||||
|
||||
for line in text.split('\n') {
|
||||
if let Some(content) = line.strip_prefix('\t') {
|
||||
let meta = commit_meta.get(¤t_hash).cloned().unwrap_or_default();
|
||||
lines_out.push(GitBlameLine {
|
||||
line_number: current_final_line,
|
||||
content: content.to_string(),
|
||||
commit_hash: current_hash.clone(),
|
||||
short_hash: short_hash(¤t_hash),
|
||||
author_name: meta.author_name,
|
||||
author_email: meta.author_email,
|
||||
author_time: meta.author_time,
|
||||
summary: meta.summary,
|
||||
is_uncommitted: current_hash == UNCOMMITTED_BLAME_HASH,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut parts = line.splitn(2, ' ');
|
||||
let head = parts.next().unwrap_or("");
|
||||
let tail = parts.next().unwrap_or("");
|
||||
|
||||
if head.len() == 40 && head.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
if let Some(final_line) = tail.split_whitespace().nth(1) {
|
||||
current_final_line = final_line.parse().unwrap_or(current_final_line);
|
||||
}
|
||||
current_hash = head.to_string();
|
||||
commit_meta.entry(current_hash.clone()).or_default();
|
||||
continue;
|
||||
}
|
||||
|
||||
match head {
|
||||
"author" => {
|
||||
commit_meta
|
||||
.entry(current_hash.clone())
|
||||
.or_default()
|
||||
.author_name = tail.to_string()
|
||||
}
|
||||
"author-mail" => {
|
||||
let email = tail.trim_matches(|c| c == '<' || c == '>').to_string();
|
||||
commit_meta
|
||||
.entry(current_hash.clone())
|
||||
.or_default()
|
||||
.author_email = email;
|
||||
}
|
||||
"author-time" => {
|
||||
commit_meta
|
||||
.entry(current_hash.clone())
|
||||
.or_default()
|
||||
.author_time = tail.parse().unwrap_or(0);
|
||||
}
|
||||
"summary" => {
|
||||
commit_meta.entry(current_hash.clone()).or_default().summary = tail.to_string()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
lines_out
|
||||
}
|
||||
|
||||
fn list_file_history_core(
|
||||
repo: &Path,
|
||||
file: String,
|
||||
@@ -2049,6 +2476,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
||||
clean: files.is_empty(),
|
||||
files,
|
||||
rebase_in_progress: rebase_in_progress(repo),
|
||||
cherry_pick_in_progress: cherry_pick_in_progress(repo),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2056,6 +2484,10 @@ fn rebase_in_progress(repo: &Path) -> bool {
|
||||
git_path_exists(repo, "rebase-merge") || git_path_exists(repo, "rebase-apply")
|
||||
}
|
||||
|
||||
fn cherry_pick_in_progress(repo: &Path) -> bool {
|
||||
git_path_exists(repo, "CHERRY_PICK_HEAD")
|
||||
}
|
||||
|
||||
fn git_path_exists(repo: &Path, name: &str) -> bool {
|
||||
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
|
||||
return false;
|
||||
@@ -2245,6 +2677,7 @@ fn clone_repository_core(
|
||||
let repo = resolve_repo(&target.to_string_lossy())?;
|
||||
let status = status_for_repo(&repo)?;
|
||||
let branches = branches_for_repo(&repo)?;
|
||||
let tags = tags_for_repo(&repo)?;
|
||||
let stashes = stashes_for_repo(&repo)?;
|
||||
let commits = commits_for_repo(&repo, commit_limit)?;
|
||||
let files = repository_files_with_status(&repo, &status)?;
|
||||
@@ -2252,6 +2685,7 @@ fn clone_repository_core(
|
||||
Ok(RepositoryBundle {
|
||||
status,
|
||||
branches,
|
||||
tags,
|
||||
stashes,
|
||||
commits,
|
||||
files,
|
||||
@@ -5105,4 +5539,44 @@ mod tests {
|
||||
|
||||
assert_eq!(result.unwrap_err(), SEARCH_CANCELLED_MESSAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_file_blame_attributes_lines_to_the_commits_that_introduced_them() {
|
||||
let repo = init_temp_repo("file_blame");
|
||||
commit_initial_file(&repo.path);
|
||||
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, ["commit", "-q", "-m", "add second line"]);
|
||||
|
||||
let result = get_file_blame(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"old.txt".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.path, "old.txt");
|
||||
assert_eq!(result.lines.len(), 2);
|
||||
assert_eq!(result.lines[0].content, "original");
|
||||
assert_eq!(result.lines[0].summary, "init");
|
||||
assert_eq!(result.lines[1].content, "two");
|
||||
assert_eq!(result.lines[1].summary, "add second line");
|
||||
assert!(!result.lines[1].author_name.is_empty());
|
||||
assert!(!result.lines[1].is_uncommitted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_file_blame_marks_uncommitted_working_tree_changes() {
|
||||
let repo = init_temp_repo("file_blame_uncommitted");
|
||||
commit_initial_file(&repo.path);
|
||||
fs::write(repo.path.join("old.txt"), "changed\n").expect("tracked file should change");
|
||||
|
||||
let result = get_file_blame(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"old.txt".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.lines.len(), 1);
|
||||
assert!(result.lines[0].is_uncommitted);
|
||||
}
|
||||
}
|
||||
|
||||
+31
-11
@@ -5,22 +5,31 @@ mod git;
|
||||
|
||||
use badge::set_sync_badge;
|
||||
use git::{
|
||||
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
|
||||
checkout_branch, clone_repository, commit, commit_ai_generate, commit_ai_load,
|
||||
SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search,
|
||||
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
|
||||
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
|
||||
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
|
||||
compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, delete_branch,
|
||||
diff_file_against_working_tree, fetch, get_file_patch, get_remote_url, get_status,
|
||||
list_branches, list_commits, list_file_history, list_repository_files, list_stashes,
|
||||
merge_branch, open_repo_in_explorer, open_repository, open_repository_bundle,
|
||||
open_repository_file, pull, push, read_conflict, rebase_abort, rebase_branch, rebase_continue,
|
||||
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
||||
restore_files, restore_to_commit, search_code_introductions, stage_files, stash_apply,
|
||||
stash_drop, stash_pop, stash_push, unstage_files,
|
||||
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
|
||||
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame,
|
||||
get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, list_commits,
|
||||
list_file_history, list_repository_files, list_stashes, list_tags, merge_branch,
|
||||
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
|
||||
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch,
|
||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
||||
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop,
|
||||
stash_push, undo_last_commit, unstage_files,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_aptabase::Builder::new("A-SH-1344793789").with_options(
|
||||
tauri_plugin_aptabase::InitOptions {
|
||||
host: Some("https://aptabase.cbsk-tech.de".to_string()),
|
||||
flush_interval:None
|
||||
}
|
||||
).build())
|
||||
.manage(SearchCancellationState::default())
|
||||
.manage(commit_ai::CommitAiEngine::new())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@@ -36,6 +45,13 @@ fn main() {
|
||||
create_branch,
|
||||
rename_branch,
|
||||
delete_branch,
|
||||
list_tags,
|
||||
create_tag,
|
||||
delete_tag,
|
||||
push_tag,
|
||||
cherry_pick_commit,
|
||||
cherry_pick_continue,
|
||||
cherry_pick_abort,
|
||||
stage_files,
|
||||
unstage_files,
|
||||
stash_push,
|
||||
@@ -46,6 +62,9 @@ fn main() {
|
||||
get_file_patch,
|
||||
apply_file_patch,
|
||||
commit,
|
||||
amend_commit,
|
||||
undo_last_commit,
|
||||
last_commit_message,
|
||||
commit_ai_status,
|
||||
commit_ai_load,
|
||||
commit_ai_local_models,
|
||||
@@ -64,6 +83,7 @@ fn main() {
|
||||
open_repository_bundle,
|
||||
list_file_history,
|
||||
cancel_file_history,
|
||||
get_file_blame,
|
||||
compare_commits,
|
||||
compare_file_to_head,
|
||||
compare_file_to_parent,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "GitLite",
|
||||
"version": "2026.7.13",
|
||||
"version": "2026.7.14",
|
||||
"identifier": "com.git-lite",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+505
-20
@@ -2,11 +2,14 @@
|
||||
import { onDestroy, onMount, tick } from "svelte";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||||
import { AlertCircle, BookOpen, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
|
||||
import { AlertCircle, BookOpen, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
|
||||
|
||||
import TitleBar from "./lib/TitleBar.svelte";
|
||||
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
|
||||
import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte";
|
||||
import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
|
||||
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
|
||||
import BlameDialog from "./lib/components/BlameDialog.svelte";
|
||||
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
||||
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
|
||||
import CommitPanel from "./lib/components/CommitPanel.svelte";
|
||||
@@ -28,7 +31,11 @@
|
||||
import UpdateToast from "./lib/components/UpdateToast.svelte";
|
||||
|
||||
import {
|
||||
amendCommit,
|
||||
checkoutBranch,
|
||||
cherryPickAbort,
|
||||
cherryPickCommit,
|
||||
cherryPickContinue,
|
||||
cloneRepository,
|
||||
commit,
|
||||
commitAiGenerate,
|
||||
@@ -40,13 +47,18 @@
|
||||
cancelFileHistory,
|
||||
applyFilePatch,
|
||||
createBranch,
|
||||
createTag,
|
||||
deleteBranch,
|
||||
deleteTag,
|
||||
diffFileAgainstWorkingTree,
|
||||
compareFileToParent,
|
||||
fetchRemote,
|
||||
getFileBlame,
|
||||
getStatus,
|
||||
lastCommitMessage,
|
||||
listBranches,
|
||||
listStashes,
|
||||
listTags,
|
||||
listCommits,
|
||||
listFileHistory,
|
||||
listRepositoryFiles,
|
||||
@@ -56,6 +68,7 @@
|
||||
openRepositoryBundle,
|
||||
pull,
|
||||
push,
|
||||
pushTag,
|
||||
renameBranch,
|
||||
rebaseAbort,
|
||||
rebaseBranch,
|
||||
@@ -78,15 +91,18 @@
|
||||
stashDrop,
|
||||
stashPop,
|
||||
stashPush,
|
||||
undoLastCommit,
|
||||
unstageFiles,
|
||||
} from "./lib/git";
|
||||
|
||||
import type {
|
||||
AiSettings,
|
||||
AnalyticsSettings,
|
||||
CommitAiPhase,
|
||||
ConflictFile,
|
||||
ExplorerNode,
|
||||
ExplorerNodeKind,
|
||||
GitBlameLine,
|
||||
GitBranch as GitBranchInfo,
|
||||
GitCommit,
|
||||
GitCommitFile,
|
||||
@@ -97,6 +113,7 @@
|
||||
GitSearchHit,
|
||||
GitStash,
|
||||
GitStatus,
|
||||
GitTag,
|
||||
LocalModelOption,
|
||||
PatchApplyAction,
|
||||
PreparedResolution,
|
||||
@@ -109,6 +126,7 @@
|
||||
isAuthError,
|
||||
stripAuthPrefix,
|
||||
} from "./lib/credentials";
|
||||
import { trackAnalyticsEvent, type AnalyticsEventProperties } from "./lib/analytics";
|
||||
|
||||
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
||||
type AppView = "management" | "repository";
|
||||
@@ -135,7 +153,9 @@
|
||||
|
||||
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
|
||||
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
|
||||
const REPO_STATUS_CACHE_KEY = "gitlite.repoStatusCache.v1";
|
||||
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
|
||||
const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
|
||||
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
|
||||
const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1";
|
||||
const COMMIT_PANEL_DEFAULT_HEIGHT = 220;
|
||||
@@ -153,6 +173,9 @@
|
||||
let activeView: AppView = "management";
|
||||
let repoTabs: RepoTab[] = [];
|
||||
let recentRepoPaths: string[] = [];
|
||||
// Last-seen branch/ahead/behind/changed for repos that are known (recent/all)
|
||||
// but not currently open as a tab — keyed by normalized path (repoKey).
|
||||
let repoStatusCache: Record<string, RepoTab> = {};
|
||||
let repoSearch = "";
|
||||
let cloneDialogOpen = false;
|
||||
let cloneDialogError = "";
|
||||
@@ -160,6 +183,7 @@
|
||||
let pendingClone: CloneRequest | null = null;
|
||||
let status: GitStatus | null = null;
|
||||
let branches: GitBranchInfo[] = [];
|
||||
let tags: GitTag[] = [];
|
||||
let stashes: GitStash[] = [];
|
||||
let commits: GitCommit[] = [];
|
||||
let repoFiles: GitRepositoryFile[] = [];
|
||||
@@ -173,12 +197,17 @@
|
||||
let activeFileHistoryRequestId = "";
|
||||
let lastFileHistoryHeadHash = "";
|
||||
let commitMessage = "";
|
||||
let amendMode = false;
|
||||
let preAmendDraftMessage = "";
|
||||
let lastLocalAiGeneratedMessage = "";
|
||||
let commitAiPhase: CommitAiPhase = "idle";
|
||||
let commitAiGenerating = false;
|
||||
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let aiSettings: AiSettings = defaultAiSettings();
|
||||
let aiSettingsOpen = false;
|
||||
let appSettingsOpen = false;
|
||||
let analyticsNoticeOpen = false;
|
||||
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
|
||||
let localModelOptions: LocalModelOption[] = [];
|
||||
let errorMessage = "";
|
||||
let operation = "";
|
||||
@@ -200,6 +229,11 @@
|
||||
let linePatchText = "";
|
||||
let linePatchLoading = false;
|
||||
let linePatchError = "";
|
||||
let blameOpen = false;
|
||||
let blameFilePath = "";
|
||||
let blameLines: GitBlameLine[] = [];
|
||||
let blameLoading = false;
|
||||
let blameError = "";
|
||||
let pendingDiscard: PendingDiscard | null = null;
|
||||
let globalSearchOpen = false;
|
||||
let lastSearchQuery = "";
|
||||
@@ -220,10 +254,16 @@
|
||||
let lastStatusFingerprint = "";
|
||||
const AUTO_REFRESH_INTERVAL = 4000;
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
|
||||
const BACKGROUND_REPO_STATUS_INTERVAL = 30_000;
|
||||
const BACKGROUND_REPO_STATUS_BATCH_SIZE = 4;
|
||||
const BACKGROUND_FETCH_INTERVAL = 180_000;
|
||||
const BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS = 30_000;
|
||||
const BACKGROUND_FETCH_OTHER_BATCH_SIZE = 2;
|
||||
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let backgroundRepoStatusTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let backgroundFetchInFlight = false;
|
||||
let backgroundRepoStatusInFlight = false;
|
||||
let backgroundRepoStatusIndex = 0;
|
||||
let lastRepoSwitchAt = 0;
|
||||
let updateToastOpen = false;
|
||||
let updateToastState: UpdateToastState = "available";
|
||||
@@ -260,9 +300,23 @@
|
||||
$: conflictedFiles = changedFiles.filter((f) => f.staged === "conflicted" || f.unstaged === "conflicted");
|
||||
$: hasConflicts = conflictedFiles.length > 0;
|
||||
$: rebaseInProgress = status?.rebase_in_progress ?? false;
|
||||
$: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !rebaseInProgress && !isBusy;
|
||||
$: cherryPickInProgress = status?.cherry_pick_in_progress ?? false;
|
||||
// Amending/undoing is only offered while the last commit hasn't reached a
|
||||
// remote yet: no upstream at all, or the branch is still ahead of it.
|
||||
$: canAmend = hasRepository && commits.length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress
|
||||
&& (!status?.upstream || (status?.ahead ?? 0) > 0);
|
||||
// Safety net: if the last commit gets pushed elsewhere (or a conflict/rebase starts)
|
||||
// while amend mode is active, drop out of it instead of leaving a stale, hidden toggle.
|
||||
$: if (!canAmend && amendMode) {
|
||||
amendMode = false;
|
||||
commitMessage = preAmendDraftMessage;
|
||||
preAmendDraftMessage = "";
|
||||
}
|
||||
$: canCommit = hasRepository && (amendMode || stagedCount > 0) && commitMessage.trim().length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress && !isBusy;
|
||||
$: commitBlockReason = rebaseInProgress
|
||||
? "A rebase is in progress. Resolve conflicts and use Rebase continue or abort the rebase."
|
||||
: cherryPickInProgress
|
||||
? "A cherry-pick is in progress. Resolve conflicts and use Cherry-pick continue or abort it."
|
||||
: hasConflicts
|
||||
? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "conflict must" : "conflicts must"} be resolved before committing.`
|
||||
: "";
|
||||
@@ -283,15 +337,19 @@
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
onMount(() => {
|
||||
initAnalytics();
|
||||
loadRepoLists();
|
||||
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
|
||||
backgroundRepoStatusTimer = setInterval(() => { void backgroundRepoStatusTick(false); }, BACKGROUND_REPO_STATUS_INTERVAL);
|
||||
backgroundFetchTimer = setInterval(() => { void backgroundFetchTick(); }, BACKGROUND_FETCH_INTERVAL);
|
||||
void backgroundRepoStatusTick(false);
|
||||
void checkForUpdates();
|
||||
void initCommitAi();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
||||
if (backgroundRepoStatusTimer) clearInterval(backgroundRepoStatusTimer);
|
||||
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
|
||||
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
|
||||
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
|
||||
@@ -336,11 +394,14 @@
|
||||
// Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/
|
||||
// Push buttons instead, not as a background popup.
|
||||
async function backgroundFetchTick() {
|
||||
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || backgroundFetchInFlight) return;
|
||||
if (Date.now() - lastRepoSwitchAt < BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) return;
|
||||
if (!autoRefreshEnabled) return;
|
||||
|
||||
if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight
|
||||
&& !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) {
|
||||
backgroundFetchInFlight = true;
|
||||
try {
|
||||
await fetchRemote(activeRepoPath);
|
||||
applyStatus(await getStatus(activeRepoPath));
|
||||
} catch {
|
||||
// ignore — see comment above
|
||||
} finally {
|
||||
@@ -348,6 +409,86 @@
|
||||
}
|
||||
}
|
||||
|
||||
void backgroundRepoStatusTick(true);
|
||||
}
|
||||
|
||||
async function backgroundFetchRepo(path: string) {
|
||||
if (!autoRefreshEnabled || !path || backgroundFetchInFlight) return;
|
||||
|
||||
backgroundFetchInFlight = true;
|
||||
try {
|
||||
await fetchRemote(path);
|
||||
const nextStatus = await getStatus(path);
|
||||
if (sameRepoPath(path, activeRepoPath)) applyStatus(nextStatus);
|
||||
else updateRepoManagementStatus(path, nextStatus);
|
||||
} catch {
|
||||
// ignore; manual Fetch/Pull surfaces auth or network problems
|
||||
} finally {
|
||||
backgroundFetchInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Keeps the repo list's branch/ahead/behind up to date for every *other* open
|
||||
// tab, not just the active one, so switching to the management view (or just
|
||||
// glancing at the tab bar) shows current data without an explicit fetch.
|
||||
// One repo per tick, round-robin: spreads the git subprocess cost over time
|
||||
// instead of firing N fetches at once when many repos are open.
|
||||
// Every repo we know about besides the active one: open tabs (minus the active
|
||||
// tab) plus recent repos that aren't currently open — the same universe the
|
||||
// Recent/All repositories lists in Repository Management draw from.
|
||||
function knownRepoPathsForBackground(): string[] {
|
||||
return uniqueRepoPaths([...repoTabs.map((tab) => tab.path), ...recentRepoPaths])
|
||||
.filter((path) => !(activeView === "repository" && sameRepoPath(path, activeRepoPath)));
|
||||
}
|
||||
|
||||
function updateRepoManagementStatus(path: string, nextStatus: GitStatus) {
|
||||
const openTab = repoTabs.find((tab) => sameRepoPath(tab.path, path));
|
||||
const cached = repoStatusCache[repoKey(path)];
|
||||
const row: RepoTab = {
|
||||
path,
|
||||
name: repoNameFromPath(path),
|
||||
branch: nextStatus.current_branch,
|
||||
ahead: nextStatus.ahead,
|
||||
behind: nextStatus.behind,
|
||||
changed: nextStatus.files.length,
|
||||
lastOpened: openTab?.lastOpened ?? cached?.lastOpened ?? 0,
|
||||
};
|
||||
|
||||
if (openTab) {
|
||||
repoTabs = repoTabs.map((tab) => sameRepoPath(tab.path, path) ? row : tab);
|
||||
}
|
||||
cacheRepoStatus(row);
|
||||
}
|
||||
|
||||
async function backgroundRepoStatusTick(fetchFirst: boolean) {
|
||||
if (!autoRefreshEnabled || backgroundRepoStatusInFlight) return;
|
||||
const others = knownRepoPathsForBackground();
|
||||
if (others.length === 0) return;
|
||||
|
||||
backgroundRepoStatusInFlight = true;
|
||||
try {
|
||||
// A handful per tick, sequentially (not in parallel) — enough to fill in
|
||||
// a long recent-repos list within a few minutes instead of an hour, while
|
||||
// still never running more than one `git fetch` subprocess at a time.
|
||||
const batchSize = fetchFirst ? BACKGROUND_FETCH_OTHER_BATCH_SIZE : BACKGROUND_REPO_STATUS_BATCH_SIZE;
|
||||
for (let step = 0; step < Math.min(batchSize, others.length); step++) {
|
||||
if (backgroundRepoStatusIndex >= others.length) backgroundRepoStatusIndex = 0;
|
||||
const path = others[backgroundRepoStatusIndex];
|
||||
backgroundRepoStatusIndex += 1;
|
||||
|
||||
try {
|
||||
if (fetchFirst) await fetchRemote(path);
|
||||
const nextStatus = await getStatus(path);
|
||||
updateRepoManagementStatus(path, nextStatus);
|
||||
} catch {
|
||||
// ignore this repo — same rationale as the active-repo background fetch above
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
backgroundRepoStatusInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function autoRefreshTick() {
|
||||
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
|
||||
autoRefreshInFlight = true;
|
||||
@@ -360,6 +501,7 @@
|
||||
const bundle = await openRepositoryBundle(activeRepoPath, 100);
|
||||
const previousHeadHash = lastFileHistoryHeadHash;
|
||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||
await refreshTags(activeRepoPath, bundle.tags);
|
||||
await refreshStashes(activeRepoPath, bundle.stashes);
|
||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||
@@ -421,6 +563,57 @@
|
||||
startCommitAiPolling();
|
||||
}
|
||||
|
||||
function defaultAnalyticsSettings(): AnalyticsSettings {
|
||||
return {
|
||||
enabled: true,
|
||||
noticeSeen: false,
|
||||
};
|
||||
}
|
||||
|
||||
function loadAnalyticsSettings(): AnalyticsSettings {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(ANALYTICS_SETTINGS_KEY) ?? "null") as unknown;
|
||||
if (stored && typeof stored === "object") {
|
||||
return { ...defaultAnalyticsSettings(), ...(stored as Partial<AnalyticsSettings>) };
|
||||
}
|
||||
} catch { /* ignore malformed settings */ }
|
||||
return defaultAnalyticsSettings();
|
||||
}
|
||||
|
||||
function persistAnalyticsSettings(next: AnalyticsSettings) {
|
||||
try {
|
||||
localStorage.setItem(ANALYTICS_SETTINGS_KEY, JSON.stringify(next));
|
||||
} catch { /* ignore storage quota/private-mode errors */ }
|
||||
}
|
||||
|
||||
function initAnalytics() {
|
||||
analyticsSettings = loadAnalyticsSettings();
|
||||
if (!analyticsSettings.noticeSeen) {
|
||||
analyticsNoticeOpen = true;
|
||||
return;
|
||||
}
|
||||
trackEvent("app_started");
|
||||
}
|
||||
|
||||
function trackEvent(name: string, props?: AnalyticsEventProperties) {
|
||||
if (!analyticsSettings.enabled) return;
|
||||
trackAnalyticsEvent(name, props);
|
||||
}
|
||||
|
||||
function acceptAnalyticsNotice(enabled: boolean) {
|
||||
analyticsSettings = { enabled, noticeSeen: true };
|
||||
persistAnalyticsSettings(analyticsSettings);
|
||||
analyticsNoticeOpen = false;
|
||||
trackEvent("app_started", { first_run: 1 });
|
||||
}
|
||||
|
||||
function saveAppSettings(next: AnalyticsSettings) {
|
||||
analyticsSettings = next;
|
||||
persistAnalyticsSettings(next);
|
||||
appSettingsOpen = false;
|
||||
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1 });
|
||||
}
|
||||
|
||||
function updateCommitMessage(message: string) {
|
||||
commitMessage = message;
|
||||
if (message !== lastLocalAiGeneratedMessage) {
|
||||
@@ -620,7 +813,13 @@
|
||||
}
|
||||
|
||||
function repoRowFromPath(path: string): RepoTab {
|
||||
return repoTabs.find((tab) => sameRepoPath(tab.path, path)) ?? {
|
||||
const openTab = repoTabs.find((tab) => sameRepoPath(tab.path, path));
|
||||
if (openTab) return openTab;
|
||||
|
||||
const cached = repoStatusCache[repoKey(path)];
|
||||
if (cached) return { ...cached, path, name: repoNameFromPath(path) };
|
||||
|
||||
return {
|
||||
path,
|
||||
name: repoNameFromPath(path),
|
||||
branch: null,
|
||||
@@ -640,6 +839,11 @@
|
||||
|
||||
function loadRepoLists() {
|
||||
try {
|
||||
const cacheValue = JSON.parse(localStorage.getItem(REPO_STATUS_CACHE_KEY) ?? "{}") as unknown;
|
||||
repoStatusCache = cacheValue && typeof cacheValue === "object" && !Array.isArray(cacheValue)
|
||||
? cacheValue as Record<string, RepoTab>
|
||||
: {};
|
||||
|
||||
const openValue = JSON.parse(localStorage.getItem(OPEN_REPOS_KEY) ?? "[]") as unknown;
|
||||
const recentValue = JSON.parse(localStorage.getItem(RECENT_REPOS_KEY) ?? "[]") as unknown;
|
||||
const openPaths = Array.isArray(openValue)
|
||||
@@ -649,19 +853,19 @@
|
||||
? recentValue.map((item) => typeof item === "string" ? item : "").filter(Boolean)
|
||||
: [];
|
||||
|
||||
repoTabs = uniqueRepoPaths(openPaths).map((path) => ({
|
||||
path,
|
||||
name: repoNameFromPath(path),
|
||||
branch: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
changed: 0,
|
||||
lastOpened: 0,
|
||||
}));
|
||||
// Seed from the last-known status cache so tabs show real data immediately
|
||||
// on startup, instead of blank until the background poll catches up.
|
||||
repoTabs = uniqueRepoPaths(openPaths).map((path) => {
|
||||
const cached = repoStatusCache[repoKey(path)];
|
||||
return cached
|
||||
? { ...cached, path, name: repoNameFromPath(path) }
|
||||
: { path, name: repoNameFromPath(path), branch: null, ahead: 0, behind: 0, changed: 0, lastOpened: 0 };
|
||||
});
|
||||
recentRepoPaths = uniqueRepoPaths([...recentPaths, ...openPaths]);
|
||||
} catch {
|
||||
repoTabs = [];
|
||||
recentRepoPaths = [];
|
||||
repoStatusCache = {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,6 +878,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function persistRepoStatusCache() {
|
||||
try {
|
||||
localStorage.setItem(REPO_STATUS_CACHE_KEY, JSON.stringify(repoStatusCache));
|
||||
} catch {
|
||||
// Local storage is best-effort only; the Git workflow must keep working without it.
|
||||
}
|
||||
}
|
||||
|
||||
function defaultAiSettings(): AiSettings {
|
||||
return {
|
||||
provider: "openai",
|
||||
@@ -831,6 +1043,16 @@
|
||||
? repoTabs.map((tab) => sameRepoPath(tab.path, path) ? next : tab)
|
||||
: [...repoTabs, next];
|
||||
rememberRecentRepo(path);
|
||||
cacheRepoStatus(next);
|
||||
}
|
||||
|
||||
// Keeps last-known branch/ahead/behind/changed around under the repo's
|
||||
// normalized path, independent of repoTabs — so a closed tab (or a repo
|
||||
// that's only ever shown up in "recent") still displays real data instead
|
||||
// of the "known repo" placeholder in the Recent/All repositories lists.
|
||||
function cacheRepoStatus(row: RepoTab) {
|
||||
repoStatusCache = { ...repoStatusCache, [repoKey(row.path)]: row };
|
||||
persistRepoStatusCache();
|
||||
}
|
||||
|
||||
function resetRepositoryState(clearActive = false) {
|
||||
@@ -941,6 +1163,10 @@
|
||||
branches = prefetched ?? (await listBranches(path));
|
||||
}
|
||||
|
||||
async function refreshTags(path = activeRepoPath, prefetched?: GitTag[]) {
|
||||
tags = prefetched ?? (await listTags(path));
|
||||
}
|
||||
|
||||
async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) {
|
||||
stashes = prefetched ?? (await listStashes(path));
|
||||
}
|
||||
@@ -1039,10 +1265,16 @@
|
||||
if (globalSearchBusy) void cancelGlobalSearch();
|
||||
activeView = "repository";
|
||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||
await refreshTags(activeRepoPath, bundle.tags);
|
||||
await refreshStashes(activeRepoPath, bundle.stashes);
|
||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||
lastRepoSwitchAt = Date.now();
|
||||
trackEvent("repository_opened", {
|
||||
changed_files: bundle.status.files.length,
|
||||
has_upstream: bundle.status.upstream ? 1 : 0,
|
||||
});
|
||||
void backgroundFetchRepo(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1106,6 +1338,7 @@
|
||||
if (globalSearchBusy) void cancelGlobalSearch();
|
||||
activeView = "repository";
|
||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||
await refreshTags(activeRepoPath, bundle.tags);
|
||||
await refreshStashes(activeRepoPath, bundle.stashes);
|
||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||
@@ -1151,6 +1384,7 @@
|
||||
function openRepoManagement() {
|
||||
if (isBusy) return;
|
||||
activeView = "management";
|
||||
void backgroundRepoStatusTick(false);
|
||||
}
|
||||
|
||||
async function selectRepoTab(path: string) {
|
||||
@@ -1183,6 +1417,11 @@
|
||||
if (isBusy) return;
|
||||
recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path));
|
||||
persistRepoLists();
|
||||
if (repoStatusCache[repoKey(path)]) {
|
||||
const { [repoKey(path)]: _removed, ...rest } = repoStatusCache;
|
||||
repoStatusCache = rest;
|
||||
persistRepoStatusCache();
|
||||
}
|
||||
if (repoTabs.some((tab) => sameRepoPath(tab.path, path))) {
|
||||
await closeRepoTab(path);
|
||||
}
|
||||
@@ -1327,7 +1566,7 @@
|
||||
}
|
||||
|
||||
async function rebaseOnto(branch: GitBranchInfo) {
|
||||
if (!activeRepoPath || branch.current || rebaseInProgress) return;
|
||||
if (!activeRepoPath || branch.current || rebaseInProgress || cherryPickInProgress) return;
|
||||
await runOperation(`Rebasing onto ${branch.name}`, async () => {
|
||||
applyStatus(await rebaseBranch(activeRepoPath, branch.name));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
@@ -1366,6 +1605,84 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function createNewTag(name: string, message: string) {
|
||||
const trimmed = name.trim();
|
||||
if (!activeRepoPath || !trimmed) return;
|
||||
await runOperation(`Creating tag ${trimmed}`, async () => {
|
||||
await refreshTags(activeRepoPath, await createTag(activeRepoPath, trimmed, undefined, message));
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteLocalTag(tag: GitTag) {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
const confirmed = window.confirm(`Delete tag '${tag.name}'?\n\nThis only removes the local tag, not any copy already pushed to a remote.`);
|
||||
if (!confirmed) return;
|
||||
|
||||
await runOperation(`Deleting tag ${tag.name}`, async () => {
|
||||
await refreshTags(activeRepoPath, await deleteTag(activeRepoPath, tag.name));
|
||||
});
|
||||
}
|
||||
|
||||
async function pushLocalTag(tag: GitTag) {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
const key = await currentCredKey();
|
||||
const stored = await loadStoredCredential(key);
|
||||
const credential = stored && !isCredentialExpired(stored) ? stored : null;
|
||||
|
||||
await runOperation(`Pushing tag ${tag.name}`, async () => {
|
||||
try {
|
||||
await pushTag(activeRepoPath, tag.name, credential?.username, credential?.password);
|
||||
} catch (error) {
|
||||
const message = errorToMessage(error);
|
||||
throw new Error(
|
||||
isAuthError(message)
|
||||
? `Sign in via the Push button first, then retry pushing tag '${tag.name}'.`
|
||||
: stripAuthPrefix(message),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function cherryPickFromCommit(commit: GitCommit) {
|
||||
if (!activeRepoPath || rebaseInProgress || cherryPickInProgress) return;
|
||||
await runOperation(`Cherry-picking ${commit.short_hash}`, async () => {
|
||||
applyStatus(await cherryPickCommit(activeRepoPath, commit.hash));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function continueCherryPick() {
|
||||
if (!activeRepoPath || !cherryPickInProgress || hasConflicts) return;
|
||||
await runOperation("Continuing cherry-pick", async () => {
|
||||
applyStatus(await cherryPickContinue(activeRepoPath));
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function abortCherryPick() {
|
||||
if (!activeRepoPath || !cherryPickInProgress) return;
|
||||
const confirmed = window.confirm("Abort the current cherry-pick and return to the previous state?");
|
||||
if (!confirmed) return;
|
||||
|
||||
await runOperation("Aborting cherry-pick", async () => {
|
||||
applyStatus(await cherryPickAbort(activeRepoPath));
|
||||
preparedResolutions = {};
|
||||
resolveDialogOpen = false;
|
||||
conflict = null;
|
||||
conflictTarget = "";
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the keychain key (host/org) for the active repo's remote.
|
||||
async function currentCredKey(): Promise<string | null> {
|
||||
if (!activeRepoPath) return null;
|
||||
@@ -1676,6 +1993,34 @@
|
||||
linePatchError = "";
|
||||
}
|
||||
|
||||
async function openBlame(node: ExplorerNode) {
|
||||
if (!activeRepoPath || node.kind !== "file") return;
|
||||
blameOpen = true;
|
||||
blameFilePath = node.path;
|
||||
blameLines = [];
|
||||
blameError = "";
|
||||
blameLoading = true;
|
||||
|
||||
try {
|
||||
const result = await getFileBlame(activeRepoPath, node.path);
|
||||
blameLines = result.lines;
|
||||
trackEvent("blame_opened", { lines: result.lines.length });
|
||||
} catch (error) {
|
||||
blameError = errorToMessage(error);
|
||||
errorMessage = blameError;
|
||||
} finally {
|
||||
blameLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeBlame() {
|
||||
if (isBusy) return;
|
||||
blameOpen = false;
|
||||
blameFilePath = "";
|
||||
blameLines = [];
|
||||
blameError = "";
|
||||
}
|
||||
|
||||
function patchOperationLabel(action: PatchApplyAction, file: GitFileStatus): string {
|
||||
switch (action) {
|
||||
case "stage":
|
||||
@@ -1774,7 +2119,8 @@
|
||||
|
||||
async function commitChanges() {
|
||||
const message = commitMessage.trim();
|
||||
if (!message || !activeRepoPath) return;
|
||||
if (!activeRepoPath) return;
|
||||
if (!amendMode && !message) return;
|
||||
if (hasConflicts) {
|
||||
errorMessage = "Resolve all conflicts before committing.";
|
||||
return;
|
||||
@@ -1783,6 +2129,28 @@
|
||||
errorMessage = "A rebase is in progress. Use Rebase continue or abort the rebase.";
|
||||
return;
|
||||
}
|
||||
if (cherryPickInProgress) {
|
||||
errorMessage = "A cherry-pick is in progress. Use Cherry-pick continue or abort it.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (amendMode) {
|
||||
await runOperation("Amending", async () => {
|
||||
applyStatus(await amendCommit(activeRepoPath, message));
|
||||
commitMessage = "";
|
||||
amendMode = false;
|
||||
preAmendDraftMessage = "";
|
||||
lastLocalAiGeneratedMessage = "";
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
trackEvent("commit_created", { amend: 1 });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const trackedStagedCount = stagedCount;
|
||||
await runOperation("Committing", async () => {
|
||||
applyStatus(await commit(activeRepoPath, message));
|
||||
commitMessage = "";
|
||||
@@ -1791,6 +2159,51 @@
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
trackEvent("commit_created", { amend: 0, staged_files: trackedStagedCount });
|
||||
});
|
||||
}
|
||||
|
||||
// Only offered when the last commit hasn't reached a remote yet (no upstream,
|
||||
// or the branch is ahead of it) — amending/undoing a pushed commit rewrites
|
||||
// history other clones already have, which needs a force-push to reconcile.
|
||||
async function toggleAmendMode(checked: boolean) {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
if (!checked) {
|
||||
amendMode = false;
|
||||
commitMessage = preAmendDraftMessage;
|
||||
preAmendDraftMessage = "";
|
||||
return;
|
||||
}
|
||||
if (!canAmend) return;
|
||||
|
||||
try {
|
||||
const message = await lastCommitMessage(activeRepoPath);
|
||||
preAmendDraftMessage = commitMessage;
|
||||
commitMessage = message ?? "";
|
||||
amendMode = true;
|
||||
} catch (error) {
|
||||
errorMessage = errorToMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function undoLastCommitChange() {
|
||||
if (!activeRepoPath || !canAmend || isBusy) return;
|
||||
const confirmed = window.confirm(
|
||||
"Undo the last commit?\n\nIts changes come back as uncommitted changes in the working tree — nothing is discarded.",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
await runOperation("Undoing last commit", async () => {
|
||||
applyStatus(await undoLastCommit(activeRepoPath));
|
||||
if (amendMode) {
|
||||
amendMode = false;
|
||||
commitMessage = preAmendDraftMessage;
|
||||
preAmendDraftMessage = "";
|
||||
}
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1925,6 +2338,12 @@
|
||||
function openCompareSelect() {
|
||||
if (!hasRepository) return;
|
||||
compareSelectOpen = true;
|
||||
trackEvent("compare_opened");
|
||||
}
|
||||
|
||||
function openGlobalSearchDialog() {
|
||||
globalSearchOpen = true;
|
||||
trackEvent("global_search_opened");
|
||||
}
|
||||
|
||||
async function compareSelectedCommits() {
|
||||
@@ -1992,6 +2411,7 @@
|
||||
const results = await searchCodeIntroductions(activeRepoPath, query, caseSensitive, limit, searchId);
|
||||
if (globalSearchId === searchId) {
|
||||
globalSearchResults = results;
|
||||
trackEvent("global_search_completed", { results: results.length, case_sensitive: caseSensitive ? 1 : 0 });
|
||||
}
|
||||
} catch (error) {
|
||||
if (globalSearchId === searchId) {
|
||||
@@ -2123,10 +2543,11 @@
|
||||
onPull={pullRepo}
|
||||
onPush={pushRepo}
|
||||
onRefresh={refreshRepo}
|
||||
onSearch={() => { globalSearchOpen = true; }}
|
||||
onSearch={openGlobalSearchDialog}
|
||||
onCompare={openCompareSelect}
|
||||
onOpenInExplorer={openActiveRepoInExplorer}
|
||||
onToggleAutoRefresh={toggleAutoRefresh}
|
||||
onOpenSettings={() => { appSettingsOpen = true; }}
|
||||
/>
|
||||
|
||||
<div class="shell-body">
|
||||
@@ -2201,7 +2622,7 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if workspaceActive && hasConflicts && !rebaseInProgress}
|
||||
{#if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress}
|
||||
<section class="notice conflict" role="alert">
|
||||
<GitMerge size={17} aria-hidden="true" />
|
||||
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span>
|
||||
@@ -2228,6 +2649,25 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if workspaceActive && cherryPickInProgress}
|
||||
<section class="notice rebase" role="status">
|
||||
<Cherry size={17} aria-hidden="true" />
|
||||
<span>
|
||||
Cherry-pick in progress.
|
||||
{#if hasConflicts}
|
||||
Resolve conflicts, then continue.
|
||||
{:else}
|
||||
Continue when the index is ready, or abort to return to the previous state.
|
||||
{/if}
|
||||
</span>
|
||||
{#if hasConflicts}
|
||||
<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>
|
||||
{/if}
|
||||
<button type="button" onclick={continueCherryPick} disabled={isBusy || hasConflicts}>Continue</button>
|
||||
<button type="button" onclick={abortCherryPick} disabled={isBusy}>Abort</button>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if activeView === "management"}
|
||||
<section class="repo-management" aria-label="Repository Management">
|
||||
<div class="repo-management-head">
|
||||
@@ -2305,7 +2745,12 @@
|
||||
<button class="repo-row-main" type="button" onclick={() => openRepo(repo.path)} disabled={isBusy}>
|
||||
<span class="repo-row-name">{repo.name}</span>
|
||||
<span class="repo-row-path">{repo.path}</span>
|
||||
<span class="repo-row-meta quiet">recent</span>
|
||||
<span class="repo-row-meta">
|
||||
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em class="quiet">recent</em>{/if}
|
||||
{#if repo.ahead > 0}<em class="ahead">↑ {repo.ahead}</em>{/if}
|
||||
{#if repo.behind > 0}<em class="behind">↓ {repo.behind}</em>{/if}
|
||||
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
|
||||
</span>
|
||||
</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}`}>
|
||||
<X size={14} aria-hidden="true" />
|
||||
@@ -2331,7 +2776,10 @@
|
||||
<span class="repo-row-name">{repo.name}</span>
|
||||
<span class="repo-row-path">{repo.path}</span>
|
||||
<span class="repo-row-meta">
|
||||
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em>known repo</em>{/if}
|
||||
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em class="quiet">known repo</em>{/if}
|
||||
{#if repo.ahead > 0}<em class="ahead">↑ {repo.ahead}</em>{/if}
|
||||
{#if repo.behind > 0}<em class="behind">↓ {repo.behind}</em>{/if}
|
||||
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
|
||||
</span>
|
||||
</button>
|
||||
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove" aria-label={`Remove ${repo.name}`}>
|
||||
@@ -2354,6 +2802,7 @@
|
||||
{branches}
|
||||
{localBranches}
|
||||
{remoteBranches}
|
||||
{tags}
|
||||
{hasRepository}
|
||||
{isBusy}
|
||||
onCheckout={checkout}
|
||||
@@ -2362,6 +2811,9 @@
|
||||
onCreateBranch={createNewBranch}
|
||||
onRenameBranch={renameLocalBranch}
|
||||
onDeleteBranch={deleteLocalBranch}
|
||||
onCreateTag={createNewTag}
|
||||
onDeleteTag={deleteLocalTag}
|
||||
onPushTag={pushLocalTag}
|
||||
/>
|
||||
<StashPanel
|
||||
{stashes}
|
||||
@@ -2385,6 +2837,7 @@
|
||||
onCollapseAllFolders={collapseAllExplorerFolders}
|
||||
onSelectNode={selectExplorerNode}
|
||||
onOpenFile={openFileFromExplorer}
|
||||
onBlame={openBlame}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
@@ -2451,10 +2904,14 @@
|
||||
commitAiProvider={aiSettings.provider}
|
||||
{commitAiPhase}
|
||||
{commitAiGenerating}
|
||||
{canAmend}
|
||||
{amendMode}
|
||||
onCommit={commitChanges}
|
||||
onCommitMessageChange={updateCommitMessage}
|
||||
onGenerateCommitMessage={generateCommitMessageWithAi}
|
||||
onOpenAiSettings={() => { aiSettingsOpen = true; }}
|
||||
onToggleAmend={toggleAmendMode}
|
||||
onUndoLastCommit={undoLastCommitChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -2483,6 +2940,7 @@
|
||||
{commits}
|
||||
{localBranchNames}
|
||||
activeBranch={status?.current_branch ?? ""}
|
||||
activeUpstream={status?.upstream ?? ""}
|
||||
repositoryKey={activeRepoPath}
|
||||
{hasRepository}
|
||||
{isBusy}
|
||||
@@ -2490,6 +2948,7 @@
|
||||
onRestoreCommit={restoreCommit}
|
||||
onPreviewCommitFile={previewCommitFileFromHistory}
|
||||
onCreateBranchFromCommit={openNewBranchDialog}
|
||||
onCherryPickCommit={cherryPickFromCommit}
|
||||
onToggleCommitFiles={(hash) => {
|
||||
const next = new Set(expandedCommitHashes);
|
||||
if (next.has(hash)) next.delete(hash); else next.add(hash);
|
||||
@@ -2525,6 +2984,21 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if analyticsNoticeOpen}
|
||||
<AnalyticsNoticeDialog
|
||||
enabled={analyticsSettings.enabled}
|
||||
onContinue={acceptAnalyticsNotice}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if appSettingsOpen}
|
||||
<AppSettingsDialog
|
||||
analytics={analyticsSettings}
|
||||
onSave={saveAppSettings}
|
||||
onClose={() => { appSettingsOpen = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if linePatchOpen && linePatchFile}
|
||||
<LinePatchDialog
|
||||
file={linePatchFile}
|
||||
@@ -2539,6 +3013,17 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if blameOpen}
|
||||
<BlameDialog
|
||||
filePath={blameFilePath}
|
||||
lines={blameLines}
|
||||
{isBusy}
|
||||
isLoading={blameLoading}
|
||||
error={blameError}
|
||||
onClose={closeBlame}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if pendingDiscard}
|
||||
<DiscardConfirmDialog
|
||||
file={pendingDiscard.file}
|
||||
|
||||
+366
-24
@@ -234,8 +234,7 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(4,8,18,0.58);
|
||||
backdrop-filter: blur(8px);
|
||||
background: #050712;
|
||||
}
|
||||
|
||||
.branch-filter-dialog {
|
||||
@@ -247,8 +246,8 @@
|
||||
border: 1px solid rgba(94,110,156,0.24);
|
||||
border-radius: 10px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.045), transparent 70%),
|
||||
var(--color-surface);
|
||||
linear-gradient(180deg, #20253f 0%, #151827 72%),
|
||||
#151827;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
@@ -440,7 +439,13 @@
|
||||
|
||||
/* --- App shell --- */
|
||||
|
||||
.shell { display: flex; flex-direction: column; height: 100%; background: transparent; }
|
||||
.shell {
|
||||
--app-titlebar-height: 42px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.shell-body {
|
||||
display: flex;
|
||||
@@ -454,10 +459,12 @@
|
||||
/* --- Titlebar --- */
|
||||
|
||||
.titlebar {
|
||||
position: relative;
|
||||
z-index: 500;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
height: 42px;
|
||||
height: var(--app-titlebar-height);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(100,108,255,0.16), rgba(65,209,255,0.08), rgba(255,211,67,0.035)),
|
||||
var(--color-bar);
|
||||
@@ -833,8 +840,8 @@
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
}
|
||||
.repo-row-meta .ahead { color: #4eca76; }
|
||||
.repo-row-meta .behind { color: #e0a040; }
|
||||
.repo-row-meta .ahead { color: #e0a040; background: rgba(224,160,64,0.13); }
|
||||
.repo-row-meta .behind { color: #7aacff; background: rgba(122,172,255,0.13); }
|
||||
.repo-row-icon {
|
||||
min-height: 36px;
|
||||
min-width: 36px;
|
||||
@@ -1550,6 +1557,12 @@
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tag-group-head { display: flex; align-items: center; gap: 6px; }
|
||||
.tag-group-head .branch-group-toggle { flex: 1; min-width: 0; }
|
||||
.tag-group-head .branch-create-toggle { flex-shrink: 0; }
|
||||
|
||||
.tag-create-form { grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto auto; }
|
||||
|
||||
.branch-empty {
|
||||
padding: 8px 10px;
|
||||
color: var(--color-ink-faint);
|
||||
@@ -1801,6 +1814,56 @@
|
||||
resize: none;
|
||||
overflow: auto;
|
||||
}
|
||||
.commit-amend-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex: 0 0 auto;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.commit-amend-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--color-ink-dim);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.commit-amend-toggle input {
|
||||
appearance: none;
|
||||
flex: 0 0 auto;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
min-width: 14px;
|
||||
min-height: 14px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(94,110,156,0.42);
|
||||
border-radius: 4px;
|
||||
background: rgba(13,14,26,0.78);
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.commit-amend-toggle input:checked {
|
||||
border-color: rgba(65,209,255,0.62);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(65,209,255,0.92), rgba(100,108,255,0.92));
|
||||
box-shadow: inset 0 0 0 2px rgba(13,14,26,0.82);
|
||||
}
|
||||
.commit-amend-toggle input:focus {
|
||||
box-shadow: 0 0 0 2px rgba(65,209,255,0.2);
|
||||
}
|
||||
.commit-amend-toggle input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.commit-undo-button {
|
||||
flex: 0 0 auto;
|
||||
min-height: 26px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.commit-actions-row {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
@@ -2185,6 +2248,15 @@
|
||||
.graph-svg path.hidden-branch {
|
||||
opacity: 0.08;
|
||||
}
|
||||
.graph-svg path.graph-segment-ahead {
|
||||
stroke: #e0a040;
|
||||
filter: drop-shadow(0 0 3px rgba(224,160,64,0.24));
|
||||
}
|
||||
.graph-svg path.graph-segment-behind {
|
||||
stroke: #7aacff;
|
||||
stroke-dasharray: 4 4;
|
||||
filter: drop-shadow(0 0 3px rgba(122,172,255,0.24));
|
||||
}
|
||||
.graph-dot {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
@@ -2202,6 +2274,14 @@
|
||||
opacity: 0.16;
|
||||
box-shadow: none;
|
||||
}
|
||||
.graph-dot.ahead {
|
||||
background: #e0a040;
|
||||
box-shadow: 0 0 0 1px rgba(224,160,64,0.42), 0 0 10px rgba(224,160,64,0.16);
|
||||
}
|
||||
.graph-dot.behind {
|
||||
background: #7aacff;
|
||||
box-shadow: 0 0 0 1px rgba(122,172,255,0.46), 0 0 10px rgba(122,172,255,0.18);
|
||||
}
|
||||
.graph-dot.tip { width: 14px; height: 14px; box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); }
|
||||
.graph-dot.merge {
|
||||
width: 15px;
|
||||
@@ -2247,6 +2327,22 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.graph-hover-branches span.remote,
|
||||
.branch-filter-option.remote {
|
||||
border-color: rgba(122,172,255,0.32);
|
||||
color: #bcd2ff;
|
||||
background: rgba(31,43,72,0.88);
|
||||
}
|
||||
.graph-hover-branches span.ahead {
|
||||
border-color: rgba(224,160,64,0.42);
|
||||
color: #ffd99a;
|
||||
background: rgba(58,42,20,0.94);
|
||||
}
|
||||
.graph-hover-branches span.behind {
|
||||
border-color: rgba(122,172,255,0.46);
|
||||
color: #c7dbff;
|
||||
background: rgba(25,39,70,0.94);
|
||||
}
|
||||
.graph-hover-branches svg {
|
||||
flex: 0 0 auto;
|
||||
color: #76d995;
|
||||
@@ -2260,6 +2356,12 @@
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(94,110,156,0.1); }
|
||||
.graph-row.graph-ahead-row .commit-body {
|
||||
box-shadow: inset 3px 0 0 rgba(224,160,64,0.72);
|
||||
}
|
||||
.graph-row.graph-behind-row .commit-body {
|
||||
box-shadow: inset 3px 0 0 rgba(122,172,255,0.72);
|
||||
}
|
||||
.graph-row:hover .commit-body { background: rgba(37,40,62,0.54); }
|
||||
.graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; }
|
||||
.graph-row:hover .graph-svg path.hidden-branch { opacity: 0.12; stroke-width: 2.2; }
|
||||
@@ -2321,8 +2423,12 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(2px);
|
||||
background-color: #050712;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.app-chrome-backdrop {
|
||||
top: var(--app-titlebar-height, 42px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
@@ -2332,7 +2438,8 @@
|
||||
height: min(760px, 100%);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
background: var(--color-surface);
|
||||
background-color: #111321;
|
||||
background-image: none;
|
||||
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.6), 0 2px 12px rgba(0,0,0,0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -2349,6 +2456,11 @@
|
||||
width: min(1320px, calc(100vw - 32px));
|
||||
height: min(860px, calc(100vh - 32px));
|
||||
}
|
||||
.blame-dialog {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
width: min(1320px, calc(100vw - 32px));
|
||||
height: min(860px, calc(100vh - 32px));
|
||||
}
|
||||
.compare-select-dialog {
|
||||
display: block;
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
@@ -2385,6 +2497,14 @@
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
}
|
||||
.app-settings-dialog,
|
||||
.analytics-notice-dialog {
|
||||
display: block;
|
||||
width: min(620px, calc(100vw - 32px));
|
||||
height: auto;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
}
|
||||
.clone-repository-dialog {
|
||||
display: block;
|
||||
width: min(620px, calc(100vw - 32px));
|
||||
@@ -2439,6 +2559,101 @@
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
.app-settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
.settings-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.settings-section > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.settings-section > header svg {
|
||||
color: var(--color-accent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.settings-section h3 {
|
||||
margin: 1px 0 0;
|
||||
color: var(--color-ink);
|
||||
font-size: 15px;
|
||||
font-weight: 750;
|
||||
}
|
||||
.settings-toggle-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 8px;
|
||||
background: #151827;
|
||||
color: var(--color-ink-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.settings-toggle-row input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 2px;
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
.settings-toggle-row span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.settings-toggle-row strong {
|
||||
color: var(--color-ink);
|
||||
font-size: 13px;
|
||||
}
|
||||
.settings-toggle-row small {
|
||||
color: var(--color-ink-dim);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.analytics-notice-body {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
padding: 18px 16px 16px;
|
||||
}
|
||||
.analytics-notice-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 1px solid rgba(65,209,255,0.28);
|
||||
border-radius: 10px;
|
||||
color: var(--color-accent);
|
||||
background: rgba(65,209,255,0.08);
|
||||
}
|
||||
.analytics-notice-copy {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.analytics-notice-copy p {
|
||||
margin: 0;
|
||||
}
|
||||
.analytics-notice-toggle {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.analytics-notice-footer {
|
||||
align-items: center;
|
||||
}
|
||||
.ai-provider-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -2548,7 +2763,7 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
|
||||
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: #171a2b; }
|
||||
.dialog-header > div:first-child { min-width: 0; }
|
||||
.dialog-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex: 0 0 auto; min-width: 0; }
|
||||
.compare-restore { max-width: 170px; min-width: 0; }
|
||||
@@ -2562,7 +2777,7 @@
|
||||
.dialog-body { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 0; }
|
||||
.compare-dialog .dialog-body { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); }
|
||||
|
||||
.dialog-files { display: grid; align-content: start; gap: 4px; padding: 8px; overflow: auto; border-right: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
|
||||
.dialog-files { display: grid; align-content: start; gap: 4px; padding: 8px; overflow: auto; border-right: 1px solid var(--color-border-subtle); background: #171a2b; }
|
||||
|
||||
.dialog-file-row {
|
||||
display: grid;
|
||||
@@ -2592,7 +2807,7 @@
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
background: var(--color-surface-dim);
|
||||
background: #171a2b;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--color-ink-muted);
|
||||
@@ -2614,7 +2829,7 @@
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
tab-size: 2;
|
||||
background: var(--color-surface-raised);
|
||||
background: #111321;
|
||||
}
|
||||
|
||||
.split-pane {
|
||||
@@ -2638,7 +2853,7 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.split-span.split-meta { color: var(--color-ink-faint); background: rgba(0,0,0,0.18); font-size: 11px; }
|
||||
.split-span.split-meta { color: var(--color-ink-faint); background: #0c0e18; font-size: 11px; }
|
||||
.split-span.split-hunk { color: #7aacff; background: rgba(122,172,255,0.08); padding: 3px 10px; }
|
||||
|
||||
.split-num {
|
||||
@@ -2648,11 +2863,11 @@
|
||||
font-size: 11px;
|
||||
user-select: none;
|
||||
border-right: 1px solid var(--color-border-subtle);
|
||||
background: rgba(0,0,0,0.14);
|
||||
background: #0d101a;
|
||||
}
|
||||
.split-num.del { background: rgba(232,96,96,0.12); color: rgba(232,96,96,0.6); border-right-color: rgba(232,96,96,0.2); }
|
||||
.split-num.add { background: rgba(78,202,118,0.1); color: rgba(78,202,118,0.6); border-right-color: rgba(78,202,118,0.2); }
|
||||
.split-num.empty { background: rgba(0,0,0,0.08); }
|
||||
.split-num.empty { background: #10131e; }
|
||||
|
||||
.split-cell {
|
||||
padding: 0 8px;
|
||||
@@ -2663,7 +2878,7 @@
|
||||
}
|
||||
.split-cell.del { background: rgba(232,96,96,0.1); color: #ef8080; }
|
||||
.split-cell.add { background: rgba(78,202,118,0.09); color: #5dd88a; }
|
||||
.split-cell.empty { background: rgba(0,0,0,0.06); }
|
||||
.split-cell.empty { background: #10131e; }
|
||||
|
||||
/* Search-hit highlight: amber, distinct from add (green) / del (red).
|
||||
Higher specificity so it overrides the add/del backgrounds on a matched line. */
|
||||
@@ -2694,7 +2909,7 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-ink-faint);
|
||||
background: rgba(0,0,0,0.1);
|
||||
background: #0d101a;
|
||||
}
|
||||
.split-col-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); }
|
||||
.split-col-hash {
|
||||
@@ -2710,7 +2925,7 @@
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
|
||||
.dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-border-subtle); background: #171a2b; }
|
||||
.dialog-footer-info { color: var(--color-ink-dim); font-size: 13px; font-weight: 700; }
|
||||
|
||||
.prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; }
|
||||
@@ -2885,6 +3100,134 @@
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.blame-body {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #111321;
|
||||
}
|
||||
|
||||
.blame-code-header strong {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.blame-column-headers {
|
||||
grid-template-columns: minmax(300px, 340px) minmax(0, 1fr);
|
||||
}
|
||||
.blame-commit-col-label {
|
||||
padding-left: 12px;
|
||||
}
|
||||
.blame-code-col-label {
|
||||
padding-left: calc(3.2rem + 10px);
|
||||
}
|
||||
.blame-commit-col-label + .blame-code-col-label {
|
||||
border-left: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.blame-diff {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.blame-scroll {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.blame-code-table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.blame-group {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 340px) minmax(max-content, 1fr);
|
||||
align-items: stretch;
|
||||
min-width: max-content;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.blame-group.uncommitted {
|
||||
background: #171725;
|
||||
}
|
||||
|
||||
.blame-meta {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 8px 12px;
|
||||
border-right: 1px solid var(--color-border-subtle);
|
||||
background: #0d101a;
|
||||
box-shadow: 8px 0 18px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.blame-hash {
|
||||
align-self: flex-start;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid rgba(90,140,248,0.2);
|
||||
border-radius: 5px;
|
||||
background: #141b2d;
|
||||
color: var(--color-accent);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.blame-author {
|
||||
overflow: hidden;
|
||||
color: var(--color-ink);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.blame-summary {
|
||||
overflow: hidden;
|
||||
color: #aeb6d8;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.blame-date {
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.blame-group:hover .blame-meta {
|
||||
background: #111728;
|
||||
}
|
||||
.blame-group.uncommitted .blame-hash,
|
||||
.blame-group.uncommitted .blame-author {
|
||||
color: #e8b45a;
|
||||
}
|
||||
.blame-group.uncommitted .blame-hash {
|
||||
border-color: rgba(232, 180, 90, 0.26);
|
||||
background: #271f14;
|
||||
}
|
||||
|
||||
.blame-lines {
|
||||
min-width: max-content;
|
||||
grid-template-columns: 4rem minmax(max-content, 1fr);
|
||||
}
|
||||
.blame-line-number {
|
||||
min-height: 20px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
.blame-line-code {
|
||||
min-height: 20px;
|
||||
}
|
||||
.blame-group:hover .blame-line-number {
|
||||
background: #111728;
|
||||
}
|
||||
.blame-group:hover .blame-line-code {
|
||||
background: #151a2b;
|
||||
}
|
||||
|
||||
.global-search-body {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
@@ -3275,11 +3618,10 @@
|
||||
border: 1px solid rgba(100, 108, 255, 0.36);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.06), transparent 42%),
|
||||
rgba(15, 16, 28, 0.96);
|
||||
linear-gradient(180deg, #222743 0%, #111321 42%),
|
||||
#111321;
|
||||
box-shadow: 0 32px 90px rgba(0, 0, 0, 0.68), 0 0 0 1px rgba(255,255,255,0.04) inset;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.cred-hero {
|
||||
|
||||
+12
-1
@@ -2,7 +2,7 @@
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
|
||||
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte";
|
||||
|
||||
export let branch: string = "";
|
||||
export let ahead: number = 0;
|
||||
@@ -21,6 +21,7 @@
|
||||
export let onCompare: () => void = () => {};
|
||||
export let onOpenInExplorer: () => void = () => {};
|
||||
export let onToggleAutoRefresh: () => void = () => {};
|
||||
export let onOpenSettings: () => void = () => {};
|
||||
|
||||
const win = getCurrentWindow();
|
||||
let isMaximized = false;
|
||||
@@ -198,6 +199,16 @@
|
||||
/>
|
||||
<span class="tb-action-label">Auto</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onOpenSettings}
|
||||
title="Settings"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<Settings size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="titlebar-divider" aria-hidden="true"></div>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export type AnalyticsEventProperties = Record<string, string | number>;
|
||||
|
||||
export function trackAnalyticsEvent(name: string, props?: AnalyticsEventProperties) {
|
||||
void invoke("plugin:aptabase|track_event", {
|
||||
name,
|
||||
props: props && Object.keys(props).length > 0 ? props : undefined,
|
||||
}).catch(() => {
|
||||
// Analytics must never interrupt the Git workflow.
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { BarChart3, Check, ShieldCheck } from "@lucide/svelte";
|
||||
|
||||
interface Props {
|
||||
enabled: boolean;
|
||||
onContinue: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
let { enabled = true, onContinue = () => {} }: Props = $props();
|
||||
let allowAnalytics = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
allowAnalytics = enabled;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||
<div class="dialog analytics-notice-dialog" role="dialog" aria-modal="true" aria-label="Analytics notice" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Privacy</span>
|
||||
<h2 class="dialog-title">Anonymous usage analytics</h2>
|
||||
</div>
|
||||
<ShieldCheck size={20} aria-hidden="true" />
|
||||
</header>
|
||||
|
||||
<div class="analytics-notice-body">
|
||||
<div class="analytics-notice-icon">
|
||||
<BarChart3 size={24} aria-hidden="true" />
|
||||
</div>
|
||||
<div class="analytics-notice-copy">
|
||||
<p>
|
||||
GitLite can send anonymous usage events to Aptabase so crashes, rough edges, and commonly used workflows are easier to improve.
|
||||
</p>
|
||||
<p>
|
||||
Events do not include repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, or source code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="settings-toggle-row analytics-notice-toggle">
|
||||
<input type="checkbox" bind:checked={allowAnalytics} />
|
||||
<span>
|
||||
<strong>Allow anonymous analytics</strong>
|
||||
<small>You can change this later in Settings.</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<footer class="dialog-footer analytics-notice-footer">
|
||||
<span class="dialog-footer-info">Privacy-friendly, optional, and limited to product usage events.</span>
|
||||
<button class="btn-primary" type="button" onclick={() => onContinue(allowAnalytics)}>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
Continue
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { Check, Settings, X } from "@lucide/svelte";
|
||||
import type { AnalyticsSettings } from "../types";
|
||||
|
||||
interface Props {
|
||||
analytics: AnalyticsSettings;
|
||||
onSave: (settings: AnalyticsSettings) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { analytics, onSave = () => {}, onClose = () => {} }: Props = $props();
|
||||
|
||||
let analyticsEnabled = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
analyticsEnabled = analytics.enabled;
|
||||
});
|
||||
|
||||
function save() {
|
||||
onSave({
|
||||
...analytics,
|
||||
enabled: analyticsEnabled,
|
||||
noticeSeen: true,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">GitLite</span>
|
||||
<h2 class="dialog-title">Settings</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<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">Analytics</span>
|
||||
<h3>Anonymous usage analytics</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<label class="settings-toggle-row">
|
||||
<input type="checkbox" bind:checked={analyticsEnabled} />
|
||||
<span>
|
||||
<strong>Allow anonymous Aptabase events</strong>
|
||||
<small>No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent.</small>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="new-branch-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose}>Cancel</button>
|
||||
<button class="btn-primary" type="submit">
|
||||
<Check size={16} aria-hidden="true" />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { FileCode, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { GitBlameLine } from "../types";
|
||||
|
||||
interface BlameGroup {
|
||||
id: string;
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
authorName: string;
|
||||
authorEmail: string;
|
||||
summary: string;
|
||||
authorTime: number;
|
||||
isUncommitted: boolean;
|
||||
lines: GitBlameLine[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
filePath: string;
|
||||
lines: GitBlameLine[];
|
||||
isBusy: boolean;
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
filePath = "",
|
||||
lines = [],
|
||||
isBusy = false,
|
||||
isLoading = false,
|
||||
error = "",
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function groupBlameLines(source: GitBlameLine[]): BlameGroup[] {
|
||||
const groups: BlameGroup[] = [];
|
||||
for (const line of source) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.hash === line.commit_hash) {
|
||||
last.lines.push(line);
|
||||
continue;
|
||||
}
|
||||
groups.push({
|
||||
id: `${line.commit_hash}-${line.line_number}`,
|
||||
hash: line.commit_hash,
|
||||
shortHash: line.short_hash,
|
||||
authorName: line.author_name,
|
||||
authorEmail: line.author_email,
|
||||
summary: line.summary,
|
||||
authorTime: line.author_time,
|
||||
isUncommitted: line.is_uncommitted,
|
||||
lines: [line],
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function formatBlameDate(seconds: number): string {
|
||||
if (!seconds) return "";
|
||||
const date = new Date(seconds * 1000);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||
}
|
||||
|
||||
function groupTooltip(group: BlameGroup): string {
|
||||
if (group.isUncommitted) return "Not committed yet";
|
||||
return `${group.authorName} <${group.authorEmail}>\n${group.summary}\n${group.hash}`;
|
||||
}
|
||||
|
||||
let groups = $derived(groupBlameLines(lines));
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog blame-dialog" role="dialog" aria-modal="true" aria-label="File blame">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Blame</span>
|
||||
<p class="dialog-title" title={filePath}>{filePath}</p>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
<span class="pill pill-count">{lines.length}</span>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="dialog-diff blame-body">
|
||||
{#if isLoading}
|
||||
<div class="blank-state">
|
||||
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
||||
Loading blame...
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="blank-state">{error}</div>
|
||||
{:else if groups.length === 0}
|
||||
<div class="blank-state">No blame information available for this file.</div>
|
||||
{:else}
|
||||
<div class="diff-header blame-code-header">
|
||||
<FileCode size={13} aria-hidden="true" />
|
||||
<span title={filePath}>{filePath}</span>
|
||||
<strong>{lines.length} lines</strong>
|
||||
</div>
|
||||
<div class="split-col-headers blame-column-headers">
|
||||
<div class="split-col-label blame-commit-col-label">Commit</div>
|
||||
<div class="split-col-label blame-code-col-label">Code</div>
|
||||
</div>
|
||||
<div class="split-diff blame-diff" role="table" aria-label="File blame">
|
||||
<div class="split-pane blame-scroll">
|
||||
<div class="blame-code-table">
|
||||
{#each groups as group (group.id)}
|
||||
<div class="blame-group" class:uncommitted={group.isUncommitted} title={groupTooltip(group)}>
|
||||
<div class="blame-meta">
|
||||
<span class="blame-hash">{group.isUncommitted ? "Uncommitted" : group.shortHash}</span>
|
||||
<span class="blame-author">{group.isUncommitted ? "Not committed yet" : group.authorName}</span>
|
||||
<span class="blame-summary">{group.summary || "No commit message"}</span>
|
||||
{#if !group.isUncommitted}
|
||||
<span class="blame-date">{formatBlameDate(group.authorTime)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="split-pane-grid blame-lines">
|
||||
{#each group.lines as line (line.line_number)}
|
||||
<span class="split-num blame-line-number">{line.line_number}</span>
|
||||
<code class="split-cell blame-line-code">{line.content}</code>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Trash2, X } from "@lucide/svelte";
|
||||
import type { GitBranch as GitBranchInfo } from "../types";
|
||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
|
||||
|
||||
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
branches: GitBranchInfo[];
|
||||
localBranches: GitBranchInfo[];
|
||||
remoteBranches: GitBranchInfo[];
|
||||
tags: GitTag[];
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
onCheckout: (branch: GitBranchInfo) => void;
|
||||
@@ -53,12 +54,16 @@
|
||||
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||
onCreateTag: (name: string, message: string) => void | Promise<void>;
|
||||
onDeleteTag: (tag: GitTag) => void | Promise<void>;
|
||||
onPushTag: (tag: GitTag) => void | Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
branches = [],
|
||||
localBranches = [],
|
||||
remoteBranches = [],
|
||||
tags = [],
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
onCheckout = () => {},
|
||||
@@ -67,17 +72,28 @@
|
||||
onCreateBranch = () => {},
|
||||
onRenameBranch = () => {},
|
||||
onDeleteBranch = () => {},
|
||||
onCreateTag = () => {},
|
||||
onDeleteTag = () => {},
|
||||
onPushTag = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let localOpen = $state(true);
|
||||
let remoteOpen = $state(false);
|
||||
let tagsOpen = $state(false);
|
||||
let createOpen = $state(false);
|
||||
let newBranchName = $state("");
|
||||
let createInput = $state<HTMLInputElement | null>(null);
|
||||
let tagCreateOpen = $state(false);
|
||||
let newTagName = $state("");
|
||||
let newTagMessage = $state("");
|
||||
let tagCreateInput = $state<HTMLInputElement | null>(null);
|
||||
let panelElement = $state<HTMLElement | null>(null);
|
||||
let contextBranch = $state<GitBranchInfo | null>(null);
|
||||
let contextMenuX = $state(0);
|
||||
let contextMenuY = $state(0);
|
||||
let contextTag = $state<GitTag | null>(null);
|
||||
let tagContextMenuX = $state(0);
|
||||
let tagContextMenuY = $state(0);
|
||||
let collapsedBranchFolders = $state<Set<string>>(new Set());
|
||||
|
||||
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
|
||||
@@ -270,12 +286,74 @@
|
||||
await onRebase(branch);
|
||||
}
|
||||
|
||||
function openTagCreateForm() {
|
||||
if (!hasRepository || isBusy) return;
|
||||
tagCreateOpen = true;
|
||||
queueMicrotask(() => tagCreateInput?.focus());
|
||||
}
|
||||
|
||||
function closeTagCreateForm() {
|
||||
tagCreateOpen = false;
|
||||
newTagName = "";
|
||||
newTagMessage = "";
|
||||
}
|
||||
|
||||
async function submitCreateTag(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const name = newTagName.trim();
|
||||
if (!name || !hasRepository || isBusy) return;
|
||||
await onCreateTag(name, newTagMessage.trim());
|
||||
newTagName = "";
|
||||
newTagMessage = "";
|
||||
tagCreateOpen = false;
|
||||
tagsOpen = true;
|
||||
}
|
||||
|
||||
function openTagContextMenu(event: MouseEvent, tag: GitTag) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (isBusy) return;
|
||||
|
||||
const rect = panelElement?.getBoundingClientRect();
|
||||
const rawX = rect ? event.clientX - rect.left : event.offsetX;
|
||||
const rawY = rect ? event.clientY - rect.top : event.offsetY;
|
||||
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
|
||||
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 130);
|
||||
|
||||
contextTag = tag;
|
||||
tagContextMenuX = Math.max(8, Math.min(rawX, maxX));
|
||||
tagContextMenuY = Math.max(8, Math.min(rawY, maxY));
|
||||
}
|
||||
|
||||
function closeTagContextMenu() {
|
||||
contextTag = null;
|
||||
}
|
||||
|
||||
async function pushContextTag() {
|
||||
const tag = contextTag;
|
||||
if (!tag || isBusy) return;
|
||||
closeTagContextMenu();
|
||||
await onPushTag(tag);
|
||||
}
|
||||
|
||||
async function deleteContextTag() {
|
||||
const tag = contextTag;
|
||||
if (!tag || isBusy) return;
|
||||
closeTagContextMenu();
|
||||
await onDeleteTag(tag);
|
||||
}
|
||||
|
||||
function closeAllContextMenus() {
|
||||
closeBranchContextMenu();
|
||||
closeTagContextMenu();
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeBranchContextMenu();
|
||||
if (event.key === "Escape") closeAllContextMenus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeBranchContextMenu} />
|
||||
<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">
|
||||
<div class="section-head">
|
||||
@@ -300,7 +378,7 @@
|
||||
|
||||
{#if !hasRepository}
|
||||
<p class="blank-state">Open a repository to list branches.</p>
|
||||
{:else if branches.length === 0}
|
||||
{:else if branches.length === 0 && tags.length === 0}
|
||||
<p class="blank-state">No branches returned.</p>
|
||||
{:else}
|
||||
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
||||
@@ -458,6 +536,86 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="branch-group">
|
||||
<div class="tag-group-head">
|
||||
<button
|
||||
class="branch-group-toggle"
|
||||
type="button"
|
||||
onclick={() => { tagsOpen = !tagsOpen; }}
|
||||
aria-expanded={tagsOpen}
|
||||
>
|
||||
{#if tagsOpen}
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
<span>Tags</span>
|
||||
<span class="branch-group-count">{tags.length}</span>
|
||||
</button>
|
||||
<button
|
||||
class="branch-create-toggle"
|
||||
type="button"
|
||||
onclick={openTagCreateForm}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Create new tag"
|
||||
aria-label="Create new tag"
|
||||
>
|
||||
<Plus size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if tagCreateOpen}
|
||||
<form class="branch-create-form tag-create-form" onsubmit={submitCreateTag}>
|
||||
<TagIcon size={15} aria-hidden="true" />
|
||||
<input
|
||||
bind:this={tagCreateInput}
|
||||
bind:value={newTagName}
|
||||
disabled={isBusy}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="v1.0.0"
|
||||
aria-label="New tag name"
|
||||
/>
|
||||
<input
|
||||
bind:value={newTagMessage}
|
||||
disabled={isBusy}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="Message (optional)"
|
||||
aria-label="Tag message"
|
||||
/>
|
||||
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newTagName.trim().length === 0} title="Create tag">
|
||||
<Check size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button class="branch-create-action" type="button" onclick={closeTagCreateForm} disabled={isBusy} title="Cancel">
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if tagsOpen}
|
||||
{#if tags.length === 0}
|
||||
<div class="branch-empty">No tags.</div>
|
||||
{:else}
|
||||
{#each tags as tag (tag.name)}
|
||||
<article
|
||||
class="branch-row"
|
||||
oncontextmenu={(event) => openTagContextMenu(event, tag)}
|
||||
title={tag.message ?? tag.name}
|
||||
>
|
||||
<div class="branch-info">
|
||||
<TagIcon size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{tag.name}</strong>
|
||||
<span>{tag.short_hash}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -499,4 +657,24 @@
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if contextTag}
|
||||
<div
|
||||
class="branch-context-menu"
|
||||
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
aria-label={`Actions for ${contextTag.name}`}
|
||||
>
|
||||
<button type="button" role="menuitem" onclick={pushContextTag} disabled={isBusy}>
|
||||
<Upload size={14} aria-hidden="true" />
|
||||
Push to remote
|
||||
</button>
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
<button class="danger" type="button" role="menuitem" onclick={deleteContextTag} disabled={isBusy} title="Delete local tag">
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Check, LoaderCircle, Settings, Sparkles } from "@lucide/svelte";
|
||||
import { Check, LoaderCircle, RotateCcw, Settings, Sparkles } from "@lucide/svelte";
|
||||
import type { CommitAiPhase, CommitAiProvider } from "../types";
|
||||
|
||||
interface Props {
|
||||
@@ -13,10 +13,14 @@
|
||||
commitAiProvider: CommitAiProvider;
|
||||
commitAiPhase: CommitAiPhase;
|
||||
commitAiGenerating: boolean;
|
||||
canAmend: boolean;
|
||||
amendMode: boolean;
|
||||
onCommit: () => void;
|
||||
onCommitMessageChange: (msg: string) => void;
|
||||
onGenerateCommitMessage: () => void;
|
||||
onOpenAiSettings: () => void;
|
||||
onToggleAmend: (checked: boolean) => void;
|
||||
onUndoLastCommit: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -30,10 +34,14 @@
|
||||
commitAiProvider = "local",
|
||||
commitAiPhase = "idle",
|
||||
commitAiGenerating = false,
|
||||
canAmend = false,
|
||||
amendMode = false,
|
||||
onCommit = () => {},
|
||||
onCommitMessageChange = () => {},
|
||||
onGenerateCommitMessage = () => {},
|
||||
onOpenAiSettings = () => {},
|
||||
onToggleAmend = () => {},
|
||||
onUndoLastCommit = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
@@ -77,14 +85,37 @@
|
||||
{#if commitBlockReason}
|
||||
<p class="commit-block-reason">{commitBlockReason}</p>
|
||||
{/if}
|
||||
{#if canAmend}
|
||||
<div class="commit-amend-row">
|
||||
<label class="commit-amend-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={amendMode}
|
||||
disabled={isBusy}
|
||||
onchange={(e) => onToggleAmend((e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
Amend last commit
|
||||
</label>
|
||||
<button
|
||||
class="btn-secondary commit-undo-button"
|
||||
type="button"
|
||||
onclick={onUndoLastCommit}
|
||||
disabled={isBusy}
|
||||
title="Undo the last commit — its changes come back as uncommitted changes (not pushed yet, so safe)"
|
||||
>
|
||||
<RotateCcw size={13} aria-hidden="true" />
|
||||
Undo last commit
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="commit-actions-row flex-shrink-0">
|
||||
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
|
||||
{#if operation === "Committing"}
|
||||
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || (amendMode ? "Amend the last commit" : "Commit staged changes")}>
|
||||
{#if operation === "Committing" || operation === "Amending"}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Commit
|
||||
{amendMode ? "Amend" : "Commit"}
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary commit-ai-button"
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
let username = $state("");
|
||||
let password = $state("");
|
||||
let showPassword = $state(false);
|
||||
let saveSession = $state(false);
|
||||
let saveSession = $state(true);
|
||||
let expiresAt = $state("");
|
||||
|
||||
let canSubmit = $derived(
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ExternalLink,
|
||||
History,
|
||||
Terminal,
|
||||
} from "@lucide/svelte";
|
||||
import { languageIconForPath } from "../languageIcons";
|
||||
@@ -36,6 +37,7 @@
|
||||
onCollapseAllFolders: () => void;
|
||||
onSelectNode: (node: ExplorerNode) => void;
|
||||
onOpenFile: (node: ExplorerNode) => void;
|
||||
onBlame: (node: ExplorerNode) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -50,6 +52,7 @@
|
||||
onCollapseAllFolders = () => {},
|
||||
onSelectNode = () => {},
|
||||
onOpenFile = () => {},
|
||||
onBlame = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let contextNode = $state<ExplorerNode | null>(null);
|
||||
@@ -182,6 +185,13 @@
|
||||
onOpenFile(node);
|
||||
}
|
||||
|
||||
function openContextBlame() {
|
||||
const node = contextNode;
|
||||
if (!node || node.kind !== "file") return;
|
||||
closeFileContextMenu();
|
||||
onBlame(node);
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeFileContextMenu();
|
||||
}
|
||||
@@ -335,5 +345,15 @@
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
Open in Explorer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={openContextBlame}
|
||||
disabled={!contextNode.tracked || contextNode.status === "deleted"}
|
||||
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
|
||||
>
|
||||
<History size={14} aria-hidden="true" />
|
||||
Blame
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
|
||||
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
|
||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||
|
||||
interface GraphSegment {
|
||||
@@ -32,6 +32,7 @@
|
||||
commits: GitCommit[];
|
||||
localBranchNames: string[];
|
||||
activeBranch: string;
|
||||
activeUpstream: string;
|
||||
repositoryKey: string;
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
@@ -40,12 +41,14 @@
|
||||
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
||||
onToggleCommitFiles: (hash: string) => void;
|
||||
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
||||
onCherryPickCommit: (commit: GitCommit) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
commits = [],
|
||||
localBranchNames = [],
|
||||
activeBranch = "",
|
||||
activeUpstream = "",
|
||||
repositoryKey = "",
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
@@ -54,6 +57,7 @@
|
||||
onPreviewCommitFile = () => {},
|
||||
onToggleCommitFiles = () => {},
|
||||
onCreateBranchFromCommit = () => {},
|
||||
onCherryPickCommit = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let hiddenGraphBranches = $state<Set<string>>(new Set());
|
||||
@@ -199,7 +203,13 @@
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, activeUpstream].filter(Boolean)));
|
||||
let graphBranchNameSet = $derived(new Set(graphBranchNames));
|
||||
|
||||
function branchIsVisible(branch: string): boolean {
|
||||
if (branch === activeUpstream && activeUpstream) {
|
||||
return !activeBranch || !hiddenGraphBranches.has(activeBranch);
|
||||
}
|
||||
return !hiddenGraphBranches.has(branch);
|
||||
}
|
||||
|
||||
@@ -208,7 +218,7 @@
|
||||
}
|
||||
|
||||
function commitHoverBranchLabels(commit: GitCommit, row: GraphRow | undefined): string[] {
|
||||
const directBranches = localBranchRefs(commit);
|
||||
const directBranches = graphBranchRefs(commit);
|
||||
if (directBranches.length > 0) return directBranches;
|
||||
|
||||
const containingBranches = row?.branchLabels ?? [];
|
||||
@@ -217,7 +227,7 @@
|
||||
}
|
||||
|
||||
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
||||
const directBranches = localBranchRefs(commit);
|
||||
const directBranches = graphBranchRefs(commit);
|
||||
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
|
||||
|
||||
const containingBranches = row?.branchLabels ?? [];
|
||||
@@ -229,8 +239,17 @@
|
||||
return segment.branches.length === 0 || segment.branches.some(branchIsVisible);
|
||||
}
|
||||
|
||||
function syncClassForBranches(labels: string[]): "" | "ahead" | "behind" {
|
||||
if (!activeBranch || !activeUpstream) return "";
|
||||
const hasLocal = labels.includes(activeBranch);
|
||||
const hasRemote = labels.includes(activeUpstream);
|
||||
if (hasLocal && !hasRemote) return "ahead";
|
||||
if (hasRemote && !hasLocal) return "behind";
|
||||
return "";
|
||||
}
|
||||
|
||||
function branchesAreVisible(branches: string[]): boolean {
|
||||
if (localBranchNames.length === 0) return true;
|
||||
if (graphBranchNames.length === 0) return true;
|
||||
return branches.some(branchIsVisible);
|
||||
}
|
||||
|
||||
@@ -262,7 +281,7 @@
|
||||
const membership = new Map<string, Set<string>>();
|
||||
|
||||
for (const commit of items) {
|
||||
for (const branch of localBranchRefs(commit)) {
|
||||
for (const branch of graphBranchRefs(commit)) {
|
||||
const stack = [commit.hash];
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -287,7 +306,7 @@
|
||||
return new Map(
|
||||
items.map((commit) => [
|
||||
commit.hash,
|
||||
localBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
|
||||
graphBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -380,6 +399,13 @@
|
||||
await onRestoreCommit(commit);
|
||||
}
|
||||
|
||||
async function cherryPickContextCommit() {
|
||||
const commit = contextCommit;
|
||||
if (!commit || isBusy) return;
|
||||
closeCommitContextMenu();
|
||||
await onCherryPickCommit(commit);
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape") return;
|
||||
closeCommitContextMenu();
|
||||
@@ -398,6 +424,18 @@
|
||||
return labels;
|
||||
}
|
||||
|
||||
function graphBranchRefs(commit: GitCommit): string[] {
|
||||
const seen = new Set<string>();
|
||||
const labels: string[] = [];
|
||||
for (const ref of commit.refs) {
|
||||
const label = refLabel(ref);
|
||||
if (!graphBranchNameSet.has(label) || seen.has(label)) continue;
|
||||
seen.add(label);
|
||||
labels.push(label);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
function visibleRefs(commit: GitCommit): string[] {
|
||||
return commit.refs.filter((ref) => !localBranchNameSet.has(refLabel(ref)));
|
||||
}
|
||||
@@ -431,7 +469,7 @@
|
||||
$effect(() => {
|
||||
const defaultBranch = activeBranch && localBranchNames.includes(activeBranch)
|
||||
? activeBranch
|
||||
: (localBranchNames[0] ?? "");
|
||||
: (graphBranchNames[0] ?? "");
|
||||
const defaultFilterKey = `${repositoryKey}::${defaultBranch}`;
|
||||
|
||||
if (!defaultBranch) {
|
||||
@@ -503,8 +541,11 @@
|
||||
{@const row = graphRows[rowIndex]}
|
||||
{@const hoverBranchRefs = commitHoverBranchLabels(item, row)}
|
||||
{@const otherRefs = visibleRefs(item)}
|
||||
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
|
||||
<article
|
||||
class="commit-row graph-row"
|
||||
class:graph-ahead-row={rowSyncClass === "ahead"}
|
||||
class:graph-behind-row={rowSyncClass === "behind"}
|
||||
class:merge-row={item.parents.length > 1}
|
||||
class:root-row={item.parents.length === 0}
|
||||
class:tip-row={item.refs.length > 0}
|
||||
@@ -515,6 +556,8 @@
|
||||
{#each row.top as seg}
|
||||
<path
|
||||
class:hidden-branch={!segmentIsVisible(seg)}
|
||||
class:graph-segment-ahead={syncClassForBranches(seg.branches) === "ahead"}
|
||||
class:graph-segment-behind={syncClassForBranches(seg.branches) === "behind"}
|
||||
d={graphPath(seg, 0, 50)}
|
||||
stroke={seg.color}
|
||||
stroke-width="2.2"
|
||||
@@ -524,6 +567,8 @@
|
||||
{#each row.bottom as seg}
|
||||
<path
|
||||
class:hidden-branch={!segmentIsVisible(seg)}
|
||||
class:graph-segment-ahead={syncClassForBranches(seg.branches) === "ahead"}
|
||||
class:graph-segment-behind={syncClassForBranches(seg.branches) === "behind"}
|
||||
d={graphPath(seg, 50, 100)}
|
||||
stroke={seg.color}
|
||||
stroke-width="2.2"
|
||||
@@ -536,13 +581,20 @@
|
||||
class:merge={item.parents.length > 1}
|
||||
class:tip={item.refs.length > 0}
|
||||
class:hidden-branch={!rowGraphIsVisible(row)}
|
||||
class:ahead={rowSyncClass === "ahead"}
|
||||
class:behind={rowSyncClass === "behind"}
|
||||
title={commitHoverTitle(item, row)}
|
||||
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
||||
></span>
|
||||
{#if hoverBranchRefs.length > 0}
|
||||
<div class="graph-hover-branches" style={`left:${graphColX(row.dotCol) + 13}px`}>
|
||||
{#each hoverBranchRefs as branch}
|
||||
<span title={branch}>
|
||||
<span
|
||||
class:remote={branch === activeUpstream}
|
||||
class:ahead={activeBranch === branch && rowSyncClass === "ahead"}
|
||||
class:behind={activeUpstream === branch && rowSyncClass === "behind"}
|
||||
title={branch}
|
||||
>
|
||||
<GitBranch size={10} aria-hidden="true" />
|
||||
{branch}
|
||||
</span>
|
||||
@@ -656,6 +708,16 @@
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={cherryPickContextCommit}
|
||||
disabled={isBusy}
|
||||
title="Apply this commit's changes on top of the current branch"
|
||||
>
|
||||
<Cherry size={14} aria-hidden="true" />
|
||||
Cherry-pick
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -43,12 +43,14 @@
|
||||
<style>
|
||||
.repo-loading {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
top: var(--app-titlebar-height, 42px);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 400;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(6, 6, 14, 0.72);
|
||||
backdrop-filter: blur(8px);
|
||||
background: #050712;
|
||||
animation: overlay-in 180ms ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CommitAiProvider,
|
||||
CommitAiStatus,
|
||||
ConflictFile,
|
||||
GitBlameResult,
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
GitCommitComparison,
|
||||
@@ -12,6 +13,7 @@ import type {
|
||||
GitSearchHit,
|
||||
GitStash,
|
||||
GitStatus,
|
||||
GitTag,
|
||||
LocalModelOption,
|
||||
PatchApplyAction,
|
||||
RepositoryBundle,
|
||||
@@ -94,6 +96,49 @@ export function deleteBranch(path: string, branch: string, force = false): Promi
|
||||
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
||||
}
|
||||
|
||||
export function listTags(path: string): Promise<GitTag[]> {
|
||||
return invoke<GitTag[]>("list_tags", { path });
|
||||
}
|
||||
|
||||
export function createTag(
|
||||
path: string,
|
||||
name: string,
|
||||
target?: string,
|
||||
message?: string,
|
||||
): Promise<GitTag[]> {
|
||||
return invoke<GitTag[]>("create_tag", {
|
||||
path,
|
||||
name,
|
||||
target: target?.trim() ? target.trim() : null,
|
||||
message: message?.trim() ? message.trim() : null,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTag(path: string, name: string): Promise<GitTag[]> {
|
||||
return invoke<GitTag[]>("delete_tag", { path, name });
|
||||
}
|
||||
|
||||
export function pushTag(
|
||||
path: string,
|
||||
name: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
): Promise<void> {
|
||||
return invoke<void>("push_tag", { path, name, username: username ?? null, password: password ?? null });
|
||||
}
|
||||
|
||||
export function cherryPickCommit(path: string, commit: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("cherry_pick_commit", { path, commit });
|
||||
}
|
||||
|
||||
export function cherryPickContinue(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("cherry_pick_continue", { path });
|
||||
}
|
||||
|
||||
export function cherryPickAbort(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("cherry_pick_abort", { path });
|
||||
}
|
||||
|
||||
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("stage_files", { path, files });
|
||||
}
|
||||
@@ -127,6 +172,18 @@ export function commit(path: string, message: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("commit", { path, message });
|
||||
}
|
||||
|
||||
export function amendCommit(path: string, message?: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("amend_commit", { path, message: message?.trim() ? message.trim() : null });
|
||||
}
|
||||
|
||||
export function lastCommitMessage(path: string): Promise<string | null> {
|
||||
return invoke<string | null>("last_commit_message", { path });
|
||||
}
|
||||
|
||||
export function undoLastCommit(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("undo_last_commit", { path });
|
||||
}
|
||||
|
||||
export function stashPush(
|
||||
path: string,
|
||||
message?: string,
|
||||
@@ -266,6 +323,10 @@ export function cancelFileHistory(requestId: string): Promise<void> {
|
||||
return invoke<void>("cancel_file_history", { requestId });
|
||||
}
|
||||
|
||||
export function getFileBlame(path: string, file: string): Promise<GitBlameResult> {
|
||||
return invoke<GitBlameResult>("get_file_blame", { path, file });
|
||||
}
|
||||
|
||||
export function compareCommits(
|
||||
path: string,
|
||||
from: string,
|
||||
|
||||
@@ -33,6 +33,11 @@ export interface AiSettings {
|
||||
customModel: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsSettings {
|
||||
enabled: boolean;
|
||||
noticeSeen: boolean;
|
||||
}
|
||||
|
||||
export interface GitStatus {
|
||||
repo_path: string;
|
||||
current_branch: string | null;
|
||||
@@ -42,6 +47,7 @@ export interface GitStatus {
|
||||
files: GitFileStatus[];
|
||||
clean: boolean;
|
||||
rebase_in_progress: boolean;
|
||||
cherry_pick_in_progress: boolean;
|
||||
}
|
||||
|
||||
export interface GitFileStatus {
|
||||
@@ -59,6 +65,15 @@ export interface GitBranch {
|
||||
remote: boolean;
|
||||
}
|
||||
|
||||
export interface GitTag {
|
||||
name: string;
|
||||
hash: string;
|
||||
short_hash: string;
|
||||
message: string | null;
|
||||
date: string;
|
||||
annotated: boolean;
|
||||
}
|
||||
|
||||
export interface GitStash {
|
||||
selector: string;
|
||||
index: number;
|
||||
@@ -95,6 +110,7 @@ export interface GitRepositoryFile {
|
||||
export interface RepositoryBundle {
|
||||
status: GitStatus;
|
||||
branches: GitBranch[];
|
||||
tags: GitTag[];
|
||||
stashes: GitStash[];
|
||||
commits: GitCommit[];
|
||||
files: GitRepositoryFile[];
|
||||
@@ -164,6 +180,23 @@ export interface ConflictFile {
|
||||
theirs_size: number | null;
|
||||
}
|
||||
|
||||
export interface GitBlameLine {
|
||||
line_number: number;
|
||||
content: string;
|
||||
commit_hash: string;
|
||||
short_hash: string;
|
||||
author_name: string;
|
||||
author_email: string;
|
||||
author_time: number;
|
||||
summary: string;
|
||||
is_uncommitted: boolean;
|
||||
}
|
||||
|
||||
export interface GitBlameResult {
|
||||
path: string;
|
||||
lines: GitBlameLine[];
|
||||
}
|
||||
|
||||
export interface StoredCredential {
|
||||
username: string;
|
||||
password: string;
|
||||
|
||||
Reference in New Issue
Block a user