| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439 |
- import { useFilteredList } from "@kirincode-ai/ui/hooks"
- import { getDirectory, getFilename } from "@kirincode-ai/core/util/path"
- import { createSignal, For, onMount, Show, splitProps, type JSX } from "solid-js"
- import { Button } from "@kirincode-ai/ui/button"
- import { FileIcon } from "@kirincode-ai/ui/file-icon"
- import { Icon } from "@kirincode-ai/ui/icon"
- import { installLineCommentStyles } from "./line-comment-styles"
- import { useI18n } from "@kirincode-ai/ui/context/i18n"
- installLineCommentStyles()
- export type LineCommentVariant = "default" | "editor" | "add"
- function InlineGlyph(props: { icon: "comment" | "plus" }) {
- return (
- <svg data-slot="line-comment-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
- <Show
- when={props.icon === "comment"}
- fallback={
- <path
- d="M10 5.41699V10.0003M10 10.0003V14.5837M10 10.0003H5.4165M10 10.0003H14.5832"
- stroke="currentColor"
- stroke-linecap="square"
- />
- }
- >
- <path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square" />
- </Show>
- </svg>
- )
- }
- export type LineCommentAnchorProps = {
- id?: string
- top?: number
- inline?: boolean
- hideButton?: boolean
- open: boolean
- variant?: LineCommentVariant
- icon?: "comment" | "plus"
- buttonLabel?: string
- onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
- onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
- onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
- class?: string
- popoverClass?: string
- children?: JSX.Element
- }
- export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
- const hidden = () => !props.inline && props.top === undefined
- const variant = () => props.variant ?? "default"
- const icon = () => props.icon ?? "comment"
- const inlineBody = () => props.inline && props.hideButton
- return (
- <div
- data-component="line-comment"
- data-prevent-autofocus=""
- data-variant={variant()}
- data-comment-id={props.id}
- data-open={props.open ? "" : undefined}
- data-inline={props.inline ? "" : undefined}
- classList={{
- [props.class ?? ""]: !!props.class,
- }}
- style={
- props.inline
- ? undefined
- : {
- top: `${props.top ?? 0}px`,
- opacity: hidden() ? 0 : 1,
- "pointer-events": hidden() ? "none" : "auto",
- }
- }
- >
- <Show
- when={inlineBody()}
- fallback={
- <>
- <button
- type="button"
- aria-label={props.buttonLabel}
- data-slot="line-comment-button"
- on:mousedown={(e) => e.stopPropagation()}
- on:mouseup={(e) => e.stopPropagation()}
- on:click={props.onClick as any}
- on:mouseenter={props.onMouseEnter as any}
- >
- <Show
- when={props.inline}
- fallback={<Icon name={icon() === "plus" ? "plus-small" : "comment"} size="small" />}
- >
- <InlineGlyph icon={icon()} />
- </Show>
- </button>
- <Show when={props.open}>
- <div
- data-slot="line-comment-popover"
- classList={{
- [props.popoverClass ?? ""]: !!props.popoverClass,
- }}
- on:mousedown={(e) => e.stopPropagation()}
- on:focusout={props.onPopoverFocusOut as any}
- >
- {props.children}
- </div>
- </Show>
- </>
- }
- >
- <div
- data-slot="line-comment-popover"
- data-inline-body=""
- classList={{
- [props.popoverClass ?? ""]: !!props.popoverClass,
- }}
- on:mousedown={(e) => e.stopPropagation()}
- on:click={props.onClick as any}
- on:mouseenter={props.onMouseEnter as any}
- on:focusout={props.onPopoverFocusOut as any}
- >
- {props.children}
- </div>
- </Show>
- </div>
- )
- }
- export type LineCommentProps = Omit<LineCommentAnchorProps, "children" | "variant"> & {
- comment: JSX.Element
- selection: JSX.Element
- actions?: JSX.Element
- }
- export const LineComment = (props: LineCommentProps) => {
- const i18n = useI18n()
- const [split, rest] = splitProps(props, ["comment", "selection", "actions"])
- return (
- <LineCommentAnchor {...rest} variant="default" hideButton={props.inline}>
- <div data-slot="line-comment-content">
- <div data-slot="line-comment-head">
- <div data-slot="line-comment-text">{split.comment}</div>
- <Show when={split.actions}>
- <div data-slot="line-comment-tools">{split.actions}</div>
- </Show>
- </div>
- <div data-slot="line-comment-label">
- {i18n.t("ui.lineComment.label.prefix")}
- {split.selection}
- {i18n.t("ui.lineComment.label.suffix")}
- </div>
- </div>
- </LineCommentAnchor>
- )
- }
- export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
- label?: string
- }
- export const LineCommentAdd = (props: LineCommentAddProps) => {
- const [split, rest] = splitProps(props, ["label"])
- const i18n = useI18n()
- return (
- <LineCommentAnchor
- {...rest}
- open={false}
- variant="add"
- icon="plus"
- buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
- />
- )
- }
- export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
- value: string
- selection: JSX.Element
- onInput: (value: string) => void
- onCancel: VoidFunction
- onSubmit: (value: string) => void
- placeholder?: string
- rows?: number
- autofocus?: boolean
- cancelLabel?: string
- submitLabel?: string
- mention?: {
- items: (query: string) => string[] | Promise<string[]>
- }
- }
- export const LineCommentEditor = (props: LineCommentEditorProps) => {
- const i18n = useI18n()
- const [split, rest] = splitProps(props, [
- "value",
- "selection",
- "onInput",
- "onCancel",
- "onSubmit",
- "placeholder",
- "rows",
- "autofocus",
- "cancelLabel",
- "submitLabel",
- "mention",
- ])
- const refs = {
- textarea: undefined as HTMLTextAreaElement | undefined,
- }
- 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 = `${textarea.value.slice(0, query.start)}@${item.path} ${textarea.value.slice(query.end)}`
- const cursor = query.start + item.path.length + 2
- 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"],
- skipFilter: () => true,
- onSelect: selectMention,
- })
- const focus = () => refs.textarea?.focus()
- const hold: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
- e.preventDefault()
- e.stopPropagation()
- }
- const click =
- (fn: VoidFunction): JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> =>
- (e) => {
- e.stopPropagation()
- fn()
- }
- 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 = split.value.trim()
- if (!value) return
- split.onSubmit(value)
- }
- onMount(() => {
- if (split.autofocus === false) return
- requestAnimationFrame(focus)
- })
- return (
- <LineCommentAnchor {...rest} open={true} variant="editor" hideButton={props.inline} onClick={() => focus()}>
- <div data-slot="line-comment-editor">
- <textarea
- ref={(el) => {
- refs.textarea = el
- }}
- data-slot="line-comment-textarea"
- rows={split.rows ?? 3}
- placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
- value={split.value}
- on:input={(e) => {
- const value = (e.currentTarget as HTMLTextAreaElement).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()
- split.onCancel()
- return
- }
- if (e.key !== "Enter") return
- if (e.shiftKey) return
- event.preventDefault()
- 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")}
- {split.selection}
- {i18n.t("ui.lineComment.editorLabel.suffix")}
- </div>
- <Show
- when={!props.inline}
- fallback={
- <>
- <button
- type="button"
- data-slot="line-comment-action"
- data-variant="ghost"
- on:mousedown={hold as any}
- on:click={click(split.onCancel) as any}
- >
- {split.cancelLabel ?? i18n.t("ui.common.cancel")}
- </button>
- <button
- type="button"
- data-slot="line-comment-action"
- data-variant="primary"
- disabled={split.value.trim().length === 0}
- on:mousedown={hold as any}
- on:click={click(submit) as any}
- >
- {split.submitLabel ?? i18n.t("ui.lineComment.submit")}
- </button>
- </>
- }
- >
- <Button size="small" variant="ghost" onClick={split.onCancel}>
- {split.cancelLabel ?? i18n.t("ui.common.cancel")}
- </Button>
- <Button size="small" variant="primary" disabled={split.value.trim().length === 0} onClick={submit}>
- {split.submitLabel ?? i18n.t("ui.lineComment.submit")}
- </Button>
- </Show>
- </div>
- </div>
- </LineCommentAnchor>
- )
- }
|