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

{:else} - -
+ + +
+ ({ value: source.id, label: source.label }))} ariaLabel={de ? "Integration auswählen" : "Select integration"} onChange={selectSource} /> +
- -
- -
{#if actionNotice}
{actionNotice}
{/if} @@ -351,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 …"}
@@ -372,20 +454,20 @@ {#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} - + {#if actionMenuId === request.id} - {#if request.state === "open" || request.state === "draft"} - - + {#if isWaitingForResolvedStatus(request)}{:else if hasConflicts(request)}{:else}{/if} + + {:else if request.state === "closed"} @@ -405,20 +487,38 @@
{#if detailOpen && selected} -