<script lang="ts">
import { resolve } from "$app/paths";
import ChevronDown from "$icon/chevron-down";
import ChevronUp from "$icon/chevron-up";
import Eye from "$icon/eye";
import type {
Chunk,
FileResult
} from "#lib/api/lexicons/types/org/tangled/temp/search/searchCode.ts";
import { profileIdentifier } from "#lib/api/identity.ts";
import {
LANGUAGE_COLORS,
LANGUAGE_COLOR_FALLBACK
} from "#lib/components/repo/languageColors.ts";
import Button from "#lib/components/ui/Button.svelte";
import ColorBall from "#lib/components/ui/ColorBall.svelte";
import FileCardHeader from "#lib/components/repo/FileCardHeader.svelte";
import { encodePathSegments } from "#lib/components/repo/urls.ts";
import { chunkLines, type ThemedToken } from "#lib/components/search/chunks.ts";
import { highlightChunks, resolveLanguage } from "#lib/components/search/highlight.ts";
import { plural } from "#lib/format.ts";
const VISIBLE_CHUNKS = 3;
let { result }: { result: FileResult } = $props();
const chunks = $derived(result.chunks ?? []);
const hasChunks = $derived(chunks.length > 0);
let open = $state(true);
let showAll = $state(false);
let tokensByChunk = $state.raw<ThemedToken[][][] | undefined>(undefined);
$effect(() => {
tokensByChunk = undefined;
const currChunks = result.chunks ?? [];
if (currChunks.length === 0) return;
const lang = resolveLanguage(result.language, result.path);
if (!lang) return;
let cancelled = false;
highlightChunks(currChunks, lang).then((tokens) => {
if (!cancelled && tokens) {
tokensByChunk = tokens;
}
});
return () => {
cancelled = true;
};
});
const pathSpans = $derived(
chunkLines({ content: result.path, lineStart: 1, highlights: result.ranges ?? [] })[0].spans
);
const filePath = $derived(
`/${profileIdentifier(result.repo.owner.handle, result.repo.owner.did)}/${result.repo.slug}/blob/${encodeURIComponent(result.commit)}/${encodePathSegments(result.path)}`
);
const fileHref = $derived(resolve(filePath as "/"));
const lineHref = (num: number) => resolve(`${filePath}#L${num}` as "/");
const visible = $derived(chunks.slice(0, VISIBLE_CHUNKS));
const hidden = $derived(chunks.slice(VISIBLE_CHUNKS));
const hiddenMatches = $derived(hidden.reduce((n, c) => n + (c.highlights?.length ?? 0), 0));
const languageColor = (lang: string) => {
if (LANGUAGE_COLORS[lang]) return LANGUAGE_COLORS[lang];
const lower = lang.toLowerCase();
const entry = Object.entries(LANGUAGE_COLORS).find(([k]) => k.toLowerCase() === lower);
return entry ? entry[1] : LANGUAGE_COLOR_FALLBACK;
};
</script>
<div class="overflow-hidden rounded-md">
<FileCardHeader
{open}
collapsible={hasChunks}
onToggle={hasChunks ? () => (open = !open) : undefined}
>
{#snippet title()}
<span class="truncate typography-paragraph-small text-foreground-default">
{#each pathSpans as span, i (i)}
{#if span.match}
<mark
class="rounded-xs bg-background-highlight font-medium text-foreground-default"
>{span.text}</mark
>
{:else}
<span>{span.text}</span>
{/if}
{/each}
</span>
{/snippet}
{#snippet actions()}
{#if result.language}
<span
class="flex items-center gap-1.5 typography-paragraph-small text-foreground-subtle"
>
<ColorBall color={languageColor(result.language)} />
{result.language}
</span>
{/if}
<Button
href={fileHref as "/"}
variant="ghost"
size="small"
icon={Eye}
class="hidden md:inline-flex"
>
View file
</Button>
{/snippet}
</FileCardHeader>
{#if hasChunks && open}
<div
class="overflow-x-auto rounded-b-md border-x border-b border-border-default bg-background-default"
>
<table class="w-full border-collapse typography-monospace-regular">
<tbody>
{#each visible as chunk, i (`${chunk.lineStart}-${i}`)}
{#if i > 0}
{@render gapRow()}
{/if}
{@render chunkRows(chunk, i)}
{/each}
{#if showAll}
{#each hidden as chunk, i (`${chunk.lineStart}-${i}`)}
{@render gapRow()}
{@render chunkRows(chunk, VISIBLE_CHUNKS + i)}
{/each}
{/if}
{#if hidden.length > 0}
<tr class="border-t border-border-default bg-background-default">
<td colspan={2} class="p-none">
<button
type="button"
class="flex w-full cursor-pointer items-center justify-center gap-1.5 px-sm py-1.5 typography-paragraph-small text-foreground-subtle transition-colors hover:bg-background-subtle hover:text-foreground-default"
onclick={() => (showAll = !showAll)}
>
{#if showAll}
<ChevronUp class="size-3.5" aria-hidden="true" />
<span>Show less</span>
{:else}
<ChevronDown class="size-3.5" aria-hidden="true" />
<span
>Show {hiddenMatches} more {plural(
hiddenMatches,
"match",
"matches"
)}</span
>
{/if}
</button>
</td>
</tr>
{/if}
</tbody>
</table>
</div>
{/if}
</div>
{#snippet gapRow()}
<tr class="border-y border-border-default bg-background-inset select-none">
<td
colspan={2}
class="py-3xs text-center typography-monospace-small text-foreground-subtle"
>
ยทยทยท
</td>
</tr>
{/snippet}
{#snippet chunkRows(chunk: Chunk, chunkIndex: number)}
{@const lines = chunkLines(chunk, tokensByChunk?.[chunkIndex])}
{#each lines as line (line.num)}
<tr class="hover:bg-background-subtle">
<td
class="w-px border-r border-border-default bg-background-default px-sm py-3xs text-right typography-monospace-regular whitespace-nowrap text-foreground-subtle tabular-nums select-none"
>
<a
href={lineHref(line.num)}
class="text-inherit no-underline hover:text-foreground-default"
>
{line.num}
</a>
</td>
<td
class="px-sm py-3xs typography-monospace-regular whitespace-pre text-foreground-default"
>
{#each line.spans as span, i (i)}
{#if span.match}
<mark
class="rounded-xs bg-background-highlight font-medium text-foreground-default"
style:color={span.color}>{span.text}</mark
>
{:else if span.color}
<span style:color={span.color}>{span.text}</span>
{:else}
<span>{span.text}</span>
{/if}
{/each}
</td>
</tr>
{/each}
{/snippet}