@@ -3,9 +3,13 @@ import SwiftUI
3struct LiveTailView: View {
3struct LiveTailView: View {
4 @Environment(AppState.self) private var appState
4 @Environment(AppState.self) private var appState
5 @State private var viewModel = LiveTailViewModel()
5 @State private var viewModel = LiveTailViewModel()
6− @State private var selectedEntry: LiveTailViewModel.LiveTailEntry?
6+ @State private var selectedRecord: LogRecord?
7 @State private var autoScroll = true
7 @State private var autoScroll = true
8 @State private var pulseOpacity: Double = 1.0
8 @State private var pulseOpacity: Double = 1.0
9+ @State private var showColumnPopover = false
10+ @State private var sortColumn: String?
11+ @State private var sortAscending = false
12+ @State private var columnWidths: [String: CGFloat] = [:]
10 var body: some View {
14 var body: some View {
11 @Bindable var vm = viewModel
15 @Bindable var vm = viewModel
@@ -66,11 +70,36 @@ struct LiveTailView: View {
66 .accessibilityLabel("Filter live tail entries")
70 .accessibilityLabel("Filter live tail entries")
73+ if !viewModel.columns.isEmpty {
75+ showColumnPopover.toggle()
78+ Image(systemName: "eye")
81+ if !viewModel.hiddenColumns.isEmpty {
82+ Text("(\(viewModel.visibleColumns.count)/\(viewModel.columns.count))")
84+ .foregroundStyle(.secondary)
88+ .accessibilityLabel("Manage column visibility and order")
89+ .help("Show, hide, and reorder columns")
90+ .popover(isPresented: $showColumnPopover, arrowEdge: .bottom) {
91+ LiveTailColumnManagerView(viewModel: viewModel)
69 Toggle("Auto-scroll", isOn: $autoScroll)
95 Toggle("Auto-scroll", isOn: $autoScroll)
70 .toggleStyle(.checkbox)
96 .toggleStyle(.checkbox)
73 viewModel.clear()
99 viewModel.clear()
100+ selectedRecord = nil
74 } label: {
103 } label: {
75 Image(systemName: "trash")
104 Image(systemName: "trash")
@@ -132,37 +161,32 @@ struct LiveTailView: View {
133 Divider()
162 Divider()
135− // Entries list
164+ // Column table or empty state
136− ScrollViewReader { proxy in
165+ if viewModel.visibleColumns.isEmpty {
137− List(viewModel.filteredEntries, selection: Binding(
166+ VStack(spacing: 8) {
138− get: { selectedEntry?.id },
167+ if !viewModel.isRunning {
139− set: { id in
168+ Image(systemName: "antenna.radiowaves.left.and.right")
140− selectedEntry = viewModel.filteredEntries.first { $0.id == id }
143− LiveTailEntryRow(entry: entry)
148− .font(.system(.caption, design: .monospaced))
169+ .font(.system(size: 32))
149− .onChange(of: viewModel.entries.count) { _, _ in
170+ .foregroundStyle(.secondary)
150− guard autoScroll else { return }
171+ Text("Start live tail to stream log entries")
151− // When a filter is active, scroll to the last filtered entry;
172+ .foregroundStyle(.secondary)
152− // when no filter, scroll to the absolute last entry.
173+ } else {
153− let target = viewModel.filterText.isEmpty
174+ ProgressView()
154− ? viewModel.entries.last
175+ .controlSize(.small)
155− : viewModel.filteredEntries.last
176+ Text("Waiting for entries...")
156− if let target {
177+ .foregroundStyle(.secondary)
157− proxy.scrollTo(target.id, anchor: .bottom)
180+ .frame(maxWidth: .infinity, maxHeight: .infinity)
185+ .frame(minWidth: 400)
163 // Detail pane
187 // Detail pane
164− if let entry = selectedEntry {
188+ if let record = selectedRecord {
165− LogDetailView(record: entry.record)
189+ LogDetailView(record: record)
166 .frame(minWidth: 280, idealWidth: 320)
190 .frame(minWidth: 280, idealWidth: 320)
@@ -174,30 +198,146 @@ struct LiveTailView: View {
174 viewModel.stop()
198 viewModel.stop()
176 viewModel.clear()
200 viewModel.clear()
201+ selectedRecord = nil
205+ .onChange(of: viewModel.visibleColumns) { _, newColumns in
206+ // Compute widths for any newly visible columns
207+ let records = viewModel.filteredEntries.map { $0.record }
208+ for col in newColumns where columnWidths[col] == nil {
209+ columnWidths[col] = idealColumnWidth(for: col, records: records)
214+ private var sortedEntries: [LiveTailViewModel.LiveTailEntry] {
215+ let entries = viewModel.filteredEntries
216+ guard let sortColumn else { return entries }
217+ return entries.sorted { a, b in
218+ let aVal = a.record[sortColumn] ?? .null
219+ let bVal = b.record[sortColumn] ?? .null
220+ return sortAscending ? aVal < bVal : bVal < aVal
224+ private var liveTailTable: some View {
225+ ScrollViewReader { proxy in
226+ ScrollView([.horizontal, .vertical]) {
227+ LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
229+ let sorted = sortedEntries
230+ ForEach(sorted.indices, id: \.self) { index in
232+ record: sorted[index].record,
233+ columns: viewModel.visibleColumns,
234+ columnWidths: columnWidths,
235+ isSelected: selectedRecord == sorted[index].record,
236+ isAlternate: index % 2 == 1
238+ .id(sorted[index].id)
240+ selectedRecord = sorted[index].record
245+ columns: viewModel.visibleColumns,
246+ sortColumn: $sortColumn,
247+ sortAscending: $sortAscending,
248+ columnWidths: $columnWidths,
249+ records: viewModel.filteredEntries.map { $0.record },
250+ onMoveColumn: { from, to in
251+ viewModel.moveColumn(from, to: to)
257+ .onChange(of: viewModel.entries.count) { _, _ in
258+ guard autoScroll, sortColumn == nil else { return }
259+ // When a filter is active, scroll to the last filtered entry;
260+ // when no filter, scroll to the absolute last entry.
261+ let target = viewModel.filterText.isEmpty
262+ ? viewModel.entries.last
263+ : viewModel.filteredEntries.last
265+ proxy.scrollTo(target.id, anchor: .bottom)
181−struct LiveTailEntryRow: View {
272+// MARK: - Column Manager Popover
274+struct LiveTailColumnManagerView: View {
182− let entry: LiveTailViewModel.LiveTailEntry
275+ let viewModel: LiveTailViewModel
184 var body: some View {
277 var body: some View {
185− HStack(spacing: 8) {
278+ VStack(alignment: .leading, spacing: 0) {
186− Text(entry.displayTimestamp)
279+ HStack {
187− .foregroundStyle(.secondary)
280+ Text("Columns")
188− .frame(width: 90, alignment: .leading)
281+ .font(.headline)
190− if let level = entry.record["level"] ?? entry.record["severity"] {
283+ if !viewModel.hiddenColumns.isEmpty || viewModel.columnOrder != viewModel.columns {
191− Text(level.displayString)
284+ Button("Reset") {
192− .fontWeight(.medium)
285+ viewModel.resetColumnConfig()
193− .foregroundStyle(levelColor(for: level.displayString))
286+ }
290+ .padding(.horizontal, 12)
292+ .padding(.bottom, 6)
297+ ForEach(viewModel.columnOrder, id: \.self) { column in
300+ viewModel.toggleColumnVisibility(column)
302+ Image(systemName: viewModel.hiddenColumns.contains(column)
303+ ? "eye.slash" : "eye")
304+ .foregroundStyle(viewModel.hiddenColumns.contains(column)
305+ ? .secondary : .primary)
194− .frame(width: 50, alignment: .leading)
306+ .frame(width: 20)
308+ .buttonStyle(.plain)
309+ .help(viewModel.hiddenColumns.contains(column) ? "Show column" : "Hide column")
312+ .font(.system(.body, design: .monospaced))
314+ .foregroundStyle(viewModel.hiddenColumns.contains(column)
315+ ? .secondary : .primary)
319+ Image(systemName: "line.3.horizontal")
320+ .foregroundStyle(.tertiary)
323+ .padding(.vertical, 2)
325+ .onMove { source, destination in
326+ viewModel.moveColumn(from: source, to: destination)
197− Text(entry.summary)
331+ if !viewModel.hiddenColumns.isEmpty {
198− .lineLimit(1)
332+ Divider()
199− .frame(maxWidth: .infinity, alignment: .leading)
333+ Button("Show All Columns") {
200− .help(entry.summary)
334+ viewModel.showAllColumns()
337+ .padding(.horizontal, 12)
338+ .padding(.vertical, 8)
341+ .frame(width: 280, height: min(CGFloat(viewModel.columnOrder.count) * 32 + 80, 400))