From 60fb16c47d497029cc44b4af03bc7112d52b215c Mon Sep 17 00:00:00 2001 From: blobsID Date: Wed, 28 Jan 2026 10:20:09 +0700 Subject: [PATCH] feat(desktop): add Expo build button to TopBar --- apps/desktop/docs/EXPO_BUTTON.md | 56 ++++++ .../workspaces/procedures/detect-expo.ts | 26 +++ .../lib/trpc/routers/workspaces/workspaces.ts | 3 + .../main/components/TopBar/ExpoButton.tsx | 181 ++++++++++++++++++ .../screens/main/components/TopBar/index.tsx | 7 + 5 files changed, 273 insertions(+) create mode 100644 apps/desktop/docs/EXPO_BUTTON.md create mode 100644 apps/desktop/src/lib/trpc/routers/workspaces/procedures/detect-expo.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx diff --git a/apps/desktop/docs/EXPO_BUTTON.md b/apps/desktop/docs/EXPO_BUTTON.md new file mode 100644 index 00000000000..edee0d2b363 --- /dev/null +++ b/apps/desktop/docs/EXPO_BUTTON.md @@ -0,0 +1,56 @@ +# Expo Button + +The Expo button is an icon-only button in the TopBar that lets users run `npx expo run:ios --device` in a dedicated terminal tab. It appears only when an Expo project is detected in the workspace. + +## Detection + +The button queries `workspaces.detectExpo` with the current `worktreePath`. If no Expo project is found, the button is hidden entirely. + +## States + +The button uses the Expo chevron logo (`logo-type-a`) with color to communicate state: + +| State | Color | Behavior | +|-------|-------|----------| +| Idle | `text-muted-foreground` | Click → starts build | +| Starting | `text-muted-foreground` + `opacity-50` | Disabled, waiting for session | +| Running | `text-green-500` | Build is active | +| Running + hover | `text-red-500` | Click → sends Ctrl+C to stop | + +Tooltips provide textual context for each state. + +## Terminal Session Management + +On first click, the button creates a new terminal tab (named "Expo iOS") and runs the command via `createOrAttach` with `initialCommands`. Subsequent clicks reuse the same tab — if the tab still exists, it focuses it and re-runs the command (sending Ctrl+C first to kill any prior process). + +Session tracking uses a ref (`sessionRef`) for imperative tab/pane access and a state (`activePaneId`) to reactively enable the stream subscription. + +## Exit Detection + +The button subscribes to `terminal.stream` for the active pane. When the PTY process emits an `exit` event (crash, shell exit, tab close), the button resets to idle. + +The button also watches the tabs store — if the user closes the Expo tab, state resets to idle. + +## Known Limitation: Child Process Exit + +The terminal stream `exit` event fires when the **shell process** (bash/zsh) exits, not when a child command (`npx expo run:ios`) finishes or is interrupted. This means: + +- **Covered**: Shell crash, PTY death, tab close → button resets to idle +- **Not covered**: User types Ctrl+C in the terminal, Expo command fails/finishes on its own → button stays green + +The root cause is that the PTY layer only tracks the shell PID, not foreground child processes. The codebase does not implement OSC 133 (FinalTerm shell integration protocol), which could detect command completion via `\x1b]133;D` sequences. Adding OSC 133 support would require: + +1. Shell init scripts that emit OSC 133 sequences (zsh/bash/fish) +2. Parsing OSC 133 in the terminal data pipeline (similar to existing OSC-7 CWD tracking in `headless-emulator.ts`) +3. Exposing a "command finished" signal to the renderer + +This is a broader terminal infrastructure change not scoped to the Expo button. See also `plans/20260107-1107-terminal-persistence-dx-hardening.md` which identifies this same limitation for general command completion detection. + +## Files + +| File | Purpose | +|------|---------| +| `TopBar/ExpoButton.tsx` | Button component with state machine and stream subscription | +| `TopBar/index.tsx` | Mounts ExpoButton in the top bar | +| `lib/trpc/routers/workspaces/` | `detectExpo` procedure | +| `lib/trpc/routers/terminal/terminal.ts` | `createOrAttach`, `write`, `stream` procedures | diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/detect-expo.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/detect-expo.ts new file mode 100644 index 00000000000..fd96204fb4b --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/detect-expo.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { publicProcedure, router } from "../../.."; +import { secureFs } from "../../changes/security/secure-fs"; + +export const createDetectExpoProcedures = () => { + return router({ + detectExpo: publicProcedure + .input(z.object({ worktreePath: z.string() })) + .query(async ({ input }) => { + try { + const content = await secureFs.readFile( + input.worktreePath, + "package.json", + ); + const packageJson = JSON.parse(content); + const hasExpo = !!( + packageJson.dependencies?.expo || + packageJson.devDependencies?.expo + ); + return { hasExpo }; + } catch { + return { hasExpo: false }; + } + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index 53918b32db0..1f10e20447d 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -2,6 +2,7 @@ import { mergeRouters } from "../.."; import { createBranchProcedures } from "./procedures/branch"; import { createCreateProcedures } from "./procedures/create"; import { createDeleteProcedures } from "./procedures/delete"; +import { createDetectExpoProcedures } from "./procedures/detect-expo"; import { createGitStatusProcedures } from "./procedures/git-status"; import { createInitProcedures } from "./procedures/init"; import { createQueryProcedures } from "./procedures/query"; @@ -18,6 +19,7 @@ import { createStatusProcedures } from "./procedures/status"; * - git-status: refreshGitStatus, getGitHubStatus, getWorktreeInfo, getWorktreesByProject * - status: reorder, update, setUnread * - init: onInitProgress, retryInit, getInitProgress, getSetupCommands + * - detect-expo: detectExpo */ export const createWorkspacesRouter = () => { return mergeRouters( @@ -28,6 +30,7 @@ export const createWorkspacesRouter = () => { createGitStatusProcedures(), createStatusProcedures(), createInitProcedures(), + createDetectExpoProcedures(), ); }; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx new file mode 100644 index 00000000000..c3b172e2699 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/ExpoButton.tsx @@ -0,0 +1,181 @@ +import { toast } from "@superset/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; + +function ExpoIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +interface ExpoButtonProps { + workspaceId: string; + worktreePath: string; +} + +type ExpoState = "idle" | "starting" | "running"; + +const EXPO_COMMAND = "npx expo run:ios --device"; + +export function ExpoButton({ workspaceId, worktreePath }: ExpoButtonProps) { + const addTab = useTabsStore((state) => state.addTab); + const renameTab = useTabsStore((state) => state.renameTab); + const setActiveTab = useTabsStore((state) => state.setActiveTab); + const tabs = useTabsStore((state) => state.tabs); + + const [expoState, setExpoState] = useState("idle"); + const [isHovered, setIsHovered] = useState(false); + const [activePaneId, setActivePaneId] = useState(null); + const sessionRef = useRef<{ tabId: string; paneId: string } | null>(null); + + const createOrAttach = electronTrpc.terminal.createOrAttach.useMutation({ + onSuccess: () => setExpoState("running"), + onError: (error) => { + toast.error(`Failed to start Expo build: ${error.message}`); + setExpoState("idle"); + sessionRef.current = null; + setActivePaneId(null); + }, + }); + + const writeMutation = electronTrpc.terminal.write.useMutation({ + onError: (error) => { + toast.error(`Terminal write failed: ${error.message}`); + }, + }); + + const { data, isLoading } = electronTrpc.workspaces.detectExpo.useQuery({ + worktreePath, + }); + + // Listen for terminal process exit to reset button state + electronTrpc.terminal.stream.useSubscription(activePaneId ?? "", { + enabled: !!activePaneId, + onData: (event) => { + if (event.type === "exit") { + setExpoState("idle"); + } + }, + }); + + // Reset state if the tracked tab is closed by the user + useEffect(() => { + if (!sessionRef.current) return; + const tabStillExists = tabs.some((t) => t.id === sessionRef.current?.tabId); + if (!tabStillExists) { + setExpoState("idle"); + sessionRef.current = null; + setActivePaneId(null); + } + }, [tabs]); + + const handleStart = useCallback(() => { + if (createOrAttach.isPending || writeMutation.isPending) return; + + const session = sessionRef.current; + + if (session) { + setActiveTab(workspaceId, session.tabId); + setExpoState("starting"); + // \x03 = Ctrl+C (kill any running process), \x15 = Ctrl+U (clear partial input) + writeMutation.mutate( + { paneId: session.paneId, data: `\x03\x15${EXPO_COMMAND}\n` }, + { onSuccess: () => setExpoState("running") }, + ); + } else { + setExpoState("starting"); + const { tabId, paneId } = addTab(workspaceId); + sessionRef.current = { tabId, paneId }; + setActivePaneId(paneId); + createOrAttach.mutate({ + paneId, + tabId, + workspaceId, + initialCommands: [EXPO_COMMAND], + }); + renameTab(tabId, "Expo iOS"); + } + }, [workspaceId, addTab, renameTab, setActiveTab, createOrAttach, writeMutation]); + + const handleStop = useCallback(() => { + const session = sessionRef.current; + if (!session || writeMutation.isPending) return; + + // \x03 = Ctrl+C — terminal driver sends SIGINT to the foreground process group + writeMutation.mutate( + { paneId: session.paneId, data: "\x03" }, + { onSuccess: () => setExpoState("idle") }, + ); + }, [writeMutation]); + + const handleClick = useCallback(() => { + if (expoState === "running" && isHovered) { + handleStop(); + } else if (expoState === "idle") { + handleStart(); + } + }, [expoState, isHovered, handleStart, handleStop]); + + // Hide button if loading or no Expo detected + if (isLoading || !data?.hasExpo) { + return null; + } + + const isDisabled = expoState === "starting" || writeMutation.isPending; + const showStop = expoState === "running" && isHovered; + + let tooltipText: string; + if (isDisabled) { + tooltipText = + expoState === "starting" ? "Starting Expo..." : "Stopping Expo..."; + } else if (showStop) { + tooltipText = "Stop Expo build"; + } else if (expoState === "running") { + tooltipText = "Expo build running"; + } else { + tooltipText = "Run on iOS Device"; + } + + return ( +
+ + + + + {tooltipText} + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx index 9e36080cd8c..c4a513e88b3 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx @@ -1,5 +1,6 @@ import { useParams } from "@tanstack/react-router"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { ExpoButton } from "./ExpoButton"; import { OpenInMenuButton } from "./OpenInMenuButton"; import { OrganizationDropdown } from "./OrganizationDropdown"; import { WindowControls } from "./WindowControls"; @@ -26,6 +27,12 @@ export function TopBar() {
+ {workspace?.worktreePath && workspace?.id && ( + + )} {workspace?.worktreePath && (