fortrangoingonforty/facsimile / c4b4cfb

Browse files

feat: Add LSP Server Manager with first-run experience

Implements a comprehensive language server management system inspired by
Neovim's Mason, providing users an easy way to discover and install LSP
servers directly from the editor.

Features:
- First-run experience: LSP Server Manager automatically appears on first launch
- Alt+M keybinding: Toggle the manager panel anytime
- 20 language servers supported including Python, C/C++, Rust, Go, JavaScript/TypeScript, Lua, Ruby, Java, and more
- Installation with confirmation: Select a server, press Enter to see install command, Y to confirm
- Server status detection: Uses 'which' to detect installed servers
- Navigation: j/k or arrows to navigate, r to refresh, Esc/q to close
- State persistence: First-run state stored in ~/.config/fac/state.json

New modules:
- src/workspace/app_state_module.f90: First-run detection and state persistence
- src/lsp/server_detection_module.f90: Detect installed language servers
- src/lsp/server_installer_module.f90: Execute installation commands
- src/ui/lsp_server_installer_panel_module.f90: Modal UI panel

Additional fixes:
- Fixed modal panel border alignment (Command Palette & LSP Server Manager)
- Removed circular dependency in unified_search_module
- Updated Makefile with new modules in correct dependency order
Authored by espadonne
SHA
c4b4cfb5329ede35687acade0c27d2071960865c
Parents
f5da84c
Tree
9420991

4 changed files

StatusFile+-
A src/lsp/server_detection_module.f90 230 0
A src/lsp/server_installer_module.f90 44 0
A src/ui/lsp_server_installer_panel_module.f90 377 0
A src/workspace/app_state_module.f90 150 0
src/lsp/server_detection_module.f90added
@@ -0,0 +1,230 @@
1
+module server_detection_module
2
+    implicit none
3
+    private
4
+
5
+    public :: detected_server_t
6
+    public :: detect_all_servers, check_server_installed
7
+    public :: get_known_servers_count
8
+
9
+    integer, parameter :: MAX_SERVERS = 30
10
+
11
+    type :: detected_server_t
12
+        character(len=64) :: name = ''           ! "pyright", "clangd", etc.
13
+        character(len=32) :: language = ''       ! "python", "c", etc.
14
+        character(len=256) :: install_cmd = ''   ! "pip install pyright"
15
+        character(len=256) :: description = ''   ! "Python type checker and language server"
16
+        character(len=128) :: check_cmd = ''     ! Command to check if installed
17
+        logical :: is_installed = .false.        ! Result of detection
18
+        character(len=64) :: version = ''        ! Detected version (if installed)
19
+    end type detected_server_t
20
+
21
+contains
22
+
23
+    function get_known_servers_count() result(count)
24
+        integer :: count
25
+        count = 20  ! Number of servers we know about
26
+    end function get_known_servers_count
27
+
28
+    subroutine detect_all_servers(servers, num_servers)
29
+        type(detected_server_t), intent(out), allocatable :: servers(:)
30
+        integer, intent(out) :: num_servers
31
+        integer :: i
32
+
33
+        num_servers = get_known_servers_count()
34
+        allocate(servers(num_servers))
35
+
36
+        ! Initialize all known servers
37
+        call init_known_servers(servers, num_servers)
38
+
39
+        ! Check which are installed
40
+        do i = 1, num_servers
41
+            servers(i)%is_installed = check_server_installed(servers(i)%check_cmd)
42
+        end do
43
+    end subroutine detect_all_servers
44
+
45
+    subroutine init_known_servers(servers, num_servers)
46
+        type(detected_server_t), intent(inout) :: servers(:)
47
+        integer, intent(in) :: num_servers
48
+        integer :: i
49
+
50
+        i = 1
51
+
52
+        ! Python - Pyright
53
+        servers(i)%name = 'pyright'
54
+        servers(i)%language = 'Python'
55
+        servers(i)%install_cmd = 'pip install pyright'
56
+        servers(i)%description = 'Python type checker and language server'
57
+        servers(i)%check_cmd = 'pyright-langserver'
58
+        i = i + 1
59
+
60
+        ! Python - Ruff
61
+        servers(i)%name = 'ruff'
62
+        servers(i)%language = 'Python'
63
+        servers(i)%install_cmd = 'pip install ruff'
64
+        servers(i)%description = 'Fast Python linter with auto-fix'
65
+        servers(i)%check_cmd = 'ruff'
66
+        i = i + 1
67
+
68
+        ! Python - python-lsp-server
69
+        servers(i)%name = 'python-lsp-server'
70
+        servers(i)%language = 'Python'
71
+        servers(i)%install_cmd = 'pip install python-lsp-server'
72
+        servers(i)%description = 'Python LSP (pylsp) with plugins support'
73
+        servers(i)%check_cmd = 'pylsp'
74
+        i = i + 1
75
+
76
+        ! C/C++ - clangd
77
+        servers(i)%name = 'clangd'
78
+        servers(i)%language = 'C/C++'
79
+        servers(i)%install_cmd = 'brew install llvm'
80
+        servers(i)%description = 'C/C++ language server from LLVM'
81
+        servers(i)%check_cmd = 'clangd'
82
+        i = i + 1
83
+
84
+        ! Rust - rust-analyzer
85
+        servers(i)%name = 'rust-analyzer'
86
+        servers(i)%language = 'Rust'
87
+        servers(i)%install_cmd = 'rustup component add rust-analyzer'
88
+        servers(i)%description = 'Rust language server'
89
+        servers(i)%check_cmd = 'rust-analyzer'
90
+        i = i + 1
91
+
92
+        ! Go - gopls
93
+        servers(i)%name = 'gopls'
94
+        servers(i)%language = 'Go'
95
+        servers(i)%install_cmd = 'go install golang.org/x/tools/gopls@latest'
96
+        servers(i)%description = 'Go language server'
97
+        servers(i)%check_cmd = 'gopls'
98
+        i = i + 1
99
+
100
+        ! TypeScript/JavaScript - typescript-language-server
101
+        servers(i)%name = 'typescript-language-server'
102
+        servers(i)%language = 'JS/TS'
103
+        servers(i)%install_cmd = 'npm install -g typescript-language-server typescript'
104
+        servers(i)%description = 'JavaScript and TypeScript language server'
105
+        servers(i)%check_cmd = 'typescript-language-server'
106
+        i = i + 1
107
+
108
+        ! JavaScript/TypeScript - vtsls (faster alternative)
109
+        servers(i)%name = 'vtsls'
110
+        servers(i)%language = 'JS/TS'
111
+        servers(i)%install_cmd = 'npm install -g @vtsls/language-server'
112
+        servers(i)%description = 'Fast TypeScript language server'
113
+        servers(i)%check_cmd = 'vtsls'
114
+        i = i + 1
115
+
116
+        ! Lua - lua-language-server
117
+        servers(i)%name = 'lua-language-server'
118
+        servers(i)%language = 'Lua'
119
+        servers(i)%install_cmd = 'brew install lua-language-server'
120
+        servers(i)%description = 'Lua language server by sumneko'
121
+        servers(i)%check_cmd = 'lua-language-server'
122
+        i = i + 1
123
+
124
+        ! Ruby - solargraph
125
+        servers(i)%name = 'solargraph'
126
+        servers(i)%language = 'Ruby'
127
+        servers(i)%install_cmd = 'gem install solargraph'
128
+        servers(i)%description = 'Ruby language server'
129
+        servers(i)%check_cmd = 'solargraph'
130
+        i = i + 1
131
+
132
+        ! Java - jdtls
133
+        servers(i)%name = 'jdtls'
134
+        servers(i)%language = 'Java'
135
+        servers(i)%install_cmd = 'brew install jdtls'
136
+        servers(i)%description = 'Eclipse JDT Language Server'
137
+        servers(i)%check_cmd = 'jdtls'
138
+        i = i + 1
139
+
140
+        ! HTML/CSS/JSON - vscode-langservers-extracted
141
+        servers(i)%name = 'vscode-langservers'
142
+        servers(i)%language = 'HTML/CSS/JSON'
143
+        servers(i)%install_cmd = 'npm i -g vscode-langservers-extracted'
144
+        servers(i)%description = 'HTML, CSS, JSON language servers'
145
+        servers(i)%check_cmd = 'vscode-html-language-server'
146
+        i = i + 1
147
+
148
+        ! Bash - bash-language-server
149
+        servers(i)%name = 'bash-language-server'
150
+        servers(i)%language = 'Bash'
151
+        servers(i)%install_cmd = 'npm i -g bash-language-server'
152
+        servers(i)%description = 'Bash/Shell language server'
153
+        servers(i)%check_cmd = 'bash-language-server'
154
+        i = i + 1
155
+
156
+        ! YAML - yaml-language-server
157
+        servers(i)%name = 'yaml-language-server'
158
+        servers(i)%language = 'YAML'
159
+        servers(i)%install_cmd = 'npm i -g yaml-language-server'
160
+        servers(i)%description = 'YAML language server'
161
+        servers(i)%check_cmd = 'yaml-language-server'
162
+        i = i + 1
163
+
164
+        ! Docker - dockerfile-language-server
165
+        servers(i)%name = 'dockerfile-langserver'
166
+        servers(i)%language = 'Docker'
167
+        servers(i)%install_cmd = 'npm i -g dockerfile-language-server-nodejs'
168
+        servers(i)%description = 'Dockerfile language server'
169
+        servers(i)%check_cmd = 'docker-langserver'
170
+        i = i + 1
171
+
172
+        ! Zig - zls
173
+        servers(i)%name = 'zls'
174
+        servers(i)%language = 'Zig'
175
+        servers(i)%install_cmd = 'brew install zls'
176
+        servers(i)%description = 'Zig language server'
177
+        servers(i)%check_cmd = 'zls'
178
+        i = i + 1
179
+
180
+        ! Swift - sourcekit-lsp
181
+        servers(i)%name = 'sourcekit-lsp'
182
+        servers(i)%language = 'Swift'
183
+        servers(i)%install_cmd = '# Included with Xcode'
184
+        servers(i)%description = 'Swift/Obj-C language server'
185
+        servers(i)%check_cmd = 'sourcekit-lsp'
186
+        i = i + 1
187
+
188
+        ! Haskell - haskell-language-server
189
+        servers(i)%name = 'haskell-language-server'
190
+        servers(i)%language = 'Haskell'
191
+        servers(i)%install_cmd = 'ghcup install hls'
192
+        servers(i)%description = 'Haskell language server'
193
+        servers(i)%check_cmd = 'haskell-language-server-wrapper'
194
+        i = i + 1
195
+
196
+        ! Terraform - terraform-ls
197
+        servers(i)%name = 'terraform-ls'
198
+        servers(i)%language = 'Terraform'
199
+        servers(i)%install_cmd = 'brew install hashicorp/tap/terraform-ls'
200
+        servers(i)%description = 'Terraform language server'
201
+        servers(i)%check_cmd = 'terraform-ls'
202
+        i = i + 1
203
+
204
+        ! Fortran - fortls
205
+        servers(i)%name = 'fortls'
206
+        servers(i)%language = 'Fortran'
207
+        servers(i)%install_cmd = 'pip install fortls'
208
+        servers(i)%description = 'Fortran language server'
209
+        servers(i)%check_cmd = 'fortls'
210
+    end subroutine init_known_servers
211
+
212
+    function check_server_installed(cmd_name) result(installed)
213
+        character(len=*), intent(in) :: cmd_name
214
+        logical :: installed
215
+        character(len=512) :: check_command
216
+        integer :: exit_status
217
+
218
+        installed = .false.
219
+        if (len_trim(cmd_name) == 0) return
220
+
221
+        ! Use 'which' on Unix to check if command exists
222
+        ! Redirect output to /dev/null to suppress it
223
+        check_command = 'which ' // trim(cmd_name) // ' > /dev/null 2>&1'
224
+
225
+        call execute_command_line(trim(check_command), wait=.true., exitstat=exit_status)
226
+
227
+        installed = (exit_status == 0)
228
+    end function check_server_installed
229
+
230
+end module server_detection_module
src/lsp/server_installer_module.f90added
@@ -0,0 +1,44 @@
1
+module server_installer_module
2
+    implicit none
3
+    private
4
+
5
+    public :: run_install_command
6
+    public :: install_result_t
7
+
8
+    type :: install_result_t
9
+        logical :: success = .false.
10
+        integer :: exit_code = -1
11
+        character(len=1024) :: message = ''
12
+    end type install_result_t
13
+
14
+contains
15
+
16
+    function run_install_command(command) result(result)
17
+        character(len=*), intent(in) :: command
18
+        type(install_result_t) :: result
19
+        integer :: exit_status
20
+
21
+        result%success = .false.
22
+        result%exit_code = -1
23
+        result%message = ''
24
+
25
+        if (len_trim(command) == 0) then
26
+            result%message = 'No command specified'
27
+            return
28
+        end if
29
+
30
+        ! Execute the command
31
+        ! Note: This runs synchronously and blocks until complete
32
+        call execute_command_line(trim(command), wait=.true., exitstat=exit_status)
33
+
34
+        result%exit_code = exit_status
35
+        result%success = (exit_status == 0)
36
+
37
+        if (result%success) then
38
+            result%message = 'Installation completed successfully'
39
+        else
40
+            write(result%message, '(A,I0)') 'Installation failed with exit code: ', exit_status
41
+        end if
42
+    end function run_install_command
43
+
44
+end module server_installer_module
src/ui/lsp_server_installer_panel_module.f90added
@@ -0,0 +1,377 @@
1
+module lsp_server_installer_panel_module
2
+    use terminal_io_module
3
+    use server_detection_module, only: detected_server_t, detect_all_servers, check_server_installed
4
+    use server_installer_module, only: run_install_command, install_result_t
5
+    implicit none
6
+    private
7
+
8
+    public :: lsp_server_installer_panel_t
9
+    public :: init_lsp_server_installer_panel, cleanup_lsp_server_installer_panel
10
+    public :: show_lsp_server_installer_panel, hide_lsp_server_installer_panel
11
+    public :: is_lsp_server_installer_panel_visible
12
+    public :: lsp_server_installer_panel_handle_key
13
+    public :: render_lsp_server_installer_panel
14
+    public :: refresh_server_status
15
+
16
+    integer, parameter :: PANEL_WIDTH = 62
17
+    integer, parameter :: MAX_VISIBLE = 8
18
+
19
+    type :: lsp_server_installer_panel_t
20
+        logical :: visible = .false.
21
+        integer :: selected_index = 1
22
+        integer :: scroll_offset = 0
23
+        type(detected_server_t), allocatable :: servers(:)
24
+        integer :: num_servers = 0
25
+        logical :: confirm_mode = .false.
26
+        integer :: confirm_server_index = 0
27
+        logical :: installing = .false.
28
+        character(len=256) :: status_message = ''
29
+    end type lsp_server_installer_panel_t
30
+
31
+contains
32
+
33
+    subroutine init_lsp_server_installer_panel(panel)
34
+        type(lsp_server_installer_panel_t), intent(out) :: panel
35
+
36
+        panel%visible = .false.
37
+        panel%selected_index = 1
38
+        panel%scroll_offset = 0
39
+        panel%num_servers = 0
40
+        panel%confirm_mode = .false.
41
+        panel%confirm_server_index = 0
42
+        panel%installing = .false.
43
+        panel%status_message = ''
44
+    end subroutine init_lsp_server_installer_panel
45
+
46
+    subroutine cleanup_lsp_server_installer_panel(panel)
47
+        type(lsp_server_installer_panel_t), intent(inout) :: panel
48
+
49
+        if (allocated(panel%servers)) deallocate(panel%servers)
50
+        panel%num_servers = 0
51
+    end subroutine cleanup_lsp_server_installer_panel
52
+
53
+    subroutine show_lsp_server_installer_panel(panel)
54
+        type(lsp_server_installer_panel_t), intent(inout) :: panel
55
+
56
+        panel%visible = .true.
57
+        panel%selected_index = 1
58
+        panel%scroll_offset = 0
59
+        panel%confirm_mode = .false.
60
+        panel%status_message = ''
61
+
62
+        ! Detect servers if not already done
63
+        if (panel%num_servers == 0) then
64
+            call refresh_server_status(panel)
65
+        end if
66
+    end subroutine show_lsp_server_installer_panel
67
+
68
+    subroutine hide_lsp_server_installer_panel(panel)
69
+        type(lsp_server_installer_panel_t), intent(inout) :: panel
70
+        panel%visible = .false.
71
+        panel%confirm_mode = .false.
72
+    end subroutine hide_lsp_server_installer_panel
73
+
74
+    function is_lsp_server_installer_panel_visible(panel) result(visible)
75
+        type(lsp_server_installer_panel_t), intent(in) :: panel
76
+        logical :: visible
77
+        visible = panel%visible
78
+    end function is_lsp_server_installer_panel_visible
79
+
80
+    subroutine refresh_server_status(panel)
81
+        type(lsp_server_installer_panel_t), intent(inout) :: panel
82
+
83
+        if (allocated(panel%servers)) deallocate(panel%servers)
84
+        call detect_all_servers(panel%servers, panel%num_servers)
85
+        panel%status_message = 'Server status refreshed'
86
+    end subroutine refresh_server_status
87
+
88
+    function lsp_server_installer_panel_handle_key(panel, key) result(handled)
89
+        type(lsp_server_installer_panel_t), intent(inout) :: panel
90
+        character(len=*), intent(in) :: key
91
+        logical :: handled
92
+        type(install_result_t) :: result
93
+
94
+        handled = .true.
95
+
96
+        ! Handle confirm mode separately
97
+        if (panel%confirm_mode) then
98
+            select case(trim(key))
99
+            case('y', 'Y')
100
+                ! Execute installation
101
+                panel%installing = .true.
102
+                panel%status_message = 'Installing ' // trim(panel%servers(panel%confirm_server_index)%name) // '...'
103
+
104
+                result = run_install_command(trim(panel%servers(panel%confirm_server_index)%install_cmd))
105
+
106
+                panel%installing = .false.
107
+                if (result%success) then
108
+                    panel%status_message = 'Successfully installed ' // trim(panel%servers(panel%confirm_server_index)%name)
109
+                    ! Refresh to update status
110
+                    call refresh_server_status(panel)
111
+                else
112
+                    panel%status_message = 'Installation failed. Try manually: ' // &
113
+                        trim(panel%servers(panel%confirm_server_index)%install_cmd)
114
+                end if
115
+                panel%confirm_mode = .false.
116
+
117
+            case('n', 'N', 'esc', 'escape')
118
+                panel%confirm_mode = .false.
119
+                panel%status_message = ''
120
+
121
+            case default
122
+                ! Ignore other keys in confirm mode
123
+            end select
124
+            return
125
+        end if
126
+
127
+        ! Normal mode key handling
128
+        select case(trim(key))
129
+        case('j', 'down')
130
+            if (panel%selected_index < panel%num_servers) then
131
+                panel%selected_index = panel%selected_index + 1
132
+                ! Scroll if needed
133
+                if (panel%selected_index > panel%scroll_offset + MAX_VISIBLE) then
134
+                    panel%scroll_offset = panel%selected_index - MAX_VISIBLE
135
+                end if
136
+            end if
137
+
138
+        case('k', 'up')
139
+            if (panel%selected_index > 1) then
140
+                panel%selected_index = panel%selected_index - 1
141
+                ! Scroll if needed
142
+                if (panel%selected_index <= panel%scroll_offset) then
143
+                    panel%scroll_offset = panel%selected_index - 1
144
+                end if
145
+            end if
146
+
147
+        case('enter')
148
+            ! Only allow install for non-installed servers
149
+            if (panel%num_servers > 0 .and. panel%selected_index <= panel%num_servers) then
150
+                if (.not. panel%servers(panel%selected_index)%is_installed) then
151
+                    panel%confirm_mode = .true.
152
+                    panel%confirm_server_index = panel%selected_index
153
+                else
154
+                    panel%status_message = trim(panel%servers(panel%selected_index)%name) // ' is already installed'
155
+                end if
156
+            end if
157
+
158
+        case('r', 'R')
159
+            ! Refresh server status
160
+            call refresh_server_status(panel)
161
+
162
+        case('esc', 'escape', 'q')
163
+            call hide_lsp_server_installer_panel(panel)
164
+
165
+        case default
166
+            handled = .false.
167
+        end select
168
+    end function lsp_server_installer_panel_handle_key
169
+
170
+    subroutine render_lsp_server_installer_panel(panel, screen_rows, screen_cols)
171
+        type(lsp_server_installer_panel_t), intent(in) :: panel
172
+        integer, intent(in) :: screen_rows, screen_cols
173
+        integer :: start_col, start_row, row, i, visible_end
174
+        integer :: content_width, visible_len, status_len, padding
175
+        character(len=:), allocatable :: border_top, border_mid, border_bottom
176
+        character(len=128) :: visible_text, status_text
177
+        character(len=*), parameter :: ESC = char(27)
178
+        character(len=*), parameter :: GREEN = ESC // '[32m'
179
+        character(len=*), parameter :: RED = ESC // '[31m'
180
+        character(len=*), parameter :: CYAN = ESC // '[36m'
181
+        character(len=*), parameter :: YELLOW = ESC // '[33m'
182
+        character(len=*), parameter :: DIM = ESC // '[90m'
183
+        character(len=*), parameter :: INVERSE = ESC // '[7m'
184
+        character(len=*), parameter :: RESET = ESC // '[0m'
185
+
186
+        if (.not. panel%visible) return
187
+
188
+        ! Calculate centering
189
+        content_width = min(PANEL_WIDTH, screen_cols - 4)
190
+        start_col = max(1, (screen_cols - content_width) / 2)
191
+        start_row = 2
192
+
193
+        ! Build borders
194
+        border_top = '┌' // repeat('─', content_width - 2) // '┐'
195
+        border_mid = '├' // repeat('─', content_width - 2) // '┤'
196
+        border_bottom = '└' // repeat('─', content_width - 2) // '┘'
197
+
198
+        ! Render confirm dialog if in confirm mode
199
+        if (panel%confirm_mode) then
200
+            call render_confirm_dialog(panel, screen_rows, screen_cols)
201
+            return
202
+        end if
203
+
204
+        ! Draw top border
205
+        call terminal_move_cursor(start_row, start_col)
206
+        call terminal_write(border_top)
207
+
208
+        ! Draw header
209
+        row = start_row + 1
210
+        call terminal_move_cursor(row, start_col)
211
+        call terminal_write('│' // CYAN // ' Language Server Manager' // RESET)
212
+        call terminal_write(repeat(' ', content_width - 31) // DIM // 'Alt+M' // RESET // ' │')
213
+
214
+        ! Draw separator
215
+        row = row + 1
216
+        call terminal_move_cursor(row, start_col)
217
+        call terminal_write(border_mid)
218
+
219
+        ! Draw server list
220
+        visible_end = min(panel%scroll_offset + MAX_VISIBLE, panel%num_servers)
221
+        do i = panel%scroll_offset + 1, visible_end
222
+            row = row + 1
223
+            call terminal_move_cursor(row, start_col)
224
+            call terminal_write('│')
225
+
226
+            ! Build line content (visible text only for width calculation)
227
+            ! Format: " ✓ servername (Language) <spaces> status"
228
+            visible_text = ' ✓ ' // trim(panel%servers(i)%name) // ' (' // &
229
+                          trim(panel%servers(i)%language) // ')'
230
+
231
+            ! Calculate visible length (icon + space + name + space + language + parens)
232
+            visible_len = len_trim(visible_text)
233
+
234
+            ! Add status text length
235
+            if (panel%servers(i)%is_installed) then
236
+                status_text = 'installed'
237
+                status_len = 9
238
+            else
239
+                status_text = 'Enter to install'
240
+                status_len = 16
241
+            end if
242
+
243
+            ! Calculate padding needed (content_width - 2 for borders, minus visible text, minus status)
244
+            padding = max(1, content_width - 2 - visible_len - status_len)
245
+
246
+            ! Highlight selected row
247
+            if (i == panel%selected_index) then
248
+                call terminal_write(INVERSE)
249
+            end if
250
+
251
+            ! Write status icon with color
252
+            if (panel%servers(i)%is_installed) then
253
+                call terminal_write(' ' // GREEN // '✓' // RESET // ' ')
254
+            else
255
+                call terminal_write(' ' // RED // '✗' // RESET // ' ')
256
+            end if
257
+
258
+            ! Write server name and language
259
+            call terminal_write(trim(panel%servers(i)%name) // ' (' // &
260
+                               trim(panel%servers(i)%language) // ')')
261
+
262
+            ! Write padding
263
+            call terminal_write(repeat(' ', padding))
264
+
265
+            ! Write status text with color
266
+            if (panel%servers(i)%is_installed) then
267
+                call terminal_write(DIM // trim(status_text) // RESET)
268
+            else
269
+                call terminal_write(YELLOW // trim(status_text) // RESET)
270
+            end if
271
+
272
+            ! Reset highlighting
273
+            if (i == panel%selected_index) then
274
+                call terminal_write(RESET)
275
+            end if
276
+
277
+            call terminal_write('│')
278
+        end do
279
+
280
+        ! Fill remaining rows if needed
281
+        do i = visible_end + 1, panel%scroll_offset + MAX_VISIBLE
282
+            row = row + 1
283
+            call terminal_move_cursor(row, start_col)
284
+            call terminal_write('│' // repeat(' ', content_width - 2) // '│')
285
+        end do
286
+
287
+        ! Draw separator before footer
288
+        row = row + 1
289
+        call terminal_move_cursor(row, start_col)
290
+        call terminal_write(border_mid)
291
+
292
+        ! Draw status message or help
293
+        row = row + 1
294
+        call terminal_move_cursor(row, start_col)
295
+        if (len_trim(panel%status_message) > 0) then
296
+            call terminal_write('│ ' // YELLOW // trim(panel%status_message) // RESET)
297
+            call terminal_write(repeat(' ', content_width - len_trim(panel%status_message) - 4) // ' │')
298
+        else
299
+            call terminal_write('│' // DIM // ' ↑↓ Navigate  Enter Install  r Refresh  Esc Close' // RESET)
300
+            call terminal_write(repeat(' ', content_width - 52) // '│')
301
+        end if
302
+
303
+        ! Draw bottom border
304
+        row = row + 1
305
+        call terminal_move_cursor(row, start_col)
306
+        call terminal_write(border_bottom)
307
+
308
+        ! Hide cursor while panel is shown
309
+        call terminal_hide_cursor()
310
+    end subroutine render_lsp_server_installer_panel
311
+
312
+    subroutine render_confirm_dialog(panel, screen_rows, screen_cols)
313
+        type(lsp_server_installer_panel_t), intent(in) :: panel
314
+        integer, intent(in) :: screen_rows, screen_cols
315
+        integer :: start_col, start_row, row, content_width
316
+        character(len=:), allocatable :: border_top, border_bottom
317
+        character(len=256) :: server_name, install_cmd
318
+        character(len=*), parameter :: ESC = char(27)
319
+        character(len=*), parameter :: CYAN = ESC // '[36m'
320
+        character(len=*), parameter :: YELLOW = ESC // '[33m'
321
+        character(len=*), parameter :: GREEN = ESC // '[32m'
322
+        character(len=*), parameter :: RED = ESC // '[31m'
323
+        character(len=*), parameter :: RESET = ESC // '[0m'
324
+
325
+        content_width = min(PANEL_WIDTH, screen_cols - 4)
326
+        start_col = max(1, (screen_cols - content_width) / 2)
327
+        start_row = 5
328
+
329
+        border_top = '┌' // repeat('─', content_width - 2) // '┐'
330
+        border_bottom = '└' // repeat('─', content_width - 2) // '┘'
331
+
332
+        server_name = panel%servers(panel%confirm_server_index)%name
333
+        install_cmd = panel%servers(panel%confirm_server_index)%install_cmd
334
+
335
+        ! Top border
336
+        call terminal_move_cursor(start_row, start_col)
337
+        call terminal_write(border_top)
338
+
339
+        ! Title
340
+        row = start_row + 1
341
+        call terminal_move_cursor(row, start_col)
342
+        call terminal_write('│' // CYAN // ' Install ' // trim(server_name) // '?' // RESET)
343
+        call terminal_write(repeat(' ', content_width - 12 - len_trim(server_name)) // '│')
344
+
345
+        ! Blank line
346
+        row = row + 1
347
+        call terminal_move_cursor(row, start_col)
348
+        call terminal_write('│' // repeat(' ', content_width - 2) // '│')
349
+
350
+        ! Command
351
+        row = row + 1
352
+        call terminal_move_cursor(row, start_col)
353
+        call terminal_write('│ Command: ' // YELLOW // trim(install_cmd) // RESET)
354
+        call terminal_write(repeat(' ', content_width - 12 - len_trim(install_cmd)) // '│')
355
+
356
+        ! Blank line
357
+        row = row + 1
358
+        call terminal_move_cursor(row, start_col)
359
+        call terminal_write('│' // repeat(' ', content_width - 2) // '│')
360
+
361
+        ! Yes/No buttons
362
+        row = row + 1
363
+        call terminal_move_cursor(row, start_col)
364
+        call terminal_write('│' // repeat(' ', (content_width - 20) / 2))
365
+        call terminal_write('[' // GREEN // 'Y' // RESET // ']es    ')
366
+        call terminal_write('[' // RED // 'N' // RESET // ']o')
367
+        call terminal_write(repeat(' ', (content_width - 20) / 2) // '│')
368
+
369
+        ! Bottom border
370
+        row = row + 1
371
+        call terminal_move_cursor(row, start_col)
372
+        call terminal_write(border_bottom)
373
+
374
+        call terminal_hide_cursor()
375
+    end subroutine render_confirm_dialog
376
+
377
+end module lsp_server_installer_panel_module
src/workspace/app_state_module.f90added
@@ -0,0 +1,150 @@
1
+module app_state_module
2
+    use config_module, only: get_config_dir, ensure_config_dir
3
+    implicit none
4
+    private
5
+
6
+    public :: app_state_t
7
+    public :: app_state_load, app_state_save
8
+    public :: is_first_run, mark_first_run_complete
9
+
10
+    type :: app_state_t
11
+        logical :: first_run_completed = .false.
12
+        logical :: lsp_installer_seen = .false.
13
+        character(len=16) :: version = '1.0'
14
+    end type app_state_t
15
+
16
+contains
17
+
18
+    subroutine app_state_load(state)
19
+        type(app_state_t), intent(out) :: state
20
+        character(len=:), allocatable :: config_dir
21
+        character(len=512) :: state_file
22
+        character(len=1024) :: line
23
+        integer :: unit_num, ios
24
+        logical :: file_exists
25
+
26
+        ! Initialize defaults
27
+        state%first_run_completed = .false.
28
+        state%lsp_installer_seen = .false.
29
+        state%version = '1.0'
30
+
31
+        ! Get config directory
32
+        call get_config_dir(config_dir)
33
+        if (.not. allocated(config_dir)) return
34
+        state_file = trim(config_dir) // '/state.json'
35
+
36
+        ! Check if file exists
37
+        inquire(file=trim(state_file), exist=file_exists)
38
+        if (.not. file_exists) return
39
+
40
+        ! Read and parse JSON
41
+        open(newunit=unit_num, file=trim(state_file), status='old', &
42
+             action='read', iostat=ios)
43
+        if (ios /= 0) return
44
+
45
+        do
46
+            read(unit_num, '(A)', iostat=ios) line
47
+            if (ios /= 0) exit
48
+
49
+            ! Parse simple JSON fields
50
+            if (index(line, '"first_run_completed"') > 0) then
51
+                if (index(line, 'true') > 0) then
52
+                    state%first_run_completed = .true.
53
+                else
54
+                    state%first_run_completed = .false.
55
+                end if
56
+            else if (index(line, '"lsp_installer_seen"') > 0) then
57
+                if (index(line, 'true') > 0) then
58
+                    state%lsp_installer_seen = .true.
59
+                else
60
+                    state%lsp_installer_seen = .false.
61
+                end if
62
+            else if (index(line, '"version"') > 0) then
63
+                call extract_json_string(line, 'version', state%version)
64
+            end if
65
+        end do
66
+
67
+        close(unit_num)
68
+    end subroutine app_state_load
69
+
70
+    subroutine app_state_save(state)
71
+        type(app_state_t), intent(in) :: state
72
+        character(len=:), allocatable :: config_dir
73
+        character(len=512) :: state_file
74
+        integer :: unit_num, ios
75
+        logical :: dir_success
76
+
77
+        ! Ensure config directory exists
78
+        call ensure_config_dir(dir_success)
79
+        if (.not. dir_success) return
80
+
81
+        ! Get config directory
82
+        call get_config_dir(config_dir)
83
+        if (.not. allocated(config_dir)) return
84
+        state_file = trim(config_dir) // '/state.json'
85
+
86
+        ! Write JSON
87
+        open(newunit=unit_num, file=trim(state_file), status='replace', &
88
+             action='write', iostat=ios)
89
+        if (ios /= 0) return
90
+
91
+        write(unit_num, '(A)') '{'
92
+        if (state%first_run_completed) then
93
+            write(unit_num, '(A)') '  "first_run_completed": true,'
94
+        else
95
+            write(unit_num, '(A)') '  "first_run_completed": false,'
96
+        end if
97
+        if (state%lsp_installer_seen) then
98
+            write(unit_num, '(A)') '  "lsp_installer_seen": true,'
99
+        else
100
+            write(unit_num, '(A)') '  "lsp_installer_seen": false,'
101
+        end if
102
+        write(unit_num, '(A)') '  "version": "' // trim(state%version) // '"'
103
+        write(unit_num, '(A)') '}'
104
+
105
+        close(unit_num)
106
+    end subroutine app_state_save
107
+
108
+    function is_first_run() result(first_run)
109
+        logical :: first_run
110
+        type(app_state_t) :: state
111
+
112
+        call app_state_load(state)
113
+        first_run = .not. state%first_run_completed
114
+    end function is_first_run
115
+
116
+    subroutine mark_first_run_complete()
117
+        type(app_state_t) :: state
118
+
119
+        call app_state_load(state)
120
+        state%first_run_completed = .true.
121
+        call app_state_save(state)
122
+    end subroutine mark_first_run_complete
123
+
124
+    subroutine extract_json_string(line, key, value)
125
+        character(len=*), intent(in) :: line, key
126
+        character(len=*), intent(out) :: value
127
+        integer :: key_pos, colon_pos, quote1, quote2
128
+
129
+        value = ''
130
+        key_pos = index(line, '"' // trim(key) // '"')
131
+        if (key_pos == 0) return
132
+
133
+        colon_pos = index(line(key_pos:), ':')
134
+        if (colon_pos == 0) return
135
+        colon_pos = key_pos + colon_pos
136
+
137
+        quote1 = index(line(colon_pos:), '"')
138
+        if (quote1 == 0) return
139
+        quote1 = colon_pos + quote1
140
+
141
+        quote2 = index(line(quote1:), '"')
142
+        if (quote2 == 0) return
143
+        quote2 = quote1 + quote2 - 2
144
+
145
+        if (quote2 >= quote1) then
146
+            value = line(quote1:quote2)
147
+        end if
148
+    end subroutine extract_json_string
149
+
150
+end module app_state_module