feat(tui): add semantic file path truncation (#36352)
This commit is contained in:
parent
5945a8d429
commit
e3f7637eb2
8 changed files with 312 additions and 57 deletions
|
|
@ -1,9 +1,9 @@
|
|||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import type { VcsFileStatus } from "@opencode-ai/sdk/v2"
|
||||
import { createMemo, For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Locale } from "../util/locale"
|
||||
import { FilePath } from "../ui/file-path"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useTuiConfig } from "../config"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
|
|
@ -33,10 +33,13 @@ export function DialogWorkspaceFileChanges(props: {
|
|||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
|
||||
const height = createMemo(() => Math.min(props.files.length, 8))
|
||||
const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0))
|
||||
const fileNameWidth = createMemo(
|
||||
() => Math.max(2, Math.min(60, dimensions().width - 2) - 6 - Math.max(7, ...props.files.map(changeCountWidth))),
|
||||
)
|
||||
|
||||
function confirm() {
|
||||
props.onSelect(store.active)
|
||||
|
|
@ -93,9 +96,7 @@ export function DialogWorkspaceFileChanges(props: {
|
|||
<box width={2} flexShrink={0}>
|
||||
<text fg={theme.textMuted}>{statusLabel(item.status)}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{Locale.truncateLeft(item.file, fileNameWidth())}
|
||||
</text>
|
||||
<FilePath value={item.file} maxWidth={fileNameWidth()} fg={theme.textMuted} />
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
|
||||
<text>
|
||||
|
|
|
|||
|
|
@ -4,24 +4,45 @@ import { createMemo, Match, Show, Switch } from "solid-js"
|
|||
import { abbreviateHome } from "../../runtime"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useHomeSessionDestination } from "../../routes/home/session-destination"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
const id = "internal:home-footer"
|
||||
|
||||
function Directory(props: { api: TuiPluginApi }) {
|
||||
function Directory(props: { api: TuiPluginApi; maxWidth: number }) {
|
||||
const theme = () => props.api.theme.current
|
||||
const destination = useHomeSessionDestination()
|
||||
const paths = useTuiPaths()
|
||||
const dir = createMemo(() => {
|
||||
const selected = destination?.destination()
|
||||
if (!selected || selected.type === "new") return
|
||||
const out = abbreviateHome(selected.directory, paths.home)
|
||||
const branch =
|
||||
selected.directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined
|
||||
if (branch) return out + ":" + branch
|
||||
return out
|
||||
return { path: abbreviateHome(selected.directory, paths.home), branch }
|
||||
})
|
||||
|
||||
return <Show when={dir()}>{(value) => <text fg={theme().textMuted}>{value()}</text>}</Show>
|
||||
return (
|
||||
<Show when={dir()}>
|
||||
{(value) => {
|
||||
const suffix = () => (value().branch ? `:${value().branch}` : "")
|
||||
const suffixWidth = () => Math.min(Bun.stringWidth(suffix()), Math.max(0, props.maxWidth - 2))
|
||||
return (
|
||||
<box flexDirection="row" minWidth={0}>
|
||||
<FilePath
|
||||
value={value().path}
|
||||
maxWidth={Math.max(2, props.maxWidth - suffixWidth())}
|
||||
fg={theme().textMuted}
|
||||
/>
|
||||
<Show when={suffix()}>
|
||||
<text width={suffixWidth()} wrapMode="none" truncate fg={theme().textMuted}>
|
||||
{suffix()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function Mcp(props: { api: TuiPluginApi }) {
|
||||
|
|
@ -62,6 +83,16 @@ function Version(props: { api: TuiPluginApi }) {
|
|||
}
|
||||
|
||||
function View(props: { api: TuiPluginApi }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const mcpWidth = createMemo(() => {
|
||||
const list = props.api.state.mcp()
|
||||
if (list.length === 0) return 0
|
||||
const count = list.filter((item) => item.status === "connected").length
|
||||
return Bun.stringWidth(`⊙ ${count} MCP /status`) + 2
|
||||
})
|
||||
const directoryWidth = createMemo(() =>
|
||||
Math.max(2, dimensions().width - 8 - Bun.stringWidth(props.api.app.version) - mcpWidth()),
|
||||
)
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
|
|
@ -73,7 +104,7 @@ function View(props: { api: TuiPluginApi }) {
|
|||
flexShrink={0}
|
||||
gap={2}
|
||||
>
|
||||
<Directory api={props.api} />
|
||||
<Directory api={props.api} maxWidth={directoryWidth()} />
|
||||
<Mcp api={props.api} />
|
||||
<box flexGrow={1} />
|
||||
<Version api={props.api} />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
import { createMemo, For, Show, createSignal } from "solid-js"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
|
||||
const id = "internal:sidebar-files"
|
||||
|
||||
|
|
@ -31,9 +31,11 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
|
|||
<For each={list()}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||
<text fg={theme().textMuted} wrapMode="none">
|
||||
{Locale.truncateLeft(item.file, Math.max(2, 36 - changeCountWidth(item)))}
|
||||
</text>
|
||||
<FilePath
|
||||
value={item.file}
|
||||
maxWidth={Math.max(2, 36 - changeCountWidth(item))}
|
||||
fg={theme().textMuted}
|
||||
/>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<Show when={item.additions}>
|
||||
<text fg={theme().diffAdded}>+{item.additions}</text>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { BuiltinTuiPlugin } from "../builtins"
|
|||
import { createMemo, Show } from "solid-js"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
|
||||
const id = "internal:sidebar-footer"
|
||||
|
||||
|
|
@ -16,16 +17,12 @@ function View(props: { api: TuiPluginApi; directory: string }) {
|
|||
)
|
||||
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
|
||||
const show = createMemo(() => !has() && !done())
|
||||
const path = createMemo(() => {
|
||||
const out = abbreviateHome(props.directory, paths.home)
|
||||
const location = createMemo(() => {
|
||||
const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
|
||||
const text = branch ? out + ":" + branch : out
|
||||
const list = text.split("/")
|
||||
return {
|
||||
parent: list.slice(0, -1).join("/"),
|
||||
name: list.at(-1) ?? "",
|
||||
}
|
||||
return { path: abbreviateHome(props.directory, paths.home), branch }
|
||||
})
|
||||
const suffix = createMemo(() => (location().branch ? `:${location().branch}` : ""))
|
||||
const suffixWidth = createMemo(() => Math.min(Bun.stringWidth(suffix()), 36))
|
||||
|
||||
return (
|
||||
<box gap={1}>
|
||||
|
|
@ -62,10 +59,19 @@ function View(props: { api: TuiPluginApi; directory: string }) {
|
|||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
<text>
|
||||
<span style={{ fg: theme().textMuted }}>{path().parent}/</span>
|
||||
<span style={{ fg: theme().text }}>{path().name}</span>
|
||||
</text>
|
||||
<box flexDirection="row" minWidth={0}>
|
||||
<FilePath
|
||||
value={location().path}
|
||||
maxWidth={Math.max(2, 38 - suffixWidth())}
|
||||
fg={theme().textMuted}
|
||||
basenameFg={theme().text}
|
||||
/>
|
||||
<Show when={suffix()}>
|
||||
<text width={suffixWidth()} wrapMode="none" truncate fg={theme().textMuted}>
|
||||
{suffix()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={theme().textMuted}>
|
||||
<span style={{ fg: theme().success }}>•</span> <b>Open</b>
|
||||
<span style={{ fg: theme().text }}>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import type {
|
|||
} from "@opencode-ai/sdk/v2"
|
||||
import { useLocal } from "../../context/local"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { webSearchProviderLabel } from "../../util/tool-display"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
|
|
@ -1402,6 +1403,7 @@ function RevertMessage(props: {
|
|||
readonly deletions: number
|
||||
}>
|
||||
}) {
|
||||
const ctx = use()
|
||||
const { theme } = useTheme()
|
||||
const route = useRouteData("session")
|
||||
const sdk = useSDK()
|
||||
|
|
@ -1444,9 +1446,17 @@ function RevertMessage(props: {
|
|||
{(file) => (
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<text fg={theme.textMuted}>{statusLabel(file.status)}</text>
|
||||
<text fg={theme.text} wrapMode="none">
|
||||
{Locale.truncateLeft(file.file, 60)}
|
||||
</text>
|
||||
<FilePath
|
||||
value={file.file}
|
||||
maxWidth={Math.max(
|
||||
2,
|
||||
ctx.width -
|
||||
5 -
|
||||
(file.additions > 0 ? Bun.stringWidth(`+${file.additions}`) + 1 : 0) -
|
||||
(file.deletions > 0 ? Bun.stringWidth(`-${file.deletions}`) + 1 : 0),
|
||||
)}
|
||||
fg={theme.text}
|
||||
/>
|
||||
<Show when={file.additions > 0}>
|
||||
<text fg={theme.diffAdded}>+{file.additions}</text>
|
||||
</Show>
|
||||
|
|
@ -2138,6 +2148,7 @@ export function InlineToolRow(props: {
|
|||
|
||||
function BlockTool(props: {
|
||||
title?: string
|
||||
path?: { label: string; value: string }
|
||||
children?: JSX.Element
|
||||
onClick?: () => void
|
||||
part?: SessionMessageAssistantTool
|
||||
|
|
@ -2171,18 +2182,45 @@ function BlockTool(props: {
|
|||
props.onClick?.()
|
||||
}}
|
||||
>
|
||||
<Show when={props.title}>
|
||||
{(title) => (
|
||||
<Show
|
||||
when={props.spinner}
|
||||
fallback={
|
||||
<text paddingLeft={3} fg={permission() ? theme.warning : theme.textMuted}>
|
||||
{title()}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<Spinner color={permission() ? theme.warning : theme.textMuted}>{title().replace(/^# /, "")}</Spinner>
|
||||
<Show
|
||||
when={props.path}
|
||||
fallback={
|
||||
<Show when={props.title}>
|
||||
{(title) => (
|
||||
<Show
|
||||
when={props.spinner}
|
||||
fallback={
|
||||
<text fg={permission() ? theme.warning : theme.textMuted}>
|
||||
{title()}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<Spinner color={permission() ? theme.warning : theme.textMuted}>{title().replace(/^# /, "")}</Spinner>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(path) => (
|
||||
<box flexDirection="row" gap={1} minWidth={0}>
|
||||
<Show
|
||||
when={props.spinner}
|
||||
fallback={
|
||||
<text flexShrink={0} fg={permission() ? theme.warning : theme.textMuted}>
|
||||
{path().label}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<Spinner color={permission() ? theme.warning : theme.textMuted}>
|
||||
{path().label.replace(/^# /, "")}
|
||||
</Spinner>
|
||||
</Show>
|
||||
<FilePath
|
||||
value={path().value}
|
||||
maxWidth={Math.max(2, ctx.width - 4 - Bun.stringWidth(path().label) - (props.spinner ? 2 : 0))}
|
||||
fg={permission() ? theme.warning : theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
{props.children}
|
||||
|
|
@ -2277,7 +2315,10 @@ function Write(props: ToolProps) {
|
|||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.diagnostics !== undefined}>
|
||||
<BlockTool title={"# Wrote " + pathFormatter.format(stringValue(props.input.path))} part={props.part}>
|
||||
<BlockTool
|
||||
path={{ label: "# Wrote", value: pathFormatter.format(stringValue(props.input.path)) }}
|
||||
part={props.part}
|
||||
>
|
||||
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
|
||||
<code
|
||||
conceal={false}
|
||||
|
|
@ -2503,7 +2544,7 @@ function Edit(props: ToolProps) {
|
|||
<Switch>
|
||||
<Match when={file()}>
|
||||
{(item) => (
|
||||
<BlockTool title={"← Edit " + pathFormatter.format(path())} part={props.part}>
|
||||
<BlockTool path={{ label: "← Edit", value: pathFormatter.format(path()) }} part={props.part}>
|
||||
<box paddingLeft={1}>
|
||||
<diff
|
||||
diff={item().patch}
|
||||
|
|
@ -2531,11 +2572,12 @@ function Edit(props: ToolProps) {
|
|||
</Match>
|
||||
<Match when={true}>
|
||||
<BlockTool
|
||||
title={
|
||||
path={
|
||||
stringValue(props.input.path)
|
||||
? "← Edit " + pathFormatter.format(stringValue(props.input.path))
|
||||
: "# Preparing edit..."
|
||||
? { label: "← Edit", value: pathFormatter.format(stringValue(props.input.path)) }
|
||||
: undefined
|
||||
}
|
||||
title={stringValue(props.input.path) ? undefined : "# Preparing edit..."}
|
||||
part={props.part}
|
||||
spinner={props.part.state.status === "streaming"}
|
||||
/>
|
||||
|
|
@ -2576,7 +2618,10 @@ function ApplyPatch(props: ToolProps) {
|
|||
<For each={files()}>
|
||||
{(file) => (
|
||||
<BlockTool
|
||||
title={`${file.type === "add" ? "# Created" : file.type === "delete" ? "# Deleted" : "← Patched"} ${pathFormatter.format(file.relativePath)}`}
|
||||
path={{
|
||||
label: file.type === "add" ? "# Created" : file.type === "delete" ? "# Deleted" : "← Patched",
|
||||
value: pathFormatter.format(file.relativePath),
|
||||
}}
|
||||
part={props.part}
|
||||
>
|
||||
<box paddingLeft={1}>
|
||||
|
|
@ -2610,10 +2655,17 @@ function ApplyPatch(props: ToolProps) {
|
|||
<For each={applied()}>
|
||||
{(file) => (
|
||||
<BlockTool
|
||||
title={`${file.type === "add" ? "# Created" : file.type === "delete" ? "# Deleted" : "← Patched"} ${pathFormatter.format(file.resource)}`}
|
||||
path={{
|
||||
label: file.type === "add" ? "# Created" : file.type === "delete" ? "# Deleted" : "← Patched",
|
||||
value: pathFormatter.format(file.resource),
|
||||
}}
|
||||
part={props.part}
|
||||
>
|
||||
<text fg={file.type === "delete" ? theme.diffRemoved : theme.textMuted}>{file.resource}</text>
|
||||
<FilePath
|
||||
value={file.resource}
|
||||
maxWidth={Math.max(2, ctx.width - 3)}
|
||||
fg={file.type === "delete" ? theme.diffRemoved : theme.textMuted}
|
||||
/>
|
||||
</BlockTool>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -2621,15 +2673,23 @@ function ApplyPatch(props: ToolProps) {
|
|||
</Match>
|
||||
<Match when={true}>
|
||||
<BlockTool
|
||||
path={
|
||||
targets().length === 1
|
||||
? {
|
||||
label: props.part.state.status === "error" ? "# Patch failed" : "Patching",
|
||||
value: pathFormatter.format(targets()[0]),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={
|
||||
targets().length === 1
|
||||
? `${props.part.state.status === "error" ? "# Patch failed" : "# Preparing patch"} ${pathFormatter.format(targets()[0])}`
|
||||
? undefined
|
||||
: props.part.state.status === "error"
|
||||
? "# Patch failed"
|
||||
: "# Preparing patch..."
|
||||
: "Patching"
|
||||
}
|
||||
part={props.part}
|
||||
spinner={props.part.state.status === "streaming"}
|
||||
spinner={props.part.state.status === "streaming" || props.part.state.status === "running"}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useData } from "../../context/data"
|
|||
import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useTuiConfig } from "../../config"
|
||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { usePluginRuntime } from "../../plugin/runtime"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
|
|
@ -56,9 +56,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||
<text fg={theme.text}>
|
||||
<b>{session()!.title}</b>
|
||||
</text>
|
||||
<Show when={InstallationChannel !== "latest"}>
|
||||
<text fg={theme.textMuted}>{props.sessionID}</text>
|
||||
</Show>
|
||||
<Show when={session()!.location.workspaceID}>
|
||||
<text fg={theme.textMuted}>
|
||||
<Show
|
||||
|
|
|
|||
106
packages/tui/src/ui/file-path.tsx
Normal file
106
packages/tui/src/ui/file-path.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import type { RGBA } from "@opentui/core"
|
||||
import { createMemo } from "solid-js"
|
||||
|
||||
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
|
||||
export interface FilePathProps {
|
||||
value: string
|
||||
maxWidth: number
|
||||
fg?: RGBA
|
||||
basenameFg?: RGBA
|
||||
}
|
||||
|
||||
export function FilePath(props: FilePathProps) {
|
||||
const display = createMemo(() => {
|
||||
const value = truncateFilePath(props.value, props.maxWidth)
|
||||
const index = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"))
|
||||
return {
|
||||
parent: value.slice(0, index + 1),
|
||||
basename: value.slice(index + 1),
|
||||
}
|
||||
})
|
||||
return (
|
||||
<text fg={props.fg} wrapMode="none" truncate>
|
||||
<span style={{ fg: props.fg }}>{display().parent}</span>
|
||||
<span style={{ fg: props.basenameFg ?? props.fg }}>{display().basename}</span>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
export function truncateFilePath(value: string, maxWidth: number) {
|
||||
if (maxWidth <= 0) return ""
|
||||
if (Bun.stringWidth(value) <= maxWidth) return value
|
||||
|
||||
const drive = value.match(/^([A-Za-z]:)([\\/])/)
|
||||
const unc = value.match(/^(\\\\|\/\/)([^\\/]+)[\\/]([^\\/]+)(?:[\\/]|$)/)
|
||||
const windows = drive !== null || unc !== null || (!value.includes("/") && value.includes("\\"))
|
||||
const separator = drive?.[2] ?? (unc?.[1] === "//" ? "/" : windows ? "\\" : "/")
|
||||
const root = drive
|
||||
? drive[1] + separator
|
||||
: unc
|
||||
? unc[1] + unc[2] + separator + unc[3] + separator
|
||||
: value.startsWith("/")
|
||||
? "/"
|
||||
: ""
|
||||
const source = value.slice(drive?.[0].length ?? unc?.[0].length ?? root.length)
|
||||
const segments = source.split(windows ? /[\\/]/ : separator).filter(Boolean)
|
||||
const basename = segments.at(-1) ?? value
|
||||
if (segments.length < 2) {
|
||||
const rootWidth = Bun.stringWidth(root)
|
||||
if (rootWidth >= maxWidth) return takeStart(root, maxWidth)
|
||||
return root + truncateBasename(basename, maxWidth - rootWidth)
|
||||
}
|
||||
|
||||
const prefix = `${root}…${separator}`
|
||||
const basenameWidth = maxWidth - Bun.stringWidth(prefix)
|
||||
if (basenameWidth <= 0) return takeStart(prefix, maxWidth)
|
||||
const compact = truncateBasename(basename, basenameWidth)
|
||||
if (compact !== basename) return prefix + compact
|
||||
|
||||
const selected = [basename]
|
||||
const separatorWidth = Bun.stringWidth(separator)
|
||||
let width = Bun.stringWidth(prefix + basename)
|
||||
for (let index = segments.length - 2; index >= 0; index--) {
|
||||
const next = Bun.stringWidth(segments[index]!) + separatorWidth
|
||||
if (width + next > maxWidth) break
|
||||
selected.unshift(segments[index]!)
|
||||
width += next
|
||||
}
|
||||
return prefix + selected.join(separator)
|
||||
}
|
||||
|
||||
function truncateBasename(value: string, maxWidth: number) {
|
||||
if (Bun.stringWidth(value) <= maxWidth) return value
|
||||
if (maxWidth <= 1) return takeStart("…", maxWidth)
|
||||
|
||||
const dot = value.lastIndexOf(".")
|
||||
const extension = dot > 0 ? value.slice(dot) : ""
|
||||
const extensionWidth = Bun.stringWidth(extension)
|
||||
if (extensionWidth >= maxWidth) return "…" + takeEnd(extension, maxWidth - 1)
|
||||
|
||||
const stem = extension ? value.slice(0, dot) : value
|
||||
return takeStart(stem, maxWidth - extensionWidth - 1) + "…" + extension
|
||||
}
|
||||
|
||||
function takeStart(value: string, maxWidth: number) {
|
||||
return take(value, maxWidth, false)
|
||||
}
|
||||
|
||||
function takeEnd(value: string, maxWidth: number) {
|
||||
return take(value, maxWidth, true)
|
||||
}
|
||||
|
||||
function take(value: string, maxWidth: number, reverse: boolean) {
|
||||
const segments = Array.from(graphemeSegmenter.segment(value), (item) => item.segment)
|
||||
if (reverse) segments.reverse()
|
||||
const selected: string[] = []
|
||||
let width = 0
|
||||
for (const segment of segments) {
|
||||
const next = Bun.stringWidth(segment)
|
||||
if (width + next > maxWidth) break
|
||||
selected.push(segment)
|
||||
width += next
|
||||
}
|
||||
if (reverse) selected.reverse()
|
||||
return selected.join("")
|
||||
}
|
||||
52
packages/tui/test/ui/file-path.test.ts
Normal file
52
packages/tui/test/ui/file-path.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { truncateFilePath } from "../../src/ui/file-path"
|
||||
|
||||
describe("truncateFilePath", () => {
|
||||
const path = "packages/tui/src/ui/dialog-select.tsx"
|
||||
|
||||
test("keeps the full path when it fits", () => {
|
||||
expect(truncateFilePath(path, 37)).toBe(path)
|
||||
})
|
||||
|
||||
test("adds nearest parent segments from right to left", () => {
|
||||
expect(truncateFilePath(path, 26)).toBe("…/src/ui/dialog-select.tsx")
|
||||
expect(truncateFilePath(path, 22)).toBe("…/ui/dialog-select.tsx")
|
||||
expect(truncateFilePath(path, 19)).toBe("…/dialog-select.tsx")
|
||||
})
|
||||
|
||||
test("preserves the extension when the basename must shrink", () => {
|
||||
expect(truncateFilePath(path, 16)).toBe("…/dialog-se….tsx")
|
||||
expect(truncateFilePath("dialog-select.tsx", 12)).toBe("dialog-….tsx")
|
||||
})
|
||||
|
||||
test("preserves the input separator", () => {
|
||||
expect(truncateFilePath("packages\\tui\\src\\ui\\dialog-select.tsx", 22)).toBe("…\\ui\\dialog-select.tsx")
|
||||
})
|
||||
|
||||
test("does not treat a backslash in a POSIX filename as a separator", () => {
|
||||
expect(truncateFilePath("dir/file\\name.ts", 14)).toBe("…/file\\name.ts")
|
||||
})
|
||||
|
||||
test("preserves absolute roots", () => {
|
||||
expect(truncateFilePath("/file.ts", 7)).toBe("/fi….ts")
|
||||
expect(truncateFilePath("C:\\file.ts", 9)).toBe("C:\\fi….ts")
|
||||
expect(truncateFilePath("/usr/local/bin/file.ts", 14)).toBe("/…/bin/file.ts")
|
||||
expect(truncateFilePath("C:\\Users\\kit\\src\\file.ts", 16)).toBe("C:\\…\\src\\file.ts")
|
||||
expect(truncateFilePath("C:/Users/kit/src/file.ts", 16)).toBe("C:/…/src/file.ts")
|
||||
expect(truncateFilePath("C:\\Users\\kit/src/file.ts", 16)).toBe("C:\\…\\src\\file.ts")
|
||||
expect(truncateFilePath("\\\\server\\share\\src\\file.ts", 25)).toBe("\\\\server\\share\\…\\file.ts")
|
||||
})
|
||||
|
||||
test("measures terminal columns without splitting graphemes", () => {
|
||||
expect(truncateFilePath("packages/组件/对话框.tsx", 12)).toBe("…/对话框.tsx")
|
||||
expect(truncateFilePath("src/👩💻-notes.tsx", 12)).toContain("👩💻")
|
||||
expect(truncateFilePath("中a.txt", 6)).toBe("….txt")
|
||||
expect(truncateFilePath("file.中a", 3)).toBe("…a")
|
||||
})
|
||||
|
||||
test("never exceeds the requested width", () => {
|
||||
for (let width = 0; width <= Bun.stringWidth(path); width++) {
|
||||
expect(Bun.stringWidth(truncateFilePath(path, width))).toBeLessThanOrEqual(width)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue