Files
GitLite/src/lib/components/IssueCenter.svelte
T
Christoph d26d2ac982 refactor(dialogs): add unified dialog header chrome and opt-in markup
Introduce a shared dialog header style (.unified-dialog-header) in app.css and opt dialog components into the new chrome by updating their header markup. Headers now use unified-dialog-icon and unified-dialog-text elements (and import the matching Lucide icons where needed), which standardizes icon placement, title/eyebrow layout, close button styling and responsive behavior. The Command Palette layout was adjusted to include the new header and its grid rows.

The CSS is explicitly opt-in (so page/section headers remain unchanged) and includes hover/focus styles and a theme-sensitive close color variable. This commit is a UI refactor only — no API or behavior logic changes.
2026-09-11 22:50:31 +02:00

348 lines
22 KiB
Svelte

<script context="module" lang="ts">
const cache = new Map<string, { issues: IntegrationIssue[]; nextCursor: string | null }>();
</script>
<script lang="ts">
import { onMount, onDestroy, tick } from "svelte";
import { fly } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import { ChevronRight, CircleDot, Plus, FolderGit2, ExternalLink, RefreshCw, Search, Settings2, X, XCircle } from "@lucide/svelte";
import "../issueWorkspace.css";
import CreateIssueDialog from "./CreateIssueDialog.svelte";
import IssueComments from "./IssueComments.svelte";
import IssueLabels from "./IssueLabels.svelte";
import IssueAssignees from "./IssueAssignees.svelte";
import { issueTone, issueStateLabel } from "../issuePresentation";
import IntegrationBoardView from "./IntegrationBoardView.svelte";
import SelectMenu from "./SelectMenu.svelte";
import { readWorkspacePreferences, writeWorkspacePreferences } from "../workspacePreferences";
import { listIntegrationIssues, closeIntegrationIssue, listAzureIssueStates, setAzureIssueState, openInBrowser } from "../git";
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
import type { GitIntegrationSettings, GitIntegrationSource, IntegrationIssue, StoredCredential } from "../types";
export let language: "de" | "en" = "en";
export let integrations: GitIntegrationSettings;
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
export let onOpenSettings: () => void;
let createSource: GitIntegrationSource | null = null;
let createKey = "";
let createRepository = "";
let viewMode: "list" | "board" = "list";
let sourceId = "";
let issues: IntegrationIssue[] = [];
let nextCursor: string | null = null;
let query = "";
let stateFilter = "";
let repositoryFilter = "";
let collapsedRepositories = new Set<string>();
let selectedId = "";
let detailTrigger: HTMLElement | null = null;
let detailPanel: HTMLElement | undefined;
let reduceMotion = false;
let loading = false;
let closingId = "";
let closingKey = "";
let actionError = "";
let azureStates: string[] = [];
let statesLoading = false;
let statesError = "";
let statesGeneration = 0;
let lastStateKey = "";
let targetState = "";
let closedStates = new Map<string, string>();
let error = "";
let mounted = false;
let generation = 0;
let lastKey = "";
const preferenceKey = "gitty.issues.v1";
$: de = language === "de";
$: sources = configuredIntegrationSources(integrations);
$: source = sources.find(item => item.id === sourceId) ?? sources[0];
$: sourceKey = source ? JSON.stringify([source.id, source.provider, source.accountId, source.baseUrl]) : "";
$: if (mounted && sourceKey !== lastKey) {
lastKey = sourceKey;
generation++;
loading = false;
const saved = cache.get(sourceKey);
issues = saved?.issues ?? [];
nextCursor = saved?.nextCursor ?? null;
selectedId = "";
restoreFilters(sourceKey);
error = "";
if (source) void loadIssues();
}
$: if (mounted && sourceKey && sourceKey === lastKey) {
writeWorkspacePreferences(preferenceKey, { sourceId: source?.id ?? "", viewMode });
writeWorkspacePreferences(`${preferenceKey}:${sourceKey}`, { stateFilter, repositoryFilter, query });
}
$: normalizedQuery = query.trim().toLocaleLowerCase();
$: filtered = issues.filter(issue => (!repositoryFilter || issue.repositoryName === repositoryFilter) && (!stateFilter || issue.state === stateFilter) &&
`${issue.title} ${issue.number} ${issue.repositoryName} ${issue.author} ${issue.labels.join(" ")} ${issue.assignees.join(" ")}`.toLocaleLowerCase().includes(normalizedQuery));
$: repositoryOptions = [{ value: "", label: de ? "Alle Repositories" : "All repositories" }, ...[...new Set(issues.map(issue => issue.repositoryName))].sort().map(value => ({ value, label: value || (de ? "Ohne Repository" : "No repository") }))];
$: groups = [...new Set(filtered.map(issue => issue.repositoryName))].sort().map(repository => ({ repository, issues: filtered.filter(issue => issue.repositoryName === repository) }));
$: stateOptions = [{ value: "", label: de ? "Alle Status" : "All states" }, ...[...new Set(issues.map(issue => issue.state))].sort().map(value => ({ value, label: value }))];
$: selected = issues.find(issue => issue.id === selectedId);
$: description = selected ? plainDescription(selected) : "";
$: stateKey = source?.provider === "azure-devops" && selected ? JSON.stringify([sourceKey, selected.id]) : "";
$: if (stateKey !== lastStateKey) {
lastStateKey = stateKey;
statesGeneration++;
azureStates = [];
statesError = "";
statesLoading = false;
actionError = "";
targetState = "";
if (stateKey) void loadAzureStates();
}
onMount(() => {
const saved = readWorkspacePreferences(preferenceKey);
sourceId = saved.sourceId ?? "";
viewMode = saved.viewMode === "board" ? "board" : "list";
mounted = true;
reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const closeDetailsOutside = (event: PointerEvent) => {
if (selectedId && detailPanel && event.target instanceof Node && !detailPanel.contains(event.target)) {
selectedId = "";
}
};
window.addEventListener("pointerdown", closeDetailsOutside, true);
const timer = window.setInterval(() => { if (!loading && !closingId && source) void loadIssues(); }, 180_000);
return () => {
window.clearInterval(timer);
window.removeEventListener("pointerdown", closeDetailsOutside, true);
};
});
onDestroy(() => { generation++; statesGeneration++; });
function restoreFilters(key: string) {
const saved = readWorkspacePreferences(`${preferenceKey}:${key}`);
stateFilter = saved.stateFilter ?? "";
repositoryFilter = saved.repositoryFilter ?? "";
query = saved.query ?? "";
}
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
try {
return await Promise.race([promise, new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(de ? "Zeitüberschreitung beim Laden." : "Loading timed out.")), ms);
})]);
} finally { clearTimeout(timer!); }
}
async function loadIssues(more = false) {
if (!source || loading || (closingId && closingKey === sourceKey)) return;
const current = source;
const key = sourceKey;
const requestGeneration = ++generation;
loading = true;
error = "";
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(current.provider, current.accountId)), 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token stored for this integration.");
const page = await withTimeout(listIntegrationIssues(current.provider, current.baseUrl, credential.username, credential.password, more ? nextCursor : null), 40_000);
if (generation !== requestGeneration) return;
issues = [...new Map([...(more ? issues : []), ...page.issues].map(issue => [issue.id, issue])).values()];
nextCursor = page.nextCursor;
cache.set(key, { issues, nextCursor });
} catch (cause) {
if (generation === requestGeneration) error = cause instanceof Error ? cause.message : String(cause);
} finally {
if (generation === requestGeneration) loading = false;
}
}
async function closeDetails() {
selectedId = "";
await tick();
detailTrigger?.focus();
}
function toggleRepository(repository: string) {
const key = JSON.stringify([sourceKey, repository]);
const next = new Set(collapsedRepositories);
if (next.has(key)) next.delete(key);
else next.add(key);
collapsedRepositories = next;
}
function plainDescription(issue: IntegrationIssue) {
if (!issue.descriptionHtml) return issue.description;
// Azure returns HTML. Display inert text, never inject provider HTML into the app.
const document = new DOMParser().parseFromString(issue.description, "text/html");
document.querySelectorAll("script, style").forEach(element => element.remove());
document.querySelectorAll("br").forEach(element => element.replaceWith("\n"));
document.querySelectorAll("p, div, li").forEach(element => element.append("\n"));
return document.body.textContent?.trim() ?? "";
}
async function loadAzureStates() {
if (!selected || source?.provider !== "azure-devops") return;
const current = source;
const issue = selected;
const requestGeneration = ++statesGeneration;
statesLoading = true;
statesError = "";
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(current.provider, current.accountId)), 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
const states = await withTimeout(listAzureIssueStates(current.baseUrl, credential.username, credential.password, issue.repositoryName, issue.number), 70_000);
if (requestGeneration !== statesGeneration) return;
azureStates = states;
targetState = issue.state;
} catch (cause) {
if (requestGeneration === statesGeneration) statesError = `${de ? "Status konnten nicht geladen werden." : "Could not load states."} ${String(cause)}`;
} finally { if (requestGeneration === statesGeneration) statesLoading = false; }
}
async function changeIssueState(nextState?: string) {
if (!selected || !source || closingId || loading) return;
const issue = selected;
const current = source;
const key = sourceKey;
closingId = issue.id;
closingKey = key;
actionError = "";
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(current.provider, current.accountId)), 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
const state = nextState && current.provider === "azure-devops"
? await setAzureIssueState(current.baseUrl, credential.username, credential.password, issue.repositoryName, issue.number, nextState)
: await closeIntegrationIssue(current.provider, current.baseUrl, credential.username, credential.password, issue.repositoryName, issue.number);
const saved = cache.get(key);
if (saved) cache.set(key, { ...saved, issues: saved.issues.map(item => item.id === issue.id ? { ...item, state } : item) });
if (!nextState) closedStates = new Map(closedStates).set(`${key}:${issue.id}`, state);
if (sourceKey === key) issues = issues.map(item => item.id === issue.id ? { ...item, state } : item);
} catch (cause) {
if (sourceKey === key && selectedId === issue.id) actionError = `${de ? "Status konnte nicht geändert werden. Bitte Schreibrechte und Übergangsregeln prüfen." : "Could not change state. Check write permissions and transition rules."} ${String(cause)}`;
} finally { closingId = ""; }
}
function issueCreated(issue: IntegrationIssue) {
const key = createKey;
const saved = cache.get(key);
const next = [issue, ...(saved?.issues ?? []).filter(item => item.id !== issue.id)];
cache.set(key, { issues: next, nextCursor: saved?.nextCursor ?? null });
createSource = null;
if (sourceKey !== key) return;
generation++; // Ignore any list response started before creation.
loading = false;
issues = next;
viewMode = "list";
repositoryFilter = ""; stateFilter = ""; query = "";
const collapsed = new Set(collapsedRepositories);
collapsed.delete(JSON.stringify([key, issue.repositoryName]));
collapsedRepositories = collapsed;
selectedId = issue.id;
error = ""; actionError = "";
}
async function openIssue(url: string) {
try { await openInBrowser(url); }
catch (cause) { error = String(cause); }
}
</script>
<svelte:window onkeydown={event => { if (event.key === "Escape" && selectedId) void closeDetails(); }} />
<section class="issue-center" aria-label="Issues">
<header class="workspace-heading page-header">
<div class="page-heading"><CircleDot size={17} /><h1>Issues</h1></div>
<button class="workspace-button" onclick={onOpenSettings} title={de ? "Integrationen konfigurieren" : "Configure integrations"}><Settings2 size={14} />{de ? "Integrationen" : "Integrations"}</button>
</header>
{#if !sources.length}
<div class="workspace-empty"><span class="empty-symbol"><CircleDot size={26} /></span><h2>{de ? "Noch keine Integration eingerichtet" : "No integrations configured"}</h2><p>{de ? "Verbinde einen Anbieter, um Issues zu sehen." : "Connect a provider to view issues."}</p><button class="workspace-button" onclick={onOpenSettings}>{de ? "Integration einrichten" : "Set up integration"}</button></div>
{:else}
<div class="workspace-navigation">
<div class="view-toggle" role="group" aria-label={de ? "Issue-Ansicht" : "Issue view"}>
<button class:active={viewMode === "list"} aria-pressed={viewMode === "list"} onclick={() => { viewMode = "list"; }}>{de ? "Liste" : "List"}</button>
<button class:active={viewMode === "board"} aria-pressed={viewMode === "board"} onclick={() => { viewMode = "board"; }}>Board</button>
</div>
<SelectMenu class="source-select" value={source?.id ?? ""} options={sources.map(item => ({ value: item.id, label: item.label }))} ariaLabel={de ? "Integration auswählen" : "Select integration"} onChange={value => { sourceId = value; }} />
</div>
{#if viewMode === "board" && source}
{#key sourceKey}<IntegrationBoardView {source} {language} {loadCredential} />{/key}
{:else}
<div class="issue-content">
<div class="issue-main">
<div class="issue-toolbar">
<label class="workspace-search"><Search size={15} /><input bind:value={query} placeholder={de ? "Issues durchsuchen …" : "Search issues …"} aria-label={de ? "Issues durchsuchen" : "Search issues"} /></label>
<SelectMenu class="repository-select" value={repositoryFilter} options={repositoryOptions} ariaLabel={de ? "Repository filtern" : "Filter repository"} onChange={value => { repositoryFilter = value; selectedId = ""; }} />
<SelectMenu class="state-select" value={stateFilter} options={stateOptions} ariaLabel={de ? "Status filtern" : "Filter state"} onChange={value => { stateFilter = value; }} />
<button class="workspace-button icon-button" disabled={loading} onclick={() => loadIssues()} title={de ? "Aktualisieren" : "Refresh"} aria-label={de ? "Aktualisieren" : "Refresh"}><RefreshCw size={15} /></button>
{#if source}<button class="workspace-button issue-create-button" onclick={() => { createKey = sourceKey; createRepository = repositoryFilter; createSource = source; }}><Plus size={14}/>{de ? "Neues Issue" : "New issue"}</button>{/if}
</div>
{#if error}<div class="workspace-notice error" role="alert">{error}<p>{de ? "Bitte Token und Leserechte für Issues bzw. Azure Boards prüfen." : "Check the token and read permissions for issues or Azure Boards."}</p></div>{/if}
<div class="issue-list">
{#if filtered.length}<div class="issue-table-heading"><span>Issue</span><span>Status</span><span>{de ? "Zugewiesen" : "Assignee"}</span></div>{/if}
{#each groups as group (group.repository)}
{@const collapsed = collapsedRepositories.has(JSON.stringify([sourceKey, group.repository]))}
<button class="repository-group-heading" aria-expanded={!collapsed} onclick={() => toggleRepository(group.repository)}>
<ChevronRight size={14} class={collapsed ? "repository-chevron" : "repository-chevron expanded"} />
<FolderGit2 size={15} /><strong>{group.repository || (de ? "Ohne Repository" : "No repository")}</strong><span>{group.issues.length}</span>
</button>
{#if !collapsed}
{#each group.issues as issue (issue.id)}
<button class="issue-row" class:selected={selectedId === issue.id} aria-pressed={selectedId === issue.id} onclick={event => { detailTrigger = event.currentTarget; selectedId = issue.id; actionError = ""; }}>
<span class="issue-identity"><span class="issue-number">#{issue.number}</span><span class="issue-text"><strong>{issue.title}</strong><small>{issue.repositoryName}</small><IssueLabels labels={issue.labels} /></span></span>
<span class="issue-status" data-tone={issueTone(issue.state)}><span class="status-dot"></span>{issueStateLabel(issue.state, de)}</span>
<IssueAssignees names={issue.assignees} compact />
</button>
{/each}
{/if}
{:else}
<div class="workspace-empty"><span class="empty-symbol"><CircleDot size={26} /></span><h2>{loading && !issues.length ? (de ? "Issues werden geladen" : "Loading issues") : error && !issues.length ? (de ? "Issues konnten nicht geladen werden" : "Could not load issues") : (de ? "Keine passenden Issues" : "No matching issues")}</h2><p>{de ? "Suche und Filter gelten für die bereits geladenen Einträge." : "Search and filters apply to the items already loaded."}</p></div>
{/each}
{#if nextCursor}<button class="workspace-button load-more" disabled={loading} onclick={() => loadIssues(true)}>{de ? "Weitere laden" : "Load more"}</button>{/if}
</div>
<footer class="issue-footer" aria-live="polite"><span>{filtered.length !== issues.length ? `${filtered.length} / ` : ""}{issues.length} {de ? "geladene Issues" : "loaded issues"}</span>{#if loading}<span>{de ? "Aktualisierung im Hintergrund …" : "Updating in background …"}</span>{/if}</footer>
</div>
</div>
{#if selected}
<aside bind:this={detailPanel} class="issue-detail-drawer" aria-label={de ? "Issue-Details" : "Issue details"} transition:fly={{ x: 140, duration: reduceMotion ? 0 : 210, easing: cubicOut }}>
<header class="issue-detail-header unified-dialog-header">
<div class="unified-dialog-heading"><span class="unified-dialog-icon" aria-hidden="true"><CircleDot size={17} /></span><div class="unified-dialog-text"><strong>{source?.label} · Issue</strong></div></div>
<div class="issue-detail-header-actions">
{#if selected.webUrl}<button class="workspace-button close-button" onclick={() => openIssue(selected!.webUrl)} aria-label={de ? "Im Anbieter öffnen" : "Open in provider"}><ExternalLink size={16} /></button>{/if}
<button data-dialog-close class="workspace-button close-button" onclick={closeDetails} aria-label={de ? "Details schließen" : "Close details"}><X size={17} /></button>
</div>
</header>
<div class="issue-detail-body">
<div class="issue-detail-main">
<div class="issue-detail-title"><div><span>#{selected.number}</span><h2>{selected.title}</h2></div>
<div class="issue-detail-summary"><span class="issue-status" data-tone={issueTone(selected.state)}><span class="status-dot"></span>{issueStateLabel(selected.state, de)}</span><IssueAssignees names={selected.author ? [selected.author] : []} /></div>
</div>
<section class="issue-detail-description"><h3>{de ? "Beschreibung" : "Description"}</h3><div class="description">{description || (de ? "Keine Beschreibung vorhanden." : "No description provided.")}</div></section>
{#if source}{#key `${sourceKey}:${selected.id}`}<IssueComments {source} issue={selected} {language} {loadCredential} />{/key}{/if}
</div>
<div class="issue-detail-sidebar">
{#if source?.provider === "azure-devops"}
<section>
<h3>{de ? "Status ändern" : "Change state"}</h3>
<SelectMenu value={targetState} options={azureStates.map(value => ({ value, label: value }))} disabled={statesLoading || !!closingId || loading || !azureStates.length} placeholder={statesLoading ? (de ? "Status werden geladen …" : "Loading states …") : selected.state} ariaLabel={de ? "Neuer Status" : "New state"} onChange={value => { targetState = value; }} />
<button class="workspace-button issue-state-action" disabled={statesLoading || !!closingId || loading || !targetState || targetState === selected.state || !azureStates.includes(targetState)} onclick={() => changeIssueState(targetState)}>{closingId === selected.id ? (de ? "Wird gespeichert …" : "Saving …") : (de ? "Status übernehmen" : "Apply state")}</button>
{#if statesError}<p class="comment-error" role="alert">{statesError}</p><button class="workspace-button" onclick={loadAzureStates}>{de ? "Erneut versuchen" : "Retry"}</button>{/if}
</section>
{:else if selected.state !== "closed" && closedStates.get(`${sourceKey}:${selected.id}`) !== selected.state}
<button class="workspace-button issue-close-action" disabled={!!closingId || loading} onclick={() => changeIssueState()}><XCircle size={15} />{closingId === selected.id ? (de ? "Wird geschlossen …" : "Closing …") : (de ? "Issue schließen" : "Close issue")}</button>
{/if}
{#if actionError}<p class="comment-error" role="alert">{actionError}</p>{/if}
{#if selected.webUrl}<button class="workspace-button inspector-open" onclick={() => openIssue(selected!.webUrl)}><ExternalLink size={14} />{de ? "Im Browser öffnen" : "Open in browser"}</button>{/if}
<section><h3>{de ? "Zugewiesen" : "Assignees"}</h3><IssueAssignees names={selected.assignees} /></section>
<section><h3>Labels</h3>{#if selected.labels.length}<IssueLabels labels={selected.labels} />{:else}<small>{de ? "Keine Labels" : "No labels"}</small>{/if}</section>
<section><h3>Repository</h3><strong class="issue-detail-repo"><FolderGit2 size={15} />{selected.repositoryName}</strong></section>
{#if selected.updatedAt}<section><h3>{de ? "Aktualisiert" : "Updated"}</h3><small>{new Date(selected.updatedAt).toLocaleString(de ? "de-DE" : "en-US")}</small></section>{/if}
</div>
</div>
</aside>
{/if}
{/if}
{/if}
</section>
{#if createSource}
<CreateIssueDialog source={createSource} {de} initialRepository={createRepository} {loadCredential} onClose={() => { createSource = null; }} onCreated={issueCreated}/>
{/if}