diff --git a/src/components/repo.tsx b/src/components/repo.tsx index e35730f..99e0b10 100644 --- a/src/components/repo.tsx +++ b/src/components/repo.tsx @@ -3,13 +3,16 @@ import DOMPurify from 'dompurify'; import { ChevronRight, Circle, + Columns2, FileText, + FoldVertical, Folder, Minimize2, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, + UnfoldVertical, ChevronLeft, } from 'lucide-solid'; import { marked } from 'marked'; @@ -523,6 +526,20 @@ const parseDiffFiles = (patch: string): DiffFile[] => { const pushCurrent = () => { if (!current) return; + const hasRenderableDiff = current.lines.some( + (line) => + line.startsWith('@@') || + line.startsWith('Binary files ') || + line.startsWith('new file mode ') || + line.startsWith('deleted file mode ') || + line.startsWith('old mode ') || + line.startsWith('similarity index ') || + line.startsWith('rename from '), + ); + if (!hasRenderableDiff && current.additions === 0 && current.deletions === 0) { + current = null; + return; + } files.push({ path: current.path, text: current.lines.join('\n'), @@ -559,26 +576,91 @@ const parseDiffFiles = (patch: string): DiffFile[] => { export const DiffView: Component<{ patch: string; maxHeight?: string; wrap?: boolean }> = (props) => { const files = createMemo(() => parseDiffFiles(props.patch)); + const stats = createMemo(() => totalDiffStats(files())); + const [expanded, setExpanded] = createSignal(false); + const previewLimit = 5; + const visibleFiles = createMemo(() => files().slice(0, previewLimit)); + const hiddenFiles = createMemo(() => files().slice(previewLimit)); return (
- - {(file) => ( -
- -
- - {file.path} -
-
- +{file.additions} - -{file.deletions} +
+
+
+ + + {files().length} changed file{files().length === 1 ? '' : 's'} + +
+
+ +
+ + +
+
+
+
+ + {(file) => ( +
+ +
+ + + {file.path} +
+
+ +
+ )} +
+ 0}> +
+ + + + Show {hiddenFiles().length} more file{hiddenFiles().length === 1 ? '' : 's'} + + + + Hide {hiddenFiles().length} file{hiddenFiles().length === 1 ? '' : 's'} + + +
+ + {(file) => ( +
+ +
+ + + {file.path} +
+
+ +
+ )} +
-
- -
- )} -
+ + + +
No differences found.
+
+
+ ); }; diff --git a/src/index.css b/src/index.css index 5ffccb4..92134ad 100644 --- a/src/index.css +++ b/src/index.css @@ -5,6 +5,62 @@ font-family: InterVariable, system-ui, sans-serif, ui-sans-serif; } +.untangled-new-pr-step { + position: relative; + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.75rem; +} + +.untangled-new-pr-heading { + position: relative; + display: flex; + align-items: center; + gap: 1rem; +} + +.untangled-new-pr-step-line { + position: absolute; + left: 0.75rem; + width: 1px; + transform: translateX(-50%); + background: rgb(229 231 235); +} + +.untangled-new-pr-step-line-after { + top: 0.75rem; + bottom: -1.5rem; +} + +.untangled-new-pr-step-line-before { + top: -1.5rem; +} + +.untangled-new-pr-step-line-through { + bottom: -1.5rem; +} + +.untangled-new-pr-step-line-fade { + height: 3rem; + -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 1.5rem), transparent); + mask-image: linear-gradient(to bottom, black calc(100% - 1.5rem), transparent); +} + +.untangled-new-pr-step-line-before.untangled-new-pr-step-line-fade { + bottom: auto; +} + +.untangled-new-pr-diff { + min-width: 0; +} + +@media (prefers-color-scheme: dark) { + .untangled-new-pr-step-line { + background: rgb(55 65 81); + } +} + .untangled-pr-detail { display: flex; min-width: 0; @@ -1140,7 +1196,76 @@ details[open] > .untangled-language-bar { .untangled-diff-view { display: flex; flex-direction: column; - gap: 0.75rem; + gap: 0; +} + +.untangled-diff-panel { + overflow: hidden; + border: 1px solid rgb(55 65 81); + border-radius: 0.25rem; + background: rgb(31 41 55); +} + +.untangled-diff-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + background: rgb(55 65 81 / 0.5); + border-bottom: 1px solid rgb(55 65 81); + padding: 0.5rem 0.75rem; +} + +.untangled-diff-tool-button, +.untangled-diff-mode-button { + display: inline-flex; + align-items: center; + gap: 0.375rem; + min-height: 2rem; + border: 1px solid rgb(55 65 81); + border-radius: 0.25rem; + background: rgb(17 24 39 / 0.35); + padding: 0.25rem 0.625rem; + font-size: 0.875rem; + color: rgb(229 231 235); +} + +.untangled-diff-tool-button:hover, +.untangled-diff-mode-button:not(:disabled):hover { + background: rgb(55 65 81 / 0.65); +} + +.untangled-diff-mode-group { + display: inline-flex; + align-items: center; + border: 1px solid rgb(55 65 81); + border-radius: 0.25rem; + overflow: hidden; +} + +.untangled-diff-mode-group .untangled-diff-mode-button { + border: 0; + border-radius: 0; +} + +.untangled-diff-mode-group .untangled-diff-mode-button + .untangled-diff-mode-button { + border-left: 1px solid rgb(55 65 81); +} + +.untangled-diff-mode-button-active { + background: rgb(55 65 81); +} + +.untangled-diff-mode-button:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.untangled-diff-panel-body { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem; } .untangled-diff-file { @@ -1166,10 +1291,71 @@ details[open] > .untangled-language-bar { display: none; } +.untangled-diff-chevron { + transition: transform 120ms ease; +} + +.untangled-diff-file[open] .untangled-diff-chevron { + transform: rotate(90deg); +} + .untangled-diff-file:not([open]) .untangled-diff-file-header { border-bottom: 0; } +.untangled-diff-show-more { + min-width: 0; +} + +.untangled-diff-show-more-summary { + display: flex; + list-style: none; + cursor: pointer; + user-select: none; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.25rem; + font-size: 0.875rem; + color: rgb(209 213 219); +} + +.untangled-diff-show-more-summary::-webkit-details-marker { + display: none; +} + +.untangled-diff-show-more-closed, +.untangled-diff-show-more-open { + align-items: center; + gap: 0.5rem; +} + +.untangled-diff-show-more-closed { + display: inline-flex; +} + +.untangled-diff-show-more-open { + display: none; +} + +.untangled-diff-show-more[open] > .untangled-diff-show-more-summary .untangled-diff-show-more-closed { + display: none; +} + +.untangled-diff-show-more[open] > .untangled-diff-show-more-summary .untangled-diff-show-more-open { + display: inline-flex; +} + +.untangled-diff-show-more[open] > .untangled-diff-show-more-summary .untangled-diff-chevron { + transform: rotate(90deg); +} + +.untangled-diff-show-more-files { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.5rem; +} + .z-60 { z-index: 60; } diff --git a/src/pages/repo/pulls-helpers.ts b/src/pages/repo/pulls-helpers.ts index 57826ec..00cd38d 100644 --- a/src/pages/repo/pulls-helpers.ts +++ b/src/pages/repo/pulls-helpers.ts @@ -1,6 +1,7 @@ import type { SearchParamValue } from '../../lib/repo-utils'; export type PullStateFilter = 'open' | 'closed' | 'merged'; +export type PullSourceMode = 'branch' | 'patch'; export const parsePullStateFilter = (value: SearchParamValue): PullStateFilter => { if (value === 'state:closed') { @@ -13,3 +14,19 @@ export const parsePullStateFilter = (value: SearchParamValue): PullStateFilter = return 'open'; }; + +export const parsePastedPatchPrefill = (patch: string): { title: string; body: string } => { + const subject = /^Subject:\s*(?:\[[^\]]+\]\s*)?(.*)$/im.exec(patch)?.[1]?.trim() ?? ''; + const headerEnd = patch.indexOf('\n---'); + const bodyStart = patch.search(/^Subject:.*$/im); + + if (!subject || bodyStart === -1 || headerEnd === -1 || headerEnd <= bodyStart) { + return { title: subject, body: '' }; + } + + const afterSubject = patch.slice(bodyStart).replace(/^Subject:.*(?:\r?\n)?/i, ''); + const separator = afterSubject.indexOf('\n---'); + const body = separator === -1 ? '' : afterSubject.slice(0, separator).trim(); + + return { title: subject, body }; +}; diff --git a/src/pages/repo/pulls.tsx b/src/pages/repo/pulls.tsx index 1057914..5ea4836 100644 --- a/src/pages/repo/pulls.tsx +++ b/src/pages/repo/pulls.tsx @@ -1,15 +1,28 @@ import clsx from 'clsx'; -import { CirclePlus, Copy, GitMerge, GitPullRequest, GitPullRequestClosed, LoaderCircle, MessageSquare, Search } from 'lucide-solid'; +import { + ArrowLeftRight, + CirclePlus, + Copy, + Eye, + FolderCode, + GitMerge, + GitPullRequest, + GitPullRequestClosed, + LoaderCircle, + MessageSquare, + Pencil, + Search, +} from 'lucide-solid'; import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router'; import { createQuery, useQueryClient } from '@tanstack/solid-query'; -import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; +import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js'; import { compareBranches, createPull, createPullComment, fetchPullRoundPatch, getPull, getRepoBranches, listPullsPage, parseAtUri, setPullStatus, type RepoContext } from '../../lib/api'; import { useAuth } from '../../lib/auth'; -import { Avatar, ErrorState, LoadingState, StateBadge, ToggleButton, buttonStyles, cardStyles, inputStyles, textareaStyles } from '../../components/common'; +import { Avatar, ErrorState, LoadingState, StateBadge, ToggleButton, buttonStyles, cardStyles, textareaStyles } from '../../components/common'; import { BranchPill, CommentCard, DiffView, MarkdownBlock, PaginationControls, PullDiffView, RepoListSkeleton, RepoThreadSkeleton } from '../../components/repo'; import { RepoFrame, pullQueryKey, pullsQueryKey, useRepoQuery } from './shared'; import { REPO_LIST_PAGE_LIMIT, formatRelativeTime, getErrorMessage, parseIntegerSearchParam, pullHref, uniqueCommenters } from '../../lib/repo-utils'; -import { parsePullStateFilter, type PullStateFilter } from './pulls-helpers'; +import { parsePastedPatchPrefill, parsePullStateFilter, type PullSourceMode, type PullStateFilter } from './pulls-helpers'; export const PullsPage: Component = () => { const auth = useAuth(); @@ -175,12 +188,15 @@ export const NewPullPage: Component = () => { const repoQuery = useRepoQuery(); const navigate = useNavigate(); const client = useQueryClient(); + const [sourceMode, setSourceMode] = createSignal('branch'); const [sourceBranch, setSourceBranch] = createSignal(''); const [targetBranch, setTargetBranch] = createSignal(''); + const [patchText, setPatchText] = createSignal(''); const [title, setTitle] = createSignal(''); const [body, setBody] = createSignal(''); const [touchedTitle, setTouchedTitle] = createSignal(false); const [touchedBody, setTouchedBody] = createSignal(false); + const [previewingBody, setPreviewingBody] = createSignal(false); const [error, setError] = createSignal(null); const [submitting, setSubmitting] = createSignal(false); @@ -195,9 +211,7 @@ export const NewPullPage: Component = () => { createEffect(() => { const branches = branchesQuery.data?.branches ?? []; - if (branches.length === 0) { - return; - } + if (branches.length === 0) return; if (!targetBranch()) { const fallback = branches.find((branch) => branch.is_default)?.reference.name || branches[0].reference.name; @@ -206,8 +220,7 @@ export const NewPullPage: Component = () => { if (!sourceBranch()) { const fallback = - branches.find((branch) => branch.reference.name !== targetBranch())?.reference.name || - branches[0].reference.name; + branches.find((branch) => branch.reference.name !== targetBranch())?.reference.name || branches[0].reference.name; setSourceBranch(fallback); } }); @@ -216,41 +229,64 @@ export const NewPullPage: Component = () => { const repo = repoQuery.data; const source = sourceBranch(); const target = targetBranch(); + const mode = sourceMode(); return { - queryKey: ['compare', repo?.repoDid, target, source], - enabled: Boolean(repo && source && target && source !== target), + queryKey: ['compare', repo?.repoDid, mode, target, source], + enabled: Boolean(mode === 'branch' && repo && source && target && source !== target), queryFn: async () => compareBranches(repo!, target, source), }; }); createEffect(() => { + if (sourceMode() !== 'branch') return; const comparison = compareQuery.data; - if (!comparison || comparison.format_patch.length === 0) { - return; - } - + if (!comparison || comparison.format_patch.length === 0) return; const patch = comparison.format_patch[0]; - if (!touchedTitle()) { - setTitle(patch.Title || ''); - } - if (!touchedBody()) { - setBody(patch.Body || ''); - } + if (!touchedTitle()) setTitle(patch.Title || ''); + if (!touchedBody()) setBody(patch.Body || ''); }); + createEffect(() => { + if (sourceMode() !== 'patch') return; + const prefill = parsePastedPatchPrefill(patchText()); + if (!prefill.title) return; + if (!touchedTitle()) setTitle(prefill.title); + if (!touchedBody()) setBody(prefill.body); + }); + + const pastedPatch = createMemo(() => patchText().trim()); + const hasPastedPatch = createMemo(() => sourceMode() === 'patch' && Boolean(pastedPatch())); + const activePatch = createMemo(() => (sourceMode() === 'patch' ? pastedPatch() : compareQuery.data?.patch ?? '')); + const hasComparison = createMemo(() => sourceMode() === 'branch' && Boolean(compareQuery.data)); + const commits = createMemo(() => (sourceMode() === 'branch' ? (compareQuery.data?.format_patch ?? []) : [])); + const hasCommits = createMemo(() => commits().length > 0); + const hasPatch = createMemo(() => Boolean(activePatch())); + const showDetails = createMemo(() => hasCommits() || hasPatch()); + const submit = async (event: SubmitEvent) => { event.preventDefault(); const agent = auth.agent(); const repo = repoQuery.data; - const comparison = compareQuery.data; + const patch = activePatch(); + const mode = sourceMode(); if (!agent || !repo) { setError('Sign in first.'); return; } - if (!comparison?.patch) { - setError('No patch available for the selected branches.'); + if (!targetBranch()) { + setError('Target branch required.'); + return; + } + + if (mode === 'branch' && !sourceBranch()) { + setError('Source branch required.'); + return; + } + + if (!patch) { + setError(mode === 'patch' ? 'Paste a patch first.' : 'No patch available for the selected branches.'); return; } @@ -261,14 +297,13 @@ export const NewPullPage: Component = () => { setSubmitting(true); setError(null); - try { const created = await createPull(agent, repo, { title: title(), body: body(), - sourceBranch: sourceBranch(), + sourceBranch: mode === 'patch' ? targetBranch() : sourceBranch(), targetBranch: targetBranch(), - patch: comparison.patch, + patch, }); await client.invalidateQueries({ queryKey: pullsQueryKey(repo.repoDid) }); navigate(pullHref(repo, created.rkey)); @@ -281,92 +316,392 @@ export const NewPullPage: Component = () => { return ( -
+
-

new pull

- -
-
- - -
-
- - +
+
+
+ +

source

-
+
+
+
+ setSourceMode('branch')} + /> + + Paste a git diff or git format-patch + + } + onClick={() => setSourceMode('patch')} + /> +
-
- - { - setTouchedTitle(true); - setTitle(event.currentTarget.value); - }} - class={inputStyles()} - /> -
+
+
+ +
+ + + + +
+
+ + +
+ +
+ + + + +
+
+
+
+ + +
+
+

+ You can paste a git diff or a git format-patch patch series here. +

+
+