Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f85eb7d98 | ||
|
|
c2f55d96db | ||
|
|
0dd5441754 | ||
|
|
82c47c8d03 | ||
|
|
bad1263dcf | ||
|
|
cc805e04bf | ||
|
|
735acd2551 | ||
|
|
f9c0c00618 | ||
|
|
ff00925b13 | ||
|
|
982dbf136d | ||
|
|
646dcc341e | ||
|
|
5f3e55dcd7 | ||
|
|
fdbff8175e | ||
|
|
a6e7e991dd | ||
|
|
3491b8efb3 | ||
|
|
59a8f34e0e | ||
|
|
286b106baa | ||
|
|
670e7e24fe |
@@ -104,7 +104,19 @@
|
||||
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/**)",
|
||||
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-runtime-wry-2.11.4/src/**)",
|
||||
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/window/**)",
|
||||
"WebSearch"
|
||||
"WebSearch",
|
||||
"Bash(node -p \"require\\('./package.json'\\).version\")",
|
||||
"Bash(bash -n PKGBUILD)",
|
||||
"Bash(makepkg --printsrcinfo)",
|
||||
"Bash(npx tauri *)",
|
||||
"Bash(echo \"---exit code:$?---\")",
|
||||
"Bash(git -C /mnt/data/Development/GitLite status --short)",
|
||||
"Bash(makepkg -f --noconfirm)",
|
||||
"Bash(tar -tf gitty-2026.7.19-1-x86_64.pkg.tar.zst)",
|
||||
"Bash(cp /mnt/data/Development/GitLite/PKGBUILD .)",
|
||||
"Bash(sed -i 's/^pkgrel=1/pkgrel=3/' PKGBUILD)",
|
||||
"Bash(node -e \"const fs=require\\('fs'\\); const version='2026.8.1'; const pkgbuild=fs.readFileSync\\('PKGBUILD','utf8'\\).replace\\(/^pkgver=.*\\\\$/m, 'pkgver='+version\\).replace\\(/^pkgrel=.*\\\\$/m, 'pkgrel=1'\\); fs.writeFileSync\\('PKGBUILD', pkgbuild\\);\")",
|
||||
"Bash(rm PKGBUILD)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,10 @@ jobs:
|
||||
run: |
|
||||
node -e "const fs=require('fs'); const path='src-tauri/tauri.conf.json'; const config=JSON.parse(fs.readFileSync(path,'utf8')); config.version=process.env.RELEASE_VERSION; fs.writeFileSync(path, JSON.stringify(config,null,2)+'\n');"
|
||||
|
||||
- name: Update PKGBUILD Version
|
||||
run: |
|
||||
node -e "const fs=require('fs'); const version=require('./package.json').version; const pkgbuild=fs.readFileSync('PKGBUILD','utf8').replace(/^pkgver=.*$/m, 'pkgver='+version).replace(/^pkgrel=.*$/m, 'pkgrel=1'); fs.writeFileSync('PKGBUILD', pkgbuild);"
|
||||
|
||||
- name: Commit and Push Changes
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: powershell
|
||||
@@ -95,7 +99,7 @@ jobs:
|
||||
git config user.email '${{ vars.EMAIL_GIT }}'
|
||||
# npm version also bumps the version inside package-lock.json, so stage it
|
||||
# too -- otherwise it stays as an unstaged change and blocks the rebase.
|
||||
git add package.json package-lock.json src-tauri/tauri.conf.json
|
||||
git add package.json package-lock.json src-tauri/tauri.conf.json PKGBUILD
|
||||
|
||||
git commit -m 'Update version to ${{ github.ref_name }}'
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
|
||||
@@ -7,3 +7,6 @@
|
||||
~
|
||||
.codex*
|
||||
target
|
||||
|
||||
*.pkg.tar.zst
|
||||
pkg
|
||||
@@ -0,0 +1,66 @@
|
||||
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
|
||||
#
|
||||
# Local/in-tree PKGBUILD: builds straight from this working directory (no
|
||||
# source download, no tauri-bundler/AppImage step) and installs the raw
|
||||
# binary + desktop entry. Run `makepkg -si` from the repo root.
|
||||
|
||||
pkgname=gitty
|
||||
pkgver=2026.7.19
|
||||
pkgrel=1
|
||||
pkgdesc="A lightweight, modern Git client built with Tauri"
|
||||
arch=('x86_64')
|
||||
url="https://git.cbsk-tech.de/Christoph/GitLite"
|
||||
license=('MIT')
|
||||
depends=('webkit2gtk-4.1' 'gtk3' 'git' 'hicolor-icon-theme')
|
||||
makedepends=('rust' 'nodejs' 'npm')
|
||||
options=('!lto')
|
||||
|
||||
source=()
|
||||
sha256sums=()
|
||||
|
||||
# Keeps pkgver in sync with package.json (the release CI bumps that file).
|
||||
pkgver() {
|
||||
cd "$startdir"
|
||||
node -p "require('./package.json').version"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$startdir"
|
||||
npm ci
|
||||
# Build through the Tauri CLI (not a raw `cargo build --release`): it enables
|
||||
# the `custom-protocol` cargo feature that makes the binary load the embedded
|
||||
# `dist/` assets instead of the Vite dev server URL (127.0.0.1:1420), and runs
|
||||
# `beforeBuildCommand` (`npm run build`) for us. `--no-bundle` skips deb/rpm/
|
||||
# appimage packaging (and with it linuxdeploy/FUSE) since pacman owns that here.
|
||||
npm run tauri -- build --no-bundle
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$startdir"
|
||||
|
||||
install -Dm755 "src-tauri/target/release/gitty" "$pkgdir/usr/bin/gitty"
|
||||
|
||||
install -Dm644 "src-tauri/icons/32x32.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/gitty.png"
|
||||
install -Dm644 "src-tauri/icons/128x128.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/gitty.png"
|
||||
install -Dm644 "src-tauri/icons/128x128@2x.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256@2/apps/gitty.png"
|
||||
install -Dm644 "src-tauri/icons/icon.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/512x512/apps/gitty.png"
|
||||
|
||||
install -d "$pkgdir/usr/share/applications"
|
||||
cat > "$pkgdir/usr/share/applications/gitty.desktop" <<-EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Gitty
|
||||
Comment=$pkgdesc
|
||||
Exec=gitty
|
||||
Icon=gitty
|
||||
Terminal=false
|
||||
Categories=Development;RevisionControl;
|
||||
StartupWMClass=gitty
|
||||
EOF
|
||||
|
||||
install -Dm644 "README.md" "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
}
|
||||
@@ -27,6 +27,12 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
# Self-updating only makes sense for install methods this app ships itself
|
||||
# (NSIS on Windows, the .app bundle on macOS). On Linux the app is meant to be
|
||||
# installed via the system package manager (e.g. the PKGBUILD), which owns
|
||||
# updates instead — so the updater plugin isn't even compiled in there.
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios", target_os = "linux")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
# ── Build profiles ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
"identifier": "desktop-capability",
|
||||
"platforms": [
|
||||
"macOS",
|
||||
"windows",
|
||||
"linux"
|
||||
"windows"
|
||||
],
|
||||
"windows": [
|
||||
"main"
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{build_messages, looks_like_diff_echo, sanitize_message};
|
||||
use crate::{build_messages, build_review_messages, looks_like_diff_echo, sanitize_message};
|
||||
|
||||
// Generous sizing so a detailed body with bullet points isn't cut off.
|
||||
const DEFAULT_MAX_TOKENS: u32 = 1500;
|
||||
@@ -139,6 +139,82 @@ pub async fn generate_custom(
|
||||
openai_compatible_request(url, api_key, model, diff, notes).await
|
||||
}
|
||||
|
||||
async fn openai_compatible_review_request(
|
||||
url: String,
|
||||
bearer: Option<&str>,
|
||||
model: &str,
|
||||
diff: &str,
|
||||
) -> Result<String, String> {
|
||||
let (system, user) = build_review_messages(diff)?;
|
||||
let body = OpenAiRequest {
|
||||
model: model.to_string(),
|
||||
messages: vec![
|
||||
OpenAiMessage {
|
||||
role: "system",
|
||||
content: system,
|
||||
},
|
||||
OpenAiMessage {
|
||||
role: "user",
|
||||
content: user,
|
||||
},
|
||||
],
|
||||
temperature: 0.1,
|
||||
};
|
||||
let client = http_client()?;
|
||||
let mut request = client.post(url).json(&body);
|
||||
if let Some(key) = bearer.filter(|key| !key.trim().is_empty()) {
|
||||
request = request.bearer_auth(key);
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("Request to the AI model failed: {err}"))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("API error ({status}): {text}"));
|
||||
}
|
||||
let parsed: OpenAiResponse =
|
||||
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||
parsed
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|choice| choice.message.content)
|
||||
.map(|content| sanitize_message(&content))
|
||||
.filter(|content| !content.is_empty())
|
||||
.ok_or_else(|| "The model did not return a review.".to_string())
|
||||
}
|
||||
|
||||
pub async fn review_openai(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||
if api_key.trim().is_empty() {
|
||||
return Err("OpenAI API key is missing.".to_string());
|
||||
}
|
||||
openai_compatible_review_request(
|
||||
"https://api.openai.com/v1/chat/completions".to_string(),
|
||||
Some(api_key),
|
||||
model,
|
||||
diff,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn review_custom(
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
model: &str,
|
||||
diff: &str,
|
||||
) -> Result<String, String> {
|
||||
if base_url.trim().is_empty() {
|
||||
return Err("Endpoint URL is missing.".to_string());
|
||||
}
|
||||
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
|
||||
openai_compatible_review_request(url, api_key, model, diff).await
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AnthropicMessage {
|
||||
role: &'static str,
|
||||
@@ -220,3 +296,45 @@ pub async fn generate_anthropic(
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
pub async fn review_anthropic(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||
if api_key.trim().is_empty() {
|
||||
return Err("Anthropic API key is missing.".to_string());
|
||||
}
|
||||
let (system, user) = build_review_messages(diff)?;
|
||||
let body = AnthropicRequest {
|
||||
model: model.to_string(),
|
||||
max_tokens: 2400,
|
||||
system,
|
||||
messages: vec![AnthropicMessage {
|
||||
role: "user",
|
||||
content: user,
|
||||
}],
|
||||
};
|
||||
let client = http_client()?;
|
||||
let response = client
|
||||
.post("https://api.anthropic.com/v1/messages")
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!("API error ({status}): {text}"));
|
||||
}
|
||||
let parsed: AnthropicResponse =
|
||||
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||
parsed
|
||||
.content
|
||||
.into_iter()
|
||||
.find_map(|block| block.text)
|
||||
.map(|text| sanitize_message(&text))
|
||||
.filter(|text| !text.is_empty())
|
||||
.ok_or_else(|| "The model did not return a review.".to_string())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
mod cloud;
|
||||
|
||||
pub use cloud::{generate_anthropic, generate_custom, generate_openai};
|
||||
pub use cloud::{
|
||||
generate_anthropic, generate_custom, generate_openai, review_anthropic, review_custom,
|
||||
review_openai,
|
||||
};
|
||||
|
||||
use std::{
|
||||
collections::hash_map::DefaultHasher,
|
||||
@@ -443,3 +446,27 @@ instead of a direct string comparison.\n\n\
|
||||
user.push_str(&format!("Staged diff:\n{diff}"));
|
||||
Ok((system, user))
|
||||
}
|
||||
|
||||
pub(crate) fn build_review_messages(diff: &str) -> Result<(String, String), String> {
|
||||
if diff.trim().is_empty() {
|
||||
return Err("No staged changes available for review.".to_string());
|
||||
}
|
||||
|
||||
const MAX_CHARS: usize = 36_000;
|
||||
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
|
||||
let system = r#"You are a senior software engineer performing a focused pre-commit review.
|
||||
Review only the supplied staged Git diff. Look for concrete correctness bugs, security issues,
|
||||
data loss, regressions, broken edge cases, unsafe error handling, and meaningful performance or
|
||||
maintainability risks. Do not report formatting preferences or speculative nitpicks.
|
||||
|
||||
Return ONLY valid JSON with this exact shape:
|
||||
{"summary":"one concise overall assessment","risk":"low|medium|high","findings":[{"severity":"critical|warning|info","title":"short title","description":"clear evidence and impact","file":"path or null","line":123,"suggestion":"specific safe next step"}]}
|
||||
|
||||
Use the new-file line number from the diff when it is known; otherwise use null. Use null for file
|
||||
when the issue is repository-wide. Maximum 12 findings, ordered critical then warning then info.
|
||||
If no actionable issue exists, return an empty findings array and risk low. Never use markdown,
|
||||
code fences, commentary outside the JSON, or claim that tests were executed."#
|
||||
.to_string();
|
||||
let user = format!("Staged diff to review:\n{diff}");
|
||||
Ok((system, user))
|
||||
}
|
||||
|
||||
@@ -1145,6 +1145,143 @@ pub async fn commit_ai_generate(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AiReviewRisk {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AiReviewSeverity {
|
||||
Critical,
|
||||
Warning,
|
||||
Info,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiReviewFinding {
|
||||
pub severity: AiReviewSeverity,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub file: Option<String>,
|
||||
pub line: Option<u32>,
|
||||
pub suggestion: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiReviewResult {
|
||||
pub summary: String,
|
||||
pub risk: AiReviewRisk,
|
||||
pub findings: Vec<AiReviewFinding>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AiReviewWireFinding {
|
||||
severity: String,
|
||||
title: String,
|
||||
description: String,
|
||||
file: Option<String>,
|
||||
line: Option<u32>,
|
||||
suggestion: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AiReviewWireResult {
|
||||
summary: String,
|
||||
risk: String,
|
||||
#[serde(default)]
|
||||
findings: Vec<AiReviewWireFinding>,
|
||||
}
|
||||
|
||||
fn parse_ai_review(raw: &str) -> Result<AiReviewResult, String> {
|
||||
let trimmed = raw.trim().trim_matches('`').trim();
|
||||
let json = match (trimmed.find('{'), trimmed.rfind('}')) {
|
||||
(Some(start), Some(end)) if start <= end => &trimmed[start..=end],
|
||||
_ => return Err("The AI review did not contain valid JSON.".to_string()),
|
||||
};
|
||||
let wire: AiReviewWireResult = serde_json::from_str(json)
|
||||
.map_err(|error| format!("Could not process the AI review: {error}"))?;
|
||||
let risk = match wire.risk.trim().to_ascii_lowercase().as_str() {
|
||||
"high" => AiReviewRisk::High,
|
||||
"medium" => AiReviewRisk::Medium,
|
||||
_ => AiReviewRisk::Low,
|
||||
};
|
||||
let mut findings = wire
|
||||
.findings
|
||||
.into_iter()
|
||||
.filter(|finding| {
|
||||
!finding.title.trim().is_empty() && !finding.description.trim().is_empty()
|
||||
})
|
||||
.map(|finding| AiReviewFinding {
|
||||
severity: match finding.severity.trim().to_ascii_lowercase().as_str() {
|
||||
"critical" | "error" | "high" => AiReviewSeverity::Critical,
|
||||
"warning" | "warn" | "medium" => AiReviewSeverity::Warning,
|
||||
_ => AiReviewSeverity::Info,
|
||||
},
|
||||
title: finding.title.trim().to_string(),
|
||||
description: finding.description.trim().to_string(),
|
||||
file: finding.file.filter(|file| !file.trim().is_empty()),
|
||||
line: finding.line,
|
||||
suggestion: finding.suggestion.trim().to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
findings.sort_by_key(|finding| match finding.severity {
|
||||
AiReviewSeverity::Critical => 0,
|
||||
AiReviewSeverity::Warning => 1,
|
||||
AiReviewSeverity::Info => 2,
|
||||
});
|
||||
findings.truncate(12);
|
||||
Ok(AiReviewResult {
|
||||
summary: if wire.summary.trim().is_empty() {
|
||||
"Review completed.".to_string()
|
||||
} else {
|
||||
wire.summary.trim().to_string()
|
||||
},
|
||||
risk,
|
||||
findings,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn commit_ai_review(
|
||||
path: String,
|
||||
provider: String,
|
||||
model: Option<String>,
|
||||
api_key: Option<String>,
|
||||
base_url: Option<String>,
|
||||
) -> Result<AiReviewResult, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let diff = staged_diff(&repo)?;
|
||||
let model = model.filter(|value| !value.trim().is_empty());
|
||||
let api_key = api_key.filter(|value| !value.trim().is_empty());
|
||||
let base_url = base_url.filter(|value| !value.trim().is_empty());
|
||||
let raw = match provider.as_str() {
|
||||
"openai" => {
|
||||
let api_key = api_key.ok_or_else(|| "OpenAI API key is missing.".to_string())?;
|
||||
let model = model.unwrap_or_else(|| "gpt-4o-mini".to_string());
|
||||
commit_ai::review_openai(&api_key, &model, &diff).await?
|
||||
}
|
||||
"anthropic" => {
|
||||
let api_key = api_key.ok_or_else(|| "Anthropic API key is missing.".to_string())?;
|
||||
let model = model.unwrap_or_else(|| "claude-3-5-haiku-latest".to_string());
|
||||
commit_ai::review_anthropic(&api_key, &model, &diff).await?
|
||||
}
|
||||
"custom" => {
|
||||
let base_url = base_url.ok_or_else(|| "Endpoint URL is missing.".to_string())?;
|
||||
let model = model.ok_or_else(|| "Model name is missing.".to_string())?;
|
||||
commit_ai::review_custom(&base_url, api_key.as_deref(), &model, &diff).await?
|
||||
}
|
||||
"local" => return Err("Pre-commit review currently requires an API provider.".to_string()),
|
||||
other => return Err(format!("Unknown AI provider: {other}")),
|
||||
};
|
||||
parse_ai_review(&raw)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn apply_file_patch(
|
||||
path: String,
|
||||
@@ -6213,4 +6350,19 @@ mod tests {
|
||||
assert_eq!(result.lines.len(), 1);
|
||||
assert!(result.lines[0].is_uncommitted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ai_review_accepts_fenced_json_and_normalizes_findings() {
|
||||
let raw = r#"```json
|
||||
{"summary":"One issue found","risk":"HIGH","findings":[{"severity":"warn","title":"Unchecked result","description":"The new call ignores an error.","file":"src/main.rs","line":42,"suggestion":"Propagate the error."}]}
|
||||
```"#;
|
||||
|
||||
let review = parse_ai_review(raw).expect("review JSON should parse");
|
||||
|
||||
assert_eq!(review.risk, AiReviewRisk::High);
|
||||
assert_eq!(review.findings.len(), 1);
|
||||
assert_eq!(review.findings[0].severity, AiReviewSeverity::Warning);
|
||||
assert_eq!(review.findings[0].file.as_deref(), Some("src/main.rs"));
|
||||
assert_eq!(review.findings[0].line, Some(42));
|
||||
}
|
||||
}
|
||||
|
||||
+20
-11
@@ -8,14 +8,14 @@ use git::{
|
||||
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, 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_interactive_rebase_commits, list_reflog, 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,
|
||||
commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits,
|
||||
compare_file_to_head, 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_interactive_rebase_commits, list_reflog,
|
||||
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_reflog_entry,
|
||||
restore_to_commit, run_sequence_editor_if_requested, search_code_introductions, stage_files,
|
||||
start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit,
|
||||
@@ -33,6 +33,7 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
||||
}
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.maximize();
|
||||
window
|
||||
.show()
|
||||
.map_err(|error| format!("failed to show main window: {error}"))?;
|
||||
@@ -52,7 +53,7 @@ async fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
tauri::Builder::default()
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
||||
#[cfg(desktop)]
|
||||
let _ = app
|
||||
@@ -60,7 +61,6 @@ async fn main() {
|
||||
.expect("no main window")
|
||||
.set_focus();
|
||||
}))
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(
|
||||
tauri_plugin_aptabase::Builder::new("A-SH-1344793789")
|
||||
.with_options(tauri_plugin_aptabase::InitOptions {
|
||||
@@ -71,7 +71,15 @@ async fn main() {
|
||||
)
|
||||
.manage(SearchCancellationState::default())
|
||||
.manage(commit_ai::CommitAiEngine::new())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_dialog::init());
|
||||
|
||||
// Linux installs are expected to come from the system package manager (see the
|
||||
// PKGBUILD), which owns updates itself — the self-updater is only wired up for
|
||||
// the platforms whose install method this app ships (NSIS/Windows, .app/macOS).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
|
||||
builder
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
open_repository,
|
||||
clone_repository,
|
||||
@@ -108,6 +116,7 @@ async fn main() {
|
||||
commit_ai_load,
|
||||
commit_ai_local_models,
|
||||
commit_ai_generate,
|
||||
commit_ai_review,
|
||||
pull,
|
||||
push,
|
||||
fetch,
|
||||
|
||||
+134
-89
@@ -1,12 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from "svelte";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||||
import { AlertCircle, BookOpen, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
|
||||
import { AlertCircle, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
|
||||
|
||||
import TitleBar from "./lib/TitleBar.svelte";
|
||||
import RepoToolbar from "./lib/RepoToolbar.svelte";
|
||||
import RepoTabs from "./lib/RepoTabs.svelte";
|
||||
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
|
||||
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
|
||||
import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte";
|
||||
import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
|
||||
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
|
||||
@@ -43,6 +47,7 @@
|
||||
cloneRepository,
|
||||
commit,
|
||||
commitAiGenerate,
|
||||
commitAiReview,
|
||||
commitAiLoad,
|
||||
commitAiLocalModels,
|
||||
commitAiStatus,
|
||||
@@ -104,6 +109,7 @@
|
||||
} from "./lib/git";
|
||||
|
||||
import type {
|
||||
AiReviewResult,
|
||||
AiSettings,
|
||||
AppLanguage,
|
||||
AppTheme,
|
||||
@@ -179,6 +185,7 @@
|
||||
const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
|
||||
const APP_THEME_KEY = "gitlite.theme.v1";
|
||||
const APP_LANGUAGE_KEY = "gitlite.language.v1";
|
||||
const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1";
|
||||
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
|
||||
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
|
||||
const LEFT_BRANCH_PANEL_HEIGHT_KEY = "gitlite.leftBranchPanelHeight.v1";
|
||||
@@ -247,6 +254,9 @@
|
||||
let lastLocalAiGeneratedMessage = "";
|
||||
let commitAiPhase: CommitAiPhase = "idle";
|
||||
let commitAiGenerating = false;
|
||||
let commitAiReviewing = false;
|
||||
let aiReviewResult: AiReviewResult | null = null;
|
||||
let aiReviewOpen = false;
|
||||
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let aiSettings: AiSettings = defaultAiSettings();
|
||||
let aiSettingsOpen = false;
|
||||
@@ -302,7 +312,7 @@
|
||||
let conflictTarget = "";
|
||||
let conflict: ConflictFile | null = null;
|
||||
let preparedResolutions: Record<string, PreparedResolution> = {};
|
||||
let autoRefreshEnabled = true;
|
||||
let autoRefreshEnabled = loadStoredBoolean(AUTO_REFRESH_ENABLED_KEY, true);
|
||||
let autoRefreshInFlight = false;
|
||||
let credDialogOpen = false;
|
||||
let credDialogAction: CredentialAction | null = null;
|
||||
@@ -364,6 +374,7 @@
|
||||
let fileHistoryResizeStartWidth = 0;
|
||||
let fileHistoryCollapsed = true;
|
||||
let themeMediaQuery: MediaQueryList | undefined;
|
||||
let appVersion = "";
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -425,6 +436,7 @@
|
||||
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
|
||||
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
|
||||
void runStartupSequence();
|
||||
void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; });
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -857,15 +869,19 @@
|
||||
if (appTheme === "system") applyThemePreference(appTheme);
|
||||
}
|
||||
|
||||
function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme, nextLanguage: AppLanguage) {
|
||||
function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme, nextLanguage: AppLanguage, nextAutoRefresh: boolean) {
|
||||
const autoRefreshWasEnabled = autoRefreshEnabled;
|
||||
analyticsSettings = next;
|
||||
appTheme = nextTheme;
|
||||
appLanguage = nextLanguage;
|
||||
autoRefreshEnabled = nextAutoRefresh;
|
||||
persistAnalyticsSettings(next);
|
||||
persistThemePreference(nextTheme);
|
||||
persistLanguagePreference(nextLanguage);
|
||||
persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh);
|
||||
appSettingsOpen = false;
|
||||
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1, theme: nextTheme, language: nextLanguage });
|
||||
if (nextAutoRefresh && !autoRefreshWasEnabled) void autoRefreshTick();
|
||||
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1, theme: nextTheme, language: nextLanguage, auto_refresh: nextAutoRefresh ? 1 : 0 });
|
||||
}
|
||||
|
||||
function updateCommitMessage(message: string) {
|
||||
@@ -926,9 +942,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
autoRefreshEnabled = !autoRefreshEnabled;
|
||||
if (autoRefreshEnabled) void autoRefreshTick();
|
||||
async function reviewStagedWithAi() {
|
||||
if (!activeRepoPath || commitAiReviewing || stagedCount === 0) return;
|
||||
if (aiSettings.provider === "local") {
|
||||
errorMessage = "Pre-commit review currently requires OpenAI, Anthropic, or a custom endpoint.";
|
||||
return;
|
||||
}
|
||||
commitAiReviewing = true;
|
||||
errorMessage = "";
|
||||
try {
|
||||
if (aiSettings.provider === "openai") {
|
||||
const cred = await credLoad("ai:openai");
|
||||
aiReviewResult = await commitAiReview(activeRepoPath, {
|
||||
provider: "openai",
|
||||
model: aiSettings.openaiModel,
|
||||
apiKey: cred?.password,
|
||||
});
|
||||
} else if (aiSettings.provider === "anthropic") {
|
||||
const cred = await credLoad("ai:anthropic");
|
||||
aiReviewResult = await commitAiReview(activeRepoPath, {
|
||||
provider: "anthropic",
|
||||
model: aiSettings.anthropicModel,
|
||||
apiKey: cred?.password,
|
||||
});
|
||||
} else {
|
||||
const cred = await credLoad("ai:custom");
|
||||
aiReviewResult = await commitAiReview(activeRepoPath, {
|
||||
provider: "custom",
|
||||
model: aiSettings.customModel,
|
||||
baseUrl: aiSettings.customBaseUrl,
|
||||
apiKey: cred?.password,
|
||||
});
|
||||
}
|
||||
aiReviewOpen = true;
|
||||
trackEvent("ai_precommit_review", {
|
||||
provider: aiSettings.provider,
|
||||
findings: aiReviewResult.findings.length,
|
||||
risk: aiReviewResult.risk,
|
||||
});
|
||||
} catch (error) {
|
||||
errorMessage = errorToMessage(error);
|
||||
} finally {
|
||||
commitAiReviewing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Updates ────────────────────────────────────────────────────────────────
|
||||
@@ -1556,6 +1612,15 @@
|
||||
fileHistoryCollapsed = false;
|
||||
}
|
||||
|
||||
function hideAndResetFileHistory() {
|
||||
fileHistoryRequestId += 1;
|
||||
cancelActiveFileHistoryLoad();
|
||||
activeFileHistoryRequestId = "";
|
||||
fileHistoryLoading = false;
|
||||
fileHistory = [];
|
||||
fileHistoryCollapsed = true;
|
||||
}
|
||||
|
||||
function rememberRecentRepo(path: string) {
|
||||
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
|
||||
persistRepoLists();
|
||||
@@ -1762,7 +1827,7 @@
|
||||
await refreshBranchList(path);
|
||||
await refreshTags(path);
|
||||
await refreshCommitHistory(path);
|
||||
if (lastFileHistoryHeadHash !== previousHeadHash) {
|
||||
if (lastFileHistoryHeadHash !== previousHeadHash && !fileHistoryCollapsed) {
|
||||
await refreshFileHistory(path);
|
||||
}
|
||||
}
|
||||
@@ -3206,7 +3271,7 @@
|
||||
}
|
||||
|
||||
async function selectExplorerNode(node: ExplorerNode) {
|
||||
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
|
||||
if (!activeRepoPath) return;
|
||||
selectedExplorerPath = node.path;
|
||||
selectedExplorerKind = node.kind;
|
||||
revealFileHistory();
|
||||
@@ -3236,9 +3301,7 @@
|
||||
selectedExplorerPath = file.path;
|
||||
selectedExplorerKind = "file";
|
||||
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
||||
revealFileHistory();
|
||||
|
||||
void loadSelectedFileHistory(file.path);
|
||||
hideAndResetFileHistory();
|
||||
trackEvent("explorer_file_selected", {
|
||||
source: "status",
|
||||
status: file.unstaged ?? file.staged ?? "unknown",
|
||||
@@ -3524,25 +3587,6 @@
|
||||
|
||||
<main class="shell">
|
||||
<TitleBar
|
||||
branch={status?.current_branch ?? ""}
|
||||
ahead={status?.ahead ?? 0}
|
||||
behind={status?.behind ?? 0}
|
||||
repoName={activeRepoPath ? (activeRepoPath.split(/[\\/]/).filter(Boolean).slice(-1)[0] ?? "") : ""}
|
||||
hasRepository={workspaceActive}
|
||||
{isBusy}
|
||||
{operation}
|
||||
{autoRefreshEnabled}
|
||||
{autoRefreshInFlight}
|
||||
onFetch={fetchRepo}
|
||||
onPull={pullRepo}
|
||||
onPush={pushRepo}
|
||||
onRefresh={refreshRepo}
|
||||
onSearch={openGlobalSearchDialog}
|
||||
onCompare={openCompareSelect}
|
||||
onInteractiveRebase={openInteractiveRebase}
|
||||
onReflog={openReflog}
|
||||
onOpenInExplorer={openActiveRepoInExplorer}
|
||||
onToggleAutoRefresh={toggleAutoRefresh}
|
||||
onOpenSettings={() => { appSettingsOpen = true; }}
|
||||
onOpenHelp={openHelp}
|
||||
language={appLanguage}
|
||||
@@ -3550,65 +3594,40 @@
|
||||
|
||||
<div class="shell-body">
|
||||
|
||||
<header class="repo-tabbar" aria-label="Repository tabs">
|
||||
<button
|
||||
class="repo-tab management"
|
||||
class:active={activeView === "management"}
|
||||
type="button"
|
||||
onclick={openRepoManagement}
|
||||
disabled={isBusy}
|
||||
title="Repository Management"
|
||||
>
|
||||
<BookOpen size={14} aria-hidden="true" />
|
||||
Repository Management
|
||||
</button>
|
||||
<RepoTabs
|
||||
{activeView}
|
||||
{repoTabs}
|
||||
{isBusy}
|
||||
language={appLanguage}
|
||||
onOpenManagement={openRepoManagement}
|
||||
isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)}
|
||||
onSelect={selectRepoTab}
|
||||
onClose={closeRepoTab}
|
||||
onContextMenu={openRepoTabContextMenu}
|
||||
onAdd={chooseRepositoryFolder}
|
||||
/>
|
||||
|
||||
<div class="repo-tabs-scroll">
|
||||
{#each repoTabs as repo (repo.path)}
|
||||
<div
|
||||
class="repo-tab-wrap"
|
||||
class:active={activeView === "repository" && sameRepoPath(activeRepoPath, repo.path)}
|
||||
role="presentation"
|
||||
oncontextmenu={(event) => openRepoTabContextMenu(repo.path, event)}
|
||||
>
|
||||
<button
|
||||
class="repo-tab"
|
||||
type="button"
|
||||
onclick={() => selectRepoTab(repo.path)}
|
||||
disabled={isBusy}
|
||||
title={repo.path}
|
||||
>
|
||||
<FolderOpen size={14} aria-hidden="true" />
|
||||
<span>{repo.name}</span>
|
||||
{#if repo.branch}
|
||||
<strong>{repo.branch}</strong>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="repo-tab-close"
|
||||
type="button"
|
||||
onclick={(event) => closeRepoTab(repo.path, event)}
|
||||
disabled={isBusy}
|
||||
aria-label={`Close ${repo.name}`}
|
||||
title="Close repository tab"
|
||||
>
|
||||
<X size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="repo-tab-add"
|
||||
type="button"
|
||||
onclick={chooseRepositoryFolder}
|
||||
disabled={isBusy}
|
||||
title="Open repository folder"
|
||||
aria-label="Open repository folder"
|
||||
>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<!-- Repo actions live under the tab bar and disappear in Repository
|
||||
Management, where none of them are applicable. -->
|
||||
{#if workspaceActive}
|
||||
<RepoToolbar
|
||||
hasRepository={workspaceActive}
|
||||
{isBusy}
|
||||
{operation}
|
||||
ahead={status?.ahead ?? 0}
|
||||
behind={status?.behind ?? 0}
|
||||
language={appLanguage}
|
||||
onFetch={fetchRepo}
|
||||
onPull={pullRepo}
|
||||
onPush={pushRepo}
|
||||
onRefresh={refreshRepo}
|
||||
onSearch={openGlobalSearchDialog}
|
||||
onCompare={openCompareSelect}
|
||||
onInteractiveRebase={openInteractiveRebase}
|
||||
onReflog={openReflog}
|
||||
onOpenInExplorer={openActiveRepoInExplorer}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if repoTabContextMenu}
|
||||
{@const menu = repoTabContextMenu}
|
||||
@@ -4048,11 +4067,13 @@
|
||||
commitAiProvider={aiSettings.provider}
|
||||
{commitAiPhase}
|
||||
{commitAiGenerating}
|
||||
{commitAiReviewing}
|
||||
{canAmend}
|
||||
{amendMode}
|
||||
onCommit={commitChanges}
|
||||
onCommitMessageChange={updateCommitMessage}
|
||||
onGenerateCommitMessage={generateCommitMessageWithAi}
|
||||
onReviewStaged={reviewStagedWithAi}
|
||||
onOpenAiSettings={() => { aiSettingsOpen = true; }}
|
||||
onToggleAmend={toggleAmendMode}
|
||||
onUndoLastCommit={undoLastCommitChange}
|
||||
@@ -4133,6 +4154,19 @@
|
||||
</aside>
|
||||
</section>
|
||||
{/if}
|
||||
<footer class="workspace-statusbar" aria-label="Application status summary">
|
||||
{#if workspaceActive}
|
||||
<span class:clean={status?.clean} class="workspace-health"><span aria-hidden="true"></span>{status?.clean ? "Working tree clean" : `${changedFiles.length} changed ${changedFiles.length === 1 ? "file" : "files"}`}</span>
|
||||
{/if}
|
||||
<span class="workspace-status-spacer"></span>
|
||||
{#if workspaceActive}
|
||||
<span class="workspace-branch"><GitBranch size={12} aria-hidden="true" />{status?.current_branch ?? "No branch"}</span>
|
||||
<span class="ahead">↑ {status?.ahead ?? 0}</span>
|
||||
<span class="behind">↓ {status?.behind ?? 0}</span>
|
||||
<span class:active={autoRefreshEnabled} class="workspace-auto">Auto <i aria-hidden="true"></i></span>
|
||||
{/if}
|
||||
{#if appVersion}<span class="app-version" title={`Gitty version ${appVersion}`}>Gitty v{appVersion}</span>{/if}
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -4161,6 +4195,7 @@
|
||||
analytics={analyticsSettings}
|
||||
theme={appTheme}
|
||||
language={appLanguage}
|
||||
autoRefresh={autoRefreshEnabled}
|
||||
onSave={saveAppSettings}
|
||||
onClose={() => { appSettingsOpen = false; }}
|
||||
/>
|
||||
@@ -4170,6 +4205,16 @@
|
||||
<HelpOverlay language={appLanguage} onClose={() => { helpOpen = false; }} />
|
||||
{/if}
|
||||
|
||||
{#if aiReviewOpen && aiReviewResult}
|
||||
<AiReviewDialog
|
||||
result={aiReviewResult}
|
||||
provider={aiSettings.provider}
|
||||
isReviewing={commitAiReviewing}
|
||||
onRerun={reviewStagedWithAi}
|
||||
onClose={() => { aiReviewOpen = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if linePatchOpen && linePatchFile}
|
||||
<LinePatchDialog
|
||||
file={linePatchFile}
|
||||
|
||||
+849
-47
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { BookOpen, Database, Plus, X } from "@lucide/svelte";
|
||||
|
||||
interface RepositoryTabItem {
|
||||
path: string;
|
||||
name: string;
|
||||
branch: string | null;
|
||||
}
|
||||
|
||||
export let activeView: "management" | "repository" = "management";
|
||||
export let repoTabs: RepositoryTabItem[] = [];
|
||||
export let isBusy: boolean = false;
|
||||
export let language: "en" | "de" = "en";
|
||||
export let onOpenManagement: () => void = () => {};
|
||||
export let isActive: (path: string) => boolean = () => false;
|
||||
export let onSelect: (path: string) => void | Promise<void> = () => {};
|
||||
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
|
||||
export let onContextMenu: (path: string, event: MouseEvent) => void = () => {};
|
||||
export let onAdd: () => void | Promise<void> = () => {};
|
||||
</script>
|
||||
|
||||
<header class="repo-tabbar" aria-label={language === "de" ? "Repository-Reiter" : "Repository tabs"}>
|
||||
<button
|
||||
class="repo-tab management"
|
||||
class:active={activeView === "management"}
|
||||
type="button"
|
||||
onclick={onOpenManagement}
|
||||
disabled={isBusy}
|
||||
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
||||
>
|
||||
<BookOpen size={14} aria-hidden="true" />
|
||||
<span>{language === "de" ? "Repository-Verwaltung" : "Repository Management"}</span>
|
||||
</button>
|
||||
|
||||
<div class="repo-tabs-scroll">
|
||||
{#each repoTabs as repo (repo.path)}
|
||||
<div
|
||||
class="repo-tab-wrap"
|
||||
class:active={isActive(repo.path)}
|
||||
role="presentation"
|
||||
oncontextmenu={(event) => onContextMenu(repo.path, event)}
|
||||
>
|
||||
<button
|
||||
class="repo-tab"
|
||||
type="button"
|
||||
onclick={() => onSelect(repo.path)}
|
||||
disabled={isBusy}
|
||||
title={repo.path}
|
||||
>
|
||||
<Database size={15} aria-hidden="true" />
|
||||
<span>{repo.name}</span>
|
||||
</button>
|
||||
<button
|
||||
class="repo-tab-close"
|
||||
type="button"
|
||||
onclick={(event) => onClose(repo.path, event)}
|
||||
disabled={isBusy}
|
||||
aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}
|
||||
title={language === "de" ? "Repository-Tab schließen" : "Close repository tab"}
|
||||
>
|
||||
<X size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="repo-tab-add"
|
||||
type="button"
|
||||
onclick={onAdd}
|
||||
disabled={isBusy}
|
||||
title={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}
|
||||
aria-label={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}
|
||||
>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ChevronDown,
|
||||
CloudDownload,
|
||||
Download,
|
||||
FolderOpen,
|
||||
GitCompare,
|
||||
History,
|
||||
ListRestart,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Upload,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
export let hasRepository: boolean = false;
|
||||
export let isBusy: boolean = false;
|
||||
export let operation: string = "";
|
||||
export let ahead: number = 0;
|
||||
export let behind: number = 0;
|
||||
export let language: "en" | "de" = "en";
|
||||
export let onFetch: () => void = () => {};
|
||||
export let onPull: () => void = () => {};
|
||||
export let onPush: () => void = () => {};
|
||||
export let onRefresh: () => void = () => {};
|
||||
export let onSearch: () => void = () => {};
|
||||
export let onCompare: () => void = () => {};
|
||||
export let onInteractiveRebase: () => void = () => {};
|
||||
export let onReflog: () => void = () => {};
|
||||
export let onOpenInExplorer: () => void = () => {};
|
||||
|
||||
let historyOpen = false;
|
||||
let toolbarElement: HTMLDivElement;
|
||||
|
||||
$: isGerman = language === "de";
|
||||
|
||||
function runHistoryAction(action: () => void) {
|
||||
historyOpen = false;
|
||||
action();
|
||||
}
|
||||
|
||||
function handleWindowClick(event: MouseEvent) {
|
||||
if (historyOpen && toolbarElement && !toolbarElement.contains(event.target as Node)) {
|
||||
historyOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && historyOpen) {
|
||||
event.stopPropagation();
|
||||
historyOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} onkeydown={handleWindowKeydown} />
|
||||
|
||||
<div bind:this={toolbarElement} class="repo-toolbar" role="toolbar" aria-label={isGerman ? "Repository-Aktionen" : "Repository actions"}>
|
||||
<div class="repo-toolbar-sync">
|
||||
<div class="repo-action-group repo-sync-actions">
|
||||
<button
|
||||
class="repo-action fetch"
|
||||
onclick={onFetch}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Fetch"
|
||||
aria-label="Fetch"
|
||||
>
|
||||
{#if operation === "Fetching"}
|
||||
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||
{:else}
|
||||
<CloudDownload size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="repo-action-label">Fetch</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="repo-action sync-primary"
|
||||
onclick={onPull}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Pull"
|
||||
aria-label={behind > 0
|
||||
? `Pull, ${behind} ${isGerman ? (behind === 1 ? "Remote-Commit voraus" : "Remote-Commits voraus") : (behind === 1 ? "commit behind" : "commits behind")}`
|
||||
: "Pull"}
|
||||
>
|
||||
{#if operation === "Pulling"}
|
||||
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||
{:else}
|
||||
<Download size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="repo-action-label">Pull</span>
|
||||
{#if behind > 0}<span class="repo-action-count behind">↓{behind}</span>{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="repo-action sync-primary"
|
||||
onclick={onPush}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Push"
|
||||
aria-label={ahead > 0
|
||||
? `Push, ${ahead} ${isGerman ? (ahead === 1 ? "lokaler Commit voraus" : "lokale Commits voraus") : (ahead === 1 ? "commit ahead" : "commits ahead")}`
|
||||
: "Push"}
|
||||
>
|
||||
{#if operation === "Pushing"}
|
||||
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||
{:else}
|
||||
<Upload size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="repo-action-label">Push</span>
|
||||
{#if ahead > 0}<span class="repo-action-count ahead">↑{ahead}</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="repo-toolbar-divider" aria-hidden="true"></div>
|
||||
|
||||
<div class="repo-action-group repo-inspect-actions">
|
||||
<button
|
||||
class="repo-action"
|
||||
onclick={onSearch}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title={isGerman ? "Globale Suche" : "Global search"}
|
||||
aria-label={isGerman ? "Globale Suche" : "Global search"}
|
||||
>
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<span class="repo-action-label">{isGerman ? "Suchen" : "Search"}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="repo-action"
|
||||
onclick={onCompare}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title={isGerman ? "Commits vergleichen" : "Compare commits"}
|
||||
aria-label={isGerman ? "Commits vergleichen" : "Compare commits"}
|
||||
>
|
||||
<GitCompare size={15} aria-hidden="true" />
|
||||
<span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span>
|
||||
</button>
|
||||
|
||||
<div class="repo-history-wrap">
|
||||
<button
|
||||
class="repo-action history-trigger"
|
||||
class:active={historyOpen}
|
||||
type="button"
|
||||
onclick={() => { historyOpen = !historyOpen; }}
|
||||
disabled={!hasRepository || isBusy}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={historyOpen}
|
||||
title={isGerman ? "Verlauf und Rebase" : "History and rebase"}
|
||||
>
|
||||
<History size={15} aria-hidden="true" />
|
||||
<span class="repo-action-label">{isGerman ? "Verlauf" : "History"}</span>
|
||||
<ChevronDown class="history-chevron" size={13} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{#if historyOpen}
|
||||
<div class="repo-history-menu" role="menu">
|
||||
<button type="button" role="menuitem" onclick={() => runHistoryAction(onReflog)}>
|
||||
<History size={15} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>Reflog</strong>
|
||||
<small>{isGerman ? "Lokale Referenzbewegungen" : "Local reference movements"}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" onclick={() => runHistoryAction(onInteractiveRebase)}>
|
||||
<ListRestart size={15} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{isGerman ? "Interaktiver Rebase" : "Interactive rebase"}</strong>
|
||||
<small>{isGerman ? "Commits ordnen und zusammenfassen" : "Reorder and combine commits"}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="repo-toolbar-spacer"></div>
|
||||
<div class="repo-toolbar-divider utility" aria-hidden="true"></div>
|
||||
|
||||
<div class="repo-action-group repo-utility-actions">
|
||||
<button
|
||||
class="repo-action"
|
||||
onclick={onOpenInExplorer}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"}
|
||||
aria-label={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"}
|
||||
>
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
<span class="repo-action-label utility-label">Explorer</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="repo-action"
|
||||
onclick={onRefresh}
|
||||
disabled={isBusy || !hasRepository}
|
||||
title={isGerman ? "Manuell aktualisieren" : "Refresh now"}
|
||||
aria-label={isGerman ? "Manuell aktualisieren" : "Refresh now"}
|
||||
>
|
||||
<RefreshCw class={operation === "Refreshing" ? "spin" : ""} size={16} aria-hidden="true" />
|
||||
<span class="repo-action-label utility-label">{isGerman ? "Aktualisieren" : "Refresh"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
+46
-238
@@ -1,29 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { CircleHelp, CloudDownload, Download, FolderOpen, GitBranch, GitCompare, History, ListRestart, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte";
|
||||
import { CircleHelp, Minus, Settings, X } from "@lucide/svelte";
|
||||
import iconUrl from "../../src-tauri/icons/icon.png";
|
||||
|
||||
export let branch: string = "";
|
||||
export let ahead: number = 0;
|
||||
export let behind: number = 0;
|
||||
export let repoName: string = "";
|
||||
export let hasRepository: boolean = false;
|
||||
export let isBusy: boolean = false;
|
||||
export let operation: string = "";
|
||||
export let autoRefreshEnabled: boolean = true;
|
||||
export let autoRefreshInFlight: boolean = false;
|
||||
export let onFetch: () => void = () => {};
|
||||
export let onPull: () => void = () => {};
|
||||
export let onPush: () => void = () => {};
|
||||
export let onRefresh: () => void = () => {};
|
||||
export let onSearch: () => void = () => {};
|
||||
export let onCompare: () => void = () => {};
|
||||
export let onInteractiveRebase: () => void = () => {};
|
||||
export let onReflog: () => void = () => {};
|
||||
export let onOpenInExplorer: () => void = () => {};
|
||||
export let onToggleAutoRefresh: () => void = () => {};
|
||||
export let onOpenHelp: () => void = () => {};
|
||||
export let onOpenSettings: () => void = () => {};
|
||||
export let language: "en" | "de" = "en";
|
||||
@@ -31,7 +11,6 @@
|
||||
let win: ReturnType<typeof getCurrentWindow> | null = null;
|
||||
let isMaximized = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
let appVersion = "";
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
@@ -43,12 +22,6 @@
|
||||
} catch {
|
||||
win = null;
|
||||
}
|
||||
|
||||
try {
|
||||
appVersion = await getVersion();
|
||||
} catch {
|
||||
appVersion = "";
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -75,218 +48,53 @@
|
||||
<img src={iconUrl} alt="" data-tauri-drag-region />
|
||||
</span>
|
||||
<span data-tauri-drag-region>Gitty</span>
|
||||
{#if appVersion}
|
||||
<span class="tb-version" data-tauri-drag-region title="Version {appVersion}">v{appVersion}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Center: repo + branch info -->
|
||||
<div class="titlebar-info" data-tauri-drag-region>
|
||||
{#if hasRepository}
|
||||
<div class="titlebar-context" data-tauri-drag-region>
|
||||
{#if repoName}
|
||||
<span class="tb-repo" data-tauri-drag-region>{repoName}</span>
|
||||
<span class="tb-sep" data-tauri-drag-region aria-hidden="true">/</span>
|
||||
{/if}
|
||||
<GitBranch size={12} aria-hidden="true" />
|
||||
<span class="tb-branch" data-tauri-drag-region title={branch}>{branch}</span>
|
||||
</div>
|
||||
{#if ahead > 0 || behind > 0}
|
||||
<div class="tb-sync-group" aria-label="Branch synchronization status">
|
||||
{#if ahead > 0}
|
||||
<span class="tb-sync ahead" title="{ahead} commits ahead">↑{ahead}</span>
|
||||
{/if}
|
||||
{#if behind > 0}
|
||||
<span class="tb-sync behind" title="{behind} commits behind">↓{behind}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="titlebar-drag" data-tauri-drag-region aria-hidden="true"></div>
|
||||
|
||||
<!-- Right: app-global actions + window controls -->
|
||||
<div class="titlebar-globals">
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onOpenHelp}
|
||||
title={language === "de" ? "Hilfe (Ctrl+/)" : "Help (Ctrl+/)"}
|
||||
aria-label={language === "de" ? "Hilfe öffnen" : "Open help"}
|
||||
>
|
||||
<CircleHelp size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onOpenSettings}
|
||||
title={language === "de" ? "Einstellungen" : "Settings"}
|
||||
aria-label={language === "de" ? "Einstellungen" : "Settings"}
|
||||
>
|
||||
<Settings size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="titlebar-controls">
|
||||
<button class="tb-btn" onclick={minimizeWindow} title="Minimize" aria-label="Minimize">
|
||||
<Minus size={12} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="tb-btn"
|
||||
onclick={toggleMaximizeWindow}
|
||||
title={isMaximized ? "Restore" : "Maximize"}
|
||||
aria-label={isMaximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
{#if isMaximized}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="1" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M1 3v7a1 1 0 0 0 1 1h7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<rect x="1" y="1" width="10" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="tb-no-repo" data-tauri-drag-region>No repository open</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: actions + window controls -->
|
||||
<div class="titlebar-right">
|
||||
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onOpenInExplorer}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Open repository in Explorer"
|
||||
aria-label="Open repository in Explorer"
|
||||
>
|
||||
<FolderOpen size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">Explorer</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onSearch}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Global search"
|
||||
aria-label="Global search"
|
||||
>
|
||||
<Search size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">Search</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onCompare}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Compare commits"
|
||||
aria-label="Compare commits"
|
||||
>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">Compare</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onInteractiveRebase}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Interactive rebase"
|
||||
aria-label="Interactive rebase"
|
||||
>
|
||||
<ListRestart size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">Rebase</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onReflog}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Reflog"
|
||||
aria-label="Reflog"
|
||||
>
|
||||
<History size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">Reflog</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onFetch}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Fetch"
|
||||
aria-label="Fetch"
|
||||
>
|
||||
{#if operation === "Fetching"}
|
||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<CloudDownload size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="tb-action-label">Fetch</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onPull}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Pull"
|
||||
aria-label="Pull"
|
||||
>
|
||||
{#if operation === "Pulling"}
|
||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<Download size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="tb-action-label">Pull</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onPush}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Push"
|
||||
aria-label="Push"
|
||||
>
|
||||
{#if operation === "Pushing"}
|
||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<Upload size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="tb-action-label">Push</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onRefresh}
|
||||
disabled={isBusy || !hasRepository}
|
||||
title="Refresh"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
class={operation === "Refreshing" ? "spin" : ""}
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="tb-action-label">Refresh</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action auto-toggle"
|
||||
class:active={autoRefreshEnabled}
|
||||
onclick={onToggleAutoRefresh}
|
||||
aria-pressed={autoRefreshEnabled}
|
||||
title={autoRefreshEnabled ? "Auto-refresh on — click to disable" : "Auto-refresh off — click to enable"}
|
||||
aria-label="Toggle auto-refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
class={autoRefreshInFlight ? "spin" : ""}
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="tb-action-label">Auto</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onOpenHelp}
|
||||
title={language === "de" ? "Hilfe (Ctrl+/)" : "Help (Ctrl+/)"}
|
||||
aria-label={language === "de" ? "Hilfe öffnen" : "Open help"}
|
||||
>
|
||||
<CircleHelp size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">{language === "de" ? "Hilfe" : "Help"}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onOpenSettings}
|
||||
title={language === "de" ? "Einstellungen" : "Settings"}
|
||||
aria-label={language === "de" ? "Einstellungen" : "Settings"}
|
||||
>
|
||||
<Settings size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">{language === "de" ? "Einstellungen" : "Settings"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="titlebar-divider" aria-hidden="true"></div>
|
||||
|
||||
<div class="titlebar-controls">
|
||||
<button class="tb-btn" onclick={minimizeWindow} title="Minimize" aria-label="Minimize">
|
||||
<Minus size={12} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="tb-btn"
|
||||
onclick={toggleMaximizeWindow}
|
||||
title={isMaximized ? "Restore" : "Maximize"}
|
||||
aria-label={isMaximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
{#if isMaximized}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="1" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M1 3v7a1 1 0 0 0 1 1h7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<rect x="1" y="1" width="10" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="tb-btn close" onclick={closeWindow} title="Close" aria-label="Close">
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
<button class="tb-btn close" onclick={closeWindow} title="Close" aria-label="Close">
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { AlertTriangle, CircleAlert, FileCode, Info, LoaderCircle, RotateCw, ShieldCheck, X } from "@lucide/svelte";
|
||||
import type { AiReviewFinding, AiReviewResult, CommitAiProvider } from "../types";
|
||||
|
||||
interface Props {
|
||||
result: AiReviewResult;
|
||||
provider: CommitAiProvider;
|
||||
isReviewing: boolean;
|
||||
onRerun: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { result, provider, isReviewing = false, onRerun, onClose }: Props = $props();
|
||||
|
||||
function providerLabel(value: CommitAiProvider): string {
|
||||
if (value === "openai") return "OpenAI";
|
||||
if (value === "anthropic") return "Anthropic";
|
||||
if (value === "custom") return "Custom endpoint";
|
||||
return "Local AI";
|
||||
}
|
||||
|
||||
function locationLabel(finding: AiReviewFinding): string {
|
||||
if (!finding.file) return "Repository-wide";
|
||||
return finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(event) => { if (event.key === "Escape" && !isReviewing) onClose(); }} />
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog ai-review-dialog" role="dialog" aria-modal="true" aria-label="AI pre-commit review" tabindex="-1">
|
||||
<header class="dialog-header ai-review-header">
|
||||
<div>
|
||||
<span class="eyebrow">Staged changes</span>
|
||||
<h2>AI pre-commit review</h2>
|
||||
</div>
|
||||
<div class="dialog-header-actions">
|
||||
<span class="ai-review-provider">{providerLabel(provider)}</span>
|
||||
<button class="dialog-close" type="button" onclick={onClose} disabled={isReviewing} aria-label="Close review"><X size={18} aria-hidden="true" /></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="ai-review-summary">
|
||||
<div class="ai-review-summary-icon" class:clean={result.findings.length === 0}>
|
||||
{#if result.findings.length === 0}<ShieldCheck size={22} aria-hidden="true" />{:else}<AlertTriangle size={22} aria-hidden="true" />{/if}
|
||||
</div>
|
||||
<div>
|
||||
<div class="ai-review-summary-line">
|
||||
<strong>{result.findings.length === 0 ? "No actionable issues found" : `${result.findings.length} review ${result.findings.length === 1 ? "finding" : "findings"}`}</strong>
|
||||
<span class="ai-review-risk {result.risk}">{result.risk} risk</span>
|
||||
</div>
|
||||
<p>{result.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ai-review-findings">
|
||||
{#if result.findings.length === 0}
|
||||
<div class="ai-review-clean-state">
|
||||
<ShieldCheck size={30} aria-hidden="true" />
|
||||
<strong>The staged diff looks ready for human verification.</strong>
|
||||
<span>AI reviews can miss issues. Run the relevant tests before committing.</span>
|
||||
</div>
|
||||
{:else}
|
||||
{#each result.findings as finding, index (`${finding.file ?? "repo"}:${finding.line ?? 0}:${finding.title}:${index}`)}
|
||||
<article class="ai-review-finding {finding.severity}">
|
||||
<div class="ai-review-finding-icon">
|
||||
{#if finding.severity === "critical"}<CircleAlert size={17} aria-hidden="true" />{:else if finding.severity === "warning"}<AlertTriangle size={17} aria-hidden="true" />{:else}<Info size={17} aria-hidden="true" />{/if}
|
||||
</div>
|
||||
<div class="ai-review-finding-body">
|
||||
<div class="ai-review-finding-title">
|
||||
<span>{finding.severity}</span>
|
||||
<strong>{finding.title}</strong>
|
||||
</div>
|
||||
<p>{finding.description}</p>
|
||||
<div class="ai-review-location"><FileCode size={13} aria-hidden="true" /><code>{locationLabel(finding)}</code></div>
|
||||
{#if finding.suggestion}<div class="ai-review-suggestion"><strong>Suggested next step</strong><span>{finding.suggestion}</span></div>{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="dialog-footer ai-review-footer">
|
||||
<p>Review suggestions are advisory and never modify files automatically.</p>
|
||||
<div>
|
||||
<button class="btn-secondary" type="button" onclick={onRerun} disabled={isReviewing}>
|
||||
{#if isReviewing}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<RotateCw size={14} aria-hidden="true" />{/if}
|
||||
Review again
|
||||
</button>
|
||||
<button class="btn-primary" type="button" onclick={onClose} disabled={isReviewing}>Done</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,26 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { Check, Languages, Settings, X } from "@lucide/svelte";
|
||||
import { Check, Languages, RefreshCw, Settings, X } from "@lucide/svelte";
|
||||
import type { AnalyticsSettings, AppLanguage, AppTheme } from "../types";
|
||||
|
||||
interface Props {
|
||||
analytics: AnalyticsSettings;
|
||||
theme: AppTheme;
|
||||
language: AppLanguage;
|
||||
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage) => void;
|
||||
autoRefresh: boolean;
|
||||
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { analytics, theme = "system", language = "en", onSave = () => {}, onClose = () => {} }: Props = $props();
|
||||
let { analytics, theme = "system", language = "en", autoRefresh = true, onSave = () => {}, onClose = () => {} }: Props = $props();
|
||||
|
||||
let analyticsEnabled = $state(true);
|
||||
let selectedTheme = $state<AppTheme>("system");
|
||||
let selectedLanguage = $state<AppLanguage>("en");
|
||||
let autoRefreshEnabled = $state(true);
|
||||
const isGerman = $derived(selectedLanguage === "de");
|
||||
|
||||
$effect(() => {
|
||||
analyticsEnabled = analytics.enabled;
|
||||
selectedTheme = theme;
|
||||
selectedLanguage = language;
|
||||
autoRefreshEnabled = autoRefresh;
|
||||
});
|
||||
|
||||
function save() {
|
||||
@@ -28,7 +31,7 @@
|
||||
...analytics,
|
||||
enabled: analyticsEnabled,
|
||||
noticeSeen: true,
|
||||
}, selectedTheme, selectedLanguage);
|
||||
}, selectedTheme, selectedLanguage, autoRefreshEnabled);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -70,6 +73,24 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<span class="eyebrow">Repository</span>
|
||||
<h3>{isGerman ? "Automatische Aktualisierung" : "Auto refresh"}</h3>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<label class="settings-toggle-row">
|
||||
<input type="checkbox" bind:checked={autoRefreshEnabled} />
|
||||
<span>
|
||||
<strong>{isGerman ? "Repositories automatisch aktualisieren" : "Refresh repositories automatically"}</strong>
|
||||
<small>{isGerman ? "Aktualisiert Arbeitsbereich, Branch-Status und Remotes regelmäßig im Hintergrund." : "Periodically refreshes the working tree, branch status, and remotes in the background."}</small>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<header>
|
||||
<Languages size={16} aria-hidden="true" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Check, LoaderCircle, RotateCcw, Settings, Sparkles } from "@lucide/svelte";
|
||||
import { Check, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
|
||||
import type { CommitAiPhase, CommitAiProvider } from "../types";
|
||||
|
||||
interface Props {
|
||||
@@ -13,11 +13,13 @@
|
||||
commitAiProvider: CommitAiProvider;
|
||||
commitAiPhase: CommitAiPhase;
|
||||
commitAiGenerating: boolean;
|
||||
commitAiReviewing: boolean;
|
||||
canAmend: boolean;
|
||||
amendMode: boolean;
|
||||
onCommit: () => void;
|
||||
onCommitMessageChange: (msg: string) => void;
|
||||
onGenerateCommitMessage: () => void;
|
||||
onReviewStaged: () => void;
|
||||
onOpenAiSettings: () => void;
|
||||
onToggleAmend: (checked: boolean) => void;
|
||||
onUndoLastCommit: () => void;
|
||||
@@ -34,11 +36,13 @@
|
||||
commitAiProvider = "local",
|
||||
commitAiPhase = "idle",
|
||||
commitAiGenerating = false,
|
||||
commitAiReviewing = false,
|
||||
canAmend = false,
|
||||
amendMode = false,
|
||||
onCommit = () => {},
|
||||
onCommitMessageChange = () => {},
|
||||
onGenerateCommitMessage = () => {},
|
||||
onReviewStaged = () => {},
|
||||
onOpenAiSettings = () => {},
|
||||
onToggleAmend = () => {},
|
||||
onUndoLastCommit = () => {},
|
||||
@@ -61,25 +65,52 @@
|
||||
hasRepository &&
|
||||
!isBusy &&
|
||||
!commitAiGenerating &&
|
||||
!commitAiReviewing &&
|
||||
stagedCount > 0 &&
|
||||
(commitAiProvider !== "local" || commitAiPhase === "ready"),
|
||||
);
|
||||
let canReview = $derived(canGenerate && commitAiProvider !== "local");
|
||||
</script>
|
||||
|
||||
<section class="panel commit-panel" aria-label="Commit">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Commit</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Message</h2>
|
||||
<span class="eyebrow">Create revision</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commit</h2>
|
||||
</div>
|
||||
<div class="commit-head-actions">
|
||||
<span class="pill pill-count">{stagedCount} staged</span>
|
||||
<button
|
||||
class="commit-review-button"
|
||||
type="button"
|
||||
onclick={onReviewStaged}
|
||||
disabled={!canReview}
|
||||
title={commitAiProvider === "local" ? "Pre-commit review currently requires an API provider" : stagedCount === 0 ? "Stage changes first" : "Review staged changes for bugs and risks"}
|
||||
>
|
||||
{#if commitAiReviewing}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<ShieldCheck size={14} aria-hidden="true" />{/if}
|
||||
Review
|
||||
</button>
|
||||
<button
|
||||
class="commit-generate-button"
|
||||
type="button"
|
||||
onclick={onGenerateCommitMessage}
|
||||
disabled={!canGenerate}
|
||||
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
|
||||
>
|
||||
{#if commitAiGenerating || localModelLoading}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<Sparkles size={14} aria-hidden="true" />{/if}
|
||||
Generate
|
||||
</button>
|
||||
<button class="commit-settings-button" type="button" onclick={onOpenAiSettings} disabled={isBusy} title="AI settings" aria-label="AI settings">
|
||||
<Settings size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<span class="pill pill-count">{stagedCount} staged</span>
|
||||
</div>
|
||||
|
||||
<form class="commit-form" onsubmit={handleSubmit}>
|
||||
<textarea
|
||||
value={commitMessage}
|
||||
oninput={(e) => onCommitMessageChange((e.target as HTMLTextAreaElement).value)}
|
||||
placeholder="Commit message..."
|
||||
placeholder="Commit message"
|
||||
disabled={!hasRepository || isBusy}
|
||||
></textarea>
|
||||
{#if commitBlockReason}
|
||||
@@ -117,30 +148,6 @@
|
||||
{/if}
|
||||
{amendMode ? "Amend" : "Commit"}
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary commit-ai-button"
|
||||
type="button"
|
||||
onclick={onGenerateCommitMessage}
|
||||
disabled={!canGenerate}
|
||||
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
|
||||
>
|
||||
{#if commitAiGenerating || localModelLoading}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Sparkles size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
AI
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary commit-ai-settings-button"
|
||||
type="button"
|
||||
onclick={onOpenAiSettings}
|
||||
disabled={isBusy}
|
||||
title="AI settings"
|
||||
aria-label="AI settings"
|
||||
>
|
||||
<Settings size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
rightNum?: number; rightText?: string; rightKind: "add" | "context" | "empty";
|
||||
};
|
||||
|
||||
interface DiffMarker {
|
||||
start: number;
|
||||
end: number;
|
||||
kind: "add" | "delete" | "mixed";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
comparison: GitCommitComparison;
|
||||
selectedDiffPath: string;
|
||||
@@ -173,10 +179,40 @@
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildDiffMarkers(rows: SplitRow[]): DiffMarker[] {
|
||||
const markers: DiffMarker[] = [];
|
||||
let current: DiffMarker | null = null;
|
||||
for (let index = 0; index < rows.length; index++) {
|
||||
const row = rows[index];
|
||||
if (row.type !== "pair" || (row.leftKind === "context" && row.rightKind === "context")) {
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
const kind = row.leftKind === "del" && row.rightKind === "add"
|
||||
? "mixed"
|
||||
: row.rightKind === "add" ? "add" : "delete";
|
||||
if (current && current.end === index - 1 && current.kind === kind) {
|
||||
current.end = index;
|
||||
} else {
|
||||
current = { start: index, end: index, kind };
|
||||
markers.push(current);
|
||||
}
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
function scrollToDiffMarker(rowIndex: number) {
|
||||
const ratio = rowIndex / Math.max(splitRows.length - 1, 1);
|
||||
for (const pane of [beforePane, afterPane]) {
|
||||
if (pane) pane.scrollTop = ratio * Math.max(pane.scrollHeight - pane.clientHeight, 0);
|
||||
}
|
||||
}
|
||||
|
||||
let diffByPath = $derived(buildDiffByPath(comparison.patch));
|
||||
let selectedFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null);
|
||||
let selectedPatch = $derived(selectedFile ? (diffByPath.get(selectedFile.path) ?? "") : "");
|
||||
let splitRows = $derived(buildSplitRows(selectedPatch));
|
||||
let diffMarkers = $derived(buildDiffMarkers(splitRows));
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -262,6 +298,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Split diff grid -->
|
||||
<div class="split-diff-shell">
|
||||
<div class="split-diff" role="table" aria-label="Side-by-side diff">
|
||||
<div
|
||||
class="split-pane"
|
||||
@@ -298,6 +335,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="diff-overview" aria-label="Change overview">
|
||||
{#each diffMarkers as marker, index (`${marker.start}-${marker.end}-${marker.kind}`)}
|
||||
<button
|
||||
class="diff-overview-marker {marker.kind}"
|
||||
type="button"
|
||||
style={`--marker-position: ${(marker.start / Math.max(splitRows.length - 1, 1)) * 100}%`}
|
||||
onclick={() => scrollToDiffMarker(marker.start)}
|
||||
title={`Jump to change ${index + 1} of ${diffMarkers.length}`}
|
||||
aria-label={`Jump to change ${index + 1} of ${diffMarkers.length}`}
|
||||
></button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
GitCommitHorizontal,
|
||||
Home,
|
||||
Keyboard,
|
||||
Library,
|
||||
Lightbulb,
|
||||
ListChecks,
|
||||
Search,
|
||||
Wrench,
|
||||
X,
|
||||
@@ -465,6 +467,732 @@
|
||||
},
|
||||
];
|
||||
|
||||
// Extended handbook chapters. Keeping these additions next to the shared data makes
|
||||
// it straightforward to compare the German and English coverage section by section.
|
||||
deCategories.find((category) => category.id === "start")?.sections.push(
|
||||
{
|
||||
id: "start-model",
|
||||
title: "Das Git-Grundmodell verstehen",
|
||||
summary: "Git speichert keine fortlaufende Liste einzelner Dateiänderungen, sondern verknüpfte Schnappschüsse deines Projekts. HEAD zeigt auf deinen aktuellen Commit; der Branch-Name bewegt sich beim Commit mit.",
|
||||
steps: [
|
||||
"Arbeitsverzeichnis: Hier bearbeitest du echte Dateien. Änderungen sind noch nicht Teil eines Commits.",
|
||||
"Staging-Bereich: Hier stellst du exakt den Inhalt des nächsten Commits zusammen.",
|
||||
"Lokales Repository: Commits, Branches und Tags liegen zunächst nur auf deinem Rechner.",
|
||||
"Remote-Repository: Push veröffentlicht lokale Commits; Fetch lädt fremde Referenzen; Pull lädt und integriert.",
|
||||
],
|
||||
note: "Der Staging-Bereich ist kein zusätzlicher Ordner. Er ist ein Git-Schnappschuss, den Gitty als „Staged“ darstellt.",
|
||||
},
|
||||
{
|
||||
id: "start-before-work",
|
||||
title: "Checkliste vor jeder Aufgabe",
|
||||
summary: "Ein kurzer Zustandscheck verhindert die meisten versehentlichen Commits und komplizierten Konflikte.",
|
||||
commands: [
|
||||
{ command: "git status --short --branch", description: "Branch, Upstream und Änderungen kompakt prüfen" },
|
||||
{ command: "git fetch --prune", description: "Remote-Stand aktualisieren, ohne Dateien zu verändern" },
|
||||
{ command: "git log --oneline --decorate -10", description: "Die letzten zehn Commits und Referenzen prüfen" },
|
||||
],
|
||||
steps: [
|
||||
"Prüfe, ob du auf dem richtigen Branch bist.",
|
||||
"Sichere oder stash unvollständige Änderungen, bevor du den Branch wechselst.",
|
||||
"Hole Remote-Informationen mit Fetch und entscheide erst danach über Pull, Rebase oder Merge.",
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
deCategories.find((category) => category.id === "app")?.sections.push(
|
||||
{
|
||||
id: "app-repositories",
|
||||
title: "Repositories und Tabs verwalten",
|
||||
summary: "Die Repository-Verwaltung bündelt offene, zuletzt verwendete und favorisierte Projekte. Jedes offene Repository erhält einen eigenen Tab mit Branch- und Änderungsstatus.",
|
||||
steps: [
|
||||
"Browse öffnet ein bestehendes lokales Repository; Clone lädt ein Remote-Repository in einen neuen Ordner.",
|
||||
"Markiere häufig verwendete Projekte als Favorit, damit sie unabhängig von der Verlaufsliste sichtbar bleiben.",
|
||||
"Wechsle über die Tabs zwischen Projekten. Gitty merkt sich Status, Größen und ausgewählte Bereiche pro Sitzung.",
|
||||
"Öffne das Tab-Kontextmenü, um ein Repository aus der aktuellen Arbeitsfläche zu entfernen, ohne Dateien zu löschen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "app-commit-detail",
|
||||
title: "Saubere Commits in Gitty erstellen",
|
||||
summary: "Ein guter Commit enthält genau eine logisch zusammengehörige Änderung und lässt sich unabhängig erklären, prüfen und notfalls zurücknehmen.",
|
||||
steps: [
|
||||
"Prüfe zuerst Unstaged und den vollständigen Diff jeder betroffenen Datei.",
|
||||
"Stage nur passende Dateien, Hunks oder Zeilen. Tests und Implementierung dürfen zusammengehören; zufällige Formatierungen meist nicht.",
|
||||
"Lies anschließend ausschließlich den Staged-Diff – genau dieser Inhalt wird committed.",
|
||||
"Formuliere eine kurze, imperative Betreffzeile, zum Beispiel „Handle expired credentials“.",
|
||||
"Nutze Amend nur, solange der letzte Commit noch nicht von anderen verwendet wird.",
|
||||
],
|
||||
note: "Wenn du im Staged-Diff etwas Überraschendes siehst, entferne es wieder aus dem Staging-Bereich. Ein Commit ist der falsche Ort für „wird schon passen“.",
|
||||
},
|
||||
{
|
||||
id: "app-branches-tags",
|
||||
title: "Branches und Tags in der App",
|
||||
summary: "Das Branch-Panel zeigt lokale und Remote-Branches sowie Ahead/Behind. Über das Kontextmenü kannst du wechseln, erstellen, umbenennen, löschen, mergen oder rebasen.",
|
||||
steps: [
|
||||
"Erstelle einen Branch vom aktuellen HEAD oder gezielt von einem Commit im Verlauf.",
|
||||
"Ein Checkout/Switch aktualisiert Arbeitsverzeichnis und HEAD. Sichere inkompatible lokale Änderungen vorher.",
|
||||
"Tags markieren feste Commits, typischerweise Releases. Ein Tag bewegt sich nicht automatisch weiter.",
|
||||
"Remote-Branches sind zunächst Referenzen. Erstelle beim Wechsel einen lokalen Tracking-Branch.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "app-history-tools",
|
||||
title: "History, Dateiverlauf, Blame und Restore",
|
||||
summary: "Gitty verbindet Commit-Graph, Dateiverlauf und Wiederherstellung, damit du Ursache und Entwicklung einer Änderung nachvollziehen kannst.",
|
||||
steps: [
|
||||
"Wähle eine Datei im Explorer, um ihren eigenen Verlauf unabhängig vom Gesamtprojekt zu sehen.",
|
||||
"Blame ordnet jeder aktuellen Zeile den letzten verändernden Commit zu. Nutze es als Einstieg, nicht als Schuldzuweisung.",
|
||||
"Compare zeigt Unterschiede zwischen zwei beliebigen Commits oder Branch-Spitzen.",
|
||||
"Restore from commit übernimmt eine ältere Dateiversion ins Arbeitsverzeichnis. Prüfe und committe das Ergebnis anschließend normal.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "app-search",
|
||||
title: "Code-Ursprung mit Global Search finden",
|
||||
summary: "Die Code-Suche untersucht die Commit-Historie und findet, in welchem Commit eine Zeichenfolge oder Funktion eingeführt wurde. Die Dateisuche verbindet Pfadsuche mit Dateihistorie.",
|
||||
steps: [
|
||||
"Suche nach einem stabilen, möglichst eindeutigen Ausschnitt statt nach einer häufigen Einzelzeile.",
|
||||
"Aktiviere Groß-/Kleinschreibung nur, wenn sie die Treffermenge sinnvoll reduziert.",
|
||||
"Öffne einen Treffer als Diff, um die Einführung im Kontext des gesamten Commits zu prüfen.",
|
||||
"Bei Umbenennungen zeigt Gitty alten und neuen Pfad, soweit Git sie aus der Ähnlichkeit ableiten kann.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "app-operations",
|
||||
title: "Laufende Git-Operationen sicher beenden",
|
||||
summary: "Während Rebase oder Cherry-pick zeigt Gitty einen speziellen Status. Löse alle Konflikte und entscheide dann bewusst zwischen Continue und Abort.",
|
||||
steps: [
|
||||
"Resolve öffnet jede Konfliktdatei mit Current, Incoming und editierbarem Zielinhalt.",
|
||||
"Markiere erst nach inhaltlicher Prüfung als gelöst; „keine Konfliktmarker mehr“ bedeutet nicht automatisch „fachlich richtig“.",
|
||||
"Continue verarbeitet den nächsten Commit und kann weitere Konflikte erzeugen.",
|
||||
"Abort stellt den Zustand vor Beginn der gesamten Operation wieder her.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "app-credentials-settings",
|
||||
title: "Zugangsdaten, AI und Einstellungen",
|
||||
summary: "Gitty fragt Zugangsdaten erst bei einer authentifizierten Remote-Aktion ab. App-Theme, Sprache und anonyme Analytics liegen in den Einstellungen; AI-Anbieter werden separat konfiguriert.",
|
||||
steps: [
|
||||
"Verwende für HTTPS-Remotes ein persönliches Zugriffstoken statt des Account-Passworts.",
|
||||
"Begrenze Token-Rechte und Laufzeit auf das tatsächlich benötigte Minimum.",
|
||||
"AI-generierte Commit-Texte sind Vorschläge: Prüfe Inhalt, sensible Daten und tatsächlichen Staged-Diff.",
|
||||
"Gitty sendet über Analytics keine Pfade, Remotes, Branches, Commit-Texte, Dateinamen, Diffs, Zugangsdaten oder Code.",
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
deCategories.find((category) => category.id === "basics")?.sections.push(
|
||||
{
|
||||
id: "basics-config",
|
||||
title: "Identität und Konfiguration",
|
||||
summary: "Git schreibt Name und E-Mail in jeden Commit. Globale Werte gelten für alle Repositories; lokale Werte überschreiben sie nur im aktuellen Projekt.",
|
||||
commands: [
|
||||
{ command: "git config --global user.name \"Ada Lovelace\"", description: "Globalen Anzeigenamen setzen" },
|
||||
{ command: "git config --global user.email \"ada@example.com\"", description: "Globale Commit-E-Mail setzen" },
|
||||
{ command: "git config --list --show-origin", description: "Wirksame Einstellungen und Quelldateien anzeigen" },
|
||||
{ command: "git config user.email \"work@example.com\"", description: "E-Mail nur für das aktuelle Repository setzen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "basics-ignore",
|
||||
title: ".gitignore richtig verwenden",
|
||||
summary: ".gitignore verhindert, dass neue, noch ungetrackte Dateien vorgeschlagen werden. Bereits getrackte Dateien werden dadurch nicht automatisch entfernt.",
|
||||
commands: [
|
||||
{ command: "git check-ignore -v <datei>", description: "Zeigen, welche Ignore-Regel auf eine Datei wirkt" },
|
||||
{ command: "git rm --cached <datei>", description: "Datei nur aus Git entfernen, lokal aber behalten" },
|
||||
{ command: "git status --ignored", description: "Auch ignorierte Dateien anzeigen" },
|
||||
],
|
||||
note: "Committe niemals Secrets. .gitignore verhindert zukünftiges Tracking, entfernt aber keine Geheimnisse aus bereits vorhandenen Commits.",
|
||||
},
|
||||
{
|
||||
id: "basics-show",
|
||||
title: "Commits und Objekte untersuchen",
|
||||
summary: "Hashes identifizieren Git-Objekte. Meist reichen die ersten eindeutigen Zeichen; Referenzen wie HEAD~1 oder main sind lesbare Zeiger auf Commits.",
|
||||
commands: [
|
||||
{ command: "git show <commit>", description: "Metadaten und Patch eines Commits anzeigen" },
|
||||
{ command: "git show <commit>:<pfad>", description: "Dateiinhalt aus einem bestimmten Commit ausgeben" },
|
||||
{ command: "git diff <von>..<bis>", description: "Zwei Zustände direkt vergleichen" },
|
||||
{ command: "git log --follow -- <datei>", description: "Dateiverlauf über Umbenennungen hinweg verfolgen" },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
deCategories.find((category) => category.id === "branches")?.sections.push(
|
||||
{
|
||||
id: "branches-strategy",
|
||||
title: "Eine einfache Branch-Strategie",
|
||||
summary: "Kurze, fokussierte Branches reduzieren Konflikte. Aktualisiere sie regelmäßig und integriere sie nach Review möglichst schnell.",
|
||||
steps: [
|
||||
"Starte vom aktuellen main und gib dem Branch einen beschreibenden Namen wie feature/help-search.",
|
||||
"Committe kleine, nachvollziehbare Einheiten und pushe den Branch als Sicherung und für Review.",
|
||||
"Synchronisiere vor Abschluss mit dem aktuellen Ziel-Branch und löse Konflikte im eigenen Branch.",
|
||||
"Merge nach bestandenem Review und lösche den kurzlebigen Branch lokal sowie remote.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "branches-cherry-pick",
|
||||
title: "Cherry-pick gezielt einsetzen",
|
||||
summary: "Cherry-pick kopiert die Änderung eines vorhandenen Commits als neuen Commit auf den aktuellen Branch. Das ist praktisch für einzelne Fixes, ersetzt aber keine normale Branch-Integration.",
|
||||
commands: [
|
||||
{ command: "git cherry-pick <commit>", description: "Einen Commit auf den aktuellen Branch kopieren" },
|
||||
{ command: "git cherry-pick --no-commit <commit>", description: "Änderung übernehmen, aber vor dem Commit weiter bearbeiten" },
|
||||
{ command: "git cherry-pick --continue", description: "Nach Konfliktlösung fortsetzen" },
|
||||
{ command: "git cherry-pick --abort", description: "Gesamten Cherry-pick abbrechen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "branches-interactive-rebase",
|
||||
title: "Interactive Rebase",
|
||||
summary: "Vor dem Veröffentlichen kannst du lokale Commits neu ordnen, umbenennen, zusammenfassen oder entfernen. Gitty bietet dafür einen visuellen Rebase-Plan.",
|
||||
steps: [
|
||||
"Pick behält einen Commit, Reword ändert seine Nachricht, Squash/Fixup kombiniert ihn mit dem vorherigen Commit, Drop entfernt ihn.",
|
||||
"Ordne Abhängigkeiten so, dass jeder Zwischenschritt möglichst baubar und verständlich bleibt.",
|
||||
"Prüfe nach dem Rebase Tests, Commit-Reihenfolge und finalen Diff gegen den Ziel-Branch.",
|
||||
],
|
||||
note: "Interactive Rebase erzeugt neue Commit-Hashes. Verwende ihn bevorzugt für deine eigenen, noch nicht gemeinsam genutzten Commits.",
|
||||
},
|
||||
{
|
||||
id: "branches-tags",
|
||||
title: "Releases mit Tags markieren",
|
||||
summary: "Ein annotierter Tag speichert zusätzlich Autor, Datum und Nachricht und eignet sich deshalb besser für Releases als ein einfacher Lightweight-Tag.",
|
||||
commands: [
|
||||
{ command: "git tag -a v1.2.0 -m \"Release 1.2.0\"", description: "Annotierten Release-Tag erstellen" },
|
||||
{ command: "git show v1.2.0", description: "Tag und zugehörigen Commit prüfen" },
|
||||
{ command: "git push origin v1.2.0", description: "Einen bestimmten Tag veröffentlichen" },
|
||||
{ command: "git push origin --tags", description: "Alle noch fehlenden lokalen Tags veröffentlichen" },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
deCategories.find((category) => category.id === "remote")?.sections.push(
|
||||
{
|
||||
id: "remote-tracking",
|
||||
title: "Tracking-Branches und Upstream",
|
||||
summary: "Der Upstream verbindet einen lokalen Branch mit seiner Remote-Referenz. Dadurch wissen Pull, Push und Ahead/Behind, welche beiden Linien verglichen werden.",
|
||||
commands: [
|
||||
{ command: "git branch --show-current", description: "Aktuellen lokalen Branch anzeigen" },
|
||||
{ command: "git branch -u origin/<branch>", description: "Upstream für den aktuellen Branch setzen" },
|
||||
{ command: "git branch -vv", description: "Upstream und Ahead/Behind aller lokalen Branches anzeigen" },
|
||||
{ command: "git push -u origin HEAD", description: "Aktuellen Branch veröffentlichen und Upstream setzen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "remote-safe-pull",
|
||||
title: "Sicher synchronisieren",
|
||||
summary: "Fetch ist immer der kontrollierteste erste Schritt. Danach kannst du den Unterschied prüfen und bewusst Merge oder Rebase wählen.",
|
||||
commands: [
|
||||
{ command: "git fetch origin", description: "Remote-Informationen laden, ohne den lokalen Branch zu ändern" },
|
||||
{ command: "git log --oneline HEAD..@{upstream}", description: "Commits anzeigen, die lokal noch fehlen" },
|
||||
{ command: "git log --oneline @{upstream}..HEAD", description: "Noch nicht veröffentlichte lokale Commits anzeigen" },
|
||||
{ command: "git diff HEAD...@{upstream}", description: "Änderungen seit dem gemeinsamen Ausgangspunkt vergleichen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "remote-force",
|
||||
title: "Force Push verstehen",
|
||||
summary: "Nach einem Rebase stimmt die lokale Historie nicht mehr mit dem Remote überein. --force-with-lease überschreibt nur, wenn niemand den Remote-Branch seit deinem letzten Fetch verändert hat.",
|
||||
commands: [
|
||||
{ command: "git push --force-with-lease", description: "Rebaseten Branch mit Schutz vor fremden neuen Commits aktualisieren" },
|
||||
],
|
||||
note: "Verwende niemals blind --force auf gemeinsam genutzten Branches. Bevorzuge --force-with-lease und stimme das Umschreiben der Historie im Team ab.",
|
||||
},
|
||||
);
|
||||
|
||||
deCategories.find((category) => category.id === "troubleshooting")?.sections.push(
|
||||
{
|
||||
id: "trouble-undo-map",
|
||||
title: "Restore, Reset und Revert unterscheiden",
|
||||
summary: "Die drei Befehle lösen verschiedene Probleme: Restore betrifft Dateien, Reset verschiebt Branch/Index, Revert macht veröffentlichte Änderungen durch einen neuen Commit rückgängig.",
|
||||
commands: [
|
||||
{ command: "git restore <datei>", description: "Nicht gestagte Dateiänderungen verwerfen" },
|
||||
{ command: "git restore --staged <datei>", description: "Staging rückgängig machen, Dateiänderung behalten" },
|
||||
{ command: "git reset --soft HEAD~1", description: "Letzten lokalen Commit entfernen, alles gestaged behalten" },
|
||||
{ command: "git revert <commit>", description: "Veröffentlichten Commit sicher durch Gegen-Commit umkehren" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "trouble-errors",
|
||||
title: "Typische Fehlermeldungen",
|
||||
summary: "Git-Fehler beschreiben meist den blockierenden Zustand. Prüfe zuerst status, Branch, Upstream und laufende Operationen, bevor du Befehle wiederholst.",
|
||||
steps: [
|
||||
"non-fast-forward: Im Remote existieren Commits, die lokal fehlen. Fetch, vergleichen und integrieren.",
|
||||
"detached HEAD: Du bist direkt auf einem Commit. Erstelle einen Branch, wenn du neue Arbeit behalten willst.",
|
||||
"pathspec did not match: Pfad oder Branch-Name ist falsch oder lokal noch nicht vorhanden. Prüfe Schreibweise und Fetch-Stand.",
|
||||
"local changes would be overwritten: Committe, stash oder verwerfe die genannten Änderungen vor Checkout/Pull.",
|
||||
"not a git repository: Aktueller Ordner liegt außerhalb eines Repositorys oder .git fehlt.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "trouble-diagnose",
|
||||
title: "Diagnose ohne weitere Schäden",
|
||||
summary: "Bevor du Reset, Clean oder Force verwendest, sichere den aktuellen Zustand und sammle lesende Informationen.",
|
||||
commands: [
|
||||
{ command: "git status", description: "Aktuellen Zustand und Handlungsanweisungen anzeigen" },
|
||||
{ command: "git diff && git diff --staged", description: "Ungesicherte und gestagte Änderungen vollständig prüfen" },
|
||||
{ command: "git branch backup/before-recovery", description: "Aktuellen Commit mit einem Sicherungs-Branch verankern" },
|
||||
{ command: "git stash push -u -m \"backup before recovery\"", description: "Auch ungetrackte lokale Arbeit vorübergehend sichern" },
|
||||
],
|
||||
note: "git clean -fd und git reset --hard können nicht getrackte beziehungsweise lokale Daten endgültig löschen. Nutze zuerst Vorschau, Backup-Branch oder Stash.",
|
||||
},
|
||||
);
|
||||
|
||||
deCategories.push(
|
||||
{
|
||||
id: "workflows",
|
||||
label: "Praxis-Workflows",
|
||||
description: "Bewährte Rezepte für typische Aufgaben vom Feature bis zum Hotfix.",
|
||||
sections: [
|
||||
{
|
||||
id: "workflow-feature",
|
||||
title: "Feature-Branch von Anfang bis Ende",
|
||||
summary: "Dieser Ablauf hält den Branch aktuell, den Commit-Verlauf verständlich und die Integration überschaubar.",
|
||||
commands: [
|
||||
{ command: "git switch main && git pull --ff-only", description: "Aktuellen, unveränderten Ausgangspunkt herstellen" },
|
||||
{ command: "git switch -c feature/<name>", description: "Neuen Feature-Branch erstellen" },
|
||||
{ command: "git push -u origin HEAD", description: "Branch veröffentlichen und Upstream setzen" },
|
||||
{ command: "git fetch origin && git rebase origin/main", description: "Vor Review auf aktuellen main setzen" },
|
||||
],
|
||||
steps: [
|
||||
"Arbeite in kleinen Commits und prüfe vor jedem Commit den Staged-Diff.",
|
||||
"Pushe regelmäßig als Sicherung und für Zusammenarbeit.",
|
||||
"Führe Tests nach der letzten Synchronisierung aus.",
|
||||
"Erstelle Review/PR, integriere nach Freigabe und lösche den Branch.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "workflow-hotfix",
|
||||
title: "Einzelnen Fix übernehmen",
|
||||
summary: "Wenn ein bereits vorhandener Fix gezielt in einen Release-Branch muss, ist Cherry-pick oft präziser als ein vollständiger Merge.",
|
||||
commands: [
|
||||
{ command: "git switch release/<version>", description: "Ziel-Branch wechseln" },
|
||||
{ command: "git pull --ff-only", description: "Sicherstellen, dass der Ziel-Branch aktuell ist" },
|
||||
{ command: "git cherry-pick -x <fix-commit>", description: "Fix übernehmen und Herkunft in der Nachricht dokumentieren" },
|
||||
],
|
||||
note: "Prüfe, ob der Fix von früheren Commits abhängt. Ein technisch erfolgreicher Cherry-pick kann fachlich unvollständig sein.",
|
||||
},
|
||||
{
|
||||
id: "workflow-clean-commit",
|
||||
title: "Gemischte Änderungen in saubere Commits teilen",
|
||||
summary: "Du musst nicht alles committen, was gerade geändert ist. Staging nach Hunk oder Zeile trennt Refactoring, Fix und Dokumentation.",
|
||||
commands: [
|
||||
{ command: "git add -p", description: "Änderungen abschnittsweise auswählen" },
|
||||
{ command: "git diff --staged", description: "Ersten Commit-Inhalt prüfen" },
|
||||
{ command: "git commit", description: "Ersten logischen Commit erstellen" },
|
||||
{ command: "git add -p && git commit", description: "Mit dem nächsten Themenblock fortfahren" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reference",
|
||||
label: "Referenz & Glossar",
|
||||
description: "Kompakte Befehlsübersicht und zentrale Git-Begriffe zum Nachschlagen.",
|
||||
sections: [
|
||||
{
|
||||
id: "reference-daily",
|
||||
title: "Tägliche Kurzreferenz",
|
||||
summary: "Die häufigsten sicheren Befehle für Orientierung, Änderung, Commit und Synchronisierung.",
|
||||
commands: [
|
||||
{ command: "git status", description: "Zustand prüfen" },
|
||||
{ command: "git diff", description: "Lokale Änderungen lesen" },
|
||||
{ command: "git add -p", description: "Gezielt stagen" },
|
||||
{ command: "git diff --staged", description: "Commit-Inhalt prüfen" },
|
||||
{ command: "git commit", description: "Commit erstellen" },
|
||||
{ command: "git fetch --prune", description: "Remote-Stand aktualisieren" },
|
||||
{ command: "git push", description: "Lokale Commits veröffentlichen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reference-glossary",
|
||||
title: "Git-Glossar",
|
||||
summary: "HEAD ist der aktuelle Checkout. Branches und Tags sind Referenzen auf Commits. origin ist nur der übliche Name eines Remotes. Upstream ist die zugeordnete Remote-Referenz eines lokalen Branches.",
|
||||
steps: [
|
||||
"Commit: Unveränderlicher Projekt-Schnappschuss mit Eltern, Autor, Zeit und Nachricht.",
|
||||
"Index/Staging: Vorbereiteter Schnappschuss für den nächsten Commit.",
|
||||
"Working tree: Ausgecheckte Dateien, die du gerade bearbeitest.",
|
||||
"Remote: Benannte Verbindung zu einem anderen Repository, nicht automatisch „die Cloud“.",
|
||||
"Fast-forward: Branch-Zeiger kann ohne Merge-Commit direkt nach vorn bewegt werden.",
|
||||
"Detached HEAD: HEAD zeigt direkt auf einen Commit statt auf einen lokalen Branch.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reference-safety",
|
||||
title: "Gefahrenstufen von Git-Befehlen",
|
||||
summary: "Lesende Befehle wie status, log, show und diff sind unkritisch. Restore, Reset, Clean, Rebase und Force Push verändern oder löschen Zustand und verdienen eine zusätzliche Prüfung.",
|
||||
steps: [
|
||||
"Sicher lesend: status, log, show, diff, branch, remote -v, reflog.",
|
||||
"Lokal verändernd: add, restore, commit, stash, switch, merge, rebase.",
|
||||
"Potenziell datenlöschend: reset --hard, clean -fd, branch -D.",
|
||||
"Teamweit riskant: push --force, veröffentlichte Commits rebasen oder Tags verschieben.",
|
||||
],
|
||||
note: "Wenn du unsicher bist: Stoppe, erstelle einen Backup-Branch und prüfe git status sowie git reflog. Git belohnt kleine, nachvollziehbare Schritte.",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
enCategories.find((category) => category.id === "start")?.sections.push(
|
||||
{
|
||||
id: "start-model",
|
||||
title: "Understand Git's core model",
|
||||
summary: "Git stores linked snapshots of your project rather than a running list of individual file edits. HEAD points to the current commit; the branch name moves forward when you commit.",
|
||||
steps: [
|
||||
"Working tree: The real files you edit. Changes are not part of a commit yet.",
|
||||
"Staging area: The exact snapshot you are preparing for the next commit.",
|
||||
"Local repository: Commits, branches, and tags initially exist only on your machine.",
|
||||
"Remote repository: Push publishes commits, Fetch downloads references, and Pull downloads and integrates.",
|
||||
],
|
||||
note: "The staging area is not another folder. It is a Git snapshot that Gitty presents as “Staged”.",
|
||||
},
|
||||
{
|
||||
id: "start-before-work",
|
||||
title: "Checklist before every task",
|
||||
summary: "A short state check prevents most accidental commits and complicated conflicts.",
|
||||
commands: [
|
||||
{ command: "git status --short --branch", description: "Check branch, upstream, and changes concisely" },
|
||||
{ command: "git fetch --prune", description: "Refresh remote state without changing files" },
|
||||
{ command: "git log --oneline --decorate -10", description: "Review the latest ten commits and references" },
|
||||
],
|
||||
steps: [
|
||||
"Confirm that you are on the correct branch.",
|
||||
"Commit or stash unfinished changes before switching branches.",
|
||||
"Fetch remote information, then choose deliberately between Pull, Rebase, and Merge.",
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
enCategories.find((category) => category.id === "app")?.sections.push(
|
||||
{
|
||||
id: "app-repositories",
|
||||
title: "Manage repositories and tabs",
|
||||
summary: "Repository Management groups open, recent, and favorite projects. Every open repository gets a tab with its branch and change status.",
|
||||
steps: [
|
||||
"Browse opens an existing local repository; Clone downloads a remote repository into a new folder.",
|
||||
"Favorite frequently used projects so they remain visible independently of recent history.",
|
||||
"Switch between projects with tabs. Gitty keeps useful repository state available during the session.",
|
||||
"Use the tab context menu to remove a repository from the workspace without deleting its files.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "app-commit-detail",
|
||||
title: "Create clean commits in Gitty",
|
||||
summary: "A good commit contains one logical change and can be explained, reviewed, and reverted independently.",
|
||||
steps: [
|
||||
"Review Unstaged and the complete diff of every affected file.",
|
||||
"Stage only related files, hunks, or lines. Tests and implementation may belong together; unrelated formatting usually does not.",
|
||||
"Review the Staged diff by itself—this is exactly what will be committed.",
|
||||
"Write a short imperative subject, for example “Handle expired credentials”.",
|
||||
"Use Amend only while nobody else depends on the latest commit.",
|
||||
],
|
||||
note: "If the Staged diff contains a surprise, unstage it. A commit is the wrong place for “it will probably be fine”.",
|
||||
},
|
||||
{
|
||||
id: "app-branches-tags",
|
||||
title: "Branches and tags in the app",
|
||||
summary: "The Branch panel shows local and remote branches plus Ahead/Behind. Its context menu supports switch, create, rename, delete, merge, and rebase actions.",
|
||||
steps: [
|
||||
"Create a branch from the current HEAD or from a specific commit in History.",
|
||||
"Checkout/Switch updates the working tree and HEAD. Save incompatible local changes first.",
|
||||
"Tags mark fixed commits, usually releases. A tag does not move forward automatically.",
|
||||
"Remote branches are references. Switching creates a local tracking branch when needed.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "app-history-tools",
|
||||
title: "History, file history, Blame, and Restore",
|
||||
summary: "Gitty connects the commit graph, file history, and restoration tools so you can understand how and why a change evolved.",
|
||||
steps: [
|
||||
"Select a file in Explorer to view its history separately from the project history.",
|
||||
"Blame links every current line to its latest modifying commit. Use it as a starting point, not as an accusation.",
|
||||
"Compare shows the difference between any two commits or branch tips.",
|
||||
"Restore from commit writes an older file version into the working tree. Review and commit the result normally.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "app-search",
|
||||
title: "Find code origins with Global Search",
|
||||
summary: "Code Search examines commit history to find where text or a function was introduced. File Search combines path search with file history.",
|
||||
steps: [
|
||||
"Search for a stable, distinctive excerpt rather than a common single line.",
|
||||
"Enable case sensitivity only when it meaningfully reduces results.",
|
||||
"Open a result as a diff to review the introduction in the full commit context.",
|
||||
"For renames, Gitty shows old and new paths when Git can infer the similarity.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "app-operations",
|
||||
title: "Finish active Git operations safely",
|
||||
summary: "During Rebase or Cherry-pick, Gitty displays a dedicated state. Resolve every conflict, then choose deliberately between Continue and Abort.",
|
||||
steps: [
|
||||
"Resolve opens each conflict with Current, Incoming, and editable result content.",
|
||||
"Mark a file resolved only after reviewing its meaning; removing conflict markers is not enough.",
|
||||
"Continue processes the next commit and may reveal additional conflicts.",
|
||||
"Abort restores the state from before the entire operation began.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "app-credentials-settings",
|
||||
title: "Credentials, AI, and settings",
|
||||
summary: "Gitty requests credentials only for authenticated remote actions. Theme, language, and anonymous analytics live in Settings; AI providers are configured separately.",
|
||||
steps: [
|
||||
"Use a personal access token instead of the account password for HTTPS remotes.",
|
||||
"Limit token permissions and lifetime to the minimum required.",
|
||||
"AI-generated commit messages are suggestions: verify content, sensitive data, and the actual Staged diff.",
|
||||
"Analytics never sends repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code.",
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
enCategories.find((category) => category.id === "basics")?.sections.push(
|
||||
{
|
||||
id: "basics-config",
|
||||
title: "Identity and configuration",
|
||||
summary: "Git writes your name and email into every commit. Global values apply to all repositories; local values override them only in the current project.",
|
||||
commands: [
|
||||
{ command: "git config --global user.name \"Ada Lovelace\"", description: "Set the global display name" },
|
||||
{ command: "git config --global user.email \"ada@example.com\"", description: "Set the global commit email" },
|
||||
{ command: "git config --list --show-origin", description: "Show effective settings and their source files" },
|
||||
{ command: "git config user.email \"work@example.com\"", description: "Set email only for the current repository" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "basics-ignore",
|
||||
title: "Use .gitignore correctly",
|
||||
summary: ".gitignore prevents new untracked files from being suggested. It does not automatically remove files that Git already tracks.",
|
||||
commands: [
|
||||
{ command: "git check-ignore -v <file>", description: "Show which ignore rule applies to a file" },
|
||||
{ command: "git rm --cached <file>", description: "Remove a file from Git while keeping it locally" },
|
||||
{ command: "git status --ignored", description: "Include ignored files in status output" },
|
||||
],
|
||||
note: "Never commit secrets. .gitignore prevents future tracking but does not remove secrets from existing commits.",
|
||||
},
|
||||
{
|
||||
id: "basics-show",
|
||||
title: "Inspect commits and objects",
|
||||
summary: "Hashes identify Git objects. The first unique characters are usually enough; references such as HEAD~1 or main are readable pointers to commits.",
|
||||
commands: [
|
||||
{ command: "git show <commit>", description: "Show a commit's metadata and patch" },
|
||||
{ command: "git show <commit>:<path>", description: "Print a file from a specific commit" },
|
||||
{ command: "git diff <from>..<to>", description: "Compare two states directly" },
|
||||
{ command: "git log --follow -- <file>", description: "Follow file history across renames" },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
enCategories.find((category) => category.id === "branches")?.sections.push(
|
||||
{
|
||||
id: "branches-strategy",
|
||||
title: "A simple branch strategy",
|
||||
summary: "Short, focused branches reduce conflicts. Update them regularly and integrate them soon after review.",
|
||||
steps: [
|
||||
"Start from an up-to-date main and use a descriptive name such as feature/help-search.",
|
||||
"Commit small understandable units and push the branch for backup and review.",
|
||||
"Synchronize with the target branch before completion and resolve conflicts on your branch.",
|
||||
"Merge after review and delete the short-lived branch locally and remotely.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "branches-cherry-pick",
|
||||
title: "Use Cherry-pick deliberately",
|
||||
summary: "Cherry-pick copies the change from an existing commit as a new commit on the current branch. It is useful for individual fixes but not a replacement for normal branch integration.",
|
||||
commands: [
|
||||
{ command: "git cherry-pick <commit>", description: "Copy one commit onto the current branch" },
|
||||
{ command: "git cherry-pick --no-commit <commit>", description: "Apply the change but edit it before committing" },
|
||||
{ command: "git cherry-pick --continue", description: "Continue after conflict resolution" },
|
||||
{ command: "git cherry-pick --abort", description: "Abort the complete cherry-pick" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "branches-interactive-rebase",
|
||||
title: "Interactive Rebase",
|
||||
summary: "Before publishing, you can reorder, rename, combine, or remove local commits. Gitty provides a visual rebase plan.",
|
||||
steps: [
|
||||
"Pick keeps a commit, Reword changes its message, Squash/Fixup combines it with the previous commit, and Drop removes it.",
|
||||
"Order dependencies so each intermediate step remains as understandable and buildable as possible.",
|
||||
"After rebasing, run tests and review commit order and the final diff against the target branch.",
|
||||
],
|
||||
note: "Interactive Rebase creates new commit hashes. Prefer it for your own commits that are not yet shared.",
|
||||
},
|
||||
{
|
||||
id: "branches-tags",
|
||||
title: "Mark releases with tags",
|
||||
summary: "An annotated tag also stores author, date, and message, making it better for releases than a lightweight tag.",
|
||||
commands: [
|
||||
{ command: "git tag -a v1.2.0 -m \"Release 1.2.0\"", description: "Create an annotated release tag" },
|
||||
{ command: "git show v1.2.0", description: "Inspect the tag and referenced commit" },
|
||||
{ command: "git push origin v1.2.0", description: "Publish a specific tag" },
|
||||
{ command: "git push origin --tags", description: "Publish all missing local tags" },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
enCategories.find((category) => category.id === "remote")?.sections.push(
|
||||
{
|
||||
id: "remote-tracking",
|
||||
title: "Tracking branches and upstream",
|
||||
summary: "An upstream connects a local branch with its remote reference. This tells Pull, Push, and Ahead/Behind which two histories to compare.",
|
||||
commands: [
|
||||
{ command: "git branch --show-current", description: "Show the current local branch" },
|
||||
{ command: "git branch -u origin/<branch>", description: "Set the upstream of the current branch" },
|
||||
{ command: "git branch -vv", description: "Show upstream and Ahead/Behind for local branches" },
|
||||
{ command: "git push -u origin HEAD", description: "Publish the current branch and set its upstream" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "remote-safe-pull",
|
||||
title: "Synchronize safely",
|
||||
summary: "Fetch is the most controlled first step. You can then inspect the difference and deliberately choose Merge or Rebase.",
|
||||
commands: [
|
||||
{ command: "git fetch origin", description: "Download remote information without changing the local branch" },
|
||||
{ command: "git log --oneline HEAD..@{upstream}", description: "Show commits that are still missing locally" },
|
||||
{ command: "git log --oneline @{upstream}..HEAD", description: "Show unpublished local commits" },
|
||||
{ command: "git diff HEAD...@{upstream}", description: "Compare changes since the common ancestor" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "remote-force",
|
||||
title: "Understand Force Push",
|
||||
summary: "After rebasing, local history no longer matches the remote. --force-with-lease overwrites only if nobody changed the remote branch since your latest Fetch.",
|
||||
commands: [
|
||||
{ command: "git push --force-with-lease", description: "Update a rebased branch while protecting others' new commits" },
|
||||
],
|
||||
note: "Never use --force blindly on shared branches. Prefer --force-with-lease and coordinate history rewrites with the team.",
|
||||
},
|
||||
);
|
||||
|
||||
enCategories.find((category) => category.id === "troubleshooting")?.sections.push(
|
||||
{
|
||||
id: "trouble-undo-map",
|
||||
title: "Distinguish Restore, Reset, and Revert",
|
||||
summary: "The commands solve different problems: Restore changes files, Reset moves a branch or the index, and Revert undoes published changes through a new commit.",
|
||||
commands: [
|
||||
{ command: "git restore <file>", description: "Discard unstaged file changes" },
|
||||
{ command: "git restore --staged <file>", description: "Undo staging while keeping the file change" },
|
||||
{ command: "git reset --soft HEAD~1", description: "Remove the latest local commit and keep everything staged" },
|
||||
{ command: "git revert <commit>", description: "Safely undo a published commit with an inverse commit" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "trouble-errors",
|
||||
title: "Common error messages",
|
||||
summary: "Git errors usually describe the blocking state. Check status, branch, upstream, and active operations before repeating commands.",
|
||||
steps: [
|
||||
"non-fast-forward: The remote contains commits missing locally. Fetch, compare, and integrate them.",
|
||||
"detached HEAD: You are directly on a commit. Create a branch if you want to keep new work.",
|
||||
"pathspec did not match: The path or branch name is wrong or not available locally. Check spelling and Fetch state.",
|
||||
"local changes would be overwritten: Commit, stash, or discard the listed changes before Checkout or Pull.",
|
||||
"not a git repository: The current folder is outside a repository or its .git data is missing.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "trouble-diagnose",
|
||||
title: "Diagnose without causing more damage",
|
||||
summary: "Before using Reset, Clean, or Force, preserve the current state and collect read-only information.",
|
||||
commands: [
|
||||
{ command: "git status", description: "Show the current state and Git's suggested next steps" },
|
||||
{ command: "git diff && git diff --staged", description: "Review unstaged and staged changes completely" },
|
||||
{ command: "git branch backup/before-recovery", description: "Anchor the current commit with a backup branch" },
|
||||
{ command: "git stash push -u -m \"backup before recovery\"", description: "Temporarily protect tracked and untracked work" },
|
||||
],
|
||||
note: "git clean -fd and git reset --hard can permanently remove untracked or local data. Prefer a preview, backup branch, or stash first.",
|
||||
},
|
||||
);
|
||||
|
||||
enCategories.push(
|
||||
{
|
||||
id: "workflows",
|
||||
label: "Practical workflows",
|
||||
description: "Reliable recipes for common tasks from feature work to hotfixes.",
|
||||
sections: [
|
||||
{
|
||||
id: "workflow-feature",
|
||||
title: "Feature branch from start to finish",
|
||||
summary: "This workflow keeps your branch current, the commit history understandable, and integration manageable.",
|
||||
commands: [
|
||||
{ command: "git switch main && git pull --ff-only", description: "Establish a current, unchanged starting point" },
|
||||
{ command: "git switch -c feature/<name>", description: "Create a new feature branch" },
|
||||
{ command: "git push -u origin HEAD", description: "Publish the branch and set its upstream" },
|
||||
{ command: "git fetch origin && git rebase origin/main", description: "Move onto the latest main before review" },
|
||||
],
|
||||
steps: [
|
||||
"Work in small commits and review the Staged diff before every commit.",
|
||||
"Push regularly for backup and collaboration.",
|
||||
"Run tests after the final synchronization.",
|
||||
"Open a review or PR, integrate after approval, and delete the branch.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "workflow-hotfix",
|
||||
title: "Apply one isolated fix",
|
||||
summary: "When an existing fix must be applied to a release branch, Cherry-pick is often more precise than merging a complete branch.",
|
||||
commands: [
|
||||
{ command: "git switch release/<version>", description: "Switch to the target branch" },
|
||||
{ command: "git pull --ff-only", description: "Ensure the target branch is current" },
|
||||
{ command: "git cherry-pick -x <fix-commit>", description: "Apply the fix and record its origin in the message" },
|
||||
],
|
||||
note: "Check whether the fix depends on earlier commits. A technically successful Cherry-pick can still be functionally incomplete.",
|
||||
},
|
||||
{
|
||||
id: "workflow-clean-commit",
|
||||
title: "Split mixed changes into clean commits",
|
||||
summary: "You do not have to commit everything that is currently changed. Hunk or line staging separates refactoring, fixes, and documentation.",
|
||||
commands: [
|
||||
{ command: "git add -p", description: "Select changes hunk by hunk" },
|
||||
{ command: "git diff --staged", description: "Review the first commit's content" },
|
||||
{ command: "git commit", description: "Create the first logical commit" },
|
||||
{ command: "git add -p && git commit", description: "Continue with the next topic" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reference",
|
||||
label: "Reference & glossary",
|
||||
description: "A compact command overview and the core Git terms in one place.",
|
||||
sections: [
|
||||
{
|
||||
id: "reference-daily",
|
||||
title: "Daily quick reference",
|
||||
summary: "The most common safe commands for orientation, changes, commits, and synchronization.",
|
||||
commands: [
|
||||
{ command: "git status", description: "Check state" },
|
||||
{ command: "git diff", description: "Read local changes" },
|
||||
{ command: "git add -p", description: "Stage selectively" },
|
||||
{ command: "git diff --staged", description: "Review commit contents" },
|
||||
{ command: "git commit", description: "Create a commit" },
|
||||
{ command: "git fetch --prune", description: "Refresh remote state" },
|
||||
{ command: "git push", description: "Publish local commits" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reference-glossary",
|
||||
title: "Git glossary",
|
||||
summary: "HEAD is the current checkout. Branches and tags are references to commits. origin is only the conventional name of a remote. Upstream is the remote reference assigned to a local branch.",
|
||||
steps: [
|
||||
"Commit: Immutable project snapshot with parents, author, time, and message.",
|
||||
"Index/Staging: Prepared snapshot for the next commit.",
|
||||
"Working tree: Checked-out files you are currently editing.",
|
||||
"Remote: Named connection to another repository, not necessarily “the cloud”.",
|
||||
"Fast-forward: A branch pointer can move forward without a merge commit.",
|
||||
"Detached HEAD: HEAD points directly to a commit instead of a local branch.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "reference-safety",
|
||||
title: "Risk levels of Git commands",
|
||||
summary: "Read-only commands such as status, log, show, and diff are harmless. Restore, Reset, Clean, Rebase, and Force Push change or remove state and deserve an extra check.",
|
||||
steps: [
|
||||
"Safe and read-only: status, log, show, diff, branch, remote -v, reflog.",
|
||||
"Locally modifying: add, restore, commit, stash, switch, merge, rebase.",
|
||||
"Potentially destructive: reset --hard, clean -fd, branch -D.",
|
||||
"Team-wide risk: push --force, rebasing published commits, or moving tags.",
|
||||
],
|
||||
note: "When unsure, stop, create a backup branch, and inspect git status and git reflog. Git rewards small, understandable steps.",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
let { language = "en", onClose = () => {} }: Props = $props();
|
||||
const isGerman = $derived(language === "de");
|
||||
const categories = $derived(isGerman ? deCategories : enCategories);
|
||||
@@ -561,19 +1289,21 @@
|
||||
<div class="help-layout">
|
||||
<nav class="help-nav" aria-label={isGerman ? "Hilfethemen" : "Help topics"}>
|
||||
<p class="help-nav-label">{isGerman ? "Themen" : "Topics"}</p>
|
||||
{#each categories as category, index}
|
||||
{#each categories as category}
|
||||
<button
|
||||
type="button"
|
||||
class:active={!normalizedQuery && category.id === selectedCategoryId}
|
||||
onclick={() => selectCategory(category.id)}
|
||||
>
|
||||
<span class="help-nav-icon">
|
||||
{#if index === 0}<Home size={17} aria-hidden="true" />
|
||||
{:else if index === 1}<BookOpen size={17} aria-hidden="true" />
|
||||
{:else if index === 2}<GitCommitHorizontal size={17} aria-hidden="true" />
|
||||
{:else if index === 3}<GitBranch size={17} aria-hidden="true" />
|
||||
{:else if index === 4}<Cloud size={17} aria-hidden="true" />
|
||||
{:else if index === 5}<Wrench size={17} aria-hidden="true" />
|
||||
{#if category.id === "start"}<Home size={17} aria-hidden="true" />
|
||||
{:else if category.id === "app"}<BookOpen size={17} aria-hidden="true" />
|
||||
{:else if category.id === "basics"}<GitCommitHorizontal size={17} aria-hidden="true" />
|
||||
{:else if category.id === "branches"}<GitBranch size={17} aria-hidden="true" />
|
||||
{:else if category.id === "remote"}<Cloud size={17} aria-hidden="true" />
|
||||
{:else if category.id === "troubleshooting"}<Wrench size={17} aria-hidden="true" />
|
||||
{:else if category.id === "workflows"}<ListChecks size={17} aria-hidden="true" />
|
||||
{:else if category.id === "reference"}<Library size={17} aria-hidden="true" />
|
||||
{:else}<Keyboard size={17} aria-hidden="true" />{/if}
|
||||
</span>
|
||||
<span>{category.label}</span>
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
}: Props = $props();
|
||||
|
||||
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
||||
let patchScroll = $state<HTMLDivElement | null>(null);
|
||||
|
||||
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
||||
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||
@@ -118,6 +119,23 @@
|
||||
if (isBusy || isLoading) return;
|
||||
await onApply(action, buildHunkPatch(hunk));
|
||||
}
|
||||
|
||||
function hunkPosition(index: number): number {
|
||||
const totalLines = parsed.hunks.reduce((sum, hunk) => sum + Math.max(hunk.lines.length, 1), 0);
|
||||
const precedingLines = parsed.hunks.slice(0, index).reduce((sum, hunk) => sum + Math.max(hunk.lines.length, 1), 0);
|
||||
return (precedingLines / Math.max(totalLines - 1, 1)) * 100;
|
||||
}
|
||||
|
||||
function hunkKind(hunk: PatchHunk): "add" | "delete" | "mixed" {
|
||||
const hasAdd = hunk.lines.some((line) => line.kind === "add");
|
||||
const hasDelete = hunk.lines.some((line) => line.kind === "delete");
|
||||
return hasAdd && hasDelete ? "mixed" : hasAdd ? "add" : "delete";
|
||||
}
|
||||
|
||||
function scrollToHunk(hunkId: string) {
|
||||
const target = patchScroll?.querySelector<HTMLElement>(`[data-hunk-id="${hunkId}"]`);
|
||||
if (patchScroll && target) patchScroll.scrollTop = Math.max(target.offsetTop - 8, 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
@@ -148,9 +166,10 @@
|
||||
{:else if parsed.binary || parsed.hunks.length === 0}
|
||||
<div class="blank-state">This change cannot be split into text lines.</div>
|
||||
{:else}
|
||||
<div class="line-patch-scroll">
|
||||
<div class="line-patch-workspace">
|
||||
<div class="line-patch-scroll" bind:this={patchScroll}>
|
||||
{#each parsed.hunks as hunk (hunk.id)}
|
||||
<section class="line-patch-hunk">
|
||||
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
|
||||
<div class="line-patch-hunk-head">
|
||||
<code>{hunk.header}</code>
|
||||
<div class="line-patch-hunk-actions">
|
||||
@@ -183,6 +202,19 @@
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
<nav class="diff-overview line-patch-overview" aria-label="Change overview">
|
||||
{#each parsed.hunks as hunk, index (hunk.id)}
|
||||
<button
|
||||
class="diff-overview-marker {hunkKind(hunk)}"
|
||||
type="button"
|
||||
style={`--marker-position: ${hunkPosition(index)}%`}
|
||||
onclick={() => scrollToHunk(hunk.id)}
|
||||
title={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
|
||||
aria-label={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
|
||||
></button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -144,7 +144,6 @@
|
||||
|
||||
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
||||
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
||||
let selectedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f))).length);
|
||||
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
|
||||
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
|
||||
|
||||
@@ -156,85 +155,22 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="panel relative grid grid-rows-[auto_auto_1fr] overflow-hidden" aria-label="Working tree status">
|
||||
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Working tree status">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Working tree</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Status</h2>
|
||||
<span class="eyebrow">Workspace</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="status-head-actions">
|
||||
<span class="pill pill-count">{stagedCount} staged</span>
|
||||
<span class="pill pill-count">{unstagedCount} unstaged</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if hasRepository && changedFiles.length > 0}
|
||||
<div class="status-toolbar">
|
||||
<button
|
||||
class="btn-sm"
|
||||
type="button"
|
||||
onclick={onStageAll}
|
||||
disabled={isBusy || !hasUnstaged}
|
||||
title="Stage all unstaged files"
|
||||
>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
Stage all
|
||||
</button>
|
||||
<button
|
||||
class="btn-sm"
|
||||
type="button"
|
||||
onclick={onUnstageAll}
|
||||
disabled={isBusy || !hasStaged}
|
||||
title="Unstage all staged files"
|
||||
>
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
Unstage all
|
||||
</button>
|
||||
<button
|
||||
class="btn-sm danger"
|
||||
type="button"
|
||||
onclick={() => onDiscardMany(changedFiles)}
|
||||
disabled={isBusy || changedFiles.length === 0}
|
||||
title="Discard all changes"
|
||||
>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Discard all
|
||||
</button>
|
||||
{#if selectedCount > 1}
|
||||
<span class="status-selection-count">{selectedCount} selected</span>
|
||||
<button
|
||||
class="btn-sm"
|
||||
type="button"
|
||||
onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))}
|
||||
disabled={isBusy || selectedUnstagedCount === 0}
|
||||
title="Stage selected unstaged files"
|
||||
>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
Stage selected
|
||||
</button>
|
||||
<button
|
||||
class="btn-sm"
|
||||
type="button"
|
||||
onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))}
|
||||
disabled={isBusy || selectedStagedCount === 0}
|
||||
title="Unstage selected staged files"
|
||||
>
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
Unstage selected
|
||||
</button>
|
||||
<button
|
||||
class="btn-sm danger"
|
||||
type="button"
|
||||
onclick={() => onDiscardMany(selectedFiles())}
|
||||
disabled={isBusy || selectedCount === 0}
|
||||
title="Discard changes in selected files"
|
||||
>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Discard selected
|
||||
{#if hasRepository && changedFiles.length > 0}
|
||||
<button class="btn-sm status-discard-all" type="button" onclick={() => onDiscardMany(changedFiles)} disabled={isBusy} title="Discard all staged and unstaged changes">
|
||||
<RotateCcw size={13} aria-hidden="true" /> Discard all
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">No repository loaded.</div>
|
||||
@@ -243,79 +179,86 @@
|
||||
{:else if changedFiles.length === 0}
|
||||
<div class="blank-state">No file changes returned.</div>
|
||||
{:else}
|
||||
<div class="overflow-auto p-2">
|
||||
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||
<article
|
||||
class="file-row"
|
||||
class:selected={isStatusSelected(file)}
|
||||
class:active={selectedFilePath === file.path}
|
||||
>
|
||||
<div class="file-title">
|
||||
<button
|
||||
class="file-title-button"
|
||||
type="button"
|
||||
onclick={(event) => handleFileSelect(event, file)}
|
||||
title={`Select ${displayPath(file)} in Explorer`}
|
||||
>
|
||||
<strong>{fileName(file)}</strong>
|
||||
<div class="status-lanes overflow-auto">
|
||||
<section class="status-lane" aria-label="Unstaged changes">
|
||||
<header class="status-lane-head">
|
||||
<div class="status-lane-title"><strong>Unstaged</strong><span>{unstagedCount}</span></div>
|
||||
<div class="status-lane-actions">
|
||||
{#if selectedUnstagedCount > 1}
|
||||
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
|
||||
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={`Stage ${selectedUnstagedCount} selected files`}>
|
||||
<Check size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
|
||||
<Check size={13} aria-hidden="true" /> Stage all
|
||||
</button>
|
||||
{#if selectedUnstagedCount > 1}
|
||||
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null), false)} disabled={isBusy} title={`Discard unstaged changes in ${selectedUnstagedCount} selected files`}>
|
||||
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedUnstagedCount}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
<div class="status-file-list">
|
||||
{#each changedFiles.filter((file) => file.unstaged !== null) as file (`unstaged:${fileKey(file)}`)}
|
||||
{@const stageTargets = selectedStageTargets(file)}
|
||||
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
|
||||
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||
<strong>{fileName(file)}</strong>
|
||||
<span>{displayPath(file)}</span>
|
||||
</button>
|
||||
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
||||
<div class="status-file-actions">
|
||||
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><Check size={14} aria-hidden="true" /></button>
|
||||
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
|
||||
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="change-lanes">
|
||||
<div class="change-lane" class:inactive={!file.staged}>
|
||||
<div class="lane-header">
|
||||
<span class="lane-name">Staged</span>
|
||||
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
||||
</div>
|
||||
<div class="lane-actions">
|
||||
{#if file.staged}
|
||||
{@const unstageTargets = selectedUnstageTargets(file)}
|
||||
<button class="btn-sm" type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={unstageTargets.length > 1 ? `Unstage ${unstageTargets.length} selected files` : "Unstage file"}>
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
{unstageTargets.length > 1 ? `Unstage ${unstageTargets.length}` : "Unstage"}
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
|
||||
<FileDiff size={14} aria-hidden="true" />
|
||||
Details
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={unstageTargets.length > 1 ? `Discard staged changes in ${unstageTargets.length} selected files` : "Discard staged changes"}>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
{unstageTargets.length > 1 ? `Discard ${unstageTargets.length}` : "Discard"}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="quiet">No staged change</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="change-lane" class:inactive={!file.unstaged}>
|
||||
<div class="lane-header">
|
||||
<span class="lane-name">Unstaged</span>
|
||||
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
||||
</div>
|
||||
<div class="lane-actions">
|
||||
{#if file.unstaged}
|
||||
{@const stageTargets = selectedStageTargets(file)}
|
||||
<button class="btn-sm" type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={stageTargets.length > 1 ? `Stage ${stageTargets.length} selected files` : "Stage file"}>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
{stageTargets.length > 1 ? `Stage ${stageTargets.length}` : "Stage"}
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines">
|
||||
<FileDiff size={14} aria-hidden="true" />
|
||||
Details
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={stageTargets.length > 1 ? `Discard unstaged changes in ${stageTargets.length} selected files` : "Discard unstaged changes"}>
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
{stageTargets.length > 1 ? `Discard ${stageTargets.length}` : "Discard"}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="quiet">No unstaged change</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<section class="status-lane" aria-label="Staged changes">
|
||||
<header class="status-lane-head">
|
||||
<div class="status-lane-title"><strong>Staged</strong><span>{stagedCount}</span></div>
|
||||
<div class="status-lane-actions">
|
||||
{#if selectedStagedCount > 1}
|
||||
<span class="status-selection-count">{selectedStagedCount} selected</span>
|
||||
<button class="btn-sm" type="button" onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))} disabled={isBusy} title={`Unstage ${selectedStagedCount} selected files`}>
|
||||
<Undo2 size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title="Unstage all staged files">
|
||||
<Undo2 size={13} aria-hidden="true" /> Unstage all
|
||||
</button>
|
||||
{#if selectedStagedCount > 1}
|
||||
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null), true)} disabled={isBusy} title={`Discard staged changes in ${selectedStagedCount} selected files`}>
|
||||
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedStagedCount}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</header>
|
||||
<div class="status-file-list">
|
||||
{#each changedFiles.filter((file) => file.staged !== null) as file (`staged:${fileKey(file)}`)}
|
||||
{@const unstageTargets = selectedUnstageTargets(file)}
|
||||
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
|
||||
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||
<strong>{fileName(file)}</strong>
|
||||
<span>{displayPath(file)}</span>
|
||||
</button>
|
||||
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
||||
<div class="status-file-actions">
|
||||
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><Undo2 size={14} aria-hidden="true" /></button>
|
||||
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
|
||||
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
AiReviewResult,
|
||||
CommitAiLocalProfile,
|
||||
CommitAiProvider,
|
||||
CommitAiStatus,
|
||||
@@ -244,6 +245,16 @@ export function commitAiGenerate(path: string, options: CommitAiGenerateOptions)
|
||||
});
|
||||
}
|
||||
|
||||
export function commitAiReview(path: string, options: CommitAiGenerateOptions): Promise<AiReviewResult> {
|
||||
return invoke<AiReviewResult>("commit_ai_review", {
|
||||
path,
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
apiKey: options.apiKey,
|
||||
baseUrl: options.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
|
||||
}
|
||||
|
||||
@@ -19,6 +19,24 @@ export interface CommitAiStatus {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export type AiReviewRisk = "low" | "medium" | "high";
|
||||
export type AiReviewSeverity = "critical" | "warning" | "info";
|
||||
|
||||
export interface AiReviewFinding {
|
||||
severity: AiReviewSeverity;
|
||||
title: string;
|
||||
description: string;
|
||||
file: string | null;
|
||||
line: number | null;
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
export interface AiReviewResult {
|
||||
summary: string;
|
||||
risk: AiReviewRisk;
|
||||
findings: AiReviewFinding[];
|
||||
}
|
||||
|
||||
export interface LocalModelOption {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
Reference in New Issue
Block a user