@@ -3,9 +3,13 @@ import SwiftUI
33struct LiveTailView: View {
44 @Environment(AppState.self) private var appState
55 @State private var viewModel = LiveTailViewModel()
6− @State private var selectedEntry: LiveTailViewModel.LiveTailEntry?
6+ @State private var selectedRecord: LogRecord?
77 @State private var autoScroll = true
88 @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] = [:]
913
1014 var body: some View {
1115 @Bindable var vm = viewModel
@@ -66,11 +70,36 @@ struct LiveTailView: View {
6670 .accessibilityLabel("Filter live tail entries")
6771 }
6872
73+ if !viewModel.columns.isEmpty {
74+ Button {
75+ showColumnPopover.toggle()
76+ } label: {
77+ HStack(spacing: 4) {
78+ Image(systemName: "eye")
79+ Text("Columns")
80+ .font(.caption)
81+ if !viewModel.hiddenColumns.isEmpty {
82+ Text("(\(viewModel.visibleColumns.count)/\(viewModel.columns.count))")
83+ .font(.caption)
84+ .foregroundStyle(.secondary)
85+ }
86+ }
87+ }
88+ .accessibilityLabel("Manage column visibility and order")
89+ .help("Show, hide, and reorder columns")
90+ .popover(isPresented: $showColumnPopover, arrowEdge: .bottom) {
91+ LiveTailColumnManagerView(viewModel: viewModel)
92+ }
93+ }
94+
6995 Toggle("Auto-scroll", isOn: $autoScroll)
7096 .toggleStyle(.checkbox)
7197
7298 Button {
7399 viewModel.clear()
100+ selectedRecord = nil
101+ sortColumn = nil
102+ columnWidths = [:]
74103 } label: {
75104 Image(systemName: "trash")
76105 }
@@ -132,37 +161,32 @@ struct LiveTailView: View {
132161
133162 Divider()
134163
135− // Entries list
136− ScrollViewReader { proxy in
137− List(viewModel.filteredEntries, selection: Binding(
138− get: { selectedEntry?.id },
139− set: { id in
140− selectedEntry = viewModel.filteredEntries.first { $0.id == id }
141− }
142− )) { entry in
143− LiveTailEntryRow(entry: entry)
144− .id(entry.id)
145− .tag(entry.id)
146− }
147− .listStyle(.plain)
148− .font(.system(.caption, design: .monospaced))
149− .onChange(of: viewModel.entries.count) { _, _ in
150− guard autoScroll else { return }
151− // When a filter is active, scroll to the last filtered entry;
152− // when no filter, scroll to the absolute last entry.
153− let target = viewModel.filterText.isEmpty
154− ? viewModel.entries.last
155− : viewModel.filteredEntries.last
156− if let target {
157− proxy.scrollTo(target.id, anchor: .bottom)
164+ // Column table or empty state
165+ if viewModel.visibleColumns.isEmpty {
166+ VStack(spacing: 8) {
167+ if !viewModel.isRunning {
168+ Image(systemName: "antenna.radiowaves.left.and.right")
169+ .font(.system(size: 32))
170+ .foregroundStyle(.secondary)
171+ Text("Start live tail to stream log entries")
172+ .foregroundStyle(.secondary)
173+ } else {
174+ ProgressView()
175+ .controlSize(.small)
176+ Text("Waiting for entries...")
177+ .foregroundStyle(.secondary)
158178 }
159179 }
180+ .frame(maxWidth: .infinity, maxHeight: .infinity)
181+ } else {
182+ liveTailTable
160183 }
161184 }
185+ .frame(minWidth: 400)
162186
163187 // Detail pane
164− if let entry = selectedEntry {
165− LogDetailView(record: entry.record)
188+ if let record = selectedRecord {
189+ LogDetailView(record: record)
166190 .frame(minWidth: 280, idealWidth: 320)
167191 }
168192 }
@@ -174,30 +198,146 @@ struct LiveTailView: View {
174198 viewModel.stop()
175199 }
176200 viewModel.clear()
201+ selectedRecord = nil
202+ sortColumn = nil
203+ columnWidths = [:]
204+ }
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)
210+ }
211+ }
212+ }
213+
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
221+ }
222+ }
223+
224+ private var liveTailTable: some View {
225+ ScrollViewReader { proxy in
226+ ScrollView([.horizontal, .vertical]) {
227+ LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
228+ Section {
229+ let sorted = sortedEntries
230+ ForEach(sorted.indices, id: \.self) { index in
231+ LogRowView(
232+ record: sorted[index].record,
233+ columns: viewModel.visibleColumns,
234+ columnWidths: columnWidths,
235+ isSelected: selectedRecord == sorted[index].record,
236+ isAlternate: index % 2 == 1
237+ )
238+ .id(sorted[index].id)
239+ .onTapGesture {
240+ selectedRecord = sorted[index].record
241+ }
242+ }
243+ } header: {
244+ LogHeaderView(
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)
252+ }
253+ )
254+ }
255+ }
256+ }
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
264+ if let target {
265+ proxy.scrollTo(target.id, anchor: .bottom)
266+ }
267+ }
177268 }
178269 }
179270}
180271
181−struct LiveTailEntryRow: View {
182− let entry: LiveTailViewModel.LiveTailEntry
272+// MARK: - Column Manager Popover
273+
274+struct LiveTailColumnManagerView: View {
275+ let viewModel: LiveTailViewModel
183276
184277 var body: some View {
185− HStack(spacing: 8) {
186− Text(entry.displayTimestamp)
187− .foregroundStyle(.secondary)
188− .frame(width: 90, alignment: .leading)
189−
190− if let level = entry.record["level"] ?? entry.record["severity"] {
191− Text(level.displayString)
192− .fontWeight(.medium)
193− .foregroundStyle(levelColor(for: level.displayString))
194− .frame(width: 50, alignment: .leading)
278+ VStack(alignment: .leading, spacing: 0) {
279+ HStack {
280+ Text("Columns")
281+ .font(.headline)
282+ Spacer()
283+ if !viewModel.hiddenColumns.isEmpty || viewModel.columnOrder != viewModel.columns {
284+ Button("Reset") {
285+ viewModel.resetColumnConfig()
286+ }
287+ .font(.caption)
288+ }
289+ }
290+ .padding(.horizontal, 12)
291+ .padding(.top, 10)
292+ .padding(.bottom, 6)
293+
294+ Divider()
295+
296+ List {
297+ ForEach(viewModel.columnOrder, id: \.self) { column in
298+ HStack {
299+ Button {
300+ viewModel.toggleColumnVisibility(column)
301+ } label: {
302+ Image(systemName: viewModel.hiddenColumns.contains(column)
303+ ? "eye.slash" : "eye")
304+ .foregroundStyle(viewModel.hiddenColumns.contains(column)
305+ ? .secondary : .primary)
306+ .frame(width: 20)
307+ }
308+ .buttonStyle(.plain)
309+ .help(viewModel.hiddenColumns.contains(column) ? "Show column" : "Hide column")
310+
311+ Text(column)
312+ .font(.system(.body, design: .monospaced))
313+ .lineLimit(1)
314+ .foregroundStyle(viewModel.hiddenColumns.contains(column)
315+ ? .secondary : .primary)
316+
317+ Spacer()
318+
319+ Image(systemName: "line.3.horizontal")
320+ .foregroundStyle(.tertiary)
321+ .font(.caption)
322+ }
323+ .padding(.vertical, 2)
324+ }
325+ .onMove { source, destination in
326+ viewModel.moveColumn(from: source, to: destination)
327+ }
195328 }
329+ .listStyle(.plain)
196330
197− Text(entry.summary)
198− .lineLimit(1)
199− .frame(maxWidth: .infinity, alignment: .leading)
200− .help(entry.summary)
331+ if !viewModel.hiddenColumns.isEmpty {
332+ Divider()
333+ Button("Show All Columns") {
334+ viewModel.showAllColumns()
335+ }
336+ .font(.caption)
337+ .padding(.horizontal, 12)
338+ .padding(.vertical, 8)
339+ }
201340 }
341+ .frame(width: 280, height: min(CGFloat(viewModel.columnOrder.count) * 32 + 80, 400))
202342 }
203343}