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.
397 lines
15 KiB
Svelte
397 lines
15 KiB
Svelte
<script lang="ts">
|
|
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;
|
|
isBusy: boolean;
|
|
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 {
|
|
hasRepository = false,
|
|
isBusy = false,
|
|
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();
|
|
const value = query.trim();
|
|
if (!value || !hasRepository || isBusy || isSearching) return;
|
|
searched = true;
|
|
searchedQuery = value;
|
|
void onSearch(value, caseSensitive, limit);
|
|
}
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
|
submit();
|
|
}
|
|
}
|
|
|
|
function formatCommitDate(value: string): string {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
|
}
|
|
|
|
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
|
|
class="dialog-backdrop"
|
|
role="presentation"
|
|
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
>
|
|
<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">{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" />
|
|
</button>
|
|
</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>
|
|
<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-options">
|
|
<label class="check-row">
|
|
<input type="checkbox" bind:checked={caseSensitive} disabled={!hasRepository || isBusy || isSearching} />
|
|
<span>Exact case</span>
|
|
</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>
|
|
|
|
<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}
|
|
</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>
|