diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 00000000..54fcf3cb
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,16 @@
+{
+ "name": "Node.js with Bun",
+ "image": "mcr.microsoft.com/devcontainers/javascript-node:1-18-bullseye",
+ "customizations": {
+ "vscode": {
+ "extensions": ["streetsidesoftware.code-spell-checker"]
+ }
+ },
+ "portsAttributes": {
+ "3000": {
+ "label": "Application",
+ "onAutoForward": "notify"
+ }
+ },
+ "postCreateCommand": "/bin/bash -c 'curl -fsSL https://bun.sh/install | bash && export PATH=\"$HOME/.bun/bin:$PATH\" && bun install'"
+}
\ No newline at end of file
diff --git a/packages/ui/src/v2/components/keyboardHandlers.ts b/packages/ui/src/v2/components/keyboardHandlers.ts
new file mode 100644
index 00000000..3bd54deb
--- /dev/null
+++ b/packages/ui/src/v2/components/keyboardHandlers.ts
@@ -0,0 +1,35 @@
+export function handleMentionKeyDown(
+ e: KeyboardEvent,
+ mentionOpen: boolean,
+ mention: any,
+ { closeMention, selectActiveMention, submit, onCancel }: any
+ ) {
+ if (e.isComposing || e.keyCode === 229) return
+
+ if (mentionOpen) {
+ if (e.key === "Escape") {
+ e.preventDefault()
+ closeMention()
+ return true
+ }
+ if (e.key === "Tab" && mention.flat().length > 0) {
+ e.preventDefault()
+ selectActiveMention()
+ return true
+ }
+ }
+
+ if (e.key === "Escape") {
+ e.preventDefault()
+ onCancel()
+ return true
+ }
+
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault()
+ submit()
+ return true
+ }
+
+ return false
+ }
\ No newline at end of file
diff --git a/packages/ui/src/v2/components/line-comment-v2.tsx b/packages/ui/src/v2/components/line-comment-v2.tsx
index 056b3ddb..7c309580 100644
--- a/packages/ui/src/v2/components/line-comment-v2.tsx
+++ b/packages/ui/src/v2/components/line-comment-v2.tsx
@@ -1,8 +1,9 @@
-import { For, Show, createSignal, onMount, splitProps, type ComponentProps, type JSX } from "solid-js"
+import { For, Show, onMount, splitProps, type ComponentProps, type JSX } from "solid-js"
import { FileIcon } from "../../components/file-icon"
import { useI18n } from "../../context/i18n"
-import { useFilteredList } from "../../hooks"
import { ButtonV2 } from "./button-v2"
+import { useMentionHandler } from "../components/useMentionHandler"
+import { handleMentionKeyDown } from "../components/keyboardHandlers"
import "./line-comment-v2.css"
/** Horizontal “more” glyph for the display-card overflow control (Figma outline-dots). */
@@ -89,7 +90,6 @@ function pathDirectory(path: string) {
export function LineCommentEditorV2(props: LineCommentEditorV2Props) {
const i18n = useI18n()
let textareaRef: HTMLTextAreaElement | undefined
- const [mentionOpen, setMentionOpen] = createSignal(false)
const [local, rest] = splitProps(props, [
"heading",
@@ -108,81 +108,14 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) {
"classList",
])
+ const { mentionOpen, closeMention, selectMention, mention, syncMention, selectActiveMention } = useMentionHandler(
+ () => textareaRef,
+ local,
+ )
+
const heading = () => local.heading ?? i18n.t("ui.lineComment.submit")
const canSubmit = () => local.value.trim().length > 0
- const closeMention = () => {
- setMentionOpen(false)
- mention.clear()
- }
-
- const currentMention = () => {
- const textarea = textareaRef
- if (!textarea) return
- if (!local.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,
- }
- }
-
- function selectMention(item: { path: string } | undefined) {
- if (!item) return
-
- const textarea = textareaRef
- 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
-
- local.onInput(value)
- closeMention()
-
- requestAnimationFrame(() => {
- textarea.focus()
- textarea.setSelectionRange(cursor, cursor)
- })
- }
-
- const mention = useFilteredList<{ path: string }>({
- items: async (query) => {
- if (!local.mention) return []
- if (!query.trim()) return []
- const paths = await local.mention.items(query)
- return paths.map((path) => ({ path }))
- },
- key: (item) => item.path,
- filterKeys: ["path"],
- skipFilter: () => true,
- onSelect: selectMention,
- })
-
- const syncMention = () => {
- const item = currentMention()
- if (!item) {
- closeMention()
- return
- }
-
- setMentionOpen(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 v = local.value.trim()
if (!v) return
@@ -194,6 +127,8 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) {
requestAnimationFrame(() => textareaRef?.focus())
})
+
+
return (
syncMention()}
onKeyDown={(e) => {
e.stopPropagation()
- if (e.isComposing || e.keyCode === 229) return
-
- if (mentionOpen()) {
- if (e.key === "Escape") {
- e.preventDefault()
- closeMention()
- return
- }
-
- if (e.key === "Tab") {
- if (mention.flat().length === 0) return
- e.preventDefault()
- selectActiveMention()
- return
- }
-
- const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter"
- const ctrlNav = e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p")
- if ((nav || ctrlNav) && mention.flat().length > 0) {
- mention.onKeyDown(e)
- e.preventDefault()
- return
- }
- }
-
- if (e.key === "Escape") {
- e.preventDefault()
- e.currentTarget.blur()
- local.onCancel()
- return
- }
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault()
- submit()
- }
+ handleMentionKeyDown(e, mentionOpen(), mention, {
+ closeMention,
+ selectActiveMention,
+ submit,
+ onCancel: local.onCancel,
+ })
}}
/>
0}>
diff --git a/packages/ui/src/v2/components/mention-utils.test.ts b/packages/ui/src/v2/components/mention-utils.test.ts
new file mode 100644
index 00000000..19f64e33
--- /dev/null
+++ b/packages/ui/src/v2/components/mention-utils.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, test } from "bun:test"
+import { extractMentionQuery, insertMention } from "./mention-utils"
+
+describe("extractMentionQuery", () => {
+ test("extracts mention query when @ is present", () => {
+ const result = extractMentionQuery("Hello @use", 10)
+ expect(result).toEqual({ query: "use", start: 6, end: 10 })
+ })
+
+ test("extracts empty query for just @", () => {
+ const result = extractMentionQuery("Hello @", 7)
+ expect(result).toEqual({ query: "", start: 6, end: 7 })
+ })
+
+ test("returns undefined when no @ is present", () => {
+ const result = extractMentionQuery("Hello world", 11)
+ expect(result).toBeUndefined()
+ })
+
+ test("returns undefined when @ is not at end of word", () => {
+ const result = extractMentionQuery("Hello @user more", 12)
+ expect(result).toBeUndefined()
+ })
+})
+
+describe("insertMention", () => {
+ test("inserts mention and returns new value and cursor position", () => {
+ const result = insertMention("Hello ", "user.tsx", 6, 6)
+ expect(result.value).toBe("Hello @user.tsx ")
+ expect(result.cursorPosition).toBe(16)
+ })
+
+ test("replaces partial mention with full mention", () => {
+ const result = insertMention("Hello @us", "user.tsx", 6, 9)
+ expect(result.value).toBe("Hello @user.tsx ")
+ expect(result.cursorPosition).toBe(16)
+ })
+
+ test("handles mention in middle of text", () => {
+ const result = insertMention("Hello @user and more", "component.tsx", 6, 11)
+ expect(result.value).toBe("Hello @component.tsx and more")
+ expect(result.cursorPosition).toBe(21)
+ })
+})
diff --git a/packages/ui/src/v2/components/mention-utils.ts b/packages/ui/src/v2/components/mention-utils.ts
new file mode 100644
index 00000000..f4314686
--- /dev/null
+++ b/packages/ui/src/v2/components/mention-utils.ts
@@ -0,0 +1,33 @@
+/**
+ * Extracts mention query from textarea value at cursor position
+ */
+export function extractMentionQuery(
+ value: string,
+ cursorPosition: number,
+): { query: string; start: number; end: number } | undefined {
+ const beforeCursor = value.slice(0, cursorPosition)
+ const match = beforeCursor.match(/@(\S*)$/)
+
+ if (!match) return undefined
+
+ return {
+ query: match[1] ?? "",
+ start: cursorPosition - match[0].length,
+ end: cursorPosition,
+ }
+}
+
+/**
+ * Inserts selected mention into text
+ */
+export function insertMention(
+ value: string,
+ mentionPath: string,
+ start: number,
+ end: number,
+): { value: string; cursorPosition: number } {
+ const newValue = `${value.slice(0, start)}@${mentionPath} ${value.slice(end)}`
+ const cursorPosition = start + mentionPath.length + 2
+
+ return { value: newValue, cursorPosition }
+}
diff --git a/packages/ui/src/v2/components/useMentionHandler.ts b/packages/ui/src/v2/components/useMentionHandler.ts
new file mode 100644
index 00000000..463202c0
--- /dev/null
+++ b/packages/ui/src/v2/components/useMentionHandler.ts
@@ -0,0 +1,90 @@
+import { createSignal } from "solid-js"
+import { useFilteredList } from "../../hooks"
+
+export function useMentionHandler(
+ getTextareaRef: () => HTMLTextAreaElement | undefined,
+ local: any,
+) {
+ const [mentionOpen, setMentionOpen] = createSignal(false)
+
+ const closeMention = () => {
+ setMentionOpen(false)
+ mention.clear()
+ }
+
+ const currentMention = () => {
+ const textarea = getTextareaRef()
+ if (!textarea) return
+ if (!local.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 selectMention = (item: { path: string } | undefined) => {
+ if (!item) return
+
+ const textarea = getTextareaRef()
+ 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
+
+ local.onInput(value)
+ closeMention()
+
+ requestAnimationFrame(() => {
+ textarea.focus()
+ textarea.setSelectionRange(cursor, cursor)
+ })
+ }
+
+ const mention = useFilteredList<{ path: string }>({
+ items: async (query: string) => {
+ if (!local.mention) return []
+ if (!query.trim()) return []
+ const paths = await local.mention.items(query)
+ return paths.map((path: string) => ({ path }))
+ },
+ key: (item: {path: string}) => item.path,
+ filterKeys: ["path"],
+ skipFilter: () => true,
+ onSelect: selectMention,
+ })
+
+ const syncMention = () => {
+ const item = currentMention()
+ if (!item) {
+ closeMention()
+ return
+ }
+
+ setMentionOpen(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])
+ }
+
+ return {
+ mentionOpen,
+ closeMention,
+ selectMention,
+ mention,
+ syncMention,
+ selectActiveMention,
+ }
+}
\ No newline at end of file