diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d6e4d4a..87ab815 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,8 +3,10 @@ name: CI
on:
push:
branches: [main]
+ # Sem filtro de base: roda em PR para QUALQUER branch. Com "branches: [main]" os
+ # PRs empilhados (que apontam para outra branch de feature) ficavam sem CI, e o
+ # gate de testes só valia para o último PR da fila.
pull_request:
- branches: [main]
workflow_dispatch:
permissions:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cce69c3..b2b4f9d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -58,7 +58,11 @@ jobs:
}
while ($p.Count -lt 3) { $p += '0' }
$version = '{0}.{1}.{2}.0' -f $p[0], $p[1], $p[2]
+ # 3 partes para o MSBuild: o Directory.Build.props faz $(Version).0 para chegar
+ # às 4 do assembly; passar 4 partes aqui produziria 5.
+ $msbuild = '{0}.{1}.{2}' -f $p[0], $p[1], $p[2]
"version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
+ "msbuildVersion=$msbuild" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
Write-Host "Versão do pacote: $version"
# Carimba a identidade do pacote no manifesto (só durante o build do CI; não é commitado):
@@ -93,6 +97,7 @@ jobs:
/p:AppxBundlePlatforms=x64
/p:UapAppxPackageBuildMode=StoreUpload
/p:AppxPackageSigningEnabled=false
+ /p:Version=${{ steps.ver.outputs.msbuildVersion }}
- name: Locate upload package
id: pkg
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..532b205
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,33 @@
+
+
+
+
+ 1.1.0
+
+
+ $(ClefVersion)
+
+
+ $(Version).0
+ $(Version).0
+
+
+
diff --git a/ROADMAP.md b/ROADMAP.md
index 1736c9c..97c9192 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -55,12 +55,12 @@ O app está publicado na Store (product `9MVZN1HVJ230`, gratuito) e o CI está v
| # | Item | Tipo | Esforço | Status |
|---|---|---|---|---|
-| 18 | Virtualização da lista (alternativa/complemento à paginação) | ⚡ Perf | 🟡 | ⬜ |
-| 19 | Painel de estatísticas / timeline | ✨ Feature | 🔴 | ⬜ |
-| 20 | Acessibilidade (teclado, ARIA, foco) | ♿ A11y | 🟡 | ⬜ |
-| 21 | Versionamento coerente + auto-update da versão standalone | 🧱 Débito | 🟡 | ⬜ |
-| 22 | Internacionalização (i18n) | ✨ Feature | 🔴 | ⬜ |
-| 23 | Higiene geral do código e nomenclatura | 🧱 Débito | 🟢 | 🟨 parcial (avisos `CS*` zerados) |
+| 18 | Virtualização da lista (alternativa/complemento à paginação) | ⚡ Perf | 🟡 | ✅ (+ páginas de até 1000) |
+| 19 | Painel de estatísticas / timeline | ✨ Feature | 🔴 | ✅ |
+| 20 | Acessibilidade (teclado, ARIA, foco) | ♿ A11y | 🟡 | ✅ (anel de foco não verificado na tela) |
+| 21 | Versionamento coerente + auto-update da versão standalone | 🧱 Débito | 🟡 | 🟨 versão unificada; auto-update pendente |
+| 22 | Internacionalização (i18n) | ✨ Feature | 🔴 | ⬜ (depende de decisão de alcance) |
+| 23 | Higiene geral do código e nomenclatura | 🧱 Débito | 🟢 | ✅ |
---
diff --git a/publish-store-package.ps1 b/publish-store-package.ps1
index 8f50191..9717d18 100644
--- a/publish-store-package.ps1
+++ b/publish-store-package.ps1
@@ -38,8 +38,9 @@
#>
[CmdletBinding()]
param(
- [Parameter(Mandatory = $true)]
- [string]$Version,
+ # Sem Mandatory: omitir cai no do Directory.Build.props. Com Mandatory,
+ # o PowerShell pediria o valor no prompt e o fallback nunca seria alcançado.
+ [string]$Version = '',
[string]$Platform = 'x64',
[string]$Configuration = 'Release',
@@ -60,6 +61,18 @@ foreach ($p in @($srcProj, $pkgProj, $manifest)) {
if (-not (Test-Path $p)) { throw "Não encontrei: $p (rode o script da raiz do repositório)." }
}
+# ---------- Versão: parâmetro, ou a do Directory.Build.props ----------
+if ([string]::IsNullOrWhiteSpace($Version)) {
+ $propsPath = Join-Path $root 'Directory.Build.props'
+ if (Test-Path $propsPath) {
+ $Version = ([xml](Get-Content $propsPath)).Project.PropertyGroup.ClefVersion
+ }
+ if ([string]::IsNullOrWhiteSpace($Version)) {
+ throw "Informe -Version ou defina em Directory.Build.props."
+ }
+ Write-Host "Versão herdada do Directory.Build.props: $Version" -ForegroundColor DarkGray
+}
+
# ---------- Normaliza a versão para x.y.z.0 ----------
$v = $Version.Trim().TrimStart('v', 'V')
if ($v -notmatch '^\d+\.\d+(\.\d+){0,2}$') {
@@ -72,6 +85,9 @@ if ($parts.Count -eq 4 -and $parts[3] -ne '0') {
}
while ($parts.Count -lt 3) { $parts += '0' }
$fullVersion = '{0}.{1}.{2}.0' -f $parts[0], $parts[1], $parts[2]
+# 3 partes para o MSBuild: o Directory.Build.props faz $(Version).0 para chegar às 4
+# do assembly; passar 4 partes aqui produziria 5.
+$msbuildVersion = '{0}.{1}.{2}' -f $parts[0], $parts[1], $parts[2]
Write-Host "Versão do pacote: $fullVersion" -ForegroundColor Cyan
# ---------- Dica: versão já instalada/publicada ----------
@@ -154,6 +170,7 @@ try {
/p:AppxBundlePlatforms=$Platform `
/p:UapAppxPackageBuildMode=StoreUpload `
/p:AppxPackageSigningEnabled=false `
+ /p:Version=$msbuildVersion `
/v:minimal
if ($LASTEXITCODE -ne 0) { throw "MSBuild falhou (exit $LASTEXITCODE)." }
}
diff --git a/src/ClefExplorer.csproj b/src/ClefExplorer.csproj
index aa1fd79..3a7d63b 100644
--- a/src/ClefExplorer.csproj
+++ b/src/ClefExplorer.csproj
@@ -11,7 +11,9 @@
truetrue
- win-x64;win-x86
+
+ win-x64truetrueapp.ico
@@ -24,9 +26,7 @@
https://github.com/afernandes/ClefExplorerhttps://github.com/afernandes/ClefExplorer.gitlog;serilog;clef;viewer;blazor;winforms;structured-logging
- 1.0.0
- 1.0.0.0
- 1.0.0.0
+
diff --git a/src/Components/LogGrid.razor b/src/Components/LogGrid.razor
new file mode 100644
index 0000000..68f5e93
--- /dev/null
+++ b/src/Components/LogGrid.razor
@@ -0,0 +1,177 @@
+@using ClefExplorer.Models
+@using ClefExplorer.Helpers
+
+@* Visão em tabela dos eventos: ordenar, agrupar (arrastando a coluna para a faixa
+ acima), filtrar por coluna e redimensionar.
+
+ Além das colunas fixas, as colunas de propriedade são DESCOBERTAS a partir do
+ conteúdo dos logs carregados — cada aplicação emite um conjunto próprio
+ (SourceContext, RequestId, MachineName…), então fixá-las na mão não funcionaria.
+
+ O seletor de colunas é NOSSO, e não o embutido (AllowColumnVisibility): a coluna
+ aplica a visibilidade no próprio OnInitialized, então toda vez que o grid é
+ recriado — o que acontece ao abrir/fechar o painel de detalhes, já que o layout
+ troca de splitter — as escolhas do menu interno se perdiam. Mantendo o conjunto
+ aqui, ele sobrevive à recriação e ainda é persistido entre execuções. *@
+
+
+
+
+
+
+
+
+
+
+ @foreach (var col in TodasAsColunas)
+ {
+ var c = col;
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @* Só o nome do arquivo na célula; o caminho completo fica no title. *@
+
+
+ @NomeArquivo(ev)
+
+
+
+
+
+ @if (!string.IsNullOrEmpty(ev.Exception))
+ {
+
+ }
+
+
+
+ @* Colunas derivadas do conteúdo dos logs carregados. *@
+ @foreach (var coluna in Colunas)
+ {
+ var c = coluna;
+
+ }
+
+
+
+
+@code {
+ [Parameter] public IReadOnlyList Eventos { get; set; } = Array.Empty();
+ [Parameter] public ClefEvent? SelectedEvent { get; set; }
+ [Parameter] public EventCallback OnSelect { get; set; }
+
+ /// Colunas descobertas no conteúdo dos logs carregados.
+ [Parameter] public IReadOnlyList Colunas { get; set; } = Array.Empty();
+
+ /// Chaves das colunas visíveis (fixas e descobertas).
+ [Parameter] public HashSet VisibleColumns { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+
+ [Parameter] public EventCallback> VisibleColumnsChanged { get; set; }
+
+ private bool _colunasOpen;
+
+ /// Fixas primeiro, depois as descobertas — a mesma ordem da tabela.
+ private IEnumerable<(string Key, string Title)> TodasAsColunas =>
+ LogGridColumns.Fixed.Concat(Colunas.Select(c => (c.Key, c.Title)));
+
+ private async Task Alternar(string key, bool visivel)
+ {
+ var novo = new HashSet(VisibleColumns, StringComparer.OrdinalIgnoreCase);
+ if (visivel) novo.Add(key); else novo.Remove(key);
+
+ VisibleColumns = novo;
+ await VisibleColumnsChanged.InvokeAsync(novo);
+ }
+
+ private static string NomeArquivo(ClefEvent e) =>
+ string.IsNullOrEmpty(e.SourceFile) ? string.Empty : Path.GetFileName(e.SourceFile);
+
+ /// Destaca a linha selecionada e as de erro, como na lista.
+ private string LinhaCss(ClefEvent e)
+ {
+ var classes = new List(2);
+
+ if (ReferenceEquals(e, SelectedEvent)) classes.Add("is-selected");
+
+ if (string.Equals(e.Level, "Error", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(e.Level, "Fatal", StringComparison.OrdinalIgnoreCase))
+ {
+ classes.Add("clef-grid-row-error");
+ }
+
+ return string.Join(' ', classes);
+ }
+}
diff --git a/src/Components/LogGroupManager.razor b/src/Components/LogGroupManager.razor
index 7743a66..e25eee3 100644
--- a/src/Components/LogGroupManager.razor
+++ b/src/Components/LogGroupManager.razor
@@ -11,10 +11,16 @@
@foreach (var group in GroupService.Groups)
{
var g = group;
-
EditGroup(g))">
+ @*
}
diff --git a/src/Components/LogHeader.razor b/src/Components/LogHeader.razor
index 6bc245e..af99720 100644
--- a/src/Components/LogHeader.razor
+++ b/src/Components/LogHeader.razor
@@ -42,7 +42,9 @@
[Parameter] public EventCallback OnReload { get; set; }
- private readonly int[] _qtdOptions = { 30, 50, 100 };
+ // Páginas grandes só passaram a fazer sentido depois que a lista virou virtualizada:
+ // antes, 1000 itens significavam 1000 nós no DOM.
+ private readonly int[] _qtdOptions = { 30, 50, 100, 250, 500, 1000 };
private static string FormatQtd(int v) => $"{v} itens";
diff --git a/src/Components/LogList.razor b/src/Components/LogList.razor
index 633497a..389b02a 100644
--- a/src/Components/LogList.razor
+++ b/src/Components/LogList.razor
@@ -1,7 +1,9 @@
@using ClefExplorer.Models
@using ClefExplorer.Helpers
-
+@* role=listbox + option: os itens são
por causa do layout, mas precisam ser
+ anunciados como uma lista de opções selecionáveis e alcançáveis pelo teclado. *@
+
@if (IsBusy)
{
@@ -9,29 +11,39 @@
Carregando logs...
}
- else if (Eventos != null && Eventos.Any())
+ else if (_itens.Count > 0)
{
- @foreach (var e in Eventos)
- {
- var ev = e;
- var selected = ReferenceEquals(SelectedEvent, ev);
-
OnSelect.InvokeAsync(ev))">
-
-
-
- @ev.Timestamp?.ToString("HH:mm:ss")
-
-
@ev.Message
-
- @if (!string.IsNullOrEmpty(ev.Exception))
- {
- Exception
- }
- @ObterResumoProps(ev)
+ @* Virtualizado: só as linhas visíveis (mais um pequeno overscan) vão ao DOM.
+ Height=null porque o pai (.clef-list) já limita a altura e faz o scroll —
+ sem altura limitada o Virtualize renderizaria tudo. É o que permite páginas
+ grandes (500/1000) sem a UI travar. *@
+ @* Height="@null" e não Height="null": o segundo passaria a STRING "null". *@
+
+
+
- }
+
+
}
else
{
@@ -47,6 +59,35 @@
[Parameter] public ClefEvent? SelectedEvent { get; set; }
[Parameter] public EventCallback OnSelect { get; set; }
+ ///
+ /// O OmniVirtualize exige ICollection: materializa uma vez por mudança de
+ /// parâmetros, em vez de a cada render.
+ ///
+ private ICollection _itens = Array.Empty();
+
+ protected override void OnParametersSet() =>
+ _itens = Eventos as ICollection ?? Eventos?.ToList() ?? (ICollection)Array.Empty();
+
+ /// Enter/Espaço ativam o item, como num botão.
+ private async Task AoTeclar(KeyboardEventArgs e, ClefEvent ev)
+ {
+ if (e.Key is "Enter" or " ")
+ {
+ await OnSelect.InvokeAsync(ev);
+ }
+ }
+
+ ///
+ /// Nome anunciado pelo leitor de tela. Sem ele, o item seria lido como um amontoado de
+ /// textos soltos — e o nível, que visualmente é um badge colorido, se perderia.
+ ///
+ private static string RotuloAcessivel(ClefEvent e)
+ {
+ var hora = e.Timestamp?.ToString("HH:mm:ss") ?? "";
+ var excecao = string.IsNullOrEmpty(e.Exception) ? "" : ", com exceção";
+ return $"{e.Level} às {hora}: {e.Message}{excecao}";
+ }
+
private string ObterResumoProps(ClefEvent e)
{
if (e.Properties == null) return "";
diff --git a/src/Components/LogStatsPanel.razor b/src/Components/LogStatsPanel.razor
new file mode 100644
index 0000000..1530d50
--- /dev/null
+++ b/src/Components/LogStatsPanel.razor
@@ -0,0 +1,167 @@
+@using ClefExplorer.Models
+@using ClefExplorer.Helpers
+@using Omni.Blazor.Models
+
+@* Visão agregada do conjunto FILTRADO — não do arquivo inteiro. Assim as estatísticas
+ respondem ao que está em tela: filtrou por período, os números acompanham.
+
+ Tudo aqui é clicável e aplica o filtro correspondente: é o que transforma o painel
+ de "relatório" em ponto de partida da investigação. *@
+
+
+
+@code {
+ [Parameter] public LogStats Stats { get; set; } = new();
+
+ /// Clique num nível aplica o filtro rápido correspondente.
+ [Parameter] public EventCallback OnFilterLevel { get; set; }
+
+ /// Clique numa origem/mensagem/exceção joga o texto na busca.
+ [Parameter] public EventCallback OnFilterText { get; set; }
+
+ private int MaiorNivel => Stats.ByLevel.Count == 0 ? 1 : Stats.ByLevel.Max(e => e.Count);
+
+ private string PercentualErro =>
+ Stats.Total == 0 ? "0%" : (Stats.ErrorCount / (double)Stats.Total).ToString("P1");
+
+ private static string Largura(int valor, int maximo) =>
+ $"width: {(maximo <= 0 ? 0 : valor * 100.0 / maximo).ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}%";
+
+ private RenderFragment RenderRanking(IReadOnlyList entradas, EventCallback aoClicar) => __builder =>
+ {
+ if (entradas.Count == 0)
+ {
+
Nada a destacar.
+ }
+ else
+ {
+ var maior = entradas.Max(e => e.Count);
+
+ @foreach (var entrada in entradas)
+ {
+ var e = entrada;
+ aoClicar.InvokeAsync(e.Key))">
+ @e.Label
+
+ @e.Count.ToString("N0")
+
+ }
+
+ }
+ };
+
+ /// Duas séries: o volume total e, sobreposto, quanto dele é erro.
+ private IEnumerable SerieTimeline => new[]
+ {
+ new ChartSeries
+ {
+ Title = "Eventos",
+ Type = ChartSeriesType.Column,
+ Points = Stats.Timeline.Select(b => new ChartDataPoint
+ {
+ Category = b.Start.ToString(FormatoDaFatia),
+ Value = b.Total,
+ }).ToList(),
+ },
+ new ChartSeries
+ {
+ Title = "Erros",
+ Type = ChartSeriesType.Column,
+ Points = Stats.Timeline.Select(b => new ChartDataPoint
+ {
+ Category = b.Start.ToString(FormatoDaFatia),
+ Value = b.Errors,
+ }).ToList(),
+ },
+ };
+
+ /// O eixo mostra só o que distingue as fatias: hora para períodos curtos, data para longos.
+ private string FormatoDaFatia => Stats.BucketSize >= TimeSpan.FromDays(1) ? "dd/MM"
+ : Stats.BucketSize >= TimeSpan.FromHours(1) ? "dd/MM HH'h'"
+ : "HH:mm";
+
+ private string RotuloFatia
+ {
+ get
+ {
+ var b = Stats.BucketSize;
+ if (b <= TimeSpan.Zero) return "—";
+ if (b >= TimeSpan.FromDays(1)) return $"por {b.TotalDays:0.#} dia(s)";
+ if (b >= TimeSpan.FromHours(1)) return $"por {b.TotalHours:0.#} hora(s)";
+ if (b >= TimeSpan.FromMinutes(1)) return $"por {b.TotalMinutes:0.#} minuto(s)";
+ return $"por {Math.Max(1, b.TotalSeconds):0.#} segundo(s)";
+ }
+ }
+}
diff --git a/src/Components/LogToolbar.razor b/src/Components/LogToolbar.razor
index eade214..b6d3e5d 100644
--- a/src/Components/LogToolbar.razor
+++ b/src/Components/LogToolbar.razor
@@ -23,8 +23,40 @@
Disabled="@(TotalCount == 0)"
Title="Exporta os eventos filtrados (não apenas a página atual)"
OnClick="OnExport" />
-
+
+
+
+
+
+ @* Só faz sentido quando há um detalhe aberto para reposicionar. O ícone aponta
+ para ONDE o painel vai, e não onde ele está — a lib não tem ícones de painel,
+ e a seta remove a ambiguidade. *@
+ @if (DetailVisible)
+ {
+
+ }
+ @* OmniPagination é zero-based; o app conta a partir de 1 (é o que aparece no
+ "X-Y de Z"). Sem a conversão, a página 1 era exibida como "2" e a última
+ ficava inalcançável. A tradução fica aqui, na fronteira com o componente. *@
+
@@ -42,4 +74,15 @@
[Parameter] public EventCallback OnToggleTail { get; set; }
[Parameter] public bool IsLoading { get; set; }
[Parameter] public EventCallback OnCancelLoad { get; set; }
+
+ [Parameter] public LogViewMode ViewMode { get; set; } = LogViewMode.List;
+ [Parameter] public EventCallback OnSetViewMode { get; set; }
+
+ private ButtonVariant VarianteModo(LogViewMode modo) =>
+ ViewMode == modo ? ButtonVariant.Primary : ButtonVariant.Ghost;
+
+ /// Há um evento selecionado (o painel de detalhes está aberto).
+ [Parameter] public bool DetailVisible { get; set; }
+ [Parameter] public bool DetailAtBottom { get; set; }
+ [Parameter] public EventCallback OnToggleDetailPosition { get; set; }
}
diff --git a/src/Components/LogViewer.razor b/src/Components/LogViewer.razor
index 5f4adf0..0980e98 100644
--- a/src/Components/LogViewer.razor
+++ b/src/Components/LogViewer.razor
@@ -1,6 +1,7 @@
@using System.Threading
@using ClefExplorer.Models
@using ClefExplorer.Services
+@using ClefExplorer.Helpers
@implements IDisposable
@inject LogStore Store
@@ -10,6 +11,7 @@
@inject SettingsService SettingsService
@inject DialogService Dialog
@inject NotificationService Notifications
+@inject UiPreferencesService UiPreferences
@inject IJSRuntime JS
- @* Sem seleção: lista ocupa todo o espaço (sem splitter, evitando a
- race de medição do OmniSplitter aninhado no estado de 1 pane).
- Com seleção: splitter lista|detalhe — criado quando o pane pai já
- tem largura, então a medição funciona. *@
+ @* Sem seleção: o miolo ocupa todo o espaço, SEM splitter. Manter o
+ splitter sempre montado (com o pane do detalhe colapsado) foi
+ tentado e reintroduz a race de medição: o splitter aninhado mede
+ 0px e o conteúdo some.
+
+ O @key na orientação: o splitter mede e fixa os tamanhos dos panes
+ na inicialização, então reaproveitar a instância deixaria as
+ medidas da disposição anterior aplicadas na nova. *@
@if (_selected != null)
{
-
-
+
+
-
+ @RenderEventos()
-
+
@@ -88,10 +98,7 @@
else
{
-
+ @RenderEventos()
}
@@ -101,7 +108,35 @@
+@* Lista ou tabela conforme a preferência — o resto do layout (splitter, detalhe) é o
+ mesmo nos dois modos, então só o miolo troca. *@
@code {
+ private RenderFragment RenderEventos() => __builder =>
+ {
+ if (ModoEstatisticas)
+ {
+
+ }
+ else if (ModoTabela)
+ {
+
+ }
+ else
+ {
+
+ }
+ };
+
private ClefEvent? _selected;
private bool _isBusy;
private List _todosEventos = new();
@@ -499,6 +534,10 @@
_todosEventos = result;
if (_pagina > UltimaPagina) _pagina = 1;
AtualizarPagina();
+ // Colunas e estatísticas derivam do conjunto, então mudam junto com ele.
+ // Só recalcula no modo que as usa — nos demais seria trabalho jogado fora.
+ if (ModoTabela) DescobrirColunas();
+ if (ModoEstatisticas) CalcularEstatisticas();
_isBusy = false;
StateHasChanged();
});
@@ -572,6 +611,92 @@
}
}
+ /// Painel de detalhes abaixo da lista (em vez de à direita).
+ private bool DetalheAbaixo => UiPreferences.Preferences.DetailPanelPosition == DetailPanelPosition.Bottom;
+
+ private bool ModoTabela => UiPreferences.Preferences.ViewMode == LogViewMode.Grid;
+ private bool ModoEstatisticas => UiPreferences.Preferences.ViewMode == LogViewMode.Stats;
+
+ private LogStats _stats = new();
+
+ ///
+ /// Colunas visíveis na tabela. Vazio (primeira abertura) cai no conjunto padrão — o
+ /// usuário nunca vê uma tabela sem colunas.
+ ///
+ private HashSet ColunasVisiveis
+ {
+ get
+ {
+ var salvas = UiPreferences.Preferences.GridVisibleColumns;
+ return new HashSet(
+ salvas.Count > 0 ? salvas : LogGridColumns.Defaults,
+ StringComparer.OrdinalIgnoreCase);
+ }
+ }
+
+ private void SalvarColunasVisiveis(HashSet colunas) =>
+ UiPreferences.SetGridVisibleColumns(colunas);
+
+ private IReadOnlyList _colunasDescobertas = Array.Empty();
+
+ /// Troca o modo de visualização, persistindo a escolha.
+ private void DefinirModoVisualizacao(LogViewMode modo)
+ {
+ UiPreferences.SetViewMode(modo);
+
+ // Colunas e estatísticas vêm do conjunto atual; só são calculadas no modo que
+ // as usa, para não gastar trabalho à toa nos demais.
+ if (ModoTabela) DescobrirColunas();
+ if (ModoEstatisticas) CalcularEstatisticas();
+ }
+
+ ///
+ /// Agrega o conjunto FILTRADO inteiro — não a página. As estatísticas precisam refletir
+ /// tudo o que o filtro selecionou, senão responderiam sobre uma amostra arbitrária.
+ ///
+ private void CalcularEstatisticas()
+ {
+ try
+ {
+ _stats = LogStatistics.Compute(_todosEventos);
+ }
+ catch (Exception ex)
+ {
+ AppLog.Error("Falha ao calcular as estatísticas", ex);
+ _stats = new LogStats();
+ }
+ }
+
+ /// Clique num nível do painel: passa a filtrar só por ele.
+ private void FiltrarPorNivel(string nivel)
+ {
+ NiveisSelecionados = new HashSet(new[] { nivel }, StringComparer.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Deriva as colunas de propriedade do conteúdo carregado. Roda sobre o resultado
+ /// filtrado inteiro (não a página), para uma propriedade que só aparece em eventos de
+ /// outras páginas não ficar de fora.
+ ///
+ private void DescobrirColunas()
+ {
+ try
+ {
+ _colunasDescobertas = LogColumnDiscovery.Discover(_todosEventos);
+ }
+ catch (Exception ex)
+ {
+ AppLog.Warning("Falha ao descobrir colunas a partir do conteúdo dos logs", ex);
+ _colunasDescobertas = Array.Empty();
+ }
+ }
+
+ ///
+ /// Alterna entre detalhe à direita e abaixo. À direita funciona melhor em telas largas;
+ /// abaixo, para stack traces longos e telas estreitas. A escolha é persistida.
+ ///
+ private void AlternarPosicaoDetalhe() => UiPreferences.ToggleDetailPanelPosition();
+
/// Interrompe o carregamento em andamento, preservando o conteúdo anterior.
private void CancelarCarregamento()
{
diff --git a/src/Helpers/LogColumnDiscovery.cs b/src/Helpers/LogColumnDiscovery.cs
new file mode 100644
index 0000000..844bbcd
--- /dev/null
+++ b/src/Helpers/LogColumnDiscovery.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using ClefExplorer.Models;
+using Serilog.Events;
+
+namespace ClefExplorer.Helpers
+{
+ /// Uma coluna derivada de uma propriedade estruturada do log.
+ /// Nome da propriedade no evento (ex.: SourceContext).
+ /// Rótulo exibido no cabeçalho.
+ /// Fração dos eventos amostrados que possuem a propriedade (0..1).
+ public sealed record DiscoveredColumn(string Key, string Title, double Frequency);
+
+ ///
+ /// Deriva colunas do CONTEÚDO dos logs.
+ ///
+ /// Logs CLEF carregam propriedades estruturadas (SourceContext,
+ /// RequestId, MachineName…) que variam conforme a aplicação que os
+ /// gerou. Em vez de fixar uma lista, amostramos os eventos carregados e oferecemos como
+ /// coluna as propriedades que aparecem com frequência — o usuário liga/desliga cada uma
+ /// pelo menu de colunas.
+ ///
+ public static class LogColumnDiscovery
+ {
+ /// Quantos eventos são inspecionados. Amostra basta e evita varrer milhões de linhas.
+ public const int DefaultSampleSize = 2_000;
+
+ /// Máximo de colunas sugeridas, para o menu não virar uma lista interminável.
+ public const int DefaultMaxColumns = 15;
+
+ /// Fração mínima de eventos com a propriedade para ela virar coluna.
+ public const double DefaultMinFrequency = 0.05;
+
+ ///
+ /// Propriedades que já têm coluna própria ou que não agregam nada numa tabela.
+ /// Comparação sem diferenciar maiúsculas, como o resto do tratamento de propriedades.
+ ///
+ private static readonly HashSet Excluded = new(StringComparer.OrdinalIgnoreCase)
+ {
+ // Já exibidas em colunas fixas.
+ "SourceFile", "Level", "Message", "MessageTemplate", "Timestamp", "Exception",
+ // Ruído do Serilog: o template renderizado já está na coluna Mensagem.
+ "SourceContextTemplate",
+ };
+
+ public static IReadOnlyList Discover(
+ IEnumerable events,
+ int sampleSize = DefaultSampleSize,
+ int maxColumns = DefaultMaxColumns,
+ double minFrequency = DefaultMinFrequency)
+ {
+ ArgumentNullException.ThrowIfNull(events);
+
+ var contagem = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var amostrados = 0;
+
+ foreach (var ev in events.Take(sampleSize))
+ {
+ amostrados++;
+ if (ev.Properties is null) continue;
+
+ foreach (var chave in ev.Properties.Keys)
+ {
+ if (string.IsNullOrWhiteSpace(chave) || Excluded.Contains(chave)) continue;
+ contagem[chave] = contagem.GetValueOrDefault(chave) + 1;
+ }
+ }
+
+ if (amostrados == 0) return Array.Empty();
+
+ return contagem
+ .Select(p => new DiscoveredColumn(p.Key, Humanize(p.Key), p.Value / (double)amostrados))
+ .Where(c => c.Frequency >= minFrequency)
+ // Mais frequentes primeiro; nome como desempate, para a ordem ser estável
+ // entre carregamentos (senão as colunas dançariam a cada abertura).
+ .OrderByDescending(c => c.Frequency)
+ .ThenBy(c => c.Key, StringComparer.OrdinalIgnoreCase)
+ .Take(maxColumns)
+ .ToList();
+ }
+
+ ///
+ /// "RequestId" → "Request Id". Nomes de propriedade vêm em PascalCase do código que
+ /// emitiu o log; separá-los deixa o cabeçalho legível.
+ ///
+ public static string Humanize(string key)
+ {
+ if (string.IsNullOrEmpty(key)) return key;
+
+ var sb = new System.Text.StringBuilder(key.Length + 4);
+ for (var i = 0; i < key.Length; i++)
+ {
+ var c = key[i];
+ var anterior = i > 0 ? key[i - 1] : '\0';
+ var proximo = i + 1 < key.Length ? key[i + 1] : '\0';
+
+ // Espaço antes de uma maiúscula que inicia palavra — inclusive no fim de uma
+ // sigla ("HTTPRequest" → "HTTP Request").
+ var iniciaPalavra = char.IsUpper(c)
+ && i > 0
+ && (!char.IsUpper(anterior) || (char.IsUpper(anterior) && char.IsLower(proximo)));
+
+ if (iniciaPalavra) sb.Append(' ');
+ sb.Append(c);
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Texto da célula para uma propriedade estruturada.
+ /// envolve strings em aspas — indesejado numa
+ /// tabela, onde a coluna já dá o contexto.
+ ///
+ public static string FormatValue(ClefEvent ev, string key)
+ {
+ if (ev.Properties is null || !ev.Properties.TryGetValue(key, out var valor)) return string.Empty;
+
+ return valor switch
+ {
+ null => string.Empty,
+ ScalarValue s => s.Value?.ToString() ?? string.Empty,
+ _ => valor.ToString(),
+ };
+ }
+ }
+}
diff --git a/src/Helpers/LogGridColumns.cs b/src/Helpers/LogGridColumns.cs
new file mode 100644
index 0000000..c97b8f3
--- /dev/null
+++ b/src/Helpers/LogGridColumns.cs
@@ -0,0 +1,36 @@
+using System.Collections.Generic;
+
+namespace ClefExplorer.Helpers
+{
+ ///
+ /// Colunas fixas da visão em tabela. As demais são descobertas em tempo de execução
+ /// pelo , a partir do conteúdo dos logs carregados.
+ ///
+ public static class LogGridColumns
+ {
+ public const string Timestamp = "Timestamp";
+ public const string Level = "Level";
+ public const string Message = "Message";
+ public const string SourceFile = "SourceFile";
+ public const string Exception = "Exception";
+
+ /// Colunas fixas, na ordem em que aparecem na tabela.
+ public static readonly IReadOnlyList<(string Key, string Title)> Fixed = new[]
+ {
+ (Timestamp, "Data/hora"),
+ (Level, "Nível"),
+ (Message, "Mensagem"),
+ (SourceFile, "Arquivo"),
+ (Exception, "Exceção"),
+ };
+
+ ///
+ /// Visíveis na primeira abertura. Exceção fica de fora: é útil, mas polui a tabela
+ /// para quem só quer ler as mensagens — e está a um clique de distância.
+ ///
+ public static IReadOnlyList Defaults { get; } = new[]
+ {
+ Timestamp, Level, Message, SourceFile,
+ };
+ }
+}
diff --git a/src/Helpers/LogStatistics.cs b/src/Helpers/LogStatistics.cs
new file mode 100644
index 0000000..bebee27
--- /dev/null
+++ b/src/Helpers/LogStatistics.cs
@@ -0,0 +1,218 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using ClefExplorer.Models;
+using ClefExplorer.Services;
+using Serilog.Events;
+
+namespace ClefExplorer.Helpers
+{
+ /// Um item de ranking (nível, mensagem, exceção, origem…) com sua contagem.
+ /// Valor usado para filtrar ao clicar.
+ /// Texto exibido.
+ /// Quantidade de eventos.
+ public sealed record StatEntry(string Key, string Label, int Count);
+
+ /// Uma fatia do histograma temporal.
+ /// Início do intervalo.
+ /// Eventos no intervalo.
+ /// Quantos deles são Error/Fatal — é o que revela os picos.
+ public sealed record TimeBucket(DateTimeOffset Start, int Total, int Errors);
+
+ /// Visão agregada do conjunto filtrado.
+ public sealed class LogStats
+ {
+ public int Total { get; init; }
+ public IReadOnlyList ByLevel { get; init; } = Array.Empty();
+ public IReadOnlyList TopMessages { get; init; } = Array.Empty();
+ public IReadOnlyList TopExceptions { get; init; } = Array.Empty();
+ public IReadOnlyList TopSources { get; init; } = Array.Empty();
+ public IReadOnlyList Timeline { get; init; } = Array.Empty();
+
+ /// Tamanho de cada fatia da timeline, para rotular o eixo.
+ public TimeSpan BucketSize { get; init; }
+
+ public int ErrorCount => ByLevel
+ .Where(e => e.Key is "Error" or "Fatal")
+ .Sum(e => e.Count);
+ }
+
+ ///
+ /// Calcula a visão agregada dos eventos filtrados: o que transforma o app de leitor em
+ /// analisador. Puro e sem dependência de UI, para poder ser testado direto.
+ ///
+ public static class LogStatistics
+ {
+ /// Quantas entradas cada ranking traz.
+ public const int DefaultTopCount = 8;
+
+ /// Fatias desejadas na timeline. O tamanho de cada uma sai do período coberto.
+ public const int DefaultBuckets = 40;
+
+ /// Ordem de exibição dos níveis: do mais grave ao mais verboso.
+ private static readonly string[] LevelOrder = LogFilter.AllLevels;
+
+ public static LogStats Compute(IReadOnlyList events, int topCount = DefaultTopCount, int buckets = DefaultBuckets)
+ {
+ ArgumentNullException.ThrowIfNull(events);
+ // buckets = 0 dividiria por zero em MontarTimeline e criaria um vetor vazio,
+ // e um topCount não positivo devolveria rankings sempre vazios. Falhar aqui é
+ // mais claro do que a exceção que apareceria lá dentro.
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(topCount);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(buckets);
+
+ if (events.Count == 0) return new LogStats();
+
+ var (timeline, bucketSize) = MontarTimeline(events, buckets);
+
+ return new LogStats
+ {
+ Total = events.Count,
+ ByLevel = ContarPorNivel(events),
+ TopMessages = TopPor(events, MensagemAgrupavel, topCount),
+ TopExceptions = TopPor(events.Where(e => !string.IsNullOrEmpty(e.Exception)).ToList(), TipoDaExcecao, topCount),
+ TopSources = TopPor(events, OrigemDoEvento, topCount),
+ Timeline = timeline,
+ BucketSize = bucketSize,
+ };
+ }
+
+ // --- Níveis ------------------------------------------------------------------
+
+ private static IReadOnlyList ContarPorNivel(IReadOnlyList events)
+ {
+ var contagem = events
+ .GroupBy(e => e.Level ?? "Information", StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
+
+ // Ordem fixa por gravidade, e não por contagem: a lista fica estável entre
+ // filtragens, então o olho encontra "Error" sempre no mesmo lugar.
+ var ordenados = LevelOrder
+ .Where(contagem.ContainsKey)
+ .Select(nivel => new StatEntry(nivel, nivel, contagem[nivel]))
+ .ToList();
+
+ // Níveis fora da lista conhecida (log de terceiro com nome próprio) vão ao fim.
+ ordenados.AddRange(contagem
+ .Where(kv => !LevelOrder.Contains(kv.Key, StringComparer.OrdinalIgnoreCase))
+ .OrderByDescending(kv => kv.Value)
+ .Select(kv => new StatEntry(kv.Key, kv.Key, kv.Value)));
+
+ return ordenados;
+ }
+
+ // --- Rankings ----------------------------------------------------------------
+
+ private static IReadOnlyList TopPor(
+ IReadOnlyList events,
+ Func seletor,
+ int topCount)
+ {
+ return events
+ .Select(seletor)
+ .Where(x => x is not null)
+ .Select(x => x!.Value)
+ .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
+ .Select(g => new StatEntry(g.Key, g.First().Label, g.Count()))
+ .OrderByDescending(e => e.Count)
+ .ThenBy(e => e.Label, StringComparer.OrdinalIgnoreCase)
+ .Take(topCount)
+ .ToList();
+ }
+
+ ///
+ /// Agrupa pelo TEMPLATE, não pela mensagem renderizada: "Pedido 1 processado" e
+ /// "Pedido 2 processado" são a mesma ocorrência com parâmetros diferentes, e contá-las
+ /// separadamente esconderia justamente o que mais se repete.
+ ///
+ private static (string, string)? MensagemAgrupavel(ClefEvent e)
+ {
+ var chave = !string.IsNullOrWhiteSpace(e.MessageTemplate) ? e.MessageTemplate : e.Message;
+ return string.IsNullOrWhiteSpace(chave) ? null : (chave, chave);
+ }
+
+ ///
+ /// Primeira linha da exceção — normalmente "Namespace.TipoException: mensagem".
+ /// O stack trace inteiro seria único por ocorrência e não agruparia nada.
+ ///
+ private static (string, string)? TipoDaExcecao(ClefEvent e)
+ {
+ if (string.IsNullOrWhiteSpace(e.Exception)) return null;
+
+ var primeiraLinha = e.Exception
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries)
+ .FirstOrDefault()?
+ .Trim();
+
+ return string.IsNullOrWhiteSpace(primeiraLinha) ? null : (primeiraLinha, primeiraLinha);
+ }
+
+ ///
+ /// Origem do evento: o SourceContext (a classe que logou) quando existe;
+ /// senão o arquivo. É o que responde "de onde vem esse barulho todo".
+ ///
+ private static (string, string)? OrigemDoEvento(ClefEvent e)
+ {
+ if (e.Properties is not null
+ && e.Properties.TryGetValue("SourceContext", out var ctx)
+ && ctx is ScalarValue { Value: string s }
+ && !string.IsNullOrWhiteSpace(s))
+ {
+ return (s, s);
+ }
+
+ if (!string.IsNullOrEmpty(e.SourceFile))
+ {
+ return (e.SourceFile, System.IO.Path.GetFileName(e.SourceFile));
+ }
+
+ return null;
+ }
+
+ // --- Timeline -----------------------------------------------------------------
+
+ private static (IReadOnlyList Timeline, TimeSpan BucketSize) MontarTimeline(
+ IReadOnlyList events, int buckets)
+ {
+ var comHora = events.Where(e => e.Timestamp.HasValue).ToList();
+ if (comHora.Count == 0) return (Array.Empty(), TimeSpan.Zero);
+
+ var inicio = comHora.Min(e => e.Timestamp!.Value);
+ var fim = comHora.Max(e => e.Timestamp!.Value);
+ var periodo = fim - inicio;
+
+ // Tudo no mesmo instante (ou um evento só): uma fatia basta.
+ if (periodo <= TimeSpan.Zero)
+ {
+ return (new[] { new TimeBucket(inicio, comHora.Count, ContarErros(comHora)) }, TimeSpan.FromSeconds(1));
+ }
+
+ var bucketSize = TimeSpan.FromTicks(Math.Max(1, periodo.Ticks / buckets));
+
+ var porFatia = new int[buckets];
+ var errosPorFatia = new int[buckets];
+
+ foreach (var e in comHora)
+ {
+ var offset = (e.Timestamp!.Value - inicio).Ticks / bucketSize.Ticks;
+ // O evento mais recente cairia em `buckets`; encaixa na última fatia.
+ var indice = (int)Math.Min(offset, buckets - 1);
+
+ porFatia[indice]++;
+ if (EhErro(e)) errosPorFatia[indice]++;
+ }
+
+ var timeline = Enumerable.Range(0, buckets)
+ .Select(i => new TimeBucket(inicio + TimeSpan.FromTicks(bucketSize.Ticks * i), porFatia[i], errosPorFatia[i]))
+ .ToList();
+
+ return (timeline, bucketSize);
+ }
+
+ private static int ContarErros(IEnumerable events) => events.Count(EhErro);
+
+ private static bool EhErro(ClefEvent e) =>
+ string.Equals(e.Level, "Error", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(e.Level, "Fatal", StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/Models/UiEnums.cs b/src/Models/UiEnums.cs
new file mode 100644
index 0000000..40c233c
--- /dev/null
+++ b/src/Models/UiEnums.cs
@@ -0,0 +1,25 @@
+namespace ClefExplorer.Models
+{
+ /// Como os eventos são apresentados.
+ public enum LogViewMode
+ {
+ /// Lista compacta (padrão): uma linha por evento, com a mensagem em destaque.
+ List = 0,
+
+ /// Tabela com colunas: permite ordenar, agrupar e escolher as colunas exibidas.
+ Grid = 1,
+
+ /// Visão agregada do conjunto filtrado: contagens, rankings e timeline.
+ Stats = 2,
+ }
+
+ /// Onde o painel de detalhes aparece em relação à lista de eventos.
+ public enum DetailPanelPosition
+ {
+ /// Ao lado da lista (padrão). Bom para mensagens curtas e telas largas.
+ Right = 0,
+
+ /// Abaixo da lista. Bom para stack traces longos e telas estreitas.
+ Bottom = 1,
+ }
+}
diff --git a/src/Program.cs b/src/Program.cs
index b778634..c75e131 100644
--- a/src/Program.cs
+++ b/src/Program.cs
@@ -35,6 +35,7 @@ static void Main(string[] args)
services.AddOmniComponents();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/Services/FileAssociationService.cs b/src/Services/FileAssociationService.cs
index 0c32768..bb0c038 100644
--- a/src/Services/FileAssociationService.cs
+++ b/src/Services/FileAssociationService.cs
@@ -101,7 +101,9 @@ private void RegisterAppId(string exePath)
using var key = Registry.CurrentUser.CreateSubKey($@"Software\Classes\{AppId}");
if (key != null)
{
- key.SetValue(null, "Reader Log File");
+ // Mesmo rótulo declarado no Package.appxmanifest, para o tipo de arquivo
+ // aparecer igual na versão instalada e na avulsa.
+ key.SetValue(null, "Clef Log File");
key.SetValue("Icon", $"\"{exePath}\",0");
using var shell = key.CreateSubKey("shell");
diff --git a/src/Services/UiPreferencesService.cs b/src/Services/UiPreferencesService.cs
new file mode 100644
index 0000000..f96be69
--- /dev/null
+++ b/src/Services/UiPreferencesService.cs
@@ -0,0 +1,111 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ClefExplorer.Models;
+
+namespace ClefExplorer.Services
+{
+ /// Preferências de layout da interface, preservadas entre execuções.
+ public class UiPreferences
+ {
+ public DetailPanelPosition DetailPanelPosition { get; set; } = DetailPanelPosition.Right;
+
+ public LogViewMode ViewMode { get; set; } = LogViewMode.List;
+
+ ///
+ /// Colunas visíveis no modo tabela, por chave (fixas e descobertas). Guardamos por
+ /// nome, e não por posição: as colunas disponíveis vêm do conteúdo dos logs
+ /// carregados, então mudam conforme os arquivos abertos. Lista vazia = ainda não
+ /// escolhido, usa o padrão.
+ ///
+ public List GridVisibleColumns { get; set; } = new();
+ }
+
+ ///
+ /// Persiste preferências de interface em ui.json.
+ ///
+ /// Ficam fora do settings.json de propósito: o
+ /// dispara Changed ao salvar, e o reage a esse evento
+ /// recarregando todos os arquivos. Uma preferência puramente visual não pode custar um
+ /// recarregamento completo dos logs.
+ ///
+ public class UiPreferencesService
+ {
+ private const string FileName = "ui.json";
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ // Grava "Right"/"Bottom" em vez de 0/1: o arquivo é editável à mão e um número
+ // não diria nada a quem o abrisse.
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ private readonly AppStorage _storage;
+ private UiPreferences _preferences;
+
+ public UiPreferencesService(AppStorage storage)
+ {
+ _storage = storage;
+ _preferences = Load();
+ }
+
+ public UiPreferences Preferences => _preferences;
+
+ private UiPreferences Load()
+ {
+ try
+ {
+ var json = _storage.ReadText(FileName);
+ return json is null ? new UiPreferences() : JsonSerializer.Deserialize(json, JsonOptions) ?? new UiPreferences();
+ }
+ catch (Exception ex)
+ {
+ // Preferência visual: cair no padrão é aceitável, sem incomodar o usuário.
+ AppLog.Warning("Não foi possível ler as preferências de interface", ex);
+ return new UiPreferences();
+ }
+ }
+
+ public void Save()
+ {
+ try
+ {
+ _storage.WriteText(FileName, JsonSerializer.Serialize(_preferences, JsonOptions));
+ }
+ catch (Exception ex)
+ {
+ AppLog.Warning("Não foi possível salvar as preferências de interface", ex);
+ }
+ }
+
+ /// Alterna a posição do painel de detalhes e persiste a escolha.
+ public DetailPanelPosition ToggleDetailPanelPosition()
+ {
+ _preferences.DetailPanelPosition = _preferences.DetailPanelPosition == DetailPanelPosition.Right
+ ? DetailPanelPosition.Bottom
+ : DetailPanelPosition.Right;
+
+ Save();
+ return _preferences.DetailPanelPosition;
+ }
+
+ /// Define o modo de visualização e persiste a escolha.
+ public void SetViewMode(LogViewMode mode)
+ {
+ if (_preferences.ViewMode == mode) return;
+
+ _preferences.ViewMode = mode;
+ Save();
+ }
+
+ /// Grava quais colunas ficam visíveis no modo tabela.
+ public void SetGridVisibleColumns(IEnumerable keys)
+ {
+ _preferences.GridVisibleColumns = keys.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ Save();
+ }
+ }
+}
diff --git a/src/wwwroot/css/app.css b/src/wwwroot/css/app.css
index dd2919a..415487c 100644
--- a/src/wwwroot/css/app.css
+++ b/src/wwwroot/css/app.css
@@ -552,15 +552,21 @@ span.omni-tree-text.clef-tree-backup { font-weight: 600; }
flex-direction: column;
gap: 8px;
}
+/* É um (foco + teclado); daí o reset de aparência e o width/text-align. */
.clef-group-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
+ width: 100%;
padding: 8px 10px;
border-radius: var(--omni-radius-sm);
cursor: pointer;
border: 1px solid transparent;
+ background: transparent;
+ color: var(--omni-fg);
+ font: inherit;
+ text-align: left;
}
.clef-group-item:hover { background: var(--omni-bg-sunken); }
.clef-group-item.is-active {
@@ -607,3 +613,163 @@ span.omni-tree-text.clef-tree-backup { font-weight: 600; }
.clef-path-list::-webkit-scrollbar-thumb:hover {
background: var(--omni-fg-soft);
}
+
+/* ---- Visão em tabela (OmniDataGrid) --------------------------------------- */
+.clef-grid {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ background: var(--omni-bg);
+}
+
+/* Embed="true" faz o grid preencher o pai; garante o mesmo aqui. */
+.clef-grid > .omni-datagrid {
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
+/* Linha do evento selecionado: mesma leitura da lista. */
+.clef-grid .omni-datagrid tbody tr.is-selected {
+ background: color-mix(in oklab, var(--omni-accent) 14%, var(--omni-bg));
+}
+
+/* Barra à esquerda em erros/fatais, como na lista — cor, e não só o badge,
+ para o erro saltar mesmo com a coluna Nível oculta. */
+.clef-grid .omni-datagrid tbody tr.clef-grid-row-error td:first-child {
+ box-shadow: inset 3px 0 0 0 color-mix(in oklab, var(--omni-danger) 80%, var(--omni-fg));
+}
+
+.clef-grid .clef-grid-exc {
+ color: color-mix(in oklab, var(--omni-danger) 80%, var(--omni-fg));
+}
+
+/* Seletor de colunas da tabela (próprio, não o embutido do grid). */
+.clef-col-chooser {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ max-height: 320px;
+ overflow-y: auto;
+}
+
+.clef-col-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 8px;
+ border-radius: var(--omni-radius);
+ font-size: 13px;
+ color: var(--omni-fg);
+ cursor: pointer;
+}
+
+.clef-col-item:hover { background: var(--omni-bg-sunken); }
+.clef-col-item input { cursor: pointer; }
+
+/* ---- Foco visível --------------------------------------------------------- */
+/* Nenhum dos elementos clicáveis próprios (clef-*) tinha indicador de foco: quem
+ navega por teclado não sabia onde estava. :focus-visible mostra o anel apenas na
+ navegação por teclado, sem poluir o clique de mouse. */
+.clef-list-item:focus-visible,
+.clef-filter:focus-visible,
+.clef-group-item:focus-visible,
+.clef-regex-toggle:focus-visible,
+.clef-col-item:focus-within {
+ outline: 2px solid var(--omni-accent);
+ outline-offset: -2px;
+}
+
+/* ---- Painel de estatísticas ----------------------------------------------- */
+.clef-stats {
+ height: 100%;
+ overflow-y: auto;
+ padding: 12px;
+ background: var(--omni-bg);
+}
+
+/* Cartões fluem em colunas; os largos (timeline, exceções) ocupam a linha toda. */
+.clef-stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: 12px;
+ align-items: start;
+}
+
+.clef-stats-wide { grid-column: 1 / -1; }
+
+.clef-stat-numbers {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+ margin-bottom: 14px;
+}
+
+.clef-stat-big { display: flex; flex-direction: column; }
+
+.clef-stat-value {
+ font-size: 24px;
+ font-weight: 700;
+ line-height: 1.1;
+ color: var(--omni-fg);
+}
+
+.clef-stat-big.is-error .clef-stat-value {
+ color: color-mix(in oklab, var(--omni-danger) 80%, var(--omni-fg));
+}
+
+.clef-stat-label {
+ font-size: 11.5px;
+ color: var(--omni-fg-muted);
+}
+
+.clef-stat-bars { display: flex; flex-direction: column; gap: 2px; }
+
+/* Cada linha é um : clicar aplica o filtro correspondente. */
+.clef-stat-row {
+ display: grid;
+ grid-template-columns: minmax(90px, 34%) 1fr auto;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 4px 6px;
+ border: 0;
+ background: transparent;
+ border-radius: var(--omni-radius);
+ cursor: pointer;
+ font: inherit;
+ color: var(--omni-fg);
+ text-align: left;
+}
+
+.clef-stat-row:hover { background: var(--omni-bg-sunken); }
+
+.clef-stat-name {
+ font-size: 12.5px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.clef-stat-track {
+ height: 8px;
+ border-radius: 999px;
+ background: var(--omni-bg-sunken);
+ overflow: hidden;
+}
+
+.clef-stat-fill {
+ display: block;
+ height: 100%;
+ border-radius: 999px;
+ background: var(--omni-accent);
+}
+
+.clef-stat-count {
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+ color: var(--omni-fg-muted);
+}
+
+/* Grupo de botões do modo de visualização. */
+.clef-viewmode { display: flex; gap: 2px; }
diff --git a/test/ClefExplorer.Tests/LogColumnDiscoveryTests.cs b/test/ClefExplorer.Tests/LogColumnDiscoveryTests.cs
new file mode 100644
index 0000000..40f4ecc
--- /dev/null
+++ b/test/ClefExplorer.Tests/LogColumnDiscoveryTests.cs
@@ -0,0 +1,182 @@
+using ClefExplorer.Helpers;
+using ClefExplorer.Models;
+using Serilog.Events;
+
+namespace ClefExplorer.Tests;
+
+///
+/// Descoberta de colunas a partir do conteúdo dos logs. Cada aplicação emite um conjunto
+/// próprio de propriedades estruturadas, então as colunas não podem ser fixadas na mão.
+///
+public class LogColumnDiscoveryTests
+{
+ private static ClefEvent Event(params (string Key, object Value)[] props)
+ {
+ var ev = new ClefEvent
+ {
+ Level = "Information",
+ Timestamp = DateTimeOffset.UtcNow,
+ Properties = new Dictionary(StringComparer.OrdinalIgnoreCase),
+ };
+
+ foreach (var (key, value) in props)
+ {
+ ev.Properties![key] = new ScalarValue(value);
+ }
+
+ return ev;
+ }
+
+ // --- Descoberta --------------------------------------------------------------
+
+ [Fact]
+ public void Discovers_properties_present_in_the_events()
+ {
+ var eventos = new[]
+ {
+ Event(("SourceContext", "Api.Pedido"), ("RequestId", "req-1")),
+ Event(("SourceContext", "Api.Pagamento"), ("RequestId", "req-2")),
+ };
+
+ var colunas = LogColumnDiscovery.Discover(eventos);
+
+ Assert.Contains(colunas, c => c.Key == "SourceContext");
+ Assert.Contains(colunas, c => c.Key == "RequestId");
+ }
+
+ [Fact]
+ public void Orders_by_frequency_so_the_most_useful_come_first()
+ {
+ var eventos = new[]
+ {
+ Event(("Comum", 1), ("Raro", 1)),
+ Event(("Comum", 2)),
+ Event(("Comum", 3)),
+ };
+
+ var colunas = LogColumnDiscovery.Discover(eventos);
+
+ Assert.Equal("Comum", colunas[0].Key);
+ }
+
+ [Fact]
+ public void Ignores_properties_that_are_too_rare_to_be_worth_a_column()
+ {
+ // 1 evento em 100 com a propriedade: abaixo do mínimo de 5%.
+ var eventos = Enumerable.Range(0, 100)
+ .Select(i => i == 0 ? Event(("QuaseNunca", 1), ("Sempre", 1)) : Event(("Sempre", 1)))
+ .ToArray();
+
+ var colunas = LogColumnDiscovery.Discover(eventos);
+
+ Assert.Contains(colunas, c => c.Key == "Sempre");
+ Assert.DoesNotContain(colunas, c => c.Key == "QuaseNunca");
+ }
+
+ [Fact]
+ public void Skips_properties_that_already_have_a_fixed_column()
+ {
+ var eventos = new[] { Event(("SourceFile", @"C:\logs\a.clef"), ("Level", "Error"), ("Util", 1)) };
+
+ var colunas = LogColumnDiscovery.Discover(eventos);
+
+ Assert.DoesNotContain(colunas, c => c.Key == "SourceFile");
+ Assert.DoesNotContain(colunas, c => c.Key == "Level");
+ Assert.Contains(colunas, c => c.Key == "Util");
+ }
+
+ [Fact]
+ public void Caps_the_number_of_columns_so_the_menu_stays_usable()
+ {
+ var muitas = Enumerable.Range(0, 50).Select(i => ($"Prop{i}", (object)i)).ToArray();
+
+ var colunas = LogColumnDiscovery.Discover(new[] { Event(muitas) }, maxColumns: 5);
+
+ Assert.Equal(5, colunas.Count);
+ }
+
+ [Fact]
+ public void The_order_is_stable_between_runs()
+ {
+ // Sem desempate estável, as colunas dançariam a cada abertura do arquivo.
+ var eventos = new[] { Event(("Bbb", 1), ("Aaa", 1), ("Ccc", 1)) };
+
+ var primeira = LogColumnDiscovery.Discover(eventos).Select(c => c.Key);
+ var segunda = LogColumnDiscovery.Discover(eventos).Select(c => c.Key);
+
+ Assert.Equal(primeira, segunda);
+ }
+
+ [Fact]
+ public void Only_the_sample_is_inspected()
+ {
+ // A propriedade só existe além da amostra: não deve virar coluna.
+ var eventos = Enumerable.Range(0, 10)
+ .Select(i => i < 5 ? Event(("Cedo", 1)) : Event(("Tarde", 1)))
+ .ToArray();
+
+ var colunas = LogColumnDiscovery.Discover(eventos, sampleSize: 5);
+
+ Assert.Contains(colunas, c => c.Key == "Cedo");
+ Assert.DoesNotContain(colunas, c => c.Key == "Tarde");
+ }
+
+ [Fact]
+ public void An_empty_set_yields_no_columns()
+ {
+ Assert.Empty(LogColumnDiscovery.Discover(Array.Empty()));
+ }
+
+ [Fact]
+ public void Events_without_properties_do_not_break_discovery()
+ {
+ var eventos = new[] { new ClefEvent { Level = "Information" }, Event(("Util", 1)) };
+
+ var colunas = LogColumnDiscovery.Discover(eventos);
+
+ Assert.Contains(colunas, c => c.Key == "Util");
+ }
+
+ // --- Rótulo -------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("RequestId", "Request Id")]
+ [InlineData("SourceContext", "Source Context")]
+ [InlineData("MachineName", "Machine Name")]
+ [InlineData("Id", "Id")]
+ [InlineData("HTTPRequest", "HTTP Request")] // sigla seguida de palavra
+ [InlineData("threadId", "thread Id")] // já começa minúsculo
+ [InlineData("", "")]
+ public void Property_names_become_readable_headers(string key, string expected)
+ {
+ Assert.Equal(expected, LogColumnDiscovery.Humanize(key));
+ }
+
+ // --- Valor da célula ----------------------------------------------------------
+
+ [Fact]
+ public void Scalar_strings_are_shown_without_the_quotes_serilog_adds()
+ {
+ var ev = Event(("SourceContext", "Api.Pedido"));
+
+ Assert.Equal("Api.Pedido", LogColumnDiscovery.FormatValue(ev, "SourceContext"));
+ }
+
+ [Fact]
+ public void Numbers_are_shown_as_is()
+ {
+ Assert.Equal("42", LogColumnDiscovery.FormatValue(Event(("PedidoId", 42)), "PedidoId"));
+ }
+
+ [Fact]
+ public void A_missing_property_yields_an_empty_cell()
+ {
+ Assert.Equal(string.Empty, LogColumnDiscovery.FormatValue(Event(("Outra", 1)), "Inexistente"));
+ }
+
+ [Fact]
+ public void An_event_without_properties_yields_an_empty_cell()
+ {
+ Assert.Equal(string.Empty, LogColumnDiscovery.FormatValue(new ClefEvent(), "Qualquer"));
+ }
+}
diff --git a/test/ClefExplorer.Tests/LogStatisticsTests.cs b/test/ClefExplorer.Tests/LogStatisticsTests.cs
new file mode 100644
index 0000000..32409f7
--- /dev/null
+++ b/test/ClefExplorer.Tests/LogStatisticsTests.cs
@@ -0,0 +1,292 @@
+using ClefExplorer.Helpers;
+using ClefExplorer.Models;
+using Serilog.Events;
+
+namespace ClefExplorer.Tests;
+
+///
+/// Agregações do painel de estatísticas — o que transforma o app de leitor em analisador.
+///
+public class LogStatisticsTests
+{
+ private static readonly DateTimeOffset Base = new(2026, 7, 5, 10, 0, 0, TimeSpan.Zero);
+
+ private static ClefEvent Event(
+ string level = "Information",
+ string? message = "mensagem",
+ string? template = null,
+ string? exception = null,
+ string? sourceContext = null,
+ string? sourceFile = null,
+ int minutosDepois = 0)
+ {
+ var ev = new ClefEvent
+ {
+ Level = level,
+ Message = message,
+ MessageTemplate = template,
+ Exception = exception,
+ SourceFile = sourceFile,
+ Timestamp = Base.AddMinutes(minutosDepois),
+ };
+
+ if (sourceContext is not null)
+ {
+ ev.Properties = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["SourceContext"] = new ScalarValue(sourceContext),
+ };
+ }
+
+ return ev;
+ }
+
+ // --- Contagem por nível --------------------------------------------------------
+
+ [Fact]
+ public void Counts_events_by_level()
+ {
+ var stats = LogStatistics.Compute(new[]
+ {
+ Event("Error"), Event("Error"), Event("Warning"), Event("Information"),
+ });
+
+ Assert.Equal(4, stats.Total);
+ Assert.Equal(2, stats.ByLevel.Single(e => e.Key == "Error").Count);
+ Assert.Equal(1, stats.ByLevel.Single(e => e.Key == "Warning").Count);
+ }
+
+ [Fact]
+ public void Levels_come_in_severity_order_not_by_count()
+ {
+ // Ordem estável: o olho procura "Error" sempre no mesmo lugar, mesmo quando
+ // Information é muito mais numeroso.
+ var eventos = Enumerable.Repeat(Event("Information"), 50)
+ .Append(Event("Error"))
+ .Append(Event("Warning"))
+ .ToArray();
+
+ var stats = LogStatistics.Compute(eventos);
+
+ Assert.Equal(new[] { "Error", "Warning", "Information" }, stats.ByLevel.Select(e => e.Key));
+ }
+
+ [Fact]
+ public void An_unknown_level_still_appears_at_the_end()
+ {
+ var stats = LogStatistics.Compute(new[] { Event("Information"), Event("Auditoria") });
+
+ Assert.Equal("Auditoria", stats.ByLevel.Last().Key);
+ }
+
+ [Fact]
+ public void ErrorCount_covers_both_Error_and_Fatal()
+ {
+ var stats = LogStatistics.Compute(new[] { Event("Error"), Event("Fatal"), Event("Warning") });
+
+ Assert.Equal(2, stats.ErrorCount);
+ }
+
+ // --- Mensagens -----------------------------------------------------------------
+
+ [Fact]
+ public void Messages_are_grouped_by_template_not_by_rendered_text()
+ {
+ // O ponto central do ranking: 3 renderizações do mesmo template são a MESMA
+ // ocorrência. Agrupar pelo texto renderizado daria três linhas de contagem 1 e
+ // esconderia justamente o que mais se repete.
+ var eventos = new[]
+ {
+ Event(message: "Pedido 1 processado", template: "Pedido {Id} processado"),
+ Event(message: "Pedido 2 processado", template: "Pedido {Id} processado"),
+ Event(message: "Pedido 3 processado", template: "Pedido {Id} processado"),
+ };
+
+ var stats = LogStatistics.Compute(eventos);
+
+ var top = Assert.Single(stats.TopMessages);
+ Assert.Equal("Pedido {Id} processado", top.Key);
+ Assert.Equal(3, top.Count);
+ }
+
+ [Fact]
+ public void Without_a_template_the_rendered_message_is_used()
+ {
+ var stats = LogStatistics.Compute(new[] { Event(message: "sem template"), Event(message: "sem template") });
+
+ Assert.Equal(2, stats.TopMessages.Single().Count);
+ }
+
+ [Fact]
+ public void Rankings_are_capped()
+ {
+ var eventos = Enumerable.Range(0, 30).Select(i => Event(message: $"msg {i}")).ToArray();
+
+ var stats = LogStatistics.Compute(eventos, topCount: 5);
+
+ Assert.Equal(5, stats.TopMessages.Count);
+ }
+
+ // --- Exceções ------------------------------------------------------------------
+
+ [Fact]
+ public void Exceptions_are_grouped_by_their_first_line()
+ {
+ // O stack trace inteiro é único por ocorrência e não agruparia nada; a primeira
+ // linha traz o tipo e a mensagem, que é o que se repete.
+ var eventos = new[]
+ {
+ Event("Error", exception: "System.TimeoutException: tempo esgotado\n at Foo.Bar()"),
+ Event("Error", exception: "System.TimeoutException: tempo esgotado\n at Outro.Metodo()"),
+ };
+
+ var stats = LogStatistics.Compute(eventos);
+
+ var top = Assert.Single(stats.TopExceptions);
+ Assert.Equal("System.TimeoutException: tempo esgotado", top.Key);
+ Assert.Equal(2, top.Count);
+ }
+
+ [Fact]
+ public void Events_without_an_exception_are_left_out_of_that_ranking()
+ {
+ var stats = LogStatistics.Compute(new[] { Event(), Event("Error", exception: "Boom: x") });
+
+ Assert.Single(stats.TopExceptions);
+ }
+
+ // --- Origens -------------------------------------------------------------------
+
+ [Fact]
+ public void Source_prefers_the_SourceContext_property()
+ {
+ var stats = LogStatistics.Compute(new[]
+ {
+ Event(sourceContext: "Api.Pedido", sourceFile: @"C:\logs\a.clef"),
+ Event(sourceContext: "Api.Pedido", sourceFile: @"C:\logs\b.clef"),
+ });
+
+ var top = Assert.Single(stats.TopSources);
+ Assert.Equal("Api.Pedido", top.Key);
+ Assert.Equal(2, top.Count);
+ }
+
+ [Fact]
+ public void Source_falls_back_to_the_file_name()
+ {
+ var stats = LogStatistics.Compute(new[] { Event(sourceFile: @"C:\logs\pedidos.clef") });
+
+ var top = Assert.Single(stats.TopSources);
+ Assert.Equal(@"C:\logs\pedidos.clef", top.Key); // a chave filtra pelo caminho
+ Assert.Equal("pedidos.clef", top.Label); // o rótulo mostra só o nome
+ }
+
+ // --- Timeline ------------------------------------------------------------------
+
+ [Fact]
+ public void The_timeline_has_the_requested_number_of_buckets()
+ {
+ var eventos = Enumerable.Range(0, 100).Select(i => Event(minutosDepois: i)).ToArray();
+
+ var stats = LogStatistics.Compute(eventos, buckets: 10);
+
+ Assert.Equal(10, stats.Timeline.Count);
+ Assert.Equal(100, stats.Timeline.Sum(b => b.Total));
+ }
+
+ [Fact]
+ public void The_timeline_separates_errors_from_the_total()
+ {
+ // É o que revela os picos: a série de erros sobreposta ao volume.
+ var eventos = new[]
+ {
+ Event("Information", minutosDepois: 0),
+ Event("Error", minutosDepois: 0),
+ Event("Fatal", minutosDepois: 0),
+ };
+
+ var stats = LogStatistics.Compute(eventos, buckets: 1);
+
+ Assert.Equal(3, stats.Timeline[0].Total);
+ Assert.Equal(2, stats.Timeline[0].Errors);
+ }
+
+ [Fact]
+ public void The_newest_event_lands_in_the_last_bucket()
+ {
+ // Sem o clamp, o evento do limite superior cairia num índice fora do vetor.
+ var eventos = new[] { Event(minutosDepois: 0), Event(minutosDepois: 10) };
+
+ var stats = LogStatistics.Compute(eventos, buckets: 4);
+
+ Assert.Equal(2, stats.Timeline.Sum(b => b.Total));
+ Assert.Equal(1, stats.Timeline.Last().Total);
+ }
+
+ [Fact]
+ public void Events_all_at_the_same_instant_yield_a_single_bucket()
+ {
+ var eventos = new[] { Event(minutosDepois: 0), Event(minutosDepois: 0) };
+
+ var stats = LogStatistics.Compute(eventos);
+
+ Assert.Single(stats.Timeline);
+ Assert.Equal(2, stats.Timeline[0].Total);
+ }
+
+ [Fact]
+ public void The_bucket_size_shrinks_when_the_period_is_shorter()
+ {
+ // Comportamento observado ao filtrar: o mesmo painel re-fatia para o novo período.
+ var largo = LogStatistics.Compute(
+ Enumerable.Range(0, 50).Select(i => Event(minutosDepois: i * 60)).ToArray(), buckets: 10);
+ var estreito = LogStatistics.Compute(
+ Enumerable.Range(0, 50).Select(i => Event(minutosDepois: i)).ToArray(), buckets: 10);
+
+ Assert.True(estreito.BucketSize < largo.BucketSize);
+ }
+
+ [Fact]
+ public void Events_without_a_timestamp_do_not_break_the_timeline()
+ {
+ var eventos = new[] { new ClefEvent { Level = "Information" }, Event(minutosDepois: 5) };
+
+ var stats = LogStatistics.Compute(eventos);
+
+ Assert.Equal(2, stats.Total);
+ Assert.NotEmpty(stats.Timeline);
+ }
+
+ // --- Vazio ---------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void An_invalid_bucket_count_fails_explicitly(int buckets)
+ {
+ // Sem a validação isto viraria DivideByZeroException lá dentro (ou um índice
+ // negativo), bem longe da causa.
+ Assert.Throws(
+ () => LogStatistics.Compute(new[] { Event() }, buckets: buckets));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void An_invalid_top_count_fails_explicitly(int topCount)
+ {
+ Assert.Throws(
+ () => LogStatistics.Compute(new[] { Event() }, topCount: topCount));
+ }
+
+ [Fact]
+ public void An_empty_set_yields_empty_stats_without_throwing()
+ {
+ var stats = LogStatistics.Compute(Array.Empty());
+
+ Assert.Equal(0, stats.Total);
+ Assert.Empty(stats.ByLevel);
+ Assert.Empty(stats.Timeline);
+ Assert.Equal(0, stats.ErrorCount);
+ }
+}
diff --git a/test/ClefExplorer.Tests/UiPreferencesTests.cs b/test/ClefExplorer.Tests/UiPreferencesTests.cs
new file mode 100644
index 0000000..2f40515
--- /dev/null
+++ b/test/ClefExplorer.Tests/UiPreferencesTests.cs
@@ -0,0 +1,76 @@
+using ClefExplorer.Models;
+using ClefExplorer.Services;
+
+namespace ClefExplorer.Tests;
+
+///
+/// Preferências de layout da interface. Ficam em ui.json, e não no
+/// settings.json, porque salvar as configurações dispara um recarregamento de todos
+/// os arquivos de log — inaceitável para uma preferência puramente visual.
+///
+public class UiPreferencesTests : IDisposable
+{
+ private readonly string _root;
+
+ public UiPreferencesTests()
+ {
+ _root = Path.Combine(Path.GetTempPath(), "ClefExplorerTests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_root);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_root, recursive: true); } catch { /* limpeza best-effort */ }
+ }
+
+ private UiPreferencesService NewService() => new(new AppStorage(_root, legacyFolder: null));
+
+ [Fact]
+ public void Detail_panel_starts_on_the_right()
+ {
+ Assert.Equal(DetailPanelPosition.Right, NewService().Preferences.DetailPanelPosition);
+ }
+
+ [Fact]
+ public void Toggling_moves_the_panel_to_the_bottom_and_back()
+ {
+ var service = NewService();
+
+ Assert.Equal(DetailPanelPosition.Bottom, service.ToggleDetailPanelPosition());
+ Assert.Equal(DetailPanelPosition.Right, service.ToggleDetailPanelPosition());
+ }
+
+ [Fact]
+ public void The_chosen_position_survives_a_restart()
+ {
+ NewService().ToggleDetailPanelPosition();
+
+ Assert.Equal(DetailPanelPosition.Bottom, NewService().Preferences.DetailPanelPosition);
+ }
+
+ [Fact]
+ public void The_position_is_written_by_name_not_by_number()
+ {
+ // O arquivo é editável à mão: "Bottom" diz algo, "1" não.
+ NewService().ToggleDetailPanelPosition();
+
+ var json = File.ReadAllText(Path.Combine(_root, "ui.json"));
+ Assert.Contains("\"Bottom\"", json);
+ }
+
+ [Fact]
+ public void A_corrupt_file_falls_back_to_the_default_without_throwing()
+ {
+ File.WriteAllText(Path.Combine(_root, "ui.json"), "não é json");
+
+ Assert.Equal(DetailPanelPosition.Right, NewService().Preferences.DetailPanelPosition);
+ }
+
+ [Fact]
+ public void An_unknown_position_falls_back_to_the_default()
+ {
+ File.WriteAllText(Path.Combine(_root, "ui.json"), """{"DetailPanelPosition":"Diagonal"}""");
+
+ Assert.Equal(DetailPanelPosition.Right, NewService().Preferences.DetailPanelPosition);
+ }
+}