heathrow 0.7.5 → 0.7.7

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: d7c8e6773f02f56ba4ac097d508bafaf526e2aed3bb327220614640d5c1bc3bd
4
+ data.tar.gz: 78db591713e7d944436a7c797c7099f337e5050bd4bce8689b38637330665056
5
5
  SHA512:
6
- metadata.gz: 4c98df39af32e449f46f7a4d7c58436d88cfd6e32499394624ad74c2b16c4e390512ecbe18fff5fbe0462c1bb76b18921b806a72403aa08e7e39ccfc7ac69ccc
7
- data.tar.gz: 9fb82e3608cb1fd6d63078b8584aad96deb3e43072421468b4c712d8c3ead53c192b7378c4d1cef75ada0f5f8d430e81d5847d71c27a04accfdf333f59fe01c8
6
+ metadata.gz: c7b866bfaaf9c2f2edd272be1950fd1730d0e40df9500779dbc51fa8f166e9a149472edd7b1099eea0ece4b713b62201e010cd165161c1a8a0f15725400a00ec
7
+ data.tar.gz: b1bde82078786c429268cf54cce8bef68de0ef03368612e90414c9b484e76181fe3aba59e1157daa7808c968128c750e565d273ea4c30044fa19a847314d76fb
@@ -375,9 +375,9 @@ module Heathrow
375
375
  # Handle sender pattern (supports regex via pipe separation)
376
376
  if filters[:sender_pattern]
377
377
  patterns = filters[:sender_pattern].split('|')
378
- conditions = patterns.map { "sender LIKE ?" }.join(' OR ')
378
+ conditions = patterns.map { "(sender LIKE ? OR sender_name LIKE ?)" }.join(' OR ')
379
379
  query += " AND (#{conditions})"
380
- params += patterns.map { |p| "%#{p}%" }
380
+ patterns.each { |p| params += ["%#{p}%", "%#{p}%"] }
381
381
  end
382
382
 
383
383
  # Handle subject pattern
@@ -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
@@ -826,6 +835,8 @@ module Heathrow
826
835
  label_message
827
836
  when 'v'
828
837
  view_attachments
838
+ when 'Z'
839
+ open_in_timely
829
840
  when 'I'
830
841
  ai_assistant
831
842
  when 'V'
@@ -1143,7 +1154,7 @@ module Heathrow
1143
1154
  set_feedback("Deleted: #{name} (#{deleted_msgs} messages removed)", 156, 3)
1144
1155
 
1145
1156
  # Re-load the view to reflect removed messages
1146
- reset_threading if respond_to?(:reset_threading)
1157
+ reset_threading
1147
1158
  switch_to_view(@current_view) if @current_view
1148
1159
  end
1149
1160
 
@@ -1362,10 +1373,15 @@ module Heathrow
1362
1373
  count_text = "#{total} sources"
1363
1374
  else
1364
1375
  # 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
1376
+ if @cached_unread.nil?
1377
+ real_msgs = @filtered_messages.reject { |m| header_message?(m) }
1378
+ @cached_unread = real_msgs.count { |m| m['is_read'].to_i == 0 }
1379
+ @cached_starred = real_msgs.count { |m| m['is_starred'].to_i == 1 }
1380
+ @cached_total = real_msgs.size
1381
+ end
1382
+ unread = @cached_unread
1383
+ starred = @cached_starred
1384
+ total = @cached_total
1369
1385
  count_text = "#{unread} unread / #{total} msgs"
1370
1386
  count_text += " / #{starred}*" if starred > 0
1371
1387
  end
@@ -1381,6 +1397,7 @@ module Heathrow
1381
1397
  when 'latest' then 'Latest'
1382
1398
  when 'alphabetical' then 'A-Z'
1383
1399
  when 'sender' then 'Sender'
1400
+ when 'from' then 'From'
1384
1401
  when 'unread' then 'Unread'
1385
1402
  when 'source' then 'Source'
1386
1403
  else @sort_order.capitalize
@@ -1392,7 +1409,7 @@ module Heathrow
1392
1409
 
1393
1410
  # Threading mode indicator
1394
1411
  mode_part = ""
1395
- if @current_view != 'S' && respond_to?(:thread_mode_label)
1412
+ if @current_view != 'S'
1396
1413
  mode_part = " [#{thread_mode_label}]".fg(245)
1397
1414
  end
1398
1415
 
@@ -1520,7 +1537,7 @@ module Heathrow
1520
1537
  end
1521
1538
 
1522
1539
  # Build the line with timestamp, icon and sender
1523
- icon = respond_to?(:get_source_icon) ? get_source_icon(msg['source_type']) : '•'
1540
+ icon = get_source_icon(msg['source_type'])
1524
1541
  line_prefix = "#{timestamp} #{icon} #{sender_display} "
1525
1542
 
1526
1543
  # Calculate remaining space for subject (use display_width for CJK)
@@ -1837,7 +1854,7 @@ module Heathrow
1837
1854
  show_loading("Loading sources...")
1838
1855
 
1839
1856
  # Reset threading state when changing views
1840
- reset_threading if respond_to?(:reset_threading)
1857
+ reset_threading
1841
1858
 
1842
1859
  # Reload source colors to ensure they're fresh
1843
1860
  load_source_colors
@@ -1885,6 +1902,7 @@ module Heathrow
1885
1902
 
1886
1903
  def show_all_messages
1887
1904
  flush_pending_read
1905
+ invalidate_counts
1888
1906
  @current_view = 'A'
1889
1907
  @current_folder = nil
1890
1908
  @in_source_view = false
@@ -1895,8 +1913,8 @@ module Heathrow
1895
1913
  @last_rendered_index = nil # Force right pane refresh
1896
1914
 
1897
1915
  # 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)
1916
+ reset_threading
1917
+ restore_view_thread_mode
1900
1918
 
1901
1919
  # Show view name instantly
1902
1920
  render_top_bar
@@ -1912,14 +1930,6 @@ module Heathrow
1912
1930
  def render_message_content
1913
1931
  current_msg = current_message
1914
1932
 
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
1933
  # Reset scroll position when switching messages
1924
1934
  if @last_rendered_index != @index
1925
1935
  @panes[:right].ix = 0
@@ -1947,23 +1957,11 @@ module Heathrow
1947
1957
  end
1948
1958
 
1949
1959
  # Special handling for headers (channel headers, thread headers, etc.)
1950
- if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header']
1960
+ if header_message?(msg)
1951
1961
  render_header_summary(msg)
1952
1962
  return
1953
1963
  end
1954
1964
 
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
1965
  # Format message header
1968
1966
  header = []
1969
1967
 
@@ -2000,23 +1998,20 @@ module Heathrow
2000
1998
  else
2001
1999
  # Regular message display
2002
2000
  header << "From: #{msg['sender']}".fg(2) if msg['sender']
2003
- # Show recipients (To field)
2001
+ # Show recipients (To field, already parsed by normalize_message_row)
2004
2002
  to = msg['recipients'] || msg['recipient']
2005
2003
  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
2004
+ to_str = to.is_a?(Array) ? to.join(', ') : to.to_s
2008
2005
  header << "To: #{to_str}".fg(2) unless to_str.empty?
2009
2006
  end
2010
- # Show CC recipients
2007
+ # Show CC recipients (already parsed by normalize_message_row)
2011
2008
  cc = msg['cc']
2012
2009
  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
2010
+ cc_str = cc.is_a?(Array) ? cc.join(', ') : cc.to_s
2015
2011
  header << "Cc: #{cc_str}".fg(2) unless cc_str.empty?
2016
2012
  end
2017
2013
  # For weechat, show channel name from metadata instead of content preview
2018
2014
  meta = msg['metadata']
2019
- meta = JSON.parse(meta) if meta.is_a?(String) rescue nil
2020
2015
  if meta.is_a?(Hash) && meta['channel_name']
2021
2016
  header << "Subject: #{meta['channel_name']}".b.fg(1)
2022
2017
  elsif msg['subject']
@@ -2024,22 +2019,9 @@ module Heathrow
2024
2019
  end
2025
2020
  end
2026
2021
 
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
2022
  # Parse timestamp using helper method
2034
2023
  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
-
2024
+
2043
2025
  header << "Date: #{date_str}".fg(240)
2044
2026
  if msg['source_type']
2045
2027
  source_label = case msg['source_type']
@@ -2050,10 +2032,10 @@ module Heathrow
2050
2032
  header << "Type: #{source_label}".fg(get_source_color(msg))
2051
2033
  end
2052
2034
 
2053
- # Show labels (if any beyond the folder name)
2035
+ # Show labels (if any beyond the folder name, already parsed by normalize_message_row)
2054
2036
  labels = msg['labels']
2055
- labels = JSON.parse(labels) if labels.is_a?(String) rescue []
2056
- if labels.is_a?(Array) && labels.size > 0
2037
+ labels = [] unless labels.is_a?(Array)
2038
+ if labels.size > 0
2057
2039
  # Skip folder name (first label) if it matches the folder
2058
2040
  display_labels = labels.reject { |l| l == msg['folder'] }
2059
2041
  unless display_labels.empty?
@@ -2061,11 +2043,6 @@ module Heathrow
2061
2043
  end
2062
2044
  end
2063
2045
 
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
2046
  header << ("─" * 60).fg(238)
2070
2047
 
2071
2048
  # Format message body — prefer HTML rendered via w3m
@@ -2134,13 +2111,6 @@ module Heathrow
2134
2111
  content = content_parts.join("\n")
2135
2112
  end
2136
2113
 
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
2114
  # Ensure content is UTF-8 compatible (handle emails with various encodings)
2145
2115
  # Create a mutable copy if frozen, then fix encoding
2146
2116
  content = content.dup if content.frozen?
@@ -2154,9 +2124,8 @@ module Heathrow
2154
2124
  # Colorize email content (quote levels + signature)
2155
2125
  content = colorize_email_content(content)
2156
2126
 
2157
- # Store maildir file path for calendar parser
2127
+ # Store maildir file path for calendar parser (metadata already parsed by normalize_message_row)
2158
2128
  meta = msg['metadata']
2159
- meta = JSON.parse(meta) if meta.is_a?(String) rescue nil
2160
2129
  @_current_render_msg_file = meta['maildir_file'] if meta.is_a?(Hash)
2161
2130
 
2162
2131
  # Attachment list under header, before body
@@ -2193,11 +2162,6 @@ module Heathrow
2193
2162
  @panes[:right].text = full_text
2194
2163
  @panes[:right].refresh
2195
2164
 
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
2165
  end
2202
2166
 
2203
2167
  def render_header_summary(header_msg)
@@ -2392,7 +2356,7 @@ module Heathrow
2392
2356
  end
2393
2357
 
2394
2358
  # Force threaded view to rebuild organizer with new messages
2395
- if @show_threaded && respond_to?(:organize_current_messages)
2359
+ if @show_threaded
2396
2360
  organize_current_messages(true)
2397
2361
  end
2398
2362
 
@@ -2455,7 +2419,7 @@ module Heathrow
2455
2419
  }
2456
2420
  end
2457
2421
 
2458
- def apply_view_filters_with_limit(view, limit)
2422
+ def build_db_filters(view)
2459
2423
  filters = view[:filters] || {}
2460
2424
  db_filters = {}
2461
2425
  if filters['rules'].is_a?(Array)
@@ -2476,6 +2440,11 @@ module Heathrow
2476
2440
  end
2477
2441
  end
2478
2442
  end
2443
+ db_filters
2444
+ end
2445
+
2446
+ def apply_view_filters_with_limit(view, limit)
2447
+ db_filters = build_db_filters(view)
2479
2448
  @filtered_messages = @db.get_messages(db_filters, limit, 0, light: true)
2480
2449
  end
2481
2450
 
@@ -2552,6 +2521,7 @@ module Heathrow
2552
2521
  # View switching
2553
2522
  def show_new_messages
2554
2523
  flush_pending_read
2524
+ invalidate_counts
2555
2525
  @current_view = 'N'
2556
2526
  @current_folder = nil
2557
2527
  @in_source_view = false
@@ -2561,8 +2531,8 @@ module Heathrow
2561
2531
  @last_rendered_index = nil # Force right pane refresh
2562
2532
 
2563
2533
  # 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)
2534
+ reset_threading
2535
+ restore_view_thread_mode
2566
2536
 
2567
2537
  render_top_bar
2568
2538
 
@@ -2585,6 +2555,7 @@ module Heathrow
2585
2555
 
2586
2556
  def switch_to_view(key)
2587
2557
  flush_pending_read
2558
+ invalidate_counts
2588
2559
  @current_view = key
2589
2560
  @current_folder = nil
2590
2561
  @in_source_view = false
@@ -2592,8 +2563,8 @@ module Heathrow
2592
2563
  @last_rendered_index = nil # Force right pane refresh
2593
2564
 
2594
2565
  # 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)
2566
+ reset_threading
2567
+ restore_view_thread_mode
2597
2568
 
2598
2569
  render_top_bar
2599
2570
 
@@ -2631,12 +2602,12 @@ module Heathrow
2631
2602
 
2632
2603
  def apply_view_filters(view)
2633
2604
  filters = view[:filters] || {}
2634
-
2605
+
2635
2606
  # Check for special filter types
2636
2607
  if filters['special'] == 'uncategorized'
2637
2608
  # Get all messages first
2638
2609
  all_messages = @db.get_messages({}, 1000, 0, light: true)
2639
-
2610
+
2640
2611
  # Get messages from all other configured views
2641
2612
  categorized_ids = Set.new
2642
2613
 
@@ -2648,7 +2619,7 @@ module Heathrow
2648
2619
  other_view[:filters].each do |key, value|
2649
2620
  symbolized_filters[key.to_sym] = value
2650
2621
  end
2651
-
2622
+
2652
2623
  view_messages = @db.get_messages(symbolized_filters, 1000, 0, light: true)
2653
2624
  view_messages.each { |msg| categorized_ids.add(msg['id']) }
2654
2625
  end
@@ -2657,32 +2628,12 @@ module Heathrow
2657
2628
  # Filter to only uncategorized messages
2658
2629
  @filtered_messages = all_messages.reject { |msg| categorized_ids.include?(msg['id']) }
2659
2630
  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
2631
+ db_filters = build_db_filters(view)
2632
+
2633
+ # Legacy simple filters (no rules array)
2634
+ if !filters['rules'].is_a?(Array)
2684
2635
  filters.each do |key, value|
2685
- next if key == 'rules' # Skip rules key
2636
+ next if key == 'rules'
2686
2637
  db_filters[key.to_sym] = value
2687
2638
  end
2688
2639
  end
@@ -2694,7 +2645,6 @@ module Heathrow
2694
2645
  ensure_all_feeds_loaded(db_filters[:source_id].to_i)
2695
2646
  end
2696
2647
  end
2697
-
2698
2648
  end
2699
2649
 
2700
2650
  # Message operations
@@ -2748,7 +2698,7 @@ module Heathrow
2748
2698
  end
2749
2699
 
2750
2700
  # Re-render
2751
- render_message_list_threaded if respond_to?(:render_message_list_threaded)
2701
+ render_message_list_threaded
2752
2702
  render_message_content
2753
2703
  end
2754
2704
 
@@ -2786,7 +2736,7 @@ module Heathrow
2786
2736
  end
2787
2737
 
2788
2738
  # Re-render
2789
- render_message_list_threaded if respond_to?(:render_message_list_threaded)
2739
+ render_message_list_threaded
2790
2740
 
2791
2741
  # Restore position if we had one saved
2792
2742
  if restore_position
@@ -2820,7 +2770,7 @@ module Heathrow
2820
2770
  def toggle_tag
2821
2771
  msg = current_message
2822
2772
  return unless msg
2823
- return if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
2773
+ return if header_message?(msg)
2824
2774
  return unless msg['id']
2825
2775
 
2826
2776
  if @tagged_messages.include?(msg['id'])
@@ -2847,7 +2797,7 @@ module Heathrow
2847
2797
 
2848
2798
  count = 0
2849
2799
  @filtered_messages.each do |msg|
2850
- next if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
2800
+ next if header_message?(msg)
2851
2801
  next unless msg['id']
2852
2802
 
2853
2803
  match = [msg['sender'], msg['subject'], msg['content']].compact.any? { |f| f.match?(regex) }
@@ -2867,7 +2817,7 @@ module Heathrow
2867
2817
 
2868
2818
  # Tag/untag all messages in current view
2869
2819
  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'] }
2820
+ msgs = @filtered_messages.reject { |m| header_message?(m) }
2871
2821
  ids = msgs.map { |m| m['id'] }.compact
2872
2822
  if ids.all? { |id| @tagged_messages.include?(id) }
2873
2823
  # All tagged, untag all
@@ -2895,7 +2845,7 @@ module Heathrow
2895
2845
  idx = (@index + 1 + i) % display.size
2896
2846
  msg = display[idx]
2897
2847
  next unless msg
2898
- next if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
2848
+ next if header_message?(msg)
2899
2849
  if msg['is_read'].to_i == 0
2900
2850
  @index = idx
2901
2851
  track_browsed_message
@@ -2914,14 +2864,14 @@ module Heathrow
2914
2864
  if unread_msgs.any?
2915
2865
  # Re-render to rebuild display_messages with uncollapsed sections
2916
2866
  organize_current_messages(true)
2917
- render_message_list_threaded if respond_to?(:render_message_list_threaded)
2867
+ render_message_list_threaded
2918
2868
  new_display = @display_messages || @filtered_messages
2919
2869
  # Find the first visible unread after current position
2920
2870
  new_display.size.times do |i|
2921
2871
  idx = (@index + 1 + i) % new_display.size
2922
2872
  m = new_display[idx]
2923
2873
  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']
2874
+ next if header_message?(m)
2925
2875
  @index = idx
2926
2876
  track_browsed_message
2927
2877
  render_all
@@ -2943,7 +2893,7 @@ module Heathrow
2943
2893
  idx = (@index - 1 - i) % size
2944
2894
  msg = display[idx]
2945
2895
  next unless msg
2946
- next if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
2896
+ next if header_message?(msg)
2947
2897
  if msg['is_read'].to_i == 0
2948
2898
  @index = idx
2949
2899
  track_browsed_message
@@ -2960,14 +2910,14 @@ module Heathrow
2960
2910
  end
2961
2911
  if unread_msgs.any?
2962
2912
  organize_current_messages(true)
2963
- render_message_list_threaded if respond_to?(:render_message_list_threaded)
2913
+ render_message_list_threaded
2964
2914
  new_display = @display_messages || @filtered_messages
2965
2915
  new_size = new_display.size
2966
2916
  new_size.times do |i|
2967
2917
  idx = (@index - 1 - i) % new_size
2968
2918
  m = new_display[idx]
2969
2919
  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']
2920
+ next if header_message?(m)
2971
2921
  @index = idx
2972
2922
  track_browsed_message
2973
2923
  render_all
@@ -3050,10 +3000,87 @@ module Heathrow
3050
3000
  (msg['content'] && msg['content'] =~ /\A\s*<(!DOCTYPE|html|head|body)\b/i)
3051
3001
  end
3052
3002
 
3003
+ def open_in_timely
3004
+ msg = current_message
3005
+ return unless msg
3006
+ msg = ensure_full_message(msg)
3007
+
3008
+ # Try to find a date from calendar data or message timestamp
3009
+ timely_home = File.expand_path('~/.timely')
3010
+ return set_feedback("Timely not configured (~/.timely missing)", 196, 3) unless File.directory?(timely_home)
3011
+
3012
+ # Check for ICS attachment or inline calendar data
3013
+ date_str = nil
3014
+ meta = msg['metadata']
3015
+ meta = JSON.parse(meta) if meta.is_a?(String) rescue nil
3016
+ file = meta['maildir_file'] if meta.is_a?(Hash)
3017
+
3018
+ if file && File.exist?(file)
3019
+ begin
3020
+ require 'mail'
3021
+ mail = Mail.read(file)
3022
+ if mail.multipart?
3023
+ mail.parts.each do |part|
3024
+ ct = (part.content_type || '').downcase
3025
+ if ct.include?('calendar') || ct.include?('ics')
3026
+ ics = part.decoded
3027
+ vevent = ics[/BEGIN:VEVENT(.*?)END:VEVENT/m, 1]
3028
+ if vevent
3029
+ vevent = vevent.gsub(/\r?\n[ \t]/, '')
3030
+ if vevent =~ /^DTSTART;TZID=[^:]*:(\d{8})/i ||
3031
+ vevent =~ /^DTSTART:(\d{8})/i ||
3032
+ vevent =~ /^DTSTART;VALUE=DATE:(\d{8})/i
3033
+ d = $1
3034
+ date_str = "#{d[0,4]}-#{d[4,2]}-#{d[6,2]}"
3035
+ end
3036
+ end
3037
+ break
3038
+ end
3039
+ end
3040
+ end
3041
+ rescue => e
3042
+ # Fall through to timestamp
3043
+ end
3044
+ end
3045
+
3046
+ # Fallback: use message timestamp
3047
+ unless date_str
3048
+ ts = msg['timestamp'].to_i
3049
+ date_str = Time.at(ts).strftime('%Y-%m-%d') if ts > 0
3050
+ end
3051
+
3052
+ return set_feedback("Could not determine date for Timely", 245, 2) unless date_str
3053
+
3054
+ # Write goto file for Timely
3055
+ File.write(File.join(timely_home, 'goto'), date_str)
3056
+
3057
+ # Also copy ICS to incoming if it has calendar data
3058
+ if file && File.exist?(file)
3059
+ begin
3060
+ incoming = File.join(timely_home, 'incoming')
3061
+ FileUtils.mkdir_p(incoming)
3062
+ require 'mail'
3063
+ mail = Mail.read(file)
3064
+ mail.parts.each do |part|
3065
+ ct = (part.content_type || '').downcase
3066
+ if ct.include?('calendar') || ct.include?('ics')
3067
+ ics_file = File.join(incoming, "heathrow_#{msg['id']}.ics")
3068
+ File.write(ics_file, part.decoded) unless File.exist?(ics_file)
3069
+ break
3070
+ end
3071
+ end
3072
+ rescue => e
3073
+ # Non-fatal
3074
+ end
3075
+ end
3076
+
3077
+ set_feedback("Sent to Timely: #{date_str}", 156, 0)
3078
+ end
3079
+
3053
3080
  def open_message_external
3054
3081
  msg = current_message
3055
3082
  return unless msg
3056
- return if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header']
3083
+ return if header_message?(msg)
3057
3084
  msg = ensure_full_message(msg)
3058
3085
 
3059
3086
  # Mark as read
@@ -3147,7 +3174,7 @@ module Heathrow
3147
3174
  def view_attachments
3148
3175
  msg = current_message
3149
3176
  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']
3177
+ return set_feedback("Select a message first", 245, 2) if header_message?(msg)
3151
3178
 
3152
3179
  # Load full message if needed (use numeric id only)
3153
3180
  msg_id = msg['id']
@@ -3354,7 +3381,7 @@ module Heathrow
3354
3381
  if @sort_order == 'unread'
3355
3382
  sort_messages
3356
3383
  # Reset threading to reorganize with new unread counts (preserve collapsed state)
3357
- reset_threading(true) if respond_to?(:reset_threading)
3384
+ reset_threading(true)
3358
3385
  end
3359
3386
 
3360
3387
  # Update displays if UI is initialized
@@ -3451,6 +3478,8 @@ module Heathrow
3451
3478
  rows = nil
3452
3479
  end
3453
3480
 
3481
+ organized_sections = (@show_threaded && @organizer) ? @organizer.get_organized_view(@sort_order, @sort_inverted) : nil
3482
+
3454
3483
  if rows
3455
3484
  # Sync maildir flags for all affected messages
3456
3485
  count = rows.size
@@ -3465,8 +3494,8 @@ module Heathrow
3465
3494
  next unless list
3466
3495
  list.each { |m| m['is_read'] = 1 if m['id'] && m['is_read'].to_i == 0 }
3467
3496
  end
3468
- if @show_threaded && @organizer
3469
- @organizer.get_organized_view(@sort_order, @sort_inverted).each do |section|
3497
+ if organized_sections
3498
+ organized_sections.each do |section|
3470
3499
  next unless section[:messages]
3471
3500
  section[:messages].each { |m| m['is_read'] = 1 if m['is_read'].to_i == 0 }
3472
3501
  end
@@ -3481,14 +3510,15 @@ module Heathrow
3481
3510
  return if msgs.nil? || msgs.empty?
3482
3511
  count = mark_messages_read(msgs)
3483
3512
  # Also mark messages inside collapsed sections
3484
- if @show_threaded && @organizer
3485
- @organizer.get_organized_view(@sort_order, @sort_inverted).each do |section|
3513
+ if organized_sections
3514
+ organized_sections.each do |section|
3486
3515
  next unless section[:messages]
3487
3516
  count += mark_messages_read(section[:messages])
3488
3517
  end
3489
3518
  end
3490
3519
  end
3491
3520
 
3521
+ invalidate_counts
3492
3522
  set_feedback("Marked #{count} messages as read", 156, 3)
3493
3523
  render_top_bar
3494
3524
  render_message_list
@@ -3499,7 +3529,7 @@ module Heathrow
3499
3529
  def mark_messages_read(msgs)
3500
3530
  count = 0
3501
3531
  msgs.each do |msg|
3502
- next if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
3532
+ next if header_message?(msg)
3503
3533
  next if msg['is_read'].to_i == 1
3504
3534
  next unless msg['id']
3505
3535
  @db.mark_as_read(msg['id'])
@@ -3516,7 +3546,7 @@ module Heathrow
3516
3546
  return nil unless msg && @show_threaded && @organizer
3517
3547
 
3518
3548
  # 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']
3549
+ if header_message?(msg)
3520
3550
  return msg['section_messages'] if msg['section_messages']
3521
3551
  end
3522
3552
 
@@ -3524,7 +3554,7 @@ module Heathrow
3524
3554
  idx = @index - 1
3525
3555
  while idx >= 0
3526
3556
  prev = @display_messages[idx]
3527
- if prev && (prev['is_header'] || prev['is_channel_header'] || prev['is_thread_header'] || prev['is_dm_header'])
3557
+ if prev && (header_message?(prev))
3528
3558
  return prev['section_messages'] if prev['section_messages']
3529
3559
  break
3530
3560
  end
@@ -3566,7 +3596,7 @@ module Heathrow
3566
3596
  # Toggle all messages in this section
3567
3597
  messages.each do |msg|
3568
3598
  next unless msg['id'] && !msg['id'].to_s.start_with?('header_') # Skip synthetic headers
3569
-
3599
+
3570
3600
  if all_read
3571
3601
  @db.mark_as_unread(msg['id'])
3572
3602
  msg['is_read'] = 0
@@ -3575,12 +3605,13 @@ module Heathrow
3575
3605
  msg['is_read'] = 1
3576
3606
  end
3577
3607
  end
3578
-
3608
+ invalidate_counts
3609
+
3579
3610
  # Re-sort if sorting by unread
3580
3611
  if @sort_order == 'unread'
3581
3612
  sort_messages
3582
3613
  # Reset threading to reorganize with new unread counts (preserve collapsed state)
3583
- reset_threading(true) if respond_to?(:reset_threading)
3614
+ reset_threading(true)
3584
3615
  end
3585
3616
 
3586
3617
  # Update displays if UI is initialized
@@ -3601,6 +3632,7 @@ module Heathrow
3601
3632
 
3602
3633
  # Toggle the specific message
3603
3634
  current_read_status = msg['is_read'].to_i
3635
+ invalidate_counts
3604
3636
 
3605
3637
  if current_read_status == 1
3606
3638
  success = @db.mark_as_unread(msg['id'])
@@ -3620,10 +3652,10 @@ module Heathrow
3620
3652
  if is_unread_view?
3621
3653
  if @show_threaded
3622
3654
  # In threaded view, need to reload filtered messages to rebuild threads
3623
- reset_threading if respond_to?(:reset_threading)
3655
+ reset_threading
3624
3656
  @filtered_messages = @db.get_messages({is_read: false}, 1000, 0, light: true)
3625
3657
  sort_messages
3626
- organize_current_messages(force_reinit: true) if respond_to?(:organize_current_messages)
3658
+ organize_current_messages(force_reinit: true)
3627
3659
  @index = [@index, filtered_messages_size - 1].min
3628
3660
  @index = 0 if @index < 0
3629
3661
  else
@@ -3640,7 +3672,7 @@ module Heathrow
3640
3672
  if @sort_order == 'unread'
3641
3673
  sort_messages
3642
3674
  # Reset threading to reorganize with new unread counts (preserve collapsed state)
3643
- reset_threading(true) if respond_to?(:reset_threading)
3675
+ reset_threading(true)
3644
3676
  end
3645
3677
 
3646
3678
  # Update displays immediately if UI is initialized
@@ -3696,6 +3728,7 @@ module Heathrow
3696
3728
  return unless msg
3697
3729
  @db.toggle_star(msg['id'])
3698
3730
  msg['is_starred'] = msg['is_starred'] == 1 ? 0 : 1
3731
+ invalidate_counts
3699
3732
 
3700
3733
  # Sync flagged status to Maildir file
3701
3734
  sync_maildir_flag(msg, 'F', msg['is_starred'] == 1)
@@ -3986,7 +4019,7 @@ module Heathrow
3986
4019
  @current_source_filter = "Folder: #{folder_name}"
3987
4020
 
3988
4021
  # Reset threading state so old @display_messages don't persist
3989
- reset_threading if respond_to?(:reset_threading)
4022
+ reset_threading
3990
4023
 
3991
4024
  # Show progress while loading
3992
4025
  @panes[:bottom].text = " Loading #{folder_name}...".fg(226)
@@ -4273,7 +4306,7 @@ module Heathrow
4273
4306
  if @show_threaded
4274
4307
  @display_messages&.reject! { |m| m['id'] && filed_ids.include?(m['id']) }
4275
4308
  # Force organizer to rebuild from updated @filtered_messages
4276
- organize_current_messages(true) if respond_to?(:organize_current_messages)
4309
+ organize_current_messages(true)
4277
4310
  end
4278
4311
  @tagged_messages.clear if @tagged_messages.size > 0
4279
4312
  @index = [@index, (@filtered_messages.size - 1)].min
@@ -4963,7 +4996,7 @@ module Heathrow
4963
4996
  @filtered_messages = results
4964
4997
  sort_messages
4965
4998
  @index = 0
4966
- reset_threading if respond_to?(:reset_threading)
4999
+ reset_threading
4967
5000
  set_feedback("#{results.size} results for: #{query}", 156, 0)
4968
5001
  render_all
4969
5002
  end
@@ -4987,7 +5020,7 @@ module Heathrow
4987
5020
  else
4988
5021
  msg = current_message
4989
5022
  return unless msg
4990
- return if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header']
5023
+ return if header_message?(msg)
4991
5024
 
4992
5025
  if @delete_marked.include?(msg['id'])
4993
5026
  @delete_marked.delete(msg['id'])
@@ -5052,7 +5085,7 @@ module Heathrow
5052
5085
  @delete_marked.clear
5053
5086
 
5054
5087
  # Force threaded view to rebuild with purged messages gone
5055
- reset_threading(true) if respond_to?(:reset_threading)
5088
+ reset_threading(true)
5056
5089
 
5057
5090
  # Position cursor on the message above the first deleted one
5058
5091
  new_display = @display_messages || @filtered_messages
@@ -5100,7 +5133,7 @@ module Heathrow
5100
5133
  msg = ensure_full_message(msg)
5101
5134
 
5102
5135
  # Don't allow replying to header messages
5103
- if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header'] || msg['is_dm_header']
5136
+ if header_message?(msg)
5104
5137
  set_feedback("Cannot reply to section headers. Select a message.", 226, 3)
5105
5138
  render_bottom_bar
5106
5139
  return
@@ -5241,7 +5274,7 @@ module Heathrow
5241
5274
  def edit_message_content
5242
5275
  msg = current_message
5243
5276
  return unless msg
5244
- return if msg['is_header'] || msg['is_channel_header'] || msg['is_thread_header']
5277
+ return if header_message?(msg)
5245
5278
  msg = ensure_full_message(msg)
5246
5279
 
5247
5280
  source_id = msg['source_id']
@@ -5866,7 +5899,7 @@ module Heathrow
5866
5899
  ['%d %b %H:%M', 'DD Mon HH:MM'],
5867
5900
  ['%b %d %H:%M', 'Mon DD HH:MM']
5868
5901
  ]
5869
- sort_orders = ['latest', 'alphabetical', 'sender', 'conversation', 'unread', 'source']
5902
+ sort_orders = ['latest', 'alphabetical', 'sender', 'from', 'conversation', 'unread', 'source']
5870
5903
  border_labels = ['none', 'right', 'both', 'left']
5871
5904
  # Build view choices: A, N, plus any user-defined views
5872
5905
  view_choices = [['A', 'All'], ['N', 'New/Unread']]
@@ -6051,9 +6084,9 @@ module Heathrow
6051
6084
  @panes[:right].w = @w - @panes[:left].w - 4
6052
6085
 
6053
6086
  # Reset threading for sort changes
6054
- reset_threading(true) if respond_to?(:reset_threading)
6087
+ reset_threading(true)
6055
6088
  sort_messages
6056
- organize_current_messages(true) if respond_to?(:organize_current_messages)
6089
+ organize_current_messages(true)
6057
6090
 
6058
6091
  Rcurses.clear_screen
6059
6092
  @panes.each_value { |p| p.cleanup if p.respond_to?(:cleanup) }
@@ -6296,11 +6329,12 @@ module Heathrow
6296
6329
  end
6297
6330
 
6298
6331
  def cycle_sort_order
6299
- # Cycle through: latest -> alphabetical -> sender -> conversation -> unread -> source -> latest
6332
+ # Cycle through: latest -> alphabetical -> sender -> from -> conversation -> unread -> source -> latest
6300
6333
  @sort_order = case @sort_order
6301
6334
  when 'latest' then 'alphabetical'
6302
6335
  when 'alphabetical' then 'sender'
6303
- when 'sender' then 'conversation'
6336
+ when 'sender' then 'from'
6337
+ when 'from' then 'conversation'
6304
6338
  when 'conversation' then 'unread'
6305
6339
  when 'unread' then 'source'
6306
6340
  else 'latest' # This handles 'source' and any other value
@@ -6322,13 +6356,13 @@ module Heathrow
6322
6356
  end
6323
6357
 
6324
6358
  # Reset threading state to force reorganization with new sort (preserve collapsed state)
6325
- reset_threading(true) if respond_to?(:reset_threading)
6359
+ reset_threading(true)
6326
6360
 
6327
6361
  # Re-sort and redisplay messages
6328
6362
  sort_messages
6329
6363
 
6330
6364
  # Force reinit the organizer with the newly sorted messages
6331
- organize_current_messages(true) if respond_to?(:organize_current_messages)
6365
+ organize_current_messages(true)
6332
6366
 
6333
6367
  @index = 0 # Reset to top
6334
6368
  render_all # Re-render everything to show the new sort
@@ -6354,13 +6388,13 @@ module Heathrow
6354
6388
  end
6355
6389
 
6356
6390
  # Reset threading state to force reorganization with new sort (preserve collapsed state)
6357
- reset_threading(true) if respond_to?(:reset_threading)
6391
+ reset_threading(true)
6358
6392
 
6359
6393
  # Re-sort and redisplay messages
6360
6394
  sort_messages
6361
6395
 
6362
6396
  # Force reinit the organizer with the newly sorted messages
6363
- organize_current_messages(true) if respond_to?(:organize_current_messages)
6397
+ organize_current_messages(true)
6364
6398
 
6365
6399
  @index = 0 # Reset to top
6366
6400
  render_all # Re-render everything
@@ -6386,7 +6420,11 @@ module Heathrow
6386
6420
 
6387
6421
  # Make sure we have a mutable array
6388
6422
  @filtered_messages = @filtered_messages.dup if @filtered_messages.frozen?
6389
-
6423
+
6424
+ # Pre-compute timestamp cache to avoid repeated parsing in sort comparisons
6425
+ ts_cache = {}
6426
+ @filtered_messages.each { |m| ts_cache[m.object_id] = timestamp_to_time(m['timestamp']) }
6427
+
6390
6428
  begin
6391
6429
  case @sort_order
6392
6430
  when 'alphabetical'
@@ -6410,7 +6448,7 @@ module Heathrow
6410
6448
  else
6411
6449
  # If subjects are the same, sort by timestamp
6412
6450
  begin
6413
- timestamp_to_time(b["timestamp"]) <=> timestamp_to_time(a["timestamp"])
6451
+ ts_cache[b.object_id] <=> ts_cache[a.object_id]
6414
6452
  rescue
6415
6453
  0
6416
6454
  end
@@ -6428,12 +6466,30 @@ module Heathrow
6428
6466
  else
6429
6467
  # Safe timestamp comparison
6430
6468
  begin
6431
- timestamp_to_time(b["timestamp"]) <=> timestamp_to_time(a["timestamp"])
6469
+ ts_cache[b.object_id] <=> ts_cache[a.object_id]
6432
6470
  rescue
6433
6471
  0
6434
6472
  end
6435
6473
  end
6436
6474
  end
6475
+ when 'from'
6476
+ # Group by sender, most recently active sender first
6477
+ # Within each sender group, newest message first
6478
+ latest_per_sender = {}
6479
+ @filtered_messages.each do |m|
6480
+ s = display_sender(m).downcase
6481
+ t = ts_cache[m.object_id] || Time.at(0)
6482
+ latest_per_sender[s] = t if !latest_per_sender[s] || t > latest_per_sender[s]
6483
+ end
6484
+ @filtered_messages.sort! do |a, b|
6485
+ sa = display_sender(a).downcase
6486
+ sb = display_sender(b).downcase
6487
+ if sa == sb
6488
+ (ts_cache[b.object_id] || Time.at(0)) <=> (ts_cache[a.object_id] || Time.at(0))
6489
+ else
6490
+ (latest_per_sender[sb] || Time.at(0)) <=> (latest_per_sender[sa] || Time.at(0))
6491
+ end
6492
+ end
6437
6493
  when 'unread'
6438
6494
  # Sort by unread first, then by timestamp
6439
6495
  @filtered_messages.sort! do |a, b|
@@ -6443,7 +6499,7 @@ module Heathrow
6443
6499
  else
6444
6500
  # Safe timestamp comparison
6445
6501
  begin
6446
- timestamp_to_time(b["timestamp"]) <=> timestamp_to_time(a["timestamp"])
6502
+ ts_cache[b.object_id] <=> ts_cache[a.object_id]
6447
6503
  rescue
6448
6504
  0 # If timestamp parsing fails, consider them equal
6449
6505
  end
@@ -6460,7 +6516,7 @@ module Heathrow
6460
6516
  conv_cmp
6461
6517
  else
6462
6518
  begin
6463
- timestamp_to_time(b["timestamp"]) <=> timestamp_to_time(a["timestamp"])
6519
+ ts_cache[b.object_id] <=> ts_cache[a.object_id]
6464
6520
  rescue
6465
6521
  0
6466
6522
  end
@@ -6478,7 +6534,7 @@ module Heathrow
6478
6534
  else
6479
6535
  # Safe timestamp comparison
6480
6536
  begin
6481
- timestamp_to_time(b["timestamp"]) <=> timestamp_to_time(a["timestamp"])
6537
+ ts_cache[b.object_id] <=> ts_cache[a.object_id]
6482
6538
  rescue
6483
6539
  0 # If timestamp parsing fails, consider them equal
6484
6540
  end
@@ -6488,7 +6544,7 @@ module Heathrow
6488
6544
  # Sort by timestamp descending (newest first)
6489
6545
  @filtered_messages.sort! do |a, b|
6490
6546
  begin
6491
- timestamp_to_time(b["timestamp"]) <=> timestamp_to_time(a["timestamp"])
6547
+ ts_cache[b.object_id] <=> ts_cache[a.object_id]
6492
6548
  rescue
6493
6549
  0 # If timestamp parsing fails, consider them equal
6494
6550
  end
@@ -6512,8 +6568,7 @@ module Heathrow
6512
6568
  @panes[:right].ix = 0 # Reset scroll position
6513
6569
  help_text = get_help_text # Use the colored version directly
6514
6570
 
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']
6571
+
6517
6572
 
6518
6573
  # Just set the text and let rcurses handle everything
6519
6574
  @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.7'
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.7
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-24 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rcurses