ghi 0.3.1 → 0.9.0.dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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