giterm 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/LICENSE +24 -0
- data/README.md +175 -0
- data/giterm +2423 -0
- metadata +95 -0
data/giterm
ADDED
@@ -0,0 +1,2423 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# SCRIPT INFO {{{1
|
5
|
+
# Name: GiTerm - Git & GitHub Terminal User Interface
|
6
|
+
# Language: Pure Ruby, following RTFM conventions
|
7
|
+
# Author: Geir Isene <g@isene.com> (adapted from RTFM)
|
8
|
+
# Github: https://github.com/isene/GiTerm
|
9
|
+
# License: Public domain
|
10
|
+
@version = '1.0.0'
|
11
|
+
|
12
|
+
# SAVE & STORE TERMINAL {{{1
|
13
|
+
ORIG_STTY = `stty -g 2>/dev/null`.chomp rescue ''
|
14
|
+
|
15
|
+
# Debug logging
|
16
|
+
LOG_FILE = '/tmp/giterm_debug.log'
|
17
|
+
def log_debug(msg)
|
18
|
+
File.open(LOG_FILE, 'a') do |f|
|
19
|
+
f.puts "#{Time.now.strftime('%H:%M:%S')} - #{msg}"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Mode-specific index management
|
24
|
+
def save_current_index
|
25
|
+
@mode_indices[@mode] = @index if @mode_indices.key?(@mode)
|
26
|
+
log_debug("Saved index #{@index} for mode #{@mode}")
|
27
|
+
end
|
28
|
+
|
29
|
+
def restore_mode_index(new_mode)
|
30
|
+
if @mode_indices.key?(new_mode)
|
31
|
+
@index = @mode_indices[new_mode]
|
32
|
+
log_debug("Restored index #{@index} for mode #{new_mode}")
|
33
|
+
else
|
34
|
+
@index = 0
|
35
|
+
log_debug("No saved index for mode #{new_mode}, using 0")
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Clear log at start
|
40
|
+
File.write(LOG_FILE, "=== GiTerm Debug Log ===\n")
|
41
|
+
log_debug('Starting GiTerm...')
|
42
|
+
|
43
|
+
at_exit do
|
44
|
+
log_debug('Exiting GiTerm...')
|
45
|
+
system("stty #{ORIG_STTY} 2>/dev/null") rescue nil unless ORIG_STTY.empty?
|
46
|
+
end
|
47
|
+
|
48
|
+
# ENCODING {{{1
|
49
|
+
# encoding: utf-8
|
50
|
+
|
51
|
+
# LOAD LIBRARIES {{{1
|
52
|
+
begin
|
53
|
+
require 'rcurses'
|
54
|
+
class Object
|
55
|
+
include Rcurses
|
56
|
+
include Rcurses::Input
|
57
|
+
end
|
58
|
+
rescue StandardError
|
59
|
+
puts 'GiTerm is built using rcurses. Install rcurses to run GiTerm.'
|
60
|
+
exit 1
|
61
|
+
end
|
62
|
+
|
63
|
+
require 'fileutils'
|
64
|
+
require 'shellwords'
|
65
|
+
require 'json'
|
66
|
+
require 'net/http'
|
67
|
+
require 'uri'
|
68
|
+
require 'base64'
|
69
|
+
require 'cgi'
|
70
|
+
|
71
|
+
# CREATE DIRS & SET FILE CONSTS {{{1
|
72
|
+
GITERM_HOME = File.join(Dir.home, '.giterm')
|
73
|
+
CONFIG_FILE = File.join(GITERM_HOME, 'conf')
|
74
|
+
KEYS_FILE = File.join(GITERM_HOME, 'keys.rb')
|
75
|
+
FileUtils.mkdir_p(GITERM_HOME)
|
76
|
+
|
77
|
+
# CONFIG FUNCTIONS {{{1
|
78
|
+
def load_config
|
79
|
+
# Ensure config directory exists
|
80
|
+
Dir.mkdir(GITERM_HOME) unless Dir.exist?(GITERM_HOME)
|
81
|
+
|
82
|
+
# Initialize defaults
|
83
|
+
@github_token = ENV['GITHUB_TOKEN'] || ''
|
84
|
+
@width_setting = 3
|
85
|
+
|
86
|
+
return unless File.exist?(CONFIG_FILE)
|
87
|
+
|
88
|
+
config = {}
|
89
|
+
File.readlines(CONFIG_FILE).each do |line|
|
90
|
+
next if line.strip.empty? || line.start_with?('#')
|
91
|
+
|
92
|
+
key, value = line.strip.split('=', 2)
|
93
|
+
config[key] = value if key && value
|
94
|
+
end
|
95
|
+
|
96
|
+
@github_token = config['github_token'] || @github_token
|
97
|
+
@width_setting = (config['width_setting'] || '3').to_i
|
98
|
+
@width_setting = 3 if @width_setting < 2 || @width_setting > 7
|
99
|
+
|
100
|
+
# Debug: log what we loaded
|
101
|
+
log_debug("Config loaded: token=#{@github_token.empty? ? 'empty' : @github_token[0..7]+'...'}, width=#{@width_setting}")
|
102
|
+
end
|
103
|
+
|
104
|
+
def save_config
|
105
|
+
config_content = <<~CONFIG
|
106
|
+
# GiTerm Configuration File
|
107
|
+
# Generated automatically - edit carefully
|
108
|
+
|
109
|
+
github_token=#{@github_token}
|
110
|
+
width_setting=#{@width_setting}
|
111
|
+
CONFIG
|
112
|
+
|
113
|
+
File.write(CONFIG_FILE, config_content)
|
114
|
+
end
|
115
|
+
|
116
|
+
def github_token_popup
|
117
|
+
# Show step-by-step guide first
|
118
|
+
show_token_guide
|
119
|
+
|
120
|
+
# Create temporary overlay pane for token input (nice blue color)
|
121
|
+
rows, cols = IO.console ? IO.console.winsize : [24, 80]
|
122
|
+
overlay_width = [cols - 4, 60].min
|
123
|
+
overlay_x = (cols - overlay_width) / 2
|
124
|
+
overlay_y = rows - 3 # Position above bottom pane
|
125
|
+
|
126
|
+
@token_overlay = Pane.new(overlay_x, overlay_y, overlay_width, 1, 255, 24) # White on blue
|
127
|
+
@token_overlay.border = false
|
128
|
+
@token_overlay.define_singleton_method(:update) { @update }
|
129
|
+
@token_overlay.define_singleton_method(:update=) { |val| @update = val }
|
130
|
+
@token_overlay.update = true
|
131
|
+
|
132
|
+
@token_overlay.say('Token input: visible and pastable (Ctrl+V, Shift+Insert). ESC to cancel.')
|
133
|
+
sleep 1
|
134
|
+
|
135
|
+
# Get token input using overlay pane
|
136
|
+
token = get_input_line_overlay
|
137
|
+
|
138
|
+
if token && !token.strip.empty?
|
139
|
+
@github_token = token.strip
|
140
|
+
save_config
|
141
|
+
@token_overlay.say('✓ GitHub token saved! Press any key to continue...')
|
142
|
+
else
|
143
|
+
@token_overlay.say('GitHub token setup cancelled. Press any key to continue...')
|
144
|
+
end
|
145
|
+
|
146
|
+
getchr # Wait for keypress
|
147
|
+
|
148
|
+
# Clear overlay by refreshing all panes
|
149
|
+
update_bottom_legends # Restore bottom pane
|
150
|
+
@p_left.update = true
|
151
|
+
@p_right.update = true
|
152
|
+
refresh_view
|
153
|
+
end
|
154
|
+
|
155
|
+
def open_github_token_page
|
156
|
+
@p_bottom.say('Opening GitHub token page in browser...')
|
157
|
+
if system('command -v xdg-open >/dev/null 2>&1')
|
158
|
+
system('xdg-open https://github.com/settings/tokens/new >/dev/null 2>&1 &')
|
159
|
+
elsif system('command -v open >/dev/null 2>&1') # macOS
|
160
|
+
system('open https://github.com/settings/tokens/new >/dev/null 2>&1 &')
|
161
|
+
else
|
162
|
+
@p_bottom.say('Cannot open browser. Go to: https://github.com/settings/tokens/new')
|
163
|
+
sleep 2
|
164
|
+
return
|
165
|
+
end
|
166
|
+
sleep 1
|
167
|
+
@p_bottom.say('Browser opened. Press T when ready to enter token.')
|
168
|
+
end
|
169
|
+
|
170
|
+
def show_token_guide
|
171
|
+
# Get terminal dimensions for centering
|
172
|
+
rows, cols = IO.console ? IO.console.winsize : [24, 80]
|
173
|
+
|
174
|
+
# Create large centered popup for detailed instructions
|
175
|
+
popup_width = [cols - 4, 80].min
|
176
|
+
popup_height = [rows - 4, 20].min
|
177
|
+
popup_x = (cols - popup_width) / 2
|
178
|
+
popup_y = (rows - popup_height) / 2
|
179
|
+
|
180
|
+
# Create popup pane with dark blue/gray background
|
181
|
+
popup = Pane.new(popup_x, popup_y, popup_width, popup_height, 255, 237) # Light gray text on dark gray
|
182
|
+
popup.border = true
|
183
|
+
|
184
|
+
# Add update method
|
185
|
+
popup.define_singleton_method(:update) { @update }
|
186
|
+
popup.define_singleton_method(:update=) { |val| @update = val }
|
187
|
+
popup.update = true
|
188
|
+
|
189
|
+
guide_content = <<~GUIDE
|
190
|
+
GitHub Personal Access Token Setup
|
191
|
+
==================================
|
192
|
+
|
193
|
+
STEP 1: Create a new token
|
194
|
+
• Go to: https://github.com/settings/tokens
|
195
|
+
* Click "Generate new token" → "Generate new token (classic)"
|
196
|
+
• Give it a name like "GiTerm"
|
197
|
+
|
198
|
+
STEP 2: Set permissions (scopes)
|
199
|
+
Required scopes for GiTerm:
|
200
|
+
✓ repo (to access private repositories)
|
201
|
+
✓ user:read (to get user info)
|
202
|
+
✓ user:email (to get email)
|
203
|
+
|
204
|
+
Optional scopes for full functionality:
|
205
|
+
✓ notifications (to view notifications)
|
206
|
+
✓ gist (to view gists)
|
207
|
+
|
208
|
+
STEP 3: Set expiration
|
209
|
+
• Choose "No expiration" or set a long period
|
210
|
+
• Click "Generate token"
|
211
|
+
|
212
|
+
STEP 4: Copy the token
|
213
|
+
• GitHub will show the token ONCE
|
214
|
+
• Copy it immediately (starts with ghp_)
|
215
|
+
• Paste it in the input field below
|
216
|
+
|
217
|
+
Navigation: ↑/↓ or j/k to scroll, ENTER to continue
|
218
|
+
GUIDE
|
219
|
+
|
220
|
+
popup.say(guide_content)
|
221
|
+
popup.refresh
|
222
|
+
|
223
|
+
# Handle scrolling in popup
|
224
|
+
handle_popup_scrolling(popup)
|
225
|
+
|
226
|
+
# Simply refresh underlying panes - popup will be drawn over and disappear
|
227
|
+
@p_left.update = true
|
228
|
+
@p_right.update = true
|
229
|
+
end
|
230
|
+
|
231
|
+
def handle_popup_scrolling(popup)
|
232
|
+
loop do
|
233
|
+
chr = getchr
|
234
|
+
case chr
|
235
|
+
when 'j', 'DOWN'
|
236
|
+
popup.linedown
|
237
|
+
when 'k', 'UP'
|
238
|
+
popup.lineup
|
239
|
+
when 'PgDOWN'
|
240
|
+
popup.pagedown
|
241
|
+
when 'PgUP'
|
242
|
+
popup.pageup
|
243
|
+
when 'ENTER', ' ', 'q', 'ESCAPE'
|
244
|
+
break
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
def get_input_line_overlay
|
250
|
+
# Use rcurses' built-in editline which supports Shift+Insert and other paste methods
|
251
|
+
original_text = ''
|
252
|
+
result = @token_overlay.ask('Enter GitHub token: ', original_text)
|
253
|
+
|
254
|
+
# If result equals original text, user pressed ESC; otherwise they pressed ENTER
|
255
|
+
if result == original_text
|
256
|
+
nil # ESC was pressed
|
257
|
+
else
|
258
|
+
result.empty? ? nil : result # ENTER was pressed
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
# GLOBAL VARIABLES {{{1
|
263
|
+
@status_items = []
|
264
|
+
@diff_lines = []
|
265
|
+
@log_entries = []
|
266
|
+
@branches = []
|
267
|
+
@remotes = []
|
268
|
+
@current_branch = ''
|
269
|
+
@index = 0
|
270
|
+
@max_index = 0
|
271
|
+
@min_index = 0
|
272
|
+
@mode = :status # :status, :diff, :log, :branches, :github_repos, :github_issues, :github_prs
|
273
|
+
|
274
|
+
# Mode-specific index memory for TAB switching
|
275
|
+
@mode_indices = {
|
276
|
+
status: 0,
|
277
|
+
log: 0,
|
278
|
+
branches: 0,
|
279
|
+
github_repos: 0,
|
280
|
+
github_issues: 0,
|
281
|
+
github_prs: 0,
|
282
|
+
github_search: 0
|
283
|
+
}
|
284
|
+
|
285
|
+
@github_token = ENV['GITHUB_TOKEN'] || ''
|
286
|
+
@github_user = ''
|
287
|
+
@github_repos = []
|
288
|
+
@github_issues = []
|
289
|
+
@github_prs = []
|
290
|
+
@selected_repo = ''
|
291
|
+
@width_setting = 3 # Width setting (2-7, like RTFM)
|
292
|
+
|
293
|
+
# HELP {{{1
|
294
|
+
@help = <<~HELPTEXT
|
295
|
+
GiTerm - Git & GitHub Terminal User Interface
|
296
|
+
|
297
|
+
BASIC KEYS
|
298
|
+
? = Show this help text
|
299
|
+
q = Quit GiTerm
|
300
|
+
r = Refresh current view
|
301
|
+
TAB = Switch between Git and GitHub mode
|
302
|
+
w = Change pane width ratio
|
303
|
+
|
304
|
+
NAVIGATION
|
305
|
+
j/DOWN = Move down in left pane
|
306
|
+
k/UP = Move up in left pane
|
307
|
+
J/S-DOWN = Scroll down one line in right pane
|
308
|
+
K/S-UP = Scroll up one line in right pane
|
309
|
+
h/LEFT = Go back/up one level
|
310
|
+
l/RIGHT = Enter/view details
|
311
|
+
g/HOME = Go to top
|
312
|
+
G/END = Go to bottom
|
313
|
+
PgDown = Page down in right pane
|
314
|
+
PgUp = Page up in right pane
|
315
|
+
S-RIGHT = Page down in right pane
|
316
|
+
S-LEFT = Page up in right pane
|
317
|
+
|
318
|
+
GIT MODE
|
319
|
+
s = Show git status
|
320
|
+
d = Show git diff
|
321
|
+
D = Show staged diff
|
322
|
+
a = Stage file (in status view)
|
323
|
+
u = Unstage file (in status view)
|
324
|
+
c = Commit staged changes
|
325
|
+
l = Show git log
|
326
|
+
b = Show branches
|
327
|
+
B = Create new branch
|
328
|
+
m = Merge branch
|
329
|
+
p = Pull from remote
|
330
|
+
P = Push to remote
|
331
|
+
|
332
|
+
GITHUB MODE
|
333
|
+
T = Setup GitHub token (guided)
|
334
|
+
O = Open GitHub token page in browser
|
335
|
+
/ = Search repositories
|
336
|
+
i = Show issues
|
337
|
+
I = Create new issue
|
338
|
+
p = Show pull requests
|
339
|
+
P = Create new pull request
|
340
|
+
w = Open in web browser
|
341
|
+
|
342
|
+
INTERACTIVE COMMANDS
|
343
|
+
: = Enter command mode (try :cd <path> to change git repos)
|
344
|
+
! = Run shell command
|
345
|
+
HELPTEXT
|
346
|
+
|
347
|
+
# INITIALIZATION {{{1
|
348
|
+
def init_windows
|
349
|
+
log_debug('Starting init_windows')
|
350
|
+
|
351
|
+
# Get terminal size directly (like astropanel)
|
352
|
+
log_debug('Getting terminal dimensions...')
|
353
|
+
if IO.console
|
354
|
+
rows, cols = IO.console.winsize
|
355
|
+
log_debug("Terminal size: #{rows}x#{cols}")
|
356
|
+
else
|
357
|
+
log_debug('No IO.console, using fallback')
|
358
|
+
rows = 24
|
359
|
+
cols = 80
|
360
|
+
end
|
361
|
+
|
362
|
+
# Clear screen and hide cursor (like astropanel)
|
363
|
+
log_debug('Clearing screen...')
|
364
|
+
Rcurses.clear_screen
|
365
|
+
Cursor.hide
|
366
|
+
|
367
|
+
# Calculate pane dimensions (following RTFM pattern)
|
368
|
+
top_height = 1 # Single row for top info bar
|
369
|
+
bottom_height = 1
|
370
|
+
main_height = rows - 4 # Account for top (1) + gap (1) + bottom (1) + gap (1)
|
371
|
+
# Calculate left width based on width setting (like RTFM)
|
372
|
+
left_ratio = [0.2, 0.3, 0.4, 0.5, 0.6, 0.7][@width_setting - 2] || 0.4
|
373
|
+
left_width = ((cols - 4) * left_ratio).to_i
|
374
|
+
|
375
|
+
# Create panes (x, y, width, height, fg, bg) with astropanel-inspired colors
|
376
|
+
bottom_y = rows - bottom_height + 1 # Position bottom pane at actual bottom
|
377
|
+
log_debug("Pane layout: top(1,1,#{cols},#{top_height}) left(2,3,#{left_width},#{main_height}) right(#{left_width + 4},3,#{cols - left_width - 4},#{main_height}) bottom(1,#{bottom_y},#{cols},#{bottom_height})")
|
378
|
+
@p_top = Pane.new(1, 1, cols, top_height, 255, 236) # White on dark gray
|
379
|
+
log_debug('Top pane created')
|
380
|
+
@p_left = Pane.new(2, 3, left_width, main_height, 255, 232) # White on light gray
|
381
|
+
log_debug('Left pane created')
|
382
|
+
@p_right = Pane.new(left_width + 4, 3, cols - left_width - 4, main_height, 255, 232) # White on light gray
|
383
|
+
log_debug('Right pane created')
|
384
|
+
@p_bottom = Pane.new(1, bottom_y, cols, bottom_height, 255, 24) # White on dark blue
|
385
|
+
log_debug('Bottom pane created')
|
386
|
+
|
387
|
+
# Set borders (only on main content panes, not top/bottom)
|
388
|
+
log_debug('Setting borders...')
|
389
|
+
@p_top.border = false # No border, just background color
|
390
|
+
@p_left.border = true # Main content pane with border
|
391
|
+
@p_right.border = true # Main content pane with border
|
392
|
+
@p_bottom.border = false # No border, just background color
|
393
|
+
|
394
|
+
# Enable scrolling indicators
|
395
|
+
@p_left.scroll = true # Show scroll indicators (triangles)
|
396
|
+
@p_right.scroll = true
|
397
|
+
|
398
|
+
# Initialize scroll positions
|
399
|
+
@p_left.ix = 0
|
400
|
+
@p_right.ix = 0
|
401
|
+
|
402
|
+
# Add update attributes (like astropanel)
|
403
|
+
log_debug('Adding update attributes...')
|
404
|
+
[@p_top, @p_left, @p_right, @p_bottom].each do |pane|
|
405
|
+
pane.define_singleton_method(:update) { @update }
|
406
|
+
pane.define_singleton_method(:update=) { |val| @update = val }
|
407
|
+
pane.update = true
|
408
|
+
end
|
409
|
+
|
410
|
+
# Initial content
|
411
|
+
log_debug('Updating repo info...')
|
412
|
+
update_repo_info
|
413
|
+
update_bottom_legends
|
414
|
+
log_debug('init_windows completed')
|
415
|
+
end
|
416
|
+
|
417
|
+
def update_bottom_legends
|
418
|
+
# Show key legends in bottom pane
|
419
|
+
legends = 'j/k:Nav PgUp/PgDn:Page g/G:Top/End J/K:Scroll d:Diff l:Log b:Branches TAB:GitHub q:Quit'
|
420
|
+
@p_bottom.say(legends)
|
421
|
+
end
|
422
|
+
|
423
|
+
def update_repo_info
|
424
|
+
branch = `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
|
425
|
+
remote = `git config --get remote.origin.url 2>/dev/null`.strip
|
426
|
+
|
427
|
+
if branch.empty?
|
428
|
+
@p_top.say('Not a git repository'.fg(196))
|
429
|
+
else
|
430
|
+
repo_name = remote.empty? ? 'Local repository' : File.basename(remote, '.git')
|
431
|
+
@current_branch = branch
|
432
|
+
@current_repo = repo_name
|
433
|
+
update_mode_info(@current_mode_title || 'Git Status')
|
434
|
+
end
|
435
|
+
end
|
436
|
+
|
437
|
+
def update_mode_info(mode_title)
|
438
|
+
@current_mode_title = mode_title
|
439
|
+
if @current_branch
|
440
|
+
info = "[#{mode_title}]".fg(156) + " | " + @current_repo.fg(249) + " | " + @current_branch.fg(154)
|
441
|
+
else
|
442
|
+
info = "[#{mode_title}]".fg(156)
|
443
|
+
end
|
444
|
+
@p_top.say(info)
|
445
|
+
end
|
446
|
+
|
447
|
+
# GIT FUNCTIONS {{{1
|
448
|
+
def git_status
|
449
|
+
@mode = :status
|
450
|
+
@status_items = []
|
451
|
+
@p_left.ix = 0 # Reset scroll position
|
452
|
+
|
453
|
+
# Restore saved index for this mode
|
454
|
+
restore_mode_index(:status)
|
455
|
+
|
456
|
+
output = `git status --porcelain 2>/dev/null`.lines
|
457
|
+
output.each do |line|
|
458
|
+
status = line[0..1]
|
459
|
+
file = line[3..].strip
|
460
|
+
@status_items << { status: status, file: file }
|
461
|
+
end
|
462
|
+
|
463
|
+
@max_index = @status_items.length - 1
|
464
|
+
@min_index = 0
|
465
|
+
|
466
|
+
# Ensure restored index is within bounds
|
467
|
+
@index = [@index, @max_index].min if @max_index >= 0
|
468
|
+
@index = [@index, @min_index].max
|
469
|
+
|
470
|
+
display_status
|
471
|
+
end
|
472
|
+
|
473
|
+
def display_status
|
474
|
+
# Update top pane with mode
|
475
|
+
update_mode_info('Git Status')
|
476
|
+
|
477
|
+
# Always rebuild content to show current selection
|
478
|
+
content = ""
|
479
|
+
|
480
|
+
if @status_items.empty?
|
481
|
+
content += 'Working tree clean'.fg(154)
|
482
|
+
else
|
483
|
+
@status_items.each_with_index do |item, i|
|
484
|
+
marker = i == @index ? '→ ' : ' '
|
485
|
+
color = case item[:status]
|
486
|
+
when 'M ' then 220 # Modified (staged)
|
487
|
+
when ' M' then 214 # Modified (not staged)
|
488
|
+
when 'MM' then 202 # Modified (staged and unstaged)
|
489
|
+
when 'A ' then 154 # Added (new file staged)
|
490
|
+
when 'D ' then 196 # Deleted
|
491
|
+
when ' D' then 196 # Deleted (not staged)
|
492
|
+
when '??' then 245 # Untracked
|
493
|
+
when 'R ' then 51 # Renamed
|
494
|
+
when 'C ' then 51 # Copied
|
495
|
+
else 255
|
496
|
+
end
|
497
|
+
|
498
|
+
# Add helpful status descriptions
|
499
|
+
status_desc = case item[:status]
|
500
|
+
when 'M ' then '(staged)'
|
501
|
+
when ' M' then '(modified)'
|
502
|
+
when 'MM' then '(staged+modified)'
|
503
|
+
when 'A ' then '(new file)'
|
504
|
+
when 'D ' then '(deleted)'
|
505
|
+
when ' D' then '(deleted)'
|
506
|
+
when '??' then '(untracked)'
|
507
|
+
when 'R ' then '(renamed)'
|
508
|
+
when 'C ' then '(copied)'
|
509
|
+
else ''
|
510
|
+
end
|
511
|
+
|
512
|
+
content += marker + item[:status].fg(color) + ' ' + item[:file] + ' ' + status_desc.fg(242) + "\n"
|
513
|
+
end
|
514
|
+
end
|
515
|
+
|
516
|
+
# Preserve scroll position when updating content
|
517
|
+
saved_ix = @p_left.ix
|
518
|
+
@p_left.clear
|
519
|
+
@p_left.say(content)
|
520
|
+
@p_left.ix = saved_ix
|
521
|
+
|
522
|
+
# Show diff in right pane if file selected
|
523
|
+
show_file_diff(@status_items[@index][:file]) if @index >= 0 && @index < @status_items.length
|
524
|
+
end
|
525
|
+
|
526
|
+
def show_file_diff(file)
|
527
|
+
diff = `git diff #{Shellwords.escape(file)} 2>/dev/null`
|
528
|
+
diff = `git diff --cached #{Shellwords.escape(file)} 2>/dev/null` if diff.empty?
|
529
|
+
|
530
|
+
@p_right.clear
|
531
|
+
if diff.empty?
|
532
|
+
@p_right.say('No changes to display'.fg(245))
|
533
|
+
else
|
534
|
+
content = ''
|
535
|
+
diff.lines.each do |line|
|
536
|
+
content += case line[0]
|
537
|
+
when '+'
|
538
|
+
line.fg(154)
|
539
|
+
when '-'
|
540
|
+
line.fg(196)
|
541
|
+
when '@'
|
542
|
+
line.fg(51)
|
543
|
+
else
|
544
|
+
line
|
545
|
+
end
|
546
|
+
end
|
547
|
+
@p_right.say(content)
|
548
|
+
end
|
549
|
+
end
|
550
|
+
|
551
|
+
def git_log
|
552
|
+
@mode = :log
|
553
|
+
@log_entries = []
|
554
|
+
@p_left.ix = 0 # Reset scroll position
|
555
|
+
|
556
|
+
log = `git log --oneline -50 2>/dev/null`.lines
|
557
|
+
log.each do |line|
|
558
|
+
@log_entries << { hash: $1, message: $2 } if line =~ /^(\w+)\s+(.*)$/
|
559
|
+
end
|
560
|
+
|
561
|
+
@max_index = @log_entries.length - 1
|
562
|
+
@min_index = 0
|
563
|
+
@index = 0 if @index > @max_index
|
564
|
+
|
565
|
+
display_log
|
566
|
+
end
|
567
|
+
|
568
|
+
def display_log
|
569
|
+
# Update top pane with mode
|
570
|
+
update_mode_info('Git Log')
|
571
|
+
|
572
|
+
content = ""
|
573
|
+
|
574
|
+
@log_entries.each_with_index do |entry, i|
|
575
|
+
marker = i == @index ? '→ ' : ' '
|
576
|
+
content += marker + entry[:hash].fg(220) + ' ' + entry[:message] + "\n"
|
577
|
+
end
|
578
|
+
|
579
|
+
# Preserve scroll position when updating content
|
580
|
+
saved_ix = @p_left.ix
|
581
|
+
@p_left.clear
|
582
|
+
@p_left.say(content)
|
583
|
+
@p_left.ix = saved_ix
|
584
|
+
|
585
|
+
# Show commit details in right pane
|
586
|
+
show_commit_details(@log_entries[@index][:hash]) if @index >= 0 && @index < @log_entries.length
|
587
|
+
end
|
588
|
+
|
589
|
+
def show_commit_details(hash)
|
590
|
+
details = `git show --stat #{hash} 2>/dev/null`
|
591
|
+
|
592
|
+
@p_right.clear
|
593
|
+
@p_right.say(details)
|
594
|
+
end
|
595
|
+
|
596
|
+
def git_branches
|
597
|
+
@mode = :branches
|
598
|
+
@branches = []
|
599
|
+
@p_left.ix = 0 # Reset scroll position
|
600
|
+
|
601
|
+
branches = `git branch -a 2>/dev/null`.lines
|
602
|
+
branches.each do |branch|
|
603
|
+
current = branch.start_with?('*')
|
604
|
+
name = branch.strip.sub(/^\*\s*/, '')
|
605
|
+
@branches << { name: name, current: current }
|
606
|
+
end
|
607
|
+
|
608
|
+
@max_index = @branches.length - 1
|
609
|
+
@min_index = 0
|
610
|
+
@index = 0 if @index > @max_index
|
611
|
+
|
612
|
+
display_branches
|
613
|
+
end
|
614
|
+
|
615
|
+
def display_branches
|
616
|
+
# Update top pane with mode
|
617
|
+
update_mode_info('Git Branches')
|
618
|
+
|
619
|
+
content = ""
|
620
|
+
|
621
|
+
@branches.each_with_index do |branch, i|
|
622
|
+
marker = i == @index ? '→ ' : ' '
|
623
|
+
content += if branch[:current]
|
624
|
+
marker + '* ' + branch[:name].fg(154) + "\n"
|
625
|
+
else
|
626
|
+
marker + ' ' + branch[:name] + "\n"
|
627
|
+
end
|
628
|
+
end
|
629
|
+
|
630
|
+
# Preserve scroll position when updating content
|
631
|
+
saved_ix = @p_left.ix
|
632
|
+
@p_left.clear
|
633
|
+
@p_left.say(content)
|
634
|
+
@p_left.ix = saved_ix
|
635
|
+
end
|
636
|
+
|
637
|
+
def git_pull
|
638
|
+
@p_bottom.say('Pulling from remote...')
|
639
|
+
result = `git pull 2>&1`
|
640
|
+
@p_bottom.say(result.lines.first.strip)
|
641
|
+
git_status # Refresh status after pull
|
642
|
+
end
|
643
|
+
|
644
|
+
def git_push
|
645
|
+
@p_bottom.say('Pushing to remote...')
|
646
|
+
result = `git push 2>&1`
|
647
|
+
@p_bottom.say(result.lines.first.strip)
|
648
|
+
end
|
649
|
+
|
650
|
+
# GITHUB FUNCTIONS {{{1
|
651
|
+
def verify_github_token
|
652
|
+
# First check if we have a token loaded
|
653
|
+
if @github_token.empty?
|
654
|
+
@p_bottom.say('No GitHub token found in memory. Press T to set up token.')
|
655
|
+
return false
|
656
|
+
end
|
657
|
+
|
658
|
+
@p_bottom.say("Verifying GitHub token (#{@github_token[0..7]}...)...")
|
659
|
+
result = github_request('/user')
|
660
|
+
|
661
|
+
if result.is_a?(Hash) && result[:error]
|
662
|
+
@p_bottom.say("Token verification failed: #{result[:error]}")
|
663
|
+
return false
|
664
|
+
elsif result.is_a?(Hash) && result['login']
|
665
|
+
@p_bottom.say("✓ Token valid for user: #{result['login']}")
|
666
|
+
return true
|
667
|
+
else
|
668
|
+
@p_bottom.say('Token verification returned unexpected format')
|
669
|
+
return false
|
670
|
+
end
|
671
|
+
end
|
672
|
+
|
673
|
+
def github_request(endpoint)
|
674
|
+
return { error: 'No GitHub token found. Set GITHUB_TOKEN environment variable.' } if @github_token.empty?
|
675
|
+
|
676
|
+
# Always prefer curl when available due to chunked encoding issues with Net::HTTP
|
677
|
+
if system('command -v curl >/dev/null 2>&1')
|
678
|
+
result = github_request_curl(endpoint)
|
679
|
+
# Only fall back to Net::HTTP for non-auth errors and non-chunked encoding errors
|
680
|
+
if result.is_a?(Hash) && result[:error] && !result[:error].include?('JSON')
|
681
|
+
log_debug("curl failed: #{result[:error]}")
|
682
|
+
# Don't fallback for now - curl is more reliable
|
683
|
+
end
|
684
|
+
return result
|
685
|
+
end
|
686
|
+
|
687
|
+
# If curl is not available, use Net::HTTP
|
688
|
+
github_request_http(endpoint)
|
689
|
+
end
|
690
|
+
|
691
|
+
def github_request_http(endpoint)
|
692
|
+
begin
|
693
|
+
uri = URI.parse("https://api.github.com#{endpoint}")
|
694
|
+
|
695
|
+
# More robust HTTPS setup
|
696
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
697
|
+
http.use_ssl = true
|
698
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
699
|
+
http.read_timeout = 15 # Increased timeout
|
700
|
+
http.open_timeout = 10 # Increased connection timeout
|
701
|
+
http.keep_alive_timeout = 2
|
702
|
+
|
703
|
+
# Ensure we start fresh connection
|
704
|
+
http.start do |connection|
|
705
|
+
request = Net::HTTP::Get.new(uri)
|
706
|
+
request['Authorization'] = "token #{@github_token}"
|
707
|
+
request['Accept'] = 'application/vnd.github.v3+json'
|
708
|
+
request['User-Agent'] = 'GiTerm/1.0'
|
709
|
+
request['Connection'] = 'close' # Force connection close to avoid keep-alive issues
|
710
|
+
|
711
|
+
response = connection.request(request)
|
712
|
+
|
713
|
+
if response.code == '200'
|
714
|
+
# Handle chunked encoding issues by reading body carefully
|
715
|
+
body = response.body
|
716
|
+
return { error: 'Empty response body' } if body.nil? || body.empty?
|
717
|
+
|
718
|
+
JSON.parse(body)
|
719
|
+
else
|
720
|
+
{ error: "GitHub API error: #{response.code} - #{response.message}" }
|
721
|
+
end
|
722
|
+
end
|
723
|
+
rescue JSON::ParserError => e
|
724
|
+
{ error: "Invalid JSON response: #{e.message}" }
|
725
|
+
rescue Net::ReadTimeout, Net::OpenTimeout => e
|
726
|
+
{ error: "Request timeout: #{e.message}" }
|
727
|
+
rescue EOFError
|
728
|
+
{ error: 'Connection terminated unexpectedly. Try again.' }
|
729
|
+
rescue OpenSSL::SSL::SSLError => e
|
730
|
+
{ error: "SSL connection error: #{e.message}" }
|
731
|
+
rescue Net::HTTPBadResponse, Net::ProtocolError => e
|
732
|
+
{ error: "HTTP protocol error (chunked encoding issue): #{e.message}" }
|
733
|
+
rescue => e
|
734
|
+
{ error: "Network error: #{e.message}" }
|
735
|
+
end
|
736
|
+
end
|
737
|
+
|
738
|
+
def github_request_curl(endpoint)
|
739
|
+
begin
|
740
|
+
# Build curl command as a string to ensure proper formatting
|
741
|
+
url = "https://api.github.com#{endpoint}"
|
742
|
+
curl_cmd = "curl -s -f --max-time 10 " \
|
743
|
+
"-H 'Authorization: token #{@github_token}' " \
|
744
|
+
"-H 'Accept: application/vnd.github.v3+json' " \
|
745
|
+
"-H 'User-Agent: GiTerm/1.0' " \
|
746
|
+
"'#{url}'"
|
747
|
+
|
748
|
+
# Debug: log the curl command (without showing full token)
|
749
|
+
debug_cmd = curl_cmd.sub(@github_token, "#{@github_token[0..10]}...")
|
750
|
+
log_debug("Executing curl: #{debug_cmd}")
|
751
|
+
|
752
|
+
# Capture both stdout and stderr for better debugging
|
753
|
+
result = `#{curl_cmd} 2>&1`
|
754
|
+
exit_code = $?.exitstatus
|
755
|
+
|
756
|
+
if exit_code == 0 && !result.empty?
|
757
|
+
JSON.parse(result)
|
758
|
+
else
|
759
|
+
error_msg = case exit_code
|
760
|
+
when 22 then 'HTTP error (likely 401/403 - check token)'
|
761
|
+
when 6 then 'Could not resolve host (network issue)'
|
762
|
+
when 7 then 'Failed to connect (network issue)'
|
763
|
+
when 28 then 'Operation timeout'
|
764
|
+
else "curl failed (exit code: #{exit_code})"
|
765
|
+
end
|
766
|
+
{ error: "#{error_msg}. Response: #{result[0..100]}" }
|
767
|
+
end
|
768
|
+
rescue JSON::ParserError => e
|
769
|
+
{ error: "Invalid JSON from curl: #{e.message}" }
|
770
|
+
rescue => e
|
771
|
+
{ error: "curl error: #{e.message}" }
|
772
|
+
end
|
773
|
+
end
|
774
|
+
|
775
|
+
def github_repos
|
776
|
+
@mode = :github_repos
|
777
|
+
@github_repos = []
|
778
|
+
|
779
|
+
# Restore saved index for this mode
|
780
|
+
restore_mode_index(:github_repos)
|
781
|
+
|
782
|
+
# Show loading message
|
783
|
+
@p_bottom.say('Loading GitHub repositories...')
|
784
|
+
|
785
|
+
if @github_token.empty?
|
786
|
+
content = "GitHub Integration Setup\n".b.fg(156)
|
787
|
+
content += ('=' * 35) + "\n\n"
|
788
|
+
content += "[!] No GitHub token found\n\n".fg(196)
|
789
|
+
|
790
|
+
content += "To access GitHub features, you need a\n".fg(249)
|
791
|
+
content += "Personal Access Token.\n\n".fg(249)
|
792
|
+
|
793
|
+
content += "QUICK SETUP:\n".fg(154)
|
794
|
+
content += '1. Press '.fg(249) + "'T'".fg(51) + " for guided setup\n".fg(249)
|
795
|
+
content += "2. Follow the step-by-step instructions\n".fg(249)
|
796
|
+
content += "3. Create token with 'repo' permissions\n".fg(249)
|
797
|
+
content += "4. Paste token and you're done!\n\n".fg(249)
|
798
|
+
|
799
|
+
content += "ALTERNATIVE:\n".fg(214)
|
800
|
+
content += "Set GITHUB_TOKEN environment variable\n".fg(249)
|
801
|
+
content += "export GITHUB_TOKEN=ghp_your_token\n\n".fg(242)
|
802
|
+
|
803
|
+
content += "Why do I need this?\n".fg(220)
|
804
|
+
content += "• View your repositories\n".fg(249)
|
805
|
+
content += "• Browse issues and pull requests\n".fg(249)
|
806
|
+
content += "• Access private repos you own\n".fg(249)
|
807
|
+
content += "• Get more API requests (5000/hr vs 60/hr)\n".fg(249)
|
808
|
+
|
809
|
+
@p_left.say(content)
|
810
|
+
return
|
811
|
+
end
|
812
|
+
|
813
|
+
result = github_request('/user/repos?per_page=100')
|
814
|
+
|
815
|
+
if result.is_a?(Hash) && result[:error]
|
816
|
+
@p_left.say("GitHub Error: #{result[:error]}".fg(196))
|
817
|
+
@p_bottom.say('Failed to load repositories. Press any key to continue...')
|
818
|
+
return
|
819
|
+
end
|
820
|
+
|
821
|
+
# Ensure result is an array
|
822
|
+
unless result.is_a?(Array)
|
823
|
+
@p_left.say('Invalid response format from GitHub API'.fg(196))
|
824
|
+
@p_bottom.say('GitHub API returned unexpected data format')
|
825
|
+
return
|
826
|
+
end
|
827
|
+
|
828
|
+
result.each do |repo|
|
829
|
+
@github_repos << {
|
830
|
+
name: repo['name'],
|
831
|
+
full_name: repo['full_name'],
|
832
|
+
description: repo['description'],
|
833
|
+
private: repo['private'],
|
834
|
+
language: repo['language'],
|
835
|
+
stargazers_count: repo['stargazers_count'],
|
836
|
+
forks_count: repo['forks_count'],
|
837
|
+
open_issues_count: repo['open_issues_count'],
|
838
|
+
updated_at: repo['updated_at'],
|
839
|
+
clone_url: repo['clone_url']
|
840
|
+
}
|
841
|
+
end
|
842
|
+
|
843
|
+
@max_index = @github_repos.length - 1
|
844
|
+
@min_index = 0
|
845
|
+
|
846
|
+
# Ensure restored index is within bounds
|
847
|
+
@index = [@index, @max_index].min if @max_index >= 0
|
848
|
+
@index = [@index, @min_index].max
|
849
|
+
|
850
|
+
# Update bottom pane to show legends instead of loading message
|
851
|
+
update_bottom_legends
|
852
|
+
|
853
|
+
# Mark panes for update using RTFM pattern
|
854
|
+
@p_left.update = true
|
855
|
+
@p_right.update = true
|
856
|
+
|
857
|
+
# Display GitHub repos and update right pane with first repository
|
858
|
+
display_github_repos
|
859
|
+
update_right_pane(false) # false = not fast = immediate extended fetch for mode switch
|
860
|
+
end
|
861
|
+
|
862
|
+
def display_github_repos
|
863
|
+
# Update top pane with mode
|
864
|
+
update_mode_info('GitHub Repositories')
|
865
|
+
|
866
|
+
content = ""
|
867
|
+
|
868
|
+
@github_repos.each_with_index do |repo, i|
|
869
|
+
marker = i == @index ? '→ ' : ' '
|
870
|
+
privacy = repo[:private] ? '[P]'.fg(196) : '[O]'.fg(154)
|
871
|
+
|
872
|
+
# Color code by organization/owner
|
873
|
+
full_name = repo[:full_name] || 'unknown/unknown'
|
874
|
+
owner, name = full_name.split('/', 2)
|
875
|
+
owner_color = get_owner_color(owner)
|
876
|
+
|
877
|
+
content += marker + privacy + ' ' + owner.fg(owner_color) + '/'.fg(245) + name.fg(255) + "\n"
|
878
|
+
end
|
879
|
+
|
880
|
+
# Preserve scroll position when updating content
|
881
|
+
saved_ix = @p_left.ix
|
882
|
+
@p_left.clear
|
883
|
+
@p_left.say(content)
|
884
|
+
@p_left.ix = saved_ix
|
885
|
+
|
886
|
+
# Right pane will be updated separately by the calling code
|
887
|
+
end
|
888
|
+
|
889
|
+
def show_repo_details(repo, immediate_extended_fetch = false)
|
890
|
+
return unless repo
|
891
|
+
|
892
|
+
log_debug("Showing repo details for: #{repo[:full_name] || 'nil'} (immediate: #{immediate_extended_fetch})")
|
893
|
+
|
894
|
+
# Always show basic info immediately
|
895
|
+
display_basic_repo_info(repo)
|
896
|
+
|
897
|
+
if immediate_extended_fetch
|
898
|
+
# Fetch extended content right now (for initial display or full refresh)
|
899
|
+
log_debug("Fetching extended content immediately for: #{repo[:full_name]}")
|
900
|
+
fetch_and_display_extended_content(repo)
|
901
|
+
else
|
902
|
+
# Schedule for delayed fetch (for fast navigation)
|
903
|
+
log_debug("Scheduling extended fetch for: #{repo[:full_name]}")
|
904
|
+
schedule_extended_fetch(repo)
|
905
|
+
end
|
906
|
+
end
|
907
|
+
|
908
|
+
def display_basic_repo_info(repo)
|
909
|
+
content = (repo[:full_name] || 'Unknown repository').b.fg(156) + "\n"
|
910
|
+
content += ('=' * 60) + "\n\n"
|
911
|
+
|
912
|
+
# Basic repo info with nil protection (always shown immediately)
|
913
|
+
content += 'Description: '.fg(249) + (repo[:description] || 'No description') + "\n"
|
914
|
+
content += 'Private: '.fg(249) + (repo[:private] ? 'Yes' : 'No') + "\n"
|
915
|
+
content += 'Language: '.fg(249) + (repo[:language] || 'Not specified') + "\n"
|
916
|
+
content += 'Stars: '.fg(249) + (repo[:stargazers_count] || 0).to_s + " "
|
917
|
+
content += 'Forks: '.fg(249) + (repo[:forks_count] || 0).to_s + " "
|
918
|
+
content += 'Issues: '.fg(249) + (repo[:open_issues_count] || 0).to_s + "\n"
|
919
|
+
content += 'Updated: '.fg(249) + (repo[:updated_at] || 'Unknown') + "\n"
|
920
|
+
content += 'Clone URL: '.fg(249) + (repo[:clone_url] || 'Not available') + "\n\n"
|
921
|
+
|
922
|
+
@p_right.clear
|
923
|
+
@p_right.say(content)
|
924
|
+
end
|
925
|
+
|
926
|
+
def schedule_extended_fetch(repo)
|
927
|
+
# Store the repo for delayed fetching - will be handled in main loop
|
928
|
+
log_debug("Scheduling extended fetch for: #{repo[:full_name]}")
|
929
|
+
@pending_extended_fetch = repo
|
930
|
+
log_debug("@pending_extended_fetch now set to: #{@pending_extended_fetch[:full_name]}")
|
931
|
+
end
|
932
|
+
|
933
|
+
def fetch_and_display_extended_content(repo)
|
934
|
+
return unless repo[:full_name]
|
935
|
+
|
936
|
+
log_debug("EXECUTING fetch_and_display_extended_content for: #{repo[:full_name]}")
|
937
|
+
|
938
|
+
# Build the basic content again
|
939
|
+
content = (repo[:full_name] || 'Unknown repository').b.fg(156) + "\n"
|
940
|
+
content += ('=' * 60) + "\n\n"
|
941
|
+
|
942
|
+
content += 'Description: '.fg(249) + (repo[:description] || 'No description') + "\n"
|
943
|
+
content += 'Private: '.fg(249) + (repo[:private] ? 'Yes' : 'No') + "\n"
|
944
|
+
content += 'Language: '.fg(249) + (repo[:language] || 'Not specified') + "\n"
|
945
|
+
content += 'Stars: '.fg(249) + (repo[:stargazers_count] || 0).to_s + " "
|
946
|
+
content += 'Forks: '.fg(249) + (repo[:forks_count] || 0).to_s + " "
|
947
|
+
content += 'Issues: '.fg(249) + (repo[:open_issues_count] || 0).to_s + "\n"
|
948
|
+
content += 'Updated: '.fg(249) + (repo[:updated_at] || 'Unknown') + "\n"
|
949
|
+
content += 'Clone URL: '.fg(249) + (repo[:clone_url] || 'Not available') + "\n\n"
|
950
|
+
|
951
|
+
# Fetch README
|
952
|
+
readme_content = fetch_readme(repo[:full_name])
|
953
|
+
if readme_content
|
954
|
+
content += "README.md".b.fg(154) + "\n"
|
955
|
+
content += ('-' * 20) + "\n"
|
956
|
+
content += readme_content + "\n\n"
|
957
|
+
end
|
958
|
+
|
959
|
+
# Fetch directory structure
|
960
|
+
files_content = fetch_repo_files(repo[:full_name])
|
961
|
+
if files_content
|
962
|
+
content += "Repository Files".b.fg(154) + "\n"
|
963
|
+
content += ('-' * 20) + "\n"
|
964
|
+
content += files_content
|
965
|
+
end
|
966
|
+
|
967
|
+
# Update the pane (now safe - no threads)
|
968
|
+
@p_right.clear
|
969
|
+
@p_right.say(content)
|
970
|
+
rescue => e
|
971
|
+
log_debug("Error in extended fetch: #{e.message}")
|
972
|
+
end
|
973
|
+
|
974
|
+
def fetch_readme(repo_full_name)
|
975
|
+
return nil unless repo_full_name
|
976
|
+
|
977
|
+
# Try different README variations
|
978
|
+
readme_files = ['README.md', 'readme.md', 'README.txt', 'README', 'readme.txt']
|
979
|
+
|
980
|
+
readme_files.each do |filename|
|
981
|
+
begin
|
982
|
+
result = github_request("/repos/#{repo_full_name}/contents/#{filename}")
|
983
|
+
next if result.is_a?(Hash) && result[:error]
|
984
|
+
|
985
|
+
# Decode base64 content
|
986
|
+
if result.is_a?(Hash) && result['content'] && result['encoding'] == 'base64'
|
987
|
+
decoded = Base64.decode64(result['content'].gsub(/\s/, ''))
|
988
|
+
# Handle encoding issues
|
989
|
+
decoded = decoded.force_encoding('UTF-8')
|
990
|
+
unless decoded.valid_encoding?
|
991
|
+
decoded = decoded.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '?')
|
992
|
+
end
|
993
|
+
# Limit README display to avoid overwhelming the pane
|
994
|
+
return decoded.lines.first(20).join.strip
|
995
|
+
end
|
996
|
+
rescue => e
|
997
|
+
log_debug("Error fetching README #{filename}: #{e.message}")
|
998
|
+
next
|
999
|
+
end
|
1000
|
+
end
|
1001
|
+
|
1002
|
+
nil
|
1003
|
+
end
|
1004
|
+
|
1005
|
+
def fetch_repo_files(repo_full_name, path = '')
|
1006
|
+
return nil unless repo_full_name
|
1007
|
+
|
1008
|
+
begin
|
1009
|
+
result = github_request("/repos/#{repo_full_name}/contents/#{path}")
|
1010
|
+
return nil if result.is_a?(Hash) && result[:error]
|
1011
|
+
return nil unless result.is_a?(Array)
|
1012
|
+
|
1013
|
+
content = ""
|
1014
|
+
files = []
|
1015
|
+
dirs = []
|
1016
|
+
|
1017
|
+
# Separate files and directories
|
1018
|
+
result.each do |item|
|
1019
|
+
next unless item.is_a?(Hash) && item['name']
|
1020
|
+
|
1021
|
+
if item['type'] == 'dir'
|
1022
|
+
dirs << item
|
1023
|
+
else
|
1024
|
+
files << item
|
1025
|
+
end
|
1026
|
+
end
|
1027
|
+
|
1028
|
+
# Show directories first, then files
|
1029
|
+
(dirs + files).each do |item|
|
1030
|
+
icon = case item['type']
|
1031
|
+
when 'dir' then '📁'
|
1032
|
+
when 'file'
|
1033
|
+
case File.extname(item['name']).downcase
|
1034
|
+
when '.md' then '📄'
|
1035
|
+
when '.rb' then '💎'
|
1036
|
+
when '.py' then '🐍'
|
1037
|
+
when '.js' then '📜'
|
1038
|
+
when '.json' then '⚙️'
|
1039
|
+
when '.yml', '.yaml' then '⚙️'
|
1040
|
+
else '📄'
|
1041
|
+
end
|
1042
|
+
else '❓'
|
1043
|
+
end
|
1044
|
+
|
1045
|
+
size_info = item['size'] ? " (#{format_file_size(item['size'])})" : ""
|
1046
|
+
content += "#{icon} #{item['name']}#{size_info}\n"
|
1047
|
+
end
|
1048
|
+
|
1049
|
+
content.empty? ? nil : content
|
1050
|
+
rescue => e
|
1051
|
+
log_debug("Error fetching repo files: #{e.message}")
|
1052
|
+
nil
|
1053
|
+
end
|
1054
|
+
end
|
1055
|
+
|
1056
|
+
def format_file_size(bytes)
|
1057
|
+
return "0 B" if bytes == 0
|
1058
|
+
|
1059
|
+
units = ['B', 'KB', 'MB', 'GB']
|
1060
|
+
exp = (Math.log(bytes) / Math.log(1024)).floor
|
1061
|
+
exp = [exp, units.length - 1].min
|
1062
|
+
|
1063
|
+
"%.1f %s" % [bytes.to_f / (1024 ** exp), units[exp]]
|
1064
|
+
end
|
1065
|
+
|
1066
|
+
def github_issues
|
1067
|
+
@mode = :github_issues
|
1068
|
+
@github_issues = []
|
1069
|
+
@selected_repo = current_repo
|
1070
|
+
|
1071
|
+
return if @selected_repo.empty?
|
1072
|
+
|
1073
|
+
result = github_request("/repos/#{@selected_repo}/issues?state=open&per_page=50")
|
1074
|
+
|
1075
|
+
if result[:error]
|
1076
|
+
@p_left.say(result[:error].fg(196))
|
1077
|
+
return
|
1078
|
+
end
|
1079
|
+
|
1080
|
+
result.each do |issue|
|
1081
|
+
next if issue['pull_request'] # Skip PRs
|
1082
|
+
|
1083
|
+
@github_issues << {
|
1084
|
+
number: issue['number'],
|
1085
|
+
title: issue['title'],
|
1086
|
+
state: issue['state'],
|
1087
|
+
user: issue['user']['login'],
|
1088
|
+
created_at: issue['created_at'],
|
1089
|
+
body: issue['body'],
|
1090
|
+
labels: issue['labels']
|
1091
|
+
}
|
1092
|
+
end
|
1093
|
+
|
1094
|
+
@max_index = @github_issues.length - 1
|
1095
|
+
@min_index = 0
|
1096
|
+
@index = 0 if @index > @max_index
|
1097
|
+
|
1098
|
+
# Update bottom pane to show legends
|
1099
|
+
update_bottom_legends
|
1100
|
+
|
1101
|
+
display_github_issues
|
1102
|
+
end
|
1103
|
+
|
1104
|
+
def display_github_issues
|
1105
|
+
# Update top pane with mode
|
1106
|
+
update_mode_info("GitHub Issues - #{@selected_repo}")
|
1107
|
+
|
1108
|
+
content = ""
|
1109
|
+
|
1110
|
+
if @github_issues.empty?
|
1111
|
+
content += 'No open issues'.fg(245)
|
1112
|
+
else
|
1113
|
+
@github_issues.each_with_index do |issue, i|
|
1114
|
+
marker = i == @index ? '→ ' : ' '
|
1115
|
+
number = "##{issue[:number]}".fg(220)
|
1116
|
+
content += marker + number + ' ' + issue[:title] + "\n"
|
1117
|
+
end
|
1118
|
+
end
|
1119
|
+
|
1120
|
+
# Preserve scroll position when updating content
|
1121
|
+
saved_ix = @p_left.ix
|
1122
|
+
@p_left.clear
|
1123
|
+
@p_left.say(content)
|
1124
|
+
@p_left.ix = saved_ix
|
1125
|
+
|
1126
|
+
# Show issue details in right pane
|
1127
|
+
show_issue_details(@github_issues[@index]) if @index >= 0 && @index < @github_issues.length
|
1128
|
+
end
|
1129
|
+
|
1130
|
+
def show_issue_details(issue)
|
1131
|
+
content = "##{issue[:number]} #{issue[:title]}".b.fg(156) + "\n"
|
1132
|
+
content += ('=' * 60) + "\n\n"
|
1133
|
+
content += 'Author: '.fg(249) + issue[:user] + "\n"
|
1134
|
+
content += 'Created: '.fg(249) + issue[:created_at] + "\n"
|
1135
|
+
content += 'State: '.fg(249) + issue[:state].fg(issue[:state] == 'open' ? 154 : 196) + "\n\n"
|
1136
|
+
|
1137
|
+
unless issue[:labels].empty?
|
1138
|
+
content += 'Labels: '.fg(249)
|
1139
|
+
issue[:labels].each do |label|
|
1140
|
+
content += "[#{label['name']}]".fg(51) + ' '
|
1141
|
+
end
|
1142
|
+
content += "\n\n"
|
1143
|
+
end
|
1144
|
+
|
1145
|
+
content += "Description:\n".fg(249)
|
1146
|
+
content += ('-' * 20) + "\n"
|
1147
|
+
content += issue[:body] || 'No description'
|
1148
|
+
|
1149
|
+
@p_right.clear
|
1150
|
+
@p_right.say(content)
|
1151
|
+
end
|
1152
|
+
|
1153
|
+
def github_pull_requests
|
1154
|
+
@mode = :github_prs
|
1155
|
+
@github_prs = []
|
1156
|
+
@selected_repo = current_repo
|
1157
|
+
|
1158
|
+
return if @selected_repo.empty?
|
1159
|
+
|
1160
|
+
result = github_request("/repos/#{@selected_repo}/pulls?state=open&per_page=50")
|
1161
|
+
|
1162
|
+
if result[:error]
|
1163
|
+
@p_left.say(result[:error].fg(196))
|
1164
|
+
return
|
1165
|
+
end
|
1166
|
+
|
1167
|
+
result.each do |pr|
|
1168
|
+
@github_prs << {
|
1169
|
+
number: pr['number'],
|
1170
|
+
title: pr['title'],
|
1171
|
+
state: pr['state'],
|
1172
|
+
user: pr['user']['login'],
|
1173
|
+
created_at: pr['created_at'],
|
1174
|
+
body: pr['body'],
|
1175
|
+
head: pr['head']['ref'],
|
1176
|
+
base: pr['base']['ref']
|
1177
|
+
}
|
1178
|
+
end
|
1179
|
+
|
1180
|
+
@max_index = @github_prs.length - 1
|
1181
|
+
@min_index = 0
|
1182
|
+
@index = 0 if @index > @max_index
|
1183
|
+
|
1184
|
+
# Update bottom pane to show legends
|
1185
|
+
update_bottom_legends
|
1186
|
+
|
1187
|
+
display_github_prs
|
1188
|
+
end
|
1189
|
+
|
1190
|
+
def display_github_prs
|
1191
|
+
# Update top pane with mode
|
1192
|
+
update_mode_info("Pull Requests - #{@selected_repo}")
|
1193
|
+
|
1194
|
+
content = ""
|
1195
|
+
|
1196
|
+
if @github_prs.empty?
|
1197
|
+
content += 'No open pull requests'.fg(245)
|
1198
|
+
else
|
1199
|
+
@github_prs.each_with_index do |pr, i|
|
1200
|
+
marker = i == @index ? '→ ' : ' '
|
1201
|
+
number = "##{pr[:number]}".fg(220)
|
1202
|
+
content += marker + number + ' ' + pr[:title] + "\n"
|
1203
|
+
end
|
1204
|
+
end
|
1205
|
+
|
1206
|
+
# Preserve scroll position when updating content
|
1207
|
+
saved_ix = @p_left.ix
|
1208
|
+
@p_left.clear
|
1209
|
+
@p_left.say(content)
|
1210
|
+
@p_left.ix = saved_ix
|
1211
|
+
|
1212
|
+
# Show PR details in right pane
|
1213
|
+
show_pr_details(@github_prs[@index]) if @index >= 0 && @index < @github_prs.length
|
1214
|
+
end
|
1215
|
+
|
1216
|
+
def show_pr_details(pull_request)
|
1217
|
+
content = "##{pull_request[:number]} #{pull_request[:title]}".b.fg(156) + "\n"
|
1218
|
+
content += ('=' * 60) + "\n\n"
|
1219
|
+
content += 'Author: '.fg(249) + pull_request[:user] + "\n"
|
1220
|
+
content += 'Created: '.fg(249) + pull_request[:created_at] + "\n"
|
1221
|
+
content += 'State: '.fg(249) + pull_request[:state].fg(pull_request[:state] == 'open' ? 154 : 196) + "\n"
|
1222
|
+
content += 'Branch: '.fg(249) + "#{pull_request[:head]} → #{pull_request[:base]}" + "\n\n"
|
1223
|
+
|
1224
|
+
content += "Description:\n".fg(249)
|
1225
|
+
content += ('-' * 20) + "\n"
|
1226
|
+
content += pull_request[:body] || 'No description'
|
1227
|
+
|
1228
|
+
@p_right.clear
|
1229
|
+
@p_right.say(content)
|
1230
|
+
end
|
1231
|
+
|
1232
|
+
def current_repo
|
1233
|
+
remote_url = `git config --get remote.origin.url 2>/dev/null`.strip
|
1234
|
+
return '' if remote_url.empty?
|
1235
|
+
|
1236
|
+
# Extract owner/repo from various URL formats
|
1237
|
+
if remote_url =~ %r{github\.com[/:]([^/]+)/([^/]+?)(?:\.git)?$}
|
1238
|
+
"#{$1}/#{$2}"
|
1239
|
+
else
|
1240
|
+
''
|
1241
|
+
end
|
1242
|
+
end
|
1243
|
+
|
1244
|
+
# GITHUB SEARCH {{{1
|
1245
|
+
def github_search_repositories
|
1246
|
+
# Use bottom pane for search input (cleaner than popup)
|
1247
|
+
@p_bottom.clear
|
1248
|
+
@p_bottom.say("Search GitHub repositories: ")
|
1249
|
+
|
1250
|
+
query = ""
|
1251
|
+
loop do
|
1252
|
+
chr = getchr
|
1253
|
+
case chr
|
1254
|
+
when 'ENTER'
|
1255
|
+
break
|
1256
|
+
when 'ESCAPE'
|
1257
|
+
@p_bottom.clear
|
1258
|
+
update_bottom_legends
|
1259
|
+
return
|
1260
|
+
when 'BACKSPACE'
|
1261
|
+
query.chop!
|
1262
|
+
@p_bottom.clear
|
1263
|
+
@p_bottom.say("Search GitHub repositories: #{query}")
|
1264
|
+
else
|
1265
|
+
if chr && chr.length == 1
|
1266
|
+
query += chr
|
1267
|
+
@p_bottom.clear
|
1268
|
+
@p_bottom.say("Search GitHub repositories: #{query}")
|
1269
|
+
end
|
1270
|
+
end
|
1271
|
+
end
|
1272
|
+
|
1273
|
+
return if query.empty?
|
1274
|
+
|
1275
|
+
@mode = :github_search
|
1276
|
+
@github_search_results = []
|
1277
|
+
@index = 0
|
1278
|
+
|
1279
|
+
# Perform the search
|
1280
|
+
result = github_request("/search/repositories?q=#{CGI.escape(query)}&sort=stars&order=desc&per_page=50")
|
1281
|
+
|
1282
|
+
if result[:error]
|
1283
|
+
@p_left.clear
|
1284
|
+
@p_left.say("Search Error: #{result[:error]}".fg(196))
|
1285
|
+
return
|
1286
|
+
end
|
1287
|
+
|
1288
|
+
# Process search results
|
1289
|
+
if result['items'] && result['items'].any?
|
1290
|
+
result['items'].each do |repo|
|
1291
|
+
@github_search_results << {
|
1292
|
+
id: repo['id'],
|
1293
|
+
name: repo['name'],
|
1294
|
+
full_name: repo['full_name'],
|
1295
|
+
description: repo['description'],
|
1296
|
+
private: repo['private'],
|
1297
|
+
language: repo['language'],
|
1298
|
+
stargazers_count: repo['stargazers_count'],
|
1299
|
+
forks_count: repo['forks_count'],
|
1300
|
+
open_issues_count: repo['open_issues_count'],
|
1301
|
+
updated_at: repo['updated_at'],
|
1302
|
+
clone_url: repo['clone_url'],
|
1303
|
+
owner: repo['owner']['login']
|
1304
|
+
}
|
1305
|
+
end
|
1306
|
+
|
1307
|
+
@max_index = @github_search_results.length - 1
|
1308
|
+
@min_index = 0
|
1309
|
+
@index = 0
|
1310
|
+
|
1311
|
+
update_mode_info("Search Results: \"#{query}\"")
|
1312
|
+
display_github_search_results
|
1313
|
+
else
|
1314
|
+
@p_left.clear
|
1315
|
+
@p_left.say("No repositories found for: #{query}".fg(245))
|
1316
|
+
end
|
1317
|
+
|
1318
|
+
update_bottom_legends
|
1319
|
+
end
|
1320
|
+
|
1321
|
+
def display_github_search_results
|
1322
|
+
content = ""
|
1323
|
+
|
1324
|
+
@github_search_results.each_with_index do |repo, i|
|
1325
|
+
marker = i == @index ? '→ ' : ' '
|
1326
|
+
stars = "⭐#{repo[:stargazers_count]}".fg(220)
|
1327
|
+
language = repo[:language] ? "[#{repo[:language]}]".fg(51) : ""
|
1328
|
+
content += marker + repo[:full_name].fg(156) + " #{stars} #{language}\n"
|
1329
|
+
end
|
1330
|
+
|
1331
|
+
# Preserve scroll position when updating content
|
1332
|
+
saved_ix = @p_left.ix
|
1333
|
+
@p_left.clear
|
1334
|
+
@p_left.say(content)
|
1335
|
+
@p_left.ix = saved_ix
|
1336
|
+
|
1337
|
+
# Right pane will be updated separately by the calling code
|
1338
|
+
end
|
1339
|
+
|
1340
|
+
def show_search_repo_details(repo, immediate_extended_fetch = false)
|
1341
|
+
return unless repo
|
1342
|
+
|
1343
|
+
log_debug("Showing search repo details for: #{repo[:full_name] || 'nil'} (immediate: #{immediate_extended_fetch})")
|
1344
|
+
|
1345
|
+
# Always show basic info immediately
|
1346
|
+
display_basic_search_repo_info(repo)
|
1347
|
+
|
1348
|
+
if immediate_extended_fetch
|
1349
|
+
# Fetch extended content right now (for initial display or full refresh)
|
1350
|
+
fetch_and_display_extended_content(repo)
|
1351
|
+
else
|
1352
|
+
# Schedule for delayed fetch (for fast navigation)
|
1353
|
+
schedule_extended_fetch(repo)
|
1354
|
+
end
|
1355
|
+
end
|
1356
|
+
|
1357
|
+
def display_basic_search_repo_info(repo)
|
1358
|
+
content = (repo[:full_name] || 'Unknown repository').b.fg(156) + "\n"
|
1359
|
+
content += ('=' * 60) + "\n\n"
|
1360
|
+
|
1361
|
+
# Basic repo info with more details for search results (always shown immediately)
|
1362
|
+
content += 'Owner: '.fg(249) + (repo[:owner] || 'Unknown') + "\n"
|
1363
|
+
content += 'Description: '.fg(249) + (repo[:description] || 'No description') + "\n"
|
1364
|
+
content += 'Private: '.fg(249) + (repo[:private] ? 'Yes' : 'No') + "\n"
|
1365
|
+
content += 'Language: '.fg(249) + (repo[:language] || 'Not specified') + "\n"
|
1366
|
+
content += 'Stars: '.fg(249) + (repo[:stargazers_count] || 0).to_s + " "
|
1367
|
+
content += 'Forks: '.fg(249) + (repo[:forks_count] || 0).to_s + " "
|
1368
|
+
content += 'Issues: '.fg(249) + (repo[:open_issues_count] || 0).to_s + "\n"
|
1369
|
+
content += 'Updated: '.fg(249) + (repo[:updated_at] || 'Unknown') + "\n"
|
1370
|
+
content += 'Clone URL: '.fg(249) + (repo[:clone_url] || 'Not available') + "\n\n"
|
1371
|
+
|
1372
|
+
@p_right.clear
|
1373
|
+
@p_right.say(content)
|
1374
|
+
end
|
1375
|
+
|
1376
|
+
# KEY BINDINGS {{{1
|
1377
|
+
def getkey
|
1378
|
+
# If we have pending extended fetch, go straight to timeout logic
|
1379
|
+
log_debug("Checking pending fetch: #{@pending_extended_fetch ? @pending_extended_fetch[:full_name] : 'none'}")
|
1380
|
+
if @pending_extended_fetch
|
1381
|
+
# Wait up to 0.5 seconds for either a key press or timeout for extended fetch
|
1382
|
+
log_debug("Waiting for key or extended fetch timeout: #{@pending_extended_fetch[:full_name]}")
|
1383
|
+
chr = getchr(0.5)
|
1384
|
+
|
1385
|
+
if chr
|
1386
|
+
# Key pressed during wait - cancel extended fetch and return key
|
1387
|
+
log_debug("Key pressed during wait, cancelling extended fetch")
|
1388
|
+
@pending_extended_fetch = nil
|
1389
|
+
return chr
|
1390
|
+
else
|
1391
|
+
# Timeout reached - perform extended fetch
|
1392
|
+
repo = @pending_extended_fetch
|
1393
|
+
@pending_extended_fetch = nil
|
1394
|
+
log_debug("Timeout reached, fetching extended content for: #{repo[:full_name]}")
|
1395
|
+
|
1396
|
+
# Check if we're still on the same repo
|
1397
|
+
current_repo = case @mode
|
1398
|
+
when :github_repos
|
1399
|
+
@github_repos[@index] if @index >= 0 && @index < @github_repos.length
|
1400
|
+
when :github_search
|
1401
|
+
@github_search_results[@index] if @index >= 0 && @index < @github_search_results.length
|
1402
|
+
end
|
1403
|
+
|
1404
|
+
log_debug("Repo comparison: current=#{current_repo ? current_repo[:full_name] : 'nil'}, pending=#{repo[:full_name]}")
|
1405
|
+
|
1406
|
+
if current_repo && current_repo[:full_name] == repo[:full_name]
|
1407
|
+
log_debug("Still on same repo, fetching extended content")
|
1408
|
+
fetch_and_display_extended_content(repo)
|
1409
|
+
else
|
1410
|
+
log_debug("Repo changed, skipping extended fetch")
|
1411
|
+
end
|
1412
|
+
|
1413
|
+
# Now wait for the next actual user input
|
1414
|
+
chr = getchr # Block until user presses a key
|
1415
|
+
log_debug("Post-fetch key: #{chr}")
|
1416
|
+
return chr
|
1417
|
+
end
|
1418
|
+
else
|
1419
|
+
# No pending fetch - check for immediate key, then wait if needed
|
1420
|
+
chr = getchr(0) # 0 timeout = non-blocking check
|
1421
|
+
|
1422
|
+
if chr
|
1423
|
+
log_debug("Immediate key (no pending fetch): #{chr}")
|
1424
|
+
return chr
|
1425
|
+
else
|
1426
|
+
# No immediate key - wait for input normally
|
1427
|
+
chr = getchr # Block until user presses a key
|
1428
|
+
log_debug("Normal key: #{chr}")
|
1429
|
+
return chr
|
1430
|
+
end
|
1431
|
+
end
|
1432
|
+
end
|
1433
|
+
|
1434
|
+
def handle_key(chr)
|
1435
|
+
return unless chr
|
1436
|
+
|
1437
|
+
log_debug("Key received: '#{chr}' (length: #{chr.length})")
|
1438
|
+
|
1439
|
+
case chr
|
1440
|
+
when '?'
|
1441
|
+
show_help
|
1442
|
+
when 'q'
|
1443
|
+
quit
|
1444
|
+
when 'r'
|
1445
|
+
refresh_view
|
1446
|
+
when 'j', 'DOWN'
|
1447
|
+
move_down
|
1448
|
+
when 'k', 'UP'
|
1449
|
+
move_up
|
1450
|
+
when 'g', 'HOME'
|
1451
|
+
old_index = @index
|
1452
|
+
@index = @min_index
|
1453
|
+
|
1454
|
+
# Position pane at the top
|
1455
|
+
@p_left.ix = 0
|
1456
|
+
|
1457
|
+
# Update left pane content directly instead of full refresh
|
1458
|
+
case @mode
|
1459
|
+
when :github_repos
|
1460
|
+
display_github_repos
|
1461
|
+
when :github_search
|
1462
|
+
display_github_search_results
|
1463
|
+
when :status
|
1464
|
+
display_status
|
1465
|
+
when :log
|
1466
|
+
display_log
|
1467
|
+
when :branches
|
1468
|
+
display_branches
|
1469
|
+
when :github_issues
|
1470
|
+
display_github_issues
|
1471
|
+
when :github_prs
|
1472
|
+
display_github_prs
|
1473
|
+
end
|
1474
|
+
|
1475
|
+
# Use fast update for major jumps to avoid extended fetch delays
|
1476
|
+
update_right_pane(true)
|
1477
|
+
when 'G', 'END'
|
1478
|
+
old_index = @index
|
1479
|
+
@index = @max_index
|
1480
|
+
|
1481
|
+
# Position pane to show the last item
|
1482
|
+
pane_height = @p_left.h - 2 # Account for border
|
1483
|
+
total_items = @max_index + 1
|
1484
|
+
if total_items > pane_height
|
1485
|
+
@p_left.ix = total_items - pane_height
|
1486
|
+
else
|
1487
|
+
@p_left.ix = 0
|
1488
|
+
end
|
1489
|
+
|
1490
|
+
# Update left pane content directly instead of full refresh
|
1491
|
+
case @mode
|
1492
|
+
when :github_repos
|
1493
|
+
display_github_repos
|
1494
|
+
when :github_search
|
1495
|
+
display_github_search_results
|
1496
|
+
when :status
|
1497
|
+
display_status
|
1498
|
+
when :log
|
1499
|
+
display_log
|
1500
|
+
when :branches
|
1501
|
+
display_branches
|
1502
|
+
when :github_issues
|
1503
|
+
display_github_issues
|
1504
|
+
when :github_prs
|
1505
|
+
display_github_prs
|
1506
|
+
end
|
1507
|
+
|
1508
|
+
# Use fast update for major jumps to avoid extended fetch delays
|
1509
|
+
update_right_pane(true)
|
1510
|
+
when 'PgDOWN'
|
1511
|
+
# Page down in left pane
|
1512
|
+
old_index = @index
|
1513
|
+
@index = [@index + @p_left.h - 3, @max_index].min
|
1514
|
+
|
1515
|
+
# Calculate where the selection should be visible and set scroll position
|
1516
|
+
target_line = @index
|
1517
|
+
pane_height = @p_left.h - 2 # Account for border
|
1518
|
+
|
1519
|
+
# If the new index is beyond what's currently visible, scroll to show it
|
1520
|
+
if target_line >= @p_left.ix + pane_height
|
1521
|
+
@p_left.ix = [target_line - pane_height + 1, 0].max
|
1522
|
+
elsif target_line < @p_left.ix
|
1523
|
+
@p_left.ix = target_line
|
1524
|
+
end
|
1525
|
+
|
1526
|
+
# Update left pane content directly instead of full refresh
|
1527
|
+
case @mode
|
1528
|
+
when :github_repos
|
1529
|
+
display_github_repos
|
1530
|
+
when :github_search
|
1531
|
+
display_github_search_results
|
1532
|
+
when :status
|
1533
|
+
display_status
|
1534
|
+
when :log
|
1535
|
+
display_log
|
1536
|
+
when :branches
|
1537
|
+
display_branches
|
1538
|
+
when :github_issues
|
1539
|
+
display_github_issues
|
1540
|
+
when :github_prs
|
1541
|
+
display_github_prs
|
1542
|
+
end
|
1543
|
+
|
1544
|
+
# Show basic info immediately, schedule extended fetch for when user pauses
|
1545
|
+
update_right_pane(true)
|
1546
|
+
when 'PgUP'
|
1547
|
+
# Page up in left pane
|
1548
|
+
old_index = @index
|
1549
|
+
@index = [@index - @p_left.h + 3, @min_index].max
|
1550
|
+
|
1551
|
+
# Calculate where the selection should be visible and set scroll position
|
1552
|
+
target_line = @index
|
1553
|
+
pane_height = @p_left.h - 2 # Account for border
|
1554
|
+
|
1555
|
+
# If the new index is beyond what's currently visible, scroll to show it
|
1556
|
+
if target_line >= @p_left.ix + pane_height
|
1557
|
+
@p_left.ix = [target_line - pane_height + 1, 0].max
|
1558
|
+
elsif target_line < @p_left.ix
|
1559
|
+
@p_left.ix = target_line
|
1560
|
+
end
|
1561
|
+
|
1562
|
+
# Update left pane content directly instead of full refresh
|
1563
|
+
case @mode
|
1564
|
+
when :github_repos
|
1565
|
+
display_github_repos
|
1566
|
+
when :github_search
|
1567
|
+
display_github_search_results
|
1568
|
+
when :status
|
1569
|
+
display_status
|
1570
|
+
when :log
|
1571
|
+
display_log
|
1572
|
+
when :branches
|
1573
|
+
display_branches
|
1574
|
+
when :github_issues
|
1575
|
+
display_github_issues
|
1576
|
+
when :github_prs
|
1577
|
+
display_github_prs
|
1578
|
+
end
|
1579
|
+
|
1580
|
+
# Show basic info immediately, schedule extended fetch for when user pauses
|
1581
|
+
update_right_pane(true)
|
1582
|
+
when 'S-RIGHT'
|
1583
|
+
log_debug("S-RIGHT pressed - right pane pagedown")
|
1584
|
+
# Use full page height for more obvious scrolling
|
1585
|
+
page_height = @p_right.h - 2 # Account for borders
|
1586
|
+
@p_right.ix = [@p_right.ix + page_height, @p_right.text.split("\n").length - @p_right.h].min
|
1587
|
+
@p_right.refresh
|
1588
|
+
when 'S-LEFT'
|
1589
|
+
log_debug("S-LEFT pressed - right pane pageup")
|
1590
|
+
# Use full page height for more obvious scrolling
|
1591
|
+
page_height = @p_right.h - 2 # Account for borders
|
1592
|
+
@p_right.ix = [@p_right.ix - page_height, 0].max
|
1593
|
+
@p_right.refresh
|
1594
|
+
when 'S-DOWN' # Shift-Down for line down in right pane
|
1595
|
+
@p_right.linedown
|
1596
|
+
when 'S-UP' # Shift-Up for line up in right pane
|
1597
|
+
@p_right.lineup
|
1598
|
+
when 'J' # Capital J for line down in right pane
|
1599
|
+
@p_right.linedown
|
1600
|
+
when 'K' # Capital K for line up in right pane
|
1601
|
+
@p_right.lineup
|
1602
|
+
when 'w'
|
1603
|
+
change_width
|
1604
|
+
when 'T' # Capital T for Token setup
|
1605
|
+
github_token_popup
|
1606
|
+
when 'V' # Capital V for Verify token
|
1607
|
+
verify_github_token
|
1608
|
+
when 'O' # Capital O for Open token page
|
1609
|
+
open_github_token_page
|
1610
|
+
when 's'
|
1611
|
+
git_status
|
1612
|
+
when 'd'
|
1613
|
+
case @mode
|
1614
|
+
when :status
|
1615
|
+
git_diff
|
1616
|
+
else
|
1617
|
+
git_status
|
1618
|
+
end
|
1619
|
+
when 'l'
|
1620
|
+
git_log
|
1621
|
+
when 'b'
|
1622
|
+
git_branches
|
1623
|
+
when 'TAB'
|
1624
|
+
# Toggle between Git and GitHub modes with index memory
|
1625
|
+
save_current_index # Save current position
|
1626
|
+
|
1627
|
+
if @mode.to_s.start_with?('github')
|
1628
|
+
git_status # Go back to local Git
|
1629
|
+
else
|
1630
|
+
github_repos # Switch to GitHub
|
1631
|
+
end
|
1632
|
+
when 'i'
|
1633
|
+
if @mode.to_s.start_with?('github')
|
1634
|
+
github_issues
|
1635
|
+
else
|
1636
|
+
git_status # fallback
|
1637
|
+
end
|
1638
|
+
when 'p'
|
1639
|
+
case @mode
|
1640
|
+
when :status, :log, :branches
|
1641
|
+
git_pull
|
1642
|
+
else
|
1643
|
+
github_pull_requests
|
1644
|
+
end
|
1645
|
+
when 'P'
|
1646
|
+
git_push
|
1647
|
+
when 'a'
|
1648
|
+
stage_file if @mode == :status
|
1649
|
+
when 'u'
|
1650
|
+
unstage_file if @mode == :status
|
1651
|
+
when 'c'
|
1652
|
+
commit_changes
|
1653
|
+
when '/'
|
1654
|
+
github_search_repositories
|
1655
|
+
when ':'
|
1656
|
+
command_mode
|
1657
|
+
when '!'
|
1658
|
+
shell_command
|
1659
|
+
end
|
1660
|
+
end
|
1661
|
+
|
1662
|
+
def move_down
|
1663
|
+
old_index = @index
|
1664
|
+
|
1665
|
+
if @index >= @max_index
|
1666
|
+
@index = @min_index
|
1667
|
+
@p_left.ix = 0 # Jump to top of pane
|
1668
|
+
|
1669
|
+
# Full display update needed for wrapping
|
1670
|
+
case @mode
|
1671
|
+
when :github_repos
|
1672
|
+
display_github_repos
|
1673
|
+
when :github_search
|
1674
|
+
display_github_search_results
|
1675
|
+
when :status
|
1676
|
+
display_status
|
1677
|
+
when :log
|
1678
|
+
display_log
|
1679
|
+
when :branches
|
1680
|
+
display_branches
|
1681
|
+
when :github_issues
|
1682
|
+
display_github_issues
|
1683
|
+
when :github_prs
|
1684
|
+
display_github_prs
|
1685
|
+
end
|
1686
|
+
else
|
1687
|
+
@index += 1
|
1688
|
+
if @index - @p_left.ix >= @p_left.h - 3 # Near bottom with scrolloff
|
1689
|
+
@p_left.linedown
|
1690
|
+
|
1691
|
+
# Full display update needed for scrolling
|
1692
|
+
case @mode
|
1693
|
+
when :github_repos
|
1694
|
+
display_github_repos
|
1695
|
+
when :github_search
|
1696
|
+
display_github_search_results
|
1697
|
+
when :status
|
1698
|
+
display_status
|
1699
|
+
when :log
|
1700
|
+
display_log
|
1701
|
+
when :branches
|
1702
|
+
display_branches
|
1703
|
+
when :github_issues
|
1704
|
+
display_github_issues
|
1705
|
+
when :github_prs
|
1706
|
+
display_github_prs
|
1707
|
+
end
|
1708
|
+
else
|
1709
|
+
# Only update the selection markers without full refresh
|
1710
|
+
update_selection_only(old_index, @index)
|
1711
|
+
end
|
1712
|
+
end
|
1713
|
+
|
1714
|
+
# Schedule extended fetch for when user pauses
|
1715
|
+
log_debug("Navigation: calling update_right_pane(true) for scheduling")
|
1716
|
+
update_right_pane(true)
|
1717
|
+
end
|
1718
|
+
|
1719
|
+
def move_up
|
1720
|
+
old_index = @index
|
1721
|
+
|
1722
|
+
if @index <= @min_index
|
1723
|
+
@index = @max_index
|
1724
|
+
# Calculate proper scroll position for bottom
|
1725
|
+
pane_height = @p_left.h - 2
|
1726
|
+
total_items = @max_index + 1
|
1727
|
+
if total_items > pane_height
|
1728
|
+
@p_left.ix = total_items - pane_height
|
1729
|
+
else
|
1730
|
+
@p_left.ix = 0
|
1731
|
+
end
|
1732
|
+
|
1733
|
+
# Full display update needed for wrapping
|
1734
|
+
case @mode
|
1735
|
+
when :github_repos
|
1736
|
+
display_github_repos
|
1737
|
+
when :github_search
|
1738
|
+
display_github_search_results
|
1739
|
+
when :status
|
1740
|
+
display_status
|
1741
|
+
when :log
|
1742
|
+
display_log
|
1743
|
+
when :branches
|
1744
|
+
display_branches
|
1745
|
+
when :github_issues
|
1746
|
+
display_github_issues
|
1747
|
+
when :github_prs
|
1748
|
+
display_github_prs
|
1749
|
+
end
|
1750
|
+
else
|
1751
|
+
@index -= 1
|
1752
|
+
if @index - @p_left.ix < 3 # Near top with scrolloff
|
1753
|
+
@p_left.lineup
|
1754
|
+
|
1755
|
+
# Full display update needed for scrolling
|
1756
|
+
case @mode
|
1757
|
+
when :github_repos
|
1758
|
+
display_github_repos
|
1759
|
+
when :github_search
|
1760
|
+
display_github_search_results
|
1761
|
+
when :status
|
1762
|
+
display_status
|
1763
|
+
when :log
|
1764
|
+
display_log
|
1765
|
+
when :branches
|
1766
|
+
display_branches
|
1767
|
+
when :github_issues
|
1768
|
+
display_github_issues
|
1769
|
+
when :github_prs
|
1770
|
+
display_github_prs
|
1771
|
+
end
|
1772
|
+
else
|
1773
|
+
# Only update the selection markers without full refresh
|
1774
|
+
update_selection_only(old_index, @index)
|
1775
|
+
end
|
1776
|
+
end
|
1777
|
+
|
1778
|
+
# Schedule extended fetch for when user pauses
|
1779
|
+
log_debug("Navigation: calling update_right_pane(true) for scheduling")
|
1780
|
+
update_right_pane(true)
|
1781
|
+
end
|
1782
|
+
|
1783
|
+
def update_display
|
1784
|
+
# Update the left pane to show new selection
|
1785
|
+
refresh_view
|
1786
|
+
# Update the right pane content
|
1787
|
+
update_right_pane
|
1788
|
+
end
|
1789
|
+
|
1790
|
+
# Optimized method to update only selection markers without full refresh
|
1791
|
+
def update_selection_only(old_index, new_index)
|
1792
|
+
return if old_index == new_index
|
1793
|
+
|
1794
|
+
case @mode
|
1795
|
+
when :status
|
1796
|
+
update_status_selection(old_index, new_index)
|
1797
|
+
when :github_repos
|
1798
|
+
update_github_repos_selection(old_index, new_index)
|
1799
|
+
when :github_issues
|
1800
|
+
update_github_issues_selection(old_index, new_index)
|
1801
|
+
when :github_prs
|
1802
|
+
update_github_prs_selection(old_index, new_index)
|
1803
|
+
when :github_search
|
1804
|
+
update_github_search_selection(old_index, new_index)
|
1805
|
+
when :log
|
1806
|
+
update_log_selection(old_index, new_index)
|
1807
|
+
when :branches
|
1808
|
+
update_branches_selection(old_index, new_index)
|
1809
|
+
end
|
1810
|
+
end
|
1811
|
+
|
1812
|
+
def update_status_selection(old_index, new_index)
|
1813
|
+
return if @status_items.empty?
|
1814
|
+
|
1815
|
+
# Build the two lines we need to update
|
1816
|
+
lines_to_update = []
|
1817
|
+
|
1818
|
+
# Update old selection (remove marker)
|
1819
|
+
if old_index >= 0 && old_index < @status_items.length
|
1820
|
+
item = @status_items[old_index]
|
1821
|
+
color = get_status_color(item[:status])
|
1822
|
+
status_desc = get_status_desc(item[:status])
|
1823
|
+
old_line = ' ' + item[:status].fg(color) + ' ' + item[:file] + ' ' + status_desc.fg(242)
|
1824
|
+
lines_to_update << { index: old_index, content: old_line }
|
1825
|
+
end
|
1826
|
+
|
1827
|
+
# Update new selection (add marker)
|
1828
|
+
if new_index >= 0 && new_index < @status_items.length
|
1829
|
+
item = @status_items[new_index]
|
1830
|
+
color = get_status_color(item[:status])
|
1831
|
+
status_desc = get_status_desc(item[:status])
|
1832
|
+
new_line = '→ ' + item[:status].fg(color) + ' ' + item[:file] + ' ' + status_desc.fg(242)
|
1833
|
+
lines_to_update << { index: new_index, content: new_line }
|
1834
|
+
end
|
1835
|
+
|
1836
|
+
# Update the specific lines in the pane without full refresh
|
1837
|
+
update_pane_lines(lines_to_update)
|
1838
|
+
end
|
1839
|
+
|
1840
|
+
def update_github_repos_selection(old_index, new_index)
|
1841
|
+
return if @github_repos.empty?
|
1842
|
+
|
1843
|
+
lines_to_update = []
|
1844
|
+
|
1845
|
+
# Update old selection (remove marker)
|
1846
|
+
if old_index >= 0 && old_index < @github_repos.length
|
1847
|
+
repo = @github_repos[old_index]
|
1848
|
+
privacy = repo[:private] ? '[P]'.fg(196) : '[O]'.fg(154)
|
1849
|
+
|
1850
|
+
# Use the same color scheme as display_github_repos
|
1851
|
+
full_name = repo[:full_name] || 'unknown/unknown'
|
1852
|
+
owner, name = full_name.split('/', 2)
|
1853
|
+
owner_color = get_owner_color(owner)
|
1854
|
+
|
1855
|
+
old_line = ' ' + privacy + ' ' + owner.fg(owner_color) + '/'.fg(245) + name.fg(255)
|
1856
|
+
lines_to_update << { index: old_index, content: old_line }
|
1857
|
+
end
|
1858
|
+
|
1859
|
+
# Update new selection (add marker)
|
1860
|
+
if new_index >= 0 && new_index < @github_repos.length
|
1861
|
+
repo = @github_repos[new_index]
|
1862
|
+
privacy = repo[:private] ? '[P]'.fg(196) : '[O]'.fg(154)
|
1863
|
+
|
1864
|
+
# Use the same color scheme as display_github_repos
|
1865
|
+
full_name = repo[:full_name] || 'unknown/unknown'
|
1866
|
+
owner, name = full_name.split('/', 2)
|
1867
|
+
owner_color = get_owner_color(owner)
|
1868
|
+
|
1869
|
+
new_line = '→ ' + privacy + ' ' + owner.fg(owner_color) + '/'.fg(245) + name.fg(255)
|
1870
|
+
lines_to_update << { index: new_index, content: new_line }
|
1871
|
+
end
|
1872
|
+
|
1873
|
+
update_pane_lines(lines_to_update)
|
1874
|
+
end
|
1875
|
+
|
1876
|
+
def update_github_issues_selection(old_index, new_index)
|
1877
|
+
return if @github_issues.empty?
|
1878
|
+
|
1879
|
+
lines_to_update = []
|
1880
|
+
|
1881
|
+
# Update old selection
|
1882
|
+
if old_index >= 0 && old_index < @github_issues.length
|
1883
|
+
issue = @github_issues[old_index]
|
1884
|
+
number = "##{issue[:number]}".fg(220)
|
1885
|
+
old_line = ' ' + number + ' ' + issue[:title]
|
1886
|
+
lines_to_update << { index: old_index, content: old_line }
|
1887
|
+
end
|
1888
|
+
|
1889
|
+
# Update new selection
|
1890
|
+
if new_index >= 0 && new_index < @github_issues.length
|
1891
|
+
issue = @github_issues[new_index]
|
1892
|
+
number = "##{issue[:number]}".fg(220)
|
1893
|
+
new_line = '→ ' + number + ' ' + issue[:title]
|
1894
|
+
lines_to_update << { index: new_index, content: new_line }
|
1895
|
+
end
|
1896
|
+
|
1897
|
+
update_pane_lines(lines_to_update)
|
1898
|
+
end
|
1899
|
+
|
1900
|
+
def update_github_prs_selection(old_index, new_index)
|
1901
|
+
return if @github_prs.empty?
|
1902
|
+
|
1903
|
+
lines_to_update = []
|
1904
|
+
|
1905
|
+
# Update old selection
|
1906
|
+
if old_index >= 0 && old_index < @github_prs.length
|
1907
|
+
pr = @github_prs[old_index]
|
1908
|
+
number = "##{pr[:number]}".fg(220)
|
1909
|
+
old_line = ' ' + number + ' ' + pr[:title]
|
1910
|
+
lines_to_update << { index: old_index, content: old_line }
|
1911
|
+
end
|
1912
|
+
|
1913
|
+
# Update new selection
|
1914
|
+
if new_index >= 0 && new_index < @github_prs.length
|
1915
|
+
pr = @github_prs[new_index]
|
1916
|
+
number = "##{pr[:number]}".fg(220)
|
1917
|
+
new_line = '→ ' + number + ' ' + pr[:title]
|
1918
|
+
lines_to_update << { index: new_index, content: new_line }
|
1919
|
+
end
|
1920
|
+
|
1921
|
+
update_pane_lines(lines_to_update)
|
1922
|
+
end
|
1923
|
+
|
1924
|
+
def update_log_selection(old_index, new_index)
|
1925
|
+
# Implement if needed for log mode
|
1926
|
+
refresh_view
|
1927
|
+
end
|
1928
|
+
|
1929
|
+
def update_branches_selection(old_index, new_index)
|
1930
|
+
# Implement if needed for branches mode
|
1931
|
+
refresh_view
|
1932
|
+
end
|
1933
|
+
|
1934
|
+
def update_github_search_selection(old_index, new_index)
|
1935
|
+
return if @github_search_results.empty?
|
1936
|
+
|
1937
|
+
lines_to_update = []
|
1938
|
+
|
1939
|
+
# Update old selection (remove marker)
|
1940
|
+
if old_index >= 0 && old_index < @github_search_results.length
|
1941
|
+
repo = @github_search_results[old_index]
|
1942
|
+
stars = "⭐#{repo[:stargazers_count]}".fg(220)
|
1943
|
+
language = repo[:language] ? "[#{repo[:language]}]".fg(51) : ""
|
1944
|
+
old_line = ' ' + repo[:full_name].fg(156) + " #{stars} #{language}"
|
1945
|
+
lines_to_update << { index: old_index, content: old_line }
|
1946
|
+
end
|
1947
|
+
|
1948
|
+
# Update new selection (add marker)
|
1949
|
+
if new_index >= 0 && new_index < @github_search_results.length
|
1950
|
+
repo = @github_search_results[new_index]
|
1951
|
+
stars = "⭐#{repo[:stargazers_count]}".fg(220)
|
1952
|
+
language = repo[:language] ? "[#{repo[:language]}]".fg(51) : ""
|
1953
|
+
new_line = '→ ' + repo[:full_name].fg(156) + " #{stars} #{language}"
|
1954
|
+
lines_to_update << { index: new_index, content: new_line }
|
1955
|
+
end
|
1956
|
+
|
1957
|
+
update_pane_lines(lines_to_update)
|
1958
|
+
end
|
1959
|
+
|
1960
|
+
# Helper method to update specific lines in the left pane
|
1961
|
+
def update_pane_lines(lines_to_update)
|
1962
|
+
return if lines_to_update.empty?
|
1963
|
+
|
1964
|
+
# Get current pane content lines
|
1965
|
+
current_content = @p_left.text.split("\n")
|
1966
|
+
|
1967
|
+
# Update the specific lines
|
1968
|
+
lines_to_update.each do |line_update|
|
1969
|
+
index = line_update[:index]
|
1970
|
+
content = line_update[:content]
|
1971
|
+
|
1972
|
+
# Make sure we're within bounds
|
1973
|
+
if index >= 0 && index < current_content.length
|
1974
|
+
current_content[index] = content
|
1975
|
+
end
|
1976
|
+
end
|
1977
|
+
|
1978
|
+
# Set the updated content without clearing (uses rcurses diff-based refresh)
|
1979
|
+
new_content = current_content.join("\n")
|
1980
|
+
@p_left.text = new_content
|
1981
|
+
@p_left.refresh # This uses the optimized diff-based refresh
|
1982
|
+
end
|
1983
|
+
|
1984
|
+
# Helper methods for status colors and descriptions
|
1985
|
+
def get_status_color(status)
|
1986
|
+
case status
|
1987
|
+
when 'M ' then 220 # Modified (staged)
|
1988
|
+
when ' M' then 214 # Modified (not staged)
|
1989
|
+
when 'MM' then 202 # Modified (staged and unstaged)
|
1990
|
+
when 'A ' then 154 # Added (new file staged)
|
1991
|
+
when 'D ' then 196 # Deleted
|
1992
|
+
when ' D' then 196 # Deleted (not staged)
|
1993
|
+
when '??' then 245 # Untracked
|
1994
|
+
when 'R ' then 51 # Renamed
|
1995
|
+
when 'C ' then 51 # Copied
|
1996
|
+
else 255
|
1997
|
+
end
|
1998
|
+
end
|
1999
|
+
|
2000
|
+
def get_status_desc(status)
|
2001
|
+
case status
|
2002
|
+
when 'M ' then '(staged)'
|
2003
|
+
when ' M' then '(modified)'
|
2004
|
+
when 'MM' then '(staged+modified)'
|
2005
|
+
when 'A ' then '(new file)'
|
2006
|
+
when 'D ' then '(deleted)'
|
2007
|
+
when ' D' then '(deleted)'
|
2008
|
+
when '??' then '(untracked)'
|
2009
|
+
when 'R ' then '(renamed)'
|
2010
|
+
when 'C ' then '(copied)'
|
2011
|
+
else ''
|
2012
|
+
end
|
2013
|
+
end
|
2014
|
+
|
2015
|
+
def get_owner_color(owner)
|
2016
|
+
return 245 unless owner
|
2017
|
+
|
2018
|
+
# Expanded color palette for better visual distinction between organizations
|
2019
|
+
colors = [
|
2020
|
+
51, # Bright cyan
|
2021
|
+
214, # Orange
|
2022
|
+
156, # Light green
|
2023
|
+
220, # Yellow
|
2024
|
+
141, # Light purple
|
2025
|
+
81, # Light blue
|
2026
|
+
203, # Pink
|
2027
|
+
118, # Lime green
|
2028
|
+
196, # Red
|
2029
|
+
33, # Dark blue
|
2030
|
+
166, # Orange-red
|
2031
|
+
46, # Green
|
2032
|
+
201, # Magenta
|
2033
|
+
226, # Bright yellow
|
2034
|
+
39, # Cyan-blue
|
2035
|
+
129, # Purple
|
2036
|
+
208, # Dark orange
|
2037
|
+
82, # Bright green
|
2038
|
+
99, # Light purple-blue
|
2039
|
+
228 # Bright lime
|
2040
|
+
]
|
2041
|
+
|
2042
|
+
# Use a simple hash of the owner name to get consistent color assignment
|
2043
|
+
hash = owner.bytes.sum % colors.length
|
2044
|
+
colors[hash]
|
2045
|
+
end
|
2046
|
+
|
2047
|
+
def update_right_pane(fast_update = false)
|
2048
|
+
log_debug("Updating right pane for mode: #{@mode}, index: #{@index}, fast: #{fast_update}")
|
2049
|
+
|
2050
|
+
case @mode
|
2051
|
+
when :status
|
2052
|
+
show_file_diff(@status_items[@index][:file]) if @index >= 0 && @index < @status_items.length
|
2053
|
+
when :log
|
2054
|
+
show_commit_details(@log_entries[@index]) if @index >= 0 && @index < @log_entries.length
|
2055
|
+
when :branches
|
2056
|
+
# Handle branch details if needed
|
2057
|
+
when :github_repos
|
2058
|
+
log_debug("GitHub repos mode: showing repo #{@index}/#{@github_repos.length}")
|
2059
|
+
show_repo_details(@github_repos[@index], !fast_update) if @index >= 0 && @index < @github_repos.length
|
2060
|
+
when :github_issues
|
2061
|
+
show_issue_details(@github_issues[@index]) if @index >= 0 && @index < @github_issues.length
|
2062
|
+
when :github_prs
|
2063
|
+
show_pr_details(@github_prs[@index]) if @index >= 0 && @index < @github_prs.length
|
2064
|
+
when :github_search
|
2065
|
+
show_search_repo_details(@github_search_results[@index], !fast_update) if @index >= 0 && @index < @github_search_results.length
|
2066
|
+
end
|
2067
|
+
end
|
2068
|
+
|
2069
|
+
def adjust_left_pane_scroll
|
2070
|
+
# Implement scroll-off like RTFM/astropanel
|
2071
|
+
scrolloff = 3
|
2072
|
+
page_height = @p_left.h
|
2073
|
+
|
2074
|
+
# Store old scroll position to detect changes
|
2075
|
+
old_ix = @p_left.ix
|
2076
|
+
|
2077
|
+
# Handle wrapping cases
|
2078
|
+
if @index == @max_index && old_ix < @max_index - page_height + 1
|
2079
|
+
# When jumping to bottom from top area
|
2080
|
+
@p_left.ix = [@max_index - page_height + 1, 0].max
|
2081
|
+
elsif @index == @min_index && old_ix > 0
|
2082
|
+
# When jumping to top from bottom area
|
2083
|
+
@p_left.ix = 0
|
2084
|
+
# Normal scrolling with scrolloff
|
2085
|
+
elsif @index - @p_left.ix < scrolloff
|
2086
|
+
# If index is near the top of visible area
|
2087
|
+
@p_left.ix = [@index - scrolloff, 0].max
|
2088
|
+
elsif @p_left.ix + page_height - 1 - @index < scrolloff
|
2089
|
+
# If index is near the bottom of visible area
|
2090
|
+
max_scroll = [@max_index - page_height + 1, 0].max
|
2091
|
+
@p_left.ix = [@index + scrolloff - page_height + 1, max_scroll].min
|
2092
|
+
end
|
2093
|
+
|
2094
|
+
# Debug logging and refresh if scroll changed
|
2095
|
+
if @p_left.ix != old_ix
|
2096
|
+
log_debug("Scroll adjusted: index=#{@index}, ix=#{@p_left.ix} (was #{old_ix}), height=#{page_height}, max=#{@max_index}")
|
2097
|
+
@p_left.refresh # Force immediate refresh when scroll changes
|
2098
|
+
end
|
2099
|
+
end
|
2100
|
+
|
2101
|
+
def refresh_view
|
2102
|
+
case @mode
|
2103
|
+
when :status
|
2104
|
+
display_status
|
2105
|
+
when :log
|
2106
|
+
display_log
|
2107
|
+
when :branches
|
2108
|
+
display_branches
|
2109
|
+
when :github_repos
|
2110
|
+
display_github_repos
|
2111
|
+
when :github_issues
|
2112
|
+
display_github_issues
|
2113
|
+
when :github_prs
|
2114
|
+
display_github_prs
|
2115
|
+
when :github_search
|
2116
|
+
display_github_search_results
|
2117
|
+
end
|
2118
|
+
|
2119
|
+
@p_top.update = @p_left.update = @p_right.update = @p_bottom.update = true
|
2120
|
+
end
|
2121
|
+
|
2122
|
+
def show_help
|
2123
|
+
@p_right.clear
|
2124
|
+
@p_right.say(@help)
|
2125
|
+
end
|
2126
|
+
|
2127
|
+
def stage_file
|
2128
|
+
return unless @mode == :status && @index < @status_items.length
|
2129
|
+
|
2130
|
+
file = @status_items[@index][:file]
|
2131
|
+
`git add #{Shellwords.escape(file)}`
|
2132
|
+
git_status
|
2133
|
+
end
|
2134
|
+
|
2135
|
+
def unstage_file
|
2136
|
+
return unless @mode == :status && @index < @status_items.length
|
2137
|
+
|
2138
|
+
file = @status_items[@index][:file]
|
2139
|
+
`git reset HEAD #{Shellwords.escape(file)}`
|
2140
|
+
git_status
|
2141
|
+
end
|
2142
|
+
|
2143
|
+
def commit_changes
|
2144
|
+
@p_bottom.clear
|
2145
|
+
@p_bottom.say('Commit message: ')
|
2146
|
+
|
2147
|
+
# Simple input handling
|
2148
|
+
message = ''
|
2149
|
+
loop do
|
2150
|
+
chr = getchr
|
2151
|
+
case chr
|
2152
|
+
when 'ENTER'
|
2153
|
+
break
|
2154
|
+
when 'ESCAPE'
|
2155
|
+
@p_bottom.say('Commit cancelled')
|
2156
|
+
return
|
2157
|
+
when 'BACKSPACE'
|
2158
|
+
message.chop!
|
2159
|
+
@p_bottom.clear
|
2160
|
+
@p_bottom.say("Commit message: #{message}")
|
2161
|
+
else
|
2162
|
+
if chr && chr.length == 1
|
2163
|
+
message += chr
|
2164
|
+
@p_bottom.say("Commit message: #{message}")
|
2165
|
+
end
|
2166
|
+
end
|
2167
|
+
end
|
2168
|
+
|
2169
|
+
if message.strip.empty?
|
2170
|
+
@p_bottom.say('Commit cancelled - empty message')
|
2171
|
+
else
|
2172
|
+
result = `git commit -m #{Shellwords.escape(message)} 2>&1`
|
2173
|
+
@p_bottom.say(result.lines.first.strip)
|
2174
|
+
git_status
|
2175
|
+
end
|
2176
|
+
end
|
2177
|
+
|
2178
|
+
def command_mode
|
2179
|
+
@p_bottom.clear
|
2180
|
+
@p_bottom.say(':')
|
2181
|
+
|
2182
|
+
command = ''
|
2183
|
+
loop do
|
2184
|
+
chr = getchr
|
2185
|
+
case chr
|
2186
|
+
when 'ENTER'
|
2187
|
+
break
|
2188
|
+
when 'ESCAPE'
|
2189
|
+
@p_bottom.clear
|
2190
|
+
return
|
2191
|
+
when 'BACKSPACE'
|
2192
|
+
command.chop!
|
2193
|
+
@p_bottom.clear
|
2194
|
+
@p_bottom.say(":#{command}")
|
2195
|
+
else
|
2196
|
+
if chr && chr.length == 1
|
2197
|
+
command += chr
|
2198
|
+
@p_bottom.say(":#{command}")
|
2199
|
+
end
|
2200
|
+
end
|
2201
|
+
end
|
2202
|
+
|
2203
|
+
# Execute command
|
2204
|
+
if command.strip.empty?
|
2205
|
+
@p_bottom.clear
|
2206
|
+
elsif command.strip.start_with?('cd ')
|
2207
|
+
# Handle directory change
|
2208
|
+
path = command.strip[3..] # Remove 'cd ' prefix
|
2209
|
+
path = File.expand_path(path) if path && !path.empty?
|
2210
|
+
|
2211
|
+
if path && Dir.exist?(path)
|
2212
|
+
begin
|
2213
|
+
Dir.chdir(path)
|
2214
|
+
@p_bottom.say("Changed to: #{Dir.pwd}")
|
2215
|
+
|
2216
|
+
# Check if new directory is a git repo and refresh
|
2217
|
+
if system('git rev-parse --git-dir > /dev/null 2>&1')
|
2218
|
+
git_status # Refresh to show new repo
|
2219
|
+
else
|
2220
|
+
@p_left.clear
|
2221
|
+
@p_right.clear
|
2222
|
+
@p_left.say("Not a git repository. Use 'git init' to initialize.")
|
2223
|
+
end
|
2224
|
+
rescue => e
|
2225
|
+
@p_bottom.say("Error: #{e.message}")
|
2226
|
+
end
|
2227
|
+
else
|
2228
|
+
@p_bottom.say("Error: Directory '#{path}' does not exist")
|
2229
|
+
end
|
2230
|
+
else
|
2231
|
+
# Execute git command
|
2232
|
+
result = `git #{command} 2>&1`
|
2233
|
+
@p_right.clear
|
2234
|
+
@p_right.say(result)
|
2235
|
+
end
|
2236
|
+
end
|
2237
|
+
|
2238
|
+
def shell_command
|
2239
|
+
@p_bottom.clear
|
2240
|
+
@p_bottom.say('!')
|
2241
|
+
|
2242
|
+
command = ''
|
2243
|
+
loop do
|
2244
|
+
chr = getchr
|
2245
|
+
case chr
|
2246
|
+
when 'ENTER'
|
2247
|
+
break
|
2248
|
+
when 'ESCAPE'
|
2249
|
+
@p_bottom.clear
|
2250
|
+
return
|
2251
|
+
when 'BACKSPACE'
|
2252
|
+
command.chop!
|
2253
|
+
@p_bottom.clear
|
2254
|
+
@p_bottom.say("!#{command}")
|
2255
|
+
else
|
2256
|
+
if chr && chr.length == 1
|
2257
|
+
command += chr
|
2258
|
+
@p_bottom.say("!#{command}")
|
2259
|
+
end
|
2260
|
+
end
|
2261
|
+
end
|
2262
|
+
|
2263
|
+
# Execute shell command
|
2264
|
+
if command.strip.empty?
|
2265
|
+
@p_bottom.clear
|
2266
|
+
else
|
2267
|
+
result = `#{command} 2>&1`
|
2268
|
+
@p_right.clear
|
2269
|
+
@p_right.say(result)
|
2270
|
+
end
|
2271
|
+
end
|
2272
|
+
|
2273
|
+
def git_diff
|
2274
|
+
case @mode
|
2275
|
+
when :status
|
2276
|
+
if @index < @status_items.length
|
2277
|
+
file = @status_items[@index][:file]
|
2278
|
+
diff = `git diff #{Shellwords.escape(file)} 2>/dev/null`
|
2279
|
+
|
2280
|
+
@p_right.clear
|
2281
|
+
if diff.empty?
|
2282
|
+
@p_right.say('No unstaged changes'.fg(245))
|
2283
|
+
else
|
2284
|
+
formatted_diff = ''
|
2285
|
+
diff.lines.each do |line|
|
2286
|
+
formatted_diff += case line[0]
|
2287
|
+
when '+'
|
2288
|
+
line.fg(154)
|
2289
|
+
when '-'
|
2290
|
+
line.fg(196)
|
2291
|
+
when '@'
|
2292
|
+
line.fg(51)
|
2293
|
+
else
|
2294
|
+
line
|
2295
|
+
end
|
2296
|
+
end
|
2297
|
+
@p_right.say(formatted_diff)
|
2298
|
+
end
|
2299
|
+
end
|
2300
|
+
else
|
2301
|
+
# Show full diff
|
2302
|
+
diff = `git diff 2>/dev/null`
|
2303
|
+
|
2304
|
+
@p_right.clear
|
2305
|
+
if diff.empty?
|
2306
|
+
@p_right.say('No changes'.fg(245))
|
2307
|
+
else
|
2308
|
+
formatted_diff = ''
|
2309
|
+
diff.lines.each do |line|
|
2310
|
+
formatted_diff += case line[0]
|
2311
|
+
when '+'
|
2312
|
+
line.fg(154)
|
2313
|
+
when '-'
|
2314
|
+
line.fg(196)
|
2315
|
+
when '@'
|
2316
|
+
line.fg(51)
|
2317
|
+
else
|
2318
|
+
line
|
2319
|
+
end
|
2320
|
+
end
|
2321
|
+
@p_right.say(formatted_diff)
|
2322
|
+
end
|
2323
|
+
end
|
2324
|
+
end
|
2325
|
+
|
2326
|
+
def change_width
|
2327
|
+
@width_setting += 1
|
2328
|
+
@width_setting = 2 if @width_setting > 7
|
2329
|
+
|
2330
|
+
left_ratio = [0.2, 0.3, 0.4, 0.5, 0.6, 0.7][@width_setting - 2] || 0.4
|
2331
|
+
@p_bottom.say("Width: #{@width_setting} (Left: #{(left_ratio * 100).to_i}%, Right: #{((1 - left_ratio) * 100).to_i}%)")
|
2332
|
+
|
2333
|
+
# Save the new setting
|
2334
|
+
save_config
|
2335
|
+
|
2336
|
+
# Recreate panes with new dimensions
|
2337
|
+
init_windows
|
2338
|
+
refresh_view
|
2339
|
+
end
|
2340
|
+
|
2341
|
+
def quit
|
2342
|
+
log_debug('Quitting...')
|
2343
|
+
Cursor.show
|
2344
|
+
Rcurses.clear_screen
|
2345
|
+
exit 0
|
2346
|
+
end
|
2347
|
+
|
2348
|
+
# MAIN LOOP {{{1
|
2349
|
+
def render
|
2350
|
+
# Selective refresh like astropanel/RTFM
|
2351
|
+
@p_top.refresh if @p_top.update
|
2352
|
+
@p_left.refresh if @p_left.update
|
2353
|
+
@p_right.refresh if @p_right.update
|
2354
|
+
@p_bottom.refresh if @p_bottom.update
|
2355
|
+
end
|
2356
|
+
|
2357
|
+
def main_loop
|
2358
|
+
log_debug('Entered main loop')
|
2359
|
+
loop_count = 0
|
2360
|
+
loop do
|
2361
|
+
|
2362
|
+
loop_count += 1
|
2363
|
+
log_debug("Main loop iteration #{loop_count}") if loop_count % 100 == 1
|
2364
|
+
render
|
2365
|
+
chr = getkey
|
2366
|
+
handle_key(chr)
|
2367
|
+
rescue => e
|
2368
|
+
log_debug("Error in main loop: #{e.class}: #{e.message}")
|
2369
|
+
@p_bottom.say("Error: #{e.message}") if @p_bottom
|
2370
|
+
sleep 1
|
2371
|
+
|
2372
|
+
end
|
2373
|
+
rescue Interrupt
|
2374
|
+
log_debug('Received interrupt')
|
2375
|
+
quit
|
2376
|
+
end
|
2377
|
+
|
2378
|
+
# MAIN {{{1
|
2379
|
+
begin
|
2380
|
+
log_debug('Checking TTY...')
|
2381
|
+
# Check if we're in a terminal
|
2382
|
+
unless $stdin.tty?
|
2383
|
+
log_debug('Not in TTY, exiting')
|
2384
|
+
puts 'GiTerm requires a TTY terminal to run'
|
2385
|
+
exit 1
|
2386
|
+
end
|
2387
|
+
log_debug('TTY check passed')
|
2388
|
+
|
2389
|
+
log_debug('Checking Git repository...')
|
2390
|
+
# Check if we're in a Git repository
|
2391
|
+
unless system('git rev-parse --git-dir >/dev/null 2>&1')
|
2392
|
+
log_debug('Not in Git repo, exiting')
|
2393
|
+
puts 'GiTerm must be run from within a Git repository'
|
2394
|
+
exit 1
|
2395
|
+
end
|
2396
|
+
log_debug('Git repo check passed')
|
2397
|
+
|
2398
|
+
log_debug('Loading configuration...')
|
2399
|
+
load_config
|
2400
|
+
log_debug('Config loaded')
|
2401
|
+
|
2402
|
+
log_debug('Initializing windows...')
|
2403
|
+
init_windows
|
2404
|
+
log_debug('Windows initialized')
|
2405
|
+
|
2406
|
+
log_debug('Getting git status...')
|
2407
|
+
git_status # Start with git status view
|
2408
|
+
log_debug('Git status retrieved')
|
2409
|
+
|
2410
|
+
update_bottom_legends
|
2411
|
+
log_debug('Starting main loop...')
|
2412
|
+
main_loop
|
2413
|
+
rescue => e
|
2414
|
+
log_debug("Fatal error: #{e.class}: #{e.message}")
|
2415
|
+
log_debug("Backtrace: #{e.backtrace.first(3).join(' | ')}")
|
2416
|
+
puts "Fatal error: #{e.message} (see #{LOG_FILE})"
|
2417
|
+
Cursor.show rescue nil
|
2418
|
+
exit 1
|
2419
|
+
ensure
|
2420
|
+
log_debug('Ensuring cleanup...')
|
2421
|
+
Cursor.show rescue nil
|
2422
|
+
end
|
2423
|
+
# vim: set sw=2 sts=2 et fdm=marker:
|