diff --git a/packages/app/e2e/browser/workspace-archive-undo.spec.ts b/packages/app/e2e/browser/workspace-archive-undo.spec.ts index 9bf8e4cafd..6606a8a3c5 100644 --- a/packages/app/e2e/browser/workspace-archive-undo.spec.ts +++ b/packages/app/e2e/browser/workspace-archive-undo.spec.ts @@ -19,7 +19,9 @@ import { expectDirectoryPresence, expectHostWorkspaceSettledActive, outlastUndoWindow, + pressArchiveShortcut, pressUndoShortcut, + resizeSidebarToMinimum, rowArchiveButton, rowKebab, undoButton, @@ -84,7 +86,64 @@ test.describe("Workspace archive undo", () => { rowArchiveButton(page, worktree.workspaceId).boundingBox(), rowKebab(page, worktree.workspaceId).boundingBox(), ]); - expect(buttonBox && kebabBox && buttonBox.x + buttonBox.width <= kebabBox.x).toBe(true); + if (!buttonBox || !kebabBox) throw new Error("Row actions are not laid out"); + expect(kebabBox.x - (buttonBox.x + buttonBox.width)).toBeGreaterThanOrEqual(8); + }); + + test("the archived toast sits over the sidebar above its footer, and only Undo presses", async ({ + page, + }) => { + const worktree = await seedWorktree(page, "archive-toast-sidebar"); + + await archiveFromRowButton(page, worktree.workspaceId); + + const [toastBox, footerBox] = await Promise.all([ + page.getByTestId("workspace-archived-toast").boundingBox(), + page.getByTestId("sidebar-footer").boundingBox(), + ]); + if (!toastBox || !footerBox) throw new Error("Toast or footer is not laid out"); + expect(toastBox.x).toBeGreaterThanOrEqual(footerBox.x); + expect(toastBox.x + toastBox.width).toBeLessThanOrEqual(footerBox.x + footerBox.width); + expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(footerBox.y); + expect(footerBox.y - (toastBox.y + toastBox.height)).toBeLessThanOrEqual(16); + + await expect(page.getByTestId("app-toast-message")).toHaveCSS("cursor", "auto"); + await expect(undoButton(page)).toHaveCSS("cursor", "pointer"); + }); + + test("the archived toast keeps Undo inside the card at the narrowest sidebar", async ({ + page, + }) => { + const worktree = await seedWorktree(page, "archive-toast-narrow"); + await resizeSidebarToMinimum(page); + + await archiveFromRowButton(page, worktree.workspaceId); + + const [toastBox, undoBox] = await Promise.all([ + page.getByTestId("workspace-archived-toast").boundingBox(), + undoButton(page).boundingBox(), + ]); + if (!toastBox || !undoBox) throw new Error("Toast is not laid out"); + expect(undoBox.x + undoBox.width).toBeLessThanOrEqual(toastBox.x + toastBox.width); + expect(undoBox.y + undoBox.height).toBeLessThanOrEqual(toastBox.y + toastBox.height); + }); + + test("archiving by shortcut with the sidebar collapsed shows the toast over the app", async ({ + page, + }) => { + const worktree = await seedWorktree(page, "archive-toast-collapsed"); + await workspaceRow(page, worktree.workspaceId).click(); + await page.keyboard.press("ControlOrMeta+b"); + await expect(workspaceRow(page, worktree.workspaceId)).not.toBeInViewport(); + + page.once("dialog", (dialog) => void dialog.accept()); + await pressArchiveShortcut(page); + + const toast = page.getByTestId("workspace-archived-toast"); + await expect(toast).toBeInViewport(); + const toastBox = await toast.boundingBox(); + if (!toastBox) throw new Error("Toast is not laid out"); + expect(toastBox.y).toBeLessThan(page.viewportSize()!.height / 2); }); test("Cmd/Ctrl+Z right after archiving keeps the worktree", async ({ page }) => { diff --git a/packages/app/e2e/support/helpers/workspace-archive-undo.ts b/packages/app/e2e/support/helpers/workspace-archive-undo.ts index 0eadc58644..54516b9d41 100644 --- a/packages/app/e2e/support/helpers/workspace-archive-undo.ts +++ b/packages/app/e2e/support/helpers/workspace-archive-undo.ts @@ -10,6 +10,8 @@ import { expectWorkspaceAbsentFromSidebar } from "./sidebar"; type DaemonClient = Awaited>; const UNDO_SHORTCUT = process.platform === "darwin" ? "Meta+Z" : "Control+Z"; +const ARCHIVE_SHORTCUT = + process.platform === "darwin" ? "Meta+Shift+Backspace" : "Control+Shift+Backspace"; /** The archived toast's lifetime, from `WORKSPACE_ARCHIVE_UNDO_WINDOW_MS`. */ const UNDO_WINDOW_MS = 6000; @@ -46,6 +48,25 @@ export async function archiveFromRowButton(page: Page, workspaceId: string): Pro await expect(undoButton(page)).toBeVisible(); } +/** Drags the sidebar's edge past its minimum, leaving it as narrow as it goes. */ +export async function resizeSidebarToMinimum(page: Page): Promise { + const handle = await page.getByTestId("left-sidebar-resize-handle").boundingBox(); + if (!handle) throw new Error("Sidebar resize handle is not laid out"); + const y = handle.y + handle.height / 2; + await page.mouse.move(handle.x + handle.width / 2, y); + await page.mouse.down(); + await page.mouse.move(handle.x - 30, y, { steps: 8 }); + await page.mouse.move(10, y, { steps: 16 }); + await page.mouse.up(); + await expect + .poll(async () => (await page.getByTestId("sidebar-footer").boundingBox())?.width) + .toBeLessThanOrEqual(200); +} + +export async function pressArchiveShortcut(page: Page): Promise { + await page.keyboard.press(ARCHIVE_SHORTCUT); +} + export async function pressUndoShortcut(page: Page): Promise { await page.keyboard.press(UNDO_SHORTCUT); } diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 7504b175aa..2adc95e7f6 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -39,6 +39,7 @@ import { import { HostPicker } from "@/components/hosts/host-picker"; import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu"; import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; +import { SidebarToastRegion } from "@/components/sidebar/sidebar-toast-region"; import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows"; import { SidebarFooterIconHighlight } from "@/components/sidebar/sidebar-footer-icon-highlight"; import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu"; @@ -644,31 +645,33 @@ function MobileSidebar({ - {isInitialLoad && !hasActiveHostFilter ? ( - - ) : ( - - )} + + {isInitialLoad && !hasActiveHostFilter ? ( + + ) : ( + + )} + - {isInitialLoad && !hasActiveHostFilter ? ( - - ) : ( - - )} + + {isInitialLoad && !hasActiveHostFilter ? ( + + ) : ( + + )} + diff --git a/packages/app/src/components/sidebar/sidebar-toast-region.tsx b/packages/app/src/components/sidebar/sidebar-toast-region.tsx new file mode 100644 index 0000000000..ef8c8ffdc9 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-toast-region.tsx @@ -0,0 +1,48 @@ +import { useEffect, useMemo, useRef, type ReactNode } from "react"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { ToastViewport, useToastHost, type ToastApi } from "@/components/toast-host"; +import { ToastApiProvider, useToast } from "@/contexts/toast-context"; + +/** + * Shows toasts raised by the sidebar's list over its bottom edge, just above the footer. + * + * A collapsed sidebar stays mounted and its rows still handle shortcuts, so while it is hidden + * their toasts go to the app's host instead. The sidebar's API stays stable across open and close + * so that toggling the panel doesn't re-render every row. + */ +export function SidebarToastRegion({ active, children }: { active: boolean; children: ReactNode }) { + const appToast = useToast(); + const { api: sidebarToast, toast, dismiss } = useToastHost(); + const activeRef = useRef(active); + + useEffect(() => { + activeRef.current = active; + // A toast must not outlive its visibility: its dismissal is what ends an archive's undo. + return active ? dismiss : undefined; + }, [active, dismiss]); + + const api = useMemo(() => { + const visibleHost = () => (activeRef.current ? sidebarToast : appToast); + return { + show: (content, options) => visibleHost().show(content, options), + copied: (label) => visibleHost().copied(label), + error: (message) => visibleHost().error(message), + }; + }, [appToast, sidebarToast]); + + return ( + + {children} + + + ); +} + +const styles = StyleSheet.create({ + region: { + position: "relative", + flex: 1, + minHeight: 0, + }, +}); diff --git a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx index f1c2fabcd1..ac872ffbd8 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx @@ -74,7 +74,11 @@ const pinLeadingIcon = ; /** Pointer-only, so it is always the kebab's pointer size. */ -export const ARCHIVE_BUTTON_SIZE = 18; +const ARCHIVE_BUTTON_SIZE = 18; +/** Keeps a press aimed at the kebab from landing on the one-click archive. */ +const ARCHIVE_BUTTON_KEBAB_GAP = 8; +/** The width the archive button and its gap take up left of the kebab. */ +export const ARCHIVE_BUTTON_FOOTPRINT = ARCHIVE_BUTTON_SIZE + ARCHIVE_BUTTON_KEBAB_GAP; const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping); const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping); @@ -482,7 +486,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", borderRadius: 4, - marginLeft: 2, // Center the touch box on the 20px title line it is anchored to. marginTop: bySizing({ touch: -6, pointer: 0 }), // MoreVertical paints only around the center of its SVG. Keep the padded hit box, but @@ -493,10 +496,11 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, // The kebab's box without its offsets: the archive glyph fills its view box, so it needs - // none of the nudging the three dots do. The kebab's marginLeft is the gap between them. + // none of the nudging the three dots do. archiveButton: { width: ARCHIVE_BUTTON_SIZE, height: ARCHIVE_BUTTON_SIZE, + marginRight: ARCHIVE_BUTTON_KEBAB_GAP, alignItems: "center", justifyContent: "center", borderRadius: 4, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index aef846c4a3..eda162d52c 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -31,7 +31,7 @@ import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sideba import { DensityIcon } from "@/components/ui/density-icon"; import { SCRIM_WIDTH, TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { - ARCHIVE_BUTTON_SIZE, + ARCHIVE_BUTTON_FOOTPRINT, SidebarWorkspaceArchiveButton, } from "@/components/sidebar/sidebar-workspace-menu"; import { useWorkspaceLabelDefinitions } from "@/workspace-labels"; @@ -446,9 +446,6 @@ export function SidebarWorkspaceTrailingActionBase({ ); } -// The archive button plus the kebab's marginLeft, which is the gap between them. -const ARCHIVE_BUTTON_SCRIM_WIDTH = ARCHIVE_BUTTON_SIZE + 2; - export function SidebarWorkspaceTrailingActionOverlay({ visible, scrimBackdrop, @@ -473,7 +470,7 @@ export function SidebarWorkspaceTrailingActionOverlay({ {scrimBackdrop ? ( ) : null} diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 81020210d9..7be8273a79 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -4,14 +4,7 @@ import { } from "@/components/floating-header"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; -import { - Platform, - Pressable, - StyleSheet as NativeStyleSheet, - Text, - ToastAndroid, - View, -} from "react-native"; +import { Platform, StyleSheet as NativeStyleSheet, Text, ToastAndroid, View } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; @@ -59,7 +52,7 @@ export interface ToastApi { error: (message: string) => void; } -type ToastViewportPlacement = "app-shell" | "panel"; +type ToastViewportPlacement = "app-shell" | "panel" | "sidebar"; const DEFAULT_DURATION_MS = 2200; const noopDismiss: DismissToast = () => {}; @@ -221,11 +214,17 @@ export function ToastViewport({ const content = ( - + {toast ? ( - + ) : null} @@ -250,12 +249,12 @@ export function ToastViewport({ function ToastCard({ toast, - onHoverIn, - onHoverOut, + onPointerEnter, + onPointerLeave, }: { toast: ToastState; - onHoverIn: () => void; - onHoverOut: () => void; + onPointerEnter: () => void; + onPointerLeave: () => void; }) { let defaultIcon: ReactNode = null; if (toast.variant === "info") { @@ -270,10 +269,9 @@ function ToastCard({ const icon = toast.icon ?? defaultIcon; return ( - )} - + ); } @@ -300,7 +298,7 @@ const animatedStyles = NativeStyleSheet.create({ }); const styles = StyleSheet.create((theme) => ({ - container: (placement: ToastViewportPlacement, topOffset: number) => ({ + container: (placement: Exclude, topOffset: number) => ({ position: "absolute", left: theme.spacing[4], right: theme.spacing[4], @@ -308,11 +306,23 @@ const styles = StyleSheet.create((theme) => ({ zIndex: OVERLAY_Z.toast, alignItems: "center", }), + sidebarContainer: { + position: "absolute", + left: theme.spacing[2], + right: theme.spacing[2], + bottom: theme.spacing[2], + zIndex: OVERLAY_Z.toast, + alignItems: "center", + }, widthBoundary: { width: "92%", maxWidth: TOAST_MAX_WIDTH, alignItems: "center", }, + sidebarWidthBoundary: { + width: "100%", + alignItems: "center", + }, toast: { alignSelf: "center", maxWidth: "100%", diff --git a/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx b/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx index 584b71dec9..de416b906f 100644 --- a/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx +++ b/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx @@ -29,6 +29,7 @@ export function WorkspaceArchivedToast({ onUndo }: { onUndo: () => void }) { const styles = StyleSheet.create((theme) => ({ row: { flexDirection: "row", + flexWrap: "wrap", alignItems: "center", gap: theme.spacing[2], },