Files
GitLite/src/lib/components/GlobalSearchDialog.svelte
T
Christoph 50524c2759 feat(commits): enhance commit listing with pagination and skipping
This update introduces pagination and skipping functionality for the
commit listing feature, allowing users to load commits in pages and
navigate through them more efficiently. The UI has been adjusted to
support loading more commits dynamically, improving the overall user
experience when dealing with large repositories.

- Added pagination support for commit history
- Introduced a loading mechanism for fetching more commits
- Updated UI components to reflect changes in commit loading behavior
2026-08-04 18:04:10 +02:00

401 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";
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
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 commitDateFormatter.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"
>
<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>