~james/core

core/web/src/lib/components/search/FileMatch.svelte -rw-r--r-- 6.2 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
<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}