From 58fddfbacc36bb9699712b556148f269329f4838 Mon Sep 17 00:00:00 2001 From: Uri Bloch Date: Fri, 9 Oct 2026 12:03:42 -0400 Subject: [PATCH 1/2] feat(run): switch a running Flutter app to another folder without rebuilding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing a run node's folder used to stop the app and `flutter run` again from the new checkout — a full native build for what is usually a Dart-only difference between worktrees. When the running app is the one the new folder would build (same device, same flavor, a Flutter run on both sides — canHotSwitch), the toolbar now offers "Switch (keep app)" beside "Rebuild": 1. type `d` — flutter's own detach: the tool exits, the app keeps running; 2. `flutter attach` from the new folder to the same device; 3. once it has connected (its key help printed after its own launch line), a hot RESTART by SIGUSR2. Not a reload: a freshly attached tool only pushes files changed after it connected. The app's state resets; nothing native is rebuilt. Every failure leaves the app running and says so, with Rebuild one click away. attachArgs keeps only the flags `flutter attach --help` lists (target, dart-defines, app id, VM service URL, device options) and drops run-only ones — attach has no --flavor and rejects unknown options. Flutter refuses a hand-passed FLUTTER_APP_FLAVOR, so after an attach appFlavor is the pubspec default; an app whose lib/ reads appFlavor is refused the switch with that reason. Dart "request": "attach" configurations now run as flutter attach; every other attach is still refused. --- CLAUDE.md | 12 ++- src/core/run-service.test.ts | 27 +++++++ src/core/run-service.ts | 58 +++++++++++++- src/preload/index.ts | 2 +- src/renderer/bridge/ws-bridge.ts | 2 +- src/renderer/nodes/RunBar.tsx | 114 +++++++++++++++++++++++++- src/shared/run-config.test.ts | 79 +++++++++++++++++- src/shared/run-config.ts | 132 +++++++++++++++++++++++++++++-- 8 files changed, 409 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6405065581..be051ea2ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1956,7 +1956,17 @@ session. `fs.watch` of `/lib` that deliberately OUTLIVES the node's view; (5) the terminal is hidden by default (`runConfig.showTerminal`, toggled by ⋯ with the extra-args field): collapse's `display: none` path via `.term-node:has(.run-bar--compact)` and a node height fitted to the - rows; a run that ends without our Stop says where to look. No "bring simulator forward": + rows; a run that ends without our Stop says where to look; (6) **switching folders keeps the app + when it can** (`canHotSwitch`: a running Flutter run, same device, same flavor, Flutter on both + sides): the toolbar offers **Switch (keep app)** beside **Rebuild** — type `d` (flutter's own + detach: the tool exits, the app keeps running), `flutter attach` from the new folder (only the + flags attach accepts survive, `attachArgs`, MEASURED against `flutter attach --help` — attach has + no `--flavor`), wait until it prints its key help AFTER its own `▶ flutter attach` line + (`attachConnected`), then a hot RESTART — never a reload: a freshly attached tool only pushes + files changed after it connected. Flutter REFUSES a hand-passed `FLUTTER_APP_FLAVOR`, so after an + attach `appFlavor` is the pubspec default; an app whose `lib/` reads `appFlavor` is refused the + switch with that reason. Dart `"request": "attach"` configurations run as `flutter attach` too; + every other attach is still refused. No "bring simulator forward": Simulator.app can only be activated as a whole (same-named simulators are told apart by id in the dropdown). Add menus: **New view ▸ New run configuration**. Local projects only (disabled with `RUN_SSH_HINT` in SSH projects; relay stub answers "managed on the host"); POSIX only diff --git a/src/core/run-service.test.ts b/src/core/run-service.test.ts index e54d62c46a..074614d96f 100644 --- a/src/core/run-service.test.ts +++ b/src/core/run-service.test.ts @@ -131,6 +131,33 @@ describe('run-service', () => { expect(await startRun('../x', { projectDir: w, reloadOnSave: true })).toMatchObject({ ok: false }) }) + it('switch mode writes a flutter attach launcher; refuses non-Flutter runs and apps that read appFlavor', async () => { + write('app/pubspec.yaml', PUBSPEC) + write('app/lib/main_dev.dart', 'void main() {}') + write('app/.vscode/launch.json', `{ "configurations": [ + { "name": "Dev", "type": "dart", "request": "launch", "program": "lib/main_dev.dart", "args": ["--flavor", "dev", "--web-port=8080"] }, + { "name": "Tool", "type": "node", "request": "launch", "program": "a.js" } + ] }`) + const app = path.join(root, 'app') + const r = await startRun('n8', { projectDir: app, launchConfig: 'Dev', deviceId: 'SIM-1', reloadOnSave: true }, { attach: true }) + expect(r).toMatchObject({ ok: true, kind: 'process', hotReload: true }) + const script = readFileSync(launcherPath('n8'), 'utf8') + expect(script).toContain(`'flutter' 'attach' '-t' 'lib/main_dev.dart' '-d' 'SIM-1' '--pid-file' '${flutterPidFile('n8')}'`) + expect(script).not.toContain('--flavor') + expect(script).not.toContain('--web-port') + + expect(await startRun('n8', { projectDir: app, launchConfig: 'Tool', reloadOnSave: true }, { attach: true })).toMatchObject({ + ok: false, + error: expect.stringMatching(/not a Flutter run/) + }) + + write('app/lib/flavor.dart', "import 'package:flutter/services.dart'; final f = appFlavor;") + expect(await startRun('n8', { projectDir: app, launchConfig: 'Dev', deviceId: 'SIM-1', reloadOnSave: true }, { attach: true })).toEqual({ + ok: false, + error: 'This app reads appFlavor, which flutter attach cannot set — rebuild to switch.' + }) + }) + it.skipIf(!posix)('runs the preLaunchTask, then the program with env + envFile, and records the exit code', async () => { const w = path.join(root, 'w') const out = path.join(root, 'out.txt') diff --git a/src/core/run-service.ts b/src/core/run-service.ts index 3919623087..7a04fc4fcd 100644 --- a/src/core/run-service.ts +++ b/src/core/run-service.ts @@ -299,7 +299,43 @@ function referencedEnv(...values: unknown[]): string[] { return names } -export async function startRun(nodeId: unknown, rawConfig: unknown): Promise { +/** + * Does the app's own Dart code read `appFlavor`? `flutter attach` has no `--flavor`, and Flutter + * REFUSES a hand-passed `FLUTTER_APP_FLAVOR` define (flutter_command.dart, + * `_ensureReservedDartDefineIsUnset`), so after an attach + hot restart `appFlavor` falls back to + * the pubspec's default flavor — the wrong answer for an app that branches on it. Bounded scan of + * `lib/` (the app's code; packages are not the app's choice). A read failure answers "no". + */ +async function readsAppFlavor(dir: string): Promise { + const stack = [path.join(dir, 'lib')] + let files = 0 + while (stack.length && files < 4000) { + const d = stack.pop() as string + let entries: import('node:fs').Dirent[] + try { + entries = await readdir(d, { withFileTypes: true }) + } catch { + continue + } + for (const e of entries) { + const p = path.join(d, e.name) + if (e.isDirectory()) stack.push(p) + else if (e.name.endsWith('.dart')) { + files++ + try { + if ((await readFile(p, 'utf8')).includes('appFlavor')) return true + } catch { + /* unreadable — not evidence */ + } + } + } + } + return false +} + +export async function startRun(nodeId: unknown, rawConfig: unknown, rawOpts?: unknown): Promise { + // `attach`: switch a running Flutter app to this folder without rebuilding it (see canHotSwitch). + const attach = !!rawOpts && typeof rawOpts === 'object' && (rawOpts as { attach?: unknown }).attach === true if (!validNode(nodeId)) return { ok: false, error: 'Invalid node.' } if (process.platform === 'win32') { return { ok: false, error: 'Run configurations need a POSIX shell — not supported on Windows yet.' } @@ -326,7 +362,21 @@ export async function startRun(nodeId: unknown, rawConfig: unknown): Promise c.name === entry.name) as LaunchConfig const tasks = await readTasks(dir) @@ -344,7 +394,7 @@ export async function startRun(nodeId: unknown, rawConfig: unknown): Promise listDevices(refresh === true)) platform().handle(IPC.runBootDevice, (udid: unknown) => bootSimulator(udid)) platform().handle(IPC.runDiscover, (dir: unknown) => discoverProjects(dir)) - platform().handle(IPC.runStart, (nodeId: unknown, config: unknown) => startRun(nodeId, config)) + platform().handle(IPC.runStart, (nodeId: unknown, config: unknown, opts: unknown) => startRun(nodeId, config, opts)) platform().handle(IPC.runStatus, (nodeId: unknown) => runStatus(nodeId)) platform().handle(IPC.runStop, (nodeId: unknown, force: unknown) => stopRun(nodeId, force === true)) platform().handle(IPC.runSignal, (nodeId: unknown, kind: unknown) => signalRun(nodeId, kind)) diff --git a/src/preload/index.ts b/src/preload/index.ts index e389a58683..a10262faae 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -564,7 +564,7 @@ const api: NodeTerminalApi = { devices: (refresh) => ipcRenderer.invoke(IPC.runDevices, refresh), bootDevice: (udid) => ipcRenderer.invoke(IPC.runBootDevice, udid), discoverProjects: (dir) => ipcRenderer.invoke(IPC.runDiscover, dir), - start: (nodeId, config) => ipcRenderer.invoke(IPC.runStart, nodeId, config), + start: (nodeId, config, opts) => ipcRenderer.invoke(IPC.runStart, nodeId, config, opts), status: (nodeId) => ipcRenderer.invoke(IPC.runStatus, nodeId), stop: (nodeId, force) => ipcRenderer.invoke(IPC.runStop, nodeId, force), signal: (nodeId, kind) => ipcRenderer.invoke(IPC.runSignal, nodeId, kind), diff --git a/src/renderer/bridge/ws-bridge.ts b/src/renderer/bridge/ws-bridge.ts index 8df46c1e0b..c39d1ef10e 100644 --- a/src/renderer/bridge/ws-bridge.ts +++ b/src/renderer/bridge/ws-bridge.ts @@ -1104,7 +1104,7 @@ export function buildRunConfigApi(client: RpcClient): Pick client.request(IPC.runDevices, refresh) as Promise, bootDevice: (udid) => client.request(IPC.runBootDevice, udid) as Promise, discoverProjects: (dir) => client.request(IPC.runDiscover, dir) as Promise, - start: (nodeId, config) => client.request(IPC.runStart, nodeId, config) as Promise, + start: (nodeId, config, opts) => client.request(IPC.runStart, nodeId, config, opts) as Promise, status: (nodeId) => client.request(IPC.runStatus, nodeId) as Promise, stop: (nodeId, force) => client.request(IPC.runStop, nodeId, force) as Promise, signal: (nodeId, kind) => client.request(IPC.runSignal, nodeId, kind) as Promise, diff --git a/src/renderer/nodes/RunBar.tsx b/src/renderer/nodes/RunBar.tsx index 98cc2b2abd..dee18b6b65 100644 --- a/src/renderer/nodes/RunBar.tsx +++ b/src/renderer/nodes/RunBar.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useReactFlow, useStore } from '@xyflow/react' import { + attachConnected, + canHotSwitch, deviceClaims, resolveEntry, runDirLabel, @@ -9,6 +11,7 @@ import { type RunDevice, type RunEntriesResult, type RunNodeConfig, + type RunSnapshot, type RunStatus } from '@shared/run-config' import { isShellCommand } from '@shared/agents/pane' @@ -38,7 +41,7 @@ const STATUS_POLL_MS = 2_000 const STOP_GRACE_MS = 5_000 const NOTE_MS = 6_000 -type Busy = 'booting' | 'starting' | 'stopping' | 'reloading' | 'restarting' | null +type Busy = 'booting' | 'starting' | 'stopping' | 'reloading' | 'restarting' | 'switching' | null const CHOOSE_FOLDER = '\u0000choose' const NO_ENTRY = '\u0000none' @@ -100,6 +103,8 @@ export function RunBar({ nodeId, config, autoStart }: Props) { const cfgRef = useRef(config) cfgRef.current = config + /** The entry the node resolves to now (kept current below), for callbacks and the status poll. */ + const entryRef = useRef(undefined) const running = !!status?.running const say = useCallback((kind: 'error' | 'info', text: string) => setNote({ kind, text }), []) @@ -155,6 +160,7 @@ export function RunBar({ nodeId, config, autoStart }: Props) { [entries, config.launchConfig] ) const wantsDevices = entries.some((e) => e.usesDevice) + entryRef.current = entry const loadDevices = useCallback( (refresh: boolean) => { @@ -173,9 +179,18 @@ export function RunBar({ nodeId, config, autoStart }: Props) { // Set while WE are stopping the run, so its end is not reported as unexpected. const stoppingRef = useRef(false) const lastRunningRef = useRef(null) + /** What the live run was started from — what a folder switch must match to keep the app. + * Set at Run; recovered from the node's config when the run was already going at mount (the + * config cannot have changed before this view existed to change it). Cleared when it ends. */ + const runningRef = useRef(null) const pollStatus = useCallback(async () => { const s = await api.runConfig.status(nodeId) if (!mounted.current) return s.running + if (!s.running && !stoppingRef.current) runningRef.current = null + if (s.running && !runningRef.current && entryRef.current) { + const c = cfgRef.current + runningRef.current = { projectDir: c.projectDir, deviceId: c.deviceId, flavor: entryRef.current.flavor, hotReload: entryRef.current.hotReload } + } if (lastRunningRef.current === true && !s.running && !stoppingRef.current && !cfgRef.current.showTerminal) { const how = s.exitCode === null || s.exitCode === 0 ? 'The run ended' : `The run exited with code ${s.exitCode}` setNote({ kind: s.exitCode ? 'error' : 'info', text: `${how} — open ⋯ to see the terminal output.` }) @@ -284,6 +299,7 @@ export function RunBar({ nodeId, config, autoStart }: Props) { await start({ launchConfig: first }) return } + runningRef.current = { projectDir: cfg.projectDir, deviceId: cfg.deviceId, flavor: target?.flavor, hotReload: r.hotReload } const sent = await api.pty.sendText(nodeId, r.command) setNeedsRerun(false) if (sent === 'pasted-not-submitted') say('info', 'The command is in the terminal — press Enter to start.') @@ -334,6 +350,73 @@ export function RunBar({ nodeId, config, autoStart }: Props) { await start() }, [running, stop, start]) + /** + * Switch the running Flutter app to the node's (new) folder WITHOUT rebuilding it: + * 1. `d` — flutter run/attach's own "detach": the tool exits, the app keeps running; + * 2. `flutter attach` from the new folder, to the same device; + * 3. once it has connected, a hot RESTART (SIGUSR2). Not a reload: a freshly attached tool only + * pushes files that change after it connected, so the new checkout's code would mostly not + * reach the app. A restart pushes the whole program — the app's state resets, nothing native + * is rebuilt. + * Every failure leaves the app running and says so; Rebuild stays one click away. + */ + const switchKeepApp = useCallback(async () => { + const cfg = cfgRef.current + setBusy('switching') + stoppingRef.current = true + await api.pty.sendText(nodeId, 'd', { enter: false }) + const detached = await waitStopped(10_000) + stoppingRef.current = false + if (!detached) { + setBusy(null) + say('error', 'The running tool did not detach — open ⋯ to check the terminal.') + return + } + const r = await api.runConfig.start(nodeId, cfg, { attach: true }) + if (!r.ok || r.kind !== 'process') { + setBusy(null) + say('error', `${r.ok ? 'Could not attach.' : r.error} The app is still running on the device; Rebuild restarts it from this folder.`) + return + } + const sent = await api.pty.sendText(nodeId, r.command) + if (sent !== true) { + setBusy(null) + say('error', sent === 'pasted-not-submitted' ? 'The attach command is in the terminal — press Enter.' : 'Could not type into this terminal.') + return + } + // Wait for the attach to connect (it prints its key help once it has). Discovering the app + // can take a while on a busy simulator; an attach that exits early has failed. + let connected = false + let sawRunning = false + const deadline = Date.now() + 120_000 + while (mounted.current && Date.now() < deadline) { + if (attachConnected(await api.pty.capture(nodeId))) { + connected = true + break + } + const alive = await pollStatus() + if (alive) sawRunning = true + else if (sawRunning) break + await sleep(700) + } + if (!mounted.current) return + if (!connected) { + setBusy(null) + say('error', 'flutter attach did not connect — open ⋯ to see why. Rebuild starts the app from this folder.') + return + } + const restarted = await api.runConfig.signal(nodeId, 'restart') + runningRef.current = { projectDir: cfg.projectDir, deviceId: cfg.deviceId, flavor: entryRef.current?.flavor, hotReload: true } + setNeedsRerun(false) + setBusy(null) + say( + restarted ? 'info' : 'error', + restarted + ? `Switched to ${runDirLabel(cfg.projectDir)} without rebuilding — hot restarted (the app's state was reset).` + : 'Attached, but the hot restart failed — press R in the terminal.' + ) + }, [api, nodeId, waitStopped, pollStatus, say]) + const signal = useCallback( async (kind: 'reload' | 'restart') => { setBusy(kind === 'reload' ? 'reloading' : 'restarting') @@ -460,6 +543,9 @@ export function RunBar({ nodeId, config, autoStart }: Props) { ) const browser = !!entry?.browser + // Folder/configuration changed under a running Flutter app that the new choice would build the + // same way: offer to switch without rebuilding (canHotSwitch states the rule). + const hotSwitch = canHotSwitch(runningRef.current, { config, entry }) const canRun = !!entry?.supported && !busy const statusText = busy === 'booting' @@ -472,6 +558,8 @@ export function RunBar({ nodeId, config, autoStart }: Props) { ? 'Hot reload' : busy === 'restarting' ? 'Hot restart' + : busy === 'switching' + ? 'Switching…' : running ? 'Running' : status?.exitCode @@ -612,7 +700,29 @@ export function RunBar({ nodeId, config, autoStart }: Props) { ) )} - {needsRerun && running && ( + {needsRerun && running && hotSwitch && ( + <> + + + + )} + {needsRerun && running && !hotSwitch && (