defmodule PhoenixGenApiTui.State do
@moduledoc """
State struct and pure navigation logic for the PhoenixGenApi TUI explorer.
All state transitions are pure functions: `handle_key(state, key) -> state`.
No side effects, no processes — just data transformation.
"""
require Logger
alias PhoenixGenApiTui.Format
alias PhoenixGenApiTui.Introspection.ServiceInfo
alias PhoenixGenApiTui.Telemetry
defstruct [
:introspection,
:current_service,
:current_function,
:search_input,
current_tab: :functions,
nav_selected: 0,
detail_selected: 0,
focus: :nav,
nav_stack: [],
show_help: false,
detail_overlay: nil,
searching: false,
search_query: "",
last_error: nil,
notice: nil
]
@type tab ::
:functions
| :call_flows
| :cluster
| :health
| :stats
| :rate_limits
| :errors
| :system
| :service_config
@type t :: %__MODULE__{
introspection: PhoenixGenApiTui.Introspection.t(),
current_service: ServiceInfo.t() | nil,
current_function: struct() | nil,
current_tab: tab(),
nav_selected: non_neg_integer(),
detail_selected: non_neg_integer(),
focus: :nav | :detail,
nav_stack: [{atom(), tab()}],
show_help: boolean(),
detail_overlay: struct() | nil,
search_input: reference() | nil,
searching: boolean(),
search_query: String.t(),
last_error: String.t() | nil,
notice: String.t() | nil
}
@tabs [
:functions,
:call_flows,
:cluster,
:health,
:stats,
:rate_limits,
:errors,
:system,
:service_config
]
@doc """
Creates a new state from introspection data.
## Examples
iex> introspection = %PhoenixGenApiTui.Introspection{
...> services: [%PhoenixGenApiTui.Introspection.ServiceInfo{name: MyApp.Services.UserService, functions: []}],
...> call_flows: [], cluster_view: %{}, health_check: %{}, statistics: %{}, rate_limits: %{},
...> status: :ok, loaded_at: 0
...> }
iex> state = PhoenixGenApiTui.State.new(introspection)
iex> state.current_service.name
MyApp.Services.UserService
iex> state.current_tab
:functions
iex> state = PhoenixGenApiTui.State.new(%PhoenixGenApiTui.Introspection{services: [], call_flows: [], cluster_view: %{}, health_check: %{}, statistics: %{}, rate_limits: %{}})
iex> state.current_service
nil
"""
@spec new(PhoenixGenApiTui.Introspection.t()) :: t()
def new(%{services: []} = introspection) do
%__MODULE__{introspection: introspection, current_service: nil, current_function: nil}
end
def new(introspection) do
service = hd(introspection.services)
%__MODULE__{
introspection: introspection,
current_service: service,
current_function: List.first(service.functions)
}
end
@doc """
Refreshes the introspection data attached to the state.
Returns a new state with updated data, preserving navigation context where possible.
"""
@spec refresh(t()) :: t()
def refresh(%__MODULE__{introspection: intro, current_service: current_svc} = state) do
# Always force fresh data on explicit user refresh
Telemetry.execute([:phoenix_gen_api_tui, :refresh, :start], %{}, %{})
# Preserve the same node target (local or remote) on refresh
intro_opts = if intro.node, do: [node: intro.node], else: []
new_intro = PhoenixGenApiTui.Introspection.load!(intro_opts)
Telemetry.execute([:phoenix_gen_api_tui, :refresh, :stop], %{}, %{status: new_intro.status})
# Try to preserve the current service selection
current_service =
if current_svc do
case Enum.find(new_intro.services, &(&1.name == current_svc.name)) do
nil -> List.first(new_intro.services)
found -> found
end
else
List.first(new_intro.services)
end
%{
state
| introspection: new_intro,
current_service: current_service,
current_function: current_service && List.first(current_service.functions),
notice: "Data refreshed"
}
|> reconcile_nav_selected()
end
@doc """
Clears any notice or error message in the state.
"""
@spec clear_notice(t()) :: t()
def clear_notice(state), do: %{state | notice: nil, last_error: nil}
# ── Navigation Items ────────────────────────────────────────
@doc """
Returns the list of items shown in the navigation panel.
This is a flat list of services with their function counts.
Supports fuzzy substring matching when searching.
"""
@spec nav_items(t()) :: [ServiceInfo.t()]
def nav_items(%__MODULE__{introspection: introspection} = state) do
query = state.search_query
all_services = get_all_services(introspection)
if query == "" do
all_services
else
fuzzy_filter(all_services, query)
end
end
# Collects services from current introspection plus all connected nodes.
defp get_all_services(introspection) do
local_services = introspection.services
cluster = introspection.cluster_view
node_services = get_node_services(cluster[:phoenix_gen_api_services])
connected_nodes = List.wrap(cluster[:connected])
current_node = introspection.node
# Gather service names from all connected nodes
remote_service_names =
connected_nodes
|> Enum.flat_map(&(node_services[&1] || []))
|> Enum.uniq()
# Gather service names from current node
current_service_names =
current_node
|> then(&(node_services[&1] || []))
all_service_names =
(current_service_names ++ remote_service_names ++ Enum.map(local_services, & &1.name))
|> Enum.uniq()
# Build ServiceInfo for each unique service name
Enum.map(all_service_names, fn name ->
existing = Enum.find(local_services, &(&1.name == name))
if existing do
existing
else
%ServiceInfo{name: name, functions: [], function_count: 0}
end
end)
end
defp get_node_services(services) when is_map(services), do: services
defp get_node_services(_), do: %{}
@doc """
Returns the cached search query (updated on search input changes).
"""
@spec search_query(t()) :: String.t()
def search_query(%__MODULE__{search_query: q}), do: q
# Fuzzy filter: matches characters in order, case-insensitive
# Uses charlist traversal for O(n) single-pass matching.
defp fuzzy_filter(services, query) do
query_chars = String.downcase(query) |> String.to_charlist()
Enum.filter(services, fn service ->
name = service.name |> to_string() |> String.downcase() |> String.to_charlist()
fuzzy_match?(name, query_chars)
end)
end
# Single-pass charlist matching: O(n) where n = length of name
defp fuzzy_match?(_name, []), do: true
defp fuzzy_match?([], _query), do: false
defp fuzzy_match?([h | name_rest], [h | query_rest]), do: fuzzy_match?(name_rest, query_rest)
defp fuzzy_match?([_ | name_rest], query), do: fuzzy_match?(name_rest, query)
@doc """
Returns the items for the currently active detail tab.
"""
@spec detail_items(t()) :: [term()]
def detail_items(%__MODULE__{current_tab: :functions, current_service: nil}), do: []
def detail_items(%__MODULE__{current_tab: :call_flows} = state),
do: state.introspection.call_flows
def detail_items(%__MODULE__{current_tab: :cluster}), do: []
def detail_items(%__MODULE__{current_tab: :health}), do: []
def detail_items(%__MODULE__{current_tab: :stats}), do: []
def detail_items(%__MODULE__{current_tab: :rate_limits}), do: []
def detail_items(%__MODULE__{current_tab: :errors} = state),
do: state.introspection.failed_configs || []
def detail_items(%__MODULE__{current_tab: :system} = state),
do: state.introspection.system_info || []
def detail_items(%__MODULE__{current_tab: :service_config}) do
try do
PhoenixGenApi.ConfigPuller.get_services()
|> Map.values()
catch
:exit, _ -> []
end
end
def detail_items(%__MODULE__{current_tab: :functions, current_service: service}) do
service.functions
end
@doc """
Returns the breadcrumb trail for the header.
"""
@spec breadcrumb(t()) :: String.t()
def breadcrumb(%__MODULE__{nav_stack: [], current_service: nil}), do: ""
def breadcrumb(%__MODULE__{nav_stack: [], current_service: service}) do
Format.short_name(service.name)
end
def breadcrumb(%__MODULE__{nav_stack: stack, current_service: service}) do
trail =
stack
|> Enum.reverse()
|> Enum.map(fn {service_name, _tab} -> Format.short_name(service_name) end)
current = if service, do: Format.short_name(service.name), else: ""
Enum.join(trail ++ [current], " > ")
end
# ── Key Handling ─────────────────────────────────────────────
@doc """
Processes a key press and returns the updated state.
Handles vim-style navigation (`j`/`k`/`h`/`l`), tab switching (`tab`, `1`-`6`),
enter/esc for selection and back-navigation, `?` for help, and `/` for search.
"""
@spec handle_key(t(), String.t()) :: t()
# Help overlay — any key closes it
def handle_key(%__MODULE__{show_help: true} = state, _key) do
%{state | show_help: false}
end
# Detail overlay — esc closes, other keys ignored
def handle_key(%__MODULE__{detail_overlay: overlay} = state, "esc") when not is_nil(overlay) do
%{state | detail_overlay: nil}
end
def handle_key(%__MODULE__{detail_overlay: overlay} = state, _key) when not is_nil(overlay) do
state
end
# Search mode — handle input, enter, and esc
def handle_key(%__MODULE__{searching: true} = state, "esc") do
clear_search(state)
end
def handle_key(%__MODULE__{searching: true} = state, "enter") do
%{state | searching: false, focus: :nav, nav_selected: 0}
end
def handle_key(%__MODULE__{searching: true, search_input: ref} = state, key) do
ExRatatui.text_input_handle_key(ref, key)
new_query = ExRatatui.text_input_get_value(ref)
%{state | nav_selected: 0, search_query: new_query}
end
# Single-key commands
def handle_key(state, "?"), do: %{state | show_help: true}
def handle_key(%__MODULE__{search_input: ref} = state, "/") when not is_nil(ref),
do: %{state | searching: true, focus: :nav}
def handle_key(state, "r"), do: refresh(state)
def handle_key(state, "tab"), do: next_tab(state)
def handle_key(state, "enter"), do: handle_enter(state)
def handle_key(state, "esc"), do: pop_nav_stack(state)
# Vim navigation
def handle_key(state, key) when key in ["j", "down"], do: move_selection(state, :down)
def handle_key(state, key) when key in ["k", "up"], do: move_selection(state, :up)
def handle_key(state, key) when key in ["h", "left"], do: %{state | focus: :nav}
def handle_key(state, key) when key in ["l", "right"], do: %{state | focus: :detail}
# Number keys for tab selection
def handle_key(state, key) when key in ["1", "2", "3", "4", "5", "6", "7", "8", "9"] do
idx = String.to_integer(key) - 1
tab = Enum.at(@tabs, idx)
switch_tab(state, tab)
end
# Fallback — ignore unknown keys
def handle_key(state, _key), do: state
# ── State Transitions ───────────────────────────────────────
defp move_selection(%{focus: :nav} = state, direction) do
items = nav_items(state)
max_idx = max(length(items) - 1, 0)
new_selected =
case direction do
:down -> min(state.nav_selected + 1, max_idx)
:up -> max(state.nav_selected - 1, 0)
end
%{state | nav_selected: new_selected}
end
defp move_selection(%{focus: :detail} = state, direction) do
items = detail_items(state)
max_idx = max(length(items) - 1, 0)
new_selected =
case direction do
:down -> min(state.detail_selected + 1, max_idx)
:up -> max(state.detail_selected - 1, 0)
end
%{state | detail_selected: new_selected}
end
defp move_selection(state, _direction), do: state
defp handle_enter(%{focus: :nav} = state) do
items = nav_items(state)
case Enum.at(items, state.nav_selected) do
%ServiceInfo{} = service ->
select_service(state, service)
nil ->
state
end
end
defp handle_enter(%{focus: :detail, current_tab: :functions} = state) do
case Enum.at(state.current_service.functions, state.detail_selected) do
fun when not is_nil(fun) ->
%{state | detail_overlay: fun}
nil ->
state
end
end
defp handle_enter(%{focus: :detail, current_tab: :service_config} = state) do
pulled_services = PhoenixGenApi.ConfigPuller.get_services() |> Map.values()
service = Enum.at(pulled_services, state.detail_selected)
if service, do: %{state | detail_overlay: service}, else: state
catch
:exit, _ -> state
end
defp handle_enter(state), do: state
defp select_service(state, service) do
%{
state
| current_service: service,
current_function: List.first(service.functions),
detail_selected: 0
}
|> reconcile_nav_selected()
end
defp pop_nav_stack(%{nav_stack: []} = state), do: state
defp pop_nav_stack(%{nav_stack: [{service_name, tab} | rest]} = state) do
service = Enum.find(state.introspection.services, &(&1.name == service_name))
%{
state
| nav_stack: rest,
current_service: service,
current_function: service && List.first(service.functions),
current_tab: tab,
detail_selected: 0
}
|> reconcile_nav_selected()
end
defp next_tab(state) do
current_idx = Enum.find_index(@tabs, &(&1 == state.current_tab))
next_idx = rem(current_idx + 1, length(@tabs))
switch_tab(state, Enum.at(@tabs, next_idx))
end
defp switch_tab(state, tab) when tab in @tabs do
%{state | current_tab: tab, detail_selected: 0}
end
defp switch_tab(state, tab) do
Logger.warning("Attempted to switch to unknown tab: #{inspect(tab)}")
state
end
defp clear_search(%__MODULE__{search_input: ref} = state) when not is_nil(ref) do
ExRatatui.text_input_set_value(ref, "")
%{state | searching: false, nav_selected: 0, search_query: ""}
end
defp clear_search(state), do: %{state | searching: false, search_query: ""}
# Expanding a different service (or popping the nav stack) reshapes the visible
# list, so a previously valid `nav_selected` can fall off the shorter list.
# Clamp it back into range.
defp reconcile_nav_selected(state) do
max_idx = max(length(nav_items(state)) - 1, 0)
%{state | nav_selected: min(state.nav_selected, max_idx)}
end
end