@@ -21,6 +21,12 @@ final class LiveTailViewModel {
21 var columns: [String] = []
21 var columns: [String] = []
22 var columnOrder: [String] = []
22 var columnOrder: [String] = []
23 var hiddenColumns: Set<String> = []
23 var hiddenColumns: Set<String> = []
24+ /// The full persisted set of hidden columns, including ones not yet seen in
25+ /// this session. Live-tail columns are discovered incrementally across polls,
26+ /// so `hiddenColumns` (only the seen ones) can't be the source of truth — a
27+ /// column hidden last session would reappear visible when it shows up in a
28+ /// later poll. This retains the intent so it stays hidden when it appears.
29+ private var savedHiddenColumns: Set<String> = []
24 private var currentStream: String?
30 private var currentStream: String?
26 // nonisolated(unsafe) so deinit can invalidate the timer.
32 // nonisolated(unsafe) so deinit can invalidate the timer.
@@ -37,6 +43,11 @@ final class LiveTailViewModel {
37 private var allKnownKeys: Set<String> = []
43 private var allKnownKeys: Set<String> = []
38 private var consecutiveErrors = 0
44 private var consecutiveErrors = 0
39 private static let maxConsecutiveErrors = 5
45 private static let maxConsecutiveErrors = 5
46+ /// Guards against overlapping polls: if a poll's network round-trip outlasts
47+ /// the timer interval, the next tick must not start a second concurrent poll
48+ /// (which would let the two interleave and write `lastTimestamp` /
49+ /// `consecutiveErrors` out of order).
50+ private var isPolling = false
42 timer?.invalidate()
53 timer?.invalidate()
@@ -170,6 +181,7 @@ final class LiveTailViewModel {
170 columns = []
181 columns = []
171 columnOrder = []
182 columnOrder = []
172 hiddenColumns = []
183 hiddenColumns = []
184+ savedHiddenColumns = []
173 columnFilters = []
185 columnFilters = []
174 currentStream = stream
186 currentStream = stream
175 rebuildFilteredEntries()
187 rebuildFilteredEntries()
@@ -201,6 +213,7 @@ final class LiveTailViewModel {
201 columns = []
213 columns = []
202 columnOrder = []
214 columnOrder = []
203 hiddenColumns = []
215 hiddenColumns = []
216+ savedHiddenColumns = []
204 columnFilters = []
217 columnFilters = []
205 rebuildFilteredEntries()
218 rebuildFilteredEntries()
@@ -214,10 +227,12 @@ final class LiveTailViewModel {
214 func toggleColumnVisibility(_ column: String) {
227 func toggleColumnVisibility(_ column: String) {
215 if hiddenColumns.contains(column) {
228 if hiddenColumns.contains(column) {
216 hiddenColumns.remove(column)
229 hiddenColumns.remove(column)
230+ savedHiddenColumns.remove(column)
218 let visibleCount = columnOrder.count - hiddenColumns.count
232 let visibleCount = columnOrder.count - hiddenColumns.count
219 if visibleCount > 1 {
233 if visibleCount > 1 {
220 hiddenColumns.insert(column)
234 hiddenColumns.insert(column)
235+ savedHiddenColumns.insert(column)
223 saveColumnConfig()
238 saveColumnConfig()
@@ -225,6 +240,7 @@ final class LiveTailViewModel {
226 func showAllColumns() {
241 func showAllColumns() {
227 hiddenColumns.removeAll()
242 hiddenColumns.removeAll()
243+ savedHiddenColumns.removeAll()
228 saveColumnConfig()
244 saveColumnConfig()
@@ -245,6 +261,7 @@ final class LiveTailViewModel {
245 func resetColumnConfig() {
261 func resetColumnConfig() {
246 columnOrder = columns
262 columnOrder = columns
247 hiddenColumns.removeAll()
263 hiddenColumns.removeAll()
264+ savedHiddenColumns.removeAll()
248 saveColumnConfig()
265 saveColumnConfig()
@@ -256,7 +273,9 @@ final class LiveTailViewModel {
257 private func saveColumnConfig() {
274 private func saveColumnConfig() {
258 guard let stream = currentStream else { return }
275 guard let stream = currentStream else { return }
276+ // Persist the full intended hidden set (including columns not yet seen
277+ // this session) so it survives across restarts and incremental discovery.
259− let config = QueryViewModel.ColumnConfiguration(order: columnOrder, hidden: hiddenColumns)
278+ let config = QueryViewModel.ColumnConfiguration(order: columnOrder, hidden: savedHiddenColumns)
260 if let data = try? JSONEncoder().encode(config) {
279 if let data = try? JSONEncoder().encode(config) {
261 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
280 UserDefaults.standard.set(data, forKey: Self.columnConfigKey(for: stream))
@@ -298,9 +317,11 @@ final class LiveTailViewModel {
298 merged.append(col)
317 merged.append(col)
300 columnOrder = merged
319 columnOrder = merged
320+ savedHiddenColumns = config.hidden
301 hiddenColumns = config.hidden.intersection(extractedSet)
321 hiddenColumns = config.hidden.intersection(extractedSet)
303 columnOrder = extracted
323 columnOrder = extracted
324+ savedHiddenColumns = []
304 hiddenColumns = []
325 hiddenColumns = []
@@ -313,11 +334,18 @@ final class LiveTailViewModel {
313 if !newCols.isEmpty {
334 if !newCols.isEmpty {
314 columns.append(contentsOf: newCols)
335 columns.append(contentsOf: newCols)
315 columnOrder.append(contentsOf: newCols)
336 columnOrder.append(contentsOf: newCols)
337+ // Re-hide any newly appearing column that was hidden in a prior
338+ // session but hadn't been seen yet this run.
339+ hiddenColumns.formUnion(newCols.filter { savedHiddenColumns.contains($0) })
320 private func poll(client: ParseableClient, stream: String) async {
344 private func poll(client: ParseableClient, stream: String) async {
345+ guard !isPolling else { return }
347+ defer { isPolling = false }
321 let now = Date()
349 let now = Date()
322 // The Parseable server truncates query time-ranges to minute
350 // The Parseable server truncates query time-ranges to minute
323 // boundaries, so a narrow window (< 60 s) can collapse to a
351 // boundaries, so a narrow window (< 60 s) can collapse to a