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
+1
View File
@@ -5,3 +5,4 @@
.idea .idea
.DS_Store .DS_Store
~ ~
.codex*
+36
View File
@@ -208,6 +208,12 @@ pub fn open_repository(path: String) -> Result<GitStatus, String> {
status_for_repo(&repo) status_for_repo(&repo)
} }
#[tauri::command]
pub fn open_repo_in_explorer(path: String) -> Result<(), String> {
let repo = resolve_repo(&path)?;
open_path_in_file_manager(&repo)
}
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct RepositoryBundle { pub struct RepositoryBundle {
pub status: GitStatus, pub status: GitStatus,
@@ -1314,6 +1320,36 @@ fn resolve_repo(path: &str) -> Result<PathBuf, String> {
Ok(PathBuf::from(top_level)) Ok(PathBuf::from(top_level))
} }
#[cfg(windows)]
fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
let native_path = path.to_string_lossy().replace('/', "\\");
let mut command = Command::new("explorer.exe");
command.arg(native_path);
command.creation_flags(CREATE_NO_WINDOW);
command
.spawn()
.map_err(|err| format!("Explorer konnte nicht gestartet werden: {err}"))?;
Ok(())
}
#[cfg(target_os = "macos")]
fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
Command::new("open")
.arg(path)
.spawn()
.map_err(|err| format!("Finder konnte nicht gestartet werden: {err}"))?;
Ok(())
}
#[cfg(all(unix, not(target_os = "macos")))]
fn open_path_in_file_manager(path: &Path) -> Result<(), String> {
Command::new("xdg-open")
.arg(path)
.spawn()
.map_err(|err| format!("Dateimanager konnte nicht gestartet werden: {err}"))?;
Ok(())
}
fn verify_commit(repo: &Path, commit: &str) -> Result<String, String> { fn verify_commit(repo: &Path, commit: &str) -> Result<String, String> {
let commit = commit.trim(); let commit = commit.trim();
if commit.is_empty() { if commit.is_empty() {
+5 -4
View File
@@ -6,10 +6,10 @@ use git::{
apply_file_patch, cancel_code_search, checkout_branch, commit, compare_commits, apply_file_patch, cancel_code_search, checkout_branch, commit, compare_commits,
compare_file_to_head, compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, compare_file_to_head, compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save,
diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, list_branches, diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, list_branches,
list_commits, list_file_history, list_repository_files, merge_branch, open_repository, list_commits, list_file_history, list_repository_files, merge_branch, open_repo_in_explorer,
open_repository_bundle, pull, push, read_conflict, resolve_conflict, resolve_conflict_side, open_repository, open_repository_bundle, pull, push, read_conflict, resolve_conflict,
restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions, resolve_conflict_side, restore_file_from_commit, restore_files, restore_to_commit,
stage_files, unstage_files, SearchCancellationState, search_code_introductions, stage_files, unstage_files, SearchCancellationState,
}; };
fn main() { fn main() {
@@ -19,6 +19,7 @@ fn main() {
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
open_repository, open_repository,
open_repo_in_explorer,
get_status, get_status,
list_branches, list_branches,
checkout_branch, checkout_branch,
+392 -49
View File
@@ -2,7 +2,7 @@
import { onDestroy, onMount, tick } from "svelte"; import { onDestroy, onMount, tick } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater"; 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 TitleBar from "./lib/TitleBar.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte";
@@ -36,6 +36,7 @@
listFileHistory, listFileHistory,
listRepositoryFiles, listRepositoryFiles,
mergeBranch, mergeBranch,
openRepoInExplorer,
openRepositoryBundle, openRepositoryBundle,
pull, pull,
push, push,
@@ -81,11 +82,29 @@
} from "./lib/credentials"; } from "./lib/credentials";
type UpdateToastState = "available" | "downloading" | "installed" | "error"; 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 ────────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────────
let repoPath = ""; let repoPath = "";
let activeRepoPath = ""; let activeRepoPath = "";
let activeView: AppView = "management";
let repoTabs: RepoTab[] = [];
let recentRepoPaths: string[] = [];
let repoSearch = "";
let status: GitStatus | null = null; let status: GitStatus | null = null;
let branches: GitBranchInfo[] = []; let branches: GitBranchInfo[] = [];
let commits: GitCommit[] = []; let commits: GitCommit[] = [];
@@ -147,6 +166,7 @@
$: isBusy = operation.length > 0; $: isBusy = operation.length > 0;
$: hasRepository = activeRepoPath.length > 0 && status !== null; $: hasRepository = activeRepoPath.length > 0 && status !== null;
$: workspaceActive = activeView === "repository" && hasRepository;
$: openingRepo = operation === "Opening repository"; $: openingRepo = operation === "Opening repository";
$: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? ""; $: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? "";
$: changedFiles = status?.files ?? []; $: changedFiles = status?.files ?? [];
@@ -161,10 +181,20 @@
$: canCompare = hasRepository && compareFrom.length > 0 && compareTo.length > 0 && compareFrom !== compareTo && !isBusy; $: canCompare = hasRepository && compareFrom.length > 0 && compareTo.length > 0 && compareFrom !== compareTo && !isBusy;
$: localBranches = branches.filter((b) => !b.remote); $: localBranches = branches.filter((b) => !b.remote);
$: remoteBranches = 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 ────────────────────────────────────────────────────────────── // ── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => { onMount(() => {
loadRepoLists();
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL); autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
void checkForUpdates(); void checkForUpdates();
}); });
@@ -180,7 +210,7 @@
} }
async function autoRefreshTick() { 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; autoRefreshInFlight = true;
try { try {
// Cheap fast path: only fetch status; skip the heavy reload if nothing changed. // Cheap fast path: only fetch status; skip the heavy reload if nothing changed.
@@ -277,11 +307,148 @@
// ── Utilities ────────────────────────────────────────────────────────────── // ── 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) { function applyStatus(nextStatus: GitStatus) {
status = nextStatus; status = nextStatus;
activeRepoPath = nextStatus.repo_path || activeRepoPath || repoPath.trim(); activeRepoPath = nextStatus.repo_path || activeRepoPath || repoPath.trim();
repoPath = activeRepoPath; repoPath = activeRepoPath;
lastStatusFingerprint = statusFingerprint(nextStatus); lastStatusFingerprint = statusFingerprint(nextStatus);
upsertRepoTab(activeRepoPath, nextStatus);
} }
function errorToMessage(error: unknown): string { function errorToMessage(error: unknown): string {
@@ -385,17 +552,10 @@
// Single backend round-trip: resolves the repo and reads status, branches, // Single backend round-trip: resolves the repo and reads status, branches,
// commits and files in one pass instead of four sequential git calls. // commits and files in one pass instead of four sequential git calls.
const bundle = await openRepositoryBundle(path, 100); const bundle = await openRepositoryBundle(path, 100);
resetRepositoryState(false);
applyStatus(bundle.status); 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(); if (globalSearchBusy) void cancelGlobalSearch();
globalSearchResults = []; globalSearchOpen = false; globalSearchError = ""; activeView = "repository";
resolveDialogOpen = false; conflictTarget = ""; conflict = null;
preparedResolutions = {};
await refreshBranchList(activeRepoPath, bundle.branches); await refreshBranchList(activeRepoPath, bundle.branches);
await refreshCommitHistory(activeRepoPath, bundle.commits); await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files); 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() { async function refreshRepo() {
if (!activeRepoPath) { await openRepo(); return; } if (!activeRepoPath) { await openRepo(); return; }
await runOperation("Refreshing", async () => { await runOperation("Refreshing", async () => {
@@ -1059,8 +1268,6 @@
// ── Event handlers ───────────────────────────────────────────────────────── // ── Event handlers ─────────────────────────────────────────────────────────
function submitRepo(event: SubmitEvent) { event.preventDefault(); void openRepo(); }
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog(); if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null; else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
@@ -1081,7 +1288,7 @@
ahead={status?.ahead ?? 0} ahead={status?.ahead ?? 0}
behind={status?.behind ?? 0} behind={status?.behind ?? 0}
repoName={activeRepoPath ? (activeRepoPath.split(/[\\/]/).filter(Boolean).slice(-1)[0] ?? "") : ""} repoName={activeRepoPath ? (activeRepoPath.split(/[\\/]/).filter(Boolean).slice(-1)[0] ?? "") : ""}
{hasRepository} hasRepository={workspaceActive}
{isBusy} {isBusy}
{operation} {operation}
{autoRefreshEnabled} {autoRefreshEnabled}
@@ -1091,43 +1298,65 @@
onRefresh={refreshRepo} onRefresh={refreshRepo}
onSearch={() => { globalSearchOpen = true; }} onSearch={() => { globalSearchOpen = true; }}
onCompare={openCompareSelect} onCompare={openCompareSelect}
onOpenInExplorer={openActiveRepoInExplorer}
onToggleAutoRefresh={toggleAutoRefresh} onToggleAutoRefresh={toggleAutoRefresh}
/> />
<div class="shell-body"> <div class="shell-body">
<!-- Repository path form --> <header class="repo-tabbar" aria-label="Repository tabs">
<header class="topbar"> <button
<form class="repo-form" onsubmit={submitRepo}> class="repo-tab management"
<label for="repo-path">Repository</label> class:active={activeView === "management"}
<input type="button"
id="repo-path" onclick={openRepoManagement}
bind:value={repoPath} disabled={isBusy}
autocomplete="off" title="Repository Management"
spellcheck="false" >
placeholder="/path/to/repository" <BookOpen size={14} aria-hidden="true" />
disabled={isBusy} Repository Management
/> </button>
<button
class="btn-secondary repo-browse" <div class="repo-tabs-scroll">
type="button" {#each repoTabs as repo (repo.path)}
onclick={chooseRepositoryFolder} <div class="repo-tab-wrap" class:active={activeView === "repository" && sameRepoPath(activeRepoPath, repo.path)}>
disabled={isBusy} <button
title="Repository-Ordner auswaehlen" class="repo-tab"
aria-label="Repository-Ordner auswaehlen" type="button"
> onclick={() => selectRepoTab(repo.path)}
<FolderOpen size={16} aria-hidden="true" /> disabled={isBusy}
Browse title={repo.path}
</button> >
<button class="btn-primary" type="submit" disabled={isBusy || repoPath.trim().length === 0}> <FolderOpen size={14} aria-hidden="true" />
{#if operation === "Opening repository"} <span>{repo.name}</span>
<LoaderCircle class="spin" size={16} aria-hidden="true" /> {#if repo.branch}
{:else} <strong>{repo.branch}</strong>
<Check size={16} aria-hidden="true" /> {/if}
{/if} </button>
Open <button
</button> class="repo-tab-close"
</form> 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> </header>
<!-- Status notices --> <!-- Status notices -->
@@ -1145,7 +1374,7 @@
</section> </section>
{/if} {/if}
{#if hasConflicts} {#if workspaceActive && hasConflicts}
<section class="notice conflict" role="alert"> <section class="notice conflict" role="alert">
<GitMerge size={17} aria-hidden="true" /> <GitMerge size={17} aria-hidden="true" />
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} merge conflicts.</span> <span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} merge conflicts.</span>
@@ -1153,8 +1382,121 @@
</section> </section>
{/if} {/if}
<!-- Workspace --> {#if activeView === "management"}
<section class="workspace" aria-label="Git workspace"> <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 --> <!-- Left sidebar: branches + explorer -->
<aside class="left-sidebar" aria-label="Repository navigation"> <aside class="left-sidebar" aria-label="Repository navigation">
@@ -1255,6 +1597,7 @@
/> />
</aside> </aside>
</section> </section>
{/if}
</div> </div>
</main> </main>
+271
View File
@@ -365,6 +365,271 @@
.repo-form label { color: var(--color-ink-faint); font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; } .repo-form label { color: var(--color-ink-faint); font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
.repo-browse { min-width: 104px; } .repo-browse { min-width: 104px; }
/* --- Repository tabs + management --- */
.repo-tabbar {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: stretch;
min-height: 40px;
border: 1px solid var(--color-border-subtle);
border-radius: 10px;
background: rgba(16, 17, 29, 0.9);
overflow: hidden;
}
.repo-tabs-scroll {
display: flex;
align-items: stretch;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
}
.repo-tab-wrap {
display: flex;
align-items: stretch;
min-width: 0;
border-right: 1px solid var(--color-border-subtle);
background: rgba(255,255,255,0.015);
}
.repo-tab-wrap.active {
background: rgba(90, 140, 248, 0.16);
box-shadow: inset 0 -2px 0 var(--color-primary);
}
.repo-tab {
min-height: 38px;
min-width: 0;
max-width: 250px;
padding: 0 10px;
border: 0;
border-right: 1px solid var(--color-border-subtle);
border-radius: 0;
background: transparent;
color: var(--color-ink-muted);
font-size: 12px;
font-weight: 700;
}
.repo-tab.management {
max-width: none;
min-width: 190px;
}
.repo-tab.active,
.repo-tab-wrap.active .repo-tab {
color: var(--color-ink);
}
.repo-tab span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.repo-tab strong {
flex: 0 0 auto;
max-width: 90px;
overflow: hidden;
padding: 2px 6px;
border-radius: 4px;
background: rgba(94, 110, 156, 0.18);
color: var(--color-ink-dim);
font-size: 10.5px;
font-family: var(--font-mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.repo-tab-close,
.repo-tab-add {
min-height: 38px;
min-width: 38px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--color-ink-faint);
}
.repo-tab-add {
border-left: 1px solid var(--color-border-subtle);
}
.repo-management {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
flex: 1 1 0;
min-height: 0;
border: 1px solid var(--color-border);
border-radius: 12px;
background: rgba(20, 21, 34, 0.82);
overflow: hidden;
}
.repo-management-head,
.repo-management-tools {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface-dim);
}
.repo-management-head h1 {
margin: 2px 0 0;
color: var(--color-ink);
font-size: 18px;
line-height: 1.2;
}
.repo-management-actions {
display: flex;
align-items: center;
gap: 8px;
}
.repo-management-tools {
padding: 9px 16px;
background: rgba(12, 13, 24, 0.72);
}
.repo-search {
position: relative;
width: min(620px, 100%);
}
.repo-search svg {
position: absolute;
left: 10px;
top: 50%;
color: var(--color-ink-faint);
transform: translateY(-50%);
pointer-events: none;
}
.repo-search input {
padding-left: 34px;
}
.repo-sections {
min-height: 0;
overflow: auto;
padding: 14px 16px 18px;
}
.repo-section {
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: rgba(255,255,255,0.018);
overflow: hidden;
}
.repo-section + .repo-section { margin-top: 12px; }
.repo-section > header {
display: flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 0 12px;
border-bottom: 1px solid var(--color-border-subtle);
background: rgba(255,255,255,0.045);
}
.repo-section h2 {
margin: 0;
color: var(--color-ink);
font-size: 13px;
font-weight: 800;
}
.repo-section > header span {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
min-height: 20px;
border-radius: 999px;
background: rgba(94, 110, 156, 0.18);
color: var(--color-ink-dim);
font-size: 11px;
font-weight: 800;
}
.repo-empty {
padding: 20px 24px;
color: var(--color-ink-faint);
font-size: 13px;
font-style: italic;
}
.repo-table {
display: grid;
}
.repo-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: stretch;
min-height: 36px;
border-bottom: 1px solid rgba(255,255,255,0.035);
}
.repo-row:last-child { border-bottom: 0; }
.repo-row-main {
display: grid;
grid-template-columns: minmax(160px, 220px) minmax(220px, 1fr) minmax(160px, 360px);
justify-content: stretch;
min-height: 36px;
padding: 0 10px 0 28px;
border: 0;
border-radius: 0;
background: transparent;
text-align: left;
}
.repo-row-main:hover:not(:disabled) {
background: rgba(90, 140, 248, 0.08);
}
.repo-row-name,
.repo-row-path,
.repo-row-meta {
display: inline-flex;
align-items: center;
min-width: 0;
overflow: hidden;
color: var(--color-ink-muted);
font-size: 12px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.repo-row-path {
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 11.5px;
font-weight: 500;
}
.repo-row-meta {
gap: 5px;
justify-content: flex-start;
color: var(--color-ink-dim);
}
.repo-row-meta strong,
.repo-row-meta em {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 20px;
padding: 0 6px;
border-radius: 4px;
background: rgba(94, 110, 156, 0.18);
color: var(--color-ink-dim);
font-size: 10.5px;
font-style: normal;
font-weight: 800;
}
.repo-row-meta .ahead { color: #4eca76; }
.repo-row-meta .behind { color: #e0a040; }
.repo-row-icon {
min-height: 36px;
min-width: 36px;
padding: 0;
border: 0;
border-left: 1px solid rgba(255,255,255,0.035);
border-radius: 0;
background: transparent;
color: var(--color-ink-faint);
}
/* --- Notices --- */ /* --- Notices --- */
.notice { .notice {
@@ -2380,6 +2645,12 @@
.left-sidebar { grid-template-rows: minmax(200px, 1fr) minmax(240px, 1.2fr); min-height: 440px; } .left-sidebar { grid-template-rows: minmax(200px, 1fr) minmax(240px, 1.2fr); min-height: 440px; }
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 190px; } .top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 190px; }
.repo-form { grid-template-columns: 1fr; } .repo-form { grid-template-columns: 1fr; }
.repo-tabbar { grid-template-columns: minmax(0, 1fr) auto; }
.repo-tab.management { min-width: 0; }
.repo-tabs-scroll { grid-column: 1 / -1; order: 2; border-top: 1px solid var(--color-border-subtle); }
.repo-row-main { grid-template-columns: minmax(0, 1fr); gap: 2px; align-content: center; padding-left: 12px; }
.repo-row { min-height: 58px; }
.repo-row-icon { min-height: 58px; }
.repo-path { display: none; } .repo-path { display: none; }
.repo-summary { height: 40px; } .repo-summary { height: 40px; }
.change-lanes { grid-template-columns: 1fr; } .change-lanes { grid-template-columns: 1fr; }
+13 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { Download, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte"; import { Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
export let branch: string = ""; export let branch: string = "";
export let ahead: number = 0; export let ahead: number = 0;
@@ -17,6 +17,7 @@
export let onRefresh: () => void = () => {}; export let onRefresh: () => void = () => {};
export let onSearch: () => void = () => {}; export let onSearch: () => void = () => {};
export let onCompare: () => void = () => {}; export let onCompare: () => void = () => {};
export let onOpenInExplorer: () => void = () => {};
export let onToggleAutoRefresh: () => void = () => {}; export let onToggleAutoRefresh: () => void = () => {};
const win = getCurrentWindow(); const win = getCurrentWindow();
@@ -82,6 +83,17 @@
<!-- Right: actions + window controls --> <!-- Right: actions + window controls -->
<div class="titlebar-right"> <div class="titlebar-right">
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions"> <div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
<button
class="tb-action"
onclick={onOpenInExplorer}
disabled={!hasRepository || isBusy}
title="Open repository in Explorer"
aria-label="Open repository in Explorer"
>
<FolderOpen size={14} aria-hidden="true" />
<span class="tb-action-label">Explorer</span>
</button>
<button <button
class="tb-action" class="tb-action"
onclick={onSearch} onclick={onSearch}
+2 -2
View File
@@ -121,7 +121,7 @@
</button> </button>
<button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines"> <button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
<FileDiff size={14} aria-hidden="true" /> <FileDiff size={14} aria-hidden="true" />
Lines Details
</button> </button>
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes"> <button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes">
<RotateCcw size={14} aria-hidden="true" /> <RotateCcw size={14} aria-hidden="true" />
@@ -146,7 +146,7 @@
</button> </button>
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines"> <button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines">
<FileDiff size={14} aria-hidden="true" /> <FileDiff size={14} aria-hidden="true" />
Lines Details
</button> </button>
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes"> <button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
<RotateCcw size={14} aria-hidden="true" /> <RotateCcw size={14} aria-hidden="true" />
+4
View File
@@ -17,6 +17,10 @@ export function openRepository(path: string): Promise<GitStatus> {
return invoke<GitStatus>("open_repository", { path }); return invoke<GitStatus>("open_repository", { path });
} }
export function openRepoInExplorer(path: string): Promise<void> {
return invoke<void>("open_repo_in_explorer", { path });
}
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> { export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit }); return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
} }