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.
Files changed (5) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +24 -0
  3. data/README.md +175 -0
  4. data/giterm +2423 -0
  5. 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: