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