diff --git a/README.md b/README.md
index 0e2e5b0..03b7ef7 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,11 @@ Fast, simple, and designed for developers who want a clean Git experience withou
- 🗄️ Git LFS detection, tracking and object management
- 🎨 Modern and intuitive UI
+---
+
+## 📸 Preview
+
+> Screenshots comes later.
---
diff --git a/src/App.svelte b/src/App.svelte
index f0fca95..a24b505 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -168,6 +168,7 @@
GitIntegrationSecretUpdate,
GitIntegrationSettings,
GitIntegrationProvider,
+ IntegrationReviewRequest,
GitBlameLine,
GitBranch as GitBranchInfo,
GitCommit,
@@ -218,6 +219,7 @@
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "review-center" | "repository";
+ type ReviewConflictPhase = "idle" | "preparing" | "conflicts" | "ready-to-continue" | "ready-to-push" | "complete" | "error";
type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
type CredentialMode = "credentials" | "token";
type PendingDiscard =
@@ -317,6 +319,12 @@
let activeView: AppView = "management";
let reviewCenterInitialQuery = "";
let reviewCenterInitialSourceId = "";
+ let reviewConflictRequestId = "";
+ let reviewConflictPhase: ReviewConflictPhase = "idle";
+ let reviewConflictMessage = "";
+ let reviewConflictRepoPath = "";
+ let reviewConflictRemote = "";
+ let reviewConflictPushRequested = false;
let repoTabs: RepoTab[] = [];
let repoTabContextMenu: RepoTabContextMenu | null = null;
let recentRepoPaths: string[] = [];
@@ -2598,6 +2606,180 @@
trackEvent("review_center_opened", { integrations: Object.values(gitIntegrationSettings.providers).filter((provider) => provider.enabled && provider.tokenStored).length });
}
+ function reviewRemoteIdentity(value: string): { host: string; repository: string } | null {
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+ let host = "";
+ let pathname = "";
+ const scp = trimmed.match(/^[^@\s]+@([^:\s]+):(.+)$/);
+ if (scp) {
+ host = scp[1];
+ pathname = scp[2];
+ } else {
+ try {
+ const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
+ host = url.hostname;
+ pathname = url.pathname;
+ } catch {
+ return null;
+ }
+ }
+ const parts = pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/").filter(Boolean);
+ const gitIndex = parts.findIndex((part) => part.toLocaleLowerCase() === "_git");
+ const repository = gitIndex > 0 && parts[gitIndex + 1]
+ ? `${parts[gitIndex - 1]}/${parts[gitIndex + 1]}`
+ : parts[0]?.toLocaleLowerCase() === "v3" && parts.length >= 4
+ ? `${parts[2]}/${parts[3]}`
+ : parts.join("/");
+ return { host: host.toLocaleLowerCase().replace(/^ssh\./, ""), repository: repository.toLocaleLowerCase() };
+ }
+
+ function reviewRepositoryMatches(remoteRepository: string, requestRepository: string): boolean {
+ const request = requestRepository.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLocaleLowerCase();
+ return Boolean(request) && (remoteRepository === request || remoteRepository.endsWith(`/${request}`) || request.endsWith(`/${remoteRepository}`));
+ }
+
+ async function findReviewConflictRepository(request: IntegrationReviewRequest, sourceId: string): Promise<{ path: string; remote: GitRemote } | null> {
+ const source = configuredIntegrationSources(gitIntegrationSettings).find((candidate) => candidate.id === sourceId);
+ if (!source) return null;
+ let sourceHost = "";
+ try { sourceHost = new URL(source.baseUrl).hostname.toLocaleLowerCase().replace(/^ssh\./, ""); }
+ catch { return null; }
+ for (const repo of dashboardRepos) {
+ try {
+ const remotes = (await listRemotes(repo.path)).sort((left, right) => Number(right.name === "origin") - Number(left.name === "origin"));
+ const remote = remotes.find((candidate) => {
+ const identity = reviewRemoteIdentity(candidate.fetch_url);
+ return identity?.host === sourceHost && reviewRepositoryMatches(identity.repository, request.repositoryName);
+ });
+ if (remote) return { path: repo.path, remote };
+ } catch {
+ // Invalid or unavailable dashboard repositories are ignored while matching.
+ }
+ }
+ return null;
+ }
+
+ function setReviewConflictError(message: string) {
+ reviewConflictPhase = "error";
+ reviewConflictMessage = message;
+ activeView = "review-center";
+ }
+
+ async function startReviewConflictResolution(request: IntegrationReviewRequest, sourceId: string) {
+ if (isBusy || !request.sourceBranch || !request.targetBranch) return;
+ reviewConflictRequestId = request.id;
+ reviewConflictPhase = "preparing";
+ reviewConflictMessage = appLanguage === "de" ? "Lokales Repository und Branches werden vorbereitet …" : "Preparing the local repository and branches …";
+ reviewConflictRepoPath = "";
+ reviewConflictRemote = "";
+ reviewConflictPushRequested = false;
+ try {
+ const match = await findReviewConflictRepository(request, sourceId);
+ if (!match) throw new Error(appLanguage === "de" ? "Kein passendes lokales Repository gefunden. Öffne oder klone das Repository zuerst im Dashboard." : "No matching local repository was found. Open or clone the repository from the dashboard first.");
+ await openRepo(match.path);
+ activeView = "review-center";
+ if (!sameRepoPath(activeRepoPath, match.path) || !status) throw new Error(errorMessage || (appLanguage === "de" ? "Das lokale Repository konnte nicht geöffnet werden." : "The local repository could not be opened."));
+ if (status.merge_in_progress) throw new Error(appLanguage === "de" ? "In diesem Repository läuft bereits ein Merge. Schließe ihn zuerst ab oder brich ihn ab." : "A merge is already in progress in this repository. Complete or abort it first.");
+ if (!status.clean) throw new Error(appLanguage === "de" ? "Der Working Tree enthält Änderungen. Committe oder stashe sie, bevor du den PR-Konflikt löst." : "The working tree contains changes. Commit or stash them before resolving the PR conflict.");
+
+ const source = configuredIntegrationSources(gitIntegrationSettings).find((candidate) => candidate.id === sourceId);
+ if (!source) throw new Error(appLanguage === "de" ? "Die Integration ist nicht mehr verfügbar." : "The integration is no longer available.");
+ const credential = await loadStoredCredential(integrationCredentialKey(source.provider, source.accountId));
+ if (!credential?.password) throw new Error(appLanguage === "de" ? "Für diese Integration ist kein Token gespeichert." : "No token is stored for this integration.");
+
+ operation = appLanguage === "de" ? "PR-Konflikt wird vorbereitet" : "Preparing PR conflict resolution";
+ errorMessage = "";
+ reviewConflictRepoPath = match.path;
+ reviewConflictRemote = match.remote.name;
+ selectedRemote = match.remote.name;
+ applyStatus(await fetchRemote(match.path, credential.username, credential.password, false, match.remote.name));
+ const availableBranches = await listBranches(match.path);
+ const localSource = availableBranches.find((branch) => !branch.remote && branch.name === request.sourceBranch);
+ const remoteSource = availableBranches.find((branch) => branch.remote && branch.name === `${match.remote.name}/${request.sourceBranch}`);
+ const sourceBranch = localSource?.name ?? remoteSource?.name;
+ if (!sourceBranch) throw new Error(appLanguage === "de" ? `Der Quellbranch „${request.sourceBranch}“ wurde lokal und auf ${match.remote.name} nicht gefunden.` : `The source branch “${request.sourceBranch}” was not found locally or on ${match.remote.name}.`);
+ if (status?.current_branch !== request.sourceBranch) applyStatus(await checkoutBranch(match.path, sourceBranch));
+ if (remoteSource) applyStatus(await mergeBranch(match.path, remoteSource.name, "ff-only"));
+ const targetBranch = availableBranches.some((branch) => branch.remote && branch.name === `${match.remote.name}/${request.targetBranch}`)
+ ? `${match.remote.name}/${request.targetBranch}`
+ : request.targetBranch;
+ applyStatus(await mergeBranch(match.path, targetBranch, "default"));
+ await refreshRepositoryViews(match.path);
+ activeView = "review-center";
+ operation = "";
+ if (statusHasConflicts(status)) {
+ reviewConflictPhase = "conflicts";
+ reviewConflictMessage = appLanguage === "de" ? "Wähle für jede Konfliktstelle die gewünschte Version." : "Choose the version to keep for each conflict.";
+ await tick();
+ await openReviewResolveDialog();
+ } else {
+ reviewConflictPhase = "ready-to-push";
+ reviewConflictMessage = appLanguage === "de" ? "Der Zielbranch wurde ohne Dateikonflikte übernommen. Der Quellbranch kann jetzt gepusht werden." : "The target branch was merged without file conflicts. The source branch is ready to push.";
+ await tick();
+ await pushReviewConflictResolution();
+ }
+ trackEvent("review_conflict_resolution_started");
+ } catch (error) {
+ operation = "";
+ setReviewConflictError(errorToMessage(error));
+ }
+ }
+
+ async function reopenReviewConflictResolver() {
+ if (reviewConflictPhase !== "conflicts" || !hasConflicts) return;
+ await openReviewResolveDialog();
+ }
+
+ async function continueReviewConflictMerge() {
+ if (!reviewConflictRequestId || hasConflicts || !mergeInProgress || isBusy) return;
+ reviewConflictPhase = "ready-to-continue";
+ reviewConflictMessage = appLanguage === "de" ? "Der Merge-Commit wird erstellt …" : "Creating the merge commit …";
+ await continueMerge();
+ activeView = "review-center";
+ await tick();
+ if (!errorMessage && !mergeInProgress) {
+ reviewConflictPhase = "ready-to-push";
+ reviewConflictMessage = appLanguage === "de" ? "Der Merge ist abgeschlossen. Pushe den Quellbranch, damit der PR aktualisiert wird." : "The merge is complete. Push the source branch to update the pull request.";
+ await tick();
+ await pushReviewConflictResolution();
+ } else if (errorMessage) {
+ reviewConflictMessage = appLanguage === "de" ? `Der Merge-Commit konnte nicht erstellt werden: ${errorMessage}` : `The merge commit could not be created: ${errorMessage}`;
+ }
+ }
+
+ async function abortReviewConflictResolution() {
+ if (mergeInProgress) await abortMerge();
+ if (mergeInProgress) return;
+ resolveDialogOpen = false;
+ reviewConflictPhase = "idle";
+ reviewConflictRequestId = "";
+ reviewConflictMessage = "";
+ reviewConflictPushRequested = false;
+ activeView = "review-center";
+ }
+
+ async function pushReviewConflictResolution() {
+ if (reviewConflictPhase !== "ready-to-push" || !reviewConflictRepoPath || isBusy) return;
+ selectedRemote = reviewConflictRemote;
+ reviewConflictPushRequested = true;
+ await pushRepo();
+ activeView = "review-center";
+ completeReviewConflictPushIfReady();
+ }
+
+ $: if (reviewConflictPhase === "conflicts" && reviewConflictRequestId && mergeInProgress && !hasConflicts) {
+ reviewConflictPhase = "ready-to-continue";
+ reviewConflictMessage = appLanguage === "de" ? "Alle Dateien sind gelöst. Schließe jetzt den lokalen Merge ab." : "All files are resolved. Complete the local merge now.";
+ }
+
+ function completeReviewConflictPushIfReady() {
+ if (reviewConflictPhase !== "ready-to-push" || !reviewConflictPushRequested || !reviewConflictRepoPath || !sameRepoPath(activeRepoPath, reviewConflictRepoPath) || errorMessage || credDialogOpen || mergeInProgress || (status?.ahead ?? 0) !== 0) return;
+ reviewConflictPhase = "complete";
+ reviewConflictMessage = appLanguage === "de" ? "Der gelöste Branch wurde gepusht. Der Anbieter prüft den Merge-Status erneut." : "The resolved branch was pushed. The provider is checking the merge status again.";
+ trackEvent("review_conflict_resolution_pushed");
+ }
+
async function selectRepoTab(path: string) {
if (isBusy) return;
if (activeView === "repository" && sameRepoPath(activeRepoPath, path)) return;
@@ -3906,6 +4088,7 @@
}
handleRemoteResult("push", key, fromStore, username, mode);
+ completeReviewConflictPushIfReady();
}
async function doActualRemoteRename(
@@ -5042,6 +5225,19 @@
});
}
+ async function openReviewResolveDialog() {
+ if (!hasConflicts || isBusy) return;
+ const first = conflictedFiles[0].path;
+ await runOperation("Loading conflicts", async () => {
+ preparedResolutions = {};
+ resolveDialogOpen = true;
+ await loadConflict(first);
+ trackEvent("review_conflict_editor_opened", {
+ conflicts: conflictedFiles.length,
+ });
+ });
+ }
+
async function selectConflictFile(path: string) {
if (path === conflictTarget || isBusy) return;
const openExternally = externalToolsSettings.mergeOpenMode === "external";
@@ -5085,6 +5281,7 @@
async function applyPreparedResolutions() {
if (!activeRepoPath || isBusy || Object.keys(preparedResolutions).length === 0) return;
const entries = Object.entries(preparedResolutions);
+ let completeReviewMerge = false;
await runOperation(`Resolving ${entries.length} ${entries.length === 1 ? "file" : "files"}`, async () => {
let nextStatus: GitStatus | null = null;
for (const [file, prepared] of entries) {
@@ -5103,6 +5300,12 @@
resolveDialogOpen = false;
conflict = null;
conflictTarget = "";
+ completeReviewMerge = Boolean(
+ reviewConflictRequestId
+ && reviewConflictRepoPath
+ && sameRepoPath(activeRepoPath, reviewConflictRepoPath)
+ && nextStatus?.merge_in_progress,
+ );
} else {
await loadConflict(remaining[0].path);
}
@@ -5111,6 +5314,12 @@
remaining: remaining.length,
});
});
+ if (completeReviewMerge && !errorMessage) {
+ reviewConflictPhase = "ready-to-continue";
+ activeView = "review-center";
+ await tick();
+ await continueReviewConflictMerge();
+ }
}
// ── Event handlers ─────────────────────────────────────────────────────────
@@ -5360,8 +5569,16 @@
integrations={gitIntegrationSettings}
initialQuery={reviewCenterInitialQuery}
initialSourceId={reviewCenterInitialSourceId}
+ localResolutionRequestId={reviewConflictRequestId}
+ localResolutionPhase={reviewConflictPhase}
+ localResolutionMessage={reviewConflictMessage}
loadCredential={loadStoredCredential}
onOpenSettings={() => { appSettingsOpen = true; }}
+ onStartLocalResolution={startReviewConflictResolution}
+ onOpenLocalResolver={reopenReviewConflictResolver}
+ onContinueLocalResolution={continueReviewConflictMerge}
+ onAbortLocalResolution={abortReviewConflictResolution}
+ onPushLocalResolution={pushReviewConflictResolution}
/>
{:else}
diff --git a/src/lib/components/ReviewCenter.svelte b/src/lib/components/ReviewCenter.svelte
index 05c45f7..e3d4b77 100644
--- a/src/lib/components/ReviewCenter.svelte
+++ b/src/lib/components/ReviewCenter.svelte
@@ -1,26 +1,37 @@
-
+
-
Review Center
{activeSource?.label ?? ""}
+
Review Center
{#if sources.length === 0}
{de ? "Keine Integration eingerichtet" : "No integration configured"}
{de ? "Verbinde GitHub, GitLab, Gitea oder Azure DevOps, um Pull- und Merge-Requests hier zu sehen." : "Connect GitHub, GitLab, Gitea, or Azure DevOps to see pull and merge requests here."}