diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 98f69808b..87f41af01 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -553,8 +553,19 @@ test("changes diff switches between flat and tree file lists", async ({ page }) "none", ); 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); + 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 .getByTestId("diff-folder-src") .getByText("src", { exact: true }) @@ -565,7 +576,7 @@ test("changes diff switches between flat and tree file lists", async ({ page }) .boundingBox(); expect(folderLabelBounds).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"); await folderToggle.click(); diff --git a/packages/app/e2e/browser/composer-attachments.spec.ts b/packages/app/e2e/browser/composer-attachments.spec.ts index 26a16f517..acf1941c2 100644 --- a/packages/app/e2e/browser/composer-attachments.spec.ts +++ b/packages/app/e2e/browser/composer-attachments.spec.ts @@ -37,6 +37,7 @@ import { import { seedWorkspace } from "../support/helpers/seed-client"; import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures"; import { getServerId } from "../support/helpers/server-id"; +import { openFileExplorer } from "../support/helpers/file-explorer"; const MINIMAL_PNG = Buffer.from( "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.setTimeout(120_000); const serverId = getServerId(); diff --git a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts index e4643b87e..d8622a616 100644 --- a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts +++ b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts @@ -124,6 +124,14 @@ test("creates, renames, copies, and deletes entries through the file explorer", await page.getByTestId("files-new-folder").click(); 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 expect(nameInput).toBeHidden(); @@ -189,18 +197,38 @@ test("creates, renames, copies, and deletes entries through the file explorer", const fileRow = entry("created.txt").locator( "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); const gitRow = entry(".git").locator( "xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]", ); const collapsedChevronBounds = await gitRow.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(expandedChevronBounds).not.toBeNull(); + expect(collapsedChevronSlotBounds).not.toBeNull(); + expect(expandedChevronSlotBounds).not.toBeNull(); const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2; const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2; 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 fileLabelBounds = await entry("created.txt").boundingBox(); expect(folderLabelBounds).not.toBeNull(); diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 969a1b596..c24268a6d 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -27,15 +27,7 @@ import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles" import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import * as Clipboard from "expo-clipboard"; -import { - ChevronDown, - Eye, - EyeOff, - FilePlus, - Folder, - FolderPlus, - RotateCw, -} from "lucide-react-native"; +import { ChevronDown, Eye, EyeOff, FilePlus, FolderPlus, RotateCw } from "lucide-react-native"; import { MaterialFileIcon } from "@/components/material-file-icon"; import { TreeChevron, @@ -89,7 +81,6 @@ const SORT_OPTIONS: { value: SortOption }[] = [ ]; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); -const ThemedFolder = withUnistyles(Folder); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); @@ -213,12 +204,9 @@ function EntryNameInputRow({ - - {kind === "directory" ? : null} - {kind === "directory" ? ( - + ) : ( )} @@ -237,6 +225,7 @@ function EntryNameInputRow({ } autoCapitalize="none" autoCorrect={false} + placeholderTextColor={styles.draftPlaceholder.color} style={styles.draftInput} selectTextOnFocus={Boolean(initialName)} testID="file-explorer-name-input" @@ -381,15 +370,9 @@ function TreeRowItem({ > - - {isDirectory ? : null} - {isDirectory ? ( - + ) : ( )} @@ -1772,13 +1755,6 @@ const styles = StyleSheet.create((theme) => ({ gap: WORKSPACE_TREE_ICON_LABEL_GAP, minWidth: 0, }, - entryChevron: { - width: WORKSPACE_TREE_ICON_SIZE, - height: WORKSPACE_TREE_ICON_SIZE, - alignItems: "center", - justifyContent: "center", - flexShrink: 0, - }, entryIcon: { width: WORKSPACE_TREE_ICON_SIZE, height: WORKSPACE_TREE_ICON_SIZE, @@ -1799,6 +1775,9 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: 0, paddingHorizontal: 0, }, + draftPlaceholder: { + color: theme.colors.foregroundExtraMuted, + }, contextMetaBlock: { paddingVertical: theme.spacing[1], }, diff --git a/packages/app/src/components/tree-primitives.tsx b/packages/app/src/components/tree-primitives.tsx index 4a0e18a4f..9d3c129d4 100644 --- a/packages/app/src/components/tree-primitives.tsx +++ b/packages/app/src/components/tree-primitives.tsx @@ -10,22 +10,22 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; // 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 // drift apart. -export const TREE_INDENT_PER_LEVEL = 14; -export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1]; -export const WORKSPACE_TREE_ICON_SIZE = 14; -export const WORKSPACE_TREE_LOADING_ICON_SIZE = 12; -export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[1]; +export const TREE_INDENT_PER_LEVEL = 16; +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. * The extra 2px is optical: text ink ends inside its layout box, while the * 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 * in the Changes tree so their indentation can't drift apart. */ 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) => ({ @@ -46,7 +46,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) { key: index, style: [ styles.indentGuide, - inlineUnistylesStyle({ left: SPACING[2] + index * TREE_INDENT_PER_LEVEL + 3 }), + inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }), ], })), [depth], diff --git a/packages/app/src/git/diff-folder-row.tsx b/packages/app/src/git/diff-folder-row.tsx index f32bc6859..be5b76fc7 100644 --- a/packages/app/src/git/diff-folder-row.tsx +++ b/packages/app/src/git/diff-folder-row.tsx @@ -1,7 +1,6 @@ import { useCallback, useMemo } from "react"; import { View, Text, type LayoutChangeEvent, type PressableStateCallbackType } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { Folder } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; import { DiffStat } from "@/components/diff-stat"; import { TreeChevron, @@ -18,9 +17,6 @@ import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; import { FileActionsContextMenuContent } from "@/components/file-actions-menu"; import { isWeb } from "@/constants/platform"; -const ThemedFolder = withUnistyles(Folder); -const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - interface DiffFolderRowProps { /** full uncompressed directory path — the collapse identity */ dirPath: string; @@ -145,15 +141,9 @@ export function DiffFolderRow({ testID={testID ? `${testID}-toggle` : undefined} > - + - - - {displayName} @@ -204,13 +194,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ flex: 1, minWidth: 0, }, - chevronOpticalOffset: { - width: WORKSPACE_TREE_ICON_SIZE, - height: WORKSPACE_TREE_ICON_SIZE, - alignItems: "center", - justifyContent: "center", - }, - folderIcon: { + chevronSlot: { width: WORKSPACE_TREE_ICON_SIZE, height: WORKSPACE_TREE_ICON_SIZE, alignItems: "center", diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index f0756b34d..5c70f61da 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1161,7 +1161,6 @@ const DiffFileHeader = memo(function DiffFileHeader({ ref={dragSourceRef} style={showDir ? styles.fileHeaderLeft : [styles.fileHeaderLeft, styles.fileHeaderLeftTree]} > - {showDir ? null : } {showDir ? null : ( @@ -3465,11 +3464,6 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[1], flexShrink: 0, }, - fileChevronSpacer: { - width: WORKSPACE_TREE_ICON_SIZE, - height: WORKSPACE_TREE_ICON_SIZE, - flexShrink: 0, - }, fileIcon: { width: WORKSPACE_TREE_ICON_SIZE, height: WORKSPACE_TREE_ICON_SIZE, diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 69fd45b8b..9a88975d2 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -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[] = [ { name: "does not keep old Mod+Alt+N binding", diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index bd0afaf44..6653e412d 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -76,8 +76,8 @@ interface ShortcutWhen { terminal?: false; /** false = disabled when command center is open */ commandCenter?: false; - /** Exact focus scope match */ - focusScope?: KeyboardFocusScope; + /** Allowed focus scope or scopes */ + focusScope?: KeyboardFocusScope | readonly KeyboardFocusScope[]; } type ShortcutPayloadDef = @@ -1042,7 +1042,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "agent-interrupt", action: "agent.interrupt", combo: "Escape", - when: { commandCenter: false, terminal: false }, + when: { commandCenter: false, focusScope: ["message-input", "other"] }, preventDefault: false, stopPropagation: false, help: { @@ -1215,7 +1215,14 @@ export function matchesKeyboardShortcutContext( } if (when.terminal === false && context.focusScope === "terminal") 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; }