| 1 | import { |
| 2 | chipField, |
| 3 | createMarkdownEditor, |
| 4 | livePreview, |
| 5 | type MarkdownEditor, |
| 6 | } from "@clo/markdown-editor"; |
| 7 | import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; |
| 8 | import { Compartment, EditorState, Prec, type Range, Transaction } from "@codemirror/state"; |
| 9 | import { |
| 10 | Decoration, |
| 11 | EditorView, |
| 12 | placeholder as placeholderText, |
| 13 | WidgetType, |
| 14 | } from "@codemirror/view"; |
| 15 | import { BotIcon } from "lucide-react"; |
| 16 | import { useEffect, useLayoutEffect, useRef, useState } from "react"; |
| 17 | import { createPortal } from "react-dom"; |
| 18 | |
| 19 | import { ClaudeAI, OpenAI } from "../Icons"; |
| 20 | import { ISSUE_PREFIX, parseCommitTitle } from "./commitTitle"; |
| 21 | |
| 22 | /** Line 1 is always the title: no heading, list, or setext reading of its content. */ |
| 23 | const commitMarkdown = Prec.highest( |
| 24 | markdown({ |
| 25 | base: markdownLanguage, |
| 26 | addKeymap: false, |
| 27 | pasteURLAsLink: false, |
| 28 | completeHTMLTags: false, |
| 29 | extensions: { |
| 30 | defineNodes: [{ name: "CommitTitle", block: true }], |
| 31 | parseBlock: [ |
| 32 | { |
| 33 | name: "CommitTitle", |
| 34 | before: "LinkReference", |
| 35 | parse(cx, line) { |
| 36 | if (cx.lineStart !== 0) return false; |
| 37 | const from = cx.lineStart + line.pos; |
| 38 | const to = cx.lineStart + line.text.length; |
| 39 | cx.nextLine(); |
| 40 | cx.addElement(cx.elt("CommitTitle", from, to)); |
| 41 | return true; |
| 42 | }, |
| 43 | }, |
| 44 | ], |
| 45 | }, |
| 46 | }), |
| 47 | ); |
| 48 | |
| 49 | function titleDecorations(state: EditorState): Range<Decoration>[] { |
| 50 | const title = state.doc.line(1); |
| 51 | const ranges = [Decoration.line({ class: "cm-commit-title" }).range(title.from)]; |
| 52 | const parsed = parseCommitTitle(title.text); |
| 53 | if (parsed.issues.length > 0) { |
| 54 | const issuesEnd = title.from + (ISSUE_PREFIX.exec(title.text)?.[0].trimEnd().length ?? 0); |
| 55 | ranges.push(Decoration.mark({ class: "cm-commit-issues" }).range(title.from, issuesEnd)); |
| 56 | } |
| 57 | if (!parsed.prefix) return ranges; |
| 58 | ranges.push( |
| 59 | Decoration.mark({ class: `cm-commit-type-${parsed.prefix.tone}` }).range( |
| 60 | title.from + parsed.prefix.from, |
| 61 | title.from + parsed.prefix.to, |
| 62 | ), |
| 63 | ); |
| 64 | if (parsed.scope) { |
| 65 | ranges.push( |
| 66 | Decoration.mark({ class: "cm-commit-scope" }).range( |
| 67 | title.from + parsed.scope.from, |
| 68 | title.from + parsed.scope.to, |
| 69 | ), |
| 70 | ); |
| 71 | } |
| 72 | return ranges; |
| 73 | } |
| 74 | |
| 75 | const ASSISTED_BY = /^Assisted-by:[ \t]*(\S(?:.*\S)?)[ \t]*$/i; |
| 76 | |
| 77 | function assistedByTrailers(state: EditorState) { |
| 78 | const trailers: { from: number; to: number; model: string }[] = []; |
| 79 | for (let number = 2; number <= state.doc.lines; number++) { |
| 80 | const line = state.doc.line(number); |
| 81 | const model = ASSISTED_BY.exec(line.text)?.[1]; |
| 82 | if (model) trailers.push({ from: line.from, to: line.to, model }); |
| 83 | } |
| 84 | return trailers; |
| 85 | } |
| 86 | |
| 87 | interface TrailerSlot { |
| 88 | readonly key: number; |
| 89 | readonly dom: HTMLElement; |
| 90 | readonly model: string; |
| 91 | } |
| 92 | |
| 93 | /** Hands its icon slot to React, which portals the provider glyph in. */ |
| 94 | class AssistedByWidget extends WidgetType { |
| 95 | readonly model: string; |
| 96 | readonly slots: { |
| 97 | readonly add: (dom: HTMLElement, model: string) => void; |
| 98 | readonly remove: (dom: HTMLElement) => void; |
| 99 | }; |
| 100 | |
| 101 | constructor(model: string, slots: AssistedByWidget["slots"]) { |
| 102 | super(); |
| 103 | this.model = model; |
| 104 | this.slots = slots; |
| 105 | } |
| 106 | |
| 107 | override eq(other: AssistedByWidget): boolean { |
| 108 | return other.model === this.model; |
| 109 | } |
| 110 | |
| 111 | override toDOM(): HTMLElement { |
| 112 | const chip = document.createElement("span"); |
| 113 | chip.className = "cm-commit-trailer"; |
| 114 | chip.title = `Assisted-by: ${this.model}`; |
| 115 | const icon = document.createElement("span"); |
| 116 | icon.className = "cm-commit-trailer-icon"; |
| 117 | chip.append(icon, this.model); |
| 118 | this.slots.add(icon, this.model); |
| 119 | return chip; |
| 120 | } |
| 121 | |
| 122 | override destroy(dom: HTMLElement): void { |
| 123 | if (dom.firstElementChild instanceof HTMLElement) this.slots.remove(dom.firstElementChild); |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | function trailerIcon(model: string) { |
| 128 | if (/^claude-/i.test(model)) return ClaudeAI; |
| 129 | if (/^(?:gpt-|chatgpt|o\d)/i.test(model)) return OpenAI; |
| 130 | return BotIcon; |
| 131 | } |
| 132 | |
| 133 | const commitTheme = Prec.highest( |
| 134 | EditorView.theme({ |
| 135 | "&": { |
| 136 | fontSize: "13.5px", |
| 137 | color: "var(--foreground)", |
| 138 | "--markdown-text": "var(--foreground)", |
| 139 | "--markdown-muted": "var(--muted-foreground)", |
| 140 | "--markdown-panel": "var(--muted)", |
| 141 | "--markdown-accent": "var(--primary)", |
| 142 | }, |
| 143 | "&.cm-focused": { outline: "none" }, |
| 144 | ".cm-scroller": { fontFamily: "var(--font-sans)", lineHeight: "1.55" }, |
| 145 | ".cm-content": { padding: "10px 12px" }, |
| 146 | ".cm-commit-title": { |
| 147 | fontSize: "15px", |
| 148 | fontWeight: "600", |
| 149 | lineHeight: "1.45", |
| 150 | paddingBottom: "4px", |
| 151 | }, |
| 152 | ".cm-commit-issues": { color: "var(--muted-foreground)" }, |
| 153 | ".cm-commit-type-add": { color: "var(--success-foreground)" }, |
| 154 | ".cm-commit-type-fix": { color: "var(--error-foreground)" }, |
| 155 | ".cm-commit-type-other": { color: "var(--info-foreground)" }, |
| 156 | ".cm-commit-scope": { opacity: "0.65" }, |
| 157 | ".cm-commit-type-wip": { |
| 158 | color: "var(--background)", |
| 159 | backgroundColor: "var(--warning)", |
| 160 | borderRadius: "3px", |
| 161 | boxDecorationBreak: "clone", |
| 162 | }, |
| 163 | ".cm-commit-trailer": { |
| 164 | display: "inline-flex", |
| 165 | alignItems: "center", |
| 166 | gap: "4px", |
| 167 | fontSize: "12px", |
| 168 | color: "var(--muted-foreground)", |
| 169 | verticalAlign: "baseline", |
| 170 | }, |
| 171 | ".cm-commit-trailer-icon": { display: "inline-flex", width: "12px", height: "12px" }, |
| 172 | ".cm-lp-code, .cm-lp-codeblock": { |
| 173 | fontFamily: 'var(--chat-code-font, "AT Name Mono"), "T3 Markdown Symbols", var(--font-mono)', |
| 174 | fontVariationSettings: '"CASL" 0.15, "MONO" 1', |
| 175 | fontSize: "var(--font-size-code, 13px)", |
| 176 | }, |
| 177 | }), |
| 178 | ); |
| 179 | |
| 180 | /** Edits a commit message in editor form: title on line 1, body from line 2. */ |
| 181 | export function CommitMessageEditor(props: { |
| 182 | /** Editor form: title on line 1, body from line 2 (no blank separator). */ |
| 183 | value: string; |
| 184 | readOnly: boolean; |
| 185 | onChange: (value: string) => void; |
| 186 | placeholder?: string; |
| 187 | }) { |
| 188 | const host = useRef<HTMLDivElement>(null); |
| 189 | const editor = useRef<MarkdownEditor>(null); |
| 190 | const latest = useRef(props); |
| 191 | const [editing] = useState(() => new Compartment()); |
| 192 | const [trailerSlots, setTrailerSlots] = useState<readonly TrailerSlot[]>([]); |
| 193 | |
| 194 | useLayoutEffect(() => { |
| 195 | latest.current = props; |
| 196 | }); |
| 197 | // oxlint-disable react/exhaustive-effect-dependencies -- One view per mount; the effects below keep it controlled. |
| 198 | useLayoutEffect(() => { |
| 199 | let nextKey = 0; |
| 200 | const slots = { |
| 201 | add: (dom: HTMLElement, model: string) => { |
| 202 | const key = nextKey++; |
| 203 | setTrailerSlots((current) => [...current, { key, dom, model }]); |
| 204 | }, |
| 205 | remove: (dom: HTMLElement) => |
| 206 | setTrailerSlots((current) => current.filter((slot) => slot.dom !== dom)), |
| 207 | }; |
| 208 | const instance = createMarkdownEditor({ |
| 209 | preset: "document", |
| 210 | mentions: false, |
| 211 | preview: false, |
| 212 | doc: latest.current.value, |
| 213 | onChange: (text) => { |
| 214 | if (text !== latest.current.value && !latest.current.readOnly) |
| 215 | latest.current.onChange(text); |
| 216 | }, |
| 217 | extensions: [ |
| 218 | commitMarkdown, |
| 219 | livePreview({ |
| 220 | blockWidgets: true, |
| 221 | exclude: assistedByTrailers, |
| 222 | }), |
| 223 | chipField({ chips: titleDecorations }), |
| 224 | chipField({ |
| 225 | atomic: true, |
| 226 | chips: (state) => |
| 227 | assistedByTrailers(state).map(({ from, to, model }) => |
| 228 | Decoration.replace({ widget: new AssistedByWidget(model, slots) }).range(from, to), |
| 229 | ), |
| 230 | }), |
| 231 | editing.of([]), |
| 232 | commitTheme, |
| 233 | ], |
| 234 | }); |
| 235 | editor.current = instance; |
| 236 | host.current?.append(instance.dom); |
| 237 | return () => { |
| 238 | editor.current = null; |
| 239 | instance.destroy(); |
| 240 | }; |
| 241 | }, []); |
| 242 | // oxlint-enable react/exhaustive-effect-dependencies |
| 243 | |
| 244 | useEffect(() => { |
| 245 | editor.current?.view.dispatch({ |
| 246 | effects: editing.reconfigure([ |
| 247 | EditorState.readOnly.of(props.readOnly), |
| 248 | EditorView.editable.of(!props.readOnly), |
| 249 | EditorView.contentAttributes.of({ |
| 250 | "aria-label": "Commit message", |
| 251 | "aria-readonly": String(props.readOnly), |
| 252 | }), |
| 253 | props.placeholder === undefined ? [] : placeholderText(props.placeholder), |
| 254 | ]), |
| 255 | }); |
| 256 | }, [editing, props.placeholder, props.readOnly]); |
| 257 | |
| 258 | useEffect(() => { |
| 259 | const view = editor.current?.view; |
| 260 | const current = view?.state.doc.toString(); |
| 261 | if (!view || current === undefined || current === props.value) return; |
| 262 | |
| 263 | // Replace only the differing middle so the selection maps through the change. |
| 264 | const next = props.value; |
| 265 | let from = 0; |
| 266 | while (from < current.length && from < next.length && current[from] === next[from]) from++; |
| 267 | let suffix = 0; |
| 268 | while ( |
| 269 | suffix < current.length - from && |
| 270 | suffix < next.length - from && |
| 271 | current[current.length - 1 - suffix] === next[next.length - 1 - suffix] |
| 272 | ) |
| 273 | suffix++; |
| 274 | |
| 275 | view.dispatch({ |
| 276 | changes: { |
| 277 | from, |
| 278 | to: current.length - suffix, |
| 279 | insert: next.slice(from, next.length - suffix), |
| 280 | }, |
| 281 | annotations: Transaction.addToHistory.of(false), |
| 282 | }); |
| 283 | }, [props.value]); |
| 284 | |
| 285 | return ( |
| 286 | <> |
| 287 | <div ref={host} data-commit-message-editor /> |
| 288 | {trailerSlots.map(({ key, dom, model }) => { |
| 289 | const Glyph = trailerIcon(model); |
| 290 | return createPortal(<Glyph className="size-3 shrink-0" aria-hidden />, dom, String(key)); |
| 291 | })} |
| 292 | </> |
| 293 | ); |
| 294 | } |