feat: add iOS simulator worktree preview
Adds the worktree service, setup cache seeding, and Metro preview mount for /.sim. Also includes the related startup and mobile sidebar fixes needed by the updated worktree app flow.
This commit is contained in:
@@ -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.
|
- **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.
|
- **`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.
|
- **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:
|
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.
|
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
|
### Desktop renderer profiling
|
||||||
|
|
||||||
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
|
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
|
||||||
|
|||||||
Generated
+30
@@ -24193,6 +24193,19 @@
|
|||||||
"css-in-js-utils": "^3.1.0"
|
"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": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@@ -32923,6 +32936,22 @@
|
|||||||
"seroval": "^1.0"
|
"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": {
|
"node_modules/serve-static": {
|
||||||
"version": "1.16.3",
|
"version": "1.16.3",
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||||
@@ -37049,6 +37078,7 @@
|
|||||||
"jsdom": "^20.0.3",
|
"jsdom": "^20.0.3",
|
||||||
"material-icon-theme": "^5.32.0",
|
"material-icon-theme": "^5.32.0",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
|
"serve-sim": "^0.1.40",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"vitest": "^4.1.6",
|
"vitest": "^4.1.6",
|
||||||
"wrangler": "^4.75.0",
|
"wrangler": "^4.75.0",
|
||||||
|
|||||||
@@ -78,4 +78,31 @@ config.resolver.resolveRequest = (context, moduleName, platform) => {
|
|||||||
return resolveWithCustomWebOverlay(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;
|
module.exports = config;
|
||||||
|
|||||||
@@ -125,6 +125,7 @@
|
|||||||
"jsdom": "^20.0.3",
|
"jsdom": "^20.0.3",
|
||||||
"material-icon-theme": "^5.32.0",
|
"material-icon-theme": "^5.32.0",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
|
"serve-sim": "^0.1.40",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"vitest": "^4.1.6",
|
"vitest": "^4.1.6",
|
||||||
"wrangler": "^4.75.0",
|
"wrangler": "^4.75.0",
|
||||||
|
|||||||
@@ -41,15 +41,20 @@ import {
|
|||||||
} from "@/contexts/horizontal-scroll-context";
|
} from "@/contexts/horizontal-scroll-context";
|
||||||
import { SessionProvider } from "@/contexts/session-context";
|
import { SessionProvider } from "@/contexts/session-context";
|
||||||
import {
|
import {
|
||||||
MOBILE_VISUAL_PANEL_AGENT,
|
|
||||||
MOBILE_VISUAL_PANEL_AGENT_LIST,
|
|
||||||
SidebarAnimationProvider,
|
SidebarAnimationProvider,
|
||||||
useSidebarAnimation,
|
useSidebarAnimation,
|
||||||
} from "@/contexts/sidebar-animation-context";
|
} from "@/contexts/sidebar-animation-context";
|
||||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
||||||
import { ToastProvider } from "@/contexts/toast-context";
|
import { ToastProvider } from "@/contexts/toast-context";
|
||||||
import { VoiceProvider } from "@/contexts/voice-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 { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
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 { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
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 { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
@@ -70,7 +74,6 @@ import { polyfillCrypto } from "@/polyfills/crypto";
|
|||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
hasConfiguredLocalDaemonOverride,
|
|
||||||
useHostMutations,
|
useHostMutations,
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
useHosts,
|
useHosts,
|
||||||
@@ -82,6 +85,7 @@ import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
|||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { resolveActiveHost } from "@/utils/active-host";
|
import { resolveActiveHost } from "@/utils/active-host";
|
||||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||||
|
import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||||
import {
|
import {
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
@@ -103,6 +107,7 @@ export interface HostRuntimeBootstrapState {
|
|||||||
retry: () => void;
|
retry: () => void;
|
||||||
hasGivenUpWaitingForHost: boolean;
|
hasGivenUpWaitingForHost: boolean;
|
||||||
storeReady: boolean;
|
storeReady: boolean;
|
||||||
|
startupBlocker: StartupBlocker;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||||
@@ -110,6 +115,7 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
|||||||
retry: () => {},
|
retry: () => {},
|
||||||
hasGivenUpWaitingForHost: false,
|
hasGivenUpWaitingForHost: false,
|
||||||
storeReady: false,
|
storeReady: false,
|
||||||
|
startupBlocker: { kind: "none" },
|
||||||
});
|
});
|
||||||
|
|
||||||
function PushNotificationRouter() {
|
function PushNotificationRouter() {
|
||||||
@@ -313,18 +319,26 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||||
const daemonStartError = useDaemonStartLastError();
|
const daemonStartError = useDaemonStartLastError();
|
||||||
const daemonStartIsRunning = useDaemonStartIsRunning();
|
const daemonStartIsRunning = useDaemonStartIsRunning();
|
||||||
const waitForConfiguredLocalDaemon =
|
|
||||||
hasConfiguredLocalDaemonOverride() && !shouldUseDesktopDaemon();
|
|
||||||
|
|
||||||
const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (
|
if (!shouldRunGiveUpTimer) {
|
||||||
anyOnlineHostServerId ||
|
|
||||||
daemonStartError ||
|
|
||||||
daemonStartIsRunning ||
|
|
||||||
waitForConfiguredLocalDaemon ||
|
|
||||||
hasGivenUpWaitingForHost
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const handle = setTimeout(() => {
|
const handle = setTimeout(() => {
|
||||||
@@ -333,13 +347,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => {
|
return () => {
|
||||||
clearTimeout(handle);
|
clearTimeout(handle);
|
||||||
};
|
};
|
||||||
}, [
|
}, [shouldRunGiveUpTimer]);
|
||||||
anyOnlineHostServerId,
|
|
||||||
daemonStartError,
|
|
||||||
daemonStartIsRunning,
|
|
||||||
waitForConfiguredLocalDaemon,
|
|
||||||
hasGivenUpWaitingForHost,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const retry = useCallback(() => {
|
const retry = useCallback(() => {
|
||||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||||
@@ -350,14 +358,13 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const splashError = !anyOnlineHostServerId ? daemonStartError : null;
|
const splashError =
|
||||||
const isCurrentlyStoreReady =
|
startupBlocker.kind === "managed-daemon-error" ? startupBlocker.message : null;
|
||||||
Boolean(anyOnlineHostServerId) || Boolean(splashError) || hasGivenUpWaitingForHost;
|
const storeReady = resolveStartupNavigationReady({ startupBlocker });
|
||||||
const storeReady = useLatchedBoolean(isCurrentlyStoreReady);
|
|
||||||
|
|
||||||
const state = useMemo<HostRuntimeBootstrapState>(
|
const state = useMemo<HostRuntimeBootstrapState>(
|
||||||
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady }),
|
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker }),
|
||||||
[splashError, retry, hasGivenUpWaitingForHost, storeReady],
|
[splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -504,7 +511,7 @@ function MobileGestureWrapper({
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
mobileVisualPanel,
|
mobilePanelState,
|
||||||
gestureAnimatingRef,
|
gestureAnimatingRef,
|
||||||
openGestureRef,
|
openGestureRef,
|
||||||
} = useSidebarAnimation();
|
} = useSidebarAnimation();
|
||||||
@@ -540,7 +547,7 @@ function MobileGestureWrapper({
|
|||||||
const absDeltaX = Math.abs(deltaX);
|
const absDeltaX = Math.abs(deltaX);
|
||||||
const absDeltaY = Math.abs(deltaY);
|
const absDeltaY = Math.abs(deltaY);
|
||||||
|
|
||||||
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) {
|
if (!canOpenLeftSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
|
||||||
stateManager.fail();
|
stateManager.fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -586,11 +593,9 @@ function MobileGestureWrapper({
|
|||||||
isGesturing.value = false;
|
isGesturing.value = false;
|
||||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
|
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
runOnJS(handleGestureOpen)();
|
runOnJS(handleGestureOpen)();
|
||||||
} else {
|
} else {
|
||||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
|
||||||
animateToClose();
|
animateToClose();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -602,7 +607,7 @@ function MobileGestureWrapper({
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
mobileVisualPanel,
|
mobilePanelState,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
handleGestureOpen,
|
handleGestureOpen,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
resolveStartupBlocker,
|
||||||
|
resolveStartupNavigationReady,
|
||||||
resolveStartupRedirectRoute,
|
resolveStartupRedirectRoute,
|
||||||
resolveStartupWorkspaceSelection,
|
resolveStartupWorkspaceSelection,
|
||||||
|
shouldRunStartupGiveUpTimer,
|
||||||
startHostRuntimeBootstrap,
|
startHostRuntimeBootstrap,
|
||||||
WELCOME_ROUTE,
|
WELCOME_ROUTE,
|
||||||
} from "./host-runtime-bootstrap";
|
} 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", () => {
|
describe("resolveStartupRedirectRoute", () => {
|
||||||
const baseInput = {
|
const baseInput = {
|
||||||
pathname: "/",
|
pathname: "/",
|
||||||
|
|||||||
@@ -58,6 +58,56 @@ export function startDaemonIfGateAllows(input: {
|
|||||||
|
|
||||||
export const WELCOME_ROUTE: Href = "/welcome";
|
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 {
|
export interface ResolveStartupRedirectInput {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
anyOnlineHostServerId: string | null;
|
anyOnlineHostServerId: string | null;
|
||||||
|
|||||||
@@ -23,11 +23,8 @@ import {
|
|||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import {
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
MOBILE_VISUAL_PANEL_AGENT,
|
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||||
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
|
|
||||||
useSidebarAnimation,
|
|
||||||
} from "@/contexts/sidebar-animation-context";
|
|
||||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { GitDiffPane } from "@/git/diff-pane";
|
import { GitDiffPane } from "@/git/diff-pane";
|
||||||
import { FileExplorerPane } from "./file-explorer-pane";
|
import { FileExplorerPane } from "./file-explorer-pane";
|
||||||
@@ -68,7 +65,7 @@ export function ExplorerSidebar({
|
|||||||
const { width: viewportWidth } = useWindowDimensions();
|
const { width: viewportWidth } = useWindowDimensions();
|
||||||
const closeTouchStartX = useSharedValue(0);
|
const closeTouchStartX = useSharedValue(0);
|
||||||
const closeTouchStartY = useSharedValue(0);
|
const closeTouchStartY = useSharedValue(0);
|
||||||
const { mobileVisualPanel, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
|
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
|
||||||
useSidebarAnimation();
|
useSidebarAnimation();
|
||||||
|
|
||||||
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
||||||
@@ -166,7 +163,7 @@ export function ExplorerSidebar({
|
|||||||
const absDeltaX = Math.abs(deltaX);
|
const absDeltaX = Math.abs(deltaX);
|
||||||
const absDeltaY = Math.abs(deltaY);
|
const absDeltaY = Math.abs(deltaY);
|
||||||
|
|
||||||
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_FILE_EXPLORER) {
|
if (!canCloseRightSidebarGesture(mobilePanelState.value)) {
|
||||||
stateManager.fail();
|
stateManager.fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -206,11 +203,9 @@ export function ExplorerSidebar({
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
});
|
});
|
||||||
if (shouldClose) {
|
if (shouldClose) {
|
||||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
|
||||||
animateToClose();
|
animateToClose();
|
||||||
runOnJS(handleCloseFromGesture)();
|
runOnJS(handleCloseFromGesture)();
|
||||||
} else {
|
} else {
|
||||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
|
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -222,7 +217,7 @@ export function ExplorerSidebar({
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
mobileVisualPanel,
|
mobilePanelState,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
handleCloseFromGesture,
|
handleCloseFromGesture,
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
|
||||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||||
|
|
||||||
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
|
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
|
||||||
const storeReady = useStoreReady();
|
|
||||||
const bootstrapState = useHostRuntimeBootstrapState();
|
const bootstrapState = useHostRuntimeBootstrapState();
|
||||||
const isDesktop = shouldUseDesktopDaemon();
|
|
||||||
|
|
||||||
if (!storeReady) {
|
if (bootstrapState.startupBlocker.kind !== "none") {
|
||||||
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
|
|||||||
@@ -40,11 +40,7 @@ import { Shortcut } from "@/components/ui/shortcut";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import {
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
MOBILE_VISUAL_PANEL_AGENT,
|
|
||||||
MOBILE_VISUAL_PANEL_AGENT_LIST,
|
|
||||||
useSidebarAnimation,
|
|
||||||
} from "@/contexts/sidebar-animation-context";
|
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||||
@@ -64,6 +60,7 @@ import {
|
|||||||
import { resolveActiveHost } from "@/utils/active-host";
|
import { resolveActiveHost } from "@/utils/active-host";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
|
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||||
import {
|
import {
|
||||||
buildHostOpenProjectRoute,
|
buildHostOpenProjectRoute,
|
||||||
buildHostNewWorkspaceRoute,
|
buildHostNewWorkspaceRoute,
|
||||||
@@ -581,7 +578,7 @@ function MobileSidebar({
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
mobileVisualPanel,
|
mobilePanelState,
|
||||||
gestureAnimatingRef,
|
gestureAnimatingRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
} = useSidebarAnimation();
|
} = useSidebarAnimation();
|
||||||
@@ -645,7 +642,7 @@ function MobileSidebar({
|
|||||||
const absDeltaX = Math.abs(deltaX);
|
const absDeltaX = Math.abs(deltaX);
|
||||||
const absDeltaY = Math.abs(deltaY);
|
const absDeltaY = Math.abs(deltaY);
|
||||||
|
|
||||||
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT_LIST) {
|
if (!canCloseLeftSidebarGesture(mobilePanelState.value)) {
|
||||||
stateManager.fail();
|
stateManager.fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -679,11 +676,9 @@ function MobileSidebar({
|
|||||||
isGesturing.value = false;
|
isGesturing.value = false;
|
||||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
||||||
if (shouldClose) {
|
if (shouldClose) {
|
||||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
|
||||||
animateToClose();
|
animateToClose();
|
||||||
runOnJS(handleCloseFromGesture)();
|
runOnJS(handleCloseFromGesture)();
|
||||||
} else {
|
} else {
|
||||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
|
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -695,7 +690,7 @@ function MobileSidebar({
|
|||||||
closeTouchStartX,
|
closeTouchStartX,
|
||||||
closeTouchStartY,
|
closeTouchStartY,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
mobileVisualPanel,
|
mobilePanelState,
|
||||||
windowWidth,
|
windowWidth,
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
|
|||||||
@@ -11,12 +11,7 @@ import { useWindowDimensions } from "react-native";
|
|||||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import {
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
MOBILE_VISUAL_PANEL_AGENT,
|
|
||||||
MOBILE_VISUAL_PANEL_AGENT_LIST,
|
|
||||||
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
|
|
||||||
useSidebarAnimation,
|
|
||||||
} from "@/contexts/sidebar-animation-context";
|
|
||||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
getRightSidebarAnimationTargets,
|
getRightSidebarAnimationTargets,
|
||||||
@@ -41,18 +36,8 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number {
|
|
||||||
if (mobileView === "agent-list") {
|
|
||||||
return MOBILE_VISUAL_PANEL_AGENT_LIST;
|
|
||||||
}
|
|
||||||
if (mobileView === "file-explorer") {
|
|
||||||
return MOBILE_VISUAL_PANEL_FILE_EXPLORER;
|
|
||||||
}
|
|
||||||
return MOBILE_VISUAL_PANEL_AGENT;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||||
const { mobileVisualPanel } = useSidebarAnimation();
|
const { startMobilePanelTransition, settleMobilePanel } = useSidebarAnimation();
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
@@ -84,6 +69,9 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
});
|
});
|
||||||
const didMobileViewChange = prevMobileView.current !== mobileView;
|
const didMobileViewChange = prevMobileView.current !== mobileView;
|
||||||
const previousIsOpen = prevIsOpen.current;
|
const previousIsOpen = prevIsOpen.current;
|
||||||
|
const previousMobileView = prevMobileView.current;
|
||||||
|
const ownsMobileViewChange =
|
||||||
|
previousMobileView === "file-explorer" || mobileView === "file-explorer";
|
||||||
prevIsOpen.current = isOpen;
|
prevIsOpen.current = isOpen;
|
||||||
prevMobileView.current = mobileView;
|
prevMobileView.current = mobileView;
|
||||||
prevWindowWidth.current = windowWidth;
|
prevWindowWidth.current = windowWidth;
|
||||||
@@ -102,17 +90,49 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCompactLayout) {
|
|
||||||
mobileVisualPanel.value = getMobileVisualPanel(mobileView);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
|
|
||||||
if (previousIsOpen !== isOpen) {
|
if (previousIsOpen !== isOpen) {
|
||||||
translateX.value = withTiming(targets.translateX, {
|
if (isOpen) {
|
||||||
duration: ANIMATION_DURATION,
|
if (isCompactLayout) {
|
||||||
easing: ANIMATION_EASING,
|
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, {
|
backdropOpacity.value = withTiming(targets.backdropOpacity, {
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
@@ -122,6 +142,9 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
|
|
||||||
translateX.value = targets.translateX;
|
translateX.value = targets.translateX;
|
||||||
backdropOpacity.value = targets.backdropOpacity;
|
backdropOpacity.value = targets.backdropOpacity;
|
||||||
|
if (isCompactLayout && ownsMobileViewChange) {
|
||||||
|
settleMobilePanel(mobileView);
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
isOpen,
|
isOpen,
|
||||||
mobileView,
|
mobileView,
|
||||||
@@ -130,32 +153,49 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
isCompactLayout,
|
isCompactLayout,
|
||||||
mobileVisualPanel,
|
startMobilePanelTransition,
|
||||||
|
settleMobilePanel,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const animateToOpen = useCallback(() => {
|
const animateToOpen = useCallback(() => {
|
||||||
"worklet";
|
"worklet";
|
||||||
translateX.value = withTiming(0, {
|
startMobilePanelTransition("file-explorer");
|
||||||
duration: ANIMATION_DURATION,
|
translateX.value = withTiming(
|
||||||
easing: ANIMATION_EASING,
|
0,
|
||||||
});
|
{
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
easing: ANIMATION_EASING,
|
||||||
|
},
|
||||||
|
(finished) => {
|
||||||
|
if (!finished) return;
|
||||||
|
settleMobilePanel("file-explorer");
|
||||||
|
},
|
||||||
|
);
|
||||||
backdropOpacity.value = withTiming(1, {
|
backdropOpacity.value = withTiming(1, {
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
}, [translateX, backdropOpacity]);
|
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
|
||||||
|
|
||||||
const animateToClose = useCallback(() => {
|
const animateToClose = useCallback(() => {
|
||||||
"worklet";
|
"worklet";
|
||||||
translateX.value = withTiming(windowWidth, {
|
startMobilePanelTransition("agent");
|
||||||
duration: ANIMATION_DURATION,
|
translateX.value = withTiming(
|
||||||
easing: ANIMATION_EASING,
|
windowWidth,
|
||||||
});
|
{
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
easing: ANIMATION_EASING,
|
||||||
|
},
|
||||||
|
(finished) => {
|
||||||
|
if (!finished) return;
|
||||||
|
settleMobilePanel("agent");
|
||||||
|
},
|
||||||
|
);
|
||||||
backdropOpacity.value = withTiming(0, {
|
backdropOpacity.value = withTiming(0, {
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
}, [translateX, backdropOpacity, windowWidth]);
|
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
|
||||||
|
|
||||||
const value = useMemo<ExplorerSidebarAnimationContextValue>(
|
const value = useMemo<ExplorerSidebarAnimationContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ import { isNative } from "@/constants/platform";
|
|||||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
getLeftSidebarAnimationTargets,
|
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,
|
shouldSyncSidebarAnimation,
|
||||||
} from "@/utils/sidebar-animation-state";
|
} from "@/utils/sidebar-animation-state";
|
||||||
|
|
||||||
@@ -30,8 +41,11 @@ interface SidebarAnimationContextValue {
|
|||||||
windowWidth: number;
|
windowWidth: number;
|
||||||
animateToOpen: () => void;
|
animateToOpen: () => void;
|
||||||
animateToClose: () => void;
|
animateToClose: () => void;
|
||||||
|
startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
|
||||||
|
settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
|
||||||
isGesturing: SharedValue<boolean>;
|
isGesturing: SharedValue<boolean>;
|
||||||
mobileVisualPanel: SharedValue<number>;
|
mobileVisualPanel: SharedValue<number>;
|
||||||
|
mobilePanelState: SharedValue<number>;
|
||||||
gestureAnimatingRef: React.MutableRefObject<boolean>;
|
gestureAnimatingRef: React.MutableRefObject<boolean>;
|
||||||
openGestureRef: React.MutableRefObject<GestureType | undefined>;
|
openGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||||
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||||
@@ -49,6 +63,16 @@ function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explore
|
|||||||
return MOBILE_VISUAL_PANEL_AGENT;
|
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 }) {
|
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
@@ -63,6 +87,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||||
const isGesturing = useSharedValue(false);
|
const isGesturing = useSharedValue(false);
|
||||||
const mobileVisualPanel = useSharedValue(getMobileVisualPanel(mobileView));
|
const mobileVisualPanel = useSharedValue(getMobileVisualPanel(mobileView));
|
||||||
|
const mobilePanelState = useSharedValue(getSettledMobilePanelState(mobileView));
|
||||||
|
const mobilePanelTarget = useSharedValue(getMobileVisualPanel(mobileView));
|
||||||
const gestureAnimatingRef = useRef(false);
|
const gestureAnimatingRef = useRef(false);
|
||||||
const openGestureRef = useRef<GestureType | undefined>(undefined);
|
const openGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
@@ -72,6 +98,71 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
const prevMobileView = useRef(mobileView);
|
const prevMobileView = useRef(mobileView);
|
||||||
const prevWindowWidth = useRef(windowWidth);
|
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)
|
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const didStateChange = shouldSyncSidebarAnimation({
|
const didStateChange = shouldSyncSidebarAnimation({
|
||||||
@@ -82,6 +173,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
});
|
});
|
||||||
const didMobileViewChange = prevMobileView.current !== mobileView;
|
const didMobileViewChange = prevMobileView.current !== mobileView;
|
||||||
const previousIsOpen = prevIsOpen.current;
|
const previousIsOpen = prevIsOpen.current;
|
||||||
|
const previousMobileView = prevMobileView.current;
|
||||||
|
const ownsMobileViewChange = previousMobileView === "agent-list" || mobileView === "agent-list";
|
||||||
prevIsOpen.current = isOpen;
|
prevIsOpen.current = isOpen;
|
||||||
prevMobileView.current = mobileView;
|
prevMobileView.current = mobileView;
|
||||||
prevWindowWidth.current = windowWidth;
|
prevWindowWidth.current = windowWidth;
|
||||||
@@ -108,17 +201,49 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCompactLayout) {
|
|
||||||
mobileVisualPanel.value = getMobileVisualPanel(mobileView);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
|
|
||||||
if (previousIsOpen !== isOpen) {
|
if (previousIsOpen !== isOpen) {
|
||||||
translateX.value = withTiming(targets.translateX, {
|
if (isOpen) {
|
||||||
duration: ANIMATION_DURATION,
|
if (isCompactLayout) {
|
||||||
easing: ANIMATION_EASING,
|
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, {
|
backdropOpacity.value = withTiming(targets.backdropOpacity, {
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
@@ -128,6 +253,9 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
|
|
||||||
translateX.value = targets.translateX;
|
translateX.value = targets.translateX;
|
||||||
backdropOpacity.value = targets.backdropOpacity;
|
backdropOpacity.value = targets.backdropOpacity;
|
||||||
|
if (isCompactLayout && ownsMobileViewChange) {
|
||||||
|
settleMobilePanel(mobileView);
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
isOpen,
|
isOpen,
|
||||||
mobileView,
|
mobileView,
|
||||||
@@ -137,31 +265,50 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
isGesturing,
|
isGesturing,
|
||||||
isCompactLayout,
|
isCompactLayout,
|
||||||
mobileVisualPanel,
|
mobileVisualPanel,
|
||||||
|
mobilePanelState,
|
||||||
|
startMobilePanelTransition,
|
||||||
|
settleMobilePanel,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const animateToOpen = useCallback(() => {
|
const animateToOpen = useCallback(() => {
|
||||||
"worklet";
|
"worklet";
|
||||||
translateX.value = withTiming(0, {
|
startMobilePanelTransition("agent-list");
|
||||||
duration: ANIMATION_DURATION,
|
translateX.value = withTiming(
|
||||||
easing: ANIMATION_EASING,
|
0,
|
||||||
});
|
{
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
easing: ANIMATION_EASING,
|
||||||
|
},
|
||||||
|
(finished) => {
|
||||||
|
if (!finished) return;
|
||||||
|
settleMobilePanel("agent-list");
|
||||||
|
},
|
||||||
|
);
|
||||||
backdropOpacity.value = withTiming(1, {
|
backdropOpacity.value = withTiming(1, {
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
}, [translateX, backdropOpacity]);
|
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
|
||||||
|
|
||||||
const animateToClose = useCallback(() => {
|
const animateToClose = useCallback(() => {
|
||||||
"worklet";
|
"worklet";
|
||||||
translateX.value = withTiming(-windowWidth, {
|
startMobilePanelTransition("agent");
|
||||||
duration: ANIMATION_DURATION,
|
translateX.value = withTiming(
|
||||||
easing: ANIMATION_EASING,
|
-windowWidth,
|
||||||
});
|
{
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
easing: ANIMATION_EASING,
|
||||||
|
},
|
||||||
|
(finished) => {
|
||||||
|
if (!finished) return;
|
||||||
|
settleMobilePanel("agent");
|
||||||
|
},
|
||||||
|
);
|
||||||
backdropOpacity.value = withTiming(0, {
|
backdropOpacity.value = withTiming(0, {
|
||||||
duration: ANIMATION_DURATION,
|
duration: ANIMATION_DURATION,
|
||||||
easing: ANIMATION_EASING,
|
easing: ANIMATION_EASING,
|
||||||
});
|
});
|
||||||
}, [translateX, backdropOpacity, windowWidth]);
|
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
|
||||||
|
|
||||||
const value = useMemo<SidebarAnimationContextValue>(
|
const value = useMemo<SidebarAnimationContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -170,8 +317,11 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
|
startMobilePanelTransition,
|
||||||
|
settleMobilePanel,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
mobileVisualPanel,
|
mobileVisualPanel,
|
||||||
|
mobilePanelState,
|
||||||
gestureAnimatingRef,
|
gestureAnimatingRef,
|
||||||
openGestureRef,
|
openGestureRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
@@ -182,8 +332,11 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
|
startMobilePanelTransition,
|
||||||
|
settleMobilePanel,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
mobileVisualPanel,
|
mobileVisualPanel,
|
||||||
|
mobilePanelState,
|
||||||
gestureAnimatingRef,
|
gestureAnimatingRef,
|
||||||
openGestureRef,
|
openGestureRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ import { useCallback, useMemo } from "react";
|
|||||||
import { Gesture } from "react-native-gesture-handler";
|
import { Gesture } from "react-native-gesture-handler";
|
||||||
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import {
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
MOBILE_VISUAL_PANEL_AGENT,
|
|
||||||
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
|
|
||||||
useSidebarAnimation,
|
|
||||||
} from "@/contexts/sidebar-animation-context";
|
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { canOpenRightSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||||
|
|
||||||
interface UseExplorerOpenGestureParams {
|
interface UseExplorerOpenGestureParams {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -28,7 +25,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
openGestureRef,
|
openGestureRef,
|
||||||
} = useExplorerSidebarAnimation();
|
} = useExplorerSidebarAnimation();
|
||||||
const {
|
const {
|
||||||
mobileVisualPanel,
|
mobilePanelState,
|
||||||
gestureAnimatingRef: mobilePanelGestureAnimatingRef,
|
gestureAnimatingRef: mobilePanelGestureAnimatingRef,
|
||||||
openGestureRef: leftOpenGestureRef,
|
openGestureRef: leftOpenGestureRef,
|
||||||
} = useSidebarAnimation();
|
} = useSidebarAnimation();
|
||||||
@@ -68,7 +65,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
const absDeltaX = Math.abs(deltaX);
|
const absDeltaX = Math.abs(deltaX);
|
||||||
const absDeltaY = Math.abs(deltaY);
|
const absDeltaY = Math.abs(deltaY);
|
||||||
|
|
||||||
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) {
|
if (!canOpenRightSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
|
||||||
stateManager.fail();
|
stateManager.fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -117,11 +114,9 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
const shouldOpenByVelocity = event.velocityX < -500;
|
const shouldOpenByVelocity = event.velocityX < -500;
|
||||||
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
|
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
|
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
runOnJS(handleGestureOpen)();
|
runOnJS(handleGestureOpen)();
|
||||||
} else {
|
} else {
|
||||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
|
||||||
animateToClose();
|
animateToClose();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -133,7 +128,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
mobileVisualPanel,
|
mobilePanelState,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
|||||||
@@ -655,21 +655,6 @@ function buildComposerConfig(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeWorkspaceTitle(
|
|
||||||
workspace: ReturnType<typeof normalizeWorkspaceDescriptor> | 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<UserComposerAttachment>): Set<number> {
|
function collectAttachedPrNumbers(attachments: ReadonlyArray<UserComposerAttachment>): Set<number> {
|
||||||
const numbers = new Set<number>();
|
const numbers = new Set<number>();
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
@@ -801,7 +786,6 @@ export function NewWorkspaceScreen({
|
|||||||
projects,
|
projects,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSourceDirectory,
|
selectedSourceDirectory,
|
||||||
selectedDisplayName,
|
|
||||||
projectPickerOptions,
|
projectPickerOptions,
|
||||||
projectByOptionId,
|
projectByOptionId,
|
||||||
selectedProjectOptionId,
|
selectedProjectOptionId,
|
||||||
@@ -1091,12 +1075,6 @@ export function NewWorkspaceScreen({
|
|||||||
[composerState, draftKey, ensureWorkspace, serverId, toast],
|
[composerState, draftKey, ensureWorkspace, serverId, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const workspaceTitle = computeWorkspaceTitle(
|
|
||||||
workspace,
|
|
||||||
selectedDisplayName,
|
|
||||||
selectedSourceDirectory,
|
|
||||||
);
|
|
||||||
|
|
||||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||||
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
|
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
|
||||||
addImagesRef.current = addImages;
|
addImagesRef.current = addImages;
|
||||||
@@ -1308,30 +1286,18 @@ export function NewWorkspaceScreen({
|
|||||||
triggerLabel,
|
triggerLabel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileDropZone onFilesDropped={handleFilesDropped}>
|
<FileDropZone onFilesDropped={handleFilesDropped}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<ScreenHeader
|
<ScreenHeader left={screenHeaderLeft} borderless />
|
||||||
left={
|
|
||||||
<>
|
|
||||||
<SidebarMenuToggle />
|
|
||||||
<View style={styles.headerTitleContainer}>
|
|
||||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
|
||||||
New workspace
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.headerProjectTitle} numberOfLines={1}>
|
|
||||||
{workspaceTitle}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
leftStyle={styles.headerLeft}
|
|
||||||
borderless
|
|
||||||
/>
|
|
||||||
<View style={contentStyle}>
|
<View style={contentStyle}>
|
||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
|
<View style={styles.composerTitleContainer}>
|
||||||
|
<Text style={styles.composerTitle}>New workspace</Text>
|
||||||
|
</View>
|
||||||
<Composer
|
<Composer
|
||||||
agentId={draftKey}
|
agentId={draftKey}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
@@ -1385,29 +1351,15 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: MAX_CONTENT_WIDTH,
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
},
|
},
|
||||||
headerLeft: {
|
composerTitleContainer: {
|
||||||
gap: theme.spacing[2],
|
marginBottom: theme.spacing[8],
|
||||||
|
paddingLeft: theme.spacing[6],
|
||||||
|
paddingRight: theme.spacing[4],
|
||||||
},
|
},
|
||||||
headerTitleContainer: {
|
composerTitle: {
|
||||||
flexShrink: 1,
|
fontSize: theme.fontSize.xl,
|
||||||
minWidth: 0,
|
fontWeight: theme.fontWeight.normal,
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
headerTitle: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
fontWeight: {
|
|
||||||
xs: "400",
|
|
||||||
md: "300",
|
|
||||||
},
|
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
headerProjectTitle: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
flexShrink: 1,
|
|
||||||
},
|
},
|
||||||
errorText: {
|
errorText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
RotateCw,
|
RotateCw,
|
||||||
Rows2,
|
Rows2,
|
||||||
Globe,
|
Globe,
|
||||||
|
Plus,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
X,
|
X,
|
||||||
@@ -48,6 +49,7 @@ import { Shortcut } from "@/components/ui/shortcut";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
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 { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
||||||
import {
|
import {
|
||||||
WorkspaceTabPresentationResolver,
|
WorkspaceTabPresentationResolver,
|
||||||
@@ -66,6 +68,7 @@ import { RenderProfile } from "@/utils/render-profiler";
|
|||||||
|
|
||||||
const DROPDOWN_WIDTH = 220;
|
const DROPDOWN_WIDTH = 220;
|
||||||
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
||||||
|
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
|
||||||
|
|
||||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||||
const ThemedX = withUnistyles(X);
|
const ThemedX = withUnistyles(X);
|
||||||
@@ -80,6 +83,7 @@ const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
|||||||
const ThemedGlobe = withUnistyles(Globe);
|
const ThemedGlobe = withUnistyles(Globe);
|
||||||
const ThemedColumns2 = withUnistyles(Columns2);
|
const ThemedColumns2 = withUnistyles(Columns2);
|
||||||
const ThemedRows2 = withUnistyles(Rows2);
|
const ThemedRows2 = withUnistyles(Rows2);
|
||||||
|
const ThemedPlus = withUnistyles(Plus);
|
||||||
|
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
@@ -88,6 +92,51 @@ function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackTyp
|
|||||||
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
|
return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inlineAddActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) {
|
||||||
|
return [styles.inlineAddActionButton, (hovered || pressed) && styles.newTabActionButtonHovered];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMeasuredWidth(setWidth: Dispatch<SetStateAction<number>>, 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 (
|
||||||
|
<View style={styles.inlineAddButton} onLayout={onLayout}>
|
||||||
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
|
<TooltipTrigger
|
||||||
|
testID="workspace-new-agent-tab-inline"
|
||||||
|
onPress={onPress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="New agent tab"
|
||||||
|
style={inlineAddActionButtonStyle}
|
||||||
|
>
|
||||||
|
<ThemedPlus size={16} uniProps={mutedColorMapping} />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="center" offset={8}>
|
||||||
|
<View style={styles.newTabTooltipRow}>
|
||||||
|
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
||||||
|
{shortcutKeys ? (
|
||||||
|
<Shortcut chord={shortcutKeys} style={styles.newTabTooltipShortcut} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TabContextMenuItem({
|
function TabContextMenuItem({
|
||||||
entry,
|
entry,
|
||||||
}: {
|
}: {
|
||||||
@@ -490,21 +539,27 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
|
const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
|
||||||
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
||||||
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
|
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
|
||||||
|
const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState<number>(0);
|
||||||
|
|
||||||
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
const nextWidth = Math.round(event.nativeEvent.layout.width);
|
updateMeasuredWidth(setTabsContainerWidth, event);
|
||||||
setTabsContainerWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTabsActionsLayout = useCallback((event: LayoutChangeEvent) => {
|
const handleTabsActionsLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
const nextWidth = Math.round(event.nativeEvent.layout.width);
|
updateMeasuredWidth(setTabsActionsWidth, event);
|
||||||
setTabsActionsWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
|
}, []);
|
||||||
|
|
||||||
|
const handleInlineAddButtonLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
|
updateMeasuredWidth(setInlineAddButtonWidth, event);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const layoutMetrics = useMemo(
|
const layoutMetrics = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
rowHorizontalInset: 0,
|
rowHorizontalInset: 0,
|
||||||
actionsReservedWidth: Math.max(0, tabsActionsWidth),
|
actionsReservedWidth: Math.max(
|
||||||
|
0,
|
||||||
|
tabsActionsWidth + (inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH),
|
||||||
|
),
|
||||||
rowPaddingHorizontal: 0,
|
rowPaddingHorizontal: 0,
|
||||||
tabGap: 0,
|
tabGap: 0,
|
||||||
maxTabWidth: 200,
|
maxTabWidth: 200,
|
||||||
@@ -513,7 +568,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
estimatedCharWidth: 7,
|
estimatedCharWidth: 7,
|
||||||
closeButtonWidth: 22,
|
closeButtonWidth: 22,
|
||||||
}),
|
}),
|
||||||
[tabsActionsWidth],
|
[inlineAddButtonWidth, tabsActionsWidth],
|
||||||
);
|
);
|
||||||
|
|
||||||
const tabLabelLengths = useMemo(
|
const tabLabelLengths = useMemo(
|
||||||
@@ -672,6 +727,11 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
getItemData={getTabDragData}
|
getItemData={getTabDragData}
|
||||||
renderItem={renderTab}
|
renderItem={renderTab}
|
||||||
/>
|
/>
|
||||||
|
<WorkspaceInlineAddTabButton
|
||||||
|
shortcutKeys={newTabKeys}
|
||||||
|
onPress={handleCreateAgentTab}
|
||||||
|
onLayout={handleInlineAddButtonLayout}
|
||||||
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
|
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
@@ -928,6 +988,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
inlineAddButton: {
|
||||||
|
paddingHorizontal: theme.spacing[1],
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
tab: {
|
tab: {
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
@@ -1028,6 +1093,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
|
inlineAddActionButton: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
newTabActionButtonDisabled: {
|
newTabActionButtonDisabled: {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
canCloseLeftSidebarGesture,
|
||||||
|
canCloseRightSidebarGesture,
|
||||||
|
canOpenLeftSidebarGesture,
|
||||||
|
canOpenRightSidebarGesture,
|
||||||
getLeftSidebarAnimationTargets,
|
getLeftSidebarAnimationTargets,
|
||||||
getRightSidebarAnimationTargets,
|
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,
|
shouldSyncSidebarAnimation,
|
||||||
} from "./sidebar-animation-state";
|
} from "./sidebar-animation-state";
|
||||||
|
|
||||||
@@ -41,4 +56,56 @@ describe("sidebar-animation-state", () => {
|
|||||||
backdropOpacity: 0,
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ interface SidebarAnimationTargets {
|
|||||||
backdropOpacity: number;
|
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 {
|
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
|
||||||
return (
|
return (
|
||||||
input.previousIsOpen !== input.nextIsOpen || input.previousWindowWidth !== input.nextWindowWidth
|
input.previousIsOpen !== input.nextIsOpen || input.previousWindowWidth !== input.nextWindowWidth
|
||||||
@@ -38,3 +52,45 @@ export function getRightSidebarAnimationTargets(
|
|||||||
backdropOpacity: input.isOpen ? 1 : 0,
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"worktree": {
|
"worktree": {
|
||||||
"setup": [
|
"setup": [
|
||||||
"npm ci",
|
"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",
|
"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:server",
|
||||||
"npm run build --workspace=@getpaseo/expo-two-way-audio",
|
"npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||||
@@ -20,6 +21,10 @@
|
|||||||
"desktop": {
|
"desktop": {
|
||||||
"type": "service",
|
"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"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+356
@@ -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));
|
||||||
|
}
|
||||||
Executable
+6
@@ -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"
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user