try to use local ai to generate commit message
This commit is contained in:
@@ -34,7 +34,21 @@
|
|||||||
"Read(//mnt/c/Users/cbr/Desktop/src-tauri/src/**)",
|
"Read(//mnt/c/Users/cbr/Desktop/src-tauri/src/**)",
|
||||||
"Bash(sudo apt install -y libdbus-1-dev pkg-config)",
|
"Bash(sudo apt install -y libdbus-1-dev pkg-config)",
|
||||||
"Bash(dpkg -l)",
|
"Bash(dpkg -l)",
|
||||||
"Bash(apt list *)"
|
"Bash(apt list *)",
|
||||||
|
"Bash(cargo search *)",
|
||||||
|
"Bash(curl -s \"https://crates.io/api/v1/crates/mistralrs\")",
|
||||||
|
"Bash(cargo info *)",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"Bash(gh api *)",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"WebFetch(domain:ericlbuehler.github.io)",
|
||||||
|
"WebFetch(domain:docs.rs)",
|
||||||
|
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF\")",
|
||||||
|
"Bash(python3 -c ' *)",
|
||||||
|
"Bash(curl -sI \"https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf\")",
|
||||||
|
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF?blobs=true\")",
|
||||||
|
"Bash(grep -n 'from \"\\\\./lib/git\"\\\\|from \"\\\\./lib/types\"\\\\|^ commit,$' src/App.svelte)",
|
||||||
|
"Bash(kill 24343 24363 24375 24376)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -5,4 +5,5 @@
|
|||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
~
|
~
|
||||||
.codex*
|
.codex*
|
||||||
|
target
|
||||||
|
|||||||
Generated
+3528
-41
File diff suppressed because it is too large
Load Diff
@@ -2,16 +2,23 @@
|
|||||||
name = "git_lite"
|
name = "git_lite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Rust backend for a lightweight Git desktop client"
|
description = "Rust backend for a lightweight Git desktop client"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
rust-version = "1.77"
|
rust-version = "1.88"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = [".", "crates/commit_ai"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-dialog = "=2.7.0"
|
tauri-plugin-dialog = "=2.7.0"
|
||||||
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
|
||||||
|
commit_ai = { path = "crates/commit_ai" }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
[package]
|
||||||
|
name = "commit_ai"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
mistralrs = "0.8"
|
||||||
|
tokio = { version = "1", features = ["sync"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use mistralrs::{GgufModelBuilder, Model, TextMessageRole, TextMessages};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
// Small instruct model, quantized to keep the one-time download reasonable (~1 GB) while
|
||||||
|
// still being fast enough for short, structured generations like a commit message on CPU.
|
||||||
|
const HF_REPO: &str = "Qwen/Qwen2.5-1.5B-Instruct-GGUF";
|
||||||
|
const GGUF_FILE: &str = "qwen2.5-1.5b-instruct-q4_k_m.gguf";
|
||||||
|
const TOKENIZER_MODEL_ID: &str = "Qwen/Qwen2.5-1.5B-Instruct";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum CommitAiPhase {
|
||||||
|
/// Nothing has been requested yet.
|
||||||
|
Idle,
|
||||||
|
/// Downloading (first run only, then cached by hf-hub) and/or loading into memory.
|
||||||
|
Loading,
|
||||||
|
Ready,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct CommitAiStatus {
|
||||||
|
pub phase: CommitAiPhase,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Inner {
|
||||||
|
phase: CommitAiPhase,
|
||||||
|
error: Option<String>,
|
||||||
|
model: Option<Arc<Model>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap to clone: shares one model instance across the app via an inner `Arc`.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CommitAiEngine {
|
||||||
|
inner: Arc<RwLock<Inner>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CommitAiEngine {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(RwLock::new(Inner {
|
||||||
|
phase: CommitAiPhase::Idle,
|
||||||
|
error: None,
|
||||||
|
model: None,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommitAiEngine {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(&self) -> CommitAiStatus {
|
||||||
|
let guard = self.inner.read().await;
|
||||||
|
CommitAiStatus {
|
||||||
|
phase: guard.phase,
|
||||||
|
error: guard.error.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads (first run only; hf-hub caches the files afterwards) and loads the model.
|
||||||
|
/// Safe to call multiple times — only the first caller actually triggers a load, later
|
||||||
|
/// callers just return once the in-flight or previous attempt is done.
|
||||||
|
pub async fn ensure_loaded(&self) {
|
||||||
|
{
|
||||||
|
let mut guard = self.inner.write().await;
|
||||||
|
if guard.phase != CommitAiPhase::Idle {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
guard.phase = CommitAiPhase::Loading;
|
||||||
|
guard.error = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = GgufModelBuilder::new(HF_REPO, vec![GGUF_FILE])
|
||||||
|
.with_tok_model_id(TOKENIZER_MODEL_ID)
|
||||||
|
.with_logging()
|
||||||
|
.build()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut guard = self.inner.write().await;
|
||||||
|
match result {
|
||||||
|
Ok(model) => {
|
||||||
|
guard.model = Some(Arc::new(model));
|
||||||
|
guard.phase = CommitAiPhase::Ready;
|
||||||
|
guard.error = None;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
guard.phase = CommitAiPhase::Error;
|
||||||
|
guard.error = Some(err.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate_commit_message(
|
||||||
|
&self,
|
||||||
|
diff: &str,
|
||||||
|
notes: Option<&str>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let model = {
|
||||||
|
let guard = self.inner.read().await;
|
||||||
|
match (guard.phase, &guard.model) {
|
||||||
|
(CommitAiPhase::Ready, Some(model)) => model.clone(),
|
||||||
|
_ => return Err("Das KI-Modell ist noch nicht bereit.".to_string()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if diff.trim().is_empty() {
|
||||||
|
return Err("Keine gestagten Änderungen für eine Commit-Message vorhanden.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (system, user) = build_messages(diff, notes);
|
||||||
|
let messages = TextMessages::new()
|
||||||
|
.add_message(TextMessageRole::System, system)
|
||||||
|
.add_message(TextMessageRole::User, user);
|
||||||
|
|
||||||
|
let response = model
|
||||||
|
.send_chat_request(messages)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let content = response
|
||||||
|
.choices
|
||||||
|
.first()
|
||||||
|
.and_then(|choice| choice.message.content.clone())
|
||||||
|
.ok_or_else(|| "Das Modell hat keine Antwort geliefert.".to_string())?;
|
||||||
|
|
||||||
|
Ok(content.trim().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_messages(diff: &str, notes: Option<&str>) -> (String, String) {
|
||||||
|
// grobe Token-Schätzung, kleine Modelle haben oft 8–32k Kontext
|
||||||
|
const MAX_CHARS: usize = 24_000;
|
||||||
|
let diff = if diff.len() > MAX_CHARS {
|
||||||
|
format!("{}\n\n[... Diff gekürzt ...]", &diff[..MAX_CHARS])
|
||||||
|
} else {
|
||||||
|
diff.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let system = "Du bist ein Werkzeug, das Git-Commit-Messages erzeugt. \
|
||||||
|
Antworte ausschließlich mit der Commit-Message im Conventional-Commits-Format \
|
||||||
|
(<type>(<scope>): <subject>), optional gefolgt von einem Body nach einer Leerzeile. \
|
||||||
|
Subject imperativ, max. 72 Zeichen. Kein Vorspann, keine Erklärung, keine Code-Fences, in Englisch antworten"
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let mut user = String::new();
|
||||||
|
if let Some(n) = notes.filter(|n| !n.trim().is_empty()) {
|
||||||
|
user.push_str(&format!("Anmerkungen des Entwicklers:\n{n}\n\n"));
|
||||||
|
}
|
||||||
|
user.push_str(&format!("Staged diff:\n{diff}"));
|
||||||
|
(system, user)
|
||||||
|
}
|
||||||
@@ -479,6 +479,37 @@ pub fn get_file_patch(path: String, file: String, staged: bool) -> Result<String
|
|||||||
Ok(String::from_utf8_lossy(&output).to_string())
|
Ok(String::from_utf8_lossy(&output).to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn commit_ai_status(
|
||||||
|
engine: tauri::State<'_, commit_ai::CommitAiEngine>,
|
||||||
|
) -> Result<commit_ai::CommitAiStatus, String> {
|
||||||
|
Ok(engine.status().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn commit_ai_generate(
|
||||||
|
path: String,
|
||||||
|
notes: Option<String>,
|
||||||
|
engine: tauri::State<'_, commit_ai::CommitAiEngine>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let diff = run_git(
|
||||||
|
&repo,
|
||||||
|
[
|
||||||
|
"diff",
|
||||||
|
"--cached",
|
||||||
|
"--no-ext-diff",
|
||||||
|
"--no-textconv",
|
||||||
|
"--unified=3",
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let diff = String::from_utf8_lossy(&diff).to_string();
|
||||||
|
|
||||||
|
engine
|
||||||
|
.generate_commit_message(&diff, notes.as_deref())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn apply_file_patch(
|
pub fn apply_file_patch(
|
||||||
path: String,
|
path: String,
|
||||||
@@ -1573,6 +1604,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);
|
||||||
|
|
||||||
|
|
||||||
Ok(GitStatus {
|
Ok(GitStatus {
|
||||||
repo_path: repo.to_string_lossy().to_string(),
|
repo_path: repo.to_string_lossy().to_string(),
|
||||||
current_branch: branch.current_branch,
|
current_branch: branch.current_branch,
|
||||||
|
|||||||
+21
-7
@@ -4,21 +4,33 @@ mod git;
|
|||||||
|
|
||||||
use git::{
|
use git::{
|
||||||
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, commit,
|
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, commit,
|
||||||
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, cred_delete,
|
commit_ai_generate, commit_ai_status, compare_commits, compare_file_to_head,
|
||||||
cred_load, cred_save, delete_branch, diff_file_against_working_tree, get_file_patch,
|
compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, delete_branch,
|
||||||
get_remote_url, get_status, list_branches, list_commits, list_file_history,
|
diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, list_branches,
|
||||||
list_repository_files, merge_branch, open_repo_in_explorer, open_repository,
|
list_commits, list_file_history, list_repository_files, merge_branch, open_repo_in_explorer,
|
||||||
open_repository_bundle, open_repository_file, pull, push, read_conflict, rename_branch,
|
open_repository, open_repository_bundle, open_repository_file, pull, push, read_conflict,
|
||||||
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files,
|
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
||||||
restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
||||||
SearchCancellationState,
|
SearchCancellationState,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
let commit_ai_engine = commit_ai::CommitAiEngine::new();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.manage(SearchCancellationState::default())
|
.manage(SearchCancellationState::default())
|
||||||
|
.manage(commit_ai_engine.clone())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.setup(move |_app| {
|
||||||
|
// Kick off the (first-run-only) download and model load in the background so the
|
||||||
|
// "Generate with AI" button becomes enabled once it's ready, without blocking startup.
|
||||||
|
let engine = commit_ai_engine.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
engine.ensure_loaded().await;
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
open_repository,
|
open_repository,
|
||||||
open_repo_in_explorer,
|
open_repo_in_explorer,
|
||||||
@@ -35,6 +47,8 @@ fn main() {
|
|||||||
get_file_patch,
|
get_file_patch,
|
||||||
apply_file_patch,
|
apply_file_patch,
|
||||||
commit,
|
commit,
|
||||||
|
commit_ai_status,
|
||||||
|
commit_ai_generate,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
list_commits,
|
list_commits,
|
||||||
|
|||||||
+54
-1
@@ -25,6 +25,8 @@
|
|||||||
import {
|
import {
|
||||||
checkoutBranch,
|
checkoutBranch,
|
||||||
commit,
|
commit,
|
||||||
|
commitAiGenerate,
|
||||||
|
commitAiStatus,
|
||||||
compareCommits,
|
compareCommits,
|
||||||
cancelCodeSearch,
|
cancelCodeSearch,
|
||||||
cancelFileHistory,
|
cancelFileHistory,
|
||||||
@@ -62,6 +64,7 @@
|
|||||||
} from "./lib/git";
|
} from "./lib/git";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
CommitAiPhase,
|
||||||
ConflictFile,
|
ConflictFile,
|
||||||
ExplorerNode,
|
ExplorerNode,
|
||||||
ExplorerNodeKind,
|
ExplorerNodeKind,
|
||||||
@@ -122,7 +125,11 @@
|
|||||||
let fileHistoryLoading = false;
|
let fileHistoryLoading = false;
|
||||||
let fileHistoryRequestId = 0;
|
let fileHistoryRequestId = 0;
|
||||||
let activeFileHistoryRequestId = "";
|
let activeFileHistoryRequestId = "";
|
||||||
|
let lastFileHistoryHeadHash = "";
|
||||||
let commitMessage = "";
|
let commitMessage = "";
|
||||||
|
let commitAiPhase: CommitAiPhase = "idle";
|
||||||
|
let commitAiGenerating = false;
|
||||||
|
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
let errorMessage = "";
|
let errorMessage = "";
|
||||||
let operation = "";
|
let operation = "";
|
||||||
let compareFrom = "";
|
let compareFrom = "";
|
||||||
@@ -206,10 +213,12 @@
|
|||||||
loadRepoLists();
|
loadRepoLists();
|
||||||
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
|
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
|
||||||
void checkForUpdates();
|
void checkForUpdates();
|
||||||
|
startCommitAiPolling();
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
||||||
|
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
|
||||||
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
|
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,15 +238,54 @@
|
|||||||
applyStatus(nextStatus);
|
applyStatus(nextStatus);
|
||||||
// Something changed — reload branches, commits and files in one bundled call.
|
// Something changed — reload branches, commits and files in one bundled call.
|
||||||
const bundle = await openRepositoryBundle(activeRepoPath, 100);
|
const bundle = await openRepositoryBundle(activeRepoPath, 100);
|
||||||
|
const previousHeadHash = lastFileHistoryHeadHash;
|
||||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||||
await refreshFileHistory(activeRepoPath);
|
// File history reflects `git log`, which only changes when HEAD actually moves
|
||||||
|
// (new commit, checkout, merge, ...) — skip the reload otherwise so a plain
|
||||||
|
// working-tree/status change (staging, edits) doesn't keep re-fetching and
|
||||||
|
// flickering the currently viewed file's history.
|
||||||
|
if (lastFileHistoryHeadHash !== previousHeadHash) {
|
||||||
|
await refreshFileHistory(activeRepoPath);
|
||||||
|
}
|
||||||
} catch { /* ignore transient errors */ } finally {
|
} catch { /* ignore transient errors */ } finally {
|
||||||
autoRefreshInFlight = false;
|
autoRefreshInFlight = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Commit AI ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function pollCommitAiStatus() {
|
||||||
|
try {
|
||||||
|
const result = await commitAiStatus();
|
||||||
|
commitAiPhase = result.phase;
|
||||||
|
} catch { /* ignore transient errors */ }
|
||||||
|
if (commitAiPhase === "ready" || commitAiPhase === "error") {
|
||||||
|
if (commitAiPollTimer) { clearInterval(commitAiPollTimer); commitAiPollTimer = undefined; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCommitAiPolling() {
|
||||||
|
// The model downloads (first run only) and loads in the background on app start;
|
||||||
|
// poll until it's ready (or failed) so the "AI" button can enable itself.
|
||||||
|
void pollCommitAiStatus();
|
||||||
|
commitAiPollTimer = setInterval(() => { void pollCommitAiStatus(); }, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateCommitMessageWithAi() {
|
||||||
|
if (!activeRepoPath || commitAiPhase !== "ready" || commitAiGenerating) return;
|
||||||
|
commitAiGenerating = true;
|
||||||
|
errorMessage = "";
|
||||||
|
try {
|
||||||
|
commitMessage = await commitAiGenerate(activeRepoPath, commitMessage.trim() || undefined);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = errorToMessage(error);
|
||||||
|
} finally {
|
||||||
|
commitAiGenerating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleAutoRefresh() {
|
function toggleAutoRefresh() {
|
||||||
autoRefreshEnabled = !autoRefreshEnabled;
|
autoRefreshEnabled = !autoRefreshEnabled;
|
||||||
if (autoRefreshEnabled) void autoRefreshTick();
|
if (autoRefreshEnabled) void autoRefreshTick();
|
||||||
@@ -430,6 +478,7 @@
|
|||||||
}
|
}
|
||||||
branches = [];
|
branches = [];
|
||||||
commits = [];
|
commits = [];
|
||||||
|
lastFileHistoryHeadHash = "";
|
||||||
repoFiles = [];
|
repoFiles = [];
|
||||||
selectedExplorerPath = "";
|
selectedExplorerPath = "";
|
||||||
selectedExplorerKind = "file";
|
selectedExplorerKind = "file";
|
||||||
@@ -520,6 +569,7 @@
|
|||||||
|
|
||||||
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
|
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
|
||||||
commits = prefetched ?? (await listCommits(path, 100));
|
commits = prefetched ?? (await listCommits(path, 100));
|
||||||
|
lastFileHistoryHeadHash = commits[0]?.hash ?? "";
|
||||||
const hashes = new Set(commits.map((c) => c.hash));
|
const hashes = new Set(commits.map((c) => c.hash));
|
||||||
if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
|
if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
|
||||||
if (compareTo && !hashes.has(compareTo)) compareTo = "";
|
if (compareTo && !hashes.has(compareTo)) compareTo = "";
|
||||||
@@ -1692,8 +1742,11 @@
|
|||||||
{isBusy}
|
{isBusy}
|
||||||
{operation}
|
{operation}
|
||||||
{stagedCount}
|
{stagedCount}
|
||||||
|
{commitAiPhase}
|
||||||
|
{commitAiGenerating}
|
||||||
onCommit={commitChanges}
|
onCommit={commitChanges}
|
||||||
onCommitMessageChange={(msg) => { commitMessage = msg; }}
|
onCommitMessageChange={(msg) => { commitMessage = msg; }}
|
||||||
|
onGenerateCommitMessage={generateCommitMessageWithAi}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1332,6 +1332,8 @@
|
|||||||
|
|
||||||
.commit-form { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; gap: 8px; padding: 10px; }
|
.commit-form { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; gap: 8px; padding: 10px; }
|
||||||
.commit-form textarea { flex: 1 1 0; min-height: 80px; resize: none; }
|
.commit-form textarea { flex: 1 1 0; min-height: 80px; resize: none; }
|
||||||
|
.commit-actions-row { display: flex; gap: 8px; }
|
||||||
|
.commit-ai-button { flex: 0 0 auto; min-width: 64px; justify-content: center; }
|
||||||
.commit-block-reason {
|
.commit-block-reason {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, LoaderCircle } from "@lucide/svelte";
|
import { Check, LoaderCircle, Sparkles } from "@lucide/svelte";
|
||||||
|
import type { CommitAiPhase } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
commitMessage: string;
|
commitMessage: string;
|
||||||
@@ -9,8 +10,11 @@
|
|||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
operation: string;
|
operation: string;
|
||||||
stagedCount: number;
|
stagedCount: number;
|
||||||
|
commitAiPhase: CommitAiPhase;
|
||||||
|
commitAiGenerating: boolean;
|
||||||
onCommit: () => void;
|
onCommit: () => void;
|
||||||
onCommitMessageChange: (msg: string) => void;
|
onCommitMessageChange: (msg: string) => void;
|
||||||
|
onGenerateCommitMessage: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -21,14 +25,28 @@
|
|||||||
isBusy = false,
|
isBusy = false,
|
||||||
operation = "",
|
operation = "",
|
||||||
stagedCount = 0,
|
stagedCount = 0,
|
||||||
|
commitAiPhase = "idle",
|
||||||
|
commitAiGenerating = false,
|
||||||
onCommit = () => {},
|
onCommit = () => {},
|
||||||
onCommitMessageChange = () => {},
|
onCommitMessageChange = () => {},
|
||||||
|
onGenerateCommitMessage = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function handleSubmit(event: SubmitEvent) {
|
function handleSubmit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onCommit();
|
onCommit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function aiButtonTitle(phase: CommitAiPhase, staged: number): string {
|
||||||
|
if (phase === "loading") return "AI model is downloading/loading — this happens once";
|
||||||
|
if (phase === "error") return "AI model failed to load";
|
||||||
|
if (staged === 0) return "Stage changes first";
|
||||||
|
return "Generate commit message with AI from the staged diff";
|
||||||
|
}
|
||||||
|
|
||||||
|
let canGenerate = $derived(
|
||||||
|
hasRepository && !isBusy && !commitAiGenerating && commitAiPhase === "ready" && stagedCount > 0,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel flex flex-col" aria-label="Commit">
|
<section class="panel flex flex-col" aria-label="Commit">
|
||||||
@@ -50,13 +68,29 @@
|
|||||||
{#if commitBlockReason}
|
{#if commitBlockReason}
|
||||||
<p class="commit-block-reason">{commitBlockReason}</p>
|
<p class="commit-block-reason">{commitBlockReason}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="btn-primary w-full flex-shrink-0" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
|
<div class="commit-actions-row flex-shrink-0">
|
||||||
{#if operation === "Committing"}
|
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
|
||||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
{#if operation === "Committing"}
|
||||||
{:else}
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
<Check size={16} aria-hidden="true" />
|
{:else}
|
||||||
{/if}
|
<Check size={16} aria-hidden="true" />
|
||||||
Commit
|
{/if}
|
||||||
</button>
|
Commit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-secondary commit-ai-button"
|
||||||
|
type="button"
|
||||||
|
onclick={onGenerateCommitMessage}
|
||||||
|
disabled={!canGenerate}
|
||||||
|
title={aiButtonTitle(commitAiPhase, stagedCount)}
|
||||||
|
>
|
||||||
|
{#if commitAiGenerating || commitAiPhase === "loading"}
|
||||||
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<Sparkles size={16} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
AI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
CommitAiStatus,
|
||||||
ConflictFile,
|
ConflictFile,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
@@ -94,6 +95,14 @@ export function commit(path: string, message: string): Promise<GitStatus> {
|
|||||||
return invoke<GitStatus>("commit", { path, message });
|
return invoke<GitStatus>("commit", { path, message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function commitAiStatus(): Promise<CommitAiStatus> {
|
||||||
|
return invoke<CommitAiStatus>("commit_ai_status");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commitAiGenerate(path: string, notes?: string): Promise<string> {
|
||||||
|
return invoke<string>("commit_ai_generate", { path, notes });
|
||||||
|
}
|
||||||
|
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ export type FileStatusKind =
|
|||||||
| "conflicted"
|
| "conflicted"
|
||||||
| "unknown";
|
| "unknown";
|
||||||
|
|
||||||
|
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
|
export interface CommitAiStatus {
|
||||||
|
phase: CommitAiPhase;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitStatus {
|
export interface GitStatus {
|
||||||
repo_path: string;
|
repo_path: string;
|
||||||
current_branch: string | null;
|
current_branch: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user