feat(management): extract repository management into dashboard component
Move the inline repository management UI into a new RepositoryDashboard component and wire it into the application. Refactor app state to compute a reactive dashboardRepos list and update tab-closing logic so tabs can be closed while preserving the management view when appropriate. - Add a full dashboard with search, categories, workspaces and persisted prefs. - Refactor close behavior to support keeping the management view open. - Improve UI/CSS and accessibility for the management tab (compact layout and label).
This commit is contained in:
+28
-177
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import TitleBar from "./lib/TitleBar.svelte";
|
import TitleBar from "./lib/TitleBar.svelte";
|
||||||
import RepoToolbar from "./lib/RepoToolbar.svelte";
|
import RepoToolbar from "./lib/RepoToolbar.svelte";
|
||||||
|
import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
|
||||||
import RepoTabs from "./lib/RepoTabs.svelte";
|
import RepoTabs from "./lib/RepoTabs.svelte";
|
||||||
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
|
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
|
||||||
import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte";
|
import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte";
|
||||||
@@ -314,7 +315,12 @@
|
|||||||
// Last-seen branch/ahead/behind/changed for repos that are known (recent/favorites)
|
// Last-seen branch/ahead/behind/changed for repos that are known (recent/favorites)
|
||||||
// but not currently open as a tab — keyed by normalized path (repoKey).
|
// but not currently open as a tab — keyed by normalized path (repoKey).
|
||||||
let repoStatusCache: Record<string, RepoTab> = {};
|
let repoStatusCache: Record<string, RepoTab> = {};
|
||||||
let repoSearch = "";
|
$: dashboardRepos = uniqueRepoPaths([...repoTabs.map(tab => tab.path), ...recentRepoPaths, ...favoriteRepoPaths])
|
||||||
|
.map(path => ({ ...repoRowFromPath(path, repoTabs, repoStatusCache),
|
||||||
|
isOpen: repoTabs.some(tab => sameRepoPath(tab.path, path)),
|
||||||
|
known: Boolean(repoStatusCache[repoKey(path)]),
|
||||||
|
recent: recentRepoPaths.some(recent => sameRepoPath(recent, path)),
|
||||||
|
favorite: favoriteRepoPaths.some(favorite => sameRepoPath(favorite, path)) }));
|
||||||
let cloneDialogOpen = false;
|
let cloneDialogOpen = false;
|
||||||
let initRepositoryDialogOpen = false;
|
let initRepositoryDialogOpen = false;
|
||||||
let pullStrategy: PullStrategy = (localStorage.getItem("gitlite.pullStrategy") as PullStrategy) || "merge";
|
let pullStrategy: PullStrategy = (localStorage.getItem("gitlite.pullStrategy") as PullStrategy) || "merge";
|
||||||
@@ -552,15 +558,6 @@
|
|||||||
) as Record<string, string>;
|
) as Record<string, string>;
|
||||||
$: remoteBranches = branches.filter((b) => b.remote);
|
$: remoteBranches = branches.filter((b) => b.remote);
|
||||||
$: currentBranchIsLocalOnly = Boolean(status?.current_branch) && !status?.upstream;
|
$: currentBranchIsLocalOnly = Boolean(status?.current_branch) && !status?.upstream;
|
||||||
$: repoSearchTerm = repoSearch.trim().toLowerCase();
|
|
||||||
$: openRepoRows = repoTabs.filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
|
|
||||||
$: recentRepoRows = recentRepoPaths
|
|
||||||
.filter((path) => !repoTabs.some((tab) => sameRepoPath(tab.path, path)))
|
|
||||||
.map((path) => repoRowFromPath(path, repoTabs, repoStatusCache))
|
|
||||||
.filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
|
|
||||||
$: favoriteRepoRows = favoriteRepoPaths
|
|
||||||
.map((path) => repoRowFromPath(path, repoTabs, repoStatusCache))
|
|
||||||
.filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
|
|
||||||
$: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed);
|
$: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed);
|
||||||
$: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
|
$: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
|
||||||
$: editorToolName = externalToolDisplayName("editor", externalToolsSettings.editor, detectedExternalTools);
|
$: editorToolName = externalToolDisplayName("editor", externalToolsSettings.editor, detectedExternalTools);
|
||||||
@@ -987,7 +984,9 @@
|
|||||||
backgroundRepoStatusIndex += 1;
|
backgroundRepoStatusIndex += 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (fetchFirst) await fetchRemote(path);
|
if (fetchFirst) {
|
||||||
|
await fetchRemote(path);
|
||||||
|
}
|
||||||
const nextStatus = await getStatus(path);
|
const nextStatus = await getStatus(path);
|
||||||
updateRepoManagementStatus(path, nextStatus);
|
updateRepoManagementStatus(path, nextStatus);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1605,13 +1604,6 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function repoMatchesSearch(repo: RepoTab, searchTerm = repoSearchTerm): boolean {
|
|
||||||
if (!searchTerm) return true;
|
|
||||||
return repo.name.toLowerCase().includes(searchTerm)
|
|
||||||
|| repo.path.toLowerCase().includes(searchTerm)
|
|
||||||
|| (repo.branch ?? "").toLowerCase().includes(searchTerm);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadRepoLists() {
|
function loadRepoLists() {
|
||||||
try {
|
try {
|
||||||
const cacheValue = JSON.parse(localStorage.getItem(REPO_STATUS_CACHE_KEY) ?? "{}") as unknown;
|
const cacheValue = JSON.parse(localStorage.getItem(REPO_STATUS_CACHE_KEY) ?? "{}") as unknown;
|
||||||
@@ -2614,7 +2606,11 @@
|
|||||||
await closeRepoTab(path);
|
await closeRepoTab(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeRepoTab(path: string, event?: MouseEvent) {
|
async function closeDashboardRepository(path: string) {
|
||||||
|
await closeRepoTab(path, undefined, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeRepoTab(path: string, event?: MouseEvent, keepManagement = false) {
|
||||||
event?.stopPropagation();
|
event?.stopPropagation();
|
||||||
if (isBusy) return;
|
if (isBusy) return;
|
||||||
closeRepoTabContextMenu();
|
closeRepoTabContextMenu();
|
||||||
@@ -2631,6 +2627,12 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!wasActive) return;
|
if (!wasActive) return;
|
||||||
|
if (keepManagement) {
|
||||||
|
repoOpenRequestId += 1;
|
||||||
|
resetRepositoryState(true);
|
||||||
|
activeView = "management";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (next) {
|
if (next) {
|
||||||
// Switch identity immediately. Otherwise a status/fetch request for the
|
// Switch identity immediately. Otherwise a status/fetch request for the
|
||||||
// just-closed repository can still finish while it remains active and
|
// just-closed repository can still finish while it remains active and
|
||||||
@@ -5236,164 +5238,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if activeView === "management"}
|
{#if activeView === "management"}
|
||||||
<section class="repo-management" aria-label="Repository Management">
|
<RepositoryDashboard
|
||||||
<div class="repo-management-head">
|
repos={dashboardRepos} language={appLanguage} {isBusy}
|
||||||
<div>
|
onOpen={selectRepoTab} onAdd={chooseRepositoryFolder} onClone={openCloneDialog}
|
||||||
<span class="eyebrow">Repository Management</span>
|
onInit={initializeRepository}
|
||||||
<h1>Repositories</h1>
|
onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository}
|
||||||
</div>
|
onRemoveRecent={removeRepoFromRecent}
|
||||||
<div class="repo-management-actions">
|
/>
|
||||||
<button class="btn-primary" type="button" onclick={openCloneDialog} disabled={isBusy}>
|
|
||||||
<Download size={15} aria-hidden="true" />
|
|
||||||
Clone
|
|
||||||
</button>
|
|
||||||
<button class="btn-secondary" type="button" onclick={initializeRepository} disabled={isBusy}>
|
|
||||||
<Plus size={15} aria-hidden="true" /> Init
|
|
||||||
</button>
|
|
||||||
<button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}>
|
|
||||||
<FolderOpen size={15} aria-hidden="true" />
|
|
||||||
Browse
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="repo-management-tools">
|
|
||||||
<div class="repo-search">
|
|
||||||
<Search size={15} aria-hidden="true" />
|
|
||||||
<input
|
|
||||||
bind:value={repoSearch}
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
placeholder="Search repositories"
|
|
||||||
aria-label="Search repositories"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="repo-sections">
|
|
||||||
<section class="repo-section">
|
|
||||||
<header>
|
|
||||||
<h2>Open repositories</h2>
|
|
||||||
<span>{openRepoRows.length}</span>
|
|
||||||
</header>
|
|
||||||
{#if openRepoRows.length === 0}
|
|
||||||
<div class="repo-empty">No open repositories.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="repo-table">
|
|
||||||
{#each openRepoRows as repo (repo.path)}
|
|
||||||
<div class="repo-row">
|
|
||||||
<button class="repo-row-main" type="button" onclick={() => selectRepoTab(repo.path)} disabled={isBusy}>
|
|
||||||
<span class="repo-row-name">{repo.name}</span>
|
|
||||||
<span class="repo-row-path">{repo.path}</span>
|
|
||||||
<span class="repo-row-meta">
|
|
||||||
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{/if}
|
|
||||||
{#if repo.ahead > 0}<em class="ahead">↑ {repo.ahead}</em>{/if}
|
|
||||||
{#if repo.behind > 0}<em class="behind">↓ {repo.behind}</em>{/if}
|
|
||||||
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="repo-row-icon repo-row-favorite"
|
|
||||||
class:active={isFavoriteRepo(repo.path)}
|
|
||||||
type="button"
|
|
||||||
onclick={(event) => toggleFavoriteRepo(repo.path, event)}
|
|
||||||
disabled={isBusy}
|
|
||||||
title={isFavoriteRepo(repo.path) ? "Remove from favorites" : "Add to favorites"}
|
|
||||||
aria-label={isFavoriteRepo(repo.path) ? `Remove ${repo.name} from favorites` : `Add ${repo.name} to favorites`}
|
|
||||||
>
|
|
||||||
<Star size={14} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<button class="repo-row-icon" type="button" onclick={(event) => closeRepoTab(repo.path, event)} disabled={isBusy} title="Close tab" aria-label={`Close ${repo.name}`}>
|
|
||||||
<X size={14} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="repo-section">
|
|
||||||
<header>
|
|
||||||
<h2>Recent repositories</h2>
|
|
||||||
<span>{recentRepoRows.length}</span>
|
|
||||||
</header>
|
|
||||||
{#if recentRepoRows.length === 0}
|
|
||||||
<div class="repo-empty">No recent repositories.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="repo-table">
|
|
||||||
{#each recentRepoRows as repo (repo.path)}
|
|
||||||
<div class="repo-row">
|
|
||||||
<button class="repo-row-main" type="button" onclick={() => openRepo(repo.path)} disabled={isBusy}>
|
|
||||||
<span class="repo-row-name">{repo.name}</span>
|
|
||||||
<span class="repo-row-path">{repo.path}</span>
|
|
||||||
<span class="repo-row-meta">
|
|
||||||
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em class="quiet">recent</em>{/if}
|
|
||||||
{#if repo.ahead > 0}<em class="ahead">↑ {repo.ahead}</em>{/if}
|
|
||||||
{#if repo.behind > 0}<em class="behind">↓ {repo.behind}</em>{/if}
|
|
||||||
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="repo-row-icon repo-row-favorite"
|
|
||||||
class:active={isFavoriteRepo(repo.path)}
|
|
||||||
type="button"
|
|
||||||
onclick={(event) => toggleFavoriteRepo(repo.path, event)}
|
|
||||||
disabled={isBusy}
|
|
||||||
title={isFavoriteRepo(repo.path) ? "Remove from favorites" : "Add to favorites"}
|
|
||||||
aria-label={isFavoriteRepo(repo.path) ? `Remove ${repo.name} from favorites` : `Add ${repo.name} to favorites`}
|
|
||||||
>
|
|
||||||
<Star size={14} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromRecent(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}>
|
|
||||||
<X size={14} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="repo-section">
|
|
||||||
<header>
|
|
||||||
<h2>Favorites</h2>
|
|
||||||
<span>{favoriteRepoRows.length}</span>
|
|
||||||
</header>
|
|
||||||
{#if favoriteRepoRows.length === 0}
|
|
||||||
<div class="repo-empty">Mark repositories with the star to keep them here.</div>
|
|
||||||
{:else}
|
|
||||||
<div class="repo-table">
|
|
||||||
{#each favoriteRepoRows as repo (repo.path)}
|
|
||||||
<div class="repo-row">
|
|
||||||
<button class="repo-row-main" type="button" onclick={() => openRepo(repo.path)} disabled={isBusy}>
|
|
||||||
<span class="repo-row-name">{repo.name}</span>
|
|
||||||
<span class="repo-row-path">{repo.path}</span>
|
|
||||||
<span class="repo-row-meta">
|
|
||||||
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em class="quiet">favorite</em>{/if}
|
|
||||||
{#if repo.ahead > 0}<em class="ahead">↑ {repo.ahead}</em>{/if}
|
|
||||||
{#if repo.behind > 0}<em class="behind">↓ {repo.behind}</em>{/if}
|
|
||||||
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="repo-row-icon repo-row-favorite active"
|
|
||||||
type="button"
|
|
||||||
onclick={(event) => toggleFavoriteRepo(repo.path, event)}
|
|
||||||
disabled={isBusy}
|
|
||||||
title="Remove from favorites"
|
|
||||||
aria-label={`Remove ${repo.name} from favorites`}
|
|
||||||
>
|
|
||||||
<Star size={14} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove" aria-label={`Remove ${repo.name}`}>
|
|
||||||
<X size={14} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Workspace -->
|
<!-- Workspace -->
|
||||||
<section
|
<section
|
||||||
|
|||||||
@@ -9050,3 +9050,12 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
|
|||||||
border-color: #a9212b;
|
border-color: #a9212b;
|
||||||
background: #c92f3a;
|
background: #c92f3a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep the repository overview as a compact icon tab. */
|
||||||
|
.repo-tab.management {
|
||||||
|
flex: 0 0 38px;
|
||||||
|
width: 38px;
|
||||||
|
min-width: 38px;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
onclick={onOpenManagement}
|
onclick={onOpenManagement}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
||||||
|
aria-label={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
|
||||||
>
|
>
|
||||||
<Folder size={15} aria-hidden="true" />
|
<Folder size={15} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, Plus, Search, Star, Trash2, X } from "@lucide/svelte";
|
||||||
|
import SelectMenu from "./SelectMenu.svelte";
|
||||||
|
|
||||||
|
interface DashboardRepository {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
branch: string | null;
|
||||||
|
ahead: number;
|
||||||
|
behind: number;
|
||||||
|
changed: number;
|
||||||
|
lastOpened: number;
|
||||||
|
known: boolean;
|
||||||
|
favorite: boolean;
|
||||||
|
isOpen: boolean;
|
||||||
|
recent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Workspace { id: string; name: string; }
|
||||||
|
|
||||||
|
export let repos: DashboardRepository[] = [];
|
||||||
|
export let language: "de" | "en" = "en";
|
||||||
|
export let isBusy = false;
|
||||||
|
export let onOpen: (path: string) => unknown;
|
||||||
|
export let onClose: (path: string) => unknown;
|
||||||
|
export let onAdd: () => unknown;
|
||||||
|
export let onClone: () => unknown;
|
||||||
|
export let onInit: () => unknown;
|
||||||
|
export let onFavorite: (path: string) => unknown;
|
||||||
|
export let onRemoveRecent: (path: string) => unknown;
|
||||||
|
|
||||||
|
const STORAGE_KEY = "gitty.dashboard.v1";
|
||||||
|
let query = "";
|
||||||
|
let selectedWorkspace = "";
|
||||||
|
let collapsedCategories = new Set<string>();
|
||||||
|
let workspaces: Workspace[] = [];
|
||||||
|
let assignments: Record<string, string> = {};
|
||||||
|
let workspaceDialog: HTMLDialogElement;
|
||||||
|
let workspaceInput: HTMLInputElement;
|
||||||
|
let workspaceName = "";
|
||||||
|
let workspaceError = "";
|
||||||
|
let workspaceSelection = new Set<string>();
|
||||||
|
|
||||||
|
$: de = language === "de";
|
||||||
|
$: normalizedQuery = query.trim().toLocaleLowerCase();
|
||||||
|
$: filteredRepos = repos.filter((repo) => {
|
||||||
|
const matchesSearch = !normalizedQuery || `${repo.name} ${repo.path} ${repo.branch ?? ""}`.toLocaleLowerCase().includes(normalizedQuery);
|
||||||
|
return matchesSearch && (!selectedWorkspace || assignments[repo.path] === selectedWorkspace);
|
||||||
|
});
|
||||||
|
$: openRepos = filteredRepos.filter((repo) => repo.isOpen);
|
||||||
|
$: favoriteRepos = filteredRepos.filter((repo) => repo.favorite);
|
||||||
|
$: recentRepos = filteredRepos.filter((repo) => repo.recent && !repo.isOpen);
|
||||||
|
$: categories = [
|
||||||
|
{ id: "open", label: "Open", repos: openRepos },
|
||||||
|
{ id: "favorites", label: de ? "Favoriten" : "Favorites", repos: favoriteRepos },
|
||||||
|
{ id: "recent", label: "Recent", repos: recentRepos },
|
||||||
|
];
|
||||||
|
$: changedCount = repos.filter((repo) => repo.known && repo.changed > 0).length;
|
||||||
|
$: workspaceOptions = [
|
||||||
|
{ value: "", label: de ? "Alle Workspaces" : "All workspaces" },
|
||||||
|
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, group: "Workspaces" })),
|
||||||
|
];
|
||||||
|
|
||||||
|
loadPreferences();
|
||||||
|
|
||||||
|
function loadPreferences() {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as { workspaces?: unknown; assignments?: unknown };
|
||||||
|
if (Array.isArray(saved.workspaces)) {
|
||||||
|
workspaces = saved.workspaces.filter((item): item is Workspace => Boolean(
|
||||||
|
item && typeof item === "object"
|
||||||
|
&& "id" in item && typeof item.id === "string" && item.id.startsWith("workspace-")
|
||||||
|
&& "name" in item && typeof item.name === "string" && item.name.trim(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (saved.assignments && typeof saved.assignments === "object" && !Array.isArray(saved.assignments)) {
|
||||||
|
assignments = Object.fromEntries(Object.entries(saved.assignments).filter(([, workspaceId]) =>
|
||||||
|
typeof workspaceId === "string" && workspaces.some((workspace) => workspace.id === workspaceId)));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
workspaces = [];
|
||||||
|
assignments = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePreferences() {
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ workspaces, assignments })); }
|
||||||
|
catch { /* Preferences remain optional when storage is unavailable. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openWorkspaceDialog() {
|
||||||
|
workspaceName = "";
|
||||||
|
workspaceError = "";
|
||||||
|
workspaceSelection = new Set();
|
||||||
|
workspaceDialog.showModal();
|
||||||
|
requestAnimationFrame(() => workspaceInput.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWorkspaceRepo(path: string) {
|
||||||
|
const next = new Set(workspaceSelection);
|
||||||
|
if (next.has(path)) next.delete(path);
|
||||||
|
else next.add(path);
|
||||||
|
workspaceSelection = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWorkspace(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = workspaceName.trim();
|
||||||
|
if (!name) {
|
||||||
|
workspaceError = de ? "Bitte einen Namen eingeben." : "Please enter a name.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (workspaces.some((workspace) => workspace.name.toLocaleLowerCase() === name.toLocaleLowerCase())) {
|
||||||
|
workspaceError = de ? "Dieser Workspace existiert bereits." : "This workspace already exists.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = `workspace-${crypto.randomUUID()}`;
|
||||||
|
workspaces = [...workspaces, { id, name }];
|
||||||
|
assignments = { ...assignments, ...Object.fromEntries([...workspaceSelection].map((path) => [path, id])) };
|
||||||
|
selectedWorkspace = id;
|
||||||
|
savePreferences();
|
||||||
|
workspaceDialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSelectedWorkspace() {
|
||||||
|
if (!selectedWorkspace) return;
|
||||||
|
const deletedWorkspace = selectedWorkspace;
|
||||||
|
workspaces = workspaces.filter((workspace) => workspace.id !== deletedWorkspace);
|
||||||
|
assignments = Object.fromEntries(Object.entries(assignments).filter(([, workspaceId]) => workspaceId !== deletedWorkspace));
|
||||||
|
selectedWorkspace = "";
|
||||||
|
savePreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCard(path: string) { if (!isBusy) onOpen(path); }
|
||||||
|
function closeCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onClose(path); }
|
||||||
|
function favoriteCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onFavorite(path); }
|
||||||
|
function removeRecentCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onRemoveRecent(path); }
|
||||||
|
function toggleCategory(id: string) {
|
||||||
|
const next = new Set(collapsedCategories);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
collapsedCategories = next;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="repo-dashboard" aria-label={de ? "Repository-Verwaltung" : "Repository management"}>
|
||||||
|
<header class="dashboard-header">
|
||||||
|
<h1>Repository Management</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button type="button" onclick={onClone} disabled={isBusy}><Download size={14} />{de ? "Klonen" : "Clone"}</button>
|
||||||
|
<button type="button" onclick={onInit} disabled={isBusy}><FolderGit2 size={14} />{de ? "Initialisieren" : "Initialize"}</button>
|
||||||
|
<button class="primary" type="button" onclick={onAdd} disabled={isBusy}><Plus size={14} />Repository</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="dashboard-toolbar">
|
||||||
|
<label class="dashboard-search">
|
||||||
|
<Search size={14} />
|
||||||
|
<input bind:value={query} autocomplete="off" spellcheck="false" placeholder={de ? "Repositories suchen …" : "Search repositories …"} aria-label={de ? "Repositories suchen" : "Search repositories"} />
|
||||||
|
</label>
|
||||||
|
<div class="workspace-tools">
|
||||||
|
<SelectMenu
|
||||||
|
class="workspace-select"
|
||||||
|
value={selectedWorkspace}
|
||||||
|
options={workspaceOptions}
|
||||||
|
ariaLabel={de ? "Workspace auswählen" : "Select workspace"}
|
||||||
|
onChange={(value) => { selectedWorkspace = value; }}
|
||||||
|
/>
|
||||||
|
{#if selectedWorkspace}
|
||||||
|
<button class="workspace-delete" type="button" onclick={deleteSelectedWorkspace} aria-label={de ? "Ausgewählten Workspace löschen" : "Delete selected workspace"} title={de ? "Workspace löschen" : "Delete workspace"}><Trash2 size={15} strokeWidth={2} /></button>
|
||||||
|
{/if}
|
||||||
|
<button type="button" onclick={openWorkspaceDialog}><Plus size={14} />Workspace</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-summary">{repos.length} Repositories <span>·</span> {changedCount} {de ? "mit Änderungen" : "with changes"}</div>
|
||||||
|
|
||||||
|
<div class="dashboard-content">
|
||||||
|
{#each categories as category (category.id)}
|
||||||
|
<section class="dashboard-section">
|
||||||
|
<header class="section-header">
|
||||||
|
<button class="section-toggle" type="button" aria-expanded={!collapsedCategories.has(category.id)} onclick={() => toggleCategory(category.id)}>
|
||||||
|
{#if collapsedCategories.has(category.id)}<ChevronRight size={14} />{:else}<ChevronDown size={14} />{/if}
|
||||||
|
<strong>{category.label}</strong><span>{category.repos.length}</span><i></i>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
{#if !collapsedCategories.has(category.id)}
|
||||||
|
{#if category.repos.length > 0}
|
||||||
|
<div class="repo-grid">
|
||||||
|
{#each category.repos as repo (repo.path)}
|
||||||
|
<article class="repo-card" class:open={repo.isOpen}>
|
||||||
|
<button class="card-main" type="button" onclick={() => openCard(repo.path)} disabled={isBusy} title={repo.path}>
|
||||||
|
<span class="card-title"><FolderGit2 size={15} /><strong>{repo.name}</strong></span>
|
||||||
|
<span class="card-path">{repo.path}</span>
|
||||||
|
<span class="card-branch"><GitBranch size={13} />{repo.branch ?? (de ? "Branch unbekannt" : "Unknown branch")}</span>
|
||||||
|
<span class="card-status">
|
||||||
|
{#if !repo.known}<span class="muted"><Circle size={10} />{de ? "Status nicht geladen" : "Status not loaded"}</span>
|
||||||
|
{:else if repo.changed > 0}<span class="changed"><Circle class="filled" size={9} />{repo.changed} {de ? (repo.changed === 1 ? "Änderung" : "Änderungen") : (repo.changed === 1 ? "change" : "changes")}</span>
|
||||||
|
{:else}<span class="clean"><Circle class="filled" size={9} />{de ? "Sauber" : "Clean"}</span>{/if}
|
||||||
|
{#if repo.ahead > 0}<span class="ahead"><ArrowUp size={13} />{repo.ahead} {de ? "voraus" : "ahead"}</span>{/if}
|
||||||
|
{#if repo.behind > 0}<span class="behind"><ArrowDown size={13} />{repo.behind} {de ? "zurück" : "behind"}</span>{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class:active={repo.favorite} type="button" onclick={(event) => favoriteCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Favorit umschalten" : "Toggle favorite"}`} aria-pressed={repo.favorite}><Star size={15} /></button>
|
||||||
|
{#if repo.isOpen}<button type="button" onclick={(event) => closeCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Schließen" : "Close"}`} title={de ? "Repository schließen" : "Close repository"}><X size={15} /></button>{/if}
|
||||||
|
{#if category.id === "recent"}<button type="button" onclick={(event) => removeRecentCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Aus Recent entfernen" : "Remove from recent"}`} title={de ? "Aus Recent entfernen" : "Remove from recent"}><X size={15} /></button>{/if}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="category-empty">{de ? "Keine Repositories in dieser Kategorie." : "No repositories in this category."}</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<footer class="dashboard-footer">{repos.length} {de ? "lokale Repositories" : "local repositories"}</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<dialog class="workspace-dialog" bind:this={workspaceDialog} aria-labelledby="workspace-title">
|
||||||
|
<form onsubmit={createWorkspace}>
|
||||||
|
<header><h2 id="workspace-title">{de ? "Workspace anlegen" : "Create workspace"}</h2><button type="button" onclick={() => workspaceDialog.close()} aria-label={de ? "Dialog schließen" : "Close dialog"}><X size={14} /></button></header>
|
||||||
|
<label for="workspace-name">Name</label>
|
||||||
|
<input id="workspace-name" bind:this={workspaceInput} bind:value={workspaceName} maxlength="64" autocomplete="off" aria-invalid={Boolean(workspaceError)} />
|
||||||
|
{#if workspaceError}<p class="dialog-error" role="alert">{workspaceError}</p>{/if}
|
||||||
|
{#if repos.length > 0}
|
||||||
|
<fieldset><legend>{de ? "Repositories zuordnen" : "Assign repositories"}</legend><div class="workspace-repos">
|
||||||
|
{#each repos as repo (repo.path)}<label><input type="checkbox" checked={workspaceSelection.has(repo.path)} onchange={() => toggleWorkspaceRepo(repo.path)} /><span>{repo.name}</span><small>{repo.path}</small></label>{/each}
|
||||||
|
</div></fieldset>
|
||||||
|
{/if}
|
||||||
|
<footer><button type="button" onclick={() => workspaceDialog.close()}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit"><Check size={14} />{de ? "Anlegen" : "Create"}</button></footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.repo-dashboard{display:flex;flex:1;min-height:0;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}button,input{font:inherit}button{color:inherit;cursor:pointer}button:disabled{cursor:default;opacity:.5}
|
||||||
|
.dashboard-header{display:flex;min-height:58px;align-items:center;justify-content:space-between;gap:16px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-header h1{margin:0;font-size:18px;line-height:1.2;font-weight:700}.header-actions,.workspace-tools{display:flex;align-items:center;gap:8px}.header-actions button,.workspace-tools button{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}button.primary{border-color:var(--color-primary);background:var(--color-primary);color:#fff}
|
||||||
|
.dashboard-toolbar{display:flex;min-height:48px;align-items:center;gap:16px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-search{display:flex;width:300px;height:30px;align-items:center;gap:8px;padding:0 9px;border:1px solid var(--color-border-input);border-radius:var(--ui-radius-sm);color:var(--color-ink-faint);background:var(--app-input-bg)}.dashboard-search:focus-within{border-color:var(--color-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 14%,transparent)}.dashboard-search :global(svg){flex:0 0 auto}.dashboard-search input{width:100%;min-width:0;height:100%;padding:0;border:0;border-radius:0;outline:0;color:var(--color-ink);background:transparent;box-shadow:none}.dashboard-search input:focus,.dashboard-search input:focus-visible{border:0;outline:0;box-shadow:none}.workspace-tools{margin-left:auto}.workspace-tools :global(.workspace-select){width:240px}.workspace-tools :global(.workspace-select .select-menu-trigger){height:30px;min-height:30px;padding:0 9px;border-color:var(--color-border-input);background:var(--app-input-bg);font-size:12px;font-weight:500}.workspace-tools .workspace-delete{width:30px;min-width:30px;padding:0;border-color:color-mix(in srgb,#df626b 55%,var(--color-border));color:#df747b;background:color-mix(in srgb,#c92f3a 8%,var(--app-button-bg))}.workspace-tools .workspace-delete:hover:not(:disabled){border-color:#df626b;color:#fff;background:#c93b45;box-shadow:0 0 0 2px color-mix(in srgb,#c92f3a 18%,transparent)}
|
||||||
|
.dashboard-summary{padding:8px 20px;color:var(--color-ink-muted)}.dashboard-summary span{padding:0 6px;color:var(--color-ink-faint)}.dashboard-content{flex:1;min-height:0;overflow:auto;padding:0 20px 24px}.dashboard-section+.dashboard-section{margin-top:12px}.section-header{min-height:31px}.section-toggle{display:flex;width:100%;min-height:31px;align-items:center;justify-content:flex-start;gap:7px;padding:0;border:0;background:transparent;color:var(--color-ink);text-align:left}.section-toggle:hover:not(:disabled){border:0;background:transparent;color:var(--color-ink)}.section-toggle :global(svg){flex:0 0 auto;color:var(--color-ink-muted)}.section-toggle strong{font-size:13px;line-height:1.2;font-weight:750}.section-toggle span{color:var(--color-ink-faint);font-weight:400}.section-toggle i{height:1px;flex:1;background:var(--color-border-subtle)}.category-empty{margin:0;padding:9px 12px;border:1px solid var(--color-border-subtle);color:var(--color-ink-faint);background:var(--color-surface)}
|
||||||
|
.repo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.repo-card{position:relative;min-width:0;height:138px;border:1px solid var(--color-border);background:var(--color-surface)}.repo-card:hover,.repo-card:focus-within{border-color:var(--color-border-input);background:var(--color-surface-hover)}.card-main{display:flex;width:100%;height:100%;flex-direction:column;align-items:flex-start;gap:7px;padding:12px 70px 11px 14px;border:0;background:transparent;text-align:left}.card-main:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}.card-title{display:flex;max-width:100%;align-items:center;gap:8px}.card-title strong{overflow:hidden;font-size:14px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.card-path{width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-branch,.card-status,.card-status>span{display:flex;align-items:center;gap:6px}.card-branch{max-width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-status{flex-wrap:wrap;gap:8px 18px;margin-top:auto}.card-status :global(.filled){fill:currentColor}.changed,.behind{color:#eeb94e}.clean{color:#68c878}.ahead{color:var(--color-accent)}.muted{color:var(--color-ink-muted)}
|
||||||
|
.card-actions{position:absolute;top:8px;right:8px;z-index:2;display:flex;align-items:center;gap:2px}.card-actions button{display:grid;width:26px;height:26px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.card-actions button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-dim)}.card-actions button.active{color:var(--color-accent)}.card-actions button.active :global(svg){fill:color-mix(in srgb,var(--color-accent) 22%,transparent)}
|
||||||
|
.dashboard-footer{display:flex;min-height:30px;align-items:center;padding:0 20px;border-top:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
|
||||||
|
.workspace-dialog{width:min(420px,calc(100vw - 32px));margin:auto;padding:0;border:1px solid var(--color-border);color:var(--color-ink);background:var(--app-dialog-bg);box-shadow:var(--app-dialog-shadow);font-size:12px}.workspace-dialog::backdrop{background:var(--app-dialog-backdrop)}.workspace-dialog form>header,.workspace-dialog form>footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--app-dialog-chrome)}.workspace-dialog form>header{border-bottom:1px solid var(--color-border)}.workspace-dialog form>footer{justify-content:flex-end;gap:8px;border-top:1px solid var(--color-border)}.workspace-dialog h2{margin:0;font-size:13px}.workspace-dialog header button{width:26px;height:26px;padding:0;border:0;background:transparent}.workspace-dialog form>label,.workspace-dialog form>input,.workspace-dialog fieldset,.dialog-error{margin-right:12px;margin-left:12px}.workspace-dialog form>label{display:block;margin-top:12px;margin-bottom:5px;color:var(--color-ink-muted)}.workspace-dialog form>input{width:calc(100% - 24px);height:30px;padding:0 8px;border:1px solid var(--color-border-input);color:var(--color-ink);background:var(--app-input-bg)}.dialog-error{margin-top:7px;color:#ed9292}.workspace-dialog fieldset{margin-top:14px;margin-bottom:14px;padding:0;border:0}.workspace-dialog legend{margin-bottom:6px;color:var(--color-ink-muted)}.workspace-repos{max-height:190px;overflow:auto;border:1px solid var(--color-border-subtle)}.workspace-repos label{display:grid;grid-template-columns:auto minmax(100px,auto) 1fr;align-items:center;gap:8px;min-height:32px;padding:4px 8px;border-bottom:1px solid var(--color-border-subtle)}.workspace-repos label:last-child{border-bottom:0}.workspace-repos small{overflow:hidden;color:var(--color-ink-faint);text-overflow:ellipsis;white-space:nowrap}.workspace-dialog footer button{display:inline-flex;min-height:28px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}
|
||||||
|
@media(max-width:1000px){.dashboard-toolbar{flex-wrap:wrap;gap:8px}.dashboard-search{flex:1 1 260px}.workspace-tools{margin-left:0}.repo-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||||
|
@media(max-width:680px){.dashboard-header{min-height:auto;align-items:flex-start;flex-direction:column}.header-actions{flex-wrap:wrap}.workspace-tools{width:100%}.workspace-tools :global(.workspace-select){min-width:0;flex:1}.repo-grid{grid-template-columns:1fr}.repo-card{height:132px}}
|
||||||
|
</style>
|
||||||
@@ -50,7 +50,8 @@
|
|||||||
const rect = trigger.getBoundingClientRect();
|
const rect = trigger.getBoundingClientRect();
|
||||||
const viewportGap = 8;
|
const viewportGap = 8;
|
||||||
const menuGap = 5;
|
const menuGap = 5;
|
||||||
const desiredHeight = Math.min(300, options.length * 34 + 24);
|
const groupHeaderCount = new Set(options.map((option) => option.group).filter(Boolean)).size;
|
||||||
|
const desiredHeight = Math.min(300, options.length * 32 + groupHeaderCount * 36 + 12);
|
||||||
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
|
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
|
||||||
const spaceAbove = rect.top - viewportGap;
|
const spaceAbove = rect.top - viewportGap;
|
||||||
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
|
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
|
||||||
|
|||||||
Reference in New Issue
Block a user