mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
Fix the shortenPath function to correctly insert ellipsis. (#12004)
Co-authored-by: Greg Shikhman <shikhman@google.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
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', () => {
|
describe('escapePath', () => {
|
||||||
it.each([
|
it.each([
|
||||||
@@ -257,3 +257,218 @@ describe('isSubpath on Windows', () => {
|
|||||||
expect(isSubpath('Users\\Test\\file.txt', 'Users\\Test')).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,6 +40,53 @@ export function shortenPath(filePath: string, maxLen: number = 35): string {
|
|||||||
return filePath;
|
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 parsedPath = path.parse(filePath);
|
||||||
const root = parsedPath.root;
|
const root = parsedPath.root;
|
||||||
const separator = path.sep;
|
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
|
// Handle cases with no segments after root (e.g., "/", "C:\") or only one segment
|
||||||
if (segments.length <= 1) {
|
if (segments.length <= 1) {
|
||||||
// Fall back to simple start/end truncation for very short paths or single segments
|
// Fall back to simple start/end truncation for very short paths or single segments
|
||||||
const keepLen = Math.floor((maxLen - 3) / 2);
|
return simpleTruncate();
|
||||||
// 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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstDir = segments[0];
|
const firstDir = segments[0];
|
||||||
const lastSegment = segments[segments.length - 1];
|
const lastSegment = segments[segments.length - 1];
|
||||||
const startComponent = root + firstDir;
|
const startComponent = root + firstDir;
|
||||||
|
|
||||||
const endPartSegments: string[] = [];
|
const endPartSegments = [lastSegment];
|
||||||
// Base length: separator + "..." + lastDir
|
let endPartLength = lastSegment.length;
|
||||||
let currentLength = separator.length + lastSegment.length;
|
|
||||||
|
|
||||||
// Iterate backwards through segments (excluding the first one)
|
// Iterate backwards through the middle segments
|
||||||
for (let i = segments.length - 2; i >= 0; i--) {
|
for (let i = segments.length - 2; i > 0; i--) {
|
||||||
const segment = segments[i];
|
const segment = segments[i];
|
||||||
// Length needed if we add this segment: current + separator + segment
|
const newLength =
|
||||||
const lengthWithSegment = currentLength + separator.length + segment.length;
|
startComponent.length +
|
||||||
|
separator.length +
|
||||||
|
3 + // for "..."
|
||||||
|
separator.length +
|
||||||
|
endPartLength +
|
||||||
|
separator.length +
|
||||||
|
segment.length;
|
||||||
|
|
||||||
if (lengthWithSegment <= maxLen) {
|
if (newLength <= maxLen) {
|
||||||
endPartSegments.unshift(segment); // Add to the beginning of the end part
|
endPartSegments.unshift(segment);
|
||||||
currentLength = lengthWithSegment;
|
endPartLength += separator.length + segment.length;
|
||||||
} else {
|
} else {
|
||||||
break;
|
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) {
|
const separatorsCount = endPartSegments.length + 1;
|
||||||
return result;
|
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
|
const minLengths = components.map((component, index) => {
|
||||||
result = startComponent + separator + result;
|
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) {
|
if (result.length > maxLen) {
|
||||||
return '...' + result.substring(result.length - maxLen - 3);
|
return trailingFallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
Reference in New Issue
Block a user