From 3e9f4674941aefb75e37e9c98316739c57cd0309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tautvydas=20S=CC=8Cidlauskas?= Date: Mon, 28 Sep 2026 08:27:25 +0300 Subject: [PATCH] feat(dap): show debug adapter progress natively and keep it alive - Launch, attach, hot reload and hot restart progress from the Flutter debug adapter is shown as native progress (Neovim 0.12+) - Shared ui.progress helper resends running progress every 10s, since Ghostty drops an OSC 9;4 bar after 15s without updates; it stops after 2 min without a real update, and progress still running when the session closes is marked failed - Devices and widget preview use the same helper --- lua/flutter-tools/devices.lua | 17 +---- lua/flutter-tools/runners/debugger_runner.lua | 31 +++++++++ lua/flutter-tools/ui.lua | 62 ++++++++++++++++++ lua/flutter-tools/widget_preview.lua | 18 +++--- tests/debugger_runner_spec.lua | 63 ++++++++++++++++++- tests/devices_spec.lua | 3 + tests/ui_spec.lua | 47 ++++++++++++++ 7 files changed, 217 insertions(+), 24 deletions(-) create mode 100644 tests/ui_spec.lua diff --git a/lua/flutter-tools/devices.lua b/lua/flutter-tools/devices.lua index e2a33c8..1bd7b69 100644 --- a/lua/flutter-tools/devices.lua +++ b/lua/flutter-tools/devices.lua @@ -6,7 +6,6 @@ local commands = lazy.require("flutter-tools.commands") ---@module "flutter-tool local executable = lazy.require("flutter-tools.executable") ---@module "flutter-tools.executable" local config = lazy.require("flutter-tools.config") ---@module "flutter-tools.config" local fmt = string.format -local api = vim.api ---@alias Device {name: string, id: string, platform: string, system: string, type: integer, cold_boot: boolean} @@ -175,19 +174,9 @@ end ---@param title string ---@return fun(status: "success"|"failed") local function start_progress(title) - if vim.fn.has("nvim-0.12") == 0 then - return function() end - end - local opts = { kind = "progress", source = "flutter-tools", title = title, status = "running" } - local id = api.nvim_echo({ { "Loading" } }, false, opts) - return function(status) - local message = status == "success" and "Done" or "Failed" - api.nvim_echo( - { { message } }, - false, - vim.tbl_extend("force", opts, { id = id, status = status }) - ) - end + local progress = ui.progress(title) + progress:report("Loading", "running") + return function(status) progress:report(status == "success" and "Done" or "Failed", status) end end -----------------------------------------------------------------------------// diff --git a/lua/flutter-tools/runners/debugger_runner.lua b/lua/flutter-tools/runners/debugger_runner.lua index e29faae..6b10262 100644 --- a/lua/flutter-tools/runners/debugger_runner.lua +++ b/lua/flutter-tools/runners/debugger_runner.lua @@ -167,8 +167,21 @@ local function handle_inspect_event(isolate_id) end) end +---@type table +local active_progress = {} + +local function end_all_progress() + for progress_id, progress in pairs(active_progress) do + progress:report("Stopped", "failed") + active_progress[progress_id] = nil + end +end + local listened_events = { { "after", "event_output" }, + { "before", "event_progressStart" }, + { "before", "event_progressUpdate" }, + { "before", "event_progressEnd" }, { "before", "event_exited" }, { "before", "event_terminated" }, { "before", "event_app.started" }, @@ -197,7 +210,24 @@ local function register_dap_listeners(on_run_data, on_run_exit) end end + dap.listeners.before["event_progressStart"][plugin_identifier] = function(_, body) + local progress = active_progress[body.progressId] or ui.progress(body.title) + active_progress[body.progressId] = progress + progress:report(body.message, "running", { percent = body.percentage }) + end + dap.listeners.before["event_progressUpdate"][plugin_identifier] = function(_, body) + local progress = active_progress[body.progressId] + if progress then progress:report(body.message, "running", { percent = body.percentage }) end + end + dap.listeners.before["event_progressEnd"][plugin_identifier] = function(_, body) + local progress = active_progress[body.progressId] + if not progress then return end + progress:report(body.message or "Done", "success") + active_progress[body.progressId] = nil + end + local handle_termination = function() + end_all_progress() if next(before_start_logs) ~= nil then on_run_exit(before_start_logs) end if vm_service.is_connected() then vm_service.disconnect() end end @@ -271,6 +301,7 @@ dap.listeners.on_session[plugin_identifier] = function(_, session) return end tracked_session_id = session.id + session.on_close[plugin_identifier] = end_all_progress local listeners = pending_listeners or get_untracked_session_listeners() pending_listeners = nil if listeners then diff --git a/lua/flutter-tools/ui.lua b/lua/flutter-tools/ui.lua index 130e08f..3fa2648 100644 --- a/lua/flutter-tools/ui.lua +++ b/lua/flutter-tools/ui.lua @@ -140,4 +140,66 @@ function M.open_win(opts, on_open) end end +M.progress_timing = { + -- Ghostty drops an OSC 9;4 progress bar after 15s without an update. + keepalive_ms = 10000, + -- Give up on a progress with no real update in this long, so a hung or crashed process cannot + -- keep the bar alive forever. + stall_ms = 2 * 60 * 1000, +} + +---@alias flutter.ProgressStatus "running"|"success"|"failed" + +---@class flutter.Progress +---@field report fun(self: flutter.Progress, message: string?, status: flutter.ProgressStatus, opts: {percent?: number, history?: boolean}?) + +---A native progress message (Neovim 0.12+) that stays visible in terminals which expire idle +---progress bars. Reporting keeps one message id, so later reports update it in place. +---@param title string +---@return flutter.Progress +function M.progress(title) + if vim.fn.has("nvim-0.12") == 0 then return { report = function() end } end + local id, message, percent, timer + local last_update = 0 + + local function stop_keepalive() + if not timer then return end + timer:stop() + timer:close() + timer = nil + end + + local function echo(status, history) + id = api.nvim_echo({ { message or title } }, history or false, { + id = id, + kind = "progress", + source = "flutter-tools", + title = title, + status = status, + percent = percent, + }) + end + + local function keepalive() + if not timer then return end + if vim.uv.now() - last_update > M.progress_timing.stall_ms then return stop_keepalive() end + echo("running") + end + + return { + report = function(_, new_message, status, opts) + opts = opts or {} + message = new_message or message + percent = opts.percent and math.floor(opts.percent) + last_update = vim.uv.now() + echo(status, opts.history) + if status ~= "running" then return stop_keepalive() end + if timer then return end + timer = assert(vim.uv.new_timer()) + local interval = M.progress_timing.keepalive_ms + timer:start(interval, interval, vim.schedule_wrap(keepalive)) + end, + } +end + return M diff --git a/lua/flutter-tools/widget_preview.lua b/lua/flutter-tools/widget_preview.lua index eb3d307..101635a 100644 --- a/lua/flutter-tools/widget_preview.lua +++ b/lua/flutter-tools/widget_preview.lua @@ -23,7 +23,7 @@ local OUTPUT_LINES_ON_FAILURE = 15 ---@field cancelled boolean ---@field output string[] recent output, shown if the previewer fails ---@field dir string ----@field progress_id (integer|string)? +---@field progress flutter.Progress ---@type flutter.WidgetPreviewState? local state = nil @@ -58,13 +58,7 @@ end ---@param message string ---@param status "running"|"success"|"failed" local function report(current, message, status) - current.progress_id = api.nvim_echo({ { message } }, status ~= "running", { - id = current.progress_id, - kind = "progress", - source = "flutter-tools", - title = "Widget preview", - status = status, - }) + current.progress:report(message, status, { history = status ~= "running" }) end ---@param current flutter.WidgetPreviewState @@ -208,7 +202,13 @@ function M.show(dir) if not project_dir then return ui.notify("Unable to find a Flutter project", ui.ERROR) end ---@type flutter.WidgetPreviewState - local current = { started = false, cancelled = false, output = {}, dir = project_dir } + local current = { + started = false, + cancelled = false, + output = {}, + dir = project_dir, + progress = ui.progress("Widget preview"), + } state = current report(current, "Starting", "running") dtd.start(function(err) diff --git a/tests/debugger_runner_spec.lua b/tests/debugger_runner_spec.lua index c500b10..0bb028a 100644 --- a/tests/debugger_runner_spec.lua +++ b/tests/debugger_runner_spec.lua @@ -17,6 +17,9 @@ describe("debugger runner", function() dap.adapters.dart = nil dap.configurations.dart = nil dap.listeners.after["event_output"]["flutter-tools"] = nil + for _, event in ipairs({ "event_progressStart", "event_progressUpdate", "event_progressEnd" }) do + dap.listeners.before[event]["flutter-tools"] = nil + end runner.on_untracked_session(function() end) package.loaded["flutter-tools.runners.debugger_runner"] = nil end) @@ -57,7 +60,10 @@ describe("debugger runner", function() } end) - dap.listeners.on_session["flutter-tools"](nil, { id = 1, config = { type = "dart" } }) + dap.listeners.on_session["flutter-tools"]( + nil, + { id = 1, config = { type = "dart" }, on_close = {} } + ) dap.listeners.after["event_output"]["flutter-tools"](nil, { category = "stderr", output = "first\nsecond", @@ -74,4 +80,59 @@ describe("debugger runner", function() assert.is_false(called) end) + + it("reports adapter progress as native progress messages", function() + local reported = {} + local autocmd = vim.api.nvim_create_autocmd("Progress", { + callback = function(ev) + table.insert(reported, { ev.data.title, ev.data.status, ev.data.percent }) + end, + }) + runner.on_untracked_session(function() + return { on_run_data = function() end, on_run_exit = function() end } + end) + dap.listeners.on_session["flutter-tools"]( + nil, + { id = 3, config = { type = "dart" }, on_close = {} } + ) + + local before = dap.listeners.before + before["event_progressStart"]["flutter-tools"](nil, { + progressId = "launch", + title = "Flutter", + message = "Launching…", + }) + before["event_progressUpdate"]["flutter-tools"](nil, { progressId = "launch", percentage = 50 }) + before["event_progressUpdate"]["flutter-tools"](nil, { progressId = "unknown", message = "x" }) + before["event_progressEnd"]["flutter-tools"](nil, { progressId = "launch" }) + vim.api.nvim_del_autocmd(autocmd) + + assert.are.same({ + { "Flutter", "running" }, + { "Flutter", "running", 50 }, + { "Flutter", "success" }, + }, reported) + end) + + it("fails running progress when the session closes without ending it", function() + local statuses = {} + local autocmd = vim.api.nvim_create_autocmd("Progress", { + callback = function(ev) table.insert(statuses, ev.data.status) end, + }) + runner.on_untracked_session(function() + return { on_run_data = function() end, on_run_exit = function() end } + end) + local session = { id = 4, config = { type = "dart" }, on_close = {} } + dap.listeners.on_session["flutter-tools"](nil, session) + + dap.listeners.before["event_progressStart"]["flutter-tools"](nil, { + progressId = "launch", + title = "Flutter", + message = "Launching…", + }) + session.on_close["flutter-tools"](session) + vim.api.nvim_del_autocmd(autocmd) + + assert.are.same({ "running", "failed" }, statuses) + end) end) diff --git a/tests/devices_spec.lua b/tests/devices_spec.lua index 1a999b2..3546e2f 100644 --- a/tests/devices_spec.lua +++ b/tests/devices_spec.lua @@ -177,6 +177,9 @@ INFO | Storing crashdata in: /tmp/android-ts/emu-crash-34.2.14.db, detection notify = function(msg, level) if msg ~= "" then table.insert(notifications, { msg = msg, level = level }) end end, + progress = function() + return { report = function() end } + end, } package.loaded["plenary.job"] = { new = function(_, opts) diff --git a/tests/ui_spec.lua b/tests/ui_spec.lua new file mode 100644 index 0000000..4fdd84e --- /dev/null +++ b/tests/ui_spec.lua @@ -0,0 +1,47 @@ +describe("ui.progress", function() + local ui + local original_timing + local events + local autocmd + + before_each(function() + ui = require("flutter-tools.ui") + original_timing = ui.progress_timing + events = {} + autocmd = vim.api.nvim_create_autocmd("Progress", { + callback = function(ev) table.insert(events, { ev.data.status, ev.data.percent }) end, + }) + end) + + after_each(function() + ui.progress_timing = original_timing + vim.api.nvim_del_autocmd(autocmd) + end) + + it("resends a running progress so terminals keep the bar", function() + ui.progress_timing = { keepalive_ms = 10, stall_ms = 60000 } + local progress = ui.progress("Test") + + progress:report("Working", "running", { percent = 40 }) + vim.wait(100, function() return #events >= 3 end) + progress:report("Done", "success") + local count = #events + vim.wait(50) + + assert.is_true(count >= 4) + assert.are.same({ "running", 40 }, events[2]) + assert.are.same({ "success" }, events[count]) + assert.are.equal(count, #events) + end) + + it("stops resending a progress that has not been updated for too long", function() + ui.progress_timing = { keepalive_ms = 10, stall_ms = 0 } + local progress = ui.progress("Test") + + progress:report("Working", "running") + vim.wait(50) + + assert.are.same({ { "running" } }, events) + progress:report("Done", "success") + end) +end)