add new style and global search

This commit is contained in:
Christoph Brandau
2026-06-29 17:24:08 +02:00
parent 9861fa2446
commit b0491d1479
17 changed files with 1805 additions and 70 deletions
+13 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Download, GitBranch, LoaderCircle, Minus, RefreshCw, Upload, X } from "@lucide/svelte";
import { Download, GitBranch, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
export let branch: string = "";
export let ahead: number = 0;
@@ -15,6 +15,7 @@
export let onPull: () => void = () => {};
export let onPush: () => void = () => {};
export let onRefresh: () => void = () => {};
export let onSearch: () => void = () => {};
export let onToggleAutoRefresh: () => void = () => {};
const win = getCurrentWindow();
@@ -80,6 +81,17 @@
<!-- Right: actions + window controls -->
<div class="titlebar-right">
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
<button
class="tb-action"
onclick={onSearch}
disabled={!hasRepository || isBusy}
title="Global search"
aria-label="Global search"
>
<Search size={14} aria-hidden="true" />
<span class="tb-action-label">Search</span>
</button>
<button
class="tb-action"
onclick={onPull}
+7 -7
View File
@@ -74,15 +74,15 @@
</script>
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Selected file history">
<div class="section-head">
<div class="min-w-0 flex-1 overflow-hidden">
<div class="section-head file-history-head">
<div class="file-history-heading">
<span class="eyebrow">{selectedExplorerLabel}</span>
<h2
class="mt-0.5 text-ink text-base font-bold leading-tight truncate"
class="file-history-name"
use:pathTooltip={selectedExplorerPath}
>{selectedExplorerPath ? fileName(selectedExplorerPath) : "No file"}</h2>
</div>
<span class="pill pill-count flex-shrink-0">{fileHistory.length}</span>
<span class="pill pill-count file-history-count">{fileHistory.length}</span>
</div>
{#if !hasRepository}
@@ -94,16 +94,16 @@
{:else}
<div class="history-list overflow-auto p-2">
{#each fileHistory as item (item.hash)}
<article class="commit-row compact">
<article class="commit-row compact file-history-row">
<div class="commit-line">
<History size={16} aria-hidden="true" />
<div>
<div class="commit-line-text">
<strong title={item.summary}>{item.summary}</strong>
<span>{item.short_hash} - {item.author_name}</span>
</div>
</div>
<div class="commit-actions">
<div class="commit-actions file-history-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons">
<button class="btn-sm" type="button" onclick={() => onDiff(item)} disabled={isBusy} title="Show changes vs working tree">
@@ -0,0 +1,174 @@
<script lang="ts">
import { CalendarDays, FileCode, LoaderCircle, Search, User, X } from "@lucide/svelte";
import type { GitSearchHit } from "../types";
interface Props {
hasRepository: boolean;
isBusy: boolean;
isSearching: boolean;
error: string;
results: GitSearchHit[];
onClose: () => void;
onSearch: (query: string, caseSensitive: boolean, limit: number) => void | Promise<void>;
onCancel: () => void | Promise<void>;
}
let {
hasRepository = false,
isBusy = false,
isSearching = false,
error = "",
results = [],
onClose = () => {},
onSearch = () => {},
onCancel = () => {},
}: Props = $props();
let query = $state("");
let caseSensitive = $state(false);
let limit = $state(250);
let searchedQuery = $state("");
let searched = $state(false);
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;
}
</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 code search" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Global search</span>
<h2 class="dialog-title">Find where code was introduced</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">
<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}
</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>
</div>
</div>
</div>
+21
View File
@@ -6,6 +6,7 @@ import type {
GitCommit,
GitCommitComparison,
GitRepositoryFile,
GitSearchHit,
GitStatus,
} from "./types";
@@ -97,6 +98,26 @@ export function diffFileAgainstWorkingTree(
return invoke<GitCommitComparison>("diff_file_against_working_tree", { path, commit, file });
}
export function searchCodeIntroductions(
path: string,
query: string,
caseSensitive = false,
limit = 250,
searchId?: string,
): Promise<GitSearchHit[]> {
return invoke<GitSearchHit[]>("search_code_introductions", {
path,
query,
caseSensitive,
limit,
searchId: searchId ?? null,
});
}
export function cancelCodeSearch(searchId: string): Promise<void> {
return invoke<void>("cancel_code_search", { searchId });
}
export function readConflict(path: string, file: string): Promise<ConflictFile> {
return invoke<ConflictFile>("read_conflict", { path, file });
}
+14
View File
@@ -71,6 +71,20 @@ export interface GitCommitComparison {
patch: string;
}
export interface GitSearchHit {
commit_hash: string;
short_hash: string;
summary: string;
author_name: string;
author_email: string;
date: string;
file: string;
old_file: string | null;
line_number: number | null;
line: string;
matches_added: number;
}
export type ExplorerNodeKind = "folder" | "file";
export interface ExplorerNode {