Fortran · 316476 bytes Raw Blame History
1 module command_handler_module
2 use iso_fortran_env, only: int32, error_unit
3 use iso_c_binding, only: c_int
4 use editor_state_module, only: editor_state_t, cursor_t, switch_to_tab_with_buffer, &
5 close_tab, create_tab, close_pane, split_pane_vertical, split_pane_horizontal, &
6 navigate_to_pane_left, navigate_to_pane_right, navigate_to_pane_up, navigate_to_pane_down, &
7 sync_editor_to_pane, tab_t
8 use text_buffer_module
9 use renderer_module, only: update_viewport, render_screen, render_screen_with_tree, tree_state, &
10 fuss_search_buffer, fuss_search_len, fuss_search_last_time, &
11 fuss_fuzzy_jump, fuss_reset_search, get_time_ms, &
12 fuss_git_prefix_active
13 use yank_stack_module
14 use clipboard_module
15 use help_display_module, only: show_help
16 use goto_prompt_module, only: show_goto_prompt
17 use search_prompt_module, only: show_search_prompt, search_forward, search_backward, &
18 current_search_pattern
19 use replace_prompt_module, only: show_replace_prompt
20 use unified_search_module, only: show_unified_search_prompt
21 use undo_stack_module
22 use terminal_io_module, only: terminal_move_cursor, terminal_write, terminal_clear_screen
23 use bracket_matching_module, only: find_matching_bracket
24 use file_tree_module
25 use git_ops_module
26 use text_prompt_module, only: show_text_prompt, show_yes_no_prompt
27 use fortress_navigator_module, only: open_fortress_navigator
28 use binary_prompt_module, only: binary_file_prompt
29 use lsp_server_manager_module, only: request_completion, request_hover, request_definition, &
30 request_references, request_code_actions, request_document_symbols, &
31 request_signature_help, request_formatting, request_rename, &
32 process_server_messages, filename_to_uri, &
33 get_server_with_capability, notify_file_opened, &
34 CAP_COMPLETION, CAP_DEFINITION, CAP_REFERENCES, CAP_RENAME, &
35 CAP_CODE_ACTIONS, CAP_FORMATTING, CAP_HOVER, CAP_DOCUMENT_SYMBOLS
36 use rename_prompt_module, only: show_rename_prompt
37 use completion_popup_module, only: show_completion_popup, hide_completion_popup, &
38 handle_completion_response, navigate_completion_up, &
39 navigate_completion_down, get_selected_completion, &
40 is_completion_visible
41 use hover_tooltip_module, only: show_hover_tooltip, hide_hover_tooltip, &
42 handle_hover_response, is_hover_visible
43 use diagnostics_panel_module, only: toggle_panel => toggle_diagnostics_panel, &
44 is_diagnostics_panel_visible, &
45 diagnostics_panel_handle_key
46 use references_panel_module, only: toggle_references_panel, &
47 is_references_panel_visible, &
48 references_panel_handle_key, &
49 get_selected_reference_location, &
50 hide_references_panel, &
51 show_references_panel, &
52 set_references, reference_location_t
53 use code_actions_panel_module, only: code_actions_panel_t, init_code_actions_panel, &
54 cleanup_code_actions_panel, show_code_actions_panel, &
55 hide_code_actions_panel, is_code_actions_panel_visible, &
56 code_actions_panel_handle_key, set_code_actions, &
57 clear_code_actions, get_selected_action
58 use symbols_panel_module, only: symbols_panel_t, document_symbol_t, &
59 toggle_symbols_panel, is_symbols_panel_visible, &
60 symbols_panel_handle_key, get_selected_symbol_location, &
61 hide_symbols_panel, show_symbols_panel, &
62 set_symbols, clear_symbols
63 use signature_tooltip_module, only: signature_tooltip_t, show_signature_tooltip, &
64 hide_signature_tooltip, is_signature_tooltip_visible, &
65 handle_signature_response
66 use jump_stack_module, only: push_jump_location, pop_jump_location, &
67 is_jump_stack_empty
68 use diagnostics_module, only: get_diagnostics_for_line, get_diagnostics_for_line_by_server, &
69 diagnostics_to_json, diagnostic_t
70 use json_module, only: json_value_t
71 use lsp_server_installer_panel_module, only: show_lsp_server_installer_panel, &
72 hide_lsp_server_installer_panel, &
73 is_lsp_server_installer_panel_visible, &
74 lsp_server_installer_panel_handle_key, &
75 render_lsp_server_installer_panel, &
76 refresh_server_status
77 implicit none
78 private
79
80 public :: handle_key_command, init_command_handler, cleanup_command_handler
81 public :: save_initial_state_for_undo
82 public :: search_pattern, match_case_sensitive ! Exposed for status bar hint
83 public :: g_lsp_modified_buffer ! Flag for immediate render after LSP edits
84 public :: g_lsp_ui_changed ! Flag for immediate render after LSP UI changes
85 public :: g_cursor_only_move ! Flag for cursor-only moves (skip full re-render)
86
87 ! Flag to track if LSP modified the buffer (for immediate rendering)
88 logical :: g_lsp_modified_buffer = .false.
89 ! Flag to track if LSP changed UI panels (for immediate rendering)
90 logical :: g_lsp_ui_changed = .false.
91 ! Flag for cursor-only movements (can skip full re-render)
92 logical :: g_cursor_only_move = .false.
93
94 type(yank_stack_t) :: yank_stack
95 type(undo_stack_t) :: undo_stack
96 character(len=:), allocatable :: search_pattern ! For ctrl-d functionality
97 logical :: match_case_sensitive = .true. ! Case sensitivity for ctrl-d match mode
98 logical :: last_action_was_edit = .false.
99
100 ! Module-level storage for LSP callbacks
101 type(editor_state_t), pointer, save :: saved_editor_for_callback => null()
102 type(buffer_t), pointer, save :: saved_buffer_for_callback => null()
103
104
105 contains
106
107 ! Helper to get a server index for a specific capability
108 function get_lsp_server_for_cap(editor, capability) result(server_idx)
109 type(editor_state_t), intent(in) :: editor
110 integer, intent(in) :: capability
111 integer :: server_idx
112 integer :: tab_idx
113
114 server_idx = 0
115 tab_idx = editor%active_tab_index
116
117 if (tab_idx < 1 .or. tab_idx > size(editor%tabs)) return
118 if (editor%tabs(tab_idx)%num_lsp_servers < 1) return
119 if (.not. allocated(editor%tabs(tab_idx)%lsp_server_indices)) return
120
121 server_idx = get_server_with_capability(editor%lsp_manager, &
122 editor%tabs(tab_idx)%lsp_server_indices, &
123 editor%tabs(tab_idx)%num_lsp_servers, &
124 capability)
125 end function get_lsp_server_for_cap
126
127 subroutine init_command_handler()
128 call init_yank_stack(yank_stack)
129 call init_undo_stack(undo_stack)
130 last_action_was_edit = .false.
131 end subroutine init_command_handler
132
133 subroutine save_initial_state_for_undo(buffer, editor)
134 use undo_stack_module, only: save_initial_undo_state
135 type(buffer_t), intent(in) :: buffer
136 type(editor_state_t), intent(in) :: editor
137 call save_initial_undo_state(undo_stack, buffer, editor%cursors(editor%active_cursor))
138 end subroutine save_initial_state_for_undo
139
140 subroutine cleanup_command_handler()
141 call cleanup_yank_stack(yank_stack)
142 call cleanup_undo_stack(undo_stack)
143 if (allocated(search_pattern)) deallocate(search_pattern)
144 end subroutine cleanup_command_handler
145
146 subroutine save_undo_state(buffer, editor)
147 type(buffer_t), intent(in) :: buffer
148 type(editor_state_t), intent(in) :: editor
149
150 ! Save current state to undo stack
151 call push_undo_state(undo_stack, buffer, editor%cursors(editor%active_cursor))
152 end subroutine save_undo_state
153
154 subroutine handle_key_command(key_str, editor, buffer, should_quit)
155 character(len=*), intent(in) :: key_str
156 type(editor_state_t), intent(inout), target :: editor
157 type(buffer_t), intent(inout), target :: buffer
158 logical, intent(out) :: should_quit
159 integer :: line_count, i, j, insert_line, pane_idx
160 logical :: is_edit_action
161 type(cursor_t), allocatable :: new_cursors(:)
162 integer, allocatable :: original_lines(:)
163 character(len=:), allocatable :: line
164
165 should_quit = .false.
166 line_count = buffer_get_line_count(buffer)
167 is_edit_action = .false.
168
169 ! Ignore empty key strings (from terminal position reports, etc)
170 if (len_trim(key_str) == 0 .and. key_str(1:1) /= ' ') then
171 return
172 end if
173
174 ! Reset search_pattern for any key except ctrl-d and alt-c
175 ! This ensures ctrl-d always starts fresh when not in an active match sequence
176 ! alt-c is preserved to allow toggling case sensitivity during match mode
177 if (trim(key_str) /= 'ctrl-d' .and. trim(key_str) /= 'alt-c' .and. allocated(search_pattern)) then
178 deallocate(search_pattern)
179 match_case_sensitive = .true. ! Reset to default
180 end if
181
182 ! Route input when in fuss mode (except ctrl-b/ctrl-shift-b/F-keys/Alt-keys/ctrl-q which work in both modes)
183 if (editor%fuss_mode_active .and. trim(key_str) /= 'ctrl-b' .and. &
184 trim(key_str) /= 'ctrl-shift-b' .and. trim(key_str) /= 'f2' .and. &
185 trim(key_str) /= 'f3' .and. trim(key_str) /= 'f4' .and. &
186 trim(key_str) /= 'f6' .and. trim(key_str) /= 'f8' .and. &
187 trim(key_str) /= 'f12' .and. trim(key_str) /= 'shift-f12' .and. &
188 trim(key_str) /= 'alt-g' .and. trim(key_str) /= 'alt-o' .and. &
189 trim(key_str) /= 'alt-p' .and. trim(key_str) /= 'alt-e' .and. &
190 trim(key_str) /= 'alt-r' .and. trim(key_str) /= 'ctrl-\\' .and. &
191 trim(key_str) /= 'ctrl-q') then
192 call handle_fuss_input(key_str, editor, buffer)
193 return
194 end if
195
196 ! Route keys to diagnostics panel when visible (j/k/arrows for navigation)
197 if (is_diagnostics_panel_visible(editor%diagnostics_panel)) then
198 if (diagnostics_panel_handle_key(editor%diagnostics_panel, trim(key_str))) then
199 return
200 end if
201 end if
202
203 ! Route keys to code actions panel when visible
204 if (is_code_actions_panel_visible(editor%code_actions_panel)) then
205 if (code_actions_panel_handle_key(editor%code_actions_panel, trim(key_str))) then
206 ! For Enter, we need to apply the code action here since panel just returns handled=true
207 if (trim(key_str) == 'enter') then
208 call apply_selected_code_action(editor, buffer)
209 end if
210 return
211 end if
212 end if
213
214 ! Route keys to references panel when visible
215 if (is_references_panel_visible(editor%references_panel)) then
216 ! Handle Enter specially - jump to reference location
217 if (trim(key_str) == 'enter') then
218 block
219 use iso_fortran_env, only: int32
220 character(len=:), allocatable :: uri
221 integer(int32) :: ref_line, ref_col
222
223 if (get_selected_reference_location(editor%references_panel, uri, ref_line, ref_col)) then
224 ! Convert URI to file path and navigate
225 if (len(uri) >= 7 .and. uri(1:7) == "file://") then
226 ! Jump to the reference location
227 editor%cursors(editor%active_cursor)%line = ref_line
228 editor%cursors(editor%active_cursor)%column = ref_col
229 ! Center the view on the target line
230 editor%viewport_line = max(1, ref_line - editor%screen_rows / 2)
231 ! Hide the panel after jumping
232 call hide_references_panel(editor%references_panel)
233 end if
234 end if
235 end block
236 return
237 end if
238 if (references_panel_handle_key(editor%references_panel, trim(key_str))) then
239 return
240 end if
241 end if
242
243 ! Route keys to symbols panel when visible
244 if (is_symbols_panel_visible(editor%symbols_panel)) then
245 ! Handle Enter specially - jump to symbol location
246 if (trim(key_str) == 'enter') then
247 block
248 use iso_fortran_env, only: int32
249 integer(int32) :: sym_line, sym_col
250 if (get_selected_symbol_location(editor%symbols_panel, sym_line, sym_col)) then
251 ! Jump to the symbol location
252 editor%cursors(editor%active_cursor)%line = sym_line
253 editor%cursors(editor%active_cursor)%column = sym_col
254 ! Center the view on the target line
255 editor%viewport_line = max(1, sym_line - editor%screen_rows / 2)
256 ! Hide the panel after jumping
257 call hide_symbols_panel(editor%symbols_panel)
258 end if
259 end block
260 return
261 end if
262 if (symbols_panel_handle_key(editor%symbols_panel, trim(key_str))) then
263 return
264 end if
265 end if
266
267 ! Route keys to LSP server installer panel when visible
268 if (is_lsp_server_installer_panel_visible(editor%lsp_installer_panel)) then
269 if (lsp_server_installer_panel_handle_key(editor%lsp_installer_panel, trim(key_str))) then
270 call render_lsp_server_installer_panel(editor%lsp_installer_panel, &
271 editor%screen_cols)
272 return
273 end if
274 end if
275
276 select case(trim(key_str))
277 ! File operations
278 case('ctrl-q')
279 should_quit = .true.
280
281 case('ctrl-b', 'ctrl-shift-b', 'f3')
282 ! Toggle fuss mode (file tree)
283 ! ctrl-b: Original binding (conflicts with tmux prefix)
284 ! ctrl-shift-b: Alternative (tmux may still catch this)
285 ! f3: Tmux/terminal-safe alternative (recommended)
286 call toggle_fuss_mode(editor)
287
288 case('ctrl-o')
289 ! Open fortress navigator (file/directory picker)
290 call handle_fortress_navigator(editor, buffer)
291
292 case('esc')
293 ! If completion popup is visible, hide it
294 if (is_completion_visible(editor%completion_popup)) then
295 call hide_completion_popup(editor%completion_popup)
296 return
297 end if
298
299 ! If hover tooltip is visible, hide it
300 if (is_hover_visible(editor%hover_tooltip)) then
301 call hide_hover_tooltip(editor%hover_tooltip)
302 return
303 end if
304
305 ! Other panels (diagnostics, code_actions, references, symbols) are handled in early routing
306
307 ! ESC - Clear selections and return to single cursor mode
308 if (size(editor%cursors) > 1) then
309 ! Keep only the active cursor
310 allocate(new_cursors(1))
311 new_cursors(1) = editor%cursors(editor%active_cursor)
312 new_cursors(1)%has_selection = .false.
313 deallocate(editor%cursors)
314 editor%cursors = new_cursors
315 editor%active_cursor = 1
316 else
317 ! Single cursor - just clear selection
318 editor%cursors(editor%active_cursor)%has_selection = .false.
319 end if
320 call sync_editor_to_pane(editor)
321 call update_viewport(editor)
322
323 case('ctrl-?', 'ctrl-/')
324 ! Show help menu
325 ! Both ctrl-? and ctrl-/ supported for compatibility
326 ! ctrl-/ is more reliable across terminals
327 call show_help(editor)
328 ! Screen will be redrawn automatically by main loop
329
330 case('ctrl-g')
331 ! Go to line:column
332 call show_goto_prompt(editor, buffer)
333 call sync_editor_to_pane(editor)
334 call update_viewport(editor)
335
336 case('ctrl-l')
337 ! Clear and redraw screen
338 call terminal_clear_screen()
339 ! Screen will be redrawn automatically by main loop
340
341 ! Undo/Redo
342 case('ctrl-z')
343 ! Undo
344 if (can_undo(undo_stack)) then
345 call perform_undo(undo_stack, buffer, editor%cursors(editor%active_cursor))
346 ! Sync even for single cursor case since undo changes cursor position
347 call sync_editor_to_pane(editor)
348 ! If we have multiple cursors, reset to single cursor mode
349 ! (Undo only tracks one cursor's state)
350 if (size(editor%cursors) > 1) then
351 allocate(new_cursors(1))
352 new_cursors(1) = editor%cursors(editor%active_cursor)
353 ! Clamp cursor to actual line length after undo
354 line = buffer_get_line(buffer, new_cursors(1)%line)
355 if (new_cursors(1)%column > len(line) + 1) then
356 new_cursors(1)%column = len(line) + 1
357 end if
358 new_cursors(1)%desired_column = new_cursors(1)%column
359 if (allocated(line)) deallocate(line)
360 deallocate(editor%cursors)
361 editor%cursors = new_cursors
362 editor%active_cursor = 1
363 end if
364 call sync_editor_to_pane(editor)
365 call update_viewport(editor)
366 end if
367
368 case('ctrl-shift-z', 'ctrl-]')
369 ! Redo
370 ! ctrl-shift-z: Standard redo (WezTerm may intercept - disable in config)
371 ! ctrl-]: Alternative redo binding
372 if (can_redo(undo_stack)) then
373 call perform_redo(undo_stack, buffer, editor%cursors(editor%active_cursor))
374 ! Sync even for single cursor case since redo changes cursor position
375 call sync_editor_to_pane(editor)
376 ! If we have multiple cursors, reset to single cursor mode
377 ! (Undo only tracks one cursor's state)
378 if (size(editor%cursors) > 1) then
379 allocate(new_cursors(1))
380 new_cursors(1) = editor%cursors(editor%active_cursor)
381 deallocate(editor%cursors)
382 editor%cursors = new_cursors
383 editor%active_cursor = 1
384 end if
385 call sync_editor_to_pane(editor)
386 call update_viewport(editor)
387 end if
388
389 case('ctrl-y')
390 ! Yank (paste from yank stack)
391 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
392 if (size(editor%cursors) > 1) then
393 ! Apply yank to all cursors
394 do i = 1, size(editor%cursors)
395 call yank_text(editor%cursors(i), buffer)
396 end do
397 else
398 call yank_text(editor%cursors(editor%active_cursor), buffer)
399 end if
400 call sync_editor_to_pane(editor)
401 is_edit_action = .true.
402
403 ! Navigation
404 case('up')
405 ! If completion popup is visible, navigate it instead
406 if (is_completion_visible(editor%completion_popup)) then
407 call navigate_completion_up(editor%completion_popup)
408 return
409 end if
410
411 ! Other panels (diagnostics, code_actions, references, symbols) are handled in early routing
412
413 if (size(editor%cursors) > 1) then
414 ! Move all cursors
415 do i = 1, size(editor%cursors)
416 call move_cursor_up(editor%cursors(i), buffer)
417 end do
418 ! Remove duplicate cursors that ended up at same position
419 call deduplicate_cursors(editor)
420 else
421 call move_cursor_up(editor%cursors(editor%active_cursor), buffer)
422 end if
423 call sync_editor_to_pane(editor)
424 call update_viewport(editor)
425 g_cursor_only_move = .true.
426
427 case('down')
428 ! If completion popup is visible, navigate it instead
429 if (is_completion_visible(editor%completion_popup)) then
430 call navigate_completion_down(editor%completion_popup)
431 return
432 end if
433
434 ! Other panels (diagnostics, code_actions, references, symbols) are handled in early routing
435
436 if (size(editor%cursors) > 1) then
437 ! Move all cursors
438 do i = 1, size(editor%cursors)
439 call move_cursor_down(editor%cursors(i), buffer, line_count)
440 end do
441 ! Remove duplicate cursors that ended up at same position
442 call deduplicate_cursors(editor)
443 else
444 call move_cursor_down(editor%cursors(editor%active_cursor), buffer, line_count)
445 end if
446 call sync_editor_to_pane(editor)
447 call update_viewport(editor)
448 g_cursor_only_move = .true.
449
450 case('left')
451 ! Hide hover tooltip on movement
452 if (is_hover_visible(editor%hover_tooltip)) then
453 call hide_hover_tooltip(editor%hover_tooltip)
454 end if
455
456 if (size(editor%cursors) > 1) then
457 ! Move all cursors
458 do i = 1, size(editor%cursors)
459 call move_cursor_left(editor%cursors(i), buffer)
460 end do
461 ! Remove duplicate cursors that ended up at same position
462 call deduplicate_cursors(editor)
463 else
464 call move_cursor_left(editor%cursors(editor%active_cursor), buffer)
465 end if
466 call sync_editor_to_pane(editor)
467 call update_viewport(editor)
468 g_cursor_only_move = .true.
469
470 case('right')
471 ! Hide hover tooltip on movement
472 if (is_hover_visible(editor%hover_tooltip)) then
473 call hide_hover_tooltip(editor%hover_tooltip)
474 end if
475
476 if (size(editor%cursors) > 1) then
477 ! Move all cursors
478 do i = 1, size(editor%cursors)
479 call move_cursor_right(editor%cursors(i), buffer)
480 end do
481 ! Remove duplicate cursors that ended up at same position
482 call deduplicate_cursors(editor)
483 else
484 call move_cursor_right(editor%cursors(editor%active_cursor), buffer)
485 end if
486 call sync_editor_to_pane(editor)
487 call update_viewport(editor)
488 g_cursor_only_move = .true.
489
490 ! Selection with shift+motion
491 case('shift-up')
492 call extend_selection_up(editor%cursors(editor%active_cursor), buffer)
493 call sync_editor_to_pane(editor)
494 call update_viewport(editor)
495
496 case('shift-down')
497 call extend_selection_down(editor%cursors(editor%active_cursor), buffer, line_count)
498 call sync_editor_to_pane(editor)
499 call update_viewport(editor)
500
501 case('shift-left')
502 call extend_selection_left(editor%cursors(editor%active_cursor), buffer)
503 call sync_editor_to_pane(editor)
504 call update_viewport(editor)
505
506 case('shift-right')
507 call extend_selection_right(editor%cursors(editor%active_cursor), buffer)
508 call sync_editor_to_pane(editor)
509 call update_viewport(editor)
510
511 case('home', 'ctrl-a')
512 if (size(editor%cursors) > 1) then
513 ! Move all cursors
514 do i = 1, size(editor%cursors)
515 call move_cursor_smart_home(editor%cursors(i), buffer)
516 end do
517 else
518 call move_cursor_smart_home(editor%cursors(editor%active_cursor), buffer)
519 end if
520 call sync_editor_to_pane(editor)
521 call update_viewport(editor)
522
523 case('end', 'ctrl-e')
524 if (size(editor%cursors) > 1) then
525 ! Move all cursors
526 do i = 1, size(editor%cursors)
527 call move_cursor_end(editor%cursors(i), buffer)
528 end do
529 else
530 call move_cursor_end(editor%cursors(editor%active_cursor), buffer)
531 end if
532 call sync_editor_to_pane(editor)
533 call update_viewport(editor)
534
535 case('shift-home', 'ctrl-shift-a')
536 call extend_selection_home(editor%cursors(editor%active_cursor))
537 call sync_editor_to_pane(editor)
538 call update_viewport(editor)
539
540 case('shift-end', 'ctrl-shift-e')
541 call extend_selection_end(editor%cursors(editor%active_cursor), buffer)
542 call sync_editor_to_pane(editor)
543 call update_viewport(editor)
544
545 case('pageup')
546 if (size(editor%cursors) > 1) then
547 ! Move all cursors
548 do i = 1, size(editor%cursors)
549 call move_cursor_page_up(editor%cursors(i), editor)
550 end do
551 else
552 call move_cursor_page_up(editor%cursors(editor%active_cursor), editor)
553 end if
554 call sync_editor_to_pane(editor)
555 call update_viewport(editor)
556
557 case('pagedown')
558 if (size(editor%cursors) > 1) then
559 ! Move all cursors
560 do i = 1, size(editor%cursors)
561 call move_cursor_page_down(editor%cursors(i), editor, line_count)
562 end do
563 else
564 call move_cursor_page_down(editor%cursors(editor%active_cursor), editor, line_count)
565 end if
566 call sync_editor_to_pane(editor)
567 call update_viewport(editor)
568
569 case('mouse-scroll-up')
570 ! Scroll viewport up by 3 lines (don't move cursor)
571 editor%viewport_line = max(1, editor%viewport_line - 3)
572 call sync_editor_to_pane(editor)
573
574 case('mouse-scroll-down')
575 ! Scroll viewport down by 3 lines (don't move cursor)
576 editor%viewport_line = min(max(1, line_count - editor%screen_rows + 2), &
577 editor%viewport_line + 3)
578 call sync_editor_to_pane(editor)
579
580 case('ctrl-home')
581 ! Jump to beginning of file
582 editor%cursors(editor%active_cursor)%line = 1
583 editor%cursors(editor%active_cursor)%column = 1
584 editor%cursors(editor%active_cursor)%desired_column = 1
585 editor%cursors(editor%active_cursor)%has_selection = .false.
586 call sync_editor_to_pane(editor)
587 call update_viewport(editor)
588
589 case('ctrl-end')
590 ! Jump to end of file
591 line_count = buffer_get_line_count(buffer)
592 editor%cursors(editor%active_cursor)%line = line_count
593 call move_cursor_end(editor%cursors(editor%active_cursor), buffer)
594 call sync_editor_to_pane(editor)
595 call update_viewport(editor)
596
597 case('shift-pageup')
598 call extend_selection_page_up(editor%cursors(editor%active_cursor), editor)
599 call sync_editor_to_pane(editor)
600 call update_viewport(editor)
601
602 case('shift-pagedown')
603 call extend_selection_page_down(editor%cursors(editor%active_cursor), editor, line_count)
604 call sync_editor_to_pane(editor)
605 call update_viewport(editor)
606
607 case('alt-left')
608 if (size(editor%cursors) > 1) then
609 ! Move all cursors
610 do i = 1, size(editor%cursors)
611 call move_cursor_word_left(editor%cursors(i), buffer)
612 end do
613 else
614 call move_cursor_word_left(editor%cursors(editor%active_cursor), buffer)
615 end if
616 call sync_editor_to_pane(editor)
617 call update_viewport(editor)
618
619 case('alt-right')
620 if (size(editor%cursors) > 1) then
621 ! Move all cursors
622 do i = 1, size(editor%cursors)
623 call move_cursor_word_right(editor%cursors(i), buffer)
624 end do
625 else
626 call move_cursor_word_right(editor%cursors(editor%active_cursor), buffer)
627 end if
628 call sync_editor_to_pane(editor)
629 call update_viewport(editor)
630
631 case('alt-shift-left')
632 call extend_selection_word_left(editor%cursors(editor%active_cursor), buffer)
633 call sync_editor_to_pane(editor)
634 call update_viewport(editor)
635
636 case('alt-shift-right')
637 call extend_selection_word_right(editor%cursors(editor%active_cursor), buffer)
638 call sync_editor_to_pane(editor)
639 call update_viewport(editor)
640
641 case('alt-up')
642 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
643 call move_line_up(editor%cursors(editor%active_cursor), buffer)
644 call sync_editor_to_pane(editor)
645 call update_viewport(editor)
646 is_edit_action = .true.
647
648 case('alt-down')
649 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
650 call move_line_down(editor%cursors(editor%active_cursor), buffer)
651 call sync_editor_to_pane(editor)
652 call update_viewport(editor)
653 is_edit_action = .true.
654
655 case('alt-shift-up')
656 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
657 call duplicate_line_up(editor%cursors(editor%active_cursor), buffer)
658 call sync_editor_to_pane(editor)
659 call update_viewport(editor)
660 is_edit_action = .true.
661
662 case('alt-shift-down')
663 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
664 call duplicate_line_down(editor%cursors(editor%active_cursor), buffer)
665 call sync_editor_to_pane(editor)
666 call update_viewport(editor)
667 is_edit_action = .true.
668
669 ! Tab navigation
670 case('alt-1', 'ctrl-1')
671 ! Switch to tab 1 (alt-1 or ctrl-1)
672 if (size(editor%tabs) >= 1) call switch_to_tab_with_buffer(editor, 1, buffer)
673 case('alt-2', 'ctrl-2')
674 ! Switch to tab 2
675 if (size(editor%tabs) >= 2) call switch_to_tab_with_buffer(editor, 2, buffer)
676 case('alt-3', 'ctrl-3')
677 ! Switch to tab 3
678 if (size(editor%tabs) >= 3) call switch_to_tab_with_buffer(editor, 3, buffer)
679 case('alt-4', 'ctrl-4')
680 ! Switch to tab 4
681 if (size(editor%tabs) >= 4) call switch_to_tab_with_buffer(editor, 4, buffer)
682 case('alt-5', 'ctrl-5')
683 ! Switch to tab 5
684 if (size(editor%tabs) >= 5) call switch_to_tab_with_buffer(editor, 5, buffer)
685 case('alt-6', 'ctrl-6')
686 ! Switch to tab 6
687 if (size(editor%tabs) >= 6) call switch_to_tab_with_buffer(editor, 6, buffer)
688 case('alt-7', 'ctrl-7')
689 ! Switch to tab 7
690 if (size(editor%tabs) >= 7) call switch_to_tab_with_buffer(editor, 7, buffer)
691 case('alt-8', 'ctrl-8')
692 ! Switch to tab 8
693 if (size(editor%tabs) >= 8) call switch_to_tab_with_buffer(editor, 8, buffer)
694 case('alt-9', 'ctrl-9')
695 ! Switch to tab 9
696 if (size(editor%tabs) >= 9) call switch_to_tab_with_buffer(editor, 9, buffer)
697 case('alt-0', 'ctrl-0')
698 ! Switch to tab 10
699 if (size(editor%tabs) >= 10) call switch_to_tab_with_buffer(editor, 10, buffer)
700
701 case('ctrl-alt-left', 'ctrl-pageup')
702 ! Previous tab (ctrl-alt-left or ctrl-pageup)
703 if (size(editor%tabs) > 0) then
704 if (editor%active_tab_index > 1) then
705 call switch_to_tab_with_buffer(editor, editor%active_tab_index - 1, buffer)
706 else
707 call switch_to_tab_with_buffer(editor, size(editor%tabs), buffer) ! Wrap to last tab
708 end if
709 end if
710
711 case('ctrl-alt-right', 'ctrl-pagedown')
712 ! Next tab (ctrl-alt-right or ctrl-pagedown)
713 if (size(editor%tabs) > 0) then
714 if (editor%active_tab_index < size(editor%tabs)) then
715 call switch_to_tab_with_buffer(editor, editor%active_tab_index + 1, buffer)
716 else
717 call switch_to_tab_with_buffer(editor, 1, buffer) ! Wrap to first tab
718 end if
719 end if
720
721 ! Text modification
722 case('backspace')
723 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
724 if (size(editor%cursors) > 1) then
725 ! Apply to all cursors
726 do i = 1, size(editor%cursors)
727 call handle_backspace(editor%cursors(i), buffer)
728 end do
729 else
730 call handle_backspace(editor%cursors(editor%active_cursor), buffer)
731 end if
732 call sync_editor_to_pane(editor)
733 is_edit_action = .true.
734
735 case('delete')
736 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
737 if (size(editor%cursors) > 1) then
738 ! Apply to all cursors
739 do i = 1, size(editor%cursors)
740 call handle_delete(editor%cursors(i), buffer)
741 end do
742 else
743 call handle_delete(editor%cursors(editor%active_cursor), buffer)
744 end if
745 call sync_editor_to_pane(editor)
746 is_edit_action = .true.
747
748 case('enter')
749 ! Code actions panel is handled in early routing above
750
751 ! If symbols panel is visible, jump to selected symbol
752 if (is_symbols_panel_visible(editor%symbols_panel)) then
753 block
754 integer(int32) :: line, col
755
756 if (get_selected_symbol_location(editor%symbols_panel, line, col)) then
757 ! Jump to the symbol location
758 editor%cursors(editor%active_cursor)%line = line
759 editor%cursors(editor%active_cursor)%column = col
760 call update_viewport(editor)
761
762 ! Hide panel after jump
763 call hide_symbols_panel(editor%symbols_panel)
764 end if
765 end block
766 return
767 end if
768
769 ! If references panel is visible, jump to selected reference
770 if (is_references_panel_visible(editor%references_panel)) then
771 block
772 character(len=:), allocatable :: uri
773 integer(int32) :: line, col
774
775 if (get_selected_reference_location(editor%references_panel, uri, line, col)) then
776 ! Convert URI to file path
777 if (uri(1:7) == "file://") then
778 ! Jump to the reference location
779 ! TODO: Handle cross-file navigation
780 editor%cursors(editor%active_cursor)%line = line
781 editor%cursors(editor%active_cursor)%column = col
782 call update_viewport(editor)
783 call hide_references_panel(editor%references_panel)
784 end if
785 end if
786 end block
787 return
788 end if
789
790 ! If completion popup is visible, insert selected completion
791 if (is_completion_visible(editor%completion_popup)) then
792 block
793 character(len=:), allocatable :: completion_text
794 integer :: text_i
795 completion_text = get_selected_completion(editor%completion_popup)
796 if (len(completion_text) > 0) then
797 ! Insert the completion text at cursor
798 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
799 do text_i = 1, len(completion_text)
800 call buffer_insert_char(buffer, editor%cursors(editor%active_cursor), &
801 completion_text(text_i:text_i))
802 editor%cursors(editor%active_cursor)%column = &
803 editor%cursors(editor%active_cursor)%column + 1
804 end do
805 end if
806 call hide_completion_popup(editor%completion_popup)
807 end block
808 is_edit_action = .true.
809 return
810 end if
811
812 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
813 if (size(editor%cursors) > 1) then
814 ! Sort cursors and apply from bottom to top to avoid position shifts
815 call sort_cursors_by_position(editor)
816 ! Save original line numbers before any insertions
817 allocate(original_lines(size(editor%cursors)))
818 do i = 1, size(editor%cursors)
819 original_lines(i) = editor%cursors(i)%line
820 end do
821
822 ! Process in reverse order (bottom to top)
823 do i = size(editor%cursors), 1, -1
824 ! Save the line where we're inserting
825 insert_line = original_lines(i)
826
827 call handle_enter(editor%cursors(i), buffer)
828
829 ! Adjust ALL other cursors that were BELOW where we inserted
830 ! (cursors at same line are handled by their own handle_enter)
831 do j = 1, size(editor%cursors)
832 if (j /= i .and. original_lines(j) > insert_line) then
833 ! This cursor was below where we inserted, shift it down
834 editor%cursors(j)%line = editor%cursors(j)%line + 1
835 end if
836 end do
837 end do
838 deallocate(original_lines)
839 else
840 call handle_enter(editor%cursors(editor%active_cursor), buffer)
841 end if
842 call sync_editor_to_pane(editor)
843 is_edit_action = .true.
844
845 case('tab')
846 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
847 if (size(editor%cursors) > 1) then
848 ! Apply to all cursors
849 do i = 1, size(editor%cursors)
850 if (editor%cursors(i)%has_selection) then
851 call indent_selection(editor%cursors(i), buffer)
852 else
853 call handle_tab(editor%cursors(i), buffer)
854 end if
855 end do
856 else
857 if (editor%cursors(editor%active_cursor)%has_selection) then
858 call indent_selection(editor%cursors(editor%active_cursor), buffer)
859 else
860 call handle_tab(editor%cursors(editor%active_cursor), buffer)
861 end if
862 end if
863 is_edit_action = .true.
864
865 case('shift-tab')
866 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
867 if (editor%cursors(editor%active_cursor)%has_selection) then
868 call dedent_selection(editor%cursors(editor%active_cursor), buffer)
869 else
870 call dedent_current_line(editor%cursors(editor%active_cursor), buffer)
871 end if
872 is_edit_action = .true.
873
874 ! Editing keybinds
875 case('ctrl-k')
876 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
877 if (size(editor%cursors) > 1) then
878 ! Apply to all cursors
879 do i = 1, size(editor%cursors)
880 call kill_line_forward(editor%cursors(i), buffer)
881 end do
882 else
883 call kill_line_forward(editor%cursors(editor%active_cursor), buffer)
884 end if
885 call sync_editor_to_pane(editor)
886 is_edit_action = .true.
887
888 case('ctrl-u')
889 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
890 if (size(editor%cursors) > 1) then
891 ! Apply to all cursors
892 do i = 1, size(editor%cursors)
893 call kill_line_backward(editor%cursors(i), buffer)
894 end do
895 else
896 call kill_line_backward(editor%cursors(editor%active_cursor), buffer)
897 end if
898 call sync_editor_to_pane(editor)
899 is_edit_action = .true.
900
901 case('alt-v')
902 ! Split pane vertically
903 if (size(editor%tabs) > 0 .and. editor%active_tab_index > 0) then
904 ! Sync current buffer to active pane before splitting
905 call sync_editor_to_pane(editor)
906 if (allocated(editor%tabs(editor%active_tab_index)%panes)) then
907 pane_idx = editor%tabs(editor%active_tab_index)%active_pane_index
908 if (pane_idx > 0 .and. pane_idx <= size(editor%tabs(editor%active_tab_index)%panes)) then
909 call copy_buffer(editor%tabs(editor%active_tab_index)%panes(pane_idx)%buffer, buffer)
910 end if
911 end if
912 call split_pane_vertical(editor)
913 end if
914
915 case('alt-s')
916 ! Split pane horizontally
917 if (size(editor%tabs) > 0 .and. editor%active_tab_index > 0) then
918 ! Sync current buffer to active pane before splitting
919 call sync_editor_to_pane(editor)
920 if (allocated(editor%tabs(editor%active_tab_index)%panes)) then
921 pane_idx = editor%tabs(editor%active_tab_index)%active_pane_index
922 if (pane_idx > 0 .and. pane_idx <= size(editor%tabs(editor%active_tab_index)%panes)) then
923 call copy_buffer(editor%tabs(editor%active_tab_index)%panes(pane_idx)%buffer, buffer)
924 end if
925 end if
926 call split_pane_horizontal(editor)
927 end if
928
929 case('alt-q')
930 ! Close current pane (creates UNTITLED.txt if last pane of last tab)
931 if (size(editor%tabs) > 0 .and. editor%active_tab_index > 0) then
932 call close_pane(editor)
933
934 ! Always copy the buffer (either new tab or UNTITLED.txt)
935 if (size(editor%tabs) > 0 .and. editor%active_tab_index > 0) then
936 call copy_buffer(buffer, editor%tabs(editor%active_tab_index)%buffer)
937 editor%modified = editor%tabs(editor%active_tab_index)%modified
938 if (allocated(editor%filename)) deallocate(editor%filename)
939 allocate(character(len=len(editor%tabs(editor%active_tab_index)%filename)) :: editor%filename)
940 editor%filename = editor%tabs(editor%active_tab_index)%filename
941 ! Should not happen with new logic
942 else
943 editor%fuss_mode_active = .true.
944 if (allocated(editor%workspace_path)) then
945 call init_tree_state(tree_state, editor%workspace_path)
946 end if
947 end if
948 end if
949
950 case('ctrl-w')
951 ! Close current tab (prompts to save if modified)
952 if (size(editor%tabs) > 0 .and. editor%active_tab_index > 0) then
953 ! Check if tab is modified - prompt before closing
954 if (editor%tabs(editor%active_tab_index)%modified) then
955 call prompt_save_before_close_tab(editor, buffer)
956 else
957 call close_tab_without_prompt(editor, buffer)
958 end if
959 end if
960
961 case('ctrl-shift-left', 'alt-h')
962 ! Navigate to pane on the left (Vim-style hjkl with alt)
963 if (size(editor%tabs) > 0 .and. editor%active_tab_index > 0) then
964 call navigate_to_pane_left(editor)
965 end if
966
967 case('ctrl-shift-right', 'alt-l')
968 ! Navigate to pane on the right
969 if (size(editor%tabs) > 0 .and. editor%active_tab_index > 0) then
970 call navigate_to_pane_right(editor)
971 end if
972
973 case('ctrl-shift-up', 'alt-k')
974 ! Navigate to pane above
975 if (size(editor%tabs) > 0 .and. editor%active_tab_index > 0) then
976 call navigate_to_pane_up(editor)
977 end if
978
979 case('ctrl-shift-down', 'alt-j')
980 ! Navigate to pane below
981 if (size(editor%tabs) > 0 .and. editor%active_tab_index > 0) then
982 call navigate_to_pane_down(editor)
983 end if
984
985 case('alt-d', 'alt-delete')
986 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
987 if (size(editor%cursors) > 1) then
988 ! Apply to all cursors
989 do i = 1, size(editor%cursors)
990 call delete_word_forward(editor%cursors(i), buffer)
991 end do
992 else
993 call delete_word_forward(editor%cursors(editor%active_cursor), buffer)
994 end if
995 call sync_editor_to_pane(editor)
996 call update_viewport(editor)
997 is_edit_action = .true.
998
999 case('alt-backspace')
1000 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
1001 if (size(editor%cursors) > 1) then
1002 ! Apply to all cursors
1003 do i = 1, size(editor%cursors)
1004 call delete_word_backward(editor%cursors(i), buffer)
1005 end do
1006 else
1007 call delete_word_backward(editor%cursors(editor%active_cursor), buffer)
1008 end if
1009 call sync_editor_to_pane(editor)
1010 call update_viewport(editor)
1011 is_edit_action = .true.
1012
1013 case('ctrl-t')
1014 ! Create new empty tab with unique name
1015 block
1016 integer :: untitled_counter, i, name_len, max_untitled, dash_pos, num_start
1017 character(len=32) :: untitled_name, num_str
1018 logical :: name_exists
1019 integer :: ios
1020
1021 ! Scan existing tabs to find highest untitled number
1022 max_untitled = 0
1023 if (allocated(editor%tabs)) then
1024 do i = 1, size(editor%tabs)
1025 if (allocated(editor%tabs(i)%filename)) then
1026 ! Check if it's an untitled tab
1027 if (index(editor%tabs(i)%filename, '[Untitled') == 1) then
1028 ! Check for plain [Untitled]
1029 if (trim(editor%tabs(i)%filename) == '[Untitled]') then
1030 max_untitled = max(max_untitled, 1)
1031 else
1032 ! Check for [Untitled-N]
1033 dash_pos = index(editor%tabs(i)%filename, '-')
1034 if (dash_pos > 0) then
1035 num_start = dash_pos + 1
1036 num_str = editor%tabs(i)%filename(num_start:len_trim(editor%tabs(i)%filename)-1)
1037 read(num_str, *, iostat=ios) untitled_counter
1038 if (ios == 0) then
1039 max_untitled = max(max_untitled, untitled_counter)
1040 end if
1041 end if
1042 end if
1043 end if
1044 end if
1045 end do
1046 end if
1047
1048 ! Start checking from max_untitled + 1, but check backwards too in case of gaps
1049 untitled_counter = max(1, max_untitled)
1050 do
1051 if (untitled_counter == 1) then
1052 write(untitled_name, '(A)') '[Untitled]'
1053 else
1054 write(untitled_name, '(A,I0,A)') '[Untitled-', untitled_counter, ']'
1055 end if
1056
1057 ! Check if this name already exists
1058 name_exists = .false.
1059 if (allocated(editor%tabs)) then
1060 do i = 1, size(editor%tabs)
1061 if (allocated(editor%tabs(i)%filename)) then
1062 if (trim(editor%tabs(i)%filename) == trim(untitled_name)) then
1063 name_exists = .true.
1064 exit
1065 end if
1066 end if
1067 end do
1068 end if
1069
1070 if (.not. name_exists) exit
1071 untitled_counter = untitled_counter + 1
1072 end do
1073
1074 call create_tab(editor, trim(untitled_name))
1075
1076 ! Switch to the new tab (it's already active after create_tab)
1077 if (editor%active_tab_index > 0 .and. editor%active_tab_index <= size(editor%tabs)) then
1078 ! Clear the display buffer and copy from the new tab's pane (which is empty)
1079 call cleanup_buffer(buffer)
1080 call init_buffer(buffer)
1081
1082 ! Copy empty buffer to the new tab's pane
1083 if (allocated(editor%tabs(editor%active_tab_index)%panes) .and. &
1084 size(editor%tabs(editor%active_tab_index)%panes) > 0) then
1085 call copy_buffer(editor%tabs(editor%active_tab_index)%panes(1)%buffer, buffer)
1086 call copy_buffer(editor%tabs(editor%active_tab_index)%buffer, buffer)
1087 end if
1088
1089 ! Update editor state with the new tab
1090 name_len = len_trim(untitled_name)
1091 if (allocated(editor%filename)) deallocate(editor%filename)
1092 allocate(character(len=name_len) :: editor%filename)
1093 editor%filename = trim(untitled_name)
1094
1095 ! Reset cursor to top
1096 editor%cursors(editor%active_cursor)%line = 1
1097 editor%cursors(editor%active_cursor)%column = 1
1098 editor%cursors(editor%active_cursor)%desired_column = 1
1099 editor%viewport_line = 1
1100 editor%viewport_column = 1
1101 editor%modified = .false.
1102 end if
1103 end block
1104
1105 case('ctrl-j')
1106 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
1107 if (size(editor%cursors) > 1) then
1108 ! Apply to all cursors
1109 do i = 1, size(editor%cursors)
1110 call join_lines(editor%cursors(i), buffer)
1111 end do
1112 else
1113 call join_lines(editor%cursors(editor%active_cursor), buffer)
1114 end if
1115 is_edit_action = .true.
1116
1117 case('ctrl-x')
1118 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
1119 if (size(editor%cursors) > 1) then
1120 ! Apply to all cursors in reverse order (bottom to top)
1121 do i = size(editor%cursors), 1, -1
1122 call cut_selection_or_line(editor%cursors(i), buffer)
1123 end do
1124 else
1125 call cut_selection_or_line(editor%cursors(editor%active_cursor), buffer)
1126 end if
1127 call sync_editor_to_pane(editor)
1128 is_edit_action = .true.
1129
1130 case('ctrl-c')
1131 ! Copy only needs active cursor (copies to shared clipboard)
1132 call copy_selection_or_line(editor%cursors(editor%active_cursor), buffer)
1133
1134 case('ctrl-v')
1135 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
1136 if (size(editor%cursors) > 1) then
1137 ! Apply to all cursors in reverse order (bottom to top)
1138 ! This prevents position shifts as we insert text
1139 do i = size(editor%cursors), 1, -1
1140 call paste_clipboard(editor%cursors(i), buffer)
1141 end do
1142 else
1143 call paste_clipboard(editor%cursors(editor%active_cursor), buffer)
1144 end if
1145 call sync_editor_to_pane(editor)
1146 is_edit_action = .true.
1147
1148 case('ctrl-s')
1149 ! Save the active pane/tab's buffer
1150 ! (All panes in a tab share the same buffer, so saving saves the entire tab)
1151 call save_file(editor, buffer)
1152
1153 ! LSP features
1154 case('ctrl-space')
1155 ! Trigger code completion
1156 block
1157 integer :: completion_server
1158 completion_server = get_lsp_server_for_cap(editor, CAP_COMPLETION)
1159 if (completion_server > 0) then
1160 ! Request completion at current cursor position
1161 ! LSP uses 0-based positions
1162 block
1163 integer :: request_id, lsp_line, lsp_char
1164 lsp_line = editor%cursors(editor%active_cursor)%line - 1
1165 lsp_char = editor%cursors(editor%active_cursor)%column - 1
1166
1167 request_id = request_completion(editor%lsp_manager, &
1168 completion_server, &
1169 editor%tabs(editor%active_tab_index)%filename, &
1170 lsp_line, lsp_char)
1171
1172 if (request_id > 0) then
1173 ! Show popup at cursor position (will populate when response arrives)
1174 call show_completion_popup(editor%completion_popup, &
1175 editor%cursors(editor%active_cursor)%line - editor%viewport_line + 2, &
1176 editor%cursors(editor%active_cursor)%column - editor%viewport_column + 1)
1177 end if
1178 end block
1179 end if
1180 end block
1181
1182 case('ctrl-h')
1183 ! Trigger hover information
1184 block
1185 integer :: hover_server
1186 hover_server = get_lsp_server_for_cap(editor, CAP_HOVER)
1187 if (hover_server > 0) then
1188 ! Request hover at current cursor position
1189 block
1190 integer :: request_id, lsp_line, lsp_char
1191 lsp_line = editor%cursors(editor%active_cursor)%line - 1
1192 lsp_char = editor%cursors(editor%active_cursor)%column - 1
1193
1194 request_id = request_hover(editor%lsp_manager, &
1195 hover_server, &
1196 editor%tabs(editor%active_tab_index)%filename, &
1197 lsp_line, lsp_char)
1198
1199 if (request_id > 0) then
1200 ! Show tooltip at cursor position (will populate when response arrives)
1201 call show_hover_tooltip(editor%hover_tooltip, &
1202 editor%cursors(editor%active_cursor)%line - editor%viewport_line + 2, &
1203 editor%cursors(editor%active_cursor)%column - editor%viewport_column + 1)
1204 end if
1205 end block
1206 end if
1207 end block
1208
1209 case('f10', 'alt-.')
1210 ! Trigger code actions (F10 or Alt+.) - toggle behavior
1211 ! If panel is already visible, close it
1212 if (is_code_actions_panel_visible(editor%code_actions_panel)) then
1213 call hide_code_actions_panel(editor%code_actions_panel)
1214 else
1215 block
1216 integer :: code_actions_server
1217 code_actions_server = get_lsp_server_for_cap(editor, CAP_CODE_ACTIONS)
1218 if (code_actions_server > 0) then
1219 ! Request code actions for the current line
1220 block
1221 integer :: request_id, lsp_line
1222 character(len=:), allocatable :: file_uri
1223 type(diagnostic_t), allocatable :: line_diags(:)
1224 type(json_value_t) :: diags_json
1225
1226 lsp_line = editor%cursors(editor%active_cursor)%line - 1
1227
1228 ! Save editor state for callback
1229 saved_editor_for_callback => editor
1230
1231 ! Get diagnostics for this line FROM THIS SERVER to include in request
1232 ! This is critical for multi-LSP: Ruff should only see Ruff's diagnostics
1233 file_uri = filename_to_uri(editor%tabs(editor%active_tab_index)%filename)
1234
1235 line_diags = get_diagnostics_for_line_by_server(editor%diagnostics, file_uri, &
1236 editor%cursors(editor%active_cursor)%line, code_actions_server)
1237
1238 ! Convert diagnostics to JSON for request
1239 diags_json = diagnostics_to_json(line_diags)
1240
1241 ! Request code actions for entire line with diagnostics context
1242 request_id = request_code_actions(editor%lsp_manager, &
1243 code_actions_server, &
1244 editor%tabs(editor%active_tab_index)%filename, &
1245 lsp_line, 0, &
1246 lsp_line, 999, &
1247 handle_code_actions_response_wrapper, &
1248 diags_json)
1249 ! Panel will be shown when response arrives in handle_code_actions_response_impl
1250 end block
1251 end if
1252 end block
1253 end if
1254
1255 case('f12', 'ctrl-\\', 'alt-g')
1256 ! Go to definition (F12, Ctrl+\, or Alt+G)
1257 call terminal_move_cursor(editor%screen_rows, 1)
1258 block
1259 integer :: def_server
1260 def_server = get_lsp_server_for_cap(editor, CAP_DEFINITION)
1261 if (def_server > 0) then
1262 ! Save current location to jump stack
1263 if (allocated(editor%filename)) then
1264 call push_jump_location(editor%jump_stack, &
1265 trim(editor%filename), &
1266 editor%cursors(editor%active_cursor)%line, &
1267 editor%cursors(editor%active_cursor)%column)
1268 end if
1269
1270 ! Request definition at current cursor position
1271 block
1272 integer :: request_id, lsp_line, lsp_char
1273 lsp_line = editor%cursors(editor%active_cursor)%line - 1
1274 lsp_char = editor%cursors(editor%active_cursor)%column - 1
1275
1276 ! Save editor state and buffer pointers for callback
1277 saved_editor_for_callback => editor
1278 saved_buffer_for_callback => buffer
1279
1280 request_id = request_definition(editor%lsp_manager, &
1281 def_server, &
1282 editor%tabs(editor%active_tab_index)%filename, &
1283 lsp_line, lsp_char, handle_definition_response_wrapper)
1284
1285 if (request_id > 0) then
1286 ! Response will be handled by callback
1287 call terminal_write('Searching for definition... ')
1288 else
1289 call terminal_write('[F12] LSP request failed ')
1290 end if
1291 end block
1292 else
1293 call terminal_write('[F12] No LSP server with definition support ')
1294 end if
1295 end block
1296
1297 case('shift-f12', 'alt-r')
1298 ! Find all references (Shift+F12 or Alt+R)
1299 block
1300 integer :: refs_server
1301 refs_server = get_lsp_server_for_cap(editor, CAP_REFERENCES)
1302 if (refs_server > 0) then
1303 ! Request references at current cursor position
1304 block
1305 use references_panel_module, only: clear_references
1306 integer :: request_id, lsp_line, lsp_char
1307 lsp_line = editor%cursors(editor%active_cursor)%line - 1
1308 lsp_char = editor%cursors(editor%active_cursor)%column - 1
1309
1310 ! Clear any existing references so we can detect when new ones arrive
1311 call clear_references(editor%references_panel)
1312
1313 ! Save editor state for callback
1314 saved_editor_for_callback => editor
1315
1316 request_id = request_references(editor%lsp_manager, &
1317 refs_server, &
1318 editor%tabs(editor%active_tab_index)%filename, &
1319 lsp_line, lsp_char, handle_references_response_wrapper)
1320
1321 if (request_id > 0) then
1322 ! Response will be handled by callback
1323 call terminal_move_cursor(editor%screen_rows, 1)
1324 call terminal_write('Searching for references... ')
1325 ! Show panel (will be populated when response arrives)
1326 call show_references_panel(editor%references_panel, editor%screen_cols, editor%screen_rows)
1327
1328 ! Wait for LSP response to populate panel
1329 block
1330 use lsp_server_manager_module, only: process_server_messages
1331 integer :: poll_count, max_polls
1332 logical :: response_received
1333 integer :: count_rate, count_start, count_end
1334 real :: elapsed_ms
1335
1336 max_polls = 50 ! Poll up to 50 times (500ms total)
1337 poll_count = 0
1338 response_received = .false.
1339
1340 call system_clock(count_rate=count_rate)
1341
1342 do while (poll_count < max_polls .and. .not. response_received)
1343 ! Process any pending LSP messages
1344 call process_server_messages(editor%lsp_manager)
1345
1346 ! Check if we got a response (panel populated or explicitly set to 0)
1347 if (allocated(editor%references_panel%references)) then
1348 response_received = .true.
1349 end if
1350
1351 if (.not. response_received) then
1352 ! Sleep for ~10ms between polls
1353 call system_clock(count=count_start)
1354 do
1355 call system_clock(count=count_end)
1356 elapsed_ms = real(count_end - count_start) / real(count_rate) * 1000.0
1357 if (elapsed_ms >= 10.0) exit
1358 end do
1359 poll_count = poll_count + 1
1360 end if
1361 end do
1362 end block
1363
1364 ! Interactive loop for references panel (offcanvas mode)
1365 block
1366 use input_handler_module, only: get_key_input
1367 use references_panel_module, only: hide_references_panel, references_panel_handle_key
1368 use renderer_module, only: render_screen_with_lsp_panel
1369 character(len=32) :: key_input
1370 integer :: status
1371 logical :: handled
1372
1373 ! Initial render with offcanvas panel
1374 call render_screen_with_lsp_panel(buffer, editor, "references")
1375
1376 do
1377 call get_key_input(key_input, status)
1378 if (status /= 0) cycle
1379
1380 if (key_input == 'esc') then
1381 call hide_references_panel(editor%references_panel)
1382 call render_screen(buffer, editor)
1383 exit
1384 else if (key_input == 'enter') then
1385 ! Navigate to selected reference
1386 block
1387 use references_panel_module, only: get_selected_reference_location
1388 character(len=:), allocatable :: ref_uri
1389 integer :: ref_line, ref_col
1390
1391 if (get_selected_reference_location( &
1392 editor%references_panel, &
1393 ref_uri, ref_line, ref_col)) then
1394 if (len(ref_uri) >= 7 .and. ref_uri(1:7) == "file://") then
1395 ! Jump to the reference location
1396 editor%cursors(editor%active_cursor)%line = ref_line
1397 editor%cursors(editor%active_cursor)%column = ref_col
1398 ! Center the view on the target line
1399 editor%viewport_line = max(1, ref_line - editor%screen_rows / 2)
1400 end if
1401 end if
1402 end block
1403 ! Sync editor cursor to pane before rendering
1404 call sync_editor_to_pane(editor)
1405 call hide_references_panel(editor%references_panel)
1406 call render_screen(buffer, editor)
1407 exit
1408 else
1409 ! Try panel-specific key handling
1410 handled = references_panel_handle_key(editor%references_panel, key_input)
1411 ! Re-render with offcanvas panel
1412 call render_screen_with_lsp_panel(buffer, editor, "references")
1413 end if
1414 end do
1415 end block
1416 end if
1417 end block
1418 end if
1419 end block
1420
1421 case('f2')
1422 ! Rename symbol
1423 block
1424 integer :: rename_server
1425 rename_server = get_lsp_server_for_cap(editor, CAP_RENAME)
1426 if (rename_server > 0) then
1427 ! Get word under cursor as old name
1428 block
1429 character(len=:), allocatable :: line, old_name, new_name
1430 integer :: word_start, word_end, lsp_line, lsp_char, request_id
1431 logical :: cancelled
1432
1433 line = buffer_get_line(buffer, editor%cursors(editor%active_cursor)%line)
1434 call find_word_boundaries(line, editor%cursors(editor%active_cursor)%column, &
1435 word_start, word_end)
1436
1437 if (word_start > 0 .and. word_end >= word_start) then
1438 old_name = line(word_start:word_end)
1439
1440 ! Show rename prompt
1441 call show_rename_prompt(editor%screen_rows, old_name, new_name, cancelled)
1442
1443 if (.not. cancelled .and. allocated(new_name)) then
1444 ! Send rename request
1445 lsp_line = editor%cursors(editor%active_cursor)%line - 1
1446 lsp_char = editor%cursors(editor%active_cursor)%column - 1
1447
1448 ! Save editor state for callback
1449 saved_editor_for_callback => editor
1450
1451 request_id = request_rename(editor%lsp_manager, &
1452 rename_server, &
1453 editor%tabs(editor%active_tab_index)%filename, &
1454 lsp_line, lsp_char, new_name, handle_rename_response_wrapper)
1455
1456 if (request_id > 0) then
1457 call terminal_move_cursor(editor%screen_rows, 1)
1458 call terminal_write('Renaming symbol... ')
1459
1460 ! Poll for LSP response and render immediately when received
1461 block
1462 integer :: poll_count, max_polls, pane_idx
1463 integer(8) :: start_time, end_time, count_rate, target_time
1464 max_polls = 100 ! Poll up to 100 times (1 second total)
1465
1466 do poll_count = 1, max_polls
1467 ! Process any LSP messages
1468 call process_server_messages(editor%lsp_manager)
1469
1470 ! Check if rename response modified the buffer
1471 if (g_lsp_modified_buffer) then
1472 ! LSP now modifies pane buffer directly, sync FROM pane TO local buffer and tab
1473 if (allocated(editor%tabs(editor%active_tab_index)%panes) .and. &
1474 size(editor%tabs(editor%active_tab_index)%panes) > 0) then
1475 pane_idx = editor%tabs(editor%active_tab_index)%active_pane_index
1476 if (pane_idx > 0 .and. pane_idx <= &
1477 size(editor%tabs(editor%active_tab_index)%panes)) then
1478 ! Copy FROM pane buffer TO local buffer (for rendering)
1479 call copy_buffer(buffer, &
1480 editor%tabs(editor%active_tab_index)%panes(pane_idx)%buffer)
1481 ! Also sync to tab buffer (to keep them consistent)
1482 call copy_buffer(editor%tabs(editor%active_tab_index)%buffer, buffer)
1483 end if
1484 end if
1485
1486 ! Render the updated buffer immediately
1487 if (editor%fuss_mode_active) then
1488 call render_screen_with_tree(buffer, editor, &
1489 allocated(search_pattern), match_case_sensitive)
1490 else
1491 call render_screen(buffer, editor, &
1492 allocated(search_pattern), match_case_sensitive)
1493 end if
1494
1495 ! Reset flag and exit loop
1496 g_lsp_modified_buffer = .false.
1497 exit
1498 end if
1499
1500 ! Delay 10ms between polls using system_clock
1501 call system_clock(start_time, count_rate)
1502 target_time = start_time + (count_rate / 100) ! 10ms
1503 do
1504 call system_clock(end_time)
1505 if (end_time >= target_time) exit
1506 end do
1507 end do
1508 end block
1509 end if
1510
1511 deallocate(new_name)
1512 end if
1513
1514 if (allocated(old_name)) deallocate(old_name)
1515 else
1516 call terminal_move_cursor(editor%screen_rows, 1)
1517 call terminal_write('No symbol under cursor ')
1518 end if
1519
1520 if (allocated(line)) deallocate(line)
1521 end block
1522 else
1523 call terminal_move_cursor(editor%screen_rows, 1)
1524 call terminal_write('[F2] No LSP server with rename support ')
1525 end if
1526 end block
1527
1528 case('shift-alt-f')
1529 ! Format document
1530 block
1531 integer :: format_server
1532 format_server = get_lsp_server_for_cap(editor, CAP_FORMATTING)
1533 if (format_server > 0) then
1534 block
1535 integer :: request_id
1536
1537 ! Save editor state for callback
1538 saved_editor_for_callback => editor
1539
1540 ! Request formatting with 4 spaces (configurable later)
1541 request_id = request_formatting(editor%lsp_manager, &
1542 format_server, &
1543 editor%tabs(editor%active_tab_index)%filename, &
1544 4, .true., handle_formatting_response_wrapper)
1545
1546 if (request_id > 0) then
1547 call terminal_move_cursor(editor%screen_rows, 1)
1548 call terminal_write('Formatting document... ')
1549 end if
1550 end block
1551 end if
1552 end block
1553
1554 case('f4', 'alt-o')
1555 ! Document symbols outline (F4 or Alt+O) - toggle behavior
1556 if (is_symbols_panel_visible(editor%symbols_panel)) then
1557 ! Panel is visible, hide it
1558 call hide_symbols_panel(editor%symbols_panel)
1559 else
1560 ! Panel is hidden, request symbols and show it
1561 block
1562 integer :: symbols_server
1563 symbols_server = get_lsp_server_for_cap(editor, CAP_DOCUMENT_SYMBOLS)
1564 if (symbols_server > 0) then
1565 ! Request document symbols
1566 block
1567 integer :: request_id
1568
1569 ! Save editor state for callback
1570 saved_editor_for_callback => editor
1571
1572 request_id = request_document_symbols(editor%lsp_manager, &
1573 symbols_server, &
1574 editor%tabs(editor%active_tab_index)%filename, &
1575 handle_symbols_response_wrapper)
1576
1577 if (request_id > 0) then
1578 ! Response will be handled by callback
1579 call terminal_move_cursor(editor%screen_rows, 1)
1580 call terminal_write('Loading document symbols... ')
1581 ! Show panel (will be populated when response arrives)
1582 call show_symbols_panel(editor%symbols_panel, editor%screen_cols, editor%screen_rows)
1583 end if
1584 end block
1585 end if
1586 end block
1587 end if
1588
1589 case('ctrl-p')
1590 ! Command palette (Ctrl+P - VSCode standard)
1591 ! Note: ctrl-shift-p doesn't work - terminals can't distinguish ctrl-p from ctrl-shift-p
1592 block
1593 use command_palette_module, only: show_command_palette_interactive
1594 character(len=:), allocatable :: cmd_id
1595
1596 cmd_id = show_command_palette_interactive(editor%command_palette, editor%screen_cols)
1597
1598 if (allocated(cmd_id) .and. len_trim(cmd_id) > 0) then
1599 ! Execute the command by re-processing as a key
1600 call execute_palette_command(editor, buffer, cmd_id, should_quit)
1601 end if
1602
1603 ! Redraw screen after palette
1604 call render_screen(buffer, editor)
1605 end block
1606
1607 case('f6', 'alt-p')
1608 ! Workspace symbols (F6 or Alt+P for project) - offcanvas panel with fzf-like filtering
1609 if (size(editor%tabs) > 0 .and. editor%active_tab_index > 0) then
1610 block
1611 use workspace_symbols_panel_module, only: show_workspace_symbols_panel, &
1612 render_workspace_symbols_panel, &
1613 workspace_symbols_panel_handle_key, &
1614 hide_workspace_symbols_panel, &
1615 is_workspace_symbols_panel_visible, &
1616 get_selected_symbol, &
1617 get_search_query, &
1618 workspace_symbol_t
1619 use input_handler_module, only: get_key_input
1620 use lsp_server_manager_module, only: request_workspace_symbols, &
1621 CAP_WORKSPACE_SYMBOLS_USE => CAP_WORKSPACE_SYMBOLS, &
1622 process_server_messages
1623 integer :: request_id, status, ws_server
1624 character(len=32) :: key_input
1625 character(len=:), allocatable :: prev_query, curr_query
1626 logical :: handled
1627 type(workspace_symbol_t) :: selected_symbol
1628
1629 ! Toggle behavior - if already visible, hide and return
1630 if (is_workspace_symbols_panel_visible(editor%workspace_symbols_panel)) then
1631 call hide_workspace_symbols_panel(editor%workspace_symbols_panel)
1632 call render_screen(buffer, editor)
1633 return
1634 end if
1635
1636 ! Show panel with screen dimensions
1637 call show_workspace_symbols_panel(editor%workspace_symbols_panel, &
1638 editor%screen_cols, editor%screen_rows)
1639
1640 ! Get server with workspace symbols capability
1641 ws_server = get_lsp_server_for_cap(editor, CAP_WORKSPACE_SYMBOLS_USE)
1642
1643 ! Save editor state for LSP callback
1644 saved_editor_for_callback => editor
1645
1646 ! Don't send initial empty query - pyright requires at least 1 char
1647 ! Query will be sent when user starts typing
1648
1649 prev_query = ''
1650
1651 ! Interactive loop
1652 do while (is_workspace_symbols_panel_visible(editor%workspace_symbols_panel))
1653 ! Process any pending LSP responses
1654 call process_server_messages(editor%lsp_manager)
1655
1656 ! Render the panel
1657 call render_workspace_symbols_panel(editor%workspace_symbols_panel, editor%screen_rows)
1658
1659 call get_key_input(key_input, status)
1660 if (status /= 0) cycle
1661
1662 ! Handle enter specially - navigate to symbol
1663 if (trim(key_input) == 'enter') then
1664 selected_symbol = get_selected_symbol(editor%workspace_symbols_panel)
1665 if (allocated(selected_symbol%file_uri) .and. len_trim(selected_symbol%file_uri) > 0) then
1666 call navigate_to_workspace_symbol(editor, buffer, selected_symbol, should_quit)
1667 else if (allocated(selected_symbol%file_path) .and. len_trim(selected_symbol%file_path) > 0) then
1668 ! Use file_path if file_uri not set
1669 selected_symbol%file_uri = 'file://' // trim(selected_symbol%file_path)
1670 call navigate_to_workspace_symbol(editor, buffer, selected_symbol, should_quit)
1671 end if
1672 call hide_workspace_symbols_panel(editor%workspace_symbols_panel)
1673 call render_screen(buffer, editor)
1674 exit
1675 end if
1676
1677 ! Let the panel handle all other keys
1678 handled = workspace_symbols_panel_handle_key(editor%workspace_symbols_panel, trim(key_input))
1679
1680 ! Check if query changed - send new LSP request (only if query has content)
1681 if (ws_server > 0) then
1682 curr_query = get_search_query(editor%workspace_symbols_panel)
1683 if (curr_query /= prev_query .and. len_trim(curr_query) > 0) then
1684 request_id = request_workspace_symbols(editor%lsp_manager, &
1685 ws_server, curr_query, handle_workspace_symbols_response_wrapper)
1686 prev_query = curr_query
1687 end if
1688 end if
1689 end do
1690
1691 call render_screen(buffer, editor)
1692 end block
1693 end if
1694
1695 case('alt-comma')
1696 ! Jump back in navigation history (Alt+,)
1697 if (.not. is_jump_stack_empty(editor%jump_stack)) then
1698 block
1699 character(len=:), allocatable :: jump_filename
1700 integer(int32) :: jump_line, jump_column
1701 logical :: success
1702
1703 success = pop_jump_location(editor%jump_stack, jump_filename, jump_line, jump_column)
1704 if (success) then
1705 ! Check if we need to open a different file
1706 if (allocated(editor%filename)) then
1707 if (trim(jump_filename) /= trim(editor%filename)) then
1708 ! TODO: Open the file
1709 call terminal_move_cursor(editor%screen_rows, 1)
1710 call terminal_write('Opening: ' // trim(jump_filename))
1711 ! For now, just jump if same file
1712 end if
1713 end if
1714
1715 ! Jump to the location
1716 editor%cursors(editor%active_cursor)%line = jump_line
1717 editor%cursors(editor%active_cursor)%column = jump_column
1718 editor%cursors(editor%active_cursor)%desired_column = jump_column
1719 call sync_editor_to_pane(editor)
1720 call update_viewport(editor)
1721 end if
1722 end block
1723 end if
1724
1725 case("ctrl-'", "ctrl-apostrophe", "alt-'")
1726 ! Cycle quotes: " -> ' -> `
1727 ! ctrl-': Doesn't work (terminals send plain apostrophe)
1728 ! alt-': Alternative binding (Option+' on Mac)
1729 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
1730 call cycle_quotes(editor%cursors(editor%active_cursor), buffer)
1731 is_edit_action = .true.
1732
1733 case('ctrl-opt-backspace', 'ctrl-alt-backspace', 'alt-shift-backspace', 'alt-shift-apostrophe')
1734 ! Remove surrounding brackets/quotes
1735 ! ctrl-alt-backspace: Doesn't work (terminals send alt-backspace)
1736 ! alt-shift-backspace: Doesn't work (terminals send alt-backspace)
1737 ! alt-shift-': Alternative binding (Alt+Shift+' = Alt+")
1738 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
1739 call remove_brackets(editor%cursors(editor%active_cursor), buffer)
1740 is_edit_action = .true.
1741
1742 case('ctrl-d')
1743 call select_next_match(editor, buffer)
1744 call sync_editor_to_pane(editor)
1745 call update_viewport(editor)
1746
1747 case('f8', 'alt-e')
1748 ! Toggle diagnostics panel (F8 or Alt+E for errors)
1749 call toggle_panel(editor%diagnostics_panel)
1750 ! Re-render screen to show/hide the panel
1751 call render_screen(buffer, editor)
1752
1753 case('alt-m')
1754 ! Toggle LSP server installer panel (Alt+M for Manager)
1755 if (is_lsp_server_installer_panel_visible(editor%lsp_installer_panel)) then
1756 call hide_lsp_server_installer_panel(editor%lsp_installer_panel)
1757 else
1758 call show_lsp_server_installer_panel(editor%lsp_installer_panel)
1759 end if
1760 call render_screen(buffer, editor)
1761
1762 case('alt-c')
1763 ! Toggle case sensitivity for match mode (ctrl-d)
1764 ! Only has effect when in active match mode (search_pattern allocated)
1765 if (allocated(search_pattern)) then
1766 match_case_sensitive = .not. match_case_sensitive
1767 end if
1768
1769 case('alt-[', 'alt-]')
1770 ! Jump to matching bracket
1771 call jump_to_matching_bracket(editor, buffer)
1772
1773 case('opt-meta-up', 'ctrl-alt-up', 'alt-ctrl-up')
1774 ! Add cursor on line above
1775 ! opt-meta-up: Doesn't work (terminals don't send Cmd)
1776 ! ctrl-alt-up: Alternative binding that works
1777 call add_cursor_above(editor)
1778 call sync_editor_to_pane(editor)
1779 call update_viewport(editor)
1780
1781 case('opt-meta-down', 'ctrl-alt-down', 'alt-ctrl-down')
1782 ! Add cursor on line below
1783 ! opt-meta-down: Doesn't work (terminals don't send Cmd)
1784 ! ctrl-alt-down: Alternative binding that works
1785 call add_cursor_below(editor, buffer)
1786 call sync_editor_to_pane(editor)
1787 call update_viewport(editor)
1788
1789 ! Search commands
1790 case('ctrl-f')
1791 ! Unified search and replace (Ctrl+F)
1792 call show_unified_search_prompt(editor, buffer)
1793 call update_viewport(editor)
1794
1795 case('ctrl-r')
1796 ! Find and replace
1797 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
1798 call show_replace_prompt(editor, buffer)
1799 call update_viewport(editor)
1800 is_edit_action = .true.
1801
1802 case('n')
1803 ! Only use 'n' for search navigation if we have an active search
1804 if (allocated(current_search_pattern)) then
1805 call search_forward(editor, buffer)
1806 call update_viewport(editor)
1807 else
1808 ! No active search, treat as regular character with multi-cursor support
1809 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
1810 if (size(editor%cursors) > 1) then
1811 call insert_char_multiple_cursors(editor, buffer, 'n')
1812 else
1813 call insert_char(editor%cursors(editor%active_cursor), buffer, 'n')
1814 end if
1815 call sync_editor_to_pane(editor)
1816 is_edit_action = .true.
1817 end if
1818
1819 case('N')
1820 ! Only use 'N' for search navigation if we have an active search
1821 if (allocated(current_search_pattern)) then
1822 call search_backward(editor, buffer)
1823 call update_viewport(editor)
1824 else
1825 ! No active search, treat as regular character with multi-cursor support
1826 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
1827 if (size(editor%cursors) > 1) then
1828 call insert_char_multiple_cursors(editor, buffer, 'N')
1829 else
1830 call insert_char(editor%cursors(editor%active_cursor), buffer, 'N')
1831 end if
1832 call sync_editor_to_pane(editor)
1833 is_edit_action = .true.
1834 end if
1835
1836 case default
1837 ! Check for mouse events
1838 if (index(key_str, 'mouse-') == 1) then
1839 call handle_mouse_event_action(key_str, editor, buffer)
1840 call sync_editor_to_pane(editor)
1841 ! Regular character input (including space)
1842 ! Check for single char: either len_trim=1, or it's a space (trim removes it)
1843 else if (len_trim(key_str) == 1 .or. (len_trim(key_str) == 0 .and. key_str(1:1) == ' ')) then
1844 if (.not. last_action_was_edit) call save_undo_state(buffer, editor)
1845 ! Handle character input for all cursors
1846 if (size(editor%cursors) > 1) then
1847 call insert_char_multiple_cursors(editor, buffer, key_str(1:1))
1848 else
1849 call insert_char(editor%cursors(editor%active_cursor), buffer, key_str(1:1))
1850 end if
1851 call sync_editor_to_pane(editor)
1852 is_edit_action = .true.
1853
1854 ! Auto-trigger signature help on '(' or ','
1855 if (key_str(1:1) == '(' .or. key_str(1:1) == ',') then
1856 block
1857 integer :: sig_server
1858 sig_server = get_lsp_server_for_cap(editor, CAP_COMPLETION) ! Signature help typically comes from completion provider
1859 if (sig_server > 0) then
1860 block
1861 integer :: request_id, lsp_line, lsp_char
1862 lsp_line = editor%cursors(editor%active_cursor)%line - 1
1863 lsp_char = editor%cursors(editor%active_cursor)%column - 1
1864
1865 ! Save editor state for callback
1866 saved_editor_for_callback => editor
1867
1868 request_id = request_signature_help(editor%lsp_manager, &
1869 sig_server, &
1870 editor%tabs(editor%active_tab_index)%filename, &
1871 lsp_line, lsp_char, handle_signature_response_wrapper)
1872
1873 if (request_id > 0) then
1874 ! Show tooltip placeholder
1875 call show_signature_tooltip(editor%signature_tooltip, &
1876 editor%cursors(editor%active_cursor)%line - editor%viewport_line + 1, &
1877 editor%cursors(editor%active_cursor)%column - editor%viewport_column + 1)
1878 end if
1879 end block
1880 end if
1881 end block
1882 end if
1883
1884 ! Hide signature help on ')'
1885 if (key_str(1:1) == ')') then
1886 call hide_signature_tooltip(editor%signature_tooltip)
1887 end if
1888 end if
1889 end select
1890
1891 ! Update edit action state
1892 last_action_was_edit = is_edit_action
1893
1894 ! Notify LSP of document changes if buffer was modified
1895 if (is_edit_action) then
1896 call notify_buffer_change(editor, buffer)
1897 end if
1898 end subroutine handle_key_command
1899
1900 subroutine move_cursor_up(cursor, buffer)
1901 type(cursor_t), intent(inout) :: cursor
1902 type(buffer_t), intent(in) :: buffer
1903 character(len=:), allocatable :: target_line
1904
1905 cursor%has_selection = .false. ! Clear selection
1906 if (cursor%line > 1) then
1907 cursor%line = cursor%line - 1
1908 target_line = buffer_get_line(buffer, cursor%line)
1909
1910 ! Always use goal column, clamped to line bounds (standard editor behavior)
1911 cursor%column = cursor%desired_column
1912 if (cursor%column > len(target_line) + 1) then
1913 cursor%column = len(target_line) + 1
1914 end if
1915
1916 if (allocated(target_line)) deallocate(target_line)
1917 end if
1918 end subroutine move_cursor_up
1919
1920 subroutine move_cursor_down(cursor, buffer, line_count)
1921 type(cursor_t), intent(inout) :: cursor
1922 type(buffer_t), intent(in) :: buffer
1923 integer, intent(in) :: line_count
1924 character(len=:), allocatable :: target_line
1925
1926 cursor%has_selection = .false. ! Clear selection
1927 if (cursor%line < line_count) then
1928 cursor%line = cursor%line + 1
1929 target_line = buffer_get_line(buffer, cursor%line)
1930
1931 ! Always use goal column, clamped to line bounds (standard editor behavior)
1932 cursor%column = cursor%desired_column
1933 if (cursor%column > len(target_line) + 1) then
1934 cursor%column = len(target_line) + 1
1935 end if
1936
1937 if (allocated(target_line)) deallocate(target_line)
1938 end if
1939 end subroutine move_cursor_down
1940
1941 subroutine move_cursor_left(cursor, buffer)
1942 type(cursor_t), intent(inout) :: cursor
1943 type(buffer_t), intent(in) :: buffer
1944 integer :: char_count
1945
1946 ! If we have a selection, move to START of selection (leftmost/earliest position)
1947 if (cursor%has_selection) then
1948 ! Find which end is further left (start of selection)
1949 if (cursor%selection_start_line < cursor%line .or. &
1950 (cursor%selection_start_line == cursor%line .and. cursor%selection_start_col < cursor%column)) then
1951 ! selection_start is the start - move there
1952 cursor%line = cursor%selection_start_line
1953 cursor%column = cursor%selection_start_col
1954 end if
1955 ! Otherwise cursor is already at the start
1956 cursor%has_selection = .false.
1957 cursor%desired_column = cursor%column
1958 return
1959 end if
1960
1961 if (cursor%column > 1) then
1962 cursor%column = cursor%column - 1
1963 cursor%desired_column = cursor%column
1964 else if (cursor%line > 1) then
1965 ! Move to end of previous line
1966 cursor%line = cursor%line - 1
1967 char_count = buffer_get_line_char_count(buffer, cursor%line)
1968 cursor%column = char_count + 1
1969 cursor%desired_column = cursor%column
1970 end if
1971 end subroutine move_cursor_left
1972
1973 subroutine move_cursor_right(cursor, buffer)
1974 type(cursor_t), intent(inout) :: cursor
1975 type(buffer_t), intent(in) :: buffer
1976 integer :: line_count, char_count
1977
1978 ! If we have a selection, move to END of selection (rightmost/latest position)
1979 if (cursor%has_selection) then
1980 ! Find which end is further right (end of selection)
1981 if (cursor%selection_start_line > cursor%line .or. &
1982 (cursor%selection_start_line == cursor%line .and. cursor%selection_start_col > cursor%column)) then
1983 ! selection_start is the end - move there
1984 cursor%line = cursor%selection_start_line
1985 cursor%column = cursor%selection_start_col
1986 end if
1987 ! Otherwise cursor is already at the end
1988 cursor%has_selection = .false.
1989 cursor%desired_column = cursor%column
1990 return
1991 end if
1992
1993 char_count = buffer_get_line_char_count(buffer, cursor%line)
1994 line_count = buffer_get_line_count(buffer)
1995
1996 if (cursor%column <= char_count) then
1997 cursor%column = cursor%column + 1
1998 cursor%desired_column = cursor%column
1999 else if (cursor%line < line_count) then
2000 ! Move to start of next line
2001 cursor%line = cursor%line + 1
2002 cursor%column = 1
2003 cursor%desired_column = cursor%column
2004 end if
2005 end subroutine move_cursor_right
2006
2007 subroutine move_cursor_smart_home(cursor, buffer)
2008 type(cursor_t), intent(inout) :: cursor
2009 type(buffer_t), intent(in) :: buffer
2010 character(len=:), allocatable :: line
2011 integer :: first_non_whitespace, i
2012
2013 cursor%has_selection = .false. ! Clear selection
2014
2015 ! Get the current line
2016 line = buffer_get_line(buffer, cursor%line)
2017
2018 ! Find the first non-whitespace character
2019 first_non_whitespace = 1
2020 do i = 1, len(line)
2021 if (line(i:i) /= ' ' .and. line(i:i) /= char(9)) then ! Not space or tab
2022 first_non_whitespace = i
2023 exit
2024 end if
2025 end do
2026
2027 ! Smart home behavior:
2028 ! If we're already at the first non-whitespace, go to column 1
2029 ! If we're at column 1, go to first non-whitespace
2030 ! Otherwise, go to first non-whitespace
2031 if (cursor%column == first_non_whitespace .and. first_non_whitespace > 1) then
2032 cursor%column = 1
2033 else
2034 cursor%column = first_non_whitespace
2035 end if
2036
2037 cursor%desired_column = cursor%column
2038
2039 if (allocated(line)) deallocate(line)
2040 end subroutine move_cursor_smart_home
2041
2042 subroutine move_cursor_end(cursor, buffer)
2043 type(cursor_t), intent(inout) :: cursor
2044 type(buffer_t), intent(in) :: buffer
2045 character(len=:), allocatable :: line
2046
2047 cursor%has_selection = .false. ! Clear selection
2048 line = buffer_get_line(buffer, cursor%line)
2049 cursor%column = len(line) + 1
2050 cursor%desired_column = cursor%column
2051 if (allocated(line)) deallocate(line)
2052 end subroutine move_cursor_end
2053
2054 subroutine move_cursor_page_up(cursor, editor)
2055 type(cursor_t), intent(inout) :: cursor
2056 type(editor_state_t), intent(in) :: editor
2057 integer :: page_size
2058
2059 cursor%has_selection = .false. ! Clear selection
2060 page_size = editor%screen_rows - 2 ! Leave room for status bar
2061 cursor%line = max(1, cursor%line - page_size)
2062 cursor%column = cursor%desired_column
2063 end subroutine move_cursor_page_up
2064
2065 subroutine move_cursor_page_down(cursor, editor, line_count)
2066 type(cursor_t), intent(inout) :: cursor
2067 type(editor_state_t), intent(in) :: editor
2068 integer, intent(in) :: line_count
2069 integer :: page_size
2070
2071 cursor%has_selection = .false. ! Clear selection
2072 page_size = editor%screen_rows - 2 ! Leave room for status bar
2073 cursor%line = min(line_count, cursor%line + page_size)
2074 cursor%column = cursor%desired_column
2075 end subroutine move_cursor_page_down
2076
2077 subroutine move_cursor_word_left(cursor, buffer)
2078 type(cursor_t), intent(inout) :: cursor
2079 type(buffer_t), intent(in) :: buffer
2080 character(len=:), allocatable :: line
2081 integer :: pos, line_len
2082
2083 cursor%has_selection = .false. ! Clear selection
2084 line = buffer_get_line(buffer, cursor%line)
2085 line_len = len(line)
2086 pos = cursor%column
2087
2088 ! Handle empty lines
2089 if (line_len == 0) then
2090 if (cursor%line > 1) then
2091 ! Move to end of previous line
2092 cursor%line = cursor%line - 1
2093 if (allocated(line)) deallocate(line)
2094 line = buffer_get_line(buffer, cursor%line)
2095 cursor%column = len(line) + 1
2096 else
2097 cursor%column = 1
2098 end if
2099 cursor%desired_column = cursor%column
2100 if (allocated(line)) deallocate(line)
2101 return
2102 end if
2103
2104 if (pos > 1 .and. line_len > 0) then
2105 ! Simple algorithm: move left one position at a time until we find a word start
2106 ! A word start is: a word char that's either at position 1 OR preceded by a non-word char
2107
2108 pos = pos - 1 ! Move left one position
2109
2110 ! Skip any whitespace
2111 do while (pos > 1 .and. pos <= line_len)
2112 if (line(pos:pos) /= ' ') exit
2113 pos = pos - 1
2114 end do
2115
2116 ! If we're on a word character, go to the start of this word
2117 if (pos >= 1 .and. pos <= line_len) then
2118 if (is_word_char(line(pos:pos))) then
2119 ! Move to the start of the current word
2120 do while (pos > 1)
2121 if (pos-1 < 1) exit ! Safety check
2122 if (.not. is_word_char(line(pos-1:pos-1))) exit
2123 pos = pos - 1
2124 end do
2125 end if
2126 end if
2127
2128 ! Clamp to valid range
2129 if (pos < 1) pos = 1
2130 if (pos > line_len + 1) pos = line_len + 1
2131
2132 cursor%column = pos
2133 else if (cursor%line > 1) then
2134 ! Move to end of previous line
2135 cursor%line = cursor%line - 1
2136 if (allocated(line)) deallocate(line)
2137 line = buffer_get_line(buffer, cursor%line)
2138 cursor%column = len(line) + 1
2139 else
2140 cursor%column = 1
2141 end if
2142
2143 cursor%desired_column = cursor%column
2144 if (allocated(line)) deallocate(line)
2145 end subroutine move_cursor_word_left
2146
2147 subroutine move_cursor_word_right(cursor, buffer)
2148 type(cursor_t), intent(inout) :: cursor
2149 type(buffer_t), intent(in) :: buffer
2150 character(len=:), allocatable :: line
2151 integer :: pos, line_count, line_len
2152
2153 cursor%has_selection = .false. ! Clear selection
2154 line = buffer_get_line(buffer, cursor%line)
2155 line_count = buffer_get_line_count(buffer)
2156 line_len = len(line)
2157 pos = cursor%column
2158
2159 if (pos <= line_len) then
2160 ! VSCode-style word navigation: stop after each word OR punctuation group
2161 if (line(pos:pos) == ' ') then
2162 ! On whitespace - skip all whitespace
2163 do while (pos < line_len)
2164 if (pos+1 <= line_len .and. line(pos+1:pos+1) == ' ') then
2165 pos = pos + 1
2166 else
2167 exit
2168 end if
2169 end do
2170 pos = pos + 1 ! Move past whitespace
2171 else if (is_word_char(line(pos:pos))) then
2172 ! We're on a word character - skip to end of this word
2173 do while (pos < line_len)
2174 if (pos+1 <= line_len) then
2175 if (.not. is_word_char(line(pos+1:pos+1))) exit
2176 end if
2177 pos = pos + 1
2178 end do
2179 pos = pos + 1 ! Move past the word
2180 else
2181 ! We're on punctuation - skip to end of punctuation group
2182 ! e.g., "##" should be treated as one group
2183 do while (pos < line_len)
2184 if (pos+1 <= line_len) then
2185 ! Stop if next char is word char or space
2186 if (is_word_char(line(pos+1:pos+1)) .or. line(pos+1:pos+1) == ' ') exit
2187 end if
2188 pos = pos + 1
2189 end do
2190 pos = pos + 1 ! Move past punctuation
2191 end if
2192
2193 cursor%column = pos
2194 else if (cursor%line < line_count) then
2195 ! Move to start of next line
2196 cursor%line = cursor%line + 1
2197 cursor%column = 1
2198 else
2199 cursor%column = len(line) + 1
2200 end if
2201
2202 cursor%desired_column = cursor%column
2203 if (allocated(line)) deallocate(line)
2204 end subroutine move_cursor_word_right
2205
2206 function is_word_char(ch) result(is_word)
2207 character, intent(in) :: ch
2208 logical :: is_word
2209
2210 is_word = (ch >= 'a' .and. ch <= 'z') .or. &
2211 (ch >= 'A' .and. ch <= 'Z') .or. &
2212 (ch >= '0' .and. ch <= '9') .or. &
2213 ch == '_'
2214 end function is_word_char
2215
2216 subroutine handle_backspace(cursor, buffer)
2217 type(cursor_t), intent(inout) :: cursor
2218 type(buffer_t), intent(inout) :: buffer
2219
2220 ! Delete selection if one exists
2221 if (cursor%has_selection) then
2222 call delete_selection(cursor, buffer)
2223 return
2224 end if
2225
2226 if (cursor%column > 1) then
2227 ! Delete character before cursor
2228 cursor%column = cursor%column - 1
2229 call buffer_delete_at_cursor(buffer, cursor)
2230 cursor%desired_column = cursor%column
2231 else if (cursor%line > 1) then
2232 ! Join with previous line
2233 call join_line_with_previous(cursor, buffer)
2234 end if
2235 end subroutine handle_backspace
2236
2237 subroutine handle_delete(cursor, buffer)
2238 type(cursor_t), intent(inout) :: cursor
2239 type(buffer_t), intent(inout) :: buffer
2240 character(len=:), allocatable :: line
2241 integer :: line_count
2242
2243 ! Delete selection if one exists
2244 if (cursor%has_selection) then
2245 call delete_selection(cursor, buffer)
2246 return
2247 end if
2248
2249 line = buffer_get_line(buffer, cursor%line)
2250 line_count = buffer_get_line_count(buffer)
2251
2252 if (cursor%column <= len(line)) then
2253 ! Delete character at cursor
2254 call buffer_delete_at_cursor(buffer, cursor)
2255 else if (cursor%line < line_count) then
2256 ! Join with next line
2257 call join_line_with_next(cursor, buffer)
2258 end if
2259
2260 if (allocated(line)) deallocate(line)
2261 end subroutine handle_delete
2262
2263 subroutine handle_enter(cursor, buffer)
2264 type(cursor_t), intent(inout) :: cursor
2265 type(buffer_t), intent(inout) :: buffer
2266 character(len=:), allocatable :: current_line
2267 integer :: indent_level, i
2268
2269 ! Delete selection if one exists
2270 if (cursor%has_selection) then
2271 call delete_selection(cursor, buffer)
2272 end if
2273
2274 ! Get the current line to determine indentation
2275 current_line = buffer_get_line(buffer, cursor%line)
2276
2277 ! Count leading spaces/tabs for auto-indent
2278 indent_level = 0
2279 do i = 1, len(current_line)
2280 if (current_line(i:i) == ' ') then
2281 indent_level = indent_level + 1
2282 else if (current_line(i:i) == char(9)) then ! Tab
2283 indent_level = indent_level + 4 ! Treat tab as 4 spaces
2284 else
2285 exit ! Found non-whitespace character
2286 end if
2287 end do
2288
2289 ! Insert the newline
2290 call buffer_insert_newline(buffer, cursor)
2291 cursor%line = cursor%line + 1
2292 cursor%column = 1
2293
2294 ! Insert the same indentation on the new line
2295 do i = 1, indent_level
2296 call buffer_insert_char(buffer, cursor, ' ')
2297 cursor%column = cursor%column + 1
2298 end do
2299
2300 cursor%desired_column = cursor%column
2301
2302 if (allocated(current_line)) deallocate(current_line)
2303 end subroutine handle_enter
2304
2305 subroutine handle_tab(cursor, buffer)
2306 type(cursor_t), intent(inout) :: cursor
2307 type(buffer_t), intent(inout) :: buffer
2308 integer :: i
2309
2310 ! Insert 4 spaces
2311 do i = 1, 4
2312 call buffer_insert_char(buffer, cursor, ' ')
2313 cursor%column = cursor%column + 1
2314 end do
2315 cursor%desired_column = cursor%column
2316 end subroutine handle_tab
2317
2318 subroutine indent_selection(cursor, buffer)
2319 type(cursor_t), intent(inout) :: cursor
2320 type(buffer_t), intent(inout) :: buffer
2321 integer :: start_line, end_line, i
2322 character(len=:), allocatable :: line
2323
2324 if (.not. cursor%has_selection) return
2325
2326 ! Get the range of lines to indent
2327 start_line = min(cursor%selection_start_line, cursor%line)
2328 end_line = max(cursor%selection_start_line, cursor%line)
2329
2330 ! Indent each line in the selection
2331 do i = start_line, end_line
2332 line = buffer_get_line(buffer, i)
2333 ! Insert 4 spaces at the beginning of the line
2334 call buffer_insert_text_at(buffer, i, 1, " ")
2335 if (allocated(line)) deallocate(line)
2336 end do
2337
2338 ! Adjust cursor position if needed
2339 if (cursor%column > 1) then
2340 cursor%column = cursor%column + 4
2341 end if
2342 if (cursor%selection_start_col > 1) then
2343 cursor%selection_start_col = cursor%selection_start_col + 4
2344 end if
2345 end subroutine indent_selection
2346
2347 subroutine dedent_selection(cursor, buffer)
2348 type(cursor_t), intent(inout) :: cursor
2349 type(buffer_t), intent(inout) :: buffer
2350 integer :: start_line, end_line, i, spaces_to_remove
2351 character(len=:), allocatable :: line
2352
2353 if (.not. cursor%has_selection) return
2354
2355 ! Get the range of lines to dedent
2356 start_line = min(cursor%selection_start_line, cursor%line)
2357 end_line = max(cursor%selection_start_line, cursor%line)
2358
2359 ! Dedent each line in the selection
2360 do i = start_line, end_line
2361 line = buffer_get_line(buffer, i)
2362 spaces_to_remove = 0
2363
2364 ! Count how many spaces we can remove (max 4)
2365 do while (spaces_to_remove < 4 .and. spaces_to_remove < len(line))
2366 if (line(spaces_to_remove + 1:spaces_to_remove + 1) == ' ') then
2367 spaces_to_remove = spaces_to_remove + 1
2368 else
2369 exit
2370 end if
2371 end do
2372
2373 ! Remove the spaces
2374 if (spaces_to_remove > 0) then
2375 call buffer_delete_range(buffer, i, 1, i, spaces_to_remove + 1)
2376
2377 ! Adjust cursor position for current line
2378 if (i == cursor%line .and. cursor%column > spaces_to_remove) then
2379 cursor%column = cursor%column - spaces_to_remove
2380 else if (i == cursor%line .and. cursor%column <= spaces_to_remove) then
2381 cursor%column = 1
2382 end if
2383
2384 if (i == cursor%selection_start_line .and. cursor%selection_start_col > spaces_to_remove) then
2385 cursor%selection_start_col = cursor%selection_start_col - spaces_to_remove
2386 else if (i == cursor%selection_start_line .and. cursor%selection_start_col <= spaces_to_remove) then
2387 cursor%selection_start_col = 1
2388 end if
2389 end if
2390
2391 if (allocated(line)) deallocate(line)
2392 end do
2393 end subroutine dedent_selection
2394
2395 subroutine dedent_current_line(cursor, buffer)
2396 type(cursor_t), intent(inout) :: cursor
2397 type(buffer_t), intent(inout) :: buffer
2398 character(len=:), allocatable :: line
2399 integer :: spaces_to_remove
2400
2401 line = buffer_get_line(buffer, cursor%line)
2402 spaces_to_remove = 0
2403
2404 ! Count how many spaces we can remove (max 4)
2405 do while (spaces_to_remove < 4 .and. spaces_to_remove < len(line))
2406 if (line(spaces_to_remove + 1:spaces_to_remove + 1) == ' ') then
2407 spaces_to_remove = spaces_to_remove + 1
2408 else
2409 exit
2410 end if
2411 end do
2412
2413 ! Remove the spaces
2414 if (spaces_to_remove > 0) then
2415 call buffer_delete_range(buffer, cursor%line, 1, cursor%line, spaces_to_remove + 1)
2416
2417 ! Adjust cursor position
2418 if (cursor%column > spaces_to_remove) then
2419 cursor%column = cursor%column - spaces_to_remove
2420 else
2421 cursor%column = 1
2422 end if
2423 cursor%desired_column = cursor%column
2424 end if
2425
2426 if (allocated(line)) deallocate(line)
2427 end subroutine dedent_current_line
2428
2429 subroutine insert_char(cursor, buffer, ch)
2430 type(cursor_t), intent(inout) :: cursor
2431 type(buffer_t), intent(inout) :: buffer
2432 character, intent(in) :: ch
2433 character :: closing_char
2434 logical :: should_auto_close, should_wrap
2435 integer :: start_line, start_col, end_line, end_col
2436
2437 ! Check if we should auto-close or wrap brackets/quotes
2438 should_auto_close = .false.
2439 should_wrap = .false.
2440 select case(ch)
2441 case('(')
2442 closing_char = ')'
2443 should_auto_close = .true.
2444 if (cursor%has_selection) should_wrap = .true.
2445 case('[')
2446 closing_char = ']'
2447 should_auto_close = .true.
2448 if (cursor%has_selection) should_wrap = .true.
2449 case('{')
2450 closing_char = '}'
2451 should_auto_close = .true.
2452 if (cursor%has_selection) should_wrap = .true.
2453 case('"')
2454 closing_char = '"'
2455 should_auto_close = .true.
2456 if (cursor%has_selection) should_wrap = .true.
2457 case("'")
2458 closing_char = "'"
2459 should_auto_close = .true.
2460 if (cursor%has_selection) should_wrap = .true.
2461 case('`')
2462 closing_char = '`'
2463 should_auto_close = .true.
2464 if (cursor%has_selection) should_wrap = .true.
2465 end select
2466
2467 ! If we should wrap, don't delete - wrap the selection instead
2468 if (should_wrap) then
2469 ! Find selection bounds
2470 if (cursor%line < cursor%selection_start_line .or. &
2471 (cursor%line == cursor%selection_start_line .and. cursor%column < cursor%selection_start_col)) then
2472 start_line = cursor%line
2473 start_col = cursor%column
2474 end_line = cursor%selection_start_line
2475 end_col = cursor%selection_start_col
2476 else
2477 start_line = cursor%selection_start_line
2478 start_col = cursor%selection_start_col
2479 end_line = cursor%line
2480 end_col = cursor%column
2481 end if
2482
2483 ! Insert closing character at end
2484 cursor%line = end_line
2485 cursor%column = end_col
2486 call buffer_insert_char(buffer, cursor, closing_char)
2487
2488 ! Insert opening character at start
2489 cursor%line = start_line
2490 cursor%column = start_col
2491 call buffer_insert_char(buffer, cursor, ch)
2492
2493 ! Position cursor after the opening bracket (inside the wrapped text)
2494 cursor%column = start_col + 1
2495 cursor%has_selection = .false.
2496 cursor%desired_column = cursor%column
2497 return
2498 end if
2499
2500 ! Delete selection if one exists (normal behavior)
2501 if (cursor%has_selection) then
2502 call delete_selection(cursor, buffer)
2503 end if
2504
2505 ! Insert the character
2506 call buffer_insert_char(buffer, cursor, ch)
2507 cursor%column = cursor%column + 1
2508
2509 ! If auto-close is enabled, insert the closing character
2510 if (should_auto_close) then
2511 call buffer_insert_char(buffer, cursor, closing_char)
2512 ! Don't move cursor forward - stay between the brackets/quotes
2513 end if
2514
2515 cursor%desired_column = cursor%column
2516 end subroutine insert_char
2517
2518 subroutine insert_char_multiple_cursors(editor, buffer, ch)
2519 type(editor_state_t), intent(inout) :: editor
2520 type(buffer_t), intent(inout) :: buffer
2521 character, intent(in) :: ch
2522 integer :: i
2523 integer :: offset_adjust
2524
2525 ! Sort cursors by position to handle offset adjustments
2526 call sort_cursors_by_position(editor)
2527
2528 offset_adjust = 0
2529 do i = 1, size(editor%cursors)
2530 ! For cursors with selection, delete selection first
2531 if (editor%cursors(i)%has_selection) then
2532 call delete_selection(editor%cursors(i), buffer)
2533 editor%cursors(i)%has_selection = .false.
2534 end if
2535
2536 ! Insert character
2537 call buffer_insert_char(buffer, editor%cursors(i), ch)
2538 editor%cursors(i)%column = editor%cursors(i)%column + 1
2539 editor%cursors(i)%desired_column = editor%cursors(i)%column
2540 end do
2541 end subroutine insert_char_multiple_cursors
2542
2543 subroutine delete_selection(cursor, buffer)
2544 type(cursor_t), intent(inout) :: cursor
2545 type(buffer_t), intent(inout) :: buffer
2546 integer :: start_line, start_col, end_line, end_col
2547 integer :: i
2548 character(len=:), allocatable :: line
2549
2550 if (.not. cursor%has_selection) return
2551
2552 ! Determine start and end of selection
2553 if (cursor%line < cursor%selection_start_line .or. &
2554 (cursor%line == cursor%selection_start_line .and. &
2555 cursor%column < cursor%selection_start_col)) then
2556 start_line = cursor%line
2557 start_col = cursor%column
2558 end_line = cursor%selection_start_line
2559 end_col = cursor%selection_start_col
2560 else
2561 start_line = cursor%selection_start_line
2562 start_col = cursor%selection_start_col
2563 end_line = cursor%line
2564 end_col = cursor%column
2565 end if
2566
2567 ! Delete the selection
2568 if (start_line == end_line) then
2569 ! Single-line selection
2570 line = buffer_get_line(buffer, start_line)
2571 cursor%line = start_line
2572 cursor%column = start_col
2573 do i = start_col, end_col - 1
2574 call buffer_delete_at_cursor(buffer, cursor)
2575 end do
2576 if (allocated(line)) deallocate(line)
2577 else
2578 ! Multi-line selection
2579 ! Delete from start_col to end of first line
2580 cursor%line = start_line
2581 cursor%column = start_col
2582 line = buffer_get_line(buffer, start_line)
2583 do i = start_col, len(line)
2584 call buffer_delete_at_cursor(buffer, cursor)
2585 end do
2586 if (allocated(line)) deallocate(line)
2587
2588 ! Delete entire lines in between
2589 do i = start_line + 1, end_line - 1
2590 ! After deleting from first line, the next line moves up
2591 ! So we keep deleting line at position start_line + 1
2592 if (buffer_get_line_count(buffer) > start_line) then
2593 ! Delete the newline to join with next line
2594 line = buffer_get_line(buffer, start_line)
2595 cursor%column = len(line) + 1
2596 call buffer_delete_at_cursor(buffer, cursor) ! Delete newline
2597 if (allocated(line)) deallocate(line)
2598
2599 ! Delete all content of the joined line
2600 line = buffer_get_line(buffer, start_line)
2601 cursor%column = len(line)
2602 do while (cursor%column > start_col .and. cursor%column > 0)
2603 call buffer_delete_at_cursor(buffer, cursor)
2604 cursor%column = cursor%column - 1
2605 end do
2606 if (allocated(line)) deallocate(line)
2607 end if
2608 end do
2609
2610 ! Delete from beginning of last line to end_col
2611 if (buffer_get_line_count(buffer) > start_line) then
2612 line = buffer_get_line(buffer, start_line)
2613 cursor%column = len(line) + 1
2614 call buffer_delete_at_cursor(buffer, cursor) ! Delete newline
2615 if (allocated(line)) deallocate(line)
2616
2617 ! Delete from start to end_col
2618 cursor%column = start_col
2619 do i = 1, end_col - 1
2620 if (cursor%column <= buffer_get_line_count(buffer)) then
2621 call buffer_delete_at_cursor(buffer, cursor)
2622 end if
2623 end do
2624 end if
2625
2626 cursor%line = start_line
2627 cursor%column = start_col
2628 end if
2629
2630 cursor%has_selection = .false.
2631 end subroutine delete_selection
2632
2633 function get_selection_text(cursor, buffer) result(text)
2634 type(cursor_t), intent(in) :: cursor
2635 type(buffer_t), intent(in) :: buffer
2636 character(len=:), allocatable :: text
2637 integer :: start_line, start_col, end_line, end_col
2638 integer :: i
2639 character(len=:), allocatable :: line
2640
2641 if (.not. cursor%has_selection) then
2642 allocate(character(len=0) :: text)
2643 return
2644 end if
2645
2646 ! Determine start and end of selection
2647 if (cursor%line < cursor%selection_start_line .or. &
2648 (cursor%line == cursor%selection_start_line .and. &
2649 cursor%column < cursor%selection_start_col)) then
2650 start_line = cursor%line
2651 start_col = cursor%column
2652 end_line = cursor%selection_start_line
2653 end_col = cursor%selection_start_col
2654 else
2655 start_line = cursor%selection_start_line
2656 start_col = cursor%selection_start_col
2657 end_line = cursor%line
2658 end_col = cursor%column
2659 end if
2660
2661 ! Extract text based on selection
2662 if (start_line == end_line) then
2663 ! Single-line selection
2664 line = buffer_get_line(buffer, start_line)
2665 if (allocated(line) .and. start_col <= len(line) + 1 .and. end_col <= len(line) + 1) then
2666 if (end_col > start_col) then
2667 allocate(character(len=end_col - start_col) :: text)
2668 text = line(start_col:end_col - 1)
2669 else
2670 allocate(character(len=0) :: text)
2671 end if
2672 else
2673 allocate(character(len=0) :: text)
2674 end if
2675 if (allocated(line)) deallocate(line)
2676 else
2677 ! Multi-line selection
2678 text = ""
2679
2680 ! First line (from start_col to end)
2681 line = buffer_get_line(buffer, start_line)
2682 if (allocated(line)) then
2683 if (start_col <= len(line)) then
2684 text = text // line(start_col:)
2685 end if
2686 text = text // char(10) ! newline
2687 deallocate(line)
2688 end if
2689
2690 ! Middle lines (complete lines)
2691 do i = start_line + 1, end_line - 1
2692 line = buffer_get_line(buffer, i)
2693 if (allocated(line)) then
2694 text = text // line // char(10)
2695 deallocate(line)
2696 end if
2697 end do
2698
2699 ! Last line (from beginning to end_col)
2700 line = buffer_get_line(buffer, end_line)
2701 if (allocated(line)) then
2702 if (end_col > 1 .and. end_col <= len(line) + 1) then
2703 text = text // line(1:end_col - 1)
2704 end if
2705 deallocate(line)
2706 end if
2707 end if
2708 end function get_selection_text
2709
2710 subroutine sort_cursors_by_position(editor)
2711 type(editor_state_t), intent(inout) :: editor
2712 type(cursor_t) :: temp
2713 integer :: i, j
2714 logical :: swapped
2715
2716 ! Simple bubble sort for small number of cursors
2717 do i = 1, size(editor%cursors) - 1
2718 swapped = .false.
2719 do j = 1, size(editor%cursors) - i
2720 if (editor%cursors(j)%line > editor%cursors(j+1)%line .or. &
2721 (editor%cursors(j)%line == editor%cursors(j+1)%line .and. &
2722 editor%cursors(j)%column > editor%cursors(j+1)%column)) then
2723 temp = editor%cursors(j)
2724 editor%cursors(j) = editor%cursors(j+1)
2725 editor%cursors(j+1) = temp
2726 if (editor%active_cursor == j) then
2727 editor%active_cursor = j + 1
2728 else if (editor%active_cursor == j + 1) then
2729 editor%active_cursor = j
2730 end if
2731 swapped = .true.
2732 end if
2733 end do
2734 if (.not. swapped) exit
2735 end do
2736 end subroutine sort_cursors_by_position
2737
2738 subroutine deduplicate_cursors(editor)
2739 type(editor_state_t), intent(inout) :: editor
2740 type(cursor_t), allocatable :: unique_cursors(:)
2741 integer :: i, j, unique_count, duplicate_of
2742 logical :: is_duplicate
2743 integer, allocatable :: old_to_new_map(:)
2744
2745 if (size(editor%cursors) <= 1) return
2746
2747 ! Allocate mapping from old cursor indices to new indices
2748 allocate(old_to_new_map(size(editor%cursors)))
2749 old_to_new_map = 0
2750
2751 ! Count unique cursors
2752 unique_count = 0
2753 do i = 1, size(editor%cursors)
2754 is_duplicate = .false.
2755 do j = 1, i-1
2756 if (editor%cursors(i)%line == editor%cursors(j)%line .and. &
2757 editor%cursors(i)%column == editor%cursors(j)%column) then
2758 is_duplicate = .true.
2759 exit
2760 end if
2761 end do
2762 if (.not. is_duplicate) then
2763 unique_count = unique_count + 1
2764 end if
2765 end do
2766
2767 ! If we have duplicates, create new array with only unique cursors
2768 if (unique_count < size(editor%cursors)) then
2769 allocate(unique_cursors(unique_count))
2770 unique_count = 0
2771 do i = 1, size(editor%cursors)
2772 is_duplicate = .false.
2773 duplicate_of = 0
2774 do j = 1, i-1
2775 if (editor%cursors(i)%line == editor%cursors(j)%line .and. &
2776 editor%cursors(i)%column == editor%cursors(j)%column) then
2777 is_duplicate = .true.
2778 duplicate_of = j
2779 exit
2780 end if
2781 end do
2782 if (.not. is_duplicate) then
2783 unique_count = unique_count + 1
2784 unique_cursors(unique_count) = editor%cursors(i)
2785 old_to_new_map(i) = unique_count
2786 else
2787 ! This cursor is a duplicate of an earlier one
2788 ! Map it to the same new index as the earlier cursor
2789 old_to_new_map(i) = old_to_new_map(duplicate_of)
2790 end if
2791 end do
2792
2793 ! Update active cursor using the mapping
2794 if (editor%active_cursor > 0 .and. editor%active_cursor <= size(old_to_new_map)) then
2795 editor%active_cursor = old_to_new_map(editor%active_cursor)
2796 end if
2797 ! Ensure active_cursor is valid
2798 if (editor%active_cursor < 1 .or. editor%active_cursor > unique_count) then
2799 editor%active_cursor = 1
2800 end if
2801
2802 deallocate(editor%cursors)
2803 editor%cursors = unique_cursors
2804 deallocate(old_to_new_map)
2805 end if
2806 end subroutine deduplicate_cursors
2807
2808 subroutine join_line_with_previous(cursor, buffer)
2809 type(cursor_t), intent(inout) :: cursor
2810 type(buffer_t), intent(inout) :: buffer
2811 character(len=:), allocatable :: prev_line
2812 integer :: new_column
2813
2814 prev_line = buffer_get_line(buffer, cursor%line - 1)
2815 new_column = len(prev_line) + 1
2816
2817 ! Move to end of previous line
2818 cursor%line = cursor%line - 1
2819 cursor%column = new_column
2820
2821 ! Delete the newline
2822 call buffer_delete_at_cursor(buffer, cursor)
2823
2824 cursor%desired_column = cursor%column
2825 if (allocated(prev_line)) deallocate(prev_line)
2826 end subroutine join_line_with_previous
2827
2828 subroutine join_line_with_next(cursor, buffer)
2829 type(cursor_t), intent(inout) :: cursor
2830 type(buffer_t), intent(inout) :: buffer
2831
2832 ! Delete the newline at end of current line
2833 call buffer_delete_at_cursor(buffer, cursor)
2834 end subroutine join_line_with_next
2835
2836 subroutine kill_line_forward(cursor, buffer)
2837 type(cursor_t), intent(inout) :: cursor
2838 type(buffer_t), intent(inout) :: buffer
2839 character(len=:), allocatable :: line
2840 character(len=:), allocatable :: killed_text
2841 integer :: i
2842
2843 cursor%has_selection = .false. ! Clear selection
2844
2845 line = buffer_get_line(buffer, cursor%line)
2846
2847 if (cursor%column <= len(line)) then
2848 ! Kill from cursor to end of line
2849 killed_text = line(cursor%column:)
2850 do i = cursor%column, len(line)
2851 call buffer_delete_at_cursor(buffer, cursor)
2852 end do
2853 else
2854 ! At end of line - kill the newline
2855 killed_text = char(10) ! newline
2856 call buffer_delete_at_cursor(buffer, cursor)
2857 end if
2858
2859 ! Add to yank stack
2860 if (len(killed_text) > 0) then
2861 call push_yank(yank_stack, killed_text)
2862 end if
2863
2864 buffer%modified = .true.
2865 if (allocated(line)) deallocate(line)
2866 if (allocated(killed_text)) deallocate(killed_text)
2867 end subroutine kill_line_forward
2868
2869 subroutine kill_line_backward(cursor, buffer)
2870 type(cursor_t), intent(inout) :: cursor
2871 type(buffer_t), intent(inout) :: buffer
2872 character(len=:), allocatable :: line
2873 character(len=:), allocatable :: killed_text
2874 integer :: i, start_col
2875
2876 cursor%has_selection = .false. ! Clear selection
2877
2878 line = buffer_get_line(buffer, cursor%line)
2879 start_col = cursor%column
2880
2881 if (cursor%column > 1) then
2882 ! Kill from start of line to cursor
2883 killed_text = line(1:cursor%column-1)
2884 cursor%column = 1
2885 do i = 1, start_col - 1
2886 call buffer_delete_at_cursor(buffer, cursor)
2887 end do
2888 cursor%desired_column = 1
2889 end if
2890
2891 ! Add to yank stack
2892 if (allocated(killed_text)) then
2893 if (len(killed_text) > 0) then
2894 call push_yank(yank_stack, killed_text)
2895 end if
2896 end if
2897
2898 buffer%modified = .true.
2899 if (allocated(line)) deallocate(line)
2900 if (allocated(killed_text)) deallocate(killed_text)
2901 end subroutine kill_line_backward
2902
2903 subroutine yank_text(cursor, buffer)
2904 type(cursor_t), intent(inout) :: cursor
2905 type(buffer_t), intent(inout) :: buffer
2906 character(len=:), allocatable :: text
2907 integer :: i
2908
2909 text = pop_yank(yank_stack)
2910 if (allocated(text)) then
2911 do i = 1, len(text)
2912 if (text(i:i) == char(10)) then
2913 call buffer_insert_newline(buffer, cursor)
2914 cursor%line = cursor%line + 1
2915 cursor%column = 1
2916 else
2917 call buffer_insert_char(buffer, cursor, text(i:i))
2918 cursor%column = cursor%column + 1
2919 end if
2920 end do
2921 cursor%desired_column = cursor%column
2922 buffer%modified = .true.
2923 deallocate(text)
2924 end if
2925 end subroutine yank_text
2926
2927 subroutine move_line_up(cursor, buffer)
2928 type(cursor_t), intent(inout) :: cursor
2929 type(buffer_t), intent(inout) :: buffer
2930 character(len=:), allocatable :: current_line, prev_line
2931 integer :: saved_column, original_line, total_lines
2932
2933 if (cursor%line <= 1) return
2934
2935 ! Save state
2936 saved_column = cursor%column
2937 original_line = cursor%line
2938 total_lines = buffer_get_line_count(buffer)
2939
2940 ! Get both lines
2941 current_line = buffer_get_line(buffer, cursor%line)
2942 prev_line = buffer_get_line(buffer, cursor%line - 1)
2943
2944 ! Delete current line entirely (including newline)
2945 cursor%column = 1
2946 call delete_entire_line(buffer, cursor)
2947
2948 ! Move to previous line (now current_line position after delete)
2949 cursor%line = cursor%line - 1
2950 cursor%column = 1
2951
2952 ! Delete previous line entirely (including newline)
2953 call delete_entire_line(buffer, cursor)
2954
2955 ! Now insert current_line first, then prev_line
2956 cursor%column = 1
2957 call insert_line_text(buffer, cursor, current_line)
2958 call buffer_insert_newline(buffer, cursor)
2959
2960 cursor%line = cursor%line + 1
2961 cursor%column = 1
2962 call insert_line_text(buffer, cursor, prev_line)
2963 ! Add newline if we're not at the last line
2964 if (original_line < total_lines) then
2965 call buffer_insert_newline(buffer, cursor)
2966 end if
2967
2968 ! Restore cursor to moved line
2969 cursor%line = cursor%line - 1
2970 cursor%column = min(saved_column, len(current_line) + 1)
2971 cursor%desired_column = cursor%column
2972
2973 buffer%modified = .true.
2974 if (allocated(current_line)) deallocate(current_line)
2975 if (allocated(prev_line)) deallocate(prev_line)
2976 end subroutine move_line_up
2977
2978 subroutine move_line_down(cursor, buffer)
2979 type(cursor_t), intent(inout) :: cursor
2980 type(buffer_t), intent(inout) :: buffer
2981 character(len=:), allocatable :: current_line, next_line
2982 integer :: line_count, saved_column, original_line, total_lines
2983
2984 line_count = buffer_get_line_count(buffer)
2985 if (cursor%line >= line_count) return
2986
2987 ! Save state
2988 saved_column = cursor%column
2989 original_line = cursor%line
2990 total_lines = line_count
2991
2992 ! Get both lines
2993 current_line = buffer_get_line(buffer, cursor%line)
2994 next_line = buffer_get_line(buffer, cursor%line + 1)
2995
2996 ! Delete current line entirely (including newline)
2997 cursor%column = 1
2998 call delete_entire_line(buffer, cursor)
2999
3000 ! Delete next line entirely (including newline)
3001 ! After deleting current line, next line is now at cursor%line
3002 cursor%column = 1
3003 call delete_entire_line(buffer, cursor)
3004
3005 ! Now insert next_line first, then current_line
3006 cursor%column = 1
3007 call insert_line_text(buffer, cursor, next_line)
3008 call buffer_insert_newline(buffer, cursor)
3009
3010 cursor%line = cursor%line + 1
3011 cursor%column = 1
3012 call insert_line_text(buffer, cursor, current_line)
3013 ! Add newline if we're not at the last line
3014 if (original_line + 1 < total_lines) then
3015 call buffer_insert_newline(buffer, cursor)
3016 end if
3017
3018 ! Restore cursor position on moved line
3019 ! Current line is now at cursor%line (which is original_line + 1)
3020 ! So cursor is already on the moved line, just need to fix column
3021 cursor%column = min(saved_column, len(current_line) + 1)
3022 cursor%desired_column = cursor%column
3023
3024 buffer%modified = .true.
3025 if (allocated(current_line)) deallocate(current_line)
3026 if (allocated(next_line)) deallocate(next_line)
3027 end subroutine move_line_down
3028
3029 subroutine duplicate_line_up(cursor, buffer)
3030 type(cursor_t), intent(inout) :: cursor
3031 type(buffer_t), intent(inout) :: buffer
3032 character(len=:), allocatable :: line
3033
3034 line = buffer_get_line(buffer, cursor%line)
3035
3036 ! Move to start of line
3037 cursor%column = 1
3038 ! Insert newline before
3039 call buffer_insert_newline(buffer, cursor)
3040 ! Insert the duplicated text
3041 call insert_line_text(buffer, cursor, line)
3042 ! Stay on original line
3043 cursor%line = cursor%line + 1
3044
3045 buffer%modified = .true.
3046 if (allocated(line)) deallocate(line)
3047 end subroutine duplicate_line_up
3048
3049 subroutine duplicate_line_down(cursor, buffer)
3050 type(cursor_t), intent(inout) :: cursor
3051 type(buffer_t), intent(inout) :: buffer
3052 character(len=:), allocatable :: line
3053 integer :: saved_column
3054
3055 line = buffer_get_line(buffer, cursor%line)
3056 saved_column = cursor%column
3057
3058 ! Move to end of line
3059 cursor%column = len(line) + 1
3060 ! Insert newline
3061 call buffer_insert_newline(buffer, cursor)
3062 cursor%line = cursor%line + 1
3063 cursor%column = 1
3064 ! Insert the duplicated text
3065 call insert_line_text(buffer, cursor, line)
3066
3067 ! Return to original position
3068 cursor%line = cursor%line - 1
3069 cursor%column = saved_column
3070 cursor%desired_column = saved_column
3071
3072 buffer%modified = .true.
3073 if (allocated(line)) deallocate(line)
3074 end subroutine duplicate_line_down
3075
3076 subroutine delete_entire_line(buffer, cursor)
3077 type(buffer_t), intent(inout) :: buffer
3078 type(cursor_t), intent(inout) :: cursor
3079 character(len=:), allocatable :: line
3080 integer :: i
3081
3082 line = buffer_get_line(buffer, cursor%line)
3083 cursor%column = 1
3084
3085 ! Delete all characters in line
3086 do i = 1, len(line)
3087 call buffer_delete_at_cursor(buffer, cursor)
3088 end do
3089
3090 ! Delete the newline if not the last line
3091 if (cursor%line < buffer_get_line_count(buffer)) then
3092 call buffer_delete_at_cursor(buffer, cursor)
3093 end if
3094
3095 if (allocated(line)) deallocate(line)
3096 end subroutine delete_entire_line
3097
3098 subroutine insert_line_text(buffer, cursor, text)
3099 type(buffer_t), intent(inout) :: buffer
3100 type(cursor_t), intent(inout) :: cursor
3101 character(len=*), intent(in) :: text
3102 integer :: i
3103
3104 do i = 1, len(text)
3105 call buffer_insert_char(buffer, cursor, text(i:i))
3106 cursor%column = cursor%column + 1
3107 end do
3108 end subroutine insert_line_text
3109
3110 function buffer_get_char_at(buffer, pos) result(ch)
3111 type(buffer_t), intent(in) :: buffer
3112 integer, intent(in) :: pos
3113 character :: ch
3114
3115 if (pos < buffer%gap_start) then
3116 ch = buffer%data(pos:pos)
3117 else
3118 ch = buffer%data(pos + (buffer%gap_end - buffer%gap_start):&
3119 pos + (buffer%gap_end - buffer%gap_start))
3120 end if
3121 end function buffer_get_char_at
3122
3123 subroutine cut_selection_or_line(cursor, buffer)
3124 type(cursor_t), intent(inout) :: cursor
3125 type(buffer_t), intent(inout) :: buffer
3126 character(len=:), allocatable :: text
3127
3128 if (cursor%has_selection) then
3129 ! Get selected text
3130 text = get_selection_text(cursor, buffer)
3131
3132 ! Copy to clipboard
3133 if (allocated(text)) then
3134 call copy_to_clipboard(text)
3135 end if
3136
3137 ! Delete the selection
3138 call delete_selection(cursor, buffer)
3139 else
3140 ! Get current line
3141 text = buffer_get_line(buffer, cursor%line)
3142
3143 ! Copy to clipboard
3144 call copy_to_clipboard(text)
3145
3146 ! Delete the line
3147 cursor%column = 1
3148 call delete_entire_line(buffer, cursor)
3149 end if
3150
3151 buffer%modified = .true.
3152 if (allocated(text)) deallocate(text)
3153 end subroutine cut_selection_or_line
3154
3155 subroutine copy_selection_or_line(cursor, buffer)
3156 type(cursor_t), intent(in) :: cursor
3157 type(buffer_t), intent(in) :: buffer
3158 character(len=:), allocatable :: text
3159
3160 if (cursor%has_selection) then
3161 ! Get selected text (only what's selected, no automatic newlines)
3162 text = get_selection_text(cursor, buffer)
3163 else
3164 ! Get current line - don't add newline, user can select it if they want it
3165 text = buffer_get_line(buffer, cursor%line)
3166 end if
3167
3168 ! Copy to clipboard
3169 if (allocated(text)) then
3170 call copy_to_clipboard(text)
3171 deallocate(text)
3172 end if
3173 end subroutine copy_selection_or_line
3174
3175 subroutine paste_clipboard(cursor, buffer)
3176 type(cursor_t), intent(inout) :: cursor
3177 type(buffer_t), intent(inout) :: buffer
3178 character(len=:), allocatable :: text
3179 integer :: i
3180
3181 ! Get text from clipboard
3182 text = paste_from_clipboard()
3183
3184 if (allocated(text)) then
3185 ! Insert text at cursor position
3186 do i = 1, len(text)
3187 if (text(i:i) == char(10)) then
3188 call buffer_insert_newline(buffer, cursor)
3189 cursor%line = cursor%line + 1
3190 cursor%column = 1
3191 else
3192 call buffer_insert_char(buffer, cursor, text(i:i))
3193 cursor%column = cursor%column + 1
3194 end if
3195 end do
3196 cursor%desired_column = cursor%column
3197 buffer%modified = .true.
3198 deallocate(text)
3199 end if
3200 end subroutine paste_clipboard
3201
3202 subroutine save_file(editor, buffer)
3203 use text_prompt_module, only: show_text_prompt
3204 use lsp_server_manager_module, only: notify_file_saved
3205 use text_buffer_module, only: buffer_to_string
3206 type(editor_state_t), intent(inout) :: editor
3207 type(buffer_t), intent(inout) :: buffer
3208 integer :: ios, tab_idx
3209 character(len=256) :: temp_filename, command
3210 character(len=512) :: new_filename
3211 logical :: file_exists, cancelled
3212
3213 if (.not. allocated(editor%filename)) return
3214
3215 ! Check if this is an untitled file - prompt for filename
3216 if (index(editor%filename, '[Untitled') == 1) then
3217 call show_text_prompt('Save as: ', new_filename, cancelled, editor%screen_rows)
3218
3219 if (cancelled .or. len_trim(new_filename) == 0) then
3220 ! User cancelled or entered empty filename
3221 call terminal_move_cursor(editor%screen_rows, 1)
3222 call terminal_write('Save cancelled')
3223 return
3224 end if
3225
3226 ! Update editor filename
3227 if (allocated(editor%filename)) deallocate(editor%filename)
3228 allocate(character(len=len_trim(new_filename)) :: editor%filename)
3229 editor%filename = trim(new_filename)
3230
3231 ! Update tab filename if in workspace mode
3232 if (allocated(editor%tabs) .and. editor%active_tab_index > 0) then
3233 tab_idx = editor%active_tab_index
3234 if (tab_idx <= size(editor%tabs)) then
3235 if (allocated(editor%tabs(tab_idx)%filename)) then
3236 deallocate(editor%tabs(tab_idx)%filename)
3237 end if
3238 allocate(character(len=len_trim(new_filename)) :: editor%tabs(tab_idx)%filename)
3239 editor%tabs(tab_idx)%filename = trim(new_filename)
3240
3241 ! Update pane filename
3242 if (allocated(editor%tabs(tab_idx)%panes)) then
3243 if (size(editor%tabs(tab_idx)%panes) > 0) then
3244 if (allocated(editor%tabs(tab_idx)%panes(1)%filename)) then
3245 deallocate(editor%tabs(tab_idx)%panes(1)%filename)
3246 end if
3247 allocate(character(len=len_trim(new_filename)) :: &
3248 editor%tabs(tab_idx)%panes(1)%filename)
3249 editor%tabs(tab_idx)%panes(1)%filename = trim(new_filename)
3250 end if
3251 end if
3252 end if
3253 end if
3254 end if
3255
3256 ! First try normal save
3257 call buffer_save_file(buffer, editor%filename, ios)
3258
3259 if (ios == 0) then
3260 buffer%modified = .false.
3261
3262 ! Send LSP didSave notification to ALL active servers
3263 if (allocated(editor%tabs) .and. editor%active_tab_index > 0) then
3264 tab_idx = editor%active_tab_index
3265 if (tab_idx <= size(editor%tabs)) then
3266 if (editor%tabs(tab_idx)%num_lsp_servers > 0) then
3267 block
3268 integer :: srv_i
3269 do srv_i = 1, editor%tabs(tab_idx)%num_lsp_servers
3270 call notify_file_saved(editor%lsp_manager, &
3271 editor%tabs(tab_idx)%lsp_server_indices(srv_i), &
3272 trim(editor%filename), buffer_to_string(buffer))
3273 end do
3274 end block
3275 end if
3276 end if
3277 end if
3278
3279 return
3280 end if
3281
3282 ! Check if file exists and we have write permission
3283 inquire(file=editor%filename, exist=file_exists)
3284
3285 ! If save failed, try sudo save
3286 write(temp_filename, '(a,i0)') '/tmp/facsimile_sudo_', get_process_id()
3287
3288 ! Save to temporary file
3289 call buffer_save_file(buffer, temp_filename, ios)
3290 if (ios /= 0) then
3291 ! Can't even save to /tmp, serious problem
3292 write(error_unit, *) 'Error: Cannot save file even to /tmp'
3293 return
3294 end if
3295
3296 ! Use sudo to move the file
3297 write(command, '(a,a,a,a,a)') 'sudo mv ', trim(temp_filename), ' ', &
3298 trim(editor%filename), ' 2>/dev/null'
3299
3300 ! Show message to user
3301 call terminal_move_cursor(editor%screen_rows, 1)
3302 call terminal_write('[sudo] password required to save file')
3303
3304 ! Execute sudo command
3305 call execute_command_line(command, exitstat=ios)
3306
3307 if (ios == 0) then
3308 buffer%modified = .false.
3309 call terminal_move_cursor(editor%screen_rows, 1)
3310 call terminal_write('File saved with sudo ')
3311
3312 ! Send LSP didSave notification to ALL active servers
3313 if (allocated(editor%tabs) .and. editor%active_tab_index > 0) then
3314 tab_idx = editor%active_tab_index
3315 if (tab_idx <= size(editor%tabs)) then
3316 if (editor%tabs(tab_idx)%num_lsp_servers > 0) then
3317 block
3318 integer :: srv_i
3319 do srv_i = 1, editor%tabs(tab_idx)%num_lsp_servers
3320 call notify_file_saved(editor%lsp_manager, &
3321 editor%tabs(tab_idx)%lsp_server_indices(srv_i), &
3322 trim(editor%filename), buffer_to_string(buffer))
3323 end do
3324 end block
3325 end if
3326 end if
3327 end if
3328 else
3329 ! Clean up temp file
3330 write(command, '(a,a)') 'rm -f ', trim(temp_filename)
3331 call execute_command_line(command)
3332 call terminal_move_cursor(editor%screen_rows, 1)
3333 call terminal_write('Save failed - permission denied ')
3334 end if
3335 end subroutine save_file
3336
3337 function get_process_id() result(pid)
3338 integer :: pid
3339 interface
3340 function c_getpid() bind(C, name="getpid")
3341 use iso_c_binding, only: c_int
3342 integer(c_int) :: c_getpid
3343 end function
3344 end interface
3345 pid = c_getpid()
3346 end function get_process_id
3347
3348 subroutine cycle_quotes(cursor, buffer)
3349 type(cursor_t), intent(inout) :: cursor
3350 type(buffer_t), intent(inout) :: buffer
3351 character(len=:), allocatable :: line
3352 integer :: quote_start, quote_end
3353 character :: current_quote, new_quote
3354
3355 line = buffer_get_line(buffer, cursor%line)
3356
3357 ! Find surrounding quotes
3358 call find_surrounding_quotes(line, cursor%column, quote_start, quote_end, current_quote)
3359
3360 if (quote_start > 0 .and. quote_end > 0) then
3361 ! Determine next quote type
3362 select case(current_quote)
3363 case('"')
3364 new_quote = "'"
3365 case("'")
3366 new_quote = '`'
3367 case('`')
3368 new_quote = '"'
3369 case default
3370 return
3371 end select
3372
3373 ! Replace quotes
3374 cursor%column = quote_start
3375 call buffer_delete_at_cursor(buffer, cursor)
3376 call buffer_insert_char(buffer, cursor, new_quote)
3377
3378 cursor%column = quote_end
3379 call buffer_delete_at_cursor(buffer, cursor)
3380 call buffer_insert_char(buffer, cursor, new_quote)
3381
3382 ! Restore cursor position
3383 cursor%column = quote_end
3384 buffer%modified = .true.
3385 end if
3386
3387 if (allocated(line)) deallocate(line)
3388 end subroutine cycle_quotes
3389
3390 subroutine find_surrounding_quotes(line, pos, start_pos, end_pos, quote_char)
3391 character(len=*), intent(in) :: line
3392 integer, intent(in) :: pos
3393 integer, intent(out) :: start_pos, end_pos
3394 character, intent(out) :: quote_char
3395 integer :: i
3396
3397 start_pos = 0
3398 end_pos = 0
3399 quote_char = ' '
3400
3401 ! Search backward for opening quote
3402 do i = pos - 1, 1, -1
3403 if (line(i:i) == '"' .or. line(i:i) == "'" .or. line(i:i) == '`') then
3404 start_pos = i
3405 quote_char = line(i:i)
3406 exit
3407 end if
3408 end do
3409
3410 if (start_pos > 0) then
3411 ! Search forward for closing quote
3412 do i = pos, len(line)
3413 if (line(i:i) == quote_char) then
3414 end_pos = i
3415 exit
3416 end if
3417 end do
3418 end if
3419 end subroutine find_surrounding_quotes
3420
3421 subroutine remove_brackets(cursor, buffer)
3422 type(cursor_t), intent(inout) :: cursor
3423 type(buffer_t), intent(inout) :: buffer
3424 character(len=:), allocatable :: line
3425 integer :: bracket_start, bracket_end
3426 character :: open_bracket, close_bracket
3427
3428 line = buffer_get_line(buffer, cursor%line)
3429
3430 ! Find surrounding brackets
3431 call find_surrounding_brackets(line, cursor%column, bracket_start, bracket_end, &
3432 open_bracket, close_bracket)
3433
3434 if (bracket_start > 0 .and. bracket_end > 0) then
3435 ! Delete closing bracket first (to maintain positions)
3436 cursor%column = bracket_end
3437 call buffer_delete_at_cursor(buffer, cursor)
3438
3439 ! Delete opening bracket
3440 cursor%column = bracket_start
3441 call buffer_delete_at_cursor(buffer, cursor)
3442
3443 buffer%modified = .true.
3444 end if
3445
3446 if (allocated(line)) deallocate(line)
3447 end subroutine remove_brackets
3448
3449 subroutine find_surrounding_brackets(line, pos, start_pos, end_pos, open_br, close_br)
3450 character(len=*), intent(in) :: line
3451 integer, intent(in) :: pos
3452 integer, intent(out) :: start_pos, end_pos
3453 character, intent(out) :: open_br, close_br
3454 integer :: i
3455
3456 start_pos = 0
3457 end_pos = 0
3458 open_br = ' '
3459 close_br = ' '
3460
3461 ! Search backward for opening bracket
3462 do i = pos - 1, 1, -1
3463 select case(line(i:i))
3464 case('(')
3465 start_pos = i
3466 open_br = '('
3467 close_br = ')'
3468 exit
3469 case('[')
3470 start_pos = i
3471 open_br = '['
3472 close_br = ']'
3473 exit
3474 case('{')
3475 start_pos = i
3476 open_br = '{'
3477 close_br = '}'
3478 exit
3479 end select
3480 end do
3481
3482 if (start_pos > 0) then
3483 ! Search forward for matching closing bracket
3484 do i = pos, len(line)
3485 if (line(i:i) == close_br) then
3486 end_pos = i
3487 exit
3488 end if
3489 end do
3490 end if
3491 end subroutine find_surrounding_brackets
3492
3493 subroutine init_cursor(cursor)
3494 type(cursor_t), intent(out) :: cursor
3495 cursor%line = 1
3496 cursor%column = 1
3497 cursor%desired_column = 1
3498 cursor%has_selection = .false.
3499 cursor%selection_start_line = 1
3500 cursor%selection_start_col = 1
3501 end subroutine init_cursor
3502
3503 subroutine handle_mouse_event_action(key_str, editor, buffer)
3504 use editor_state_module, only: get_active_pane_indices
3505 character(len=*), intent(in) :: key_str
3506 type(editor_state_t), intent(inout) :: editor
3507 type(buffer_t), intent(in) :: buffer
3508 integer :: button, row, col
3509 integer :: colon1, colon2, colon3
3510 character(len=100) :: event_type
3511 integer :: ios, line_count
3512 type(cursor_t), allocatable :: new_cursors(:)
3513 integer :: i, cursor_exists
3514 ! Variables for mouse drag handling
3515 logical :: had_selection, in_active_pane
3516 integer :: selection_start_line, selection_start_col
3517 integer :: tab_idx, pane_idx
3518
3519 line_count = buffer_get_line_count(buffer)
3520
3521 ! Parse the mouse event string format: "mouse-type:button:row:col"
3522 colon1 = index(key_str, ':')
3523 if (colon1 == 0) return
3524
3525 event_type = key_str(1:colon1-1)
3526 colon2 = index(key_str(colon1+1:), ':') + colon1
3527 if (colon2 == colon1) return
3528
3529 colon3 = index(key_str(colon2+1:), ':') + colon2
3530 if (colon3 == colon2) return
3531
3532 ! Parse button, row, and col
3533 read(key_str(colon1+1:colon2-1), '(i10)', iostat=ios) button
3534 if (ios /= 0) return
3535
3536 read(key_str(colon2+1:colon3-1), '(i10)', iostat=ios) row
3537 if (ios /= 0) return
3538
3539 read(key_str(colon3+1:), '(i10)', iostat=ios) col
3540 if (ios /= 0) return
3541
3542 ! Handle different mouse event types
3543 select case(trim(event_type))
3544 case('mouse-click')
3545 ! Regular click - move cursor to position
3546 if (button == 0) then ! Left click
3547 ! Clear other cursors first (single cursor mode)
3548 if (allocated(editor%cursors)) then
3549 if (size(editor%cursors) > 1) then
3550 deallocate(editor%cursors)
3551 allocate(editor%cursors(1))
3552 call init_cursor(editor%cursors(1))
3553 editor%active_cursor = 1
3554 end if
3555 end if
3556
3557 ! Now position cursor (this might switch panes and update cursors)
3558 call position_cursor_at_screen(editor%active_cursor, &
3559 editor, buffer, row, col)
3560
3561 ! Clear selection after positioning (cursor array is now stable)
3562 if (allocated(editor%cursors) .and. editor%active_cursor > 0 .and. &
3563 editor%active_cursor <= size(editor%cursors)) then
3564 editor%cursors(editor%active_cursor)%has_selection = .false.
3565 end if
3566 end if
3567
3568 case('mouse-drag')
3569 ! Mouse drag - extend selection within current pane only
3570 ! We shouldn't switch panes while dragging, only move cursor within current pane
3571
3572 ! Check if we're in the current active pane - don't switch panes during drag
3573 call get_active_pane_indices(editor, tab_idx, pane_idx)
3574 in_active_pane = .false.
3575
3576 if (tab_idx > 0 .and. pane_idx > 0 .and. allocated(editor%tabs(tab_idx)%panes)) then
3577 associate(pane => editor%tabs(tab_idx)%panes(pane_idx))
3578 ! Check if mouse is still in the active pane
3579 if (row >= pane%screen_row .and. &
3580 row < pane%screen_row + pane%screen_height .and. &
3581 col >= pane%screen_col .and. &
3582 col < pane%screen_col + pane%screen_width) then
3583 in_active_pane = .true.
3584 end if
3585 end associate
3586 end if
3587
3588 ! Only process drag if within active pane
3589 if (in_active_pane) then
3590 had_selection = editor%cursors(editor%active_cursor)%has_selection
3591 if (.not. had_selection) then
3592 ! Start selection from current position
3593 selection_start_line = editor%cursors(editor%active_cursor)%line
3594 selection_start_col = editor%cursors(editor%active_cursor)%column
3595 else
3596 selection_start_line = editor%cursors(editor%active_cursor)%selection_start_line
3597 selection_start_col = editor%cursors(editor%active_cursor)%selection_start_col
3598 end if
3599
3600 ! Move cursor to drag position (won't switch panes since we're in active pane)
3601 call position_cursor_at_screen(editor%active_cursor, &
3602 editor, buffer, row, col)
3603
3604 ! Restore/set selection state after positioning
3605 if (allocated(editor%cursors) .and. editor%active_cursor > 0 .and. &
3606 editor%active_cursor <= size(editor%cursors)) then
3607 editor%cursors(editor%active_cursor)%has_selection = .true.
3608 editor%cursors(editor%active_cursor)%selection_start_line = selection_start_line
3609 editor%cursors(editor%active_cursor)%selection_start_col = selection_start_col
3610 end if
3611
3612 call update_viewport(editor)
3613 end if
3614
3615 case('mouse-release')
3616 ! Mouse button released - nothing special to do
3617 continue
3618
3619 case('mouse-scroll-up')
3620 ! Scroll up by 3 lines
3621 editor%viewport_line = max(1, editor%viewport_line - 3)
3622 call sync_editor_to_pane(editor)
3623
3624 case('mouse-scroll-down')
3625 ! Scroll down by 3 lines
3626 editor%viewport_line = min(buffer_get_line_count(buffer) - editor%screen_rows + 2, &
3627 editor%viewport_line + 3)
3628 call sync_editor_to_pane(editor)
3629
3630 case('mouse-alt')
3631 ! Alt+click - add or remove cursor
3632 if (button == 8) then ! Alt + left click (button code includes alt modifier)
3633 ! Check if cursor already exists at this position
3634 cursor_exists = 0
3635 do i = 1, size(editor%cursors)
3636 if (is_cursor_at_screen_pos(editor%cursors(i), editor, row, col)) then
3637 cursor_exists = i
3638 exit
3639 end if
3640 end do
3641
3642 if (cursor_exists > 0) then
3643 ! Remove the cursor
3644 if (size(editor%cursors) > 1) then
3645 allocate(new_cursors(size(editor%cursors) - 1))
3646 do i = 1, cursor_exists - 1
3647 new_cursors(i) = editor%cursors(i)
3648 end do
3649 do i = cursor_exists + 1, size(editor%cursors)
3650 new_cursors(i-1) = editor%cursors(i)
3651 end do
3652 deallocate(editor%cursors)
3653 editor%cursors = new_cursors
3654 if (editor%active_cursor >= cursor_exists) then
3655 editor%active_cursor = max(1, editor%active_cursor - 1)
3656 end if
3657 end if
3658 else
3659 ! Add a new cursor
3660 allocate(new_cursors(size(editor%cursors) + 1))
3661 do i = 1, size(editor%cursors)
3662 new_cursors(i) = editor%cursors(i)
3663 end do
3664 call init_cursor(new_cursors(size(new_cursors)))
3665 ! First move the new cursors to editor
3666 deallocate(editor%cursors)
3667 editor%cursors = new_cursors
3668 editor%active_cursor = size(editor%cursors)
3669 ! Then position the new cursor using its index
3670 call position_cursor_at_screen(editor%active_cursor, &
3671 editor, buffer, row, col)
3672 end if
3673 end if
3674
3675 end select
3676 end subroutine handle_mouse_event_action
3677
3678 subroutine position_cursor_at_screen(cursor_idx, editor, buffer, screen_row, screen_col)
3679 use renderer_module, only: show_line_numbers, LINE_NUMBER_WIDTH
3680 use editor_state_module, only: get_active_pane_indices, switch_to_pane, sync_editor_to_pane
3681 integer, intent(inout) :: cursor_idx ! Use index instead of reference
3682 type(editor_state_t), intent(inout) :: editor
3683 type(buffer_t), intent(in) :: buffer
3684 integer, intent(in) :: screen_row, screen_col
3685 integer :: target_line, target_col, col_offset, row_offset
3686 character(len=:), allocatable :: line
3687 integer :: line_count
3688 integer :: tab_idx, pane_idx, i
3689 integer :: pane_row, pane_col
3690 logical :: in_pane
3691
3692 line_count = buffer_get_line_count(buffer)
3693 in_pane = .false.
3694
3695 ! Account for tab bar offset - when tabs exist, row 1 is tab bar, content starts at row 2
3696 if (size(editor%tabs) > 0) then
3697 row_offset = 2 ! Tab bar takes row 1
3698 else
3699 row_offset = 1 ! No tab bar
3700 end if
3701
3702 ! Ignore clicks on the tab bar
3703 if (size(editor%tabs) > 0 .and. screen_row < row_offset) then
3704 return ! Don't move cursor if clicking on tab bar
3705 end if
3706
3707 ! Account for line number display offset
3708 if (show_line_numbers) then
3709 col_offset = LINE_NUMBER_WIDTH + 1 ! +1 for separator space
3710 else
3711 col_offset = 0
3712 end if
3713
3714 ! Check if we're in a pane system
3715 call get_active_pane_indices(editor, tab_idx, pane_idx)
3716 if (tab_idx > 0 .and. pane_idx > 0 .and. allocated(editor%tabs(tab_idx)%panes)) then
3717 ! Find which pane was clicked
3718 do i = 1, size(editor%tabs(tab_idx)%panes)
3719 ! Check if click is within this pane's boundaries
3720 if (screen_row >= editor%tabs(tab_idx)%panes(i)%screen_row .and. &
3721 screen_row < editor%tabs(tab_idx)%panes(i)%screen_row + &
3722 editor%tabs(tab_idx)%panes(i)%screen_height .and. &
3723 screen_col >= editor%tabs(tab_idx)%panes(i)%screen_col .and. &
3724 screen_col < editor%tabs(tab_idx)%panes(i)%screen_col + &
3725 editor%tabs(tab_idx)%panes(i)%screen_width) then
3726
3727 ! If clicking on a different pane, switch to it first
3728 if (i /= pane_idx) then
3729 call switch_to_pane(editor, tab_idx, i)
3730 pane_idx = i
3731 ! Update cursor index after switching (might have changed)
3732 cursor_idx = editor%active_cursor
3733 end if
3734
3735 ! Now use the active pane's data
3736 associate(pane => editor%tabs(tab_idx)%panes(pane_idx))
3737 ! Calculate position relative to pane
3738 ! Note: screen_row is where the click occurred, pane%screen_row is top of pane
3739 ! We want 0-based offset into the pane
3740 pane_row = screen_row - pane%screen_row
3741 pane_col = screen_col - pane%screen_col + 1
3742
3743 ! Convert pane position to buffer position using pane's viewport
3744 target_line = pane%viewport_line + pane_row
3745 target_col = pane%viewport_column + max(1, pane_col - col_offset)
3746 in_pane = .true.
3747 end associate
3748 exit
3749 end if
3750 end do
3751
3752 if (.not. in_pane) then
3753 return ! Click outside of any pane
3754 end if
3755 else
3756 ! No panes, use editor viewport
3757 target_line = editor%viewport_line + screen_row - row_offset
3758 target_col = editor%viewport_column + max(1, screen_col - col_offset)
3759 end if
3760
3761 ! Clamp to valid range
3762 if (target_line < 1) target_line = 1
3763 if (target_line > line_count) target_line = line_count
3764
3765 ! Get line and adjust column to valid positions only
3766 line = buffer_get_line(buffer, target_line)
3767 if (target_col < 1) target_col = 1
3768 ! Clamp column to actual line length + 1 (position after last char)
3769 if (target_col > len(line) + 1) target_col = len(line) + 1
3770
3771 ! Set cursor position (ensure cursor_idx is valid)
3772 if (allocated(editor%cursors) .and. cursor_idx > 0 .and. cursor_idx <= size(editor%cursors)) then
3773 editor%cursors(cursor_idx)%line = target_line
3774 editor%cursors(cursor_idx)%column = target_col
3775 editor%cursors(cursor_idx)%desired_column = target_col
3776 end if
3777
3778 if (allocated(line)) deallocate(line)
3779
3780 ! Sync the updated cursor position back to the active pane
3781 call sync_editor_to_pane(editor)
3782 end subroutine position_cursor_at_screen
3783
3784 function is_cursor_at_screen_pos(cursor, editor, screen_row, screen_col) result(at_pos)
3785 type(cursor_t), intent(in) :: cursor
3786 type(editor_state_t), intent(in) :: editor
3787 integer, intent(in) :: screen_row, screen_col
3788 logical :: at_pos
3789 integer :: cursor_screen_row, cursor_screen_col, row_offset
3790
3791 ! Account for tab bar - when tabs exist, content starts at row 2
3792 if (size(editor%tabs) > 0) then
3793 row_offset = 2
3794 else
3795 row_offset = 1
3796 end if
3797
3798 cursor_screen_row = cursor%line - editor%viewport_line + row_offset
3799 cursor_screen_col = cursor%column - editor%viewport_column + 1
3800 at_pos = (cursor_screen_row == screen_row .and. cursor_screen_col == screen_col)
3801 end function is_cursor_at_screen_pos
3802
3803 subroutine select_next_match(editor, buffer)
3804 type(editor_state_t), intent(inout) :: editor
3805 type(buffer_t), intent(inout) :: buffer
3806 type(cursor_t), allocatable :: new_cursors(:)
3807 character(len=:), allocatable :: word
3808 integer :: i
3809 integer :: found_line, found_col
3810 logical :: found
3811
3812 ! If no pattern selected yet, select word at cursor
3813 if (.not. allocated(search_pattern)) then
3814 call select_word_at_cursor(editor%cursors(editor%active_cursor), buffer)
3815 word = get_selected_text(editor%cursors(editor%active_cursor), buffer)
3816 if (allocated(word)) then
3817 search_pattern = word
3818 end if
3819 else
3820 ! Search for next occurrence
3821 call find_next_occurrence(buffer, search_pattern, &
3822 editor%cursors(size(editor%cursors))%line, &
3823 editor%cursors(size(editor%cursors))%column, &
3824 found, found_line, found_col)
3825
3826 if (found) then
3827 ! Add a new cursor at the found position
3828 allocate(new_cursors(size(editor%cursors) + 1))
3829 do i = 1, size(editor%cursors)
3830 new_cursors(i) = editor%cursors(i)
3831 end do
3832
3833 ! Initialize new cursor
3834 call init_cursor(new_cursors(size(new_cursors)))
3835 new_cursors(size(new_cursors))%line = found_line
3836 new_cursors(size(new_cursors))%column = found_col
3837 new_cursors(size(new_cursors))%has_selection = .true.
3838 new_cursors(size(new_cursors))%selection_start_line = found_line
3839 new_cursors(size(new_cursors))%selection_start_col = found_col
3840 new_cursors(size(new_cursors))%column = found_col + len(search_pattern)
3841
3842 deallocate(editor%cursors)
3843 editor%cursors = new_cursors
3844 editor%active_cursor = size(editor%cursors)
3845 end if
3846 end if
3847 end subroutine select_next_match
3848
3849 subroutine select_word_at_cursor(cursor, buffer)
3850 type(cursor_t), intent(inout) :: cursor
3851 type(buffer_t), intent(in) :: buffer
3852 character(len=:), allocatable :: line
3853 integer :: word_start, word_end
3854
3855 line = buffer_get_line(buffer, cursor%line)
3856
3857 ! Find word boundaries
3858 call find_word_boundaries(line, cursor%column, word_start, word_end)
3859
3860 if (word_start > 0 .and. word_end >= word_start) then
3861 ! Select the word
3862 cursor%has_selection = .true.
3863 cursor%selection_start_line = cursor%line
3864 cursor%selection_start_col = word_start
3865 cursor%column = word_end + 1
3866 cursor%desired_column = cursor%column
3867 end if
3868
3869 if (allocated(line)) deallocate(line)
3870 end subroutine select_word_at_cursor
3871
3872 function get_selected_text(cursor, buffer) result(text)
3873 type(cursor_t), intent(in) :: cursor
3874 type(buffer_t), intent(in) :: buffer
3875 character(len=:), allocatable :: text
3876 character(len=:), allocatable :: line
3877 integer :: start_col, end_col
3878
3879 if (.not. cursor%has_selection) then
3880 allocate(character(len=0) :: text)
3881 return
3882 end if
3883
3884 ! For single-line selection only (for now)
3885 if (cursor%selection_start_line == cursor%line) then
3886 line = buffer_get_line(buffer, cursor%line)
3887 start_col = min(cursor%selection_start_col, cursor%column)
3888 end_col = max(cursor%selection_start_col, cursor%column) - 1
3889
3890 if (start_col <= len(line) .and. end_col <= len(line)) then
3891 text = line(start_col:end_col)
3892 else
3893 allocate(character(len=0) :: text)
3894 end if
3895 if (allocated(line)) deallocate(line)
3896 else
3897 allocate(character(len=0) :: text)
3898 end if
3899 end function get_selected_text
3900
3901 subroutine find_word_boundaries(line, pos, word_start, word_end)
3902 character(len=*), intent(in) :: line
3903 integer, intent(in) :: pos
3904 integer, intent(out) :: word_start, word_end
3905 integer :: i
3906
3907 word_start = 0
3908 word_end = 0
3909
3910 ! Check if we're on a word character
3911 if (pos <= len(line)) then
3912 if (.not. is_word_char(line(pos:pos))) then
3913 return
3914 end if
3915
3916 ! Find start of word
3917 word_start = pos
3918 do i = pos - 1, 1, -1
3919 if (is_word_char(line(i:i))) then
3920 word_start = i
3921 else
3922 exit
3923 end if
3924 end do
3925
3926 ! Find end of word
3927 word_end = pos
3928 do i = pos + 1, len(line)
3929 if (is_word_char(line(i:i))) then
3930 word_end = i
3931 else
3932 exit
3933 end if
3934 end do
3935 end if
3936 end subroutine find_word_boundaries
3937
3938 subroutine find_next_occurrence(buffer, pattern, start_line, start_col, &
3939 found, found_line, found_col)
3940 type(buffer_t), intent(in) :: buffer
3941 character(len=*), intent(in) :: pattern
3942 integer, intent(in) :: start_line, start_col
3943 logical, intent(out) :: found
3944 integer, intent(out) :: found_line, found_col
3945 character(len=:), allocatable :: line
3946 character(len=:), allocatable :: search_line, search_pattern
3947 integer :: line_count, current_line, pos
3948 integer :: search_col
3949
3950 found = .false.
3951 found_line = 0
3952 found_col = 0
3953 line_count = buffer_get_line_count(buffer)
3954
3955 ! Search from current position to end
3956 do current_line = start_line, line_count
3957 line = buffer_get_line(buffer, current_line)
3958
3959 if (current_line == start_line) then
3960 search_col = start_col + 1
3961 else
3962 search_col = 1
3963 end if
3964
3965 ! Perform case-sensitive or case-insensitive search
3966 if (match_case_sensitive) then
3967 pos = index(line(search_col:), pattern)
3968 else
3969 search_line = to_lower(line(search_col:))
3970 search_pattern = to_lower(pattern)
3971 pos = index(search_line, search_pattern)
3972 if (allocated(search_line)) deallocate(search_line)
3973 end if
3974
3975 if (pos > 0) then
3976 found = .true.
3977 found_line = current_line
3978 found_col = search_col + pos - 1
3979 if (allocated(line)) deallocate(line)
3980 if (allocated(search_pattern)) deallocate(search_pattern)
3981 return
3982 end if
3983 if (allocated(line)) deallocate(line)
3984 end do
3985
3986 ! Wrap around to beginning
3987 do current_line = 1, start_line
3988 line = buffer_get_line(buffer, current_line)
3989
3990 if (current_line == start_line) then
3991 ! Search only up to start position
3992 if (start_col > 1) then
3993 if (match_case_sensitive) then
3994 pos = index(line(1:start_col-1), pattern)
3995 else
3996 search_line = to_lower(line(1:start_col-1))
3997 search_pattern = to_lower(pattern)
3998 pos = index(search_line, search_pattern)
3999 if (allocated(search_line)) deallocate(search_line)
4000 end if
4001 else
4002 pos = 0
4003 end if
4004 else
4005 if (match_case_sensitive) then
4006 pos = index(line, pattern)
4007 else
4008 search_line = to_lower(line)
4009 search_pattern = to_lower(pattern)
4010 pos = index(search_line, search_pattern)
4011 if (allocated(search_line)) deallocate(search_line)
4012 end if
4013 end if
4014
4015 if (pos > 0) then
4016 found = .true.
4017 found_line = current_line
4018 found_col = pos
4019 if (allocated(line)) deallocate(line)
4020 if (allocated(search_pattern)) deallocate(search_pattern)
4021 return
4022 end if
4023
4024 if (allocated(line)) deallocate(line)
4025 end do
4026
4027 if (allocated(search_pattern)) deallocate(search_pattern)
4028 end subroutine find_next_occurrence
4029
4030 ! Helper function to convert a string to lowercase for case-insensitive comparison
4031 function to_lower(str) result(lower_str)
4032 character(len=*), intent(in) :: str
4033 character(len=:), allocatable :: lower_str
4034 integer :: i
4035
4036 allocate(character(len=len(str)) :: lower_str)
4037
4038 do i = 1, len(str)
4039 if (iachar(str(i:i)) >= iachar('A') .and. &
4040 iachar(str(i:i)) <= iachar('Z')) then
4041 lower_str(i:i) = char(iachar(str(i:i)) + 32)
4042 else
4043 lower_str(i:i) = str(i:i)
4044 end if
4045 end do
4046 end function to_lower
4047
4048 ! ========================================================================
4049 ! Buffer Helper Functions - Wrappers for cursor-based operations
4050 ! ========================================================================
4051
4052 subroutine buffer_delete_at_cursor(buffer, cursor)
4053 type(buffer_t), intent(inout) :: buffer
4054 type(cursor_t), intent(in) :: cursor
4055 integer :: pos
4056
4057 ! Convert cursor position to buffer position
4058 pos = get_buffer_position(buffer, cursor%line, cursor%column)
4059 if (pos > 0 .and. pos <= get_buffer_content_size(buffer)) then
4060 call buffer_delete(buffer, pos, 1)
4061 end if
4062 end subroutine buffer_delete_at_cursor
4063
4064 subroutine buffer_insert_char(buffer, cursor, ch)
4065 type(buffer_t), intent(inout) :: buffer
4066 type(cursor_t), intent(in) :: cursor
4067 character, intent(in) :: ch
4068 integer :: pos
4069
4070 ! Convert cursor position to buffer position
4071 pos = get_buffer_position(buffer, cursor%line, cursor%column)
4072 call buffer_insert(buffer, pos, ch)
4073 end subroutine buffer_insert_char
4074
4075 subroutine buffer_insert_newline(buffer, cursor)
4076 type(buffer_t), intent(inout) :: buffer
4077 type(cursor_t), intent(in) :: cursor
4078 integer :: pos
4079
4080 ! Convert cursor position to buffer position
4081 pos = get_buffer_position(buffer, cursor%line, cursor%column)
4082 call buffer_insert(buffer, pos, char(10))
4083 end subroutine buffer_insert_newline
4084
4085 subroutine buffer_insert_text_at(buffer, line, column, text)
4086 type(buffer_t), intent(inout) :: buffer
4087 integer, intent(in) :: line, column
4088 character(len=*), intent(in) :: text
4089 integer :: pos
4090
4091 ! Convert line/column to buffer position
4092 pos = get_buffer_position(buffer, line, column)
4093 call buffer_insert(buffer, pos, text)
4094 end subroutine buffer_insert_text_at
4095
4096 subroutine buffer_delete_range(buffer, start_line, start_col, end_line, end_col)
4097 type(buffer_t), intent(inout) :: buffer
4098 integer, intent(in) :: start_line, start_col, end_line, end_col
4099 integer :: start_pos, end_pos, count
4100
4101 ! Convert positions to buffer positions
4102 start_pos = get_buffer_position(buffer, start_line, start_col)
4103 end_pos = get_buffer_position(buffer, end_line, end_col)
4104 count = end_pos - start_pos
4105
4106 if (count > 0) then
4107 call buffer_delete(buffer, start_pos, count)
4108 end if
4109 end subroutine buffer_delete_range
4110
4111 function get_buffer_position(buffer, line, column) result(pos)
4112 type(buffer_t), intent(in) :: buffer
4113 integer, intent(in) :: line, column
4114 integer :: pos
4115 integer :: current_line, i, col_in_line
4116 character :: ch
4117
4118 pos = 1
4119 current_line = 1
4120 col_in_line = 1
4121
4122 ! Find the position for the given line and column
4123 do i = 1, get_buffer_content_size(buffer)
4124 if (current_line == line .and. col_in_line == column) then
4125 pos = i
4126 return
4127 end if
4128
4129 ch = buffer_get_char(buffer, i)
4130 if (ch == char(10)) then
4131 if (current_line == line) then
4132 ! We're at the end of the target line
4133 pos = i
4134 return
4135 end if
4136 current_line = current_line + 1
4137 col_in_line = 1
4138 else
4139 col_in_line = col_in_line + 1
4140 end if
4141 end do
4142
4143 ! If we reach here, we're at the end of the buffer
4144 pos = get_buffer_content_size(buffer) + 1
4145 end function get_buffer_position
4146
4147 function get_buffer_content_size(buffer) result(size)
4148 type(buffer_t), intent(in) :: buffer
4149 integer :: size
4150
4151 size = buffer%size - (buffer%gap_end - buffer%gap_start)
4152 end function get_buffer_content_size
4153
4154 ! ========================================================================
4155 ! Multiple Cursor Addition Above/Below
4156 ! ========================================================================
4157
4158 subroutine add_cursor_above(editor)
4159 type(editor_state_t), intent(inout) :: editor
4160 type(cursor_t), allocatable :: new_cursors(:)
4161 type(cursor_t) :: active_cursor
4162 integer :: i, new_line
4163
4164 active_cursor = editor%cursors(editor%active_cursor)
4165 new_line = active_cursor%line - 1
4166
4167 ! Check if we can add a cursor above
4168 if (new_line < 1) return
4169
4170 ! Allocate space for additional cursor
4171 allocate(new_cursors(size(editor%cursors) + 1))
4172
4173 ! Copy existing cursors
4174 do i = 1, size(editor%cursors)
4175 new_cursors(i) = editor%cursors(i)
4176 end do
4177
4178 ! Add new cursor above
4179 new_cursors(size(new_cursors))%line = new_line
4180 new_cursors(size(new_cursors))%column = active_cursor%column
4181 new_cursors(size(new_cursors))%desired_column = active_cursor%desired_column
4182 new_cursors(size(new_cursors))%has_selection = .false.
4183
4184 ! Replace cursors array
4185 call move_alloc(new_cursors, editor%cursors)
4186
4187 ! Set the new cursor as active
4188 editor%active_cursor = size(editor%cursors)
4189 end subroutine add_cursor_above
4190
4191 subroutine add_cursor_below(editor, buffer)
4192 type(editor_state_t), intent(inout) :: editor
4193 type(buffer_t), intent(in) :: buffer
4194 type(cursor_t), allocatable :: new_cursors(:)
4195 type(cursor_t) :: active_cursor
4196 integer :: i, new_line, line_count
4197
4198 active_cursor = editor%cursors(editor%active_cursor)
4199 line_count = buffer_get_line_count(buffer)
4200 new_line = active_cursor%line + 1
4201
4202 ! Check if we can add a cursor below
4203 if (new_line > line_count) return
4204
4205 ! Allocate space for additional cursor
4206 allocate(new_cursors(size(editor%cursors) + 1))
4207
4208 ! Copy existing cursors
4209 do i = 1, size(editor%cursors)
4210 new_cursors(i) = editor%cursors(i)
4211 end do
4212
4213 ! Add new cursor below
4214 new_cursors(size(new_cursors))%line = new_line
4215 new_cursors(size(new_cursors))%column = active_cursor%column
4216 new_cursors(size(new_cursors))%desired_column = active_cursor%desired_column
4217 new_cursors(size(new_cursors))%has_selection = .false.
4218
4219 ! Replace cursors array
4220 call move_alloc(new_cursors, editor%cursors)
4221
4222 ! Set the new cursor as active
4223 editor%active_cursor = size(editor%cursors)
4224 end subroutine add_cursor_below
4225
4226 subroutine jump_to_matching_bracket(editor, buffer)
4227 type(editor_state_t), intent(inout) :: editor
4228 type(buffer_t), intent(in) :: buffer
4229 logical :: found
4230 integer :: match_line, match_col
4231
4232 ! Find matching bracket from current cursor position
4233 call find_matching_bracket(buffer, &
4234 editor%cursors(editor%active_cursor)%line, &
4235 editor%cursors(editor%active_cursor)%column, &
4236 found, match_line, match_col)
4237
4238 if (found) then
4239 ! Jump to the matching bracket
4240 editor%cursors(editor%active_cursor)%line = match_line
4241 editor%cursors(editor%active_cursor)%column = match_col
4242 editor%cursors(editor%active_cursor)%desired_column = match_col
4243
4244 ! Update viewport to ensure cursor is visible
4245 call update_viewport(editor)
4246 end if
4247 end subroutine jump_to_matching_bracket
4248
4249 ! ========================================================================
4250 ! Selection Extension Subroutines
4251 ! ========================================================================
4252
4253 subroutine extend_selection_up(cursor, buffer)
4254 type(cursor_t), intent(inout) :: cursor
4255 type(buffer_t), intent(in) :: buffer
4256 character(len=:), allocatable :: current_line, target_line
4257
4258 ! Initialize selection if not already started
4259 if (.not. cursor%has_selection) then
4260 cursor%has_selection = .true.
4261 cursor%selection_start_line = cursor%line
4262 cursor%selection_start_col = cursor%column
4263 end if
4264
4265 ! Move cursor up
4266 if (cursor%line > 1) then
4267 current_line = buffer_get_line(buffer, cursor%line)
4268 cursor%line = cursor%line - 1
4269 target_line = buffer_get_line(buffer, cursor%line)
4270
4271 ! If coming from empty line, go to end of target line
4272 if (len(current_line) == 0) then
4273 cursor%column = len(target_line) + 1
4274 cursor%desired_column = cursor%column
4275 else
4276 cursor%column = cursor%desired_column
4277 if (cursor%column > len(target_line) + 1) then
4278 cursor%column = len(target_line) + 1
4279 end if
4280 end if
4281
4282 if (allocated(current_line)) deallocate(current_line)
4283 if (allocated(target_line)) deallocate(target_line)
4284 end if
4285 end subroutine extend_selection_up
4286
4287 subroutine extend_selection_down(cursor, buffer, line_count)
4288 type(cursor_t), intent(inout) :: cursor
4289 type(buffer_t), intent(in) :: buffer
4290 integer, intent(in) :: line_count
4291 character(len=:), allocatable :: current_line, target_line
4292
4293 ! Initialize selection if not already started
4294 if (.not. cursor%has_selection) then
4295 cursor%has_selection = .true.
4296 cursor%selection_start_line = cursor%line
4297 cursor%selection_start_col = cursor%column
4298 end if
4299
4300 ! Move cursor down
4301 if (cursor%line < line_count) then
4302 current_line = buffer_get_line(buffer, cursor%line)
4303 cursor%line = cursor%line + 1
4304 target_line = buffer_get_line(buffer, cursor%line)
4305
4306 ! If coming from empty line, go to column 1 of target line
4307 if (len(current_line) == 0) then
4308 cursor%column = 1
4309 cursor%desired_column = 1
4310 else
4311 cursor%column = cursor%desired_column
4312 if (cursor%column > len(target_line) + 1) then
4313 cursor%column = len(target_line) + 1
4314 end if
4315 end if
4316
4317 if (allocated(current_line)) deallocate(current_line)
4318 if (allocated(target_line)) deallocate(target_line)
4319 end if
4320 end subroutine extend_selection_down
4321
4322 subroutine extend_selection_left(cursor, buffer)
4323 type(cursor_t), intent(inout) :: cursor
4324 type(buffer_t), intent(in) :: buffer
4325 character(len=:), allocatable :: line
4326
4327 ! Initialize selection if not already started
4328 if (.not. cursor%has_selection) then
4329 cursor%has_selection = .true.
4330 cursor%selection_start_line = cursor%line
4331 cursor%selection_start_col = cursor%column
4332 end if
4333
4334 ! Move cursor left
4335 if (cursor%column > 1) then
4336 cursor%column = cursor%column - 1
4337 cursor%desired_column = cursor%column
4338 else if (cursor%line > 1) then
4339 ! Move to end of previous line
4340 cursor%line = cursor%line - 1
4341 line = buffer_get_line(buffer, cursor%line)
4342 cursor%column = len(line) + 1
4343 cursor%desired_column = cursor%column
4344 if (allocated(line)) deallocate(line)
4345 end if
4346 end subroutine extend_selection_left
4347
4348 subroutine extend_selection_right(cursor, buffer)
4349 type(cursor_t), intent(inout) :: cursor
4350 type(buffer_t), intent(in) :: buffer
4351 character(len=:), allocatable :: line
4352 integer :: line_count
4353
4354 ! Initialize selection if not already started
4355 if (.not. cursor%has_selection) then
4356 cursor%has_selection = .true.
4357 cursor%selection_start_line = cursor%line
4358 cursor%selection_start_col = cursor%column
4359 end if
4360
4361 line = buffer_get_line(buffer, cursor%line)
4362 line_count = buffer_get_line_count(buffer)
4363
4364 ! Move cursor right
4365 if (cursor%column <= len(line)) then
4366 cursor%column = cursor%column + 1
4367 cursor%desired_column = cursor%column
4368 else if (cursor%line < line_count) then
4369 ! Move to start of next line
4370 cursor%line = cursor%line + 1
4371 cursor%column = 1
4372 cursor%desired_column = cursor%column
4373 end if
4374
4375 if (allocated(line)) deallocate(line)
4376 end subroutine extend_selection_right
4377
4378 subroutine extend_selection_home(cursor)
4379 type(cursor_t), intent(inout) :: cursor
4380
4381 ! Initialize selection if not already started
4382 if (.not. cursor%has_selection) then
4383 cursor%has_selection = .true.
4384 cursor%selection_start_line = cursor%line
4385 cursor%selection_start_col = cursor%column
4386 end if
4387
4388 cursor%column = 1
4389 cursor%desired_column = 1
4390 end subroutine extend_selection_home
4391
4392 subroutine extend_selection_end(cursor, buffer)
4393 type(cursor_t), intent(inout) :: cursor
4394 type(buffer_t), intent(in) :: buffer
4395 character(len=:), allocatable :: line
4396
4397 ! Initialize selection if not already started
4398 if (.not. cursor%has_selection) then
4399 cursor%has_selection = .true.
4400 cursor%selection_start_line = cursor%line
4401 cursor%selection_start_col = cursor%column
4402 end if
4403
4404 line = buffer_get_line(buffer, cursor%line)
4405 cursor%column = len(line) + 1
4406 cursor%desired_column = cursor%column
4407 if (allocated(line)) deallocate(line)
4408 end subroutine extend_selection_end
4409
4410 subroutine extend_selection_page_up(cursor, editor)
4411 type(cursor_t), intent(inout) :: cursor
4412 type(editor_state_t), intent(in) :: editor
4413 integer :: page_size
4414
4415 ! Initialize selection if not already started
4416 if (.not. cursor%has_selection) then
4417 cursor%has_selection = .true.
4418 cursor%selection_start_line = cursor%line
4419 cursor%selection_start_col = cursor%column
4420 end if
4421
4422 page_size = editor%screen_rows - 2 ! Leave room for status bar
4423 cursor%line = max(1, cursor%line - page_size)
4424 cursor%column = cursor%desired_column
4425 end subroutine extend_selection_page_up
4426
4427 subroutine extend_selection_page_down(cursor, editor, line_count)
4428 type(cursor_t), intent(inout) :: cursor
4429 type(editor_state_t), intent(in) :: editor
4430 integer, intent(in) :: line_count
4431 integer :: page_size
4432
4433 ! Initialize selection if not already started
4434 if (.not. cursor%has_selection) then
4435 cursor%has_selection = .true.
4436 cursor%selection_start_line = cursor%line
4437 cursor%selection_start_col = cursor%column
4438 end if
4439
4440 page_size = editor%screen_rows - 2 ! Leave room for status bar
4441 cursor%line = min(line_count, cursor%line + page_size)
4442 cursor%column = cursor%desired_column
4443 end subroutine extend_selection_page_down
4444
4445 subroutine extend_selection_word_left(cursor, buffer)
4446 type(cursor_t), intent(inout) :: cursor
4447 type(buffer_t), intent(in) :: buffer
4448 character(len=:), allocatable :: line
4449 integer :: pos, line_len
4450
4451 ! Initialize selection if not already started
4452 if (.not. cursor%has_selection) then
4453 cursor%has_selection = .true.
4454 cursor%selection_start_line = cursor%line
4455 cursor%selection_start_col = cursor%column
4456 end if
4457
4458 line = buffer_get_line(buffer, cursor%line)
4459 line_len = len(line)
4460 pos = cursor%column
4461
4462 ! Handle empty lines
4463 if (line_len == 0) then
4464 if (cursor%line > 1) then
4465 cursor%line = cursor%line - 1
4466 if (allocated(line)) deallocate(line)
4467 line = buffer_get_line(buffer, cursor%line)
4468 cursor%column = len(line) + 1
4469 else
4470 cursor%column = 1
4471 end if
4472 cursor%desired_column = cursor%column
4473 if (allocated(line)) deallocate(line)
4474 return
4475 end if
4476
4477 if (pos > 1 .and. line_len > 0) then
4478 ! Simple algorithm: move left one position at a time until we find a word start
4479 pos = pos - 1 ! Move left one position
4480
4481 ! Skip any whitespace
4482 do while (pos > 1 .and. pos <= line_len)
4483 if (line(pos:pos) /= ' ') exit
4484 pos = pos - 1
4485 end do
4486
4487 ! If we're on a word character, go to the start of this word
4488 if (pos >= 1 .and. pos <= line_len) then
4489 if (is_word_char(line(pos:pos))) then
4490 ! Move to the start of the current word
4491 do while (pos > 1)
4492 if (pos-1 < 1) exit ! Safety check
4493 if (.not. is_word_char(line(pos-1:pos-1))) exit
4494 pos = pos - 1
4495 end do
4496 end if
4497 end if
4498
4499 ! Clamp to valid range
4500 if (pos < 1) pos = 1
4501 if (pos > line_len + 1) pos = line_len + 1
4502
4503 cursor%column = pos
4504 else if (cursor%line > 1) then
4505 ! Move to end of previous line
4506 cursor%line = cursor%line - 1
4507 if (allocated(line)) deallocate(line)
4508 line = buffer_get_line(buffer, cursor%line)
4509 cursor%column = len(line) + 1
4510 else
4511 cursor%column = 1
4512 end if
4513
4514 cursor%desired_column = cursor%column
4515 if (allocated(line)) deallocate(line)
4516 end subroutine extend_selection_word_left
4517
4518 subroutine extend_selection_word_right(cursor, buffer)
4519 type(cursor_t), intent(inout) :: cursor
4520 type(buffer_t), intent(in) :: buffer
4521 character(len=:), allocatable :: line
4522 integer :: pos, line_count, line_len
4523
4524 ! Initialize selection if not already started
4525 if (.not. cursor%has_selection) then
4526 cursor%has_selection = .true.
4527 cursor%selection_start_line = cursor%line
4528 cursor%selection_start_col = cursor%column
4529 end if
4530
4531 line = buffer_get_line(buffer, cursor%line)
4532 line_count = buffer_get_line_count(buffer)
4533 line_len = len(line)
4534 pos = cursor%column
4535
4536 ! Clamp position to valid range
4537 if (pos > line_len + 1) pos = line_len + 1
4538 if (pos < 1) pos = 1
4539
4540 if (line_len == 0 .or. pos > line_len) then
4541 ! At end of line or empty line - move to next line
4542 if (cursor%line < line_count) then
4543 cursor%line = cursor%line + 1
4544 cursor%column = 1
4545 else
4546 cursor%column = line_len + 1
4547 end if
4548 else if (pos >= 1 .and. pos <= line_len) then
4549 ! Check what we're currently on (with bounds checking)
4550 if (is_word_char(line(pos:pos))) then
4551 ! We're on a word character - skip to end of word
4552 do while (pos < line_len)
4553 if (pos+1 <= line_len) then
4554 if (.not. is_word_char(line(pos+1:pos+1))) exit
4555 end if
4556 pos = pos + 1
4557 end do
4558 pos = pos + 1 ! Move past the word
4559 else
4560 ! We're on whitespace or punctuation - skip to next word
4561 ! Skip non-word characters
4562 do while (pos < line_len)
4563 if (pos+1 <= line_len) then
4564 if (is_word_char(line(pos+1:pos+1))) exit
4565 end if
4566 pos = pos + 1
4567 end do
4568
4569 ! If we found a word, move to its end
4570 if (pos < line_len .and. pos+1 <= line_len) then
4571 pos = pos + 1 ! Move to start of word
4572 do while (pos < line_len)
4573 if (pos+1 <= line_len) then
4574 if (.not. is_word_char(line(pos+1:pos+1))) exit
4575 end if
4576 pos = pos + 1
4577 end do
4578 pos = pos + 1 ! Move past the word
4579 else
4580 pos = line_len + 1 ! At end of line
4581 end if
4582 end if
4583
4584 cursor%column = pos
4585 else if (cursor%line < line_count) then
4586 ! Move to start of next line
4587 cursor%line = cursor%line + 1
4588 cursor%column = 1
4589 else
4590 cursor%column = line_len + 1
4591 end if
4592
4593 cursor%desired_column = cursor%column
4594 if (allocated(line)) deallocate(line)
4595 end subroutine extend_selection_word_right
4596
4597 ! ========================================================================
4598 ! Word Deletion Subroutines
4599 ! ========================================================================
4600
4601 subroutine delete_word_forward(cursor, buffer)
4602 type(cursor_t), intent(inout) :: cursor
4603 type(buffer_t), intent(inout) :: buffer
4604 character(len=:), allocatable :: line
4605 integer :: start_col, end_col, line_len
4606 logical :: in_word
4607
4608 line = buffer_get_line(buffer, cursor%line)
4609 line_len = len(line)
4610 start_col = cursor%column
4611 end_col = start_col
4612
4613 ! If cursor is past end of line, do nothing (can't delete forward from past the line end)
4614 if (start_col > line_len + 1) then
4615 if (allocated(line)) deallocate(line)
4616 return
4617 end if
4618
4619 if (end_col <= line_len) then
4620 ! Skip current word (use nested ifs to avoid bounds issues)
4621 in_word = is_word_char(line(end_col:end_col))
4622 do while (end_col < line_len)
4623 if (is_word_char(line(end_col:end_col)) .eqv. in_word) then
4624 end_col = end_col + 1
4625 else
4626 exit
4627 end if
4628 end do
4629
4630 ! Check if we're still on the same word type at end_col
4631 if (end_col <= line_len) then
4632 if (is_word_char(line(end_col:end_col)) .eqv. in_word) then
4633 end_col = end_col + 1
4634 end if
4635 end if
4636
4637 ! Skip trailing whitespace
4638 do while (end_col <= line_len)
4639 if (line(end_col:end_col) == ' ') then
4640 end_col = end_col + 1
4641 else
4642 exit
4643 end if
4644 end do
4645
4646 ! Delete from cursor to end position
4647 if (end_col > start_col) then
4648 call delete_range(buffer, cursor%line, start_col, cursor%line, end_col - 1)
4649 buffer%modified = .true.
4650 end if
4651 end if
4652
4653 if (allocated(line)) deallocate(line)
4654 end subroutine delete_word_forward
4655
4656 subroutine delete_word_backward(cursor, buffer)
4657 type(cursor_t), intent(inout) :: cursor
4658 type(buffer_t), intent(inout) :: buffer
4659 character(len=:), allocatable :: line
4660 integer :: start_col, end_col, line_len
4661 logical :: in_word
4662
4663 line = buffer_get_line(buffer, cursor%line)
4664 line_len = len(line)
4665 end_col = cursor%column - 1
4666 start_col = end_col
4667
4668 ! Skip whitespace to the left (use nested ifs for safety)
4669 do while (start_col > 0 .and. start_col <= line_len)
4670 if (line(start_col:start_col) == ' ') then
4671 start_col = start_col - 1
4672 else
4673 exit
4674 end if
4675 end do
4676
4677 ! Delete word to the left
4678 if (start_col > 0 .and. start_col <= line_len) then
4679 in_word = is_word_char(line(start_col:start_col))
4680 do while (start_col > 1)
4681 if (is_word_char(line(start_col-1:start_col-1)) .eqv. in_word) then
4682 start_col = start_col - 1
4683 else
4684 exit
4685 end if
4686 end do
4687
4688 ! Delete from start position to cursor
4689 if (start_col <= end_col) then
4690 call delete_range(buffer, cursor%line, start_col, cursor%line, end_col)
4691 cursor%column = start_col
4692 cursor%desired_column = cursor%column
4693 buffer%modified = .true.
4694 end if
4695 end if
4696
4697 if (allocated(line)) deallocate(line)
4698 end subroutine delete_word_backward
4699
4700 ! ========================================================================
4701 ! Character Transpose Subroutine
4702 ! ========================================================================
4703
4704 subroutine join_lines(cursor, buffer)
4705 type(cursor_t), intent(inout) :: cursor
4706 type(buffer_t), intent(inout) :: buffer
4707 character(len=:), allocatable :: current_line, next_line
4708 integer :: line_count, current_len, leading_spaces
4709
4710 line_count = buffer_get_line_count(buffer)
4711
4712 ! Can't join if we're on the last line
4713 if (cursor%line >= line_count) return
4714
4715 ! Get the current line and next line
4716 current_line = buffer_get_line(buffer, cursor%line)
4717 next_line = buffer_get_line(buffer, cursor%line + 1)
4718 current_len = len(current_line)
4719
4720 ! Count leading whitespace in next line
4721 leading_spaces = 0
4722 do while (leading_spaces < len(next_line) .and. &
4723 (next_line(leading_spaces + 1:leading_spaces + 1) == ' ' .or. &
4724 next_line(leading_spaces + 1:leading_spaces + 1) == char(9)))
4725 leading_spaces = leading_spaces + 1
4726 end do
4727
4728 ! Delete the newline and leading whitespace from next line
4729 if (leading_spaces > 0) then
4730 call buffer_delete_range(buffer, cursor%line, current_len + 1, cursor%line + 1, leading_spaces + 1)
4731 else
4732 call buffer_delete_range(buffer, cursor%line, current_len + 1, cursor%line + 1, 1)
4733 end if
4734
4735 ! If the next line had non-whitespace content, insert a space between the lines
4736 if (leading_spaces < len(next_line)) then
4737 ! Insert a space if current line doesn't end with space
4738 if (current_len > 0) then
4739 if (current_line(current_len:current_len) /= ' ') then
4740 call buffer_insert_text_at(buffer, cursor%line, current_len + 1, ' ')
4741 end if
4742 end if
4743 end if
4744
4745 if (allocated(current_line)) deallocate(current_line)
4746 if (allocated(next_line)) deallocate(next_line)
4747 end subroutine join_lines
4748
4749 function get_line_start_pos(buffer, line_num) result(pos)
4750 type(buffer_t), intent(in) :: buffer
4751 integer, intent(in) :: line_num
4752 integer :: pos
4753 integer :: i, current_line
4754
4755 pos = 1
4756 current_line = 1
4757
4758 ! Find the start position of the given line
4759 do i = 1, buffer%size
4760 if (current_line == line_num) then
4761 return
4762 end if
4763
4764 if (buffer_get_char_at(buffer, i) == char(10)) then ! Newline
4765 current_line = current_line + 1
4766 pos = i + 1
4767 end if
4768 end do
4769
4770 ! If line_num is beyond the last line
4771 if (current_line < line_num) then
4772 pos = buffer%size + 1
4773 end if
4774 end function get_line_start_pos
4775
4776 subroutine delete_range(buffer, start_line, start_col, end_line, end_col)
4777 type(buffer_t), intent(inout) :: buffer
4778 integer, intent(in) :: start_line, start_col, end_line, end_col
4779 integer :: pos
4780
4781 ! For now, handle single-line deletions
4782 if (start_line == end_line) then
4783 ! Calculate buffer position
4784 pos = get_line_start_pos(buffer, start_line) + start_col - 1
4785
4786 ! Move gap to deletion point
4787 call buffer_move_gap(buffer, pos)
4788
4789 ! Extend gap to delete characters
4790 buffer%gap_end = buffer%gap_end + (end_col - start_col + 1)
4791 end if
4792 end subroutine delete_range
4793
4794 ! UNUSED: subroutine insert_char_at(buffer, line_num, col, ch)
4795 ! UNUSED: type(buffer_t), intent(inout) :: buffer
4796 ! UNUSED: integer, intent(in) :: line_num, col
4797 ! UNUSED: character, intent(in) :: ch
4798 ! UNUSED: integer :: pos
4799 ! UNUSED:
4800 ! UNUSED: ! Calculate buffer position
4801 ! UNUSED: pos = get_line_start_pos(buffer, line_num) + col - 1
4802 ! UNUSED:
4803 ! UNUSED: ! Move gap to insertion point
4804 ! UNUSED: call buffer_move_gap(buffer, pos)
4805 ! UNUSED:
4806 ! UNUSED: ! Insert character
4807 ! UNUSED: buffer%data(buffer%gap_start:buffer%gap_start) = ch
4808 ! UNUSED: buffer%gap_start = buffer%gap_start + 1
4809 ! UNUSED: end subroutine insert_char_at
4810
4811 ! Handle input when in fuss mode
4812 subroutine handle_fuss_input(key_str, editor, buffer)
4813 character(len=*), intent(in) :: key_str
4814 type(editor_state_t), intent(inout) :: editor
4815 type(buffer_t), intent(inout) :: buffer
4816 character(len=:), allocatable :: selected_path
4817 integer :: i
4818
4819 select case(trim(key_str))
4820 case('j', 'down')
4821 ! Move down in tree
4822 call tree_move_down(tree_state)
4823 ! Update viewport to keep selection visible (estimate ~18 visible lines)
4824 call update_tree_viewport(tree_state, 18)
4825
4826 case('k', 'up')
4827 ! Move up in tree
4828 call tree_move_up(tree_state)
4829 ! Update viewport to keep selection visible (estimate ~18 visible lines)
4830 call update_tree_viewport(tree_state, 18)
4831
4832 case('left')
4833 ! Move up to parent directory
4834 if (tree_state%selected_index >= 1 .and. tree_state%selected_index <= tree_state%n_selectable) then
4835 if (associated(tree_state%selectable_files(tree_state%selected_index)%node)) then
4836 if (associated(tree_state%selectable_files(tree_state%selected_index)%node%parent)) then
4837 ! Find the parent in the selectable list
4838 do i = 1, tree_state%n_selectable
4839 if (associated(tree_state%selectable_files(i)%node, &
4840 tree_state%selectable_files(tree_state%selected_index)%node%parent)) then
4841 tree_state%selected_index = i
4842 exit
4843 end if
4844 end do
4845 end if
4846 end if
4847 end if
4848
4849 case('right')
4850 ! Move into first child of directory (and expand if needed)
4851 if (tree_state%selected_index >= 1 .and. tree_state%selected_index <= tree_state%n_selectable) then
4852 if (tree_state%selectable_files(tree_state%selected_index)%is_directory .and. &
4853 associated(tree_state%selectable_files(tree_state%selected_index)%node)) then
4854 ! Expand if collapsed
4855 if (.not. tree_state%selectable_files(tree_state%selected_index)%node%expanded) then
4856 tree_state%selectable_files(tree_state%selected_index)%node%expanded = .true.
4857 ! Rebuild selectable list
4858 if (allocated(tree_state%selectable_files)) deallocate(tree_state%selectable_files)
4859 call build_selectable_list(tree_state%root, tree_state%selectable_files, tree_state%n_selectable)
4860 end if
4861 ! Find first child in selectable list (look for item whose parent is current node)
4862 do i = tree_state%selected_index + 1, tree_state%n_selectable
4863 if (associated(tree_state%selectable_files(i)%node)) then
4864 if (associated(tree_state%selectable_files(i)%node%parent, &
4865 tree_state%selectable_files(tree_state%selected_index)%node)) then
4866 tree_state%selected_index = i
4867 exit
4868 end if
4869 end if
4870 end do
4871 end if
4872 end if
4873
4874 case(' ', 'space')
4875 ! Toggle directory expand/collapse
4876 if (tree_state%selected_index >= 1 .and. tree_state%selected_index <= tree_state%n_selectable) then
4877 if (.not. tree_state%selectable_files(tree_state%selected_index)%is_directory) then
4878 ! Not a directory - do nothing
4879 else if (associated(tree_state%selectable_files(tree_state%selected_index)%node)) then
4880 ! Toggle expanded
4881 tree_state%selectable_files(tree_state%selected_index)%node%expanded = &
4882 .not. tree_state%selectable_files(tree_state%selected_index)%node%expanded
4883 ! Rebuild selectable list
4884 if (allocated(tree_state%selectable_files)) deallocate(tree_state%selectable_files)
4885 call build_selectable_list(tree_state%root, tree_state%selectable_files, tree_state%n_selectable)
4886 ! Clamp selection
4887 if (tree_state%selected_index > tree_state%n_selectable .and. tree_state%n_selectable > 0) then
4888 tree_state%selected_index = tree_state%n_selectable
4889 end if
4890 end if
4891 end if
4892
4893 case('ctrl-g')
4894 ! Activate git prefix mode (Ctrl+g then a/u/m/p/f/l/t/d)
4895 fuss_git_prefix_active = .true.
4896
4897 case('a')
4898 ! Stage file (only with Ctrl+g prefix)
4899 if (fuss_git_prefix_active) then
4900 fuss_git_prefix_active = .false.
4901 if (allocated(editor%workspace_path)) then
4902 call tree_stage_file(tree_state, editor%workspace_path)
4903 end if
4904 else
4905 call handle_fuss_fuzzy_search(key_str)
4906 end if
4907
4908 case('u')
4909 ! Unstage file (only with Ctrl+g prefix)
4910 if (fuss_git_prefix_active) then
4911 fuss_git_prefix_active = .false.
4912 if (allocated(editor%workspace_path)) then
4913 call tree_unstage_file(tree_state, editor%workspace_path)
4914 end if
4915 else
4916 call handle_fuss_fuzzy_search(key_str)
4917 end if
4918
4919 case('m')
4920 ! Git commit with message (only with Ctrl+g prefix)
4921 if (fuss_git_prefix_active) then
4922 fuss_git_prefix_active = .false.
4923 if (allocated(editor%workspace_path)) then
4924 call handle_git_commit(editor)
4925 end if
4926 else
4927 call handle_fuss_fuzzy_search(key_str)
4928 end if
4929
4930 case('p')
4931 ! Git push (only with Ctrl+g prefix)
4932 if (fuss_git_prefix_active) then
4933 fuss_git_prefix_active = .false.
4934 if (allocated(editor%workspace_path)) then
4935 call handle_git_push(editor)
4936 end if
4937 else
4938 call handle_fuss_fuzzy_search(key_str)
4939 end if
4940
4941 case('f')
4942 ! Git fetch (only with Ctrl+g prefix)
4943 if (fuss_git_prefix_active) then
4944 fuss_git_prefix_active = .false.
4945 if (allocated(editor%workspace_path)) then
4946 call handle_git_fetch(editor)
4947 end if
4948 else
4949 call handle_fuss_fuzzy_search(key_str)
4950 end if
4951
4952 case('l')
4953 ! Git pull (only with Ctrl+g prefix)
4954 if (fuss_git_prefix_active) then
4955 fuss_git_prefix_active = .false.
4956 if (allocated(editor%workspace_path)) then
4957 call handle_git_pull(editor)
4958 end if
4959 else
4960 call handle_fuss_fuzzy_search(key_str)
4961 end if
4962
4963 case('t')
4964 ! Git tag (only with Ctrl+g prefix)
4965 if (fuss_git_prefix_active) then
4966 fuss_git_prefix_active = .false.
4967 if (allocated(editor%workspace_path)) then
4968 call handle_git_tag(editor)
4969 end if
4970 else
4971 call handle_fuss_fuzzy_search(key_str)
4972 end if
4973
4974 case('d')
4975 ! Git diff (only with Ctrl+g prefix)
4976 if (fuss_git_prefix_active) then
4977 fuss_git_prefix_active = .false.
4978 if (allocated(editor%workspace_path)) then
4979 call handle_git_diff(editor, buffer)
4980 end if
4981 else
4982 call handle_fuss_fuzzy_search(key_str)
4983 end if
4984
4985 case('enter', 'o')
4986 ! Open file in editor (only for files, not directories)
4987 if (tree_state%selected_index >= 1 .and. tree_state%selected_index <= tree_state%n_selectable) then
4988 if (.not. tree_state%selectable_files(tree_state%selected_index)%is_directory) then
4989 selected_path = get_selected_item_path(tree_state)
4990 if (len_trim(selected_path) > 0) then
4991 call open_file_in_editor(selected_path, editor, buffer)
4992 end if
4993 end if
4994 end if
4995
4996 case('v')
4997 ! Fuzzy search (vsplit moved to alt-v)
4998 if (fuss_git_prefix_active) then
4999 fuss_git_prefix_active = .false.
5000 end if
5001 call handle_fuss_fuzzy_search(key_str)
5002
5003 case('s')
5004 ! Fuzzy search (hsplit moved to alt-s)
5005 if (fuss_git_prefix_active) then
5006 fuss_git_prefix_active = .false.
5007 end if
5008 call handle_fuss_fuzzy_search(key_str)
5009
5010 case('alt-v')
5011 ! Open file in vertical split (direct shortcut)
5012 if (tree_state%selected_index >= 1 .and. tree_state%selected_index <= tree_state%n_selectable) then
5013 if (.not. tree_state%selectable_files(tree_state%selected_index)%is_directory) then
5014 selected_path = get_selected_item_path(tree_state)
5015 if (len_trim(selected_path) > 0) then
5016 call open_file_in_vertical_split(selected_path, editor, buffer)
5017 end if
5018 end if
5019 end if
5020
5021 case('alt-s')
5022 ! Open file in horizontal split (direct shortcut)
5023 if (tree_state%selected_index >= 1 .and. tree_state%selected_index <= tree_state%n_selectable) then
5024 if (.not. tree_state%selectable_files(tree_state%selected_index)%is_directory) then
5025 selected_path = get_selected_item_path(tree_state)
5026 if (len_trim(selected_path) > 0) then
5027 call open_file_in_horizontal_split(selected_path, editor, buffer)
5028 end if
5029 end if
5030 end if
5031
5032 case('.')
5033 ! Toggle hiding dotfiles/gitignored files
5034 tree_state%hide_dotfiles = .not. tree_state%hide_dotfiles
5035
5036 case('ctrl-/')
5037 ! Toggle fuss mode hints expansion
5038 editor%fuss_hints_expanded = .not. editor%fuss_hints_expanded
5039
5040 case('esc')
5041 ! Exit fuss mode (or cancel git prefix mode)
5042 if (fuss_git_prefix_active) then
5043 fuss_git_prefix_active = .false.
5044 else
5045 editor%fuss_mode_active = .false.
5046 editor%fuss_hints_expanded = .false. ! Reset to collapsed
5047 fuss_git_prefix_active = .false.
5048 call fuss_reset_search()
5049 call cleanup_tree_state(tree_state)
5050 end if
5051
5052 case default
5053 ! Reset git prefix mode if invalid key in prefix mode
5054 if (fuss_git_prefix_active) then
5055 fuss_git_prefix_active = .false.
5056 end if
5057 ! Fuzzy search: accumulate typed characters and jump to match
5058 ! Only handle single printable characters (letters, digits)
5059 if (len_trim(key_str) == 1) then
5060 call handle_fuss_fuzzy_search(key_str)
5061 end if
5062
5063 end select
5064 end subroutine handle_fuss_input
5065
5066 ! Handle fuzzy search character input in fuss mode
5067 subroutine handle_fuss_fuzzy_search(key_str)
5068 use iso_fortran_env, only: int64
5069 character(len=*), intent(in) :: key_str
5070 integer(int64) :: current_time, elapsed
5071 character(len=1) :: ch
5072 logical :: found
5073
5074 ch = key_str(1:1)
5075
5076 ! Only accept printable characters (letters, digits, some punctuation)
5077 if (ichar(ch) < 32 .or. ichar(ch) > 126) return
5078
5079 ! Get current time
5080 current_time = get_time_ms()
5081
5082 ! Check for timeout (500ms) - reset if too long since last keystroke
5083 if (fuss_search_last_time > 0) then
5084 elapsed = current_time - fuss_search_last_time
5085 if (elapsed > 500) then
5086 ! Timeout - reset search buffer
5087 fuss_search_buffer = ''
5088 fuss_search_len = 0
5089 end if
5090 end if
5091
5092 ! Add character to search buffer (if there's room)
5093 if (fuss_search_len < 64) then
5094 fuss_search_len = fuss_search_len + 1
5095 fuss_search_buffer(fuss_search_len:fuss_search_len) = ch
5096 end if
5097
5098 ! Update timestamp
5099 fuss_search_last_time = current_time
5100
5101 ! Try to jump to a match
5102 found = fuss_fuzzy_jump(fuss_search_buffer(1:fuss_search_len))
5103
5104 ! Update viewport to keep selection visible
5105 if (found) then
5106 call update_tree_viewport(tree_state, 18)
5107 end if
5108 end subroutine handle_fuss_fuzzy_search
5109
5110 ! Open a file in the editor
5111 subroutine open_file_in_editor(file_path, editor, buffer)
5112 use editor_state_module, only: create_tab
5113 use text_buffer_module, only: copy_buffer
5114 character(len=*), intent(in) :: file_path
5115 type(editor_state_t), intent(inout) :: editor
5116 type(buffer_t), intent(inout) :: buffer
5117 character(len=:), allocatable :: full_path
5118 integer :: status
5119
5120 ! Build full path
5121 if (allocated(editor%workspace_path)) then
5122 full_path = trim(editor%workspace_path) // '/' // trim(file_path)
5123 else
5124 full_path = trim(file_path)
5125 end if
5126
5127 ! Create a new tab for this file
5128 call create_tab(editor, full_path)
5129
5130 ! Load file into the new tab's buffer
5131 if (editor%active_tab_index > 0 .and. editor%active_tab_index <= size(editor%tabs)) then
5132 call buffer_load_file(editor%tabs(editor%active_tab_index)%buffer, full_path, status)
5133
5134 ! Handle binary files
5135 if (status == -2) then
5136 ! Binary file detected - prompt user
5137 if (binary_file_prompt(full_path)) then
5138 ! User wants to view in hex mode
5139 call buffer_load_file_as_hex(editor%tabs(editor%active_tab_index)%buffer, full_path, status)
5140 if (status /= 0) then
5141 ! Failed to load hex view - close the tab and return
5142 call close_tab(editor, editor%active_tab_index)
5143 return
5144 end if
5145 ! Mark as hex view in filename
5146 full_path = trim(full_path) // ' [HEX]'
5147 else
5148 ! User cancelled - close the tab and return
5149 call close_tab(editor, editor%active_tab_index)
5150 return
5151 end if
5152 end if
5153
5154 if (status == 0) then
5155 ! Copy tab's buffer to pane's buffer
5156 if (allocated(editor%tabs(editor%active_tab_index)%panes) .and. &
5157 editor%tabs(editor%active_tab_index)%active_pane_index > 0) then
5158 call copy_buffer(editor%tabs(editor%active_tab_index)%panes( &
5159 editor%tabs(editor%active_tab_index)%active_pane_index)%buffer, &
5160 editor%tabs(editor%active_tab_index)%buffer)
5161 end if
5162
5163 ! Copy tab's buffer to main buffer so it's displayed
5164 call copy_buffer(buffer, editor%tabs(editor%active_tab_index)%buffer)
5165
5166 ! Update editor state with the new tab's info
5167 if (allocated(editor%filename)) deallocate(editor%filename)
5168 allocate(character(len=len_trim(full_path)) :: editor%filename)
5169 editor%filename = full_path
5170
5171 ! Send LSP didOpen notification to ALL active servers
5172 if (editor%tabs(editor%active_tab_index)%num_lsp_servers > 0) then
5173 block
5174 integer :: srv_i
5175 do srv_i = 1, editor%tabs(editor%active_tab_index)%num_lsp_servers
5176 call notify_file_opened(editor%lsp_manager, &
5177 editor%tabs(editor%active_tab_index)%lsp_server_indices(srv_i), &
5178 full_path, buffer_to_string(editor%tabs(editor%active_tab_index)%buffer))
5179 end do
5180 end block
5181 end if
5182
5183 ! Reset cursor to top of file
5184 editor%cursors(editor%active_cursor)%line = 1
5185 editor%cursors(editor%active_cursor)%column = 1
5186 editor%cursors(editor%active_cursor)%desired_column = 1
5187 editor%viewport_line = 1
5188 editor%viewport_column = 1
5189
5190 ! Also update tab's active pane state
5191 if (allocated(editor%tabs(editor%active_tab_index)%panes) .and. &
5192 editor%tabs(editor%active_tab_index)%active_pane_index > 0) then
5193 associate (pane => &
5194 editor%tabs(editor%active_tab_index)%panes( &
5195 editor%tabs(editor%active_tab_index)%active_pane_index))
5196 if (allocated(pane%cursors) .and. size(pane%cursors) > 0) then
5197 pane%cursors(1)%line = 1
5198 pane%cursors(1)%column = 1
5199 pane%cursors(1)%desired_column = 1
5200 end if
5201 pane%viewport_line = 1
5202 pane%viewport_column = 1
5203 end associate
5204 end if
5205 end if
5206 end if
5207 ! Note: fuss mode stays active - user must press ctrl-b to exit
5208 end subroutine open_file_in_editor
5209
5210 ! Open a file in a vertical split
5211 subroutine open_file_in_vertical_split(file_path, editor, buffer)
5212 use editor_state_module, only: split_pane_vertical, sync_editor_to_pane
5213 use text_buffer_module, only: copy_buffer
5214 character(len=*), intent(in) :: file_path
5215 type(editor_state_t), intent(inout) :: editor
5216 type(buffer_t), intent(inout) :: buffer
5217 character(len=:), allocatable :: full_path
5218 integer :: status, tab_idx, pane_idx
5219
5220 ! Build full path
5221 if (allocated(editor%workspace_path)) then
5222 full_path = trim(editor%workspace_path) // '/' // trim(file_path)
5223 else
5224 full_path = trim(file_path)
5225 end if
5226
5227 ! Exit fuss mode
5228 editor%fuss_mode_active = .false.
5229 editor%fuss_hints_expanded = .false.
5230 call cleanup_tree_state(tree_state)
5231
5232 ! If no tabs exist, create one first
5233 if (size(editor%tabs) == 0 .or. editor%active_tab_index == 0) then
5234 call open_file_in_editor(file_path, editor, buffer)
5235 return
5236 end if
5237
5238 ! Split the current pane vertically
5239 call split_pane_vertical(editor)
5240
5241 ! Get the new pane index (it becomes the active pane)
5242 tab_idx = editor%active_tab_index
5243 if (tab_idx > 0 .and. tab_idx <= size(editor%tabs)) then
5244 pane_idx = editor%tabs(tab_idx)%active_pane_index
5245
5246 ! Load the file into the new pane's buffer
5247 if (allocated(editor%tabs(tab_idx)%panes) .and. pane_idx > 0) then
5248 call buffer_load_file(editor%tabs(tab_idx)%panes(pane_idx)%buffer, full_path, status)
5249
5250 ! Handle binary files
5251 if (status == -2) then
5252 ! Binary file detected - prompt user
5253 if (binary_file_prompt(full_path)) then
5254 ! User wants to view in hex mode
5255 call buffer_load_file_as_hex(editor%tabs(tab_idx)%panes(pane_idx)%buffer, full_path, status)
5256 if (status /= 0) then
5257 ! Failed to load hex view - close the pane and return
5258 call close_pane(editor)
5259 return
5260 end if
5261 ! Mark as hex view in filename
5262 full_path = trim(full_path) // ' [HEX]'
5263 else
5264 ! User cancelled - close the pane and return
5265 call close_pane(editor)
5266 return
5267 end if
5268 end if
5269
5270 if (status == 0) then
5271 ! Update filename for the pane
5272 if (allocated(editor%tabs(tab_idx)%panes(pane_idx)%filename)) &
5273 deallocate(editor%tabs(tab_idx)%panes(pane_idx)%filename)
5274 allocate(character(len=len_trim(full_path)) :: editor%tabs(tab_idx)%panes(pane_idx)%filename)
5275 editor%tabs(tab_idx)%panes(pane_idx)%filename = full_path
5276
5277 ! Copy to main buffer
5278 call copy_buffer(buffer, editor%tabs(tab_idx)%panes(pane_idx)%buffer)
5279
5280 ! Update editor filename
5281 if (allocated(editor%filename)) deallocate(editor%filename)
5282 allocate(character(len=len_trim(full_path)) :: editor%filename)
5283 editor%filename = full_path
5284
5285 ! Reset cursor in the new pane
5286 if (allocated(editor%tabs(tab_idx)%panes(pane_idx)%cursors) .and. &
5287 size(editor%tabs(tab_idx)%panes(pane_idx)%cursors) > 0) then
5288 editor%tabs(tab_idx)%panes(pane_idx)%cursors(1)%line = 1
5289 editor%tabs(tab_idx)%panes(pane_idx)%cursors(1)%column = 1
5290 editor%tabs(tab_idx)%panes(pane_idx)%cursors(1)%desired_column = 1
5291 end if
5292 editor%tabs(tab_idx)%panes(pane_idx)%viewport_line = 1
5293 editor%tabs(tab_idx)%panes(pane_idx)%viewport_column = 1
5294
5295 ! Sync editor state with the new pane
5296 call sync_editor_to_pane(editor)
5297 end if
5298 end if
5299 end if
5300 end subroutine open_file_in_vertical_split
5301
5302 ! Open a file in a horizontal split
5303 subroutine open_file_in_horizontal_split(file_path, editor, buffer)
5304 use editor_state_module, only: split_pane_horizontal, sync_editor_to_pane
5305 use text_buffer_module, only: copy_buffer
5306 character(len=*), intent(in) :: file_path
5307 type(editor_state_t), intent(inout) :: editor
5308 type(buffer_t), intent(inout) :: buffer
5309 character(len=:), allocatable :: full_path
5310 integer :: status, tab_idx, pane_idx
5311
5312 ! Build full path
5313 if (allocated(editor%workspace_path)) then
5314 full_path = trim(editor%workspace_path) // '/' // trim(file_path)
5315 else
5316 full_path = trim(file_path)
5317 end if
5318
5319 ! Exit fuss mode
5320 editor%fuss_mode_active = .false.
5321 editor%fuss_hints_expanded = .false.
5322 call cleanup_tree_state(tree_state)
5323
5324 ! If no tabs exist, create one first
5325 if (size(editor%tabs) == 0 .or. editor%active_tab_index == 0) then
5326 call open_file_in_editor(file_path, editor, buffer)
5327 return
5328 end if
5329
5330 ! Split the current pane horizontally
5331 call split_pane_horizontal(editor)
5332
5333 ! Get the new pane index (it becomes the active pane)
5334 tab_idx = editor%active_tab_index
5335 if (tab_idx > 0 .and. tab_idx <= size(editor%tabs)) then
5336 pane_idx = editor%tabs(tab_idx)%active_pane_index
5337
5338 ! Load the file into the new pane's buffer
5339 if (allocated(editor%tabs(tab_idx)%panes) .and. pane_idx > 0) then
5340 call buffer_load_file(editor%tabs(tab_idx)%panes(pane_idx)%buffer, full_path, status)
5341
5342 ! Handle binary files
5343 if (status == -2) then
5344 ! Binary file detected - prompt user
5345 if (binary_file_prompt(full_path)) then
5346 ! User wants to view in hex mode
5347 call buffer_load_file_as_hex(editor%tabs(tab_idx)%panes(pane_idx)%buffer, full_path, status)
5348 if (status /= 0) then
5349 ! Failed to load hex view - close the pane and return
5350 call close_pane(editor)
5351 return
5352 end if
5353 ! Mark as hex view in filename
5354 full_path = trim(full_path) // ' [HEX]'
5355 else
5356 ! User cancelled - close the pane and return
5357 call close_pane(editor)
5358 return
5359 end if
5360 end if
5361
5362 if (status == 0) then
5363 ! Update filename for the pane
5364 if (allocated(editor%tabs(tab_idx)%panes(pane_idx)%filename)) &
5365 deallocate(editor%tabs(tab_idx)%panes(pane_idx)%filename)
5366 allocate(character(len=len_trim(full_path)) :: editor%tabs(tab_idx)%panes(pane_idx)%filename)
5367 editor%tabs(tab_idx)%panes(pane_idx)%filename = full_path
5368
5369 ! Copy to main buffer
5370 call copy_buffer(buffer, editor%tabs(tab_idx)%panes(pane_idx)%buffer)
5371
5372 ! Update editor filename
5373 if (allocated(editor%filename)) deallocate(editor%filename)
5374 allocate(character(len=len_trim(full_path)) :: editor%filename)
5375 editor%filename = full_path
5376
5377 ! Reset cursor in the new pane
5378 if (allocated(editor%tabs(tab_idx)%panes(pane_idx)%cursors) .and. &
5379 size(editor%tabs(tab_idx)%panes(pane_idx)%cursors) > 0) then
5380 editor%tabs(tab_idx)%panes(pane_idx)%cursors(1)%line = 1
5381 editor%tabs(tab_idx)%panes(pane_idx)%cursors(1)%column = 1
5382 editor%tabs(tab_idx)%panes(pane_idx)%cursors(1)%desired_column = 1
5383 end if
5384 editor%tabs(tab_idx)%panes(pane_idx)%viewport_line = 1
5385 editor%tabs(tab_idx)%panes(pane_idx)%viewport_column = 1
5386
5387 ! Sync editor state with the new pane
5388 call sync_editor_to_pane(editor)
5389 end if
5390 end if
5391 end if
5392 end subroutine open_file_in_horizontal_split
5393
5394 ! Toggle fuss mode (file tree)
5395 subroutine toggle_fuss_mode(editor)
5396 type(editor_state_t), intent(inout) :: editor
5397
5398 editor%fuss_mode_active = .not. editor%fuss_mode_active
5399
5400 if (editor%fuss_mode_active) then
5401 ! Entering fuss mode - initialize tree state
5402 if (allocated(editor%workspace_path)) then
5403 call init_tree_state(tree_state, editor%workspace_path)
5404 end if
5405 else
5406 ! Exiting fuss mode - cleanup tree state
5407 call cleanup_tree_state(tree_state)
5408 end if
5409 end subroutine toggle_fuss_mode
5410
5411 ! UNUSED: Toggle diagnostics panel
5412 ! Kept for potential future use
5413 ! subroutine toggle_diagnostics_panel(editor)
5414 ! type(editor_state_t), intent(inout) :: editor
5415 ! call toggle_panel(editor%diagnostics_panel)
5416 ! end subroutine toggle_diagnostics_panel
5417
5418 ! Handle git commit with message prompt
5419 subroutine handle_git_commit(editor)
5420 type(editor_state_t), intent(inout) :: editor
5421 character(len=512) :: commit_message
5422 logical :: cancelled, success
5423
5424 ! Show prompt for commit message
5425 call show_text_prompt('Commit message (ESC to cancel): ', commit_message, cancelled, editor%screen_rows)
5426
5427 if (.not. cancelled .and. len_trim(commit_message) > 0) then
5428 call git_commit(editor%workspace_path, commit_message, success)
5429
5430 ! Show feedback message
5431 call terminal_move_cursor(editor%screen_rows, 1)
5432 call terminal_write(repeat(' ', 200))
5433 call terminal_move_cursor(editor%screen_rows, 1)
5434 if (success) then
5435 call terminal_write(char(27) // '[32m✓ Committed successfully!' // char(27) // '[0m')
5436 else
5437 call terminal_write(char(27) // '[31m✗ Commit failed (nothing staged?)' // char(27) // '[0m')
5438 end if
5439
5440 ! Brief pause
5441 call execute_command_line('sleep 1')
5442
5443 ! Refresh tree
5444 call refresh_tree_state(tree_state, editor%workspace_path)
5445 end if
5446 end subroutine handle_git_commit
5447
5448 ! Handle git push
5449 subroutine handle_git_push(editor)
5450 type(editor_state_t), intent(inout) :: editor
5451 logical :: success
5452
5453 ! Show progress message
5454 call terminal_move_cursor(editor%screen_rows, 1)
5455 call terminal_write(repeat(' ', 200))
5456 call terminal_move_cursor(editor%screen_rows, 1)
5457 call terminal_write('Pushing to remote...')
5458
5459 call git_push(editor%workspace_path, success)
5460
5461 ! Show result
5462 call terminal_move_cursor(editor%screen_rows, 1)
5463 call terminal_write(repeat(' ', 200))
5464 call terminal_move_cursor(editor%screen_rows, 1)
5465 if (success) then
5466 call terminal_write(char(27) // '[32m✓ Pushed successfully!' // char(27) // '[0m')
5467 else
5468 call terminal_write(char(27) // '[31m✗ Push failed (check remote/branch)' // char(27) // '[0m')
5469 end if
5470
5471 ! Brief pause
5472 call execute_command_line('sleep 1')
5473
5474 ! Refresh tree
5475 call refresh_tree_state(tree_state, editor%workspace_path)
5476 end subroutine handle_git_push
5477
5478 ! Handle git fetch
5479 subroutine handle_git_fetch(editor)
5480 type(editor_state_t), intent(inout) :: editor
5481 logical :: success
5482
5483 ! Show progress message
5484 call terminal_move_cursor(editor%screen_rows, 1)
5485 call terminal_write(repeat(' ', 200))
5486 call terminal_move_cursor(editor%screen_rows, 1)
5487 call terminal_write('Fetching from remote...')
5488
5489 call git_fetch(editor%workspace_path, success)
5490
5491 ! Show result
5492 call terminal_move_cursor(editor%screen_rows, 1)
5493 call terminal_write(repeat(' ', 200))
5494 call terminal_move_cursor(editor%screen_rows, 1)
5495 if (success) then
5496 call terminal_write(char(27) // '[32m✓ Fetch completed!' // char(27) // '[0m')
5497 else
5498 call terminal_write(char(27) // '[31m✗ Fetch failed!' // char(27) // '[0m')
5499 end if
5500
5501 ! Brief pause
5502 call execute_command_line('sleep 1')
5503
5504 ! Refresh tree
5505 call refresh_tree_state(tree_state, editor%workspace_path)
5506 end subroutine handle_git_fetch
5507
5508 ! Handle git pull
5509 subroutine handle_git_pull(editor)
5510 type(editor_state_t), intent(inout) :: editor
5511 logical :: success
5512
5513 ! Show progress message
5514 call terminal_move_cursor(editor%screen_rows, 1)
5515 call terminal_write(repeat(' ', 200))
5516 call terminal_move_cursor(editor%screen_rows, 1)
5517 call terminal_write('Pulling from remote...')
5518
5519 call git_pull(editor%workspace_path, success)
5520
5521 ! Show result
5522 call terminal_move_cursor(editor%screen_rows, 1)
5523 call terminal_write(repeat(' ', 200))
5524 call terminal_move_cursor(editor%screen_rows, 1)
5525 if (success) then
5526 call terminal_write(char(27) // '[32m✓ Pull completed!' // char(27) // '[0m')
5527 else
5528 call terminal_write(char(27) // '[31m✗ Pull failed!' // char(27) // '[0m')
5529 end if
5530
5531 ! Brief pause
5532 call execute_command_line('sleep 1')
5533
5534 ! Refresh tree
5535 call refresh_tree_state(tree_state, editor%workspace_path)
5536 end subroutine handle_git_pull
5537
5538 ! Handle git tag
5539 subroutine handle_git_tag(editor)
5540 use help_display_module, only: display_tags_header
5541 type(editor_state_t), intent(inout) :: editor
5542 character(len=256) :: tag_name, tag_message
5543 character(len=256), allocatable :: existing_tags(:)
5544 integer :: n_tags
5545 logical :: cancelled, success, push_tag
5546
5547 ! Fetch and display existing tags (keeps them visible during prompts)
5548 call git_list_tags(editor%workspace_path, existing_tags, n_tags)
5549 call display_tags_header(editor, existing_tags, n_tags)
5550
5551 ! Show prompt for tag name (tags remain visible above)
5552 call show_text_prompt('Tag name (ESC to cancel): ', tag_name, cancelled, editor%screen_rows)
5553
5554 if (allocated(existing_tags)) deallocate(existing_tags)
5555
5556 if (.not. cancelled .and. len_trim(tag_name) > 0) then
5557 ! Show prompt for tag message (optional)
5558 call show_text_prompt('Tag message (optional, ESC to skip): ', tag_message, cancelled, editor%screen_rows)
5559
5560 if (.not. cancelled) then
5561 call git_tag(editor%workspace_path, tag_name, tag_message, success)
5562
5563 ! Show result
5564 call terminal_move_cursor(editor%screen_rows, 1)
5565 call terminal_write(repeat(' ', 200))
5566 call terminal_move_cursor(editor%screen_rows, 1)
5567 if (success) then
5568 call terminal_write(char(27) // '[32m✓ Tag created: ' // trim(tag_name) // char(27) // '[0m')
5569
5570 ! Brief pause
5571 call execute_command_line('sleep 1')
5572
5573 ! Ask if user wants to push the tag to origin (auto-submit on y/n)
5574 call show_yes_no_prompt('Push tag to origin? (y/n, ESC to skip): ', push_tag, cancelled, editor%screen_rows)
5575
5576 if (.not. cancelled .and. push_tag) then
5577 call git_push_tag(editor%workspace_path, tag_name, success)
5578
5579 ! Show push result
5580 call terminal_move_cursor(editor%screen_rows, 1)
5581 call terminal_write(repeat(' ', 200))
5582 call terminal_move_cursor(editor%screen_rows, 1)
5583 if (success) then
5584 call terminal_write(char(27) // '[32m✓ Tag pushed to origin' // char(27) // '[0m')
5585 else
5586 call terminal_write(char(27) // '[31m✗ Failed to push tag (check remote)' // char(27) // '[0m')
5587 end if
5588
5589 call execute_command_line('sleep 1')
5590 end if
5591 else
5592 call terminal_write(char(27) // '[31m✗ Failed to create tag' // char(27) // '[0m')
5593 call execute_command_line('sleep 1')
5594 end if
5595
5596 ! Refresh tree
5597 call refresh_tree_state(tree_state, editor%workspace_path)
5598 end if
5599 end if
5600 end subroutine handle_git_tag
5601
5602 subroutine handle_git_diff(editor, buffer)
5603 use editor_state_module, only: create_tab
5604 use text_buffer_module, only: buffer_insert, copy_buffer
5605 use file_tree_module, only: get_selected_item_path
5606 type(editor_state_t), intent(inout) :: editor
5607 type(buffer_t), intent(inout) :: buffer
5608 character(len=:), allocatable :: selected_path, diff_content, tab_name
5609 character(len=256) :: branch_name
5610 logical :: success
5611
5612 ! Get selected file from tree
5613 if (tree_state%selected_index < 1 .or. tree_state%selected_index > tree_state%n_selectable) return
5614 if (tree_state%selectable_files(tree_state%selected_index)%is_directory) return
5615
5616 selected_path = get_selected_item_path(tree_state)
5617 if (len_trim(selected_path) == 0) return
5618
5619 ! Get diff content
5620 call git_diff_file(editor%workspace_path, selected_path, diff_content, branch_name, success)
5621
5622 if (.not. success) then
5623 call terminal_move_cursor(editor%screen_rows, 1)
5624 call terminal_write(repeat(' ', 200))
5625 call terminal_move_cursor(editor%screen_rows, 1)
5626 call terminal_write(char(27) // '[31m✗ Failed to get diff' // char(27) // '[0m')
5627 call execute_command_line('sleep 1')
5628 return
5629 end if
5630
5631 ! Create tab name: diff:<filename>:<branch>
5632 if (len_trim(branch_name) > 0) then
5633 tab_name = 'diff:' // trim(selected_path) // ':' // trim(branch_name)
5634 else
5635 tab_name = 'diff:' // trim(selected_path)
5636 end if
5637
5638 ! Create new tab
5639 call create_tab(editor, tab_name)
5640
5641 ! Load diff content into the tab's buffer
5642 if (editor%active_tab_index > 0 .and. editor%active_tab_index <= size(editor%tabs)) then
5643 ! Insert diff content at the beginning of the buffer
5644 call buffer_insert(editor%tabs(editor%active_tab_index)%buffer, 1, diff_content)
5645
5646 ! Copy tab's buffer to main buffer so it's displayed
5647 call copy_buffer(buffer, editor%tabs(editor%active_tab_index)%buffer)
5648
5649 ! Update editor state with the new tab's info
5650 if (allocated(editor%filename)) deallocate(editor%filename)
5651 allocate(character(len=len_trim(tab_name)) :: editor%filename)
5652 editor%filename = tab_name
5653
5654 ! Reset cursor to top of file
5655 editor%cursors(editor%active_cursor)%line = 1
5656 editor%cursors(editor%active_cursor)%column = 1
5657 editor%cursors(editor%active_cursor)%desired_column = 1
5658
5659 ! Exit fuss mode and show diff
5660 editor%fuss_mode_active = .false.
5661 end if
5662 end subroutine handle_git_diff
5663
5664 subroutine handle_fortress_navigator(editor, buffer)
5665 use workspace_module, only: workspace_is_file_in_workspace, workspace_switch
5666 use save_prompt_module, only: save_prompt, save_prompt_result_t
5667 use input_handler_module, only: get_key_input
5668 type(editor_state_t), intent(inout) :: editor
5669 type(buffer_t), intent(inout) :: buffer
5670 character(len=:), allocatable :: selected_path
5671 character(len=32) :: key_input
5672 logical :: is_directory, cancelled, is_in_workspace, switch_success
5673 logical :: should_switch
5674 integer :: load_status, tab_idx, status
5675
5676 ! Call fortress navigator (start in workspace if available)
5677 if (allocated(editor%workspace_path)) then
5678 call open_fortress_navigator(selected_path, is_directory, cancelled, editor%workspace_path)
5679 else
5680 call open_fortress_navigator(selected_path, is_directory, cancelled)
5681 end if
5682
5683 ! If user selected something, open it
5684 if (.not. cancelled .and. allocated(selected_path)) then
5685 if (len_trim(selected_path) > 0) then
5686 if (.not. is_directory) then
5687 ! Selected a file - create a new tab for it
5688 ! Check if file is within workspace
5689 if (allocated(editor%workspace_path)) then
5690 is_in_workspace = workspace_is_file_in_workspace(selected_path, editor%workspace_path)
5691 else
5692 is_in_workspace = .false.
5693 end if
5694
5695 ! Create new tab
5696 call create_tab(editor, trim(selected_path))
5697 tab_idx = editor%active_tab_index
5698
5699 ! Mark as orphan if outside workspace
5700 if (allocated(editor%tabs) .and. tab_idx > 0 .and. tab_idx <= size(editor%tabs)) then
5701 editor%tabs(tab_idx)%is_orphan = .not. is_in_workspace
5702
5703 ! Load file into tab's buffer
5704 call buffer_load_file(editor%tabs(tab_idx)%buffer, selected_path, load_status)
5705
5706 ! Also load into first pane's buffer
5707 if (allocated(editor%tabs(tab_idx)%panes) .and. &
5708 size(editor%tabs(tab_idx)%panes) > 0) then
5709 call buffer_load_file(editor%tabs(tab_idx)%panes(1)%buffer, selected_path, load_status)
5710 ! Copy to main buffer for rendering
5711 call copy_buffer(buffer, editor%tabs(tab_idx)%panes(1)%buffer)
5712 else
5713 ! Copy tab buffer to main buffer
5714 call copy_buffer(buffer, editor%tabs(tab_idx)%buffer)
5715 end if
5716
5717 ! Update editor filename
5718 if (allocated(editor%filename)) deallocate(editor%filename)
5719 allocate(character(len=len_trim(selected_path)) :: editor%filename)
5720 editor%filename = selected_path
5721
5722 ! Reset cursor to top
5723 editor%cursors(editor%active_cursor)%line = 1
5724 editor%cursors(editor%active_cursor)%column = 1
5725 editor%cursors(editor%active_cursor)%desired_column = 1
5726 end if
5727 else
5728 ! Selected a directory - switch workspace (Phase 6)
5729 should_switch = .true.
5730
5731 ! Check for dirty buffers and prompt to save
5732 if (allocated(editor%tabs)) then
5733 call handle_dirty_buffers_before_switch(editor, should_switch)
5734 end if
5735
5736 ! If user didn't cancel, perform the switch
5737 if (should_switch) then
5738 call workspace_switch(editor, selected_path, switch_success)
5739
5740 if (.not. switch_success) then
5741 ! Show error message
5742 call terminal_move_cursor(1, 1)
5743 call terminal_write("Error: Could not switch to workspace: " // trim(selected_path))
5744 call terminal_write("Press any key to continue...")
5745 ! Wait for keypress (simple implementation)
5746 call get_key_input(key_input, status)
5747 else
5748 ! Phase 7: Update file tree if it's active after successful workspace switch
5749 if (editor%fuss_mode_active .and. allocated(editor%workspace_path)) then
5750 call refresh_tree_state(tree_state, editor%workspace_path)
5751 end if
5752 end if
5753 end if
5754 end if
5755 end if
5756 end if
5757
5758 ! Re-render after returning from fortress
5759 call terminal_clear_screen()
5760 end subroutine handle_fortress_navigator
5761
5762 !> Handle dirty buffers before workspace switch
5763 subroutine handle_dirty_buffers_before_switch(editor, should_continue)
5764 use save_prompt_module, only: save_prompt, save_prompt_result_t
5765 use text_buffer_module, only: buffer_save_file
5766 type(editor_state_t), intent(inout) :: editor
5767 logical, intent(inout) :: should_continue
5768 type(save_prompt_result_t) :: prompt_result
5769 integer :: i, save_status
5770
5771 should_continue = .true.
5772
5773 ! Check each tab for modified buffers
5774 do i = 1, size(editor%tabs)
5775 if (editor%tabs(i)%modified .and. allocated(editor%tabs(i)%filename)) then
5776 ! Prompt user for this file
5777 call save_prompt(editor%tabs(i)%filename, prompt_result)
5778
5779 select case (prompt_result%action)
5780 case ('y')
5781 ! Save the file
5782 if (allocated(editor%tabs(i)%panes) .and. size(editor%tabs(i)%panes) > 0) then
5783 call buffer_save_file(editor%tabs(i)%panes(1)%buffer, &
5784 editor%tabs(i)%filename, save_status)
5785 else
5786 call buffer_save_file(editor%tabs(i)%buffer, &
5787 editor%tabs(i)%filename, save_status)
5788 end if
5789
5790 if (save_status == 0) then
5791 editor%tabs(i)%modified = .false.
5792 end if
5793
5794 case ('n')
5795 ! Skip saving - continue
5796
5797 case ('c')
5798 ! Cancel the workspace switch
5799 should_continue = .false.
5800 return
5801 end select
5802 end if
5803 end do
5804 end subroutine handle_dirty_buffers_before_switch
5805
5806 !> Prompt to save before closing tab
5807 subroutine prompt_save_before_close_tab(editor, buffer)
5808 use save_prompt_module, only: save_prompt, save_prompt_result_t
5809 use text_prompt_module, only: show_text_prompt
5810 type(editor_state_t), intent(inout) :: editor
5811 type(buffer_t), intent(inout) :: buffer
5812 type(save_prompt_result_t) :: prompt_result
5813 integer :: save_status, tab_idx
5814 character(len=512) :: new_filename
5815 logical :: cancelled
5816
5817 tab_idx = editor%active_tab_index
5818
5819 ! Prompt user to save
5820 call save_prompt(editor%tabs(tab_idx)%filename, prompt_result)
5821
5822 if (prompt_result%action == 's') then
5823 ! User wants to save
5824 ! Check if untitled - need filename
5825 if (index(editor%tabs(tab_idx)%filename, '[Untitled') == 1) then
5826 call show_text_prompt('Save as: ', new_filename, cancelled, editor%screen_rows)
5827 if (.not. cancelled .and. len_trim(new_filename) > 0) then
5828 ! Update filename and save
5829 if (allocated(editor%tabs(tab_idx)%filename)) deallocate(editor%tabs(tab_idx)%filename)
5830 allocate(character(len=len_trim(new_filename)) :: editor%tabs(tab_idx)%filename)
5831 editor%tabs(tab_idx)%filename = trim(new_filename)
5832
5833 call buffer_save_file(buffer, new_filename, save_status)
5834 if (save_status == 0) then
5835 buffer%modified = .false.
5836 editor%tabs(tab_idx)%modified = .false.
5837 end if
5838 else
5839 ! User cancelled filename prompt - don't close tab
5840 return
5841 end if
5842 else
5843 ! Not untitled - just save
5844 call buffer_save_file(buffer, editor%tabs(tab_idx)%filename, save_status)
5845 if (save_status == 0) then
5846 buffer%modified = .false.
5847 editor%tabs(tab_idx)%modified = .false.
5848 end if
5849 end if
5850
5851 ! After saving, close the tab
5852 call close_tab_without_prompt(editor, buffer)
5853
5854 else if (prompt_result%action == 'd') then
5855 ! User wants to discard - just close
5856 call close_tab_without_prompt(editor, buffer)
5857
5858 ! else if 'c' (cancel) - do nothing, don't close tab
5859 end if
5860 end subroutine prompt_save_before_close_tab
5861
5862 !> Close tab without prompting
5863 subroutine close_tab_without_prompt(editor, buffer)
5864 type(editor_state_t), intent(inout) :: editor
5865 type(buffer_t), intent(inout) :: buffer
5866 integer :: tab_idx
5867
5868 tab_idx = editor%active_tab_index
5869
5870 ! If this is the last tab, close it and clear editor
5871 if (size(editor%tabs) == 1) then
5872 call close_tab(editor, tab_idx)
5873
5874 ! Clear the buffer and open fuss mode
5875 call cleanup_buffer(buffer)
5876 call init_buffer(buffer)
5877 editor%fuss_mode_active = .true.
5878 if (allocated(editor%filename)) deallocate(editor%filename)
5879 editor%modified = .false.
5880 if (allocated(editor%workspace_path)) then
5881 call init_tree_state(tree_state, editor%workspace_path)
5882 end if
5883 else
5884 ! Multiple tabs - close current tab normally
5885 call close_tab(editor, tab_idx)
5886
5887 ! Copy new active tab's buffer
5888 if (size(editor%tabs) > 0 .and. editor%active_tab_index > 0) then
5889 call copy_buffer(buffer, editor%tabs(editor%active_tab_index)%buffer)
5890 editor%modified = editor%tabs(editor%active_tab_index)%modified
5891 if (allocated(editor%filename)) deallocate(editor%filename)
5892 allocate(character(len=len(editor%tabs(editor%active_tab_index)%filename)) :: editor%filename)
5893 editor%filename = editor%tabs(editor%active_tab_index)%filename
5894 end if
5895 end if
5896 end subroutine close_tab_without_prompt
5897
5898 ! Notify LSP server of buffer changes
5899 subroutine notify_buffer_change(editor, buffer)
5900 use document_sync_module, only: notify_document_change
5901 type(editor_state_t), intent(inout) :: editor
5902 type(buffer_t), intent(in) :: buffer
5903 character(len=:), allocatable :: full_content
5904 integer :: i, line_count
5905
5906 ! Only notify if we have an active tab with LSP support
5907 if (editor%active_tab_index < 1 .or. editor%active_tab_index > size(editor%tabs)) return
5908 if (editor%tabs(editor%active_tab_index)%num_lsp_servers < 1) return
5909
5910 ! Build full document content
5911 line_count = buffer_get_line_count(buffer)
5912 full_content = ''
5913 do i = 1, line_count
5914 if (i > 1) then
5915 full_content = full_content // char(10) ! LF
5916 end if
5917 full_content = full_content // buffer_get_line(buffer, i)
5918 end do
5919
5920 ! Notify document sync of the change
5921 call notify_document_change(editor%tabs(editor%active_tab_index)%document_sync, &
5922 full_content)
5923 end subroutine notify_buffer_change
5924
5925 ! TODO: Handle LSP textDocument/definition response
5926 ! This needs to be integrated with the main event loop callback system
5927 ! The response parsing logic is ready but needs proper callback integration
5928
5929 ! Wrapper callback that matches the LSP callback signature
5930 subroutine handle_references_response_wrapper(unused_request_id, response)
5931 use lsp_protocol_module, only: lsp_message_t
5932 integer, intent(in) :: unused_request_id
5933 type(lsp_message_t), intent(in) :: response
5934
5935 if (.false.) print *, unused_request_id ! Silence unused warning
5936
5937 ! Call the actual handler with saved editor state
5938 if (associated(saved_editor_for_callback)) then
5939 call handle_references_response_impl(saved_editor_for_callback, response)
5940 end if
5941 end subroutine handle_references_response_wrapper
5942
5943 ! Handle LSP textDocument/references response implementation
5944 subroutine handle_references_response_impl(editor, response)
5945 use lsp_protocol_module, only: lsp_message_t
5946 use json_module, only: json_value_t, json_get_array, json_get_object, &
5947 json_get_string, json_get_number, json_array_size, &
5948 json_get_array_element, json_has_key
5949 type(editor_state_t), intent(inout) :: editor
5950 type(lsp_message_t), intent(in) :: response
5951 type(json_value_t) :: result_array, location_obj, range_obj
5952 type(json_value_t) :: start_obj, end_obj
5953 type(reference_location_t), allocatable :: references(:)
5954 integer :: num_refs, i
5955 character(len=:), allocatable :: uri
5956 real(8) :: line_real, col_real
5957
5958 ! The result is directly in response%result for LSP responses
5959 result_array = response%result
5960 num_refs = json_array_size(result_array)
5961
5962 if (num_refs == 0) then
5963 ! No references found
5964 allocate(references(0))
5965 call set_references(editor%references_panel, references, 0)
5966 return
5967 end if
5968
5969 ! Allocate references array
5970 allocate(references(num_refs))
5971
5972 ! Initialize all fields
5973 do i = 1, num_refs
5974 references(i)%line = 1
5975 references(i)%column = 1
5976 references(i)%end_line = 1
5977 references(i)%end_column = 1
5978 end do
5979
5980 ! Parse each reference location
5981 do i = 1, num_refs
5982 location_obj = json_get_array_element(result_array, i - 1)
5983
5984 ! Get URI
5985 uri = json_get_string(location_obj, 'uri', '')
5986 if (len(uri) > 0) then
5987 allocate(character(len=len(uri)) :: references(i)%uri)
5988 references(i)%uri = uri
5989
5990 ! Extract filename from URI
5991 if (len(uri) > 7) then
5992 if (uri(1:7) == "file://") then
5993 allocate(character(len=len(uri)-7) :: references(i)%filename)
5994 references(i)%filename = uri(8:)
5995 end if
5996 end if
5997 end if
5998
5999 ! Get range
6000 if (json_has_key(location_obj, 'range')) then
6001 range_obj = json_get_object(location_obj, 'range')
6002
6003 ! Get start position
6004 if (json_has_key(range_obj, 'start')) then
6005 start_obj = json_get_object(range_obj, 'start')
6006 line_real = json_get_number(start_obj, 'line', 0.0d0)
6007 references(i)%line = int(line_real) + 1 ! Convert from 0-based to 1-based
6008 col_real = json_get_number(start_obj, 'character', 0.0d0)
6009 references(i)%column = int(col_real) + 1 ! Convert from 0-based to 1-based
6010 end if
6011
6012 ! Get end position
6013 if (json_has_key(range_obj, 'end')) then
6014 end_obj = json_get_object(range_obj, 'end')
6015 line_real = json_get_number(end_obj, 'line', 0.0d0)
6016 references(i)%end_line = int(line_real) + 1
6017 col_real = json_get_number(end_obj, 'character', 0.0d0)
6018 references(i)%end_column = int(col_real) + 1
6019 end if
6020 end if
6021
6022 ! TODO: Load preview text from the file if available
6023 allocate(character(len=50) :: references(i)%preview_text)
6024 references(i)%preview_text = "..." ! Placeholder
6025 end do
6026
6027 ! Update the references panel
6028 call set_references(editor%references_panel, references, num_refs)
6029
6030 ! Clean up
6031 do i = 1, num_refs
6032 if (allocated(references(i)%uri)) deallocate(references(i)%uri)
6033 if (allocated(references(i)%filename)) deallocate(references(i)%filename)
6034 if (allocated(references(i)%preview_text)) deallocate(references(i)%preview_text)
6035 end do
6036 deallocate(references)
6037
6038 end subroutine handle_references_response_impl
6039
6040 ! Wrapper callback that matches the LSP callback signature for code actions
6041 subroutine handle_code_actions_response_wrapper(unused_request_id, response)
6042 use lsp_protocol_module, only: lsp_message_t
6043 integer, intent(in) :: unused_request_id
6044 type(lsp_message_t), intent(in) :: response
6045
6046 if (.false.) print *, unused_request_id ! Silence unused warning
6047
6048 ! Call the actual handler with saved editor state
6049 if (associated(saved_editor_for_callback)) then
6050 call handle_code_actions_response_impl(saved_editor_for_callback, response)
6051 end if
6052 end subroutine handle_code_actions_response_wrapper
6053
6054 ! Handle LSP textDocument/codeAction response implementation
6055 subroutine handle_code_actions_response_impl(editor, response)
6056 use lsp_protocol_module, only: lsp_message_t
6057 use json_module, only: json_value_t, json_get_array, json_get_object, &
6058 json_get_string, json_get_bool, json_array_size, &
6059 json_get_array_element, json_has_key, json_stringify
6060 use code_actions_panel_module, only: code_action_t
6061 type(editor_state_t), intent(inout) :: editor
6062 type(lsp_message_t), intent(in) :: response
6063 type(json_value_t) :: result_array, action_obj
6064 type(code_action_t), allocatable :: actions(:)
6065 integer :: num_actions, i
6066 character(len=:), allocatable :: title, kind, action_json
6067
6068 ! The result is directly in response%result for LSP responses
6069 result_array = response%result
6070 num_actions = json_array_size(result_array)
6071
6072 if (num_actions == 0) then
6073 ! No actions available - don't show panel
6074 return
6075 end if
6076
6077 ! Allocate and fill actions array
6078 allocate(actions(num_actions))
6079
6080 do i = 1, num_actions
6081 ! json_get_array_element expects 0-based index
6082 action_obj = json_get_array_element(result_array, i - 1)
6083
6084 ! Get title (required)
6085 if (json_has_key(action_obj, 'title')) then
6086 title = json_get_string(action_obj, 'title', '')
6087 if (allocated(actions(i)%title)) deallocate(actions(i)%title)
6088 allocate(character(len=len(title)) :: actions(i)%title)
6089 actions(i)%title = title
6090 end if
6091
6092 ! Get kind (optional)
6093 if (json_has_key(action_obj, 'kind')) then
6094 kind = json_get_string(action_obj, 'kind', '')
6095 if (allocated(actions(i)%kind)) deallocate(actions(i)%kind)
6096 allocate(character(len=len(kind)) :: actions(i)%kind)
6097 actions(i)%kind = kind
6098 end if
6099
6100 ! Get isPreferred (optional)
6101 if (json_has_key(action_obj, 'isPreferred')) then
6102 actions(i)%is_preferred = json_get_bool(action_obj, 'isPreferred', .false.)
6103 else
6104 actions(i)%is_preferred = .false.
6105 end if
6106
6107 ! Store the entire action as JSON for later application
6108 action_json = json_stringify(action_obj)
6109 if (allocated(actions(i)%action_json)) deallocate(actions(i)%action_json)
6110 allocate(character(len=len(action_json)) :: actions(i)%action_json)
6111 actions(i)%action_json = action_json
6112 end do
6113
6114 ! Update the code actions panel and show it
6115 call set_code_actions(editor%code_actions_panel, actions, num_actions)
6116 call show_code_actions_panel(editor%code_actions_panel)
6117 g_lsp_ui_changed = .true. ! Trigger re-render
6118
6119 ! Clean up
6120 do i = 1, num_actions
6121 if (allocated(actions(i)%title)) deallocate(actions(i)%title)
6122 if (allocated(actions(i)%kind)) deallocate(actions(i)%kind)
6123 if (allocated(actions(i)%action_json)) deallocate(actions(i)%action_json)
6124 end do
6125 deallocate(actions)
6126
6127 end subroutine handle_code_actions_response_impl
6128
6129 ! Wrapper callback that matches the LSP callback signature for symbols
6130 subroutine handle_symbols_response_wrapper(unused_request_id, response)
6131 use lsp_protocol_module, only: lsp_message_t
6132 integer, intent(in) :: unused_request_id
6133 type(lsp_message_t), intent(in) :: response
6134
6135 if (.false.) print *, unused_request_id ! Silence unused warning
6136
6137 ! Call the actual handler with saved editor state
6138 if (associated(saved_editor_for_callback)) then
6139 call handle_symbols_response_impl(saved_editor_for_callback, response)
6140 end if
6141 end subroutine handle_symbols_response_wrapper
6142
6143 ! Handle LSP textDocument/documentSymbol response implementation
6144 subroutine handle_symbols_response_impl(editor, response)
6145 use lsp_protocol_module, only: lsp_message_t
6146 use json_module, only: json_value_t, json_get_array, json_get_object, &
6147 json_get_string, json_get_number, json_array_size, &
6148 json_get_array_element, json_has_key, json_stringify
6149 type(editor_state_t), intent(inout) :: editor
6150 type(lsp_message_t), intent(in) :: response
6151 type(json_value_t) :: result_array, symbol_obj, location_obj, range_obj
6152 type(json_value_t) :: start_obj, end_obj, children_array
6153 type(document_symbol_t), allocatable :: symbols(:)
6154 integer :: num_symbols, i
6155 character(len=:), allocatable :: name, detail
6156 real(8) :: kind_real, line_real, col_real
6157
6158 ! The result is directly in response%result for LSP responses
6159 result_array = response%result
6160 num_symbols = json_array_size(result_array)
6161
6162 if (num_symbols == 0) then
6163 call clear_symbols(editor%symbols_panel)
6164 call terminal_move_cursor(editor%screen_rows, 1)
6165 call terminal_write('No symbols found in document ')
6166 return
6167 end if
6168
6169 ! Allocate symbols array
6170 allocate(symbols(num_symbols))
6171
6172 ! Parse each symbol
6173 do i = 1, num_symbols
6174 ! json_get_array_element expects 0-based index
6175 symbol_obj = json_get_array_element(result_array, i - 1)
6176
6177 ! Get symbol name (required)
6178 if (json_has_key(symbol_obj, 'name')) then
6179 name = json_get_string(symbol_obj, 'name', '')
6180 if (len(name) > 0) then
6181 if (allocated(symbols(i)%name)) deallocate(symbols(i)%name)
6182 allocate(character(len=len(name)) :: symbols(i)%name)
6183 symbols(i)%name = name
6184 end if
6185 end if
6186
6187 ! Get detail (optional)
6188 if (json_has_key(symbol_obj, 'detail')) then
6189 detail = json_get_string(symbol_obj, 'detail', '')
6190 if (len(detail) > 0) then
6191 if (allocated(symbols(i)%detail)) deallocate(symbols(i)%detail)
6192 allocate(character(len=len(detail)) :: symbols(i)%detail)
6193 symbols(i)%detail = detail
6194 end if
6195 end if
6196
6197 ! Get kind (required)
6198 if (json_has_key(symbol_obj, 'kind')) then
6199 kind_real = json_get_number(symbol_obj, 'kind', 13.0d0) ! Default to Variable
6200 symbols(i)%kind = int(kind_real)
6201 else
6202 symbols(i)%kind = 13 ! Variable
6203 end if
6204
6205 ! Get range or location
6206 if (json_has_key(symbol_obj, 'range')) then
6207 ! DocumentSymbol format (hierarchical)
6208 range_obj = json_get_object(symbol_obj, 'range')
6209
6210 ! Get start position
6211 if (json_has_key(range_obj, 'start')) then
6212 start_obj = json_get_object(range_obj, 'start')
6213 line_real = json_get_number(start_obj, 'line', 0.0d0)
6214 symbols(i)%line = int(line_real) + 1
6215 col_real = json_get_number(start_obj, 'character', 0.0d0)
6216 symbols(i)%column = int(col_real) + 1
6217 end if
6218
6219 ! Get end position
6220 if (json_has_key(range_obj, 'end')) then
6221 end_obj = json_get_object(range_obj, 'end')
6222 line_real = json_get_number(end_obj, 'line', 0.0d0)
6223 symbols(i)%end_line = int(line_real) + 1
6224 col_real = json_get_number(end_obj, 'character', 0.0d0)
6225 symbols(i)%end_column = int(col_real) + 1
6226 end if
6227
6228 ! Check for children (hierarchical symbols)
6229 if (json_has_key(symbol_obj, 'children')) then
6230 children_array = json_get_array(symbol_obj, 'children')
6231 symbols(i)%num_children = json_array_size(children_array)
6232 ! TODO: Parse children recursively
6233 end if
6234
6235 else if (json_has_key(symbol_obj, 'location')) then
6236 ! SymbolInformation format (flat)
6237 location_obj = json_get_object(symbol_obj, 'location')
6238
6239 if (json_has_key(location_obj, 'range')) then
6240 range_obj = json_get_object(location_obj, 'range')
6241
6242 ! Get start position
6243 if (json_has_key(range_obj, 'start')) then
6244 start_obj = json_get_object(range_obj, 'start')
6245 line_real = json_get_number(start_obj, 'line', 0.0d0)
6246 symbols(i)%line = int(line_real) + 1
6247 col_real = json_get_number(start_obj, 'character', 0.0d0)
6248 symbols(i)%column = int(col_real) + 1
6249 end if
6250
6251 ! Get end position
6252 if (json_has_key(range_obj, 'end')) then
6253 end_obj = json_get_object(range_obj, 'end')
6254 line_real = json_get_number(end_obj, 'line', 0.0d0)
6255 symbols(i)%end_line = int(line_real) + 1
6256 col_real = json_get_number(end_obj, 'character', 0.0d0)
6257 symbols(i)%end_column = int(col_real) + 1
6258 end if
6259 end if
6260 end if
6261
6262 symbols(i)%depth = 0 ! Top level
6263 symbols(i)%is_expanded = .true.
6264 end do
6265
6266 ! Update the symbols panel
6267 call set_symbols(editor%symbols_panel, symbols, num_symbols)
6268 g_lsp_ui_changed = .true. ! Trigger re-render to show symbols
6269
6270 ! Show success message
6271 call terminal_move_cursor(editor%screen_rows, 1)
6272 if (num_symbols == 1) then
6273 call terminal_write('1 symbol found ')
6274 else
6275 block
6276 character(len=50) :: msg
6277 write(msg, '(I0,A)') num_symbols, ' symbols found '
6278 call terminal_write(trim(msg))
6279 end block
6280 end if
6281
6282 ! Clean up
6283 do i = 1, num_symbols
6284 if (allocated(symbols(i)%name)) deallocate(symbols(i)%name)
6285 if (allocated(symbols(i)%detail)) deallocate(symbols(i)%detail)
6286 if (allocated(symbols(i)%children)) deallocate(symbols(i)%children)
6287 end do
6288 deallocate(symbols)
6289
6290 end subroutine handle_symbols_response_impl
6291
6292 ! Wrapper callback that matches the LSP callback signature for signature help
6293 subroutine handle_signature_response_wrapper(unused_request_id, response)
6294 use lsp_protocol_module, only: lsp_message_t
6295 integer, intent(in) :: unused_request_id
6296 type(lsp_message_t), intent(in) :: response
6297
6298 if (.false.) print *, unused_request_id ! Silence unused warning
6299
6300 ! Call the actual handler with saved editor state
6301 if (associated(saved_editor_for_callback)) then
6302 call handle_signature_response(saved_editor_for_callback%signature_tooltip, response)
6303 end if
6304 end subroutine handle_signature_response_wrapper
6305
6306 ! Wrapper callback that matches the LSP callback signature for rename
6307 subroutine handle_rename_response_wrapper(unused_request_id, response)
6308 use lsp_protocol_module, only: lsp_message_t
6309 use json_module, only: json_value_t, json_stringify
6310 integer, intent(in) :: unused_request_id
6311 type(lsp_message_t), intent(in) :: response
6312
6313 character(len=:), allocatable :: result_str
6314 integer :: changes_applied
6315
6316 if (.false.) print *, unused_request_id ! Silence unused warning
6317
6318 if (.not. associated(saved_editor_for_callback)) return
6319
6320 ! Convert result to string for apply_workspace_edit
6321 result_str = json_stringify(response%result)
6322
6323 if (.not. allocated(result_str) .or. result_str == 'null' .or. len_trim(result_str) == 0) then
6324 call terminal_move_cursor(saved_editor_for_callback%screen_rows, 1)
6325 call terminal_write('Rename failed or not supported ')
6326 if (allocated(result_str)) deallocate(result_str)
6327 return
6328 end if
6329
6330 ! Apply workspace edit
6331 call apply_workspace_edit(saved_editor_for_callback, result_str, changes_applied)
6332
6333 call terminal_move_cursor(saved_editor_for_callback%screen_rows, 1)
6334 if (changes_applied > 0) then
6335 block
6336 character(len=64) :: msg
6337 write(msg, '(A,I0,A)') 'Renamed symbol (', changes_applied, ' changes applied)'
6338 call terminal_write(trim(msg) // ' ')
6339 end block
6340 else
6341 call terminal_write('No changes applied ')
6342 end if
6343
6344 if (allocated(result_str)) deallocate(result_str)
6345 end subroutine handle_rename_response_wrapper
6346
6347 ! Wrapper callback for formatting response
6348 subroutine handle_formatting_response_wrapper(unused_request_id, response)
6349 use lsp_protocol_module, only: lsp_message_t
6350 use json_module, only: json_value_t, json_array_size, json_get_array_element, &
6351 json_get_object, json_get_string, json_get_number, json_has_key
6352 integer, intent(in) :: unused_request_id
6353 type(lsp_message_t), intent(in) :: response
6354
6355 type(json_value_t) :: edits_array, edit_obj, range_obj, start_obj, end_obj
6356 character(len=:), allocatable :: new_text
6357 integer :: num_edits, i, tab_idx
6358 integer :: start_line, start_char, end_line, end_char
6359 integer :: changes_applied
6360
6361 if (.false.) print *, unused_request_id ! Silence unused warning
6362
6363 if (.not. associated(saved_editor_for_callback)) return
6364
6365 tab_idx = saved_editor_for_callback%active_tab_index
6366 if (tab_idx < 1 .or. tab_idx > size(saved_editor_for_callback%tabs)) return
6367
6368 ! The result is an array of TextEdit objects
6369 edits_array = response%result
6370 num_edits = json_array_size(edits_array)
6371
6372 if (num_edits == 0) then
6373 call terminal_move_cursor(saved_editor_for_callback%screen_rows, 1)
6374 call terminal_write('No formatting changes needed ')
6375 return
6376 end if
6377
6378 changes_applied = 0
6379
6380 ! Apply edits in reverse order (to preserve positions)
6381 do i = num_edits - 1, 0, -1
6382 edit_obj = json_get_array_element(edits_array, i)
6383
6384 if (.not. json_has_key(edit_obj, 'range')) cycle
6385 range_obj = json_get_object(edit_obj, 'range')
6386
6387 if (json_has_key(range_obj, 'start') .and. json_has_key(range_obj, 'end')) then
6388 start_obj = json_get_object(range_obj, 'start')
6389 end_obj = json_get_object(range_obj, 'end')
6390
6391 start_line = int(json_get_number(start_obj, 'line', 0.0d0)) + 1
6392 start_char = int(json_get_number(start_obj, 'character', 0.0d0)) + 1
6393 end_line = int(json_get_number(end_obj, 'line', 0.0d0)) + 1
6394 end_char = int(json_get_number(end_obj, 'character', 0.0d0)) + 1
6395
6396 new_text = json_get_string(edit_obj, 'newText')
6397
6398 if (allocated(new_text)) then
6399 call apply_single_edit(saved_editor_for_callback%tabs(tab_idx)%buffer, &
6400 start_line, start_char, end_line, end_char, new_text)
6401 changes_applied = changes_applied + 1
6402 deallocate(new_text)
6403 end if
6404 end if
6405 end do
6406
6407 call terminal_move_cursor(saved_editor_for_callback%screen_rows, 1)
6408 if (changes_applied > 0) then
6409 block
6410 character(len=64) :: msg
6411 write(msg, '(A,I0,A)') 'Formatted (', changes_applied, ' edits applied)'
6412 call terminal_write(trim(msg) // ' ')
6413 end block
6414 else
6415 call terminal_write('No formatting changes applied ')
6416 end if
6417 end subroutine handle_formatting_response_wrapper
6418
6419 ! Apply the selected code action from the panel
6420 subroutine apply_selected_code_action(editor, buffer)
6421 use json_module, only: json_parse, json_value_t, json_get_object, &
6422 json_has_key, json_stringify
6423 type(editor_state_t), intent(inout) :: editor
6424 type(buffer_t), intent(inout) :: buffer
6425 character(len=:), allocatable :: action_json, edit_json
6426 type(json_value_t) :: action_obj, edit_obj
6427 integer :: changes_applied
6428
6429 if (get_selected_action(editor%code_actions_panel, action_json)) then
6430 ! Parse the action JSON to extract the edit
6431 action_obj = json_parse(action_json)
6432
6433 if (json_has_key(action_obj, 'edit')) then
6434 ! Get the edit object and convert to string for apply_workspace_edit
6435 edit_obj = json_get_object(action_obj, 'edit')
6436 edit_json = json_stringify(edit_obj)
6437
6438 ! Apply the workspace edit
6439 call apply_workspace_edit(editor, edit_json, changes_applied)
6440
6441 if (changes_applied > 0) then
6442 ! Sync modified tab buffer back to the buffer parameter
6443 if (editor%active_tab_index > 0 .and. &
6444 editor%active_tab_index <= size(editor%tabs)) then
6445 call copy_buffer(buffer, editor%tabs(editor%active_tab_index)%buffer)
6446 end if
6447 ! Re-render screen to show the applied changes
6448 call render_screen(buffer, editor)
6449 call terminal_move_cursor(editor%screen_rows, 1)
6450 call terminal_write('Code action applied ')
6451 else
6452 call terminal_move_cursor(editor%screen_rows, 1)
6453 call terminal_write('No changes from code action ')
6454 end if
6455 else
6456 call terminal_move_cursor(editor%screen_rows, 1)
6457 call terminal_write('Code action has no edit ')
6458 end if
6459
6460 ! Hide menu after selection
6461 call hide_code_actions_panel(editor%code_actions_panel)
6462 end if
6463 end subroutine apply_selected_code_action
6464
6465 ! Apply a workspace edit from LSP
6466 subroutine apply_workspace_edit(editor, edit_json, changes_applied)
6467 use json_module, only: json_parse, json_value_t, json_get_array, json_array_size, &
6468 json_get_array_element, json_get_object, json_get_string, &
6469 json_get_number, json_has_key
6470 type(editor_state_t), intent(inout) :: editor
6471 character(len=*), intent(in) :: edit_json
6472 integer, intent(out) :: changes_applied
6473
6474 type(json_value_t) :: edit_obj, doc_changes_arr, file_change_obj
6475 type(json_value_t) :: text_doc_obj, edits_arr
6476 character(len=:), allocatable :: uri
6477 integer :: num_files, i
6478
6479 changes_applied = 0
6480
6481 ! Parse the edit JSON
6482 edit_obj = json_parse(edit_json)
6483
6484 ! Try to get documentChanges first (newer format)
6485 if (json_has_key(edit_obj, 'documentChanges')) then
6486 doc_changes_arr = json_get_array(edit_obj, 'documentChanges')
6487 num_files = json_array_size(doc_changes_arr)
6488
6489 do i = 0, num_files - 1 ! 0-based index
6490 file_change_obj = json_get_array_element(doc_changes_arr, i)
6491
6492 ! Get text document URI
6493 if (json_has_key(file_change_obj, 'textDocument')) then
6494 text_doc_obj = json_get_object(file_change_obj, 'textDocument')
6495 uri = json_get_string(text_doc_obj, 'uri')
6496 end if
6497
6498 ! Get edits array
6499 if (json_has_key(file_change_obj, 'edits') .and. allocated(uri)) then
6500 edits_arr = json_get_array(file_change_obj, 'edits')
6501 call apply_file_edits_obj(editor, uri, edits_arr, changes_applied)
6502 deallocate(uri)
6503 end if
6504 end do
6505
6506 ! Set flag if any edits were applied (documentChanges format)
6507 if (changes_applied > 0) then
6508 g_lsp_modified_buffer = .true.
6509 end if
6510 return
6511 end if
6512
6513 ! Fall back to changes format (older format - map of URI to edits)
6514 if (json_has_key(edit_obj, 'changes')) then
6515 call terminal_move_cursor(editor%screen_rows, 1)
6516 call terminal_write('Workspace edit (changes format) not fully supported')
6517 return
6518 end if
6519
6520 ! Set flag if any edits were applied
6521 if (changes_applied > 0) then
6522 g_lsp_modified_buffer = .true.
6523 end if
6524
6525 end subroutine apply_workspace_edit
6526
6527 ! Apply edits to a specific file (using json_value_t)
6528 subroutine apply_file_edits_obj(editor, uri, edits_arr, changes_applied)
6529 use json_module, only: json_value_t, json_array_size, json_get_array_element, &
6530 json_get_object, json_get_string, json_get_number, json_has_key
6531 use text_buffer_module, only: buffer_to_string
6532 use lsp_server_manager_module, only: notify_file_changed
6533 type(editor_state_t), intent(inout) :: editor
6534 character(len=*), intent(in) :: uri
6535 type(json_value_t), intent(in) :: edits_arr
6536 integer, intent(inout) :: changes_applied
6537
6538 type(json_value_t) :: edit_obj, range_obj, start_obj, end_obj
6539 character(len=:), allocatable :: filename, new_text, buffer_content
6540 integer :: num_edits, i, j, tab_idx, server_idx, pane_idx
6541 integer :: start_line, start_char, end_line, end_char
6542
6543 ! Convert URI to filename
6544 if (len(uri) >= 8 .and. uri(1:8) == 'file:///') then
6545 filename = uri(8:) ! Skip "file://" leaving one /
6546 else if (len(uri) >= 7 .and. uri(1:7) == 'file://') then
6547 filename = uri(8:)
6548 else
6549 filename = uri
6550 end if
6551
6552 ! Find the tab with this file
6553 tab_idx = 0
6554 do j = 1, size(editor%tabs)
6555 if (allocated(editor%tabs(j)%filename)) then
6556 ! Try exact match first, then check if the absolute path ends with the relative path
6557 if (trim(editor%tabs(j)%filename) == trim(filename)) then
6558 tab_idx = j
6559 exit
6560 else if (len(filename) >= len(editor%tabs(j)%filename)) then
6561 ! Check if filename ends with tab filename (handles absolute vs relative paths)
6562 if (filename(len(filename)-len(editor%tabs(j)%filename)+1:) == editor%tabs(j)%filename) then
6563 tab_idx = j
6564 exit
6565 end if
6566 end if
6567 end if
6568 end do
6569
6570 if (tab_idx == 0) then
6571 ! File not open - skip for now
6572 if (allocated(filename)) deallocate(filename)
6573 return
6574 end if
6575
6576 ! Get the active pane for this tab (panes contain the actual buffers)
6577 pane_idx = editor%tabs(tab_idx)%active_pane_index
6578 if (pane_idx < 1 .or. .not. allocated(editor%tabs(tab_idx)%panes)) then
6579 pane_idx = 1 ! Default to first pane
6580 end if
6581 if (pane_idx > size(editor%tabs(tab_idx)%panes)) then
6582 if (allocated(filename)) deallocate(filename)
6583 return
6584 end if
6585
6586 ! Debug: log tab_idx finding
6587 open(newunit=server_idx, file='/tmp/fac_tab_debug.log', status='unknown', &
6588 position='append', action='write')
6589 write(server_idx, '(A,I3,A,I3)') 'Found tab_idx=', tab_idx, ' pane_idx=', pane_idx
6590 write(server_idx, '(A,A)') 'Extracted filename: ', trim(filename)
6591 write(server_idx, '(A,A)') 'Tab filename: ', trim(editor%tabs(tab_idx)%filename)
6592 write(server_idx, '(A)') '---'
6593 close(server_idx)
6594
6595 ! Apply edits in reverse order (to preserve line numbers)
6596 num_edits = json_array_size(edits_arr)
6597
6598 do i = num_edits - 1, 0, -1 ! 0-based index, reverse order
6599 edit_obj = json_get_array_element(edits_arr, i)
6600
6601 ! Get range
6602 if (.not. json_has_key(edit_obj, 'range')) cycle
6603 range_obj = json_get_object(edit_obj, 'range')
6604
6605 if (json_has_key(range_obj, 'start') .and. json_has_key(range_obj, 'end')) then
6606 start_obj = json_get_object(range_obj, 'start')
6607 end_obj = json_get_object(range_obj, 'end')
6608
6609 start_line = int(json_get_number(start_obj, 'line', 0.0d0)) + 1
6610 start_char = int(json_get_number(start_obj, 'character', 0.0d0)) + 1
6611 end_line = int(json_get_number(end_obj, 'line', 0.0d0)) + 1
6612 end_char = int(json_get_number(end_obj, 'character', 0.0d0)) + 1
6613
6614 ! Get new text
6615 new_text = json_get_string(edit_obj, 'newText')
6616
6617 if (allocated(new_text)) then
6618 ! Apply the edit to the pane buffer (not tab buffer!)
6619 call apply_single_edit(editor%tabs(tab_idx)%panes(pane_idx)%buffer, &
6620 start_line, start_char, end_line, end_char, new_text)
6621 changes_applied = changes_applied + 1
6622
6623 ! Debug: check buffer size after edit
6624 block
6625 character(len=:), allocatable :: check_content
6626 integer :: check_unit
6627 check_content = buffer_to_string(editor%tabs(tab_idx)%panes(pane_idx)%buffer)
6628 open(newunit=check_unit, file='/tmp/fac_after_edit.log', status='unknown', &
6629 position='append', action='write')
6630 write(check_unit, '(A,I8)') 'After edit, buffer len: ', len(check_content)
6631 close(check_unit)
6632 if (allocated(check_content)) deallocate(check_content)
6633 end block
6634
6635 deallocate(new_text)
6636 end if
6637 end if
6638 end do
6639
6640 ! Sync the changed document back to all LSP servers
6641 if (changes_applied > 0) then
6642 buffer_content = buffer_to_string(editor%tabs(tab_idx)%panes(pane_idx)%buffer)
6643 if (allocated(buffer_content)) then
6644 ! Debug: log what we're about to sync
6645 open(newunit=server_idx, file='/tmp/fac_sync_debug.log', status='unknown', &
6646 position='append', action='write')
6647 write(server_idx, '(A,I4)') 'Sync after changes_applied=', changes_applied
6648 write(server_idx, '(A,I8)') 'Buffer content length: ', len(buffer_content)
6649 write(server_idx, '(A,A)') 'First 100 chars: ', buffer_content(1:min(100,len(buffer_content)))
6650 write(server_idx, '(A)') '---'
6651 close(server_idx)
6652
6653 ! Notify all active LSP servers about the document change
6654 ! Use the absolute path from the URI (filename variable) not the tab's relative path
6655 do server_idx = 1, editor%lsp_manager%num_servers
6656 if (editor%lsp_manager%servers(server_idx)%initialized) then
6657 call notify_file_changed(editor%lsp_manager, server_idx, &
6658 'file://' // filename, buffer_content)
6659 end if
6660 end do
6661 deallocate(buffer_content)
6662 end if
6663 end if
6664
6665 if (allocated(filename)) deallocate(filename)
6666 end subroutine apply_file_edits_obj
6667
6668 ! Apply a single text edit to a buffer
6669 subroutine apply_single_edit(buffer, start_line, start_char, end_line, end_char, new_text)
6670 type(buffer_t), intent(inout) :: buffer
6671 integer, intent(in) :: start_line, start_char, end_line, end_char
6672 character(len=*), intent(in) :: new_text
6673
6674 integer :: start_pos, end_pos, delete_count
6675 integer :: debug_unit
6676 character(len=256) :: debug_msg
6677
6678 ! Calculate buffer positions
6679 start_pos = get_buffer_position(buffer, start_line, start_char)
6680 end_pos = get_buffer_position(buffer, end_line, end_char)
6681
6682 ! Debug logging
6683 open(newunit=debug_unit, file='/tmp/fac_edit_debug.log', status='unknown', &
6684 position='append', action='write')
6685 write(debug_msg, '(A,I4,A,I4,A,I4,A,I4)') 'Edit range: line ', start_line, &
6686 ' char ', start_char, ' to line ', end_line, ' char ', end_char
6687 write(debug_unit, '(A)') trim(debug_msg)
6688 write(debug_msg, '(A,I6,A,I6,A,I4)') 'Buffer pos: start=', start_pos, &
6689 ' end=', end_pos, ' delete_count=', end_pos - start_pos
6690 write(debug_unit, '(A)') trim(debug_msg)
6691 write(debug_msg, '(A,I4,A,A,A)') 'New text len=', len(new_text), ' text="', new_text, '"'
6692 write(debug_unit, '(A)') trim(debug_msg)
6693 write(debug_unit, '(A)') '---'
6694 close(debug_unit)
6695
6696 if (start_pos <= 0 .or. end_pos <= 0) return
6697
6698 ! Delete the old text
6699 delete_count = end_pos - start_pos
6700
6701 ! Debug: log gap buffer state before operations
6702 open(newunit=debug_unit, file='/tmp/fac_gap_debug.log', status='unknown', &
6703 position='append', action='write')
6704 write(debug_unit, '(A,I6,A,I6,A,I6)') 'BEFORE: gap_start=', buffer%gap_start, &
6705 ' gap_end=', buffer%gap_end, ' size=', buffer%size
6706 close(debug_unit)
6707
6708 if (delete_count > 0) then
6709 call buffer_delete(buffer, start_pos, delete_count)
6710 end if
6711
6712 ! Debug: log gap buffer state after delete
6713 open(newunit=debug_unit, file='/tmp/fac_gap_debug.log', status='unknown', &
6714 position='append', action='write')
6715 write(debug_unit, '(A,I6,A,I6,A,I6)') 'AFTER DELETE: gap_start=', buffer%gap_start, &
6716 ' gap_end=', buffer%gap_end, ' size=', buffer%size
6717 close(debug_unit)
6718
6719 ! Insert the new text
6720 if (len(new_text) > 0) then
6721 call buffer_insert(buffer, start_pos, new_text)
6722 end if
6723
6724 ! Debug: log gap buffer state after insert
6725 open(newunit=debug_unit, file='/tmp/fac_gap_debug.log', status='unknown', &
6726 position='append', action='write')
6727 write(debug_unit, '(A,I6,A,I6,A,I6)') 'AFTER INSERT: gap_start=', buffer%gap_start, &
6728 ' gap_end=', buffer%gap_end, ' size=', buffer%size
6729 write(debug_unit, '(A)') '---'
6730 close(debug_unit)
6731 end subroutine apply_single_edit
6732
6733 ! Execute a command from the command palette
6734 subroutine execute_palette_command(editor, buffer, cmd_id, should_quit)
6735 type(editor_state_t), intent(inout) :: editor
6736 type(buffer_t), intent(inout) :: buffer
6737 character(len=*), intent(in) :: cmd_id
6738 logical, intent(out) :: should_quit
6739
6740 should_quit = .false.
6741
6742 ! Map command IDs to their corresponding key commands
6743 select case(trim(cmd_id))
6744 ! File operations
6745 case('save')
6746 call handle_key_command('ctrl-s', editor, buffer, should_quit)
6747 case('save-all')
6748 call handle_key_command('ctrl-shift-s', editor, buffer, should_quit)
6749 case('quit')
6750 call handle_key_command('ctrl-q', editor, buffer, should_quit)
6751 case('open')
6752 ! Open fortress mode for file browsing
6753 editor%fuss_mode_active = .true.
6754 call render_screen(buffer, editor)
6755 case('toggle-tree')
6756 call handle_key_command('f3', editor, buffer, should_quit)
6757
6758 ! Edit operations
6759 case('copy')
6760 call handle_key_command('ctrl-c', editor, buffer, should_quit)
6761 case('paste')
6762 call handle_key_command('ctrl-v', editor, buffer, should_quit)
6763 case('cut')
6764 call handle_key_command('ctrl-x', editor, buffer, should_quit)
6765 case('undo')
6766 call handle_key_command('ctrl-z', editor, buffer, should_quit)
6767 case('redo')
6768 call handle_key_command('ctrl-y', editor, buffer, should_quit)
6769
6770 ! Search operations
6771 case('find')
6772 call handle_key_command('ctrl-f', editor, buffer, should_quit)
6773 case('replace')
6774 call handle_key_command('ctrl-h', editor, buffer, should_quit)
6775 case('find-next')
6776 call handle_key_command('ctrl-g', editor, buffer, should_quit)
6777
6778 ! Navigation
6779 case('goto-line')
6780 call handle_key_command('alt-g', editor, buffer, should_quit)
6781 case('goto-def')
6782 call handle_key_command('f12', editor, buffer, should_quit)
6783 case('find-refs')
6784 call handle_key_command('shift-f12', editor, buffer, should_quit)
6785 case('jump-back')
6786 call handle_key_command('alt-,', editor, buffer, should_quit)
6787 case('goto-symbol')
6788 call handle_key_command('f4', editor, buffer, should_quit)
6789
6790 ! LSP features
6791 case('code-actions')
6792 call handle_key_command('f8', editor, buffer, should_quit)
6793 case('rename')
6794 call handle_key_command('f2', editor, buffer, should_quit)
6795 case('diagnostics')
6796 call handle_key_command('alt-e', editor, buffer, should_quit)
6797
6798 ! View
6799 case('split-v')
6800 call handle_key_command('ctrl-\\', editor, buffer, should_quit)
6801 case('close-pane')
6802 call handle_key_command('ctrl-w', editor, buffer, should_quit)
6803
6804 ! Help
6805 case('help')
6806 call handle_key_command('f1', editor, buffer, should_quit)
6807
6808 case default
6809 ! Unknown command - show message
6810 call terminal_move_cursor(editor%screen_rows, 1)
6811 call terminal_write('Unknown command: ' // trim(cmd_id) // repeat(' ', 20))
6812 end select
6813 end subroutine execute_palette_command
6814
6815 ! Handle workspace symbols LSP response
6816 subroutine handle_workspace_symbols_response_wrapper(unused_request_id, response)
6817 use lsp_protocol_module, only: lsp_message_t
6818 use json_module
6819 use workspace_symbols_panel_module, only: workspace_symbol_t, set_workspace_symbols
6820 integer, intent(in) :: unused_request_id
6821 type(lsp_message_t), intent(in) :: response
6822 type(json_value_t) :: symbol_obj, location_obj, range_obj, start_obj
6823 integer :: num_symbols, i
6824 type(workspace_symbol_t), allocatable :: symbols(:)
6825 character(len=:), allocatable :: name, container, uri
6826 real(8) :: line_num, char_num, kind_num
6827
6828 if (.false.) print *, unused_request_id ! Silence unused warning
6829
6830 num_symbols = json_array_size(response%result)
6831 if (num_symbols == 0) return
6832 allocate(symbols(num_symbols))
6833
6834 do i = 0, num_symbols - 1
6835 symbol_obj = json_get_array_element(response%result, i)
6836
6837 ! Get name
6838 name = json_get_string(symbol_obj, 'name', '')
6839 if (allocated(name)) then
6840 symbols(i+1)%name = name
6841 end if
6842
6843 ! Get kind (as number) and convert to string
6844 kind_num = json_get_number(symbol_obj, 'kind', 0.0d0)
6845 symbols(i+1)%kind_name = symbol_kind_to_string(int(kind_num))
6846
6847 ! Get container name (optional)
6848 container = json_get_string(symbol_obj, 'containerName', '')
6849 if (allocated(container)) then
6850 symbols(i+1)%container_name = container
6851 end if
6852
6853 ! Get location
6854 location_obj = json_get_object(symbol_obj, 'location')
6855 uri = json_get_string(location_obj, 'uri', '')
6856 if (allocated(uri) .and. len_trim(uri) > 0) then
6857 symbols(i+1)%file_uri = uri
6858
6859 ! Get range -> start -> line/character
6860 range_obj = json_get_object(location_obj, 'range')
6861 start_obj = json_get_object(range_obj, 'start')
6862 line_num = json_get_number(start_obj, 'line', 0.0d0)
6863 char_num = json_get_number(start_obj, 'character', 0.0d0)
6864 symbols(i+1)%line = int(line_num)
6865 symbols(i+1)%column = int(char_num)
6866 end if
6867 end do
6868
6869 ! Update the panel
6870 if (associated(saved_editor_for_callback)) then
6871 call set_workspace_symbols(saved_editor_for_callback%workspace_symbols_panel, symbols, num_symbols)
6872 end if
6873
6874 if (allocated(symbols)) deallocate(symbols)
6875 end subroutine handle_workspace_symbols_response_wrapper
6876
6877 ! Helper to convert LSP symbol kind number to string
6878 function symbol_kind_to_string(kind) result(kind_str)
6879 integer, intent(in) :: kind
6880 character(len=:), allocatable :: kind_str
6881
6882 select case(kind)
6883 case(1); kind_str = "File"
6884 case(2); kind_str = "Module"
6885 case(3); kind_str = "Namespace"
6886 case(4); kind_str = "Package"
6887 case(5); kind_str = "Class"
6888 case(6); kind_str = "Method"
6889 case(7); kind_str = "Property"
6890 case(8); kind_str = "Field"
6891 case(9); kind_str = "Constructor"
6892 case(10); kind_str = "Enum"
6893 case(11); kind_str = "Interface"
6894 case(12); kind_str = "Function"
6895 case(13); kind_str = "Variable"
6896 case(14); kind_str = "Constant"
6897 case(15); kind_str = "String"
6898 case(16); kind_str = "Number"
6899 case(17); kind_str = "Boolean"
6900 case(18); kind_str = "Array"
6901 case default; kind_str = "Unknown"
6902 end select
6903 end function symbol_kind_to_string
6904
6905 ! Navigate to a workspace symbol
6906 subroutine navigate_to_workspace_symbol(editor, buffer, symbol, should_quit)
6907 use workspace_symbols_panel_module, only: workspace_symbol_t
6908 use jump_stack_module, only: push_jump_location
6909 use editor_state_module, only: switch_to_tab_with_buffer, create_tab, sync_pane_to_editor, sync_editor_to_pane
6910 use text_buffer_module, only: buffer_load_file, copy_buffer
6911 type(editor_state_t), intent(inout) :: editor
6912 type(buffer_t), intent(inout) :: buffer
6913 type(workspace_symbol_t), intent(in) :: symbol
6914 logical, intent(out) :: should_quit
6915 character(len=:), allocatable :: filepath
6916 integer :: i
6917
6918 should_quit = .false.
6919
6920 ! Convert file:// URI to filepath
6921 if (index(symbol%file_uri, "file://") == 1) then
6922 filepath = symbol%file_uri(8:) ! Remove "file://"
6923 else
6924 filepath = symbol%file_uri
6925 end if
6926
6927 ! Push current location to jump stack
6928 if (allocated(editor%filename)) then
6929 call push_jump_location(editor%jump_stack, editor%filename, &
6930 editor%cursors(editor%active_cursor)%line, &
6931 editor%cursors(editor%active_cursor)%column)
6932 end if
6933
6934 ! FIRST: Check if symbol is in the currently active tab (just jump, no tab switch)
6935 if (editor%active_tab_index > 0 .and. editor%active_tab_index <= size(editor%tabs)) then
6936 if (allocated(editor%tabs(editor%active_tab_index)%filename)) then
6937 if (paths_match(editor%tabs(editor%active_tab_index)%filename, filepath)) then
6938 ! Same file - just jump to the position
6939 editor%cursors(editor%active_cursor)%line = symbol%line + 1 ! LSP is 0-based
6940 editor%cursors(editor%active_cursor)%column = symbol%column + 1
6941 editor%cursors(editor%active_cursor)%desired_column = symbol%column + 1
6942 editor%viewport_line = max(1, symbol%line + 1 - editor%screen_rows / 2)
6943 call sync_editor_to_pane(editor)
6944 return
6945 end if
6946 end if
6947 end if
6948
6949 ! SECOND: Check if file is open in another (inactive) tab
6950 do i = 1, size(editor%tabs)
6951 if (i == editor%active_tab_index) cycle ! Skip active tab, already checked
6952 if (allocated(editor%tabs(i)%filename)) then
6953 if (paths_match(editor%tabs(i)%filename, filepath)) then
6954 ! Save current buffer and switch to existing tab
6955 call switch_to_tab_with_buffer(editor, i, buffer)
6956 ! Jump to the symbol's position
6957 editor%cursors(editor%active_cursor)%line = symbol%line + 1 ! LSP is 0-based
6958 editor%cursors(editor%active_cursor)%column = symbol%column + 1
6959 editor%cursors(editor%active_cursor)%desired_column = symbol%column + 1
6960 editor%viewport_line = max(1, symbol%line + 1 - editor%screen_rows / 2)
6961 call sync_editor_to_pane(editor)
6962 return
6963 end if
6964 end if
6965 end do
6966
6967 ! File not open - create a new tab and load the file
6968 block
6969 integer :: status, new_tab_idx, old_tab_idx, old_pane_idx
6970
6971 ! CRITICAL: Save current buffer to old tab BEFORE create_tab changes active_tab_index
6972 old_tab_idx = editor%active_tab_index
6973 if (old_tab_idx > 0 .and. old_tab_idx <= size(editor%tabs)) then
6974 old_pane_idx = editor%tabs(old_tab_idx)%active_pane_index
6975 if (allocated(editor%tabs(old_tab_idx)%panes) .and. &
6976 old_pane_idx > 0 .and. old_pane_idx <= size(editor%tabs(old_tab_idx)%panes)) then
6977 call copy_buffer(editor%tabs(old_tab_idx)%panes(old_pane_idx)%buffer, buffer)
6978 end if
6979 call copy_buffer(editor%tabs(old_tab_idx)%buffer, buffer)
6980 end if
6981
6982 call create_tab(editor, filepath)
6983
6984 new_tab_idx = size(editor%tabs) ! The tab we just created
6985
6986 call buffer_load_file(editor%tabs(new_tab_idx)%buffer, filepath, status)
6987
6988 if (status == 0) then
6989 ! File loaded successfully
6990 ! Copy buffer to the pane's buffer
6991 if (allocated(editor%tabs(new_tab_idx)%panes)) then
6992 call copy_buffer(editor%tabs(new_tab_idx)%panes(1)%buffer, editor%tabs(new_tab_idx)%buffer)
6993 end if
6994
6995 ! Load the new tab's buffer into working buffer (create_tab already switched active_tab_index)
6996 call copy_buffer(buffer, editor%tabs(new_tab_idx)%buffer)
6997
6998 ! Update editor%filename to the new tab's filename
6999 if (allocated(editor%filename)) deallocate(editor%filename)
7000 allocate(character(len=len(editor%tabs(new_tab_idx)%filename)) :: editor%filename)
7001 editor%filename = editor%tabs(new_tab_idx)%filename
7002 editor%modified = editor%tabs(new_tab_idx)%modified
7003
7004 ! Sync the pane to editor state (this updates editor%cursors, etc.)
7005 call sync_pane_to_editor(editor, new_tab_idx, 1)
7006
7007 ! Navigate to the symbol's position
7008 editor%cursors(editor%active_cursor)%line = symbol%line + 1 ! LSP is 0-based
7009 editor%cursors(editor%active_cursor)%column = symbol%column + 1
7010 editor%cursors(editor%active_cursor)%desired_column = symbol%column + 1
7011 editor%viewport_line = max(1, symbol%line + 1 - editor%screen_rows / 2)
7012
7013 ! Sync editor state back to pane
7014 call sync_editor_to_pane(editor)
7015 else
7016 ! File load failed - could show error message
7017 ! For now, just don't navigate
7018 continue
7019 end if
7020 end block
7021 end subroutine navigate_to_workspace_symbol
7022
7023 ! Helper function to compare file paths (handles relative vs absolute)
7024 function paths_match(path1, path2) result(match)
7025 character(len=*), intent(in) :: path1, path2
7026 logical :: match
7027 character(len=:), allocatable :: p1, p2
7028
7029 match = .false.
7030
7031 ! Direct comparison first
7032 if (trim(path1) == trim(path2)) then
7033 match = .true.
7034 return
7035 end if
7036
7037 ! Try comparing just the filenames (basename) if one is relative
7038 p1 = get_path_basename(path1)
7039 p2 = get_path_basename(path2)
7040
7041 ! If basenames match and one path ends with the other, consider it a match
7042 if (trim(p1) == trim(p2)) then
7043 ! Check if one path is a suffix of the other
7044 if (index(path1, trim(path2)) > 0 .or. index(path2, trim(path1)) > 0) then
7045 match = .true.
7046 return
7047 end if
7048 ! Also match if the absolute path ends with the relative path
7049 if (len_trim(path1) > len_trim(path2)) then
7050 if (path1(len_trim(path1)-len_trim(path2)+1:) == trim(path2)) then
7051 match = .true.
7052 return
7053 end if
7054 else if (len_trim(path2) > len_trim(path1)) then
7055 if (path2(len_trim(path2)-len_trim(path1)+1:) == trim(path1)) then
7056 match = .true.
7057 return
7058 end if
7059 end if
7060 end if
7061 end function paths_match
7062
7063 ! Get basename from a path
7064 function get_path_basename(path) result(basename)
7065 character(len=*), intent(in) :: path
7066 character(len=:), allocatable :: basename
7067 integer :: i, last_slash
7068
7069 last_slash = 0
7070 do i = len_trim(path), 1, -1
7071 if (path(i:i) == '/') then
7072 last_slash = i
7073 exit
7074 end if
7075 end do
7076
7077 if (last_slash > 0 .and. last_slash < len_trim(path)) then
7078 basename = path(last_slash+1:len_trim(path))
7079 else
7080 basename = trim(path)
7081 end if
7082 end function get_path_basename
7083
7084 ! ==================================================
7085 ! LSP Definition Response Handler
7086 ! ==================================================
7087
7088 ! Wrapper callback for go to definition
7089 subroutine handle_definition_response_wrapper(unused_request_id, response)
7090 use lsp_protocol_module, only: lsp_message_t
7091 integer, intent(in) :: unused_request_id
7092 type(lsp_message_t), intent(in) :: response
7093
7094 if (.false.) print *, unused_request_id ! Silence unused warning
7095
7096 ! Call actual handler with saved editor state
7097 if (associated(saved_editor_for_callback)) then
7098 call handle_definition_response_impl(saved_editor_for_callback, response)
7099 end if
7100 end subroutine handle_definition_response_wrapper
7101
7102 ! Handle LSP textDocument/definition response
7103 subroutine handle_definition_response_impl(editor, response)
7104 use lsp_protocol_module, only: lsp_message_t
7105 use json_module, only: json_value_t, json_get_object, json_get_string, &
7106 json_get_number, json_array_size, json_get_array_element, &
7107 json_has_key, json_stringify
7108 use editor_state_module, only: switch_to_tab, sync_pane_to_editor, sync_editor_to_pane
7109 use text_buffer_module, only: buffer_load_file, copy_buffer
7110 use renderer_module, only: render_screen
7111 type(editor_state_t), intent(inout) :: editor
7112 type(lsp_message_t), intent(in) :: response
7113 type(json_value_t) :: location_obj, range_obj, start_obj
7114 character(len=:), allocatable :: uri, filepath
7115 real(8) :: line_real, col_real
7116 integer :: target_line, target_col, i, num_locations
7117 logical :: found_file
7118
7119 ! Try to treat result as array first
7120 num_locations = json_array_size(response%result)
7121
7122 if (num_locations > 0) then
7123 ! Array of locations - take first one
7124 location_obj = json_get_array_element(response%result, 0)
7125 else if (json_has_key(response%result, "uri")) then
7126 ! Single location object
7127 location_obj = response%result
7128 else
7129 ! No definition found
7130 call terminal_move_cursor(editor%screen_rows, 1)
7131 call terminal_write('No definition found ')
7132 if (associated(saved_buffer_for_callback)) then
7133 call render_screen(saved_buffer_for_callback, editor)
7134 end if
7135 return
7136 end if
7137
7138 ! Extract URI
7139 uri = json_get_string(location_obj, 'uri', '')
7140 if (len(uri) == 0) then
7141 call terminal_move_cursor(editor%screen_rows, 1)
7142 call terminal_write('Invalid definition response ')
7143 if (associated(saved_buffer_for_callback)) then
7144 call render_screen(saved_buffer_for_callback, editor)
7145 end if
7146 return
7147 end if
7148
7149 ! Convert URI to filepath (remove file:// prefix)
7150 if (len(uri) > 7 .and. uri(1:7) == 'file://') then
7151 filepath = uri(8:)
7152 else
7153 filepath = uri
7154 end if
7155
7156 ! Get range
7157 range_obj = json_get_object(location_obj, 'range')
7158 start_obj = json_get_object(range_obj, 'start')
7159
7160 line_real = json_get_number(start_obj, 'line', 0.0d0)
7161 col_real = json_get_number(start_obj, 'character', 0.0d0)
7162
7163 ! Convert from 0-based LSP to 1-based editor coordinates
7164 target_line = int(line_real) + 1
7165 target_col = int(col_real) + 1
7166
7167 ! Check if the file is already open in a tab
7168 found_file = .false.
7169 do i = 1, size(editor%tabs)
7170 if (allocated(editor%tabs(i)%filename)) then
7171 ! Check for exact match or suffix match (handles relative vs absolute paths)
7172 if (trim(editor%tabs(i)%filename) == trim(filepath)) then
7173 found_file = .true.
7174 else if (len_trim(filepath) > len_trim(editor%tabs(i)%filename)) then
7175 ! Check if filepath ends with tab filename
7176 if (filepath(len_trim(filepath)-len_trim(editor%tabs(i)%filename)+1:) == &
7177 trim(editor%tabs(i)%filename)) then
7178 found_file = .true.
7179 end if
7180 else if (len_trim(editor%tabs(i)%filename) > len_trim(filepath)) then
7181 ! Check if tab filename ends with filepath
7182 if (editor%tabs(i)%filename(len_trim(editor%tabs(i)%filename)-len_trim(filepath)+1:) == &
7183 trim(filepath)) then
7184 found_file = .true.
7185 end if
7186 end if
7187
7188 if (found_file) then
7189 ! Properly switch to this tab
7190 call switch_to_tab(editor, i)
7191 call sync_pane_to_editor(editor, i, editor%tabs(i)%active_pane_index)
7192 exit
7193 end if
7194 end if
7195 end do
7196
7197 ! If file not found in tabs, create a new tab and load it
7198 if (.not. found_file) then
7199 call create_tab(editor, filepath)
7200
7201 ! Load file content into the new tab's buffer
7202 block
7203 integer :: status, new_tab_idx
7204
7205 new_tab_idx = size(editor%tabs) ! The tab we just created
7206
7207 call buffer_load_file(editor%tabs(new_tab_idx)%buffer, filepath, status)
7208
7209 if (status == 0) then
7210 ! File loaded successfully
7211 ! Copy buffer to the pane's buffer
7212 if (allocated(editor%tabs(new_tab_idx)%panes)) then
7213 call copy_buffer(editor%tabs(new_tab_idx)%panes(1)%buffer, editor%tabs(new_tab_idx)%buffer)
7214 end if
7215
7216 ! Send LSP didOpen notification to all active servers for this tab
7217 if (editor%tabs(new_tab_idx)%num_lsp_servers > 0) then
7218 block
7219 use text_buffer_module, only: buffer_to_string
7220 integer :: srv_i
7221 do srv_i = 1, editor%tabs(new_tab_idx)%num_lsp_servers
7222 call notify_file_opened(editor%lsp_manager, &
7223 editor%tabs(new_tab_idx)%lsp_server_indices(srv_i), &
7224 filepath, buffer_to_string(editor%tabs(new_tab_idx)%buffer))
7225 end do
7226 end block
7227 end if
7228
7229 ! Switch to the new tab
7230 call switch_to_tab(editor, new_tab_idx)
7231
7232 ! Sync the pane to editor state (this updates editor%cursors, etc.)
7233 call sync_pane_to_editor(editor, new_tab_idx, 1)
7234
7235 ! Navigate to the definition position
7236 editor%cursors(editor%active_cursor)%line = target_line
7237 editor%cursors(editor%active_cursor)%column = target_col
7238 editor%cursors(editor%active_cursor)%desired_column = target_col
7239 editor%viewport_line = max(1, target_line - editor%screen_rows / 2)
7240
7241 ! Sync editor state back to pane
7242 call sync_editor_to_pane(editor)
7243
7244 call terminal_move_cursor(editor%screen_rows, 1)
7245 call terminal_write('Jumped to definition in ' // trim(filepath) // ' ')
7246 if (associated(saved_buffer_for_callback)) then
7247 call render_screen(saved_buffer_for_callback, editor)
7248 end if
7249 else
7250 ! File load failed
7251 call terminal_move_cursor(editor%screen_rows, 1)
7252 call terminal_write('Failed to load: ' // trim(filepath) // ' ')
7253 if (associated(saved_buffer_for_callback)) then
7254 call render_screen(saved_buffer_for_callback, editor)
7255 end if
7256 end if
7257 end block
7258 return
7259 end if
7260
7261 ! File already open in tabs - jump to the line and column
7262 editor%cursors(editor%active_cursor)%line = target_line
7263 editor%cursors(editor%active_cursor)%column = target_col
7264 editor%cursors(editor%active_cursor)%desired_column = target_col
7265
7266 ! Center viewport on target
7267 editor%viewport_line = max(1, target_line - editor%screen_rows / 2)
7268
7269 ! Sync cursor changes back to pane
7270 call sync_editor_to_pane(editor)
7271
7272 call terminal_move_cursor(editor%screen_rows, 1)
7273 call terminal_write('Jumped to definition ')
7274 if (associated(saved_buffer_for_callback)) then
7275 call render_screen(saved_buffer_for_callback, editor)
7276 end if
7277 end subroutine handle_definition_response_impl
7278
7279 end module command_handler_module
7280