fix(app): align workspace file tree rows

Keep the Files and Changes trees on shared icon, label, and trailing rails while applying the directory-chevron optical adjustments at the owning row.
This commit is contained in:
Mohamed Boudra
2026-08-03 23:47:29 +02:00
parent a361d1cb2f
commit ec00d57e01
5 changed files with 108 additions and 28 deletions
@@ -494,25 +494,34 @@ test("workspace file panes keep their controls on shared alignment rails", async
const fileHoverColor = await fileRow.evaluate((row) => getComputedStyle(row).backgroundColor); const fileHoverColor = await fileRow.evaluate((row) => getComputedStyle(row).backgroundColor);
expect(fileHoverColor).toBe(folderHoverColor); expect(fileHoverColor).toBe(folderHoverColor);
const [folderStat, fileStat, optionsChevron, explorerCloseIcon, diffModeLabel] = const diffFolderName = folderRow.getByText("src", { exact: true });
const diffFileName = fileRow.getByText("use-mounted-tab-set.ts", { exact: true });
const diffFolderIconLocator = folderRow.locator("svg").first();
const diffFileIconLocator = fileRow.locator("svg").first();
const [diffFolderIcon, diffFileIcon, diffFolderNameBounds, diffFileNameBounds] =
await Promise.all([ await Promise.all([
readTextInkBounds(page.getByTestId("diff-folder-src-stat"), "last"), readSvgInkBounds(diffFolderIconLocator),
readTextInkBounds(page.getByTestId("diff-file-0-stat"), "last"), readSvgInkBounds(diffFileIconLocator),
readSvgInkBounds(page.getByTestId("changes-options-menu").locator("svg")), diffFolderName.boundingBox(),
readSvgInkBounds(page.getByTestId("explorer-close").locator("svg")), diffFileName.boundingBox(),
readTextInkBounds(page.getByTestId("changes-diff-status-trigger")),
]); ]);
const expandedFolderChevron = await readSvgInkBounds(folderRow.locator("svg")); expect(diffFolderNameBounds).not.toBeNull();
expect(diffFileNameBounds).not.toBeNull();
const diffTreeIconNameGap = diffFolderNameBounds!.x - diffFolderIcon.right;
expectSameRail(diffTreeIconNameGap, diffFileNameBounds!.x - diffFileIcon.right);
const [folderStat, fileStat, optionsChevron, explorerCloseIcon] = await Promise.all([
readTextInkBounds(page.getByTestId("diff-folder-src-stat"), "last"),
readTextInkBounds(page.getByTestId("diff-file-0-stat"), "last"),
readSvgInkBounds(page.getByTestId("changes-options-menu").locator("svg")),
readSvgInkBounds(page.getByTestId("explorer-close").locator("svg")),
]);
expectSameRail(folderStat.right, fileStat.right); expectSameRail(folderStat.right, fileStat.right);
expectSameRail(flatFileStat.right, fileStat.right); expectSameRail(flatFileStat.right, fileStat.right);
expectSameRail(fileStat.right, explorerCloseIcon.right); expectSameRail(fileStat.right, explorerCloseIcon.right);
expectSameRail(fileStat.right, optionsChevron.right); expectSameRail(fileStat.right, optionsChevron.right);
expectSameRail(optionsChevron.right, explorerCloseIcon.right); expectSameRail(optionsChevron.right, explorerCloseIcon.right);
expectSameRail(expandedFolderChevron.left, diffModeLabel.left);
await folderRow.click(); await folderRow.click();
const collapsedFolderChevron = await readSvgInkBounds(folderRow.locator("svg"));
expectSameRail(collapsedFolderChevron.left, diffModeLabel.left);
await folderRow.click(); await folderRow.click();
await dragOverlayScrollbarDown(page, diffScroll); await dragOverlayScrollbarDown(page, diffScroll);
@@ -536,15 +545,49 @@ test("workspace file panes keep their controls on shared alignment rails", async
expect(fileMenuBounds!.y).toBeGreaterThan(fileExplorerRowBounds!.y + 10); expect(fileMenuBounds!.y).toBeGreaterThan(fileExplorerRowBounds!.y + 10);
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([ const directoryRow = page.getByTestId(/^file-explorer-row-\d+$/).filter({ hasText: "src" });
const directoryName = directoryRow.getByText("src", { exact: true });
const [collapsedDirectoryIcon, collapsedDirectoryNameBounds] = await Promise.all([
readSvgInkBounds(directoryRow.locator("svg").first()),
directoryName.boundingBox(),
]);
expect(collapsedDirectoryNameBounds).not.toBeNull();
await directoryRow.click();
const nestedFileRow = page
.getByTestId(/^file-explorer-row-\d+$/)
.filter({ hasText: "use-mounted-tab-set.ts" });
await expect(nestedFileRow).toBeVisible();
const [expandedDirectoryIcon, expandedDirectoryNameBounds, fileIcon, fileNameBounds] =
await Promise.all([
readSvgInkBounds(directoryRow.locator("svg").first()),
directoryName.boundingBox(),
readSvgInkBounds(nestedFileRow.locator("svg").first()),
nestedFileRow.getByText("use-mounted-tab-set.ts", { exact: true }).boundingBox(),
]);
expect(expandedDirectoryNameBounds).not.toBeNull();
expect(fileNameBounds).not.toBeNull();
expect(expandedDirectoryNameBounds!.x).toBeCloseTo(collapsedDirectoryNameBounds!.x, 1);
expectSameRail(
collapsedDirectoryNameBounds!.x - collapsedDirectoryIcon.right,
fileNameBounds!.x - fileIcon.right,
);
expectSameRail(
expandedDirectoryNameBounds!.x - expandedDirectoryIcon.right,
fileNameBounds!.x - fileIcon.right,
);
expectSameRail(diffTreeIconNameGap, fileNameBounds!.x - fileIcon.right);
const readmeRow = page.getByTestId(/^file-explorer-row-\d+$/).filter({ hasText: "README.md" });
const [sortLabel, fileRowIcon, treeBounds, rowBounds] = await Promise.all([
readTextInkBounds(page.getByTestId("files-sort-label")), readTextInkBounds(page.getByTestId("files-sort-label")),
readSvgInkBounds(page.getByTestId("file-explorer-row-0").locator("svg").first()), readSvgInkBounds(readmeRow.locator("svg").first()),
page.getByTestId("file-explorer-tree-scroll").boundingBox(), page.getByTestId("file-explorer-tree-scroll").boundingBox(),
page.getByTestId("file-explorer-row-0").boundingBox(), page.getByTestId("file-explorer-row-0").boundingBox(),
]); ]);
expect(treeBounds).not.toBeNull(); expect(treeBounds).not.toBeNull();
expect(rowBounds).not.toBeNull(); expect(rowBounds).not.toBeNull();
expectSameRail(firstRowIcon.left, sortLabel.left); expectSameRail(fileRowIcon.left, sortLabel.left);
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0); expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0); expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
}); });
@@ -22,6 +22,9 @@ import {
treeRowPaddingLeft, treeRowPaddingLeft,
WORKSPACE_FILE_ROW_TRAILING_PADDING, WORKSPACE_FILE_ROW_TRAILING_PADDING,
WORKSPACE_FILE_ROW_VERTICAL_PADDING, WORKSPACE_FILE_ROW_VERTICAL_PADDING,
WORKSPACE_TREE_ICON_LABEL_GAP,
WORKSPACE_TREE_ICON_SIZE,
WORKSPACE_TREE_LOADING_ICON_SIZE,
} from "@/components/tree-primitives"; } from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { import {
@@ -185,10 +188,15 @@ function TreeRowItem({
<View style={styles.entryIcon}> <View style={styles.entryIcon}>
{(() => { {(() => {
if (!isDirectory) { if (!isDirectory) {
return <MaterialFileIcon fileName={entry.name} size={16} />; return <MaterialFileIcon fileName={entry.name} size={WORKSPACE_TREE_ICON_SIZE} />;
} }
if (loading) { if (loading) {
return <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />; return (
<ThemedLoadingSpinner
size={WORKSPACE_TREE_LOADING_ICON_SIZE}
uniProps={foregroundMutedColorMapping}
/>
);
} }
return <TreeChevron expanded={isExpanded} />; return <TreeChevron expanded={isExpanded} />;
})()} })()}
@@ -1074,10 +1082,14 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: WORKSPACE_TREE_ICON_LABEL_GAP,
minWidth: 0, minWidth: 0,
}, },
entryIcon: { entryIcon: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
alignItems: "center",
justifyContent: "center",
flexShrink: 0, flexShrink: 0,
}, },
entryName: { entryName: {
@@ -12,6 +12,9 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
// drift apart. // drift apart.
export const TREE_INDENT_PER_LEVEL = 16; export const TREE_INDENT_PER_LEVEL = 16;
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5]; export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
export const WORKSPACE_TREE_ICON_SIZE = 16;
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14;
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2];
/** /**
* Trailing glyph rail shared with the explorer X and Changes options chevron. * Trailing glyph rail shared with the explorer X and Changes options chevron.
* The extra 2px is optical: text ink ends inside its layout box, while the * The extra 2px is optical: text ink ends inside its layout box, while the
@@ -25,7 +28,9 @@ export function treeRowPaddingLeft(depth: number): number {
return SPACING[3] + depth * TREE_INDENT_PER_LEVEL; return SPACING[3] + depth * TREE_INDENT_PER_LEVEL;
} }
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundExtraMuted,
});
const ThemedChevronRight = withUnistyles(ChevronRight); const ThemedChevronRight = withUnistyles(ChevronRight);
@@ -65,7 +70,10 @@ export function TreeChevron({ expanded }: { expanded: boolean }) {
: [styles.chevron, styles.chevronCollapsed] : [styles.chevron, styles.chevronCollapsed]
} }
> >
<ThemedChevronRight size={16} uniProps={foregroundMutedIconColorMapping} /> <ThemedChevronRight
size={WORKSPACE_TREE_ICON_SIZE}
uniProps={foregroundExtraMutedIconColorMapping}
/>
</View> </View>
); );
} }
@@ -79,19 +87,19 @@ const styles = StyleSheet.create((theme: Theme) => ({
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
chevron: { chevron: {
width: 16, width: WORKSPACE_TREE_ICON_SIZE,
height: 16, height: WORKSPACE_TREE_ICON_SIZE,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
flexShrink: 0, flexShrink: 0,
}, },
// Lucide's right-pointing path occupies the middle of its square view box. // Lucide leaves space around the chevron path. Align its painted right edge inside the fixed
// Move the painted edge—not the 16px layout slot—onto the tree's glyph rail. // slot; the rotated glyph is wider, so it needs half the optical offset.
chevronCollapsed: { chevronCollapsed: {
left: -4, left: 4,
}, },
chevronExpanded: { chevronExpanded: {
left: -2, left: 2,
transform: [{ rotate: "90deg" }], transform: [{ rotate: "90deg" }],
}, },
})); }));
+9 -2
View File
@@ -14,6 +14,7 @@ import {
treeRowPaddingLeft, treeRowPaddingLeft,
WORKSPACE_FILE_ROW_TRAILING_PADDING, WORKSPACE_FILE_ROW_TRAILING_PADDING,
WORKSPACE_FILE_ROW_VERTICAL_PADDING, WORKSPACE_FILE_ROW_VERTICAL_PADDING,
WORKSPACE_TREE_ICON_LABEL_GAP,
} from "@/components/tree-primitives"; } from "@/components/tree-primitives";
import { type Theme } from "@/styles/theme"; import { type Theme } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -80,7 +81,9 @@ export function DiffFolderRow({
testID={testID ? `${testID}-toggle` : undefined} testID={testID ? `${testID}-toggle` : undefined}
> >
<View style={leftStyle}> <View style={leftStyle}>
<TreeChevron expanded={!collapsed} /> <View style={styles.chevronOpticalOffset}>
<TreeChevron expanded={!collapsed} />
</View>
<Text style={styles.folderName} numberOfLines={1}> <Text style={styles.folderName} numberOfLines={1}>
{displayName} {displayName}
</Text> </Text>
@@ -115,10 +118,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
left: { left: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], gap: WORKSPACE_TREE_ICON_LABEL_GAP,
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
chevronOpticalOffset: {
// The Changes directory chevron reads high beside the folder label.
transform: [{ translateY: 2 }],
},
right: { right: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
+11 -1
View File
@@ -50,6 +50,8 @@ import {
treeRowPaddingLeft, treeRowPaddingLeft,
WORKSPACE_FILE_ROW_TRAILING_PADDING, WORKSPACE_FILE_ROW_TRAILING_PADDING,
WORKSPACE_FILE_ROW_VERTICAL_PADDING, WORKSPACE_FILE_ROW_VERTICAL_PADDING,
WORKSPACE_TREE_ICON_LABEL_GAP,
WORKSPACE_TREE_ICON_SIZE,
} from "@/components/tree-primitives"; } from "@/components/tree-primitives";
import { MaterialFileIcon } from "@/components/material-file-icon"; import { MaterialFileIcon } from "@/components/material-file-icon";
import { FileChangeIcon } from "@/components/file-change-icon"; import { FileChangeIcon } from "@/components/file-change-icon";
@@ -1021,7 +1023,10 @@ const DiffFileHeader = memo(function DiffFileHeader({
const fileName = file.path.split("/").pop() ?? file.path; const fileName = file.path.split("/").pop() ?? file.path;
const headerContent = ( const headerContent = (
<> <>
<View ref={dragSourceRef} style={styles.fileHeaderLeft}> <View
ref={dragSourceRef}
style={showDir ? styles.fileHeaderLeft : [styles.fileHeaderLeft, styles.fileHeaderLeftTree]}
>
{showDir ? null : ( {showDir ? null : (
<View style={styles.fileIcon}> <View style={styles.fileIcon}>
<MaterialFileIcon fileName={fileName} size={16} /> <MaterialFileIcon fileName={fileName} size={16} />
@@ -3156,6 +3161,9 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
fileHeaderLeftTree: {
gap: WORKSPACE_TREE_ICON_LABEL_GAP,
},
fileHeaderRight: { fileHeaderRight: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -3163,6 +3171,8 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0, flexShrink: 0,
}, },
fileIcon: { fileIcon: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
flexShrink: 0, flexShrink: 0,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",