Paste, drop or attach an image in any comment box to upload it
Showing a screenshot in a comment meant leaving the site for the CLI. Now every comment box takes one the way GitHub's do: paste it, drop the file on the box, or choose it with Attach an image under the box (the way in on a phone). While it uploads, `![Uploading name…]()` sits at the caret and the box stays editable; when it is kept, that placeholder alone becomes ``, wherever the writing around it has moved it. A refusal takes the placeholder out and says why under the box. The form's buttons wait while an upload is going, so nothing is posted with a placeholder in it. It is added once, in MentionTextarea, inside an <AttachImages> that says where the images are kept: the repository on a pull request, a commit and a new pull request; the work item on its page. So comments, reviews, line comments, editing a comment, a pull request's description, and new pull requests and work items all get it; the plain Textarea boxes on those pages now use MentionTextarea. A new work item filed with no repository has nowhere to keep images until it is open, so, as `g1t work new --attach` does, they go with the form and the work item keeps them once it is. Closes #159
- G1t-Session:
- ses_01m575ycxgejntcr3stqnexj6w
| 348 | 348 | ## Images | |
| 349 | 349 | ||
| 350 | 350 | To show an image in a pull request's or a work item's description, or in a | |
| 351 | − | comment (a screenshot of a preview, say), upload it and paste the Markdown | |
| 352 | − | you get back. From a shell, `g1t attach preview.png` prints | |
| 351 | + | comment (a screenshot of a preview, say), put it in the box you are writing | |
| 352 | + | in. On the site, paste it, drop the file onto the box, or select **Attach an | |
| 353 | + | image** under it (on a phone, that button is the way in). This works in every | |
| 354 | + | comment box: comments and reviews, a comment on a line of the change, editing | |
| 355 | + | a comment, a pull request's description, and a new pull request or work item. | |
| 356 | + | While it uploads, the box shows `![Uploading screenshot.png…]()` where it | |
| 357 | + | will go, and you can keep writing; once it is kept, that becomes | |
| 358 | + | ``. A pasted image | |
| 359 | + | with no name of its own is `image.png`. Comment and the other buttons wait | |
| 360 | + | until every image has uploaded. If one can't be attached, the box says why | |
| 361 | + | under it and takes its placeholder out; nothing you wrote is lost. Pasting | |
| 362 | + | text works as before: something copied with its text and a picture of it, | |
| 363 | + | such as cells from a spreadsheet, pastes as text. A new work item filed with | |
| 364 | + | no repository has nowhere to keep images yet, so they go with it when you | |
| 365 | + | open it, and the work item keeps them. | |
| 366 | + | ||
| 367 | + | Otherwise, upload it and paste the Markdown you get back. From a shell, `g1t attach preview.png` prints | |
| 353 | 368 | ``, and `--attach | |
| 354 | 369 | preview.png` on `g1t pr create`, `g1t pr ready`, `g1t pr comment`, | |
| 355 | 370 | `g1t work new` and `g1t work comment` adds it for you (see the |
| 40 | 40 | ||
| 41 | 41 | On [Work](/work/), choose **New work item**: | |
| 42 | 42 | ||
| 43 | − | 1. Give it a **title** (up to 200 characters) and a **description**. | |
| 43 | + | 1. Give it a **title** (up to 200 characters) and a **description**. Paste | |
| 44 | + | or drop images into the description, or select **Attach an image** | |
| 45 | + | under it; see [images](/guides/pull-requests/#images). Comments take | |
| 46 | + | them the same way. | |
| 44 | 47 | 2. Under **Where it lands**, tick the repositories it should land in, its | |
| 45 | 48 | targets: none, one or several. It closes once what closes it has landed | |
| 46 | 49 | in every one, or, with none, once anything does. |
| 1 | + | import { ImagePlus, LoaderCircle } from "lucide-react"; | |
| 2 | + | import { | |
| 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 | + | ||
| 15 | + | import { | |
| 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"; | |
| 31 | + | import { 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 | + | */ | |
| 40 | + | export type AttachTo = { repo: string } | { workspace: string; number: number } | "later"; | |
| 41 | + | ||
| 42 | + | const AttachContext = createContext<AttachTo | null>(null); | |
| 43 | + | ||
| 44 | + | /** Comment boxes inside take images, kept by `to` (lib/attachments.ts). */ | |
| 45 | + | export function AttachImages({ to, children }: { to: AttachTo | null; children: ReactNode }) { | |
| 46 | + | return <AttachContext.Provider value={to}>{children}</AttachContext.Provider>; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | function 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 | + | */ | |
| 61 | + | function 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 ``, 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 | + | */ | |
| 84 | + | export 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].filter((item) => item.kind === "file").map((item) => item.getAsFile()).filter((file): file is File => file != null); | |
| 179 | + | const images = pastedImages({ files, text: data.getData("text/plain") }); | |
| 180 | + | if (images.length === 0) return; | |
| 181 | + | event.preventDefault(); | |
| 182 | + | attach(images); | |
| 183 | + | }; | |
| 184 | + | const onDragOver = (event: DragEvent<HTMLTextAreaElement>) => { | |
| 185 | + | if (!dragsFiles([...event.dataTransfer.types])) return; | |
| 186 | + | event.preventDefault(); | |
| 187 | + | event.dataTransfer.dropEffect = "copy"; | |
| 188 | + | setDragging(true); | |
| 189 | + | }; | |
| 190 | + | const onDragLeave = () => setDragging(false); | |
| 191 | + | const onDrop = (event: DragEvent<HTMLTextAreaElement>) => { | |
| 192 | + | setDragging(false); | |
| 193 | + | if (!dragsFiles([...event.dataTransfer.types])) return; | |
| 194 | + | event.preventDefault(); | |
| 195 | + | const box = ref.current; | |
| 196 | + | // Where the drop landed is where the caret is, as the browser placed it on dragover. | |
| 197 | + | box?.focus(); | |
| 198 | + | attach([...event.dataTransfer.files]); | |
| 199 | + | }; | |
| 200 | + | ||
| 201 | + | const footer = ( | |
| 202 | + | <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-faint"> | |
| 203 | + | <Button | |
| 204 | + | type="button" | |
| 205 | + | variant="ghost" | |
| 206 | + | size="inline" | |
| 207 | + | className="gap-1 rounded px-1 py-0.5 text-xs font-normal text-faint hover:text-fg max-sm:min-h-9" | |
| 208 | + | onClick={() => chooser.current?.click()} | |
| 209 | + | > | |
| 210 | + | <ImagePlus size={14} aria-hidden="true" /> | |
| 211 | + | Attach an image | |
| 212 | + | </Button> | |
| 213 | + | <span className="max-sm:hidden">or paste or drop one in. PNG, JPEG, GIF or WebP, up to 10 MB.</span> | |
| 214 | + | <input | |
| 215 | + | ref={chooser} | |
| 216 | + | type="file" | |
| 217 | + | accept={ATTACHMENT_ACCEPT} | |
| 218 | + | multiple | |
| 219 | + | hidden | |
| 220 | + | tabIndex={-1} | |
| 221 | + | aria-hidden="true" | |
| 222 | + | onChange={(event) => { | |
| 223 | + | const files = [...(event.currentTarget.files ?? [])]; | |
| 224 | + | event.currentTarget.value = ""; | |
| 225 | + | ref.current?.focus(); | |
| 226 | + | attach(files); | |
| 227 | + | }} | |
| 228 | + | /> | |
| 229 | + | {to === "later" && ( | |
| 230 | + | <> | |
| 231 | + | <input ref={filesInput} type="file" name={DEFERRED_FILE_FIELD} multiple hidden tabIndex={-1} aria-hidden="true" /> | |
| 232 | + | {later.map((held) => ( | |
| 233 | + | <input key={held.ref} type="hidden" name={DEFERRED_REF_FIELD} value={held.ref} /> | |
| 234 | + | ))} | |
| 235 | + | {later.length > 0 && <span>{later.length === 1 ? "The image uploads" : "The images upload"} when you open the work item.</span>} | |
| 236 | + | </> | |
| 237 | + | )} | |
| 238 | + | <span aria-live="polite" className="contents"> | |
| 239 | + | {uploading > 0 && ( | |
| 240 | + | <span className="flex items-center gap-1 text-muted"> | |
| 241 | + | <LoaderCircle size={12} aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> | |
| 242 | + | Uploading {uploading === 1 ? "an image" : `${uploading} images`}… | |
| 243 | + | </span> | |
| 244 | + | )} | |
| 245 | + | </span> | |
| 246 | + | {error && ( | |
| 247 | + | <p role="alert" className="basis-full text-sm text-danger"> | |
| 248 | + | {error} | |
| 249 | + | </p> | |
| 250 | + | )} | |
| 251 | + | </div> | |
| 252 | + | ); | |
| 253 | + | ||
| 254 | + | return { onPaste, onDragOver, onDragLeave, onDrop, dragging, footer }; | |
| 255 | + | } |
| 30 | 30 | withHtml, | |
| 31 | 31 | } from "../lib/diff"; | |
| 32 | 32 | import { Markdown } from "./markdown"; | |
| 33 | − | import { EmptyState, SubmitButton, Textarea, TimeAgo } from "./ui"; | |
| 33 | + | import { MentionTextarea } from "./mention-textarea"; | |
| 34 | + | import { EmptyState, SubmitButton, TimeAgo } from "./ui"; | |
| 34 | 35 | import { Avatar } from "./ui/avatar"; | |
| 35 | 36 | import { Button } from "./ui/button"; | |
| 36 | 37 | import { Card } from "./ui/card"; | |
| ⋯ | |||
| 177 | 178 | <input type="hidden" name="action" value="comment" /> | |
| 178 | 179 | <input type="hidden" name="path" value={path} /> | |
| 179 | 180 | <input type="hidden" name="line" value={line} /> | |
| 180 | − | <Textarea name="body" rows={3} required autoFocus placeholder={`Comment on line ${line}`} /> | |
| 181 | + | <MentionTextarea name="body" rows={3} required autoFocus placeholder={`Comment on line ${line}`} /> | |
| 181 | 182 | <div className="flex gap-2"> | |
| 182 | 183 | <SubmitButton match={{ action: "comment", path, line: String(line) }} pending="Commenting…"> | |
| 183 | 184 | Comment | |
| 2 | 2 | import { type ComponentProps, type KeyboardEvent, useId, useRef, useState } from "react"; | |
| 3 | 3 | ||
| 4 | 4 | import { type MentionMatch, mentionSuggestions } from "../lib/mention"; | |
| 5 | + | import { cn } from "../lib/cn"; | |
| 6 | + | import { useImageAttach } from "./attach-images"; | |
| 5 | 7 | import { Textarea } from "./ui"; | |
| 6 | 8 | ||
| 7 | 9 | /** | |
| 8 | 10 | * A comment box that offers to complete the teams in `handles` | |
| 9 | 11 | * (`@acme/backend`): Up and Down move through the suggestions, Tab or Enter | |
| 10 | − | * takes one, Escape dismisses them. | |
| 12 | + | * takes one, Escape dismisses them. Inside an `<AttachImages>`, it takes | |
| 13 | + | * images too: pasted, dropped or chosen with Attach an image | |
| 14 | + | * (components/attach-images.tsx). | |
| 11 | 15 | */ | |
| 12 | 16 | export function MentionTextarea({ handles, ...props }: ComponentProps<"textarea"> & { handles?: readonly string[] }) { | |
| 13 | 17 | const ref = useRef<HTMLTextAreaElement>(null); | |
| 14 | 18 | const [match, setMatch] = useState<MentionMatch | null>(null); | |
| 15 | 19 | const [active, setActive] = useState(0); | |
| 16 | 20 | const list = useId(); | |
| 21 | + | const images = useImageAttach(ref); | |
| 17 | 22 | ||
| 18 | 23 | const look = () => { | |
| 19 | 24 | const box = ref.current; | |
| ⋯ | |||
| 56 | 61 | ||
| 57 | 62 | const open = match != null; | |
| 58 | 63 | return ( | |
| 59 | − | <div className="relative"> | |
| 60 | − | <Textarea | |
| 61 | − | {...props} | |
| 62 | − | ref={ref} | |
| 63 | − | role={open ? "combobox" : undefined} | |
| 64 | − | aria-autocomplete="list" | |
| 65 | − | aria-expanded={open} | |
| 66 | − | aria-controls={open ? list : undefined} | |
| 67 | − | aria-activedescendant={open ? `${list}-${active}` : undefined} | |
| 68 | − | onKeyDown={onKeyDown} | |
| 69 | − | onInput={(event) => { | |
| 70 | − | look(); | |
| 71 | − | props.onInput?.(event); | |
| 72 | − | }} | |
| 73 | − | onClick={(event) => { | |
| 74 | − | look(); | |
| 75 | − | props.onClick?.(event); | |
| 76 | − | }} | |
| 77 | − | onBlur={(event) => { | |
| 78 | − | // After a click on a suggestion has landed. | |
| 79 | − | setTimeout(() => setMatch(null), 150); | |
| 80 | − | props.onBlur?.(event); | |
| 81 | − | }} | |
| 82 | − | /> | |
| 83 | − | {match && ( | |
| 84 | − | <ul | |
| 85 | − | id={list} | |
| 86 | − | role="listbox" | |
| 87 | − | aria-label="Mentions" | |
| 88 | − | className="absolute top-full left-0 z-20 mt-1 max-w-full min-w-44 overflow-hidden rounded-md border border-line bg-raised p-1 shadow-lg shadow-black/30" | |
| 89 | − | > | |
| 90 | − | {match.options.map((handle, index) => ( | |
| 91 | − | <li | |
| 92 | − | key={handle} | |
| 93 | − | id={`${list}-${index}`} | |
| 94 | − | role="option" | |
| 95 | − | aria-selected={index === active} | |
| 96 | − | onMouseDown={(event) => event.preventDefault()} | |
| 97 | − | onMouseEnter={() => setActive(index)} | |
| 98 | − | onClick={() => complete(handle)} | |
| 99 | − | className={`flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 font-mono text-xs text-fg ${ | |
| 100 | − | index === active ? "bg-line" : "" | |
| 101 | − | }`} | |
| 102 | − | > | |
| 103 | − | <Users size={12} className="shrink-0 text-faint" /> | |
| 104 | − | <span className="min-w-0 truncate">{handle}</span> | |
| 105 | − | {index === active && <span className="ml-auto pl-3 font-sans text-faint">Tab</span>} | |
| 106 | − | </li> | |
| 107 | − | ))} | |
| 108 | − | </ul> | |
| 109 | − | )} | |
| 64 | + | <div> | |
| 65 | + | <div className="relative"> | |
| 66 | + | <Textarea | |
| 67 | + | {...props} | |
| 68 | + | ref={ref} | |
| 69 | + | role={open ? "combobox" : undefined} | |
| 70 | + | aria-autocomplete="list" | |
| 71 | + | aria-expanded={open} | |
| 72 | + | aria-controls={open ? list : undefined} | |
| 73 | + | aria-activedescendant={open ? `${list}-${active}` : undefined} | |
| 74 | + | onKeyDown={onKeyDown} | |
| 75 | + | onInput={(event) => { | |
| 76 | + | look(); | |
| 77 | + | props.onInput?.(event); | |
| 78 | + | }} | |
| 79 | + | onClick={(event) => { | |
| 80 | + | look(); | |
| 81 | + | props.onClick?.(event); | |
| 82 | + | }} | |
| 83 | + | onBlur={(event) => { | |
| 84 | + | // After a click on a suggestion has landed. | |
| 85 | + | setTimeout(() => setMatch(null), 150); | |
| 86 | + | props.onBlur?.(event); | |
| 87 | + | }} | |
| 88 | + | onPaste={(event) => { | |
| 89 | + | images?.onPaste(event); | |
| 90 | + | props.onPaste?.(event); | |
| 91 | + | }} | |
| 92 | + | onDragOver={(event) => { | |
| 93 | + | images?.onDragOver(event); | |
| 94 | + | props.onDragOver?.(event); | |
| 95 | + | }} | |
| 96 | + | onDragLeave={(event) => { | |
| 97 | + | images?.onDragLeave(); | |
| 98 | + | props.onDragLeave?.(event); | |
| 99 | + | }} | |
| 100 | + | onDrop={(event) => { | |
| 101 | + | images?.onDrop(event); | |
| 102 | + | props.onDrop?.(event); | |
| 103 | + | }} | |
| 104 | + | className={cn(images?.dragging && "border-accent-dim ring-2 ring-accent/25", props.className)} | |
| 105 | + | /> | |
| 106 | + | {match && ( | |
| 107 | + | <ul | |
| 108 | + | id={list} | |
| 109 | + | role="listbox" | |
| 110 | + | aria-label="Mentions" | |
| 111 | + | className="absolute top-full left-0 z-20 mt-1 max-w-full min-w-44 overflow-hidden rounded-md border border-line bg-raised p-1 shadow-lg shadow-black/30" | |
| 112 | + | > | |
| 113 | + | {match.options.map((handle, index) => ( | |
| 114 | + | <li | |
| 115 | + | key={handle} | |
| 116 | + | id={`${list}-${index}`} | |
| 117 | + | role="option" | |
| 118 | + | aria-selected={index === active} | |
| 119 | + | onMouseDown={(event) => event.preventDefault()} | |
| 120 | + | onMouseEnter={() => setActive(index)} | |
| 121 | + | onClick={() => complete(handle)} | |
| 122 | + | className={`flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 font-mono text-xs text-fg ${ | |
| 123 | + | index === active ? "bg-line" : "" | |
| 124 | + | }`} | |
| 125 | + | > | |
| 126 | + | <Users size={12} className="shrink-0 text-faint" /> | |
| 127 | + | <span className="min-w-0 truncate">{handle}</span> | |
| 128 | + | {index === active && <span className="ml-auto pl-3 font-sans text-faint">Tab</span>} | |
| 129 | + | </li> | |
| 130 | + | ))} | |
| 131 | + | </ul> | |
| 132 | + | )} | |
| 133 | + | </div> | |
| 134 | + | {images?.footer} | |
| 110 | 135 | </div> | |
| 111 | 136 | ); | |
| 112 | 137 | } | |
| 16 | 16 | import { CommitAvatars, CommitNames } from "../../components/commit-person"; | |
| 17 | 17 | import { ErrorText, TimeAgo } from "../../components/ui"; | |
| 18 | 18 | import { CommentForm, CommentList } from "../../components/work"; | |
| 19 | + | import { AttachImages } from "../../components/attach-images"; | |
| 19 | 20 | import { ItemIcon } from "../../components/work-icons"; | |
| 20 | 21 | import { Badge } from "../../components/ui/badge"; | |
| 21 | 22 | import { Button } from "../../components/ui/button"; | |
| ⋯ | |||
| 214 | 215 | const base = `/${params.owner}/${params.repo}`; | |
| 215 | 216 | const { subject, body, trailers } = parseCommitMessage(commit.message); | |
| 216 | 217 | return ( | |
| 217 | − | <div> | |
| 218 | − | <Card asChild className="overflow-hidden"> | |
| 219 | − | <section> | |
| 220 | − | <div className="p-5"> | |
| 221 | − | <p className="flex items-center gap-2 text-xs text-faint"> | |
| 222 | − | <GitCommitHorizontal size={14} /> | |
| 223 | − | <Link to={`${base}/commits`} className="hover:text-fg"> | |
| 224 | − | Commit | |
| 225 | − | </Link> | |
| 226 | − | </p> | |
| 227 | − | <h2 className="mt-2 text-xl font-semibold tracking-tight text-balance"> | |
| 228 | − | <CommitText text={subject} base={base} /> | |
| 229 | − | </h2> | |
| 230 | − | {body && ( | |
| 231 | − | <p className="mt-3 max-w-3xl text-sm whitespace-pre-wrap text-muted"> | |
| 232 | − | <CommitText text={body} base={base} /> | |
| 218 | + | <AttachImages to={viewer ? { repo: `${params.owner}/${params.repo}` } : null}> | |
| 219 | + | <div> | |
| 220 | + | <Card asChild className="overflow-hidden"> | |
| 221 | + | <section> | |
| 222 | + | <div className="p-5"> | |
| 223 | + | <p className="flex items-center gap-2 text-xs text-faint"> | |
| 224 | + | <GitCommitHorizontal size={14} /> | |
| 225 | + | <Link to={`${base}/commits`} className="hover:text-fg"> | |
| 226 | + | Commit | |
| 227 | + | </Link> | |
| 233 | 228 | </p> | |
| 234 | − | )} | |
| 235 | − | {trailers.length > 0 && ( | |
| 236 | − | <dl className="mt-3 space-y-0.5 font-mono text-xs text-faint"> | |
| 237 | − | {trailers.map((trailer) => ( | |
| 238 | − | <div key={`${trailer.key}:${trailer.value}`}> | |
| 239 | − | <dt className="inline">{trailer.key}:</dt> <dd className="inline text-muted">{trailer.value}</dd> | |
| 240 | − | </div> | |
| 241 | − | ))} | |
| 242 | − | </dl> | |
| 243 | − | )} | |
| 244 | − | </div> | |
| 245 | − | <div className="flex flex-wrap items-center gap-x-5 gap-y-3 border-t border-line bg-bg/40 px-5 py-3 text-sm"> | |
| 246 | − | <span className="flex items-center gap-2"> | |
| 247 | − | <CommitAvatars commit={commit} size={20} /> | |
| 248 | − | <span className="font-medium"> | |
| 249 | − | <CommitNames commit={commit} all /> | |
| 250 | − | </span> | |
| 251 | − | <span className="text-muted"> | |
| 252 | − | committed <TimeAgo at={commit.authoredAt} /> | |
| 253 | − | </span> | |
| 254 | − | </span> | |
| 255 | − | <span className="flex items-center gap-2 text-xs text-muted"> | |
| 256 | − | {commit.parents.length === 0 ? ( | |
| 257 | − | "The first commit" | |
| 258 | − | ) : ( | |
| 259 | − | <> | |
| 260 | − | {commit.parents.length === 1 ? "Parent" : "Parents"} | |
| 261 | − | {commit.parents.map((parent) => ( | |
| 262 | − | <Link | |
| 263 | − | key={parent} | |
| 264 | − | to={`${base}/commit/${parent}`} | |
| 265 | − | prefetch="intent" | |
| 266 | − | className="font-mono text-fg/80 hover:text-accent hover:underline" | |
| 267 | − | > | |
| 268 | − | {parent.slice(0, 7)} | |
| 269 | − | </Link> | |
| 229 | + | <h2 className="mt-2 text-xl font-semibold tracking-tight text-balance"> | |
| 230 | + | <CommitText text={subject} base={base} /> | |
| 231 | + | </h2> | |
| 232 | + | {body && ( | |
| 233 | + | <p className="mt-3 max-w-3xl text-sm whitespace-pre-wrap text-muted"> | |
| 234 | + | <CommitText text={body} base={base} /> | |
| 235 | + | </p> | |
| 236 | + | )} | |
| 237 | + | {trailers.length > 0 && ( | |
| 238 | + | <dl className="mt-3 space-y-0.5 font-mono text-xs text-faint"> | |
| 239 | + | {trailers.map((trailer) => ( | |
| 240 | + | <div key={`${trailer.key}:${trailer.value}`}> | |
| 241 | + | <dt className="inline">{trailer.key}:</dt> <dd className="inline text-muted">{trailer.value}</dd> | |
| 242 | + | </div> | |
| 270 | 243 | ))} | |
| 271 | − | </> | |
| 244 | + | </dl> | |
| 272 | 245 | )} | |
| 273 | − | </span> | |
| 274 | − | <span className="ml-auto flex items-center gap-2"> | |
| 275 | − | <CommitChecksBadge checks={checks} sha={commit.hash} /> | |
| 276 | − | <Link | |
| 277 | − | to={`${base}/tree/${commit.hash}/`} | |
| 278 | − | className="rounded-md px-2 py-1 text-xs text-muted hover:bg-raised hover:text-fg" | |
| 279 | − | > | |
| 280 | − | Browse files | |
| 281 | − | </Link> | |
| 282 | − | <CopyHash hash={commit.hash} /> | |
| 283 | − | </span> | |
| 284 | − | </div> | |
| 285 | − | <Suspense | |
| 286 | − | fallback={ | |
| 287 | − | <div aria-busy="true" className="flex h-12 items-center gap-3 border-t border-line px-4"> | |
| 288 | − | <Skeleton className="size-4 rounded-full" /> | |
| 289 | − | <Skeleton className="h-3 w-64 max-w-[60%]" /> | |
| 290 | − | </div> | |
| 291 | − | } | |
| 292 | − | > | |
| 293 | − | <Await resolve={pull}> | |
| 294 | − | {(pull) => | |
| 295 | − | isFailed(pull) ? ( | |
| 296 | − | <div className="border-t border-line px-5 py-3"> | |
| 297 | − | <Unavailable what={pull.what} compact /> | |
| 298 | − | </div> | |
| 299 | − | ) : pull ? ( | |
| 300 | − | <MergedIn base={base} pull={pull} /> | |
| 301 | − | ) : null | |
| 302 | − | } | |
| 303 | − | </Await> | |
| 304 | − | </Suspense> | |
| 305 | − | {isFailed(closed) ? ( | |
| 306 | − | <div className="border-t border-line px-5 py-3"> | |
| 307 | − | <Unavailable what={closed.what} compact /> | |
| 308 | 246 | </div> | |
| 247 | + | <div className="flex flex-wrap items-center gap-x-5 gap-y-3 border-t border-line bg-bg/40 px-5 py-3 text-sm"> | |
| 248 | + | <span className="flex items-center gap-2"> | |
| 249 | + | <CommitAvatars commit={commit} size={20} /> | |
| 250 | + | <span className="font-medium"> | |
| 251 | + | <CommitNames commit={commit} all /> | |
| 252 | + | </span> | |
| 253 | + | <span className="text-muted"> | |
| 254 | + | committed <TimeAgo at={commit.authoredAt} /> | |
| 255 | + | </span> | |
| 256 | + | </span> | |
| 257 | + | <span className="flex items-center gap-2 text-xs text-muted"> | |
| 258 | + | {commit.parents.length === 0 ? ( | |
| 259 | + | "The first commit" | |
| 260 | + | ) : ( | |
| 261 | + | <> | |
| 262 | + | {commit.parents.length === 1 ? "Parent" : "Parents"} | |
| 263 | + | {commit.parents.map((parent) => ( | |
| 264 | + | <Link | |
| 265 | + | key={parent} | |
| 266 | + | to={`${base}/commit/${parent}`} | |
| 267 | + | prefetch="intent" | |
| 268 | + | className="font-mono text-fg/80 hover:text-accent hover:underline" | |
| 269 | + | > | |
| 270 | + | {parent.slice(0, 7)} | |
| 271 | + | </Link> | |
| 272 | + | ))} | |
| 273 | + | </> | |
| 274 | + | )} | |
| 275 | + | </span> | |
| 276 | + | <span className="ml-auto flex items-center gap-2"> | |
| 277 | + | <CommitChecksBadge checks={checks} sha={commit.hash} /> | |
| 278 | + | <Link | |
| 279 | + | to={`${base}/tree/${commit.hash}/`} | |
| 280 | + | className="rounded-md px-2 py-1 text-xs text-muted hover:bg-raised hover:text-fg" | |
| 281 | + | > | |
| 282 | + | Browse files | |
| 283 | + | </Link> | |
| 284 | + | <CopyHash hash={commit.hash} /> | |
| 285 | + | </span> | |
| 286 | + | </div> | |
| 287 | + | <Suspense | |
| 288 | + | fallback={ | |
| 289 | + | <div aria-busy="true" className="flex h-12 items-center gap-3 border-t border-line px-4"> | |
| 290 | + | <Skeleton className="size-4 rounded-full" /> | |
| 291 | + | <Skeleton className="h-3 w-64 max-w-[60%]" /> | |
| 292 | + | </div> | |
| 293 | + | } | |
| 294 | + | > | |
| 295 | + | <Await resolve={pull}> | |
| 296 | + | {(pull) => | |
| 297 | + | isFailed(pull) ? ( | |
| 298 | + | <div className="border-t border-line px-5 py-3"> | |
| 299 | + | <Unavailable what={pull.what} compact /> | |
| 300 | + | </div> | |
| 301 | + | ) : pull ? ( | |
| 302 | + | <MergedIn base={base} pull={pull} /> | |
| 303 | + | ) : null | |
| 304 | + | } | |
| 305 | + | </Await> | |
| 306 | + | </Suspense> | |
| 307 | + | {isFailed(closed) ? ( | |
| 308 | + | <div className="border-t border-line px-5 py-3"> | |
| 309 | + | <Unavailable what={closed.what} compact /> | |
| 310 | + | </div> | |
| 311 | + | ) : ( | |
| 312 | + | closed.map((item) => <ClosedItemRow key={`${item.workspace}#${item.number}`} item={item} />) | |
| 313 | + | )} | |
| 314 | + | </section> | |
| 315 | + | </Card> | |
| 316 | + | <div className="mt-6"> | |
| 317 | + | <DiffView | |
| 318 | + | comparison={comparison} | |
| 319 | + | repo={base} | |
| 320 | + | empty="This commit changes no files." | |
| 321 | + | fileBase={`${base}/blob/${commit.hash}`} | |
| 322 | + | review={{ comments: said.filter((comment) => comment.path), canComment: viewer != null && commentsOn }} | |
| 323 | + | /> | |
| 324 | + | </div> | |
| 325 | + | <section aria-labelledby="commit-comments" className="mt-10 space-y-4"> | |
| 326 | + | <h3 id="commit-comments" className="flex items-center gap-2 text-sm font-medium"> | |
| 327 | + | <MessagesSquare size={15} className="text-faint" /> | |
| 328 | + | {general.length === 0 ? "Comments" : `${general.length} ${general.length === 1 ? "comment" : "comments"}`} | |
| 329 | + | {said.length > general.length && ( | |
| 330 | + | <span className="font-normal text-faint"> | |
| 331 | + | · {said.length - general.length} on lines above | |
| 332 | + | </span> | |
| 333 | + | )} | |
| 334 | + | </h3> | |
| 335 | + | {isFailed(comments) && <Unavailable what={comments.what} />} | |
| 336 | + | <CommentList | |
| 337 | + | comments={general} | |
| 338 | + | base={base} | |
| 339 | + | // Commit comments name their author by username. | |
| 340 | + | viewerId={viewer?.username ?? null} | |
| 341 | + | canModerate={canModerate} | |
| 342 | + | failed={actionData && "comment" in actionData ? { comment: String(actionData.comment), error: actionData.error } : null} | |
| 343 | + | /> | |
| 344 | + | {commentsOn ? ( | |
| 345 | + | <CommentForm author={viewer?.username ?? null} resetKey={said.length} /> | |
| 309 | 346 | ) : ( | |
| 310 | − | closed.map((item) => <ClosedItemRow key={`${item.workspace}#${item.number}`} item={item} />) | |
| 347 | + | <Card className="flex items-center gap-2.5 px-4 py-3 text-sm text-muted"> | |
| 348 | + | <MessageSquareOff size={15} className="shrink-0 text-faint" /> | |
| 349 | + | Comments on commits are turned off in this repository. | |
| 350 | + | </Card> | |
| 311 | 351 | )} | |
| 352 | + | {actionData?.action === "comment" && <ErrorText>{actionData.error}</ErrorText>} | |
| 312 | 353 | </section> | |
| 313 | − | </Card> | |
| 314 | − | <div className="mt-6"> | |
| 315 | − | <DiffView | |
| 316 | − | comparison={comparison} | |
| 317 | − | repo={base} | |
| 318 | − | empty="This commit changes no files." | |
| 319 | − | fileBase={`${base}/blob/${commit.hash}`} | |
| 320 | − | review={{ comments: said.filter((comment) => comment.path), canComment: viewer != null && commentsOn }} | |
| 321 | − | /> | |
| 322 | 354 | </div> | |
| 323 | − | <section aria-labelledby="commit-comments" className="mt-10 space-y-4"> | |
| 324 | − | <h3 id="commit-comments" className="flex items-center gap-2 text-sm font-medium"> | |
| 325 | − | <MessagesSquare size={15} className="text-faint" /> | |
| 326 | − | {general.length === 0 ? "Comments" : `${general.length} ${general.length === 1 ? "comment" : "comments"}`} | |
| 327 | − | {said.length > general.length && ( | |
| 328 | − | <span className="font-normal text-faint"> | |
| 329 | − | · {said.length - general.length} on lines above | |
| 330 | − | </span> | |
| 331 | − | )} | |
| 332 | − | </h3> | |
| 333 | − | {isFailed(comments) && <Unavailable what={comments.what} />} | |
| 334 | − | <CommentList | |
| 335 | − | comments={general} | |
| 336 | − | base={base} | |
| 337 | − | // Commit comments name their author by username. | |
| 338 | − | viewerId={viewer?.username ?? null} | |
| 339 | − | canModerate={canModerate} | |
| 340 | − | failed={actionData && "comment" in actionData ? { comment: String(actionData.comment), error: actionData.error } : null} | |
| 341 | − | /> | |
| 342 | − | {commentsOn ? ( | |
| 343 | − | <CommentForm author={viewer?.username ?? null} resetKey={said.length} /> | |
| 344 | − | ) : ( | |
| 345 | − | <Card className="flex items-center gap-2.5 px-4 py-3 text-sm text-muted"> | |
| 346 | − | <MessageSquareOff size={15} className="shrink-0 text-faint" /> | |
| 347 | − | Comments on commits are turned off in this repository. | |
| 348 | − | </Card> | |
| 349 | − | )} | |
| 350 | − | {actionData?.action === "comment" && <ErrorText>{actionData.error}</ErrorText>} | |
| 351 | − | </section> | |
| 352 | − | </div> | |
| 355 | + | </AttachImages> | |
| 353 | 356 | ); | |
| 354 | 357 | } | |
| 13 | 13 | ErrorText, | |
| 14 | 14 | Field, | |
| 15 | 15 | Input, | |
| 16 | − | Textarea, | |
| 17 | 16 | } from "../../components/ui"; | |
| 18 | 17 | import { Card } from "../../components/ui/card"; | |
| 19 | 18 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| ⋯ | |||
| 21 | 20 | import { repos, work } from "../../lib/services.server"; | |
| 22 | 21 | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; | |
| 23 | 22 | import { Unavailable } from "../../components/boundary"; | |
| 23 | + | import { AttachImages } from "../../components/attach-images"; | |
| 24 | + | import { MentionTextarea } from "../../components/mention-textarea"; | |
| 24 | 25 | import { type Failed, isFailed, part } from "../../lib/partial"; | |
| 25 | 26 | ||
| 26 | 27 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| ⋯ | |||
| 249 | 250 | <Field label="Title"> | |
| 250 | 251 | <Input name="title" required autoFocus maxLength={200} /> | |
| 251 | 252 | </Field> | |
| 252 | − | <Field label="Description" hint="What changed and why. Markdown works."> | |
| 253 | − | <Textarea name="body" rows={8} /> | |
| 253 | + | <Field label="Description" htmlFor="pull-body" hint="What changed and why. Markdown works."> | |
| 254 | + | <AttachImages to={{ repo: self }}> | |
| 255 | + | <MentionTextarea id="pull-body" name="body" rows={8} /> | |
| 256 | + | </AttachImages> | |
| 254 | 257 | </Field> | |
| 255 | 258 | <Field | |
| 256 | 259 | label="Work item (optional)" | |
| 29 | 29 | import { CheckBadge } from "../../../components/checks"; | |
| 30 | 30 | import { LabelChip, LabelsBox, MilestoneBox } from "../../../components/labels"; | |
| 31 | 31 | import { Assignee, ChangeSize, CommentForm, CommentList, ItemState, PeoplePicker, PersonLink, PullIcon, plainText } from "../../../components/work"; | |
| 32 | + | import { AttachImages } from "../../../components/attach-images"; | |
| 32 | 33 | import { RoleBadge } from "../../../components/work-items"; | |
| 33 | 34 | import { DetailsDisclosure, detailsSummary } from "../../../components/details-disclosure"; | |
| 34 | 35 | import { DecisionList, IntegrationFailure, ItemWorkPanel, StartSessionDialog, TimelineSession } from "../../../components/coordination"; | |
| ⋯ | |||
| 542 | 543 | // On a phone: the title, then the item, its repositories and the | |
| 543 | 544 | // conversation, then the sidebar as a Details disclosure; beside it | |
| 544 | 545 | // from lg up. Its main actions sit at the bottom, within a thumb's reach. | |
| 545 | − | <div className="grid gap-x-8 gap-y-5 lg:grid-cols-[1fr_19rem] lg:grid-rows-[auto_1fr] lg:gap-y-0"> | |
| 546 | − | <header className="min-w-0 lg:col-start-1"> | |
| 547 | − | <h1 className="text-xl font-semibold tracking-tight text-balance wrap-break-word sm:text-2xl"> | |
| 548 | − | {item.title} <span className="font-normal text-faint">#{item.number}</span> | |
| 549 | − | </h1> | |
| 550 | − | <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted"> | |
| 551 | − | <ItemState item={item} /> | |
| 552 | − | <span className="flex min-w-0 items-center gap-2"> | |
| 553 | − | <Avatar name={item.author.username} size={18} /> | |
| 554 | − | <span className="min-w-0"> | |
| 555 | − | <PersonLink name={item.author.username} className="font-medium text-fg hover:underline" /> opened this{" "} | |
| 556 | − | {item.requestedBy && ( | |
| 557 | − | <> | |
| 558 | − | for <PersonLink name={item.requestedBy.username} className="font-medium text-fg-soft hover:underline" />{" "} | |
| 559 | − | </> | |
| 560 | − | )} | |
| 561 | − | <TimeAgo at={item.createdAt} /> | |
| 546 | + | <AttachImages to={viewer ? { workspace, number: item.number } : null}> | |
| 547 | + | <div className="grid gap-x-8 gap-y-5 lg:grid-cols-[1fr_19rem] lg:grid-rows-[auto_1fr] lg:gap-y-0"> | |
| 548 | + | <header className="min-w-0 lg:col-start-1"> | |
| 549 | + | <h1 className="text-xl font-semibold tracking-tight text-balance wrap-break-word sm:text-2xl"> | |
| 550 | + | {item.title} <span className="font-normal text-faint">#{item.number}</span> | |
| 551 | + | </h1> | |
| 552 | + | <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted"> | |
| 553 | + | <ItemState item={item} /> | |
| 554 | + | <span className="flex min-w-0 items-center gap-2"> | |
| 555 | + | <Avatar name={item.author.username} size={18} /> | |
| 556 | + | <span className="min-w-0"> | |
| 557 | + | <PersonLink name={item.author.username} className="font-medium text-fg hover:underline" /> opened this{" "} | |
| 558 | + | {item.requestedBy && ( | |
| 559 | + | <> | |
| 560 | + | for <PersonLink name={item.requestedBy.username} className="font-medium text-fg-soft hover:underline" />{" "} | |
| 561 | + | </> | |
| 562 | + | )} | |
| 563 | + | <TimeAgo at={item.createdAt} /> | |
| 564 | + | </span> | |
| 562 | 565 | </span> | |
| 563 | − | </span> | |
| 564 | − | {item.labels.map((name) => ( | |
| 565 | − | // Without the workspace's labels, a chip still names its label, in the plain color. | |
| 566 | − | <LabelChip key={name} name={name} color={isFailed(labels) ? undefined : labels.find((label) => label.name === name)?.color} /> | |
| 567 | − | ))} | |
| 568 | − | {open && item.agent && <Assignee agent={item.agent} />} | |
| 569 | − | </div> | |
| 570 | − | </header> | |
| 571 | − | ||
| 572 | − | <div className="min-w-0 max-lg:order-1 lg:col-start-1"> | |
| 573 | − | {item.resolutions.length > 0 && ( | |
| 574 | − | <ul className="space-y-2 lg:mt-5"> | |
| 575 | − | {item.resolutions.map((landed, index) => ( | |
| 576 | − | <li key={`${landed.repo}-${landed.pull ?? landed.commit ?? index}`}> | |
| 577 | − | <Link | |
| 578 | − | to={landed.pull != null ? `/${landed.repo}/pull/${landed.pull}` : `/${landed.repo}/commit/${landed.commit ?? ""}`} | |
| 579 | − | className="flex items-start gap-3 rounded-xl border border-merged/40 bg-merged/5 px-4 py-3 text-sm transition-colors hover:border-merged/70" | |
| 580 | − | > | |
| 581 | − | {landed.pull != null ? <GitMerge size={18} className="mt-0.5 shrink-0 text-merged" /> : <GitCommitHorizontal size={18} className="mt-0.5 shrink-0 text-merged" />} | |
| 582 | − | <span className="min-w-0"> | |
| 583 | − | Landed in <span className="font-mono font-medium">{landed.repo}</span> by{" "} | |
| 584 | − | {landed.pull != null ? ( | |
| 585 | − | <span className="font-medium">#{landed.pull}</span> | |
| 586 | − | ) : ( | |
| 587 | − | <span className="font-mono font-medium">{landed.commit?.slice(0, 7)}</span> | |
| 588 | − | )} | |
| 589 | − | {landed.pushedBy && <span className="text-muted">, pushed by {landed.pushedBy}</span>} | |
| 590 | − | </span> | |
| 591 | − | </Link> | |
| 592 | − | </li> | |
| 566 | + | {item.labels.map((name) => ( | |
| 567 | + | // Without the workspace's labels, a chip still names its label, in the plain color. | |
| 568 | + | <LabelChip key={name} name={name} color={isFailed(labels) ? undefined : labels.find((label) => label.name === name)?.color} /> | |
| 593 | 569 | ))} | |
| 594 | − | </ul> | |
| 595 | − | )} | |
| 596 | − | ||
| 597 | − | {item.body ? ( | |
| 598 | − | <div className={`${item.resolutions.length > 0 ? "mt-5" : "lg:mt-5"} rounded-xl border border-line bg-surface p-4 sm:p-5`}> | |
| 599 | − | <Markdown source={item.body} workspace={workspace} /> | |
| 600 | − | <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-4" /> | |
| 570 | + | {open && item.agent && <Assignee agent={item.agent} />} | |
| 601 | 571 | </div> | |
| 602 | − | ) : ( | |
| 603 | − | (loaderData.reactions?.length ?? 0) > 0 && <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-3 lg:mt-5" /> | |
| 604 | − | )} | |
| 572 | + | </header> | |
| 605 | 573 | ||
| 606 | − | <section aria-labelledby="repositories" className="mt-8"> | |
| 607 | − | <div className="flex flex-wrap items-center justify-between gap-2"> | |
| 608 | − | <h2 id="repositories" className="font-semibold tracking-tight"> | |
| 609 | − | Repositories | |
| 610 | − | </h2> | |
| 611 | − | <span className="flex items-center gap-3"> | |
| 612 | − | {missing.length > 0 && open && ( | |
| 613 | − | <span className="text-sm text-muted"> | |
| 614 | − | {missing.length} {missing.length === 1 ? "target" : "targets"} still to land | |
| 615 | − | </span> | |
| 616 | − | )} | |
| 617 | − | {canLink && ( | |
| 618 | − | <span className="max-sm:hidden"> | |
| 619 | − | <LinkRepository workspace={workspace} repos={loaderData.repos} linked={item.repos.map((repo) => repo.path.toLowerCase())} error={linkError} /> | |
| 620 | − | </span> | |
| 621 | − | )} | |
| 622 | − | </span> | |
| 623 | − | </div> | |
| 624 | − | {isFailed(loaderData.coordination) && ( | |
| 625 | − | <div className="mt-2"> | |
| 626 | − | <Unavailable what="Each repository's pull requests" compact /> | |
| 627 | − | </div> | |
| 574 | + | <div className="min-w-0 max-lg:order-1 lg:col-start-1"> | |
| 575 | + | {item.resolutions.length > 0 && ( | |
| 576 | + | <ul className="space-y-2 lg:mt-5"> | |
| 577 | + | {item.resolutions.map((landed, index) => ( | |
| 578 | + | <li key={`${landed.repo}-${landed.pull ?? landed.commit ?? index}`}> | |
| 579 | + | <Link | |
| 580 | + | to={landed.pull != null ? `/${landed.repo}/pull/${landed.pull}` : `/${landed.repo}/commit/${landed.commit ?? ""}`} | |
| 581 | + | className="flex items-start gap-3 rounded-xl border border-merged/40 bg-merged/5 px-4 py-3 text-sm transition-colors hover:border-merged/70" | |
| 582 | + | > | |
| 583 | + | {landed.pull != null ? <GitMerge size={18} className="mt-0.5 shrink-0 text-merged" /> : <GitCommitHorizontal size={18} className="mt-0.5 shrink-0 text-merged" />} | |
| 584 | + | <span className="min-w-0"> | |
| 585 | + | Landed in <span className="font-mono font-medium">{landed.repo}</span> by{" "} | |
| 586 | + | {landed.pull != null ? ( | |
| 587 | + | <span className="font-medium">#{landed.pull}</span> | |
| 588 | + | ) : ( | |
| 589 | + | <span className="font-mono font-medium">{landed.commit?.slice(0, 7)}</span> | |
| 590 | + | )} | |
| 591 | + | {landed.pushedBy && <span className="text-muted">, pushed by {landed.pushedBy}</span>} | |
| 592 | + | </span> | |
| 593 | + | </Link> | |
| 594 | + | </li> | |
| 595 | + | ))} | |
| 596 | + | </ul> | |
| 628 | 597 | )} | |
| 629 | − | {repoRows.length === 0 ? ( | |
| 630 | − | <div className="mt-3"> | |
| 631 | − | <EmptyState title="No repository yet"> | |
| 632 | − | Filed in the workspace itself. Link the repositories it should land in, and it closes once each of them has. | |
| 633 | − | </EmptyState> | |
| 598 | + | ||
| 599 | + | {item.body ? ( | |
| 600 | + | <div className={`${item.resolutions.length > 0 ? "mt-5" : "lg:mt-5"} rounded-xl border border-line bg-surface p-4 sm:p-5`}> | |
| 601 | + | <Markdown source={item.body} workspace={workspace} /> | |
| 602 | + | <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-4" /> | |
| 634 | 603 | </div> | |
| 635 | 604 | ) : ( | |
| 636 | − | <Card asChild divided className="mt-3"> | |
| 637 | − | <ul> | |
| 638 | − | {repoRows.map(({ repo, pulls: repoPulls }) => ( | |
| 639 | − | <RepoCard key={repo.repoId} repo={repo} pulls={repoPulls} full={full} canLink={canLink} /> | |
| 640 | − | ))} | |
| 641 | − | </ul> | |
| 642 | − | </Card> | |
| 605 | + | (loaderData.reactions?.length ?? 0) > 0 && <Reactions reactions={loaderData.reactions} viewer={viewer?.username ?? null} className="mt-3 lg:mt-5" /> | |
| 643 | 606 | )} | |
| 644 | − | {suggestions.length > 0 && open && ( | |
| 645 | − | <> | |
| 646 | − | <h3 className="mt-5 text-sm font-medium">Suggested</h3> | |
| 647 | − | <p className="mt-0.5 text-xs text-muted">Repositories it may also need, and why.</p> | |
| 648 | − | <Card asChild divided className="mt-2"> | |
| 607 | + | ||
| 608 | + | <section aria-labelledby="repositories" className="mt-8"> | |
| 609 | + | <div className="flex flex-wrap items-center justify-between gap-2"> | |
| 610 | + | <h2 id="repositories" className="font-semibold tracking-tight"> | |
| 611 | + | Repositories | |
| 612 | + | </h2> | |
| 613 | + | <span className="flex items-center gap-3"> | |
| 614 | + | {missing.length > 0 && open && ( | |
| 615 | + | <span className="text-sm text-muted"> | |
| 616 | + | {missing.length} {missing.length === 1 ? "target" : "targets"} still to land | |
| 617 | + | </span> | |
| 618 | + | )} | |
| 619 | + | {canLink && ( | |
| 620 | + | <span className="max-sm:hidden"> | |
| 621 | + | <LinkRepository workspace={workspace} repos={loaderData.repos} linked={item.repos.map((repo) => repo.path.toLowerCase())} error={linkError} /> | |
| 622 | + | </span> | |
| 623 | + | )} | |
| 624 | + | </span> | |
| 625 | + | </div> | |
| 626 | + | {isFailed(loaderData.coordination) && ( | |
| 627 | + | <div className="mt-2"> | |
| 628 | + | <Unavailable what="Each repository's pull requests" compact /> | |
| 629 | + | </div> | |
| 630 | + | )} | |
| 631 | + | {repoRows.length === 0 ? ( | |
| 632 | + | <div className="mt-3"> | |
| 633 | + | <EmptyState title="No repository yet"> | |
| 634 | + | Filed in the workspace itself. Link the repositories it should land in, and it closes once each of them has. | |
| 635 | + | </EmptyState> | |
| 636 | + | </div> | |
| 637 | + | ) : ( | |
| 638 | + | <Card asChild divided className="mt-3"> | |
| 649 | 639 | <ul> | |
| 650 | − | {suggestions.map((suggestion) => ( | |
| 651 | − | <SuggestionLine key={suggestion.repoId} suggestion={suggestion} workspace={workspace} canLink={canLink} /> | |
| 640 | + | {repoRows.map(({ repo, pulls: repoPulls }) => ( | |
| 641 | + | <RepoCard key={repo.repoId} repo={repo} pulls={repoPulls} full={full} canLink={canLink} /> | |
| 652 | 642 | ))} | |
| 653 | 643 | </ul> | |
| 654 | 644 | </Card> | |
| 655 | − | </> | |
| 656 | − | )} | |
| 657 | − | {linkError && ( | |
| 658 | − | <div className="mt-2"> | |
| 659 | − | <ErrorText>{linkError}</ErrorText> | |
| 645 | + | )} | |
| 646 | + | {suggestions.length > 0 && open && ( | |
| 647 | + | <> | |
| 648 | + | <h3 className="mt-5 text-sm font-medium">Suggested</h3> | |
| 649 | + | <p className="mt-0.5 text-xs text-muted">Repositories it may also need, and why.</p> | |
| 650 | + | <Card asChild divided className="mt-2"> | |
| 651 | + | <ul> | |
| 652 | + | {suggestions.map((suggestion) => ( | |
| 653 | + | <SuggestionLine key={suggestion.repoId} suggestion={suggestion} workspace={workspace} canLink={canLink} /> | |
| 654 | + | ))} | |
| 655 | + | </ul> | |
| 656 | + | </Card> | |
| 657 | + | </> | |
| 658 | + | )} | |
| 659 | + | {linkError && ( | |
| 660 | + | <div className="mt-2"> | |
| 661 | + | <ErrorText>{linkError}</ErrorText> | |
| 662 | + | </div> | |
| 663 | + | )} | |
| 664 | + | </section> | |
| 665 | + | ||
| 666 | + | {pulls.length === 0 && open && ( | |
| 667 | + | <div className="mt-6"> | |
| 668 | + | <EmptyState title="Nobody has worked on this yet"> | |
| 669 | + | Point your own agent at <code className="font-mono">{reference}</code>. | |
| 670 | + | </EmptyState> | |
| 660 | 671 | </div> | |
| 661 | 672 | )} | |
| 662 | − | </section> | |
| 663 | 673 | ||
| 664 | − | {pulls.length === 0 && open && ( | |
| 665 | − | <div className="mt-6"> | |
| 666 | − | <EmptyState title="Nobody has worked on this yet"> | |
| 667 | − | Point your own agent at <code className="font-mono">{reference}</code>. | |
| 668 | − | </EmptyState> | |
| 669 | − | </div> | |
| 670 | − | )} | |
| 671 | − | ||
| 672 | − | {failure && <IntegrationFailure className="mt-4" failure={failure} base={failureBase} pullHref={pullHref} fixed={resolution?.passed ?? resolution?.fix} />} | |
| 674 | + | {failure && <IntegrationFailure className="mt-4" failure={failure} base={failureBase} pullHref={pullHref} fixed={resolution?.passed ?? resolution?.fix} />} | |
| 673 | 675 | ||
| 674 | − | {isFailed(loaderData.coordination) ? ( | |
| 675 | − | <> | |
| 676 | − | <h2 className="mt-10 font-semibold tracking-tight">Decisions</h2> | |
| 677 | − | <div className="mt-3"> | |
| 678 | − | <Unavailable what="Its decisions" /> | |
| 679 | − | </div> | |
| 680 | − | </> | |
| 681 | − | ) : ( | |
| 682 | − | decisions.length > 0 && ( | |
| 676 | + | {isFailed(loaderData.coordination) ? ( | |
| 683 | 677 | <> | |
| 684 | − | <div className="mt-10 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1"> | |
| 685 | − | <h2 className="font-semibold tracking-tight">Decisions</h2> | |
| 686 | − | <p className="text-sm text-muted">Why the work is the way it is, and how sure that is</p> | |
| 687 | − | </div> | |
| 678 | + | <h2 className="mt-10 font-semibold tracking-tight">Decisions</h2> | |
| 688 | 679 | <div className="mt-3"> | |
| 689 | − | <DecisionList decisions={decisions} base={failureBase} pullHref={pullHref} /> | |
| 680 | + | <Unavailable what="Its decisions" /> | |
| 690 | 681 | </div> | |
| 691 | 682 | </> | |
| 692 | − | ) | |
| 693 | − | )} | |
| 683 | + | ) : ( | |
| 684 | + | decisions.length > 0 && ( | |
| 685 | + | <> | |
| 686 | + | <div className="mt-10 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1"> | |
| 687 | + | <h2 className="font-semibold tracking-tight">Decisions</h2> | |
| 688 | + | <p className="text-sm text-muted">Why the work is the way it is, and how sure that is</p> | |
| 689 | + | </div> | |
| 690 | + | <div className="mt-3"> | |
| 691 | + | <DecisionList decisions={decisions} base={failureBase} pullHref={pullHref} /> | |
| 692 | + | </div> | |
| 693 | + | </> | |
| 694 | + | ) | |
| 695 | + | )} | |
| 694 | 696 | ||
| 695 | − | <h2 className="mt-10 font-semibold tracking-tight">Discussion</h2> | |
| 696 | − | <div className="mt-3"> | |
| 697 | − | <div className="space-y-4"> | |
| 698 | − | {isFailed(loaderData.sessionTrees) && <Unavailable what="Its sessions" compact />} | |
| 699 | − | <CommentList | |
| 700 | − | comments={comments} | |
| 701 | − | workspace={workspace} | |
| 702 | − | viewerId={viewer?.id ?? null} | |
| 703 | − | viewerName={viewer?.username ?? null} | |
| 704 | − | extra={(sessionTrees ?? []) | |
| 705 | − | .filter((session) => !session.parentId) | |
| 706 | − | .map((session) => ({ id: session.id, at: session.startedAt, node: <TimelineSession session={session} base={sessionBase(session)} /> }))} | |
| 707 | − | canModerate={loaderData.canModerate} | |
| 708 | − | failed={actionData && "comment" in actionData ? { comment: String(actionData.comment), error: actionData.error } : null} | |
| 709 | − | /> | |
| 710 | − | <div id="comment"> | |
| 711 | − | <CommentForm author={viewer?.username ?? null} resetKey={comments.length} handles={loaderData.handles} /> | |
| 712 | − | </div> | |
| 713 | − | {canManage && ( | |
| 714 | − | <Form method="post" className="flex flex-wrap justify-end gap-2 max-sm:hidden"> | |
| 715 | − | {open ? ( | |
| 716 | − | <> | |
| 717 | − | <SubmitButton variant="outline" name="action" value="close-not-planned" pending="Closing…"> | |
| 718 | − | Close as not planned | |
| 719 | − | </SubmitButton> | |
| 720 | − | <SubmitButton variant="outline" name="action" value="close-completed" pending="Closing…"> | |
| 721 | − | Close work item | |
| 697 | + | <h2 className="mt-10 font-semibold tracking-tight">Discussion</h2> | |
| 698 | + | <div className="mt-3"> | |
| 699 | + | <div className="space-y-4"> | |
| 700 | + | {isFailed(loaderData.sessionTrees) && <Unavailable what="Its sessions" compact />} | |
| 701 | + | <CommentList | |
| 702 | + | comments={comments} | |
| 703 | + | workspace={workspace} | |
| 704 | + | viewerId={viewer?.id ?? null} | |
| 705 | + | viewerName={viewer?.username ?? null} | |
| 706 | + | extra={(sessionTrees ?? []) | |
| 707 | + | .filter((session) => !session.parentId) | |
| 708 | + | .map((session) => ({ id: session.id, at: session.startedAt, node: <TimelineSession session={session} base={sessionBase(session)} /> }))} | |
| 709 | + | canModerate={loaderData.canModerate} | |
| 710 | + | failed={actionData && "comment" in actionData ? { comment: String(actionData.comment), error: actionData.error } : null} | |
| 711 | + | /> | |
| 712 | + | <div id="comment"> | |
| 713 | + | <CommentForm author={viewer?.username ?? null} resetKey={comments.length} handles={loaderData.handles} /> | |
| 714 | + | </div> | |
| 715 | + | {canManage && ( | |
| 716 | + | <Form method="post" className="flex flex-wrap justify-end gap-2 max-sm:hidden"> | |
| 717 | + | {open ? ( | |
| 718 | + | <> | |
| 719 | + | <SubmitButton variant="outline" name="action" value="close-not-planned" pending="Closing…"> | |
| 720 | + | Close as not planned | |
| 721 | + | </SubmitButton> | |
| 722 | + | <SubmitButton variant="outline" name="action" value="close-completed" pending="Closing…"> | |
| 723 | + | Close work item | |
| 724 | + | </SubmitButton> | |
| 725 | + | </> | |
| 726 | + | ) : ( | |
| 727 | + | <SubmitButton variant="outline" name="action" value="reopen" pending="Reopening…"> | |
| 728 | + | Reopen work item | |
| 722 | 729 | </SubmitButton> | |
| 723 | − | </> | |
| 724 | − | ) : ( | |
| 725 | − | <SubmitButton variant="outline" name="action" value="reopen" pending="Reopening…"> | |
| 726 | − | Reopen work item | |
| 727 | − | </SubmitButton> | |
| 728 | − | )} | |
| 729 | − | </Form> | |
| 730 | − | )} | |
| 730 | + | )} | |
| 731 | + | </Form> | |
| 732 | + | )} | |
| 733 | + | </div> | |
| 731 | 734 | </div> | |
| 732 | − | </div> | |
| 733 | − | <div className="mt-2"> | |
| 734 | − | {/* A refused comment edit says so under the comment; the sidebar's own say so there. */} | |
| 735 | − | {!(actionData && ("comment" in actionData || sideError)) && <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>} | |
| 736 | − | </div> | |
| 737 | − | ||
| 738 | − | {/* On a phone, its main actions at the bottom, above the tab bar and clear of the keyboard. */} | |
| 739 | − | {(canManage || canLink) && ( | |
| 740 | − | <div className="sticky bottom-(--tabbar-h) z-10 -mx-4 mt-6 flex items-center gap-2 border-t border-line bg-bg/90 px-4 py-3 backdrop-blur in-data-[keyboard=open]:hidden sm:hidden"> | |
| 741 | − | {canLink && ( | |
| 742 | − | <div className="min-w-0 grow"> | |
| 743 | − | <LinkRepository workspace={workspace} repos={loaderData.repos} linked={item.repos.map((repo) => repo.path.toLowerCase())} error={null} wide /> | |
| 744 | − | </div> | |
| 745 | − | )} | |
| 746 | − | {canManage && ( | |
| 747 | − | <Form method="post" className="min-w-0 grow *:h-11 *:w-full"> | |
| 748 | − | {open ? ( | |
| 749 | − | <SubmitButton variant="outline" name="action" value="close-completed" pending="Closing…"> | |
| 750 | − | Close | |
| 751 | − | </SubmitButton> | |
| 752 | − | ) : ( | |
| 753 | − | <SubmitButton variant="outline" name="action" value="reopen" pending="Reopening…"> | |
| 754 | − | Reopen | |
| 755 | − | </SubmitButton> | |
| 756 | − | )} | |
| 757 | − | </Form> | |
| 758 | − | )} | |
| 735 | + | <div className="mt-2"> | |
| 736 | + | {/* A refused comment edit says so under the comment; the sidebar's own say so there. */} | |
| 737 | + | {!(actionData && ("comment" in actionData || sideError)) && <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>} | |
| 759 | 738 | </div> | |
| 760 | − | )} | |
| 761 | − | </div> | |
| 762 | 739 | ||
| 763 | − | <DetailsDisclosure | |
| 764 | − | className="max-lg:order-2 lg:col-start-2 lg:row-span-2 lg:row-start-1" | |
| 765 | − | summary={detailsSummary([ | |
| 766 | − | [coordination?.claims.length ?? 0, "claim", "claims"], | |
| 767 | − | [item.assignees.length, "assignee", "assignees"], | |
| 768 | − | [item.labels.length, "label", "labels"], | |
| 769 | − | [item.milestone ? 1 : 0, "milestone", "milestones"], | |
| 770 | − | ])} | |
| 771 | − | > | |
| 772 | − | {isFailed(loaderData.coordination) ? ( | |
| 773 | − | <section> | |
| 774 | − | <h3 className="text-sm font-medium">Work</h3> | |
| 775 | − | <div className="mt-2"> | |
| 776 | − | <Unavailable what="Who is on it" compact /> | |
| 777 | − | </div> | |
| 778 | − | </section> | |
| 779 | − | ) : ( | |
| 780 | − | coordination && ( | |
| 781 | − | <Boundary what="Work" fallback={<Unavailable what="Who is on it" compact />}> | |
| 782 | − | <ItemWorkPanel | |
| 783 | − | work={coordination} | |
| 784 | − | ended={loaderData.endedClaims} | |
| 785 | − | base={sessionBase} | |
| 786 | − | now={now} | |
| 787 | − | canEdit={canManage} | |
| 788 | − | error={actionData && "action" in actionData && actionData.action === "blocked-by" ? actionData.error : null} | |
| 789 | − | sessions={sessionTrees} | |
| 790 | − | start={ | |
| 791 | − | loaderData.agentSetup && open && workIn ? ( | |
| 792 | − | <StartSessionDialog | |
| 793 | − | setup={loaderData.agentSetup} | |
| 794 | − | repo={workIn} | |
| 795 | − | error={actionData && "action" in actionData && actionData.action === "start-session" ? actionData.error : null} | |
| 796 | − | /> | |
| 797 | − | ) : undefined | |
| 798 | − | } | |
| 799 | − | /> | |
| 800 | − | </Boundary> | |
| 801 | − | ) | |
| 802 | − | )} | |
| 803 | − | {isFailed(loaderData.links) ? ( | |
| 804 | − | <section> | |
| 805 | − | <h3 className="text-sm font-medium">From outside g1t</h3> | |
| 806 | − | <div className="mt-2"> | |
| 807 | − | <Unavailable what="Its links" compact /> | |
| 808 | − | </div> | |
| 809 | − | </section> | |
| 810 | − | ) : ( | |
| 811 | − | loaderData.links.length > 0 && ( | |
| 812 | − | <section> | |
| 813 | − | <h3 className="text-sm font-medium">From outside g1t</h3> | |
| 814 | − | <ul className="mt-2 space-y-1.5 text-sm"> | |
| 815 | − | {loaderData.links.map((link) => ( | |
| 816 | − | <li key={`${link.connectionId}-${link.key}`}> | |
| 817 | − | <a | |
| 818 | − | href={link.url} | |
| 819 | − | target="_blank" | |
| 820 | − | rel="noreferrer" | |
| 821 | − | className="group flex items-center gap-2 rounded-lg border border-line px-2.5 py-2 transition-colors hover:border-line-strong" | |
| 822 | − | > | |
| 823 | − | <span className="min-w-0 grow"> | |
| 824 | − | <span className="block truncate font-medium"> | |
| 825 | − | {PROVIDERS[link.provider].label} <span className="font-mono text-muted">{link.key}</span> | |
| 826 | − | </span> | |
| 827 | − | <span className="block text-xs text-faint"> | |
| 828 | − | {link.count > 1 ? `Seen ${link.count} times, last ` : "Linked "} | |
| 829 | − | <TimeAgo at={link.count > 1 ? link.lastSeen : link.firstSeen} /> | |
| 830 | − | </span> | |
| 831 | − | </span> | |
| 832 | − | <ExternalLink size={13} className="shrink-0 text-faint group-hover:text-fg" /> | |
| 833 | − | </a> | |
| 834 | − | </li> | |
| 835 | − | ))} | |
| 836 | − | </ul> | |
| 837 | − | </section> | |
| 838 | − | ) | |
| 839 | − | )} | |
| 840 | − | <section> | |
| 841 | − | <h3 className="text-sm font-medium">Assignees</h3> | |
| 842 | − | <ul className="mt-2 space-y-1.5 text-sm"> | |
| 843 | − | {item.assignees.map((name) => ( | |
| 844 | − | <li key={name} className="flex items-center gap-2 px-1"> | |
| 845 | − | <Avatar name={name} size={20} agent={name === "g1t"} /> | |
| 846 | − | <span className="grow truncate font-mono text-xs">{name === "g1t" ? "g1t" : <PersonLink name={name} className="hover:underline" />}</span> | |
| 847 | − | </li> | |
| 848 | − | ))} | |
| 849 | − | {item.assignees.length === 0 && <li className="px-1 text-xs text-faint">Nobody yet</li>} | |
| 850 | − | </ul> | |
| 851 | − | {!coordination && item.blockedBy.length > 0 && ( | |
| 852 | − | <p className="mt-3 text-xs text-muted"> | |
| 853 | − | Depends on{" "} | |
| 854 | − | {item.blockedBy.map((number, index) => ( | |
| 855 | − | <span key={number}> | |
| 856 | − | {index > 0 && ", "} | |
| 857 | − | <Link to={itemPath(workspace, number)} className="font-medium text-fg hover:underline"> | |
| 858 | − | #{number} | |
| 859 | − | </Link> | |
| 860 | − | </span> | |
| 861 | − | ))} | |
| 862 | − | {open ? ", which has to land first." : "."} | |
| 863 | − | </p> | |
| 864 | − | )} | |
| 865 | − | {viewer && can.triage && open && ( | |
| 866 | − | <div className="mt-3 space-y-2"> | |
| 867 | − | {/* g1t takes it on in a session on the workspace's Claude, as whoever assigned it. */} | |
| 868 | − | {loaderData.agentSetup && !item.assignees.includes("g1t") && ( | |
| 869 | − | <Form method="post"> | |
| 870 | − | <input type="hidden" name="action" value="assign" /> | |
| 871 | − | {item.assignees.map((name) => ( | |
| 872 | − | <input key={name} type="hidden" name="assignee" value={name} /> | |
| 873 | − | ))} | |
| 874 | − | <input type="hidden" name="assignee" value="g1t" /> | |
| 875 | − | <div className="*:w-full"> | |
| 876 | − | <SubmitButton variant="outline" name="who" value="g1t" pending="Assigning…"> | |
| 877 | − | <Bot size={14} /> | |
| 878 | − | Assign to g1t | |
| 740 | + | {/* On a phone, its main actions at the bottom, above the tab bar and clear of the keyboard. */} | |
| 741 | + | {(canManage || canLink) && ( | |
| 742 | + | <div className="sticky bottom-(--tabbar-h) z-10 -mx-4 mt-6 flex items-center gap-2 border-t border-line bg-bg/90 px-4 py-3 backdrop-blur in-data-[keyboard=open]:hidden sm:hidden"> | |
| 743 | + | {canLink && ( | |
| 744 | + | <div className="min-w-0 grow"> | |
| 745 | + | <LinkRepository workspace={workspace} repos={loaderData.repos} linked={item.repos.map((repo) => repo.path.toLowerCase())} error={null} wide /> | |
| 746 | + | </div> | |
| 747 | + | )} | |
| 748 | + | {canManage && ( | |
| 749 | + | <Form method="post" className="min-w-0 grow *:h-11 *:w-full"> | |
| 750 | + | {open ? ( | |
| 751 | + | <SubmitButton variant="outline" name="action" value="close-completed" pending="Closing…"> | |
| 752 | + | Close | |
| 879 | 753 | </SubmitButton> | |
| 880 | − | </div> | |
| 881 | − | </Form> | |
| 882 | − | )} | |
| 883 | − | {!item.assignees.includes(viewer.username) && ( | |
| 884 | − | <Form method="post"> | |
| 885 | − | <input type="hidden" name="action" value="assign" /> | |
| 886 | − | {item.assignees.map((name) => ( | |
| 887 | − | <input key={name} type="hidden" name="assignee" value={name} /> | |
| 888 | − | ))} | |
| 889 | − | <input type="hidden" name="assignee" value={viewer.username} /> | |
| 890 | − | <div className="*:w-full"> | |
| 891 | − | <SubmitButton variant="outline" name="who" value="self" pending="Assigning…"> | |
| 892 | − | Assign yourself | |
| 754 | + | ) : ( | |
| 755 | + | <SubmitButton variant="outline" name="action" value="reopen" pending="Reopening…"> | |
| 756 | + | Reopen | |
| 893 | 757 | </SubmitButton> | |
| 894 | − | </div> | |
| 758 | + | )} | |
| 895 | 759 | </Form> | |
| 896 | 760 | )} | |
| 897 | − | <details className="group"> | |
| 898 | − | <summary className="cursor-pointer text-xs text-faint hover:text-fg">Assign people</summary> | |
| 899 | − | <Form method="post" className="mt-2 space-y-2" key={item.assignees.join()}> | |
| 900 | − | <input type="hidden" name="action" value="assign" /> | |
| 901 | − | {/* Without the workspace's people the picker still holds who is | |
| 902 | − | assigned now, so saving never clears them. */} | |
| 903 | − | {isFailed(members) && <Unavailable what="The workspace's people" compact />} | |
| 904 | − | <PeoplePicker name="assignee" members={isFailed(members) ? [] : members} chosen={item.assignees} /> | |
| 905 | − | <SubmitButton variant="outline" name="who" value="picked" pending="Saving…"> | |
| 906 | − | Save assignees | |
| 907 | − | </SubmitButton> | |
| 908 | − | </Form> | |
| 909 | − | </details> | |
| 910 | 761 | </div> | |
| 911 | 762 | )} | |
| 912 | − | </section> | |
| 763 | + | </div> | |
| 913 | 764 | ||
| 914 | − | {/* Another way to start the work: not once a pull request is doing it. */} | |
| 915 | − | {open && !pulls.some((pull) => pull.status === "open" || pull.status === "draft") && ( | |
| 916 | − | <Card asChild className="p-4"> | |
| 765 | + | <DetailsDisclosure | |
| 766 | + | className="max-lg:order-2 lg:col-start-2 lg:row-span-2 lg:row-start-1" | |
| 767 | + | summary={detailsSummary([ | |
| 768 | + | [coordination?.claims.length ?? 0, "claim", "claims"], | |
| 769 | + | [item.assignees.length, "assignee", "assignees"], | |
| 770 | + | [item.labels.length, "label", "labels"], | |
| 771 | + | [item.milestone ? 1 : 0, "milestone", "milestones"], | |
| 772 | + | ])} | |
| 773 | + | > | |
| 774 | + | {isFailed(loaderData.coordination) ? ( | |
| 917 | 775 | <section> | |
| 918 | − | <h3 className="text-sm font-medium">Bring your own agent</h3> | |
| 919 | − | <p className="mt-1 text-xs text-muted">With g1t connected to your agent, ask it to work on this work item.</p> | |
| 920 | − | <div className="mt-3"> | |
| 921 | − | <CopyLine text={reference} /> | |
| 776 | + | <h3 className="text-sm font-medium">Work</h3> | |
| 777 | + | <div className="mt-2"> | |
| 778 | + | <Unavailable what="Who is on it" compact /> | |
| 922 | 779 | </div> | |
| 923 | − | {viewer && item.repos.length > 0 ? ( | |
| 924 | − | <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4"> | |
| 925 | − | <input type="hidden" name="action" value="open-pull" /> | |
| 926 | − | <p className="text-xs text-muted">Or open a draft pull request yourself and get a fork to push to.</p> | |
| 927 | − | <SelectField | |
| 928 | − | name="repo" | |
| 929 | − | aria-label="Repository" | |
| 930 | − | defaultValue={workIn ?? item.repos[0]!.path} | |
| 931 | − | options={item.repos.map((repo) => ({ value: repo.path, label: `${repo.path} (${ROLE_LABELS[repo.role].toLowerCase()})` }))} | |
| 932 | − | /> | |
| 933 | − | <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} /> | |
| 934 | − | <div className="*:w-full"> | |
| 935 | − | <SubmitButton match={{ action: "open-pull" }} pending="Opening…"> | |
| 936 | − | Open pull request | |
| 780 | + | </section> | |
| 781 | + | ) : ( | |
| 782 | + | coordination && ( | |
| 783 | + | <Boundary what="Work" fallback={<Unavailable what="Who is on it" compact />}> | |
| 784 | + | <ItemWorkPanel | |
| 785 | + | work={coordination} | |
| 786 | + | ended={loaderData.endedClaims} | |
| 787 | + | base={sessionBase} | |
| 788 | + | now={now} | |
| 789 | + | canEdit={canManage} | |
| 790 | + | error={actionData && "action" in actionData && actionData.action === "blocked-by" ? actionData.error : null} | |
| 791 | + | sessions={sessionTrees} | |
| 792 | + | start={ | |
| 793 | + | loaderData.agentSetup && open && workIn ? ( | |
| 794 | + | <StartSessionDialog | |
| 795 | + | setup={loaderData.agentSetup} | |
| 796 | + | repo={workIn} | |
| 797 | + | error={actionData && "action" in actionData && actionData.action === "start-session" ? actionData.error : null} | |
| 798 | + | /> | |
| 799 | + | ) : undefined | |
| 800 | + | } | |
| 801 | + | /> | |
| 802 | + | </Boundary> | |
| 803 | + | ) | |
| 804 | + | )} | |
| 805 | + | {isFailed(loaderData.links) ? ( | |
| 806 | + | <section> | |
| 807 | + | <h3 className="text-sm font-medium">From outside g1t</h3> | |
| 808 | + | <div className="mt-2"> | |
| 809 | + | <Unavailable what="Its links" compact /> | |
| 810 | + | </div> | |
| 811 | + | </section> | |
| 812 | + | ) : ( | |
| 813 | + | loaderData.links.length > 0 && ( | |
| 814 | + | <section> | |
| 815 | + | <h3 className="text-sm font-medium">From outside g1t</h3> | |
| 816 | + | <ul className="mt-2 space-y-1.5 text-sm"> | |
| 817 | + | {loaderData.links.map((link) => ( | |
| 818 | + | <li key={`${link.connectionId}-${link.key}`}> | |
| 819 | + | <a | |
| 820 | + | href={link.url} | |
| 821 | + | target="_blank" | |
| 822 | + | rel="noreferrer" | |
| 823 | + | className="group flex items-center gap-2 rounded-lg border border-line px-2.5 py-2 transition-colors hover:border-line-strong" | |
| 824 | + | > | |
| 825 | + | <span className="min-w-0 grow"> | |
| 826 | + | <span className="block truncate font-medium"> | |
| 827 | + | {PROVIDERS[link.provider].label} <span className="font-mono text-muted">{link.key}</span> | |
| 828 | + | </span> | |
| 829 | + | <span className="block text-xs text-faint"> | |
| 830 | + | {link.count > 1 ? `Seen ${link.count} times, last ` : "Linked "} | |
| 831 | + | <TimeAgo at={link.count > 1 ? link.lastSeen : link.firstSeen} /> | |
| 832 | + | </span> | |
| 833 | + | </span> | |
| 834 | + | <ExternalLink size={13} className="shrink-0 text-faint group-hover:text-fg" /> | |
| 835 | + | </a> | |
| 836 | + | </li> | |
| 837 | + | ))} | |
| 838 | + | </ul> | |
| 839 | + | </section> | |
| 840 | + | ) | |
| 841 | + | )} | |
| 842 | + | <section> | |
| 843 | + | <h3 className="text-sm font-medium">Assignees</h3> | |
| 844 | + | <ul className="mt-2 space-y-1.5 text-sm"> | |
| 845 | + | {item.assignees.map((name) => ( | |
| 846 | + | <li key={name} className="flex items-center gap-2 px-1"> | |
| 847 | + | <Avatar name={name} size={20} agent={name === "g1t"} /> | |
| 848 | + | <span className="grow truncate font-mono text-xs">{name === "g1t" ? "g1t" : <PersonLink name={name} className="hover:underline" />}</span> | |
| 849 | + | </li> | |
| 850 | + | ))} | |
| 851 | + | {item.assignees.length === 0 && <li className="px-1 text-xs text-faint">Nobody yet</li>} | |
| 852 | + | </ul> | |
| 853 | + | {!coordination && item.blockedBy.length > 0 && ( | |
| 854 | + | <p className="mt-3 text-xs text-muted"> | |
| 855 | + | Depends on{" "} | |
| 856 | + | {item.blockedBy.map((number, index) => ( | |
| 857 | + | <span key={number}> | |
| 858 | + | {index > 0 && ", "} | |
| 859 | + | <Link to={itemPath(workspace, number)} className="font-medium text-fg hover:underline"> | |
| 860 | + | #{number} | |
| 861 | + | </Link> | |
| 862 | + | </span> | |
| 863 | + | ))} | |
| 864 | + | {open ? ", which has to land first." : "."} | |
| 865 | + | </p> | |
| 866 | + | )} | |
| 867 | + | {viewer && can.triage && open && ( | |
| 868 | + | <div className="mt-3 space-y-2"> | |
| 869 | + | {/* g1t takes it on in a session on the workspace's Claude, as whoever assigned it. */} | |
| 870 | + | {loaderData.agentSetup && !item.assignees.includes("g1t") && ( | |
| 871 | + | <Form method="post"> | |
| 872 | + | <input type="hidden" name="action" value="assign" /> | |
| 873 | + | {item.assignees.map((name) => ( | |
| 874 | + | <input key={name} type="hidden" name="assignee" value={name} /> | |
| 875 | + | ))} | |
| 876 | + | <input type="hidden" name="assignee" value="g1t" /> | |
| 877 | + | <div className="*:w-full"> | |
| 878 | + | <SubmitButton variant="outline" name="who" value="g1t" pending="Assigning…"> | |
| 879 | + | <Bot size={14} /> | |
| 880 | + | Assign to g1t | |
| 881 | + | </SubmitButton> | |
| 882 | + | </div> | |
| 883 | + | </Form> | |
| 884 | + | )} | |
| 885 | + | {!item.assignees.includes(viewer.username) && ( | |
| 886 | + | <Form method="post"> | |
| 887 | + | <input type="hidden" name="action" value="assign" /> | |
| 888 | + | {item.assignees.map((name) => ( | |
| 889 | + | <input key={name} type="hidden" name="assignee" value={name} /> | |
| 890 | + | ))} | |
| 891 | + | <input type="hidden" name="assignee" value={viewer.username} /> | |
| 892 | + | <div className="*:w-full"> | |
| 893 | + | <SubmitButton variant="outline" name="who" value="self" pending="Assigning…"> | |
| 894 | + | Assign yourself | |
| 895 | + | </SubmitButton> | |
| 896 | + | </div> | |
| 897 | + | </Form> | |
| 898 | + | )} | |
| 899 | + | <details className="group"> | |
| 900 | + | <summary className="cursor-pointer text-xs text-faint hover:text-fg">Assign people</summary> | |
| 901 | + | <Form method="post" className="mt-2 space-y-2" key={item.assignees.join()}> | |
| 902 | + | <input type="hidden" name="action" value="assign" /> | |
| 903 | + | {/* Without the workspace's people the picker still holds who is | |
| 904 | + | assigned now, so saving never clears them. */} | |
| 905 | + | {isFailed(members) && <Unavailable what="The workspace's people" compact />} | |
| 906 | + | <PeoplePicker name="assignee" members={isFailed(members) ? [] : members} chosen={item.assignees} /> | |
| 907 | + | <SubmitButton variant="outline" name="who" value="picked" pending="Saving…"> | |
| 908 | + | Save assignees | |
| 937 | 909 | </SubmitButton> | |
| 938 | − | </div> | |
| 939 | − | {actionData && "action" in actionData && actionData.action === "open-pull" && <ErrorText>{actionData.error}</ErrorText>} | |
| 940 | − | {workIn && ( | |
| 941 | − | <p className="text-xs text-muted"> | |
| 942 | − | Already pushed a branch?{" "} | |
| 943 | − | <Link to={`/${workIn}/pulls/new?item=${item.number}`} className="text-fg underline underline-offset-4"> | |
| 944 | − | Open a pull request from it | |
| 945 | − | </Link> | |
| 946 | − | . | |
| 910 | + | </Form> | |
| 911 | + | </details> | |
| 912 | + | </div> | |
| 913 | + | )} | |
| 914 | + | </section> | |
| 915 | + | ||
| 916 | + | {/* Another way to start the work: not once a pull request is doing it. */} | |
| 917 | + | {open && !pulls.some((pull) => pull.status === "open" || pull.status === "draft") && ( | |
| 918 | + | <Card asChild className="p-4"> | |
| 919 | + | <section> | |
| 920 | + | <h3 className="text-sm font-medium">Bring your own agent</h3> | |
| 921 | + | <p className="mt-1 text-xs text-muted">With g1t connected to your agent, ask it to work on this work item.</p> | |
| 922 | + | <div className="mt-3"> | |
| 923 | + | <CopyLine text={reference} /> | |
| 924 | + | </div> | |
| 925 | + | {viewer && item.repos.length > 0 ? ( | |
| 926 | + | <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4"> | |
| 927 | + | <input type="hidden" name="action" value="open-pull" /> | |
| 928 | + | <p className="text-xs text-muted">Or open a draft pull request yourself and get a fork to push to.</p> | |
| 929 | + | <SelectField | |
| 930 | + | name="repo" | |
| 931 | + | aria-label="Repository" | |
| 932 | + | defaultValue={workIn ?? item.repos[0]!.path} | |
| 933 | + | options={item.repos.map((repo) => ({ value: repo.path, label: `${repo.path} (${ROLE_LABELS[repo.role].toLowerCase()})` }))} | |
| 934 | + | /> | |
| 935 | + | <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} /> | |
| 936 | + | <div className="*:w-full"> | |
| 937 | + | <SubmitButton match={{ action: "open-pull" }} pending="Opening…"> | |
| 938 | + | Open pull request | |
| 939 | + | </SubmitButton> | |
| 940 | + | </div> | |
| 941 | + | {actionData && "action" in actionData && actionData.action === "open-pull" && <ErrorText>{actionData.error}</ErrorText>} | |
| 942 | + | {workIn && ( | |
| 943 | + | <p className="text-xs text-muted"> | |
| 944 | + | Already pushed a branch?{" "} | |
| 945 | + | <Link to={`/${workIn}/pulls/new?item=${item.number}`} className="text-fg underline underline-offset-4"> | |
| 946 | + | Open a pull request from it | |
| 947 | + | </Link> | |
| 948 | + | . | |
| 949 | + | </p> | |
| 950 | + | )} | |
| 951 | + | </Form> | |
| 952 | + | ) : ( | |
| 953 | + | !viewer && ( | |
| 954 | + | <p className="mt-4 border-t border-line pt-4 text-sm text-muted"> | |
| 955 | + | <Link to="/login" className="text-fg underline underline-offset-4"> | |
| 956 | + | Sign in | |
| 957 | + | </Link>{" "} | |
| 958 | + | to open a pull request. | |
| 947 | 959 | </p> | |
| 948 | − | )} | |
| 949 | − | </Form> | |
| 950 | − | ) : ( | |
| 951 | − | !viewer && ( | |
| 952 | − | <p className="mt-4 border-t border-line pt-4 text-sm text-muted"> | |
| 953 | − | <Link to="/login" className="text-fg underline underline-offset-4"> | |
| 954 | − | Sign in | |
| 955 | − | </Link>{" "} | |
| 956 | − | to open a pull request. | |
| 957 | − | </p> | |
| 958 | − | ) | |
| 959 | − | )} | |
| 960 | − | </section> | |
| 961 | − | </Card> | |
| 962 | − | )} | |
| 960 | + | ) | |
| 961 | + | )} | |
| 962 | + | </section> | |
| 963 | + | </Card> | |
| 964 | + | )} | |
| 963 | 965 | ||
| 964 | − | {isFailed(labels) ? ( | |
| 965 | − | <SideUnavailable heading="Labels" what="Labels" /> | |
| 966 | − | ) : ( | |
| 967 | − | <LabelsBox | |
| 968 | − | labels={labels} | |
| 969 | − | chosen={item.labels} | |
| 970 | − | canEdit={canManage} | |
| 971 | − | canCreate={can.manage_labels} | |
| 972 | − | manageUrl={can.manage_labels ? workPath(workspace, "labels") : undefined} | |
| 973 | − | /> | |
| 974 | − | )} | |
| 975 | − | {isFailed(loaderData.milestones) ? ( | |
| 976 | − | <SideUnavailable heading="Milestone" what="Milestones" /> | |
| 977 | − | ) : ( | |
| 978 | − | <MilestoneBox milestones={loaderData.milestones} current={item.milestone} canEdit={can.triage} base={workPath(workspace)} /> | |
| 979 | − | )} | |
| 980 | − | {viewer && | |
| 981 | − | (isFailed(loaderData.subscription) ? ( | |
| 982 | − | <SideUnavailable heading="Notifications" what="Your notification settings" /> | |
| 966 | + | {isFailed(labels) ? ( | |
| 967 | + | <SideUnavailable heading="Labels" what="Labels" /> | |
| 968 | + | ) : ( | |
| 969 | + | <LabelsBox | |
| 970 | + | labels={labels} | |
| 971 | + | chosen={item.labels} | |
| 972 | + | canEdit={canManage} | |
| 973 | + | canCreate={can.manage_labels} | |
| 974 | + | manageUrl={can.manage_labels ? workPath(workspace, "labels") : undefined} | |
| 975 | + | /> | |
| 976 | + | )} | |
| 977 | + | {isFailed(loaderData.milestones) ? ( | |
| 978 | + | <SideUnavailable heading="Milestone" what="Milestones" /> | |
| 983 | 979 | ) : ( | |
| 984 | − | <SubscriptionBox action={itemPath(workspace, item.number)} number={item.number} kind="item" subscription={loaderData.subscription} /> | |
| 985 | − | ))} | |
| 986 | − | </DetailsDisclosure> | |
| 987 | − | </div> | |
| 980 | + | <MilestoneBox milestones={loaderData.milestones} current={item.milestone} canEdit={can.triage} base={workPath(workspace)} /> | |
| 981 | + | )} | |
| 982 | + | {viewer && | |
| 983 | + | (isFailed(loaderData.subscription) ? ( | |
| 984 | + | <SideUnavailable heading="Notifications" what="Your notification settings" /> | |
| 985 | + | ) : ( | |
| 986 | + | <SubscriptionBox action={itemPath(workspace, item.number)} number={item.number} kind="item" subscription={loaderData.subscription} /> | |
| 987 | + | ))} | |
| 988 | + | </DetailsDisclosure> | |
| 989 | + | </div> | |
| 990 | + | </AttachImages> | |
| 988 | 991 | ); | |
| 989 | 992 | } | |
| 990 | 993 | ||
| 1 | 1 | import { Download, Info } from "lucide-react"; | |
| 2 | 2 | import { Form, data, redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import { PROVIDERS } from "@g1t/contracts"; | |
| 4 | + | import { PROVIDERS, type User, type WorkItem } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/new"; | |
| 7 | 7 | import { page } from "../../../lib/meta"; | |
| 8 | − | import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../../components/ui"; | |
| 8 | + | import { ErrorText, Field, Input, SubmitButton } from "../../../components/ui"; | |
| 9 | + | import { AttachImages } from "../../../components/attach-images"; | |
| 10 | + | import { MentionTextarea } from "../../../components/mention-textarea"; | |
| 9 | 11 | import { Card } from "../../../components/ui/card"; | |
| 10 | 12 | import { CheckboxOption } from "../../../components/ui/checkbox"; | |
| 11 | 13 | import { SelectField } from "../../../components/ui/select"; | |
| ⋯ | |||
| 17 | 19 | import { isFailed, part } from "../../../lib/partial"; | |
| 18 | 20 | import { itemPath } from "../../../lib/work-items"; | |
| 19 | 21 | import { workspaceProjects } from "../../../lib/workspace-projects.server"; | |
| 22 | + | import { addresses } from "../../../lib/addresses.server"; | |
| 23 | + | import { DEFERRED_FILE_FIELD, DEFERRED_REF_FIELD, deferredRefs, resolveDeferred } from "../../../lib/attachments"; | |
| 24 | + | import { toBase64 } from "../../../lib/avatar-upload"; | |
| 20 | 25 | ||
| 21 | 26 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 22 | 27 | return page(args, { title: `New work item · ${params.owner} · g1t` }); | |
| ⋯ | |||
| 92 | 97 | milestone: Number(form.get("milestone")) || undefined, | |
| 93 | 98 | }); | |
| 94 | 99 | if (!result.ok) return { error: result.error.message }; | |
| 100 | + | await attachHeld(user, result.value, form); | |
| 95 | 101 | throw redirect(itemPath(result.value.workspace, result.value.number)); | |
| 96 | 102 | } | |
| 97 | 103 | ||
| 104 | + | /** | |
| 105 | + | * Images put in the description of a work item filed with no repository | |
| 106 | + | * (components/attach-images.tsx, `later`): with nothing to keep them until | |
| 107 | + | * it was open, they came with the form as ``. | |
| 108 | + | * Now the work item keeps them, and its description gets their addresses, | |
| 109 | + | * as `g1t work new --attach` does. One that fails to upload is taken out | |
| 110 | + | * of the description; the box checked its type and size before it was sent. | |
| 111 | + | */ | |
| 112 | + | async function attachHeld(user: User, item: WorkItem, form: FormData) { | |
| 113 | + | const files = form.getAll(DEFERRED_FILE_FIELD); | |
| 114 | + | const refs = form.getAll(DEFERRED_REF_FIELD).map(String); | |
| 115 | + | const body = item.body ?? ""; | |
| 116 | + | const wanted = deferredRefs(body); | |
| 117 | + | if (wanted.size === 0) return; | |
| 118 | + | const urls = new Map<string, string | null>(); | |
| 119 | + | for (const [index, ref] of refs.entries()) { | |
| 120 | + | const file = files[index]; | |
| 121 | + | if (!wanted.has(ref) || !file || typeof file === "string") continue; | |
| 122 | + | const bytes = new Uint8Array(await file.arrayBuffer()); | |
| 123 | + | const kept = await work | |
| 124 | + | .uploadAttachment(user, { workspace: item.workspace, number: item.number }, { name: file.name || "image.png", data: toBase64(bytes) }) | |
| 125 | + | .catch(() => null); | |
| 126 | + | urls.set(ref, kept?.ok ? `${addresses().usercontent}${kept.value.path}` : null); | |
| 127 | + | } | |
| 128 | + | // Any still not uploaded (its file did not arrive) goes too. | |
| 129 | + | for (const ref of wanted) if (!urls.has(ref)) urls.set(ref, null); | |
| 130 | + | await work.updateWorkItem(user, item.workspace, item.number, { body: resolveDeferred(body, urls) }).catch(() => null); | |
| 131 | + | } | |
| 132 | + | ||
| 98 | 133 | export default function NewWorkItem({ loaderData, actionData }: Route.ComponentProps) { | |
| 99 | 134 | const { origin, workspace } = loaderData; | |
| 100 | 135 | const names = loaderData.sources.map((source) => PROVIDERS[source].label); | |
| ⋯ | |||
| 138 | 173 | </Form> | |
| 139 | 174 | </Card> | |
| 140 | 175 | )} | |
| 141 | − | <Form method="post" className="space-y-5"> | |
| 176 | + | <Form method="post" encType="multipart/form-data" className="space-y-5"> | |
| 142 | 177 | <input type="hidden" name="origin" value={origin ?? ""} /> | |
| 143 | 178 | <Field label="Title"> | |
| 144 | 179 | <Input | |
| ⋯ | |||
| 153 | 188 | </Field> | |
| 154 | 189 | <Field | |
| 155 | 190 | label="Description" | |
| 191 | + | htmlFor="item-body" | |
| 156 | 192 | hint="What is wrong or wanted, and anything needed to act on it. Whoever works on it, person or agent, works from this text. To say what done looks like, add a list under a “## Definition of done” heading." | |
| 157 | 193 | > | |
| 158 | − | <Textarea name="body" rows={8} autoCapitalize="sentences" /> | |
| 194 | + | {/* Filed from a repository, it keeps the images; with none, the work item does, once it is open. */} | |
| 195 | + | <AttachImages to={origin ? { repo: `${workspace}/${origin}` } : "later"}> | |
| 196 | + | <MentionTextarea id="item-body" name="body" rows={8} autoCapitalize="sentences" /> | |
| 197 | + | </AttachImages> | |
| 159 | 198 | </Field> | |
| 160 | 199 | {isFailed(loaderData.repos) ? ( | |
| 161 | 200 | <Field label="Where it lands"> | |
Comments
Sign in to comment.