mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 09:54:40 +00:00
fix(app): show the archive undo toast in the sidebar, with only Undo pressable (#9104)
* fix(app): show the archive undo toast in the sidebar, with only Undo pressable The archive button sat 2px from the kebab, so a press aimed at the menu could archive the workspace. The toast card was a Pressable used only to pause on hover, which made the whole card look clickable. Toasts raised by the workspace list now land above the sidebar footer, next to the row that raised them. A collapsed sidebar stays mounted and its rows still handle the archive shortcut, so while it is hidden those toasts go to the app host. A toast leaves when the sidebar hides because its dismissal is what ends an archive's undo window. * fix(app): show late archive failures after the sidebar unmounts An archive keeps the toast API it started with. Unmounting the sidebar (focus mode, a layout change, a plugin view) left the ref saying it was visible, so a failure arriving afterwards went to the removed host and the user saw nothing. * fix(app): tighten the Undo shortcut and prove late archive errors in a browser The button's gap and the shortcut badge's own inset added up to 12px between the label and the keys. Late archive failures are now covered by a Playwright journey that swaps the sidebar while a real archive request is held, replacing the JSDOM component test. * test(app): prove late archive errors against a real daemon refusal The test answered the held archive request with a response it built itself, so no daemon ever failed. It now releases the held request naming a workspace the daemon has no record of, which is the one archive refusal a test can trigger without patching the daemon.
This commit is contained in:
@@ -18,8 +18,11 @@ import {
|
||||
createGatedTeardown,
|
||||
expectDirectoryPresence,
|
||||
expectHostWorkspaceSettledActive,
|
||||
holdNextArchiveRequest,
|
||||
outlastUndoWindow,
|
||||
pressArchiveShortcut,
|
||||
pressUndoShortcut,
|
||||
resizeSidebarToMinimum,
|
||||
rowArchiveButton,
|
||||
rowKebab,
|
||||
undoButton,
|
||||
@@ -84,7 +87,90 @@ 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");
|
||||
|
||||
const [labelBox, shortcutBox] = await Promise.all([
|
||||
undoButton(page).getByText("Undo", { exact: true }).boundingBox(),
|
||||
undoButton(page).getByText(/Z$/).boundingBox(),
|
||||
]);
|
||||
if (!labelBox || !shortcutBox) throw new Error("Undo button is not laid out");
|
||||
expect(shortcutBox.x - (labelBox.x + labelBox.width)).toBeLessThanOrEqual(8.5);
|
||||
});
|
||||
|
||||
test("an archive that fails after the sidebar was replaced still shows its error", async ({
|
||||
page,
|
||||
}) => {
|
||||
const archive = await holdNextArchiveRequest(page);
|
||||
const worktree = await seedWorktree(page, "archive-late-failure");
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
await archive.waitForHeld();
|
||||
// Crossing the compact breakpoint swaps the sidebar, unmounting the toast's host.
|
||||
await page.setViewportSize({ width: 400, height: 720 });
|
||||
await expect(undoButton(page)).toHaveCount(0);
|
||||
|
||||
archive.releaseForUnknownWorkspace("workspace-the-daemon-never-had");
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(
|
||||
"Workspace not found: workspace-the-daemon-never-had",
|
||||
);
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
|
||||
@@ -4,12 +4,15 @@ import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "../fixtures";
|
||||
import type { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectWorkspaceAbsentFromSidebar } from "./sidebar";
|
||||
|
||||
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
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 +49,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<void> {
|
||||
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<void> {
|
||||
await page.keyboard.press(ARCHIVE_SHORTCUT);
|
||||
}
|
||||
|
||||
export async function pressUndoShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press(UNDO_SHORTCUT);
|
||||
}
|
||||
@@ -76,6 +98,51 @@ export async function expectDirectoryPresence(directory: string, present: boolea
|
||||
await expect.poll(() => existsSync(directory), { timeout: 30_000 }).toBe(present);
|
||||
}
|
||||
|
||||
export interface HeldArchiveRequest {
|
||||
waitForHeld(): Promise<void>;
|
||||
/** Sends the held request to the daemon naming a workspace it has no record of, so it refuses. */
|
||||
releaseForUnknownWorkspace(workspaceId: string): void;
|
||||
}
|
||||
|
||||
/** Keeps the next archive request from reaching the daemon until the test releases it. */
|
||||
export async function holdNextArchiveRequest(page: Page): Promise<HeldArchiveRequest> {
|
||||
let held: { request: SessionRequest; forward: (raw: string) => void } | null = null;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => {
|
||||
const request = parseSessionRequest(message);
|
||||
if (!held && request?.type === "archive_workspace_request") {
|
||||
held = { request, forward: (raw) => server.send(raw) };
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => browser.send(message));
|
||||
});
|
||||
return {
|
||||
waitForHeld: () => expect.poll(() => held !== null).toBe(true),
|
||||
releaseForUnknownWorkspace: (workspaceId) => {
|
||||
if (!held) throw new Error("No archive request is held");
|
||||
held.forward(JSON.stringify({ type: "session", message: { ...held.request, workspaceId } }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionRequest {
|
||||
type: string;
|
||||
requestId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
function parseSessionRequest(message: string | Buffer): SessionRequest | null {
|
||||
try {
|
||||
const envelope = JSON.parse(typeof message === "string" ? message : message.toString("utf8"));
|
||||
return envelope?.type === "session" ? envelope.message : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GatedTeardown {
|
||||
/** The `paseo.json` config whose worktree teardown parks until released. */
|
||||
paseoConfig: Record<string, unknown>;
|
||||
|
||||
@@ -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({
|
||||
<SidebarSeparator />
|
||||
|
||||
<SidebarViewSlot dismiss={handleWorkspacePress}>
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
<SidebarToastRegion active={active}>
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
</SidebarToastRegion>
|
||||
</SidebarViewSlot>
|
||||
|
||||
<SidebarFooter
|
||||
@@ -842,31 +845,33 @@ function DesktopSidebar({
|
||||
</View>
|
||||
|
||||
<SidebarViewSlot dismiss={closeSidebar}>
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
onWorkspacePress={closeSidebar}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
<SidebarToastRegion active={active}>
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
workspaceGroups={workspaceGroups}
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
onWorkspacePress={closeSidebar}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
</SidebarToastRegion>
|
||||
</SidebarViewSlot>
|
||||
|
||||
<SidebarCalloutSlot />
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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;
|
||||
return () => {
|
||||
// An archive holds on to this API, so a failure that lands later needs a host on screen.
|
||||
activeRef.current = false;
|
||||
// A toast must not outlive its visibility: its dismissal is what ends an archive's undo.
|
||||
dismiss();
|
||||
};
|
||||
}, [active, dismiss]);
|
||||
|
||||
const api = useMemo<ToastApi>(() => {
|
||||
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 (
|
||||
<View style={styles.region}>
|
||||
<ToastApiProvider api={api}>{children}</ToastApiProvider>
|
||||
<ToastViewport toast={toast} onDismiss={dismiss} placement="sidebar" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
region: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
});
|
||||
@@ -74,7 +74,11 @@ const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMappin
|
||||
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
/** 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,
|
||||
|
||||
@@ -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 ? (
|
||||
<TrailingActionScrim
|
||||
backdrop={scrimBackdrop}
|
||||
width={onArchiveButtonPress ? SCRIM_WIDTH + ARCHIVE_BUTTON_SCRIM_WIDTH : SCRIM_WIDTH}
|
||||
width={onArchiveButtonPress ? SCRIM_WIDTH + ARCHIVE_BUTTON_FOOTPRINT : SCRIM_WIDTH}
|
||||
testID="sidebar-workspace-trailing-scrim"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -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 = (
|
||||
<View
|
||||
style={styles.container(placement, topOffset)}
|
||||
style={
|
||||
placement === "sidebar" ? styles.sidebarContainer : styles.container(placement, topOffset)
|
||||
}
|
||||
pointerEvents="box-none"
|
||||
collapsable={false}
|
||||
>
|
||||
<View style={styles.widthBoundary} pointerEvents="box-none" collapsable={false}>
|
||||
<View
|
||||
style={placement === "sidebar" ? styles.sidebarWidthBoundary : styles.widthBoundary}
|
||||
pointerEvents="box-none"
|
||||
collapsable={false}
|
||||
>
|
||||
{toast ? (
|
||||
<Animated.View
|
||||
entering={toastEntering}
|
||||
@@ -234,7 +233,7 @@ export function ToastViewport({
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
accessibilityRole="alert"
|
||||
>
|
||||
<ToastCard toast={toast} onHoverIn={pauseDismiss} onHoverOut={resumeDismiss} />
|
||||
<ToastCard toast={toast} onPointerEnter={pauseDismiss} onPointerLeave={resumeDismiss} />
|
||||
</Animated.View>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
accessible={false}
|
||||
onHoverIn={onHoverIn}
|
||||
onHoverOut={onHoverOut}
|
||||
<View
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
style={[
|
||||
styles.toast,
|
||||
toast.variant === "info" ? styles.toastInfo : null,
|
||||
@@ -291,7 +289,7 @@ function ToastCard({
|
||||
{toast.content}
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -300,7 +298,7 @@ const animatedStyles = NativeStyleSheet.create({
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: (placement: ToastViewportPlacement, topOffset: number) => ({
|
||||
container: (placement: Exclude<ToastViewportPlacement, "sidebar">, 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%",
|
||||
|
||||
@@ -9,7 +9,10 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
export function WorkspaceArchivedToast({ onUndo }: { onUndo: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const undoKeys = useShortcutKeys("undo-archive-workspace");
|
||||
const undoShortcut = useMemo(() => (undoKeys ? <Shortcut chord={undoKeys} /> : null), [undoKeys]);
|
||||
const undoShortcut = useMemo(
|
||||
() => (undoKeys ? <Shortcut chord={undoKeys} style={styles.shortcut} /> : null),
|
||||
[undoKeys],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
@@ -29,9 +32,14 @@ export function WorkspaceArchivedToast({ onUndo }: { onUndo: () => void }) {
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
// Cancels the badge's own inset, which would otherwise add to the button's gap.
|
||||
shortcut: {
|
||||
marginLeft: -theme.spacing[1],
|
||||
},
|
||||
message: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
|
||||
Reference in New Issue
Block a user