Fortran · 81425 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 ! Mode state (normal or git)
25 character(len=10) :: mode = 'normal'
26
27 ! Fuzzy search state
28 character(len=32) :: search_buffer = ''
29 integer :: search_length = 0
30 integer(8) :: last_search_tick = 0
31 integer(8) :: clock_rate, current_tick
32
33 ! Move mode state
34 logical :: move_mode = .false.
35 character(len=MAX_PATH) :: move_source_path
36 character(len=MAX_PATH) :: move_source_name
37 integer :: move_dest_selected = 1
38
39 ! Clipboard state
40 logical :: has_clipboard = .false.
41 logical :: clipboard_is_cut = .false. ! true = cut, false = copy
42 character(len=MAX_PATH) :: clipboard_source_path
43 character(len=MAX_PATH) :: clipboard_source_name
44
45 ! Multi-select state
46 logical, dimension(MAX_FILES) :: is_selected
47 integer :: selection_count = 0
48 logical :: in_selection_mode = .false.
49 integer :: selection_anchor = -1 ! For shift+arrow block selection
50 logical :: has_disjoint_selection = .false. ! True if non-contiguous items selected
51
52 ! Multi-clipboard for batch operations
53 character(len=MAX_PATH), dimension(MAX_FILES) :: clipboard_paths
54 character(len=MAX_PATH), dimension(MAX_FILES) :: clipboard_names
55 integer :: clipboard_count = 0
56
57 ! Favorites/bookmarks state
58 character(len=MAX_PATH), dimension(10) :: favorite_dirs
59 integer :: favorite_count = 0
60 logical, dimension(MAX_FILES) :: current_is_favorite, parent_is_favorite
61
62 ! Rename mode state
63 logical :: in_rename_mode = .false.
64 character(len=MAX_PATH) :: rename_buffer = ''
65 integer :: rename_cursor_pos = 0
66
67 character(len=1) :: key
68 integer :: i, rows, cols, visible_height
69 logical :: is_shift_pressed, is_alt_pressed
70
71 ! Initialize
72 current_dir = get_pwd()
73 parent_dir = get_parent_path(current_dir)
74 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
75 call load_favorites(favorite_dirs, favorite_count)
76 call system_clock(count_rate=clock_rate)
77 call setup_raw_mode()
78
79 ! Main loop
80 do while (running)
81 ! Get files
82 call get_file_list(current_dir, current_files, current_is_dir, current_is_exec, current_count)
83 call get_file_list(parent_dir, parent_files, parent_is_dir, parent_is_exec, parent_count)
84
85 ! Filter dotfiles if needed
86 if (.not. show_dotfiles) then
87 call filter_dotfiles(current_files, current_is_dir, current_is_exec, current_count)
88 call filter_dotfiles(parent_files, parent_is_dir, parent_is_exec, parent_count)
89 end if
90
91 ! Initialize git arrays and selection - only for actual file counts
92 do i = 1, current_count
93 current_is_staged(i) = .false.
94 current_is_unstaged(i) = .false.
95 current_is_untracked(i) = .false.
96 current_has_incoming(i) = .false.
97 ! Keep selections if still in same directory, clear otherwise
98 if (i > MAX_FILES) then
99 is_selected(i) = .false.
100 end if
101 end do
102 do i = 1, parent_count
103 parent_is_staged(i) = .false.
104 parent_is_unstaged(i) = .false.
105 parent_is_untracked(i) = .false.
106 end do
107
108 ! Get git status if in a repo
109 if (in_git_repo) then
110 call get_git_status(current_dir, current_files, current_is_dir, current_count, &
111 current_is_staged, current_is_unstaged, current_is_untracked)
112 call mark_incoming_changes(current_dir, current_files, current_count, current_has_incoming)
113 end if
114
115 ! Mark favorited directories
116 call mark_favorites_in_lists(current_dir, current_files, current_count, current_is_dir, &
117 favorite_dirs, favorite_count, current_is_favorite)
118 call mark_favorites_in_lists(parent_dir, parent_files, parent_count, parent_is_dir, &
119 favorite_dirs, favorite_count, parent_is_favorite)
120
121 ! Get terminal size
122 call get_term_size(rows, cols)
123 visible_height = rows - 2 ! Account for header + footer
124
125 ! Handle navigation signals from previous iteration
126 if (selected == -1) then
127 selected = find_in_parent(temp_dir, current_files, current_count)
128 scroll_offset = max(0, selected - visible_height / 2)
129 else if (selected == -2) then
130 selected = find_file_in_list(temp_dir, current_files, current_count)
131 scroll_offset = max(0, selected - visible_height / 2)
132 end if
133
134 ! Handle move mode destination cursor
135 if (move_mode .and. move_dest_selected == -1) then
136 move_dest_selected = find_in_parent(temp_dir, current_files, current_count)
137 end if
138
139 ! Bounds check
140 if (current_count > 0) then
141 selected = max(1, min(selected, current_count))
142 if (move_mode) then
143 move_dest_selected = max(1, min(move_dest_selected, current_count))
144 end if
145 else
146 selected = 1
147 if (move_mode) move_dest_selected = 1
148 end if
149
150 ! Find current dir in parent
151 parent_selected = find_in_parent(current_dir, parent_files, parent_count)
152
153 ! Adjust scroll to keep cursor visible
154 if (move_mode) then
155 ! In move mode, track the destination cursor
156 if (move_dest_selected < scroll_offset + 1) scroll_offset = max(0, move_dest_selected - 1)
157 if (move_dest_selected > scroll_offset + visible_height) scroll_offset = move_dest_selected - visible_height
158 scroll_offset = max(0, min(scroll_offset, max(0, current_count - visible_height)))
159 else
160 ! Normal mode, track the selection cursor
161 if (selected < scroll_offset + 1) scroll_offset = max(0, selected - 1)
162 if (selected > scroll_offset + visible_height) scroll_offset = selected - visible_height
163 scroll_offset = max(0, min(scroll_offset, max(0, current_count - visible_height)))
164 end if
165
166 if (parent_selected > 0) then
167 if (parent_selected < parent_scroll_offset + 1) parent_scroll_offset = max(0, parent_selected - 1)
168 if (parent_selected > parent_scroll_offset + visible_height) parent_scroll_offset = parent_selected - visible_height
169 parent_scroll_offset = max(0, min(parent_scroll_offset, max(0, parent_count - visible_height)))
170 end if
171
172 ! Draw
173 write(output_unit, '(a)', advance='no') CLEAR
174 call draw_interface(rows, cols, current_dir, current_files, current_is_dir, current_is_exec, &
175 current_is_staged, current_is_unstaged, current_is_untracked, current_has_incoming, &
176 current_count, parent_files, parent_is_dir, parent_is_exec, parent_count, &
177 selected, parent_selected, scroll_offset, parent_scroll_offset, &
178 in_git_repo, repo_name, branch_name, mode, &
179 move_mode, move_source_name, move_dest_selected, &
180 has_clipboard, clipboard_is_cut, clipboard_source_name, clipboard_count, &
181 is_selected, selection_count, &
182 current_is_favorite, parent_is_favorite, &
183 search_buffer, search_length, &
184 in_rename_mode, rename_buffer, rename_cursor_pos)
185
186 ! Get input (with error handling for End-of-record after Enter key)
187 read(*, '(a1)', advance='no', iostat=i) key
188
189 ! Rename mode key handling - intercept ALL keys when in rename mode (BEFORE error handling)
190 if (in_rename_mode) then
191 ! In rename mode, handle read errors differently
192 if (i < 0) then
193 ! End-of-record in rename mode - treat as Enter
194 key = achar(13)
195 else if (i > 0) then
196 ! Read error - ignore and continue
197 cycle
198 end if
199
200 ! Handle ESC to cancel rename
201 if (ichar(key) == 27) then
202 in_rename_mode = .false.
203 rename_buffer = ''
204 rename_cursor_pos = 0
205 cycle
206 ! Handle Enter to confirm rename
207 else if (ichar(key) == 10 .or. ichar(key) == 13) then
208 ! Execute rename if name changed
209 if (len_trim(rename_buffer) > 0 .and. trim(rename_buffer) /= trim(current_files(selected))) then
210 block
211 character(len=MAX_PATH) :: old_path, new_path, old_name
212 character(len=MAX_PATH*2) :: mv_cmd
213 integer :: stat
214
215 ! Store the old name for cursor tracking
216 old_name = current_files(selected)
217 old_path = join_path(current_dir, old_name)
218 new_path = join_path(current_dir, trim(rename_buffer))
219
220 ! Use -f flag for case-only renames, double quotes for paths
221 mv_cmd = 'mv -f "' // trim(old_path) // '" "' // trim(new_path) // '"'
222 call execute_command_line(trim(mv_cmd), exitstat=stat, wait=.true.)
223
224 ! After rename succeeds, find the renamed file in the refreshed list
225 if (stat == 0) then
226 temp_dir = new_path
227 selected = -2 ! Signal to find this file in the next iteration
228 end if
229 end block
230 end if
231 in_rename_mode = .false.
232 rename_buffer = ''
233 rename_cursor_pos = 0
234 cycle
235 ! Handle Backspace
236 else if (ichar(key) == 127 .or. ichar(key) == 8) then
237 if (rename_cursor_pos > 0) then
238 if (rename_cursor_pos == len_trim(rename_buffer)) then
239 ! Cursor at end - simple delete
240 rename_buffer = rename_buffer(1:len_trim(rename_buffer)-1)
241 else
242 ! Cursor in middle - delete and shift left
243 rename_buffer = rename_buffer(1:rename_cursor_pos-1) // &
244 rename_buffer(rename_cursor_pos+1:len_trim(rename_buffer))
245 end if
246 rename_cursor_pos = rename_cursor_pos - 1
247 end if
248 cycle
249 ! Handle printable characters - insert at cursor position
250 else if ((ichar(key) >= ichar('a') .and. ichar(key) <= ichar('z')) .or. &
251 (ichar(key) >= ichar('A') .and. ichar(key) <= ichar('Z')) .or. &
252 (ichar(key) >= ichar('0') .and. ichar(key) <= ichar('9')) .or. &
253 key == '_' .or. key == '-' .or. key == '.' .or. key == ' ') then
254 if (len_trim(rename_buffer) < MAX_PATH - 1) then
255 if (rename_cursor_pos == len_trim(rename_buffer)) then
256 ! Cursor at end - simple append
257 rename_buffer = trim(rename_buffer) // key
258 else
259 ! Cursor in middle - insert and shift right
260 rename_buffer = rename_buffer(1:rename_cursor_pos) // key // &
261 rename_buffer(rename_cursor_pos+1:len_trim(rename_buffer))
262 end if
263 rename_cursor_pos = rename_cursor_pos + 1
264 end if
265 cycle
266 end if
267 ! Ignore all other keys in rename mode
268 cycle
269 end if
270
271 ! Handle read errors for normal mode
272 if (i < 0) cycle ! End-of-record - skip and try again
273 if (i > 0) cycle ! Other read errors - skip and try again
274
275 ! Fuzzy search: handle printable characters (only in normal mode, no special modes active)
276 if (trim(mode) == 'normal' .and. .not. move_mode .and. selection_count == 0) then
277 ! Check if character is printable (letters, digits, dash, underscore, dot)
278 if ((ichar(key) >= ichar('a') .and. ichar(key) <= ichar('z')) .or. &
279 (ichar(key) >= ichar('A') .and. ichar(key) <= ichar('Z')) .or. &
280 (ichar(key) >= ichar('0') .and. ichar(key) <= ichar('9')) .or. &
281 key == '-' .or. key == '_' .or. key == '.') then
282
283 ! Check timeout (0.5 seconds = clock_rate / 2)
284 if (search_length > 0) then
285 call system_clock(current_tick)
286 if (current_tick - last_search_tick > clock_rate / 2) then
287 ! Timeout - clear buffer and start fresh
288 search_length = 0
289 search_buffer = ''
290 end if
291 end if
292
293 ! Add to buffer
294 if (search_length < 32) then
295 search_length = search_length + 1
296 search_buffer(search_length:search_length) = key
297 call system_clock(last_search_tick)
298
299 ! Perform fuzzy jump
300 call fuzzy_jump(current_files, current_count, search_buffer(1:search_length), selected)
301 end if
302 cycle ! Skip normal key processing
303 else if (ichar(key) == 127 .or. ichar(key) == 8) then
304 ! Backspace - remove last character from search
305 if (search_length > 0) then
306 search_length = search_length - 1
307 call system_clock(last_search_tick)
308 if (search_length > 0) then
309 call fuzzy_jump(current_files, current_count, search_buffer(1:search_length), selected)
310 end if
311 end if
312 cycle
313 end if
314 end if
315
316 ! Handle input
317 select case(ichar(key))
318 case(27) ! ESC - arrow keys, Shift+arrow keys, or Alt+key
319 ! Clear search buffer if active (but still process the arrow key)
320 if (search_length > 0) then
321 search_length = 0
322 search_buffer = ''
323 end if
324
325 call read_key_with_modifiers(key, is_shift_pressed, is_alt_pressed)
326
327 ! Handle Alt+key combinations
328 if (is_alt_pressed) then
329 ! Process Alt keys here (key is now encoded as achar(1-26))
330 select case(ichar(key))
331 case(7) ! Alt+g - toggle git mode
332 if (in_git_repo) then
333 if (mode == 'normal') then
334 mode = 'git'
335 else
336 mode = 'normal'
337 end if
338 end if
339 case(14) ! Alt+n - enter rename mode
340 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
341 in_rename_mode = .true.
342 rename_buffer = current_files(selected)
343 rename_cursor_pos = len_trim(rename_buffer)
344 end if
345 case(22) ! Alt+v - view file
346 if (.not. current_is_dir(selected)) then
347 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
348 call open_file_in_default_app(join_path(current_dir, current_files(selected)))
349 end if
350 end if
351 case(19) ! Alt+s - fzf search
352 call fzf_search(current_dir, temp_dir)
353 if (len_trim(temp_dir) > 0) then
354 parent_dir = get_parent_path(temp_dir)
355 current_dir = parent_dir
356 parent_dir = get_parent_path(current_dir)
357 selected = -2
358 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
359 end if
360 case(3) ! Alt+c - cd on exit
361 if (current_is_dir(selected)) then
362 if (trim(current_files(selected)) == "..") then
363 exit_dir = parent_dir
364 else if (trim(current_files(selected)) == ".") then
365 exit_dir = current_dir
366 else
367 exit_dir = join_path(current_dir, current_files(selected))
368 end if
369 cd_on_exit = .true.
370 running = .false.
371 end if
372 case(18) ! Alt+r - delete
373 if (selection_count > 0) then
374 call delete_multi_with_confirmation(current_dir, current_files, current_is_dir, &
375 is_selected, selection_count, current_count)
376 call clear_all_selections(is_selected, selection_count, in_selection_mode)
377 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
378 call delete_with_confirmation(current_dir, current_files(selected), current_is_dir(selected))
379 end if
380 case(13) ! Alt+m - move mode
381 if (move_mode) then
382 call execute_move_file(move_source_path, current_dir, current_files(move_dest_selected), &
383 current_is_dir(move_dest_selected))
384 move_mode = .false.
385 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
386 move_source_path = join_path(current_dir, current_files(selected))
387 move_source_name = current_files(selected)
388 move_mode = .true.
389 move_dest_selected = find_first_directory(current_files, current_is_dir, current_count)
390 end if
391 case(25) ! Alt+y - copy
392 if (selection_count > 0) then
393 clipboard_count = 0
394 do i = 1, current_count
395 if (is_selected(i) .and. trim(current_files(i)) /= "." .and. &
396 trim(current_files(i)) /= "..") then
397 clipboard_count = clipboard_count + 1
398 clipboard_paths(clipboard_count) = join_path(current_dir, current_files(i))
399 clipboard_names(clipboard_count) = current_files(i)
400 end if
401 end do
402 clipboard_is_cut = .false.
403 has_clipboard = .true.
404 call clear_all_selections(is_selected, selection_count, in_selection_mode)
405 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
406 clipboard_count = 1
407 clipboard_paths(1) = join_path(current_dir, current_files(selected))
408 clipboard_names(1) = current_files(selected)
409 clipboard_source_path = clipboard_paths(1)
410 clipboard_source_name = clipboard_names(1)
411 clipboard_is_cut = .false.
412 has_clipboard = .true.
413 end if
414 case(24) ! Alt+x - cut
415 if (selection_count > 0) then
416 clipboard_count = 0
417 do i = 1, current_count
418 if (is_selected(i) .and. trim(current_files(i)) /= "." .and. &
419 trim(current_files(i)) /= "..") then
420 clipboard_count = clipboard_count + 1
421 clipboard_paths(clipboard_count) = join_path(current_dir, current_files(i))
422 clipboard_names(clipboard_count) = current_files(i)
423 end if
424 end do
425 clipboard_is_cut = .true.
426 has_clipboard = .true.
427 call clear_all_selections(is_selected, selection_count, in_selection_mode)
428 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
429 clipboard_count = 1
430 clipboard_paths(1) = join_path(current_dir, current_files(selected))
431 clipboard_names(1) = current_files(selected)
432 clipboard_source_path = clipboard_paths(1)
433 clipboard_source_name = clipboard_names(1)
434 clipboard_is_cut = .true.
435 has_clipboard = .true.
436 end if
437 case(16) ! Alt+p - paste
438 if (has_clipboard) then
439 if (clipboard_count > 1) then
440 call execute_multi_paste(clipboard_paths, clipboard_names, clipboard_count, &
441 clipboard_is_cut, current_dir, current_files(selected), &
442 current_is_dir(selected))
443 else
444 call execute_paste(clipboard_paths(1), clipboard_is_cut, current_dir, &
445 current_files(selected), current_is_dir(selected))
446 end if
447 if (clipboard_is_cut) then
448 has_clipboard = .false.
449 clipboard_count = 0
450 end if
451 end if
452 end select
453 ! Alt key processed, skip rest of case(27)
454 cycle
455 end if
456
457 ! Handle arrow keys (only if not Alt key)
458 if (.true.) then
459 ! Process arrow keys normally
460 if (move_mode) then
461 ! In move mode, navigate directories only
462 select case(key)
463 case('A') ! Up - jump to previous directory
464 move_dest_selected = find_prev_directory(current_files, current_is_dir, current_count, move_dest_selected)
465 case('B') ! Down - jump to next directory
466 move_dest_selected = find_next_directory(current_files, current_is_dir, current_count, move_dest_selected)
467 case('C') ! Right - enter directory
468 if (current_is_dir(move_dest_selected)) then
469 if (trim(current_files(move_dest_selected)) == "..") then
470 ! Don't descend into ..
471 else if (trim(current_files(move_dest_selected)) /= ".") then
472 ! Descend into directory
473 parent_dir = current_dir
474 current_dir = join_path(current_dir, current_files(move_dest_selected))
475 move_dest_selected = find_first_directory(current_files, current_is_dir, current_count)
476 scroll_offset = 0
477 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
478 end if
479 end if
480 case('D') ! Left - go to parent
481 if (current_dir /= "/") then
482 temp_dir = current_dir
483 current_dir = parent_dir
484 parent_dir = get_parent_path(current_dir)
485 move_dest_selected = -1 ! Will be set to parent dir position
486 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
487 end if
488 end select
489 else
490 ! Normal navigation
491 select case(key)
492 case('A') ! Up
493 if (is_shift_pressed .and. .not. has_disjoint_selection) then
494 ! Shift+Up: Start or extend block selection upward
495 if (selection_anchor == -1) then
496 ! Start new block selection
497 selection_anchor = selected
498 call clear_all_selections(is_selected, selection_count, in_selection_mode)
499 end if
500 if (selected > 1) selected = selected - 1
501 ! Select range from anchor to current
502 call select_range(is_selected, selection_count, selection_anchor, selected, &
503 current_files, current_count)
504 else
505 ! Normal up movement - clear anchor
506 if (selected > 1) selected = selected - 1
507 selection_anchor = -1
508 end if
509 case('B') ! Down
510 if (is_shift_pressed .and. .not. has_disjoint_selection) then
511 ! Shift+Down: Start or extend block selection downward
512 if (selection_anchor == -1) then
513 ! Start new block selection
514 selection_anchor = selected
515 call clear_all_selections(is_selected, selection_count, in_selection_mode)
516 end if
517 if (selected < current_count .and. current_count > 0) selected = selected + 1
518 ! Select range from anchor to current
519 call select_range(is_selected, selection_count, selection_anchor, selected, &
520 current_files, current_count)
521 else
522 ! Normal down movement - clear anchor
523 if (selected < current_count .and. current_count > 0) selected = selected + 1
524 selection_anchor = -1
525 end if
526 case('C') ! Right - enter directory
527 if (current_is_dir(selected)) then
528 if (trim(current_files(selected)) == "..") then
529 temp_dir = current_dir
530 current_dir = parent_dir
531 parent_dir = get_parent_path(current_dir)
532 selected = -1
533 selection_anchor = -1
534 call clear_all_selections(is_selected, selection_count, in_selection_mode)
535 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
536 else if (trim(current_files(selected)) /= ".") then
537 parent_dir = current_dir
538 current_dir = join_path(current_dir, current_files(selected))
539 selected = 1
540 scroll_offset = 0
541 selection_anchor = -1
542 call clear_all_selections(is_selected, selection_count, in_selection_mode)
543 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
544 end if
545 end if
546 case('D') ! Left - go back
547 if (current_dir /= "/") then
548 temp_dir = current_dir
549 current_dir = parent_dir
550 parent_dir = get_parent_path(current_dir)
551 selected = -1
552 selection_anchor = -1
553 call clear_all_selections(is_selected, selection_count, in_selection_mode)
554 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
555 end if
556 end select
557 end if
558 end if ! End of .not. is_alt_pressed check
559 case(7) ! Alt+g - toggle git mode
560 if (in_git_repo) then
561 if (mode == 'normal') then
562 mode = 'git'
563 else
564 mode = 'normal'
565 end if
566 end if
567 case(14) ! Alt+n - enter rename mode
568 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
569 ! Enter rename mode - pre-fill with current filename
570 in_rename_mode = .true.
571 rename_buffer = current_files(selected)
572 rename_cursor_pos = len_trim(rename_buffer)
573 end if
574 case(22) ! Alt+v - view file (always available)
575 if (.not. current_is_dir(selected)) then
576 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
577 call open_file_in_default_app(join_path(current_dir, current_files(selected)))
578 end if
579 end if
580 case(113, 81) ! 'q' or 'Q' - exit move mode
581 if (move_mode) then
582 move_mode = .false.
583 end if
584 case(17) ! Ctrl-q - quit program
585 running = .false.
586 case(3) ! Alt+c - cd to directory on exit
587 if (current_is_dir(selected)) then
588 if (trim(current_files(selected)) == "..") then
589 exit_dir = parent_dir
590 else if (trim(current_files(selected)) == ".") then
591 exit_dir = current_dir
592 else
593 exit_dir = join_path(current_dir, current_files(selected))
594 end if
595 cd_on_exit = .true.
596 running = .false.
597 end if
598 case(19) ! Alt+s - fzf search
599 call fzf_search(current_dir, temp_dir)
600 if (len_trim(temp_dir) > 0) then
601 parent_dir = get_parent_path(temp_dir)
602 current_dir = parent_dir
603 parent_dir = get_parent_path(current_dir)
604 selected = -2
605 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
606 end if
607 case(65, 97) ! 'A' or 'a' - git add (batch stage directories)
608 if (in_git_repo .and. trim(mode) == 'git') then
609 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
610 call git_add_file(current_dir, current_files(selected))
611 end if
612 end if
613 case(85, 117) ! 'U' or 'u' - git unstage (batch unstage directories)
614 if (in_git_repo .and. trim(mode) == 'git') then
615 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
616 if (current_is_staged(selected)) then
617 call git_unstage_file(current_dir, current_files(selected))
618 end if
619 end if
620 end if
621 case(77, 109) ! 'M' or 'm' - git commit
622 if (in_git_repo .and. trim(mode) == 'git') then
623 call git_commit_prompt(current_dir, repo_name)
624 end if
625 case(72, 104) ! 'H' or 'h' - git push (h for "push to remote Host")
626 if (in_git_repo .and. trim(mode) == 'git') then
627 call git_push_prompt(current_dir, repo_name)
628 end if
629 case(84, 116) ! 'T' or 't' - git tag
630 if (in_git_repo .and. trim(mode) == 'git') then
631 call git_tag_prompt(current_dir, repo_name)
632 end if
633 case(70, 102) ! 'F' or 'f' - git fetch
634 if (in_git_repo .and. trim(mode) == 'git') then
635 call git_fetch_prompt(current_dir, repo_name)
636 end if
637 case(76, 108) ! 'L' or 'l' - git pull
638 if (in_git_repo .and. trim(mode) == 'git') then
639 call git_pull_prompt(current_dir, repo_name)
640 end if
641 case(68, 100) ! 'D' or 'd' - show git diff
642 if (in_git_repo .and. trim(mode) == 'git' .and. .not. current_is_dir(selected)) then
643 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
644 if (current_is_staged(selected) .or. current_is_unstaged(selected)) then
645 call show_git_diff_fullscreen(current_dir, current_files(selected), &
646 current_is_staged(selected), current_is_unstaged(selected))
647 end if
648 end if
649 end if
650 case(18) ! Alt+r - delete/remove with confirmation
651 if (selection_count > 0) then
652 ! Delete multiple selections
653 call delete_multi_with_confirmation(current_dir, current_files, current_is_dir, &
654 is_selected, selection_count, current_count)
655 ! Clear selections after delete
656 call clear_all_selections(is_selected, selection_count, in_selection_mode)
657 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
658 call delete_with_confirmation(current_dir, current_files(selected), current_is_dir(selected))
659 end if
660 case(46) ! '.' - toggle dotfiles visibility
661 show_dotfiles = .not. show_dotfiles
662 ! Reset selection to avoid going out of bounds
663 selected = 1
664 scroll_offset = 0
665 case(126) ! '~' - go to home directory
666 call get_environment_variable("HOME", temp_dir)
667 if (len_trim(temp_dir) > 0) then
668 current_dir = temp_dir
669 parent_dir = get_parent_path(current_dir)
670 selected = 1
671 scroll_offset = 0
672 selection_anchor = -1
673 call clear_all_selections(is_selected, selection_count, in_selection_mode)
674 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
675 end if
676 case(47) ! '/' - go to root directory
677 current_dir = "/"
678 parent_dir = "/"
679 selected = 1
680 scroll_offset = 0
681 selection_anchor = -1
682 call clear_all_selections(is_selected, selection_count, in_selection_mode)
683 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
684 case(42) ! '*' - toggle favorite on current directory
685 if (current_is_dir(selected) .and. trim(current_files(selected)) /= "." .and. &
686 trim(current_files(selected)) /= "..") then
687 ! Build full path for the directory
688 temp_dir = join_path(current_dir, current_files(selected))
689
690 ! Check if already favorited
691 if (is_dir_favorited(temp_dir, favorite_dirs, favorite_count)) then
692 ! Remove from favorites
693 call toggle_favorite(temp_dir, favorite_dirs, favorite_count)
694 call save_favorites(favorite_dirs, favorite_count)
695 else
696 ! Try to add
697 if (favorite_count < 10) then
698 ! Room available - add it
699 call toggle_favorite(temp_dir, favorite_dirs, favorite_count)
700 call save_favorites(favorite_dirs, favorite_count)
701 else
702 ! Full - prompt for replacement
703 call add_favorite_with_replacement(temp_dir, favorite_dirs, favorite_count)
704 call save_favorites(favorite_dirs, favorite_count)
705 end if
706 end if
707 end if
708 case(56) ! '8' - open favorites picker
709 call open_favorites_picker(favorite_dirs, favorite_count, temp_dir)
710 if (len_trim(temp_dir) > 0) then
711 ! Navigate to selected favorite
712 parent_dir = get_parent_path(temp_dir)
713 current_dir = temp_dir
714 selected = 1
715 scroll_offset = 0
716 selection_anchor = -1
717 call clear_all_selections(is_selected, selection_count, in_selection_mode)
718 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
719 end if
720 case(32) ! Space - toggle selection on current item
721 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
722 if (is_selected(selected)) then
723 is_selected(selected) = .false.
724 selection_count = selection_count - 1
725 else
726 is_selected(selected) = .true.
727 selection_count = selection_count + 1
728 in_selection_mode = .true.
729 end if
730 ! Clear the selection anchor when toggling individual items
731 selection_anchor = -1
732 ! Check if we have disjoint selections
733 call check_disjoint_selection(is_selected, current_count, has_disjoint_selection)
734 end if
735 case(13) ! Alt+m - enter move mode OR confirm move
736 if (move_mode) then
737 ! Confirm move - execute the move to the white-highlighted directory
738 call execute_move_file(move_source_path, current_dir, current_files(move_dest_selected), &
739 current_is_dir(move_dest_selected))
740 move_mode = .false.
741 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
742 ! Enter move mode - store source file or directory
743 move_source_path = join_path(current_dir, current_files(selected))
744 move_source_name = current_files(selected)
745 move_mode = .true.
746 ! Find first directory for destination cursor
747 move_dest_selected = find_first_directory(current_files, current_is_dir, current_count)
748 end if
749 case(69, 101) ! 'E' or 'e' - exit/clear multi-select mode
750 if (selection_count > 0) then
751 call clear_all_selections(is_selected, selection_count, in_selection_mode)
752 selection_anchor = -1
753 end if
754 case(25) ! Alt+y - yank/copy to clipboard
755 if (selection_count > 0) then
756 ! Copy multiple selections
757 clipboard_count = 0
758 do i = 1, current_count
759 if (is_selected(i) .and. trim(current_files(i)) /= "." .and. &
760 trim(current_files(i)) /= "..") then
761 clipboard_count = clipboard_count + 1
762 clipboard_paths(clipboard_count) = join_path(current_dir, current_files(i))
763 clipboard_names(clipboard_count) = current_files(i)
764 end if
765 end do
766 clipboard_is_cut = .false.
767 has_clipboard = .true.
768 ! Clear selections after copy
769 call clear_all_selections(is_selected, selection_count, in_selection_mode)
770 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
771 ! Single item copy
772 clipboard_count = 1
773 clipboard_paths(1) = join_path(current_dir, current_files(selected))
774 clipboard_names(1) = current_files(selected)
775 clipboard_source_path = clipboard_paths(1) ! For backward compatibility
776 clipboard_source_name = clipboard_names(1)
777 clipboard_is_cut = .false.
778 has_clipboard = .true.
779 end if
780 case(24) ! Alt+x - cut to clipboard
781 if (selection_count > 0) then
782 ! Cut multiple selections
783 clipboard_count = 0
784 do i = 1, current_count
785 if (is_selected(i) .and. trim(current_files(i)) /= "." .and. &
786 trim(current_files(i)) /= "..") then
787 clipboard_count = clipboard_count + 1
788 clipboard_paths(clipboard_count) = join_path(current_dir, current_files(i))
789 clipboard_names(clipboard_count) = current_files(i)
790 end if
791 end do
792 clipboard_is_cut = .true.
793 has_clipboard = .true.
794 ! Clear selections after cut
795 call clear_all_selections(is_selected, selection_count, in_selection_mode)
796 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
797 ! Single item cut
798 clipboard_count = 1
799 clipboard_paths(1) = join_path(current_dir, current_files(selected))
800 clipboard_names(1) = current_files(selected)
801 clipboard_source_path = clipboard_paths(1) ! For backward compatibility
802 clipboard_source_name = clipboard_names(1)
803 clipboard_is_cut = .true.
804 has_clipboard = .true.
805 end if
806 case(16) ! Alt+p - paste from clipboard
807 if (has_clipboard) then
808 if (clipboard_count > 1) then
809 ! Paste multiple items
810 call execute_multi_paste(clipboard_paths, clipboard_names, clipboard_count, &
811 clipboard_is_cut, current_dir, current_files(selected), &
812 current_is_dir(selected))
813 else
814 ! Single item paste (backward compatibility)
815 call execute_paste(clipboard_paths(1), clipboard_is_cut, current_dir, &
816 current_files(selected), current_is_dir(selected))
817 end if
818 ! Clear clipboard after cut operation
819 if (clipboard_is_cut) then
820 has_clipboard = .false.
821 clipboard_count = 0
822 end if
823 end if
824 end select
825 end do
826
827 ! Cleanup
828 call restore_terminal()
829 write(output_unit, '(a)', advance='no') CLEAR
830
831 if (cd_on_exit) then
832 call write_exit_dir(exit_dir)
833 else
834 write(output_unit, '(a)') "Thanks for using FORTRESS!"
835 end if
836
837 contains
838
839 subroutine filter_dotfiles(files, is_dir, is_exec, count)
840 character(len=*), dimension(*), intent(inout) :: files
841 logical, dimension(*), intent(inout) :: is_dir, is_exec
842 integer, intent(inout) :: count
843 character(len=MAX_PATH), dimension(MAX_FILES) :: temp_files
844 logical, dimension(MAX_FILES) :: temp_is_dir, temp_is_exec
845 integer :: i, new_count
846
847 new_count = 0
848 do i = 1, count
849 ! Always keep "." and "..", filter other dotfiles
850 if (trim(files(i)) == "." .or. trim(files(i)) == ".." .or. files(i)(1:1) /= '.') then
851 new_count = new_count + 1
852 temp_files(new_count) = files(i)
853 temp_is_dir(new_count) = is_dir(i)
854 temp_is_exec(new_count) = is_exec(i)
855 end if
856 end do
857
858 ! Copy back
859 do i = 1, new_count
860 files(i) = temp_files(i)
861 is_dir(i) = temp_is_dir(i)
862 is_exec(i) = temp_is_exec(i)
863 end do
864 count = new_count
865 end subroutine filter_dotfiles
866
867 function find_first_directory(files, is_dir, count) result(idx)
868 character(len=*), dimension(*), intent(in) :: files
869 logical, dimension(*), intent(in) :: is_dir
870 integer, intent(in) :: count
871 integer :: idx, i
872
873 ! Find first directory (including . and ..)
874 do i = 1, count
875 if (is_dir(i)) then
876 idx = i
877 return
878 end if
879 end do
880
881 ! If no directory found, default to first item
882 idx = 1
883 end function find_first_directory
884
885 function find_next_directory(files, is_dir, count, current) result(idx)
886 character(len=*), dimension(*), intent(in) :: files
887 logical, dimension(*), intent(in) :: is_dir
888 integer, intent(in) :: count, current
889 integer :: idx, i
890
891 ! Search forward from current position (including . and ..)
892 do i = current + 1, count
893 if (is_dir(i)) then
894 idx = i
895 return
896 end if
897 end do
898
899 ! No directory found forward, stay at current
900 idx = current
901 end function find_next_directory
902
903 function find_prev_directory(files, is_dir, count, current) result(idx)
904 character(len=*), dimension(*), intent(in) :: files
905 logical, dimension(*), intent(in) :: is_dir
906 integer, intent(in) :: count, current
907 integer :: idx, i
908
909 ! Search backward from current position (including . and ..)
910 do i = current - 1, 1, -1
911 if (is_dir(i)) then
912 idx = i
913 return
914 end if
915 end do
916
917 ! No directory found backward, stay at current
918 idx = current
919 end function find_prev_directory
920
921 subroutine execute_move_file(source_path, dest_dir, dest_name, is_dest_dir)
922 use iso_fortran_env, only: output_unit
923 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD
924 character(len=*), intent(in) :: source_path, dest_dir, dest_name
925 logical, intent(in) :: is_dest_dir
926 character(len=MAX_PATH*2) :: dest_path, mv_cmd
927 integer :: stat
928
929 ! Build destination path
930 if (is_dest_dir) then
931 if (trim(dest_name) == ".") then
932 ! Move to current directory
933 dest_path = dest_dir
934 else if (trim(dest_name) == "..") then
935 ! Move to parent directory
936 dest_path = get_parent_path(dest_dir)
937 else
938 ! Move into the selected directory
939 dest_path = join_path(dest_dir, dest_name)
940 end if
941 else
942 ! Not a directory - shouldn't happen due to our navigation, but handle it
943 dest_path = dest_dir
944 end if
945
946 ! Execute move command (mv will move file into dest_path directory)
947 mv_cmd = "mv '" // trim(source_path) // "' '" // trim(dest_path) // "'"
948 call execute_command_line(trim(mv_cmd), exitstat=stat, wait=.true.)
949
950 ! Show result briefly (no user input to avoid terminal state issues)
951 write(output_unit, '(a)', advance='no') CLEAR
952 write(output_unit, '(a)') BOLD // "Move Result" // RESET
953 write(output_unit, *)
954 if (stat == 0) then
955 write(output_unit, '(a)') GREEN // "✓ Moved successfully!" // RESET
956 write(output_unit, '(a)') " From: " // trim(source_path)
957 write(output_unit, '(a)') " To: " // trim(dest_path)
958 else
959 write(output_unit, '(a)') RED // "✗ Move failed" // RESET
960 write(output_unit, '(a)') " (destination may already exist or be invalid)"
961 end if
962 write(output_unit, *)
963
964 ! Brief pause to let user see the result (use Fortran sleep to avoid stdin issues)
965 call sleep(2)
966 end subroutine execute_move_file
967
968 subroutine execute_paste(source_path, is_cut, dest_dir, dest_name, dest_is_dir)
969 use iso_fortran_env, only: output_unit
970 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD, YELLOW
971 character(len=*), intent(in) :: source_path, dest_dir, dest_name
972 logical, intent(in) :: is_cut, dest_is_dir
973 character(len=MAX_PATH*2) :: dest_path, cmd, final_dest, base_name, extension
974 character(len=MAX_PATH*2) :: test_path
975 character(len=10) :: suffix_str
976 integer :: stat, suffix_num, ext_pos, name_len, ios
977
978 ! Determine destination directory based on cursor position
979 if (dest_is_dir) then
980 if (trim(dest_name) == ".") then
981 ! Paste into current directory
982 dest_path = dest_dir
983 else if (trim(dest_name) == "..") then
984 ! Paste into parent directory
985 dest_path = get_parent_path(dest_dir)
986 else
987 ! Paste into the selected directory
988 dest_path = join_path(dest_dir, dest_name)
989 end if
990 else
991 ! Cursor is on a file - paste into current directory (next to the file)
992 dest_path = dest_dir
993 end if
994
995 ! Extract the source filename from source_path
996 stat = index(source_path, "/", back=.true.)
997 if (stat > 0) then
998 base_name = source_path(stat+1:)
999 else
1000 base_name = source_path
1001 end if
1002
1003 ! Build initial destination (directory + filename)
1004 final_dest = join_path(dest_path, base_name)
1005
1006 ! Check if destination exists and find available suffix if needed
1007 call execute_command_line("test -e '" // trim(final_dest) // "'", exitstat=stat, wait=.true.)
1008 if (stat == 0) then
1009 ! Destination exists - find next available suffix (for both copy and cut)
1010 ! Split filename into name and extension
1011 ext_pos = index(base_name, ".", back=.true.)
1012 if (ext_pos > 1) then
1013 ! Has extension
1014 extension = base_name(ext_pos:)
1015 name_len = ext_pos - 1
1016 else
1017 ! No extension
1018 extension = ""
1019 name_len = len_trim(base_name)
1020 end if
1021
1022 ! Find next available suffix number
1023 suffix_num = 1
1024 do while (suffix_num < 1000) ! Safety limit
1025 ! Build the test path with suffix using concatenation
1026 write(suffix_str, '(i0)') suffix_num
1027
1028 if (len_trim(extension) > 0) then
1029 ! With extension: filename-N.ext
1030 test_path = trim(dest_path) // "/" // base_name(1:name_len) // "-" // &
1031 trim(suffix_str) // trim(extension)
1032 else
1033 ! Without extension: filename-N
1034 test_path = trim(dest_path) // "/" // trim(base_name) // "-" // trim(suffix_str)
1035 end if
1036
1037 ! Check if this suffixed name exists
1038 call execute_command_line("test -e '" // trim(test_path) // "'", exitstat=stat, wait=.true.)
1039 if (stat /= 0) then
1040 ! This name is available!
1041 final_dest = test_path
1042 exit
1043 end if
1044 suffix_num = suffix_num + 1
1045 end do
1046 end if
1047
1048 ! Execute copy or move command
1049 if (is_cut) then
1050 ! Cut = move
1051 cmd = "mv '" // trim(source_path) // "' '" // trim(final_dest) // "'"
1052 else
1053 ! Copy recursively (works for both files and directories)
1054 cmd = "cp -r '" // trim(source_path) // "' '" // trim(final_dest) // "'"
1055 end if
1056 call execute_command_line(trim(cmd), exitstat=stat, wait=.true.)
1057
1058 ! Show result briefly
1059 write(output_unit, '(a)', advance='no') CLEAR
1060 if (is_cut) then
1061 write(output_unit, '(a)') BOLD // "Cut Result" // RESET
1062 else
1063 write(output_unit, '(a)') BOLD // "Copy Result" // RESET
1064 end if
1065 write(output_unit, *)
1066 if (stat == 0) then
1067 if (is_cut) then
1068 write(output_unit, '(a)') GREEN // "✓ Cut and pasted successfully!" // RESET
1069 else
1070 write(output_unit, '(a)') GREEN // "✓ Copied successfully!" // RESET
1071 end if
1072 write(output_unit, '(a)') " From: " // trim(source_path)
1073 write(output_unit, '(a)') " To: " // trim(final_dest)
1074 else
1075 if (is_cut) then
1076 write(output_unit, '(a)') RED // "✗ Cut failed" // RESET
1077 else
1078 write(output_unit, '(a)') RED // "✗ Copy failed" // RESET
1079 end if
1080 write(output_unit, '(a)') " (destination may already exist or be invalid)"
1081 end if
1082 write(output_unit, *)
1083
1084 ! Brief pause to let user see the result
1085 call sleep(2)
1086 end subroutine execute_paste
1087
1088 subroutine delete_with_confirmation(dir, filename, is_dir)
1089 use iso_fortran_env, only: output_unit
1090 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD, YELLOW
1091 character(len=*), intent(in) :: dir, filename
1092 logical, intent(in) :: is_dir
1093 character(len=MAX_PATH*2) :: full_path, rm_cmd
1094 character(len=1) :: response
1095 integer :: stat, ios
1096
1097 ! Build full path
1098 full_path = join_path(dir, filename)
1099
1100 ! Clear screen and show confirmation prompt
1101 write(output_unit, '(a)', advance='no') CLEAR
1102 write(output_unit, '(a)') BOLD // "Delete Confirmation" // RESET
1103 write(output_unit, *)
1104 if (is_dir) then
1105 write(output_unit, '(a)') YELLOW // "WARNING: You are about to delete a directory!" // RESET
1106 write(output_unit, '(a)') "Directory: " // trim(filename)
1107 else
1108 write(output_unit, '(a)') "File: " // trim(filename)
1109 end if
1110 write(output_unit, '(a)') "Path: " // trim(full_path)
1111 write(output_unit, *)
1112 write(output_unit, '(a)', advance='no') RED // "Are you sure? (y/N): " // RESET
1113
1114 ! Read single character immediately (no need to wait for Enter)
1115 read(*, '(a1)', advance='no', iostat=ios) response
1116
1117 if (ios == 0 .and. (response == 'y' .or. response == 'Y')) then
1118 ! User confirmed - proceed with deletion
1119 if (is_dir) then
1120 ! Delete directory recursively
1121 rm_cmd = "rm -rf '" // trim(full_path) // "'"
1122 else
1123 ! Delete file
1124 rm_cmd = "rm -f '" // trim(full_path) // "'"
1125 end if
1126
1127 call execute_command_line(trim(rm_cmd), exitstat=stat, wait=.true.)
1128
1129 ! Show result
1130 write(output_unit, *)
1131 write(output_unit, *)
1132 if (stat == 0) then
1133 write(output_unit, '(a)') GREEN // "✓ Deleted successfully!" // RESET
1134 else
1135 write(output_unit, '(a)') RED // "✗ Delete failed" // RESET
1136 end if
1137 write(output_unit, *)
1138 write(output_unit, '(a)') "Press any key to continue..."
1139
1140 ! Wait for keypress
1141 read(*, '(a1)', advance='no', iostat=ios) response
1142 else
1143 ! User cancelled
1144 write(output_unit, *)
1145 write(output_unit, *)
1146 write(output_unit, '(a)') "Delete cancelled."
1147 write(output_unit, *)
1148 write(output_unit, '(a)') "Press any key to continue..."
1149
1150 ! Wait for keypress
1151 read(*, '(a1)', advance='no', iostat=ios) response
1152 end if
1153 end subroutine delete_with_confirmation
1154
1155 subroutine clear_all_selections(is_selected, selection_count, in_selection_mode)
1156 logical, dimension(*), intent(inout) :: is_selected
1157 integer, intent(inout) :: selection_count
1158 logical, intent(inout) :: in_selection_mode
1159 integer :: i
1160
1161 ! Clear all selections
1162 do i = 1, MAX_FILES
1163 is_selected(i) = .false.
1164 end do
1165 selection_count = 0
1166 in_selection_mode = .false.
1167 end subroutine clear_all_selections
1168
1169 subroutine check_disjoint_selection(is_selected, count, has_disjoint)
1170 logical, dimension(*), intent(in) :: is_selected
1171 integer, intent(in) :: count
1172 logical, intent(out) :: has_disjoint
1173 integer :: i, first_selected, last_selected
1174
1175 has_disjoint = .false.
1176 first_selected = -1
1177 last_selected = -1
1178
1179 ! Find first and last selected items
1180 do i = 1, count
1181 if (is_selected(i)) then
1182 if (first_selected == -1) first_selected = i
1183 last_selected = i
1184 end if
1185 end do
1186
1187 ! If we have a range, check if all items in range are selected
1188 if (first_selected > 0 .and. last_selected > first_selected) then
1189 do i = first_selected + 1, last_selected - 1
1190 if (.not. is_selected(i)) then
1191 has_disjoint = .true.
1192 exit
1193 end if
1194 end do
1195 end if
1196 end subroutine check_disjoint_selection
1197
1198 subroutine select_range(is_selected, selection_count, anchor, cursor, files, count)
1199 logical, dimension(*), intent(inout) :: is_selected
1200 integer, intent(inout) :: selection_count
1201 integer, intent(in) :: anchor, cursor, count
1202 character(len=*), dimension(*), intent(in) :: files
1203 integer :: i, range_start, range_end
1204
1205 ! Determine range boundaries
1206 range_start = min(anchor, cursor)
1207 range_end = max(anchor, cursor)
1208
1209 ! Clear all selections first
1210 do i = 1, count
1211 is_selected(i) = .false.
1212 end do
1213
1214 ! Select the range, skipping "." and ".."
1215 selection_count = 0
1216 do i = range_start, range_end
1217 if (i >= 1 .and. i <= count) then
1218 ! Skip special directories "." and ".."
1219 if (trim(files(i)) /= "." .and. trim(files(i)) /= "..") then
1220 is_selected(i) = .true.
1221 selection_count = selection_count + 1
1222 end if
1223 end if
1224 end do
1225 end subroutine select_range
1226
1227 subroutine delete_multi_with_confirmation(dir, files, is_dir, is_selected, selection_count, count)
1228 use iso_fortran_env, only: output_unit
1229 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD, YELLOW
1230 character(len=*), intent(in) :: dir
1231 character(len=*), dimension(*), intent(in) :: files
1232 logical, dimension(*), intent(in) :: is_dir, is_selected
1233 integer, intent(in) :: selection_count, count
1234 character(len=MAX_PATH*2) :: full_path, rm_cmd
1235 character(len=1) :: response
1236 integer :: stat, ios, i, deleted_count
1237
1238 ! Clear screen and show confirmation prompt
1239 write(output_unit, '(a)', advance='no') CLEAR
1240 write(output_unit, '(a)') BOLD // "Delete Multiple Items" // RESET
1241 write(output_unit, *)
1242 write(output_unit, '(a)') YELLOW // "WARNING: You are about to delete " // &
1243 trim(adjustl(itoa(selection_count))) // " items!" // RESET
1244 write(output_unit, *)
1245 write(output_unit, '(a)') "Selected items:"
1246
1247 ! List selected items (up to 10)
1248 i = 0
1249 do stat = 1, count
1250 if (is_selected(stat)) then
1251 i = i + 1
1252 if (i <= 10) then
1253 if (is_dir(stat)) then
1254 write(output_unit, '(a)') " [DIR] " // trim(files(stat))
1255 else
1256 write(output_unit, '(a)') " [FILE] " // trim(files(stat))
1257 end if
1258 else if (i == 11) then
1259 write(output_unit, '(a)') " ... and " // &
1260 trim(adjustl(itoa(selection_count - 10))) // " more"
1261 exit
1262 end if
1263 end if
1264 end do
1265
1266 write(output_unit, *)
1267 write(output_unit, '(a)', advance='no') RED // "Delete all selected items? (y/N): " // RESET
1268
1269 ! Read single character immediately
1270 read(*, '(a1)', advance='no', iostat=ios) response
1271
1272 if (ios == 0 .and. (response == 'y' .or. response == 'Y')) then
1273 ! User confirmed - proceed with deletion
1274 deleted_count = 0
1275
1276 do i = 1, count
1277 if (is_selected(i) .and. trim(files(i)) /= "." .and. trim(files(i)) /= "..") then
1278 full_path = join_path(dir, files(i))
1279
1280 if (is_dir(i)) then
1281 rm_cmd = "rm -rf '" // trim(full_path) // "'"
1282 else
1283 rm_cmd = "rm -f '" // trim(full_path) // "'"
1284 end if
1285
1286 call execute_command_line(trim(rm_cmd), exitstat=stat, wait=.true.)
1287 if (stat == 0) deleted_count = deleted_count + 1
1288 end if
1289 end do
1290
1291 ! Show result
1292 write(output_unit, *)
1293 write(output_unit, *)
1294 if (deleted_count == selection_count) then
1295 write(output_unit, '(a)') GREEN // "✓ All items deleted successfully!" // RESET
1296 else if (deleted_count > 0) then
1297 write(output_unit, '(a)') YELLOW // "⚠ Deleted " // &
1298 trim(adjustl(itoa(deleted_count))) // " of " // &
1299 trim(adjustl(itoa(selection_count))) // " items" // RESET
1300 else
1301 write(output_unit, '(a)') RED // "✗ Delete failed" // RESET
1302 end if
1303 write(output_unit, *)
1304 write(output_unit, '(a)') "Press any key to continue..."
1305
1306 ! Wait for keypress
1307 read(*, '(a1)', advance='no', iostat=ios) response
1308 else
1309 ! User cancelled
1310 write(output_unit, *)
1311 write(output_unit, *)
1312 write(output_unit, '(a)') "Delete cancelled."
1313 write(output_unit, *)
1314 write(output_unit, '(a)') "Press any key to continue..."
1315
1316 ! Wait for keypress
1317 read(*, '(a1)', advance='no', iostat=ios) response
1318 end if
1319 end subroutine delete_multi_with_confirmation
1320
1321 function itoa(n) result(str)
1322 integer, intent(in) :: n
1323 character(len=10) :: str
1324 write(str, '(i0)') n
1325 end function itoa
1326
1327 subroutine execute_multi_paste(paths, names, count, is_cut, dest_dir, dest_name, dest_is_dir)
1328 use iso_fortran_env, only: output_unit
1329 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD, YELLOW
1330 character(len=*), dimension(*), intent(in) :: paths, names
1331 integer, intent(in) :: count
1332 logical, intent(in) :: is_cut, dest_is_dir
1333 character(len=*), intent(in) :: dest_dir, dest_name
1334 character(len=MAX_PATH*2) :: dest_path, cmd, final_dest
1335 integer :: i, stat, success_count
1336 character(len=1) :: response
1337
1338 ! Determine destination directory
1339 if (dest_is_dir) then
1340 if (trim(dest_name) == ".") then
1341 dest_path = dest_dir
1342 else if (trim(dest_name) == "..") then
1343 dest_path = get_parent_path(dest_dir)
1344 else
1345 dest_path = join_path(dest_dir, dest_name)
1346 end if
1347 else
1348 dest_path = dest_dir
1349 end if
1350
1351 ! Show operation preview
1352 write(output_unit, '(a)', advance='no') CLEAR
1353 if (is_cut) then
1354 write(output_unit, '(a)') BOLD // "Multi-Cut Operation" // RESET
1355 else
1356 write(output_unit, '(a)') BOLD // "Multi-Copy Operation" // RESET
1357 end if
1358 write(output_unit, *)
1359 write(output_unit, '(a)') "Pasting " // trim(adjustl(itoa(count))) // " items to:"
1360 write(output_unit, '(a)') " " // trim(dest_path)
1361 write(output_unit, *)
1362
1363 success_count = 0
1364
1365 ! Process each item
1366 do i = 1, count
1367 ! Generate unique destination name if needed
1368 call get_unique_dest_name(dest_path, names(i), final_dest)
1369
1370 ! Execute operation
1371 if (is_cut) then
1372 cmd = "mv '" // trim(paths(i)) // "' '" // trim(final_dest) // "'"
1373 else
1374 cmd = "cp -r '" // trim(paths(i)) // "' '" // trim(final_dest) // "'"
1375 end if
1376
1377 call execute_command_line(trim(cmd), exitstat=stat, wait=.true.)
1378
1379 if (stat == 0) then
1380 success_count = success_count + 1
1381 write(output_unit, '(a)') GREEN // " ✓ " // RESET // trim(names(i))
1382 else
1383 write(output_unit, '(a)') RED // " ✗ " // RESET // trim(names(i))
1384 end if
1385 end do
1386
1387 ! Show summary
1388 write(output_unit, *)
1389 if (success_count == count) then
1390 write(output_unit, '(a)') GREEN // "✓ All items processed successfully!" // RESET
1391 else if (success_count > 0) then
1392 write(output_unit, '(a)') YELLOW // "⚠ Processed " // &
1393 trim(adjustl(itoa(success_count))) // " of " // &
1394 trim(adjustl(itoa(count))) // " items" // RESET
1395 else
1396 write(output_unit, '(a)') RED // "✗ Operation failed" // RESET
1397 end if
1398 write(output_unit, *)
1399 write(output_unit, '(a)') "Press any key to continue..."
1400
1401 ! Wait for keypress
1402 read(*, '(a1)', advance='no', iostat=stat) response
1403 end subroutine execute_multi_paste
1404
1405 subroutine get_unique_dest_name(dest_dir, base_name, unique_name)
1406 character(len=*), intent(in) :: dest_dir, base_name
1407 character(len=*), intent(out) :: unique_name
1408 character(len=MAX_PATH*2) :: test_path
1409 character(len=256) :: name_part, extension
1410 character(len=10) :: suffix_str
1411 integer :: ext_pos, suffix_num, stat
1412
1413 ! Initial destination
1414 unique_name = join_path(dest_dir, base_name)
1415
1416 ! Check if exists
1417 call execute_command_line("test -e '" // trim(unique_name) // "'", exitstat=stat, wait=.true.)
1418 if (stat /= 0) return ! Doesn't exist, we're good
1419
1420 ! Split name and extension
1421 ext_pos = index(base_name, ".", back=.true.)
1422 if (ext_pos > 1) then
1423 name_part = base_name(1:ext_pos-1)
1424 extension = base_name(ext_pos:)
1425 else
1426 name_part = base_name
1427 extension = ""
1428 end if
1429
1430 ! Find available suffix
1431 do suffix_num = 1, 999
1432 write(suffix_str, '(i0)') suffix_num
1433 if (len_trim(extension) > 0) then
1434 test_path = trim(dest_dir) // "/" // trim(name_part) // "-" // &
1435 trim(suffix_str) // trim(extension)
1436 else
1437 test_path = trim(dest_dir) // "/" // trim(name_part) // "-" // &
1438 trim(suffix_str)
1439 end if
1440
1441 call execute_command_line("test -e '" // trim(test_path) // "'", exitstat=stat, wait=.true.)
1442 if (stat /= 0) then
1443 unique_name = test_path
1444 exit
1445 end if
1446 end do
1447 end subroutine get_unique_dest_name
1448
1449 subroutine load_favorites(favs, count)
1450 character(len=MAX_PATH), dimension(10), intent(out) :: favs
1451 integer, intent(out) :: count
1452 character(len=MAX_PATH) :: favorites_file
1453 integer :: unit, ios
1454
1455 count = 0
1456 call get_environment_variable("HOME", favorites_file)
1457 favorites_file = trim(favorites_file) // "/.fortress_favorites"
1458
1459 open(newunit=unit, file=favorites_file, status='old', action='read', iostat=ios)
1460 if (ios /= 0) return ! File doesn't exist yet
1461
1462 do while (count < 10)
1463 read(unit, '(a)', iostat=ios) favs(count + 1)
1464 if (ios /= 0) exit
1465 if (len_trim(favs(count + 1)) > 0) count = count + 1
1466 end do
1467
1468 close(unit)
1469 end subroutine load_favorites
1470
1471 subroutine save_favorites(favs, count)
1472 character(len=MAX_PATH), dimension(10), intent(in) :: favs
1473 integer, intent(in) :: count
1474 character(len=MAX_PATH) :: favorites_file
1475 integer :: unit, ios, i
1476
1477 call get_environment_variable("HOME", favorites_file)
1478 favorites_file = trim(favorites_file) // "/.fortress_favorites"
1479
1480 open(newunit=unit, file=favorites_file, status='replace', action='write', iostat=ios)
1481 if (ios /= 0) return
1482
1483 do i = 1, count
1484 write(unit, '(a)') trim(favs(i))
1485 end do
1486
1487 close(unit)
1488 end subroutine save_favorites
1489
1490 function is_dir_favorited(dir_path, favs, count) result(is_fav)
1491 character(len=*), intent(in) :: dir_path
1492 character(len=MAX_PATH), dimension(10), intent(in) :: favs
1493 integer, intent(in) :: count
1494 logical :: is_fav
1495 integer :: i
1496
1497 is_fav = .false.
1498 do i = 1, count
1499 if (trim(favs(i)) == trim(dir_path)) then
1500 is_fav = .true.
1501 return
1502 end if
1503 end do
1504 end function is_dir_favorited
1505
1506 subroutine toggle_favorite(dir_path, favs, count)
1507 character(len=*), intent(in) :: dir_path
1508 character(len=MAX_PATH), dimension(10), intent(inout) :: favs
1509 integer, intent(inout) :: count
1510 integer :: i, j
1511 logical :: found
1512
1513 ! Check if already favorited
1514 found = .false.
1515 do i = 1, count
1516 if (trim(favs(i)) == trim(dir_path)) then
1517 ! Found - remove it
1518 found = .true.
1519 ! Shift remaining favorites down
1520 do j = i, count - 1
1521 favs(j) = favs(j + 1)
1522 end do
1523 favs(count) = ""
1524 count = count - 1
1525 exit
1526 end if
1527 end do
1528
1529 if (.not. found) then
1530 ! Not found - add it (if we have space)
1531 if (count < 10) then
1532 count = count + 1
1533 favs(count) = dir_path
1534 end if
1535 end if
1536 end subroutine toggle_favorite
1537
1538 subroutine add_favorite_with_replacement(dir_path, favs, count)
1539 use iso_fortran_env, only: output_unit
1540 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD
1541 character(len=*), intent(in) :: dir_path
1542 character(len=MAX_PATH), dimension(10), intent(inout) :: favs
1543 integer, intent(in) :: count
1544 character(len=MAX_PATH) :: temp_file, selected_fav
1545 integer :: unit, ios, stat, i
1546
1547 ! Clear screen and show prompt
1548 write(output_unit, '(a)', advance='no') CLEAR
1549 write(output_unit, '(a)') BOLD // "Favorites Full (10/10)" // RESET
1550 write(output_unit, *)
1551 write(output_unit, '(a)') "Select a favorite to replace:"
1552 write(output_unit, *)
1553
1554 ! Restore terminal for fzf
1555 call execute_command_line("stty sane 2>/dev/null")
1556
1557 ! Create temp file with current favorites
1558 call get_environment_variable("HOME", temp_file)
1559 temp_file = trim(temp_file) // "/.fortress_fav_temp"
1560
1561 open(newunit=unit, file=temp_file, status='replace', action='write', iostat=ios)
1562 if (ios == 0) then
1563 do i = 1, count
1564 write(unit, '(a)') trim(favs(i))
1565 end do
1566 close(unit)
1567 end if
1568
1569 ! Use fzf to select which favorite to replace
1570 call get_environment_variable("HOME", temp_file)
1571 temp_file = trim(temp_file) // "/.fortress_fav_select"
1572 call execute_command_line("cat ~/.fortress_fav_temp | fzf --height=10 --prompt='Replace: ' > " // &
1573 trim(temp_file) // " 2>/dev/null", exitstat=stat, wait=.true.)
1574
1575 if (stat == 0) then
1576 ! Read selected favorite
1577 open(newunit=unit, file=temp_file, status='old', action='read', iostat=ios)
1578 if (ios == 0) then
1579 read(unit, '(a)', iostat=ios) selected_fav
1580 close(unit)
1581
1582 if (ios == 0 .and. len_trim(selected_fav) > 0) then
1583 ! Replace the selected favorite with the new one
1584 do i = 1, count
1585 if (trim(favs(i)) == trim(selected_fav)) then
1586 favs(i) = dir_path
1587 write(output_unit, '(a)') GREEN // "✓ Replaced: " // trim(selected_fav) // RESET
1588 write(output_unit, '(a)') " With: " // trim(dir_path)
1589 call execute_command_line("sleep 1")
1590 exit
1591 end if
1592 end do
1593 end if
1594 end if
1595 else
1596 write(output_unit, '(a)') RED // "Cancelled." // RESET
1597 call execute_command_line("sleep 1")
1598 end if
1599
1600 ! Cleanup temp files
1601 call execute_command_line("rm -f ~/.fortress_fav_temp ~/.fortress_fav_select 2>/dev/null")
1602
1603 ! Re-enable raw mode
1604 call setup_raw_mode()
1605 end subroutine add_favorite_with_replacement
1606
1607 subroutine mark_favorites_in_lists(current_dir, files, count, is_dir, favs, fav_count, is_fav)
1608 character(len=*), intent(in) :: current_dir
1609 character(len=*), dimension(*), intent(in) :: files
1610 integer, intent(in) :: count
1611 logical, dimension(*), intent(in) :: is_dir
1612 character(len=MAX_PATH), dimension(10), intent(in) :: favs
1613 integer, intent(in) :: fav_count
1614 logical, dimension(*), intent(out) :: is_fav
1615 character(len=MAX_PATH) :: full_path
1616 integer :: i
1617
1618 ! Initialize all to false
1619 do i = 1, count
1620 is_fav(i) = .false.
1621 end do
1622
1623 ! Mark directories that are in favorites
1624 do i = 1, count
1625 if (is_dir(i) .and. trim(files(i)) /= "." .and. trim(files(i)) /= "..") then
1626 ! Build full path and check if favorited
1627 full_path = join_path(current_dir, files(i))
1628 is_fav(i) = is_dir_favorited(full_path, favs, fav_count)
1629 end if
1630 end do
1631 end subroutine mark_favorites_in_lists
1632
1633 subroutine open_favorites_picker(favs, count, selected_dir)
1634 use iso_fortran_env, only: output_unit
1635 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD
1636 character(len=MAX_PATH), dimension(10), intent(in) :: favs
1637 integer, intent(in) :: count
1638 character(len=MAX_PATH), intent(out) :: selected_dir
1639 character(len=MAX_PATH) :: temp_file
1640 integer :: unit, ios, stat, i
1641
1642 selected_dir = ""
1643
1644 if (count == 0) then
1645 ! No favorites yet
1646 write(output_unit, '(a)', advance='no') CLEAR
1647 write(output_unit, '(a)') RED // "No favorites yet!" // RESET
1648 write(output_unit, *)
1649 write(output_unit, '(a)') "Press '*' on a directory to add it to favorites."
1650 call execute_command_line("sleep 2")
1651 return
1652 end if
1653
1654 ! Restore terminal for fzf
1655 call execute_command_line("stty sane 2>/dev/null")
1656
1657 ! Create temp file with favorites
1658 call get_environment_variable("HOME", temp_file)
1659 temp_file = trim(temp_file) // "/.fortress_fav_picker"
1660
1661 open(newunit=unit, file=temp_file, status='replace', action='write', iostat=ios)
1662 if (ios == 0) then
1663 do i = 1, count
1664 write(unit, '(a)') trim(favs(i))
1665 end do
1666 close(unit)
1667 end if
1668
1669 ! Use fzf to select favorite
1670 call get_environment_variable("HOME", temp_file)
1671 temp_file = trim(temp_file) // "/.fortress_fav_selected"
1672 call execute_command_line("cat ~/.fortress_fav_picker | fzf --height=10 --prompt='Jump to: ' > " // &
1673 trim(temp_file) // " 2>/dev/null", exitstat=stat, wait=.true.)
1674
1675 if (stat == 0) then
1676 ! Read selected directory
1677 open(newunit=unit, file=temp_file, status='old', action='read', iostat=ios)
1678 if (ios == 0) then
1679 read(unit, '(a)', iostat=ios) selected_dir
1680 close(unit)
1681 end if
1682 end if
1683
1684 ! Cleanup temp files
1685 call execute_command_line("rm -f ~/.fortress_fav_picker ~/.fortress_fav_selected 2>/dev/null")
1686
1687 ! Re-enable raw mode
1688 call setup_raw_mode()
1689 end subroutine open_favorites_picker
1690
1691 subroutine fuzzy_jump(files, count, pattern, selected)
1692 character(len=*), dimension(*), intent(in) :: files
1693 integer, intent(in) :: count
1694 character(len=*), intent(in) :: pattern
1695 integer, intent(inout) :: selected
1696 integer :: i, score, best_score, best_idx
1697 character(len=256) :: pattern_lower, filename_lower
1698
1699 if (len_trim(pattern) == 0) return
1700
1701 best_score = -1
1702 best_idx = selected
1703
1704 ! Convert pattern to lowercase
1705 pattern_lower = pattern
1706 call to_lowercase(pattern_lower)
1707
1708 ! Search for best match
1709 do i = 1, count
1710 ! Skip "." and ".."
1711 if (trim(files(i)) == "." .or. trim(files(i)) == "..") cycle
1712
1713 ! Convert filename to lowercase
1714 filename_lower = files(i)
1715 call to_lowercase(filename_lower)
1716
1717 ! Get fuzzy match score
1718 score = fuzzy_score(pattern_lower, filename_lower)
1719
1720 if (score > best_score) then
1721 best_score = score
1722 best_idx = i
1723 end if
1724 end do
1725
1726 ! Jump to best match if we found something
1727 if (best_score >= 0) then
1728 selected = best_idx
1729 end if
1730 end subroutine fuzzy_jump
1731
1732 function fuzzy_score(pattern, text) result(score)
1733 character(len=*), intent(in) :: pattern, text
1734 integer :: score
1735 integer :: i, j, pat_len, text_len, consecutive
1736 logical :: match_found
1737 character(len=256) :: pattern_trim, text_trim
1738
1739 pattern_trim = trim(pattern)
1740 text_trim = trim(text)
1741 pat_len = len_trim(pattern_trim)
1742 text_len = len_trim(text_trim)
1743
1744 if (pat_len == 0) then
1745 score = 0
1746 return
1747 end if
1748
1749 ! Exact match (highest priority)
1750 if (pattern_trim == text_trim) then
1751 score = 10000
1752 return
1753 end if
1754
1755 ! Prefix match (very high priority)
1756 if (text_len >= pat_len) then
1757 if (text_trim(1:pat_len) == pattern_trim) then
1758 score = 5000
1759 return
1760 end if
1761 end if
1762
1763 ! Fuzzy match with scoring
1764 score = 0
1765 consecutive = 0
1766 j = 1
1767
1768 do i = 1, pat_len
1769 match_found = .false.
1770 do while (j <= text_len)
1771 if (pattern_trim(i:i) == text_trim(j:j)) then
1772 ! Base score for character match
1773 score = score + 100
1774
1775 ! Bonus for consecutive characters
1776 if (consecutive > 0) then
1777 score = score + 50
1778 end if
1779 consecutive = consecutive + 1
1780
1781 ! Bonus for match at start
1782 if (j == i) then
1783 score = score + 200
1784 end if
1785
1786 ! Bonus for word boundary (after /, -, _, .)
1787 if (j > 1) then
1788 if (text_trim(j-1:j-1) == '/' .or. text_trim(j-1:j-1) == '-' .or. &
1789 text_trim(j-1:j-1) == '_' .or. text_trim(j-1:j-1) == '.') then
1790 score = score + 150
1791 end if
1792 end if
1793
1794 j = j + 1
1795 match_found = .true.
1796 exit
1797 else
1798 consecutive = 0
1799 j = j + 1
1800 end if
1801 end do
1802
1803 ! If character not found, no match
1804 if (.not. match_found) then
1805 score = -1
1806 return
1807 end if
1808 end do
1809
1810 ! Penalty for length (prefer shorter matches)
1811 score = score - (text_len - pat_len)
1812 end function fuzzy_score
1813
1814 subroutine to_lowercase(str)
1815 character(len=*), intent(inout) :: str
1816 integer :: i, char_code
1817
1818 do i = 1, len_trim(str)
1819 char_code = ichar(str(i:i))
1820 if (char_code >= ichar('A') .and. char_code <= ichar('Z')) then
1821 str(i:i) = achar(char_code + 32)
1822 end if
1823 end do
1824 end subroutine to_lowercase
1825
1826 end program fortress
1827