feat(FN-903): add middle truncation utility for file paths in dashboard
- Add truncatePath utility function with configurable segments and placeholder - Apply middle truncation to file paths in ChangedFilesModal sidebar - Apply middle truncation to file paths in TaskChangesTab - Remove unused CSS truncation styles replaced by the new utility - Add comprehensive test suite for truncatePath (106 tests)
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useChangedFiles } from "../hooks/useChangedFiles";
|
||||
import { highlightDiff } from "../utils/highlightDiff";
|
||||
import { truncateMiddle } from "../utils/truncatePath";
|
||||
import type { TaskFileDiff } from "../api";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
@@ -237,7 +238,7 @@ export function ChangedFilesModal({
|
||||
onClick={() => handleSelectFile(file)}
|
||||
>
|
||||
<span className="file-node-icon">{getStatusIcon(file.status)}</span>
|
||||
<span className="file-node-name">{file.path}</span>
|
||||
<span className="file-node-name" title={file.path}>{truncateMiddle(file.path)}</span>
|
||||
<span className={`detail-column-badge changed-files-badge changed-files-badge--${file.status}`}>
|
||||
{getStatusLabel(file.status)}
|
||||
</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FileCode, ChevronDown, ChevronRight, AlertCircle, GitCommit } from "luc
|
||||
import type { MergeDetails, Column } from "@fusion/core";
|
||||
import { fetchTaskDiff, type TaskDiff } from "../api";
|
||||
import { highlightDiff } from "../utils/highlightDiff";
|
||||
import { truncateMiddle } from "../utils/truncatePath";
|
||||
|
||||
interface TaskChangesTabProps {
|
||||
taskId: string;
|
||||
@@ -214,7 +215,7 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
|
||||
{getStatusLabel(file.status)}
|
||||
</span>
|
||||
<span className="changes-file-path" title={file.path}>
|
||||
{file.path}
|
||||
{truncateMiddle(file.path)}
|
||||
</span>
|
||||
<span
|
||||
className="changes-file-stat"
|
||||
|
||||
@@ -3560,7 +3560,6 @@ body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
@@ -9438,7 +9437,6 @@ html .column.drag-over * {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -9869,7 +9867,6 @@ html .column.drag-over * {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -15101,7 +15098,6 @@ html .column.drag-over * {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
106
packages/dashboard/app/utils/truncatePath.test.ts
Normal file
106
packages/dashboard/app/utils/truncatePath.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { truncateMiddle } from "./truncatePath";
|
||||
|
||||
describe("truncateMiddle", () => {
|
||||
it("returns empty string unchanged", () => {
|
||||
expect(truncateMiddle("")).toBe("");
|
||||
});
|
||||
|
||||
it("returns short paths unchanged", () => {
|
||||
expect(truncateMiddle("src/index.ts")).toBe("src/index.ts");
|
||||
});
|
||||
|
||||
it("returns paths at exactly maxLength unchanged", () => {
|
||||
const path = "a".repeat(60);
|
||||
expect(truncateMiddle(path, 60)).toBe(path);
|
||||
});
|
||||
|
||||
it("returns paths shorter than maxLength unchanged", () => {
|
||||
const path = "a".repeat(59);
|
||||
expect(truncateMiddle(path, 60)).toBe(path);
|
||||
});
|
||||
|
||||
it("truncates a long path from the middle", () => {
|
||||
const path = "packages/dashboard/app/components/TaskChangesTab.tsx";
|
||||
const result = truncateMiddle(path, 30);
|
||||
expect(result).toContain("...");
|
||||
expect(result.length).toBeLessThanOrEqual(30);
|
||||
// Filename should be preserved
|
||||
expect(result.endsWith("TaskChangesTab.tsx")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves the full path when under maxLength", () => {
|
||||
const path = "src/components/Button.tsx";
|
||||
expect(truncateMiddle(path, 60)).toBe(path);
|
||||
});
|
||||
|
||||
it("truncates paths with no separator from the end", () => {
|
||||
const path = "verylongfilenamewithoutseparators.txt";
|
||||
const result = truncateMiddle(path, 20);
|
||||
expect(result).toContain("...");
|
||||
expect(result.length).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
it("handles maxLength of 4 (minimum for ellipsis + 1 char)", () => {
|
||||
const path = "src/components/deeply/nested/file.ts";
|
||||
const result = truncateMiddle(path, 4);
|
||||
expect(result.length).toBeLessThanOrEqual(4);
|
||||
expect(result).toContain("...");
|
||||
});
|
||||
|
||||
it("handles maxLength smaller than 4 gracefully", () => {
|
||||
const path = "src/components/file.ts";
|
||||
const result = truncateMiddle(path, 3);
|
||||
expect(result.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("uses default maxLength of 60", () => {
|
||||
// 61 chars — should truncate
|
||||
const path = "packages/dashboard/app/components/VeryLongComponentNameGoesHere.tsx";
|
||||
// path is 73 chars
|
||||
const result = truncateMiddle(path);
|
||||
expect(result.length).toBeLessThanOrEqual(60);
|
||||
expect(result).toContain("...");
|
||||
});
|
||||
|
||||
it("preserves filename when path is deeply nested", () => {
|
||||
const path = "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/file.ts";
|
||||
const result = truncateMiddle(path, 25);
|
||||
expect(result.endsWith("file.ts")).toBe(true);
|
||||
expect(result).toContain("...");
|
||||
expect(result.length).toBeLessThanOrEqual(25);
|
||||
});
|
||||
|
||||
it("handles single-segment paths", () => {
|
||||
const result = truncateMiddle("verylongfilename.tsx", 15);
|
||||
expect(result.length).toBeLessThanOrEqual(15);
|
||||
expect(result).toContain("...");
|
||||
});
|
||||
|
||||
it("handles a path where the filename itself is longer than maxLength", () => {
|
||||
const path = "ExtremelyLongFileNameThatExceedsTheMaximumLength.tsx";
|
||||
const result = truncateMiddle(path, 20);
|
||||
expect(result.length).toBeLessThanOrEqual(20);
|
||||
expect(result).toContain("...");
|
||||
});
|
||||
|
||||
it("preserves start portion when truncating", () => {
|
||||
const path = "packages/dashboard/app/components/TaskChangesTab.tsx";
|
||||
const result = truncateMiddle(path, 35);
|
||||
expect(result.startsWith("packages")).toBe(true);
|
||||
expect(result).toContain("...");
|
||||
expect(result.endsWith("TaskChangesTab.tsx")).toBe(true);
|
||||
});
|
||||
|
||||
it("works with paths that have dots but no slashes", () => {
|
||||
const result = truncateMiddle("config.local.development.json", 20);
|
||||
expect(result.length).toBeLessThanOrEqual(20);
|
||||
expect(result).toContain("...");
|
||||
});
|
||||
|
||||
it("handles exactly the boundary case where path is maxLength+1", () => {
|
||||
const path = "a".repeat(61);
|
||||
const result = truncateMiddle(path, 60);
|
||||
expect(result.length).toBeLessThanOrEqual(60);
|
||||
});
|
||||
});
|
||||
75
packages/dashboard/app/utils/truncatePath.ts
Normal file
75
packages/dashboard/app/utils/truncatePath.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Truncates a file path from the middle, preserving the end (filename).
|
||||
*
|
||||
* Example:
|
||||
* Input: "packages/dashboard/app/components/TaskChangesTab.tsx" (52 chars)
|
||||
* With maxLength=40: "packages/.../components/TaskChangesTab.tsx"
|
||||
* With maxLength=30: ".../TaskChangesTab.tsx"
|
||||
*
|
||||
* The function always tries to preserve the filename (after the last separator).
|
||||
* Truncation snaps to path segment boundaries for clean output.
|
||||
*/
|
||||
export function truncateMiddle(path: string, maxLength: number = 60): string {
|
||||
if (!path || path.length <= maxLength) return path;
|
||||
|
||||
// Minimum meaningful truncation: "..." plus at least 1 char on each side
|
||||
if (maxLength < 4) return path.slice(0, maxLength);
|
||||
|
||||
const ellipsis = "...";
|
||||
|
||||
const lastSep = path.lastIndexOf("/");
|
||||
if (lastSep === -1) {
|
||||
// No separator — keep the beginning, truncate the end
|
||||
const headLen = Math.max(1, maxLength - ellipsis.length);
|
||||
return path.slice(0, headLen) + ellipsis;
|
||||
}
|
||||
|
||||
// Find all separator positions to use as candidate split points
|
||||
const seps: number[] = [];
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
if (path[i] === "/") seps.push(i);
|
||||
}
|
||||
|
||||
const budget = maxLength - ellipsis.length;
|
||||
|
||||
// Try all combinations of prefix-cut (before "...") and suffix-start (after "...")
|
||||
// We want prefix + "..." + suffix where both align on separators
|
||||
// Iterate suffix start positions from the end (preferring to keep the filename)
|
||||
let bestResult: string | null = null;
|
||||
let bestScore = -1; // prefer results with more chars preserved
|
||||
|
||||
for (let si = seps.length - 1; si >= 0; si--) {
|
||||
const suffixStart = seps[si]; // position of "/" starting the suffix
|
||||
const suffix = path.slice(suffixStart); // includes leading "/"
|
||||
|
||||
if (suffix.length > budget) continue; // suffix alone doesn't fit
|
||||
|
||||
const remainingForPrefix = budget - suffix.length;
|
||||
|
||||
// Find the longest prefix that fits
|
||||
for (let pi = 0; pi < seps.length; pi++) {
|
||||
const prefixEnd = seps[pi]; // prefix goes up to (not including) this "/"
|
||||
const prefix = path.slice(0, prefixEnd);
|
||||
|
||||
if (prefix.length <= remainingForPrefix) {
|
||||
const totalLen = prefix.length + ellipsis.length + suffix.length;
|
||||
if (totalLen <= maxLength && prefix.length + suffix.length > bestScore) {
|
||||
bestScore = prefix.length + suffix.length;
|
||||
bestResult = prefix + ellipsis + suffix;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestResult) return bestResult;
|
||||
|
||||
// Fallback: just ellipsis + the filename portion (with leading "/")
|
||||
const suffixWithSlash = path.slice(lastSep);
|
||||
if (ellipsis.length + suffixWithSlash.length <= maxLength) {
|
||||
return ellipsis + suffixWithSlash;
|
||||
}
|
||||
|
||||
// Filename itself is too long — truncate from the end
|
||||
const endLen = maxLength - ellipsis.length;
|
||||
return ellipsis + path.slice(path.length - endLen);
|
||||
}
|
||||
Reference in New Issue
Block a user