1import { useState } from "react";
2import {
3 AuthSourceControlWriteScope,
4 type EnvironmentId,
5 type ScopedThreadRef,
6} from "@t3tools/contracts";
7
8import { cn } from "~/lib/utils";
9import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
10
11import { Button } from "../ui/button";
12import { Textarea } from "../ui/textarea";
13import { Toggle, ToggleGroup } from "../ui/toggle-group";
14import { PullRequestMarkdown } from "./PullRequestMarkdown";
15
16/**
17 * The box a body is rewritten in — a description, or a remark already posted. It owns the draft
18 * and nothing else: the caller sends the request and says whether it is still in flight, so the
19 * same box serves every mutation without knowing which one it is.
20 *
21 * Preview renders through the same component the saved body will be read through, which is the
22 * only way to see what a host's markdown will actually become before it is sent.
23 */
24export function PullRequestMarkdownEditor({
25 value,
26 cwd,
27 environmentId,
28 threadRef = null,
29 placeholder,
30 label,
31 saving,
32 allowEmpty = false,
33 className,
34 onSave,
35 onCancel,
36}: {
37 readonly value: string;
38 readonly cwd: string;
39 readonly environmentId: EnvironmentId;
40 /** Thread the editor sits beside, so links in its preview follow the link target setting. */
41 readonly threadRef?: ScopedThreadRef | null;
42 readonly placeholder?: string | undefined;
43 readonly label: string;
44 readonly saving: boolean;
45 /** A description may be cleared, which is how one is removed; a remark may not be emptied. */
46 readonly allowEmpty?: boolean;
47 readonly className?: string | undefined;
48 readonly onSave: (next: string) => void;
49 readonly onCancel: () => void;
50}) {
51 const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope);
52 const [draft, setDraft] = useState(value);
53 const [preview, setPreview] = useState(false);
54 // The words this draft started from. React keeps a component instance wherever the same
55 // position and key come round again, so an editor opened on one remark can be handed another's
56 // words without being rebuilt — and saving would then write the first remark's text onto the
57 // second. Different words mean a different subject, and the draft starts again from them.
58 const [seed, setSeed] = useState(value);
59 if (seed !== value) {
60 setSeed(value);
61 setDraft(value);
62 }
63 const empty = draft.trim().length === 0;
64 const saveDisabled = !canWriteSourceControl || saving || (empty && !allowEmpty);
65
66 return (
67 <div
68 className={cn("space-y-2", className)}
69 onKeyDown={(event) => {
70 if (event.nativeEvent.isComposing || event.keyCode === 229) return;
71 if (
72 event.key === "Enter" &&
73 (event.metaKey || event.ctrlKey) &&
74 !event.shiftKey &&
75 !event.altKey
76 ) {
77 event.preventDefault();
78 event.stopPropagation();
79 if (
80 !saveDisabled &&
81 !event.repeat &&
82 readEnvironmentScope(environmentId, AuthSourceControlWriteScope)
83 )
84 onSave(draft);
85 return;
86 }
87 if (event.key !== "Escape" || saving) return;
88 event.preventDefault();
89 onCancel();
90 }}
91 >
92 <ToggleGroup
93 aria-label="Markdown editor mode"
94 variant="segmented"
95 value={[preview ? "preview" : "write"]}
96 disabled={saving}
97 onValueChange={(next) => {
98 const mode = next[0];
99 if (mode === "write" || mode === "preview") setPreview(mode === "preview");
100 }}
101 >
102 <Toggle value="write">Write</Toggle>
103 <Toggle value="preview">Preview</Toggle>
104 </ToggleGroup>
105 {preview ? (
106 <div className="rounded-lg border border-border/60 px-3 py-2">
107 {empty ? (
108 <p className="text-xs text-muted-foreground">Nothing to preview.</p>
109 ) : (
110 <PullRequestMarkdown
111 text={draft}
112 cwd={cwd}
113 environmentId={environmentId}
114 threadRef={threadRef}
115 />
116 )}
117 </div>
118 ) : (
119 <Textarea
120 autoFocus
121 disabled={saving}
122 value={draft}
123 rows={6}
124 placeholder={placeholder}
125 aria-label={label}
126 onChange={(event) => setDraft(event.target.value)}
127 />
128 )}
129 <div className="flex justify-end gap-2">
130 <Button size="xs" variant="ghost" disabled={saving} onClick={onCancel}>
131 Cancel
132 </Button>
133 <Button
134 size="xs"
135 variant="outline"
136 disabled={!canWriteSourceControl || saving || (empty && !allowEmpty)}
137 onClick={() => {
138 if (
139 !readEnvironmentScope(environmentId, AuthSourceControlWriteScope) ||
140 saving ||
141 (empty && !allowEmpty)
142 )
143 return;
144 onSave(draft);
145 }}
146 >
147 {saving ? "Saving..." : "Save"}
148 </Button>
149 </div>
150 </div>
151 );
152}