{onAction && (
)}
{onDelete && (
diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx
index fed6386..d03034b 100644
--- a/src/components/layout/TopBar.tsx
+++ b/src/components/layout/TopBar.tsx
@@ -1,101 +1,180 @@
-import { open } from '@tauri-apps/plugin-dialog';
-import { FolderOpen, RefreshCw, Download, Upload, ArrowDownToLine, Settings, GitBranch, Loader2 } from 'lucide-react';
+import { type ReactNode } from 'react';
+import { Archive, ArchiveRestore, ArrowDownToLine, GitBranch, GitMerge, Loader2, Redo2, RefreshCw, Search, Settings, Terminal, Undo2, Upload } from 'lucide-react';
import { useGitStore } from '../../store/gitStore';
import { gitService } from '../../services/gitService';
+import { GitPilotIcon } from '../common/GitPilotIcon';
+import { RepoSwitcher } from './RepoSwitcher';
+import { BranchSwitcher } from './BranchSwitcher';
+import { invoke } from '@tauri-apps/api/core';
+import { gpPrompt, gpConfirm, gpAlert } from '../common/Dialog';
export function TopBar() {
- const { repo, status, busy, openRepo, refresh, run } = useGitStore();
- const pick = async () => {
- const p = await open({ directory: true, multiple: false });
- if (p && !Array.isArray(p)) void openRepo(p);
- };
- const branch = status.currentBranch || repo?.currentBranch || 'no branch';
+ const repo = useGitStore(s => s.repo);
+ const busy = useGitStore(s => s.busy);
+ const refreshing = useGitStore(s => s.refreshing);
+ const runningOp = useGitStore(s => s.runningOp);
+ const undoStack = useGitStore(s => s.undoStack);
+ const redoStack = useGitStore(s => s.redoStack);
+ const performUndo = useGitStore(s => s.performUndo);
+ const performRedo = useGitStore(s => s.performRedo);
+ const op = (label: string) => runningOp === label;
+ const run = useGitStore(s => s.run);
+ const branches = useGitStore(s => s.branches);
+ const stashes = useGitStore(s => s.stashes);
+ const status = useGitStore(s => s.status);
const ahead = status.ahead ?? 0;
const behind = status.behind ?? 0;
+ const mergeBranch = async () => {
+ if (!repo) return;
+ const candidate = branches.find(b => !b.current)?.name ?? '';
+ const branch = status.currentBranch || repo.currentBranch || '';
+ const branchName = await gpPrompt('Merge branch into current branch', candidate);
+ if (!branchName) return;
+ if (await gpConfirm(`Merge ${branchName} into ${branch}?`)) {
+ void run('merge branch', () => gitService.mergeBranch(repo.path, branchName));
+ }
+ };
+
+ const createBranch = async () => {
+ if (!repo) return;
+ const name = await gpPrompt('New branch name');
+ if (!name) return;
+ void run('create branch', () => gitService.createBranch(repo.path, name, true), 'full', {
+ undo: () => gitService.deleteBranch(repo.path, name, false),
+ redo: () => gitService.createBranch(repo.path, name, true),
+ });
+ };
+
+ const createStash = async () => {
+ if (!repo) return;
+ const msg = (await gpPrompt('Stash message (optional)', '')) ?? '';
+ const stashMsg = msg || 'GitPilot stash';
+ void run('stash', () => gitService.createStash(repo.path, stashMsg), 'full', {
+ undo: () => gitService.popStash(repo.path, 'stash@{0}'),
+ redo: () => gitService.createStash(repo.path, stashMsg),
+ });
+ };
+
+ const popStash = async () => {
+ if (!repo) return;
+ const top = stashes[0];
+ if (!top) { await gpAlert('No stashes to pop.'); return; }
+ void run('pop stash', () => gitService.popStash(repo.path, top.name));
+ };
+
+ const openTerminal = () => {
+ if (repo) void invoke('open_in_terminal', { path: repo.path });
+ };
+
+ const p = repo?.path;
+
return (
-
- {/* Logo / app name */}
-
-
-
GitPilot
+
+ {/* Logo */}
+
+
+
+ git
+ PILOT
+
- {/* Open repo */}
-
+ {/* Repo + Branch — shrink-0, natural width */}
+
+ {repo && }
- {/* Repo info */}
+ {/* Divider */}
+
+
+ {/* Action buttons */}
{repo && (
-
-
{repo.name}
-
{repo.path}
+
+ {/* Undo / Redo / Refresh */}
+ } label="Undo" loading={op(`undo: ${undoStack[0]?.label ?? ''}`)} disabled={busy || undoStack.length === 0} title={undoStack[0] ? `Undo ${undoStack[0].label}` : 'Nothing to undo'} onClick={() => void performUndo()} />
+ } label="Redo" loading={op(`redo: ${redoStack[0]?.label ?? ''}`)} disabled={busy || redoStack.length === 0} title={redoStack[0] ? `Redo ${redoStack[0].label}` : 'Nothing to redo'} onClick={() => void performRedo()} />
+ } label="Refresh" loading={refreshing} disabled={busy} title="Refresh" onClick={() => void useGitStore.getState().refresh()} />
+
+
+
+ {/* Pull / Push */}
+ } label="Pull" badge={behind || undefined} loading={op('pull')} disabled={busy} title={`Pull${behind ? ` (${behind} behind)` : ''}`} onClick={() => p && run('pull', () => gitService.pull(p))} />
+ } label="Push" badge={ahead || undefined} loading={op('push')} disabled={busy} title={`Push${ahead ? ` (${ahead} ahead)` : ''}`} accent onClick={() => p && run('push', () => gitService.push(p))} />
+
+
+
+ {/* Branch / Merge / Stash / Pop */}
+ } label="Branch" loading={op('create branch')} disabled={busy} title="Create new branch" onClick={createBranch} />
+ } label="Merge" loading={op('merge branch')} disabled={busy} title="Merge branch into current" onClick={mergeBranch} />
+
+
+
+ } label="Stash" loading={op('stash')} disabled={busy} title="Stash working changes" onClick={createStash} />
+ } label="Pop" loading={op('pop stash')} disabled={busy || stashes.length === 0} title="Pop top stash" onClick={popStash} />
+
+
+
+ {/* Terminal */}
+ } label="Terminal" disabled={!repo} title="Open in terminal" onClick={openTerminal} />
)}
- {/* Branch badge */}
-
-
-
{branch}
- {behind > 0 &&
↓{behind}}
- {ahead > 0 &&
↑{ahead}}
+ {/* Right: Search + Settings */}
+
+ {repo && (
+ }
+ label="Search"
+ title="Smart search (commits, files, authors)"
+ onClick={async () => {
+ const q = await gpPrompt('Search commits, files, authors…');
+ if (q && repo) void gitService.smartSearch(repo.path, q).then(r => useGitStore.getState().log(`Search "${q}": ${r.length} result(s)`));
+ }}
+ />
+ )}
+ } label="Settings" title="Settings" onClick={() => useGitStore.setState({ settingsOpen: true, settingsTab: 'general' })} />
+
+ );
+}
- {/* Action buttons */}
-
-
-
-
-
-
-
-
-
-
-
+// ── Toolbar button ────────────────────────────────────────────────────────────
+
+function Btn({ icon, label, title, onClick, disabled, accent, badge, loading }: {
+ icon: ReactNode;
+ label: string;
+ title?: string;
+ onClick?: () => void;
+ disabled?: boolean;
+ accent?: boolean;
+ badge?: number;
+ loading?: boolean;
+}) {
+ return (
+
);
}
+
+function Sep() {
+ return
;
+}
diff --git a/src/components/merge-conflict/ConflictFileList.tsx b/src/components/merge-conflict/ConflictFileList.tsx
new file mode 100644
index 0000000..078370a
--- /dev/null
+++ b/src/components/merge-conflict/ConflictFileList.tsx
@@ -0,0 +1,45 @@
+import { GitMerge, CheckCircle, AlertCircle } from 'lucide-react';
+import type { GitFileStatus } from '../../types/git';
+
+interface Props {
+ files: GitFileStatus[];
+ resolvedPaths: Set
;
+ selectedPath: string | null;
+ onSelect: (path: string) => void;
+}
+
+export function ConflictFileList({ files, resolvedPaths, selectedPath, onSelect }: Props) {
+ if (files.length === 0) {
+ return (
+
+
+
All conflicts resolved
+
+ );
+ }
+ return (
+
+ {files.map(f => {
+ const resolved = resolvedPaths.has(f.path);
+ const active = selectedPath === f.path;
+ const name = f.path.split('/').pop() ?? f.path;
+ const dir = f.path.includes('/') ? f.path.slice(0, f.path.lastIndexOf('/')) : '';
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/merge-conflict/MergeConflictPanel.tsx b/src/components/merge-conflict/MergeConflictPanel.tsx
new file mode 100644
index 0000000..b32dc23
--- /dev/null
+++ b/src/components/merge-conflict/MergeConflictPanel.tsx
@@ -0,0 +1,154 @@
+import { useState, useCallback } from 'react';
+import { GitMerge, GitBranch, AlertTriangle, CheckCircle } from 'lucide-react';
+import { useGitStore } from '../../store/gitStore';
+import { gitService } from '../../services/gitService';
+import type { ConflictFileData } from '../../types/git';
+import { ConflictFileList } from './ConflictFileList';
+import { MergeEditor } from './MergeEditor';
+
+export function MergeConflictPanel() {
+ const { repo, status, run } = useGitStore();
+ const [selectedPath, setSelectedPath] = useState(null);
+ const [fileData, setFileData] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [resolvedPaths, setResolvedPaths] = useState>(new Set());
+
+ const isMerging = status.mergeState.isMerging;
+ const isRebasing = status.mergeState.isRebasing;
+ const conflicted = status.conflicted;
+ const allResolved = conflicted.length === 0 && (isMerging || isRebasing);
+
+ const selectFile = useCallback(async (path: string) => {
+ if (!repo) return;
+ setSelectedPath(path);
+ setFileData(null);
+ setLoading(true);
+ try {
+ const data = await gitService.getConflictFile(repo.path, path);
+ setFileData(data);
+ } catch (e) {
+ useGitStore.getState().log(String(e));
+ } finally {
+ setLoading(false);
+ }
+ }, [repo]);
+
+ const handleSave = useCallback(async (content: string) => {
+ if (!repo || !selectedPath) return;
+ try {
+ await gitService.saveResolvedFile(repo.path, selectedPath, content);
+ setResolvedPaths(prev => new Set([...prev, selectedPath]));
+ await useGitStore.getState().refreshStatus();
+ // Move to next unresolved file
+ const remaining = useGitStore.getState().status.conflicted;
+ const next = remaining.find(f => f.path !== selectedPath && !resolvedPaths.has(f.path));
+ if (next) {
+ void selectFile(next.path);
+ } else {
+ setSelectedPath(null);
+ setFileData(null);
+ }
+ } catch (e) {
+ useGitStore.getState().log(String(e));
+ }
+ }, [repo, selectedPath, resolvedPaths, selectFile]);
+
+ const handleContinue = () => {
+ if (!repo) return;
+ if (isRebasing) {
+ void run('rebase continue', () => gitService.continueRebase(repo.path));
+ } else {
+ void run('merge continue', () => gitService.continueMerge(repo.path));
+ }
+ };
+
+ const handleAbort = () => {
+ if (!repo) return;
+ if (isRebasing) {
+ void run('rebase abort', () => gitService.abortRebase(repo.path));
+ } else {
+ void run('merge abort', () => gitService.abortMerge(repo.path));
+ }
+ };
+
+ return (
+
+ {/* Banner */}
+
+
+
+
+ {isRebasing ? 'Rebase in progress' : 'Merge in progress'}
+
+ {conflicted.length > 0 && (
+
+ {conflicted.length} file{conflicted.length !== 1 ? 's' : ''} with conflicts
+
+ )}
+
+ {allResolved ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* File list */}
+
+
+ {/* Editor area */}
+
+ {loading && (
+
+ Loading…
+
+ )}
+ {!loading && fileData && (
+
{ setSelectedPath(null); setFileData(null); }}
+ />
+ )}
+ {!loading && !fileData && !selectedPath && (
+
+ {allResolved ? (
+ <>
+
+
All conflicts resolved
+
Click Continue {isRebasing ? 'Rebase' : 'Merge'} to finish
+ >
+ ) : (
+ <>
+
+
Select a conflicted file to resolve
+
{conflicted.length} file{conflicted.length !== 1 ? 's' : ''} remaining
+ >
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/merge-conflict/MergeEditor.tsx b/src/components/merge-conflict/MergeEditor.tsx
new file mode 100644
index 0000000..1827038
--- /dev/null
+++ b/src/components/merge-conflict/MergeEditor.tsx
@@ -0,0 +1,261 @@
+import { useEffect, useRef, useState, useCallback } from 'react';
+import Editor, { type OnMount } from '@monaco-editor/react';
+import type { editor as MonacoEditorNS } from 'monaco-editor';
+import { ChevronLeft, ChevronRight, Save, X, SkipForward } from 'lucide-react';
+import type { ConflictFileData } from '../../types/git';
+import { gpConfirm } from '../common/Dialog';
+import { findConflicts, resolveBlock, resolveAll, hasConflicts, countConflicts, type ResolveChoice } from './diff3-model';
+
+interface Props {
+ fileData: ConflictFileData;
+ onSave: (content: string) => void;
+ onClose: () => void;
+}
+
+const LANG_MAP: Record = {
+ ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
+ rs: 'rust', py: 'python', go: 'go', java: 'java', c: 'c', cpp: 'cpp',
+ cs: 'csharp', rb: 'ruby', php: 'php', json: 'json', yaml: 'yaml', yml: 'yaml',
+ toml: 'toml', md: 'markdown', html: 'html', css: 'css', scss: 'scss', sql: 'sql',
+ sh: 'shell', bash: 'shell', kt: 'kotlin', swift: 'swift', dart: 'dart',
+};
+
+function getLang(path: string): string {
+ return LANG_MAP[path.split('.').pop()?.toLowerCase() ?? ''] ?? 'plaintext';
+}
+
+const EDITOR_OPTS: MonacoEditorNS.IStandaloneEditorConstructionOptions = {
+ minimap: { enabled: false },
+ scrollBeyondLastLine: false,
+ fontSize: 12,
+ lineNumbers: 'on',
+ folding: false,
+ wordWrap: 'off',
+ renderWhitespace: 'none',
+ occurrencesHighlight: 'off',
+ selectionHighlight: false,
+ scrollbar: { verticalScrollbarSize: 6, horizontalScrollbarSize: 6 },
+};
+
+export function MergeEditor({ fileData, onSave, onClose }: Props) {
+ const outputEditorRef = useRef(null);
+ const decorCollRef = useRef(null);
+ const currentConflictRef = useRef(0);
+ const [currentConflict, setCurrentConflict] = useState(0);
+ const [conflictCount, setConflictCount] = useState(() => countConflicts(fileData.workingContent));
+ const lang = getLang(fileData.path);
+ const fileName = fileData.path.split('/').pop() ?? fileData.path;
+
+ const refreshDecorations = useCallback((currentIdx: number) => {
+ const editor = outputEditorRef.current;
+ const coll = decorCollRef.current;
+ if (!editor || !coll) return;
+ const content = editor.getValue();
+ const blocks = findConflicts(content);
+ setConflictCount(blocks.length);
+ if (currentIdx >= blocks.length && blocks.length > 0) {
+ setCurrentConflict(blocks.length - 1);
+ }
+ const decos: MonacoEditorNS.IModelDeltaDecoration[] = [];
+ for (const block of blocks) {
+ const isCurrent = block.index === currentIdx;
+ const cls = isCurrent ? 'mc-active' : '';
+ decos.push({
+ range: { startLineNumber: block.startLine, startColumn: 1, endLineNumber: block.startLine, endColumn: 9999 },
+ options: { isWholeLine: true, className: `mc-ours-marker ${cls}`.trim() },
+ });
+ if (block.oursLines.length > 0) {
+ const s = block.startLine + 1, e = block.startLine + block.oursLines.length;
+ decos.push({
+ range: { startLineNumber: s, startColumn: 1, endLineNumber: e, endColumn: 9999 },
+ options: { isWholeLine: true, className: `mc-ours-content ${cls}`.trim() },
+ });
+ }
+ const sep = block.startLine + block.oursLines.length + 1;
+ decos.push({
+ range: { startLineNumber: sep, startColumn: 1, endLineNumber: sep, endColumn: 9999 },
+ options: { isWholeLine: true, className: 'mc-separator' },
+ });
+ if (block.theirsLines.length > 0) {
+ const s = sep + 1, e = sep + block.theirsLines.length;
+ decos.push({
+ range: { startLineNumber: s, startColumn: 1, endLineNumber: e, endColumn: 9999 },
+ options: { isWholeLine: true, className: `mc-theirs-content ${cls}`.trim() },
+ });
+ }
+ decos.push({
+ range: { startLineNumber: block.endLine, startColumn: 1, endLineNumber: block.endLine, endColumn: 9999 },
+ options: { isWholeLine: true, className: `mc-theirs-marker ${cls}`.trim() },
+ });
+ }
+ coll.set(decos);
+ if (blocks[currentIdx]) {
+ editor.revealLineInCenter(blocks[currentIdx].startLine);
+ }
+ }, []);
+
+ const handleOutputMount: OnMount = (editor) => {
+ outputEditorRef.current = editor;
+ decorCollRef.current = editor.createDecorationsCollection([]);
+ refreshDecorations(0);
+ let timer: ReturnType;
+ editor.onDidChangeModelContent(() => {
+ clearTimeout(timer);
+ timer = setTimeout(() => refreshDecorations(currentConflictRef.current), 200);
+ });
+ };
+
+ useEffect(() => {
+ refreshDecorations(currentConflict);
+ }, [currentConflict, refreshDecorations]);
+
+ const applyResolve = useCallback((choice: ResolveChoice) => {
+ const editor = outputEditorRef.current;
+ if (!editor) return;
+ const content = editor.getValue();
+ const blocks = findConflicts(content);
+ if (blocks.length === 0) return;
+ const newContent = resolveBlock(content, blocks, currentConflict, choice);
+ editor.getModel()?.setValue(newContent);
+ const newBlocks = findConflicts(newContent);
+ const next = Math.min(currentConflict, Math.max(0, newBlocks.length - 1));
+ currentConflictRef.current = next;
+ setCurrentConflict(next);
+ setTimeout(() => refreshDecorations(next), 0);
+ }, [currentConflict, refreshDecorations]);
+
+ const applyResolveAll = useCallback((choice: 'ours' | 'theirs') => {
+ const editor = outputEditorRef.current;
+ if (!editor) return;
+ const newContent = resolveAll(editor.getValue(), choice);
+ editor.getModel()?.setValue(newContent);
+ currentConflictRef.current = 0;
+ setCurrentConflict(0);
+ setTimeout(() => refreshDecorations(0), 0);
+ }, [refreshDecorations]);
+
+ const handleSave = async () => {
+ const editor = outputEditorRef.current;
+ if (!editor) return;
+ const content = editor.getValue();
+ if (hasConflicts(content) && !await gpConfirm('Conflict markers remain. Save anyway?')) return;
+ onSave(content);
+ };
+
+ const goNext = () => {
+ const next = Math.min(conflictCount - 1, currentConflict + 1);
+ currentConflictRef.current = next;
+ setCurrentConflict(next);
+ };
+ const goPrev = () => {
+ const prev = Math.max(0, currentConflict - 1);
+ currentConflictRef.current = prev;
+ setCurrentConflict(prev);
+ };
+
+ const blocks = findConflicts(outputEditorRef.current?.getValue() ?? fileData.workingContent);
+ const currentBlock = blocks[currentConflict];
+
+ return (
+
+
+
+ {/* Header */}
+
+ {fileData.path}
+ {conflictCount > 0
+ ? {conflictCount} conflict{conflictCount !== 1 ? 's' : ''}
+ : Resolved}
+
+
+
+
+ {/* Top: Ours | Theirs */}
+
+
+
+
+ Current / Ours{currentBlock ? ` · ${currentBlock.oursLabel}` : ''}
+
+
+
+
+
+
+
+
+
+
+ Incoming / Theirs{currentBlock ? ` · ${currentBlock.theirsLabel}` : ''}
+
+
+
+
+
+
+
+
+
+ {/* Conflict nav */}
+
+ {conflictCount > 0 ? (
+ <>
+
+ {currentConflict + 1}/{conflictCount}
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+
All conflicts resolved — click Save + Stage
+ )}
+
+
+ {/* Output editor */}
+
+
+ Result · {fileName}
+ Edit directly or use buttons above
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/merge-conflict/diff3-model.ts b/src/components/merge-conflict/diff3-model.ts
new file mode 100644
index 0000000..0a5bfbe
--- /dev/null
+++ b/src/components/merge-conflict/diff3-model.ts
@@ -0,0 +1,72 @@
+import type { ConflictBlock } from './types';
+
+export type { ConflictBlock };
+
+export function findConflicts(content: string): ConflictBlock[] {
+ const lines = content.split('\n');
+ const blocks: ConflictBlock[] = [];
+ let i = 0;
+ while (i < lines.length) {
+ const line = lines[i];
+ if (line.startsWith('<<<<<<<')) {
+ const startLine = i + 1;
+ const oursLabel = line.slice(7).trim() || 'HEAD';
+ i++;
+ const oursLines: string[] = [];
+ while (i < lines.length && !lines[i].startsWith('=======') && !lines[i].startsWith('|||||||')) {
+ oursLines.push(lines[i]);
+ i++;
+ }
+ // skip ======= or ||||||| separator(s)
+ while (i < lines.length && (lines[i].startsWith('=======') || lines[i].startsWith('|||||||'))) i++;
+ const theirsLines: string[] = [];
+ while (i < lines.length && !lines[i].startsWith('>>>>>>>')) {
+ theirsLines.push(lines[i]);
+ i++;
+ }
+ const theirsLabel = lines[i]?.slice(7).trim() || 'incoming';
+ const endLine = i + 1;
+ blocks.push({ index: blocks.length, startLine, endLine, oursLines, theirsLines, oursLabel, theirsLabel });
+ }
+ i++;
+ }
+ return blocks;
+}
+
+export type ResolveChoice = 'ours' | 'theirs' | 'both' | 'theirs-then-ours';
+
+export function resolveBlock(
+ content: string,
+ blocks: ConflictBlock[],
+ blockIndex: number,
+ choice: ResolveChoice,
+): string {
+ const block = blocks[blockIndex];
+ if (!block) return content;
+ const lines = content.split('\n');
+ const resolved =
+ choice === 'ours' ? block.oursLines
+ : choice === 'theirs' ? block.theirsLines
+ : choice === 'both' ? [...block.oursLines, ...block.theirsLines]
+ : [...block.theirsLines, ...block.oursLines];
+ lines.splice(block.startLine - 1, block.endLine - block.startLine + 1, ...resolved);
+ return lines.join('\n');
+}
+
+export function resolveAll(content: string, choice: 'ours' | 'theirs'): string {
+ let result = content;
+ let blocks = findConflicts(result);
+ while (blocks.length > 0) {
+ result = resolveBlock(result, blocks, blocks.length - 1, choice);
+ blocks = findConflicts(result);
+ }
+ return result;
+}
+
+export function hasConflicts(content: string): boolean {
+ return content.split('\n').some(l => l.startsWith('<<<<<<<'));
+}
+
+export function countConflicts(content: string): number {
+ return content.split('\n').filter(l => l.startsWith('<<<<<<<')).length;
+}
diff --git a/src/components/merge-conflict/types.ts b/src/components/merge-conflict/types.ts
new file mode 100644
index 0000000..7636e3d
--- /dev/null
+++ b/src/components/merge-conflict/types.ts
@@ -0,0 +1,9 @@
+export interface ConflictBlock {
+ index: number;
+ startLine: number;
+ endLine: number;
+ oursLines: string[];
+ theirsLines: string[];
+ oursLabel: string;
+ theirsLabel: string;
+}
diff --git a/src/components/settings/SettingsPanel.test.tsx b/src/components/settings/SettingsPanel.test.tsx
new file mode 100644
index 0000000..8cb3305
--- /dev/null
+++ b/src/components/settings/SettingsPanel.test.tsx
@@ -0,0 +1,396 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { SettingsPanel } from './SettingsPanel';
+import type { Settings } from '../../types/git';
+
+// ── Mocks ─────────────────────────────────────────────────────────────────────
+
+const { mockSaveSettings, mockSetState, mockStartAutoFetch } = vi.hoisted(() => ({
+ mockSaveSettings: vi.fn(),
+ mockSetState: vi.fn(),
+ mockStartAutoFetch: vi.fn(),
+}));
+
+vi.mock('../../services/gitService', () => ({
+ gitService: { saveSettings: mockSaveSettings },
+}));
+
+vi.mock('../../store/gitStore', () => {
+ const useGitStore = Object.assign(vi.fn(), { setState: mockSetState });
+ return { useGitStore, startAutoFetch: mockStartAutoFetch };
+});
+
+const { useGitStore } = await import('../../store/gitStore');
+const mockUseGitStore = vi.mocked(useGitStore);
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+const defaultSettings: Settings = {
+ theme: 'dark',
+ gitPath: 'git',
+ defaultTargetBranch: 'main',
+ recentRepositories: [],
+ aiProvider: 'ollama',
+ aiApiKey: '',
+ aiModel: 'llama3',
+ validationCommands: [],
+ shortcuts: [],
+ autoFetchInterval: 0,
+ updateChannel: 'stable',
+};
+
+function setupStore(overrides: Partial<{ settings: Settings; settingsTab: string }> = {}) {
+ const state = { settings: defaultSettings, settingsTab: 'general', ...overrides };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ mockUseGitStore.mockImplementation((selector: any) => selector(state));
+}
+
+function renderPanel() {
+ return render();
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('SettingsPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockSetState.mockReset();
+ mockSaveSettings.mockResolvedValue(defaultSettings);
+ setupStore();
+ });
+
+ // ── Render ─────────────────────────────────────────────────────────────────
+
+ describe('initial render', () => {
+ it('renders without crashing', () => {
+ renderPanel();
+ // "General" appears in both sidebar button and header — check header specifically
+ expect(screen.getByRole('heading', { level: 2, name: 'General' })).toBeInTheDocument();
+ });
+
+ it('renders all four sidebar tabs', () => {
+ renderPanel();
+ expect(screen.getByRole('button', { name: /general/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /git/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /ai/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /about/i })).toBeInTheDocument();
+ });
+
+ it('returns null when settings is undefined', () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ mockUseGitStore.mockImplementation((selector: any) =>
+ selector({ settings: undefined, settingsTab: 'general' }),
+ );
+ const { container } = renderPanel();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('shows Save Settings and Cancel buttons on non-about tabs', () => {
+ renderPanel();
+ expect(screen.getByRole('button', { name: 'Save Settings' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+ });
+ });
+
+ // ── General tab ────────────────────────────────────────────────────────────
+
+ describe('General tab', () => {
+ it('shows auto-fetch interval select defaulting to Disabled', () => {
+ renderPanel();
+ expect(screen.getByDisplayValue('Disabled')).toBeInTheDocument();
+ });
+
+ it('shows all auto-fetch interval options', () => {
+ renderPanel();
+ const expected = ['Disabled', '30 seconds', '1 minute', '5 minutes', '10 minutes', '30 minutes'];
+ for (const label of expected) {
+ expect(screen.getByRole('option', { name: label })).toBeInTheDocument();
+ }
+ });
+
+ it('shows update channel defaulting to Stable', () => {
+ renderPanel();
+ expect(screen.getByDisplayValue('Stable')).toBeInTheDocument();
+ });
+
+ it('shows alpha option in update channel select', () => {
+ renderPanel();
+ expect(screen.getByRole('option', { name: 'Alpha (early access)' })).toBeInTheDocument();
+ });
+
+ it('shows default target branch input with current value', () => {
+ renderPanel();
+ expect(screen.getByPlaceholderText('main')).toHaveValue('main');
+ });
+
+ it('reflects non-default settings values', () => {
+ setupStore({
+ settings: { ...defaultSettings, autoFetchInterval: 300, updateChannel: 'alpha', defaultTargetBranch: 'master' },
+ });
+ renderPanel();
+ expect(screen.getByDisplayValue('5 minutes')).toBeInTheDocument();
+ expect(screen.getByDisplayValue('Alpha (early access)')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('main')).toHaveValue('master');
+ });
+ });
+
+ // ── Git tab ────────────────────────────────────────────────────────────────
+
+ describe('Git tab', () => {
+ it('shows git path input with current value', () => {
+ setupStore({ settingsTab: 'git' });
+ renderPanel();
+ expect(screen.getByPlaceholderText('git')).toHaveValue('git');
+ });
+
+ it('shows custom git path when configured', () => {
+ setupStore({ settingsTab: 'git', settings: { ...defaultSettings, gitPath: '/usr/local/bin/git' } });
+ renderPanel();
+ expect(screen.getByPlaceholderText('git')).toHaveValue('/usr/local/bin/git');
+ });
+ });
+
+ // ── AI tab ─────────────────────────────────────────────────────────────────
+
+ describe('AI tab', () => {
+ beforeEach(() => setupStore({ settingsTab: 'ai' }));
+
+ it('shows provider select with current provider', () => {
+ renderPanel();
+ expect(screen.getByDisplayValue('ollama')).toBeInTheDocument();
+ });
+
+ it('lists all AI providers', () => {
+ renderPanel();
+ for (const p of ['ollama', 'openai', 'anthropic', 'groq']) {
+ expect(screen.getByRole('option', { name: p })).toBeInTheDocument();
+ }
+ });
+
+ it('shows API key input as password field', () => {
+ renderPanel();
+ const input = screen.getByPlaceholderText(/sk-/i);
+ expect(input).toHaveAttribute('type', 'password');
+ });
+
+ it('shows model input with current value', () => {
+ renderPanel();
+ expect(screen.getByDisplayValue('llama3')).toBeInTheDocument();
+ });
+
+ it('shows model placeholder when model is empty', () => {
+ setupStore({ settingsTab: 'ai', settings: { ...defaultSettings, aiModel: '' } });
+ renderPanel();
+ expect(screen.getByPlaceholderText(/llama3/i)).toBeInTheDocument();
+ });
+ });
+
+ // ── About tab ──────────────────────────────────────────────────────────────
+
+ describe('About tab', () => {
+ beforeEach(() => setupStore({ settingsTab: 'about' }));
+
+ it('renders the app name', () => {
+ renderPanel();
+ expect(screen.getByText('Visual Git Client')).toBeInTheDocument();
+ });
+
+ it('shows version string after getVersion resolves', async () => {
+ renderPanel();
+ await waitFor(() => {
+ expect(screen.getByText(/v0\.1\.0-alpha/i)).toBeInTheDocument();
+ });
+ });
+
+ it('does not show Save Settings or Cancel buttons', () => {
+ renderPanel();
+ expect(screen.queryByRole('button', { name: 'Save Settings' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument();
+ });
+
+ it('shows built-with text', () => {
+ renderPanel();
+ expect(screen.getByText(/tauri/i)).toBeInTheDocument();
+ });
+ });
+
+ // ── Field interactions (dispatch to store) ────────────────────────────────
+
+ describe('field editing dispatches to store', () => {
+ it('auto-fetch interval change calls useGitStore.setState with new value', async () => {
+ const user = userEvent.setup();
+ renderPanel();
+ const select = screen.getByDisplayValue('Disabled');
+ await user.selectOptions(select, '60');
+ expect(mockSetState).toHaveBeenCalledWith({
+ settings: expect.objectContaining({ autoFetchInterval: 60 }),
+ });
+ });
+
+ it('update channel change calls useGitStore.setState', async () => {
+ const user = userEvent.setup();
+ renderPanel();
+ await user.selectOptions(screen.getByDisplayValue('Stable'), 'alpha');
+ expect(mockSetState).toHaveBeenCalledWith({
+ settings: expect.objectContaining({ updateChannel: 'alpha' }),
+ });
+ });
+
+ it('default target branch input calls useGitStore.setState', () => {
+ renderPanel();
+ fireEvent.change(screen.getByPlaceholderText('main'), { target: { value: 'develop' } });
+ expect(mockSetState).toHaveBeenCalledWith({
+ settings: expect.objectContaining({ defaultTargetBranch: 'develop' }),
+ });
+ });
+
+ it('git path input calls useGitStore.setState', () => {
+ setupStore({ settingsTab: 'git' });
+ renderPanel();
+ fireEvent.change(screen.getByPlaceholderText('git'), { target: { value: '/usr/bin/git' } });
+ expect(mockSetState).toHaveBeenCalledWith({
+ settings: expect.objectContaining({ gitPath: '/usr/bin/git' }),
+ });
+ });
+
+ it('AI provider change calls useGitStore.setState', async () => {
+ const user = userEvent.setup();
+ setupStore({ settingsTab: 'ai' });
+ renderPanel();
+ await user.selectOptions(screen.getByDisplayValue('ollama'), 'openai');
+ expect(mockSetState).toHaveBeenCalledWith({
+ settings: expect.objectContaining({ aiProvider: 'openai' }),
+ });
+ });
+
+ it('AI model input calls useGitStore.setState', () => {
+ setupStore({ settingsTab: 'ai' });
+ renderPanel();
+ fireEvent.change(screen.getByDisplayValue('llama3'), { target: { value: 'gpt-4o' } });
+ expect(mockSetState).toHaveBeenCalledWith({
+ settings: expect.objectContaining({ aiModel: 'gpt-4o' }),
+ });
+ });
+ });
+
+ // ── Save ───────────────────────────────────────────────────────────────────
+
+ describe('save', () => {
+ it('calls gitService.saveSettings with current store settings', async () => {
+ const user = userEvent.setup();
+ renderPanel();
+ await user.click(screen.getByRole('button', { name: 'Save Settings' }));
+ await waitFor(() => expect(mockSaveSettings).toHaveBeenCalledWith(defaultSettings));
+ });
+
+ it('shows Saving… while save is in progress', async () => {
+ mockSaveSettings.mockImplementation(
+ () => new Promise(resolve => setTimeout(() => resolve(defaultSettings), 200)),
+ );
+ const user = userEvent.setup();
+ renderPanel();
+ await user.click(screen.getByRole('button', { name: 'Save Settings' }));
+ expect(screen.getByText('Saving…')).toBeInTheDocument();
+ });
+
+ it('Save button is disabled while saving', async () => {
+ mockSaveSettings.mockImplementation(
+ () => new Promise(resolve => setTimeout(() => resolve(defaultSettings), 200)),
+ );
+ const user = userEvent.setup();
+ renderPanel();
+ await user.click(screen.getByRole('button', { name: 'Save Settings' }));
+ expect(screen.getByText('Saving…').closest('button')).toBeDisabled();
+ });
+
+ it('calls startAutoFetch with saved interval after successful save', async () => {
+ const savedSettings = { ...defaultSettings, autoFetchInterval: 300 };
+ mockSaveSettings.mockResolvedValue(savedSettings);
+ const user = userEvent.setup();
+ renderPanel();
+ await user.click(screen.getByRole('button', { name: 'Save Settings' }));
+ await waitFor(() => expect(mockStartAutoFetch).toHaveBeenCalledWith(300));
+ });
+
+ it('closes panel (settingsOpen: false) after successful save', async () => {
+ const user = userEvent.setup();
+ renderPanel();
+ await user.click(screen.getByRole('button', { name: 'Save Settings' }));
+ await waitFor(() =>
+ expect(mockSetState).toHaveBeenCalledWith(
+ expect.objectContaining({ settingsOpen: false }),
+ ),
+ );
+ });
+
+ it('stores saved settings back into store after save', async () => {
+ const savedSettings = { ...defaultSettings, gitPath: '/usr/bin/git' };
+ mockSaveSettings.mockResolvedValue(savedSettings);
+ const user = userEvent.setup();
+ renderPanel();
+ await user.click(screen.getByRole('button', { name: 'Save Settings' }));
+ await waitFor(() =>
+ expect(mockSetState).toHaveBeenCalledWith(
+ expect.objectContaining({ settings: savedSettings }),
+ ),
+ );
+ });
+ });
+
+ // ── Cancel ─────────────────────────────────────────────────────────────────
+
+ describe('cancel', () => {
+ it('sets settingsOpen to false', async () => {
+ const user = userEvent.setup();
+ renderPanel();
+ await user.click(screen.getByRole('button', { name: 'Cancel' }));
+ expect(mockSetState).toHaveBeenCalledWith({ settingsOpen: false });
+ });
+
+ it('does not call saveSettings', async () => {
+ const user = userEvent.setup();
+ renderPanel();
+ await user.click(screen.getByRole('button', { name: 'Cancel' }));
+ expect(mockSaveSettings).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── Close (X) button ───────────────────────────────────────────────────────
+
+ describe('close (X) button in header', () => {
+ it('sets settingsOpen to false', async () => {
+ const user = userEvent.setup();
+ renderPanel();
+ // X button is in the header, alongside the tab title
+ const header = screen.getByRole('heading', { level: 2 });
+ const xBtn = header.closest('div')!.querySelector('button')!;
+ await user.click(xBtn);
+ expect(mockSetState).toHaveBeenCalledWith({ settingsOpen: false });
+ });
+ });
+
+ // ── Auto-fetch option values ───────────────────────────────────────────────
+
+ describe('auto-fetch option values', () => {
+ it('Disabled option has value 0', () => {
+ renderPanel();
+ expect((screen.getByRole('option', { name: 'Disabled' }) as HTMLOptionElement).value).toBe('0');
+ });
+
+ it('30 seconds option has value 30', () => {
+ renderPanel();
+ expect((screen.getByRole('option', { name: '30 seconds' }) as HTMLOptionElement).value).toBe('30');
+ });
+
+ it('1 minute option has value 60', () => {
+ renderPanel();
+ expect((screen.getByRole('option', { name: '1 minute' }) as HTMLOptionElement).value).toBe('60');
+ });
+
+ it('30 minutes option has value 1800', () => {
+ renderPanel();
+ expect((screen.getByRole('option', { name: '30 minutes' }) as HTMLOptionElement).value).toBe('1800');
+ });
+ });
+});
diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx
index 5f7a886..16dd989 100644
--- a/src/components/settings/SettingsPanel.tsx
+++ b/src/components/settings/SettingsPanel.tsx
@@ -1 +1,280 @@
-import { useGitStore } from '../../store/gitStore';import { gitService } from '../../services/gitService';export function SettingsPanel(){const settings=useGitStore(s=>s.settings);if(!settings)return null;const set=(k:keyof typeof settings,v:string)=>useGitStore.setState({settings:{...settings,[k]:v}});return Settings
{(['theme','gitPath','defaultTargetBranch','aiProvider','aiApiKey','aiModel'] as const).map(k=>)}}
+import { useState, useEffect } from 'react';
+import { getVersion } from '@tauri-apps/api/app';
+import { Bot, ChevronRight, GitBranch, Info, Settings, Terminal, X } from 'lucide-react';
+import { useGitStore, startAutoFetch } from '../../store/gitStore';
+import { gitService } from '../../services/gitService';
+import type { Settings as SettingsType } from '../../types/git';
+import { GitPilotIcon } from '../common/GitPilotIcon';
+
+type Tab = 'general' | 'git' | 'ai' | 'about';
+
+const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
+ { id: 'general', label: 'General', icon: },
+ { id: 'git', label: 'Git', icon: },
+ { id: 'ai', label: 'AI', icon: },
+ { id: 'about', label: 'About', icon: },
+];
+
+const AUTO_FETCH_OPTIONS = [
+ { label: 'Disabled', value: 0 },
+ { label: '30 seconds', value: 30 },
+ { label: '1 minute', value: 60 },
+ { label: '5 minutes', value: 300 },
+ { label: '10 minutes', value: 600 },
+ { label: '30 minutes', value: 1800 },
+];
+
+const AI_PROVIDERS = ['ollama', 'openai', 'anthropic', 'groq'];
+
+function FieldRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
+ return (
+
+
+
{label}
+ {hint &&
{hint}
}
+
+
{children}
+
+ );
+}
+
+function SectionTitle({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function SettingsPanel() {
+ const settings = useGitStore(s => s.settings);
+ const tab = useGitStore(s => s.settingsTab) as Tab;
+ const setTab = (t: Tab) => useGitStore.setState({ settingsTab: t });
+ const [version, setVersion] = useState('');
+
+ useEffect(() => { getVersion().then(setVersion); }, []);
+ const [saving, setSaving] = useState(false);
+
+ if (!settings) return null;
+
+ const set = (k: keyof SettingsType, v: string | number) =>
+ useGitStore.setState({ settings: { ...settings, [k]: v } });
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ const saved = await gitService.saveSettings(settings);
+ useGitStore.setState({ settings: saved, settingsOpen: false });
+ startAutoFetch(saved.autoFetchInterval);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ {/* Sidebar */}
+
+
+
+
+ gitPILOT
+
+
+
+
+
+
+
+ Changes save when you click Save Settings.
+
+
+
+
+ {/* Content */}
+
+ {/* Header */}
+
+
+ {TABS.find(t => t.id === tab)?.label}
+
+
+
+
+ {/* Tab content */}
+
+ {tab === 'general' && (
+
+
Sync
+
+
+
+
+
+
+
+
+
Defaults
+
+
+
+ set('defaultTargetBranch', e.target.value)}
+ placeholder="main"
+ />
+
+
+
+ )}
+
+ {tab === 'git' && (
+
+
Executable
+
+
+
+ set('gitPath', e.target.value)}
+ placeholder="git"
+ />
+
+
+
+ )}
+
+ {tab === 'ai' && (
+
+ Provider
+
+
+
+
+ Credentials
+
+ set('aiApiKey', e.target.value)}
+ placeholder="sk-…"
+ />
+
+
+ Model
+
+ set('aiModel', e.target.value)}
+ placeholder="llama3, gpt-4o, claude-sonnet-4-6…"
+ />
+
+
+ )}
+
+ {tab === 'about' && (
+
+
+
+ git
+ PILOT
+
+
Visual Git Client
+
+ v{version || '…'}
+
+
Built with Tauri 2 · React 18 · Rust
+
+
+ )}
+
+
+ {/* Footer */}
+ {tab !== 'about' && (
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/stash/StashPanel.tsx b/src/components/stash/StashPanel.tsx
index c85a652..8c5ded0 100644
--- a/src/components/stash/StashPanel.tsx
+++ b/src/components/stash/StashPanel.tsx
@@ -1,2 +1,3 @@
+import { gpPrompt, gpConfirm } from '../common/Dialog';
import { useGitStore } from '../../store/gitStore';import { gitService } from '../../services/gitService';
-export function StashPanel(){const s=useGitStore();const repo=s.repo?.path;return Stashes
{s.stashes.map(st=>
{st.name}
{st.message||st.branch}
)}
}
+export function StashPanel(){const s=useGitStore();const repo=s.repo?.path;return Stashes
{s.stashes.map(st=>
{st.name}
{st.message||st.branch}
)}
}
diff --git a/src/components/status/StatusPanel.tsx b/src/components/status/StatusPanel.tsx
index 1c7e06f..38903ce 100644
--- a/src/components/status/StatusPanel.tsx
+++ b/src/components/status/StatusPanel.tsx
@@ -1,3 +1,253 @@
-import { useGitStore } from '../../store/gitStore';import { gitService } from '../../services/gitService';import type { GitFileStatus } from '../../types/git';
-export function StatusPanel(){const s=useGitStore();const repo=s.repo?.path;const act=(label:string,fn:()=>Promise)=>repo&&s.run(label,fn);return act('unstage',()=>gitService.unstageFile(repo!,f.path))} cached/>act('stage',()=>gitService.stageFile(repo!,f.path))}/>act('stage',()=>gitService.stageFile(repo!,f.path))}/>void s.loadConflict(f.path)}/>}
-function Group({title,files,action,onAction,cached=false}:{title:string;files:GitFileStatus[];action:string;onAction:(f:GitFileStatus)=>void;cached?:boolean}){const select=useGitStore(st=>st.setSelectedFile);const repo=useGitStore(st=>st.repo?.path);const run=useGitStore(st=>st.run);return {title}{files.length}
{files.map(f=>
{title!=='Staged'&&title!=='Untracked'?:null}{title==='Untracked'?:null}
)}
}
+import { useState } from 'react';
+import { ChevronRight, FilePlus2, Minus, Plus, RotateCcw, Trash2 } from 'lucide-react';
+import { gpConfirm } from '../common/Dialog';
+import { useGitStore } from '../../store/gitStore';
+import { gitService } from '../../services/gitService';
+import type { GitFileStatus } from '../../types/git';
+import { ContextMenu, type ContextMenuItem } from '../common/ContextMenu';
+
+export function StatusPanel() {
+ const status = useGitStore(s => s.status);
+ const repo = useGitStore(s => s.repo?.path);
+ const run = useGitStore(s => s.run);
+ const loadConflict = useGitStore(s => s.loadConflict);
+ const [stagedOpen, setStagedOpen] = useState(true);
+ const [unstagedOpen, setUnstagedOpen] = useState(true);
+ const [menu, setMenu] = useState<{ x: number; y: number; file: GitFileStatus; cached: boolean; conflicted?: boolean }>();
+
+ const act = (label: string, fn: () => Promise) => repo && run(label, fn, 'status');
+ const copyPath = (path: string) => void navigator.clipboard.writeText(path).then(() => useGitStore.getState().log(`Copied path: ${path}`));
+ const fileMenuItems = ({ file, cached, conflicted }: NonNullable): ContextMenuItem[] => [
+ {
+ label: cached ? 'Unstage file' : conflicted ? 'Open resolver' : 'Stage file',
+ action: () => { if (cached) act('unstage', () => gitService.unstageFile(repo!, file.path)); else if (conflicted) void loadConflict(file.path); else act('stage', () => gitService.stageFile(repo!, file.path)); },
+ },
+ {
+ label: 'Show diff',
+ action: () => void useGitStore.getState().setSelectedFile(file, cached),
+ },
+ { label: 'file-separator', separator: true, action: () => undefined },
+ {
+ label: 'Discard changes',
+ danger: true,
+ disabled: cached || conflicted || file.worktreeStatus === '?',
+ action: async () => { if (repo && await gpConfirm(`Discard ${file.path}?`, true)) void run('discard', () => gitService.discardFile(repo, file.path), 'status'); },
+ },
+ {
+ label: 'Delete untracked file',
+ danger: true,
+ disabled: file.worktreeStatus !== '?' && file.indexStatus !== '?',
+ action: async () => { if (repo && await gpConfirm(`Delete ${file.path}?`, true)) void run('delete', () => gitService.deleteUntrackedFile(repo, file.path), 'status'); },
+ },
+ { label: 'copy-separator', separator: true, action: () => undefined },
+ {
+ label: 'Copy relative path',
+ action: () => copyPath(file.path),
+ },
+ ];
+
+ const staged = status.staged;
+ const unstaged = [...status.unstaged, ...status.untracked, ...status.conflicted];
+ const stagedCount = staged.length;
+ const unstagedCount = unstaged.length;
+
+ return (
+
+ {/* Staged section */}
+
+
+
+ {repo && (
+
+ )}
+ {repo && (
+
+ )}
+
+ {stagedOpen && stagedCount > 0 && (
+
+ {staged.map(f => (
+ }
+ onAction={() => act('unstage', () => gitService.unstageFile(repo!, f.path))}
+ onContextMenu={event => { event.preventDefault(); setMenu({ x: event.clientX, y: event.clientY, file: f, cached: true }); }}
+ cached
+ />
+ ))}
+
+ )}
+ {stagedOpen && stagedCount === 0 && (
+
No staged files
+ )}
+
+
+ {/* Unstaged section */}
+
+
+
+ {repo && unstagedCount > 0 && (
+
+ )}
+
+ {unstagedOpen && (
+
+ {status.unstaged.map(f => (
+
}
+ onAction={() => act('stage', () => gitService.stageFile(repo!, f.path))}
+ onDiscard={async () => repo && await gpConfirm(`Discard ${f.path}?`, true) && run('discard', () => gitService.discardFile(repo, f.path), 'status')}
+ onContextMenu={event => { event.preventDefault(); setMenu({ x: event.clientX, y: event.clientY, file: f, cached: false }); }}
+ />
+ ))}
+ {status.untracked.map(f => (
+
}
+ onAction={() => act('stage', () => gitService.stageFile(repo!, f.path))}
+ onDelete={async () => repo && await gpConfirm(`Delete ${f.path}?`, true) && run('delete', () => gitService.deleteUntrackedFile(repo, f.path), 'status')}
+ onContextMenu={event => { event.preventDefault(); setMenu({ x: event.clientX, y: event.clientY, file: f, cached: false }); }}
+ />
+ ))}
+ {status.conflicted.map(f => (
+
}
+ onAction={() => void loadConflict(f.path)}
+ onContextMenu={event => { event.preventDefault(); setMenu({ x: event.clientX, y: event.clientY, file: f, cached: false, conflicted: true }); }}
+ conflicted
+ />
+ ))}
+ {unstagedCount === 0 && (
+
No unstaged changes
+ )}
+
+ )}
+
+ {menu && (
+ setMenu(undefined)}
+ />
+ )}
+
+ );
+}
+
+function FileRow({
+ file,
+ action,
+ actionIcon,
+ onAction,
+ onDiscard,
+ onDelete,
+ onContextMenu,
+ cached = false,
+ conflicted = false,
+}: {
+ file: GitFileStatus;
+ action: string;
+ actionIcon: React.ReactNode;
+ onAction: () => void;
+ onDiscard?: () => void;
+ onDelete?: () => void;
+ onContextMenu?: React.MouseEventHandler;
+ cached?: boolean;
+ conflicted?: boolean;
+}) {
+ const select = useGitStore(st => st.setSelectedFile);
+
+ return (
+
+
+ {file.displayStatus || (file.indexStatus + file.worktreeStatus).trim() || '?'}
+
+
+
+
+ {onDiscard && (
+
+ )}
+ {onDelete && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/tag/TagPanel.tsx b/src/components/tag/TagPanel.tsx
index ae393dc..51a53a5 100644
--- a/src/components/tag/TagPanel.tsx
+++ b/src/components/tag/TagPanel.tsx
@@ -1 +1,2 @@
-import { useGitStore } from '../../store/gitStore';import { gitService } from '../../services/gitService';export function TagPanel(){const s=useGitStore();const repo=s.repo?.path;return {s.tags.map(t=>{t.name})}
}
+import { gpPrompt } from '../common/Dialog';
+import { useGitStore } from '../../store/gitStore';import { gitService } from '../../services/gitService';export function TagPanel(){const s=useGitStore();const repo=s.repo?.path;return {s.tags.map(t=>{t.name})}
}
diff --git a/src/components/update/UpdateDialog.tsx b/src/components/update/UpdateDialog.tsx
new file mode 100644
index 0000000..9abf209
--- /dev/null
+++ b/src/components/update/UpdateDialog.tsx
@@ -0,0 +1,168 @@
+import { useEffect, useState } from 'react';
+import { check, type Update } from '@tauri-apps/plugin-updater';
+import { relaunch } from '@tauri-apps/plugin-process';
+import { ArrowDownToLine, CheckCircle2, Loader2, X } from 'lucide-react';
+import { GitPilotIcon } from '../common/GitPilotIcon';
+
+type Status = 'checking' | 'available' | 'latest' | 'downloading' | 'installing' | 'error';
+
+const MOCK_UPDATE = {
+ version: '9.9.9',
+ body: '- New feature: something awesome\n- Fix: critical bug\n- Improvement: performance boost',
+} as unknown as Update;
+
+const ENDPOINTS = {
+ stable: 'https://github.com/ePlus-DEV/GitPilot/releases/latest/download/latest.json',
+ beta: 'https://github.com/ePlus-DEV/GitPilot/releases/download/beta-channel/latest.json',
+};
+
+export function UpdateDialog({ onClose, testMode, channel = 'stable' }: { onClose: () => void; testMode?: boolean; channel?: string }) {
+ const [status, setStatus] = useState(testMode ? 'available' : 'checking');
+ const [update, setUpdate] = useState(testMode ? MOCK_UPDATE : null);
+ const [progress, setProgress] = useState(0);
+ const [downloaded, setDownloaded] = useState(0);
+ const [total, setTotal] = useState(0);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ if (testMode) return;
+ const url = ENDPOINTS[channel as keyof typeof ENDPOINTS] ?? ENDPOINTS.stable;
+ // @ts-expect-error — url is supported at runtime but missing from installed type defs
+ check({ url })
+ .then(u => {
+ if (u) { setUpdate(u); setStatus('available'); }
+ else setStatus('latest');
+ })
+ .catch(e => { setError(String(e)); setStatus('error'); });
+ }, [testMode, channel]);
+
+ const handleInstall = async () => {
+ if (!update) return;
+ setStatus('downloading');
+ try {
+ await update.downloadAndInstall(event => {
+ if (event.event === 'Started') {
+ setTotal(event.data.contentLength ?? 0);
+ } else if (event.event === 'Progress') {
+ setDownloaded(d => {
+ const next = d + event.data.chunkLength;
+ if (total > 0) setProgress(Math.round((next / total) * 100));
+ return next;
+ });
+ } else if (event.event === 'Finished') {
+ setStatus('installing');
+ }
+ });
+ await relaunch();
+ } catch (e) {
+ setError(String(e));
+ setStatus('error');
+ }
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ Check for Update
+ {channel === 'beta' && (
+ Beta
+ )}
+
+
+
+
+ {/* Body */}
+
+ {status === 'checking' && (
+
+
+
Checking for updates…
+
+ )}
+
+ {status === 'latest' && (
+
+
+
You're up to date!
+
GitPilot is running the latest version.
+
+ )}
+
+ {status === 'available' && update && (
+
+
+
+
+
+ Version {update.version} available
+
+
A new version of GitPilot is ready to install.
+
+
+ {update.body && (
+
+ )}
+
+ )}
+
+ {status === 'downloading' && (
+
+
+ Downloading…
+ {progress}%
+
+
+ {total > 0 && (
+
+ {(downloaded / 1024 / 1024).toFixed(1)} / {(total / 1024 / 1024).toFixed(1)} MB
+
+ )}
+
+ )}
+
+ {status === 'installing' && (
+
+
+
Installing update… App will restart.
+
+ )}
+
+ {status === 'error' && (
+
+
Update failed
+
{error}
+
+ )}
+
+
+ {/* Footer */}
+
+
+ {status === 'available' && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/welcome/WelcomeScreen.tsx b/src/components/welcome/WelcomeScreen.tsx
new file mode 100644
index 0000000..7cb58c1
--- /dev/null
+++ b/src/components/welcome/WelcomeScreen.tsx
@@ -0,0 +1,312 @@
+import { FormEvent, useMemo, useState } from 'react';
+import { open } from '@tauri-apps/plugin-dialog';
+import {
+ ArrowRight,
+ CheckCircle2,
+ ChevronRight,
+ Clock,
+ FolderOpen,
+ GitBranchPlus,
+ GitFork,
+ Keyboard,
+ Layers3,
+ Rocket,
+ ShieldCheck,
+ Sparkles,
+ TerminalSquare,
+} from 'lucide-react';
+import { gitService } from '../../services/gitService';
+import { useGitStore } from '../../store/gitStore';
+import { GitPilotIcon } from '../common/GitPilotIcon';
+import {
+ getDestinationPreview,
+ getSuggestedRepoName,
+ inferRepoName,
+ joinPath,
+ HIGHLIGHTS,
+ SETUP_STEPS,
+ SHORTCUTS,
+} from './welcomeSetupUtils.js';
+
+function RecentItem({ path, onClick }: { path: string; onClick: () => void }) {
+ const name = path.split(/[\\/]/).pop() ?? path;
+ const dir = path.slice(0, path.length - name.length - 1);
+ return (
+
+ );
+}
+
+const ICONS = { FolderOpen, GitBranchPlus, Rocket, ShieldCheck, Layers3, TerminalSquare };
+
+function WelcomeIcon({ name, size, className }: { name: keyof typeof ICONS; size: number; className?: string }) {
+ const Icon = ICONS[name];
+ return ;
+}
+
+export function WelcomeScreen() {
+ const recent = useGitStore(s => s.recent);
+ const openRepo = useGitStore(s => s.openRepo);
+ const busy = useGitStore(s => s.busy);
+ const [cloning, setCloning] = useState(false);
+ const [cloneUrl, setCloneUrl] = useState('');
+ const [destinationParent, setDestinationParent] = useState('');
+ const [destinationName, setDestinationName] = useState('');
+ const log = useGitStore(s => s.log);
+
+ const suggestedName = useMemo(() => getSuggestedRepoName(cloneUrl), [cloneUrl]);
+ const destinationPreview = getDestinationPreview(destinationParent, destinationName, suggestedName);
+
+ const pick = async () => {
+ const p = await open({ directory: true, multiple: false });
+ if (p && !Array.isArray(p)) void openRepo(p);
+ };
+
+ const chooseCloneParent = async () => {
+ const parent = await open({ directory: true, multiple: false, title: 'Choose clone parent folder' });
+ if (parent && !Array.isArray(parent)) setDestinationParent(parent);
+ };
+
+ const cloneRepo = async (event?: FormEvent) => {
+ event?.preventDefault();
+ const url = cloneUrl.trim();
+ if (!url) return;
+ let parent = destinationParent;
+ if (!parent) {
+ const selected = await open({ directory: true, multiple: false, title: 'Choose clone parent folder' });
+ if (!selected || Array.isArray(selected)) return;
+ parent = selected;
+ setDestinationParent(selected);
+ }
+ const folder = (destinationName.trim() || inferRepoName(url)).trim();
+ setCloning(true);
+ try {
+ const cloned = await gitService.cloneRepository(url, joinPath(parent, folder));
+ await gitService.saveRecentRepository(cloned.path);
+ await openRepo(cloned.path);
+ } catch (e) {
+ log(String((e as Error).message ?? e));
+ } finally {
+ setCloning(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Setup
+
+
+
+
+
+
+ git
+ PILOT
+
+
+ Visual Git Client
+
+
+
+
+
+
+
+ Quick app setup
+
+
+ Install your Git workspace in seconds.
+
+
+ Open a local repository or clone a remote project, then GitPilot will load the graph, working tree, command log, and review tools automatically.
+
+
+
+
+ {SETUP_STEPS.map((step, index) => (
+
+
+
+
+
+ 0{index + 1}
+
+
{step.title}
+
{step.text}
+
+ ))}
+
+
+
+
+
+ {HIGHLIGHTS.map(item => (
+
+
+ {item.label}
+
+ ))}
+
+
+
+
+
+
+
+
Clone a remote repository
+
Paste a URL and choose where GitPilot should install it.
+
+
+
+
+
+
+
+
+
+
+ Recent repositories
+
+
{recent.length} saved
+
+ {recent.length === 0 ? (
+
+
+
No recent repositories yet
+
Open or clone a repository to pin it here for the next launch.
+
+ ) : (
+
+ {recent.slice(0, 12).map(path => (
+ void openRepo(path)} />
+ ))}
+
+ )}
+
+
+
+
+ Shortcuts
+
+
+ {SHORTCUTS.map(s => (
+
+ {s.label}
+ {s.keys}
+
+ ))}
+
+
+
+
+
+ );
+}
+
+function BackgroundGraph() {
+ const lanes = [
+ { x: '10%', color: '#38bdf8', delay: '0s', dur: '8s' },
+ { x: '23%', color: '#a78bfa', delay: '1.2s', dur: '11s' },
+ { x: '39%', color: '#34d399', delay: '0.5s', dur: '9s' },
+ { x: '58%', color: '#fb923c', delay: '2s', dur: '13s' },
+ { x: '74%', color: '#38bdf8', delay: '0.8s', dur: '10s' },
+ { x: '90%', color: '#a78bfa', delay: '1.8s', dur: '7s' },
+ ];
+ return (
+
+ {lanes.map((l, i) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/welcome/welcomeSetupUtils.d.ts b/src/components/welcome/welcomeSetupUtils.d.ts
new file mode 100644
index 0000000..a797bfc
--- /dev/null
+++ b/src/components/welcome/welcomeSetupUtils.d.ts
@@ -0,0 +1,31 @@
+export type WelcomeIconName =
+ | 'FolderOpen'
+ | 'GitBranchPlus'
+ | 'Rocket'
+ | 'ShieldCheck'
+ | 'Layers3'
+ | 'TerminalSquare';
+
+export interface SetupStep {
+ icon: WelcomeIconName;
+ title: string;
+ text: string;
+}
+
+export interface HighlightItem {
+ icon: WelcomeIconName;
+ label: string;
+}
+
+export interface ShortcutItem {
+ keys: string;
+ label: string;
+}
+
+export const SHORTCUTS: readonly ShortcutItem[];
+export const SETUP_STEPS: readonly SetupStep[];
+export const HIGHLIGHTS: readonly HighlightItem[];
+export function inferRepoName(url: string): string;
+export function joinPath(parent: string, child: string): string;
+export function getSuggestedRepoName(cloneUrl: string): string;
+export function getDestinationPreview(destinationParent: string, destinationName: string, suggestedName: string): string;
diff --git a/src/components/welcome/welcomeSetupUtils.js b/src/components/welcome/welcomeSetupUtils.js
new file mode 100644
index 0000000..c9e2a10
--- /dev/null
+++ b/src/components/welcome/welcomeSetupUtils.js
@@ -0,0 +1,36 @@
+export const SHORTCUTS = [
+ { keys: 'Ctrl R', label: 'Refresh' },
+ { keys: 'Ctrl ↵', label: 'Commit' },
+ { keys: 'Ctrl ⇧ P', label: 'Push' },
+];
+
+export const SETUP_STEPS = [
+ { icon: 'FolderOpen', title: 'Open', text: 'Pick an existing repository on your machine.' },
+ { icon: 'GitBranchPlus', title: 'Clone', text: 'Bring a remote repository into your workspace.' },
+ { icon: 'Rocket', title: 'Ship', text: 'Review changes, commit, push, and resolve conflicts.' },
+];
+
+export const HIGHLIGHTS = [
+ { icon: 'ShieldCheck', label: 'Safe local-first workflow' },
+ { icon: 'Layers3', label: 'Visual branch graph' },
+ { icon: 'TerminalSquare', label: 'Command log included' },
+];
+
+export function inferRepoName(url) {
+ return (url.split(/[\\/]/).pop() ?? 'repository').replace(/\.git$/, '') || 'repository';
+}
+
+export function joinPath(parent, child) {
+ return `${parent.replace(/[\\/]+$/, '')}${parent.includes('\\') ? '\\' : '/'}${child.replace(/^[\\/]+/, '')}`;
+}
+
+export function getSuggestedRepoName(cloneUrl) {
+ const trimmedUrl = cloneUrl.trim();
+ return trimmedUrl ? inferRepoName(trimmedUrl) : 'my-repository';
+}
+
+export function getDestinationPreview(destinationParent, destinationName, suggestedName) {
+ return destinationParent
+ ? joinPath(destinationParent, destinationName.trim() || suggestedName)
+ : 'Choose a parent folder to preview the install path';
+}
diff --git a/src/demo/currentRepoDemo.ts b/src/demo/currentRepoDemo.ts
index e5e2bf5..defb581 100644
--- a/src/demo/currentRepoDemo.ts
+++ b/src/demo/currentRepoDemo.ts
@@ -1,4 +1,4 @@
-import type { CommitInfo, GitStatus, RepositoryInfo } from '../types/git';
+import type { CommitInfo, GitStatus, RepositoryInfo } from '../types/git';
export const demoRepo: RepositoryInfo = {
"name": "GitPilot",
@@ -15,12 +15,15 @@ export const demoHistory: CommitInfo[] = [
"08f0633368c98f75d2074eb502d03bce9fda70f4"
],
"author": "David Nguyen",
+ "authorEmail": "",
"date": "2026-06-26",
"refs": [
"HEAD -> work"
],
"message": "Merge pull request #8 from ePlus-DEV/codex/fix-missing-icon-file-error-kb9qnb",
"head": true,
+ "insertions": 0,
+ "deletions": 0,
"graph": ""
},
{
@@ -30,10 +33,13 @@ export const demoHistory: CommitInfo[] = [
"e3bce538cc3966894d182740f4e8b505d1986b7d"
],
"author": "David Nguyen",
+ "authorEmail": "",
"date": "2026-06-26",
"refs": [],
"message": "Reduce headless app warning noise",
"head": false,
+ "insertions": 0,
+ "deletions": 0,
"graph": ""
},
{
@@ -43,10 +49,13 @@ export const demoHistory: CommitInfo[] = [
"0b45c4af87657035ba2378f2c28b6d1b9e049609"
],
"author": "David Nguyen",
+ "authorEmail": "",
"date": "2026-06-26",
"refs": [],
"message": "Publish screenshot for inline PR preview",
"head": false,
+ "insertions": 0,
+ "deletions": 0,
"graph": ""
},
{
@@ -57,10 +66,13 @@ export const demoHistory: CommitInfo[] = [
"f494a9f18b43a76d9c2ae67ebde4c4cdc0115fa8"
],
"author": "David Nguyen",
+ "authorEmail": "",
"date": "2026-06-26",
"refs": [],
"message": "Merge pull request #5 from ePlus-DEV/codex/bo-sung-github-action-va-template",
"head": false,
+ "insertions": 0,
+ "deletions": 0,
"graph": ""
},
{
@@ -71,10 +83,13 @@ export const demoHistory: CommitInfo[] = [
"19df9c1a406da8b6b3e5f3e6dbccfde1368d1594"
],
"author": "David Nguyen",
+ "authorEmail": "",
"date": "2026-06-26",
"refs": [],
"message": "Merge pull request #6 from ePlus-DEV/codex/sua-loi-github-action",
"head": false,
+ "insertions": 0,
+ "deletions": 0,
"graph": ""
},
{
@@ -84,10 +99,13 @@ export const demoHistory: CommitInfo[] = [
"746178e746328c9dd70fa0e97d78227bfd7f038a"
],
"author": "David Nguyen",
+ "authorEmail": "",
"date": "2026-06-26",
"refs": [],
"message": "Fix CI dependency lock usage",
"head": false,
+ "insertions": 0,
+ "deletions": 0,
"graph": ""
},
{
@@ -97,10 +115,13 @@ export const demoHistory: CommitInfo[] = [
"a903a03e3f68baa320dfed521fde3d6181b054b4"
],
"author": "David Nguyen",
+ "authorEmail": "",
"date": "2026-06-26",
"refs": [],
"message": "feat: enhance UI components and improve functionality",
"head": false,
+ "insertions": 0,
+ "deletions": 0,
"graph": ""
},
{
@@ -110,10 +131,13 @@ export const demoHistory: CommitInfo[] = [
"0142632ca27e48a64d2053a1597a93c9ef2bcdf6"
],
"author": "David Nguyen",
+ "authorEmail": "",
"date": "2026-06-26",
"refs": [],
"message": "Remove --locked flag from cargo check command in CI workflow",
"head": false,
+ "insertions": 0,
+ "deletions": 0,
"graph": ""
},
{
@@ -123,10 +147,13 @@ export const demoHistory: CommitInfo[] = [
"e7166bfb97ff895130623089792176e92115a09a"
],
"author": "David Nguyen",
+ "authorEmail": "",
"date": "2026-06-26",
"refs": [],
"message": "fix(ci): remove --locked flag from cargo check",
"head": false,
+ "insertions": 0,
+ "deletions": 0,
"graph": ""
},
{
@@ -136,10 +163,13 @@ export const demoHistory: CommitInfo[] = [
"ec964c6827a08a856131e9051f8b183ca065f215"
],
"author": "David Nguyen",
+ "authorEmail": "",
"date": "2026-06-25",
"refs": [],
"message": "Add GitHub workflow and contribution templates",
"head": false,
+ "insertions": 0,
+ "deletions": 0,
"graph": ""
},
{
@@ -150,10 +180,13 @@ export const demoHistory: CommitInfo[] = [
"bea3e014f6ca91eff3bb9db174488bdb1acef4ed"
],
"author": "David Nguyen",
+ "authorEmail": "",
"date": "2026-06-25",
"refs": [],
"message": "Merge pull request #2 from ePlus-DEV/codex/create-gitpilot-desktop-client-mvp",
"head": false,
+ "insertions": 0,
+ "deletions": 0,
"graph": ""
},
{
@@ -163,10 +196,13 @@ export const demoHistory: CommitInfo[] = [
"bc9686e297e76fcbda321c4792b72cffdecc5cb6"
],
"author": "David Nguyen",
+ "authorEmail": "",
"date": "2026-06-25",
"refs": [],
"message": "Expand GitPilot into full desktop Git client",
"head": false,
+ "insertions": 0,
+ "deletions": 0,
"graph": ""
},
{
@@ -177,10 +213,13 @@ export const demoHistory: CommitInfo[] = [
"e722ae4c41f2ad3e54649ef658a9efb1e27d9c82"
],
"author": "David Nguyen",
+ "authorEmail": "",
"date": "2026-06-25",
"refs": [],
"message": "Merge pull request #1 from ePlus-DEV/codex/design-logo-for-gitpilot-git-client",
"head": false,
+ "insertions": 0,
+ "deletions": 0,
"graph": ""
},
{
@@ -190,10 +229,13 @@ export const demoHistory: CommitInfo[] = [
"ba77da2a712322b58dd60262752b94839279f3cf"
],
"author": "David Nguyen",
+ "authorEmail": "",
"date": "2026-06-25",
"refs": [],
"message": "Add GitPilot source scaffold",
"head": false,
+ "insertions": 0,
+ "deletions": 0,
"graph": ""
},
{
@@ -201,10 +243,13 @@ export const demoHistory: CommitInfo[] = [
"shortHash": "ba77da2",
"parents": [],
"author": "David Nguyen",
+ "authorEmail": "",
"date": "2026-06-24",
"refs": [],
"message": "Initialize repository",
"head": false,
+ "insertions": 0,
+ "deletions": 0,
"graph": ""
}
];
@@ -223,3 +268,4 @@ export const demoStatus: GitStatus = {
"conflictedFiles": []
}
};
+
diff --git a/src/services/gitService.ts b/src/services/gitService.ts
index 865932f..550fd78 100644
--- a/src/services/gitService.ts
+++ b/src/services/gitService.ts
@@ -1,9 +1,14 @@
import { invoke } from '@tauri-apps/api/core';
import { demoHistory, demoRepo, demoStatus } from '../demo/currentRepoDemo';
-import type {AiResponse,BranchInfo,CommitFile,CommitInfo,DiffResult,GitCommandOutput,GitStatus,ParsedConflictFile,RemoteInfo,RepositoryInfo,Settings,StashInfo,TagInfo,HistoryFilters,RebaseState,RebaseTodoItem,BlameLine,WorktreeInfo,SearchResult,ReflogEntry,SubmoduleInfo,BisectState} from '../types/git';
+import type {AiResponse,BranchInfo,CommitFile,CommitInfo,CommitGraphRow,ConflictFileData,DiffResult,GitCommandOutput,GitStatus,ParsedConflictFile,RemoteInfo,RepositoryInfo,Settings,StashInfo,TagInfo,HistoryFilters,RebaseState,RebaseTodoItem,BlameLine,WorktreeInfo,SearchResult,ReflogEntry,SubmoduleInfo,BisectState} from '../types/git';
const isTauriRuntime=()=>typeof window!=='undefined'&&'__TAURI_INTERNALS__' in window;
-const demoSettings={theme:'dark',gitPath:'git',defaultTargetBranch:'main',recentRepositories:[demoRepo.path],aiProvider:'ollama',aiApiKey:'',aiModel:'',validationCommands:[],shortcuts:[]};
+const demoSettings={theme:'dark',gitPath:'git',defaultTargetBranch:'main',recentRepositories:[demoRepo.path],aiProvider:'ollama',aiApiKey:'',aiModel:'',validationCommands:[],shortcuts:[],autoFetchInterval:0};
+const demoCommitFiles:CommitFile[]=[
+ {status:'M',path:'src/App.tsx'},
+ {status:'M',path:'src/components/graph/GitGraph.tsx'},
+ {status:'M',path:'src-tauri/src/commands/history.rs'},
+];
const demoCall=(cmd:string,args:Record={}):Promise=>{
switch(cmd){
case 'get_settings': return Promise.resolve(demoSettings as T);
@@ -16,9 +21,10 @@ const demoCall=(cmd:string,args:Record={}):Promise=>{
case 'list_branches': return Promise.resolve([{name:demoStatus.currentBranch,current:true,remote:false,upstream:null,ahead:0,behind:0}] as T);
case 'list_remotes': return Promise.resolve([] as T);
case 'get_history': return Promise.resolve(demoHistory as T);
+ case 'get_commit_graph': return Promise.resolve([] as T);
case 'list_stashes': return Promise.resolve([] as T);
case 'list_tags': return Promise.resolve([] as T);
- case 'get_commit_files': return Promise.resolve([] as T);
+ case 'get_commit_files': return Promise.resolve(demoCommitFiles as T);
default: return Promise.reject(new Error(`Demo mode does not implement ${cmd}. Open the Tauri app for write operations.`));
}
};
@@ -29,9 +35,9 @@ export const gitService={
getStatus:(repoPath:string)=>call('get_status',{repoPath}), stageFile:(repoPath:string,filePath:string)=>call('stage_file',{repoPath,filePath}), unstageFile:(repoPath:string,filePath:string)=>call('unstage_file',{repoPath,filePath}), stageAll:(repoPath:string)=>call('stage_all',{repoPath}), unstageAll:(repoPath:string)=>call('unstage_all',{repoPath}), discardFile:(repoPath:string,filePath:string)=>call('discard_file',{repoPath,filePath}), deleteUntrackedFile:(repoPath:string,filePath:string)=>call('delete_untracked_file',{repoPath,filePath}),
getDiff:(repoPath:string,filePath:string,cached:boolean)=>call('get_diff',{repoPath,filePath,cached}), getCommitFileDiff:(repoPath:string,commit:string,filePath:string)=>call('get_commit_file_diff',{repoPath,commit,filePath}), commit:(repoPath:string,message:string,amend:boolean)=>call('commit',{repoPath,message,amend}), stagedDiff:(repoPath:string)=>call('staged_diff',{repoPath}),
listBranches:(repoPath:string)=>call('list_branches',{repoPath}), createBranch:(repoPath:string,name:string,checkout:boolean)=>call('create_branch',{repoPath,name,checkout}), checkoutBranch:(repoPath:string,name:string)=>call('checkout_branch',{repoPath,name}), renameBranch:(repoPath:string,oldName:string,newName:string)=>call('rename_branch',{repoPath,oldName,newName}), deleteBranch:(repoPath:string,name:string,force:boolean)=>call('delete_branch',{repoPath,name,force}), compareBranch:(repoPath:string,branch:string)=>call('compare_branch',{repoPath,branch}),
- listRemotes:(repoPath:string)=>call('list_remotes',{repoPath}), fetch:(repoPath:string,remote='origin')=>call('fetch',{repoPath,remote}), pull:(repoPath:string)=>call('pull',{repoPath}), push:(repoPath:string)=>call('push',{repoPath}), pushNewBranch:(repoPath:string,remote:string,branch:string)=>call('push_new_branch',{repoPath,remote,branch}),
- getHistory:(repoPath:string,limit=500,filters:HistoryFilters={})=>call('get_history',{repoPath,limit,branch:filters.branch,author:filters.author,since:filters.since,until:filters.until,keyword:filters.keyword,filePath:filters.filePath}), getCommitFiles:(repoPath:string,commit:string)=>call('get_commit_files',{repoPath,commit}), compareCommits:(repoPath:string,from:string,to:string)=>call('compare_commits',{repoPath,from,to}), checkoutCommit:(repoPath:string,commit:string)=>call('checkout_commit',{repoPath,commit}), createBranchFromCommit:(repoPath:string,name:string,commit:string,checkout:boolean)=>call('create_branch_from_commit',{repoPath,name,commit,checkout}), createTagFromCommit:(repoPath:string,name:string,commit:string)=>call('create_tag_from_commit',{repoPath,name,commit}), cherryPickCommit:(repoPath:string,commit:string)=>call('cherry_pick_commit',{repoPath,commit}), abortCherryPick:(repoPath:string)=>call('abort_cherry_pick',{repoPath}), blameFile:(repoPath:string,filePath:string)=>call('blame_file',{repoPath,filePath}), revertCommit:(repoPath:string,commit:string)=>call('revert_commit',{repoPath,commit}), resetToCommit:(repoPath:string,commit:string,mode:'soft'|'mixed'|'hard')=>call('reset_to_commit',{repoPath,commit,mode}),
- mergeBranch:(repoPath:string,branch:string)=>call('merge_branch',{repoPath,branch}), abortMerge:(repoPath:string)=>call('abort_merge',{repoPath}), continueMerge:(repoPath:string)=>call('continue_merge',{repoPath}), parseConflictFile:(repoPath:string,filePath:string)=>call('parse_conflict_file',{repoPath,filePath}), saveResolvedFile:(repoPath:string,filePath:string,content:string)=>call('save_resolved_file',{repoPath,filePath,content}),
+ listRemotes:(repoPath:string)=>call('list_remotes',{repoPath}), fetch:(repoPath:string,remote='origin')=>call('fetch',{repoPath,remote}), fetchAll:(repoPath:string)=>call('fetch_all',{repoPath}), pull:(repoPath:string)=>call('pull',{repoPath}), push:(repoPath:string)=>call('push',{repoPath}), pushNewBranch:(repoPath:string,remote:string,branch:string)=>call('push_new_branch',{repoPath,remote,branch}),
+ getHistory:(repoPath:string,limit=500,filters:HistoryFilters={},skip=0)=>call('get_history',{repoPath,limit,skip:skip||undefined,branch:filters.branch,author:filters.author,since:filters.since,until:filters.until,keyword:filters.keyword,filePath:filters.filePath}), getCommitGraph:(repoPath:string,limit=500,branch?:string,includeAll=true,skip?:number)=>call('get_commit_graph',{repoPath,limit,branch,includeAll,skip}), getCommitFiles:(repoPath:string,commit:string)=>call('get_commit_files',{repoPath,commit}), compareCommits:(repoPath:string,from:string,to:string)=>call('compare_commits',{repoPath,from,to}), checkoutCommit:(repoPath:string,commit:string)=>call('checkout_commit',{repoPath,commit}), createBranchFromCommit:(repoPath:string,name:string,commit:string,checkout:boolean)=>call('create_branch_from_commit',{repoPath,name,commit,checkout}), createTagFromCommit:(repoPath:string,name:string,commit:string)=>call('create_tag_from_commit',{repoPath,name,commit}), createAnnotatedTagFromCommit:(repoPath:string,name:string,message:string,commit:string)=>call('create_annotated_tag_from_commit',{repoPath,name,message,commit}), createPatchFromCommit:(repoPath:string,commit:string)=>call('create_patch_from_commit',{repoPath,commit}), restoreFileFromCommit:(repoPath:string,commit:string,filePath:string)=>call('restore_file_from_commit',{repoPath,commit,filePath}), cherryPickCommit:(repoPath:string,commit:string)=>call('cherry_pick_commit',{repoPath,commit}), abortCherryPick:(repoPath:string)=>call('abort_cherry_pick',{repoPath}), blameFile:(repoPath:string,filePath:string)=>call('blame_file',{repoPath,filePath}), revertCommit:(repoPath:string,commit:string)=>call('revert_commit',{repoPath,commit}), resetToCommit:(repoPath:string,commit:string,mode:'soft'|'mixed'|'hard')=>call('reset_to_commit',{repoPath,commit,mode}),
+ mergeBranch:(repoPath:string,branch:string)=>call('merge_branch',{repoPath,branch}), abortMerge:(repoPath:string)=>call('abort_merge',{repoPath}), continueMerge:(repoPath:string)=>call('continue_merge',{repoPath}), parseConflictFile:(repoPath:string,filePath:string)=>call('parse_conflict_file',{repoPath,filePath}), getConflictFile:(repoPath:string,filePath:string)=>call('get_conflict_file',{repoPath,filePath}), saveResolvedFile:(repoPath:string,filePath:string,content:string)=>call('save_resolved_file',{repoPath,filePath,content}),
startRebase:(repoPath:string,onto:string)=>call('start_rebase',{repoPath,onto}), startInteractiveRebase:(repoPath:string,base:string,todo:RebaseTodoItem[])=>call('start_interactive_rebase',{repoPath,base,todo}), getRebaseState:(repoPath:string)=>call('get_rebase_state',{repoPath}), continueRebase:(repoPath:string)=>call('continue_rebase',{repoPath}), abortRebase:(repoPath:string)=>call('abort_rebase',{repoPath}), skipRebase:(repoPath:string)=>call('skip_rebase',{repoPath}),
listStashes:(repoPath:string)=>call('list_stashes',{repoPath}), createStash:(repoPath:string,message:string)=>call('create_stash',{repoPath,message}), applyStash:(repoPath:string,stash:string)=>call('apply_stash',{repoPath,stash}), popStash:(repoPath:string,stash:string)=>call('pop_stash',{repoPath,stash}), dropStash:(repoPath:string,stash:string)=>call('drop_stash',{repoPath,stash}), renameStash:(repoPath:string,stash:string,message:string)=>call('rename_stash',{repoPath,stash,message}),
listTags:(repoPath:string)=>call('list_tags',{repoPath}), createLightweightTag:(repoPath:string,name:string)=>call('create_lightweight_tag',{repoPath,name}), createAnnotatedTag:(repoPath:string,name:string,message:string)=>call('create_annotated_tag',{repoPath,name,message}), deleteTag:(repoPath:string,name:string)=>call('delete_tag',{repoPath,name}), pushTag:(repoPath:string,remote:string,name:string)=>call('push_tag',{repoPath,remote,name}),
diff --git a/src/store/gitStore.ts b/src/store/gitStore.ts
index 220ebc6..3c8c5e5 100644
--- a/src/store/gitStore.ts
+++ b/src/store/gitStore.ts
@@ -1,7 +1,372 @@
import { create } from 'zustand';
import { gitService } from '../services/gitService';
-import type {BranchInfo,CommitFile,CommitInfo,DiffResult,GitCommandOutput,GitFileStatus,GitStatus,ParsedConflictFile,RemoteInfo,RepositoryInfo,Settings,StashInfo,TagInfo,HistoryFilters} from '../types/git';
-const empty:GitStatus={currentBranch:'',staged:[],unstaged:[],untracked:[],conflicted:[],ahead:0,behind:0,mergeState:{isMerging:false,isRebasing:false,conflictedFiles:[]}};
-type State={repo?:RepositoryInfo;recent:string[];status:GitStatus;branches:BranchInfo[];remotes:RemoteInfo[];history:CommitInfo[];commitFiles:CommitFile[];stashes:StashInfo[];tags:TagInfo[];selectedFile?:GitFileStatus;selectedCommit?:CommitInfo;diff?:DiffResult;conflict?:ParsedConflictFile;settings?:Settings;console:string[];problems:string[];aiText:string;busy:boolean;historyLimit:number;historyFilters:HistoryFilters;openRepo:(path:string)=>Promise;refresh:()=>Promise;loadHistory:(filters?:HistoryFilters,limit?:number)=>Promise;run:(label:string,fn:()=>Promise)=>Promise;setSelectedFile:(f:GitFileStatus,cached:boolean)=>Promise;selectCommit:(c:CommitInfo)=>Promise;loadConflict:(path:string)=>Promise;log:(m:string)=>void;};
-const fmt=(r:unknown):string=>Array.isArray(r)?r.map(fmt).join('\n'):(typeof r==='object'&&r&&'command'in r?`$ ${(r as GitCommandOutput).command}\n${(r as GitCommandOutput).stdout}${(r as GitCommandOutput).stderr}`:String(r));
-export const useGitStore=create((set,get)=>({recent:[],status:empty,branches:[],remotes:[],history:[],commitFiles:[],stashes:[],tags:[],console:[],problems:[],aiText:'',busy:false,historyLimit:500,historyFilters:{},log:(m)=>set(s=>({console:[m,...s.console].slice(0,100)})),openRepo:async(path)=>{set({busy:true});try{const repo=await gitService.openRepository(path);await gitService.saveRecentRepository(path);set({repo});await get().refresh()}catch(e){get().log(String((e as Error).message??e))}finally{set({busy:false})}},refresh:async()=>{const repo=get().repo;if(!repo)return;set({busy:true});try{const [status,branches,remotes,history,stashes,tags,recent,settings]=await Promise.all([gitService.getStatus(repo.path),gitService.listBranches(repo.path),gitService.listRemotes(repo.path),gitService.getHistory(repo.path,get().historyLimit,get().historyFilters),gitService.listStashes(repo.path),gitService.listTags(repo.path),gitService.listRecentRepositories(),gitService.getSettings()]);set({status,branches,remotes,history,stashes,tags,recent,settings,repo:{...repo,currentBranch:status.currentBranch}})}catch(e){get().log(String((e as Error).message??e))}finally{set({busy:false})}},loadHistory:async(filters,limit)=>{const repo=get().repo;if(!repo)return;const nextFilters=filters??get().historyFilters;const nextLimit=limit??get().historyLimit;set({busy:true,historyFilters:nextFilters,historyLimit:nextLimit});try{set({history:await gitService.getHistory(repo.path,nextLimit,nextFilters)})}catch(e){get().log(String((e as Error).message??e))}finally{set({busy:false})}},run:async(label,fn)=>{try{const r=await fn();get().log(`${label}\n${fmt(r)}`);await get().refresh()}catch(e){get().log(String((e as Error).message??e))}},setSelectedFile:async(f,cached)=>{const repo=get().repo;if(!repo)return;set({selectedFile:f,diff:undefined});try{set({diff:await gitService.getDiff(repo.path,f.path,cached)})}catch(e){get().log(String((e as Error).message??e))}},selectCommit:async(c)=>{const repo=get().repo;if(!repo)return;set({selectedCommit:c,commitFiles:await gitService.getCommitFiles(repo.path,c.hash)})},loadConflict:async(path)=>{const repo=get().repo;if(!repo)return;set({conflict:await gitService.parseConflictFile(repo.path,path)})}}));
+import { getRepoConfig } from '../utils/repoConfig';
+
+let _autoFetchTimer: ReturnType | null = null;
+
+export function startAutoFetch(intervalSeconds: number) {
+ if (_autoFetchTimer) { clearInterval(_autoFetchTimer); _autoFetchTimer = null; }
+ if (intervalSeconds <= 0) return;
+ _autoFetchTimer = setInterval(async () => {
+ const { repo, fetchAll } = useGitStore.getState();
+ if (repo) await fetchAll();
+ }, intervalSeconds * 1000);
+}
+
+export function stopAutoFetch() {
+ if (_autoFetchTimer) { clearInterval(_autoFetchTimer); _autoFetchTimer = null; }
+}
+import type {
+ BranchInfo,
+ CommitFile,
+ CommitGraphRow,
+ CommitInfo,
+ DiffResult,
+ GitCommandOutput,
+ GitFileStatus,
+ GitStatus,
+ HistoryFilters,
+ ParsedConflictFile,
+ RemoteInfo,
+ RepositoryInfo,
+ Settings,
+ StashInfo,
+ TagInfo,
+} from '../types/git';
+
+const empty: GitStatus = {
+ currentBranch: '',
+ staged: [],
+ unstaged: [],
+ untracked: [],
+ conflicted: [],
+ ahead: 0,
+ behind: 0,
+ mergeState: { isMerging: false, isRebasing: false, conflictedFiles: [] },
+};
+
+type RefreshMode = 'full' | 'status' | 'none';
+
+type State = {
+ repo?: RepositoryInfo;
+ recent: string[];
+ status: GitStatus;
+ branches: BranchInfo[];
+ remotes: RemoteInfo[];
+ history: CommitInfo[];
+ graphData: CommitGraphRow[];
+ commitFiles: CommitFile[];
+ commitFilesLoading: boolean;
+ commitFilesError?: string;
+ stashes: StashInfo[];
+ tags: TagInfo[];
+ selectedFile?: GitFileStatus;
+ selectedCommit?: CommitInfo;
+ diff?: DiffResult;
+ conflict?: ParsedConflictFile;
+ settings?: Settings;
+ settingsOpen: boolean;
+ settingsTab: string;
+ repoMgmtOpen: boolean;
+ newTabOpen: boolean;
+ rightPanelTab: 'working' | 'review';
+ console: string[];
+ problems: string[];
+ aiText: string;
+ busy: boolean;
+ refreshing: boolean;
+ runningOp: string | null;
+ historyLimit: number;
+ historyFilters: HistoryFilters;
+ openRepo: (path: string) => Promise;
+ closeRepo: () => void;
+ refresh: (silent?: boolean) => Promise;
+ refreshStatus: () => Promise;
+ loadHistory: (filters?: HistoryFilters, limit?: number) => Promise;
+ loadGraphData: (filters?: HistoryFilters, limit?: number) => Promise;
+ run: (label: string, fn: () => Promise, refreshMode?: RefreshMode, undoable?: { undo: () => Promise; redo: () => Promise }) => Promise;
+ undoStack: UndoEntry[];
+ redoStack: UndoEntry[];
+ performUndo: () => Promise;
+ performRedo: () => Promise