Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -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'"
}
35 changes: 35 additions & 0 deletions packages/ui/src/v2/components/keyboardHandlers.ts
Original file line number Diff line number Diff line change
@@ -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
}
126 changes: 16 additions & 110 deletions packages/ui/src/v2/components/line-comment-v2.tsx
Original file line number Diff line number Diff line change
@@ -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). */
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand All @@ -194,6 +127,8 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) {
requestAnimationFrame(() => textareaRef?.focus())
})



return (
<div
{...rest}
Expand Down Expand Up @@ -223,41 +158,12 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) {
onSelect={() => 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,
})
}}
/>
<Show when={mentionOpen() && mention.flat().length > 0}>
Expand Down
44 changes: 44 additions & 0 deletions packages/ui/src/v2/components/mention-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
33 changes: 33 additions & 0 deletions packages/ui/src/v2/components/mention-utils.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
90 changes: 90 additions & 0 deletions packages/ui/src/v2/components/useMentionHandler.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
Loading