2497 lines
101 KiB
GDScript
2497 lines
101 KiB
GDScript
@tool
|
|
class_name McpDock
|
|
extends VBoxContainer
|
|
|
|
## Editor dock panel showing MCP connection status, client config, and command log.
|
|
##
|
|
## Audit-v2 #360 partial extraction. Two cohesive subpanels live in
|
|
## res://addons/godot_ai/dock_panels/:
|
|
## - log_viewer.gd: MCP request/response log (dev-mode only).
|
|
## - port_picker_panel.gd: spawn-failure escape hatch nested in the crash panel.
|
|
##
|
|
## The audit also called for ServerStatusPanel and ClientRowController
|
|
## extractions; those were *deliberately deferred*. Their UI scatters across
|
|
## the dock layout (status icon at top, crash panel mid, setup section lower;
|
|
## client rows + drift banner + scroll grid spread similarly), so a clean
|
|
## extract-by-panel needs either visible UI reorganization or a coordinator-
|
|
## Node pattern with property-accessor façades on McpDock that re-tangle the
|
|
## very state they claim to move.
|
|
##
|
|
## A future refactor probably wants extract-by-concern instead — e.g.
|
|
## `utils/mcp_async_refresh_state_machine.gd` owning the IDLE → RUNNING →
|
|
## RUNNING_TIMED_OUT → DEFERRED_FOR_FILESYSTEM → SHUTTING_DOWN transitions
|
|
## and pending-flag triplet, `utils/mcp_client_action_dispatcher.gd` owning
|
|
## the per-row Configure/Remove worker pool. The dock would keep UI
|
|
## construction and lose the state-machine ownership. See issue #360.
|
|
|
|
const ServerStateScript := preload("res://addons/godot_ai/utils/mcp_server_state.gd")
|
|
const ClientRefreshStateScript := preload("res://addons/godot_ai/utils/mcp_client_refresh_state.gd")
|
|
const Telemetry := preload("res://addons/godot_ai/telemetry.gd")
|
|
const UpdateManagerScript := preload("res://addons/godot_ai/utils/update_manager.gd")
|
|
const UpdateMixedStateScript := preload("res://addons/godot_ai/utils/update_mixed_state.gd")
|
|
const Client := preload("res://addons/godot_ai/clients/_base.gd")
|
|
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
|
|
const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd")
|
|
const JsonStrategy := preload("res://addons/godot_ai/clients/_json_strategy.gd")
|
|
const TomlStrategy := preload("res://addons/godot_ai/clients/_toml_strategy.gd")
|
|
const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd")
|
|
const ToolCatalog := preload("res://addons/godot_ai/tool_catalog.gd")
|
|
const LogViewerScript := preload("res://addons/godot_ai/dock_panels/log_viewer.gd")
|
|
const PortPickerPanelScript := preload("res://addons/godot_ai/dock_panels/port_picker_panel.gd")
|
|
|
|
const DEV_MODE_SETTING := "godot_ai/dev_mode"
|
|
const CLIENT_STATUS_REFRESH_COOLDOWN_MSEC := 15 * 1000
|
|
const CLIENT_STATUS_REFRESH_TIMEOUT_MSEC := 30 * 1000
|
|
static var COLOR_MUTED := Color(0.7, 0.7, 0.7)
|
|
static var COLOR_HEADER := Color(0.95, 0.95, 0.95)
|
|
## Used for "in-progress" / "stale, action needed" UI: the startup-grace
|
|
## status icon, the spawn-failure suggested-port hint, the drift banner,
|
|
## and the per-row mismatch dot. One constant so a future palette tweak
|
|
## doesn't have to find every literal.
|
|
static var COLOR_AMBER := Color(1.0, 0.75, 0.25)
|
|
|
|
var _connection
|
|
var _log_buffer
|
|
var _plugin: EditorPlugin
|
|
|
|
# Always visible
|
|
var _redock_btn: Button
|
|
var _status_icon: ColorRect
|
|
var _status_label: Label
|
|
var _client_grid: VBoxContainer
|
|
var _client_configure_all_btn: Button
|
|
var _clients_summary_label: Label
|
|
var _clients_window: Window
|
|
var _dev_mode_toggle: CheckButton
|
|
var _install_label: Label
|
|
|
|
# Settings tab (secondary window, Tab 2) — domain-exclusion UI for clients
|
|
# that cap total tool count (Antigravity: 100). Pending set is mutated by
|
|
# checkbox clicks; saved set reflects what the spawned server actually
|
|
# sees. `Apply & Restart Server` writes pending → setting and triggers a
|
|
# plugin reload so the new server comes up with the trimmed list.
|
|
var _tools_pending_excluded: PackedStringArray = PackedStringArray()
|
|
var _tools_saved_excluded: PackedStringArray = PackedStringArray()
|
|
var _tools_domain_checkboxes: Dictionary = {}
|
|
var _tools_count_label: Label
|
|
var _tools_apply_btn: Button
|
|
var _tools_reset_btn: Button
|
|
var _tools_dirty_warning: Label
|
|
var _tools_close_confirm: ConfirmationDialog
|
|
var _telemetry_toggle: CheckButton
|
|
var _telemetry_pending_enabled: bool = true
|
|
var _telemetry_saved_enabled: bool = true
|
|
|
|
## Per-client UI handles, keyed by client id. Each entry holds the row's
|
|
## status dot, configure button, remove button, manual-command panel + text.
|
|
var _client_rows: Dictionary = {}
|
|
|
|
# Drift banner — surfaced near the Clients section when one or more clients
|
|
# have a stored entry whose URL no longer matches `http_url()` (typical after
|
|
# the user changes `godot_ai/http_port`). Refreshes are stale-while-refreshing:
|
|
# cached row dots/banner remain visible while a background worker performs the
|
|
# potentially blocking config/CLI probes, then the main thread applies results.
|
|
# Automatic focus-in refreshes use a short cooldown to avoid repeated sweeps
|
|
# during tab-away/tab-back churn. See #166 and #226.
|
|
var _drift_banner: VBoxContainer
|
|
var _drift_label: Label
|
|
## Handles for the Setup section's "Server" row. `_update_status` keeps
|
|
## the label text/color in sync with `McpConnection.server_version` so the
|
|
## dock reports the TRUE running server version, not the plugin's
|
|
## expected version. See #174 follow-up — a plugin upgrade via self-
|
|
## update can leave the plugin connected to an older adopted server
|
|
## (foreign-port branch never sets `_server_pid`, so `_stop_server`
|
|
## can't kill it); the line has to show the mismatch honestly.
|
|
var _setup_server_label: Label
|
|
## Last rendered server-version string. `_update_status` runs every
|
|
## frame; early-outs text repaint when nothing changed. Empty means
|
|
## "no line rendered yet" (dev-checkout branch doesn't render a
|
|
## user-mode Server line).
|
|
var _last_rendered_server_text: String = ""
|
|
## Restart-server button shown next to the Setup container when
|
|
## `McpConnection.server_version` drifts from the plugin version. Hidden
|
|
## in the match case so the UI stays calm.
|
|
var _version_restart_btn: Button
|
|
var _server_restart_in_progress := false
|
|
## Sorted snapshot of the most recent mismatched-client set. Powers two things:
|
|
## (a) the Reconfigure button reuses this list instead of re-running
|
|
## `check_status` per row (saves ~18 filesystem reads per click), and
|
|
## (b) `_refresh_drift_banner` early-returns when the set is unchanged so
|
|
## repeated explicit refreshes don't repaint identical text. Mirrors the
|
|
## `_last_server_status` pattern used by the crash panel.
|
|
var _last_mismatched_ids: Array[String] = []
|
|
var _client_status_refresh_thread: Thread
|
|
## Single source of truth for the refresh-sweep state machine. See
|
|
## `ClientRefreshStateScript` for the transition table. Replaces the
|
|
## previously scattered booleans (`_in_flight`, `_timed_out`,
|
|
## `_deferred_until_filesystem_ready`, `_shutdown_requested`).
|
|
var _refresh_state: int = ClientRefreshStateScript.IDLE
|
|
## Pending-request flags. Kept separate from `_refresh_state` because
|
|
## they're "what should the next refresh look like" — not state of
|
|
## any current refresh. A pending request is queued when a refresh
|
|
## arrives during RUNNING / RUNNING_TIMED_OUT and consumed by
|
|
## `_apply_client_status_refresh_results` once the in-flight worker
|
|
## drains. `_pending_force` also captures forced retries deferred via
|
|
## DEFERRED_FOR_FILESYSTEM so a pending user click survives the wait.
|
|
var _client_status_refresh_pending: bool = false
|
|
var _client_status_refresh_pending_force: bool = false
|
|
var _client_status_refresh_pending_initial: bool = false
|
|
var _last_client_status_refresh_completed_msec: int = 0
|
|
var _client_status_refresh_started_msec: int = 0
|
|
var _client_status_refresh_generation: int = 0
|
|
## Owns the self-update slice: GitHub Releases poll, ZIP download, install
|
|
## orchestration, and the install-in-flight gate. Dock keeps banner UI
|
|
## only and consults the gate via `_is_self_update_in_progress()`.
|
|
var _update_manager
|
|
static var _orphaned_client_status_refresh_threads: Array[Thread] = []
|
|
|
|
## Per-row worker state for Configure / Remove. Issue #239: shelling out
|
|
## to a hung CLI on main hangs the editor. We dispatch each click to its
|
|
## own thread (one slot per client) and apply the result via call_deferred
|
|
## once the subprocess returns or the wall-clock budget in McpCliExec
|
|
## kicks in. The buttons stay disabled while the slot is busy so the user
|
|
## can't queue a re-click on the same row.
|
|
##
|
|
## Per-client (not single-slot) so Configure-all can fan out — the
|
|
## workers are independent, only the row UI is shared, and McpCliExec
|
|
## bounds the wall-clock for each.
|
|
##
|
|
## No orphan-thread list (unlike the refresh worker): action threads
|
|
## never get abandoned mid-flight. McpCliExec's wall-clock budget caps
|
|
## the worst case at ~10s, so the `_exit_tree` / `McpUpdateManager`
|
|
## install-time drain blocks briefly and finishes — there's no path that
|
|
## "gives up" on an action thread the way `_abandon_client_status_refresh_thread`
|
|
## does for the refresh worker.
|
|
var _client_action_threads: Dictionary = {}
|
|
var _client_action_generations: Dictionary = {}
|
|
|
|
# Dev-mode only
|
|
var _dev_section: VBoxContainer
|
|
var _server_label: Label
|
|
var _reload_btn: Button
|
|
var _setup_section: VBoxContainer
|
|
var _setup_container: VBoxContainer
|
|
## Primary dev-section button — always (re)starts a `--reload` dev server.
|
|
## Same-version Python edits get adopted as compatible by the lifecycle, so
|
|
## neither the drift nor the crash Restart button surfaces; this is the
|
|
## unconditional kick contributors need to pick up source changes without
|
|
## a version bump.
|
|
var _dev_primary_btn: Button
|
|
## Small "✕" affordance next to the primary — stops the dev server without
|
|
## spawning a replacement. Disabled when no dev server is running.
|
|
var _dev_stop_btn: Button
|
|
var _log_viewer: LogViewerScript
|
|
|
|
var _last_connected := false
|
|
var _last_status_text := ""
|
|
var _startup_grace_until_msec: int = 0
|
|
|
|
# Spawn-failure panel — rendered when `get_server_status` reports a
|
|
# non-OK `state`. One panel, one body paragraph per state, no cascading
|
|
# booleans. See `_crash_body_for_state`.
|
|
var _crash_panel: VBoxContainer
|
|
var _crash_output: RichTextLabel
|
|
var _crash_restart_btn: Button
|
|
var _crash_reload_btn: Button
|
|
## Port-picker escape hatch — visible inside the crash panel when the root
|
|
## cause is port contention (PORT_EXCLUDED or FOREIGN_PORT). The dock writes
|
|
## the EditorSetting and reloads the plugin in response to the panel's
|
|
## `port_apply_requested` signal.
|
|
var _port_picker_panel: PortPickerPanelScript
|
|
## Last status Dict rendered into the panel — used to skip re-population
|
|
## when nothing changed, which would otherwise reset the user's scroll
|
|
## position on every frame. GDScript Dicts compare by value with `==`.
|
|
var _last_server_status: Dictionary = {}
|
|
|
|
# First-run grace: uvx installs 60+ Python packages on first run (can take
|
|
# 10-30s on a slow connection). Don't scare users with "Disconnected" during
|
|
# that window — show "Starting server…" instead. After this expires, fall
|
|
# back to the normal disconnect UI.
|
|
const STARTUP_GRACE_MSEC := 60 * 1000
|
|
|
|
# Update banner — visible UI only. Releases polling, ZIP download, and
|
|
# the install pipeline live on `_update_manager`.
|
|
var _update_banner: VBoxContainer
|
|
var _update_label: Label
|
|
var _update_btn: Button
|
|
|
|
# Mixed-state banner — surfaces when `addons/godot_ai/` contains
|
|
# `*.update_backup` files left by a self-update whose rollback failed
|
|
# (`UpdateReloadRunner.InstallStatus.FAILED_MIXED`). Without this banner
|
|
# the user sees "plugin won't start" with no actionable context, re-runs
|
|
# the update, and compounds the mismatch (issue #354 / audit-v2 #10).
|
|
var _mixed_state_banner: VBoxContainer
|
|
var _mixed_state_label: Label
|
|
var _mixed_state_files: RichTextLabel
|
|
var _mixed_state_rescan_btn: Button
|
|
|
|
|
|
func setup(connection: McpConnection, log_buffer: McpLogBuffer, plugin: EditorPlugin) -> void:
|
|
_connection = connection
|
|
_log_buffer = log_buffer
|
|
_plugin = plugin
|
|
_startup_grace_until_msec = Time.get_ticks_msec() + STARTUP_GRACE_MSEC
|
|
|
|
|
|
func _ready() -> void:
|
|
_build_ui()
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
if _connection == null:
|
|
return
|
|
_prune_orphaned_client_status_refresh_threads()
|
|
_check_client_status_refresh_timeout()
|
|
_retry_deferred_client_status_refresh()
|
|
_update_status()
|
|
if _log_viewer != null and _log_viewer.visible:
|
|
_log_viewer.tick()
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
## Block on any in-flight refresh worker before letting the dock leave the
|
|
## tree. The plugin disable path (editor_reload_plugin, Project Settings
|
|
## toggle) reloads the McpDock script class — which wipes the static
|
|
## `_orphaned_client_status_refresh_threads`, GCs the Thread objects mid-
|
|
## execution, and triggers `~Thread … destroyed without its completion
|
|
## having been realized` plus GDScript VM corruption (Opcode: 0, IP-bounds
|
|
## errors, intermittent SIGSEGV). Probes finish in well under a second
|
|
## under normal conditions; if a CLI probe genuinely hung, the runtime
|
|
## timeout path (`_abandon_client_status_refresh_thread`) has already
|
|
## moved that thread into the orphan list, so we drain it here too.
|
|
##
|
|
## `wait_to_finish` is unbounded by design: GDScript's Thread API has no
|
|
## timeout, and a polling/abandon fallback would just re-introduce the
|
|
## GC-mid-execution crash this fix exists to prevent. Blocking the editor
|
|
## briefly on plugin-reload is strictly better than the SIGSEGV.
|
|
_refresh_state = ClientRefreshStateScript.SHUTTING_DOWN
|
|
_drain_client_status_refresh_workers()
|
|
_drain_client_action_workers()
|
|
|
|
|
|
## Public drain entry consulted by `McpUpdateManager._install_zip` before
|
|
## any disk write. Pairs both worker pools so the manager doesn't reach
|
|
## into private dock methods. `_exit_tree` still calls the two underlying
|
|
## drains directly because it has additional state-machine work
|
|
## (SHUTTING_DOWN sticky-set) that the install-time path must NOT inherit.
|
|
func prepare_for_self_update_drain() -> void:
|
|
_drain_client_status_refresh_workers()
|
|
_drain_client_action_workers()
|
|
|
|
|
|
func _drain_client_status_refresh_workers() -> void:
|
|
## Block until any in-flight refresh worker (and any orphaned workers from
|
|
## a prior timeout) finish, then clear refresh state. Same blocking
|
|
## semantics as the `_exit_tree` drain — see #232. Used by `_exit_tree`
|
|
## (dock teardown) and `McpUpdateManager._install_zip` (before extract
|
|
## overwrites plugin scripts on disk).
|
|
_client_status_refresh_generation += 1
|
|
if _client_status_refresh_thread != null:
|
|
_client_status_refresh_thread.wait_to_finish()
|
|
_client_status_refresh_thread = null
|
|
for thread in _orphaned_client_status_refresh_threads:
|
|
if thread != null:
|
|
thread.wait_to_finish()
|
|
_orphaned_client_status_refresh_threads.clear()
|
|
## Don't transition out of SHUTTING_DOWN — the drain is called from
|
|
## `_exit_tree` (sticky shutdown) and from
|
|
## `McpUpdateManager._install_zip`'s post-drain reset, which writes
|
|
## the state explicitly.
|
|
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
|
|
_refresh_state = ClientRefreshStateScript.IDLE
|
|
_client_status_refresh_pending = false
|
|
_client_status_refresh_pending_force = false
|
|
_client_status_refresh_pending_initial = false
|
|
|
|
|
|
func _drain_client_action_workers() -> void:
|
|
## Same drain semantics as the refresh worker (see comment above): the
|
|
## plugin disable / install-update path reloads our script class, so any
|
|
## live Thread must finish before its slot is GC'd or we hit
|
|
## `~Thread … destroyed without its completion having been realized` →
|
|
## VM corruption. Bounded by `McpCliExec` wall-clock budgets, so the
|
|
## worst case is a ~10s blocking drain, vs. an unbounded SIGSEGV.
|
|
##
|
|
## Generation-bumped per-row so any pending `call_deferred(
|
|
## "_apply_client_action_result")` from a worker that finished after we
|
|
## started draining detects the generation mismatch and short-circuits
|
|
## without touching freed UI state.
|
|
##
|
|
## After draining, restore the row UI for any in-flight rows: bare
|
|
## `_client_action_threads.clear()` would leave the dock stuck showing
|
|
## "Configuring…" / "Removing…" with disabled buttons forever — a
|
|
## user-visible failure mode for the install-update bail-out branch
|
|
## (zip extract failure on the manager clears `_install_in_flight` and
|
|
## the dock stays alive).
|
|
for client_id in _client_action_threads.keys():
|
|
var t: Thread = _client_action_threads[client_id]
|
|
if t != null:
|
|
t.wait_to_finish()
|
|
_client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1
|
|
_finalize_action_buttons(String(client_id))
|
|
var row: Dictionary = _client_rows.get(String(client_id), {})
|
|
if not row.is_empty():
|
|
_apply_row_status(
|
|
String(client_id),
|
|
row.get("status", Client.Status.NOT_CONFIGURED),
|
|
""
|
|
)
|
|
_client_action_threads.clear()
|
|
|
|
|
|
func _notification(what: int) -> void:
|
|
# Detect dock/undock by watching for reparenting events.
|
|
if what == NOTIFICATION_PARENTED or what == NOTIFICATION_UNPARENTED:
|
|
_update_redock_visibility.call_deferred()
|
|
elif what == NOTIFICATION_APPLICATION_FOCUS_IN:
|
|
if _should_refresh_client_statuses_on_focus_in():
|
|
_request_client_status_refresh(false)
|
|
|
|
|
|
func _should_refresh_client_statuses_on_focus_in() -> bool:
|
|
## Focus-in is part of Godot/editor window activation. Keep automatic refresh,
|
|
## but only through the async/cooldown-protected path; never run a blocking
|
|
## client-status sweep directly from this notification.
|
|
return true
|
|
|
|
|
|
func _is_floating() -> bool:
|
|
var p := get_parent()
|
|
while p != null:
|
|
if p is Window:
|
|
return p != get_tree().root
|
|
p = p.get_parent()
|
|
return false
|
|
|
|
|
|
func _update_redock_visibility() -> void:
|
|
if _redock_btn == null:
|
|
return
|
|
var floating := _is_floating()
|
|
if _redock_btn.visible != floating:
|
|
_redock_btn.visible = floating
|
|
|
|
|
|
func _on_redock() -> void:
|
|
# When floating, our Window is NOT the editor root. Closing it triggers
|
|
# Godot's internal dock-return logic (same as clicking the window's X).
|
|
var win := get_window()
|
|
if win != null and win != get_tree().root:
|
|
win.close_requested.emit()
|
|
|
|
|
|
func _build_margin_container(margin: int = 12) -> MarginContainer:
|
|
var margin_container := MarginContainer.new()
|
|
margin_container.add_theme_constant_override("margin_left", margin)
|
|
margin_container.add_theme_constant_override("margin_right", margin)
|
|
margin_container.add_theme_constant_override("margin_top", margin)
|
|
margin_container.add_theme_constant_override("margin_bottom", margin)
|
|
return margin_container
|
|
|
|
|
|
func _build_ui() -> void:
|
|
add_theme_constant_override("separation", 8)
|
|
|
|
# --- Top row: status indicator + redock button (when floating) ---
|
|
var status_row := HBoxContainer.new()
|
|
status_row.add_theme_constant_override("separation", 8)
|
|
|
|
_status_icon = ColorRect.new()
|
|
_status_icon.custom_minimum_size = Vector2(14, 14)
|
|
# Amber on first paint — matches the "Starting server…" label text and
|
|
# distinguishes from a real disconnect (red).
|
|
_status_icon.color = COLOR_AMBER
|
|
var icon_center := CenterContainer.new()
|
|
icon_center.add_child(_status_icon)
|
|
status_row.add_child(icon_center)
|
|
|
|
_status_label = Label.new()
|
|
# Start in grace state — _update_status will take over on the next frame
|
|
# once the connection is available. Never show bare "Disconnected" on
|
|
# first paint because that's misleading while the server is still
|
|
# spinning up.
|
|
_status_label.text = "Starting server…"
|
|
_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
status_row.add_child(_status_label)
|
|
|
|
_redock_btn = Button.new()
|
|
_redock_btn.text = "Dock"
|
|
_redock_btn.tooltip_text = "Return this panel to the editor dock"
|
|
_redock_btn.visible = false
|
|
_redock_btn.pressed.connect(_on_redock)
|
|
status_row.add_child(_redock_btn)
|
|
|
|
add_child(status_row)
|
|
|
|
# Install-mode line — so a git-clone user doesn't press the yellow Update
|
|
# banner below and silently downgrade from main to the last release tag.
|
|
# See #144.
|
|
_install_label = Label.new()
|
|
_install_label.add_theme_color_override("font_color", COLOR_MUTED)
|
|
_install_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_install_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_install_label.text = _install_mode_text()
|
|
_install_label.tooltip_text = _install_mode_tooltip()
|
|
_install_label.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
add_child(_install_label)
|
|
|
|
# --- Spawn-failure panel (shown when `_start_server` reports a non-OK
|
|
# state via `get_server_status`). One body paragraph + the matching
|
|
# action; the top status label already carries the state headline.
|
|
_crash_panel = VBoxContainer.new()
|
|
_crash_panel.add_theme_constant_override("separation", 6)
|
|
_crash_panel.visible = false
|
|
|
|
_crash_output = RichTextLabel.new()
|
|
_crash_output.custom_minimum_size = Vector2(0, 60)
|
|
_crash_output.bbcode_enabled = false
|
|
_crash_output.selection_enabled = true
|
|
_crash_output.scroll_following = false
|
|
_crash_output.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_crash_output.fit_content = true
|
|
_crash_panel.add_child(_crash_output)
|
|
|
|
_port_picker_panel = PortPickerPanelScript.new()
|
|
_port_picker_panel.setup()
|
|
_port_picker_panel.port_apply_requested.connect(_on_port_apply_requested)
|
|
_crash_panel.add_child(_port_picker_panel)
|
|
|
|
_crash_restart_btn = Button.new()
|
|
_crash_restart_btn.text = "Restart Server"
|
|
_crash_restart_btn.tooltip_text = "Stop the old server on this port and start the bundled godot-ai server"
|
|
_crash_restart_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_crash_restart_btn.add_theme_color_override("font_color", Color.WHITE)
|
|
_crash_restart_btn.add_theme_color_override("font_hover_color", Color.WHITE)
|
|
_crash_restart_btn.add_theme_color_override("font_pressed_color", Color.WHITE)
|
|
_crash_restart_btn.pressed.connect(_on_restart_stale_server)
|
|
_crash_restart_btn.visible = false
|
|
_crash_panel.add_child(_crash_restart_btn)
|
|
|
|
_crash_reload_btn = Button.new()
|
|
_crash_reload_btn.text = "Reload Plugin"
|
|
_crash_reload_btn.tooltip_text = "Re-run the spawn after fixing the underlying issue"
|
|
_crash_reload_btn.pressed.connect(_on_reload_plugin)
|
|
_crash_panel.add_child(_crash_reload_btn)
|
|
|
|
_crash_panel.add_child(HSeparator.new())
|
|
add_child(_crash_panel)
|
|
|
|
_build_mixed_state_banner()
|
|
_refresh_mixed_state_banner()
|
|
|
|
# --- Update banner (top of dock, hidden until check finds a newer version) ---
|
|
_update_banner = VBoxContainer.new()
|
|
_update_banner.add_theme_constant_override("separation", 4)
|
|
_update_banner.visible = false
|
|
|
|
_update_label = Label.new()
|
|
_update_label.add_theme_font_size_override("font_size", 15)
|
|
_update_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.3))
|
|
## Wrap long banner text (e.g. the < 4.4 manual-update guidance) instead
|
|
## of letting a single line stretch the whole dock wide. The dock is a
|
|
## fixed-width side panel, so constrain horizontally and wrap.
|
|
_update_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_update_label.size_flags_horizontal = Control.SIZE_FILL
|
|
_update_label.custom_minimum_size = Vector2(0, 0)
|
|
_update_banner.add_child(_update_label)
|
|
|
|
var update_btn_row := HBoxContainer.new()
|
|
update_btn_row.add_theme_constant_override("separation", 6)
|
|
|
|
_update_btn = Button.new()
|
|
_update_btn.text = "Update"
|
|
_update_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_update_btn.pressed.connect(_on_update_pressed)
|
|
update_btn_row.add_child(_update_btn)
|
|
|
|
var release_link := Button.new()
|
|
release_link.text = "Release notes"
|
|
release_link.pressed.connect(func(): OS.shell_open(UpdateManagerScript.RELEASES_PAGE))
|
|
update_btn_row.add_child(release_link)
|
|
|
|
_update_banner.add_child(update_btn_row)
|
|
_update_banner.add_child(HSeparator.new())
|
|
|
|
add_child(_update_banner)
|
|
|
|
if _update_manager == null:
|
|
_update_manager = UpdateManagerScript.new()
|
|
_update_manager.setup(_plugin, self)
|
|
_update_manager.update_check_completed.connect(_on_update_check_result)
|
|
_update_manager.install_state_changed.connect(_on_install_state_changed)
|
|
add_child(_update_manager)
|
|
_update_manager.check_for_updates.call_deferred()
|
|
|
|
# --- Dev-only connection extras (server label + reload button) ---
|
|
_dev_section = VBoxContainer.new()
|
|
_dev_section.add_theme_constant_override("separation", 6)
|
|
add_child(_dev_section)
|
|
|
|
_server_label = Label.new()
|
|
_server_label.add_theme_color_override("font_color", COLOR_MUTED)
|
|
_dev_section.add_child(_server_label)
|
|
_refresh_server_label()
|
|
|
|
var btn_row := HBoxContainer.new()
|
|
btn_row.add_theme_constant_override("separation", 6)
|
|
|
|
_reload_btn = Button.new()
|
|
_reload_btn.text = "Dev: Reload Plugin"
|
|
_reload_btn.tooltip_text = "Developer utility: reload the GDScript plugin. This does not restart or replace the server."
|
|
_reload_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_reload_btn.pressed.connect(_on_reload_plugin)
|
|
btn_row.add_child(_reload_btn)
|
|
|
|
_dev_section.add_child(btn_row)
|
|
|
|
# --- Setup section (dev-only or when uv missing) ---
|
|
_setup_section = VBoxContainer.new()
|
|
_setup_section.add_theme_constant_override("separation", 6)
|
|
add_child(_setup_section)
|
|
|
|
_setup_section.add_child(HSeparator.new())
|
|
_setup_section.add_child(_make_header("Setup"))
|
|
_setup_container = VBoxContainer.new()
|
|
_setup_container.add_theme_constant_override("separation", 6)
|
|
_setup_section.add_child(_setup_container)
|
|
|
|
add_child(HSeparator.new())
|
|
|
|
# --- Clients ---
|
|
var clients_row := HBoxContainer.new()
|
|
clients_row.add_theme_constant_override("separation", 8)
|
|
|
|
var clients_header := _make_header("Clients")
|
|
clients_row.add_child(clients_header)
|
|
|
|
_clients_summary_label = Label.new()
|
|
_clients_summary_label.add_theme_color_override("font_color", COLOR_MUTED)
|
|
_clients_summary_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
clients_row.add_child(_clients_summary_label)
|
|
|
|
var clients_refresh_btn := Button.new()
|
|
clients_refresh_btn.text = "Refresh"
|
|
clients_refresh_btn.tooltip_text = "Refresh client status in the background. Cached status stays visible while checks run."
|
|
clients_refresh_btn.pressed.connect(_on_refresh_clients_pressed)
|
|
clients_row.add_child(clients_refresh_btn)
|
|
|
|
var clients_open_btn := Button.new()
|
|
clients_open_btn.text = "Clients & Settings"
|
|
clients_open_btn.tooltip_text = "Open the MCP settings window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)."
|
|
clients_open_btn.pressed.connect(_on_open_clients_window)
|
|
clients_row.add_child(clients_open_btn)
|
|
|
|
add_child(clients_row)
|
|
|
|
# Drift banner — hidden until a sweep finds at least one mismatched client.
|
|
_drift_banner = VBoxContainer.new()
|
|
_drift_banner.add_theme_constant_override("separation", 4)
|
|
_drift_banner.visible = false
|
|
_drift_label = Label.new()
|
|
_drift_label.add_theme_color_override("font_color", COLOR_AMBER)
|
|
_drift_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_drift_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_drift_banner.add_child(_drift_label)
|
|
var drift_btn := Button.new()
|
|
drift_btn.text = "Reconfigure mismatched"
|
|
drift_btn.tooltip_text = "Re-run Configure on every client whose stored URL doesn't match the current server URL."
|
|
drift_btn.pressed.connect(_on_reconfigure_mismatched)
|
|
_drift_banner.add_child(drift_btn)
|
|
add_child(_drift_banner)
|
|
|
|
_clients_window = Window.new()
|
|
_clients_window.title = "MCP Clients & Settings"
|
|
## `Vector2i * float` yields Vector2; wrap the result back to Vector2i.
|
|
_clients_window.min_size = Vector2i(Vector2(560, 460) * EditorInterface.get_editor_scale())
|
|
_clients_window.visible = false
|
|
_clients_window.close_requested.connect(_on_clients_window_close_requested)
|
|
add_child(_clients_window)
|
|
|
|
## Two-tab secondary window: Clients (existing per-client rows) and Tools
|
|
## (domain-exclusion checkboxes for clients that cap total tool count,
|
|
## like Antigravity at 100). Adding a third tab is one more _build_*_tab
|
|
## call and a set_tab_title line — no surgery on the rest of the window.
|
|
var tabs := TabContainer.new()
|
|
tabs.anchor_right = 1.0
|
|
tabs.anchor_bottom = 1.0
|
|
_clients_window.add_child(tabs)
|
|
|
|
var clients_tab := VBoxContainer.new()
|
|
clients_tab.add_theme_constant_override("separation", 8)
|
|
var clients_margin := _build_margin_container()
|
|
clients_margin.name = "Clients"
|
|
clients_margin.add_child(clients_tab)
|
|
tabs.add_child(clients_margin)
|
|
|
|
_client_configure_all_btn = Button.new()
|
|
_client_configure_all_btn.text = "Configure all"
|
|
_client_configure_all_btn.tooltip_text = "Configure every client that isn't already pointing at this server"
|
|
_client_configure_all_btn.size_flags_horizontal = Control.SIZE_SHRINK_END
|
|
_client_configure_all_btn.pressed.connect(_on_configure_all_clients)
|
|
clients_tab.add_child(_client_configure_all_btn)
|
|
|
|
var clients_scroll := ScrollContainer.new()
|
|
clients_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
clients_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
clients_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
|
clients_tab.add_child(clients_scroll)
|
|
|
|
_client_grid = VBoxContainer.new()
|
|
_client_grid.add_theme_constant_override("separation", 4)
|
|
_client_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
clients_scroll.add_child(_client_grid)
|
|
|
|
for client_id in ClientConfigurator.client_ids():
|
|
_build_client_row(client_id)
|
|
|
|
_build_tools_tab(tabs)
|
|
|
|
add_child(HSeparator.new())
|
|
|
|
# --- Dev mode toggle (always visible) ---
|
|
var dev_toggle_row := HBoxContainer.new()
|
|
var dev_toggle_label := Label.new()
|
|
dev_toggle_label.text = "Developer mode"
|
|
dev_toggle_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
dev_toggle_row.add_child(dev_toggle_label)
|
|
|
|
_dev_mode_toggle = CheckButton.new()
|
|
_dev_mode_toggle.button_pressed = _load_dev_mode()
|
|
_dev_mode_toggle.toggled.connect(_on_dev_mode_toggled)
|
|
dev_toggle_row.add_child(_dev_mode_toggle)
|
|
add_child(dev_toggle_row)
|
|
|
|
# --- Log section (dev-only) ---
|
|
_log_viewer = LogViewerScript.new()
|
|
_log_viewer.setup(_log_buffer)
|
|
_log_viewer.logging_enabled_changed.connect(_on_log_logging_enabled_changed)
|
|
add_child(_log_viewer)
|
|
|
|
# Apply initial dev-mode visibility
|
|
_apply_dev_mode_visibility()
|
|
_refresh_setup_status.call_deferred()
|
|
_perform_initial_client_status_refresh()
|
|
|
|
|
|
## Static so `dock_panels/*.gd` subpanels can call it via `McpDock._make_header(...)`
|
|
## without re-declaring identical helpers + COLOR_HEADER constants.
|
|
static func _make_header(text: String) -> Label:
|
|
var label := Label.new()
|
|
label.text = text
|
|
label.add_theme_font_size_override("font_size", 18)
|
|
label.add_theme_color_override("font_color", COLOR_HEADER)
|
|
return label
|
|
|
|
|
|
func _build_client_row(client_id: String) -> void:
|
|
var row := HBoxContainer.new()
|
|
row.add_theme_constant_override("separation", 6)
|
|
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
|
|
var dot := ColorRect.new()
|
|
dot.custom_minimum_size = Vector2(10, 10)
|
|
dot.color = COLOR_MUTED
|
|
var dot_center := CenterContainer.new()
|
|
dot_center.add_child(dot)
|
|
row.add_child(dot_center)
|
|
|
|
var name_label := Label.new()
|
|
name_label.text = ClientConfigurator.client_display_name(client_id)
|
|
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
## Long error messages from `_verify_post_state` (e.g. "reported remove ok
|
|
## but verification still reads configured…") used to push the Retry /
|
|
## Configure button off-screen — the row's Label wanted its full text
|
|
## width as minimum size, so the buttons got squeezed out. Wrap onto
|
|
## multiple lines instead so the row keeps its right edge stable and
|
|
## the buttons remain visible; the user can also read the whole message
|
|
## without resizing the window.
|
|
name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
|
row.add_child(name_label)
|
|
|
|
var configure_btn := Button.new()
|
|
configure_btn.text = "Configure"
|
|
configure_btn.pressed.connect(_on_configure_client.bind(client_id))
|
|
row.add_child(configure_btn)
|
|
|
|
var remove_btn := Button.new()
|
|
remove_btn.text = "Remove"
|
|
remove_btn.visible = false
|
|
remove_btn.pressed.connect(_on_remove_client.bind(client_id))
|
|
row.add_child(remove_btn)
|
|
|
|
_client_grid.add_child(row)
|
|
|
|
var manual_panel := VBoxContainer.new()
|
|
manual_panel.add_theme_constant_override("separation", 4)
|
|
manual_panel.visible = false
|
|
|
|
var manual_hint := Label.new()
|
|
manual_hint.text = "Run this manually:"
|
|
manual_hint.add_theme_color_override("font_color", COLOR_MUTED)
|
|
manual_panel.add_child(manual_hint)
|
|
|
|
var manual_text := TextEdit.new()
|
|
manual_text.editable = false
|
|
manual_text.custom_minimum_size = Vector2(0, 60)
|
|
manual_text.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
|
|
manual_panel.add_child(manual_text)
|
|
|
|
var copy_btn := Button.new()
|
|
copy_btn.text = "Copy"
|
|
copy_btn.pressed.connect(_on_copy_manual_command.bind(client_id))
|
|
manual_panel.add_child(copy_btn)
|
|
|
|
_client_grid.add_child(manual_panel)
|
|
|
|
_client_rows[client_id] = {
|
|
"dot": dot,
|
|
"status": Client.Status.NOT_CONFIGURED,
|
|
"name_label": name_label,
|
|
"configure_btn": configure_btn,
|
|
"remove_btn": remove_btn,
|
|
"manual_panel": manual_panel,
|
|
"manual_text": manual_text,
|
|
}
|
|
|
|
|
|
# --- Status updates ---
|
|
|
|
func _update_status() -> void:
|
|
var connected: bool = _connection.is_connected
|
|
## During plugin self-update there's a brief window where this dock
|
|
## script is already the new version (Godot hot-reloads scripts on
|
|
## file change) but `_plugin` is still the old `EditorPlugin` instance
|
|
## (only `set_plugin_enabled(false, true)` re-instantiates that). When
|
|
## the new dock calls a method the old plugin doesn't have, `_process`
|
|
## errors every frame until `McpUpdateManager._reload_after_update`
|
|
## lands. Guard every `_plugin.<new_method>()` call with `has_method`
|
|
## so that window stays silent. See #168.
|
|
var server_status: Dictionary = (
|
|
_plugin.get_server_status()
|
|
if _plugin != null and _plugin.has_method("get_server_status")
|
|
else {}
|
|
)
|
|
var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED))
|
|
if ServerStateScript.blocks_client_health(state):
|
|
connected = false
|
|
|
|
## One `match`/`elif` chain, one source of truth. Adding a new
|
|
## spawn outcome = one `ServerStateScript` constant + one arm here +
|
|
## one body string in `_crash_body_for_state`.
|
|
var status_text: String
|
|
var status_color: Color
|
|
if _server_restart_in_progress:
|
|
status_text = "Restarting server..."
|
|
status_color = COLOR_AMBER
|
|
elif connected:
|
|
status_text = "Connected"
|
|
status_color = Color.GREEN
|
|
elif state == ServerStateScript.CRASHED:
|
|
var exit_ms: int = server_status.get("exit_ms", 0)
|
|
status_text = "Server exited after %.1fs" % (exit_ms / 1000.0)
|
|
status_color = Color.RED
|
|
elif state == ServerStateScript.PORT_EXCLUDED:
|
|
status_text = "Port %d reserved by Windows" % ClientConfigurator.http_port()
|
|
status_color = Color.RED
|
|
elif state == ServerStateScript.INCOMPATIBLE:
|
|
status_text = "Incompatible server on port %d" % ClientConfigurator.http_port()
|
|
status_color = Color.RED
|
|
elif state == ServerStateScript.FOREIGN_PORT:
|
|
status_text = "Port %d held by another process" % ClientConfigurator.http_port()
|
|
status_color = Color.RED
|
|
elif state == ServerStateScript.NO_COMMAND:
|
|
status_text = "No server command found"
|
|
status_color = Color.RED
|
|
elif Time.get_ticks_msec() < _startup_grace_until_msec:
|
|
## Inside startup grace — distinguish from real disconnect so
|
|
## first-run users don't assume it's broken while uvx downloads.
|
|
status_text = "Starting server…"
|
|
status_color = COLOR_AMBER
|
|
else:
|
|
status_text = "Disconnected"
|
|
status_color = Color.RED
|
|
|
|
_update_crash_panel(server_status)
|
|
_refresh_server_version_label(server_status)
|
|
|
|
var changed: bool = connected != _last_connected or status_text != _last_status_text
|
|
if not changed:
|
|
return
|
|
_last_connected = connected
|
|
_last_status_text = status_text
|
|
_status_icon.color = status_color
|
|
_status_label.text = status_text
|
|
|
|
_update_dev_section_buttons()
|
|
|
|
|
|
## Render the diagnostic panel body for a given spawn state. The top
|
|
## status label already names the problem; this answers "what do I do?".
|
|
## Panel shows for any non-OK state; picker shows only when moving the HTTP
|
|
## port alone is a valid recovery. Incompatible godot-ai servers commonly
|
|
## hold both HTTP and WS ports, so their message points to Editor Settings
|
|
## instead of offering the HTTP-only quick picker.
|
|
func _update_crash_panel(server_status: Dictionary) -> void:
|
|
var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED))
|
|
if not ServerStateScript.is_terminal_diagnosis(state):
|
|
if _crash_panel.visible:
|
|
_crash_panel.visible = false
|
|
_last_server_status = {}
|
|
return
|
|
if server_status == _last_server_status:
|
|
return
|
|
_last_server_status = server_status.duplicate()
|
|
_crash_panel.visible = true
|
|
_crash_output.clear()
|
|
_crash_output.add_text(_crash_body_for_state(state, server_status))
|
|
var show_recovery_restart := (
|
|
state == ServerStateScript.INCOMPATIBLE
|
|
and bool(server_status.get("can_recover_incompatible", false))
|
|
)
|
|
if _crash_restart_btn != null:
|
|
_crash_restart_btn.visible = show_recovery_restart
|
|
_crash_restart_btn.disabled = _server_restart_in_progress
|
|
_crash_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart Server"
|
|
if _crash_reload_btn != null:
|
|
_crash_reload_btn.visible = (
|
|
not show_recovery_restart
|
|
and state != ServerStateScript.INCOMPATIBLE
|
|
)
|
|
|
|
var port_picker_visible := (
|
|
state == ServerStateScript.PORT_EXCLUDED
|
|
or state == ServerStateScript.FOREIGN_PORT
|
|
)
|
|
_port_picker_panel.visible = port_picker_visible
|
|
if port_picker_visible:
|
|
## Seed the spinbox with a suggested non-reserved port each time the
|
|
## panel surfaces. Idempotent when the user already has a good
|
|
## candidate queued up.
|
|
_port_picker_panel.seed_suggested_port()
|
|
|
|
|
|
static func _crash_body_for_state(state: int, server_status: Dictionary = {}) -> String:
|
|
## Single sentence per state. The top status label already names the
|
|
## problem; don't repeat it here. This copy answers "what do I do?".
|
|
var port := ClientConfigurator.http_port()
|
|
match state:
|
|
ServerStateScript.PORT_EXCLUDED:
|
|
return "Windows (Hyper-V / WSL2 / Docker) reserved port %d. Pick a free port or try `net stop winnat; net start winnat` in an admin shell." % port
|
|
ServerStateScript.INCOMPATIBLE:
|
|
var message := str(server_status.get("message", ""))
|
|
if bool(server_status.get("can_recover_incompatible", false)):
|
|
var expected := str(server_status.get("expected_version", ""))
|
|
if expected.is_empty():
|
|
expected = ClientConfigurator.get_plugin_version()
|
|
if not message.is_empty():
|
|
return "%s Click Restart Server below to replace it with godot-ai v%s." % [message, expected]
|
|
return "Port %d is occupied by an older godot-ai server. Click Restart Server below to replace it with godot-ai v%s." % [port, expected]
|
|
if not message.is_empty():
|
|
return message
|
|
return "Port %d is occupied by an incompatible server. Stop it or change both HTTP and WS ports." % port
|
|
ServerStateScript.FOREIGN_PORT:
|
|
return "Another process is already bound to port %d. Pick a free port or stop the other process." % port
|
|
ServerStateScript.CRASHED:
|
|
## Both spawn attempts failed on the uvx tier — almost always
|
|
## means PyPI hasn't propagated this version yet (~10 min after
|
|
## publish). `_start_server` already tried `--refresh` once, so
|
|
## the next realistic move is to wait and reload.
|
|
if ClientConfigurator.get_server_launch_mode() == "uvx":
|
|
var version := ClientConfigurator.get_plugin_version()
|
|
return "The server exited before the WebSocket handshake, even after a `uvx --refresh` retry. If this is a brand-new release, PyPI's index may still be propagating (~10 min). Wait a moment and click Reload Plugin to retry, or check Godot's output log for Python's traceback. Target: godot-ai==%s." % version
|
|
return "The server exited before the WebSocket handshake. Check Godot's output log (bottom panel) for Python's traceback."
|
|
ServerStateScript.NO_COMMAND:
|
|
return "No godot-ai server found. Install `uv` via the Setup panel above, or run `pip install godot-ai`."
|
|
_:
|
|
return ""
|
|
|
|
|
|
## Build the mixed-state banner. Hidden until `_refresh_mixed_state_banner`
|
|
## confirms `*.update_backup` files exist in the addons tree. Mirrors the
|
|
## issue #354 fix shape: structured, agent-readable diagnostic that survives
|
|
## a normal editor restart so the user can act on it instead of re-running
|
|
## the update.
|
|
func _build_mixed_state_banner() -> void:
|
|
_mixed_state_banner = VBoxContainer.new()
|
|
_mixed_state_banner.add_theme_constant_override("separation", 4)
|
|
_mixed_state_banner.visible = false
|
|
|
|
_mixed_state_label = Label.new()
|
|
_mixed_state_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
_mixed_state_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_mixed_state_label.add_theme_color_override("font_color", Color.RED)
|
|
_mixed_state_banner.add_child(_mixed_state_label)
|
|
|
|
_mixed_state_files = RichTextLabel.new()
|
|
_mixed_state_files.bbcode_enabled = false
|
|
_mixed_state_files.fit_content = true
|
|
_mixed_state_files.autowrap_mode = TextServer.AUTOWRAP_OFF
|
|
_mixed_state_files.selection_enabled = true
|
|
_mixed_state_files.scroll_active = true
|
|
_mixed_state_files.custom_minimum_size = Vector2(0, 90)
|
|
_mixed_state_files.add_theme_color_override("default_color", COLOR_AMBER)
|
|
_mixed_state_banner.add_child(_mixed_state_files)
|
|
|
|
_mixed_state_rescan_btn = Button.new()
|
|
_mixed_state_rescan_btn.text = "Re-scan"
|
|
_mixed_state_rescan_btn.tooltip_text = (
|
|
"Scan addons/godot_ai/ for *.update_backup files again."
|
|
+ " Click after restoring the addon manually to dismiss this banner."
|
|
)
|
|
_mixed_state_rescan_btn.pressed.connect(func(): _refresh_mixed_state_banner(true))
|
|
_mixed_state_banner.add_child(_mixed_state_rescan_btn)
|
|
|
|
_mixed_state_banner.add_child(HSeparator.new())
|
|
add_child(_mixed_state_banner)
|
|
|
|
|
|
func _refresh_mixed_state_banner(force: bool = false) -> void:
|
|
## Re-scan button passes `force=true` to bypass the scanner's TTL
|
|
## cache so a manual fix is reflected immediately.
|
|
_apply_mixed_state_banner_diagnostic(UpdateMixedStateScript.diagnose(
|
|
UpdateMixedStateScript.ADDON_DIR, force
|
|
))
|
|
|
|
|
|
## Render seam exposed for testing — the GDScript test suite drives this
|
|
## directly with synthetic diagnostics so dock banner contracts can be
|
|
## pinned without polluting the real `addons/godot_ai/` tree with backup
|
|
## files. Callers from production go through `_refresh_mixed_state_banner`.
|
|
func _apply_mixed_state_banner_diagnostic(diag: Dictionary) -> void:
|
|
if _mixed_state_banner == null:
|
|
return
|
|
if diag.is_empty():
|
|
_mixed_state_banner.visible = false
|
|
return
|
|
_mixed_state_banner.visible = true
|
|
## `Dictionary.get(...)` returns Variant; Label.text is typed String.
|
|
## Explicit cast keeps the type contract honest and dodges some Godot
|
|
## 4.x point-release quirks around Variant→typed-property assignment.
|
|
_mixed_state_label.text = String(diag.get("message", ""))
|
|
_mixed_state_files.clear()
|
|
for path in diag.get("backup_files", []):
|
|
_mixed_state_files.add_text(String(path))
|
|
_mixed_state_files.newline()
|
|
if bool(diag.get("truncated", false)):
|
|
_mixed_state_files.add_text(
|
|
"… (list truncated at %d entries)" % UpdateMixedStateScript.MAX_BACKUP_RESULTS
|
|
)
|
|
_mixed_state_files.newline()
|
|
|
|
|
|
## Signal handler for the extracted LogViewer — the panel owns its own
|
|
## display visibility, the dock owns dispatcher logging routing.
|
|
func _on_log_logging_enabled_changed(enabled: bool) -> void:
|
|
if _connection and _connection.dispatcher:
|
|
_connection.dispatcher.mcp_logging = enabled
|
|
|
|
|
|
## Signal handler for the extracted PortPickerPanel — the panel range-validates
|
|
## the spinbox value before emitting, so we just write the EditorSetting and
|
|
## reload the plugin here.
|
|
func _on_port_apply_requested(new_port: int) -> void:
|
|
var es := EditorInterface.get_editor_settings()
|
|
if es != null:
|
|
es.set_setting(McpSettings.SETTING_HTTP_PORT, new_port)
|
|
## Every saved client config now points at the old port. Re-sweep so the
|
|
## drift banner appears in the same frame the user committed the change —
|
|
## the plugin reload below will run a second sweep on its own first paint,
|
|
## but we want the banner up immediately rather than after the reload
|
|
## handshake races to completion. See #166.
|
|
_refresh_all_client_statuses()
|
|
## Reload after the setting is committed so `_start_server` reads the new
|
|
## port on the re-enabled plugin instance.
|
|
_on_reload_plugin()
|
|
|
|
|
|
func _refresh_server_label() -> void:
|
|
if _server_label == null:
|
|
return
|
|
var ws_port := ClientConfigurator.ws_port()
|
|
if _plugin != null and _plugin.has_method("get_resolved_ws_port"):
|
|
ws_port = int(_plugin.get_resolved_ws_port())
|
|
_server_label.text = "WS: %d HTTP: %d" % [ws_port, ClientConfigurator.http_port()]
|
|
|
|
|
|
# --- Telemetry setting persistence ---
|
|
|
|
|
|
## Returns true if GODOT_AI_DISABLE_TELEMETRY or DISABLE_TELEMETRY is set
|
|
## to a truthy value, false if either is set and non-truthy, null if neither
|
|
## env var is present at all.
|
|
func _is_telemetry_disabled_via_env() -> Variant:
|
|
if not (OS.has_environment("GODOT_AI_DISABLE_TELEMETRY") or OS.has_environment("DISABLE_TELEMETRY")):
|
|
return null
|
|
return McpSettings.env_truthy("GODOT_AI_DISABLE_TELEMETRY") or McpSettings.env_truthy("DISABLE_TELEMETRY")
|
|
|
|
|
|
## Reads the telemetry preference, applying env-var override when present.
|
|
## Initialises _telemetry_pending_enabled / _telemetry_saved_enabled and
|
|
## sets the checkbox state + locked tooltip. Call after _telemetry_toggle
|
|
## has been created.
|
|
func _load_telemetry_setting() -> void:
|
|
var es := EditorInterface.get_editor_settings()
|
|
var env_disabled = _is_telemetry_disabled_via_env()
|
|
|
|
var enabled: bool
|
|
if env_disabled != null:
|
|
## Env var present: resolve and save to EditorSettings so future sessions without
|
|
## the env var honour the last-set value.
|
|
enabled = not bool(env_disabled)
|
|
if es != null:
|
|
es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, enabled)
|
|
else:
|
|
## No env var: read (or create) the EditorSettings key.
|
|
if es != null and es.has_setting(McpSettings.SETTING_TELEMETRY_ENABLED):
|
|
enabled = bool(es.get_setting(McpSettings.SETTING_TELEMETRY_ENABLED))
|
|
else:
|
|
enabled = true
|
|
if es != null:
|
|
es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, true)
|
|
|
|
_telemetry_pending_enabled = enabled
|
|
_telemetry_saved_enabled = enabled
|
|
|
|
if _telemetry_toggle == null:
|
|
return
|
|
_telemetry_toggle.set_pressed_no_signal(enabled)
|
|
if env_disabled != null:
|
|
_telemetry_toggle.disabled = true
|
|
_telemetry_toggle.tooltip_text = (
|
|
"Telemetry is controlled by an environment variable "
|
|
+ "(GODOT_AI_DISABLE_TELEMETRY / DISABLE_TELEMETRY)."
|
|
)
|
|
else:
|
|
_telemetry_toggle.disabled = false
|
|
_telemetry_toggle.tooltip_text = ""
|
|
|
|
|
|
func _on_telemetry_toggled(pressed: bool) -> void:
|
|
_telemetry_pending_enabled = pressed
|
|
_refresh_tools_ui_state()
|
|
|
|
|
|
# --- Dev mode persistence ---
|
|
|
|
|
|
func _load_dev_mode() -> bool:
|
|
# Default OFF for every install (including dev checkouts). Contributors
|
|
# who want the extra diagnostic UI (Reload Plugin, MCP log
|
|
# panel, Start/Stop Dev Server) can flip the toggle once — editor
|
|
# settings persist across sessions.
|
|
var es := EditorInterface.get_editor_settings()
|
|
if es == null:
|
|
return false
|
|
if not es.has_setting(DEV_MODE_SETTING):
|
|
es.set_setting(DEV_MODE_SETTING, false)
|
|
return false
|
|
return bool(es.get_setting(DEV_MODE_SETTING))
|
|
|
|
|
|
func _on_dev_mode_toggled(enabled: bool) -> void:
|
|
var es := EditorInterface.get_editor_settings()
|
|
if es != null:
|
|
es.set_setting(DEV_MODE_SETTING, enabled)
|
|
_apply_dev_mode_visibility()
|
|
_refresh_setup_status()
|
|
|
|
|
|
func _apply_dev_mode_visibility() -> void:
|
|
var dev := _dev_mode_toggle.button_pressed
|
|
_dev_section.visible = dev
|
|
if _log_viewer != null:
|
|
_log_viewer.visible = dev
|
|
|
|
# Setup section: visible in dev mode, OR in user mode when uv is missing
|
|
# (so users can install uv from the dock).
|
|
var is_dev := ClientConfigurator.is_dev_checkout()
|
|
var uv_missing := not is_dev and ClientConfigurator.check_uv_version().is_empty()
|
|
_setup_section.visible = dev or uv_missing
|
|
|
|
|
|
# --- Button handlers ---
|
|
|
|
|
|
func _do_plugin_reload() -> void:
|
|
EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", false)
|
|
EditorInterface.set_plugin_enabled("res://addons/godot_ai/plugin.cfg", true)
|
|
|
|
|
|
func _on_reload_plugin() -> void:
|
|
# Persist a pending plugin_reload telemetry event *before* the
|
|
# disable kills the live WebSocket — the new plugin's _enter_tree
|
|
# flushes it via `_telemetry.flush_pending_plugin_reload()`.
|
|
Telemetry.record_pending_plugin_reload("dock_button")
|
|
# Defer the toggle so any in-flight input event finishes propagating
|
|
# before the dock (and its Window children) leave the tree. Calling
|
|
# set_plugin_enabled synchronously from a button press frees the
|
|
# viewport mid-dispatch.
|
|
_do_plugin_reload.call_deferred()
|
|
|
|
|
|
## Setup-section "Server" row: always report the TRUE running server
|
|
## version (from the handshake_ack) rather than the plugin's expected
|
|
## version, and highlight the mismatch so self-update drift is visible
|
|
## at a glance instead of silently masked by a green label.
|
|
##
|
|
## Render states, keyed off live version metadata:
|
|
## - empty (pre-ack): show the expected version only as an unverified target
|
|
## - matches plugin: show it green, no Restart button
|
|
## - dev mismatch: show amber with an explicit dev marker
|
|
## - release mismatch: show actual vs expected; only surface Restart when the
|
|
## plugin has ownership proof for the process
|
|
func _refresh_server_version_label(server_status: Dictionary = {}) -> void:
|
|
if _setup_server_label == null:
|
|
return
|
|
var plugin_ver := ClientConfigurator.get_plugin_version()
|
|
if server_status.is_empty():
|
|
## Re-fetch only when called outside `_update_status`'s frame
|
|
## (e.g. from `_apply_new_port`, `_on_restart_*`). Inside the
|
|
## per-frame loop, the caller threads its cached snapshot through
|
|
## so we don't allocate a fresh Dictionary every frame.
|
|
server_status = (
|
|
_plugin.get_server_status()
|
|
if _plugin != null and _plugin.has_method("get_server_status")
|
|
else {}
|
|
)
|
|
var server_ver: String = _connection.server_version if _connection != null else ""
|
|
if server_ver.is_empty():
|
|
server_ver = str(server_status.get("actual_version", ""))
|
|
var expected_ver := str(server_status.get("expected_version", ""))
|
|
if expected_ver.is_empty():
|
|
expected_ver = plugin_ver
|
|
var state: int = int(server_status.get("state", ServerStateScript.UNINITIALIZED))
|
|
if _server_restart_in_progress and (
|
|
server_ver == expected_ver
|
|
or (
|
|
ServerStateScript.is_terminal_diagnosis(state)
|
|
and state != ServerStateScript.INCOMPATIBLE
|
|
)
|
|
):
|
|
_server_restart_in_progress = false
|
|
var text: String
|
|
var color: Color
|
|
var show_restart := false
|
|
if _server_restart_in_progress:
|
|
text = "restarting server..."
|
|
color = COLOR_AMBER
|
|
show_restart = true
|
|
elif server_ver.is_empty():
|
|
text = "checking live version (expected godot-ai == %s)" % expected_ver
|
|
color = COLOR_MUTED
|
|
elif server_ver == expected_ver:
|
|
text = "godot-ai == %s" % server_ver
|
|
color = Color.GREEN
|
|
else:
|
|
text = "godot-ai == %s (expected %s)" % [server_ver, expected_ver]
|
|
var is_incompatible: bool = state == ServerStateScript.INCOMPATIBLE
|
|
color = Color.RED if is_incompatible else COLOR_AMBER
|
|
var has_managed_proof: bool = (
|
|
_plugin != null
|
|
and _plugin.has_method("can_restart_managed_server")
|
|
and _plugin.can_restart_managed_server()
|
|
)
|
|
var can_recover: bool = bool(server_status.get("can_recover_incompatible", false))
|
|
show_restart = (
|
|
(not is_incompatible and has_managed_proof)
|
|
## Recoverable incompatible servers get the primary action in
|
|
## the top error panel. Duplicating it in Setup made the UI
|
|
## look like it had multiple restart paths.
|
|
or (is_incompatible and can_recover and _crash_restart_btn == null)
|
|
)
|
|
if text == _last_rendered_server_text:
|
|
_setup_server_label.add_theme_color_override("font_color", color)
|
|
_update_restart_button(show_restart)
|
|
return
|
|
_last_rendered_server_text = text
|
|
_setup_server_label.text = text
|
|
_setup_server_label.add_theme_color_override("font_color", color)
|
|
_update_restart_button(show_restart)
|
|
|
|
|
|
func _update_restart_button(visible: bool) -> void:
|
|
if _version_restart_btn != null:
|
|
_version_restart_btn.visible = visible
|
|
_version_restart_btn.disabled = _server_restart_in_progress
|
|
_version_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart"
|
|
if _crash_restart_btn != null:
|
|
_crash_restart_btn.disabled = _server_restart_in_progress
|
|
_crash_restart_btn.text = "Restarting..." if _server_restart_in_progress else "Restart Server"
|
|
|
|
|
|
func _on_restart_stale_server() -> void:
|
|
if _plugin == null or _server_restart_in_progress:
|
|
return
|
|
_server_restart_in_progress = true
|
|
_last_rendered_server_text = ""
|
|
_refresh_server_version_label()
|
|
if not is_inside_tree():
|
|
_dispatch_stale_server_restart()
|
|
_server_restart_in_progress = false
|
|
_last_rendered_server_text = ""
|
|
_refresh_server_version_label()
|
|
return
|
|
call_deferred("_restart_stale_server_after_feedback")
|
|
|
|
|
|
func _restart_stale_server_after_feedback() -> void:
|
|
await get_tree().create_timer(0.15).timeout
|
|
if not _dispatch_stale_server_restart():
|
|
_server_restart_in_progress = false
|
|
_last_rendered_server_text = ""
|
|
_refresh_server_version_label()
|
|
|
|
|
|
func _dispatch_stale_server_restart() -> bool:
|
|
if _plugin == null:
|
|
return false
|
|
var status: Dictionary = (
|
|
_plugin.get_server_status()
|
|
if _plugin.has_method("get_server_status")
|
|
else {}
|
|
)
|
|
if int(status.get("state", ServerStateScript.UNINITIALIZED)) == ServerStateScript.INCOMPATIBLE:
|
|
if _plugin.has_method("recover_incompatible_server"):
|
|
return bool(_plugin.recover_incompatible_server())
|
|
elif _plugin.has_method("force_restart_server"):
|
|
_plugin.force_restart_server()
|
|
return true
|
|
return false
|
|
|
|
|
|
# --- Setup section ---
|
|
|
|
func _refresh_setup_status() -> void:
|
|
if _setup_container == null:
|
|
return
|
|
for child in _setup_container.get_children():
|
|
child.queue_free()
|
|
_dev_primary_btn = null
|
|
_dev_stop_btn = null
|
|
|
|
var is_dev := ClientConfigurator.is_dev_checkout()
|
|
if is_dev:
|
|
_setup_container.add_child(_make_status_row("Mode", "Dev (venv)", Color.CYAN))
|
|
|
|
var btn_row := HBoxContainer.new()
|
|
btn_row.add_theme_constant_override("separation", 4)
|
|
btn_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
|
|
_dev_primary_btn = Button.new()
|
|
_dev_primary_btn.text = "Restart Dev Server"
|
|
_dev_primary_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_dev_primary_btn.pressed.connect(_on_dev_primary_pressed)
|
|
btn_row.add_child(_dev_primary_btn)
|
|
|
|
_dev_stop_btn = Button.new()
|
|
_dev_stop_btn.text = "✕"
|
|
_dev_stop_btn.tooltip_text = "Stop the dev server without spawning a replacement."
|
|
_dev_stop_btn.pressed.connect(_on_dev_stop_pressed)
|
|
btn_row.add_child(_dev_stop_btn)
|
|
|
|
_setup_container.add_child(btn_row)
|
|
_update_dev_section_buttons()
|
|
return
|
|
|
|
# User mode — check for uv
|
|
var uv_version := ClientConfigurator.check_uv_version()
|
|
if not uv_version.is_empty():
|
|
_setup_container.add_child(_make_status_row("uv", uv_version, Color.GREEN))
|
|
## Build the Server row with a placeholder label we can update every
|
|
## frame. `_refresh_server_version_label` replaces the text + color
|
|
## once `McpConnection.server_version` lands via `handshake_ack`, and
|
|
## flips to amber + "(plugin X)" on drift. Pre-ack we show the
|
|
## plugin's expected version so the row isn't blank.
|
|
var server_row := HBoxContainer.new()
|
|
server_row.add_theme_constant_override("separation", 8)
|
|
var key_label := Label.new()
|
|
key_label.text = "Server"
|
|
key_label.add_theme_color_override("font_color", COLOR_MUTED)
|
|
key_label.custom_minimum_size = Vector2(60, 0)
|
|
server_row.add_child(key_label)
|
|
_setup_server_label = Label.new()
|
|
_setup_server_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
server_row.add_child(_setup_server_label)
|
|
_version_restart_btn = Button.new()
|
|
_version_restart_btn.text = "Restart"
|
|
_version_restart_btn.tooltip_text = "Kill the server on port %d and respawn with the plugin's bundled version" % ClientConfigurator.http_port()
|
|
_version_restart_btn.pressed.connect(_on_restart_stale_server)
|
|
_version_restart_btn.visible = false
|
|
server_row.add_child(_version_restart_btn)
|
|
_setup_container.add_child(server_row)
|
|
_last_rendered_server_text = ""
|
|
_refresh_server_version_label()
|
|
else:
|
|
_setup_container.add_child(_make_status_row("uv", "not found", Color.RED))
|
|
var install_btn := Button.new()
|
|
install_btn.text = "Install uv"
|
|
install_btn.pressed.connect(_on_install_uv)
|
|
_setup_container.add_child(install_btn)
|
|
|
|
|
|
func _install_mode_text() -> String:
|
|
if ClientConfigurator.is_dev_checkout():
|
|
return "Install: dev checkout — update via git pull"
|
|
return "Install: v%s" % ClientConfigurator.get_plugin_version()
|
|
|
|
|
|
func _install_mode_tooltip() -> String:
|
|
if not ClientConfigurator.is_dev_checkout():
|
|
return "Plugin installed from a release ZIP, Asset Library, or source copy. Update button in this dock downloads the latest GitHub release."
|
|
var target := _resolve_plugin_symlink_target()
|
|
if target.is_empty():
|
|
return "Plugin source tree resolved via local .venv — press Reload Plugin after editing."
|
|
return "Plugin source: %s\nPress Reload Plugin after editing." % target
|
|
|
|
|
|
func _resolve_plugin_symlink_target() -> String:
|
|
var addons_path := ProjectSettings.globalize_path("res://addons/godot_ai")
|
|
var dir := DirAccess.open(addons_path.get_base_dir())
|
|
if dir == null or not dir.is_link(addons_path):
|
|
return ""
|
|
var target := dir.read_link(addons_path)
|
|
if target.is_empty():
|
|
return ""
|
|
if target.is_relative_path():
|
|
target = addons_path.get_base_dir().path_join(target).simplify_path()
|
|
return target
|
|
|
|
|
|
func _make_status_row(label_text: String, value_text: String, value_color: Color) -> HBoxContainer:
|
|
var row := HBoxContainer.new()
|
|
row.add_theme_constant_override("separation", 6)
|
|
|
|
var label := Label.new()
|
|
label.text = label_text
|
|
label.add_theme_color_override("font_color", COLOR_MUTED)
|
|
label.custom_minimum_size.x = 60
|
|
row.add_child(label)
|
|
|
|
var value := Label.new()
|
|
value.text = value_text
|
|
value.add_theme_color_override("font_color", value_color)
|
|
row.add_child(value)
|
|
|
|
return row
|
|
|
|
|
|
## Pure helper for the primary "Restart Dev Server" button. Always enabled
|
|
## (clicking with nothing running just spawns fresh); tooltip adapts to
|
|
## whether a kill+respawn or fresh spawn is what'll happen.
|
|
static func _dev_primary_btn_state(has_managed: bool, dev_running: bool) -> Dictionary:
|
|
var port := ClientConfigurator.http_port()
|
|
if has_managed or dev_running:
|
|
return {
|
|
"text": "Restart Dev Server",
|
|
"tooltip": (
|
|
"Kill the server on port %d and start a fresh --reload dev server. "
|
|
+ "Use this to pick up Python source changes that don't bump the version."
|
|
) % port,
|
|
}
|
|
return {
|
|
"text": "Start Dev Server",
|
|
"tooltip": "Spawn a --reload dev server on port %d. Auto-restarts when you edit Python sources." % port,
|
|
}
|
|
|
|
|
|
## Pure helper for the small "✕" stop button — only enabled when a dev
|
|
## server is actually running. Stops without respawning; intentionally
|
|
## never targets a managed server (that's the lifecycle's responsibility).
|
|
static func _dev_stop_btn_state(dev_running: bool) -> Dictionary:
|
|
if dev_running:
|
|
return {"enabled": true, "tooltip": "Stop the dev server without spawning a replacement."}
|
|
return {"enabled": false, "tooltip": "No --reload dev server to stop."}
|
|
|
|
|
|
func _on_dev_primary_pressed() -> void:
|
|
if _plugin == null or _server_restart_in_progress:
|
|
return
|
|
if not _plugin.has_method("force_restart_or_start_dev_server"):
|
|
return
|
|
if _plugin.has_method("record_dev_server_toggle"):
|
|
_plugin.record_dev_server_toggle("start")
|
|
_server_restart_in_progress = true
|
|
_update_dev_section_buttons()
|
|
if not is_inside_tree():
|
|
## Test path — no scene tree means no timer; run synchronously
|
|
## so suite assertions see the dispatch without `await`.
|
|
_plugin.force_restart_or_start_dev_server()
|
|
_server_restart_in_progress = false
|
|
return
|
|
call_deferred("_perform_dev_restart_after_feedback")
|
|
|
|
|
|
func _on_dev_stop_pressed() -> void:
|
|
if _plugin == null:
|
|
return
|
|
if _plugin.has_method("stop_dev_server"):
|
|
_plugin.stop_dev_server()
|
|
if _plugin.has_method("record_dev_server_toggle"):
|
|
_plugin.record_dev_server_toggle("stop")
|
|
_update_dev_section_buttons.call_deferred()
|
|
|
|
|
|
func _perform_dev_restart_after_feedback() -> void:
|
|
## Brief paint cycle so the user sees "Restarting..." before the
|
|
## blocking _wait_for_port_free freezes the editor for up to 5s.
|
|
await get_tree().create_timer(0.15).timeout
|
|
## Re-check has_method post-await — a self-update mixed-state window
|
|
## could swap _plugin's script class while we were sleeping, leaving
|
|
## the old reference pointing at a class that no longer carries the
|
|
## new method. Same #168 guard pattern as _update_dev_section_buttons.
|
|
if _plugin != null and _plugin.has_method("force_restart_or_start_dev_server"):
|
|
_plugin.force_restart_or_start_dev_server()
|
|
## start_dev_server's spawn happens via a 0.5s SceneTree timer; give
|
|
## it time to land plus a buffer for the WS reconnect before clearing
|
|
## the busy state. The unconditional clear matches sibling restart
|
|
## buttons — overshoot is fine because subsequent _update_status calls
|
|
## refresh the button against live plugin state.
|
|
await get_tree().create_timer(2.0).timeout
|
|
_server_restart_in_progress = false
|
|
_update_dev_section_buttons()
|
|
|
|
|
|
## Single-scan refresh of every dev-section button state. Both buttons
|
|
## key off the same `has_managed_server` / `is_dev_server_running` pair,
|
|
## and the latter scrapes lsof/ps — so doing the discovery once and
|
|
## applying to both avoids the duplicate subprocess fork on every
|
|
## connection-state transition.
|
|
func _update_dev_section_buttons() -> void:
|
|
if _plugin == null:
|
|
return
|
|
if not (_plugin.has_method("has_managed_server") and _plugin.has_method("is_dev_server_running")):
|
|
return
|
|
var has_managed: bool = _plugin.has_managed_server()
|
|
var dev_running: bool = _plugin.is_dev_server_running()
|
|
if _dev_primary_btn != null:
|
|
if _server_restart_in_progress:
|
|
_dev_primary_btn.disabled = true
|
|
_dev_primary_btn.text = "Restarting..."
|
|
_dev_primary_btn.tooltip_text = "Killing the current server and respawning..."
|
|
else:
|
|
var primary_state := _dev_primary_btn_state(has_managed, dev_running)
|
|
_dev_primary_btn.disabled = false
|
|
_dev_primary_btn.text = primary_state["text"]
|
|
_dev_primary_btn.tooltip_text = primary_state["tooltip"]
|
|
if _dev_stop_btn != null:
|
|
var stop_state := _dev_stop_btn_state(dev_running)
|
|
_dev_stop_btn.disabled = (not stop_state["enabled"]) or _server_restart_in_progress
|
|
_dev_stop_btn.tooltip_text = stop_state["tooltip"]
|
|
|
|
|
|
func _on_install_uv() -> void:
|
|
match OS.get_name():
|
|
"Windows":
|
|
OS.execute("powershell", ["-ExecutionPolicy", "ByPass", "-c", "irm https://astral.sh/uv/install.ps1 | iex"], [], false)
|
|
_:
|
|
OS.execute("bash", ["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"], [], false)
|
|
## Drop the cached uvx path AND the cached `uvx --version` so the
|
|
## next `_refresh_setup_status` finds and reads the freshly-installed
|
|
## binary instead of returning the pre-install "not found" result.
|
|
## Routing through the configurator here matters on Windows, where
|
|
## the CLI-finder cache key is `uvx.exe` — invalidating just `"uvx"`
|
|
## would leave the cache stale and the dock would keep showing
|
|
## "uv: not found" for the rest of the session.
|
|
ClientConfigurator.invalidate_uvx_cli_cache()
|
|
ClientConfigurator.invalidate_uv_version_cache()
|
|
_refresh_setup_status.call_deferred()
|
|
|
|
|
|
# --- Client section ---
|
|
|
|
func _on_configure_client(client_id: String) -> void:
|
|
if _server_blocks_client_health():
|
|
_apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message())
|
|
_refresh_clients_summary()
|
|
return
|
|
_dispatch_client_action(client_id, "configure")
|
|
|
|
|
|
func _on_remove_client(client_id: String) -> void:
|
|
_dispatch_client_action(client_id, "remove")
|
|
|
|
|
|
## Spawn a worker thread for Configure / Remove so a hung CLI can't lock
|
|
## the editor (issue #239). The action verbs are: "configure" → calls
|
|
## `ClientConfigurator.configure`; "remove" → calls
|
|
## `ClientConfigurator.remove`. Both routes shell out to the per-client
|
|
## CLI via `McpCliExec.run`, which is wall-clock-bounded.
|
|
##
|
|
## Per-row in-flight rules:
|
|
## - One worker at a time per client (the row's slot).
|
|
## - Both buttons disabled while the slot is busy — prevents a
|
|
## double-click queueing a stale Configure on top of a still-running
|
|
## Remove.
|
|
## - The dot turns amber and the row label gets a "Configuring…" /
|
|
## "Removing…" suffix so the user can see the click was registered.
|
|
func _dispatch_client_action(client_id: String, action: String) -> void:
|
|
if _is_self_update_in_progress():
|
|
## Same gate as the refresh worker — the install window overwrites
|
|
## plugin scripts on disk, and a worker mid-call into them would
|
|
## SIGABRT in `GDScriptFunction::call`. See `_update_manager`.
|
|
return
|
|
if _client_action_threads.has(client_id):
|
|
return
|
|
var row: Dictionary = _client_rows.get(client_id, {})
|
|
if row.is_empty():
|
|
return
|
|
|
|
_set_row_action_in_flight(client_id, action)
|
|
## Snapshot `server_url` on main: `http_url()` reads
|
|
## `EditorInterface.get_editor_settings()`, which is main-thread-only.
|
|
## The status-refresh worker uses the same pattern — see
|
|
## `_perform_initial_client_status_refresh` and
|
|
## `_request_client_status_refresh`.
|
|
var server_url := ClientConfigurator.http_url()
|
|
var generation := int(_client_action_generations.get(client_id, 0)) + 1
|
|
_client_action_generations[client_id] = generation
|
|
var thread := Thread.new()
|
|
_client_action_threads[client_id] = thread
|
|
var err := thread.start(
|
|
Callable(self, "_run_client_action_worker").bind(client_id, action, server_url, generation)
|
|
)
|
|
if err != OK:
|
|
_client_action_threads.erase(client_id)
|
|
_finalize_action_buttons(client_id)
|
|
_apply_row_status(client_id, Client.Status.ERROR, "couldn't start worker thread")
|
|
_refresh_clients_summary()
|
|
|
|
|
|
func _run_client_action_worker(client_id: String, action: String, server_url: String, generation: int) -> void:
|
|
var result: Dictionary
|
|
if action == "remove":
|
|
result = ClientConfigurator.remove(client_id, server_url)
|
|
else:
|
|
result = ClientConfigurator.configure(client_id, server_url)
|
|
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
|
|
call_deferred("_apply_client_action_result", client_id, action, result, generation)
|
|
|
|
|
|
func _apply_client_action_result(client_id: String, action: String, result: Dictionary, generation: int) -> void:
|
|
if int(_client_action_generations.get(client_id, 0)) != generation:
|
|
return
|
|
if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN:
|
|
return
|
|
if _client_action_threads.has(client_id):
|
|
var t: Thread = _client_action_threads[client_id]
|
|
if t != null:
|
|
t.wait_to_finish()
|
|
_client_action_threads.erase(client_id)
|
|
_finalize_action_buttons(client_id)
|
|
if _server_blocks_client_health():
|
|
_apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message())
|
|
_refresh_clients_summary()
|
|
return
|
|
|
|
var success_status := Client.Status.NOT_CONFIGURED if action == "remove" else Client.Status.CONFIGURED
|
|
if result.get("status") == "ok":
|
|
_apply_row_status(client_id, success_status)
|
|
var row: Dictionary = _client_rows.get(client_id, {})
|
|
if not row.is_empty():
|
|
(row["manual_panel"] as VBoxContainer).visible = false
|
|
else:
|
|
_apply_row_status(client_id, Client.Status.ERROR, str(result.get("message", "failed")))
|
|
if action == "configure":
|
|
_show_manual_command_for(client_id)
|
|
_refresh_clients_summary()
|
|
|
|
|
|
## In-flight visual: rewrite the verb onto the button the user just
|
|
## clicked ("Configuring…" / "Removing…") so the feedback lands where
|
|
## their attention already is. Don't pollute the row label — that'd
|
|
## clobber any drift hint ("URL out of date") still relevant to the row.
|
|
## The dot turns amber so the row reads as "busy" at a glance, not as
|
|
## green (premature success) or red (premature failure). Both buttons
|
|
## go disabled so a double-click or second action can't queue stale
|
|
## work behind the in-flight worker.
|
|
func _set_row_action_in_flight(client_id: String, action: String) -> void:
|
|
var row: Dictionary = _client_rows.get(client_id, {})
|
|
if row.is_empty():
|
|
return
|
|
var configure_btn: Button = row["configure_btn"]
|
|
var remove_btn: Button = row["remove_btn"]
|
|
configure_btn.disabled = true
|
|
remove_btn.disabled = true
|
|
if action == "remove":
|
|
remove_btn.text = "Removing…"
|
|
else:
|
|
configure_btn.text = "Configuring…"
|
|
(row["dot"] as ColorRect).color = COLOR_AMBER
|
|
|
|
|
|
## Re-enable both buttons and reset their text back to canonical labels.
|
|
## `_apply_row_status` sets `configure_btn.text` per the resulting
|
|
## Status (Configure / Reconfigure / Retry), so we only need to reset
|
|
## `remove_btn.text` here — its sibling visibility toggle already
|
|
## handles whether to show it at all.
|
|
func _finalize_action_buttons(client_id: String) -> void:
|
|
var row: Dictionary = _client_rows.get(client_id, {})
|
|
if row.is_empty():
|
|
return
|
|
(row["configure_btn"] as Button).disabled = false
|
|
var remove_btn: Button = row["remove_btn"]
|
|
remove_btn.disabled = false
|
|
remove_btn.text = "Remove"
|
|
|
|
|
|
func _on_refresh_clients_pressed() -> void:
|
|
_request_client_status_refresh(true)
|
|
|
|
|
|
func _on_configure_all_clients() -> void:
|
|
if _server_blocks_client_health():
|
|
for client_id in _client_rows:
|
|
_apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message())
|
|
_refresh_clients_summary()
|
|
return
|
|
if ClientRefreshStateScript.has_worker_alive(_refresh_state):
|
|
return
|
|
for client_id in _client_rows:
|
|
var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED)
|
|
if status == Client.Status.CONFIGURED:
|
|
continue
|
|
_on_configure_client(String(client_id))
|
|
_refresh_clients_summary()
|
|
|
|
|
|
func _on_open_clients_window() -> void:
|
|
if _clients_window == null:
|
|
return
|
|
## Re-sweep before the user has time to act on stale dot colors. The request
|
|
## is async/stale-while-refreshing so the popup paints immediately with
|
|
## last-known state; the fresh colors land when the background worker returns.
|
|
## This is an explicit user action, so it bypasses the focus-in cooldown.
|
|
_request_client_status_refresh(true)
|
|
## Also re-sync the Tools tab from the persisted setting — another
|
|
## editor instance (or a hand-edit of editor_settings-4.tres) may have
|
|
## changed the excluded list while the window was closed.
|
|
_reset_tools_pending_from_setting()
|
|
_refresh_tools_ui_state()
|
|
# popup_centered() with a minsize forces the window to that size and
|
|
# centers on the parent viewport. Setting .size on a hidden Window
|
|
# doesn't always take effect, so we force it at popup time here.
|
|
_clients_window.popup_centered(Vector2i(640, 600))
|
|
|
|
|
|
func _settings_are_dirty() -> bool:
|
|
return _tools_pending_excluded != _tools_saved_excluded or _telemetry_pending_enabled != _telemetry_saved_enabled
|
|
|
|
|
|
func _on_clients_window_close_requested() -> void:
|
|
if _clients_window == null:
|
|
return
|
|
## If the user has unapplied settings, a close would silently throw the
|
|
## pending state away. Prompt before discarding current options and if
|
|
## they confirm, reset pending → saved so the window shows the persisted
|
|
## state the next time they open it.
|
|
if _settings_are_dirty():
|
|
_show_tools_close_confirm()
|
|
return
|
|
_clients_window.hide()
|
|
|
|
|
|
# --- Tools tab (domain exclusion) ---
|
|
|
|
func _build_tools_tab(tabs: TabContainer) -> void:
|
|
## Tab 2 — domain-exclusion checkboxes. Rendered once, on dock construction.
|
|
## `_reset_tools_pending_from_setting()` re-syncs checkbox state from the
|
|
## saved setting each time the window opens.
|
|
var tools_tab := VBoxContainer.new()
|
|
tools_tab.add_theme_constant_override("separation", 8)
|
|
var tools_margin := _build_margin_container()
|
|
tools_margin.name = "Settings"
|
|
tools_margin.add_child(tools_tab)
|
|
tabs.add_child(tools_margin)
|
|
|
|
var intro := Label.new()
|
|
intro.text = (
|
|
"Some MCP clients cap tools per connection (Antigravity: 100). "
|
|
+ "Uncheck a domain to drop its non-core tools from this server. "
|
|
+ "Core tools stay on. Changes require a server restart."
|
|
)
|
|
intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
intro.add_theme_color_override("font_color", COLOR_MUTED)
|
|
intro.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
tools_tab.add_child(intro)
|
|
|
|
var count_row := HBoxContainer.new()
|
|
count_row.add_theme_constant_override("separation", 8)
|
|
var count_header := Label.new()
|
|
count_header.text = "Tools Enabled:"
|
|
count_header.add_theme_color_override("font_color", COLOR_MUTED)
|
|
count_row.add_child(count_header)
|
|
_tools_count_label = Label.new()
|
|
_tools_count_label.add_theme_font_size_override("font_size", 15)
|
|
count_row.add_child(_tools_count_label)
|
|
_tools_dirty_warning = Label.new()
|
|
_tools_dirty_warning.add_theme_color_override("font_color", COLOR_AMBER)
|
|
_tools_dirty_warning.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_tools_dirty_warning.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
|
_tools_dirty_warning.visible = false
|
|
_tools_dirty_warning.text = "Unapplied changes"
|
|
count_row.add_child(_tools_dirty_warning)
|
|
tools_tab.add_child(count_row)
|
|
|
|
tools_tab.add_child(HSeparator.new())
|
|
|
|
var scroll := ScrollContainer.new()
|
|
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
|
tools_tab.add_child(scroll)
|
|
|
|
var grid := VBoxContainer.new()
|
|
grid.add_theme_constant_override("separation", 4)
|
|
grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
scroll.add_child(grid)
|
|
|
|
## Core pseudo-row — disabled checkbox, always checked. Shows the 5
|
|
## always-loaded tools as a single line item so the user can see where
|
|
## their baseline tool budget goes without listing individual core names
|
|
## inline (tooltip has them).
|
|
var core_row := HBoxContainer.new()
|
|
core_row.add_theme_constant_override("separation", 8)
|
|
var core_chk := CheckBox.new()
|
|
core_chk.button_pressed = true
|
|
core_chk.disabled = true
|
|
core_chk.focus_mode = Control.FOCUS_NONE
|
|
core_row.add_child(core_chk)
|
|
var core_label := Label.new()
|
|
core_label.text = "Core (always on)"
|
|
core_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
core_row.add_child(core_label)
|
|
var core_count := Label.new()
|
|
core_count.text = "%d tools" % ToolCatalog.CORE_TOOLS.size()
|
|
core_count.add_theme_color_override("font_color", COLOR_MUTED)
|
|
core_row.add_child(core_count)
|
|
core_row.tooltip_text = ", ".join(ToolCatalog.CORE_TOOLS)
|
|
grid.add_child(core_row)
|
|
|
|
grid.add_child(HSeparator.new())
|
|
|
|
_tools_domain_checkboxes.clear()
|
|
for entry in ToolCatalog.DOMAINS:
|
|
_build_tools_domain_row(grid, entry)
|
|
|
|
tools_tab.add_child(HSeparator.new())
|
|
|
|
var telemetry_row := HBoxContainer.new()
|
|
telemetry_row.add_theme_constant_override("separation", 8)
|
|
var telemetry_label := Label.new()
|
|
telemetry_label.text = "Telemetry"
|
|
telemetry_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
telemetry_row.add_child(telemetry_label)
|
|
_telemetry_toggle = CheckButton.new()
|
|
_telemetry_toggle.toggled.connect(_on_telemetry_toggled)
|
|
telemetry_row.add_child(_telemetry_toggle)
|
|
tools_tab.add_child(telemetry_row)
|
|
|
|
tools_tab.add_child(HSeparator.new())
|
|
|
|
var footer := HBoxContainer.new()
|
|
footer.add_theme_constant_override("separation", 8)
|
|
|
|
_tools_apply_btn = Button.new()
|
|
_tools_apply_btn.text = "Apply && Restart Server"
|
|
_tools_apply_btn.tooltip_text = "Save the excluded list to Editor Settings and reload the plugin so the server respawns with --exclude-domains."
|
|
_tools_apply_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_tools_apply_btn.pressed.connect(_on_tools_apply)
|
|
footer.add_child(_tools_apply_btn)
|
|
|
|
_tools_reset_btn = Button.new()
|
|
_tools_reset_btn.text = "Reset to defaults"
|
|
_tools_reset_btn.tooltip_text = "Re-enable every domain (no --exclude-domains flag). Still needs Apply."
|
|
_tools_reset_btn.pressed.connect(_on_tools_reset)
|
|
footer.add_child(_tools_reset_btn)
|
|
|
|
tools_tab.add_child(footer)
|
|
|
|
_tools_close_confirm = ConfirmationDialog.new()
|
|
_tools_close_confirm.title = "Discard unapplied changes?"
|
|
_tools_close_confirm.dialog_text = (
|
|
"You've checked/unchecked domains but haven't clicked Apply.\n"
|
|
+ "Close the window and discard those changes?"
|
|
)
|
|
_tools_close_confirm.ok_button_text = "Discard"
|
|
_tools_close_confirm.confirmed.connect(_on_tools_discard_confirmed)
|
|
add_child(_tools_close_confirm)
|
|
|
|
_reset_tools_pending_from_setting()
|
|
_refresh_tools_ui_state()
|
|
|
|
|
|
func _build_tools_domain_row(parent: VBoxContainer, entry: Dictionary) -> void:
|
|
var row := HBoxContainer.new()
|
|
row.add_theme_constant_override("separation", 8)
|
|
|
|
var chk := CheckBox.new()
|
|
chk.button_pressed = true # default; `_reset_tools_pending_from_setting` corrects
|
|
chk.toggled.connect(_on_tools_domain_toggled.bind(String(entry["id"])))
|
|
row.add_child(chk)
|
|
|
|
var name_label := Label.new()
|
|
name_label.text = String(entry["label"])
|
|
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
row.add_child(name_label)
|
|
|
|
var count_label := Label.new()
|
|
count_label.text = "%d tools" % int(entry["count"])
|
|
count_label.add_theme_color_override("font_color", COLOR_MUTED)
|
|
row.add_child(count_label)
|
|
|
|
## Hover tooltip = flat list of tool names in this domain. Lets the
|
|
## user decide without leaving the dock (e.g. "I just want to drop
|
|
## `animation_preset_*` — do I lose anything else?").
|
|
var tools_list: Array = entry.get("tools", [])
|
|
row.tooltip_text = ", ".join(tools_list)
|
|
name_label.tooltip_text = row.tooltip_text
|
|
count_label.tooltip_text = row.tooltip_text
|
|
|
|
parent.add_child(row)
|
|
_tools_domain_checkboxes[String(entry["id"])] = chk
|
|
|
|
|
|
func _reset_tools_pending_from_setting() -> void:
|
|
## Read the saved setting → pending/saved arrays, then sync checkbox state.
|
|
## Unknown domain names in the setting (e.g. from an older plugin
|
|
## version) are silently dropped — matches the Python side's
|
|
## warn-and-continue behavior when it sees an unknown name.
|
|
var saved_raw := ClientConfigurator.excluded_domains()
|
|
var saved := PackedStringArray()
|
|
if not saved_raw.is_empty():
|
|
for part in saved_raw.split(","):
|
|
var t := part.strip_edges()
|
|
if t.is_empty():
|
|
continue
|
|
if _tools_domain_checkboxes.has(t) and saved.find(t) == -1:
|
|
saved.append(t)
|
|
saved.sort()
|
|
_tools_saved_excluded = saved
|
|
_tools_pending_excluded = saved.duplicate()
|
|
for id in _tools_domain_checkboxes:
|
|
var chk: CheckBox = _tools_domain_checkboxes[id]
|
|
## `set_pressed_no_signal` — mutating programmatically should not
|
|
## fire the toggled handler, which would mutate pending back.
|
|
chk.set_pressed_no_signal(_tools_pending_excluded.find(id) == -1)
|
|
## Also reset telemetry pending state from the persisted setting.
|
|
if _telemetry_toggle != null:
|
|
_load_telemetry_setting()
|
|
|
|
|
|
func _on_tools_domain_toggled(pressed: bool, domain_id: String) -> void:
|
|
var idx := _tools_pending_excluded.find(domain_id)
|
|
if pressed and idx != -1:
|
|
_tools_pending_excluded.remove_at(idx)
|
|
elif not pressed and idx == -1:
|
|
_tools_pending_excluded.append(domain_id)
|
|
_tools_pending_excluded.sort()
|
|
_refresh_tools_ui_state()
|
|
|
|
|
|
func _refresh_tools_ui_state() -> void:
|
|
if _tools_count_label == null:
|
|
return
|
|
var enabled := ToolCatalog.enabled_tool_count(_tools_pending_excluded)
|
|
var total := ToolCatalog.total_tool_count()
|
|
_tools_count_label.text = "%d / %d" % [enabled, total]
|
|
var dirty := _settings_are_dirty()
|
|
_tools_dirty_warning.visible = dirty
|
|
_tools_apply_btn.disabled = not dirty
|
|
## Color the count when the user is over Antigravity's cap — a soft
|
|
## signal that their selection still won't fit. 100 is the Antigravity
|
|
## limit; other clients may cap higher, so this is advisory only.
|
|
if enabled > 100:
|
|
_tools_count_label.add_theme_color_override("font_color", COLOR_AMBER)
|
|
else:
|
|
_tools_count_label.remove_theme_color_override("font_color")
|
|
|
|
|
|
func _on_tools_apply() -> void:
|
|
var canonical_excluded := ToolCatalog.canonical(_tools_pending_excluded)
|
|
var es := EditorInterface.get_editor_settings()
|
|
if es != null:
|
|
es.set_setting(McpSettings.SETTING_EXCLUDED_DOMAINS, canonical_excluded)
|
|
es.set_setting(McpSettings.SETTING_TELEMETRY_ENABLED, _telemetry_pending_enabled)
|
|
_tools_saved_excluded = _tools_pending_excluded.duplicate()
|
|
_telemetry_saved_enabled = _telemetry_pending_enabled
|
|
_refresh_tools_ui_state()
|
|
## Plugin reload respawns the server with the new `--exclude-domains` flag
|
|
## (see `plugin.gd::_build_server_flags`) and telemetry option. Mirrors the
|
|
## port-change Apply flow.
|
|
_on_reload_plugin()
|
|
|
|
|
|
func _on_tools_reset() -> void:
|
|
## Resets only the tool-domain exclusions, not the telemetry toggle.
|
|
## Telemetry is a privacy preference users typically want to set once
|
|
## and have honored — flipping it back to "on" via a generic Reset
|
|
## button would be a surprising privacy regression. The button label
|
|
## is scoped to tools accordingly.
|
|
_tools_pending_excluded = PackedStringArray()
|
|
for id in _tools_domain_checkboxes:
|
|
var chk: CheckBox = _tools_domain_checkboxes[id]
|
|
chk.set_pressed_no_signal(true)
|
|
_refresh_tools_ui_state()
|
|
|
|
|
|
func _show_tools_close_confirm() -> void:
|
|
if _tools_close_confirm == null:
|
|
return
|
|
_tools_close_confirm.popup_centered()
|
|
|
|
|
|
func _on_tools_discard_confirmed() -> void:
|
|
_reset_tools_pending_from_setting()
|
|
_refresh_tools_ui_state()
|
|
if _clients_window != null:
|
|
_clients_window.hide()
|
|
|
|
|
|
func _refresh_clients_summary() -> void:
|
|
# Count from cached row status values — `_apply_row_status` is the single
|
|
# source of truth, and reading cached status avoids re-running
|
|
# filesystem/CLI-hitting checks on every refresh. The same cache re-derives
|
|
# the drift banner so per-row mutations (Configure/Reconfigure/Remove on a
|
|
# row in the Clients & Tools window) keep the dock-level banner in sync
|
|
# without an extra sweep. See #166 and #226.
|
|
if _clients_summary_label == null:
|
|
return
|
|
var configured := 0
|
|
var mismatched_ids: Array[String] = []
|
|
for client_id in _client_rows:
|
|
var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED)
|
|
if status == Client.Status.CONFIGURED:
|
|
configured += 1
|
|
elif status == Client.Status.CONFIGURED_MISMATCH:
|
|
mismatched_ids.append(client_id)
|
|
var text := "%d / %d configured" % [configured, _client_rows.size()]
|
|
if mismatched_ids.size() > 0:
|
|
text += " (%d stale)" % mismatched_ids.size()
|
|
if ClientRefreshStateScript.should_show_checking_badge(_refresh_state):
|
|
text += (
|
|
" (checking...)"
|
|
if _refresh_state != ClientRefreshStateScript.RUNNING_TIMED_OUT
|
|
else " (client probe still running)"
|
|
)
|
|
_clients_summary_label.text = text
|
|
if _client_configure_all_btn != null:
|
|
_client_configure_all_btn.disabled = ClientRefreshStateScript.has_worker_alive(_refresh_state)
|
|
_refresh_drift_banner(mismatched_ids)
|
|
|
|
|
|
func _show_manual_command_for(client_id: String) -> void:
|
|
var row: Dictionary = _client_rows.get(client_id, {})
|
|
if row.is_empty():
|
|
return
|
|
var cmd := ClientConfigurator.manual_command(client_id)
|
|
if cmd.is_empty():
|
|
row["manual_panel"].visible = false
|
|
return
|
|
row["manual_text"].text = cmd
|
|
row["manual_panel"].visible = true
|
|
|
|
|
|
func _on_copy_manual_command(client_id: String) -> void:
|
|
var row: Dictionary = _client_rows.get(client_id, {})
|
|
if row.is_empty():
|
|
return
|
|
DisplayServer.clipboard_set(row["manual_text"].text)
|
|
|
|
|
|
func _refresh_all_client_statuses() -> void:
|
|
## Compatibility wrapper for older explicit call sites. Treat this as a manual
|
|
## refresh: it bypasses focus-in cooldown but still runs probes off the editor
|
|
## main thread.
|
|
if _server_blocks_client_health():
|
|
for client_id in _client_rows:
|
|
_apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message())
|
|
_refresh_clients_summary()
|
|
return
|
|
_request_client_status_refresh(true)
|
|
|
|
|
|
func _is_client_status_refresh_in_cooldown() -> bool:
|
|
if _last_client_status_refresh_completed_msec <= 0:
|
|
return false
|
|
return Time.get_ticks_msec() - _last_client_status_refresh_completed_msec < CLIENT_STATUS_REFRESH_COOLDOWN_MSEC
|
|
|
|
|
|
func _has_client_status_refresh_timed_out() -> bool:
|
|
if not ClientRefreshStateScript.has_worker_alive(_refresh_state):
|
|
return false
|
|
if _client_status_refresh_started_msec <= 0:
|
|
return false
|
|
return Time.get_ticks_msec() - _client_status_refresh_started_msec >= CLIENT_STATUS_REFRESH_TIMEOUT_MSEC
|
|
|
|
|
|
func _check_client_status_refresh_timeout() -> void:
|
|
if not _has_client_status_refresh_timed_out():
|
|
return
|
|
if _refresh_state == ClientRefreshStateScript.RUNNING_TIMED_OUT:
|
|
return
|
|
_refresh_state = ClientRefreshStateScript.RUNNING_TIMED_OUT
|
|
_refresh_clients_summary()
|
|
|
|
|
|
func _abandon_client_status_refresh_thread() -> void:
|
|
## GDScript cannot interrupt a blocking `OS.execute(..., true)` call in a
|
|
## worker. If a CLI probe hangs, orphan this run, bump the generation so any
|
|
## late result becomes a no-op, and let a forced/manual refresh start a fresh
|
|
## probe slot. Completed orphan threads are pruned from `_process`.
|
|
_client_status_refresh_generation += 1
|
|
if _client_status_refresh_thread != null:
|
|
_orphaned_client_status_refresh_threads.append(_client_status_refresh_thread)
|
|
_client_status_refresh_thread = null
|
|
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
|
|
_refresh_state = ClientRefreshStateScript.IDLE
|
|
## Reset the full pending-request triplet, not just the
|
|
## focus-in / cooldown half. A timed-out worker has already
|
|
## warmed bytecode, so any stale `_pending_initial` from an
|
|
## earlier deferred-during-busy startup is no longer load-bearing
|
|
## — leaving it set would cause `_retry_deferred_*` to dispatch
|
|
## `_perform_initial_*` a second time after this abandon
|
|
## (which would then no-op because no fresh worker is needed
|
|
## but still re-warm bytecode and walk the row set redundantly).
|
|
_client_status_refresh_pending = false
|
|
_client_status_refresh_pending_force = false
|
|
_client_status_refresh_pending_initial = false
|
|
_client_status_refresh_started_msec = 0
|
|
_refresh_clients_summary()
|
|
|
|
|
|
func _prune_orphaned_client_status_refresh_threads() -> void:
|
|
for i in range(_orphaned_client_status_refresh_threads.size() - 1, -1, -1):
|
|
var thread := _orphaned_client_status_refresh_threads[i]
|
|
if thread == null:
|
|
_orphaned_client_status_refresh_threads.remove_at(i)
|
|
elif not thread.is_alive():
|
|
thread.wait_to_finish()
|
|
_orphaned_client_status_refresh_threads.remove_at(i)
|
|
|
|
|
|
func _perform_initial_client_status_refresh() -> void:
|
|
## Pre-warm strategy bytecode on main, then hand every client probe
|
|
## (JSON / TOML / CLI alike) to the worker.
|
|
##
|
|
## Godot's GDScript hot-reload of overwritten plugin files is lazy: the
|
|
## bytecode swap happens on first dereference, not at `set_plugin_enabled`
|
|
## time. A worker thread spawned from a fresh `_build_ui` walks into
|
|
## `_json_strategy.*` / `_cli_strategy.*` / `client_configurator.*` while
|
|
## bytecode pages are mid-swap → SIGABRT. Dereferencing those scripts on
|
|
## main first forces the swap to complete here; the worker then finds
|
|
## stable bytecode. Filesystem signals don't bracket the swap window
|
|
## (they fire before bytecode replacement), and FOCUS_IN doesn't fire on
|
|
## in-place plugin reload because the editor stays focused — so neither
|
|
## works as a gate. See #233 / #235.
|
|
##
|
|
## Phase 1 (sync, on main): a single explicit `_warm_strategy_bytecode`
|
|
## call invokes a pure-memory helper on each strategy script —
|
|
## `_json_strategy.gd`, `_toml_strategy.gd`, `_cli_strategy.gd`, plus
|
|
## `client_configurator.gd` via `client_ids()` / `get_by_id`. No disk,
|
|
## no `OS.execute`, no JSON parse on main. `client_status_probe_snapshot`
|
|
## per client adds the `installed` flag and (for CLI clients) a cached
|
|
## CLI path to each probe.
|
|
##
|
|
## Phase 2 (worker): every probe — JSON, TOML, CLI — runs through the
|
|
## same `_run_client_status_refresh_worker` pipeline. Disk reads + JSON
|
|
## parses for the ~17 non-CLI clients now happen off the main thread,
|
|
## so the dock paints immediately on cold open instead of stalling
|
|
## behind ~16 sync `FileAccess.open` + `JSON.parse_string` calls.
|
|
##
|
|
## No-op outside the tree — GDScript tests instantiate via `new()`.
|
|
if not is_inside_tree():
|
|
return
|
|
if _client_rows.is_empty():
|
|
return
|
|
if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN:
|
|
return
|
|
if _is_self_update_in_progress():
|
|
return
|
|
if _is_editor_filesystem_busy():
|
|
_defer_initial_client_status_refresh_until_filesystem_ready()
|
|
return
|
|
if ClientRefreshStateScript.has_worker_alive(_refresh_state):
|
|
return
|
|
|
|
if _server_blocks_client_health():
|
|
for client_id in _client_rows:
|
|
_apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message())
|
|
_refresh_clients_summary()
|
|
return
|
|
|
|
_warm_strategy_bytecode()
|
|
|
|
var generation := _begin_client_status_refresh_run()
|
|
var server_url := ClientConfigurator.http_url()
|
|
var all_probes: Array[Dictionary] = []
|
|
|
|
for client_id in _client_rows:
|
|
var probe := ClientConfigurator.client_status_probe_snapshot(String(client_id))
|
|
if probe.is_empty():
|
|
continue
|
|
all_probes.append(probe)
|
|
_refresh_clients_summary()
|
|
|
|
if all_probes.is_empty():
|
|
_finalize_completed_refresh()
|
|
return
|
|
|
|
_client_status_refresh_thread = Thread.new()
|
|
var err := _client_status_refresh_thread.start(
|
|
Callable(self, "_run_client_status_refresh_worker").bind(
|
|
all_probes, server_url, generation
|
|
)
|
|
)
|
|
if err != OK:
|
|
_refresh_state = ClientRefreshStateScript.IDLE
|
|
_client_status_refresh_thread = null
|
|
_refresh_clients_summary()
|
|
|
|
|
|
## Force GDScript's lazy bytecode swap to complete for every script the
|
|
## worker thread will reach into. Each call is pure-memory — no disk, no
|
|
## network, no `OS.execute` — so it only costs the bytecode dereference
|
|
## itself. See `_perform_initial_client_status_refresh` for context and
|
|
## #233 / #235 for the SIGABRT this exists to prevent.
|
|
func _warm_strategy_bytecode() -> void:
|
|
var ids := ClientConfigurator.client_ids()
|
|
if ids.is_empty():
|
|
return
|
|
var any_client := ClientRegistry.get_by_id(String(ids[0]))
|
|
if any_client != null:
|
|
JsonStrategy.verify_entry(any_client, {}, "")
|
|
TomlStrategy.format_body(PackedStringArray(), "")
|
|
CliStrategy.format_args(PackedStringArray(), "", "")
|
|
|
|
|
|
func _begin_client_status_refresh_run() -> int:
|
|
## Marks a refresh as starting and returns the new generation token.
|
|
## Generation is bumped here (not at completion) so that a worker callback
|
|
## arriving after `_abandon_client_status_refresh_thread` or `_exit_tree`
|
|
## fires can be detected as stale via generation mismatch.
|
|
_refresh_state = ClientRefreshStateScript.RUNNING
|
|
_client_status_refresh_pending = false
|
|
_client_status_refresh_pending_force = false
|
|
_client_status_refresh_started_msec = Time.get_ticks_msec()
|
|
_client_status_refresh_generation += 1
|
|
_refresh_clients_summary()
|
|
return _client_status_refresh_generation
|
|
|
|
|
|
func _finalize_completed_refresh() -> void:
|
|
## Stamps cooldown and clears in-flight state. Called at the end of every
|
|
## refresh that successfully applied results — the worker callback path
|
|
## and the no-CLI fast path in `_perform_initial_client_status_refresh`.
|
|
_last_client_status_refresh_completed_msec = Time.get_ticks_msec()
|
|
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
|
|
_refresh_state = ClientRefreshStateScript.IDLE
|
|
_refresh_clients_summary()
|
|
|
|
|
|
func _request_client_status_refresh(force: bool = false) -> bool:
|
|
## Stale-while-refreshing: do not clear dots, summary, or the drift banner
|
|
## when a refresh is requested. The existing UI remains visible until the
|
|
## background worker's result is applied on the main thread.
|
|
if _server_blocks_client_health():
|
|
for client_id in _client_rows:
|
|
_apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message())
|
|
_refresh_clients_summary()
|
|
return false
|
|
if _is_self_update_in_progress():
|
|
## Self-update is overwriting plugin scripts on disk; spawning a worker
|
|
## now would crash it inside `GDScriptFunction::call` once the bytecode
|
|
## swap reaches a script the worker is mid-call into. Focus-in /
|
|
## manual button / cooldown timer all funnel through here, so one
|
|
## gate covers every spawn path during the install window. The flag
|
|
## lives on `_update_manager` and dies with the dock instance during
|
|
## `set_plugin_enabled(false)`.
|
|
return false
|
|
if ClientRefreshStateScript.has_worker_alive(_refresh_state):
|
|
if force and _has_client_status_refresh_timed_out():
|
|
_abandon_client_status_refresh_thread()
|
|
else:
|
|
_client_status_refresh_pending = true
|
|
_client_status_refresh_pending_force = _client_status_refresh_pending_force or force
|
|
_refresh_clients_summary()
|
|
return false
|
|
if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN:
|
|
return false
|
|
if not force and _is_client_status_refresh_in_cooldown():
|
|
return false
|
|
if _client_rows.is_empty():
|
|
return false
|
|
if _is_editor_filesystem_busy():
|
|
if force:
|
|
_defer_client_status_refresh_until_filesystem_ready(force)
|
|
return false
|
|
|
|
## Manual refresh (any `force=true` path: button click, popup open,
|
|
## external API caller) implies "may have installed a CLI since the
|
|
## last sweep" — flush CliFinder so freshly-installed binaries get
|
|
## re-detected. Focus-in (`force=false`) stays cached so the cheap
|
|
## case stays cheap. Per-CLI invalidation
|
|
## (`invalidate_uvx_cli_cache`) still pairs with specific events
|
|
## like `_on_install_uv` where the binary name is known.
|
|
if force:
|
|
ClientConfigurator.invalidate_cli_cache()
|
|
|
|
## Force the bytecode swap on the same scripts the worker will reach
|
|
## into — same #233/#235 guard `_perform_initial_*` already had.
|
|
## Without this, a manual refresh dispatched before the initial sweep
|
|
## has run (e.g. user clicks Refresh during the deferred-initial
|
|
## window after `_defer_client_status_refresh_until_filesystem_ready`
|
|
## cleared `_pending_initial`) walks into mid-swap bytecode and
|
|
## SIGABRTs.
|
|
_warm_strategy_bytecode()
|
|
|
|
var client_probes: Array[Dictionary] = []
|
|
for client_id in _client_rows:
|
|
client_probes.append(ClientConfigurator.client_status_probe_snapshot(String(client_id)))
|
|
var server_url := ClientConfigurator.http_url()
|
|
|
|
var generation := _begin_client_status_refresh_run()
|
|
_client_status_refresh_thread = Thread.new()
|
|
var err := _client_status_refresh_thread.start(
|
|
Callable(self, "_run_client_status_refresh_worker").bind(client_probes, server_url, generation)
|
|
)
|
|
if err != OK:
|
|
_refresh_state = ClientRefreshStateScript.IDLE
|
|
_client_status_refresh_thread = null
|
|
_refresh_clients_summary()
|
|
return false
|
|
return true
|
|
|
|
|
|
func _is_editor_filesystem_busy() -> bool:
|
|
var fs := EditorInterface.get_resource_filesystem()
|
|
return fs != null and fs.is_scanning()
|
|
|
|
|
|
func _defer_initial_client_status_refresh_until_filesystem_ready() -> void:
|
|
_refresh_state = ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM
|
|
_client_status_refresh_pending_initial = true
|
|
|
|
|
|
func _defer_client_status_refresh_until_filesystem_ready(force: bool) -> void:
|
|
## Godot can still be reparsing/reloading plugin scripts while the editor
|
|
## filesystem is busy. Do not spawn a worker into that window: the worker
|
|
## can call plugin GDScript while the main thread is reloading it, which
|
|
## crashes in `GDScriptFunction::call`.
|
|
##
|
|
## A manual refresh request is more recent intent than any earlier
|
|
## deferred-initial sweep, so we clear `_pending_initial` here.
|
|
## `_request_client_status_refresh` warms strategy bytecode itself
|
|
## now (see #233/#235), so the safety net the initial path provided
|
|
## still applies to the replayed manual refresh.
|
|
_refresh_state = ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM
|
|
_client_status_refresh_pending_force = _client_status_refresh_pending_force or force
|
|
_client_status_refresh_pending_initial = false
|
|
|
|
|
|
func _retry_deferred_client_status_refresh() -> void:
|
|
if _refresh_state != ClientRefreshStateScript.DEFERRED_FOR_FILESYSTEM:
|
|
return
|
|
if _is_self_update_in_progress():
|
|
return
|
|
if _is_editor_filesystem_busy():
|
|
return
|
|
|
|
var initial := _client_status_refresh_pending_initial
|
|
var force := _client_status_refresh_pending_force
|
|
_refresh_state = ClientRefreshStateScript.IDLE
|
|
_client_status_refresh_pending_force = false
|
|
_client_status_refresh_pending_initial = false
|
|
if initial:
|
|
_perform_initial_client_status_refresh()
|
|
else:
|
|
_request_client_status_refresh(force)
|
|
|
|
|
|
func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_url: String, generation: int) -> void:
|
|
var results: Dictionary = {}
|
|
for probe in client_probes:
|
|
var client_id := String(probe.get("id", ""))
|
|
if client_id.is_empty():
|
|
continue
|
|
var details := ClientConfigurator.check_status_details_for_url_with_cli_path(
|
|
client_id,
|
|
server_url,
|
|
String(probe.get("cli_path", ""))
|
|
)
|
|
var installed := bool(probe.get("installed", false))
|
|
results[client_id] = {
|
|
"status": details.get("status", Client.Status.NOT_CONFIGURED),
|
|
"installed": installed,
|
|
"error_msg": details.get("error_msg", ""),
|
|
}
|
|
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
|
|
call_deferred("_apply_client_status_refresh_results", results, generation)
|
|
|
|
|
|
func _apply_client_status_refresh_results(results: Dictionary, generation: int) -> void:
|
|
if generation != _client_status_refresh_generation or _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN:
|
|
return
|
|
if _client_status_refresh_thread != null:
|
|
_client_status_refresh_thread.wait_to_finish()
|
|
_client_status_refresh_thread = null
|
|
if _server_blocks_client_health():
|
|
for client_id in _client_rows:
|
|
_apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message())
|
|
_finalize_completed_refresh()
|
|
return
|
|
|
|
for client_id in results:
|
|
## Skip rows whose Configure / Remove worker is still running so the
|
|
## status refresh doesn't overwrite the "Configuring…" / "Removing…"
|
|
## badge with a stale dot color. The action's own completion handler
|
|
## will repaint the row when it lands.
|
|
if _client_action_threads.has(String(client_id)):
|
|
continue
|
|
var result: Dictionary = results[client_id]
|
|
_apply_row_status(
|
|
String(client_id),
|
|
result.get("status", Client.Status.NOT_CONFIGURED),
|
|
str(result.get("error_msg", "")),
|
|
result.get("installed", false)
|
|
)
|
|
_finalize_completed_refresh()
|
|
|
|
if _client_status_refresh_pending:
|
|
var pending_force := _client_status_refresh_pending_force
|
|
_client_status_refresh_pending = false
|
|
_client_status_refresh_pending_force = false
|
|
_request_client_status_refresh(pending_force)
|
|
|
|
|
|
func _server_blocks_client_health() -> bool:
|
|
if _plugin == null or not _plugin.has_method("get_server_status"):
|
|
return false
|
|
var status: Dictionary = _plugin.get_server_status()
|
|
return ServerStateScript.blocks_client_health(
|
|
int(status.get("state", ServerStateScript.UNINITIALIZED))
|
|
)
|
|
|
|
|
|
func _server_blocked_client_message() -> String:
|
|
if _plugin == null or not _plugin.has_method("get_server_status"):
|
|
return "server incompatible"
|
|
var status: Dictionary = _plugin.get_server_status()
|
|
var message := str(status.get("message", ""))
|
|
return message if not message.is_empty() else "server incompatible"
|
|
|
|
|
|
func _refresh_drift_banner(mismatched_ids: Array[String]) -> void:
|
|
if _drift_banner == null:
|
|
return
|
|
## Sort so set-equality is order-independent — `_client_rows` iteration
|
|
## order is dict-insertion order, but a future change to the iteration
|
|
## site shouldn't make us repaint identical content.
|
|
mismatched_ids = mismatched_ids.duplicate()
|
|
mismatched_ids.sort()
|
|
if mismatched_ids == _last_mismatched_ids:
|
|
return
|
|
_last_mismatched_ids = mismatched_ids
|
|
if mismatched_ids.is_empty():
|
|
_drift_banner.visible = false
|
|
return
|
|
var names: Array[String] = []
|
|
for id in mismatched_ids:
|
|
names.append(ClientConfigurator.client_display_name(id))
|
|
## Active server URL is already shown on the WS:/HTTP: line above the
|
|
## Clients section, so it doesn't need to repeat here. Lead with the
|
|
## client names — that's the only thing the user can act on.
|
|
var verb := "needs" if mismatched_ids.size() == 1 else "need"
|
|
_drift_label.text = "%s %s to be reconfigured." % [", ".join(names), verb]
|
|
_drift_banner.visible = true
|
|
|
|
|
|
func _on_reconfigure_mismatched() -> void:
|
|
## Re-Configure every client whose URL is currently stale. Iterates the
|
|
## cached list from the most recent sweep instead of re-running
|
|
## `check_status` per row (saves ~18 filesystem reads per click). The
|
|
## trailing `_refresh_all_client_statuses()` re-sweeps anyway, so any
|
|
## entries the user manually fixed between sweep and click get re-counted
|
|
## as CONFIGURED there.
|
|
for client_id in _last_mismatched_ids:
|
|
if _client_rows.has(client_id):
|
|
_on_configure_client(client_id)
|
|
_refresh_all_client_statuses()
|
|
|
|
|
|
func _apply_row_status(
|
|
client_id: String,
|
|
status: Client.Status,
|
|
error_msg: String = "",
|
|
installed_override: Variant = null,
|
|
) -> void:
|
|
var row: Dictionary = _client_rows.get(client_id, {})
|
|
if row.is_empty():
|
|
return
|
|
row["status"] = status
|
|
var dot: ColorRect = row["dot"]
|
|
var configure_btn: Button = row["configure_btn"]
|
|
var remove_btn: Button = row["remove_btn"]
|
|
var name_label: Label = row["name_label"]
|
|
var base_name := ClientConfigurator.client_display_name(client_id)
|
|
match status:
|
|
Client.Status.CONFIGURED:
|
|
dot.color = Color.GREEN
|
|
configure_btn.text = "Reconfigure"
|
|
remove_btn.visible = true
|
|
name_label.text = base_name
|
|
Client.Status.NOT_CONFIGURED:
|
|
dot.color = COLOR_MUTED
|
|
configure_btn.text = "Configure"
|
|
remove_btn.visible = false
|
|
var installed: bool = installed_override if installed_override != null else ClientConfigurator.is_installed(client_id)
|
|
name_label.text = base_name if installed else "%s (not detected)" % base_name
|
|
Client.Status.CONFIGURED_MISMATCH:
|
|
## Amber matches the dock-level drift banner so a glance at the
|
|
## row + the banner read as the same condition.
|
|
dot.color = COLOR_AMBER
|
|
configure_btn.text = "Reconfigure"
|
|
remove_btn.visible = true
|
|
name_label.text = "%s (URL out of date)" % base_name
|
|
_:
|
|
dot.color = Color.RED
|
|
configure_btn.text = "Retry"
|
|
remove_btn.visible = false
|
|
name_label.text = "%s — %s" % [base_name, error_msg] if not error_msg.is_empty() else base_name
|
|
|
|
|
|
# --- Update check & self-update ---
|
|
|
|
## Tolerates a null manager so test fixtures that build the dock without
|
|
## `_build_ui()` don't false-positive on the worker-spawn gate.
|
|
func _is_self_update_in_progress() -> bool:
|
|
return _update_manager != null and bool(_update_manager.is_install_in_flight())
|
|
|
|
|
|
func _on_update_pressed() -> void:
|
|
if _update_manager != null:
|
|
_update_manager.start_install()
|
|
|
|
|
|
func _on_update_check_result(result: Dictionary) -> void:
|
|
_update_label.text = String(result.get("label_text", ""))
|
|
_update_banner.visible = true
|
|
|
|
|
|
## Apply only the keys present so the manager can ship partial updates
|
|
## (e.g. button-text-only during the download phase) without clobbering
|
|
## banner state.
|
|
func _on_install_state_changed(state: Dictionary) -> void:
|
|
if state.has("button_text") and _update_btn != null:
|
|
_update_btn.text = String(state["button_text"])
|
|
if state.has("button_disabled") and _update_btn != null:
|
|
_update_btn.disabled = bool(state["button_disabled"])
|
|
if state.has("label_text") and _update_label != null:
|
|
_update_label.text = String(state["label_text"])
|
|
if state.has("banner_visible") and _update_banner != null:
|
|
_update_banner.visible = bool(state["banner_visible"])
|
|
if String(state.get("outcome", "")) == "success" and _update_label != null:
|
|
## Visual confirmation for the pre-4.4 "Updated! Restart the editor."
|
|
## terminal state — the only outcome the manager paints green for.
|
|
_update_label.add_theme_color_override("font_color", Color.GREEN)
|