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:
2026-09-05 23:45:23 +02:00
parent 4a4a13dcf8
commit fcd884ee0b
5 changed files with 288 additions and 178 deletions
+28 -177
View File
@@ -10,6 +10,7 @@
import TitleBar from "./lib/TitleBar.svelte";
import RepoToolbar from "./lib/RepoToolbar.svelte";
import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
import RepoTabs from "./lib/RepoTabs.svelte";
import AiReviewDialog from "./lib/components/AiReviewDialog.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)
// but not currently open as a tab — keyed by normalized path (repoKey).
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 initRepositoryDialogOpen = false;
let pullStrategy: PullStrategy = (localStorage.getItem("gitlite.pullStrategy") as PullStrategy) || "merge";
@@ -552,15 +558,6 @@
) as Record<string, string>;
$: remoteBranches = branches.filter((b) => b.remote);
$: 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);
$: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
$: editorToolName = externalToolDisplayName("editor", externalToolsSettings.editor, detectedExternalTools);
@@ -987,7 +984,9 @@
backgroundRepoStatusIndex += 1;
try {
if (fetchFirst) await fetchRemote(path);
if (fetchFirst) {
await fetchRemote(path);
}
const nextStatus = await getStatus(path);
updateRepoManagementStatus(path, nextStatus);
} 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() {
try {
const cacheValue = JSON.parse(localStorage.getItem(REPO_STATUS_CACHE_KEY) ?? "{}") as unknown;
@@ -2614,7 +2606,11 @@
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();
if (isBusy) return;
closeRepoTabContextMenu();
@@ -2631,6 +2627,12 @@
});
if (!wasActive) return;
if (keepManagement) {
repoOpenRequestId += 1;
resetRepositoryState(true);
activeView = "management";
return;
}
if (next) {
// Switch identity immediately. Otherwise a status/fetch request for the
// just-closed repository can still finish while it remains active and
@@ -5236,164 +5238,13 @@
{/if}
{#if activeView === "management"}
<section class="repo-management" aria-label="Repository Management">
<div class="repo-management-head">
<div>
<span class="eyebrow">Repository Management</span>
<h1>Repositories</h1>
</div>
<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>
<RepositoryDashboard
repos={dashboardRepos} language={appLanguage} {isBusy}
onOpen={selectRepoTab} onAdd={chooseRepositoryFolder} onClone={openCloneDialog}
onInit={initializeRepository}
onFavorite={toggleFavoriteRepo} onClose={closeDashboardRepository}
onRemoveRecent={removeRepoFromRecent}
/>
{:else}
<!-- Workspace -->
<section