ghi 0.3.1 → 0.9.0.dev

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,133 @@
1
+ require 'curses'
2
+ require 'date'
3
+
4
+ module GHI
5
+ module Commands
6
+ class List < Command
7
+ attr_accessor :reverse
8
+ attr_accessor :quiet
9
+
10
+ def options
11
+ OptionParser.new do |opts|
12
+ opts.banner = 'usage: ghi list [options]'
13
+ opts.separator ''
14
+ opts.on '-a', '--global', '--all', 'all of your issues on GitHub' do
15
+ @repo = nil
16
+ end
17
+ opts.on(
18
+ '-s', '--state <in>', %w(open closed),
19
+ {'o'=>'open', 'c'=>'closed'}, "'open' or 'closed'"
20
+ ) do |state|
21
+ assigns[:state] = state
22
+ end
23
+ opts.on(
24
+ '-L', '--label <labelname>...', Array, 'by label(s)'
25
+ ) do |labels|
26
+ assigns[:labels] = labels.join ','
27
+ end
28
+ opts.on(
29
+ '-S', '--sort <by>', %w(created updated comments),
30
+ {'c'=>'created','u'=>'updated','m'=>'comments'},
31
+ "'created', 'updated', or 'comments'"
32
+ ) do |sort|
33
+ assigns[:sort] = sort
34
+ end
35
+ opts.on '--reverse', 'reverse (ascending) sort order' do
36
+ self.reverse = !reverse
37
+ end
38
+ opts.on(
39
+ '--since <date>', 'issues more recent than',
40
+ "e.g., '2011-04-30'"
41
+ ) do |date|
42
+ begin
43
+ assigns[:since] = DateTime.parse date # TODO: Better parsing.
44
+ rescue ArgumentError => e
45
+ raise OptionParser::InvalidArgument, e.message
46
+ end
47
+ end
48
+ opts.on '-v', '--verbose' do
49
+ self.verbose = true
50
+ end
51
+ opts.separator ''
52
+ opts.separator 'Global options'
53
+ opts.on(
54
+ '-f', '--filter <by>',
55
+ filters = %w(assigned created mentioned subscribed),
56
+ Hash[filters.map { |f| [f[0, 1], f] }],
57
+ "'assigned', 'created', 'mentioned', or", "'subscribed'"
58
+ ) do |filter|
59
+ assigns[:filter] = filter
60
+ end
61
+ opts.separator ''
62
+ opts.separator 'Project options'
63
+ opts.on(
64
+ '-M', '--[no-]milestone [<n>]', Integer,
65
+ 'with (specified) milestone'
66
+ ) do |milestone|
67
+ assigns[:milestone] = any_or_none_or milestone
68
+ end
69
+ opts.on(
70
+ '-u', '--[no-]assignee [<user>]', 'assigned to specified user'
71
+ ) do |assignee|
72
+ assigns[:assignee] = any_or_none_or assignee
73
+ end
74
+ opts.on '--mine', 'assigned to you' do
75
+ assigns[:assignee] = Authorization.username
76
+ end
77
+ opts.on(
78
+ '-U', '--mentioned [<user>]', 'mentioning you or specified user'
79
+ ) do |mentioned|
80
+ assigns[:mentioned] = mentioned || Authorization.username
81
+ end
82
+ opts.separator ''
83
+ end
84
+ end
85
+
86
+ def execute
87
+ begin
88
+ options.parse! args
89
+ rescue OptionParser::InvalidOption => e
90
+ fallback.parse! e.args
91
+ retry
92
+ end
93
+
94
+ if reverse
95
+ assigns[:sort] ||= 'created'
96
+ assigns[:direction] = 'asc'
97
+ end
98
+
99
+ unless quiet
100
+ print format_issues_header
101
+ print "\n" unless STDOUT.tty?
102
+ end
103
+ res = throb(
104
+ 0, format_state(assigns[:state], quiet ? CURSOR[:up][1] : '#')
105
+ ) { api.get uri, assigns }
106
+ loop do
107
+ issues = res.body
108
+ if verbose
109
+ puts issues.map { |i| format_issue i }
110
+ else
111
+ puts format_issues(issues, repo.nil?)
112
+ end
113
+ break unless res.next_page
114
+ page?
115
+ res = throb { api.get res.next_page }
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ def uri
122
+ repo ? "/repos/#{repo}/issues" : '/issues'
123
+ end
124
+
125
+ def fallback
126
+ OptionParser.new do |opts|
127
+ opts.on('-c', '--closed') { assigns[:state] = 'closed' }
128
+ opts.on('-q', '--quiet') { self.quiet = true }
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,150 @@
1
+ require 'date'
2
+
3
+ module GHI
4
+ module Commands
5
+ class Milestone < Command
6
+ attr_accessor :reverse
7
+
8
+ #--
9
+ # FIXME: Opt for better interface, e.g.,
10
+ #
11
+ # ghi milestone [-v | --verbose] [--[no-]closed]
12
+ # ghi milestone add <name> <description>
13
+ # ghi milestone rm <milestoneno>
14
+ #++
15
+ def options
16
+ OptionParser.new do |opts|
17
+ opts.banner = <<EOF
18
+ usage: ghi milestone [<modification options>] [<milestoneno>]
19
+ or: ghi milestone -D <milestoneno>
20
+ or: ghi milestone -l [-c] [-v]
21
+ EOF
22
+ opts.separator ''
23
+ opts.on '-l', '--list', 'list milestones' do
24
+ self.action = 'index'
25
+ end
26
+ opts.on '-c', '--[no-]closed', 'show closed milestones' do |closed|
27
+ assigns[:state] = closed ? 'closed' : 'open'
28
+ end
29
+ opts.on(
30
+ '-S', '--sort <on>', %w(due_date completeness),
31
+ {'d'=>'due_date', 'due'=>'due_date', 'c'=>'completeness'},
32
+ "'due_date' or 'completeness'"
33
+ ) do |sort|
34
+ assigns[:sort] = sort
35
+ end
36
+ opts.on '--reverse', 'reverse (ascending) sort order' do
37
+ self.reverse = !reverse
38
+ end
39
+ opts.on '-v', '--verbose', 'list milestones verbosely' do
40
+ self.verbose = true
41
+ end
42
+ opts.separator ''
43
+ opts.separator 'Milestone modification options'
44
+ opts.on(
45
+ '-m', '--message <text>', 'change milestone description'
46
+ ) do |text|
47
+ self.action = 'create'
48
+ assigns[:title], assigns[:description] = text.split(/\n+/, 2)
49
+ end
50
+ # FIXME: We already describe --[no-]closed; describe this, too?
51
+ opts.on(
52
+ '-s', '--state <in>', %w(open closed),
53
+ {'o'=>'open', 'c'=>'closed'}, "'open' or 'closed'"
54
+ ) do |state|
55
+ self.action = 'create'
56
+ assigns[:state] = state
57
+ end
58
+ opts.on(
59
+ '--due <on>', 'when milestone should be complete',
60
+ "e.g., '2012-04-30'"
61
+ ) do |date|
62
+ self.action = 'create'
63
+ begin
64
+ # TODO: Better parsing.
65
+ assigns[:due_on] = DateTime.parse(date).strftime
66
+ rescue ArgumentError => e
67
+ raise OptionParser::InvalidArgument, e.message
68
+ end
69
+ end
70
+ opts.on '-D', '--delete', 'delete milestone' do
71
+ self.action = 'destroy'
72
+ end
73
+ opts.separator ''
74
+ end
75
+ end
76
+
77
+ def execute
78
+ self.action = 'index'
79
+ extract_milestone
80
+
81
+ begin
82
+ options.parse! args
83
+ rescue OptionParser::AmbiguousOption => e
84
+ fallback.parse! e.args
85
+ end
86
+
87
+ milestone and case action
88
+ when 'create' then self.action = 'update'
89
+ when 'index' then self.action = 'show'
90
+ end
91
+
92
+ if reverse
93
+ assigns[:sort] ||= 'created'
94
+ assigns[:direction] = 'asc'
95
+ end
96
+
97
+ case action
98
+ when 'index'
99
+ state = assigns[:state] || 'open'
100
+ print format_state state, "# #{repo} #{state} milestones"
101
+ print "\n" unless STDOUT.tty?
102
+ res = throb(0, format_state(state, '#')) { api.get uri }
103
+ loop do
104
+ milestones = res.body
105
+ if verbose
106
+ puts milestones.map { |m| format_milestone m }
107
+ else
108
+ puts format_milestones(milestones)
109
+ end
110
+ break unless res.next_page
111
+ page?
112
+ res = throb { api.get res.next_page }
113
+ end
114
+ when 'show'
115
+ m = throb { api.get uri }.body
116
+ print format_milestone(m)
117
+ puts 'Issues:'
118
+ List.execute %W(-q -M #{milestone} -- #{repo})
119
+ when 'create'
120
+ m = throb { api.post uri, assigns }.body
121
+ puts 'Milestone #%d created.' % m['number']
122
+ when 'update'
123
+ throb { api.patch uri, assigns }
124
+ puts 'Milestone updated.'
125
+ when 'destroy'
126
+ throb { api.delete uri }
127
+ puts 'Milestone deleted.'
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ def uri
134
+ if milestone
135
+ "/repos/#{repo}/milestones/#{milestone}"
136
+ else
137
+ "/repos/#{repo}/milestones"
138
+ end
139
+ end
140
+
141
+ def fallback
142
+ OptionParser.new do |opts|
143
+ opts.on '-d' do
144
+ self.action = 'destroy'
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,69 @@
1
+ module GHI
2
+ module Commands
3
+ class Open < Command
4
+ def options
5
+ #--
6
+ # TODO: Support shortcuts, e.g,
7
+ #
8
+ # ghi open "Issue Title"
9
+ #++
10
+ OptionParser.new do |opts|
11
+ opts.banner = <<EOF
12
+ usage: ghi open [options]
13
+ or: ghi reopen [options] <issueno>
14
+ EOF
15
+ opts.separator ''
16
+ opts.on '-l', '--list', 'list open tickets' do
17
+ self.action = 'index'
18
+ end
19
+ opts.separator ''
20
+ opts.separator 'Issue modification options'
21
+ opts.on '-m', '--message <text>', 'describe issue' do |text|
22
+ assigns[:title], assigns[:body] = text.split(/\n+/, 2)
23
+ end
24
+ opts.on(
25
+ '-u', '--[no-]assign <user>', 'assign to specified user'
26
+ ) do |assignee|
27
+ assigns[:assignee] = assignee
28
+ end
29
+ opts.on(
30
+ '-M', '--milestone <n>', 'associate with milestone'
31
+ ) do |milestone|
32
+ assigns[:milestone] = milestone
33
+ end
34
+ opts.on(
35
+ '-L', '--label <labelname>...', Array, 'associate with label(s)'
36
+ ) do |labels|
37
+ assigns[:labels] = labels
38
+ end
39
+ opts.separator ''
40
+ end
41
+ end
42
+
43
+ def execute
44
+ self.action = 'create'
45
+
46
+ if extract_issue
47
+ Edit.new(args.unshift('-so', issue)).execute
48
+ puts 'Reopened.'
49
+ exit
50
+ end
51
+
52
+ options.parse! args
53
+
54
+ case action
55
+ when 'index'
56
+ List.new(args).execute
57
+ when 'create'
58
+ if assigns[:title].nil? # FIXME: Open $EDITOR
59
+ warn "Missing argument: -m"
60
+ abort options.to_s
61
+ end
62
+ i = throb { api.post "/repos/#{repo}/issues", assigns }.body
63
+ puts format_issue(i)
64
+ puts 'Opened.'
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,21 @@
1
+ module GHI
2
+ module Commands
3
+ class Show < Command
4
+ def options
5
+ OptionParser.new do |opts|
6
+ opts.banner = 'usage: ghi show <issueno> [[<user>/]<repo>]'
7
+ opts.separator ''
8
+ end
9
+ end
10
+
11
+ def execute
12
+ require_issue
13
+ require_repo
14
+ i = throb { api.get "/repos/#{repo}/issues/#{issue}" }.body
15
+ puts format_issue(i)
16
+ page? 'Load comments?'
17
+ Comment.execute %W(-l #{issue} -- #{repo})
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,16 @@
1
+ module GHI
2
+ module Commands
3
+ module Version
4
+ MAJOR = 0
5
+ MINOR = 9
6
+ PATCH = 0
7
+ PRE = 'dev'
8
+
9
+ VERSION = [MAJOR, MINOR, PATCH, PRE].compact.join '.'
10
+
11
+ def self.execute args
12
+ puts "ghi version #{VERSION}"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,301 @@
1
+ # encoding: utf-8
2
+ require 'date'
3
+ require 'erb'
4
+
5
+ module GHI
6
+ module Formatting
7
+ autoload :Colors, 'ghi/formatting/colors'
8
+ include Colors
9
+
10
+ CURSOR = {
11
+ :up => lambda { |n| "\e[#{n}A" },
12
+ :column => lambda { |n| "\e[#{n}G" },
13
+ :hide => "\e[?25l",
14
+ :show => "\e[?25h"
15
+ }
16
+
17
+ THROBBERS = [
18
+ %w(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏),
19
+ %w(⠋ ⠙ ⠚ ⠞ ⠖ ⠦ ⠴ ⠲ ⠳ ⠓),
20
+ %w(⠄ ⠆ ⠇ ⠋ ⠙ ⠸ ⠰ ⠠ ⠰ ⠸ ⠙ ⠋ ⠇ ⠆ ),
21
+ %w(⠋ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋),
22
+ %w(⠁ ⠉ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠤ ⠄ ⠄ ⠤ ⠴ ⠲ ⠒ ⠂ ⠂ ⠒ ⠚ ⠙ ⠉ ⠁),
23
+ %w(⠈ ⠉ ⠋ ⠓ ⠒ ⠐ ⠐ ⠒ ⠖ ⠦ ⠤ ⠠ ⠠ ⠤ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋ ⠉ ⠈),
24
+ %w(⠁ ⠁ ⠉ ⠙ ⠚ ⠒ ⠂ ⠂ ⠒ ⠲ ⠴ ⠤ ⠄ ⠄ ⠤ ⠠ ⠠ ⠤ ⠦ ⠖ ⠒ ⠐ ⠐ ⠒ ⠓ ⠋ ⠉ ⠈ ⠈ ⠉)
25
+ ]
26
+
27
+ def puts *strings
28
+ strings = strings.flatten.map { |s|
29
+ s.gsub(/@([^@\s]+)/) {
30
+ if $1 == Authorization.username
31
+ bright { fg(:yellow) { "@#$1" } }
32
+ else
33
+ bright { "@#$1" }
34
+ end
35
+ }
36
+ }
37
+ stdout, $stdout = $stdout, IO.popen('less -ErX', 'w')
38
+ super strings
39
+ rescue Errno::EPIPE
40
+ ensure
41
+ $stdout.close_write
42
+ $stdout = stdout
43
+ print CURSOR[:show]
44
+ end
45
+
46
+ def truncate string, reserved
47
+ result = string.scan(/.{0,#{columns - reserved}}(?:\s|\Z)/).first.strip
48
+ result << "..." if result != string
49
+ result
50
+ end
51
+
52
+ def indent string, level = 4
53
+ string = string.gsub(/\r/, '')
54
+ string.gsub!(/[\t ]+$/, '')
55
+ string.gsub!(/\n{3,}/, "\n\n")
56
+ width = columns - level - 1
57
+ lines = string.scan(
58
+ /.{0,#{width}}(?:\s|\Z)|[\S]{#{width},}/ # TODO: Test long lines.
59
+ ).map { |line| " " * level + line.chomp }
60
+ format_markdown lines.join("\n").rstrip, level
61
+ end
62
+
63
+ def columns
64
+ dimensions[1] || 80
65
+ end
66
+
67
+ def dimensions
68
+ `stty size`.chomp.split(' ').map { |n| n.to_i }
69
+ end
70
+
71
+ #--
72
+ # Specific formatters:
73
+ #++
74
+
75
+ def format_issues_header
76
+ state = assigns[:state] || 'open'
77
+ header = "# #{repo || 'Global,'} #{state} issues"
78
+ if repo
79
+ if milestone = assigns[:milestone]
80
+ header.sub! repo, "#{repo} milestone ##{milestone}"
81
+ end
82
+ if assignee = assigns[:assignee]
83
+ header << case assignee
84
+ when '*' then ', assigned'
85
+ when 'none' then ', unassigned'
86
+ else
87
+ assignee = 'you' if Authorization.username == assignee
88
+ ", assigned to #{assignee}"
89
+ end
90
+ end
91
+ if mentioned = assigns[:mentioned]
92
+ mentioned = 'you' if Authorization.username == mentioned
93
+ header << ", mentioning #{mentioned}"
94
+ end
95
+ else
96
+ header << case assigns[:filter]
97
+ when 'created' then ' you created'
98
+ when 'mentioned' then ' that mention you'
99
+ when 'subscribed' then " you're subscribed to"
100
+ else
101
+ ' assigned to you'
102
+ end
103
+ end
104
+ if labels = assigns[:labels]
105
+ header << ", labeled #{assigns[:labels].gsub ',', ', '}"
106
+ end
107
+ if sort = assigns[:sort]
108
+ header << ", by #{sort} #{reverse ? 'ascending' : 'descending'}"
109
+ end
110
+ format_state assigns[:state], header
111
+ end
112
+
113
+ # TODO: Show milestones.
114
+ def format_issues issues, include_repo
115
+ return 'None.' if issues.empty?
116
+
117
+ include_repo and issues.each do |i|
118
+ %r{/repos/[^/]+/([^/]+)} === i['url'] and i['repo'] = $1
119
+ end
120
+
121
+ nmax, rmax = %w(number repo).map { |f|
122
+ issues.sort_by { |i| i[f].to_s.size }.last[f].to_s.size
123
+ }
124
+
125
+ issues.map { |i|
126
+ n, title, labels = i['number'], i['title'], i['labels']
127
+ l = 8 + nmax + rmax + no_color { format_labels labels }.to_s.length
128
+ a = i['assignee'] && i['assignee']['login'] == Authorization.username
129
+ l += 2 if a
130
+ p = i['pull_request']['html_url'] and l += 2
131
+ c = i['comments'] if i['comments'] > 0 and l += 2
132
+ [
133
+ " ",
134
+ (i['repo'].to_s.rjust(rmax) if i['repo']),
135
+ "#{bright { n.to_s.rjust nmax }}:",
136
+ truncate(title, l),
137
+ format_labels(labels),
138
+ (fg('aaaaaa') { c } if c),
139
+ (fg('aaaaaa') { '↑' } if p),
140
+ (fg(:yellow) { '@' } if a)
141
+ ].compact.join ' '
142
+ }
143
+ end
144
+
145
+ # TODO: Show milestone, number of comments, pull request attached.
146
+ def format_issue i
147
+ ERB.new(<<EOF).result binding
148
+ <% p = i['pull_request'].key? 'html_url' %>\
149
+ <%= bright { \
150
+ indent '%s%s: %s' % [p ? '↑' : '#', *i.values_at('number', 'title')], 0 } %>
151
+ @<%= i['user']['login'] %> opened this <%= p ? 'pull request' : 'issue' %> \
152
+ <%= format_date DateTime.parse(i['created_at']) %>. \
153
+ <%= format_state i['state'], format_tag(i['state']), :bg %>\
154
+ <% if i['assignee'] || !i['labels'].empty? %>
155
+ <% if i['assignee'] %>@<%= i['assignee']['login'] %> is assigned. <% end %>\
156
+ <% unless i['labels'].empty? %><%= format_labels(i['labels']) %><% end %>\
157
+ <% end %>
158
+ <% if i['body'] && !i['body'].empty? %>\n<%= indent i['body'] %>
159
+ <% end %>
160
+
161
+ EOF
162
+ end
163
+
164
+ def format_comments comments
165
+ return 'None.' if comments.empty?
166
+ comments.map { |comment| format_comment comment }
167
+ end
168
+
169
+ def format_comment c
170
+ <<EOF
171
+ @#{c['user']['login']} commented \
172
+ #{format_date DateTime.parse(c['created_at'])}:
173
+ #{indent c['body']}
174
+
175
+
176
+ EOF
177
+ end
178
+
179
+ def format_milestones milestones
180
+ return 'None.' if milestones.empty?
181
+
182
+ max = milestones.sort_by { |m|
183
+ m['number'].to_s.size
184
+ }.last['number'].to_s.size
185
+
186
+ milestones.map { |m|
187
+ due_on = m['due_on'] && DateTime.parse(m['due_on'])
188
+ [
189
+ " #{bright { m['number'].to_s.rjust max }}:",
190
+ fg((:red if due_on && due_on <= DateTime.now)) {
191
+ truncate(m['title'], max + 4)
192
+ }
193
+ ].compact.join ' '
194
+ }
195
+ end
196
+
197
+ def format_milestone m
198
+ ERB.new(<<EOF).result binding
199
+ <%= bright { indent '#%s: %s' % m.values_at('number', 'title'), 0 } %>
200
+ @<%= m['creator']['login'] %> created this milestone <%= m['created_at'] %>. \
201
+ <%= format_state m['state'], format_tag(m['state']), :bg %>
202
+ <% if m['due_on'] %>\
203
+ <% due_on = DateTime.parse m['due_on'] %>\
204
+ Due <%= fg((:red if due_on <= DateTime.now)) { format_date due_on } %>.
205
+ <% end %>\
206
+ <% if m['description'] && !m['description'].empty? %>
207
+ <%= indent m['description'] %>\
208
+ <% end %>
209
+ EOF
210
+ end
211
+
212
+ def format_state state, string = state, layer = :fg
213
+ send(layer, state == 'closed' ? :red : :green) { string }
214
+ end
215
+
216
+ def format_labels labels
217
+ return if labels.empty?
218
+ [*labels].map { |l| bg(l['color']) { format_tag l['name'] } }.join ' '
219
+ end
220
+
221
+ def format_tag tag
222
+ (colorize? ? ' %s ' : '[%s]') % tag
223
+ end
224
+
225
+ def format_markdown string, indent = 4
226
+ # Headers.
227
+ string.gsub!(/^( {#{indent}}\#{1,6} .+)$/, bright{'\1'})
228
+ string.gsub!(
229
+ /(^ {#{indent}}.+$\n^ {#{indent}}[-=]+$)/, bright{'\1'}
230
+ )
231
+ # Emphasis.
232
+ string.gsub!(
233
+ /(^|\s)(\*\w(?:[^*]*\w)?\*)(\s|$)/m, '\1' + underline{'\2'} + '\3'
234
+ )
235
+ string.gsub!(
236
+ /(^|\s)(_\w(?:[^_]*\w)?_)(\s|$)/m, '\1' + underline{'\2'} + '\3'
237
+ )
238
+ # Strong.
239
+ string.gsub!(
240
+ /(^|\s)(\*{2}\w(?:[^*]*\w)?\*{2})(\s|$)/m, '\1' + bright{'\2'} + '\3'
241
+ )
242
+ string.gsub!(
243
+ /(^|\s)(_{2}\w(?:[^_]*\w)?_{2})(\s|$)/m, '\1' + bright {'\2'} + '\3'
244
+ )
245
+ # Code.
246
+ string.gsub!(
247
+ /
248
+ (^\ {#{indent}}```.*?$)(.+?^\ {#{indent}}```$)|
249
+ (^|[^`])(`[^`]+`)([^`]|$)
250
+ /mx,
251
+ fg(c = '268bd2'){'\1'} + bg(c){'\2'} + '\3' + fg(c){'\4'} + '\5'
252
+ )
253
+ # URI.
254
+ string.gsub!(
255
+ %r{\b(<)?(https?://[\s]+|\w+@\w+)(>)?\b},
256
+ '\1' + underline{'\2'} + '\3'
257
+ )
258
+ string
259
+ end
260
+
261
+ def format_date date
262
+ days = (interval = DateTime.now - date).to_i.abs
263
+ string = if days.zero?
264
+ hours, minutes, seconds = DateTime.day_fraction_to_time interval.abs
265
+ if hours > 0
266
+ "#{hours} hour#{'s' unless hours == 1}"
267
+ elsif minutes > 0
268
+ "#{minutes} minute#{'s' unless minutes == 1}"
269
+ else
270
+ "#{seconds} second#{'s' unless seconds == 1}"
271
+ end
272
+ else
273
+ "#{days} day#{'s' unless days == 1}"
274
+ end
275
+ [string, interval < 0 ? 'from now' : 'ago'].join ' '
276
+ end
277
+
278
+ def throb position = 0, redraw = CURSOR[:up][1]
279
+ return yield unless STDOUT.tty?
280
+
281
+ throb = THROBBERS[rand(THROBBERS.length)]
282
+ throb.reverse! if rand > 0.5
283
+ i = rand throb.length
284
+
285
+ thread = Thread.new do
286
+ dot = lambda do
287
+ print("\r#{CURSOR[:column][position]}#{throb[i]}#{CURSOR[:hide]}")
288
+ i = (i + 1) % throb.length
289
+ sleep 0.1 and dot.call
290
+ end
291
+ dot.call
292
+ end
293
+ yield
294
+ ensure
295
+ if thread
296
+ thread.kill
297
+ puts "\r#{CURSOR[:column][position]}#{redraw}#{CURSOR[:show]}"
298
+ end
299
+ end
300
+ end
301
+ end