heathrow 0.7.8 → 0.8.0

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.
@@ -736,6 +736,8 @@ module Heathrow
736
736
  jump_to_date
737
737
  when 'x'
738
738
  open_message_external
739
+ when 'X'
740
+ open_in_brrowser
739
741
  when 'HOME'
740
742
  go_first
741
743
  when 'END'
@@ -1035,10 +1037,10 @@ module Heathrow
1035
1037
  idx = 0
1036
1038
 
1037
1039
  render_pick = -> {
1038
- lines = [title.b.fg(226), ""]
1040
+ lines = [title.bd.fg(226), ""]
1039
1041
  names.each_with_index do |name, i|
1040
1042
  if i == idx
1041
- lines << "→ #{name}".b.fg(226)
1043
+ lines << "→ #{name}".bd.fg(226)
1042
1044
  else
1043
1045
  lines << " #{name}".fg(252)
1044
1046
  end
@@ -1390,7 +1392,7 @@ module Heathrow
1390
1392
 
1391
1393
  # Build colored components
1392
1394
  title_part = " Heathrow - ".fg(248)
1393
- view_part = view_name.b.fg(255)
1395
+ view_part = view_name.bd.fg(255)
1394
1396
 
1395
1397
  # Add sort order info (only for message views, not Sources)
1396
1398
  sort_part = ""
@@ -1501,9 +1503,9 @@ module Heathrow
1501
1503
  end
1502
1504
 
1503
1505
  @panes[:left].text = new_text
1504
- @panes[:left].refresh
1506
+ @panes[:left].full_refresh
1505
1507
  end
1506
-
1508
+
1507
1509
  def format_message_line(msg, selected)
1508
1510
  # Extract message details
1509
1511
  timestamp = (parse_timestamp(msg['timestamp']) || "").ljust(6)
@@ -1595,7 +1597,7 @@ module Heathrow
1595
1597
 
1596
1598
  if selected
1597
1599
  content = "#{name} #{poll_col} #{count_col}"
1598
- health + " " + content.b.u.fg(src_color) + padding
1600
+ health + " " + content.bd.ul.fg(src_color) + padding
1599
1601
  elsif msg['enabled'].to_i == 0
1600
1602
  health + " " + line.fg(240)
1601
1603
  else
@@ -1742,7 +1744,7 @@ module Heathrow
1742
1744
 
1743
1745
  # Show edit options in right pane
1744
1746
  options = []
1745
- options << "EDIT SOURCE: #{source['name']}".b.fg(226)
1747
+ options << "EDIT SOURCE: #{source['name']}".bd.fg(226)
1746
1748
  options << "=" * 40
1747
1749
  options << ""
1748
1750
  options << "What would you like to edit?"
@@ -1945,6 +1947,9 @@ module Heathrow
1945
1947
  return
1946
1948
  end
1947
1949
 
1950
+ # Auto-mark as read when content is rendered in the right pane
1951
+ mark_current_message_as_read
1952
+
1948
1953
  msg = current_msg
1949
1954
 
1950
1955
  # Lazily load full content if this was a light query result
@@ -1969,7 +1974,7 @@ module Heathrow
1969
1974
 
1970
1975
  # Special handling for RSS/HN messages
1971
1976
  if msg['source_type'] == 'rss' || msg['source_type'] == 'hacker_news'
1972
- header << "📰 #{msg['subject']}".b.fg(226) if msg['subject']
1977
+ header << "📰 #{msg['subject']}".bd.fg(226) if msg['subject']
1973
1978
 
1974
1979
  # Extract metadata from raw_data if available
1975
1980
  if msg['raw_data']
@@ -2015,9 +2020,9 @@ module Heathrow
2015
2020
  # For weechat, show channel name from metadata instead of content preview
2016
2021
  meta = msg['metadata']
2017
2022
  if meta.is_a?(Hash) && meta['channel_name']
2018
- header << "Subject: #{meta['channel_name']}".b.fg(1)
2023
+ header << "Subject: #{meta['channel_name']}".bd.fg(1)
2019
2024
  elsif msg['subject']
2020
- header << "Subject: #{msg['subject']}".b.fg(1)
2025
+ header << "Subject: #{msg['subject']}".bd.fg(1)
2021
2026
  end
2022
2027
  end
2023
2028
 
@@ -2063,7 +2068,7 @@ module Heathrow
2063
2068
  # For RSS/HN, add extra formatting and info
2064
2069
  if msg['source_type'] == 'rss' || msg['source_type'] == 'hacker_news'
2065
2070
  content_parts = []
2066
- content_parts << "📄 Article Summary:".b.fg(226)
2071
+ content_parts << "📄 Article Summary:".bd.fg(226)
2067
2072
  content_parts << ""
2068
2073
 
2069
2074
  # Word wrap the content for better readability
@@ -2102,7 +2107,7 @@ module Heathrow
2102
2107
  # Add helpful instructions
2103
2108
  content_parts << ""
2104
2109
  content_parts << "─" * 40
2105
- content_parts << "💡 Keyboard Shortcuts:".b.fg(156)
2110
+ content_parts << "💡 Keyboard Shortcuts:".bd.fg(156)
2106
2111
  content_parts << ""
2107
2112
  content_parts << " x - Open full article in browser".fg(250)
2108
2113
  content_parts << " SPACE - Toggle read/unread status".fg(250)
@@ -2172,7 +2177,7 @@ module Heathrow
2172
2177
 
2173
2178
  # Title
2174
2179
  title = header_msg['subject'] || header_msg['channel_name'] || 'Group'
2175
- lines << title.b.fg(226)
2180
+ lines << title.bd.fg(226)
2176
2181
  lines << ""
2177
2182
 
2178
2183
  # Message count
@@ -2253,8 +2258,14 @@ module Heathrow
2253
2258
  end
2254
2259
 
2255
2260
  def render_bottom_bar
2256
- # Check if there's an active feedback message
2257
- if @feedback_expires_at && Time.now < @feedback_expires_at
2261
+ # Check if there's an active feedback message (timed or sticky)
2262
+ if @feedback_sticky && @feedback_message
2263
+ if @panes[:bottom]
2264
+ @panes[:bottom].text = " #{@feedback_message}".fg(@feedback_color || 156)
2265
+ @panes[:bottom].refresh
2266
+ end
2267
+ return
2268
+ elsif @feedback_expires_at && Time.now < @feedback_expires_at
2258
2269
  if @panes[:bottom]
2259
2270
  @panes[:bottom].text = " #{@feedback_message}".fg(@feedback_color || 156)
2260
2271
  @panes[:bottom].refresh
@@ -3173,6 +3184,47 @@ module Heathrow
3173
3184
  end
3174
3185
  end
3175
3186
 
3187
+ def open_in_brrowser
3188
+ unless system("which brrowser > /dev/null 2>&1")
3189
+ set_feedback("brrowser not installed. See https://github.com/isene/brrowser", 196, 4)
3190
+ return
3191
+ end
3192
+
3193
+ msg = current_message
3194
+ return unless msg
3195
+ return if header_message?(msg)
3196
+ msg = ensure_full_message(msg)
3197
+
3198
+ # Build URL from message (same logic as open_message_external)
3199
+ url = nil
3200
+ if message_has_html?(msg)
3201
+ html = msg['html_content']
3202
+ html = msg['content'] if !html || html.to_s.strip.empty?
3203
+ tmpfile = "/tmp/heathrow-view-#{msg['id']}.html"
3204
+ File.write(tmpfile, html)
3205
+ url = "file://#{tmpfile}"
3206
+ else
3207
+ meta = msg['metadata']
3208
+ if meta
3209
+ parsed = meta.is_a?(Hash) ? meta : (JSON.parse(meta) rescue {})
3210
+ url = parsed['link'] || parsed['url']
3211
+ end
3212
+ url ||= msg['url'] || msg['link'] || msg['permalink']
3213
+ url ||= msg['external_id'] if msg['external_id']&.start_with?('http')
3214
+ end
3215
+
3216
+ unless url
3217
+ set_feedback("No URL or HTML content to open", 226, 3)
3218
+ return
3219
+ end
3220
+
3221
+ Rcurses.clear_screen
3222
+ system("brrowser '#{url}'")
3223
+ setup_display
3224
+ create_panes
3225
+ render_all
3226
+ end
3227
+
3176
3228
  def view_attachments
3177
3229
  msg = current_message
3178
3230
  return unless msg
@@ -3250,14 +3302,14 @@ module Heathrow
3250
3302
  end
3251
3303
 
3252
3304
  def render_attachment_list(attachments, idx, tagged)
3253
- lines = ["Attachments:".b.fg(226), ""]
3305
+ lines = ["Attachments:".bd.fg(226), ""]
3254
3306
  attachments.each_with_index do |att, i|
3255
3307
  name = att['name'] || att['filename'] || 'unnamed'
3256
3308
  size = att['size'] ? " (#{human_size(att['size'])})" : ''
3257
3309
  ctype = att['content_type']&.split(';')&.first || ''
3258
3310
  tag = tagged.include?(i) ? "* ".fg(226) : " "
3259
3311
  if i == idx
3260
- lines << "→ ".fg(226) + tag + "#{name}#{size} #{ctype}".b.fg(255)
3312
+ lines << "→ ".fg(226) + tag + "#{name}#{size} #{ctype}".bd.fg(255)
3261
3313
  else
3262
3314
  lines << " " + tag + "#{name}#{size} #{ctype}".fg(250)
3263
3315
  end
@@ -3378,7 +3430,8 @@ module Heathrow
3378
3430
  success = @db.mark_as_read(msg['id'])
3379
3431
  if success
3380
3432
  msg['is_read'] = 1
3381
-
3433
+ sync_maildir_flag(msg, 'S', true)
3434
+
3382
3435
  # Re-sort if sorting by unread
3383
3436
  if @sort_order == 'unread'
3384
3437
  sort_messages
@@ -3829,7 +3882,7 @@ module Heathrow
3829
3882
  star = @browser_favorites.include?(folder[:full_name]) ? "* ".fg(226) : " "
3830
3883
 
3831
3884
  if i == @folder_browser_index
3832
- line = "→ ".fg(226) + indent + arrow.fg(226) + star + folder[:name].b.u.fg(255)
3885
+ line = "→ ".fg(226) + indent + arrow.fg(226) + star + folder[:name].bd.ul.fg(255)
3833
3886
  else
3834
3887
  line = " " + indent + arrow.fg(245) + star + folder[:name].fg(245)
3835
3888
  end
@@ -3861,7 +3914,7 @@ module Heathrow
3861
3914
  @folder_count_cache ||= {}
3862
3915
  counts = @folder_count_cache[folder[:full_name]] ||= folder_message_count(folder[:full_name])
3863
3916
  info = []
3864
- info << "FOLDER: #{folder[:full_name]}".b.fg(226)
3917
+ info << "FOLDER: #{folder[:full_name]}".bd.fg(226)
3865
3918
  info << ""
3866
3919
  info << "Messages: #{counts[:total]}".fg(39)
3867
3920
  info << "Unread: #{counts[:unread]}".fg(counts[:unread] > 0 ? 208 : 245)
@@ -3876,7 +3929,7 @@ module Heathrow
3876
3929
  # Update top bar (preserve Favorites title if in favorites mode)
3877
3930
  browser_title = @in_favorites_browser ? "Favorites" : "Folder Browser"
3878
3931
  browser_color = @in_favorites_browser ? 226 : 201
3879
- @panes[:top].text = " Heathrow - ".b.fg(255) + browser_title.b.fg(browser_color) + " [#{@folder_display.size} folders]".fg(246)
3932
+ @panes[:top].text = " Heathrow - ".bd.fg(255) + browser_title.bd.fg(browser_color) + " [#{@folder_display.size} folders]".fg(246)
3880
3933
  @panes[:top].refresh
3881
3934
 
3882
3935
  # Update bottom bar
@@ -3893,7 +3946,7 @@ module Heathrow
3893
3946
  arrow = folder[:has_children] ? (folder[:collapsed] ? "▸ " : "▾ ") : " "
3894
3947
  star = @browser_favorites.include?(folder[:full_name]) ? "* ".fg(226) : " "
3895
3948
  if i == @folder_browser_index
3896
- line = "→ ".fg(226) + indent + arrow.fg(226) + star + folder[:name].b.u.fg(255)
3949
+ line = "→ ".fg(226) + indent + arrow.fg(226) + star + folder[:name].bd.ul.fg(255)
3897
3950
  else
3898
3951
  line = " " + indent + arrow.fg(245) + star + folder[:name].fg(245)
3899
3952
  end
@@ -4082,7 +4135,7 @@ module Heathrow
4082
4135
  end
4083
4136
 
4084
4137
  render_folder_browser
4085
- @panes[:top].text = " Heathrow - ".b.fg(255) + "Favorites".b.fg(226) + " [#{favorites.size} folders]".fg(246)
4138
+ @panes[:top].text = " Heathrow - ".bd.fg(255) + "Favorites".bd.fg(226) + " [#{favorites.size} folders]".fg(246)
4086
4139
  @panes[:top].refresh
4087
4140
  @panes[:bottom].text = " j/k:Navigate | Enter:Open | C-Up/C-Down:Reorder | B:All folders | +:Remove fav | ESC:Back".fg(245)
4088
4141
  @panes[:bottom].refresh
@@ -4121,7 +4174,7 @@ module Heathrow
4121
4174
  @folder_browser_index = 0 if @folder_browser_index < 0
4122
4175
  set_feedback("Removed #{folder[:full_name]} from favorites", 226, 2)
4123
4176
  render_folder_browser
4124
- @panes[:top].text = " Heathrow - ".b.fg(255) + "Favorites".b.fg(226) + " [#{@folder_display.size} folders]".fg(246)
4177
+ @panes[:top].text = " Heathrow - ".bd.fg(255) + "Favorites".bd.fg(226) + " [#{@folder_display.size} folders]".fg(246)
4125
4178
  @panes[:top].refresh
4126
4179
  end
4127
4180
  end
@@ -4181,7 +4234,7 @@ module Heathrow
4181
4234
  shortcuts = get_folder_shortcuts
4182
4235
  # Show shortcuts in right pane
4183
4236
  info = []
4184
- info << "FOLDER SHORTCUTS".b.fg(226)
4237
+ info << "FOLDER SHORTCUTS".bd.fg(226)
4185
4238
  info << "Press a key to jump to folder:".fg(245)
4186
4239
  info << ""
4187
4240
  shortcuts.sort_by { |k, _| k }.each do |key, folder|
@@ -4297,13 +4350,19 @@ module Heathrow
4297
4350
  return if msgs.empty?
4298
4351
 
4299
4352
  count = 0
4353
+ failed = 0
4354
+ filed_ids = Set.new
4300
4355
  msgs.each do |msg|
4301
- file_single_message(msg, dest)
4302
- count += 1
4356
+ begin
4357
+ file_single_message(msg, dest)
4358
+ filed_ids << msg['id'] if msg['id']
4359
+ count += 1
4360
+ rescue => e
4361
+ failed += 1
4362
+ end
4303
4363
  end
4304
4364
 
4305
4365
  # Remove filed messages from current view (by id, works in both flat and threaded mode)
4306
- filed_ids = msgs.map { |m| m['id'] }.compact.to_set
4307
4366
  @filtered_messages.reject! { |m| m['id'] && filed_ids.include?(m['id']) }
4308
4367
  if @show_threaded
4309
4368
  @display_messages&.reject! { |m| m['id'] && filed_ids.include?(m['id']) }
@@ -4314,7 +4373,9 @@ module Heathrow
4314
4373
  @index = [@index, (@filtered_messages.size - 1)].min
4315
4374
  @index = 0 if @index < 0 || @filtered_messages.empty?
4316
4375
 
4317
- set_feedback("Moved #{count} message#{count > 1 ? 's' : ''} to #{dest}", 156, 2)
4376
+ msg_text = "Moved #{count} message#{count > 1 ? 's' : ''} to #{dest}"
4377
+ msg_text += " (#{failed} failed)" if failed > 0
4378
+ set_feedback(msg_text, failed > 0 ? 208 : 156, 2)
4318
4379
  render_all
4319
4380
  end
4320
4381
 
@@ -4457,7 +4518,7 @@ module Heathrow
4457
4518
  indent = " " * folder[:depth]
4458
4519
  arrow = folder[:has_children] ? (folder[:collapsed] ? "▸ " : "▾ ") : " "
4459
4520
  if i == idx
4460
- "→ ".fg(226) + indent + arrow.fg(226) + folder[:name].b.u.fg(255)
4521
+ "→ ".fg(226) + indent + arrow.fg(226) + folder[:name].bd.ul.fg(255)
4461
4522
  else
4462
4523
  " " + indent + arrow.fg(245) + folder[:name].fg(245)
4463
4524
  end
@@ -4480,7 +4541,7 @@ module Heathrow
4480
4541
  end
4481
4542
  @panes[:left].refresh
4482
4543
 
4483
- @panes[:top].text = " Heathrow - ".b.fg(255) + title.b.fg(226)
4544
+ @panes[:top].text = " Heathrow - ".bd.fg(255) + title.bd.fg(226)
4484
4545
  @panes[:top].refresh
4485
4546
  @panes[:bottom].text = " j/k:Navigate | Enter:Save here | h/l:Collapse/Expand | ESC:Cancel".fg(245)
4486
4547
  @panes[:bottom].refresh
@@ -4494,7 +4555,7 @@ module Heathrow
4494
4555
  loop do
4495
4556
  # Display current shortcuts in right pane
4496
4557
  info = []
4497
- info << "SAVE FOLDER SHORTCUTS".b.fg(226)
4558
+ info << "SAVE FOLDER SHORTCUTS".bd.fg(226)
4498
4559
  info << ""
4499
4560
  if shortcuts.empty?
4500
4561
  info << "No shortcuts configured".fg(245)
@@ -4609,7 +4670,7 @@ module Heathrow
4609
4670
  return
4610
4671
  end
4611
4672
 
4612
- lines = ["LABELS IN USE".b.fg(226), ""]
4673
+ lines = ["LABELS IN USE".bd.fg(226), ""]
4613
4674
  label_counts.sort_by { |_, c| -c }.each do |label, count|
4614
4675
  lines << " #{label}".fg(51) + " (#{count})".fg(245)
4615
4676
  end
@@ -4721,7 +4782,7 @@ module Heathrow
4721
4782
 
4722
4783
  def ai_show_response(title, response)
4723
4784
  lines = []
4724
- lines << title.b.fg(226)
4785
+ lines << title.bd.fg(226)
4725
4786
  lines << ""
4726
4787
  lines << response
4727
4788
  @panes[:right].ix = 0
@@ -4838,7 +4899,7 @@ module Heathrow
4838
4899
 
4839
4900
  # Show diff-like view
4840
4901
  lines = []
4841
- lines << "GRAMMAR/SPELLING FIX".b.fg(226)
4902
+ lines << "GRAMMAR/SPELLING FIX".bd.fg(226)
4842
4903
  lines << ""
4843
4904
  lines << "Original:".fg(245)
4844
4905
  lines << msg['content'].to_s
@@ -5398,7 +5459,7 @@ module Heathrow
5398
5459
  idx = 0
5399
5460
  loop do
5400
5461
  ch = channels[idx]
5401
- @panes[:bottom].text = " New message via: #{ch[:name].b} (TAB to cycle, ENTER to confirm, ESC to cancel)".fg(226)
5462
+ @panes[:bottom].text = " New message via: #{ch[:name].bd} (TAB to cycle, ENTER to confirm, ESC to cancel)".fg(226)
5402
5463
  @panes[:bottom].refresh
5403
5464
 
5404
5465
  key = getchr
@@ -5536,6 +5597,9 @@ module Heathrow
5536
5597
  end
5537
5598
 
5538
5599
  loop do
5600
+ # Ensure terminal is in raw mode with cursor hidden (safety after external tools)
5601
+ Cursor.hide
5602
+
5539
5603
  # Show attachments and recipients in right pane
5540
5604
  right_lines = []
5541
5605
  if composed
@@ -5549,7 +5613,7 @@ module Heathrow
5549
5613
  else
5550
5614
  total_size = attachments.sum { |f| File.size(f) rescue 0 }
5551
5615
  size_str = total_size < 1_000_000 ? "#{(total_size / 1024.0).round(1)}KB" : "#{(total_size / 1_000_000.0).round(1)}MB"
5552
- right_lines << "Attachments (#{attachments.size}, #{size_str}):".b
5616
+ right_lines << "Attachments (#{attachments.size}, #{size_str}):".bd
5553
5617
  attachments.each_with_index do |f, i|
5554
5618
  fsize = File.size(f) rescue 0
5555
5619
  fs = fsize < 1_000_000 ? "#{(fsize / 1024.0).round(1)}KB" : "#{(fsize / 1_000_000.0).round(1)}MB"
@@ -5559,10 +5623,15 @@ module Heathrow
5559
5623
  @panes[:right].text = right_lines.join("\n")
5560
5624
  @panes[:right].refresh
5561
5625
 
5626
+ # Build prompt with compose plugin keys
5627
+ plugins = compose_plugins
5628
+ plugin_hint = plugins.map { |p| "#{p[:label]} (#{p[:key]})" }.join(" | ")
5629
+ plugin_hint = " | #{plugin_hint}" unless plugin_hint.empty?
5630
+
5562
5631
  if attachments.empty?
5563
- prompt = " Send (ENTER) | Edit (e) | Attach (a) | Postpone (p) | Cancel (ESC)"
5632
+ prompt = " Send (ENTER) | Edit (e) | Attach (a)#{plugin_hint} | Postpone (p) | Cancel (ESC)"
5564
5633
  else
5565
- prompt = " Send (ENTER) | Edit (e) | More (a) | Clear (x) | Postpone (p) | ESC"
5634
+ prompt = " Send (ENTER) | Edit (e) | More (a)#{plugin_hint} | Remove (x) | Postpone (p) | ESC"
5566
5635
  end
5567
5636
 
5568
5637
  @panes[:bottom].text = prompt.fg(226)
@@ -5577,12 +5646,31 @@ module Heathrow
5577
5646
  when 'p'
5578
5647
  return :postpone
5579
5648
  when 'e'
5649
+ composed[:attachments] = attachments.dup unless attachments.empty?
5580
5650
  return :edit
5581
5651
  when 'a', 'A'
5582
5652
  new_files = run_rtfm_picker
5583
5653
  attachments.concat(new_files) if new_files && !new_files.empty?
5584
5654
  when 'x', 'X'
5585
- attachments.clear
5655
+ if attachments.size == 1
5656
+ attachments.clear
5657
+ elsif attachments.size > 1
5658
+ @panes[:bottom].text = " Remove which? (1-#{attachments.size}, or 'a' for all): ".fg(226)
5659
+ @panes[:bottom].refresh
5660
+ ans = getchr
5661
+ if ans == 'a' || ans == 'A'
5662
+ attachments.clear
5663
+ elsif ans =~ /^\d$/ && (idx = ans.to_i) >= 1 && idx <= attachments.size
5664
+ attachments.delete_at(idx - 1)
5665
+ end
5666
+ end
5667
+ else
5668
+ # Check compose plugins
5669
+ plugin = plugins.find { |p| p[:key] == chr }
5670
+ if plugin
5671
+ new_files = run_compose_plugin(plugin)
5672
+ attachments.concat(new_files) if new_files && !new_files.empty?
5673
+ end
5586
5674
  end
5587
5675
  end
5588
5676
  end
@@ -5592,7 +5680,8 @@ module Heathrow
5592
5680
  pick_file = "/tmp/rtfm_pick_#{Process.pid}.txt"
5593
5681
  File.delete(pick_file) if File.exist?(pick_file)
5594
5682
 
5595
- # Restore terminal for RTFM
5683
+ # Flush input, restore terminal for RTFM
5684
+ $stdin.getc while $stdin.wait_readable(0)
5596
5685
  system("stty sane 2>/dev/null")
5597
5686
  Cursor.show
5598
5687
 
@@ -5616,6 +5705,58 @@ module Heathrow
5616
5705
  end
5617
5706
  end
5618
5707
 
5708
+
5709
+ # Compose plugins: loaded from ~/.heathrow/plugins/compose/*.rb
5710
+ # Each plugin file should define a hash with :key, :label, :command
5711
+ # Example: { key: 'i', label: 'Insight', command: 'cd ~/myapp && ./picker --pick=%{pick_file}' }
5712
+ def compose_plugins
5713
+ @_compose_plugins ||= begin
5714
+ plugins = []
5715
+ dir = File.join(Dir.home, '.heathrow', 'plugins', 'compose')
5716
+ if Dir.exist?(dir)
5717
+ Dir.glob(File.join(dir, '*.rb')).each do |f|
5718
+ begin
5719
+ plugin = eval(File.read(f))
5720
+ plugins << plugin if plugin.is_a?(Hash) && plugin[:key] && plugin[:command]
5721
+ rescue => e
5722
+ File.open('/tmp/heathrow_debug.log', 'a') { |log| log.puts "Compose plugin error (#{f}): #{e.message}" }
5723
+ end
5724
+ end
5725
+ end
5726
+ plugins
5727
+ end
5728
+ end
5729
+
5730
+ def run_compose_plugin(plugin)
5731
+ pick_file = "/tmp/heathrow_plugin_pick_#{Process.pid}.txt"
5732
+ File.delete(pick_file) if File.exist?(pick_file)
5733
+
5734
+ # Flush any queued input, restore terminal, clear screen
5735
+ $stdin.getc while $stdin.wait_readable(0)
5736
+ system("stty sane 2>/dev/null")
5737
+ Cursor.show
5738
+ print "\e[2J\e[H" # Clear screen + home cursor
5739
+
5740
+ cmd = plugin[:command].gsub('%{pick_file}', Shellwords.escape(pick_file))
5741
+ system(cmd)
5742
+
5743
+ $stdin.raw!
5744
+ $stdin.echo = false
5745
+ Cursor.hide
5746
+ Rcurses.clear_screen
5747
+ setup_display
5748
+ create_panes
5749
+ render_all
5750
+
5751
+ if File.exist?(pick_file)
5752
+ files = File.read(pick_file).lines.map(&:strip).reject(&:empty?)
5753
+ File.delete(pick_file) rescue nil
5754
+ files.select { |f| File.exist?(f) && File.file?(f) }
5755
+ else
5756
+ []
5757
+ end
5758
+ end
5759
+
5619
5760
  # Unified send prompt loop: handles send, edit, postpone, cancel
5620
5761
  def finalize_compose(source, composed, cancel_label = "cancelled")
5621
5762
  require_relative '../message_composer'
@@ -5626,12 +5767,13 @@ module Heathrow
5626
5767
  pending_attachments = Array(composed[:attachments]).dup
5627
5768
  loop do
5628
5769
  attachments = prompt_attachments(pending_attachments, composed: composed)
5629
- pending_attachments = [] # Only seed on first iteration
5630
5770
  case attachments
5631
5771
  when :postpone
5632
5772
  postpone_message(source, composed)
5633
5773
  return
5634
5774
  when :edit
5775
+ # Preserve attachments across edit
5776
+ pending_attachments = Array(composed[:attachments]).dup
5635
5777
  # Re-open editor with current composed data
5636
5778
  composer = MessageComposer.new(nil, identity: current_identity, address_book: @address_book, editor_args: @editor_args)
5637
5779
  re_composed = composer.compose_draft(composed.transform_keys(&:to_s))
@@ -5640,10 +5782,9 @@ module Heathrow
5640
5782
  render_all
5641
5783
  if re_composed
5642
5784
  composed = re_composed
5643
- else
5644
- set_feedback("Edit cancelled", 245, 1)
5645
- return
5785
+ composed[:attachments] = pending_attachments unless pending_attachments.empty?
5646
5786
  end
5787
+ # If unchanged (re_composed nil), just return to send prompt
5647
5788
  when nil
5648
5789
  set_feedback(cancel_label, 245, 1)
5649
5790
  return
@@ -5698,7 +5839,7 @@ module Heathrow
5698
5839
 
5699
5840
  build = -> {
5700
5841
  popup.full_refresh
5701
- lines = ["", " " + "Postponed Messages".b.fg(theme[:accent])]
5842
+ lines = ["", " " + "Postponed Messages".bd.fg(theme[:accent])]
5702
5843
  lines << " " + "\u2500" * [pw - 6, 1].max
5703
5844
  drafts.each_with_index do |d, i|
5704
5845
  data = JSON.parse(d['data']) rescue {}
@@ -5918,7 +6059,7 @@ module Heathrow
5918
6059
  inner_w = pw - 4
5919
6060
  lines = []
5920
6061
  lines << ""
5921
- lines << " " + "Settings".b.fg(theme[:accent])
6062
+ lines << " " + "Settings".bd.fg(theme[:accent])
5922
6063
  lines << " " + "\u2500" * [inner_w - 3, 1].max
5923
6064
 
5924
6065
  settings_rows.each_with_index do |key, i|
@@ -6140,7 +6281,7 @@ module Heathrow
6140
6281
  lines = []
6141
6282
  title = theme_name ? "Edit: #{theme_name}" : "Theme Editor (#{@color_theme})"
6142
6283
  lines << ""
6143
- lines << " " + title.b.fg(editing[:accent] || 10)
6284
+ lines << " " + title.bd.fg(editing[:accent] || 10)
6144
6285
  lines << " " + "\u2500" * [inner_w - 3, 1].max
6145
6286
 
6146
6287
  # Ensure scroll follows selection
@@ -6612,13 +6753,13 @@ module Heathrow
6612
6753
  content = $2.chomp
6613
6754
  case level
6614
6755
  when 1
6615
- colored += content.b.fg(226) + "\n" # Bright yellow bold for H1
6756
+ colored += content.bd.fg(226) + "\n" # Bright yellow bold for H1
6616
6757
  when 2
6617
- colored += content.b.fg(14) + "\n" # Cyan bold for H2
6758
+ colored += content.bd.fg(14) + "\n" # Cyan bold for H2
6618
6759
  when 3
6619
- colored += content.b.fg(10) + "\n" # Green bold for H3
6760
+ colored += content.bd.fg(10) + "\n" # Green bold for H3
6620
6761
  else
6621
- colored += content.b.fg(11) + "\n" # Yellow bold for H4-H6
6762
+ colored += content.bd.fg(11) + "\n" # Yellow bold for H4-H6
6622
6763
  end
6623
6764
  when /^\s*[-*+]\s+(.+)$/ # Bullet points
6624
6765
  indent = line[/^\s*/]
@@ -6648,8 +6789,8 @@ module Heathrow
6648
6789
  processed = line.dup
6649
6790
 
6650
6791
  # Bold text **text** or __text__
6651
- processed.gsub!(/\*\*(.+?)\*\*/, '\1'.b)
6652
- processed.gsub!(/__(.+?)__/, '\1'.b)
6792
+ processed.gsub!(/\*\*(.+?)\*\*/, '\1'.bd)
6793
+ processed.gsub!(/__(.+?)__/, '\1'.bd)
6653
6794
 
6654
6795
  # Italic text *text* or _text_
6655
6796
  processed.gsub!(/\*([^*]+)\*/, '\1'.fg(252))
@@ -6672,14 +6813,14 @@ module Heathrow
6672
6813
 
6673
6814
  def get_extended_help_text
6674
6815
  <<~HELP
6675
- #{"HEATHROW - COMPREHENSIVE DOCUMENTATION".b.fg(226)}
6816
+ #{"HEATHROW - COMPREHENSIVE DOCUMENTATION".bd.fg(226)}
6676
6817
  #{"=" * 60}
6677
6818
 
6678
6819
  Heathrow is a unified terminal interface for all your communication sources.
6679
6820
  It aggregates messages from email, WhatsApp, Telegram, Discord, Reddit,
6680
6821
  RSS feeds, and more into a single, keyboard-driven interface.
6681
6822
 
6682
- #{"KEYBOARD SHORTCUTS".b.fg(theme[:accent])}
6823
+ #{"KEYBOARD SHORTCUTS".bd.fg(theme[:accent])}
6683
6824
 
6684
6825
  #{"Navigation".fg(11)}
6685
6826
  j/↓ Move down in message list
@@ -6740,7 +6881,7 @@ RSS feeds, and more into a single, keyboard-driven interface.
6740
6881
  t = Translate message
6741
6882
  a = Ask anything about the message
6742
6883
 
6743
- #{"FILTER SYNTAX".b.fg(theme[:accent])}
6884
+ #{"FILTER SYNTAX".bd.fg(theme[:accent])}
6744
6885
 
6745
6886
  Filters support powerful pattern matching:
6746
6887
  - Comma (,) = AND condition - all must match
@@ -6752,7 +6893,7 @@ RSS feeds, and more into a single, keyboard-driven interface.
6752
6893
  - "critical,production" = matches critical AND production
6753
6894
  - "error|warning,production" = (error OR warning) AND production
6754
6895
 
6755
- #{"SOURCE TYPES".b.fg(theme[:accent])}
6896
+ #{"SOURCE TYPES".bd.fg(theme[:accent])}
6756
6897
 
6757
6898
  #{"Email (IMAP)".fg(39)}
6758
6899
  Connect to any IMAP email server. Supports Gmail, Outlook, Yahoo, etc.
@@ -6782,7 +6923,7 @@ Required: Client ID, client secret
6782
6923
  Monitor web pages for changes.
6783
6924
  Required: URL, optional CSS selector
6784
6925
 
6785
- #{"CONFIGURATION".b.fg(theme[:accent])}
6926
+ #{"CONFIGURATION".bd.fg(theme[:accent])}
6786
6927
 
6787
6928
  Config file: ~/.heathrow/config.yml
6788
6929
  Database: ~/.heathrow/heathrow.db
@@ -6793,7 +6934,7 @@ Required: URL, optional CSS selector
6793
6934
  - Notification settings
6794
6935
  - Custom key bindings
6795
6936
 
6796
- #{"TIPS & TRICKS".b.fg(theme[:accent])}
6937
+ #{"TIPS & TRICKS".bd.fg(theme[:accent])}
6797
6938
 
6798
6939
  1. Use numbered views (0-9) to organize messages by topic
6799
6940
  2. Combine source filters with content patterns for precision
@@ -6807,16 +6948,16 @@ Required: URL, optional CSS selector
6807
6948
 
6808
6949
  def get_help_text
6809
6950
  <<~HELP
6810
- #{"HEATHROW - Communication Hub In The Terminal".b.fg(226)}
6951
+ #{"HEATHROW - Communication Hub In The Terminal".bd.fg(226)}
6811
6952
 
6812
- #{"BASIC KEYS".b.fg(theme[:accent])}
6953
+ #{"BASIC KEYS".bd.fg(theme[:accent])}
6813
6954
  #{"?".fg(10)} = Show this help text (press again for extended help)
6814
6955
  #{"q".fg(10)} = Quit Heathrow
6815
6956
  #{"Q".fg(10)} = QUIT (force quit without saving state)
6816
6957
  #{"Ctrl-r".fg(10)} = Refresh current view (sync + reload)
6817
6958
  #{"Ctrl-l".fg(10)} = Redraw panes (no fetch)
6818
6959
 
6819
- #{"NAVIGATION".b.fg(theme[:accent])}
6960
+ #{"NAVIGATION".bd.fg(theme[:accent])}
6820
6961
  #{"j/↓".fg(10)} = Move down in message list (rounds to top)
6821
6962
  #{"k/↑".fg(10)} = Move up in message list (rounds to bottom)
6822
6963
  #{"h/←".fg(10)} = Go back / parent view
@@ -6827,7 +6968,7 @@ Required: URL, optional CSS selector
6827
6968
  #{"End".fg(10)} = Go to last message
6828
6969
  #{"J".fg(10)} = Jump to date (yyyy-mm-dd)
6829
6970
 
6830
- #{"RIGHT PANE SCROLLING".b.fg(theme[:accent])}
6971
+ #{"RIGHT PANE SCROLLING".bd.fg(theme[:accent])}
6831
6972
  #{"S-↓".fg(10)} = Scroll content down one line
6832
6973
  #{"S-↑".fg(10)} = Scroll content up one line
6833
6974
  #{"S-PgDn".fg(10)} = Scroll content down one page
@@ -6836,7 +6977,7 @@ Required: URL, optional CSS selector
6836
6977
  #{"S-LEFT".fg(10)} = Scroll content up one page
6837
6978
  #{"TAB".fg(10)} = Scroll content down one page
6838
6979
 
6839
- #{"VIEWS & FILTERS".b.fg(theme[:accent])}
6980
+ #{"VIEWS & FILTERS".bd.fg(theme[:accent])}
6840
6981
  #{"A".fg(10)} = Show all messages
6841
6982
  #{"N".fg(10)} = Show new (unread) messages only
6842
6983
  #{"S".fg(10)} = Sources configuration and management
@@ -6845,7 +6986,7 @@ Required: URL, optional CSS selector
6845
6986
  #{"Ctrl-f".fg(10)} = Edit/create filter for current view (0-9, F1-F12)
6846
6987
  #{"K".fg(10)} = Kill/delete a view (with confirmation)
6847
6988
 
6848
- #{"MESSAGE ACTIONS".b.fg(theme[:accent])}
6989
+ #{"MESSAGE ACTIONS".bd.fg(theme[:accent])}
6849
6990
  #{"R".fg(10)} = Toggle read/unread status
6850
6991
  #{"M".fg(10)} = Mark all messages in view as read
6851
6992
  #{"Space".fg(10)} = Collapse/expand thread (threaded view)
@@ -6868,14 +7009,14 @@ Required: URL, optional CSS selector
6868
7009
  #{"m".fg(10)} = Mail/compose new message
6869
7010
  #{"y".fg(10)} = Copy message ID to clipboard (for CC sessions)
6870
7011
 
6871
- #{"SOURCE MANAGEMENT".b.fg(theme[:accent])} (in Sources view with 'S')
7012
+ #{"SOURCE MANAGEMENT".bd.fg(theme[:accent])} (in Sources view with 'S')
6872
7013
  #{"a".fg(10)} = Add new source
6873
7014
  #{"e".fg(10)} = Edit selected source
6874
7015
  #{"d".fg(10)} = Delete selected source
6875
7016
  #{"Enter".fg(10)} = Show all messages from selected source
6876
7017
  #{"Space".fg(10)} = Enable/disable source
6877
7018
 
6878
- #{"FOLDER NAVIGATION".b.fg(theme[:accent])}
7019
+ #{"FOLDER NAVIGATION".bd.fg(theme[:accent])}
6879
7020
  #{"B".fg(10)} = Browse all folders (folder tree)
6880
7021
  #{"F".fg(10)} = Browse favorite folders
6881
7022
  #{"+".fg(10)} = Add/remove current folder from favorites
@@ -6888,7 +7029,7 @@ Required: URL, optional CSS selector
6888
7029
  #{"l".fg(10)} = Add/remove labels (+label / -label / ? to list)
6889
7030
  #{"/".fg(10)} = Full-text search (notmuch)
6890
7031
 
6891
- #{"AI ASSISTANT".b.fg(theme[:accent])}
7032
+ #{"AI ASSISTANT".bd.fg(theme[:accent])}
6892
7033
  #{"I".fg(10)} = AI assistant (Claude Code integration)
6893
7034
  #{" d".fg(10)} = Draft a reply
6894
7035
  #{" f".fg(10)} = Fix grammar/spelling
@@ -6896,7 +7037,7 @@ Required: URL, optional CSS selector
6896
7037
  #{" t".fg(10)} = Translate message
6897
7038
  #{" a".fg(10)} = Ask anything about the message
6898
7039
 
6899
- #{"UI CONTROLS".b.fg(theme[:accent])}
7040
+ #{"UI CONTROLS".bd.fg(theme[:accent])}
6900
7041
  #{"w".fg(10)} = Change left pane width (20% → 60%)
6901
7042
  #{"Ctrl-b".fg(10)} = Cycle border style (none/single/double)
6902
7043
  #{"D".fg(10)} = Cycle date/time format
@@ -6915,7 +7056,7 @@ Required: URL, optional CSS selector
6915
7056
  bindings = @config.custom_bindings
6916
7057
  return "" if bindings.empty?
6917
7058
 
6918
- lines = ["\n #{" CUSTOM BINDINGS".b.fg(theme[:accent])}"]
7059
+ lines = ["\n #{" CUSTOM BINDINGS".bd.fg(theme[:accent])}"]
6919
7060
  bindings.each do |key, b|
6920
7061
  desc = b[:description] || b[:shell] || b[:action].to_s
6921
7062
  lines << " #{key.fg(10).ljust(16)}= #{desc}"
@@ -6925,7 +7066,7 @@ Required: URL, optional CSS selector
6925
7066
 
6926
7067
  def render_sources_info
6927
7068
  source_text = []
6928
- source_text << "SOURCE MANAGEMENT".b.fg(226)
7069
+ source_text << "SOURCE MANAGEMENT".bd.fg(226)
6929
7070
  source_text << "=" * 40
6930
7071
  source_text << ""
6931
7072
 
@@ -6934,7 +7075,7 @@ Required: URL, optional CSS selector
6934
7075
  source_text << ""
6935
7076
  source_text << "Press 'a' to add a new source"
6936
7077
  source_text << ""
6937
- source_text << "Available source types:".b.fg(39)
7078
+ source_text << "Available source types:".bd.fg(39)
6938
7079
  types = @source_manager.get_source_types
6939
7080
  types.each do |key, info|
6940
7081
  source_text << "• #{info[:icon]} #{info[:name]}".fg(226)
@@ -6945,7 +7086,7 @@ Required: URL, optional CSS selector
6945
7086
  if selected
6946
7087
  source = @source_manager.sources[selected['id']]
6947
7088
  if source
6948
- source_text << "Selected: #{source['name']}".b.fg(39)
7089
+ source_text << "Selected: #{source['name']}".bd.fg(39)
6949
7090
  source_text << "Type: #{source['plugin_type'] || source['type']}".fg(245)
6950
7091
  source_text << "Status: #{source['enabled'] ? 'Enabled' : 'Disabled'}".fg(source['enabled'] ? 40 : 196)
6951
7092
  interval = (source['poll_interval'] || 900).to_i
@@ -6981,7 +7122,7 @@ Required: URL, optional CSS selector
6981
7122
  item_name = stype == 'rss' ? 'feed' : 'page'
6982
7123
  items = config[stype == 'rss' ? 'feeds' : 'pages'] || []
6983
7124
  unless items.empty?
6984
- source_text << "#{items.size} #{item_name}s:".b.fg(245)
7125
+ source_text << "#{items.size} #{item_name}s:".bd.fg(245)
6985
7126
  items.each_with_index do |item, i|
6986
7127
  name = item['title'] || item['url'] || item['name'] || "Item #{i}"
6987
7128
  status = item['last_status']
@@ -7001,14 +7142,14 @@ Required: URL, optional CSS selector
7001
7142
  end
7002
7143
 
7003
7144
  # Context-sensitive actions
7004
- source_text << "ACTIONS".b.fg(226)
7145
+ source_text << "ACTIONS".bd.fg(226)
7005
7146
  source_text << "-" * 40
7006
7147
  source_text << "a - Add #{item_name}"
7007
7148
  source_text << "d - Remove #{item_name}"
7008
7149
  source_text << "e - Edit source settings"
7009
7150
  else
7010
7151
  # Show config (hide secrets)
7011
- source_text << "Configuration:".b.fg(39)
7152
+ source_text << "Configuration:".bd.fg(39)
7012
7153
  config.each do |key, value|
7013
7154
  next if key.to_s =~ /password|secret|token/
7014
7155
  source_text << " #{key}: #{value}".fg(245)
@@ -7016,7 +7157,7 @@ Required: URL, optional CSS selector
7016
7157
  source_text << ""
7017
7158
 
7018
7159
  # Context-sensitive actions
7019
- source_text << "ACTIONS".b.fg(226)
7160
+ source_text << "ACTIONS".bd.fg(226)
7020
7161
  source_text << "-" * 40
7021
7162
  source_text << "a - Add new source"
7022
7163
  source_text << "e - Edit this source"
@@ -7043,7 +7184,7 @@ Required: URL, optional CSS selector
7043
7184
 
7044
7185
  # Build a 256-color grid in the right pane
7045
7186
  lines = []
7046
- lines << "COLOR PICKER for #{source['name']}".b.fg(226)
7187
+ lines << "COLOR PICKER for #{source['name']}".bd.fg(226)
7047
7188
  lines << "=" * 40
7048
7189
  lines << ""
7049
7190
  lines << "Enter color number (0-255) or RGB hex (e.g. ff8800):"
@@ -7265,7 +7406,7 @@ Required: URL, optional CSS selector
7265
7406
  end
7266
7407
 
7267
7408
  lines = []
7268
- lines << "POLL INTERVAL for #{source['name']}".b.fg(226)
7409
+ lines << "POLL INTERVAL for #{source['name']}".bd.fg(226)
7269
7410
  lines << "=" * 40
7270
7411
  lines << ""
7271
7412
  lines << "Current: #{current_str}".fg(245)
@@ -7337,14 +7478,14 @@ Required: URL, optional CSS selector
7337
7478
 
7338
7479
  def show_filter_details(view_num, view_config)
7339
7480
  filter_text = []
7340
- filter_text << "VIEW #{view_num} CONFIGURATION".b.fg(226)
7481
+ filter_text << "VIEW #{view_num} CONFIGURATION".bd.fg(226)
7341
7482
  filter_text << "=" * 40
7342
7483
  filter_text << ""
7343
7484
 
7344
7485
  if view_config[:filters] && !view_config[:filters].empty?
7345
- filter_text << "Name:".b.fg(39) + " #{view_config[:name] || 'View ' + view_num.to_s}"
7486
+ filter_text << "Name:".bd.fg(39) + " #{view_config[:name] || 'View ' + view_num.to_s}"
7346
7487
  filter_text << ""
7347
- filter_text << "Active Filters:".b.fg(39)
7488
+ filter_text << "Active Filters:".bd.fg(39)
7348
7489
  filter_text << "-" * 20
7349
7490
 
7350
7491
  filters = view_config[:filters]
@@ -7395,14 +7536,14 @@ Required: URL, optional CSS selector
7395
7536
  filter_text << ""
7396
7537
  filter_text << "-" * 40
7397
7538
  filter_text << ""
7398
- filter_text << "Matching Messages:".b.fg(39) + " #{@filtered_messages.size}"
7539
+ filter_text << "Matching Messages:".bd.fg(39) + " #{@filtered_messages.size}"
7399
7540
  else
7400
7541
  filter_text << "No filters configured".fg(245)
7401
7542
  filter_text << ""
7402
7543
  filter_text << "This view will show an empty list until"
7403
7544
  filter_text << "you configure filters."
7404
7545
  filter_text << ""
7405
- filter_text << "Available filter options:".b.fg(39)
7546
+ filter_text << "Available filter options:".bd.fg(39)
7406
7547
  filter_text << "• Source types (email, whatsapp, etc.)"
7407
7548
  filter_text << "• Sender pattern (pipe | for OR)"
7408
7549
  filter_text << "• Subject pattern (pipe | for OR)"
@@ -7410,7 +7551,7 @@ Required: URL, optional CSS selector
7410
7551
  filter_text << "• Label (use 'l' to add labels, filter here)"
7411
7552
  filter_text << "• Read/unread status"
7412
7553
  filter_text << ""
7413
- filter_text << "Pattern Examples:".b.fg(39)
7554
+ filter_text << "Pattern Examples:".bd.fg(39)
7414
7555
  filter_text << "Sender: Mom|Dad|Sister (any of them)"
7415
7556
  filter_text << "Content: error|warning,critical"
7416
7557
  filter_text << " → (error OR warning) AND critical"
@@ -7629,7 +7770,7 @@ Required: URL, optional CSS selector
7629
7770
 
7630
7771
  welcome = []
7631
7772
  welcome << ""
7632
- welcome << " " + "Welcome to Heathrow!".b.fg(226)
7773
+ welcome << " " + "Welcome to Heathrow!".bd.fg(226)
7633
7774
  welcome << " " + "Where all your messages connect.".fg(245)
7634
7775
  welcome << ""
7635
7776
  welcome << " " + "\u2500" * [pw - 6, 1].max
@@ -7637,7 +7778,7 @@ Required: URL, optional CSS selector
7637
7778
  welcome << " No message sources configured yet."
7638
7779
  welcome << " Let's get you started with your first source."
7639
7780
  welcome << ""
7640
- welcome << " Available source types:".b.fg(39)
7781
+ welcome << " Available source types:".bd.fg(39)
7641
7782
  welcome << ""
7642
7783
  welcome << " " + "1".fg(226) + " - Maildir (local email, works with offlineimap/mbsync/fetchmail)"
7643
7784
  welcome << " " + "2".fg(226) + " - RSS/Atom feeds"
@@ -7831,19 +7972,72 @@ Required: URL, optional CSS selector
7831
7972
  if view && view[:filters] && view[:filters]['rules'].is_a?(Array)
7832
7973
  rule = view[:filters]['rules'].find { |r| r['field'] == 'source_type' && r['op'] == '=' }
7833
7974
  source_type = rule['value'] if rule
7975
+ # Also check source_id rules to determine source type
7976
+ if !source_type
7977
+ sid_rule = view[:filters]['rules'].find { |r| r['field'] == 'source_id' && r['op'] == '=' }
7978
+ if sid_rule
7979
+ src = @db.get_source_by_id(sid_rule['value'].to_i)
7980
+ source_type = src['plugin_type'] if src
7981
+ end
7982
+ end
7834
7983
  end
7835
7984
  folder = current_view_folder
7836
7985
 
7837
7986
  set_feedback("Syncing #{source_type || 'view'}...", 226, 30)
7838
7987
  @needs_redraw = true
7839
7988
 
7989
+ # Extract thread info BEFORE spawning thread (current_message depends on UI state)
7990
+ cur_msg = current_message
7991
+ cur_meta = cur_msg && cur_msg['metadata']
7992
+ cur_meta = JSON.parse(cur_meta) if cur_meta.is_a?(String) rescue nil
7993
+ cur_thread_id = cur_meta['thread_id'] if cur_meta.is_a?(Hash)
7994
+ # For thread headers, look for thread_id in section messages
7995
+ if !cur_thread_id && cur_msg && cur_msg['section_messages']
7996
+ first_msg = cur_msg['section_messages'].first
7997
+ if first_msg
7998
+ fm = first_msg['metadata']
7999
+ fm = JSON.parse(fm) if fm.is_a?(String) rescue nil
8000
+ cur_thread_id = fm['thread_id'] if fm.is_a?(Hash)
8001
+ end
8002
+ end
8003
+ cur_thread_name = cur_msg['subject'] if cur_msg
8004
+ File.open('/tmp/heathrow_sync_debug.log', 'w') { |f|
8005
+ f.puts "source_type=#{source_type}"
8006
+ f.puts "cur_msg_id=#{cur_msg&.[]('id')}"
8007
+ f.puts "cur_msg_keys=#{cur_msg&.keys}"
8008
+ f.puts "cur_meta_class=#{cur_meta.class}"
8009
+ f.puts "cur_meta=#{cur_meta.inspect}"
8010
+ f.puts "cur_thread_id=#{cur_thread_id}"
8011
+ f.puts "cur_thread_name=#{cur_thread_name}"
8012
+ f.puts "is_header=#{cur_msg&.[]('is_header')}"
8013
+ f.puts "section_messages=#{cur_msg&.[]('section_messages')&.length}"
8014
+ }
8015
+
7840
8016
  Thread.new do
7841
8017
  thread_db = Heathrow::Database.new
8018
+
7842
8019
  case source_type
7843
8020
  when 'maildir' then sync_maildir(folder: folder, db: thread_db)
7844
8021
  when 'rss' then sync_rss(db: thread_db)
7845
8022
  when 'web' then sync_webwatch(db: thread_db)
7846
- when 'messenger' then sync_messenger(db: thread_db)
8023
+ when 'messenger'
8024
+ if cur_thread_id && cur_thread_name
8025
+ require_relative '../sources/messenger'
8026
+ src = thread_db.get_sources.find { |s| s['plugin_type'] == 'messenger' }
8027
+ if src
8028
+ config = src['config']
8029
+ config = JSON.parse(config) if config.is_a?(String)
8030
+ instance = Heathrow::Sources::Messenger.new(src['name'], config || {}, thread_db)
8031
+ count = instance.sync_thread(src['id'], cur_thread_id.to_s, cur_thread_name)
8032
+ if instance.sync_error
8033
+ @last_sync_errors = (@last_sync_errors || []) << instance.sync_error
8034
+ else
8035
+ @_thread_sync_count = count
8036
+ end
8037
+ end
8038
+ else
8039
+ sync_messenger(db: thread_db)
8040
+ end
7847
8041
  when 'instagram' then sync_instagram(db: thread_db)
7848
8042
  when 'weechat' then sync_weechat(db: thread_db)
7849
8043
  else
@@ -7856,7 +8050,15 @@ Required: URL, optional CSS selector
7856
8050
  end
7857
8051
  thread_db.close rescue nil
7858
8052
  @pending_view_refresh = true
7859
- set_feedback("Synced", 46, 2)
8053
+ if @last_sync_errors && !@last_sync_errors.empty?
8054
+ set_feedback("Sync errors: #{@last_sync_errors.join('; ')}", 208, 0)
8055
+ @last_sync_errors = nil
8056
+ elsif @_thread_sync_count
8057
+ set_feedback("Fetched #{@_thread_sync_count} messages from #{cur_thread_name}", @_thread_sync_count > 0 ? 156 : 208, 0)
8058
+ @_thread_sync_count = nil
8059
+ else
8060
+ set_feedback("Synced", 46, 2)
8061
+ end
7860
8062
  rescue => e
7861
8063
  set_feedback("Refresh error: #{e.message}", 196, 3)
7862
8064
  end
@@ -8241,7 +8443,7 @@ Required: URL, optional CSS selector
8241
8443
 
8242
8444
  # Format the event for display
8243
8445
  lines = []
8244
- lines << "Calendar Event".b.fg(226)
8446
+ lines << "Calendar Event".bd.fg(226)
8245
8447
  lines << ""
8246
8448
  lines << "WHAT: #{event[:summary]}".fg(156) if event[:summary]
8247
8449
  if event[:dates]
@@ -8365,7 +8567,7 @@ Required: URL, optional CSS selector
8365
8567
  def format_attachments(attachments)
8366
8568
  return nil unless attachments.is_a?(Array) && !attachments.empty?
8367
8569
  lines = []
8368
- lines << "Attachments:".b.fg(208)
8570
+ lines << "Attachments:".bd.fg(208)
8369
8571
  attachments.each_with_index do |att, i|
8370
8572
  name = att['name'] || att['filename'] || 'unnamed'
8371
8573
  size = att['size'] ? " (#{human_size(att['size'])})" : ''
@@ -8403,7 +8605,7 @@ Required: URL, optional CSS selector
8403
8605
  result = ""
8404
8606
  parts.each_with_index do |part, i|
8405
8607
  result += base_color ? part.fg(base_color) : part
8406
- result += urls[i].u.fg(link_color) if urls[i]
8608
+ result += urls[i].ul.fg(link_color) if urls[i]
8407
8609
  end
8408
8610
  result
8409
8611
  end
@@ -8535,14 +8737,18 @@ Required: URL, optional CSS selector
8535
8737
 
8536
8738
  require_relative '../sources/messenger'
8537
8739
  total = 0
8740
+ errors = []
8538
8741
  sources.each do |source|
8539
8742
  config = source['config']
8540
8743
  config = JSON.parse(config) if config.is_a?(String)
8541
8744
  instance = Heathrow::Sources::Messenger.new(source['name'], config, db)
8542
8745
  total += (instance.sync(source['id']) || 0)
8746
+ errors << instance.sync_error if instance.sync_error
8543
8747
  end
8748
+ @last_sync_errors = (@last_sync_errors || []) + errors if errors.any?
8544
8749
  total > 0
8545
8750
  rescue => e
8751
+ @last_sync_errors = (@last_sync_errors || []) << "Messenger: #{e.message}"
8546
8752
  File.open('/tmp/heathrow_debug.log', 'a') { |f| f.puts "Messenger sync error: #{e.message}\n#{e.backtrace.first(3).join("\n")}" }
8547
8753
  false
8548
8754
  end