@@ -9,6 +9,7 @@ program fuss |
| 9 | 9 | logical :: is_staged |
| 10 | 10 | logical :: is_unstaged |
| 11 | 11 | logical :: is_untracked |
| 12 | + logical :: has_incoming |
| 12 | 13 | type(tree_node), pointer :: first_child => null() |
| 13 | 14 | type(tree_node), pointer :: next_sibling => null() |
| 14 | 15 | end type tree_node |
@@ -19,6 +20,7 @@ program fuss |
| 19 | 20 | logical :: is_staged |
| 20 | 21 | logical :: is_unstaged |
| 21 | 22 | logical :: is_untracked |
| 23 | + logical :: has_incoming |
| 22 | 24 | end type file_entry |
| 23 | 25 | |
| 24 | 26 | type :: selectable_item |
@@ -26,6 +28,7 @@ program fuss |
| 26 | 28 | logical :: is_staged |
| 27 | 29 | logical :: is_unstaged |
| 28 | 30 | logical :: is_untracked |
| 31 | + logical :: has_incoming |
| 29 | 32 | logical :: is_file |
| 30 | 33 | end type selectable_item |
| 31 | 34 | |
@@ -94,6 +97,9 @@ contains |
| 94 | 97 | call get_dirty_files(files, n_files) |
| 95 | 98 | end if |
| 96 | 99 | |
| 100 | + ! Mark files with incoming changes |
| 101 | + call mark_incoming_changes(files, n_files) |
| 102 | + |
| 97 | 103 | ! Display the tree |
| 98 | 104 | if (n_files > 0) then |
| 99 | 105 | print '(A)', '.' |
@@ -167,6 +173,7 @@ contains |
| 167 | 173 | temp_files(n_files)%is_untracked = (git_status == '??') |
| 168 | 174 | temp_files(n_files)%is_staged = (git_status(1:1) /= ' ' .and. git_status(1:1) /= '?') |
| 169 | 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. |
| 170 | 177 | end if |
| 171 | 178 | end do |
| 172 | 179 | |
@@ -242,6 +249,7 @@ contains |
| 242 | 249 | temp_files(n_files)%is_staged = .false. |
| 243 | 250 | temp_files(n_files)%is_unstaged = .false. |
| 244 | 251 | temp_files(n_files)%is_untracked = .false. |
| 252 | + temp_files(n_files)%has_incoming = .false. |
| 245 | 253 | do i = 1, n_dirty |
| 246 | 254 | if (trim(dirty_files(i)%path) == trim(line)) then |
| 247 | 255 | is_dirty_file = .true. |
@@ -249,6 +257,7 @@ contains |
| 249 | 257 | temp_files(n_files)%is_staged = dirty_files(i)%is_staged |
| 250 | 258 | temp_files(n_files)%is_unstaged = dirty_files(i)%is_unstaged |
| 251 | 259 | temp_files(n_files)%is_untracked = dirty_files(i)%is_untracked |
| 260 | + temp_files(n_files)%has_incoming = dirty_files(i)%has_incoming |
| 252 | 261 | exit |
| 253 | 262 | end if |
| 254 | 263 | end do |
@@ -265,6 +274,51 @@ contains |
| 265 | 274 | if (allocated(dirty_files)) deallocate(dirty_files) |
| 266 | 275 | end subroutine get_all_files |
| 267 | 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 | + |
| 268 | 322 | subroutine resize_array(array, new_size) |
| 269 | 323 | type(file_entry), allocatable, intent(inout) :: array(:) |
| 270 | 324 | integer, intent(in) :: new_size |
@@ -323,6 +377,7 @@ contains |
| 323 | 377 | files(n_files)%is_untracked = (git_status == '??') |
| 324 | 378 | files(n_files)%is_staged = (git_status(1:1) /= ' ' .and. git_status(1:1) /= '?') |
| 325 | 379 | files(n_files)%is_unstaged = (git_status(2:2) /= ' ' .and. .not. files(n_files)%is_untracked) |
| 380 | + files(n_files)%has_incoming = .false. |
| 326 | 381 | end if |
| 327 | 382 | end do |
| 328 | 383 | |
@@ -342,12 +397,13 @@ contains |
| 342 | 397 | root%is_staged = .false. |
| 343 | 398 | root%is_unstaged = .false. |
| 344 | 399 | root%is_untracked = .false. |
| 400 | + root%has_incoming = .false. |
| 345 | 401 | root%first_child => null() |
| 346 | 402 | root%next_sibling => null() |
| 347 | 403 | |
| 348 | 404 | ! Build tree |
| 349 | 405 | do i = 1, n_files |
| 350 | | - call add_to_tree(root, files(i)%path, files(i)%is_staged, files(i)%is_unstaged, files(i)%is_untracked) |
| 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) |
| 351 | 407 | end do |
| 352 | 408 | |
| 353 | 409 | ! Sort tree (directories first, then alphabetically) |
@@ -375,6 +431,9 @@ contains |
| 375 | 431 | call get_dirty_files(files, n_files) |
| 376 | 432 | end if |
| 377 | 433 | |
| 434 | + ! Mark files with incoming changes |
| 435 | + call mark_incoming_changes(files, n_files) |
| 436 | + |
| 378 | 437 | if (n_files == 0) then |
| 379 | 438 | print '(A)', 'No files to display' |
| 380 | 439 | return |
@@ -414,10 +473,37 @@ contains |
| 414 | 473 | else |
| 415 | 474 | call get_dirty_files(files, n_files) |
| 416 | 475 | end if |
| 476 | + call mark_incoming_changes(files, n_files) |
| 417 | 477 | call build_item_list(files, n_files, items, n_items) |
| 418 | 478 | if (selected > n_items .and. n_items > 0) selected = n_items |
| 419 | 479 | if (n_items == 0) running = .false. |
| 420 | 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 |
| 421 | 507 | case ('q', 'Q') ! Quit |
| 422 | 508 | running = .false. |
| 423 | 509 | end select |
@@ -447,11 +533,12 @@ contains |
| 447 | 533 | root%is_staged = .false. |
| 448 | 534 | root%is_unstaged = .false. |
| 449 | 535 | root%is_untracked = .false. |
| 536 | + root%has_incoming = .false. |
| 450 | 537 | root%first_child => null() |
| 451 | 538 | root%next_sibling => null() |
| 452 | 539 | |
| 453 | 540 | do i = 1, n_files |
| 454 | | - call add_to_tree(root, files(i)%path, files(i)%is_staged, files(i)%is_unstaged, files(i)%is_untracked) |
| 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) |
| 455 | 542 | end do |
| 456 | 543 | |
| 457 | 544 | call sort_tree(root) |
@@ -501,6 +588,7 @@ contains |
| 501 | 588 | items(n_items)%is_staged = node%is_staged |
| 502 | 589 | items(n_items)%is_unstaged = node%is_unstaged |
| 503 | 590 | items(n_items)%is_untracked = node%is_untracked |
| 591 | + items(n_items)%has_incoming = node%has_incoming |
| 504 | 592 | else |
| 505 | 593 | full_path = '' |
| 506 | 594 | end if |
@@ -591,6 +679,68 @@ contains |
| 591 | 679 | call execute_command_line('sleep 0.5', exitstat=status) |
| 592 | 680 | end subroutine git_add_file |
| 593 | 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 | + |
| 594 | 744 | subroutine draw_interactive_tree(files, n_files, items, n_items, selected) |
| 595 | 745 | type(file_entry), intent(in) :: files(:) |
| 596 | 746 | integer, intent(in) :: n_files, n_items, selected |
@@ -605,11 +755,12 @@ contains |
| 605 | 755 | root%is_staged = .false. |
| 606 | 756 | root%is_unstaged = .false. |
| 607 | 757 | root%is_untracked = .false. |
| 758 | + root%has_incoming = .false. |
| 608 | 759 | root%first_child => null() |
| 609 | 760 | root%next_sibling => null() |
| 610 | 761 | |
| 611 | 762 | do i = 1, n_files |
| 612 | | - call add_to_tree(root, files(i)%path, files(i)%is_staged, files(i)%is_unstaged, files(i)%is_untracked) |
| 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) |
| 613 | 764 | end do |
| 614 | 765 | |
| 615 | 766 | call sort_tree(root) |
@@ -620,12 +771,13 @@ contains |
| 620 | 771 | call print_interactive_node(root, '', .true., .true., items, & |
| 621 | 772 | selected, item_idx) |
| 622 | 773 | |
| 623 | | - ! Print help |
| 774 | + ! Print help (two rows for better readability) |
| 624 | 775 | print '(A)', '' |
| 625 | | - print '(A)', achar(27) // '[32m↑' // achar(27) // '[0m=staged ' // & |
| 776 | + print '(A)', 'Legend: ' // achar(27) // '[32m↑' // achar(27) // '[0m=staged ' // & |
| 626 | 777 | achar(27) // '[31m✗' // achar(27) // '[0m=modified ' // & |
| 627 | | - achar(27) // '[90m✗' // achar(27) // '[0m=untracked' |
| 628 | | - print '(A)', 'j/↓: down | k/↑: up | Space: stage file | q: quit' |
| 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' |
| 629 | 781 | |
| 630 | 782 | call free_tree(root) |
| 631 | 783 | end subroutine draw_interactive_tree |
@@ -656,11 +808,13 @@ contains |
| 656 | 808 | character(len=50) :: mark_unstaged |
| 657 | 809 | character(len=50) :: mark_untracked |
| 658 | 810 | character(len=50) :: mark_staged |
| 811 | + character(len=50) :: mark_incoming |
| 659 | 812 | |
| 660 | 813 | ! Initialize colored marks with explicit ESC characters |
| 661 | 814 | write(mark_unstaged, '(A,A,A,A,A)') ESC, '[31m', ' ✗', ESC, '[0m' ! Red for modified |
| 662 | 815 | write(mark_untracked, '(A,A,A,A,A)') ESC, '[90m', ' ✗', ESC, '[0m' ! Dim grey for untracked |
| 663 | 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 |
| 664 | 818 | |
| 665 | 819 | ! Count children first |
| 666 | 820 | n_children = 0 |
@@ -696,6 +850,9 @@ contains |
| 696 | 850 | if (node%is_untracked) then |
| 697 | 851 | line = trim(line) // trim(mark_untracked) |
| 698 | 852 | end if |
| 853 | + if (node%has_incoming) then |
| 854 | + line = trim(line) // trim(mark_incoming) |
| 855 | + end if |
| 699 | 856 | line = trim(line) // highlight_off |
| 700 | 857 | else |
| 701 | 858 | line = trim(line) // trim(node%name) |
@@ -709,6 +866,9 @@ contains |
| 709 | 866 | if (node%is_untracked) then |
| 710 | 867 | line = trim(line) // trim(mark_untracked) |
| 711 | 868 | end if |
| 869 | + if (node%has_incoming) then |
| 870 | + line = trim(line) // trim(mark_incoming) |
| 871 | + end if |
| 712 | 872 | end if |
| 713 | 873 | |
| 714 | 874 | print '(A)', trim(line) |
@@ -820,10 +980,10 @@ contains |
| 820 | 980 | before = (trim(a%name) < trim(b%name)) |
| 821 | 981 | end function should_insert_before |
| 822 | 982 | |
| 823 | | - recursive subroutine add_to_tree(node, path, is_staged, is_unstaged, is_untracked) |
| 983 | + recursive subroutine add_to_tree(node, path, is_staged, is_unstaged, is_untracked, has_incoming) |
| 824 | 984 | type(tree_node), pointer, intent(in) :: node |
| 825 | 985 | character(len=*), intent(in) :: path |
| 826 | | - logical, intent(in) :: is_staged, is_unstaged, is_untracked |
| 986 | + logical, intent(in) :: is_staged, is_unstaged, is_untracked, has_incoming |
| 827 | 987 | |
| 828 | 988 | integer :: slash_pos, iostat |
| 829 | 989 | character(len=512) :: first_part, rest |
@@ -843,6 +1003,7 @@ contains |
| 843 | 1003 | child%is_staged = child%is_staged .or. is_staged |
| 844 | 1004 | child%is_unstaged = child%is_unstaged .or. is_unstaged |
| 845 | 1005 | child%is_untracked = child%is_untracked .or. is_untracked |
| 1006 | + child%has_incoming = child%has_incoming .or. has_incoming |
| 846 | 1007 | return |
| 847 | 1008 | end if |
| 848 | 1009 | if (.not. associated(child%next_sibling)) exit |
@@ -869,6 +1030,7 @@ contains |
| 869 | 1030 | new_child%is_staged = is_staged |
| 870 | 1031 | new_child%is_unstaged = is_unstaged |
| 871 | 1032 | new_child%is_untracked = is_untracked |
| 1033 | + new_child%has_incoming = has_incoming |
| 872 | 1034 | new_child%first_child => null() |
| 873 | 1035 | new_child%next_sibling => null() |
| 874 | 1036 | |
@@ -886,7 +1048,7 @@ contains |
| 886 | 1048 | child => node%first_child |
| 887 | 1049 | do while (associated(child)) |
| 888 | 1050 | if (trim(child%name) == trim(first_part)) then |
| 889 | | - call add_to_tree(child, rest, is_staged, is_unstaged, is_untracked) |
| 1051 | + call add_to_tree(child, rest, is_staged, is_unstaged, is_untracked, has_incoming) |
| 890 | 1052 | return |
| 891 | 1053 | end if |
| 892 | 1054 | if (.not. associated(child%next_sibling)) exit |
@@ -900,6 +1062,7 @@ contains |
| 900 | 1062 | new_child%is_staged = .false. |
| 901 | 1063 | new_child%is_unstaged = .false. |
| 902 | 1064 | new_child%is_untracked = .false. |
| 1065 | + new_child%has_incoming = .false. |
| 903 | 1066 | new_child%first_child => null() |
| 904 | 1067 | new_child%next_sibling => null() |
| 905 | 1068 | |
@@ -909,7 +1072,7 @@ contains |
| 909 | 1072 | child%next_sibling => new_child |
| 910 | 1073 | end if |
| 911 | 1074 | |
| 912 | | - call add_to_tree(new_child, rest, is_staged, is_unstaged, is_untracked) |
| 1075 | + call add_to_tree(new_child, rest, is_staged, is_unstaged, is_untracked, has_incoming) |
| 913 | 1076 | end if |
| 914 | 1077 | end subroutine add_to_tree |
| 915 | 1078 | |
@@ -933,11 +1096,13 @@ contains |
| 933 | 1096 | character(len=50) :: mark_unstaged |
| 934 | 1097 | character(len=50) :: mark_untracked |
| 935 | 1098 | character(len=50) :: mark_staged |
| 1099 | + character(len=50) :: mark_incoming |
| 936 | 1100 | |
| 937 | 1101 | ! Initialize colored marks with explicit ESC characters |
| 938 | 1102 | write(mark_unstaged, '(A,A,A,A,A)') ESC, '[31m', ' ✗', ESC, '[0m' ! Red for modified |
| 939 | 1103 | write(mark_untracked, '(A,A,A,A,A)') ESC, '[90m', ' ✗', ESC, '[0m' ! Dim grey for untracked |
| 940 | 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 |
| 941 | 1106 | |
| 942 | 1107 | ! Count children first |
| 943 | 1108 | n_children = 0 |
@@ -965,6 +1130,9 @@ contains |
| 965 | 1130 | if (node%is_untracked) then |
| 966 | 1131 | line = trim(line) // trim(mark_untracked) |
| 967 | 1132 | end if |
| 1133 | + if (node%has_incoming) then |
| 1134 | + line = trim(line) // trim(mark_incoming) |
| 1135 | + end if |
| 968 | 1136 | print '(A)', trim(line) |
| 969 | 1137 | end if |
| 970 | 1138 | |