sup 0.3 → 0.4

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of sup might be problematic. Click here for more details.

Files changed (52) hide show
  1. data/HACKING +31 -9
  2. data/History.txt +7 -0
  3. data/Manifest.txt +2 -0
  4. data/Rakefile +9 -5
  5. data/bin/sup +81 -57
  6. data/bin/sup-config +1 -1
  7. data/bin/sup-sync +3 -0
  8. data/bin/sup-tweak-labels +127 -0
  9. data/doc/TODO +23 -12
  10. data/lib/sup.rb +13 -11
  11. data/lib/sup/account.rb +25 -12
  12. data/lib/sup/buffer.rb +61 -41
  13. data/lib/sup/colormap.rb +2 -0
  14. data/lib/sup/contact.rb +28 -18
  15. data/lib/sup/crypto.rb +86 -31
  16. data/lib/sup/draft.rb +12 -6
  17. data/lib/sup/horizontal-selector.rb +47 -0
  18. data/lib/sup/imap.rb +50 -37
  19. data/lib/sup/index.rb +76 -13
  20. data/lib/sup/keymap.rb +27 -8
  21. data/lib/sup/maildir.rb +1 -1
  22. data/lib/sup/mbox/loader.rb +1 -1
  23. data/lib/sup/message-chunks.rb +43 -15
  24. data/lib/sup/message.rb +67 -31
  25. data/lib/sup/mode.rb +40 -9
  26. data/lib/sup/modes/completion-mode.rb +1 -1
  27. data/lib/sup/modes/compose-mode.rb +3 -3
  28. data/lib/sup/modes/contact-list-mode.rb +12 -8
  29. data/lib/sup/modes/edit-message-mode.rb +100 -36
  30. data/lib/sup/modes/file-browser-mode.rb +1 -0
  31. data/lib/sup/modes/forward-mode.rb +43 -8
  32. data/lib/sup/modes/inbox-mode.rb +8 -5
  33. data/lib/sup/modes/label-search-results-mode.rb +12 -1
  34. data/lib/sup/modes/line-cursor-mode.rb +4 -7
  35. data/lib/sup/modes/reply-mode.rb +59 -54
  36. data/lib/sup/modes/resume-mode.rb +6 -6
  37. data/lib/sup/modes/scroll-mode.rb +4 -3
  38. data/lib/sup/modes/search-results-mode.rb +8 -5
  39. data/lib/sup/modes/text-mode.rb +19 -2
  40. data/lib/sup/modes/thread-index-mode.rb +109 -40
  41. data/lib/sup/modes/thread-view-mode.rb +180 -49
  42. data/lib/sup/person.rb +3 -3
  43. data/lib/sup/poll.rb +9 -8
  44. data/lib/sup/rfc2047.rb +7 -1
  45. data/lib/sup/sent.rb +1 -1
  46. data/lib/sup/tagger.rb +10 -4
  47. data/lib/sup/textfield.rb +7 -7
  48. data/lib/sup/thread.rb +86 -49
  49. data/lib/sup/update.rb +11 -0
  50. data/lib/sup/util.rb +74 -34
  51. data/test/test_message.rb +441 -0
  52. metadata +136 -117
@@ -9,7 +9,18 @@ class LabelSearchResultsMode < ThreadIndexMode
9
9
  super [], opts
10
10
  end
11
11
 
12
- def is_relevant? m; @labels.all? { |l| m.has_label? l }; end
12
+ register_keymap do |k|
13
+ k.add :refine_search, "Refine search", '.'
14
+ end
15
+
16
+ def refine_search
17
+ label_query = @labels.size > 1 ? "(#{@labels.join('||')})" : @labels.first
18
+ query = BufferManager.ask :search, "query: ", "+label:#{label_query} "
19
+ return unless query && query !~ /^\s*$/
20
+ SearchResultsMode.spawn_from_query query
21
+ end
22
+
23
+ def is_relevant? m; @labels.all? { |l| m.has_label? l } end
13
24
 
14
25
  def self.spawn_nicely label
15
26
  label = LabelManager.label_for(label) unless label.is_a?(Symbol)
@@ -55,14 +55,11 @@ protected
55
55
  buffer.mark_dirty
56
56
  end
57
57
 
58
- ## override search behavior to be cursor-based
58
+ ## override search behavior to be cursor-based. this is a stupid
59
+ ## implementation and should be made better. TODO: improve.
59
60
  def search_goto_line line
60
- while line > botline
61
- page_down
62
- end
63
- while line < topline
64
- page_up
65
- end
61
+ page_down while line >= botline
62
+ page_up while line < topline
66
63
  set_cursor_pos line
67
64
  end
68
65
 
@@ -3,17 +3,21 @@ module Redwood
3
3
  class ReplyMode < EditMessageMode
4
4
  REPLY_TYPES = [:sender, :recipient, :list, :all, :user]
5
5
  TYPE_DESCRIPTIONS = {
6
- :sender => "Reply to sender",
7
- :recipient => "Reply to recipient",
8
- :all => "Reply to all",
9
- :list => "Reply to mailing list",
10
- :user => "Customized reply"
6
+ :sender => "Sender",
7
+ :recipient => "Recipient",
8
+ :all => "All",
9
+ :list => "Mailing list",
10
+ :user => "Customized"
11
11
  }
12
12
 
13
- register_keymap do |k|
14
- k.add :move_cursor_right, "Move cursor to the right", :right
15
- k.add :move_cursor_left, "Move cursor to the left", :left
16
- end
13
+ HookManager.register "attribution", <<EOS
14
+ Generates an attribution ("Excerpts from Joe Bloggs's message of Fri Jan 11 09:54:32 -0500 2008:").
15
+ Variables:
16
+ message: a message object representing the message being replied to
17
+ (useful values include message.from.name and message.date)
18
+ Return value:
19
+ A string containing the text of the quote line (can be multi-line)
20
+ EOS
17
21
 
18
22
  def initialize message
19
23
  @m = message
@@ -23,27 +27,33 @@ class ReplyMode < EditMessageMode
23
27
  ## any)
24
28
  body = reply_body_lines message
25
29
 
30
+ ## first, determine the address at which we received this email. this will
31
+ ## become our From: address in the reply.
26
32
  from =
27
- if @m.recipient_email && (a = AccountManager.account_for(@m.recipient_email))
28
- a
33
+ if @m.recipient_email && AccountManager.is_account_email?(@m.recipient_email)
34
+ PersonManager.person_for(@m.recipient_email)
29
35
  elsif(b = (@m.to + @m.cc).find { |p| AccountManager.is_account? p })
30
36
  b
31
37
  else
32
38
  AccountManager.default_account
33
39
  end
34
40
 
35
- ## ignore reply-to for list messages because it's typically set to
36
- ## the list address, which we explicitly treat with :list
41
+ ## now, determine to: and cc: addressess. we ignore reply-to for list
42
+ ## messages because it's typically set to the list address, which we
43
+ ## explicitly treat with reply type :list
37
44
  to = @m.is_list_message? ? @m.from : (@m.replyto || @m.from)
38
- cc = (@m.to + @m.cc - [from, to]).uniq
39
45
 
40
- @headers = {}
46
+ ## next, cc:
47
+ cc = (@m.to + @m.cc - [from, to]).uniq
41
48
 
49
+ ## one potential reply type is "reply to recipient". this only happens
50
+ ## in certain cases:
42
51
  ## if there's no cc, then the sender is the person you want to reply
43
52
  ## to. if it's a list message, then the list address is. otherwise,
44
53
  ## the cc contains a recipient.
45
54
  useful_recipient = !(cc.empty? || @m.is_list_message?)
46
55
 
56
+ @headers = {}
47
57
  @headers[:recipient] = {
48
58
  "To" => cc.map { |p| p.full_address },
49
59
  } if useful_recipient
@@ -55,10 +65,11 @@ class ReplyMode < EditMessageMode
55
65
 
56
66
  @headers[:user] = {}
57
67
 
68
+ not_me_ccs = cc.select { |p| !AccountManager.is_account?(p) }
58
69
  @headers[:all] = {
59
70
  "To" => [to.full_address],
60
- "Cc" => cc.select { |p| !AccountManager.is_account?(p) }.map { |p| p.full_address },
61
- } unless cc.empty?
71
+ "Cc" => not_me_ccs.map { |p| p.full_address },
72
+ } unless not_me_ccs.empty?
62
73
 
63
74
  @headers[:list] = {
64
75
  "To" => [@m.list_address.full_address],
@@ -68,7 +79,7 @@ class ReplyMode < EditMessageMode
68
79
 
69
80
  @headers.each do |k, v|
70
81
  @headers[k] = {
71
- "From" => "#{from.name} <#{from.email}>",
82
+ "From" => from.full_address,
72
83
  "To" => [],
73
84
  "Cc" => [],
74
85
  "Bcc" => [],
@@ -78,47 +89,55 @@ class ReplyMode < EditMessageMode
78
89
  }.merge v
79
90
  end
80
91
 
81
- @type_labels = REPLY_TYPES.select { |t| @headers.member?(t) }
82
- @selected_type =
92
+ types = REPLY_TYPES.select { |t| @headers.member?(t) }
93
+ @type_selector = HorizontalSelector.new "Reply to:", types, types.map { |x| TYPE_DESCRIPTIONS[x] }
94
+
95
+ @type_selector.set_to(
83
96
  if @m.is_list_message?
84
97
  :list
85
98
  elsif @headers.member? :sender
86
99
  :sender
87
100
  else
88
101
  :recipient
89
- end
102
+ end)
90
103
 
91
- super :header => @headers[@selected_type], :body => body,
92
- :skip_top_rows => 2, :twiddles => false
104
+ super :header => @headers[@type_selector.val], :body => body, :twiddles => false
105
+ add_selector @type_selector
93
106
  end
94
107
 
95
- def lines; super + 2; end
96
- def [] i
97
- case i
98
- when 0
99
- @type_labels.inject([]) do |array, t|
100
- array + [[(t == @selected_type ? :none_highlight : :none),
101
- "#{TYPE_DESCRIPTIONS[t]}"], [:none, " "]]
102
- end + [[:none, ""]]
103
- when 1
104
- ""
105
- else
106
- super(i - 2)
108
+ protected
109
+
110
+ def move_cursor_right
111
+ super
112
+ if @headers[@type_selector.val] != self.header
113
+ self.header = @headers[@type_selector.val]
114
+ update
107
115
  end
108
116
  end
109
117
 
110
- protected
118
+ def move_cursor_left
119
+ super
120
+ if @headers[@type_selector.val] != self.header
121
+ self.header = @headers[@type_selector.val]
122
+ update
123
+ end
124
+ end
111
125
 
112
126
  def reply_body_lines m
113
- lines = ["Excerpts from #{@m.from.name}'s message of #{@m.date}:"] + m.quotable_body_lines.map { |l| "> #{l}" }
127
+ attribution = HookManager.run("attribution", :message => m) || default_attribution(m)
128
+ lines = attribution.split("\n") + m.quotable_body_lines.map { |l| "> #{l}" }
114
129
  lines.pop while lines.last =~ /^\s*$/
115
130
  lines
116
131
  end
117
132
 
133
+ def default_attribution m
134
+ "Excerpts from #{@m.from.name}'s message of #{@m.date}:"
135
+ end
136
+
118
137
  def handle_new_text new_header, new_body
119
- old_header = @headers[@selected_type]
138
+ old_header = @headers[@type_selector.val]
120
139
  if new_header.size != old_header.size || old_header.any? { |k, v| new_header[k] != v }
121
- @selected_type = :user
140
+ @type_selector.set_to :user
122
141
  self.header = @headers[:user] = new_header
123
142
  update
124
143
  end
@@ -131,24 +150,10 @@ protected
131
150
  def edit_field field
132
151
  edited_field = super
133
152
  if edited_field && edited_field != "Subject"
134
- @selected_type = :user
153
+ @type_selector.set_to :user
135
154
  update
136
155
  end
137
156
  end
138
-
139
- def move_cursor_left
140
- i = @type_labels.index @selected_type
141
- @selected_type = @type_labels[(i - 1) % @type_labels.length]
142
- self.header = @headers[@selected_type]
143
- update
144
- end
145
-
146
- def move_cursor_right
147
- i = @type_labels.index @selected_type
148
- @selected_type = @type_labels[(i + 1) % @type_labels.length]
149
- self.header = @headers[@selected_type]
150
- update
151
- end
152
157
  end
153
158
 
154
159
  end
@@ -2,13 +2,13 @@ module Redwood
2
2
 
3
3
  class ResumeMode < EditMessageMode
4
4
  def initialize m
5
- @id = m.id
5
+ @m = m
6
6
  @safe = false
7
7
 
8
8
  header, body = parse_file m.draft_filename
9
9
  header.delete "Date"
10
10
 
11
- super :header => header, :body => body
11
+ super :header => header, :body => body, :have_signature => true
12
12
  end
13
13
 
14
14
  def killable?
@@ -16,13 +16,13 @@ class ResumeMode < EditMessageMode
16
16
 
17
17
  case BufferManager.ask_yes_or_no "Discard draft?"
18
18
  when true
19
- DraftManager.discard @id
19
+ DraftManager.discard @m
20
20
  BufferManager.flash "Draft discarded."
21
21
  true
22
22
  when false
23
23
  if edited?
24
24
  DraftManager.write_draft { |f| write_message f, false }
25
- DraftManager.discard @id
25
+ DraftManager.discard @m
26
26
  BufferManager.flash "Draft saved."
27
27
  end
28
28
  true
@@ -33,14 +33,14 @@ class ResumeMode < EditMessageMode
33
33
 
34
34
  def send_message
35
35
  if super
36
- DraftManager.discard @id
36
+ DraftManager.discard @m
37
37
  @safe = true
38
38
  end
39
39
  end
40
40
 
41
41
  def save_as_draft
42
42
  @safe = true
43
- DraftManager.discard @id if super
43
+ DraftManager.discard @m if super
44
44
  end
45
45
  end
46
46
 
@@ -12,14 +12,14 @@ class ScrollMode < Mode
12
12
 
13
13
  attr_reader :status, :topline, :botline, :leftcol
14
14
 
15
- COL_JUMP = 2
15
+ COL_JUMP = 4
16
16
 
17
17
  register_keymap do |k|
18
18
  k.add :line_down, "Down one line", :down, 'j', 'J'
19
19
  k.add :line_up, "Up one line", :up, 'k', 'K'
20
20
  k.add :col_left, "Left one column", :left, 'h'
21
21
  k.add :col_right, "Right one column", :right, 'l'
22
- k.add :page_down, "Down one page", :page_down, 'n', ' '
22
+ k.add :page_down, "Down one page", :page_down, ' '
23
23
  k.add :page_up, "Up one page", :page_up, 'p', :backspace
24
24
  k.add :jump_to_start, "Jump to top", :home, '^', '1'
25
25
  k.add :jump_to_end, "Jump to bottom", :end, '$', '0'
@@ -101,6 +101,7 @@ class ScrollMode < Mode
101
101
  end
102
102
 
103
103
  def jump_to_col col
104
+ col = col - (col % COL_JUMP)
104
105
  buffer.mark_dirty unless @leftcol == col
105
106
  @leftcol = col
106
107
  end
@@ -176,7 +177,7 @@ protected
176
177
  draw_line_from_array ln, s, opts
177
178
  end
178
179
  else
179
- raise "unknown drawable object: #{s.inspect}" # good for debugging
180
+ raise "unknown drawable object: #{s.inspect} in #{self} for line #{ln}" # good for debugging
180
181
  end
181
182
 
182
183
  ## speed test
@@ -1,9 +1,11 @@
1
1
  module Redwood
2
2
 
3
3
  class SearchResultsMode < ThreadIndexMode
4
- def initialize qobj
4
+ def initialize qobj, qopts = nil
5
5
  @qobj = qobj
6
- super [], { :qobj => @qobj }
6
+ @qopts = qopts
7
+
8
+ super [], { :qobj => @qobj }.merge(@qopts)
7
9
  end
8
10
 
9
11
  register_keymap do |k|
@@ -13,7 +15,7 @@ class SearchResultsMode < ThreadIndexMode
13
15
  def refine_search
14
16
  query = BufferManager.ask :search, "query: ", (@qobj.to_s + " ")
15
17
  return unless query && query !~ /^\s*$/
16
- SearchResultsMode.spawn_from_query query
18
+ SearchResultsMode.spawn_from_query query, @qopts
17
19
  end
18
20
 
19
21
  ## a proper is_relevant? method requires some way of asking ferret
@@ -24,9 +26,10 @@ class SearchResultsMode < ThreadIndexMode
24
26
 
25
27
  def self.spawn_from_query text
26
28
  begin
27
- qobj = Index.parse_user_query_string(text) or return
29
+ qobj, extraopts = Index.parse_user_query_string(text)
30
+ return unless qobj
28
31
  short_text = text.length < 20 ? text : text[0 ... 20] + "..."
29
- mode = SearchResultsMode.new qobj
32
+ mode = SearchResultsMode.new qobj, extraopts
30
33
  BufferManager.spawn "search: \"#{short_text}\"", mode
31
34
  mode.load_threads :num => mode.buffer.content_height
32
35
  rescue Ferret::QueryParser::QueryParseException => e
@@ -4,20 +4,37 @@ class TextMode < ScrollMode
4
4
  attr_reader :text
5
5
  register_keymap do |k|
6
6
  k.add :save_to_disk, "Save to disk", 's'
7
+ k.add :pipe, "Pipe to process", '|'
7
8
  end
8
9
 
9
- def initialize text=""
10
+ def initialize text="", filename=nil
10
11
  @text = text
12
+ @filename = filename
11
13
  update_lines
12
14
  buffer.mark_dirty if buffer
13
15
  super()
14
16
  end
15
17
 
16
18
  def save_to_disk
17
- fn = BufferManager.ask_for_filename :filename, "Save to file: "
19
+ fn = BufferManager.ask_for_filename :filename, "Save to file: ", @filename
18
20
  save_to_file(fn) { |f| f.puts text } if fn
19
21
  end
20
22
 
23
+ def pipe
24
+ command = BufferManager.ask(:shell, "pipe command: ")
25
+ return if command.nil? || command.empty?
26
+
27
+ output = pipe_to_process(command) do |stream|
28
+ @text.each { |l| stream.puts l }
29
+ end
30
+
31
+ if output
32
+ BufferManager.spawn "Output of '#{command}'", TextMode.new(output)
33
+ else
34
+ BufferManager.flash "'#{command}' done!"
35
+ end
36
+ end
37
+
21
38
  def text= t
22
39
  @text = t
23
40
  update_lines
@@ -31,7 +31,9 @@ EOS
31
31
  k.add :forward, "Forward latest message in a thread", 'f'
32
32
  k.add :toggle_tagged, "Tag/untag selected thread", 't'
33
33
  k.add :toggle_tagged_all, "Tag/untag all threads", 'T'
34
+ k.add :tag_matching, "Tag matching threads", 'g'
34
35
  k.add :apply_to_tagged, "Apply next command to all tagged threads", ';'
36
+ k.add :join_threads, "Force tagged threads to be joined into the same thread", '#'
35
37
  end
36
38
 
37
39
  def initialize hidden_labels=[], load_thread_opts={}
@@ -74,7 +76,7 @@ EOS
74
76
  end
75
77
 
76
78
  ## open up a thread view window
77
- def select t=nil
79
+ def select t=nil, when_done=nil
78
80
  t ||= cursor_thread or return
79
81
 
80
82
  Redwood::reporting_thread("load messages for thread-view-mode") do
@@ -87,73 +89,98 @@ EOS
87
89
  m.load_from_source!
88
90
  end
89
91
  end
90
- mode = ThreadViewMode.new t, @hidden_labels
92
+ mode = ThreadViewMode.new t, @hidden_labels, self
91
93
  BufferManager.spawn t.subj, mode
92
94
  BufferManager.draw_screen
93
- mode.jump_to_first_open
95
+ mode.jump_to_first_open true
94
96
  BufferManager.draw_screen # lame TODO: make this unnecessary
95
97
  ## the first draw_screen is needed before topline and botline
96
98
  ## are set, and the second to show the cursor having moved
97
99
 
98
100
  update_text_for_line curpos
99
- UpdateManager.relay self, :read, t
101
+ UpdateManager.relay self, :read, t.first
102
+ when_done.call if when_done
100
103
  end
101
104
  end
102
105
 
103
106
  def multi_select threads
104
107
  threads.each { |t| select t }
105
108
  end
106
-
107
- def handle_label_update sender, m
108
- t = @ts_mutex.synchronize { @ts.thread_for(m) } or return
109
- handle_label_thread_update sender, t
110
- end
111
109
 
112
- def handle_label_thread_update sender, t
113
- l = @lines[t] or return
114
- update_text_for_line l
115
- BufferManager.draw_screen
110
+ ## this is called by thread-view-modes when the user wants to view
111
+ ## the next thread without going to index-mode. we update the cursor
112
+ ## as a convenience.
113
+ def launch_next_thread_after thread, &b
114
+ l = @lines[thread] or return
115
+ t = @mutex.synchronize do
116
+ if l < @threads.length - 1
117
+ set_cursor_pos l + 1 # move out of mutex?
118
+ @threads[l + 1]
119
+ end
120
+ end or return
121
+
122
+ select t, b
123
+ end
124
+
125
+ def handle_single_message_labeled_update sender, m
126
+ ## no need to do anything different here; we don't differentiate
127
+ ## messages from their containing threads
128
+ handle_labeled_update sender, m
129
+ end
130
+
131
+ def handle_labeled_update sender, m
132
+ if(t = thread_containing(m))
133
+ l = @lines[t] or return
134
+ update_text_for_line l
135
+ elsif is_relevant?(m)
136
+ add_or_unhide m
137
+ end
116
138
  end
117
139
 
118
- def handle_read_update sender, t
140
+ def handle_simple_update sender, m
141
+ t = thread_containing(m) or return
119
142
  l = @lines[t] or return
120
143
  update_text_for_line l
121
- BufferManager.draw_screen
122
144
  end
123
145
 
124
- def handle_archived_update *a; handle_read_update(*a); end
125
-
126
- def handle_deleted_update sender, t
127
- handle_read_update sender, t
128
- hide_thread t
129
- regen_text
146
+ %w(read unread archived starred unstarred).each do |state|
147
+ define_method "handle_#{state}_update" do |*a|
148
+ handle_simple_update(*a)
149
+ end
130
150
  end
131
151
 
132
152
  ## overwrite me!
133
153
  def is_relevant? m; false; end
134
154
 
135
- def handle_add_update sender, m
155
+ def handle_added_update sender, m
156
+ add_or_unhide m
157
+ BufferManager.draw_screen
158
+ end
159
+
160
+ def handle_single_message_deleted_update sender, m
136
161
  @ts_mutex.synchronize do
137
- return unless is_relevant?(m) || @ts.is_relevant?(m)
138
- @ts.load_thread_for_message m
162
+ return unless @ts.contains? m
163
+ @ts.remove_id m.id
139
164
  end
140
165
  update
141
- BufferManager.draw_screen
142
166
  end
143
167
 
144
- def handle_delete_update sender, mid
168
+ def handle_deleted_update sender, m
145
169
  @ts_mutex.synchronize do
146
- return unless @ts.contains_id? mid
147
- @ts.remove mid
170
+ return unless @ts.contains? m
171
+ @ts.remove_thread_containing_id m.id
148
172
  end
149
173
  update
150
- BufferManager.draw_screen
174
+ end
175
+
176
+ def handle_undeleted_update sender, m
177
+ add_or_unhide m
151
178
  end
152
179
 
153
180
  def update
154
181
  @mutex.synchronize do
155
182
  ## let's see you do THIS in python
156
- @threads = @ts.threads.select { |t| !@hidden_threads[t] }.sort_by { |t| t.date }.reverse
183
+ @threads = @ts.threads.select { |t| !@hidden_threads[t] }.sort_by { |t| [t.date, t.first.id] }.reverse
157
184
  @size_widgets = @threads.map { |t| size_widget_for_thread t }
158
185
  @size_widget_width = @size_widgets.max_of { |w| w.length }
159
186
  end
@@ -175,10 +202,10 @@ EOS
175
202
  def actually_toggle_starred t
176
203
  if t.has_label? :starred # if ANY message has a star
177
204
  t.remove_label :starred # remove from all
178
- UpdateManager.relay self, :unstarred, t
205
+ UpdateManager.relay self, :unstarred, t.first
179
206
  else
180
207
  t.first.add_label :starred # add only to first
181
- UpdateManager.relay self, :starred, t
208
+ UpdateManager.relay self, :starred, t.first
182
209
  end
183
210
  end
184
211
 
@@ -197,30 +224,30 @@ EOS
197
224
  def actually_toggle_archived t
198
225
  if t.has_label? :inbox
199
226
  t.remove_label :inbox
200
- UpdateManager.relay self, :archived, t
227
+ UpdateManager.relay self, :archived, t.first
201
228
  else
202
229
  t.apply_label :inbox
203
- UpdateManager.relay self, :unarchived, t
230
+ UpdateManager.relay self, :unarchived, t.first
204
231
  end
205
232
  end
206
233
 
207
234
  def actually_toggle_spammed t
208
235
  if t.has_label? :spam
209
236
  t.remove_label :spam
210
- UpdateManager.relay self, :unspammed, t
237
+ UpdateManager.relay self, :unspammed, t.first
211
238
  else
212
239
  t.apply_label :spam
213
- UpdateManager.relay self, :spammed, t
240
+ UpdateManager.relay self, :spammed, t.first
214
241
  end
215
242
  end
216
243
 
217
244
  def actually_toggle_deleted t
218
245
  if t.has_label? :deleted
219
246
  t.remove_label :deleted
220
- UpdateManager.relay self, :undeleted, t
247
+ UpdateManager.relay self, :undeleted, t.first
221
248
  else
222
249
  t.apply_label :deleted
223
- UpdateManager.relay self, :deleted, t
250
+ UpdateManager.relay self, :deleted, t.first
224
251
  end
225
252
  end
226
253
 
@@ -252,6 +279,18 @@ EOS
252
279
  regen_text
253
280
  end
254
281
 
282
+ def join_threads
283
+ ## this command has no non-tagged form. as a convenience, allow this
284
+ ## command to be applied to tagged threads without hitting ';'.
285
+ @tags.apply_to_tagged :join_threads
286
+ end
287
+
288
+ def multi_join_threads threads
289
+ @ts.join_threads threads or return
290
+ @tags.drop_all_tags # otherwise we have tag pointers to invalid threads!
291
+ update
292
+ end
293
+
255
294
  def jump_to_next_new
256
295
  n = @mutex.synchronize do
257
296
  ((curpos + 1) ... lines).find { |i| @threads[i].has_label? :unread } ||
@@ -351,6 +390,14 @@ EOS
351
390
  regen_text
352
391
  end
353
392
 
393
+ def tag_matching
394
+ query = BufferManager.ask :search, "tag threads matching: "
395
+ return if query.nil? || query.empty?
396
+ query = /#{query}/i
397
+ @mutex.synchronize { @threads.each { |t| @tags.tag t if thread_matches?(t, query) } }
398
+ regen_text
399
+ end
400
+
354
401
  def apply_to_tagged; @tags.apply_to_tagged; end
355
402
 
356
403
  def edit_labels
@@ -363,7 +410,7 @@ EOS
363
410
  return unless user_labels
364
411
  thread.labels = keepl + user_labels
365
412
  user_labels.each { |l| LabelManager << l }
366
- update_text_for_line curpos
413
+ UpdateManager.relay self, :labeled, thread.first
367
414
  end
368
415
 
369
416
  def multi_edit_labels threads
@@ -395,7 +442,7 @@ EOS
395
442
  m = t.latest_message
396
443
  return if m.nil? # probably won't happen
397
444
  m.load_from_source!
398
- ForwardMode.spawn_nicely m
445
+ ForwardMode.spawn_nicely :message => m
399
446
  end
400
447
 
401
448
  def load_n_threads_background n=LOAD_MORE_THREAD_NUM, opts={}
@@ -465,6 +512,28 @@ EOS
465
512
 
466
513
  protected
467
514
 
515
+ def add_or_unhide m
516
+ if @hidden_threads[m]
517
+ @hidden_threads.delete m
518
+ ## now it will re-appear when #update is called
519
+ else
520
+ @ts_mutex.synchronize do
521
+ return unless is_relevant?(m) || @ts.is_relevant?(m)
522
+ @ts.load_thread_for_message m
523
+ end
524
+ end
525
+
526
+ update
527
+ end
528
+
529
+ def thread_containing m; @ts_mutex.synchronize { @ts.thread_for m } end
530
+
531
+ ## used to tag threads by query. this can be made a lot more sophisticated,
532
+ ## but for right now we'll do the obvious this.
533
+ def thread_matches? t, query
534
+ t.subj =~ query || t.snippet =~ query || t.participants.any? { |x| x.longname =~ query }
535
+ end
536
+
468
537
  def size_widget_for_thread t
469
538
  HookManager.run("index-mode-size-widget", :thread => t) || default_size_widget_for(t)
470
539
  end