Fortran · 37674 bytes Raw Blame History
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