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)
}
#[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]
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
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())
}
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")
@@ -3186,6 +3226,31 @@ 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(),
)
.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]
fn restore_to_commit_resets_branch_to_selected_commit() {
let repo = init_temp_repo("restore_to_commit");
+6 -5
View File
@@ -4,11 +4,11 @@ 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, 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 +22,7 @@ fn main() {
get_status,
list_branches,
checkout_branch,
create_branch,
stage_files,
unstage_files,
restore_files,
+42
View File
@@ -23,6 +23,7 @@
commit,
compareCommits,
cancelCodeSearch,
createBranch,
diffFileAgainstWorkingTree,
compareFileToParent,
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) {
if (!activeRepoPath || branch.current) return;
await runOperation(`Merging ${branch.name}`, async () => {
@@ -719,6 +732,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;
@@ -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) {
if (!activeRepoPath || !selectedExplorerPath) return;
const kind = selectedExplorerKind === "folder" ? "folder" : "file";
@@ -1009,6 +1044,7 @@
{isBusy}
onCheckout={checkout}
onMerge={merge}
onCreateBranch={createNewBranch}
/>
<ExplorerPanel
{repoFiles}
@@ -1133,10 +1169,16 @@
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}
+305 -1
View File
@@ -715,6 +715,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 +820,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;
@@ -1175,11 +1263,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;
@@ -1206,6 +1321,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;
@@ -1342,6 +1462,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 {
@@ -1929,6 +2228,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; }
+104 -11
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,22 +58,69 @@
<span class="eyebrow">Branches</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Refs</h2>
</div>
<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}
<p class="blank-state">Open a repository to list branches.</p>
{: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">
<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}
<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>
</div>
</button>
{#if localOpen}
{#if localBranches.length === 0}
<div class="branch-empty">No local branches.</div>
{:else}
{#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" />
@@ -67,16 +143,31 @@
</div>
{/if}
</article>
{/snippet}
{@render branchCard()}
{/each}
{/if}
{/if}
</div>
{#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={() => { 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>
</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">
@@ -102,6 +193,8 @@
</article>
{/each}
{/if}
{/if}
</div>
</div>
{/if}
</section>
+214 -4
View File
@@ -1,6 +1,21 @@
<script lang="ts">
import { CalendarDays, FileCode, GitCompare, 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,10 +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 {
@@ -20,17 +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();
@@ -56,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
@@ -63,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" />
@@ -75,6 +143,32 @@
</header>
<div class="global-search-body">
<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>
{#if activeTab === "code"}
<form class="global-search-form" onsubmit={submit}>
<label class="global-search-query">
<span>String or function</span>
@@ -181,6 +275,122 @@
</div>
{/if}
</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 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="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>
{#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="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>
+4
View File
@@ -27,6 +27,10 @@ export function checkoutBranch(path: string, branch: string): Promise<GitStatus>
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> {
return invoke<GitStatus>("stage_files", { path, files });
}