This change introduces Git stash support end-to-end, including a new backend command to list stashes and operations to push, apply, pop, and drop them. The frontend now fetches stashes as part of the repository bundle and provides a dedicated panel to manage shelved changes. - Add GitStash model and Tauri commands for stash operations - Implement StashPanel component and wire it into the app - Adjust sidebar layout and add stash-specific styling
2378 lines
84 KiB
Svelte
2378 lines
84 KiB
Svelte
<script lang="ts">
|
||
import { onDestroy, onMount, tick } from "svelte";
|
||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||
import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
|
||
|
||
import TitleBar from "./lib/TitleBar.svelte";
|
||
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
|
||
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
||
import CommitPanel from "./lib/components/CommitPanel.svelte";
|
||
import CompareDialog from "./lib/components/CompareDialog.svelte";
|
||
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
|
||
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
|
||
import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte";
|
||
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
||
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
|
||
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
|
||
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
||
import LinePatchDialog from "./lib/components/LinePatchDialog.svelte";
|
||
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
|
||
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
|
||
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
|
||
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
|
||
import StashPanel from "./lib/components/StashPanel.svelte";
|
||
import StatusPanel from "./lib/components/StatusPanel.svelte";
|
||
import UpdateToast from "./lib/components/UpdateToast.svelte";
|
||
|
||
import {
|
||
checkoutBranch,
|
||
commit,
|
||
commitAiGenerate,
|
||
commitAiLoad,
|
||
commitAiLocalModels,
|
||
commitAiStatus,
|
||
compareCommits,
|
||
cancelCodeSearch,
|
||
cancelFileHistory,
|
||
applyFilePatch,
|
||
createBranch,
|
||
deleteBranch,
|
||
diffFileAgainstWorkingTree,
|
||
compareFileToParent,
|
||
fetchRemote,
|
||
getStatus,
|
||
listBranches,
|
||
listStashes,
|
||
listCommits,
|
||
listFileHistory,
|
||
listRepositoryFiles,
|
||
mergeBranch,
|
||
openRepoInExplorer,
|
||
openRepositoryFile,
|
||
openRepositoryBundle,
|
||
pull,
|
||
push,
|
||
renameBranch,
|
||
getRemoteUrl,
|
||
credLoad,
|
||
credSave,
|
||
credDelete,
|
||
getFilePatch,
|
||
readConflict,
|
||
resolveConflict,
|
||
resolveConflictSide,
|
||
restoreFileFromCommit,
|
||
restoreFiles,
|
||
restoreToCommit,
|
||
searchCodeIntroductions,
|
||
setSyncBadge,
|
||
stageFiles,
|
||
stashApply,
|
||
stashDrop,
|
||
stashPop,
|
||
stashPush,
|
||
unstageFiles,
|
||
} from "./lib/git";
|
||
|
||
import type {
|
||
AiSettings,
|
||
CommitAiPhase,
|
||
ConflictFile,
|
||
ExplorerNode,
|
||
ExplorerNodeKind,
|
||
GitBranch as GitBranchInfo,
|
||
GitCommit,
|
||
GitCommitFile,
|
||
GitCommitComparison,
|
||
GitDiffFile,
|
||
GitFileStatus,
|
||
GitRepositoryFile,
|
||
GitSearchHit,
|
||
GitStash,
|
||
GitStatus,
|
||
LocalModelOption,
|
||
PatchApplyAction,
|
||
PreparedResolution,
|
||
StoredCredential,
|
||
} from "./lib/types";
|
||
|
||
import {
|
||
orgKeyFromUrl,
|
||
isCredentialExpired,
|
||
isAuthError,
|
||
stripAuthPrefix,
|
||
} from "./lib/credentials";
|
||
|
||
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
||
type AppView = "management" | "repository";
|
||
type PendingDiscard =
|
||
| { kind: "file"; file: GitFileStatus; staged: boolean }
|
||
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
|
||
|
||
interface RepoTab {
|
||
path: string;
|
||
name: string;
|
||
branch: string | null;
|
||
ahead: number;
|
||
behind: number;
|
||
changed: number;
|
||
lastOpened: number;
|
||
}
|
||
|
||
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
|
||
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
|
||
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
|
||
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
|
||
const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1";
|
||
const COMMIT_PANEL_DEFAULT_HEIGHT = 220;
|
||
const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT;
|
||
const COMMIT_PANEL_MAX_HEIGHT = 640;
|
||
const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
|
||
const HISTORY_ASIDE_MIN_WIDTH = 560;
|
||
const HISTORY_ASIDE_MAX_WIDTH = 920;
|
||
|
||
// ── State ──────────────────────────────────────────────────────────────────
|
||
|
||
let repoPath = "";
|
||
let activeRepoPath = "";
|
||
let activeView: AppView = "management";
|
||
let repoTabs: RepoTab[] = [];
|
||
let recentRepoPaths: string[] = [];
|
||
let repoSearch = "";
|
||
let status: GitStatus | null = null;
|
||
let branches: GitBranchInfo[] = [];
|
||
let stashes: GitStash[] = [];
|
||
let commits: GitCommit[] = [];
|
||
let repoFiles: GitRepositoryFile[] = [];
|
||
let selectedExplorerPath = "";
|
||
let selectedExplorerKind: ExplorerNodeKind = "file";
|
||
let expandedExplorerPaths = new Set<string>();
|
||
let expandedCommitHashes = new Set<string>();
|
||
let fileHistory: GitCommit[] = [];
|
||
let fileHistoryLoading = false;
|
||
let fileHistoryRequestId = 0;
|
||
let activeFileHistoryRequestId = "";
|
||
let lastFileHistoryHeadHash = "";
|
||
let commitMessage = "";
|
||
let lastLocalAiGeneratedMessage = "";
|
||
let commitAiPhase: CommitAiPhase = "idle";
|
||
let commitAiGenerating = false;
|
||
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
|
||
let aiSettings: AiSettings = defaultAiSettings();
|
||
let aiSettingsOpen = false;
|
||
let localModelOptions: LocalModelOption[] = [];
|
||
let errorMessage = "";
|
||
let operation = "";
|
||
let compareFrom = "";
|
||
let compareTo = "";
|
||
let comparison: GitCommitComparison | null = null;
|
||
let newBranchCommit: GitCommit | null = null;
|
||
let renameBranchTarget: GitBranchInfo | null = null;
|
||
let compareSelectOpen = false;
|
||
let compareDialogOpen = false;
|
||
let selectedDiffPath = "";
|
||
let diffHighlightQuery = "";
|
||
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
|
||
let linePatchOpen = false;
|
||
let linePatchFile: GitFileStatus | null = null;
|
||
let linePatchStaged = false;
|
||
let linePatchText = "";
|
||
let linePatchLoading = false;
|
||
let linePatchError = "";
|
||
let pendingDiscard: PendingDiscard | null = null;
|
||
let globalSearchOpen = false;
|
||
let lastSearchQuery = "";
|
||
let globalSearchResults: GitSearchHit[] = [];
|
||
let globalSearchBusy = false;
|
||
let globalSearchError = "";
|
||
let globalSearchId = "";
|
||
let resolveDialogOpen = false;
|
||
let conflictTarget = "";
|
||
let conflict: ConflictFile | null = null;
|
||
let preparedResolutions: Record<string, PreparedResolution> = {};
|
||
let autoRefreshEnabled = true;
|
||
let autoRefreshInFlight = false;
|
||
let credDialogOpen = false;
|
||
let credDialogAction: "push" | "pull" | "fetch" | null = null;
|
||
let credDialogError = "";
|
||
let credDialogKey: string | null = null;
|
||
let lastStatusFingerprint = "";
|
||
const AUTO_REFRESH_INTERVAL = 4000;
|
||
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
|
||
const BACKGROUND_FETCH_INTERVAL = 180_000;
|
||
const BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS = 30_000;
|
||
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
|
||
let backgroundFetchInFlight = false;
|
||
let lastRepoSwitchAt = 0;
|
||
let updateToastOpen = false;
|
||
let updateToastState: UpdateToastState = "available";
|
||
let pendingUpdate: Update | null = null;
|
||
let updateVersion = "";
|
||
let updateCurrentVersion = "";
|
||
let updateProgress = 0;
|
||
let updateError = "";
|
||
let updateCheckInFlight = false;
|
||
let updateDownloadTotal = 0;
|
||
let updateDownloadedBytes = 0;
|
||
let commitPanelHeight = loadCommitPanelHeight();
|
||
let resizingCommitPanel = false;
|
||
let resizeStartY = 0;
|
||
let resizeStartHeight = 0;
|
||
let historyAsideWidth = loadHistoryAsideWidth();
|
||
let resizingHistoryAside = false;
|
||
let historyResizeStartX = 0;
|
||
let historyResizeStartWidth = 0;
|
||
|
||
// ── Derived ────────────────────────────────────────────────────────────────
|
||
|
||
$: isBusy = operation.length > 0;
|
||
$: hasRepository = activeRepoPath.length > 0 && status !== null;
|
||
$: workspaceActive = activeView === "repository" && hasRepository;
|
||
$: openingRepo = operation === "Opening repository";
|
||
$: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? "";
|
||
$: changedFiles = status?.files ?? [];
|
||
$: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0;
|
||
$: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0;
|
||
$: conflictedFiles = changedFiles.filter((f) => f.staged === "conflicted" || f.unstaged === "conflicted");
|
||
$: hasConflicts = conflictedFiles.length > 0;
|
||
$: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !isBusy;
|
||
$: commitBlockReason = hasConflicts
|
||
? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "merge conflict must" : "merge conflicts must"} be resolved before committing.`
|
||
: "";
|
||
$: canCompare = hasRepository && compareFrom.length > 0 && compareTo.length > 0 && compareFrom !== compareTo && !isBusy;
|
||
$: localBranches = branches.filter((b) => !b.remote);
|
||
$: localBranchNames = localBranches.map((b) => b.name);
|
||
$: remoteBranches = branches.filter((b) => b.remote);
|
||
$: repoSearchTerm = repoSearch.trim().toLowerCase();
|
||
$: openRepoRows = repoTabs.filter(repoMatchesSearch);
|
||
$: recentRepoRows = recentRepoPaths
|
||
.filter((path) => !repoTabs.some((tab) => sameRepoPath(tab.path, path)))
|
||
.map(repoRowFromPath)
|
||
.filter(repoMatchesSearch);
|
||
$: allRepoRows = uniqueRepoPaths([...repoTabs.map((tab) => tab.path), ...recentRepoPaths])
|
||
.map(repoRowFromPath)
|
||
.filter(repoMatchesSearch);
|
||
|
||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||
|
||
onMount(() => {
|
||
loadRepoLists();
|
||
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
|
||
backgroundFetchTimer = setInterval(() => { void backgroundFetchTick(); }, BACKGROUND_FETCH_INTERVAL);
|
||
void checkForUpdates();
|
||
void initCommitAi();
|
||
});
|
||
|
||
onDestroy(() => {
|
||
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
||
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
|
||
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
|
||
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
|
||
});
|
||
|
||
// ── Auto-refresh ───────────────────────────────────────────────────────────
|
||
|
||
function statusFingerprint(value: GitStatus): string {
|
||
return JSON.stringify({ branch: value.current_branch, upstream: value.upstream, ahead: value.ahead, behind: value.behind, files: value.files });
|
||
}
|
||
|
||
// Silent background fetch (every 180s): only updates the local remote-tracking ref so
|
||
// ahead/behind (and the taskbar badge) stay accurate without the user pulling manually.
|
||
// Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/
|
||
// Push buttons instead, not as a background popup.
|
||
async function backgroundFetchTick() {
|
||
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || backgroundFetchInFlight) return;
|
||
if (Date.now() - lastRepoSwitchAt < BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) return;
|
||
backgroundFetchInFlight = true;
|
||
try {
|
||
await fetchRemote(activeRepoPath);
|
||
} catch {
|
||
// ignore — see comment above
|
||
} finally {
|
||
backgroundFetchInFlight = false;
|
||
}
|
||
}
|
||
|
||
async function autoRefreshTick() {
|
||
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
|
||
autoRefreshInFlight = true;
|
||
try {
|
||
// Cheap fast path: only fetch status; skip the heavy reload if nothing changed.
|
||
const nextStatus = await getStatus(activeRepoPath);
|
||
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
|
||
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 refreshStashes(activeRepoPath, bundle.stashes);
|
||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||
// 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 ──────────────────────────────────────────────────────────────
|
||
|
||
function stopCommitAiPolling() {
|
||
if (commitAiPollTimer) { clearInterval(commitAiPollTimer); commitAiPollTimer = undefined; }
|
||
}
|
||
|
||
async function pollCommitAiStatus() {
|
||
try {
|
||
const result = await commitAiStatus();
|
||
commitAiPhase = result.phase;
|
||
} catch { /* ignore transient errors */ }
|
||
if (commitAiPhase === "ready" || commitAiPhase === "error") stopCommitAiPolling();
|
||
}
|
||
|
||
function startCommitAiPolling() {
|
||
// Only the local model has a download/load phase worth polling — cloud providers are
|
||
// plain API calls with nothing to wait for.
|
||
stopCommitAiPolling();
|
||
if (aiSettings.provider !== "local") return;
|
||
void pollCommitAiStatus();
|
||
commitAiPollTimer = setInterval(() => { void pollCommitAiStatus(); }, 2000);
|
||
}
|
||
|
||
async function initCommitAi() {
|
||
aiSettings = loadAiSettings();
|
||
try {
|
||
localModelOptions = await commitAiLocalModels();
|
||
} catch { /* AI features stay disabled if this fails; not fatal to the app */ }
|
||
if (aiSettings.provider === "local") {
|
||
try { await commitAiLoad(aiSettings.localModelId); } catch { /* surfaced via status polling */ }
|
||
}
|
||
startCommitAiPolling();
|
||
}
|
||
|
||
function saveAiSettings(next: AiSettings) {
|
||
const modelChanged = next.provider === "local" && next.localModelId !== aiSettings.localModelId;
|
||
aiSettings = next;
|
||
persistAiSettings(next);
|
||
aiSettingsOpen = false;
|
||
if (next.provider === "local" && (modelChanged || commitAiPhase === "idle")) {
|
||
commitAiPhase = "idle";
|
||
void commitAiLoad(next.localModelId);
|
||
}
|
||
startCommitAiPolling();
|
||
}
|
||
|
||
function updateCommitMessage(message: string) {
|
||
commitMessage = message;
|
||
if (message !== lastLocalAiGeneratedMessage) {
|
||
lastLocalAiGeneratedMessage = "";
|
||
}
|
||
}
|
||
|
||
async function generateCommitMessageWithAi() {
|
||
if (!activeRepoPath || commitAiGenerating) return;
|
||
if (aiSettings.provider === "local" && commitAiPhase !== "ready") return;
|
||
commitAiGenerating = true;
|
||
errorMessage = "";
|
||
try {
|
||
const notes = commitMessage.trim() || undefined;
|
||
if (aiSettings.provider === "local") {
|
||
const localNotes = notes && notes !== lastLocalAiGeneratedMessage ? notes : undefined;
|
||
commitMessage = await commitAiGenerate(activeRepoPath, {
|
||
provider: "local",
|
||
notes: localNotes,
|
||
localProfile: aiSettings.localProfile,
|
||
});
|
||
lastLocalAiGeneratedMessage = commitMessage;
|
||
} else if (aiSettings.provider === "openai") {
|
||
const cred = await credLoad("ai:openai");
|
||
commitMessage = await commitAiGenerate(activeRepoPath, {
|
||
provider: "openai",
|
||
notes,
|
||
model: aiSettings.openaiModel,
|
||
apiKey: cred?.password,
|
||
});
|
||
lastLocalAiGeneratedMessage = "";
|
||
} else if (aiSettings.provider === "anthropic") {
|
||
const cred = await credLoad("ai:anthropic");
|
||
commitMessage = await commitAiGenerate(activeRepoPath, {
|
||
provider: "anthropic",
|
||
notes,
|
||
model: aiSettings.anthropicModel,
|
||
apiKey: cred?.password,
|
||
});
|
||
lastLocalAiGeneratedMessage = "";
|
||
} else {
|
||
const cred = await credLoad("ai:custom");
|
||
commitMessage = await commitAiGenerate(activeRepoPath, {
|
||
provider: "custom",
|
||
notes,
|
||
model: aiSettings.customModel,
|
||
baseUrl: aiSettings.customBaseUrl,
|
||
apiKey: cred?.password,
|
||
});
|
||
lastLocalAiGeneratedMessage = "";
|
||
}
|
||
} catch (error) {
|
||
errorMessage = errorToMessage(error);
|
||
} finally {
|
||
commitAiGenerating = false;
|
||
}
|
||
}
|
||
|
||
function toggleAutoRefresh() {
|
||
autoRefreshEnabled = !autoRefreshEnabled;
|
||
if (autoRefreshEnabled) void autoRefreshTick();
|
||
}
|
||
|
||
// ── Updates ────────────────────────────────────────────────────────────────
|
||
|
||
async function checkForUpdates() {
|
||
if (updateCheckInFlight || updateToastState === "downloading") return;
|
||
updateCheckInFlight = true;
|
||
|
||
try {
|
||
const update = await check();
|
||
if (!update) return;
|
||
|
||
if (pendingUpdate && pendingUpdate !== update) {
|
||
void pendingUpdate.close().catch(() => {});
|
||
}
|
||
|
||
pendingUpdate = update;
|
||
updateVersion = update.version;
|
||
updateCurrentVersion = update.currentVersion;
|
||
updateProgress = 0;
|
||
updateError = "";
|
||
updateToastState = "available";
|
||
updateToastOpen = true;
|
||
} catch {
|
||
// Update checks should be quiet when offline or when the endpoint is unavailable.
|
||
} finally {
|
||
updateCheckInFlight = false;
|
||
}
|
||
}
|
||
|
||
function updateDownloadProgress(event: DownloadEvent) {
|
||
if (event.event === "Started") {
|
||
updateProgress = 0;
|
||
updateDownloadTotal = event.data.contentLength ?? 0;
|
||
updateDownloadedBytes = 0;
|
||
return;
|
||
}
|
||
|
||
if (event.event === "Progress") {
|
||
updateDownloadedBytes += event.data.chunkLength;
|
||
updateProgress = updateDownloadTotal > 0
|
||
? Math.min(99, Math.round((updateDownloadedBytes / updateDownloadTotal) * 100))
|
||
: 0;
|
||
return;
|
||
}
|
||
|
||
updateProgress = 100;
|
||
}
|
||
|
||
async function installPendingUpdate() {
|
||
if (!pendingUpdate || updateToastState === "downloading") return;
|
||
|
||
updateToastState = "downloading";
|
||
updateProgress = 0;
|
||
updateError = "";
|
||
updateToastOpen = true;
|
||
updateDownloadTotal = 0;
|
||
updateDownloadedBytes = 0;
|
||
|
||
try {
|
||
await pendingUpdate.downloadAndInstall(updateDownloadProgress);
|
||
updateProgress = 100;
|
||
updateToastState = "installed";
|
||
} catch (error) {
|
||
updateToastState = "error";
|
||
updateError = errorToMessage(error);
|
||
}
|
||
}
|
||
|
||
function dismissUpdateToast() {
|
||
if (updateToastState === "downloading") return;
|
||
updateToastOpen = false;
|
||
}
|
||
|
||
// ── Utilities ──────────────────────────────────────────────────────────────
|
||
|
||
function repoNameFromPath(path: string): string {
|
||
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
|
||
}
|
||
|
||
function repoKey(path: string): string {
|
||
return path.replace(/\\/g, "/").trim().toLowerCase();
|
||
}
|
||
|
||
function sameRepoPath(left: string, right: string): boolean {
|
||
return repoKey(left) === repoKey(right);
|
||
}
|
||
|
||
function uniqueRepoPaths(paths: string[]): string[] {
|
||
const seen = new Set<string>();
|
||
const result: string[] = [];
|
||
for (const path of paths) {
|
||
const trimmed = path.trim();
|
||
if (!trimmed) continue;
|
||
const key = repoKey(trimmed);
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
result.push(trimmed);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function repoRowFromPath(path: string): RepoTab {
|
||
return repoTabs.find((tab) => sameRepoPath(tab.path, path)) ?? {
|
||
path,
|
||
name: repoNameFromPath(path),
|
||
branch: null,
|
||
ahead: 0,
|
||
behind: 0,
|
||
changed: 0,
|
||
lastOpened: 0,
|
||
};
|
||
}
|
||
|
||
function repoMatchesSearch(repo: RepoTab): boolean {
|
||
if (!repoSearchTerm) return true;
|
||
return repo.name.toLowerCase().includes(repoSearchTerm)
|
||
|| repo.path.toLowerCase().includes(repoSearchTerm)
|
||
|| (repo.branch ?? "").toLowerCase().includes(repoSearchTerm);
|
||
}
|
||
|
||
function loadRepoLists() {
|
||
try {
|
||
const openValue = JSON.parse(localStorage.getItem(OPEN_REPOS_KEY) ?? "[]") as unknown;
|
||
const recentValue = JSON.parse(localStorage.getItem(RECENT_REPOS_KEY) ?? "[]") as unknown;
|
||
const openPaths = Array.isArray(openValue)
|
||
? openValue.map((item) => typeof item === "string" ? item : "").filter(Boolean)
|
||
: [];
|
||
const recentPaths = Array.isArray(recentValue)
|
||
? recentValue.map((item) => typeof item === "string" ? item : "").filter(Boolean)
|
||
: [];
|
||
|
||
repoTabs = uniqueRepoPaths(openPaths).map((path) => ({
|
||
path,
|
||
name: repoNameFromPath(path),
|
||
branch: null,
|
||
ahead: 0,
|
||
behind: 0,
|
||
changed: 0,
|
||
lastOpened: 0,
|
||
}));
|
||
recentRepoPaths = uniqueRepoPaths([...recentPaths, ...openPaths]);
|
||
} catch {
|
||
repoTabs = [];
|
||
recentRepoPaths = [];
|
||
}
|
||
}
|
||
|
||
function persistRepoLists() {
|
||
try {
|
||
localStorage.setItem(OPEN_REPOS_KEY, JSON.stringify(repoTabs.map((tab) => tab.path)));
|
||
localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(recentRepoPaths));
|
||
} catch {
|
||
// Local storage is best-effort only; the Git workflow must keep working without it.
|
||
}
|
||
}
|
||
|
||
function defaultAiSettings(): AiSettings {
|
||
return {
|
||
provider: "openai",
|
||
localModelId: "qwen2.5-0.5b",
|
||
localProfile: "fast",
|
||
openaiModel: "gpt-4o-mini",
|
||
anthropicModel: "claude-3-5-haiku-latest",
|
||
customBaseUrl: "",
|
||
customModel: "",
|
||
};
|
||
}
|
||
|
||
function loadAiSettings(): AiSettings {
|
||
try {
|
||
const stored = JSON.parse(localStorage.getItem(AI_SETTINGS_KEY) ?? "null") as unknown;
|
||
if (stored && typeof stored === "object") {
|
||
const merged = { ...defaultAiSettings(), ...(stored as Partial<AiSettings>) };
|
||
// Local AI is still in development and disabled in the settings UI — migrate any
|
||
// previously saved selection away from it so nobody gets stuck on a dead option.
|
||
if (merged.provider === "local") merged.provider = "openai";
|
||
return merged;
|
||
}
|
||
} catch {
|
||
// Fall through to defaults below.
|
||
}
|
||
return defaultAiSettings();
|
||
}
|
||
|
||
function persistAiSettings(next: AiSettings) {
|
||
try {
|
||
localStorage.setItem(AI_SETTINGS_KEY, JSON.stringify(next));
|
||
} catch {
|
||
// Local storage is best-effort only; AI generation must keep working without it.
|
||
}
|
||
}
|
||
|
||
function clampCommitPanelHeight(value: number): number {
|
||
return Math.min(COMMIT_PANEL_MAX_HEIGHT, Math.max(COMMIT_PANEL_MIN_HEIGHT, Math.round(value)));
|
||
}
|
||
|
||
function loadCommitPanelHeight(): number {
|
||
try {
|
||
const stored = Number(localStorage.getItem(COMMIT_PANEL_HEIGHT_KEY));
|
||
if (Number.isFinite(stored) && stored > 0) return clampCommitPanelHeight(stored);
|
||
} catch {
|
||
// Fall through to the default below.
|
||
}
|
||
return COMMIT_PANEL_DEFAULT_HEIGHT;
|
||
}
|
||
|
||
function persistCommitPanelHeight(value: number) {
|
||
try {
|
||
localStorage.setItem(COMMIT_PANEL_HEIGHT_KEY, String(value));
|
||
} catch {
|
||
// Local storage is best-effort only; resizing must keep working without it.
|
||
}
|
||
}
|
||
|
||
function clampHistoryAsideWidth(value: number): number {
|
||
return Math.min(HISTORY_ASIDE_MAX_WIDTH, Math.max(HISTORY_ASIDE_MIN_WIDTH, Math.round(value)));
|
||
}
|
||
|
||
function loadHistoryAsideWidth(): number {
|
||
try {
|
||
const stored = Number(localStorage.getItem(HISTORY_ASIDE_WIDTH_KEY));
|
||
if (Number.isFinite(stored) && stored > 0) return clampHistoryAsideWidth(stored);
|
||
} catch {
|
||
// Fall through to the default below.
|
||
}
|
||
return HISTORY_ASIDE_DEFAULT_WIDTH;
|
||
}
|
||
|
||
function persistHistoryAsideWidth(value: number) {
|
||
try {
|
||
localStorage.setItem(HISTORY_ASIDE_WIDTH_KEY, String(value));
|
||
} catch {
|
||
// Local storage is best-effort only; resizing must keep working without it.
|
||
}
|
||
}
|
||
|
||
function startCommitPanelResize(event: PointerEvent) {
|
||
event.preventDefault();
|
||
resizingCommitPanel = true;
|
||
resizeStartY = event.clientY;
|
||
resizeStartHeight = commitPanelHeight;
|
||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||
}
|
||
|
||
function onCommitPanelResizeMove(event: PointerEvent) {
|
||
if (!resizingCommitPanel) return;
|
||
commitPanelHeight = clampCommitPanelHeight(resizeStartHeight + (resizeStartY - event.clientY));
|
||
}
|
||
|
||
function endCommitPanelResize(event: PointerEvent) {
|
||
if (!resizingCommitPanel) return;
|
||
resizingCommitPanel = false;
|
||
persistCommitPanelHeight(commitPanelHeight);
|
||
const target = event.currentTarget as HTMLElement;
|
||
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
||
}
|
||
|
||
function onCommitPanelResizeKeydown(event: KeyboardEvent) {
|
||
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
||
event.preventDefault();
|
||
commitPanelHeight = clampCommitPanelHeight(commitPanelHeight + (event.key === "ArrowUp" ? 20 : -20));
|
||
persistCommitPanelHeight(commitPanelHeight);
|
||
}
|
||
|
||
function startHistoryAsideResize(event: PointerEvent) {
|
||
event.preventDefault();
|
||
resizingHistoryAside = true;
|
||
historyResizeStartX = event.clientX;
|
||
historyResizeStartWidth = historyAsideWidth;
|
||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||
}
|
||
|
||
function onHistoryAsideResizeMove(event: PointerEvent) {
|
||
if (!resizingHistoryAside) return;
|
||
historyAsideWidth = clampHistoryAsideWidth(historyResizeStartWidth + (historyResizeStartX - event.clientX));
|
||
}
|
||
|
||
function endHistoryAsideResize(event: PointerEvent) {
|
||
if (!resizingHistoryAside) return;
|
||
resizingHistoryAside = false;
|
||
persistHistoryAsideWidth(historyAsideWidth);
|
||
const target = event.currentTarget as HTMLElement;
|
||
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
||
}
|
||
|
||
function onHistoryAsideResizeKeydown(event: KeyboardEvent) {
|
||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||
event.preventDefault();
|
||
historyAsideWidth = clampHistoryAsideWidth(historyAsideWidth + (event.key === "ArrowLeft" ? 24 : -24));
|
||
persistHistoryAsideWidth(historyAsideWidth);
|
||
}
|
||
|
||
function rememberRecentRepo(path: string) {
|
||
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
|
||
persistRepoLists();
|
||
}
|
||
|
||
function upsertRepoTab(path: string, nextStatus?: GitStatus | null) {
|
||
const existing = repoTabs.find((tab) => sameRepoPath(tab.path, path));
|
||
const next: RepoTab = {
|
||
path,
|
||
name: repoNameFromPath(path),
|
||
branch: nextStatus?.current_branch ?? existing?.branch ?? null,
|
||
ahead: nextStatus?.ahead ?? existing?.ahead ?? 0,
|
||
behind: nextStatus?.behind ?? existing?.behind ?? 0,
|
||
changed: nextStatus?.files.length ?? existing?.changed ?? 0,
|
||
lastOpened: Date.now(),
|
||
};
|
||
|
||
repoTabs = existing
|
||
? repoTabs.map((tab) => sameRepoPath(tab.path, path) ? next : tab)
|
||
: [...repoTabs, next];
|
||
rememberRecentRepo(path);
|
||
}
|
||
|
||
function resetRepositoryState(clearActive = false) {
|
||
if (clearActive) {
|
||
activeRepoPath = "";
|
||
repoPath = "";
|
||
status = null;
|
||
lastStatusFingerprint = "";
|
||
void setSyncBadge(0, 0, 0).catch(() => {});
|
||
}
|
||
branches = [];
|
||
stashes = [];
|
||
commits = [];
|
||
lastFileHistoryHeadHash = "";
|
||
repoFiles = [];
|
||
selectedExplorerPath = "";
|
||
selectedExplorerKind = "file";
|
||
expandedExplorerPaths = new Set();
|
||
expandedCommitHashes = new Set();
|
||
fileHistory = [];
|
||
compareFrom = "";
|
||
compareTo = "";
|
||
comparison = null;
|
||
compareSelectOpen = false;
|
||
compareDialogOpen = false;
|
||
selectedDiffPath = "";
|
||
pendingRestoreFile = null;
|
||
newBranchCommit = null;
|
||
globalSearchResults = [];
|
||
globalSearchOpen = false;
|
||
globalSearchError = "";
|
||
resolveDialogOpen = false;
|
||
conflictTarget = "";
|
||
conflict = null;
|
||
preparedResolutions = {};
|
||
}
|
||
|
||
function applyStatus(nextStatus: GitStatus) {
|
||
status = nextStatus;
|
||
activeRepoPath = nextStatus.repo_path || activeRepoPath || repoPath.trim();
|
||
repoPath = activeRepoPath;
|
||
lastStatusFingerprint = statusFingerprint(nextStatus);
|
||
upsertRepoTab(activeRepoPath, nextStatus);
|
||
void setSyncBadge(nextStatus.ahead, nextStatus.behind, nextStatus.files.length).catch(() => {});
|
||
}
|
||
|
||
function errorToMessage(error: unknown): string {
|
||
if (error instanceof Error) return error.message;
|
||
if (typeof error === "string") return error;
|
||
try { return JSON.stringify(error) ?? "Unknown error"; } catch { return "Unknown error"; }
|
||
}
|
||
|
||
function isNonFastForwardPushError(message: string): boolean {
|
||
const value = message.toLowerCase();
|
||
return value.includes("non-fast-forward")
|
||
|| value.includes("failed to push some refs")
|
||
|| value.includes("tip of your current branch is behind")
|
||
|| value.includes("fetch first");
|
||
}
|
||
|
||
function statusHasConflicts(value: GitStatus | null): boolean {
|
||
return (value?.files ?? []).some((file) => file.staged === "conflicted" || file.unstaged === "conflicted");
|
||
}
|
||
|
||
async function runOperation(label: string, task: () => Promise<void>) {
|
||
if (isBusy) return;
|
||
operation = label;
|
||
errorMessage = "";
|
||
try { await task(); } catch (error) { errorMessage = errorToMessage(error); } finally { operation = ""; }
|
||
}
|
||
|
||
function normalizeExplorerPath(path: string): string {
|
||
return path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||
}
|
||
|
||
function explorerPathExists(files: GitRepositoryFile[], path: string): boolean {
|
||
if (!path) return false;
|
||
const normalized = normalizeExplorerPath(path);
|
||
return files.some((f) => {
|
||
const fp = normalizeExplorerPath(f.path);
|
||
return fp === normalized || fp.startsWith(`${normalized}/`);
|
||
});
|
||
}
|
||
|
||
function allExplorerFolderPaths(files: GitRepositoryFile[]): Set<string> {
|
||
const folders = new Set<string>();
|
||
for (const file of files) {
|
||
const parts = file.path.split(/[\\/]+/).filter(Boolean);
|
||
let currentPath = "";
|
||
for (let index = 0; index < parts.length - 1; index++) {
|
||
currentPath = currentPath ? `${currentPath}/${parts[index]}` : parts[index];
|
||
folders.add(currentPath);
|
||
}
|
||
}
|
||
return folders;
|
||
}
|
||
|
||
// ── Refresh helpers ────────────────────────────────────────────────────────
|
||
|
||
async function refreshBranchList(path = activeRepoPath, prefetched?: GitBranchInfo[]) {
|
||
branches = prefetched ?? (await listBranches(path));
|
||
}
|
||
|
||
async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) {
|
||
stashes = prefetched ?? (await listStashes(path));
|
||
}
|
||
|
||
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 = "";
|
||
if (comparison && comparison.to_hash.length > 0 && (!hashes.has(comparison.from_hash) || !hashes.has(comparison.to_hash))) {
|
||
comparison = null;
|
||
compareDialogOpen = false;
|
||
selectedDiffPath = "";
|
||
pendingRestoreFile = null;
|
||
}
|
||
}
|
||
|
||
async function refreshExplorerFiles(path = activeRepoPath, prefetched?: GitRepositoryFile[]) {
|
||
repoFiles = prefetched ?? (await listRepositoryFiles(path));
|
||
const folderPaths = allExplorerFolderPaths(repoFiles);
|
||
expandedExplorerPaths = new Set([...expandedExplorerPaths].filter((folder) => folderPaths.has(folder)));
|
||
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
|
||
selectedExplorerPath = "";
|
||
selectedExplorerKind = "file";
|
||
cancelActiveFileHistoryLoad();
|
||
activeFileHistoryRequestId = "";
|
||
fileHistoryLoading = false;
|
||
fileHistory = [];
|
||
}
|
||
}
|
||
|
||
function isCancellationMessage(message: string): boolean {
|
||
return message.toLowerCase().includes("cancelled");
|
||
}
|
||
|
||
function cancelActiveFileHistoryLoad() {
|
||
const requestId = activeFileHistoryRequestId;
|
||
if (!requestId) return;
|
||
void cancelFileHistory(requestId).catch(() => {});
|
||
}
|
||
|
||
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) {
|
||
const requestId = ++fileHistoryRequestId;
|
||
cancelActiveFileHistoryLoad();
|
||
|
||
if (!path || !file) {
|
||
activeFileHistoryRequestId = "";
|
||
fileHistoryLoading = false;
|
||
fileHistory = [];
|
||
return;
|
||
}
|
||
|
||
const historyRequestId = `file-history-${requestId}-${Date.now()}`;
|
||
activeFileHistoryRequestId = historyRequestId;
|
||
fileHistoryLoading = true;
|
||
fileHistory = [];
|
||
|
||
try {
|
||
const history = await listFileHistory(path, file, 100, historyRequestId);
|
||
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
|
||
fileHistory = history;
|
||
}
|
||
} catch (error) {
|
||
const message = errorToMessage(error);
|
||
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
|
||
fileHistory = [];
|
||
if (!isCancellationMessage(message)) errorMessage = message;
|
||
}
|
||
} finally {
|
||
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
|
||
activeFileHistoryRequestId = "";
|
||
fileHistoryLoading = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Repository operations ──────────────────────────────────────────────────
|
||
|
||
async function openRepo(pathOverride?: string) {
|
||
const path = (pathOverride ?? repoPath).trim();
|
||
if (!path) { errorMessage = "Enter a repository path."; return; }
|
||
repoPath = path;
|
||
|
||
await runOperation("Opening repository", async () => {
|
||
// Paint the loading overlay before the (potentially slow) git enumeration
|
||
// starts — otherwise the first paint is deferred until the bundle resolves
|
||
// and the overlay appears to "come late".
|
||
await tick();
|
||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||
// Single backend round-trip: resolves the repo and reads status, branches,
|
||
// commits and files in one pass instead of four sequential git calls.
|
||
const bundle = await openRepositoryBundle(path, 100);
|
||
resetRepositoryState(false);
|
||
applyStatus(bundle.status);
|
||
if (globalSearchBusy) void cancelGlobalSearch();
|
||
activeView = "repository";
|
||
await refreshBranchList(activeRepoPath, bundle.branches);
|
||
await refreshStashes(activeRepoPath, bundle.stashes);
|
||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||
lastRepoSwitchAt = Date.now();
|
||
});
|
||
}
|
||
|
||
async function chooseRepositoryFolder() {
|
||
if (isBusy) return;
|
||
try {
|
||
const selected = await openDialog({
|
||
title: "Select repository folder",
|
||
directory: true,
|
||
multiple: false,
|
||
defaultPath: repoPath.trim() || activeRepoPath || undefined,
|
||
});
|
||
if (typeof selected !== "string") return;
|
||
repoPath = selected;
|
||
await openRepo(selected);
|
||
} catch (error) {
|
||
errorMessage = errorToMessage(error);
|
||
}
|
||
}
|
||
|
||
function openRepoManagement() {
|
||
if (isBusy) return;
|
||
activeView = "management";
|
||
}
|
||
|
||
async function selectRepoTab(path: string) {
|
||
if (isBusy) return;
|
||
if (activeView === "repository" && sameRepoPath(activeRepoPath, path)) return;
|
||
await openRepo(path);
|
||
}
|
||
|
||
async function closeRepoTab(path: string, event?: MouseEvent) {
|
||
event?.stopPropagation();
|
||
if (isBusy) return;
|
||
|
||
const index = repoTabs.findIndex((tab) => sameRepoPath(tab.path, path));
|
||
const remaining = repoTabs.filter((tab) => !sameRepoPath(tab.path, path));
|
||
const next = remaining[index] ?? remaining[index - 1] ?? null;
|
||
repoTabs = remaining;
|
||
persistRepoLists();
|
||
|
||
if (!sameRepoPath(activeRepoPath, path)) return;
|
||
if (next) {
|
||
await openRepo(next.path);
|
||
} else {
|
||
resetRepositoryState(true);
|
||
activeView = "management";
|
||
}
|
||
}
|
||
|
||
async function removeRepoFromManagement(path: string, event?: MouseEvent) {
|
||
event?.stopPropagation();
|
||
if (isBusy) return;
|
||
recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path));
|
||
persistRepoLists();
|
||
if (repoTabs.some((tab) => sameRepoPath(tab.path, path))) {
|
||
await closeRepoTab(path);
|
||
}
|
||
}
|
||
|
||
async function openActiveRepoInExplorer() {
|
||
if (!activeRepoPath || isBusy) return;
|
||
try {
|
||
await openRepoInExplorer(activeRepoPath);
|
||
} catch (error) {
|
||
errorMessage = errorToMessage(error);
|
||
}
|
||
}
|
||
|
||
async function refreshRepo() {
|
||
if (!activeRepoPath) { await openRepo(); return; }
|
||
await runOperation("Refreshing", async () => {
|
||
applyStatus(await getStatus(activeRepoPath));
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshStashes(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
async function checkout(branch: GitBranchInfo) {
|
||
if (!activeRepoPath || branch.current) return;
|
||
await runOperation(`Checking out ${branch.name}`, async () => {
|
||
applyStatus(await checkoutBranch(activeRepoPath, branch.name));
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
async function createNewBranch(branchName: string) {
|
||
const name = branchName.trim();
|
||
if (!activeRepoPath || !name) return;
|
||
await runOperation(`Creating ${name}`, async () => {
|
||
applyStatus(await createBranch(activeRepoPath, name));
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
function renameLocalBranch(branch: GitBranchInfo) {
|
||
if (!activeRepoPath || branch.remote) return;
|
||
renameBranchTarget = branch;
|
||
}
|
||
|
||
async function submitRenameBranch(branchName: string) {
|
||
const branch = renameBranchTarget;
|
||
const name = branchName.trim();
|
||
if (!activeRepoPath || !branch || branch.remote || !name || name === branch.name) return;
|
||
|
||
await runOperation(`Renaming ${branch.name}`, async () => {
|
||
applyStatus(await renameBranch(activeRepoPath, branch.name, name));
|
||
renameBranchTarget = null;
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
async function deleteLocalBranch(branch: GitBranchInfo) {
|
||
if (!activeRepoPath || branch.remote) return;
|
||
if (branch.current) {
|
||
errorMessage = "The current branch cannot be deleted.";
|
||
return;
|
||
}
|
||
|
||
const confirmed = window.confirm(`Delete local branch "${branch.name}"?\n\nGit will refuse if the branch has unmerged changes.`);
|
||
if (!confirmed) return;
|
||
|
||
await runOperation(`Deleting ${branch.name}`, async () => {
|
||
applyStatus(await deleteBranch(activeRepoPath, branch.name));
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
function openNewBranchDialog(commit: GitCommit) {
|
||
if (!activeRepoPath || isBusy) return;
|
||
newBranchCommit = commit;
|
||
}
|
||
|
||
async function createBranchFromCommit(branchName: string) {
|
||
const target = newBranchCommit;
|
||
const name = branchName.trim();
|
||
if (!activeRepoPath || !target || !name) return;
|
||
await runOperation(`Creating ${name}`, async () => {
|
||
applyStatus(await createBranch(activeRepoPath, name, target.hash));
|
||
newBranchCommit = null;
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
async function merge(branch: GitBranchInfo) {
|
||
if (!activeRepoPath || branch.current) return;
|
||
await runOperation(`Merging ${branch.name}`, async () => {
|
||
applyStatus(await mergeBranch(activeRepoPath, branch.name));
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
// Resolve the keychain key (host/org) for the active repo's remote.
|
||
async function currentCredKey(): Promise<string | null> {
|
||
if (!activeRepoPath) return null;
|
||
try {
|
||
const url = await getRemoteUrl(activeRepoPath);
|
||
return url ? orgKeyFromUrl(url) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function loadStoredCredential(key: string | null): Promise<StoredCredential | null> {
|
||
if (!key) return null;
|
||
try {
|
||
return await credLoad(key);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function openCredentialDialog(action: "push" | "pull" | "fetch", key?: string | null) {
|
||
if (!activeRepoPath) return;
|
||
credDialogError = "";
|
||
credDialogAction = action;
|
||
credDialogKey = key === undefined ? await currentCredKey() : key;
|
||
credDialogOpen = true;
|
||
}
|
||
|
||
// Post-process a pull/push result: surface errors, and on rejected/expired
|
||
// credentials drop the stored entry and re-open the login dialog.
|
||
function handleRemoteResult(action: "push" | "pull" | "fetch", key: string | null, fromStore: boolean) {
|
||
if (!errorMessage) {
|
||
credDialogOpen = false;
|
||
credDialogAction = null;
|
||
return;
|
||
}
|
||
const auth = isAuthError(errorMessage);
|
||
const message = stripAuthPrefix(errorMessage);
|
||
errorMessage = "";
|
||
|
||
if (fromStore) {
|
||
if (auth) {
|
||
if (key) void credDelete(key).catch(() => {});
|
||
credDialogError =
|
||
"Credentials were rejected or have expired. Please sign in again.";
|
||
credDialogAction = action;
|
||
credDialogKey = key;
|
||
credDialogOpen = true;
|
||
} else {
|
||
// Non-auth failure (e.g. network) – keep the stored credential, show it inline.
|
||
errorMessage = message;
|
||
}
|
||
} else {
|
||
credDialogError = message || "Sign-in failed.";
|
||
}
|
||
}
|
||
|
||
async function doActualPull(
|
||
username: string,
|
||
password: string,
|
||
key: string | null,
|
||
fromStore: boolean,
|
||
) {
|
||
errorMessage = "";
|
||
await runOperation("Pulling", async () => {
|
||
applyStatus(await pull(activeRepoPath, username, password));
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
handleRemoteResult("pull", key, fromStore);
|
||
}
|
||
|
||
async function doActualFetch(
|
||
username: string,
|
||
password: string,
|
||
key: string | null,
|
||
fromStore: boolean,
|
||
) {
|
||
errorMessage = "";
|
||
await runOperation("Fetching", async () => {
|
||
applyStatus(await fetchRemote(activeRepoPath, username, password));
|
||
});
|
||
handleRemoteResult("fetch", key, fromStore);
|
||
}
|
||
|
||
async function doActualPush(
|
||
username: string,
|
||
password: string,
|
||
key: string | null,
|
||
fromStore: boolean,
|
||
) {
|
||
errorMessage = "";
|
||
await runOperation("Pushing", async () => {
|
||
applyStatus(await push(activeRepoPath, username, password));
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
|
||
if (errorMessage && !isAuthError(errorMessage) && isNonFastForwardPushError(errorMessage)) {
|
||
errorMessage = "";
|
||
const shouldSync = window.confirm(
|
||
"The remote has newer commits, so the push was rejected.\n\nRun Pull/Merge now and try pushing again afterwards?",
|
||
);
|
||
|
||
if (!shouldSync) {
|
||
const message = "Push rejected: the remote has newer commits. Pull first, then push again.";
|
||
if (fromStore) errorMessage = message;
|
||
else credDialogError = message;
|
||
return;
|
||
}
|
||
|
||
if (!fromStore) credDialogError = "";
|
||
|
||
await runOperation("Pulling before push", async () => {
|
||
applyStatus(await pull(activeRepoPath, username, password));
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
|
||
if (errorMessage) {
|
||
handleRemoteResult("pull", key, fromStore);
|
||
return;
|
||
}
|
||
|
||
if (statusHasConflicts(status)) {
|
||
credDialogOpen = false;
|
||
credDialogAction = null;
|
||
errorMessage = "Pull produced merge conflicts. Resolve the conflicts, commit the merge, and then push again.";
|
||
return;
|
||
}
|
||
|
||
await runOperation("Pushing after pull", async () => {
|
||
applyStatus(await push(activeRepoPath, username, password));
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
handleRemoteResult("push", key, fromStore);
|
||
}
|
||
|
||
async function handleCredentialSubmit(
|
||
username: string,
|
||
password: string,
|
||
save: boolean,
|
||
expiresAt: string | null,
|
||
) {
|
||
const key = credDialogKey;
|
||
if (credDialogAction === "pull") await doActualPull(username, password, key, false);
|
||
else if (credDialogAction === "push") await doActualPush(username, password, key, false);
|
||
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false);
|
||
|
||
// Only persist once the operation actually succeeded (dialog has closed).
|
||
if (!credDialogOpen && save && key) {
|
||
try {
|
||
await credSave(key, username, password, expiresAt);
|
||
} catch (error) {
|
||
errorMessage = errorToMessage(error);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function startRemoteAction(action: "push" | "pull" | "fetch") {
|
||
if (!activeRepoPath) return;
|
||
const key = await currentCredKey();
|
||
const stored = await loadStoredCredential(key);
|
||
|
||
if (stored && !isCredentialExpired(stored)) {
|
||
if (action === "pull") await doActualPull(stored.username, stored.password, key, true);
|
||
else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true);
|
||
else await doActualPush(stored.username, stored.password, key, true);
|
||
return;
|
||
}
|
||
|
||
// Expired entry → clean it up before prompting again.
|
||
if (stored && key) await credDelete(key).catch(() => {});
|
||
await openCredentialDialog(action, key);
|
||
}
|
||
|
||
async function fetchRepo() {
|
||
await startRemoteAction("fetch");
|
||
}
|
||
|
||
async function pullRepo() {
|
||
await startRemoteAction("pull");
|
||
}
|
||
|
||
async function pushRepo() {
|
||
await startRemoteAction("push");
|
||
}
|
||
|
||
async function saveStash(message: string, includeUntracked: boolean) {
|
||
if (!activeRepoPath || changedFiles.length === 0) return;
|
||
await runOperation("Stashing changes", async () => {
|
||
applyStatus(await stashPush(activeRepoPath, message, includeUntracked));
|
||
await refreshStashes(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
async function applyStashEntry(stash: GitStash) {
|
||
if (!activeRepoPath) return;
|
||
await runOperation(`Applying ${stash.selector}`, async () => {
|
||
applyStatus(await stashApply(activeRepoPath, stash.selector));
|
||
await refreshStashes(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
async function popStashEntry(stash: GitStash) {
|
||
if (!activeRepoPath) return;
|
||
await runOperation(`Popping ${stash.selector}`, async () => {
|
||
applyStatus(await stashPop(activeRepoPath, stash.selector));
|
||
await refreshStashes(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
async function dropStashEntry(stash: GitStash) {
|
||
if (!activeRepoPath) return;
|
||
const confirmed = window.confirm(`Delete ${stash.selector}?\n\n"${stash.message || stash.selector}"`);
|
||
if (!confirmed) return;
|
||
|
||
await runOperation(`Dropping ${stash.selector}`, async () => {
|
||
applyStatus(await stashDrop(activeRepoPath, stash.selector));
|
||
await refreshStashes(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
// ── File staging / restore ─────────────────────────────────────────────────
|
||
|
||
async function stageFile(file: GitFileStatus) {
|
||
await runOperation(`Staging ${file.path}`, async () => {
|
||
applyStatus(await stageFiles(activeRepoPath, [file.path]));
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
async function unstageFile(file: GitFileStatus) {
|
||
await runOperation(`Unstaging ${file.path}`, async () => {
|
||
applyStatus(await unstageFiles(activeRepoPath, [file.path]));
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
function discardFile(file: GitFileStatus, staged: boolean) {
|
||
if (!activeRepoPath || isBusy) return;
|
||
pendingDiscard = { kind: "file", file, staged };
|
||
}
|
||
|
||
async function runDiscardFile(file: GitFileStatus, staged: boolean) {
|
||
await runOperation(`Discarding ${file.path}`, async () => {
|
||
applyStatus(await restoreFiles(activeRepoPath, [file.path], staged));
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
async function openLinePatch(file: GitFileStatus, staged: boolean) {
|
||
if (!activeRepoPath) return;
|
||
linePatchOpen = true;
|
||
linePatchFile = file;
|
||
linePatchStaged = staged;
|
||
linePatchText = "";
|
||
linePatchError = "";
|
||
linePatchLoading = true;
|
||
|
||
try {
|
||
linePatchText = await getFilePatch(activeRepoPath, file.path, staged);
|
||
} catch (error) {
|
||
linePatchError = errorToMessage(error);
|
||
errorMessage = linePatchError;
|
||
} finally {
|
||
linePatchLoading = false;
|
||
}
|
||
}
|
||
|
||
async function refreshLinePatch() {
|
||
if (!activeRepoPath || !linePatchFile) return;
|
||
await openLinePatch(linePatchFile, linePatchStaged);
|
||
}
|
||
|
||
function closeLinePatch() {
|
||
if (isBusy) return;
|
||
linePatchOpen = false;
|
||
linePatchFile = null;
|
||
linePatchText = "";
|
||
linePatchError = "";
|
||
}
|
||
|
||
function patchOperationLabel(action: PatchApplyAction, file: GitFileStatus): string {
|
||
switch (action) {
|
||
case "stage":
|
||
return `Staging hunk in ${file.path}`;
|
||
case "unstage":
|
||
return `Unstaging hunk in ${file.path}`;
|
||
default:
|
||
return `Discarding hunk in ${file.path}`;
|
||
}
|
||
}
|
||
|
||
function isDiscardPatchAction(action: PatchApplyAction): boolean {
|
||
return action === "discard-staged" || action === "discard-unstaged";
|
||
}
|
||
|
||
async function runLinePatchAction(
|
||
action: PatchApplyAction,
|
||
patch: string,
|
||
file: GitFileStatus,
|
||
staged: boolean,
|
||
) {
|
||
if (!activeRepoPath || isBusy) return;
|
||
operation = patchOperationLabel(action, file);
|
||
errorMessage = "";
|
||
linePatchError = "";
|
||
|
||
try {
|
||
applyStatus(await applyFilePatch(activeRepoPath, file.path, patch, action));
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
|
||
const updatedPatch = await getFilePatch(activeRepoPath, file.path, staged);
|
||
if (updatedPatch.trim()) {
|
||
linePatchText = updatedPatch;
|
||
} else {
|
||
linePatchOpen = false;
|
||
linePatchFile = null;
|
||
linePatchText = "";
|
||
}
|
||
} catch (error) {
|
||
linePatchError = errorToMessage(error);
|
||
errorMessage = linePatchError;
|
||
} finally {
|
||
operation = "";
|
||
}
|
||
}
|
||
|
||
async function applyLinePatch(action: PatchApplyAction, patch: string) {
|
||
if (!activeRepoPath || !linePatchFile || isBusy) return;
|
||
const file = linePatchFile;
|
||
const staged = linePatchStaged;
|
||
|
||
if (isDiscardPatchAction(action)) {
|
||
pendingDiscard = { kind: "hunk", file, staged, action, patch };
|
||
return;
|
||
}
|
||
|
||
await runLinePatchAction(action, patch, file, staged);
|
||
}
|
||
|
||
async function confirmDiscard() {
|
||
const discard = pendingDiscard;
|
||
if (!discard || !activeRepoPath || isBusy) return;
|
||
|
||
if (discard.kind === "file") {
|
||
await runDiscardFile(discard.file, discard.staged);
|
||
} else {
|
||
await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged);
|
||
}
|
||
|
||
pendingDiscard = null;
|
||
}
|
||
|
||
function closeDiscardConfirm() {
|
||
if (isBusy) return;
|
||
pendingDiscard = null;
|
||
}
|
||
|
||
async function stageAllFiles() {
|
||
const paths = changedFiles.filter((f) => f.unstaged !== null).map((f) => f.path);
|
||
if (paths.length === 0) return;
|
||
await runOperation("Staging all", async () => {
|
||
applyStatus(await stageFiles(activeRepoPath, paths));
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
async function unstageAllFiles() {
|
||
const paths = changedFiles.filter((f) => f.staged !== null).map((f) => f.path);
|
||
if (paths.length === 0) return;
|
||
await runOperation("Unstaging all", async () => {
|
||
applyStatus(await unstageFiles(activeRepoPath, paths));
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
async function commitChanges() {
|
||
const message = commitMessage.trim();
|
||
if (!message || !activeRepoPath) return;
|
||
if (hasConflicts) {
|
||
errorMessage = "Resolve all merge conflicts before committing.";
|
||
return;
|
||
}
|
||
await runOperation("Committing", async () => {
|
||
applyStatus(await commit(activeRepoPath, message));
|
||
commitMessage = "";
|
||
lastLocalAiGeneratedMessage = "";
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
// ── Commit restore ─────────────────────────────────────────────────────────
|
||
|
||
async function restoreCommit(target: GitCommit) {
|
||
if (!activeRepoPath) return;
|
||
const confirmed = window.confirm(`Restore working tree to ${target.short_hash}?\n\nThis brings back the files from that commit as unstaged changes so you can review and commit them. No commit is removed and the branch stays where it is.`);
|
||
if (!confirmed) return;
|
||
await runOperation(`Restoring ${target.short_hash}`, async () => {
|
||
applyStatus(await restoreToCommit(activeRepoPath, target.hash));
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
async function restoreCommitFile(target: GitCommit, file: GitCommitFile): Promise<boolean> {
|
||
if (!activeRepoPath) return false;
|
||
const confirmed = window.confirm(`Restore ${file.path} from ${target.short_hash}?\n\nThis changes the file in your working tree so you can review and commit it.`);
|
||
if (!confirmed) return false;
|
||
await runOperation(`Restoring ${file.path}`, async () => {
|
||
applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, file.path));
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
return !errorMessage;
|
||
}
|
||
|
||
async function previewCommitFileFromHistory(target: GitCommit, file: GitCommitFile) {
|
||
if (!activeRepoPath) return;
|
||
await runOperation(`Diffing ${file.path}`, async () => {
|
||
const result = await compareFileToParent(activeRepoPath, target.hash, file.path, file.old_path);
|
||
const matchingFile = result.files.find((diffFile) =>
|
||
diffFile.path === file.path || diffFile.old_path === file.old_path || diffFile.old_path === file.path,
|
||
);
|
||
comparison = result;
|
||
selectedDiffPath = matchingFile?.path ?? result.files[0]?.path ?? file.path;
|
||
pendingRestoreFile = { commit: target, file };
|
||
compareDialogOpen = true;
|
||
});
|
||
}
|
||
|
||
// ── Explorer interaction ───────────────────────────────────────────────────
|
||
|
||
function toggleExplorerFolder(node: ExplorerNode) {
|
||
if (node.kind !== "folder") return;
|
||
const next = new Set(expandedExplorerPaths);
|
||
if (next.has(node.path)) next.delete(node.path); else next.add(node.path);
|
||
expandedExplorerPaths = next;
|
||
}
|
||
|
||
function expandAllExplorerFolders() {
|
||
expandedExplorerPaths = allExplorerFolderPaths(repoFiles);
|
||
}
|
||
|
||
function collapseAllExplorerFolders() {
|
||
expandedExplorerPaths = new Set();
|
||
}
|
||
|
||
function explorerParentFolders(path: string): string[] {
|
||
const parts = normalizeExplorerPath(path).split("/").filter(Boolean);
|
||
const folders: string[] = [];
|
||
let current = "";
|
||
for (let index = 0; index < parts.length - 1; index++) {
|
||
current = current ? `${current}/${parts[index]}` : parts[index];
|
||
folders.push(current);
|
||
}
|
||
return folders;
|
||
}
|
||
|
||
// Loads history for a selected explorer node without blocking the rest of the UI
|
||
// (isBusy/runOperation would disable every button in the app while this awaits).
|
||
// A request id guards against a slower, stale request overwriting a newer selection.
|
||
async function loadSelectedFileHistory(path: string, repo = activeRepoPath) {
|
||
await refreshFileHistory(repo, path);
|
||
}
|
||
|
||
async function selectExplorerNode(node: ExplorerNode) {
|
||
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
|
||
selectedExplorerPath = node.path;
|
||
selectedExplorerKind = node.kind;
|
||
void loadSelectedFileHistory(node.path);
|
||
}
|
||
|
||
async function selectFileFromSearch(file: GitRepositoryFile) {
|
||
if (!activeRepoPath) return;
|
||
selectedExplorerPath = file.path;
|
||
selectedExplorerKind = "file";
|
||
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
||
|
||
void loadSelectedFileHistory(file.path);
|
||
}
|
||
|
||
function selectFileFromStatus(file: GitFileStatus) {
|
||
if (!activeRepoPath) return;
|
||
selectedExplorerPath = file.path;
|
||
selectedExplorerKind = "file";
|
||
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
|
||
|
||
void loadSelectedFileHistory(file.path);
|
||
}
|
||
|
||
async function openFileFromExplorer(node: ExplorerNode) {
|
||
if (!activeRepoPath || node.kind !== "file") return;
|
||
selectedExplorerPath = node.path;
|
||
selectedExplorerKind = "file";
|
||
void loadSelectedFileHistory(node.path);
|
||
|
||
try {
|
||
await openRepositoryFile(activeRepoPath, node.path);
|
||
} catch (error) {
|
||
errorMessage = errorToMessage(error);
|
||
}
|
||
}
|
||
|
||
async function restoreSelectedFileFromCommit(target: GitCommit) {
|
||
if (!activeRepoPath || !selectedExplorerPath) return;
|
||
const kind = selectedExplorerKind === "folder" ? "folder" : "file";
|
||
const confirmed = window.confirm(`Restore ${kind} ${selectedExplorerPath} from ${target.short_hash}?\n\nThis changes the selected ${kind} in your working tree so you can review and commit it.`);
|
||
if (!confirmed) return;
|
||
await runOperation(`Restoring ${selectedExplorerPath}`, async () => {
|
||
applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, selectedExplorerPath));
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
// ── Compare ────────────────────────────────────────────────────────────────
|
||
|
||
function openCompareSelect() {
|
||
if (!hasRepository) return;
|
||
compareSelectOpen = true;
|
||
}
|
||
|
||
async function compareSelectedCommits() {
|
||
if (!canCompare) return;
|
||
await runOperation("Comparing commits", async () => {
|
||
const result = await compareCommits(activeRepoPath, compareFrom, compareTo);
|
||
comparison = result;
|
||
selectedDiffPath = result.files[0]?.path ?? "";
|
||
diffHighlightQuery = "";
|
||
pendingRestoreFile = null;
|
||
compareSelectOpen = false;
|
||
compareDialogOpen = true;
|
||
});
|
||
}
|
||
|
||
async function diffSelectedFileFromCommit(historyCommit: GitCommit) {
|
||
if (!activeRepoPath || !selectedExplorerPath) return;
|
||
await runOperation(`Diffing ${selectedExplorerPath}`, async () => {
|
||
const result = await diffFileAgainstWorkingTree(activeRepoPath, historyCommit.hash, selectedExplorerPath);
|
||
comparison = result;
|
||
selectedDiffPath = result.files[0]?.path ?? selectedExplorerPath;
|
||
diffHighlightQuery = "";
|
||
pendingRestoreFile = null;
|
||
compareDialogOpen = true;
|
||
});
|
||
}
|
||
|
||
async function diffSearchHit(hit: GitSearchHit) {
|
||
if (!activeRepoPath) return;
|
||
await runOperation(`Diffing ${hit.file}`, async () => {
|
||
const result = await diffFileAgainstWorkingTree(activeRepoPath, hit.commit_hash, hit.file);
|
||
comparison = result;
|
||
selectedDiffPath = result.files[0]?.path ?? hit.file;
|
||
diffHighlightQuery = lastSearchQuery;
|
||
pendingRestoreFile = null;
|
||
compareDialogOpen = true;
|
||
});
|
||
}
|
||
|
||
function closeCompareDialog() {
|
||
compareDialogOpen = false;
|
||
pendingRestoreFile = null;
|
||
}
|
||
|
||
async function restorePreviewedCommitFile() {
|
||
if (!pendingRestoreFile) return;
|
||
const restored = await restoreCommitFile(pendingRestoreFile.commit, pendingRestoreFile.file);
|
||
if (restored) closeCompareDialog();
|
||
}
|
||
|
||
function selectDiffFile(file: GitDiffFile) {
|
||
selectedDiffPath = file.path;
|
||
}
|
||
|
||
async function runGlobalSearch(query: string, caseSensitive: boolean, limit: number) {
|
||
if (!activeRepoPath || globalSearchBusy) return;
|
||
const searchId = `search-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||
globalSearchId = searchId;
|
||
lastSearchQuery = query;
|
||
globalSearchBusy = true;
|
||
globalSearchError = "";
|
||
globalSearchResults = [];
|
||
|
||
try {
|
||
const results = await searchCodeIntroductions(activeRepoPath, query, caseSensitive, limit, searchId);
|
||
if (globalSearchId === searchId) {
|
||
globalSearchResults = results;
|
||
}
|
||
} catch (error) {
|
||
if (globalSearchId === searchId) {
|
||
const message = errorToMessage(error);
|
||
globalSearchError = message.includes("cancelled") ? "Search was cancelled." : message;
|
||
}
|
||
} finally {
|
||
if (globalSearchId === searchId) {
|
||
globalSearchBusy = false;
|
||
globalSearchId = "";
|
||
}
|
||
}
|
||
}
|
||
|
||
async function cancelGlobalSearch() {
|
||
if (!globalSearchId) return;
|
||
const searchId = globalSearchId;
|
||
globalSearchError = "Requesting cancellation...";
|
||
try {
|
||
await cancelCodeSearch(searchId);
|
||
} catch (error) {
|
||
globalSearchError = errorToMessage(error);
|
||
}
|
||
}
|
||
|
||
function closeGlobalSearchDialog() {
|
||
if (globalSearchBusy) void cancelGlobalSearch();
|
||
globalSearchOpen = false;
|
||
}
|
||
|
||
// ── Conflict resolution ────────────────────────────────────────────────────
|
||
|
||
async function loadConflict(file: string) {
|
||
conflictTarget = file;
|
||
conflict = await readConflict(activeRepoPath, file);
|
||
}
|
||
|
||
async function openResolveDialog() {
|
||
if (!hasConflicts || isBusy) return;
|
||
const first = conflictedFiles[0].path;
|
||
await runOperation("Loading conflicts", async () => {
|
||
preparedResolutions = {};
|
||
resolveDialogOpen = true;
|
||
await loadConflict(first);
|
||
});
|
||
}
|
||
|
||
async function selectConflictFile(path: string) {
|
||
if (path === conflictTarget || isBusy) return;
|
||
await runOperation(`Loading ${path}`, async () => {
|
||
await loadConflict(path);
|
||
});
|
||
}
|
||
|
||
async function handleMarkResolved(path: string, resolution: PreparedResolution) {
|
||
preparedResolutions = { ...preparedResolutions, [path]: resolution };
|
||
|
||
const next = conflictedFiles.find((f) => f.path !== path && preparedResolutions[f.path] == null);
|
||
if (next) {
|
||
await runOperation(`Loading ${next.path}`, async () => {
|
||
await loadConflict(next.path);
|
||
});
|
||
}
|
||
}
|
||
|
||
async function applyPreparedResolutions() {
|
||
if (!activeRepoPath || isBusy || Object.keys(preparedResolutions).length === 0) return;
|
||
const entries = Object.entries(preparedResolutions);
|
||
await runOperation(`Resolving ${entries.length} ${entries.length === 1 ? "file" : "files"}`, async () => {
|
||
let nextStatus: GitStatus | null = null;
|
||
for (const [file, prepared] of entries) {
|
||
nextStatus = prepared.kind === "side"
|
||
? await resolveConflictSide(activeRepoPath, file, prepared.side)
|
||
: await resolveConflict(activeRepoPath, file, prepared.content);
|
||
}
|
||
preparedResolutions = {};
|
||
if (nextStatus) applyStatus(nextStatus);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
|
||
const remaining = (nextStatus?.files ?? status?.files ?? []).filter(
|
||
(f) => f.staged === "conflicted" || f.unstaged === "conflicted",
|
||
);
|
||
if (remaining.length === 0) {
|
||
resolveDialogOpen = false;
|
||
conflict = null;
|
||
conflictTarget = "";
|
||
} else {
|
||
await loadConflict(remaining[0].path);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Event handlers ─────────────────────────────────────────────────────────
|
||
|
||
function handleWindowKeydown(event: KeyboardEvent) {
|
||
if (event.key === "Escape" && pendingDiscard && !isBusy) closeDiscardConfirm();
|
||
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
|
||
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
|
||
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
|
||
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
|
||
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
|
||
}
|
||
|
||
function handleWindowContextMenu(event: MouseEvent) {
|
||
event.preventDefault();
|
||
}
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>GitLite</title>
|
||
</svelte:head>
|
||
|
||
<svelte:window on:keydown={handleWindowKeydown} on:contextmenu={handleWindowContextMenu} />
|
||
|
||
<main class="shell">
|
||
<TitleBar
|
||
branch={status?.current_branch ?? ""}
|
||
ahead={status?.ahead ?? 0}
|
||
behind={status?.behind ?? 0}
|
||
repoName={activeRepoPath ? (activeRepoPath.split(/[\\/]/).filter(Boolean).slice(-1)[0] ?? "") : ""}
|
||
hasRepository={workspaceActive}
|
||
{isBusy}
|
||
{operation}
|
||
{autoRefreshEnabled}
|
||
{autoRefreshInFlight}
|
||
onFetch={fetchRepo}
|
||
onPull={pullRepo}
|
||
onPush={pushRepo}
|
||
onRefresh={refreshRepo}
|
||
onSearch={() => { globalSearchOpen = true; }}
|
||
onCompare={openCompareSelect}
|
||
onOpenInExplorer={openActiveRepoInExplorer}
|
||
onToggleAutoRefresh={toggleAutoRefresh}
|
||
/>
|
||
|
||
<div class="shell-body">
|
||
|
||
<header class="repo-tabbar" aria-label="Repository tabs">
|
||
<button
|
||
class="repo-tab management"
|
||
class:active={activeView === "management"}
|
||
type="button"
|
||
onclick={openRepoManagement}
|
||
disabled={isBusy}
|
||
title="Repository Management"
|
||
>
|
||
<BookOpen size={14} aria-hidden="true" />
|
||
Repository Management
|
||
</button>
|
||
|
||
<div class="repo-tabs-scroll">
|
||
{#each repoTabs as repo (repo.path)}
|
||
<div class="repo-tab-wrap" class:active={activeView === "repository" && sameRepoPath(activeRepoPath, repo.path)}>
|
||
<button
|
||
class="repo-tab"
|
||
type="button"
|
||
onclick={() => selectRepoTab(repo.path)}
|
||
disabled={isBusy}
|
||
title={repo.path}
|
||
>
|
||
<FolderOpen size={14} aria-hidden="true" />
|
||
<span>{repo.name}</span>
|
||
{#if repo.branch}
|
||
<strong>{repo.branch}</strong>
|
||
{/if}
|
||
</button>
|
||
<button
|
||
class="repo-tab-close"
|
||
type="button"
|
||
onclick={(event) => closeRepoTab(repo.path, event)}
|
||
disabled={isBusy}
|
||
aria-label={`Close ${repo.name}`}
|
||
title="Close repository tab"
|
||
>
|
||
<X size={13} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
<button
|
||
class="repo-tab-add"
|
||
type="button"
|
||
onclick={chooseRepositoryFolder}
|
||
disabled={isBusy}
|
||
title="Open repository folder"
|
||
aria-label="Open repository folder"
|
||
>
|
||
<Plus size={15} aria-hidden="true" />
|
||
</button>
|
||
</header>
|
||
|
||
<!-- Status notices -->
|
||
{#if errorMessage}
|
||
<section class="notice error" role="alert">
|
||
<AlertCircle size={17} aria-hidden="true" />
|
||
<span>{errorMessage}</span>
|
||
</section>
|
||
{/if}
|
||
|
||
{#if operation && operation !== "Opening repository"}
|
||
<section class="notice busy" aria-live="polite">
|
||
<LoaderCircle class="spin" size={17} aria-hidden="true" />
|
||
<span>{operation}</span>
|
||
</section>
|
||
{/if}
|
||
|
||
{#if workspaceActive && hasConflicts}
|
||
<section class="notice conflict" role="alert">
|
||
<GitMerge size={17} aria-hidden="true" />
|
||
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} merge conflicts.</span>
|
||
<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>
|
||
</section>
|
||
{/if}
|
||
|
||
{#if activeView === "management"}
|
||
<section class="repo-management" aria-label="Repository Management">
|
||
<div class="repo-management-head">
|
||
<div>
|
||
<span class="eyebrow">Repository Management</span>
|
||
<h1>Repositories</h1>
|
||
</div>
|
||
<div class="repo-management-actions">
|
||
<button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}>
|
||
<FolderOpen size={15} aria-hidden="true" />
|
||
Browse
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="repo-management-tools">
|
||
<div class="repo-search">
|
||
<Search size={15} aria-hidden="true" />
|
||
<input
|
||
bind:value={repoSearch}
|
||
autocomplete="off"
|
||
spellcheck="false"
|
||
placeholder="Search repositories"
|
||
aria-label="Search repositories"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="repo-sections">
|
||
<section class="repo-section">
|
||
<header>
|
||
<h2>Open repositories</h2>
|
||
<span>{openRepoRows.length}</span>
|
||
</header>
|
||
{#if openRepoRows.length === 0}
|
||
<div class="repo-empty">No open repositories.</div>
|
||
{:else}
|
||
<div class="repo-table">
|
||
{#each openRepoRows as repo (repo.path)}
|
||
<div class="repo-row">
|
||
<button class="repo-row-main" type="button" onclick={() => selectRepoTab(repo.path)} disabled={isBusy}>
|
||
<span class="repo-row-name">{repo.name}</span>
|
||
<span class="repo-row-path">{repo.path}</span>
|
||
<span class="repo-row-meta">
|
||
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{/if}
|
||
{#if repo.ahead > 0}<em class="ahead">↑ {repo.ahead}</em>{/if}
|
||
{#if repo.behind > 0}<em class="behind">↓ {repo.behind}</em>{/if}
|
||
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
|
||
</span>
|
||
</button>
|
||
<button class="repo-row-icon" type="button" onclick={(event) => closeRepoTab(repo.path, event)} disabled={isBusy} title="Close tab" aria-label={`Close ${repo.name}`}>
|
||
<X size={14} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</section>
|
||
|
||
<section class="repo-section">
|
||
<header>
|
||
<h2>Recent repositories</h2>
|
||
<span>{recentRepoRows.length}</span>
|
||
</header>
|
||
{#if recentRepoRows.length === 0}
|
||
<div class="repo-empty">No recent repositories.</div>
|
||
{:else}
|
||
<div class="repo-table">
|
||
{#each recentRepoRows as repo (repo.path)}
|
||
<div class="repo-row">
|
||
<button class="repo-row-main" type="button" onclick={() => openRepo(repo.path)} disabled={isBusy}>
|
||
<span class="repo-row-name">{repo.name}</span>
|
||
<span class="repo-row-path">{repo.path}</span>
|
||
<span class="repo-row-meta quiet">recent</span>
|
||
</button>
|
||
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}>
|
||
<X size={14} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</section>
|
||
|
||
<section class="repo-section">
|
||
<header>
|
||
<h2>All repositories</h2>
|
||
<span>{allRepoRows.length}</span>
|
||
</header>
|
||
{#if allRepoRows.length === 0}
|
||
<div class="repo-empty">Browse for a repository to add it here.</div>
|
||
{:else}
|
||
<div class="repo-table">
|
||
{#each allRepoRows as repo (repo.path)}
|
||
<div class="repo-row">
|
||
<button class="repo-row-main" type="button" onclick={() => openRepo(repo.path)} disabled={isBusy}>
|
||
<span class="repo-row-name">{repo.name}</span>
|
||
<span class="repo-row-path">{repo.path}</span>
|
||
<span class="repo-row-meta">
|
||
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em>known repo</em>{/if}
|
||
</span>
|
||
</button>
|
||
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove" aria-label={`Remove ${repo.name}`}>
|
||
<X size={14} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</section>
|
||
</div>
|
||
</section>
|
||
{:else}
|
||
<!-- Workspace -->
|
||
<section class="workspace" aria-label="Git workspace" style="--history-aside-width: {historyAsideWidth}px;">
|
||
|
||
<!-- Left sidebar: branches + explorer -->
|
||
<aside class="left-sidebar" aria-label="Repository navigation">
|
||
<BranchPanel
|
||
{branches}
|
||
{localBranches}
|
||
{remoteBranches}
|
||
{hasRepository}
|
||
{isBusy}
|
||
onCheckout={checkout}
|
||
onMerge={merge}
|
||
onCreateBranch={createNewBranch}
|
||
onRenameBranch={renameLocalBranch}
|
||
onDeleteBranch={deleteLocalBranch}
|
||
/>
|
||
<StashPanel
|
||
{stashes}
|
||
changedCount={changedFiles.length}
|
||
{hasRepository}
|
||
{isBusy}
|
||
onPush={saveStash}
|
||
onApply={applyStashEntry}
|
||
onPop={popStashEntry}
|
||
onDrop={dropStashEntry}
|
||
/>
|
||
<ExplorerPanel
|
||
{repoFiles}
|
||
{expandedExplorerPaths}
|
||
{selectedExplorerPath}
|
||
{selectedExplorerKind}
|
||
{hasRepository}
|
||
{isBusy}
|
||
onToggleFolder={toggleExplorerFolder}
|
||
onExpandAllFolders={expandAllExplorerFolders}
|
||
onCollapseAllFolders={collapseAllExplorerFolders}
|
||
onSelectNode={selectExplorerNode}
|
||
onOpenFile={openFileFromExplorer}
|
||
/>
|
||
</aside>
|
||
|
||
<!-- Center: summary + status + commit -->
|
||
<section class="main-panel" aria-label="Repository status">
|
||
<div class="repo-summary">
|
||
<div class="repo-meta">
|
||
<GitBranch size={13} aria-hidden="true" />
|
||
<strong class="repo-branch">{status?.current_branch ?? "No repository"}</strong>
|
||
{#if activeRepoPath}
|
||
<span class="repo-path" title={activeRepoPath}>{activeRepoPath}</span>
|
||
{/if}
|
||
</div>
|
||
<div class="sync-stats" aria-label="Sync state">
|
||
{#if status?.upstream}<span title="Upstream">{status.upstream}</span>{/if}
|
||
<strong>{status?.ahead ?? 0} ahead</strong>
|
||
<strong>{status?.behind ?? 0} behind</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="top-section" style="--commit-panel-height: {commitPanelHeight}px;">
|
||
<StatusPanel
|
||
{changedFiles}
|
||
{stagedCount}
|
||
{unstagedCount}
|
||
{hasRepository}
|
||
{isBusy}
|
||
{status}
|
||
selectedFilePath={selectedExplorerPath}
|
||
onSelectFile={selectFileFromStatus}
|
||
onStage={stageFile}
|
||
onUnstage={unstageFile}
|
||
onDiscard={discardFile}
|
||
onPatch={openLinePatch}
|
||
onStageAll={stageAllFiles}
|
||
onUnstageAll={unstageAllFiles}
|
||
/>
|
||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||
<div
|
||
class="panel-resize-handle"
|
||
class:resizing={resizingCommitPanel}
|
||
role="separator"
|
||
aria-orientation="horizontal"
|
||
aria-label="Resize commit panel height"
|
||
aria-valuenow={commitPanelHeight}
|
||
aria-valuemin={COMMIT_PANEL_MIN_HEIGHT}
|
||
aria-valuemax={COMMIT_PANEL_MAX_HEIGHT}
|
||
tabindex="0"
|
||
onpointerdown={startCommitPanelResize}
|
||
onpointermove={onCommitPanelResizeMove}
|
||
onpointerup={endCommitPanelResize}
|
||
onpointercancel={endCommitPanelResize}
|
||
onkeydown={onCommitPanelResizeKeydown}
|
||
></div>
|
||
<CommitPanel
|
||
{commitMessage}
|
||
{canCommit}
|
||
{commitBlockReason}
|
||
{hasRepository}
|
||
{isBusy}
|
||
{operation}
|
||
{stagedCount}
|
||
commitAiProvider={aiSettings.provider}
|
||
{commitAiPhase}
|
||
{commitAiGenerating}
|
||
onCommit={commitChanges}
|
||
onCommitMessageChange={updateCommitMessage}
|
||
onGenerateCommitMessage={generateCommitMessageWithAi}
|
||
onOpenAiSettings={() => { aiSettingsOpen = true; }}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Right sidebar: commit graph + file history -->
|
||
<aside class="history-aside" aria-label="Commit history">
|
||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||
<div
|
||
class="history-resize-handle"
|
||
class:resizing={resizingHistoryAside}
|
||
role="separator"
|
||
aria-orientation="vertical"
|
||
aria-label="Resize history panel width"
|
||
aria-valuenow={historyAsideWidth}
|
||
aria-valuemin={HISTORY_ASIDE_MIN_WIDTH}
|
||
aria-valuemax={HISTORY_ASIDE_MAX_WIDTH}
|
||
tabindex="0"
|
||
onpointerdown={startHistoryAsideResize}
|
||
onpointermove={onHistoryAsideResizeMove}
|
||
onpointerup={endHistoryAsideResize}
|
||
onpointercancel={endHistoryAsideResize}
|
||
onkeydown={onHistoryAsideResizeKeydown}
|
||
></div>
|
||
<HistoryPanel
|
||
{commits}
|
||
{localBranchNames}
|
||
activeBranch={status?.current_branch ?? ""}
|
||
repositoryKey={activeRepoPath}
|
||
{hasRepository}
|
||
{isBusy}
|
||
{expandedCommitHashes}
|
||
onRestoreCommit={restoreCommit}
|
||
onPreviewCommitFile={previewCommitFileFromHistory}
|
||
onCreateBranchFromCommit={openNewBranchDialog}
|
||
onToggleCommitFiles={(hash) => {
|
||
const next = new Set(expandedCommitHashes);
|
||
if (next.has(hash)) next.delete(hash); else next.add(hash);
|
||
expandedCommitHashes = next;
|
||
}}
|
||
/>
|
||
<FileHistoryPanel
|
||
{fileHistory}
|
||
{selectedExplorerPath}
|
||
selectedExplorerLabel={selectedExplorerPath ? `${selectedExplorerKind === "folder" ? "Folder" : "File"} history` : "File history"}
|
||
{hasRepository}
|
||
{isBusy}
|
||
isLoading={fileHistoryLoading}
|
||
onDiff={diffSelectedFileFromCommit}
|
||
onRestore={restoreSelectedFileFromCommit}
|
||
/>
|
||
</aside>
|
||
</section>
|
||
{/if}
|
||
</div>
|
||
</main>
|
||
|
||
{#if updateToastOpen}
|
||
<UpdateToast
|
||
state={updateToastState}
|
||
version={updateVersion}
|
||
currentVersion={updateCurrentVersion}
|
||
progress={updateProgress}
|
||
error={updateError}
|
||
onInstall={installPendingUpdate}
|
||
onLater={dismissUpdateToast}
|
||
onDismiss={dismissUpdateToast}
|
||
/>
|
||
{/if}
|
||
|
||
{#if linePatchOpen && linePatchFile}
|
||
<LinePatchDialog
|
||
file={linePatchFile}
|
||
staged={linePatchStaged}
|
||
patch={linePatchText}
|
||
{isBusy}
|
||
isLoading={linePatchLoading}
|
||
error={linePatchError}
|
||
onClose={closeLinePatch}
|
||
onRefresh={refreshLinePatch}
|
||
onApply={applyLinePatch}
|
||
/>
|
||
{/if}
|
||
|
||
{#if pendingDiscard}
|
||
<DiscardConfirmDialog
|
||
file={pendingDiscard.file}
|
||
staged={pendingDiscard.staged}
|
||
scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"}
|
||
{isBusy}
|
||
onConfirm={confirmDiscard}
|
||
onClose={closeDiscardConfirm}
|
||
/>
|
||
{/if}
|
||
|
||
{#if globalSearchOpen}
|
||
<GlobalSearchDialog
|
||
{hasRepository}
|
||
{isBusy}
|
||
isSearching={globalSearchBusy}
|
||
error={globalSearchError}
|
||
results={globalSearchResults}
|
||
files={repoFiles}
|
||
fileHistory={fileHistory}
|
||
selectedFilePath={selectedExplorerPath}
|
||
onClose={closeGlobalSearchDialog}
|
||
onSearch={runGlobalSearch}
|
||
onCancel={cancelGlobalSearch}
|
||
onDiff={diffSearchHit}
|
||
onSelectFile={selectFileFromSearch}
|
||
onFileHistoryDiff={diffSelectedFileFromCommit}
|
||
onFileHistoryRestore={restoreSelectedFileFromCommit}
|
||
/>
|
||
{/if}
|
||
|
||
<!-- Create a branch from a specific commit in the history -->
|
||
{#if newBranchCommit}
|
||
<NewBranchDialog
|
||
commit={newBranchCommit}
|
||
{isBusy}
|
||
onCreate={createBranchFromCommit}
|
||
onClose={() => { newBranchCommit = null; }}
|
||
/>
|
||
{/if}
|
||
|
||
<!-- Rename a local branch from the branch context menu -->
|
||
{#if renameBranchTarget}
|
||
<RenameBranchDialog
|
||
branch={renameBranchTarget}
|
||
{isBusy}
|
||
onRename={submitRenameBranch}
|
||
onClose={() => { renameBranchTarget = null; }}
|
||
/>
|
||
{/if}
|
||
|
||
<!-- Choose the AI provider/model used to generate commit messages -->
|
||
{#if aiSettingsOpen}
|
||
<AiSettingsDialog
|
||
settings={aiSettings}
|
||
localModels={localModelOptions}
|
||
onSave={saveAiSettings}
|
||
onClose={() => { aiSettingsOpen = false; }}
|
||
/>
|
||
{/if}
|
||
|
||
<!-- Compare: pick the two commits to diff -->
|
||
{#if compareSelectOpen}
|
||
<CompareSelectDialog
|
||
{commits}
|
||
{compareFrom}
|
||
{compareTo}
|
||
{canCompare}
|
||
{isBusy}
|
||
{operation}
|
||
onCompareFromChange={(val) => { compareFrom = val; }}
|
||
onCompareToChange={(val) => { compareTo = val; }}
|
||
onCompare={compareSelectedCommits}
|
||
onClose={() => { compareSelectOpen = false; }}
|
||
/>
|
||
{/if}
|
||
|
||
<!-- Compare diff dialog (rendered last so it overlays the search dialog when opened from a hit) -->
|
||
{#if compareDialogOpen && comparison}
|
||
<CompareDialog
|
||
{comparison}
|
||
{selectedDiffPath}
|
||
{isBusy}
|
||
highlightQuery={diffHighlightQuery}
|
||
restoreLabel={pendingRestoreFile ? "Restore file" : ""}
|
||
onClose={closeCompareDialog}
|
||
onRestore={restorePreviewedCommitFile}
|
||
onSelectFile={selectDiffFile}
|
||
/>
|
||
{/if}
|
||
|
||
<!-- Credential dialog for push/pull -->
|
||
{#if credDialogOpen && credDialogAction}
|
||
<CredentialDialog
|
||
action={credDialogAction}
|
||
error={credDialogError}
|
||
{isBusy}
|
||
onSubmit={handleCredentialSubmit}
|
||
onCancel={() => { credDialogOpen = false; credDialogAction = null; credDialogError = ""; credDialogKey = null; }}
|
||
/>
|
||
{/if}
|
||
|
||
<!-- Full-screen overlay while a repository is being opened -->
|
||
{#if openingRepo}
|
||
<RepoLoadingOverlay repoName={repoDisplayName} />
|
||
{/if}
|
||
|
||
<!-- Conflict resolve dialog -->
|
||
{#if resolveDialogOpen}
|
||
<ResolveDialog
|
||
{conflictedFiles}
|
||
{conflictTarget}
|
||
{conflict}
|
||
{preparedResolutions}
|
||
{isBusy}
|
||
{operation}
|
||
onClose={() => { resolveDialogOpen = false; }}
|
||
onSelectFile={selectConflictFile}
|
||
onMarkResolved={handleMarkResolved}
|
||
onApply={applyPreparedResolutions}
|
||
/>
|
||
{/if}
|