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:
@@ -5,3 +5,4 @@
|
||||
.idea
|
||||
.DS_Store
|
||||
~
|
||||
.codex*
|
||||
@@ -208,6 +208,12 @@ pub fn open_repository(path: String) -> Result<GitStatus, String> {
|
||||
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)]
|
||||
pub struct RepositoryBundle {
|
||||
pub status: GitStatus,
|
||||
@@ -1314,6 +1320,36 @@ fn resolve_repo(path: &str) -> Result<PathBuf, String> {
|
||||
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> {
|
||||
let commit = commit.trim();
|
||||
if commit.is_empty() {
|
||||
|
||||
@@ -6,10 +6,10 @@ use git::{
|
||||
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,
|
||||
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,
|
||||
open_repository_bundle, pull, push, read_conflict, resolve_conflict, resolve_conflict_side,
|
||||
restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions,
|
||||
stage_files, unstage_files, SearchCancellationState,
|
||||
list_commits, list_file_history, list_repository_files, merge_branch, open_repo_in_explorer,
|
||||
open_repository, open_repository_bundle, pull, push, read_conflict, resolve_conflict,
|
||||
resolve_conflict_side, restore_file_from_commit, restore_files, restore_to_commit,
|
||||
search_code_introductions, stage_files, unstage_files, SearchCancellationState,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
@@ -19,6 +19,7 @@ fn main() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
open_repository,
|
||||
open_repo_in_explorer,
|
||||
get_status,
|
||||
list_branches,
|
||||
checkout_branch,
|
||||
|
||||
+392
-49
@@ -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>
|
||||
|
||||
|
||||
+271
@@ -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-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 --- */
|
||||
|
||||
.notice {
|
||||
@@ -2380,6 +2645,12 @@
|
||||
.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; }
|
||||
.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-summary { height: 40px; }
|
||||
.change-lanes { grid-template-columns: 1fr; }
|
||||
|
||||
+13
-1
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
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 ahead: number = 0;
|
||||
@@ -17,6 +17,7 @@
|
||||
export let onRefresh: () => void = () => {};
|
||||
export let onSearch: () => void = () => {};
|
||||
export let onCompare: () => void = () => {};
|
||||
export let onOpenInExplorer: () => void = () => {};
|
||||
export let onToggleAutoRefresh: () => void = () => {};
|
||||
|
||||
const win = getCurrentWindow();
|
||||
@@ -82,6 +83,17 @@
|
||||
<!-- Right: actions + window controls -->
|
||||
<div class="titlebar-right">
|
||||
<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
|
||||
class="tb-action"
|
||||
onclick={onSearch}
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
</button>
|
||||
<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" />
|
||||
Lines
|
||||
Details
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes">
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
@@ -146,7 +146,7 @@
|
||||
</button>
|
||||
<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" />
|
||||
Lines
|
||||
Details
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
|
||||
@@ -17,6 +17,10 @@ export function openRepository(path: string): Promise<GitStatus> {
|
||||
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> {
|
||||
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user