sup 0.0.8 → 0.1

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 (57) hide show
  1. data/HACKING +6 -36
  2. data/History.txt +11 -0
  3. data/Manifest.txt +5 -0
  4. data/README.txt +13 -31
  5. data/Rakefile +3 -3
  6. data/bin/sup +167 -89
  7. data/bin/sup-add +39 -29
  8. data/bin/sup-config +57 -31
  9. data/bin/sup-sync +60 -54
  10. data/bin/sup-sync-back +143 -0
  11. data/doc/FAQ.txt +56 -19
  12. data/doc/Philosophy.txt +34 -33
  13. data/doc/TODO +76 -46
  14. data/doc/UserGuide.txt +142 -122
  15. data/lib/sup.rb +76 -36
  16. data/lib/sup/account.rb +27 -19
  17. data/lib/sup/buffer.rb +130 -44
  18. data/lib/sup/contact.rb +1 -1
  19. data/lib/sup/draft.rb +1 -2
  20. data/lib/sup/imap.rb +64 -19
  21. data/lib/sup/index.rb +95 -16
  22. data/lib/sup/keymap.rb +1 -1
  23. data/lib/sup/label.rb +31 -5
  24. data/lib/sup/maildir.rb +7 -5
  25. data/lib/sup/mbox.rb +34 -15
  26. data/lib/sup/mbox/loader.rb +30 -12
  27. data/lib/sup/mbox/ssh-loader.rb +7 -5
  28. data/lib/sup/message.rb +93 -44
  29. data/lib/sup/modes/buffer-list-mode.rb +1 -1
  30. data/lib/sup/modes/completion-mode.rb +55 -0
  31. data/lib/sup/modes/compose-mode.rb +6 -25
  32. data/lib/sup/modes/contact-list-mode.rb +1 -1
  33. data/lib/sup/modes/edit-message-mode.rb +119 -29
  34. data/lib/sup/modes/file-browser-mode.rb +108 -0
  35. data/lib/sup/modes/forward-mode.rb +3 -20
  36. data/lib/sup/modes/inbox-mode.rb +9 -12
  37. data/lib/sup/modes/label-list-mode.rb +28 -46
  38. data/lib/sup/modes/label-search-results-mode.rb +1 -16
  39. data/lib/sup/modes/line-cursor-mode.rb +44 -5
  40. data/lib/sup/modes/person-search-results-mode.rb +1 -16
  41. data/lib/sup/modes/reply-mode.rb +18 -31
  42. data/lib/sup/modes/resume-mode.rb +6 -6
  43. data/lib/sup/modes/scroll-mode.rb +6 -5
  44. data/lib/sup/modes/search-results-mode.rb +6 -17
  45. data/lib/sup/modes/thread-index-mode.rb +70 -28
  46. data/lib/sup/modes/thread-view-mode.rb +65 -29
  47. data/lib/sup/person.rb +71 -30
  48. data/lib/sup/poll.rb +13 -4
  49. data/lib/sup/rfc2047.rb +61 -0
  50. data/lib/sup/sent.rb +7 -5
  51. data/lib/sup/source.rb +12 -9
  52. data/lib/sup/suicide.rb +36 -0
  53. data/lib/sup/tagger.rb +6 -6
  54. data/lib/sup/textfield.rb +76 -14
  55. data/lib/sup/thread.rb +97 -123
  56. data/lib/sup/util.rb +167 -1
  57. metadata +30 -5
data/lib/sup.rb CHANGED
@@ -12,8 +12,26 @@ class Object
12
12
  end
13
13
  end
14
14
 
15
+ class Module
16
+ def yaml_properties *props
17
+ props = props.map { |p| p.to_s }
18
+ vars = props.map { |p| "@#{p}" }
19
+ klass = self
20
+ path = klass.name.gsub(/::/, "/")
21
+
22
+ klass.instance_eval do
23
+ define_method(:to_yaml_properties) { vars }
24
+ define_method(:to_yaml_type) { "!#{Redwood::YAML_DOMAIN},#{Redwood::YAML_DATE}/#{path}" }
25
+ end
26
+
27
+ YAML.add_domain_type("#{Redwood::YAML_DOMAIN},#{Redwood::YAML_DATE}", path) do |type, val|
28
+ klass.new(*props.map { |p| val[p] })
29
+ end
30
+ end
31
+ end
32
+
15
33
  module Redwood
16
- VERSION = "0.0.8"
34
+ VERSION = "0.1"
17
35
 
18
36
  BASE_DIR = ENV["SUP_BASE"] || File.join(ENV["HOME"], ".sup")
19
37
  CONFIG_FN = File.join(BASE_DIR, "config.yaml")
@@ -23,46 +41,51 @@ module Redwood
23
41
  CONTACT_FN = File.join(BASE_DIR, "contacts.txt")
24
42
  DRAFT_DIR = File.join(BASE_DIR, "drafts")
25
43
  SENT_FN = File.join(BASE_DIR, "sent.mbox")
44
+ LOCK_FN = File.join(BASE_DIR, "lock")
45
+ SUICIDE_FN = File.join(BASE_DIR, "please-kill-yourself")
26
46
 
27
47
  YAML_DOMAIN = "masanjin.net"
28
48
  YAML_DATE = "2006-10-01"
29
49
 
50
+ ## determine encoding and character set
51
+ ## probably a better way to do this
52
+ $ctype = ENV["LC_CTYPE"] || ENV["LANG"] || "en-US.utf-8"
53
+ $encoding =
54
+ if $ctype =~ /\.(.*)?/
55
+ $1
56
+ else
57
+ "utf-8"
58
+ end
59
+
30
60
  ## record exceptions thrown in threads nicely
31
61
  $exception = nil
32
62
  def reporting_thread
33
- ::Thread.new do
34
- begin
35
- yield
36
- rescue Exception => e
37
- File.open("sup-exception-log.txt", "w") do |f|
38
- f.puts "--- #{e.class.name} at #{Time.now}"
39
- f.puts e.message, e.backtrace
63
+ if $opts[:no_threads]
64
+ yield
65
+ else
66
+ ::Thread.new do
67
+ begin
68
+ yield
69
+ rescue Exception => e
70
+ File.open("sup-exception-log.txt", "w") do |f|
71
+ f.puts "--- #{e.class.name} at #{Time.now}"
72
+ f.puts e.message, e.backtrace
73
+ end
74
+ $exception ||= e
75
+ raise
40
76
  end
41
- $exception ||= e
42
- raise
43
77
  end
44
78
  end
45
79
  end
46
80
  module_function :reporting_thread
47
81
 
48
82
  ## one-stop shop for yamliciousness
49
- def register_yaml klass, props
50
- vars = props.map { |p| "@#{p}" }
51
- path = klass.name.gsub(/::/, "/")
52
-
53
- klass.instance_eval do
54
- define_method(:to_yaml_properties) { vars }
55
- define_method(:to_yaml_type) { "!#{YAML_DOMAIN},#{YAML_DATE}/#{path}" }
56
- end
57
-
58
- YAML.add_domain_type("#{YAML_DOMAIN},#{YAML_DATE}", path) do |type, val|
59
- klass.new(*props.map { |p| val[p] })
60
- end
61
- end
62
-
63
- def save_yaml_obj object, fn, compress=false
64
- if compress
65
- Zlib::GzipWriter.open(fn) { |f| f.puts object.to_yaml }
83
+ def save_yaml_obj object, fn, safe=false
84
+ if safe
85
+ safe_fn = "#{File.dirname fn}/safe_#{File.basename fn}"
86
+ mode = File.stat(fn) if File.exists? fn
87
+ File.open(safe_fn, "w", mode) { |f| f.puts object.to_yaml }
88
+ FileUtils.mv safe_fn, fn
66
89
  else
67
90
  File.open(fn, "w") { |f| f.puts object.to_yaml }
68
91
  end
@@ -87,13 +110,14 @@ module Redwood
87
110
  Redwood::DraftManager.new Redwood::DRAFT_DIR
88
111
  Redwood::UpdateManager.new
89
112
  Redwood::PollManager.new
113
+ Redwood::SuicideManager.new Redwood::SUICIDE_FN
90
114
  end
91
115
 
92
116
  def finish
93
- Redwood::LabelManager.save
94
- Redwood::ContactManager.save
95
- Redwood::PersonManager.save
96
- Redwood::BufferManager.deinstantiate!
117
+ Redwood::LabelManager.save if Redwood::LabelManager.instantiated?
118
+ Redwood::ContactManager.save if Redwood::ContactManager.instantiated?
119
+ Redwood::PersonManager.save if Redwood::PersonManager.instantiated?
120
+ Redwood::BufferManager.deinstantiate! if Redwood::BufferManager.instantiated?
97
121
  end
98
122
 
99
123
  ## not really a good place for this, so I'll just dump it here.
@@ -110,7 +134,7 @@ Hi there. It looks like one or more message sources is reporting
110
134
  errors. Until this is corrected, messages from these sources cannot
111
135
  be viewed, and new messages will not be detected.
112
136
 
113
- #{broken_sources.map { |s| "Source: " + s.to_s + "\n Error: " + s.error.message.wrap(70).join("\n ")}.join('\n\n')}
137
+ #{broken_sources.map { |s| "Source: " + s.to_s + "\n Error: " + s.error.message.wrap(70).join("\n ")}.join("\n\n")}
114
138
  EOM
115
139
  #' stupid ruby-mode
116
140
  end
@@ -138,24 +162,37 @@ EOM
138
162
  end
139
163
  end
140
164
 
141
- module_function :register_yaml, :save_yaml_obj, :load_yaml_obj, :start, :finish, :report_broken_sources
165
+ module_function :save_yaml_obj, :load_yaml_obj, :start, :finish,
166
+ :report_broken_sources
142
167
  end
143
168
 
144
169
  ## set up default configuration file
145
170
  if File.exists? Redwood::CONFIG_FN
146
171
  $config = Redwood::load_yaml_obj Redwood::CONFIG_FN
147
172
  else
173
+ require 'etc'
174
+ require 'socket'
175
+ name = Etc.getpwnam(ENV["USER"]).gecos.split(/,/).first
176
+ email = ENV["USER"] + "@" +
177
+ begin
178
+ Socket.gethostbyname(Socket.gethostname).first
179
+ rescue SocketError
180
+ Socket.gethostname
181
+ end
182
+
148
183
  $config = {
149
184
  :accounts => {
150
185
  :default => {
151
- :name => "Sup Rocks",
152
- :email => "sup-rocks@reading-my-emails",
186
+ :name => name,
187
+ :email => email,
153
188
  :alternates => [],
154
189
  :sendmail => "/usr/sbin/sendmail -oem -ti",
155
190
  :signature => File.join(ENV["HOME"], ".signature")
156
191
  }
157
192
  },
158
- :editor => ENV["EDITOR"] || "/usr/bin/vi",
193
+ :editor => ENV["EDITOR"] || "/usr/bin/vim -f -c 'setlocal spell spelllang=en_us' -c 'set filetype=mail'",
194
+ :thread_by_subject => false,
195
+ :edit_signature => false,
159
196
  }
160
197
  begin
161
198
  FileUtils.mkdir_p Redwood::BASE_DIR
@@ -167,6 +204,7 @@ end
167
204
 
168
205
  require "sup/util"
169
206
  require "sup/update"
207
+ require "sup/suicide"
170
208
  require "sup/message"
171
209
  require "sup/source"
172
210
  require "sup/mbox"
@@ -206,6 +244,8 @@ require "sup/modes/inbox-mode"
206
244
  require "sup/modes/buffer-list-mode"
207
245
  require "sup/modes/log-mode"
208
246
  require "sup/modes/poll-mode"
247
+ require "sup/modes/file-browser-mode"
248
+ require "sup/modes/completion-mode"
209
249
  require "sup/logger"
210
250
  require "sup/sent"
211
251
 
data/lib/sup/account.rb CHANGED
@@ -1,12 +1,12 @@
1
1
  module Redwood
2
2
 
3
3
  class Account < Person
4
- attr_accessor :sendmail, :sig_file
4
+ attr_accessor :sendmail, :signature
5
5
 
6
- def initialize h
7
- super h[:name], h[:email]
6
+ def initialize email, h
7
+ super h[:name], email, 0, true
8
8
  @sendmail = h[:sendmail]
9
- @sig_file = h[:signature]
9
+ @signature = h[:signature]
10
10
  end
11
11
  end
12
12
 
@@ -17,37 +17,45 @@ class AccountManager
17
17
 
18
18
  def initialize accounts
19
19
  @email_map = {}
20
- @alternate_map = {}
21
20
  @accounts = {}
22
21
  @default_account = nil
23
22
 
24
- accounts.each { |k, v| add_account v, k == :default }
23
+ add_account accounts[:default], true
24
+ accounts.each { |k, v| add_account v unless k == :default }
25
25
 
26
26
  self.class.i_am_the_instance self
27
27
  end
28
28
 
29
29
  def user_accounts; @accounts.keys; end
30
- def user_emails; (@email_map.keys + @alternate_map.keys).uniq.select { |e| String === e }; end
30
+ def user_emails; @email_map.keys.select { |e| String === e }; end
31
31
 
32
+ ## must be called first with the default account. fills in missing
33
+ ## values from the default account.
32
34
  def add_account hash, default=false
33
- email = hash[:email]
35
+ raise ArgumentError, "no email specified for account" unless hash[:email]
36
+ unless default
37
+ [:name, :sendmail, :signature].each { |k| hash[k] ||= @default_account.send(k) }
38
+ end
39
+ hash[:alternates] ||= []
40
+
41
+ main_email = hash[:email]
42
+ ([hash[:email]] + hash[:alternates]).each do |email|
43
+ next if @email_map.member? email
44
+ a = Account.new main_email, hash
45
+ PersonManager.register a
46
+ @accounts[a] = true
47
+ @email_map[email] = a
48
+ end
34
49
 
35
- next if @email_map.member? email
36
- a = Account.new hash
37
- @accounts[a] = true
38
- @email_map[email] = a
39
- hash[:alternates].each { |aa| @alternate_map[aa] = a }
40
50
  if default
41
51
  raise ArgumentError, "multiple default accounts" if @default_account
42
- @default_account = a
52
+ @default_account = @email_map[main_email]
43
53
  end
44
54
  end
45
55
 
46
- def is_account? p; @accounts.member? p; end
47
- def account_for email
48
- @email_map[email] || @alternate_map[email] || @alternate_map.argfind { |k, v| k === email && v }
49
- end
50
- def is_account_email? email; !account_for(email).nil?; end
56
+ def is_account? p; is_account_email? p.email end
57
+ def account_for email; @email_map[email] end
58
+ def is_account_email? email; !account_for(email).nil? end
51
59
  end
52
60
 
53
61
  end
data/lib/sup/buffer.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require 'etc'
1
2
  require 'thread'
2
3
 
3
4
  module Ncurses
@@ -16,24 +17,10 @@ module Ncurses
16
17
  def mutex; @mutex ||= Mutex.new; end
17
18
  def sync &b; mutex.synchronize(&b); end
18
19
 
19
- ## aaahhh, user input. who would have though that such a simple
20
- ## idea would be SO FUCKING COMPLICATED?! because apparently
21
- ## Ncurses.getch (and Curses.getch), even in cbreak mode, BLOCKS
22
- ## ALL THREAD ACTIVITY. as in, no threads anywhere will run while
23
- ## it's waiting for input. ok, fine, so we wrap it in a select. Of
24
- ## course we also rely on Ncurses.getch to tell us when an xterm
25
- ## resize has occurred, which select won't catch, so we won't
26
- ## resize outselves after a sigwinch until the user hits a key.
27
- ## and installing our own sigwinch handler means that the screen
28
- ## size returned by getmaxyx() DOESN'T UPDATE! and Kernel#trap
29
- ## RETURNS NIL as the previous handler!
30
- ##
31
- ## so basically, resizing with multi-threaded ruby Ncurses
32
- ## applications will always be broken.
33
- ##
34
- ## i've coined a new word for this: lametarded.
20
+ ## magically, this stuff seems to work now. i could swear it didn't
21
+ ## before. hm.
35
22
  def nonblocking_getch
36
- if IO.select([$stdin], nil, nil, nil)
23
+ if IO.select([$stdin], nil, nil, 1)
37
24
  Ncurses.getch
38
25
  else
39
26
  nil
@@ -42,7 +29,9 @@ module Ncurses
42
29
 
43
30
  module_function :rows, :cols, :nonblocking_getch, :mutex, :sync
44
31
 
45
- KEY_CANCEL = "\a"[0] # ctrl-g
32
+ KEY_ENTER = 10
33
+ KEY_CANCEL = ?\a # ctrl-g
34
+ KEY_TAB = 9
46
35
  end
47
36
 
48
37
  module Redwood
@@ -196,6 +185,7 @@ class BufferManager
196
185
  def [] n; @name_map[n]; end
197
186
  def []= n, b
198
187
  raise ArgumentError, "duplicate buffer name" if b && @name_map.member?(n)
188
+ raise ArgumentError, "title must be a string" unless n.is_a? String
199
189
  @name_map[n] = b
200
190
  end
201
191
 
@@ -209,14 +199,6 @@ class BufferManager
209
199
  end
210
200
  end
211
201
 
212
- def handle_resize
213
- return if @shelled
214
- rows, cols = Ncurses.rows, Ncurses.cols
215
- @buffers.each { |b| b.resize rows - minibuf_lines, cols }
216
- completely_redraw_screen
217
- flash "Resized to #{rows}x#{cols}"
218
- end
219
-
220
202
  def draw_screen opts={}
221
203
  return if @shelled
222
204
 
@@ -227,7 +209,9 @@ class BufferManager
227
209
  ## TODO: reenable this if we allow multiple buffers
228
210
  false && @buffers.inject(@dirty) do |dirty, buf|
229
211
  buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols
230
- @dirty ? buf.draw : buf.redraw
212
+ #dirty ? buf.draw : buf.redraw
213
+ buf.draw
214
+ dirty
231
215
  end
232
216
 
233
217
  ## quick hack
@@ -238,6 +222,7 @@ class BufferManager
238
222
  end
239
223
 
240
224
  draw_minibuf :sync => false unless opts[:skip_minibuf]
225
+
241
226
  @dirty = false
242
227
  Ncurses.doupdate
243
228
  Ncurses.refresh if opts[:refresh]
@@ -258,6 +243,7 @@ class BufferManager
258
243
  end
259
244
 
260
245
  def spawn title, mode, opts={}
246
+ raise ArgumentError, "title must be a string" unless title.is_a? String
261
247
  realtitle = title
262
248
  num = 2
263
249
  while @name_map.member? realtitle
@@ -288,11 +274,29 @@ class BufferManager
288
274
  b
289
275
  end
290
276
 
277
+ ## requires the mode to have #done? and #value methods
278
+ def spawn_modal title, mode, opts={}
279
+ b = spawn title, mode, opts
280
+ draw_screen
281
+
282
+ until mode.done?
283
+ c = Ncurses.nonblocking_getch
284
+ next unless c # getch timeout
285
+ break if c == Ncurses::KEY_CANCEL
286
+ mode.handle_input c
287
+ draw_screen
288
+ erase_flash
289
+ end
290
+
291
+ kill_buffer b
292
+ mode.value
293
+ end
294
+
291
295
  def kill_all_buffers_safely
292
296
  until @buffers.empty?
293
297
  ## inbox mode always claims it's unkillable. we'll ignore it.
294
- return false unless @buffers.first.mode.is_a?(InboxMode) || @buffers.first.mode.killable?
295
- kill_buffer @buffers.first
298
+ return false unless @buffers.last.mode.is_a?(InboxMode) || @buffers.last.mode.killable?
299
+ kill_buffer @buffers.last
296
300
  end
297
301
  true
298
302
  end
@@ -322,20 +326,63 @@ class BufferManager
322
326
  end
323
327
  end
324
328
 
325
- ## not really thread safe.
326
- def ask domain, question, default=nil
329
+ def ask_with_completions domain, question, completions, default=nil
330
+ ask domain, question, default do |s|
331
+ completions.select { |x| x =~ /^#{s}/i }.map { |x| [x.downcase, x] }
332
+ end
333
+ end
334
+
335
+ ## returns an ARRAY of filenames!
336
+ def ask_for_filenames domain, question, default=nil
337
+ answer = ask domain, question, default do |s|
338
+ if s =~ /(~([^\s\/]*))/ # twiddle directory expansion
339
+ full = $1
340
+ name = $2.empty? ? Etc.getlogin : $2
341
+ dir = Etc.getpwnam(name).dir rescue nil
342
+ if dir
343
+ [[s.sub(full, dir), "~#{name}"]]
344
+ else
345
+ users.select { |u| u =~ /^#{name}/ }.map do |u|
346
+ [s.sub("~#{name}", "~#{u}"), "~#{u}"]
347
+ end
348
+ end
349
+ else # regular filename completion
350
+ Dir["#{s}*"].sort.map do |fn|
351
+ suffix = File.directory?(fn) ? "/" : ""
352
+ [fn + suffix, File.basename(fn) + suffix]
353
+ end
354
+ end
355
+ end
356
+
357
+ if answer
358
+ answer =
359
+ if answer.empty?
360
+ spawn_modal "file browser", FileBrowserMode.new
361
+ elsif File.directory?(answer)
362
+ spawn_modal "file browser", FileBrowserMode.new(answer)
363
+ else
364
+ [answer]
365
+ end
366
+ end
367
+
368
+ answer || []
369
+ end
370
+
371
+ def ask domain, question, default=nil, &block
327
372
  raise "impossible!" if @asking
373
+ @asking = true
328
374
 
329
375
  @textfields[domain] ||= TextField.new Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols
330
376
  tf = @textfields[domain]
377
+ completion_buf = nil
331
378
 
332
- ## this goddamn ncurses form shit is a fucking 1970's
333
- ## nightmare. jesus christ. the exact sequence of ncurses events
334
- ## that needs to happen in order to display a form and have the
335
- ## entire screen not disappear and have the cursor in the right
336
- ## place is TOO FUCKING COMPLICATED.
379
+ ## this goddamn ncurses form shit is a fucking 1970's nightmare.
380
+ ## jesus christ. the exact sequence of ncurses events that needs
381
+ ## to happen in order to display a form and have the entire screen
382
+ ## not disappear and have the cursor in the right place is TOO
383
+ ## FUCKING COMPLICATED.
337
384
  Ncurses.sync do
338
- tf.activate question, default
385
+ tf.activate question, default, &block
339
386
  @dirty = true
340
387
  draw_screen :skip_minibuf => true, :sync => false
341
388
  end
@@ -344,15 +391,42 @@ class BufferManager
344
391
  tf.position_cursor
345
392
  Ncurses.sync { Ncurses.refresh }
346
393
 
347
- @asking = true
348
- while tf.handle_input(Ncurses.nonblocking_getch); end
349
- @asking = false
394
+ while true
395
+ c = Ncurses.nonblocking_getch
396
+ next unless c # getch timeout
397
+ break unless tf.handle_input c # process keystroke
398
+
399
+ if tf.new_completions?
400
+ kill_buffer completion_buf if completion_buf
401
+
402
+ prefix_len =
403
+ if tf.value =~ /\/$/
404
+ 0
405
+ else
406
+ File.basename(tf.value).length
407
+ end
408
+
409
+ mode = CompletionMode.new tf.completions.map { |full, short| short }, :header => "Possible completions for \"#{tf.value}\": ", :prefix_len => prefix_len
410
+ completion_buf = spawn "<completions>", mode, :height => 10
411
+
412
+ draw_screen :skip_minibuf => true
413
+ tf.position_cursor
414
+ elsif tf.roll_completions?
415
+ completion_buf.mode.roll
416
+
417
+ draw_screen :skip_minibuf => true
418
+ tf.position_cursor
419
+ end
350
420
 
351
- ret = tf.value
421
+ Ncurses.sync { Ncurses.refresh }
422
+ end
423
+
352
424
  Ncurses.sync { tf.deactivate }
425
+ kill_buffer completion_buf if completion_buf
353
426
  @dirty = true
354
-
355
- ret
427
+ @asking = false
428
+ draw_screen
429
+ tf.value
356
430
  end
357
431
 
358
432
  ## some pretty lame code in here!
@@ -370,7 +444,7 @@ class BufferManager
370
444
  done = false
371
445
  @shelled = true
372
446
  until done
373
- key = Ncurses.nonblocking_getch
447
+ key = Ncurses.nonblocking_getch or next
374
448
  if key == Ncurses::KEY_CANCEL
375
449
  done = true
376
450
  elsif (accept && accept.member?(key)) || !accept
@@ -487,5 +561,17 @@ class BufferManager
487
561
  end
488
562
  @shelled = false
489
563
  end
564
+
565
+ private
566
+
567
+ def users
568
+ unless @users
569
+ @users = []
570
+ while(u = Etc.getpwent)
571
+ @users << u.name
572
+ end
573
+ end
574
+ @users
575
+ end
490
576
  end
491
577
  end