@git-snitch/renderer 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/build.d.ts +7 -0
  2. package/dist/build.d.ts.map +1 -0
  3. package/dist/build.js +53 -0
  4. package/dist/charts.d.ts +106 -0
  5. package/dist/charts.d.ts.map +1 -0
  6. package/dist/charts.js +212 -0
  7. package/dist/custom-templates.d.ts +3 -0
  8. package/dist/custom-templates.d.ts.map +1 -0
  9. package/dist/custom-templates.js +1 -0
  10. package/dist/data.d.ts +24 -0
  11. package/dist/data.d.ts.map +1 -0
  12. package/dist/data.js +30 -0
  13. package/dist/empty-state.d.ts +13 -0
  14. package/dist/empty-state.d.ts.map +1 -0
  15. package/dist/empty-state.js +9 -0
  16. package/dist/export.d.ts +15 -0
  17. package/dist/export.d.ts.map +1 -0
  18. package/dist/export.js +53 -0
  19. package/dist/index.d.ts +15 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +9 -0
  22. package/dist/inline-plugin.d.ts +13 -0
  23. package/dist/inline-plugin.d.ts.map +1 -0
  24. package/dist/inline-plugin.js +81 -0
  25. package/dist/layout.d.ts +43 -0
  26. package/dist/layout.d.ts.map +1 -0
  27. package/dist/layout.js +25 -0
  28. package/dist/remote-urls.d.ts +6 -0
  29. package/dist/remote-urls.d.ts.map +1 -0
  30. package/dist/remote-urls.js +82 -0
  31. package/dist/serialization.d.ts +5 -0
  32. package/dist/serialization.d.ts.map +1 -0
  33. package/dist/serialization.js +46 -0
  34. package/dist/tables.d.ts +50 -0
  35. package/dist/tables.d.ts.map +1 -0
  36. package/dist/tables.js +228 -0
  37. package/dist/template/report-template.html +135 -0
  38. package/dist/template.d.ts +21 -0
  39. package/dist/template.d.ts.map +1 -0
  40. package/dist/template.js +1 -0
  41. package/dist/theme-toggle.d.ts +2 -0
  42. package/dist/theme-toggle.d.ts.map +1 -0
  43. package/dist/theme-toggle.js +9 -0
  44. package/dist/theme.d.ts +16 -0
  45. package/dist/theme.d.ts.map +1 -0
  46. package/dist/theme.js +70 -0
  47. package/package.json +57 -0
  48. package/report-template.html +15 -0
  49. package/src/app.tsx +351 -0
  50. package/src/build.ts +68 -0
  51. package/src/charts-route.tsx +158 -0
  52. package/src/charts.tsx +482 -0
  53. package/src/custom-template-module.d.ts +5 -0
  54. package/src/custom-templates.ts +3 -0
  55. package/src/data.ts +52 -0
  56. package/src/empty-state.tsx +31 -0
  57. package/src/export.ts +77 -0
  58. package/src/index.ts +52 -0
  59. package/src/inline-plugin.ts +123 -0
  60. package/src/layout.tsx +152 -0
  61. package/src/main.tsx +17 -0
  62. package/src/overview.tsx +253 -0
  63. package/src/quality-hotspots-routes.tsx +340 -0
  64. package/src/remote-urls.ts +97 -0
  65. package/src/repo-routes.tsx +285 -0
  66. package/src/scan-routes.tsx +393 -0
  67. package/src/serialization.ts +58 -0
  68. package/src/styles.css +2 -0
  69. package/src/tables.tsx +467 -0
  70. package/src/template.ts +30 -0
  71. package/src/theme-toggle.tsx +24 -0
  72. package/src/theme.tsx +108 -0
  73. package/src/vite-env.d.ts +1 -0
  74. package/vite.config.ts +41 -0
@@ -0,0 +1,58 @@
1
+ import type { ReportData } from "@git-snitch/core";
2
+
3
+ export const REPORT_DATA_PLACEHOLDER = "__GIT_SNITCH_REPORT_DATA__";
4
+
5
+ const escapedCharacters = /[<>&\u2028\u2029]/g;
6
+
7
+ function escapeCharacter(character: string): string {
8
+ switch (character) {
9
+ case "<":
10
+ return "\\u003c";
11
+ case ">":
12
+ return "\\u003e";
13
+ case "&":
14
+ return "\\u0026";
15
+ case "\u2028":
16
+ return "\\u2028";
17
+ case "\u2029":
18
+ return "\\u2029";
19
+ default:
20
+ return character;
21
+ }
22
+ }
23
+
24
+ function escapeJsonForScript(json: string): string {
25
+ return json.replace(escapedCharacters, escapeCharacter);
26
+ }
27
+
28
+ export function serializeReportDataForHtml(report: ReportData): string {
29
+ let json: string | undefined;
30
+
31
+ try {
32
+ json = JSON.stringify(report);
33
+ } catch (error) {
34
+ const message = error instanceof Error ? error.message : "unknown serialization error";
35
+ throw new Error(`Report data could not be serialized for HTML injection: ${message}`);
36
+ }
37
+
38
+ if (json === undefined) {
39
+ throw new Error("Report data could not be serialized for HTML injection: JSON.stringify returned undefined.");
40
+ }
41
+
42
+ return escapeJsonForScript(json);
43
+ }
44
+
45
+ export function injectReportDataIntoHtml(templateHtml: string, report: ReportData): string {
46
+ const placeholderLiteral = JSON.stringify(REPORT_DATA_PLACEHOLDER);
47
+ const placeholderIndex = templateHtml.indexOf(placeholderLiteral);
48
+
49
+ if (placeholderIndex === -1) {
50
+ throw new Error(`Report template is missing the ${REPORT_DATA_PLACEHOLDER} data placeholder.`);
51
+ }
52
+
53
+ if (templateHtml.indexOf(placeholderLiteral, placeholderIndex + placeholderLiteral.length) !== -1) {
54
+ throw new Error(`Report template must contain exactly one ${REPORT_DATA_PLACEHOLDER} data placeholder.`);
55
+ }
56
+
57
+ return templateHtml.replace(placeholderLiteral, serializeReportDataForHtml(report));
58
+ }
package/src/styles.css ADDED
@@ -0,0 +1,2 @@
1
+ @import "@git-snitch/ui/globals.css";
2
+ @source "./**/*.{ts,tsx}";
package/src/tables.tsx ADDED
@@ -0,0 +1,467 @@
1
+ import {
2
+ flexRender,
3
+ getCoreRowModel,
4
+ getPaginationRowModel,
5
+ getSortedRowModel,
6
+ useReactTable,
7
+ } from "@tanstack/react-table";
8
+ import { Button } from "@git-snitch/ui/components/button";
9
+ import { Input } from "@git-snitch/ui/components/input";
10
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@git-snitch/ui/components/table";
11
+ import { cn } from "@git-snitch/ui/lib/utils";
12
+ import { useMemo, useState } from "react";
13
+ import type { ColumnDef, PaginationState, SortingState } from "@tanstack/react-table";
14
+ import type { FileHotspot, CommitRecord, ContributorSummary } from "@git-snitch/core";
15
+
16
+ import { buildCommitUrl, buildFileUrl } from "./remote-urls.js";
17
+ import { EmptyState } from "./empty-state.js";
18
+ import { downloadCsv } from "./export.js";
19
+ import type { CsvRow, DownloadResult } from "./export.js";
20
+
21
+ type CsvDownloader = (filename: string, rows: readonly CsvRow[], columns?: readonly string[]) => DownloadResult;
22
+
23
+ export type DataTableExport<TData> = {
24
+ readonly filename: string;
25
+ readonly mapRow: (row: TData) => CsvRow;
26
+ readonly columns?: readonly string[];
27
+ readonly downloader?: CsvDownloader;
28
+ };
29
+
30
+ export type DataTableEmptyState = {
31
+ readonly title: string;
32
+ readonly description: string;
33
+ };
34
+
35
+ export type DataTableProps<TData> = {
36
+ readonly data: readonly TData[];
37
+ readonly columns: ColumnDef<TData>[];
38
+ readonly emptyState: DataTableEmptyState;
39
+ readonly search?: {
40
+ readonly placeholder: string;
41
+ readonly toText: (row: TData) => string;
42
+ };
43
+ readonly exportConfig?: DataTableExport<TData>;
44
+ readonly initialPageSize?: number;
45
+ readonly ariaLabel: string;
46
+ };
47
+
48
+ const defaultPageSize = 10;
49
+ const pageSizes = [5, 10, 25, 50] as const;
50
+
51
+ function getAriaSort(sortDirection: false | "asc" | "desc"): "ascending" | "descending" | "none" {
52
+ if (sortDirection === "asc") {
53
+ return "ascending";
54
+ }
55
+ if (sortDirection === "desc") {
56
+ return "descending";
57
+ }
58
+ return "none";
59
+ }
60
+
61
+ export function DataTable<TData>({
62
+ data,
63
+ columns,
64
+ emptyState,
65
+ search,
66
+ exportConfig,
67
+ initialPageSize = defaultPageSize,
68
+ ariaLabel,
69
+ }: DataTableProps<TData>) {
70
+ const [query, setQuery] = useState("");
71
+ const [sorting, setSorting] = useState<SortingState>([]);
72
+ const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: initialPageSize });
73
+ const [exportStatus, setExportStatus] = useState<string | undefined>();
74
+
75
+ const filteredData = useMemo(() => {
76
+ const normalizedQuery = query.trim().toLocaleLowerCase();
77
+ if (search === undefined || normalizedQuery.length === 0) {
78
+ return data;
79
+ }
80
+ return data.filter((row) => search.toText(row).toLocaleLowerCase().includes(normalizedQuery));
81
+ }, [data, query, search]);
82
+ const tableData = useMemo(() => [...filteredData], [filteredData]);
83
+
84
+ const table = useReactTable({
85
+ data: tableData,
86
+ columns,
87
+ getCoreRowModel: getCoreRowModel(),
88
+ getPaginationRowModel: getPaginationRowModel(),
89
+ getSortedRowModel: getSortedRowModel(),
90
+ onPaginationChange: setPagination,
91
+ onSortingChange: setSorting,
92
+ state: { pagination, sorting },
93
+ });
94
+
95
+ const totalRows = filteredData.length;
96
+ const firstVisibleRow = totalRows === 0 ? 0 : pagination.pageIndex * pagination.pageSize + 1;
97
+ const lastVisibleRow = Math.min(totalRows, (pagination.pageIndex + 1) * pagination.pageSize);
98
+ const canExport = exportConfig !== undefined && totalRows > 0;
99
+
100
+ function handleSearchChange(value: string) {
101
+ setQuery(value);
102
+ setPagination((current) => ({ ...current, pageIndex: 0 }));
103
+ }
104
+
105
+ function handleExport() {
106
+ if (exportConfig === undefined) {
107
+ return;
108
+ }
109
+ const rows = table.getSortedRowModel().rows.map((row) => exportConfig.mapRow(row.original));
110
+ const result = (exportConfig.downloader ?? downloadCsv)(exportConfig.filename, rows, exportConfig.columns);
111
+ setExportStatus(result.status === "downloaded" ? "CSV export started." : result.reason);
112
+ }
113
+
114
+ if (data.length === 0) {
115
+ return <EmptyState title={emptyState.title} description={emptyState.description} />;
116
+ }
117
+
118
+ return (
119
+ <section className="rounded-xl border border-border/70 bg-card/80 shadow-sm" aria-label={ariaLabel}>
120
+ <div className="flex flex-col gap-3 border-b border-border/70 p-3 sm:flex-row sm:items-center sm:justify-between">
121
+ {search ? (
122
+ <label className="min-w-0 flex-1 sm:max-w-sm">
123
+ <span className="sr-only">Search table</span>
124
+ <Input
125
+ value={query}
126
+ onChange={(event) => handleSearchChange(event.currentTarget.value)}
127
+ placeholder={search.placeholder}
128
+ className="h-9 rounded-lg border-border/70 bg-background/70 text-sm"
129
+ />
130
+ </label>
131
+ ) : (
132
+ <div />
133
+ )}
134
+ <div className="flex items-center gap-2">
135
+ <p className="text-xs text-muted-foreground" aria-live="polite">
136
+ {totalRows === 0 ? "No matching rows" : `${firstVisibleRow}-${lastVisibleRow} of ${totalRows}`}
137
+ </p>
138
+ {exportConfig ? (
139
+ <Button variant="outline" size="sm" onClick={handleExport} disabled={!canExport}>
140
+ Export CSV
141
+ </Button>
142
+ ) : null}
143
+ </div>
144
+ </div>
145
+
146
+ {totalRows === 0 ? (
147
+ <div className="p-4">
148
+ <EmptyState title="No matching rows" description="Adjust the search term to bring matching report rows back into view." />
149
+ </div>
150
+ ) : (
151
+ <Table aria-label={ariaLabel}>
152
+ <TableHeader>
153
+ {table.getHeaderGroups().map((headerGroup) => (
154
+ <TableRow key={headerGroup.id} className="hover:bg-transparent">
155
+ {headerGroup.headers.map((header) => {
156
+ const canSort = header.column.getCanSort();
157
+ const sortDirection = header.column.getIsSorted();
158
+ return (
159
+ <TableHead
160
+ key={header.id}
161
+ className="bg-muted/30 px-3 py-2 text-[0.7rem] uppercase tracking-[0.18em] text-muted-foreground"
162
+ aria-sort={canSort ? getAriaSort(sortDirection) : undefined}
163
+ >
164
+ {header.isPlaceholder ? null : canSort ? (
165
+ <button
166
+ type="button"
167
+ className="inline-flex items-center gap-1 rounded-md text-left font-semibold outline-none transition-colors hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring"
168
+ onClick={header.column.getToggleSortingHandler()}
169
+ aria-label={`Sort by ${String(header.column.columnDef.header)}`}
170
+ >
171
+ {flexRender(header.column.columnDef.header, header.getContext())}
172
+ <span aria-hidden="true">{sortDirection === "asc" ? "↑" : sortDirection === "desc" ? "↓" : "↕"}</span>
173
+ </button>
174
+ ) : (
175
+ flexRender(header.column.columnDef.header, header.getContext())
176
+ )}
177
+ </TableHead>
178
+ );
179
+ })}
180
+ </TableRow>
181
+ ))}
182
+ </TableHeader>
183
+ <TableBody>
184
+ {table.getRowModel().rows.map((row) => (
185
+ <TableRow key={row.id}>
186
+ {row.getVisibleCells().map((cell) => (
187
+ <TableCell key={cell.id} className="px-3 py-3 text-sm">
188
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
189
+ </TableCell>
190
+ ))}
191
+ </TableRow>
192
+ ))}
193
+ </TableBody>
194
+ </Table>
195
+ )}
196
+
197
+ <div className="flex flex-col gap-3 border-t border-border/70 p-3 sm:flex-row sm:items-center sm:justify-between">
198
+ <div className="flex items-center gap-2">
199
+ <span className="text-xs text-muted-foreground">Rows per page</span>
200
+ <div className="flex gap-1" role="group" aria-label="Rows per page">
201
+ {pageSizes.map((size) => (
202
+ <Button
203
+ key={size}
204
+ type="button"
205
+ variant={pagination.pageSize === size ? "secondary" : "ghost"}
206
+ size="xs"
207
+ onClick={() => table.setPageSize(size)}
208
+ aria-pressed={pagination.pageSize === size}
209
+ aria-label={`${size} rows per page`}
210
+ >
211
+ {size}
212
+ </Button>
213
+ ))}
214
+ </div>
215
+ </div>
216
+ <div className="flex items-center justify-between gap-2 sm:justify-end">
217
+ <Button type="button" variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
218
+ Previous
219
+ </Button>
220
+ <span className="min-w-20 text-center text-xs text-muted-foreground">
221
+ Page {table.getState().pagination.pageIndex + 1} of {Math.max(table.getPageCount(), 1)}
222
+ </span>
223
+ <Button type="button" variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
224
+ Next
225
+ </Button>
226
+ </div>
227
+ </div>
228
+ {exportStatus ? <p className="border-t border-border/70 px-3 py-2 text-xs text-muted-foreground" aria-live="polite">{exportStatus}</p> : null}
229
+ </section>
230
+ );
231
+ }
232
+
233
+ export type CommitsTableProps = {
234
+ readonly commits: readonly CommitRecord[];
235
+ readonly exportFilename?: string;
236
+ readonly downloader?: CsvDownloader;
237
+ readonly remoteUrl?: string;
238
+ };
239
+
240
+ export function CommitsTable({ commits, exportFilename = "commits.csv", downloader, remoteUrl }: CommitsTableProps) {
241
+ return (
242
+ <DataTable
243
+ ariaLabel="Commits table"
244
+ data={commits}
245
+ columns={buildCommitColumns(remoteUrl)}
246
+ search={{ placeholder: "Search commits, authors, files", toText: commitSearchText }}
247
+ emptyState={{ title: "No commits to show", description: "This report did not include commits for the selected repository and branch scope." }}
248
+ exportConfig={{ filename: exportFilename, mapRow: commitToCsvRow, columns: commitCsvColumns, downloader }}
249
+ />
250
+ );
251
+ }
252
+
253
+ export type ContributorsTableProps = {
254
+ readonly contributors: readonly ContributorSummary[];
255
+ readonly exportFilename?: string;
256
+ readonly downloader?: CsvDownloader;
257
+ };
258
+
259
+ export function ContributorsTable({ contributors, exportFilename = "contributors.csv", downloader }: ContributorsTableProps) {
260
+ return (
261
+ <DataTable
262
+ ariaLabel="Contributors table"
263
+ data={contributors}
264
+ columns={contributorColumns}
265
+ search={{ placeholder: "Search contributors", toText: contributorSearchText }}
266
+ emptyState={{ title: "No contributors to show", description: "This report has no contributor activity yet." }}
267
+ exportConfig={{ filename: exportFilename, mapRow: contributorToCsvRow, columns: contributorCsvColumns, downloader }}
268
+ />
269
+ );
270
+ }
271
+
272
+ export type HotspotsTableProps = {
273
+ readonly hotspots: readonly FileHotspot[];
274
+ readonly exportFilename?: string;
275
+ readonly downloader?: CsvDownloader;
276
+ readonly remoteUrl?: string;
277
+ readonly currentBranch?: string;
278
+ };
279
+
280
+ export function HotspotsTable({ hotspots, exportFilename = "hotspots.csv", downloader, remoteUrl, currentBranch }: HotspotsTableProps) {
281
+ return (
282
+ <DataTable
283
+ ariaLabel="Hotspots table"
284
+ data={hotspots}
285
+ columns={buildHotspotColumns(remoteUrl, currentBranch)}
286
+ search={{ placeholder: "Search files or contributors", toText: hotspotSearchText }}
287
+ emptyState={{ title: "No hotspots to show", description: "This repository has no file churn data to rank yet." }}
288
+ exportConfig={{ filename: exportFilename, mapRow: hotspotToCsvRow, columns: hotspotCsvColumns, downloader }}
289
+ />
290
+ );
291
+ }
292
+
293
+ function commitAdditions(commit: CommitRecord): number {
294
+ return commit.files.reduce((sum, file) => sum + file.additions, 0);
295
+ }
296
+
297
+ function commitDeletions(commit: CommitRecord): number {
298
+ return commit.files.reduce((sum, file) => sum + file.deletions, 0);
299
+ }
300
+
301
+ function formatDate(isoDate: string | undefined): string {
302
+ if (isoDate === undefined) {
303
+ return "—";
304
+ }
305
+ return new Intl.DateTimeFormat("en", { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }).format(new Date(isoDate));
306
+ }
307
+
308
+ function numberCell(value: number): string {
309
+ return value.toLocaleString("en");
310
+ }
311
+
312
+ function RiskBadge({ level }: { readonly level: FileHotspot["riskLevel"]["level"] }) {
313
+ return (
314
+ <span
315
+ className={cn(
316
+ "inline-flex rounded-full border px-2 py-0.5 text-xs font-medium capitalize",
317
+ level === "high" ? "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300" : undefined,
318
+ level === "medium" ? "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300" : undefined,
319
+ level === "low" ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : undefined,
320
+ )}
321
+ >
322
+ {level}
323
+ </span>
324
+ );
325
+ }
326
+
327
+ function buildCommitColumns(remoteUrl?: string): ColumnDef<CommitRecord>[] {
328
+ return [
329
+ {
330
+ accessorKey: "shortHash",
331
+ header: "Commit",
332
+ cell: ({ row }) => {
333
+ const url = buildCommitUrl(remoteUrl, row.original.hash);
334
+ const inner = <span className="font-mono text-xs text-muted-foreground">{row.original.shortHash}</span>;
335
+ return url ? <a href={url} target="_blank" rel="noopener noreferrer">{inner}</a> : inner;
336
+ },
337
+ },
338
+ {
339
+ accessorKey: "message",
340
+ header: "Message",
341
+ cell: ({ row }) => <span className="block max-w-xl truncate font-medium text-foreground">{row.original.message}</span>,
342
+ },
343
+ {
344
+ accessorFn: (commit) => commit.author.name,
345
+ id: "author",
346
+ header: "Author",
347
+ },
348
+ {
349
+ accessorKey: "classification",
350
+ header: "Type",
351
+ cell: ({ row }) => <span className="capitalize">{row.original.classification}</span>,
352
+ },
353
+ {
354
+ accessorFn: (commit) => commitAdditions(commit),
355
+ id: "additions",
356
+ header: "Additions",
357
+ cell: ({ row }) => numberCell(commitAdditions(row.original)),
358
+ },
359
+ {
360
+ accessorFn: (commit) => commitDeletions(commit),
361
+ id: "deletions",
362
+ header: "Deletions",
363
+ cell: ({ row }) => numberCell(commitDeletions(row.original)),
364
+ },
365
+ {
366
+ accessorKey: "authoredAt",
367
+ header: "Authored",
368
+ cell: ({ row }) => formatDate(row.original.authoredAt),
369
+ },
370
+ ];
371
+ }
372
+
373
+ const contributorColumns: ColumnDef<ContributorSummary>[] = [
374
+ { accessorKey: "name", header: "Contributor", cell: ({ row }) => <span className="font-medium text-foreground">{row.original.name}</span> },
375
+ { accessorKey: "email", header: "Email" },
376
+ { accessorKey: "commitCount", header: "Commits", cell: ({ row }) => numberCell(row.original.commitCount) },
377
+ { accessorKey: "additions", header: "Additions", cell: ({ row }) => numberCell(row.original.additions) },
378
+ { accessorKey: "deletions", header: "Deletions", cell: ({ row }) => numberCell(row.original.deletions) },
379
+ { accessorKey: "filesChanged", header: "Files", cell: ({ row }) => numberCell(row.original.filesChanged) },
380
+ { accessorKey: "lastCommitAt", header: "Last seen", cell: ({ row }) => formatDate(row.original.lastCommitAt) },
381
+ ];
382
+
383
+ function buildHotspotColumns(remoteUrl?: string, currentBranch?: string): ColumnDef<FileHotspot>[] {
384
+ return [
385
+ {
386
+ accessorKey: "path",
387
+ header: "File",
388
+ cell: ({ row }) => {
389
+ const url = buildFileUrl(remoteUrl, currentBranch, row.original.path);
390
+ const inner = <span className="font-mono text-xs text-foreground">{row.original.path}</span>;
391
+ return url ? <a href={url} target="_blank" rel="noopener noreferrer">{inner}</a> : inner;
392
+ },
393
+ },
394
+ { accessorKey: "riskLevel.level", header: "Risk", cell: ({ row }) => <RiskBadge level={row.original.riskLevel.level} /> },
395
+ { accessorKey: "hotspotScore", header: "Score", cell: ({ row }) => numberCell(row.original.hotspotScore) },
396
+ { accessorKey: "changeCount", header: "Changes", cell: ({ row }) => numberCell(row.original.changeCount) },
397
+ { accessorKey: "churn", header: "Churn", cell: ({ row }) => numberCell(row.original.churn) },
398
+ { accessorKey: "contributorCount", header: "Contributors", cell: ({ row }) => numberCell(row.original.contributorCount) },
399
+ { accessorKey: "lastChangedAt", header: "Last changed", cell: ({ row }) => formatDate(row.original.lastChangedAt) },
400
+ ];
401
+ }
402
+
403
+ const commitCsvColumns = ["hash", "message", "author", "email", "classification", "additions", "deletions", "authoredAt", "files"] as const;
404
+ const contributorCsvColumns = ["name", "email", "commitCount", "additions", "deletions", "filesChanged", "firstCommitAt", "lastCommitAt"] as const;
405
+ const hotspotCsvColumns = ["path", "risk", "hotspotScore", "changeCount", "additions", "deletions", "churn", "contributors", "lastChangedAt"] as const;
406
+
407
+ function commitSearchText(commit: CommitRecord): string {
408
+ return [
409
+ commit.hash,
410
+ commit.shortHash,
411
+ commit.message,
412
+ commit.author.name,
413
+ commit.author.email,
414
+ commit.classification,
415
+ ...commit.refs,
416
+ ...commit.files.map((file) => file.path),
417
+ ].join(" ");
418
+ }
419
+
420
+ function contributorSearchText(contributor: ContributorSummary): string {
421
+ return `${contributor.name} ${contributor.email}`;
422
+ }
423
+
424
+ function hotspotSearchText(hotspot: FileHotspot): string {
425
+ return `${hotspot.path} ${hotspot.riskLevel.level} ${hotspot.contributors.join(" ")}`;
426
+ }
427
+
428
+ function commitToCsvRow(commit: CommitRecord): CsvRow {
429
+ return {
430
+ hash: commit.hash,
431
+ message: commit.message,
432
+ author: commit.author.name,
433
+ email: commit.author.email,
434
+ classification: commit.classification,
435
+ additions: commitAdditions(commit),
436
+ deletions: commitDeletions(commit),
437
+ authoredAt: commit.authoredAt,
438
+ files: commit.files.map((file) => file.path).join("; "),
439
+ };
440
+ }
441
+
442
+ function contributorToCsvRow(contributor: ContributorSummary): CsvRow {
443
+ return {
444
+ name: contributor.name,
445
+ email: contributor.email,
446
+ commitCount: contributor.commitCount,
447
+ additions: contributor.additions,
448
+ deletions: contributor.deletions,
449
+ filesChanged: contributor.filesChanged,
450
+ firstCommitAt: contributor.firstCommitAt,
451
+ lastCommitAt: contributor.lastCommitAt,
452
+ };
453
+ }
454
+
455
+ function hotspotToCsvRow(hotspot: FileHotspot): CsvRow {
456
+ return {
457
+ path: hotspot.path,
458
+ risk: hotspot.riskLevel.level,
459
+ hotspotScore: hotspot.hotspotScore,
460
+ changeCount: hotspot.changeCount,
461
+ additions: hotspot.additions,
462
+ deletions: hotspot.deletions,
463
+ churn: hotspot.churn,
464
+ contributors: hotspot.contributors.join("; "),
465
+ lastChangedAt: hotspot.lastChangedAt,
466
+ };
467
+ }
@@ -0,0 +1,30 @@
1
+ import type {
2
+ RepoTemplateContext,
3
+ ScanProjectTemplateContext,
4
+ ScanTemplateContext,
5
+ TemplateRouteId,
6
+ } from "@git-snitch/core";
7
+ import type { ReactNode } from "react";
8
+
9
+ export type TemplateComponent<Props> = (props: Props) => ReactNode;
10
+
11
+ export interface RouteTemplatePropsById {
12
+ readonly overview: RepoTemplateContext;
13
+ readonly commits: RepoTemplateContext;
14
+ readonly contributors: RepoTemplateContext;
15
+ readonly charts: RepoTemplateContext;
16
+ readonly quality: RepoTemplateContext;
17
+ readonly hotspots: RepoTemplateContext;
18
+ readonly scanOverview: ScanTemplateContext;
19
+ readonly scanProject: ScanProjectTemplateContext;
20
+ }
21
+
22
+ export type RouteTemplateOverrides = {
23
+ readonly [RouteId in TemplateRouteId]?: TemplateComponent<RouteTemplatePropsById[RouteId]>;
24
+ };
25
+
26
+ export interface TemplateModule {
27
+ readonly templates: RouteTemplateOverrides;
28
+ }
29
+
30
+ export type { RepoTemplateContext, ScanProjectTemplateContext, ScanTemplateContext, TemplateRouteId } from "@git-snitch/core";
@@ -0,0 +1,24 @@
1
+ import { Button } from "@git-snitch/ui/components/button";
2
+
3
+ import { useTheme } from "./theme.js";
4
+
5
+ export function ThemeToggle() {
6
+ const { theme, toggleTheme } = useTheme();
7
+ const isDark = theme === "dark";
8
+ const label = isDark ? "Switch to light theme" : "Switch to dark theme";
9
+
10
+ return (
11
+ <Button
12
+ type="button"
13
+ variant="outline"
14
+ size="sm"
15
+ aria-label={label}
16
+ aria-pressed={isDark}
17
+ onClick={toggleTheme}
18
+ className="border-foreground/15 bg-background/80 text-foreground shadow-sm backdrop-blur transition-colors hover:bg-muted"
19
+ >
20
+ <span aria-hidden="true" className="size-2 rounded-full bg-current" />
21
+ <span>{isDark ? "Light" : "Dark"}</span>
22
+ </Button>
23
+ );
24
+ }
package/src/theme.tsx ADDED
@@ -0,0 +1,108 @@
1
+ import { createContext, useContext, useEffect, useMemo, useState } from "react";
2
+ import type { ReactNode } from "react";
3
+
4
+ export type Theme = "light" | "dark";
5
+
6
+ type ThemeContextValue = {
7
+ theme: Theme;
8
+ setTheme: (theme: Theme) => void;
9
+ toggleTheme: () => void;
10
+ };
11
+
12
+ type ThemeProviderProps = {
13
+ children: ReactNode;
14
+ defaultTheme?: Theme;
15
+ storageKey?: string;
16
+ };
17
+
18
+ const DEFAULT_THEME: Theme = "light";
19
+ const DEFAULT_STORAGE_KEY = "git-snitch-theme";
20
+
21
+ const ThemeContext = createContext<ThemeContextValue | null>(null);
22
+
23
+ function isTheme(value: string | null): value is Theme {
24
+ return value === "light" || value === "dark";
25
+ }
26
+
27
+ function getStoredTheme(storageKey: string): Theme | null {
28
+ if (typeof window === "undefined") {
29
+ return null;
30
+ }
31
+
32
+ try {
33
+ const storedTheme = window.localStorage.getItem(storageKey);
34
+ return isTheme(storedTheme) ? storedTheme : null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function getSystemTheme(): Theme | null {
41
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
42
+ return null;
43
+ }
44
+
45
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
46
+ }
47
+
48
+ function persistTheme(storageKey: string, theme: Theme): void {
49
+ if (typeof window === "undefined") {
50
+ return;
51
+ }
52
+
53
+ try {
54
+ window.localStorage.setItem(storageKey, theme);
55
+ } catch {
56
+ return;
57
+ }
58
+ }
59
+
60
+ function applyThemeClass(theme: Theme): void {
61
+ if (typeof document === "undefined") {
62
+ return;
63
+ }
64
+
65
+ document.documentElement.classList.toggle("dark", theme === "dark");
66
+ document.documentElement.style.colorScheme = theme;
67
+ }
68
+
69
+ export function ThemeProvider({
70
+ children,
71
+ defaultTheme = DEFAULT_THEME,
72
+ storageKey = DEFAULT_STORAGE_KEY,
73
+ }: ThemeProviderProps) {
74
+ const [theme, setThemeState] = useState<Theme>(defaultTheme);
75
+
76
+ useEffect(() => {
77
+ const initialTheme = getStoredTheme(storageKey) ?? getSystemTheme() ?? defaultTheme;
78
+ setThemeState(initialTheme);
79
+ }, [defaultTheme, storageKey]);
80
+
81
+ useEffect(() => {
82
+ applyThemeClass(theme);
83
+ persistTheme(storageKey, theme);
84
+ }, [storageKey, theme]);
85
+
86
+ const value = useMemo<ThemeContextValue>(
87
+ () => ({
88
+ theme,
89
+ setTheme: setThemeState,
90
+ toggleTheme: () => {
91
+ setThemeState((currentTheme) => (currentTheme === "dark" ? "light" : "dark"));
92
+ },
93
+ }),
94
+ [theme],
95
+ );
96
+
97
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
98
+ }
99
+
100
+ export function useTheme() {
101
+ const context = useContext(ThemeContext);
102
+
103
+ if (!context) {
104
+ throw new Error("useTheme must be used within ThemeProvider.");
105
+ }
106
+
107
+ return context;
108
+ }
@@ -0,0 +1 @@
1
+ declare module "*.css";