| 1 | import { useRef, useState } from "react"; |
| 2 | |
| 3 | import type { CommitMessageDefault, MergeMethod, RepoSettings } from "@g1t/contracts"; |
| 4 | |
| 5 | import { |
| 6 | MERGE_METHODS, |
| 7 | MESSAGE_CHOICES, |
| 8 | METHOD_INFO, |
| 9 | PLACEHOLDERS, |
| 10 | SAMPLE_PULL, |
| 11 | defaultMessage, |
| 12 | } from "../lib/merge"; |
| 13 | import { Badge } from "./ui/badge"; |
| 14 | import { Button } from "./ui/button"; |
| 15 | import { Card } from "./ui/card"; |
| 16 | import { CheckboxOption } from "./ui/checkbox"; |
| 17 | import { Field, FieldDescription, FieldLabel } from "./ui/field"; |
| 18 | import { Hint } from "./ui/hint"; |
| 19 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; |
| 20 | import { Textarea } from "./ui/textarea"; |
| 21 | |
| 22 | type Settings = Pick< |
| 23 | RepoSettings, |
| 24 | | "allowMergeCommit" |
| 25 | | "allowSquashMerge" |
| 26 | | "allowRebaseMerge" |
| 27 | | "allowFastForward" |
| 28 | | "defaultMergeMethod" |
| 29 | | "mergeCommitMessage" |
| 30 | | "squashCommitMessage" |
| 31 | | "mergeCommitTemplate" |
| 32 | | "squashCommitTemplate" |
| 33 | >; |
| 34 | |
| 35 | /** |
| 36 | * Which merge methods pull requests may use, what merge and squash commits |
| 37 | * say by default, and which method is offered first. Posts `allowMergeCommit` |
| 38 | * and the rest by their settings' names. At least one method stays on. |
| 39 | */ |
| 40 | export function MergeMethodSettings({ settings, disabled }: { settings: Settings; disabled?: boolean }) { |
| 41 | const [allowed, setAllowed] = useState<Record<MergeMethod, boolean>>({ |
| 42 | merge: settings.allowMergeCommit, |
| 43 | squash: settings.allowSquashMerge, |
| 44 | rebase: settings.allowRebaseMerge, |
| 45 | fast_forward: settings.allowFastForward, |
| 46 | }); |
| 47 | const on = MERGE_METHODS.filter((method) => allowed[method]); |
| 48 | const [chosenDefault, setDefault] = useState<MergeMethod>(settings.defaultMergeMethod); |
| 49 | // A default that is no longer allowed gives way to the first that is. |
| 50 | const defaultMethod = on.includes(chosenDefault) ? chosenDefault : (on[0] ?? "merge"); |
| 51 | return ( |
| 52 | <> |
| 53 | <Card divided className="overflow-hidden"> |
| 54 | {MERGE_METHODS.map((method) => { |
| 55 | const info = METHOD_INFO[method]; |
| 56 | const last = on.length === 1 && allowed[method]; |
| 57 | return ( |
| 58 | <div key={method} className="space-y-3 p-4"> |
| 59 | <Hint label={last ? "At least one merge method stays on." : null} disabled={last} align="start"> |
| 60 | <CheckboxOption |
| 61 | name={info.setting} |
| 62 | checked={allowed[method]} |
| 63 | disabled={disabled || last} |
| 64 | onCheckedChange={(checked) => setAllowed((now) => ({ ...now, [method]: checked === true }))} |
| 65 | label={ |
| 66 | <span className="flex flex-wrap items-center gap-2 font-medium"> |
| 67 | Allow {info.label.toLowerCase()} |
| 68 | {defaultMethod === method && on.length > 1 && <Badge tone="accent">Default</Badge>} |
| 69 | </span> |
| 70 | } |
| 71 | description={info.about} |
| 72 | /> |
| 73 | </Hint> |
| 74 | {(method === "merge" || method === "squash") && allowed[method] && ( |
| 75 | <MessageDefault |
| 76 | method={method} |
| 77 | choice={method === "merge" ? settings.mergeCommitMessage : settings.squashCommitMessage} |
| 78 | template={method === "merge" ? settings.mergeCommitTemplate : settings.squashCommitTemplate} |
| 79 | disabled={disabled} |
| 80 | /> |
| 81 | )} |
| 82 | </div> |
| 83 | ); |
| 84 | })} |
| 85 | </Card> |
| 86 | {/* A method whose box is unticked does not post: its template is kept by the action. */} |
| 87 | <Card className="flex flex-col gap-3 p-4 sm:flex-row sm:items-start"> |
| 88 | <div className="grow"> |
| 89 | <p className="text-sm font-medium">Default merge method</p> |
| 90 | <p className="mt-1 text-sm text-muted"> |
| 91 | Offered first on the merge button, and used by the API, the CLI and auto-merge when no method is named. A |
| 92 | person's last choice in this repository is offered to them first. |
| 93 | </p> |
| 94 | </div> |
| 95 | <Select name="defaultMergeMethod" value={defaultMethod} onValueChange={(value) => setDefault(value as MergeMethod)} disabled={disabled || on.length < 2}> |
| 96 | <SelectTrigger size="sm" aria-label="Default merge method" className="w-full shrink-0 sm:w-52"> |
| 97 | <SelectValue /> |
| 98 | </SelectTrigger> |
| 99 | <SelectContent align="end"> |
| 100 | {on.map((method) => ( |
| 101 | <SelectItem key={method} value={method}> |
| 102 | {METHOD_INFO[method].action} |
| 103 | </SelectItem> |
| 104 | ))} |
| 105 | </SelectContent> |
| 106 | </Select> |
| 107 | </Card> |
| 108 | </> |
| 109 | ); |
| 110 | } |
| 111 | |
| 112 | /** What a merge or squash commit says unless whoever merges writes it: a choice, or a template. */ |
| 113 | function MessageDefault({ |
| 114 | method, |
| 115 | choice: saved, |
| 116 | template: savedTemplate, |
| 117 | disabled, |
| 118 | }: { |
| 119 | method: "merge" | "squash"; |
| 120 | choice: CommitMessageDefault; |
| 121 | template: string | null; |
| 122 | disabled?: boolean; |
| 123 | }) { |
| 124 | const [choice, setChoice] = useState<CommitMessageDefault>(saved); |
| 125 | const [template, setTemplate] = useState(savedTemplate ?? "{title} (#{number})\n\n{body}\n\nCloses {issues}\n{co_authors}"); |
| 126 | const box = useRef<HTMLTextAreaElement>(null); |
| 127 | const name = method === "merge" ? "mergeCommit" : "squashCommit"; |
| 128 | const preview = defaultMessage(method, choice, template, SAMPLE_PULL); |
| 129 | // A placeholder goes in where the cursor is, or at the end. |
| 130 | const insert = (placeholder: string) => { |
| 131 | const area = box.current; |
| 132 | const text = `{${placeholder}}`; |
| 133 | if (!area) return setTemplate((now) => now + text); |
| 134 | const start = area.selectionStart ?? template.length; |
| 135 | const end = area.selectionEnd ?? start; |
| 136 | const next = template.slice(0, start) + text + template.slice(end); |
| 137 | setTemplate(next); |
| 138 | requestAnimationFrame(() => { |
| 139 | area.focus(); |
| 140 | area.setSelectionRange(start + text.length, start + text.length); |
| 141 | }); |
| 142 | }; |
| 143 | return ( |
| 144 | <div className="ml-6.5 space-y-3"> |
| 145 | <Field> |
| 146 | <FieldLabel htmlFor={`${name}-message`} className="text-xs text-muted"> |
| 147 | Default commit message |
| 148 | </FieldLabel> |
| 149 | <Select name={`${name}Message`} value={choice} onValueChange={(value) => setChoice(value as CommitMessageDefault)} disabled={disabled}> |
| 150 | <SelectTrigger id={`${name}-message`} size="sm" className="w-full sm:w-80"> |
| 151 | <SelectValue /> |
| 152 | </SelectTrigger> |
| 153 | <SelectContent> |
| 154 | {MESSAGE_CHOICES[method].map((option) => ( |
| 155 | <SelectItem key={option.value} value={option.value}> |
| 156 | {option.label} |
| 157 | </SelectItem> |
| 158 | ))} |
| 159 | </SelectContent> |
| 160 | </Select> |
| 161 | </Field> |
| 162 | {choice === "custom" ? ( |
| 163 | <Field> |
| 164 | <FieldLabel htmlFor={`${name}-template`} className="text-xs text-muted"> |
| 165 | Template |
| 166 | </FieldLabel> |
| 167 | <Textarea |
| 168 | ref={box} |
| 169 | id={`${name}-template`} |
| 170 | name={`${name}Template`} |
| 171 | rows={6} |
| 172 | maxLength={4000} |
| 173 | value={template} |
| 174 | onChange={(event) => setTemplate(event.target.value)} |
| 175 | disabled={disabled} |
| 176 | className="font-mono text-xs" |
| 177 | /> |
| 178 | <FieldDescription>The first line is the commit's title. Press a placeholder to put it in:</FieldDescription> |
| 179 | <div className="flex flex-wrap gap-1.5"> |
| 180 | {PLACEHOLDERS.map((placeholder) => ( |
| 181 | <Hint key={placeholder.name} label={placeholder.about}> |
| 182 | <Button |
| 183 | type="button" |
| 184 | variant="outline" |
| 185 | size="xs" |
| 186 | disabled={disabled} |
| 187 | onClick={() => insert(placeholder.name)} |
| 188 | className="font-mono" |
| 189 | > |
| 190 | {`{${placeholder.name}}`} |
| 191 | </Button> |
| 192 | </Hint> |
| 193 | ))} |
| 194 | </div> |
| 195 | </Field> |
| 196 | ) : ( |
| 197 | // Kept while another choice is made, so switching back finds it. |
| 198 | savedTemplate != null && <input type="hidden" name={`${name}Template`} value={savedTemplate} /> |
| 199 | )} |
| 200 | <div> |
| 201 | <p className="text-xs text-faint">Preview, for a sample pull request</p> |
| 202 | <Card tone="bg" radius="lg" className="mt-1.5 px-3 py-2 font-mono text-xs"> |
| 203 | <p className="font-semibold text-fg wrap-break-word">{preview.title || <span className="text-faint">(no title)</span>}</p> |
| 204 | {preview.message && <p className="mt-2 whitespace-pre-wrap text-muted wrap-break-word">{preview.message}</p>} |
| 205 | </Card> |
| 206 | </div> |
| 207 | </div> |
| 208 | ); |
| 209 | } |