Fortran · 20613 bytes Raw Blame History
1 program fortress
2 use iso_fortran_env, only: output_unit
3 use terminal_control
4 use filesystem_ops
5 use git_ops
6 use ui_display
7 implicit none
8
9 ! State variables
10 character(len=MAX_PATH) :: current_dir, parent_dir, temp_dir, exit_dir
11 character(len=MAX_PATH), dimension(MAX_FILES) :: current_files, parent_files
12 logical, dimension(MAX_FILES) :: current_is_dir, parent_is_dir
13 logical, dimension(MAX_FILES) :: current_is_exec, parent_is_exec
14 logical, dimension(MAX_FILES) :: current_is_staged, current_is_unstaged, current_is_untracked
15 logical, dimension(MAX_FILES) :: parent_is_staged, parent_is_unstaged, parent_is_untracked
16 logical, dimension(MAX_FILES) :: current_has_incoming
17 integer :: current_count, parent_count
18 integer :: selected = 1, parent_selected = -1
19 integer :: scroll_offset = 0, parent_scroll_offset = 0
20 character(len=256) :: repo_name, branch_name
21 logical :: in_git_repo = .false., running = .true., cd_on_exit = .false.
22 logical :: show_dotfiles = .true.
23
24 ! Move mode state
25 logical :: move_mode = .false.
26 character(len=MAX_PATH) :: move_source_path
27 character(len=MAX_PATH) :: move_source_name
28 integer :: move_dest_selected = 1
29
30 character(len=1) :: key
31 integer :: i, rows, cols, visible_height
32
33 ! Initialize
34 current_dir = get_pwd()
35 parent_dir = get_parent_path(current_dir)
36 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
37 call setup_raw_mode()
38
39 ! Main loop
40 do while (running)
41 ! Get files
42 call get_file_list(current_dir, current_files, current_is_dir, current_is_exec, current_count)
43 call get_file_list(parent_dir, parent_files, parent_is_dir, parent_is_exec, parent_count)
44
45 ! Filter dotfiles if needed
46 if (.not. show_dotfiles) then
47 call filter_dotfiles(current_files, current_is_dir, current_is_exec, current_count)
48 call filter_dotfiles(parent_files, parent_is_dir, parent_is_exec, parent_count)
49 end if
50
51 ! Initialize git arrays - only for actual file counts
52 do i = 1, current_count
53 current_is_staged(i) = .false.
54 current_is_unstaged(i) = .false.
55 current_is_untracked(i) = .false.
56 current_has_incoming(i) = .false.
57 end do
58 do i = 1, parent_count
59 parent_is_staged(i) = .false.
60 parent_is_unstaged(i) = .false.
61 parent_is_untracked(i) = .false.
62 end do
63
64 ! Get git status if in a repo
65 if (in_git_repo) then
66 call get_git_status(current_dir, current_files, current_is_dir, current_count, &
67 current_is_staged, current_is_unstaged, current_is_untracked)
68 call mark_incoming_changes(current_dir, current_files, current_count, current_has_incoming)
69 end if
70
71 ! Get terminal size
72 call get_term_size(rows, cols)
73 visible_height = rows - 3
74
75 ! Handle navigation signals from previous iteration
76 if (selected == -1) then
77 selected = find_in_parent(temp_dir, current_files, current_count)
78 scroll_offset = max(0, selected - visible_height / 2)
79 else if (selected == -2) then
80 selected = find_file_in_list(temp_dir, current_files, current_count)
81 scroll_offset = max(0, selected - visible_height / 2)
82 end if
83
84 ! Handle move mode destination cursor
85 if (move_mode .and. move_dest_selected == -1) then
86 move_dest_selected = find_in_parent(temp_dir, current_files, current_count)
87 end if
88
89 ! Bounds check
90 if (current_count > 0) then
91 selected = max(1, min(selected, current_count))
92 if (move_mode) then
93 move_dest_selected = max(1, min(move_dest_selected, current_count))
94 end if
95 else
96 selected = 1
97 if (move_mode) move_dest_selected = 1
98 end if
99
100 ! Find current dir in parent
101 parent_selected = find_in_parent(current_dir, parent_files, parent_count)
102
103 ! Adjust scroll to keep cursor visible
104 if (move_mode) then
105 ! In move mode, track the destination cursor
106 if (move_dest_selected < scroll_offset + 1) scroll_offset = max(0, move_dest_selected - 1)
107 if (move_dest_selected > scroll_offset + visible_height) scroll_offset = move_dest_selected - visible_height
108 scroll_offset = max(0, min(scroll_offset, max(0, current_count - visible_height)))
109 else
110 ! Normal mode, track the selection cursor
111 if (selected < scroll_offset + 1) scroll_offset = max(0, selected - 1)
112 if (selected > scroll_offset + visible_height) scroll_offset = selected - visible_height
113 scroll_offset = max(0, min(scroll_offset, max(0, current_count - visible_height)))
114 end if
115
116 if (parent_selected > 0) then
117 if (parent_selected < parent_scroll_offset + 1) parent_scroll_offset = max(0, parent_selected - 1)
118 if (parent_selected > parent_scroll_offset + visible_height) parent_scroll_offset = parent_selected - visible_height
119 parent_scroll_offset = max(0, min(parent_scroll_offset, max(0, parent_count - visible_height)))
120 end if
121
122 ! Draw
123 write(output_unit, '(a)', advance='no') CLEAR
124 call draw_interface(rows, cols, current_dir, current_files, current_is_dir, current_is_exec, &
125 current_is_staged, current_is_unstaged, current_is_untracked, current_has_incoming, &
126 current_count, parent_files, parent_is_dir, parent_is_exec, parent_count, &
127 selected, parent_selected, scroll_offset, parent_scroll_offset, &
128 in_git_repo, repo_name, branch_name, &
129 move_mode, move_source_name, move_dest_selected)
130
131 ! Get input (with error handling for End-of-record after Enter key)
132 read(*, '(a1)', advance='no', iostat=i) key
133 ! Only cycle on End-of-record (negative iostat), which happens after pressing Enter
134 ! Don't skip on positive errors or when we successfully read a character
135 if (i < 0) cycle ! End-of-record - skip and try again
136 if (i > 0) cycle ! Other read errors - skip and try again
137
138 ! Handle input
139 select case(ichar(key))
140 case(27) ! ESC - arrow keys or cancel move mode
141 call read_arrow_key(key)
142
143 ! If standalone ESC (not followed by '['), cancel move mode
144 if (key /= 'A' .and. key /= 'B' .and. key /= 'C' .and. key /= 'D' .and. key /= '[') then
145 if (move_mode) then
146 move_mode = .false.
147 end if
148 else if (move_mode) then
149 ! In move mode, navigate directories only
150 select case(key)
151 case('A') ! Up - jump to previous directory
152 move_dest_selected = find_prev_directory(current_files, current_is_dir, current_count, move_dest_selected)
153 case('B') ! Down - jump to next directory
154 move_dest_selected = find_next_directory(current_files, current_is_dir, current_count, move_dest_selected)
155 case('C') ! Right - enter directory
156 if (current_is_dir(move_dest_selected)) then
157 if (trim(current_files(move_dest_selected)) == "..") then
158 ! Don't descend into ..
159 else if (trim(current_files(move_dest_selected)) /= ".") then
160 ! Descend into directory
161 parent_dir = current_dir
162 current_dir = join_path(current_dir, current_files(move_dest_selected))
163 move_dest_selected = find_first_directory(current_files, current_is_dir, current_count)
164 scroll_offset = 0
165 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
166 end if
167 end if
168 case('D') ! Left - go to parent
169 if (current_dir /= "/") then
170 temp_dir = current_dir
171 current_dir = parent_dir
172 parent_dir = get_parent_path(current_dir)
173 move_dest_selected = -1 ! Will be set to parent dir position
174 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
175 end if
176 end select
177 else
178 ! Normal navigation
179 select case(key)
180 case('A') ! Up
181 if (selected > 1) selected = selected - 1
182 case('B') ! Down
183 if (selected < current_count .and. current_count > 0) selected = selected + 1
184 case('C') ! Right - enter directory
185 if (current_is_dir(selected)) then
186 if (trim(current_files(selected)) == "..") then
187 temp_dir = current_dir
188 current_dir = parent_dir
189 parent_dir = get_parent_path(current_dir)
190 selected = -1
191 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
192 else if (trim(current_files(selected)) /= ".") then
193 parent_dir = current_dir
194 current_dir = join_path(current_dir, current_files(selected))
195 selected = 1
196 scroll_offset = 0
197 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
198 end if
199 end if
200 case('D') ! Left - go back
201 if (current_dir /= "/") then
202 temp_dir = current_dir
203 current_dir = parent_dir
204 parent_dir = get_parent_path(current_dir)
205 selected = -1
206 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
207 end if
208 end select
209 end if
210 case(113, 81) ! 'q' or 'Q' - quit
211 running = .false.
212 case(99, 67) ! 'c' or 'C' - cd to directory on exit
213 if (current_is_dir(selected)) then
214 if (trim(current_files(selected)) == "..") then
215 exit_dir = parent_dir
216 else if (trim(current_files(selected)) == ".") then
217 exit_dir = current_dir
218 else
219 exit_dir = join_path(current_dir, current_files(selected))
220 end if
221 cd_on_exit = .true.
222 running = .false.
223 end if
224 case(83, 115) ! 'S' or 's' - fzf search (moved from 'f')
225 call fzf_search(current_dir, temp_dir)
226 if (len_trim(temp_dir) > 0) then
227 parent_dir = get_parent_path(temp_dir)
228 current_dir = parent_dir
229 parent_dir = get_parent_path(current_dir)
230 selected = -2
231 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
232 end if
233 case(65, 97) ! 'A' or 'a' - git add
234 if (in_git_repo .and. .not. current_is_dir(selected)) then
235 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
236 call git_add_file(current_dir, current_files(selected))
237 end if
238 end if
239 case(85, 117) ! 'U' or 'u' - git unstage
240 if (in_git_repo .and. .not. current_is_dir(selected)) then
241 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
242 if (current_is_staged(selected)) then
243 call git_unstage_file(current_dir, current_files(selected))
244 end if
245 end if
246 end if
247 case(77, 109) ! 'M' or 'm' - git commit
248 if (in_git_repo) then
249 call git_commit_prompt(current_dir, repo_name)
250 end if
251 case(80, 112) ! 'P' or 'p' - git push
252 if (in_git_repo) then
253 call git_push_prompt(current_dir, repo_name)
254 end if
255 case(84, 116) ! 'T' or 't' - git tag
256 if (in_git_repo) then
257 call git_tag_prompt(current_dir, repo_name)
258 end if
259 case(70, 102) ! 'F' or 'f' - git fetch
260 if (in_git_repo) then
261 call git_fetch_prompt(current_dir, repo_name)
262 end if
263 case(76, 108) ! 'L' or 'l' - git pull
264 if (in_git_repo) then
265 call git_pull_prompt(current_dir, repo_name)
266 end if
267 case(79, 111) ! 'O' or 'o' - open file
268 if (.not. current_is_dir(selected)) then
269 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
270 call open_file_in_default_app(join_path(current_dir, current_files(selected)))
271 end if
272 end if
273 case(78, 110) ! 'N' or 'n' - rename file/directory
274 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
275 call rename_file_prompt(current_dir, current_files(selected))
276 end if
277 case(68, 100) ! 'D' or 'd' - show git diff
278 if (in_git_repo .and. .not. current_is_dir(selected)) then
279 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
280 if (current_is_staged(selected) .or. current_is_unstaged(selected)) then
281 call show_git_diff_fullscreen(current_dir, current_files(selected), &
282 current_is_staged(selected), current_is_unstaged(selected))
283 end if
284 end if
285 end if
286 case(46) ! '.' - toggle dotfiles visibility
287 show_dotfiles = .not. show_dotfiles
288 ! Reset selection to avoid going out of bounds
289 selected = 1
290 scroll_offset = 0
291 case(86, 118) ! 'V' or 'v' - enter move mode OR confirm move
292 if (move_mode) then
293 ! Confirm move - execute the move to the white-highlighted directory
294 call execute_move_file(move_source_path, current_dir, current_files(move_dest_selected), &
295 current_is_dir(move_dest_selected))
296 move_mode = .false.
297 else if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
298 ! Enter move mode - store source file or directory
299 move_source_path = join_path(current_dir, current_files(selected))
300 move_source_name = current_files(selected)
301 move_mode = .true.
302 ! Find first directory for destination cursor
303 move_dest_selected = find_first_directory(current_files, current_is_dir, current_count)
304 end if
305 end select
306 end do
307
308 ! Cleanup
309 call restore_terminal()
310 write(output_unit, '(a)', advance='no') CLEAR
311
312 if (cd_on_exit) then
313 call write_exit_dir(exit_dir)
314 else
315 write(output_unit, '(a)') "Thanks for using FORTRESS!"
316 end if
317
318 contains
319
320 subroutine filter_dotfiles(files, is_dir, is_exec, count)
321 character(len=*), dimension(*), intent(inout) :: files
322 logical, dimension(*), intent(inout) :: is_dir, is_exec
323 integer, intent(inout) :: count
324 character(len=MAX_PATH), dimension(MAX_FILES) :: temp_files
325 logical, dimension(MAX_FILES) :: temp_is_dir, temp_is_exec
326 integer :: i, new_count
327
328 new_count = 0
329 do i = 1, count
330 ! Always keep "." and "..", filter other dotfiles
331 if (trim(files(i)) == "." .or. trim(files(i)) == ".." .or. files(i)(1:1) /= '.') then
332 new_count = new_count + 1
333 temp_files(new_count) = files(i)
334 temp_is_dir(new_count) = is_dir(i)
335 temp_is_exec(new_count) = is_exec(i)
336 end if
337 end do
338
339 ! Copy back
340 do i = 1, new_count
341 files(i) = temp_files(i)
342 is_dir(i) = temp_is_dir(i)
343 is_exec(i) = temp_is_exec(i)
344 end do
345 count = new_count
346 end subroutine filter_dotfiles
347
348 function find_first_directory(files, is_dir, count) result(idx)
349 character(len=*), dimension(*), intent(in) :: files
350 logical, dimension(*), intent(in) :: is_dir
351 integer, intent(in) :: count
352 integer :: idx, i
353
354 ! Find first directory (including . and ..)
355 do i = 1, count
356 if (is_dir(i)) then
357 idx = i
358 return
359 end if
360 end do
361
362 ! If no directory found, default to first item
363 idx = 1
364 end function find_first_directory
365
366 function find_next_directory(files, is_dir, count, current) result(idx)
367 character(len=*), dimension(*), intent(in) :: files
368 logical, dimension(*), intent(in) :: is_dir
369 integer, intent(in) :: count, current
370 integer :: idx, i
371
372 ! Search forward from current position (including . and ..)
373 do i = current + 1, count
374 if (is_dir(i)) then
375 idx = i
376 return
377 end if
378 end do
379
380 ! No directory found forward, stay at current
381 idx = current
382 end function find_next_directory
383
384 function find_prev_directory(files, is_dir, count, current) result(idx)
385 character(len=*), dimension(*), intent(in) :: files
386 logical, dimension(*), intent(in) :: is_dir
387 integer, intent(in) :: count, current
388 integer :: idx, i
389
390 ! Search backward from current position (including . and ..)
391 do i = current - 1, 1, -1
392 if (is_dir(i)) then
393 idx = i
394 return
395 end if
396 end do
397
398 ! No directory found backward, stay at current
399 idx = current
400 end function find_prev_directory
401
402 subroutine execute_move_file(source_path, dest_dir, dest_name, is_dest_dir)
403 use iso_fortran_env, only: output_unit
404 use terminal_control, only: CLEAR, GREEN, RED, RESET, BOLD
405 character(len=*), intent(in) :: source_path, dest_dir, dest_name
406 logical, intent(in) :: is_dest_dir
407 character(len=MAX_PATH*2) :: dest_path, mv_cmd
408 integer :: stat
409
410 ! Build destination path
411 if (is_dest_dir) then
412 if (trim(dest_name) == ".") then
413 ! Move to current directory
414 dest_path = dest_dir
415 else if (trim(dest_name) == "..") then
416 ! Move to parent directory
417 dest_path = get_parent_path(dest_dir)
418 else
419 ! Move into the selected directory
420 dest_path = join_path(dest_dir, dest_name)
421 end if
422 else
423 ! Not a directory - shouldn't happen due to our navigation, but handle it
424 dest_path = dest_dir
425 end if
426
427 ! Execute move command (mv will move file into dest_path directory)
428 mv_cmd = "mv '" // trim(source_path) // "' '" // trim(dest_path) // "'"
429 call execute_command_line(trim(mv_cmd), exitstat=stat, wait=.true.)
430
431 ! Show result briefly (no user input to avoid terminal state issues)
432 write(output_unit, '(a)', advance='no') CLEAR
433 write(output_unit, '(a)') BOLD // "Move Result" // RESET
434 write(output_unit, *)
435 if (stat == 0) then
436 write(output_unit, '(a)') GREEN // "✓ Moved successfully!" // RESET
437 write(output_unit, '(a)') " From: " // trim(source_path)
438 write(output_unit, '(a)') " To: " // trim(dest_path)
439 else
440 write(output_unit, '(a)') RED // "✗ Move failed" // RESET
441 write(output_unit, '(a)') " (destination may already exist or be invalid)"
442 end if
443 write(output_unit, *)
444
445 ! Brief pause to let user see the result (use Fortran sleep to avoid stdin issues)
446 call sleep(2)
447 end subroutine execute_move_file
448
449 end program fortress
450