Rework CloneRepositoryDialog into a two-column layout with a source sidebar and focused content area to make browsing sources easier. Group Azure DevOps repositories by project with a new derived value and render sticky project headers for clearer navigation. Replace showIntegrations with selectIntegrationSource to load the chosen integration, refresh repositories, and update labels and styles. - Introduce source sidebar and refreshed dialog layout - Add azureRepositoryGroups and project grouping UI - Rename flow to selectIntegrationSource and improve loading logic
476 lines
31 KiB
Svelte
476 lines
31 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, onMount } from "svelte";
|
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
|
import { Cloud, Download, FolderOpen, GitBranch, Globe2, LoaderCircle, LockKeyhole, RefreshCw, Search, X } from "@lucide/svelte";
|
|
import { listIntegrationRepositories } from "../git";
|
|
import { configuredIntegrationSources } from "../integrations";
|
|
import type { AppLanguage, GitIntegrationProvider, GitIntegrationRepository, GitIntegrationSettings, GitIntegrationSource } from "../types";
|
|
|
|
type CloneSource = "url" | "integrations";
|
|
|
|
interface Props {
|
|
isBusy: boolean;
|
|
error: string;
|
|
language: AppLanguage;
|
|
integrations: GitIntegrationSettings;
|
|
onClone: (remoteUrl: string, parentPath: string, directoryName: string, provider?: GitIntegrationProvider, accountId?: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
let { isBusy = false, error = "", language = "en", integrations, onClone = () => {}, onClose = () => {} }: Props = $props();
|
|
let source = $state<CloneSource>("url");
|
|
let remoteUrl = $state("");
|
|
let parentPath = $state("");
|
|
let directoryName = $state("");
|
|
let directoryNameEdited = $state(false);
|
|
let directoryAutoName = $state("");
|
|
let browseError = $state("");
|
|
let visibleError = $state("");
|
|
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let selectedSourceId = $state("");
|
|
let selectedRepositoryId = $state("");
|
|
let repositorySearch = $state("");
|
|
let repositoriesBySource = $state<Record<string, GitIntegrationRepository[]>>({});
|
|
let loadingSourceId = $state("");
|
|
let repositoryError = $state("");
|
|
let repositoryListElement = $state<HTMLDivElement>();
|
|
let repositoryScrollbarElement = $state<HTMLDivElement>();
|
|
let repositoryScrollbarVisible = $state(false);
|
|
let repositoryScrollbarTop = $state(0);
|
|
let repositoryScrollbarHeight = $state(28);
|
|
let repositoryScrollTop = $state(0);
|
|
let repositoryScrollMax = $state(0);
|
|
let repositoryScrollbarPointerId = $state<number>();
|
|
let repositoryScrollbarDragY = 0;
|
|
let repositoryScrollbarDragScrollTop = 0;
|
|
let repositoryScrollbarFrame: number | undefined;
|
|
let repositoryRequestId = 0;
|
|
|
|
const isGerman = $derived(language === "de");
|
|
const configuredSources = $derived(configuredIntegrationSources(integrations));
|
|
const activeSource = $derived(configuredSources.find((candidate) => candidate.id === selectedSourceId));
|
|
const activeRepositories = $derived(activeSource ? repositoriesBySource[activeSource.id] ?? [] : []);
|
|
const filteredRepositories = $derived.by(() => {
|
|
const query = repositorySearch.trim().toLocaleLowerCase();
|
|
if (!query) return activeRepositories;
|
|
return activeRepositories.filter((repository) => `${repository.fullName} ${repository.description}`.toLocaleLowerCase().includes(query));
|
|
});
|
|
const azureRepositoryGroups = $derived.by(() => {
|
|
if (activeSource?.provider !== "azure-devops") return [];
|
|
const groups = new Map<string, GitIntegrationRepository[]>();
|
|
for (const repository of filteredRepositories) {
|
|
const separator = repository.fullName.indexOf("/");
|
|
const project = separator > 0 ? repository.fullName.slice(0, separator) : (isGerman ? "Weitere Repositories" : "Other repositories");
|
|
const repositories = groups.get(project) ?? [];
|
|
repositories.push(repository);
|
|
groups.set(project, repositories);
|
|
}
|
|
return [...groups.entries()].map(([project, repositories]) => ({ project, repositories }));
|
|
});
|
|
const selectedRepository = $derived(activeRepositories.find((repository) => repository.id === selectedRepositoryId));
|
|
const directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
|
|
const canSubmit = $derived(!isBusy && remoteUrl.trim().length > 0 && parentPath.trim().length > 0);
|
|
|
|
$effect(() => {
|
|
const nextError = error || browseError;
|
|
if (errorHideTimer) clearTimeout(errorHideTimer);
|
|
visibleError = nextError;
|
|
if (nextError) errorHideTimer = setTimeout(() => { visibleError = ""; }, 6000);
|
|
});
|
|
|
|
$effect(() => {
|
|
filteredRepositories.length;
|
|
loadingSourceId;
|
|
repositoryError;
|
|
scheduleRepositoryScrollbarUpdate();
|
|
});
|
|
|
|
onMount(() => {
|
|
window.addEventListener("resize", scheduleRepositoryScrollbarUpdate);
|
|
return () => window.removeEventListener("resize", scheduleRepositoryScrollbarUpdate);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (errorHideTimer) clearTimeout(errorHideTimer);
|
|
if (repositoryScrollbarFrame !== undefined) cancelAnimationFrame(repositoryScrollbarFrame);
|
|
});
|
|
|
|
function scheduleRepositoryScrollbarUpdate() {
|
|
if (repositoryScrollbarFrame !== undefined) cancelAnimationFrame(repositoryScrollbarFrame);
|
|
repositoryScrollbarFrame = requestAnimationFrame(() => {
|
|
repositoryScrollbarFrame = undefined;
|
|
updateRepositoryScrollbar();
|
|
});
|
|
}
|
|
|
|
function updateRepositoryScrollbar() {
|
|
const list = repositoryListElement;
|
|
const track = repositoryScrollbarElement;
|
|
if (!list || !track) {
|
|
repositoryScrollbarVisible = false;
|
|
return;
|
|
}
|
|
const scrollMax = Math.max(0, list.scrollHeight - list.clientHeight);
|
|
const trackHeight = track.clientHeight;
|
|
const thumbHeight = scrollMax > 0
|
|
? Math.max(28, trackHeight * (list.clientHeight / list.scrollHeight))
|
|
: trackHeight;
|
|
const thumbTravel = Math.max(0, trackHeight - thumbHeight);
|
|
repositoryScrollTop = list.scrollTop;
|
|
repositoryScrollMax = scrollMax;
|
|
repositoryScrollbarHeight = thumbHeight;
|
|
repositoryScrollbarTop = scrollMax > 0 ? (list.scrollTop / scrollMax) * thumbTravel : 0;
|
|
repositoryScrollbarVisible = scrollMax > 1;
|
|
}
|
|
|
|
function startRepositoryScrollbarDrag(event: PointerEvent) {
|
|
if (!repositoryListElement) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
repositoryScrollbarPointerId = event.pointerId;
|
|
repositoryScrollbarDragY = event.clientY;
|
|
repositoryScrollbarDragScrollTop = repositoryListElement.scrollTop;
|
|
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
}
|
|
|
|
function dragRepositoryScrollbar(event: PointerEvent) {
|
|
if (repositoryScrollbarPointerId !== event.pointerId || !repositoryListElement || !repositoryScrollbarElement) return;
|
|
const thumbTravel = repositoryScrollbarElement.clientHeight - repositoryScrollbarHeight;
|
|
if (thumbTravel <= 0) return;
|
|
repositoryListElement.scrollTop = repositoryScrollbarDragScrollTop
|
|
+ ((event.clientY - repositoryScrollbarDragY) / thumbTravel) * repositoryScrollMax;
|
|
}
|
|
|
|
function stopRepositoryScrollbarDrag(event: PointerEvent) {
|
|
if (repositoryScrollbarPointerId !== event.pointerId) return;
|
|
repositoryScrollbarPointerId = undefined;
|
|
const target = event.currentTarget as HTMLElement;
|
|
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
|
}
|
|
|
|
function jumpRepositoryScrollbar(event: MouseEvent) {
|
|
if (event.target !== event.currentTarget || !repositoryListElement || !repositoryScrollbarElement) return;
|
|
const track = repositoryScrollbarElement.getBoundingClientRect();
|
|
const thumbTravel = track.height - repositoryScrollbarHeight;
|
|
if (thumbTravel <= 0) return;
|
|
const targetTop = Math.max(0, Math.min(thumbTravel, event.clientY - track.top - repositoryScrollbarHeight / 2));
|
|
repositoryListElement.scrollTop = (targetTop / thumbTravel) * repositoryScrollMax;
|
|
}
|
|
|
|
function handleRepositoryScrollbarKey(event: KeyboardEvent) {
|
|
if (!repositoryListElement) return;
|
|
const page = repositoryListElement.clientHeight * 0.85;
|
|
const changes: Record<string, number> = {
|
|
ArrowUp: repositoryListElement.scrollTop - 40,
|
|
ArrowDown: repositoryListElement.scrollTop + 40,
|
|
PageUp: repositoryListElement.scrollTop - page,
|
|
PageDown: repositoryListElement.scrollTop + page,
|
|
Home: 0,
|
|
End: repositoryScrollMax,
|
|
};
|
|
if (!(event.key in changes)) return;
|
|
event.preventDefault();
|
|
repositoryListElement.scrollTop = changes[event.key];
|
|
}
|
|
|
|
function directoryNameFromRemoteUrl(url: string): string {
|
|
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
|
|
const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
|
|
return lastSegment.replace(/\.git$/i, "").trim();
|
|
}
|
|
|
|
function errorToMessage(error: unknown): string {
|
|
if (error instanceof Error) return error.message;
|
|
if (typeof error === "string") return error;
|
|
try { return JSON.stringify(error) ?? "Unknown error"; } catch { return "Unknown error"; }
|
|
}
|
|
|
|
async function chooseParentFolder() {
|
|
if (isBusy) return;
|
|
browseError = "";
|
|
try {
|
|
const selected = await openDialog({ title: isGerman ? "Zielordner zum Klonen auswählen" : "Select clone destination", directory: true, multiple: false, defaultPath: parentPath.trim() || undefined });
|
|
if (typeof selected === "string") parentPath = selected;
|
|
} catch (error) { browseError = errorToMessage(error); }
|
|
}
|
|
|
|
function setRemoteUrl(nextRemoteUrl: string) {
|
|
remoteUrl = nextRemoteUrl;
|
|
if (directoryNameEdited) return;
|
|
directoryAutoName = directoryNameFromRemoteUrl(nextRemoteUrl);
|
|
directoryName = directoryAutoName;
|
|
}
|
|
|
|
function handleRemoteInput(event: Event) {
|
|
setRemoteUrl((event.currentTarget as HTMLInputElement).value);
|
|
selectedRepositoryId = "";
|
|
}
|
|
|
|
function handleDirectoryInput(event: Event) {
|
|
const nextDirectoryName = (event.currentTarget as HTMLInputElement).value;
|
|
directoryNameEdited = nextDirectoryName.trim().length > 0 && nextDirectoryName !== directoryAutoName;
|
|
}
|
|
|
|
function selectRepository(repository: GitIntegrationRepository) {
|
|
selectedRepositoryId = repository.id;
|
|
setRemoteUrl(repository.cloneUrl);
|
|
}
|
|
|
|
function sortRepositories(repositories: GitIntegrationRepository[]): GitIntegrationRepository[] {
|
|
return [...repositories].sort((left, right) => left.fullName.localeCompare(
|
|
right.fullName,
|
|
isGerman ? "de" : "en",
|
|
{ numeric: true, sensitivity: "base" },
|
|
));
|
|
}
|
|
|
|
async function loadRepositories(integrationSource: GitIntegrationSource, force = false) {
|
|
selectedSourceId = integrationSource.id;
|
|
selectedRepositoryId = "";
|
|
repositorySearch = "";
|
|
repositoryError = "";
|
|
if (!force && repositoriesBySource[integrationSource.id]) return;
|
|
const requestId = ++repositoryRequestId;
|
|
loadingSourceId = integrationSource.id;
|
|
try {
|
|
const repositories = await listIntegrationRepositories(integrationSource.provider, integrationSource.baseUrl, integrationSource.accountId);
|
|
if (requestId === repositoryRequestId) repositoriesBySource = { ...repositoriesBySource, [integrationSource.id]: sortRepositories(repositories) };
|
|
} catch (error) {
|
|
if (requestId === repositoryRequestId) repositoryError = errorToMessage(error);
|
|
} finally {
|
|
if (requestId === repositoryRequestId) loadingSourceId = "";
|
|
}
|
|
}
|
|
|
|
function selectIntegrationSource(integrationSource: GitIntegrationSource) {
|
|
source = "integrations";
|
|
void loadRepositories(integrationSource);
|
|
}
|
|
|
|
function showUrlInput() {
|
|
source = "url";
|
|
selectedRepositoryId = "";
|
|
}
|
|
|
|
function formatUpdatedAt(value: string): string {
|
|
if (!value) return "";
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? "" : new Intl.DateTimeFormat(isGerman ? "de-DE" : "en-US", { dateStyle: "medium" }).format(date);
|
|
}
|
|
|
|
function submit(event: SubmitEvent) {
|
|
event.preventDefault();
|
|
if (canSubmit) onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim(), source === "integrations" ? activeSource?.provider : undefined, source === "integrations" ? activeSource?.accountId : undefined);
|
|
}
|
|
</script>
|
|
|
|
<div class="dialog-backdrop" role="presentation">
|
|
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Repository klonen" : "Clone repository"} tabindex="-1">
|
|
<header class="dialog-header clone-dialog-header">
|
|
<div><h2>{isGerman ? "Repository klonen" : "Clone a Repository"}</h2></div>
|
|
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Klonen schließen" : "Close clone dialog"}><X size={18} /></button>
|
|
</header>
|
|
|
|
<form class="clone-dialog-form" onsubmit={submit}>
|
|
<div class="clone-dialog-layout">
|
|
<aside class="clone-source-nav" aria-label={isGerman ? "Repository-Quellen" : "Repository sources"}>
|
|
<div class="clone-source-heading">{isGerman ? "Quelle" : "Source"}</div>
|
|
<div class="clone-source-list" role="tablist" aria-label={isGerman ? "Repository-Quelle" : "Repository source"}>
|
|
<button type="button" role="tab" aria-selected={source === "url"} class:active={source === "url"} onclick={showUrlInput}><Globe2 size={15} /><span>{isGerman ? "Mit URL klonen" : "Clone with URL"}</span></button>
|
|
{#each configuredSources as integrationSource}
|
|
<button type="button" role="tab" aria-selected={source === "integrations" && selectedSourceId === integrationSource.id} class:active={source === "integrations" && selectedSourceId === integrationSource.id} onclick={() => selectIntegrationSource(integrationSource)}>
|
|
{#if integrationSource.provider === "azure-devops"}<Cloud size={15} />{:else}<GitBranch size={15} />{/if}
|
|
<span>{integrationSource.provider === "azure-devops" ? `Azure · ${integrationSource.label}` : integrationSource.label}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{#if configuredSources.length === 0}<p>{isGerman ? "Integrationen kannst du in den Einstellungen einrichten." : "Set up integrations in Settings."}</p>{/if}
|
|
</aside>
|
|
|
|
<section class="clone-dialog-content">
|
|
<div class="clone-dialog-title">
|
|
<span>{source === "integrations" ? (activeSource?.label ?? "Integration") : "URL"}</span>
|
|
<h3>{isGerman ? "Repository klonen" : "Clone a Repo"}</h3>
|
|
</div>
|
|
|
|
<div class="clone-target-grid">
|
|
<label class="clone-dialog-field"><span>{isGerman ? "Klonen nach" : "Where to clone to"}</span><div class="clone-dialog-path-field"><input bind:value={parentPath} autocomplete="off" spellcheck="false" placeholder={isGerman ? "Übergeordneten Ordner auswählen" : "Choose parent folder"} disabled={isBusy} /><button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}><FolderOpen size={14} />{isGerman ? "Durchsuchen" : "Browse"}</button></div></label>
|
|
<label class="clone-dialog-field"><span>{isGerman ? "Ordnername" : "Folder name"}</span><input bind:value={directoryName} oninput={handleDirectoryInput} autocomplete="off" spellcheck="false" placeholder={directorySuggestion || "Optional"} disabled={isBusy} /></label>
|
|
</div>
|
|
|
|
{#if source === "url"}
|
|
<label class="clone-dialog-field clone-url-field">
|
|
<span>{isGerman ? "Repository-URL" : "Repository URL"}</span>
|
|
<!-- svelte-ignore a11y_autofocus -->
|
|
<input value={remoteUrl} oninput={handleRemoteInput} autocomplete="off" spellcheck="false" placeholder="https://gitlab.com/org/project.git" disabled={isBusy} autofocus />
|
|
</label>
|
|
{:else}
|
|
<section class="integration-browser" aria-label={isGerman ? "Repositories aus Integrationen" : "Repositories from integrations"}>
|
|
{#if configuredSources.length === 0}
|
|
<div class="integration-empty"><Cloud size={26} /><strong>{isGerman ? "Keine aktive Integration" : "No active integration"}</strong><p>{isGerman ? "Richte unter Einstellungen → Integrationen zuerst GitHub, GitLab, Azure DevOps oder Gitea ein." : "Set up GitHub, GitLab, Azure DevOps, or Gitea under Settings → Integrations first."}</p></div>
|
|
{:else}
|
|
<div class="repository-toolbar">
|
|
<label><Search size={14} /><input bind:value={repositorySearch} placeholder={isGerman ? "Repositories durchsuchen…" : "Search repositories…"} aria-label={isGerman ? "Repositories durchsuchen" : "Search repositories"} /></label>
|
|
<button type="button" onclick={() => activeSource && loadRepositories(activeSource, true)} disabled={!activeSource || loadingSourceId.length > 0} title={isGerman ? "Neu laden" : "Refresh"} aria-label={isGerman ? "Repository-Liste neu laden" : "Refresh repository list"}><RefreshCw class={loadingSourceId ? "spin" : ""} size={14} /></button>
|
|
</div>
|
|
<div class="repository-list-shell">
|
|
<div id="integration-repository-list" class="repository-list" bind:this={repositoryListElement} onscroll={updateRepositoryScrollbar} aria-live="polite">
|
|
{#if loadingSourceId}
|
|
<div class="repository-state"><LoaderCircle class="spin" size={20} /><span>{isGerman ? "Repositories werden geladen…" : "Loading repositories…"}</span></div>
|
|
{:else if repositoryError}
|
|
<div class="repository-state repository-state-error"><strong>{isGerman ? "Repositories konnten nicht geladen werden" : "Could not load repositories"}</strong><span>{repositoryError}</span></div>
|
|
{:else if filteredRepositories.length === 0}
|
|
<div class="repository-state"><GitBranch size={20} /><span>{repositorySearch ? (isGerman ? "Keine passenden Repositories." : "No matching repositories.") : (isGerman ? "Keine Repositories gefunden." : "No repositories found.")}</span></div>
|
|
{:else if activeSource?.provider === "azure-devops"}
|
|
{#each azureRepositoryGroups as group (group.project)}
|
|
<section class="repository-project-group" aria-label={group.project}>
|
|
<div class="repository-project-header"><span>{group.project}</span><em>{group.repositories.length}</em></div>
|
|
{#each group.repositories as repository (repository.id)}
|
|
<button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}>
|
|
<span class="repository-option-icon"><GitBranch size={15} /></span>
|
|
<span class="repository-option-copy"><strong>{repository.name}</strong><small>{repository.description || repository.cloneUrl}</small></span>
|
|
<span class="repository-option-meta">{#if repository.private}<LockKeyhole size={12} aria-label={isGerman ? "Privat" : "Private"} />{/if}{formatUpdatedAt(repository.updatedAt)}</span>
|
|
</button>
|
|
{/each}
|
|
</section>
|
|
{/each}
|
|
{:else}
|
|
{#each filteredRepositories as repository (repository.id)}
|
|
<button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}>
|
|
<span class="repository-option-icon"><GitBranch size={16} /></span>
|
|
<span class="repository-option-copy"><strong>{repository.fullName}</strong><small>{repository.description || repository.cloneUrl}</small></span>
|
|
<span class="repository-option-meta">{#if repository.private}<LockKeyhole size={12} aria-label={isGerman ? "Privat" : "Private"} />{/if}{formatUpdatedAt(repository.updatedAt)}</span>
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
<div
|
|
class="repository-scrollbar"
|
|
class:visible={repositoryScrollbarVisible}
|
|
class:dragging={repositoryScrollbarPointerId !== undefined}
|
|
bind:this={repositoryScrollbarElement}
|
|
role="scrollbar"
|
|
tabindex={repositoryScrollbarVisible ? 0 : -1}
|
|
aria-controls="integration-repository-list"
|
|
aria-label={isGerman ? "Repository-Liste scrollen" : "Scroll repository list"}
|
|
aria-orientation="vertical"
|
|
aria-valuemin="0"
|
|
aria-valuemax={repositoryScrollMax}
|
|
aria-valuenow={repositoryScrollTop}
|
|
onclick={jumpRepositoryScrollbar}
|
|
onkeydown={handleRepositoryScrollbarKey}
|
|
>
|
|
<div
|
|
class="repository-scrollbar-thumb"
|
|
role="presentation"
|
|
style={`height:${repositoryScrollbarHeight}px;transform:translateY(${repositoryScrollbarTop}px)`}
|
|
onpointerdown={startRepositoryScrollbarDrag}
|
|
onpointermove={dragRepositoryScrollbar}
|
|
onpointerup={stopRepositoryScrollbarDrag}
|
|
onpointercancel={stopRepositoryScrollbarDrag}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
{#if selectedRepository}<div class="selected-repository"><span>{isGerman ? "Ausgewählt" : "Selected"}</span><strong>{selectedRepository.fullName}</strong><code>{selectedRepository.cloneUrl}</code></div>{/if}
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
|
|
{#if visibleError}<div class="clone-dialog-error" role="alert">{visibleError}</div>{/if}
|
|
</section>
|
|
</div>
|
|
<div class="clone-dialog-actions"><button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{isGerman ? "Abbrechen" : "Cancel"}</button><button class="btn-primary" type="submit" disabled={!canSubmit}>{#if isBusy}<LoaderCircle class="spin" size={16} />{:else}<Download size={16} />{/if}{isGerman ? "Klonen" : "Clone"}</button></div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.clone-repository-dialog { width: min(900px, calc(100vw - 32px)); height: min(660px, calc(100vh - 32px)); }
|
|
.clone-dialog-header { min-height: 52px; padding: 0 16px 0 20px; }
|
|
.clone-dialog-header h2 { margin: 0; color: var(--color-ink); font-size: 15px; font-weight: 650; }
|
|
.clone-dialog-form { grid-template-rows: minmax(0, 1fr) auto; gap: 0; height: calc(100% - 53px); padding: 0; }
|
|
.clone-dialog-layout { display: grid; grid-template-columns: 205px minmax(0, 1fr); min-height: 0; }
|
|
.clone-source-nav { min-width: 0; padding: 12px 0; border-right: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
|
.clone-source-heading { padding: 2px 14px 8px; color: var(--color-ink-faint); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .08em; }
|
|
.clone-source-list { display: grid; gap: 2px; }
|
|
.clone-source-list button { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 8px; width: 100%; min-height: 38px; padding: 0 14px; border: 0; border-radius: 0; color: var(--color-ink-dim); background: transparent; box-shadow: none; font-size: 10.5px; font-weight: 650; text-align: left; }
|
|
.clone-source-list button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.clone-source-list button :global(svg) { color: var(--color-ink-faint); }
|
|
.clone-source-list button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
|
.clone-source-list button.active { color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 18%, var(--color-surface-hover)); box-shadow: inset 3px 0 0 var(--color-accent); }
|
|
.clone-source-list button.active :global(svg) { color: var(--color-accent); }
|
|
.clone-source-nav > p { margin: 12px 14px 0; color: var(--color-ink-faint); font-size: 9px; line-height: 1.45; }
|
|
.clone-dialog-content { display: grid; align-content: start; gap: 14px; min-width: 0; min-height: 0; padding: 16px 18px; overflow: auto; }
|
|
.clone-dialog-title { display: grid; gap: 3px; }
|
|
.clone-dialog-title > span { color: var(--color-accent); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .07em; }
|
|
.clone-dialog-title h3 { margin: 0; color: var(--color-ink); font-size: 16px; font-weight: 650; }
|
|
.clone-url-field { margin-top: 2px; }
|
|
.integration-browser { display: grid; gap: 8px; min-height: 0; }
|
|
.repository-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) 32px; gap: 6px; }
|
|
.repository-toolbar label { position: relative; min-width: 0; }
|
|
.repository-toolbar label > :global(svg) { position: absolute; z-index: 1; top: 10px; left: 10px; color: var(--color-ink-faint); }
|
|
.repository-toolbar input { height: 34px; padding-left: 31px; font-size: 11px; }
|
|
.repository-toolbar button { min-height: 32px; padding: 0; }
|
|
.repository-list-shell { position: relative; min-height: 190px; max-height: 286px; overflow: hidden; border: 1px solid var(--color-border-input); border-radius: 7px; background: var(--color-surface-raised); box-shadow: 0 8px 18px rgba(0,0,0,.13); }
|
|
.repository-list { min-height: 188px; max-height: 284px; padding-right: 8px; overflow: auto; scrollbar-width: none; border-radius: inherit; background: transparent; }
|
|
.repository-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
|
.repository-scrollbar { position: absolute; z-index: 2; top: 3px; right: 1px; bottom: 3px; width: 7px; border-radius: 4px; opacity: 0; pointer-events: none; touch-action: none; transition: opacity 120ms ease; }
|
|
.repository-scrollbar.visible { opacity: 1; pointer-events: auto; }
|
|
.repository-scrollbar-thumb { position: absolute; top: 0; right: 2px; width: 3px; min-height: 28px; border-radius: 3px; background: var(--app-scrollbar-thumb); cursor: pointer; transition: width 100ms ease, background 100ms ease; }
|
|
.repository-scrollbar:hover .repository-scrollbar-thumb,
|
|
.repository-scrollbar:focus-visible .repository-scrollbar-thumb,
|
|
.repository-scrollbar.dragging .repository-scrollbar-thumb { right: 1px; width: 4px; background: var(--app-scrollbar-thumb-hover); }
|
|
.repository-scrollbar:focus-visible { outline: 1px solid color-mix(in srgb, var(--color-accent) 70%, transparent); outline-offset: 1px; }
|
|
.repository-project-group + .repository-project-group { border-top: 1px solid var(--color-border-subtle); }
|
|
.repository-project-header { position: sticky; z-index: 1; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 34px; padding: 7px 10px 6px 12px; color: var(--color-accent); background: color-mix(in srgb, var(--color-surface-raised) 96%, transparent); font-size: 9.5px; font-weight: 900; text-transform: uppercase; letter-spacing: .045em; }
|
|
.repository-project-header span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.repository-project-header em { display: grid; flex: 0 0 auto; place-items: center; min-width: 19px; height: 16px; padding: 0 5px; color: var(--color-ink); background: color-mix(in srgb, var(--color-ink) 12%, transparent); font-size: 8px; font-style: normal; line-height: 1; letter-spacing: 0; }
|
|
.repository-option { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; min-height: 44px; padding: 6px 10px 6px 12px; border: 0; border-bottom: 1px solid var(--color-border-subtle); border-radius: 0; color: var(--color-ink-dim); background: transparent; text-align: left; }
|
|
.repository-option:last-child { border-bottom: 0; }
|
|
.repository-option:hover { color: var(--color-ink); background: var(--color-surface-hover); }
|
|
.repository-option.selected { color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 15%, var(--color-surface-hover)); box-shadow: inset 3px 0 0 var(--color-accent); }
|
|
.repository-option-icon { display: grid; place-items: center; width: 22px; height: 22px; color: var(--color-ink-faint); }
|
|
.repository-option.selected .repository-option-icon { color: var(--color-accent); }
|
|
.repository-option-copy { display: grid; min-width: 0; gap: 3px; }
|
|
.repository-option-copy strong, .repository-option-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.repository-option-copy strong { color: inherit; font-size: 10.5px; }
|
|
.repository-option-copy small { color: var(--color-ink-faint); font-size: 9px; }
|
|
.repository-project-group .repository-option { min-height: 38px; padding-block: 5px; }
|
|
.repository-project-group .repository-option-copy { gap: 0; }
|
|
.repository-project-group .repository-option-copy strong { color: var(--color-ink); font-size: 11.5px; font-weight: 800; }
|
|
.repository-project-group .repository-option-copy small { display: none; }
|
|
.repository-option-meta { display: flex; align-items: center; gap: 5px; color: var(--color-ink-faint); font-size: 8.5px; }
|
|
.repository-state, .integration-empty { display: grid; place-items: center; align-content: center; min-height: 188px; padding: 20px; color: var(--color-ink-faint); text-align: center; }
|
|
.repository-state { gap: 7px; font-size: 10.5px; }
|
|
.repository-state strong, .integration-empty strong { color: var(--color-ink); font-size: 11px; }
|
|
.repository-state-error strong { color: #e86060; }
|
|
.repository-state-error span { max-width: 520px; line-height: 1.45; }
|
|
.integration-empty { min-height: 260px; gap: 8px; }
|
|
.integration-empty :global(svg) { color: var(--color-accent); }
|
|
.integration-empty p { max-width: 400px; margin: 0; color: var(--color-ink-faint); font-size: 10px; line-height: 1.5; }
|
|
.selected-repository { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 3px 8px; min-width: 0; padding: 8px 9px; border-left: 2px solid var(--color-accent); background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
|
|
.selected-repository span { color: var(--color-accent); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
|
|
.selected-repository strong { overflow: hidden; color: var(--color-ink); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
|
.selected-repository code { grid-column: 2; overflow: hidden; color: var(--color-ink-faint); font: 8.5px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
|
|
.clone-target-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(150px, .72fr); gap: 10px; }
|
|
.clone-dialog-actions { min-height: 54px; align-items: center; padding: 9px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
|
|
@media (max-width: 700px) {
|
|
.clone-repository-dialog { width: min(660px, calc(100vw - 20px)); }
|
|
.clone-dialog-layout { grid-template-columns: 155px minmax(0, 1fr); }
|
|
.clone-source-list button { padding-inline: 10px; }
|
|
.clone-target-grid { grid-template-columns: 1fr; }
|
|
.repository-option-meta { display: none; }
|
|
}
|
|
@media (max-width: 500px) {
|
|
.dialog-backdrop { padding: 10px; }
|
|
.clone-repository-dialog { width: calc(100vw - 20px); height: min(660px, calc(100vh - 20px)); }
|
|
.clone-dialog-layout { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
|
|
.clone-source-nav { padding: 6px 0; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--color-border-subtle); }
|
|
.clone-source-heading, .clone-source-nav > p { display: none; }
|
|
.clone-source-list { display: flex; width: max-content; min-width: 100%; padding: 0 6px; }
|
|
.clone-source-list button { width: auto; min-height: 34px; padding-inline: 9px; border-radius: 5px; }
|
|
.clone-source-list button.active { box-shadow: inset 0 -2px 0 var(--color-accent); }
|
|
.clone-dialog-content { padding: 13px 12px; }
|
|
}
|
|
</style>
|