1import {
2 chipField,
3 createMarkdownEditor,
4 livePreview,
5 type MarkdownEditor,
6} from "@clo/markdown-editor";
7import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
8import { Compartment, EditorState, Prec, type Range, Transaction } from "@codemirror/state";
9import {
10 Decoration,
11 EditorView,
12 placeholder as placeholderText,
13 WidgetType,
14} from "@codemirror/view";
15import { BotIcon } from "lucide-react";
16import { useEffect, useLayoutEffect, useRef, useState } from "react";
17import { createPortal } from "react-dom";
18
19import { ClaudeAI, OpenAI } from "../Icons";
20import { ISSUE_PREFIX, parseCommitTitle } from "./commitTitle";
21
22/** Line 1 is always the title: no heading, list, or setext reading of its content. */
23const 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
49function 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
75const ASSISTED_BY = /^Assisted-by:[ \t]*(\S(?:.*\S)?)[ \t]*$/i;
76
77function 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
87interface 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. */
94class 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
127function 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
133const 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. */
181export 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}