| 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 :: open_file_in_default_app |
| 8 | public :: rename_file_prompt |
| 9 | public :: MAX_PATH, MAX_FILES |
| 10 | |
| 11 | integer, parameter :: MAX_PATH = 512 |
| 12 | integer, parameter :: MAX_FILES = 500 |
| 13 | |
| 14 | contains |
| 15 | |
| 16 | subroutine get_file_list(dir, files, is_dir, is_exec, count) |
| 17 | character(len=*), intent(in) :: dir |
| 18 | character(len=*), dimension(*), intent(out) :: files |
| 19 | logical, dimension(*), intent(out) :: is_dir, is_exec |
| 20 | integer, intent(out) :: count |
| 21 | integer :: unit, ios, i, stat_code |
| 22 | character(len=MAX_PATH) :: temp_file, stat_file |
| 23 | character(len=MAX_PATH) :: line, filename, file_type, fullpath |
| 24 | |
| 25 | ! Get list of files |
| 26 | call get_environment_variable("HOME", temp_file) |
| 27 | temp_file = trim(temp_file) // "/.fortress_ls" |
| 28 | stat_file = trim(temp_file) // "_stat" |
| 29 | |
| 30 | call execute_command_line("ls -1a '" // trim(dir) // "' > " // trim(temp_file) // " 2>/dev/null", wait=.true.) |
| 31 | |
| 32 | open(newunit=unit, file=temp_file, status='old', iostat=ios) |
| 33 | if (ios /= 0) then |
| 34 | count = 0 |
| 35 | call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null") |
| 36 | return |
| 37 | end if |
| 38 | |
| 39 | ! Read all filenames first |
| 40 | count = 0 |
| 41 | do |
| 42 | count = count + 1 |
| 43 | if (count > MAX_FILES) exit |
| 44 | read(unit, '(a)', iostat=ios) files(count) |
| 45 | if (ios /= 0) then |
| 46 | count = count - 1 |
| 47 | exit |
| 48 | end if |
| 49 | end do |
| 50 | close(unit) |
| 51 | |
| 52 | ! Now check file attributes - use simpler approach with stat via ls |
| 53 | ! Generate a script that checks each file and outputs "filename:type" |
| 54 | call execute_command_line("cd '" // trim(dir) // "' && " // & |
| 55 | "for f in $(ls -1a 2>/dev/null); do " // & |
| 56 | " if [ -d ""$f"" ]; then echo ""$f:d""; " // & |
| 57 | " elif [ -x ""$f"" ] && [ ! -d ""$f"" ]; then echo ""$f:x""; " // & |
| 58 | " else echo ""$f:f""; fi; " // & |
| 59 | "done > " // trim(stat_file) // " 2>/dev/null", wait=.true.) |
| 60 | |
| 61 | ! Initialize all as regular non-executable files |
| 62 | do i = 1, count |
| 63 | is_dir(i) = .false. |
| 64 | is_exec(i) = .false. |
| 65 | end do |
| 66 | |
| 67 | ! Read the stat results and update file types |
| 68 | open(newunit=unit, file=stat_file, status='old', iostat=ios) |
| 69 | if (ios == 0) then |
| 70 | do |
| 71 | read(unit, '(a)', iostat=ios) line |
| 72 | if (ios /= 0) exit |
| 73 | |
| 74 | ! Parse "filename:type" format |
| 75 | stat_code = index(line, ':', back=.true.) |
| 76 | if (stat_code > 0) then |
| 77 | filename = line(1:stat_code-1) |
| 78 | file_type = line(stat_code+1:stat_code+1) |
| 79 | |
| 80 | ! Find this file in our list and update its type |
| 81 | do i = 1, count |
| 82 | if (trim(files(i)) == trim(filename)) then |
| 83 | if (file_type == 'd') then |
| 84 | is_dir(i) = .true. |
| 85 | is_exec(i) = .false. |
| 86 | else if (file_type == 'x') then |
| 87 | is_dir(i) = .false. |
| 88 | is_exec(i) = .true. |
| 89 | else |
| 90 | is_dir(i) = .false. |
| 91 | is_exec(i) = .false. |
| 92 | end if |
| 93 | exit |
| 94 | end if |
| 95 | end do |
| 96 | end if |
| 97 | end do |
| 98 | close(unit) |
| 99 | end if |
| 100 | |
| 101 | ! Cleanup temp files |
| 102 | call execute_command_line("rm -f " // trim(temp_file) // " " // trim(stat_file) // " 2>/dev/null") |
| 103 | end subroutine get_file_list |
| 104 | |
| 105 | function get_pwd() result(path) |
| 106 | character(len=MAX_PATH) :: path |
| 107 | integer :: unit, ios |
| 108 | |
| 109 | call execute_command_line("pwd > .fortress_pwd 2>/dev/null", wait=.true.) |
| 110 | open(newunit=unit, file=".fortress_pwd", status='old', iostat=ios) |
| 111 | if (ios == 0) then |
| 112 | read(unit, '(a)') path |
| 113 | close(unit) |
| 114 | else |
| 115 | path = "." |
| 116 | end if |
| 117 | call execute_command_line("rm -f .fortress_pwd 2>/dev/null") |
| 118 | end function get_pwd |
| 119 | |
| 120 | function get_parent_path(path) result(parent) |
| 121 | character(len=*), intent(in) :: path |
| 122 | character(len=MAX_PATH) :: parent |
| 123 | integer :: pos |
| 124 | |
| 125 | pos = index(path, "/", back=.true.) |
| 126 | if (pos > 1) then |
| 127 | parent = path(1:pos-1) |
| 128 | else if (pos == 1) then |
| 129 | parent = "/" |
| 130 | else |
| 131 | parent = "." |
| 132 | end if |
| 133 | end function get_parent_path |
| 134 | |
| 135 | function join_path(base, name) result(full) |
| 136 | character(len=*), intent(in) :: base, name |
| 137 | character(len=MAX_PATH) :: full |
| 138 | |
| 139 | if (base == "/") then |
| 140 | full = "/" // trim(name) |
| 141 | else |
| 142 | full = trim(base) // "/" // trim(name) |
| 143 | end if |
| 144 | end function join_path |
| 145 | |
| 146 | function find_in_parent(dir, files, count) result(idx) |
| 147 | character(len=*), intent(in) :: dir |
| 148 | character(len=*), dimension(*), intent(in) :: files |
| 149 | integer, intent(in) :: count |
| 150 | integer :: idx, pos |
| 151 | character(len=256) :: basename |
| 152 | |
| 153 | pos = index(dir, "/", back=.true.) |
| 154 | if (pos > 0) then |
| 155 | basename = dir(pos+1:) |
| 156 | else |
| 157 | basename = dir |
| 158 | end if |
| 159 | |
| 160 | do idx = 1, count |
| 161 | if (trim(files(idx)) == trim(basename)) return |
| 162 | end do |
| 163 | idx = 1 |
| 164 | end function find_in_parent |
| 165 | |
| 166 | function find_file_in_list(target_path, files, count) result(idx) |
| 167 | character(len=*), intent(in) :: target_path |
| 168 | character(len=*), dimension(*), intent(in) :: files |
| 169 | integer, intent(in) :: count |
| 170 | integer :: idx, pos |
| 171 | character(len=MAX_PATH) :: basename |
| 172 | |
| 173 | ! Extract basename from target_path |
| 174 | pos = index(target_path, "/", back=.true.) |
| 175 | if (pos > 0) then |
| 176 | basename = target_path(pos+1:) |
| 177 | else |
| 178 | basename = target_path |
| 179 | end if |
| 180 | |
| 181 | ! Search for the file in the list |
| 182 | do idx = 1, count |
| 183 | if (trim(files(idx)) == trim(basename)) return |
| 184 | end do |
| 185 | |
| 186 | ! Default to first item if not found |
| 187 | idx = 1 |
| 188 | end function find_file_in_list |
| 189 | |
| 190 | subroutine fzf_search(search_dir, result_path) |
| 191 | character(len=*), intent(in) :: search_dir |
| 192 | character(len=*), intent(out) :: result_path |
| 193 | character(len=MAX_PATH) :: temp_file, fzf_cmd |
| 194 | integer :: unit, ios, stat |
| 195 | |
| 196 | result_path = "" |
| 197 | |
| 198 | ! Create temp file for fzf output |
| 199 | call get_environment_variable("HOME", temp_file) |
| 200 | temp_file = trim(temp_file) // "/.fortress_fzf" |
| 201 | |
| 202 | ! Restore terminal for fzf |
| 203 | call execute_command_line("stty icanon echo 2>/dev/null") |
| 204 | |
| 205 | ! Build fzf command: find files, pipe to fzf, save selection |
| 206 | fzf_cmd = "cd '" // trim(search_dir) // "' && " // & |
| 207 | "find . -type f -o -type d | " // & |
| 208 | "sed 's|^\./||' | " // & |
| 209 | "fzf --height=40% --reverse --border --preview 'ls -lh {}' " // & |
| 210 | "> " // trim(temp_file) // " 2>/dev/null" |
| 211 | |
| 212 | ! Run fzf |
| 213 | call execute_command_line(trim(fzf_cmd), exitstat=stat, wait=.true.) |
| 214 | |
| 215 | ! Restore raw mode |
| 216 | call execute_command_line("stty -icanon -echo min 1 time 0 2>/dev/null") |
| 217 | |
| 218 | ! Read result if fzf succeeded |
| 219 | if (stat == 0) then |
| 220 | open(newunit=unit, file=temp_file, status='old', iostat=ios) |
| 221 | if (ios == 0) then |
| 222 | read(unit, '(a)', iostat=ios) result_path |
| 223 | if (ios == 0) then |
| 224 | ! Convert relative path to absolute |
| 225 | result_path = join_path(search_dir, result_path) |
| 226 | end if |
| 227 | close(unit) |
| 228 | end if |
| 229 | end if |
| 230 | |
| 231 | ! Cleanup |
| 232 | call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null") |
| 233 | end subroutine fzf_search |
| 234 | |
| 235 | subroutine write_exit_dir(dir) |
| 236 | character(len=*), intent(in) :: dir |
| 237 | character(len=MAX_PATH) :: temp_file |
| 238 | integer :: unit, ios |
| 239 | |
| 240 | ! Create temp file in HOME directory |
| 241 | call get_environment_variable("HOME", temp_file) |
| 242 | temp_file = trim(temp_file) // "/.fortress_cd" |
| 243 | |
| 244 | open(newunit=unit, file=temp_file, status='replace', action='write', iostat=ios) |
| 245 | if (ios == 0) then |
| 246 | write(unit, '(a)') trim(dir) |
| 247 | close(unit) |
| 248 | end if |
| 249 | end subroutine write_exit_dir |
| 250 | |
| 251 | subroutine open_file_in_default_app(filepath) |
| 252 | character(len=*), intent(in) :: filepath |
| 253 | character(len=MAX_PATH) :: editor, visual, platform, temp_file |
| 254 | character(len=MAX_PATH*2) :: open_cmd |
| 255 | integer :: stat, unit, ios |
| 256 | |
| 257 | ! Try $EDITOR first (preferred for text editing) |
| 258 | call get_environment_variable("EDITOR", editor, status=stat) |
| 259 | if (stat == 0 .and. len_trim(editor) > 0) then |
| 260 | ! Restore terminal to normal mode (like fuss does for pager) |
| 261 | call execute_command_line("stty sane < /dev/tty", exitstat=stat) |
| 262 | |
| 263 | ! Open with $EDITOR - wait for it to finish |
| 264 | open_cmd = trim(editor) // " '" // trim(filepath) // "'" |
| 265 | call execute_command_line(trim(open_cmd), exitstat=stat, wait=.true.) |
| 266 | |
| 267 | ! Restore raw mode |
| 268 | call execute_command_line("stty -icanon -echo min 1 time 0 < /dev/tty", exitstat=stat) |
| 269 | return |
| 270 | end if |
| 271 | |
| 272 | ! Try $VISUAL as fallback |
| 273 | call get_environment_variable("VISUAL", visual, status=stat) |
| 274 | if (stat == 0 .and. len_trim(visual) > 0) then |
| 275 | ! Restore terminal to normal mode |
| 276 | call execute_command_line("stty sane < /dev/tty", exitstat=stat) |
| 277 | |
| 278 | ! Open with $VISUAL - wait for it to finish |
| 279 | open_cmd = trim(visual) // " '" // trim(filepath) // "'" |
| 280 | call execute_command_line(trim(open_cmd), exitstat=stat, wait=.true.) |
| 281 | |
| 282 | ! Restore raw mode |
| 283 | call execute_command_line("stty -icanon -echo min 1 time 0 < /dev/tty", exitstat=stat) |
| 284 | return |
| 285 | end if |
| 286 | |
| 287 | ! Fall back to platform-specific default application opener |
| 288 | ! Detect platform using uname |
| 289 | call get_environment_variable("HOME", temp_file) |
| 290 | temp_file = trim(temp_file) // "/.fortress_platform" |
| 291 | call execute_command_line("uname > " // trim(temp_file) // " 2>/dev/null", wait=.true.) |
| 292 | |
| 293 | open(newunit=unit, file=temp_file, status='old', iostat=ios) |
| 294 | if (ios == 0) then |
| 295 | read(unit, '(a)', iostat=ios) platform |
| 296 | close(unit) |
| 297 | else |
| 298 | platform = "unknown" |
| 299 | end if |
| 300 | call execute_command_line("rm -f " // trim(temp_file) // " 2>/dev/null") |
| 301 | |
| 302 | ! Restore terminal before launching (in case default app is terminal-based) |
| 303 | call execute_command_line("stty sane < /dev/tty", exitstat=stat) |
| 304 | |
| 305 | ! Use platform-specific opener (run in background, don't wait) |
| 306 | if (index(platform, "Darwin") > 0) then |
| 307 | ! macOS - open launches apps in new windows (usually GUI) |
| 308 | open_cmd = "open '" // trim(filepath) // "' 2>/dev/null &" |
| 309 | else if (index(platform, "Linux") > 0) then |
| 310 | ! Linux - xdg-open uses desktop environment defaults |
| 311 | open_cmd = "xdg-open '" // trim(filepath) // "' 2>/dev/null &" |
| 312 | else |
| 313 | ! Unknown platform - try xdg-open as a reasonable default |
| 314 | open_cmd = "xdg-open '" // trim(filepath) // "' 2>/dev/null &" |
| 315 | end if |
| 316 | |
| 317 | call execute_command_line(trim(open_cmd), wait=.false.) |
| 318 | |
| 319 | ! Restore raw mode immediately (since we're not waiting for the app) |
| 320 | call execute_command_line("stty -icanon -echo min 1 time 0 < /dev/tty", exitstat=stat) |
| 321 | end subroutine open_file_in_default_app |
| 322 | |
| 323 | subroutine rename_file_prompt(dir, current_name, new_name, was_renamed) |
| 324 | use iso_fortran_env, only: output_unit |
| 325 | use terminal_control, only: BOLD, RESET, YELLOW, ESC |
| 326 | character(len=*), intent(in) :: dir, current_name |
| 327 | character(len=*), intent(out) :: new_name |
| 328 | logical, intent(out) :: was_renamed |
| 329 | character(len=MAX_PATH) :: input_buffer |
| 330 | integer :: input_len |
| 331 | character(len=1) :: key |
| 332 | integer :: ios |
| 333 | logical :: editing |
| 334 | |
| 335 | ! Initialize - pre-fill with current name |
| 336 | was_renamed = .false. |
| 337 | input_buffer = current_name |
| 338 | input_len = len_trim(current_name) |
| 339 | editing = .true. |
| 340 | |
| 341 | do while (editing) |
| 342 | ! Move cursor to bottom of screen and show rename prompt |
| 343 | write(output_unit, '(a)', advance='no') ESC // "[999;1H" ! Move to bottom left |
| 344 | write(output_unit, '(a)', advance='no') ESC // "[K" ! Clear line |
| 345 | write(output_unit, '(a)', advance='no') YELLOW // BOLD // "Rename: " // RESET // & |
| 346 | trim(input_buffer(1:input_len)) |
| 347 | call flush(output_unit) |
| 348 | |
| 349 | ! Read single character |
| 350 | read(*, '(a1)', advance='no', iostat=ios) key |
| 351 | if (ios /= 0) cycle |
| 352 | |
| 353 | select case(ichar(key)) |
| 354 | case(27) ! ESC - cancel |
| 355 | editing = .false. |
| 356 | was_renamed = .false. |
| 357 | case(13, 10) ! Enter - confirm |
| 358 | if (input_len > 0 .and. trim(input_buffer(1:input_len)) /= trim(current_name)) then |
| 359 | new_name = input_buffer(1:input_len) |
| 360 | was_renamed = .true. |
| 361 | end if |
| 362 | editing = .false. |
| 363 | case(127, 8) ! Backspace |
| 364 | if (input_len > 0) then |
| 365 | input_len = input_len - 1 |
| 366 | end if |
| 367 | case(32:126) ! Printable characters |
| 368 | if (input_len < MAX_PATH) then |
| 369 | input_len = input_len + 1 |
| 370 | input_buffer(input_len:input_len) = key |
| 371 | end if |
| 372 | end select |
| 373 | end do |
| 374 | |
| 375 | ! Clear the prompt line |
| 376 | write(output_unit, '(a)', advance='no') ESC // "[999;1H" ! Move to bottom |
| 377 | write(output_unit, '(a)', advance='no') ESC // "[K" ! Clear line |
| 378 | call flush(output_unit) |
| 379 | end subroutine rename_file_prompt |
| 380 | |
| 381 | end module filesystem_ops |
| 382 |