Review center #37
@@ -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