Fortran · 42584 bytes Raw Blame History
1 program fuss
2 use iso_fortran_env, only: error_unit
3 implicit none
4
5 ! Tree node using linked list structure (first-child, next-sibling)
6 type :: tree_node
7 character(len=256) :: name
8 logical :: is_file
9 logical :: is_staged
10 logical :: is_unstaged
11 logical :: is_untracked
12 logical :: has_incoming
13 type(tree_node), pointer :: first_child => null()
14 type(tree_node), pointer :: next_sibling => null()
15 end type tree_node
16
17 type :: file_entry
18 character(len=512) :: path
19 character(len=2) :: status
20 logical :: is_staged
21 logical :: is_unstaged
22 logical :: is_untracked
23 logical :: has_incoming
24 end type file_entry
25
26 type :: selectable_item
27 character(len=512) :: path
28 logical :: is_staged
29 logical :: is_unstaged
30 logical :: is_untracked
31 logical :: has_incoming
32 logical :: is_file
33 end type selectable_item
34
35 ! Main program variables
36 logical :: show_all, interactive
37 character(len=:), allocatable :: root_path
38
39 ! Parse command line arguments
40 call parse_arguments(show_all, interactive)
41
42 ! Get current directory
43 call get_current_dir(root_path)
44
45 ! Build and display tree
46 if (interactive) then
47 call interactive_mode(show_all)
48 else
49 call build_and_display_tree(root_path, show_all)
50 end if
51
52 contains
53
54 subroutine parse_arguments(show_all, interactive)
55 logical, intent(out) :: show_all, interactive
56 integer :: i, nargs
57 character(len=256) :: arg
58
59 show_all = .false.
60 interactive = .false.
61 nargs = command_argument_count()
62
63 do i = 1, nargs
64 call get_command_argument(i, arg)
65 if (trim(arg) == '--all' .or. trim(arg) == '-a') then
66 show_all = .true.
67 else if (trim(arg) == '-i' .or. trim(arg) == '--interactive') then
68 interactive = .true.
69 end if
70 end do
71 end subroutine parse_arguments
72
73 subroutine get_current_dir(path)
74 character(len=:), allocatable, intent(out) :: path
75 character(len=1024) :: buffer
76 integer :: status
77
78 call execute_command_line('pwd > /tmp/fuss_pwd.txt', exitstat=status)
79
80 open(unit=99, file='/tmp/fuss_pwd.txt', status='old', action='read')
81 read(99, '(A)') buffer
82 close(99, status='delete')
83
84 path = trim(buffer)
85 end subroutine get_current_dir
86
87 subroutine build_and_display_tree(root_path, show_all)
88 character(len=*), intent(in) :: root_path
89 logical, intent(in) :: show_all
90 type(file_entry), allocatable :: files(:)
91 integer :: n_files
92
93 ! Get files from git or filesystem
94 if (show_all) then
95 call get_all_files(files, n_files)
96 else
97 call get_dirty_files(files, n_files)
98 end if
99
100 ! Mark files with incoming changes
101 call mark_incoming_changes(files, n_files)
102
103 ! Display the tree
104 if (n_files > 0) then
105 print '(A)', '.'
106 call display_tree(files, n_files)
107 else
108 print '(A)', 'No files to display'
109 end if
110 end subroutine build_and_display_tree
111
112 subroutine get_dirty_files(files, n_files)
113 type(file_entry), allocatable, intent(out) :: files(:)
114 integer, intent(out) :: n_files
115 integer :: iostat, unit_num, status_code
116 character(len=1024) :: line
117 character(len=512) :: file_path
118 character(len=2) :: git_status
119 integer :: max_files
120 type(file_entry), allocatable :: temp_files(:)
121
122 max_files = 1000
123 allocate(temp_files(max_files))
124 n_files = 0
125
126 ! Execute git status
127 call execute_command_line('git status --porcelain > /tmp/fuss_git_status.txt', exitstat=status_code)
128
129 if (status_code /= 0) then
130 write(error_unit, '(A)') 'Error: Not a git repository or git command failed'
131 allocate(files(0))
132 return
133 end if
134
135 ! Read git status output
136 open(newunit=unit_num, file='/tmp/fuss_git_status.txt', status='old', action='read', iostat=iostat)
137
138 if (iostat /= 0) then
139 allocate(files(0))
140 return
141 end if
142
143 do
144 read(unit_num, '(A)', iostat=iostat) line
145 if (iostat /= 0) exit
146
147 if (len_trim(line) > 3) then
148 ! Parse git status line (format: "XY filename")
149 git_status = line(1:2)
150 file_path = adjustl(line(4:))
151
152 ! Skip if path is empty
153 if (len_trim(file_path) == 0) cycle
154
155 ! Check if this is a directory entry (ending with /)
156 if (len_trim(file_path) > 0) then
157 if (file_path(len_trim(file_path):len_trim(file_path)) == '/') then
158 ! Directory entry - expand it to find all files inside
159 call expand_directory(file_path, git_status, temp_files, n_files, max_files)
160 cycle
161 end if
162 end if
163
164 n_files = n_files + 1
165 if (n_files > max_files) then
166 max_files = max_files * 2
167 call resize_array(temp_files, max_files)
168 end if
169
170 temp_files(n_files)%status = git_status
171 temp_files(n_files)%path = trim(file_path)
172 ! Column 1 = staged status, Column 2 = unstaged status
173 temp_files(n_files)%is_untracked = (git_status == '??')
174 temp_files(n_files)%is_staged = (git_status(1:1) /= ' ' .and. git_status(1:1) /= '?')
175 temp_files(n_files)%is_unstaged = (git_status(2:2) /= ' ' .and. .not. temp_files(n_files)%is_untracked)
176 temp_files(n_files)%has_incoming = .false.
177 end if
178 end do
179
180 close(unit_num, status='delete')
181
182 ! Copy to output array
183 allocate(files(n_files))
184 if (n_files > 0) files(1:n_files) = temp_files(1:n_files)
185 deallocate(temp_files)
186 end subroutine get_dirty_files
187
188 subroutine get_all_files(files, n_files)
189 type(file_entry), allocatable, intent(out) :: files(:)
190 integer, intent(out) :: n_files
191 integer :: iostat, unit_num, status_code, i
192 character(len=1024) :: line
193 type(file_entry), allocatable :: dirty_files(:), temp_files(:)
194 integer :: n_dirty, max_files
195 logical :: is_dirty_file
196
197 ! First get dirty files
198 call get_dirty_files(dirty_files, n_dirty)
199
200 ! Get all files using find
201 call execute_command_line('find . -type f ! -path "*/\.git/*" > /tmp/fuss_all_files.txt', exitstat=status_code)
202
203 if (status_code /= 0) then
204 ! If find fails, just return dirty files
205 allocate(files(n_dirty))
206 if (n_dirty > 0) files = dirty_files
207 n_files = n_dirty
208 if (allocated(dirty_files)) deallocate(dirty_files)
209 return
210 end if
211
212 open(newunit=unit_num, file='/tmp/fuss_all_files.txt', status='old', action='read', iostat=iostat)
213
214 if (iostat /= 0) then
215 ! If open fails, just return dirty files
216 allocate(files(n_dirty))
217 if (n_dirty > 0) files = dirty_files
218 n_files = n_dirty
219 if (allocated(dirty_files)) deallocate(dirty_files)
220 return
221 end if
222
223 max_files = 1000
224 allocate(temp_files(max_files))
225 n_files = 0
226
227 do
228 read(unit_num, '(A)', iostat=iostat) line
229 if (iostat /= 0) exit
230
231 if (len_trim(line) > 0) then
232 ! Remove leading "./"
233 if (len(line) >= 2) then
234 if (line(1:2) == './') line = line(3:)
235 end if
236
237 ! Skip if path is empty after trimming
238 if (len_trim(line) == 0) cycle
239
240 n_files = n_files + 1
241 if (n_files > max_files) then
242 max_files = max_files * 2
243 call resize_array(temp_files, max_files)
244 end if
245
246 ! Check if file is dirty and get status
247 is_dirty_file = .false.
248 temp_files(n_files)%status = ' ' ! Initialize as clean
249 temp_files(n_files)%is_staged = .false.
250 temp_files(n_files)%is_unstaged = .false.
251 temp_files(n_files)%is_untracked = .false.
252 temp_files(n_files)%has_incoming = .false.
253 do i = 1, n_dirty
254 if (trim(dirty_files(i)%path) == trim(line)) then
255 is_dirty_file = .true.
256 temp_files(n_files)%status = dirty_files(i)%status
257 temp_files(n_files)%is_staged = dirty_files(i)%is_staged
258 temp_files(n_files)%is_unstaged = dirty_files(i)%is_unstaged
259 temp_files(n_files)%is_untracked = dirty_files(i)%is_untracked
260 temp_files(n_files)%has_incoming = dirty_files(i)%has_incoming
261 exit
262 end if
263 end do
264
265 temp_files(n_files)%path = trim(line)
266 end if
267 end do
268
269 close(unit_num, status='delete')
270
271 allocate(files(n_files))
272 if (n_files > 0) files(1:n_files) = temp_files(1:n_files)
273 deallocate(temp_files)
274 if (allocated(dirty_files)) deallocate(dirty_files)
275 end subroutine get_all_files
276
277 subroutine mark_incoming_changes(files, n_files)
278 type(file_entry), intent(inout) :: files(:)
279 integer, intent(in) :: n_files
280 integer :: iostat, unit_num, status_code, i
281 character(len=1024) :: line
282 character(len=512) :: incoming_path
283
284 ! Check if there's an upstream branch configured
285 call execute_command_line('git rev-parse --abbrev-ref @{upstream} > /dev/null 2>&1', exitstat=status_code)
286 if (status_code /= 0) then
287 ! No upstream configured, no incoming changes possible
288 return
289 end if
290
291 ! Get list of files that differ between HEAD and upstream
292 call execute_command_line('git diff --name-only HEAD...@{upstream} > /tmp/fuss_incoming.txt 2>/dev/null', &
293 exitstat=status_code)
294
295 if (status_code /= 0) then
296 ! If diff fails, no incoming changes
297 return
298 end if
299
300 open(newunit=unit_num, file='/tmp/fuss_incoming.txt', status='old', action='read', iostat=iostat)
301 if (iostat /= 0) return
302
303 do
304 read(unit_num, '(A)', iostat=iostat) line
305 if (iostat /= 0) exit
306
307 if (len_trim(line) > 0) then
308 incoming_path = trim(line)
309 ! Mark this file as having incoming changes
310 do i = 1, n_files
311 if (trim(files(i)%path) == trim(incoming_path)) then
312 files(i)%has_incoming = .true.
313 exit
314 end if
315 end do
316 end if
317 end do
318
319 close(unit_num, status='delete')
320 end subroutine mark_incoming_changes
321
322 subroutine resize_array(array, new_size)
323 type(file_entry), allocatable, intent(inout) :: array(:)
324 integer, intent(in) :: new_size
325 type(file_entry), allocatable :: temp(:)
326 integer :: old_size
327
328 old_size = size(array)
329 allocate(temp(old_size))
330 temp = array
331 deallocate(array)
332 allocate(array(new_size))
333 array(1:old_size) = temp
334 deallocate(temp)
335 end subroutine resize_array
336
337 subroutine expand_directory(dir_path, git_status, files, n_files, max_files)
338 character(len=*), intent(in) :: dir_path, git_status
339 type(file_entry), allocatable, intent(inout) :: files(:)
340 integer, intent(inout) :: n_files, max_files
341 integer :: iostat, unit_num, status_code
342 character(len=1024) :: line, command
343 character(len=512) :: dir_no_slash
344
345 ! Remove trailing slash
346 dir_no_slash = dir_path(1:len_trim(dir_path)-1)
347
348 ! Use find to list all files in this directory
349 write(command, '(A,A,A)') 'find "', trim(dir_no_slash), '" -type f > /tmp/fuss_expand_dir.txt'
350 call execute_command_line(trim(command), exitstat=status_code)
351
352 if (status_code /= 0) return
353
354 open(newunit=unit_num, file='/tmp/fuss_expand_dir.txt', status='old', action='read', iostat=iostat)
355 if (iostat /= 0) return
356
357 do
358 read(unit_num, '(A)', iostat=iostat) line
359 if (iostat /= 0) exit
360
361 if (len_trim(line) > 0) then
362 ! Remove leading "./" if present
363 if (len(line) >= 2) then
364 if (line(1:2) == './') line = line(3:)
365 end if
366
367 if (len_trim(line) == 0) cycle
368
369 n_files = n_files + 1
370 if (n_files > max_files) then
371 max_files = max_files * 2
372 call resize_array(files, max_files)
373 end if
374
375 files(n_files)%status = git_status
376 files(n_files)%path = trim(line)
377 files(n_files)%is_untracked = (git_status == '??')
378 files(n_files)%is_staged = (git_status(1:1) /= ' ' .and. git_status(1:1) /= '?')
379 files(n_files)%is_unstaged = (git_status(2:2) /= ' ' .and. .not. files(n_files)%is_untracked)
380 files(n_files)%has_incoming = .false.
381 end if
382 end do
383
384 close(unit_num, status='delete')
385 end subroutine expand_directory
386
387 subroutine display_tree(files, n_files)
388 type(file_entry), intent(in) :: files(:)
389 integer, intent(in) :: n_files
390 type(tree_node), pointer :: root
391 integer :: i
392
393 ! Create root
394 allocate(root)
395 root%name = '.'
396 root%is_file = .false.
397 root%is_staged = .false.
398 root%is_unstaged = .false.
399 root%is_untracked = .false.
400 root%has_incoming = .false.
401 root%first_child => null()
402 root%next_sibling => null()
403
404 ! Build tree
405 do i = 1, n_files
406 call add_to_tree(root, files(i)%path, files(i)%is_staged, files(i)%is_unstaged, files(i)%is_untracked, files(i)%has_incoming)
407 end do
408
409 ! Sort tree (directories first, then alphabetically)
410 call sort_tree(root)
411
412 ! Print tree
413 call print_tree_node(root, '', .true., .true.)
414
415 ! Cleanup
416 call free_tree(root)
417 end subroutine display_tree
418
419 subroutine interactive_mode(show_all)
420 logical, intent(in) :: show_all
421 type(file_entry), allocatable :: files(:)
422 type(selectable_item), allocatable :: items(:)
423 integer :: n_files, n_items, selected, i, status
424 character(len=1) :: key
425 logical :: running
426
427 ! Get files
428 if (show_all) then
429 call get_all_files(files, n_files)
430 else
431 call get_dirty_files(files, n_files)
432 end if
433
434 ! Mark files with incoming changes
435 call mark_incoming_changes(files, n_files)
436
437 if (n_files == 0) then
438 print '(A)', 'No files to display'
439 return
440 end if
441
442 ! Build flat list of items for navigation
443 call build_item_list(files, n_files, items, n_items)
444
445 ! Initialize selection
446 selected = 1
447 running = .true.
448
449 ! Enable raw terminal mode
450 call enable_raw_mode()
451
452 ! Main interactive loop
453 do while (running)
454 ! Clear screen and redraw
455 call clear_screen()
456 call draw_interactive_tree(files, n_files, items, n_items, selected)
457
458 ! Read key
459 call read_key(key)
460
461 ! Handle input
462 select case (key)
463 case ('j', 'B') ! j or down arrow
464 if (selected < n_items) selected = selected + 1
465 case ('k', 'A') ! k or up arrow
466 if (selected > 1) selected = selected - 1
467 case (achar(10), achar(13), ' ') ! Enter or Space
468 if (items(selected)%is_file .and. (items(selected)%is_unstaged .or. items(selected)%is_untracked)) then
469 call git_add_file(items(selected)%path)
470 ! Refresh files after git add
471 if (show_all) then
472 call get_all_files(files, n_files)
473 else
474 call get_dirty_files(files, n_files)
475 end if
476 call mark_incoming_changes(files, n_files)
477 call build_item_list(files, n_files, items, n_items)
478 if (selected > n_items .and. n_items > 0) selected = n_items
479 if (n_items == 0) running = .false.
480 end if
481 case ('f', 'F') ! Git fetch
482 call git_fetch()
483 ! Refresh files after fetch to update incoming indicators
484 if (show_all) then
485 call get_all_files(files, n_files)
486 else
487 call get_dirty_files(files, n_files)
488 end if
489 call mark_incoming_changes(files, n_files)
490 call build_item_list(files, n_files, items, n_items)
491 if (selected > n_items .and. n_items > 0) selected = n_items
492 case ('d', 'D') ! Git diff with less
493 if (items(selected)%is_file) then
494 call git_diff_file(items(selected)%path)
495 end if
496 case ('l', 'L') ! Git pull
497 call git_pull()
498 ! Refresh files after pull
499 if (show_all) then
500 call get_all_files(files, n_files)
501 else
502 call get_dirty_files(files, n_files)
503 end if
504 call mark_incoming_changes(files, n_files)
505 call build_item_list(files, n_files, items, n_items)
506 if (selected > n_items .and. n_items > 0) selected = n_items
507 case ('q', 'Q') ! Quit
508 running = .false.
509 end select
510 end do
511
512 ! Restore terminal
513 call disable_raw_mode()
514
515 ! Final display
516 call clear_screen()
517 call build_and_display_tree('', show_all)
518 end subroutine interactive_mode
519
520 subroutine build_item_list(files, n_files, items, n_items)
521 type(file_entry), intent(in) :: files(:)
522 integer, intent(in) :: n_files
523 type(selectable_item), allocatable, intent(out) :: items(:)
524 integer, intent(out) :: n_items
525 type(tree_node), pointer :: root
526 type(selectable_item), allocatable :: temp_items(:)
527 integer :: i, max_items
528
529 ! Build the tree first
530 allocate(root)
531 root%name = '.'
532 root%is_file = .false.
533 root%is_staged = .false.
534 root%is_unstaged = .false.
535 root%is_untracked = .false.
536 root%has_incoming = .false.
537 root%first_child => null()
538 root%next_sibling => null()
539
540 do i = 1, n_files
541 call add_to_tree(root, files(i)%path, files(i)%is_staged, files(i)%is_unstaged, files(i)%is_untracked, files(i)%has_incoming)
542 end do
543
544 call sort_tree(root)
545
546 ! Collect items from tree in traversal order
547 max_items = 1000
548 allocate(temp_items(max_items))
549 n_items = 0
550
551 ! Traverse tree and collect all items (files and directories)
552 call collect_items_from_tree(root, '', temp_items, n_items, max_items)
553
554 ! Copy to output
555 allocate(items(n_items))
556 if (n_items > 0) items(1:n_items) = temp_items(1:n_items)
557 deallocate(temp_items)
558
559 call free_tree(root)
560 end subroutine build_item_list
561
562 recursive subroutine collect_items_from_tree(node, parent_path, items, n_items, max_items)
563 type(tree_node), pointer, intent(in) :: node
564 character(len=*), intent(in) :: parent_path
565 type(selectable_item), allocatable, intent(inout) :: items(:)
566 integer, intent(inout) :: n_items, max_items
567 type(tree_node), pointer :: child
568 character(len=512) :: full_path
569
570 ! Skip root node
571 if (len_trim(parent_path) > 0 .or. trim(node%name) /= '.') then
572 ! Build full path
573 if (len_trim(parent_path) == 0) then
574 full_path = trim(node%name)
575 else
576 full_path = trim(parent_path) // '/' // trim(node%name)
577 end if
578
579 ! Add this item
580 n_items = n_items + 1
581 if (n_items > max_items) then
582 ! Resize array
583 call resize_item_array(items, max_items)
584 end if
585
586 items(n_items)%path = trim(full_path)
587 items(n_items)%is_file = node%is_file
588 items(n_items)%is_staged = node%is_staged
589 items(n_items)%is_unstaged = node%is_unstaged
590 items(n_items)%is_untracked = node%is_untracked
591 items(n_items)%has_incoming = node%has_incoming
592 else
593 full_path = ''
594 end if
595
596 ! Recursively add children
597 child => node%first_child
598 do while (associated(child))
599 call collect_items_from_tree(child, full_path, items, n_items, max_items)
600 child => child%next_sibling
601 end do
602 end subroutine collect_items_from_tree
603
604 subroutine resize_item_array(items, max_items)
605 type(selectable_item), allocatable, intent(inout) :: items(:)
606 integer, intent(inout) :: max_items
607 type(selectable_item), allocatable :: temp_items(:)
608 integer :: old_size
609
610 old_size = max_items
611 allocate(temp_items(old_size))
612 temp_items = items(1:old_size)
613 deallocate(items)
614 max_items = max_items * 2
615 allocate(items(max_items))
616 items(1:old_size) = temp_items
617 deallocate(temp_items)
618 end subroutine resize_item_array
619
620 subroutine clear_screen()
621 ! ANSI escape code to clear screen and move cursor to top
622 print '(A)', achar(27) // '[2J' // achar(27) // '[H'
623 end subroutine clear_screen
624
625 subroutine enable_raw_mode()
626 integer :: status
627 ! Use stty cbreak mode (processes newlines correctly) instead of raw
628 call execute_command_line('stty cbreak -echo < /dev/tty', exitstat=status)
629 end subroutine enable_raw_mode
630
631 subroutine disable_raw_mode()
632 integer :: status
633 ! Restore terminal
634 call execute_command_line('stty sane < /dev/tty', exitstat=status)
635 end subroutine disable_raw_mode
636
637 subroutine read_key(key)
638 character(len=1), intent(out) :: key
639 character(len=3) :: escape_seq
640 integer :: iostat, tty_unit
641
642 ! Open /dev/tty for reading
643 open(newunit=tty_unit, file='/dev/tty', status='old', action='read', iostat=iostat)
644 if (iostat /= 0) then
645 key = 'q' ! If we can't open tty, quit
646 return
647 end if
648
649 ! Read one character
650 read(tty_unit, '(A1)', iostat=iostat, advance='no') key
651
652 ! Check for escape sequence (arrow keys)
653 if (key == achar(27)) then
654 read(tty_unit, '(A2)', iostat=iostat, advance='no') escape_seq
655 if (escape_seq(1:1) == '[') then
656 key = escape_seq(2:2) ! Return A, B, C, or D
657 end if
658 end if
659
660 close(tty_unit)
661 end subroutine read_key
662
663 subroutine git_add_file(filepath)
664 character(len=*), intent(in) :: filepath
665 character(len=1024) :: command
666 integer :: status
667
668 write(command, '(A,A,A)') 'git add "', trim(filepath), '"'
669 call execute_command_line(trim(command), exitstat=status)
670
671 ! Show feedback at bottom of screen
672 if (status == 0) then
673 print '(A)', 'Staged: ' // trim(filepath)
674 else
675 print '(A)', 'Failed to stage: ' // trim(filepath)
676 end if
677
678 ! Brief pause to show message
679 call execute_command_line('sleep 0.5', exitstat=status)
680 end subroutine git_add_file
681
682 subroutine git_fetch()
683 integer :: status
684
685 ! Restore terminal temporarily for git output
686 call disable_raw_mode()
687
688 ! Run git fetch
689 print '(A)', 'Fetching from remote...'
690 call execute_command_line('git fetch', exitstat=status)
691
692 if (status == 0) then
693 print '(A)', 'Fetch completed successfully!'
694 else
695 print '(A)', 'Fetch failed!'
696 end if
697
698 ! Brief pause to show message
699 call execute_command_line('sleep 1', exitstat=status)
700
701 ! Re-enable raw mode
702 call enable_raw_mode()
703 end subroutine git_fetch
704
705 subroutine git_pull()
706 integer :: status
707
708 ! Restore terminal temporarily for git output
709 call disable_raw_mode()
710
711 ! Run git pull
712 print '(A)', 'Pulling from remote...'
713 call execute_command_line('git pull', exitstat=status)
714
715 if (status == 0) then
716 print '(A)', 'Pull completed successfully!'
717 else
718 print '(A)', 'Pull failed!'
719 end if
720
721 ! Brief pause to show message
722 call execute_command_line('sleep 1', exitstat=status)
723
724 ! Re-enable raw mode
725 call enable_raw_mode()
726 end subroutine git_pull
727
728 subroutine git_diff_file(filepath)
729 character(len=*), intent(in) :: filepath
730 character(len=1024) :: command
731 integer :: status
732
733 ! Restore terminal temporarily for less
734 call disable_raw_mode()
735
736 ! Show diff with less
737 write(command, '(A,A,A)') 'git diff HEAD...@{upstream} -- "', trim(filepath), '" | less -R'
738 call execute_command_line(trim(command), exitstat=status)
739
740 ! Re-enable raw mode
741 call enable_raw_mode()
742 end subroutine git_diff_file
743
744 subroutine draw_interactive_tree(files, n_files, items, n_items, selected)
745 type(file_entry), intent(in) :: files(:)
746 integer, intent(in) :: n_files, n_items, selected
747 type(selectable_item), intent(in) :: items(:)
748 type(tree_node), pointer :: root
749 integer :: i, item_idx
750
751 ! Build tree
752 allocate(root)
753 root%name = '.'
754 root%is_file = .false.
755 root%is_staged = .false.
756 root%is_unstaged = .false.
757 root%is_untracked = .false.
758 root%has_incoming = .false.
759 root%first_child => null()
760 root%next_sibling => null()
761
762 do i = 1, n_files
763 call add_to_tree(root, files(i)%path, files(i)%is_staged, files(i)%is_unstaged, files(i)%is_untracked, files(i)%has_incoming)
764 end do
765
766 call sort_tree(root)
767
768 ! Print tree with selection highlighting
769 item_idx = 0
770 print '(A)', '.'
771 call print_interactive_node(root, '', .true., .true., items, &
772 selected, item_idx)
773
774 ! Print help (two rows for better readability)
775 print '(A)', ''
776 print '(A)', 'Legend: ' // achar(27) // '[32m↑' // achar(27) // '[0m=staged ' // &
777 achar(27) // '[31m✗' // achar(27) // '[0m=modified ' // &
778 achar(27) // '[90m✗' // achar(27) // '[0m=untracked ' // &
779 achar(27) // '[34m↓' // achar(27) // '[0m=incoming'
780 print '(A)', 'Keys: j/k/↑/↓:nav | Space:stage | f:fetch | d:diff | l:pull | q:quit'
781
782 call free_tree(root)
783 end subroutine draw_interactive_tree
784
785 recursive subroutine print_interactive_node(node, prefix, is_last, &
786 is_root, items, selected, item_idx)
787 type(tree_node), pointer, intent(in) :: node
788 character(len=*), intent(in) :: prefix
789 logical, intent(in) :: is_last, is_root
790 type(selectable_item), intent(in) :: items(:)
791 integer, intent(in) :: selected
792 integer, intent(inout) :: item_idx
793
794 character(len=1024) :: line
795 character(len=:), allocatable :: new_prefix
796 type(tree_node), pointer :: child
797 integer :: n_children, i
798 logical :: is_selected
799
800 character(len=*), parameter :: branch_last = '└──'
801 character(len=*), parameter :: branch_mid = '├──'
802 character(len=*), parameter :: vertical = '│'
803 character(len=*), parameter :: highlight_on = achar(27) // '[7m'
804 character(len=*), parameter :: highlight_off = achar(27) // '[0m'
805 character(len=1), parameter :: ESC = achar(27)
806
807 ! Build colored marks as character arrays
808 character(len=50) :: mark_unstaged
809 character(len=50) :: mark_untracked
810 character(len=50) :: mark_staged
811 character(len=50) :: mark_incoming
812
813 ! Initialize colored marks with explicit ESC characters
814 write(mark_unstaged, '(A,A,A,A,A)') ESC, '[31m', ' ✗', ESC, '[0m' ! Red for modified
815 write(mark_untracked, '(A,A,A,A,A)') ESC, '[90m', ' ✗', ESC, '[0m' ! Dim grey for untracked
816 write(mark_staged, '(A,A,A,A,A)') ESC, '[32m', ' ↑', ESC, '[0m' ! Green for staged
817 write(mark_incoming, '(A,A,A,A,A)') ESC, '[34m', ' ↓', ESC, '[0m' ! Blue for incoming
818
819 ! Count children first
820 n_children = 0
821 child => node%first_child
822 do while (associated(child))
823 n_children = n_children + 1
824 child => child%next_sibling
825 end do
826
827 ! Don't print root node
828 if (.not. is_root) then
829 ! Increment item index for all nodes (files and directories)
830 item_idx = item_idx + 1
831 is_selected = (item_idx == selected)
832
833 ! Build line with appropriate branch character (exactly like print_tree_node)
834 if (is_last) then
835 line = prefix // branch_last // ' '
836 else
837 line = prefix // branch_mid // ' '
838 end if
839
840 ! Add name with highlighting if selected
841 if (is_selected) then
842 line = trim(line) // highlight_on // trim(node%name)
843 ! Show all applicable indicators
844 if (node%is_staged) then
845 line = trim(line) // trim(mark_staged)
846 end if
847 if (node%is_unstaged) then
848 line = trim(line) // trim(mark_unstaged)
849 end if
850 if (node%is_untracked) then
851 line = trim(line) // trim(mark_untracked)
852 end if
853 if (node%has_incoming) then
854 line = trim(line) // trim(mark_incoming)
855 end if
856 line = trim(line) // highlight_off
857 else
858 line = trim(line) // trim(node%name)
859 ! Show all applicable indicators
860 if (node%is_staged) then
861 line = trim(line) // trim(mark_staged)
862 end if
863 if (node%is_unstaged) then
864 line = trim(line) // trim(mark_unstaged)
865 end if
866 if (node%is_untracked) then
867 line = trim(line) // trim(mark_untracked)
868 end if
869 if (node%has_incoming) then
870 line = trim(line) // trim(mark_incoming)
871 end if
872 end if
873
874 print '(A)', trim(line)
875 end if
876
877 ! Print children
878 i = 0
879 child => node%first_child
880 do while (associated(child))
881 i = i + 1
882
883 if (is_root) then
884 new_prefix = ''
885 else
886 if (is_last) then
887 new_prefix = prefix // ' '
888 else
889 new_prefix = prefix // vertical // ' '
890 end if
891 end if
892
893 call print_interactive_node(child, new_prefix, i == n_children, &
894 .false., items, selected, item_idx)
895 child => child%next_sibling
896 end do
897 end subroutine print_interactive_node
898
899 recursive subroutine sort_tree(node)
900 type(tree_node), pointer :: node
901 type(tree_node), pointer :: child
902
903 if (.not. associated(node)) return
904
905 ! Sort children of this node
906 call sort_children(node)
907
908 ! Recursively sort all children
909 child => node%first_child
910 do while (associated(child))
911 call sort_tree(child)
912 child => child%next_sibling
913 end do
914 end subroutine sort_tree
915
916 subroutine sort_children(node)
917 type(tree_node), pointer :: node
918 type(tree_node), pointer :: sorted_head, sorted_tail
919 type(tree_node), pointer :: current, next_node, insert_pos, prev
920 logical :: inserted
921
922 if (.not. associated(node%first_child)) return
923 if (.not. associated(node%first_child%next_sibling)) return
924
925 ! Build sorted list
926 sorted_head => null()
927 sorted_tail => null()
928
929 current => node%first_child
930 do while (associated(current))
931 next_node => current%next_sibling
932
933 ! Insert current into sorted list
934 if (.not. associated(sorted_head)) then
935 ! First element
936 sorted_head => current
937 sorted_tail => current
938 current%next_sibling => null()
939 else
940 ! Find insertion point: directories before files, alphabetical
941 inserted = .false.
942 prev => null()
943 insert_pos => sorted_head
944
945 do while (associated(insert_pos))
946 if (should_insert_before(current, insert_pos)) then
947 ! Insert before insert_pos
948 current%next_sibling => insert_pos
949 if (associated(prev)) then
950 prev%next_sibling => current
951 else
952 sorted_head => current
953 end if
954 inserted = .true.
955 exit
956 end if
957 prev => insert_pos
958 insert_pos => insert_pos%next_sibling
959 end do
960
961 if (.not. inserted) then
962 ! Insert at end
963 sorted_tail%next_sibling => current
964 sorted_tail => current
965 current%next_sibling => null()
966 end if
967 end if
968
969 current => next_node
970 end do
971
972 node%first_child => sorted_head
973 end subroutine sort_children
974
975 function should_insert_before(a, b) result(before)
976 type(tree_node), pointer, intent(in) :: a, b
977 logical :: before
978
979 ! Pure alphabetical sorting (like tree command)
980 before = (trim(a%name) < trim(b%name))
981 end function should_insert_before
982
983 recursive subroutine add_to_tree(node, path, is_staged, is_unstaged, is_untracked, has_incoming)
984 type(tree_node), pointer, intent(in) :: node
985 character(len=*), intent(in) :: path
986 logical, intent(in) :: is_staged, is_unstaged, is_untracked, has_incoming
987
988 integer :: slash_pos, iostat
989 character(len=512) :: first_part, rest
990 type(tree_node), pointer :: child, new_child
991 logical :: is_directory
992
993 ! Find first slash
994 slash_pos = index(path, '/')
995
996 if (slash_pos == 0) then
997 ! This could be a file or a directory in current directory
998 child => node%first_child
999
1000 ! Check if already exists
1001 do while (associated(child))
1002 if (trim(child%name) == trim(path)) then
1003 child%is_staged = child%is_staged .or. is_staged
1004 child%is_unstaged = child%is_unstaged .or. is_unstaged
1005 child%is_untracked = child%is_untracked .or. is_untracked
1006 child%has_incoming = child%has_incoming .or. has_incoming
1007 return
1008 end if
1009 if (.not. associated(child%next_sibling)) exit
1010 child => child%next_sibling
1011 end do
1012
1013 ! Check if this is a directory (simple check if path exists as dir)
1014 inquire(file=trim(path), exist=is_directory, iostat=iostat)
1015 if (iostat /= 0) is_directory = .false.
1016
1017 ! If the name matches common directory patterns or actually is a dir, treat as directory
1018 ! Otherwise treat as file
1019 if (is_directory) then
1020 call execute_command_line('test -d "' // trim(path) // '"', exitstat=iostat)
1021 is_directory = (iostat == 0)
1022 else
1023 is_directory = .false.
1024 end if
1025
1026 ! Add new child
1027 allocate(new_child)
1028 new_child%name = trim(path)
1029 new_child%is_file = .not. is_directory
1030 new_child%is_staged = is_staged
1031 new_child%is_unstaged = is_unstaged
1032 new_child%is_untracked = is_untracked
1033 new_child%has_incoming = has_incoming
1034 new_child%first_child => null()
1035 new_child%next_sibling => null()
1036
1037 if (.not. associated(node%first_child)) then
1038 node%first_child => new_child
1039 else
1040 child%next_sibling => new_child
1041 end if
1042 else
1043 ! Split path
1044 first_part = path(1:slash_pos-1)
1045 rest = path(slash_pos+1:)
1046
1047 ! Find or create subdirectory
1048 child => node%first_child
1049 do while (associated(child))
1050 if (trim(child%name) == trim(first_part)) then
1051 call add_to_tree(child, rest, is_staged, is_unstaged, is_untracked, has_incoming)
1052 return
1053 end if
1054 if (.not. associated(child%next_sibling)) exit
1055 child => child%next_sibling
1056 end do
1057
1058 ! Create new directory
1059 allocate(new_child)
1060 new_child%name = trim(first_part)
1061 new_child%is_file = .false.
1062 new_child%is_staged = .false.
1063 new_child%is_unstaged = .false.
1064 new_child%is_untracked = .false.
1065 new_child%has_incoming = .false.
1066 new_child%first_child => null()
1067 new_child%next_sibling => null()
1068
1069 if (.not. associated(node%first_child)) then
1070 node%first_child => new_child
1071 else
1072 child%next_sibling => new_child
1073 end if
1074
1075 call add_to_tree(new_child, rest, is_staged, is_unstaged, is_untracked, has_incoming)
1076 end if
1077 end subroutine add_to_tree
1078
1079 recursive subroutine print_tree_node(node, prefix, is_last, is_root)
1080 type(tree_node), pointer, intent(in) :: node
1081 character(len=*), intent(in) :: prefix
1082 logical, intent(in) :: is_last, is_root
1083
1084 character(len=1024) :: line
1085 character(len=:), allocatable :: new_prefix
1086 type(tree_node), pointer :: child
1087 integer :: n_children, i
1088
1089 ! UTF-8 box-drawing characters (like tree command)
1090 character(len=*), parameter :: branch_last = '└──'
1091 character(len=*), parameter :: branch_mid = '├──'
1092 character(len=*), parameter :: vertical = '│'
1093 character(len=1), parameter :: ESC = achar(27)
1094
1095 ! Build colored marks as character arrays
1096 character(len=50) :: mark_unstaged
1097 character(len=50) :: mark_untracked
1098 character(len=50) :: mark_staged
1099 character(len=50) :: mark_incoming
1100
1101 ! Initialize colored marks with explicit ESC characters
1102 write(mark_unstaged, '(A,A,A,A,A)') ESC, '[31m', ' ✗', ESC, '[0m' ! Red for modified
1103 write(mark_untracked, '(A,A,A,A,A)') ESC, '[90m', ' ✗', ESC, '[0m' ! Dim grey for untracked
1104 write(mark_staged, '(A,A,A,A,A)') ESC, '[32m', ' ↑', ESC, '[0m' ! Green for staged
1105 write(mark_incoming, '(A,A,A,A,A)') ESC, '[34m', ' ↓', ESC, '[0m' ! Blue for incoming
1106
1107 ! Count children first
1108 n_children = 0
1109 child => node%first_child
1110 do while (associated(child))
1111 n_children = n_children + 1
1112 child => child%next_sibling
1113 end do
1114
1115 ! Don't print root node
1116 if (.not. is_root) then
1117 ! Build line with appropriate branch character
1118 if (is_last) then
1119 line = prefix // branch_last // ' ' // trim(node%name)
1120 else
1121 line = prefix // branch_mid // ' ' // trim(node%name)
1122 end if
1123 ! Show all applicable indicators
1124 if (node%is_staged) then
1125 line = trim(line) // trim(mark_staged)
1126 end if
1127 if (node%is_unstaged) then
1128 line = trim(line) // trim(mark_unstaged)
1129 end if
1130 if (node%is_untracked) then
1131 line = trim(line) // trim(mark_untracked)
1132 end if
1133 if (node%has_incoming) then
1134 line = trim(line) // trim(mark_incoming)
1135 end if
1136 print '(A)', trim(line)
1137 end if
1138
1139 ! Print children
1140 i = 0
1141 child => node%first_child
1142 do while (associated(child))
1143 i = i + 1
1144
1145 if (is_root) then
1146 new_prefix = ''
1147 else
1148 ! Build new prefix with proper indentation
1149 if (is_last) then
1150 new_prefix = prefix // ' '
1151 else
1152 new_prefix = prefix // vertical // ' '
1153 end if
1154 end if
1155
1156 call print_tree_node(child, new_prefix, i == n_children, .false.)
1157 child => child%next_sibling
1158 end do
1159 end subroutine print_tree_node
1160
1161 recursive subroutine free_tree(node)
1162 type(tree_node), pointer :: node
1163 type(tree_node), pointer :: child, next_child
1164
1165 if (.not. associated(node)) return
1166
1167 ! Free all children
1168 child => node%first_child
1169 do while (associated(child))
1170 next_child => child%next_sibling
1171 call free_tree(child)
1172 child => next_child
1173 end do
1174
1175 ! Free this node
1176 deallocate(node)
1177 nullify(node)
1178 end subroutine free_tree
1179
1180 end program fuss
1181