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..46d4107 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,6 +93,7 @@ jobs: /p:AppxBundlePlatforms=x64 /p:UapAppxPackageBuildMode=StoreUpload /p:AppxPackageSigningEnabled=false + /p:Version=${{ steps.ver.outputs.version }} - name: Locate upload package id: pkg diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..077b652 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,27 @@ + + + + + 1.1.0 + + $(ClefVersion) + + $(ClefVersion).0 + $(ClefVersion).0 + + + diff --git a/ROADMAP.md b/ROADMAP.md index 1736c9c..e4885e0 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 | 🟡 | ⬜ | +| 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 | 🟡 | ⬜ | -| 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) | +| 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..0b55679 100644 --- a/publish-store-package.ps1 +++ b/publish-store-package.ps1 @@ -39,7 +39,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$Version, + [string]$Version = '', [string]$Platform = 'x64', [string]$Configuration = 'Release', @@ -60,6 +60,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}$') { 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 @@ true true - win-x64;win-x86 + + win-x64 true true app.ico @@ -24,9 +26,7 @@ https://github.com/afernandes/ClefExplorer https://github.com/afernandes/ClefExplorer.git log;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. *@ + + + + + + + + + @* 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; -
+ @*
+ } 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..ee91237 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,38 @@ 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); -
-
-
- - @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. *@ + + +
+
+
+ + @ev.Timestamp?.ToString("HH:mm:ss") +
+
@ev.Message
+
+ @if (!string.IsNullOrEmpty(ev.Exception)) + { + Exception + } + @ObterResumoProps(ev) +
-
- } + + } else { @@ -47,6 +58,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/LogToolbar.razor b/src/Components/LogToolbar.razor index eade214..f5bb27e 100644 --- a/src/Components/LogToolbar.razor +++ b/src/Components/LogToolbar.razor @@ -23,8 +23,30 @@ 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 +64,13 @@ [Parameter] public EventCallback OnToggleTail { get; set; } [Parameter] public bool IsLoading { get; set; } [Parameter] public EventCallback OnCancelLoad { get; set; } + + /// Exibindo em tabela (em vez da lista). + [Parameter] public bool GridMode { get; set; } + [Parameter] public EventCallback OnToggleViewMode { get; set; } + + /// 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..c1e4820 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
@@ -60,25 +62,33 @@ TailEnabled="@Store.TailEnabled" OnToggleTail="AlternarAoVivo" IsLoading="@Store.IsLoading" - OnCancelLoad="CancelarCarregamento" /> + OnCancelLoad="CancelarCarregamento" + DetailVisible="@(_selected != null)" + DetailAtBottom="@DetalheAbaixo" + OnToggleDetailPosition="AlternarPosicaoDetalhe" + GridMode="@ModoTabela" + OnToggleViewMode="AlternarModoVisualizacao" />
- @* 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,29 @@
+@* 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 (ModoTabela) + { + + } + else + { + + } + }; + private ClefEvent? _selected; private bool _isBusy; private List _todosEventos = new(); @@ -499,6 +528,9 @@ _todosEventos = result; if (_pagina > UltimaPagina) _pagina = 1; AtualizarPagina(); + // As colunas vêm do conteúdo, então mudam quando o conjunto muda. + // Só recalcula no modo tabela — na lista seria trabalho jogado fora. + if (ModoTabela) DescobrirColunas(); _isBusy = false; StateHasChanged(); }); @@ -572,6 +604,62 @@ } } + /// 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; + + /// + /// 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(); + + /// Alterna entre lista e tabela, persistindo a escolha. + private void AlternarModoVisualizacao() + { + UiPreferences.ToggleViewMode(); + if (ModoTabela) DescobrirColunas(); + } + + /// + /// 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/Models/UiEnums.cs b/src/Models/UiEnums.cs new file mode 100644 index 0000000..9dc8d00 --- /dev/null +++ b/src/Models/UiEnums.cs @@ -0,0 +1,22 @@ +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, + } + + /// 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..6c64a23 --- /dev/null +++ b/src/Services/UiPreferencesService.cs @@ -0,0 +1,113 @@ +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; + } + + /// Alterna entre lista e tabela e persiste a escolha. + public LogViewMode ToggleViewMode() + { + _preferences.ViewMode = _preferences.ViewMode == LogViewMode.List + ? LogViewMode.Grid + : LogViewMode.List; + + Save(); + return _preferences.ViewMode; + } + + /// 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..3a9226f 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