line-comment.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. import { useFilteredList } from "@kirincode-ai/ui/hooks"
  2. import { getDirectory, getFilename } from "@kirincode-ai/core/util/path"
  3. import { createSignal, For, onMount, Show, splitProps, type JSX } from "solid-js"
  4. import { Button } from "@kirincode-ai/ui/button"
  5. import { FileIcon } from "@kirincode-ai/ui/file-icon"
  6. import { Icon } from "@kirincode-ai/ui/icon"
  7. import { installLineCommentStyles } from "./line-comment-styles"
  8. import { useI18n } from "@kirincode-ai/ui/context/i18n"
  9. installLineCommentStyles()
  10. export type LineCommentVariant = "default" | "editor" | "add"
  11. function InlineGlyph(props: { icon: "comment" | "plus" }) {
  12. return (
  13. <svg data-slot="line-comment-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
  14. <Show
  15. when={props.icon === "comment"}
  16. fallback={
  17. <path
  18. d="M10 5.41699V10.0003M10 10.0003V14.5837M10 10.0003H5.4165M10 10.0003H14.5832"
  19. stroke="currentColor"
  20. stroke-linecap="square"
  21. />
  22. }
  23. >
  24. <path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square" />
  25. </Show>
  26. </svg>
  27. )
  28. }
  29. export type LineCommentAnchorProps = {
  30. id?: string
  31. top?: number
  32. inline?: boolean
  33. hideButton?: boolean
  34. open: boolean
  35. variant?: LineCommentVariant
  36. icon?: "comment" | "plus"
  37. buttonLabel?: string
  38. onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
  39. onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
  40. onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
  41. class?: string
  42. popoverClass?: string
  43. children?: JSX.Element
  44. }
  45. export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
  46. const hidden = () => !props.inline && props.top === undefined
  47. const variant = () => props.variant ?? "default"
  48. const icon = () => props.icon ?? "comment"
  49. const inlineBody = () => props.inline && props.hideButton
  50. return (
  51. <div
  52. data-component="line-comment"
  53. data-prevent-autofocus=""
  54. data-variant={variant()}
  55. data-comment-id={props.id}
  56. data-open={props.open ? "" : undefined}
  57. data-inline={props.inline ? "" : undefined}
  58. classList={{
  59. [props.class ?? ""]: !!props.class,
  60. }}
  61. style={
  62. props.inline
  63. ? undefined
  64. : {
  65. top: `${props.top ?? 0}px`,
  66. opacity: hidden() ? 0 : 1,
  67. "pointer-events": hidden() ? "none" : "auto",
  68. }
  69. }
  70. >
  71. <Show
  72. when={inlineBody()}
  73. fallback={
  74. <>
  75. <button
  76. type="button"
  77. aria-label={props.buttonLabel}
  78. data-slot="line-comment-button"
  79. on:mousedown={(e) => e.stopPropagation()}
  80. on:mouseup={(e) => e.stopPropagation()}
  81. on:click={props.onClick as any}
  82. on:mouseenter={props.onMouseEnter as any}
  83. >
  84. <Show
  85. when={props.inline}
  86. fallback={<Icon name={icon() === "plus" ? "plus-small" : "comment"} size="small" />}
  87. >
  88. <InlineGlyph icon={icon()} />
  89. </Show>
  90. </button>
  91. <Show when={props.open}>
  92. <div
  93. data-slot="line-comment-popover"
  94. classList={{
  95. [props.popoverClass ?? ""]: !!props.popoverClass,
  96. }}
  97. on:mousedown={(e) => e.stopPropagation()}
  98. on:focusout={props.onPopoverFocusOut as any}
  99. >
  100. {props.children}
  101. </div>
  102. </Show>
  103. </>
  104. }
  105. >
  106. <div
  107. data-slot="line-comment-popover"
  108. data-inline-body=""
  109. classList={{
  110. [props.popoverClass ?? ""]: !!props.popoverClass,
  111. }}
  112. on:mousedown={(e) => e.stopPropagation()}
  113. on:click={props.onClick as any}
  114. on:mouseenter={props.onMouseEnter as any}
  115. on:focusout={props.onPopoverFocusOut as any}
  116. >
  117. {props.children}
  118. </div>
  119. </Show>
  120. </div>
  121. )
  122. }
  123. export type LineCommentProps = Omit<LineCommentAnchorProps, "children" | "variant"> & {
  124. comment: JSX.Element
  125. selection: JSX.Element
  126. actions?: JSX.Element
  127. }
  128. export const LineComment = (props: LineCommentProps) => {
  129. const i18n = useI18n()
  130. const [split, rest] = splitProps(props, ["comment", "selection", "actions"])
  131. return (
  132. <LineCommentAnchor {...rest} variant="default" hideButton={props.inline}>
  133. <div data-slot="line-comment-content">
  134. <div data-slot="line-comment-head">
  135. <div data-slot="line-comment-text">{split.comment}</div>
  136. <Show when={split.actions}>
  137. <div data-slot="line-comment-tools">{split.actions}</div>
  138. </Show>
  139. </div>
  140. <div data-slot="line-comment-label">
  141. {i18n.t("ui.lineComment.label.prefix")}
  142. {split.selection}
  143. {i18n.t("ui.lineComment.label.suffix")}
  144. </div>
  145. </div>
  146. </LineCommentAnchor>
  147. )
  148. }
  149. export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
  150. label?: string
  151. }
  152. export const LineCommentAdd = (props: LineCommentAddProps) => {
  153. const [split, rest] = splitProps(props, ["label"])
  154. const i18n = useI18n()
  155. return (
  156. <LineCommentAnchor
  157. {...rest}
  158. open={false}
  159. variant="add"
  160. icon="plus"
  161. buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
  162. />
  163. )
  164. }
  165. export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
  166. value: string
  167. selection: JSX.Element
  168. onInput: (value: string) => void
  169. onCancel: VoidFunction
  170. onSubmit: (value: string) => void
  171. placeholder?: string
  172. rows?: number
  173. autofocus?: boolean
  174. cancelLabel?: string
  175. submitLabel?: string
  176. mention?: {
  177. items: (query: string) => string[] | Promise<string[]>
  178. }
  179. }
  180. export const LineCommentEditor = (props: LineCommentEditorProps) => {
  181. const i18n = useI18n()
  182. const [split, rest] = splitProps(props, [
  183. "value",
  184. "selection",
  185. "onInput",
  186. "onCancel",
  187. "onSubmit",
  188. "placeholder",
  189. "rows",
  190. "autofocus",
  191. "cancelLabel",
  192. "submitLabel",
  193. "mention",
  194. ])
  195. const refs = {
  196. textarea: undefined as HTMLTextAreaElement | undefined,
  197. }
  198. const [open, setOpen] = createSignal(false)
  199. function selectMention(item: { path: string } | undefined) {
  200. if (!item) return
  201. const textarea = refs.textarea
  202. const query = currentMention()
  203. if (!textarea || !query) return
  204. const value = `${textarea.value.slice(0, query.start)}@${item.path} ${textarea.value.slice(query.end)}`
  205. const cursor = query.start + item.path.length + 2
  206. split.onInput(value)
  207. closeMention()
  208. requestAnimationFrame(() => {
  209. textarea.focus()
  210. textarea.setSelectionRange(cursor, cursor)
  211. })
  212. }
  213. const mention = useFilteredList<{ path: string }>({
  214. items: async (query) => {
  215. if (!split.mention) return []
  216. if (!query.trim()) return []
  217. const paths = await split.mention.items(query)
  218. return paths.map((path) => ({ path }))
  219. },
  220. key: (item) => item.path,
  221. filterKeys: ["path"],
  222. skipFilter: () => true,
  223. onSelect: selectMention,
  224. })
  225. const focus = () => refs.textarea?.focus()
  226. const hold: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
  227. e.preventDefault()
  228. e.stopPropagation()
  229. }
  230. const click =
  231. (fn: VoidFunction): JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> =>
  232. (e) => {
  233. e.stopPropagation()
  234. fn()
  235. }
  236. const closeMention = () => {
  237. setOpen(false)
  238. mention.clear()
  239. }
  240. const currentMention = () => {
  241. const textarea = refs.textarea
  242. if (!textarea) return
  243. if (!split.mention) return
  244. if (textarea.selectionStart !== textarea.selectionEnd) return
  245. const end = textarea.selectionStart
  246. const match = textarea.value.slice(0, end).match(/@(\S*)$/)
  247. if (!match) return
  248. return {
  249. query: match[1] ?? "",
  250. start: end - match[0].length,
  251. end,
  252. }
  253. }
  254. const syncMention = () => {
  255. const item = currentMention()
  256. if (!item) {
  257. closeMention()
  258. return
  259. }
  260. setOpen(true)
  261. mention.onInput(item.query)
  262. }
  263. const selectActiveMention = () => {
  264. const items = mention.flat()
  265. if (items.length === 0) return
  266. const active = mention.active()
  267. selectMention(items.find((item) => item.path === active) ?? items[0])
  268. }
  269. const submit = () => {
  270. const value = split.value.trim()
  271. if (!value) return
  272. split.onSubmit(value)
  273. }
  274. onMount(() => {
  275. if (split.autofocus === false) return
  276. requestAnimationFrame(focus)
  277. })
  278. return (
  279. <LineCommentAnchor {...rest} open={true} variant="editor" hideButton={props.inline} onClick={() => focus()}>
  280. <div data-slot="line-comment-editor">
  281. <textarea
  282. ref={(el) => {
  283. refs.textarea = el
  284. }}
  285. data-slot="line-comment-textarea"
  286. rows={split.rows ?? 3}
  287. placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
  288. value={split.value}
  289. on:input={(e) => {
  290. const value = (e.currentTarget as HTMLTextAreaElement).value
  291. split.onInput(value)
  292. syncMention()
  293. }}
  294. on:click={() => syncMention()}
  295. on:select={() => syncMention()}
  296. on:keydown={(e) => {
  297. const event = e as KeyboardEvent
  298. if (event.isComposing || event.keyCode === 229) return
  299. event.stopPropagation()
  300. if (open()) {
  301. if (e.key === "Escape") {
  302. event.preventDefault()
  303. closeMention()
  304. return
  305. }
  306. if (e.key === "Tab") {
  307. if (mention.flat().length === 0) return
  308. event.preventDefault()
  309. selectActiveMention()
  310. return
  311. }
  312. const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter"
  313. const ctrlNav =
  314. event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && (e.key === "n" || e.key === "p")
  315. if ((nav || ctrlNav) && mention.flat().length > 0) {
  316. mention.onKeyDown(event)
  317. event.preventDefault()
  318. return
  319. }
  320. }
  321. if (e.key === "Escape") {
  322. event.preventDefault()
  323. e.currentTarget.blur()
  324. split.onCancel()
  325. return
  326. }
  327. if (e.key !== "Enter") return
  328. if (e.shiftKey) return
  329. event.preventDefault()
  330. submit()
  331. }}
  332. />
  333. <Show when={open() && mention.flat().length > 0}>
  334. <div data-slot="line-comment-mention-list">
  335. <For each={mention.flat().slice(0, 10)}>
  336. {(item) => {
  337. const directory = item.path.endsWith("/") ? item.path : getDirectory(item.path)
  338. const name = item.path.endsWith("/") ? "" : getFilename(item.path)
  339. return (
  340. <button
  341. type="button"
  342. data-slot="line-comment-mention-item"
  343. data-active={mention.active() === item.path ? "" : undefined}
  344. onMouseDown={(event) => event.preventDefault()}
  345. onMouseEnter={() => mention.setActive(item.path)}
  346. onClick={() => selectMention(item)}
  347. >
  348. <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
  349. <div data-slot="line-comment-mention-path">
  350. <span data-slot="line-comment-mention-dir">{directory}</span>
  351. <Show when={name}>
  352. <span data-slot="line-comment-mention-file">{name}</span>
  353. </Show>
  354. </div>
  355. </button>
  356. )
  357. }}
  358. </For>
  359. </div>
  360. </Show>
  361. <div data-slot="line-comment-actions">
  362. <div data-slot="line-comment-editor-label">
  363. {i18n.t("ui.lineComment.editorLabel.prefix")}
  364. {split.selection}
  365. {i18n.t("ui.lineComment.editorLabel.suffix")}
  366. </div>
  367. <Show
  368. when={!props.inline}
  369. fallback={
  370. <>
  371. <button
  372. type="button"
  373. data-slot="line-comment-action"
  374. data-variant="ghost"
  375. on:mousedown={hold as any}
  376. on:click={click(split.onCancel) as any}
  377. >
  378. {split.cancelLabel ?? i18n.t("ui.common.cancel")}
  379. </button>
  380. <button
  381. type="button"
  382. data-slot="line-comment-action"
  383. data-variant="primary"
  384. disabled={split.value.trim().length === 0}
  385. on:mousedown={hold as any}
  386. on:click={click(submit) as any}
  387. >
  388. {split.submitLabel ?? i18n.t("ui.lineComment.submit")}
  389. </button>
  390. </>
  391. }
  392. >
  393. <Button size="small" variant="ghost" onClick={split.onCancel}>
  394. {split.cancelLabel ?? i18n.t("ui.common.cancel")}
  395. </Button>
  396. <Button size="small" variant="primary" disabled={split.value.trim().length === 0} onClick={submit}>
  397. {split.submitLabel ?? i18n.t("ui.lineComment.submit")}
  398. </Button>
  399. </Show>
  400. </div>
  401. </div>
  402. </LineCommentAnchor>
  403. )
  404. }