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