@@ -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 : 12 px }. review-center button , . review-center input , . review-center select { font : inherit }. review-header { display : flex ; min-height : 48 px ; align-items : center ; padding : 0 18 px ; border-bottom : 1 px solid var ( -- color - border - subtle ); background : var ( -- color - surface )}. review-heading { display : flex ; align-items : center ; gap : 9 px }. review-heading > : global ( svg ) { color : var ( -- color - accent )}. review-heading h1 { margin : 0 ; font-size : 16 px ; font-weight : 650 }. review-heading span { margin-left : 3 px ; padding-left : 12 px ; border-left : 1 px solid var ( -- color - border ); color : var ( -- color - ink - faint ); font-size : 10.5 px }
. state-tabs { display : flex ; min-height : 42 px ; align-items : stretch ; gap : 18 px ; padding : 0 18 px ; border-bottom : 1 px solid var ( -- color - border - subtle ); background : var ( -- color - surface )}. state-tabs button { position : relative ; display : flex ; align-items : center ; gap : 7 px ; padding : 0 5 px ; border : 0 ; color : var ( -- color - ink - muted ); background : transparent ; font-size : 11 px }. 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 : 2 px ; background : var ( -- color - accent ); content : "" }. state-tabs span , . group-header > span { display : grid ; min-width : 18 px ; height : 18 px ; place-items : center ; padding : 0 5 px ; border-radius : 9 px ; color : var ( -- color - ink - muted ); background : var ( -- color - surface - raised ); font-size : 9.5 px }
. review-toolbar { display : flex ; min-height : 48 px ; align-items : center ; gap : 10 px ; padding : 7 px 18 px ; border-bottom : 1 px solid var ( -- color - border - subtle )}. group-actions { display : flex ; align-items : center ; gap : 2 px }. group-actions button , . icon-button { display : inline-flex ; height : 30 px ; align-items : center ; gap : 5 px ; padding : 0 7 px ; border : 1 px 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 : 30 px ; justify-content : center ; padding : 0 ; border-color : var ( -- color - border - subtle ); margin-left : 3 px }. review-search { display : flex ; min-width : 180 px ; height : 30 px ; align-items : center ; gap : 7 px ; flex : 1 ; padding : 0 9 px ; border : 1 px 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 : 190 px ; height : 30 px ; padding : 0 28 px 0 9 px ; border : 1 px solid var ( -- color - border - input ); color : var ( -- color - ink ); background : var ( -- app - input - bg )}
. source-warning { padding : 7 px 18 px ; border-bottom : 1 px 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 : 7 px ; cursor : pointer }. source-warning ul { display : grid ; gap : 4 px ; margin : 7 px 0 2 px ; padding-left : 22 px ; color : var ( -- color - ink - muted ); font-size : 10.5 px }. source-warning li strong { color : var ( -- color - ink )}. action-notice { display : flex ; min-height : 30 px ; align-items : center ; gap : 7 px ; padding : 0 18 px ; border-bottom : 1 px solid color-mix ( in srgb , #63c783 28 % , var ( -- color - border )); color : #63c783 ; background : color-mix ( in srgb , #63c783 6 % , var ( -- app - bg )); font-size : 10.5 px }
. 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 : 95 px minmax ( 280 px , 1.5 fr ) 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 : 30 px ; padding : 0 12 px ; border-bottom : 1 px solid var ( -- color - border ); color : var ( -- color - ink - faint ); background : var ( -- color - surface - raised ); font-size : 9 px ; font-weight : 700 ; letter-spacing : .035 em ; text-transform : uppercase }. request-groups { min-width : 1075 px }. request-group { border-bottom : 1 px solid var ( -- color - border - subtle )}. group-header { display : flex ; width : 100 % ; height : 34 px ; align-items : center ; justify-content : flex-start ; gap : 7 px ; padding : 0 12 px ; border : 0 ; border-bottom : 1 px 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 : 11 px }. group-header > span { margin-left : 2 px }. request-row { position : relative ; min-height : 52 px ; padding : 0 12 px ; border-bottom : 1 px solid var ( -- color - border - subtle ); outline : 1 px 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 : 6 px ; font-size : 10 px }. request-status > span { display : grid ; gap : 2 px }. request-status small { color : var ( -- color - ink - faint ); font-size : 9 px }. request-title { display : grid ; min-width : 0 ; gap : 5 px ; padding-right : 14 px }. request-title > span { display : flex ; min-width : 0 ; gap : 8 px }. request-title code { color : var ( -- color - accent ); font-size : 10 px }. request-title strong { overflow : hidden ; color : var ( -- color - ink ); font-size : 11 px ; font-weight : 560 ; text-overflow : ellipsis ; white-space : nowrap }. change-stats { display : flex ; align-items : center ; gap : 6 px ; font-size : 9.5 px }. change-stats b { color : #63c783 }. 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 : 7 px ; padding-right : 10 px }. request-author i , . avatar , . collaborators i { display : grid ; width : 23 px ; height : 23 px ; flex : 0 0 auto ; place-items : center ; border : 1 px 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 : 9 px ; font-style : normal ; font-weight : 750 }. request-author > span { overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap }. collaborators { display : flex ; padding-left : 3 px }. collaborators i + i { margin-left : -5 px }. repo-branch { display : grid ; min-width : 0 ; gap : 4 px }. repo-branch > strong { overflow : hidden ; color : var ( -- color - ink - muted ); font-size : 10 px ; text-overflow : ellipsis ; white-space : nowrap }. branch-route { display : flex ; min-width : 0 ; align-items : center ; gap : 5 px ; padding-right : 12 px }. branch-route code { max-width : 42 % ; overflow : hidden ; color : var ( -- color - ink - faint ); font-size : 9 px ; 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 : 5 px }. provider-action { position : relative ; display : flex }. provider-button , . action-toggle , . detail-action { display : inline-flex ; height : 28 px ; align-items : center ; justify-content : center ; gap : 6 px ; border : 1 px solid var ( -- color - accent ); color : var ( -- color - accent ); background : color-mix ( in srgb , var ( -- color - accent ) 5 % , transparent )}. provider-button { min-width : 120 px ; padding : 0 8 px ; border-right : 0 }. provider-button . merge-primary { color : #63c783 ; border-color : #4fa565 ; background : color-mix ( in srgb , #4fa565 15 % , var ( -- color - surface ))}. provider-button . conflict { color : #e0737b ; border-color : #a74b55 }. action-toggle { width : 27 px ; 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 : 31 px ; right : 0 ; display : grid ; width : 178 px ; padding : 4 px ; border : 1 px solid var ( -- color - border ); box-shadow : 0 8 px 22 px #0008 ; background : var ( -- color - surface - raised )}. action-menu button { display : flex ; height : 29 px ; align-items : center ; gap : 8 px ; 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 : 28 px ; height : 28 px ; place-items : center ; border : 1 px 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 : 1 px solid var ( -- color - border ); background : var ( -- color - surface )}. detail-header { display : flex ; min-height : 78 px ; align-items : flex-start ; justify-content : space-between ; gap : 14 px ; padding : 15 px 16 px ; border-bottom : 1 px 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 : 48 px ; align-items : center ; gap : 8 px ; border-bottom : 1 px solid var ( -- color - border - subtle )}. state-badge { padding : 3 px 7 px ; border : 1 px solid currentColor ; font-size : 9 px ; font-weight : 700 }. detail-summary . avatar { margin-left : 4 px }. detail-summary strong { font-size : 11 px }. branch-detail { display : flex ; align-items : center ; gap : 7 px ; padding : 13 px 0 ; border-bottom : 1 px solid var ( -- color - border - subtle )}. branch-detail > : global ( svg ) { color : var ( -- color - accent )}. branch-detail code { min-width : 0 ; overflow : hidden ; padding : 2 px 5 px ; color : var ( -- color - accent ); background : color-mix ( in srgb , var ( -- color - accent ) 8 % , transparent ); font-size : 10 px ; text-overflow : ellipsis ; white-space : nowrap }. branch-detail span { color : var ( -- color - ink - faint )}. description { padding : 15 px 0 ; border-bottom : 1 px solid var ( -- color - border - subtle )}. description h3 { margin : 0 0 9 px ; font-size : 11 px }. description p { margin : 0 ; white-space : pre-wrap ; color : var ( -- color - ink - muted ); font-size : 11 px ; 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 : 7 px ; margin-top : 16 px }. detail-action { width : 100 % ; height : 32 px ; padding : 0 8 px }. detail-action . primary-action { color : #63c783 ; border-color : #63c783 }. detail-action . danger-action { color : #e0737b ; border-color : color-mix ( in srgb , #e0737b 75 % , var ( -- color - border ))}. open { color : #63c783 }. draft { color : var ( -- color - ink - muted )}. merged { color : #b785e8 }. closed { color : #e0737b }
. loading-state , . list-empty , . empty-state { display : flex ; min-height : 180 px ; align-items : center ; justify-content : center ; gap : 8 px ; color : var ( -- color - ink - muted )}. list-empty , . empty-state { flex-direction : column }. list-empty strong { color : var ( -- color - ink ); font-size : 12 px }. list-empty span { font-size : 10 px }. empty-state { flex : 1 ; text-align : center }. empty-state h2 { margin : 4 px 0 0 ; font-size : 14 px }. empty-state p { max-width : 430 px ; margin : 0 0 8 px ; line-height : 1.5 }. empty-icon { display : grid ; width : 42 px ; height : 42 px ; place-items : center ; border : 1 px solid var ( -- color - border ); color : var ( -- color - accent ); background : var ( -- color - surface )}. primary { display : inline-flex ; min-height : 30 px ; align-items : center ; gap : 6 px ; padding : 0 10 px ; border : 1 px 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 : 78 px minmax ( 230 px , 1.5 fr ) minmax ( 180 px , 1 fr ) 90 px 170 px }. table-head > span : nth-child ( 3 ), . request-author { display : none }. request-groups { min-width : 760 px }}
@ 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 : 90 px 0 0 15 % ; z-index : 5 ; box-shadow : -12 px 0 30 px #0008 }. state-tabs { gap : 8 px }. group-actions button { font-size : 0 }. group-actions button > : global ( svg ) { margin : 0 }. table-head , . request-row { grid-template-columns : 72 px 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 : 580 px }}
. merge-summary { display : flex ; min-height : 45 px ; align-items : center ; gap : 7 px ; border-bottom : 1 px solid var ( -- color - border - subtle ); color : #63c783 }. merge-summary . conflict { color : #e0737b }. merge-summary > span , . merge-summary > strong { font-size : 10.5 px }
@ media ( max-width : 1100px ) {. table-head , . request-row { grid-template-columns : 78 px 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 : 780 px }}
@ media ( max-width : 760px ) {. table-head , . request-row { grid-template-columns : 72 px minmax ( 260 px , 1 fr ) 190 px }. table-head > span : nth-child ( 5 ), . repo-branch { display : none }. request-groups { min-width : 600 px }. merge-summary { align-items : flex-start ; flex-wrap : wrap ; padding : 10 px 0 }}
. branch-loading { display : flex ; align-items : center ; gap : 5 px ; color : var ( -- color - ink - faint ); font-size : 9 px }
. provider-action { overflow : visible ; border-radius : 2 px ; box-shadow : 0 1 px 0 #0005 }. provider-button { border-radius : 2 px 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 : 2 px ; border-radius : 2 px }. 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 : 15 px 0 ; border-bottom : 1 px solid var ( -- color - border - subtle )}. comments-section > header { display : flex ; align-items : center ; gap : 7 px ; margin-bottom : 10 px }. comments-section h3 { margin : 0 ; font-size : 11 px }. comments-section > header > span { display : grid ; min-width : 18 px ; height : 18 px ; place-items : center ; border-radius : 9 px ; color : var ( -- color - ink - faint ); background : var ( -- color - surface - raised ); font-size : 9 px }. comments-loading , . no-comments { margin : 8 px 0 ; color : var ( -- color - ink - faint ); font-size : 10 px }. comments-loading { display : flex ; align-items : center ; gap : 6 px }. comment-list { display : grid ; gap : 10 px ; margin-bottom : 12 px }. comment-list article { display : flex ; align-items : flex-start ; gap : 9 px }. comment-list article > div { min-width : 0 ; flex : 1 ; padding : 9 px 10 px ; border : 1 px solid var ( -- color - border - subtle ); border-radius : 2 px ; background : var ( -- app - bg )}. comment-list article header { display : flex ; align-items : center ; justify-content : space-between ; gap : 8 px ; margin-bottom : 6 px }. comment-list article strong { font-size : 10.5 px }. comment-list article time { color : var ( -- color - ink - faint ); font-size : 9 px }. comment-list article p { margin : 0 ; white-space : pre-wrap ; color : var ( -- color - ink - muted ); font-size : 10.5 px ; line-height : 1.5 }. comment-composer { display : flex ; align-items : flex-start ; gap : 9 px ; margin-top : 10 px }. comment-composer > div { display : grid ; flex : 1 ; justify-items : end ; gap : 7 px }. comment-composer textarea { box-sizing : border-box ; width : 100 % ; min-height : 82 px ; resize : vertical ; padding : 9 px 10 px ; border : 1 px 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 : 29 px ; align-items : center ; gap : 6 px ; padding : 0 10 px ; border : 1 px solid #4fa565 ; border-radius : 2 px ; color : #63c783 ; background : color-mix ( in srgb , #4fa565 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 : 90 px 0 0 12 % ; z-index : 5 ; box-shadow : -12 px 0 30 px #0008 }}
. comment-list { max-height : 230 px ; overflow-y : auto ; overscroll-behavior : contain ; padding-right : 5 px ; scrollbar-gutter : stable }. comment-list :: -webkit-scrollbar { width : 7 px }. comment-list :: -webkit-scrollbar-thumb { border : 2 px solid transparent ; border-radius : 4 px ; 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 : 68 px ; max-height : 92 px }. detail-actions { margin-bottom : 12 px }
. 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 : 48 px ; align-items : center ; padding : 0 20 px ; border-bottom : 1 px solid var ( -- color - border ); background : var ( -- color - surface - raised )}. detail-provider { display : flex ; align-items : center ; gap : 9 px ; color : var ( -- color - ink - muted )}. detail-provider > : global ( svg ) { color : var ( -- color - accent )}. detail-provider strong { font-size : 13 px ; font-weight : 600 }. detail-header-actions { display : flex ; align-items : center ; gap : 3 px ; margin-left : auto }. detail-content { display : grid ; min-height : 0 ; flex : 1 ; grid-template-columns : minmax ( 0 , 1 fr ) 300 px ; gap : 28 px ; 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 : 18 px 0 0 ; border-bottom : 1 px solid var ( -- color - border - subtle )}. detail-title > span { color : var ( -- color - ink - faint ); font-size : 10 px }. detail-title h2 { margin : 7 px 0 10 px ; font-size : 20 px ; font-weight : 520 ; line-height : 1.25 }. detail-title . detail-summary { min-height : 34 px ; border : 0 }. detail-title . detail-summary > span : last-child { color : var ( -- color - ink - muted ); font-size : 10.5 px }. detail-title . branch-detail { padding : 8 px 0 13 px ; border : 0 }. detail-main > . description { padding : 13 px 0 }. detail-main > . comments-section { display : flex ; min-height : 0 ; flex : 1 ; flex-direction : column ; overflow : hidden ; padding : 13 px 0 18 px }. detail-main . merge-summary { flex : 0 0 auto ; margin-bottom : 11 px ; padding : 0 12 px ; border : 1 px 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 : 18 px 0 ; border-left : 1 px solid var ( -- color - border - subtle ); padding-left : 22 px ; background : var ( -- color - surface )}. detail-sidebar . detail-actions { display : grid ; grid-template-columns : 1 fr ; gap : 6 px ; margin : 0 0 8 px }. detail-sidebar section { padding : 17 px 0 ; border-bottom : 1 px solid var ( -- color - border - subtle )}. detail-sidebar section header { display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px }. detail-sidebar section h3 { margin : 0 ; color : var ( -- color - ink - muted ); font-size : 11 px ; font-weight : 560 }. people { display : grid ; grid-template-columns : 24 px minmax ( 0 , 1 fr ); align-items : center ; gap : 8 px ; margin-top : 12 px }. people . compact { grid-template-columns : 24 px }
@ media ( max-width : 900px ) {. detail-content { grid-template-columns : minmax ( 0 , 1 fr ) 270 px ; gap : 18 px ; padding : 0 18 px }. detail-sidebar { padding-left : 16 px }. detail-title h2 { font-size : 17 px }}
@ 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 : 760 px ; max-width : 100 % ; height : 100 % ; border-left : 1 px 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 : #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 ))}
@ media ( max-width : 760px ) {. detail-panel { inset : 0 ; width : 100 % ; min-width : 0 ; max-width : none }}
</ style >