diff --git a/.gitignore b/.gitignore index 5d15844..035e28c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ *.log .idea .DS_Store -~ \ No newline at end of file +~ +.codex* \ No newline at end of file diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index c062ebe..f0fd4bc 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -208,6 +208,12 @@ pub fn open_repository(path: String) -> Result { 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 { 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 { let commit = commit.trim(); if commit.is_empty() { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4d09990..746d297 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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, diff --git a/src/App.svelte b/src/App.svelte index 6f71d71..2f1daf2 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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(); + 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} />
- -
-
- - - - -
+
+ + +
+ {#each repoTabs as repo (repo.path)} +
+ + +
+ {/each} +
+ +
@@ -1145,7 +1374,7 @@ {/if} - {#if hasConflicts} + {#if workspaceActive && hasConflicts} {/if} - -
+ {#if activeView === "management"} +
+
+
+ Repository Management +

Repositories

+
+
+ +
+
+ +
+ +
+ +
+
+
+

Open repositories

+ {openRepoRows.length} +
+ {#if openRepoRows.length === 0} +
No open repositories.
+ {:else} +
+ {#each openRepoRows as repo (repo.path)} +
+ + +
+ {/each} +
+ {/if} +
+ +
+
+

Recent repositories

+ {recentRepoRows.length} +
+ {#if recentRepoRows.length === 0} +
No recent repositories.
+ {:else} +
+ {#each recentRepoRows as repo (repo.path)} +
+ + +
+ {/each} +
+ {/if} +
+ +
+
+

All repositories

+ {allRepoRows.length} +
+ {#if allRepoRows.length === 0} +
Browse for a repository to add it here.
+ {:else} +
+ {#each allRepoRows as repo (repo.path)} +
+ + +
+ {/each} +
+ {/if} +
+
+
+ {:else} + +
+ {/if}
diff --git a/src/app.css b/src/app.css index 22e7bc0..2f0a771 100644 --- a/src/app.css +++ b/src/app.css @@ -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; } diff --git a/src/lib/TitleBar.svelte b/src/lib/TitleBar.svelte index f1b6a4a..28df81b 100644 --- a/src/lib/TitleBar.svelte +++ b/src/lib/TitleBar.svelte @@ -1,7 +1,7 @@