feat(review-center): add local pull-request conflict resolution flow
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.
This commit is contained in:
@@ -28,7 +28,7 @@ Fast, simple, and designed for developers who want a clean Git experience withou
|
|||||||
|
|
||||||
## 📸 Preview
|
## 📸 Preview
|
||||||
|
|
||||||
> Screenshots coming soon.
|
> Screenshots comes later.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+196
@@ -168,6 +168,7 @@
|
|||||||
GitIntegrationSecretUpdate,
|
GitIntegrationSecretUpdate,
|
||||||
GitIntegrationSettings,
|
GitIntegrationSettings,
|
||||||
GitIntegrationProvider,
|
GitIntegrationProvider,
|
||||||
|
IntegrationReviewRequest,
|
||||||
GitBlameLine,
|
GitBlameLine,
|
||||||
GitBranch as GitBranchInfo,
|
GitBranch as GitBranchInfo,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
@@ -218,6 +219,7 @@
|
|||||||
|
|
||||||
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
type UpdateToastState = "available" | "downloading" | "installed" | "error";
|
||||||
type AppView = "management" | "review-center" | "repository";
|
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 CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
|
||||||
type CredentialMode = "credentials" | "token";
|
type CredentialMode = "credentials" | "token";
|
||||||
type PendingDiscard =
|
type PendingDiscard =
|
||||||
@@ -317,6 +319,12 @@
|
|||||||
let activeView: AppView = "management";
|
let activeView: AppView = "management";
|
||||||
let reviewCenterInitialQuery = "";
|
let reviewCenterInitialQuery = "";
|
||||||
let reviewCenterInitialSourceId = "";
|
let reviewCenterInitialSourceId = "";
|
||||||
|
let reviewConflictRequestId = "";
|
||||||
|
let reviewConflictPhase: ReviewConflictPhase = "idle";
|
||||||
|
let reviewConflictMessage = "";
|
||||||
|
let reviewConflictRepoPath = "";
|
||||||
|
let reviewConflictRemote = "";
|
||||||
|
let reviewConflictPushRequested = false;
|
||||||
let repoTabs: RepoTab[] = [];
|
let repoTabs: RepoTab[] = [];
|
||||||
let repoTabContextMenu: RepoTabContextMenu | null = null;
|
let repoTabContextMenu: RepoTabContextMenu | null = null;
|
||||||
let recentRepoPaths: string[] = [];
|
let recentRepoPaths: string[] = [];
|
||||||
@@ -2598,6 +2606,172 @@
|
|||||||
trackEvent("review_center_opened", { integrations: Object.values(gitIntegrationSettings.providers).filter((provider) => provider.enabled && provider.tokenStored).length });
|
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) {
|
async function selectRepoTab(path: string) {
|
||||||
if (isBusy) return;
|
if (isBusy) return;
|
||||||
if (activeView === "repository" && sameRepoPath(activeRepoPath, path)) return;
|
if (activeView === "repository" && sameRepoPath(activeRepoPath, path)) return;
|
||||||
@@ -3906,6 +4080,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleRemoteResult("push", key, fromStore, username, mode);
|
handleRemoteResult("push", key, fromStore, username, mode);
|
||||||
|
completeReviewConflictPushIfReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doActualRemoteRename(
|
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) {
|
async function selectConflictFile(path: string) {
|
||||||
if (path === conflictTarget || isBusy) return;
|
if (path === conflictTarget || isBusy) return;
|
||||||
const openExternally = externalToolsSettings.mergeOpenMode === "external";
|
const openExternally = externalToolsSettings.mergeOpenMode === "external";
|
||||||
@@ -5360,8 +5548,16 @@
|
|||||||
integrations={gitIntegrationSettings}
|
integrations={gitIntegrationSettings}
|
||||||
initialQuery={reviewCenterInitialQuery}
|
initialQuery={reviewCenterInitialQuery}
|
||||||
initialSourceId={reviewCenterInitialSourceId}
|
initialSourceId={reviewCenterInitialSourceId}
|
||||||
|
localResolutionRequestId={reviewConflictRequestId}
|
||||||
|
localResolutionPhase={reviewConflictPhase}
|
||||||
|
localResolutionMessage={reviewConflictMessage}
|
||||||
loadCredential={loadStoredCredential}
|
loadCredential={loadStoredCredential}
|
||||||
onOpenSettings={() => { appSettingsOpen = true; }}
|
onOpenSettings={() => { appSettingsOpen = true; }}
|
||||||
|
onStartLocalResolution={startReviewConflictResolution}
|
||||||
|
onOpenLocalResolver={reopenReviewConflictResolver}
|
||||||
|
onContinueLocalResolution={continueReviewConflictMerge}
|
||||||
|
onAbortLocalResolution={abortReviewConflictResolution}
|
||||||
|
onPushLocalResolution={pushReviewConflictResolution}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Workspace -->
|
<!-- Workspace -->
|
||||||
|
|||||||
@@ -11,16 +11,26 @@
|
|||||||
import { configuredIntegrationSources, integrationCredentialKey, providerLabel } from "../integrations";
|
import { configuredIntegrationSources, integrationCredentialKey, providerLabel } from "../integrations";
|
||||||
import type { AppLanguage, GitIntegrationSettings, IntegrationReviewAction, IntegrationReviewRequest, IntegrationReviewState, StoredCredential } from "../types";
|
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 {
|
interface Props {
|
||||||
language: AppLanguage;
|
language: AppLanguage;
|
||||||
integrations: GitIntegrationSettings;
|
integrations: GitIntegrationSettings;
|
||||||
initialQuery?: string;
|
initialQuery?: string;
|
||||||
initialSourceId?: string;
|
initialSourceId?: string;
|
||||||
|
localResolutionRequestId?: string;
|
||||||
|
localResolutionPhase?: LocalResolutionPhase;
|
||||||
|
localResolutionMessage?: string;
|
||||||
loadCredential: (key: string) => Promise<StoredCredential | null>;
|
loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
|
onStartLocalResolution?: (request: IntegrationReviewRequest, sourceId: string) => void | Promise<void>;
|
||||||
|
onOpenLocalResolver?: () => void | Promise<void>;
|
||||||
|
onContinueLocalResolution?: () => void | Promise<void>;
|
||||||
|
onAbortLocalResolution?: () => void | Promise<void>;
|
||||||
|
onPushLocalResolution?: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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<IntegrationReviewRequest[]>([]);
|
let requests = $state<IntegrationReviewRequest[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let errors = $state<Array<{ source: string; message: string }>>([]);
|
let errors = $state<Array<{ source: string; message: string }>>([]);
|
||||||
@@ -238,14 +248,28 @@
|
|||||||
|
|
||||||
function hasConflicts(request: IntegrationReviewRequest): boolean { return request.mergeStatus === "conflicts"; }
|
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) {
|
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 if (request.state === "closed") await performReviewAction(request, "reopen");
|
||||||
else await openRequest(request);
|
else await openRequest(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
function primaryActionLabel(request: IntegrationReviewRequest): string {
|
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 === "open" || request.state === "draft") return de ? "Zusammenführen" : "Merge";
|
||||||
if (request.state === "closed") return de ? "Wieder öffnen" : "Reopen";
|
if (request.state === "closed") return de ? "Wieder öffnen" : "Reopen";
|
||||||
return actionLabel(request.provider);
|
return actionLabel(request.provider);
|
||||||
@@ -378,13 +402,13 @@
|
|||||||
<span class="repo-branch"><strong>{request.repositoryName}</strong>{#if request.sourceBranch && request.targetBranch}<span class="branch-route"><GitBranch size={11} /><code title={request.sourceBranch}>{request.sourceBranch}</code><b>→</b><code title={request.targetBranch}>{request.targetBranch}</code></span>{:else}<span class="branch-loading"><LoaderCircle class="spin" size={11} />{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</span>
|
<span class="repo-branch"><strong>{request.repositoryName}</strong>{#if request.sourceBranch && request.targetBranch}<span class="branch-route"><GitBranch size={11} /><code title={request.sourceBranch}>{request.sourceBranch}</code><b>→</b><code title={request.targetBranch}>{request.targetBranch}</code></span>{:else}<span class="branch-loading"><LoaderCircle class="spin" size={11} />{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</span>
|
||||||
<span class="row-actions">
|
<span class="row-actions">
|
||||||
<span class:merge-action={request.state === "open" || request.state === "draft"} class:conflict-action={hasConflicts(request)} class="provider-action">
|
<span class:merge-action={request.state === "open" || request.state === "draft"} class:conflict-action={hasConflicts(request)} class="provider-action">
|
||||||
<button class="provider-button" class:merge-primary={request.state === "open" || request.state === "draft"} class:conflict={hasConflicts(request)} disabled={hasConflicts(request) || !!actionBusyId} type="button" onclick={(event) => { event.stopPropagation(); void runPrimaryAction(request); }}>{#if actionBusyId === request.id}<LoaderCircle class="spin" size={12} />{:else if request.state === "open" || request.state === "draft"}<GitMerge size={12} />{:else}<ExternalLink size={12} />{/if}{primaryActionLabel(request)}</button>
|
<button class="provider-button" class:merge-primary={request.state === "open" || request.state === "draft"} class:conflict={hasConflicts(request)} disabled={!!actionBusyId || (hasConflicts(request) && (!request.sourceBranch || !request.targetBranch || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")))} type="button" onclick={(event) => { event.stopPropagation(); void runPrimaryAction(request); }}>{#if actionBusyId === request.id || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")}<LoaderCircle class="spin" size={12} />{:else if request.state === "open" || request.state === "draft"}<GitMerge size={12} />{:else}<ExternalLink size={12} />{/if}{primaryActionLabel(request)}</button>
|
||||||
<button class="action-toggle" class:active={actionMenuId === request.id} type="button" aria-label={de ? "Weitere Aktionen" : "More actions"} onclick={(event) => { event.stopPropagation(); actionMenuId = actionMenuId === request.id ? "" : request.id; }}><ChevronDown size={13} /></button>
|
<button class="action-toggle" class:active={actionMenuId === request.id} type="button" aria-label={de ? "Weitere Aktionen" : "More actions"} onclick={(event) => { event.stopPropagation(); actionMenuId = actionMenuId === request.id ? "" : request.id; }}><ChevronDown size={13} /></button>
|
||||||
{#if actionMenuId === request.id}
|
{#if actionMenuId === request.id}
|
||||||
<span class="action-menu" role="menu" tabindex="-1">
|
<span class="action-menu" role="menu" tabindex="-1">
|
||||||
<button type="button" role="menuitem" onclick={() => openDetail(request)}><PanelRightOpen size={13} />{de ? "Request prüfen" : "Review request"}</button>
|
<button type="button" role="menuitem" onclick={() => openDetail(request)}><PanelRightOpen size={13} />{de ? "Request prüfen" : "Review request"}</button>
|
||||||
{#if request.state === "open" || request.state === "draft"}
|
{#if request.state === "open" || request.state === "draft"}
|
||||||
<button type="button" role="menuitem" disabled={actionBusyId === request.id || hasConflicts(request)} onclick={() => void performReviewAction(request, "merge")}><GitMerge size={13} />{hasConflicts(request) ? (de ? "Merge-Konflikt" : "Merge conflict") : reviewActionLabel("merge")}</button>
|
{#if hasConflicts(request)}<button type="button" role="menuitem" disabled={!request.sourceBranch || !request.targetBranch || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")} onclick={() => void startLocalResolution(request)}><GitMerge size={13} />{de ? "Konflikt lokal lösen" : "Resolve conflict locally"}</button>{:else}<button type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "merge")}><GitMerge size={13} />{reviewActionLabel("merge")}</button>{/if}
|
||||||
<button type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "approve")}><Check size={13} />{reviewActionLabel("approve")}</button>
|
<button type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "approve")}><Check size={13} />{reviewActionLabel("approve")}</button>
|
||||||
<button class="danger" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "close")}><XCircle size={13} />{reviewActionLabel("close")}</button>
|
<button class="danger" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "close")}><XCircle size={13} />{reviewActionLabel("close")}</button>
|
||||||
{:else if request.state === "closed"}
|
{:else if request.state === "closed"}
|
||||||
@@ -414,10 +438,26 @@
|
|||||||
<main class="detail-main">
|
<main class="detail-main">
|
||||||
<section class="detail-title"><span>#{selected.number}</span><h2>{selected.title}</h2><div class="detail-summary"><span class="state-badge" class:open={selected.state === "open"} class:draft={selected.state === "draft"} class:merged={selected.state === "merged"} class:closed={selected.state === "closed"}>{stateLabel(selected.state)}</span><span class="avatar">{initials(selected.author)}</span><strong>{selected.author || (de ? "Unbekannt" : "Unknown")}</strong><span>{de ? "möchte" : "wants to merge"}</span></div><div class="branch-detail"><GitBranch size={15} />{#if selected.sourceBranch && selected.targetBranch}<code>{selected.sourceBranch}</code><span>→</span><code>{selected.targetBranch}</code>{:else}<span>{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</div></section>
|
<section class="detail-title"><span>#{selected.number}</span><h2>{selected.title}</h2><div class="detail-summary"><span class="state-badge" class:open={selected.state === "open"} class:draft={selected.state === "draft"} class:merged={selected.state === "merged"} class:closed={selected.state === "closed"}>{stateLabel(selected.state)}</span><span class="avatar">{initials(selected.author)}</span><strong>{selected.author || (de ? "Unbekannt" : "Unknown")}</strong><span>{de ? "möchte" : "wants to merge"}</span></div><div class="branch-detail"><GitBranch size={15} />{#if selected.sourceBranch && selected.targetBranch}<code>{selected.sourceBranch}</code><span>→</span><code>{selected.targetBranch}</code>{:else}<span>{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</div></section>
|
||||||
<section class="description"><h3>{de ? "Beschreibung" : "Description"}</h3><p class:muted={!selected.description}>{selected.description || (de ? "Keine Beschreibung vorhanden." : "No description provided.")}</p></section>
|
<section class="description"><h3>{de ? "Beschreibung" : "Description"}</h3><p class:muted={!selected.description}>{selected.description || (de ? "Keine Beschreibung vorhanden." : "No description provided.")}</p></section>
|
||||||
<section class="comments-section"><header><h3>{de ? "Kommentare" : "Comments"}</h3><span>{selected.comments?.length ?? 0}</span></header><div class:conflict={hasConflicts(selected)} class="merge-summary">{#if detailLoadingId === selected.id}<LoaderCircle class="spin" size={14} /><span>{de ? "Merge-Status wird geladen …" : "Loading merge status …"}</span>{:else if hasConflicts(selected)}<AlertTriangle size={14} /><strong>{de ? "Dieser Request hat Merge-Konflikte." : "This request has merge conflicts."}</strong>{:else}<Check size={14} /><span>{de ? "Dieser Branch hat keine erkannten Konflikte mit dem Zielbranch." : "This branch has no detected conflicts with the base branch."}</span>{/if}</div>{#if detailLoadingId === selected.id && !(selected.comments?.length)}<div class="comments-loading"><LoaderCircle class="spin" size={13} />{de ? "Kommentare werden geladen …" : "Loading comments …"}</div>{:else if selected.comments?.length}<div class="comment-list">{#each selected.comments as comment (comment.id)}<article><span class="avatar">{initials(comment.author)}</span><div><header><strong>{comment.author || (de ? "Unbekannt" : "Unknown")}</strong><time>{formatRelativeDate(comment.createdAt)}</time></header><p>{comment.body}</p></div></article>{/each}</div>{:else}<p class="no-comments">{de ? "Noch keine Kommentare." : "No comments yet."}</p>{/if}<form class="comment-composer" onsubmit={(event) => { event.preventDefault(); void submitComment(); }}><span class="avatar">{initials(selected.author)}</span><div><textarea bind:value={commentDraft} maxlength="100000" rows="4" placeholder={de ? "Kommentar hinzufügen …" : "Add a comment …"}></textarea><button type="submit" disabled={!commentDraft.trim() || commentPosting}>{#if commentPosting}<LoaderCircle class="spin" size={13} />{/if}{de ? "Kommentar senden" : "Add comment"}</button></div></form></section>
|
<section class="comments-section">
|
||||||
|
<header><h3>{de ? "Kommentare" : "Comments"}</h3><span>{selected.comments?.length ?? 0}</span></header>
|
||||||
|
<div class:conflict={hasConflicts(selected)} class="merge-summary">{#if detailLoadingId === selected.id}<LoaderCircle class="spin" size={14} /><span>{de ? "Merge-Status wird geladen …" : "Loading merge status …"}</span>{:else if hasConflicts(selected)}<AlertTriangle size={14} /><strong>{de ? "Dieser Request hat Merge-Konflikte." : "This request has merge conflicts."}</strong>{:else}<Check size={14} /><span>{de ? "Dieser Branch hat keine erkannten Konflikte mit dem Zielbranch." : "This branch has no detected conflicts with the base branch."}</span>{/if}</div>
|
||||||
|
{#if hasConflicts(selected) || isLocalResolutionActive(selected)}
|
||||||
|
<div class:error={localResolutionPhase === "error"} class:complete={localResolutionPhase === "complete"} class="local-resolution">
|
||||||
|
<div>{#if localResolutionPhase === "preparing"}<LoaderCircle class="spin" size={14} />{:else if localResolutionPhase === "complete"}<Check size={14} />{:else}<GitMerge size={14} />{/if}<span>{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.")}</span></div>
|
||||||
|
<footer>
|
||||||
|
{#if !isLocalResolutionActive(selected) || localResolutionPhase === "idle" || localResolutionPhase === "error"}<button type="button" onclick={() => void startLocalResolution(selected)} disabled={!selected.sourceBranch || !selected.targetBranch}>{de ? "Konflikt lokal lösen" : "Resolve locally"}</button>{/if}
|
||||||
|
{#if isLocalResolutionActive(selected) && localResolutionPhase === "conflicts"}<button type="button" onclick={() => void onOpenLocalResolver()}>{de ? "Konflikt-Editor öffnen" : "Open conflict editor"}</button><button class="secondary" type="button" onclick={() => void onAbortLocalResolution()}>{de ? "Abbrechen" : "Abort"}</button>{/if}
|
||||||
|
{#if isLocalResolutionActive(selected) && localResolutionPhase === "ready-to-continue"}<button type="button" onclick={() => void onContinueLocalResolution()}>{de ? "Merge abschließen" : "Complete merge"}</button><button class="secondary" type="button" onclick={() => void onAbortLocalResolution()}>{de ? "Abbrechen" : "Abort"}</button>{/if}
|
||||||
|
{#if isLocalResolutionActive(selected) && localResolutionPhase === "ready-to-push"}<button type="button" onclick={() => void onPushLocalResolution()}>{de ? "Gelösten Branch pushen" : "Push resolved branch"}</button>{/if}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if detailLoadingId === selected.id && !(selected.comments?.length)}<div class="comments-loading"><LoaderCircle class="spin" size={13} />{de ? "Kommentare werden geladen …" : "Loading comments …"}</div>{:else if selected.comments?.length}<div class="comment-list">{#each selected.comments as comment (comment.id)}<article><span class="avatar">{initials(comment.author)}</span><div><header><strong>{comment.author || (de ? "Unbekannt" : "Unknown")}</strong><time>{formatRelativeDate(comment.createdAt)}</time></header><p>{comment.body}</p></div></article>{/each}</div>{:else}<p class="no-comments">{de ? "Noch keine Kommentare." : "No comments yet."}</p>{/if}
|
||||||
|
<form class="comment-composer" onsubmit={(event) => { event.preventDefault(); void submitComment(); }}><span class="avatar">{initials(selected.author)}</span><div><textarea bind:value={commentDraft} maxlength="100000" rows="4" placeholder={de ? "Kommentar hinzufügen …" : "Add a comment …"}></textarea><button type="submit" disabled={!commentDraft.trim() || commentPosting}>{#if commentPosting}<LoaderCircle class="spin" size={13} />{/if}{de ? "Kommentar senden" : "Add comment"}</button></div></form>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<aside class="detail-sidebar">
|
<aside class="detail-sidebar">
|
||||||
<div class="detail-actions">{#if selected.state === "open" || selected.state === "draft"}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId || hasConflicts(selected)} onclick={() => void performReviewAction(selected, "merge")}><GitMerge size={14} />{hasConflicts(selected) ? (de ? "Merge-Konflikt" : "Merge conflict") : reviewActionLabel("merge")}</button><button class="detail-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "approve")}><Check size={14} />{reviewActionLabel("approve")}</button><button class="detail-action danger-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "close")}><XCircle size={14} />{reviewActionLabel("close")}</button>{:else if selected.state === "closed"}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "reopen")}><RotateCcw size={14} />{reviewActionLabel("reopen")}</button>{/if}<button class="detail-action" type="button" onclick={() => void openRequest()} disabled={!selected.webUrl}><ExternalLink size={14} />{actionLabel(selected.provider)}</button></div>
|
<div class="detail-actions">{#if selected.state === "open" || selected.state === "draft"}{#if hasConflicts(selected)}<button class="detail-action danger-action" type="button" disabled={isLocalResolutionActive(selected) && localResolutionPhase === "preparing"} onclick={() => void startLocalResolution(selected)}><GitMerge size={14} />{de ? "Konflikt lösen" : "Resolve conflict"}</button>{:else}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "merge")}><GitMerge size={14} />{reviewActionLabel("merge")}</button>{/if}<button class="detail-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "approve")}><Check size={14} />{reviewActionLabel("approve")}</button><button class="detail-action danger-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "close")}><XCircle size={14} />{reviewActionLabel("close")}</button>{:else if selected.state === "closed"}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "reopen")}><RotateCcw size={14} />{reviewActionLabel("reopen")}</button>{/if}<button class="detail-action" type="button" onclick={() => void openRequest()} disabled={!selected.webUrl}><ExternalLink size={14} />{actionLabel(selected.provider)}</button></div>
|
||||||
<section class="people-section"><header><h3>{de ? "Teilnehmer" : "Participants"}</h3></header><div class="people compact"><span class="avatar">{initials(selected.author)}</span></div></section>
|
<section class="people-section"><header><h3>{de ? "Teilnehmer" : "Participants"}</h3></header><div class="people compact"><span class="avatar">{initials(selected.author)}</span></div></section>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -453,5 +493,6 @@
|
|||||||
.comment-list{align-content:start;grid-auto-rows:max-content}
|
.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)}
|
.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))}
|
.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}}
|
@media(max-width:760px){.detail-panel{inset:0;width:100%;min-width:0;max-width:none}}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user