diff --git a/packages/core/src/utils/paths.test.ts b/packages/core/src/utils/paths.test.ts index 602f977a0c..210dc8b448 100644 --- a/packages/core/src/utils/paths.test.ts +++ b/packages/core/src/utils/paths.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { escapePath, unescapePath, isSubpath } from './paths.js'; +import { escapePath, unescapePath, isSubpath, shortenPath } from './paths.js'; describe('escapePath', () => { it.each([ @@ -257,3 +257,218 @@ describe('isSubpath on Windows', () => { expect(isSubpath('Users\\Test\\file.txt', 'Users\\Test')).toBe(false); }); }); + +describe('shortenPath', () => { + describe.skipIf(process.platform === 'win32')('on POSIX', () => { + it('should not shorten a path that is shorter than maxLen', () => { + const p = '/path/to/file.txt'; + expect(shortenPath(p, 40)).toBe(p); + }); + + it('should not shorten a path that is equal to maxLen', () => { + const p = '/path/to/file.txt'; + expect(shortenPath(p, p.length)).toBe(p); + }); + + it('should shorten a long path, keeping start and end from a short limit', () => { + const p = '/path/to/a/very/long/directory/name/file.txt'; + expect(shortenPath(p, 25)).toBe('/path/.../name/file.txt'); + }); + + it('should shorten a long path, keeping more from the end from a longer limit', () => { + const p = '/path/to/a/very/long/directory/name/file.txt'; + expect(shortenPath(p, 35)).toBe('/path/.../directory/name/file.txt'); + }); + + it('should handle deep paths where few segments from the end fit', () => { + const p = '/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/file.txt'; + expect(shortenPath(p, 20)).toBe('/a/.../y/z/file.txt'); + }); + + it('should handle deep paths where many segments from the end fit', () => { + const p = '/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/file.txt'; + expect(shortenPath(p, 45)).toBe( + '/a/.../l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/file.txt', + ); + }); + + it('should handle a long filename in the root when it needs shortening', () => { + const p = '/a-very-long-filename-that-needs-to-be-shortened.txt'; + expect(shortenPath(p, 40)).toBe( + '/a-very-long-filen...o-be-shortened.txt', + ); + }); + + it('should handle root path', () => { + const p = '/'; + expect(shortenPath(p, 10)).toBe('/'); + }); + + it('should handle a path with one long segment after root', () => { + const p = '/a-very-long-directory-name'; + expect(shortenPath(p, 20)).toBe('/a-very-...ory-name'); + }); + + it('should handle a path with just a long filename (no root)', () => { + const p = 'a-very-long-filename-that-needs-to-be-shortened.txt'; + expect(shortenPath(p, 40)).toBe( + 'a-very-long-filena...o-be-shortened.txt', + ); + }); + + it('should fallback to truncating earlier segments while keeping the last intact', () => { + const p = '/abcdef/fghij.txt'; + const result = shortenPath(p, 10); + expect(result).toBe('/fghij.txt'); + expect(result.length).toBeLessThanOrEqual(10); + }); + + it('should fallback by truncating start and middle segments when needed', () => { + const p = '/averylongcomponentname/another/short.txt'; + const result = shortenPath(p, 25); + expect(result).toBe('/averylo.../.../short.txt'); + expect(result.length).toBeLessThanOrEqual(25); + }); + + it('should show only the last segment when maxLen is tiny', () => { + const p = '/foo/bar/baz.txt'; + const result = shortenPath(p, 8); + expect(result).toBe('/baz.txt'); + expect(result.length).toBeLessThanOrEqual(8); + }); + + it('should fall back to simple truncation when the last segment exceeds maxLen', () => { + const longFile = 'x'.repeat(60) + '.txt'; + const p = `/really/long/${longFile}`; + const result = shortenPath(p, 50); + expect(result).toBe('/really/long/xxxxxxxxxx...xxxxxxxxxxxxxxxxxxx.txt'); + expect(result.length).toBeLessThanOrEqual(50); + }); + + it('should handle relative paths without a root', () => { + const p = 'foo/bar/baz/qux.txt'; + const result = shortenPath(p, 18); + expect(result).toBe('foo/.../qux.txt'); + expect(result.length).toBeLessThanOrEqual(18); + }); + + it('should ignore empty segments created by repeated separators', () => { + const p = '/foo//bar///baz/verylongname.txt'; + const result = shortenPath(p, 20); + expect(result).toBe('.../verylongname.txt'); + expect(result.length).toBeLessThanOrEqual(20); + }); + }); + + describe.skipIf(process.platform !== 'win32')('on Windows', () => { + it('should not shorten a path that is shorter than maxLen', () => { + const p = 'C\\Users\\Test\\file.txt'; + expect(shortenPath(p, 40)).toBe(p); + }); + + it('should not shorten a path that is equal to maxLen', () => { + const p = 'C\\path\\to\\file.txt'; + expect(shortenPath(p, p.length)).toBe(p); + }); + + it('should shorten a long path, keeping start and end from a short limit', () => { + const p = 'C\\path\\to\\a\\very\\long\\directory\\name\\file.txt'; + expect(shortenPath(p, 30)).toBe('C\\...\\directory\\name\\file.txt'); + }); + + it('should shorten a long path, keeping more from the end from a longer limit', () => { + const p = 'C\\path\\to\\a\\very\\long\\directory\\name\\file.txt'; + expect(shortenPath(p, 42)).toBe( + 'C\\...\\a\\very\\long\\directory\\name\\file.txt', + ); + }); + + it('should handle deep paths where few segments from the end fit', () => { + const p = + 'C\\a\\b\\c\\d\\e\\f\\g\\h\\i\\j\\k\\l\\m\\n\\o\\p\\q\\r\\s\\t\\u\\v\\w\\x\\y\\z\\file.txt'; + expect(shortenPath(p, 22)).toBe('C\\...\\w\\x\\y\\z\\file.txt'); + }); + + it('should handle deep paths where many segments from the end fit', () => { + const p = + 'C\\a\\b\\c\\d\\e\\f\\g\\h\\i\\j\\k\\l\\m\\n\\o\\p\\q\\r\\s\\t\\u\\v\\w\\x\\y\\z\\file.txt'; + expect(shortenPath(p, 47)).toBe( + 'C\\...\\k\\l\\m\\n\\o\\p\\q\\r\\s\\t\\u\\v\\w\\x\\y\\z\\file.txt', + ); + }); + + it('should handle a long filename in the root when it needs shortening', () => { + const p = 'C\\a-very-long-filename-that-needs-to-be-shortened.txt'; + expect(shortenPath(p, 40)).toBe( + 'C\\a-very-long-file...o-be-shortened.txt', + ); + }); + + it('should handle root path', () => { + const p = 'C\\'; + expect(shortenPath(p, 10)).toBe('C\\'); + }); + + it('should handle a path with one long segment after root', () => { + const p = 'C\\a-very-long-directory-name'; + expect(shortenPath(p, 22)).toBe('C\\a-very-...tory-name'); + }); + + it('should handle a path with just a long filename (no root)', () => { + const p = 'a-very-long-filename-that-needs-to-be-shortened.txt'; + expect(shortenPath(p, 40)).toBe( + 'a-very-long-filena...o-be-shortened.txt', + ); + }); + + it('should fallback to truncating earlier segments while keeping the last intact', () => { + const p = 'C\\abcdef\\fghij.txt'; + const result = shortenPath(p, 15); + expect(result).toBe('C\\...\\fghij.txt'); + expect(result.length).toBeLessThanOrEqual(15); + }); + + it('should fallback by truncating start and middle segments when needed', () => { + const p = 'C\\averylongcomponentname\\another\\short.txt'; + const result = shortenPath(p, 30); + expect(result).toBe('C\\...\\another\\short.txt'); + expect(result.length).toBeLessThanOrEqual(30); + }); + + it('should show only the last segment for tiny maxLen values', () => { + const p = 'C\\foo\\bar\\baz.txt'; + const result = shortenPath(p, 12); + expect(result).toBe('...\\baz.txt'); + expect(result.length).toBeLessThanOrEqual(12); + }); + + it('should keep the drive prefix when space allows', () => { + const p = 'C\\foo\\bar\\baz.txt'; + const result = shortenPath(p, 14); + expect(result).toBe('C\\...\\baz.txt'); + expect(result.length).toBeLessThanOrEqual(14); + }); + + it('should fall back when the last segment exceeds maxLen on Windows', () => { + const longFile = 'x'.repeat(60) + '.txt'; + const p = `C\\really\\long\\${longFile}`; + const result = shortenPath(p, 40); + expect(result).toBe('C\\really\\long\\xxxx...xxxxxxxxxxxxxx.txt'); + expect(result.length).toBeLessThanOrEqual(40); + }); + + it('should handle UNC paths with limited space', () => { + const p = '\\server\\share\\deep\\path\\file.txt'; + const result = shortenPath(p, 25); + expect(result).toBe('\\server\\...\\path\\file.txt'); + expect(result.length).toBeLessThanOrEqual(25); + }); + + it('should collapse UNC paths further when maxLen shrinks', () => { + const p = '\\server\\share\\deep\\path\\file.txt'; + const result = shortenPath(p, 18); + expect(result).toBe('\\s...\\...\\file.txt'); + expect(result.length).toBeLessThanOrEqual(18); + }); + }); +}); diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index 5723527996..0546e11ffe 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -40,6 +40,53 @@ export function shortenPath(filePath: string, maxLen: number = 35): string { return filePath; } + const simpleTruncate = () => { + const keepLen = Math.floor((maxLen - 3) / 2); + if (keepLen <= 0) { + return filePath.substring(0, maxLen - 3) + '...'; + } + const start = filePath.substring(0, keepLen); + const end = filePath.substring(filePath.length - keepLen); + return `${start}...${end}`; + }; + + type TruncateMode = 'start' | 'end' | 'center'; + + const truncateComponent = ( + component: string, + targetLength: number, + mode: TruncateMode, + ): string => { + if (component.length <= targetLength) { + return component; + } + + if (targetLength <= 0) { + return ''; + } + + if (targetLength <= 3) { + if (mode === 'end') { + return component.slice(-targetLength); + } + return component.slice(0, targetLength); + } + + if (mode === 'start') { + return `${component.slice(0, targetLength - 3)}...`; + } + + if (mode === 'end') { + return `...${component.slice(component.length - (targetLength - 3))}`; + } + + const front = Math.ceil((targetLength - 3) / 2); + const back = targetLength - 3 - front; + return `${component.slice(0, front)}...${component.slice( + component.length - back, + )}`; + }; + const parsedPath = path.parse(filePath); const root = parsedPath.root; const separator = path.sep; @@ -51,51 +98,134 @@ export function shortenPath(filePath: string, maxLen: number = 35): string { // Handle cases with no segments after root (e.g., "/", "C:\") or only one segment if (segments.length <= 1) { // Fall back to simple start/end truncation for very short paths or single segments - const keepLen = Math.floor((maxLen - 3) / 2); - // Ensure keepLen is not negative if maxLen is very small - if (keepLen <= 0) { - return filePath.substring(0, maxLen - 3) + '...'; - } - const start = filePath.substring(0, keepLen); - const end = filePath.substring(filePath.length - keepLen); - return `${start}...${end}`; + return simpleTruncate(); } const firstDir = segments[0]; const lastSegment = segments[segments.length - 1]; const startComponent = root + firstDir; - const endPartSegments: string[] = []; - // Base length: separator + "..." + lastDir - let currentLength = separator.length + lastSegment.length; + const endPartSegments = [lastSegment]; + let endPartLength = lastSegment.length; - // Iterate backwards through segments (excluding the first one) - for (let i = segments.length - 2; i >= 0; i--) { + // Iterate backwards through the middle segments + for (let i = segments.length - 2; i > 0; i--) { const segment = segments[i]; - // Length needed if we add this segment: current + separator + segment - const lengthWithSegment = currentLength + separator.length + segment.length; + const newLength = + startComponent.length + + separator.length + + 3 + // for "..." + separator.length + + endPartLength + + separator.length + + segment.length; - if (lengthWithSegment <= maxLen) { - endPartSegments.unshift(segment); // Add to the beginning of the end part - currentLength = lengthWithSegment; + if (newLength <= maxLen) { + endPartSegments.unshift(segment); + endPartLength += separator.length + segment.length; } else { break; } } - let result = endPartSegments.join(separator) + separator + lastSegment; + const components = [firstDir, ...endPartSegments]; + const componentModes: TruncateMode[] = components.map((_, index) => { + if (index === 0) { + return 'start'; + } + if (index === components.length - 1) { + return 'end'; + } + return 'center'; + }); - if (currentLength > maxLen) { - return result; + const separatorsCount = endPartSegments.length + 1; + const fixedLen = root.length + separatorsCount * separator.length + 3; // ellipsis length + const availableForComponents = maxLen - fixedLen; + + const trailingFallback = () => { + const ellipsisTail = `...${separator}${lastSegment}`; + if (ellipsisTail.length <= maxLen) { + return ellipsisTail; + } + + if (root) { + const rootEllipsisTail = `${root}...${separator}${lastSegment}`; + if (rootEllipsisTail.length <= maxLen) { + return rootEllipsisTail; + } + } + + if (root && `${root}${lastSegment}`.length <= maxLen) { + return `${root}${lastSegment}`; + } + + if (lastSegment.length <= maxLen) { + return lastSegment; + } + + // As a final resort (e.g., last segment itself exceeds maxLen), fall back to simple truncation. + return simpleTruncate(); + }; + + if (availableForComponents <= 0) { + return trailingFallback(); } - // Construct the final path - result = startComponent + separator + result; + const minLengths = components.map((component, index) => { + if (index === 0) { + return Math.min(component.length, 1); + } + if (index === components.length - 1) { + return component.length; // Never truncate the last segment when possible. + } + return Math.min(component.length, 1); + }); + + const minTotal = minLengths.reduce((sum, len) => sum + len, 0); + if (availableForComponents < minTotal) { + return trailingFallback(); + } + + const budgets = components.map((component) => component.length); + let currentTotal = budgets.reduce((sum, len) => sum + len, 0); + + const pickIndexToReduce = () => { + let bestIndex = -1; + let bestScore = -Infinity; + for (let i = 0; i < budgets.length; i++) { + if (budgets[i] <= minLengths[i]) { + continue; + } + const isLast = i === budgets.length - 1; + const score = (isLast ? 0 : 1_000_000) + budgets[i]; + if (score > bestScore) { + bestScore = score; + bestIndex = i; + } + } + return bestIndex; + }; + + while (currentTotal > availableForComponents) { + const index = pickIndexToReduce(); + if (index === -1) { + return trailingFallback(); + } + budgets[index]--; + currentTotal--; + } + + const truncatedComponents = components.map((component, index) => + truncateComponent(component, budgets[index], componentModes[index]), + ); + + const truncatedFirst = truncatedComponents[0]; + const truncatedEnd = truncatedComponents.slice(1).join(separator); + const result = `${root}${truncatedFirst}${separator}...${separator}${truncatedEnd}`; - // As a final check, if the result is somehow still too long - // truncate the result string from the beginning, prefixing with "...". if (result.length > maxLen) { - return '...' + result.substring(result.length - maxLen - 3); + return trailingFallback(); } return result;