Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

258 linesCodeBlameRaw
1import { ImagePlus, LoaderCircle } from "lucide-react";
2import {
3 type ClipboardEvent,
4 type DragEvent,
5 type ReactNode,
6 type RefObject,
7 createContext,
8 useCallback,
9 useContext,
10 useEffect,
11 useRef,
12 useState,
13} from "react";
14
15import {
16 ATTACHMENT_ACCEPT,
17 DEFERRED_FILE_FIELD,
18 DEFERRED_REF_FIELD,
19 caretAfter,
20 deferredUrl,
21 dragsFiles,
22 imageMarkdown,
23 imageName,
24 insertion,
25 pastedImages,
26 placeholderFor,
27 placeholderRange,
28 refusal,
29 uploadFailure,
30} from "../lib/attachments";
31import { Button } from "./ui/button";
32
33/**
34 * Where the comment boxes inside keep the images put in them: a
35 * repository (its pull requests, commits, and work items filed from it),
36 * a work item, or `later` for a work item not open yet with no repository
37 * to keep them (they go with the form, and the work item keeps them once
38 * it is open, as `g1t work new --attach` does).
39 */
40export type AttachTo = { repo: string } | { workspace: string; number: number } | "later";
41
42const AttachContext = createContext<AttachTo | null>(null);
43
44/** Comment boxes inside take images, kept by `to` (lib/attachments.ts). */
45export function AttachImages({ to, children }: { to: AttachTo | null; children: ReactNode }) {
46 return <AttachContext.Provider value={to}>{children}</AttachContext.Provider>;
47}
48
49function endpoint(to: Exclude<AttachTo, "later">, name: string): string {
50 const query = new URLSearchParams("repo" in to ? { repo: to.repo } : { workspace: to.workspace, number: String(to.number) });
51 query.set("name", name);
52 return `/-/attachments?${query}`;
53}
54
55/**
56 * Puts `text` in for `start`..`end` and tells the box it changed. At the
57 * caret (`end`), it keeps the browser's undo when the box is focused;
58 * elsewhere (`keep`), the person's caret or selection stays where it was
59 * in what they wrote.
60 */
61function put(box: HTMLTextAreaElement, text: string, start: number, end: number, select: "end" | "keep") {
62 if (select === "end") {
63 if (document.activeElement === box) {
64 box.setSelectionRange(start, end);
65 // Deprecated but still the one way to keep the edit undoable; falls back below.
66 if (document.execCommand?.("insertText", false, text)) return;
67 }
68 box.setRangeText(text, start, end, "end");
69 } else {
70 const [from, to, direction] = [box.selectionStart, box.selectionEnd, box.selectionDirection];
71 box.setRangeText(text, start, end);
72 box.setSelectionRange(caretAfter(from, start, end, text.length), caretAfter(to, start, end, text.length), direction ?? undefined);
73 }
74 box.dispatchEvent(new Event("input", { bubbles: true }));
75}
76
77/**
78 * Paste, drop or choose images in the comment box `ref`, GitHub's way:
79 * each puts `![Uploading name…]()` at the caret while it uploads, and that
80 * placeholder alone becomes `![name](url)`, wherever the person's writing
81 * has moved it. Its form can't be sent while one is uploading. Nothing
82 * outside an `<AttachImages>` takes images.
83 */
84export function useImageAttach(ref: RefObject<HTMLTextAreaElement | null>) {
85 const to = useContext(AttachContext);
86 const [uploading, setUploading] = useState(0);
87 const [error, setError] = useState<string | null>(null);
88 const [dragging, setDragging] = useState(false);
89 // A work item not open yet: its images, by the ref their Markdown holds.
90 const [later, setLater] = useState<{ ref: string; file: File }[]>([]);
91 const filesInput = useRef<HTMLInputElement>(null);
92 const chooser = useRef<HTMLInputElement>(null);
93
94 const attach = useCallback(
95 (files: readonly File[]) => {
96 const box = ref.current;
97 if (!box || !to) return;
98 const refused: string[] = [];
99 for (const file of files) {
100 const why = refusal(file);
101 if (why) {
102 refused.push(why);
103 continue;
104 }
105 const name = imageName(file);
106 if (to === "later") {
107 const id = Math.random().toString(36).slice(2, 10);
108 const at = insertion(box.value, box.selectionStart, box.selectionEnd, imageMarkdown(name, deferredUrl(id)));
109 put(box, at.text, at.start, at.end, "end");
110 setLater((held) => [...held, { ref: id, file }]);
111 continue;
112 }
113 const placeholder = placeholderFor(name, box.value);
114 const at = insertion(box.value, box.selectionStart, box.selectionEnd, placeholder);
115 put(box, at.text, at.start, at.end, "end");
116 setUploading((count) => count + 1);
117 void (async () => {
118 let replacement = "";
119 let failed: string | null = null;
120 try {
121 const answer = await fetch(endpoint(to, name), { method: "POST", body: file, headers: { "content-type": file.type } });
122 const body = (await answer.json().catch(() => null)) as
123 | { ok: true; markdown: string }
124 | { ok: false; error: { code: string; message: string } }
125 | null;
126 if (body?.ok) replacement = body.markdown;
127 else failed = uploadFailure(name, body && !body.ok ? body.error : null);
128 } catch {
129 failed = `${name} didn't upload. Check your connection and try again.`;
130 }
131 const now = ref.current;
132 const range = now && placeholderRange(now.value, placeholder, replacement);
133 if (now && range) put(now, replacement, range.start, range.end, "keep");
134 // Beside any other refusal from the same paste or drop, not over it.
135 if (failed) setError((shown) => (shown ? `${shown} ${failed}` : failed));
136 setUploading((count) => count - 1);
137 })();
138 }
139 setError(refused.length ? refused.join(" ") : null);
140 },
141 [ref, to],
142 );
143
144 // While an image uploads, the form waits: its buttons are off, and a
145 // submit some other way (Enter in a field) is held back.
146 useEffect(() => {
147 const form = ref.current?.form;
148 if (!form || uploading === 0) return;
149 const hold = (event: SubmitEvent) => {
150 event.preventDefault();
151 event.stopImmediatePropagation();
152 setError("Wait for the image to finish uploading, then send it.");
153 };
154 form.addEventListener("submit", hold, { capture: true });
155 const buttons = [...form.querySelectorAll<HTMLButtonElement>("button[type=submit]:not(:disabled)")];
156 for (const button of buttons) button.disabled = true;
157 return () => {
158 form.removeEventListener("submit", hold, { capture: true });
159 for (const button of buttons) button.disabled = false;
160 };
161 }, [ref, uploading]);
162
163 // A work item not open yet: the images held go with the form, those
164 // whose Markdown is still in the box (the action checks again).
165 useEffect(() => {
166 const input = filesInput.current;
167 if (!input || typeof DataTransfer === "undefined") return;
168 const files = new DataTransfer();
169 for (const held of later) files.items.add(held.file);
170 input.files = files.files;
171 }, [later]);
172
173 if (!to) return null;
174
175 const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
176 const data = event.clipboardData;
177 if (!data) return;
178 const files = [...data.items]
179 .filter((item) => item.kind === "file")
180 .map((item) => item.getAsFile())
181 .filter((file): file is File => file != null);
182 const images = pastedImages({ files, text: data.getData("text/plain") });
183 if (images.length === 0) return;
184 event.preventDefault();
185 attach(images);
186 };
187 const onDragOver = (event: DragEvent<HTMLTextAreaElement>) => {
188 if (!dragsFiles([...event.dataTransfer.types])) return;
189 event.preventDefault();
190 event.dataTransfer.dropEffect = "copy";
191 setDragging(true);
192 };
193 const onDragLeave = () => setDragging(false);
194 const onDrop = (event: DragEvent<HTMLTextAreaElement>) => {
195 setDragging(false);
196 if (!dragsFiles([...event.dataTransfer.types])) return;
197 event.preventDefault();
198 const box = ref.current;
199 // Where the drop landed is where the caret is, as the browser placed it on dragover.
200 box?.focus();
201 attach([...event.dataTransfer.files]);
202 };
203
204 const footer = (
205 <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-faint">
206 <Button
207 type="button"
208 variant="ghost"
209 size="inline"
210 className="gap-1 rounded px-1 py-0.5 text-xs font-normal text-faint hover:text-fg max-sm:min-h-9"
211 onClick={() => chooser.current?.click()}
212 >
213 <ImagePlus size={14} aria-hidden="true" />
214 Attach an image
215 </Button>
216 <span className="max-sm:hidden">or paste or drop one in. PNG, JPEG, GIF or WebP, up to 10 MB.</span>
217 <input
218 ref={chooser}
219 type="file"
220 accept={ATTACHMENT_ACCEPT}
221 multiple
222 hidden
223 tabIndex={-1}
224 aria-hidden="true"
225 onChange={(event) => {
226 const files = [...(event.currentTarget.files ?? [])];
227 event.currentTarget.value = "";
228 ref.current?.focus();
229 attach(files);
230 }}
231 />
232 {to === "later" && (
233 <>
234 <input ref={filesInput} type="file" name={DEFERRED_FILE_FIELD} multiple hidden tabIndex={-1} aria-hidden="true" />
235 {later.map((held) => (
236 <input key={held.ref} type="hidden" name={DEFERRED_REF_FIELD} value={held.ref} />
237 ))}
238 {later.length > 0 && <span>{later.length === 1 ? "The image uploads" : "The images upload"} when you open the work item.</span>}
239 </>
240 )}
241 <span aria-live="polite" className="contents">
242 {uploading > 0 && (
243 <span className="flex items-center gap-1 text-muted">
244 <LoaderCircle size={12} aria-hidden="true" className="animate-spin motion-reduce:animate-none" />
245 Uploading {uploading === 1 ? "an image" : `${uploading} images`}…
246 </span>
247 )}
248 </span>
249 {error && (
250 <p role="alert" className="basis-full text-sm text-danger">
251 {error}
252 </p>
253 )}
254 </div>
255 );
256
257 return { onPaste, onDragOver, onDragLeave, onDrop, dragging, footer };
258}