csr tangled client in solid-js
15

Configure Feed

Select the types of activity you want to include in your feed.

update new pr page to be closer to upstream

dawn (May 19, 2026, 7:30 PM +0300) 0cc0af31 54f2bace

+734 -114
+98 -16
src/components/repo.tsx
··· 3 3 import { 4 4 ChevronRight, 5 5 Circle, 6 + Columns2, 6 7 FileText, 8 + FoldVertical, 7 9 Folder, 8 10 Minimize2, 9 11 PanelLeftClose, 10 12 PanelLeftOpen, 11 13 PanelRightClose, 12 14 PanelRightOpen, 15 + UnfoldVertical, 13 16 ChevronLeft, 14 17 } from 'lucide-solid'; 15 18 import { marked } from 'marked'; ··· 523 526 524 527 const pushCurrent = () => { 525 528 if (!current) return; 529 + const hasRenderableDiff = current.lines.some( 530 + (line) => 531 + line.startsWith('@@') || 532 + line.startsWith('Binary files ') || 533 + line.startsWith('new file mode ') || 534 + line.startsWith('deleted file mode ') || 535 + line.startsWith('old mode ') || 536 + line.startsWith('similarity index ') || 537 + line.startsWith('rename from '), 538 + ); 539 + if (!hasRenderableDiff && current.additions === 0 && current.deletions === 0) { 540 + current = null; 541 + return; 542 + } 526 543 files.push({ 527 544 path: current.path, 528 545 text: current.lines.join('\n'), ··· 559 576 560 577 export const DiffView: Component<{ patch: string; maxHeight?: string; wrap?: boolean }> = (props) => { 561 578 const files = createMemo(() => parseDiffFiles(props.patch)); 579 + const stats = createMemo(() => totalDiffStats(files())); 580 + const [expanded, setExpanded] = createSignal(false); 581 + const previewLimit = 5; 582 + const visibleFiles = createMemo(() => files().slice(0, previewLimit)); 583 + const hiddenFiles = createMemo(() => files().slice(previewLimit)); 562 584 563 585 return ( 564 586 <div class="untangled-diff-view"> 565 - <For each={files()}> 566 - {(file) => ( 567 - <details class="untangled-diff-file" open> 568 - <summary class="untangled-diff-file-header"> 569 - <div class="flex min-w-0 items-center gap-2"> 570 - <FileText class="size-4 shrink-0 text-gray-400" /> 571 - <span class="truncate font-mono text-sm text-gray-100">{file.path}</span> 587 + <div class="untangled-diff-panel"> 588 + <div class="untangled-diff-toolbar"> 589 + <div class="flex min-w-0 items-center gap-2"> 590 + <DiffStatPill additions={stats().additions} deletions={stats().deletions} /> 591 + <span class="text-xs text-gray-300"> 592 + {files().length} changed file{files().length === 1 ? '' : 's'} 593 + </span> 594 + </div> 595 + <div class="flex items-center gap-2"> 596 + <button type="button" class="untangled-diff-tool-button" onClick={() => setExpanded(!expanded())}> 597 + <Show when={expanded()} fallback={<UnfoldVertical class="w-4 h-4" />}> 598 + <FoldVertical class="w-4 h-4" /> 599 + </Show> 600 + {expanded() ? 'Collapse all' : 'Expand all'} 601 + </button> 602 + <div class="untangled-diff-mode-group"> 603 + <button type="button" class="untangled-diff-mode-button untangled-diff-mode-button-active"> 604 + <FileText class="w-4 h-4" /> 605 + unified 606 + </button> 607 + <button type="button" class="untangled-diff-mode-button" disabled title="Split diff is not available yet"> 608 + <Columns2 class="w-4 h-4" /> 609 + split 610 + </button> 611 + </div> 612 + </div> 613 + </div> 614 + <div class="untangled-diff-panel-body"> 615 + <For each={visibleFiles()}> 616 + {(file) => ( 617 + <details class="untangled-diff-file" open={expanded()}> 618 + <summary class="untangled-diff-file-header"> 619 + <div class="flex min-w-0 items-center gap-2"> 620 + <ChevronRight class="untangled-diff-chevron size-4 shrink-0" /> 621 + <DiffStatPill additions={file.additions} deletions={file.deletions} /> 622 + <span class="truncate font-mono text-sm text-gray-100">{file.path}</span> 623 + </div> 624 + </summary> 625 + <CodeView text={file.text} wrap={props.wrap} maxHeight={props.maxHeight} diff /> 626 + </details> 627 + )} 628 + </For> 629 + <Show when={hiddenFiles().length > 0}> 630 + <details class="untangled-diff-show-more"> 631 + <summary class="untangled-diff-show-more-summary"> 632 + <span class="untangled-diff-show-more-closed"> 633 + <ChevronRight class="untangled-diff-chevron size-4 shrink-0" /> 634 + Show {hiddenFiles().length} more file{hiddenFiles().length === 1 ? '' : 's'} 635 + </span> 636 + <span class="untangled-diff-show-more-open"> 637 + <ChevronRight class="untangled-diff-chevron size-4 shrink-0" /> 638 + Hide {hiddenFiles().length} file{hiddenFiles().length === 1 ? '' : 's'} 639 + </span> 640 + </summary> 641 + <div class="untangled-diff-show-more-files"> 642 + <For each={hiddenFiles()}> 643 + {(file) => ( 644 + <details class="untangled-diff-file" open={expanded()}> 645 + <summary class="untangled-diff-file-header"> 646 + <div class="flex min-w-0 items-center gap-2"> 647 + <ChevronRight class="untangled-diff-chevron size-4 shrink-0" /> 648 + <DiffStatPill additions={file.additions} deletions={file.deletions} /> 649 + <span class="truncate font-mono text-sm text-gray-100">{file.path}</span> 650 + </div> 651 + </summary> 652 + <CodeView text={file.text} wrap={props.wrap} maxHeight={props.maxHeight} diff /> 653 + </details> 654 + )} 655 + </For> 572 656 </div> 573 - <div class="flex shrink-0 items-center gap-2 font-mono text-xs"> 574 - <span class="text-green-300">+{file.additions}</span> 575 - <span class="text-red-300">-{file.deletions}</span> 576 - </div> 577 - </summary> 578 - <CodeView text={file.text} wrap={props.wrap} maxHeight={props.maxHeight} diff /> 579 - </details> 580 - )} 581 - </For> 657 + </details> 658 + </Show> 659 + <Show when={files().length === 0}> 660 + <div class="px-4 py-8 text-center text-sm text-gray-400">No differences found.</div> 661 + </Show> 662 + </div> 663 + </div> 582 664 </div> 583 665 ); 584 666 };
+187 -1
src/index.css
··· 5 5 font-family: InterVariable, system-ui, sans-serif, ui-sans-serif; 6 6 } 7 7 8 + .untangled-new-pr-step { 9 + position: relative; 10 + display: flex; 11 + min-width: 0; 12 + flex-direction: column; 13 + gap: 0.75rem; 14 + } 15 + 16 + .untangled-new-pr-heading { 17 + position: relative; 18 + display: flex; 19 + align-items: center; 20 + gap: 1rem; 21 + } 22 + 23 + .untangled-new-pr-step-line { 24 + position: absolute; 25 + left: 0.75rem; 26 + width: 1px; 27 + transform: translateX(-50%); 28 + background: rgb(229 231 235); 29 + } 30 + 31 + .untangled-new-pr-step-line-after { 32 + top: 0.75rem; 33 + bottom: -1.5rem; 34 + } 35 + 36 + .untangled-new-pr-step-line-before { 37 + top: -1.5rem; 38 + } 39 + 40 + .untangled-new-pr-step-line-through { 41 + bottom: -1.5rem; 42 + } 43 + 44 + .untangled-new-pr-step-line-fade { 45 + height: 3rem; 46 + -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 1.5rem), transparent); 47 + mask-image: linear-gradient(to bottom, black calc(100% - 1.5rem), transparent); 48 + } 49 + 50 + .untangled-new-pr-step-line-before.untangled-new-pr-step-line-fade { 51 + bottom: auto; 52 + } 53 + 54 + .untangled-new-pr-diff { 55 + min-width: 0; 56 + } 57 + 58 + @media (prefers-color-scheme: dark) { 59 + .untangled-new-pr-step-line { 60 + background: rgb(55 65 81); 61 + } 62 + } 63 + 8 64 .untangled-pr-detail { 9 65 display: flex; 10 66 min-width: 0; ··· 1140 1196 .untangled-diff-view { 1141 1197 display: flex; 1142 1198 flex-direction: column; 1143 - gap: 0.75rem; 1199 + gap: 0; 1200 + } 1201 + 1202 + .untangled-diff-panel { 1203 + overflow: hidden; 1204 + border: 1px solid rgb(55 65 81); 1205 + border-radius: 0.25rem; 1206 + background: rgb(31 41 55); 1207 + } 1208 + 1209 + .untangled-diff-toolbar { 1210 + display: flex; 1211 + align-items: center; 1212 + justify-content: space-between; 1213 + gap: 1rem; 1214 + background: rgb(55 65 81 / 0.5); 1215 + border-bottom: 1px solid rgb(55 65 81); 1216 + padding: 0.5rem 0.75rem; 1217 + } 1218 + 1219 + .untangled-diff-tool-button, 1220 + .untangled-diff-mode-button { 1221 + display: inline-flex; 1222 + align-items: center; 1223 + gap: 0.375rem; 1224 + min-height: 2rem; 1225 + border: 1px solid rgb(55 65 81); 1226 + border-radius: 0.25rem; 1227 + background: rgb(17 24 39 / 0.35); 1228 + padding: 0.25rem 0.625rem; 1229 + font-size: 0.875rem; 1230 + color: rgb(229 231 235); 1231 + } 1232 + 1233 + .untangled-diff-tool-button:hover, 1234 + .untangled-diff-mode-button:not(:disabled):hover { 1235 + background: rgb(55 65 81 / 0.65); 1236 + } 1237 + 1238 + .untangled-diff-mode-group { 1239 + display: inline-flex; 1240 + align-items: center; 1241 + border: 1px solid rgb(55 65 81); 1242 + border-radius: 0.25rem; 1243 + overflow: hidden; 1244 + } 1245 + 1246 + .untangled-diff-mode-group .untangled-diff-mode-button { 1247 + border: 0; 1248 + border-radius: 0; 1249 + } 1250 + 1251 + .untangled-diff-mode-group .untangled-diff-mode-button + .untangled-diff-mode-button { 1252 + border-left: 1px solid rgb(55 65 81); 1253 + } 1254 + 1255 + .untangled-diff-mode-button-active { 1256 + background: rgb(55 65 81); 1257 + } 1258 + 1259 + .untangled-diff-mode-button:disabled { 1260 + cursor: not-allowed; 1261 + opacity: 0.65; 1262 + } 1263 + 1264 + .untangled-diff-panel-body { 1265 + display: flex; 1266 + flex-direction: column; 1267 + gap: 0.5rem; 1268 + padding: 0.5rem; 1144 1269 } 1145 1270 1146 1271 .untangled-diff-file { ··· 1166 1291 display: none; 1167 1292 } 1168 1293 1294 + .untangled-diff-chevron { 1295 + transition: transform 120ms ease; 1296 + } 1297 + 1298 + .untangled-diff-file[open] .untangled-diff-chevron { 1299 + transform: rotate(90deg); 1300 + } 1301 + 1169 1302 .untangled-diff-file:not([open]) .untangled-diff-file-header { 1170 1303 border-bottom: 0; 1304 + } 1305 + 1306 + .untangled-diff-show-more { 1307 + min-width: 0; 1308 + } 1309 + 1310 + .untangled-diff-show-more-summary { 1311 + display: flex; 1312 + list-style: none; 1313 + cursor: pointer; 1314 + user-select: none; 1315 + align-items: center; 1316 + gap: 0.5rem; 1317 + padding: 0.5rem 0.25rem; 1318 + font-size: 0.875rem; 1319 + color: rgb(209 213 219); 1320 + } 1321 + 1322 + .untangled-diff-show-more-summary::-webkit-details-marker { 1323 + display: none; 1324 + } 1325 + 1326 + .untangled-diff-show-more-closed, 1327 + .untangled-diff-show-more-open { 1328 + align-items: center; 1329 + gap: 0.5rem; 1330 + } 1331 + 1332 + .untangled-diff-show-more-closed { 1333 + display: inline-flex; 1334 + } 1335 + 1336 + .untangled-diff-show-more-open { 1337 + display: none; 1338 + } 1339 + 1340 + .untangled-diff-show-more[open] > .untangled-diff-show-more-summary .untangled-diff-show-more-closed { 1341 + display: none; 1342 + } 1343 + 1344 + .untangled-diff-show-more[open] > .untangled-diff-show-more-summary .untangled-diff-show-more-open { 1345 + display: inline-flex; 1346 + } 1347 + 1348 + .untangled-diff-show-more[open] > .untangled-diff-show-more-summary .untangled-diff-chevron { 1349 + transform: rotate(90deg); 1350 + } 1351 + 1352 + .untangled-diff-show-more-files { 1353 + display: flex; 1354 + flex-direction: column; 1355 + gap: 0.5rem; 1356 + margin-top: 0.5rem; 1171 1357 } 1172 1358 1173 1359 .z-60 {
+17
src/pages/repo/pulls-helpers.ts
··· 1 1 import type { SearchParamValue } from '../../lib/repo-utils'; 2 2 3 3 export type PullStateFilter = 'open' | 'closed' | 'merged'; 4 + export type PullSourceMode = 'branch' | 'patch'; 4 5 5 6 export const parsePullStateFilter = (value: SearchParamValue): PullStateFilter => { 6 7 if (value === 'state:closed') { ··· 13 14 14 15 return 'open'; 15 16 }; 17 + 18 + export const parsePastedPatchPrefill = (patch: string): { title: string; body: string } => { 19 + const subject = /^Subject:\s*(?:\[[^\]]+\]\s*)?(.*)$/im.exec(patch)?.[1]?.trim() ?? ''; 20 + const headerEnd = patch.indexOf('\n---'); 21 + const bodyStart = patch.search(/^Subject:.*$/im); 22 + 23 + if (!subject || bodyStart === -1 || headerEnd === -1 || headerEnd <= bodyStart) { 24 + return { title: subject, body: '' }; 25 + } 26 + 27 + const afterSubject = patch.slice(bodyStart).replace(/^Subject:.*(?:\r?\n)?/i, ''); 28 + const separator = afterSubject.indexOf('\n---'); 29 + const body = separator === -1 ? '' : afterSubject.slice(0, separator).trim(); 30 + 31 + return { title: subject, body }; 32 + };
+432 -97
src/pages/repo/pulls.tsx
··· 1 1 import clsx from 'clsx'; 2 - import { CirclePlus, Copy, GitMerge, GitPullRequest, GitPullRequestClosed, LoaderCircle, MessageSquare, Search } from 'lucide-solid'; 2 + import { 3 + ArrowLeftRight, 4 + CirclePlus, 5 + Copy, 6 + Eye, 7 + FolderCode, 8 + GitMerge, 9 + GitPullRequest, 10 + GitPullRequestClosed, 11 + LoaderCircle, 12 + MessageSquare, 13 + Pencil, 14 + Search, 15 + } from 'lucide-solid'; 3 16 import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router'; 4 17 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 5 - import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; 18 + import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js'; 6 19 import { compareBranches, createPull, createPullComment, fetchPullRoundPatch, getPull, getRepoBranches, listPullsPage, parseAtUri, setPullStatus, type RepoContext } from '../../lib/api'; 7 20 import { useAuth } from '../../lib/auth'; 8 - import { Avatar, ErrorState, LoadingState, StateBadge, ToggleButton, buttonStyles, cardStyles, inputStyles, textareaStyles } from '../../components/common'; 21 + import { Avatar, ErrorState, LoadingState, StateBadge, ToggleButton, buttonStyles, cardStyles, textareaStyles } from '../../components/common'; 9 22 import { BranchPill, CommentCard, DiffView, MarkdownBlock, PaginationControls, PullDiffView, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; 10 23 import { RepoFrame, pullQueryKey, pullsQueryKey, useRepoQuery } from './shared'; 11 24 import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, parseIntegerSearchParam, pullHref, uniqueCommenters } from '../../lib/repo-utils'; 12 - import { parsePullStateFilter, type PullStateFilter } from './pulls-helpers'; 25 + import { parsePastedPatchPrefill, parsePullStateFilter, type PullSourceMode, type PullStateFilter } from './pulls-helpers'; 13 26 14 27 export const PullsPage: Component = () => { 15 28 const auth = useAuth(); ··· 175 188 const repoQuery = useRepoQuery(); 176 189 const navigate = useNavigate(); 177 190 const client = useQueryClient(); 191 + const [sourceMode, setSourceMode] = createSignal<PullSourceMode>('branch'); 178 192 const [sourceBranch, setSourceBranch] = createSignal(''); 179 193 const [targetBranch, setTargetBranch] = createSignal(''); 194 + const [patchText, setPatchText] = createSignal(''); 180 195 const [title, setTitle] = createSignal(''); 181 196 const [body, setBody] = createSignal(''); 182 197 const [touchedTitle, setTouchedTitle] = createSignal(false); 183 198 const [touchedBody, setTouchedBody] = createSignal(false); 199 + const [previewingBody, setPreviewingBody] = createSignal(false); 184 200 const [error, setError] = createSignal<string | null>(null); 185 201 const [submitting, setSubmitting] = createSignal(false); 186 202 ··· 195 211 196 212 createEffect(() => { 197 213 const branches = branchesQuery.data?.branches ?? []; 198 - if (branches.length === 0) { 199 - return; 200 - } 214 + if (branches.length === 0) return; 201 215 202 216 if (!targetBranch()) { 203 217 const fallback = branches.find((branch) => branch.is_default)?.reference.name || branches[0].reference.name; ··· 206 220 207 221 if (!sourceBranch()) { 208 222 const fallback = 209 - branches.find((branch) => branch.reference.name !== targetBranch())?.reference.name || 210 - branches[0].reference.name; 223 + branches.find((branch) => branch.reference.name !== targetBranch())?.reference.name || branches[0].reference.name; 211 224 setSourceBranch(fallback); 212 225 } 213 226 }); ··· 216 229 const repo = repoQuery.data; 217 230 const source = sourceBranch(); 218 231 const target = targetBranch(); 232 + const mode = sourceMode(); 219 233 return { 220 - queryKey: ['compare', repo?.repoDid, target, source], 221 - enabled: Boolean(repo && source && target && source !== target), 234 + queryKey: ['compare', repo?.repoDid, mode, target, source], 235 + enabled: Boolean(mode === 'branch' && repo && source && target && source !== target), 222 236 queryFn: async () => compareBranches(repo!, target, source), 223 237 }; 224 238 }); 225 239 226 240 createEffect(() => { 241 + if (sourceMode() !== 'branch') return; 227 242 const comparison = compareQuery.data; 228 - if (!comparison || comparison.format_patch.length === 0) { 229 - return; 230 - } 231 - 243 + if (!comparison || comparison.format_patch.length === 0) return; 232 244 const patch = comparison.format_patch[0]; 233 - if (!touchedTitle()) { 234 - setTitle(patch.Title || ''); 235 - } 236 - if (!touchedBody()) { 237 - setBody(patch.Body || ''); 238 - } 245 + if (!touchedTitle()) setTitle(patch.Title || ''); 246 + if (!touchedBody()) setBody(patch.Body || ''); 247 + }); 248 + 249 + createEffect(() => { 250 + if (sourceMode() !== 'patch') return; 251 + const prefill = parsePastedPatchPrefill(patchText()); 252 + if (!prefill.title) return; 253 + if (!touchedTitle()) setTitle(prefill.title); 254 + if (!touchedBody()) setBody(prefill.body); 239 255 }); 256 + 257 + const pastedPatch = createMemo(() => patchText().trim()); 258 + const hasPastedPatch = createMemo(() => sourceMode() === 'patch' && Boolean(pastedPatch())); 259 + const activePatch = createMemo(() => (sourceMode() === 'patch' ? pastedPatch() : compareQuery.data?.patch ?? '')); 260 + const hasComparison = createMemo(() => sourceMode() === 'branch' && Boolean(compareQuery.data)); 261 + const commits = createMemo(() => (sourceMode() === 'branch' ? (compareQuery.data?.format_patch ?? []) : [])); 262 + const hasCommits = createMemo(() => commits().length > 0); 263 + const hasPatch = createMemo(() => Boolean(activePatch())); 264 + const showDetails = createMemo(() => hasCommits() || hasPatch()); 240 265 241 266 const submit = async (event: SubmitEvent) => { 242 267 event.preventDefault(); 243 268 const agent = auth.agent(); 244 269 const repo = repoQuery.data; 245 - const comparison = compareQuery.data; 270 + const patch = activePatch(); 271 + const mode = sourceMode(); 246 272 247 273 if (!agent || !repo) { 248 274 setError('Sign in first.'); 249 275 return; 250 276 } 251 277 252 - if (!comparison?.patch) { 253 - setError('No patch available for the selected branches.'); 278 + if (!targetBranch()) { 279 + setError('Target branch required.'); 280 + return; 281 + } 282 + 283 + if (mode === 'branch' && !sourceBranch()) { 284 + setError('Source branch required.'); 285 + return; 286 + } 287 + 288 + if (!patch) { 289 + setError(mode === 'patch' ? 'Paste a patch first.' : 'No patch available for the selected branches.'); 254 290 return; 255 291 } 256 292 ··· 261 297 262 298 setSubmitting(true); 263 299 setError(null); 264 - 265 300 try { 266 301 const created = await createPull(agent, repo, { 267 302 title: title(), 268 303 body: body(), 269 - sourceBranch: sourceBranch(), 304 + sourceBranch: mode === 'patch' ? targetBranch() : sourceBranch(), 270 305 targetBranch: targetBranch(), 271 - patch: comparison.patch, 306 + patch, 272 307 }); 273 308 await client.invalidateQueries({ queryKey: pullsQueryKey(repo.repoDid) }); 274 309 navigate(pullHref(repo, created.rkey)); ··· 281 316 282 317 return ( 283 318 <RepoFrame active="pulls"> 284 - <div class={cardStyles('p-6')}> 319 + <div id="pr-compose-host" class="flex flex-col bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full mx-auto dark:text-white"> 285 320 <form onSubmit={submit} class="flex flex-col gap-6"> 286 - <h1 class="text-2xl font-bold m-0">new pull</h1> 321 + <section class="untangled-new-pr-step"> 322 + <div class="untangled-new-pr-step-line untangled-new-pr-step-line-after" /> 323 + <div class="untangled-new-pr-heading"> 324 + <PullComposeSectionNumber value={1} /> 325 + <h3 class="my-0 text-sm font-bold uppercase tracking-wide dark:text-white">source</h3> 326 + </div> 327 + <div class="ml-10 flex flex-col gap-3"> 328 + <section class="flex flex-col gap-3"> 329 + <div class="flex gap-1 p-1.5 rounded-md bg-slate-100 dark:bg-gray-900 border dark:border-gray-700 items-center justify-stretch"> 330 + <PullSourceTab 331 + active={sourceMode() === 'branch'} 332 + title="Compare branches" 333 + description="Select a source branch" 334 + onClick={() => setSourceMode('branch')} 335 + /> 336 + <PullSourceTab 337 + active={sourceMode() === 'patch'} 338 + title="Paste patch" 339 + description={ 340 + <span> 341 + Paste a <code class="font-mono">git diff</code> or <code class="font-mono">git format-patch</code> 342 + </span> 343 + } 344 + onClick={() => setSourceMode('patch')} 345 + /> 346 + </div> 347 + 348 + <div class="flex flex-col md:flex-row md:flex-wrap gap-3"> 349 + <div class="flex flex-col gap-1"> 350 + <label for="targetBranch" class="text-xs uppercase tracking-wide text-gray-800 dark:text-gray-200"> 351 + Merge into 352 + </label> 353 + <div class="flex flex-wrap gap-2 items-center"> 354 + <select 355 + id="targetBranch" 356 + name="targetBranch" 357 + required 358 + value={targetBranch()} 359 + onChange={(event) => setTargetBranch(event.currentTarget.value)} 360 + class="peer p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 361 + > 362 + <option disabled selected={!targetBranch()}> 363 + Target branch 364 + </option> 365 + <For each={branchesQuery.data?.branches ?? []}> 366 + {(branch) => { 367 + const name = branch.reference.name; 368 + const isSource = sourceMode() === 'branch' && name === sourceBranch(); 369 + return ( 370 + <option value={name} disabled={isSource}> 371 + {name} 372 + {isSource ? ' (source)' : ''} 373 + </option> 374 + ); 375 + }} 376 + </For> 377 + </select> 378 + <Show when={branchesQuery.isLoading}> 379 + <LoaderCircle class="size-4 animate-spin text-gray-500 dark:text-gray-400" /> 380 + </Show> 381 + </div> 382 + </div> 287 383 288 - <div class="grid gap-4 md:grid-cols-2"> 289 - <div class="flex flex-col gap-2"> 290 - <label class="text-sm font-medium">source branch</label> 291 - <select value={sourceBranch()} onChange={(event) => setSourceBranch(event.currentTarget.value)} class={inputStyles()}> 292 - <For each={branchesQuery.data?.branches ?? []}> 293 - {(branch) => <option value={branch.reference.name}>{branch.reference.name}</option>} 294 - </For> 295 - </select> 384 + <Show when={sourceMode() === 'branch'}> 385 + <div id="patch-strategy" class="flex flex-col gap-1"> 386 + <label for="sourceBranch" class="text-xs uppercase tracking-wide text-gray-800 dark:text-gray-200"> 387 + Pull from 388 + </label> 389 + <div class="flex flex-wrap gap-2 items-center"> 390 + <select 391 + id="sourceBranch" 392 + name="sourceBranch" 393 + required 394 + value={sourceBranch()} 395 + onChange={(event) => setSourceBranch(event.currentTarget.value)} 396 + class="peer p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 397 + > 398 + <option disabled selected={!sourceBranch()}> 399 + Source branch 400 + </option> 401 + <For each={branchesQuery.data?.branches ?? []}> 402 + {(branch, index) => { 403 + const name = branch.reference.name; 404 + const isTarget = name === targetBranch(); 405 + return ( 406 + <option value={name} disabled={isTarget}> 407 + {name} 408 + {index() === 0 ? ' (new)' : ''} 409 + {isTarget ? ' (target)' : ''} 410 + </option> 411 + ); 412 + }} 413 + </For> 414 + </select> 415 + <Show when={compareQuery.isFetching}> 416 + <LoaderCircle class="size-4 animate-spin text-gray-500 dark:text-gray-400" /> 417 + </Show> 418 + </div> 419 + </div> 420 + </Show> 421 + </div> 422 + 423 + <Show when={sourceMode() === 'patch'}> 424 + <div id="patch-upload"> 425 + <div class="flex items-center justify-between gap-2"> 426 + <p class="my-0"> 427 + You can paste a <code>git diff</code> or a <code>git format-patch</code> patch series here. 428 + </p> 429 + </div> 430 + <textarea 431 + name="patch" 432 + id="patch" 433 + rows="12" 434 + value={patchText()} 435 + onInput={(event) => setPatchText(event.currentTarget.value)} 436 + class="w-full mt-2 resize-y font-mono dark:bg-gray-800 dark:text-white dark:border-gray-700" 437 + placeholder={`diff --git a/file.txt b/file.txt 438 + index 1234567..abcdefg 100644 439 + --- a/file.txt 440 + +++ b/file.txt`} 441 + /> 442 + </div> 443 + </Show> 444 + 445 + <Show when={branchesQuery.error}> 446 + <div id="patch-error" class="error dark:text-red-300"> 447 + {getErrorMessage(branchesQuery.error)} 448 + </div> 449 + </Show> 450 + </section> 296 451 </div> 297 - <div class="flex flex-col gap-2"> 298 - <label class="text-sm font-medium">target branch</label> 299 - <select value={targetBranch()} onChange={(event) => setTargetBranch(event.currentTarget.value)} class={inputStyles()}> 300 - <For each={branchesQuery.data?.branches ?? []}> 301 - {(branch) => <option value={branch.reference.name}>{branch.reference.name}</option>} 302 - </For> 303 - </select> 452 + </section> 453 + 454 + <section class="untangled-new-pr-step"> 455 + <div 456 + class={clsx( 457 + 'untangled-new-pr-step-line untangled-new-pr-step-line-before', 458 + showDetails() ? 'untangled-new-pr-step-line-through' : 'untangled-new-pr-step-line-fade', 459 + )} 460 + /> 461 + <div class="untangled-new-pr-heading"> 462 + <PullComposeSectionNumber value={2} /> 463 + <h3 class="my-0 text-sm font-bold uppercase tracking-wide dark:text-white">review</h3> 304 464 </div> 305 - </div> 465 + <div class="ml-10 flex flex-col gap-3"> 466 + <section class="flex flex-col gap-3"> 467 + <Switch> 468 + <Match when={sourceMode() === 'branch' && compareQuery.isLoading}> 469 + <div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800"> 470 + <LoadingState label="Generating comparison..." /> 471 + </div> 472 + </Match> 473 + <Match when={sourceMode() === 'branch' && compareQuery.error}> 474 + <div class="p-4 border border-red-200 dark:border-red-800 rounded bg-red-50 dark:bg-gray-800"> 475 + <ErrorState message={getErrorMessage(compareQuery.error)} /> 476 + </div> 477 + </Match> 478 + <Match when={sourceMode() === 'patch' && !hasPastedPatch()}> 479 + <div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400"> 480 + Paste a patch above to see a comparison. 481 + </div> 482 + </Match> 483 + <Match when={sourceMode() === 'patch' && hasPastedPatch()}> 484 + <div class="flex items-center justify-between gap-3 min-w-0 text-sm text-gray-500 dark:text-gray-400"> 485 + <span class="flex-shrink-0">Pasted patch</span> 486 + <span class="inline-flex items-center gap-2 font-mono text-gray-600 dark:text-gray-300 truncate"> 487 + <span>{targetBranch()}</span> 488 + </span> 489 + </div> 490 + </Match> 491 + <Match when={sourceMode() === 'branch' && (!sourceBranch() || !targetBranch() || sourceBranch() === targetBranch())}> 492 + <div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400"> 493 + Pick a source and target above to see a comparison. 494 + </div> 495 + </Match> 496 + <Match when={sourceMode() === 'branch' && hasComparison() && !hasCommits()}> 497 + <div class="p-4 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400"> 498 + No commits between target and source. Make sure your source branch has commits not on the target. 499 + </div> 500 + </Match> 501 + <Match when={sourceMode() === 'branch' && hasCommits()}> 502 + <div class="flex flex-col gap-2"> 503 + <div class="flex items-center justify-between gap-3 min-w-0 text-sm text-gray-500 dark:text-gray-400"> 504 + <span class="flex-shrink-0"> 505 + {commits().length} commit{commits().length === 1 ? '' : 's'} 506 + </span> 507 + <span class="inline-flex items-center gap-2 font-mono text-gray-600 dark:text-gray-300 truncate"> 508 + <span>{targetBranch()}</span> 509 + <ArrowLeftRight class="w-4 h-4 flex-shrink-0" /> 510 + <span>{sourceBranch()}</span> 511 + </span> 512 + </div> 306 513 307 - <div class="flex flex-col gap-2"> 308 - <label class="text-sm font-medium">title</label> 309 - <input 310 - value={title()} 311 - onInput={(event) => { 312 - setTouchedTitle(true); 313 - setTitle(event.currentTarget.value); 314 - }} 315 - class={inputStyles()} 316 - /> 317 - </div> 514 + <ul class="flex flex-col gap-2"> 515 + <For each={commits()}> 516 + {(patch) => ( 517 + <li class="border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 px-3 py-2 flex items-center gap-3"> 518 + <span class="text-gray-700 dark:text-gray-300 flex-shrink-0"> 519 + <GitPullRequest class="w-4 h-4" /> 520 + </span> 521 + <span class="flex-1 min-w-0 truncate text-sm dark:text-gray-200">{patch.Title}</span> 522 + <span class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"> 523 + <Show when={patch.AuthorDate}> 524 + <span>{formatRelativeTime(patch.AuthorDate)}</span> 525 + </Show> 526 + <Show when={patch.SHA}> 527 + <span class="font-mono bg-gray-100 dark:bg-gray-900 text-gray-700 dark:text-gray-300 px-2 py-0.5 rounded"> 528 + {patch.SHA.slice(0, 8)} 529 + </span> 530 + </Show> 531 + </span> 532 + <Show when={patch.SHA}> 533 + <span class="-my-2 self-stretch w-px bg-gray-200 dark:bg-gray-700" /> 534 + <button 535 + type="button" 536 + class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300" 537 + title="Copy SHA" 538 + onClick={() => void navigator.clipboard?.writeText(patch.SHA)} 539 + > 540 + <Copy class="w-4 h-4" /> 541 + </button> 542 + <Show when={repoQuery.data}> 543 + {(repo) => ( 544 + <A 545 + href={`/${repo().owner.handle}/${repo().slug}/tree/${patch.SHA}`} 546 + class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300" 547 + title="Browse repository at this commit" 548 + > 549 + <FolderCode class="w-4 h-4" /> 550 + </A> 551 + )} 552 + </Show> 553 + </Show> 554 + </li> 555 + )} 556 + </For> 557 + </ul> 558 + </div> 559 + </Match> 560 + </Switch> 318 561 319 - <div class="flex flex-col gap-2"> 320 - <label class="text-sm font-medium">body</label> 321 - <textarea 322 - value={body()} 323 - onInput={(event) => { 324 - setTouchedBody(true); 325 - setBody(event.currentTarget.value); 326 - }} 327 - class={textareaStyles()} 328 - rows="10" 329 - /> 330 - </div> 562 + <Show when={hasPatch()}> 563 + <div class="untangled-new-pr-diff"> 564 + <DiffView patch={activePatch()} wrap maxHeight="40rem" /> 565 + </div> 566 + </Show> 567 + </section> 568 + </div> 569 + </section> 331 570 332 - <div class={cardStyles('p-4')}> 333 - <div class="text-sm font-medium mb-3">patch preview</div> 334 - <Switch> 335 - <Match when={compareQuery.isLoading}> 336 - <LoadingState label="Generating patch…" /> 337 - </Match> 338 - <Match when={compareQuery.error}> 339 - <ErrorState message={getErrorMessage(compareQuery.error)} /> 340 - </Match> 341 - <Match when={compareQuery.data}> 342 - <DiffView patch={compareQuery.data!.patch} wrap maxHeight="32rem" /> 343 - </Match> 344 - <Match when={true}> 345 - <div class="text-sm text-gray-500 dark:text-gray-400">Select two different branches to preview the patch.</div> 346 - </Match> 347 - </Switch> 348 - </div> 571 + <Show when={showDetails()}> 572 + <section class="untangled-new-pr-step"> 573 + <div class="untangled-new-pr-step-line untangled-new-pr-step-line-before untangled-new-pr-step-line-fade" /> 574 + <div class="untangled-new-pr-heading"> 575 + <PullComposeSectionNumber value={3} /> 576 + <h3 class="my-0 text-sm font-bold uppercase tracking-wide dark:text-white">details</h3> 577 + </div> 578 + <div class="ml-10 flex flex-col gap-3"> 579 + <section class="flex flex-col md:flex-row gap-6"> 580 + <div class="flex-1 min-w-0 flex flex-col gap-4"> 581 + <div class="flex flex-col gap-1"> 582 + <label for="title" class="text-xs uppercase tracking-wide text-gray-800 dark:text-gray-200"> 583 + title 584 + </label> 585 + <input 586 + type="text" 587 + name="title" 588 + id="title" 589 + value={title()} 590 + onInput={(event) => { 591 + setTouchedTitle(true); 592 + setTitle(event.currentTarget.value); 593 + }} 594 + class="w-full dark:bg-gray-800 dark:text-white dark:border-gray-700" 595 + placeholder="One-line summary of your change." 596 + /> 597 + </div> 349 598 350 - <Show when={error()}> 351 - <p class="text-sm text-red-500">{error()}</p> 599 + <div class="flex flex-col gap-2" data-md-editor="pull-body"> 600 + <div class="btn-group self-start text-gray-600 dark:text-gray-300"> 601 + <button 602 + type="button" 603 + class={clsx('btn-group-item group', !previewingBody() && 'active')} 604 + onClick={() => setPreviewingBody(false)} 605 + > 606 + <Pencil class="w-3.5 h-3.5" /> 607 + Write 608 + </button> 609 + <button 610 + type="button" 611 + class={clsx('btn-group-item group', previewingBody() && 'active')} 612 + onClick={() => setPreviewingBody(true)} 613 + > 614 + <Eye class="w-3.5 h-3.5" /> 615 + Preview 616 + </button> 617 + </div> 618 + <Show 619 + when={previewingBody()} 620 + fallback={ 621 + <textarea 622 + id="pull-body" 623 + name="body" 624 + rows="6" 625 + value={body()} 626 + onInput={(event) => { 627 + setTouchedBody(true); 628 + setBody(event.currentTarget.value); 629 + }} 630 + class="w-full resize-y dark:bg-gray-800 dark:text-white dark:border-gray-700" 631 + placeholder="Describe your change. Markdown is supported." 632 + /> 633 + } 634 + > 635 + <div class="min-h-[6rem] p-3 border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-900"> 636 + <Show 637 + when={body().trim()} 638 + fallback={<span class="text-gray-400 dark:text-gray-500 italic">Nothing to preview.</span>} 639 + > 640 + <MarkdownBlock markdown={body()} /> 641 + </Show> 642 + </div> 643 + </Show> 644 + </div> 645 + 646 + <div class="flex items-center justify-end gap-4 mt-auto"> 647 + <button 648 + type="submit" 649 + class={clsx(buttonStyles('primary'), 'justify-center min-w-48')} 650 + disabled={submitting()} 651 + > 652 + <GitPullRequest class="w-4 h-4" /> 653 + <Show when={submitting()} fallback="Create pull request"> 654 + <span class="inline-flex items-center gap-2"> 655 + Creating 656 + <LoaderCircle class="w-4 h-4 animate-spin" /> 657 + </span> 658 + </Show> 659 + </button> 660 + </div> 661 + </div> 662 + </section> 663 + </div> 664 + </section> 352 665 </Show> 666 + </form> 353 667 354 - <div class="flex justify-end"> 355 - <button type="submit" class={clsx(buttonStyles('primary'), 'justify-center min-w-32')} disabled={submitting()}> 356 - <Show when={submitting()} fallback="create pull"> 357 - <span class="inline-flex items-center gap-2"> 358 - <LoaderCircle class="size-4 animate-spin" /> 359 - creating 360 - </span> 361 - </Show> 362 - </button> 668 + <Show when={error()}> 669 + <div id="pull" class="error dark:text-red-300 mt-4 ml-10"> 670 + {error()} 363 671 </div> 364 - </form> 672 + </Show> 365 673 </div> 366 674 </RepoFrame> 367 675 ); 368 676 }; 677 + 678 + const PullComposeSectionNumber: Component<{ value: number }> = (props) => ( 679 + <span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300 text-xs font-bold"> 680 + {props.value} 681 + </span> 682 + ); 683 + 684 + const PullSourceTab: Component<{ 685 + active: boolean; 686 + title: string; 687 + description: JSX.Element; 688 + onClick: () => void; 689 + }> = (props) => ( 690 + <button 691 + type="button" 692 + class={clsx( 693 + 'group flex-1 p-3 text-left hover:no-underline flex flex-col gap-1 rounded', 694 + props.active 695 + ? 'bg-white dark:bg-gray-800 shadow-sm cursor-default border border-gray-200 dark:border-gray-700' 696 + : 'bg-transparent hover:bg-white/50 dark:hover:bg-gray-800/50', 697 + )} 698 + onClick={props.onClick} 699 + > 700 + <span class="font-medium text-sm dark:text-white flex items-center gap-2">{props.title}</span> 701 + <span class="text-xs text-gray-500 dark:text-gray-400">{props.description}</span> 702 + </button> 703 + ); 369 704 370 705 export const PullPage: Component = () => { 371 706 const auth = useAuth();