Fortran · 64170 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
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(32) ! Space - toggle selection on current item
472 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
473 if (is_selected(selected)) then
474 is_selected(selected) = .false.
475 selection_count = selection_count - 1
476 else
477 is_selected(selected) = .true.
478 selection_count = selection_count + 1
479 in_selection_mode = .true.
480 end if
481 ! Clear the selection anchor when toggling individual items
482 selection_anchor = -1
483 ! Check if we have disjoint selections
484 call check_disjoint_selection(is_selected, current_count, has_disjoint_selection)
485 end if
486 case(86, 118) ! 'V' or 'v' - enter move mode OR confirm move
487 if (move_mode) then
488 ! Confirm move - execute the move to the white-highlighted directory
489 call execute_move_file(move_source_path, current_dir, current_files(move_dest_selected), &
490 current_is_dir(move_dest_selected))
491 move_mode = .false.
492 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
493 ! Enter move mode - store source file or directory
494 move_source_path = join_path(current_dir, current_files(selected))
495 move_source_name = current_files(selected)
496 move_mode = .true.
497 ! Find first directory for destination cursor
498 move_dest_selected = find_first_directory(current_files, current_is_dir, current_count)
499 end if
500 case(89, 121) ! 'Y' or 'y' - yank/copy to clipboard
501 if (selection_count > 0) then
502 ! Copy multiple selections
503 clipboard_count = 0
504 do i = 1, current_count
505 if (is_selected(i) .and. trim(current_files(i)) /= "." .and. &
506 trim(current_files(i)) /= "..") then
507 clipboard_count = clipboard_count + 1
508 clipboard_paths(clipboard_count) = join_path(current_dir, current_files(i))
509 clipboard_names(clipboard_count) = current_files(i)
510 end if
511 end do
512 clipboard_is_cut = .false.
513 has_clipboard = .true.
514 ! Clear selections after copy
515 call clear_all_selections(is_selected, selection_count, in_selection_mode)
516 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
517 ! Single item copy
518 clipboard_count = 1
519 clipboard_paths(1) = join_path(current_dir, current_files(selected))
520 clipboard_names(1) = current_files(selected)
521 clipboard_source_path = clipboard_paths(1) ! For backward compatibility
522 clipboard_source_name = clipboard_names(1)
523 clipboard_is_cut = .false.
524 has_clipboard = .true.
525 end if
526 case(88, 120) ! 'X' or 'x' - cut to clipboard
527 if (selection_count > 0) then
528 ! Cut multiple selections
529 clipboard_count = 0
530 do i = 1, current_count
531 if (is_selected(i) .and. trim(current_files(i)) /= "." .and. &
532 trim(current_files(i)) /= "..") then
533 clipboard_count = clipboard_count + 1
534 clipboard_paths(clipboard_count) = join_path(current_dir, current_files(i))
535 clipboard_names(clipboard_count) = current_files(i)
536 end if
537 end do
538 clipboard_is_cut = .true.
539 has_clipboard = .true.
540 ! Clear selections after cut
541 call clear_all_selections(is_selected, selection_count, in_selection_mode)
542 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
543 ! Single item cut
544 clipboard_count = 1
545 clipboard_paths(1) = join_path(current_dir, current_files(selected))
546 clipboard_names(1) = current_files(selected)
547 clipboard_source_path = clipboard_paths(1) ! For backward compatibility
548 clipboard_source_name = clipboard_names(1)
549 clipboard_is_cut = .true.
550 has_clipboard = .true.
551 end if
552 case(80, 112) ! 'P' or 'p' - paste from clipboard
553 if (has_clipboard) then
554 if (clipboard_count > 1) then
555 ! Paste multiple items
556 call execute_multi_paste(clipboard_paths, clipboard_names, clipboard_count, &
557 clipboard_is_cut, current_dir, current_files(selected), &
558 current_is_dir(selected))
559 else
560 ! Single item paste (backward compatibility)
561 call execute_paste(clipboard_paths(1), clipboard_is_cut, current_dir, &
562 current_files(selected), current_is_dir(selected))
563 end if
564 ! Clear clipboard after cut operation
565 if (clipboard_is_cut) then
566 has_clipboard = .false.
567 clipboard_count = 0
568 end if
569 end if
570 end select
571 end do
572
573 ! Cleanup
574 call show_cursor() ! Restore cursor visibility
575 call exit_alt_screen() ! Return to normal screen buffer
576 call restore_terminal()
577 write(output_unit, '(a)', advance='no') CLEAR
578
579 if (cd_on_exit) then
580 call write_exit_dir(exit_dir)
581 else
582 write(output_unit, '(a)') "Thanks for using FORTRESS!"
583 end if
584
585 contains
586
587 subroutine filter_dotfiles(files, is_dir, is_exec, count)
588 character(len=*), dimension(*), intent(inout) :: files
589 logical, dimension(*), intent(inout) :: is_dir, is_exec
590 integer, intent(inout) :: count
591 character(len=MAX_PATH), dimension(MAX_FILES) :: temp_files
592 logical, dimension(MAX_FILES) :: temp_is_dir, temp_is_exec
593 integer :: i, new_count
594
595 new_count = 0
596 do i = 1, count
597 ! Always keep "." and "..", filter other dotfiles
598 if (trim(files(i)) == "." .or. trim(files(i)) == ".." .or. files(i)(1:1) /= '.') then
599 new_count = new_count + 1
600 temp_files(new_count) = files(i)
601 temp_is_dir(new_count) = is_dir(i)
602 temp_is_exec(new_count) = is_exec(i)
603 end if
604 end do
605
606 ! Copy back
607 do i = 1, new_count
608 files(i) = temp_files(i)
609 is_dir(i) = temp_is_dir(i)
610 is_exec(i) = temp_is_exec(i)
611 end do
612 count = new_count
613 end subroutine filter_dotfiles
614
615 function find_first_directory(files, is_dir, count) result(idx)
616 character(len=*), dimension(*), intent(in) :: files
617 logical, dimension(*), intent(in) :: is_dir
618 integer, intent(in) :: count
619 integer :: idx, i
620
621 ! Find first directory (including . and ..)
622 do i = 1, count
623 if (is_dir(i)) then
624 idx = i
625 return
626 end if
627 end do
628
629 ! If no directory found, default to first item
630 idx = 1
631 end function find_first_directory
632
633 function find_next_directory(files, is_dir, count, current) result(idx)
634 character(len=*), dimension(*), intent(in) :: files
635 logical, dimension(*), intent(in) :: is_dir
636 integer, intent(in) :: count, current
637 integer :: idx, i
638
639 ! Search forward from current position (including . and ..)
640 do i = current + 1, count
641 if (is_dir(i)) then
642 idx = i
643 return
644 end if
645 end do
646
647 ! No directory found forward, stay at current
648 idx = current
649 end function find_next_directory
650
651 function find_prev_directory(files, is_dir, count, current) result(idx)
652 character(len=*), dimension(*), intent(in) :: files
653 logical, dimension(*), intent(in) :: is_dir
654 integer, intent(in) :: count, current
655 integer :: idx, i
656
657 ! Search backward from current position (including . and ..)
658 do i = current - 1, 1, -1
659 if (is_dir(i)) then
660 idx = i
661 return
662 end if
663 end do
664
665 ! No directory found backward, stay at current
666 idx = current
667 end function find_prev_directory
668
669 subroutine execute_move_file(source_path, dest_dir, dest_name, is_dest_dir)
670 use iso_fortran_env, only: output_unit
671 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD
672 character(len=*), intent(in) :: source_path, dest_dir, dest_name
673 logical, intent(in) :: is_dest_dir
674 character(len=MAX_PATH*2) :: dest_path, mv_cmd
675 integer :: stat
676
677 ! Build destination path
678 if (is_dest_dir) then
679 if (trim(dest_name) == ".") then
680 ! Move to current directory
681 dest_path = dest_dir
682 else if (trim(dest_name) == "..") then
683 ! Move to parent directory
684 dest_path = get_parent_path(dest_dir)
685 else
686 ! Move into the selected directory
687 dest_path = join_path(dest_dir, dest_name)
688 end if
689 else
690 ! Not a directory - shouldn't happen due to our navigation, but handle it
691 dest_path = dest_dir
692 end if
693
694 ! Execute move command (mv will move file into dest_path directory)
695 mv_cmd = "mv '" // trim(source_path) // "' '" // trim(dest_path) // "'"
696 call execute_command_line(trim(mv_cmd), exitstat=stat, wait=.true.)
697
698 ! Show result briefly (no user input to avoid terminal state issues)
699 write(output_unit, '(a)', advance='no') CLEAR
700 write(output_unit, '(a)') BOLD // "Move Result" // RESET
701 write(output_unit, *)
702 if (stat == 0) then
703 write(output_unit, '(a)') GREEN // "✓ Moved successfully!" // RESET
704 write(output_unit, '(a)') " From: " // trim(source_path)
705 write(output_unit, '(a)') " To: " // trim(dest_path)
706 else
707 write(output_unit, '(a)') RED // "✗ Move failed" // RESET
708 write(output_unit, '(a)') " (destination may already exist or be invalid)"
709 end if
710 write(output_unit, *)
711
712 ! Brief pause to let user see the result (use Fortran sleep to avoid stdin issues)
713 call sleep(2)
714 end subroutine execute_move_file
715
716 subroutine execute_paste(source_path, is_cut, dest_dir, dest_name, dest_is_dir)
717 use iso_fortran_env, only: output_unit
718 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD, YELLOW
719 character(len=*), intent(in) :: source_path, dest_dir, dest_name
720 logical, intent(in) :: is_cut, dest_is_dir
721 character(len=MAX_PATH*2) :: dest_path, cmd, final_dest, base_name, extension
722 character(len=MAX_PATH*2) :: test_path
723 character(len=10) :: suffix_str
724 integer :: stat, suffix_num, ext_pos, name_len, ios
725
726 ! Determine destination directory based on cursor position
727 if (dest_is_dir) then
728 if (trim(dest_name) == ".") then
729 ! Paste into current directory
730 dest_path = dest_dir
731 else if (trim(dest_name) == "..") then
732 ! Paste into parent directory
733 dest_path = get_parent_path(dest_dir)
734 else
735 ! Paste into the selected directory
736 dest_path = join_path(dest_dir, dest_name)
737 end if
738 else
739 ! Cursor is on a file - paste into current directory (next to the file)
740 dest_path = dest_dir
741 end if
742
743 ! Extract the source filename from source_path
744 stat = index(source_path, "/", back=.true.)
745 if (stat > 0) then
746 base_name = source_path(stat+1:)
747 else
748 base_name = source_path
749 end if
750
751 ! Build initial destination (directory + filename)
752 final_dest = join_path(dest_path, base_name)
753
754 ! Check if destination exists and find available suffix if needed
755 call execute_command_line("test -e '" // trim(final_dest) // "'", exitstat=stat, wait=.true.)
756 if (stat == 0) then
757 ! Destination exists - find next available suffix (for both copy and cut)
758 ! Split filename into name and extension
759 ext_pos = index(base_name, ".", back=.true.)
760 if (ext_pos > 1) then
761 ! Has extension
762 extension = base_name(ext_pos:)
763 name_len = ext_pos - 1
764 else
765 ! No extension
766 extension = ""
767 name_len = len_trim(base_name)
768 end if
769
770 ! Find next available suffix number
771 suffix_num = 1
772 do while (suffix_num < 1000) ! Safety limit
773 ! Build the test path with suffix using concatenation
774 write(suffix_str, '(i0)') suffix_num
775
776 if (len_trim(extension) > 0) then
777 ! With extension: filename-N.ext
778 test_path = trim(dest_path) // "/" // base_name(1:name_len) // "-" // &
779 trim(suffix_str) // trim(extension)
780 else
781 ! Without extension: filename-N
782 test_path = trim(dest_path) // "/" // trim(base_name) // "-" // trim(suffix_str)
783 end if
784
785 ! Check if this suffixed name exists
786 call execute_command_line("test -e '" // trim(test_path) // "'", exitstat=stat, wait=.true.)
787 if (stat /= 0) then
788 ! This name is available!
789 final_dest = test_path
790 exit
791 end if
792 suffix_num = suffix_num + 1
793 end do
794 end if
795
796 ! Execute copy or move command
797 if (is_cut) then
798 ! Cut = move
799 cmd = "mv '" // trim(source_path) // "' '" // trim(final_dest) // "'"
800 else
801 ! Copy recursively (works for both files and directories)
802 cmd = "cp -r '" // trim(source_path) // "' '" // trim(final_dest) // "'"
803 end if
804 call execute_command_line(trim(cmd), exitstat=stat, wait=.true.)
805
806 ! Show result briefly
807 write(output_unit, '(a)', advance='no') CLEAR
808 if (is_cut) then
809 write(output_unit, '(a)') BOLD // "Cut Result" // RESET
810 else
811 write(output_unit, '(a)') BOLD // "Copy Result" // RESET
812 end if
813 write(output_unit, *)
814 if (stat == 0) then
815 if (is_cut) then
816 write(output_unit, '(a)') GREEN // "✓ Cut and pasted successfully!" // RESET
817 else
818 write(output_unit, '(a)') GREEN // "✓ Copied successfully!" // RESET
819 end if
820 write(output_unit, '(a)') " From: " // trim(source_path)
821 write(output_unit, '(a)') " To: " // trim(final_dest)
822 else
823 if (is_cut) then
824 write(output_unit, '(a)') RED // "✗ Cut failed" // RESET
825 else
826 write(output_unit, '(a)') RED // "✗ Copy failed" // RESET
827 end if
828 write(output_unit, '(a)') " (destination may already exist or be invalid)"
829 end if
830 write(output_unit, *)
831
832 ! Brief pause to let user see the result
833 call sleep(2)
834 end subroutine execute_paste
835
836 subroutine delete_with_confirmation(dir, filename, is_dir)
837 use iso_fortran_env, only: output_unit
838 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD, YELLOW
839 character(len=*), intent(in) :: dir, filename
840 logical, intent(in) :: is_dir
841 character(len=MAX_PATH*2) :: full_path, rm_cmd
842 character(len=1) :: response
843 integer :: stat, ios
844
845 ! Build full path
846 full_path = join_path(dir, filename)
847
848 ! Clear screen and show confirmation prompt
849 write(output_unit, '(a)', advance='no') CLEAR
850 write(output_unit, '(a)') BOLD // "Delete Confirmation" // RESET
851 write(output_unit, *)
852 if (is_dir) then
853 write(output_unit, '(a)') YELLOW // "WARNING: You are about to delete a directory!" // RESET
854 write(output_unit, '(a)') "Directory: " // trim(filename)
855 else
856 write(output_unit, '(a)') "File: " // trim(filename)
857 end if
858 write(output_unit, '(a)') "Path: " // trim(full_path)
859 write(output_unit, *)
860 write(output_unit, '(a)', advance='no') RED // "Are you sure? (y/N): " // RESET
861
862 ! Read single character immediately (no need to wait for Enter)
863 read(*, '(a1)', advance='no', iostat=ios) response
864
865 if (ios == 0 .and. (response == 'y' .or. response == 'Y')) then
866 ! User confirmed - proceed with deletion
867 if (is_dir) then
868 ! Delete directory recursively
869 rm_cmd = "rm -rf '" // trim(full_path) // "'"
870 else
871 ! Delete file
872 rm_cmd = "rm -f '" // trim(full_path) // "'"
873 end if
874
875 call execute_command_line(trim(rm_cmd), exitstat=stat, wait=.true.)
876
877 ! Show result
878 write(output_unit, *)
879 write(output_unit, *)
880 if (stat == 0) then
881 write(output_unit, '(a)') GREEN // "✓ Deleted successfully!" // RESET
882 else
883 write(output_unit, '(a)') RED // "✗ Delete failed" // RESET
884 end if
885 write(output_unit, *)
886 write(output_unit, '(a)') "Press any key to continue..."
887
888 ! Wait for keypress
889 read(*, '(a1)', advance='no', iostat=ios) response
890 else
891 ! User cancelled
892 write(output_unit, *)
893 write(output_unit, *)
894 write(output_unit, '(a)') "Delete cancelled."
895 write(output_unit, *)
896 write(output_unit, '(a)') "Press any key to continue..."
897
898 ! Wait for keypress
899 read(*, '(a1)', advance='no', iostat=ios) response
900 end if
901 end subroutine delete_with_confirmation
902
903 subroutine clear_all_selections(is_selected, selection_count, in_selection_mode)
904 logical, dimension(*), intent(inout) :: is_selected
905 integer, intent(inout) :: selection_count
906 logical, intent(inout) :: in_selection_mode
907 integer :: i
908
909 ! Clear all selections
910 do i = 1, MAX_FILES
911 is_selected(i) = .false.
912 end do
913 selection_count = 0
914 in_selection_mode = .false.
915 end subroutine clear_all_selections
916
917 subroutine check_disjoint_selection(is_selected, count, has_disjoint)
918 logical, dimension(*), intent(in) :: is_selected
919 integer, intent(in) :: count
920 logical, intent(out) :: has_disjoint
921 integer :: i, first_selected, last_selected
922
923 has_disjoint = .false.
924 first_selected = -1
925 last_selected = -1
926
927 ! Find first and last selected items
928 do i = 1, count
929 if (is_selected(i)) then
930 if (first_selected == -1) first_selected = i
931 last_selected = i
932 end if
933 end do
934
935 ! If we have a range, check if all items in range are selected
936 if (first_selected > 0 .and. last_selected > first_selected) then
937 do i = first_selected + 1, last_selected - 1
938 if (.not. is_selected(i)) then
939 has_disjoint = .true.
940 exit
941 end if
942 end do
943 end if
944 end subroutine check_disjoint_selection
945
946 subroutine select_range(is_selected, selection_count, anchor, cursor, files, count)
947 logical, dimension(*), intent(inout) :: is_selected
948 integer, intent(inout) :: selection_count
949 integer, intent(in) :: anchor, cursor, count
950 character(len=*), dimension(*), intent(in) :: files
951 integer :: i, range_start, range_end
952
953 ! Determine range boundaries
954 range_start = min(anchor, cursor)
955 range_end = max(anchor, cursor)
956
957 ! Clear all selections first
958 do i = 1, count
959 is_selected(i) = .false.
960 end do
961
962 ! Select the range, skipping "." and ".."
963 selection_count = 0
964 do i = range_start, range_end
965 if (i >= 1 .and. i <= count) then
966 ! Skip special directories "." and ".."
967 if (trim(files(i)) /= "." .and. trim(files(i)) /= "..") then
968 is_selected(i) = .true.
969 selection_count = selection_count + 1
970 end if
971 end if
972 end do
973 end subroutine select_range
974
975 subroutine delete_multi_with_confirmation(dir, files, is_dir, is_selected, selection_count, count)
976 use iso_fortran_env, only: output_unit
977 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD, YELLOW
978 character(len=*), intent(in) :: dir
979 character(len=*), dimension(*), intent(in) :: files
980 logical, dimension(*), intent(in) :: is_dir, is_selected
981 integer, intent(in) :: selection_count, count
982 character(len=MAX_PATH*2) :: full_path, rm_cmd
983 character(len=1) :: response
984 integer :: stat, ios, i, deleted_count
985
986 ! Clear screen and show confirmation prompt
987 write(output_unit, '(a)', advance='no') CLEAR
988 write(output_unit, '(a)') BOLD // "Delete Multiple Items" // RESET
989 write(output_unit, *)
990 write(output_unit, '(a)') YELLOW // "WARNING: You are about to delete " // &
991 trim(adjustl(itoa(selection_count))) // " items!" // RESET
992 write(output_unit, *)
993 write(output_unit, '(a)') "Selected items:"
994
995 ! List selected items (up to 10)
996 i = 0
997 do stat = 1, count
998 if (is_selected(stat)) then
999 i = i + 1
1000 if (i <= 10) then
1001 if (is_dir(stat)) then
1002 write(output_unit, '(a)') " [DIR] " // trim(files(stat))
1003 else
1004 write(output_unit, '(a)') " [FILE] " // trim(files(stat))
1005 end if
1006 else if (i == 11) then
1007 write(output_unit, '(a)') " ... and " // &
1008 trim(adjustl(itoa(selection_count - 10))) // " more"
1009 exit
1010 end if
1011 end if
1012 end do
1013
1014 write(output_unit, *)
1015 write(output_unit, '(a)', advance='no') RED // "Delete all selected items? (y/N): " // RESET
1016
1017 ! Read single character immediately
1018 read(*, '(a1)', advance='no', iostat=ios) response
1019
1020 if (ios == 0 .and. (response == 'y' .or. response == 'Y')) then
1021 ! User confirmed - proceed with deletion
1022 deleted_count = 0
1023
1024 do i = 1, count
1025 if (is_selected(i) .and. trim(files(i)) /= "." .and. trim(files(i)) /= "..") then
1026 full_path = join_path(dir, files(i))
1027
1028 if (is_dir(i)) then
1029 rm_cmd = "rm -rf '" // trim(full_path) // "'"
1030 else
1031 rm_cmd = "rm -f '" // trim(full_path) // "'"
1032 end if
1033
1034 call execute_command_line(trim(rm_cmd), exitstat=stat, wait=.true.)
1035 if (stat == 0) deleted_count = deleted_count + 1
1036 end if
1037 end do
1038
1039 ! Show result
1040 write(output_unit, *)
1041 write(output_unit, *)
1042 if (deleted_count == selection_count) then
1043 write(output_unit, '(a)') GREEN // "✓ All items deleted successfully!" // RESET
1044 else if (deleted_count > 0) then
1045 write(output_unit, '(a)') YELLOW // "⚠ Deleted " // &
1046 trim(adjustl(itoa(deleted_count))) // " of " // &
1047 trim(adjustl(itoa(selection_count))) // " items" // RESET
1048 else
1049 write(output_unit, '(a)') RED // "✗ Delete failed" // RESET
1050 end if
1051 write(output_unit, *)
1052 write(output_unit, '(a)') "Press any key to continue..."
1053
1054 ! Wait for keypress
1055 read(*, '(a1)', advance='no', iostat=ios) response
1056 else
1057 ! User cancelled
1058 write(output_unit, *)
1059 write(output_unit, *)
1060 write(output_unit, '(a)') "Delete cancelled."
1061 write(output_unit, *)
1062 write(output_unit, '(a)') "Press any key to continue..."
1063
1064 ! Wait for keypress
1065 read(*, '(a1)', advance='no', iostat=ios) response
1066 end if
1067 end subroutine delete_multi_with_confirmation
1068
1069 function itoa(n) result(str)
1070 integer, intent(in) :: n
1071 character(len=10) :: str
1072 write(str, '(i0)') n
1073 end function itoa
1074
1075 subroutine execute_multi_paste(paths, names, count, is_cut, dest_dir, dest_name, dest_is_dir)
1076 use iso_fortran_env, only: output_unit
1077 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD, YELLOW
1078 character(len=*), dimension(*), intent(in) :: paths, names
1079 integer, intent(in) :: count
1080 logical, intent(in) :: is_cut, dest_is_dir
1081 character(len=*), intent(in) :: dest_dir, dest_name
1082 character(len=MAX_PATH*2) :: dest_path, cmd, final_dest
1083 integer :: i, stat, success_count
1084 character(len=1) :: response
1085
1086 ! Determine destination directory
1087 if (dest_is_dir) then
1088 if (trim(dest_name) == ".") then
1089 dest_path = dest_dir
1090 else if (trim(dest_name) == "..") then
1091 dest_path = get_parent_path(dest_dir)
1092 else
1093 dest_path = join_path(dest_dir, dest_name)
1094 end if
1095 else
1096 dest_path = dest_dir
1097 end if
1098
1099 ! Show operation preview
1100 write(output_unit, '(a)', advance='no') CLEAR
1101 if (is_cut) then
1102 write(output_unit, '(a)') BOLD // "Multi-Cut Operation" // RESET
1103 else
1104 write(output_unit, '(a)') BOLD // "Multi-Copy Operation" // RESET
1105 end if
1106 write(output_unit, *)
1107 write(output_unit, '(a)') "Pasting " // trim(adjustl(itoa(count))) // " items to:"
1108 write(output_unit, '(a)') " " // trim(dest_path)
1109 write(output_unit, *)
1110
1111 success_count = 0
1112
1113 ! Process each item
1114 do i = 1, count
1115 ! Generate unique destination name if needed
1116 call get_unique_dest_name(dest_path, names(i), final_dest)
1117
1118 ! Execute operation
1119 if (is_cut) then
1120 cmd = "mv '" // trim(paths(i)) // "' '" // trim(final_dest) // "'"
1121 else
1122 cmd = "cp -r '" // trim(paths(i)) // "' '" // trim(final_dest) // "'"
1123 end if
1124
1125 call execute_command_line(trim(cmd), exitstat=stat, wait=.true.)
1126
1127 if (stat == 0) then
1128 success_count = success_count + 1
1129 write(output_unit, '(a)') GREEN // " ✓ " // RESET // trim(names(i))
1130 else
1131 write(output_unit, '(a)') RED // " ✗ " // RESET // trim(names(i))
1132 end if
1133 end do
1134
1135 ! Show summary
1136 write(output_unit, *)
1137 if (success_count == count) then
1138 write(output_unit, '(a)') GREEN // "✓ All items processed successfully!" // RESET
1139 else if (success_count > 0) then
1140 write(output_unit, '(a)') YELLOW // "⚠ Processed " // &
1141 trim(adjustl(itoa(success_count))) // " of " // &
1142 trim(adjustl(itoa(count))) // " items" // RESET
1143 else
1144 write(output_unit, '(a)') RED // "✗ Operation failed" // RESET
1145 end if
1146 write(output_unit, *)
1147 write(output_unit, '(a)') "Press any key to continue..."
1148
1149 ! Wait for keypress
1150 read(*, '(a1)', advance='no', iostat=stat) response
1151 end subroutine execute_multi_paste
1152
1153 subroutine get_unique_dest_name(dest_dir, base_name, unique_name)
1154 character(len=*), intent(in) :: dest_dir, base_name
1155 character(len=*), intent(out) :: unique_name
1156 character(len=MAX_PATH*2) :: test_path
1157 character(len=256) :: name_part, extension
1158 character(len=10) :: suffix_str
1159 integer :: ext_pos, suffix_num, stat
1160
1161 ! Initial destination
1162 unique_name = join_path(dest_dir, base_name)
1163
1164 ! Check if exists
1165 call execute_command_line("test -e '" // trim(unique_name) // "'", exitstat=stat, wait=.true.)
1166 if (stat /= 0) return ! Doesn't exist, we're good
1167
1168 ! Split name and extension
1169 ext_pos = index(base_name, ".", back=.true.)
1170 if (ext_pos > 1) then
1171 name_part = base_name(1:ext_pos-1)
1172 extension = base_name(ext_pos:)
1173 else
1174 name_part = base_name
1175 extension = ""
1176 end if
1177
1178 ! Find available suffix
1179 do suffix_num = 1, 999
1180 write(suffix_str, '(i0)') suffix_num
1181 if (len_trim(extension) > 0) then
1182 test_path = trim(dest_dir) // "/" // trim(name_part) // "-" // &
1183 trim(suffix_str) // trim(extension)
1184 else
1185 test_path = trim(dest_dir) // "/" // trim(name_part) // "-" // &
1186 trim(suffix_str)
1187 end if
1188
1189 call execute_command_line("test -e '" // trim(test_path) // "'", exitstat=stat, wait=.true.)
1190 if (stat /= 0) then
1191 unique_name = test_path
1192 exit
1193 end if
1194 end do
1195 end subroutine get_unique_dest_name
1196
1197 subroutine load_favorites(favs, count)
1198 character(len=MAX_PATH), dimension(10), intent(out) :: favs
1199 integer, intent(out) :: count
1200 character(len=MAX_PATH) :: favorites_file
1201 integer :: unit, ios
1202
1203 count = 0
1204 call get_environment_variable("HOME", favorites_file)
1205 favorites_file = trim(favorites_file) // "/.fortress_favorites"
1206
1207 open(newunit=unit, file=favorites_file, status='old', action='read', iostat=ios)
1208 if (ios /= 0) return ! File doesn't exist yet
1209
1210 do while (count < 10)
1211 read(unit, '(a)', iostat=ios) favs(count + 1)
1212 if (ios /= 0) exit
1213 if (len_trim(favs(count + 1)) > 0) count = count + 1
1214 end do
1215
1216 close(unit)
1217 end subroutine load_favorites
1218
1219 subroutine save_favorites(favs, count)
1220 character(len=MAX_PATH), dimension(10), intent(in) :: favs
1221 integer, intent(in) :: count
1222 character(len=MAX_PATH) :: favorites_file
1223 integer :: unit, ios, i
1224
1225 call get_environment_variable("HOME", favorites_file)
1226 favorites_file = trim(favorites_file) // "/.fortress_favorites"
1227
1228 open(newunit=unit, file=favorites_file, status='replace', action='write', iostat=ios)
1229 if (ios /= 0) return
1230
1231 do i = 1, count
1232 write(unit, '(a)') trim(favs(i))
1233 end do
1234
1235 close(unit)
1236 end subroutine save_favorites
1237
1238 function is_dir_favorited(dir_path, favs, count) result(is_fav)
1239 character(len=*), intent(in) :: dir_path
1240 character(len=MAX_PATH), dimension(10), intent(in) :: favs
1241 integer, intent(in) :: count
1242 logical :: is_fav
1243 integer :: i
1244
1245 is_fav = .false.
1246 do i = 1, count
1247 if (trim(favs(i)) == trim(dir_path)) then
1248 is_fav = .true.
1249 return
1250 end if
1251 end do
1252 end function is_dir_favorited
1253
1254 subroutine toggle_favorite(dir_path, favs, count)
1255 character(len=*), intent(in) :: dir_path
1256 character(len=MAX_PATH), dimension(10), intent(inout) :: favs
1257 integer, intent(inout) :: count
1258 integer :: i, j
1259 logical :: found
1260
1261 ! Check if already favorited
1262 found = .false.
1263 do i = 1, count
1264 if (trim(favs(i)) == trim(dir_path)) then
1265 ! Found - remove it
1266 found = .true.
1267 ! Shift remaining favorites down
1268 do j = i, count - 1
1269 favs(j) = favs(j + 1)
1270 end do
1271 favs(count) = ""
1272 count = count - 1
1273 exit
1274 end if
1275 end do
1276
1277 if (.not. found) then
1278 ! Not found - add it (if we have space)
1279 if (count < 10) then
1280 count = count + 1
1281 favs(count) = dir_path
1282 end if
1283 end if
1284 end subroutine toggle_favorite
1285
1286 subroutine add_favorite_with_replacement(dir_path, favs, count)
1287 use iso_fortran_env, only: output_unit
1288 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD
1289 character(len=*), intent(in) :: dir_path
1290 character(len=MAX_PATH), dimension(10), intent(inout) :: favs
1291 integer, intent(in) :: count
1292 character(len=MAX_PATH) :: temp_file, selected_fav
1293 integer :: unit, ios, stat, i
1294
1295 ! Clear screen and show prompt
1296 write(output_unit, '(a)', advance='no') CLEAR
1297 write(output_unit, '(a)') BOLD // "Favorites Full (10/10)" // RESET
1298 write(output_unit, *)
1299 write(output_unit, '(a)') "Select a favorite to replace:"
1300 write(output_unit, *)
1301
1302 ! Restore terminal for fzf
1303 call execute_command_line("stty sane 2>/dev/null")
1304
1305 ! Create temp file with current favorites
1306 call get_environment_variable("HOME", temp_file)
1307 temp_file = trim(temp_file) // "/.fortress_fav_temp"
1308
1309 open(newunit=unit, file=temp_file, status='replace', action='write', iostat=ios)
1310 if (ios == 0) then
1311 do i = 1, count
1312 write(unit, '(a)') trim(favs(i))
1313 end do
1314 close(unit)
1315 end if
1316
1317 ! Use fzf to select which favorite to replace
1318 call get_environment_variable("HOME", temp_file)
1319 temp_file = trim(temp_file) // "/.fortress_fav_select"
1320 call execute_command_line("cat ~/.fortress_fav_temp | fzf --height=10 --prompt='Replace: ' > " // &
1321 trim(temp_file) // " 2>/dev/null", exitstat=stat, wait=.true.)
1322
1323 if (stat == 0) then
1324 ! Read selected favorite
1325 open(newunit=unit, file=temp_file, status='old', action='read', iostat=ios)
1326 if (ios == 0) then
1327 read(unit, '(a)', iostat=ios) selected_fav
1328 close(unit)
1329
1330 if (ios == 0 .and. len_trim(selected_fav) > 0) then
1331 ! Replace the selected favorite with the new one
1332 do i = 1, count
1333 if (trim(favs(i)) == trim(selected_fav)) then
1334 favs(i) = dir_path
1335 write(output_unit, '(a)') GREEN // "✓ Replaced: " // trim(selected_fav) // RESET
1336 write(output_unit, '(a)') " With: " // trim(dir_path)
1337 call execute_command_line("sleep 1")
1338 exit
1339 end if
1340 end do
1341 end if
1342 end if
1343 else
1344 write(output_unit, '(a)') RED // "Cancelled." // RESET
1345 call execute_command_line("sleep 1")
1346 end if
1347
1348 ! Cleanup temp files
1349 call execute_command_line("rm -f ~/.fortress_fav_temp ~/.fortress_fav_select 2>/dev/null")
1350
1351 ! Re-enable raw mode
1352 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null", wait=.true.)
1353 end subroutine add_favorite_with_replacement
1354
1355 subroutine mark_favorites_in_lists(current_dir, files, count, is_dir, favs, fav_count, is_fav)
1356 character(len=*), intent(in) :: current_dir
1357 character(len=*), dimension(*), intent(in) :: files
1358 integer, intent(in) :: count
1359 logical, dimension(*), intent(in) :: is_dir
1360 character(len=MAX_PATH), dimension(10), intent(in) :: favs
1361 integer, intent(in) :: fav_count
1362 logical, dimension(*), intent(out) :: is_fav
1363 character(len=MAX_PATH) :: full_path
1364 integer :: i
1365
1366 ! Initialize all to false
1367 do i = 1, count
1368 is_fav(i) = .false.
1369 end do
1370
1371 ! Mark directories that are in favorites
1372 do i = 1, count
1373 if (is_dir(i) .and. trim(files(i)) /= "." .and. trim(files(i)) /= "..") then
1374 ! Build full path and check if favorited
1375 full_path = join_path(current_dir, files(i))
1376 is_fav(i) = is_dir_favorited(full_path, favs, fav_count)
1377 end if
1378 end do
1379 end subroutine mark_favorites_in_lists
1380
1381 subroutine open_favorites_picker(favs, count, selected_dir)
1382 use iso_fortran_env, only: output_unit
1383 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD
1384 character(len=MAX_PATH), dimension(10), intent(in) :: favs
1385 integer, intent(in) :: count
1386 character(len=MAX_PATH), intent(out) :: selected_dir
1387 character(len=MAX_PATH) :: temp_file
1388 integer :: unit, ios, stat, i
1389
1390 selected_dir = ""
1391
1392 if (count == 0) then
1393 ! No favorites yet
1394 write(output_unit, '(a)', advance='no') CLEAR
1395 write(output_unit, '(a)') RED // "No favorites yet!" // RESET
1396 write(output_unit, *)
1397 write(output_unit, '(a)') "Press '*' on a directory to add it to favorites."
1398 call execute_command_line("sleep 2")
1399 return
1400 end if
1401
1402 ! Restore terminal for fzf
1403 call execute_command_line("stty sane 2>/dev/null")
1404
1405 ! Create temp file with favorites
1406 call get_environment_variable("HOME", temp_file)
1407 temp_file = trim(temp_file) // "/.fortress_fav_picker"
1408
1409 open(newunit=unit, file=temp_file, status='replace', action='write', iostat=ios)
1410 if (ios == 0) then
1411 do i = 1, count
1412 write(unit, '(a)') trim(favs(i))
1413 end do
1414 close(unit)
1415 end if
1416
1417 ! Use fzf to select favorite
1418 call get_environment_variable("HOME", temp_file)
1419 temp_file = trim(temp_file) // "/.fortress_fav_selected"
1420 call execute_command_line("cat ~/.fortress_fav_picker | fzf --height=10 --prompt='Jump to: ' > " // &
1421 trim(temp_file) // " 2>/dev/null", exitstat=stat, wait=.true.)
1422
1423 if (stat == 0) then
1424 ! Read selected directory
1425 open(newunit=unit, file=temp_file, status='old', action='read', iostat=ios)
1426 if (ios == 0) then
1427 read(unit, '(a)', iostat=ios) selected_dir
1428 close(unit)
1429 end if
1430 end if
1431
1432 ! Cleanup temp files
1433 call execute_command_line("rm -f ~/.fortress_fav_picker ~/.fortress_fav_selected 2>/dev/null")
1434
1435 ! Re-enable raw mode
1436 call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null", wait=.true.)
1437 end subroutine open_favorites_picker
1438
1439 end program fortress
1440