Fortran · 70917 bytes Raw Blame History
1 program fuss
2 use iso_fortran_env, only: error_unit
3 use types_module
4 use git_module
5 use tree_module
6 use display_module
7 use terminal_module
8 use cache_module
9 implicit none
10
11 ! Main program variables
12 logical :: show_all, interactive
13 character(len=:), allocatable :: root_path
14
15 ! Initialize caches for performance optimization
16 call init_caches()
17
18 ! Parse command line arguments
19 call parse_arguments(show_all, interactive)
20
21 ! Get current directory
22 call get_current_dir(root_path)
23
24 ! Build and display tree
25 if (interactive) then
26 call interactive_mode(show_all)
27 else
28 call build_and_display_tree(show_all)
29 end if
30
31 ! Ensure terminal is always restored (safety cleanup)
32 call cleanup_terminal()
33
34 contains
35
36 subroutine parse_arguments(show_all, interactive)
37 logical, intent(out) :: show_all, interactive
38 integer :: i, nargs
39 character(len=256) :: arg
40 logical :: print_only
41
42 show_all = .false.
43 interactive = .true. ! Interactive is now the default
44 print_only = .false.
45 nargs = command_argument_count()
46
47 do i = 1, nargs
48 call get_command_argument(i, arg)
49 if (trim(arg) == '--help' .or. trim(arg) == '-h') then
50 call print_help()
51 stop
52 else if (trim(arg) == '--version' .or. trim(arg) == '-v') then
53 call print_version()
54 stop
55 else if (trim(arg) == '--all' .or. trim(arg) == '-a') then
56 show_all = .true.
57 else if (trim(arg) == '-i' .or. trim(arg) == '--interactive') then
58 interactive = .true.
59 else if (trim(arg) == '-p' .or. trim(arg) == '--print') then
60 print_only = .true.
61 else
62 print '(A)', 'Error: Unknown option: ' // trim(arg)
63 print '(A)', 'Run ''fuss --help'' for usage information'
64 stop 1
65 end if
66 end do
67
68 ! If print_only is set, disable interactive mode
69 if (print_only) then
70 interactive = .false.
71 end if
72 end subroutine parse_arguments
73
74 subroutine print_version()
75 print '(A)', 'fuss v1.0.0'
76 print '(A)', ''
77 print '(A)', 'A git staging tool. Written in Fortran, for some reason.'
78 print '(A)', 'https://github.com/FortranGoingOnForty/fuss'
79 end subroutine print_version
80
81 subroutine print_help()
82 print '(A)', 'fuss - git staging with a tree view'
83 print '(A)', ''
84 print '(A)', 'USAGE:'
85 print '(A)', ' fuss [OPTIONS]'
86 print '(A)', ''
87 print '(A)', 'OPTIONS:'
88 print '(A)', ' -h, --help Show this'
89 print '(A)', ' -v, --version Show version'
90 print '(A)', ' -p, --print Print tree and exit (non-interactive)'
91 print '(A)', ' -a, --all Show all files, not just dirty'
92 print '(A)', ''
93 print '(A)', 'KEYS:'
94 print '(A)', ' j/k, ↑/↓ Navigate up/down'
95 print '(A)', ' ←/→ Parent/child directory'
96 print '(A)', ' space Toggle directory'
97 print '(A)', ' a/u Stage/unstage file'
98 print '(A)', ' S/U Stage/unstage all'
99 print '(A)', ' m Commit'
100 print '(A)', ' M Amend commit'
101 print '(A)', ' p/l/f Push/pull/fetch'
102 print '(A)', ' d Diff'
103 print '(A)', ' c View file'
104 print '(A)', ' w Blame'
105 print '(A)', ' h History'
106 print '(A)', ' L Reflog'
107 print '(A)', ' y Cherry-pick'
108 print '(A)', ' v Revert commit'
109 print '(A)', ' x Discard changes'
110 print '(A)', ' b Switch branch'
111 print '(A)', ' n New branch'
112 print '(A)', ' R Delete branch'
113 print '(A)', ' G Merge branch'
114 print '(A)', ' O Reset'
115 print '(A)', ' I Interactive rebase'
116 print '(A)', ' z/Z Stash/pop'
117 print '(A)', ' t Tag'
118 print '(A)', ' r Delete file'
119 print '(A)', ' s Status'
120 print '(A)', ' . Toggle dotfiles'
121 print '(A)', ' q Quit'
122 print '(A)', ''
123 end subroutine print_help
124
125 subroutine get_current_dir(path)
126 character(len=:), allocatable, intent(out) :: path
127 character(len=1024) :: buffer
128 integer :: status
129
130 call execute_command_line('pwd > /tmp/fuss_tmp.txt', exitstat=status)
131
132 open(unit=99, file='/tmp/fuss_tmp.txt', status='old', action='read')
133 read(99, '(A)') buffer
134 close(99, status='delete')
135
136 path = trim(buffer)
137 end subroutine get_current_dir
138
139 subroutine build_and_display_tree(show_all)
140 logical, intent(in) :: show_all
141 type(file_entry), allocatable :: files(:)
142 integer :: n_files
143
144 ! Get files from git or filesystem
145 if (show_all) then
146 call get_all_files(files, n_files)
147 else
148 call get_dirty_files(files, n_files)
149 end if
150
151 ! Mark files with incoming changes
152 call mark_incoming_changes(files, n_files)
153
154 ! Display the tree
155 if (n_files > 0) then
156 print '(A)', '.'
157 call display_tree(files, n_files)
158 else
159 print '(A)', 'No files to display'
160 end if
161 end subroutine build_and_display_tree
162
163 subroutine cleanup_terminal()
164 ! Emergency cleanup - restores terminal to normal state
165 ! Call this before any exit or when calling external programs
166 call disable_raw_mode()
167 call exit_alternate_screen()
168 end subroutine cleanup_terminal
169
170 subroutine interactive_mode(show_all)
171 logical, intent(in) :: show_all
172 type(file_entry), allocatable :: files(:)
173 type(selectable_item), allocatable :: items(:)
174 integer :: n_files, n_items, selected
175 character(len=1) :: key
176 logical :: running, hide_dotfiles
177 character(len=256) :: repo_name, branch_name, term_program
178 integer :: term_height, viewport_offset, visible_items, top_padding
179 integer :: prev_selected, prev_viewport
180 logical :: needs_full_redraw
181 character(len=10) :: mode ! "normal" or "git" mode
182 ! Search state for fuzzy jump
183 character(len=32) :: search_buffer
184 integer :: search_length
185 integer(8) :: last_search_tick, current_tick, clock_rate
186 type(tree_node), pointer :: tree_root
187
188 ! Initialize tree pointer
189 tree_root => null()
190
191 ! Detect terminal type for padding (fixes WezTerm/Ghostty/iTerm top line cutoff)
192 ! Alternate screen buffer needs more padding to prevent top cutoff
193 call get_environment_variable("TERM_PROGRAM", term_program)
194 if (index(term_program, "iTerm") > 0) then
195 top_padding = 4 ! iTerm2 needs 4 lines in alternate screen
196 else if (index(term_program, "WezTerm") > 0 .or. index(term_program, "ghostty") > 0) then
197 top_padding = 3 ! WezTerm/Ghostty need 3 lines in alternate screen
198 else if (index(term_program, "Apple_Terminal") > 0) then
199 top_padding = 3 ! Terminal.app needs 3 lines
200 else
201 top_padding = 2 ! Other terminals need 2 lines
202 end if
203
204 ! Get repo and branch info
205 call get_repo_info(repo_name, branch_name)
206
207 ! Get terminal height
208 call get_terminal_height(term_height)
209
210 ! DEBUG: Show terminal height
211 ! print '(A,I0)', 'DEBUG: Terminal height detected: ', term_height
212
213 ! Initialize hide_dotfiles before first use
214 hide_dotfiles = .false.
215
216 ! Get files and mark incoming changes
217 if (show_all) then
218 call get_all_files(files, n_files)
219 else
220 call get_dirty_files(files, n_files)
221 end if
222 call mark_incoming_changes(files, n_files)
223
224 if (n_files == 0) then
225 print '(A)', 'No files to display'
226 return
227 end if
228
229 ! Build flat list of items for navigation
230 call build_item_list(files, n_files, items, n_items, tree_root, hide_dotfiles)
231
232 ! Calculate visible items accurately
233 ! Fixed UI elements that take screen space:
234 ! top_padding lines: blank padding for terminal compatibility (2-3 lines)
235 ! 1 line: repo:branch (e.g., "fuss:trunk")
236 ! 1 line: blank line after repo
237 ! 1 line: "." root
238 ! Lines X to N-3: tree items (VIEWPORT)
239 ! 1 line: blank line before help
240 ! 1 line: help legend (↑=staged ✗=modified ✗=untracked)
241 ! 1 line: help controls (j/k/↓/↑: navigate | ...)
242 ! Total fixed: top_padding + 6 lines
243 visible_items = term_height - top_padding - 6
244 if (visible_items < 3) visible_items = 3 ! Absolute minimum
245 if (visible_items > n_items) visible_items = n_items ! Don't exceed total items
246
247 ! Initialize selection and viewport at TOP of tree
248 selected = 1
249 viewport_offset = 1
250 running = .true.
251 mode = 'normal' ! Start in normal mode
252
253 ! Initialize search state
254 search_buffer = ''
255 search_length = 0
256 last_search_tick = 0
257 call system_clock(count_rate=clock_rate)
258
259 ! Partial redraw optimization: initialize tracking state
260 prev_selected = 0 ! Force initial draw
261 prev_viewport = 0
262 needs_full_redraw = .true.
263
264 ! Enter alternate screen buffer (preserves terminal content)
265 call enter_alternate_screen()
266
267 ! Enable raw terminal mode
268 call enable_raw_mode()
269
270 ! Main interactive loop
271 do while (running)
272 ! Center viewport on selection - keeps highlighted item in middle of screen
273 viewport_offset = selected - visible_items / 2
274
275 ! Clamp viewport to valid range
276 if (viewport_offset < 1) viewport_offset = 1
277 if (viewport_offset > n_items - visible_items + 1 .and. n_items > visible_items) then
278 viewport_offset = n_items - visible_items + 1
279 end if
280
281 ! Conditional redraw for performance optimization
282 if (needs_full_redraw .or. viewport_offset /= prev_viewport) then
283 ! Full redraw needed: viewport scrolled or forced refresh
284 call clear_screen()
285 call draw_interactive_tree(tree_root, items, n_items, selected, &
286 repo_name, branch_name, viewport_offset, visible_items, top_padding, mode)
287 needs_full_redraw = .false.
288 else if (selected /= prev_selected) then
289 ! Only selection changed within same viewport - still need full redraw for now
290 ! TODO: Could optimize this with partial line updates in the future
291 call clear_screen()
292 call draw_interactive_tree(tree_root, items, n_items, selected, &
293 repo_name, branch_name, viewport_offset, visible_items, top_padding, mode)
294 end if
295
296 ! Update tracking state
297 prev_selected = selected
298 prev_viewport = viewport_offset
299
300 ! Check search timeout (0.5 seconds)
301 if (search_length > 0) then
302 call system_clock(current_tick)
303 ! Check if 0.5 seconds has elapsed (clock_rate/2 ticks)
304 if (current_tick - last_search_tick > clock_rate / 2) then
305 search_length = 0
306 search_buffer = ''
307 needs_full_redraw = .true.
308 end if
309 end if
310
311 ! Always use fast blocking read - timeouts are too slow
312 call read_key(key)
313
314 ! DEBUG: Log all control characters to see what we're getting
315 if (ichar(key) < 32) then
316 open(99, file='/tmp/fuss_debug.log', position='append')
317 write(99, '(A,I0)') 'Control char received: ', ichar(key)
318 close(99)
319 end if
320
321 ! Check for ctrl-c to quit (priority over everything)
322 if (key == achar(3)) then
323 open(99, file='/tmp/fuss_debug.log', position='append')
324 write(99, '(A)') 'CTRL-C detected - quitting!'
325 close(99)
326 running = .false.
327 cycle
328 end if
329
330 ! Check for alt-g to toggle git mode
331 ! alt-g is encoded as achar(1 + ichar('g') - ichar('a')) = achar(7)
332 if (key == achar(7)) then
333 ! Toggle between normal and git mode
334 if (mode == 'normal') then
335 mode = 'git'
336 else
337 mode = 'normal'
338 end if
339 ! Temporarily restore terminal to flush output properly
340 call execute_command_line('stty sane < /dev/tty')
341 call clear_screen()
342 call draw_interactive_tree(tree_root, items, n_items, selected, &
343 repo_name, branch_name, viewport_offset, visible_items, top_padding, mode)
344 ! Restore cbreak mode
345 call enable_raw_mode()
346 cycle ! Skip rest of key handling
347 end if
348
349 ! Check for alt-s to show git status (available in both modes)
350 ! alt-s is encoded as achar(1 + ichar('s') - ichar('a')) = achar(19)
351 if (key == achar(19)) then
352 call show_status_view()
353 needs_full_redraw = .true.
354 cycle
355 end if
356
357 ! Check for alt-v to view file (available in both modes)
358 ! alt-v is encoded as achar(1 + ichar('v') - ichar('a')) = achar(22)
359 if (key == achar(22)) then
360 if (items(selected)%is_file) then
361 call view_file(items(selected)%path)
362 needs_full_redraw = .true.
363 end if
364 cycle
365 end if
366
367 ! Handle ESC key - exit git mode or clear search
368 if (key == achar(27)) then
369 if (mode == 'git') then
370 mode = 'normal'
371 ! Temporarily restore terminal to flush output properly
372 call execute_command_line('stty sane < /dev/tty')
373 call clear_screen()
374 call draw_interactive_tree(tree_root, items, n_items, selected, &
375 repo_name, branch_name, viewport_offset, visible_items, top_padding, mode)
376 ! Restore cbreak mode
377 call enable_raw_mode()
378 cycle
379 else if (search_length > 0) then
380 ! Clear search in normal mode
381 search_length = 0
382 search_buffer = ''
383 needs_full_redraw = .true.
384 cycle
385 end if
386 ! In normal mode, ESC does nothing for now
387 cycle
388 end if
389
390 ! Fuzzy search in normal mode - handle any printable character
391 ! Exclude A, B, C, D since those are arrow key codes after escape sequence processing
392 if (mode == 'normal') then
393 if ((key >= 'a' .and. key <= 'z') .or. &
394 ((key >= 'E' .and. key <= 'Z') .or. (key >= '0' .and. key <= '9')) .or. &
395 key == '_' .or. key == '-' .or. key == '.') then
396
397 ! Check if timeout elapsed since last keypress - if so, start fresh search
398 if (search_length > 0) then
399 call system_clock(current_tick)
400 if (current_tick - last_search_tick > clock_rate / 2) then
401 ! Timeout elapsed (0.5 seconds) - clear buffer and start new search
402 search_length = 0
403 search_buffer = ''
404 ! DEBUG
405 open(99, file='/tmp/fuss_debug.log', position='append')
406 write(99, '(A)') 'TIMEOUT: Starting fresh search (0.5s elapsed)'
407 close(99)
408 end if
409 end if
410
411 ! Add to search buffer
412 if (search_length < 32) then
413 search_length = search_length + 1
414 search_buffer(search_length:search_length) = key
415 call system_clock(last_search_tick)
416
417 ! DEBUG
418 open(99, file='/tmp/fuss_debug.log', position='append')
419 write(99, '(A,A,A,I0)') 'Buffer: "', search_buffer(1:search_length), '" -> jumping to match'
420 close(99)
421
422 call fuzzy_jump_to_match(items, n_items, search_buffer(1:search_length), selected)
423
424 needs_full_redraw = .true.
425 end if
426 cycle ! Skip case statement
427 else if (key == achar(127) .or. key == achar(8)) then
428 ! Backspace - remove last character
429 if (search_length > 0) then
430 search_length = search_length - 1
431 call system_clock(last_search_tick)
432 if (search_length > 0) then
433 call fuzzy_jump_to_match(items, n_items, search_buffer(1:search_length), selected)
434 end if
435 needs_full_redraw = .true.
436 end if
437 cycle ! Skip case statement
438 end if
439 end if
440
441 ! Handle input
442 select case (key)
443 case ('j', 'B') ! j or down arrow - navigate to next sibling (skip nested items)
444 ! Clear search buffer on navigation
445 if (search_length > 0) then
446 search_length = 0
447 search_buffer = ''
448 end if
449 call navigate_down(items, n_items, selected)
450 case ('k', 'A') ! k or up arrow - navigate to previous sibling (skip nested items)
451 ! Clear search buffer on navigation
452 if (search_length > 0) then
453 search_length = 0
454 search_buffer = ''
455 end if
456 call navigate_up(items, n_items, selected)
457 case ('D') ! Left arrow - navigate to parent directory
458 ! Clear search buffer on navigation
459 if (search_length > 0) then
460 search_length = 0
461 search_buffer = ''
462 end if
463 call navigate_left(items, n_items, selected)
464 case ('C') ! Right arrow - enter directory
465 ! Clear search buffer on navigation
466 if (search_length > 0) then
467 search_length = 0
468 search_buffer = ''
469 end if
470 call navigate_right(items, n_items, selected, tree_root, hide_dotfiles)
471 case (' ') ! Space bar - toggle expand/collapse
472 ! Clear search buffer on navigation
473 if (search_length > 0) then
474 search_length = 0
475 search_buffer = ''
476 end if
477 if (.not. items(selected)%is_file .and. associated(items(selected)%node)) then
478 ! Toggle the expanded state
479 items(selected)%node%is_expanded = .not. items(selected)%node%is_expanded
480 ! Rebuild item list to reflect change
481 call rebuild_item_list_from_tree(tree_root, items, n_items, hide_dotfiles)
482 ! Adjust selection if needed
483 if (selected > n_items .and. n_items > 0) selected = n_items
484 ! Force full redraw after tree structure change
485 needs_full_redraw = .true.
486 end if
487 ! Git operations - only available in git mode
488 case ('a') ! Stage file or directory (lowercase to avoid conflict with arrow A)
489 if (mode == 'git') then
490 ! Check if it's a directory - stage all files in it
491 if (.not. items(selected)%is_file) then
492 call git_stage_directory(items(selected)%path)
493 ! Refresh files after staging directory
494 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
495 hide_dotfiles, selected, running, exit_if_empty=.true., force_refresh=.true.)
496 needs_full_redraw = .true.
497 ! Otherwise it's a file - stage individual file
498 else if (items(selected)%is_file .and. (items(selected)%is_unstaged .or. items(selected)%is_untracked)) then
499 call git_add_file(items(selected)%path)
500 ! Refresh files after git add
501 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
502 hide_dotfiles, selected, running, exit_if_empty=.true., force_refresh=.true.)
503 needs_full_redraw = .true.
504 end if
505 end if
506 case ('u') ! Unstage file (lowercase)
507 if (mode == 'git' .and. items(selected)%is_file .and. items(selected)%is_staged) then
508 call git_unstage_file(items(selected)%path)
509 ! Refresh files after git unstage
510 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
511 hide_dotfiles, selected, running, force_refresh=.true.)
512 needs_full_redraw = .true.
513 end if
514 case ('S') ! Stage all (Shift+S to avoid conflict with up arrow 'A')
515 if (mode == 'git') then
516 call git_stage_all()
517 ! Refresh files after staging all
518 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
519 hide_dotfiles, selected, running, exit_if_empty=.true., force_refresh=.true.)
520 needs_full_redraw = .true.
521 end if
522 case ('U') ! Unstage all (Shift+U)
523 if (mode == 'git') then
524 call git_unstage_all()
525 ! Refresh files after unstaging all
526 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
527 hide_dotfiles, selected, running, force_refresh=.true.)
528 needs_full_redraw = .true.
529 end if
530 case ('m') ! Commit (lowercase)
531 if (mode == 'git') then
532 call commit_prompt()
533 ! Refresh files after commit
534 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
535 hide_dotfiles, selected, running, force_refresh=.true.)
536 needs_full_redraw = .true.
537 end if
538 case ('M') ! Amend last commit (Shift+m)
539 if (mode == 'git') then
540 call amend_commit_prompt()
541 ! Refresh files after amend commit
542 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
543 hide_dotfiles, selected, running, force_refresh=.true.)
544 needs_full_redraw = .true.
545 end if
546 case ('p') ! Push (lowercase)
547 if (mode == 'git') then
548 call push_prompt()
549 ! Refresh files after push
550 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
551 hide_dotfiles, selected, running, force_refresh=.true.)
552 needs_full_redraw = .true.
553 end if
554 case ('t') ! Tag (lowercase)
555 if (mode == 'git') then
556 call tag_prompt()
557 needs_full_redraw = .true.
558 end if
559 case ('b') ! Switch branch
560 if (mode == 'git') then
561 call branch_switch_prompt()
562 ! Refresh files after branch switch
563 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
564 hide_dotfiles, selected, running, exit_if_empty=.true., force_refresh=.true.)
565 needs_full_redraw = .true.
566 ! Update branch name display
567 call get_repo_info(repo_name, branch_name)
568 end if
569 case ('n') ! Create new branch
570 if (mode == 'git') then
571 call branch_create_prompt()
572 ! Refresh files after branch creation
573 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
574 hide_dotfiles, selected, running, exit_if_empty=.true., force_refresh=.true.)
575 needs_full_redraw = .true.
576 ! Update branch name display
577 call get_repo_info(repo_name, branch_name)
578 end if
579 case ('R') ! Delete branch (Shift+r, since 'r' is used for delete file)
580 if (mode == 'git') then
581 call branch_delete_prompt()
582 needs_full_redraw = .true.
583 ! No need to refresh files or update branch name (stays on current branch)
584 end if
585 case ('f') ! Git fetch
586 if (mode == 'git') then
587 call git_fetch()
588 ! Refresh files after fetch and include files with incoming changes
589 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
590 hide_dotfiles, selected, running, include_incoming=.true., force_refresh=.true.)
591 needs_full_redraw = .true.
592 end if
593 case ('d') ! Git diff with less
594 if (mode == 'git' .and. items(selected)%is_file) then
595 call git_diff_file(items(selected)%path, items(selected)%has_incoming)
596 needs_full_redraw = .true.
597 end if
598 case ('c') ! View file contents (git mode shortcut; use alt-v in normal mode)
599 if (mode == 'git' .and. items(selected)%is_file) then
600 call view_file(items(selected)%path)
601 needs_full_redraw = .true.
602 end if
603 case ('s') ! Show git status (git mode shortcut; use alt-s in normal mode)
604 if (mode == 'git') then
605 call show_status_view()
606 needs_full_redraw = .true.
607 end if
608 case ('w') ! Git blame (who changed this line)
609 if (mode == 'git' .and. items(selected)%is_file) then
610 call blame_prompt(items(selected)%path)
611 needs_full_redraw = .true.
612 end if
613 case ('r') ! Remove/delete file
614 if (mode == 'git' .and. items(selected)%is_file) then
615 call delete_prompt(items(selected)%path, items(selected)%is_untracked)
616 ! Refresh files after delete
617 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
618 hide_dotfiles, selected, running, exit_if_empty=.true., force_refresh=.true.)
619 needs_full_redraw = .true.
620 end if
621 case ('x', 'X') ! Discard changes
622 if (mode == 'git' .and. items(selected)%is_file .and. (items(selected)%is_staged .or. items(selected)%is_unstaged .or. items(selected)%is_untracked)) then
623 call discard_prompt(items(selected)%path, items(selected)%is_staged, items(selected)%is_untracked)
624 ! Refresh files after discard
625 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
626 hide_dotfiles, selected, running, exit_if_empty=.true., force_refresh=.true.)
627 needs_full_redraw = .true.
628 end if
629 case ('l') ! Git pull
630 if (mode == 'git') then
631 call git_pull()
632 ! Refresh files after pull (incoming indicators will automatically clear)
633 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
634 hide_dotfiles, selected, running, include_incoming=.true., force_refresh=.true.)
635 needs_full_redraw = .true.
636 ! Note: After successful pull, git diff will show no upstream differences
637 ! so has_incoming will be .false. for all files automatically
638 end if
639 case ('z') ! Stash push (save changes)
640 if (mode == 'git') then
641 call stash_push_prompt()
642 ! Refresh files after stash
643 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
644 hide_dotfiles, selected, running, exit_if_empty=.true., force_refresh=.true.)
645 needs_full_redraw = .true.
646 end if
647 case ('Z') ! Stash pop/apply (restore changes)
648 if (mode == 'git') then
649 call stash_pop_apply_prompt()
650 ! Refresh files after stash pop/apply
651 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
652 hide_dotfiles, selected, running, force_refresh=.true.)
653 needs_full_redraw = .true.
654 end if
655 case ('y') ! Cherry-pick (yank commit)
656 if (mode == 'git') then
657 call cherry_pick_prompt()
658 ! Refresh files after cherry-pick
659 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
660 hide_dotfiles, selected, running, force_refresh=.true.)
661 needs_full_redraw = .true.
662 end if
663 case ('v') ! Revert commit
664 if (mode == 'git') then
665 call revert_commit_prompt()
666 ! Refresh files after revert
667 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
668 hide_dotfiles, selected, running, force_refresh=.true.)
669 needs_full_redraw = .true.
670 end if
671 case ('h') ! Show commit history
672 if (mode == 'git') then
673 call history_browser_prompt()
674 needs_full_redraw = .true.
675 end if
676 case ('L') ! Show reflog (Shift+l)
677 if (mode == 'git') then
678 call reflog_browser_prompt()
679 needs_full_redraw = .true.
680 end if
681 case ('G') ! Merge branch (Shift+g)
682 if (mode == 'git') then
683 call merge_branch_prompt()
684 ! Refresh files after merge
685 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
686 hide_dotfiles, selected, running, force_refresh=.true.)
687 needs_full_redraw = .true.
688 ! Update branch name display in case we merged
689 call get_repo_info(repo_name, branch_name)
690 end if
691 case ('O') ! Reset (Shift+o - "Oh no, undo!")
692 if (mode == 'git') then
693 call reset_prompt()
694 ! Refresh files after reset
695 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
696 hide_dotfiles, selected, running, force_refresh=.true.)
697 needs_full_redraw = .true.
698 end if
699 case ('I') ! Interactive rebase (Shift+i)
700 if (mode == 'git') then
701 call rebase_prompt()
702 ! Refresh files after rebase
703 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
704 hide_dotfiles, selected, running, force_refresh=.true.)
705 needs_full_redraw = .true.
706 end if
707 case ('.') ! Toggle hiding dotfiles and gitignored files
708 hide_dotfiles = .not. hide_dotfiles
709 ! Rebuild item list with new filter
710 call build_item_list(files, n_files, items, n_items, tree_root, hide_dotfiles)
711 ! Adjust selection and visible_items for new item count
712 if (selected > n_items .and. n_items > 0) selected = n_items
713 if (n_items > 0 .and. selected < 1) selected = 1
714 ! Force full redraw after filter change
715 needs_full_redraw = .true.
716 ! Recalculate visible_items in case n_items changed
717 visible_items = term_height - top_padding - 6
718 if (visible_items < 3) visible_items = 3
719 if (visible_items > n_items) visible_items = n_items
720 case ('q', 'Q') ! Exit git mode
721 if (mode == 'git') then
722 ! In git mode: q exits to normal mode
723 mode = 'normal'
724 needs_full_redraw = .true.
725 end if
726 ! Note: In normal mode, 'q' is used for fuzzy search
727 ! Use ctrl-c to quit from normal mode
728 case default
729 ! Unhandled keys - do nothing
730 continue
731 end select
732 end do
733
734 ! Restore terminal to normal state
735 call cleanup_terminal()
736
737 ! Free the tree
738 if (associated(tree_root)) then
739 call free_tree(tree_root)
740 end if
741
742 ! Final display (now in normal terminal buffer)
743 call clear_screen()
744 call build_and_display_tree(show_all)
745 end subroutine interactive_mode
746
747 subroutine build_item_list(files, n_files, items, n_items, tree_root, hide_dotfiles)
748 type(file_entry), intent(in) :: files(:)
749 integer, intent(in) :: n_files
750 type(selectable_item), allocatable, intent(out) :: items(:)
751 integer, intent(out) :: n_items
752 type(tree_node), pointer, intent(inout) :: tree_root
753 logical, intent(in) :: hide_dotfiles
754 type(selectable_item), allocatable :: temp_items(:)
755 integer :: i, max_items
756 character(len=512), allocatable :: collapsed_paths(:)
757 integer :: n_collapsed, max_collapsed
758
759 ! Save collapsed state from old tree if it exists
760 n_collapsed = 0
761 max_collapsed = 100
762 allocate(collapsed_paths(max_collapsed))
763 if (associated(tree_root)) then
764 call collect_collapsed_paths(tree_root, '', collapsed_paths, n_collapsed, max_collapsed)
765 call free_tree(tree_root)
766 end if
767
768 ! Build new tree
769 allocate(tree_root)
770 tree_root%name = '.'
771 tree_root%is_file = .false.
772 tree_root%is_staged = .false.
773 tree_root%is_unstaged = .false.
774 tree_root%is_untracked = .false.
775 tree_root%has_incoming = .false.
776 tree_root%is_expanded = .true. ! Root is always expanded
777 tree_root%first_child => null()
778 tree_root%next_sibling => null()
779
780 do i = 1, n_files
781 ! Skip gitignored files and dotfiles if hide_dotfiles is enabled
782 if (hide_dotfiles) then
783 ! Check if this is a gitignored file
784 if (files(i)%is_gitignored) then
785 cycle ! Skip this file
786 end if
787 ! Check if this is a dotfile (path starts with . or contains /.)
788 if (index(files(i)%path, '/.') > 0 .or. files(i)%path(1:1) == '.') then
789 cycle ! Skip this file
790 end if
791 end if
792 call add_to_tree(tree_root, files(i)%path, files(i)%is_staged, files(i)%is_unstaged, files(i)%is_untracked, files(i)%has_incoming, files(i)%is_gitignored)
793 end do
794
795 call sort_tree(tree_root)
796
797 ! Restore collapsed state to new tree (sort first for binary search optimization)
798 if (n_collapsed > 0) then
799 call quicksort_collapsed_paths(collapsed_paths, 1, n_collapsed)
800 call restore_collapsed_state(tree_root, '', collapsed_paths, n_collapsed)
801 end if
802 deallocate(collapsed_paths)
803
804 ! Collect items from tree in traversal order
805 max_items = 1000
806 allocate(temp_items(max_items))
807 n_items = 0
808
809 ! Traverse tree and collect all items
810 call collect_items_from_tree(tree_root, '', 0, temp_items, n_items, max_items, hide_dotfiles)
811
812 ! Copy to output
813 allocate(items(n_items))
814 if (n_items > 0) items(1:n_items) = temp_items(1:n_items)
815 deallocate(temp_items)
816
817 ! Don't free tree - it's kept alive for expand/collapse operations
818 end subroutine build_item_list
819
820 subroutine rebuild_item_list_from_tree(tree_root, items, n_items, hide_dotfiles)
821 type(tree_node), pointer, intent(in) :: tree_root
822 type(selectable_item), allocatable, intent(out) :: items(:)
823 integer, intent(out) :: n_items
824 logical, intent(in) :: hide_dotfiles
825 type(selectable_item), allocatable :: temp_items(:)
826 integer :: max_items
827
828 ! Collect items from existing tree
829 max_items = 1000
830 allocate(temp_items(max_items))
831 n_items = 0
832
833 ! Traverse tree and collect all items
834 call collect_items_from_tree(tree_root, '', 0, temp_items, n_items, max_items, hide_dotfiles)
835
836 ! Copy to output
837 allocate(items(n_items))
838 if (n_items > 0) items(1:n_items) = temp_items(1:n_items)
839 deallocate(temp_items)
840 end subroutine rebuild_item_list_from_tree
841
842 recursive subroutine collect_collapsed_paths(node, parent_path, collapsed_paths, n_collapsed, max_collapsed)
843 type(tree_node), pointer, intent(in) :: node
844 character(len=*), intent(in) :: parent_path
845 character(len=512), allocatable, intent(inout) :: collapsed_paths(:)
846 integer, intent(inout) :: n_collapsed, max_collapsed
847 type(tree_node), pointer :: child
848 character(len=512) :: full_path
849
850 ! Build full path for this node
851 if (len_trim(parent_path) == 0) then
852 full_path = trim(node%name)
853 else
854 full_path = trim(parent_path) // '/' // trim(node%name)
855 end if
856
857 ! If this is a collapsed directory, save its path
858 if (.not. node%is_file .and. .not. node%is_expanded) then
859 n_collapsed = n_collapsed + 1
860 if (n_collapsed > max_collapsed) then
861 ! Resize array
862 call resize_path_array(collapsed_paths, max_collapsed)
863 end if
864 collapsed_paths(n_collapsed) = trim(full_path)
865 end if
866
867 ! Recursively check children
868 child => node%first_child
869 do while (associated(child))
870 call collect_collapsed_paths(child, full_path, collapsed_paths, n_collapsed, max_collapsed)
871 child => child%next_sibling
872 end do
873 end subroutine collect_collapsed_paths
874
875 subroutine resize_path_array(paths, max_size)
876 character(len=512), allocatable, intent(inout) :: paths(:)
877 integer, intent(inout) :: max_size
878 character(len=512), allocatable :: temp_paths(:)
879 integer :: old_size
880
881 old_size = max_size
882 allocate(temp_paths(old_size))
883 temp_paths = paths(1:old_size)
884 deallocate(paths)
885 max_size = max_size * 2
886 allocate(paths(max_size))
887 paths(1:old_size) = temp_paths
888 deallocate(temp_paths)
889 end subroutine resize_path_array
890
891 ! ========== Performance Optimization: Binary Search for Collapsed Paths ==========
892 recursive subroutine quicksort_collapsed_paths(arr, low, high)
893 character(len=512), intent(inout) :: arr(:)
894 integer, intent(in) :: low, high
895 integer :: pivot_idx
896
897 if (low < high) then
898 call partition_collapsed_paths(arr, low, high, pivot_idx)
899 call quicksort_collapsed_paths(arr, low, pivot_idx - 1)
900 call quicksort_collapsed_paths(arr, pivot_idx + 1, high)
901 end if
902 end subroutine quicksort_collapsed_paths
903
904 subroutine partition_collapsed_paths(arr, low, high, pivot_idx)
905 character(len=512), intent(inout) :: arr(:)
906 integer, intent(in) :: low, high
907 integer, intent(out) :: pivot_idx
908 character(len=512) :: pivot, temp
909 integer :: i, j
910
911 pivot = trim(arr(high))
912 i = low - 1
913
914 do j = low, high - 1
915 if (trim(arr(j)) <= pivot) then
916 i = i + 1
917 temp = arr(i)
918 arr(i) = arr(j)
919 arr(j) = temp
920 end if
921 end do
922
923 temp = arr(i + 1)
924 arr(i + 1) = arr(high)
925 arr(high) = temp
926
927 pivot_idx = i + 1
928 end subroutine partition_collapsed_paths
929
930 function binary_search_path(paths, n, target) result(index)
931 character(len=512), intent(in) :: paths(:)
932 integer, intent(in) :: n
933 character(len=*), intent(in) :: target
934 integer :: index
935 integer :: low, high, mid
936 character(len=512) :: target_trimmed, mid_val
937
938 index = -1
939 if (n == 0) return
940
941 target_trimmed = trim(target)
942 low = 1
943 high = n
944
945 do while (low <= high)
946 mid = low + (high - low) / 2
947 mid_val = trim(paths(mid))
948
949 if (mid_val == target_trimmed) then
950 index = mid
951 return
952 else if (mid_val < target_trimmed) then
953 low = mid + 1
954 else
955 high = mid - 1
956 end if
957 end do
958 end function binary_search_path
959
960 recursive subroutine restore_collapsed_state(node, parent_path, collapsed_paths, n_collapsed)
961 type(tree_node), pointer, intent(inout) :: node
962 character(len=*), intent(in) :: parent_path
963 character(len=512), intent(in) :: collapsed_paths(:)
964 integer, intent(in) :: n_collapsed
965 type(tree_node), pointer :: child
966 character(len=512) :: full_path
967 integer :: idx
968
969 ! Build full path for this node
970 if (len_trim(parent_path) == 0) then
971 full_path = trim(node%name)
972 else
973 full_path = trim(parent_path) // '/' // trim(node%name)
974 end if
975
976 ! Check if this directory should be collapsed using binary search (O(log n) vs O(n))
977 if (.not. node%is_file) then
978 idx = binary_search_path(collapsed_paths, n_collapsed, full_path)
979 if (idx > 0) then
980 node%is_expanded = .false.
981 end if
982 end if
983
984 ! Recursively restore for children
985 child => node%first_child
986 do while (associated(child))
987 call restore_collapsed_state(child, full_path, collapsed_paths, n_collapsed)
988 child => child%next_sibling
989 end do
990 end subroutine restore_collapsed_state
991
992 recursive subroutine collect_items_from_tree(node, parent_path, depth, items, n_items, max_items, hide_dotfiles)
993 type(tree_node), pointer, intent(in) :: node
994 character(len=*), intent(in) :: parent_path
995 integer, intent(in) :: depth
996 type(selectable_item), allocatable, intent(inout) :: items(:)
997 integer, intent(inout) :: n_items, max_items
998 logical, intent(in) :: hide_dotfiles
999 type(tree_node), pointer :: child
1000 character(len=512) :: full_path
1001 logical :: is_root
1002
1003 ! Check if this is the root node
1004 is_root = (len_trim(parent_path) == 0 .and. trim(node%name) == '.')
1005
1006 ! Build full path
1007 if (is_root) then
1008 full_path = ''
1009 else if (len_trim(parent_path) == 0) then
1010 full_path = trim(node%name)
1011 else
1012 full_path = trim(parent_path) // '/' // trim(node%name)
1013 end if
1014
1015 ! Add this item to the list (unless it's root)
1016 if (.not. is_root) then
1017 n_items = n_items + 1
1018 if (n_items > max_items) then
1019 call resize_item_array(items, max_items)
1020 end if
1021
1022 items(n_items)%path = trim(full_path)
1023 items(n_items)%is_file = node%is_file
1024 items(n_items)%is_staged = node%is_staged
1025 items(n_items)%is_unstaged = node%is_unstaged
1026 items(n_items)%is_untracked = node%is_untracked
1027 items(n_items)%has_incoming = node%has_incoming
1028 items(n_items)%is_gitignored = node%is_gitignored
1029 items(n_items)%depth = depth
1030 items(n_items)%node => node
1031 end if
1032
1033 ! Recursively process children if this node is expanded
1034 if (node%is_expanded) then
1035 child => node%first_child
1036 do while (associated(child))
1037 call collect_items_from_tree(child, full_path, depth + 1, items, n_items, max_items, hide_dotfiles)
1038 child => child%next_sibling
1039 end do
1040 end if
1041 end subroutine collect_items_from_tree
1042
1043 subroutine resize_item_array(items, max_items)
1044 type(selectable_item), allocatable, intent(inout) :: items(:)
1045 integer, intent(inout) :: max_items
1046 type(selectable_item), allocatable :: temp_items(:)
1047 integer :: old_size
1048
1049 old_size = max_items
1050 allocate(temp_items(old_size))
1051 temp_items = items(1:old_size)
1052 deallocate(items)
1053 max_items = max_items * 2
1054 allocate(items(max_items))
1055 items(1:old_size) = temp_items
1056 deallocate(temp_items)
1057 end subroutine resize_item_array
1058
1059 ! ========== Navigation Functions for New Navigation Model ==========
1060
1061 subroutine navigate_down(items, n_items, selected)
1062 type(selectable_item), intent(in) :: items(:)
1063 integer, intent(in) :: n_items
1064 integer, intent(inout) :: selected
1065 integer :: current_depth, i
1066
1067 if (n_items == 0) return
1068
1069 current_depth = items(selected)%depth
1070
1071 ! Search forward for next item at same depth
1072 do i = selected + 1, n_items
1073 if (items(i)%depth == current_depth) then
1074 selected = i
1075 return
1076 end if
1077 end do
1078
1079 ! No item found - wrap to beginning
1080 do i = 1, selected - 1
1081 if (items(i)%depth == current_depth) then
1082 selected = i
1083 return
1084 end if
1085 end do
1086 ! If we get here, we're the only item at this depth, so stay put
1087 end subroutine navigate_down
1088
1089 subroutine navigate_up(items, n_items, selected)
1090 type(selectable_item), intent(in) :: items(:)
1091 integer, intent(in) :: n_items
1092 integer, intent(inout) :: selected
1093 integer :: current_depth, i
1094
1095 if (n_items == 0) return
1096
1097 current_depth = items(selected)%depth
1098
1099 ! Search backward for previous item at same depth
1100 do i = selected - 1, 1, -1
1101 if (items(i)%depth == current_depth) then
1102 selected = i
1103 return
1104 end if
1105 end do
1106
1107 ! No item found - wrap to end
1108 do i = n_items, selected + 1, -1
1109 if (items(i)%depth == current_depth) then
1110 selected = i
1111 return
1112 end if
1113 end do
1114 ! If we get here, we're the only item at this depth, so stay put
1115 end subroutine navigate_up
1116
1117 subroutine navigate_right(items, n_items, selected, tree_root, hide_dotfiles)
1118 type(selectable_item), allocatable, intent(inout) :: items(:)
1119 integer, intent(inout) :: n_items
1120 integer, intent(inout) :: selected
1121 type(tree_node), pointer, intent(in) :: tree_root
1122 logical, intent(in) :: hide_dotfiles
1123 integer :: i, target_depth
1124
1125 if (n_items == 0) return
1126 if (items(selected)%is_file) return ! Can't enter a file
1127
1128 ! We're on a directory
1129 if (.not. items(selected)%node%is_expanded) then
1130 ! Directory is collapsed - expand it
1131 items(selected)%node%is_expanded = .true.
1132 ! Rebuild item list
1133 call rebuild_item_list_from_tree(tree_root, items, n_items, hide_dotfiles)
1134 ! Adjust selection if needed
1135 if (selected > n_items .and. n_items > 0) selected = n_items
1136 end if
1137
1138 ! Now move to first child (next item with depth+1)
1139 target_depth = items(selected)%depth + 1
1140 do i = selected + 1, n_items
1141 if (items(i)%depth == target_depth) then
1142 selected = i
1143 return
1144 end if
1145 end do
1146 ! No children - stay on directory
1147 end subroutine navigate_right
1148
1149 subroutine navigate_left(items, n_items, selected)
1150 type(selectable_item), allocatable, intent(inout) :: items(:)
1151 integer, intent(inout) :: n_items
1152 integer, intent(inout) :: selected
1153 integer :: i, target_depth
1154
1155 if (n_items == 0) return
1156
1157 ! Move to parent (previous item with depth-1)
1158 target_depth = items(selected)%depth - 1
1159 if (target_depth < 0) return ! Already at root level
1160
1161 ! Search backward for parent
1162 do i = selected - 1, 1, -1
1163 if (items(i)%depth == target_depth) then
1164 selected = i
1165 return
1166 end if
1167 end do
1168 end subroutine navigate_left
1169
1170 subroutine commit_prompt()
1171 character(len=512) :: commit_msg
1172 logical :: success
1173 character(len=1) :: key
1174
1175 ! Clear screen for commit prompt
1176 call clear_screen()
1177 print '(A)', achar(27) // '[1mGit Commit' // achar(27) // '[0m'
1178 print '(A)', ''
1179
1180 ! Read commit message
1181 call read_line('Commit message: ', commit_msg)
1182
1183 ! Execute commit if message is not empty
1184 if (len_trim(commit_msg) > 0) then
1185 call git_commit_with_message(commit_msg, success)
1186
1187 ! Wait for keypress to continue (flush buffered input first)
1188 call wait_for_key(key)
1189 end if
1190 end subroutine commit_prompt
1191
1192 subroutine amend_commit_prompt()
1193 character(len=512) :: commit_msg, last_commit_msg
1194 logical :: success
1195 character(len=1) :: key
1196
1197 ! Clear screen for amend commit prompt
1198 call clear_screen()
1199 print '(A)', achar(27) // '[1mGit Commit --amend' // achar(27) // '[0m'
1200 print '(A)', ''
1201
1202 ! Get the last commit message as default
1203 call get_last_commit_message(last_commit_msg)
1204
1205 ! Show the last commit message
1206 if (len_trim(last_commit_msg) > 0) then
1207 print '(A)', achar(27) // '[2mLast commit message:' // achar(27) // '[0m'
1208 print '(A)', ' ' // trim(last_commit_msg)
1209 print '(A)', ''
1210 end if
1211
1212 ! Read new commit message
1213 call read_line('New commit message (empty to keep): ', commit_msg)
1214
1215 ! If no message provided, keep the old one
1216 if (len_trim(commit_msg) == 0) then
1217 commit_msg = last_commit_msg
1218 end if
1219
1220 ! Execute amend if we have a message
1221 if (len_trim(commit_msg) > 0) then
1222 call git_commit_amend(commit_msg, success)
1223
1224 ! Wait for keypress to continue (flush buffered input first)
1225 call wait_for_key(key)
1226 else
1227 print '(A)', 'No commit message provided. Amend cancelled.'
1228 print '(A)', 'Press any key to continue...'
1229 call wait_for_key(key)
1230 end if
1231 end subroutine amend_commit_prompt
1232
1233 subroutine show_status_view()
1234 ! Use less for scrollable, searchable git status view
1235 call show_git_status_paged()
1236 end subroutine show_status_view
1237
1238 subroutine push_prompt()
1239 logical :: success
1240 character(len=1) :: key
1241
1242 ! Clear screen for push prompt
1243 call clear_screen()
1244 print '(A)', achar(27) // '[1mGit Push' // achar(27) // '[0m'
1245 print '(A)', ''
1246 print '(A)', 'Pushing to remote...'
1247 print '(A)', ''
1248
1249 ! Execute push
1250 call git_push(success)
1251
1252 ! Wait for keypress to continue (flush buffered input first)
1253 call wait_for_key(key)
1254 end subroutine push_prompt
1255
1256 subroutine tag_prompt()
1257 character(len=512) :: tag_name, tag_message
1258 logical :: success, push_tag
1259 character(len=1) :: key
1260 integer :: status
1261
1262 ! Clear screen for tag prompt
1263 call clear_screen()
1264 print '(A)', achar(27) // '[1mGit Tag' // achar(27) // '[0m'
1265 print '(A)', ''
1266
1267 ! Fetch tags from remote to ensure list is up to date
1268 print '(A)', 'Fetching tags from remote...'
1269 call execute_command_line('git fetch --tags --quiet 2>&1', exitstat=status)
1270 print '(A)', ''
1271
1272 ! Show existing tags in compact format
1273 print '(A)', achar(27) // '[2mExisting tags:' // achar(27) // '[0m'
1274 call execute_command_line('git tag --sort=-version:refname | head -10 | column -c 80 2>/dev/null || git tag --sort=-version:refname | head -10', exitstat=status)
1275 print '(A)', ''
1276
1277 ! Read tag name
1278 call read_line('Tag name: ', tag_name)
1279
1280 ! Execute tag if name is not empty
1281 if (len_trim(tag_name) > 0) then
1282 ! Read tag message (optional)
1283 call read_line('Tag message (enter for none): ', tag_message)
1284
1285 call git_tag(tag_name, tag_message, success)
1286
1287 if (success) then
1288 ! Ask if user wants to push the tag
1289 print '(A)', ''
1290 print '(A)', 'Push tag to origin? (y/n)'
1291 call read_key(key)
1292
1293 if (key == 'y' .or. key == 'Y') then
1294 call git_push_tag(tag_name, push_tag)
1295 end if
1296 end if
1297
1298 ! Wait for keypress to continue (flush buffered input first)
1299 print '(A)', 'Press any key to continue...'
1300 call wait_for_key(key)
1301 end if
1302 end subroutine tag_prompt
1303
1304 subroutine branch_switch_prompt()
1305 logical :: success
1306
1307 ! Clear screen for branch switch
1308 call clear_screen()
1309 print '(A)', achar(27) // '[1mSwitch Branch' // achar(27) // '[0m'
1310 print '(A)', ''
1311
1312 ! Call git branch switch with fzf
1313 call git_switch_branch(success)
1314 end subroutine branch_switch_prompt
1315
1316 subroutine delete_prompt(filepath, is_untracked)
1317 character(len=*), intent(in) :: filepath
1318 logical, intent(in) :: is_untracked
1319 logical :: deleted
1320 character(len=1) :: key
1321
1322 ! Clear screen for delete prompt
1323 call clear_screen()
1324 print '(A)', achar(27) // '[1mDelete File' // achar(27) // '[0m'
1325 print '(A)', ''
1326
1327 ! Execute delete with confirmation
1328 call git_delete_file(filepath, is_untracked, deleted)
1329
1330 ! Wait for keypress to continue
1331 call read_key(key)
1332 end subroutine delete_prompt
1333
1334 subroutine discard_prompt(filepath, is_staged, is_untracked)
1335 character(len=*), intent(in) :: filepath
1336 logical, intent(in) :: is_staged, is_untracked
1337 logical :: discarded
1338 character(len=1) :: key
1339
1340 ! Clear screen for discard prompt
1341 call clear_screen()
1342 print '(A)', achar(27) // '[1mDiscard Changes' // achar(27) // '[0m'
1343 print '(A)', ''
1344
1345 ! Execute discard with confirmation
1346 call git_discard_changes(filepath, is_staged, is_untracked, discarded)
1347
1348 ! Wait for keypress to continue
1349 call read_key(key)
1350 end subroutine discard_prompt
1351
1352 subroutine stash_push_prompt()
1353 character(len=512) :: stash_msg
1354 logical :: success
1355 character(len=1) :: key
1356
1357 ! Clear screen for stash prompt
1358 call clear_screen()
1359 print '(A)', achar(27) // '[1mGit Stash (Save)' // achar(27) // '[0m'
1360 print '(A)', ''
1361
1362 ! Read stash message (optional)
1363 call read_line('Stash message (optional): ', stash_msg)
1364
1365 ! Execute stash push
1366 call git_stash_push(stash_msg, success)
1367
1368 ! Wait for keypress to continue
1369 call read_key(key)
1370 end subroutine stash_push_prompt
1371
1372 subroutine stash_pop_apply_prompt()
1373 logical :: success
1374 character(len=1) :: key
1375
1376 ! Clear screen for stash pop/apply prompt
1377 call clear_screen()
1378 print '(A)', achar(27) // '[1mGit Stash (Pop/Apply)' // achar(27) // '[0m'
1379 print '(A)', ''
1380
1381 ! Execute stash pop/apply with fzf selection
1382 call git_stash_pop_apply(success)
1383
1384 ! Wait for keypress to continue
1385 call read_key(key)
1386 end subroutine stash_pop_apply_prompt
1387
1388 subroutine cherry_pick_prompt()
1389 logical :: success
1390
1391 ! Clear screen for cherry-pick
1392 call clear_screen()
1393
1394 ! Call git cherry-pick (handles its own prompts and key wait)
1395 call git_cherry_pick(success)
1396 end subroutine cherry_pick_prompt
1397
1398 subroutine revert_commit_prompt()
1399 logical :: success
1400
1401 ! Clear screen for revert
1402 call clear_screen()
1403
1404 ! Call git revert (handles its own prompts and key wait)
1405 call git_revert_commit(success)
1406 end subroutine revert_commit_prompt
1407
1408 subroutine history_browser_prompt()
1409 ! Clear screen for history browser
1410 call clear_screen()
1411
1412 ! Call git history browser (handles its own terminal setup)
1413 call git_show_history()
1414 end subroutine history_browser_prompt
1415
1416 subroutine reflog_browser_prompt()
1417 ! Clear screen for reflog browser
1418 call clear_screen()
1419
1420 ! Call git reflog browser (handles its own terminal setup)
1421 call git_show_reflog()
1422 end subroutine reflog_browser_prompt
1423
1424 subroutine merge_branch_prompt()
1425 logical :: success
1426
1427 ! Clear screen for merge
1428 call clear_screen()
1429
1430 ! Call git merge (handles its own prompts and key wait)
1431 call git_merge_branch(success)
1432 end subroutine merge_branch_prompt
1433
1434 subroutine blame_prompt(filepath)
1435 character(len=*), intent(in) :: filepath
1436
1437 ! Clear screen for blame view
1438 call clear_screen()
1439
1440 ! Call git blame (handles its own display and key wait)
1441 call git_blame_file(filepath)
1442 end subroutine blame_prompt
1443
1444 subroutine reset_prompt()
1445 logical :: success
1446
1447 ! Clear screen for reset
1448 call clear_screen()
1449
1450 ! Call git reset (handles its own prompts and key wait)
1451 call git_reset_interactive(success)
1452 end subroutine reset_prompt
1453
1454 subroutine rebase_prompt()
1455 logical :: success
1456
1457 ! Clear screen for rebase
1458 call clear_screen()
1459
1460 ! Call git rebase (handles its own prompts and key wait)
1461 call git_interactive_rebase(success)
1462 end subroutine rebase_prompt
1463
1464 subroutine branch_create_prompt()
1465 character(len=512) :: branch_name
1466 logical :: success
1467 character(len=1) :: key
1468
1469 ! Clear screen for branch creation
1470 call clear_screen()
1471 print '(A)', achar(27) // '[1mCreate New Branch' // achar(27) // '[0m'
1472 print '(A)', ''
1473
1474 ! Read branch name
1475 call read_line('New branch name: ', branch_name)
1476
1477 ! Create branch if name is not empty
1478 if (len_trim(branch_name) > 0) then
1479 call git_create_branch(branch_name, success)
1480 ! Wait for keypress to continue
1481 call read_key(key)
1482 end if
1483 end subroutine branch_create_prompt
1484
1485 subroutine branch_delete_prompt()
1486 logical :: success
1487 character(len=1) :: key
1488
1489 ! Clear screen for branch deletion
1490 call clear_screen()
1491 print '(A)', achar(27) // '[1mDelete Branch' // achar(27) // '[0m'
1492 print '(A)', ''
1493
1494 ! Call git branch delete with fzf
1495 call git_delete_branch(success)
1496
1497 ! Wait for keypress to continue
1498 call read_key(key)
1499 end subroutine branch_delete_prompt
1500
1501 subroutine refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
1502 hide_dotfiles, selected, running, exit_if_empty, include_incoming, force_refresh)
1503 ! Centralized helper to refresh file list and rebuild tree
1504 ! Consolidates the pattern repeated 20+ times in the codebase
1505 ! Now with caching support for performance optimization
1506 logical, intent(in) :: show_all, hide_dotfiles
1507 type(file_entry), allocatable, intent(inout) :: files(:)
1508 integer, intent(inout) :: n_files, n_items, selected
1509 type(tree_node), pointer, intent(inout) :: tree_root
1510 type(selectable_item), allocatable, intent(inout) :: items(:)
1511 logical, intent(inout), optional :: running
1512 logical, intent(in), optional :: exit_if_empty, include_incoming, force_refresh
1513
1514 logical :: do_exit_if_empty, do_include_incoming, do_force_refresh
1515
1516 ! Handle optional parameters
1517 do_exit_if_empty = .false.
1518 if (present(exit_if_empty)) do_exit_if_empty = exit_if_empty
1519
1520 do_include_incoming = .false.
1521 if (present(include_incoming)) do_include_incoming = include_incoming
1522
1523 do_force_refresh = .false.
1524 if (present(force_refresh)) do_force_refresh = force_refresh
1525
1526 ! Get files based on mode (with caching support)
1527 if (show_all) then
1528 call get_all_files(files, n_files, force_refresh=do_force_refresh)
1529 call mark_incoming_changes(files, n_files)
1530 else
1531 call get_dirty_files(files, n_files, force_refresh=do_force_refresh)
1532 if (do_include_incoming) then
1533 ! For fetch/pull: also include files with only incoming changes
1534 call add_incoming_files(files, n_files)
1535 else
1536 call mark_incoming_changes(files, n_files)
1537 end if
1538 end if
1539
1540 ! Rebuild tree and flatten to items
1541 call build_item_list(files, n_files, items, n_items, tree_root, hide_dotfiles)
1542
1543 ! Adjust selection if needed
1544 if (selected > n_items .and. n_items > 0) selected = n_items
1545
1546 ! Exit if no items and exit_if_empty is set
1547 if (do_exit_if_empty .and. n_items == 0) then
1548 if (present(running)) running = .false.
1549 end if
1550 end subroutine refresh_and_rebuild
1551
1552 subroutine fuzzy_jump_to_match(items, n_items, pattern, selected)
1553 ! Jump to BEST matching item using fzf-style scoring
1554 ! Two-pass approach: basename matches first, then path matches
1555 ! This ensures "src" matches "src/" directory before "src/file.f90"
1556 type(selectable_item), intent(in) :: items(:)
1557 integer, intent(in) :: n_items
1558 character(len=*), intent(in) :: pattern
1559 integer, intent(inout) :: selected
1560 integer :: i, best_idx, best_score, score, current_score
1561
1562 best_idx = selected ! Stay at current if no matches
1563 best_score = 0
1564
1565 ! Check current item's basename first - if it's a perfect match, stay on it!
1566 if (associated(items(selected)%node)) then
1567 current_score = fuzzy_match_score(pattern, items(selected)%node%name)
1568 if (current_score >= 10000) then ! Exact match - stay here!
1569 ! DEBUG
1570 open(99, file='/tmp/fuss_debug.log', position='append')
1571 write(99, '(A,I0,A,A,A,I0,A)') ' EXACT MATCH (current): item=', selected, ' path=', &
1572 trim(items(selected)%path), ' score=', current_score, ' (basename)'
1573 close(99)
1574 return
1575 end if
1576 best_score = current_score
1577 best_idx = selected
1578 end if
1579
1580 ! PASS 1: Search for basename matches (directories, file names)
1581 do i = 1, n_items
1582 if (i == selected) cycle ! Already checked current above
1583
1584 if (associated(items(i)%node)) then
1585 score = fuzzy_match_score(pattern, items(i)%node%name)
1586 if (score > best_score) then
1587 best_score = score
1588 best_idx = i
1589 end if
1590 end if
1591 end do
1592
1593 ! If we found a good basename match, use it
1594 if (best_score >= 5000) then ! Prefix or exact match
1595 selected = best_idx
1596 ! DEBUG
1597 open(99, file='/tmp/fuss_debug.log', position='append')
1598 write(99, '(A,I0,A,A,A,I0,A)') ' BASENAME MATCH: item=', best_idx, ' path=', &
1599 trim(items(best_idx)%path), ' score=', best_score, ' (basename)'
1600 close(99)
1601 return
1602 end if
1603
1604 ! PASS 2: Search full paths if no good basename match
1605 do i = 1, n_items
1606 if (i == selected) cycle
1607
1608 score = fuzzy_match_score(pattern, items(i)%path)
1609 if (score > best_score) then
1610 best_score = score
1611 best_idx = i
1612 end if
1613 end do
1614
1615 ! Jump to best match if any was found
1616 if (best_score > 0) then
1617 selected = best_idx
1618 ! DEBUG
1619 open(99, file='/tmp/fuss_debug.log', position='append')
1620 write(99, '(A,I0,A,A,A,I0,A)') ' PATH MATCH: item=', best_idx, ' path=', &
1621 trim(items(best_idx)%path), ' score=', best_score, ' (fullpath)'
1622 close(99)
1623 end if
1624 end subroutine fuzzy_jump_to_match
1625
1626 function fuzzy_match_score(pattern, text) result(score)
1627 ! Fuzzy matching with fzf-style scoring
1628 ! Returns a score (higher is better), 0 means no match
1629 character(len=*), intent(in) :: pattern, text
1630 integer :: score
1631 integer :: pattern_idx, text_idx, match_start, consecutive_bonus
1632 character(len=256) :: pattern_lower, text_lower
1633 logical :: is_consecutive
1634
1635 score = 0
1636
1637 ! Empty pattern matches everything with score 1
1638 if (len_trim(pattern) == 0) then
1639 score = 1
1640 return
1641 end if
1642
1643 ! Convert to lowercase once
1644 pattern_lower = pattern
1645 text_lower = text
1646 call to_lowercase(pattern_lower)
1647 call to_lowercase(text_lower)
1648
1649 ! Check for exact match first (highest score)
1650 if (trim(pattern_lower) == trim(text_lower)) then
1651 score = 10000
1652 return
1653 end if
1654
1655 ! Check for prefix match (very high score)
1656 if (len_trim(pattern_lower) <= len_trim(text_lower)) then
1657 if (text_lower(1:len_trim(pattern_lower)) == trim(pattern_lower)) then
1658 score = 5000
1659 return
1660 end if
1661 end if
1662
1663 ! Fuzzy match with scoring
1664 pattern_idx = 1
1665 consecutive_bonus = 0
1666 is_consecutive = .false.
1667 match_start = -1
1668
1669 do text_idx = 1, len_trim(text_lower)
1670 if (pattern_idx > len_trim(pattern_lower)) exit
1671
1672 if (pattern_lower(pattern_idx:pattern_idx) == text_lower(text_idx:text_idx)) then
1673 if (match_start == -1) match_start = text_idx
1674
1675 ! Base score for each matched character
1676 score = score + 100
1677
1678 ! Bonus for consecutive characters
1679 if (is_consecutive) then
1680 consecutive_bonus = consecutive_bonus + 1
1681 score = score + consecutive_bonus * 50
1682 else
1683 consecutive_bonus = 1
1684 is_consecutive = .true.
1685 end if
1686
1687 ! Bonus for matching at start of text
1688 if (text_idx == 1) then
1689 score = score + 200
1690 end if
1691
1692 ! Bonus for matching after separator (word boundary)
1693 if (text_idx > 1) then
1694 if (text_lower(text_idx-1:text_idx-1) == '/' .or. &
1695 text_lower(text_idx-1:text_idx-1) == '_' .or. &
1696 text_lower(text_idx-1:text_idx-1) == '-' .or. &
1697 text_lower(text_idx-1:text_idx-1) == '.') then
1698 score = score + 150
1699 end if
1700 end if
1701
1702 pattern_idx = pattern_idx + 1
1703 else
1704 ! Reset consecutive bonus when characters don't match
1705 is_consecutive = .false.
1706 consecutive_bonus = 0
1707 ! Small penalty for gaps
1708 if (match_start > 0) then
1709 score = score - 1
1710 end if
1711 end if
1712 end do
1713
1714 ! No match if we didn't find all pattern characters
1715 if (pattern_idx <= len_trim(pattern_lower)) then
1716 score = 0
1717 return
1718 end if
1719
1720 ! Bonus for shorter strings (prefer concise matches)
1721 score = score - len_trim(text_lower)
1722
1723 end function fuzzy_match_score
1724
1725 subroutine to_lowercase(str)
1726 ! Convert string to lowercase in-place
1727 character(len=*), intent(inout) :: str
1728 integer :: i
1729 character(len=1) :: c
1730
1731 do i = 1, len_trim(str)
1732 c = str(i:i)
1733 if (c >= 'A' .and. c <= 'Z') then
1734 str(i:i) = achar(ichar(c) + 32)
1735 end if
1736 end do
1737 end subroutine to_lowercase
1738
1739 end program fuss
1740