Text · 52935 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
41 show_all = .false.
42 interactive = .false.
43 nargs = command_argument_count()
44
45 do i = 1, nargs
46 call get_command_argument(i, arg)
47 if (trim(arg) == '--help' .or. trim(arg) == '-h') then
48 call print_help()
49 stop
50 else if (trim(arg) == '--all' .or. trim(arg) == '-a') then
51 show_all = .true.
52 else if (trim(arg) == '-i' .or. trim(arg) == '--interactive') then
53 interactive = .true.
54 end if
55 end do
56 end subroutine parse_arguments
57
58 subroutine print_help()
59 print '(A)', ''
60 print '(A)', achar(27) // '[1mfuss' // achar(27) // '[0m - Fortran Utility for Simple Staging'
61 print '(A)', ''
62 print '(A)', achar(27) // '[1mUSAGE:' // achar(27) // '[0m'
63 print '(A)', ' fuss [OPTIONS]'
64 print '(A)', ''
65 print '(A)', achar(27) // '[1mOPTIONS:' // achar(27) // '[0m'
66 print '(A)', ' -h, --help Show this help message'
67 print '(A)', ' -i, --interactive Launch interactive tree view (default mode)'
68 print '(A)', ' -a, --all Show all files (not just dirty files)'
69 print '(A)', ''
70 print '(A)', achar(27) // '[1mDESCRIPTION:' // achar(27) // '[0m'
71 print '(A)', ' fuss is a git staging utility with an interactive tree interface.'
72 print '(A)', ' Navigate with j/k or arrow keys, stage with ''a'', unstage with ''u''.'
73 print '(A)', ''
74 print '(A)', achar(27) // '[1mINTERACTIVE MODE KEYS:' // achar(27) // '[0m'
75 print '(A)', ' Navigation:'
76 print '(A)', ' j/k or ↑/↓ Navigate files (siblings only)'
77 print '(A)', ' ←/→ Navigate tree (parent/child)'
78 print '(A)', ' space Toggle directory expand/collapse'
79 print '(A)', ''
80 print '(A)', ' Staging:'
81 print '(A)', ' a Stage file or directory'
82 print '(A)', ' u Unstage file'
83 print '(A)', ' S Stage all files'
84 print '(A)', ' U Unstage all files'
85 print '(A)', ''
86 print '(A)', ' Git Operations:'
87 print '(A)', ' m Commit staged changes'
88 print '(A)', ' M Amend last commit'
89 print '(A)', ' p Push to remote'
90 print '(A)', ' l Pull from remote'
91 print '(A)', ' f Fetch from remote'
92 print '(A)', ' d Show diff for file'
93 print '(A)', ' c View file contents (bat/less/cat)'
94 print '(A)', ' w Git blame (who changed this line)'
95 print '(A)', ' h Browse commit history (detailed/oneline)'
96 print '(A)', ' L Browse reflog (recover lost commits)'
97 print '(A)', ' y Cherry-pick commit from branch'
98 print '(A)', ' v Revert commit (safe undo)'
99 print '(A)', ' x Discard changes'
100 print '(A)', ''
101 print '(A)', ' Branches & Stash:'
102 print '(A)', ' b Switch branch'
103 print '(A)', ' n Create new branch'
104 print '(A)', ' R Delete branch'
105 print '(A)', ' G Merge branch into current'
106 print '(A)', ' O Reset to commit (soft/mixed/hard)'
107 print '(A)', ' I Interactive rebase (reorder/squash commits)'
108 print '(A)', ' z Stash changes'
109 print '(A)', ' Z Unstash/pop changes'
110 print '(A)', ''
111 print '(A)', ' Other:'
112 print '(A)', ' t Create/push tag'
113 print '(A)', ' r Delete file'
114 print '(A)', ' s Show git status'
115 print '(A)', ' . Toggle hide dotfiles/gitignored'
116 print '(A)', ' q Quit'
117 print '(A)', ''
118 print '(A)', achar(27) // '[1mEXAMPLES:' // achar(27) // '[0m'
119 print '(A)', ' fuss Launch interactive mode (dirty files only)'
120 print '(A)', ' fuss -a Launch interactive mode showing all files'
121 print '(A)', ' fuss -i Explicitly launch interactive mode'
122 print '(A)', ' fuss --all Show all tracked files in tree view'
123 print '(A)', ''
124 end subroutine print_help
125
126 subroutine get_current_dir(path)
127 character(len=:), allocatable, intent(out) :: path
128 character(len=1024) :: buffer
129 integer :: status
130
131 call execute_command_line('pwd > /tmp/fuss_pwd.txt', exitstat=status)
132
133 open(unit=99, file='/tmp/fuss_pwd.txt', status='old', action='read')
134 read(99, '(A)') buffer
135 close(99, status='delete')
136
137 path = trim(buffer)
138 end subroutine get_current_dir
139
140 subroutine build_and_display_tree(show_all)
141 logical, intent(in) :: show_all
142 type(file_entry), allocatable :: files(:)
143 integer :: n_files
144
145 ! Get files from git or filesystem
146 if (show_all) then
147 call get_all_files(files, n_files)
148 else
149 call get_dirty_files(files, n_files)
150 end if
151
152 ! Mark files with incoming changes
153 call mark_incoming_changes(files, n_files)
154
155 ! Display the tree
156 if (n_files > 0) then
157 print '(A)', '.'
158 call display_tree(files, n_files)
159 else
160 print '(A)', 'No files to display'
161 end if
162 end subroutine build_and_display_tree
163
164 subroutine cleanup_terminal()
165 ! Emergency cleanup - restores terminal to normal state
166 ! Call this before any exit or when calling external programs
167 call disable_raw_mode()
168 call exit_alternate_screen()
169 end subroutine cleanup_terminal
170
171 subroutine interactive_mode(show_all)
172 logical, intent(in) :: show_all
173 type(file_entry), allocatable :: files(:)
174 type(selectable_item), allocatable :: items(:)
175 integer :: n_files, n_items, selected
176 character(len=1) :: key
177 logical :: running, hide_dotfiles
178 character(len=256) :: repo_name, branch_name, term_program
179 integer :: term_height, viewport_offset, visible_items, top_padding
180 type(tree_node), pointer :: tree_root
181
182 ! Initialize tree pointer
183 tree_root => null()
184
185 ! Detect terminal type for padding (fixes WezTerm/Ghostty top line cutoff)
186 call get_environment_variable("TERM_PROGRAM", term_program)
187 if (index(term_program, "WezTerm") > 0 .or. index(term_program, "ghostty") > 0) then
188 top_padding = 2 ! WezTerm/Ghostty need 2 lines of padding
189 else if (index(term_program, "Apple_Terminal") > 0 .or. index(term_program, "iTerm") > 0) then
190 top_padding = 2 ! Terminal.app and iTerm2 also need 2 lines
191 else
192 top_padding = 1 ! Other terminals need 1 line
193 end if
194
195 ! Get repo and branch info
196 call get_repo_info(repo_name, branch_name)
197
198 ! Get terminal height
199 call get_terminal_height(term_height)
200
201 ! DEBUG: Show terminal height
202 ! print '(A,I0)', 'DEBUG: Terminal height detected: ', term_height
203
204 ! Initialize hide_dotfiles before first use
205 hide_dotfiles = .false.
206
207 ! Get files and mark incoming changes
208 if (show_all) then
209 call get_all_files(files, n_files)
210 else
211 call get_dirty_files(files, n_files)
212 end if
213 call mark_incoming_changes(files, n_files)
214
215 if (n_files == 0) then
216 print '(A)', 'No files to display'
217 return
218 end if
219
220 ! Build flat list of items for navigation
221 call build_item_list(files, n_files, items, n_items, tree_root, hide_dotfiles)
222
223 ! Calculate visible items accurately
224 ! Fixed UI elements that take screen space:
225 ! Line 1: repo:branch (e.g., "fuss:trunk")
226 ! Line 2: blank line after repo
227 ! Line 3: "." root
228 ! Lines 4 to N-3: tree items (VIEWPORT)
229 ! Line N-2: blank line before help
230 ! Line N-1: help legend (=staged =modified =untracked)
231 ! Line N: help controls (j/k//: navigate | ...)
232 ! Total fixed: 6 lines (2 + 1 + 3)
233 visible_items = term_height - 6
234 if (visible_items < 3) visible_items = 3 ! Absolute minimum
235 if (visible_items > n_items) visible_items = n_items ! Don't exceed total items
236
237 ! Initialize selection and viewport at TOP of tree
238 selected = 1
239 viewport_offset = 1
240 running = .true.
241
242 ! Partial redraw optimization: track previous state
243 integer :: prev_selected, prev_viewport
244 logical :: needs_full_redraw
245 prev_selected = 0 ! Force initial draw
246 prev_viewport = 0
247 needs_full_redraw = .true.
248
249 ! Enter alternate screen buffer (preserves terminal content)
250 call enter_alternate_screen()
251
252 ! Enable raw terminal mode
253 call enable_raw_mode()
254
255 ! Main interactive loop
256 do while (running)
257 ! Center viewport on selection - keeps highlighted item in middle of screen
258 viewport_offset = selected - visible_items / 2
259
260 ! Clamp viewport to valid range
261 if (viewport_offset < 1) viewport_offset = 1
262 if (viewport_offset > n_items - visible_items + 1 .and. n_items > visible_items) then
263 viewport_offset = n_items - visible_items + 1
264 end if
265
266 ! Conditional redraw for performance optimization
267 if (needs_full_redraw .or. viewport_offset /= prev_viewport) then
268 ! Full redraw needed: viewport scrolled or forced refresh
269 call clear_screen()
270 call draw_interactive_tree(tree_root, items, n_items, selected, &
271 repo_name, branch_name, viewport_offset, visible_items, top_padding)
272 needs_full_redraw = .false.
273 else if (selected /= prev_selected) then
274 ! Only selection changed within same viewport - still need full redraw for now
275 ! TODO: Could optimize this with partial line updates in the future
276 call clear_screen()
277 call draw_interactive_tree(tree_root, items, n_items, selected, &
278 repo_name, branch_name, viewport_offset, visible_items, top_padding)
279 end if
280
281 ! Update tracking state
282 prev_selected = selected
283 prev_viewport = viewport_offset
284
285 ! Read key
286 call read_key(key)
287
288 ! Handle input
289 select case (key)
290 case ('j', 'B') ! j or down arrow - navigate to next sibling (skip nested items)
291 call navigate_down(items, n_items, selected)
292 case ('k', 'A') ! k or up arrow - navigate to previous sibling (skip nested items)
293 call navigate_up(items, n_items, selected)
294 case ('D') ! Left arrow - navigate to parent directory
295 call navigate_left(items, n_items, selected)
296 case ('C') ! Right arrow - enter directory
297 call navigate_right(items, n_items, selected, tree_root, hide_dotfiles)
298 case (' ') ! Space bar - toggle expand/collapse
299 if (.not. items(selected)%is_file .and. associated(items(selected)%node)) then
300 ! Toggle the expanded state
301 items(selected)%node%is_expanded = .not. items(selected)%node%is_expanded
302 ! Rebuild item list to reflect change
303 call rebuild_item_list_from_tree(tree_root, items, n_items, hide_dotfiles)
304 ! Adjust selection if needed
305 if (selected > n_items .and. n_items > 0) selected = n_items
306 ! Force full redraw after tree structure change
307 needs_full_redraw = .true.
308 end if
309 case ('a') ! Stage file or directory (lowercase to avoid conflict with arrow A)
310 ! Check if it's a directory - stage all files in it
311 if (.not. items(selected)%is_file) then
312 call git_stage_directory(items(selected)%path)
313 ! Refresh files after staging directory
314 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
315 hide_dotfiles, selected, running, exit_if_empty=.true., force_refresh=.true.)
316 needs_full_redraw = .true.
317 ! Otherwise it's a file - stage individual file
318 else if (items(selected)%is_file .and. (items(selected)%is_unstaged .or. items(selected)%is_untracked)) then
319 call git_add_file(items(selected)%path)
320 ! Refresh files after git add
321 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
322 hide_dotfiles, selected, running, exit_if_empty=.true., force_refresh=.true.)
323 end if
324 case ('u') ! Unstage file (lowercase)
325 if (items(selected)%is_file .and. items(selected)%is_staged) then
326 call git_unstage_file(items(selected)%path)
327 ! Refresh files after git unstage
328 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
329 hide_dotfiles, selected, running, force_refresh=.true.)
330 end if
331 case ('S') ! Stage all (Shift+S to avoid conflict with up arrow 'A')
332 call git_stage_all()
333 ! Refresh files after staging all
334 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
335 hide_dotfiles, selected, running, exit_if_empty=.true., force_refresh=.true.)
336 case ('U') ! Unstage all (Shift+U)
337 call git_unstage_all()
338 ! Refresh files after unstaging all
339 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
340 hide_dotfiles, selected, running, force_refresh=.true.)
341 case ('m') ! Commit (lowercase)
342 call commit_prompt()
343 ! Refresh files after commit
344 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
345 hide_dotfiles, selected, running, force_refresh=.true.)
346 case ('M') ! Amend last commit (Shift+m)
347 call amend_commit_prompt()
348 ! Refresh files after amend commit
349 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
350 hide_dotfiles, selected, running, force_refresh=.true.)
351 case ('s') ! Show git status (lowercase)
352 call show_status_view()
353 case ('p') ! Push (lowercase)
354 call push_prompt()
355 ! Refresh files after push
356 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
357 hide_dotfiles, selected, running, force_refresh=.true.)
358 case ('t') ! Tag (lowercase)
359 call tag_prompt()
360 case ('b') ! Switch branch
361 call branch_switch_prompt()
362 ! Refresh files after branch switch
363 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
364 hide_dotfiles, selected, running, exit_if_empty=.true., force_refresh=.true.)
365 ! Update branch name display
366 call get_repo_info(repo_name, branch_name)
367 case ('n') ! Create new branch
368 call branch_create_prompt()
369 ! Refresh files after branch creation
370 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
371 hide_dotfiles, selected, running, exit_if_empty=.true., force_refresh=.true.)
372 ! Update branch name display
373 call get_repo_info(repo_name, branch_name)
374 case ('R') ! Delete branch (Shift+r, since 'r' is used for delete file)
375 call branch_delete_prompt()
376 ! No need to refresh files or update branch name (stays on current branch)
377 case ('f') ! Git fetch
378 call git_fetch()
379 ! Refresh files after fetch and include files with incoming changes
380 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
381 hide_dotfiles, selected, running, include_incoming=.true., force_refresh=.true.)
382 case ('d') ! Git diff with less
383 if (items(selected)%is_file) then
384 call git_diff_file(items(selected)%path, items(selected)%has_incoming)
385 end if
386 case ('c') ! View file contents (cat/bat/less)
387 if (items(selected)%is_file) then
388 call view_file(items(selected)%path)
389 end if
390 case ('w') ! Git blame (who changed this line)
391 if (items(selected)%is_file) then
392 call blame_prompt(items(selected)%path)
393 end if
394 case ('r') ! Remove/delete file
395 if (items(selected)%is_file) then
396 call delete_prompt(items(selected)%path, items(selected)%is_untracked)
397 ! Refresh files after delete
398 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
399 hide_dotfiles, selected, running, exit_if_empty=.true., force_refresh=.true.)
400 end if
401 case ('x', 'X') ! Discard changes
402 if (items(selected)%is_file .and. (items(selected)%is_staged .or. items(selected)%is_unstaged .or. items(selected)%is_untracked)) then
403 call discard_prompt(items(selected)%path, items(selected)%is_staged, items(selected)%is_untracked)
404 ! Refresh files after discard
405 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
406 hide_dotfiles, selected, running, exit_if_empty=.true., force_refresh=.true.)
407 end if
408 case ('l') ! Git pull
409 call git_pull()
410 ! Refresh files after pull (incoming indicators will automatically clear)
411 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
412 hide_dotfiles, selected, running, include_incoming=.true., force_refresh=.true.)
413 ! Note: After successful pull, git diff will show no upstream differences
414 ! so has_incoming will be .false. for all files automatically
415 case ('z') ! Stash push (save changes)
416 call stash_push_prompt()
417 ! Refresh files after stash
418 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
419 hide_dotfiles, selected, running, exit_if_empty=.true., force_refresh=.true.)
420 case ('Z') ! Stash pop/apply (restore changes)
421 call stash_pop_apply_prompt()
422 ! Refresh files after stash pop/apply
423 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
424 hide_dotfiles, selected, running, force_refresh=.true.)
425 case ('y') ! Cherry-pick (yank commit)
426 call cherry_pick_prompt()
427 ! Refresh files after cherry-pick
428 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
429 hide_dotfiles, selected, running, force_refresh=.true.)
430 case ('v') ! Revert commit
431 call revert_commit_prompt()
432 ! Refresh files after revert
433 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
434 hide_dotfiles, selected, running, force_refresh=.true.)
435 case ('h') ! Show commit history
436 call history_browser_prompt()
437 ! No refresh needed - read-only
438 case ('L') ! Show reflog (Shift+l)
439 call reflog_browser_prompt()
440 ! No refresh needed - read-only
441 case ('G') ! Merge branch (Shift+g)
442 call merge_branch_prompt()
443 ! Refresh files after merge
444 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
445 hide_dotfiles, selected, running, force_refresh=.true.)
446 ! Update branch name display in case we merged
447 call get_repo_info(repo_name, branch_name)
448 case ('O') ! Reset (Shift+o - "Oh no, undo!")
449 call reset_prompt()
450 ! Refresh files after reset
451 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
452 hide_dotfiles, selected, running, force_refresh=.true.)
453 case ('I') ! Interactive rebase (Shift+i)
454 call rebase_prompt()
455 ! Refresh files after rebase
456 call refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
457 hide_dotfiles, selected, running, force_refresh=.true.)
458 case ('.') ! Toggle hiding dotfiles and gitignored files
459 hide_dotfiles = .not. hide_dotfiles
460 ! Rebuild item list with new filter
461 call build_item_list(files, n_files, items, n_items, tree_root, hide_dotfiles)
462 ! Adjust selection and visible_items for new item count
463 if (selected > n_items .and. n_items > 0) selected = n_items
464 if (n_items > 0 .and. selected < 1) selected = 1
465 ! Recalculate visible_items in case n_items changed
466 visible_items = term_height - 6
467 if (visible_items < 3) visible_items = 3
468 if (visible_items > n_items) visible_items = n_items
469 case ('q', 'Q') ! Quit
470 running = .false.
471 end select
472 end do
473
474 ! Restore terminal to normal state
475 call cleanup_terminal()
476
477 ! Free the tree
478 if (associated(tree_root)) then
479 call free_tree(tree_root)
480 end if
481
482 ! Final display (now in normal terminal buffer)
483 call clear_screen()
484 call build_and_display_tree(show_all)
485 end subroutine interactive_mode
486
487 subroutine build_item_list(files, n_files, items, n_items, tree_root, hide_dotfiles)
488 type(file_entry), intent(in) :: files(:)
489 integer, intent(in) :: n_files
490 type(selectable_item), allocatable, intent(out) :: items(:)
491 integer, intent(out) :: n_items
492 type(tree_node), pointer, intent(inout) :: tree_root
493 logical, intent(in) :: hide_dotfiles
494 type(selectable_item), allocatable :: temp_items(:)
495 integer :: i, max_items
496 character(len=512), allocatable :: collapsed_paths(:)
497 integer :: n_collapsed, max_collapsed
498
499 ! Save collapsed state from old tree if it exists
500 n_collapsed = 0
501 max_collapsed = 100
502 allocate(collapsed_paths(max_collapsed))
503 if (associated(tree_root)) then
504 call collect_collapsed_paths(tree_root, '', collapsed_paths, n_collapsed, max_collapsed)
505 call free_tree(tree_root)
506 end if
507
508 ! Build new tree
509 allocate(tree_root)
510 tree_root%name = '.'
511 tree_root%is_file = .false.
512 tree_root%is_staged = .false.
513 tree_root%is_unstaged = .false.
514 tree_root%is_untracked = .false.
515 tree_root%has_incoming = .false.
516 tree_root%is_expanded = .true. ! Root is always expanded
517 tree_root%first_child => null()
518 tree_root%next_sibling => null()
519
520 do i = 1, n_files
521 ! Skip gitignored files and dotfiles if hide_dotfiles is enabled
522 if (hide_dotfiles) then
523 ! Check if this is a gitignored file
524 if (files(i)%is_gitignored) then
525 cycle ! Skip this file
526 end if
527 ! Check if this is a dotfile (path starts with . or contains /.)
528 if (index(files(i)%path, '/.') > 0 .or. files(i)%path(1:1) == '.') then
529 cycle ! Skip this file
530 end if
531 end if
532 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)
533 end do
534
535 call sort_tree(tree_root)
536
537 ! Restore collapsed state to new tree (sort first for binary search optimization)
538 if (n_collapsed > 0) then
539 call quicksort_collapsed_paths(collapsed_paths, 1, n_collapsed)
540 call restore_collapsed_state(tree_root, '', collapsed_paths, n_collapsed)
541 end if
542 deallocate(collapsed_paths)
543
544 ! Collect items from tree in traversal order
545 max_items = 1000
546 allocate(temp_items(max_items))
547 n_items = 0
548
549 ! Traverse tree and collect all items
550 call collect_items_from_tree(tree_root, '', 0, temp_items, n_items, max_items, hide_dotfiles)
551
552 ! Copy to output
553 allocate(items(n_items))
554 if (n_items > 0) items(1:n_items) = temp_items(1:n_items)
555 deallocate(temp_items)
556
557 ! Don't free tree - it's kept alive for expand/collapse operations
558 end subroutine build_item_list
559
560 subroutine rebuild_item_list_from_tree(tree_root, items, n_items, hide_dotfiles)
561 type(tree_node), pointer, intent(in) :: tree_root
562 type(selectable_item), allocatable, intent(out) :: items(:)
563 integer, intent(out) :: n_items
564 logical, intent(in) :: hide_dotfiles
565 type(selectable_item), allocatable :: temp_items(:)
566 integer :: max_items
567
568 ! Collect items from existing tree
569 max_items = 1000
570 allocate(temp_items(max_items))
571 n_items = 0
572
573 ! Traverse tree and collect all items
574 call collect_items_from_tree(tree_root, '', 0, temp_items, n_items, max_items, hide_dotfiles)
575
576 ! Copy to output
577 allocate(items(n_items))
578 if (n_items > 0) items(1:n_items) = temp_items(1:n_items)
579 deallocate(temp_items)
580 end subroutine rebuild_item_list_from_tree
581
582 recursive subroutine collect_collapsed_paths(node, parent_path, collapsed_paths, n_collapsed, max_collapsed)
583 type(tree_node), pointer, intent(in) :: node
584 character(len=*), intent(in) :: parent_path
585 character(len=512), allocatable, intent(inout) :: collapsed_paths(:)
586 integer, intent(inout) :: n_collapsed, max_collapsed
587 type(tree_node), pointer :: child
588 character(len=512) :: full_path
589
590 ! Build full path for this node
591 if (len_trim(parent_path) == 0) then
592 full_path = trim(node%name)
593 else
594 full_path = trim(parent_path) // '/' // trim(node%name)
595 end if
596
597 ! If this is a collapsed directory, save its path
598 if (.not. node%is_file .and. .not. node%is_expanded) then
599 n_collapsed = n_collapsed + 1
600 if (n_collapsed > max_collapsed) then
601 ! Resize array
602 call resize_path_array(collapsed_paths, max_collapsed)
603 end if
604 collapsed_paths(n_collapsed) = trim(full_path)
605 end if
606
607 ! Recursively check children
608 child => node%first_child
609 do while (associated(child))
610 call collect_collapsed_paths(child, full_path, collapsed_paths, n_collapsed, max_collapsed)
611 child => child%next_sibling
612 end do
613 end subroutine collect_collapsed_paths
614
615 subroutine resize_path_array(paths, max_size)
616 character(len=512), allocatable, intent(inout) :: paths(:)
617 integer, intent(inout) :: max_size
618 character(len=512), allocatable :: temp_paths(:)
619 integer :: old_size
620
621 old_size = max_size
622 allocate(temp_paths(old_size))
623 temp_paths = paths(1:old_size)
624 deallocate(paths)
625 max_size = max_size * 2
626 allocate(paths(max_size))
627 paths(1:old_size) = temp_paths
628 deallocate(temp_paths)
629 end subroutine resize_path_array
630
631 ! ========== Performance Optimization: Binary Search for Collapsed Paths ==========
632 recursive subroutine quicksort_collapsed_paths(arr, low, high)
633 character(len=512), intent(inout) :: arr(:)
634 integer, intent(in) :: low, high
635 integer :: pivot_idx
636
637 if (low < high) then
638 call partition_collapsed_paths(arr, low, high, pivot_idx)
639 call quicksort_collapsed_paths(arr, low, pivot_idx - 1)
640 call quicksort_collapsed_paths(arr, pivot_idx + 1, high)
641 end if
642 end subroutine quicksort_collapsed_paths
643
644 subroutine partition_collapsed_paths(arr, low, high, pivot_idx)
645 character(len=512), intent(inout) :: arr(:)
646 integer, intent(in) :: low, high
647 integer, intent(out) :: pivot_idx
648 character(len=512) :: pivot, temp
649 integer :: i, j
650
651 pivot = trim(arr(high))
652 i = low - 1
653
654 do j = low, high - 1
655 if (trim(arr(j)) <= pivot) then
656 i = i + 1
657 temp = arr(i)
658 arr(i) = arr(j)
659 arr(j) = temp
660 end if
661 end do
662
663 temp = arr(i + 1)
664 arr(i + 1) = arr(high)
665 arr(high) = temp
666
667 pivot_idx = i + 1
668 end subroutine partition_collapsed_paths
669
670 function binary_search_path(paths, n, target) result(index)
671 character(len=512), intent(in) :: paths(:)
672 integer, intent(in) :: n
673 character(len=*), intent(in) :: target
674 integer :: index
675 integer :: low, high, mid
676 character(len=512) :: target_trimmed, mid_val
677
678 index = -1
679 if (n == 0) return
680
681 target_trimmed = trim(target)
682 low = 1
683 high = n
684
685 do while (low <= high)
686 mid = low + (high - low) / 2
687 mid_val = trim(paths(mid))
688
689 if (mid_val == target_trimmed) then
690 index = mid
691 return
692 else if (mid_val < target_trimmed) then
693 low = mid + 1
694 else
695 high = mid - 1
696 end if
697 end do
698 end function binary_search_path
699
700 recursive subroutine restore_collapsed_state(node, parent_path, collapsed_paths, n_collapsed)
701 type(tree_node), pointer, intent(inout) :: node
702 character(len=*), intent(in) :: parent_path
703 character(len=512), intent(in) :: collapsed_paths(:)
704 integer, intent(in) :: n_collapsed
705 type(tree_node), pointer :: child
706 character(len=512) :: full_path
707 integer :: idx
708
709 ! Build full path for this node
710 if (len_trim(parent_path) == 0) then
711 full_path = trim(node%name)
712 else
713 full_path = trim(parent_path) // '/' // trim(node%name)
714 end if
715
716 ! Check if this directory should be collapsed using binary search (O(log n) vs O(n))
717 if (.not. node%is_file) then
718 idx = binary_search_path(collapsed_paths, n_collapsed, full_path)
719 if (idx > 0) then
720 node%is_expanded = .false.
721 end if
722 end if
723
724 ! Recursively restore for children
725 child => node%first_child
726 do while (associated(child))
727 call restore_collapsed_state(child, full_path, collapsed_paths, n_collapsed)
728 child => child%next_sibling
729 end do
730 end subroutine restore_collapsed_state
731
732 recursive subroutine collect_items_from_tree(node, parent_path, depth, items, n_items, max_items, hide_dotfiles)
733 type(tree_node), pointer, intent(in) :: node
734 character(len=*), intent(in) :: parent_path
735 integer, intent(in) :: depth
736 type(selectable_item), allocatable, intent(inout) :: items(:)
737 integer, intent(inout) :: n_items, max_items
738 logical, intent(in) :: hide_dotfiles
739 type(tree_node), pointer :: child
740 character(len=512) :: full_path
741 logical :: is_root
742
743 ! Check if this is the root node
744 is_root = (len_trim(parent_path) == 0 .and. trim(node%name) == '.')
745
746 ! Build full path
747 if (is_root) then
748 full_path = ''
749 else if (len_trim(parent_path) == 0) then
750 full_path = trim(node%name)
751 else
752 full_path = trim(parent_path) // '/' // trim(node%name)
753 end if
754
755 ! Add this item to the list (unless it's root)
756 if (.not. is_root) then
757 n_items = n_items + 1
758 if (n_items > max_items) then
759 call resize_item_array(items, max_items)
760 end if
761
762 items(n_items)%path = trim(full_path)
763 items(n_items)%is_file = node%is_file
764 items(n_items)%is_staged = node%is_staged
765 items(n_items)%is_unstaged = node%is_unstaged
766 items(n_items)%is_untracked = node%is_untracked
767 items(n_items)%has_incoming = node%has_incoming
768 items(n_items)%is_gitignored = node%is_gitignored
769 items(n_items)%depth = depth
770 items(n_items)%node => node
771 end if
772
773 ! Recursively process children if this node is expanded
774 if (node%is_expanded) then
775 child => node%first_child
776 do while (associated(child))
777 call collect_items_from_tree(child, full_path, depth + 1, items, n_items, max_items, hide_dotfiles)
778 child => child%next_sibling
779 end do
780 end if
781 end subroutine collect_items_from_tree
782
783 subroutine resize_item_array(items, max_items)
784 type(selectable_item), allocatable, intent(inout) :: items(:)
785 integer, intent(inout) :: max_items
786 type(selectable_item), allocatable :: temp_items(:)
787 integer :: old_size
788
789 old_size = max_items
790 allocate(temp_items(old_size))
791 temp_items = items(1:old_size)
792 deallocate(items)
793 max_items = max_items * 2
794 allocate(items(max_items))
795 items(1:old_size) = temp_items
796 deallocate(temp_items)
797 end subroutine resize_item_array
798
799 ! ========== Navigation Functions for New Navigation Model ==========
800
801 subroutine navigate_down(items, n_items, selected)
802 type(selectable_item), intent(in) :: items(:)
803 integer, intent(in) :: n_items
804 integer, intent(inout) :: selected
805 integer :: current_depth, i
806
807 if (n_items == 0) return
808
809 current_depth = items(selected)%depth
810
811 ! Search forward for next item at same depth
812 do i = selected + 1, n_items
813 if (items(i)%depth == current_depth) then
814 selected = i
815 return
816 end if
817 end do
818
819 ! No item found - wrap to beginning
820 do i = 1, selected - 1
821 if (items(i)%depth == current_depth) then
822 selected = i
823 return
824 end if
825 end do
826 ! If we get here, we're the only item at this depth, so stay put
827 end subroutine navigate_down
828
829 subroutine navigate_up(items, n_items, selected)
830 type(selectable_item), intent(in) :: items(:)
831 integer, intent(in) :: n_items
832 integer, intent(inout) :: selected
833 integer :: current_depth, i
834
835 if (n_items == 0) return
836
837 current_depth = items(selected)%depth
838
839 ! Search backward for previous item at same depth
840 do i = selected - 1, 1, -1
841 if (items(i)%depth == current_depth) then
842 selected = i
843 return
844 end if
845 end do
846
847 ! No item found - wrap to end
848 do i = n_items, selected + 1, -1
849 if (items(i)%depth == current_depth) then
850 selected = i
851 return
852 end if
853 end do
854 ! If we get here, we're the only item at this depth, so stay put
855 end subroutine navigate_up
856
857 subroutine navigate_right(items, n_items, selected, tree_root, hide_dotfiles)
858 type(selectable_item), allocatable, intent(inout) :: items(:)
859 integer, intent(inout) :: n_items
860 integer, intent(inout) :: selected
861 type(tree_node), pointer, intent(in) :: tree_root
862 logical, intent(in) :: hide_dotfiles
863 integer :: i, target_depth
864
865 if (n_items == 0) return
866 if (items(selected)%is_file) return ! Can't enter a file
867
868 ! We're on a directory
869 if (.not. items(selected)%node%is_expanded) then
870 ! Directory is collapsed - expand it
871 items(selected)%node%is_expanded = .true.
872 ! Rebuild item list
873 call rebuild_item_list_from_tree(tree_root, items, n_items, hide_dotfiles)
874 ! Adjust selection if needed
875 if (selected > n_items .and. n_items > 0) selected = n_items
876 end if
877
878 ! Now move to first child (next item with depth+1)
879 target_depth = items(selected)%depth + 1
880 do i = selected + 1, n_items
881 if (items(i)%depth == target_depth) then
882 selected = i
883 return
884 end if
885 end do
886 ! No children - stay on directory
887 end subroutine navigate_right
888
889 subroutine navigate_left(items, n_items, selected)
890 type(selectable_item), allocatable, intent(inout) :: items(:)
891 integer, intent(inout) :: n_items
892 integer, intent(inout) :: selected
893 integer :: i, target_depth
894
895 if (n_items == 0) return
896
897 ! Move to parent (previous item with depth-1)
898 target_depth = items(selected)%depth - 1
899 if (target_depth < 0) return ! Already at root level
900
901 ! Search backward for parent
902 do i = selected - 1, 1, -1
903 if (items(i)%depth == target_depth) then
904 selected = i
905 return
906 end if
907 end do
908 end subroutine navigate_left
909
910 subroutine commit_prompt()
911 character(len=512) :: commit_msg
912 logical :: success
913 character(len=1) :: key
914
915 ! Clear screen for commit prompt
916 call clear_screen()
917 print '(A)', achar(27) // '[1mGit Commit' // achar(27) // '[0m'
918 print '(A)', ''
919
920 ! Read commit message
921 call read_line('Commit message: ', commit_msg)
922
923 ! Execute commit if message is not empty
924 if (len_trim(commit_msg) > 0) then
925 call git_commit_with_message(commit_msg, success)
926
927 ! Wait for keypress to continue
928 call read_key(key)
929 end if
930 end subroutine commit_prompt
931
932 subroutine amend_commit_prompt()
933 character(len=512) :: commit_msg, last_commit_msg
934 logical :: success
935 character(len=1) :: key
936
937 ! Clear screen for amend commit prompt
938 call clear_screen()
939 print '(A)', achar(27) // '[1mGit Commit --amend' // achar(27) // '[0m'
940 print '(A)', ''
941
942 ! Get the last commit message as default
943 call get_last_commit_message(last_commit_msg)
944
945 ! Show the last commit message
946 if (len_trim(last_commit_msg) > 0) then
947 print '(A)', achar(27) // '[2mLast commit message:' // achar(27) // '[0m'
948 print '(A)', ' ' // trim(last_commit_msg)
949 print '(A)', ''
950 end if
951
952 ! Read new commit message
953 call read_line('New commit message (empty to keep): ', commit_msg)
954
955 ! If no message provided, keep the old one
956 if (len_trim(commit_msg) == 0) then
957 commit_msg = last_commit_msg
958 end if
959
960 ! Execute amend if we have a message
961 if (len_trim(commit_msg) > 0) then
962 call git_commit_amend(commit_msg, success)
963
964 ! Wait for keypress to continue
965 call read_key(key)
966 else
967 print '(A)', 'No commit message provided. Amend cancelled.'
968 print '(A)', 'Press any key to continue...'
969 call read_key(key)
970 end if
971 end subroutine amend_commit_prompt
972
973 subroutine show_status_view()
974 ! Use less for scrollable, searchable git status view
975 call show_git_status_paged()
976 end subroutine show_status_view
977
978 subroutine push_prompt()
979 logical :: success
980 character(len=1) :: key
981
982 ! Clear screen for push prompt
983 call clear_screen()
984 print '(A)', achar(27) // '[1mGit Push' // achar(27) // '[0m'
985 print '(A)', ''
986 print '(A)', 'Pushing to remote...'
987 print '(A)', ''
988
989 ! Execute push
990 call git_push(success)
991
992 ! Wait for keypress to continue
993 call read_key(key)
994 end subroutine push_prompt
995
996 subroutine tag_prompt()
997 character(len=512) :: tag_name, tag_message
998 logical :: success, push_tag
999 character(len=1) :: key
1000 integer :: status
1001
1002 ! Clear screen for tag prompt
1003 call clear_screen()
1004 print '(A)', achar(27) // '[1mGit Tag' // achar(27) // '[0m'
1005 print '(A)', ''
1006
1007 ! Fetch tags from remote to ensure list is up to date
1008 print '(A)', 'Fetching tags from remote...'
1009 call execute_command_line('git fetch --tags --quiet 2>&1', exitstat=status)
1010 print '(A)', ''
1011
1012 ! Show existing tags in compact format
1013 print '(A)', achar(27) // '[2mExisting tags:' // achar(27) // '[0m'
1014 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)
1015 print '(A)', ''
1016
1017 ! Read tag name
1018 call read_line('Tag name: ', tag_name)
1019
1020 ! Execute tag if name is not empty
1021 if (len_trim(tag_name) > 0) then
1022 ! Read tag message (optional)
1023 call read_line('Tag message (enter for none): ', tag_message)
1024
1025 call git_tag(tag_name, tag_message, success)
1026
1027 if (success) then
1028 ! Ask if user wants to push the tag
1029 print '(A)', ''
1030 print '(A)', 'Push tag to origin? (y/n)'
1031 call read_key(key)
1032
1033 if (key == 'y' .or. key == 'Y') then
1034 call git_push_tag(tag_name, push_tag)
1035 end if
1036 end if
1037
1038 ! Wait for keypress to continue
1039 print '(A)', 'Press any key to continue...'
1040 call read_key(key)
1041 end if
1042 end subroutine tag_prompt
1043
1044 subroutine branch_switch_prompt()
1045 logical :: success
1046
1047 ! Clear screen for branch switch
1048 call clear_screen()
1049 print '(A)', achar(27) // '[1mSwitch Branch' // achar(27) // '[0m'
1050 print '(A)', ''
1051
1052 ! Call git branch switch with fzf
1053 call git_switch_branch(success)
1054 end subroutine branch_switch_prompt
1055
1056 subroutine delete_prompt(filepath, is_untracked)
1057 character(len=*), intent(in) :: filepath
1058 logical, intent(in) :: is_untracked
1059 logical :: deleted
1060 character(len=1) :: key
1061
1062 ! Clear screen for delete prompt
1063 call clear_screen()
1064 print '(A)', achar(27) // '[1mDelete File' // achar(27) // '[0m'
1065 print '(A)', ''
1066
1067 ! Execute delete with confirmation
1068 call git_delete_file(filepath, is_untracked, deleted)
1069
1070 ! Wait for keypress to continue
1071 call read_key(key)
1072 end subroutine delete_prompt
1073
1074 subroutine discard_prompt(filepath, is_staged, is_untracked)
1075 character(len=*), intent(in) :: filepath
1076 logical, intent(in) :: is_staged, is_untracked
1077 logical :: discarded
1078 character(len=1) :: key
1079
1080 ! Clear screen for discard prompt
1081 call clear_screen()
1082 print '(A)', achar(27) // '[1mDiscard Changes' // achar(27) // '[0m'
1083 print '(A)', ''
1084
1085 ! Execute discard with confirmation
1086 call git_discard_changes(filepath, is_staged, is_untracked, discarded)
1087
1088 ! Wait for keypress to continue
1089 call read_key(key)
1090 end subroutine discard_prompt
1091
1092 subroutine stash_push_prompt()
1093 character(len=512) :: stash_msg
1094 logical :: success
1095 character(len=1) :: key
1096
1097 ! Clear screen for stash prompt
1098 call clear_screen()
1099 print '(A)', achar(27) // '[1mGit Stash (Save)' // achar(27) // '[0m'
1100 print '(A)', ''
1101
1102 ! Read stash message (optional)
1103 call read_line('Stash message (optional): ', stash_msg)
1104
1105 ! Execute stash push
1106 call git_stash_push(stash_msg, success)
1107
1108 ! Wait for keypress to continue
1109 call read_key(key)
1110 end subroutine stash_push_prompt
1111
1112 subroutine stash_pop_apply_prompt()
1113 logical :: success
1114 character(len=1) :: key
1115
1116 ! Clear screen for stash pop/apply prompt
1117 call clear_screen()
1118 print '(A)', achar(27) // '[1mGit Stash (Pop/Apply)' // achar(27) // '[0m'
1119 print '(A)', ''
1120
1121 ! Execute stash pop/apply with fzf selection
1122 call git_stash_pop_apply(success)
1123
1124 ! Wait for keypress to continue
1125 call read_key(key)
1126 end subroutine stash_pop_apply_prompt
1127
1128 subroutine cherry_pick_prompt()
1129 logical :: success
1130
1131 ! Clear screen for cherry-pick
1132 call clear_screen()
1133
1134 ! Call git cherry-pick (handles its own prompts and key wait)
1135 call git_cherry_pick(success)
1136 end subroutine cherry_pick_prompt
1137
1138 subroutine revert_commit_prompt()
1139 logical :: success
1140
1141 ! Clear screen for revert
1142 call clear_screen()
1143
1144 ! Call git revert (handles its own prompts and key wait)
1145 call git_revert_commit(success)
1146 end subroutine revert_commit_prompt
1147
1148 subroutine history_browser_prompt()
1149 ! Clear screen for history browser
1150 call clear_screen()
1151
1152 ! Call git history browser (handles its own terminal setup)
1153 call git_show_history()
1154 end subroutine history_browser_prompt
1155
1156 subroutine reflog_browser_prompt()
1157 ! Clear screen for reflog browser
1158 call clear_screen()
1159
1160 ! Call git reflog browser (handles its own terminal setup)
1161 call git_show_reflog()
1162 end subroutine reflog_browser_prompt
1163
1164 subroutine merge_branch_prompt()
1165 logical :: success
1166
1167 ! Clear screen for merge
1168 call clear_screen()
1169
1170 ! Call git merge (handles its own prompts and key wait)
1171 call git_merge_branch(success)
1172 end subroutine merge_branch_prompt
1173
1174 subroutine blame_prompt(filepath)
1175 character(len=*), intent(in) :: filepath
1176
1177 ! Clear screen for blame view
1178 call clear_screen()
1179
1180 ! Call git blame (handles its own display and key wait)
1181 call git_blame_file(filepath)
1182 end subroutine blame_prompt
1183
1184 subroutine reset_prompt()
1185 logical :: success
1186
1187 ! Clear screen for reset
1188 call clear_screen()
1189
1190 ! Call git reset (handles its own prompts and key wait)
1191 call git_reset_interactive(success)
1192 end subroutine reset_prompt
1193
1194 subroutine rebase_prompt()
1195 logical :: success
1196
1197 ! Clear screen for rebase
1198 call clear_screen()
1199
1200 ! Call git rebase (handles its own prompts and key wait)
1201 call git_interactive_rebase(success)
1202 end subroutine rebase_prompt
1203
1204 subroutine branch_create_prompt()
1205 character(len=512) :: branch_name
1206 logical :: success
1207 character(len=1) :: key
1208
1209 ! Clear screen for branch creation
1210 call clear_screen()
1211 print '(A)', achar(27) // '[1mCreate New Branch' // achar(27) // '[0m'
1212 print '(A)', ''
1213
1214 ! Read branch name
1215 call read_line('New branch name: ', branch_name)
1216
1217 ! Create branch if name is not empty
1218 if (len_trim(branch_name) > 0) then
1219 call git_create_branch(branch_name, success)
1220 ! Wait for keypress to continue
1221 call read_key(key)
1222 end if
1223 end subroutine branch_create_prompt
1224
1225 subroutine branch_delete_prompt()
1226 logical :: success
1227 character(len=1) :: key
1228
1229 ! Clear screen for branch deletion
1230 call clear_screen()
1231 print '(A)', achar(27) // '[1mDelete Branch' // achar(27) // '[0m'
1232 print '(A)', ''
1233
1234 ! Call git branch delete with fzf
1235 call git_delete_branch(success)
1236
1237 ! Wait for keypress to continue
1238 call read_key(key)
1239 end subroutine branch_delete_prompt
1240
1241 subroutine refresh_and_rebuild(show_all, files, n_files, items, n_items, tree_root, &
1242 hide_dotfiles, selected, running, exit_if_empty, include_incoming, force_refresh)
1243 ! Centralized helper to refresh file list and rebuild tree
1244 ! Consolidates the pattern repeated 20+ times in the codebase
1245 ! Now with caching support for performance optimization
1246 logical, intent(in) :: show_all, hide_dotfiles
1247 type(file_entry), allocatable, intent(inout) :: files(:)
1248 integer, intent(inout) :: n_files, n_items, selected
1249 type(tree_node), pointer, intent(inout) :: tree_root
1250 type(selectable_item), allocatable, intent(inout) :: items(:)
1251 logical, intent(inout), optional :: running
1252 logical, intent(in), optional :: exit_if_empty, include_incoming, force_refresh
1253
1254 logical :: do_exit_if_empty, do_include_incoming, do_force_refresh
1255
1256 ! Handle optional parameters
1257 do_exit_if_empty = .false.
1258 if (present(exit_if_empty)) do_exit_if_empty = exit_if_empty
1259
1260 do_include_incoming = .false.
1261 if (present(include_incoming)) do_include_incoming = include_incoming
1262
1263 do_force_refresh = .false.
1264 if (present(force_refresh)) do_force_refresh = force_refresh
1265
1266 ! Get files based on mode (with caching support)
1267 if (show_all) then
1268 call get_all_files(files, n_files, force_refresh=do_force_refresh)
1269 call mark_incoming_changes(files, n_files)
1270 else
1271 call get_dirty_files(files, n_files, force_refresh=do_force_refresh)
1272 if (do_include_incoming) then
1273 ! For fetch/pull: also include files with only incoming changes
1274 call add_incoming_files(files, n_files)
1275 else
1276 call mark_incoming_changes(files, n_files)
1277 end if
1278 end if
1279
1280 ! Rebuild tree and flatten to items
1281 call build_item_list(files, n_files, items, n_items, tree_root, hide_dotfiles)
1282
1283 ! Adjust selection if needed
1284 if (selected > n_items .and. n_items > 0) selected = n_items
1285
1286 ! Exit if no items and exit_if_empty is set
1287 if (do_exit_if_empty .and. n_items == 0) then
1288 if (present(running)) running = .false.
1289 end if
1290 end subroutine refresh_and_rebuild
1291
1292 end program fuss