feat(blame): add search functionality to blame dialog

This update introduces a search bar to the blame dialog, allowing users to filter blame lines based on their input. The search highlights matching text segments, improving the usability of the blame feature for navigating large files.

- Implemented a search bar for filtering blame lines
- Added visual feedback for matched search terms
- Enhanced overall user experience in the blame dialog
This commit is contained in:
Christoph Brandau
2026-07-08 14:20:51 +02:00
parent de6f497e35
commit c9a93f1d0a
2 changed files with 163 additions and 23 deletions
+45
View File
@@ -3120,6 +3120,45 @@
font-weight: 800; font-weight: 800;
} }
.blame-search-bar {
position: relative;
display: flex;
align-items: center;
min-height: 38px;
padding: 6px 10px;
border-bottom: 1px solid var(--color-border-subtle);
background: #111321;
}
.blame-search-bar svg {
position: absolute;
left: 20px;
color: var(--color-ink-faint);
pointer-events: none;
}
.blame-search-bar input {
width: 100%;
height: 26px;
padding: 0 34px;
border: 1px solid var(--color-border-subtle);
border-radius: 6px;
background: #0b0e18;
color: var(--color-ink);
font-size: 12px;
}
.blame-search-clear {
position: absolute;
right: 14px;
width: 24px;
height: 24px;
padding: 0;
border: 0;
background: transparent;
color: var(--color-ink-faint);
}
.blame-search-clear svg {
position: static;
}
.blame-column-headers { .blame-column-headers {
grid-template-columns: minmax(300px, 340px) minmax(0, 1fr); grid-template-columns: minmax(300px, 340px) minmax(0, 1fr);
} }
@@ -3233,6 +3272,12 @@
.blame-group:hover .blame-line-code { .blame-group:hover .blame-line-code {
background: #151a2b; background: #151a2b;
} }
.blame-search-hit {
padding: 0 1px;
border-radius: 3px;
background: rgba(240,182,72,0.28);
color: #f3d487;
}
.global-search-body { .global-search-body {
display: grid; display: grid;
+118 -23
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { FileCode, LoaderCircle, X } from "@lucide/svelte"; import { FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
import type { GitBlameLine } from "../types"; import type { GitBlameLine } from "../types";
interface BlameGroup { interface BlameGroup {
@@ -14,6 +14,11 @@
lines: GitBlameLine[]; lines: GitBlameLine[];
} }
interface TextSegment {
text: string;
matched: boolean;
}
interface Props { interface Props {
filePath: string; filePath: string;
lines: GitBlameLine[]; lines: GitBlameLine[];
@@ -32,6 +37,8 @@
onClose = () => {}, onClose = () => {},
}: Props = $props(); }: Props = $props();
let blameSearch = $state("");
function groupBlameLines(source: GitBlameLine[]): BlameGroup[] { function groupBlameLines(source: GitBlameLine[]): BlameGroup[] {
const groups: BlameGroup[] = []; const groups: BlameGroup[] = [];
for (const line of source) { for (const line of source) {
@@ -67,7 +74,56 @@
return `${group.authorName} <${group.authorEmail}>\n${group.summary}\n${group.hash}`; return `${group.authorName} <${group.authorEmail}>\n${group.summary}\n${group.hash}`;
} }
let groups = $derived(groupBlameLines(lines)); function blameLineSearchText(line: GitBlameLine): string {
return [
line.line_number,
line.content,
line.commit_hash,
line.short_hash,
line.author_name,
line.author_email,
formatBlameDate(line.author_time),
line.summary,
line.is_uncommitted ? "uncommitted not committed yet" : "",
].join("\n").toLowerCase();
}
function lineMatchesSearch(line: GitBlameLine, query: string): boolean {
return !query || blameLineSearchText(line).includes(query);
}
function textSegments(value: string | number): TextSegment[] {
const text = String(value ?? "");
const query = blameSearch.trim();
if (!query || !text) return [{ text, matched: false }];
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const segments: TextSegment[] = [];
let cursor = 0;
let matchIndex = lowerText.indexOf(lowerQuery);
while (matchIndex !== -1) {
if (matchIndex > cursor) {
segments.push({ text: text.slice(cursor, matchIndex), matched: false });
}
const end = matchIndex + query.length;
segments.push({ text: text.slice(matchIndex, end), matched: true });
cursor = end;
matchIndex = lowerText.indexOf(lowerQuery, cursor);
}
if (cursor < text.length) {
segments.push({ text: text.slice(cursor), matched: false });
}
return segments.length > 0 ? segments : [{ text, matched: false }];
}
let searchQuery = $derived(blameSearch.trim().toLowerCase());
let searchActive = $derived(searchQuery.length > 0);
let visibleLines = $derived(searchActive ? lines.filter((line) => lineMatchesSearch(line, searchQuery)) : lines);
let groups = $derived(groupBlameLines(visibleLines));
</script> </script>
<div class="dialog-backdrop" role="presentation"> <div class="dialog-backdrop" role="presentation">
@@ -93,13 +149,28 @@
</div> </div>
{:else if error} {:else if error}
<div class="blank-state">{error}</div> <div class="blank-state">{error}</div>
{:else if groups.length === 0} {:else if lines.length === 0}
<div class="blank-state">No blame information available for this file.</div> <div class="blank-state">No blame information available for this file.</div>
{:else} {:else}
<div class="diff-header blame-code-header"> <div class="diff-header blame-code-header">
<FileCode size={13} aria-hidden="true" /> <FileCode size={13} aria-hidden="true" />
<span title={filePath}>{filePath}</span> <span title={filePath}>{filePath}</span>
<strong>{lines.length} lines</strong> <strong>{searchActive ? `${visibleLines.length} of ${lines.length} lines` : `${lines.length} lines`}</strong>
</div>
<div class="blame-search-bar">
<Search size={14} aria-hidden="true" />
<input
bind:value={blameSearch}
autocomplete="off"
spellcheck="false"
placeholder="Search blame"
aria-label="Search blame"
/>
{#if searchActive}
<button class="btn-sm blame-search-clear" type="button" onclick={() => { blameSearch = ""; }} aria-label="Clear blame search">
<X size={14} aria-hidden="true" />
</button>
{/if}
</div> </div>
<div class="split-col-headers blame-column-headers"> <div class="split-col-headers blame-column-headers">
<div class="split-col-label blame-commit-col-label">Commit</div> <div class="split-col-label blame-commit-col-label">Commit</div>
@@ -107,26 +178,50 @@
</div> </div>
<div class="split-diff blame-diff" role="table" aria-label="File blame"> <div class="split-diff blame-diff" role="table" aria-label="File blame">
<div class="split-pane blame-scroll"> <div class="split-pane blame-scroll">
<div class="blame-code-table"> {#if groups.length === 0}
{#each groups as group (group.id)} <div class="blank-state">No matches found.</div>
<div class="blame-group" class:uncommitted={group.isUncommitted} title={groupTooltip(group)}> {:else}
<div class="blame-meta"> <div class="blame-code-table">
<span class="blame-hash">{group.isUncommitted ? "Uncommitted" : group.shortHash}</span> {#each groups as group (group.id)}
<span class="blame-author">{group.isUncommitted ? "Not committed yet" : group.authorName}</span> <div class="blame-group" class:uncommitted={group.isUncommitted} title={groupTooltip(group)}>
<span class="blame-summary">{group.summary || "No commit message"}</span> <div class="blame-meta">
{#if !group.isUncommitted} <span class="blame-hash">
<span class="blame-date">{formatBlameDate(group.authorTime)}</span> {#each textSegments(group.isUncommitted ? "Uncommitted" : group.shortHash) as segment, index (`hash-${group.id}-${index}`)}
{/if} {#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
{/each}
</span>
<span class="blame-author">
{#each textSegments(group.isUncommitted ? "Not committed yet" : group.authorName) as segment, index (`author-${group.id}-${index}`)}
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
{/each}
</span>
<span class="blame-summary">
{#each textSegments(group.summary || "No commit message") as segment, index (`summary-${group.id}-${index}`)}
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
{/each}
</span>
{#if !group.isUncommitted}
<span class="blame-date">
{#each textSegments(formatBlameDate(group.authorTime)) as segment, index (`date-${group.id}-${index}`)}
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
{/each}
</span>
{/if}
</div>
<div class="split-pane-grid blame-lines">
{#each group.lines as line (line.line_number)}
<span class="split-num blame-line-number">{line.line_number}</span>
<code class="split-cell blame-line-code">
{#each textSegments(line.content) as segment, index (`line-${line.line_number}-${index}`)}
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
{/each}
</code>
{/each}
</div>
</div> </div>
<div class="split-pane-grid blame-lines"> {/each}
{#each group.lines as line (line.line_number)} </div>
<span class="split-num blame-line-number">{line.line_number}</span> {/if}
<code class="split-cell blame-line-code">{line.content}</code>
{/each}
</div>
</div>
{/each}
</div>
</div> </div>
</div> </div>
{/if} {/if}