diff --git a/src/editor/api.ts b/src/editor/api.ts index 9804e1361..5206a786b 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -30,6 +30,7 @@ import { } from "@codemirror/state"; import { GutterMarker, ViewPlugin, type EditorView } from "@codemirror/view"; import type { LanguageDescription } from "@codemirror/language"; +import type { LineEnding } from "@/platform/ipc"; import { create } from "zustand"; import { maybeEditorService } from "@/workbench/contributions"; @@ -636,6 +637,8 @@ export interface DocumentAttachment { doc: number; /** Current document text; build the view's state from it. */ text(): string; + /** The document's line ending (detected on load, as the status bar shows). */ + lineEnding(): LineEnding; /** The document's language mode name (backend-assigned, as the editor tabs use). */ uiMode(): string | null; readOnly: boolean; diff --git a/src/editor/core/model/attachment.ts b/src/editor/core/model/attachment.ts index 0720754cc..65f496e32 100644 --- a/src/editor/core/model/attachment.ts +++ b/src/editor/core/model/attachment.ts @@ -126,6 +126,7 @@ export async function attachDocument( path, doc: model.doc, text: () => model.sync.localText.toString(), + lineEnding: () => model.getMeta().lineEnding, uiMode: () => model.getMeta().uiMode, get readOnly() { return model.getMeta().readOnly || model.virtual; diff --git a/src/features/todo/PreviewEditor.test.tsx b/src/features/todo/PreviewEditor.test.tsx index 4a8c8f378..0e176044d 100644 --- a/src/features/todo/PreviewEditor.test.tsx +++ b/src/features/todo/PreviewEditor.test.tsx @@ -16,6 +16,7 @@ function stubAttacher(docs: Map docs.get(path)!.text, + lineEnding: () => "lf", dirty: () => !!docs.get(path)!.dirty, uiMode: () => null, readOnly: !!docs.get(path)!.readOnly, diff --git a/src/features/vcs/BranchSync.module.css b/src/features/vcs/BranchSync.module.css new file mode 100644 index 000000000..81d876266 --- /dev/null +++ b/src/features/vcs/BranchSync.module.css @@ -0,0 +1,25 @@ +.sync { + flex: none; + display: inline-flex; + align-items: center; + gap: 3px; + font-family: var(--font-mono); + font-size: var(--fs-small); + line-height: 1; + vertical-align: middle; +} + +.item { + flex: none; + display: inline-flex; + align-items: center; + gap: 1px; +} + +.incoming { + color: var(--accent); +} + +.outgoing { + color: var(--success); +} diff --git a/src/features/vcs/BranchSync.tsx b/src/features/vcs/BranchSync.tsx new file mode 100644 index 000000000..62c011bcc --- /dev/null +++ b/src/features/vcs/BranchSync.tsx @@ -0,0 +1,41 @@ +import clsx from "clsx"; +import { Icon } from "@/ui"; +import { tx } from "@/platform/i18n"; +import styles from "./BranchSync.module.css"; + +interface BranchSyncProps { + ahead: number; + behind: number; + /** Include the commit counts; the title-bar widget passes false and keeps them in the tooltip. */ + showCounts?: boolean; + className?: string; +} + +/** Incoming / outgoing branch markers shared by the branch widget, popup and Git Log tree. */ +export function BranchSync({ ahead, behind, showCounts = true, className }: BranchSyncProps) { + if (ahead <= 0 && behind <= 0) return null; + return ( + + {behind > 0 && } + {ahead > 0 && } + + ); +} + +function BranchSyncItem({ + count, + direction, + showCount, +}: { + count: number; + direction: "incoming" | "outgoing"; + showCount: boolean; +}) { + const label = direction === "incoming" ? tx`${count} incoming commits` : tx`${count} outgoing commits`; + return ( + + + {showCount && {count}} + + ); +} diff --git a/src/features/vcs/BranchWidget.module.css b/src/features/vcs/BranchWidget.module.css index a274be507..2e89df2d1 100644 --- a/src/features/vcs/BranchWidget.module.css +++ b/src/features/vcs/BranchWidget.module.css @@ -35,9 +35,6 @@ text-transform: uppercase; letter-spacing: 0.04em; } -.sync { - flex: none; -} .popup { width: 380px; display: flex; @@ -112,24 +109,6 @@ font-size: var(--fs-tiny); line-height: 15px; } -.syncSmall { - flex: none; - display: inline-flex; - align-items: center; - gap: 1px; - color: var(--fg-muted); - font-family: var(--font-mono); - font-size: var(--fs-tiny); -} -.syncSmall + .syncSmall { - margin-left: -4px; -} -.incoming { - color: var(--accent); -} -.outgoing { - color: var(--success); -} .upstream { flex: 0 4 auto; margin-left: auto; diff --git a/src/features/vcs/BranchWidget.tsx b/src/features/vcs/BranchWidget.tsx index 3ce432b2b..9a09f4547 100644 --- a/src/features/vcs/BranchWidget.tsx +++ b/src/features/vcs/BranchWidget.tsx @@ -37,6 +37,7 @@ import { type RepoRow, } from "./repoBranches"; import { currentRepo, focusRepo, loadBranches, repoName, useVcs } from "./store"; +import { BranchSync } from "./BranchSync"; import styles from "./BranchWidget.module.css"; import { tx } from "@/platform/i18n"; @@ -116,12 +117,7 @@ export function BranchWidget() { {branch.operation} )} {/* Like IDEA's widget: icons only; counts are in the tooltip. */} - {branch.behind > 0 && ( - - )} - {branch.ahead > 0 && ( - - )} + {open && useBranchesPopup.setState({ open: false })} />} @@ -898,19 +894,7 @@ function BranchRowView({ row }: { row: BranchRow | Extractcurrent )} - {/* Like IDEA: incoming (↙) before outgoing (↗). */} - {b.behind > 0 && ( - - - {b.behind} - - )} - {b.ahead > 0 && ( - - - {b.ahead} - - )} + {!b.remote && b.upstream && {b.upstream}} diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index 2ac253588..61b966e6e 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -51,6 +51,9 @@ import { import { chainNeighbour, diffTitle, + detectLineEnding, + liveSideText, + lineEndingBadge, isIndex, isWorktree, loadSide, @@ -327,7 +330,7 @@ export function DiffEditor({ }; }, [path]); - const updateChunkInfo = useCallback(() => { + const updateChunkInfo = useCallback((docChanged = false) => { const v = viewsRef.current?.b; if (!v) return; const chunks = getChunks(v.state)?.chunks ?? []; @@ -335,6 +338,7 @@ export function DiffEditor({ const current = chunks.findIndex((c) => c.fromB <= head && head <= Math.max(c.fromB, c.endB)); const { moves, whitespaceOnly } = getDiffInsights(chunks); setChunkInfo((p) => + !docChanged && p.count === chunks.length && p.current === current && p.moves === moves.length && @@ -377,7 +381,7 @@ export function DiffEditor({ prev?.destroy(); host.textContent = ""; const listener = EditorView.updateListener.of((u) => { - if (u.selectionSet || u.docChanged) updateChunkInfo(); + if (u.selectionSet || u.docChanged) updateChunkInfo(u.docChanged); }); const findWatch = (s: Side) => EditorView.updateListener.of((u) => findWatchers.current[s].forEach((fn) => fn(u))); const focusTracker = (side: "a" | "b") => @@ -555,7 +559,7 @@ export function DiffEditor({ viewerRef.current = built; setViewer(built); setViewsGen((g) => g + 1); - requestAnimationFrame(updateChunkInfo); + requestAnimationFrame(() => updateChunkInfo()); }, [ contribs, attached, @@ -1006,8 +1010,18 @@ export function DiffEditor({ [], ); - const identical = - ready && chunkInfo.count === 0 && (attached?.a?.text() ?? statics?.a) === (attached?.b?.text() ?? statics?.b); + const textA = attached?.a?.text() ?? statics?.a ?? ""; + const textB = attached?.b?.text() ?? statics?.b ?? ""; + const eolA = attached?.a?.lineEnding() ?? detectLineEnding(textA); + const eolB = attached?.b?.lineEnding() ?? detectLineEnding(textB); + const rawA = attached?.a ? liveSideText(textA, eolA) : textA; + const rawB = attached?.b ? liveSideText(textB, eolB) : textB; + const badgeA = lineEndingBadge(rawA); + const badgeB = lineEndingBadge(rawB); + const sameText = rawA.replace(/\r\n?/g, "\n") === rawB.replace(/\r\n?/g, "\n"); + const eolOnly = ready && !binary && rawA !== rawB && sameText; + const eolDiffers = ready && !binary && (badgeA !== badgeB || eolOnly); + const identical = ready && chunkInfo.count === 0 && rawA === rawB; // Staging applies to local changes against HEAD / the index only. const canStage = editableB && isWorktree(right) && left.type === "rev" && !!useVcs.getState().root; const hasSource = !!source; @@ -1127,9 +1141,11 @@ export function DiffEditor({ : chunkInfo.count === 0 ? identical ? tx("Contents are identical") - : chunkInfo.whitespaceOnly - ? tx("Whitespace-only changes") - : tx("No differences") + : eolOnly + ? tx("Contents differ only in line separators") + : chunkInfo.whitespaceOnly + ? tx("Whitespace-only changes") + : tx("No differences") : chunkInfo.current >= 0 ? tx`${chunkInfo.current + 1} of ${chunkInfo.count} differences` : chunkInfo.count === 1 @@ -1156,6 +1172,7 @@ export function DiffEditor({
{sideLabel(left)} + {eolDiffers && eolA && {badgeA}} {editableA && ( editable @@ -1164,6 +1181,7 @@ export function DiffEditor({ {sideLabel(right)} + {eolDiffers && eolB && {badgeB}} {editableB && ( {left.type === "rev" ? tx("working copy") : tx("editable")} )} @@ -1172,7 +1190,9 @@ export function DiffEditor({ ) : (
- {sideLabel(left)} → {sideLabel(right)} + {sideLabel(left)} + {eolDiffers && eolA && {badgeA}} → {sideLabel(right)} + {eolDiffers && eolB && {badgeB}}
)} diff --git a/src/features/vcs/GitLog.module.css b/src/features/vcs/GitLog.module.css index 2917f7794..1c735e1c0 100644 --- a/src/features/vcs/GitLog.module.css +++ b/src/features/vcs/GitLog.module.css @@ -60,9 +60,18 @@ min-width: 60px; } .branchTree { + --tree-detail-flex-shrink: 0; flex: 1; min-height: 0; } +.branchDetail { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + line-height: 1; + vertical-align: middle; +} /* filters */ .filters { diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index b1d5e846c..f33e4ec67 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -88,6 +88,7 @@ import { clearConsole, useVcsConsole } from "./ops"; import { currentRepo, currentRepoRoot, loadBranches, repoName, useVcs } from "./store"; import { canDeleteBranch, loadFavoriteBranches, useFavoriteBranches } from "./branchOps"; import { branchMenu, inRepo, tagMenu } from "./branchMenu"; +import { BranchSync } from "./BranchSync"; import { branchFolderKeys, branchGroupKeys, @@ -348,11 +349,16 @@ export function LogView() { // --- branches --------------------------------------------------------------- -function branchDetail(n: BranchTreeNode): string | undefined { +function branchDetail(n: BranchTreeNode): ReactNode { const b = n.branch; if (!b) return undefined; - const sync = [b.behind > 0 ? `↓${b.behind}` : "", b.ahead > 0 ? `↑${b.ahead}` : ""].filter(Boolean).join(" "); - return [b.current ? tx("current") : "", sync].filter(Boolean).join(" ") || undefined; + if (!b.current && b.behind <= 0 && b.ahead <= 0) return undefined; + return ( + + {b.current && {tx("current")}} + + + ); } export function BranchesPanel() { diff --git a/src/features/vcs/__tests__/diffFind.test.tsx b/src/features/vcs/__tests__/diffFind.test.tsx index 8931ed3d2..3da384e28 100644 --- a/src/features/vcs/__tests__/diffFind.test.tsx +++ b/src/features/vcs/__tests__/diffFind.test.tsx @@ -33,6 +33,7 @@ vi.mock("@/editor/api", async (importOriginal) => ({ Promise.resolve({ path, text: () => "foo\nqux\nfoo\n", + lineEnding: () => "lf", readOnly: false, extension: [], save: () => Promise.resolve(), diff --git a/src/features/vcs/__tests__/diffToolbar.test.tsx b/src/features/vcs/__tests__/diffToolbar.test.tsx index bdac8d2e6..be9fe6a9d 100644 --- a/src/features/vcs/__tests__/diffToolbar.test.tsx +++ b/src/features/vcs/__tests__/diffToolbar.test.tsx @@ -11,6 +11,7 @@ const openFile = vi.fn(); const openInput = vi.fn(); const texts: Record = { HEAD: "a\n", abc123: "a\n" }; let working = "a\n"; +let workingEol = "lf"; vi.mock("@/platform/ipc", async (importOriginal) => { const real = await importOriginal(); @@ -36,6 +37,7 @@ vi.mock("@/editor/api", async (importOriginal) => ({ Promise.resolve({ path, text: () => working, + lineEnding: () => workingEol, readOnly: false, extension: [], save: () => Promise.resolve(), @@ -63,6 +65,7 @@ const input = { const paths = (html: string) => [...html.matchAll(/ d="([^"]+)"/g)].map((m) => m[1]); beforeEach(() => { + workingEol = "lf"; probe.mockReset(); openFile.mockReset(); openInput.mockReset(); @@ -94,6 +97,50 @@ describe("Diff toolbar", () => { await waitFor(() => expect(screen.queryByText("Binary files cannot be shown as text")).toBeNull()); }); + it.each([ + ["CRLF", "a\r\n", "a\n"], + ["CR", "a\r", "a\n"], + ["mixed with the same dominant ending", "a\r\nb\nc\n", "a\nb\nc\n"], + ["mixed with a different dominant ending", "a\r\nb\r\nc\n", "a\nb\nc\n"], + ])("reports %s line-separator-only differences", async (_name, a, b) => { + texts.HEAD = a; + texts.abc123 = b; + render(); + expect(await screen.findByText("Contents differ only in line separators")).toBeInTheDocument(); + expect(screen.queryByText("No differences")).toBeNull(); + expect(screen.queryByText("Contents are identical")).toBeNull(); + }); + + it.each(["a\n", "a\r\n", "a\r\nb\n", ""])("recognizes identical contents %j", async (text) => { + texts.HEAD = texts.abc123 = text; + render(); + expect(await screen.findByText("Contents are identical")).toBeInTheDocument(); + expect(screen.queryByText("Contents differ only in line separators")).toBeNull(); + }); + + it("does not describe ignored whitespace as only line separators", async () => { + localStorage.setItem("openidea.vcs.diffOptions", JSON.stringify({ ignore: "whitespace" })); + texts.HEAD = "a \r\n"; + texts.abc123 = "a\n"; + const mounted = render(); + await waitFor(() => expect(mounted.container.querySelectorAll(".cm-editor")).toHaveLength(2)); + expect(screen.queryByText("Contents differ only in line separators")).toBeNull(); + mounted.unmount(); + localStorage.removeItem("openidea.vcs.diffOptions"); + }); + + it("compares a normalized live CRLF document with a raw CRLF revision", async () => { + texts.HEAD = "a\r\n"; + workingEol = "crLf"; + render( + , + ); + expect(await screen.findByText("Contents are identical")).toBeInTheDocument(); + expect(screen.queryByText("Contents differ only in line separators")).toBeNull(); + }); + it("puts Jump to Source right after Next Difference, in the navigation group", async () => { render(); await screen.findByRole("button", { name: "Jump to Source" }); diff --git a/src/features/vcs/__tests__/logBranches.test.tsx b/src/features/vcs/__tests__/logBranches.test.tsx index 1136d8097..29f3d2195 100644 --- a/src/features/vcs/__tests__/logBranches.test.tsx +++ b/src/features/vcs/__tests__/logBranches.test.tsx @@ -23,6 +23,7 @@ vi.mock("@/platform/actions", async (importOriginal) => ({ import { ContextMenuHost } from "@/ui"; import { BranchesPanel } from "../GitLog"; +import syncStyles from "../BranchSync.module.css"; import { emptyFilters, useGitLog } from "../logStore"; import { useVcs } from "../store"; @@ -88,6 +89,22 @@ describe("Git Log branches pane", () => { expect(screen.getByText("feature").closest("[data-row]")).toHaveAttribute("aria-selected", "true"); }); + it.each([ + [2, 0], + [0, 3], + [2, 3], + [0, 0], + ])("renders shared branch markers for ahead %i / behind %i", (ahead, behind) => { + useVcs.setState({ branches: [b("main", { current: true, ahead, behind })] }); + render(); + const incoming = screen.queryByLabelText("3 incoming commits"); + const outgoing = screen.queryByLabelText("2 outgoing commits"); + if (behind) expect(incoming).toHaveClass(syncStyles.incoming!); + else expect(incoming).toBeNull(); + if (ahead) expect(outgoing).toHaveClass(syncStyles.outgoing!); + else expect(outgoing).toBeNull(); + }); + it("double click switches the log to that branch", () => { render(); fireEvent.doubleClick(screen.getByText("feature")); diff --git a/src/features/vcs/diffInput.ts b/src/features/vcs/diffInput.ts index cea3a56da..05567bd55 100644 --- a/src/features/vcs/diffInput.ts +++ b/src/features/vcs/diffInput.ts @@ -1,6 +1,6 @@ /** Diff editor inputs and helpers to open them. */ import type { ChangedFile, RevisionChange } from "@/bindings"; -import { call, commands } from "@/platform/ipc"; +import { call, commands, type LineEnding } from "@/platform/ipc"; import { editorService, type EditorInput } from "@/workbench/contributions"; import { baseName, effectiveKind } from "./model"; import { readWorkingText } from "./ops"; @@ -219,3 +219,40 @@ export const isWorktree = (s: DiffSide) => s.type === "worktree"; export const sideFile = (s: DiffSide, path: string): string | null => s.type === "worktree" ? path : s.type === "file" ? s.path : null; export const isIndex = (s: DiffSide) => s.type === "rev" && (s.rev === ":0" || s.rev === ":"); + +/** The dominant line ending of `text` (`lf` for ties and no breaks), like the backend's detection. */ +export function detectLineEnding(text: string): LineEnding { + let lf = 0; + let crlf = 0; + let cr = 0; + for (let i = 0; i < text.length; i++) { + const ch = text.charCodeAt(i); + if (ch === 10) lf++; + else if (ch === 13) { + if (text.charCodeAt(i + 1) === 10) { + crlf++; + i++; + } else cr++; + } + } + return lf >= crlf && lf >= cr ? "lf" : crlf >= cr ? "crLf" : "cr"; +} + +const EOL_BADGE: Record = { lf: "LF", crLf: "CRLF", cr: "CR" }; + +const EOL_SEPARATOR: Record = { lf: "\n", crLf: "\r\n", cr: "\r" }; + +/** Reconstruct a normalized document using its configured save separator. */ +export function liveSideText(text: string, ending: LineEnding): string { + return text.replace(/\n/g, EOL_SEPARATOR[ending]); +} + +export function lineEndingBadge(text: string): string { + const separators = new Set(text.match(/\r\n|\r|\n/g)); + return ( + Object.entries(EOL_SEPARATOR) + .filter(([, separator]) => separators.has(separator)) + .map(([ending]) => EOL_BADGE[ending as LineEnding]) + .join(" / ") || "LF" + ); +} diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 0b106bc20..ef5b162e4 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1,4 +1,5 @@ { + "Contents differ only in line separators": "Contents differ only in line separators", "Cannot remove a worktree containing the open project.": "Cannot remove a worktree containing the open project.", "Delete Remote Branches": "Delete Remote Branches", "Delete the {length} branches '{names}' on the remote? The remote-tracking branches are removed as well.": "Delete the {length} branches '{names}' on the remote? The remote-tracking branches are removed as well.", diff --git a/src/platform/locales/text/es.json b/src/platform/locales/text/es.json index beb81df79..2c6d802dd 100644 --- a/src/platform/locales/text/es.json +++ b/src/platform/locales/text/es.json @@ -1044,7 +1044,6 @@ "Include": "Incluir", "Include non-project items": "Incluir elementos fuera del proyecto", "Include untracked files": "Incluir archivos sin seguimiento", - "Incoming commits": "Confirmaciones entrantes", "Indent": "Aplicar sangría", "Indent Block": "Aplicar sangría al bloque", "Indent Each Line": "Aplicar sangría a cada línea", diff --git a/src/platform/locales/text/fr.json b/src/platform/locales/text/fr.json index 08f620429..10c2b2414 100644 --- a/src/platform/locales/text/fr.json +++ b/src/platform/locales/text/fr.json @@ -975,7 +975,6 @@ "Include": "Inclure", "Include non-project items": "Inclure les éléments hors projet", "Include untracked files": "Inclure les fichiers non suivis", - "Incoming commits": "Commits entrants", "Indent": "Indenter", "Indent Block": "Indenter le bloc", "Indent Each Line": "Indenter chaque ligne", diff --git a/src/platform/locales/text/ko.json b/src/platform/locales/text/ko.json index e4b6206d7..8b0fcf329 100644 --- a/src/platform/locales/text/ko.json +++ b/src/platform/locales/text/ko.json @@ -1085,7 +1085,6 @@ "Include": "포함", "Include non-project items": "프로젝트 외 항목 포함", "Include untracked files": "추적되지 않는 파일 포함", - "Incoming commits": "수신 커밋", "Indent": "들여쓰기", "Indent Block": "블록 들여쓰기", "Indent Each Line": "각 줄 들여쓰기", diff --git a/src/platform/locales/text/pl.json b/src/platform/locales/text/pl.json index 315a58a0c..6cf17dfe2 100644 --- a/src/platform/locales/text/pl.json +++ b/src/platform/locales/text/pl.json @@ -1036,7 +1036,6 @@ "Include": "Dołącz", "Include non-project items": "Dołącz elementy spoza projektu", "Include untracked files": "Dołącz niesformatowane/nieśledzone pliki", - "Incoming commits": "Przychodzące zatwierdzenia", "Indent": "Wcięcie", "Indent Block": "Zwiększ wcięcie bloku", "Indent Each Line": "Zwiększ wcięcie każdego wiersza", diff --git a/src/platform/locales/text/pt.json b/src/platform/locales/text/pt.json index 05280f0da..e31a05e8f 100644 --- a/src/platform/locales/text/pt.json +++ b/src/platform/locales/text/pt.json @@ -1036,7 +1036,6 @@ "Include": "Incluir", "Include non-project items": "Incluir itens fora do projeto", "Include untracked files": "Incluir arquivos não rastreados", - "Incoming commits": "Commits recebidos", "Indent": "Indentar", "Indent Block": "Indentar bloco", "Indent Each Line": "Indentar cada linha", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index b1a884c49..d81acbc2c 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1138,7 +1138,6 @@ "Include": "包括", "Include non-project items": "包括非项目项", "Include untracked files": "包括未跟踪的文件", - "Incoming commits": "传入的提交", "Indent": "缩进", "Indent Block": "缩进块", "Indent Each Line": "缩进每一行", @@ -4928,6 +4927,7 @@ "Upgrading": "正在升级", "Uninstalling": "正在卸载", "Contents are identical": "内容完全一致", + "Contents differ only in line separators": "内容仅在行分隔符上有差异", "working copy": "工作副本", "No commits match the filters": "没有与筛选条件匹配的提交", "No commits yet": "还没有提交", diff --git a/src/ui/Tree.module.css b/src/ui/Tree.module.css index e94759b2e..99dac0308 100644 --- a/src/ui/Tree.module.css +++ b/src/ui/Tree.module.css @@ -129,6 +129,7 @@ text-overflow: ellipsis; } .detail { + flex-shrink: var(--tree-detail-flex-shrink, 1); min-width: 0; margin-left: 4px; overflow: hidden; diff --git a/src/ui/Tree.tsx b/src/ui/Tree.tsx index f8ab23703..fd6b427e8 100644 --- a/src/ui/Tree.tsx +++ b/src/ui/Tree.tsx @@ -86,8 +86,8 @@ export interface TreeProps { /** Custom row content (after the chevron). Defaults to icon + highlighted label + `getDetail`. */ renderNode?: (node: T, state: TreeRowState) => ReactNode; getIcon?: (node: T, expanded: boolean) => IconName | (string & {}) | undefined; - /** Muted trailing text (e.g. a path next to the project root). */ - getDetail?: (node: T) => string | undefined; + /** Muted trailing detail (e.g. a path next to the project root); may include styled content. */ + getDetail?: (node: T) => ReactNode; /** Label colour, e.g. VCS status. */ getColor?: (node: T) => string | undefined; /** Filesystem directory for actions on the focused selection; groups return null. */ @@ -750,7 +750,9 @@ export function Tree(props: TreeProps) { indices={matches} tone="accent" /> - {detail && {detail}} + {detail != null && detail !== false && detail !== "" && ( + {detail} + )} )}