@@ -315,23 +315,7 @@ struct LogTableView: View {
316 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
316 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
317 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
317 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
318− .onChange(of: selectedRecord) { _, newValue in
319− // The owner can drop the selection on its own (QueryViewModel
320− // clears it after every query). When the new result set equals the
321− // old one, `onChange(of: records)` doesn't fire, so nothing would
322− // reconcile the row highlight: the row stayed highlighted with no
323− // detail pane, and the next click on it merely "deselected" it.
324− if newValue == nil { selectedIndex = nil }
326 .onAppear {
318 .onAppear {
327− // This branch is unmounted while results are empty (e.g. across a
328− // stream switch, which clears them first), so `onChange(of: columns)`
329− // never sees the switch. Drop a sort column the new columns don't
330− // have — or hide — before sorting, or rows would be ordered by an
331− // invisible column with no header showing the sort.
332− if let sortColumn, !columns.contains(sortColumn) {
333− self.sortColumn = nil
335 recomputeColumnWidths(columns: columns, records: records)
319 recomputeColumnWidths(columns: columns, records: records)
336 rebuildSort()
320 rebuildSort()
@@ -417,9 +401,6 @@ struct LogTableView: View {
419 private func rebuildSort() {
403 private func rebuildSort() {
420− // A debounced rebuild scheduled before the table unmounted must not land
421− // after (and overwrite) this synchronous one.
423 let sevCols = buildSeverityColumnSet(columns: columns)
404 let sevCols = buildSeverityColumnSet(columns: columns)
424 let enumerated = Array(records.enumerated())
405 let enumerated = Array(records.enumerated())
425 let sorted: [(offset: Int, element: LogRecord)]
406 let sorted: [(offset: Int, element: LogRecord)]
@@ -495,28 +476,12 @@ struct LogHeaderView: View {
496 @State private var draggedColumn: String?
477 @State private var draggedColumn: String?
497 @State private var dropTargetColumn: String?
478 @State private var dropTargetColumn: String?
498− /// `.contextMenu`'s builder runs during body evaluation, so the filter menus
499− /// compute their values while the view is being rendered. Writing an `@State`
500− /// dictionary from there is "modifying state during view update" (undefined
501− /// behavior, and it re-invalidates the header). The cache is therefore a plain
502− /// reference that isn't observed; `recordsGeneration` — bumped outside body
503− /// evaluation — is what invalidates it and triggers the re-render.
504− @State private var uniqueValuesCache = UniqueValuesCache()
479+ @State private var uniqueValuesCache: [String: [JSONValue]] = [:]
505− @State private var recordsGeneration = 0
507− private final class UniqueValuesCache {
509− var values: [String: [JSONValue]] = [:]
512 private static let maxFilterValues = 20
481 private static let maxFilterValues = 20
514 private func uniqueValues(for column: String) -> [JSONValue] {
483 private func uniqueValues(for column: String) -> [JSONValue] {
515− if uniqueValuesCache.generation != recordsGeneration {
516− uniqueValuesCache.generation = recordsGeneration
517− uniqueValuesCache.values = [:]
519− if let cached = uniqueValuesCache.values[column] {
484+ if let cached = uniqueValuesCache[column] {
520 return cached
485 return cached
522 var seen = Set<JSONValue>()
487 var seen = Set<JSONValue>()
@@ -528,7 +493,7 @@ struct LogHeaderView: View {
530 let sorted = result.sorted()
495 let sorted = result.sorted()
531− uniqueValuesCache.values[column] = sorted
496+ uniqueValuesCache[column] = sorted
532 return sorted
497 return sorted
@@ -671,7 +636,7 @@ struct LogHeaderView: View {
671 .background(Color(nsColor: .controlBackgroundColor))
636 .background(Color(nsColor: .controlBackgroundColor))
672 .border(Color.secondary.opacity(0.2), width: 0.5)
637 .border(Color.secondary.opacity(0.2), width: 0.5)
673 .onChange(of: records) { _, _ in
638 .onChange(of: records) { _, _ in
674− recordsGeneration &+= 1
639+ uniqueValuesCache = [:]
@@ -704,26 +669,14 @@ private struct ColumnDropDelegate: DropDelegate {
706 func performDrop(info: DropInfo) -> Bool {
671 func performDrop(info: DropInfo) -> Bool {
707− guard let dragged = draggedColumn, dragged != column,
672+ guard let dragged = draggedColumn, dragged != column else {
708− let provider = info.itemProviders(for: [UTType.plainText]).first else {
709 draggedColumn = nil
673 draggedColumn = nil
710 dropTargetColumn = nil
674 dropTargetColumn = nil
711 return false
675 return false
677+ onMoveColumn?(dragged, column)
713 draggedColumn = nil
678 draggedColumn = nil
714 dropTargetColumn = nil
679 dropTargetColumn = nil
715− // `draggedColumn` is only cleared by a drop on a header, so a header drag
716− // that was cancelled (or dropped elsewhere) leaves it set, and any later
717− // plain-text drag onto a header would move that stale column. Only move
718− // when the dropped payload really is the column header being dragged.
720− let move = onMoveColumn
721− _ = provider.loadObject(ofClass: NSString.self) { object, _ in
722− guard let payload = object as? NSString, payload as String == dragged else { return }
723− DispatchQueue.main.async {
724− move?(dragged, target)
727 return true
680 return true