feat(debugger): debug Flutter apps through the Flutter debug adapter (#1474)

Dart projects that depend on the Flutter SDK were debugged through
`dart debug_adapter`, which cannot start a program importing dart:ui and has no
hot reload. A session now uses `flutter debug_adapter` when the program's or the
working directory's pubspec.yaml depends on the Flutter SDK, or when an attach
configuration asks for it (add-to-app: vm-service-uri and device map to the
adapter's attach arguments). Hot Reload and Hot Restart are sent over DAP and
appear in the toolbar of Dart sessions. Dart files also get a debug configuration.

Co-authored-by: payailk <[email protected]>
Цей коміт міститься в:
payailk authored and GitHub committed 2026-11-05 14:14:47 +08:00
1 джерело 8869f1ce1c
коміт 566190e94b
16 files changed
+187 -2

No files matched your search

+11
Переглянути файл
@@ -52,6 +52,17 @@ const session: DebugSession = {
};
describe("debug tool window", () => {
it("offers Hot Reload and Hot Restart for Dart and Flutter sessions only", () => {
const hot = () => ["debug.hotReload", "debug.hotRestart"].map((name) => !!screen.queryByRole("button", { name }));
useDebug.setState({ sessions: { 1: { ...session, status: "running" } }, active: 1 });
const { unmount } = render(<DebugToolWindow />);
expect(hot()).toEqual([false, false]);
unmount();
useDebug.setState({ sessions: { 1: { ...session, adapter: "dart", status: "running" } }, active: 1 });
render(<DebugToolWindow />);
expect(hot()).toEqual([true, true]);
});
it("shows preparation output, preserves a chosen tab and opens variables on pause", () => {
useDebug.setState({ sessions: { 1: { ...session, status: "starting" } }, active: 1 });
render(<DebugToolWindow />);
+24
Переглянути файл
@@ -436,6 +436,30 @@ export function registerDebugActions(): () => void {
await report(tx("Pause failed"), call(commands.debugPause(s.id, thread)));
},
},
{
id: "debug.hotReload",
title: "Hot Reload",
category: "Debug",
icon: "hot-reload",
description: "Flutter: inject the changed sources into the running app",
when: "debugSessionRunning && debugSessionDart",
run: () => {
const s = activeSession();
if (s) void report("Hot Reload failed", call(commands.debugHotReload(s.id)));
},
},
{
id: "debug.hotRestart",
title: "Hot Restart",
category: "Debug",
icon: "refresh",
description: "Flutter: restart the app, discarding its state",
when: "debugSessionRunning && debugSessionDart",
run: () => {
const s = activeSession();
if (s) void report("Hot Restart failed", call(commands.debugHotRestart(s.id)));
},
},
step("debug.stepOver", "Step Over", "over", "step-over"),
step("debug.stepInto", "Step Into", "into", "step-into"),
step("debug.stepOut", "Step Out", "out", "step-out"),
+46
Переглянути файл
@@ -313,6 +313,49 @@ function nativeLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig
return out;
}
/**
* Flutter: `flutter debug_adapter` starts the tool itself, so `program` is the
* entry point (`lib/main.dart`) — never the interpreter named by `command`.
*/
function flutterLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig {
// A file-scoped config carries the entry point as its last `.dart` argument
// (`dart run lib/main.dart`); a user config may name none, which is the
// conventional Flutter entry point.
const entry = [...rc.args].reverse().find((a) => a.endsWith(".dart")) ?? "lib/main.dart";
return {
...base(rc, adapter, cwd),
program: join(cwd, entry),
};
}
/**
* Flutter add-to-app: the host app is built and installed by its own Gradle
* build, so the debugger attaches to the already running app instead of
* launching `lib/main.dart` (which would build the module on its own).
*
* `params.vm-service-uri` pins the VM Service endpoint (`flutter attach
* --debug-uri`); without it `flutter attach` discovers the running app on the
* device. `params.device` becomes `-d <device>`.
*/
function flutterAttach(rc: RunConfig, cwd: string, adapter: string): LaunchConfig {
const p = rc.params ?? {};
const uri = p["vm-service-uri"] || p.vmServiceUri;
const device = p.device;
// `__flutter` tells the adapter launcher to use `flutter debug_adapter`
// whatever the working directory holds (an add-to-app module lives below a
// workspace root that has no Flutter dependency of its own). It is removed
// before the request reaches the adapter.
const extra: Record<string, unknown> = { __flutter: true };
if (uri) extra.vmServiceUri = uri;
if (device) extra.toolArgs = ["-d", device];
return {
...base(rc, adapter, cwd),
request: "attach",
program: null,
extraJson: JSON.stringify(extra),
};
}
/**
* The launch configuration that debugs `rc`, or `null` when it has no adapter.
* `root` is the project root (default working directory).
@@ -321,6 +364,9 @@ export function launchConfigFor(rc: RunConfig, root: string): LaunchConfig | nul
const adapter = rc.debugAdapter;
if (!adapter || debugUnsupportedReason(rc)) return null;
const cwd = rc.cwd ? join(root, rc.cwd) : root;
if (adapter === "dart") {
return rc.params?.request === "attach" ? flutterAttach(rc, cwd, adapter) : flutterLaunch(rc, cwd, adapter);
}
if (adapter === "codelldb" || adapter === "lldb-dap") {
return rc.kind === "cargo" ? cargoLaunch(rc, cwd, adapter) : nativeLaunch(rc, cwd, adapter);
}
+2
Переглянути файл
@@ -328,6 +328,8 @@ export function publishContext() {
setContext("debugSessionActive", anyLive);
setContext("debugSessionRunning", s?.status === "running");
setContext("debugSessionPaused", s?.status === "paused");
// Dart and Flutter sessions: Hot Reload / Hot Restart.
setContext("debugSessionDart", !!s && rootOf(s).adapter === "dart");
setContext("debugCanRerun", !!s && !!(rootOf(s).runConfig || rootOf(s).launch));
setContext(
"debugCanHotSwap",
+8
Переглянути файл
@@ -32,6 +32,7 @@ import {
selectSession,
selectThread,
revealFrame,
rootOf,
sessionSupports,
useDebug,
type DebugSession,
@@ -200,6 +201,13 @@ export function DebugToolWindow() {
<ToolButton action="debug.stepInto" icon="step-into" />
<ToolButton action="debug.stepOut" icon="step-out" />
<span className={styles.sep} aria-hidden />
{rootOf(active).adapter === "dart" && (
<>
<ToolButton action="debug.hotReload" icon="hot-reload" />
<ToolButton action="debug.hotRestart" icon="refresh" />
<span className={styles.sep} aria-hidden />
</>
)}
<IconButton
icon="view-breakpoints"
action="debug.viewBreakpoints"
+1 -1
Переглянути файл
@@ -807,7 +807,7 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
case "r":
return simple(lang, s, env, "r", () => [s.arg]);
case "dart":
return simple(lang, s, env, "dart", () => ["run", s.arg]);
return simple(lang, s, env, "dart", () => ["run", s.arg], "dart");
case "scala":
return simple(lang, s, env, "scala-cli", () => ["run", s.arg]);
case "kotlin":