fix(app): restore file tree layout

Restore the established tree rails while retaining file actions. Keep Escape local to generic editable fields so cancelling a file draft cannot interrupt an agent.
This commit is contained in:
Mohamed Boudra
2026-08-12 15:54:11 +02:00
parent 124e512b58
commit 474b4e70b4
9 changed files with 101 additions and 68 deletions
+13 -2
View File
@@ -553,8 +553,19 @@ test("changes diff switches between flat and tree file lists", async ({ page })
"none", "none",
); );
await expect(page.getByTestId("diff-file-0")).toBeVisible(); await expect(page.getByTestId("diff-file-0")).toBeVisible();
await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(2); await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1);
await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1); await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1);
const folderToggleBounds = await page.getByTestId("diff-folder-src-toggle").boundingBox();
const folderChevronBounds = await page
.getByTestId("diff-folder-src-toggle")
.locator("svg")
.boundingBox();
expect(folderToggleBounds).not.toBeNull();
expect(folderChevronBounds).not.toBeNull();
expect(folderChevronBounds!.y + folderChevronBounds!.height / 2).toBeCloseTo(
folderToggleBounds!.y + folderToggleBounds!.height / 2,
0,
);
const folderLabelBounds = await page const folderLabelBounds = await page
.getByTestId("diff-folder-src") .getByTestId("diff-folder-src")
.getByText("src", { exact: true }) .getByText("src", { exact: true })
@@ -565,7 +576,7 @@ test("changes diff switches between flat and tree file lists", async ({ page })
.boundingBox(); .boundingBox();
expect(folderLabelBounds).not.toBeNull(); expect(folderLabelBounds).not.toBeNull();
expect(fileLabelBounds).not.toBeNull(); expect(fileLabelBounds).not.toBeNull();
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(14, 0); expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(16, 0);
const folderToggle = page.getByTestId("diff-folder-src-toggle"); const folderToggle = page.getByTestId("diff-folder-src-toggle");
await folderToggle.click(); await folderToggle.click();
@@ -37,6 +37,7 @@ import {
import { seedWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace } from "../support/helpers/seed-client";
import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures"; import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures";
import { getServerId } from "../support/helpers/server-id"; import { getServerId } from "../support/helpers/server-id";
import { openFileExplorer } from "../support/helpers/file-explorer";
const MINIMAL_PNG = Buffer.from( const MINIMAL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
@@ -294,6 +295,28 @@ test.describe("Composer attachments", () => {
} }
}); });
test("Escape cancels file creation without interrupting the running agent", async ({ page }) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
prefix: "file-create-escape-",
model: "one-minute-stream",
prompt: "Stay running while a file draft is cancelled.",
});
try {
await openFileExplorer(page);
await page.getByTestId("files-new-file").click();
const nameInput = page.getByTestId("file-explorer-name-input");
await expect(nameInput).toBeVisible();
await nameInput.press("Escape");
await expect(nameInput).toBeHidden();
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
} finally {
await agent.cleanup();
}
});
test("composer is locked while new workspace agent is being created", async ({ page }) => { test("composer is locked while new workspace agent is being created", async ({ page }) => {
test.setTimeout(120_000); test.setTimeout(120_000);
const serverId = getServerId(); const serverId = getServerId();
@@ -124,6 +124,14 @@ test("creates, renames, copies, and deletes entries through the file explorer",
await page.getByTestId("files-new-folder").click(); await page.getByTestId("files-new-folder").click();
await expect(nameInput).toBeVisible(); await expect(nameInput).toBeVisible();
const draftRow = nameInput.locator("xpath=..");
const placeholderColor = await nameInput.evaluate(
(input) => getComputedStyle(input, "::placeholder").color,
);
const extraMutedChevronColor = await draftRow
.locator("svg")
.evaluate((icon) => getComputedStyle(icon).stroke);
expect(placeholderColor).toBe(extraMutedChevronColor);
await nameInput.press("Tab"); await nameInput.press("Tab");
await expect(nameInput).toBeHidden(); await expect(nameInput).toBeHidden();
@@ -189,18 +197,38 @@ test("creates, renames, copies, and deletes entries through the file explorer",
const fileRow = entry("created.txt").locator( const fileRow = entry("created.txt").locator(
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]", "xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
); );
await expect(folderRow.locator("svg")).toHaveCount(2); await expect(folderRow.locator("svg")).toHaveCount(1);
await expect(fileRow.locator("svg")).toHaveCount(1); await expect(fileRow.locator("svg")).toHaveCount(1);
const gitRow = entry(".git").locator( const gitRow = entry(".git").locator(
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]", "xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
); );
const collapsedChevronBounds = await gitRow.locator("svg").first().boundingBox(); const collapsedChevronBounds = await gitRow.locator("svg").first().boundingBox();
const expandedChevronBounds = await folderRow.locator("svg").first().boundingBox(); const expandedChevronBounds = await folderRow.locator("svg").first().boundingBox();
const collapsedChevronSlotBounds = await gitRow
.locator("svg")
.first()
.locator("xpath=../..")
.boundingBox();
const expandedChevronSlotBounds = await folderRow
.locator("svg")
.first()
.locator("xpath=../..")
.boundingBox();
expect(collapsedChevronBounds).not.toBeNull(); expect(collapsedChevronBounds).not.toBeNull();
expect(expandedChevronBounds).not.toBeNull(); expect(expandedChevronBounds).not.toBeNull();
expect(collapsedChevronSlotBounds).not.toBeNull();
expect(expandedChevronSlotBounds).not.toBeNull();
const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2; const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2;
const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2; const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2;
expect(collapsedChevronCenter).toBeCloseTo(expandedChevronCenter, 0); expect(collapsedChevronCenter).toBeCloseTo(expandedChevronCenter, 0);
expect(collapsedChevronCenter).toBeCloseTo(
collapsedChevronSlotBounds!.x + collapsedChevronSlotBounds!.width / 2,
0,
);
expect(expandedChevronCenter).toBeCloseTo(
expandedChevronSlotBounds!.x + expandedChevronSlotBounds!.width / 2,
0,
);
const folderLabelBounds = await entry("folder").boundingBox(); const folderLabelBounds = await entry("folder").boundingBox();
const fileLabelBounds = await entry("created.txt").boundingBox(); const fileLabelBounds = await entry("created.txt").boundingBox();
expect(folderLabelBounds).not.toBeNull(); expect(folderLabelBounds).not.toBeNull();
@@ -27,15 +27,7 @@ import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { import { ChevronDown, Eye, EyeOff, FilePlus, FolderPlus, RotateCw } from "lucide-react-native";
ChevronDown,
Eye,
EyeOff,
FilePlus,
Folder,
FolderPlus,
RotateCw,
} from "lucide-react-native";
import { MaterialFileIcon } from "@/components/material-file-icon"; import { MaterialFileIcon } from "@/components/material-file-icon";
import { import {
TreeChevron, TreeChevron,
@@ -89,7 +81,6 @@ const SORT_OPTIONS: { value: SortOption }[] = [
]; ];
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedFolder = withUnistyles(Folder);
const foregroundMutedColorMapping = (theme: Theme) => ({ const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}); });
@@ -213,12 +204,9 @@ function EntryNameInputRow({
<View style={[styles.entryRow, { paddingLeft: treeRowPaddingLeft(depth) }]}> <View style={[styles.entryRow, { paddingLeft: treeRowPaddingLeft(depth) }]}>
<TreeIndentGuides depth={depth} /> <TreeIndentGuides depth={depth} />
<View style={styles.entryInfo}> <View style={styles.entryInfo}>
<View style={styles.entryChevron}>
{kind === "directory" ? <TreeChevron expanded={false} /> : null}
</View>
<View style={styles.entryIcon}> <View style={styles.entryIcon}>
{kind === "directory" ? ( {kind === "directory" ? (
<ThemedFolder size={WORKSPACE_TREE_ICON_SIZE} uniProps={foregroundMutedColorMapping} /> <TreeChevron expanded={false} />
) : ( ) : (
<MaterialFileIcon fileName={name || "untitled"} size={WORKSPACE_TREE_ICON_SIZE} /> <MaterialFileIcon fileName={name || "untitled"} size={WORKSPACE_TREE_ICON_SIZE} />
)} )}
@@ -237,6 +225,7 @@ function EntryNameInputRow({
} }
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
placeholderTextColor={styles.draftPlaceholder.color}
style={styles.draftInput} style={styles.draftInput}
selectTextOnFocus={Boolean(initialName)} selectTextOnFocus={Boolean(initialName)}
testID="file-explorer-name-input" testID="file-explorer-name-input"
@@ -381,15 +370,9 @@ function TreeRowItem({
> >
<TreeIndentGuides depth={depth} /> <TreeIndentGuides depth={depth} />
<View ref={dragSourceRef} style={styles.entryInfo}> <View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryChevron}>
{isDirectory ? <DirectoryChevronIcon loading={loading} expanded={isExpanded} /> : null}
</View>
<View style={styles.entryIcon}> <View style={styles.entryIcon}>
{isDirectory ? ( {isDirectory ? (
<ThemedFolder <DirectoryChevronIcon loading={loading} expanded={isExpanded} />
size={WORKSPACE_TREE_ICON_SIZE}
uniProps={foregroundMutedColorMapping}
/>
) : ( ) : (
<MaterialFileIcon fileName={entry.name} size={WORKSPACE_TREE_ICON_SIZE} /> <MaterialFileIcon fileName={entry.name} size={WORKSPACE_TREE_ICON_SIZE} />
)} )}
@@ -1772,13 +1755,6 @@ const styles = StyleSheet.create((theme) => ({
gap: WORKSPACE_TREE_ICON_LABEL_GAP, gap: WORKSPACE_TREE_ICON_LABEL_GAP,
minWidth: 0, minWidth: 0,
}, },
entryChevron: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
entryIcon: { entryIcon: {
width: WORKSPACE_TREE_ICON_SIZE, width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE, height: WORKSPACE_TREE_ICON_SIZE,
@@ -1799,6 +1775,9 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: 0, paddingVertical: 0,
paddingHorizontal: 0, paddingHorizontal: 0,
}, },
draftPlaceholder: {
color: theme.colors.foregroundExtraMuted,
},
contextMetaBlock: { contextMetaBlock: {
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
}, },
@@ -10,22 +10,22 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
// paths) render different data, but their ROWS should look identical — same // paths) render different data, but their ROWS should look identical — same
// indentation, guide lines, and chevron. Keep those here so the two trees can't // indentation, guide lines, and chevron. Keep those here so the two trees can't
// drift apart. // drift apart.
export const TREE_INDENT_PER_LEVEL = 14; export const TREE_INDENT_PER_LEVEL = 16;
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1]; export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
export const WORKSPACE_TREE_ICON_SIZE = 14; export const WORKSPACE_TREE_ICON_SIZE = 16;
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 12; export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14;
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[1]; 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
* header icons' strokes extend to theirs. * header icons' strokes extend to theirs.
*/ */
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[3] + 2; export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2;
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers /** Left padding for a tree row at `depth`. Shared by folder rows and file headers
* in the Changes tree so their indentation can't drift apart. */ * in the Changes tree so their indentation can't drift apart. */
export function treeRowPaddingLeft(depth: number): number { export function treeRowPaddingLeft(depth: number): number {
return SPACING[2] + depth * TREE_INDENT_PER_LEVEL; return SPACING[3] + depth * TREE_INDENT_PER_LEVEL;
} }
const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({ const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({
@@ -46,7 +46,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) {
key: index, key: index,
style: [ style: [
styles.indentGuide, styles.indentGuide,
inlineUnistylesStyle({ left: SPACING[2] + index * TREE_INDENT_PER_LEVEL + 3 }), inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }),
], ],
})), })),
[depth], [depth],
+3 -19
View File
@@ -1,7 +1,6 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { View, Text, type LayoutChangeEvent, type PressableStateCallbackType } from "react-native"; import { View, Text, type LayoutChangeEvent, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { Folder } from "lucide-react-native";
import { DiffStat } from "@/components/diff-stat"; import { DiffStat } from "@/components/diff-stat";
import { import {
TreeChevron, TreeChevron,
@@ -18,9 +17,6 @@ import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { FileActionsContextMenuContent } from "@/components/file-actions-menu"; import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
const ThemedFolder = withUnistyles(Folder);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
interface DiffFolderRowProps { interface DiffFolderRowProps {
/** full uncompressed directory path — the collapse identity */ /** full uncompressed directory path — the collapse identity */
dirPath: string; dirPath: string;
@@ -145,15 +141,9 @@ export function DiffFolderRow({
testID={testID ? `${testID}-toggle` : undefined} testID={testID ? `${testID}-toggle` : undefined}
> >
<View style={leftStyle}> <View style={leftStyle}>
<View style={styles.chevronOpticalOffset}> <View style={styles.chevronSlot}>
<TreeChevron expanded={!collapsed} /> <TreeChevron expanded={!collapsed} />
</View> </View>
<View style={styles.folderIcon}>
<ThemedFolder
size={WORKSPACE_TREE_ICON_SIZE}
uniProps={foregroundMutedColorMapping}
/>
</View>
<Text style={styles.folderName} numberOfLines={1}> <Text style={styles.folderName} numberOfLines={1}>
{displayName} {displayName}
</Text> </Text>
@@ -204,13 +194,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
chevronOpticalOffset: { chevronSlot: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
alignItems: "center",
justifyContent: "center",
},
folderIcon: {
width: WORKSPACE_TREE_ICON_SIZE, width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE, height: WORKSPACE_TREE_ICON_SIZE,
alignItems: "center", alignItems: "center",
-6
View File
@@ -1161,7 +1161,6 @@ const DiffFileHeader = memo(function DiffFileHeader({
ref={dragSourceRef} ref={dragSourceRef}
style={showDir ? styles.fileHeaderLeft : [styles.fileHeaderLeft, styles.fileHeaderLeftTree]} style={showDir ? styles.fileHeaderLeft : [styles.fileHeaderLeft, styles.fileHeaderLeftTree]}
> >
{showDir ? null : <View style={styles.fileChevronSpacer} />}
{showDir ? null : ( {showDir ? null : (
<View style={styles.fileIcon}> <View style={styles.fileIcon}>
<MaterialFileIcon fileName={fileName} size={WORKSPACE_TREE_ICON_SIZE} /> <MaterialFileIcon fileName={fileName} size={WORKSPACE_TREE_ICON_SIZE} />
@@ -3465,11 +3464,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1], gap: theme.spacing[1],
flexShrink: 0, flexShrink: 0,
}, },
fileChevronSpacer: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
flexShrink: 0,
},
fileIcon: { fileIcon: {
width: WORKSPACE_TREE_ICON_SIZE, width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE, height: WORKSPACE_TREE_ICON_SIZE,
@@ -404,6 +404,13 @@ describe("keyboard-shortcuts", () => {
}, },
); );
it("leaves Escape to an editable field", () => {
expectNoShortcutResolution({
event: { key: "Escape", code: "Escape" },
context: { focusScope: "editable" },
});
});
const nonMatchingCases: NonMatchingShortcutCase[] = [ const nonMatchingCases: NonMatchingShortcutCase[] = [
{ {
name: "does not keep old Mod+Alt+N binding", name: "does not keep old Mod+Alt+N binding",
@@ -76,8 +76,8 @@ interface ShortcutWhen {
terminal?: false; terminal?: false;
/** false = disabled when command center is open */ /** false = disabled when command center is open */
commandCenter?: false; commandCenter?: false;
/** Exact focus scope match */ /** Allowed focus scope or scopes */
focusScope?: KeyboardFocusScope; focusScope?: KeyboardFocusScope | readonly KeyboardFocusScope[];
} }
type ShortcutPayloadDef = type ShortcutPayloadDef =
@@ -1042,7 +1042,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "agent-interrupt", id: "agent-interrupt",
action: "agent.interrupt", action: "agent.interrupt",
combo: "Escape", combo: "Escape",
when: { commandCenter: false, terminal: false }, when: { commandCenter: false, focusScope: ["message-input", "other"] },
preventDefault: false, preventDefault: false,
stopPropagation: false, stopPropagation: false,
help: { help: {
@@ -1215,7 +1215,14 @@ export function matchesKeyboardShortcutContext(
} }
if (when.terminal === false && context.focusScope === "terminal") return false; if (when.terminal === false && context.focusScope === "terminal") return false;
if (when.commandCenter === false && context.commandCenterOpen) return false; if (when.commandCenter === false && context.commandCenterOpen) return false;
if (when.focusScope !== undefined && context.focusScope !== when.focusScope) return false; if (
when.focusScope !== undefined &&
!(typeof when.focusScope === "string"
? context.focusScope === when.focusScope
: when.focusScope.includes(context.focusScope))
) {
return false;
}
return true; return true;
} }