mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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": "实际大小",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user