fortrangoingonforty/fortress / 2367faf

Browse files

refactor, modularize

Authored by espadonne
SHA
2367faf260d712efda30769750ed8919dffb2951
Parents
47be62b
Tree
869f5c7

6 changed files

StatusFile+-
M app/main.f90 44 603
C app/main.f90.backup 0 0
A src/filesystem/fs_ops.f90 203 0
A src/git/git_ops.f90 196 0
A src/terminal/term_control.f90 70 0
A src/ui/display.f90 129 0
app/main.f90modified
@@ -1,51 +1,31 @@
1
-program fortress_clean
2
-    use iso_fortran_env, only: output_unit, error_unit
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
37
     implicit none
48
 
5
-    ! Constants
6
-    integer, parameter :: MAX_PATH = 512
7
-    integer, parameter :: MAX_FILES = 500
8
-    
9
-    character(len=*), parameter :: ESC = char(27)
10
-    character(len=*), parameter :: CLEAR = ESC // "[2J" // ESC // "[H"
11
-    character(len=*), parameter :: BOLD = ESC // "[1m"
12
-    character(len=*), parameter :: DIM = ESC // "[2m"
13
-    character(len=*), parameter :: REVERSE = ESC // "[7m"
14
-    character(len=*), parameter :: RESET = ESC // "[0m"
15
-    character(len=*), parameter :: BLUE = ESC // "[34m"
16
-    character(len=*), parameter :: GREEN = ESC // "[32m"
17
-    character(len=*), parameter :: RED = ESC // "[31m"
18
-    character(len=*), parameter :: GREY = ESC // "[90m"
19
-    character(len=*), parameter :: WHITE = ESC // "[37m"
20
-
21
-    ! Variables
22
-    character(len=MAX_PATH) :: current_dir, parent_dir, temp_dir
9
+    ! State variables
10
+    character(len=MAX_PATH) :: current_dir, parent_dir, temp_dir, exit_dir
2311
     character(len=MAX_PATH), dimension(MAX_FILES) :: current_files, parent_files
2412
     logical, dimension(MAX_FILES) :: current_is_dir, parent_is_dir
2513
     logical, dimension(MAX_FILES) :: current_is_exec, parent_is_exec
2614
     logical, dimension(MAX_FILES) :: current_is_staged, current_is_unstaged, current_is_untracked
2715
     logical, dimension(MAX_FILES) :: parent_is_staged, parent_is_unstaged, parent_is_untracked
2816
     integer :: current_count, parent_count
29
-    integer :: selected = 1
30
-    integer :: parent_selected = -1
31
-    integer :: scroll_offset = 0
32
-    integer :: parent_scroll_offset = 0
17
+    integer :: selected = 1, parent_selected = -1
18
+    integer :: scroll_offset = 0, parent_scroll_offset = 0
19
+    character(len=256) :: repo_name, branch_name
20
+    logical :: in_git_repo = .false., running = .true., cd_on_exit = .false.
3321
     character(len=1) :: key
34
-    logical :: running = .true.
35
-    logical :: cd_on_exit = .false.
36
-    character(len=MAX_PATH) :: exit_dir
37
-    character(len=256) :: repo_name
38
-    character(len=256) :: branch_name
39
-    logical :: in_git_repo = .false.
4022
     integer :: i, rows, cols, visible_height
4123
 
4224
     ! Initialize
4325
     current_dir = get_pwd()
4426
     parent_dir = get_parent_path(current_dir)
4527
     call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
46
-
47
-    ! Setup terminal
48
-    call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null")
28
+    call setup_raw_mode()
4929
 
5030
     ! Main loop
5131
     do while (running)
@@ -71,23 +51,20 @@ program fortress_clean
7151
                                current_is_staged, current_is_unstaged, current_is_untracked)
7252
         end if
7353
 
74
-        ! Get terminal size early to use for scroll calculations
54
+        ! Get terminal size
7555
         call get_term_size(rows, cols)
76
-        visible_height = rows - 3  ! Header + footer + 1 for indexing
56
+        visible_height = rows - 3
7757
 
78
-        ! Handle navigation - find position in parent if needed
58
+        ! Handle navigation signals from previous iteration
7959
         if (selected == -1) then
8060
             selected = find_in_parent(temp_dir, current_files, current_count)
81
-            ! Center the cursor in viewport if possible
8261
             scroll_offset = max(0, selected - visible_height / 2)
8362
         else if (selected == -2) then
84
-            ! Find position after fzf selection
8563
             selected = find_file_in_list(temp_dir, current_files, current_count)
86
-            ! Center the cursor in viewport
8764
             scroll_offset = max(0, selected - visible_height / 2)
8865
         end if
8966
 
90
-        ! Ensure selected cursor is within valid bounds
67
+        ! Bounds check
9168
         if (current_count > 0) then
9269
             selected = max(1, min(selected, current_count))
9370
         else
@@ -97,75 +74,63 @@ program fortress_clean
9774
         ! Find current dir in parent
9875
         parent_selected = find_in_parent(current_dir, parent_files, parent_count)
9976
 
100
-        ! Adjust scroll offset to keep selected item visible
101
-        if (selected <= scroll_offset) then
102
-            ! Scrolled above viewport - move viewport up
103
-            scroll_offset = max(0, selected - 1)
104
-        else if (selected > scroll_offset + visible_height) then
105
-            ! Scrolled below viewport - move viewport down
106
-            scroll_offset = selected - visible_height
107
-        end if
77
+        ! Adjust scroll to keep cursor visible
78
+        if (selected < scroll_offset + 1) scroll_offset = max(0, selected - 1)
79
+        if (selected > scroll_offset + visible_height) scroll_offset = selected - visible_height
10880
         scroll_offset = max(0, min(scroll_offset, max(0, current_count - visible_height)))
10981
 
110
-        ! Adjust parent scroll offset to keep parent selection visible
11182
         if (parent_selected > 0) then
112
-            if (parent_selected <= parent_scroll_offset) then
113
-                parent_scroll_offset = max(0, parent_selected - 1)
114
-            else if (parent_selected > parent_scroll_offset + visible_height) then
115
-                parent_scroll_offset = parent_selected - visible_height
116
-            end if
83
+            if (parent_selected < parent_scroll_offset + 1) parent_scroll_offset = max(0, parent_selected - 1)
84
+            if (parent_selected > parent_scroll_offset + visible_height) parent_scroll_offset = parent_selected - visible_height
11785
             parent_scroll_offset = max(0, min(parent_scroll_offset, max(0, parent_count - visible_height)))
11886
         end if
11987
 
120
-        ! Draw interface
88
+        ! Draw
12189
         write(output_unit, '(a)', advance='no') CLEAR
122
-        call draw_interface(rows, cols)
90
+        call draw_interface(rows, cols, current_dir, current_files, current_is_dir, current_is_exec, &
91
+                           current_is_staged, current_is_unstaged, current_is_untracked, current_count, &
92
+                           parent_files, parent_is_dir, parent_is_exec, parent_count, &
93
+                           selected, parent_selected, scroll_offset, parent_scroll_offset, &
94
+                           in_git_repo, repo_name, branch_name)
12395
 
12496
         ! Get input
12597
         read(*, '(a1)', advance='no') key
12698
 
12799
         ! Handle input
128100
         select case(ichar(key))
129
-        case(27)  ! ESC sequence
101
+        case(27)  ! ESC - arrow keys
130102
             call read_arrow_key(key)
131103
             select case(key)
132104
             case('A')  ! Up
133
-                if (selected > 1) then
134
-                    selected = selected - 1
135
-                end if
105
+                if (selected > 1) selected = selected - 1
136106
             case('B')  ! Down
137
-                if (selected < current_count .and. current_count > 0) then
138
-                    selected = selected + 1
139
-                end if
140
-            case('C')  ! Right - enter
107
+                if (selected < current_count .and. current_count > 0) selected = selected + 1
108
+            case('C')  ! Right - enter directory
141109
                 if (current_is_dir(selected)) then
142110
                     if (trim(current_files(selected)) == "..") then
143111
                         temp_dir = current_dir
144112
                         current_dir = parent_dir
145113
                         parent_dir = get_parent_path(current_dir)
146
-                        selected = -1  ! Signal to find position in parent
147
-                        ! Re-detect git repo after navigation
114
+                        selected = -1
148115
                         call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
149116
                     else if (trim(current_files(selected)) /= ".") then
150117
                         parent_dir = current_dir
151118
                         current_dir = join_path(current_dir, current_files(selected))
152119
                         selected = 1
153120
                         scroll_offset = 0
154
-                        ! Re-detect git repo after entering directory
155121
                         call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
156122
                     end if
157123
                 end if
158
-            case('D')  ! Left - back
124
+            case('D')  ! Left - go back
159125
                 if (current_dir /= "/") then
160126
                     temp_dir = current_dir
161127
                     current_dir = parent_dir
162128
                     parent_dir = get_parent_path(current_dir)
163
-                    selected = -1  ! Signal to find position in parent
164
-                    ! Re-detect git repo after going back
129
+                    selected = -1
165130
                     call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
166131
                 end if
167132
             end select
168
-        case(113, 81)  ! 'q' or 'Q'
133
+        case(113, 81)  ! 'q' or 'Q' - quit
169134
             running = .false.
170135
         case(99, 67)  ! 'c' or 'C' - cd to directory on exit
171136
             if (current_is_dir(selected)) then
@@ -182,12 +147,10 @@ program fortress_clean
182147
         case(102, 70)  ! 'f' or 'F' - fzf search
183148
             call fzf_search(current_dir, temp_dir)
184149
             if (len_trim(temp_dir) > 0) then
185
-                ! Navigate to the selected file's directory
186150
                 parent_dir = get_parent_path(temp_dir)
187151
                 current_dir = parent_dir
188152
                 parent_dir = get_parent_path(current_dir)
189
-                selected = -2  ! Signal to find and center on fzf result
190
-                ! Re-detect git repo after fzf navigation
153
+                selected = -2
191154
                 call detect_git_repo(current_dir, in_git_repo, repo_name, branch_name)
192155
             end if
193156
         case(65, 97)  ! 'A' or 'a' - git add
@@ -196,551 +159,29 @@ program fortress_clean
196159
                     call git_add_file(current_dir, current_files(selected))
197160
                 end if
198161
             end if
199
-        case(77, 109)  ! 'M' or 'm' - git commit
200
-            if (in_git_repo) then
201
-                call git_commit_prompt(current_dir)
202
-            end if
203
-        case(85, 117)  ! 'U' or 'u' - git unstage (restore --staged)
162
+        case(85, 117)  ! 'U' or 'u' - git unstage
204163
             if (in_git_repo .and. .not. current_is_dir(selected)) then
205164
                 if (trim(current_files(selected)) /= "." .and. trim(current_files(selected)) /= "..") then
206
-                    ! Only unstage if file is actually staged
207165
                     if (current_is_staged(selected)) then
208166
                         call git_unstage_file(current_dir, current_files(selected))
209167
                     end if
210168
                 end if
211169
             end if
170
+        case(77, 109)  ! 'M' or 'm' - git commit
171
+            if (in_git_repo) then
172
+                call git_commit_prompt(current_dir, repo_name)
173
+            end if
212174
         end select
213175
     end do
214176
 
215177
     ! Cleanup
216
-    call execute_command_line("stty icanon echo 2>/dev/null")
178
+    call restore_terminal()
217179
     write(output_unit, '(a)', advance='no') CLEAR
218180
 
219
-    ! If cd_on_exit is set, write the directory to a temp file
220181
     if (cd_on_exit) then
221182
         call write_exit_dir(exit_dir)
222183
     else
223184
         write(output_unit, '(a)') "Thanks for using FORTRESS!"
224185
     end if
225186
 
226
-contains
227
-
228
-    function get_pwd() result(path)
229
-        character(len=MAX_PATH) :: path
230
-        integer :: unit, ios
231
-
232
-        call execute_command_line("pwd > .fortress_pwd 2>/dev/null", wait=.true.)
233
-        open(newunit=unit, file=".fortress_pwd", status='old', iostat=ios)
234
-        if (ios == 0) then
235
-            read(unit, '(a)') path
236
-            close(unit)
237
-        else
238
-            path = "."
239
-        end if
240
-        call execute_command_line("rm -f .fortress_pwd 2>/dev/null")
241
-    end function get_pwd
242
-
243
-    function get_parent_path(path) result(parent)
244
-        character(len=*), intent(in) :: path
245
-        character(len=MAX_PATH) :: parent
246
-        integer :: pos
247
-
248
-        pos = index(path, "/", back=.true.)
249
-        if (pos > 1) then
250
-            parent = path(1:pos-1)
251
-        else if (pos == 1) then
252
-            parent = "/"
253
-        else
254
-            parent = "."
255
-        end if
256
-    end function get_parent_path
257
-
258
-    function join_path(base, name) result(full)
259
-        character(len=*), intent(in) :: base, name
260
-        character(len=MAX_PATH) :: full
261
-
262
-        if (base == "/") then
263
-            full = "/" // trim(name)
264
-        else
265
-            full = trim(base) // "/" // trim(name)
266
-        end if
267
-    end function join_path
268
-
269
-    function find_in_parent(dir, files, count) result(idx)
270
-        character(len=*), intent(in) :: dir
271
-        character(len=*), dimension(*), intent(in) :: files
272
-        integer, intent(in) :: count
273
-        integer :: idx, pos
274
-        character(len=256) :: basename
275
-
276
-        pos = index(dir, "/", back=.true.)
277
-        if (pos > 0) then
278
-            basename = dir(pos+1:)
279
-        else
280
-            basename = dir
281
-        end if
282
-
283
-        do idx = 1, count
284
-            if (trim(files(idx)) == trim(basename)) return
285
-        end do
286
-        idx = 1
287
-    end function find_in_parent
288
-
289
-    subroutine get_file_list(dir, files, is_dir, is_exec, count)
290
-        character(len=*), intent(in) :: dir
291
-        character(len=*), dimension(*), intent(out) :: files
292
-        logical, dimension(*), intent(out) :: is_dir, is_exec
293
-        integer, intent(out) :: count
294
-        integer :: unit, ios, stat
295
-        character(len=MAX_PATH) :: fullpath
296
-
297
-        call execute_command_line("ls -1a '" // trim(dir) // "' > .fortress_ls 2>/dev/null", wait=.true.)
298
-
299
-        open(newunit=unit, file=".fortress_ls", status='old', iostat=ios)
300
-        if (ios /= 0) then
301
-            count = 0
302
-            return
303
-        end if
304
-
305
-        count = 0
306
-        do
307
-            count = count + 1
308
-            if (count > MAX_FILES) exit
309
-            read(unit, '(a)', iostat=ios) files(count)
310
-            if (ios /= 0) then
311
-                count = count - 1
312
-                exit
313
-            end if
314
-
315
-            fullpath = join_path(dir, files(count))
316
-            call execute_command_line("test -d '" // trim(fullpath) // "'", exitstat=stat, wait=.true.)
317
-            is_dir(count) = (stat == 0)
318
-
319
-            ! Check if executable (but not directories)
320
-            if (.not. is_dir(count)) then
321
-                call execute_command_line("test -x '" // trim(fullpath) // "'", exitstat=stat, wait=.true.)
322
-                is_exec(count) = (stat == 0)
323
-            else
324
-                is_exec(count) = .false.
325
-            end if
326
-        end do
327
-
328
-        close(unit)
329
-        call execute_command_line("rm -f .fortress_ls 2>/dev/null")
330
-    end subroutine get_file_list
331
-
332
-    subroutine get_term_size(r, c)
333
-        integer, intent(out) :: r, c
334
-        integer :: unit, ios
335
-
336
-        call execute_command_line("tput lines > .fortress_size 2>/dev/null", wait=.true.)
337
-        open(newunit=unit, file=".fortress_size", status='old', iostat=ios)
338
-        if (ios == 0) then
339
-            read(unit, *) r
340
-            close(unit)
341
-        else
342
-            r = 24
343
-        end if
344
-
345
-        call execute_command_line("tput cols > .fortress_size 2>/dev/null", wait=.true.)
346
-        open(newunit=unit, file=".fortress_size", status='old', iostat=ios)
347
-        if (ios == 0) then
348
-            read(unit, *) c
349
-            close(unit)
350
-        else
351
-            c = 80
352
-        end if
353
-
354
-        call execute_command_line("rm -f .fortress_size 2>/dev/null")
355
-    end subroutine get_term_size
356
-
357
-    subroutine draw_interface(r, c)
358
-        integer, intent(in) :: r, c
359
-        integer :: left_w, i, parent_idx, current_idx, vis_h
360
-        character(len=256) :: fname
361
-        character(len=20) :: color_code
362
-
363
-        left_w = c * 3 / 10
364
-        vis_h = r - 3  ! Visible height
365
-
366
-        ! Header
367
-        write(output_unit, '(a)') BOLD // "FORTRESS" // RESET // " - " // trim(current_dir)
368
-
369
-        ! Files (render based on scroll offsets)
370
-        do i = 1, vis_h
371
-            parent_idx = i + parent_scroll_offset
372
-            current_idx = i + scroll_offset
373
-
374
-            ! Parent pane
375
-            if (parent_idx >= 1 .and. parent_idx <= parent_count) then
376
-                fname = parent_files(parent_idx)
377
-                if (parent_is_dir(parent_idx) .and. fname /= "." .and. fname /= "..") then
378
-                    fname = trim(fname) // "/"
379
-                end if
380
-
381
-                ! Get color for parent file
382
-                color_code = get_file_color(parent_files(parent_idx), parent_is_dir(parent_idx), parent_is_exec(parent_idx))
383
-
384
-                if (parent_idx == parent_selected) then
385
-                    write(output_unit, '(a)', advance='no') DIM // BOLD // trim(color_code) // &
386
-                        fname(1:min(len_trim(fname),left_w)) // RESET
387
-                else
388
-                    write(output_unit, '(a)', advance='no') DIM // trim(color_code) // &
389
-                        fname(1:min(len_trim(fname),left_w)) // RESET
390
-                end if
391
-                write(output_unit, '(a)', advance='no') repeat(" ", max(0, left_w - len_trim(fname)))
392
-            else
393
-                write(output_unit, '(a)', advance='no') repeat(" ", left_w)
394
-            end if
395
-
396
-            ! Separator
397
-            write(output_unit, '(a)', advance='no') " │ "
398
-
399
-            ! Current pane
400
-            if (current_idx >= 1 .and. current_idx <= current_count) then
401
-                fname = current_files(current_idx)
402
-                if (current_is_dir(current_idx) .and. fname /= "." .and. fname /= "..") then
403
-                    fname = trim(fname) // "/"
404
-                end if
405
-
406
-                ! Get color for current file
407
-                color_code = get_file_color(current_files(current_idx), current_is_dir(current_idx), current_is_exec(current_idx))
408
-
409
-                if (current_idx == selected) then
410
-                    write(output_unit, '(a)', advance='no') REVERSE // trim(color_code) // trim(fname)
411
-                    ! Add git indicators if in repo
412
-                    if (in_git_repo) then
413
-                        call write_git_indicators(current_is_staged(current_idx), &
414
-                                                  current_is_unstaged(current_idx), &
415
-                                                  current_is_untracked(current_idx), .true.)
416
-                    end if
417
-                    write(output_unit, '(a)') RESET
418
-                else
419
-                    write(output_unit, '(a)', advance='no') trim(color_code) // trim(fname)
420
-                    ! Add git indicators if in repo
421
-                    if (in_git_repo) then
422
-                        call write_git_indicators(current_is_staged(current_idx), &
423
-                                                  current_is_unstaged(current_idx), &
424
-                                                  current_is_untracked(current_idx), .false.)
425
-                    end if
426
-                    write(output_unit, '(a)') RESET
427
-                end if
428
-            else
429
-                write(output_unit, *)
430
-            end if
431
-        end do
432
-
433
-        ! Footer
434
-        if (in_git_repo) then
435
-            write(output_unit, '(a)') DIM // trim(repo_name) // ":" // trim(branch_name) // " | " // RESET // &
436
-                                     DIM // "↑↓:nav →:enter ←:back f:find A:add U:unstage M:commit c:cd q:quit" // RESET
437
-        else
438
-            write(output_unit, '(a)') DIM // "↑↓:nav →:enter ←:back f:find c:cd q:quit" // RESET
439
-        end if
440
-    end subroutine draw_interface
441
-
442
-    subroutine read_arrow_key(k)
443
-        character(len=1), intent(out) :: k
444
-        character(len=1) :: ch
445
-
446
-        read(*, '(a1)', advance='no') ch
447
-        if (ch == '[') then
448
-            read(*, '(a1)', advance='no') k
449
-        else
450
-            k = ch
451
-        end if
452
-    end subroutine read_arrow_key
453
-
454
-    function get_file_color(filename, is_dir, is_exec) result(color)
455
-        character(len=*), intent(in) :: filename
456
-        logical, intent(in) :: is_dir, is_exec
457
-        character(len=20) :: color
458
-
459
-        ! Directories: Blue and bold
460
-        if (is_dir) then
461
-            color = BOLD // BLUE
462
-        ! Dotfiles: Grey
463
-        else if (filename(1:1) == '.') then
464
-            color = GREY
465
-        ! Executable files: Green
466
-        else if (is_exec) then
467
-            color = GREEN
468
-        ! All other files: White
469
-        else
470
-            color = WHITE
471
-        end if
472
-    end function get_file_color
473
-
474
-    subroutine write_exit_dir(dir)
475
-        character(len=*), intent(in) :: dir
476
-        character(len=MAX_PATH) :: temp_file
477
-        integer :: unit, ios
478
-
479
-        ! Create temp file in HOME directory
480
-        call get_environment_variable("HOME", temp_file)
481
-        temp_file = trim(temp_file) // "/.fortress_cd"
482
-
483
-        open(newunit=unit, file=temp_file, status='replace', action='write', iostat=ios)
484
-        if (ios == 0) then
485
-            write(unit, '(a)') trim(dir)
486
-            close(unit)
487
-        end if
488
-    end subroutine write_exit_dir
489
-
490
-    subroutine fzf_search(search_dir, result_path)
491
-        character(len=*), intent(in) :: search_dir
492
-        character(len=*), intent(out) :: result_path
493
-        character(len=MAX_PATH) :: temp_file, fzf_cmd
494
-        integer :: unit, ios, stat
495
-
496
-        result_path = ""
497
-
498
-        ! Create temp file for fzf output
499
-        call get_environment_variable("HOME", temp_file)
500
-        temp_file = trim(temp_file) // "/.fortress_fzf"
501
-
502
-        ! Restore terminal for fzf
503
-        call execute_command_line("stty icanon echo 2>/dev/null")
504
-
505
-        ! Build fzf command: find files, pipe to fzf, save selection
506
-        fzf_cmd = "cd '" // trim(search_dir) // "' && " // &
507
-                  "find . -type f -o -type d | " // &
508
-                  "sed 's|^\./||' | " // &
509
-                  "fzf --height=40% --reverse --border --preview 'ls -lh {}' " // &
510
-                  "> " // trim(temp_file) // " 2>/dev/null"
511
-
512
-        ! Run fzf
513
-        call execute_command_line(trim(fzf_cmd), exitstat=stat, wait=.true.)
514
-
515
-        ! Restore raw mode
516
-        call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null")
517
-
518
-        ! Read result if fzf succeeded
519
-        if (stat == 0) then
520
-            open(newunit=unit, file=temp_file, status='old', iostat=ios)
521
-            if (ios == 0) then
522
-                read(unit, '(a)', iostat=ios) result_path
523
-                if (ios == 0) then
524
-                    ! Convert relative path to absolute
525
-                    result_path = join_path(search_dir, result_path)
526
-                end if
527
-                close(unit)
528
-            end if
529
-        end if
530
-
531
-        ! Cleanup
532
-        call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
533
-    end subroutine fzf_search
534
-
535
-    function find_file_in_list(target_path, files, count) result(idx)
536
-        character(len=*), intent(in) :: target_path
537
-        character(len=*), dimension(*), intent(in) :: files
538
-        integer, intent(in) :: count
539
-        integer :: idx, pos
540
-        character(len=MAX_PATH) :: basename
541
-
542
-        ! Extract basename from target_path
543
-        pos = index(target_path, "/", back=.true.)
544
-        if (pos > 0) then
545
-            basename = target_path(pos+1:)
546
-        else
547
-            basename = target_path
548
-        end if
549
-
550
-        ! Search for the file in the list
551
-        do idx = 1, count
552
-            if (trim(files(idx)) == trim(basename)) return
553
-        end do
554
-
555
-        ! Default to first item if not found
556
-        idx = 1
557
-    end function find_file_in_list
558
-
559
-    subroutine detect_git_repo(dir, is_git, repo, branch)
560
-        character(len=*), intent(in) :: dir
561
-        logical, intent(out) :: is_git
562
-        character(len=*), intent(out) :: repo, branch
563
-        integer :: stat
564
-        character(len=MAX_PATH) :: temp_file
565
-
566
-        is_git = .false.
567
-        repo = ""
568
-        branch = ""
569
-
570
-        ! Check if .git directory exists
571
-        call execute_command_line("git -C '" // trim(dir) // "' rev-parse --git-dir > /dev/null 2>&1", &
572
-                                  exitstat=stat, wait=.true.)
573
-        is_git = (stat == 0)
574
-
575
-        if (is_git) then
576
-            ! Get repo name (basename of repo root)
577
-            call get_environment_variable("HOME", temp_file)
578
-            temp_file = trim(temp_file) // "/.fortress_repo"
579
-            call execute_command_line("git -C '" // trim(dir) // "' rev-parse --show-toplevel 2>/dev/null | " // &
580
-                                     "xargs basename > " // trim(temp_file), wait=.true.)
581
-            open(newunit=stat, file=temp_file, status='old', iostat=i)
582
-            if (i == 0) then
583
-                read(stat, '(a)', iostat=i) repo
584
-                close(stat)
585
-            end if
586
-            call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
587
-
588
-            ! Get current branch name
589
-            temp_file = trim(temp_file) // "_branch"
590
-            call execute_command_line("git -C '" // trim(dir) // "' rev-parse --abbrev-ref HEAD 2>/dev/null > " // &
591
-                                     trim(temp_file), wait=.true.)
592
-            open(newunit=stat, file=temp_file, status='old', iostat=i)
593
-            if (i == 0) then
594
-                read(stat, '(a)', iostat=i) branch
595
-                close(stat)
596
-            end if
597
-            call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
598
-        end if
599
-    end subroutine detect_git_repo
600
-
601
-    subroutine get_git_status(dir, files, count, is_staged, is_unstaged, is_untracked)
602
-        character(len=*), intent(in) :: dir
603
-        character(len=*), dimension(*), intent(in) :: files
604
-        integer, intent(in) :: count
605
-        logical, dimension(*), intent(out) :: is_staged, is_unstaged, is_untracked
606
-        character(len=MAX_PATH) :: temp_file, line, file_path, git_status
607
-        integer :: unit, ios, stat, i, j
608
-        character(len=MAX_PATH) :: full_path
609
-
610
-        ! Initialize all to false
611
-        do i = 1, count
612
-            is_staged(i) = .false.
613
-            is_unstaged(i) = .false.
614
-            is_untracked(i) = .false.
615
-        end do
616
-
617
-        ! Get git status
618
-        call get_environment_variable("HOME", temp_file)
619
-        temp_file = trim(temp_file) // "/.fortress_git_status"
620
-        call execute_command_line("cd '" // trim(dir) // "' && git status --porcelain 2>/dev/null > " // &
621
-                                 trim(temp_file), exitstat=stat, wait=.true.)
622
-
623
-        if (stat /= 0) return
624
-
625
-        ! Parse git status output
626
-        open(newunit=unit, file=temp_file, status='old', iostat=ios)
627
-        if (ios /= 0) return
628
-
629
-        do
630
-            read(unit, '(a)', iostat=ios) line
631
-            if (ios /= 0) exit
632
-
633
-            if (len_trim(line) > 3) then
634
-                git_status = line(1:2)
635
-                file_path = trim(adjustl(line(4:)))
636
-
637
-                ! Match against our file list
638
-                do i = 1, count
639
-                    if (trim(files(i)) == trim(file_path)) then
640
-                        ! Parse git status (XY format)
641
-                        is_untracked(i) = (git_status == '??')
642
-                        is_staged(i) = (git_status(1:1) /= ' ' .and. git_status(1:1) /= '?')
643
-                        is_unstaged(i) = (git_status(2:2) /= ' ' .and. .not. is_untracked(i))
644
-                        exit
645
-                    end if
646
-                end do
647
-            end if
648
-        end do
649
-
650
-        close(unit)
651
-        call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
652
-    end subroutine get_git_status
653
-
654
-    subroutine write_git_indicators(staged, unstaged, untracked, highlighted)
655
-        logical, intent(in) :: staged, unstaged, untracked, highlighted
656
-
657
-        ! Write indicators without RESET (caller handles that)
658
-        if (staged) then
659
-            if (highlighted) then
660
-                write(output_unit, '(a)', advance='no') GREEN // " ↑"
661
-            else
662
-                write(output_unit, '(a)', advance='no') GREEN // " ↑" // RESET
663
-            end if
664
-        end if
665
-        if (unstaged) then
666
-            if (highlighted) then
667
-                write(output_unit, '(a)', advance='no') RED // " ✗"
668
-            else
669
-                write(output_unit, '(a)', advance='no') RED // " ✗" // RESET
670
-            end if
671
-        end if
672
-        if (untracked) then
673
-            if (highlighted) then
674
-                write(output_unit, '(a)', advance='no') GREY // " ✗"
675
-            else
676
-                write(output_unit, '(a)', advance='no') GREY // " ✗" // RESET
677
-            end if
678
-        end if
679
-    end subroutine write_git_indicators
680
-
681
-    subroutine git_add_file(dir, filename)
682
-        character(len=*), intent(in) :: dir, filename
683
-        character(len=MAX_PATH*2) :: git_cmd
684
-        integer :: stat
685
-
686
-        ! Build git add command
687
-        git_cmd = "cd '" // trim(dir) // "' && git add '" // trim(filename) // "' 2>/dev/null"
688
-        call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
689
-
690
-        ! Note: git status will be refreshed in the next main loop iteration
691
-    end subroutine git_add_file
692
-
693
-    subroutine git_unstage_file(dir, filename)
694
-        character(len=*), intent(in) :: dir, filename
695
-        character(len=MAX_PATH*2) :: git_cmd
696
-        integer :: stat
697
-
698
-        ! Build git restore --staged command
699
-        git_cmd = "cd '" // trim(dir) // "' && git restore --staged '" // trim(filename) // "' 2>/dev/null"
700
-        call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
701
-
702
-        ! Note: git status will be refreshed in the next main loop iteration
703
-    end subroutine git_unstage_file
704
-
705
-    subroutine git_commit_prompt(dir)
706
-        character(len=*), intent(in) :: dir
707
-        character(len=512) :: commit_msg
708
-        character(len=MAX_PATH*2) :: git_cmd
709
-        integer :: stat, ios
710
-
711
-        ! Clear screen and show prompt
712
-        write(output_unit, '(a)', advance='no') CLEAR
713
-        write(output_unit, '(a)', advance='no') BOLD // "Git Commit" // RESET // " - " // trim(repo_name)
714
-        write(output_unit, *)
715
-        write(output_unit, *)
716
-        write(output_unit, '(a)', advance='no') "Commit message: "
717
-
718
-        ! Restore terminal to canonical mode for reading input
719
-        call execute_command_line("stty icanon echo 2>/dev/null")
720
-
721
-        ! Read commit message
722
-        read(*, '(a)', iostat=ios) commit_msg
723
-
724
-        ! Restore raw mode
725
-        call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null")
726
-
727
-        if (ios == 0 .and. len_trim(commit_msg) > 0) then
728
-            ! Execute git commit (use single quotes for message to avoid escaping issues)
729
-            git_cmd = "cd '" // trim(dir) // "' && git commit -m '" // trim(commit_msg) // "' 2>&1"
730
-            call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
731
-
732
-            ! Show result briefly
733
-            write(output_unit, *)
734
-            if (stat == 0) then
735
-                write(output_unit, '(a)') GREEN // "✓ Committed successfully!" // RESET
736
-            else
737
-                write(output_unit, '(a)') RED // "✗ Commit failed (nothing to commit?)" // RESET
738
-            end if
739
-            write(output_unit, '(a)') "Press any key to continue..."
740
-
741
-            ! Wait for keypress
742
-            read(*, '(a1)', advance='no') key
743
-        end if
744
-    end subroutine git_commit_prompt
745
-
746
-end program fortress_clean
187
+end program fortress
app/main.f90 → app/main.f90.backupcopied (100% similarity)
src/filesystem/fs_ops.f90added
@@ -0,0 +1,203 @@
1
+module filesystem_ops
2
+    implicit none
3
+    private
4
+
5
+    public :: get_file_list, get_pwd, get_parent_path, join_path
6
+    public :: find_in_parent, find_file_in_list, fzf_search, write_exit_dir
7
+    public :: MAX_PATH, MAX_FILES
8
+
9
+    integer, parameter :: MAX_PATH = 512
10
+    integer, parameter :: MAX_FILES = 500
11
+
12
+contains
13
+
14
+    subroutine get_file_list(dir, files, is_dir, is_exec, count)
15
+        character(len=*), intent(in) :: dir
16
+        character(len=*), dimension(*), intent(out) :: files
17
+        logical, dimension(*), intent(out) :: is_dir, is_exec
18
+        integer, intent(out) :: count
19
+        integer :: unit, ios, stat
20
+        character(len=MAX_PATH) :: fullpath
21
+
22
+        call execute_command_line("ls -1a '" // trim(dir) // "' > .fortress_ls 2>/dev/null", wait=.true.)
23
+
24
+        open(newunit=unit, file=".fortress_ls", status='old', iostat=ios)
25
+        if (ios /= 0) then
26
+            count = 0
27
+            return
28
+        end if
29
+
30
+        count = 0
31
+        do
32
+            count = count + 1
33
+            if (count > MAX_FILES) exit
34
+            read(unit, '(a)', iostat=ios) files(count)
35
+            if (ios /= 0) then
36
+                count = count - 1
37
+                exit
38
+            end if
39
+
40
+            fullpath = join_path(dir, files(count))
41
+            call execute_command_line("test -d '" // trim(fullpath) // "'", exitstat=stat, wait=.true.)
42
+            is_dir(count) = (stat == 0)
43
+
44
+            ! Check if executable (but not directories)
45
+            if (.not. is_dir(count)) then
46
+                call execute_command_line("test -x '" // trim(fullpath) // "'", exitstat=stat, wait=.true.)
47
+                is_exec(count) = (stat == 0)
48
+            else
49
+                is_exec(count) = .false.
50
+            end if
51
+        end do
52
+
53
+        close(unit)
54
+        call execute_command_line("rm -f .fortress_ls 2>/dev/null")
55
+    end subroutine get_file_list
56
+
57
+    function get_pwd() result(path)
58
+        character(len=MAX_PATH) :: path
59
+        integer :: unit, ios
60
+
61
+        call execute_command_line("pwd > .fortress_pwd 2>/dev/null", wait=.true.)
62
+        open(newunit=unit, file=".fortress_pwd", status='old', iostat=ios)
63
+        if (ios == 0) then
64
+            read(unit, '(a)') path
65
+            close(unit)
66
+        else
67
+            path = "."
68
+        end if
69
+        call execute_command_line("rm -f .fortress_pwd 2>/dev/null")
70
+    end function get_pwd
71
+
72
+    function get_parent_path(path) result(parent)
73
+        character(len=*), intent(in) :: path
74
+        character(len=MAX_PATH) :: parent
75
+        integer :: pos
76
+
77
+        pos = index(path, "/", back=.true.)
78
+        if (pos > 1) then
79
+            parent = path(1:pos-1)
80
+        else if (pos == 1) then
81
+            parent = "/"
82
+        else
83
+            parent = "."
84
+        end if
85
+    end function get_parent_path
86
+
87
+    function join_path(base, name) result(full)
88
+        character(len=*), intent(in) :: base, name
89
+        character(len=MAX_PATH) :: full
90
+
91
+        if (base == "/") then
92
+            full = "/" // trim(name)
93
+        else
94
+            full = trim(base) // "/" // trim(name)
95
+        end if
96
+    end function join_path
97
+
98
+    function find_in_parent(dir, files, count) result(idx)
99
+        character(len=*), intent(in) :: dir
100
+        character(len=*), dimension(*), intent(in) :: files
101
+        integer, intent(in) :: count
102
+        integer :: idx, pos
103
+        character(len=256) :: basename
104
+
105
+        pos = index(dir, "/", back=.true.)
106
+        if (pos > 0) then
107
+            basename = dir(pos+1:)
108
+        else
109
+            basename = dir
110
+        end if
111
+
112
+        do idx = 1, count
113
+            if (trim(files(idx)) == trim(basename)) return
114
+        end do
115
+        idx = 1
116
+    end function find_in_parent
117
+
118
+    function find_file_in_list(target_path, files, count) result(idx)
119
+        character(len=*), intent(in) :: target_path
120
+        character(len=*), dimension(*), intent(in) :: files
121
+        integer, intent(in) :: count
122
+        integer :: idx, pos
123
+        character(len=MAX_PATH) :: basename
124
+
125
+        ! Extract basename from target_path
126
+        pos = index(target_path, "/", back=.true.)
127
+        if (pos > 0) then
128
+            basename = target_path(pos+1:)
129
+        else
130
+            basename = target_path
131
+        end if
132
+
133
+        ! Search for the file in the list
134
+        do idx = 1, count
135
+            if (trim(files(idx)) == trim(basename)) return
136
+        end do
137
+
138
+        ! Default to first item if not found
139
+        idx = 1
140
+    end function find_file_in_list
141
+
142
+    subroutine fzf_search(search_dir, result_path)
143
+        character(len=*), intent(in) :: search_dir
144
+        character(len=*), intent(out) :: result_path
145
+        character(len=MAX_PATH) :: temp_file, fzf_cmd
146
+        integer :: unit, ios, stat
147
+
148
+        result_path = ""
149
+
150
+        ! Create temp file for fzf output
151
+        call get_environment_variable("HOME", temp_file)
152
+        temp_file = trim(temp_file) // "/.fortress_fzf"
153
+
154
+        ! Restore terminal for fzf
155
+        call execute_command_line("stty icanon echo 2>/dev/null")
156
+
157
+        ! Build fzf command: find files, pipe to fzf, save selection
158
+        fzf_cmd = "cd '" // trim(search_dir) // "' && " // &
159
+                  "find . -type f -o -type d | " // &
160
+                  "sed 's|^\./||' | " // &
161
+                  "fzf --height=40% --reverse --border --preview 'ls -lh {}' " // &
162
+                  "> " // trim(temp_file) // " 2>/dev/null"
163
+
164
+        ! Run fzf
165
+        call execute_command_line(trim(fzf_cmd), exitstat=stat, wait=.true.)
166
+
167
+        ! Restore raw mode
168
+        call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null")
169
+
170
+        ! Read result if fzf succeeded
171
+        if (stat == 0) then
172
+            open(newunit=unit, file=temp_file, status='old', iostat=ios)
173
+            if (ios == 0) then
174
+                read(unit, '(a)', iostat=ios) result_path
175
+                if (ios == 0) then
176
+                    ! Convert relative path to absolute
177
+                    result_path = join_path(search_dir, result_path)
178
+                end if
179
+                close(unit)
180
+            end if
181
+        end if
182
+
183
+        ! Cleanup
184
+        call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
185
+    end subroutine fzf_search
186
+
187
+    subroutine write_exit_dir(dir)
188
+        character(len=*), intent(in) :: dir
189
+        character(len=MAX_PATH) :: temp_file
190
+        integer :: unit, ios
191
+
192
+        ! Create temp file in HOME directory
193
+        call get_environment_variable("HOME", temp_file)
194
+        temp_file = trim(temp_file) // "/.fortress_cd"
195
+
196
+        open(newunit=unit, file=temp_file, status='replace', action='write', iostat=ios)
197
+        if (ios == 0) then
198
+            write(unit, '(a)') trim(dir)
199
+            close(unit)
200
+        end if
201
+    end subroutine write_exit_dir
202
+
203
+end module filesystem_ops
src/git/git_ops.f90added
@@ -0,0 +1,196 @@
1
+module git_ops
2
+    use iso_fortran_env, only: output_unit
3
+    use terminal_control, only: GREEN, RED, GREY, RESET, BOLD, CLEAR
4
+    use filesystem_ops, only: MAX_PATH
5
+    implicit none
6
+    private
7
+
8
+    public :: detect_git_repo, get_git_status, write_git_indicators
9
+    public :: git_add_file, git_unstage_file, git_commit_prompt
10
+
11
+contains
12
+
13
+    subroutine detect_git_repo(dir, is_git, repo, branch)
14
+        character(len=*), intent(in) :: dir
15
+        logical, intent(out) :: is_git
16
+        character(len=*), intent(out) :: repo, branch
17
+        integer :: stat, i
18
+        character(len=MAX_PATH) :: temp_file
19
+
20
+        is_git = .false.
21
+        repo = ""
22
+        branch = ""
23
+
24
+        ! Check if .git directory exists
25
+        call execute_command_line("git -C '" // trim(dir) // "' rev-parse --git-dir > /dev/null 2>&1", &
26
+                                  exitstat=stat, wait=.true.)
27
+        is_git = (stat == 0)
28
+
29
+        if (is_git) then
30
+            ! Get repo name (basename of repo root)
31
+            call get_environment_variable("HOME", temp_file)
32
+            temp_file = trim(temp_file) // "/.fortress_repo"
33
+            call execute_command_line("git -C '" // trim(dir) // "' rev-parse --show-toplevel 2>/dev/null | " // &
34
+                                     "xargs basename > " // trim(temp_file), wait=.true.)
35
+            open(newunit=stat, file=temp_file, status='old', iostat=i)
36
+            if (i == 0) then
37
+                read(stat, '(a)', iostat=i) repo
38
+                close(stat)
39
+            end if
40
+            call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
41
+
42
+            ! Get current branch name
43
+            temp_file = trim(temp_file) // "_branch"
44
+            call execute_command_line("git -C '" // trim(dir) // "' rev-parse --abbrev-ref HEAD 2>/dev/null > " // &
45
+                                     trim(temp_file), wait=.true.)
46
+            open(newunit=stat, file=temp_file, status='old', iostat=i)
47
+            if (i == 0) then
48
+                read(stat, '(a)', iostat=i) branch
49
+                close(stat)
50
+            end if
51
+            call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
52
+        end if
53
+    end subroutine detect_git_repo
54
+
55
+    subroutine get_git_status(dir, files, count, is_staged, is_unstaged, is_untracked)
56
+        character(len=*), intent(in) :: dir
57
+        character(len=*), dimension(*), intent(in) :: files
58
+        integer, intent(in) :: count
59
+        logical, dimension(*), intent(out) :: is_staged, is_unstaged, is_untracked
60
+        character(len=MAX_PATH) :: temp_file, line, file_path, git_status
61
+        integer :: unit, ios, stat, i
62
+
63
+        ! Initialize all to false
64
+        do i = 1, count
65
+            is_staged(i) = .false.
66
+            is_unstaged(i) = .false.
67
+            is_untracked(i) = .false.
68
+        end do
69
+
70
+        ! Get git status
71
+        call get_environment_variable("HOME", temp_file)
72
+        temp_file = trim(temp_file) // "/.fortress_git_status"
73
+        call execute_command_line("cd '" // trim(dir) // "' && git status --porcelain 2>/dev/null > " // &
74
+                                 trim(temp_file), exitstat=stat, wait=.true.)
75
+
76
+        if (stat /= 0) return
77
+
78
+        ! Parse git status output
79
+        open(newunit=unit, file=temp_file, status='old', iostat=ios)
80
+        if (ios /= 0) return
81
+
82
+        do
83
+            read(unit, '(a)', iostat=ios) line
84
+            if (ios /= 0) exit
85
+
86
+            if (len_trim(line) > 3) then
87
+                git_status = line(1:2)
88
+                file_path = trim(adjustl(line(4:)))
89
+
90
+                ! Match against our file list
91
+                do i = 1, count
92
+                    if (trim(files(i)) == trim(file_path)) then
93
+                        ! Parse git status (XY format)
94
+                        is_untracked(i) = (git_status == '??')
95
+                        is_staged(i) = (git_status(1:1) /= ' ' .and. git_status(1:1) /= '?')
96
+                        is_unstaged(i) = (git_status(2:2) /= ' ' .and. .not. is_untracked(i))
97
+                        exit
98
+                    end if
99
+                end do
100
+            end if
101
+        end do
102
+
103
+        close(unit)
104
+        call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null")
105
+    end subroutine get_git_status
106
+
107
+    subroutine write_git_indicators(staged, unstaged, untracked, highlighted)
108
+        logical, intent(in) :: staged, unstaged, untracked, highlighted
109
+
110
+        ! Write indicators without RESET (caller handles that)
111
+        if (staged) then
112
+            if (highlighted) then
113
+                write(output_unit, '(a)', advance='no') GREEN // " ↑"
114
+            else
115
+                write(output_unit, '(a)', advance='no') GREEN // " ↑" // RESET
116
+            end if
117
+        end if
118
+        if (unstaged) then
119
+            if (highlighted) then
120
+                write(output_unit, '(a)', advance='no') RED // " ✗"
121
+            else
122
+                write(output_unit, '(a)', advance='no') RED // " ✗" // RESET
123
+            end if
124
+        end if
125
+        if (untracked) then
126
+            if (highlighted) then
127
+                write(output_unit, '(a)', advance='no') GREY // " ✗"
128
+            else
129
+                write(output_unit, '(a)', advance='no') GREY // " ✗" // RESET
130
+            end if
131
+        end if
132
+    end subroutine write_git_indicators
133
+
134
+    subroutine git_add_file(dir, filename)
135
+        character(len=*), intent(in) :: dir, filename
136
+        character(len=MAX_PATH*2) :: git_cmd
137
+        integer :: stat
138
+
139
+        ! Build git add command
140
+        git_cmd = "cd '" // trim(dir) // "' && git add '" // trim(filename) // "' 2>/dev/null"
141
+        call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
142
+    end subroutine git_add_file
143
+
144
+    subroutine git_unstage_file(dir, filename)
145
+        character(len=*), intent(in) :: dir, filename
146
+        character(len=MAX_PATH*2) :: git_cmd
147
+        integer :: stat
148
+
149
+        ! Build git restore --staged command
150
+        git_cmd = "cd '" // trim(dir) // "' && git restore --staged '" // trim(filename) // "' 2>/dev/null"
151
+        call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
152
+    end subroutine git_unstage_file
153
+
154
+    subroutine git_commit_prompt(dir, repo_name)
155
+        character(len=*), intent(in) :: dir, repo_name
156
+        character(len=512) :: commit_msg
157
+        character(len=MAX_PATH*2) :: git_cmd
158
+        character(len=1) :: key
159
+        integer :: stat, ios
160
+
161
+        ! Clear screen and show prompt
162
+        write(output_unit, '(a)', advance='no') CLEAR
163
+        write(output_unit, '(a)', advance='no') BOLD // "Git Commit" // RESET // " - " // trim(repo_name)
164
+        write(output_unit, *)
165
+        write(output_unit, *)
166
+        write(output_unit, '(a)', advance='no') "Commit message: "
167
+
168
+        ! Restore terminal to canonical mode for reading input
169
+        call execute_command_line("stty icanon echo 2>/dev/null")
170
+
171
+        ! Read commit message
172
+        read(*, '(a)', iostat=ios) commit_msg
173
+
174
+        ! Restore raw mode
175
+        call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null")
176
+
177
+        if (ios == 0 .and. len_trim(commit_msg) > 0) then
178
+            ! Execute git commit (use single quotes for message to avoid escaping issues)
179
+            git_cmd = "cd '" // trim(dir) // "' && git commit -m '" // trim(commit_msg) // "' 2>&1"
180
+            call execute_command_line(trim(git_cmd), exitstat=stat, wait=.true.)
181
+
182
+            ! Show result briefly
183
+            write(output_unit, *)
184
+            if (stat == 0) then
185
+                write(output_unit, '(a)') GREEN // "✓ Committed successfully!" // RESET
186
+            else
187
+                write(output_unit, '(a)') RED // "✗ Commit failed (nothing to commit?)" // RESET
188
+            end if
189
+            write(output_unit, '(a)') "Press any key to continue..."
190
+
191
+            ! Wait for keypress
192
+            read(*, '(a1)', advance='no') key
193
+        end if
194
+    end subroutine git_commit_prompt
195
+
196
+end module git_ops
src/terminal/term_control.f90added
@@ -0,0 +1,70 @@
1
+module terminal_control
2
+    use iso_fortran_env, only: output_unit
3
+    implicit none
4
+    private
5
+
6
+    public :: get_term_size, setup_raw_mode, restore_terminal, read_arrow_key
7
+    public :: ESC, CLEAR, BOLD, DIM, REVERSE, RESET
8
+    public :: BLUE, GREEN, RED, GREY, WHITE
9
+
10
+    ! ANSI escape codes
11
+    character(len=*), parameter :: ESC = char(27)
12
+    character(len=*), parameter :: CLEAR = ESC // "[2J" // ESC // "[H"
13
+    character(len=*), parameter :: BOLD = ESC // "[1m"
14
+    character(len=*), parameter :: DIM = ESC // "[2m"
15
+    character(len=*), parameter :: REVERSE = ESC // "[7m"
16
+    character(len=*), parameter :: RESET = ESC // "[0m"
17
+    character(len=*), parameter :: BLUE = ESC // "[34m"
18
+    character(len=*), parameter :: GREEN = ESC // "[32m"
19
+    character(len=*), parameter :: RED = ESC // "[31m"
20
+    character(len=*), parameter :: GREY = ESC // "[90m"
21
+    character(len=*), parameter :: WHITE = ESC // "[37m"
22
+
23
+contains
24
+
25
+    subroutine get_term_size(r, c)
26
+        integer, intent(out) :: r, c
27
+        integer :: unit, ios
28
+
29
+        call execute_command_line("tput lines > .fortress_size 2>/dev/null", wait=.true.)
30
+        open(newunit=unit, file=".fortress_size", status='old', iostat=ios)
31
+        if (ios == 0) then
32
+            read(unit, *) r
33
+            close(unit)
34
+        else
35
+            r = 24
36
+        end if
37
+
38
+        call execute_command_line("tput cols > .fortress_size 2>/dev/null", wait=.true.)
39
+        open(newunit=unit, file=".fortress_size", status='old', iostat=ios)
40
+        if (ios == 0) then
41
+            read(unit, *) c
42
+            close(unit)
43
+        else
44
+            c = 80
45
+        end if
46
+
47
+        call execute_command_line("rm -f .fortress_size 2>/dev/null")
48
+    end subroutine get_term_size
49
+
50
+    subroutine setup_raw_mode()
51
+        call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null")
52
+    end subroutine setup_raw_mode
53
+
54
+    subroutine restore_terminal()
55
+        call execute_command_line("stty icanon echo 2>/dev/null")
56
+    end subroutine restore_terminal
57
+
58
+    subroutine read_arrow_key(k)
59
+        character(len=1), intent(out) :: k
60
+        character(len=1) :: ch
61
+
62
+        read(*, '(a1)', advance='no') ch
63
+        if (ch == '[') then
64
+            read(*, '(a1)', advance='no') k
65
+        else
66
+            k = ch
67
+        end if
68
+    end subroutine read_arrow_key
69
+
70
+end module terminal_control
src/ui/display.f90added
@@ -0,0 +1,129 @@
1
+module ui_display
2
+    use iso_fortran_env, only: output_unit
3
+    use terminal_control
4
+    use git_ops, only: write_git_indicators
5
+    use filesystem_ops, only: MAX_PATH, MAX_FILES
6
+    implicit none
7
+    private
8
+
9
+    public :: draw_interface, get_file_color
10
+
11
+contains
12
+
13
+    subroutine draw_interface(r, c, current_dir, current_files, current_is_dir, current_is_exec, &
14
+                              current_is_staged, current_is_unstaged, current_is_untracked, current_count, &
15
+                              parent_files, parent_is_dir, parent_is_exec, parent_count, &
16
+                              selected, parent_selected, scroll_offset, parent_scroll_offset, &
17
+                              in_git_repo, repo_name, branch_name)
18
+        integer, intent(in) :: r, c, current_count, parent_count, selected, parent_selected
19
+        integer, intent(in) :: scroll_offset, parent_scroll_offset
20
+        character(len=*), intent(in) :: current_dir, repo_name, branch_name
21
+        character(len=*), dimension(*), intent(in) :: current_files, parent_files
22
+        logical, dimension(*), intent(in) :: current_is_dir, parent_is_dir
23
+        logical, dimension(*), intent(in) :: current_is_exec, parent_is_exec
24
+        logical, dimension(*), intent(in) :: current_is_staged, current_is_unstaged, current_is_untracked
25
+        logical, intent(in) :: in_git_repo
26
+        integer :: left_w, i, parent_idx, current_idx, vis_h
27
+        character(len=256) :: fname
28
+        character(len=20) :: color_code
29
+
30
+        left_w = c * 3 / 10
31
+        vis_h = r - 3  ! Visible height
32
+
33
+        ! Header
34
+        write(output_unit, '(a)') BOLD // "FORTRESS" // RESET // " - " // trim(current_dir)
35
+
36
+        ! Files (render based on scroll offsets)
37
+        do i = 1, vis_h
38
+            parent_idx = i + parent_scroll_offset
39
+            current_idx = i + scroll_offset
40
+
41
+            ! Parent pane
42
+            if (parent_idx >= 1 .and. parent_idx <= parent_count) then
43
+                fname = parent_files(parent_idx)
44
+                if (parent_is_dir(parent_idx) .and. fname /= "." .and. fname /= "..") then
45
+                    fname = trim(fname) // "/"
46
+                end if
47
+
48
+                ! Get color for parent file
49
+                color_code = get_file_color(parent_files(parent_idx), parent_is_dir(parent_idx), parent_is_exec(parent_idx))
50
+
51
+                if (parent_idx == parent_selected) then
52
+                    write(output_unit, '(a)', advance='no') DIM // BOLD // trim(color_code) // &
53
+                        fname(1:min(len_trim(fname),left_w)) // RESET
54
+                else
55
+                    write(output_unit, '(a)', advance='no') DIM // trim(color_code) // &
56
+                        fname(1:min(len_trim(fname),left_w)) // RESET
57
+                end if
58
+                write(output_unit, '(a)', advance='no') repeat(" ", max(0, left_w - len_trim(fname)))
59
+            else
60
+                write(output_unit, '(a)', advance='no') repeat(" ", left_w)
61
+            end if
62
+
63
+            ! Separator
64
+            write(output_unit, '(a)', advance='no') " │ "
65
+
66
+            ! Current pane
67
+            if (current_idx >= 1 .and. current_idx <= current_count) then
68
+                fname = current_files(current_idx)
69
+                if (current_is_dir(current_idx) .and. fname /= "." .and. fname /= "..") then
70
+                    fname = trim(fname) // "/"
71
+                end if
72
+
73
+                ! Get color for current file
74
+                color_code = get_file_color(current_files(current_idx), current_is_dir(current_idx), current_is_exec(current_idx))
75
+
76
+                if (current_idx == selected) then
77
+                    write(output_unit, '(a)', advance='no') REVERSE // trim(color_code) // trim(fname)
78
+                    ! Add git indicators if in repo
79
+                    if (in_git_repo) then
80
+                        call write_git_indicators(current_is_staged(current_idx), &
81
+                                                  current_is_unstaged(current_idx), &
82
+                                                  current_is_untracked(current_idx), .true.)
83
+                    end if
84
+                    write(output_unit, '(a)') RESET
85
+                else
86
+                    write(output_unit, '(a)', advance='no') trim(color_code) // trim(fname)
87
+                    ! Add git indicators if in repo
88
+                    if (in_git_repo) then
89
+                        call write_git_indicators(current_is_staged(current_idx), &
90
+                                                  current_is_unstaged(current_idx), &
91
+                                                  current_is_untracked(current_idx), .false.)
92
+                    end if
93
+                    write(output_unit, '(a)') RESET
94
+                end if
95
+            else
96
+                write(output_unit, *)
97
+            end if
98
+        end do
99
+
100
+        ! Footer
101
+        if (in_git_repo) then
102
+            write(output_unit, '(a)') DIM // trim(repo_name) // ":" // trim(branch_name) // " | " // RESET // &
103
+                                     DIM // "↑↓:nav →:enter ←:back f:find A:add U:unstage M:commit c:cd q:quit" // RESET
104
+        else
105
+            write(output_unit, '(a)') DIM // "↑↓:nav →:enter ←:back f:find c:cd q:quit" // RESET
106
+        end if
107
+    end subroutine draw_interface
108
+
109
+    function get_file_color(filename, is_dir, is_exec) result(color)
110
+        character(len=*), intent(in) :: filename
111
+        logical, intent(in) :: is_dir, is_exec
112
+        character(len=20) :: color
113
+
114
+        ! Directories: Blue and bold
115
+        if (is_dir) then
116
+            color = BOLD // BLUE
117
+        ! Dotfiles: Grey
118
+        else if (filename(1:1) == '.') then
119
+            color = GREY
120
+        ! Executable files: Green
121
+        else if (is_exec) then
122
+            color = GREEN
123
+        ! All other files: White
124
+        else
125
+            color = WHITE
126
+        end if
127
+    end function get_file_color
128
+
129
+end module ui_display