Files
GitLite/src/lib/components/IssueCenter.svelte
T
Christoph 0539020545 feat(integrations): add multi-provider board and issue integrations
Add backend integration modules to discover, read, and modify
provider-hosted boards, issues, and comments across multiple providers.
Expose Tauri commands for board discovery, listing, and card moves,
and implement safe issue actions and comment APIs.
Wire new Svelte UI components to render boards, issue centers,
comments, labels, and assignees, and add sanitized markdown rendering.

- Add board discovery, board reading, and card-move APIs
- Add Svelte components and styles for integrated board UI
- Use marked + DOMPurify for safe markdown rendering
2026-09-08 21:19:54 +02:00

260 lines
17 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, FolderGit2, ExternalLink, RefreshCw, Search, Settings2, X, XCircle } from "@lucide/svelte";
import "../issueWorkspace.css";
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 { listIntegrationIssues, closeIntegrationIssue, openInBrowser } from "../git";
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
import type { GitIntegrationSettings, 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 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 closedStates = new Map<string, string>();
let error = "";
let mounted = false;
let generation = 0;
let lastKey = "";
$: 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 = "";
stateFilter = "";
repositoryFilter = "";
error = "";
if (source) void loadIssues();
}
$: 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) : "";
onMount(() => {
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++; });
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 closeIssue() {
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 = 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) });
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 ? "Issue konnte nicht geschlossen werden. Bitte Schreibrechte prüfen." : "Could not close issue. Check write permissions."} ${String(cause)}`;
} finally { closingId = ""; }
}
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">
<div class="workspace-title"><span class="workspace-symbol"><CircleDot size={21} strokeWidth={1.7} /></span><div><h1>Issues</h1><p>{de ? "Arbeit im Blick. Über alle Integrationen." : "Work in focus. Across your integrations."}</p></div></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>
</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} />
</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">
<div><CircleDot size={17} /><strong>{source?.label} · Issue</strong></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 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 selected.state !== "closed" && closedStates.get(`${sourceKey}:${selected.id}`) !== selected.state}
<button class="workspace-button issue-close-action" disabled={!!closingId || loading} onclick={closeIssue}><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>