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