Implement repository management view and multi-repo tabs

Introduces a new "Repository Management" view for browsing open and recent repositories. Adds a tabbed interface for quickly switching between multiple active repositories, with persistent state. Also includes a new command to open the current repository in the native file explorer.
This commit is contained in:
Christoph Brandau
2026-07-02 07:53:29 +02:00
parent e6d22765be
commit 97fc4fc1e0
8 changed files with 725 additions and 57 deletions
+392 -49
View File
@@ -2,7 +2,7 @@
import { onDestroy, onMount, tick } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
import { AlertCircle, Check, FolderOpen, GitBranch, GitMerge, LoaderCircle } from "@lucide/svelte";
import { AlertCircle, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
import TitleBar from "./lib/TitleBar.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte";
@@ -36,6 +36,7 @@
listFileHistory,
listRepositoryFiles,
mergeBranch,
openRepoInExplorer,
openRepositoryBundle,
pull,
push,
@@ -81,11 +82,29 @@
} from "./lib/credentials";
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository";
interface RepoTab {
path: string;
name: string;
branch: string | null;
ahead: number;
behind: number;
changed: number;
lastOpened: number;
}
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
// ── State ──────────────────────────────────────────────────────────────────
let repoPath = "";
let activeRepoPath = "";
let activeView: AppView = "management";
let repoTabs: RepoTab[] = [];
let recentRepoPaths: string[] = [];
let repoSearch = "";
let status: GitStatus | null = null;
let branches: GitBranchInfo[] = [];
let commits: GitCommit[] = [];
@@ -147,6 +166,7 @@
$: isBusy = operation.length > 0;
$: hasRepository = activeRepoPath.length > 0 && status !== null;
$: workspaceActive = activeView === "repository" && hasRepository;
$: openingRepo = operation === "Opening repository";
$: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? "";
$: changedFiles = status?.files ?? [];
@@ -161,10 +181,20 @@
$: canCompare = hasRepository && compareFrom.length > 0 && compareTo.length > 0 && compareFrom !== compareTo && !isBusy;
$: localBranches = branches.filter((b) => !b.remote);
$: remoteBranches = branches.filter((b) => b.remote);
$: repoSearchTerm = repoSearch.trim().toLowerCase();
$: openRepoRows = repoTabs.filter(repoMatchesSearch);
$: recentRepoRows = recentRepoPaths
.filter((path) => !repoTabs.some((tab) => sameRepoPath(tab.path, path)))
.map(repoRowFromPath)
.filter(repoMatchesSearch);
$: allRepoRows = uniqueRepoPaths([...repoTabs.map((tab) => tab.path), ...recentRepoPaths])
.map(repoRowFromPath)
.filter(repoMatchesSearch);
// ── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => {
loadRepoLists();
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
void checkForUpdates();
});
@@ -180,7 +210,7 @@
}
async function autoRefreshTick() {
if (!autoRefreshEnabled || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
autoRefreshInFlight = true;
try {
// Cheap fast path: only fetch status; skip the heavy reload if nothing changed.
@@ -277,11 +307,148 @@
// ── Utilities ──────────────────────────────────────────────────────────────
function repoNameFromPath(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function repoKey(path: string): string {
return path.replace(/\\/g, "/").trim().toLowerCase();
}
function sameRepoPath(left: string, right: string): boolean {
return repoKey(left) === repoKey(right);
}
function uniqueRepoPaths(paths: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const path of paths) {
const trimmed = path.trim();
if (!trimmed) continue;
const key = repoKey(trimmed);
if (seen.has(key)) continue;
seen.add(key);
result.push(trimmed);
}
return result;
}
function repoRowFromPath(path: string): RepoTab {
return repoTabs.find((tab) => sameRepoPath(tab.path, path)) ?? {
path,
name: repoNameFromPath(path),
branch: null,
ahead: 0,
behind: 0,
changed: 0,
lastOpened: 0,
};
}
function repoMatchesSearch(repo: RepoTab): boolean {
if (!repoSearchTerm) return true;
return repo.name.toLowerCase().includes(repoSearchTerm)
|| repo.path.toLowerCase().includes(repoSearchTerm)
|| (repo.branch ?? "").toLowerCase().includes(repoSearchTerm);
}
function loadRepoLists() {
try {
const openValue = JSON.parse(localStorage.getItem(OPEN_REPOS_KEY) ?? "[]") as unknown;
const recentValue = JSON.parse(localStorage.getItem(RECENT_REPOS_KEY) ?? "[]") as unknown;
const openPaths = Array.isArray(openValue)
? openValue.map((item) => typeof item === "string" ? item : "").filter(Boolean)
: [];
const recentPaths = Array.isArray(recentValue)
? recentValue.map((item) => typeof item === "string" ? item : "").filter(Boolean)
: [];
repoTabs = uniqueRepoPaths(openPaths).map((path) => ({
path,
name: repoNameFromPath(path),
branch: null,
ahead: 0,
behind: 0,
changed: 0,
lastOpened: 0,
}));
recentRepoPaths = uniqueRepoPaths([...recentPaths, ...openPaths]);
} catch {
repoTabs = [];
recentRepoPaths = [];
}
}
function persistRepoLists() {
try {
localStorage.setItem(OPEN_REPOS_KEY, JSON.stringify(repoTabs.map((tab) => tab.path)));
localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(recentRepoPaths));
} catch {
// Local storage is best-effort only; the Git workflow must keep working without it.
}
}
function rememberRecentRepo(path: string) {
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
persistRepoLists();
}
function upsertRepoTab(path: string, nextStatus?: GitStatus | null) {
const existing = repoTabs.find((tab) => sameRepoPath(tab.path, path));
const next: RepoTab = {
path,
name: repoNameFromPath(path),
branch: nextStatus?.current_branch ?? existing?.branch ?? null,
ahead: nextStatus?.ahead ?? existing?.ahead ?? 0,
behind: nextStatus?.behind ?? existing?.behind ?? 0,
changed: nextStatus?.files.length ?? existing?.changed ?? 0,
lastOpened: Date.now(),
};
repoTabs = existing
? repoTabs.map((tab) => sameRepoPath(tab.path, path) ? next : tab)
: [...repoTabs, next];
rememberRecentRepo(path);
}
function resetRepositoryState(clearActive = false) {
if (clearActive) {
activeRepoPath = "";
repoPath = "";
status = null;
lastStatusFingerprint = "";
}
branches = [];
commits = [];
repoFiles = [];
selectedExplorerPath = "";
selectedExplorerKind = "file";
expandedExplorerPaths = new Set();
expandedCommitHashes = new Set();
fileHistory = [];
compareFrom = "";
compareTo = "";
comparison = null;
compareSelectOpen = false;
compareDialogOpen = false;
selectedDiffPath = "";
pendingRestoreFile = null;
newBranchCommit = null;
globalSearchResults = [];
globalSearchOpen = false;
globalSearchError = "";
resolveDialogOpen = false;
conflictTarget = "";
conflict = null;
preparedResolutions = {};
}
function applyStatus(nextStatus: GitStatus) {
status = nextStatus;
activeRepoPath = nextStatus.repo_path || activeRepoPath || repoPath.trim();
repoPath = activeRepoPath;
lastStatusFingerprint = statusFingerprint(nextStatus);
upsertRepoTab(activeRepoPath, nextStatus);
}
function errorToMessage(error: unknown): string {
@@ -385,17 +552,10 @@
// Single backend round-trip: resolves the repo and reads status, branches,
// commits and files in one pass instead of four sequential git calls.
const bundle = await openRepositoryBundle(path, 100);
resetRepositoryState(false);
applyStatus(bundle.status);
branches = []; commits = []; repoFiles = [];
selectedExplorerPath = ""; selectedExplorerKind = "file";
expandedExplorerPaths = new Set(); expandedCommitHashes = new Set();
fileHistory = []; compareFrom = ""; compareTo = "";
comparison = null; compareSelectOpen = false; compareDialogOpen = false; selectedDiffPath = ""; pendingRestoreFile = null;
newBranchCommit = null;
if (globalSearchBusy) void cancelGlobalSearch();
globalSearchResults = []; globalSearchOpen = false; globalSearchError = "";
resolveDialogOpen = false; conflictTarget = ""; conflict = null;
preparedResolutions = {};
activeView = "repository";
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
@@ -419,6 +579,55 @@
}
}
function openRepoManagement() {
if (isBusy) return;
activeView = "management";
}
async function selectRepoTab(path: string) {
if (isBusy) return;
if (activeView === "repository" && sameRepoPath(activeRepoPath, path)) return;
await openRepo(path);
}
async function closeRepoTab(path: string, event?: MouseEvent) {
event?.stopPropagation();
if (isBusy) return;
const index = repoTabs.findIndex((tab) => sameRepoPath(tab.path, path));
const remaining = repoTabs.filter((tab) => !sameRepoPath(tab.path, path));
const next = remaining[index] ?? remaining[index - 1] ?? null;
repoTabs = remaining;
persistRepoLists();
if (!sameRepoPath(activeRepoPath, path)) return;
if (next) {
await openRepo(next.path);
} else {
resetRepositoryState(true);
activeView = "management";
}
}
async function removeRepoFromManagement(path: string, event?: MouseEvent) {
event?.stopPropagation();
if (isBusy) return;
recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path));
persistRepoLists();
if (repoTabs.some((tab) => sameRepoPath(tab.path, path))) {
await closeRepoTab(path);
}
}
async function openActiveRepoInExplorer() {
if (!activeRepoPath || isBusy) return;
try {
await openRepoInExplorer(activeRepoPath);
} catch (error) {
errorMessage = errorToMessage(error);
}
}
async function refreshRepo() {
if (!activeRepoPath) { await openRepo(); return; }
await runOperation("Refreshing", async () => {
@@ -1059,8 +1268,6 @@
// ── Event handlers ─────────────────────────────────────────────────────────
function submitRepo(event: SubmitEvent) { event.preventDefault(); void openRepo(); }
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
@@ -1081,7 +1288,7 @@
ahead={status?.ahead ?? 0}
behind={status?.behind ?? 0}
repoName={activeRepoPath ? (activeRepoPath.split(/[\\/]/).filter(Boolean).slice(-1)[0] ?? "") : ""}
{hasRepository}
hasRepository={workspaceActive}
{isBusy}
{operation}
{autoRefreshEnabled}
@@ -1091,43 +1298,65 @@
onRefresh={refreshRepo}
onSearch={() => { globalSearchOpen = true; }}
onCompare={openCompareSelect}
onOpenInExplorer={openActiveRepoInExplorer}
onToggleAutoRefresh={toggleAutoRefresh}
/>
<div class="shell-body">
<!-- Repository path form -->
<header class="topbar">
<form class="repo-form" onsubmit={submitRepo}>
<label for="repo-path">Repository</label>
<input
id="repo-path"
bind:value={repoPath}
autocomplete="off"
spellcheck="false"
placeholder="/path/to/repository"
disabled={isBusy}
/>
<button
class="btn-secondary repo-browse"
type="button"
onclick={chooseRepositoryFolder}
disabled={isBusy}
title="Repository-Ordner auswaehlen"
aria-label="Repository-Ordner auswaehlen"
>
<FolderOpen size={16} aria-hidden="true" />
Browse
</button>
<button class="btn-primary" type="submit" disabled={isBusy || repoPath.trim().length === 0}>
{#if operation === "Opening repository"}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Open
</button>
</form>
<header class="repo-tabbar" aria-label="Repository tabs">
<button
class="repo-tab management"
class:active={activeView === "management"}
type="button"
onclick={openRepoManagement}
disabled={isBusy}
title="Repository Management"
>
<BookOpen size={14} aria-hidden="true" />
Repository Management
</button>
<div class="repo-tabs-scroll">
{#each repoTabs as repo (repo.path)}
<div class="repo-tab-wrap" class:active={activeView === "repository" && sameRepoPath(activeRepoPath, repo.path)}>
<button
class="repo-tab"
type="button"
onclick={() => selectRepoTab(repo.path)}
disabled={isBusy}
title={repo.path}
>
<FolderOpen size={14} aria-hidden="true" />
<span>{repo.name}</span>
{#if repo.branch}
<strong>{repo.branch}</strong>
{/if}
</button>
<button
class="repo-tab-close"
type="button"
onclick={(event) => closeRepoTab(repo.path, event)}
disabled={isBusy}
aria-label={`Close ${repo.name}`}
title="Close repository tab"
>
<X size={13} aria-hidden="true" />
</button>
</div>
{/each}
</div>
<button
class="repo-tab-add"
type="button"
onclick={chooseRepositoryFolder}
disabled={isBusy}
title="Open repository folder"
aria-label="Open repository folder"
>
<Plus size={15} aria-hidden="true" />
</button>
</header>
<!-- Status notices -->
@@ -1145,7 +1374,7 @@
</section>
{/if}
{#if hasConflicts}
{#if workspaceActive && hasConflicts}
<section class="notice conflict" role="alert">
<GitMerge size={17} aria-hidden="true" />
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} merge conflicts.</span>
@@ -1153,8 +1382,121 @@
</section>
{/if}
<!-- Workspace -->
<section class="workspace" aria-label="Git workspace">
{#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-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" 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 quiet">recent</span>
</button>
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(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>All repositories</h2>
<span>{allRepoRows.length}</span>
</header>
{#if allRepoRows.length === 0}
<div class="repo-empty">Browse for a repository to add it here.</div>
{:else}
<div class="repo-table">
{#each allRepoRows 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>known repo</em>{/if}
</span>
</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}
<!-- Workspace -->
<section class="workspace" aria-label="Git workspace">
<!-- Left sidebar: branches + explorer -->
<aside class="left-sidebar" aria-label="Repository navigation">
@@ -1255,6 +1597,7 @@
/>
</aside>
</section>
{/if}
</div>
</main>