| 1 | program facsimile |
| 2 | use iso_fortran_env, only: error_unit, input_unit, output_unit, int64 |
| 3 | use version_module |
| 4 | use terminal_io_module |
| 5 | use input_handler_module, only: get_key_input |
| 6 | use editor_state_module |
| 7 | use text_buffer_module |
| 8 | use renderer_module |
| 9 | use command_handler_module, only: handle_key_command, init_command_handler, cleanup_command_handler, & |
| 10 | save_initial_state_for_undo, search_pattern, match_case_sensitive, & |
| 11 | g_lsp_modified_buffer, g_lsp_ui_changed |
| 12 | use workspace_module |
| 13 | use backup_module |
| 14 | use save_prompt_module |
| 15 | use command_palette_module, only: register_command |
| 16 | use welcome_menu_module, only: show_welcome_menu |
| 17 | use fortress_navigator_module, only: open_fortress_navigator |
| 18 | use binary_prompt_module, only: binary_file_prompt |
| 19 | use lsp_server_manager_module, only: notify_file_opened, notify_file_changed, & |
| 20 | notify_file_closed, process_server_messages, & |
| 21 | set_diagnostics_handler |
| 22 | use lsp_protocol_module, only: lsp_message_t |
| 23 | implicit none |
| 24 | |
| 25 | type(editor_state_t) :: editor |
| 26 | type(buffer_t) :: buffer |
| 27 | character(len=32) :: key_input |
| 28 | character(len=512) :: filename, arg, workspace_dir |
| 29 | logical :: running, should_quit, is_workspace_mode, workspace_success |
| 30 | logical :: welcome_cancelled, is_browse, nav_cancelled, is_directory |
| 31 | character(len=:), allocatable :: selected_path |
| 32 | integer :: status, argc, rows, cols |
| 33 | |
| 34 | |
| 35 | ! Get command line arguments |
| 36 | argc = command_argument_count() |
| 37 | is_workspace_mode = .false. |
| 38 | workspace_dir = "" |
| 39 | filename = "" |
| 40 | |
| 41 | if (argc > 0) then |
| 42 | call get_command_argument(1, arg) |
| 43 | |
| 44 | ! Handle version flags |
| 45 | if (trim(arg) == '--version' .or. trim(arg) == '-v') then |
| 46 | write(output_unit, '(A,A)') 'fac version ', VERSION |
| 47 | stop |
| 48 | end if |
| 49 | |
| 50 | ! Handle help flags |
| 51 | if (trim(arg) == '--help' .or. trim(arg) == '-h') then |
| 52 | call print_help() |
| 53 | stop |
| 54 | end if |
| 55 | |
| 56 | ! Check if argument is a directory (workspace mode) |
| 57 | ! Use test -d which is POSIX compliant (works on Linux, macOS, BSD) |
| 58 | call execute_command_line("test -d '" // trim(arg) // & |
| 59 | "' && echo 'Directory' > /tmp/.fac_filetype || " // & |
| 60 | "echo 'File' > /tmp/.fac_filetype", wait=.true.) |
| 61 | call read_file_type(status) |
| 62 | if (status == 0) then |
| 63 | ! Directory - workspace mode |
| 64 | is_workspace_mode = .true. |
| 65 | call workspace_get_path(trim(arg), workspace_dir) |
| 66 | else |
| 67 | ! File - check if parent directory has a workspace |
| 68 | workspace_dir = workspace_detect_from_file(trim(arg)) |
| 69 | if (len_trim(workspace_dir) > 0) then |
| 70 | is_workspace_mode = .true. |
| 71 | end if |
| 72 | filename = arg |
| 73 | end if |
| 74 | else |
| 75 | ! No arguments - launch Fortress welcome menu (Phase 5) |
| 76 | call terminal_init() |
| 77 | call show_welcome_menu(selected_path, welcome_cancelled) |
| 78 | |
| 79 | if (welcome_cancelled) then |
| 80 | ! Check if user wants to browse filesystem |
| 81 | is_browse = .false. |
| 82 | if (allocated(selected_path) .and. selected_path == "BROWSE") then |
| 83 | is_browse = .true. |
| 84 | end if |
| 85 | |
| 86 | call terminal_cleanup() |
| 87 | |
| 88 | if (is_browse) then |
| 89 | ! Launch fortress navigator |
| 90 | call terminal_init() |
| 91 | call open_fortress_navigator(selected_path, is_directory, nav_cancelled) |
| 92 | call terminal_cleanup() |
| 93 | |
| 94 | if (nav_cancelled .or. .not. allocated(selected_path)) then |
| 95 | ! User cancelled navigation too |
| 96 | stop |
| 97 | end if |
| 98 | |
| 99 | ! Handle the selection from navigator |
| 100 | if (is_directory) then |
| 101 | ! Directory - open as workspace |
| 102 | is_workspace_mode = .true. |
| 103 | call workspace_get_path(trim(selected_path), workspace_dir) |
| 104 | else |
| 105 | ! File - open in single-file mode (like `fac filename`) |
| 106 | filename = selected_path |
| 107 | ! Check if parent directory has a workspace |
| 108 | workspace_dir = workspace_detect_from_file(trim(filename)) |
| 109 | if (len_trim(workspace_dir) > 0) then |
| 110 | is_workspace_mode = .true. |
| 111 | end if |
| 112 | end if |
| 113 | else |
| 114 | ! User just cancelled |
| 115 | stop |
| 116 | end if |
| 117 | end if |
| 118 | |
| 119 | ! User selected a workspace from welcome menu (not browse) |
| 120 | ! This handles favorites, recents, and CURRENT DIRECTORY |
| 121 | if (allocated(selected_path) .and. .not. is_browse) then |
| 122 | ! Check if user selected CURRENT DIRECTORY option |
| 123 | if (selected_path == "CWD") then |
| 124 | ! Get actual current working directory |
| 125 | call get_workspace_path(selected_path) |
| 126 | arg = selected_path |
| 127 | else |
| 128 | arg = selected_path |
| 129 | end if |
| 130 | |
| 131 | ! Check if it's a directory |
| 132 | call execute_command_line("test -d '" // trim(arg) // & |
| 133 | "' && echo 'Directory' > /tmp/.fac_filetype || " // & |
| 134 | "echo 'File' > /tmp/.fac_filetype", wait=.true.) |
| 135 | call read_file_type(status) |
| 136 | if (status == 0) then |
| 137 | ! Directory - workspace mode |
| 138 | is_workspace_mode = .true. |
| 139 | call workspace_get_path(trim(arg), workspace_dir) |
| 140 | else |
| 141 | ! Invalid selection (favorites/recents should only have directories) |
| 142 | write(error_unit, '(A)') 'Error: Selected path is not a directory' |
| 143 | stop 1 |
| 144 | end if |
| 145 | end if |
| 146 | end if |
| 147 | |
| 148 | ! Handle workspace mode |
| 149 | if (is_workspace_mode) then |
| 150 | ! Check if workspace exists, create if not |
| 151 | if (.not. workspace_exists(workspace_dir)) then |
| 152 | call workspace_init(workspace_dir, workspace_success) |
| 153 | if (.not. workspace_success) then |
| 154 | write(error_unit, '(A)') 'Error: Failed to create workspace' |
| 155 | stop 1 |
| 156 | end if |
| 157 | else |
| 158 | ! Load existing workspace |
| 159 | call workspace_load(workspace_dir, workspace_success) |
| 160 | if (.not. workspace_success) then |
| 161 | write(error_unit, '(A)') 'Error: Failed to load workspace' |
| 162 | stop 1 |
| 163 | end if |
| 164 | end if |
| 165 | end if |
| 166 | |
| 167 | ! Initialize editor |
| 168 | call init_editor(editor) |
| 169 | running = .true. |
| 170 | |
| 171 | ! Set up diagnostics handler for LSP |
| 172 | call set_diagnostics_handler(editor%lsp_manager, handle_diagnostics) |
| 173 | |
| 174 | ! Register all commands for command palette |
| 175 | call register_all_commands() |
| 176 | |
| 177 | ! Initialize terminal early (needed for workspace restoration warnings) |
| 178 | call terminal_init() |
| 179 | call terminal_clear_screen() |
| 180 | |
| 181 | ! Initialize main buffer early (needed for workspace restoration) |
| 182 | call init_buffer(buffer) |
| 183 | |
| 184 | ! Set workspace path |
| 185 | if (is_workspace_mode) then |
| 186 | ! Use detected/created workspace directory |
| 187 | allocate(character(len=len_trim(workspace_dir)) :: editor%workspace_path) |
| 188 | editor%workspace_path = trim(workspace_dir) |
| 189 | |
| 190 | ! Restore workspace state (tabs, cursor positions, etc.) |
| 191 | call workspace_restore_state(editor, editor%workspace_path, workspace_success) |
| 192 | |
| 193 | ! Sync restored active tab's buffer to main buffer |
| 194 | if (workspace_success .and. allocated(editor%tabs) .and. editor%active_tab_index > 0) then |
| 195 | if (editor%active_tab_index <= size(editor%tabs)) then |
| 196 | if (allocated(editor%tabs(editor%active_tab_index)%panes) .and. & |
| 197 | size(editor%tabs(editor%active_tab_index)%panes) > 0) then |
| 198 | ! Copy active pane's buffer to main buffer (replaces the empty init) |
| 199 | call copy_buffer(buffer, editor%tabs(editor%active_tab_index)%panes(1)%buffer) |
| 200 | end if |
| 201 | end if |
| 202 | end if |
| 203 | else |
| 204 | ! Single-file mode - use current directory |
| 205 | call get_workspace_path(editor%workspace_path) |
| 206 | end if |
| 207 | |
| 208 | ! Check for backups and offer restoration (after workspace load, after terminal init) |
| 209 | if (is_workspace_mode .and. backup_detect(editor%workspace_path)) then |
| 210 | call handle_backup_restoration(editor, buffer) |
| 211 | end if |
| 212 | |
| 213 | ! Get terminal size |
| 214 | call terminal_get_size(rows, cols) |
| 215 | editor%screen_rows = rows |
| 216 | editor%screen_cols = cols |
| 217 | |
| 218 | ! Initialize renderer (pass filename for syntax highlighting detection) |
| 219 | if (len_trim(filename) > 0) then |
| 220 | call init_renderer(rows, cols, trim(filename)) |
| 221 | else |
| 222 | call init_renderer(rows, cols) |
| 223 | end if |
| 224 | |
| 225 | ! Initialize command handler (for yank stack) |
| 226 | call init_command_handler() |
| 227 | |
| 228 | ! Initialize buffer and load file if specified |
| 229 | if (len_trim(filename) > 0) then |
| 230 | ! Create a tab for the initial file |
| 231 | call create_tab(editor, trim(filename)) |
| 232 | |
| 233 | ! Load file into tab's buffer and first pane's buffer |
| 234 | if (editor%active_tab_index > 0) then |
| 235 | call buffer_load_file(editor%tabs(editor%active_tab_index)%buffer, trim(filename), status) |
| 236 | |
| 237 | ! Also load into first pane's buffer |
| 238 | if (allocated(editor%tabs(editor%active_tab_index)%panes) .and. & |
| 239 | size(editor%tabs(editor%active_tab_index)%panes) > 0) then |
| 240 | call buffer_load_file(editor%tabs(editor%active_tab_index)%panes(1)%buffer, trim(filename), status) |
| 241 | ! Copy first pane's buffer to main buffer |
| 242 | call copy_buffer(buffer, editor%tabs(editor%active_tab_index)%panes(1)%buffer) |
| 243 | else |
| 244 | ! Copy tab's buffer to main buffer |
| 245 | call copy_buffer(buffer, editor%tabs(editor%active_tab_index)%buffer) |
| 246 | end if |
| 247 | else |
| 248 | call buffer_load_file(buffer, trim(filename), status) |
| 249 | end if |
| 250 | |
| 251 | if (status == 0) then |
| 252 | if (allocated(editor%filename)) deallocate(editor%filename) |
| 253 | allocate(character(len=len_trim(filename)) :: editor%filename) |
| 254 | editor%filename = trim(filename) |
| 255 | |
| 256 | ! Send LSP didOpen notification to ALL active servers |
| 257 | if (editor%active_tab_index > 0 .and. editor%active_tab_index <= size(editor%tabs)) then |
| 258 | if (editor%tabs(editor%active_tab_index)%num_lsp_servers > 0) then |
| 259 | block |
| 260 | integer :: srv_i |
| 261 | do srv_i = 1, editor%tabs(editor%active_tab_index)%num_lsp_servers |
| 262 | call notify_file_opened(editor%lsp_manager, & |
| 263 | editor%tabs(editor%active_tab_index)%lsp_server_indices(srv_i), & |
| 264 | trim(filename), buffer_to_string(buffer)) |
| 265 | end do |
| 266 | end block |
| 267 | end if |
| 268 | end if |
| 269 | else if (status == -2) then |
| 270 | ! Binary file detected - prompt user |
| 271 | if (binary_file_prompt(trim(filename))) then |
| 272 | ! User wants to view in hex mode |
| 273 | call buffer_load_file_as_hex(buffer, trim(filename), status) |
| 274 | if (status == 0) then |
| 275 | ! Deallocate if already allocated |
| 276 | if (allocated(editor%filename)) deallocate(editor%filename) |
| 277 | allocate(character(len=len_trim(filename) + 6) :: editor%filename) |
| 278 | editor%filename = trim(filename) // ' [HEX]' |
| 279 | |
| 280 | ! Copy hex buffer to tab and pane buffers |
| 281 | if (editor%active_tab_index > 0 .and. editor%active_tab_index <= size(editor%tabs)) then |
| 282 | call copy_buffer(editor%tabs(editor%active_tab_index)%buffer, buffer) |
| 283 | if (allocated(editor%tabs(editor%active_tab_index)%panes) .and. & |
| 284 | size(editor%tabs(editor%active_tab_index)%panes) > 0) then |
| 285 | call copy_buffer(editor%tabs(editor%active_tab_index)%panes(1)%buffer, buffer) |
| 286 | end if |
| 287 | end if |
| 288 | ! TODO: Mark as read-only in future enhancement |
| 289 | else |
| 290 | ! Failed to load hex view |
| 291 | call terminal_cleanup() |
| 292 | write(error_unit, '(A)') 'Error: Failed to load binary file' |
| 293 | stop 1 |
| 294 | end if |
| 295 | else |
| 296 | ! User cancelled - cleanup and exit |
| 297 | call terminal_cleanup() |
| 298 | stop |
| 299 | end if |
| 300 | else |
| 301 | ! If file doesn't exist, create empty buffer for new file |
| 302 | call init_buffer(buffer) |
| 303 | if (allocated(editor%filename)) deallocate(editor%filename) |
| 304 | allocate(character(len=len_trim(filename)) :: editor%filename) |
| 305 | editor%filename = trim(filename) |
| 306 | end if |
| 307 | else |
| 308 | ! Only initialize empty buffer if we don't have restored tabs |
| 309 | if (.not. (allocated(editor%tabs) .and. editor%active_tab_index > 0)) then |
| 310 | call init_buffer(buffer) |
| 311 | end if |
| 312 | end if |
| 313 | |
| 314 | ! Save initial file state for undo (position 0) |
| 315 | call save_initial_state_for_undo(buffer, editor) |
| 316 | |
| 317 | ! Initial render |
| 318 | call render_screen(buffer, editor, allocated(search_pattern), match_case_sensitive) |
| 319 | |
| 320 | ! Main event loop |
| 321 | do while (running) |
| 322 | ! Process any LSP messages |
| 323 | call process_server_messages(editor%lsp_manager) |
| 324 | |
| 325 | ! Sync local buffer from tab after LSP processing (in case LSP modified it) |
| 326 | block |
| 327 | logical :: should_render |
| 328 | should_render = .false. |
| 329 | |
| 330 | ! Check if LSP set the UI changed flag (e.g., code actions panel shown) |
| 331 | if (g_lsp_ui_changed) then |
| 332 | should_render = .true. |
| 333 | g_lsp_ui_changed = .false. |
| 334 | end if |
| 335 | |
| 336 | if (editor%active_tab_index > 0 .and. editor%active_tab_index <= size(editor%tabs)) then |
| 337 | ! Check if LSP set the modified flag |
| 338 | if (g_lsp_modified_buffer) then |
| 339 | should_render = .true. |
| 340 | g_lsp_modified_buffer = .false. |
| 341 | end if |
| 342 | |
| 343 | call copy_buffer(buffer, editor%tabs(editor%active_tab_index)%buffer) |
| 344 | |
| 345 | ! Also sync to active pane buffer if panes exist (so pane doesn't overwrite LSP changes) |
| 346 | if (allocated(editor%tabs(editor%active_tab_index)%panes) .and. & |
| 347 | size(editor%tabs(editor%active_tab_index)%panes) > 0) then |
| 348 | status = editor%tabs(editor%active_tab_index)%active_pane_index |
| 349 | if (status > 0 .and. status <= size(editor%tabs(editor%active_tab_index)%panes)) then |
| 350 | call copy_buffer(editor%tabs(editor%active_tab_index)%panes(status)%buffer, buffer) |
| 351 | end if |
| 352 | end if |
| 353 | end if |
| 354 | |
| 355 | ! Render immediately if LSP modified the buffer (do this OUTSIDE the if block) |
| 356 | if (should_render) then |
| 357 | if (editor%fuss_mode_active) then |
| 358 | call render_screen_with_tree(buffer, editor, allocated(search_pattern), match_case_sensitive) |
| 359 | else |
| 360 | call render_screen(buffer, editor, allocated(search_pattern), match_case_sensitive) |
| 361 | end if |
| 362 | end if |
| 363 | end block |
| 364 | |
| 365 | ! Flush any pending document changes to LSP |
| 366 | call flush_pending_document_changes(editor) |
| 367 | |
| 368 | ! Get input |
| 369 | call get_key_input(key_input, status) |
| 370 | |
| 371 | if (status == 0) then |
| 372 | ! Sync buffer before and after input when using panes |
| 373 | ! Before: copy active pane's buffer -> global buffer |
| 374 | ! After: copy global buffer -> active pane's buffer |
| 375 | if (editor%active_tab_index > 0 .and. editor%active_tab_index <= size(editor%tabs)) then |
| 376 | if (allocated(editor%tabs(editor%active_tab_index)%panes) .and. & |
| 377 | size(editor%tabs(editor%active_tab_index)%panes) > 0) then |
| 378 | ! Get active pane index |
| 379 | status = editor%tabs(editor%active_tab_index)%active_pane_index |
| 380 | if (status > 0 .and. status <= size(editor%tabs(editor%active_tab_index)%panes)) then |
| 381 | ! Copy active pane's buffer to main buffer |
| 382 | call copy_buffer(buffer, editor%tabs(editor%active_tab_index)%panes(status)%buffer) |
| 383 | end if |
| 384 | end if |
| 385 | end if |
| 386 | |
| 387 | ! Process input |
| 388 | call handle_key_command(key_input, editor, buffer, should_quit) |
| 389 | |
| 390 | ! Sync back to active pane and other instances |
| 391 | if (editor%active_tab_index > 0 .and. editor%active_tab_index <= size(editor%tabs)) then |
| 392 | if (allocated(editor%tabs(editor%active_tab_index)%panes) .and. & |
| 393 | size(editor%tabs(editor%active_tab_index)%panes) > 0) then |
| 394 | ! Get active pane index |
| 395 | status = editor%tabs(editor%active_tab_index)%active_pane_index |
| 396 | if (status > 0 .and. status <= size(editor%tabs(editor%active_tab_index)%panes)) then |
| 397 | ! Copy main buffer back to active pane's buffer |
| 398 | call copy_buffer(editor%tabs(editor%active_tab_index)%panes(status)%buffer, buffer) |
| 399 | |
| 400 | ! Sync to all instances of this file |
| 401 | if (allocated(editor%tabs(editor%active_tab_index)%panes(status)%filename)) then |
| 402 | call sync_buffer_to_all_instances(editor, & |
| 403 | editor%tabs(editor%active_tab_index)%panes(status)%filename, buffer) |
| 404 | end if |
| 405 | end if |
| 406 | |
| 407 | ! Also update tab buffer for backwards compatibility |
| 408 | call copy_buffer(editor%tabs(editor%active_tab_index)%buffer, buffer) |
| 409 | |
| 410 | ! Sync modified flag from buffer to tab |
| 411 | editor%tabs(editor%active_tab_index)%modified = buffer%modified |
| 412 | end if |
| 413 | end if |
| 414 | |
| 415 | if (should_quit) then |
| 416 | running = .false. |
| 417 | else |
| 418 | ! Re-render screen after each command |
| 419 | if (editor%fuss_mode_active) then |
| 420 | call render_screen_with_tree(buffer, editor, allocated(search_pattern), match_case_sensitive) |
| 421 | else |
| 422 | call render_screen(buffer, editor, allocated(search_pattern), match_case_sensitive) |
| 423 | end if |
| 424 | end if |
| 425 | end if |
| 426 | end do |
| 427 | |
| 428 | ! IMPORTANT: Save workspace state FIRST, before any prompts or cleanup |
| 429 | ! This ensures we capture the current state before tabs might be closed |
| 430 | if (should_quit .and. allocated(editor%workspace_path)) then |
| 431 | call workspace_save_state(editor, editor%workspace_path, workspace_success) |
| 432 | end if |
| 433 | |
| 434 | ! Handle unsaved files - prompt for save/backup |
| 435 | if (allocated(editor%tabs) .and. allocated(editor%workspace_path)) then |
| 436 | ! Workspace mode - handle all modified tabs |
| 437 | call handle_unsaved_files_on_quit(editor, buffer, should_quit) |
| 438 | ! If user cancelled (should_quit = .false.), skip cleanup and restart loop |
| 439 | else if (buffer%modified .and. allocated(editor%workspace_path) .and. allocated(editor%filename)) then |
| 440 | ! Single-file mode - handle the current buffer if modified |
| 441 | call handle_single_file_on_quit(buffer, editor, should_quit) |
| 442 | end if |
| 443 | |
| 444 | ! Only proceed with cleanup if actually quitting |
| 445 | if (should_quit) then |
| 446 | |
| 447 | ! Cleanup |
| 448 | call cleanup_renderer() |
| 449 | call cleanup_command_handler() |
| 450 | call terminal_cleanup() |
| 451 | call cleanup_editor(editor) |
| 452 | call cleanup_buffer(buffer) |
| 453 | else |
| 454 | ! User cancelled quit - re-render and continue |
| 455 | running = .true. |
| 456 | call terminal_clear_screen() |
| 457 | if (editor%fuss_mode_active) then |
| 458 | call render_screen_with_tree(buffer, editor, allocated(search_pattern), match_case_sensitive) |
| 459 | else |
| 460 | call render_screen(buffer, editor, allocated(search_pattern), match_case_sensitive) |
| 461 | end if |
| 462 | end if |
| 463 | |
| 464 | contains |
| 465 | |
| 466 | ! Handler for LSP diagnostics notifications (with server attribution) |
| 467 | subroutine handle_diagnostics(notification, server_index) |
| 468 | use lsp_protocol_module, only: lsp_message_t |
| 469 | use diagnostics_module, only: parse_diagnostics_from_params_with_server |
| 470 | use terminal_io_module, only: terminal_write |
| 471 | type(lsp_message_t), intent(in) :: notification |
| 472 | integer, intent(in) :: server_index |
| 473 | |
| 474 | ! Parse and store diagnostics with server attribution (for multi-LSP) |
| 475 | ! This keeps diagnostics from different servers separate |
| 476 | call parse_diagnostics_from_params_with_server(editor%diagnostics, notification%params, server_index) |
| 477 | end subroutine handle_diagnostics |
| 478 | |
| 479 | ! Flush pending document changes for all tabs |
| 480 | subroutine flush_pending_document_changes(editor) |
| 481 | use document_sync_module, only: flush_pending_changes |
| 482 | type(editor_state_t), intent(inout) :: editor |
| 483 | integer :: i |
| 484 | |
| 485 | ! Check all tabs for pending changes |
| 486 | if (allocated(editor%tabs)) then |
| 487 | do i = 1, size(editor%tabs) |
| 488 | if (editor%tabs(i)%num_lsp_servers > 0) then |
| 489 | call flush_pending_changes(editor%tabs(i)%document_sync, & |
| 490 | editor%lsp_manager, .false.) |
| 491 | end if |
| 492 | end do |
| 493 | end if |
| 494 | end subroutine flush_pending_document_changes |
| 495 | |
| 496 | subroutine read_file_type(is_directory) |
| 497 | integer, intent(out) :: is_directory |
| 498 | character(len=20) :: file_type |
| 499 | integer :: unit, ios |
| 500 | |
| 501 | is_directory = 1 ! Default to not a directory |
| 502 | |
| 503 | open(newunit=unit, file='/tmp/.fac_filetype', status='old', iostat=ios) |
| 504 | if (ios == 0) then |
| 505 | read(unit, '(A)', iostat=ios) file_type |
| 506 | close(unit) |
| 507 | call execute_command_line('rm -f /tmp/.fac_filetype', wait=.true.) |
| 508 | if (ios == 0) then |
| 509 | if (trim(file_type) == 'Directory') then |
| 510 | is_directory = 0 |
| 511 | end if |
| 512 | end if |
| 513 | end if |
| 514 | end subroutine read_file_type |
| 515 | |
| 516 | subroutine get_workspace_path(path) |
| 517 | character(len=:), allocatable, intent(out) :: path |
| 518 | character(len=1024) :: buffer |
| 519 | integer :: status |
| 520 | |
| 521 | ! Use execute_command_line to get current directory |
| 522 | call execute_command_line('pwd > /tmp/fac_pwd.txt', wait=.true., exitstat=status) |
| 523 | if (status == 0) then |
| 524 | open(unit=99, file='/tmp/fac_pwd.txt', status='old', action='read', iostat=status) |
| 525 | if (status == 0) then |
| 526 | read(99, '(A)', iostat=status) buffer |
| 527 | close(99, status='delete') |
| 528 | if (status == 0) then |
| 529 | path = trim(buffer) |
| 530 | return |
| 531 | end if |
| 532 | end if |
| 533 | end if |
| 534 | ! Fallback if command fails |
| 535 | path = '.' |
| 536 | end subroutine get_workspace_path |
| 537 | |
| 538 | subroutine print_help() |
| 539 | write(output_unit, '(A)') 'fac - Fortran text editor' |
| 540 | write(output_unit, '(A,A)') 'Version: ', VERSION |
| 541 | write(output_unit, '(A)') '' |
| 542 | write(output_unit, '(A)') 'Usage:' |
| 543 | write(output_unit, '(A)') ' fac [filename] Open a file for editing' |
| 544 | write(output_unit, '(A)') ' fac Start with empty buffer' |
| 545 | write(output_unit, '(A)') ' fac --version, -v Show version information' |
| 546 | write(output_unit, '(A)') ' fac --help, -h Show this help message' |
| 547 | write(output_unit, '(A)') '' |
| 548 | write(output_unit, '(A)') 'Key Bindings:' |
| 549 | write(output_unit, '(A)') ' Ctrl-Q Quit' |
| 550 | write(output_unit, '(A)') ' Ctrl-S Save' |
| 551 | write(output_unit, '(A)') ' Ctrl-F Find/Replace (unified prompt)' |
| 552 | write(output_unit, '(A)') ' Ctrl-G Go to line' |
| 553 | write(output_unit, '(A)') ' Ctrl-Z Undo' |
| 554 | write(output_unit, '(A)') ' Ctrl-Y Redo' |
| 555 | write(output_unit, '(A)') ' Ctrl-X Cut line' |
| 556 | write(output_unit, '(A)') ' Ctrl-C Copy line' |
| 557 | write(output_unit, '(A)') ' Ctrl-V Paste' |
| 558 | write(output_unit, '(A)') ' Ctrl-D Delete line' |
| 559 | write(output_unit, '(A)') ' Ctrl-L Toggle line numbers' |
| 560 | write(output_unit, '(A)') ' Ctrl-P Cycle yank stack backward' |
| 561 | write(output_unit, '(A)') ' Ctrl-N Cycle yank stack forward' |
| 562 | write(output_unit, '(A)') ' Ctrl-T New tab' |
| 563 | write(output_unit, '(A)') ' Ctrl-W Close tab' |
| 564 | write(output_unit, '(A)') ' Alt-1 to Alt-9 Switch to tab 1-9' |
| 565 | write(output_unit, '(A)') ' Ctrl-B Toggle file tree (fuss mode)' |
| 566 | write(output_unit, '(A)') ' Ctrl-\ Split pane vertically' |
| 567 | write(output_unit, '(A)') ' Ctrl-_ Split pane horizontally' |
| 568 | write(output_unit, '(A)') ' Ctrl-Arrow Navigate between panes' |
| 569 | write(output_unit, '(A)') ' Alt-X Close current pane' |
| 570 | write(output_unit, '(A)') '' |
| 571 | write(output_unit, '(A)') 'Find/Replace Mode (Ctrl-F):' |
| 572 | write(output_unit, '(A)') ' Ctrl-F Find next match' |
| 573 | write(output_unit, '(A)') ' Ctrl-R Replace current match' |
| 574 | write(output_unit, '(A)') ' Ctrl-A Replace all matches' |
| 575 | write(output_unit, '(A)') ' Alt-C Toggle case sensitivity' |
| 576 | write(output_unit, '(A)') ' Alt-W Toggle whole word match' |
| 577 | write(output_unit, '(A)') ' Alt-R Toggle regex mode' |
| 578 | write(output_unit, '(A)') ' Tab Switch between find/replace fields' |
| 579 | write(output_unit, '(A)') ' Enter Jump to match and exit' |
| 580 | write(output_unit, '(A)') ' ESC Exit find/replace mode' |
| 581 | end subroutine print_help |
| 582 | |
| 583 | !> Prompt for filename and save an untitled file |
| 584 | subroutine prompt_for_filename_and_save(editor, buffer, tab_index, success) |
| 585 | use text_prompt_module, only: show_text_prompt |
| 586 | type(editor_state_t), intent(inout) :: editor |
| 587 | type(buffer_t), intent(inout) :: buffer |
| 588 | integer, intent(in) :: tab_index |
| 589 | logical, intent(out) :: success |
| 590 | character(len=512) :: new_filename |
| 591 | logical :: cancelled |
| 592 | integer :: save_status |
| 593 | |
| 594 | success = .false. |
| 595 | |
| 596 | ! Prompt for filename |
| 597 | call show_text_prompt('Save as: ', new_filename, cancelled, editor%screen_rows) |
| 598 | |
| 599 | if (cancelled .or. len_trim(new_filename) == 0) then |
| 600 | ! User cancelled or entered empty filename |
| 601 | return |
| 602 | end if |
| 603 | |
| 604 | ! Update the tab filename |
| 605 | if (allocated(editor%tabs(tab_index)%filename)) then |
| 606 | deallocate(editor%tabs(tab_index)%filename) |
| 607 | end if |
| 608 | allocate(character(len=len_trim(new_filename)) :: editor%tabs(tab_index)%filename) |
| 609 | editor%tabs(tab_index)%filename = trim(new_filename) |
| 610 | |
| 611 | ! Update pane filename if exists |
| 612 | if (allocated(editor%tabs(tab_index)%panes)) then |
| 613 | if (size(editor%tabs(tab_index)%panes) > 0) then |
| 614 | if (allocated(editor%tabs(tab_index)%panes(1)%filename)) then |
| 615 | deallocate(editor%tabs(tab_index)%panes(1)%filename) |
| 616 | end if |
| 617 | allocate(character(len=len_trim(new_filename)) :: editor%tabs(tab_index)%panes(1)%filename) |
| 618 | editor%tabs(tab_index)%panes(1)%filename = trim(new_filename) |
| 619 | end if |
| 620 | end if |
| 621 | |
| 622 | ! Update editor filename |
| 623 | if (allocated(editor%filename)) then |
| 624 | deallocate(editor%filename) |
| 625 | end if |
| 626 | allocate(character(len=len_trim(new_filename)) :: editor%filename) |
| 627 | editor%filename = trim(new_filename) |
| 628 | |
| 629 | ! Now save the file |
| 630 | call buffer_save_file(buffer, new_filename, save_status) |
| 631 | if (save_status == 0) then |
| 632 | buffer%modified = .false. |
| 633 | editor%tabs(tab_index)%modified = .false. |
| 634 | success = .true. |
| 635 | end if |
| 636 | end subroutine prompt_for_filename_and_save |
| 637 | |
| 638 | !> Handle unsaved files on quit - prompt for save/backup |
| 639 | subroutine handle_unsaved_files_on_quit(editor, buffer, should_quit) |
| 640 | type(editor_state_t), intent(inout) :: editor |
| 641 | type(buffer_t), intent(inout) :: buffer |
| 642 | logical, intent(inout) :: should_quit |
| 643 | type(save_prompt_result_t) :: prompt_result |
| 644 | integer :: i, save_status, modified_count, current_modified |
| 645 | logical :: backup_success, save_all |
| 646 | |
| 647 | ! Count total modified tabs |
| 648 | modified_count = 0 |
| 649 | do i = 1, size(editor%tabs) |
| 650 | if (editor%tabs(i)%modified) then |
| 651 | modified_count = modified_count + 1 |
| 652 | end if |
| 653 | end do |
| 654 | |
| 655 | ! Process each modified tab |
| 656 | current_modified = 0 |
| 657 | save_all = .false. |
| 658 | do i = 1, size(editor%tabs) |
| 659 | if (editor%tabs(i)%modified) then |
| 660 | current_modified = current_modified + 1 |
| 661 | |
| 662 | ! Skip prompt if "save all" was selected |
| 663 | if (.not. save_all) then |
| 664 | ! Prompt user for this file with progress |
| 665 | call save_prompt(editor%tabs(i)%filename, prompt_result, current_modified, modified_count) |
| 666 | |
| 667 | if (prompt_result%action == 'a') then |
| 668 | ! Save all - set flag and treat as save for this file |
| 669 | save_all = .true. |
| 670 | prompt_result%action = 's' |
| 671 | end if |
| 672 | |
| 673 | if (prompt_result%action == 'c') then |
| 674 | ! User cancelled - don't quit |
| 675 | should_quit = .false. |
| 676 | return |
| 677 | end if |
| 678 | else |
| 679 | ! Save all is active - auto-save this file |
| 680 | prompt_result%action = 's' |
| 681 | end if |
| 682 | |
| 683 | if (prompt_result%action == 's') then |
| 684 | ! User wants to save - switch to this tab and save |
| 685 | editor%active_tab_index = i |
| 686 | call switch_to_tab_with_buffer(editor, i, buffer) |
| 687 | |
| 688 | ! Check if this is an [Untitled] file - need to prompt for filename |
| 689 | if (index(editor%tabs(i)%filename, '[Untitled') == 1) then |
| 690 | call prompt_for_filename_and_save(editor, buffer, i, should_quit) |
| 691 | if (.not. should_quit) return ! User cancelled |
| 692 | else |
| 693 | ! Save the file (no backup - it's saved!) |
| 694 | call buffer_save_file(buffer, editor%tabs(i)%filename, save_status) |
| 695 | if (save_status == 0) then |
| 696 | buffer%modified = .false. |
| 697 | editor%tabs(i)%modified = .false. |
| 698 | end if |
| 699 | end if |
| 700 | |
| 701 | else if (prompt_result%action == 'd') then |
| 702 | ! User wants to discard - create backup for later recovery |
| 703 | ! But skip [Untitled] files - they're in-memory only |
| 704 | if (index(editor%tabs(i)%filename, '[Untitled') /= 1) then |
| 705 | call backup_create(editor%workspace_path, editor%tabs(i)%filename, backup_success) |
| 706 | end if |
| 707 | ! Continue even if backup fails |
| 708 | end if |
| 709 | end if |
| 710 | end do |
| 711 | |
| 712 | ! All handled - proceed with quit |
| 713 | should_quit = .true. |
| 714 | end subroutine handle_unsaved_files_on_quit |
| 715 | |
| 716 | !> Handle single file on quit (for non-workspace mode) |
| 717 | subroutine handle_single_file_on_quit(buffer, editor, should_quit) |
| 718 | type(buffer_t), intent(inout) :: buffer |
| 719 | type(editor_state_t), intent(inout) :: editor |
| 720 | logical, intent(inout) :: should_quit |
| 721 | type(save_prompt_result_t) :: prompt_result |
| 722 | integer :: save_status |
| 723 | logical :: backup_success |
| 724 | |
| 725 | if (.not. buffer%modified) return |
| 726 | if (.not. allocated(editor%filename)) return |
| 727 | |
| 728 | ! Prompt user for this file |
| 729 | call save_prompt(editor%filename, prompt_result) |
| 730 | |
| 731 | if (prompt_result%action == 'y') then |
| 732 | ! User wants to save |
| 733 | call buffer_save_file(buffer, editor%filename, save_status) |
| 734 | if (save_status == 0) then |
| 735 | buffer%modified = .false. |
| 736 | editor%modified = .false. |
| 737 | end if |
| 738 | else if (prompt_result%action == 'n') then |
| 739 | ! User wants to skip - create backup |
| 740 | call backup_create(editor%workspace_path, editor%filename, backup_success) |
| 741 | ! Continue even if backup fails |
| 742 | else if (prompt_result%action == 'c') then |
| 743 | ! User cancelled - don't quit |
| 744 | should_quit = .false. |
| 745 | return |
| 746 | end if |
| 747 | |
| 748 | ! Proceed with quit |
| 749 | should_quit = .true. |
| 750 | end subroutine handle_single_file_on_quit |
| 751 | |
| 752 | !> Handle a restored file - open in tab or reload existing tab |
| 753 | subroutine handle_restored_file(editor, buffer, restored_file) |
| 754 | use text_buffer_module, only: buffer_load_file |
| 755 | type(editor_state_t), intent(inout) :: editor |
| 756 | type(buffer_t), intent(inout) :: buffer |
| 757 | character(len=*), intent(in) :: restored_file |
| 758 | integer :: tab_idx, status |
| 759 | logical :: found |
| 760 | |
| 761 | ! Check if file is already open in a tab |
| 762 | found = .false. |
| 763 | if (allocated(editor%tabs)) then |
| 764 | do tab_idx = 1, size(editor%tabs) |
| 765 | if (allocated(editor%tabs(tab_idx)%filename)) then |
| 766 | if (trim(editor%tabs(tab_idx)%filename) == trim(restored_file)) then |
| 767 | ! File is already in a tab - reload it |
| 768 | found = .true. |
| 769 | call switch_to_tab_with_buffer(editor, tab_idx, buffer) |
| 770 | call buffer_load_file(buffer, restored_file, status) |
| 771 | if (status == 0) then |
| 772 | buffer%modified = .false. |
| 773 | editor%tabs(tab_idx)%modified = .false. |
| 774 | end if |
| 775 | exit |
| 776 | end if |
| 777 | end if |
| 778 | end do |
| 779 | end if |
| 780 | |
| 781 | ! If not found, create a new tab with this file |
| 782 | if (.not. found) then |
| 783 | call create_tab(editor, restored_file) |
| 784 | if (allocated(editor%tabs) .and. editor%active_tab_index > 0) then |
| 785 | ! Load the restored file into the buffer |
| 786 | call buffer_load_file(buffer, restored_file, status) |
| 787 | if (status == 0) then |
| 788 | buffer%modified = .false. |
| 789 | ! Now switch to the tab and sync the buffer |
| 790 | call switch_to_tab_with_buffer(editor, editor%active_tab_index, buffer) |
| 791 | if (allocated(editor%tabs) .and. editor%active_tab_index <= size(editor%tabs)) then |
| 792 | editor%tabs(editor%active_tab_index)%modified = .false. |
| 793 | end if |
| 794 | end if |
| 795 | end if |
| 796 | end if |
| 797 | end subroutine handle_restored_file |
| 798 | |
| 799 | !> Handle backup restoration on workspace load |
| 800 | subroutine handle_backup_restoration(editor, buffer) |
| 801 | use backup_module, only: backup_list, backup_info_t, backup_restore, backup_delete |
| 802 | type(editor_state_t), intent(inout) :: editor |
| 803 | type(buffer_t), intent(inout) :: buffer |
| 804 | type(backup_info_t), allocatable :: backups(:), unique_backups(:) |
| 805 | integer :: backup_count, unique_count, i, j, status |
| 806 | character :: choice |
| 807 | character(len=32) :: key_input |
| 808 | logical :: restore_success, found |
| 809 | integer(int64) :: current_timestamp, best_timestamp |
| 810 | |
| 811 | ! Get list of backups |
| 812 | call backup_list(editor%workspace_path, backups, backup_count) |
| 813 | |
| 814 | ! Deduplicate - keep only the most recent backup for each unique file |
| 815 | allocate(unique_backups(backup_count)) |
| 816 | unique_count = 0 |
| 817 | |
| 818 | do i = 1, backup_count |
| 819 | if (len_trim(backups(i)%original_file) == 0) cycle |
| 820 | |
| 821 | ! Skip [Untitled] backups - they're in-memory only |
| 822 | if (index(backups(i)%original_file, '[Untitled') == 1) then |
| 823 | call backup_delete(backups(i)%backup_file) |
| 824 | cycle |
| 825 | end if |
| 826 | |
| 827 | ! Check if we already have a backup for this file |
| 828 | found = .false. |
| 829 | do j = 1, unique_count |
| 830 | if (trim(unique_backups(j)%original_file) == trim(backups(i)%original_file)) then |
| 831 | ! Found duplicate - keep the one with newer timestamp |
| 832 | found = .true. |
| 833 | read(backups(i)%timestamp, *, iostat=status) current_timestamp |
| 834 | read(unique_backups(j)%timestamp, *, iostat=status) best_timestamp |
| 835 | if (status == 0 .and. current_timestamp > best_timestamp) then |
| 836 | ! This backup is newer - replace it and delete old one |
| 837 | call backup_delete(unique_backups(j)%backup_file) |
| 838 | unique_backups(j) = backups(i) |
| 839 | else |
| 840 | ! Keep existing, delete this duplicate |
| 841 | call backup_delete(backups(i)%backup_file) |
| 842 | end if |
| 843 | exit |
| 844 | end if |
| 845 | end do |
| 846 | |
| 847 | ! If not found, add to unique list |
| 848 | if (.not. found) then |
| 849 | unique_count = unique_count + 1 |
| 850 | unique_backups(unique_count) = backups(i) |
| 851 | end if |
| 852 | end do |
| 853 | |
| 854 | ! Prompt for each unique backup |
| 855 | i = 1 |
| 856 | do while (i <= unique_count) |
| 857 | ! Show restore prompt with progress |
| 858 | choice = backup_prompt_restore(unique_backups(i)%original_file, i, unique_count, & |
| 859 | unique_backups(i)%timestamp) |
| 860 | |
| 861 | if (choice == 'r') then |
| 862 | ! Restore the backup |
| 863 | call backup_restore(unique_backups(i)%backup_file, & |
| 864 | unique_backups(i)%original_file, restore_success) |
| 865 | |
| 866 | if (restore_success) then |
| 867 | ! After successful restore, open/reload this file in a tab |
| 868 | call handle_restored_file(editor, buffer, unique_backups(i)%original_file) |
| 869 | end if |
| 870 | |
| 871 | ! Show confirmation |
| 872 | call terminal_clear_screen() |
| 873 | call terminal_move_cursor(1, 1) |
| 874 | if (restore_success) then |
| 875 | call terminal_write('Restored: ' // trim(unique_backups(i)%original_file)) |
| 876 | else |
| 877 | call terminal_write('Failed to restore: ' // trim(unique_backups(i)%original_file)) |
| 878 | end if |
| 879 | call terminal_move_cursor(3, 1) |
| 880 | call terminal_write('Press any key to continue...') |
| 881 | call get_key_input(key_input, status) |
| 882 | |
| 883 | i = i + 1 ! Move to next |
| 884 | |
| 885 | else if (choice == 'd') then |
| 886 | ! Delete backup - keep current file |
| 887 | call backup_delete(unique_backups(i)%backup_file) |
| 888 | i = i + 1 ! Move to next |
| 889 | |
| 890 | else if (choice == 'c') then |
| 891 | ! Compare - show diff (then loop back to prompt again) |
| 892 | call show_backup_diff(unique_backups(i)%backup_file, & |
| 893 | unique_backups(i)%original_file) |
| 894 | ! Don't increment i - re-prompt for same file |
| 895 | end if |
| 896 | end do |
| 897 | |
| 898 | ! Clear screen after all prompts |
| 899 | call terminal_clear_screen() |
| 900 | end subroutine handle_backup_restoration |
| 901 | |
| 902 | !> Show diff between backup and current file |
| 903 | subroutine show_backup_diff(backup_file, original_file) |
| 904 | character(len=*), intent(in) :: backup_file, original_file |
| 905 | character(len=512) :: cmd |
| 906 | character(len=32) :: key_input |
| 907 | integer :: status |
| 908 | |
| 909 | ! backup_file already contains full path, use it directly |
| 910 | ! Clear screen and show diff |
| 911 | call terminal_clear_screen() |
| 912 | call terminal_move_cursor(1, 1) |
| 913 | call terminal_write('Diff: ' // trim(original_file) // ' vs backup') |
| 914 | call terminal_move_cursor(2, 1) |
| 915 | call terminal_write('=' // repeat('=', 70)) |
| 916 | |
| 917 | ! Shell out to diff command |
| 918 | write(cmd, '(A,A,A,A,A)') "diff -u '", trim(backup_file), "' '", trim(original_file), "'" |
| 919 | call execute_command_line(trim(cmd), wait=.true.) |
| 920 | |
| 921 | ! Wait for user |
| 922 | call terminal_move_cursor(24, 1) |
| 923 | call terminal_write('Press any key to continue...') |
| 924 | call get_key_input(key_input, status) |
| 925 | end subroutine show_backup_diff |
| 926 | |
| 927 | ! Register all available commands for the command palette |
| 928 | subroutine register_all_commands() |
| 929 | ! File operations |
| 930 | call register_command('Save File', 'save', 'Ctrl+S', 'File') |
| 931 | call register_command('Save All', 'save-all', 'Ctrl+Shift+S', 'File') |
| 932 | call register_command('Quit', 'quit', 'Ctrl+Q', 'File') |
| 933 | call register_command('Open File', 'open', 'Ctrl+O', 'File') |
| 934 | call register_command('Toggle File Tree', 'toggle-tree', 'F3', 'File') |
| 935 | |
| 936 | ! Edit operations |
| 937 | call register_command('Copy', 'copy', 'Ctrl+C', 'Edit') |
| 938 | call register_command('Paste', 'paste', 'Ctrl+V', 'Edit') |
| 939 | call register_command('Cut', 'cut', 'Ctrl+X', 'Edit') |
| 940 | call register_command('Undo', 'undo', 'Ctrl+Z', 'Edit') |
| 941 | call register_command('Redo', 'redo', 'Ctrl+Y', 'Edit') |
| 942 | |
| 943 | ! Search operations |
| 944 | call register_command('Find', 'find', 'Ctrl+F', 'Search') |
| 945 | call register_command('Replace', 'replace', 'Ctrl+H', 'Search') |
| 946 | call register_command('Find Next', 'find-next', 'Ctrl+G', 'Search') |
| 947 | call register_command('Find Previous', 'find-prev', 'Shift+Ctrl+G', 'Search') |
| 948 | |
| 949 | ! Navigation |
| 950 | call register_command('Go to Line', 'goto-line', 'Ctrl+G', 'Navigation') |
| 951 | call register_command('Go to Definition', 'goto-def', 'F12', 'Navigation') |
| 952 | call register_command('Find References', 'find-refs', 'Shift+F12', 'Navigation') |
| 953 | call register_command('Jump Back', 'jump-back', 'Alt+,', 'Navigation') |
| 954 | call register_command('Go to Symbol', 'goto-symbol', 'Ctrl+Shift+O', 'Navigation') |
| 955 | |
| 956 | ! LSP features |
| 957 | call register_command('Code Actions', 'code-actions', 'Ctrl+.', 'LSP') |
| 958 | call register_command('Rename Symbol', 'rename', 'F2', 'LSP') |
| 959 | call register_command('Show Diagnostics', 'diagnostics', 'Ctrl+Shift+D', 'LSP') |
| 960 | call register_command('Show Hover Info', 'hover', 'Ctrl+K Ctrl+I', 'LSP') |
| 961 | |
| 962 | ! View |
| 963 | call register_command('Split Vertical', 'split-v', 'Ctrl+\\', 'View') |
| 964 | call register_command('Split Horizontal', 'split-h', 'Ctrl+Shift+\\', 'View') |
| 965 | call register_command('Close Pane', 'close-pane', 'Ctrl+W', 'View') |
| 966 | call register_command('Navigate Pane Left', 'pane-left', 'Ctrl+H', 'View') |
| 967 | call register_command('Navigate Pane Right', 'pane-right', 'Ctrl+L', 'View') |
| 968 | call register_command('Navigate Pane Up', 'pane-up', 'Ctrl+K', 'View') |
| 969 | call register_command('Navigate Pane Down', 'pane-down', 'Ctrl+J', 'View') |
| 970 | |
| 971 | ! Help |
| 972 | call register_command('Show Help', 'help', '?', 'Help') |
| 973 | call register_command('Command Palette', 'palette', 'Ctrl+Shift+P', 'Help') |
| 974 | end subroutine register_all_commands |
| 975 | |
| 976 | end program facsimile |