Merge pull request 'Features/repo mangament' (#4) from Features/repo_mangament into master
publish / publish-tauri (, windows-latest) (release) Successful in 9m10s

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-07-02 06:36:27 +00:00
10 changed files with 1440 additions and 115 deletions
+2 -1
View File
@@ -4,4 +4,5 @@
*.log *.log
.idea .idea
.DS_Store .DS_Store
~ ~
.codex*
+152
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,
@@ -341,6 +347,42 @@ pub fn create_branch(
status_for_repo(&repo) status_for_repo(&repo)
} }
#[tauri::command]
pub fn rename_branch(
path: String,
old_branch: String,
new_branch: String,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let old_branch = validate_existing_local_branch_name(&repo, &old_branch)?;
let new_branch = validate_new_branch_name(&repo, &new_branch)?;
run_git(
&repo,
[
"branch",
"-m",
"--",
old_branch.as_str(),
new_branch.as_str(),
],
)?;
status_for_repo(&repo)
}
#[tauri::command]
pub fn delete_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = validate_existing_local_branch_name(&repo, &branch)?;
let status = status_for_repo(&repo)?;
if status.current_branch.as_deref() == Some(branch.as_str()) {
return Err("Der aktuelle Branch kann nicht geloescht werden.".to_string());
}
run_git(&repo, ["branch", "-d", "--", branch.as_str()])?;
status_for_repo(&repo)
}
#[tauri::command] #[tauri::command]
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> { pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
@@ -1314,6 +1356,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() {
@@ -2067,6 +2139,41 @@ fn validate_new_branch_name(repo: &Path, branch: &str) -> Result<String, String>
Ok(normalized) Ok(normalized)
} }
fn validate_existing_local_branch_name(repo: &Path, branch: &str) -> Result<String, String> {
let branch = branch.trim();
if branch.is_empty() {
return Err("Branch-Name darf nicht leer sein.".to_string());
}
let normalized = validate_branch_ref_name(branch)?;
if !ref_exists(repo, &format!("refs/heads/{normalized}"))? {
return Err(format!(
"Lokaler Branch '{normalized}' wurde nicht gefunden."
));
}
Ok(normalized)
}
fn validate_branch_ref_name(branch: &str) -> Result<String, String> {
let output = git_command()
.args(["check-ref-format", "--branch", branch])
.output()
.map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?;
if !output.status.success() {
let details = command_output_details(&output);
return Err(format!("Ungueltiger Branch-Name: {details}"));
}
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(if normalized.is_empty() {
branch.to_string()
} else {
normalized
})
}
fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, String> { fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, String> {
let output = git_command() let output = git_command()
.arg("-C") .arg("-C")
@@ -3480,6 +3587,51 @@ mod tests {
assert!(err.contains("existiert bereits")); assert!(err.contains("existiert bereits"));
} }
#[test]
fn rename_branch_renames_existing_local_branch() {
let repo = init_temp_repo("rename_branch");
commit_initial_file(&repo.path);
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["branch", "feature/old-panel"]);
let status = rename_branch(
repo.path.to_string_lossy().to_string(),
"feature/old-panel".to_string(),
"feature/new-panel".to_string(),
)
.unwrap();
assert_eq!(status.current_branch.as_deref(), Some(current.as_str()));
assert!(
!ref_exists(&repo.path, "refs/heads/feature/old-panel").unwrap(),
"old branch should be gone"
);
assert!(
ref_exists(&repo.path, "refs/heads/feature/new-panel").unwrap(),
"new branch should exist"
);
}
#[test]
fn delete_branch_removes_local_branch_but_rejects_current_branch() {
let repo = init_temp_repo("delete_branch");
commit_initial_file(&repo.path);
let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["branch", "stale"]);
let status =
delete_branch(repo.path.to_string_lossy().to_string(), "stale".to_string()).unwrap();
assert_eq!(status.current_branch.as_deref(), Some(current.as_str()));
assert!(
!ref_exists(&repo.path, "refs/heads/stale").unwrap(),
"deleted branch should be gone"
);
let err = delete_branch(repo.path.to_string_lossy().to_string(), current).unwrap_err();
assert!(err.contains("aktuelle Branch"));
}
#[test] #[test]
fn apply_file_patch_stages_and_discards_selected_changes() { fn apply_file_patch_stages_and_discards_selected_changes() {
let repo = init_temp_repo("apply_file_patch"); let repo = init_temp_repo("apply_file_patch");
+9 -5
View File
@@ -5,11 +5,12 @@ mod git;
use git::{ 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, delete_branch, diff_file_against_working_tree, get_file_patch, get_remote_url, get_status,
list_commits, list_file_history, list_repository_files, merge_branch, open_repository, list_branches, list_commits, list_file_history, list_repository_files, merge_branch,
open_repository_bundle, pull, push, read_conflict, resolve_conflict, resolve_conflict_side, open_repo_in_explorer, open_repository, open_repository_bundle, pull, push, read_conflict,
restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions, rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
stage_files, unstage_files, SearchCancellationState, restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
SearchCancellationState,
}; };
fn main() { fn main() {
@@ -19,10 +20,13 @@ 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,
create_branch, create_branch,
rename_branch,
delete_branch,
stage_files, stage_files,
unstage_files, unstage_files,
restore_files, restore_files,
+453 -50
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";
@@ -16,6 +16,7 @@
import HistoryPanel from "./lib/components/HistoryPanel.svelte"; import HistoryPanel from "./lib/components/HistoryPanel.svelte";
import LinePatchDialog from "./lib/components/LinePatchDialog.svelte"; import LinePatchDialog from "./lib/components/LinePatchDialog.svelte";
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte"; import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte"; import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
import ResolveDialog from "./lib/components/ResolveDialog.svelte"; import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte"; import StatusPanel from "./lib/components/StatusPanel.svelte";
@@ -28,6 +29,7 @@
cancelCodeSearch, cancelCodeSearch,
applyFilePatch, applyFilePatch,
createBranch, createBranch,
deleteBranch,
diffFileAgainstWorkingTree, diffFileAgainstWorkingTree,
compareFileToParent, compareFileToParent,
getStatus, getStatus,
@@ -36,9 +38,11 @@
listFileHistory, listFileHistory,
listRepositoryFiles, listRepositoryFiles,
mergeBranch, mergeBranch,
openRepoInExplorer,
openRepositoryBundle, openRepositoryBundle,
pull, pull,
push, push,
renameBranch,
getRemoteUrl, getRemoteUrl,
credLoad, credLoad,
credSave, credSave,
@@ -81,11 +85,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[] = [];
@@ -102,6 +124,7 @@
let compareTo = ""; let compareTo = "";
let comparison: GitCommitComparison | null = null; let comparison: GitCommitComparison | null = null;
let newBranchCommit: GitCommit | null = null; let newBranchCommit: GitCommit | null = null;
let renameBranchTarget: GitBranchInfo | null = null;
let compareSelectOpen = false; let compareSelectOpen = false;
let compareDialogOpen = false; let compareDialogOpen = false;
let selectedDiffPath = ""; let selectedDiffPath = "";
@@ -147,6 +170,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 +185,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 +214,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 +311,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 +556,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 +583,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 () => {
@@ -453,6 +666,45 @@
}); });
} }
function renameLocalBranch(branch: GitBranchInfo) {
if (!activeRepoPath || branch.remote) return;
renameBranchTarget = branch;
}
async function submitRenameBranch(branchName: string) {
const branch = renameBranchTarget;
const name = branchName.trim();
if (!activeRepoPath || !branch || branch.remote || !name || name === branch.name) return;
await runOperation(`Renaming ${branch.name}`, async () => {
applyStatus(await renameBranch(activeRepoPath, branch.name, name));
renameBranchTarget = null;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function deleteLocalBranch(branch: GitBranchInfo) {
if (!activeRepoPath || branch.remote) return;
if (branch.current) {
errorMessage = "The current branch cannot be deleted.";
return;
}
const confirmed = window.confirm(`Delete local branch "${branch.name}"?\n\nGit will refuse if the branch has unmerged changes.`);
if (!confirmed) return;
await runOperation(`Deleting ${branch.name}`, async () => {
applyStatus(await deleteBranch(activeRepoPath, branch.name));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
function openNewBranchDialog(commit: GitCommit) { function openNewBranchDialog(commit: GitCommit) {
if (!activeRepoPath || isBusy) return; if (!activeRepoPath || isBusy) return;
newBranchCommit = commit; newBranchCommit = commit;
@@ -1059,21 +1311,24 @@
// ── 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;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false; else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog(); else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
} }
function handleWindowContextMenu(event: MouseEvent) {
event.preventDefault();
}
</script> </script>
<svelte:head> <svelte:head>
<title>GitLite</title> <title>GitLite</title>
</svelte:head> </svelte:head>
<svelte:window on:keydown={handleWindowKeydown} /> <svelte:window on:keydown={handleWindowKeydown} on:contextmenu={handleWindowContextMenu} />
<main class="shell"> <main class="shell">
<TitleBar <TitleBar
@@ -1081,7 +1336,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 +1346,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 +1422,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 +1430,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">
@@ -1167,6 +1557,8 @@
onCheckout={checkout} onCheckout={checkout}
onMerge={merge} onMerge={merge}
onCreateBranch={createNewBranch} onCreateBranch={createNewBranch}
onRenameBranch={renameLocalBranch}
onDeleteBranch={deleteLocalBranch}
/> />
<ExplorerPanel <ExplorerPanel
{repoFiles} {repoFiles}
@@ -1255,6 +1647,7 @@
/> />
</aside> </aside>
</section> </section>
{/if}
</div> </div>
</main> </main>
@@ -1315,6 +1708,16 @@
/> />
{/if} {/if}
<!-- Rename a local branch from the branch context menu -->
{#if renameBranchTarget}
<RenameBranchDialog
branch={renameBranchTarget}
{isBusy}
onRename={submitRenameBranch}
onClose={() => { renameBranchTarget = null; }}
/>
{/if}
<!-- Compare: pick the two commits to diff --> <!-- Compare: pick the two commits to diff -->
{#if compareSelectOpen} {#if compareSelectOpen}
<CompareSelectDialog <CompareSelectDialog
+392
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 {
@@ -721,6 +986,8 @@
align-items: center; align-items: center;
gap: 6px; gap: 6px;
} }
.branch-panel { position: relative; }
.branch-create-toggle { .branch-create-toggle {
width: 26px; width: 26px;
min-width: 26px; min-width: 26px;
@@ -827,6 +1094,60 @@
font-size: 12px; font-size: 12px;
} }
.branch-folder-row {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
justify-content: stretch;
gap: 7px;
width: 100%;
min-height: 34px;
padding: 5px 8px;
padding-left: calc(8px + var(--branch-indent, 0px));
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--color-ink-dim);
text-align: left;
}
.branch-folder-row + .branch-folder-row,
.branch-folder-row + .branch-row,
.branch-row + .branch-folder-row {
margin-top: 3px;
}
.branch-folder-row:hover:not(:disabled) {
border-color: var(--color-border-subtle);
background: var(--color-surface-hover);
color: var(--color-ink);
}
.branch-folder-row.current {
border-color: rgba(78,202,118,0.2);
background: rgba(78,202,118,0.08);
}
.branch-folder-row svg { color: var(--color-ink-faint); }
.branch-folder-row.current svg { color: #4eca76; }
.branch-folder-name {
overflow: hidden;
color: var(--color-ink-muted);
font-size: 12.5px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.branch-folder-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
min-height: 18px;
padding: 0 6px;
border-radius: 999px;
color: var(--color-ink-dim);
background: rgba(94,110,156,0.14);
font-size: 10px;
font-weight: 800;
}
.branch-row { .branch-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@@ -834,6 +1155,7 @@
gap: 8px; gap: 8px;
min-height: 46px; min-height: 46px;
padding: 7px 8px; padding: 7px 8px;
padding-left: calc(8px + var(--branch-indent, 0px));
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 8px; border-radius: 8px;
transition: background 120ms, border-color 120ms; transition: background 120ms, border-color 120ms;
@@ -853,6 +1175,57 @@
.branch-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; } .branch-actions { display: flex; align-items: center; justify-content: flex-end; gap: 5px; }
.branch-context-menu {
position: absolute;
z-index: 120;
display: grid;
gap: 2px;
min-width: 184px;
padding: 5px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
box-shadow: 0 18px 50px rgba(0,0,0,0.35);
}
.branch-context-menu button {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
width: 100%;
min-height: 30px;
padding: 6px 8px;
border-color: transparent;
border-radius: 6px;
background: transparent;
color: var(--color-ink-muted);
font-size: 12px;
font-weight: 700;
text-align: left;
}
.branch-context-menu button:hover:not(:disabled) {
border-color: var(--color-border-subtle);
background: rgba(255,255,255,0.06);
color: var(--color-ink);
}
.branch-context-menu button.danger {
color: #ff9aa8;
}
.branch-context-menu button.danger:hover:not(:disabled) {
border-color: rgba(255,92,117,0.34);
background: rgba(255,92,117,0.12);
color: #ffd0d6;
}
.branch-context-menu button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
/* --- Explorer --- */ /* --- Explorer --- */
.explorer-head-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; } .explorer-head-actions { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
@@ -1113,12 +1486,25 @@
max-height: calc(100vh - 32px); max-height: calc(100vh - 32px);
overflow: auto; overflow: auto;
} }
.rename-branch-dialog {
display: block;
width: min(520px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
}
.new-branch-form { .new-branch-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
padding: 16px; padding: 16px;
} }
.rename-branch-form {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
}
.new-branch-target { .new-branch-target {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2380,6 +2766,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}
+324 -56
View File
@@ -1,7 +1,46 @@
<script lang="ts"> <script lang="ts">
import { Check, ChevronDown, ChevronRight, GitBranch, GitMerge, Plus, X } from "@lucide/svelte"; import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Trash2, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types"; import type { GitBranch as GitBranchInfo } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
interface BranchFolderNode {
kind: "folder";
id: string;
name: string;
children: BranchTreeNode[];
branchCount: number;
current: boolean;
folders: Map<string, BranchFolderNode>;
}
interface BranchLeafNode {
kind: "branch";
id: string;
branch: GitBranchInfo;
displayName: string;
}
type BranchRow = BranchFolderRow | BranchLeafRow;
interface BranchFolderRow {
kind: "folder";
id: string;
name: string;
depth: number;
branchCount: number;
current: boolean;
}
interface BranchLeafRow {
kind: "branch";
id: string;
branch: GitBranchInfo;
displayName: string;
depth: number;
scopeLabel: string;
}
interface Props { interface Props {
branches: GitBranchInfo[]; branches: GitBranchInfo[];
localBranches: GitBranchInfo[]; localBranches: GitBranchInfo[];
@@ -11,6 +50,8 @@
onCheckout: (branch: GitBranchInfo) => void; onCheckout: (branch: GitBranchInfo) => void;
onMerge: (branch: GitBranchInfo) => void; onMerge: (branch: GitBranchInfo) => void;
onCreateBranch: (branchName: string) => void | Promise<void>; onCreateBranch: (branchName: string) => void | Promise<void>;
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
} }
let { let {
@@ -22,6 +63,8 @@
onCheckout = () => {}, onCheckout = () => {},
onMerge = () => {}, onMerge = () => {},
onCreateBranch = () => {}, onCreateBranch = () => {},
onRenameBranch = () => {},
onDeleteBranch = () => {},
}: Props = $props(); }: Props = $props();
let localOpen = $state(true); let localOpen = $state(true);
@@ -29,6 +72,118 @@
let createOpen = $state(false); let createOpen = $state(false);
let newBranchName = $state(""); let newBranchName = $state("");
let createInput = $state<HTMLInputElement | null>(null); let createInput = $state<HTMLInputElement | null>(null);
let panelElement = $state<HTMLElement | null>(null);
let contextBranch = $state<GitBranchInfo | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let collapsedBranchFolders = $state<Set<string>>(new Set());
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
let remoteBranchRows = $derived(buildBranchRows("remote", remoteBranches, "remote"));
function createFolder(id: string, name: string): BranchFolderNode {
return {
kind: "folder",
id,
name,
children: [],
branchCount: 0,
current: false,
folders: new Map(),
};
}
function buildBranchRows(scope: string, branchList: GitBranchInfo[], scopeLabel: string): BranchRow[] {
const root = createFolder(`${scope}:root`, "");
for (const branch of branchList) {
const parts = branch.name.split("/").filter(Boolean);
const displayName = parts.length > 0 ? parts[parts.length - 1] : branch.name;
const folderParts = parts.slice(0, -1);
let parent = root;
for (let index = 0; index < folderParts.length; index += 1) {
const folderName = folderParts[index];
const folderPath = folderParts.slice(0, index + 1).join("/");
let folder = parent.folders.get(folderName);
if (!folder) {
folder = createFolder(`${scope}:folder:${folderPath}`, folderName);
parent.folders.set(folderName, folder);
parent.children.push(folder);
}
folder.branchCount += 1;
folder.current ||= branch.current;
parent = folder;
}
parent.children.push({
kind: "branch",
id: `${scope}:branch:${branch.name}`,
branch,
displayName,
});
}
sortBranchNodes(root.children);
const rows: BranchRow[] = [];
flattenBranchNodes(root.children, rows, 0, scopeLabel);
return rows;
}
function sortBranchNodes(nodes: BranchTreeNode[]) {
nodes.sort((left, right) => {
if (left.kind !== right.kind) return left.kind === "folder" ? -1 : 1;
const leftName = left.kind === "folder" ? left.name : left.displayName;
const rightName = right.kind === "folder" ? right.name : right.displayName;
return leftName.localeCompare(rightName, undefined, { sensitivity: "base" });
});
for (const node of nodes) {
if (node.kind === "folder") sortBranchNodes(node.children);
}
}
function flattenBranchNodes(nodes: BranchTreeNode[], rows: BranchRow[], depth: number, scopeLabel: string) {
for (const node of nodes) {
if (node.kind === "folder") {
rows.push({
kind: "folder",
id: node.id,
name: node.name,
depth,
branchCount: node.branchCount,
current: node.current,
});
if (isBranchFolderOpen(node.id)) {
flattenBranchNodes(node.children, rows, depth + 1, scopeLabel);
}
} else {
rows.push({
kind: "branch",
id: node.id,
branch: node.branch,
displayName: node.displayName,
depth,
scopeLabel,
});
}
}
}
function isBranchFolderOpen(id: string) {
return !collapsedBranchFolders.has(id);
}
function toggleBranchFolder(id: string) {
const next = new Set(collapsedBranchFolders);
if (next.has(id)) next.delete(id);
else next.add(id);
collapsedBranchFolders = next;
}
function openCreateForm() { function openCreateForm() {
if (!hasRepository || isBusy) return; if (!hasRepository || isBusy) return;
@@ -57,9 +212,49 @@
if (target?.closest("button")) return; if (target?.closest("button")) return;
onCheckout(branch); onCheckout(branch);
} }
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
event.preventDefault();
event.stopPropagation();
if (isBusy || branch.remote) return;
const rect = panelElement?.getBoundingClientRect();
const rawX = rect ? event.clientX - rect.left : event.offsetX;
const rawY = rect ? event.clientY - rect.top : event.offsetY;
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 92);
contextBranch = branch;
contextMenuX = Math.max(8, Math.min(rawX, maxX));
contextMenuY = Math.max(8, Math.min(rawY, maxY));
}
function closeBranchContextMenu() {
contextBranch = null;
}
async function renameContextBranch() {
const branch = contextBranch;
if (!branch || isBusy) return;
closeBranchContextMenu();
await onRenameBranch(branch);
}
async function deleteContextBranch() {
const branch = contextBranch;
if (!branch || branch.current || isBusy) return;
closeBranchContextMenu();
await onDeleteBranch(branch);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeBranchContextMenu();
}
</script> </script>
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches"> <svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} />
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
<div class="section-head"> <div class="section-head">
<div> <div>
<span class="eyebrow">Branches</span> <span class="eyebrow">Branches</span>
@@ -127,34 +322,58 @@
{#if localBranches.length === 0} {#if localBranches.length === 0}
<div class="branch-empty">No local branches.</div> <div class="branch-empty">No local branches.</div>
{:else} {:else}
{#each localBranches as branch (branch.name)} {#each localBranchRows as row (row.id)}
<article {#if row.kind === "folder"}
class="branch-row" <button
class:current={branch.current} class="branch-folder-row"
ondblclick={(event) => checkoutOnDoubleClick(event, branch)} class:current={row.current}
title={branch.current ? "Current branch" : "Double-click to checkout"} style={`--branch-indent: ${row.depth * 16}px;`}
> type="button"
<div class="branch-info"> onclick={() => toggleBranchFolder(row.id)}
<GitBranch size={16} aria-hidden="true" /> aria-expanded={isBranchFolderOpen(row.id)}
<div> title={`${row.name} (${row.branchCount})`}
<strong>{branch.name}</strong> >
<span>local</span> {#if isBranchFolderOpen(row.id)}
<ChevronDown size={14} aria-hidden="true" />
<FolderOpen size={15} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
<Folder size={15} aria-hidden="true" />
{/if}
<span class="branch-folder-name">{row.name}</span>
<span class="branch-folder-count">{row.branchCount}</span>
</button>
{:else}
<article
class="branch-row"
class:current={row.branch.current}
style={`--branch-indent: ${row.depth * 16}px;`}
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
title={row.branch.current ? "Current branch" : row.branch.name}
>
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{row.displayName}</strong>
<span>{row.scopeLabel}</span>
</div>
</div> </div>
</div> {#if row.branch.current}
{#if branch.current} <span class="pill pill-active">Current</span>
<span class="pill pill-active">Current</span> {:else}
{:else} <div class="branch-actions">
<div class="branch-actions"> <button class="btn-sm" type="button" onclick={() => onCheckout(row.branch)} disabled={isBusy}>
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}> Checkout
Checkout </button>
</button> <button class="btn-sm" type="button" onclick={() => onMerge(row.branch)} disabled={isBusy} title="Merge into current">
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current"> <GitMerge size={15} aria-hidden="true" />
<GitMerge size={15} aria-hidden="true" /> Merge
Merge </button>
</button> </div>
</div> {/if}
{/if} </article>
</article> {/if}
{/each} {/each}
{/if} {/if}
{/if} {/if}
@@ -180,38 +399,87 @@
{#if remoteBranches.length === 0} {#if remoteBranches.length === 0}
<div class="branch-empty">No remote branches.</div> <div class="branch-empty">No remote branches.</div>
{:else} {:else}
{#each remoteBranches as branch (branch.name)} {#each remoteBranchRows as row (row.id)}
<article {#if row.kind === "folder"}
class="branch-row" <button
class:current={branch.current} class="branch-folder-row"
ondblclick={(event) => checkoutOnDoubleClick(event, branch)} class:current={row.current}
title={branch.current ? "Current branch" : "Double-click to checkout"} style={`--branch-indent: ${row.depth * 16}px;`}
> type="button"
<div class="branch-info"> onclick={() => toggleBranchFolder(row.id)}
<GitBranch size={16} aria-hidden="true" /> aria-expanded={isBranchFolderOpen(row.id)}
<div> title={`${row.name} (${row.branchCount})`}
<strong>{branch.name}</strong> >
<span>remote</span> {#if isBranchFolderOpen(row.id)}
<ChevronDown size={14} aria-hidden="true" />
<FolderOpen size={15} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
<Folder size={15} aria-hidden="true" />
{/if}
<span class="branch-folder-name">{row.name}</span>
<span class="branch-folder-count">{row.branchCount}</span>
</button>
{:else}
<article
class="branch-row"
class:current={row.branch.current}
style={`--branch-indent: ${row.depth * 16}px;`}
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
title={row.branch.current ? "Current branch" : row.branch.name}
>
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{row.displayName}</strong>
<span>{row.scopeLabel}</span>
</div>
</div> </div>
</div> {#if row.branch.current}
{#if branch.current} <span class="pill pill-active">Current</span>
<span class="pill pill-active">Current</span> {:else}
{:else} <div class="branch-actions">
<div class="branch-actions"> <button class="btn-sm" type="button" onclick={() => onCheckout(row.branch)} disabled={isBusy}>
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}> Checkout
Checkout </button>
</button> <button class="btn-sm" type="button" onclick={() => onMerge(row.branch)} disabled={isBusy} title="Merge into current">
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current"> <GitMerge size={15} aria-hidden="true" />
<GitMerge size={15} aria-hidden="true" /> Merge
Merge </button>
</button> </div>
</div> {/if}
{/if} </article>
</article> {/if}
{/each} {/each}
{/if} {/if}
{/if} {/if}
</div> </div>
</div> </div>
{/if} {/if}
{#if contextBranch}
<div
class="branch-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextBranch.name}`}
>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
<Pencil size={14} aria-hidden="true" />
Rename
</button>
<button
class="danger"
type="button"
role="menuitem"
onclick={deleteContextBranch}
disabled={isBusy || contextBranch.current}
title={contextBranch.current ? "Current branch cannot be deleted" : "Delete local branch"}
>
<Trash2 size={14} aria-hidden="true" />
Delete
</button>
</div>
{/if}
</section> </section>
@@ -0,0 +1,77 @@
<script lang="ts">
import { GitBranch, LoaderCircle, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types";
interface Props {
branch: GitBranchInfo;
isBusy: boolean;
onRename: (name: string) => void;
onClose: () => void;
}
let {
branch,
isBusy = false,
onRename = () => {},
onClose = () => {},
}: Props = $props();
let name = $state("");
$effect(() => {
name = branch.name;
});
function submit(event: SubmitEvent) {
event.preventDefault();
const value = name.trim();
if (!value || value === branch.name) return;
onRename(value);
}
</script>
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog rename-branch-dialog" role="dialog" aria-modal="true" aria-label="Rename branch" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Rename branch</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{branch.name}</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
<form class="rename-branch-form" onsubmit={submit}>
<label class="new-branch-field">
<span>Branch name</span>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={name}
autocomplete="off"
spellcheck="false"
disabled={isBusy}
autofocus
/>
</label>
<div class="new-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
Cancel
</button>
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0 || name.trim() === branch.name}>
{#if isBusy}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<GitBranch size={16} aria-hidden="true" />
{/if}
Rename
</button>
</div>
</form>
</div>
</div>
+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" />
+16
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 });
} }
@@ -41,6 +45,18 @@ export function createBranch(
return invoke<GitStatus>("create_branch", { path, branch, startPoint: startPoint ?? null }); return invoke<GitStatus>("create_branch", { path, branch, startPoint: startPoint ?? null });
} }
export function renameBranch(
path: string,
oldBranch: string,
newBranch: string,
): Promise<GitStatus> {
return invoke<GitStatus>("rename_branch", { path, oldBranch, newBranch });
}
export function deleteBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("delete_branch", { path, branch });
}
export function stageFiles(path: string, files: string[]): Promise<GitStatus> { export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
return invoke<GitStatus>("stage_files", { path, files }); return invoke<GitStatus>("stage_files", { path, files });
} }