try to use local ai to generate commit message
This commit is contained in:
+54
-1
@@ -25,6 +25,8 @@
|
||||
import {
|
||||
checkoutBranch,
|
||||
commit,
|
||||
commitAiGenerate,
|
||||
commitAiStatus,
|
||||
compareCommits,
|
||||
cancelCodeSearch,
|
||||
cancelFileHistory,
|
||||
@@ -62,6 +64,7 @@
|
||||
} from "./lib/git";
|
||||
|
||||
import type {
|
||||
CommitAiPhase,
|
||||
ConflictFile,
|
||||
ExplorerNode,
|
||||
ExplorerNodeKind,
|
||||
@@ -122,7 +125,11 @@
|
||||
let fileHistoryLoading = false;
|
||||
let fileHistoryRequestId = 0;
|
||||
let activeFileHistoryRequestId = "";
|
||||
let lastFileHistoryHeadHash = "";
|
||||
let commitMessage = "";
|
||||
let commitAiPhase: CommitAiPhase = "idle";
|
||||
let commitAiGenerating = false;
|
||||
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let errorMessage = "";
|
||||
let operation = "";
|
||||
let compareFrom = "";
|
||||
@@ -206,10 +213,12 @@
|
||||
loadRepoLists();
|
||||
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
|
||||
void checkForUpdates();
|
||||
startCommitAiPolling();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
||||
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
|
||||
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
|
||||
});
|
||||
|
||||
@@ -229,15 +238,54 @@
|
||||
applyStatus(nextStatus);
|
||||
// Something changed — reload branches, commits and files in one bundled call.
|
||||
const bundle = await openRepositoryBundle(activeRepoPath, 100);
|
||||
const previousHeadHash = lastFileHistoryHeadHash;
|
||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||
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 {
|
||||
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() {
|
||||
autoRefreshEnabled = !autoRefreshEnabled;
|
||||
if (autoRefreshEnabled) void autoRefreshTick();
|
||||
@@ -430,6 +478,7 @@
|
||||
}
|
||||
branches = [];
|
||||
commits = [];
|
||||
lastFileHistoryHeadHash = "";
|
||||
repoFiles = [];
|
||||
selectedExplorerPath = "";
|
||||
selectedExplorerKind = "file";
|
||||
@@ -520,6 +569,7 @@
|
||||
|
||||
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
|
||||
commits = prefetched ?? (await listCommits(path, 100));
|
||||
lastFileHistoryHeadHash = commits[0]?.hash ?? "";
|
||||
const hashes = new Set(commits.map((c) => c.hash));
|
||||
if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
|
||||
if (compareTo && !hashes.has(compareTo)) compareTo = "";
|
||||
@@ -1692,8 +1742,11 @@
|
||||
{isBusy}
|
||||
{operation}
|
||||
{stagedCount}
|
||||
{commitAiPhase}
|
||||
{commitAiGenerating}
|
||||
onCommit={commitChanges}
|
||||
onCommitMessageChange={(msg) => { commitMessage = msg; }}
|
||||
onGenerateCommitMessage={generateCommitMessageWithAi}
|
||||
/>
|
||||
</div>
|
||||
</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 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 {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Check, LoaderCircle } from "@lucide/svelte";
|
||||
import { Check, LoaderCircle, Sparkles } from "@lucide/svelte";
|
||||
import type { CommitAiPhase } from "../types";
|
||||
|
||||
interface Props {
|
||||
commitMessage: string;
|
||||
@@ -9,8 +10,11 @@
|
||||
isBusy: boolean;
|
||||
operation: string;
|
||||
stagedCount: number;
|
||||
commitAiPhase: CommitAiPhase;
|
||||
commitAiGenerating: boolean;
|
||||
onCommit: () => void;
|
||||
onCommitMessageChange: (msg: string) => void;
|
||||
onGenerateCommitMessage: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -21,14 +25,28 @@
|
||||
isBusy = false,
|
||||
operation = "",
|
||||
stagedCount = 0,
|
||||
commitAiPhase = "idle",
|
||||
commitAiGenerating = false,
|
||||
onCommit = () => {},
|
||||
onCommitMessageChange = () => {},
|
||||
onGenerateCommitMessage = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
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>
|
||||
|
||||
<section class="panel flex flex-col" aria-label="Commit">
|
||||
@@ -50,13 +68,29 @@
|
||||
{#if commitBlockReason}
|
||||
<p class="commit-block-reason">{commitBlockReason}</p>
|
||||
{/if}
|
||||
<button class="btn-primary w-full flex-shrink-0" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
|
||||
{#if operation === "Committing"}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Commit
|
||||
</button>
|
||||
<div class="commit-actions-row flex-shrink-0">
|
||||
<button class="btn-primary flex-1" type="submit" disabled={!canCommit} title={commitBlockReason || "Commit staged changes"}>
|
||||
{#if operation === "Committing"}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
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>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
CommitAiStatus,
|
||||
ConflictFile,
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
@@ -94,6 +95,14 @@ export function commit(path: string, message: string): Promise<GitStatus> {
|
||||
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> {
|
||||
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@ export type FileStatusKind =
|
||||
| "conflicted"
|
||||
| "unknown";
|
||||
|
||||
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
export interface CommitAiStatus {
|
||||
phase: CommitAiPhase;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface GitStatus {
|
||||
repo_path: string;
|
||||
current_branch: string | null;
|
||||
|
||||
Reference in New Issue
Block a user