Compare commits

...
29 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
Christoph Brandau 6729d61dca feat(language): implement internationalization and help overlay
This update introduces comprehensive language support (English/German) across the application, enabling localization for UI elements and settings dialogs. It also adds a dedicated Help Overlay component with detailed guides on using Gitty's core Git features.

- Adds language selection to App Settings
- Implements German translations in key areas
- Introduces global help documentation accessible via Ctrl+/
2026-07-11 15:03:01 +02:00
Christoph b181b384e7 Update version to 2026.7.19 2026-07-10 23:09:04 +02:00
Christoph Brandau c174fb3a80 Merge branch 'opt/light_mode'
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 18m59s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Successful in 27m53s
2026-07-10 22:59:20 +02:00
Christoph Brandau c747e02f29 feat(git): improve interactive rebase base handling and reflog display
The git module now correctly handles scenarios where the selected base branch is diverged from HEAD. This removes unnecessary ancestor checks during rebase planning, improving overall robustness of the feature. Additionally, the ReflogDialog component was updated to reliably retrieve the current HEAD hash for accurate display in the UI.

- Removed strict ancestor checking when starting interactive rebase operations.
- Updated App.svelte to accurately find and use the current HEAD entry from reflog data.
2026-07-10 22:56:48 +02:00
Christoph Brandau 791275f341 refactor(git): improve interactive rebase workflow robustness
This commit removes the dedicated feature QA page and significantly refactors the internal Git library logic for handling complex workflows, particularly interactive rebase. The changes introduce helper functions to manage temporary files and ensure proper cleanup of rebase artifacts regardless of success or failure. This improves the reliability of advanced git operations within the application.

- Added comprehensive cleanup routines for rebase helpers
- Centralized constants for rebase file names
- Removed obsolete QA feature code
2026-07-10 22:53:24 +02:00
Christoph Brandau f7b8beaad4 feat(git): add interactive rebase and reflog recovery features
This update significantly expands Git functionality by implementing support for advanced workflows, including interactive rebasing and recovering lost commits via the reflog. New logic handles preparing the necessary environment files (todo lists and reword queues) required by Git's internal editors. The frontend components are also updated to expose these new capabilities to the user interface.

- Implements full planning and execution flow for interactive rebase
- Adds functionality to list and restore commits using the reflog history
- Updates Rust backend commands to support advanced git operations
2026-07-10 22:49:08 +02:00
Christoph Brandau d0bca62362 refactor(ui): Improve title bar layout using flex and grid
The structure of the application title bar has been updated to utilize modern CSS layout techniques like Grid and Flexbox. This refactoring improves the responsiveness and organization of key elements such as repository name, branch indicator, and synchronization status. The changes ensure better alignment and handling of varying content lengths across different viewports.

- Restructures repo/branch info into a dedicated context container
- Uses flex properties for dynamic sizing of title bar sections
- Groups sync indicators into a cohesive unit
2026-07-10 22:21:53 +02:00
Christoph Brandau d3cde83518 style(css): standardize code surface and highlight colors
Introduces a comprehensive set of CSS variables defining surfaces, syntax elements, and diff coloring across both light and dark themes. This refactoring replaces numerous hardcoded color values throughout the stylesheet with these new variables, ensuring that all components consistently adhere to the defined theme palette.

- Added detailed variable definitions for code surfaces (e.g., `--code-surface`, `--code-input-bg`).
- Updated diff, hunk, and blame sections to use variable colors for better thematic consistency.
- Replaced fixed hex codes with variables across various component backgrounds and text colors.
2026-07-10 22:16:06 +02:00
Christoph 3fdd6d3467 Update version to 0.200.10 2026-07-10 20:27:29 +02:00
Christoph 900a6fa230 feat(core): improve platform compatibility and startup robustness
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Successful in 18m21s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Failing after 1h43m55s
This update enhances cross-platform reliability by addressing potential hangs during application startup on Linux environments using WebKitGTK. Additionally, native taskbar badge functionality is improved for macOS and Linux, ensuring accurate display of sync status. Configuration settings are also updated to include more specific internal crate reads and web search capabilities.

- Added timeout fallback to waitForAnimationFrame in Svelte
- Improved set_sync_badge logic for macOS/Linux platforms
- Updated local configuration with detailed dependency reads
2026-07-10 20:24:48 +02:00
Christoph 2d7946f6fd Update version to 2026.7.18 2026-07-10 18:38:11 +02:00
30 changed files with 5077 additions and 634 deletions
+17 -1
View File
@@ -100,7 +100,23 @@
"Bash(pkg-config --exists openssl)",
"Bash(sudo apt-get install -y libssl-dev pkg-config)",
"Bash(dpkg -L libssl3t64)",
"Bash(grep *)"
"Bash(grep *)",
"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",
"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"
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitty",
"version": "2026.7.17",
"version": "2026.7.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitty",
"version": "2026.7.17",
"version": "2026.7.19",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitty",
"version": "2026.7.17",
"version": "2026.7.19",
"private": true,
"type": "module",
"scripts": {
+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))
}
+15 -1
View File
@@ -177,7 +177,21 @@ pub fn set_sync_badge(
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
}
#[cfg(not(target_os = "windows"))]
// Native badge count: works on Linux (via the desktop's launcher API, e.g. Unity's
// LauncherEntry, also honored by GNOME/KDE) and macOS (dock badge). Matched against the
// app's `<product-name>.desktop` file on Linux, so it's a silent no-op on launchers that
// don't implement the protocol.
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let Some(window) = app.get_webview_window("main") else {
return Ok(());
};
window
.set_badge_count(if count > 0 { Some(count as i64) } else { None })
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
let _ = (app, count);
}
+732 -7
View File
@@ -1,7 +1,9 @@
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
env,
ffi::{OsStr, OsString},
fs,
path::{Path, PathBuf},
process::{Command, Output, Stdio},
sync::{
@@ -170,6 +172,112 @@ pub struct GitSearchHit {
pub matches_added: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RebaseCommit {
pub hash: String,
pub short_hash: String,
pub summary: String,
pub author_name: String,
pub date: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RebaseAction {
Pick,
Reword,
Squash,
Fixup,
Drop,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct RebasePlanItem {
pub hash: String,
pub action: RebaseAction,
pub message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ReflogEntry {
pub hash: String,
pub short_hash: String,
pub selector: String,
pub action: String,
pub author_name: String,
pub date: String,
}
const SEQUENCE_EDITOR_PLAN_ENV: &str = "GITTY_SEQUENCE_EDITOR_PLAN";
const COMMIT_EDITOR_QUEUE_ENV: &str = "GITTY_COMMIT_EDITOR_QUEUE";
const REBASE_TODO_FILE: &str = "gitty-interactive-rebase-todo";
const REWORD_QUEUE_FILE: &str = "gitty-interactive-rebase-messages";
const SEQUENCE_HELPER_STEM: &str = ".gitty-sequence-editor";
const COMMIT_HELPER_STEM: &str = ".gitty-commit-editor";
pub fn run_sequence_editor_if_requested() -> Option<Result<(), String>> {
let executable = env::current_exe().ok()?;
let executable_name = executable.file_name()?.to_string_lossy();
let target = env::args_os()
.nth(1)
.ok_or_else(|| "Git did not provide a sequence-editor target path.".to_string());
if executable_name.contains("gitty-sequence-editor") {
let plan = env::var_os(SEQUENCE_EDITOR_PLAN_ENV)?;
return Some(target.and_then(|target| {
fs::copy(PathBuf::from(plan), PathBuf::from(target))
.map(|_| ())
.map_err(|err| format!("Could not write the interactive rebase plan: {err}"))
}));
}
if executable_name.contains("gitty-commit-editor") {
let queue = env::var_os(COMMIT_EDITOR_QUEUE_ENV)?;
return Some(
target.and_then(|target| apply_reword_message(Path::new(&queue), Path::new(&target))),
);
}
None
}
fn apply_reword_message(queue_path: &Path, message_path: &Path) -> Result<(), String> {
let queue = fs::read_to_string(queue_path)
.map_err(|err| format!("Could not read the reword queue: {err}"))?;
if queue.is_empty() {
return Ok(());
}
let current = fs::read_to_string(message_path)
.map_err(|err| format!("Could not read the commit message: {err}"))?;
let current_subject = current
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with('#'))
.unwrap_or("");
let mut remaining = Vec::new();
let mut replacement = None;
for record in queue.split('\x1e').filter(|record| !record.is_empty()) {
let Some((old, new)) = record.split_once('\x1f') else {
return Err("The reword queue is malformed.".to_string());
};
if replacement.is_none() && old == current_subject {
replacement = Some(new.to_string());
} else {
remaining.push(record);
}
}
if let Some(message) = replacement {
fs::write(message_path, format!("{message}\n"))
.map_err(|err| format!("Could not update the commit message: {err}"))?;
let mut next_queue = remaining.join("\x1e");
if !next_queue.is_empty() {
next_queue.push('\x1e');
}
fs::write(queue_path, next_queue)
.map_err(|err| format!("Could not update the reword queue: {err}"))?;
}
Ok(())
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct BranchInfo {
current_branch: Option<String>,
@@ -838,7 +946,11 @@ pub async fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus
}
#[tauri::command]
pub async fn restore_files(path: String, files: Vec<String>, staged: bool) -> Result<GitStatus, String> {
pub async fn restore_files(
path: String,
files: Vec<String>,
staged: bool,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
validate_files(&files)?;
@@ -1033,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,
@@ -1481,6 +1730,86 @@ pub async fn rebase_branch(path: String, branch: String) -> Result<GitStatus, St
.map_err(|err| format!("Could not rebase: {err}"))?
}
#[tauri::command]
pub fn list_interactive_rebase_commits(
path: String,
base: String,
) -> Result<Vec<RebaseCommit>, String> {
let repo = resolve_repo(&path)?;
interactive_rebase_commits_for_repo(&repo, &base)
}
#[tauri::command]
pub async fn start_interactive_rebase(
path: String,
base: String,
plan: Vec<RebasePlanItem>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let status = status_for_repo(&repo)?;
if !status.clean {
return Err(
"Commit or stash working tree changes before starting an interactive rebase."
.to_string(),
);
}
if status.rebase_in_progress || status.cherry_pick_in_progress {
return Err(
"Finish the current Git operation before starting an interactive rebase."
.to_string(),
);
}
let base_hash = verify_commit(&repo, &base)?;
let available = interactive_rebase_commits_for_repo(&repo, &base_hash)?;
validate_rebase_plan(&available, &plan)?;
let todo = build_rebase_todo(&available, &plan)?;
let reword_queue = build_reword_queue(&available, &plan)?;
let git_dir = git_dir_for_repo(&repo)?;
cleanup_interactive_rebase_helpers(&repo);
let todo_path = git_dir.join(REBASE_TODO_FILE);
let reword_queue_path = git_dir.join(REWORD_QUEUE_FILE);
fs::write(&todo_path, todo)
.map_err(|err| format!("Could not prepare interactive rebase plan: {err}"))?;
fs::write(&reword_queue_path, reword_queue)
.map_err(|err| format!("Could not prepare reword messages: {err}"))?;
let sequence_helper_path = repo.join(sequence_helper_name());
let commit_helper_path = repo.join(commit_helper_name());
let current_exe = env::current_exe()
.map_err(|err| format!("Could not locate the Gitty executable: {err}"))?;
fs::copy(&current_exe, &sequence_helper_path)
.and_then(|_| fs::copy(&current_exe, &commit_helper_path))
.map_err(|err| format!("Could not prepare interactive rebase helpers: {err}"))?;
let sequence_editor_command = format!("./{}", sequence_helper_name());
let commit_editor_command = format!("./{}", commit_helper_name());
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["rebase", "-i", base_hash.as_str()])
.env("GIT_SEQUENCE_EDITOR", sequence_editor_command)
.env(SEQUENCE_EDITOR_PLAN_ENV, &todo_path)
.env("GIT_EDITOR", commit_editor_command)
.env(COMMIT_EDITOR_QUEUE_ENV, &reword_queue_path)
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"));
let result = rebase_status_or_error(&repo, output?, "Interactive rebase failed", true);
let _ = fs::remove_file(&todo_path);
let _ = fs::remove_file(&sequence_helper_path);
if !matches!(&result, Ok(status) if status.rebase_in_progress) {
let _ = fs::remove_file(&reword_queue_path);
let _ = fs::remove_file(&commit_helper_path);
}
result
})
.await
.map_err(|err| format!("Could not run interactive rebase: {err}"))?
}
#[tauri::command]
pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
@@ -1488,15 +1817,27 @@ pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
return Err("No rebase is currently in progress.".to_string());
}
let output = git_command()
.arg("-C")
.arg(&repo)
.args(["rebase", "--continue"])
.env("GIT_EDITOR", "true")
let git_dir = git_dir_for_repo(&repo)?;
let queue_path = git_dir.join(REWORD_QUEUE_FILE);
let helper_path = repo.join(commit_helper_name());
let mut command = git_command();
command.arg("-C").arg(&repo).args(["rebase", "--continue"]);
if queue_path.exists() && helper_path.exists() {
command
.env("GIT_EDITOR", format!("./{}", commit_helper_name()))
.env(COMMIT_EDITOR_QUEUE_ENV, &queue_path);
} else {
command.env("GIT_EDITOR", "true");
}
let output = command
.output()
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
rebase_status_or_error(&repo, output, "Rebase continue failed", false)
let result = rebase_status_or_error(&repo, output, "Rebase continue failed", false);
if !matches!(&result, Ok(status) if status.rebase_in_progress) {
cleanup_interactive_rebase_helpers(&repo);
}
result
}
#[tauri::command]
@@ -1507,9 +1848,249 @@ pub fn rebase_abort(path: String) -> Result<GitStatus, String> {
}
run_git(&repo, ["rebase", "--abort"])?;
cleanup_interactive_rebase_helpers(&repo);
status_for_repo(&repo)
}
#[tauri::command]
pub fn list_reflog(path: String, limit: Option<u32>) -> Result<Vec<ReflogEntry>, String> {
let repo = resolve_repo(&path)?;
let limit = limit.unwrap_or(250).clamp(1, 1_000).to_string();
let output = run_git(
&repo,
[
"reflog",
"show",
"--date=iso-strict",
"--format=%H%x1f%h%x1f%gD%x1f%gs%x1f%an%x1f%aI%x1e",
"-n",
limit.as_str(),
],
)?;
parse_reflog(&output)
}
#[tauri::command]
pub fn restore_reflog_entry(
path: String,
commit: String,
branch: String,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let status = status_for_repo(&repo)?;
if !status.clean {
return Err(
"Commit or stash working tree changes before restoring from the reflog.".to_string(),
);
}
let commit_hash = verify_commit(&repo, &commit)?;
let branch = validate_new_branch_name(&repo, &branch)?;
run_git(
&repo,
["checkout", "-b", branch.as_str(), commit_hash.as_str()],
)?;
status_for_repo(&repo)
}
fn interactive_rebase_commits_for_repo(
repo: &Path,
base: &str,
) -> Result<Vec<RebaseCommit>, String> {
let base_hash = verify_commit(repo, base)?;
let range = format!("{base_hash}..HEAD");
let merges = run_git(repo, ["rev-list", "--merges", range.as_str()])?;
if !String::from_utf8_lossy(&merges).trim().is_empty() {
return Err("Interactive rebase currently supports linear commit ranges only. Choose a base after the last merge commit.".to_string());
}
let output = run_git(
repo,
[
"log",
"--reverse",
"--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI%x1e",
range.as_str(),
],
)?;
let mut commits = Vec::new();
for raw in output.split(|byte| *byte == 0x1e) {
let record = String::from_utf8_lossy(raw);
let record = record.trim_matches(['\r', '\n', ' ']);
if record.is_empty() {
continue;
}
let fields = record.split('\x1f').collect::<Vec<_>>();
if fields.len() != 5 {
return Err("Git returned an unexpected interactive rebase record.".to_string());
}
commits.push(RebaseCommit {
hash: fields[0].to_string(),
short_hash: fields[1].to_string(),
summary: fields[2].to_string(),
author_name: fields[3].to_string(),
date: fields[4].to_string(),
});
}
Ok(commits)
}
fn validate_rebase_plan(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result<(), String> {
if commits.is_empty() {
return Err("There are no commits to rebase onto the selected base.".to_string());
}
if commits.len() != plan.len() {
return Err("The rebase plan must include every commit exactly once.".to_string());
}
let expected = commits
.iter()
.map(|item| item.hash.as_str())
.collect::<BTreeSet<_>>();
let actual = plan
.iter()
.map(|item| item.hash.as_str())
.collect::<BTreeSet<_>>();
if actual.len() != plan.len() || actual != expected {
return Err(
"The rebase plan contains missing, duplicate, or unexpected commits.".to_string(),
);
}
let mut has_kept_commit = false;
for item in plan {
match item.action {
RebaseAction::Drop => {}
RebaseAction::Squash | RebaseAction::Fixup if !has_kept_commit => {
return Err("Squash and fixup need an earlier picked commit.".to_string());
}
RebaseAction::Reword => {
let message = item.message.as_deref().unwrap_or("").trim();
if message.is_empty() || message.contains(['\r', '\n']) {
return Err("Reword messages must be a single non-empty line.".to_string());
}
has_kept_commit = true;
}
_ => has_kept_commit = true,
}
}
if !has_kept_commit {
return Err("Keep at least one commit in the interactive rebase plan.".to_string());
}
Ok(())
}
fn build_rebase_todo(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result<String, String> {
let by_hash = commits
.iter()
.map(|commit| (commit.hash.as_str(), commit))
.collect::<BTreeMap<_, _>>();
let mut todo = String::new();
for item in plan {
let commit = by_hash
.get(item.hash.as_str())
.ok_or_else(|| "The rebase plan references an unknown commit.".to_string())?;
let summary = commit.summary.replace(['\r', '\n'], " ");
match item.action {
RebaseAction::Pick => todo.push_str(&format!("pick {} {}\n", item.hash, summary)),
RebaseAction::Reword => todo.push_str(&format!("reword {} {}\n", item.hash, summary)),
RebaseAction::Squash => todo.push_str(&format!("squash {} {}\n", item.hash, summary)),
RebaseAction::Fixup => todo.push_str(&format!("fixup {} {}\n", item.hash, summary)),
RebaseAction::Drop => todo.push_str(&format!("drop {} {}\n", item.hash, summary)),
}
}
Ok(todo)
}
fn build_reword_queue(commits: &[RebaseCommit], plan: &[RebasePlanItem]) -> Result<String, String> {
let by_hash = commits
.iter()
.map(|commit| (commit.hash.as_str(), commit))
.collect::<BTreeMap<_, _>>();
let mut queue = String::new();
for item in plan
.iter()
.filter(|item| item.action == RebaseAction::Reword)
{
let commit = by_hash
.get(item.hash.as_str())
.ok_or_else(|| "The rebase plan references an unknown commit.".to_string())?;
let message = item.message.as_deref().unwrap_or("").trim();
queue.push_str(&commit.summary.replace(['\r', '\n'], " "));
queue.push('\x1f');
queue.push_str(message);
queue.push('\x1e');
}
Ok(queue)
}
fn git_dir_for_repo(repo: &Path) -> Result<PathBuf, String> {
let output = run_git(repo, ["rev-parse", "--absolute-git-dir"])?;
let path = String::from_utf8_lossy(&output).trim().to_string();
if path.is_empty() {
Err("Git could not determine its metadata directory.".to_string())
} else {
Ok(PathBuf::from(path))
}
}
fn sequence_helper_name() -> &'static str {
if cfg!(windows) {
".gitty-sequence-editor.exe"
} else {
SEQUENCE_HELPER_STEM
}
}
fn commit_helper_name() -> &'static str {
if cfg!(windows) {
".gitty-commit-editor.exe"
} else {
COMMIT_HELPER_STEM
}
}
fn cleanup_interactive_rebase_helpers(repo: &Path) {
if let Ok(git_dir) = git_dir_for_repo(repo) {
let _ = fs::remove_file(git_dir.join(REBASE_TODO_FILE));
let _ = fs::remove_file(git_dir.join(REWORD_QUEUE_FILE));
}
let _ = fs::remove_file(repo.join(sequence_helper_name()));
let _ = fs::remove_file(repo.join(commit_helper_name()));
}
fn is_interactive_rebase_helper_path(path: &str) -> bool {
matches!(
path.replace('\\', "/").rsplit('/').next(),
Some(name) if name == sequence_helper_name() || name == commit_helper_name()
)
}
fn parse_reflog(output: &[u8]) -> Result<Vec<ReflogEntry>, String> {
let mut entries = Vec::new();
for raw in output.split(|byte| *byte == 0x1e) {
let record = String::from_utf8_lossy(raw);
let record = record.trim_matches(['\r', '\n', ' ']);
if record.is_empty() {
continue;
}
let fields = record.split('\x1f').collect::<Vec<_>>();
if fields.len() != 6 {
return Err("Git returned an unexpected reflog record.".to_string());
}
entries.push(ReflogEntry {
hash: fields[0].to_string(),
short_hash: fields[1].to_string(),
selector: fields[2].to_string(),
action: fields[3].to_string(),
author_name: fields[4].to_string(),
date: fields[5].to_string(),
});
}
Ok(entries)
}
fn rebase_status_or_error(
repo: &Path,
output: Output,
@@ -2521,6 +3102,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
)?;
let (branch, mut files) = parse_status_output(&output)?;
detect_worktree_renames(repo, &mut files);
files.retain(|file| !is_interactive_rebase_helper_path(&file.path));
Ok(GitStatus {
repo_path: repo.to_string_lossy().to_string(),
@@ -4582,6 +5164,134 @@ mod tests {
assert_eq!(branch.behind, 0);
}
#[test]
fn parses_reflog_records() {
let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fHEAD@{0}\x1fcommit: Add feature\x1fAda\x1f2026-07-10T12:00:00+02:00\x1e";
let entries = parse_reflog(raw).expect("reflog should parse");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].selector, "HEAD@{0}");
assert_eq!(entries[0].action, "commit: Add feature");
}
#[test]
fn commit_editor_applies_only_the_matching_reword_message() {
let temp = temp_dir("commit_editor");
let queue = temp.path.join("queue");
let message = temp.path.join("COMMIT_EDITMSG");
fs::write(
&queue,
"first commit\x1frenamed first\x1esecond commit\x1frenamed second\x1e",
)
.expect("queue should be written");
fs::write(
&message,
"first commit\n\n# Please enter the commit message\n",
)
.expect("message should be written");
apply_reword_message(&queue, &message).expect("message should be applied");
assert_eq!(fs::read_to_string(&message).unwrap(), "renamed first\n");
assert_eq!(
fs::read_to_string(&queue).unwrap(),
"second commit\x1frenamed second\x1e"
);
}
#[test]
fn interactive_rebase_builds_a_valid_reword_and_squash_plan() {
let repo = init_temp_repo("interactive_rebase");
commit_initial_file(&repo.path);
let base = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
fs::write(repo.path.join("first.txt"), "first\n").expect("first file should be written");
run_git_test(&repo.path, ["add", "first.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "first commit"]);
fs::write(repo.path.join("second.txt"), "second\n").expect("second file should be written");
run_git_test(&repo.path, ["add", "second.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "second commit"]);
let commits = interactive_rebase_commits_for_repo(&repo.path, &base)
.expect("rebase commits should load");
assert_eq!(commits.len(), 2);
let plan = vec![
RebasePlanItem {
hash: commits[0].hash.clone(),
action: RebaseAction::Reword,
message: Some("combined feature".to_string()),
},
RebasePlanItem {
hash: commits[1].hash.clone(),
action: RebaseAction::Squash,
message: None,
},
];
validate_rebase_plan(&commits, &plan).expect("plan should be valid");
let todo = build_rebase_todo(&commits, &plan).expect("todo should build");
assert!(todo.contains(&format!("reword {} first commit", commits[0].hash)));
assert!(todo.contains(&format!("squash {} second commit", commits[1].hash)));
assert_eq!(
build_reword_queue(&commits, &plan).expect("queue should build"),
"first commit\x1fcombined feature\x1e"
);
}
#[test]
fn interactive_rebase_accepts_a_diverged_base_branch() {
let repo = init_temp_repo("interactive_rebase_diverged");
commit_initial_file(&repo.path);
let main_branch = git_output_test(&repo.path, ["branch", "--show-current"]);
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]);
fs::write(repo.path.join("feature.txt"), "feature\n")
.expect("feature file should be written");
run_git_test(&repo.path, ["add", "feature.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "feature commit"]);
run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]);
fs::write(repo.path.join("main.txt"), "main\n").expect("main file should be written");
run_git_test(&repo.path, ["add", "main.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "main advanced"]);
run_git_test(&repo.path, ["checkout", "-q", "feature"]);
let commits = interactive_rebase_commits_for_repo(&repo.path, &main_branch)
.expect("diverged base should be accepted");
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].summary, "feature commit");
}
#[test]
fn reflog_restore_creates_a_recovery_branch_without_resetting_existing_branch() {
let repo = init_temp_repo("reflog_restore");
commit_initial_file(&repo.path);
let initial = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
let original_branch = git_output_test(&repo.path, ["branch", "--show-current"]);
fs::write(repo.path.join("later.txt"), "later\n").expect("later file should be written");
run_git_test(&repo.path, ["add", "later.txt"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "later"]);
let entries = list_reflog(repo.path.to_string_lossy().to_string(), Some(20))
.expect("reflog should load");
assert!(entries.len() >= 2);
let status = restore_reflog_entry(
repo.path.to_string_lossy().to_string(),
initial.clone(),
"recovery/initial".to_string(),
)
.expect("recovery branch should be created");
assert_eq!(status.current_branch.as_deref(), Some("recovery/initial"));
assert_eq!(git_output_test(&repo.path, ["rev-parse", "HEAD"]), initial);
assert!(
ref_exists(&repo.path, &format!("refs/heads/{original_branch}"))
.expect("original branch should still exist")
);
}
#[test]
fn parses_commit_history_records() {
let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fAda Lovelace\x1fada@example.com\x1f2026-06-26T12:34:56+02:00\x1fHEAD -> main, tag: v1\x1f2222222222222222222222222222222222222222 3333333333333333333333333333333333333333\x1fAdd history panel\x1e";
@@ -5640,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));
}
}
+39 -15
View File
@@ -8,18 +8,20 @@ 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_repository_files, list_stashes, list_tags, merge_branch,
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch,
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop,
stash_push, undo_last_commit, unstage_files,
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,
unstage_files,
};
use tauri::{Manager, AppHandle};
use tauri::Manager;
#[tauri::command]
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
@@ -31,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}"))?;
@@ -42,14 +45,22 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
#[tokio::main]
async fn main() {
tauri::Builder::default()
if let Some(result) = run_sequence_editor_if_requested() {
if let Err(error) = result {
eprintln!("{error}");
std::process::exit(1);
}
return;
}
let builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
#[cfg(desktop)]
let _ = app.get_webview_window("main")
let _ = app
.get_webview_window("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 {
@@ -60,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,
@@ -97,6 +116,7 @@ async fn main() {
commit_ai_load,
commit_ai_local_models,
commit_ai_generate,
commit_ai_review,
pull,
push,
fetch,
@@ -107,6 +127,10 @@ async fn main() {
rebase_branch,
rebase_continue,
rebase_abort,
list_interactive_rebase_commits,
start_interactive_rebase,
list_reflog,
restore_reflog_entry,
list_repository_files,
open_repository_bundle,
list_file_history,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty",
"version": "2026.7.17",
"version": "2026.7.19",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run dev",
+353 -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";
@@ -21,10 +25,13 @@
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
import HelpOverlay from "./lib/components/HelpOverlay.svelte";
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
import InteractiveRebaseDialog from "./lib/components/InteractiveRebaseDialog.svelte";
import LinePatchDialog from "./lib/components/LinePatchDialog.svelte";
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
import ReflogDialog from "./lib/components/ReflogDialog.svelte";
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StashPanel from "./lib/components/StashPanel.svelte";
@@ -40,6 +47,7 @@
cloneRepository,
commit,
commitAiGenerate,
commitAiReview,
commitAiLoad,
commitAiLocalModels,
commitAiStatus,
@@ -62,6 +70,8 @@
listTags,
listCommits,
listFileHistory,
listInteractiveRebaseCommits,
listReflog,
listRepositoryFiles,
mergeBranch,
openRepoInExplorer,
@@ -83,9 +93,11 @@
resolveConflict,
resolveConflictSide,
restoreFileFromCommit,
restoreReflogEntry,
restoreFiles,
restoreToCommit,
searchCodeIntroductions,
startInteractiveRebase,
setSyncBadge,
stageFiles,
stashApply,
@@ -97,7 +109,9 @@
} from "./lib/git";
import type {
AiReviewResult,
AiSettings,
AppLanguage,
AppTheme,
AnalyticsSettings,
CommitAiPhase,
@@ -119,6 +133,9 @@
LocalModelOption,
PatchApplyAction,
PreparedResolution,
RebaseCommit,
RebasePlanItem,
ReflogEntry,
StoredCredential,
} from "./lib/types";
@@ -167,6 +184,8 @@
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
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";
@@ -235,13 +254,18 @@
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;
let appSettingsOpen = false;
let helpOpen = false;
let analyticsNoticeOpen = false;
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
let appTheme: AppTheme = loadThemePreference();
let appLanguage: AppLanguage = loadLanguagePreference();
let localModelOptions: LocalModelOption[] = [];
let errorMessage = "";
let operation = "";
@@ -254,6 +278,15 @@
let deleteBranchForce = false;
let compareSelectOpen = false;
let compareDialogOpen = false;
let interactiveRebaseOpen = false;
let interactiveRebaseBase = "";
let interactiveRebaseCommits: RebaseCommit[] = [];
let interactiveRebaseLoading = false;
let interactiveRebaseError = "";
let reflogOpen = false;
let reflogEntries: ReflogEntry[] = [];
let reflogLoading = false;
let reflogError = "";
let selectedDiffPath = "";
let diffHighlightQuery = "";
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
@@ -279,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;
@@ -341,6 +374,7 @@
let fileHistoryResizeStartWidth = 0;
let fileHistoryCollapsed = true;
let themeMediaQuery: MediaQueryList | undefined;
let appVersion = "";
// ── Derived ────────────────────────────────────────────────────────────────
@@ -394,6 +428,7 @@
$: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
$: applyThemePreference(appTheme);
$: applyLanguagePreference(appLanguage);
// ── Lifecycle ──────────────────────────────────────────────────────────────
@@ -401,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(() => {
@@ -421,7 +457,21 @@
}
function waitForAnimationFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
// requestAnimationFrame never fires for an unmapped/invisible window on
// WebKitGTK (Linux) — the compositor frame clock only runs for realized
// windows. The main window starts hidden until the splashscreen closes,
// so without a timeout fallback this promise (and the whole startup
// sequence, including closing the splashscreen) would hang forever on Linux.
return new Promise((resolve) => {
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
resolve();
};
requestAnimationFrame(() => settle());
window.setTimeout(settle, 50);
});
}
async function waitForStartupPaint() {
@@ -640,7 +690,7 @@
}
async function autoRefreshTick() {
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || newBranchCommit || globalSearchOpen || helpOpen) return;
const path = activeRepoPath;
autoRefreshInFlight = true;
try {
@@ -781,6 +831,29 @@
}
}
function loadLanguagePreference(): AppLanguage {
try {
const stored = localStorage.getItem(APP_LANGUAGE_KEY);
if (stored === "en" || stored === "de") return stored;
} catch {
// Local storage is optional; English is the first-start default.
}
return "en";
}
function persistLanguagePreference(next: AppLanguage) {
try {
localStorage.setItem(APP_LANGUAGE_KEY, next);
} catch {
// Ignore storage quota/private-mode errors.
}
}
function applyLanguagePreference(next: AppLanguage) {
document.documentElement.lang = next;
document.documentElement.dataset.language = next;
}
function applyThemePreference(next: AppTheme) {
const prefersLight = themeMediaQuery?.matches ?? window.matchMedia("(prefers-color-scheme: light)").matches;
const resolved = next === "system"
@@ -796,13 +869,19 @@
if (appTheme === "system") applyThemePreference(appTheme);
}
function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme) {
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 });
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) {
@@ -863,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 ────────────────────────────────────────────────────────────────
@@ -1493,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();
@@ -1566,6 +1694,13 @@
comparison = null;
compareSelectOpen = false;
compareDialogOpen = false;
interactiveRebaseOpen = false;
interactiveRebaseBase = "";
interactiveRebaseCommits = [];
interactiveRebaseError = "";
reflogOpen = false;
reflogEntries = [];
reflogError = "";
selectedDiffPath = "";
pendingRestoreFile = null;
newBranchCommit = null;
@@ -1692,7 +1827,7 @@
await refreshBranchList(path);
await refreshTags(path);
await refreshCommitHistory(path);
if (lastFileHistoryHeadHash !== previousHeadHash) {
if (lastFileHistoryHeadHash !== previousHeadHash && !fileHistoryCollapsed) {
await refreshFileHistory(path);
}
}
@@ -2236,6 +2371,99 @@
});
}
function preferredInteractiveRebaseBase(): string {
const candidates = [status?.upstream, "origin/main", "main", "origin/master", "master"]
.filter((value): value is string => Boolean(value) && value !== status?.current_branch);
for (const candidate of candidates) {
if (branches.some((branch) => branch.name === candidate)) return candidate;
}
return branches.find((branch) => !branch.current)?.name ?? "";
}
async function loadInteractiveRebaseRange(base: string) {
interactiveRebaseBase = base;
interactiveRebaseCommits = [];
interactiveRebaseError = "";
if (!activeRepoPath || !base) return;
interactiveRebaseLoading = true;
try {
const result = await listInteractiveRebaseCommits(activeRepoPath, base);
if (interactiveRebaseBase === base) interactiveRebaseCommits = result;
} catch (error) {
if (interactiveRebaseBase === base) interactiveRebaseError = errorToMessage(error);
} finally {
if (interactiveRebaseBase === base) interactiveRebaseLoading = false;
}
}
function openInteractiveRebase() {
if (!hasRepository || rebaseInProgress || cherryPickInProgress || isBusy) return;
interactiveRebaseOpen = true;
const base = preferredInteractiveRebaseBase();
void loadInteractiveRebaseRange(base);
trackEvent("interactive_rebase_opened");
}
async function runInteractiveRebase(plan: RebasePlanItem[]) {
if (!activeRepoPath || !interactiveRebaseBase || isBusy) return;
interactiveRebaseError = "";
await runOperation("Starting interactive rebase", async () => {
applyStatus(await startInteractiveRebase(activeRepoPath, interactiveRebaseBase, plan));
interactiveRebaseOpen = false;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("interactive_rebase_started", { commits: plan.length });
});
if (interactiveRebaseOpen && errorMessage) interactiveRebaseError = errorMessage;
}
async function openReflog() {
if (!hasRepository || isBusy) return;
reflogOpen = true;
reflogEntries = [];
reflogError = "";
reflogLoading = true;
try {
reflogEntries = await listReflog(activeRepoPath, 300);
trackEvent("reflog_opened", { entries: reflogEntries.length });
} catch (error) {
reflogError = errorToMessage(error);
} finally {
reflogLoading = false;
}
}
async function previewReflogEntry(entry: ReflogEntry) {
if (!activeRepoPath || isBusy) return;
await runOperation("Previewing reflog entry", async () => {
const result = await compareCommits(activeRepoPath, entry.hash, "HEAD");
comparison = result;
selectedDiffPath = result.files[0]?.path ?? "";
diffHighlightQuery = "";
pendingRestoreFile = null;
reflogOpen = false;
compareDialogOpen = true;
trackEvent("reflog_previewed", { files: result.files.length });
});
}
async function recoverReflogEntry(entry: ReflogEntry, branch: string) {
if (!activeRepoPath || !branch.trim() || isBusy) return;
reflogError = "";
await runOperation("Restoring reflog entry", async () => {
applyStatus(await restoreReflogEntry(activeRepoPath, entry.hash, branch.trim()));
reflogOpen = false;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("reflog_recovered");
});
if (reflogOpen && errorMessage) reflogError = errorMessage;
}
async function createNewTag(name: string, message: string) {
const trimmed = name.trim();
if (!activeRepoPath || !trimmed) return;
@@ -3043,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();
@@ -3073,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",
@@ -3127,6 +3353,11 @@
trackEvent("global_search_opened");
}
function openHelp() {
helpOpen = true;
trackEvent("help_opened");
}
async function compareSelectedCommits() {
if (!canCompare) return;
await runOperation("Comparing commits", async () => {
@@ -3317,12 +3548,23 @@
// ── Event handlers ─────────────────────────────────────────────────────────
function handleWindowKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key === "/") {
event.preventDefault();
openHelp();
return;
}
if (event.key === "Escape" && helpOpen) {
helpOpen = false;
return;
}
if (event.key === "Escape" && repoTabContextMenu) closeRepoTabContextMenu();
else if (event.key === "Escape" && pendingDiscard && !isBusy) closeDiscardConfirm();
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false;
else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false;
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
}
@@ -3345,87 +3587,47 @@
<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}
onOpenInExplorer={openActiveRepoInExplorer}
onToggleAutoRefresh={toggleAutoRefresh}
onOpenSettings={() => { appSettingsOpen = true; }}
onOpenHelp={openHelp}
language={appLanguage}
/>
<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}
@@ -3865,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}
@@ -3950,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>
@@ -3977,11 +4194,27 @@
<AppSettingsDialog
analytics={analyticsSettings}
theme={appTheme}
language={appLanguage}
autoRefresh={autoRefreshEnabled}
onSave={saveAppSettings}
onClose={() => { appSettingsOpen = false; }}
/>
{/if}
{#if helpOpen}
<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}
@@ -4079,6 +4312,37 @@
/>
{/if}
<!-- Compare: pick the two commits to diff -->
{#if interactiveRebaseOpen}
<InteractiveRebaseDialog
{branches}
currentBranch={status?.current_branch ?? ""}
base={interactiveRebaseBase}
commits={interactiveRebaseCommits}
isLoading={interactiveRebaseLoading}
{isBusy}
{operation}
error={interactiveRebaseError}
onBaseChange={loadInteractiveRebaseRange}
onStart={runInteractiveRebase}
onClose={() => { if (!isBusy) interactiveRebaseOpen = false; }}
/>
{/if}
{#if reflogOpen}
<ReflogDialog
entries={reflogEntries}
currentHash={reflogEntries.find((entry) => entry.selector === "HEAD@{0}")?.hash ?? ""}
isLoading={reflogLoading}
{isBusy}
{operation}
error={reflogError}
onPreview={previewReflogEntry}
onRestore={recoverReflogEntry}
onClose={() => { if (!isBusy) reflogOpen = false; }}
/>
{/if}
<!-- Compare: pick the two commits to diff -->
{#if compareSelectOpen}
<CompareSelectDialog
+1167 -125
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>
+47 -197
View File
@@ -1,33 +1,16 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, 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 onOpenInExplorer: () => void = () => {};
export let onToggleAutoRefresh: () => void = () => {};
export let onOpenHelp: () => void = () => {};
export let onOpenSettings: () => void = () => {};
export let language: "en" | "de" = "en";
let win: ReturnType<typeof getCurrentWindow> | null = null;
let isMaximized = false;
let unlisten: (() => void) | undefined;
let appVersion = "";
onMount(async () => {
try {
@@ -39,12 +22,6 @@
} catch {
win = null;
}
try {
appVersion = await getVersion();
} catch {
appVersion = "";
}
});
onDestroy(() => {
@@ -71,180 +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}
{#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>{branch}</span>
{#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}
{:else}
<span class="tb-no-repo" data-tauri-drag-region>No repository open</span>
{/if}
<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>
<!-- 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={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={onOpenSettings}
title="Settings"
aria-label="Settings"
>
<Settings size={14} aria-hidden="true" />
<span class="tb-action-label">Settings</span>
</button>
</div>
<div class="titlebar-divider" aria-hidden="true"></div>
<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>
<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>
</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>
+64 -18
View File
@@ -1,22 +1,29 @@
<script lang="ts">
import { Check, Settings, X } from "@lucide/svelte";
import type { AnalyticsSettings, AppTheme } from "../types";
import { Check, Languages, RefreshCw, Settings, X } from "@lucide/svelte";
import type { AnalyticsSettings, AppLanguage, AppTheme } from "../types";
interface Props {
analytics: AnalyticsSettings;
theme: AppTheme;
onSave: (settings: AnalyticsSettings, theme: AppTheme) => void;
language: AppLanguage;
autoRefresh: boolean;
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean) => void;
onClose: () => void;
}
let { analytics, theme = "system", 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() {
@@ -24,18 +31,18 @@
...analytics,
enabled: analyticsEnabled,
noticeSeen: true,
}, selectedTheme);
}, selectedTheme, selectedLanguage, autoRefreshEnabled);
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1">
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Einstellungen" : "Settings"} tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Gitty</span>
<h2 class="dialog-title">Settings</h2>
<h2 class="dialog-title">{isGerman ? "Einstellungen" : "Settings"}</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"}>
<X size={18} aria-hidden="true" />
</button>
</header>
@@ -45,23 +52,62 @@
<header>
<Settings size={16} aria-hidden="true" />
<div>
<span class="eyebrow">Appearance</span>
<h3>Theme</h3>
<span class="eyebrow">{isGerman ? "Darstellung" : "Appearance"}</span>
<h3>{isGerman ? "Farbschema" : "Theme"}</h3>
</div>
</header>
<div class="settings-segmented" role="radiogroup" aria-label="Theme">
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Farbschema" : "Theme"}>
<label class:active={selectedTheme === "system"}>
<input type="radio" bind:group={selectedTheme} value="system" />
<span>System</span>
</label>
<label class:active={selectedTheme === "light"}>
<input type="radio" bind:group={selectedTheme} value="light" />
<span>Light</span>
<span>{isGerman ? "Hell" : "Light"}</span>
</label>
<label class:active={selectedTheme === "dark"}>
<input type="radio" bind:group={selectedTheme} value="dark" />
<span>Dark</span>
<span>{isGerman ? "Dunkel" : "Dark"}</span>
</label>
</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" />
<div>
<span class="eyebrow">{isGerman ? "Sprache" : "Language"}</span>
<h3>{isGerman ? "App-Sprache" : "App language"}</h3>
</div>
</header>
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
<label class:active={selectedLanguage === "en"}>
<input type="radio" bind:group={selectedLanguage} value="en" />
<span>EN · English</span>
</label>
<label class:active={selectedLanguage === "de"}>
<input type="radio" bind:group={selectedLanguage} value="de" />
<span>DE · Deutsch</span>
</label>
</div>
</section>
@@ -71,24 +117,24 @@
<Settings size={16} aria-hidden="true" />
<div>
<span class="eyebrow">Analytics</span>
<h3>Anonymous usage analytics</h3>
<h3>{isGerman ? "Anonyme Nutzungsanalyse" : "Anonymous usage analytics"}</h3>
</div>
</header>
<label class="settings-toggle-row">
<input type="checkbox" bind:checked={analyticsEnabled} />
<span>
<strong>Allow anonymous Aptabase events</strong>
<small>No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent.</small>
<strong>{isGerman ? "Anonyme Aptabase-Ereignisse erlauben" : "Allow anonymous Aptabase events"}</strong>
<small>{isGerman ? "Es werden keine Repository-Pfade, Remotes, Branches, Commit-Nachrichten, Dateinamen, Diffs, Zugangsdaten oder Code übertragen." : "No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent."}</small>
</span>
</label>
</section>
<div class="new-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose}>Cancel</button>
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="submit">
<Check size={16} aria-hidden="true" />
Save
{isGerman ? "Speichern" : "Save"}
</button>
</div>
</form>
+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>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,148 @@
<script lang="ts">
import { AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
interface PlanRow extends RebaseCommit {
action: RebaseAction;
message: string;
}
interface Props {
branches: GitBranchInfo[];
currentBranch: string;
base: string;
commits: RebaseCommit[];
isLoading: boolean;
isBusy: boolean;
operation: string;
error: string;
onBaseChange: (base: string) => void;
onStart: (plan: RebasePlanItem[]) => void;
onClose: () => void;
}
let {
branches = [], currentBranch = "", base = "", commits = [], isLoading = false,
isBusy = false, operation = "", error = "", onBaseChange = () => {},
onStart = () => {}, onClose = () => {},
}: Props = $props();
let rows = $state<PlanRow[]>([]);
$effect(() => {
rows = commits.map((commit) => ({ ...commit, action: "pick", message: commit.summary }));
});
let availableBases = $derived(branches.filter((branch) => !branch.current));
let keptCount = $derived(rows.filter((row) => row.action !== "drop").length);
let invalidSquash = $derived(rows.some((row, index) =>
(row.action === "squash" || row.action === "fixup")
&& rows.slice(0, index).every((previous) => previous.action === "drop")
));
let invalidReword = $derived(rows.some((row) => row.action === "reword" && !row.message.trim()));
let canStart = $derived(Boolean(base) && rows.length > 0 && keptCount > 0 && !invalidSquash && !invalidReword && !isLoading && !isBusy);
function updateAction(index: number, action: RebaseAction) {
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, action } : row);
}
function updateMessage(index: number, message: string) {
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, message } : row);
}
function move(index: number, direction: -1 | 1) {
const target = index + direction;
if (target < 0 || target >= rows.length) return;
const next = [...rows];
[next[index], next[target]] = [next[target], next[index]];
rows = next;
}
function start() {
if (!canStart) return;
onStart(rows.map((row) => ({
hash: row.hash,
action: row.action,
message: row.action === "reword" ? row.message.trim() : null,
})));
}
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog interactive-rebase-dialog" role="dialog" aria-modal="true" aria-label="Interactive rebase" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Rewrite local history</span>
<h2 class="dialog-title">Interactive rebase</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
</header>
<div class="interactive-rebase-body">
<section class="rebase-base-bar">
<label>
<span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span>
<select value={base} onchange={(event) => onBaseChange((event.target as HTMLSelectElement).value)} disabled={isBusy || isLoading}>
<option value="" disabled>Select a base branch</option>
{#each availableBases as branch (branch.name)}
<option value={branch.name}>{branch.remote ? "Remote · " : "Local · "}{branch.name}</option>
{/each}
</select>
</label>
<p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p>
</section>
{#if error}
<div class="rebase-warning error"><AlertTriangle size={16} aria-hidden="true" /><span>{error}</span></div>
{/if}
{#if isLoading}
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading rebase range…</div>
{:else if !base}
<div class="blank-state">Select the branch or commit that should become the new base.</div>
{:else if rows.length === 0}
<div class="blank-state">No linear commits are available above this base.</div>
{:else}
<div class="rebase-plan" role="list" aria-label="Interactive rebase plan">
{#each rows as row, index (row.hash)}
<article class:drop={row.action === "drop"} class="rebase-plan-row" role="listitem">
<div class="rebase-order-actions">
<button type="button" onclick={() => move(index, -1)} disabled={isBusy || index === 0} title="Move up"><ArrowUp size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => move(index, 1)} disabled={isBusy || index === rows.length - 1} title="Move down"><ArrowDown size={14} aria-hidden="true" /></button>
</div>
<select class={`rebase-action ${row.action}`} value={row.action} onchange={(event) => updateAction(index, (event.target as HTMLSelectElement).value as RebaseAction)} disabled={isBusy} aria-label={`Action for ${row.short_hash}`}>
<option value="pick">pick</option><option value="reword">reword</option><option value="squash">squash</option><option value="fixup">fixup</option><option value="drop">drop</option>
</select>
<code>{row.short_hash}</code>
<div class="rebase-commit-copy">
{#if row.action === "reword"}
<input value={row.message} oninput={(event) => updateMessage(index, (event.target as HTMLInputElement).value)} disabled={isBusy} aria-label={`New message for ${row.short_hash}`} maxlength="240" />
{:else}
<strong>{row.summary}</strong>
{/if}
<span>{row.author_name} · {new Date(row.date).toLocaleString()}</span>
</div>
</article>
{/each}
</div>
{/if}
{#if invalidSquash}
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Squash and fixup need an earlier commit that is not dropped.</div>
{:else if invalidReword}
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Reword messages cannot be empty.</div>
{/if}
</div>
<footer class="dialog-footer">
<span class="dialog-footer-info">{keptCount} of {rows.length} commits kept</span>
<div class="rebase-footer-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-primary" type="button" onclick={start} disabled={!canStart}>
{#if operation === "Starting interactive rebase"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<Play size={16} aria-hidden="true" />{/if}
Start rebase
</button>
</div>
</footer>
</div>
</div>
+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>
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import { GitBranch, History, LoaderCircle, Search, ShieldCheck, X } from "@lucide/svelte";
import type { ReflogEntry } from "../types";
interface Props {
entries: ReflogEntry[];
currentHash: string;
isLoading: boolean;
isBusy: boolean;
operation: string;
error: string;
onPreview: (entry: ReflogEntry) => void;
onRestore: (entry: ReflogEntry, branch: string) => void;
onClose: () => void;
}
let { entries = [], currentHash = "", isLoading = false, isBusy = false, operation = "", error = "", onPreview = () => {}, onRestore = () => {}, onClose = () => {} }: Props = $props();
let query = $state("");
let selectedHash = $state("");
let recoveryBranch = $state("");
let filteredEntries = $derived(entries.filter((entry) => `${entry.selector} ${entry.action} ${entry.short_hash} ${entry.author_name}`.toLowerCase().includes(query.trim().toLowerCase())));
let selected = $derived(entries.find((entry) => entry.hash === selectedHash) ?? filteredEntries[0] ?? null);
$effect(() => {
if (!selectedHash && entries.length > 0) select(entries[0]);
});
function select(entry: ReflogEntry) {
selectedHash = entry.hash;
recoveryBranch = `recovery/${entry.short_hash}`;
}
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog reflog-dialog" role="dialog" aria-modal="true" aria-label="Reflog" tabindex="-1">
<header class="dialog-header">
<div><span class="eyebrow">Recovery history</span><h2 class="dialog-title">Reflog</h2></div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
</header>
<div class="reflog-body">
<aside class="reflog-list-pane">
<label class="reflog-search"><Search size={15} aria-hidden="true" /><input bind:value={query} placeholder="Search actions, hashes or authors" aria-label="Search reflog" /></label>
{#if isLoading}
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading reflog…</div>
{:else if filteredEntries.length === 0}
<div class="blank-state">No reflog entries match this search.</div>
{:else}
<div class="reflog-list" role="listbox" aria-label="Reflog entries">
{#each filteredEntries as entry (`${entry.selector}:${entry.hash}`)}
<button class:active={selected?.selector === entry.selector} type="button" role="option" aria-selected={selected?.selector === entry.selector} onclick={() => select(entry)}>
<span class="reflog-row-top"><code>{entry.selector}</code><span>{new Date(entry.date).toLocaleString()}</span></span>
<strong>{entry.action}</strong>
<span class="reflog-row-bottom"><code>{entry.short_hash}</code><span>{entry.author_name}</span></span>
</button>
{/each}
</div>
{/if}
</aside>
<section class="reflog-detail">
{#if error}<div class="rebase-warning error">{error}</div>{/if}
{#if selected}
<div class="reflog-detail-head"><History size={20} aria-hidden="true" /><div><span class="eyebrow">{selected.selector}</span><h3>{selected.action}</h3></div></div>
<dl><div><dt>Commit</dt><dd><code>{selected.hash}</code></dd></div><div><dt>Author</dt><dd>{selected.author_name}</dd></div><div><dt>Date</dt><dd>{new Date(selected.date).toLocaleString()}</dd></div></dl>
<button class="btn-secondary reflog-preview" type="button" onclick={() => onPreview(selected)} disabled={isBusy || selected.hash === currentHash}><History size={15} aria-hidden="true" /> Preview changes to current HEAD</button>
<div class="reflog-recovery-card">
<div class="reflog-recovery-title"><ShieldCheck size={18} aria-hidden="true" /><div><strong>Safe recovery</strong><span>Create a new branch here. The current branch is not reset or deleted.</span></div></div>
<label><span>Recovery branch</span><div><GitBranch size={15} aria-hidden="true" /><input bind:value={recoveryBranch} disabled={isBusy} spellcheck="false" /></div></label>
<button class="btn-primary" type="button" onclick={() => onRestore(selected, recoveryBranch.trim())} disabled={isBusy || !recoveryBranch.trim()}>
{#if operation === "Restoring reflog entry"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<ShieldCheck size={16} aria-hidden="true" />{/if}
Create and checkout recovery branch
</button>
</div>
{:else}
<div class="blank-state">Select a reflog entry to inspect or recover it.</div>
{/if}
</section>
</div>
</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}
+34
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import type {
AiReviewResult,
CommitAiLocalProfile,
CommitAiProvider,
CommitAiStatus,
@@ -10,6 +11,9 @@ import type {
GitCommit,
GitCommitComparison,
GitRepositoryFile,
RebaseCommit,
RebasePlanItem,
ReflogEntry,
GitSearchHit,
GitStash,
GitStatus,
@@ -241,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 });
}
@@ -306,6 +320,26 @@ export function rebaseAbort(path: string): Promise<GitStatus> {
return invoke<GitStatus>("rebase_abort", { path });
}
export function listInteractiveRebaseCommits(path: string, base: string): Promise<RebaseCommit[]> {
return invoke<RebaseCommit[]>("list_interactive_rebase_commits", { path, base });
}
export function startInteractiveRebase(
path: string,
base: string,
plan: RebasePlanItem[],
): Promise<GitStatus> {
return invoke<GitStatus>("start_interactive_rebase", { path, base, plan });
}
export function listReflog(path: string, limit = 250): Promise<ReflogEntry[]> {
return invoke<ReflogEntry[]>("list_reflog", { path, limit });
}
export function restoreReflogEntry(path: string, commit: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("restore_reflog_entry", { path, commit, branch });
}
export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
}
+44
View File
@@ -11,6 +11,7 @@ export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
export type AppTheme = "system" | "light" | "dark";
export type AppLanguage = "en" | "de";
export interface CommitAiStatus {
phase: CommitAiPhase;
@@ -18,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;
@@ -198,6 +217,31 @@ export interface GitBlameResult {
lines: GitBlameLine[];
}
export type RebaseAction = "pick" | "reword" | "squash" | "fixup" | "drop";
export interface RebaseCommit {
hash: string;
short_hash: string;
summary: string;
author_name: string;
date: string;
}
export interface RebasePlanItem {
hash: string;
action: RebaseAction;
message: string | null;
}
export interface ReflogEntry {
hash: string;
short_hash: string;
selector: string;
action: string;
author_name: string;
date: string;
}
export interface StoredCredential {
username: string;
password: string;