Compare commits

...
11 Commits
Author SHA1 Message Date
Christoph Brandau 0bbd5ce1e8 Limit Tauri bundle targets to NSIS
publish / publish-tauri (, windows-latest) (release) Successful in 9m11s
2026-07-01 13:03:45 +02:00
Christoph 50d9232084 Update version to 2026.7.1 2026-07-01 11:47:09 +02:00
Christoph 357a0b7c5f Update version to 2026.07.1
publish / publish-tauri (, windows-latest) (release) Failing after 7m21s
2026-07-01 11:42:53 +02:00
Christoph ab54b9b6b2 Merge pull request 'Features/extend search' (#2) from Features/extendSearch into master
publish / publish-tauri (, windows-latest) (release) Failing after 2m24s
Reviewed-on: #2
2026-07-01 09:40:53 +00:00
Christoph Brandau fe392d38bf Optimize repository loading and enhance Git UI
Consolidate multiple Git backend calls into a single bundle to reduce
overhead when opening and auto-refreshing repositories. This significantly
improves performance by fetching status, branches, commits, and files
in one optimized operation, instead of re-running 'git rev-parse' and
'git status' multiple times.

Also, streamline commit history loading by fetching file changes inline
via 'git log --name-status -z', eliminating expensive per-commit
'git diff-tree' processes.

Additionally, introduce the ability to create new branches from a
specific commit in the history and refactor the commit comparison
feature into a dedicated dialog. The status panel now displays concise
file names.
2026-07-01 11:39:22 +02:00
Christoph Brandau 628e2f7c0b Add repository opening loading overlay
Introduce a full-screen, animated overlay to provide visual feedback
when the application is opening a repository. This prevents perceived
delays and informs the user about the ongoing operation.

Also, enhance build performance and binary size by configuring
Cargo profiles:
- Development: Enable incremental compilation, use line-tables-only
  debug info, and optimize third-party dependencies to speed up
  rebuilds and improve runtime snappiness during development.
- Release: Apply aggressive optimizations (opt-level 3, thin LTO,
  single codegen unit, stripping) for smaller, faster binaries.
2026-07-01 10:40:22 +02:00
Christoph Brandau 1c7c53ddf9 Add branch creation and file history search
Implement the ability to create new local branches directly from the application's UI. This includes a new backend command to handle branch creation with validation and a frontend form in the branch panel.

Additionally, extend the global search dialog to include a "Files" tab. Users can now search for files by name or path within the repository. Selecting a file in the search results displays its full commit history, with options to diff the file at a specific commit or restore it to that version.
2026-07-01 10:07:59 +02:00
Christoph 900159a3a9 Update version to 0.1.0 2026-06-30 20:36:09 +02:00
Christoph d800417d6d Merge pull request 'Improve global search: Add diffing from results with query highlighting' (#1) from FixSearch into master
publish / publish-tauri (, windows-latest) (release) Successful in 6m34s
Reviewed-on: #1
2026-06-30 18:33:59 +00:00
Christoph Brandau 405f302db9 Improve global search: Add diffing from results with query highlighting
Users can now open a diff for a specific search hit directly from the global search results. The original search query will be highlighted within the diff view, making it easier to locate the relevant changes.

Additionally, add `--no-textconv` to git commands used in search and diff operations. This prevents failures when git encounters binary files that textconv drivers might otherwise attempt to process.
2026-06-30 20:32:59 +02:00
Christoph 2cc1d34fc3 Update version to 0.0.6 2026-06-30 19:19:22 +02:00
20 changed files with 1699 additions and 230 deletions
+6 -1
View File
@@ -23,7 +23,12 @@
"Bash(npx vite *)",
"Bash(cargo tree *)",
"Bash(jobs)",
"Bash(npx svelte-check *)"
"Bash(npx svelte-check *)",
"Bash(git log *)",
"Bash(xxd)",
"Bash(python3 -)",
"Bash(echo \"exit: $?\")",
"Bash(grep -n \"input,\\\\|select,\\\\|input {\\\\|select {\\\\|.repo-form input\\\\|input:focus\\\\|::placeholder\" src/app.css)"
]
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "tauri-git-lite",
"version": "0.0.5",
"version": "2026.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tauri-git-lite",
"version": "0.0.5",
"version": "2026.7.1",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "git-lite",
"version": "0.0.5",
"version": "2026.7.1",
"private": true,
"type": "module",
"scripts": {
+21
View File
@@ -18,3 +18,24 @@ tauri-build = { version = "2", features = [] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"
# ── Build profiles ───────────────────────────────────────────────────────────
# Dev: keep incremental compilation and emit only line-table debug info instead
# of full debuginfo. This cuts link time noticeably (linking is the slow part of
# a `tauri dev` recompile) while still giving panic backtraces with line numbers.
[profile.dev]
incremental = true
debug = "line-tables-only"
# Optimize third-party dependencies once (they are cached), so the running dev
# build is snappy without slowing down rebuilds of our own crate.
[profile.dev.package."*"]
opt-level = 2
# Release: smaller binary (also shrinks the auto-updater download) without
# regressing runtime performance of the code-search feature.
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true
+212 -6
View File
@@ -208,6 +208,41 @@ pub fn open_repository(path: String) -> Result<GitStatus, String> {
status_for_repo(&repo)
}
#[derive(Debug, Clone, Serialize)]
pub struct RepositoryBundle {
pub status: GitStatus,
pub branches: Vec<GitBranch>,
pub commits: Vec<GitCommit>,
pub files: Vec<GitRepositoryFile>,
}
/// Opens a repository and gathers everything the UI needs in a single call.
///
/// Runs on a blocking thread (so the UI/overlay stays responsive) and resolves
/// the repo and its status only once, instead of the previous four separate
/// commands that each re-ran `git rev-parse` and `git status`.
#[tauri::command]
pub async fn open_repository_bundle(
path: String,
commit_limit: Option<u32>,
) -> Result<RepositoryBundle, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<RepositoryBundle, String> {
let repo = resolve_repo(&path)?;
let status = status_for_repo(&repo)?;
let branches = branches_for_repo(&repo)?;
let commits = commits_for_repo(&repo, commit_limit)?;
let files = repository_files_with_status(&repo, &status)?;
Ok(RepositoryBundle {
status,
branches,
commits,
files,
})
})
.await
.map_err(|err| format!("Repository konnte nicht geladen werden: {err}"))?
}
#[tauri::command]
pub fn get_status(path: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
@@ -217,8 +252,12 @@ pub fn get_status(path: String) -> Result<GitStatus, String> {
#[tauri::command]
pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
let repo = resolve_repo(&path)?;
branches_for_repo(&repo)
}
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
let output = run_git(
&repo,
repo,
[
"for-each-ref",
"--format=%(refname)\t%(HEAD)",
@@ -281,6 +320,27 @@ pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String
status_for_repo(&repo)
}
#[tauri::command]
pub fn create_branch(
path: String,
branch: String,
start_point: Option<String>,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = validate_new_branch_name(&repo, &branch)?;
match start_point {
Some(start) if !start.trim().is_empty() => {
// Resolve the requested commit first so we fail clearly if it is gone.
let start = verify_commit(&repo, &start)?;
run_git(&repo, ["checkout", "-b", branch.as_str(), start.as_str()])?;
}
_ => {
run_git(&repo, ["checkout", "-b", branch.as_str()])?;
}
}
status_for_repo(&repo)
}
#[tauri::command]
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
@@ -578,23 +638,34 @@ pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
#[tauri::command]
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
let repo = resolve_repo(&path)?;
if verify_commit(&repo, "HEAD").is_err() {
commits_for_repo(&repo, limit)
}
fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
if verify_commit(repo, "HEAD").is_err() {
return Ok(Vec::new());
}
let limit = limit.unwrap_or(100).clamp(1, 500).to_string();
// Fetch the per-commit changed files inline via `--name-status` in a single
// `git log` process, instead of spawning one `git diff-tree` per commit
// (which was ~100 extra processes and the main cost of opening a repo).
let output = run_git(
&repo,
repo,
[
"log",
"--decorate=short",
"--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1e",
"--name-status",
"-M",
"-z",
"--root",
"--pretty=format:%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1f",
"-n",
limit.as_str(),
],
)?;
parse_commit_log(&repo, &output)
parse_commit_log_inline(&output)
}
#[tauri::command]
@@ -1234,6 +1305,13 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
fn repository_files(repo: &Path) -> Result<Vec<GitRepositoryFile>, String> {
let status = status_for_repo(repo)?;
repository_files_with_status(repo, &status)
}
fn repository_files_with_status(
repo: &Path,
status: &GitStatus,
) -> Result<Vec<GitRepositoryFile>, String> {
let mut files = BTreeMap::<String, GitRepositoryFile>::new();
let tracked_output = run_git(repo, ["ls-files", "-z", "--cached", "--deleted"])?;
@@ -1338,6 +1416,10 @@ fn search_candidate_commits(
OsString::from("--all"),
OsString::from("--reverse"),
OsString::from("--format=%H"),
// Skip textconv diff drivers so git does not extract binary files
// (e.g. .docx / Office temp "~$" lock files) to temp files, which can
// fail with "unsupported filetype" and abort the whole search.
OsString::from("--no-textconv"),
];
if !case_sensitive {
args.push(OsString::from("-i"));
@@ -1447,7 +1529,7 @@ fn first_added_match_line(
check_search_cancelled(cancellation)?;
let output = run_git_with_paths_cancellable(
repo,
&["diff", "--unified=0", parent, commit],
&["diff", "--no-textconv", "--unified=0", parent, commit],
&[file.to_string()],
cancellation,
"Git-Diff fuer Suchtreffer fehlgeschlagen",
@@ -1568,6 +1650,71 @@ fn commit_search_metadata(repo: &Path, commit: &str) -> Result<GitSearchCommitMe
})
}
/// Parses `git log --name-status -z` output where each commit's changed files
/// are embedded inline (see `commits_for_repo`), so no per-commit git process is
/// needed. Record layout: `\x1e` then eight `\x1f`-separated header fields, then
/// git's newline, then the NUL-separated name-status entries.
fn parse_commit_log_inline(output: &[u8]) -> Result<Vec<GitCommit>, String> {
const FIELD_SEPARATOR: u8 = 0x1f;
const RECORD_SEPARATOR: u8 = 0x1e;
let mut commits = Vec::new();
for record in output.split(|byte| *byte == RECORD_SEPARATOR) {
// Skip the empty leading chunk and any stray separators left by `-z`.
if record
.iter()
.all(|&byte| matches!(byte, 0 | b'\n' | b'\r' | b' ' | b'\t'))
{
continue;
}
let parts: Vec<&[u8]> = record.splitn(9, |byte| *byte == FIELD_SEPARATOR).collect();
if parts.len() < 8 {
return Err(format!(
"Unerwarteter Git-Log-Eintrag: {}",
String::from_utf8_lossy(record)
));
}
// Field 8 (if present) holds the name-status list, preceded by the newline
// git inserts between the pretty-format output and the diff.
let mut files_bytes: &[u8] = parts.get(8).copied().unwrap_or(&[]);
while let Some((&first, rest)) = files_bytes.split_first() {
if matches!(first, b'\n' | b'\r') {
files_bytes = rest;
} else {
break;
}
}
let refs = String::from_utf8_lossy(parts[5])
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(ToString::to_string)
.collect();
let parents = String::from_utf8_lossy(parts[6])
.split_whitespace()
.map(ToString::to_string)
.collect();
commits.push(GitCommit {
hash: String::from_utf8_lossy(parts[0]).trim().to_string(),
short_hash: String::from_utf8_lossy(parts[1]).trim().to_string(),
author_name: String::from_utf8_lossy(parts[2]).to_string(),
author_email: String::from_utf8_lossy(parts[3]).to_string(),
date: String::from_utf8_lossy(parts[4]).trim().to_string(),
refs,
parents,
summary: String::from_utf8_lossy(parts[7]).to_string(),
files: parse_commit_files(files_bytes)?,
});
}
Ok(commits)
}
fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, String> {
const FIELD_SEPARATOR: char = '\x1f';
const RECORD_SEPARATOR: char = '\x1e';
@@ -1835,6 +1982,38 @@ fn local_branch_name_for_remote(remote_branch: &str) -> Option<&str> {
.filter(|local| !local.is_empty())
}
fn validate_new_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 output = git_command()
.arg("-C")
.arg(repo)
.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();
let normalized = if normalized.is_empty() {
branch.to_string()
} else {
normalized
};
if ref_exists(repo, &format!("refs/heads/{normalized}"))? {
return Err(format!("Branch '{normalized}' existiert bereits."));
}
Ok(normalized)
}
fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, String> {
let output = git_command()
.arg("-C")
@@ -3182,6 +3361,33 @@ mod tests {
assert_eq!(plan, CheckoutPlan::Local("feature/demo".to_string()));
}
#[test]
fn create_branch_creates_and_checks_out_local_branch() {
let repo = init_temp_repo("create_branch");
commit_initial_file(&repo.path);
let status = create_branch(
repo.path.to_string_lossy().to_string(),
"feature/new-panel".to_string(),
None,
)
.unwrap();
assert_eq!(status.current_branch.as_deref(), Some("feature/new-panel"));
assert!(
ref_exists(&repo.path, "refs/heads/feature/new-panel").unwrap(),
"new branch should exist"
);
let err = create_branch(
repo.path.to_string_lossy().to_string(),
"feature/new-panel".to_string(),
None,
)
.unwrap_err();
assert!(err.contains("existiert bereits"));
}
#[test]
fn restore_to_commit_resets_branch_to_selected_commit() {
let repo = init_temp_repo("restore_to_commit");
+8 -5
View File
@@ -4,11 +4,12 @@ mod git;
use git::{
cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head,
compare_file_to_parent, cred_delete, cred_load, cred_save, diff_file_against_working_tree,
get_remote_url, get_status, list_branches, list_commits, list_file_history,
list_repository_files, merge_branch, open_repository, 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,
compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save,
diff_file_against_working_tree, 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,
};
@@ -22,6 +23,7 @@ fn main() {
get_status,
list_branches,
checkout_branch,
create_branch,
stage_files,
unstage_files,
restore_files,
@@ -33,6 +35,7 @@ fn main() {
restore_file_from_commit,
merge_branch,
list_repository_files,
open_repository_bundle,
list_file_history,
compare_commits,
compare_file_to_head,
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GitLite",
"version": "0.0.5",
"version": "2026.7.1",
"identifier": "com.git-lite",
"build": {
"beforeDevCommand": "npm run dev",
@@ -28,7 +28,7 @@
},
"bundle": {
"active": true,
"targets": "all",
"targets": ["nsis"],
"icon": ["icons/icon.ico"],
"createUpdaterArtifacts": true,
"windows": {
+169 -50
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
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";
@@ -8,12 +8,14 @@
import BranchPanel from "./lib/components/BranchPanel.svelte";
import CommitPanel from "./lib/components/CommitPanel.svelte";
import CompareDialog from "./lib/components/CompareDialog.svelte";
import ComparePanel from "./lib/components/ComparePanel.svelte";
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte";
import UpdateToast from "./lib/components/UpdateToast.svelte";
@@ -23,6 +25,7 @@
commit,
compareCommits,
cancelCodeSearch,
createBranch,
diffFileAgainstWorkingTree,
compareFileToParent,
getStatus,
@@ -31,7 +34,7 @@
listFileHistory,
listRepositoryFiles,
mergeBranch,
openRepository,
openRepositoryBundle,
pull,
push,
getRemoteUrl,
@@ -94,10 +97,14 @@
let compareFrom = "";
let compareTo = "";
let comparison: GitCommitComparison | null = null;
let newBranchCommit: GitCommit | null = null;
let compareSelectOpen = false;
let compareDialogOpen = false;
let selectedDiffPath = "";
let diffHighlightQuery = "";
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
let globalSearchOpen = false;
let lastSearchQuery = "";
let globalSearchResults: GitSearchHit[] = [];
let globalSearchBusy = false;
let globalSearchError = "";
@@ -130,6 +137,8 @@
$: isBusy = operation.length > 0;
$: hasRepository = activeRepoPath.length > 0 && status !== null;
$: openingRepo = operation === "Opening repository";
$: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? "";
$: changedFiles = status?.files ?? [];
$: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0;
$: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0;
@@ -161,15 +170,18 @@
}
async function autoRefreshTick() {
if (!autoRefreshEnabled || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || globalSearchOpen) return;
if (!autoRefreshEnabled || !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.
const nextStatus = await getStatus(activeRepoPath);
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
applyStatus(nextStatus);
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
// Something changed — reload branches, commits and files in one bundled call.
const bundle = await openRepositoryBundle(activeRepoPath, 100);
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
await refreshFileHistory(activeRepoPath);
} catch { /* ignore transient errors */ } finally {
autoRefreshInFlight = false;
@@ -315,12 +327,12 @@
// ── Refresh helpers ────────────────────────────────────────────────────────
async function refreshBranchList(path = activeRepoPath) {
branches = await listBranches(path);
async function refreshBranchList(path = activeRepoPath, prefetched?: GitBranchInfo[]) {
branches = prefetched ?? (await listBranches(path));
}
async function refreshCommitHistory(path = activeRepoPath) {
commits = await listCommits(path, 100);
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
commits = prefetched ?? (await listCommits(path, 100));
const hashes = new Set(commits.map((c) => c.hash));
if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
if (compareTo && !hashes.has(compareTo)) compareTo = "";
@@ -332,8 +344,8 @@
}
}
async function refreshExplorerFiles(path = activeRepoPath) {
repoFiles = await listRepositoryFiles(path);
async function refreshExplorerFiles(path = activeRepoPath, prefetched?: GitRepositoryFile[]) {
repoFiles = prefetched ?? (await listRepositoryFiles(path));
const folderPaths = allExplorerFolderPaths(repoFiles);
expandedExplorerPaths = new Set([...expandedExplorerPaths].filter((folder) => folderPaths.has(folder)));
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
@@ -355,20 +367,28 @@
repoPath = path;
await runOperation("Opening repository", async () => {
const nextStatus = await openRepository(path);
applyStatus(nextStatus);
// Paint the loading overlay before the (potentially slow) git enumeration
// starts — otherwise the first paint is deferred until the bundle resolves
// and the overlay appears to "come late".
await tick();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
// 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);
applyStatus(bundle.status);
branches = []; commits = []; repoFiles = [];
selectedExplorerPath = ""; selectedExplorerKind = "file";
expandedExplorerPaths = new Set(); expandedCommitHashes = new Set();
fileHistory = []; compareFrom = ""; compareTo = "";
comparison = null; compareDialogOpen = false; selectedDiffPath = ""; pendingRestoreFile = null;
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 = {};
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
});
}
@@ -411,6 +431,37 @@
});
}
async function createNewBranch(branchName: string) {
const name = branchName.trim();
if (!activeRepoPath || !name) return;
await runOperation(`Creating ${name}`, async () => {
applyStatus(await createBranch(activeRepoPath, name));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
function openNewBranchDialog(commit: GitCommit) {
if (!activeRepoPath || isBusy) return;
newBranchCommit = commit;
}
async function createBranchFromCommit(branchName: string) {
const target = newBranchCommit;
const name = branchName.trim();
if (!activeRepoPath || !target || !name) return;
await runOperation(`Creating ${name}`, async () => {
applyStatus(await createBranch(activeRepoPath, name, target.hash));
newBranchCommit = null;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function merge(branch: GitBranchInfo) {
if (!activeRepoPath || branch.current) return;
await runOperation(`Merging ${branch.name}`, async () => {
@@ -717,6 +768,17 @@
expandedExplorerPaths = new Set();
}
function explorerParentFolders(path: string): string[] {
const parts = normalizeExplorerPath(path).split("/").filter(Boolean);
const folders: string[] = [];
let current = "";
for (let index = 0; index < parts.length - 1; index++) {
current = current ? `${current}/${parts[index]}` : parts[index];
folders.push(current);
}
return folders;
}
async function selectExplorerNode(node: ExplorerNode) {
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
selectedExplorerPath = node.path;
@@ -726,6 +788,17 @@
});
}
async function selectFileFromSearch(file: GitRepositoryFile) {
if (!activeRepoPath) return;
selectedExplorerPath = file.path;
selectedExplorerKind = "file";
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
await runOperation(`Loading ${file.path} history`, async () => {
await refreshFileHistory(activeRepoPath, file.path);
});
}
async function restoreSelectedFileFromCommit(target: GitCommit) {
if (!activeRepoPath || !selectedExplorerPath) return;
const kind = selectedExplorerKind === "folder" ? "folder" : "file";
@@ -740,13 +813,20 @@
// ── Compare ────────────────────────────────────────────────────────────────
function openCompareSelect() {
if (!hasRepository) return;
compareSelectOpen = true;
}
async function compareSelectedCommits() {
if (!canCompare) return;
await runOperation("Comparing commits", async () => {
const result = await compareCommits(activeRepoPath, compareFrom, compareTo);
comparison = result;
selectedDiffPath = result.files[0]?.path ?? "";
diffHighlightQuery = "";
pendingRestoreFile = null;
compareSelectOpen = false;
compareDialogOpen = true;
});
}
@@ -757,13 +837,22 @@
const result = await diffFileAgainstWorkingTree(activeRepoPath, historyCommit.hash, selectedExplorerPath);
comparison = result;
selectedDiffPath = result.files[0]?.path ?? selectedExplorerPath;
diffHighlightQuery = "";
pendingRestoreFile = null;
compareDialogOpen = true;
});
}
function openCompareDialog() {
if (comparison) compareDialogOpen = true;
async function diffSearchHit(hit: GitSearchHit) {
if (!activeRepoPath) return;
await runOperation(`Diffing ${hit.file}`, async () => {
const result = await diffFileAgainstWorkingTree(activeRepoPath, hit.commit_hash, hit.file);
comparison = result;
selectedDiffPath = result.files[0]?.path ?? hit.file;
diffHighlightQuery = lastSearchQuery;
pendingRestoreFile = null;
compareDialogOpen = true;
});
}
function closeCompareDialog() {
@@ -785,6 +874,7 @@
if (!activeRepoPath || globalSearchBusy) return;
const searchId = `search-${Date.now()}-${Math.random().toString(36).slice(2)}`;
globalSearchId = searchId;
lastSearchQuery = query;
globalSearchBusy = true;
globalSearchError = "";
globalSearchResults = [];
@@ -892,6 +982,8 @@
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
}
</script>
@@ -917,6 +1009,7 @@
onPush={pushRepo}
onRefresh={refreshRepo}
onSearch={() => { globalSearchOpen = true; }}
onCompare={openCompareSelect}
onToggleAutoRefresh={toggleAutoRefresh}
/>
@@ -992,6 +1085,7 @@
{isBusy}
onCheckout={checkout}
onMerge={merge}
onCreateBranch={createNewBranch}
/>
<ExplorerPanel
{repoFiles}
@@ -1007,7 +1101,7 @@
/>
</aside>
<!-- Center: summary + status + commit + compare -->
<!-- Center: summary + status + commit -->
<section class="main-panel" aria-label="Repository status">
<div class="repo-summary">
<div class="repo-meta">
@@ -1050,21 +1144,6 @@
onCommitMessageChange={(msg) => { commitMessage = msg; }}
/>
</div>
<ComparePanel
{commits}
{hasRepository}
{isBusy}
{compareFrom}
{compareTo}
{canCompare}
{comparison}
{operation}
onCompareFromChange={(val) => { compareFrom = val; }}
onCompareToChange={(val) => { compareTo = val; }}
onCompare={compareSelectedCommits}
onOpenDialog={openCompareDialog}
/>
</section>
<!-- Right sidebar: commit graph + file history -->
@@ -1076,6 +1155,7 @@
{expandedCommitHashes}
onRestoreCommit={restoreCommit}
onPreviewCommitFile={previewCommitFileFromHistory}
onCreateBranchFromCommit={openNewBranchDialog}
onToggleCommitFiles={(hash) => {
const next = new Set(expandedCommitHashes);
if (next.has(hash)) next.delete(hash); else next.add(hash);
@@ -1109,19 +1189,6 @@
/>
{/if}
<!-- Compare diff dialog -->
{#if compareDialogOpen && comparison}
<CompareDialog
{comparison}
{selectedDiffPath}
{isBusy}
restoreLabel={pendingRestoreFile ? "Restore file" : ""}
onClose={closeCompareDialog}
onRestore={restorePreviewedCommitFile}
onSelectFile={selectDiffFile}
/>
{/if}
{#if globalSearchOpen}
<GlobalSearchDialog
{hasRepository}
@@ -1129,9 +1196,56 @@
isSearching={globalSearchBusy}
error={globalSearchError}
results={globalSearchResults}
files={repoFiles}
fileHistory={fileHistory}
selectedFilePath={selectedExplorerPath}
onClose={closeGlobalSearchDialog}
onSearch={runGlobalSearch}
onCancel={cancelGlobalSearch}
onDiff={diffSearchHit}
onSelectFile={selectFileFromSearch}
onFileHistoryDiff={diffSelectedFileFromCommit}
onFileHistoryRestore={restoreSelectedFileFromCommit}
/>
{/if}
<!-- Create a branch from a specific commit in the history -->
{#if newBranchCommit}
<NewBranchDialog
commit={newBranchCommit}
{isBusy}
onCreate={createBranchFromCommit}
onClose={() => { newBranchCommit = null; }}
/>
{/if}
<!-- Compare: pick the two commits to diff -->
{#if compareSelectOpen}
<CompareSelectDialog
{commits}
{compareFrom}
{compareTo}
{canCompare}
{isBusy}
{operation}
onCompareFromChange={(val) => { compareFrom = val; }}
onCompareToChange={(val) => { compareTo = val; }}
onCompare={compareSelectedCommits}
onClose={() => { compareSelectOpen = false; }}
/>
{/if}
<!-- Compare diff dialog (rendered last so it overlays the search dialog when opened from a hit) -->
{#if compareDialogOpen && comparison}
<CompareDialog
{comparison}
{selectedDiffPath}
{isBusy}
highlightQuery={diffHighlightQuery}
restoreLabel={pendingRestoreFile ? "Restore file" : ""}
onClose={closeCompareDialog}
onRestore={restorePreviewedCommitFile}
onSelectFile={selectDiffFile}
/>
{/if}
@@ -1146,6 +1260,11 @@
/>
{/if}
<!-- Full-screen overlay while a repository is being opened -->
{#if openingRepo}
<RepoLoadingOverlay repoName={repoDisplayName} />
{/if}
<!-- Conflict resolve dialog -->
{#if resolveDialogOpen}
<ResolveDialog
+381 -5
View File
@@ -601,7 +601,7 @@
.main-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
min-height: 0;
border: 1px solid var(--color-border);
@@ -664,7 +664,8 @@
.top-section {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) minmax(210px, auto);
min-height: 0;
gap: 8px;
padding: 8px;
@@ -715,6 +716,88 @@
/* --- Branch list --- */
.branch-head-actions {
display: flex;
align-items: center;
gap: 6px;
}
.branch-create-toggle {
width: 26px;
min-width: 26px;
min-height: 26px;
padding: 0;
border-color: rgba(65,209,255,0.2);
border-radius: 7px;
color: var(--color-ink-dim);
background: rgba(65,209,255,0.06);
}
.branch-create-toggle:hover:not(:disabled) {
border-color: rgba(65,209,255,0.45);
color: #ffffff;
background: rgba(65,209,255,0.13);
}
.branch-list { gap: 8px; }
.branch-create-form {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 7px;
margin-bottom: 8px;
padding: 7px 8px;
border: 1px solid rgba(65,209,255,0.22);
border-radius: 8px;
background: linear-gradient(90deg, rgba(65,209,255,0.08), rgba(100,108,255,0.07));
}
.branch-create-form svg { color: var(--color-accent); }
.branch-create-form input {
height: 30px;
min-width: 0;
border-radius: 7px;
font-family: var(--font-mono);
font-size: 12px;
}
.branch-create-action {
width: 28px;
min-width: 28px;
min-height: 28px;
padding: 0;
border-radius: 7px;
}
.branch-create-action.confirm {
border-color: rgba(78,202,118,0.34);
color: #6ee090;
background: rgba(78,202,118,0.11);
}
.branch-group { display: grid; gap: 4px; min-width: 0; }
.branch-group + .branch-group { margin-top: 8px; }
.branch-group-toggle {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
justify-content: stretch;
width: 100%;
min-height: 28px;
padding: 4px 6px;
border-color: transparent;
border-radius: 7px;
background: rgba(255,255,255,0.02);
color: var(--color-ink-faint);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.07em;
text-align: left;
text-transform: uppercase;
}
.branch-group-toggle:hover:not(:disabled) {
border-color: var(--color-border-subtle);
background: rgba(255,255,255,0.05);
color: var(--color-ink-muted);
}
.branch-group-toggle svg { color: var(--color-ink-faint); }
.branch-group-label {
display: flex;
align-items: center;
@@ -738,6 +821,12 @@
font-size: 10px;
}
.branch-empty {
padding: 8px 10px;
color: var(--color-ink-faint);
font-size: 12px;
}
.branch-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -1005,6 +1094,55 @@
width: min(1180px, calc(100vw - 32px));
height: min(840px, calc(100vh - 32px));
}
.compare-select-dialog {
display: block;
width: min(720px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
}
.new-branch-dialog {
display: block;
width: min(520px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
}
.new-branch-form {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
}
.new-branch-target {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: var(--color-surface-raised);
}
.new-branch-summary {
overflow: hidden;
color: var(--color-ink-dim);
text-overflow: ellipsis;
white-space: nowrap;
}
.new-branch-field { display: grid; gap: 6px; }
.new-branch-field span {
color: var(--color-ink-faint);
font-size: 10.5px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.new-branch-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
.dialog-header > div:first-child { min-width: 0; }
@@ -1123,6 +1261,19 @@
.split-cell.add { background: rgba(78,202,118,0.09); color: #5dd88a; }
.split-cell.empty { background: rgba(0,0,0,0.06); }
/* Search-hit highlight: amber, distinct from add (green) / del (red).
Higher specificity so it overrides the add/del backgrounds on a matched line. */
.split-diff .split-cell.match {
background: rgba(240,182,72,0.22);
color: #f3c969;
box-shadow: inset 2px 0 0 rgba(240,182,72,0.9);
}
.split-diff .split-num.match {
background: rgba(240,182,72,0.2);
color: rgba(240,182,72,0.9);
border-right-color: rgba(240,182,72,0.35);
}
.split-col-headers {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -1162,11 +1313,38 @@
.global-search-body {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr);
min-width: 0;
min-height: 0;
}
.global-search-tabs {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 12px;
border-bottom: 1px solid var(--color-border-subtle);
background: rgba(7, 8, 16, 0.34);
}
.global-search-tab {
min-height: 30px;
padding: 0 12px;
border-color: transparent;
color: var(--color-ink-dim);
background: transparent;
font-size: 12px;
font-weight: 800;
}
.global-search-tab:hover:not(:disabled) {
border-color: rgba(65,209,255,0.22);
background: rgba(65,209,255,0.07);
}
.global-search-tab.active {
border-color: rgba(65,209,255,0.36);
color: #ffffff;
background: linear-gradient(135deg, rgba(100,108,255,0.26), rgba(65,209,255,0.1));
}
.global-search-form {
display: grid;
gap: 10px;
@@ -1193,6 +1371,11 @@
white-space: pre;
overflow: auto;
}
.global-search-query input {
height: 40px;
font-family: var(--font-mono);
font-size: 13px;
}
.global-search-options {
display: grid;
@@ -1254,11 +1437,21 @@
}
.search-hit-top {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
min-width: 0;
}
.search-hit-diff {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 9px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
white-space: nowrap;
}
.search-hit-top .hash {
padding: 2px 7px;
border: 1px solid rgba(90,140,248,0.22);
@@ -1319,6 +1512,185 @@
white-space: pre;
}
.file-search-form { grid-template-columns: minmax(0, 1fr); }
.file-search-results {
padding: 0;
overflow: hidden;
}
.file-search-split {
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(320px, 0.75fr);
height: 100%;
min-width: 0;
min-height: 0;
}
.file-search-column {
min-width: 0;
min-height: 0;
overflow: auto;
padding: 10px;
border-right: 1px solid var(--color-border-subtle);
}
.file-search-list { display: grid; gap: 7px; }
.file-search-hit {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
width: 100%;
min-height: 48px;
padding: 8px 10px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: var(--color-surface-raised);
text-align: left;
}
.file-search-hit:hover:not(:disabled) {
border-color: rgba(65,209,255,0.3);
background: var(--color-surface-hover);
}
.file-search-hit.active {
border-color: rgba(90,140,248,0.42);
background: rgba(90,140,248,0.11);
}
.file-search-icon {
display: grid;
place-items: center;
width: 22px;
height: 22px;
color: var(--color-accent);
}
.file-search-icon .language-icon {
display: block;
width: 17px;
height: 17px;
fill: currentColor;
}
.file-search-icon .language-icon path { fill: currentColor; }
.file-search-main {
display: grid;
gap: 2px;
min-width: 0;
}
.file-search-main strong {
min-width: 0;
overflow: hidden;
color: var(--color-ink);
font-family: var(--font-mono);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-search-main span {
min-width: 0;
overflow: hidden;
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 11.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-search-action {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--color-accent);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.file-search-history {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
min-height: 0;
overflow: hidden;
background: rgba(7, 8, 16, 0.18);
}
.file-search-history-head {
display: grid;
gap: 2px;
min-width: 0;
padding: 10px 12px;
border-bottom: 1px solid var(--color-border-subtle);
background: rgba(0,0,0,0.12);
}
.file-search-history-head span {
color: var(--color-ink-faint);
font-size: 10.5px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.file-search-history-head strong {
min-width: 0;
overflow: hidden;
color: var(--color-ink);
font-family: var(--font-mono);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-search-history-list {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
min-height: 0;
overflow: auto;
padding: 10px;
}
.file-search-history-row {
display: grid;
gap: 8px;
min-width: 0;
padding: 9px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: var(--color-surface-raised);
}
.file-search-history-main {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 5px 8px;
min-width: 0;
}
.file-search-history-main .hash {
padding: 2px 7px;
border: 1px solid rgba(90,140,248,0.22);
border-radius: 6px;
color: var(--color-accent);
background: rgba(90,140,248,0.13);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 800;
}
.file-search-history-main strong {
min-width: 0;
overflow: hidden;
color: var(--color-ink);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-search-history-main > span:last-child {
grid-column: 1 / -1;
overflow: hidden;
color: var(--color-ink-faint);
font-size: 11.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-search-history-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 6px;
}
/* --- Credential dialog --- */
.cred-card {
@@ -1860,7 +2232,6 @@
@media (min-width: 1800px) {
.workspace { grid-template-columns: 320px minmax(0, 1fr) 680px; }
.top-section { grid-template-columns: minmax(0, 1fr) 380px; }
}
@media (max-width: 1400px) {
@@ -1906,6 +2277,11 @@
.dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); }
.compare-dialog .dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); }
.global-search-options { grid-template-columns: 1fr; }
.file-search-split { grid-template-columns: 1fr; grid-template-rows: minmax(150px, 0.9fr) minmax(220px, 1fr); }
.file-search-column { border-right: none; border-bottom: 1px solid var(--color-border-subtle); }
.file-search-hit { grid-template-columns: auto minmax(0, 1fr); align-items: start; }
.file-search-hit .status-badge,
.file-search-action { grid-column: 2; justify-self: start; }
.dialog-files { border-right: none; border-bottom: 1px solid var(--color-border-subtle); }
.branch-actions { flex-direction: row; justify-content: flex-start; }
.tb-action-label { display: none; }
+13 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Download, GitBranch, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
import { Download, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
export let branch: string = "";
export let ahead: number = 0;
@@ -16,6 +16,7 @@
export let onPush: () => void = () => {};
export let onRefresh: () => void = () => {};
export let onSearch: () => void = () => {};
export let onCompare: () => void = () => {};
export let onToggleAutoRefresh: () => void = () => {};
const win = getCurrentWindow();
@@ -92,6 +93,17 @@
<span class="tb-action-label">Search</span>
</button>
<button
class="tb-action"
onclick={onCompare}
disabled={!hasRepository || isBusy}
title="Compare commits"
aria-label="Compare commits"
>
<GitCompare size={14} aria-hidden="true" />
<span class="tb-action-label">Compare</span>
</button>
<button
class="tb-action"
onclick={onPull}
+156 -63
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { GitBranch, GitMerge } from "@lucide/svelte";
import { Check, ChevronDown, ChevronRight, GitBranch, GitMerge, Plus, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types";
interface Props {
@@ -10,6 +10,7 @@
isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void;
onMerge: (branch: GitBranchInfo) => void;
onCreateBranch: (branchName: string) => void | Promise<void>;
}
let {
@@ -20,7 +21,35 @@
isBusy = false,
onCheckout = () => {},
onMerge = () => {},
onCreateBranch = () => {},
}: Props = $props();
let localOpen = $state(true);
let remoteOpen = $state(false);
let createOpen = $state(false);
let newBranchName = $state("");
let createInput = $state<HTMLInputElement | null>(null);
function openCreateForm() {
if (!hasRepository || isBusy) return;
createOpen = true;
queueMicrotask(() => createInput?.focus());
}
function closeCreateForm() {
createOpen = false;
newBranchName = "";
}
async function submitCreate(event: SubmitEvent) {
event.preventDefault();
const value = newBranchName.trim();
if (!value || !hasRepository || isBusy) return;
await onCreateBranch(value);
newBranchName = "";
createOpen = false;
localOpen = true;
}
</script>
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
@@ -29,7 +58,19 @@
<span class="eyebrow">Branches</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Refs</h2>
</div>
<span class="pill pill-count">{branches.length}</span>
<div class="branch-head-actions">
<button
class="branch-create-toggle"
type="button"
onclick={openCreateForm}
disabled={!hasRepository || isBusy}
title="Create new branch"
aria-label="Create new branch"
>
<Plus size={14} aria-hidden="true" />
</button>
<span class="pill pill-count">{branches.length}</span>
</div>
</div>
{#if !hasRepository}
@@ -37,71 +78,123 @@
{:else if branches.length === 0}
<p class="blank-state">No branches returned.</p>
{:else}
<div class="overflow-auto p-2 flex flex-col gap-0">
{#if localBranches.length > 0}
<div class="branch-group-label">
<span>Local</span>
<span class="branch-group-count">{localBranches.length}</span>
</div>
{#each localBranches as branch (branch.name)}
{#snippet branchCard()}
<article class="branch-row" class:current={branch.current}>
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{branch.name}</strong>
<span>local</span>
</div>
</div>
{#if branch.current}
<span class="pill pill-active">Current</span>
{:else}
<div class="branch-actions">
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
Checkout
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/snippet}
{@render branchCard()}
{/each}
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
{#if createOpen}
<form class="branch-create-form" onsubmit={submitCreate}>
<GitBranch size={15} aria-hidden="true" />
<input
bind:this={createInput}
bind:value={newBranchName}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="new-branch-name"
aria-label="New branch name"
/>
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newBranchName.trim().length === 0} title="Create branch">
<Check size={14} aria-hidden="true" />
</button>
<button class="branch-create-action" type="button" onclick={closeCreateForm} disabled={isBusy} title="Cancel">
<X size={14} aria-hidden="true" />
</button>
</form>
{/if}
{#if remoteBranches.length > 0}
<div class="branch-group-label" style="margin-top: {localBranches.length > 0 ? '12px' : '0'}">
<div class="branch-group">
<button
class="branch-group-toggle"
type="button"
onclick={() => { localOpen = !localOpen; }}
aria-expanded={localOpen}
>
{#if localOpen}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
<span>Local</span>
<span class="branch-group-count">{localBranches.length}</span>
</button>
{#if localOpen}
{#if localBranches.length === 0}
<div class="branch-empty">No local branches.</div>
{:else}
{#each localBranches as branch (branch.name)}
<article class="branch-row" class:current={branch.current}>
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{branch.name}</strong>
<span>local</span>
</div>
</div>
{#if branch.current}
<span class="pill pill-active">Current</span>
{:else}
<div class="branch-actions">
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
Checkout
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/each}
{/if}
{/if}
</div>
<div class="branch-group">
<button
class="branch-group-toggle"
type="button"
onclick={() => { remoteOpen = !remoteOpen; }}
aria-expanded={remoteOpen}
>
{#if remoteOpen}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
<span>Remote</span>
<span class="branch-group-count">{remoteBranches.length}</span>
</div>
{#each remoteBranches as branch (branch.name)}
<article class="branch-row" class:current={branch.current}>
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{branch.name}</strong>
<span>remote</span>
</div>
</div>
{#if branch.current}
<span class="pill pill-active">Current</span>
{:else}
<div class="branch-actions">
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
Checkout
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/each}
{/if}
</button>
{#if remoteOpen}
{#if remoteBranches.length === 0}
<div class="branch-empty">No remote branches.</div>
{:else}
{#each remoteBranches as branch (branch.name)}
<article class="branch-row" class:current={branch.current}>
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{branch.name}</strong>
<span>remote</span>
</div>
</div>
{#if branch.current}
<span class="pill pill-active">Current</span>
{:else}
<div class="branch-actions">
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
Checkout
</button>
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/each}
{/if}
{/if}
</div>
</div>
{/if}
</section>
+20 -4
View File
@@ -15,6 +15,8 @@
selectedDiffPath: string;
isBusy: boolean;
restoreLabel?: string;
/** When opened from a search hit, the term to highlight on matching lines. */
highlightQuery?: string;
onClose: () => void;
onRestore?: () => void;
onSelectFile: (file: GitDiffFile) => void;
@@ -25,11 +27,25 @@
selectedDiffPath = "",
isBusy = false,
restoreLabel = "",
highlightQuery = "",
onClose = () => {},
onRestore = undefined,
onSelectFile = () => {},
}: Props = $props();
// Needle = first non-empty line of the search query, lowercased for matching.
let highlightNeedle = $derived(
highlightQuery
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0)
?.toLowerCase() ?? ""
);
function isMatch(text?: string): boolean {
return highlightNeedle.length > 0 && !!text && text.toLowerCase().includes(highlightNeedle);
}
let beforePane = $state<HTMLDivElement | null>(null);
let afterPane = $state<HTMLDivElement | null>(null);
let isSyncingSplitScroll = false;
@@ -259,8 +275,8 @@
{#if row.type === "span"}
<div class="split-span split-{row.kind}">{row.text}</div>
{:else}
<div class="split-num" class:del={row.leftKind === "del"} class:empty={row.leftKind === "empty"}>{row.leftNum ?? ""}</div>
<div class="split-cell" class:del={row.leftKind === "del"} class:empty={row.leftKind === "empty"}>{row.leftText ?? " "}</div>
<div class="split-num" class:del={row.leftKind === "del"} class:empty={row.leftKind === "empty"} class:match={isMatch(row.leftText)}>{row.leftNum ?? ""}</div>
<div class="split-cell" class:del={row.leftKind === "del"} class:empty={row.leftKind === "empty"} class:match={isMatch(row.leftText)}>{row.leftText ?? " "}</div>
{/if}
{/each}
</div>
@@ -276,8 +292,8 @@
{#if row.type === "span"}
<div class="split-span split-{row.kind}">{row.text}</div>
{:else}
<div class="split-num" class:add={row.rightKind === "add"} class:empty={row.rightKind === "empty"}>{row.rightNum ?? ""}</div>
<div class="split-cell" class:add={row.rightKind === "add"} class:empty={row.rightKind === "empty"}>{row.rightText ?? " "}</div>
<div class="split-num" class:add={row.rightKind === "add"} class:empty={row.rightKind === "empty"} class:match={isMatch(row.rightText)}>{row.rightNum ?? ""}</div>
<div class="split-cell" class:add={row.rightKind === "add"} class:empty={row.rightKind === "empty"} class:match={isMatch(row.rightText)}>{row.rightText ?? " "}</div>
{/if}
{/each}
</div>
@@ -0,0 +1,108 @@
<script lang="ts">
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte";
import type { GitCommit } from "../types";
interface Props {
commits: GitCommit[];
compareFrom: string;
compareTo: string;
canCompare: boolean;
isBusy: boolean;
operation: string;
onCompareFromChange: (val: string) => void;
onCompareToChange: (val: string) => void;
onCompare: () => void;
onClose: () => void;
}
let {
commits = [],
compareFrom = "",
compareTo = "",
canCompare = false,
isBusy = false,
operation = "",
onCompareFromChange = () => {},
onCompareToChange = () => {},
onCompare = () => {},
onClose = () => {},
}: Props = $props();
function commitOptionLabel(item: GitCommit): string {
return `${item.short_hash} - ${item.summary}`;
}
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
onCompare();
}
</script>
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Compare</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Select commits</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
{#if commits.length < 2}
<div class="blank-state">At least two commits are needed to compare.</div>
{:else}
<form class="compare-form" onsubmit={handleSubmit}>
<label class="compare-field">
<span>From (older)</span>
<select
value={compareFrom}
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
disabled={isBusy}
>
<option value="" disabled>Select a commit</option>
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
</select>
</label>
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
<label class="compare-field">
<span>To (newer)</span>
<select
value={compareTo}
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
disabled={isBusy}
>
<option value="" disabled>Select a commit</option>
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
</select>
</label>
<button class="btn-primary" type="submit" disabled={!canCompare}>
{#if operation === "Comparing commits"}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<GitCompare size={16} aria-hidden="true" />
{/if}
Compare
</button>
</form>
{#if compareFrom && compareTo && compareFrom === compareTo}
<div class="blank-state">Select two different commits to compare.</div>
{:else}
<div class="blank-state">Pick two commits and run a comparison.</div>
{/if}
{/if}
</div>
</div>
+310 -88
View File
@@ -1,6 +1,21 @@
<script lang="ts">
import { CalendarDays, FileCode, LoaderCircle, Search, User, X } from "@lucide/svelte";
import type { GitSearchHit } from "../types";
import {
CalendarDays,
FileCode,
FileText,
GitCompare,
History,
LoaderCircle,
RotateCcw,
Search,
User,
X,
} from "@lucide/svelte";
import { languageIconForPath } from "../languageIcons";
import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types";
import LanguageIcon from "./LanguageIcon.svelte";
type SearchTab = "code" | "files";
interface Props {
hasRepository: boolean;
@@ -8,9 +23,16 @@
isSearching: boolean;
error: string;
results: GitSearchHit[];
files: GitRepositoryFile[];
fileHistory: GitCommit[];
selectedFilePath: string;
onClose: () => void;
onSearch: (query: string, caseSensitive: boolean, limit: number) => void | Promise<void>;
onCancel: () => void | Promise<void>;
onDiff: (hit: GitSearchHit) => void | Promise<void>;
onSelectFile: (file: GitRepositoryFile) => void | Promise<void>;
onFileHistoryDiff: (commit: GitCommit) => void | Promise<void>;
onFileHistoryRestore: (commit: GitCommit) => void | Promise<void>;
}
let {
@@ -19,16 +41,28 @@
isSearching = false,
error = "",
results = [],
files = [],
fileHistory = [],
selectedFilePath = "",
onClose = () => {},
onSearch = () => {},
onCancel = () => {},
onDiff = () => {},
onSelectFile = () => {},
onFileHistoryDiff = () => {},
onFileHistoryRestore = () => {},
}: Props = $props();
let activeTab = $state<SearchTab>("code");
let query = $state("");
let caseSensitive = $state(false);
let limit = $state(250);
let searchedQuery = $state("");
let searched = $state(false);
let fileQuery = $state("");
const fileSearchActive = $derived(fileQuery.trim().length > 0);
const fileSearchResults = $derived(filterFiles(files, fileQuery, 200));
function submit(event?: SubmitEvent) {
event?.preventDefault();
@@ -54,6 +88,42 @@
function displayPath(hit: GitSearchHit): string {
return hit.old_file ? `${hit.old_file} -> ${hit.file}` : hit.file;
}
function fileName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function folderName(path: string): string {
const parts = path.split(/[\\/]/).filter(Boolean);
parts.pop();
return parts.length > 0 ? parts.join("/") : "Repository root";
}
function filterFiles(source: GitRepositoryFile[], value: string, maxResults: number): GitRepositoryFile[] {
const terms = value
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
if (terms.length === 0) return [];
return source
.filter((file) => {
const path = file.path.toLowerCase();
const name = fileName(file.path).toLowerCase();
return terms.every((term) => path.includes(term) || name.includes(term));
})
.sort((a, b) => {
const aName = fileName(a.path).toLowerCase();
const bName = fileName(b.path).toLowerCase();
const first = terms[0] ?? "";
const aStarts = aName.startsWith(first) ? 0 : 1;
const bStarts = bName.startsWith(first) ? 0 : 1;
if (aStarts !== bStarts) return aStarts - bStarts;
return a.path.localeCompare(b.path);
})
.slice(0, maxResults);
}
</script>
<div
@@ -61,11 +131,11 @@
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global code search" tabindex="-1">
<div class="dialog global-search-dialog" role="dialog" aria-modal="true" aria-label="Global search" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Global search</span>
<h2 class="dialog-title">Find where code was introduced</h2>
<h2 class="dialog-title">{activeTab === "code" ? "Find where code was introduced" : "Find file history"}</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
@@ -73,102 +143,254 @@
</header>
<div class="global-search-body">
<form class="global-search-form" onsubmit={submit}>
<label class="global-search-query">
<span>String or function</span>
<textarea
bind:value={query}
onkeydown={handleKeydown}
disabled={!hasRepository || isBusy || isSearching}
spellcheck="false"
placeholder={"Paste a string, symbol, or full function body..."}
></textarea>
</label>
<div class="global-search-tabs" role="tablist" aria-label="Search mode">
<button
class="global-search-tab"
class:active={activeTab === "code"}
type="button"
role="tab"
aria-selected={activeTab === "code"}
onclick={() => { activeTab = "code"; }}
>
<Search size={14} aria-hidden="true" />
Code
</button>
<button
class="global-search-tab"
class:active={activeTab === "files"}
type="button"
role="tab"
aria-selected={activeTab === "files"}
onclick={() => { activeTab = "files"; }}
>
<FileText size={14} aria-hidden="true" />
Files
</button>
</div>
<div class="global-search-options">
<label class="check-row">
<input type="checkbox" bind:checked={caseSensitive} disabled={!hasRepository || isBusy || isSearching} />
<span>Exact case</span>
{#if activeTab === "code"}
<form class="global-search-form" onsubmit={submit}>
<label class="global-search-query">
<span>String or function</span>
<textarea
bind:value={query}
onkeydown={handleKeydown}
disabled={!hasRepository || isBusy || isSearching}
spellcheck="false"
placeholder={"Paste a string, symbol, or full function body..."}
></textarea>
</label>
<label class="search-limit">
<span>Results</span>
<select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}>
<option value={100}>100</option>
<option value={250}>250</option>
<option value={500}>500</option>
<option value={1000}>1000</option>
</select>
</label>
<div class="global-search-options">
<label class="check-row">
<input type="checkbox" bind:checked={caseSensitive} disabled={!hasRepository || isBusy || isSearching} />
<span>Exact case</span>
</label>
<button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}>
{#if isSearching}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Search size={16} aria-hidden="true" />
{/if}
Search
</button>
<label class="search-limit">
<span>Results</span>
<select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}>
<option value={100}>100</option>
<option value={250}>250</option>
<option value={500}>500</option>
<option value={1000}>1000</option>
</select>
</label>
{#if isSearching}
<button class="btn-secondary search-cancel" type="button" onclick={onCancel}>
<X size={16} aria-hidden="true" />
Cancel
<button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}>
{#if isSearching}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Search size={16} aria-hidden="true" />
{/if}
Search
</button>
{#if isSearching}
<button class="btn-secondary search-cancel" type="button" onclick={onCancel}>
<X size={16} aria-hidden="true" />
Cancel
</button>
{/if}
</div>
</form>
<section class="global-search-results" aria-live="polite">
{#if !hasRepository}
<div class="blank-state">Open a repository first.</div>
{:else if isSearching}
<div class="blank-state">
<LoaderCircle class="spin" size={20} aria-hidden="true" />
Searching all branches...
</div>
{:else if error}
<div class="blank-state search-error">{error}</div>
{:else if !searched}
<div class="blank-state">Search a string or paste a complete function to find where it was added.</div>
{:else if results.length === 0}
<div class="blank-state">No introduction found for "{searchedQuery}".</div>
{:else}
<div class="search-result-head">
<strong>{results.length}</strong>
<span>{results.length === 1 ? "introduction" : "introductions"} found for "{searchedQuery}"</span>
</div>
<div class="search-hit-list">
{#each results as hit (`${hit.commit_hash}:${hit.file}:${hit.line_number ?? 0}`)}
<article class="search-hit">
<header class="search-hit-top">
<span class="hash">{hit.short_hash}</span>
<strong title={hit.summary}>{hit.summary || "No commit message"}</strong>
{#if hit.matches_added > 1}
<span class="pill pill-active">+{hit.matches_added} matches</span>
{/if}
<button
class="btn-secondary search-hit-diff"
type="button"
disabled={isBusy}
title={`Compare this version of ${hit.file} with the current version`}
onclick={() => onDiff(hit)}
>
<GitCompare size={14} aria-hidden="true" />
DIFF
</button>
</header>
<div class="search-hit-meta">
<span><User size={12} aria-hidden="true" />{hit.author_name || "Unknown author"}</span>
<span><CalendarDays size={12} aria-hidden="true" />{formatCommitDate(hit.date)}</span>
</div>
<div class="search-hit-file" title={displayPath(hit)}>
<FileCode size={14} aria-hidden="true" />
<span>{displayPath(hit)}</span>
{#if hit.line_number}
<strong>:{hit.line_number}</strong>
{/if}
</div>
<pre class="search-hit-line">{hit.line || searchedQuery}</pre>
</article>
{/each}
</div>
{/if}
</div>
</form>
</section>
{:else}
<form class="global-search-form file-search-form" onsubmit={(event) => event.preventDefault()}>
<label class="global-search-query">
<span>File name or path</span>
<input
bind:value={fileQuery}
disabled={!hasRepository || isBusy}
spellcheck="false"
autocomplete="off"
placeholder="Search files, folders, extensions..."
/>
</label>
</form>
<section class="global-search-results" aria-live="polite">
{#if !hasRepository}
<div class="blank-state">Open a repository first.</div>
{:else if isSearching}
<div class="blank-state">
<LoaderCircle class="spin" size={20} aria-hidden="true" />
Searching all branches...
</div>
{:else if error}
<div class="blank-state search-error">{error}</div>
{:else if !searched}
<div class="blank-state">Search a string or paste a complete function to find where it was added.</div>
{:else if results.length === 0}
<div class="blank-state">No introduction found for "{searchedQuery}".</div>
{:else}
<div class="search-result-head">
<strong>{results.length}</strong>
<span>{results.length === 1 ? "introduction" : "introductions"} found for "{searchedQuery}"</span>
</div>
<section class="global-search-results file-search-results" aria-live="polite">
{#if !hasRepository}
<div class="blank-state">Open a repository first.</div>
{:else if files.length === 0}
<div class="blank-state">No files loaded for this repository.</div>
{:else}
<div class="file-search-split">
<div class="file-search-column">
{#if !fileSearchActive}
<div class="blank-state">Search a file name or path, then choose a result to show its history.</div>
{:else if fileSearchResults.length === 0}
<div class="blank-state">No file found for "{fileQuery.trim()}".</div>
{:else}
<div class="search-result-head">
<strong>{fileSearchResults.length}</strong>
<span>{fileSearchResults.length === 1 ? "file" : "files"} found for "{fileQuery.trim()}"</span>
</div>
<div class="search-hit-list">
{#each results as hit (`${hit.commit_hash}:${hit.file}:${hit.line_number ?? 0}`)}
<article class="search-hit">
<header class="search-hit-top">
<span class="hash">{hit.short_hash}</span>
<strong title={hit.summary}>{hit.summary || "No commit message"}</strong>
{#if hit.matches_added > 1}
<span class="pill pill-active">+{hit.matches_added} matches</span>
{/if}
<div class="file-search-list">
{#each fileSearchResults as file (file.path)}
{@const languageIcon = languageIconForPath(file.path)}
<button
class="file-search-hit"
class:active={selectedFilePath === file.path}
type="button"
disabled={isBusy || isSearching}
title={`Show history for ${file.path}`}
onclick={() => onSelectFile(file)}
>
<span class="file-search-icon">
{#if languageIcon}
<LanguageIcon icon={languageIcon.icon} title={languageIcon.title} />
{:else}
<FileText size={16} aria-hidden="true" />
{/if}
</span>
<span class="file-search-main">
<strong>{fileName(file.path)}</strong>
<span>{folderName(file.path)}</span>
</span>
{#if file.status}
<small class={`status-badge ${file.status}`}>{file.status}</small>
{:else if !file.tracked}
<small class="status-badge untracked">untracked</small>
{/if}
<span class="file-search-action">
<History size={14} aria-hidden="true" />
History
</span>
</button>
{/each}
</div>
{/if}
</div>
<aside class="file-search-history" aria-label="Selected file history">
<header class="file-search-history-head">
<span>History</span>
<strong title={selectedFilePath}>{selectedFilePath ? fileName(selectedFilePath) : "No file selected"}</strong>
</header>
<div class="search-hit-meta">
<span><User size={12} aria-hidden="true" />{hit.author_name || "Unknown author"}</span>
<span><CalendarDays size={12} aria-hidden="true" />{formatCommitDate(hit.date)}</span>
</div>
{#if !selectedFilePath}
<div class="blank-state">Select a file result to load its commit history.</div>
{:else if isBusy}
<div class="blank-state">
<LoaderCircle class="spin" size={18} aria-hidden="true" />
Loading history...
</div>
{:else if fileHistory.length === 0}
<div class="blank-state">No history returned for this file.</div>
{:else}
<div class="file-search-history-list">
{#each fileHistory as commit (commit.hash)}
<article class="file-search-history-row">
<div class="file-search-history-main">
<span class="hash">{commit.short_hash}</span>
<strong title={commit.summary}>{commit.summary || "No commit message"}</strong>
<span>{commit.author_name || "Unknown author"} - {formatCommitDate(commit.date)}</span>
</div>
<div class="search-hit-file" title={displayPath(hit)}>
<FileCode size={14} aria-hidden="true" />
<span>{displayPath(hit)}</span>
{#if hit.line_number}
<strong>:{hit.line_number}</strong>
{/if}
</div>
<pre class="search-hit-line">{hit.line || searchedQuery}</pre>
</article>
{/each}
</div>
{/if}
</section>
<div class="file-search-history-actions">
<button class="btn-sm" type="button" onclick={() => onFileHistoryDiff(commit)} disabled={isBusy}>
<GitCompare size={14} aria-hidden="true" />
Diff
</button>
<button class="btn-sm" type="button" onclick={() => onFileHistoryRestore(commit)} disabled={isBusy}>
<RotateCcw size={14} aria-hidden="true" />
Restore
</button>
</div>
</article>
{/each}
</div>
{/if}
</aside>
</div>
{/if}
</section>
{/if}
</div>
</div>
</div>
+7 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { ChevronDown, ChevronRight, RotateCcw } from "@lucide/svelte";
import { ChevronDown, ChevronRight, GitBranch, RotateCcw } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment {
@@ -29,6 +29,7 @@
onRestoreCommit: (commit: GitCommit) => void;
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
onToggleCommitFiles: (hash: string) => void;
onCreateBranchFromCommit: (commit: GitCommit) => void;
}
let {
@@ -39,6 +40,7 @@
onRestoreCommit = () => {},
onPreviewCommitFile = () => {},
onToggleCommitFiles = () => {},
onCreateBranchFromCommit = () => {},
}: Props = $props();
function laneColor(col: number): string {
@@ -220,6 +222,10 @@
<div class="commit-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons">
<button class="btn-sm" type="button" onclick={() => onCreateBranchFromCommit(item)} disabled={isBusy} title="Create a new branch from this commit">
<GitBranch size={15} aria-hidden="true" />
Branch
</button>
<button class="btn-sm" type="button" onclick={() => onRestoreCommit(item)} disabled={isBusy} title="Reset current branch to this commit">
<RotateCcw size={15} aria-hidden="true" />
Restore
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import { GitBranch, LoaderCircle, X } from "@lucide/svelte";
import type { GitCommit } from "../types";
interface Props {
commit: GitCommit;
isBusy: boolean;
onCreate: (name: string) => void;
onClose: () => void;
}
let {
commit,
isBusy = false,
onCreate = () => {},
onClose = () => {},
}: Props = $props();
let name = $state("");
function submit(event: SubmitEvent) {
event.preventDefault();
const value = name.trim();
if (!value) return;
onCreate(value);
}
</script>
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog new-branch-dialog" role="dialog" aria-modal="true" aria-label="Create branch from commit" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">New branch</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">From commit</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
<form class="new-branch-form" onsubmit={submit}>
<div class="new-branch-target">
<span class="hash">{commit.short_hash}</span>
<span class="new-branch-summary" title={commit.summary}>{commit.summary}</span>
</div>
<label class="new-branch-field">
<span>Branch name</span>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={name}
autocomplete="off"
spellcheck="false"
placeholder="feature/my-branch"
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}>
{#if isBusy}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<GitBranch size={16} aria-hidden="true" />
{/if}
Create branch
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,175 @@
<script lang="ts">
export let label = "Repository wird geöffnet";
export let repoName = "";
</script>
<div class="repo-loading" role="status" aria-live="polite">
<div class="repo-loading-card">
<!-- Animated git graph: nodes light up in sequence while a branch draws itself -->
<svg class="git-graph" viewBox="0 0 120 120" aria-hidden="true">
<defs>
<linearGradient id="gl-line" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#646cff" />
<stop offset="100%" stop-color="#41d1ff" />
</linearGradient>
</defs>
<!-- rotating dashed ring -->
<circle class="ring" cx="60" cy="60" r="52" />
<!-- main trunk -->
<path class="trunk" d="M42 22 L42 98" />
<!-- branch forking off and merging back -->
<path class="branch" d="M42 44 C42 62, 82 58, 82 76 L82 88" />
<!-- commit nodes -->
<circle class="node n1" cx="42" cy="30" r="6" />
<circle class="node n2" cx="42" cy="60" r="6" />
<circle class="node n3" cx="82" cy="88" r="6" />
<circle class="node n4" cx="42" cy="90" r="6" />
</svg>
<div class="repo-loading-text">
<span class="repo-loading-label">{label}</span>
{#if repoName}
<span class="repo-loading-name" title={repoName}>{repoName}</span>
{/if}
</div>
<div class="repo-loading-bar"><span></span></div>
</div>
</div>
<style>
.repo-loading {
position: fixed;
inset: 0;
z-index: 400;
display: grid;
place-items: center;
background: rgba(6, 6, 14, 0.72);
backdrop-filter: blur(8px);
animation: overlay-in 180ms ease;
}
.repo-loading-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 34px 46px;
border: 1px solid var(--color-border);
border-radius: 18px;
background: var(--color-surface-raised);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
}
.git-graph {
width: 118px;
height: 118px;
overflow: visible;
}
.git-graph .ring {
fill: none;
stroke: rgba(100, 108, 255, 0.22);
stroke-width: 2;
stroke-dasharray: 26 18;
transform-origin: 60px 60px;
animation: ring-spin 5s linear infinite;
}
.git-graph .trunk,
.git-graph .branch {
fill: none;
stroke: url(#gl-line);
stroke-width: 4;
stroke-linecap: round;
}
.git-graph .branch {
stroke-dasharray: 90;
stroke-dashoffset: 90;
animation: branch-draw 2.4s ease-in-out infinite;
}
.git-graph .node {
fill: var(--color-surface-alt);
stroke: url(#gl-line);
stroke-width: 4;
animation: node-pulse 2.4s ease-in-out infinite;
}
.git-graph .n1 { animation-delay: 0s; }
.git-graph .n2 { animation-delay: 0.5s; }
.git-graph .n3 { animation-delay: 1s; }
.git-graph .n4 { animation-delay: 1.5s; }
.repo-loading-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
}
.repo-loading-label {
color: var(--color-ink);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.01em;
}
.repo-loading-name {
max-width: 260px;
overflow: hidden;
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.repo-loading-bar {
position: relative;
width: 180px;
height: 3px;
overflow: hidden;
border-radius: 3px;
background: rgba(100, 108, 255, 0.16);
}
.repo-loading-bar span {
position: absolute;
inset: 0;
width: 40%;
border-radius: 3px;
background: linear-gradient(90deg, transparent, var(--color-accent), transparent);
animation: bar-slide 1.3s ease-in-out infinite;
}
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes ring-spin { to { transform: rotate(360deg); } }
@keyframes bar-slide {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
@keyframes branch-draw {
0% { stroke-dashoffset: 90; opacity: 0.35; }
45% { stroke-dashoffset: 0; opacity: 1; }
100% { stroke-dashoffset: 0; opacity: 1; }
}
@keyframes node-pulse {
0%, 100% { fill: var(--color-surface-alt); filter: none; }
50% {
fill: var(--color-primary);
filter: drop-shadow(0 0 6px rgba(100, 108, 255, 0.8));
}
}
@media (prefers-reduced-motion: reduce) {
.git-graph .ring,
.git-graph .branch,
.git-graph .node,
.repo-loading-bar span { animation: none; }
.git-graph .branch { stroke-dashoffset: 0; }
}
</style>
+9 -1
View File
@@ -38,6 +38,14 @@
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
}
function baseName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function fileName(file: GitFileStatus): string {
return file.old_path ? `${baseName(file.old_path)} -> ${baseName(file.path)}` : baseName(file.path);
}
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
</script>
@@ -90,7 +98,7 @@
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
<article class="file-row">
<div class="file-title">
<strong title={displayPath(file)}>{displayPath(file)}</strong>
<strong title={displayPath(file)}>{fileName(file)}</strong>
</div>
<div class="change-lanes">
+13
View File
@@ -8,6 +8,7 @@ import type {
GitRepositoryFile,
GitSearchHit,
GitStatus,
RepositoryBundle,
StoredCredential,
} from "./types";
@@ -15,6 +16,10 @@ export function openRepository(path: string): Promise<GitStatus> {
return invoke<GitStatus>("open_repository", { path });
}
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
}
export function getStatus(path: string): Promise<GitStatus> {
return invoke<GitStatus>("get_status", { path });
}
@@ -27,6 +32,14 @@ export function checkoutBranch(path: string, branch: string): Promise<GitStatus>
return invoke<GitStatus>("checkout_branch", { path, branch });
}
export function createBranch(
path: string,
branch: string,
startPoint?: string,
): Promise<GitStatus> {
return invoke<GitStatus>("create_branch", { path, branch, startPoint: startPoint ?? null });
}
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
return invoke<GitStatus>("stage_files", { path, files });
}
+7
View File
@@ -54,6 +54,13 @@ export interface GitRepositoryFile {
status: FileStatusKind | null;
}
export interface RepositoryBundle {
status: GitStatus;
branches: GitBranch[];
commits: GitCommit[];
files: GitRepositoryFile[];
}
export interface GitDiffFile {
path: string;
old_path: string | null;