Fortran · 37536 bytes Raw Blame History
1 ! Workspace management module
2 ! Handles workspace detection, creation, loading, and saving
3
4 module workspace_module
5 use iso_c_binding, only: c_int
6 use editor_state_module, only: editor_state_t, create_tab, sync_pane_to_editor
7 use text_buffer_module, only: buffer_t, init_buffer, buffer_to_string
8 use lsp_server_manager_module, only: notify_file_opened
9 use recents_module, only: recents_add_or_update
10 implicit none
11 private
12
13 ! Interface to C getpid function
14 interface
15 function c_getpid() bind(c, name="getpid")
16 use iso_c_binding, only: c_int
17 integer(c_int) :: c_getpid
18 end function c_getpid
19 end interface
20
21 public :: workspace_exists, workspace_init, workspace_load, workspace_save
22 public :: workspace_get_path, workspace_detect_from_file, workspace_is_file_in_workspace
23 public :: workspace_save_state, workspace_restore_state
24 public :: workspace_switch
25
26 integer, parameter :: MAX_PATH_LEN = 512
27
28 contains
29
30 !> Check if a directory has a workspace
31 function workspace_exists(dir_path) result(exists)
32 character(len=*), intent(in) :: dir_path
33 logical :: exists
34 character(len=MAX_PATH_LEN) :: workspace_file
35 integer :: unit, ios
36
37 ! Build path to workspace.json
38 workspace_file = trim(dir_path) // "/.fac/workspace.json"
39
40 ! Try to open the file
41 open(newunit=unit, file=workspace_file, status='old', iostat=ios)
42 exists = (ios == 0)
43
44 if (exists) close(unit)
45 end function workspace_exists
46
47 !> Detect workspace from a file path (search parent directories)
48 function workspace_detect_from_file(file_path) result(workspace_path)
49 character(len=*), intent(in) :: file_path
50 character(len=MAX_PATH_LEN) :: workspace_path
51 character(len=MAX_PATH_LEN) :: current_dir, parent_dir
52 integer :: last_slash
53
54 workspace_path = ""
55
56 ! Get directory of file
57 last_slash = index(file_path, "/", back=.true.)
58 if (last_slash > 0) then
59 current_dir = file_path(1:last_slash-1)
60 else
61 current_dir = "."
62 end if
63
64 ! Search up the directory tree for a workspace
65 do while (len_trim(current_dir) > 0)
66 if (workspace_exists(current_dir)) then
67 workspace_path = current_dir
68 return
69 end if
70
71 ! Move to parent directory
72 if (current_dir == "/" .or. current_dir == ".") exit
73
74 last_slash = index(current_dir, "/", back=.true.)
75 if (last_slash > 0) then
76 parent_dir = current_dir(1:last_slash-1)
77 if (len_trim(parent_dir) == 0) parent_dir = "/"
78 current_dir = parent_dir
79 else
80 exit
81 end if
82 end do
83 end function workspace_detect_from_file
84
85 !> Get absolute workspace path from potentially relative path
86 subroutine workspace_get_path(input_path, absolute_path)
87 character(len=*), intent(in) :: input_path
88 character(len=MAX_PATH_LEN), intent(out) :: absolute_path
89 character(len=MAX_PATH_LEN) :: temp_file, pid_str
90 integer :: unit, ios, pid
91
92 ! Get process ID for unique temp file (avoid race conditions)
93 pid = c_getpid()
94 write(pid_str, '(I0)') pid
95 temp_file = '/tmp/.fac_realpath_' // trim(pid_str)
96
97 ! Use realpath via shell command
98 call execute_command_line("realpath '" // trim(input_path) // "' > '" // trim(temp_file) // "' 2>/dev/null", &
99 wait=.true.)
100
101 open(newunit=unit, file=trim(temp_file), status='old', iostat=ios)
102 if (ios == 0) then
103 read(unit, '(a)', iostat=ios) absolute_path
104 close(unit)
105 call execute_command_line("rm -f '" // trim(temp_file) // "'", wait=.true.)
106 else
107 absolute_path = input_path
108 end if
109 end subroutine workspace_get_path
110
111 !> Check if a file path is within the workspace directory
112 function workspace_is_file_in_workspace(file_path, workspace_path) result(is_in_workspace)
113 character(len=*), intent(in) :: file_path, workspace_path
114 logical :: is_in_workspace
115 character(len=MAX_PATH_LEN) :: abs_file_path, abs_workspace_path
116 integer :: ws_len
117
118 is_in_workspace = .false.
119
120 ! Get absolute paths for both
121 call workspace_get_path(file_path, abs_file_path)
122 call workspace_get_path(workspace_path, abs_workspace_path)
123
124 ! Check if file path starts with workspace path
125 ws_len = len_trim(abs_workspace_path)
126 if (len_trim(abs_file_path) > ws_len) then
127 ! Check if file path starts with workspace path followed by /
128 if (abs_file_path(1:ws_len) == abs_workspace_path(1:ws_len)) then
129 if (abs_file_path(ws_len+1:ws_len+1) == '/') then
130 is_in_workspace = .true.
131 end if
132 end if
133 end if
134 end function workspace_is_file_in_workspace
135
136 !> Initialize a new workspace in the given directory
137 subroutine workspace_init(dir_path, success)
138 character(len=*), intent(in) :: dir_path
139 logical, intent(out) :: success
140 character(len=MAX_PATH_LEN) :: fac_dir, workspace_file, backup_dir
141 integer :: unit, ios
142
143 success = .false.
144
145 ! Create .fac directory
146 fac_dir = trim(dir_path) // "/.fac"
147 call execute_command_line("mkdir -p '" // trim(fac_dir) // "' 2>/dev/null", wait=.true.)
148
149 ! Create backups subdirectory
150 backup_dir = trim(fac_dir) // "/backups"
151 call execute_command_line("mkdir -p '" // trim(backup_dir) // "' 2>/dev/null", wait=.true.)
152
153 ! Create initial workspace.json
154 workspace_file = trim(fac_dir) // "/workspace.json"
155
156 open(newunit=unit, file=workspace_file, status='replace', iostat=ios)
157 if (ios /= 0) return
158
159 ! Write minimal initial workspace JSON
160 write(unit, '(a)') '{'
161 write(unit, '(a)') ' "version": "1.0",'
162 write(unit, '(a)') ' "workspace_path": "' // trim(dir_path) // '",'
163 write(unit, '(a)') ' "last_opened": "",'
164 write(unit, '(a)') ' "tabs": [],'
165 write(unit, '(a)') ' "orphan_tabs": [],'
166 write(unit, '(a)') ' "active_tab": 0,'
167 write(unit, '(a)') ' "fuss_mode": {'
168 write(unit, '(a)') ' "active": false,'
169 write(unit, '(a)') ' "width": 30'
170 write(unit, '(a)') ' }'
171 write(unit, '(a)') '}'
172
173 close(unit)
174 success = .true.
175
176 ! Track in recents (extract basename for label)
177 call track_workspace_in_recents(dir_path)
178 end subroutine workspace_init
179
180 !> Load workspace state (stub for now - Phase 3 will implement full deserialization)
181 subroutine workspace_load(dir_path, success)
182 character(len=*), intent(in) :: dir_path
183 logical, intent(out) :: success
184 character(len=MAX_PATH_LEN) :: workspace_file
185 integer :: unit, ios
186
187 success = .false.
188 workspace_file = trim(dir_path) // "/.fac/workspace.json"
189
190 ! For now, just verify the file exists and is readable
191 open(newunit=unit, file=workspace_file, status='old', iostat=ios)
192 if (ios == 0) then
193 close(unit)
194 success = .true.
195 ! TODO Phase 3: Parse JSON and restore tabs/panes/cursor state
196
197 ! Track in recents
198 call track_workspace_in_recents(dir_path)
199 end if
200 end subroutine workspace_load
201
202 !> Save workspace state (stub for now - Phase 3 will implement full serialization)
203 subroutine workspace_save(dir_path, success)
204 character(len=*), intent(in) :: dir_path
205 logical, intent(out) :: success
206 character(len=MAX_PATH_LEN) :: workspace_file
207 integer :: unit, ios
208
209 success = .false.
210 workspace_file = trim(dir_path) // "/.fac/workspace.json"
211
212 ! For now, just verify we can write to the file
213 open(newunit=unit, file=workspace_file, status='old', iostat=ios)
214 if (ios == 0) then
215 close(unit)
216 success = .true.
217 ! TODO Phase 3: Serialize current tabs/panes/cursor state to JSON
218 end if
219 end subroutine workspace_save
220
221 !> Save editor state to workspace JSON file
222 subroutine workspace_save_state(editor, dir_path, success)
223 type(editor_state_t), intent(in) :: editor
224 character(len=*), intent(in) :: dir_path
225 logical, intent(out) :: success
226 character(len=MAX_PATH_LEN) :: workspace_file, relative_path
227 integer :: unit, ios, i, j, ws_len
228 character(len=20) :: timestamp
229 logical :: is_relative
230
231 success = .false.
232 workspace_file = trim(dir_path) // "/.fac/workspace.json"
233
234 ! Open file for writing
235 open(newunit=unit, file=workspace_file, status='replace', iostat=ios)
236 if (ios /= 0) return
237
238 ! Get current timestamp (simplified)
239 call date_and_time(timestamp)
240
241 ! Write JSON header
242 write(unit, '(A)') '{'
243 write(unit, '(A)') ' "version": "1.0",'
244 write(unit, '(A)') ' "workspace_path": "' // trim(dir_path) // '",'
245 write(unit, '(A)') ' "last_opened": "' // trim(timestamp) // '",'
246 write(unit, '(A)') ' "tabs": ['
247
248 ! Write tabs
249 if (allocated(editor%tabs)) then
250 ws_len = len_trim(dir_path)
251 do i = 1, size(editor%tabs)
252 ! Write tab start - must be on its own line for parser
253 write(unit, '(A)') ' {'
254
255 ! Determine if we should use relative path
256 is_relative = .false.
257 if (.not. editor%tabs(i)%is_orphan .and. allocated(editor%tabs(i)%filename)) then
258 ! Check if filename starts with workspace path
259 if (len_trim(editor%tabs(i)%filename) > ws_len) then
260 if (editor%tabs(i)%filename(1:ws_len) == dir_path(1:ws_len)) then
261 if (editor%tabs(i)%filename(ws_len+1:ws_len+1) == '/') then
262 is_relative = .true.
263 relative_path = editor%tabs(i)%filename(ws_len+2:)
264 end if
265 end if
266 end if
267 end if
268
269 ! Write filename
270 write(unit, '(A)', advance='no') ' "filename": "'
271 if (is_relative) then
272 write(unit, '(A)', advance='no') trim(relative_path)
273 else if (allocated(editor%tabs(i)%filename)) then
274 write(unit, '(A)', advance='no') trim(editor%tabs(i)%filename)
275 else
276 write(unit, '(A)', advance='no') 'untitled'
277 end if
278 write(unit, '(A)') '", '
279
280 ! Write flags
281 if (editor%tabs(i)%is_orphan) then
282 write(unit, '(A)') ' "is_orphan": true, '
283 else
284 write(unit, '(A)') ' "is_orphan": false, '
285 end if
286
287 if (editor%tabs(i)%modified) then
288 write(unit, '(A)') ' "modified": true, '
289 else
290 write(unit, '(A)') ' "modified": false, '
291 end if
292
293 ! Write panes array
294 write(unit, '(A)') ' "panes": ['
295 if (allocated(editor%tabs(i)%panes) .and. size(editor%tabs(i)%panes) > 0) then
296 do j = 1, size(editor%tabs(i)%panes)
297 write(unit, '(A)') ' {'
298
299 ! Write pane coordinates - each on its own line for parseability
300 write(unit, '(A,F6.4,A)') ' "x_start": ', &
301 editor%tabs(i)%panes(j)%x_start, ','
302 write(unit, '(A,F6.4,A)') ' "y_start": ', &
303 editor%tabs(i)%panes(j)%y_start, ','
304 write(unit, '(A,F6.4,A)') ' "x_end": ', &
305 editor%tabs(i)%panes(j)%x_end, ','
306 write(unit, '(A,F6.4,A)') ' "y_end": ', &
307 editor%tabs(i)%panes(j)%y_end, ','
308
309 ! Write pane filename (may differ from tab filename)
310 if (allocated(editor%tabs(i)%panes(j)%filename)) then
311 ! Check if we should use relative path
312 if (.not. editor%tabs(i)%is_orphan) then
313 if (len_trim(editor%tabs(i)%panes(j)%filename) > ws_len) then
314 if (editor%tabs(i)%panes(j)%filename(1:ws_len) == dir_path(1:ws_len)) then
315 if (editor%tabs(i)%panes(j)%filename(ws_len+1:ws_len+1) == '/') then
316 write(unit, '(A)') ' "filename": "' // &
317 trim(editor%tabs(i)%panes(j)%filename(ws_len+2:)) // '",'
318 else
319 write(unit, '(A)') ' "filename": "' // &
320 trim(editor%tabs(i)%panes(j)%filename) // '",'
321 end if
322 else
323 write(unit, '(A)') ' "filename": "' // &
324 trim(editor%tabs(i)%panes(j)%filename) // '",'
325 end if
326 else
327 write(unit, '(A)') ' "filename": "' // &
328 trim(editor%tabs(i)%panes(j)%filename) // '",'
329 end if
330 else
331 ! Orphan tab - use absolute path
332 write(unit, '(A)') ' "filename": "' // &
333 trim(editor%tabs(i)%panes(j)%filename) // '",'
334 end if
335 else
336 write(unit, '(A)') ' "filename": "",'
337 end if
338
339 ! Write cursor and viewport - each on own line
340 if (allocated(editor%tabs(i)%panes(j)%cursors) .and. &
341 size(editor%tabs(i)%panes(j)%cursors) > 0) then
342 write(unit, '(A,I0,A)') ' "cursor_line": ', &
343 editor%tabs(i)%panes(j)%cursors(1)%line, ','
344 write(unit, '(A,I0,A)') ' "cursor_column": ', &
345 editor%tabs(i)%panes(j)%cursors(1)%column, ','
346 else
347 write(unit, '(A)') ' "cursor_line": 1,'
348 write(unit, '(A)') ' "cursor_column": 1,'
349 end if
350
351 write(unit, '(A,I0,A)') ' "viewport_line": ', &
352 editor%tabs(i)%panes(j)%viewport_line, ','
353 write(unit, '(A,I0)') ' "viewport_column": ', &
354 editor%tabs(i)%panes(j)%viewport_column
355
356 ! Close pane object
357 write(unit, '(A)') ' }'
358 if (j < size(editor%tabs(i)%panes)) then
359 write(unit, '(A)') ' ,'
360 end if
361 end do
362 end if
363 write(unit, '(A)') ' ],'
364
365 ! Write active pane index
366 write(unit, '(A,I0)') ' "active_pane": ', &
367 editor%tabs(i)%active_pane_index
368
369 ! Close tab object
370 if (i < size(editor%tabs)) then
371 write(unit, '(A)') '},'
372 else
373 write(unit, '(A)') '}'
374 end if
375 end do
376 end if
377
378 ! Write JSON footer
379 write(unit, '(A)') ' ],'
380 write(unit, '(A,I0,A)') ' "active_tab": ', editor%active_tab_index, ','
381 write(unit, '(A)') ' "fuss_mode": {'
382 if (editor%fuss_mode_active) then
383 write(unit, '(A)') ' "active": true,'
384 else
385 write(unit, '(A)') ' "active": false,'
386 end if
387 write(unit, '(A)') ' "width": 30'
388 write(unit, '(A)') ' }'
389 write(unit, '(A)') '}'
390
391 close(unit)
392 success = .true.
393 end subroutine workspace_save_state
394
395 !> Restore editor state from workspace JSON file
396 !> Note: For Phase 3, this is a simplified version that only restores the first pane
397 !> Full multi-pane restoration will be added when needed
398 subroutine workspace_restore_state(editor, dir_path, success)
399 use text_buffer_module, only: buffer_load_file
400 use terminal_io_module, only: terminal_write
401 type(editor_state_t), intent(inout) :: editor
402 character(len=*), intent(in) :: dir_path
403 logical, intent(out) :: success
404 character(len=MAX_PATH_LEN) :: workspace_file, line, tab_filename, pane_filename, full_path
405 integer :: unit, ios, colon_pos, quote1, quote2, comma_pos
406 integer :: cursor_line, cursor_col, viewport_line, viewport_col
407 real :: x_start, y_start, x_end, y_end
408 logical :: in_tabs_array, is_orphan, reading_tab, in_panes_array, reading_pane
409 logical :: file_exists
410 integer :: load_status, tab_idx, pane_count, file_unit
411 character(len=20) :: value_str
412
413 success = .false.
414 workspace_file = trim(dir_path) // "/.fac/workspace.json"
415
416 ! Open workspace file
417 open(newunit=unit, file=workspace_file, status='old', iostat=ios)
418 if (ios /= 0) then
419 ! Workspace file doesn't exist or can't be read - initialize new workspace
420 call workspace_init(dir_path, success)
421 return
422 end if
423
424 ! Parse JSON line by line (simple parser for our specific format)
425 in_tabs_array = .false.
426 reading_tab = .false.
427 in_panes_array = .false.
428 reading_pane = .false.
429 tab_filename = ""
430 pane_filename = ""
431 is_orphan = .false.
432 pane_count = 0
433 editor%active_tab_index = 1 ! Default to first tab
434
435
436 do
437 read(unit, '(A)', iostat=ios) line
438 if (ios /= 0) exit
439
440 line = adjustl(line)
441
442 ! Check if we're entering the tabs array
443 if (index(line, '"tabs":') > 0) then
444 in_tabs_array = .true.
445 cycle
446 end if
447
448 ! Parse active_tab index (outside tabs array)
449 if (.not. in_tabs_array .and. index(line, '"active_tab":') > 0) then
450 colon_pos = index(line, ':')
451 comma_pos = index(line, ',')
452 if (colon_pos > 0) then
453 if (comma_pos > colon_pos) then
454 value_str = adjustl(line(colon_pos+1:comma_pos-1))
455 else
456 value_str = adjustl(line(colon_pos+1:))
457 end if
458 read(value_str, *, iostat=ios) editor%active_tab_index
459 ! Ensure it's at least 1 if tabs were restored
460 if (editor%active_tab_index < 1) editor%active_tab_index = 1
461 end if
462 cycle
463 end if
464
465 ! Check if we're exiting the tabs array
466 if (in_tabs_array .and. index(line, '],') > 0 .and. .not. in_panes_array) then
467 in_tabs_array = .false.
468 cycle
469 end if
470
471 ! Check if we're starting a new tab object
472 if (in_tabs_array .and. index(line, '{') > 0 .and. .not. reading_tab .and. .not. in_panes_array) then
473 reading_tab = .true.
474 tab_filename = ""
475 is_orphan = .false.
476 pane_count = 0
477 cycle
478 end if
479
480 ! Check if we're entering panes array
481 if (reading_tab .and. index(line, '"panes":') > 0) then
482 in_panes_array = .true.
483 cycle
484 end if
485
486 ! Check if we're exiting panes array
487 if (in_panes_array .and. index(line, '],') > 0) then
488 in_panes_array = .false.
489 cycle
490 end if
491
492 ! Check if we're starting a new pane object
493 if (in_panes_array .and. index(line, '{') > 0 .and. .not. reading_pane) then
494 reading_pane = .true.
495 pane_filename = ""
496 cursor_line = 1
497 cursor_col = 1
498 viewport_line = 1
499 viewport_col = 1
500 x_start = 0.0
501 y_start = 0.0
502 x_end = 1.0
503 y_end = 1.0
504 pane_count = pane_count + 1
505 cycle
506 end if
507
508 ! Check if we're ending a pane object
509 if (reading_pane .and. index(line, '}') > 0) then
510 ! For Phase 3: Only restore first pane of each tab for simplicity
511 ! Full multi-pane restoration can be added later when workspace switching is implemented
512 if (pane_count == 1 .and. len_trim(pane_filename) > 0) then
513 ! Build full path
514 if (is_orphan .or. pane_filename(1:1) == '/') then
515 full_path = pane_filename
516 else
517 full_path = trim(dir_path) // '/' // trim(pane_filename)
518 end if
519
520 ! Check if this is an untitled tab (in-memory only)
521 if (index(pane_filename, '[Untitled') == 1) then
522 ! Untitled tab - create without loading from file
523 call create_tab(editor, trim(pane_filename))
524 tab_idx = editor%active_tab_index
525
526 ! Set orphan flag and initialize empty buffers
527 if (allocated(editor%tabs) .and. tab_idx > 0) then
528 editor%tabs(tab_idx)%is_orphan = .false.
529
530 ! Initialize empty buffer for tab
531 call init_buffer(editor%tabs(tab_idx)%buffer)
532
533 ! Set cursor and viewport in first pane
534 if (allocated(editor%tabs(tab_idx)%panes) .and. size(editor%tabs(tab_idx)%panes) > 0) then
535 ! Initialize empty buffer for pane
536 call init_buffer(editor%tabs(tab_idx)%panes(1)%buffer)
537
538 ! Set pane filename
539 if (allocated(editor%tabs(tab_idx)%panes(1)%filename)) then
540 deallocate(editor%tabs(tab_idx)%panes(1)%filename)
541 end if
542 allocate(character(len=len_trim(pane_filename)) :: editor%tabs(tab_idx)%panes(1)%filename)
543 editor%tabs(tab_idx)%panes(1)%filename = trim(pane_filename)
544
545 ! Set pane coordinates
546 editor%tabs(tab_idx)%panes(1)%x_start = x_start
547 editor%tabs(tab_idx)%panes(1)%y_start = y_start
548 editor%tabs(tab_idx)%panes(1)%x_end = x_end
549 editor%tabs(tab_idx)%panes(1)%y_end = y_end
550
551 if (allocated(editor%tabs(tab_idx)%panes(1)%cursors) .and. &
552 size(editor%tabs(tab_idx)%panes(1)%cursors) > 0) then
553 editor%tabs(tab_idx)%panes(1)%cursors(1)%line = cursor_line
554 editor%tabs(tab_idx)%panes(1)%cursors(1)%column = cursor_col
555 editor%tabs(tab_idx)%panes(1)%cursors(1)%desired_column = cursor_col
556 end if
557
558 editor%tabs(tab_idx)%panes(1)%viewport_line = viewport_line
559 editor%tabs(tab_idx)%panes(1)%viewport_column = viewport_col
560 end if
561 end if
562 else
563 ! Regular file tab - check if file exists before creating
564 file_exists = .false.
565 open(newunit=file_unit, file=trim(full_path), status='old', iostat=ios)
566 if (ios == 0) then
567 file_exists = .true.
568 close(file_unit)
569 end if
570
571 if (.not. file_exists) then
572 ! File doesn't exist - skip this tab silently
573 reading_pane = .false.
574 cycle
575 end if
576
577 ! Create tab
578 call create_tab(editor, trim(full_path))
579 tab_idx = editor%active_tab_index
580
581 ! Set orphan flag and load file
582 if (allocated(editor%tabs) .and. tab_idx > 0) then
583 editor%tabs(tab_idx)%is_orphan = is_orphan
584 call buffer_load_file(editor%tabs(tab_idx)%buffer, trim(full_path), load_status)
585
586 ! Send LSP didOpen notification for restored tabs
587 if (load_status == 0 .and. editor%tabs(tab_idx)%num_lsp_servers > 0) then
588 block
589 integer :: srv_i
590 do srv_i = 1, editor%tabs(tab_idx)%num_lsp_servers
591 call notify_file_opened(editor%lsp_manager, &
592 editor%tabs(tab_idx)%lsp_server_indices(srv_i), &
593 trim(full_path), buffer_to_string(editor%tabs(tab_idx)%buffer))
594 end do
595 end block
596 end if
597
598 ! Set cursor and viewport in first pane
599 if (allocated(editor%tabs(tab_idx)%panes) .and. size(editor%tabs(tab_idx)%panes) > 0) then
600 call buffer_load_file(editor%tabs(tab_idx)%panes(1)%buffer, trim(full_path), load_status)
601
602 ! Set pane coordinates (even if only 1 pane for now)
603 editor%tabs(tab_idx)%panes(1)%x_start = x_start
604 editor%tabs(tab_idx)%panes(1)%y_start = y_start
605 editor%tabs(tab_idx)%panes(1)%x_end = x_end
606 editor%tabs(tab_idx)%panes(1)%y_end = y_end
607
608 if (allocated(editor%tabs(tab_idx)%panes(1)%cursors) .and. &
609 size(editor%tabs(tab_idx)%panes(1)%cursors) > 0) then
610 editor%tabs(tab_idx)%panes(1)%cursors(1)%line = cursor_line
611 editor%tabs(tab_idx)%panes(1)%cursors(1)%column = cursor_col
612 editor%tabs(tab_idx)%panes(1)%cursors(1)%desired_column = cursor_col
613 end if
614
615 editor%tabs(tab_idx)%panes(1)%viewport_line = viewport_line
616 editor%tabs(tab_idx)%panes(1)%viewport_column = viewport_col
617 end if
618 end if
619 end if
620 end if
621
622 reading_pane = .false.
623 cycle
624 end if
625
626 ! Check if we're ending a tab object
627 if (reading_tab .and. index(line, '}') > 0 .and. .not. in_panes_array) then
628 reading_tab = .false.
629 cycle
630 end if
631
632 ! Parse tab-level properties
633 if (reading_tab .and. .not. in_panes_array) then
634 ! Extract tab filename (fallback if no panes)
635 if (index(line, '"filename":') > 0) then
636 quote1 = index(line, '"', .true.)
637 if (quote1 > 0) then
638 quote2 = index(line(1:quote1-1), '"', .true.)
639 if (quote2 > 0) then
640 tab_filename = line(quote2+1:quote1-1)
641 end if
642 end if
643 end if
644
645 ! Extract is_orphan
646 if (index(line, '"is_orphan":') > 0) then
647 is_orphan = index(line, 'true') > 0
648 end if
649 end if
650
651 ! Parse pane properties
652 if (reading_pane) then
653 ! Extract pane filename
654 if (index(line, '"filename":') > 0) then
655 quote1 = index(line, '"', .true.)
656 if (quote1 > 0) then
657 quote2 = index(line(1:quote1-1), '"', .true.)
658 if (quote2 > 0) then
659 pane_filename = line(quote2+1:quote1-1)
660 end if
661 end if
662 end if
663
664 ! Extract coordinates
665 if (index(line, '"x_start":') > 0) then
666 colon_pos = index(line, ':')
667 comma_pos = index(line, ',')
668 if (colon_pos > 0 .and. comma_pos > colon_pos) then
669 value_str = adjustl(line(colon_pos+1:comma_pos-1))
670 read(value_str, *, iostat=ios) x_start
671 end if
672 end if
673
674 if (index(line, '"y_start":') > 0) then
675 colon_pos = index(line, ':')
676 comma_pos = index(line, ',')
677 if (colon_pos > 0 .and. comma_pos > colon_pos) then
678 value_str = adjustl(line(colon_pos+1:comma_pos-1))
679 read(value_str, *, iostat=ios) y_start
680 end if
681 end if
682
683 if (index(line, '"x_end":') > 0) then
684 colon_pos = index(line, ':')
685 comma_pos = index(line, ',')
686 if (colon_pos > 0 .and. comma_pos > colon_pos) then
687 value_str = adjustl(line(colon_pos+1:comma_pos-1))
688 read(value_str, *, iostat=ios) x_end
689 end if
690 end if
691
692 if (index(line, '"y_end":') > 0) then
693 colon_pos = index(line, ':')
694 comma_pos = index(line, ',')
695 if (colon_pos > 0 .and. comma_pos > colon_pos) then
696 value_str = adjustl(line(colon_pos+1:comma_pos-1))
697 read(value_str, *, iostat=ios) y_end
698 end if
699 end if
700
701 ! Extract cursor_line
702 if (index(line, '"cursor_line":') > 0) then
703 colon_pos = index(line, ':')
704 comma_pos = index(line, ',')
705 if (colon_pos > 0) then
706 if (comma_pos > colon_pos) then
707 value_str = adjustl(line(colon_pos+1:comma_pos-1))
708 else
709 value_str = adjustl(line(colon_pos+1:))
710 end if
711 read(value_str, *, iostat=ios) cursor_line
712 end if
713 end if
714
715 ! Extract cursor_column
716 if (index(line, '"cursor_column":') > 0) then
717 colon_pos = index(line, ':')
718 comma_pos = index(line, ',')
719 if (colon_pos > 0) then
720 if (comma_pos > colon_pos) then
721 value_str = adjustl(line(colon_pos+1:comma_pos-1))
722 else
723 value_str = adjustl(line(colon_pos+1:))
724 end if
725 read(value_str, *, iostat=ios) cursor_col
726 end if
727 end if
728
729 ! Extract viewport_line
730 if (index(line, '"viewport_line":') > 0) then
731 colon_pos = index(line, ':')
732 comma_pos = index(line, ',')
733 if (colon_pos > 0) then
734 if (comma_pos > colon_pos) then
735 value_str = adjustl(line(colon_pos+1:comma_pos-1))
736 else
737 value_str = adjustl(line(colon_pos+1:))
738 end if
739 read(value_str, *, iostat=ios) viewport_line
740 end if
741 end if
742
743 ! Extract viewport_column
744 if (index(line, '"viewport_column":') > 0) then
745 colon_pos = index(line, ':')
746 comma_pos = index(line, ',')
747 if (colon_pos > 0) then
748 if (comma_pos > colon_pos) then
749 value_str = adjustl(line(colon_pos+1:comma_pos-1))
750 else
751 value_str = adjustl(line(colon_pos+1:))
752 end if
753 read(value_str, *, iostat=ios) viewport_col
754 end if
755 end if
756 end if
757 end do
758
759 close(unit)
760
761 ! Clamp active_tab_index to valid range
762 if (allocated(editor%tabs)) then
763 if (size(editor%tabs) > 0) then
764 if (editor%active_tab_index > size(editor%tabs)) then
765 editor%active_tab_index = size(editor%tabs)
766 end if
767 if (editor%active_tab_index < 1) then
768 editor%active_tab_index = 1
769 end if
770 else
771 editor%active_tab_index = 0 ! No tabs
772 end if
773 else
774 editor%active_tab_index = 0 ! No tabs
775 end if
776
777 ! Sync the active pane to editor state so status bar shows correct filename
778 if (allocated(editor%tabs) .and. editor%active_tab_index > 0) then
779 if (editor%active_tab_index <= size(editor%tabs)) then
780 if (allocated(editor%tabs(editor%active_tab_index)%panes)) then
781 if (editor%tabs(editor%active_tab_index)%active_pane_index > 0 .and. &
782 editor%tabs(editor%active_tab_index)%active_pane_index <= &
783 size(editor%tabs(editor%active_tab_index)%panes)) then
784 call sync_pane_to_editor(editor, editor%active_tab_index, &
785 editor%tabs(editor%active_tab_index)%active_pane_index)
786 end if
787 end if
788 end if
789 end if
790
791 success = .true.
792 end subroutine workspace_restore_state
793
794 !> Track workspace in recents (helper function)
795 subroutine track_workspace_in_recents(dir_path)
796 character(len=*), intent(in) :: dir_path
797 character(len=MAX_PATH_LEN) :: label
798 logical :: recents_success
799 integer :: i
800
801 ! Extract basename for label
802 label = dir_path
803 do i = len_trim(dir_path), 1, -1
804 if (dir_path(i:i) == '/') then
805 label = dir_path(i+1:)
806 exit
807 end if
808 end do
809
810 ! Add or update in recents
811 call recents_add_or_update(dir_path, trim(label), recents_success)
812 ! Silently ignore recents failures
813 end subroutine track_workspace_in_recents
814
815 !> Switch to a different workspace
816 subroutine workspace_switch(editor, new_workspace_path, success)
817 type(editor_state_t), intent(inout) :: editor
818 character(len=*), intent(in) :: new_workspace_path
819 logical, intent(out) :: success
820 character(len=MAX_PATH_LEN) :: old_workspace_path
821 logical :: save_success
822
823 success = .false.
824
825 ! Save current workspace state
826 if (allocated(editor%workspace_path)) then
827 old_workspace_path = editor%workspace_path
828 call workspace_save_state(editor, old_workspace_path, save_success)
829 ! Continue even if save fails - best effort
830 end if
831
832 ! Update workspace path
833 editor%workspace_path = trim(new_workspace_path)
834
835 ! Check if new workspace exists
836 if (.not. workspace_exists(new_workspace_path)) then
837 ! Create new workspace
838 call workspace_init(new_workspace_path, success)
839 if (.not. success) then
840 ! Restore old workspace path on failure
841 if (len_trim(old_workspace_path) > 0) then
842 editor%workspace_path = old_workspace_path
843 end if
844 return
845 end if
846 end if
847
848 ! Load/restore new workspace state
849 call workspace_restore_state(editor, new_workspace_path, success)
850
851 ! Track in recents
852 call track_workspace_in_recents(new_workspace_path)
853 end subroutine workspace_switch
854
855 end module workspace_module
856