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.
This commit is contained in:
Christoph Brandau
2026-07-01 10:07:59 +02:00
parent 900159a3a9
commit 1c7c53ddf9
7 changed files with 886 additions and 167 deletions
+65
View File
@@ -281,6 +281,14 @@ pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String
status_for_repo(&repo) status_for_repo(&repo)
} }
#[tauri::command]
pub fn create_branch(path: String, branch: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let branch = validate_new_branch_name(&repo, &branch)?;
run_git(&repo, ["checkout", "-b", 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)?;
@@ -1839,6 +1847,38 @@ fn local_branch_name_for_remote(remote_branch: &str) -> Option<&str> {
.filter(|local| !local.is_empty()) .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> { fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, String> {
let output = git_command() let output = git_command()
.arg("-C") .arg("-C")
@@ -3186,6 +3226,31 @@ mod tests {
assert_eq!(plan, CheckoutPlan::Local("feature/demo".to_string())); 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(),
)
.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(),
)
.unwrap_err();
assert!(err.contains("existiert bereits"));
}
#[test] #[test]
fn restore_to_commit_resets_branch_to_selected_commit() { fn restore_to_commit_resets_branch_to_selected_commit() {
let repo = init_temp_repo("restore_to_commit"); let repo = init_temp_repo("restore_to_commit");
+6 -5
View File
@@ -4,11 +4,11 @@ mod git;
use git::{ use git::{
cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head, 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, compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save,
get_remote_url, get_status, list_branches, list_commits, list_file_history, diff_file_against_working_tree, get_remote_url, get_status, list_branches, list_commits,
list_repository_files, merge_branch, open_repository, pull, push, read_conflict, list_file_history, list_repository_files, merge_branch, open_repository, pull, push,
resolve_conflict, resolve_conflict_side, restore_file_from_commit, restore_files, read_conflict, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
restore_to_commit, search_code_introductions, stage_files, unstage_files, restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
SearchCancellationState, SearchCancellationState,
}; };
@@ -22,6 +22,7 @@ fn main() {
get_status, get_status,
list_branches, list_branches,
checkout_branch, checkout_branch,
create_branch,
stage_files, stage_files,
unstage_files, unstage_files,
restore_files, restore_files,
+42
View File
@@ -23,6 +23,7 @@
commit, commit,
compareCommits, compareCommits,
cancelCodeSearch, cancelCodeSearch,
createBranch,
diffFileAgainstWorkingTree, diffFileAgainstWorkingTree,
compareFileToParent, compareFileToParent,
getStatus, getStatus,
@@ -413,6 +414,18 @@
}); });
} }
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);
});
}
async function merge(branch: GitBranchInfo) { async function merge(branch: GitBranchInfo) {
if (!activeRepoPath || branch.current) return; if (!activeRepoPath || branch.current) return;
await runOperation(`Merging ${branch.name}`, async () => { await runOperation(`Merging ${branch.name}`, async () => {
@@ -719,6 +732,17 @@
expandedExplorerPaths = new Set(); 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) { async function selectExplorerNode(node: ExplorerNode) {
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return; if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
selectedExplorerPath = node.path; selectedExplorerPath = node.path;
@@ -728,6 +752,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) { async function restoreSelectedFileFromCommit(target: GitCommit) {
if (!activeRepoPath || !selectedExplorerPath) return; if (!activeRepoPath || !selectedExplorerPath) return;
const kind = selectedExplorerKind === "folder" ? "folder" : "file"; const kind = selectedExplorerKind === "folder" ? "folder" : "file";
@@ -1009,6 +1044,7 @@
{isBusy} {isBusy}
onCheckout={checkout} onCheckout={checkout}
onMerge={merge} onMerge={merge}
onCreateBranch={createNewBranch}
/> />
<ExplorerPanel <ExplorerPanel
{repoFiles} {repoFiles}
@@ -1133,10 +1169,16 @@
isSearching={globalSearchBusy} isSearching={globalSearchBusy}
error={globalSearchError} error={globalSearchError}
results={globalSearchResults} results={globalSearchResults}
files={repoFiles}
fileHistory={fileHistory}
selectedFilePath={selectedExplorerPath}
onClose={closeGlobalSearchDialog} onClose={closeGlobalSearchDialog}
onSearch={runGlobalSearch} onSearch={runGlobalSearch}
onCancel={cancelGlobalSearch} onCancel={cancelGlobalSearch}
onDiff={diffSearchHit} onDiff={diffSearchHit}
onSelectFile={selectFileFromSearch}
onFileHistoryDiff={diffSelectedFileFromCommit}
onFileHistoryRestore={restoreSelectedFileFromCommit}
/> />
{/if} {/if}
+305 -1
View File
@@ -715,6 +715,88 @@
/* --- Branch list --- */ /* --- 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 { .branch-group-label {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -738,6 +820,12 @@
font-size: 10px; font-size: 10px;
} }
.branch-empty {
padding: 8px 10px;
color: var(--color-ink-faint);
font-size: 12px;
}
.branch-row { .branch-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@@ -1175,11 +1263,38 @@
.global-search-body { .global-search-body {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr);
min-width: 0; min-width: 0;
min-height: 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 { .global-search-form {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -1206,6 +1321,11 @@
white-space: pre; white-space: pre;
overflow: auto; overflow: auto;
} }
.global-search-query input {
height: 40px;
font-family: var(--font-mono);
font-size: 13px;
}
.global-search-options { .global-search-options {
display: grid; display: grid;
@@ -1342,6 +1462,185 @@
white-space: pre; 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 --- */ /* --- Credential dialog --- */
.cred-card { .cred-card {
@@ -1929,6 +2228,11 @@
.dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); } .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); } .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; } .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); } .dialog-files { border-right: none; border-bottom: 1px solid var(--color-border-subtle); }
.branch-actions { flex-direction: row; justify-content: flex-start; } .branch-actions { flex-direction: row; justify-content: flex-start; }
.tb-action-label { display: none; } .tb-action-label { display: none; }
+156 -63
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <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"; import type { GitBranch as GitBranchInfo } from "../types";
interface Props { interface Props {
@@ -10,6 +10,7 @@
isBusy: boolean; isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void; onCheckout: (branch: GitBranchInfo) => void;
onMerge: (branch: GitBranchInfo) => void; onMerge: (branch: GitBranchInfo) => void;
onCreateBranch: (branchName: string) => void | Promise<void>;
} }
let { let {
@@ -20,7 +21,35 @@
isBusy = false, isBusy = false,
onCheckout = () => {}, onCheckout = () => {},
onMerge = () => {}, onMerge = () => {},
onCreateBranch = () => {},
}: Props = $props(); }: 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> </script>
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches"> <section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
@@ -29,7 +58,19 @@
<span class="eyebrow">Branches</span> <span class="eyebrow">Branches</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Refs</h2> <h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Refs</h2>
</div> </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> </div>
{#if !hasRepository} {#if !hasRepository}
@@ -37,71 +78,123 @@
{:else if branches.length === 0} {:else if branches.length === 0}
<p class="blank-state">No branches returned.</p> <p class="blank-state">No branches returned.</p>
{:else} {:else}
<div class="overflow-auto p-2 flex flex-col gap-0"> <div class="branch-list overflow-auto p-2 flex flex-col gap-0">
{#if localBranches.length > 0} {#if createOpen}
<div class="branch-group-label"> <form class="branch-create-form" onsubmit={submitCreate}>
<span>Local</span> <GitBranch size={15} aria-hidden="true" />
<span class="branch-group-count">{localBranches.length}</span> <input
</div> bind:this={createInput}
{#each localBranches as branch (branch.name)} bind:value={newBranchName}
{#snippet branchCard()} disabled={isBusy}
<article class="branch-row" class:current={branch.current}> autocomplete="off"
<div class="branch-info"> spellcheck="false"
<GitBranch size={16} aria-hidden="true" /> placeholder="new-branch-name"
<div> aria-label="New branch name"
<strong>{branch.name}</strong> />
<span>local</span> <button class="branch-create-action confirm" type="submit" disabled={isBusy || newBranchName.trim().length === 0} title="Create branch">
</div> <Check size={14} aria-hidden="true" />
</div> </button>
{#if branch.current} <button class="branch-create-action" type="button" onclick={closeCreateForm} disabled={isBusy} title="Cancel">
<span class="pill pill-active">Current</span> <X size={14} aria-hidden="true" />
{:else} </button>
<div class="branch-actions"> </form>
<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}
{/if} {/if}
{#if remoteBranches.length > 0} <div class="branch-group">
<div class="branch-group-label" style="margin-top: {localBranches.length > 0 ? '12px' : '0'}"> <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>Remote</span>
<span class="branch-group-count">{remoteBranches.length}</span> <span class="branch-group-count">{remoteBranches.length}</span>
</div> </button>
{#each remoteBranches as branch (branch.name)}
<article class="branch-row" class:current={branch.current}> {#if remoteOpen}
<div class="branch-info"> {#if remoteBranches.length === 0}
<GitBranch size={16} aria-hidden="true" /> <div class="branch-empty">No remote branches.</div>
<div> {:else}
<strong>{branch.name}</strong> {#each remoteBranches as branch (branch.name)}
<span>remote</span> <article class="branch-row" class:current={branch.current}>
</div> <div class="branch-info">
</div> <GitBranch size={16} aria-hidden="true" />
{#if branch.current} <div>
<span class="pill pill-active">Current</span> <strong>{branch.name}</strong>
{:else} <span>remote</span>
<div class="branch-actions"> </div>
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}> </div>
Checkout {#if branch.current}
</button> <span class="pill pill-active">Current</span>
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current"> {:else}
<GitMerge size={15} aria-hidden="true" /> <div class="branch-actions">
Merge <button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
</button> Checkout
</div> </button>
{/if} <button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
</article> <GitMerge size={15} aria-hidden="true" />
{/each} Merge
{/if} </button>
</div>
{/if}
</article>
{/each}
{/if}
{/if}
</div>
</div> </div>
{/if} {/if}
</section> </section>
+308 -98
View File
@@ -1,6 +1,21 @@
<script lang="ts"> <script lang="ts">
import { CalendarDays, FileCode, GitCompare, LoaderCircle, Search, User, X } from "@lucide/svelte"; import {
import type { GitSearchHit } from "../types"; 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 { interface Props {
hasRepository: boolean; hasRepository: boolean;
@@ -8,10 +23,16 @@
isSearching: boolean; isSearching: boolean;
error: string; error: string;
results: GitSearchHit[]; results: GitSearchHit[];
files: GitRepositoryFile[];
fileHistory: GitCommit[];
selectedFilePath: string;
onClose: () => void; onClose: () => void;
onSearch: (query: string, caseSensitive: boolean, limit: number) => void | Promise<void>; onSearch: (query: string, caseSensitive: boolean, limit: number) => void | Promise<void>;
onCancel: () => void | Promise<void>; onCancel: () => void | Promise<void>;
onDiff: (hit: GitSearchHit) => 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 { let {
@@ -20,17 +41,28 @@
isSearching = false, isSearching = false,
error = "", error = "",
results = [], results = [],
files = [],
fileHistory = [],
selectedFilePath = "",
onClose = () => {}, onClose = () => {},
onSearch = () => {}, onSearch = () => {},
onCancel = () => {}, onCancel = () => {},
onDiff = () => {}, onDiff = () => {},
onSelectFile = () => {},
onFileHistoryDiff = () => {},
onFileHistoryRestore = () => {},
}: Props = $props(); }: Props = $props();
let activeTab = $state<SearchTab>("code");
let query = $state(""); let query = $state("");
let caseSensitive = $state(false); let caseSensitive = $state(false);
let limit = $state(250); let limit = $state(250);
let searchedQuery = $state(""); let searchedQuery = $state("");
let searched = $state(false); 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) { function submit(event?: SubmitEvent) {
event?.preventDefault(); event?.preventDefault();
@@ -56,6 +88,42 @@
function displayPath(hit: GitSearchHit): string { function displayPath(hit: GitSearchHit): string {
return hit.old_file ? `${hit.old_file} -> ${hit.file}` : hit.file; 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> </script>
<div <div
@@ -63,11 +131,11 @@
role="presentation" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }} 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"> <header class="dialog-header">
<div> <div>
<span class="eyebrow">Global search</span> <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> </div>
<button class="dialog-close" type="button" onclick={onClose} title="Close"> <button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" /> <X size={18} aria-hidden="true" />
@@ -75,112 +143,254 @@
</header> </header>
<div class="global-search-body"> <div class="global-search-body">
<form class="global-search-form" onsubmit={submit}> <div class="global-search-tabs" role="tablist" aria-label="Search mode">
<label class="global-search-query"> <button
<span>String or function</span> class="global-search-tab"
<textarea class:active={activeTab === "code"}
bind:value={query} type="button"
onkeydown={handleKeydown} role="tab"
disabled={!hasRepository || isBusy || isSearching} aria-selected={activeTab === "code"}
spellcheck="false" onclick={() => { activeTab = "code"; }}
placeholder={"Paste a string, symbol, or full function body..."} >
></textarea> <Search size={14} aria-hidden="true" />
</label> 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"> {#if activeTab === "code"}
<label class="check-row"> <form class="global-search-form" onsubmit={submit}>
<input type="checkbox" bind:checked={caseSensitive} disabled={!hasRepository || isBusy || isSearching} /> <label class="global-search-query">
<span>Exact case</span> <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>
<label class="search-limit"> <div class="global-search-options">
<span>Results</span> <label class="check-row">
<select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}> <input type="checkbox" bind:checked={caseSensitive} disabled={!hasRepository || isBusy || isSearching} />
<option value={100}>100</option> <span>Exact case</span>
<option value={250}>250</option> </label>
<option value={500}>500</option>
<option value={1000}>1000</option>
</select>
</label>
<button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}> <label class="search-limit">
{#if isSearching} <span>Results</span>
<LoaderCircle class="spin" size={16} aria-hidden="true" /> <select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}>
{:else} <option value={100}>100</option>
<Search size={16} aria-hidden="true" /> <option value={250}>250</option>
{/if} <option value={500}>500</option>
Search <option value={1000}>1000</option>
</button> </select>
</label>
{#if isSearching} <button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}>
<button class="btn-secondary search-cancel" type="button" onclick={onCancel}> {#if isSearching}
<X size={16} aria-hidden="true" /> <LoaderCircle class="spin" size={16} aria-hidden="true" />
Cancel {:else}
<Search size={16} aria-hidden="true" />
{/if}
Search
</button> </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} {/if}
</div> </section>
</form> {: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"> <section class="global-search-results file-search-results" aria-live="polite">
{#if !hasRepository} {#if !hasRepository}
<div class="blank-state">Open a repository first.</div> <div class="blank-state">Open a repository first.</div>
{:else if isSearching} {:else if files.length === 0}
<div class="blank-state"> <div class="blank-state">No files loaded for this repository.</div>
<LoaderCircle class="spin" size={20} aria-hidden="true" /> {:else}
Searching all branches... <div class="file-search-split">
</div> <div class="file-search-column">
{:else if error} {#if !fileSearchActive}
<div class="blank-state search-error">{error}</div> <div class="blank-state">Search a file name or path, then choose a result to show its history.</div>
{:else if !searched} {:else if fileSearchResults.length === 0}
<div class="blank-state">Search a string or paste a complete function to find where it was added.</div> <div class="blank-state">No file found for "{fileQuery.trim()}".</div>
{:else if results.length === 0} {:else}
<div class="blank-state">No introduction found for "{searchedQuery}".</div> <div class="search-result-head">
{:else} <strong>{fileSearchResults.length}</strong>
<div class="search-result-head"> <span>{fileSearchResults.length === 1 ? "file" : "files"} found for "{fileQuery.trim()}"</span>
<strong>{results.length}</strong> </div>
<span>{results.length === 1 ? "introduction" : "introductions"} found for "{searchedQuery}"</span>
</div>
<div class="search-hit-list"> <div class="file-search-list">
{#each results as hit (`${hit.commit_hash}:${hit.file}:${hit.line_number ?? 0}`)} {#each fileSearchResults as file (file.path)}
<article class="search-hit"> {@const languageIcon = languageIconForPath(file.path)}
<header class="search-hit-top"> <button
<span class="hash">{hit.short_hash}</span> class="file-search-hit"
<strong title={hit.summary}>{hit.summary || "No commit message"}</strong> class:active={selectedFilePath === file.path}
{#if hit.matches_added > 1} type="button"
<span class="pill pill-active">+{hit.matches_added} matches</span> disabled={isBusy || isSearching}
{/if} title={`Show history for ${file.path}`}
<button onclick={() => onSelectFile(file)}
class="btn-secondary search-hit-diff" >
type="button" <span class="file-search-icon">
disabled={isBusy} {#if languageIcon}
title={`Compare this version of ${hit.file} with the current version`} <LanguageIcon icon={languageIcon.icon} title={languageIcon.title} />
onclick={() => onDiff(hit)} {:else}
> <FileText size={16} aria-hidden="true" />
<GitCompare size={14} aria-hidden="true" /> {/if}
DIFF </span>
</button>
<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> </header>
<div class="search-hit-meta"> {#if !selectedFilePath}
<span><User size={12} aria-hidden="true" />{hit.author_name || "Unknown author"}</span> <div class="blank-state">Select a file result to load its commit history.</div>
<span><CalendarDays size={12} aria-hidden="true" />{formatCommitDate(hit.date)}</span> {:else if isBusy}
</div> <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)}> <div class="file-search-history-actions">
<FileCode size={14} aria-hidden="true" /> <button class="btn-sm" type="button" onclick={() => onFileHistoryDiff(commit)} disabled={isBusy}>
<span>{displayPath(hit)}</span> <GitCompare size={14} aria-hidden="true" />
{#if hit.line_number} Diff
<strong>:{hit.line_number}</strong> </button>
{/if} <button class="btn-sm" type="button" onclick={() => onFileHistoryRestore(commit)} disabled={isBusy}>
</div> <RotateCcw size={14} aria-hidden="true" />
Restore
<pre class="search-hit-line">{hit.line || searchedQuery}</pre> </button>
</article> </div>
{/each} </article>
</div> {/each}
{/if} </div>
</section> {/if}
</aside>
</div>
{/if}
</section>
{/if}
</div> </div>
</div> </div>
</div> </div>
+4
View File
@@ -27,6 +27,10 @@ export function checkoutBranch(path: string, branch: string): Promise<GitStatus>
return invoke<GitStatus>("checkout_branch", { path, branch }); return invoke<GitStatus>("checkout_branch", { path, branch });
} }
export function createBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("create_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 });
} }