]> git.cworth.org Git - sup/blobdiff - lib/sup/modes/thread-view-mode.rb
Merge branch 'master' into next
[sup] / lib / sup / modes / thread-view-mode.rb
index 9c1edb1035387591719f5c1a200eb5fac8f30701..42c62809fa8f80cb10c4730948de7fa4a208eff6 100644 (file)
@@ -1,3 +1,4 @@
+require 'open3'
 module Redwood
 
 class ThreadViewMode < LineCursorMode
@@ -17,9 +18,10 @@ class ThreadViewMode < LineCursorMode
 Add or remove headers from the detailed header display of a message.
 Variables:
   message: The message whose headers are to be formatted.
-  headers: A hash of header name, value pairs for the default display.
+  headers: A hash of header (name, value) pairs, initialized to the default
+           headers.
 Return value:
-  A hash of the same form of 'headers'.
+  None. The variable 'headers' should be modified in place.
 EOS
 
   register_keymap do |k|
@@ -28,24 +30,48 @@ EOS
     k.add :activate_chunk, "Expand/collapse or activate item", :enter
     k.add :expand_all_messages, "Expand/collapse all messages", 'E'
     k.add :edit_draft, "Edit draft", 'e'
+    k.add :send_draft, "Send draft", 'y'
     k.add :edit_labels, "Edit or add labels for a thread", 'l'
     k.add :expand_all_quotes, "Expand/collapse all quotes in a message", 'o'
     k.add :jump_to_next_open, "Jump to next open message", 'n'
     k.add :jump_to_prev_open, "Jump to previous open message", 'p'
+    k.add :align_current_message, "Align current message in buffer", 'z'
     k.add :toggle_starred, "Star or unstar message", '*'
-    k.add :toggle_new, "Toggle new/read status of message", 'N'
+    k.add :toggle_new, "Toggle unread/read status of message", 'N'
 #    k.add :collapse_non_new_messages, "Collapse all but unread messages", 'N'
     k.add :reply, "Reply to a message", 'r'
-    k.add :forward, "Forward a message", 'f'
+    k.add :forward, "Forward a message or attachment", 'f'
     k.add :alias, "Edit alias/nickname for a person", 'i'
     k.add :edit_as_new, "Edit message as new", 'D'
     k.add :save_to_disk, "Save message/attachment to disk", 's'
     k.add :search, "Search for messages from particular people", 'S'
     k.add :compose, "Compose message to person", 'm'
-    k.add :archive_and_kill, "Archive thread and kill buffer", 'a'
-    k.add :delete_and_kill, "Delete thread and kill buffer", 'd'
     k.add :subscribe_to_list, "Subscribe to/unsubscribe from mailing list", "("
     k.add :unsubscribe_from_list, "Subscribe to/unsubscribe from mailing list", ")"
+    k.add :pipe_message, "Pipe message or attachment to a shell command", '|'
+
+    k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read:", '.' do |kk|
+      kk.add :archive_and_kill, "Archive this thread and kill buffer", 'a'
+      kk.add :delete_and_kill, "Delete this thread and kill buffer", 'd'
+      kk.add :spam_and_kill, "Mark this thread as spam and kill buffer", 's'
+      kk.add :unread_and_kill, "Mark this thread as unread and kill buffer", 'N'
+    end
+
+    k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ',' do |kk|
+      kk.add :archive_and_next, "Archive this thread, kill buffer, and view next", 'a'
+      kk.add :delete_and_next, "Delete this thread, kill buffer, and view next", 'd'
+      kk.add :spam_and_next, "Mark this thread as spam, kill buffer, and view next", 's'
+      kk.add :unread_and_next, "Mark this thread as unread, kill buffer, and view next", 'N'
+      kk.add :do_nothing_and_next, "Kill buffer, and view next", 'n'
+    end
+
+    k.add_multi "(a)rchive/(d)elete/mark as (s)pam/mark as u(N)read/do (n)othing:", ']' do |kk|
+      kk.add :archive_and_prev, "Archive this thread, kill buffer, and view previous", 'a'
+      kk.add :delete_and_prev, "Delete this thread, kill buffer, and view previous", 'd'
+      kk.add :spam_and_prev, "Mark this thread as spam, kill buffer, and view previous", 's'
+      kk.add :unread_and_prev, "Mark this thread as unread, kill buffer, and view previous", 'N'
+      kk.add :do_nothing_and_prev, "Kill buffer, and view previous", 'n'
+    end
   end
 
   ## there are a couple important instance variables we hold to format
@@ -55,11 +81,15 @@ EOS
   ## Message objects.  @chunk_lines is a map from row #s to Chunk
   ## objects. @person_lines is a map from row #s to Person objects.
 
-  def initialize thread, hidden_labels=[]
+  def initialize thread, hidden_labels=[], index_mode=nil
     super()
     @thread = thread
     @hidden_labels = hidden_labels
 
+    ## used for dispatch-and-next
+    @index_mode = index_mode
+    @dying = false
+
     @layout = SavingHash.new { MessageLayout.new }
     @chunk_layout = SavingHash.new { ChunkLayout.new }
     earliest, latest = nil, nil
@@ -99,7 +129,7 @@ EOS
 
   def show_header
     m = @message_lines[curpos] or return
-    BufferManager.spawn_unless_exists("Full header") do
+    BufferManager.spawn_unless_exists("Full header for #{m.id}") do
       TextMode.new m.raw_header
     end
   end
@@ -119,7 +149,7 @@ EOS
   def subscribe_to_list
     m = @message_lines[curpos] or return
     if m.list_subscribe && m.list_subscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
-      ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [PersonManager.person_for($1)], :subj => $3
+      ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => $3
     else
       BufferManager.flash "Can't find List-Subscribe header for this message."
     end
@@ -128,15 +158,18 @@ EOS
   def unsubscribe_from_list
     m = @message_lines[curpos] or return
     if m.list_unsubscribe && m.list_unsubscribe =~ /<mailto:(.*?)\?(subject=(.*?))>/
-      ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [PersonManager.person_for($1)], :subj => $3
+      ComposeMode.spawn_nicely :from => AccountManager.account_for(m.recipient_email), :to => [Person.from_address($1)], :subj => $3
     else
       BufferManager.flash "Can't find List-Unsubscribe header for this message."
     end
   end
 
   def forward
-    m = @message_lines[curpos] or return
-    ForwardMode.spawn_nicely m
+    if(chunk = @chunk_lines[curpos]) && chunk.is_a?(Chunk::Attachment)
+      ForwardMode.spawn_nicely :attachments => [chunk]
+    elsif(m = @message_lines[curpos])
+      ForwardMode.spawn_nicely :message => m
+    end
   end
 
   include CanAliasContacts
@@ -156,7 +189,7 @@ EOS
   def compose
     p = @person_lines[curpos]
     if p
-      ComposeMode.spawn_nicely :to => [p]
+      ComposeMode.spawn_nicely :to_default => p
     else
       ComposeMode.spawn_nicely
     end
@@ -170,7 +203,7 @@ EOS
     @thread.labels = (reserved_labels + new_labels).uniq
     new_labels.each { |l| LabelManager << l }
     update
-    UpdateManager.relay self, :label_thread, @thread
+    UpdateManager.relay self, :labeled, @thread.first
   end
 
   def toggle_starred
@@ -192,7 +225,7 @@ EOS
     ## TODO: don't recalculate EVERYTHING just to add a stupid little
     ## star to the display
     update
-    UpdateManager.relay self, :label, m
+    UpdateManager.relay self, :single_message_labeled, m
   end
 
   ## called when someone presses enter when the cursor is highlighting
@@ -218,7 +251,7 @@ EOS
 
   def edit_as_new
     m = @message_lines[curpos] or return
-    mode = ComposeMode.new(:body => m.quotable_body_lines, :to => m.to, :cc => m.cc, :subj => m.subj, :bcc => m.bcc)
+    mode = ComposeMode.new(:body => m.quotable_body_lines, :to => m.to, :cc => m.cc, :subj => m.subj, :bcc => m.bcc, :refs => m.refs, :replytos => m.replytos)
     BufferManager.spawn "edit as new", mode
     mode.edit_message
   end
@@ -227,7 +260,8 @@ EOS
     chunk = @chunk_lines[curpos] or return
     case chunk
     when Chunk::Attachment
-      fn = BufferManager.ask_for_filename :filename, "Save attachment to file: ", chunk.filename
+      default_dir = File.join(($config[:default_attachment_save_dir] || "."), chunk.filename)
+      fn = BufferManager.ask_for_filename :filename, "Save attachment to file: ", default_dir
       save_to_file(fn) { |f| f.print chunk.raw_content } if fn
     else
       m = @message_lines[curpos]
@@ -251,27 +285,46 @@ EOS
     end
   end
 
-  def jump_to_first_open
+  def send_draft
+    m = @message_lines[curpos] or return
+    if m.is_draft?
+      mode = ResumeMode.new m
+      BufferManager.spawn "Send message", mode
+      BufferManager.kill_buffer self.buffer
+      mode.send_message
+    else
+      BufferManager.flash "Not a draft message!"
+    end
+  end
+
+  def jump_to_first_open loose_alignment=false
     m = @message_lines[0] or return
     if @layout[m].state != :closed
-      jump_to_message m
+      jump_to_message m, loose_alignment
     else
-      jump_to_next_open
+      jump_to_next_open loose_alignment
     end
   end
 
-  def jump_to_next_open
+  def jump_to_next_open loose_alignment=false
     return continue_search_in_buffer if in_search? # hack: allow 'n' to apply to both operations
-    m = @message_lines[curpos] or return
+    m = (curpos ... @message_lines.length).argfind { |i| @message_lines[i] }
+    return unless m
     while nextm = @layout[m].next
       break if @layout[nextm].state != :closed
       m = nextm
     end
-    jump_to_message nextm if nextm
+    jump_to_message nextm, loose_alignment if nextm
   end
 
-  def jump_to_prev_open
+  def align_current_message
     m = @message_lines[curpos] or return
+    jump_to_message m
+  end
+
+  def jump_to_prev_open loose_alignment=false
+    m = (0 .. curpos).to_a.reverse.argfind { |i| @message_lines[i] } # bah, .to_a
+    return unless m
     ## jump to the top of the current message if we're in the body;
     ## otherwise, to the previous message
     
@@ -281,22 +334,35 @@ EOS
         break if @layout[prevm].state != :closed
         m = prevm
       end
-      jump_to_message prevm if prevm
+      jump_to_message prevm, loose_alignment if prevm
     else
-      jump_to_message m
+      jump_to_message m, loose_alignment
     end
   end
 
-  def jump_to_message m
+  IDEAL_TOP_CONTEXT = 3 # try and give 3 rows of top context
+  IDEAL_LEFT_CONTEXT = 4 # try and give 4 columns of left context
+  def jump_to_message m, loose_alignment=false
     l = @layout[m]
     left = l.depth * INDENT_SPACES
     right = left + l.width
 
-    ## jump to the top line unless both top and bottom fit in the current view
-    jump_to_line l.top unless l.top >= topline && l.top <= botline && l.bot >= topline && l.bot <= botline
+    ## jump to the top line
+    if loose_alignment
+      jump_to_line [l.top - IDEAL_TOP_CONTEXT, 0].max # give 3 lines of top context
+    else
+      jump_to_line l.top
+    end
+
+    ## jump to the left column
+    ideal_left = left +
+      if loose_alignment
+        -IDEAL_LEFT_CONTEXT + (l.width - buffer.content_width + IDEAL_LEFT_CONTEXT + 1).clamp(0, IDEAL_LEFT_CONTEXT)
+      else
+        0
+      end
 
-    ## jump to the left columns unless both left and right fit in the current view
-    jump_to_col left unless left >= leftcol && left <= rightcol && right >= leftcol && right <= rightcol
+    jump_to_col [ideal_left, 0].max
 
     ## either way, move the cursor to the first line
     set_cursor_pos l.top
@@ -328,16 +394,100 @@ EOS
     @layout = @chunk_layout = @text = nil # for good luck
   end
 
-  def archive_and_kill
-    @thread.remove_label :inbox
-    UpdateManager.relay self, :archived, @thread
-    BufferManager.kill_buffer_safely buffer
+  def archive_and_kill; archive_and_then :kill end
+  def spam_and_kill; spam_and_then :kill end
+  def delete_and_kill; delete_and_then :kill end
+  def unread_and_kill; unread_and_then :kill end
+
+  def archive_and_next; archive_and_then :next end
+  def spam_and_next; spam_and_then :next end
+  def delete_and_next; delete_and_then :next end
+  def unread_and_next; unread_and_then :next end
+  def do_nothing_and_next; do_nothing_and_then :next end
+
+  def archive_and_prev; archive_and_then :prev end
+  def spam_and_prev; spam_and_then :prev end
+  def delete_and_prev; delete_and_then :prev end
+  def unread_and_prev; unread_and_then :prev end
+  def do_nothing_and_prev; do_nothing_and_then :prev end
+
+  def archive_and_then op
+    dispatch op do
+      @thread.remove_label :inbox
+      UpdateManager.relay self, :archived, @thread.first
+    end
+  end
+
+  def spam_and_then op
+    dispatch op do
+      @thread.apply_label :spam
+      UpdateManager.relay self, :spammed, @thread.first
+    end
+  end
+
+  def delete_and_then op
+    dispatch op do
+      @thread.apply_label :deleted
+      UpdateManager.relay self, :deleted, @thread.first
+    end
+  end
+
+  def unread_and_then op
+    dispatch op do
+      @thread.apply_label :unread
+      UpdateManager.relay self, :unread, @thread.first
+    end
+  end
+
+  def do_nothing_and_then op
+    dispatch op
+  end
+
+  def dispatch op
+    return if @dying
+    @dying = true
+
+    l = lambda do
+      yield if block_given?
+      BufferManager.kill_buffer_safely buffer
+    end
+
+    case op
+    when :next
+      @index_mode.launch_next_thread_after @thread, &l
+    when :prev
+      @index_mode.launch_prev_thread_before @thread, &l
+    when :kill
+      l.call
+    else
+      raise ArgumentError, "unknown thread dispatch operation #{op.inspect}"
+    end
   end
+  private :dispatch
+
+  def pipe_message
+    chunk = @chunk_lines[curpos]
+    chunk = nil unless chunk.is_a?(Chunk::Attachment)
+    message = @message_lines[curpos] unless chunk
+
+    return unless chunk || message
 
-  def delete_and_kill
-    @thread.apply_label :deleted
-    UpdateManager.relay self, :deleted, @thread
-    BufferManager.kill_buffer_safely buffer
+    command = BufferManager.ask(:shell, "pipe command: ")
+    return if command.nil? || command.empty?
+
+    output = pipe_to_process(command) do |stream|
+      if chunk
+        stream.print chunk.raw_content
+      else
+        message.each_raw_message_line { |l| stream.print l }
+      end
+    end
+
+    if output
+      BufferManager.spawn "Output of '#{command}'", TextMode.new(output)
+    else
+      BufferManager.flash "'#{command}' done!"
+    end
   end
 
 private
@@ -390,7 +540,7 @@ private
       (0 ... text.length).each do |i|
         @chunk_lines[@text.length + i] = m
         @message_lines[@text.length + i] = m
-        lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.length }.sum
+        lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum
       end
 
       @text += text
@@ -411,7 +561,7 @@ private
           (0 ... text.length).each do |i|
             @chunk_lines[@text.length + i] = c
             @message_lines[@text.length + i] = m
-            lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.length }.sum - (depth * INDENT_SPACES)
+            lw = text[i].flatten.select { |x| x.is_a? String }.map { |x| x.display_length }.sum - (depth * INDENT_SPACES)
             l.width = lw if lw > l.width
           end
           @text += text
@@ -426,29 +576,29 @@ private
 
     open_widget = [color, (state == :closed ? "+ " : "- ")]
     new_widget = [color, (m.has_label?(:unread) ? "N" : " ")]
-    starred_widget = 
-      if m.has_label?(:starred)
-        [star_color, "* "]
+    starred_widget = if m.has_label?(:starred)
+        [star_color, "*"]
       else
-        [color, "  "]
+        [color, " "]
       end
+    attach_widget = [color, (m.has_label?(:attachment) ? "@" : " ")]
 
     case state
     when :open
       @person_lines[start] = m.from
-      [[prefix_widget, open_widget, new_widget, starred_widget,
+      [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
         [color, 
             "#{m.from ? m.from.mediumname : '?'} to #{m.recipients.map { |l| l.shortname }.join(', ')} #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})"]]]
 
     when :closed
       @person_lines[start] = m.from
-      [[prefix_widget, open_widget, new_widget, starred_widget,
+      [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
         [color, 
         "#{m.from ? m.from.mediumname : '?'}, #{m.date.to_nice_s} (#{m.date.to_nice_distance_s})  #{m.snippet}"]]]
 
     when :detailed
       @person_lines[start] = m.from
-      from_line = [[prefix_widget, open_widget, new_widget, starred_widget,
+      from_line = [[prefix_widget, open_widget, new_widget, attach_widget, starred_widget,
           [color, "From: #{m.from ? format_person(m.from) : '?'}"]]]
 
       addressee_lines = []
@@ -465,10 +615,9 @@ private
         addressee_lines += format_person_list "   Bcc: ", m.bcc
       end
 
-      headers = {
-        "Date" => "#{m.date.strftime DATE_FORMAT} (#{m.date.to_nice_distance_s})",
-        "Subject" => m.subj,
-      }
+      headers = OrderedHash.new
+      headers["Date"] = "#{m.date.strftime DATE_FORMAT} (#{m.date.to_nice_distance_s})"
+      headers["Subject"] = m.subj
 
       show_labels = @thread.labels - LabelManager::HIDDEN_RESERVED_LABELS
       unless show_labels.empty?
@@ -478,7 +627,7 @@ private
         headers["In reply to"] = "#{parent.from.mediumname}'s message of #{parent.date.strftime DATE_FORMAT}"
       end
 
-      headers = (HookManager.run("detailed-headers", :message => m, :headers => headers)) || headers
+      HookManager.run "detailed-headers", :message => m, :headers => headers
       
       from_line + (addressee_lines + headers.map { |k, v| "   #{k}: #{v}" }).map { |l| [[color, prefix + "  " + l]] }
     end
@@ -486,7 +635,7 @@ private
 
   def format_person_list prefix, people
     ptext = people.map { |p| format_person p }
-    pad = " " * prefix.length
+    pad = " " * prefix.display_length
     [prefix + ptext.first + (ptext.length > 1 ? "," : "")] + 
       ptext[1 .. -1].map_with_index do |e, i|
         pad + e + (i == ptext.length - 1 ? "" : ",")
@@ -494,7 +643,7 @@ private
   end
 
   def format_person p
-    p.longname + (ContactManager.is_contact?(p) ? " (#{ContactManager.alias_for p})" : "")
+    p.longname + (ContactManager.is_aliased_contact?(p) ? " (#{ContactManager.alias_for p})" : "")
   end
 
   ## todo: check arguments on this overly complex function
@@ -507,7 +656,7 @@ private
       [[[:missing_message_color, "#{prefix}<an unreceived message>"]]]
     when Message
       message_patina_lines(chunk, state, start, parent, prefix, color, star_color) +
-        (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. To edit, hit 'e'. <<<"]]] : [])
+        (chunk.is_draft? ? [[[:draft_notification_color, prefix + " >>> This message is a draft. Hit 'e' to edit, 'y' to send. <<<"]]] : [])
 
     else
       raise "Bad chunk: #{chunk.inspect}" unless chunk.respond_to?(:inlineable?) ## debugging
@@ -532,7 +681,7 @@ private
     BufferManager.erase_flash
     BufferManager.completely_redraw_screen
     unless success
-      BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s)
+      BufferManager.spawn "Attachment: #{chunk.filename}", TextMode.new(chunk.to_s, chunk.filename)
       BufferManager.flash "Couldn't execute view command, viewing as text."
     end
   end