sup 0.7 → 0.8

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 (46) hide show
  1. data/CONTRIBUTORS +8 -3
  2. data/History.txt +19 -0
  3. data/README.txt +45 -44
  4. data/ReleaseNotes +6 -0
  5. data/bin/sup +36 -5
  6. data/bin/sup-add +0 -0
  7. data/bin/sup-config +0 -0
  8. data/bin/sup-dump +0 -0
  9. data/bin/sup-recover-sources +8 -12
  10. data/bin/sup-sync +22 -16
  11. data/bin/sup-sync-back +1 -1
  12. data/bin/sup-tweak-labels +8 -8
  13. data/lib/sup.rb +3 -17
  14. data/lib/sup/account.rb +2 -3
  15. data/lib/sup/buffer.rb +21 -10
  16. data/lib/sup/colormap.rb +30 -27
  17. data/lib/sup/contact.rb +1 -1
  18. data/lib/sup/draft.rb +1 -3
  19. data/lib/sup/imap.rb +1 -1
  20. data/lib/sup/index.rb +70 -48
  21. data/lib/sup/label.rb +12 -10
  22. data/lib/sup/logger.rb +1 -1
  23. data/lib/sup/maildir.rb +1 -1
  24. data/lib/sup/mbox.rb +13 -70
  25. data/lib/sup/mbox/loader.rb +26 -15
  26. data/lib/sup/message-chunks.rb +18 -6
  27. data/lib/sup/message.rb +56 -67
  28. data/lib/sup/mode.rb +2 -1
  29. data/lib/sup/modes/buffer-list-mode.rb +6 -2
  30. data/lib/sup/modes/compose-mode.rb +0 -1
  31. data/lib/sup/modes/contact-list-mode.rb +1 -1
  32. data/lib/sup/modes/edit-message-mode.rb +37 -9
  33. data/lib/sup/modes/inbox-mode.rb +34 -0
  34. data/lib/sup/modes/label-list-mode.rb +10 -3
  35. data/lib/sup/modes/reply-mode.rb +24 -13
  36. data/lib/sup/modes/resume-mode.rb +2 -0
  37. data/lib/sup/modes/scroll-mode.rb +10 -9
  38. data/lib/sup/modes/search-results-mode.rb +2 -2
  39. data/lib/sup/modes/thread-index-mode.rb +157 -38
  40. data/lib/sup/modes/thread-view-mode.rb +27 -11
  41. data/lib/sup/person.rb +22 -73
  42. data/lib/sup/poll.rb +18 -20
  43. data/lib/sup/source.rb +44 -0
  44. data/lib/sup/undo.rb +39 -0
  45. data/lib/sup/util.rb +25 -16
  46. metadata +46 -45
@@ -24,6 +24,7 @@ class Mode
24
24
  end
25
25
 
26
26
  def killable?; true; end
27
+ def unsaved?; false end
27
28
  def draw; end
28
29
  def focus; end
29
30
  def blur; end
@@ -57,7 +58,7 @@ class Mode
57
58
  title = "Keybindings from #{Mode.make_name klass.name}"
58
59
  s = <<EOS
59
60
  #{title}
60
- #{'-' * title.length}
61
+ #{'-' * title.display_length}
61
62
 
62
63
  #{km.help_text used_keys}
63
64
  EOS
@@ -16,6 +16,7 @@ class BufferListMode < LineCursorMode
16
16
 
17
17
  def focus
18
18
  reload # buffers may have been killed or created since last view
19
+ set_cursor_pos 0
19
20
  end
20
21
 
21
22
  protected
@@ -26,10 +27,13 @@ protected
26
27
  end
27
28
 
28
29
  def regen_text
29
- @bufs = BufferManager.buffers.sort_by { |name, buf| name }
30
+ @bufs = BufferManager.buffers.reject { |name, buf| buf.mode == self }.sort_by { |name, buf| buf.atime }.reverse
30
31
  width = @bufs.max_of { |name, buf| buf.mode.name.length }
31
32
  @text = @bufs.map do |name, buf|
32
- sprintf "%#{width}s %s", buf.mode.name, name
33
+ base_color = buf.system? ? :system_buf_color : :regular_buf_color
34
+ [[base_color, sprintf("%#{width}s ", buf.mode.name)],
35
+ [:modified_buffer_color, (buf.mode.unsaved? ? '*' : ' ')],
36
+ [base_color, " " + name]]
33
37
  end
34
38
  end
35
39
 
@@ -5,7 +5,6 @@ class ComposeMode < EditMessageMode
5
5
  header = {}
6
6
  header["From"] = (opts[:from] || AccountManager.default_account).full_address
7
7
  header["To"] = opts[:to].map { |p| p.full_address }.join(", ") if opts[:to]
8
- header["To"] = opts[:to].map { |p| p.full_address }.join(", ") if opts[:to]
9
8
  header["Cc"] = opts[:cc].map { |p| p.full_address }.join(", ") if opts[:cc]
10
9
  header["Bcc"] = opts[:bcc].map { |p| p.full_address }.join(", ") if opts[:bcc]
11
10
  header["Subject"] = opts[:subj] if opts[:subj]
@@ -23,7 +23,7 @@ class ContactListMode < LineCursorMode
23
23
  k.add :reload, "Drop contact list and reload", 'D'
24
24
  k.add :alias, "Edit alias/or name for contact", 'a', 'i'
25
25
  k.add :toggle_tagged, "Tag/untag current line", 't'
26
- k.add :apply_to_tagged, "Apply next command to all tagged items", ';'
26
+ k.add :apply_to_tagged, "Apply next command to all tagged items", '+'
27
27
  k.add :search, "Search for messages from particular people", 'S'
28
28
  end
29
29
 
@@ -2,6 +2,7 @@ require 'tempfile'
2
2
  require 'socket' # just for gethostname!
3
3
  require 'pathname'
4
4
  require 'rmail'
5
+ require 'jcode' # for RE_UTF8
5
6
 
6
7
  module Redwood
7
8
 
@@ -12,7 +13,7 @@ class EditMessageMode < LineCursorMode
12
13
 
13
14
  FORCE_HEADERS = %w(From To Cc Bcc Subject)
14
15
  MULTI_HEADERS = %w(To Cc Bcc)
15
- NON_EDITABLE_HEADERS = %w(Message-Id Date)
16
+ NON_EDITABLE_HEADERS = %w(Message-id Date)
16
17
 
17
18
  HookManager.register "signature", <<EOS
18
19
  Generates a message signature.
@@ -145,6 +146,8 @@ EOS
145
146
  !edited? || BufferManager.ask_yes_or_no("Discard message?")
146
147
  end
147
148
 
149
+ def unsaved?; edited? end
150
+
148
151
  def attach_file
149
152
  fn = BufferManager.ask_for_filename :attachment, "File name (enter for browser): "
150
153
  return unless fn
@@ -168,6 +171,29 @@ EOS
168
171
 
169
172
  protected
170
173
 
174
+ def mime_encode string
175
+ string = [string].pack('M') # basic quoted-printable
176
+ string.gsub!(/=\n/,'') # .. remove trailing newline
177
+ string.gsub!(/_/,'=96') # .. encode underscores
178
+ string.gsub!(/\?/,'=3F') # .. encode question marks
179
+ string.gsub!(/ /,'_') # .. translate space to underscores
180
+ "=?utf-8?q?#{string}?="
181
+ end
182
+
183
+ def mime_encode_subject string
184
+ return string unless string.match(String::RE_UTF8)
185
+ mime_encode string
186
+ end
187
+
188
+ RE_ADDRESS = /(.+)( <.*@.*>)/
189
+
190
+ # Encode "bælammet mitt <user@example.com>" into
191
+ # "=?utf-8?q?b=C3=A6lammet_mitt?= <user@example.com>
192
+ def mime_encode_address string
193
+ return string unless string.match(String::RE_UTF8)
194
+ string.sub(RE_ADDRESS) { |match| mime_encode($1) + $2 }
195
+ end
196
+
171
197
  def move_cursor_left
172
198
  if curpos < @selectors.length
173
199
  @selectors[curpos].roll_left
@@ -212,7 +238,7 @@ protected
212
238
 
213
239
  def parse_file fn
214
240
  File.open(fn) do |f|
215
- header = MBox::read_header f
241
+ header = Source.parse_raw_email_header(f).inject({}) { |h, (k, v)| h[k.capitalize] = v; h } # lousy HACK
216
242
  body = f.readlines.map { |l| l.chomp }
217
243
 
218
244
  header.delete_if { |k, v| NON_EDITABLE_HEADERS.member? k }
@@ -257,7 +283,7 @@ protected
257
283
  if i == 0
258
284
  header + " " + name
259
285
  else
260
- (" " * (header.length + 1)) + name
286
+ (" " * (header.display_length + 1)) + name
261
287
  end + (i == things.length - 1 ? "" : ",")
262
288
  end
263
289
  end
@@ -321,8 +347,8 @@ protected
321
347
 
322
348
  ## do whatever crypto transformation is necessary
323
349
  if @crypto_selector && @crypto_selector.val != :none
324
- from_email = PersonManager.person_for(@header["From"]).email
325
- to_email = [@header["To"], @header["Cc"], @header["Bcc"]].flatten.compact.map { |p| PersonManager.person_for(p).email }
350
+ from_email = Person.from_address(@header["From"]).email
351
+ to_email = [@header["To"], @header["Cc"], @header["Bcc"]].flatten.compact.map { |p| Person.from_address(p).email }
326
352
 
327
353
  m = CryptoManager.send @crypto_selector.val, from_email, to_email, m
328
354
  end
@@ -333,14 +359,16 @@ protected
333
359
  m.header[k] =
334
360
  case v
335
361
  when String
336
- v
362
+ k.match(/subject/i) ? mime_encode_subject(v) : mime_encode_address(v)
337
363
  when Array
338
- v.join ", "
364
+ v.map { |v| mime_encode_address v }.join ", "
339
365
  end
340
366
  end
367
+
341
368
  m.header["Date"] = date.rfc2822
342
369
  m.header["Message-Id"] = @message_id
343
370
  m.header["User-Agent"] = "Sup/#{Redwood::VERSION}"
371
+ m.header["Content-Transfer-Encoding"] = '8bit'
344
372
  m
345
373
  end
346
374
 
@@ -390,7 +418,7 @@ protected
390
418
 
391
419
  contacts = BufferManager.ask_for_contacts :people, "#{field}: ", default
392
420
  if contacts
393
- text = contacts.map { |s| s.longname }.join(", ")
421
+ text = contacts.map { |s| s.full_address }.join(", ")
394
422
  @header[field] = parse_header field, text
395
423
  update
396
424
  end
@@ -412,7 +440,7 @@ private
412
440
  end
413
441
 
414
442
  def sig_lines
415
- p = PersonManager.person_for(@header["From"])
443
+ p = Person.from_address(@header["From"])
416
444
  from_email = p && p.email
417
445
 
418
446
  ## first run the hook
@@ -26,12 +26,27 @@ class InboxMode < ThreadIndexMode
26
26
 
27
27
  def archive
28
28
  return unless cursor_thread
29
+ thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
30
+
31
+ UndoManager.register "archiving thread" do
32
+ thread.apply_label :inbox
33
+ add_or_unhide thread.first
34
+ end
35
+
29
36
  cursor_thread.remove_label :inbox
30
37
  hide_thread cursor_thread
31
38
  regen_text
32
39
  end
33
40
 
34
41
  def multi_archive threads
42
+ UndoManager.register "archiving #{threads.size.pluralize 'thread'}" do
43
+ threads.map do |t|
44
+ t.apply_label :inbox
45
+ add_or_unhide t.first
46
+ end
47
+ regen_text
48
+ end
49
+
35
50
  threads.each do |t|
36
51
  t.remove_label :inbox
37
52
  hide_thread t
@@ -41,6 +56,14 @@ class InboxMode < ThreadIndexMode
41
56
 
42
57
  def read_and_archive
43
58
  return unless cursor_thread
59
+ thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
60
+
61
+ UndoManager.register "reading and archiving thread" do
62
+ thread.apply_label :inbox
63
+ thread.apply_label :unread
64
+ add_or_unhide thread.first
65
+ end
66
+
44
67
  cursor_thread.remove_label :unread
45
68
  cursor_thread.remove_label :inbox
46
69
  hide_thread cursor_thread
@@ -48,12 +71,23 @@ class InboxMode < ThreadIndexMode
48
71
  end
49
72
 
50
73
  def multi_read_and_archive threads
74
+ old_labels = threads.map { |t| t.labels.dup }
75
+
51
76
  threads.each do |t|
52
77
  t.remove_label :unread
53
78
  t.remove_label :inbox
54
79
  hide_thread t
55
80
  end
56
81
  regen_text
82
+
83
+ UndoManager.register "reading and archiving #{threads.size.pluralize 'thread'}" do
84
+ threads.zip(old_labels).each do |t, l|
85
+ t.labels = l
86
+ add_or_unhide t.first
87
+ end
88
+ regen_text
89
+ end
90
+
57
91
  end
58
92
 
59
93
  def handle_unarchived_update sender, m
@@ -48,12 +48,12 @@ protected
48
48
 
49
49
  def regen_text
50
50
  @text = []
51
- labels = LabelManager.listable_labels
51
+ labels = LabelManager.all_labels
52
52
 
53
53
  counts = labels.map do |label|
54
54
  string = LabelManager.string_for label
55
55
  total = Index.num_results_for :label => label
56
- unread = Index.num_results_for :labels => [label, :unread]
56
+ unread = (label == :unread)? total : Index.num_results_for(:labels => [label, :unread])
57
57
  [label, string, total, unread]
58
58
  end.sort_by { |l, s, t, u| s.downcase }
59
59
 
@@ -65,7 +65,14 @@ protected
65
65
 
66
66
  @labels = []
67
67
  counts.map do |label, string, total, unread|
68
- if total == 0 && !LabelManager::RESERVED_LABELS.include?(label)
68
+ ## if we've done a search and there are no messages for this label, we can delete it from the
69
+ ## list. BUT if it's a brand-new label, the user may not have sync'ed it to the index yet, so
70
+ ## don't delete it in this case.
71
+ ##
72
+ ## this is all a hack. what should happen is:
73
+ ## TODO make the labelmanager responsible for label counts
74
+ ## and then it can listen to labeled and unlabeled events, etc.
75
+ if total == 0 && !LabelManager::RESERVED_LABELS.include?(label) && !LabelManager.new_label?(label)
69
76
  Redwood::log "no hits for label #{label}, deleting"
70
77
  LabelManager.delete label
71
78
  next
@@ -53,22 +53,33 @@ EOS
53
53
  hook_reply_from = HookManager.run "reply-from", :message => @m
54
54
 
55
55
  ## sanity check that selection is a Person (or we'll fail below)
56
- ## don't check that it's an Account, though; assume they know what they're doing.
56
+ ## don't check that it's an Account, though; assume they know what they're
57
+ ## doing.
57
58
  if hook_reply_from && !(hook_reply_from.is_a? Person)
58
- Redwood::log "reply-from returned non-Person, using default from."
59
- hook_reply_from = nil
59
+ Redwood::log "reply-from returned non-Person, using default from."
60
+ hook_reply_from = nil
60
61
  end
61
62
 
62
- from =
63
- if hook_reply_from
64
- hook_reply_from
65
- elsif @m.recipient_email && AccountManager.is_account_email?(@m.recipient_email)
66
- PersonManager.person_for(@m.recipient_email)
67
- elsif(b = (@m.to + @m.cc).find { |p| AccountManager.is_account? p })
68
- b
69
- else
70
- AccountManager.default_account
71
- end
63
+ ## determine the from address of a reply.
64
+ ## if we have a value from a hook, use it.
65
+ from = if hook_reply_from
66
+ hook_reply_from
67
+ ## otherwise, if the original email had an envelope-to header, try and use
68
+ ## it, and look up the corresponding name form the list of accounts.
69
+ ##
70
+ ## this is for the case where mail is received from a mailing lists (so the
71
+ ## To: is the list id itself). if the user subscribes via a particular
72
+ ## alias, we want to use that alias in the reply.
73
+ elsif @m.recipient_email && (a = AccountManager.account_for(@m.recipient_email))
74
+ Person.new a.name, @m.recipient_email
75
+ ## otherwise, try and find an account somewhere in the list of to's
76
+ ## and cc's.
77
+ elsif(b = (@m.to + @m.cc).find { |p| AccountManager.is_account? p })
78
+ b
79
+ ## if all else fails, use the default
80
+ else
81
+ AccountManager.default_account
82
+ end
72
83
 
73
84
  ## now, determine to: and cc: addressess. we ignore reply-to for list
74
85
  ## messages because it's typically set to the list address, which we
@@ -11,6 +11,8 @@ class ResumeMode < EditMessageMode
11
11
  super :header => header, :body => body, :have_signature => true
12
12
  end
13
13
 
14
+ def unsaved?; !@safe end
15
+
14
16
  def killable?
15
17
  return true if @safe
16
18
 
@@ -73,7 +73,7 @@ class ScrollMode < Mode
73
73
  end
74
74
  if line
75
75
  @search_line = line + 1
76
- search_goto_pos line, col, col + @search_query.length
76
+ search_goto_pos line, col, col + @search_query.display_length
77
77
  buffer.mark_dirty
78
78
  else
79
79
  BufferManager.flash "Not found!"
@@ -164,7 +164,7 @@ protected
164
164
  if match
165
165
  return [i, offset + match]
166
166
  else
167
- offset += string.length
167
+ offset += string.display_length
168
168
  end
169
169
  end
170
170
  end
@@ -219,24 +219,25 @@ protected
219
219
 
220
220
  def draw_line_from_array ln, a, opts
221
221
  xpos = 0
222
- a.each do |color, text|
222
+ a.each_with_index do |(color, text), i|
223
223
  raise "nil text for color '#{color}'" if text.nil? # good for debugging
224
+ l = text.display_length
225
+ no_fill = i != a.size - 1
224
226
 
225
- if xpos + text.length < @leftcol
227
+ if xpos + l < @leftcol
226
228
  buffer.write ln - @topline, 0, "", :color => color,
227
229
  :highlight => opts[:highlight]
228
- xpos += text.length
229
230
  elsif xpos < @leftcol
230
231
  ## partial
231
232
  buffer.write ln - @topline, 0, text[(@leftcol - xpos) .. -1],
232
233
  :color => color,
233
- :highlight => opts[:highlight]
234
- xpos += text.length
234
+ :highlight => opts[:highlight], :no_fill => no_fill
235
235
  else
236
236
  buffer.write ln - @topline, xpos - @leftcol, text,
237
- :color => color, :highlight => opts[:highlight]
238
- xpos += text.length
237
+ :color => color, :highlight => opts[:highlight],
238
+ :no_fill => no_fill
239
239
  end
240
+ xpos += l
240
241
  end
241
242
  end
242
243
 
@@ -32,8 +32,8 @@ class SearchResultsMode < ThreadIndexMode
32
32
  mode = SearchResultsMode.new qobj, extraopts
33
33
  BufferManager.spawn "search: \"#{short_text}\"", mode
34
34
  mode.load_threads :num => mode.buffer.content_height
35
- rescue Ferret::QueryParser::QueryParseException => e
36
- BufferManager.flash "Couldn't parse query."
35
+ rescue Index::ParseError => e
36
+ BufferManager.flash "Problem: #{e.message}!"
37
37
  end
38
38
  end
39
39
  end
@@ -42,8 +42,9 @@ EOS
42
42
  k.add :toggle_tagged, "Tag/untag selected thread", 't'
43
43
  k.add :toggle_tagged_all, "Tag/untag all threads", 'T'
44
44
  k.add :tag_matching, "Tag matching threads", 'g'
45
- k.add :apply_to_tagged, "Apply next command to all tagged threads", ';'
45
+ k.add :apply_to_tagged, "Apply next command to all tagged threads", '+', '='
46
46
  k.add :join_threads, "Force tagged threads to be joined into the same thread", '#'
47
+ k.add :undo, "Undo the previous action", 'u'
47
48
  end
48
49
 
49
50
  def initialize hidden_labels=[], load_thread_opts={}
@@ -68,6 +69,8 @@ EOS
68
69
 
69
70
  UpdateManager.register self
70
71
 
72
+ @save_thread_mutex = Mutex.new
73
+
71
74
  @last_load_more_size = nil
72
75
  to_load_more do |size|
73
76
  next if @last_load_more_size == 0
@@ -77,12 +80,14 @@ EOS
77
80
  end
78
81
  end
79
82
 
83
+ def unsaved?; dirty? end
80
84
  def lines; @text.length; end
81
85
  def [] i; @text[i]; end
82
86
  def contains_thread? t; @threads.include?(t) end
83
87
 
84
88
  def reload
85
89
  drop_all_threads
90
+ UndoManager.clear
86
91
  BufferManager.draw_screen
87
92
  load_threads :num => buffer.content_height
88
93
  end
@@ -208,12 +213,16 @@ EOS
208
213
  add_or_unhide m
209
214
  end
210
215
 
216
+ def undo
217
+ UndoManager.undo
218
+ end
219
+
211
220
  def update
212
221
  @mutex.synchronize do
213
222
  ## let's see you do THIS in python
214
223
  @threads = @ts.threads.select { |t| !@hidden_threads[t] }.sort_by { |t| [t.date, t.first.id] }.reverse
215
224
  @size_widgets = @threads.map { |t| size_widget_for_thread t }
216
- @size_widget_width = @size_widgets.max_of { |w| w.length }
225
+ @size_widget_width = @size_widgets.max_of { |w| w.display_length }
217
226
  end
218
227
 
219
228
  regen_text
@@ -230,66 +239,122 @@ EOS
230
239
  end
231
240
  end
232
241
 
242
+ ## returns an undo lambda
233
243
  def actually_toggle_starred t
244
+ pos = curpos
234
245
  if t.has_label? :starred # if ANY message has a star
235
246
  t.remove_label :starred # remove from all
236
247
  UpdateManager.relay self, :unstarred, t.first
248
+ lambda do
249
+ t.first.add_label :starred
250
+ UpdateManager.relay self, :starred, t.first
251
+ regen_text
252
+ end
237
253
  else
238
254
  t.first.add_label :starred # add only to first
239
255
  UpdateManager.relay self, :starred, t.first
256
+ lambda do
257
+ t.remove_label :starred
258
+ UpdateManager.relay self, :unstarred, t.first
259
+ regen_text
260
+ end
240
261
  end
241
262
  end
242
263
 
243
264
  def toggle_starred
244
265
  t = cursor_thread or return
245
- actually_toggle_starred t
266
+ undo = actually_toggle_starred t
267
+ UndoManager.register "toggling thread starred status", undo
246
268
  update_text_for_line curpos
247
269
  cursor_down
248
270
  end
249
271
 
250
272
  def multi_toggle_starred threads
251
- threads.each { |t| actually_toggle_starred t }
273
+ UndoManager.register "toggling #{threads.size.pluralize 'thread'} starred status",
274
+ threads.map { |t| actually_toggle_starred t }
252
275
  regen_text
253
276
  end
254
277
 
278
+ ## returns an undo lambda
255
279
  def actually_toggle_archived t
280
+ thread = t
281
+ pos = curpos
256
282
  if t.has_label? :inbox
257
283
  t.remove_label :inbox
258
284
  UpdateManager.relay self, :archived, t.first
285
+ lambda do
286
+ thread.apply_label :inbox
287
+ update_text_for_line pos
288
+ UpdateManager.relay self,:unarchived, thread.first
289
+ end
259
290
  else
260
291
  t.apply_label :inbox
261
292
  UpdateManager.relay self, :unarchived, t.first
293
+ lambda do
294
+ thread.remove_label :inbox
295
+ update_text_for_line pos
296
+ UpdateManager.relay self, :unarchived, thread.first
297
+ end
262
298
  end
263
299
  end
264
300
 
301
+ ## returns an undo lambda
265
302
  def actually_toggle_spammed t
303
+ thread = t
266
304
  if t.has_label? :spam
267
305
  t.remove_label :spam
306
+ add_or_unhide t.first
268
307
  UpdateManager.relay self, :unspammed, t.first
308
+ lambda do
309
+ thread.apply_label :spam
310
+ self.hide_thread thread
311
+ UpdateManager.relay self,:spammed, thread.first
312
+ end
269
313
  else
270
314
  t.apply_label :spam
315
+ hide_thread t
271
316
  UpdateManager.relay self, :spammed, t.first
317
+ lambda do
318
+ thread.remove_label :spam
319
+ add_or_unhide thread.first
320
+ UpdateManager.relay self,:unspammed, thread.first
321
+ end
272
322
  end
273
323
  end
274
324
 
325
+ ## returns an undo lambda
275
326
  def actually_toggle_deleted t
276
327
  if t.has_label? :deleted
277
328
  t.remove_label :deleted
329
+ add_or_unhide t.first
278
330
  UpdateManager.relay self, :undeleted, t.first
331
+ lambda do
332
+ t.apply_label :deleted
333
+ hide_thread t
334
+ UpdateManager.relay self, :deleted, t.first
335
+ end
279
336
  else
280
337
  t.apply_label :deleted
338
+ hide_thread t
281
339
  UpdateManager.relay self, :deleted, t.first
340
+ lambda do
341
+ t.remove_label :deleted
342
+ add_or_unhide t.first
343
+ UpdateManager.relay self, :undeleted, t.first
344
+ end
282
345
  end
283
346
  end
284
347
 
285
348
  def toggle_archived
286
349
  t = cursor_thread or return
287
- actually_toggle_archived t
350
+ undo = actually_toggle_archived t
351
+ UndoManager.register "deleting/undeleting thread #{t.first.id}", undo, lambda { update_text_for_line curpos }
288
352
  update_text_for_line curpos
289
353
  end
290
354
 
291
355
  def multi_toggle_archived threads
292
- threads.each { |t| actually_toggle_archived t }
356
+ undos = threads.map { |t| actually_toggle_archived t }
357
+ UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}", undos, lambda { regen_text }
293
358
  regen_text
294
359
  end
295
360
 
@@ -350,10 +415,9 @@ EOS
350
415
  ## see deleted or spam emails, and when you undelete or unspam them
351
416
  ## you also want them to disappear immediately.
352
417
  def multi_toggle_spam threads
353
- threads.each do |t|
354
- actually_toggle_spammed t
355
- hide_thread t
356
- end
418
+ undos = threads.map { |t| actually_toggle_spammed t }
419
+ UndoManager.register "marking/unmarking #{threads.size.pluralize 'thread'} as spam",
420
+ undos, lambda { regen_text }
357
421
  regen_text
358
422
  end
359
423
 
@@ -364,10 +428,9 @@ EOS
364
428
 
365
429
  ## see comment for multi_toggle_spam
366
430
  def multi_toggle_deleted threads
367
- threads.each do |t|
368
- actually_toggle_deleted t
369
- hide_thread t
370
- end
431
+ undos = threads.map { |t| actually_toggle_deleted t }
432
+ UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}",
433
+ undos, lambda { regen_text }
371
434
  regen_text
372
435
  end
373
436
 
@@ -376,24 +439,44 @@ EOS
376
439
  multi_kill [t]
377
440
  end
378
441
 
442
+ ## m-m-m-m-MULTI-KILL
379
443
  def multi_kill threads
444
+ UndoManager.register "killing #{threads.size.pluralize 'thread'}" do
445
+ threads.each do |t|
446
+ t.remove_label :killed
447
+ add_or_unhide t.first
448
+ end
449
+ regen_text
450
+ end
451
+
380
452
  threads.each do |t|
381
453
  t.apply_label :killed
382
454
  hide_thread t
383
455
  end
456
+
384
457
  regen_text
385
- BufferManager.flash "#{threads.size.pluralize 'Thread'} killed."
458
+ BufferManager.flash "#{threads.size.pluralize 'thread'} killed."
386
459
  end
387
460
 
388
- def save
389
- BufferManager.say("Saving contacts...") { ContactManager.instance.save }
390
- dirty_threads = @mutex.synchronize { (@threads + @hidden_threads.keys).select { |t| t.dirty? } }
391
- return if dirty_threads.empty?
461
+ def save background=true
462
+ if background
463
+ Redwood::reporting_thread("saving thread") { actually_save }
464
+ else
465
+ actually_save
466
+ end
467
+ end
392
468
 
393
- BufferManager.say("Saving threads...") do |say_id|
394
- dirty_threads.each_with_index do |t, i|
395
- BufferManager.say "Saving modified thread #{i + 1} of #{dirty_threads.length}...", say_id
396
- t.save Index
469
+ def actually_save
470
+ @save_thread_mutex.synchronize do
471
+ BufferManager.say("Saving contacts...") { ContactManager.instance.save }
472
+ dirty_threads = @mutex.synchronize { (@threads + @hidden_threads.keys).select { |t| t.dirty? } }
473
+ next if dirty_threads.empty?
474
+
475
+ BufferManager.say("Saving threads...") do |say_id|
476
+ dirty_threads.each_with_index do |t, i|
477
+ BufferManager.say "Saving modified thread #{i + 1} of #{dirty_threads.length}...", say_id
478
+ t.save Index
479
+ end
397
480
  end
398
481
  end
399
482
  end
@@ -407,7 +490,7 @@ EOS
407
490
  sleep 0.1 # TODO: necessary?
408
491
  BufferManager.erase_flash
409
492
  end
410
- save
493
+ save false
411
494
  super
412
495
  end
413
496
 
@@ -424,9 +507,14 @@ EOS
424
507
  end
425
508
 
426
509
  def tag_matching
427
- query = BufferManager.ask :search, "tag threads matching: "
510
+ query = BufferManager.ask :search, "tag threads matching (regex): "
428
511
  return if query.nil? || query.empty?
429
- query = /#{query}/i
512
+ query = begin
513
+ /#{query}/i
514
+ rescue RegexpError => e
515
+ BufferManager.flash "error interpreting '#{query}': #{e.message}"
516
+ return
517
+ end
430
518
  @mutex.synchronize { @threads.each { |t| @tags.tag t if thread_matches?(t, query) } }
431
519
  regen_text
432
520
  end
@@ -436,6 +524,10 @@ EOS
436
524
  def edit_labels
437
525
  thread = cursor_thread or return
438
526
  speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq
527
+
528
+ old_labels = thread.labels
529
+ pos = curpos
530
+
439
531
  keepl, modifyl = thread.labels.partition { |t| speciall.member? t }
440
532
 
441
533
  user_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", modifyl, @hidden_labels
@@ -444,21 +536,49 @@ EOS
444
536
  thread.labels = keepl + user_labels
445
537
  user_labels.each { |l| LabelManager << l }
446
538
  update_text_for_line curpos
539
+
540
+ UndoManager.register "labeling thread" do
541
+ thread.labels = old_labels
542
+ update_text_for_line pos
543
+ UpdateManager.relay self, :labeled, thread.first
544
+ end
545
+
447
546
  UpdateManager.relay self, :labeled, thread.first
448
547
  end
449
548
 
450
549
  def multi_edit_labels threads
451
- user_labels = BufferManager.ask_for_labels :add_labels, "Add labels: ", [], @hidden_labels
550
+ user_labels = BufferManager.ask_for_labels :labels, "Add/remove labels (use -label to remove): ", [], @hidden_labels
452
551
  return unless user_labels
453
-
454
- hl = user_labels.select { |l| @hidden_labels.member? l }
455
- if hl.empty?
456
- threads.each { |t| user_labels.each { |l| t.apply_label l } }
457
- user_labels.each { |l| LabelManager << l }
458
- else
552
+
553
+ user_labels.map! { |l| (l.to_s =~ /^-/)? [l.to_s.gsub(/^-?/, '').to_sym, true] : [l, false] }
554
+ hl = user_labels.select { |(l,_)| @hidden_labels.member? l }
555
+ unless hl.empty?
459
556
  BufferManager.flash "'#{hl}' is a reserved label!"
557
+ return
460
558
  end
559
+
560
+ old_labels = threads.map { |t| t.labels.dup }
561
+
562
+ threads.each do |t|
563
+ user_labels.each do |(l, to_remove)|
564
+ if to_remove
565
+ t.remove_label l
566
+ else
567
+ t.apply_label l
568
+ LabelManager << l
569
+ end
570
+ end
571
+ end
572
+
461
573
  regen_text
574
+
575
+ UndoManager.register "labeling #{threads.size.pluralize 'thread'}" do
576
+ threads.zip(old_labels).map do |t, old_labels|
577
+ t.labels = old_labels
578
+ UpdateManager.relay self, :labeled, t.first
579
+ end
580
+ regen_text
581
+ end
462
582
  end
463
583
 
464
584
  def reply
@@ -662,7 +782,7 @@ protected
662
782
 
663
783
  date = t.date.to_nice_s
664
784
 
665
- starred = t.has_label?(:starred)
785
+ starred = t.has_label? :starred
666
786
 
667
787
  ## format the from column
668
788
  cur_width = 0
@@ -673,9 +793,9 @@ protected
673
793
  last = i == ann.length - 1
674
794
 
675
795
  abbrev =
676
- if cur_width + name.length > from_width
796
+ if cur_width + name.display_length > from_width
677
797
  name[0 ... (from_width - cur_width - 1)] + "."
678
- elsif cur_width + name.length == from_width
798
+ elsif cur_width + name.display_length == from_width
679
799
  name[0 ... (from_width - cur_width)]
680
800
  else
681
801
  if last
@@ -685,7 +805,7 @@ protected
685
805
  end
686
806
  end
687
807
 
688
- cur_width += abbrev.length
808
+ cur_width += abbrev.display_length
689
809
 
690
810
  if last && from_width > cur_width
691
811
  abbrev += " " * (from_width - cur_width)
@@ -727,7 +847,6 @@ protected
727
847
  (t.labels - @hidden_labels).map { |label| [:label_color, "+#{label} "] } +
728
848
  [[:snippet_color, snippet]
729
849
  ]
730
-
731
850
  end
732
851
 
733
852
  def dirty?; @mutex.synchronize { (@hidden_threads.keys + @threads).any? { |t| t.dirty? } } end