From 566190e94bd83ffca650ae9df3c297a9d5cbb3be Mon Sep 17 00:00:00 2001 From: payailk <80739895+payailk@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:14:47 +0800 Subject: [PATCH] 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> --- crates/ide-dap/src/lib.rs | 43 +++++++++++++++++ crates/ide-dap/src/tests.rs | 16 +++++++ crates/ide-host/src/ops/debug.rs | 8 ++++ languages/dart.toml | 4 +- src-tauri/src/commands/debug.rs | 12 +++++ src-tauri/src/commands/mod.rs | 2 + src/bindings.ts | 2 + .../debugger/__tests__/toolWindow.test.tsx | 11 +++++ src/features/debugger/actions.ts | 24 ++++++++++ src/features/debugger/launch.ts | 46 +++++++++++++++++++ src/features/debugger/state.ts | 2 + src/features/debugger/ui/DebugToolWindow.tsx | 8 ++++ src/features/run/producers.ts | 2 +- src/platform/locales/text/zh.json | 4 ++ src/ui/icons.tsx | 1 + src/ui/openidea/general/hotReload.svg | 4 ++ 16 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 src/ui/openidea/general/hotReload.svg diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index f2790ad5..5d301237 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -302,6 +302,23 @@ pub struct DebugManager { data_breakpoints: Mutex>, } +/// 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, ) -> IdeResult { 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", diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index 1e5647b7..e1efd710 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -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(); +} diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 0d17a593..82722354 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -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, diff --git a/languages/dart.toml b/languages/dart.toml index d2cfdb3a..402b6fbb 100644 --- a/languages/dart.toml +++ b/languages/dart.toml @@ -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" diff --git a/src-tauri/src/commands/debug.rs b/src-tauri/src/commands/debug.rs index e2a23a2f..52230064 100644 --- a/src-tauri/src/commands/debug.rs +++ b/src-tauri/src/commands/debug.rs @@ -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( diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2b917c27..adaadc17 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -377,6 +377,8 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src/bindings.ts b/src/bindings.ts index ad38a6dd..6601d340 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -724,6 +724,8 @@ export const commands = { debugCompletions: (session: DebugSessionId, frameId: number | null, text: string, line: number, column: number) => typedError(__TAURI_INVOKE("debug_completions", { session, frameId, text, line, column })), debugResume: (session: DebugSessionId, threadId: number) => typedError(__TAURI_INVOKE("debug_resume", { session, threadId })), debugPause: (session: DebugSessionId, threadId: number) => typedError(__TAURI_INVOKE("debug_pause", { session, threadId })), + debugHotReload: (session: DebugSessionId) => typedError(__TAURI_INVOKE("debug_hot_reload", { session })), + debugHotRestart: (session: DebugSessionId) => typedError(__TAURI_INVOKE("debug_hot_restart", { session })), debugStep: (session: DebugSessionId, threadId: number, kind: StepKind) => typedError(__TAURI_INVOKE("debug_step", { session, threadId, kind })), debugRunTo: (session: DebugSessionId, threadId: number, path: string, line: number, force: boolean) => typedError(__TAURI_INVOKE("debug_run_to", { session, threadId, path, line, force })), debugStop: (session: DebugSessionId) => typedError(__TAURI_INVOKE("debug_stop", { session })), diff --git a/src/features/debugger/__tests__/toolWindow.test.tsx b/src/features/debugger/__tests__/toolWindow.test.tsx index 281cb275..405badef 100644 --- a/src/features/debugger/__tests__/toolWindow.test.tsx +++ b/src/features/debugger/__tests__/toolWindow.test.tsx @@ -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(); + expect(hot()).toEqual([false, false]); + unmount(); + useDebug.setState({ sessions: { 1: { ...session, adapter: "dart", status: "running" } }, active: 1 }); + render(); + 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(); diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 381f291c..759c6fe3 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -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"), diff --git a/src/features/debugger/launch.ts b/src/features/debugger/launch.ts index a7b66774..1137cdd6 100644 --- a/src/features/debugger/launch.ts +++ b/src/features/debugger/launch.ts @@ -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 `. + */ +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 = { __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); } diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index b3663f69..1bcbd3dc 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -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", diff --git a/src/features/debugger/ui/DebugToolWindow.tsx b/src/features/debugger/ui/DebugToolWindow.tsx index 93c65092..e457f0fd 100644 --- a/src/features/debugger/ui/DebugToolWindow.tsx +++ b/src/features/debugger/ui/DebugToolWindow.tsx @@ -32,6 +32,7 @@ import { selectSession, selectThread, revealFrame, + rootOf, sessionSupports, useDebug, type DebugSession, @@ -200,6 +201,13 @@ export function DebugToolWindow() { + {rootOf(active).adapter === "dart" && ( + <> + + + + + )} [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": diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 99b57a05..d67238c0 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "实际大小", diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 3c5a9f01..72f13e6f 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -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", diff --git a/src/ui/openidea/general/hotReload.svg b/src/ui/openidea/general/hotReload.svg new file mode 100644 index 00000000..909a103c --- /dev/null +++ b/src/ui/openidea/general/hotReload.svg @@ -0,0 +1,4 @@ + + + +