diff options
| author | Shoubhit Dash <[email protected]> | 2026-04-01 16:11:57 +0530 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-01 16:11:57 +0530 |
| commit | a3a6cf1c075c40c87980dda181d586a1d06ea304 (patch) | |
| tree | 71b3c3d7144f7c38eb73d7e173852858313fc4bf /packages/ui/src | |
| parent | 47a676111a3532aebed01110494742e536b7e5b4 (diff) | |
| download | opencode-a3a6cf1c075c40c87980dda181d586a1d06ea304.tar.gz opencode-a3a6cf1c075c40c87980dda181d586a1d06ea304.zip | |
feat(comments): support file mentions (#20447)
Diffstat (limited to 'packages/ui/src')
| -rw-r--r-- | packages/ui/src/components/line-comment-annotations.tsx | 8 | ||||
| -rw-r--r-- | packages/ui/src/components/line-comment-styles.ts | 52 | ||||
| -rw-r--r-- | packages/ui/src/components/line-comment.tsx | 137 | ||||
| -rw-r--r-- | packages/ui/src/components/session-review.tsx | 3 |
4 files changed, 198 insertions, 2 deletions
diff --git a/packages/ui/src/components/line-comment-annotations.tsx b/packages/ui/src/components/line-comment-annotations.tsx index a4870074d..80018d3dd 100644 --- a/packages/ui/src/components/line-comment-annotations.tsx +++ b/packages/ui/src/components/line-comment-annotations.tsx @@ -5,7 +5,7 @@ import { render as renderSolid } from "solid-js/web" import { useI18n } from "../context/i18n" import { createHoverCommentUtility } from "../pierre/comment-hover" import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge" -import { LineComment, LineCommentEditor } from "./line-comment" +import { LineComment, LineCommentEditor, type LineCommentEditorProps } from "./line-comment" export type LineCommentAnnotationMeta<T> = | { kind: "comment"; key: string; comment: T } @@ -55,6 +55,7 @@ type LineCommentControllerProps<T extends LineCommentShape> = { comments: Accessor<T[]> draftKey: Accessor<string> label: string + mention?: LineCommentEditorProps["mention"] state: LineCommentStateProps<string> onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void @@ -85,6 +86,7 @@ type CommentProps = { type DraftProps = { value: string selection: JSX.Element + mention?: LineCommentEditorProps["mention"] onInput: (value: string) => void onCancel: VoidFunction onSubmit: (value: string) => void @@ -148,6 +150,7 @@ export function createLineCommentAnnotationRenderer<T>(props: { onPopoverFocusOut={view().editor!.onPopoverFocusOut} cancelLabel={view().editor!.cancelLabel} submitLabel={view().editor!.submitLabel} + mention={view().editor!.mention} /> </Show> ) @@ -167,6 +170,7 @@ export function createLineCommentAnnotationRenderer<T>(props: { onCancel={view().onCancel} onSubmit={view().onSubmit} onPopoverFocusOut={view().onPopoverFocusOut} + mention={view().mention} /> ) }, host) @@ -389,6 +393,7 @@ export function createLineCommentController<T extends LineCommentShape>( return note.draft() }, selection: formatSelectedLineLabel(comment.selection, i18n.t), + mention: props.mention, onInput: note.setDraft, onCancel: note.cancelDraft, onSubmit: (value: string) => { @@ -415,6 +420,7 @@ export function createLineCommentController<T extends LineCommentShape>( return note.draft() }, selection: formatSelectedLineLabel(range, i18n.t), + mention: props.mention, onInput: note.setDraft, onCancel: note.cancelDraft, onSubmit: (comment) => { diff --git a/packages/ui/src/components/line-comment-styles.ts b/packages/ui/src/components/line-comment-styles.ts index 8fd02f088..59af66041 100644 --- a/packages/ui/src/components/line-comment-styles.ts +++ b/packages/ui/src/components/line-comment-styles.ts @@ -178,6 +178,58 @@ export const lineCommentStyles = ` box-shadow: var(--shadow-xs-border-select); } +[data-component="line-comment"] [data-slot="line-comment-mention-list"] { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 180px; + overflow: auto; + padding: 4px; + border: 1px solid var(--border-base); + border-radius: var(--radius-md); + background: var(--surface-base); +} + +[data-component="line-comment"] [data-slot="line-comment-mention-item"] { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + padding: 6px 8px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-strong); + text-align: left; +} + +[data-component="line-comment"] [data-slot="line-comment-mention-item"][data-active] { + background: var(--surface-raised-base-hover); +} + +[data-component="line-comment"] [data-slot="line-comment-mention-path"] { + display: flex; + align-items: center; + min-width: 0; + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + line-height: var(--line-height-large); +} + +[data-component="line-comment"] [data-slot="line-comment-mention-dir"] { + min-width: 0; + color: var(--text-weak); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +[data-component="line-comment"] [data-slot="line-comment-mention-file"] { + color: var(--text-strong); + white-space: nowrap; +} + [data-component="line-comment"] [data-slot="line-comment-actions"] { display: flex; align-items: center; diff --git a/packages/ui/src/components/line-comment.tsx b/packages/ui/src/components/line-comment.tsx index bc47ad940..f0e29a485 100644 --- a/packages/ui/src/components/line-comment.tsx +++ b/packages/ui/src/components/line-comment.tsx @@ -1,5 +1,8 @@ -import { createEffect, createSignal, onMount, Show, splitProps, type JSX } from "solid-js" +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { createEffect, createSignal, For, onMount, Show, splitProps, type JSX } from "solid-js" import { Button } from "./button" +import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { installLineCommentStyles } from "./line-comment-styles" import { useI18n } from "../context/i18n" @@ -183,6 +186,9 @@ export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | " autofocus?: boolean cancelLabel?: string submitLabel?: string + mention?: { + items: (query: string) => string[] | Promise<string[]> + } } export const LineCommentEditor = (props: LineCommentEditorProps) => { @@ -198,12 +204,46 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => { "autofocus", "cancelLabel", "submitLabel", + "mention", ]) const refs = { textarea: undefined as HTMLTextAreaElement | undefined, } const [text, setText] = createSignal(split.value) + const [open, setOpen] = createSignal(false) + + function selectMention(item: { path: string } | undefined) { + if (!item) return + + const textarea = refs.textarea + const query = currentMention() + if (!textarea || !query) return + + const value = `${text().slice(0, query.start)}@${item.path} ${text().slice(query.end)}` + const cursor = query.start + item.path.length + 2 + + setText(value) + split.onInput(value) + closeMention() + + requestAnimationFrame(() => { + textarea.focus() + textarea.setSelectionRange(cursor, cursor) + }) + } + + const mention = useFilteredList<{ path: string }>({ + items: async (query) => { + if (!split.mention) return [] + if (!query.trim()) return [] + const paths = await split.mention.items(query) + return paths.map((path) => ({ path })) + }, + key: (item) => item.path, + filterKeys: ["path"], + onSelect: selectMention, + }) const focus = () => refs.textarea?.focus() const hold: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => { @@ -221,6 +261,46 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => { setText(split.value) }) + const closeMention = () => { + setOpen(false) + mention.clear() + } + + const currentMention = () => { + const textarea = refs.textarea + if (!textarea) return + if (!split.mention) return + if (textarea.selectionStart !== textarea.selectionEnd) return + + const end = textarea.selectionStart + const match = textarea.value.slice(0, end).match(/@(\S*)$/) + if (!match) return + + return { + query: match[1] ?? "", + start: end - match[0].length, + end, + } + } + + const syncMention = () => { + const item = currentMention() + if (!item) { + closeMention() + return + } + + setOpen(true) + mention.onInput(item.query) + } + + const selectActiveMention = () => { + const items = mention.flat() + if (items.length === 0) return + const active = mention.active() + selectMention(items.find((item) => item.path === active) ?? items[0]) + } + const submit = () => { const value = text().trim() if (!value) return @@ -247,11 +327,38 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => { const value = (e.currentTarget as HTMLTextAreaElement).value setText(value) split.onInput(value) + syncMention() }} + on:click={() => syncMention()} + on:select={() => syncMention()} on:keydown={(e) => { const event = e as KeyboardEvent if (event.isComposing || event.keyCode === 229) return event.stopPropagation() + if (open()) { + if (e.key === "Escape") { + event.preventDefault() + closeMention() + return + } + + if (e.key === "Tab") { + if (mention.flat().length === 0) return + event.preventDefault() + selectActiveMention() + return + } + + const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter" + const ctrlNav = + event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && (e.key === "n" || e.key === "p") + if ((nav || ctrlNav) && mention.flat().length > 0) { + mention.onKeyDown(event) + event.preventDefault() + return + } + } + if (e.key === "Escape") { event.preventDefault() e.currentTarget.blur() @@ -264,6 +371,34 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => { submit() }} /> + <Show when={open() && mention.flat().length > 0}> + <div data-slot="line-comment-mention-list"> + <For each={mention.flat().slice(0, 10)}> + {(item) => { + const directory = item.path.endsWith("/") ? item.path : getDirectory(item.path) + const name = item.path.endsWith("/") ? "" : getFilename(item.path) + return ( + <button + type="button" + data-slot="line-comment-mention-item" + data-active={mention.active() === item.path ? "" : undefined} + onMouseDown={(event) => event.preventDefault()} + onMouseEnter={() => mention.setActive(item.path)} + onClick={() => selectMention(item)} + > + <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" /> + <div data-slot="line-comment-mention-path"> + <span data-slot="line-comment-mention-dir">{directory}</span> + <Show when={name}> + <span data-slot="line-comment-mention-file">{name}</span> + </Show> + </div> + </button> + ) + }} + </For> + </div> + </Show> <div data-slot="line-comment-actions"> <div data-slot="line-comment-editor-label"> {i18n.t("ui.lineComment.editorLabel.prefix")} diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 83d2980f6..5000fcdc4 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -23,6 +23,7 @@ import { Dynamic } from "solid-js/web" import { mediaKindFromPath } from "../pierre/media" import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge" import { createLineCommentController } from "./line-comment-annotations" +import type { LineCommentEditorProps } from "./line-comment" const MAX_DIFF_CHANGED_LINES = 500 @@ -88,6 +89,7 @@ export interface SessionReviewProps { diffs: ReviewDiff[] onViewFile?: (file: string) => void readFile?: (path: string) => Promise<FileContent | undefined> + lineCommentMention?: LineCommentEditorProps["mention"] } function ReviewCommentMenu(props: { @@ -327,6 +329,7 @@ export const SessionReview = (props: SessionReviewProps) => { comments, label: i18n.t("ui.lineComment.submit"), draftKey: () => file, + mention: props.lineCommentMention, state: { opened: () => { const current = opened() |
