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/components/line-comment.tsx | |
| parent | 47a676111a3532aebed01110494742e536b7e5b4 (diff) | |
| download | opencode-a3a6cf1c075c40c87980dda181d586a1d06ea304.tar.gz opencode-a3a6cf1c075c40c87980dda181d586a1d06ea304.zip | |
feat(comments): support file mentions (#20447)
Diffstat (limited to 'packages/ui/src/components/line-comment.tsx')
| -rw-r--r-- | packages/ui/src/components/line-comment.tsx | 137 |
1 files changed, 136 insertions, 1 deletions
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")} |
