changelog-builder 1.0.0
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.
- checksums.yaml +7 -0
- data/.gitignore +23 -0
- data/.rspec +3 -0
- data/.rubocop.yml +13 -0
- data/.ruby-version +1 -0
- data/.yardopts +3 -0
- data/CHANGELOG.md +122 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +70 -0
- data/LICENSE.txt +21 -0
- data/README.md +204 -0
- data/Rakefile +21 -0
- data/bin/console +15 -0
- data/bin/setup +21 -0
- data/changelogger.gemspec +37 -0
- data/docs/demo.gif +0 -0
- data/exe/changelogger +5 -0
- data/lib/changelogger/branches_window.rb +704 -0
- data/lib/changelogger/changelog_generator.rb +70 -0
- data/lib/changelogger/cli.rb +247 -0
- data/lib/changelogger/git.rb +45 -0
- data/lib/changelogger/header.rb +45 -0
- data/lib/changelogger/main.rb +29 -0
- data/lib/changelogger/preview_window.rb +130 -0
- data/lib/changelogger/repo_info.rb +78 -0
- data/lib/changelogger/tui.rb +39 -0
- data/lib/changelogger/version.rb +5 -0
- data/lib/changelogger/versioner.rb +68 -0
- data/lib/changelogger.rb +9 -0
- metadata +77 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "curses"
|
|
5
|
+
require_relative "git"
|
|
6
|
+
require_relative "versioner"
|
|
7
|
+
require_relative "changelog_generator"
|
|
8
|
+
require_relative "repo_info"
|
|
9
|
+
|
|
10
|
+
module Changelogger
|
|
11
|
+
# +Changelogger::Graph+ caches `git log --graph` output for rendering.
|
|
12
|
+
class Graph
|
|
13
|
+
class << self
|
|
14
|
+
# +Changelogger::Graph::FILENAME+ stores the graph cache file name.
|
|
15
|
+
FILENAME = ".graph"
|
|
16
|
+
|
|
17
|
+
# +Changelogger::Graph.ensure!+ -> void
|
|
18
|
+
#
|
|
19
|
+
# Regenerates the graph cache file from the current repository.
|
|
20
|
+
# @return [void]
|
|
21
|
+
def ensure!
|
|
22
|
+
content = `git log --graph --decorate=short --date=short --pretty=format:'%h %d %s' 2>/dev/null`
|
|
23
|
+
if content.nil? || content.strip.empty?
|
|
24
|
+
content = "(no git graph available — empty repo or not a git repository)\n"
|
|
25
|
+
end
|
|
26
|
+
File.open(FILENAME, "w") { |f| f.write(content) }
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
File.open(FILENAME, "w") { |f| f.write("(error generating graph: #{e.message})\n") }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# +Changelogger::Graph.width+ -> Integer
|
|
32
|
+
#
|
|
33
|
+
# Returns the width of the widest graph line (used to size the pane).
|
|
34
|
+
# @return [Integer]
|
|
35
|
+
def width
|
|
36
|
+
ensure! unless File.exist?(FILENAME)
|
|
37
|
+
ensure!
|
|
38
|
+
max = 1
|
|
39
|
+
IO.foreach(FILENAME) { |line| max = [max, line.rstrip.length].max }
|
|
40
|
+
max
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# +Changelogger::Graph.build+ -> String
|
|
44
|
+
#
|
|
45
|
+
# Reads the cached graph content (regenerates if missing).
|
|
46
|
+
# @return [String]
|
|
47
|
+
def build
|
|
48
|
+
ensure! unless File.exist?(FILENAME)
|
|
49
|
+
File.read(FILENAME)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# +Changelogger::BranchWindow+ is the left-right split TUI with live preview.
|
|
55
|
+
class BranchWindow
|
|
56
|
+
# @return [Array<String>, nil] selected SHAs after Enter, or nil if cancelled
|
|
57
|
+
attr_reader :selected_shas
|
|
58
|
+
|
|
59
|
+
# Color pair IDs used within curses
|
|
60
|
+
CP_HELP = 2
|
|
61
|
+
CP_HIGHLIGHT = 3 # current cursor commit block
|
|
62
|
+
CP_SELECTED = 4 # selected anchor commit header
|
|
63
|
+
CP_SEP = 5 # thin separators
|
|
64
|
+
CP_ALT = 6 # zebra alt shading
|
|
65
|
+
|
|
66
|
+
# +Changelogger::BranchWindow.new+ -> Changelogger::BranchWindow
|
|
67
|
+
#
|
|
68
|
+
# Builds the UI panes (graph on the left, preview on the right) and prepares
|
|
69
|
+
# state for selection and scrolling.
|
|
70
|
+
#
|
|
71
|
+
# @param [Integer] max_height maximum height for the panes
|
|
72
|
+
# @param [Integer] top top offset
|
|
73
|
+
# @param [Integer] left left offset
|
|
74
|
+
# @param [Integer, nil] left_width optional fixed width for the left pane
|
|
75
|
+
def initialize(max_height: 50, top: 1, left: 0, left_width: nil)
|
|
76
|
+
@top = top
|
|
77
|
+
@left = left
|
|
78
|
+
@width = Curses.cols - @left
|
|
79
|
+
screen_height = Curses.lines
|
|
80
|
+
@height = [screen_height - @top, max_height].min
|
|
81
|
+
|
|
82
|
+
@repo = Changelogger::Repo.info
|
|
83
|
+
|
|
84
|
+
@lines = Graph.build.split("\n")
|
|
85
|
+
preferred_left = Graph.width + 4
|
|
86
|
+
@left_min = 24
|
|
87
|
+
@right_min = 28
|
|
88
|
+
@left_w = compute_left_width(requested_left: left_width || preferred_left)
|
|
89
|
+
|
|
90
|
+
# Graph state
|
|
91
|
+
@headers = detect_headers(@lines)
|
|
92
|
+
@selected_header_idx = 0
|
|
93
|
+
@selected_header_idxs = []
|
|
94
|
+
@offset = 0
|
|
95
|
+
@fit_full_block = true
|
|
96
|
+
@zebra_blocks = true
|
|
97
|
+
|
|
98
|
+
recompute_blocks!
|
|
99
|
+
|
|
100
|
+
# Preview state
|
|
101
|
+
@commits = Changelogger::Git.commits
|
|
102
|
+
@preview_lines = []
|
|
103
|
+
@preview_offset = 0
|
|
104
|
+
|
|
105
|
+
@focus = :left
|
|
106
|
+
@cancelled = false
|
|
107
|
+
|
|
108
|
+
setup_windows
|
|
109
|
+
init_colors
|
|
110
|
+
update_titles
|
|
111
|
+
update_preview(reset_offset: true)
|
|
112
|
+
ensure_visible
|
|
113
|
+
redraw
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# +Changelogger::BranchWindow#select_commits+ -> Array<String>, nil
|
|
117
|
+
#
|
|
118
|
+
# Enters the TUI input loop and returns when user confirms or cancels.
|
|
119
|
+
# @return [Array<String>, nil] anchor SHAs (2+ required) or nil when cancelled
|
|
120
|
+
def select_commits
|
|
121
|
+
handle_keyboard_input
|
|
122
|
+
@cancelled ? nil : (@selected_shas || [])
|
|
123
|
+
ensure
|
|
124
|
+
teardown
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# @!visibility private
|
|
130
|
+
|
|
131
|
+
# Compute left pane width, respecting min widths for both panes.
|
|
132
|
+
# @param [Integer] requested_left
|
|
133
|
+
# @return [Integer]
|
|
134
|
+
def compute_left_width(requested_left:)
|
|
135
|
+
w = @width
|
|
136
|
+
lw = requested_left.to_i
|
|
137
|
+
[[lw, @left_min].max, w - @right_min].min
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Create frames and subwindows for both panes.
|
|
141
|
+
# @return [void]
|
|
142
|
+
def setup_windows
|
|
143
|
+
destroy_windows
|
|
144
|
+
|
|
145
|
+
@left_frame = Curses::Window.new(@height, @left_w, @top, @left)
|
|
146
|
+
@left_frame.box
|
|
147
|
+
@left_sub = @left_frame.subwin(@height - 2, @left_w - 2, @top + 1, @left + 1)
|
|
148
|
+
@left_sub.keypad(true)
|
|
149
|
+
@left_sub.scrollok(false)
|
|
150
|
+
|
|
151
|
+
right_w = @width - @left_w
|
|
152
|
+
right_x = @left + @left_w
|
|
153
|
+
@right_frame = Curses::Window.new(@height, right_w, @top, right_x)
|
|
154
|
+
@right_frame.box
|
|
155
|
+
@right_sub = @right_frame.subwin(@height - 2, right_w - 2, @top + 1, right_x + 1)
|
|
156
|
+
@right_sub.keypad(true)
|
|
157
|
+
@right_sub.scrollok(false)
|
|
158
|
+
|
|
159
|
+
@left_frame.refresh
|
|
160
|
+
@right_frame.refresh
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Close subwindows/frames and close the curses screen.
|
|
164
|
+
# @return [void]
|
|
165
|
+
def destroy_windows
|
|
166
|
+
@left_sub&.close
|
|
167
|
+
@left_frame&.close
|
|
168
|
+
@right_sub&.close
|
|
169
|
+
@right_frame&.close
|
|
170
|
+
rescue StandardError
|
|
171
|
+
# ignore
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Cleanup hook called when leaving the TUI.
|
|
175
|
+
# @return [void]
|
|
176
|
+
def teardown
|
|
177
|
+
destroy_windows
|
|
178
|
+
Curses.close_screen
|
|
179
|
+
rescue StandardError
|
|
180
|
+
# ignore
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Update titles with repo name/branch/HEAD and remote slug/identifier.
|
|
184
|
+
# @return [void]
|
|
185
|
+
def update_titles
|
|
186
|
+
dirty = @repo.dirty ? "*" : ""
|
|
187
|
+
left_title = " Graph — #{@repo.name} [#{@repo.branch}@#{@repo.head_short}#{dirty}] "
|
|
188
|
+
draw_title(@left_frame, left_title)
|
|
189
|
+
right_id = @repo.remote_slug || @repo.name
|
|
190
|
+
right_title = " Preview — #{right_id} "
|
|
191
|
+
draw_title(@right_frame, right_title)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Draw a title into a frame (top border area).
|
|
195
|
+
# @param [Curses::Window] frame
|
|
196
|
+
# @param [String] label
|
|
197
|
+
# @return [void]
|
|
198
|
+
def draw_title(frame, label)
|
|
199
|
+
width = frame.maxx
|
|
200
|
+
text = label[0, [width - 4, 0].max]
|
|
201
|
+
frame.setpos(0, 2)
|
|
202
|
+
frame.addstr(" " * [width - 4, 0].max)
|
|
203
|
+
frame.setpos(0, 2)
|
|
204
|
+
frame.addstr(text)
|
|
205
|
+
frame.refresh
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Bold the focused frame’s title.
|
|
209
|
+
# @return [void]
|
|
210
|
+
def draw_focus
|
|
211
|
+
update_titles
|
|
212
|
+
if @focus == :left
|
|
213
|
+
@left_frame.setpos(0, 2)
|
|
214
|
+
@left_frame.attron(Curses::A_BOLD) { @left_frame.addstr("") }
|
|
215
|
+
else
|
|
216
|
+
@right_frame.setpos(0, 2)
|
|
217
|
+
@right_frame.attron(Curses::A_BOLD) { @right_frame.addstr("") }
|
|
218
|
+
end
|
|
219
|
+
@left_frame.refresh
|
|
220
|
+
@right_frame.refresh
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Help texts for the help bars.
|
|
224
|
+
# @return [String]
|
|
225
|
+
def left_help_text
|
|
226
|
+
"↑/↓ j/k move • Space select • Tab focus • Enter generate • PgUp/PgDn • f fit • r refresh • z zebra • </> split"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# @return [String]
|
|
230
|
+
def right_help_text
|
|
231
|
+
"↑/↓ j/k scroll • PgUp/PgDn • g top • G bottom • Tab focus"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Number of content rows (excluding help bar) on the left pane.
|
|
235
|
+
# @return [Integer]
|
|
236
|
+
def left_content_rows
|
|
237
|
+
[@left_sub.maxy - 1, 0].max
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Number of content rows (excluding help bar) on the right pane.
|
|
241
|
+
# @return [Integer]
|
|
242
|
+
def right_content_rows
|
|
243
|
+
[@right_sub.maxy - 1, 0].max
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Initialize color pairs and attributes for styling.
|
|
247
|
+
# @return [void]
|
|
248
|
+
def init_colors
|
|
249
|
+
if Curses.has_colors?
|
|
250
|
+
begin
|
|
251
|
+
Curses.start_color
|
|
252
|
+
Curses.use_default_colors if Curses.respond_to?(:use_default_colors)
|
|
253
|
+
rescue StandardError
|
|
254
|
+
end
|
|
255
|
+
Curses.init_pair(CP_HELP, Curses::COLOR_CYAN, -1)
|
|
256
|
+
Curses.init_pair(CP_HIGHLIGHT, Curses::COLOR_BLACK, Curses::COLOR_CYAN)
|
|
257
|
+
Curses.init_pair(CP_SELECTED, Curses::COLOR_BLACK, Curses::COLOR_YELLOW)
|
|
258
|
+
Curses.init_pair(CP_SEP, Curses::COLOR_BLUE, -1)
|
|
259
|
+
Curses.init_pair(CP_ALT, Curses::COLOR_WHITE, -1)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
if Curses.has_colors?
|
|
263
|
+
@style_help = Curses.color_pair(CP_HELP) | Curses::A_DIM
|
|
264
|
+
@style_highlight = Curses.color_pair(CP_HIGHLIGHT)
|
|
265
|
+
@style_selected = Curses.color_pair(CP_SELECTED) | Curses::A_BOLD
|
|
266
|
+
@style_sep = Curses.color_pair(CP_SEP) | Curses::A_DIM
|
|
267
|
+
@style_alt = Curses.color_pair(CP_ALT) | Curses::A_DIM
|
|
268
|
+
else
|
|
269
|
+
@style_help = Curses::A_DIM
|
|
270
|
+
@style_highlight = Curses::A_STANDOUT
|
|
271
|
+
@style_selected = Curses::A_BOLD
|
|
272
|
+
@style_sep = Curses::A_DIM
|
|
273
|
+
@style_alt = Curses::A_DIM
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Add a string with an attribute, guarding for zero attr.
|
|
278
|
+
# @param [Curses::Window] win
|
|
279
|
+
# @param [String] text
|
|
280
|
+
# @param [Integer, nil] attr curses attribute (or nil)
|
|
281
|
+
# @return [void]
|
|
282
|
+
def addstr_with_attr(win, text, attr)
|
|
283
|
+
if attr && attr != 0
|
|
284
|
+
win.attron(attr)
|
|
285
|
+
win.addstr(text)
|
|
286
|
+
win.attroff(attr)
|
|
287
|
+
else
|
|
288
|
+
win.addstr(text)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Detect header lines in `git log --graph` output.
|
|
293
|
+
# @param [Array<String>] lines
|
|
294
|
+
# @return [Array<Integer>] indexes of header rows
|
|
295
|
+
def detect_headers(lines)
|
|
296
|
+
lines.each_index.select { |i| lines[i] =~ %r{^\s*[|\s\\/]*\*\s} }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Map every line to a commit block index, and mark block boundaries.
|
|
300
|
+
# @return [void]
|
|
301
|
+
def recompute_blocks!
|
|
302
|
+
@block_index_by_line = Array.new(@lines.length, 0)
|
|
303
|
+
@boundary_set = Set.new
|
|
304
|
+
@headers.each_with_index do |start, j|
|
|
305
|
+
stop = @headers[j + 1] || @lines.length
|
|
306
|
+
(start...stop).each { |idx| @block_index_by_line[idx] = j }
|
|
307
|
+
@boundary_set << (stop - 1) if stop > start
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Absolute line number of the currently selected commit header.
|
|
312
|
+
# @return [Integer]
|
|
313
|
+
def header_line_abs
|
|
314
|
+
@headers[@selected_header_idx] || 0
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Extract an abbreviated SHA from a header line.
|
|
318
|
+
# @param [Integer] abs_index
|
|
319
|
+
# @return [String, nil] short or full SHA token
|
|
320
|
+
def header_sha_at(abs_index)
|
|
321
|
+
line = @lines[abs_index] || ""
|
|
322
|
+
m = line.match(/\b([a-f0-9]{7,40})\b/i)
|
|
323
|
+
m && m[1]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Find current header index by a known SHA.
|
|
327
|
+
# @param [String] sha
|
|
328
|
+
# @return [Integer, nil]
|
|
329
|
+
def find_header_index_by_sha(sha)
|
|
330
|
+
return nil if sha.nil?
|
|
331
|
+
|
|
332
|
+
@headers.find_index { |abs| (@lines[abs] || "").include?(sha[0, 7]) }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Current commit block line range.
|
|
336
|
+
# @return [Range]
|
|
337
|
+
def current_commit_range
|
|
338
|
+
start = header_line_abs
|
|
339
|
+
stop = @headers[@selected_header_idx + 1] || @lines.length
|
|
340
|
+
(start...stop)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Ensure the selected header (and optionally its block) is visible.
|
|
344
|
+
# @param [Boolean] fit_full_block when true, keep the entire block inside viewport if it fits
|
|
345
|
+
# @return [void]
|
|
346
|
+
def ensure_visible(fit_full_block: @fit_full_block)
|
|
347
|
+
rows = [left_content_rows, 1].max
|
|
348
|
+
header_line = header_line_abs
|
|
349
|
+
stop = @headers[@selected_header_idx + 1] || @lines.length
|
|
350
|
+
block_size = stop - header_line
|
|
351
|
+
|
|
352
|
+
if header_line < @offset
|
|
353
|
+
@offset = header_line
|
|
354
|
+
elsif header_line >= @offset + rows
|
|
355
|
+
@offset = header_line - (rows - 1)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
if fit_full_block && block_size <= rows
|
|
359
|
+
@offset = stop - rows if stop > @offset + rows
|
|
360
|
+
@offset = header_line if header_line < @offset
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
@offset = [[@offset, 0].max, [@lines.length - rows, 0].max].min
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Toggle selected mark on the current header.
|
|
367
|
+
# @return [void]
|
|
368
|
+
def toggle_selection
|
|
369
|
+
idx = @selected_header_idx
|
|
370
|
+
if @selected_header_idxs.include?(idx)
|
|
371
|
+
@selected_header_idxs.delete(idx)
|
|
372
|
+
else
|
|
373
|
+
@selected_header_idxs << idx
|
|
374
|
+
@selected_header_idxs.sort!
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# List selected SHAs in header order.
|
|
379
|
+
# @return [Array<String>]
|
|
380
|
+
def selected_shas_from_idxs
|
|
381
|
+
@selected_header_idxs.map { |h_idx| header_sha_at(@headers[h_idx]) }.compact
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Render / update the live preview content.
|
|
385
|
+
# @param [Boolean] reset_offset reset preview scroll to top
|
|
386
|
+
# @return [void]
|
|
387
|
+
def update_preview(reset_offset: false)
|
|
388
|
+
anchors = selected_shas_from_idxs
|
|
389
|
+
content =
|
|
390
|
+
if anchors.size >= 2
|
|
391
|
+
begin
|
|
392
|
+
Changelogger::ChangelogGenerator.render(@commits, anchors)
|
|
393
|
+
rescue StandardError => e
|
|
394
|
+
"Preview error: #{e.message}"
|
|
395
|
+
end
|
|
396
|
+
else
|
|
397
|
+
<<~TXT
|
|
398
|
+
Preview — select at least 2 commits with SPACE to generate.
|
|
399
|
+
Controls: #{right_help_text}
|
|
400
|
+
TXT
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
@preview_lines = (content || "").split("\n")
|
|
404
|
+
@preview_offset = 0 if reset_offset
|
|
405
|
+
clamp_preview_offset
|
|
406
|
+
redraw_right
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Clamp preview offset to visible range.
|
|
410
|
+
# @return [void]
|
|
411
|
+
def clamp_preview_offset
|
|
412
|
+
max_off = [@preview_lines.length - right_content_rows, 0].max
|
|
413
|
+
@preview_offset = [[@preview_offset, 0].max, max_off].min
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Refresh repo/graph/commits and preserve cursor and selections.
|
|
417
|
+
# @return [void]
|
|
418
|
+
def refresh_graph
|
|
419
|
+
current_sha = header_sha_at(header_line_abs)
|
|
420
|
+
selected_shas = selected_shas_from_idxs
|
|
421
|
+
|
|
422
|
+
@repo = Changelogger::Repo.info
|
|
423
|
+
update_titles
|
|
424
|
+
|
|
425
|
+
Graph.ensure!
|
|
426
|
+
@lines = Graph.build.split("\n")
|
|
427
|
+
@headers = detect_headers(@lines)
|
|
428
|
+
recompute_blocks!
|
|
429
|
+
|
|
430
|
+
if current_sha
|
|
431
|
+
new_idx = find_header_index_by_sha(current_sha)
|
|
432
|
+
@selected_header_idx = new_idx || 0
|
|
433
|
+
else
|
|
434
|
+
@selected_header_idx = 0
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
@selected_header_idxs = selected_shas.filter_map { |sha| find_header_index_by_sha(sha) }.sort
|
|
438
|
+
@commits = Changelogger::Git.commits
|
|
439
|
+
|
|
440
|
+
ensure_visible
|
|
441
|
+
redraw_left
|
|
442
|
+
update_preview
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Resize split between panes.
|
|
446
|
+
# @param [Integer] delta positive to expand left, negative to shrink
|
|
447
|
+
# @return [void]
|
|
448
|
+
def adjust_split(delta)
|
|
449
|
+
new_left = compute_left_width(requested_left: @left_w + delta)
|
|
450
|
+
return if new_left == @left_w
|
|
451
|
+
|
|
452
|
+
@left_w = new_left
|
|
453
|
+
setup_windows
|
|
454
|
+
init_colors
|
|
455
|
+
update_titles
|
|
456
|
+
ensure_visible
|
|
457
|
+
redraw
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Full redraw for both panes and focus indicator.
|
|
461
|
+
# @return [void]
|
|
462
|
+
def redraw
|
|
463
|
+
redraw_left
|
|
464
|
+
redraw_right
|
|
465
|
+
draw_focus
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Redraw left pane (help bar + graph list with styling and separators).
|
|
469
|
+
# @return [void]
|
|
470
|
+
def redraw_left
|
|
471
|
+
ensure_visible
|
|
472
|
+
@left_sub.erase
|
|
473
|
+
|
|
474
|
+
@left_sub.setpos(0, 0)
|
|
475
|
+
help = left_help_text.ljust(@left_sub.maxx, " ")[0, @left_sub.maxx]
|
|
476
|
+
addstr_with_attr(@left_sub, help, @style_help)
|
|
477
|
+
|
|
478
|
+
content_h = left_content_rows
|
|
479
|
+
highlight = current_commit_range
|
|
480
|
+
selected_header_abs = @selected_header_idxs.map { |i| @headers[i] }.to_set
|
|
481
|
+
|
|
482
|
+
visible = @lines[@offset, content_h] || []
|
|
483
|
+
visible.each_with_index do |line, i|
|
|
484
|
+
idx = @offset + i
|
|
485
|
+
@left_sub.setpos(i + 1, 0)
|
|
486
|
+
|
|
487
|
+
text = line.ljust(@left_sub.maxx, " ")[0, @left_sub.maxx]
|
|
488
|
+
|
|
489
|
+
attr =
|
|
490
|
+
if selected_header_abs.include?(idx)
|
|
491
|
+
@style_selected
|
|
492
|
+
elsif highlight.cover?(idx)
|
|
493
|
+
@style_highlight
|
|
494
|
+
elsif @zebra_blocks && @block_index_by_line[idx].to_i.odd?
|
|
495
|
+
@style_alt
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
addstr_with_attr(@left_sub, text, attr)
|
|
499
|
+
|
|
500
|
+
next unless @boundary_set.include?(idx)
|
|
501
|
+
|
|
502
|
+
start_col = [line.rstrip.length, 0].max
|
|
503
|
+
start_col = [[start_col, 0].max, @left_sub.maxx - 1].min
|
|
504
|
+
sep_width = @left_sub.maxx - start_col
|
|
505
|
+
next unless sep_width.positive?
|
|
506
|
+
|
|
507
|
+
@left_sub.setpos(i + 1, start_col)
|
|
508
|
+
pattern = "┄" * sep_width
|
|
509
|
+
addstr_with_attr(@left_sub, pattern[0, sep_width], @style_sep)
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
(visible.length...content_h).each do |i|
|
|
513
|
+
@left_sub.setpos(i + 1, 0)
|
|
514
|
+
@left_sub.addstr(" " * @left_sub.maxx)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
@left_sub.refresh
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Redraw right pane (help bar + markdown preview).
|
|
521
|
+
# @return [void]
|
|
522
|
+
def redraw_right
|
|
523
|
+
@right_sub.erase
|
|
524
|
+
|
|
525
|
+
@right_sub.setpos(0, 0)
|
|
526
|
+
help = right_help_text.ljust(@right_sub.maxx, " ")[0, @right_sub.maxx]
|
|
527
|
+
addstr_with_attr(@right_sub, help, @style_help)
|
|
528
|
+
|
|
529
|
+
content_h = right_content_rows
|
|
530
|
+
clamp_preview_offset
|
|
531
|
+
visible = @preview_lines[@preview_offset, content_h] || []
|
|
532
|
+
visible.each_with_index do |line, i|
|
|
533
|
+
@right_sub.setpos(i + 1, 0)
|
|
534
|
+
@right_sub.addstr(line.ljust(@right_sub.maxx, " ")[0, @right_sub.maxx])
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
(visible.length...content_h).each do |i|
|
|
538
|
+
@right_sub.setpos(i + 1, 0)
|
|
539
|
+
@right_sub.addstr(" " * @right_sub.maxx)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
@right_sub.refresh
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Show a transient message at the bottom of a window.
|
|
546
|
+
# @param [Curses::Window] win
|
|
547
|
+
# @param [String] msg
|
|
548
|
+
# @return [void]
|
|
549
|
+
def flash_message(win, msg)
|
|
550
|
+
return if win.maxy <= 0 || win.maxx <= 0
|
|
551
|
+
|
|
552
|
+
win.setpos(win.maxy - 1, 0)
|
|
553
|
+
txt = msg.ljust(win.maxx, " ")[0, win.maxx]
|
|
554
|
+
win.attron(Curses::A_BOLD)
|
|
555
|
+
win.addstr(txt)
|
|
556
|
+
win.attroff(Curses::A_BOLD)
|
|
557
|
+
win.refresh
|
|
558
|
+
sleep(0.6)
|
|
559
|
+
redraw
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# Safely fetch a Curses key constant.
|
|
563
|
+
# @param [Symbol] name
|
|
564
|
+
# @return [Integer, nil]
|
|
565
|
+
def key_const(name)
|
|
566
|
+
Curses::Key.const_get(name)
|
|
567
|
+
rescue NameError
|
|
568
|
+
nil
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# Normalize raw key codes into symbols we can switch on.
|
|
572
|
+
# @param [Object] ch raw key
|
|
573
|
+
# @return [Symbol] normalized key
|
|
574
|
+
def normalize_key(ch)
|
|
575
|
+
return :none if ch.nil?
|
|
576
|
+
|
|
577
|
+
return :tab if ch == "\t" || ch == 9 || (kc = key_const(:TAB)) && ch == kc
|
|
578
|
+
return :shift_tab if (kc = key_const(:BTAB)) && ch == kc
|
|
579
|
+
return :quit if ["q", 27].include?(ch)
|
|
580
|
+
|
|
581
|
+
enter_key = key_const(:ENTER)
|
|
582
|
+
return :enter if ch == "\r" || ch == "\n" || ch == 10 || ch == 13 || (enter_key && ch == enter_key)
|
|
583
|
+
|
|
584
|
+
return :up if ch == key_const(:UP) || ch == "k"
|
|
585
|
+
return :down if ch == key_const(:DOWN) || ch == "j"
|
|
586
|
+
return :page_up if ch == key_const(:PPAGE)
|
|
587
|
+
return :page_down if ch == key_const(:NPAGE)
|
|
588
|
+
|
|
589
|
+
return :toggle if ch == " "
|
|
590
|
+
return :fit if ch == "f"
|
|
591
|
+
return :refresh if ch == "r"
|
|
592
|
+
return :zebra if ch == "z"
|
|
593
|
+
return :g if ch == "g"
|
|
594
|
+
return :G if ch == "G"
|
|
595
|
+
return :lt if ch == "<"
|
|
596
|
+
return :gt if ch == ">"
|
|
597
|
+
|
|
598
|
+
:other
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# The main input loop (handles focus, navigation, selection, actions).
|
|
602
|
+
# @return [void]
|
|
603
|
+
def handle_keyboard_input
|
|
604
|
+
loop do
|
|
605
|
+
raw = (@focus == :left ? @left_sub.getch : @right_sub.getch)
|
|
606
|
+
key = normalize_key(raw)
|
|
607
|
+
|
|
608
|
+
case key
|
|
609
|
+
when :tab, :shift_tab
|
|
610
|
+
@focus = (@focus == :left ? :right : :left)
|
|
611
|
+
draw_focus
|
|
612
|
+
when :quit
|
|
613
|
+
@cancelled = true
|
|
614
|
+
break
|
|
615
|
+
when :enter
|
|
616
|
+
shas = selected_shas_from_idxs
|
|
617
|
+
if shas.size >= 2
|
|
618
|
+
@selected_shas = shas
|
|
619
|
+
break
|
|
620
|
+
else
|
|
621
|
+
flash_message(@left_sub, "Select at least 2 commits (space)")
|
|
622
|
+
end
|
|
623
|
+
when :lt
|
|
624
|
+
adjust_split(-4)
|
|
625
|
+
when :gt
|
|
626
|
+
adjust_split(+4)
|
|
627
|
+
when :up
|
|
628
|
+
if @focus == :left
|
|
629
|
+
if @selected_header_idx.positive?
|
|
630
|
+
@selected_header_idx -= 1
|
|
631
|
+
ensure_visible
|
|
632
|
+
redraw_left
|
|
633
|
+
update_preview
|
|
634
|
+
end
|
|
635
|
+
else
|
|
636
|
+
@preview_offset -= 1
|
|
637
|
+
redraw_right
|
|
638
|
+
end
|
|
639
|
+
when :down
|
|
640
|
+
if @focus == :left
|
|
641
|
+
if @selected_header_idx < @headers.length - 1
|
|
642
|
+
@selected_header_idx += 1
|
|
643
|
+
ensure_visible
|
|
644
|
+
redraw_left
|
|
645
|
+
update_preview
|
|
646
|
+
end
|
|
647
|
+
else
|
|
648
|
+
@preview_offset += 1
|
|
649
|
+
redraw_right
|
|
650
|
+
end
|
|
651
|
+
when :page_up
|
|
652
|
+
if @focus == :left
|
|
653
|
+
@selected_header_idx = [@selected_header_idx - 5, 0].max
|
|
654
|
+
ensure_visible
|
|
655
|
+
redraw_left
|
|
656
|
+
update_preview
|
|
657
|
+
else
|
|
658
|
+
@preview_offset -= right_content_rows
|
|
659
|
+
redraw_right
|
|
660
|
+
end
|
|
661
|
+
when :page_down
|
|
662
|
+
if @focus == :left
|
|
663
|
+
@selected_header_idx = [@selected_header_idx + 5, @headers.length - 1].min
|
|
664
|
+
ensure_visible
|
|
665
|
+
redraw_left
|
|
666
|
+
update_preview
|
|
667
|
+
else
|
|
668
|
+
@preview_offset += right_content_rows
|
|
669
|
+
redraw_right
|
|
670
|
+
end
|
|
671
|
+
when :g
|
|
672
|
+
if @focus == :right
|
|
673
|
+
@preview_offset = 0
|
|
674
|
+
redraw_right
|
|
675
|
+
end
|
|
676
|
+
when :G
|
|
677
|
+
if @focus == :right
|
|
678
|
+
@preview_offset = [@preview_lines.length - right_content_rows, 0].max
|
|
679
|
+
redraw_right
|
|
680
|
+
end
|
|
681
|
+
when :toggle
|
|
682
|
+
if @focus == :left
|
|
683
|
+
toggle_selection
|
|
684
|
+
redraw_left
|
|
685
|
+
update_preview(reset_offset: true)
|
|
686
|
+
end
|
|
687
|
+
when :fit
|
|
688
|
+
if @focus == :left
|
|
689
|
+
@fit_full_block = !@fit_full_block
|
|
690
|
+
ensure_visible
|
|
691
|
+
redraw_left
|
|
692
|
+
end
|
|
693
|
+
when :zebra
|
|
694
|
+
if @focus == :left
|
|
695
|
+
@zebra_blocks = !@zebra_blocks
|
|
696
|
+
redraw_left
|
|
697
|
+
end
|
|
698
|
+
when :refresh
|
|
699
|
+
refresh_graph
|
|
700
|
+
end
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
end
|