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