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