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 <80739895+payailk@users.noreply.github.com>
This commit is contained in:
payailk
2026-11-05 14:14:47 +08:00
committed by GitHub
parent 8869f1ce1c
commit 566190e94b
16 changed files with 187 additions and 2 deletions
+43
View File
@@ -302,6 +302,23 @@ pub struct DebugManager {
data_breakpoints: Mutex<Vec<DataBreakpoint>>,
}
/// Whether the debugged project depends on the Flutter SDK, so it must go
/// through `flutter debug_adapter` instead of the plain Dart one.
///
/// `dart debug_adapter` cannot start a program that imports `dart:ui`, and only
/// the Flutter adapter answers `hotReload` / `hotRestart`. `dir` is the launch
/// `cwd`; the program's own directory is tried first because a module inside a
/// larger repository has its `pubspec.yaml` above the program, not above `cwd`.
fn is_flutter_project(dir: &Path, program: Option<&Path>) -> bool {
let mut start: Vec<&Path> = program.and_then(Path::parent).into_iter().collect();
start.push(dir);
start
.iter()
.filter_map(|d| d.ancestors().take(6).find(|a| a.join("pubspec.yaml").is_file()))
.filter_map(|root| std::fs::read_to_string(root.join("pubspec.yaml")).ok())
.any(|s| s.contains("sdk: flutter"))
}
fn no_session(s: DebugSessionId) -> IdeError {
IdeError::not_found(format!("no debug session {}", s.0))
}
@@ -570,6 +587,10 @@ impl DebugManager {
custom_command: impl FnOnce(&str) -> Option<String>,
) -> IdeResult<DebugSessionId> {
let mut arguments = launch_arguments(&config)?;
// An IDE-side marker (never sent to the adapter): the session is a Flutter
// one regardless of what the working directory holds.
let force_flutter =
arguments.as_object_mut().and_then(|o| o.remove("__flutter")).and_then(|v| v.as_bool()).unwrap_or(false);
let cwd = config
.cwd
.clone()
@@ -601,6 +622,15 @@ impl DebugManager {
spec.command = python;
}
}
// Flutter: a project depending on the Flutter SDK needs `flutter debug_adapter`
// (the Dart adapter cannot start a program importing `dart:ui`, and hot reload
// is Flutter-only). Plain Dart projects keep the configured `dart`.
if config.adapter == "dart" && spec.command == "dart" {
let program = arguments.get("program").and_then(Value::as_str).map(Path::new);
if force_flutter || is_flutter_project(&cwd, program) {
spec.command = "flutter".into();
}
}
let id = DebugSessionId::next();
let debug_server = take_debug_server(&mut arguments)?;
if let Some(server) = &debug_server {
@@ -1236,6 +1266,19 @@ impl DebugManager {
Ok(())
}
/// Flutter's DAP adapter: injects the changed sources into the running app.
/// Not part of the DAP standard; adapters that do not know the request reject it.
pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> {
self.session(s)?.conn.request_timeout("hotReload", json!({ "reason": "manual" }), REQUEST_TIMEOUT).await?;
Ok(())
}
/// Flutter's DAP adapter: restarts the app without preserving state.
pub async fn hot_restart(&self, s: DebugSessionId) -> IdeResult<()> {
self.session(s)?.conn.request_timeout("hotRestart", json!({ "reason": "manual" }), REQUEST_TIMEOUT).await?;
Ok(())
}
pub async fn step(&self, s: DebugSessionId, thread_id: i64, kind: StepKind) -> IdeResult<()> {
let command = match kind {
StepKind::Over => "next",
+16
View File
@@ -1269,3 +1269,19 @@ async fn a_plugin_language_runs_its_debug_adapter_from_the_approved_program() {
assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied, "{}", err.message);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
let root = std::env::temp_dir().join(format!("openidea-flutter-{}", std::process::id()));
let app = root.join("app");
let tool = root.join("tool");
std::fs::create_dir_all(app.join("lib")).unwrap();
std::fs::create_dir_all(tool.join("bin")).unwrap();
std::fs::write(app.join("pubspec.yaml"), "name: app\ndependencies:\n flutter:\n sdk: flutter\n").unwrap();
std::fs::write(tool.join("pubspec.yaml"), "name: tool\ndependencies:\n args: ^2.0.0\n").unwrap();
assert!(is_flutter_project(&app, None));
assert!(is_flutter_project(&root, Some(&app.join("lib/main.dart"))));
assert!(!is_flutter_project(&tool, Some(&tool.join("bin/main.dart"))));
assert!(!is_flutter_project(&root, None));
std::fs::remove_dir_all(&root).unwrap();
}
+8
View File
@@ -162,6 +162,14 @@ pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, th
host.debugger.pause(session, thread_id).await
}
pub async fn debug_hot_reload(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
host.debugger.hot_reload(session).await
}
pub async fn debug_hot_restart(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
host.debugger.hot_restart(session).await
}
pub async fn debug_step(
host: &Host,
_window: &str,
+3 -1
View File
@@ -14,8 +14,10 @@ args = ["language-server", "--protocol=lsp"]
root-markers = ["pubspec.yaml", "analysis_options.yaml"]
install-hint = "Install the Dart SDK (https://dart.dev/get-dart) or Flutter"
# `dart` here; a project that depends on the Flutter SDK is switched to
# `flutter debug_adapter` at session start (see `ide-dap`'s `start_with`).
[debugger]
adapter = "dart"
command = "dart"
args = ["debug_adapter"]
install-hint = "Install the Dart SDK (https://dart.dev/get-dart); use `flutter debug_adapter` for Flutter apps"
install-hint = "Install the Dart SDK (https://dart.dev/get-dart) or Flutter"
+12
View File
@@ -98,6 +98,18 @@ pub async fn debug_pause(state: State<'_, AppState>, session: DebugSessionId, th
ide_host::ops::debug::debug_pause(&state.host, "", session, thread_id).await
}
#[tauri::command]
#[specta::specta]
pub async fn debug_hot_reload(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult<()> {
ide_host::ops::debug::debug_hot_reload(&state.host, "", session).await
}
#[tauri::command]
#[specta::specta]
pub async fn debug_hot_restart(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult<()> {
ide_host::ops::debug::debug_hot_restart(&state.host, "", session).await
}
#[tauri::command]
#[specta::specta]
pub async fn debug_step(
+2
View File
@@ -377,6 +377,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
debug::debug_completions,
debug::debug_resume,
debug::debug_pause,
debug::debug_hot_reload,
debug::debug_hot_restart,
debug::debug_step,
debug::debug_run_to,
debug::debug_stop,
+2
View File
@@ -724,6 +724,8 @@ export const commands = {
debugCompletions: (session: DebugSessionId, frameId: number | null, text: string, line: number, column: number) => typedError<DebugCompletion[], IdeError>(__TAURI_INVOKE("debug_completions", { session, frameId, text, line, column })),
debugResume: (session: DebugSessionId, threadId: number) => typedError<null, IdeError>(__TAURI_INVOKE("debug_resume", { session, threadId })),
debugPause: (session: DebugSessionId, threadId: number) => typedError<null, IdeError>(__TAURI_INVOKE("debug_pause", { session, threadId })),
debugHotReload: (session: DebugSessionId) => typedError<null, IdeError>(__TAURI_INVOKE("debug_hot_reload", { session })),
debugHotRestart: (session: DebugSessionId) => typedError<null, IdeError>(__TAURI_INVOKE("debug_hot_restart", { session })),
debugStep: (session: DebugSessionId, threadId: number, kind: StepKind) => typedError<null, IdeError>(__TAURI_INVOKE("debug_step", { session, threadId, kind })),
debugRunTo: (session: DebugSessionId, threadId: number, path: string, line: number, force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("debug_run_to", { session, threadId, path, line, force })),
debugStop: (session: DebugSessionId) => typedError<null, IdeError>(__TAURI_INVOKE("debug_stop", { session })),
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
@@ -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
View File
@@ -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":
+4
View File
@@ -162,6 +162,10 @@
"Fixup…": "修补提交…",
"Step Over": "单步跳过",
"Step Out": "单步跳出",
"Hot Reload": "热重载",
"Hot Restart": "热重启",
"Flutter: inject the changed sources into the running app": "Flutter:将改动后的源码注入正在运行的应用",
"Flutter: restart the app, discarding its state": "Flutter:重启应用,丢弃当前状态",
"Zoom In": "放大",
"Zoom Out": "缩小",
"Actual Size": "实际大小",
+1
View File
@@ -361,6 +361,7 @@ const ICONS = {
"gutter-overriding": "gutter/overridingMethod",
stop: "run/stop",
rerun: "run/rerun",
"hot-reload": "openidea/general/hotReload",
"rerun-filled": "run/rerun_stroke",
"restart-debug": "run/restartDebug",
"restart-debug-filled": "run/restartDebug_stroke",
+4
View File
@@ -0,0 +1,4 @@
<!-- OpenIDEA: hot reload (a lightning bolt) in the expui style. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.5 1.5L3.5 9H7.5L6.5 14.5L12.5 7H8.5L9.5 1.5Z" stroke="#6C707E" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 273 B