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
This commit is contained in:
2026-09-08 21:19:54 +02:00
parent 80c7cf7280
commit 0539020545
24 changed files with 3098 additions and 32 deletions
+8 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Folder, GitBranch, GitPullRequest, Plus, X } from "@lucide/svelte";
import { CircleDot, Folder, GitBranch, GitPullRequest, Plus, X } from "@lucide/svelte";
interface RepositoryTabItem {
path: string;
@@ -7,11 +7,12 @@
branch: string | null;
}
export let activeView: "management" | "review-center" | "repository" = "management";
export let activeView: "management" | "review-center" | "issues" | "repository" = "management";
export let repoTabs: RepositoryTabItem[] = [];
export let isBusy: boolean = false;
export let language: "en" | "de" = "en";
export let onOpenManagement: () => void = () => {};
export let onOpenIssues: () => void = () => {};
export let onOpenReviewCenter: () => void = () => {};
export let isActive: (path: string) => boolean = () => false;
export let onSelect: (path: string) => void | Promise<void> = () => {};
@@ -40,6 +41,11 @@
<span>PR</span>
</button>
</div>
<div class="repo-tab-wrap" class:active={activeView === "issues"} role="presentation">
<button class="repo-tab" type="button" onclick={onOpenIssues} disabled={isBusy} title="Issues" aria-label="Issues">
<CircleDot size={13} aria-hidden="true" /><span>Issues</span>
</button>
</div>
{#each repoTabs as repo (repo.path)}
<div
class="repo-tab-wrap"
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
import { tick } from "svelte";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { Heading1, Heading2, Heading3, Bold, Italic, Strikethrough, Quote, Code2, Link, List, ListOrdered, ListTodo, Table2, AtSign, MessageSquare, CaseSensitive, WrapText, Send } from "@lucide/svelte";
import { openInBrowser } from "../git";
export let value = "";
export let language: "de" | "en" = "en";
export let disabled = false;
export let busy = false;
export let onSend: () => void | Promise<void>;
let textarea: HTMLTextAreaElement;
let preview = false;
let monospace = false;
let wrap = true;
let linkError = "";
$: de = language === "de";
$: rendered = preview ? DOMPurify.sanitize(marked.parse(value, { async: false, gfm: true, breaks: true }), {
ALLOWED_TAGS: ["p","br","strong","em","del","s","blockquote","pre","code","h1","h2","h3","h4","h5","h6","ul","ol","li","a","table","thead","tbody","tr","th","td","hr","input"],
ALLOWED_ATTR: ["href","title","type","checked","disabled","align"],
}) : "";
$: tools = [
{icon:Heading1,label:de?"Überschrift 1":"Heading 1",prefix:"# ",line:true},
{icon:Heading2,label:de?"Überschrift 2":"Heading 2",prefix:"## ",line:true},
{icon:Heading3,label:de?"Überschrift 3":"Heading 3",prefix:"### ",line:true},
{icon:Bold,label:de?"Fett":"Bold",prefix:"**",suffix:"**",group:true},
{icon:Italic,label:de?"Kursiv":"Italic",prefix:"_",suffix:"_"},
{icon:Strikethrough,label:de?"Durchgestrichen":"Strikethrough",prefix:"~~",suffix:"~~"},
{icon:Quote,label:de?"Zitat":"Quote",prefix:"> ",line:true,group:true},
{icon:Code2,label:"Code",prefix:"`",suffix:"`"},
{icon:Link,label:"Link",prefix:"[",suffix:"](https://example.com)"},
{icon:List,label:de?"Aufzählung":"Bullet list",prefix:"- ",line:true,group:true},
{icon:ListOrdered,label:de?"Nummerierte Liste":"Numbered list",prefix:"1. ",line:true},
{icon:ListTodo,label:de?"Aufgabenliste":"Task list",prefix:"- [ ] ",line:true},
{icon:Table2,label:de?"Tabelle":"Table",prefix:"\n| A | B |\n| --- | --- |\n| | |\n"},
{icon:AtSign,label:de?"Erwähnung einfügen":"Insert mention",prefix:"@",suffix:"",group:true},
{icon:MessageSquare,label:de?"Issue-/PR-Verweis einfügen":"Insert issue/PR reference",prefix:"#",suffix:""},
];
async function format(prefix: string, suffix = "", line = false) {
if (disabled || busy) return;
preview = false;
await tick();
let start = textarea.selectionStart, end = textarea.selectionEnd;
if (line) {
start = value.lastIndexOf("\n", start - 1) + 1;
const selected = value.slice(start, end);
const replacement = selected.split("\n").map((text, i) => `${prefix === "1. " ? `${i + 1}. ` : prefix}${text}`).join("\n");
value = value.slice(0,start) + replacement + value.slice(end);
await tick(); textarea.focus(); textarea.setSelectionRange(start, start+replacement.length);
} else {
const selected = value.slice(start,end);
// Multi-line code selections become fenced blocks.
if (prefix === "`" && selected.includes("\n")) { prefix = "\n```\n"; suffix = "\n```\n"; }
value = value.slice(0,start)+prefix+selected+suffix+value.slice(end);
await tick(); textarea.focus(); textarea.setSelectionRange(start+prefix.length,start+prefix.length+selected.length);
}
}
function keyboard(event: KeyboardEvent) {
if (!(event.ctrlKey || event.metaKey)) return;
const key = event.key.toLowerCase();
if (key === "b" || key === "i" || key === "k") { event.preventDefault(); void format(key === "b" ? "**" : key === "i" ? "_" : "[",key === "b" ? "**" : key === "i" ? "_" : "](https://example.com)"); }
if (key === "enter") { event.preventDefault(); if (value.trim() && !busy && !disabled) void onSend(); }
}
function previewLinks(node: HTMLElement) {
node.addEventListener("click", previewClick);
return { destroy() { node.removeEventListener("click", previewClick); } };
}
async function previewClick(event: MouseEvent) {
const anchor = event.target instanceof Element ? event.target.closest("a") : null;
if (!anchor) return;
event.preventDefault();
const href = anchor.getAttribute("href") ?? "";
if (!/^https?:\/\//i.test(href)) return;
try { await openInBrowser(href); } catch (cause) { linkError = String(cause); }
}
</script>
<div class="md-editor">
<div class="md-tabs" role="group" aria-label={de ? "Editor-Ansicht" : "Editor view"}>
<button type="button" class:active={!preview} aria-pressed={!preview} onclick={() => { preview = false; }}>{de ? "Schreiben" : "Write"}</button>
<button type="button" class:active={preview} aria-pressed={preview} onclick={() => { preview = true; }}>{de ? "Vorschau" : "Preview"}</button>
</div>
<div class="md-toolbar" role="group" aria-label={de ? "Markdown-Formatierung" : "Markdown formatting"}>
{#each tools as tool}<button type="button" class:group-start={tool.group} title={tool.label} aria-label={tool.label} disabled={disabled || busy || preview} onmousedown={event => event.preventDefault()} onclick={() => format(tool.prefix,tool.suffix,tool.line)}><svelte:component this={tool.icon} size={15} strokeWidth={1.7} /></button>{/each}
<span class="md-spacer"></span>
<button type="button" title={de ? "Monospace-Schrift" : "Monospace font"} aria-label={de ? "Monospace-Schrift" : "Monospace font"} aria-pressed={monospace} onclick={() => { monospace = !monospace; }}><CaseSensitive size={16} /></button>
<button type="button" title={de ? "Zeilenumbruch" : "Line wrap"} aria-label={de ? "Zeilenumbruch" : "Line wrap"} aria-pressed={wrap} onclick={() => { wrap = !wrap; }}><WrapText size={16} /></button>
</div>
{#if preview}
<!-- Rendered Markdown is restricted to safe content tags and sanitized before insertion. -->
<div class="md-preview" role="region" aria-label={de ? "Kommentarvorschau" : "Comment preview"} use:previewLinks>
{#if value.trim()}{@html rendered}{:else}<span>{de ? "Noch nichts zum Anzeigen." : "Nothing to preview yet."}</span>{/if}
</div>
{:else}
<textarea bind:this={textarea} bind:value class:monospace wrap={wrap ? "soft" : "off"} maxlength="100000" rows="5" disabled={disabled || busy} aria-label={de ? "Kommentar schreiben" : "Write comment"} placeholder={de ? "Kommentar hinzufügen …" : "Leave a comment …"} onkeydown={keyboard}></textarea>
{/if}
{#if linkError}<small role="alert">{linkError}</small>{/if}
<div class="md-footer"><small>Markdown <span>· Ctrl/⌘ + Enter</span></small><button class="md-send" type="button" disabled={!value.trim() || disabled || busy} onclick={() => onSend()}><Send size={13} />{busy ? (de ? "Wird gesendet …" : "Sending …") : (de ? "Kommentar senden" : "Post comment")}</button></div>
</div>
<style>
.md-editor{width:100%;min-width:0;color:var(--color-ink);font:400 12px/1.5 var(--font-sans)}
.md-editor button{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:0;padding:0;border:0;border-radius:0;color:var(--color-ink-muted);background:transparent;font:inherit;cursor:pointer;box-shadow:none}
.md-editor button:disabled{opacity:.4;cursor:default}.md-editor button:focus-visible{outline:2px solid var(--color-accent);outline-offset:1px}.md-editor button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-hover)}
.md-tabs{display:flex;border-bottom:1px solid var(--color-border);gap:1px}.md-tabs button{position:relative;height:36px;padding:0 12px;border:1px solid transparent;border-bottom:0;font-size:12px}.md-tabs button.active{border-color:var(--color-border);color:var(--color-ink);background:var(--app-bg);font-weight:600}.md-tabs button.active:after{content:"";position:absolute;bottom:-1px;left:0;right:0;height:1px;background:var(--app-bg)}
.md-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:2px;padding:10px 2px;row-gap:6px}.md-toolbar button{width:25px;height:25px;flex-shrink:0}.md-toolbar .group-start{border-left:1px solid var(--color-border);margin-left:4px;padding-left:5px;width:30px}.md-spacer{flex:1}.md-toolbar button[aria-pressed=true]{color:var(--color-accent)}
.md-editor textarea,.md-preview{box-sizing:border-box;width:100%;min-height:132px;max-height:300px;margin:0;padding:11px 12px;border:1px solid var(--color-border-input);border-radius:3px;color:var(--color-ink);background:var(--app-input-bg);font:400 12px/1.6 var(--font-sans);overflow:auto;resize:vertical}.md-editor textarea:focus{outline:none;box-shadow:none;border-color:var(--color-accent)}.md-editor textarea.monospace{font-family:var(--font-mono)}.md-editor textarea::placeholder{color:var(--color-ink-faint)}.md-preview{overflow-wrap:anywhere;resize:none}.md-preview>span{color:var(--color-ink-faint)}
.md-preview :global(p){margin:0 0 10px;white-space:normal}.md-preview :global(h1),.md-preview :global(h2),.md-preview :global(h3){margin:12px 0 8px;line-height:1.3}.md-preview :global(h1){font-size:22px}.md-preview :global(h2){font-size:18px}.md-preview :global(h3){font-size:15px}.md-preview :global(ul){list-style:disc;padding-left:24px}.md-preview :global(ol){list-style:decimal;padding-left:24px}.md-preview :global(blockquote){border-left:3px solid var(--color-accent);padding-left:12px;margin:10px 0;color:var(--color-ink-muted)}.md-preview :global(pre){overflow:auto;background:var(--color-surface);padding:10px}.md-preview :global(code){font-family:var(--font-mono)}.md-preview :global(table){border-collapse:collapse;max-width:100%;margin:10px 0}.md-preview :global(th),.md-preview :global(td){border:1px solid var(--color-border);padding:5px 9px;font:inherit}.md-preview :global(a){color:var(--color-accent);text-decoration:underline}.md-preview :global(input[type=checkbox]){width:auto;margin-right:6px}
.md-footer{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:9px}.md-footer small{font-size:10px;color:var(--color-ink-faint)}.md-editor .md-send{height:32px;padding:0 10px;border:1px solid var(--color-accent);color:var(--color-accent);font-size:11px;white-space:nowrap}.md-editor .md-send:hover:not(:disabled){background:color-mix(in srgb,var(--color-accent) 12%,transparent)}
@media(max-width:420px){.md-footer small span{display:none}}
</style>
@@ -0,0 +1,280 @@
<script context="module" lang="ts">
const boardCache = new Map<string, IntegrationBoard>();
</script>
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { Columns3, ExternalLink, Link2, RefreshCw, Search, X } from "@lucide/svelte";
import SelectMenu from "./SelectMenu.svelte";
import IssueLabels from "./IssueLabels.svelte";
import IssueAssignees from "./IssueAssignees.svelte";
import { issueTone } from "../issuePresentation";
import { getIntegrationBoard, moveIntegrationBoardCard, listIntegrationBoards, openInBrowser } from "../git";
import { integrationCredentialKey } from "../integrations";
import type { GitIntegrationSource, IntegrationBoard, IntegrationBoardCard, IntegrationBoardReference, StoredCredential } from "../types";
export let source: GitIntegrationSource;
export let language: "de" | "en" = "en";
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
const preferenceKey = `gitty.board.v1:${JSON.stringify([source.id, source.baseUrl])}`;
const directoryKey = `${preferenceKey}:directory`;
let savedBoards: IntegrationBoardReference[] = [];
let discovering = false;
let discoveryWarnings: string[] = [];
let destroyed = false;
let connectOpen = true;
let boardUrl = "";
let activeUrl = "";
let board: IntegrationBoard | null = null;
let loading = false;
let moving = false;
let dragged: { cardId: string; columnId: string } | null = null;
let dropColumn = "";
let moveMessage = "";
$: canMove = board?.columns.some(column => !!column.moveTarget) ?? false;
let error = "";
let query = "";
let repositoryFilter = "";
let selected: IntegrationBoardCard | null = null;
let selectedColumnId = "";
let generation = 0;
$: de = language === "de";
$: normalizedQuery = query.trim().toLocaleLowerCase();
$: repositoryOptions = [{ value: "", label: de ? "Alle Repositories" : "All repositories" }, ...[...new Set(board?.columns.flatMap(column => column.cards.map(card => card.repository)).filter(Boolean) ?? [])].sort().map(value => ({ value, label: value }))];
$: columns = board?.columns.map(column => ({ ...column, cards: column.cards.filter(card => (!repositoryFilter || card.repository === repositoryFilter) &&
`${card.title} ${card.number} ${card.repository} ${card.labels.join(" ")} ${card.assignees.join(" ")} ${card.lane}`.toLocaleLowerCase().includes(normalizedQuery)) })) ?? [];
$: placeholder = source.provider === "github" ? "https://github.com/orgs/team/projects/1/views/1"
: source.provider.startsWith("gitlab") ? `${source.baseUrl}/group/project/-/boards/1`
: source.provider === "gitea" ? `${source.baseUrl}/owner/repo/projects/1`
: `${source.baseUrl}/Project/_boards/board/t/Team/Stories`;
onMount(() => {
try { boardUrl = localStorage.getItem(preferenceKey) ?? ""; } catch { /* Optional preference. */ }
try {
const saved: unknown = JSON.parse(localStorage.getItem(directoryKey) ?? "[]");
if (Array.isArray(saved)) savedBoards = saved.filter(item => item && typeof item.title === "string" && typeof item.webUrl === "string" && typeof item.scope === "string" && validBoardUrl(item.webUrl));
} catch { /* Invalid optional preference. */ }
if (boardUrl && validBoardUrl(boardUrl)) rememberBoard({ title: boardUrl, webUrl: boardUrl, scope: "" });
void discoverBoards();
if (boardUrl) { connectOpen = false; void loadBoard(boardUrl); }
const timer = window.setInterval(() => { if (activeUrl && !loading && !moving) void loadBoard(activeUrl); }, 180_000);
return () => window.clearInterval(timer);
});
onDestroy(() => { generation++; destroyed = true; });
function validBoardUrl(url: string) {
try { const parsed = new URL(url); return parsed.origin === new URL(source.baseUrl).origin && !parsed.username && !parsed.password; } catch { return false; }
}
function rememberBoard(item: IntegrationBoardReference) {
if (!validBoardUrl(item.webUrl)) return;
savedBoards = [...savedBoards.filter(board => board.webUrl !== item.webUrl), item];
try { localStorage.setItem(directoryKey, JSON.stringify(savedBoards)); } catch { /* Optional preference. */ }
}
function boardError(message: string) {
if (de && message.includes("publishes no Projects/Columns API")) return "Dieser Gitea-Server bietet keine Projects-/Columns-API an. Boards sind im Browser verfügbar, können aber mit dem API-Token weder automatisch gefunden noch importiert werden. Bitte das Original-Board öffnen.";
if (de && message.includes("Gitea returned 404")) return "Gitea meldet 404. Bitte Board-Link und Token-Zugriff prüfen. Möglicherweise unterstützt der Server die Projects-/Columns-API nicht.";
return message;
}
async function discoverBoards() {
if (discovering) return;
discovering = true;
discoveryWarnings = [];
try {
const credential = await timeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
const directory = await timeout(listIntegrationBoards(source.provider, source.baseUrl, credential.username, credential.password), 75_000);
if (destroyed) return;
for (const item of directory.boards) rememberBoard(item);
discoveryWarnings = directory.warnings.map(boardError);
} catch (cause) {
if (!destroyed) discoveryWarnings = [boardError(String(cause))];
} finally { if (!destroyed) discovering = false; }
}
async function loadBoard(url: string, collapseEditor = false) {
if (moving) return;
const requestedUrl = url.trim();
if (!requestedUrl) return;
const requestGeneration = ++generation;
if (requestedUrl !== activeUrl) {
selected = null;
repositoryFilter = "";
board = boardCache.get(`${preferenceKey}:${requestedUrl}`) ?? null;
}
activeUrl = requestedUrl;
loading = true;
error = "";
try {
const parsed = new URL(requestedUrl);
if (parsed.origin !== new URL(source.baseUrl).origin || parsed.username || parsed.password) {
throw new Error(de ? "Der Board-Link muss zur ausgewählten Integration gehören." : "The board link must belong to the selected integration.");
}
if (!savedBoards.some(item => item.webUrl === requestedUrl)) rememberBoard({ title: requestedUrl, webUrl: requestedUrl, scope: "" });
const credential = await timeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token stored for this integration.");
const result = await timeout(getIntegrationBoard(source.provider, source.baseUrl, credential.username, credential.password, requestedUrl), 70_000);
if (requestGeneration !== generation) return;
board = result;
rememberBoard({ title: result.title, webUrl: requestedUrl, scope: savedBoards.find(item => item.webUrl === requestedUrl)?.scope ?? "" });
if (collapseEditor && boardUrl.trim() === requestedUrl) connectOpen = false;
boardCache.set(`${preferenceKey}:${requestedUrl}`, result);
selected = selected ? result.columns.flatMap(column => column.cards).find(card => card.id === selected!.id) ?? null : null;
try { localStorage.setItem(preferenceKey, requestedUrl); } catch { /* Keep the board in memory. */ }
} catch (cause) {
if (requestGeneration === generation) error = boardError(cause instanceof Error ? cause.message : String(cause));
} finally {
if (requestGeneration === generation) loading = false;
}
}
async function moveCard(cardId: string, from: string, to: string) {
if (moving || loading || from === to || !board?.columns.find(column => column.id === to)?.moveTarget) return;
const previousBoard = board;
const card = previousBoard.columns.find(column => column.id === from)?.cards.find(card => card.id === cardId);
if (!card) return;
const previousSelectedColumn = selected?.id === cardId ? selectedColumnId : from;
const url = activeUrl;
const requestGeneration = generation;
// Keep the original arrays intact for an exact rollback, including card order.
board = {
...previousBoard,
columns: previousBoard.columns.map(column => {
const remove = column.id === from || (source.provider.startsWith("gitlab") && (to === "open" || to === "closed"));
const cards = remove ? column.cards.filter(item => item.id !== cardId) : column.cards;
return { ...column, cards: column.id === to && !cards.some(item => item.id === cardId) ? [...cards, card] : cards };
}),
};
if (selected?.id === cardId) selectedColumnId = to;
moving = true;
dragged = null;
dropColumn = "";
error = "";
moveMessage = de ? "Verschiebung wird gespeichert …" : "Saving move …";
try {
const credential = await timeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
await moveIntegrationBoardCard(source.provider, source.baseUrl, credential.username, credential.password, url, cardId, from, to);
// A successful mutation invalidates the cached snapshot, even if reloading fails.
boardCache.delete(`${preferenceKey}:${url}`);
if (destroyed || generation !== requestGeneration) return;
boardCache.set(`${preferenceKey}:${url}`, board!);
moveMessage = de ? "Verschoben. Board wird aktualisiert …" : "Moved. Refreshing board …";
moving = false;
if (selected?.id === cardId) selectedColumnId = to;
await loadBoard(url);
if (!destroyed) moveMessage = error ? (de ? "Verschoben; der angezeigte Stand konnte noch nicht aktualisiert werden." : "Moved; the displayed board could not be refreshed yet.") : (de ? "Karte verschoben." : "Card moved.");
} catch (cause) {
if (!destroyed && generation === requestGeneration) {
board = previousBoard;
if (selected?.id === cardId) selectedColumnId = previousSelectedColumn;
error = `${de ? "Verschieben nicht bestätigt. Bitte aktualisieren und Schreibrechte prüfen." : "Move not confirmed. Refresh and check write access."} ${String(cause)}`;
moveMessage = "";
}
} finally { if (!destroyed) moving = false; }
}
async function timeout<T>(promise: Promise<T>, duration: number): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
try {
return await Promise.race([promise, new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(de ? "Das Board hat nicht rechtzeitig geantwortet." : "The board did not respond in time.")), duration);
})]);
} finally { clearTimeout(timer!); }
}
async function openOriginal(url: string) {
try { await openInBrowser(url); } catch (cause) { error = String(cause); }
}
function notice(text: string) {
if (!de) return text;
if (text.startsWith("Team scope")) return "Team-Zuordnung, Spalten, geteilte Spalten und Swimlane-Namen werden übernommen. Temporäre Browser-Filter und das Alterslimit für abgeschlossene Karten werden nicht angewendet.";
if (text.startsWith("Saved board scope")) return "Die gespeicherte Board-Konfiguration wird angewendet. Temporäre Suchfilter aus dem Browser werden nicht übernommen.";
if (text.startsWith("Archived cards")) return "Archivierte Karten werden ausgeblendet. Die Spalten entsprechen den Auswahloptionen des Board-Feldes.";
if (text.startsWith("The saved GitHub view filter")) return `Der gespeicherte GitHub-Ansichtsfilter wird nicht angewendet. Alle nicht archivierten Projektkarten erscheinen in ihren ursprünglichen Spalten. ${text.split(": ")[1]?.split(". All non-archived")[0] ?? ""}`;
return text;
}
</script>
<svelte:window onkeydown={event => { if (event.key === "Escape") selected = null; }} />
<section class="board-view" aria-label={de ? "Kanban-Board" : "Kanban board"}>
<div class="board-directory">
<SelectMenu class="board-select" value={activeUrl} options={[{ value: "", label: de ? "Board auswählen" : "Select board" }, ...savedBoards.map(item => ({ value: item.webUrl, label: `${item.scope ? `${item.scope} · ` : ""}${item.title}` }))]} ariaLabel={de ? "Board auswählen" : "Select board"} onChange={value => { if (value && !moving) { boardUrl = value; void loadBoard(value, true); } }} />
<button class="workspace-button" disabled={discovering} onclick={discoverBoards}><RefreshCw size={14} />{de ? "Boards suchen" : "Find boards"}</button>
<small aria-live="polite">{discovering ? (de ? "Boards werden im Hintergrund gesucht …" : "Finding boards in background …") : `${savedBoards.length} Boards`}</small>
{#if !board && boardUrl && validBoardUrl(boardUrl)}<button class="workspace-button" onclick={() => openOriginal(boardUrl)}><ExternalLink size={14} />{de ? "Original öffnen" : "Open original"}</button>{/if}
</div>
{#if discoveryWarnings.length}<details class="board-discovery-notice" open={!savedBoards.length}><summary>{de ? "Hinweise zur Board-Suche" : "Board discovery notices"} ({discoveryWarnings.length})</summary>{#each discoveryWarnings as warning}<p>{warning}</p>{/each}</details>{/if}
{#if board}
<div class="board-header">
<div class="board-title"><h2>{board.title}</h2><small>{source.label} <span>·</span> {canMove ? (de ? "Karten zwischen Spalten verschieben" : "Move cards between columns") : (de ? "Lesende Ansicht" : "Read-only view")}{#if loading} <span>·</span> {de ? "Aktualisierung läuft …" : "Updating …"}{/if}</small></div>
<div class="board-tools">
<SelectMenu class="repository-select" value={repositoryFilter} options={repositoryOptions} ariaLabel={de ? "Repository filtern" : "Filter repository"} onChange={value => { repositoryFilter = value; selected = null; }} />
<label class="workspace-search"><Search size={15} /><input bind:value={query} aria-label={de ? "Board durchsuchen" : "Search board"} placeholder={de ? "Karten suchen …" : "Search cards …"} /></label>
<button class="workspace-button" class:active={connectOpen} aria-expanded={connectOpen} onclick={() => { connectOpen = !connectOpen; }}><Link2 size={14} />{de ? "Board wechseln" : "Change board"}</button>
<button class="workspace-button icon-button" disabled={loading || moving} onclick={() => loadBoard(activeUrl)} aria-label={de ? "Board aktualisieren" : "Refresh board"}><RefreshCw size={15} /></button>
<button class="workspace-button" onclick={() => openOriginal(board!.webUrl)}><ExternalLink size={14} />{de ? "Original öffnen" : "Open original"}</button>
</div>
</div>
{/if}
{#if connectOpen || !board}
<form class="board-connect" onsubmit={event => { event.preventDefault(); void loadBoard(boardUrl, true); }}>
<label for="native-board-url">{de ? "Original-Board" : "Original board"}</label>
<div class="board-connect-fields"><input id="native-board-url" type="url" bind:value={boardUrl} {placeholder} required aria-label={de ? "Board-Link" : "Board URL"} /><button class="workspace-button" type="submit" disabled={moving || !boardUrl.trim()}>{de ? "Board übernehmen" : "Load board"}</button></div>
</form>
{/if}
{#if moveMessage}<p class="board-notice" role="status">{moveMessage}</p>{/if}
{#if error}<div class="workspace-notice error" role="alert">{error}{#if board}<p>{de ? "Der zuletzt geladene Stand bleibt sichtbar." : "The last loaded board remains visible."}</p>{/if}</div>{/if}
{#if board}
{#if board.notice}<p class="board-notice">{notice(board.notice)}</p>{/if}
<div class="board-body">
<div class="board-columns" aria-label={de ? "Board-Spalten" : "Board columns"}>
{#each columns as column (column.id)}
<section class="board-column" class:drop-target={dropColumn === column.id} aria-label={column.title}
ondragover={event => { if (dragged && column.moveTarget && !moving && !loading && dragged.columnId !== column.id) { event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; dropColumn = column.id; } }}
ondragleave={event => { if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) dropColumn = ""; }}
ondrop={event => { event.preventDefault(); if (dragged) void moveCard(dragged.cardId, dragged.columnId, column.id); dropColumn = ""; }}>
<header><h3><span class="column-dot" data-tone={issueTone(column.title)}></span>{column.title}</h3><span class="column-count">{column.cards.length}{#if column.limit}<span class="wip-limit"> · WIP {column.limit}</span>{/if}</span></header>
<div class="column-cards">
{#each column.cards as card (`${column.id}:${card.id}`)}
<button class="board-card" draggable={!!column.moveTarget && !moving && !loading}
ondragstart={event => { if (moving || loading || !column.moveTarget) { event.preventDefault(); return; } dragged = { cardId: card.id, columnId: column.id }; if (event.dataTransfer) { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", card.id); } }}
ondragend={() => { dragged = null; dropColumn = ""; }} class:chosen={selected?.id === card.id} aria-pressed={selected?.id === card.id} onclick={() => { selected = card; selectedColumnId = column.id; }}>
{#if card.repository || card.number}<small class="card-reference">{card.repository}<span class="issue-number">{card.number ? `#${card.number}` : ""}</span></small>{/if}
<strong>{card.title}</strong>
{#if card.lane}<span class="lane">{card.lane}</span>{/if}
<IssueLabels labels={card.labels} />
{#if card.assignees.length}<IssueAssignees names={card.assignees} />{/if}
</button>
{:else}<p class="column-empty">{de ? "Keine Karten" : "No cards"}</p>{/each}
</div>
</section>
{:else}<p class="column-empty">{de ? "Dieses Board hat keine Spalten." : "This board has no columns."}</p>{/each}
</div>
{#if selected}
<aside class="issue-inspector board-inspector" aria-label={de ? "Kartendetails" : "Card details"}>
<div class="inspector-top"><span>{selected.repository} <span class="issue-number">{selected.number ? `#${selected.number}` : ""}</span></span><button class="workspace-button close-button" onclick={() => { selected = null; }} aria-label={de ? "Kartendetails schließen" : "Close card details"}><X size={16} /></button></div>
<h2>{selected.title}</h2>
{#if canMove}
{@const currentColumn = board.columns.find(column => column.id === selectedColumnId && column.cards.some(card => card.id === selected?.id))}
{#if currentColumn?.moveTarget}
<div class="card-move-control"><small>{de ? "In Spalte verschieben" : "Move to column"}</small><SelectMenu value={currentColumn.id} options={board.columns.filter(column => column.moveTarget).map(column => ({ value: column.id, label: column.title }))} ariaLabel={de ? "Karte verschieben" : "Move card"} onChange={value => { if (selected && currentColumn) void moveCard(selected.id, currentColumn.id, value); }} /></div>
{/if}
{/if}
<div class="inspector-properties"><IssueAssignees names={selected.assignees} />{#if selected.lane}<span class="lane">{selected.lane}</span>{/if}</div>
{#if selected.labels.length}<div class="inspector-labels"><small>Labels</small><IssueLabels labels={selected.labels} /></div>{/if}
<div class="inspector-description"><h3>{de ? "Beschreibung" : "Description"}</h3><div class="description">{selected.description || (de ? "Weitere Details im Original-Board." : "More details in the original board.")}</div></div>
{#if selected.webUrl}<button class="workspace-button inspector-open" onclick={() => openOriginal(selected!.webUrl)}><ExternalLink size={14} />{de ? "Karte öffnen" : "Open card"}</button>{/if}
</aside>
{/if}
</div>
{:else}
<div class="workspace-empty"><span class="empty-symbol"><Columns3 size={26} /></span><h2>{loading ? (de ? "Board wird im Hintergrund geladen" : "Loading board in background") : (de ? "Dein vorhandenes Board übernehmen" : "Load your existing board")}</h2><p>{de ? "Wähle oben ein gefundenes Board aus oder füge einen Board-Link ein. Spalten und Karten werden aus der Integration gelesen; der Link wird für diese Integration gespeichert." : "Select a discovered board above or paste a board link. Columns and cards come from the integration; the link is saved for this integration."}</p>
{#if source.provider === "gitea"}<p>{de ? "Gitea benötigt eine Serverversion mit Projects-/Columns-API." : "Gitea requires a server version with the Projects/Columns API."}</p>{/if}
</div>
{/if}
</section>
+8
View File
@@ -0,0 +1,8 @@
<script lang="ts">
export let names: string[] = [];
export let emptyLabel = "—";
$: initials = names[0]?.trim().split(/[\s._-]+/).filter(Boolean).slice(0, 2).map(part => part[0]).join("").toLocaleUpperCase() ?? "";
</script>
<span class="issue-person" title={names.join(", ")}>
{#if names.length}<span class="issue-avatar" aria-hidden="true">{initials}</span><span class="person-name">{names[0]}{names.length > 1 ? ` +${names.length - 1}` : ""}</span>{:else}<span class="unassigned">{emptyLabel}</span>{/if}
</span>
+259
View File
@@ -0,0 +1,259 @@
<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>
+83
View File
@@ -0,0 +1,83 @@
<script context="module" lang="ts">
const drafts = new Map<string, string>();
</script>
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import CommentEditor from "./CommentEditor.svelte";
import { RefreshCw } from "@lucide/svelte";
import IssueAssignees from "./IssueAssignees.svelte";
import { listIntegrationIssueComments, addIntegrationIssueComment } from "../git";
import { integrationCredentialKey } from "../integrations";
import type { GitIntegrationSource, IntegrationIssue, IssueComment, StoredCredential } from "../types";
export let source: GitIntegrationSource;
export let issue: IntegrationIssue;
export let language: "de" | "en";
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
const key = JSON.stringify([source.id, source.baseUrl, issue.id]);
let draft = drafts.get(key) ?? "";
let comments: IssueComment[] = [];
let cursor: string | null = null;
let loading = false;
let posting = false;
let error = "";
let notice = "";
let destroyed = false;
$: de = language === "de";
$: drafts.set(key, draft);
onMount(() => { void load(); });
onDestroy(() => { destroyed = true; });
async function credential() {
const saved = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
if (!saved?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
return saved;
}
async function load(more = false) {
if (loading || posting) return;
loading = true; error = "";
try {
const saved = await credential();
const page = await listIntegrationIssueComments(source.provider, source.baseUrl, saved.username, saved.password, issue.repositoryName, issue.number, more ? cursor : null);
if (destroyed) return;
comments = [...new Map([...(more ? comments : []), ...page.comments].map(comment => [comment.id, comment])).values()];
cursor = page.nextCursor;
} catch (cause) { if (!destroyed) error = String(cause); }
finally { if (!destroyed) loading = false; }
}
async function submit() {
if (!draft.trim() || posting || loading) return;
const body = draft;
posting = true; error = ""; notice = "";
try {
const saved = await credential();
const comment = await addIntegrationIssueComment(source.provider, source.baseUrl, saved.username, saved.password, issue.repositoryName, issue.number, body);
if (drafts.get(key) === body) drafts.delete(key);
if (destroyed) return;
comments = [...comments.filter(item => item.id !== comment.id), comment];
if (draft === body) draft = "";
notice = de ? "Kommentar gesendet." : "Comment posted.";
} catch (cause) {
if (!destroyed) error = `${de ? "Versand nicht bestätigt. Vor erneutem Senden bitte die Kommentare aktualisieren." : "Delivery not confirmed. Refresh comments before resending."} ${String(cause)}`;
} finally { if (!destroyed) posting = false; }
}
function text(comment: IssueComment) {
if (!comment.bodyHtml) return comment.body;
const document = new DOMParser().parseFromString(comment.body, "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() ?? "";
}
</script>
<section class="issue-comments" aria-label={de ? "Issue-Kommentare" : "Issue comments"}>
<header><h3>{de ? "Kommentare" : "Comments"}</h3><small>{comments.length}{cursor ? "+" : ""}</small><button class="workspace-button close-button" aria-label={de ? "Kommentare aktualisieren" : "Refresh comments"} disabled={loading || posting} onclick={() => load()}><RefreshCw size={14} /></button></header>
{#if error}<p class="comment-error" role="alert">{error}</p>{/if}
{#if loading}<small role="status">{de ? "Kommentare werden geladen …" : "Loading comments …"}</small>{/if}
<div class="issue-comment-list">
{#each comments as comment (comment.id)}
<article><header><IssueAssignees names={[comment.author || (de ? "Unbekannt" : "Unknown")]} />{#if comment.createdAt}<time datetime={comment.createdAt}>{new Date(comment.createdAt).toLocaleString(de ? "de-DE" : "en-US")}</time>{/if}</header><p>{text(comment)}</p></article>
{:else}{#if !loading && !error}<small>{de ? "Noch keine Kommentare." : "No comments yet."}</small>{/if}{/each}
</div>
{#if cursor}<button class="workspace-button" disabled={loading || posting} onclick={() => load(true)}>{de ? "Weitere Kommentare laden" : "Load more comments"}</button>{/if}
<CommentEditor bind:value={draft} {language} disabled={loading} busy={posting} onSend={submit} />
{#if notice}<small role="status">{notice}</small>{/if}
</section>
+5
View File
@@ -0,0 +1,5 @@
<script lang="ts">
import { labelTone } from "../issuePresentation";
export let labels: string[] = [];
</script>
<span class="issue-labels">{#each labels as label}<span class="issue-label" data-tone={labelTone(label)}>{label}</span>{/each}</span>
+13 -27
View File
@@ -1,12 +1,13 @@
<script lang="ts">
import { onMount } from "svelte";
import CommentEditor from "./CommentEditor.svelte";
import SelectMenu from "./SelectMenu.svelte";
import { cubicOut } from "svelte/easing";
import { fly } from "svelte/transition";
import {
AlertTriangle, AtSign, Bold, Check, ChevronDown, ChevronRight, Code2,
CircleCheck, Clock3, CircleDotDashed, ExternalLink, GitBranch, GitMerge, GitPullRequest, Inbox, Italic,
Link, List, LoaderCircle, PanelRightOpen, RefreshCw, RotateCcw, Search, Settings2, X, XCircle,
AlertTriangle, Check, ChevronDown, ChevronRight,
CircleCheck, Clock3, 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";
@@ -46,7 +47,6 @@
let actionNotice = $state("");
let detailLoadingId = $state("");
let commentDraft = $state("");
let commentTextarea = $state<HTMLTextAreaElement>();
let commentPosting = $state(false);
let collapsedRepositories = $state(new Set<string>());
let loadGeneration = 0;
@@ -235,20 +235,6 @@
collapsedRepositories = next;
}
function formatComment(prefix: string, suffix = prefix) {
const textarea = commentTextarea;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selection = commentDraft.slice(start, end);
commentDraft = `${commentDraft.slice(0, start)}${prefix}${selection}${suffix}${commentDraft.slice(end)}`;
requestAnimationFrame(() => {
textarea.focus();
const cursorStart = start + prefix.length;
textarea.setSelectionRange(cursorStart, cursorStart + selection.length);
});
}
function selectRequest(request: IntegrationReviewRequest, showDetail = false) {
if (selectedId !== request.id) commentDraft = "";
selectedId = request.id;
@@ -511,7 +497,7 @@
<section class="comments-section">
<header><h3>{de ? "Kommentare" : "Comments"}</h3><span>{selected.comments?.length ?? 0}</span></header>
{#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><div class="comment-card"><header><span class="avatar">{initials(comment.author)}</span><strong>{comment.author || (de ? "Unbekannt" : "Unknown")}</strong><time>{formatRelativeDate(comment.createdAt)}</time>{#if comment.author === selected.author}<span class="owner-badge" title={de ? "Autor dieses Requests" : "Author of this request"}>{de ? "Autor" : "Author"}</span>{/if}</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(); }}><textarea bind:this={commentTextarea} bind:value={commentDraft} maxlength="100000" rows="4" placeholder={de ? "Kommentar hinzufügen …" : "Add a comment …"}></textarea><footer><div class="format-actions"><button type="button" title={de ? "Fett" : "Bold"} onclick={() => formatComment("**")}><Bold size={14} /></button><button type="button" title={de ? "Kursiv" : "Italic"} onclick={() => formatComment("_")}><Italic size={14} /></button><button type="button" title="Code" onclick={() => formatComment("`", "`")}><Code2 size={14} /></button><button type="button" title="Link" onclick={() => formatComment("[", "](url)")}><Link size={14} /></button><button type="button" title={de ? "Liste" : "List"} onclick={() => formatComment("- ", "")}><List size={14} /></button><button type="button" title={de ? "Erwähnen" : "Mention"} onclick={() => formatComment("@", "")}><AtSign size={14} /></button></div><button class="submit-comment" type="submit" disabled={!commentDraft.trim() || commentPosting}>{#if commentPosting}<LoaderCircle class="spin" size={13} />{/if}{de ? "Kommentar senden" : "Add comment"}</button></footer></form>
<div class="review-comment-editor"><CommentEditor bind:value={commentDraft} language={de ? "de" : "en"} busy={commentPosting} onSend={submitComment} /></div>
</section>
</main>
<aside class="detail-sidebar">
@@ -542,12 +528,12 @@
@media(max-width:760px){.table-head,.request-row{grid-template-columns:72px minmax(260px,1fr) 190px}.table-head>span:nth-child(5),.repo-branch{display:none}.request-groups{min-width:600px}.merge-summary{align-items:flex-start;flex-wrap:wrap;padding:10px 0}}
.branch-loading{display:flex;align-items:center;gap:5px;color:var(--color-ink-faint);font-size:9px}
.provider-action{overflow:visible;border-radius:2px;box-shadow:0 1px 0 #0005}.provider-button{border-radius:2px 0 0 2px;font-weight:600}.action-toggle{border-radius:0 2px 2px 0;background:color-mix(in srgb,var(--color-accent) 14%,var(--color-surface))}.panel-button{margin-left:2px;border-radius:2px}.review-layout.inspector-open{grid-template-columns:minmax(580px,1fr) minmax(420px,44%)}.detail-header{background:linear-gradient(180deg,color-mix(in srgb,var(--color-surface-raised) 72%,var(--color-surface)),var(--color-surface))}
.comments-section{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.comments-section>header{display:flex;align-items:center;gap:7px;margin-bottom:10px}.comments-section h3{margin:0;font-size:11px}.comments-section>header>span{display:grid;min-width:18px;height:18px;place-items:center;border-radius:9px;color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px}.comments-loading,.no-comments{margin:8px 0;color:var(--color-ink-faint);font-size:10px}.comments-loading{display:flex;align-items:center;gap:6px}.comment-list{display:grid;gap:10px;margin-bottom:12px}.comment-list article{display:flex;align-items:flex-start;gap:9px}.comment-list article>div{min-width:0;flex:1;padding:9px 10px;border:1px solid var(--color-border-subtle);border-radius:2px;background:var(--app-bg)}.comment-list article header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}.comment-list article strong{font-size:10.5px}.comment-list article time{color:var(--color-ink-faint);font-size:9px}.comment-list article p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:10.5px;line-height:1.5}.comment-composer{display:flex;align-items:flex-start;gap:9px;margin-top:10px}.comment-composer textarea{box-sizing:border-box;width:100%;min-height:82px;resize:vertical;padding:9px 10px;border:1px solid var(--color-border-input);outline:0;color:var(--color-ink);background:var(--app-input-bg);font:inherit;line-height:1.45}.comment-composer textarea:focus{border-color:var(--color-accent)}.comment-composer button{display:inline-flex;height:29px;align-items:center;gap:6px;padding:0 10px;border:1px solid #4fa565;border-radius:2px;color:#63c783;background:color-mix(in srgb,#4fa565 12%,var(--color-surface))}.comment-composer button:hover:not(:disabled){color:#fff;background:#397849}
.comments-section{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.comments-section>header{display:flex;align-items:center;gap:7px;margin-bottom:10px}.comments-section h3{margin:0;font-size:11px}.comments-section>header>span{display:grid;min-width:18px;height:18px;place-items:center;border-radius:9px;color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px}.comments-loading,.no-comments{margin:8px 0;color:var(--color-ink-faint);font-size:10px}.comments-loading{display:flex;align-items:center;gap:6px}.comment-list{display:grid;gap:10px;margin-bottom:12px}.comment-list article{display:flex;align-items:flex-start;gap:9px}.comment-list article>div{min-width:0;flex:1;padding:9px 10px;border:1px solid var(--color-border-subtle);border-radius:2px;background:var(--app-bg)}.comment-list article header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}.comment-list article strong{font-size:10.5px}.comment-list article time{color:var(--color-ink-faint);font-size:9px}.comment-list article p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:10.5px;line-height:1.5}
@media(max-width:1100px){.review-layout.inspector-open{grid-template-columns:minmax(480px,1fr) 380px}}
@media(max-width:760px){.review-layout.inspector-open{display:block}.detail-panel{position:absolute;inset:90px 0 0 12%;z-index:5;box-shadow:-12px 0 30px #0008}}
.comment-list{max-height:230px;overflow-y:auto;overscroll-behavior:contain;padding-right:5px;scrollbar-gutter:stable}.comment-list::-webkit-scrollbar{width:7px}.comment-list::-webkit-scrollbar-thumb{border:2px solid transparent;border-radius:4px;background:var(--color-border-input);background-clip:padding-box}.comment-list::-webkit-scrollbar-track{background:transparent}
.detail-panel{display:flex;flex-direction:column;overflow:hidden}.detail-header{flex:0 0 auto}.detail-content{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.detail-summary,.merge-summary,.description,.detail-actions{flex:0 0 auto}.description p{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3}.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.comments-section>header,.comment-composer{flex:0 0 auto}.comment-list{min-height:0;max-height:none;flex:1;overflow-y:auto}.comment-composer textarea{min-height:68px;max-height:92px}.detail-actions{margin-bottom:12px}
.review-center{position:relative}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)}.detail-panel{position:absolute;z-index:20;inset:0;display:flex;width:100%;height:100%;flex-direction:column;overflow:hidden;border:0;background:var(--app-bg)}.detail-header{display:flex;min-height:48px;align-items:center;padding:0 20px;border-bottom:1px solid var(--color-border);background:var(--color-surface-raised)}.detail-provider{display:flex;align-items:center;gap:9px;color:var(--color-ink-muted)}.detail-provider>:global(svg){color:var(--color-accent)}.detail-provider strong{font-size:13px;font-weight:600}.detail-header-actions{display:flex;align-items:center;gap:3px;margin-left:auto}.detail-content{display:grid;min-height:0;flex:1;grid-template-columns:minmax(0,1fr) 300px;gap:28px;overflow:hidden;padding:0 28px}.detail-main{display:flex;min-width:0;min-height:0;flex-direction:column;overflow:hidden}.detail-title{flex:0 0 auto;padding:18px 0 0;border-bottom:1px solid var(--color-border-subtle)}.detail-title h2{margin:7px 0 10px;font-size:20px;font-weight:520;line-height:1.25}.detail-title .detail-summary{min-height:34px;border:0}.detail-title .detail-summary>span:last-child{color:var(--color-ink-muted);font-size:10.5px}.detail-main>.description{padding:13px 0}.detail-main>.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;padding:13px 0 18px}.detail-main .merge-summary{flex:0 0 auto;margin-bottom:11px;padding:0 12px;border:1px solid var(--color-border);background:var(--color-surface)}.detail-main .comment-list{min-height:0;flex:1}.detail-main .comment-composer{flex:0 0 auto}.detail-sidebar{display:flex;min-height:0;flex-direction:column;gap:0;overflow:hidden;padding:18px 0;border-left:1px solid var(--color-border-subtle);padding-left:22px;background:var(--color-surface)}.detail-sidebar .detail-actions{display:grid;grid-template-columns:1fr;gap:6px;margin:0 0 8px}.detail-sidebar section{padding:17px 0;border-bottom:1px solid var(--color-border-subtle)}.detail-sidebar section header{display:flex;align-items:center;justify-content:space-between;gap:12px}.detail-sidebar section h3{margin:0;color:var(--color-ink-muted);font-size:11px;font-weight:560}.people{display:grid;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:8px;margin-top:12px}.people.compact{grid-template-columns:24px}
.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,.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{flex:0 0 auto}.comment-list{min-height:0;max-height:none;flex:1;overflow-y:auto}.detail-actions{margin-bottom:12px}
.review-center{position:relative}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)}.detail-panel{position:absolute;z-index:20;inset:0;display:flex;width:100%;height:100%;flex-direction:column;overflow:hidden;border:0;background:var(--app-bg)}.detail-header{display:flex;min-height:48px;align-items:center;padding:0 20px;border-bottom:1px solid var(--color-border);background:var(--color-surface-raised)}.detail-provider{display:flex;align-items:center;gap:9px;color:var(--color-ink-muted)}.detail-provider>:global(svg){color:var(--color-accent)}.detail-provider strong{font-size:13px;font-weight:600}.detail-header-actions{display:flex;align-items:center;gap:3px;margin-left:auto}.detail-content{display:grid;min-height:0;flex:1;grid-template-columns:minmax(0,1fr) 300px;gap:28px;overflow:hidden;padding:0 28px}.detail-main{display:flex;min-width:0;min-height:0;flex-direction:column;overflow:hidden}.detail-title{flex:0 0 auto;padding:18px 0 0;border-bottom:1px solid var(--color-border-subtle)}.detail-title h2{margin:7px 0 10px;font-size:20px;font-weight:520;line-height:1.25}.detail-title .detail-summary{min-height:34px;border:0}.detail-title .detail-summary>span:last-child{color:var(--color-ink-muted);font-size:10.5px}.detail-main>.description{padding:13px 0}.detail-main>.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;padding:13px 0 18px}.detail-main .merge-summary{flex:0 0 auto;margin-bottom:11px;padding:0 12px;border:1px solid var(--color-border);background:var(--color-surface)}.detail-main .comment-list{min-height:0;flex:1}.detail-sidebar{display:flex;min-height:0;flex-direction:column;gap:0;overflow:hidden;padding:18px 0;border-left:1px solid var(--color-border-subtle);padding-left:22px;background:var(--color-surface)}.detail-sidebar .detail-actions{display:grid;grid-template-columns:1fr;gap:6px;margin:0 0 8px}.detail-sidebar section{padding:17px 0;border-bottom:1px solid var(--color-border-subtle)}.detail-sidebar section header{display:flex;align-items:center;justify-content:space-between;gap:12px}.detail-sidebar section h3{margin:0;color:var(--color-ink-muted);font-size:11px;font-weight:560}.people{display:grid;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:8px;margin-top:12px}.people.compact{grid-template-columns:24px}
@media(max-width:900px){.detail-content{grid-template-columns:minmax(0,1fr) 270px;gap:18px;padding:0 18px}.detail-sidebar{padding-left:16px}.detail-title h2{font-size:17px}}
@media(max-width:680px){.detail-content{display:block;overflow:hidden;padding:0 15px}.detail-main{height:100%}.detail-sidebar{display:none}.detail-panel{inset:0}}
.comment-list{align-content:start;grid-auto-rows:max-content}
@@ -567,7 +553,7 @@
.action-menu{top:36px;width:190px;padding:6px;border-color:color-mix(in srgb,var(--color-border) 86%,var(--color-accent));box-shadow:0 12px 30px #000b,0 0 0 1px #0005;background:color-mix(in srgb,var(--color-surface-raised) 92%,#10151a)}.action-menu:before{position:absolute;top:-5px;right:10px;width:8px;height:8px;transform:rotate(45deg);border-top:1px solid var(--color-border);border-left:1px solid var(--color-border);background:inherit;content:""}.action-menu button{position:relative;height:34px;gap:10px;padding:0 10px;border-left:2px solid transparent;color:var(--color-ink-muted);font-size:10.5px}.action-menu button:hover:not(:disabled){color:var(--color-ink);border-left-color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 8%,var(--color-surface-hover))}.action-menu button>:global(svg){flex:0 0 auto}.action-menu .menu-merge>:global(svg){color:#72d58f}.action-menu .menu-approve>:global(svg),.action-menu .menu-provider>:global(svg){color:var(--color-accent)}.action-menu button.danger{margin-top:4px;border-top:1px solid var(--color-border-subtle);color:#e7767e}.action-menu button.danger:hover{border-left-color:#e7767e;background:color-mix(in srgb,#e7767e 8%,var(--color-surface-hover))}.action-menu .menu-conflict{color:#e2ac58}.action-menu button:disabled{opacity:.48}
.detail-panel{width:50%;min-width:720px;max-width:960px;box-shadow:-14px 0 30px #0008}.detail-header{min-height:47px;padding:0 16px}.detail-provider strong{font-size:12px}.detail-content{grid-template-columns:minmax(0,1fr) 250px;gap:20px;padding:0 16px}.detail-title{padding:15px 0 10px}.detail-title h2{margin:5px 0 8px;font-size:18px;font-weight:620}.detail-summary{min-height:27px;border:0}.detail-summary .avatar{margin-left:3px}
.detail-main>.merge-summary{min-height:43px;margin:11px 0 0;padding:0 10px;border:1px solid color-mix(in srgb,#63c783 48%,var(--color-border));background:color-mix(in srgb,#63c783 6%,var(--color-surface))}.detail-main>.merge-summary.conflict{border-color:color-mix(in srgb,#d6a64f 55%,var(--color-border));color:#d6a64f;background:color-mix(in srgb,#d6a64f 7%,var(--color-surface))}.merge-spacer{flex:1}.detail-change-stats{display:flex;align-items:center;gap:7px;white-space:nowrap;font-size:9px}.detail-change-stats b{color:#63c783}.detail-change-stats em{color:#e0737b;font-style:normal}.detail-change-stats span{color:var(--color-ink-faint)}
.detail-main>.description{padding:12px 0}.detail-main>.comments-section{padding:12px 0 14px}.comment-list{gap:8px}.comment-list article{gap:8px}.comment-list article>div{padding:8px 9px;border-radius:0;background:color-mix(in srgb,var(--color-surface) 48%,var(--app-bg))}.comment-composer{margin-top:9px;padding-top:9px;border-top:1px solid var(--color-border-subtle)}.comment-composer textarea{min-height:72px;max-height:92px}.comment-composer button{color:var(--color-accent);border-color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 6%,var(--color-surface))}
.detail-main>.description{padding:12px 0}.detail-main>.comments-section{padding:12px 0 14px}.comment-list{gap:8px}.comment-list article{gap:8px}.comment-list article>div{padding:8px 9px;border-radius:0;background:color-mix(in srgb,var(--color-surface) 48%,var(--app-bg))}
.detail-sidebar{padding:15px 0 15px 16px;background:color-mix(in srgb,var(--color-surface) 58%,var(--app-bg))}.detail-sidebar .detail-actions{gap:6px;margin-bottom:9px}.detail-action{height:31px}.detail-sidebar section{padding:14px 0}.people{grid-template-columns:23px minmax(0,1fr);gap:8px;margin-top:10px}.people strong,.detail-meta strong,.detail-meta span{overflow:hidden;color:var(--color-ink-muted);font-size:10px;font-weight:500;text-overflow:ellipsis}.detail-meta{display:grid;gap:8px}.detail-actions>.danger-action:first-child{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 8%,var(--color-surface))}
.local-resolution{margin:8px 0 0;border-color:color-mix(in srgb,#d6a64f 55%,var(--color-border));background:color-mix(in srgb,#d6a64f 7%,var(--color-surface))}.local-resolution>div>:global(svg){color:#d6a64f}.local-resolution button{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 10%,var(--color-surface))}
@media(max-width:1280px){.review-toolbar{grid-template-columns:auto minmax(180px,1fr) 155px auto}.state-tabs button{min-width:52px;padding:0 6px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 110px minmax(190px,1fr) 180px}.table-head>span:nth-child(4),.collaborators{display:none}.request-groups{min-width:900px}.detail-panel{width:58%;min-width:680px}}
@@ -580,7 +566,6 @@
.table-head,.request-row{grid-template-columns:90px minmax(255px,1.65fr) 105px 120px minmax(180px,1fr) 150px}.table-head{min-height:37px;padding:0 16px;background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg));font-size:8.5px}.request-groups{min-width:900px}.group-header{height:45px;padding:0 17px;background:color-mix(in srgb,var(--app-bg) 72%,var(--color-surface))}.group-header strong{font-size:11.5px}.request-row{min-height:78px;padding:0 16px}.request-row.selected{background:linear-gradient(90deg,color-mix(in srgb,var(--color-accent) 7%,var(--color-surface-raised)),color-mix(in srgb,var(--color-surface-raised) 72%,var(--app-bg)))}.request-title{gap:8px}.request-title strong{font-size:12px}.request-status{font-size:10.5px}.request-author i,.avatar,.collaborators i{width:28px;height:28px;background:color-mix(in srgb,var(--color-accent) 58%,#26333a)}.provider-button,.action-toggle,.panel-button{height:34px}.provider-button{min-width:102px}.action-toggle{width:34px}.panel-button{width:34px}.action-menu{top:38px;width:154px}.action-menu button{height:36px}
.detail-panel{width:44%;min-width:680px;max-width:none;background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface))}.detail-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.detail-provider strong{font-size:13px}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:18px;padding:0 0 0 24px}.detail-main{padding-right:0}.detail-title{padding:18px 0 16px}.detail-title-line{display:flex;min-width:0;align-items:baseline;gap:10px}.detail-title-line>span{flex:0 0 auto;color:var(--color-accent);font-size:17px;font-weight:700}.detail-title h2{min-width:0;margin:0;overflow:hidden;font-size:19px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.detail-title .detail-summary{min-height:33px;gap:7px}.detail-summary .avatar{width:23px;height:23px;margin-left:5px}.detail-summary strong{font-size:10.5px}.summary-separator{color:var(--color-ink-faint)}.detail-branch-route{display:flex;min-width:0;align-items:center;gap:6px;color:var(--color-ink-muted)}.detail-branch-route>:global(svg){color:var(--color-ink-muted)}.detail-branch-route code{max-width:95px;overflow:hidden;color:var(--color-ink-muted);font-size:10.5px;text-overflow:ellipsis;white-space:nowrap}.detail-branch-route>span{color:var(--color-ink-faint)}.state-badge{padding:0;border:0;font-size:10.5px}
.detail-main>.merge-summary{min-height:55px;margin:0 0 4px;padding:0 12px;border-color:color-mix(in srgb,#63c783 65%,var(--color-border));background:color-mix(in srgb,#63c783 5%,var(--app-bg))}.detail-main>.description{padding:14px 0 18px}.description h3,.comments-section h3{font-size:11.5px}.description p{font-size:10.5px}.detail-main>.comments-section{padding:13px 0 0}.comments-section>header{min-height:28px;margin:0 0 8px}.comments-section>header>span{border-radius:1px}.comment-list{gap:10px;padding-right:0}.comment-list article{display:block}.comment-card{padding:0!important;border:1px solid var(--color-border)!important;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))!important}.comment-card header{min-height:42px;margin:0!important;padding:0 10px;border-bottom:0}.comment-card header .avatar{width:25px;height:25px;margin-right:2px}.comment-card header strong{font-size:10.5px}.comment-card header time{margin-left:auto}.owner-badge{padding:3px 6px;border:1px solid var(--color-border);color:var(--color-ink-faint);font-size:8.5px}.comment-card p{padding:0 44px 13px!important;color:var(--color-ink)!important}
.comment-composer{display:grid;gap:0;margin:0;padding:14px 0 18px;border-top:1px solid var(--color-border-subtle)}.comment-composer textarea{min-height:86px;max-height:120px;resize:none;padding:11px 12px;border-bottom:0;background:color-mix(in srgb,var(--app-input-bg) 94%,#11171c)}.comment-composer footer{display:flex;min-height:39px;align-items:center;justify-content:space-between;border:1px solid var(--color-border-input);border-top:0;background:color-mix(in srgb,var(--app-bg) 85%,var(--color-surface))}.format-actions{display:flex;align-items:center;padding-left:5px}.comment-composer .format-actions button{display:grid;width:30px;height:30px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.comment-composer .format-actions button:hover{color:var(--color-ink);background:var(--color-surface-hover)}.comment-composer .submit-comment{height:29px;margin-right:6px;padding:0 11px;border:1px solid var(--color-accent);border-radius:0;color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 5%,var(--app-bg))}.comment-composer .submit-comment:hover:not(:disabled){color:#fff;background:color-mix(in srgb,var(--color-accent) 72%,var(--app-bg))}
.detail-sidebar{padding:28px 17px 16px;border-left-color:var(--color-border);background:color-mix(in srgb,var(--color-surface) 46%,var(--app-bg))}.detail-sidebar .detail-actions{gap:10px;margin:0 0 12px}.detail-action{height:39px;font-size:11px}.detail-sidebar section{padding:17px 0}.detail-sidebar section h3{font-size:11.5px}.people{margin-top:12px}.detail-meta{gap:11px}
@media(max-width:1280px){.review-toolbar{grid-template-columns:310px minmax(180px,1fr) 145px 34px}.state-tabs{gap:3px}.state-tabs button{min-width:64px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 105px minmax(190px,1fr) 150px}.request-groups{min-width:840px}.detail-panel{width:50%;min-width:650px}}
@media(max-width:900px){.review-toolbar{grid-template-columns:1fr 150px 34px;grid-template-rows:40px 40px}.detail-panel{width:72%;min-width:600px}.detail-content{grid-template-columns:minmax(0,1fr) 205px;gap:14px;padding-left:16px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 150px}.request-groups{min-width:720px}}
@@ -593,10 +578,10 @@
.review-heading h1,.detail-provider strong{font-size:14px}.detail-provider>:global(svg){display:none}.detail-header{gap:4px}.detail-header-actions{display:contents}.detail-header-actions button:first-child{margin-right:auto}.detail-provider{flex:none}
.review-toolbar{height:60px;min-height:60px}.review-search input{font-size:13px}.review-center .state-tabs button{font-size:13px}.state-tabs span{font-size:11px}.table-pane{padding:8px 7px 0;box-sizing:border-box}.table-head{padding:0 16px;height:37px}.table-head,.request-row{grid-template-columns:100px minmax(210px,1fr) 106px 120px 165px 145px}.table-head>span{font-size:10px}.request-groups{min-width:900px}.group-header{height:44px;padding:0 12px}.group-header strong{font-size:13px}.request-row{padding:0 10px;min-height:78px}.request-row.selected{outline-color:color-mix(in srgb,var(--color-accent) 50%,var(--color-border));background:color-mix(in srgb,var(--app-bg) 93%,var(--color-accent));box-shadow:none}.request-row.selected:before{width:2px;box-shadow:none}.request-status{font-size:13px;font-weight:400}.request-status>span{font-weight:400}.request-status small{font-size:11px}.request-title strong{font-size:13px;font-weight:650}.request-title code{font-size:12px}.change-stats{font-size:12px}.request-author{font-size:12px}.repo-branch>strong{font-size:12px;font-weight:500}.branch-route code{padding:0;background:none;font-size:11px}.branch-route{gap:6px}
.review-center .provider-button{min-width:68px;padding:0 8px;font-size:13px;font-weight:500}.provider-button.merge-primary>:global(svg){display:none}.provider-action .action-toggle{width:32px;background:transparent}.row-actions{gap:7px}.panel-button{margin:0;width:32px}.action-menu{width:154px;padding:4px;background:color-mix(in srgb,var(--app-bg) 90%,var(--color-surface-raised));border:1px solid var(--color-border);box-shadow:0 6px 12px #0005}.action-menu button{font-size:13px;gap:10px;height:37px;padding:0 10px}.action-menu button.danger{margin:0;border-top:0;color:var(--color-ink)}.action-menu button.danger>:global(svg){color:#e7767e}.action-menu .menu-merge>:global(svg),.action-menu .menu-approve>:global(svg),.action-menu .menu-provider>:global(svg){color:var(--color-ink-muted)}
.detail-panel{min-width:0;width:var(--inspector-width,44%);box-shadow:-2px 0 8px #0002}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:0;padding:0}.detail-main{padding:0 18px 0 24px}.detail-sidebar{padding:28px 17px 16px;background:transparent;border-left:1px solid var(--review-divider)}.detail-title{padding:17px 0 9px;border:0}.detail-title h2{font-size:21px;font-weight:650}.detail-title-line>span{font-size:20px;color:var(--color-ink-muted)}.detail-title .detail-summary{min-height:34px;gap:7px}.detail-summary strong{font-size:12px;font-weight:400}.state-badge{font-size:13px;font-weight:400}.detail-branch-route code{font-size:13px}.detail-main>.merge-summary{min-height:54px;margin:0;padding:0 11px;gap:10px}.merge-summary>span,.merge-summary>strong{font-size:12px}.detail-change-stats{font-size:12px;gap:9px}.merge-summary>:global(svg){flex:none}.detail-main>.description{padding:20px 0 22px}.description h3{font-size:14px;font-weight:600;margin-bottom:10px}.description p{font-size:13px}.detail-main>.comments-section{padding:12px 0 0;overflow:visible;border:0}.comments-section>header{margin-bottom:10px}.comments-section h3{font-size:14px;font-weight:600}.comments-section>header>span{font-size:10px}.comment-list{gap:10px;margin:0;scrollbar-gutter:auto}.comment-list article .comment-card{min-height:78px;border-color:var(--review-divider)!important;background:transparent!important}.comment-list article header{justify-content:flex-start;gap:5px;padding:0 11px}.comment-card header .avatar{margin-right:5px;width:28px;height:28px}.comment-list article strong{font-size:12px;font-weight:500}.comment-list article time{font-size:11px;margin:0}.owner-badge{margin-left:auto;font-size:10px}.comment-list article p{font-size:12px;padding:2px 14px 15px 56px!important}.comment-composer{margin-top:auto;padding:16px 0 26px}.comment-composer textarea{font-size:13px;min-height:86px;height:86px;max-height:86px;border-bottom:1px solid var(--color-border-input);background:transparent}.comment-composer footer{margin-top:8px;border:0;background:transparent;min-height:39px}.comment-composer .submit-comment{height:37px;margin:0;font-size:13px}.format-actions{gap:4px;padding:0}.comment-composer .format-actions button{width:27px}.detail-sidebar .detail-actions{gap:15px;margin-bottom:27px}.review-center .detail-action{font-size:13px;position:relative}.detail-action>:global(svg){position:absolute;left:12px;width:18px;height:18px}.detail-action.primary-action{background:color-mix(in srgb,#63c783 4%,transparent)}.detail-sidebar section{padding:16px 0;border-top:1px solid var(--review-divider);border-bottom:0}.detail-sidebar section h3{font-size:14px;font-weight:600}.people.compact{display:flex;gap:10px}.people strong,.detail-meta strong,.detail-meta span{font-size:12px}.detail-meta strong,.detail-meta span{display:flex;align-items:center;gap:10px}.detail-meta :global(svg){flex:none;color:var(--color-ink-muted)}
@media(max-width:1450px){.review-center.has-inspector{--inspector-width:52%}.detail-content{grid-template-columns:minmax(0,1fr) 190px}.detail-main{padding:0 14px}.detail-title h2{font-size:17px}.detail-title-line>span{font-size:17px}.detail-summary{flex-wrap:wrap}.detail-main>.merge-summary{flex-wrap:wrap;gap:7px;padding:9px}.detail-change-stats{font-size:10px}.detail-main>.merge-summary>span{font-size:11px}.detail-sidebar{padding:24px 12px}.format-actions{gap:0}.comment-composer .format-actions button{width:23px}}
.detail-panel{min-width:0;width:var(--inspector-width,44%);box-shadow:-2px 0 8px #0002}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:0;padding:0}.detail-main{padding:0 18px 0 24px}.detail-sidebar{padding:28px 17px 16px;background:transparent;border-left:1px solid var(--review-divider)}.detail-title{padding:17px 0 9px;border:0}.detail-title h2{font-size:21px;font-weight:650}.detail-title-line>span{font-size:20px;color:var(--color-ink-muted)}.detail-title .detail-summary{min-height:34px;gap:7px}.detail-summary strong{font-size:12px;font-weight:400}.state-badge{font-size:13px;font-weight:400}.detail-branch-route code{font-size:13px}.detail-main>.merge-summary{min-height:54px;margin:0;padding:0 11px;gap:10px}.merge-summary>span,.merge-summary>strong{font-size:12px}.detail-change-stats{font-size:12px;gap:9px}.merge-summary>:global(svg){flex:none}.detail-main>.description{padding:20px 0 22px}.description h3{font-size:14px;font-weight:600;margin-bottom:10px}.description p{font-size:13px}.detail-main>.comments-section{padding:12px 0 0;overflow:visible;border:0}.comments-section>header{margin-bottom:10px}.comments-section h3{font-size:14px;font-weight:600}.comments-section>header>span{font-size:10px}.comment-list{gap:10px;margin:0;scrollbar-gutter:auto}.comment-list article .comment-card{min-height:78px;border-color:var(--review-divider)!important;background:transparent!important}.comment-list article header{justify-content:flex-start;gap:5px;padding:0 11px}.comment-card header .avatar{margin-right:5px;width:28px;height:28px}.comment-list article strong{font-size:12px;font-weight:500}.comment-list article time{font-size:11px;margin:0}.owner-badge{margin-left:auto;font-size:10px}.comment-list article p{font-size:12px;padding:2px 14px 15px 56px!important}.detail-sidebar .detail-actions{gap:15px;margin-bottom:27px}.review-center .detail-action{font-size:13px;position:relative}.detail-action>:global(svg){position:absolute;left:12px;width:18px;height:18px}.detail-action.primary-action{background:color-mix(in srgb,#63c783 4%,transparent)}.detail-sidebar section{padding:16px 0;border-top:1px solid var(--review-divider);border-bottom:0}.detail-sidebar section h3{font-size:14px;font-weight:600}.people.compact{display:flex;gap:10px}.people strong,.detail-meta strong,.detail-meta span{font-size:12px}.detail-meta strong,.detail-meta span{display:flex;align-items:center;gap:10px}.detail-meta :global(svg){flex:none;color:var(--color-ink-muted)}
@media(max-width:1450px){.review-center.has-inspector{--inspector-width:52%}.detail-content{grid-template-columns:minmax(0,1fr) 190px}.detail-main{padding:0 14px}.detail-title h2{font-size:17px}.detail-title-line>span{font-size:17px}.detail-summary{flex-wrap:wrap}.detail-main>.merge-summary{flex-wrap:wrap;gap:7px;padding:9px}.detail-change-stats{font-size:10px}.detail-main>.merge-summary>span{font-size:11px}.detail-sidebar{padding:24px 12px}}
@media(max-width:900px){.review-center.has-inspector{--inspector-width:100%}.detail-content{display:grid;grid-template-columns:minmax(0,1fr) 200px}.detail-sidebar{display:flex}.detail-main{height:auto}.detail-panel{inset:0 0 0 auto;min-width:0}.group-actions .icon-button{display:flex!important}}
@media(max-width:520px){.detail-content{display:flex;flex-direction:column}.detail-main{flex:1;min-height:0}.detail-sidebar{flex:none;padding:8px 14px;border-top:1px solid var(--review-divider);border-left:0}.detail-sidebar section{display:none}.detail-sidebar .detail-actions{grid-template-columns:1fr 1fr;gap:6px;margin:0}.detail-action{height:32px}.detail-header{padding:0 14px}.detail-main>.description{padding:10px 0}.comment-composer{padding-bottom:8px}.detail-title h2{white-space:normal}}
@media(max-width:520px){.detail-content{display:flex;flex-direction:column}.detail-main{flex:1;min-height:0}.detail-sidebar{flex:none;padding:8px 14px;border-top:1px solid var(--review-divider);border-left:0}.detail-sidebar section{display:none}.detail-sidebar .detail-actions{grid-template-columns:1fr 1fr;gap:6px;margin:0}.detail-action{height:32px}.detail-header{padding:0 14px}.detail-main>.description{padding:10px 0}.detail-title h2{white-space:normal}}
/* The inspector overlays an unchanged background; only viewport size reflows it. */
@@ -628,4 +613,5 @@
.review-center .action-menu button.danger>:global(svg){color:#e7767e}
.review-center .action-menu button.menu-conflict{color:#e2ac58}
.review-center .action-menu button:hover:not(:disabled){background:var(--color-surface-hover)}
.review-comment-editor{flex:0 0 auto;min-width:0;padding:14px 0 18px;border-top:1px solid var(--color-border-subtle)}
</style>
+27
View File
@@ -675,3 +675,30 @@ export function resolveConflictSide(
): Promise<GitStatus> {
return invoke<GitStatus>("resolve_conflict_side", { path, file, side });
}
export function listIntegrationIssues(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, cursor: string | null = null): Promise<import("./types").IntegrationIssuePage> {
return invoke("list_integration_issues", { provider, baseUrl, username, token, cursor });
}
export function getIntegrationBoard(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, boardUrl: string): Promise<import("./types").IntegrationBoard> {
return invoke("get_integration_board", { provider, baseUrl, username, token, boardUrl });
}
export function listIntegrationBoards(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string): Promise<{ boards: import("./types").IntegrationBoardReference[]; warnings: string[] }> {
return invoke("list_integration_boards", { provider, baseUrl, username, token });
}
export function moveIntegrationBoardCard(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, boardUrl: string, cardId: string, sourceColumnId: string, targetColumnId: string): Promise<void> {
return invoke("move_integration_board_card", { provider, baseUrl, username, token, boardUrl, cardId, sourceColumnId, targetColumnId });
}
export function listIntegrationIssueComments(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number, cursor: string | null): Promise<{ comments: import("./types").IssueComment[]; nextCursor: string | null }> {
return invoke("list_integration_issue_comments", { provider, baseUrl, username, token, repository, number, cursor });
}
export function addIntegrationIssueComment(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number, body: string): Promise<import("./types").IssueComment> {
return invoke("add_integration_issue_comment", { provider, baseUrl, username, token, repository, number, body });
}
export function closeIntegrationIssue(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, repository: string, number: number): Promise<string> {
return invoke("close_integration_issue", { provider, baseUrl, username, token, repository, number });
}
+20
View File
@@ -0,0 +1,20 @@
/** Visual tones only; provider status values and board membership stay untouched. */
export function issueTone(value: string): "neutral" | "active" | "review" | "done" {
const name = value.toLocaleLowerCase();
if (/^(closed|done|completed|resolved|erledigt|geschlossen|shipped)$/.test(name)) return "done";
if (/review|testing|test|\bqa\b|prüfung/.test(name)) return "review";
if (/progress|development|entwicklung|active|doing|build|bearbeitung/.test(name)) return "active";
return "neutral";
}
export function issueStateLabel(value: string, de: boolean): string {
if (!de) return value;
return ({ open: "Offen", opened: "Offen", closed: "Geschlossen" } as Record<string, string>)[value] ?? value;
}
export function labelTone(label: string): string {
const name = label.toLocaleLowerCase();
if (/bug|error|fehler/.test(name)) return "red";
if (/enhancement|feature|verbesserung/.test(name)) return "blue";
return "neutral";
}
+253
View File
@@ -0,0 +1,253 @@
/* Shared visual system for the issue list, native boards and their inspectors. */
.issue-center {
--issue-muted: var(--color-ink-dim);
--issue-canvas: var(--color-surface-solid);
--issue-line: var(--color-border-subtle);
display:flex;flex:1;min-height:0;min-width:0;flex-direction:column;overflow:hidden;
background:var(--issue-canvas);color:var(--color-ink);
font:400 13px/1.5 var(--font-sans);
-webkit-font-smoothing:antialiased;
}
.issue-center * {box-sizing:border-box}
.issue-center button,.issue-center input {font-family:inherit;font-size:12px;font-weight:400;line-height:1.4}
.issue-center button {cursor:pointer;transition:background 140ms,border-color 140ms,color 140ms}
.issue-center button:disabled {cursor:default;opacity:.5}
.issue-center button:focus-visible,.issue-center input:focus-visible {outline:2px solid var(--color-accent);outline-offset:2px}
.issue-center h1,.issue-center h2,.issue-center h3,.issue-center p {margin:0}
.issue-center h1 {font-size:20px;font-weight:650;line-height:1.3;letter-spacing:-.4px}
.issue-center h2 {font-size:18px;font-weight:600;line-height:1.45;letter-spacing:-.25px;overflow-wrap:anywhere}
.issue-center h3 {font-size:12px;font-weight:600;line-height:1.5}
.issue-center small {font-size:11px;line-height:1.5;color:var(--issue-muted)}
.issue-center .workspace-heading {display:flex;align-items:center;justify-content:space-between;gap:20px;padding:14px 24px 8px;flex-shrink:0}
.issue-center .workspace-title {display:flex;align-items:center;gap:13px;min-width:0}
.issue-center .workspace-title p {color:var(--issue-muted);font-size:12px;margin-top:4px}
.issue-center .workspace-symbol {display:grid;place-items:center;flex-shrink:0;width:34px;height:34px;color:var(--color-accent);border:1px solid color-mix(in srgb,var(--color-accent) 55%,transparent);border-radius:6px;background:color-mix(in srgb,var(--color-accent) 5%,transparent)}
.issue-center .workspace-button {display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:32px;padding:6px 10px;white-space:nowrap;border:1px solid var(--color-border);border-radius:4px;background:transparent;color:var(--color-ink-muted);box-shadow:none}
.issue-center .workspace-button:hover:not(:disabled) {background:var(--color-surface-hover);border-color:var(--color-border-input);color:var(--color-ink)}
.issue-center .workspace-button.active {color:var(--color-accent)}
.issue-center .workspace-button svg {flex-shrink:0}
.issue-center .icon-button {width:32px;min-width:32px;padding:0}
.issue-center .workspace-navigation {display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:40px;padding:0 24px;border-bottom:1px solid var(--issue-line);flex-shrink:0}
.issue-center .view-toggle {display:flex;gap:6px;align-self:stretch;margin-left:-12px}
.issue-center .view-toggle button {position:relative;min-height:40px;padding:0 14px;background:transparent;border:0;border-radius:0;color:var(--issue-muted);font-weight:550}
.issue-center .view-toggle button.active {color:var(--color-accent)}
.issue-center .view-toggle button.active:after {position:absolute;content:"";bottom:-1px;left:0;right:0;height:2px;background:var(--color-accent)}
.issue-center .view-toggle button:hover {color:var(--color-ink)}
.issue-center .source-select {width:190px;flex:0 1 190px}
.issue-center .source-select button,.issue-center .state-select button {border-radius:4px;font-size:12px;font-weight:400;min-height:32px;background:transparent}
.issue-center .workspace-search {display:flex;align-items:center;gap:9px;min-width:0;height:34px;padding:0 10px;border:1px solid var(--color-border);border-radius:4px;color:var(--issue-muted);background:color-mix(in srgb,var(--color-surface) 35%,transparent)}
.issue-center .workspace-search:focus-within {border-color:var(--color-accent)}
.issue-center .workspace-search svg {flex-shrink:0}
.issue-center .workspace-search input {min-width:0;width:100%;height:100%;border:0;border-radius:0;outline:0;background:transparent;color:var(--color-ink);padding:0}
.issue-center input::placeholder {color:var(--issue-muted);opacity:1}
.issue-center .workspace-notice {margin:0 24px 12px;padding:10px 12px;border:1px solid var(--issue-line);border-radius:4px;color:var(--color-ink-muted);font-size:12px;overflow-wrap:anywhere}
.issue-center .workspace-notice.error {border-color:color-mix(in srgb,#d6a555 45%,transparent);background:color-mix(in srgb,#d6a555 4%,transparent)}
.issue-center .workspace-notice p {margin-top:4px;color:var(--issue-muted)}
.issue-center .issue-content {display:flex;flex:1;min-height:0;min-width:0;position:relative;overflow:hidden}
.issue-center .issue-main {display:flex;flex:1;flex-direction:column;min-width:0;min-height:0}
.issue-center .issue-toolbar {display:flex;align-items:center;gap:10px;padding:12px 24px 10px;flex-shrink:0}
.issue-center .issue-toolbar .workspace-search {width:360px;max-width:55%;flex:0 1 360px}
.issue-center .state-select {flex:0 0 150px;width:150px}
.issue-center .issue-list {flex:0 1 auto;min-height:0;overflow:auto;padding:0 24px}
.issue-center .issue-table-heading,.issue-center .issue-row {display:grid;grid-template-columns:minmax(0,1fr) 145px 130px;gap:18px;align-items:center;text-align:left}
.issue-center .issue-table-heading {min-height:32px;padding:0 14px 0 16px;color:var(--issue-muted);font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:.65px;background:color-mix(in srgb,var(--color-surface) 35%,transparent);border-radius:4px 4px 0 0;border-bottom:1px solid var(--issue-line);position:sticky;top:0;z-index:1}
.issue-center .issue-row {position:relative;width:100%;min-height:56px;padding:8px 14px 8px 16px;border:0;border-bottom:1px solid var(--issue-line);border-radius:0;color:var(--color-ink);background:transparent;box-shadow:none}
.issue-center .issue-row:hover {background:color-mix(in srgb,var(--color-surface-hover) 55%,transparent)}
.issue-center .issue-row.selected {background:color-mix(in srgb,var(--color-accent) 9%,transparent);box-shadow:inset 2px 0 var(--color-accent)}
.issue-center .issue-identity {display:flex;min-width:0;gap:18px;align-items:center}
.issue-center .issue-number {font-family:var(--font-mono);color:var(--issue-muted);font-size:11px;font-weight:400;white-space:nowrap}
.issue-center .issue-identity>.issue-number {flex:0 0 44px}
.issue-center .issue-text {display:grid;grid-template-columns:minmax(0,320px) auto;gap:3px 12px;flex:1;max-width:465px;min-width:0;align-items:center}
.issue-center .issue-text strong {font-size:13px;line-height:1.45;font-weight:550;overflow-wrap:anywhere;grid-column:1}
.issue-center .issue-text>small {grid-column:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.issue-center .issue-text>.issue-labels {grid-column:2;grid-row:1 / 3;max-width:145px;justify-content:flex-end}
.issue-center .issue-status {display:flex;align-items:center;gap:9px;font-size:12px;color:var(--color-ink-muted);min-width:0;overflow-wrap:anywhere}
.issue-center .status-dot {display:block;width:13px;height:13px;border:2px solid var(--issue-muted);border-radius:50%;flex:0 0 13px}
.issue-center [data-tone="active"] {--status-color:#48cef0}
.issue-center [data-tone="review"] {--status-color:#ffb52e}
.issue-center [data-tone="done"] {--status-color:#6fe480}
.issue-center [data-tone="neutral"] {--status-color:var(--issue-muted)}
.issue-center .issue-status .status-dot {border-color:var(--status-color)}
.issue-center .issue-person {display:inline-flex;align-items:center;gap:8px;min-width:0;font-size:12px;font-weight:400;color:var(--color-ink-muted)}
.issue-center .issue-avatar {display:grid;place-items:center;width:23px;height:23px;flex:0 0 23px;border-radius:50%;border:1px solid color-mix(in srgb,var(--color-ink-muted) 20%,transparent);background:color-mix(in srgb,var(--color-ink-muted) 25%,var(--color-surface));font-size:10px;font-weight:550;color:var(--color-ink)}
.issue-center .person-name {min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.issue-center .unassigned {color:var(--color-ink-faint)}
.issue-center .issue-labels {display:flex;flex-wrap:wrap;gap:4px;min-width:0}
.issue-center .issue-label {padding:2px 6px;border-radius:4px;font-size:10px;line-height:1.5;letter-spacing:.1px;color:var(--color-ink-muted);background:var(--color-surface-dim);overflow-wrap:anywhere;font-weight:400}
.issue-center .issue-label[data-tone="red"] {background:#9e3d43;color:#fff0f1}
.issue-center .issue-label[data-tone="blue"] {background:#3e5699;color:#eef2ff}
:root[data-theme="light"] .issue-center .issue-label[data-tone="red"] {color:#972e3c;background:#fbe5e8}
:root[data-theme="light"] .issue-center .issue-label[data-tone="blue"] {color:#3753a4;background:#e8edfc}
.issue-center .issue-footer {display:flex;justify-content:space-between;gap:16px;padding:12px 24px;color:var(--issue-muted);font-size:11px;flex-shrink:0}
.issue-center .load-more {display:flex;margin:18px auto}
.issue-center .issue-inspector {flex:0 0 clamp(320px,26vw,420px);width:clamp(320px,26vw,420px);min-width:0;overflow:auto;padding:20px 22px 24px;border-left:1px solid var(--issue-line);background:color-mix(in srgb,var(--color-surface) 20%,var(--issue-canvas))}
.issue-center .inspector-top {display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;color:var(--issue-muted);font-size:11px;overflow-wrap:anywhere}
.issue-center .inspector-top .issue-number {margin-left:6px}
.issue-center .close-button {border:0;background:transparent;min-height:24px;width:24px;padding:0;flex-shrink:0}
.issue-center .issue-inspector h2 {font-size:20px;font-weight:600;line-height:1.4;letter-spacing:-.3px}
.issue-center .inspector-properties {display:flex;align-items:center;flex-wrap:wrap;gap:16px;margin:18px 0}
.issue-center .inspector-properties>.issue-person:not(:first-child) {padding-left:16px;border-left:1px solid var(--color-border)}
.issue-center .inspector-labels {display:flex;flex-direction:column;gap:5px;margin-bottom:20px}
.issue-center .inspector-description {border-top:1px solid var(--issue-line);padding-top:18px;margin-top:18px}
.issue-center .description {white-space:pre-wrap;overflow-wrap:anywhere;font-size:13px;line-height:1.75;color:var(--color-ink-muted);margin-top:8px}
.issue-center .inspector-open {margin-top:22px}
.issue-center .workspace-empty {display:flex;flex:1;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center;padding:56px 24px;color:var(--issue-muted);min-height:250px}
.issue-center .workspace-empty p {font-size:12px;max-width:440px;line-height:1.7}
.issue-center .workspace-empty h2 {color:var(--color-ink);font-size:16px}
.issue-center .empty-symbol {display:grid;place-items:center;width:52px;height:52px;margin-bottom:6px;border:1px solid var(--issue-line);border-radius:12px;color:var(--issue-muted)}
.issue-center .board-view {display:flex;flex:1;flex-direction:column;min-height:0;min-width:0;overflow:hidden}
.issue-center .board-header {display:flex;align-items:center;justify-content:space-between;gap:20px;padding:18px 24px 14px;flex-shrink:0}
.issue-center .board-title {min-width:0}
.issue-center .board-title h2 {font-size:16px;line-height:1.4;font-weight:600;letter-spacing:.05px}
.issue-center .board-title small {display:block;margin-top:4px}
.issue-center .board-title small>span {padding:0 5px}
.issue-center .board-tools {display:flex;align-items:center;gap:9px;flex-shrink:0}
.issue-center .board-tools .workspace-search {width:240px}
.issue-center .board-connect {padding:18px 24px;flex-shrink:0;border-bottom:1px solid var(--issue-line)}
.issue-center .board-connect label {display:block;font-size:11px;color:var(--issue-muted);margin-bottom:7px}
.issue-center .board-connect-fields {display:flex;gap:10px}
.issue-center .board-connect input {flex:1;min-width:100px;min-height:34px;padding:6px 10px;border:1px solid var(--color-border);border-radius:4px;background:transparent;color:var(--color-ink)}
.issue-center .board-notice {font-size:11px;line-height:1.6;padding:0 24px 12px;color:var(--issue-muted);flex-shrink:0}
.issue-center .board-body {position:relative;display:flex;flex:1;min-height:0;overflow:hidden}
.issue-center .board-columns {display:flex;flex:1;min-width:0;gap:14px;padding:0 24px 20px;overflow:auto;align-items:stretch}
.issue-center .board-column {display:flex;flex:1 0 278px;max-width:400px;min-width:0;flex-direction:column;border:0;border-radius:6px;background:color-mix(in srgb,var(--color-surface) 60%,var(--issue-canvas));overflow:hidden}
.issue-center .board-column>header {display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 12px 12px;flex-shrink:0}
.issue-center .board-column h3 {display:flex;align-items:center;gap:9px;min-width:0;font-size:12px;font-weight:600;overflow-wrap:anywhere}
.issue-center .column-dot {width:10px;height:10px;flex:0 0 10px;border-radius:50%;background:var(--status-color)}
.issue-center .column-count {color:var(--issue-muted);font-size:11px;white-space:nowrap}
.issue-center .wip-limit {font-size:10px}
.issue-center .column-cards {padding:0 10px 10px;min-height:0;overflow:auto;flex:1}
.issue-center .board-card {display:flex;flex-direction:column;align-items:flex-start;gap:7px;width:100%;text-align:left;padding:12px;margin-bottom:9px;border:1px solid var(--color-border);border-radius:6px;box-shadow:0 2px 3px #0000000a;background:var(--issue-canvas);color:var(--color-ink);overflow-wrap:anywhere}
.issue-center .board-card:hover {border-color:var(--color-border-input);background:color-mix(in srgb,var(--color-surface) 55%,var(--issue-canvas))}
.issue-center .board-card.chosen {border-color:color-mix(in srgb,var(--color-accent) 65%,var(--color-border));background:color-mix(in srgb,var(--color-accent) 5%,var(--issue-canvas))}
.issue-center .board-card strong {font-size:13px;line-height:1.5;font-weight:550}
.issue-center .card-reference {display:flex;align-items:center;flex-wrap:wrap;gap:8px;font-size:11px;line-height:1.4}
.issue-center .board-card .issue-person {margin-top:3px;font-size:11px}
.issue-center .board-card .issue-avatar {width:19px;height:19px;flex-basis:19px;font-size:9px}
.issue-center .lane {font-size:10px;line-height:1.5;color:var(--color-accent);font-weight:400}
.issue-center .column-empty {padding:28px 12px;text-align:center;color:var(--color-ink-faint);font-size:11px}
@media(min-width:1250px) {
.issue-center .issue-content:not(.with-details) .issue-table-heading,.issue-center .issue-content:not(.with-details) .issue-row {grid-template-columns:minmax(0,1fr) 180px 160px}
}
@media(max-width:1150px) {
.issue-center .issue-text {display:flex;flex-direction:column;align-items:flex-start;gap:3px}
.issue-center .issue-text>.issue-labels {justify-content:flex-start;max-width:none;margin-top:3px}
.issue-center .issue-table-heading,.issue-center .issue-row {grid-template-columns:minmax(0,1fr) 110px 100px;gap:12px}
.issue-center .issue-identity {gap:10px}
.issue-center .issue-inspector {flex-basis:310px;width:310px;padding:18px}
.issue-center .board-header {align-items:flex-start;flex-direction:column;gap:14px}
.issue-center .board-tools {width:100%}
.issue-center .board-tools .workspace-search {flex:1;width:auto}
}
@media(max-width:800px) {
.issue-center .workspace-heading {padding:18px 16px 10px;gap:12px}
.issue-center .workspace-title p {max-width:235px}
.issue-center .workspace-navigation {padding:0 16px}
.issue-center .workspace-symbol {display:none}
.issue-center .issue-toolbar {padding:14px 16px 10px;gap:8px;flex-wrap:wrap}
.issue-center .issue-toolbar .workspace-search {max-width:none;flex:1 1 190px;width:auto}
.issue-center .state-select {flex:0 0 130px;width:130px}
.issue-center .issue-list {padding:0 16px}
.issue-center .issue-footer {padding:12px 16px;font-size:10px}
.issue-center .issue-inspector {position:absolute;inset:0;width:100%;max-width:none;z-index:3;border:0;flex-basis:auto;background:var(--issue-canvas);padding:20px}
.issue-center .with-details .issue-main {visibility:hidden}
.issue-center .board-header,.issue-center .board-connect {padding:16px}
.issue-center .board-tools {flex-wrap:wrap;gap:8px}
.issue-center .board-tools .workspace-search {flex:1 1 100%;height:34px}
.issue-center .board-columns {padding:0 16px 16px;gap:12px}
.issue-center .board-column {flex:0 0 270px}
.issue-center .board-notice {padding:0 16px 12px}
.issue-center .workspace-notice {margin:0 16px 12px}
}
@media(max-width:520px) {
.issue-center .workspace-title p {font-size:11px;max-width:205px}
.issue-center .workspace-heading>.workspace-button {font-size:11px;padding:6px 8px}
.issue-center .workspace-heading>.workspace-button>svg {display:none}
.issue-center .source-select {flex-basis:155px;width:155px}
.issue-center .issue-table-heading,.issue-center .issue-row {grid-template-columns:minmax(0,1fr) 94px;gap:8px}
.issue-center .issue-table-heading>span:last-child,.issue-center .issue-row>.issue-person {display:none}
.issue-center .issue-identity>.issue-number {display:none}
.issue-center .issue-row {padding:12px 10px;min-height:80px}
.issue-center .issue-status {font-size:11px;gap:6px}
.issue-center .issue-toolbar .workspace-search {flex-basis:100%}
.issue-center .board-tools>.workspace-button {font-size:11px;padding:6px 8px}
.issue-center .board-connect-fields {flex-wrap:wrap}
.issue-center .board-connect input {flex-basis:100%}
}
@media(prefers-reduced-motion:reduce) {.issue-center button {transition:none}}
/* This workspace opts into its compact rounded controls. The app-wide square
reset uses !important, so the same priority is needed for these local shapes. */
.issue-center :is(.issue-avatar,.status-dot,.column-dot) {border-radius:50% !important}
.issue-center :is(.workspace-button,.workspace-search,.issue-label,.source-select button,.state-select button,.board-connect input) {border-radius:4px !important}
.issue-center :is(.workspace-symbol,.board-column,.board-card) {border-radius:6px !important}
.issue-center .empty-symbol {border-radius:12px !important}
.issue-center .repository-select {width:190px;flex:0 1 190px;min-width:130px}
.issue-center .repository-select button {font-size:12px;min-height:32px;border-radius:4px !important}
.issue-center .repository-group-heading {display:flex;align-items:center;gap:9px;padding:14px 16px 9px;color:var(--color-ink-muted);border-bottom:1px solid var(--issue-line)}
.issue-center .repository-group-heading strong {overflow-wrap:anywhere}
.issue-center .repository-group-heading>span {margin-left:auto;color:var(--issue-muted);font-size:11px}
.issue-center .issue-toolbar {flex-wrap:wrap}
.issue-center .board-tools {flex-wrap:wrap;justify-content:flex-end;flex-shrink:1}
:root[data-theme="light"] .issue-center [data-tone="active"] {--status-color:#0086a8}
:root[data-theme="light"] .issue-center [data-tone="review"] {--status-color:#ab6800}
:root[data-theme="light"] .issue-center [data-tone="done"] {--status-color:#24853c}
@media(max-width:800px) {.issue-center .board-tools {justify-content:flex-start}}
.issue-center .repository-group-heading {width:100%;text-align:left;background:transparent;border:0;border-bottom:1px solid var(--issue-line);border-radius:0;justify-content:flex-start}
.issue-center .repository-group-heading:hover {background:var(--color-surface-hover)}
.issue-center .repository-group-heading strong {font-size:12px;font-weight:600}
.issue-center .repository-group-heading svg {flex-shrink:0}
.issue-center .repository-chevron.expanded {transform:rotate(90deg)}
.issue-center .board-directory {display:flex;align-items:center;gap:10px;padding:12px 24px;flex-wrap:wrap;border-bottom:1px solid var(--issue-line)}
.issue-center .board-select {width:360px;max-width:100%;min-width:0}
.issue-center .board-discovery-notice {padding:8px 24px;color:var(--issue-muted);font-size:12px;max-height:160px;overflow:auto;flex-shrink:0}
.issue-center .board-discovery-notice summary {cursor:pointer}
.issue-center .board-discovery-notice p {margin-top:8px;overflow-wrap:anywhere}
.issue-center .board-column.drop-target {outline:2px solid var(--color-accent);outline-offset:-2px;background:color-mix(in srgb,var(--color-accent) 12%,var(--issue-canvas))}
.issue-center .board-card[draggable="true"] {cursor:grab}
.issue-center .board-card[draggable="true"]:active {cursor:grabbing}
.issue-center .card-move-control {display:flex;flex-direction:column;gap:6px;margin-top:16px}
/* Overlay drawer follows the PR Center's header, proportions and two-column detail layout. */
.issue-center {position:relative}
.issue-center .issue-detail-drawer {position:absolute;z-index:20;inset:0 0 0 auto;display:flex;flex-direction:column;width:44%;min-width:min(680px,100%);max-width:100%;height:100%;overflow:hidden;border-left:1px solid var(--color-border);background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface));box-shadow:-14px 0 30px #0007}
.issue-center .issue-detail-header {display:flex;align-items:center;justify-content:space-between;gap:14px;min-height:54px;flex-shrink:0;padding:0 24px;border-bottom:1px solid var(--color-border);background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}
.issue-center .issue-detail-header>div {display:flex;align-items:center;gap:9px;min-width:0}
.issue-center .issue-detail-header strong {font-size:13px;font-weight:600;overflow-wrap:anywhere}
.issue-center .issue-detail-header svg {color:var(--color-accent);flex-shrink:0}
.issue-center .issue-detail-header .issue-detail-header-actions {gap:3px;flex-shrink:0}
.issue-center .issue-detail-header-actions svg {color:var(--color-ink-muted)}
.issue-center .issue-detail-body {display:grid;grid-template-columns:minmax(0,1fr) 225px;gap:18px;padding-left:24px;min-height:0;flex:1;overflow:hidden}
.issue-center .issue-detail-main {min-width:0;overflow:auto}
.issue-center .issue-detail-title {padding:18px 0 16px;border-bottom:1px solid var(--issue-line)}
.issue-center .issue-detail-title>div:first-child {display:flex;align-items:baseline;gap:10px}
.issue-center .issue-detail-title>div:first-child>span {color:var(--color-accent);font-size:17px;font-weight:700;flex-shrink:0}
.issue-center .issue-detail-title h2 {font-size:19px;line-height:1.3;font-weight:700;min-width:0}
.issue-center .issue-detail-summary {display:flex;align-items:center;flex-wrap:wrap;gap:12px;min-height:33px;margin-top:8px}
.issue-center .issue-detail-summary .issue-status,.issue-center .issue-detail-summary .issue-person {font-size:10.5px}
.issue-center .issue-detail-description {padding:14px 0 18px}
.issue-center .issue-detail-description h3 {font-size:11.5px}
.issue-center .issue-detail-description .description {font-size:11px;line-height:1.6}
.issue-center .issue-detail-sidebar {min-width:0;overflow:auto;padding:28px 17px 16px;border-left:1px solid var(--color-border);background:color-mix(in srgb,var(--color-surface) 46%,var(--app-bg))}
.issue-center .issue-detail-sidebar .inspector-open {width:100%;margin:0 0 12px;min-height:39px;font-size:11px;color:var(--color-accent);border-color:var(--color-accent)}
.issue-center .issue-detail-sidebar section {display:flex;flex-direction:column;gap:11px;padding:17px 0;border-bottom:1px solid var(--issue-line)}
.issue-center .issue-detail-sidebar h3 {font-size:11.5px;color:var(--color-ink-muted)}
.issue-center .issue-detail-repo {display:flex;align-items:flex-start;gap:8px;font-size:11px;overflow-wrap:anywhere}
.issue-center .issue-detail-repo svg {flex-shrink:0}
@media(max-width:1280px) {.issue-center .issue-detail-drawer {width:50%;min-width:min(650px,100%)}}
@media(max-width:680px) {.issue-center .issue-detail-drawer {width:100%;min-width:0}.issue-center .issue-detail-body {display:block;overflow:auto;padding:0 15px}.issue-center .issue-detail-main {overflow:visible}.issue-center .issue-detail-sidebar {padding:0 0 18px;border-left:0;background:transparent;overflow:visible}}
.issue-center .issue-comments {padding:14px 0 18px;border-top:1px solid var(--issue-line)}
.issue-center .issue-comments>header {display:flex;align-items:center;gap:8px;margin-bottom:12px}
.issue-center .issue-comments>header button {margin-left:auto}
.issue-center .issue-comment-list {display:flex;flex-direction:column;gap:10px;margin:12px 0}
.issue-center .issue-comment-list article {border:1px solid var(--color-border);background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))}
.issue-center .issue-comment-list article header {display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px}
.issue-center .issue-comment-list time {font-size:9px;color:var(--issue-muted)}
.issue-center .issue-comment-list article p {white-space:pre-wrap;overflow-wrap:anywhere;padding:0 12px 12px;font-size:11px;line-height:1.6}
.issue-center .comment-error {color:#e0737b;font-size:11px;overflow-wrap:anywhere}
.issue-center .issue-close-action {width:100%;min-height:39px;margin-bottom:10px;color:#e0737b;border-color:#a74b55;font-size:11px}
+43
View File
@@ -448,3 +448,46 @@ export interface StoredCredential {
password: string;
mode?: "credentials" | "token";
}
export interface IntegrationIssue {
id: string;
number: number;
title: string;
description: string;
descriptionHtml: boolean;
repositoryName: string;
author: string;
state: string;
labels: string[];
assignees: string[];
webUrl: string;
updatedAt: string;
}
export interface IntegrationIssuePage {
issues: IntegrationIssue[];
nextCursor: string | null;
}
export interface IntegrationBoardCard {
id: string;
title: string;
number: number;
webUrl: string;
repository: string;
description: string;
labels: string[];
assignees: string[];
lane: string;
}
export interface IntegrationBoard {
title: string;
webUrl: string;
notice: string;
columns: Array<{ id: string; title: string; limit: number; moveTarget?: Record<string, unknown> | null; cards: IntegrationBoardCard[] }>;
}
export interface IntegrationBoardReference { title: string; webUrl: string; scope: string }
export interface IssueComment { id: string; author: string; body: string; createdAt: string; bodyHtml: boolean }