mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
import { CornerDownLeft, Pencil, Trash } from 'lucide-react'
|
|
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat'
|
|
import { useMountedRef } from '@/hooks/useMountedRef'
|
|
import { translate } from '@/i18n/i18n'
|
|
|
|
// Why: the saved-note card lives inside a Monaco view zone's DOM node.
|
|
// useDiffCommentDecorator creates a React root per zone and renders this
|
|
// component into it so we can use normal lucide icons and JSX instead of
|
|
// hand-built DOM + inline SVG strings.
|
|
//
|
|
// User-facing copy uses "Note" rather than "Comment" so it is not confused
|
|
// with GitHub PR review comments (which some diff-view surfaces also render).
|
|
// Internal types/ids (`DiffComment`, `diffComments`, `addDiffComment`) keep
|
|
// the old names so we don't have to migrate the persisted WorktreeMeta shape.
|
|
|
|
type Props = {
|
|
lineNumber: number
|
|
startLine?: number
|
|
label?: string | null
|
|
quote?: string
|
|
body: string
|
|
sentAt?: number
|
|
author?: string
|
|
createdAtLabel?: string
|
|
url?: string
|
|
onDelete?: () => void
|
|
// Why: Monaco view zones have a fixed `heightInPx` set at insertion time
|
|
// and aren't auto-measured. The parent decorator re-syncs that height when
|
|
// the rendered card wraps or grows so it cannot overlap following lines.
|
|
onContentResize?: () => void
|
|
observeRenderedSize?: boolean
|
|
onSubmitEdit?: (body: string) => Promise<boolean>
|
|
headerActions?: ReactNode
|
|
}
|
|
|
|
export function DiffCommentCard({
|
|
lineNumber,
|
|
startLine,
|
|
label,
|
|
quote,
|
|
body,
|
|
sentAt,
|
|
author,
|
|
createdAtLabel,
|
|
url,
|
|
onDelete,
|
|
onContentResize,
|
|
observeRenderedSize,
|
|
onSubmitEdit,
|
|
headerActions
|
|
}: Props): React.JSX.Element {
|
|
const [editing, setEditing] = useState(false)
|
|
const [draft, setDraft] = useState(body)
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const mountedRef = useMountedRef()
|
|
const cardRef = useRef<HTMLDivElement | null>(null)
|
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
|
const resizeAfterCloseRef = useRef(false)
|
|
const observesRenderedSize = observeRenderedSize === true && onContentResize !== undefined
|
|
|
|
// Why: stash `onContentResize` in a ref so resize effects do not depend on
|
|
// the decorator's fresh arrow each render. Re-running the edit layout effect
|
|
// would yank the caret to the textarea's end while the user is mid-edit.
|
|
const onContentResizeRef = useRef(onContentResize)
|
|
onContentResizeRef.current = onContentResize
|
|
|
|
useLayoutEffect(() => {
|
|
const card = cardRef.current
|
|
if (!card || !observesRenderedSize) {
|
|
return
|
|
}
|
|
onContentResizeRef.current?.()
|
|
let frameId: number | null = null
|
|
const notifyResize = (): void => {
|
|
if (frameId !== null) {
|
|
return
|
|
}
|
|
frameId = requestAnimationFrame(() => {
|
|
frameId = null
|
|
onContentResizeRef.current?.()
|
|
})
|
|
}
|
|
if (typeof ResizeObserver === 'undefined') {
|
|
return () => {
|
|
if (frameId !== null) {
|
|
cancelAnimationFrame(frameId)
|
|
}
|
|
}
|
|
}
|
|
// Why: narrow diff panes can wrap body/header text after Monaco's initial
|
|
// estimate; observe the real card height in either diff layout.
|
|
const observer = new ResizeObserver(() => notifyResize())
|
|
observer.observe(card)
|
|
return () => {
|
|
observer.disconnect()
|
|
if (frameId !== null) {
|
|
cancelAnimationFrame(frameId)
|
|
}
|
|
}
|
|
}, [observesRenderedSize])
|
|
|
|
// Why: focus + auto-grow the textarea on entering edit mode. Layout effect
|
|
// so the height is set before the browser paints — a measurement pass on
|
|
// the next animation frame would visibly jump from 0 to N px.
|
|
useLayoutEffect(() => {
|
|
if (!editing) {
|
|
if (resizeAfterCloseRef.current) {
|
|
resizeAfterCloseRef.current = false
|
|
onContentResizeRef.current?.()
|
|
}
|
|
return
|
|
}
|
|
const el = textareaRef.current
|
|
if (!el) {
|
|
return
|
|
}
|
|
el.style.height = 'auto'
|
|
el.style.height = `${Math.min(el.scrollHeight, 240)}px`
|
|
el.focus()
|
|
el.setSelectionRange(el.value.length, el.value.length)
|
|
onContentResizeRef.current?.()
|
|
}, [editing])
|
|
|
|
const scheduleContentResizeAfterClose = (): void => {
|
|
// Why: closing edit mode removes the textarea/footer before Monaco can
|
|
// re-measure the view zone. Let the layout effect run after React commits
|
|
// the body-only card so async saves cannot measure the old edit height.
|
|
resizeAfterCloseRef.current = true
|
|
}
|
|
|
|
const handleStartEdit = (): void => {
|
|
setDraft(body)
|
|
setEditing(true)
|
|
}
|
|
|
|
const handleCancel = (): void => {
|
|
scheduleContentResizeAfterClose()
|
|
setEditing(false)
|
|
setDraft(body)
|
|
}
|
|
|
|
const trimmedDraft = draft.trim()
|
|
const canSubmit = !submitting && trimmedDraft.length > 0 && trimmedDraft !== body
|
|
const lineLabel =
|
|
label === undefined ? getDiffCommentLineLabel({ lineNumber, startLine }).toLowerCase() : label
|
|
const metaText = [author || 'Note', lineLabel, createdAtLabel || (sentAt ? 'sent' : null)]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
|
|
const handleSubmit = async (): Promise<void> => {
|
|
if (!canSubmit || !onSubmitEdit) {
|
|
return
|
|
}
|
|
setSubmitting(true)
|
|
try {
|
|
const ok = await onSubmitEdit(trimmedDraft)
|
|
if (ok && mountedRef.current) {
|
|
scheduleContentResizeAfterClose()
|
|
setEditing(false)
|
|
}
|
|
} catch (err) {
|
|
// Why: surface the error in the console but keep the editor open with
|
|
// the draft intact so the user can retry. Without this, a rejection from
|
|
// `onSubmitEdit` becomes an unhandled promise rejection at the call sites
|
|
// (`void handleSubmit()`).
|
|
console.error('Failed to submit diff comment edit:', err)
|
|
} finally {
|
|
if (mountedRef.current) {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div ref={cardRef} className="orca-diff-comment-card">
|
|
<div className="orca-diff-comment-content-col">
|
|
{/* Header Row */}
|
|
<div className="orca-diff-comment-header">
|
|
<div className="orca-diff-comment-meta-group">{metaText}</div>
|
|
|
|
{/* Action buttons pill (only shown if not editing) */}
|
|
{!editing && (
|
|
<div
|
|
className="orca-diff-comment-actions-pill"
|
|
onMouseDown={(ev) => ev.stopPropagation()}
|
|
>
|
|
{headerActions}
|
|
{headerActions && (url || onSubmitEdit || onDelete) && (
|
|
<span className="orca-diff-comment-pill-divider" />
|
|
)}
|
|
{url && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="orca-diff-comment-pill-btn"
|
|
title={translate(
|
|
'auto.components.diff.comments.DiffCommentCard.508ee678a5',
|
|
'Open in browser'
|
|
)}
|
|
aria-label={translate(
|
|
'auto.components.diff.comments.DiffCommentCard.508ee678a5',
|
|
'Open in browser'
|
|
)}
|
|
onClick={(ev) => {
|
|
ev.preventDefault()
|
|
ev.stopPropagation()
|
|
void window.api.shell.openUrl(url)
|
|
}}
|
|
>
|
|
{translate('auto.components.diff.comments.DiffCommentCard.6978871a3d', 'Open')}
|
|
</button>
|
|
{(onSubmitEdit || onDelete) && (
|
|
<span className="orca-diff-comment-pill-divider" />
|
|
)}
|
|
</>
|
|
)}
|
|
{onSubmitEdit && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="orca-diff-comment-pill-btn"
|
|
title={translate(
|
|
'auto.components.diff.comments.DiffCommentCard.cad3384faa',
|
|
'Edit note'
|
|
)}
|
|
aria-label={translate(
|
|
'auto.components.diff.comments.DiffCommentCard.cad3384faa',
|
|
'Edit note'
|
|
)}
|
|
onClick={(ev) => {
|
|
ev.preventDefault()
|
|
ev.stopPropagation()
|
|
handleStartEdit()
|
|
}}
|
|
>
|
|
<Pencil className="size-3" />
|
|
</button>
|
|
{onDelete && <span className="orca-diff-comment-pill-divider" />}
|
|
</>
|
|
)}
|
|
{onDelete && (
|
|
<button
|
|
type="button"
|
|
className="orca-diff-comment-pill-btn orca-diff-comment-pill-btn-danger"
|
|
title={translate(
|
|
'auto.components.diff.comments.DiffCommentCard.cce596969e',
|
|
'Delete note'
|
|
)}
|
|
aria-label={translate(
|
|
'auto.components.diff.comments.DiffCommentCard.cce596969e',
|
|
'Delete note'
|
|
)}
|
|
onClick={(ev) => {
|
|
ev.preventDefault()
|
|
ev.stopPropagation()
|
|
onDelete()
|
|
}}
|
|
>
|
|
<Trash className="size-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Quote Block */}
|
|
{quote ? (
|
|
<div className="orca-diff-comment-quote">
|
|
<div className="orca-diff-comment-quote-text">{quote}</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Body or Edit Mode */}
|
|
{editing ? (
|
|
<div className="flex flex-col gap-2 mt-1">
|
|
<textarea
|
|
ref={textareaRef}
|
|
className="orca-diff-comment-popover-textarea"
|
|
value={draft}
|
|
onChange={(e) => {
|
|
setDraft(e.target.value)
|
|
const el = e.currentTarget
|
|
el.style.height = 'auto'
|
|
el.style.height = `${Math.min(el.scrollHeight, 240)}px`
|
|
onContentResizeRef.current?.()
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
handleCancel()
|
|
return
|
|
}
|
|
if (e.key === 'Enter' && !e.nativeEvent.isComposing && !e.shiftKey) {
|
|
e.preventDefault()
|
|
if (!canSubmit) {
|
|
return
|
|
}
|
|
void handleSubmit()
|
|
}
|
|
}}
|
|
rows={3}
|
|
/>
|
|
<div className="orca-diff-comment-popover-footer">
|
|
<Button variant="ghost" size="sm" onClick={handleCancel} disabled={submitting}>
|
|
{translate('auto.components.diff.comments.DiffCommentCard.0203bed775', 'Cancel')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void handleSubmit()}
|
|
disabled={!canSubmit}
|
|
title={
|
|
submitting
|
|
? translate(
|
|
'auto.components.diff.comments.DiffCommentCard.bb0a55f856',
|
|
'Saving…'
|
|
)
|
|
: undefined
|
|
}
|
|
>
|
|
{translate('auto.components.diff.comments.DiffCommentCard.109a791e7b', 'Save')}
|
|
<CornerDownLeft className="ml-1 size-3 opacity-70" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="orca-diff-comment-body">{body}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|