Fortran · 48921 bytes Raw Blame History
1 program fortress
2 use iso_fortran_env, only: output_unit
3 use terminal_control
4 use filesystem_ops
5 use git_ops
6 use ui_display
7 implicit none
8
9 ! State variables
10 character(len=MAX_PATH) :: current_dir, parent_dir, temp_dir, exit_dir
11 character(len=MAX_PATH), dimension(MAX_FILES) :: current_files, parent_files
12 logical, dimension(MAX_FILES) :: current_is_dir, parent_is_dir
13 logical, dimension(MAX_FILES) :: current_is_exec, parent_is_exec
14 logical, dimension(MAX_FILES) :: current_is_staged, current_is_unstaged, current_is_untracked
15 logical, dimension(MAX_FILES) :: parent_is_staged, parent_is_unstaged, parent_is_untracked
16 logical, dimension(MAX_FILES) :: current_has_incoming
17 integer :: current_count, parent_count
18 integer :: selected = 1, parent_selected = -1
19 integer :: scroll_offset = 0, parent_scroll_offset = 0
20 character(len=256) :: repo_name, branch_name
21 logical :: in_git_repo = .false., running = .true., cd_on_exit = .false.
22 logical :: show_dotfiles = .true.
23
24 ! Move mode state
25 logical :: move_mode = .false.
26 character(len=MAX_PATH) :: move_source_path
27 character(len=MAX_PATH) :: move_source_name
28 integer :: move_dest_selected = 1
29
30 ! Clipboard state
31 logical :: has_clipboard = .false.
32 logical :: clipboard_is_cut = .false. ! true = cut, false = copy
33 character(len=MAX_PATH) :: clipboard_source_path
34 character(len=MAX_PATH) :: clipboard_source_name
35
36 ! Multi-select state
37 logical, dimension(MAX_FILES) :: is_selected
38 integer :: selection_count = 0
39 logical :: in_selection_mode = .false.
40 integer :: selection_anchor = -1 ! For shift+arrow block selection
41 logical :: has_disjoint_selection = .false. ! True if non-contiguous items selected
42
43 ! Multi-clipboard for batch operations
44 character(len=MAX_PATH), dimension(MAX_FILES) :: clipboard_paths
45 character(len=MAX_PATH), dimension(MAX_FILES) :: clipboard_names
46 integer :: clipboard_count = 0
47
48 character(len=1) :: key
49 integer :: i, rows, cols, visible_height
50 logical :: is_shift_pressed
51
52 ! Initialize
53 current_dir = get_pwd()
54 parent_dir = get_parent_path(current_dir)
55 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
56 call setup_raw_mode()
57
58 ! Initialize selection array to false
59 do i = 1, MAX_FILES
60 is_selected(i) = .false.
61 end do
62
63 ! Main loop
64 do while (running)
65 ! Get files
66 call get_file_list(current_dir, current_files, current_is_dir, current_is_exec, current_count)
67 call get_file_list(parent_dir, parent_files, parent_is_dir, parent_is_exec, parent_count)
68
69 ! Filter dotfiles if needed
70 if (.not. show_dotfiles) then
71 call filter_dotfiles(current_files, current_is_dir, current_is_exec, current_count)
72 call filter_dotfiles(parent_files, parent_is_dir, parent_is_exec, parent_count)
73 end if
74
75 ! Initialize git arrays and selection - only for actual file counts
76 do i = 1, current_count
77 current_is_staged(i) = .false.
78 current_is_unstaged(i) = .false.
79 current_is_untracked(i) = .false.
80 current_has_incoming(i) = .false.
81 ! Keep selections if still in same directory, clear otherwise
82 if (i > MAX_FILES) then
83 is_selected(i) = .false.
84 end if
85 end do
86 do i = 1, parent_count
87 parent_is_staged(i) = .false.
88 parent_is_unstaged(i) = .false.
89 parent_is_untracked(i) = .false.
90 end do
91
92 ! Get git status if in a repo
93 if (in_git_repo) then
94 call get_git_status(current_dir, current_files, current_is_dir, current_count, &
95 current_is_staged, current_is_unstaged, current_is_untracked)
96 call mark_incoming_changes(current_dir, current_files, current_count, current_has_incoming)
97 end if
98
99 ! Get terminal size
100 call get_term_size(rows, cols)
101 visible_height = rows - 3
102
103 ! Handle navigation signals from previous iteration
104 if (selected == -1) then
105 selected = find_in_parent(temp_dir, current_files, current_count)
106 scroll_offset = max(0, selected - visible_height / 2)
107 else if (selected == -2) then
108 selected = find_file_in_list(temp_dir, current_files, current_count)
109 scroll_offset = max(0, selected - visible_height / 2)
110 end if
111
112 ! Handle move mode destination cursor
113 if (move_mode .and. move_dest_selected == -1) then
114 move_dest_selected = find_in_parent(temp_dir, current_files, current_count)
115 end if
116
117 ! Bounds check
118 if (current_count > 0) then
119 selected = max(1, min(selected, current_count))
120 if (move_mode) then
121 move_dest_selected = max(1, min(move_dest_selected, current_count))
122 end if
123 else
124 selected = 1
125 if (move_mode) move_dest_selected = 1
126 end if
127
128 ! Find current dir in parent
129 parent_selected = find_in_parent(current_dir, parent_files, parent_count)
130
131 ! Adjust scroll to keep cursor visible
132 if (move_mode) then
133 ! In move mode, track the destination cursor
134 if (move_dest_selected < scroll_offset + 1) scroll_offset = max(0, move_dest_selected - 1)
135 if (move_dest_selected > scroll_offset + visible_height) scroll_offset = move_dest_selected - visible_height
136 scroll_offset = max(0, min(scroll_offset, max(0, current_count - visible_height)))
137 else
138 ! Normal mode, track the selection cursor
139 if (selected < scroll_offset + 1) scroll_offset = max(0, selected - 1)
140 if (selected > scroll_offset + visible_height) scroll_offset = selected - visible_height
141 scroll_offset = max(0, min(scroll_offset, max(0, current_count - visible_height)))
142 end if
143
144 if (parent_selected > 0) then
145 if (parent_selected < parent_scroll_offset + 1) parent_scroll_offset = max(0, parent_selected - 1)
146 if (parent_selected > parent_scroll_offset + visible_height) parent_scroll_offset = parent_selected - visible_height
147 parent_scroll_offset = max(0, min(parent_scroll_offset, max(0, parent_count - visible_height)))
148 end if
149
150 ! Draw
151 write(output_unit, '(a)', advance='no') CLEAR
152 call draw_interface(rows, cols, current_dir, current_files, current_is_dir, current_is_exec, &
153 current_is_staged, current_is_unstaged, current_is_untracked, current_has_incoming, &
154 current_count, parent_files, parent_is_dir, parent_is_exec, parent_count, &
155 selected, parent_selected, scroll_offset, parent_scroll_offset, &
156 in_git_repo, repo_name, branch_name, &
157 move_mode, move_source_name, move_dest_selected, &
158 has_clipboard, clipboard_is_cut, clipboard_source_name, clipboard_count, &
159 is_selected, selection_count)
160
161 ! Get input (with error handling for End-of-record after Enter key)
162 read(*, '(a1)', advance='no', iostat=i) key
163 ! Only cycle on End-of-record (negative iostat), which happens after pressing Enter
164 ! Don't skip on positive errors or when we successfully read a character
165 if (i < 0) cycle ! End-of-record - skip and try again
166 if (i > 0) cycle ! Other read errors - skip and try again
167
168 ! Handle input
169 select case(ichar(key))
170 case(27) ! ESC - arrow keys or Shift+arrow keys
171 call read_arrow_key_with_shift(key, is_shift_pressed)
172
173 if (move_mode) then
174 ! In move mode, navigate directories only
175 select case(key)
176 case('A') ! Up - jump to previous directory
177 move_dest_selected = find_prev_directory(current_files, current_is_dir, current_count, move_dest_selected)
178 case('B') ! Down - jump to next directory
179 move_dest_selected = find_next_directory(current_files, current_is_dir, current_count, move_dest_selected)
180 case('C') ! Right - enter directory
181 if (current_is_dir(move_dest_selected)) then
182 if (trim(current_files(move_dest_selected)) == "..") then
183 ! Don't descend into ..
184 else if (trim(current_files(move_dest_selected)) /= ".") then
185 ! Descend into directory
186 parent_dir = current_dir
187 current_dir = join_path(current_dir, current_files(move_dest_selected))
188 move_dest_selected = find_first_directory(current_files, current_is_dir, current_count)
189 scroll_offset = 0
190 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
191 end if
192 end if
193 case('D') ! Left - go to parent
194 if (current_dir /= "/") then
195 temp_dir = current_dir
196 current_dir = parent_dir
197 parent_dir = get_parent_path(current_dir)
198 move_dest_selected = -1 ! Will be set to parent dir position
199 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
200 end if
201 end select
202 else
203 ! Normal navigation
204 select case(key)
205 case('A') ! Up
206 if (is_shift_pressed .and. .not. has_disjoint_selection) then
207 ! Shift+Up: Start or extend block selection upward
208 if (selection_anchor == -1) then
209 ! Start new block selection
210 selection_anchor = selected
211 call clear_all_selections(is_selected, selection_count, in_selection_mode)
212 end if
213 if (selected > 1) selected = selected - 1
214 ! Select range from anchor to current
215 call select_range(is_selected, selection_count, selection_anchor, selected, &
216 current_files, current_count)
217 else
218 ! Normal up movement - clear anchor
219 if (selected > 1) selected = selected - 1
220 selection_anchor = -1
221 end if
222 case('B') ! Down
223 if (is_shift_pressed .and. .not. has_disjoint_selection) then
224 ! Shift+Down: Start or extend block selection downward
225 if (selection_anchor == -1) then
226 ! Start new block selection
227 selection_anchor = selected
228 call clear_all_selections(is_selected, selection_count, in_selection_mode)
229 end if
230 if (selected < current_count .and. current_count > 0) selected = selected + 1
231 ! Select range from anchor to current
232 call select_range(is_selected, selection_count, selection_anchor, selected, &
233 current_files, current_count)
234 else
235 ! Normal down movement - clear anchor
236 if (selected < current_count .and. current_count > 0) selected = selected + 1
237 selection_anchor = -1
238 end if
239 case('C') ! Right - enter directory
240 if (current_is_dir(selected)) then
241 if (trim(current_files(selected)) == "..") then
242 temp_dir = current_dir
243 current_dir = parent_dir
244 parent_dir = get_parent_path(current_dir)
245 selected = -1
246 selection_anchor = -1
247 call clear_all_selections(is_selected, selection_count, in_selection_mode)
248 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
249 else if (trim(current_files(selected)) /= ".") then
250 parent_dir = current_dir
251 current_dir = join_path(current_dir, current_files(selected))
252 selected = 1
253 scroll_offset = 0
254 selection_anchor = -1
255 call clear_all_selections(is_selected, selection_count, in_selection_mode)
256 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
257 end if
258 end if
259 case('D') ! Left - go back
260 if (current_dir /= "/") then
261 temp_dir = current_dir
262 current_dir = parent_dir
263 parent_dir = get_parent_path(current_dir)
264 selected = -1
265 selection_anchor = -1
266 call clear_all_selections(is_selected, selection_count, in_selection_mode)
267 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
268 end if
269 end select
270 end if
271 case(113, 81) ! 'q' or 'Q' - exit move mode or quit
272 if (move_mode) then
273 move_mode = .false.
274 else
275 running = .false.
276 end if
277 case(99, 67) ! 'c' or 'C' - cd to directory on exit
278 if (current_is_dir(selected)) then
279 if (trim(current_files(selected)) == "..") then
280 exit_dir = parent_dir
281 else if (trim(current_files(selected)) == ".") then
282 exit_dir = current_dir
283 else
284 exit_dir = join_path(current_dir, current_files(selected))
285 end if
286 cd_on_exit = .true.
287 running = .false.
288 end if
289 case(83, 115) ! 'S' or 's' - fzf search (moved from 'f')
290 call fzf_search(current_dir, temp_dir)
291 if (len_trim(temp_dir) > 0) then
292 parent_dir = get_parent_path(temp_dir)
293 current_dir = parent_dir
294 parent_dir = get_parent_path(current_dir)
295 selected = -2
296 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
297 end if
298 case(65, 97) ! 'A' or 'a' - git add (batch stage directories)
299 if (in_git_repo) then
300 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
301 call git_add_file(current_dir, current_files(selected))
302 end if
303 end if
304 case(85, 117) ! 'U' or 'u' - git unstage (batch unstage directories)
305 if (in_git_repo) then
306 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
307 if (current_is_staged(selected)) then
308 call git_unstage_file(current_dir, current_files(selected))
309 end if
310 end if
311 end if
312 case(77, 109) ! 'M' or 'm' - git commit
313 if (in_git_repo) then
314 call git_commit_prompt(current_dir, repo_name)
315 end if
316 case(72, 104) ! 'H' or 'h' - git push (h for "push to remote Host")
317 if (in_git_repo) then
318 call git_push_prompt(current_dir, repo_name)
319 end if
320 case(84, 116) ! 'T' or 't' - git tag
321 if (in_git_repo) then
322 call git_tag_prompt(current_dir, repo_name)
323 end if
324 case(70, 102) ! 'F' or 'f' - git fetch
325 if (in_git_repo) then
326 call git_fetch_prompt(current_dir, repo_name)
327 end if
328 case(76, 108) ! 'L' or 'l' - git pull
329 if (in_git_repo) then
330 call git_pull_prompt(current_dir, repo_name)
331 end if
332 case(79, 111) ! 'O' or 'o' - open file
333 if (.not. current_is_dir(selected)) then
334 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
335 call open_file_in_default_app(join_path(current_dir, current_files(selected)))
336 end if
337 end if
338 case(78, 110) ! 'N' or 'n' - rename file/directory
339 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
340 call rename_file_prompt(current_dir, current_files(selected))
341 end if
342 case(68, 100) ! 'D' or 'd' - show git diff
343 if (in_git_repo .and. .not. current_is_dir(selected)) then
344 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
345 if (current_is_staged(selected) .or. current_is_unstaged(selected)) then
346 call show_git_diff_fullscreen(current_dir, current_files(selected), &
347 current_is_staged(selected), current_is_unstaged(selected))
348 end if
349 end if
350 end if
351 case(82, 114) ! 'R' or 'r' - delete/remove with confirmation
352 if (selection_count > 0) then
353 ! Delete multiple selections
354 call delete_multi_with_confirmation(current_dir, current_files, current_is_dir, &
355 is_selected, selection_count, current_count)
356 ! Clear selections after delete
357 call clear_all_selections(is_selected, selection_count, in_selection_mode)
358 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
359 call delete_with_confirmation(current_dir, current_files(selected), current_is_dir(selected))
360 end if
361 case(46) ! '.' - toggle dotfiles visibility
362 show_dotfiles = .not. show_dotfiles
363 ! Reset selection to avoid going out of bounds
364 selected = 1
365 scroll_offset = 0
366 case(32) ! Space - toggle selection on current item
367 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
368 if (is_selected(selected)) then
369 is_selected(selected) = .false.
370 selection_count = selection_count - 1
371 else
372 is_selected(selected) = .true.
373 selection_count = selection_count + 1
374 in_selection_mode = .true.
375 end if
376 ! Clear the selection anchor when toggling individual items
377 selection_anchor = -1
378 ! Check if we have disjoint selections
379 call check_disjoint_selection(is_selected, current_count, has_disjoint_selection)
380 end if
381 case(86, 118) ! 'V' or 'v' - enter move mode OR confirm move
382 if (move_mode) then
383 ! Confirm move - execute the move to the white-highlighted directory
384 call execute_move_file(move_source_path, current_dir, current_files(move_dest_selected), &
385 current_is_dir(move_dest_selected))
386 move_mode = .false.
387 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
388 ! Enter move mode - store source file or directory
389 move_source_path = join_path(current_dir, current_files(selected))
390 move_source_name = current_files(selected)
391 move_mode = .true.
392 ! Find first directory for destination cursor
393 move_dest_selected = find_first_directory(current_files, current_is_dir, current_count)
394 end if
395 case(89, 121) ! 'Y' or 'y' - yank/copy to clipboard
396 if (selection_count > 0) then
397 ! Copy multiple selections
398 clipboard_count = 0
399 do i = 1, current_count
400 if (is_selected(i) .and. trim(current_files(i)) /= "." .and. &
401 trim(current_files(i)) /= "..") then
402 clipboard_count = clipboard_count + 1
403 clipboard_paths(clipboard_count) = join_path(current_dir, current_files(i))
404 clipboard_names(clipboard_count) = current_files(i)
405 end if
406 end do
407 clipboard_is_cut = .false.
408 has_clipboard = .true.
409 ! Clear selections after copy
410 call clear_all_selections(is_selected, selection_count, in_selection_mode)
411 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
412 ! Single item copy
413 clipboard_count = 1
414 clipboard_paths(1) = join_path(current_dir, current_files(selected))
415 clipboard_names(1) = current_files(selected)
416 clipboard_source_path = clipboard_paths(1) ! For backward compatibility
417 clipboard_source_name = clipboard_names(1)
418 clipboard_is_cut = .false.
419 has_clipboard = .true.
420 end if
421 case(88, 120) ! 'X' or 'x' - cut to clipboard
422 if (selection_count > 0) then
423 ! Cut multiple selections
424 clipboard_count = 0
425 do i = 1, current_count
426 if (is_selected(i) .and. trim(current_files(i)) /= "." .and. &
427 trim(current_files(i)) /= "..") then
428 clipboard_count = clipboard_count + 1
429 clipboard_paths(clipboard_count) = join_path(current_dir, current_files(i))
430 clipboard_names(clipboard_count) = current_files(i)
431 end if
432 end do
433 clipboard_is_cut = .true.
434 has_clipboard = .true.
435 ! Clear selections after cut
436 call clear_all_selections(is_selected, selection_count, in_selection_mode)
437 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
438 ! Single item cut
439 clipboard_count = 1
440 clipboard_paths(1) = join_path(current_dir, current_files(selected))
441 clipboard_names(1) = current_files(selected)
442 clipboard_source_path = clipboard_paths(1) ! For backward compatibility
443 clipboard_source_name = clipboard_names(1)
444 clipboard_is_cut = .true.
445 has_clipboard = .true.
446 end if
447 case(80, 112) ! 'P' or 'p' - paste from clipboard
448 if (has_clipboard) then
449 if (clipboard_count > 1) then
450 ! Paste multiple items
451 call execute_multi_paste(clipboard_paths, clipboard_names, clipboard_count, &
452 clipboard_is_cut, current_dir, current_files(selected), &
453 current_is_dir(selected))
454 else
455 ! Single item paste (backward compatibility)
456 call execute_paste(clipboard_paths(1), clipboard_is_cut, current_dir, &
457 current_files(selected), current_is_dir(selected))
458 end if
459 ! Clear clipboard after cut operation
460 if (clipboard_is_cut) then
461 has_clipboard = .false.
462 clipboard_count = 0
463 end if
464 end if
465 end select
466 end do
467
468 ! Cleanup
469 call restore_terminal()
470 write(output_unit, '(a)', advance='no') CLEAR
471
472 if (cd_on_exit) then
473 call write_exit_dir(exit_dir)
474 else
475 write(output_unit, '(a)') "Thanks for using FORTRESS!"
476 end if
477
478 contains
479
480 subroutine filter_dotfiles(files, is_dir, is_exec, count)
481 character(len=*), dimension(*), intent(inout) :: files
482 logical, dimension(*), intent(inout) :: is_dir, is_exec
483 integer, intent(inout) :: count
484 character(len=MAX_PATH), dimension(MAX_FILES) :: temp_files
485 logical, dimension(MAX_FILES) :: temp_is_dir, temp_is_exec
486 integer :: i, new_count
487
488 new_count = 0
489 do i = 1, count
490 ! Always keep "." and "..", filter other dotfiles
491 if (trim(files(i)) == "." .or. trim(files(i)) == ".." .or. files(i)(1:1) /= '.') then
492 new_count = new_count + 1
493 temp_files(new_count) = files(i)
494 temp_is_dir(new_count) = is_dir(i)
495 temp_is_exec(new_count) = is_exec(i)
496 end if
497 end do
498
499 ! Copy back
500 do i = 1, new_count
501 files(i) = temp_files(i)
502 is_dir(i) = temp_is_dir(i)
503 is_exec(i) = temp_is_exec(i)
504 end do
505 count = new_count
506 end subroutine filter_dotfiles
507
508 function find_first_directory(files, is_dir, count) result(idx)
509 character(len=*), dimension(*), intent(in) :: files
510 logical, dimension(*), intent(in) :: is_dir
511 integer, intent(in) :: count
512 integer :: idx, i
513
514 ! Find first directory (including . and ..)
515 do i = 1, count
516 if (is_dir(i)) then
517 idx = i
518 return
519 end if
520 end do
521
522 ! If no directory found, default to first item
523 idx = 1
524 end function find_first_directory
525
526 function find_next_directory(files, is_dir, count, current) result(idx)
527 character(len=*), dimension(*), intent(in) :: files
528 logical, dimension(*), intent(in) :: is_dir
529 integer, intent(in) :: count, current
530 integer :: idx, i
531
532 ! Search forward from current position (including . and ..)
533 do i = current + 1, count
534 if (is_dir(i)) then
535 idx = i
536 return
537 end if
538 end do
539
540 ! No directory found forward, stay at current
541 idx = current
542 end function find_next_directory
543
544 function find_prev_directory(files, is_dir, count, current) result(idx)
545 character(len=*), dimension(*), intent(in) :: files
546 logical, dimension(*), intent(in) :: is_dir
547 integer, intent(in) :: count, current
548 integer :: idx, i
549
550 ! Search backward from current position (including . and ..)
551 do i = current - 1, 1, -1
552 if (is_dir(i)) then
553 idx = i
554 return
555 end if
556 end do
557
558 ! No directory found backward, stay at current
559 idx = current
560 end function find_prev_directory
561
562 subroutine execute_move_file(source_path, dest_dir, dest_name, is_dest_dir)
563 use iso_fortran_env, only: output_unit
564 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD
565 character(len=*), intent(in) :: source_path, dest_dir, dest_name
566 logical, intent(in) :: is_dest_dir
567 character(len=MAX_PATH*2) :: dest_path, mv_cmd
568 integer :: stat
569
570 ! Build destination path
571 if (is_dest_dir) then
572 if (trim(dest_name) == ".") then
573 ! Move to current directory
574 dest_path = dest_dir
575 else if (trim(dest_name) == "..") then
576 ! Move to parent directory
577 dest_path = get_parent_path(dest_dir)
578 else
579 ! Move into the selected directory
580 dest_path = join_path(dest_dir, dest_name)
581 end if
582 else
583 ! Not a directory - shouldn't happen due to our navigation, but handle it
584 dest_path = dest_dir
585 end if
586
587 ! Execute move command (mv will move file into dest_path directory)
588 mv_cmd = "mv '" // trim(source_path) // "' '" // trim(dest_path) // "'"
589 call execute_command_line(trim(mv_cmd), exitstat=stat, wait=.true.)
590
591 ! Show result briefly (no user input to avoid terminal state issues)
592 write(output_unit, '(a)', advance='no') CLEAR
593 write(output_unit, '(a)') BOLD // "Move Result" // RESET
594 write(output_unit, *)
595 if (stat == 0) then
596 write(output_unit, '(a)') GREEN // "✓ Moved successfully!" // RESET
597 write(output_unit, '(a)') " From: " // trim(source_path)
598 write(output_unit, '(a)') " To: " // trim(dest_path)
599 else
600 write(output_unit, '(a)') RED // "✗ Move failed" // RESET
601 write(output_unit, '(a)') " (destination may already exist or be invalid)"
602 end if
603 write(output_unit, *)
604
605 ! Brief pause to let user see the result (use Fortran sleep to avoid stdin issues)
606 call sleep(2)
607 end subroutine execute_move_file
608
609 subroutine execute_paste(source_path, is_cut, dest_dir, dest_name, dest_is_dir)
610 use iso_fortran_env, only: output_unit
611 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD, YELLOW
612 character(len=*), intent(in) :: source_path, dest_dir, dest_name
613 logical, intent(in) :: is_cut, dest_is_dir
614 character(len=MAX_PATH*2) :: dest_path, cmd, final_dest, base_name, extension
615 character(len=MAX_PATH*2) :: test_path
616 character(len=10) :: suffix_str
617 integer :: stat, suffix_num, ext_pos, name_len, ios
618
619 ! Determine destination directory based on cursor position
620 if (dest_is_dir) then
621 if (trim(dest_name) == ".") then
622 ! Paste into current directory
623 dest_path = dest_dir
624 else if (trim(dest_name) == "..") then
625 ! Paste into parent directory
626 dest_path = get_parent_path(dest_dir)
627 else
628 ! Paste into the selected directory
629 dest_path = join_path(dest_dir, dest_name)
630 end if
631 else
632 ! Cursor is on a file - paste into current directory (next to the file)
633 dest_path = dest_dir
634 end if
635
636 ! Extract the source filename from source_path
637 stat = index(source_path, "/", back=.true.)
638 if (stat > 0) then
639 base_name = source_path(stat+1:)
640 else
641 base_name = source_path
642 end if
643
644 ! Build initial destination (directory + filename)
645 final_dest = join_path(dest_path, base_name)
646
647 ! Check if destination exists and find available suffix if needed
648 call execute_command_line("test -e '" // trim(final_dest) // "'", exitstat=stat, wait=.true.)
649 if (stat == 0) then
650 ! Destination exists - find next available suffix (for both copy and cut)
651 ! Split filename into name and extension
652 ext_pos = index(base_name, ".", back=.true.)
653 if (ext_pos > 1) then
654 ! Has extension
655 extension = base_name(ext_pos:)
656 name_len = ext_pos - 1
657 else
658 ! No extension
659 extension = ""
660 name_len = len_trim(base_name)
661 end if
662
663 ! Find next available suffix number
664 suffix_num = 1
665 do while (suffix_num < 1000) ! Safety limit
666 ! Build the test path with suffix using concatenation
667 write(suffix_str, '(i0)') suffix_num
668
669 if (len_trim(extension) > 0) then
670 ! With extension: filename-N.ext
671 test_path = trim(dest_path) // "/" // base_name(1:name_len) // "-" // &
672 trim(suffix_str) // trim(extension)
673 else
674 ! Without extension: filename-N
675 test_path = trim(dest_path) // "/" // trim(base_name) // "-" // trim(suffix_str)
676 end if
677
678 ! Check if this suffixed name exists
679 call execute_command_line("test -e '" // trim(test_path) // "'", exitstat=stat, wait=.true.)
680 if (stat /= 0) then
681 ! This name is available!
682 final_dest = test_path
683 exit
684 end if
685 suffix_num = suffix_num + 1
686 end do
687 end if
688
689 ! Execute copy or move command
690 if (is_cut) then
691 ! Cut = move
692 cmd = "mv '" // trim(source_path) // "' '" // trim(final_dest) // "'"
693 else
694 ! Copy recursively (works for both files and directories)
695 cmd = "cp -r '" // trim(source_path) // "' '" // trim(final_dest) // "'"
696 end if
697 call execute_command_line(trim(cmd), exitstat=stat, wait=.true.)
698
699 ! Show result briefly
700 write(output_unit, '(a)', advance='no') CLEAR
701 if (is_cut) then
702 write(output_unit, '(a)') BOLD // "Cut Result" // RESET
703 else
704 write(output_unit, '(a)') BOLD // "Copy Result" // RESET
705 end if
706 write(output_unit, *)
707 if (stat == 0) then
708 if (is_cut) then
709 write(output_unit, '(a)') GREEN // "✓ Cut and pasted successfully!" // RESET
710 else
711 write(output_unit, '(a)') GREEN // "✓ Copied successfully!" // RESET
712 end if
713 write(output_unit, '(a)') " From: " // trim(source_path)
714 write(output_unit, '(a)') " To: " // trim(final_dest)
715 else
716 if (is_cut) then
717 write(output_unit, '(a)') RED // "✗ Cut failed" // RESET
718 else
719 write(output_unit, '(a)') RED // "✗ Copy failed" // RESET
720 end if
721 write(output_unit, '(a)') " (destination may already exist or be invalid)"
722 end if
723 write(output_unit, *)
724
725 ! Brief pause to let user see the result
726 call sleep(2)
727 end subroutine execute_paste
728
729 subroutine delete_with_confirmation(dir, filename, is_dir)
730 use iso_fortran_env, only: output_unit
731 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD, YELLOW
732 character(len=*), intent(in) :: dir, filename
733 logical, intent(in) :: is_dir
734 character(len=MAX_PATH*2) :: full_path, rm_cmd
735 character(len=1) :: response
736 integer :: stat, ios
737
738 ! Build full path
739 full_path = join_path(dir, filename)
740
741 ! Clear screen and show confirmation prompt
742 write(output_unit, '(a)', advance='no') CLEAR
743 write(output_unit, '(a)') BOLD // "Delete Confirmation" // RESET
744 write(output_unit, *)
745 if (is_dir) then
746 write(output_unit, '(a)') YELLOW // "WARNING: You are about to delete a directory!" // RESET
747 write(output_unit, '(a)') "Directory: " // trim(filename)
748 else
749 write(output_unit, '(a)') "File: " // trim(filename)
750 end if
751 write(output_unit, '(a)') "Path: " // trim(full_path)
752 write(output_unit, *)
753 write(output_unit, '(a)', advance='no') RED // "Are you sure? (y/N): " // RESET
754
755 ! Read single character immediately (no need to wait for Enter)
756 read(*, '(a1)', advance='no', iostat=ios) response
757
758 if (ios == 0 .and. (response == 'y' .or. response == 'Y')) then
759 ! User confirmed - proceed with deletion
760 if (is_dir) then
761 ! Delete directory recursively
762 rm_cmd = "rm -rf '" // trim(full_path) // "'"
763 else
764 ! Delete file
765 rm_cmd = "rm -f '" // trim(full_path) // "'"
766 end if
767
768 call execute_command_line(trim(rm_cmd), exitstat=stat, wait=.true.)
769
770 ! Show result
771 write(output_unit, *)
772 write(output_unit, *)
773 if (stat == 0) then
774 write(output_unit, '(a)') GREEN // "✓ Deleted successfully!" // RESET
775 else
776 write(output_unit, '(a)') RED // "✗ Delete failed" // RESET
777 end if
778 write(output_unit, *)
779 write(output_unit, '(a)') "Press any key to continue..."
780
781 ! Wait for keypress
782 read(*, '(a1)', advance='no', iostat=ios) response
783 else
784 ! User cancelled
785 write(output_unit, *)
786 write(output_unit, *)
787 write(output_unit, '(a)') "Delete cancelled."
788 write(output_unit, *)
789 write(output_unit, '(a)') "Press any key to continue..."
790
791 ! Wait for keypress
792 read(*, '(a1)', advance='no', iostat=ios) response
793 end if
794 end subroutine delete_with_confirmation
795
796 subroutine clear_all_selections(is_selected, selection_count, in_selection_mode)
797 logical, dimension(*), intent(inout) :: is_selected
798 integer, intent(inout) :: selection_count
799 logical, intent(inout) :: in_selection_mode
800 integer :: i
801
802 ! Clear all selections
803 do i = 1, MAX_FILES
804 is_selected(i) = .false.
805 end do
806 selection_count = 0
807 in_selection_mode = .false.
808 end subroutine clear_all_selections
809
810 subroutine check_disjoint_selection(is_selected, count, has_disjoint)
811 logical, dimension(*), intent(in) :: is_selected
812 integer, intent(in) :: count
813 logical, intent(out) :: has_disjoint
814 integer :: i, first_selected, last_selected
815
816 has_disjoint = .false.
817 first_selected = -1
818 last_selected = -1
819
820 ! Find first and last selected items
821 do i = 1, count
822 if (is_selected(i)) then
823 if (first_selected == -1) first_selected = i
824 last_selected = i
825 end if
826 end do
827
828 ! If we have a range, check if all items in range are selected
829 if (first_selected > 0 .and. last_selected > first_selected) then
830 do i = first_selected + 1, last_selected - 1
831 if (.not. is_selected(i)) then
832 has_disjoint = .true.
833 exit
834 end if
835 end do
836 end if
837 end subroutine check_disjoint_selection
838
839 subroutine select_range(is_selected, selection_count, anchor, cursor, files, count)
840 logical, dimension(*), intent(inout) :: is_selected
841 integer, intent(inout) :: selection_count
842 integer, intent(in) :: anchor, cursor, count
843 character(len=*), dimension(*), intent(in) :: files
844 integer :: i, range_start, range_end
845
846 ! Determine range boundaries
847 range_start = min(anchor, cursor)
848 range_end = max(anchor, cursor)
849
850 ! Clear all selections first
851 do i = 1, count
852 is_selected(i) = .false.
853 end do
854
855 ! Select the range, skipping "." and ".."
856 selection_count = 0
857 do i = range_start, range_end
858 if (i >= 1 .and. i <= count) then
859 ! Skip special directories "." and ".."
860 if (trim(files(i)) /= "." .and. trim(files(i)) /= "..") then
861 is_selected(i) = .true.
862 selection_count = selection_count + 1
863 end if
864 end if
865 end do
866 end subroutine select_range
867
868 subroutine delete_multi_with_confirmation(dir, files, is_dir, is_selected, selection_count, count)
869 use iso_fortran_env, only: output_unit
870 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD, YELLOW
871 character(len=*), intent(in) :: dir
872 character(len=*), dimension(*), intent(in) :: files
873 logical, dimension(*), intent(in) :: is_dir, is_selected
874 integer, intent(in) :: selection_count, count
875 character(len=MAX_PATH*2) :: full_path, rm_cmd
876 character(len=1) :: response
877 integer :: stat, ios, i, deleted_count
878
879 ! Clear screen and show confirmation prompt
880 write(output_unit, '(a)', advance='no') CLEAR
881 write(output_unit, '(a)') BOLD // "Delete Multiple Items" // RESET
882 write(output_unit, *)
883 write(output_unit, '(a)') YELLOW // "WARNING: You are about to delete " // &
884 trim(adjustl(itoa(selection_count))) // " items!" // RESET
885 write(output_unit, *)
886 write(output_unit, '(a)') "Selected items:"
887
888 ! List selected items (up to 10)
889 i = 0
890 do stat = 1, count
891 if (is_selected(stat)) then
892 i = i + 1
893 if (i <= 10) then
894 if (is_dir(stat)) then
895 write(output_unit, '(a)') " [DIR] " // trim(files(stat))
896 else
897 write(output_unit, '(a)') " [FILE] " // trim(files(stat))
898 end if
899 else if (i == 11) then
900 write(output_unit, '(a)') " ... and " // &
901 trim(adjustl(itoa(selection_count - 10))) // " more"
902 exit
903 end if
904 end if
905 end do
906
907 write(output_unit, *)
908 write(output_unit, '(a)', advance='no') RED // "Delete all selected items? (y/N): " // RESET
909
910 ! Read single character immediately
911 read(*, '(a1)', advance='no', iostat=ios) response
912
913 if (ios == 0 .and. (response == 'y' .or. response == 'Y')) then
914 ! User confirmed - proceed with deletion
915 deleted_count = 0
916
917 do i = 1, count
918 if (is_selected(i) .and. trim(files(i)) /= "." .and. trim(files(i)) /= "..") then
919 full_path = join_path(dir, files(i))
920
921 if (is_dir(i)) then
922 rm_cmd = "rm -rf '" // trim(full_path) // "'"
923 else
924 rm_cmd = "rm -f '" // trim(full_path) // "'"
925 end if
926
927 call execute_command_line(trim(rm_cmd), exitstat=stat, wait=.true.)
928 if (stat == 0) deleted_count = deleted_count + 1
929 end if
930 end do
931
932 ! Show result
933 write(output_unit, *)
934 write(output_unit, *)
935 if (deleted_count == selection_count) then
936 write(output_unit, '(a)') GREEN // "✓ All items deleted successfully!" // RESET
937 else if (deleted_count > 0) then
938 write(output_unit, '(a)') YELLOW // "⚠ Deleted " // &
939 trim(adjustl(itoa(deleted_count))) // " of " // &
940 trim(adjustl(itoa(selection_count))) // " items" // RESET
941 else
942 write(output_unit, '(a)') RED // "✗ Delete failed" // RESET
943 end if
944 write(output_unit, *)
945 write(output_unit, '(a)') "Press any key to continue..."
946
947 ! Wait for keypress
948 read(*, '(a1)', advance='no', iostat=ios) response
949 else
950 ! User cancelled
951 write(output_unit, *)
952 write(output_unit, *)
953 write(output_unit, '(a)') "Delete cancelled."
954 write(output_unit, *)
955 write(output_unit, '(a)') "Press any key to continue..."
956
957 ! Wait for keypress
958 read(*, '(a1)', advance='no', iostat=ios) response
959 end if
960 end subroutine delete_multi_with_confirmation
961
962 function itoa(n) result(str)
963 integer, intent(in) :: n
964 character(len=10) :: str
965 write(str, '(i0)') n
966 end function itoa
967
968 subroutine execute_multi_paste(paths, names, count, is_cut, dest_dir, dest_name, dest_is_dir)
969 use iso_fortran_env, only: output_unit
970 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD, YELLOW
971 character(len=*), dimension(*), intent(in) :: paths, names
972 integer, intent(in) :: count
973 logical, intent(in) :: is_cut, dest_is_dir
974 character(len=*), intent(in) :: dest_dir, dest_name
975 character(len=MAX_PATH*2) :: dest_path, cmd, final_dest
976 integer :: i, stat, success_count
977 character(len=1) :: response
978
979 ! Determine destination directory
980 if (dest_is_dir) then
981 if (trim(dest_name) == ".") then
982 dest_path = dest_dir
983 else if (trim(dest_name) == "..") then
984 dest_path = get_parent_path(dest_dir)
985 else
986 dest_path = join_path(dest_dir, dest_name)
987 end if
988 else
989 dest_path = dest_dir
990 end if
991
992 ! Show operation preview
993 write(output_unit, '(a)', advance='no') CLEAR
994 if (is_cut) then
995 write(output_unit, '(a)') BOLD // "Multi-Cut Operation" // RESET
996 else
997 write(output_unit, '(a)') BOLD // "Multi-Copy Operation" // RESET
998 end if
999 write(output_unit, *)
1000 write(output_unit, '(a)') "Pasting " // trim(adjustl(itoa(count))) // " items to:"
1001 write(output_unit, '(a)') " " // trim(dest_path)
1002 write(output_unit, *)
1003
1004 success_count = 0
1005
1006 ! Process each item
1007 do i = 1, count
1008 ! Generate unique destination name if needed
1009 call get_unique_dest_name(dest_path, names(i), final_dest)
1010
1011 ! Execute operation
1012 if (is_cut) then
1013 cmd = "mv '" // trim(paths(i)) // "' '" // trim(final_dest) // "'"
1014 else
1015 cmd = "cp -r '" // trim(paths(i)) // "' '" // trim(final_dest) // "'"
1016 end if
1017
1018 call execute_command_line(trim(cmd), exitstat=stat, wait=.true.)
1019
1020 if (stat == 0) then
1021 success_count = success_count + 1
1022 write(output_unit, '(a)') GREEN // " ✓ " // RESET // trim(names(i))
1023 else
1024 write(output_unit, '(a)') RED // " ✗ " // RESET // trim(names(i))
1025 end if
1026 end do
1027
1028 ! Show summary
1029 write(output_unit, *)
1030 if (success_count == count) then
1031 write(output_unit, '(a)') GREEN // "✓ All items processed successfully!" // RESET
1032 else if (success_count > 0) then
1033 write(output_unit, '(a)') YELLOW // "⚠ Processed " // &
1034 trim(adjustl(itoa(success_count))) // " of " // &
1035 trim(adjustl(itoa(count))) // " items" // RESET
1036 else
1037 write(output_unit, '(a)') RED // "✗ Operation failed" // RESET
1038 end if
1039 write(output_unit, *)
1040 write(output_unit, '(a)') "Press any key to continue..."
1041
1042 ! Wait for keypress
1043 read(*, '(a1)', advance='no', iostat=stat) response
1044 end subroutine execute_multi_paste
1045
1046 subroutine get_unique_dest_name(dest_dir, base_name, unique_name)
1047 character(len=*), intent(in) :: dest_dir, base_name
1048 character(len=*), intent(out) :: unique_name
1049 character(len=MAX_PATH*2) :: test_path
1050 character(len=256) :: name_part, extension
1051 character(len=10) :: suffix_str
1052 integer :: ext_pos, suffix_num, stat
1053
1054 ! Initial destination
1055 unique_name = join_path(dest_dir, base_name)
1056
1057 ! Check if exists
1058 call execute_command_line("test -e '" // trim(unique_name) // "'", exitstat=stat, wait=.true.)
1059 if (stat /= 0) return ! Doesn't exist, we're good
1060
1061 ! Split name and extension
1062 ext_pos = index(base_name, ".", back=.true.)
1063 if (ext_pos > 1) then
1064 name_part = base_name(1:ext_pos-1)
1065 extension = base_name(ext_pos:)
1066 else
1067 name_part = base_name
1068 extension = ""
1069 end if
1070
1071 ! Find available suffix
1072 do suffix_num = 1, 999
1073 write(suffix_str, '(i0)') suffix_num
1074 if (len_trim(extension) > 0) then
1075 test_path = trim(dest_dir) // "/" // trim(name_part) // "-" // &
1076 trim(suffix_str) // trim(extension)
1077 else
1078 test_path = trim(dest_dir) // "/" // trim(name_part) // "-" // &
1079 trim(suffix_str)
1080 end if
1081
1082 call execute_command_line("test -e '" // trim(test_path) // "'", exitstat=stat, wait=.true.)
1083 if (stat /= 0) then
1084 unique_name = test_path
1085 exit
1086 end if
1087 end do
1088 end subroutine get_unique_dest_name
1089
1090 end program fortress
1091