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:
@@ -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>
|
||||
|
||||
@@ -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,112 +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}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user