From c46a875a9134e415051e289b984169d0f3640247 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 7 Sep 2026 17:33:54 +0200 Subject: [PATCH 1/3] 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}} From 13660c801efcb615e7071290ef0207b680d19e98 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 7 Sep 2026 18:54:06 +0200 Subject: [PATCH 2/3] feat(review): auto-continue merge and poll provider after push Coordinate local conflict resolutions with automated push and provider- side merge status checks. When a local resolution completes the app advances the merge workflow, pushes the updated branch, and attempts to continue the merge. The UI shows a checking state and disables relevant actions while the provider rechecks to prevent duplicate operations. This streamlines finishing conflict resolution and keeps PR status in sync with remote providers. - Automatically push and continue merge when local resolutions finish - Poll provider for updated mergeability and refresh request details - Add guards to disable UI actions while waiting for remote status --- src/App.svelte | 25 ++++++++++++- src/lib/components/ReviewCenter.svelte | 51 ++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 6f1ee0f..a24b505 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -2700,7 +2700,6 @@ 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}` @@ -2717,6 +2716,8 @@ } 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) { @@ -2731,12 +2732,19 @@ } async function continueReviewConflictMerge() { - if (reviewConflictPhase !== "ready-to-continue" || hasConflicts || !mergeInProgress) return; + 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}`; } } @@ -5273,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) { @@ -5291,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); } @@ -5299,6 +5314,12 @@ remaining: remaining.length, }); }); + if (completeReviewMerge && !errorMessage) { + reviewConflictPhase = "ready-to-continue"; + activeView = "review-center"; + await tick(); + await continueReviewConflictMerge(); + } } // ── Event handlers ───────────────────────────────────────────────────────── diff --git a/src/lib/components/ReviewCenter.svelte b/src/lib/components/ReviewCenter.svelte index ecbf4e2..913a1d6 100644 --- a/src/lib/components/ReviewCenter.svelte +++ b/src/lib/components/ReviewCenter.svelte @@ -48,6 +48,7 @@ let collapsedRepositories = $state(new Set()); let loadGeneration = 0; let loadedStates = $state(new Set<"open" | "merged" | "closed">()); + let completedResolutionRefreshKey = ""; const SOURCE_TIMEOUT_MS = 46_000; const de = $derived(language === "de"); @@ -81,6 +82,15 @@ return () => window.removeEventListener("click", closeActionMenu); }); + $effect(() => { + const key = localResolutionPhase === "complete" && localResolutionRequestId && selectedSourceId + ? `${selectedSourceId}:${localResolutionRequestId}` + : ""; + if (!key || key === completedResolutionRefreshKey) return; + completedResolutionRefreshKey = key; + void refreshCompletedResolution(key, localResolutionRequestId, selectedSourceId); + }); + function withTimeout(promise: Promise, source: string, timeoutMs = SOURCE_TIMEOUT_MS): Promise { return new Promise((resolve, reject) => { const seconds = Math.round(timeoutMs / 1_000); @@ -246,12 +256,43 @@ } finally { detailLoadingId = ""; } } + async function refreshCompletedResolution(key: string, requestId: string, sourceId: string) { + const source = sources.find((candidate) => candidate.id === sourceId); + const initialRequest = requests.find((candidate) => candidate.id === requestId); + if (!source || !initialRequest) return; + let request: IntegrationReviewRequest = initialRequest; + actionNotice = de ? "Branch wurde gepusht. Der Merge-Status wird geprüft …" : "Branch pushed. Checking the merge status …"; + try { + const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000); + if (!credential?.password) return; + for (const delay of [700, 1_500, 3_000, 5_000]) { + await new Promise((resolve) => window.setTimeout(resolve, delay)); + if (completedResolutionRefreshKey !== key || selectedSourceId !== sourceId) return; + const details: IntegrationReviewRequest = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label, 20_000); + requests = requests.map((candidate) => candidate.id === details.id ? details : candidate); + request = details; + if (details.mergeStatus === "mergeable") { + actionNotice = de ? "Der Konflikt ist gelöst. Der Request kann jetzt zusammengeführt werden." : "The conflict is resolved. The request can now be merged."; + return; + } + if (details.mergeStatus !== "conflicts" && details.mergeStatus !== "checking") return; + } + actionNotice = de ? "Der Anbieter prüft den neuen Branch noch. Aktualisiere den Status in einigen Sekunden erneut." : "The provider is still checking the updated branch. Refresh the status again in a few seconds."; + } catch (error) { + errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }]; + } + } + function hasConflicts(request: IntegrationReviewRequest): boolean { return request.mergeStatus === "conflicts"; } function isLocalResolutionActive(request: IntegrationReviewRequest): boolean { return request.id === localResolutionRequestId && localResolutionPhase !== "idle"; } + function isWaitingForResolvedStatus(request: IntegrationReviewRequest): boolean { + return isLocalResolutionActive(request) && localResolutionPhase === "complete" && hasConflicts(request); + } + async function startLocalResolution(request: IntegrationReviewRequest) { const source = activeSource; if (!source || !request.sourceBranch || !request.targetBranch) return; @@ -262,6 +303,7 @@ } async function runPrimaryAction(request: IntegrationReviewRequest) { + if (isWaitingForResolvedStatus(request)) return; 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"); @@ -269,6 +311,7 @@ } function primaryActionLabel(request: IntegrationReviewRequest): string { + if (isWaitingForResolvedStatus(request)) return de ? "Status wird geprüft" : "Checking status"; 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"; @@ -402,13 +445,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} + {#if isWaitingForResolvedStatus(request)}{:else if hasConflicts(request)}{:else}{/if} {:else if request.state === "closed"} @@ -440,7 +483,7 @@

{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 || isWaitingForResolvedStatus(selected)}{de ? "Der Anbieter prüft den gelösten Branch …" : "The provider is checking the resolved branch …"}{: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.")}
@@ -457,7 +500,7 @@
From f607c6b5cf206bba941a1573d92578f6d9a6abfc Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 7 Sep 2026 21:42:39 +0200 Subject: [PATCH 3/3] feat(review-center): enhance detail panel, actions, and search Refactor the Review Center component to improve layout, accessibility, and user flow. Add a SelectMenu integration picker and reorganize state tabs and search into a cleaner toolbar. Bind the detail panel, close it on outside pointer events, and introduce richer merge/conflict UI with a local-resolution flow plus comment formatting helpers and action tweaks. - Add SelectMenu for integration selection and cleaner toolbar layout - Bind detail panel, handle outside-click closing, and focus helpers - Improve conflict/merge presentation and action menu behaviors --- src/lib/components/ReviewCenter.svelte | 196 ++++++++++++++++++------- 1 file changed, 143 insertions(+), 53 deletions(-) diff --git a/src/lib/components/ReviewCenter.svelte b/src/lib/components/ReviewCenter.svelte index 913a1d6..e3d4b77 100644 --- a/src/lib/components/ReviewCenter.svelte +++ b/src/lib/components/ReviewCenter.svelte @@ -1,11 +1,12 @@ -
+
-

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."}

{:else} - -
+ + +
+ ({ value: source.id, label: source.label }))} ariaLabel={de ? "Integration auswählen" : "Select integration"} onChange={selectSource} /> +
- -
- -
{#if actionNotice}
{actionNotice}
{/if} @@ -418,7 +433,7 @@
- Status{de ? "Request" : "Request"}{de ? "Autor" : "Author"}CollaboratorsRepo/Branch{de ? "Aktion" : "Action"} + StatusRequest{de ? "Autor" : "Author"}{de ? "Prüfer" : "Reviewers"}{de ? "Repository / Branch" : "Repository / Branch"}{de ? "Aktion" : "Action"}
{#if loading && requests.length === 0}
{de ? "Requests werden geladen …" : "Loading requests …"}
@@ -439,7 +454,7 @@ {#if request.state === "merged"}{:else if request.state === "closed"}{:else if request.state === "draft"}{:else}{/if} {stateLabel(request.state)}{formatRelativeDate(request.updatedAt || request.createdAt)} - #{request.number}{request.title}+{request.additions ?? "–"}/−{request.deletions ?? "–"}{#if request.changedFiles !== null}{request.changedFiles} {de ? "Dateien" : "files"}{:else if detailLoadingId === request.id}{de ? "Lädt …" : "Loading …"}{/if} + #{request.number}{request.title}+{request.additions ?? "–"}/−{request.deletions ?? "–"}{#if request.changedFiles !== null}{request.changedFiles} {request.changedFiles === 1 ? (de ? "Datei geändert" : "file changed") : (de ? "Dateien geändert" : "files changed")}{:else if detailLoadingId === request.id}{de ? "Lädt …" : "Loading …"}{/if} {initials(request.author)}{request.author || (de ? "Unbekannt" : "Unknown")} {#if request.collaborators.length}{#each request.collaborators.slice(0, 3) as collaborator}{initials(collaborator)}{/each}{:else}{/if} {request.repositoryName}{#if request.sourceBranch && request.targetBranch}{request.sourceBranch}{request.targetBranch}{:else}{de ? "Branches werden geladen …" : "Loading branches …"}{/if} @@ -449,10 +464,10 @@ {#if actionMenuId === request.id} - {#if request.state === "open" || request.state === "draft"} - {#if isWaitingForResolvedStatus(request)}{:else if hasConflicts(request)}{:else}{/if} - + {#if isWaitingForResolvedStatus(request)}{:else if hasConflicts(request)}{:else}{/if} + + {:else if request.state === "closed"} @@ -472,36 +487,38 @@
{#if detailOpen && selected} -