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