From c46a875a9134e415051e289b984169d0f3640247 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 7 Sep 2026 17:33:54 +0200 Subject: [PATCH] feat(review-center): add local pull-request conflict resolution flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a local conflict resolution workflow that allows resolving PR merge conflicts from the review center. The change implements host and repository matching, prepares and merges branches locally, opens the resolve editor, and tracks a multi-phase state machine to continue, abort, or push the resolved branch back to the remote. - Core resolution logic to locate matching repos, prepare merges, and manage phases (preparing → conflicts → ready-to-push → complete). - UI wiring and callbacks to start, reopen, continue, abort, and push local resolutions from the review center. - Helpers to open the conflict editor, monitor merge state, and mark completion after a successful push. --- README.md | 2 +- src/App.svelte | 196 +++++++++++++++++++++++++ src/lib/components/ReviewCenter.svelte | 55 ++++++- 3 files changed, 245 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9b70277..425cc6d 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Fast, simple, and designed for developers who want a clean Git experience withou ## 📸 Preview -> Screenshots coming soon. +> Screenshots comes later. --- diff --git a/src/App.svelte b/src/App.svelte index f0fca95..6f1ee0f 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,172 @@ 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 ((status?.ahead ?? 0) > 0) throw new Error(appLanguage === "de" ? `Der lokale Branch „${request.sourceBranch}“ enthält noch nicht gepushte Commits. Pushe oder sichere sie zuerst.` : `The local branch “${request.sourceBranch}” contains unpushed commits. Push or preserve them first.`); + 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."; + } + 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 (reviewConflictPhase !== "ready-to-continue" || hasConflicts || !mergeInProgress) return; + await continueMerge(); + activeView = "review-center"; + 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."; + } + } + + 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 +4080,7 @@ } handleRemoteResult("push", key, fromStore, username, mode); + completeReviewConflictPushIfReady(); } async function doActualRemoteRename( @@ -5042,6 +5217,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"; @@ -5360,8 +5548,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..ecbf4e2 100644 --- a/src/lib/components/ReviewCenter.svelte +++ b/src/lib/components/ReviewCenter.svelte @@ -11,16 +11,26 @@ import { configuredIntegrationSources, integrationCredentialKey, providerLabel } from "../integrations"; import type { AppLanguage, GitIntegrationSettings, IntegrationReviewAction, IntegrationReviewRequest, IntegrationReviewState, StoredCredential } from "../types"; + type LocalResolutionPhase = "idle" | "preparing" | "conflicts" | "ready-to-continue" | "ready-to-push" | "complete" | "error"; + interface Props { language: AppLanguage; integrations: GitIntegrationSettings; initialQuery?: string; initialSourceId?: string; + localResolutionRequestId?: string; + localResolutionPhase?: LocalResolutionPhase; + localResolutionMessage?: string; loadCredential: (key: string) => Promise; onOpenSettings: () => void; + onStartLocalResolution?: (request: IntegrationReviewRequest, sourceId: string) => void | Promise; + onOpenLocalResolver?: () => void | Promise; + onContinueLocalResolution?: () => void | Promise; + onAbortLocalResolution?: () => void | Promise; + onPushLocalResolution?: () => void | Promise; } - let { language = "en", integrations, initialQuery = "", initialSourceId = "", loadCredential, onOpenSettings = () => {} }: Props = $props(); + let { language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props(); let requests = $state([]); let loading = $state(false); let errors = $state>([]); @@ -238,14 +248,28 @@ function hasConflicts(request: IntegrationReviewRequest): boolean { return request.mergeStatus === "conflicts"; } + function isLocalResolutionActive(request: IntegrationReviewRequest): boolean { + return request.id === localResolutionRequestId && localResolutionPhase !== "idle"; + } + + async function startLocalResolution(request: IntegrationReviewRequest) { + const source = activeSource; + if (!source || !request.sourceBranch || !request.targetBranch) return; + actionMenuId = ""; + selectedId = request.id; + detailOpen = true; + await onStartLocalResolution(request, source.id); + } + async function runPrimaryAction(request: IntegrationReviewRequest) { - if (request.state === "open" || request.state === "draft") await performReviewAction(request, "merge"); + if (hasConflicts(request)) await startLocalResolution(request); + else if (request.state === "open" || request.state === "draft") await performReviewAction(request, "merge"); else if (request.state === "closed") await performReviewAction(request, "reopen"); else await openRequest(request); } function primaryActionLabel(request: IntegrationReviewRequest): string { - if (hasConflicts(request)) return de ? "Konflikt" : "Conflict"; + if (hasConflicts(request)) return de ? "Konflikt lösen" : "Resolve conflict"; if (request.state === "open" || request.state === "draft") return de ? "Zusammenführen" : "Merge"; if (request.state === "closed") return de ? "Wieder öffnen" : "Reopen"; return actionLabel(request.provider); @@ -378,13 +402,13 @@ {request.repositoryName}{#if request.sourceBranch && request.targetBranch}{request.sourceBranch}{request.targetBranch}{:else}{de ? "Branches werden geladen …" : "Loading branches …"}{/if} - + {#if actionMenuId === request.id} {#if request.state === "open" || request.state === "draft"} - + {#if hasConflicts(request)}{:else}{/if} {:else if request.state === "closed"} @@ -414,10 +438,26 @@
#{selected.number}

{selected.title}

{stateLabel(selected.state)}{initials(selected.author)}{selected.author || (de ? "Unbekannt" : "Unknown")}{de ? "möchte" : "wants to merge"}
{#if selected.sourceBranch && selected.targetBranch}{selected.sourceBranch}{selected.targetBranch}{:else}{de ? "Branches werden geladen …" : "Loading branches …"}{/if}

{de ? "Beschreibung" : "Description"}

{selected.description || (de ? "Keine Beschreibung vorhanden." : "No description provided.")}

-

{de ? "Kommentare" : "Comments"}

{selected.comments?.length ?? 0}
{#if detailLoadingId === selected.id}{de ? "Merge-Status wird geladen …" : "Loading merge status …"}{:else if hasConflicts(selected)}{de ? "Dieser Request hat Merge-Konflikte." : "This request has merge conflicts."}{:else}{de ? "Dieser Branch hat keine erkannten Konflikte mit dem Zielbranch." : "This branch has no detected conflicts with the base branch."}{/if}
{#if detailLoadingId === selected.id && !(selected.comments?.length)}
{de ? "Kommentare werden geladen …" : "Loading comments …"}
{:else if selected.comments?.length}
{#each selected.comments as comment (comment.id)}
{initials(comment.author)}
{comment.author || (de ? "Unbekannt" : "Unknown")}

{comment.body}

{/each}
{:else}

{de ? "Noch keine Kommentare." : "No comments yet."}

{/if}
{ event.preventDefault(); void submitComment(); }}>{initials(selected.author)}
+
+

{de ? "Kommentare" : "Comments"}

{selected.comments?.length ?? 0}
+
{#if detailLoadingId === selected.id}{de ? "Merge-Status wird geladen …" : "Loading merge status …"}{:else if hasConflicts(selected)}{de ? "Dieser Request hat Merge-Konflikte." : "This request has merge conflicts."}{:else}{de ? "Dieser Branch hat keine erkannten Konflikte mit dem Zielbranch." : "This branch has no detected conflicts with the base branch."}{/if}
+ {#if hasConflicts(selected) || isLocalResolutionActive(selected)} +
+
{#if localResolutionPhase === "preparing"}{:else if localResolutionPhase === "complete"}{:else}{/if}{isLocalResolutionActive(selected) && localResolutionMessage ? localResolutionMessage : (de ? "Löse den Konflikt mit dem lokalen Repository und Gittys Konflikt-Editor." : "Resolve this conflict with the local repository and Gitty's conflict editor.")}
+
+ {#if !isLocalResolutionActive(selected) || localResolutionPhase === "idle" || localResolutionPhase === "error"}{/if} + {#if isLocalResolutionActive(selected) && localResolutionPhase === "conflicts"}{/if} + {#if isLocalResolutionActive(selected) && localResolutionPhase === "ready-to-continue"}{/if} + {#if isLocalResolutionActive(selected) && localResolutionPhase === "ready-to-push"}{/if} +
+
+ {/if} + {#if detailLoadingId === selected.id && !(selected.comments?.length)}
{de ? "Kommentare werden geladen …" : "Loading comments …"}
{:else if selected.comments?.length}
{#each selected.comments as comment (comment.id)}
{initials(comment.author)}
{comment.author || (de ? "Unbekannt" : "Unknown")}

{comment.body}

{/each}
{:else}

{de ? "Noch keine Kommentare." : "No comments yet."}

{/if} +
{ event.preventDefault(); void submitComment(); }}>{initials(selected.author)}
+
@@ -453,5 +493,6 @@ .comment-list{align-content:start;grid-auto-rows:max-content} .detail-panel{inset:0 0 0 auto;width:50%;min-width:760px;max-width:100%;height:100%;border-left:1px solid var(--color-border);box-shadow:-14px 0 30px #0007}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)} .provider-action.merge-action .action-toggle{border-color:#4fa565;color:#70d58d;background:color-mix(in srgb,#4fa565 15%,var(--color-surface))}.provider-action.merge-action .action-toggle:hover,.provider-action.merge-action .action-toggle.active{color:#fff;background:#397849}.provider-action.conflict-action .action-toggle{border-color:#a74b55;color:#e0737b;background:color-mix(in srgb,#a74b55 12%,var(--color-surface))}.panel-button{border-color:color-mix(in srgb,var(--color-accent) 45%,var(--color-border));color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 6%,var(--color-surface))}.panel-button:hover,.panel-button.active{border-color:var(--color-accent);color:#fff;background:color-mix(in srgb,var(--color-accent) 25%,var(--color-surface))} + .local-resolution{display:grid;flex:0 0 auto;gap:9px;margin:0 0 11px;padding:10px 12px;border:1px solid color-mix(in srgb,#e0737b 52%,var(--color-border));background:color-mix(in srgb,#e0737b 6%,var(--color-surface))}.local-resolution>div{display:flex;align-items:flex-start;gap:8px;color:var(--color-ink-muted);font-size:10.5px;line-height:1.45}.local-resolution>div>:global(svg){flex:0 0 auto;margin-top:1px;color:#e0737b}.local-resolution footer{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}.local-resolution button{display:inline-flex;min-height:28px;align-items:center;padding:0 9px;border:1px solid #c65e67;color:#f08a92;background:color-mix(in srgb,#c65e67 12%,var(--color-surface))}.local-resolution button:hover:not(:disabled){color:#fff;background:#8e3f47}.local-resolution button.secondary{border-color:var(--color-border-input);color:var(--color-ink-muted);background:var(--app-button-bg)}.local-resolution.error{border-color:#d29a47;background:color-mix(in srgb,#d29a47 7%,var(--color-surface))}.local-resolution.error>div>:global(svg){color:#d29a47}.local-resolution.complete{border-color:#4fa565;background:color-mix(in srgb,#4fa565 7%,var(--color-surface))}.local-resolution.complete>div>:global(svg){color:#63c783} @media(max-width:760px){.detail-panel{inset:0;width:100%;min-width:0;max-width:none}}