| 1 | module file_tree_module |
| 2 | use iso_fortran_env, only: int32, error_unit |
| 3 | implicit none |
| 4 | private |
| 5 | |
| 6 | public :: tree_node_t, file_entry_t, tree_state_t |
| 7 | public :: init_tree_state, cleanup_tree_state, refresh_tree_state |
| 8 | public :: tree_move_up, tree_move_down, get_selected_item_path |
| 9 | public :: tree_stage_file, tree_unstage_file, tree_toggle_expand |
| 10 | public :: build_selectable_list |
| 11 | public :: update_tree_viewport |
| 12 | public :: build_tree |
| 13 | |
| 14 | ! Tree node using linked list structure (first-child, next-sibling) |
| 15 | type :: tree_node_t |
| 16 | character(len=256) :: name = '' |
| 17 | character(len=512) :: full_path = '' ! Full path for files (for staging) |
| 18 | logical :: is_file = .false. |
| 19 | logical :: is_staged = .false. |
| 20 | logical :: is_unstaged = .false. |
| 21 | logical :: is_untracked = .false. |
| 22 | logical :: has_incoming = .false. |
| 23 | logical :: expanded = .true. ! For directories: true=expanded, false=collapsed |
| 24 | logical :: is_dotfile = .false. ! Is this a dotfile (starts with .) |
| 25 | logical :: is_gitignored = .false. ! Is this file gitignored |
| 26 | logical :: all_children_hidden = .false. ! For directories: all children are hidden |
| 27 | type(tree_node_t), pointer :: parent => null() ! Parent node for sibling navigation |
| 28 | type(tree_node_t), pointer :: first_child => null() |
| 29 | type(tree_node_t), pointer :: next_sibling => null() |
| 30 | end type tree_node_t |
| 31 | |
| 32 | type :: file_entry_t |
| 33 | character(len=512) :: path = '' |
| 34 | character(len=2) :: status = ' ' |
| 35 | logical :: is_staged = .false. |
| 36 | logical :: is_unstaged = .false. |
| 37 | logical :: is_untracked = .false. |
| 38 | logical :: has_incoming = .false. |
| 39 | end type file_entry_t |
| 40 | |
| 41 | ! Selectable item (files and directories, in tree traversal order) |
| 42 | type :: selectable_file_t |
| 43 | character(len=512) :: path = '' |
| 44 | logical :: is_directory = .false. |
| 45 | logical :: is_staged = .false. |
| 46 | logical :: is_unstaged = .false. |
| 47 | logical :: is_untracked = .false. |
| 48 | type(tree_node_t), pointer :: node => null() ! Pointer to actual tree node |
| 49 | end type selectable_file_t |
| 50 | |
| 51 | ! Tree state for navigation |
| 52 | type :: tree_state_t |
| 53 | type(file_entry_t), allocatable :: files(:) |
| 54 | type(selectable_file_t), allocatable :: selectable_files(:) |
| 55 | integer :: n_files = 0 |
| 56 | integer :: n_selectable = 0 |
| 57 | integer :: selected_index = 1 |
| 58 | integer :: viewport_offset = 1 |
| 59 | type(tree_node_t), pointer :: root => null() |
| 60 | character(len=256) :: repo_name = '' |
| 61 | character(len=256) :: branch_name = '' |
| 62 | logical :: hide_dotfiles = .false. |
| 63 | end type tree_state_t |
| 64 | |
| 65 | contains |
| 66 | |
| 67 | subroutine init_tree_state(state, workspace_path) |
| 68 | type(tree_state_t), intent(out) :: state |
| 69 | character(len=*), intent(in) :: workspace_path |
| 70 | |
| 71 | state%n_files = 0 |
| 72 | state%selected_index = 1 |
| 73 | state%viewport_offset = 1 |
| 74 | state%root => null() |
| 75 | |
| 76 | ! Get repository info |
| 77 | call get_repo_info(workspace_path, state%repo_name, state%branch_name) |
| 78 | |
| 79 | ! Load files |
| 80 | call refresh_tree_state(state, workspace_path) |
| 81 | end subroutine init_tree_state |
| 82 | |
| 83 | subroutine cleanup_tree_state(state) |
| 84 | type(tree_state_t), intent(inout) :: state |
| 85 | |
| 86 | if (allocated(state%files)) deallocate(state%files) |
| 87 | if (allocated(state%selectable_files)) deallocate(state%selectable_files) |
| 88 | if (associated(state%root)) call free_tree(state%root) |
| 89 | state%n_files = 0 |
| 90 | state%n_selectable = 0 |
| 91 | state%selected_index = 1 |
| 92 | end subroutine cleanup_tree_state |
| 93 | |
| 94 | subroutine refresh_tree_state(state, workspace_path) |
| 95 | type(tree_state_t), intent(inout) :: state |
| 96 | character(len=*), intent(in) :: workspace_path |
| 97 | type(file_entry_t), allocatable :: all_files(:), dirty_files(:) |
| 98 | integer :: n_all_files, n_dirty_files |
| 99 | |
| 100 | ! Free existing tree if present |
| 101 | if (associated(state%root)) then |
| 102 | call free_tree(state%root) |
| 103 | state%root => null() |
| 104 | end if |
| 105 | if (allocated(state%selectable_files)) deallocate(state%selectable_files) |
| 106 | |
| 107 | ! First: Get ALL files from filesystem |
| 108 | call get_all_files(workspace_path, all_files, n_all_files) |
| 109 | |
| 110 | ! Build tree from ALL files (not just dirty ones) |
| 111 | if (n_all_files > 0) then |
| 112 | call build_tree(all_files, n_all_files, state%root) |
| 113 | |
| 114 | ! Second: Get dirty files from git status and overlay markers |
| 115 | call get_dirty_files(workspace_path, dirty_files, n_dirty_files) |
| 116 | if (n_dirty_files > 0) then |
| 117 | call overlay_git_status(state%root, dirty_files, n_dirty_files) |
| 118 | state%files = dirty_files |
| 119 | state%n_files = n_dirty_files |
| 120 | else |
| 121 | allocate(state%files(0)) |
| 122 | state%n_files = 0 |
| 123 | end if |
| 124 | |
| 125 | ! Mark gitignored files (batch mode - single git command) |
| 126 | call mark_gitignored_files(state%root, workspace_path) |
| 127 | |
| 128 | ! Collapse tree smartly - only expand dirs with dirty files |
| 129 | call collapse_tree_smart(state%root) |
| 130 | |
| 131 | ! Build selectable files list in tree traversal order |
| 132 | call build_selectable_list(state%root, state%selectable_files, state%n_selectable) |
| 133 | else |
| 134 | state%n_selectable = 0 |
| 135 | end if |
| 136 | |
| 137 | if (allocated(all_files)) deallocate(all_files) |
| 138 | |
| 139 | ! Clamp selected index |
| 140 | if (state%selected_index > state%n_selectable .and. state%n_selectable > 0) then |
| 141 | state%selected_index = state%n_selectable |
| 142 | else if (state%n_selectable == 0) then |
| 143 | state%selected_index = 1 |
| 144 | end if |
| 145 | end subroutine refresh_tree_state |
| 146 | |
| 147 | subroutine get_dirty_files(workspace_path, files, n_files) |
| 148 | character(len=*), intent(in) :: workspace_path |
| 149 | type(file_entry_t), allocatable, intent(out) :: files(:) |
| 150 | integer, intent(out) :: n_files |
| 151 | integer :: iostat, unit_num, status_code |
| 152 | character(len=1024) :: line, cmd |
| 153 | character(len=512) :: file_path |
| 154 | character(len=2) :: git_status |
| 155 | integer :: max_files |
| 156 | type(file_entry_t), allocatable :: temp_files(:) |
| 157 | |
| 158 | max_files = 1000 |
| 159 | allocate(temp_files(max_files)) |
| 160 | n_files = 0 |
| 161 | |
| 162 | ! Build command to execute git status in workspace directory |
| 163 | write(cmd, '(A,A,A)') 'cd "', trim(workspace_path), '" && git status --porcelain > /tmp/fac_git_status.txt 2>&1' |
| 164 | call execute_command_line(trim(cmd), exitstat=status_code) |
| 165 | |
| 166 | if (status_code /= 0) then |
| 167 | allocate(files(0)) |
| 168 | return |
| 169 | end if |
| 170 | |
| 171 | ! Read git status output |
| 172 | open(newunit=unit_num, file='/tmp/fac_git_status.txt', status='old', action='read', iostat=iostat) |
| 173 | |
| 174 | if (iostat /= 0) then |
| 175 | allocate(files(0)) |
| 176 | return |
| 177 | end if |
| 178 | |
| 179 | do |
| 180 | read(unit_num, '(A)', iostat=iostat) line |
| 181 | if (iostat /= 0) exit |
| 182 | |
| 183 | if (len_trim(line) > 3) then |
| 184 | ! Parse git status line (format: "XY filename") |
| 185 | git_status = line(1:2) |
| 186 | file_path = adjustl(line(4:)) |
| 187 | |
| 188 | ! Skip if path is empty |
| 189 | if (len_trim(file_path) == 0) cycle |
| 190 | |
| 191 | n_files = n_files + 1 |
| 192 | if (n_files > max_files) then |
| 193 | max_files = max_files * 2 |
| 194 | call resize_file_array(temp_files, max_files) |
| 195 | end if |
| 196 | |
| 197 | temp_files(n_files)%status = git_status |
| 198 | temp_files(n_files)%path = trim(file_path) |
| 199 | ! Column 1 = staged status, Column 2 = unstaged status |
| 200 | temp_files(n_files)%is_untracked = (git_status == '??') |
| 201 | temp_files(n_files)%is_staged = (git_status(1:1) /= ' ' .and. git_status(1:1) /= '?') |
| 202 | temp_files(n_files)%is_unstaged = (git_status(2:2) /= ' ' .and. .not. temp_files(n_files)%is_untracked) |
| 203 | temp_files(n_files)%has_incoming = .false. |
| 204 | end if |
| 205 | end do |
| 206 | |
| 207 | close(unit_num, status='delete') |
| 208 | |
| 209 | ! Copy to output array |
| 210 | allocate(files(n_files)) |
| 211 | if (n_files > 0) files(1:n_files) = temp_files(1:n_files) |
| 212 | deallocate(temp_files) |
| 213 | end subroutine get_dirty_files |
| 214 | |
| 215 | subroutine get_all_files(workspace_path, files, n_files) |
| 216 | character(len=*), intent(in) :: workspace_path |
| 217 | type(file_entry_t), allocatable, intent(out) :: files(:) |
| 218 | integer, intent(out) :: n_files |
| 219 | integer :: iostat, unit_num, status_code |
| 220 | character(len=1024) :: line, cmd |
| 221 | character(len=1024) :: file_path |
| 222 | integer :: max_files |
| 223 | type(file_entry_t), allocatable :: temp_files(:) |
| 224 | |
| 225 | max_files = 5000 |
| 226 | allocate(temp_files(max_files)) |
| 227 | n_files = 0 |
| 228 | |
| 229 | ! Use git ls-files for tracked files, then add ALL untracked files (except .git) |
| 230 | ! This is fast because it uses git's index for most files |
| 231 | ! We don't use --exclude-standard so gitignored files are included (can be hidden with ".") |
| 232 | write(cmd, '(A,A,A)') 'cd "', trim(workspace_path), & |
| 233 | '" && { git ls-files; git ls-files --others --exclude .git; } | sort -u > /tmp/fac_all_files.txt 2>/dev/null' |
| 234 | call execute_command_line(trim(cmd), exitstat=status_code) |
| 235 | |
| 236 | if (status_code /= 0) then |
| 237 | allocate(files(0)) |
| 238 | return |
| 239 | end if |
| 240 | |
| 241 | ! Read file list |
| 242 | open(newunit=unit_num, file='/tmp/fac_all_files.txt', status='old', action='read', iostat=iostat) |
| 243 | |
| 244 | if (iostat /= 0) then |
| 245 | allocate(files(0)) |
| 246 | return |
| 247 | end if |
| 248 | |
| 249 | do |
| 250 | read(unit_num, '(A)', iostat=iostat) line |
| 251 | if (iostat /= 0) exit |
| 252 | |
| 253 | file_path = adjustl(line) |
| 254 | |
| 255 | ! Skip if path is empty |
| 256 | if (len_trim(file_path) == 0) cycle |
| 257 | |
| 258 | n_files = n_files + 1 |
| 259 | if (n_files > max_files) then |
| 260 | max_files = max_files * 2 |
| 261 | call resize_file_array(temp_files, max_files) |
| 262 | end if |
| 263 | |
| 264 | temp_files(n_files)%path = trim(file_path) |
| 265 | temp_files(n_files)%status = ' ' ! No git status yet |
| 266 | temp_files(n_files)%is_staged = .false. |
| 267 | temp_files(n_files)%is_unstaged = .false. |
| 268 | temp_files(n_files)%is_untracked = .false. |
| 269 | temp_files(n_files)%has_incoming = .false. |
| 270 | end do |
| 271 | |
| 272 | close(unit_num, status='delete') |
| 273 | |
| 274 | ! Copy to output array |
| 275 | allocate(files(n_files)) |
| 276 | if (n_files > 0) files(1:n_files) = temp_files(1:n_files) |
| 277 | deallocate(temp_files) |
| 278 | end subroutine get_all_files |
| 279 | |
| 280 | subroutine overlay_git_status(root, dirty_files, n_dirty_files) |
| 281 | type(tree_node_t), pointer, intent(inout) :: root |
| 282 | type(file_entry_t), intent(in) :: dirty_files(:) |
| 283 | integer, intent(in) :: n_dirty_files |
| 284 | integer :: i |
| 285 | |
| 286 | ! For each dirty file, find it in the tree and update its status |
| 287 | do i = 1, n_dirty_files |
| 288 | call update_node_status(root, dirty_files(i)%path, & |
| 289 | dirty_files(i)%is_staged, & |
| 290 | dirty_files(i)%is_unstaged, & |
| 291 | dirty_files(i)%is_untracked, & |
| 292 | dirty_files(i)%has_incoming) |
| 293 | end do |
| 294 | end subroutine overlay_git_status |
| 295 | |
| 296 | recursive subroutine update_node_status(node, path, is_staged, is_unstaged, is_untracked, has_incoming) |
| 297 | type(tree_node_t), pointer, intent(inout) :: node |
| 298 | character(len=*), intent(in) :: path |
| 299 | logical, intent(in) :: is_staged, is_unstaged, is_untracked, has_incoming |
| 300 | type(tree_node_t), pointer :: child |
| 301 | |
| 302 | if (.not. associated(node)) return |
| 303 | |
| 304 | ! Check if this node matches the path |
| 305 | if (node%is_file .and. trim(node%full_path) == trim(path)) then |
| 306 | node%is_staged = is_staged |
| 307 | node%is_unstaged = is_unstaged |
| 308 | node%is_untracked = is_untracked |
| 309 | node%has_incoming = has_incoming |
| 310 | return |
| 311 | end if |
| 312 | |
| 313 | ! Recurse to children |
| 314 | child => node%first_child |
| 315 | do while (associated(child)) |
| 316 | call update_node_status(child, path, is_staged, is_unstaged, is_untracked, has_incoming) |
| 317 | child => child%next_sibling |
| 318 | end do |
| 319 | end subroutine update_node_status |
| 320 | |
| 321 | subroutine resize_file_array(arr, new_size) |
| 322 | type(file_entry_t), allocatable, intent(inout) :: arr(:) |
| 323 | integer, intent(in) :: new_size |
| 324 | type(file_entry_t), allocatable :: temp(:) |
| 325 | integer :: old_size |
| 326 | |
| 327 | old_size = size(arr) |
| 328 | allocate(temp(new_size)) |
| 329 | temp(1:old_size) = arr(1:old_size) |
| 330 | deallocate(arr) |
| 331 | call move_alloc(temp, arr) |
| 332 | end subroutine resize_file_array |
| 333 | |
| 334 | subroutine build_tree(files, n_files, root) |
| 335 | type(file_entry_t), intent(in) :: files(:) |
| 336 | integer, intent(in) :: n_files |
| 337 | type(tree_node_t), pointer, intent(out) :: root |
| 338 | integer :: i |
| 339 | integer :: debug_unit |
| 340 | |
| 341 | ! Create root |
| 342 | allocate(root) |
| 343 | root%name = '.' |
| 344 | root%is_file = .false. |
| 345 | root%first_child => null() |
| 346 | root%next_sibling => null() |
| 347 | |
| 348 | ! Build tree |
| 349 | do i = 1, n_files |
| 350 | call add_to_tree(root, files(i)%path, files(i)%is_staged, & |
| 351 | files(i)%is_unstaged, files(i)%is_untracked, & |
| 352 | files(i)%has_incoming) |
| 353 | end do |
| 354 | |
| 355 | ! Sort tree |
| 356 | call sort_tree(root) |
| 357 | |
| 358 | ! Mark directories that only contain hidden files |
| 359 | i = 0 ! Dummy variable |
| 360 | if (mark_empty_directories(root)) then |
| 361 | i = 1 ! Dummy assignment to use function result |
| 362 | end if |
| 363 | |
| 364 | ! DEBUG: Write tree structure to file (unconditional) |
| 365 | open(newunit=debug_unit, file='/tmp/fac_tree_debug.txt', status='replace', action='write') |
| 366 | write(debug_unit, '(A)') '=== FINAL TREE STRUCTURE ===' |
| 367 | call debug_print_tree(root, '', debug_unit) |
| 368 | close(debug_unit) |
| 369 | |
| 370 | ! Also write to a simpler path |
| 371 | open(10, file='fac_debug.txt', status='replace', action='write') |
| 372 | write(10, '(A)') '=== FINAL TREE STRUCTURE ===' |
| 373 | call debug_print_tree(root, '', 10) |
| 374 | close(10) |
| 375 | end subroutine build_tree |
| 376 | |
| 377 | ! Recursively mark directories that only contain hidden files |
| 378 | recursive function mark_empty_directories(node) result(all_hidden) |
| 379 | type(tree_node_t), pointer, intent(inout) :: node |
| 380 | logical :: all_hidden |
| 381 | type(tree_node_t), pointer :: child |
| 382 | logical :: child_hidden |
| 383 | integer :: visible_count |
| 384 | |
| 385 | if (.not. associated(node)) then |
| 386 | all_hidden = .true. |
| 387 | return |
| 388 | end if |
| 389 | |
| 390 | ! Files are hidden if they're dotfiles or gitignored |
| 391 | if (node%is_file) then |
| 392 | all_hidden = node%is_dotfile .or. node%is_gitignored |
| 393 | return |
| 394 | end if |
| 395 | |
| 396 | ! For directories, check if all children are hidden |
| 397 | visible_count = 0 |
| 398 | child => node%first_child |
| 399 | do while (associated(child)) |
| 400 | child_hidden = mark_empty_directories(child) |
| 401 | if (.not. child_hidden) then |
| 402 | visible_count = visible_count + 1 |
| 403 | end if |
| 404 | child => child%next_sibling |
| 405 | end do |
| 406 | |
| 407 | ! Directory is "all hidden" if it has no visible children |
| 408 | all_hidden = (visible_count == 0 .and. associated(node%first_child)) |
| 409 | node%all_children_hidden = all_hidden |
| 410 | |
| 411 | end function mark_empty_directories |
| 412 | |
| 413 | ! Mark files that are gitignored |
| 414 | subroutine mark_gitignored_files(root, workspace_path) |
| 415 | type(tree_node_t), pointer, intent(inout) :: root |
| 416 | character(len=*), intent(in) :: workspace_path |
| 417 | character(len=1024) :: cmd |
| 418 | integer :: status, iostat, unit_num |
| 419 | character(len=512) :: line |
| 420 | |
| 421 | ! Run git check-ignore ONCE with all files (MUCH faster than per-file) |
| 422 | ! Create temp file with all file paths, then batch check |
| 423 | write(cmd, '(A,A,A)') 'cd "', trim(workspace_path), & |
| 424 | '" && git ls-files --others --exclude .git | git check-ignore --stdin > /tmp/fac_ignored_files.txt 2>/dev/null' |
| 425 | call execute_command_line(trim(cmd), exitstat=status) |
| 426 | |
| 427 | if (status /= 0) then |
| 428 | ! No ignored files or command failed - nothing to mark |
| 429 | return |
| 430 | end if |
| 431 | |
| 432 | ! Read the list of ignored files |
| 433 | open(newunit=unit_num, file='/tmp/fac_ignored_files.txt', status='old', action='read', iostat=iostat) |
| 434 | if (iostat /= 0) return |
| 435 | |
| 436 | ! Mark each ignored file in the tree |
| 437 | do |
| 438 | read(unit_num, '(A)', iostat=iostat) line |
| 439 | if (iostat /= 0) exit |
| 440 | if (len_trim(line) > 0) then |
| 441 | call mark_file_as_ignored(root, trim(line)) |
| 442 | end if |
| 443 | end do |
| 444 | |
| 445 | close(unit_num, status='delete') |
| 446 | end subroutine mark_gitignored_files |
| 447 | |
| 448 | ! Collapse tree intelligently - only expand directories with dirty files |
| 449 | subroutine collapse_tree_smart(root) |
| 450 | type(tree_node_t), pointer, intent(inout) :: root |
| 451 | logical :: dummy |
| 452 | |
| 453 | if (.not. associated(root)) return |
| 454 | |
| 455 | ! Recursively determine which directories should be expanded |
| 456 | dummy = has_dirty_files(root) |
| 457 | end subroutine collapse_tree_smart |
| 458 | |
| 459 | ! Recursive function: returns true if node or descendants have dirty files |
| 460 | ! Side effect: sets node%expanded based on whether it should be shown expanded |
| 461 | recursive function has_dirty_files(node) result(has_dirty) |
| 462 | type(tree_node_t), pointer, intent(inout) :: node |
| 463 | logical :: has_dirty |
| 464 | type(tree_node_t), pointer :: child |
| 465 | logical :: child_has_dirty |
| 466 | |
| 467 | if (.not. associated(node)) then |
| 468 | has_dirty = .false. |
| 469 | return |
| 470 | end if |
| 471 | |
| 472 | ! Files are dirty if they have any git status |
| 473 | if (node%is_file) then |
| 474 | has_dirty = node%is_staged .or. node%is_unstaged .or. node%is_untracked |
| 475 | return |
| 476 | end if |
| 477 | |
| 478 | ! For directories, check all children |
| 479 | has_dirty = .false. |
| 480 | child => node%first_child |
| 481 | do while (associated(child)) |
| 482 | child_has_dirty = has_dirty_files(child) |
| 483 | if (child_has_dirty) has_dirty = .true. |
| 484 | child => child%next_sibling |
| 485 | end do |
| 486 | |
| 487 | ! Collapse this directory if it has no dirty descendants |
| 488 | ! Keep root always expanded |
| 489 | if (trim(node%name) == '.') then |
| 490 | node%expanded = .true. ! Root always expanded |
| 491 | else |
| 492 | node%expanded = has_dirty ! Only expand if has dirty files |
| 493 | end if |
| 494 | end function has_dirty_files |
| 495 | |
| 496 | recursive subroutine mark_file_as_ignored(node, path) |
| 497 | type(tree_node_t), pointer, intent(inout) :: node |
| 498 | character(len=*), intent(in) :: path |
| 499 | type(tree_node_t), pointer :: child |
| 500 | |
| 501 | if (.not. associated(node)) return |
| 502 | |
| 503 | ! Check if this node matches the path |
| 504 | if (node%is_file .and. trim(node%full_path) == trim(path)) then |
| 505 | node%is_gitignored = .true. |
| 506 | return |
| 507 | end if |
| 508 | |
| 509 | ! Recurse to children |
| 510 | child => node%first_child |
| 511 | do while (associated(child)) |
| 512 | call mark_file_as_ignored(child, path) |
| 513 | child => child%next_sibling |
| 514 | end do |
| 515 | end subroutine mark_file_as_ignored |
| 516 | |
| 517 | recursive subroutine debug_print_tree(node, prefix, unit) |
| 518 | type(tree_node_t), pointer, intent(in) :: node |
| 519 | character(len=*), intent(in) :: prefix |
| 520 | integer, intent(in) :: unit |
| 521 | type(tree_node_t), pointer :: child |
| 522 | |
| 523 | if (.not. associated(node)) return |
| 524 | |
| 525 | write(unit, '(A,A,A,L1,A,L1)') trim(prefix), trim(node%name), & |
| 526 | ' is_file=', node%is_file, ' has_next_sib=', associated(node%next_sibling) |
| 527 | |
| 528 | child => node%first_child |
| 529 | do while (associated(child)) |
| 530 | call debug_print_tree(child, prefix // ' ', unit) |
| 531 | child => child%next_sibling |
| 532 | end do |
| 533 | end subroutine debug_print_tree |
| 534 | |
| 535 | subroutine add_to_tree(root, path, is_staged, is_unstaged, is_untracked, has_incoming) |
| 536 | type(tree_node_t), pointer, intent(inout) :: root |
| 537 | character(len=*), intent(in) :: path |
| 538 | logical, intent(in) :: is_staged, is_unstaged, is_untracked, has_incoming |
| 539 | character(len=512) :: remaining_path, component |
| 540 | integer :: slash_pos |
| 541 | type(tree_node_t), pointer :: current, child, new_node |
| 542 | |
| 543 | current => root |
| 544 | remaining_path = trim(path) |
| 545 | |
| 546 | do while (len_trim(remaining_path) > 0) |
| 547 | slash_pos = index(remaining_path, '/') |
| 548 | |
| 549 | if (slash_pos > 0) then |
| 550 | component = remaining_path(1:slash_pos-1) |
| 551 | remaining_path = remaining_path(slash_pos+1:) |
| 552 | else |
| 553 | component = remaining_path |
| 554 | remaining_path = '' |
| 555 | end if |
| 556 | |
| 557 | ! Find or create child with this name |
| 558 | child => current%first_child |
| 559 | do while (associated(child)) |
| 560 | if (trim(child%name) == trim(component)) exit |
| 561 | child => child%next_sibling |
| 562 | end do |
| 563 | |
| 564 | if (.not. associated(child)) then |
| 565 | ! Create new node |
| 566 | allocate(new_node) |
| 567 | new_node%name = trim(component) |
| 568 | new_node%is_file = (len_trim(remaining_path) == 0) |
| 569 | new_node%expanded = .true. ! Explicitly set expanded for directories |
| 570 | new_node%parent => current ! Set parent pointer |
| 571 | new_node%first_child => null() |
| 572 | new_node%next_sibling => current%first_child |
| 573 | ! Mark as dotfile if name starts with '.' |
| 574 | new_node%is_dotfile = (len_trim(component) > 0 .and. component(1:1) == '.') |
| 575 | current%first_child => new_node |
| 576 | child => new_node |
| 577 | end if |
| 578 | |
| 579 | ! If this is the final component, set status and full path |
| 580 | if (len_trim(remaining_path) == 0) then |
| 581 | child%is_staged = is_staged |
| 582 | child%is_unstaged = is_unstaged |
| 583 | child%is_untracked = is_untracked |
| 584 | child%has_incoming = has_incoming |
| 585 | child%full_path = trim(path) |
| 586 | end if |
| 587 | |
| 588 | current => child |
| 589 | end do |
| 590 | end subroutine add_to_tree |
| 591 | |
| 592 | recursive subroutine sort_tree(node) |
| 593 | type(tree_node_t), pointer, intent(inout) :: node |
| 594 | type(tree_node_t), pointer :: child |
| 595 | |
| 596 | if (.not. associated(node)) return |
| 597 | |
| 598 | ! Sort children |
| 599 | call sort_children(node) |
| 600 | |
| 601 | ! Recursively sort descendants |
| 602 | child => node%first_child |
| 603 | do while (associated(child)) |
| 604 | call sort_tree(child) |
| 605 | child => child%next_sibling |
| 606 | end do |
| 607 | end subroutine sort_tree |
| 608 | |
| 609 | subroutine sort_children(parent) |
| 610 | type(tree_node_t), pointer, intent(inout) :: parent |
| 611 | type(tree_node_t), pointer :: sorted, current, next_node, insert_pos |
| 612 | logical :: inserted |
| 613 | |
| 614 | if (.not. associated(parent%first_child)) return |
| 615 | |
| 616 | sorted => null() |
| 617 | |
| 618 | current => parent%first_child |
| 619 | do while (associated(current)) |
| 620 | next_node => current%next_sibling |
| 621 | |
| 622 | ! Insert current into sorted list |
| 623 | if (.not. associated(sorted)) then |
| 624 | sorted => current |
| 625 | current%next_sibling => null() |
| 626 | else if (compare_nodes(current, sorted) < 0) then |
| 627 | current%next_sibling => sorted |
| 628 | sorted => current |
| 629 | else |
| 630 | insert_pos => sorted |
| 631 | inserted = .false. |
| 632 | do while (associated(insert_pos%next_sibling)) |
| 633 | if (compare_nodes(current, insert_pos%next_sibling) < 0) then |
| 634 | current%next_sibling => insert_pos%next_sibling |
| 635 | insert_pos%next_sibling => current |
| 636 | inserted = .true. |
| 637 | exit |
| 638 | end if |
| 639 | insert_pos => insert_pos%next_sibling |
| 640 | end do |
| 641 | if (.not. inserted) then |
| 642 | insert_pos%next_sibling => current |
| 643 | current%next_sibling => null() |
| 644 | end if |
| 645 | end if |
| 646 | |
| 647 | current => next_node |
| 648 | end do |
| 649 | |
| 650 | parent%first_child => sorted |
| 651 | end subroutine sort_children |
| 652 | |
| 653 | function compare_nodes(a, b) result(cmp) |
| 654 | type(tree_node_t), pointer, intent(in) :: a, b |
| 655 | integer :: cmp |
| 656 | |
| 657 | ! Directories come before files |
| 658 | if (.not. a%is_file .and. b%is_file) then |
| 659 | cmp = -1 |
| 660 | else if (a%is_file .and. .not. b%is_file) then |
| 661 | cmp = 1 |
| 662 | else |
| 663 | ! Alphabetical comparison |
| 664 | if (a%name < b%name) then |
| 665 | cmp = -1 |
| 666 | else if (a%name > b%name) then |
| 667 | cmp = 1 |
| 668 | else |
| 669 | cmp = 0 |
| 670 | end if |
| 671 | end if |
| 672 | end function compare_nodes |
| 673 | |
| 674 | ! Build list of selectable files in tree traversal order |
| 675 | subroutine build_selectable_list(root, selectable, n_selectable) |
| 676 | type(tree_node_t), pointer, intent(in) :: root |
| 677 | type(selectable_file_t), allocatable, intent(out) :: selectable(:) |
| 678 | integer, intent(out) :: n_selectable |
| 679 | type(selectable_file_t), allocatable :: temp(:) |
| 680 | integer :: max_size, count |
| 681 | |
| 682 | max_size = 1000 |
| 683 | allocate(temp(max_size)) |
| 684 | count = 0 |
| 685 | |
| 686 | ! Traverse tree and collect files |
| 687 | call collect_files_recursive(root, temp, count, max_size) |
| 688 | |
| 689 | n_selectable = count |
| 690 | allocate(selectable(n_selectable)) |
| 691 | if (n_selectable > 0) selectable(1:n_selectable) = temp(1:n_selectable) |
| 692 | deallocate(temp) |
| 693 | end subroutine build_selectable_list |
| 694 | |
| 695 | recursive subroutine collect_files_recursive(node, list, count, max_size) |
| 696 | type(tree_node_t), pointer, intent(in) :: node |
| 697 | type(selectable_file_t), intent(inout) :: list(:) |
| 698 | integer, intent(inout) :: count |
| 699 | integer, intent(in) :: max_size |
| 700 | type(tree_node_t), pointer :: child |
| 701 | |
| 702 | if (.not. associated(node)) return |
| 703 | |
| 704 | ! Add both files and directories to selectable list |
| 705 | ! Skip root node (name = '.') |
| 706 | if (trim(node%name) /= '.') then |
| 707 | count = count + 1 |
| 708 | if (count <= max_size) then |
| 709 | if (node%is_file) then |
| 710 | list(count)%path = node%full_path |
| 711 | list(count)%is_directory = .false. |
| 712 | else |
| 713 | list(count)%path = node%name ! For directories, use name |
| 714 | list(count)%is_directory = .true. |
| 715 | end if |
| 716 | list(count)%is_staged = node%is_staged |
| 717 | list(count)%is_unstaged = node%is_unstaged |
| 718 | list(count)%is_untracked = node%is_untracked |
| 719 | list(count)%node => node |
| 720 | end if |
| 721 | end if |
| 722 | |
| 723 | ! Recursively process children if this node is expanded (always recurse for root) |
| 724 | if (node%expanded .or. trim(node%name) == '.') then |
| 725 | child => node%first_child |
| 726 | do while (associated(child)) |
| 727 | call collect_files_recursive(child, list, count, max_size) |
| 728 | child => child%next_sibling |
| 729 | end do |
| 730 | end if |
| 731 | end subroutine collect_files_recursive |
| 732 | |
| 733 | recursive subroutine free_tree(node) |
| 734 | type(tree_node_t), pointer, intent(inout) :: node |
| 735 | type(tree_node_t), pointer :: child, next_child |
| 736 | |
| 737 | if (.not. associated(node)) return |
| 738 | |
| 739 | ! Free children first |
| 740 | child => node%first_child |
| 741 | do while (associated(child)) |
| 742 | next_child => child%next_sibling |
| 743 | call free_tree(child) |
| 744 | child => next_child |
| 745 | end do |
| 746 | |
| 747 | ! Free this node |
| 748 | deallocate(node) |
| 749 | node => null() |
| 750 | end subroutine free_tree |
| 751 | |
| 752 | subroutine get_repo_info(workspace_path, repo_name, branch_name) |
| 753 | character(len=*), intent(in) :: workspace_path |
| 754 | character(len=*), intent(out) :: repo_name, branch_name |
| 755 | integer :: status, unit_num |
| 756 | character(len=1024) :: cmd, buffer |
| 757 | |
| 758 | repo_name = '' |
| 759 | branch_name = '' |
| 760 | |
| 761 | ! Get branch name |
| 762 | write(cmd, '(A,A,A)') 'cd "', trim(workspace_path), '" && git branch --show-current > /tmp/fac_branch.txt 2>/dev/null' |
| 763 | call execute_command_line(trim(cmd), exitstat=status) |
| 764 | if (status == 0) then |
| 765 | open(newunit=unit_num, file='/tmp/fac_branch.txt', status='old', action='read', iostat=status) |
| 766 | if (status == 0) then |
| 767 | read(unit_num, '(A)', iostat=status) buffer |
| 768 | close(unit_num, status='delete') |
| 769 | if (status == 0) branch_name = trim(buffer) |
| 770 | end if |
| 771 | end if |
| 772 | |
| 773 | ! Get repo name from workspace path |
| 774 | ! Extract last component of path as repo name |
| 775 | call extract_repo_name(workspace_path, repo_name) |
| 776 | end subroutine get_repo_info |
| 777 | |
| 778 | subroutine extract_repo_name(path, repo_name) |
| 779 | character(len=*), intent(in) :: path |
| 780 | character(len=*), intent(out) :: repo_name |
| 781 | integer :: last_slash |
| 782 | |
| 783 | last_slash = index(path, '/', back=.true.) |
| 784 | if (last_slash > 0) then |
| 785 | repo_name = path(last_slash+1:) |
| 786 | else |
| 787 | repo_name = path |
| 788 | end if |
| 789 | end subroutine extract_repo_name |
| 790 | |
| 791 | ! Navigation functions (sibling-only) |
| 792 | subroutine tree_move_up(state) |
| 793 | type(tree_state_t), intent(inout) :: state |
| 794 | type(tree_node_t), pointer :: current_parent, candidate_parent |
| 795 | integer :: i |
| 796 | |
| 797 | if (state%selected_index < 1 .or. state%selected_index > state%n_selectable) return |
| 798 | if (.not. associated(state%selectable_files(state%selected_index)%node)) return |
| 799 | |
| 800 | ! Get current item's parent |
| 801 | current_parent => state%selectable_files(state%selected_index)%node%parent |
| 802 | |
| 803 | ! Search backwards for item with same parent |
| 804 | do i = state%selected_index - 1, 1, -1 |
| 805 | if (associated(state%selectable_files(i)%node)) then |
| 806 | candidate_parent => state%selectable_files(i)%node%parent |
| 807 | ! Check if parents match (same address or both null) |
| 808 | if (associated(current_parent) .and. associated(candidate_parent)) then |
| 809 | if (associated(current_parent, candidate_parent)) then |
| 810 | state%selected_index = i |
| 811 | return |
| 812 | end if |
| 813 | else if (.not. associated(current_parent) .and. .not. associated(candidate_parent)) then |
| 814 | state%selected_index = i |
| 815 | return |
| 816 | end if |
| 817 | end if |
| 818 | end do |
| 819 | end subroutine tree_move_up |
| 820 | |
| 821 | subroutine tree_move_down(state) |
| 822 | type(tree_state_t), intent(inout) :: state |
| 823 | type(tree_node_t), pointer :: current_parent, candidate_parent |
| 824 | integer :: i |
| 825 | |
| 826 | if (state%selected_index < 1 .or. state%selected_index > state%n_selectable) return |
| 827 | if (.not. associated(state%selectable_files(state%selected_index)%node)) return |
| 828 | |
| 829 | ! Get current item's parent |
| 830 | current_parent => state%selectable_files(state%selected_index)%node%parent |
| 831 | |
| 832 | ! Search forwards for item with same parent |
| 833 | do i = state%selected_index + 1, state%n_selectable |
| 834 | if (associated(state%selectable_files(i)%node)) then |
| 835 | candidate_parent => state%selectable_files(i)%node%parent |
| 836 | ! Check if parents match (same address or both null) |
| 837 | if (associated(current_parent) .and. associated(candidate_parent)) then |
| 838 | if (associated(current_parent, candidate_parent)) then |
| 839 | state%selected_index = i |
| 840 | return |
| 841 | end if |
| 842 | else if (.not. associated(current_parent) .and. .not. associated(candidate_parent)) then |
| 843 | state%selected_index = i |
| 844 | return |
| 845 | end if |
| 846 | end if |
| 847 | end do |
| 848 | end subroutine tree_move_down |
| 849 | |
| 850 | function get_selected_item_path(state) result(path) |
| 851 | type(tree_state_t), intent(in) :: state |
| 852 | character(len=:), allocatable :: path |
| 853 | |
| 854 | if (state%selected_index >= 1 .and. state%selected_index <= state%n_selectable) then |
| 855 | path = trim(state%selectable_files(state%selected_index)%path) |
| 856 | else |
| 857 | path = '' |
| 858 | end if |
| 859 | end function get_selected_item_path |
| 860 | |
| 861 | ! Git operations |
| 862 | subroutine tree_stage_file(state, workspace_path) |
| 863 | type(tree_state_t), intent(inout) :: state |
| 864 | character(len=*), intent(in) :: workspace_path |
| 865 | character(len=1024) :: cmd |
| 866 | character(len=:), allocatable :: selected_path |
| 867 | integer :: status, i |
| 868 | |
| 869 | if (state%selected_index < 1 .or. state%selected_index > state%n_selectable) return |
| 870 | |
| 871 | ! Save the path of the currently selected file |
| 872 | selected_path = trim(state%selectable_files(state%selected_index)%path) |
| 873 | |
| 874 | ! Stage the file |
| 875 | write(cmd, '(A,A,A,A,A)') 'cd "', trim(workspace_path), '" && git add "', & |
| 876 | trim(selected_path), '" 2>/dev/null' |
| 877 | call execute_command_line(trim(cmd), exitstat=status) |
| 878 | |
| 879 | ! Refresh tree |
| 880 | call refresh_tree_state(state, workspace_path) |
| 881 | |
| 882 | ! Restore selection to the same file |
| 883 | do i = 1, state%n_selectable |
| 884 | if (trim(state%selectable_files(i)%path) == selected_path) then |
| 885 | state%selected_index = i |
| 886 | exit |
| 887 | end if |
| 888 | end do |
| 889 | end subroutine tree_stage_file |
| 890 | |
| 891 | subroutine tree_unstage_file(state, workspace_path) |
| 892 | type(tree_state_t), intent(inout) :: state |
| 893 | character(len=*), intent(in) :: workspace_path |
| 894 | character(len=1024) :: cmd |
| 895 | character(len=:), allocatable :: selected_path |
| 896 | integer :: status, i |
| 897 | |
| 898 | if (state%selected_index < 1 .or. state%selected_index > state%n_selectable) return |
| 899 | |
| 900 | ! Save the path of the currently selected file |
| 901 | selected_path = trim(state%selectable_files(state%selected_index)%path) |
| 902 | |
| 903 | ! Unstage the file |
| 904 | write(cmd, '(A,A,A,A,A)') 'cd "', trim(workspace_path), '" && git restore --staged "', & |
| 905 | trim(selected_path), '" 2>/dev/null' |
| 906 | call execute_command_line(trim(cmd), exitstat=status) |
| 907 | |
| 908 | ! Refresh tree |
| 909 | call refresh_tree_state(state, workspace_path) |
| 910 | |
| 911 | ! Restore selection to the same file |
| 912 | do i = 1, state%n_selectable |
| 913 | if (trim(state%selectable_files(i)%path) == selected_path) then |
| 914 | state%selected_index = i |
| 915 | exit |
| 916 | end if |
| 917 | end do |
| 918 | end subroutine tree_unstage_file |
| 919 | |
| 920 | subroutine tree_toggle_expand(state) |
| 921 | type(tree_state_t), intent(inout) :: state |
| 922 | type(tree_node_t), pointer :: selected_node |
| 923 | integer :: i |
| 924 | |
| 925 | if (state%selected_index < 1 .or. state%selected_index > state%n_selectable) return |
| 926 | |
| 927 | ! Get the selected node via the pointer |
| 928 | selected_node => state%selectable_files(state%selected_index)%node |
| 929 | |
| 930 | if (.not. associated(selected_node)) return |
| 931 | |
| 932 | ! Only toggle directories (not files) |
| 933 | if (.not. selected_node%is_file .and. associated(selected_node%first_child)) then |
| 934 | selected_node%expanded = .not. selected_node%expanded |
| 935 | |
| 936 | ! Rebuild selectable list to reflect new visibility |
| 937 | if (allocated(state%selectable_files)) deallocate(state%selectable_files) |
| 938 | call build_selectable_list(state%root, state%selectable_files, state%n_selectable) |
| 939 | |
| 940 | ! Find the toggled node in the new list to maintain selection |
| 941 | do i = 1, state%n_selectable |
| 942 | if (associated(state%selectable_files(i)%node, selected_node)) then |
| 943 | state%selected_index = i |
| 944 | return |
| 945 | end if |
| 946 | end do |
| 947 | |
| 948 | ! If node not found (shouldn't happen), clamp selected index |
| 949 | if (state%selected_index > state%n_selectable .and. state%n_selectable > 0) then |
| 950 | state%selected_index = state%n_selectable |
| 951 | end if |
| 952 | end if |
| 953 | end subroutine tree_toggle_expand |
| 954 | |
| 955 | ! Update viewport to keep selected item visible |
| 956 | subroutine update_tree_viewport(state, visible_height) |
| 957 | type(tree_state_t), intent(inout) :: state |
| 958 | integer, intent(in) :: visible_height |
| 959 | |
| 960 | ! Ensure selected index is valid |
| 961 | if (state%selected_index < 1) state%selected_index = 1 |
| 962 | if (state%selected_index > state%n_selectable .and. state%n_selectable > 0) then |
| 963 | state%selected_index = state%n_selectable |
| 964 | end if |
| 965 | |
| 966 | ! Scroll up if selected item is above viewport |
| 967 | if (state%selected_index < state%viewport_offset) then |
| 968 | state%viewport_offset = state%selected_index |
| 969 | end if |
| 970 | |
| 971 | ! Scroll down if selected item is below viewport |
| 972 | if (state%selected_index >= state%viewport_offset + visible_height) then |
| 973 | state%viewport_offset = state%selected_index - visible_height + 1 |
| 974 | end if |
| 975 | |
| 976 | ! Clamp viewport_offset to valid range |
| 977 | if (state%viewport_offset < 1) state%viewport_offset = 1 |
| 978 | if (state%n_selectable > 0 .and. state%viewport_offset > state%n_selectable) then |
| 979 | state%viewport_offset = max(1, state%n_selectable - visible_height + 1) |
| 980 | end if |
| 981 | end subroutine update_tree_viewport |
| 982 | |
| 983 | end module file_tree_module |
| 984 |