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
281 lines
21 KiB
Svelte
281 lines
21 KiB
Svelte
<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>
|