Compare commits

...
18 Commits
Author SHA1 Message Date
Christoph Brandau 7f85eb7d98 refactor(explorer): improve file history state management
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 19m26s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 32m24s
Adjusts the logic governing when file history views are refreshed or displayed. This ensures that selecting a file via the explorer correctly hides any active file history view, and prevents unnecessary refreshes of history data if the view is already collapsed or if the repository path is not yet set.

- Added explicit function to reset file history state
- Prevents automatic display of file history upon file selection
- Refines conditions for refreshing file history during repo updates
2026-07-13 00:23:27 +02:00
Christoph Brandau c2f55d96db feat(settings): implement persistent auto-refresh functionality
Adds a configurable and persistable auto-refresh setting to the application's settings dialog. This feature allows users to control whether the repository view automatically updates its status, branch information, and remote tracking in the background. The logic is integrated across core components to manage state changes and provide visual feedback to the user.

- Auto-refresh state is now persistent and managed via local storage.
- Updated settings dialog UI to include a dedicated toggle for auto-refresh.
- Removed manual auto-refresh toggling from the repository toolbar component.
2026-07-13 00:15:58 +02:00
Christoph Brandau 0dd5441754 feat(ui): enhance status panel actions and styling
This update significantly refactors the visual layout and functionality of the working tree status panel. It introduces dedicated action groups for discarding changes, staging, and unstaging files, improving user interaction and clarity.

- Added contextual action buttons (discard all/selected) to both staged and unstaged sections.
- Updated CSS structure to accommodate new status action containers.
- Improved handling of selected file counts within the panel headers.
2026-07-13 00:09:28 +02:00
Christoph Brandau 82c47c8d03 feat(ai): Add comprehensive pre-commit AI code review
Introduces a robust system for running automated, staged diff reviews against various large language models. This feature allows users to submit their changes to external AI services and receive structured feedback on potential bugs, security issues, and maintainability risks before committing.

The implementation covers the entire stack:
*   Backend logic was added to handle API communication with OpenAI, Anthropic, and custom endpoints.
*   A dedicated parser ensures that complex JSON outputs from LLMs are reliably converted into structured findings (severity, title, description).
*   New components and UI elements provide a clear visualization of the AI's assessment and actionable suggestions.

- Supports multiple major LLM providers (OpenAI, Anthropic)
- Parses structured JSON output for consistent review results
- Adds dedicated UI dialog to display AI findings and risk level
2026-07-13 00:03:11 +02:00
Christoph Brandau bad1263dcf feat(ui): enhance workspace status and diff styling
This commit updates several UI components to improve visual consistency and readability across various themes. It adds necessary class names to the Git branch display component and significantly refines the CSS for diff markers and general workspace status elements. These changes ensure that semantic colors are maintained on hover states and that key indicators remain highly visible in light mode.

- Updated styling for diff marker focus/hover states
- Improved visual feedback for line patch buttons (stage, unstage, discard)
- Added specific light theme styles for branch indicator and status counters
2026-07-12 23:46:44 +02:00
Christoph Brandau cc805e04bf style(ui): improve spacing and alignment of repository toolbar
Adjusts the CSS styling for the primary repository toolbar to enhance its visual organization. These changes introduce specific padding and margin rules, ensuring that various utility groups are properly centered and spaced vertically. This improves the overall aesthetic consistency of the component.
2026-07-12 23:38:44 +02:00
Christoph Brandau 735acd2551 refactor(ui): modernize status bar and component structure
This commit introduces a comprehensive overhaul of the application's UI, focusing on modernizing the global workspace status bar and improving overall theming consistency. Several components were refactored to simplify prop handling and improve separation of concerns, particularly within the TitleBar and RepoToolbar. The CSS includes extensive new variables and styles for better visual fidelity across light and dark themes.

- Overhauled the main application footer to display version, branch status, and sync metrics.
- Added global CSS variables and component styling for a modern look.
- Simplified repository state management by removing redundant props from TitleBar.
2026-07-12 23:32:55 +02:00
Christoph Brandau f9c0c00618 refactor(ui): extract repository tab bar into dedicated component
The complex logic and markup for rendering the repository tabs have been extracted from App.svelte into a new, reusable RepoTabs component. This refactoring significantly cleans up the main application view, improves separation of concerns, and makes the UI structure easier to maintain and extend. Corresponding CSS updates were applied to ensure the visual fidelity and responsiveness of the tab bar remain consistent across different states.

- Encapsulates all tab rendering logic in src/lib/RepoTabs.svelte
- Simplifies App.svelte by replacing large block of HTML with component usage
- Updates styling for better alignment and modern aesthetics
2026-07-12 22:56:39 +02:00
Christoph ff00925b13 chore(build): automate PKGBUILD version updates
This update enhances the build process by ensuring that the package definition file (PKGBUILD) is automatically updated with the current application version and release number. Changes are applied across both local build scripts and CI/CD workflows to maintain consistency. This prevents manual synchronization errors when building releases.

- Updates PKGBUILD version fields using package.json
- Ensures PKGBUILD is staged during final commit in CI/CD
- Improves robustness of the automated build pipeline
2026-07-11 23:27:59 +02:00
Christoph Brandau 982dbf136d feat(toolbar): overhaul repository action bar UI and status display
This refactors the entire appearance and structure of the repository toolbar, modernizing its layout and improving visual consistency across different states. The component now dynamically displays remote tracking information (ahead/behind) directly on Pull and Push buttons for immediate user feedback.

- Implemented a comprehensive CSS overhaul using grid and flexbox for better responsiveness.
- Added support for German localization within the toolbar actions.
- Enhanced visibility of sync status by displaying commit counts next to pull/push buttons.
2026-07-11 23:11:34 +02:00
Christoph Brandau 646dcc341e feat(help): Add comprehensive German documentation for Git concepts
The help overlay component has been significantly expanded with detailed sections covering advanced version control topics in German. These additions provide users with deep dives into core Git mechanics, complex workflows, and troubleshooting guides. This greatly enhances the user's ability to understand and utilize professional Git practices within the application.

- Detailed explanations of the Staging Area and Index concepts.
- Guides for advanced operations like interactive rebase and cherry-picking.
- Comprehensive sections on common errors (e.g., detached HEAD, non-fast-forward).
2026-07-11 22:52:31 +02:00
Christoph 5f3e55dcd7 chore: update package version and enhance build scripts
This commit updates the package version to 2026.7.19 and modifies
the build scripts to include additional commands for better
development workflow. The main window of the application is also set
to maximize upon launch.

- Updated package version in PKGBUILD
- Added new build commands in settings.local.json
- Maximized the main application window on startup
2026-07-11 20:02:23 +02:00
Christoph fdbff8175e style(ui): Move Buttons under the Repo selection 2026-07-11 18:04:49 +02:00
Christoph a6e7e991dd refactor(tauri): Improve builder initialization readability
The main application setup in src-tauri/src/main.rs has been refactored to enhance local variable scoping. By assigning the result of tauri::Builder::default() to a named variable, the code becomes clearer and more readable without changing runtime behavior.
2026-07-11 17:42:22 +02:00
Christoph 3491b8efb3 Merge remote-tracking branch 'origin/main' 2026-07-11 17:40:31 +02:00
Christoph 59a8f34e0e chore(config): update gitignore to exclude build artifacts
Updates the .gitignore file to ensure that generated package files and local build directories are ignored by Git. This prevents unnecessary binary data from being tracked in the repository history, keeping the project clean.

- Ignore pkg directory
- Ensure exclusion of tarball packages
2026-07-11 17:35:48 +02:00
Christoph 286b106baa feat(build): add PKGBUILD for package distribution
This introduces the necessary PKGBUILD file to define how Gitty should be built and packaged for system installation. It sets up the build process using npm and Tauri CLI, ensuring all dependencies are met before compiling the application binary. The script also handles installing required assets like icons, desktop entries, and documentation into the package structure.

- Defines build steps using tauri/npm
- Installs binaries, icons, and desktop entry files
- Sets up standard package metadata (pkgname, depends)
2026-07-11 17:34:48 +02:00
Christoph 670e7e24fe for linux build 2026-07-11 17:31:16 +02:00
24 changed files with 2810 additions and 574 deletions
+13 -1
View File
@@ -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)"
]
}
}
+5 -1
View File
@@ -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) {
+3
View File
@@ -7,3 +7,6 @@
~
.codex*
target
*.pkg.tar.zst
pkg
+66
View File
@@ -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"
}
+6
View File
@@ -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 ───────────────────────────────────────────────────────────
+1 -2
View File
@@ -2,8 +2,7 @@
"identifier": "desktop-capability",
"platforms": [
"macOS",
"windows",
"linux"
"windows"
],
"windows": [
"main"
+119 -1
View File
@@ -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())
}
+28 -1
View File
@@ -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))
}
+152
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -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>
+202
View File
@@ -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
View File
@@ -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>
+94
View File
@@ -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>
+25 -4
View File
@@ -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" />
+36 -29
View File
@@ -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>
+50
View File
@@ -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>
+737 -7
View File
@@ -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>
+34 -2
View File
@@ -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>
+84 -141
View File
@@ -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}
+11
View File
@@ -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 });
}
+18
View File
@@ -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;