Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f85eb7d98 | ||
|
|
c2f55d96db | ||
|
|
0dd5441754 | ||
|
|
82c47c8d03 | ||
|
|
bad1263dcf | ||
|
|
cc805e04bf | ||
|
|
735acd2551 | ||
|
|
f9c0c00618 | ||
|
|
ff00925b13 | ||
|
|
982dbf136d | ||
|
|
646dcc341e | ||
|
|
5f3e55dcd7 | ||
|
|
fdbff8175e | ||
|
|
a6e7e991dd | ||
|
|
3491b8efb3 | ||
|
|
59a8f34e0e | ||
|
|
286b106baa | ||
|
|
670e7e24fe | ||
|
|
6729d61dca | ||
|
|
b181b384e7 | ||
|
|
c174fb3a80 | ||
|
|
c747e02f29 | ||
|
|
791275f341 | ||
|
|
f7b8beaad4 | ||
|
|
d0bca62362 | ||
|
|
d3cde83518 | ||
|
|
3fdd6d3467 | ||
|
|
900a6fa230 | ||
|
|
2d7946f6fd |
@@ -100,7 +100,23 @@
|
|||||||
"Bash(pkg-config --exists openssl)",
|
"Bash(pkg-config --exists openssl)",
|
||||||
"Bash(sudo apt-get install -y libssl-dev pkg-config)",
|
"Bash(sudo apt-get install -y libssl-dev pkg-config)",
|
||||||
"Bash(dpkg -L libssl3t64)",
|
"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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ jobs:
|
|||||||
run: |
|
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');"
|
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
|
- name: Commit and Push Changes
|
||||||
if: matrix.platform == 'windows-latest'
|
if: matrix.platform == 'windows-latest'
|
||||||
shell: powershell
|
shell: powershell
|
||||||
@@ -95,7 +99,7 @@ jobs:
|
|||||||
git config user.email '${{ vars.EMAIL_GIT }}'
|
git config user.email '${{ vars.EMAIL_GIT }}'
|
||||||
# npm version also bumps the version inside package-lock.json, so stage it
|
# 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.
|
# 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 }}'
|
git commit -m 'Update version to ${{ github.ref_name }}'
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
|||||||
@@ -7,3 +7,6 @@
|
|||||||
~
|
~
|
||||||
.codex*
|
.codex*
|
||||||
target
|
target
|
||||||
|
|
||||||
|
*.pkg.tar.zst
|
||||||
|
pkg
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
|
||||||
|
#
|
||||||
|
# Local/in-tree PKGBUILD: builds straight from this working directory (no
|
||||||
|
# source download, no tauri-bundler/AppImage step) and installs the raw
|
||||||
|
# binary + desktop entry. Run `makepkg -si` from the repo root.
|
||||||
|
|
||||||
|
pkgname=gitty
|
||||||
|
pkgver=2026.7.19
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="A lightweight, modern Git client built with Tauri"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://git.cbsk-tech.de/Christoph/GitLite"
|
||||||
|
license=('MIT')
|
||||||
|
depends=('webkit2gtk-4.1' 'gtk3' 'git' 'hicolor-icon-theme')
|
||||||
|
makedepends=('rust' 'nodejs' 'npm')
|
||||||
|
options=('!lto')
|
||||||
|
|
||||||
|
source=()
|
||||||
|
sha256sums=()
|
||||||
|
|
||||||
|
# Keeps pkgver in sync with package.json (the release CI bumps that file).
|
||||||
|
pkgver() {
|
||||||
|
cd "$startdir"
|
||||||
|
node -p "require('./package.json').version"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$startdir"
|
||||||
|
npm ci
|
||||||
|
# Build through the Tauri CLI (not a raw `cargo build --release`): it enables
|
||||||
|
# the `custom-protocol` cargo feature that makes the binary load the embedded
|
||||||
|
# `dist/` assets instead of the Vite dev server URL (127.0.0.1:1420), and runs
|
||||||
|
# `beforeBuildCommand` (`npm run build`) for us. `--no-bundle` skips deb/rpm/
|
||||||
|
# appimage packaging (and with it linuxdeploy/FUSE) since pacman owns that here.
|
||||||
|
npm run tauri -- build --no-bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$startdir"
|
||||||
|
|
||||||
|
install -Dm755 "src-tauri/target/release/gitty" "$pkgdir/usr/bin/gitty"
|
||||||
|
|
||||||
|
install -Dm644 "src-tauri/icons/32x32.png" \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/32x32/apps/gitty.png"
|
||||||
|
install -Dm644 "src-tauri/icons/128x128.png" \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/128x128/apps/gitty.png"
|
||||||
|
install -Dm644 "src-tauri/icons/128x128@2x.png" \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/256x256@2/apps/gitty.png"
|
||||||
|
install -Dm644 "src-tauri/icons/icon.png" \
|
||||||
|
"$pkgdir/usr/share/icons/hicolor/512x512/apps/gitty.png"
|
||||||
|
|
||||||
|
install -d "$pkgdir/usr/share/applications"
|
||||||
|
cat > "$pkgdir/usr/share/applications/gitty.desktop" <<-EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Gitty
|
||||||
|
Comment=$pkgdesc
|
||||||
|
Exec=gitty
|
||||||
|
Icon=gitty
|
||||||
|
Terminal=false
|
||||||
|
Categories=Development;RevisionControl;
|
||||||
|
StartupWMClass=gitty
|
||||||
|
EOF
|
||||||
|
|
||||||
|
install -Dm644 "README.md" "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.7.17",
|
"version": "2026.7.19",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.7.17",
|
"version": "2026.7.19",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lucide/svelte": "^1.21.0",
|
"@lucide/svelte": "^1.21.0",
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gitty",
|
"name": "gitty",
|
||||||
"version": "2026.7.17",
|
"version": "2026.7.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ tauri-build = { version = "2", features = [] }
|
|||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-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"
|
tauri-plugin-updater = "2"
|
||||||
|
|
||||||
# ── Build profiles ───────────────────────────────────────────────────────────
|
# ── Build profiles ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
"identifier": "desktop-capability",
|
"identifier": "desktop-capability",
|
||||||
"platforms": [
|
"platforms": [
|
||||||
"macOS",
|
"macOS",
|
||||||
"windows",
|
"windows"
|
||||||
"linux"
|
|
||||||
],
|
],
|
||||||
"windows": [
|
"windows": [
|
||||||
"main"
|
"main"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
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.
|
// Generous sizing so a detailed body with bullet points isn't cut off.
|
||||||
const DEFAULT_MAX_TOKENS: u32 = 1500;
|
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
|
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)]
|
#[derive(Serialize)]
|
||||||
struct AnthropicMessage {
|
struct AnthropicMessage {
|
||||||
role: &'static str,
|
role: &'static str,
|
||||||
@@ -220,3 +296,45 @@ pub async fn generate_anthropic(
|
|||||||
}
|
}
|
||||||
Ok(message)
|
Ok(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn review_anthropic(api_key: &str, model: &str, diff: &str) -> Result<String, String> {
|
||||||
|
if api_key.trim().is_empty() {
|
||||||
|
return Err("Anthropic API key is missing.".to_string());
|
||||||
|
}
|
||||||
|
let (system, user) = build_review_messages(diff)?;
|
||||||
|
let body = AnthropicRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
max_tokens: 2400,
|
||||||
|
system,
|
||||||
|
messages: vec![AnthropicMessage {
|
||||||
|
role: "user",
|
||||||
|
content: user,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let client = http_client()?;
|
||||||
|
let response = client
|
||||||
|
.post("https://api.anthropic.com/v1/messages")
|
||||||
|
.header("x-api-key", api_key)
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Request to Anthropic failed: {err}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Could not read response: {err}"))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("API error ({status}): {text}"));
|
||||||
|
}
|
||||||
|
let parsed: AnthropicResponse =
|
||||||
|
serde_json::from_str(&text).map_err(|err| format!("Could not process response: {err}"))?;
|
||||||
|
parsed
|
||||||
|
.content
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|block| block.text)
|
||||||
|
.map(|text| sanitize_message(&text))
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.ok_or_else(|| "The model did not return a review.".to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
mod cloud;
|
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::{
|
use std::{
|
||||||
collections::hash_map::DefaultHasher,
|
collections::hash_map::DefaultHasher,
|
||||||
@@ -443,3 +446,27 @@ instead of a direct string comparison.\n\n\
|
|||||||
user.push_str(&format!("Staged diff:\n{diff}"));
|
user.push_str(&format!("Staged diff:\n{diff}"));
|
||||||
Ok((system, user))
|
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
@@ -177,7 +177,21 @@ pub fn set_sync_badge(
|
|||||||
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
|
.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);
|
let _ = (app, count);
|
||||||
}
|
}
|
||||||
|
|||||||
+732
-7
@@ -1,7 +1,9 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, BTreeSet},
|
collections::{BTreeMap, BTreeSet},
|
||||||
|
env,
|
||||||
ffi::{OsStr, OsString},
|
ffi::{OsStr, OsString},
|
||||||
|
fs,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::{Command, Output, Stdio},
|
process::{Command, Output, Stdio},
|
||||||
sync::{
|
sync::{
|
||||||
@@ -170,6 +172,112 @@ pub struct GitSearchHit {
|
|||||||
pub matches_added: u32,
|
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)]
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
struct BranchInfo {
|
struct BranchInfo {
|
||||||
current_branch: Option<String>,
|
current_branch: Option<String>,
|
||||||
@@ -838,7 +946,11 @@ pub async fn unstage_files(path: String, files: Vec<String>) -> Result<GitStatus
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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> {
|
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
validate_files(&files)?;
|
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]
|
#[tauri::command]
|
||||||
pub fn apply_file_patch(
|
pub fn apply_file_patch(
|
||||||
path: String,
|
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}"))?
|
.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(¤t_exe, &sequence_helper_path)
|
||||||
|
.and_then(|_| fs::copy(¤t_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]
|
#[tauri::command]
|
||||||
pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
|
pub fn rebase_continue(path: String) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
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());
|
return Err("No rebase is currently in progress.".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = git_command()
|
let git_dir = git_dir_for_repo(&repo)?;
|
||||||
.arg("-C")
|
let queue_path = git_dir.join(REWORD_QUEUE_FILE);
|
||||||
.arg(&repo)
|
let helper_path = repo.join(commit_helper_name());
|
||||||
.args(["rebase", "--continue"])
|
let mut command = git_command();
|
||||||
.env("GIT_EDITOR", "true")
|
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()
|
.output()
|
||||||
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||||
|
|
||||||
rebase_status_or_error(&repo, output, "Rebase 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]
|
#[tauri::command]
|
||||||
@@ -1507,9 +1848,249 @@ pub fn rebase_abort(path: String) -> Result<GitStatus, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
run_git(&repo, ["rebase", "--abort"])?;
|
run_git(&repo, ["rebase", "--abort"])?;
|
||||||
|
cleanup_interactive_rebase_helpers(&repo);
|
||||||
status_for_repo(&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(
|
fn rebase_status_or_error(
|
||||||
repo: &Path,
|
repo: &Path,
|
||||||
output: Output,
|
output: Output,
|
||||||
@@ -2521,6 +3102,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
|||||||
)?;
|
)?;
|
||||||
let (branch, mut files) = parse_status_output(&output)?;
|
let (branch, mut files) = parse_status_output(&output)?;
|
||||||
detect_worktree_renames(repo, &mut files);
|
detect_worktree_renames(repo, &mut files);
|
||||||
|
files.retain(|file| !is_interactive_rebase_helper_path(&file.path));
|
||||||
|
|
||||||
Ok(GitStatus {
|
Ok(GitStatus {
|
||||||
repo_path: repo.to_string_lossy().to_string(),
|
repo_path: repo.to_string_lossy().to_string(),
|
||||||
@@ -4582,6 +5164,134 @@ mod tests {
|
|||||||
assert_eq!(branch.behind, 0);
|
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]
|
#[test]
|
||||||
fn parses_commit_history_records() {
|
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";
|
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_eq!(result.lines.len(), 1);
|
||||||
assert!(result.lines[0].is_uncommitted);
|
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
@@ -8,18 +8,20 @@ use git::{
|
|||||||
SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search,
|
SearchCancellationState, amend_commit, apply_file_patch, cancel_code_search,
|
||||||
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
|
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit,
|
||||||
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load,
|
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,
|
commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits,
|
||||||
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
|
compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete,
|
||||||
delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame,
|
cred_load, cred_save, delete_branch, delete_tag, diff_file_against_working_tree, fetch,
|
||||||
get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, list_commits,
|
get_file_blame, get_file_patch, get_remote_url, get_status, last_commit_message, list_branches,
|
||||||
list_file_history, list_repository_files, list_stashes, list_tags, merge_branch,
|
list_commits, list_file_history, list_interactive_rebase_commits, list_reflog,
|
||||||
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull,
|
list_repository_files, list_stashes, list_tags, merge_branch, open_repo_in_explorer,
|
||||||
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch,
|
open_repository, open_repository_bundle, open_repository_file, pull, push, push_tag,
|
||||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch, resolve_conflict,
|
||||||
restore_to_commit, search_code_introductions, stage_files, stash_apply, stash_drop, stash_pop,
|
resolve_conflict_side, restore_file_from_commit, restore_files, restore_reflog_entry,
|
||||||
stash_push, undo_last_commit, unstage_files,
|
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]
|
#[tauri::command]
|
||||||
fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
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") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.maximize();
|
||||||
window
|
window
|
||||||
.show()
|
.show()
|
||||||
.map_err(|error| format!("failed to show main window: {error}"))?;
|
.map_err(|error| format!("failed to show main window: {error}"))?;
|
||||||
@@ -42,14 +45,22 @@ fn close_splashscreen(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn 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, _, _| {
|
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
let _ = app.get_webview_window("main")
|
let _ = app
|
||||||
|
.get_webview_window("main")
|
||||||
.expect("no main window")
|
.expect("no main window")
|
||||||
.set_focus();
|
.set_focus();
|
||||||
}))
|
}))
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_aptabase::Builder::new("A-SH-1344793789")
|
tauri_plugin_aptabase::Builder::new("A-SH-1344793789")
|
||||||
.with_options(tauri_plugin_aptabase::InitOptions {
|
.with_options(tauri_plugin_aptabase::InitOptions {
|
||||||
@@ -60,7 +71,15 @@ async fn main() {
|
|||||||
)
|
)
|
||||||
.manage(SearchCancellationState::default())
|
.manage(SearchCancellationState::default())
|
||||||
.manage(commit_ai::CommitAiEngine::new())
|
.manage(commit_ai::CommitAiEngine::new())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init());
|
||||||
|
|
||||||
|
// 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![
|
.invoke_handler(tauri::generate_handler![
|
||||||
open_repository,
|
open_repository,
|
||||||
clone_repository,
|
clone_repository,
|
||||||
@@ -97,6 +116,7 @@ async fn main() {
|
|||||||
commit_ai_load,
|
commit_ai_load,
|
||||||
commit_ai_local_models,
|
commit_ai_local_models,
|
||||||
commit_ai_generate,
|
commit_ai_generate,
|
||||||
|
commit_ai_review,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
fetch,
|
fetch,
|
||||||
@@ -107,6 +127,10 @@ async fn main() {
|
|||||||
rebase_branch,
|
rebase_branch,
|
||||||
rebase_continue,
|
rebase_continue,
|
||||||
rebase_abort,
|
rebase_abort,
|
||||||
|
list_interactive_rebase_commits,
|
||||||
|
start_interactive_rebase,
|
||||||
|
list_reflog,
|
||||||
|
restore_reflog_entry,
|
||||||
list_repository_files,
|
list_repository_files,
|
||||||
open_repository_bundle,
|
open_repository_bundle,
|
||||||
list_file_history,
|
list_file_history,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Gitty",
|
"productName": "Gitty",
|
||||||
"version": "2026.7.17",
|
"version": "2026.7.19",
|
||||||
"identifier": "com.gitty",
|
"identifier": "com.gitty",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+353
-89
@@ -1,12 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount, tick } from "svelte";
|
import { onDestroy, onMount, tick } from "svelte";
|
||||||
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
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 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 AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
|
||||||
|
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
|
||||||
import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte";
|
import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte";
|
||||||
import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
|
import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
|
||||||
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
|
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
|
||||||
@@ -21,10 +25,13 @@
|
|||||||
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
||||||
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
|
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
|
||||||
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
|
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
|
||||||
|
import HelpOverlay from "./lib/components/HelpOverlay.svelte";
|
||||||
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
||||||
|
import InteractiveRebaseDialog from "./lib/components/InteractiveRebaseDialog.svelte";
|
||||||
import LinePatchDialog from "./lib/components/LinePatchDialog.svelte";
|
import LinePatchDialog from "./lib/components/LinePatchDialog.svelte";
|
||||||
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
|
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
|
||||||
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
|
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
|
||||||
|
import ReflogDialog from "./lib/components/ReflogDialog.svelte";
|
||||||
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
|
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
|
||||||
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
|
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
|
||||||
import StashPanel from "./lib/components/StashPanel.svelte";
|
import StashPanel from "./lib/components/StashPanel.svelte";
|
||||||
@@ -40,6 +47,7 @@
|
|||||||
cloneRepository,
|
cloneRepository,
|
||||||
commit,
|
commit,
|
||||||
commitAiGenerate,
|
commitAiGenerate,
|
||||||
|
commitAiReview,
|
||||||
commitAiLoad,
|
commitAiLoad,
|
||||||
commitAiLocalModels,
|
commitAiLocalModels,
|
||||||
commitAiStatus,
|
commitAiStatus,
|
||||||
@@ -62,6 +70,8 @@
|
|||||||
listTags,
|
listTags,
|
||||||
listCommits,
|
listCommits,
|
||||||
listFileHistory,
|
listFileHistory,
|
||||||
|
listInteractiveRebaseCommits,
|
||||||
|
listReflog,
|
||||||
listRepositoryFiles,
|
listRepositoryFiles,
|
||||||
mergeBranch,
|
mergeBranch,
|
||||||
openRepoInExplorer,
|
openRepoInExplorer,
|
||||||
@@ -83,9 +93,11 @@
|
|||||||
resolveConflict,
|
resolveConflict,
|
||||||
resolveConflictSide,
|
resolveConflictSide,
|
||||||
restoreFileFromCommit,
|
restoreFileFromCommit,
|
||||||
|
restoreReflogEntry,
|
||||||
restoreFiles,
|
restoreFiles,
|
||||||
restoreToCommit,
|
restoreToCommit,
|
||||||
searchCodeIntroductions,
|
searchCodeIntroductions,
|
||||||
|
startInteractiveRebase,
|
||||||
setSyncBadge,
|
setSyncBadge,
|
||||||
stageFiles,
|
stageFiles,
|
||||||
stashApply,
|
stashApply,
|
||||||
@@ -97,7 +109,9 @@
|
|||||||
} from "./lib/git";
|
} from "./lib/git";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
AiReviewResult,
|
||||||
AiSettings,
|
AiSettings,
|
||||||
|
AppLanguage,
|
||||||
AppTheme,
|
AppTheme,
|
||||||
AnalyticsSettings,
|
AnalyticsSettings,
|
||||||
CommitAiPhase,
|
CommitAiPhase,
|
||||||
@@ -119,6 +133,9 @@
|
|||||||
LocalModelOption,
|
LocalModelOption,
|
||||||
PatchApplyAction,
|
PatchApplyAction,
|
||||||
PreparedResolution,
|
PreparedResolution,
|
||||||
|
RebaseCommit,
|
||||||
|
RebasePlanItem,
|
||||||
|
ReflogEntry,
|
||||||
StoredCredential,
|
StoredCredential,
|
||||||
} from "./lib/types";
|
} from "./lib/types";
|
||||||
|
|
||||||
@@ -167,6 +184,8 @@
|
|||||||
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
|
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
|
||||||
const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
|
const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
|
||||||
const APP_THEME_KEY = "gitlite.theme.v1";
|
const 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 COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
|
||||||
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
|
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
|
||||||
const LEFT_BRANCH_PANEL_HEIGHT_KEY = "gitlite.leftBranchPanelHeight.v1";
|
const LEFT_BRANCH_PANEL_HEIGHT_KEY = "gitlite.leftBranchPanelHeight.v1";
|
||||||
@@ -235,13 +254,18 @@
|
|||||||
let lastLocalAiGeneratedMessage = "";
|
let lastLocalAiGeneratedMessage = "";
|
||||||
let commitAiPhase: CommitAiPhase = "idle";
|
let commitAiPhase: CommitAiPhase = "idle";
|
||||||
let commitAiGenerating = false;
|
let commitAiGenerating = false;
|
||||||
|
let commitAiReviewing = false;
|
||||||
|
let aiReviewResult: AiReviewResult | null = null;
|
||||||
|
let aiReviewOpen = false;
|
||||||
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
|
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
let aiSettings: AiSettings = defaultAiSettings();
|
let aiSettings: AiSettings = defaultAiSettings();
|
||||||
let aiSettingsOpen = false;
|
let aiSettingsOpen = false;
|
||||||
let appSettingsOpen = false;
|
let appSettingsOpen = false;
|
||||||
|
let helpOpen = false;
|
||||||
let analyticsNoticeOpen = false;
|
let analyticsNoticeOpen = false;
|
||||||
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
|
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
|
||||||
let appTheme: AppTheme = loadThemePreference();
|
let appTheme: AppTheme = loadThemePreference();
|
||||||
|
let appLanguage: AppLanguage = loadLanguagePreference();
|
||||||
let localModelOptions: LocalModelOption[] = [];
|
let localModelOptions: LocalModelOption[] = [];
|
||||||
let errorMessage = "";
|
let errorMessage = "";
|
||||||
let operation = "";
|
let operation = "";
|
||||||
@@ -254,6 +278,15 @@
|
|||||||
let deleteBranchForce = false;
|
let deleteBranchForce = false;
|
||||||
let compareSelectOpen = false;
|
let compareSelectOpen = false;
|
||||||
let compareDialogOpen = 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 selectedDiffPath = "";
|
||||||
let diffHighlightQuery = "";
|
let diffHighlightQuery = "";
|
||||||
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
|
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
|
||||||
@@ -279,7 +312,7 @@
|
|||||||
let conflictTarget = "";
|
let conflictTarget = "";
|
||||||
let conflict: ConflictFile | null = null;
|
let conflict: ConflictFile | null = null;
|
||||||
let preparedResolutions: Record<string, PreparedResolution> = {};
|
let preparedResolutions: Record<string, PreparedResolution> = {};
|
||||||
let autoRefreshEnabled = true;
|
let autoRefreshEnabled = loadStoredBoolean(AUTO_REFRESH_ENABLED_KEY, true);
|
||||||
let autoRefreshInFlight = false;
|
let autoRefreshInFlight = false;
|
||||||
let credDialogOpen = false;
|
let credDialogOpen = false;
|
||||||
let credDialogAction: CredentialAction | null = null;
|
let credDialogAction: CredentialAction | null = null;
|
||||||
@@ -341,6 +374,7 @@
|
|||||||
let fileHistoryResizeStartWidth = 0;
|
let fileHistoryResizeStartWidth = 0;
|
||||||
let fileHistoryCollapsed = true;
|
let fileHistoryCollapsed = true;
|
||||||
let themeMediaQuery: MediaQueryList | undefined;
|
let themeMediaQuery: MediaQueryList | undefined;
|
||||||
|
let appVersion = "";
|
||||||
|
|
||||||
// ── Derived ────────────────────────────────────────────────────────────────
|
// ── Derived ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -394,6 +428,7 @@
|
|||||||
$: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
|
$: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
|
||||||
|
|
||||||
$: applyThemePreference(appTheme);
|
$: applyThemePreference(appTheme);
|
||||||
|
$: applyLanguagePreference(appLanguage);
|
||||||
|
|
||||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -401,6 +436,7 @@
|
|||||||
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
|
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
|
||||||
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
|
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
|
||||||
void runStartupSequence();
|
void runStartupSequence();
|
||||||
|
void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; });
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -421,7 +457,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function waitForAnimationFrame(): Promise<void> {
|
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() {
|
async function waitForStartupPaint() {
|
||||||
@@ -640,7 +690,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function autoRefreshTick() {
|
async function autoRefreshTick() {
|
||||||
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
|
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || newBranchCommit || globalSearchOpen || helpOpen) return;
|
||||||
const path = activeRepoPath;
|
const path = activeRepoPath;
|
||||||
autoRefreshInFlight = true;
|
autoRefreshInFlight = true;
|
||||||
try {
|
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) {
|
function applyThemePreference(next: AppTheme) {
|
||||||
const prefersLight = themeMediaQuery?.matches ?? window.matchMedia("(prefers-color-scheme: light)").matches;
|
const prefersLight = themeMediaQuery?.matches ?? window.matchMedia("(prefers-color-scheme: light)").matches;
|
||||||
const resolved = next === "system"
|
const resolved = next === "system"
|
||||||
@@ -796,13 +869,19 @@
|
|||||||
if (appTheme === "system") applyThemePreference(appTheme);
|
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;
|
analyticsSettings = next;
|
||||||
appTheme = nextTheme;
|
appTheme = nextTheme;
|
||||||
|
appLanguage = nextLanguage;
|
||||||
|
autoRefreshEnabled = nextAutoRefresh;
|
||||||
persistAnalyticsSettings(next);
|
persistAnalyticsSettings(next);
|
||||||
persistThemePreference(nextTheme);
|
persistThemePreference(nextTheme);
|
||||||
|
persistLanguagePreference(nextLanguage);
|
||||||
|
persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh);
|
||||||
appSettingsOpen = false;
|
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) {
|
function updateCommitMessage(message: string) {
|
||||||
@@ -863,9 +942,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAutoRefresh() {
|
async function reviewStagedWithAi() {
|
||||||
autoRefreshEnabled = !autoRefreshEnabled;
|
if (!activeRepoPath || commitAiReviewing || stagedCount === 0) return;
|
||||||
if (autoRefreshEnabled) void autoRefreshTick();
|
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 ────────────────────────────────────────────────────────────────
|
// ── Updates ────────────────────────────────────────────────────────────────
|
||||||
@@ -1493,6 +1612,15 @@
|
|||||||
fileHistoryCollapsed = false;
|
fileHistoryCollapsed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hideAndResetFileHistory() {
|
||||||
|
fileHistoryRequestId += 1;
|
||||||
|
cancelActiveFileHistoryLoad();
|
||||||
|
activeFileHistoryRequestId = "";
|
||||||
|
fileHistoryLoading = false;
|
||||||
|
fileHistory = [];
|
||||||
|
fileHistoryCollapsed = true;
|
||||||
|
}
|
||||||
|
|
||||||
function rememberRecentRepo(path: string) {
|
function rememberRecentRepo(path: string) {
|
||||||
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
|
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
|
||||||
persistRepoLists();
|
persistRepoLists();
|
||||||
@@ -1566,6 +1694,13 @@
|
|||||||
comparison = null;
|
comparison = null;
|
||||||
compareSelectOpen = false;
|
compareSelectOpen = false;
|
||||||
compareDialogOpen = false;
|
compareDialogOpen = false;
|
||||||
|
interactiveRebaseOpen = false;
|
||||||
|
interactiveRebaseBase = "";
|
||||||
|
interactiveRebaseCommits = [];
|
||||||
|
interactiveRebaseError = "";
|
||||||
|
reflogOpen = false;
|
||||||
|
reflogEntries = [];
|
||||||
|
reflogError = "";
|
||||||
selectedDiffPath = "";
|
selectedDiffPath = "";
|
||||||
pendingRestoreFile = null;
|
pendingRestoreFile = null;
|
||||||
newBranchCommit = null;
|
newBranchCommit = null;
|
||||||
@@ -1692,7 +1827,7 @@
|
|||||||
await refreshBranchList(path);
|
await refreshBranchList(path);
|
||||||
await refreshTags(path);
|
await refreshTags(path);
|
||||||
await refreshCommitHistory(path);
|
await refreshCommitHistory(path);
|
||||||
if (lastFileHistoryHeadHash !== previousHeadHash) {
|
if (lastFileHistoryHeadHash !== previousHeadHash && !fileHistoryCollapsed) {
|
||||||
await refreshFileHistory(path);
|
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) {
|
async function createNewTag(name: string, message: string) {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!activeRepoPath || !trimmed) return;
|
if (!activeRepoPath || !trimmed) return;
|
||||||
@@ -3043,7 +3271,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectExplorerNode(node: ExplorerNode) {
|
async function selectExplorerNode(node: ExplorerNode) {
|
||||||
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
|
if (!activeRepoPath) return;
|
||||||
selectedExplorerPath = node.path;
|
selectedExplorerPath = node.path;
|
||||||
selectedExplorerKind = node.kind;
|
selectedExplorerKind = node.kind;
|
||||||
revealFileHistory();
|
revealFileHistory();
|
||||||
@@ -3073,9 +3301,7 @@
|
|||||||
selectedExplorerPath = file.path;
|
selectedExplorerPath = file.path;
|
||||||
selectedExplorerKind = "file";
|
selectedExplorerKind = "file";
|
||||||
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
||||||
revealFileHistory();
|
hideAndResetFileHistory();
|
||||||
|
|
||||||
void loadSelectedFileHistory(file.path);
|
|
||||||
trackEvent("explorer_file_selected", {
|
trackEvent("explorer_file_selected", {
|
||||||
source: "status",
|
source: "status",
|
||||||
status: file.unstaged ?? file.staged ?? "unknown",
|
status: file.unstaged ?? file.staged ?? "unknown",
|
||||||
@@ -3127,6 +3353,11 @@
|
|||||||
trackEvent("global_search_opened");
|
trackEvent("global_search_opened");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openHelp() {
|
||||||
|
helpOpen = true;
|
||||||
|
trackEvent("help_opened");
|
||||||
|
}
|
||||||
|
|
||||||
async function compareSelectedCommits() {
|
async function compareSelectedCommits() {
|
||||||
if (!canCompare) return;
|
if (!canCompare) return;
|
||||||
await runOperation("Comparing commits", async () => {
|
await runOperation("Comparing commits", async () => {
|
||||||
@@ -3317,12 +3548,23 @@
|
|||||||
// ── Event handlers ─────────────────────────────────────────────────────────
|
// ── Event handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function handleWindowKeydown(event: KeyboardEvent) {
|
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();
|
if (event.key === "Escape" && repoTabContextMenu) closeRepoTabContextMenu();
|
||||||
else if (event.key === "Escape" && pendingDiscard && !isBusy) closeDiscardConfirm();
|
else if (event.key === "Escape" && pendingDiscard && !isBusy) closeDiscardConfirm();
|
||||||
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
|
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
|
||||||
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
|
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
|
||||||
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
|
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
|
||||||
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
|
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" && compareSelectOpen) compareSelectOpen = false;
|
||||||
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
|
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
|
||||||
}
|
}
|
||||||
@@ -3345,87 +3587,47 @@
|
|||||||
|
|
||||||
<main class="shell">
|
<main class="shell">
|
||||||
<TitleBar
|
<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; }}
|
onOpenSettings={() => { appSettingsOpen = true; }}
|
||||||
|
onOpenHelp={openHelp}
|
||||||
|
language={appLanguage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="shell-body">
|
<div class="shell-body">
|
||||||
|
|
||||||
<header class="repo-tabbar" aria-label="Repository tabs">
|
<RepoTabs
|
||||||
<button
|
{activeView}
|
||||||
class="repo-tab management"
|
{repoTabs}
|
||||||
class:active={activeView === "management"}
|
{isBusy}
|
||||||
type="button"
|
language={appLanguage}
|
||||||
onclick={openRepoManagement}
|
onOpenManagement={openRepoManagement}
|
||||||
disabled={isBusy}
|
isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)}
|
||||||
title="Repository Management"
|
onSelect={selectRepoTab}
|
||||||
>
|
onClose={closeRepoTab}
|
||||||
<BookOpen size={14} aria-hidden="true" />
|
onContextMenu={openRepoTabContextMenu}
|
||||||
Repository Management
|
onAdd={chooseRepositoryFolder}
|
||||||
</button>
|
/>
|
||||||
|
|
||||||
<div class="repo-tabs-scroll">
|
<!-- Repo actions live under the tab bar and disappear in Repository
|
||||||
{#each repoTabs as repo (repo.path)}
|
Management, where none of them are applicable. -->
|
||||||
<div
|
{#if workspaceActive}
|
||||||
class="repo-tab-wrap"
|
<RepoToolbar
|
||||||
class:active={activeView === "repository" && sameRepoPath(activeRepoPath, repo.path)}
|
hasRepository={workspaceActive}
|
||||||
role="presentation"
|
{isBusy}
|
||||||
oncontextmenu={(event) => openRepoTabContextMenu(repo.path, event)}
|
{operation}
|
||||||
>
|
ahead={status?.ahead ?? 0}
|
||||||
<button
|
behind={status?.behind ?? 0}
|
||||||
class="repo-tab"
|
language={appLanguage}
|
||||||
type="button"
|
onFetch={fetchRepo}
|
||||||
onclick={() => selectRepoTab(repo.path)}
|
onPull={pullRepo}
|
||||||
disabled={isBusy}
|
onPush={pushRepo}
|
||||||
title={repo.path}
|
onRefresh={refreshRepo}
|
||||||
>
|
onSearch={openGlobalSearchDialog}
|
||||||
<FolderOpen size={14} aria-hidden="true" />
|
onCompare={openCompareSelect}
|
||||||
<span>{repo.name}</span>
|
onInteractiveRebase={openInteractiveRebase}
|
||||||
{#if repo.branch}
|
onReflog={openReflog}
|
||||||
<strong>{repo.branch}</strong>
|
onOpenInExplorer={openActiveRepoInExplorer}
|
||||||
{/if}
|
/>
|
||||||
</button>
|
{/if}
|
||||||
<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>
|
|
||||||
|
|
||||||
{#if repoTabContextMenu}
|
{#if repoTabContextMenu}
|
||||||
{@const menu = repoTabContextMenu}
|
{@const menu = repoTabContextMenu}
|
||||||
@@ -3865,11 +4067,13 @@
|
|||||||
commitAiProvider={aiSettings.provider}
|
commitAiProvider={aiSettings.provider}
|
||||||
{commitAiPhase}
|
{commitAiPhase}
|
||||||
{commitAiGenerating}
|
{commitAiGenerating}
|
||||||
|
{commitAiReviewing}
|
||||||
{canAmend}
|
{canAmend}
|
||||||
{amendMode}
|
{amendMode}
|
||||||
onCommit={commitChanges}
|
onCommit={commitChanges}
|
||||||
onCommitMessageChange={updateCommitMessage}
|
onCommitMessageChange={updateCommitMessage}
|
||||||
onGenerateCommitMessage={generateCommitMessageWithAi}
|
onGenerateCommitMessage={generateCommitMessageWithAi}
|
||||||
|
onReviewStaged={reviewStagedWithAi}
|
||||||
onOpenAiSettings={() => { aiSettingsOpen = true; }}
|
onOpenAiSettings={() => { aiSettingsOpen = true; }}
|
||||||
onToggleAmend={toggleAmendMode}
|
onToggleAmend={toggleAmendMode}
|
||||||
onUndoLastCommit={undoLastCommitChange}
|
onUndoLastCommit={undoLastCommitChange}
|
||||||
@@ -3950,6 +4154,19 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -3977,11 +4194,27 @@
|
|||||||
<AppSettingsDialog
|
<AppSettingsDialog
|
||||||
analytics={analyticsSettings}
|
analytics={analyticsSettings}
|
||||||
theme={appTheme}
|
theme={appTheme}
|
||||||
|
language={appLanguage}
|
||||||
|
autoRefresh={autoRefreshEnabled}
|
||||||
onSave={saveAppSettings}
|
onSave={saveAppSettings}
|
||||||
onClose={() => { appSettingsOpen = false; }}
|
onClose={() => { appSettingsOpen = false; }}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/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}
|
{#if linePatchOpen && linePatchFile}
|
||||||
<LinePatchDialog
|
<LinePatchDialog
|
||||||
file={linePatchFile}
|
file={linePatchFile}
|
||||||
@@ -4079,6 +4312,37 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/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 -->
|
<!-- Compare: pick the two commits to diff -->
|
||||||
{#if compareSelectOpen}
|
{#if compareSelectOpen}
|
||||||
<CompareSelectDialog
|
<CompareSelectDialog
|
||||||
|
|||||||
+1167
-125
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { BookOpen, Database, Plus, X } from "@lucide/svelte";
|
||||||
|
|
||||||
|
interface RepositoryTabItem {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
branch: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export let activeView: "management" | "repository" = "management";
|
||||||
|
export let repoTabs: RepositoryTabItem[] = [];
|
||||||
|
export let isBusy: boolean = false;
|
||||||
|
export let language: "en" | "de" = "en";
|
||||||
|
export let onOpenManagement: () => void = () => {};
|
||||||
|
export let isActive: (path: string) => boolean = () => false;
|
||||||
|
export let onSelect: (path: string) => void | Promise<void> = () => {};
|
||||||
|
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
|
||||||
|
export let onContextMenu: (path: string, event: MouseEvent) => void = () => {};
|
||||||
|
export let onAdd: () => void | Promise<void> = () => {};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="repo-tabbar" aria-label={language === "de" ? "Repository-Reiter" : "Repository tabs"}>
|
||||||
|
<button
|
||||||
|
class="repo-tab management"
|
||||||
|
class:active={activeView === "management"}
|
||||||
|
type="button"
|
||||||
|
onclick={onOpenManagement}
|
||||||
|
disabled={isBusy}
|
||||||
|
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
||||||
|
>
|
||||||
|
<BookOpen size={14} aria-hidden="true" />
|
||||||
|
<span>{language === "de" ? "Repository-Verwaltung" : "Repository Management"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="repo-tabs-scroll">
|
||||||
|
{#each repoTabs as repo (repo.path)}
|
||||||
|
<div
|
||||||
|
class="repo-tab-wrap"
|
||||||
|
class:active={isActive(repo.path)}
|
||||||
|
role="presentation"
|
||||||
|
oncontextmenu={(event) => onContextMenu(repo.path, event)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="repo-tab"
|
||||||
|
type="button"
|
||||||
|
onclick={() => onSelect(repo.path)}
|
||||||
|
disabled={isBusy}
|
||||||
|
title={repo.path}
|
||||||
|
>
|
||||||
|
<Database size={15} aria-hidden="true" />
|
||||||
|
<span>{repo.name}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="repo-tab-close"
|
||||||
|
type="button"
|
||||||
|
onclick={(event) => onClose(repo.path, event)}
|
||||||
|
disabled={isBusy}
|
||||||
|
aria-label={language === "de" ? `${repo.name} schließen` : `Close ${repo.name}`}
|
||||||
|
title={language === "de" ? "Repository-Tab schließen" : "Close repository tab"}
|
||||||
|
>
|
||||||
|
<X size={13} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="repo-tab-add"
|
||||||
|
type="button"
|
||||||
|
onclick={onAdd}
|
||||||
|
disabled={isBusy}
|
||||||
|
title={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}
|
||||||
|
aria-label={language === "de" ? "Repository-Ordner öffnen" : "Open repository folder"}
|
||||||
|
>
|
||||||
|
<Plus size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
CloudDownload,
|
||||||
|
Download,
|
||||||
|
FolderOpen,
|
||||||
|
GitCompare,
|
||||||
|
History,
|
||||||
|
ListRestart,
|
||||||
|
LoaderCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Upload,
|
||||||
|
} from "@lucide/svelte";
|
||||||
|
|
||||||
|
export let hasRepository: boolean = false;
|
||||||
|
export let isBusy: boolean = false;
|
||||||
|
export let operation: string = "";
|
||||||
|
export let ahead: number = 0;
|
||||||
|
export let behind: number = 0;
|
||||||
|
export let language: "en" | "de" = "en";
|
||||||
|
export let onFetch: () => void = () => {};
|
||||||
|
export let onPull: () => void = () => {};
|
||||||
|
export let onPush: () => void = () => {};
|
||||||
|
export let onRefresh: () => void = () => {};
|
||||||
|
export let onSearch: () => void = () => {};
|
||||||
|
export let onCompare: () => void = () => {};
|
||||||
|
export let onInteractiveRebase: () => void = () => {};
|
||||||
|
export let onReflog: () => void = () => {};
|
||||||
|
export let onOpenInExplorer: () => void = () => {};
|
||||||
|
|
||||||
|
let historyOpen = false;
|
||||||
|
let toolbarElement: HTMLDivElement;
|
||||||
|
|
||||||
|
$: isGerman = language === "de";
|
||||||
|
|
||||||
|
function runHistoryAction(action: () => void) {
|
||||||
|
historyOpen = false;
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowClick(event: MouseEvent) {
|
||||||
|
if (historyOpen && toolbarElement && !toolbarElement.contains(event.target as Node)) {
|
||||||
|
historyOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape" && historyOpen) {
|
||||||
|
event.stopPropagation();
|
||||||
|
historyOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onclick={handleWindowClick} onkeydown={handleWindowKeydown} />
|
||||||
|
|
||||||
|
<div bind:this={toolbarElement} class="repo-toolbar" role="toolbar" aria-label={isGerman ? "Repository-Aktionen" : "Repository actions"}>
|
||||||
|
<div class="repo-toolbar-sync">
|
||||||
|
<div class="repo-action-group repo-sync-actions">
|
||||||
|
<button
|
||||||
|
class="repo-action fetch"
|
||||||
|
onclick={onFetch}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
title="Fetch"
|
||||||
|
aria-label="Fetch"
|
||||||
|
>
|
||||||
|
{#if operation === "Fetching"}
|
||||||
|
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<CloudDownload size={15} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
<span class="repo-action-label">Fetch</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="repo-action sync-primary"
|
||||||
|
onclick={onPull}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
title="Pull"
|
||||||
|
aria-label={behind > 0
|
||||||
|
? `Pull, ${behind} ${isGerman ? (behind === 1 ? "Remote-Commit voraus" : "Remote-Commits voraus") : (behind === 1 ? "commit behind" : "commits behind")}`
|
||||||
|
: "Pull"}
|
||||||
|
>
|
||||||
|
{#if operation === "Pulling"}
|
||||||
|
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<Download size={15} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
<span class="repo-action-label">Pull</span>
|
||||||
|
{#if behind > 0}<span class="repo-action-count behind">↓{behind}</span>{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="repo-action sync-primary"
|
||||||
|
onclick={onPush}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
title="Push"
|
||||||
|
aria-label={ahead > 0
|
||||||
|
? `Push, ${ahead} ${isGerman ? (ahead === 1 ? "lokaler Commit voraus" : "lokale Commits voraus") : (ahead === 1 ? "commit ahead" : "commits ahead")}`
|
||||||
|
: "Push"}
|
||||||
|
>
|
||||||
|
{#if operation === "Pushing"}
|
||||||
|
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<Upload size={15} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
<span class="repo-action-label">Push</span>
|
||||||
|
{#if ahead > 0}<span class="repo-action-count ahead">↑{ahead}</span>{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="repo-toolbar-divider" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="repo-action-group repo-inspect-actions">
|
||||||
|
<button
|
||||||
|
class="repo-action"
|
||||||
|
onclick={onSearch}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
title={isGerman ? "Globale Suche" : "Global search"}
|
||||||
|
aria-label={isGerman ? "Globale Suche" : "Global search"}
|
||||||
|
>
|
||||||
|
<Search size={15} aria-hidden="true" />
|
||||||
|
<span class="repo-action-label">{isGerman ? "Suchen" : "Search"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="repo-action"
|
||||||
|
onclick={onCompare}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
title={isGerman ? "Commits vergleichen" : "Compare commits"}
|
||||||
|
aria-label={isGerman ? "Commits vergleichen" : "Compare commits"}
|
||||||
|
>
|
||||||
|
<GitCompare size={15} aria-hidden="true" />
|
||||||
|
<span class="repo-action-label">{isGerman ? "Vergleichen" : "Compare"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="repo-history-wrap">
|
||||||
|
<button
|
||||||
|
class="repo-action history-trigger"
|
||||||
|
class:active={historyOpen}
|
||||||
|
type="button"
|
||||||
|
onclick={() => { historyOpen = !historyOpen; }}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={historyOpen}
|
||||||
|
title={isGerman ? "Verlauf und Rebase" : "History and rebase"}
|
||||||
|
>
|
||||||
|
<History size={15} aria-hidden="true" />
|
||||||
|
<span class="repo-action-label">{isGerman ? "Verlauf" : "History"}</span>
|
||||||
|
<ChevronDown class="history-chevron" size={13} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if historyOpen}
|
||||||
|
<div class="repo-history-menu" role="menu">
|
||||||
|
<button type="button" role="menuitem" onclick={() => runHistoryAction(onReflog)}>
|
||||||
|
<History size={15} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>Reflog</strong>
|
||||||
|
<small>{isGerman ? "Lokale Referenzbewegungen" : "Local reference movements"}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" role="menuitem" onclick={() => runHistoryAction(onInteractiveRebase)}>
|
||||||
|
<ListRestart size={15} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<strong>{isGerman ? "Interaktiver Rebase" : "Interactive rebase"}</strong>
|
||||||
|
<small>{isGerman ? "Commits ordnen und zusammenfassen" : "Reorder and combine commits"}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="repo-toolbar-spacer"></div>
|
||||||
|
<div class="repo-toolbar-divider utility" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="repo-action-group repo-utility-actions">
|
||||||
|
<button
|
||||||
|
class="repo-action"
|
||||||
|
onclick={onOpenInExplorer}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
title={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"}
|
||||||
|
aria-label={isGerman ? "Repository im Explorer öffnen" : "Open repository in Explorer"}
|
||||||
|
>
|
||||||
|
<FolderOpen size={15} aria-hidden="true" />
|
||||||
|
<span class="repo-action-label utility-label">Explorer</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="repo-action"
|
||||||
|
onclick={onRefresh}
|
||||||
|
disabled={isBusy || !hasRepository}
|
||||||
|
title={isGerman ? "Manuell aktualisieren" : "Refresh now"}
|
||||||
|
aria-label={isGerman ? "Manuell aktualisieren" : "Refresh now"}
|
||||||
|
>
|
||||||
|
<RefreshCw class={operation === "Refreshing" ? "spin" : ""} size={16} aria-hidden="true" />
|
||||||
|
<span class="repo-action-label utility-label">{isGerman ? "Aktualisieren" : "Refresh"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+47
-197
@@ -1,33 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
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";
|
import iconUrl from "../../src-tauri/icons/icon.png";
|
||||||
|
|
||||||
export let branch: string = "";
|
export let onOpenHelp: () => void = () => {};
|
||||||
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 onOpenSettings: () => void = () => {};
|
export let onOpenSettings: () => void = () => {};
|
||||||
|
export let language: "en" | "de" = "en";
|
||||||
|
|
||||||
let win: ReturnType<typeof getCurrentWindow> | null = null;
|
let win: ReturnType<typeof getCurrentWindow> | null = null;
|
||||||
let isMaximized = false;
|
let isMaximized = false;
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
let appVersion = "";
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -39,12 +22,6 @@
|
|||||||
} catch {
|
} catch {
|
||||||
win = null;
|
win = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
appVersion = await getVersion();
|
|
||||||
} catch {
|
|
||||||
appVersion = "";
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -71,180 +48,53 @@
|
|||||||
<img src={iconUrl} alt="" data-tauri-drag-region />
|
<img src={iconUrl} alt="" data-tauri-drag-region />
|
||||||
</span>
|
</span>
|
||||||
<span data-tauri-drag-region>Gitty</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>
|
</div>
|
||||||
|
|
||||||
<!-- Center: repo + branch info -->
|
<div class="titlebar-drag" data-tauri-drag-region aria-hidden="true"></div>
|
||||||
<div class="titlebar-info" data-tauri-drag-region>
|
|
||||||
{#if hasRepository}
|
<!-- Right: app-global actions + window controls -->
|
||||||
{#if repoName}
|
<div class="titlebar-globals">
|
||||||
<span class="tb-repo" data-tauri-drag-region>{repoName}</span>
|
<button
|
||||||
<span class="tb-sep" data-tauri-drag-region aria-hidden="true">/</span>
|
class="tb-action"
|
||||||
{/if}
|
onclick={onOpenHelp}
|
||||||
<GitBranch size={12} aria-hidden="true" />
|
title={language === "de" ? "Hilfe (Ctrl+/)" : "Help (Ctrl+/)"}
|
||||||
<span class="tb-branch" data-tauri-drag-region>{branch}</span>
|
aria-label={language === "de" ? "Hilfe öffnen" : "Open help"}
|
||||||
{#if ahead > 0}
|
>
|
||||||
<span class="tb-sync ahead" title="{ahead} commits ahead">↑{ahead}</span>
|
<CircleHelp size={14} aria-hidden="true" />
|
||||||
{/if}
|
</button>
|
||||||
{#if behind > 0}
|
<button
|
||||||
<span class="tb-sync behind" title="{behind} commits behind">↓{behind}</span>
|
class="tb-action"
|
||||||
{/if}
|
onclick={onOpenSettings}
|
||||||
{:else}
|
title={language === "de" ? "Einstellungen" : "Settings"}
|
||||||
<span class="tb-no-repo" data-tauri-drag-region>No repository open</span>
|
aria-label={language === "de" ? "Einstellungen" : "Settings"}
|
||||||
{/if}
|
>
|
||||||
|
<Settings size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: actions + window controls -->
|
<div class="titlebar-controls">
|
||||||
<div class="titlebar-right">
|
<button class="tb-btn" onclick={minimizeWindow} title="Minimize" aria-label="Minimize">
|
||||||
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
|
<Minus size={12} aria-hidden="true" />
|
||||||
<button
|
</button>
|
||||||
class="tb-action"
|
<button
|
||||||
onclick={onOpenInExplorer}
|
class="tb-btn"
|
||||||
disabled={!hasRepository || isBusy}
|
onclick={toggleMaximizeWindow}
|
||||||
title="Open repository in Explorer"
|
title={isMaximized ? "Restore" : "Maximize"}
|
||||||
aria-label="Open repository in Explorer"
|
aria-label={isMaximized ? "Restore" : "Maximize"}
|
||||||
>
|
>
|
||||||
<FolderOpen size={14} aria-hidden="true" />
|
{#if isMaximized}
|
||||||
<span class="tb-action-label">Explorer</span>
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
</button>
|
<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" />
|
||||||
<button
|
</svg>
|
||||||
class="tb-action"
|
{:else}
|
||||||
onclick={onSearch}
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
disabled={!hasRepository || isBusy}
|
<rect x="1" y="1" width="10" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||||
title="Global search"
|
</svg>
|
||||||
aria-label="Global search"
|
{/if}
|
||||||
>
|
</button>
|
||||||
<Search size={14} aria-hidden="true" />
|
<button class="tb-btn close" onclick={closeWindow} title="Close" aria-label="Close">
|
||||||
<span class="tb-action-label">Search</span>
|
<X size={12} aria-hidden="true" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { AlertTriangle, CircleAlert, FileCode, Info, LoaderCircle, RotateCw, ShieldCheck, X } from "@lucide/svelte";
|
||||||
|
import type { AiReviewFinding, AiReviewResult, CommitAiProvider } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
result: AiReviewResult;
|
||||||
|
provider: CommitAiProvider;
|
||||||
|
isReviewing: boolean;
|
||||||
|
onRerun: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { result, provider, isReviewing = false, onRerun, onClose }: Props = $props();
|
||||||
|
|
||||||
|
function providerLabel(value: CommitAiProvider): string {
|
||||||
|
if (value === "openai") return "OpenAI";
|
||||||
|
if (value === "anthropic") return "Anthropic";
|
||||||
|
if (value === "custom") return "Custom endpoint";
|
||||||
|
return "Local AI";
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationLabel(finding: AiReviewFinding): string {
|
||||||
|
if (!finding.file) return "Repository-wide";
|
||||||
|
return finding.line ? `${finding.file}:${finding.line}` : finding.file;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={(event) => { if (event.key === "Escape" && !isReviewing) onClose(); }} />
|
||||||
|
|
||||||
|
<div class="dialog-backdrop" role="presentation">
|
||||||
|
<div class="dialog ai-review-dialog" role="dialog" aria-modal="true" aria-label="AI pre-commit review" tabindex="-1">
|
||||||
|
<header class="dialog-header ai-review-header">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Staged changes</span>
|
||||||
|
<h2>AI pre-commit review</h2>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-header-actions">
|
||||||
|
<span class="ai-review-provider">{providerLabel(provider)}</span>
|
||||||
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isReviewing} aria-label="Close review"><X size={18} aria-hidden="true" /></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="ai-review-summary">
|
||||||
|
<div class="ai-review-summary-icon" class:clean={result.findings.length === 0}>
|
||||||
|
{#if result.findings.length === 0}<ShieldCheck size={22} aria-hidden="true" />{:else}<AlertTriangle size={22} aria-hidden="true" />{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="ai-review-summary-line">
|
||||||
|
<strong>{result.findings.length === 0 ? "No actionable issues found" : `${result.findings.length} review ${result.findings.length === 1 ? "finding" : "findings"}`}</strong>
|
||||||
|
<span class="ai-review-risk {result.risk}">{result.risk} risk</span>
|
||||||
|
</div>
|
||||||
|
<p>{result.summary}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ai-review-findings">
|
||||||
|
{#if result.findings.length === 0}
|
||||||
|
<div class="ai-review-clean-state">
|
||||||
|
<ShieldCheck size={30} aria-hidden="true" />
|
||||||
|
<strong>The staged diff looks ready for human verification.</strong>
|
||||||
|
<span>AI reviews can miss issues. Run the relevant tests before committing.</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each result.findings as finding, index (`${finding.file ?? "repo"}:${finding.line ?? 0}:${finding.title}:${index}`)}
|
||||||
|
<article class="ai-review-finding {finding.severity}">
|
||||||
|
<div class="ai-review-finding-icon">
|
||||||
|
{#if finding.severity === "critical"}<CircleAlert size={17} aria-hidden="true" />{:else if finding.severity === "warning"}<AlertTriangle size={17} aria-hidden="true" />{:else}<Info size={17} aria-hidden="true" />{/if}
|
||||||
|
</div>
|
||||||
|
<div class="ai-review-finding-body">
|
||||||
|
<div class="ai-review-finding-title">
|
||||||
|
<span>{finding.severity}</span>
|
||||||
|
<strong>{finding.title}</strong>
|
||||||
|
</div>
|
||||||
|
<p>{finding.description}</p>
|
||||||
|
<div class="ai-review-location"><FileCode size={13} aria-hidden="true" /><code>{locationLabel(finding)}</code></div>
|
||||||
|
{#if finding.suggestion}<div class="ai-review-suggestion"><strong>Suggested next step</strong><span>{finding.suggestion}</span></div>{/if}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="dialog-footer ai-review-footer">
|
||||||
|
<p>Review suggestions are advisory and never modify files automatically.</p>
|
||||||
|
<div>
|
||||||
|
<button class="btn-secondary" type="button" onclick={onRerun} disabled={isReviewing}>
|
||||||
|
{#if isReviewing}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<RotateCw size={14} aria-hidden="true" />{/if}
|
||||||
|
Review again
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary" type="button" onclick={onClose} disabled={isReviewing}>Done</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,22 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, Settings, X } from "@lucide/svelte";
|
import { Check, Languages, RefreshCw, Settings, X } from "@lucide/svelte";
|
||||||
import type { AnalyticsSettings, AppTheme } from "../types";
|
import type { AnalyticsSettings, AppLanguage, AppTheme } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
analytics: AnalyticsSettings;
|
analytics: AnalyticsSettings;
|
||||||
theme: AppTheme;
|
theme: AppTheme;
|
||||||
onSave: (settings: AnalyticsSettings, theme: AppTheme) => void;
|
language: AppLanguage;
|
||||||
|
autoRefresh: boolean;
|
||||||
|
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean) => void;
|
||||||
onClose: () => 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 analyticsEnabled = $state(true);
|
||||||
let selectedTheme = $state<AppTheme>("system");
|
let selectedTheme = $state<AppTheme>("system");
|
||||||
|
let selectedLanguage = $state<AppLanguage>("en");
|
||||||
|
let autoRefreshEnabled = $state(true);
|
||||||
|
const isGerman = $derived(selectedLanguage === "de");
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
analyticsEnabled = analytics.enabled;
|
analyticsEnabled = analytics.enabled;
|
||||||
selectedTheme = theme;
|
selectedTheme = theme;
|
||||||
|
selectedLanguage = language;
|
||||||
|
autoRefreshEnabled = autoRefresh;
|
||||||
});
|
});
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
@@ -24,18 +31,18 @@
|
|||||||
...analytics,
|
...analytics,
|
||||||
enabled: analyticsEnabled,
|
enabled: analyticsEnabled,
|
||||||
noticeSeen: true,
|
noticeSeen: true,
|
||||||
}, selectedTheme);
|
}, selectedTheme, selectedLanguage, autoRefreshEnabled);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation">
|
<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">
|
<header class="dialog-header">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Gitty</span>
|
<span class="eyebrow">Gitty</span>
|
||||||
<h2 class="dialog-title">Settings</h2>
|
<h2 class="dialog-title">{isGerman ? "Einstellungen" : "Settings"}</h2>
|
||||||
</div>
|
</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" />
|
<X size={18} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -45,23 +52,62 @@
|
|||||||
<header>
|
<header>
|
||||||
<Settings size={16} aria-hidden="true" />
|
<Settings size={16} aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Appearance</span>
|
<span class="eyebrow">{isGerman ? "Darstellung" : "Appearance"}</span>
|
||||||
<h3>Theme</h3>
|
<h3>{isGerman ? "Farbschema" : "Theme"}</h3>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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"}>
|
<label class:active={selectedTheme === "system"}>
|
||||||
<input type="radio" bind:group={selectedTheme} value="system" />
|
<input type="radio" bind:group={selectedTheme} value="system" />
|
||||||
<span>System</span>
|
<span>System</span>
|
||||||
</label>
|
</label>
|
||||||
<label class:active={selectedTheme === "light"}>
|
<label class:active={selectedTheme === "light"}>
|
||||||
<input type="radio" bind:group={selectedTheme} value="light" />
|
<input type="radio" bind:group={selectedTheme} value="light" />
|
||||||
<span>Light</span>
|
<span>{isGerman ? "Hell" : "Light"}</span>
|
||||||
</label>
|
</label>
|
||||||
<label class:active={selectedTheme === "dark"}>
|
<label class:active={selectedTheme === "dark"}>
|
||||||
<input type="radio" bind:group={selectedTheme} value="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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -71,24 +117,24 @@
|
|||||||
<Settings size={16} aria-hidden="true" />
|
<Settings size={16} aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Analytics</span>
|
<span class="eyebrow">Analytics</span>
|
||||||
<h3>Anonymous usage analytics</h3>
|
<h3>{isGerman ? "Anonyme Nutzungsanalyse" : "Anonymous usage analytics"}</h3>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<label class="settings-toggle-row">
|
<label class="settings-toggle-row">
|
||||||
<input type="checkbox" bind:checked={analyticsEnabled} />
|
<input type="checkbox" bind:checked={analyticsEnabled} />
|
||||||
<span>
|
<span>
|
||||||
<strong>Allow anonymous Aptabase events</strong>
|
<strong>{isGerman ? "Anonyme Aptabase-Ereignisse erlauben" : "Allow anonymous Aptabase events"}</strong>
|
||||||
<small>No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent.</small>
|
<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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="new-branch-actions">
|
<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">
|
<button class="btn-primary" type="submit">
|
||||||
<Check size={16} aria-hidden="true" />
|
<Check size={16} aria-hidden="true" />
|
||||||
Save
|
{isGerman ? "Speichern" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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";
|
import type { CommitAiPhase, CommitAiProvider } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -13,11 +13,13 @@
|
|||||||
commitAiProvider: CommitAiProvider;
|
commitAiProvider: CommitAiProvider;
|
||||||
commitAiPhase: CommitAiPhase;
|
commitAiPhase: CommitAiPhase;
|
||||||
commitAiGenerating: boolean;
|
commitAiGenerating: boolean;
|
||||||
|
commitAiReviewing: boolean;
|
||||||
canAmend: boolean;
|
canAmend: boolean;
|
||||||
amendMode: boolean;
|
amendMode: boolean;
|
||||||
onCommit: () => void;
|
onCommit: () => void;
|
||||||
onCommitMessageChange: (msg: string) => void;
|
onCommitMessageChange: (msg: string) => void;
|
||||||
onGenerateCommitMessage: () => void;
|
onGenerateCommitMessage: () => void;
|
||||||
|
onReviewStaged: () => void;
|
||||||
onOpenAiSettings: () => void;
|
onOpenAiSettings: () => void;
|
||||||
onToggleAmend: (checked: boolean) => void;
|
onToggleAmend: (checked: boolean) => void;
|
||||||
onUndoLastCommit: () => void;
|
onUndoLastCommit: () => void;
|
||||||
@@ -34,11 +36,13 @@
|
|||||||
commitAiProvider = "local",
|
commitAiProvider = "local",
|
||||||
commitAiPhase = "idle",
|
commitAiPhase = "idle",
|
||||||
commitAiGenerating = false,
|
commitAiGenerating = false,
|
||||||
|
commitAiReviewing = false,
|
||||||
canAmend = false,
|
canAmend = false,
|
||||||
amendMode = false,
|
amendMode = false,
|
||||||
onCommit = () => {},
|
onCommit = () => {},
|
||||||
onCommitMessageChange = () => {},
|
onCommitMessageChange = () => {},
|
||||||
onGenerateCommitMessage = () => {},
|
onGenerateCommitMessage = () => {},
|
||||||
|
onReviewStaged = () => {},
|
||||||
onOpenAiSettings = () => {},
|
onOpenAiSettings = () => {},
|
||||||
onToggleAmend = () => {},
|
onToggleAmend = () => {},
|
||||||
onUndoLastCommit = () => {},
|
onUndoLastCommit = () => {},
|
||||||
@@ -61,25 +65,52 @@
|
|||||||
hasRepository &&
|
hasRepository &&
|
||||||
!isBusy &&
|
!isBusy &&
|
||||||
!commitAiGenerating &&
|
!commitAiGenerating &&
|
||||||
|
!commitAiReviewing &&
|
||||||
stagedCount > 0 &&
|
stagedCount > 0 &&
|
||||||
(commitAiProvider !== "local" || commitAiPhase === "ready"),
|
(commitAiProvider !== "local" || commitAiPhase === "ready"),
|
||||||
);
|
);
|
||||||
|
let canReview = $derived(canGenerate && commitAiProvider !== "local");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel commit-panel" aria-label="Commit">
|
<section class="panel commit-panel" aria-label="Commit">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Commit</span>
|
<span class="eyebrow">Create revision</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Message</h2>
|
<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>
|
</div>
|
||||||
<span class="pill pill-count">{stagedCount} staged</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="commit-form" onsubmit={handleSubmit}>
|
<form class="commit-form" onsubmit={handleSubmit}>
|
||||||
<textarea
|
<textarea
|
||||||
value={commitMessage}
|
value={commitMessage}
|
||||||
oninput={(e) => onCommitMessageChange((e.target as HTMLTextAreaElement).value)}
|
oninput={(e) => onCommitMessageChange((e.target as HTMLTextAreaElement).value)}
|
||||||
placeholder="Commit message..."
|
placeholder="Commit message"
|
||||||
disabled={!hasRepository || isBusy}
|
disabled={!hasRepository || isBusy}
|
||||||
></textarea>
|
></textarea>
|
||||||
{#if commitBlockReason}
|
{#if commitBlockReason}
|
||||||
@@ -117,30 +148,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{amendMode ? "Amend" : "Commit"}
|
{amendMode ? "Amend" : "Commit"}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -10,6 +10,12 @@
|
|||||||
rightNum?: number; rightText?: string; rightKind: "add" | "context" | "empty";
|
rightNum?: number; rightText?: string; rightKind: "add" | "context" | "empty";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface DiffMarker {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
kind: "add" | "delete" | "mixed";
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
comparison: GitCommitComparison;
|
comparison: GitCommitComparison;
|
||||||
selectedDiffPath: string;
|
selectedDiffPath: string;
|
||||||
@@ -173,10 +179,40 @@
|
|||||||
return rows;
|
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 diffByPath = $derived(buildDiffByPath(comparison.patch));
|
||||||
let selectedFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null);
|
let selectedFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null);
|
||||||
let selectedPatch = $derived(selectedFile ? (diffByPath.get(selectedFile.path) ?? "") : "");
|
let selectedPatch = $derived(selectedFile ? (diffByPath.get(selectedFile.path) ?? "") : "");
|
||||||
let splitRows = $derived(buildSplitRows(selectedPatch));
|
let splitRows = $derived(buildSplitRows(selectedPatch));
|
||||||
|
let diffMarkers = $derived(buildDiffMarkers(splitRows));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -262,6 +298,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Split diff grid -->
|
<!-- Split diff grid -->
|
||||||
|
<div class="split-diff-shell">
|
||||||
<div class="split-diff" role="table" aria-label="Side-by-side diff">
|
<div class="split-diff" role="table" aria-label="Side-by-side diff">
|
||||||
<div
|
<div
|
||||||
class="split-pane"
|
class="split-pane"
|
||||||
@@ -298,6 +335,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
</div>
|
</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>
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
||||||
|
let patchScroll = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
||||||
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||||
@@ -118,6 +119,23 @@
|
|||||||
if (isBusy || isLoading) return;
|
if (isBusy || isLoading) return;
|
||||||
await onApply(action, buildHunkPatch(hunk));
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="dialog-backdrop" role="presentation">
|
<div class="dialog-backdrop" role="presentation">
|
||||||
@@ -148,9 +166,10 @@
|
|||||||
{:else if parsed.binary || parsed.hunks.length === 0}
|
{:else if parsed.binary || parsed.hunks.length === 0}
|
||||||
<div class="blank-state">This change cannot be split into text lines.</div>
|
<div class="blank-state">This change cannot be split into text lines.</div>
|
||||||
{:else}
|
{: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)}
|
{#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">
|
<div class="line-patch-hunk-head">
|
||||||
<code>{hunk.header}</code>
|
<code>{hunk.header}</code>
|
||||||
<div class="line-patch-hunk-actions">
|
<div class="line-patch-hunk-actions">
|
||||||
@@ -183,6 +202,19 @@
|
|||||||
</section>
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -144,7 +144,6 @@
|
|||||||
|
|
||||||
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
||||||
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
||||||
let selectedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f))).length);
|
|
||||||
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
|
let 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);
|
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
|
||||||
|
|
||||||
@@ -156,85 +155,22 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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 class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<span class="eyebrow">Working tree</span>
|
<span class="eyebrow">Workspace</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Status</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
|
||||||
</div>
|
</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">{stagedCount} staged</span>
|
||||||
<span class="pill pill-count">{unstagedCount} unstaged</span>
|
<span class="pill pill-count">{unstagedCount} unstaged</span>
|
||||||
</div>
|
{#if hasRepository && changedFiles.length > 0}
|
||||||
</div>
|
<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
|
||||||
{#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
|
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
|
|
||||||
{#if !hasRepository}
|
{#if !hasRepository}
|
||||||
<div class="blank-state">No repository loaded.</div>
|
<div class="blank-state">No repository loaded.</div>
|
||||||
@@ -243,79 +179,86 @@
|
|||||||
{:else if changedFiles.length === 0}
|
{:else if changedFiles.length === 0}
|
||||||
<div class="blank-state">No file changes returned.</div>
|
<div class="blank-state">No file changes returned.</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-auto p-2">
|
<div class="status-lanes overflow-auto">
|
||||||
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
<section class="status-lane" aria-label="Unstaged changes">
|
||||||
<article
|
<header class="status-lane-head">
|
||||||
class="file-row"
|
<div class="status-lane-title"><strong>Unstaged</strong><span>{unstagedCount}</span></div>
|
||||||
class:selected={isStatusSelected(file)}
|
<div class="status-lane-actions">
|
||||||
class:active={selectedFilePath === file.path}
|
{#if selectedUnstagedCount > 1}
|
||||||
>
|
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
|
||||||
<div class="file-title">
|
<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`}>
|
||||||
<button
|
<Check size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
|
||||||
class="file-title-button"
|
</button>
|
||||||
type="button"
|
{/if}
|
||||||
onclick={(event) => handleFileSelect(event, file)}
|
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
|
||||||
title={`Select ${displayPath(file)} in Explorer`}
|
<Check size={13} aria-hidden="true" /> Stage all
|
||||||
>
|
|
||||||
<strong>{fileName(file)}</strong>
|
|
||||||
</button>
|
</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>
|
</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">
|
<section class="status-lane" aria-label="Staged changes">
|
||||||
<div class="change-lane" class:inactive={!file.staged}>
|
<header class="status-lane-head">
|
||||||
<div class="lane-header">
|
<div class="status-lane-title"><strong>Staged</strong><span>{stagedCount}</span></div>
|
||||||
<span class="lane-name">Staged</span>
|
<div class="status-lane-actions">
|
||||||
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
{#if selectedStagedCount > 1}
|
||||||
</div>
|
<span class="status-selection-count">{selectedStagedCount} selected</span>
|
||||||
<div class="lane-actions">
|
<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`}>
|
||||||
{#if file.staged}
|
<Undo2 size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
|
||||||
{@const unstageTargets = selectedUnstageTargets(file)}
|
</button>
|
||||||
<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"}>
|
{/if}
|
||||||
<Undo2 size={14} aria-hidden="true" />
|
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title="Unstage all staged files">
|
||||||
{unstageTargets.length > 1 ? `Unstage ${unstageTargets.length}` : "Unstage"}
|
<Undo2 size={13} aria-hidden="true" /> Unstage all
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
|
{#if selectedStagedCount > 1}
|
||||||
<FileDiff size={14} aria-hidden="true" />
|
<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`}>
|
||||||
Details
|
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedStagedCount}
|
||||||
</button>
|
</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"}>
|
{/if}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</header>
|
||||||
{/each}
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
AiReviewResult,
|
||||||
CommitAiLocalProfile,
|
CommitAiLocalProfile,
|
||||||
CommitAiProvider,
|
CommitAiProvider,
|
||||||
CommitAiStatus,
|
CommitAiStatus,
|
||||||
@@ -10,6 +11,9 @@ import type {
|
|||||||
GitCommit,
|
GitCommit,
|
||||||
GitCommitComparison,
|
GitCommitComparison,
|
||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
|
RebaseCommit,
|
||||||
|
RebasePlanItem,
|
||||||
|
ReflogEntry,
|
||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStash,
|
GitStash,
|
||||||
GitStatus,
|
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> {
|
export function pull(path: string, username?: string, password?: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
|
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 });
|
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[]> {
|
export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]> {
|
||||||
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
|
return invoke<GitRepositoryFile[]>("list_repository_files", { path });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
|||||||
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
|
||||||
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
|
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
|
||||||
export type AppTheme = "system" | "light" | "dark";
|
export type AppTheme = "system" | "light" | "dark";
|
||||||
|
export type AppLanguage = "en" | "de";
|
||||||
|
|
||||||
export interface CommitAiStatus {
|
export interface CommitAiStatus {
|
||||||
phase: CommitAiPhase;
|
phase: CommitAiPhase;
|
||||||
@@ -18,6 +19,24 @@ export interface CommitAiStatus {
|
|||||||
error: string | null;
|
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 {
|
export interface LocalModelOption {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -198,6 +217,31 @@ export interface GitBlameResult {
|
|||||||
lines: GitBlameLine[];
|
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 {
|
export interface StoredCredential {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user