diff --git a/docs/development.md b/docs/development.md index ecc4627cd..03d638f78 100644 --- a/docs/development.md +++ b/docs/development.md @@ -29,6 +29,7 @@ Root checkout dev is intentionally split across terminals: - **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app. - **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint. - **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied. +- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so. Override knobs: @@ -48,6 +49,16 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports. +### iOS simulator preview service + +Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`. + +The service is designed for concurrent worktrees: it derives a deterministic simulator identity from the worktree path, uses the worktree's assigned `PASEO_PORT`, pins `serve-sim` to that simulator UDID, and only tears down that worktree's helper/simulator state. It must not rely on the globally booted simulator or any fixed Metro port. + +Worktree setup best-effort seeds the generated iOS project and newest native build cache from the source checkout before the service runs. The service still validates the native project by running Expo prebuild and Xcode; the seed only avoids paying all setup/build cost from a cold worktree every time. + +Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows. The browser preview is the user-visible simulator surface. + ### Desktop renderer profiling `npm run dev:desktop` starts Electron with Chromium remote debugging enabled on diff --git a/package-lock.json b/package-lock.json index 2a1382aa4..c632e98a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24193,6 +24193,19 @@ "css-in-js-utils": "^3.1.0" } }, + "node_modules/inspect-webkit": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/inspect-webkit/-/inspect-webkit-0.0.5.tgz", + "integrity": "sha512-584wP/2nJO1LX74nqHP2j0tQzlK9ZTi+D0Z9qeLQjtUR/LCMXQHGX8M0vrsqBwTeGakF7q5GMFpg81nxUYlCvw==", + "dev": true, + "license": "MIT", + "bin": { + "inspect-webkit": "dist/cli.js" + }, + "peerDependencies": { + "typescript": "^5" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -32923,6 +32936,22 @@ "seroval": "^1.0" } }, + "node_modules/serve-sim": { + "version": "0.1.40", + "resolved": "https://registry.npmjs.org/serve-sim/-/serve-sim-0.1.40.tgz", + "integrity": "sha512-32JSUriN/EnNR3zx6fmiOKjuFrbA8VHWR2J3dX8T51Z/xkSmrSLOwaz/3W9lPiCIaF2O3LT4qpAhLkp0f1kVaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "inspect-webkit": "^0.0.5" + }, + "bin": { + "serve-sim": "dist/serve-sim.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/serve-static": { "version": "1.16.3", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", @@ -37049,6 +37078,7 @@ "jsdom": "^20.0.3", "material-icon-theme": "^5.32.0", "playwright": "^1.56.1", + "serve-sim": "^0.1.40", "typescript": "~5.9.2", "vitest": "^4.1.6", "wrangler": "^4.75.0", diff --git a/packages/app/metro.config.cjs b/packages/app/metro.config.cjs index 591792bf9..5a1169d86 100644 --- a/packages/app/metro.config.cjs +++ b/packages/app/metro.config.cjs @@ -78,4 +78,31 @@ config.resolver.resolveRequest = (context, moduleName, platform) => { return resolveWithCustomWebOverlay(context, moduleName, platform); }; +if (process.env.PASEO_SERVE_SIM_PREVIEW === "1") { + const { simMiddleware } = require("serve-sim/middleware"); + const originalEnhanceMiddleware = config.server?.enhanceMiddleware; + config.server = config.server ?? {}; + config.server.enhanceMiddleware = (metroMiddleware, server) => { + const middleware = originalEnhanceMiddleware + ? originalEnhanceMiddleware(metroMiddleware, server) + : metroMiddleware; + const serveSimulator = simMiddleware({ + basePath: "/.sim", + device: process.env.PASEO_SERVE_SIM_DEVICE_UDID, + }); + return (req, res, next) => { + serveSimulator(req, res, (error) => { + if (error) { + if (next) { + next(error); + return; + } + throw error; + } + middleware(req, res, next); + }); + }; + }; +} + module.exports = config; diff --git a/packages/app/package.json b/packages/app/package.json index 21ee49fa7..6d248fe5f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -125,6 +125,7 @@ "jsdom": "^20.0.3", "material-icon-theme": "^5.32.0", "playwright": "^1.56.1", + "serve-sim": "^0.1.40", "typescript": "~5.9.2", "vitest": "^4.1.6", "wrangler": "^4.75.0", diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index a7d881be8..32c00b8d5 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -41,15 +41,20 @@ import { } from "@/contexts/horizontal-scroll-context"; import { SessionProvider } from "@/contexts/session-context"; import { - MOBILE_VISUAL_PANEL_AGENT, - MOBILE_VISUAL_PANEL_AGENT_LIST, SidebarAnimationProvider, useSidebarAnimation, } from "@/contexts/sidebar-animation-context"; import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context"; import { ToastProvider } from "@/contexts/toast-context"; import { VoiceProvider } from "@/contexts/voice-context"; -import { startDaemonIfGateAllows, startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap"; +import { + resolveStartupBlocker, + resolveStartupNavigationReady, + shouldRunStartupGiveUpTimer, + startDaemonIfGateAllows, + startHostRuntimeBootstrap, + type StartupBlocker, +} from "@/app/host-runtime-bootstrap"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { listenToDesktopEvent } from "@/desktop/electron/events"; import { updateDesktopWindowControls } from "@/desktop/electron/window"; @@ -60,7 +65,6 @@ import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source"; import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action"; import { useFaviconStatus } from "@/hooks/use-favicon-status"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; -import { useLatchedBoolean } from "@/hooks/use-latched-boolean"; import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock"; import { useOpenProject } from "@/hooks/use-open-project"; import { useAppSettings } from "@/hooks/use-settings"; @@ -70,7 +74,6 @@ import { polyfillCrypto } from "@/polyfills/crypto"; import { queryClient } from "@/query/query-client"; import { getHostRuntimeStore, - hasConfiguredLocalDaemonOverride, useHostMutations, useHostRuntimeClient, useHosts, @@ -82,6 +85,7 @@ import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; import type { HostProfile } from "@/types/host-connection"; import { resolveActiveHost } from "@/utils/active-host"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; +import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state"; import { buildHostRootRoute, parseHostAgentRouteFromPathname, @@ -103,6 +107,7 @@ export interface HostRuntimeBootstrapState { retry: () => void; hasGivenUpWaitingForHost: boolean; storeReady: boolean; + startupBlocker: StartupBlocker; } const HostRuntimeBootstrapContext = createContext({ @@ -110,6 +115,7 @@ const HostRuntimeBootstrapContext = createContext({ retry: () => {}, hasGivenUpWaitingForHost: false, storeReady: false, + startupBlocker: { kind: "none" }, }); function PushNotificationRouter() { @@ -313,18 +319,26 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { const anyOnlineHostServerId = useEarliestOnlineHostServerId(); const daemonStartError = useDaemonStartLastError(); const daemonStartIsRunning = useDaemonStartIsRunning(); - const waitForConfiguredLocalDaemon = - hasConfiguredLocalDaemonOverride() && !shouldUseDesktopDaemon(); - const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false); + const isDesktopRuntime = shouldUseDesktopDaemon(); + const startupBlocker = useMemo( + () => + resolveStartupBlocker({ + isDesktopRuntime, + anyOnlineHostServerId, + daemonStartIsRunning, + daemonStartError, + }), + [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, isDesktopRuntime], + ); + const shouldRunGiveUpTimer = shouldRunStartupGiveUpTimer({ + startupBlocker, + anyOnlineHostServerId, + hasGivenUpWaitingForHost, + }); + useEffect(() => { - if ( - anyOnlineHostServerId || - daemonStartError || - daemonStartIsRunning || - waitForConfiguredLocalDaemon || - hasGivenUpWaitingForHost - ) { + if (!shouldRunGiveUpTimer) { return; } const handle = setTimeout(() => { @@ -333,13 +347,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { return () => { clearTimeout(handle); }; - }, [ - anyOnlineHostServerId, - daemonStartError, - daemonStartIsRunning, - waitForConfiguredLocalDaemon, - hasGivenUpWaitingForHost, - ]); + }, [shouldRunGiveUpTimer]); const retry = useCallback(() => { const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() }); @@ -350,14 +358,13 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { }); }, []); - const splashError = !anyOnlineHostServerId ? daemonStartError : null; - const isCurrentlyStoreReady = - Boolean(anyOnlineHostServerId) || Boolean(splashError) || hasGivenUpWaitingForHost; - const storeReady = useLatchedBoolean(isCurrentlyStoreReady); + const splashError = + startupBlocker.kind === "managed-daemon-error" ? startupBlocker.message : null; + const storeReady = resolveStartupNavigationReady({ startupBlocker }); const state = useMemo( - () => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady }), - [splashError, retry, hasGivenUpWaitingForHost, storeReady], + () => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker }), + [splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker], ); return ( @@ -504,7 +511,7 @@ function MobileGestureWrapper({ animateToOpen, animateToClose, isGesturing, - mobileVisualPanel, + mobilePanelState, gestureAnimatingRef, openGestureRef, } = useSidebarAnimation(); @@ -540,7 +547,7 @@ function MobileGestureWrapper({ const absDeltaX = Math.abs(deltaX); const absDeltaY = Math.abs(deltaY); - if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) { + if (!canOpenLeftSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) { stateManager.fail(); return; } @@ -586,11 +593,9 @@ function MobileGestureWrapper({ isGesturing.value = false; const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; if (shouldOpen) { - mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST; animateToOpen(); runOnJS(handleGestureOpen)(); } else { - mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT; animateToClose(); } }) @@ -602,7 +607,7 @@ function MobileGestureWrapper({ windowWidth, translateX, backdropOpacity, - mobileVisualPanel, + mobilePanelState, animateToOpen, animateToClose, handleGestureOpen, diff --git a/packages/app/src/app/host-runtime-bootstrap.test.ts b/packages/app/src/app/host-runtime-bootstrap.test.ts index cd0055412..b861e8c7a 100644 --- a/packages/app/src/app/host-runtime-bootstrap.test.ts +++ b/packages/app/src/app/host-runtime-bootstrap.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import { + resolveStartupBlocker, + resolveStartupNavigationReady, resolveStartupRedirectRoute, resolveStartupWorkspaceSelection, + shouldRunStartupGiveUpTimer, startHostRuntimeBootstrap, WELCOME_ROUTE, } from "./host-runtime-bootstrap"; @@ -117,6 +120,80 @@ describe("startHostRuntimeBootstrap", () => { }); }); +describe("startup blocking policy", () => { + const noBlockerInput = { + isDesktopRuntime: false, + anyOnlineHostServerId: null, + daemonStartIsRunning: false, + daemonStartError: null, + }; + + it("runs the give-up timer when no startup blocker is active", () => { + const blocker = resolveStartupBlocker(noBlockerInput); + + expect(blocker).toEqual({ kind: "none" }); + expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true); + expect( + shouldRunStartupGiveUpTimer({ + startupBlocker: blocker, + anyOnlineHostServerId: null, + hasGivenUpWaitingForHost: false, + }), + ).toBe(true); + }); + + it("blocks navigation while desktop is starting the managed daemon", () => { + const blocker = resolveStartupBlocker({ + ...noBlockerInput, + isDesktopRuntime: true, + daemonStartIsRunning: true, + }); + + expect(blocker).toEqual({ kind: "managed-daemon-starting" }); + expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(false); + expect( + shouldRunStartupGiveUpTimer({ + startupBlocker: blocker, + anyOnlineHostServerId: null, + hasGivenUpWaitingForHost: false, + }), + ).toBe(false); + }); + + it("unblocks navigation when any host is online", () => { + const blocker = resolveStartupBlocker({ + ...noBlockerInput, + isDesktopRuntime: true, + anyOnlineHostServerId: "srv_desktop", + daemonStartIsRunning: true, + }); + + expect(blocker).toEqual({ kind: "none" }); + expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true); + }); + + it("keeps desktop daemon startup errors on the startup error surface", () => { + const blocker = resolveStartupBlocker({ + ...noBlockerInput, + isDesktopRuntime: true, + daemonStartError: "daemon failed to start", + }); + + expect(blocker).toEqual({ + kind: "managed-daemon-error", + message: "daemon failed to start", + }); + expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true); + expect( + shouldRunStartupGiveUpTimer({ + startupBlocker: blocker, + anyOnlineHostServerId: null, + hasGivenUpWaitingForHost: false, + }), + ).toBe(false); + }); +}); + describe("resolveStartupRedirectRoute", () => { const baseInput = { pathname: "/", diff --git a/packages/app/src/app/host-runtime-bootstrap.ts b/packages/app/src/app/host-runtime-bootstrap.ts index 0af3f1d91..ca61b547b 100644 --- a/packages/app/src/app/host-runtime-bootstrap.ts +++ b/packages/app/src/app/host-runtime-bootstrap.ts @@ -58,6 +58,56 @@ export function startDaemonIfGateAllows(input: { export const WELCOME_ROUTE: Href = "/welcome"; +export type StartupBlocker = + | { kind: "none" } + | { kind: "managed-daemon-starting" } + | { kind: "managed-daemon-error"; message: string }; + +export interface ResolveStartupBlockerInput { + isDesktopRuntime: boolean; + anyOnlineHostServerId: string | null; + daemonStartIsRunning: boolean; + daemonStartError: string | null; +} + +export function resolveStartupBlocker(input: ResolveStartupBlockerInput): StartupBlocker { + if (!input.isDesktopRuntime) { + return { kind: "none" }; + } + + if (input.anyOnlineHostServerId) { + return { kind: "none" }; + } + + if (input.daemonStartError) { + return { kind: "managed-daemon-error", message: input.daemonStartError }; + } + + if (input.daemonStartIsRunning) { + return { kind: "managed-daemon-starting" }; + } + + return { kind: "none" }; +} + +export function resolveStartupNavigationReady(input: { startupBlocker: StartupBlocker }): boolean { + return input.startupBlocker.kind !== "managed-daemon-starting"; +} + +export function shouldRunStartupGiveUpTimer(input: { + startupBlocker: StartupBlocker; + anyOnlineHostServerId: string | null; + hasGivenUpWaitingForHost: boolean; +}): boolean { + if (input.anyOnlineHostServerId) { + return false; + } + if (input.hasGivenUpWaitingForHost) { + return false; + } + return input.startupBlocker.kind === "none"; +} + export interface ResolveStartupRedirectInput { pathname: string; anyOnlineHostServerId: string | null; diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index fa84d9bff..2a41f83e9 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -23,11 +23,8 @@ import { type ExplorerTab, } from "@/stores/panel-store"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; -import { - MOBILE_VISUAL_PANEL_AGENT, - MOBILE_VISUAL_PANEL_FILE_EXPLORER, - useSidebarAnimation, -} from "@/contexts/sidebar-animation-context"; +import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; +import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { GitDiffPane } from "@/git/diff-pane"; import { FileExplorerPane } from "./file-explorer-pane"; @@ -68,7 +65,7 @@ export function ExplorerSidebar({ const { width: viewportWidth } = useWindowDimensions(); const closeTouchStartX = useSharedValue(0); const closeTouchStartY = useSharedValue(0); - const { mobileVisualPanel, gestureAnimatingRef: mobilePanelGestureAnimatingRef } = + const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } = useSidebarAnimation(); const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({ @@ -166,7 +163,7 @@ export function ExplorerSidebar({ const absDeltaX = Math.abs(deltaX); const absDeltaY = Math.abs(deltaY); - if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_FILE_EXPLORER) { + if (!canCloseRightSidebarGesture(mobilePanelState.value)) { stateManager.fail(); return; } @@ -206,11 +203,9 @@ export function ExplorerSidebar({ windowWidth, }); if (shouldClose) { - mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT; animateToClose(); runOnJS(handleCloseFromGesture)(); } else { - mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER; animateToOpen(); } }) @@ -222,7 +217,7 @@ export function ExplorerSidebar({ windowWidth, translateX, backdropOpacity, - mobileVisualPanel, + mobilePanelState, animateToOpen, animateToClose, handleCloseFromGesture, diff --git a/packages/app/src/components/host-route-bootstrap-boundary.tsx b/packages/app/src/components/host-route-bootstrap-boundary.tsx index 285bb61cb..6816284fc 100644 --- a/packages/app/src/components/host-route-bootstrap-boundary.tsx +++ b/packages/app/src/components/host-route-bootstrap-boundary.tsx @@ -1,15 +1,12 @@ import type { ReactNode } from "react"; -import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout"; -import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; +import { useHostRuntimeBootstrapState } from "@/app/_layout"; import { StartupSplashScreen } from "@/screens/startup-splash-screen"; export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) { - const storeReady = useStoreReady(); const bootstrapState = useHostRuntimeBootstrapState(); - const isDesktop = shouldUseDesktopDaemon(); - if (!storeReady) { - return ; + if (bootstrapState.startupBlocker.kind !== "none") { + return ; } return children; diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 60270376a..69cbabf59 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -40,11 +40,7 @@ import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; -import { - MOBILE_VISUAL_PANEL_AGENT, - MOBILE_VISUAL_PANEL_AGENT_LIST, - useSidebarAnimation, -} from "@/contexts/sidebar-animation-context"; +import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model"; @@ -64,6 +60,7 @@ import { import { resolveActiveHost } from "@/utils/active-host"; import { formatConnectionStatus } from "@/utils/daemons"; import { useWindowControlsPadding } from "@/utils/desktop-window"; +import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state"; import { buildHostOpenProjectRoute, buildHostNewWorkspaceRoute, @@ -581,7 +578,7 @@ function MobileSidebar({ animateToOpen, animateToClose, isGesturing, - mobileVisualPanel, + mobilePanelState, gestureAnimatingRef, closeGestureRef, } = useSidebarAnimation(); @@ -645,7 +642,7 @@ function MobileSidebar({ const absDeltaX = Math.abs(deltaX); const absDeltaY = Math.abs(deltaY); - if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT_LIST) { + if (!canCloseLeftSidebarGesture(mobilePanelState.value)) { stateManager.fail(); return; } @@ -679,11 +676,9 @@ function MobileSidebar({ isGesturing.value = false; const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500; if (shouldClose) { - mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT; animateToClose(); runOnJS(handleCloseFromGesture)(); } else { - mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST; animateToOpen(); } }) @@ -695,7 +690,7 @@ function MobileSidebar({ closeTouchStartX, closeTouchStartY, isGesturing, - mobileVisualPanel, + mobilePanelState, windowWidth, translateX, backdropOpacity, diff --git a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx index b5d37e17f..d5e3c08ac 100644 --- a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx +++ b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx @@ -11,12 +11,7 @@ import { useWindowDimensions } from "react-native"; import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { type GestureType } from "react-native-gesture-handler"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { - MOBILE_VISUAL_PANEL_AGENT, - MOBILE_VISUAL_PANEL_AGENT_LIST, - MOBILE_VISUAL_PANEL_FILE_EXPLORER, - useSidebarAnimation, -} from "@/contexts/sidebar-animation-context"; +import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; import { getRightSidebarAnimationTargets, @@ -41,18 +36,8 @@ const ExplorerSidebarAnimationContext = createContext state.mobileView); @@ -84,6 +69,9 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React }); const didMobileViewChange = prevMobileView.current !== mobileView; const previousIsOpen = prevIsOpen.current; + const previousMobileView = prevMobileView.current; + const ownsMobileViewChange = + previousMobileView === "file-explorer" || mobileView === "file-explorer"; prevIsOpen.current = isOpen; prevMobileView.current = mobileView; prevWindowWidth.current = windowWidth; @@ -102,17 +90,49 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React return; } - if (isCompactLayout) { - mobileVisualPanel.value = getMobileVisualPanel(mobileView); - } - const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth }); if (previousIsOpen !== isOpen) { - translateX.value = withTiming(targets.translateX, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); + if (isOpen) { + if (isCompactLayout) { + startMobilePanelTransition("file-explorer"); + } + translateX.value = withTiming( + targets.translateX, + { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }, + (finished) => { + if (!finished) return; + if (isCompactLayout) { + settleMobilePanel("file-explorer"); + } + }, + ); + backdropOpacity.value = withTiming(targets.backdropOpacity, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + return; + } + + if (isCompactLayout && mobileView === "agent") { + startMobilePanelTransition("agent"); + } + translateX.value = withTiming( + targets.translateX, + { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }, + (finished) => { + if (!finished) return; + if (isCompactLayout && mobileView === "agent") { + settleMobilePanel("agent"); + } + }, + ); backdropOpacity.value = withTiming(targets.backdropOpacity, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING, @@ -122,6 +142,9 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React translateX.value = targets.translateX; backdropOpacity.value = targets.backdropOpacity; + if (isCompactLayout && ownsMobileViewChange) { + settleMobilePanel(mobileView); + } }, [ isOpen, mobileView, @@ -130,32 +153,49 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React windowWidth, isGesturing, isCompactLayout, - mobileVisualPanel, + startMobilePanelTransition, + settleMobilePanel, ]); const animateToOpen = useCallback(() => { "worklet"; - translateX.value = withTiming(0, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); + startMobilePanelTransition("file-explorer"); + translateX.value = withTiming( + 0, + { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }, + (finished) => { + if (!finished) return; + settleMobilePanel("file-explorer"); + }, + ); backdropOpacity.value = withTiming(1, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); - }, [translateX, backdropOpacity]); + }, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]); const animateToClose = useCallback(() => { "worklet"; - translateX.value = withTiming(windowWidth, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); + startMobilePanelTransition("agent"); + translateX.value = withTiming( + windowWidth, + { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }, + (finished) => { + if (!finished) return; + settleMobilePanel("agent"); + }, + ); backdropOpacity.value = withTiming(0, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); - }, [translateX, backdropOpacity, windowWidth]); + }, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]); const value = useMemo( () => ({ diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index 4a931ee2a..caeced2d9 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -15,6 +15,17 @@ import { isNative } from "@/constants/platform"; import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; import { getLeftSidebarAnimationTargets, + MOBILE_PANEL_STATE_AGENT, + MOBILE_PANEL_STATE_AGENT_LIST_CLOSING, + MOBILE_PANEL_STATE_AGENT_LIST_OPEN, + MOBILE_PANEL_STATE_AGENT_LIST_OPENING, + MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING, + MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN, + MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING, + MOBILE_PANEL_TARGET_AGENT, + MOBILE_PANEL_TARGET_AGENT_LIST, + MOBILE_PANEL_TARGET_FILE_EXPLORER, + shouldSettleMobilePanelTransition, shouldSyncSidebarAnimation, } from "@/utils/sidebar-animation-state"; @@ -30,8 +41,11 @@ interface SidebarAnimationContextValue { windowWidth: number; animateToOpen: () => void; animateToClose: () => void; + startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void; + settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void; isGesturing: SharedValue; mobileVisualPanel: SharedValue; + mobilePanelState: SharedValue; gestureAnimatingRef: React.MutableRefObject; openGestureRef: React.MutableRefObject; closeGestureRef: React.MutableRefObject; @@ -49,6 +63,16 @@ function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explore return MOBILE_VISUAL_PANEL_AGENT; } +function getSettledMobilePanelState(mobileView: "agent" | "agent-list" | "file-explorer"): number { + if (mobileView === "agent-list") { + return MOBILE_PANEL_STATE_AGENT_LIST_OPEN; + } + if (mobileView === "file-explorer") { + return MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN; + } + return MOBILE_PANEL_STATE_AGENT; +} + export function SidebarAnimationProvider({ children }: { children: ReactNode }) { const { width: windowWidth } = useWindowDimensions(); const isCompactLayout = useIsCompactFormFactor(); @@ -63,6 +87,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) const backdropOpacity = useSharedValue(initialTargets.backdropOpacity); const isGesturing = useSharedValue(false); const mobileVisualPanel = useSharedValue(getMobileVisualPanel(mobileView)); + const mobilePanelState = useSharedValue(getSettledMobilePanelState(mobileView)); + const mobilePanelTarget = useSharedValue(getMobileVisualPanel(mobileView)); const gestureAnimatingRef = useRef(false); const openGestureRef = useRef(undefined); const closeGestureRef = useRef(undefined); @@ -72,6 +98,71 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) const prevMobileView = useRef(mobileView); const prevWindowWidth = useRef(windowWidth); + const startMobilePanelTransition = useCallback( + (nextMobileView: "agent" | "agent-list" | "file-explorer") => { + "worklet"; + if (nextMobileView === "agent-list") { + mobilePanelTarget.value = MOBILE_PANEL_TARGET_AGENT_LIST; + mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_OPENING; + return; + } + if (nextMobileView === "file-explorer") { + mobilePanelTarget.value = MOBILE_PANEL_TARGET_FILE_EXPLORER; + mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING; + return; + } + mobilePanelTarget.value = MOBILE_PANEL_TARGET_AGENT; + if (mobilePanelState.value === MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN) { + mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING; + return; + } + if (mobilePanelState.value === MOBILE_PANEL_STATE_AGENT_LIST_OPEN) { + mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_CLOSING; + return; + } + mobilePanelState.value = MOBILE_PANEL_STATE_AGENT; + }, + [mobilePanelState, mobilePanelTarget], + ); + + const settleMobilePanel = useCallback( + (nextMobileView: "agent" | "agent-list" | "file-explorer") => { + "worklet"; + if (nextMobileView === "agent-list") { + if ( + !shouldSettleMobilePanelTransition( + mobilePanelTarget.value, + MOBILE_PANEL_TARGET_AGENT_LIST, + ) + ) { + return; + } + mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST; + mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_OPEN; + return; + } + if (nextMobileView === "file-explorer") { + if ( + !shouldSettleMobilePanelTransition( + mobilePanelTarget.value, + MOBILE_PANEL_TARGET_FILE_EXPLORER, + ) + ) { + return; + } + mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER; + mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN; + return; + } + if (!shouldSettleMobilePanelTransition(mobilePanelTarget.value, MOBILE_PANEL_TARGET_AGENT)) { + return; + } + mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT; + mobilePanelState.value = MOBILE_PANEL_STATE_AGENT; + }, + [mobileVisualPanel, mobilePanelState, mobilePanelTarget], + ); + // Sync animation with store state changes (e.g., backdrop tap, programmatic open/close) useEffect(() => { const didStateChange = shouldSyncSidebarAnimation({ @@ -82,6 +173,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) }); const didMobileViewChange = prevMobileView.current !== mobileView; const previousIsOpen = prevIsOpen.current; + const previousMobileView = prevMobileView.current; + const ownsMobileViewChange = previousMobileView === "agent-list" || mobileView === "agent-list"; prevIsOpen.current = isOpen; prevMobileView.current = mobileView; prevWindowWidth.current = windowWidth; @@ -108,17 +201,49 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) return; } - if (isCompactLayout) { - mobileVisualPanel.value = getMobileVisualPanel(mobileView); - } - const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth }); if (previousIsOpen !== isOpen) { - translateX.value = withTiming(targets.translateX, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); + if (isOpen) { + if (isCompactLayout) { + startMobilePanelTransition("agent-list"); + } + translateX.value = withTiming( + targets.translateX, + { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }, + (finished) => { + if (!finished) return; + if (isCompactLayout) { + settleMobilePanel("agent-list"); + } + }, + ); + backdropOpacity.value = withTiming(targets.backdropOpacity, { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }); + return; + } + + if (isCompactLayout && mobileView === "agent") { + startMobilePanelTransition("agent"); + } + translateX.value = withTiming( + targets.translateX, + { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }, + (finished) => { + if (!finished) return; + if (isCompactLayout && mobileView === "agent") { + settleMobilePanel("agent"); + } + }, + ); backdropOpacity.value = withTiming(targets.backdropOpacity, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING, @@ -128,6 +253,9 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) translateX.value = targets.translateX; backdropOpacity.value = targets.backdropOpacity; + if (isCompactLayout && ownsMobileViewChange) { + settleMobilePanel(mobileView); + } }, [ isOpen, mobileView, @@ -137,31 +265,50 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) isGesturing, isCompactLayout, mobileVisualPanel, + mobilePanelState, + startMobilePanelTransition, + settleMobilePanel, ]); const animateToOpen = useCallback(() => { "worklet"; - translateX.value = withTiming(0, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); + startMobilePanelTransition("agent-list"); + translateX.value = withTiming( + 0, + { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }, + (finished) => { + if (!finished) return; + settleMobilePanel("agent-list"); + }, + ); backdropOpacity.value = withTiming(1, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); - }, [translateX, backdropOpacity]); + }, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]); const animateToClose = useCallback(() => { "worklet"; - translateX.value = withTiming(-windowWidth, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); + startMobilePanelTransition("agent"); + translateX.value = withTiming( + -windowWidth, + { + duration: ANIMATION_DURATION, + easing: ANIMATION_EASING, + }, + (finished) => { + if (!finished) return; + settleMobilePanel("agent"); + }, + ); backdropOpacity.value = withTiming(0, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); - }, [translateX, backdropOpacity, windowWidth]); + }, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]); const value = useMemo( () => ({ @@ -170,8 +317,11 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) windowWidth, animateToOpen, animateToClose, + startMobilePanelTransition, + settleMobilePanel, isGesturing, mobileVisualPanel, + mobilePanelState, gestureAnimatingRef, openGestureRef, closeGestureRef, @@ -182,8 +332,11 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) windowWidth, animateToOpen, animateToClose, + startMobilePanelTransition, + settleMobilePanel, isGesturing, mobileVisualPanel, + mobilePanelState, gestureAnimatingRef, openGestureRef, closeGestureRef, diff --git a/packages/app/src/hooks/use-explorer-open-gesture.ts b/packages/app/src/hooks/use-explorer-open-gesture.ts index 5faef5fd6..811232833 100644 --- a/packages/app/src/hooks/use-explorer-open-gesture.ts +++ b/packages/app/src/hooks/use-explorer-open-gesture.ts @@ -2,12 +2,9 @@ import { useCallback, useMemo } from "react"; import { Gesture } from "react-native-gesture-handler"; import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; -import { - MOBILE_VISUAL_PANEL_AGENT, - MOBILE_VISUAL_PANEL_FILE_EXPLORER, - useSidebarAnimation, -} from "@/contexts/sidebar-animation-context"; +import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { isWeb } from "@/constants/platform"; +import { canOpenRightSidebarGesture } from "@/utils/sidebar-animation-state"; interface UseExplorerOpenGestureParams { enabled: boolean; @@ -28,7 +25,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu openGestureRef, } = useExplorerSidebarAnimation(); const { - mobileVisualPanel, + mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef, openGestureRef: leftOpenGestureRef, } = useSidebarAnimation(); @@ -68,7 +65,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu const absDeltaX = Math.abs(deltaX); const absDeltaY = Math.abs(deltaY); - if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) { + if (!canOpenRightSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) { stateManager.fail(); return; } @@ -117,11 +114,9 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu const shouldOpenByVelocity = event.velocityX < -500; const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity; if (shouldOpen) { - mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER; animateToOpen(); runOnJS(handleGestureOpen)(); } else { - mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT; animateToClose(); } }) @@ -133,7 +128,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu windowWidth, translateX, backdropOpacity, - mobileVisualPanel, + mobilePanelState, animateToOpen, animateToClose, isGesturing, diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index b137188c9..32c01adcf 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -655,21 +655,6 @@ function buildComposerConfig(input: { }; } -function computeWorkspaceTitle( - workspace: ReturnType | null, - displayName: string, - sourceDirectory: string | null, -): string { - const fallbackDirectoryName = sourceDirectory?.split(/[\\/]/).findLast(Boolean) ?? null; - return ( - workspace?.name || - workspace?.projectDisplayName || - displayName || - fallbackDirectoryName || - "Choose project" - ); -} - function collectAttachedPrNumbers(attachments: ReadonlyArray): Set { const numbers = new Set(); for (const attachment of attachments) { @@ -801,7 +786,6 @@ export function NewWorkspaceScreen({ projects, selectedProject, selectedSourceDirectory, - selectedDisplayName, projectPickerOptions, projectByOptionId, selectedProjectOptionId, @@ -1091,12 +1075,6 @@ export function NewWorkspaceScreen({ [composerState, draftKey, ensureWorkspace, serverId, toast], ); - const workspaceTitle = computeWorkspaceTitle( - workspace, - selectedDisplayName, - selectedSourceDirectory, - ); - const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { addImagesRef.current = addImages; @@ -1308,30 +1286,18 @@ export function NewWorkspaceScreen({ triggerLabel, ], ); + const screenHeaderLeft = useMemo(() => , []); return ( - - - - - New workspace - - - {workspaceTitle} - - - - } - leftStyle={styles.headerLeft} - borderless - /> + + + New workspace + ({ width: "100%", maxWidth: MAX_CONTENT_WIDTH, }, - headerLeft: { - gap: theme.spacing[2], + composerTitleContainer: { + marginBottom: theme.spacing[8], + paddingLeft: theme.spacing[6], + paddingRight: theme.spacing[4], }, - headerTitleContainer: { - flexShrink: 1, - minWidth: 0, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - headerTitle: { - fontSize: theme.fontSize.base, - fontWeight: { - xs: "400", - md: "300", - }, + composerTitle: { + fontSize: theme.fontSize.xl, + fontWeight: theme.fontWeight.normal, color: theme.colors.foreground, - flexShrink: 0, - }, - headerProjectTitle: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - flexShrink: 1, }, errorText: { fontSize: theme.fontSize.sm, diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index ed53201d0..fb0155a45 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -26,6 +26,7 @@ import { RotateCw, Rows2, Globe, + Plus, SquarePen, SquareTerminal, X, @@ -48,6 +49,7 @@ import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; +import type { ShortcutKey } from "@/utils/format-shortcut"; import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout"; import { WorkspaceTabPresentationResolver, @@ -66,6 +68,7 @@ import { RenderProfile } from "@/utils/render-profiler"; const DROPDOWN_WIDTH = 220; const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; +const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36; const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedX = withUnistyles(X); @@ -80,6 +83,7 @@ const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedGlobe = withUnistyles(Globe); const ThemedColumns2 = withUnistyles(Columns2); const ThemedRows2 = withUnistyles(Rows2); +const ThemedPlus = withUnistyles(Plus); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -88,6 +92,51 @@ function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackTyp return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered]; } +function inlineAddActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) { + return [styles.inlineAddActionButton, (hovered || pressed) && styles.newTabActionButtonHovered]; +} + +function updateMeasuredWidth(setWidth: Dispatch>, event: LayoutChangeEvent) { + const nextWidth = Math.round(event.nativeEvent.layout.width); + setWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); +} + +interface WorkspaceInlineAddTabButtonProps { + shortcutKeys: ShortcutKey[][] | null; + onPress: () => void; + onLayout: (event: LayoutChangeEvent) => void; +} + +function WorkspaceInlineAddTabButton({ + shortcutKeys, + onPress, + onLayout, +}: WorkspaceInlineAddTabButtonProps) { + return ( + + + + + + + + New agent tab + {shortcutKeys ? ( + + ) : null} + + + + + ); +} + function TabContextMenuItem({ entry, }: { @@ -490,21 +539,27 @@ export function WorkspaceDesktopTabsRow({ const splitDownKeys = useShortcutKeys("workspace-pane-split-down"); const [tabsContainerWidth, setTabsContainerWidth] = useState(0); const [tabsActionsWidth, setTabsActionsWidth] = useState(0); + const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState(0); const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => { - const nextWidth = Math.round(event.nativeEvent.layout.width); - setTabsContainerWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); + updateMeasuredWidth(setTabsContainerWidth, event); }, []); const handleTabsActionsLayout = useCallback((event: LayoutChangeEvent) => { - const nextWidth = Math.round(event.nativeEvent.layout.width); - setTabsActionsWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); + updateMeasuredWidth(setTabsActionsWidth, event); + }, []); + + const handleInlineAddButtonLayout = useCallback((event: LayoutChangeEvent) => { + updateMeasuredWidth(setInlineAddButtonWidth, event); }, []); const layoutMetrics = useMemo( () => ({ rowHorizontalInset: 0, - actionsReservedWidth: Math.max(0, tabsActionsWidth), + actionsReservedWidth: Math.max( + 0, + tabsActionsWidth + (inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH), + ), rowPaddingHorizontal: 0, tabGap: 0, maxTabWidth: 200, @@ -513,7 +568,7 @@ export function WorkspaceDesktopTabsRow({ estimatedCharWidth: 7, closeButtonWidth: 22, }), - [tabsActionsWidth], + [inlineAddButtonWidth, tabsActionsWidth], ); const tabLabelLengths = useMemo( @@ -672,6 +727,11 @@ export function WorkspaceDesktopTabsRow({ getItemData={getTabDragData} renderItem={renderTab} /> + @@ -928,6 +988,11 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", paddingHorizontal: theme.spacing[2], }, + inlineAddButton: { + paddingHorizontal: theme.spacing[1], + alignItems: "center", + justifyContent: "center", + }, tab: { paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], @@ -1028,6 +1093,13 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, + inlineAddActionButton: { + width: 28, + height: 28, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + }, newTabActionButtonDisabled: { opacity: 0.5, }, diff --git a/packages/app/src/utils/sidebar-animation-state.test.ts b/packages/app/src/utils/sidebar-animation-state.test.ts index 5d587902b..5284333d0 100644 --- a/packages/app/src/utils/sidebar-animation-state.test.ts +++ b/packages/app/src/utils/sidebar-animation-state.test.ts @@ -1,7 +1,22 @@ import { describe, expect, it } from "vitest"; import { + canCloseLeftSidebarGesture, + canCloseRightSidebarGesture, + canOpenLeftSidebarGesture, + canOpenRightSidebarGesture, getLeftSidebarAnimationTargets, getRightSidebarAnimationTargets, + MOBILE_PANEL_STATE_AGENT, + MOBILE_PANEL_STATE_AGENT_LIST_CLOSING, + MOBILE_PANEL_STATE_AGENT_LIST_OPEN, + MOBILE_PANEL_STATE_AGENT_LIST_OPENING, + MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING, + MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN, + MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING, + MOBILE_PANEL_TARGET_AGENT, + MOBILE_PANEL_TARGET_AGENT_LIST, + MOBILE_PANEL_TARGET_FILE_EXPLORER, + shouldSettleMobilePanelTransition, shouldSyncSidebarAnimation, } from "./sidebar-animation-state"; @@ -41,4 +56,56 @@ describe("sidebar-animation-state", () => { backdropOpacity: 0, }); }); + + it("allows the left open gesture only after the app is settled on the agent panel", () => { + expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT, -430, 430)).toBe(true); + expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT, -240, 430)).toBe(false); + expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_CLOSING, -430, 430)).toBe(false); + expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPENING, -430, 430)).toBe(false); + expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN, -430, 430)).toBe(false); + }); + + it("allows the left close gesture only while the left sidebar is settled open", () => { + expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPEN)).toBe(true); + expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPENING)).toBe(false); + expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_CLOSING)).toBe(false); + expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT)).toBe(false); + }); + + it("allows the right open gesture only after the app is settled on the agent panel", () => { + expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT, 430, 430)).toBe(true); + expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT, 240, 430)).toBe(false); + expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING, 430, 430)).toBe( + false, + ); + expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING, 430, 430)).toBe( + false, + ); + expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPEN, 430, 430)).toBe(false); + }); + + it("allows the right close gesture only while the right sidebar is settled open", () => { + expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN)).toBe(true); + expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING)).toBe(false); + expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING)).toBe(false); + expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_AGENT)).toBe(false); + }); + + it("rejects stale settle callbacks from a panel that is no longer the destination", () => { + expect( + shouldSettleMobilePanelTransition( + MOBILE_PANEL_TARGET_AGENT_LIST, + MOBILE_PANEL_TARGET_AGENT_LIST, + ), + ).toBe(true); + expect( + shouldSettleMobilePanelTransition(MOBILE_PANEL_TARGET_AGENT_LIST, MOBILE_PANEL_TARGET_AGENT), + ).toBe(false); + expect( + shouldSettleMobilePanelTransition( + MOBILE_PANEL_TARGET_AGENT_LIST, + MOBILE_PANEL_TARGET_FILE_EXPLORER, + ), + ).toBe(false); + }); }); diff --git a/packages/app/src/utils/sidebar-animation-state.ts b/packages/app/src/utils/sidebar-animation-state.ts index 32a25943a..f62e23d00 100644 --- a/packages/app/src/utils/sidebar-animation-state.ts +++ b/packages/app/src/utils/sidebar-animation-state.ts @@ -15,6 +15,20 @@ interface SidebarAnimationTargets { backdropOpacity: number; } +export const MOBILE_PANEL_STATE_AGENT = 0; +export const MOBILE_PANEL_STATE_AGENT_LIST_OPENING = 1; +export const MOBILE_PANEL_STATE_AGENT_LIST_OPEN = 2; +export const MOBILE_PANEL_STATE_AGENT_LIST_CLOSING = 3; +export const MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING = 4; +export const MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN = 5; +export const MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING = 6; + +export const MOBILE_PANEL_TARGET_AGENT = 0; +export const MOBILE_PANEL_TARGET_AGENT_LIST = 1; +export const MOBILE_PANEL_TARGET_FILE_EXPLORER = 2; + +const CLOSED_POSITION_TOLERANCE = 1; + export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean { return ( input.previousIsOpen !== input.nextIsOpen || input.previousWindowWidth !== input.nextWindowWidth @@ -38,3 +52,45 @@ export function getRightSidebarAnimationTargets( backdropOpacity: input.isOpen ? 1 : 0, }; } + +export function canOpenLeftSidebarGesture( + mobilePanelState: number, + translateX: number, + windowWidth: number, +): boolean { + "worklet"; + return ( + mobilePanelState === MOBILE_PANEL_STATE_AGENT && + translateX <= -windowWidth + CLOSED_POSITION_TOLERANCE + ); +} + +export function canCloseLeftSidebarGesture(mobilePanelState: number): boolean { + "worklet"; + return mobilePanelState === MOBILE_PANEL_STATE_AGENT_LIST_OPEN; +} + +export function canOpenRightSidebarGesture( + mobilePanelState: number, + translateX: number, + windowWidth: number, +): boolean { + "worklet"; + return ( + mobilePanelState === MOBILE_PANEL_STATE_AGENT && + translateX >= windowWidth - CLOSED_POSITION_TOLERANCE + ); +} + +export function canCloseRightSidebarGesture(mobilePanelState: number): boolean { + "worklet"; + return mobilePanelState === MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN; +} + +export function shouldSettleMobilePanelTransition( + activeTarget: number, + settledTarget: number, +): boolean { + "worklet"; + return activeTarget === settledTarget; +} diff --git a/paseo.json b/paseo.json index fa86c580f..4600005c3 100644 --- a/paseo.json +++ b/paseo.json @@ -2,6 +2,7 @@ "worktree": { "setup": [ "npm ci", + "node ./scripts/seed-ios-native-cache.mjs", "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_SEED_HOME=\"$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" ./scripts/dev-home.sh", "npm run build:server", "npm run build --workspace=@getpaseo/expo-two-way-audio", @@ -20,6 +21,10 @@ "desktop": { "type": "service", "command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PASEO_WORKTREE_PATH\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT} EXPO_PORT=$PASEO_PORT npm run dev --workspace=@getpaseo/desktop" + }, + "ios-simulator": { + "type": "service", + "command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PASEO_WORKTREE_PATH\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT} ./scripts/paseo-ios-simulator-service.sh" } } } diff --git a/scripts/paseo-ios-simulator-service.mjs b/scripts/paseo-ios-simulator-service.mjs new file mode 100755 index 000000000..4d6fd220e --- /dev/null +++ b/scripts/paseo-ios-simulator-service.mjs @@ -0,0 +1,356 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { closeSync, existsSync, mkdirSync, openSync, readdirSync } from "node:fs"; +import { basename, dirname, join, resolve as resolvePath } from "node:path"; +import { spawn, spawnSync } from "node:child_process"; + +const rootDir = resolvePath(import.meta.dirname, ".."); +const appDir = join(rootDir, "packages/app"); +const appProductName = "PaseoDebug"; +const appScheme = "paseo"; +const preferredSimulatorType = process.env.PASEO_IOS_DEVICE_TYPE || "iPhone 16 Pro"; +const paseoPort = requiredEnv("PASEO_PORT"); +const worktreePath = process.env.PASEO_WORKTREE_PATH || rootDir; +const worktreeName = process.env.PASEO_BRANCH_NAME || basename(worktreePath); +const worktreeHash = createHash("sha1").update(worktreePath).digest("hex").slice(0, 8); +const simulatorName = + process.env.PASEO_IOS_SIMULATOR_NAME || `Paseo ${worktreeName} ${worktreeHash}`; +const daemonEndpoint = + process.env.PASEO_DEV_DAEMON_ENDPOINT || + `localhost:${process.env.PASEO_SERVICE_DAEMON_PORT || "6768"}`; + +const env = { + ...process.env, + PATH: `${join(rootDir, "node_modules/.bin")}:${process.env.PATH || ""}`, + APP_VARIANT: "development", + CI: process.env.CI || "1", + EXPO_PUBLIC_LOCAL_DAEMON: daemonEndpoint, +}; +const nativeBuildLog = join(rootDir, ".dev", "ios-build", `${simulatorSlug()}.log`); + +let simulatorUdid = ""; +let metro; +let shuttingDown = false; +let simulatorVisibilityGuard; + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : error); + process.exitCode = 1; + void cleanup(); +}); + +process.on("SIGINT", () => void shutdown()); +process.on("SIGTERM", () => void shutdown()); + +async function main() { + startSimulatorVisibilityGuard(); + simulatorUdid = findSimulator(simulatorName) || createSimulator(simulatorName); + hideNativeSimulatorApp(); + bootSimulator(simulatorUdid); + hideNativeSimulatorApp(); + run("npx", ["serve-sim", "--detach", "-q", simulatorUdid], { cwd: rootDir }); + hideNativeSimulatorApp(); + + metro = startMetro(); + await waitForUrl(`http://127.0.0.1:${paseoPort}/.sim`); + console.log(`iOS preview: ${process.env.PASEO_URL || `http://127.0.0.1:${paseoPort}`}/.sim`); + + console.log("Building app dependencies..."); + try { + run("npm", ["--prefix", rootDir, "run", "build:client"], { cwd: rootDir }); + console.log("Generating iOS project..."); + run("npx", ["expo", "prebuild", "--platform", "ios", "--non-interactive"], { + cwd: appDir, + }); + + const nativeProject = getNativeProject(); + console.log(`Building iOS app (${nativeProject.scheme}); log: ${nativeBuildLog}`); + buildApp(nativeProject); + console.log("Installing iOS app..."); + installApp(nativeProject); + console.log("Launching iOS app..."); + launchApp(); + hideNativeSimulatorApp(); + } catch (error) { + process.exitCode = 1; + console.error("iOS app build/install failed; leaving preview running for manual QA."); + console.error(error instanceof Error ? error.message : error); + } + + await waitForExit(metro); + await cleanup(); +} + +function buildApp(nativeProject) { + run( + "xcodebuild", + [ + ...nativeProject.args, + "-scheme", + nativeProject.scheme, + "-configuration", + "Debug", + "-destination", + `id=${simulatorUdid}`, + "-derivedDataPath", + nativeProject.derivedDataPath, + "build", + ], + { cwd: appDir, logFile: nativeBuildLog }, + ); +} + +function installApp(nativeProject) { + const appPath = findBuiltApp(nativeProject.derivedDataPath); + run("xcrun", ["simctl", "install", simulatorUdid, appPath], { cwd: appDir }); +} + +function launchApp() { + const metroUrl = encodeURIComponent(`http://127.0.0.1:${paseoPort}`); + run( + "xcrun", + ["simctl", "openurl", simulatorUdid, `${appScheme}://expo-development-client/?url=${metroUrl}`], + { cwd: appDir }, + ); +} + +function startMetro() { + const child = spawn("npx", ["expo", "start", "--port", paseoPort, "--localhost"], { + cwd: appDir, + env: { + ...env, + PASEO_SERVE_SIM_PREVIEW: "1", + PASEO_SERVE_SIM_DEVICE_UDID: simulatorUdid, + BROWSER: "none", + }, + stdio: "inherit", + }); + child.on("exit", (code, signal) => { + if (!shuttingDown && code !== 0) { + console.error(`Metro exited with ${signal || code}`); + process.exitCode = code || 1; + } + }); + return child; +} + +function getNativeProject() { + const workspace = firstPath(join(appDir, "ios"), (name) => name.endsWith(".xcworkspace")); + const project = firstPath(join(appDir, "ios"), (name) => name.endsWith(".xcodeproj")); + const projectFile = workspace || project; + if (!projectFile) { + throw new Error("Expo prebuild did not create an iOS workspace or project."); + } + return { + args: workspace ? ["-workspace", workspace] : ["-project", projectFile], + scheme: getScheme(workspace ? ["-workspace", workspace] : ["-project", projectFile]), + derivedDataPath: join(rootDir, ".dev", "ios-build", simulatorSlug()), + }; +} + +function getScheme(projectArgs) { + const output = run("xcodebuild", [...projectArgs, "-list", "-json"], { + cwd: appDir, + capture: true, + }); + const list = parseJson(output); + const schemes = list.workspace?.schemes || list.project?.schemes || []; + const scheme = schemes.find((value) => value === appProductName) || schemes[0]; + if (!scheme) throw new Error("No iOS scheme found after Expo prebuild."); + return scheme; +} + +function findBuiltApp(derivedDataPath) { + const productsDir = join(derivedDataPath, "Build", "Products", "Debug-iphonesimulator"); + const appPath = join(productsDir, `${appProductName}.app`); + if (existsSync(appPath)) return appPath; + const fallback = firstPath(productsDir, (name) => name.endsWith(".app")); + if (fallback) return fallback; + throw new Error(`Built app was not found in ${productsDir}`); +} + +function findSimulator(name) { + const output = parseJson(run("xcrun", ["simctl", "list", "devices", "-j"], { capture: true })); + for (const devices of Object.values(output.devices || {})) { + for (const device of devices) { + if (device.name === name && device.isAvailable !== false) { + return device.udid; + } + } + } + return null; +} + +function createSimulator(name) { + return run("xcrun", ["simctl", "create", name, resolveSimulatorType()], { + capture: true, + }).trim(); +} + +function resolveSimulatorType() { + const output = parseJson( + run("xcrun", ["simctl", "list", "devicetypes", "-j"], { + capture: true, + }), + ); + const types = output.devicetypes || []; + const preferred = types.find((type) => type.name === preferredSimulatorType); + if (preferred) return preferred.identifier; + const fallback = + types.find((type) => /^iPhone .* Pro$/.test(type.name)) || + types.find((type) => /^iPhone\b/.test(type.name)); + if (!fallback) throw new Error("No iPhone simulator device type is installed."); + return fallback.identifier; +} + +function bootSimulator(udid) { + spawnSync("xcrun", ["simctl", "boot", udid], { stdio: "ignore" }); + waitForBootedSimulator(udid); +} + +function waitForBootedSimulator(udid) { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + hideNativeSimulatorApp(); + const output = parseJson(run("xcrun", ["simctl", "list", "devices", "-j"], { capture: true })); + for (const devices of Object.values(output.devices || {})) { + for (const device of devices) { + if (device.udid === udid && device.state === "Booted") return; + } + } + spawnSync("sleep", ["1"]); + } + throw new Error(`Timed out waiting for simulator ${udid} to boot.`); +} + +async function cleanup() { + if (shuttingDown) return; + shuttingDown = true; + stopSimulatorVisibilityGuard(); + if (metro && !metro.killed) { + metro.kill("SIGTERM"); + } + if (simulatorUdid) { + spawnSync("npx", ["serve-sim", "--kill", simulatorUdid], { + cwd: rootDir, + stdio: "ignore", + env, + }); + spawnSync("xcrun", ["simctl", "shutdown", simulatorUdid], { stdio: "ignore" }); + } +} + +async function shutdown() { + await cleanup(); + process.exit(); +} + +function run(command, args, options = {}) { + if (options.logFile) { + return runWithLogFile(command, args, options); + } + const stdio = options.capture || options.logFile ? ["ignore", "pipe", "pipe"] : "inherit"; + const result = spawnSync(command, args, { + cwd: options.cwd || rootDir, + env, + encoding: "utf8", + stdio, + }); + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); + throw new Error(`${command} ${args.join(" ")} failed${output ? `\n${output}` : ""}`); + } + return result.stdout || ""; +} + +function runWithLogFile(command, args, options) { + mkdirSync(dirname(options.logFile), { recursive: true }); + const logFile = openSync(options.logFile, "w"); + try { + const result = spawnSync(command, args, { + cwd: options.cwd || rootDir, + env, + encoding: "utf8", + stdio: ["ignore", logFile, logFile], + }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed; see ${options.logFile}`); + } + return ""; + } finally { + closeSync(logFile); + } +} + +function hideNativeSimulatorApp() { + spawnSync( + "osascript", + [ + "-e", + 'tell application "System Events"', + "-e", + 'if exists application process "Simulator" then set visible of application process "Simulator" to false', + "-e", + "end tell", + ], + { stdio: "ignore", timeout: 2_000 }, + ); +} + +function startSimulatorVisibilityGuard() { + hideNativeSimulatorApp(); + simulatorVisibilityGuard = setInterval(hideNativeSimulatorApp, 250); + simulatorVisibilityGuard.unref?.(); +} + +function stopSimulatorVisibilityGuard() { + if (simulatorVisibilityGuard) { + clearInterval(simulatorVisibilityGuard); + simulatorVisibilityGuard = undefined; + } +} + +function parseJson(value) { + try { + return JSON.parse(value); + } catch (error) { + throw new Error(`Could not parse JSON: ${error instanceof Error ? error.message : error}`, { + cause: error, + }); + } +} + +function firstPath(dir, predicate) { + try { + const name = readdirSync(dir).find(predicate); + return name ? join(dir, name) : null; + } catch { + return null; + } +} + +function simulatorSlug() { + return `${simulatorName.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}-${worktreeHash}`; +} + +function requiredEnv(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required; run this as a Paseo service.`); + return value; +} + +async function waitForUrl(url) { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + try { + const response = await fetch(url); + if (response.ok) return; + } catch {} + await new Promise((done) => setTimeout(done, 1000)); + } + throw new Error(`Timed out waiting for ${url}`); +} + +async function waitForExit(child) { + return new Promise((done) => child.on("exit", done)); +} diff --git a/scripts/paseo-ios-simulator-service.sh b/scripts/paseo-ios-simulator-service.sh new file mode 100755 index 000000000..517ba2c07 --- /dev/null +++ b/scripts/paseo-ios-simulator-service.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +exec node "$SCRIPT_DIR/paseo-ios-simulator-service.mjs" diff --git a/scripts/seed-ios-native-cache.mjs b/scripts/seed-ios-native-cache.mjs new file mode 100644 index 000000000..cbd159c8c --- /dev/null +++ b/scripts/seed-ios-native-cache.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { cpSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; +import { basename, join } from "node:path"; + +const sourceRoot = process.env.PASEO_SOURCE_CHECKOUT_PATH; +const targetRoot = process.env.PASEO_WORKTREE_PATH || process.cwd(); + +if (process.env.PASEO_SKIP_IOS_NATIVE_CACHE === "1") { + process.exit(0); +} + +if (!sourceRoot || sourceRoot === targetRoot) { + process.exit(0); +} + +seedDirectory({ + label: "iOS project", + source: join(sourceRoot, "packages/app/ios"), + target: join(targetRoot, "packages/app/ios"), +}); + +seedDirectory({ + label: "iOS derived data", + source: newestDirectory(join(sourceRoot, ".dev/ios-build")), + target: join(targetRoot, ".dev/ios-build", simulatorSlug()), +}); + +function seedDirectory({ label, source, target }) { + if (!source || !existsSync(source) || existsSync(target)) { + return; + } + + try { + mkdirSync(join(target, ".."), { recursive: true }); + cpSync(source, target, { + recursive: true, + preserveTimestamps: true, + errorOnExist: false, + force: false, + }); + console.log(`Seeded ${label} cache from ${source}`); + } catch (error) { + console.warn(`Skipping ${label} cache seed: ${error instanceof Error ? error.message : error}`); + } +} + +function newestDirectory(parent) { + try { + return readdirSync(parent) + .map((name) => join(parent, name)) + .filter((path) => statSync(path).isDirectory()) + .sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs)[0]; + } catch { + return null; + } +} + +function simulatorSlug() { + const worktreeName = process.env.PASEO_BRANCH_NAME || basename(targetRoot); + const worktreeHash = createHash("sha1").update(targetRoot).digest("hex").slice(0, 8); + const simulatorName = `Paseo ${worktreeName} ${worktreeHash}`; + return `${simulatorName.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}-${worktreeHash}`; +}