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