Fortran · 64491 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 ! Favorites/bookmarks state
49 character(len=MAX_PATH), dimension(10) :: favorite_dirs
50 integer :: favorite_count = 0
51 logical, dimension(MAX_FILES) :: current_is_favorite, parent_is_favorite
52
53 character(len=1) :: key
54 integer :: i, rows, cols, visible_height, top_padding
55 logical :: is_shift_pressed
56 character(len=256) :: term_program
57
58 ! Initialize
59 current_dir = get_pwd()
60 parent_dir = get_parent_path(current_dir)
61 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
62 call load_favorites(favorite_dirs, favorite_count)
63
64 ! Detect terminal type once for consistent padding throughout
65 call get_environment_variable("TERM_PROGRAM", term_program)
66 if (index(term_program, "WezTerm") > 0 .or. index(term_program, "ghostty") > 0) then
67 top_padding = 1 ! WezTerm/Ghostty: 1 line padding (minimal but keeps FORTRESS visible)
68 else if (index(term_program, "Apple_Terminal") > 0 .or. index(term_program, "iTerm") > 0) then
69 top_padding = 2 ! Terminal.app and iTerm2 need 2 lines
70 else
71 top_padding = 1 ! Other terminals need 1 line
72 end if
73
74 call setup_raw_mode()
75 call enter_alt_screen() ! Use alternate screen buffer to prevent scrolling issues
76 call hide_cursor() ! Hide cursor for cleaner display
77
78 ! Clear screen and position at home
79 write(output_unit, '(a)', advance='no') ESC // "[H" ! Move to home (1,1)
80 write(output_unit, '(a)', advance='no') ESC // "[J" ! Clear from cursor to end
81 flush(output_unit)
82
83 ! Initialize selection array to false
84 do i = 1, MAX_FILES
85 is_selected(i) = .false.
86 end do
87
88 ! Main loop
89 do while (running)
90 ! Get files
91 call get_file_list(current_dir, current_files, current_is_dir, current_is_exec, current_count)
92 call get_file_list(parent_dir, parent_files, parent_is_dir, parent_is_exec, parent_count)
93
94 ! Filter dotfiles if needed
95 if (.not. show_dotfiles) then
96 call filter_dotfiles(current_files, current_is_dir, current_is_exec, current_count)
97 call filter_dotfiles(parent_files, parent_is_dir, parent_is_exec, parent_count)
98 end if
99
100 ! Initialize git arrays and selection - only for actual file counts
101 do i = 1, current_count
102 current_is_staged(i) = .false.
103 current_is_unstaged(i) = .false.
104 current_is_untracked(i) = .false.
105 current_has_incoming(i) = .false.
106 ! Keep selections if still in same directory, clear otherwise
107 if (i > MAX_FILES) then
108 is_selected(i) = .false.
109 end if
110 end do
111 do i = 1, parent_count
112 parent_is_staged(i) = .false.
113 parent_is_unstaged(i) = .false.
114 parent_is_untracked(i) = .false.
115 end do
116
117 ! Get git status if in a repo
118 if (in_git_repo) then
119 call get_git_status(current_dir, current_files, current_is_dir, current_count, &
120 current_is_staged, current_is_unstaged, current_is_untracked)
121 call mark_incoming_changes(current_dir, current_files, current_count, current_has_incoming)
122 end if
123
124 ! Mark favorited directories
125 call mark_favorites_in_lists(current_dir, current_files, current_count, current_is_dir, &
126 favorite_dirs, favorite_count, current_is_favorite)
127 call mark_favorites_in_lists(parent_dir, parent_files, parent_count, parent_is_dir, &
128 favorite_dirs, favorite_count, parent_is_favorite)
129
130 ! Get terminal size and calculate visible height accounting for padding and 2-line header
131 ! Layout: top_padding + header(2 lines) + vis_h + footer(1 line) + buffer = rows
132 ! Subtract 1 extra to prevent any scrolling: vis_h = rows - top_padding - 4
133 call get_term_size(rows, cols)
134 visible_height = rows - top_padding - 4
135
136 ! Handle navigation signals from previous iteration
137 if (selected == -1) then
138 selected = find_in_parent(temp_dir, current_files, current_count)
139 scroll_offset = max(0, selected - visible_height / 2)
140 else if (selected == -2) then
141 selected = find_file_in_list(temp_dir, current_files, current_count)
142 scroll_offset = max(0, selected - visible_height / 2)
143 end if
144
145 ! Handle move mode destination cursor
146 if (move_mode .and. move_dest_selected == -1) then
147 move_dest_selected = find_in_parent(temp_dir, current_files, current_count)
148 end if
149
150 ! Bounds check
151 if (current_count > 0) then
152 selected = max(1, min(selected, current_count))
153 if (move_mode) then
154 move_dest_selected = max(1, min(move_dest_selected, current_count))
155 end if
156 else
157 selected = 1
158 if (move_mode) move_dest_selected = 1
159 end if
160
161 ! Find current dir in parent
162 parent_selected = find_in_parent(current_dir, parent_files, parent_count)
163
164 ! Adjust scroll to keep cursor visible
165 if (move_mode) then
166 ! In move mode, track the destination cursor
167 if (move_dest_selected < scroll_offset + 1) scroll_offset = max(0, move_dest_selected - 1)
168 if (move_dest_selected > scroll_offset + visible_height) scroll_offset = move_dest_selected - visible_height
169 scroll_offset = max(0, min(scroll_offset, max(0, current_count - visible_height)))
170 else
171 ! Normal mode, track the selection cursor
172 if (selected < scroll_offset + 1) scroll_offset = max(0, selected - 1)
173 if (selected > scroll_offset + visible_height) scroll_offset = selected - visible_height
174 scroll_offset = max(0, min(scroll_offset, max(0, current_count - visible_height)))
175 end if
176
177 if (parent_selected > 0) then
178 if (parent_selected < parent_scroll_offset + 1) parent_scroll_offset = max(0, parent_selected - 1)
179 if (parent_selected > parent_scroll_offset + visible_height) parent_scroll_offset = parent_selected - visible_height
180 parent_scroll_offset = max(0, min(parent_scroll_offset, max(0, parent_count - visible_height)))
181 end if
182
183 ! Draw - position at 1,1 then clear from cursor to end (prevents scrollback)
184 write(output_unit, '(a)', advance='no') ESC // "[H" ! Move to home (1,1)
185 write(output_unit, '(a)', advance='no') ESC // "[J" ! Clear from cursor to end of screen
186 flush(output_unit)
187 call draw_interface(rows, cols, top_padding, current_dir, current_files, current_is_dir, current_is_exec, &
188 current_is_staged, current_is_unstaged, current_is_untracked, current_has_incoming, &
189 current_count, parent_files, parent_is_dir, parent_is_exec, parent_count, &
190 selected, parent_selected, scroll_offset, parent_scroll_offset, &
191 in_git_repo, repo_name, branch_name, &
192 move_mode, move_source_name, move_dest_selected, &
193 has_clipboard, clipboard_is_cut, clipboard_source_name, clipboard_count, &
194 is_selected, selection_count, &
195 current_is_favorite, parent_is_favorite)
196
197 ! Get input (with error handling for End-of-record after Enter key)
198 read(*, '(a1)', advance='no', iostat=i) key
199 ! Only cycle on End-of-record (negative iostat), which happens after pressing Enter
200 ! Don't skip on positive errors or when we successfully read a character
201 if (i < 0) cycle ! End-of-record - skip and try again
202 if (i > 0) cycle ! Other read errors - skip and try again
203
204 ! Handle input
205 select case(ichar(key))
206 case(27) ! ESC - could be arrow keys, Shift+arrow, or standalone ESC to clear selections
207 ! Read the arrow key sequence first to determine what was pressed
208 call read_arrow_key_with_shift(key, is_shift_pressed)
209
210 ! If it's not an arrow key (A/B/C/D), it was a standalone ESC press
211 if (key /= 'A' .and. key /= 'B' .and. key /= 'C' .and. key /= 'D') then
212 ! Standalone ESC - exit multi-select mode if active
213 if (selection_count > 0) then
214 call clear_all_selections(is_selected, selection_count, in_selection_mode)
215 selection_anchor = -1
216 has_disjoint_selection = .false.
217 end if
218 cycle ! Redraw and wait for next input
219 end if
220
221 ! If we get here, it's an arrow key - continue with normal arrow handling
222
223 if (move_mode) then
224 ! In move mode, navigate directories only
225 select case(key)
226 case('A') ! Up - jump to previous directory
227 move_dest_selected = find_prev_directory(current_files, current_is_dir, current_count, move_dest_selected)
228 case('B') ! Down - jump to next directory
229 move_dest_selected = find_next_directory(current_files, current_is_dir, current_count, move_dest_selected)
230 case('C') ! Right - enter directory
231 if (current_is_dir(move_dest_selected)) then
232 if (trim(current_files(move_dest_selected)) == "..") then
233 ! Don't descend into ..
234 else if (trim(current_files(move_dest_selected)) /= ".") then
235 ! Descend into directory
236 parent_dir = current_dir
237 current_dir = join_path(current_dir, current_files(move_dest_selected))
238 move_dest_selected = find_first_directory(current_files, current_is_dir, current_count)
239 scroll_offset = 0
240 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
241 end if
242 end if
243 case('D') ! Left - go to parent
244 if (current_dir /= "/") then
245 temp_dir = current_dir
246 current_dir = parent_dir
247 parent_dir = get_parent_path(current_dir)
248 move_dest_selected = -1 ! Will be set to parent dir position
249 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
250 end if
251 end select
252 else
253 ! Normal navigation
254 select case(key)
255 case('A') ! Up
256 if (is_shift_pressed .and. .not. has_disjoint_selection) then
257 ! Shift+Up: Start or extend block selection upward
258 if (selection_anchor == -1) then
259 ! Start new block selection
260 selection_anchor = selected
261 call clear_all_selections(is_selected, selection_count, in_selection_mode)
262 end if
263 if (selected > 1) selected = selected - 1
264 ! Select range from anchor to current
265 call select_range(is_selected, selection_count, selection_anchor, selected, &
266 current_files, current_count)
267 else
268 ! Normal up movement - clear anchor
269 if (selected > 1) selected = selected - 1
270 selection_anchor = -1
271 end if
272 case('B') ! Down
273 if (is_shift_pressed .and. .not. has_disjoint_selection) then
274 ! Shift+Down: Start or extend block selection downward
275 if (selection_anchor == -1) then
276 ! Start new block selection
277 selection_anchor = selected
278 call clear_all_selections(is_selected, selection_count, in_selection_mode)
279 end if
280 if (selected < current_count .and. current_count > 0) selected = selected + 1
281 ! Select range from anchor to current
282 call select_range(is_selected, selection_count, selection_anchor, selected, &
283 current_files, current_count)
284 else
285 ! Normal down movement - clear anchor
286 if (selected < current_count .and. current_count > 0) selected = selected + 1
287 selection_anchor = -1
288 end if
289 case('C') ! Right - enter directory
290 if (current_is_dir(selected)) then
291 if (trim(current_files(selected)) == "..") then
292 temp_dir = current_dir
293 current_dir = parent_dir
294 parent_dir = get_parent_path(current_dir)
295 selected = -1
296 selection_anchor = -1
297 call clear_all_selections(is_selected, selection_count, in_selection_mode)
298 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
299 else if (trim(current_files(selected)) /= ".") then
300 parent_dir = current_dir
301 current_dir = join_path(current_dir, current_files(selected))
302 selected = 1
303 scroll_offset = 0
304 selection_anchor = -1
305 call clear_all_selections(is_selected, selection_count, in_selection_mode)
306 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
307 end if
308 end if
309 case('D') ! Left - go back
310 if (current_dir /= "/") then
311 temp_dir = current_dir
312 current_dir = parent_dir
313 parent_dir = get_parent_path(current_dir)
314 selected = -1
315 selection_anchor = -1
316 call clear_all_selections(is_selected, selection_count, in_selection_mode)
317 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
318 end if
319 end select
320 end if
321 case(113, 81) ! 'q' or 'Q' - exit move mode or quit
322 if (move_mode) then
323 move_mode = .false.
324 else
325 running = .false.
326 end if
327 case(99, 67) ! 'c' or 'C' - cd to directory on exit
328 if (current_is_dir(selected)) then
329 if (trim(current_files(selected)) == "..") then
330 exit_dir = parent_dir
331 else if (trim(current_files(selected)) == ".") then
332 exit_dir = current_dir
333 else
334 exit_dir = join_path(current_dir, current_files(selected))
335 end if
336 cd_on_exit = .true.
337 running = .false.
338 end if
339 case(83, 115) ! 'S' or 's' - fzf search (moved from 'f')
340 call fzf_search(current_dir, temp_dir)
341 if (len_trim(temp_dir) > 0) then
342 parent_dir = get_parent_path(temp_dir)
343 current_dir = parent_dir
344 parent_dir = get_parent_path(current_dir)
345 selected = -2
346 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
347 end if
348 case(65, 97) ! 'A' or 'a' - git add (batch stage directories)
349 if (in_git_repo) then
350 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
351 call git_add_file(current_dir, current_files(selected))
352 end if
353 end if
354 case(85, 117) ! 'U' or 'u' - git unstage (batch unstage directories)
355 if (in_git_repo) then
356 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
357 if (current_is_staged(selected)) then
358 call git_unstage_file(current_dir, current_files(selected))
359 end if
360 end if
361 end if
362 case(77, 109) ! 'M' or 'm' - git commit
363 if (in_git_repo) then
364 call git_commit_prompt(current_dir, repo_name)
365 end if
366 case(72, 104) ! 'H' or 'h' - git push (h for "push to remote Host")
367 if (in_git_repo) then
368 call git_push_prompt(current_dir, repo_name)
369 end if
370 case(84, 116) ! 'T' or 't' - git tag
371 if (in_git_repo) then
372 call git_tag_prompt(current_dir, repo_name)
373 end if
374 case(70, 102) ! 'F' or 'f' - git fetch
375 if (in_git_repo) then
376 call git_fetch_prompt(current_dir, repo_name)
377 end if
378 case(76, 108) ! 'L' or 'l' - git pull
379 if (in_git_repo) then
380 call git_pull_prompt(current_dir, repo_name)
381 end if
382 case(79, 111) ! 'O' or 'o' - open file
383 if (.not. current_is_dir(selected)) then
384 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
385 call open_file_in_default_app(join_path(current_dir, current_files(selected)))
386 end if
387 end if
388 case(78, 110) ! 'N' or 'n' - rename file/directory
389 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
390 call rename_file_prompt(current_dir, current_files(selected))
391 end if
392 case(68, 100) ! 'D' or 'd' - show git diff
393 if (in_git_repo .and. .not. current_is_dir(selected)) then
394 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
395 if (current_is_staged(selected) .or. current_is_unstaged(selected)) then
396 call show_git_diff_fullscreen(current_dir, current_files(selected), &
397 current_is_staged(selected), current_is_unstaged(selected))
398 end if
399 end if
400 end if
401 case(82, 114) ! 'R' or 'r' - delete/remove with confirmation
402 if (selection_count > 0) then
403 ! Delete multiple selections
404 call delete_multi_with_confirmation(current_dir, current_files, current_is_dir, &
405 is_selected, selection_count, current_count)
406 ! Clear selections after delete
407 call clear_all_selections(is_selected, selection_count, in_selection_mode)
408 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
409 call delete_with_confirmation(current_dir, current_files(selected), current_is_dir(selected))
410 end if
411 case(46) ! '.' - toggle dotfiles visibility
412 show_dotfiles = .not. show_dotfiles
413 ! Reset selection to avoid going out of bounds
414 selected = 1
415 scroll_offset = 0
416 case(126) ! '~' - go to home directory
417 call get_environment_variable("HOME", temp_dir)
418 if (len_trim(temp_dir) > 0) then
419 current_dir = temp_dir
420 parent_dir = get_parent_path(current_dir)
421 selected = 1
422 scroll_offset = 0
423 selection_anchor = -1
424 call clear_all_selections(is_selected, selection_count, in_selection_mode)
425 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
426 end if
427 case(47) ! '/' - go to root directory
428 current_dir = "/"
429 parent_dir = "/"
430 selected = 1
431 scroll_offset = 0
432 selection_anchor = -1
433 call clear_all_selections(is_selected, selection_count, in_selection_mode)
434 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
435 case(42) ! '*' - toggle favorite on current directory
436 if (current_is_dir(selected) .and. trim(current_files(selected)) /= "." .and. &
437 trim(current_files(selected)) /= "..") then
438 ! Build full path for the directory
439 temp_dir = join_path(current_dir, current_files(selected))
440
441 ! Check if already favorited
442 if (is_dir_favorited(temp_dir, favorite_dirs, favorite_count)) then
443 ! Remove from favorites
444 call toggle_favorite(temp_dir, favorite_dirs, favorite_count)
445 call save_favorites(favorite_dirs, favorite_count)
446 else
447 ! Try to add
448 if (favorite_count < 10) then
449 ! Room available - add it
450 call toggle_favorite(temp_dir, favorite_dirs, favorite_count)
451 call save_favorites(favorite_dirs, favorite_count)
452 else
453 ! Full - prompt for replacement
454 call add_favorite_with_replacement(temp_dir, favorite_dirs, favorite_count)
455 call save_favorites(favorite_dirs, favorite_count)
456 end if
457 end if
458 end if
459 case(56) ! '8' - open favorites picker
460 call open_favorites_picker(favorite_dirs, favorite_count, temp_dir)
461 if (len_trim(temp_dir) > 0) then
462 ! Navigate to selected favorite
463 parent_dir = get_parent_path(temp_dir)
464 current_dir = temp_dir
465 selected = 1
466 scroll_offset = 0
467 selection_anchor = -1
468 call clear_all_selections(is_selected, selection_count, in_selection_mode)
469 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
470 end if
471 case(4) ! Ctrl-D - clear multi-select (deselect all)
472 if (selection_count > 0) then
473 call clear_all_selections(is_selected, selection_count, in_selection_mode)
474 selection_anchor = -1
475 has_disjoint_selection = .false.
476 end if
477 case(32) ! Space - toggle selection on current item
478 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
479 if (is_selected(selected)) then
480 is_selected(selected) = .false.
481 selection_count = selection_count - 1
482 else
483 is_selected(selected) = .true.
484 selection_count = selection_count + 1
485 in_selection_mode = .true.
486 end if
487 ! Clear the selection anchor when toggling individual items
488 selection_anchor = -1
489 ! Check if we have disjoint selections
490 call check_disjoint_selection(is_selected, current_count, has_disjoint_selection)
491 end if
492 case(86, 118) ! 'V' or 'v' - enter move mode OR confirm move
493 if (move_mode) then
494 ! Confirm move - execute the move to the white-highlighted directory
495 call execute_move_file(move_source_path, current_dir, current_files(move_dest_selected), &
496 current_is_dir(move_dest_selected))
497 move_mode = .false.
498 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
499 ! Enter move mode - store source file or directory
500 move_source_path = join_path(current_dir, current_files(selected))
501 move_source_name = current_files(selected)
502 move_mode = .true.
503 ! Find first directory for destination cursor
504 move_dest_selected = find_first_directory(current_files, current_is_dir, current_count)
505 end if
506 case(89, 121) ! 'Y' or 'y' - yank/copy to clipboard
507 if (selection_count > 0) then
508 ! Copy multiple selections
509 clipboard_count = 0
510 do i = 1, current_count
511 if (is_selected(i) .and. trim(current_files(i)) /= "." .and. &
512 trim(current_files(i)) /= "..") then
513 clipboard_count = clipboard_count + 1
514 clipboard_paths(clipboard_count) = join_path(current_dir, current_files(i))
515 clipboard_names(clipboard_count) = current_files(i)
516 end if
517 end do
518 clipboard_is_cut = .false.
519 has_clipboard = .true.
520 ! Clear selections after copy
521 call clear_all_selections(is_selected, selection_count, in_selection_mode)
522 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
523 ! Single item copy
524 clipboard_count = 1
525 clipboard_paths(1) = join_path(current_dir, current_files(selected))
526 clipboard_names(1) = current_files(selected)
527 clipboard_source_path = clipboard_paths(1) ! For backward compatibility
528 clipboard_source_name = clipboard_names(1)
529 clipboard_is_cut = .false.
530 has_clipboard = .true.
531 end if
532 case(88, 120) ! 'X' or 'x' - cut to clipboard
533 if (selection_count > 0) then
534 ! Cut multiple selections
535 clipboard_count = 0
536 do i = 1, current_count
537 if (is_selected(i) .and. trim(current_files(i)) /= "." .and. &
538 trim(current_files(i)) /= "..") then
539 clipboard_count = clipboard_count + 1
540 clipboard_paths(clipboard_count) = join_path(current_dir, current_files(i))
541 clipboard_names(clipboard_count) = current_files(i)
542 end if
543 end do
544 clipboard_is_cut = .true.
545 has_clipboard = .true.
546 ! Clear selections after cut
547 call clear_all_selections(is_selected, selection_count, in_selection_mode)
548 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
549 ! Single item cut
550 clipboard_count = 1
551 clipboard_paths(1) = join_path(current_dir, current_files(selected))
552 clipboard_names(1) = current_files(selected)
553 clipboard_source_path = clipboard_paths(1) ! For backward compatibility
554 clipboard_source_name = clipboard_names(1)
555 clipboard_is_cut = .true.
556 has_clipboard = .true.
557 end if
558 case(80, 112) ! 'P' or 'p' - paste from clipboard
559 if (has_clipboard) then
560 if (clipboard_count > 1) then
561 ! Paste multiple items
562 call execute_multi_paste(clipboard_paths, clipboard_names, clipboard_count, &
563 clipboard_is_cut, current_dir, current_files(selected), &
564 current_is_dir(selected))
565 else
566 ! Single item paste (backward compatibility)
567 call execute_paste(clipboard_paths(1), clipboard_is_cut, current_dir, &
568 current_files(selected), current_is_dir(selected))
569 end if
570 ! Clear clipboard after cut operation
571 if (clipboard_is_cut) then
572 has_clipboard = .false.
573 clipboard_count = 0
574 end if
575 end if
576 end select
577 end do
578
579 ! Cleanup
580 call show_cursor() ! Restore cursor visibility
581 call exit_alt_screen() ! Return to normal screen buffer
582 call restore_terminal()
583 write(output_unit, '(a)', advance='no') CLEAR
584
585 if (cd_on_exit) then
586 call write_exit_dir(exit_dir)
587 else
588 write(output_unit, '(a)') "Thanks for using FORTRESS!"
589 end if
590
591 contains
592
593 subroutine filter_dotfiles(files, is_dir, is_exec, count)
594 character(len=*), dimension(*), intent(inout) :: files
595 logical, dimension(*), intent(inout) :: is_dir, is_exec
596 integer, intent(inout) :: count
597 character(len=MAX_PATH), dimension(MAX_FILES) :: temp_files
598 logical, dimension(MAX_FILES) :: temp_is_dir, temp_is_exec
599 integer :: i, new_count
600
601 new_count = 0
602 do i = 1, count
603 ! Always keep "." and "..", filter other dotfiles
604 if (trim(files(i)) == "." .or. trim(files(i)) == ".." .or. files(i)(1:1) /= '.') then
605 new_count = new_count + 1
606 temp_files(new_count) = files(i)
607 temp_is_dir(new_count) = is_dir(i)
608 temp_is_exec(new_count) = is_exec(i)
609 end if
610 end do
611
612 ! Copy back
613 do i = 1, new_count
614 files(i) = temp_files(i)
615 is_dir(i) = temp_is_dir(i)
616 is_exec(i) = temp_is_exec(i)
617 end do
618 count = new_count
619 end subroutine filter_dotfiles
620
621 function find_first_directory(files, is_dir, count) result(idx)
622 character(len=*), dimension(*), intent(in) :: files
623 logical, dimension(*), intent(in) :: is_dir
624 integer, intent(in) :: count
625 integer :: idx, i
626
627 ! Find first directory (including . and ..)
628 do i = 1, count
629 if (is_dir(i)) then
630 idx = i
631 return
632 end if
633 end do
634
635 ! If no directory found, default to first item
636 idx = 1
637 end function find_first_directory
638
639 function find_next_directory(files, is_dir, count, current) result(idx)
640 character(len=*), dimension(*), intent(in) :: files
641 logical, dimension(*), intent(in) :: is_dir
642 integer, intent(in) :: count, current
643 integer :: idx, i
644
645 ! Search forward from current position (including . and ..)
646 do i = current + 1, count
647 if (is_dir(i)) then
648 idx = i
649 return
650 end if
651 end do
652
653 ! No directory found forward, stay at current
654 idx = current
655 end function find_next_directory
656
657 function find_prev_directory(files, is_dir, count, current) result(idx)
658 character(len=*), dimension(*), intent(in) :: files
659 logical, dimension(*), intent(in) :: is_dir
660 integer, intent(in) :: count, current
661 integer :: idx, i
662
663 ! Search backward from current position (including . and ..)
664 do i = current - 1, 1, -1
665 if (is_dir(i)) then
666 idx = i
667 return
668 end if
669 end do
670
671 ! No directory found backward, stay at current
672 idx = current
673 end function find_prev_directory
674
675 subroutine execute_move_file(source_path, dest_dir, dest_name, is_dest_dir)
676 use iso_fortran_env, only: output_unit
677 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD
678 character(len=*), intent(in) :: source_path, dest_dir, dest_name
679 logical, intent(in) :: is_dest_dir
680 character(len=MAX_PATH*2) :: dest_path, mv_cmd
681 integer :: stat
682
683 ! Build destination path
684 if (is_dest_dir) then
685 if (trim(dest_name) == ".") then
686 ! Move to current directory
687 dest_path = dest_dir
688 else if (trim(dest_name) == "..") then
689 ! Move to parent directory
690 dest_path = get_parent_path(dest_dir)
691 else
692 ! Move into the selected directory
693 dest_path = join_path(dest_dir, dest_name)
694 end if
695 else
696 ! Not a directory - shouldn't happen due to our navigation, but handle it
697 dest_path = dest_dir
698 end if
699
700 ! Execute move command (mv will move file into dest_path directory)
701 mv_cmd = "mv '" // trim(source_path) // "' '" // trim(dest_path) // "'"
702 call execute_command_line(trim(mv_cmd), exitstat=stat, wait=.true.)
703
704 ! Show result briefly (no user input to avoid terminal state issues)
705 write(output_unit, '(a)', advance='no') CLEAR
706 write(output_unit, '(a)') BOLD // "Move Result" // RESET
707 write(output_unit, *)
708 if (stat == 0) then
709 write(output_unit, '(a)') GREEN // "✓ Moved successfully!" // RESET
710 write(output_unit, '(a)') " From: " // trim(source_path)
711 write(output_unit, '(a)') " To: " // trim(dest_path)
712 else
713 write(output_unit, '(a)') RED // "✗ Move failed" // RESET
714 write(output_unit, '(a)') " (destination may already exist or be invalid)"
715 end if
716 write(output_unit, *)
717
718 ! Brief pause to let user see the result (use Fortran sleep to avoid stdin issues)
719 call sleep(2)
720 end subroutine execute_move_file
721
722 subroutine execute_paste(source_path, is_cut, dest_dir, dest_name, dest_is_dir)
723 use iso_fortran_env, only: output_unit
724 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD, YELLOW
725 character(len=*), intent(in) :: source_path, dest_dir, dest_name
726 logical, intent(in) :: is_cut, dest_is_dir
727 character(len=MAX_PATH*2) :: dest_path, cmd, final_dest, base_name, extension
728 character(len=MAX_PATH*2) :: test_path
729 character(len=10) :: suffix_str
730 integer :: stat, suffix_num, ext_pos, name_len, ios
731
732 ! Determine destination directory based on cursor position
733 if (dest_is_dir) then
734 if (trim(dest_name) == ".") then
735 ! Paste into current directory
736 dest_path = dest_dir
737 else if (trim(dest_name) == "..") then
738 ! Paste into parent directory
739 dest_path = get_parent_path(dest_dir)
740 else
741 ! Paste into the selected directory
742 dest_path = join_path(dest_dir, dest_name)
743 end if
744 else
745 ! Cursor is on a file - paste into current directory (next to the file)
746 dest_path = dest_dir
747 end if
748
749 ! Extract the source filename from source_path
750 stat = index(source_path, "/", back=.true.)
751 if (stat > 0) then
752 base_name = source_path(stat+1:)
753 else
754 base_name = source_path
755 end if
756
757 ! Build initial destination (directory + filename)
758 final_dest = join_path(dest_path, base_name)
759
760 ! Check if destination exists and find available suffix if needed
761 call execute_command_line("test -e '" // trim(final_dest) // "'", exitstat=stat, wait=.true.)
762 if (stat == 0) then
763 ! Destination exists - find next available suffix (for both copy and cut)
764 ! Split filename into name and extension
765 ext_pos = index(base_name, ".", back=.true.)
766 if (ext_pos > 1) then
767 ! Has extension
768 extension = base_name(ext_pos:)
769 name_len = ext_pos - 1
770 else
771 ! No extension
772 extension = ""
773 name_len = len_trim(base_name)
774 end if
775
776 ! Find next available suffix number
777 suffix_num = 1
778 do while (suffix_num < 1000) ! Safety limit
779 ! Build the test path with suffix using concatenation
780 write(suffix_str, '(i0)') suffix_num
781
782 if (len_trim(extension) > 0) then
783 ! With extension: filename-N.ext
784 test_path = trim(dest_path) // "/" // base_name(1:name_len) // "-" // &
785 trim(suffix_str) // trim(extension)
786 else
787 ! Without extension: filename-N
788 test_path = trim(dest_path) // "/" // trim(base_name) // "-" // trim(suffix_str)
789 end if
790
791 ! Check if this suffixed name exists
792 call execute_command_line("test -e '" // trim(test_path) // "'", exitstat=stat, wait=.true.)
793 if (stat /= 0) then
794 ! This name is available!
795 final_dest = test_path
796 exit
797 end if
798 suffix_num = suffix_num + 1
799 end do
800 end if
801
802 ! Execute copy or move command
803 if (is_cut) then
804 ! Cut = move
805 cmd = "mv '" // trim(source_path) // "' '" // trim(final_dest) // "'"
806 else
807 ! Copy recursively (works for both files and directories)
808 cmd = "cp -r '" // trim(source_path) // "' '" // trim(final_dest) // "'"
809 end if
810 call execute_command_line(trim(cmd), exitstat=stat, wait=.true.)
811
812 ! Show result briefly
813 write(output_unit, '(a)', advance='no') CLEAR
814 if (is_cut) then
815 write(output_unit, '(a)') BOLD // "Cut Result" // RESET
816 else
817 write(output_unit, '(a)') BOLD // "Copy Result" // RESET
818 end if
819 write(output_unit, *)
820 if (stat == 0) then
821 if (is_cut) then
822 write(output_unit, '(a)') GREEN // "✓ Cut and pasted successfully!" // RESET
823 else
824 write(output_unit, '(a)') GREEN // "✓ Copied successfully!" // RESET
825 end if
826 write(output_unit, '(a)') " From: " // trim(source_path)
827 write(output_unit, '(a)') " To: " // trim(final_dest)
828 else
829 if (is_cut) then
830 write(output_unit, '(a)') RED // "✗ Cut failed" // RESET
831 else
832 write(output_unit, '(a)') RED // "✗ Copy failed" // RESET
833 end if
834 write(output_unit, '(a)') " (destination may already exist or be invalid)"
835 end if
836 write(output_unit, *)
837
838 ! Brief pause to let user see the result
839 call sleep(2)
840 end subroutine execute_paste
841
842 subroutine delete_with_confirmation(dir, filename, is_dir)
843 use iso_fortran_env, only: output_unit
844 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD, YELLOW
845 character(len=*), intent(in) :: dir, filename
846 logical, intent(in) :: is_dir
847 character(len=MAX_PATH*2) :: full_path, rm_cmd
848 character(len=1) :: response
849 integer :: stat, ios
850
851 ! Build full path
852 full_path = join_path(dir, filename)
853
854 ! Clear screen and show confirmation prompt
855 write(output_unit, '(a)', advance='no') CLEAR
856 write(output_unit, '(a)') BOLD // "Delete Confirmation" // RESET
857 write(output_unit, *)
858 if (is_dir) then
859 write(output_unit, '(a)') YELLOW // "WARNING: You are about to delete a directory!" // RESET
860 write(output_unit, '(a)') "Directory: " // trim(filename)
861 else
862 write(output_unit, '(a)') "File: " // trim(filename)
863 end if
864 write(output_unit, '(a)') "Path: " // trim(full_path)
865 write(output_unit, *)
866 write(output_unit, '(a)', advance='no') RED // "Are you sure? (y/N): " // RESET
867
868 ! Read single character immediately (no need to wait for Enter)
869 read(*, '(a1)', advance='no', iostat=ios) response
870
871 if (ios == 0 .and. (response == 'y' .or. response == 'Y')) then
872 ! User confirmed - proceed with deletion
873 if (is_dir) then
874 ! Delete directory recursively
875 rm_cmd = "rm -rf '" // trim(full_path) // "'"
876 else
877 ! Delete file
878 rm_cmd = "rm -f '" // trim(full_path) // "'"
879 end if
880
881 call execute_command_line(trim(rm_cmd), exitstat=stat, wait=.true.)
882
883 ! Show result
884 write(output_unit, *)
885 write(output_unit, *)
886 if (stat == 0) then
887 write(output_unit, '(a)') GREEN // "✓ Deleted successfully!" // RESET
888 else
889 write(output_unit, '(a)') RED // "✗ Delete failed" // RESET
890 end if
891 write(output_unit, *)
892 write(output_unit, '(a)') "Press any key to continue..."
893
894 ! Wait for keypress
895 read(*, '(a1)', advance='no', iostat=ios) response
896 else
897 ! User cancelled
898 write(output_unit, *)
899 write(output_unit, *)
900 write(output_unit, '(a)') "Delete cancelled."
901 write(output_unit, *)
902 write(output_unit, '(a)') "Press any key to continue..."
903
904 ! Wait for keypress
905 read(*, '(a1)', advance='no', iostat=ios) response
906 end if
907 end subroutine delete_with_confirmation
908
909 subroutine clear_all_selections(is_selected, selection_count, in_selection_mode)
910 logical, dimension(*), intent(inout) :: is_selected
911 integer, intent(inout) :: selection_count
912 logical, intent(inout) :: in_selection_mode
913 integer :: i
914
915 ! Clear all selections
916 do i = 1, MAX_FILES
917 is_selected(i) = .false.
918 end do
919 selection_count = 0
920 in_selection_mode = .false.
921 end subroutine clear_all_selections
922
923 subroutine check_disjoint_selection(is_selected, count, has_disjoint)
924 logical, dimension(*), intent(in) :: is_selected
925 integer, intent(in) :: count
926 logical, intent(out) :: has_disjoint
927 integer :: i, first_selected, last_selected
928
929 has_disjoint = .false.
930 first_selected = -1
931 last_selected = -1
932
933 ! Find first and last selected items
934 do i = 1, count
935 if (is_selected(i)) then
936 if (first_selected == -1) first_selected = i
937 last_selected = i
938 end if
939 end do
940
941 ! If we have a range, check if all items in range are selected
942 if (first_selected > 0 .and. last_selected > first_selected) then
943 do i = first_selected + 1, last_selected - 1
944 if (.not. is_selected(i)) then
945 has_disjoint = .true.
946 exit
947 end if
948 end do
949 end if
950 end subroutine check_disjoint_selection
951
952 subroutine select_range(is_selected, selection_count, anchor, cursor, files, count)
953 logical, dimension(*), intent(inout) :: is_selected
954 integer, intent(inout) :: selection_count
955 integer, intent(in) :: anchor, cursor, count
956 character(len=*), dimension(*), intent(in) :: files
957 integer :: i, range_start, range_end
958
959 ! Determine range boundaries
960 range_start = min(anchor, cursor)
961 range_end = max(anchor, cursor)
962
963 ! Clear all selections first
964 do i = 1, count
965 is_selected(i) = .false.
966 end do
967
968 ! Select the range, skipping "." and ".."
969 selection_count = 0
970 do i = range_start, range_end
971 if (i >= 1 .and. i <= count) then
972 ! Skip special directories "." and ".."
973 if (trim(files(i)) /= "." .and. trim(files(i)) /= "..") then
974 is_selected(i) = .true.
975 selection_count = selection_count + 1
976 end if
977 end if
978 end do
979 end subroutine select_range
980
981 subroutine delete_multi_with_confirmation(dir, files, is_dir, is_selected, selection_count, count)
982 use iso_fortran_env, only: output_unit
983 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD, YELLOW
984 character(len=*), intent(in) :: dir
985 character(len=*), dimension(*), intent(in) :: files
986 logical, dimension(*), intent(in) :: is_dir, is_selected
987 integer, intent(in) :: selection_count, count
988 character(len=MAX_PATH*2) :: full_path, rm_cmd
989 character(len=1) :: response
990 integer :: stat, ios, i, deleted_count
991
992 ! Clear screen and show confirmation prompt
993 write(output_unit, '(a)', advance='no') CLEAR
994 write(output_unit, '(a)') BOLD // "Delete Multiple Items" // RESET
995 write(output_unit, *)
996 write(output_unit, '(a)') YELLOW // "WARNING: You are about to delete " // &
997 trim(adjustl(itoa(selection_count))) // " items!" // RESET
998 write(output_unit, *)
999 write(output_unit, '(a)') "Selected items:"
1000
1001 ! List selected items (up to 10)
1002 i = 0
1003 do stat = 1, count
1004 if (is_selected(stat)) then
1005 i = i + 1
1006 if (i <= 10) then
1007 if (is_dir(stat)) then
1008 write(output_unit, '(a)') " [DIR] " // trim(files(stat))
1009 else
1010 write(output_unit, '(a)') " [FILE] " // trim(files(stat))
1011 end if
1012 else if (i == 11) then
1013 write(output_unit, '(a)') " ... and " // &
1014 trim(adjustl(itoa(selection_count - 10))) // " more"
1015 exit
1016 end if
1017 end if
1018 end do
1019
1020 write(output_unit, *)
1021 write(output_unit, '(a)', advance='no') RED // "Delete all selected items? (y/N): " // RESET
1022
1023 ! Read single character immediately
1024 read(*, '(a1)', advance='no', iostat=ios) response
1025
1026 if (ios == 0 .and. (response == 'y' .or. response == 'Y')) then
1027 ! User confirmed - proceed with deletion
1028 deleted_count = 0
1029
1030 do i = 1, count
1031 if (is_selected(i) .and. trim(files(i)) /= "." .and. trim(files(i)) /= "..") then
1032 full_path = join_path(dir, files(i))
1033
1034 if (is_dir(i)) then
1035 rm_cmd = "rm -rf '" // trim(full_path) // "'"
1036 else
1037 rm_cmd = "rm -f '" // trim(full_path) // "'"
1038 end if
1039
1040 call execute_command_line(trim(rm_cmd), exitstat=stat, wait=.true.)
1041 if (stat == 0) deleted_count = deleted_count + 1
1042 end if
1043 end do
1044
1045 ! Show result
1046 write(output_unit, *)
1047 write(output_unit, *)
1048 if (deleted_count == selection_count) then
1049 write(output_unit, '(a)') GREEN // "✓ All items deleted successfully!" // RESET
1050 else if (deleted_count > 0) then
1051 write(output_unit, '(a)') YELLOW // "⚠ Deleted " // &
1052 trim(adjustl(itoa(deleted_count))) // " of " // &
1053 trim(adjustl(itoa(selection_count))) // " items" // RESET
1054 else
1055 write(output_unit, '(a)') RED // "✗ Delete failed" // RESET
1056 end if
1057 write(output_unit, *)
1058 write(output_unit, '(a)') "Press any key to continue..."
1059
1060 ! Wait for keypress
1061 read(*, '(a1)', advance='no', iostat=ios) response
1062 else
1063 ! User cancelled
1064 write(output_unit, *)
1065 write(output_unit, *)
1066 write(output_unit, '(a)') "Delete cancelled."
1067 write(output_unit, *)
1068 write(output_unit, '(a)') "Press any key to continue..."
1069
1070 ! Wait for keypress
1071 read(*, '(a1)', advance='no', iostat=ios) response
1072 end if
1073 end subroutine delete_multi_with_confirmation
1074
1075 function itoa(n) result(str)
1076 integer, intent(in) :: n
1077 character(len=10) :: str
1078 write(str, '(i0)') n
1079 end function itoa
1080
1081 subroutine execute_multi_paste(paths, names, count, is_cut, dest_dir, dest_name, dest_is_dir)
1082 use iso_fortran_env, only: output_unit
1083 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD, YELLOW
1084 character(len=*), dimension(*), intent(in) :: paths, names
1085 integer, intent(in) :: count
1086 logical, intent(in) :: is_cut, dest_is_dir
1087 character(len=*), intent(in) :: dest_dir, dest_name
1088 character(len=MAX_PATH*2) :: dest_path, cmd, final_dest
1089 integer :: i, stat, success_count
1090 character(len=1) :: response
1091
1092 ! Determine destination directory
1093 if (dest_is_dir) then
1094 if (trim(dest_name) == ".") then
1095 dest_path = dest_dir
1096 else if (trim(dest_name) == "..") then
1097 dest_path = get_parent_path(dest_dir)
1098 else
1099 dest_path = join_path(dest_dir, dest_name)
1100 end if
1101 else
1102 dest_path = dest_dir
1103 end if
1104
1105 ! Show operation preview
1106 write(output_unit, '(a)', advance='no') CLEAR
1107 if (is_cut) then
1108 write(output_unit, '(a)') BOLD // "Multi-Cut Operation" // RESET
1109 else
1110 write(output_unit, '(a)') BOLD // "Multi-Copy Operation" // RESET
1111 end if
1112 write(output_unit, *)
1113 write(output_unit, '(a)') "Pasting " // trim(adjustl(itoa(count))) // " items to:"
1114 write(output_unit, '(a)') " " // trim(dest_path)
1115 write(output_unit, *)
1116
1117 success_count = 0
1118
1119 ! Process each item
1120 do i = 1, count
1121 ! Generate unique destination name if needed
1122 call get_unique_dest_name(dest_path, names(i), final_dest)
1123
1124 ! Execute operation
1125 if (is_cut) then
1126 cmd = "mv '" // trim(paths(i)) // "' '" // trim(final_dest) // "'"
1127 else
1128 cmd = "cp -r '" // trim(paths(i)) // "' '" // trim(final_dest) // "'"
1129 end if
1130
1131 call execute_command_line(trim(cmd), exitstat=stat, wait=.true.)
1132
1133 if (stat == 0) then
1134 success_count = success_count + 1
1135 write(output_unit, '(a)') GREEN // " ✓ " // RESET // trim(names(i))
1136 else
1137 write(output_unit, '(a)') RED // " ✗ " // RESET // trim(names(i))
1138 end if
1139 end do
1140
1141 ! Show summary
1142 write(output_unit, *)
1143 if (success_count == count) then
1144 write(output_unit, '(a)') GREEN // "✓ All items processed successfully!" // RESET
1145 else if (success_count > 0) then
1146 write(output_unit, '(a)') YELLOW // "⚠ Processed " // &
1147 trim(adjustl(itoa(success_count))) // " of " // &
1148 trim(adjustl(itoa(count))) // " items" // RESET
1149 else
1150 write(output_unit, '(a)') RED // "✗ Operation failed" // RESET
1151 end if
1152 write(output_unit, *)
1153 write(output_unit, '(a)') "Press any key to continue..."
1154
1155 ! Wait for keypress
1156 read(*, '(a1)', advance='no', iostat=stat) response
1157 end subroutine execute_multi_paste
1158
1159 subroutine get_unique_dest_name(dest_dir, base_name, unique_name)
1160 character(len=*), intent(in) :: dest_dir, base_name
1161 character(len=*), intent(out) :: unique_name
1162 character(len=MAX_PATH*2) :: test_path
1163 character(len=256) :: name_part, extension
1164 character(len=10) :: suffix_str
1165 integer :: ext_pos, suffix_num, stat
1166
1167 ! Initial destination
1168 unique_name = join_path(dest_dir, base_name)
1169
1170 ! Check if exists
1171 call execute_command_line("test -e '" // trim(unique_name) // "'", exitstat=stat, wait=.true.)
1172 if (stat /= 0) return ! Doesn't exist, we're good
1173
1174 ! Split name and extension
1175 ext_pos = index(base_name, ".", back=.true.)
1176 if (ext_pos > 1) then
1177 name_part = base_name(1:ext_pos-1)
1178 extension = base_name(ext_pos:)
1179 else
1180 name_part = base_name
1181 extension = ""
1182 end if
1183
1184 ! Find available suffix
1185 do suffix_num = 1, 999
1186 write(suffix_str, '(i0)') suffix_num
1187 if (len_trim(extension) > 0) then
1188 test_path = trim(dest_dir) // "/" // trim(name_part) // "-" // &
1189 trim(suffix_str) // trim(extension)
1190 else
1191 test_path = trim(dest_dir) // "/" // trim(name_part) // "-" // &
1192 trim(suffix_str)
1193 end if
1194
1195 call execute_command_line("test -e '" // trim(test_path) // "'", exitstat=stat, wait=.true.)
1196 if (stat /= 0) then
1197 unique_name = test_path
1198 exit
1199 end if
1200 end do
1201 end subroutine get_unique_dest_name
1202
1203 subroutine load_favorites(favs, count)
1204 character(len=MAX_PATH), dimension(10), intent(out) :: favs
1205 integer, intent(out) :: count
1206 character(len=MAX_PATH) :: favorites_file
1207 integer :: unit, ios
1208
1209 count = 0
1210 call get_environment_variable("HOME", favorites_file)
1211 favorites_file = trim(favorites_file) // "/.fortress_favorites"
1212
1213 open(newunit=unit, file=favorites_file, status='old', action='read', iostat=ios)
1214 if (ios /= 0) return ! File doesn't exist yet
1215
1216 do while (count < 10)
1217 read(unit, '(a)', iostat=ios) favs(count + 1)
1218 if (ios /= 0) exit
1219 if (len_trim(favs(count + 1)) > 0) count = count + 1
1220 end do
1221
1222 close(unit)
1223 end subroutine load_favorites
1224
1225 subroutine save_favorites(favs, count)
1226 character(len=MAX_PATH), dimension(10), intent(in) :: favs
1227 integer, intent(in) :: count
1228 character(len=MAX_PATH) :: favorites_file
1229 integer :: unit, ios, i
1230
1231 call get_environment_variable("HOME", favorites_file)
1232 favorites_file = trim(favorites_file) // "/.fortress_favorites"
1233
1234 open(newunit=unit, file=favorites_file, status='replace', action='write', iostat=ios)
1235 if (ios /= 0) return
1236
1237 do i = 1, count
1238 write(unit, '(a)') trim(favs(i))
1239 end do
1240
1241 close(unit)
1242 end subroutine save_favorites
1243
1244 function is_dir_favorited(dir_path, favs, count) result(is_fav)
1245 character(len=*), intent(in) :: dir_path
1246 character(len=MAX_PATH), dimension(10), intent(in) :: favs
1247 integer, intent(in) :: count
1248 logical :: is_fav
1249 integer :: i
1250
1251 is_fav = .false.
1252 do i = 1, count
1253 if (trim(favs(i)) == trim(dir_path)) then
1254 is_fav = .true.
1255 return
1256 end if
1257 end do
1258 end function is_dir_favorited
1259
1260 subroutine toggle_favorite(dir_path, favs, count)
1261 character(len=*), intent(in) :: dir_path
1262 character(len=MAX_PATH), dimension(10), intent(inout) :: favs
1263 integer, intent(inout) :: count
1264 integer :: i, j
1265 logical :: found
1266
1267 ! Check if already favorited
1268 found = .false.
1269 do i = 1, count
1270 if (trim(favs(i)) == trim(dir_path)) then
1271 ! Found - remove it
1272 found = .true.
1273 ! Shift remaining favorites down
1274 do j = i, count - 1
1275 favs(j) = favs(j + 1)
1276 end do
1277 favs(count) = ""
1278 count = count - 1
1279 exit
1280 end if
1281 end do
1282
1283 if (.not. found) then
1284 ! Not found - add it (if we have space)
1285 if (count < 10) then
1286 count = count + 1
1287 favs(count) = dir_path
1288 end if
1289 end if
1290 end subroutine toggle_favorite
1291
1292 subroutine add_favorite_with_replacement(dir_path, favs, count)
1293 use iso_fortran_env, only: output_unit
1294 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD
1295 character(len=*), intent(in) :: dir_path
1296 character(len=MAX_PATH), dimension(10), intent(inout) :: favs
1297 integer, intent(in) :: count
1298 character(len=MAX_PATH) :: temp_file, selected_fav
1299 integer :: unit, ios, stat, i
1300
1301 ! Clear screen and show prompt
1302 write(output_unit, '(a)', advance='no') CLEAR
1303 write(output_unit, '(a)') BOLD // "Favorites Full (10/10)" // RESET
1304 write(output_unit, *)
1305 write(output_unit, '(a)') "Select a favorite to replace:"
1306 write(output_unit, *)
1307
1308 ! Restore terminal for fzf
1309 call execute_command_line("stty sane 2>/dev/null")
1310
1311 ! Create temp file with current favorites
1312 call get_environment_variable("HOME", temp_file)
1313 temp_file = trim(temp_file) // "/.fortress_fav_temp"
1314
1315 open(newunit=unit, file=temp_file, status='replace', action='write', iostat=ios)
1316 if (ios == 0) then
1317 do i = 1, count
1318 write(unit, '(a)') trim(favs(i))
1319 end do
1320 close(unit)
1321 end if
1322
1323 ! Use fzf to select which favorite to replace
1324 call get_environment_variable("HOME", temp_file)
1325 temp_file = trim(temp_file) // "/.fortress_fav_select"
1326 call execute_command_line("cat ~/.fortress_fav_temp | fzf --height=10 --prompt='Replace: ' > " // &
1327 trim(temp_file) // " 2>/dev/null", exitstat=stat, wait=.true.)
1328
1329 if (stat == 0) then
1330 ! Read selected favorite
1331 open(newunit=unit, file=temp_file, status='old', action='read', iostat=ios)
1332 if (ios == 0) then
1333 read(unit, '(a)', iostat=ios) selected_fav
1334 close(unit)
1335
1336 if (ios == 0 .and. len_trim(selected_fav) > 0) then
1337 ! Replace the selected favorite with the new one
1338 do i = 1, count
1339 if (trim(favs(i)) == trim(selected_fav)) then
1340 favs(i) = dir_path
1341 write(output_unit, '(a)') GREEN // "✓ Replaced: " // trim(selected_fav) // RESET
1342 write(output_unit, '(a)') " With: " // trim(dir_path)
1343 call execute_command_line("sleep 1")
1344 exit
1345 end if
1346 end do
1347 end if
1348 end if
1349 else
1350 write(output_unit, '(a)') RED // "Cancelled." // RESET
1351 call execute_command_line("sleep 1")
1352 end if
1353
1354 ! Cleanup temp files
1355 call execute_command_line("rm -f ~/.fortress_fav_temp ~/.fortress_fav_select 2>/dev/null")
1356
1357 ! Re-enable raw mode
1358 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null", wait=.true.)
1359 end subroutine add_favorite_with_replacement
1360
1361 subroutine mark_favorites_in_lists(current_dir, files, count, is_dir, favs, fav_count, is_fav)
1362 character(len=*), intent(in) :: current_dir
1363 character(len=*), dimension(*), intent(in) :: files
1364 integer, intent(in) :: count
1365 logical, dimension(*), intent(in) :: is_dir
1366 character(len=MAX_PATH), dimension(10), intent(in) :: favs
1367 integer, intent(in) :: fav_count
1368 logical, dimension(*), intent(out) :: is_fav
1369 character(len=MAX_PATH) :: full_path
1370 integer :: i
1371
1372 ! Initialize all to false
1373 do i = 1, count
1374 is_fav(i) = .false.
1375 end do
1376
1377 ! Mark directories that are in favorites
1378 do i = 1, count
1379 if (is_dir(i) .and. trim(files(i)) /= "." .and. trim(files(i)) /= "..") then
1380 ! Build full path and check if favorited
1381 full_path = join_path(current_dir, files(i))
1382 is_fav(i) = is_dir_favorited(full_path, favs, fav_count)
1383 end if
1384 end do
1385 end subroutine mark_favorites_in_lists
1386
1387 subroutine open_favorites_picker(favs, count, selected_dir)
1388 use iso_fortran_env, only: output_unit
1389 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD
1390 character(len=MAX_PATH), dimension(10), intent(in) :: favs
1391 integer, intent(in) :: count
1392 character(len=MAX_PATH), intent(out) :: selected_dir
1393 character(len=MAX_PATH) :: temp_file
1394 integer :: unit, ios, stat, i
1395
1396 selected_dir = ""
1397
1398 if (count == 0) then
1399 ! No favorites yet
1400 write(output_unit, '(a)', advance='no') CLEAR
1401 write(output_unit, '(a)') RED // "No favorites yet!" // RESET
1402 write(output_unit, *)
1403 write(output_unit, '(a)') "Press '*' on a directory to add it to favorites."
1404 call execute_command_line("sleep 2")
1405 return
1406 end if
1407
1408 ! Restore terminal for fzf
1409 call execute_command_line("stty sane 2>/dev/null")
1410
1411 ! Create temp file with favorites
1412 call get_environment_variable("HOME", temp_file)
1413 temp_file = trim(temp_file) // "/.fortress_fav_picker"
1414
1415 open(newunit=unit, file=temp_file, status='replace', action='write', iostat=ios)
1416 if (ios == 0) then
1417 do i = 1, count
1418 write(unit, '(a)') trim(favs(i))
1419 end do
1420 close(unit)
1421 end if
1422
1423 ! Use fzf to select favorite
1424 call get_environment_variable("HOME", temp_file)
1425 temp_file = trim(temp_file) // "/.fortress_fav_selected"
1426 call execute_command_line("cat ~/.fortress_fav_picker | fzf --height=10 --prompt='Jump to: ' > " // &
1427 trim(temp_file) // " 2>/dev/null", exitstat=stat, wait=.true.)
1428
1429 if (stat == 0) then
1430 ! Read selected directory
1431 open(newunit=unit, file=temp_file, status='old', action='read', iostat=ios)
1432 if (ios == 0) then
1433 read(unit, '(a)', iostat=ios) selected_dir
1434 close(unit)
1435 end if
1436 end if
1437
1438 ! Cleanup temp files
1439 call execute_command_line("rm -f ~/.fortress_fav_picker ~/.fortress_fav_selected 2>/dev/null")
1440
1441 ! Re-enable raw mode
1442 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null", wait=.true.)
1443 end subroutine open_favorites_picker
1444
1445 end program fortress
1446