--!strict
-- fmt/util.luau — busca, codificação de array e mapas de estado (paleta =
-- view.rs). Usado pelos demais submódulos fmt/*.luau.
local Types = require("types")
type ServicePair = Types.ServicePair
type ServiceSpec = Types.ServiceSpec
type DomainRoute = Types.DomainRoute
local M = {}
-- ── Busca ────────────────────────────────────────────────────────────────
-- Termo vazio sempre casa (a busca é filtro, não requisito). `term` já deve
-- vir em minúsculas.
function M.matches(term: string?, fields: {any}): boolean
if not term or term == "" then return true end
for _, f in ipairs(fields) do
if type(f) == "string" and string.find(string.lower(f), term, 1, true) then
return true
end
end
return false
end
-- ── Texto ────────────────────────────────────────────────────────────────
-- Trunca `s` para no máximo `max` caracteres, anexando "…" quando corta. Usado
-- em valores longos (ex.: URL de conexão) que senão empurram os controles ao
-- lado (o botão "Copiar") para fora da área visível. O valor completo continua
-- disponível noutra chave de contexto para o clipboard.
function M.ellipsis(s: string?, max: number): string
local str = s or ""
if utf8.len(str) and (utf8.len(str) :: number) > max then
local cut = utf8.offset(str, max + 1)
if cut then
return string.sub(str, 1, cut - 1) .. "…"
end
end
return str
end
-- Motivo de falha vindo do daemon (`DeployStateChanged.message`,
-- `ServiceStatus::Error(...)`) reduzido ao que cabe numa notificação/toast:
-- primeira linha não-vazia, aparada e truncada. O texto original é multi-linha
-- com frequência (a saída de erro do `docker build`) — o log completo continua
-- disponível na tela de log do serviço, que agora recebe a causa inteira.
-- Devolve "" quando não há motivo, para o chamador cair no texto genérico.
function M.short_reason(msg: any, max: number?): string
if type(msg) ~= "string" then
return ""
end
for linha in string.gmatch(msg, "[^\r\n]+") do
local limpa = string.match(linha, "^%s*(.-)%s*$") or ""
if limpa ~= "" then
return M.ellipsis(limpa, max or 140)
end
end
return ""
end
-- Orçamento de caracteres por coluna de tabela conforme a largura da janela,
-- espelhando as larguras de .col_svc/.col_proj/.col_state/.col_img no app.gss
-- (breakpoints 900/560; fonte mono size 13 ≈ 7,8 px/char, com 1 char de folga
-- pro "…" do ellipsis). O Row do glacier não clipa filhos: um valor sem
-- espaços mais largo que a coluna sobrepõe as vizinhas — então todo texto de
-- dado (nome de serviço/volume, tag, domínio…) passa por M.ellipsis com estes
-- limites. Recalculado a cada snapshot (~2s), como grid_cols em dashboard.luau.
export type ColBudgets = { svc: number, proj: number, state: number, img: number }
function M.col_budgets(): ColBudgets
local w = viewport().width
if w < 560 then
return { svc = 12, proj = 9, state = 12, img = 15 }
elseif w < 900 then
return { svc = 17, proj = 13, state = 14, img = 26 }
end
return { svc = 28, proj = 21, state = 19, img = 41 }
end
-- Remove sequências de escape ANSI (cor/cursor/erase) das linhas de log. O
-- daemon repassa o stdout/stderr cru dos containers, cheio de CSI (`ESC[..m`
-- etc.); sem limpar, além de aparecerem como lixo (`[2m[32m…`) na UI, cada
-- linha vira uma string longa com bytes de controle que o text-shaping do iced
-- processa a um custo patológico — reconstruído a CADA re-render (por evento de
-- log), o que congela a janela. Também tira CR e NUL soltos.
function M.strip_ansi(s: string?): string
local str = s or ""
-- CSI: ESC [ params byte-final(letra) — cobre SGR (cor), erase (K/J), cursor.
str = string.gsub(str, "\27%[[%d;?]*%a", "")
-- OSC: ESC ] … BEL. E qualquer ESC + 1 byte remanescente (ESC c, ESC =, …).
str = string.gsub(str, "\27%][^\7]*\7", "")
str = string.gsub(str, "\27.", "")
str = string.gsub(str, "[\r\0]", "")
return str
end
-- ── Codificação de arrays ────────────────────────────────────────────────
-- CUIDADO: uma tabela Luau vazia vira `{}` (objeto) no json.encode, não `[]`.
-- Os templates iteram arrays, então forçamos "[]" quando vazio.
function M.enc_array(t: {any}): string
if #t == 0 then return "[]" end
return json.encode(t)
end
-- ── Mapas de estado (paleta = view.rs) ───────────────────────────────────
-- ServiceStatus vem como string ("Running") ou tabela ({Error="..."}).
function M.status_label_color(status: any): (string, string)
if type(status) == "table" then
if status.Error ~= nil then return "Error", "#F85149" end
end
if status == "Running" then return "Running", "#3FB950"
elseif status == "Deploying" then return "Deploying", "#58A6FF"
elseif status == "Queued" then return "Na fila", "#8B949E"
elseif status == "Degraded" then return "Degraded", "#D29922"
elseif status == "Stopping" then return "Stopping", "#8B949E"
elseif status == "Stopped" then return "Stopped", "#8B949E"
end
return "Error", "#F85149"
end
function M.status_kind(status: any): string
if type(status) == "table" and status.Error ~= nil then return "bad" end
if status == "Running" then return "ok"
elseif status == "Deploying" then return "info"
elseif status == "Queued" then return "muted"
elseif status == "Degraded" then return "warn"
elseif status == "Stopping" or status == "Stopped" then return "muted"
end
return "bad"
end
-- DeployState vem como string ("Live", "Failed", "BuildingImage", ...).
function M.state_label_color(state: string?): (string, string)
if state == "Live" then return "LIVE", "#3FB950"
elseif state == "Stopped" then return "STOPPED", "#8B949E"
elseif state == "Failed" then return "FAILED", "#F85149"
elseif state == "PreDeployCheck" then return "PRÉ-DEPLOY CHECK", "#58A6FF"
end
return "BUILDING", "#58A6FF"
end
function M.state_kind(state: string?): string
if state == "Live" then return "ok"
elseif state == "Stopped" then return "muted"
elseif state == "Failed" then return "bad"
end
return "info"
end
function M.in_use_kind(in_use: boolean): string
if in_use then return "ok" end
return "muted"
end
-- Estado bruto de um container Docker ("running"/"exited"/… ) → (rótulo, kind).
-- Só "running"/"restarting"/"paused" contam como "ativo" (não removíveis sem
-- force); o resto é parado e pode ser removido individualmente.
function M.container_state(state: string?): (string, string)
local s = string.lower(state or "")
if s == "running" then return "running", "ok"
elseif s == "restarting" then return "restarting", "info"
elseif s == "paused" then return "paused", "warn"
elseif s == "created" then return "created", "info"
elseif s == "dead" then return "dead", "bad"
end
-- exited, removing, ou desconhecido
return (s ~= "" and s or "exited"), "muted"
end
-- `true` quando o container está PARADO (removível sem force). Espelha o daemon
-- (Docker recusa `rm` de um container rodando/reiniciando sem `-f`).
function M.container_stopped(state: string?): boolean
local s = string.lower(state or "")
return s ~= "running" and s ~= "restarting" and s ~= "paused"
end
-- ServiceSource → texto de "imagem/origem" (source_summary().1).
function M.source_summary(source: any): string
if type(source) ~= "table" then return "—" end
if source.Registry then return source.Registry.image or "—"
elseif source.Git then
local g = source.Git
return (g.url or "") .. " @ " .. (g.branch or "")
elseif source.Archive then return source.Archive.original_filename or source.Archive.archive_id or "zip enviado"
elseif source.Compose then return "docker-compose"
end
return "—"
end
-- domain_routes(): a lista `domains` nova, ou o legado `domain`/`tls_enabled`.
function M.domain_routes(spec: ServiceSpec): {DomainRoute}
local routes: {DomainRoute} = {}
if spec.domains and #spec.domains > 0 then
for _, r in ipairs(spec.domains) do
table.insert(routes, { domain = r.domain, port = r.port, tls = r.tls == true })
end
elseif spec.domain and spec.domain ~= "" then
table.insert(routes, { domain = spec.domain :: string, port = nil, tls = spec.tls_enabled == true })
end
return routes
end
-- Fila efetiva de pré-deploy check: `pre_deploy_job_ids` quando não vazia,
-- senão cai no `pre_deploy_job_id` legado (retrocompat) — mesmo idioma de
-- `ServiceSpec::pre_deploy_checks` no lado Rust.
function M.pre_deploy_checks(spec: any): { string }
if spec.pre_deploy_job_ids and #spec.pre_deploy_job_ids > 0 then
return spec.pre_deploy_job_ids
elseif spec.pre_deploy_job_id and spec.pre_deploy_job_id ~= "" then
return { spec.pre_deploy_job_id }
end
return {}
end
-- `services` do snapshot é uma lista de { project_name, service }.
-- Normaliza para pares { svc = Service, proj = "nome" }.
function M.pair_list(services: {any}?): {ServicePair}
local out: {ServicePair} = {}
for _, e in ipairs(services or {}) do
table.insert(out, { svc = e.service, proj = e.project_name or "" })
end
return out
end
return M