heathrow 0.7.5 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5276cc34b418c9d3b080eb711a1870857679ca87681c58436e6207ff37e7106
4
- data.tar.gz: 8a10f16633f2ac610bc191434566f883319b570a2ad773297373a8d5a4e477de
3
+ metadata.gz: edb287ccce2f2ceb0b7582ce124efe53e9e7842662e241e12951d22c9bc202a6
4
+ data.tar.gz: 9ede8d4eab17aa9d0fff183b183eec501ebbdd011c8515497772e7d26cf51841
5
5
  SHA512:
6
- metadata.gz: 4c98df39af32e449f46f7a4d7c58436d88cfd6e32499394624ad74c2b16c4e390512ecbe18fff5fbe0462c1bb76b18921b806a72403aa08e7e39ccfc7ac69ccc
7
- data.tar.gz: 9fb82e3608cb1fd6d63078b8584aad96deb3e43072421468b4c712d8c3ead53c192b7378c4d1cef75ada0f5f8d430e81d5847d71c27a04accfdf333f59fe01c8
6
+ metadata.gz: 3566cc3e67b80037396991d097b7eedac09f22a0ab6c85e9f01d098c51dfa5be7dfd1cc87d3be476adc866eb8ac05c75be48ca77b170aa2149b5f2fa3a676164
7
+ data.tar.gz: 245b04002f624724fc3e1b475656425efac505c10a28763308dcee1b2403c796c005284eb4a7f6be0c7bc272de79af1a7da2687e6a952b2b65914faf3599f8ad
@@ -206,14 +206,24 @@ module Heathrow
206
206
  selected ? base.merge(selected) : base
207
207
  end
208
208
 
209
+ def header_message?(msg)
210
+ msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
211
+ end
212
+
213
+ def invalidate_counts
214
+ @cached_unread = nil
215
+ @cached_starred = nil
216
+ @cached_total = nil
217
+ end
218
+
209
219
  # Get the current message at @index (threaded or flat view)
210
220
  def current_message
211
- respond_to?(:current_message_for_navigation) ? current_message_for_navigation : @filtered_messages[@index]
221
+ current_message_for_navigation
212
222
  end
213
223
 
214
224
  # Get the current message count (threaded or flat view)
215
225
  def message_count
216
- respond_to?(:filtered_messages_size) ? filtered_messages_size : @filtered_messages.size
226
+ filtered_messages_size
217
227
  end
218
228
 
219
229
  # Mark the previous message as read when moving away from it
@@ -223,7 +233,7 @@ module Heathrow
223
233
  # Remember current message so it gets marked read when we leave it
224
234
  msg = current_message
225
235
  return unless msg
226
- return if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
236
+ return if header_message?(msg)
227
237
  return unless msg['id'] && !msg['id'].to_s.start_with?('header_')
228
238
 
229
239
  if msg['is_read'].to_i == 0
@@ -246,6 +256,7 @@ module Heathrow
246
256
 
247
257
  @db.mark_as_read(msg['id'])
248
258
  msg['is_read'] = 1
259
+ invalidate_counts
249
260
  sync_maildir_flag(msg, 'S', true)
250
261
 
251
262
  # Also update any other references to this message in filtered/display lists
@@ -281,21 +292,22 @@ module Heathrow
281
292
  count = @browsed_message_ids.size
282
293
  return if count == 0
283
294
 
295
+ msg_by_id = @filtered_messages.each_with_object({}) { |m, h| h[m['id']] = m }
284
296
  @browsed_message_ids.each do |msg_id|
285
297
  @db.mark_as_read(msg_id)
286
- # Update in current message list
287
- msg = @filtered_messages.find { |m| m['id'] == msg_id }
298
+ msg = msg_by_id[msg_id]
288
299
  msg['is_read'] = 1 if msg
289
300
  end
290
301
 
291
302
  @browsed_message_ids.clear
303
+ invalidate_counts
292
304
  set_feedback("Marked #{count} browsed messages as read", 156, 3)
293
305
 
294
306
  # Remove from view if in unread view
295
307
  if is_unread_view?
296
308
  @filtered_messages.select! { |m| m['is_read'].to_i == 0 }
297
309
  @index = 0
298
- reset_threading if respond_to?(:reset_threading)
310
+ reset_threading
299
311
  end
300
312
 
301
313
  render_all
@@ -391,16 +403,13 @@ module Heathrow
391
403
 
392
404
  # EXACTLY LIKE RTFM
393
405
  # Initialize rcurses
394
- File.write('/tmp/heathrow_start.txt', "Starting run method\n") if ENV['DEBUG']
395
406
  Rcurses.init!
396
- File.write('/tmp/heathrow_start.txt', "Rcurses initialized\n", mode: 'a') if ENV['DEBUG']
397
407
 
398
408
  # Clear screen to remove any artifacts
399
409
  Rcurses.clear_screen
400
410
 
401
411
  # Get terminal size
402
412
  setup_display
403
- File.write('/tmp/heathrow_start.txt', "Display setup complete: #{@w}x#{@h}\n", mode: 'a') if ENV['DEBUG']
404
413
 
405
414
  # Create panes and show skeleton immediately
406
415
  create_panes
@@ -440,8 +449,8 @@ module Heathrow
440
449
  end
441
450
  sort_messages
442
451
  @index = 0
443
- reset_threading if respond_to?(:reset_threading)
444
- restore_view_thread_mode if respond_to?(:restore_view_thread_mode)
452
+ reset_threading
453
+ restore_view_thread_mode
445
454
  @initial_load_done = true
446
455
  @needs_redraw = true
447
456
  # Preload the heavy mail gem so 'v' (attachments) doesn't lag
@@ -481,14 +490,14 @@ module Heathrow
481
490
  sort_messages
482
491
  end
483
492
  # Force re-organization in threaded mode
484
- if @show_threaded && respond_to?(:reset_threading)
493
+ if @show_threaded
485
494
  reset_threading(true)
486
- organize_current_messages(true) if respond_to?(:organize_current_messages)
495
+ organize_current_messages(true)
487
496
  end
488
497
  # Defer index restoration for threaded views (display_messages is empty
489
498
  # after reset_threading; it gets populated during render). For flat views,
490
499
  # resolve immediately since @filtered_messages is the navigation list.
491
- if @show_threaded && respond_to?(:reset_threading)
500
+ if @show_threaded
492
501
  @pending_restore_id = selected_id
493
502
  @index = 0
494
503
  elsif selected_id
@@ -1143,7 +1152,7 @@ module Heathrow
1143
1152
  set_feedback("Deleted: #{name} (#{deleted_msgs} messages removed)", 156, 3)
1144
1153
 
1145
1154
  # Re-load the view to reflect removed messages
1146
- reset_threading if respond_to?(:reset_threading)
1155
+ reset_threading
1147
1156
  switch_to_view(@current_view) if @current_view
1148
1157
  end
1149
1158
 
@@ -1362,10 +1371,15 @@ module Heathrow
1362
1371
  count_text = "#{total} sources"
1363
1372
  else
1364
1373
  # For message views, show unread/total plus starred
1365
- real_msgs = @filtered_messages.reject { |m| m['is_header'] || m['is_channel_header'] || m['is_thread_header'] || m['is_dm_header'] }
1366
- unread = real_msgs.count { |m| m['is_read'].to_i == 0 }
1367
- starred = real_msgs.count { |m| m['is_starred'].to_i == 1 }
1368
- total = real_msgs.size
1374
+ if @cached_unread.nil?
1375
+ real_msgs = @filtered_messages.reject { |m| header_message?(m) }
1376
+ @cached_unread = real_msgs.count { |m| m['is_read'].to_i == 0 }
1377
+ @cached_starred = real_msgs.count { |m| m['is_starred'].to_i == 1 }
1378
+ @cached_total = real_msgs.size
1379
+ end
1380
+ unread = @cached_unread
1381
+ starred = @cached_starred
1382
+ total = @cached_total
1369
1383
  count_text = "#{unread} unread / #{total} msgs"
1370
1384
  count_text += " / #{starred}*" if starred > 0
1371
1385
  end
@@ -1392,7 +1406,7 @@ module Heathrow
1392
1406
 
1393
1407
  # Threading mode indicator
1394
1408
  mode_part = ""
1395
- if @current_view != 'S' && respond_to?(:thread_mode_label)
1409
+ if @current_view != 'S'
1396
1410
  mode_part = " [#{thread_mode_label}]".fg(245)
1397
1411
  end
1398
1412
 
@@ -1520,7 +1534,7 @@ module Heathrow
1520
1534
  end
1521
1535
 
1522
1536
  # Build the line with timestamp, icon and sender
1523
- icon = respond_to?(:get_source_icon) ? get_source_icon(msg['source_type']) : '•'
1537
+ icon = get_source_icon(msg['source_type'])
1524
1538
  line_prefix = "#{timestamp} #{icon} #{sender_display} "
1525
1539
 
1526
1540
  # Calculate remaining space for subject (use display_width for CJK)
@@ -1837,7 +1851,7 @@ module Heathrow
1837
1851
  show_loading("Loading sources...")
1838
1852
 
1839
1853
  # Reset threading state when changing views
1840
- reset_threading if respond_to?(:reset_threading)
1854
+ reset_threading
1841
1855
 
1842
1856
  # Reload source colors to ensure they're fresh
1843
1857
  load_source_colors
@@ -1885,6 +1899,7 @@ module Heathrow
1885
1899
 
1886
1900
  def show_all_messages
1887
1901
  flush_pending_read
1902
+ invalidate_counts
1888
1903
  @current_view = 'A'
1889
1904
  @current_folder = nil
1890
1905
  @in_source_view = false
@@ -1895,8 +1910,8 @@ module Heathrow
1895
1910
  @last_rendered_index = nil # Force right pane refresh
1896
1911
 
1897
1912
  # Restore per-view threading mode
1898
- reset_threading if respond_to?(:reset_threading)
1899
- restore_view_thread_mode if respond_to?(:restore_view_thread_mode)
1913
+ reset_threading
1914
+ restore_view_thread_mode
1900
1915
 
1901
1916
  # Show view name instantly
1902
1917
  render_top_bar
@@ -1912,14 +1927,6 @@ module Heathrow
1912
1927
  def render_message_content
1913
1928
  current_msg = current_message
1914
1929
 
1915
- if ENV['DEBUG']
1916
- File.open('/tmp/heathrow_debug.log', 'a') do |f|
1917
- f.puts "render_message_content START"
1918
- f.puts " @index: #{@index}"
1919
- f.puts " @filtered_messages[@index] exists: #{!current_msg.nil?}"
1920
- end
1921
- end
1922
-
1923
1930
  # Reset scroll position when switching messages
1924
1931
  if @last_rendered_index != @index
1925
1932
  @panes[:right].ix = 0
@@ -1947,23 +1954,11 @@ module Heathrow
1947
1954
  end
1948
1955
 
1949
1956
  # Special handling for headers (channel headers, thread headers, etc.)
1950
- if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header']
1957
+ if header_message?(msg)
1951
1958
  render_header_summary(msg)
1952
1959
  return
1953
1960
  end
1954
1961
 
1955
- if ENV['DEBUG']
1956
- File.open('/tmp/heathrow_debug.log', 'a') do |f|
1957
- f.puts " msg details:"
1958
- f.puts " id: #{msg['id']}"
1959
- f.puts " sender: #{msg['sender']}"
1960
- f.puts " recipient: #{msg['recipient']}"
1961
- f.puts " subject: #{msg['subject']}"
1962
- f.puts " timestamp: #{msg['timestamp'].inspect}"
1963
- f.puts " source_type: #{msg['source_type'].inspect}"
1964
- end
1965
- end
1966
-
1967
1962
  # Format message header
1968
1963
  header = []
1969
1964
 
@@ -2000,23 +1995,20 @@ module Heathrow
2000
1995
  else
2001
1996
  # Regular message display
2002
1997
  header << "From: #{msg['sender']}".fg(2) if msg['sender']
2003
- # Show recipients (To field)
1998
+ # Show recipients (To field, already parsed by normalize_message_row)
2004
1999
  to = msg['recipients'] || msg['recipient']
2005
2000
  if to
2006
- to_list = to.is_a?(String) ? (JSON.parse(to) rescue [to]) : to
2007
- to_str = to_list.is_a?(Array) ? to_list.join(', ') : to_list.to_s
2001
+ to_str = to.is_a?(Array) ? to.join(', ') : to.to_s
2008
2002
  header << "To: #{to_str}".fg(2) unless to_str.empty?
2009
2003
  end
2010
- # Show CC recipients
2004
+ # Show CC recipients (already parsed by normalize_message_row)
2011
2005
  cc = msg['cc']
2012
2006
  if cc
2013
- cc_list = cc.is_a?(String) ? (JSON.parse(cc) rescue [cc]) : cc
2014
- cc_str = cc_list.is_a?(Array) ? cc_list.join(', ') : cc_list.to_s
2007
+ cc_str = cc.is_a?(Array) ? cc.join(', ') : cc.to_s
2015
2008
  header << "Cc: #{cc_str}".fg(2) unless cc_str.empty?
2016
2009
  end
2017
2010
  # For weechat, show channel name from metadata instead of content preview
2018
2011
  meta = msg['metadata']
2019
- meta = JSON.parse(meta) if meta.is_a?(String) rescue nil
2020
2012
  if meta.is_a?(Hash) && meta['channel_name']
2021
2013
  header << "Subject: #{meta['channel_name']}".b.fg(1)
2022
2014
  elsif msg['subject']
@@ -2024,22 +2016,9 @@ module Heathrow
2024
2016
  end
2025
2017
  end
2026
2018
 
2027
- if ENV['DEBUG']
2028
- File.open('/tmp/heathrow_debug.log', 'a') do |f|
2029
- f.puts " About to parse timestamp: #{msg['timestamp'].inspect}"
2030
- end
2031
- end
2032
-
2033
2019
  # Parse timestamp using helper method
2034
2020
  date_str = parse_timestamp(msg['timestamp'], '%Y-%m-%d %H:%M:%S') || "Unknown date"
2035
-
2036
- if ENV['DEBUG']
2037
- File.open('/tmp/heathrow_debug.log', 'a') do |f|
2038
- f.puts " Final date_str: #{date_str}"
2039
- f.puts " About to add header lines"
2040
- end
2041
- end
2042
-
2021
+
2043
2022
  header << "Date: #{date_str}".fg(240)
2044
2023
  if msg['source_type']
2045
2024
  source_label = case msg['source_type']
@@ -2050,10 +2029,10 @@ module Heathrow
2050
2029
  header << "Type: #{source_label}".fg(get_source_color(msg))
2051
2030
  end
2052
2031
 
2053
- # Show labels (if any beyond the folder name)
2032
+ # Show labels (if any beyond the folder name, already parsed by normalize_message_row)
2054
2033
  labels = msg['labels']
2055
- labels = JSON.parse(labels) if labels.is_a?(String) rescue []
2056
- if labels.is_a?(Array) && labels.size > 0
2034
+ labels = [] unless labels.is_a?(Array)
2035
+ if labels.size > 0
2057
2036
  # Skip folder name (first label) if it matches the folder
2058
2037
  display_labels = labels.reject { |l| l == msg['folder'] }
2059
2038
  unless display_labels.empty?
@@ -2061,11 +2040,6 @@ module Heathrow
2061
2040
  end
2062
2041
  end
2063
2042
 
2064
- if ENV['DEBUG']
2065
- File.open('/tmp/heathrow_debug.log', 'a') do |f|
2066
- f.puts " Header built successfully, about to format content"
2067
- end
2068
- end
2069
2043
  header << ("─" * 60).fg(238)
2070
2044
 
2071
2045
  # Format message body — prefer HTML rendered via w3m
@@ -2134,13 +2108,6 @@ module Heathrow
2134
2108
  content = content_parts.join("\n")
2135
2109
  end
2136
2110
 
2137
- if ENV['DEBUG']
2138
- File.open('/tmp/heathrow_debug.log', 'a') do |f|
2139
- f.puts " Content formatted, about to set pane text"
2140
- f.puts " Content length: #{content.length}"
2141
- end
2142
- end
2143
-
2144
2111
  # Ensure content is UTF-8 compatible (handle emails with various encodings)
2145
2112
  # Create a mutable copy if frozen, then fix encoding
2146
2113
  content = content.dup if content.frozen?
@@ -2154,9 +2121,8 @@ module Heathrow
2154
2121
  # Colorize email content (quote levels + signature)
2155
2122
  content = colorize_email_content(content)
2156
2123
 
2157
- # Store maildir file path for calendar parser
2124
+ # Store maildir file path for calendar parser (metadata already parsed by normalize_message_row)
2158
2125
  meta = msg['metadata']
2159
- meta = JSON.parse(meta) if meta.is_a?(String) rescue nil
2160
2126
  @_current_render_msg_file = meta['maildir_file'] if meta.is_a?(Hash)
2161
2127
 
2162
2128
  # Attachment list under header, before body
@@ -2193,11 +2159,6 @@ module Heathrow
2193
2159
  @panes[:right].text = full_text
2194
2160
  @panes[:right].refresh
2195
2161
 
2196
- if ENV['DEBUG']
2197
- File.open('/tmp/heathrow_debug.log', 'a') do |f|
2198
- f.puts "render_message_content END - success"
2199
- end
2200
- end
2201
2162
  end
2202
2163
 
2203
2164
  def render_header_summary(header_msg)
@@ -2392,7 +2353,7 @@ module Heathrow
2392
2353
  end
2393
2354
 
2394
2355
  # Force threaded view to rebuild organizer with new messages
2395
- if @show_threaded && respond_to?(:organize_current_messages)
2356
+ if @show_threaded
2396
2357
  organize_current_messages(true)
2397
2358
  end
2398
2359
 
@@ -2455,7 +2416,7 @@ module Heathrow
2455
2416
  }
2456
2417
  end
2457
2418
 
2458
- def apply_view_filters_with_limit(view, limit)
2419
+ def build_db_filters(view)
2459
2420
  filters = view[:filters] || {}
2460
2421
  db_filters = {}
2461
2422
  if filters['rules'].is_a?(Array)
@@ -2476,6 +2437,11 @@ module Heathrow
2476
2437
  end
2477
2438
  end
2478
2439
  end
2440
+ db_filters
2441
+ end
2442
+
2443
+ def apply_view_filters_with_limit(view, limit)
2444
+ db_filters = build_db_filters(view)
2479
2445
  @filtered_messages = @db.get_messages(db_filters, limit, 0, light: true)
2480
2446
  end
2481
2447
 
@@ -2552,6 +2518,7 @@ module Heathrow
2552
2518
  # View switching
2553
2519
  def show_new_messages
2554
2520
  flush_pending_read
2521
+ invalidate_counts
2555
2522
  @current_view = 'N'
2556
2523
  @current_folder = nil
2557
2524
  @in_source_view = false
@@ -2561,8 +2528,8 @@ module Heathrow
2561
2528
  @last_rendered_index = nil # Force right pane refresh
2562
2529
 
2563
2530
  # Restore per-view threading mode
2564
- reset_threading if respond_to?(:reset_threading)
2565
- restore_view_thread_mode if respond_to?(:restore_view_thread_mode)
2531
+ reset_threading
2532
+ restore_view_thread_mode
2566
2533
 
2567
2534
  render_top_bar
2568
2535
 
@@ -2585,6 +2552,7 @@ module Heathrow
2585
2552
 
2586
2553
  def switch_to_view(key)
2587
2554
  flush_pending_read
2555
+ invalidate_counts
2588
2556
  @current_view = key
2589
2557
  @current_folder = nil
2590
2558
  @in_source_view = false
@@ -2592,8 +2560,8 @@ module Heathrow
2592
2560
  @last_rendered_index = nil # Force right pane refresh
2593
2561
 
2594
2562
  # Restore per-view threading mode
2595
- reset_threading if respond_to?(:reset_threading)
2596
- restore_view_thread_mode if respond_to?(:restore_view_thread_mode)
2563
+ reset_threading
2564
+ restore_view_thread_mode
2597
2565
 
2598
2566
  render_top_bar
2599
2567
 
@@ -2631,12 +2599,12 @@ module Heathrow
2631
2599
 
2632
2600
  def apply_view_filters(view)
2633
2601
  filters = view[:filters] || {}
2634
-
2602
+
2635
2603
  # Check for special filter types
2636
2604
  if filters['special'] == 'uncategorized'
2637
2605
  # Get all messages first
2638
2606
  all_messages = @db.get_messages({}, 1000, 0, light: true)
2639
-
2607
+
2640
2608
  # Get messages from all other configured views
2641
2609
  categorized_ids = Set.new
2642
2610
 
@@ -2648,7 +2616,7 @@ module Heathrow
2648
2616
  other_view[:filters].each do |key, value|
2649
2617
  symbolized_filters[key.to_sym] = value
2650
2618
  end
2651
-
2619
+
2652
2620
  view_messages = @db.get_messages(symbolized_filters, 1000, 0, light: true)
2653
2621
  view_messages.each { |msg| categorized_ids.add(msg['id']) }
2654
2622
  end
@@ -2657,32 +2625,12 @@ module Heathrow
2657
2625
  # Filter to only uncategorized messages
2658
2626
  @filtered_messages = all_messages.reject { |msg| categorized_ids.include?(msg['id']) }
2659
2627
  else
2660
- # Parse rules-based filters into database filter format
2661
- db_filters = {}
2662
-
2663
- if filters['rules'].is_a?(Array)
2664
- # Convert rules array to simple database filters
2665
- filters['rules'].each do |rule|
2666
- field = rule['field']
2667
- value = rule['value']
2668
- case rule['op']
2669
- when '='
2670
- db_filters[field.to_sym] = value
2671
- when 'like'
2672
- case field
2673
- when 'search' then db_filters[:search] = value
2674
- when 'sender' then db_filters[:sender_pattern] = value
2675
- when 'subject' then db_filters[:subject_pattern] = value
2676
- when 'folder' then db_filters[:maildir_folder] = value
2677
- when 'label' then db_filters[:label] = value
2678
- when 'source' then db_filters[:source_name] = value
2679
- end
2680
- end
2681
- end
2682
- else
2683
- # Legacy simple filters - convert string keys to symbols
2628
+ db_filters = build_db_filters(view)
2629
+
2630
+ # Legacy simple filters (no rules array)
2631
+ if !filters['rules'].is_a?(Array)
2684
2632
  filters.each do |key, value|
2685
- next if key == 'rules' # Skip rules key
2633
+ next if key == 'rules'
2686
2634
  db_filters[key.to_sym] = value
2687
2635
  end
2688
2636
  end
@@ -2694,7 +2642,6 @@ module Heathrow
2694
2642
  ensure_all_feeds_loaded(db_filters[:source_id].to_i)
2695
2643
  end
2696
2644
  end
2697
-
2698
2645
  end
2699
2646
 
2700
2647
  # Message operations
@@ -2748,7 +2695,7 @@ module Heathrow
2748
2695
  end
2749
2696
 
2750
2697
  # Re-render
2751
- render_message_list_threaded if respond_to?(:render_message_list_threaded)
2698
+ render_message_list_threaded
2752
2699
  render_message_content
2753
2700
  end
2754
2701
 
@@ -2786,7 +2733,7 @@ module Heathrow
2786
2733
  end
2787
2734
 
2788
2735
  # Re-render
2789
- render_message_list_threaded if respond_to?(:render_message_list_threaded)
2736
+ render_message_list_threaded
2790
2737
 
2791
2738
  # Restore position if we had one saved
2792
2739
  if restore_position
@@ -2820,7 +2767,7 @@ module Heathrow
2820
2767
  def toggle_tag
2821
2768
  msg = current_message
2822
2769
  return unless msg
2823
- return if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
2770
+ return if header_message?(msg)
2824
2771
  return unless msg['id']
2825
2772
 
2826
2773
  if @tagged_messages.include?(msg['id'])
@@ -2847,7 +2794,7 @@ module Heathrow
2847
2794
 
2848
2795
  count = 0
2849
2796
  @filtered_messages.each do |msg|
2850
- next if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
2797
+ next if header_message?(msg)
2851
2798
  next unless msg['id']
2852
2799
 
2853
2800
  match = [msg['sender'], msg['subject'], msg['content']].compact.any? { |f| f.match?(regex) }
@@ -2867,7 +2814,7 @@ module Heathrow
2867
2814
 
2868
2815
  # Tag/untag all messages in current view
2869
2816
  def tag_all_toggle
2870
- msgs = @filtered_messages.reject { |m| m['is_header'] || m['is_channel_header'] || m['is_thread_header'] || m['is_dm_header'] }
2817
+ msgs = @filtered_messages.reject { |m| header_message?(m) }
2871
2818
  ids = msgs.map { |m| m['id'] }.compact
2872
2819
  if ids.all? { |id| @tagged_messages.include?(id) }
2873
2820
  # All tagged, untag all
@@ -2895,7 +2842,7 @@ module Heathrow
2895
2842
  idx = (@index + 1 + i) % display.size
2896
2843
  msg = display[idx]
2897
2844
  next unless msg
2898
- next if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
2845
+ next if header_message?(msg)
2899
2846
  if msg['is_read'].to_i == 0
2900
2847
  @index = idx
2901
2848
  track_browsed_message
@@ -2914,14 +2861,14 @@ module Heathrow
2914
2861
  if unread_msgs.any?
2915
2862
  # Re-render to rebuild display_messages with uncollapsed sections
2916
2863
  organize_current_messages(true)
2917
- render_message_list_threaded if respond_to?(:render_message_list_threaded)
2864
+ render_message_list_threaded
2918
2865
  new_display = @display_messages || @filtered_messages
2919
2866
  # Find the first visible unread after current position
2920
2867
  new_display.size.times do |i|
2921
2868
  idx = (@index + 1 + i) % new_display.size
2922
2869
  m = new_display[idx]
2923
2870
  next unless m && m['id'] && m['is_read'].to_i == 0
2924
- next if m['is_header'] || m['is_channel_header'] || m['is_thread_header'] || m['is_dm_header']
2871
+ next if header_message?(m)
2925
2872
  @index = idx
2926
2873
  track_browsed_message
2927
2874
  render_all
@@ -2943,7 +2890,7 @@ module Heathrow
2943
2890
  idx = (@index - 1 - i) % size
2944
2891
  msg = display[idx]
2945
2892
  next unless msg
2946
- next if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
2893
+ next if header_message?(msg)
2947
2894
  if msg['is_read'].to_i == 0
2948
2895
  @index = idx
2949
2896
  track_browsed_message
@@ -2960,14 +2907,14 @@ module Heathrow
2960
2907
  end
2961
2908
  if unread_msgs.any?
2962
2909
  organize_current_messages(true)
2963
- render_message_list_threaded if respond_to?(:render_message_list_threaded)
2910
+ render_message_list_threaded
2964
2911
  new_display = @display_messages || @filtered_messages
2965
2912
  new_size = new_display.size
2966
2913
  new_size.times do |i|
2967
2914
  idx = (@index - 1 - i) % new_size
2968
2915
  m = new_display[idx]
2969
2916
  next unless m && m['id'] && m['is_read'].to_i == 0
2970
- next if m['is_header'] || m['is_channel_header'] || m['is_thread_header'] || m['is_dm_header']
2917
+ next if header_message?(m)
2971
2918
  @index = idx
2972
2919
  track_browsed_message
2973
2920
  render_all
@@ -3053,7 +3000,7 @@ module Heathrow
3053
3000
  def open_message_external
3054
3001
  msg = current_message
3055
3002
  return unless msg
3056
- return if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header']
3003
+ return if header_message?(msg)
3057
3004
  msg = ensure_full_message(msg)
3058
3005
 
3059
3006
  # Mark as read
@@ -3147,7 +3094,7 @@ module Heathrow
3147
3094
  def view_attachments
3148
3095
  msg = current_message
3149
3096
  return unless msg
3150
- return set_feedback("Select a message first", 245, 2) if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
3097
+ return set_feedback("Select a message first", 245, 2) if header_message?(msg)
3151
3098
 
3152
3099
  # Load full message if needed (use numeric id only)
3153
3100
  msg_id = msg['id']
@@ -3354,7 +3301,7 @@ module Heathrow
3354
3301
  if @sort_order == 'unread'
3355
3302
  sort_messages
3356
3303
  # Reset threading to reorganize with new unread counts (preserve collapsed state)
3357
- reset_threading(true) if respond_to?(:reset_threading)
3304
+ reset_threading(true)
3358
3305
  end
3359
3306
 
3360
3307
  # Update displays if UI is initialized
@@ -3451,6 +3398,8 @@ module Heathrow
3451
3398
  rows = nil
3452
3399
  end
3453
3400
 
3401
+ organized_sections = (@show_threaded && @organizer) ? @organizer.get_organized_view(@sort_order, @sort_inverted) : nil
3402
+
3454
3403
  if rows
3455
3404
  # Sync maildir flags for all affected messages
3456
3405
  count = rows.size
@@ -3465,8 +3414,8 @@ module Heathrow
3465
3414
  next unless list
3466
3415
  list.each { |m| m['is_read'] = 1 if m['id'] && m['is_read'].to_i == 0 }
3467
3416
  end
3468
- if @show_threaded && @organizer
3469
- @organizer.get_organized_view(@sort_order, @sort_inverted).each do |section|
3417
+ if organized_sections
3418
+ organized_sections.each do |section|
3470
3419
  next unless section[:messages]
3471
3420
  section[:messages].each { |m| m['is_read'] = 1 if m['is_read'].to_i == 0 }
3472
3421
  end
@@ -3481,14 +3430,15 @@ module Heathrow
3481
3430
  return if msgs.nil? || msgs.empty?
3482
3431
  count = mark_messages_read(msgs)
3483
3432
  # Also mark messages inside collapsed sections
3484
- if @show_threaded && @organizer
3485
- @organizer.get_organized_view(@sort_order, @sort_inverted).each do |section|
3433
+ if organized_sections
3434
+ organized_sections.each do |section|
3486
3435
  next unless section[:messages]
3487
3436
  count += mark_messages_read(section[:messages])
3488
3437
  end
3489
3438
  end
3490
3439
  end
3491
3440
 
3441
+ invalidate_counts
3492
3442
  set_feedback("Marked #{count} messages as read", 156, 3)
3493
3443
  render_top_bar
3494
3444
  render_message_list
@@ -3499,7 +3449,7 @@ module Heathrow
3499
3449
  def mark_messages_read(msgs)
3500
3450
  count = 0
3501
3451
  msgs.each do |msg|
3502
- next if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
3452
+ next if header_message?(msg)
3503
3453
  next if msg['is_read'].to_i == 1
3504
3454
  next unless msg['id']
3505
3455
  @db.mark_as_read(msg['id'])
@@ -3516,7 +3466,7 @@ module Heathrow
3516
3466
  return nil unless msg && @show_threaded && @organizer
3517
3467
 
3518
3468
  # If standing on a header, use its section_messages directly
3519
- if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
3469
+ if header_message?(msg)
3520
3470
  return msg['section_messages'] if msg['section_messages']
3521
3471
  end
3522
3472
 
@@ -3524,7 +3474,7 @@ module Heathrow
3524
3474
  idx = @index - 1
3525
3475
  while idx >= 0
3526
3476
  prev = @display_messages[idx]
3527
- if prev && (prev['is_header'] || prev['is_channel_header'] || prev['is_thread_header'] || prev['is_dm_header'])
3477
+ if prev && (header_message?(prev))
3528
3478
  return prev['section_messages'] if prev['section_messages']
3529
3479
  break
3530
3480
  end
@@ -3566,7 +3516,7 @@ module Heathrow
3566
3516
  # Toggle all messages in this section
3567
3517
  messages.each do |msg|
3568
3518
  next unless msg['id'] && !msg['id'].to_s.start_with?('header_') # Skip synthetic headers
3569
-
3519
+
3570
3520
  if all_read
3571
3521
  @db.mark_as_unread(msg['id'])
3572
3522
  msg['is_read'] = 0
@@ -3575,12 +3525,13 @@ module Heathrow
3575
3525
  msg['is_read'] = 1
3576
3526
  end
3577
3527
  end
3578
-
3528
+ invalidate_counts
3529
+
3579
3530
  # Re-sort if sorting by unread
3580
3531
  if @sort_order == 'unread'
3581
3532
  sort_messages
3582
3533
  # Reset threading to reorganize with new unread counts (preserve collapsed state)
3583
- reset_threading(true) if respond_to?(:reset_threading)
3534
+ reset_threading(true)
3584
3535
  end
3585
3536
 
3586
3537
  # Update displays if UI is initialized
@@ -3601,6 +3552,7 @@ module Heathrow
3601
3552
 
3602
3553
  # Toggle the specific message
3603
3554
  current_read_status = msg['is_read'].to_i
3555
+ invalidate_counts
3604
3556
 
3605
3557
  if current_read_status == 1
3606
3558
  success = @db.mark_as_unread(msg['id'])
@@ -3620,10 +3572,10 @@ module Heathrow
3620
3572
  if is_unread_view?
3621
3573
  if @show_threaded
3622
3574
  # In threaded view, need to reload filtered messages to rebuild threads
3623
- reset_threading if respond_to?(:reset_threading)
3575
+ reset_threading
3624
3576
  @filtered_messages = @db.get_messages({is_read: false}, 1000, 0, light: true)
3625
3577
  sort_messages
3626
- organize_current_messages(force_reinit: true) if respond_to?(:organize_current_messages)
3578
+ organize_current_messages(force_reinit: true)
3627
3579
  @index = [@index, filtered_messages_size - 1].min
3628
3580
  @index = 0 if @index < 0
3629
3581
  else
@@ -3640,7 +3592,7 @@ module Heathrow
3640
3592
  if @sort_order == 'unread'
3641
3593
  sort_messages
3642
3594
  # Reset threading to reorganize with new unread counts (preserve collapsed state)
3643
- reset_threading(true) if respond_to?(:reset_threading)
3595
+ reset_threading(true)
3644
3596
  end
3645
3597
 
3646
3598
  # Update displays immediately if UI is initialized
@@ -3696,6 +3648,7 @@ module Heathrow
3696
3648
  return unless msg
3697
3649
  @db.toggle_star(msg['id'])
3698
3650
  msg['is_starred'] = msg['is_starred'] == 1 ? 0 : 1
3651
+ invalidate_counts
3699
3652
 
3700
3653
  # Sync flagged status to Maildir file
3701
3654
  sync_maildir_flag(msg, 'F', msg['is_starred'] == 1)
@@ -3986,7 +3939,7 @@ module Heathrow
3986
3939
  @current_source_filter = "Folder: #{folder_name}"
3987
3940
 
3988
3941
  # Reset threading state so old @display_messages don't persist
3989
- reset_threading if respond_to?(:reset_threading)
3942
+ reset_threading
3990
3943
 
3991
3944
  # Show progress while loading
3992
3945
  @panes[:bottom].text = " Loading #{folder_name}...".fg(226)
@@ -4273,7 +4226,7 @@ module Heathrow
4273
4226
  if @show_threaded
4274
4227
  @display_messages&.reject! { |m| m['id'] && filed_ids.include?(m['id']) }
4275
4228
  # Force organizer to rebuild from updated @filtered_messages
4276
- organize_current_messages(true) if respond_to?(:organize_current_messages)
4229
+ organize_current_messages(true)
4277
4230
  end
4278
4231
  @tagged_messages.clear if @tagged_messages.size > 0
4279
4232
  @index = [@index, (@filtered_messages.size - 1)].min
@@ -4963,7 +4916,7 @@ module Heathrow
4963
4916
  @filtered_messages = results
4964
4917
  sort_messages
4965
4918
  @index = 0
4966
- reset_threading if respond_to?(:reset_threading)
4919
+ reset_threading
4967
4920
  set_feedback("#{results.size} results for: #{query}", 156, 0)
4968
4921
  render_all
4969
4922
  end
@@ -4987,7 +4940,7 @@ module Heathrow
4987
4940
  else
4988
4941
  msg = current_message
4989
4942
  return unless msg
4990
- return if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header']
4943
+ return if header_message?(msg)
4991
4944
 
4992
4945
  if @delete_marked.include?(msg['id'])
4993
4946
  @delete_marked.delete(msg['id'])
@@ -5052,7 +5005,7 @@ module Heathrow
5052
5005
  @delete_marked.clear
5053
5006
 
5054
5007
  # Force threaded view to rebuild with purged messages gone
5055
- reset_threading(true) if respond_to?(:reset_threading)
5008
+ reset_threading(true)
5056
5009
 
5057
5010
  # Position cursor on the message above the first deleted one
5058
5011
  new_display = @display_messages || @filtered_messages
@@ -5100,7 +5053,7 @@ module Heathrow
5100
5053
  msg = ensure_full_message(msg)
5101
5054
 
5102
5055
  # Don't allow replying to header messages
5103
- if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
5056
+ if header_message?(msg)
5104
5057
  set_feedback("Cannot reply to section headers. Select a message.", 226, 3)
5105
5058
  render_bottom_bar
5106
5059
  return
@@ -5241,7 +5194,7 @@ module Heathrow
5241
5194
  def edit_message_content
5242
5195
  msg = current_message
5243
5196
  return unless msg
5244
- return if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header']
5197
+ return if header_message?(msg)
5245
5198
  msg = ensure_full_message(msg)
5246
5199
 
5247
5200
  source_id = msg['source_id']
@@ -6051,9 +6004,9 @@ module Heathrow
6051
6004
  @panes[:right].w = @w - @panes[:left].w - 4
6052
6005
 
6053
6006
  # Reset threading for sort changes
6054
- reset_threading(true) if respond_to?(:reset_threading)
6007
+ reset_threading(true)
6055
6008
  sort_messages
6056
- organize_current_messages(true) if respond_to?(:organize_current_messages)
6009
+ organize_current_messages(true)
6057
6010
 
6058
6011
  Rcurses.clear_screen
6059
6012
  @panes.each_value { |p| p.cleanup if p.respond_to?(:cleanup) }
@@ -6322,13 +6275,13 @@ module Heathrow
6322
6275
  end
6323
6276
 
6324
6277
  # Reset threading state to force reorganization with new sort (preserve collapsed state)
6325
- reset_threading(true) if respond_to?(:reset_threading)
6278
+ reset_threading(true)
6326
6279
 
6327
6280
  # Re-sort and redisplay messages
6328
6281
  sort_messages
6329
6282
 
6330
6283
  # Force reinit the organizer with the newly sorted messages
6331
- organize_current_messages(true) if respond_to?(:organize_current_messages)
6284
+ organize_current_messages(true)
6332
6285
 
6333
6286
  @index = 0 # Reset to top
6334
6287
  render_all # Re-render everything to show the new sort
@@ -6354,13 +6307,13 @@ module Heathrow
6354
6307
  end
6355
6308
 
6356
6309
  # Reset threading state to force reorganization with new sort (preserve collapsed state)
6357
- reset_threading(true) if respond_to?(:reset_threading)
6310
+ reset_threading(true)
6358
6311
 
6359
6312
  # Re-sort and redisplay messages
6360
6313
  sort_messages
6361
6314
 
6362
6315
  # Force reinit the organizer with the newly sorted messages
6363
- organize_current_messages(true) if respond_to?(:organize_current_messages)
6316
+ organize_current_messages(true)
6364
6317
 
6365
6318
  @index = 0 # Reset to top
6366
6319
  render_all # Re-render everything
@@ -6386,7 +6339,11 @@ module Heathrow
6386
6339
 
6387
6340
  # Make sure we have a mutable array
6388
6341
  @filtered_messages = @filtered_messages.dup if @filtered_messages.frozen?
6389
-
6342
+
6343
+ # Pre-compute timestamp cache to avoid repeated parsing in sort comparisons
6344
+ ts_cache = {}
6345
+ @filtered_messages.each { |m| ts_cache[m.object_id] = timestamp_to_time(m['timestamp']) }
6346
+
6390
6347
  begin
6391
6348
  case @sort_order
6392
6349
  when 'alphabetical'
@@ -6410,7 +6367,7 @@ module Heathrow
6410
6367
  else
6411
6368
  # If subjects are the same, sort by timestamp
6412
6369
  begin
6413
- timestamp_to_time(b["timestamp"]) <=> timestamp_to_time(a["timestamp"])
6370
+ ts_cache[b.object_id] <=> ts_cache[a.object_id]
6414
6371
  rescue
6415
6372
  0
6416
6373
  end
@@ -6428,7 +6385,7 @@ module Heathrow
6428
6385
  else
6429
6386
  # Safe timestamp comparison
6430
6387
  begin
6431
- timestamp_to_time(b["timestamp"]) <=> timestamp_to_time(a["timestamp"])
6388
+ ts_cache[b.object_id] <=> ts_cache[a.object_id]
6432
6389
  rescue
6433
6390
  0
6434
6391
  end
@@ -6443,7 +6400,7 @@ module Heathrow
6443
6400
  else
6444
6401
  # Safe timestamp comparison
6445
6402
  begin
6446
- timestamp_to_time(b["timestamp"]) <=> timestamp_to_time(a["timestamp"])
6403
+ ts_cache[b.object_id] <=> ts_cache[a.object_id]
6447
6404
  rescue
6448
6405
  0 # If timestamp parsing fails, consider them equal
6449
6406
  end
@@ -6460,7 +6417,7 @@ module Heathrow
6460
6417
  conv_cmp
6461
6418
  else
6462
6419
  begin
6463
- timestamp_to_time(b["timestamp"]) <=> timestamp_to_time(a["timestamp"])
6420
+ ts_cache[b.object_id] <=> ts_cache[a.object_id]
6464
6421
  rescue
6465
6422
  0
6466
6423
  end
@@ -6478,7 +6435,7 @@ module Heathrow
6478
6435
  else
6479
6436
  # Safe timestamp comparison
6480
6437
  begin
6481
- timestamp_to_time(b["timestamp"]) <=> timestamp_to_time(a["timestamp"])
6438
+ ts_cache[b.object_id] <=> ts_cache[a.object_id]
6482
6439
  rescue
6483
6440
  0 # If timestamp parsing fails, consider them equal
6484
6441
  end
@@ -6488,7 +6445,7 @@ module Heathrow
6488
6445
  # Sort by timestamp descending (newest first)
6489
6446
  @filtered_messages.sort! do |a, b|
6490
6447
  begin
6491
- timestamp_to_time(b["timestamp"]) <=> timestamp_to_time(a["timestamp"])
6448
+ ts_cache[b.object_id] <=> ts_cache[a.object_id]
6492
6449
  rescue
6493
6450
  0 # If timestamp parsing fails, consider them equal
6494
6451
  end
@@ -6512,8 +6469,7 @@ module Heathrow
6512
6469
  @panes[:right].ix = 0 # Reset scroll position
6513
6470
  help_text = get_help_text # Use the colored version directly
6514
6471
 
6515
- # Debug: write help text to file to verify colors are included
6516
- File.write('/tmp/heathrow_help_debug.txt', help_text) if ENV['DEBUG']
6472
+
6517
6473
 
6518
6474
  # Just set the text and let rcurses handle everything
6519
6475
  @panes[:right].text = help_text
@@ -21,6 +21,8 @@ module Heathrow
21
21
  @all_start_collapsed = true # Start with everything collapsed
22
22
  @section_order = nil # Custom section order (array of names)
23
23
  @view_thread_modes = {} # Per-view threading mode: key => {threaded:, folder:}
24
+ @organized_cache = nil
25
+ @organized_cache_key = nil
24
26
  end
25
27
 
26
28
  # Toggle between flat and threaded view
@@ -69,20 +71,10 @@ module Heathrow
69
71
  def save_view_thread_mode
70
72
  return unless @current_view
71
73
  @view_thread_modes[@current_view] = { threaded: @show_threaded, folder: @group_by_folder }
72
-
73
- # Persist to DB
74
- view = @views[@current_view]
75
- if view && view[:filters].is_a?(Hash)
76
- view[:filters]['view_thread_mode'] = thread_mode_key
77
- @db.execute("UPDATE views SET filters = ?, updated_at = ? WHERE id = ?",
78
- [JSON.generate(view[:filters]), Time.now.to_i, view[:id]])
79
- else
80
- # Built-in views (A, N): store in settings table
81
- @db.execute(
82
- "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)",
83
- ["thread_mode_#{@current_view}", thread_mode_key, Time.now.to_i]
84
- )
85
- end
74
+ @db.execute(
75
+ "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)",
76
+ ["thread_mode_#{@current_view}", thread_mode_key, Time.now.to_i]
77
+ )
86
78
  end
87
79
 
88
80
  # Restore threading mode for the active view
@@ -97,19 +89,12 @@ module Heathrow
97
89
  return true
98
90
  end
99
91
 
100
- # Load from DB
101
- mode_key = nil
102
- view = @views[@current_view]
103
- if view && view[:filters].is_a?(Hash)
104
- mode_key = view[:filters]['view_thread_mode']
105
- end
106
- unless mode_key
107
- row = @db.db.get_first_row(
108
- "SELECT value FROM settings WHERE key = ?",
109
- ["thread_mode_#{@current_view}"]
110
- )
111
- mode_key = row && row['value']
112
- end
92
+ # Load from settings table
93
+ row = @db.db.get_first_row(
94
+ "SELECT value FROM settings WHERE key = ?",
95
+ ["thread_mode_#{@current_view}"]
96
+ )
97
+ mode_key = row && row['value']
113
98
  return false unless mode_key
114
99
 
115
100
  apply_thread_mode_key(mode_key)
@@ -161,6 +146,8 @@ module Heathrow
161
146
  @threading_initialized = false
162
147
  @display_messages = []
163
148
  @scroll_offset = 0 # Reset scroll position
149
+ @organized_cache = nil
150
+ @organized_cache_key = nil
164
151
 
165
152
  # Preserve or reset collapsed states
166
153
  if !preserve_collapsed_state
@@ -195,17 +182,15 @@ module Heathrow
195
182
  # Organize messages for current view
196
183
  def organize_current_messages(force_reinit = false)
197
184
  return unless @show_threaded
198
-
185
+
199
186
  # Only organize once per message set - capture the base messages on first run
200
187
  # OR if we're forcing reinitialization after a sort change
201
188
  if !@threading_initialized || force_reinit
202
189
  @base_messages = @filtered_messages.dup
203
190
  @threading_initialized = true
204
-
205
- File.open('/tmp/heathrow_debug.log', 'a') do |f|
206
- f.puts "THREADING: Creating organizer with #{@base_messages.size} base messages (#{force_reinit ? 'forced reinit' : 'first time'})"
207
- end if ENV['DEBUG']
208
-
191
+ @organized_cache = nil
192
+ @organized_cache_key = nil
193
+
209
194
  require_relative '../message_organizer'
210
195
  @organizer = MessageOrganizer.new(@base_messages, @db, group_by_folder: @group_by_folder)
211
196
  end
@@ -305,8 +290,15 @@ module Heathrow
305
290
  visible_messages = []
306
291
  current_index = 0
307
292
  @scroll_offset ||= 0 # Track scroll position
308
-
309
- organized = @organizer.get_organized_view(@sort_order, @sort_inverted)
293
+
294
+ cache_key = "#{@sort_order}|#{@sort_inverted}|#{@filtered_messages.size}"
295
+ if @organized_cache && @organized_cache_key == cache_key
296
+ organized = @organized_cache
297
+ else
298
+ organized = @organizer.get_organized_view(@sort_order, @sort_inverted)
299
+ @organized_cache = organized
300
+ @organized_cache_key = cache_key
301
+ end
310
302
 
311
303
  # Apply custom section order if set
312
304
  if @section_order && !@section_order.empty?
@@ -404,10 +396,6 @@ module Heathrow
404
396
  end
405
397
  end
406
398
 
407
- File.open('/tmp/heathrow_debug.log', 'a') do |f|
408
- f.puts "THREADING: Created #{@display_messages.size} display messages for navigation"
409
- end if ENV['DEBUG']
410
-
411
399
  # Give full text to rcurses and use its scrolling with markers
412
400
  @panes[:left].scroll = true
413
401
  new_text = lines.join("\n")
@@ -1,3 +1,3 @@
1
1
  module Heathrow
2
- VERSION = '0.7.5'
2
+ VERSION = '0.7.6'
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: heathrow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.5
4
+ version: 0.7.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-03-20 00:00:00.000000000 Z
12
+ date: 2026-03-21 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rcurses