File
Blob: src/client/islands/merge-expander.tsx
| 1 | /// <reference lib="dom" /> |
| 2 | |
| 3 | import type { MouseEvent } from "react"; |
| 4 | import { Fragment, useState } from "react"; |
| 5 | |
| 6 | import { hydrateIsland } from "@/client/hydrate"; |
| 7 | import { CommitRow } from "@/client/components/CommitRow"; |
| 8 | |
| 9 | type CommitView = { |
| 10 | oid: string; |
| 11 | shortOid: string; |
| 12 | firstLine: string; |
| 13 | authorName: string; |
| 14 | when: string; |
| 15 | isMerge?: boolean; |
| 16 | }; |
| 17 | |
| 18 | export type MergeExpanderProps = { |
| 19 | owner: string; |
| 20 | repo: string; |
| 21 | commits: CommitView[]; |
| 22 | }; |
| 23 | |
| 24 | function MergeStatusRow({ message }: { message: string }) { |
| 25 | return ( |
| 26 | <tr> |
| 27 | <td colSpan={4} className="text-zinc-500 dark:text-zinc-400"> |
| 28 | {message} |
| 29 | </td> |
| 30 | </tr> |
| 31 | ); |
| 32 | } |
| 33 | |
| 34 | export function MergeExpanderIsland({ owner, repo, commits }: MergeExpanderProps) { |
| 35 | const [expandedByOid, setExpandedByOid] = useState<Record<string, boolean>>({}); |
| 36 | const [loadingByOid, setLoadingByOid] = useState<Record<string, boolean>>({}); |
| 37 | const [errorByOid, setErrorByOid] = useState<Record<string, string | null>>({}); |
| 38 | const [mergeRowsByOid, setMergeRowsByOid] = useState<Record<string, CommitView[]>>({}); |
| 39 | |
| 40 | async function toggleMerge(oid: string) { |
| 41 | if (loadingByOid[oid]) { |
| 42 | return; |
| 43 | } |
| 44 | |
| 45 | if (expandedByOid[oid]) { |
| 46 | setExpandedByOid((current) => ({ ...current, [oid]: false })); |
| 47 | return; |
| 48 | } |
| 49 | |
| 50 | if (mergeRowsByOid[oid]) { |
| 51 | setExpandedByOid((current) => ({ ...current, [oid]: true })); |
| 52 | setErrorByOid((current) => ({ ...current, [oid]: null })); |
| 53 | return; |
| 54 | } |
| 55 | |
| 56 | setLoadingByOid((current) => ({ ...current, [oid]: true })); |
| 57 | setExpandedByOid((current) => ({ ...current, [oid]: true })); |
| 58 | setErrorByOid((current) => ({ ...current, [oid]: null })); |
| 59 | |
| 60 | try { |
| 61 | const response = await fetch(`/${owner}/${repo}/commits/fragments/${oid}?limit=20`, { |
| 62 | headers: { Accept: "application/json" }, |
| 63 | }); |
| 64 | if (!response.ok) { |
| 65 | throw new Error(`HTTP ${response.status}`); |
| 66 | } |
| 67 | |
| 68 | const data = (await response.json()) as { commits?: CommitView[] }; |
| 69 | setMergeRowsByOid((current) => ({ ...current, [oid]: data.commits || [] })); |
| 70 | } catch (error) { |
| 71 | setErrorByOid((current) => ({ |
| 72 | ...current, |
| 73 | [oid]: error instanceof Error ? error.message : "Failed to load merge commits", |
| 74 | })); |
| 75 | } finally { |
| 76 | setLoadingByOid((current) => ({ ...current, [oid]: false })); |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | function onMergeRowClick(oid: string) { |
| 81 | return (event: MouseEvent<HTMLTableRowElement>) => { |
| 82 | const target = event.target as HTMLElement; |
| 83 | if (target.closest("a, button")) { |
| 84 | return; |
| 85 | } |
| 86 | |
| 87 | void toggleMerge(oid); |
| 88 | }; |
| 89 | } |
| 90 | |
| 91 | return ( |
| 92 | <table |
| 93 | id="commits-table" |
| 94 | data-owner={owner} |
| 95 | data-repo={repo} |
| 96 | className="mt-4 overflow-hidden rounded-2xl border border-zinc-200 shadow-xs dark:border-zinc-800/60" |
| 97 | > |
| 98 | <thead> |
| 99 | <tr> |
| 100 | <th>OID</th> |
| 101 | <th>Message</th> |
| 102 | <th>Author</th> |
| 103 | <th>Date</th> |
| 104 | </tr> |
| 105 | </thead> |
| 106 | <tbody> |
| 107 | {commits.length ? ( |
| 108 | commits.map((commit) => { |
| 109 | const isMerge = Boolean(commit.isMerge); |
| 110 | const mergeOid = commit.oid; |
| 111 | const isExpanded = Boolean(expandedByOid[mergeOid]); |
| 112 | const isLoading = Boolean(loadingByOid[mergeOid]); |
| 113 | const mergeRows = mergeRowsByOid[mergeOid] || []; |
| 114 | const error = errorByOid[mergeOid]; |
| 115 | const mergeRowClass = isMerge |
| 116 | ? "cursor-pointer bg-accent-50/40 hover:bg-accent-50/60 dark:bg-accent-900/15 dark:hover:bg-accent-900/25" |
| 117 | : ""; |
| 118 | |
| 119 | return ( |
| 120 | <Fragment key={commit.oid}> |
| 121 | <CommitRow |
| 122 | owner={owner} |
| 123 | repo={repo} |
| 124 | commit={commit} |
| 125 | isMerge={isMerge} |
| 126 | rowClass={mergeRowClass} |
| 127 | toggleOid={isMerge ? mergeOid : undefined} |
| 128 | mergeExpanded={isExpanded} |
| 129 | onToggle={isMerge ? onMergeRowClick(mergeOid) : undefined} |
| 130 | /> |
| 131 | {isMerge && isExpanded && isLoading ? ( |
| 132 | <MergeStatusRow message="Loading..." /> |
| 133 | ) : null} |
| 134 | {isMerge && isExpanded && !isLoading && error ? ( |
| 135 | <MergeStatusRow message={`Failed to load merge commits: ${error}`} /> |
| 136 | ) : null} |
| 137 | {isMerge && isExpanded && !isLoading && !error && !mergeRows.length ? ( |
| 138 | <MergeStatusRow message="(No commits to show for this merge yet)" /> |
| 139 | ) : null} |
| 140 | {isMerge && isExpanded && !isLoading && !error |
| 141 | ? mergeRows.map((entry) => ( |
| 142 | <CommitRow |
| 143 | key={entry.oid} |
| 144 | owner={owner} |
| 145 | repo={repo} |
| 146 | commit={entry} |
| 147 | compact |
| 148 | mergeOf={mergeOid} |
| 149 | /> |
| 150 | )) |
| 151 | : null} |
| 152 | </Fragment> |
| 153 | ); |
| 154 | }) |
| 155 | ) : ( |
| 156 | <tr> |
| 157 | <td colSpan={4} className="text-zinc-500 dark:text-zinc-400"> |
| 158 | (none) |
| 159 | </td> |
| 160 | </tr> |
| 161 | )} |
| 162 | </tbody> |
| 163 | </table> |
| 164 | ); |
| 165 | } |
| 166 | |
| 167 | export function initMergeExpander() { |
| 168 | hydrateIsland<MergeExpanderProps>("merge-expander", MergeExpanderIsland); |
| 169 | } |