@@ -0,0 +1,454 @@
< script lang = "ts" >
import { onMount } from "svelte";
import { cubicOut } from "svelte/easing";
import { fly } from "svelte/transition";
import {
AlertTriangle, Check, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown,
CircleDotDashed, ExternalLink, GitBranch, GitMerge, GitPullRequest, Inbox,
LoaderCircle, PanelRightOpen, RefreshCw, RotateCcw, Search, Settings2, X, XCircle,
} from "@lucide/svelte";
import { addIntegrationReviewComment , getIntegrationReviewDetails , listIntegrationReviewRequests , openInBrowser , runIntegrationReviewAction } from "../git";
import { configuredIntegrationSources , integrationCredentialKey , providerLabel } from "../integrations";
import type { AppLanguage , GitIntegrationSettings , IntegrationReviewAction , IntegrationReviewRequest , IntegrationReviewState , StoredCredential } from "../types";
interface Props {
language: AppLanguage;
integrations: GitIntegrationSettings;
loadCredential: (key: string) => Promise< StoredCredential | null > ;
onOpenSettings: () => void;
}
let { language = "en" , integrations , loadCredential , onOpenSettings = () => {} } : Props = $props();
let requests = $state< IntegrationReviewRequest [ ] > ([]);
let loading = $state(false);
let errors = $state< Array < { source : string ; message : string } > >([]);
let query = $state("");
let stateFilter = $state< IntegrationReviewState > ("open");
let selectedSourceId = $state("");
let selectedId = $state("");
let detailOpen = $state(false);
let actionMenuId = $state("");
let actionBusyId = $state("");
let actionNotice = $state("");
let detailLoadingId = $state("");
let commentDraft = $state("");
let commentPosting = $state(false);
let collapsedRepositories = $state(new Set< string > ());
let loadGeneration = 0;
let loadedStates = $state(new Set< "open" | "merged" | "closed">());
const SOURCE_TIMEOUT_MS = 46_000;
const de = $derived(language === "de");
const sources = $derived(configuredIntegrationSources(integrations));
const activeSource = $derived(sources.find((source) => source.id === selectedSourceId) ?? null);
const normalizedQuery = $derived(query.trim().toLocaleLowerCase());
const filtered = $derived(requests.filter((request) => {
const haystack = `${ request . title } ${ request . repositoryName } ${ request . author } ${ request . number } ${ request . sourceBranch } ${ request . targetBranch } `.toLocaleLowerCase();
return request.state === stateFilter & & (!normalizedQuery || haystack.includes(normalizedQuery));
}));
const groupedRequests = $derived.by(() => {
const groups = new Map< string , IntegrationReviewRequest [ ] > ();
for (const request of filtered) {
const key = request.repositoryName || providerLabel(request.provider);
groups.set(key, [...(groups.get(key) ?? []), request]);
}
return [...groups.entries()].map(([name, items]) => ({ name , items } ));
});
const selected = $derived(requests.find((request) => request.id === selectedId) ?? null);
const backendRestartRequired = $derived(errors.some((error) => /command.*not found|unknown command|not registered/i.test(error.message)));
onMount(() => {
const closeActionMenu = () => { actionMenuId = "" ; } ;
window.addEventListener("click", closeActionMenu);
const source = sources[0];
if (source) {
selectedSourceId = source.id;
void loadRequests("open", source.id);
}
return () => window.removeEventListener("click", closeActionMenu);
});
function withTimeout< T > (promise: Promise< T > , source: string, timeoutMs = SOURCE_TIMEOUT_MS): Promise< T > {
return new Promise< T > ((resolve, reject) => {
const seconds = Math.round(timeoutMs / 1_000);
const timer = window.setTimeout(() => reject(new Error(de ? `${ source } hat nach ${ seconds } Sekunden nicht geantwortet.` : `${ source } did not respond within ${ seconds } seconds.`)), timeoutMs);
promise.then((value) => { window . clearTimeout ( timer ); resolve ( value ); } , (error) => { window . clearTimeout ( timer ); reject ( error ); } );
});
}
async function loadRequests(state: "open" | "merged" | "closed" = stateFilter === "draft" ? "open" : stateFilter, sourceId = selectedSourceId) {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return;
const generation = ++loadGeneration;
loading = true;
errors = [];
requests = requests.filter((request) => state === "open" ? request.state !== "open" & & request.state !== "draft" : request.state !== state);
selectedId = "";
detailOpen = false;
actionMenuId = "";
actionNotice = "";
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${ source . label } keychain`, 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
const loaded = await withTimeout(listIntegrationReviewRequests(source.provider, source.baseUrl, credential.username, credential.password, state), source.label);
if (generation !== loadGeneration) return;
requests = [...requests, ...loaded].sort((left, right) => Date.parse(right.updatedAt || right.createdAt) - Date.parse(left.updatedAt || left.createdAt));
loadedStates = new Set([...loadedStates, state]);
const missingBranches = loaded.filter((request) => !request.sourceBranch || !request.targetBranch);
if (missingBranches.length) void enrichMissingBranches(missingBranches, source, credential, generation);
} catch (error) {
if (generation !== loadGeneration) return;
errors = [{ source : source.label , message : error instanceof Error ? error.message : String ( error ) } ];
} finally {
if (generation === loadGeneration) loading = false;
}
}
async function enrichMissingBranches(items: IntegrationReviewRequest[], source: (typeof sources)[number], credential: StoredCredential, generation: number) {
const queue = [...items];
const worker = async () => {
while (queue.length && generation === loadGeneration) {
const request = queue.shift();
if (!request) return;
try {
const details = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label, 20_000);
if (generation !== loadGeneration) return;
requests = requests.map((item) => item.id === details.id ? details : item);
} catch {
// Keep the request visible; selecting it retries the detail request and surfaces the error.
}
}
};
await Promise.all(Array.from({ length : Math.min ( 4 , queue . length ) } , worker));
}
function selectSource(sourceId: string) {
if (sourceId === selectedSourceId) return;
selectedSourceId = sourceId;
stateFilter = "open";
requests = [];
errors = [];
selectedId = "";
detailOpen = false;
actionMenuId = "";
collapsedRepositories = new Set();
loadedStates = new Set();
void loadRequests("open", sourceId);
}
function selectState(state: IntegrationReviewState) {
stateFilter = state;
selectedId = "";
detailOpen = false;
const apiState = state === "draft" ? "open" : state;
if (!loadedStates.has(apiState)) void loadRequests(apiState);
}
function count(state: IntegrationReviewState): number {
return requests.filter((request) => request.state === state).length;
}
function countLabel(state: IntegrationReviewState): string {
return loadedStates.has(state === "draft" ? "open" : state) ? String(count(state)) : "";
}
function stateLabel(state: IntegrationReviewState): string {
if (state === "draft") return "Draft";
if (state === "merged") return de ? "Zusammengeführt" : "Merged";
if (state === "closed") return de ? "Geschlossen" : "Closed";
return "Open";
}
function formatDate(value: string): string {
if (!value) return "– ";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "– ";
return new Intl.DateTimeFormat(de ? "de-DE" : "en-US", { dateStyle : "medium" , timeStyle : "short" } ).format(date);
}
function formatRelativeDate(value: string): string {
if (!value) return "– ";
const timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) return "– ";
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (minutes < 1 ) return de ? " gerade eben " : " just now " ;
if (minutes < 60 ) return de ? ` vor $ { minutes } Min .` : `$ { minutes } m ago `;
const hours = Math.round(minutes / 60);
if (hours < 24 ) return de ? ` vor $ { hours } Std .` : `$ { hours } h ago `;
const days = Math.round(hours / 24);
return de ? `vor ${ days } T.` : `${ days } d ago`;
}
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
return (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : parts[0].slice(0, 2)).toLocaleUpperCase();
}
function actionLabel(provider: IntegrationReviewRequest["provider"]): string {
return provider === "azure-devops" ? "Open in Azure" : `Open in ${ providerLabel ( provider )} `;
}
function requestTypeLabel(provider: IntegrationReviewRequest["provider"]): string {
return provider === "gitlab" || provider === "gitlab-self-hosted" ? "Merge Request" : "Pull Request";
}
function toggleRepository(name: string) {
const next = new Set(collapsedRepositories);
if (next.has(name)) next.delete(name);
else next.add(name);
collapsedRepositories = next;
}
function collapseAll() {
collapsedRepositories = new Set(groupedRequests.map((group) => group.name));
}
function expandAll() {
collapsedRepositories = new Set();
}
function selectRequest(request: IntegrationReviewRequest, showDetail = false) {
if (selectedId !== request.id) commentDraft = "";
selectedId = request.id;
if (showDetail) detailOpen = true;
if (request.changedFiles === null & & detailLoadingId !== request.id) void loadDetails(request);
}
function openDetail(request: IntegrationReviewRequest) {
selectRequest(request, true);
}
async function loadDetails(request: IntegrationReviewRequest) {
const source = activeSource;
if (!source) return;
detailLoadingId = request.id;
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${ source . label } keychain`, 15_000);
if (!credential?.password) return;
const details = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label);
requests = requests.map((item) => item.id === details.id ? details : item);
} catch (error) {
errors = [{ source : source.label , message : error instanceof Error ? error.message : String ( error ) } ];
} finally { detailLoadingId = "" ; }
}
function hasConflicts(request: IntegrationReviewRequest): boolean { return request . mergeStatus === "conflicts" ; }
async function runPrimaryAction(request: IntegrationReviewRequest) {
if (request.state === "open" || request.state === "draft") await performReviewAction(request, "merge");
else if (request.state === "closed") await performReviewAction(request, "reopen");
else await openRequest(request);
}
function primaryActionLabel(request: IntegrationReviewRequest): string {
if (hasConflicts(request)) return de ? "Konflikt" : "Conflict";
if (request.state === "open" || request.state === "draft") return de ? "Zusammenführen" : "Merge";
if (request.state === "closed") return de ? "Wieder öffnen" : "Reopen";
return actionLabel(request.provider);
}
async function openRequest(request: IntegrationReviewRequest | null = selected) {
if (!request?.webUrl) return;
try { await openInBrowser ( request . webUrl ); }
catch (error) { errors = [{ source : providerLabel ( request . provider ), message : error instanceof Error ? error.message : String ( error ) }]; }
}
function reviewActionLabel(action: IntegrationReviewAction): string {
if (action === "merge") return de ? "Zusammenführen" : "Merge";
if (action === "approve") return de ? "Freigeben" : "Approve";
if (action === "close") return de ? "Request schließen" : "Close request";
return de ? "Request wieder öffnen" : "Reopen request";
}
async function performReviewAction(request: IntegrationReviewRequest, action: IntegrationReviewAction) {
const source = activeSource;
if (!source || actionBusyId) return;
if (action !== "approve") {
const prompt = action === "merge"
? (de ? `Request #${ request . number } wirklich zusammenführen?` : `Merge request #${ request . number } ?`)
: action === "close"
? (de ? `Request #${ request . number } wirklich schließen?` : `Close request #${ request . number } ?`)
: (de ? `Request #${ request . number } wieder öffnen?` : `Reopen request #${ request . number } ?`);
if (!window.confirm(prompt)) return;
}
actionMenuId = "";
actionNotice = "";
actionBusyId = request.id;
errors = [];
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${ source . label } keychain`, 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
await withTimeout(runIntegrationReviewAction(source.provider, source.baseUrl, credential.username, credential.password, request, action), source.label);
actionNotice = de ? `${ reviewActionLabel ( action )} erfolgreich.` : `${ reviewActionLabel ( action )} succeeded.`;
requests = [];
loadedStates = new Set();
await loadRequests(stateFilter === "draft" ? "open" : stateFilter);
} catch (error) {
errors = [{ source : source.label , message : error instanceof Error ? error.message : String ( error ) } ];
} finally {
actionBusyId = "";
}
}
async function submitComment() {
const request = selected;
const source = activeSource;
const body = commentDraft.trim();
if (!request || !source || !body || commentPosting) return;
commentPosting = true;
errors = [];
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${ source . label } keychain`, 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
await withTimeout(addIntegrationReviewComment(source.provider, source.baseUrl, credential.username, credential.password, request, body), source.label);
commentDraft = "";
await loadDetails(request);
} catch (error) {
errors = [{ source : source.label , message : error instanceof Error ? error.message : String ( error ) } ];
} finally { commentPosting = false ; }
}
< / script >
< section class = "review-center" aria-label = "Review Center" >
< header class = "review-header" >
< div class = "review-heading" >< GitPullRequest size = { 17 } / >< h1 > Review Center</ h1 >< span > { activeSource ? . label ?? "" } </ span ></ div >
< / header >
{ #if sources . length === 0 }
< div class = "empty-state" >< div class = "empty-icon" >< GitPullRequest size = { 23 } / ></ div >< h2 > { de ? "Keine Integration eingerichtet" : "No integration configured" } </ h2 >< p > { 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." } </ p >< button class = "primary" type = "button" onclick = { onOpenSettings } > <Settings2 size = { 14 } / > { de ? "Integrationen öffnen" : "Open integrations" } </ button ></ div >
{ : else }
< nav class = "state-tabs" aria-label = { de ? "Request-Status" : "Request status" } >
{ #each [ "open" , "draft" , "merged" , "closed" ] as state }
< button class:active = { stateFilter === state } type="button" onclick = {() => selectState ( state as IntegrationReviewState )} > { stateLabel ( state as IntegrationReviewState )} <span > { countLabel ( state as IntegrationReviewState )} </ span ></ button >
{ /each }
< / nav >
< div class = "review-toolbar" >
< div class = "group-actions" >
< button type = "button" onclick = { collapseAll } > <ChevronsDownUp size = { 14 } / > { de ? "Alle einklappen" : "Collapse all" } </ button >
< button type = "button" onclick = { expandAll } > <ChevronsUpDown size = { 14 } / > { de ? "Alle ausklappen" : "Expand all" } </ button >
< button class = "icon-button" title = { de ? "Aktualisieren" : "Refresh" } aria-label= { de ? "Aktualisieren" : "Refresh" } type = "button" onclick = {() => loadRequests ()} disabled= { loading } >< RefreshCw class = { loading ? "spin" : "" } size= { 14 } /></ button >
< / div >
< label class = "review-search" >< Search size = { 14 } / >< input bind:value = { query } placeholder= { de ? "Requests durchsuchen …" : "Search requests …" } /></ label >
< select value = { selectedSourceId } onchange= {( event ) => selectSource ( event . currentTarget . value )} aria-label = { de ? "Integration auswählen" : "Select integration" } >
{ #each sources as source } < option value = { source . id } > { source . label } </option > { /each }
< / select >
< / div >
{ #if actionNotice } < div class = "action-notice" >< Check size = { 13 } / > { actionNotice } </ div > { /if }
{ #if errors . length > 0 }
< details class = "source-warning" open >
< summary >< AlertTriangle size = { 14 } / >< span > { backendRestartRequired ? ( de ? "Gitty muss neu gestartet werden, damit das Review-Backend geladen wird." : "Restart Gitty to load the Review Center backend." ) : ( de ? "Die ausgewählte Integration konnte nicht geladen werden." : "The selected integration could not be loaded." )} </ span ></ summary >
< ul > { #each errors as error } < li >< strong > { error . source } :</ strong > { error . message } </ li > { /each } </ ul >
< / details >
{ /if }
< div class:inspector-open = { detailOpen && selected } class="review-layout" >
< main class = "table-pane" >
< div class = "table-head" >
< span > Status</ span >< span > { de ? "Request" : "Request" } </ span >< span > { de ? "Autor" : "Author" } </ span >< span > Collaborators</ span >< span > Repo/Branch</ span >< span > { de ? "Aktion" : "Action" } </ span >
< / div >
{ #if loading && requests . length === 0 }
< div class = "loading-state" >< LoaderCircle class = "spin" size = { 17 } / > { de ? "Requests werden geladen …" : "Loading requests …" } </ div >
{ :else if groupedRequests . length === 0 }
< div class = "list-empty" >< Inbox size = { 20 } / >< strong > { de ? "Keine passenden Requests" : "No matching requests" } </ strong >< span > { de ? "Passe Status oder Suche an." : "Adjust the status or search." } </ span ></ div >
{ : else }
< div class = "request-groups" >
{ #each groupedRequests as group ( group . name )}
< section class = "request-group" >
< button class = "group-header" type = "button" onclick = {() => toggleRepository ( group . name )} >
{ #if collapsedRepositories . has ( group . name )} < ChevronRight size = { 14 } / > { : else } < ChevronDown size = { 14 } / > { /if }
< GitBranch size = { 13 } / >< strong > { group . name } </ strong >< span > { group . items . length } </ span >
< / button >
{ #if ! collapsedRepositories . has ( group . name )}
{ #each group . items as request ( request . id )}
< div class:selected = { selectedId === request . id } class="request-row" role = "button" tabindex = "0" onclick = {() => selectRequest ( request )} onkeydown= {( event ) => { if ( event . key === "Enter" ) selectRequest ( request ); }} >
< span class = "request-status" class:open = { request . state === "open" } class:draft= { request . state === "draft" } class:merged = { request . state === "merged" } class:closed= { request . state === "closed" } >
{ #if request . state === "merged" } < GitMerge size = { 13 } / > { :else if request . state === "closed" } < XCircle size = { 13 } / > { :else if request . state === "draft" } < CircleDotDashed size = { 13 } / > { : else } < GitPullRequest size = { 13 } / > { /if }
< span > { stateLabel ( request . state )} < small title = { formatDate ( request . updatedAt || request . createdAt )} > { formatRelativeDate ( request . updatedAt || request . createdAt )} </small ></ span >
< / span >
< span class = "request-title" >< span >< code > #{ request . number } </ code >< strong title = { request . title } > { request . title } </strong ></ span >< small class = "change-stats" >< b > +{ request . additions ?? "– " } </ b >< i > /</ i >< em > − { request . deletions ?? "– " } </ em > { #if request . changedFiles !== null } < span title = { de ? "Geänderte Dateien" : "Changed files" } > { request . changedFiles } { de ? "Dateien" : "files" } </span > { :else if detailLoadingId === request . id } < span > { de ? "Lädt …" : "Loading …" } </ span > { /if } </ small ></ span >
< span class = "request-author" >< i > { initials ( request . author )} </ i >< span > { request . author || ( de ? "Unbekannt" : "Unknown" )} </ span ></ span >
< span class = "collaborators" > { #if request . collaborators . length }{ #each request . collaborators . slice ( 0 , 3 ) as collaborator } < i title = { collaborator } > { initials ( collaborator )} </i > { /each }{ : else } < span > – </ 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: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 = "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 }
< 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 >
{ #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 >
< 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 >
{ :else if request . state === "closed" }
< button type = "button" role = "menuitem" disabled = { actionBusyId === request . id } onclick= {() => void performReviewAction ( request , "reopen" )} >< RotateCcw size = { 13 } / > { reviewActionLabel ( "reopen" )} </ button >
{ /if }
< / span >
{ /if }
< / span >
< button class = "panel-button" class:active = { detailOpen && selectedId === request . id } title= { de ? "Detailpanel öffnen" : "Open detail panel" } aria-label = { de ? "Detailpanel öffnen" : "Open detail panel" } type="button" onclick = {( event ) => { event . stopPropagation (); openDetail ( request ); }} > <PanelRightOpen size = { 14 } / ></ button >
< / span >
< / div >
{ /each }
{ /if }
< / section >
{ /each }
< / div >
{ /if }
< / main >
{ #if detailOpen && selected }
< aside class = "detail-panel" transition:fly = {{ x : 140 , duration : 210 , easing : cubicOut }} >
< header class = "detail-header" >
< div class = "detail-provider" >< GitPullRequest size = { 17 } / >< strong > { providerLabel ( selected . provider )} { requestTypeLabel ( selected . provider )} </ strong ></ div >
< div class = "detail-header-actions" >< button class = "icon-button" type = "button" aria-label = { de ? "Im Anbieter öffnen" : "Open in provider" } onclick= {() => void openRequest ()} >< ExternalLink size = { 16 } / ></ button >< button class = "icon-button" type = "button" aria-label = { de ? "Detailansicht schließen" : "Close details" } onclick= {() => { detailOpen = false ; }} >< X size = { 17 } / ></ button ></ div >
< / header >
< div class = "detail-content" >
< 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 = "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 >
< / main >
< 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 >
< 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 >
< / div >
< / aside >
{ /if }
< / div >
{ /if }
< / section >
< style >
.review-center{ display :flex ; min - height :0 ; flex :1 ; flex - direction :column ; overflow :hidden ; color :var ( -- color - ink ); background :var ( -- app - bg ); font - size :12px } .review-center button,.review-center input,.review-center select{ font :inherit } .review-header{ display :flex ; min - height :48px ; align - items :center ; padding :0 18 px ; border - bottom :1px solid var ( -- color - border - subtle ); background :var ( -- color - surface )} .review-heading{ display :flex ; align - items :center ; gap :9px } .review-heading>:global(svg){ color :var ( -- color - accent )} .review-heading h1{ margin :0 ; font - size :16px ; font - weight :650 } .review-heading span{ margin - left :3px ; padding - left :12px ; border - left :1px solid var ( -- color - border ); color :var ( -- color - ink - faint ); font - size :10.5px }
.state-tabs{ display :flex ; min - height :42px ; align - items :stretch ; gap :18px ; padding :0 18 px ; border - bottom :1px solid var ( -- color - border - subtle ); background :var ( -- color - surface )} .state-tabs button{ position :relative ; display :flex ; align - items :center ; gap :7px ; padding :0 5 px ; border :0 ; color :var ( -- color - ink - muted ); background :transparent ; font - size :11px } .state-tabs button:hover{ color :var ( -- color - ink )} .state-tabs button.active{ color :var ( -- color - accent )} .state-tabs button.active:after{ position :absolute ; right :0 ; bottom :0 ; left :0 ; height :2px ; background :var ( -- color - accent ); content : "" } .state-tabs span,.group-header>span{ display :grid ; min - width :18px ; height :18px ; place - items :center ; padding :0 5 px ; border - radius :9px ; color :var ( -- color - ink - muted ); background :var ( -- color - surface - raised ); font - size :9.5px }
.review-toolbar{ display :flex ; min - height :48px ; align - items :center ; gap :10px ; padding :7px 18 px ; border - bottom :1px solid var ( -- color - border - subtle )} .group-actions{ display :flex ; align - items :center ; gap :2px } .group-actions button,.icon-button{ display :inline - flex ; height :30px ; align - items :center ; gap :5px ; padding :0 7 px ; border :1px solid transparent ; color :var ( -- color - ink - muted ); background :transparent } .group-actions button:hover,.icon-button:hover{ border - color :var ( -- color - border ); color :var ( -- color - ink ); background :var ( -- color - surface - hover )} .group-actions .icon-button{ width :30px ; justify - content :center ; padding :0 ; border - color :var ( -- color - border - subtle ); margin - left :3px } .review-search{ display :flex ; min - width :180px ; height :30px ; align - items :center ; gap :7px ; flex :1 ; padding :0 9 px ; border :1px solid var ( -- color - border - input ); color :var ( -- color - ink - faint ); background :var ( -- app - input - bg )} .review-search:focus-within{ border - color :var ( -- color - accent )} .review-search input{ width :100 % ; min - width :0 ; height :100 % ; padding :0 ; border :0 ; outline :0 ; color :var ( -- color - ink ); background :transparent } .review-toolbar select{ width :190px ; height :30px ; padding :0 28 px 0 9 px ; border :1px solid var ( -- color - border - input ); color :var ( -- color - ink ); background :var ( -- app - input - bg )}
.source-warning{ padding :7px 18 px ; border - bottom :1px solid color - mix ( in srgb , # d3a64d 28 % , var ( -- color - border )); color : # d3a64d ; background :color - mix ( in srgb , # d3a64d 6 % , var ( -- app - bg ))} .source-warning summary{ display :flex ; align - items :center ; gap :7px ; cursor :pointer } .source-warning ul{ display :grid ; gap :4px ; margin :7px 0 2 px ; padding - left :22px ; color :var ( -- color - ink - muted ); font - size :10.5px } .source-warning li strong{ color :var ( -- color - ink )} .action-notice{ display :flex ; min - height :30px ; align - items :center ; gap :7px ; padding :0 18 px ; border - bottom :1px solid color - mix ( in srgb , # 63 c783 28 % , var ( -- color - border )); color : # 63 c783 ; background :color - mix ( in srgb , # 63 c783 6 % , var ( -- app - bg )); font - size :10.5px }
.review-layout{ display :grid ; grid - template - columns :minmax ( 0 , 1 fr ); flex :1 ; min - height :0 } .review-layout.inspector-open{ grid - template - columns :minmax ( 560 px , 1 fr ) minmax ( 360 px , 38 % )} .table-pane{ min - width :0 ; min - height :0 ; overflow :auto } .table-head,.request-row{ display :grid ; grid - template - columns :95px minmax ( 280 px , 1.5f r ) 135 px 145 px minmax ( 210 px , 1 fr ) 190 px ; align - items :center } .table-head{ position :sticky ; z - index :2 ; top :0 ; min - height :30px ; padding :0 12 px ; border - bottom :1px solid var ( -- color - border ); color :var ( -- color - ink - faint ); background :var ( -- color - surface - raised ); font - size :9px ; font - weight :700 ; letter - spacing :.035em ; text - transform :uppercase } .request-groups{ min - width :1075px } .request-group{ border - bottom :1px solid var ( -- color - border - subtle )} .group-header{ display :flex ; width :100 % ; height :34px ; align - items :center ; justify - content :flex - start ; gap :7px ; padding :0 12 px ; border :0 ; border - bottom :1px solid var ( -- color - border - subtle ); color :var ( -- color - ink ); background :var ( -- color - surface ); text - align :left } .group-header:hover{ background :var ( -- color - surface - hover )} .group-header>:global(svg){ color :var ( -- color - ink - faint )} .group-header strong{ font - size :11px } .group-header>span{ margin - left :2px } .request-row{ position :relative ; min - height :52px ; padding :0 12 px ; border - bottom :1px solid var ( -- color - border - subtle ); outline :1px solid transparent ; outline - offset :- 1 px ; color :var ( -- color - ink - muted ); background :transparent ; cursor :default } .request-row:hover{ background :var ( -- color - surface - hover )} .request-row.selected{ z - index :1 ; outline - color :var ( -- color - accent ); background :var ( -- color - surface - raised )} .request-status{ display :flex ; align - items :center ; gap :6px ; font - size :10px } .request-status>span{ display :grid ; gap :2px } .request-status small{ color :var ( -- color - ink - faint ); font - size :9px } .request-title{ display :grid ; min - width :0 ; gap :5px ; padding - right :14px } .request-title>span{ display :flex ; min - width :0 ; gap :8px } .request-title code{ color :var ( -- color - accent ); font - size :10px } .request-title strong{ overflow :hidden ; color :var ( -- color - ink ); font - size :11px ; font - weight :560 ; text - overflow :ellipsis ; white - space :nowrap } .change-stats{ display :flex ; align - items :center ; gap :6px ; font - size :9.5px } .change-stats b{ color : # 63 c783 } .change-stats em{ color : # e0737b ; font - style :normal } .change-stats i{ color :var ( -- color - ink - faint ); font - style :normal } .change-stats span{ color :var ( -- color - ink - faint )} .request-author{ display :flex ; min - width :0 ; align - items :center ; gap :7px ; padding - right :10px } .request-author i,.avatar,.collaborators i{ display :grid ; width :23px ; height :23px ; flex :0 0 auto ; place - items :center ; border :1px solid color - mix ( in srgb , var ( -- color - accent ) 35 % , var ( -- color - border )); border - radius :50 % ; color : # fff ; background :color - mix ( in srgb , var ( -- color - accent ) 45 % , var ( -- color - surface - raised )); font - size :9px ; font - style :normal ; font - weight :750 } .request-author>span{ overflow :hidden ; text - overflow :ellipsis ; white - space :nowrap } .collaborators{ display :flex ; padding - left :3px } .collaborators i+ i{ margin - left :- 5 px } .repo-branch{ display :grid ; min - width :0 ; gap :4px } .repo-branch>strong{ overflow :hidden ; color :var ( -- color - ink - muted ); font - size :10px ; text - overflow :ellipsis ; white - space :nowrap } .branch-route{ display :flex ; min - width :0 ; align - items :center ; gap :5px ; padding - right :12px } .branch-route code{ max - width :42 % ; overflow :hidden ; color :var ( -- color - ink - faint ); font - size :9px ; text - overflow :ellipsis ; white - space :nowrap } .branch-route b{ color :var ( -- color - ink - faint ); font - weight :400 } .row-actions{ display :flex ; align - items :center ; justify - content :flex - end ; gap :5px } .provider-action{ position :relative ; display :flex } .provider-button,.action-toggle,.detail-action{ display :inline - flex ; height :28px ; align - items :center ; justify - content :center ; gap :6px ; border :1px solid var ( -- color - accent ); color :var ( -- color - accent ); background :color - mix ( in srgb , var ( -- color - accent ) 5 % , transparent )} .provider-button{ min - width :120px ; padding :0 8 px ; border - right :0 } .provider-button.merge-primary{ color : # 63 c783 ; border - color : # 4 fa565 ; background :color - mix ( in srgb , # 4 fa565 15 % , var ( -- color - surface ))} .provider-button.conflict{ color : # e0737b ; border - color : # a74b55 } .action-toggle{ width :27px ; padding :0 } .provider-button:hover,.action-toggle:hover,.action-toggle.active,.detail-action:hover{ color : # fff ; background :color - mix ( in srgb , var ( -- color - accent ) 24 % , var ( -- color - surface ))} .action-menu{ position :absolute ; z - index :8 ; top :31px ; right :0 ; display :grid ; width :178px ; padding :4px ; border :1px solid var ( -- color - border ); box - shadow :0 8 px 22 px # 0008 ; background :var ( -- color - surface - raised )} .action-menu button{ display :flex ; height :29px ; align - items :center ; gap :8px ; padding :0 8 px ; border :0 ; color :var ( -- color - ink - muted ); background :transparent ; text - align :left } .action-menu button:hover{ color :var ( -- color - ink ); background :var ( -- color - surface - hover )} .action-menu button.danger{ color : # e0737b } .panel-button{ display :grid ; width :28px ; height :28px ; place - items :center ; border :1px solid var ( -- color - border ); color :var ( -- color - ink - muted ); background :var ( -- app - button - bg )} .panel-button:hover,.panel-button.active{ border - color :var ( -- color - accent ); color :var ( -- color - accent )}
.detail-panel{ min - width :0 ; min - height :0 ; overflow :auto ; border - left :1px solid var ( -- color - border ); background :var ( -- color - surface )} .detail-header{ display :flex ; min - height :78px ; align - items :flex - start ; justify - content :space - between ; gap :14px ; padding :15px 16 px ; border - bottom :1px solid var ( -- color - border )} .detail-header>div{ min - width :0 } .detail-content{ display :grid ; gap :0 ; padding :0 16 px } .detail-summary{ display :flex ; min - height :48px ; align - items :center ; gap :8px ; border - bottom :1px solid var ( -- color - border - subtle )} .state-badge{ padding :3px 7 px ; border :1px solid currentColor ; font - size :9px ; font - weight :700 } .detail-summary .avatar{ margin - left :4px } .detail-summary strong{ font - size :11px } .branch-detail{ display :flex ; align - items :center ; gap :7px ; padding :13px 0 ; border - bottom :1px solid var ( -- color - border - subtle )} .branch-detail>:global(svg){ color :var ( -- color - accent )} .branch-detail code{ min - width :0 ; overflow :hidden ; padding :2px 5 px ; color :var ( -- color - accent ); background :color - mix ( in srgb , var ( -- color - accent ) 8 % , transparent ); font - size :10px ; text - overflow :ellipsis ; white - space :nowrap } .branch-detail span{ color :var ( -- color - ink - faint )} .description{ padding :15px 0 ; border - bottom :1px solid var ( -- color - border - subtle )} .description h3{ margin :0 0 9 px ; font - size :11px } .description p{ margin :0 ; white - space :pre - wrap ; color :var ( -- color - ink - muted ); font - size :11px ; line - height :1.55 } .description p.muted{ color :var ( -- color - ink - faint )} .detail-actions{ display :grid ; grid - template - columns :repeat ( 2 , minmax ( 0 , 1 fr )); gap :7px ; margin - top :16px } .detail-action{ width :100 % ; height :32px ; padding :0 8 px } .detail-action.primary-action{ color : # 63 c783 ; border - color : # 63 c783 } .detail-action.danger-action{ color : # e0737b ; border - color :color - mix ( in srgb , # e0737b 75 % , var ( -- color - border ))} .open{ color : # 63 c783 } .draft{ color :var ( -- color - ink - muted )} .merged{ color : # b785e8 } .closed{ color : # e0737b }
.loading-state,.list-empty,.empty-state{ display :flex ; min - height :180px ; align - items :center ; justify - content :center ; gap :8px ; color :var ( -- color - ink - muted )} .list-empty,.empty-state{ flex - direction :column } .list-empty strong{ color :var ( -- color - ink ); font - size :12px } .list-empty span{ font - size :10px } .empty-state{ flex :1 ; text - align :center } .empty-state h2{ margin :4px 0 0 ; font - size :14px } .empty-state p{ max - width :430px ; margin :0 0 8 px ; line - height :1.5 } .empty-icon{ display :grid ; width :42px ; height :42px ; place - items :center ; border :1px solid var ( -- color - border ); color :var ( -- color - accent ); background :var ( -- color - surface )} .primary{ display :inline - flex ; min - height :30px ; align - items :center ; gap :6px ; padding :0 10 px ; border :1px solid var ( -- color - primary ); color : # fff ; background :var ( -- color - primary )}
@media(max-width:1100px){. review - layout . inspector - open { grid - template - columns :minmax ( 500 px , 1 fr ) 360 px }. table - head ,. request - row { grid - template - columns :78px minmax ( 230 px , 1.5f r ) minmax ( 180 px , 1 fr ) 90 px 170 px }. table - head > span :nth - child ( 3 ),. request - author { display :none }. request - groups { min - width :760px }}
@media(max-width:760px){. review - toolbar { flex - wrap :wrap }. group - actions { order :2 }. review - search { order :1 ; flex - basis :calc ( 100 % - 200 px )}. review - toolbar select { order :1 }. review - layout . inspector - open { display :block }. detail - panel { position :absolute ; inset :90px 0 0 15 % ; z - index :5 ; box - shadow :- 12 px 0 30 px # 0008 }. state - tabs { gap :8px }. group - actions button { font - size :0 }. group - actions button >: global ( svg ){ margin :0 }. table - head ,. request - row { grid - template - columns :72px minmax ( 220 px , 1 fr ) 90 px 160 px }. table - head > span :nth - child ( 3 ),. table - head > span :nth - child ( 4 ),. request - author ,. branch - route { display :none }. request - groups { min - width :580px }}
.merge-summary{ display :flex ; min - height :45px ; align - items :center ; gap :7px ; border - bottom :1px solid var ( -- color - border - subtle ); color : # 63 c783 } .merge-summary.conflict{ color : # e0737b } .merge-summary>span,.merge-summary>strong{ font - size :10.5px }
@media(max-width:1100px){. table - head ,. request - row { grid - template - columns :78px minmax ( 270 px , 1 fr ) minmax ( 190 px , 1 fr ) 190 px }. table - head > span :nth - child ( 3 ),. table - head > span :nth - child ( 4 ),. request - author ,. collaborators { display :none }. request - groups { min - width :780px }}
@media(max-width:760px){. table - head ,. request - row { grid - template - columns :72px minmax ( 260 px , 1 fr ) 190 px }. table - head > span :nth - child ( 5 ),. repo - branch { display :none }. request - groups { min - width :600px }. merge - summary { align - items :flex - start ; flex - wrap :wrap ; padding :10px 0 }}
.branch-loading{ display :flex ; align - items :center ; gap :5px ; color :var ( -- color - ink - faint ); font - size :9px }
.provider-action{ overflow :visible ; border - radius :2px ; box - shadow :0 1 px 0 # 0005 } .provider-button{ border - radius :2px 0 0 2 px ; font - weight :600 } .action-toggle{ border - radius :0 2 px 2 px 0 ; background :color - mix ( in srgb , var ( -- color - accent ) 14 % , var ( -- color - surface ))} .panel-button{ margin - left :2px ; border - radius :2px } .review-layout.inspector-open{ grid - template - columns :minmax ( 580 px , 1 fr ) minmax ( 420 px , 44 % )} .detail-header{ background :linear - gradient ( 180 deg , color - mix ( in srgb , var ( -- color - surface - raised ) 72 % , var ( -- color - surface )), var ( -- color - surface ))}
.comments-section{ padding :15px 0 ; border - bottom :1px solid var ( -- color - border - subtle )} .comments-section>header{ display :flex ; align - items :center ; gap :7px ; margin - bottom :10px } .comments-section h3{ margin :0 ; font - size :11px } .comments-section>header>span{ display :grid ; min - width :18px ; height :18px ; place - items :center ; border - radius :9px ; color :var ( -- color - ink - faint ); background :var ( -- color - surface - raised ); font - size :9px } .comments-loading,.no-comments{ margin :8px 0 ; color :var ( -- color - ink - faint ); font - size :10px } .comments-loading{ display :flex ; align - items :center ; gap :6px } .comment-list{ display :grid ; gap :10px ; margin - bottom :12px } .comment-list article{ display :flex ; align - items :flex - start ; gap :9px } .comment-list article>div{ min - width :0 ; flex :1 ; padding :9px 10 px ; border :1px solid var ( -- color - border - subtle ); border - radius :2px ; background :var ( -- app - bg )} .comment-list article header{ display :flex ; align - items :center ; justify - content :space - between ; gap :8px ; margin - bottom :6px } .comment-list article strong{ font - size :10.5px } .comment-list article time{ color :var ( -- color - ink - faint ); font - size :9px } .comment-list article p{ margin :0 ; white - space :pre - wrap ; color :var ( -- color - ink - muted ); font - size :10.5px ; line - height :1.5 } .comment-composer{ display :flex ; align - items :flex - start ; gap :9px ; margin - top :10px } .comment-composer>div{ display :grid ; flex :1 ; justify - items :end ; gap :7px } .comment-composer textarea{ box - sizing :border - box ; width :100 % ; min - height :82px ; resize :vertical ; padding :9px 10 px ; border :1px solid var ( -- color - border - input ); outline :0 ; color :var ( -- color - ink ); background :var ( -- app - input - bg ); font :inherit ; line - height :1.45 } .comment-composer textarea:focus{ border - color :var ( -- color - accent )} .comment-composer button{ display :inline - flex ; height :29px ; align - items :center ; gap :6px ; padding :0 10 px ; border :1px solid # 4 fa565 ; border - radius :2px ; color : # 63 c783 ; background :color - mix ( in srgb , # 4 fa565 12 % , var ( -- color - surface ))} .comment-composer button:hover:not(:disabled){ color : # fff ; background : # 397849 }
@media(max-width:1100px){. review - layout . inspector - open { grid - template - columns :minmax ( 480 px , 1 fr ) 380 px }}
@media(max-width:760px){. review - layout . inspector - open { display :block }. detail - panel { position :absolute ; inset :90px 0 0 12 % ; z - index :5 ; box - shadow :- 12 px 0 30 px # 0008 }}
.comment-list{ max - height :230px ; overflow - y :auto ; overscroll - behavior :contain ; padding - right :5px ; scrollbar - gutter :stable } .comment-list::-webkit-scrollbar{ width :7px } .comment-list::-webkit-scrollbar-thumb{ border :2px solid transparent ; border - radius :4px ; background :var ( -- color - border - input ); background - clip :padding - box } .comment-list::-webkit-scrollbar-track{ background :transparent }
.detail-panel{ display :flex ; flex - direction :column ; overflow :hidden } .detail-header{ flex :0 0 auto } .detail-content{ display :flex ; min - height :0 ; flex :1 ; flex - direction :column ; overflow :hidden } .detail-summary,.branch-detail,.merge-summary,.description,.detail-actions{ flex :0 0 auto } .description p{ display :- webkit - box ; overflow :hidden ; - webkit - box - orient :vertical ; - webkit - line - clamp :3 ; line - clamp :3 } .comments-section{ display :flex ; min - height :0 ; flex :1 ; flex - direction :column ; overflow :hidden } .comments-section>header,.comment-composer{ flex :0 0 auto } .comment-list{ min - height :0 ; max - height :none ; flex :1 ; overflow - y :auto } .comment-composer textarea{ min - height :68px ; max - height :92px } .detail-actions{ margin - bottom :12px }
.review-center{ position :relative } .review-layout.inspector-open{ grid - template - columns :minmax ( 0 , 1 fr )} .detail-panel{ position :absolute ; z - index :20 ; inset :0 ; display :flex ; width :100 % ; height :100 % ; flex - direction :column ; overflow :hidden ; border :0 ; background :var ( -- app - bg )} .detail-header{ display :flex ; min - height :48px ; align - items :center ; padding :0 20 px ; border - bottom :1px solid var ( -- color - border ); background :var ( -- color - surface - raised )} .detail-provider{ display :flex ; align - items :center ; gap :9px ; color :var ( -- color - ink - muted )} .detail-provider>:global(svg){ color :var ( -- color - accent )} .detail-provider strong{ font - size :13px ; font - weight :600 } .detail-header-actions{ display :flex ; align - items :center ; gap :3px ; margin - left :auto } .detail-content{ display :grid ; min - height :0 ; flex :1 ; grid - template - columns :minmax ( 0 , 1 fr ) 300 px ; gap :28px ; overflow :hidden ; padding :0 28 px } .detail-main{ display :flex ; min - width :0 ; min - height :0 ; flex - direction :column ; overflow :hidden } .detail-title{ flex :0 0 auto ; padding :18px 0 0 ; border - bottom :1px solid var ( -- color - border - subtle )} .detail-title>span{ color :var ( -- color - ink - faint ); font - size :10px } .detail-title h2{ margin :7px 0 10 px ; font - size :20px ; font - weight :520 ; line - height :1.25 } .detail-title .detail-summary{ min - height :34px ; border :0 } .detail-title .detail-summary>span:last-child{ color :var ( -- color - ink - muted ); font - size :10.5px } .detail-title .branch-detail{ padding :8px 0 13 px ; border :0 } .detail-main>.description{ padding :13px 0 } .detail-main>.comments-section{ display :flex ; min - height :0 ; flex :1 ; flex - direction :column ; overflow :hidden ; padding :13px 0 18 px } .detail-main .merge-summary{ flex :0 0 auto ; margin - bottom :11px ; padding :0 12 px ; border :1px solid var ( -- color - border ); background :var ( -- color - surface )} .detail-main .comment-list{ min - height :0 ; flex :1 } .detail-main .comment-composer{ flex :0 0 auto } .detail-sidebar{ display :flex ; min - height :0 ; flex - direction :column ; gap :0 ; overflow :hidden ; padding :18px 0 ; border - left :1px solid var ( -- color - border - subtle ); padding - left :22px ; background :var ( -- color - surface )} .detail-sidebar .detail-actions{ display :grid ; grid - template - columns :1fr ; gap :6px ; margin :0 0 8 px } .detail-sidebar section{ padding :17px 0 ; border - bottom :1px solid var ( -- color - border - subtle )} .detail-sidebar section header{ display :flex ; align - items :center ; justify - content :space - between ; gap :12px } .detail-sidebar section h3{ margin :0 ; color :var ( -- color - ink - muted ); font - size :11px ; font - weight :560 } .people{ display :grid ; grid - template - columns :24px minmax ( 0 , 1 fr ); align - items :center ; gap :8px ; margin - top :12px } .people.compact{ grid - template - columns :24px }
@media(max-width:900px){. detail - content { grid - template - columns :minmax ( 0 , 1 fr ) 270 px ; gap :18px ; padding :0 18 px }. detail - sidebar { padding - left :16px }. detail - title h2 { font - size :17px }}
@media(max-width:680px){. detail - content { display :block ; overflow :hidden ; padding :0 15 px }. detail - main { height :100 % }. detail - sidebar { display :none }. detail - panel { inset :0 }}
.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 :- 14 px 0 30 px # 0007 } .review-layout.inspector-open{ grid - template - columns :minmax ( 0 , 1 fr )}
.provider-action.merge-action .action-toggle{ border - color : # 4 fa565 ; color : # 70 d58d ; background :color - mix ( in srgb , # 4 fa565 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 ))}
@media(max-width:760px){. detail - panel { inset :0 ; width :100 % ; min - width :0 ; max - width :none }}
< / style >