@@ -315,7 +315,23 @@ 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 }
318 .onAppear {
326 .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
319 recomputeColumnWidths(columns: columns, records: records)
335 recomputeColumnWidths(columns: columns, records: records)
320 rebuildSort()
336 rebuildSort()
@@ -401,6 +417,9 @@ struct LogTableView: View {
403 private func rebuildSort() {
419 private func rebuildSort() {
420+ // A debounced rebuild scheduled before the table unmounted must not land
421+ // after (and overwrite) this synchronous one.
404 let sevCols = buildSeverityColumnSet(columns: columns)
423 let sevCols = buildSeverityColumnSet(columns: columns)
405 let enumerated = Array(records.enumerated())
424 let enumerated = Array(records.enumerated())
406 let sorted: [(offset: Int, element: LogRecord)]
425 let sorted: [(offset: Int, element: LogRecord)]
@@ -476,12 +495,28 @@ struct LogHeaderView: View {
477 @State private var draggedColumn: String?
496 @State private var draggedColumn: String?
478 @State private var dropTargetColumn: String?
497 @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.
479− @State private var uniqueValuesCache: [String: [JSONValue]] = [:]
504+ @State private var uniqueValuesCache = UniqueValuesCache()
505+ @State private var recordsGeneration = 0
507+ private final class UniqueValuesCache {
509+ var values: [String: [JSONValue]] = [:]
481 private static let maxFilterValues = 20
512 private static let maxFilterValues = 20
483 private func uniqueValues(for column: String) -> [JSONValue] {
514 private func uniqueValues(for column: String) -> [JSONValue] {
515+ if uniqueValuesCache.generation != recordsGeneration {
516+ uniqueValuesCache.generation = recordsGeneration
517+ uniqueValuesCache.values = [:]
484− if let cached = uniqueValuesCache[column] {
519+ if let cached = uniqueValuesCache.values[column] {
485 return cached
520 return cached
487 var seen = Set<JSONValue>()
522 var seen = Set<JSONValue>()
@@ -493,7 +528,7 @@ struct LogHeaderView: View {
495 let sorted = result.sorted()
530 let sorted = result.sorted()
496− uniqueValuesCache[column] = sorted
531+ uniqueValuesCache.values[column] = sorted
497 return sorted
532 return sorted
@@ -636,7 +671,7 @@ struct LogHeaderView: View {
636 .background(Color(nsColor: .controlBackgroundColor))
671 .background(Color(nsColor: .controlBackgroundColor))
637 .border(Color.secondary.opacity(0.2), width: 0.5)
672 .border(Color.secondary.opacity(0.2), width: 0.5)
638 .onChange(of: records) { _, _ in
673 .onChange(of: records) { _, _ in
639− uniqueValuesCache = [:]
674+ recordsGeneration &+= 1
@@ -669,14 +704,26 @@ private struct ColumnDropDelegate: DropDelegate {
671 func performDrop(info: DropInfo) -> Bool {
706 func performDrop(info: DropInfo) -> Bool {
672− guard let dragged = draggedColumn, dragged != column else {
707+ guard let dragged = draggedColumn, dragged != column,
708+ let provider = info.itemProviders(for: [UTType.plainText]).first else {
673 draggedColumn = nil
709 draggedColumn = nil
674 dropTargetColumn = nil
710 dropTargetColumn = nil
675 return false
711 return false
677− onMoveColumn?(dragged, column)
678 draggedColumn = nil
713 draggedColumn = nil
679 dropTargetColumn = nil
714 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)
680 return true
727 return true