test(vscode): extract fileName, dirName, buildHighlightSegments from PromptInput and add tests

This commit is contained in:
Igor Šćekić
2026-02-20 14:15:14 +01:00
parent 5808ab0042
commit 582566f3ec
3 changed files with 168 additions and 46 deletions
@@ -0,0 +1,120 @@
import { describe, it, expect } from "bun:test"
import { fileName, dirName, buildHighlightSegments } from "../../webview-ui/src/components/chat/prompt-input-utils"
describe("fileName", () => {
it("extracts the last segment of a unix path", () => {
expect(fileName("src/components/chat/PromptInput.tsx")).toBe("PromptInput.tsx")
})
it("extracts the last segment of a Windows path", () => {
expect(fileName("src\\components\\chat\\PromptInput.tsx")).toBe("PromptInput.tsx")
})
it("returns the path itself when no separator present", () => {
expect(fileName("README.md")).toBe("README.md")
})
it("returns the filename for a single directory segment", () => {
expect(fileName("src/foo.ts")).toBe("foo.ts")
})
it("handles mixed separators", () => {
expect(fileName("src\\components/chat/File.tsx")).toBe("File.tsx")
})
})
describe("dirName", () => {
it("returns empty string for a file with no directory", () => {
expect(dirName("README.md")).toBe("")
})
it("returns the directory for a simple path", () => {
expect(dirName("src/foo.ts")).toBe("src")
})
it("returns full directory for a short path", () => {
expect(dirName("src/components/foo.ts")).toBe("src/components")
})
it("truncates long directories to last two segments", () => {
const path = "packages/kilo-vscode/webview-ui/src/components/chat/foo.ts"
const result = dirName(path)
expect(result).toMatch(/^…\//)
expect(result).toContain("components/chat")
})
it("does not truncate directories at exactly 30 chars", () => {
const dir = "a".repeat(15) + "/" + "b".repeat(14)
const result = dirName(`${dir}/file.ts`)
expect(result).toBe(dir)
})
it("truncates directories longer than 30 chars", () => {
const dir = "a".repeat(16) + "/" + "b".repeat(15)
const result = dirName(`${dir}/file.ts`)
expect(result.startsWith("…/")).toBe(true)
})
it("normalizes Windows backslashes before measuring length", () => {
const result = dirName("src\\foo.ts")
expect(result).toBe("src")
})
})
describe("buildHighlightSegments", () => {
it("returns single non-highlighted segment when paths set is empty", () => {
const result = buildHighlightSegments("hello world", new Set())
expect(result).toEqual([{ text: "hello world", highlight: false }])
})
it("returns single non-highlighted segment when no mention present", () => {
const result = buildHighlightSegments("hello world", new Set(["foo.ts"]))
expect(result).toEqual([{ text: "hello world", highlight: false }])
})
it("highlights a single mention token", () => {
const result = buildHighlightSegments("@foo.ts", new Set(["foo.ts"]))
expect(result).toEqual([{ text: "@foo.ts", highlight: true }])
})
it("splits text before and highlight token", () => {
const result = buildHighlightSegments("see @foo.ts here", new Set(["foo.ts"]))
expect(result).toEqual([
{ text: "see ", highlight: false },
{ text: "@foo.ts", highlight: true },
{ text: " here", highlight: false },
])
})
it("highlights multiple mentions in order", () => {
const result = buildHighlightSegments("@a.ts and @b.ts done", new Set(["a.ts", "b.ts"]))
expect(result).toEqual([
{ text: "@a.ts", highlight: true },
{ text: " and ", highlight: false },
{ text: "@b.ts", highlight: true },
{ text: " done", highlight: false },
])
})
it("picks the earliest mention when multiple paths could match", () => {
const result = buildHighlightSegments("@b.ts then @a.ts", new Set(["a.ts", "b.ts"]))
expect(result[0]).toEqual({ text: "@b.ts", highlight: true })
expect(result[2]).toEqual({ text: "@a.ts", highlight: true })
})
it("handles back-to-back mentions with no separator", () => {
const result = buildHighlightSegments("@a.ts@b.ts", new Set(["a.ts", "b.ts"]))
const highlighted = result.filter((s) => s.highlight)
expect(highlighted).toHaveLength(2)
})
it("returns empty array for empty string", () => {
const result = buildHighlightSegments("", new Set(["foo.ts"]))
expect(result).toEqual([])
})
it("does not partially match longer paths", () => {
const result = buildHighlightSegments("@foo.ts", new Set(["foo.tsx"]))
expect(result).toEqual([{ text: "@foo.ts", highlight: false }])
})
})
@@ -15,6 +15,7 @@ import { ModelSelector } from "./ModelSelector"
import { ModeSwitcher } from "./ModeSwitcher"
import { useFileMention } from "../../hooks/useFileMention"
import { useImageAttachments } from "../../hooks/useImageAttachments"
import { fileName, dirName, buildHighlightSegments } from "./prompt-input-utils"
const AUTOCOMPLETE_DEBOUNCE_MS = 500
const MIN_TEXT_LENGTH = 3
@@ -150,43 +151,6 @@ export const PromptInput: Component = () => {
textareaRef.style.height = `${Math.min(textareaRef.scrollHeight, 200)}px`
}
const buildHighlightSegments = (val: string) => {
const paths = mention.mentionedPaths()
if (paths.size === 0) return [{ text: val, highlight: false }]
const segments: { text: string; highlight: boolean }[] = []
let remaining = val
while (remaining.length > 0) {
let earliest = -1
let earliestPath = ""
for (const path of paths) {
const token = `@${path}`
const idx = remaining.indexOf(token)
if (idx !== -1 && (earliest === -1 || idx < earliest)) {
earliest = idx
earliestPath = path
}
}
if (earliest === -1) {
segments.push({ text: remaining, highlight: false })
break
}
if (earliest > 0) {
segments.push({ text: remaining.substring(0, earliest), highlight: false })
}
const token = `@${earliestPath}`
segments.push({ text: token, highlight: true })
remaining = remaining.substring(earliest + token.length)
}
return segments
}
const handleInput = (e: InputEvent) => {
const target = e.target as HTMLTextAreaElement
const val = target.value
@@ -263,14 +227,6 @@ export const PromptInput: Component = () => {
if (textareaRef) textareaRef.style.height = "auto"
}
const fileName = (path: string) => path.replaceAll("\\", "/").split("/").pop() ?? path
const dirName = (path: string) => {
const parts = path.replaceAll("\\", "/").split("/")
if (parts.length <= 1) return ""
const dir = parts.slice(0, -1).join("/")
return dir.length > 30 ? `…/${parts.slice(-3, -1).join("/")}` : dir
}
return (
<div
class="prompt-input-container"
@@ -327,7 +283,7 @@ export const PromptInput: Component = () => {
<div class="prompt-input-wrapper">
<div class="prompt-input-ghost-wrapper">
<div class="prompt-input-highlight-overlay" ref={highlightRef} aria-hidden="true">
<Index each={buildHighlightSegments(text())}>
<Index each={buildHighlightSegments(text(), mention.mentionedPaths())}>
{(seg) => (
<Show when={seg().highlight} fallback={<span>{seg().text}</span>}>
<span class="prompt-input-file-mention">{seg().text}</span>
@@ -0,0 +1,46 @@
export function fileName(path: string): string {
return path.replaceAll("\\", "/").split("/").pop() ?? path
}
export function dirName(path: string): string {
const parts = path.replaceAll("\\", "/").split("/")
if (parts.length <= 1) return ""
const dir = parts.slice(0, -1).join("/")
return dir.length > 30 ? `…/${parts.slice(-3, -1).join("/")}` : dir
}
export function buildHighlightSegments(val: string, paths: Set<string>): { text: string; highlight: boolean }[] {
if (paths.size === 0) return [{ text: val, highlight: false }]
const segments: { text: string; highlight: boolean }[] = []
let remaining = val
while (remaining.length > 0) {
let earliest = -1
let earliestPath = ""
for (const path of paths) {
const token = `@${path}`
const idx = remaining.indexOf(token)
if (idx !== -1 && (earliest === -1 || idx < earliest)) {
earliest = idx
earliestPath = path
}
}
if (earliest === -1) {
segments.push({ text: remaining, highlight: false })
break
}
if (earliest > 0) {
segments.push({ text: remaining.substring(0, earliest), highlight: false })
}
const token = `@${earliestPath}`
segments.push({ text: token, highlight: true })
remaining = remaining.substring(earliest + token.length)
}
return segments
}