diff --git a/README.md b/README.md index f6d7df9..3a6edaf 100644 --- a/README.md +++ b/README.md @@ -413,7 +413,7 @@ dictionary that is created if you have set any of the decorations to `true` in y The currently supported decorations are: - App version - The current version of the app from the `pubspec.yaml`. -- Device - the device passed to the flutter run command +- Device - the running device, or, while nothing is running, the device `flutter run` would default to - Project config - the name of the currently selected project configuration To add them to your config you can do something like diff --git a/lua/flutter-tools/commands.lua b/lua/flutter-tools/commands.lua index bd46820..879be37 100644 --- a/lua/flutter-tools/commands.lua +++ b/lua/flutter-tools/commands.lua @@ -47,6 +47,21 @@ local function use_debugger_runner(force_debug) return false end +---@param device Device? +local function set_current_device(device) + current_device = device + utils.emit_event(utils.events.DEVICE_CHANGED, { data = device }) +end + +---Resolve the device name from the `Launching ... on in mode` output line +---@param line string +local function update_device_from_output(line) + local name = line:match("^Launching .+ on (.+) in %w+ mode") + if not name then return end + local device = vim.tbl_extend("force", current_device or {}, { name = vim.trim(name) }) + set_current_device(device) +end + function M.current_device() return current_device end function M.is_running() return runner ~= nil and runner:is_running() end @@ -76,13 +91,14 @@ end ---@param is_err boolean if this is stdout or stderr local function on_run_data(is_err, data) if is_err and config.dev_log.notify_errors then ui.notify(data, ui.ERROR, { timeout = 5000 }) end + update_device_from_output(data) dev_log.log(data) end local function shutdown() if runner then runner:cleanup() end runner = nil - current_device = nil + set_current_device(nil) utils.emit_event(utils.events.PROJECT_CONFIG_CHANGED) dev_tools.on_flutter_shutdown() end @@ -240,7 +256,7 @@ local function run(opts, project_conf, launch_config) executable.get(function(paths) local args = opts.cli_args or get_run_args(opts, project_conf) - current_device = opts.device or get_device_from_args(args) + set_current_device(opts.device or get_device_from_args(args)) if project_conf then if project_conf.pre_run_callback then local callback_args = { @@ -642,6 +658,7 @@ end if __TEST then M.__run = run M.__get_run_args = get_run_args + M.__update_device_from_output = update_device_from_output end return M diff --git a/lua/flutter-tools/decorations.lua b/lua/flutter-tools/decorations.lua index fa674f5..d536203 100644 --- a/lua/flutter-tools/decorations.lua +++ b/lua/flutter-tools/decorations.lua @@ -1,5 +1,6 @@ local lazy = require("flutter-tools.lazy") local commands = lazy.require("flutter-tools.commands") ---@module "flutter-tools.commands" +local devices = lazy.require("flutter-tools.devices") ---@module "flutter-tools.devices" local path = lazy.require("flutter-tools.utils.path") ---@module "flutter-tools.utils.path" local utils = lazy.require("flutter-tools.utils") ---@module "flutter-tools.utils" local Path = require("plenary.path") @@ -32,12 +33,17 @@ end local function device_show() local device = commands.current_device() - if device then set_decoration_item("device", device) end + if device then return set_decoration_item("device", device) end + devices.get_default_device(function(default_device) + if commands.current_device() then return end + set_decoration_item("device", default_device) + end) end function M.statusline.device() + device_show() api.nvim_create_autocmd("User", { - pattern = utils.events.APP_STARTED, + pattern = { utils.events.APP_STARTED, utils.events.DEVICE_CHANGED }, callback = device_show, }) end diff --git a/lua/flutter-tools/devices.lua b/lua/flutter-tools/devices.lua index 4264a83..9d13a16 100644 --- a/lua/flutter-tools/devices.lua +++ b/lua/flutter-tools/devices.lua @@ -16,6 +16,17 @@ local M = { local EMULATOR = 1 local DEVICE = 2 +local NON_EPHEMERAL_PLATFORMS = { + ["darwin"] = true, + ["darwin-arm64"] = true, + ["darwin-x64"] = true, + ["linux-arm64"] = true, + ["linux-x64"] = true, + ["windows-arm64"] = true, + ["windows-x64"] = true, + ["web-javascript"] = true, +} + ---@param result string[] ---@param type integer local function get_devices(result, type) @@ -86,6 +97,51 @@ function M.to_selection_entries(result, device_type) end, devices) end +---@param entry table +---@return Device +local function to_device(entry) + return { + name = entry.name, + id = entry.id, + platform = entry.targetPlatform, + system = entry.sdk, + type = DEVICE, + } +end + +---Pick the device `flutter run` would use without an explicit `-d` argument. +---Mirrors flutter's own resolution: a single supported device wins, otherwise +---a single ephemeral (non desktop/web) device wins, otherwise flutter prompts. +---@param result string[] +---@return Device? +function M.resolve_default_device(result) + local ok, decoded = pcall(vim.json.decode, table.concat(result, "\n")) + if not ok or type(decoded) ~= "table" then return end + + local supported = vim.tbl_filter(function(entry) return entry.isSupported ~= false end, decoded) + if #supported == 0 then return end + if #supported == 1 then return to_device(supported[1]) end + + local ephemeral = vim.tbl_filter( + function(entry) return not NON_EPHEMERAL_PLATFORMS[entry.targetPlatform] end, + supported + ) + if #ephemeral == 1 then return to_device(ephemeral[1]) end +end + +---Asynchronously get the device `flutter run` would default to +---@param callback fun(device: Device?) +function M.get_default_device(callback) + executable.flutter(function(cmd) + local job = Job:new({ command = cmd, args = { "devices", "--machine" } }) + job:after(vim.schedule_wrap(function(j, code) + if code ~= 0 then return callback(nil) end + callback(M.resolve_default_device(j:result())) + end)) + job:start() + end) +end + -----------------------------------------------------------------------------// -- Emulators -----------------------------------------------------------------------------// diff --git a/lua/flutter-tools/utils/init.lua b/lua/flutter-tools/utils/init.lua index 0a5e213..572a4c5 100644 --- a/lua/flutter-tools/utils/init.lua +++ b/lua/flutter-tools/utils/init.lua @@ -92,6 +92,7 @@ function M.join(lines) return table.concat(lines, "\n") end M.events = { PROJECT_CONFIG_CHANGED = "FlutterToolsProjectConfigChanged", APP_STARTED = "FlutterToolsAppStarted", + DEVICE_CHANGED = "FlutterToolsDeviceChanged", OUTLINE_CHANGED = "FlutterToolsOutlineChanged", LSP_ANALYSIS_COMPLETED = "FlutterToolsLspAnalysisCompleted", } diff --git a/tests/commands_spec.lua b/tests/commands_spec.lua index 6aef7d8..1bc2580 100644 --- a/tests/commands_spec.lua +++ b/tests/commands_spec.lua @@ -86,4 +86,25 @@ describe("commands", function() ) end ) + + it("should pick up the device name from the run output", function() + commands.__update_device_from_output( + "Launching lib/main.dart on iPhone 16 Pro in debug mode..." + ) + + assert.equal("iPhone 16 Pro", commands.current_device().name) + end) + + it("should keep the device id when the name is parsed from the run output", function() + commands.__update_device_from_output("Launching lib/main.dart on macOS in debug mode...") + + local device = commands.current_device() + assert.equal("macOS", device.name) + end) + + it("should ignore unrelated output lines", function() + commands.__update_device_from_output('Running "flutter pub get" in example...') + + assert.is_nil(commands.current_device()) + end) end) diff --git a/tests/devices_spec.lua b/tests/devices_spec.lua index 1c266e1..0193496 100644 --- a/tests/devices_spec.lua +++ b/tests/devices_spec.lua @@ -56,4 +56,53 @@ INFO | Storing crashdata in: /tmp/android-ts/emu-crash-34.2.14.db, detection assert.same({}, devices.to_selection_entries(nil)) end) end) + + describe("default device resolution - ", function() + local devices = require("flutter-tools.devices") + + local function machine_output(entries) return { vim.json.encode(entries) } end + + it("should pick the only supported device", function() + local device = devices.resolve_default_device(machine_output({ + { name = "macOS", id = "macos", isSupported = true, targetPlatform = "darwin" }, + })) + + assert.equal("macos", device.id) + assert.equal("macOS", device.name) + assert.equal("darwin", device.platform) + end) + + it("should prefer the single ephemeral device over desktop and web", function() + local device = devices.resolve_default_device(machine_output({ + { name = "iPhone 16", id = "sim-id", isSupported = true, targetPlatform = "ios" }, + { name = "macOS", id = "macos", isSupported = true, targetPlatform = "darwin" }, + { name = "Chrome", id = "chrome", isSupported = true, targetPlatform = "web-javascript" }, + })) + + assert.equal("sim-id", device.id) + end) + + it("should ignore unsupported devices", function() + local device = devices.resolve_default_device(machine_output({ + { name = "iPhone 16", id = "sim-id", isSupported = false, targetPlatform = "ios" }, + { name = "macOS", id = "macos", isSupported = true, targetPlatform = "darwin" }, + })) + + assert.equal("macos", device.id) + end) + + it("should return nil when several ephemeral devices are connected", function() + local device = devices.resolve_default_device(machine_output({ + { name = "iPhone 16", id = "sim-id", isSupported = true, targetPlatform = "ios" }, + { name = "Pixel 8", id = "emulator-5554", isSupported = true, targetPlatform = "android" }, + })) + + assert.is_nil(device) + end) + + it("should return nil for no devices or invalid output", function() + assert.is_nil(devices.resolve_default_device(machine_output({}))) + assert.is_nil(devices.resolve_default_device({ "not json" })) + end) + end) end)