903 lines
33 KiB
Svelte
903 lines
33 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, onMount } from "svelte";
|
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
|
import { AlertCircle, Check, FolderOpen, GitBranch, GitMerge, LoaderCircle } from "@lucide/svelte";
|
|
|
|
import TitleBar from "./lib/TitleBar.svelte";
|
|
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
|
import CommitPanel from "./lib/components/CommitPanel.svelte";
|
|
import CompareDialog from "./lib/components/CompareDialog.svelte";
|
|
import ComparePanel from "./lib/components/ComparePanel.svelte";
|
|
import CredentialDialog from "./lib/components/CredentialDialog.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 ResolveDialog from "./lib/components/ResolveDialog.svelte";
|
|
import StatusPanel from "./lib/components/StatusPanel.svelte";
|
|
|
|
import {
|
|
checkoutBranch,
|
|
commit,
|
|
compareCommits,
|
|
cancelCodeSearch,
|
|
diffFileAgainstWorkingTree,
|
|
compareFileToHead,
|
|
getStatus,
|
|
listBranches,
|
|
listCommits,
|
|
listFileHistory,
|
|
listRepositoryFiles,
|
|
mergeBranch,
|
|
openRepository,
|
|
pull,
|
|
push,
|
|
readConflict,
|
|
resolveConflict,
|
|
resolveConflictSide,
|
|
restoreFileFromCommit,
|
|
restoreFiles,
|
|
restoreToCommit,
|
|
searchCodeIntroductions,
|
|
stageFiles,
|
|
unstageFiles,
|
|
} from "./lib/git";
|
|
|
|
import type {
|
|
ConflictFile,
|
|
ExplorerNode,
|
|
ExplorerNodeKind,
|
|
GitBranch as GitBranchInfo,
|
|
GitCommit,
|
|
GitCommitFile,
|
|
GitCommitComparison,
|
|
GitDiffFile,
|
|
GitFileStatus,
|
|
GitRepositoryFile,
|
|
GitSearchHit,
|
|
GitStatus,
|
|
PreparedResolution,
|
|
} from "./lib/types";
|
|
|
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
|
|
let repoPath = "";
|
|
let activeRepoPath = "";
|
|
let status: GitStatus | null = null;
|
|
let branches: GitBranchInfo[] = [];
|
|
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 commitMessage = "";
|
|
let errorMessage = "";
|
|
let operation = "";
|
|
let compareFrom = "";
|
|
let compareTo = "";
|
|
let comparison: GitCommitComparison | null = null;
|
|
let compareDialogOpen = false;
|
|
let selectedDiffPath = "";
|
|
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
|
|
let globalSearchOpen = false;
|
|
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" | null = null;
|
|
let credDialogError = "";
|
|
let sessionCredentials: { username: string; password: string } | null = null;
|
|
let lastStatusFingerprint = "";
|
|
const AUTO_REFRESH_INTERVAL = 4000;
|
|
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
|
|
// ── Derived ────────────────────────────────────────────────────────────────
|
|
|
|
$: isBusy = operation.length > 0;
|
|
$: hasRepository = activeRepoPath.length > 0 && status !== null;
|
|
$: 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);
|
|
$: remoteBranches = branches.filter((b) => b.remote);
|
|
|
|
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
|
|
onMount(() => {
|
|
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
|
});
|
|
|
|
// ── 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 });
|
|
}
|
|
|
|
async function autoRefreshTick() {
|
|
if (!autoRefreshEnabled || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || globalSearchOpen) return;
|
|
autoRefreshInFlight = true;
|
|
try {
|
|
const nextStatus = await getStatus(activeRepoPath);
|
|
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
|
|
applyStatus(nextStatus);
|
|
await refreshBranchList(activeRepoPath);
|
|
await refreshCommitHistory(activeRepoPath);
|
|
await refreshExplorerFiles(activeRepoPath);
|
|
await refreshFileHistory(activeRepoPath);
|
|
} catch { /* ignore transient errors */ } finally {
|
|
autoRefreshInFlight = false;
|
|
}
|
|
}
|
|
|
|
function toggleAutoRefresh() {
|
|
autoRefreshEnabled = !autoRefreshEnabled;
|
|
if (autoRefreshEnabled) void autoRefreshTick();
|
|
}
|
|
|
|
// ── Utilities ──────────────────────────────────────────────────────────────
|
|
|
|
function applyStatus(nextStatus: GitStatus) {
|
|
status = nextStatus;
|
|
activeRepoPath = nextStatus.repo_path || activeRepoPath || repoPath.trim();
|
|
repoPath = activeRepoPath;
|
|
lastStatusFingerprint = statusFingerprint(nextStatus);
|
|
}
|
|
|
|
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"; }
|
|
}
|
|
|
|
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 defaultExpandedExplorerPaths(files: GitRepositoryFile[]): Set<string> {
|
|
const expanded = new Set<string>();
|
|
for (const file of files) {
|
|
const parts = file.path.split(/[\\/]+/).filter(Boolean);
|
|
if (parts.length > 1) expanded.add(parts[0]);
|
|
}
|
|
return expanded;
|
|
}
|
|
|
|
// ── Refresh helpers ────────────────────────────────────────────────────────
|
|
|
|
async function refreshBranchList(path = activeRepoPath) {
|
|
branches = await listBranches(path);
|
|
}
|
|
|
|
async function refreshCommitHistory(path = activeRepoPath) {
|
|
commits = await listCommits(path, 100);
|
|
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) {
|
|
repoFiles = await listRepositoryFiles(path);
|
|
if (expandedExplorerPaths.size === 0) expandedExplorerPaths = defaultExpandedExplorerPaths(repoFiles);
|
|
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
|
|
selectedExplorerPath = "";
|
|
selectedExplorerKind = "file";
|
|
fileHistory = [];
|
|
}
|
|
}
|
|
|
|
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) {
|
|
fileHistory = file ? await listFileHistory(path, file, 100) : [];
|
|
}
|
|
|
|
// ── 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 () => {
|
|
const nextStatus = await openRepository(path);
|
|
applyStatus(nextStatus);
|
|
branches = []; commits = []; repoFiles = [];
|
|
selectedExplorerPath = ""; selectedExplorerKind = "file";
|
|
expandedExplorerPaths = new Set(); expandedCommitHashes = new Set();
|
|
fileHistory = []; compareFrom = ""; compareTo = "";
|
|
comparison = null; compareDialogOpen = false; selectedDiffPath = ""; pendingRestoreFile = null;
|
|
if (globalSearchBusy) void cancelGlobalSearch();
|
|
globalSearchResults = []; globalSearchOpen = false; globalSearchError = "";
|
|
resolveDialogOpen = false; conflictTarget = ""; conflict = null;
|
|
preparedResolutions = {};
|
|
await refreshBranchList(activeRepoPath);
|
|
await refreshCommitHistory(activeRepoPath);
|
|
await refreshExplorerFiles(activeRepoPath);
|
|
});
|
|
}
|
|
|
|
async function chooseRepositoryFolder() {
|
|
if (isBusy) return;
|
|
try {
|
|
const selected = await openDialog({
|
|
title: "Repository folder auswaehlen",
|
|
directory: true,
|
|
multiple: false,
|
|
defaultPath: repoPath.trim() || activeRepoPath || undefined,
|
|
});
|
|
if (typeof selected !== "string") return;
|
|
repoPath = selected;
|
|
await openRepo(selected);
|
|
} 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 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 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);
|
|
});
|
|
}
|
|
|
|
function openCredentialDialog(action: "push" | "pull") {
|
|
if (!activeRepoPath) return;
|
|
credDialogError = "";
|
|
credDialogAction = action;
|
|
credDialogOpen = true;
|
|
}
|
|
|
|
async function doActualPull(username: string, password: string) {
|
|
errorMessage = "";
|
|
await runOperation("Pulling", async () => {
|
|
applyStatus(await pull(activeRepoPath, username, password));
|
|
await refreshBranchList(activeRepoPath);
|
|
await refreshCommitHistory(activeRepoPath);
|
|
await refreshExplorerFiles(activeRepoPath);
|
|
await refreshFileHistory(activeRepoPath);
|
|
});
|
|
if (errorMessage) { credDialogError = errorMessage; errorMessage = ""; }
|
|
else { credDialogOpen = false; credDialogAction = null; }
|
|
}
|
|
|
|
async function doActualPush(username: string, password: string) {
|
|
errorMessage = "";
|
|
await runOperation("Pushing", async () => {
|
|
applyStatus(await push(activeRepoPath, username, password));
|
|
await refreshBranchList(activeRepoPath);
|
|
await refreshCommitHistory(activeRepoPath);
|
|
await refreshFileHistory(activeRepoPath);
|
|
});
|
|
if (errorMessage) { credDialogError = errorMessage; errorMessage = ""; }
|
|
else { credDialogOpen = false; credDialogAction = null; }
|
|
}
|
|
|
|
async function handleCredentialSubmit(username: string, password: string, save: boolean) {
|
|
if (credDialogAction === "pull") await doActualPull(username, password);
|
|
else if (credDialogAction === "push") await doActualPush(username, password);
|
|
if (!credDialogOpen && save) sessionCredentials = { username, password };
|
|
}
|
|
|
|
async function pullRepo() {
|
|
if (!activeRepoPath) return;
|
|
if (sessionCredentials) {
|
|
await doActualPull(sessionCredentials.username, sessionCredentials.password);
|
|
} else {
|
|
openCredentialDialog("pull");
|
|
}
|
|
}
|
|
|
|
async function pushRepo() {
|
|
if (!activeRepoPath) return;
|
|
if (sessionCredentials) {
|
|
await doActualPush(sessionCredentials.username, sessionCredentials.password);
|
|
} else {
|
|
openCredentialDialog("push");
|
|
}
|
|
}
|
|
|
|
// ── 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);
|
|
});
|
|
}
|
|
|
|
async function discardFile(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 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 = "";
|
|
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(`Reset current branch to ${target.short_hash}?\n\nThis moves the current branch and discards tracked local changes.`);
|
|
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 compareFileToHead(activeRepoPath, target.hash, file.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;
|
|
}
|
|
|
|
async function selectExplorerNode(node: ExplorerNode) {
|
|
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
|
|
selectedExplorerPath = node.path;
|
|
selectedExplorerKind = node.kind;
|
|
await runOperation(`Loading ${node.path} history`, async () => {
|
|
await refreshFileHistory(activeRepoPath, node.path);
|
|
});
|
|
}
|
|
|
|
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 ────────────────────────────────────────────────────────────────
|
|
|
|
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 ?? "";
|
|
pendingRestoreFile = null;
|
|
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;
|
|
pendingRestoreFile = null;
|
|
compareDialogOpen = true;
|
|
});
|
|
}
|
|
|
|
function openCompareDialog() {
|
|
if (comparison) 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;
|
|
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("abgebrochen") ? "Suche wurde abgebrochen." : message;
|
|
}
|
|
} finally {
|
|
if (globalSearchId === searchId) {
|
|
globalSearchBusy = false;
|
|
globalSearchId = "";
|
|
}
|
|
}
|
|
}
|
|
|
|
async function cancelGlobalSearch() {
|
|
if (!globalSearchId) return;
|
|
const searchId = globalSearchId;
|
|
globalSearchError = "Abbruch wird angefordert...";
|
|
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 submitRepo(event: SubmitEvent) { event.preventDefault(); void openRepo(); }
|
|
|
|
function handleWindowKeydown(event: KeyboardEvent) {
|
|
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
|
|
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>GitLite</title>
|
|
</svelte:head>
|
|
|
|
<svelte:window on:keydown={handleWindowKeydown} />
|
|
|
|
<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}
|
|
{isBusy}
|
|
{operation}
|
|
{autoRefreshEnabled}
|
|
{autoRefreshInFlight}
|
|
onPull={pullRepo}
|
|
onPush={pushRepo}
|
|
onRefresh={refreshRepo}
|
|
onSearch={() => { globalSearchOpen = true; }}
|
|
onToggleAutoRefresh={toggleAutoRefresh}
|
|
/>
|
|
|
|
<div class="shell-body">
|
|
|
|
<!-- Repository path form -->
|
|
<header class="topbar">
|
|
<form class="repo-form" onsubmit={submitRepo}>
|
|
<label for="repo-path">Repository</label>
|
|
<input
|
|
id="repo-path"
|
|
bind:value={repoPath}
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
placeholder="/path/to/repository"
|
|
disabled={isBusy}
|
|
/>
|
|
<button
|
|
class="btn-secondary repo-browse"
|
|
type="button"
|
|
onclick={chooseRepositoryFolder}
|
|
disabled={isBusy}
|
|
title="Repository-Ordner auswaehlen"
|
|
aria-label="Repository-Ordner auswaehlen"
|
|
>
|
|
<FolderOpen size={16} aria-hidden="true" />
|
|
Browse
|
|
</button>
|
|
<button class="btn-primary" type="submit" disabled={isBusy || repoPath.trim().length === 0}>
|
|
{#if operation === "Opening repository"}
|
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
|
{:else}
|
|
<Check size={16} aria-hidden="true" />
|
|
{/if}
|
|
Open
|
|
</button>
|
|
</form>
|
|
</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 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}
|
|
|
|
<!-- Workspace -->
|
|
<section class="workspace" aria-label="Git workspace">
|
|
|
|
<!-- Left sidebar: branches + explorer -->
|
|
<aside class="left-sidebar" aria-label="Repository navigation">
|
|
<BranchPanel
|
|
{branches}
|
|
{localBranches}
|
|
{remoteBranches}
|
|
{hasRepository}
|
|
{isBusy}
|
|
onCheckout={checkout}
|
|
onMerge={merge}
|
|
/>
|
|
<ExplorerPanel
|
|
{repoFiles}
|
|
{expandedExplorerPaths}
|
|
{selectedExplorerPath}
|
|
{selectedExplorerKind}
|
|
{hasRepository}
|
|
{isBusy}
|
|
onToggleFolder={toggleExplorerFolder}
|
|
onSelectNode={selectExplorerNode}
|
|
/>
|
|
</aside>
|
|
|
|
<!-- Center: summary + status + commit + compare -->
|
|
<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">
|
|
<StatusPanel
|
|
{changedFiles}
|
|
{stagedCount}
|
|
{unstagedCount}
|
|
{hasRepository}
|
|
{isBusy}
|
|
{status}
|
|
onStage={stageFile}
|
|
onUnstage={unstageFile}
|
|
onDiscard={discardFile}
|
|
onStageAll={stageAllFiles}
|
|
onUnstageAll={unstageAllFiles}
|
|
/>
|
|
<CommitPanel
|
|
{commitMessage}
|
|
{canCommit}
|
|
{commitBlockReason}
|
|
{hasRepository}
|
|
{isBusy}
|
|
{operation}
|
|
{stagedCount}
|
|
onCommit={commitChanges}
|
|
onCommitMessageChange={(msg) => { commitMessage = msg; }}
|
|
/>
|
|
</div>
|
|
|
|
<ComparePanel
|
|
{commits}
|
|
{hasRepository}
|
|
{isBusy}
|
|
{compareFrom}
|
|
{compareTo}
|
|
{canCompare}
|
|
{comparison}
|
|
{operation}
|
|
onCompareFromChange={(val) => { compareFrom = val; }}
|
|
onCompareToChange={(val) => { compareTo = val; }}
|
|
onCompare={compareSelectedCommits}
|
|
onOpenDialog={openCompareDialog}
|
|
/>
|
|
</section>
|
|
|
|
<!-- Right sidebar: commit graph + file history -->
|
|
<aside class="history-aside" aria-label="Commit history">
|
|
<HistoryPanel
|
|
{commits}
|
|
{hasRepository}
|
|
{isBusy}
|
|
{expandedCommitHashes}
|
|
onRestoreCommit={restoreCommit}
|
|
onPreviewCommitFile={previewCommitFileFromHistory}
|
|
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}
|
|
onDiff={diffSelectedFileFromCommit}
|
|
onRestore={restoreSelectedFileFromCommit}
|
|
/>
|
|
</aside>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Compare diff dialog -->
|
|
{#if compareDialogOpen && comparison}
|
|
<CompareDialog
|
|
{comparison}
|
|
{selectedDiffPath}
|
|
{isBusy}
|
|
restoreLabel={pendingRestoreFile ? "Restore file" : ""}
|
|
onClose={closeCompareDialog}
|
|
onRestore={restorePreviewedCommitFile}
|
|
onSelectFile={selectDiffFile}
|
|
/>
|
|
{/if}
|
|
|
|
{#if globalSearchOpen}
|
|
<GlobalSearchDialog
|
|
{hasRepository}
|
|
{isBusy}
|
|
isSearching={globalSearchBusy}
|
|
error={globalSearchError}
|
|
results={globalSearchResults}
|
|
onClose={closeGlobalSearchDialog}
|
|
onSearch={runGlobalSearch}
|
|
onCancel={cancelGlobalSearch}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Credential dialog for push/pull -->
|
|
{#if credDialogOpen && credDialogAction}
|
|
<CredentialDialog
|
|
action={credDialogAction}
|
|
error={credDialogError}
|
|
{isBusy}
|
|
onSubmit={handleCredentialSubmit}
|
|
onCancel={() => { credDialogOpen = false; credDialogAction = null; credDialogError = ""; }}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Conflict resolve dialog -->
|
|
{#if resolveDialogOpen}
|
|
<ResolveDialog
|
|
{conflictedFiles}
|
|
{conflictTarget}
|
|
{conflict}
|
|
{preparedResolutions}
|
|
{isBusy}
|
|
{operation}
|
|
onClose={() => { resolveDialogOpen = false; }}
|
|
onSelectFile={selectConflictFile}
|
|
onMarkResolved={handleMarkResolved}
|
|
onApply={applyPreparedResolutions}
|
|
/>
|
|
{/if}
|